import 'dart:async'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:opencv_dart/opencv_dart.dart' as cv; /// Résultat d'une tentative de redressement de cible. class RectifyResult { /// Chemin du fichier image redressé (ou original si échec). final String outputPath; /// true si une cible a été détectée et redressée, false si on a renvoyé /// l'image d'origine sans transformation. final bool rectified; /// Angle d'inclinaison estimé de la cible AVANT redressement, en degrés. /// (0 = déjà de face). Utile pour informer l'utilisateur. final double estimatedTiltDegrees; /// Message de diagnostic (utile pour debug / affichage). final String message; const RectifyResult({ required this.outputPath, required this.rectified, required this.estimatedTiltDegrees, required this.message, }); } /// Service qui redresse une cible RONDE (cercles concentriques) photographiée /// de biais, en la ramenant parfaitement de face. /// /// Principe : /// 1. Détecter le plus grand contour ~circulaire de l'image. /// 2. Ajuster une ELLIPSE sur ce contour (fitEllipse). /// 3. Un cercle vu en perspective devient une ellipse : on calcule la /// transformation de perspective qui remappe cette ellipse vers un /// CERCLE parfait, et on l'applique à toute l'image. /// 4. La cible apparaît alors de face. /// /// L'image résultat est carrée et centrée sur la cible. class TargetRectifyService { /// Taille (en pixels) du côté de l'image carrée de sortie. final int outputSize; /// Marge autour de la cible dans l'image de sortie (1.0 = cible pile au bord, /// 1.3 = 30 % de marge autour). Garde un peu de contexte. final double marginFactor; /// En dessous de cet écart d'axes (ratio petit/grand axe proche de 1), /// la cible est considérée déjà de face → pas de warp inutile. final double minTiltRatioToRectify; TargetRectifyService({ this.outputSize = 1024, this.marginFactor = 1.25, this.minTiltRatioToRectify = 0.985, }); /// Redresse l'image située à [inputPath]. Écrit le résultat dans /// [outputPath] et renvoie un [RectifyResult]. /// /// Ne bloque jamais : en cas d'échec de détection, renvoie l'image /// d'origine (rectified = false) pour ne pas perdre la photo du tireur. Future rectify({ required String inputPath, required String outputPath, }) async { cv.Mat? src; cv.Mat? gray; cv.Mat? blurred; cv.Mat? edges; try { src = cv.imread(inputPath, flags: cv.IMREAD_COLOR); if (src.isEmpty) { return RectifyResult( outputPath: inputPath, rectified: false, estimatedTiltDegrees: 0, message: 'Image illisible', ); } // ── 1. Prétraitement ──────────────────────────────────────────────── gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY); blurred = cv.gaussianBlur(gray, (5, 5), 2, sigmaY: 2); edges = cv.canny(blurred, 60, 160); // Dilatation légère pour fermer les contours brisés final cv.Mat kernel = cv.getStructuringElement( cv.MORPH_ELLIPSE, (3, 3), ); final cv.Mat dilated = cv.dilate(edges, kernel); // ── 2. Recherche du meilleur contour elliptique ────────────────────── final (contours, _) = cv.findContours( dilated, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE, ); final double imgArea = (src.width * src.height).toDouble(); cv.RotatedRect? bestEllipse; double bestScore = 0; for (int i = 0; i < contours.length; i++) { final c = contours[i]; if (c.length < 5) continue; // fitEllipse exige >= 5 points final double area = cv.contourArea(c); // On ignore les contours minuscules et ceux qui couvrent presque tout if (area < imgArea * 0.03 || area > imgArea * 0.97) continue; final cv.RotatedRect e = cv.fitEllipse(c); final ep = e.points; // 4 sommets if (ep.length < 4) continue; final double ecx = (ep[0].x + ep[1].x + ep[2].x + ep[3].x) / 4.0; final double ecy = (ep[0].y + ep[1].y + ep[2].y + ep[3].y) / 4.0; final double mAx = (ep[0].x + ep[1].x) / 2.0; final double mAy = (ep[0].y + ep[1].y) / 2.0; final double mBx = (ep[1].x + ep[2].x) / 2.0; final double mBy = (ep[1].y + ep[2].y) / 2.0; final double w = 2 * math.sqrt(math.pow(mAx - ecx, 2) + math.pow(mAy - ecy, 2)); final double h = 2 * math.sqrt(math.pow(mBx - ecx, 2) + math.pow(mBy - ecy, 2)); if (w <= 1 || h <= 1) continue; // À quel point le contour ressemble-t-il vraiment à son ellipse ? // On compare l'aire du contour à l'aire de l'ellipse ajustée. final double ellipseArea = math.pi * (w / 2) * (h / 2); if (ellipseArea <= 0) continue; final double fitRatio = area / ellipseArea; // ~1 si bon ajustement if (fitRatio < 0.7 || fitRatio > 1.3) continue; // Score = taille de l'ellipse (on veut la cible la plus grande) final double score = ellipseArea; if (score > bestScore) { bestScore = score; bestEllipse = e; } } if (bestEllipse == null) { return RectifyResult( outputPath: inputPath, rectified: false, estimatedTiltDegrees: 0, message: 'Aucune cible circulaire détectée', ); } // ── 3. Extraire les 4 sommets de l'ellipse (robuste à la version d'API) ─ // RotatedRect.points renvoie les 4 coins de la boîte englobant l'ellipse. // On en dérive nous-mêmes le centre, les demi-axes et l'orientation, ce // qui évite de dépendre de la forme exacte de `.size` / `.center` // (record vs objet) qui varie selon les versions d'opencv_dart. final pts = bestEllipse.points; // List de 4 sommets double px(int i) => pts[i].x; double py(int i) => pts[i].y; // Centre = moyenne des 4 sommets final double cx = (px(0) + px(1) + px(2) + px(3)) / 4.0; final double cy = (py(0) + py(1) + py(2) + py(3)) / 4.0; // Milieux de deux côtés adjacents → extrémités des deux demi-axes // Côté 0-1 et côté 1-2 (ordre des sommets d'un RotatedRect) final double m01x = (px(0) + px(1)) / 2.0; final double m01y = (py(0) + py(1)) / 2.0; final double m12x = (px(1) + px(2)) / 2.0; final double m12y = (py(1) + py(2)) / 2.0; // Demi-axes = distance centre → milieu de chaque côté final double axisA = math.sqrt(math.pow(m01x - cx, 2) + math.pow(m01y - cy, 2)); final double axisB = math.sqrt(math.pow(m12x - cx, 2) + math.pow(m12y - cy, 2)); final double majorAxis = math.max(axisA, axisB); final double minorAxis = math.min(axisA, axisB); if (majorAxis <= 1) { return RectifyResult( outputPath: inputPath, rectified: false, estimatedTiltDegrees: 0, message: 'Ellipse dégénérée', ); } final double axisRatio = minorAxis / majorAxis; // 1 = cercle parfait final double tiltDeg = math.acos(axisRatio.clamp(0.0, 1.0)) * (180.0 / math.pi); // Déjà quasiment de face → on ne touche pas (évite le flou inutile) if (axisRatio >= minTiltRatioToRectify) { cv.imwrite(outputPath, src); return RectifyResult( outputPath: outputPath, rectified: false, estimatedTiltDegrees: tiltDeg, message: 'Cible déjà de face', ); } // ── 4. Construire la transformation de perspective ──────────────────── // Source : extrémités des deux axes de l'ellipse (4 points). // Destination : extrémités des axes d'un cercle parfait centré. // On obtient les extrémités en prolongeant centre→milieu-de-côté. final srcPts = cv.VecPoint.fromList([ cv.Point((cx + (m01x - cx)).round(), (cy + (m01y - cy)).round()), cv.Point((cx - (m01x - cx)).round(), (cy - (m01y - cy)).round()), cv.Point((cx + (m12x - cx)).round(), (cy + (m12y - cy)).round()), cv.Point((cx - (m12x - cx)).round(), (cy - (m12y - cy)).round()), ]); // Cible : cercle parfait centré, rayon R, dans une image carrée. // L'axe "A" (m01) devient l'axe horizontal, l'axe "B" (m12) le vertical. final double out = outputSize.toDouble(); final double center = out / 2; final double radius = (out / 2) / marginFactor; final dstPts = cv.VecPoint.fromList([ cv.Point((center + radius).round(), center.round()), cv.Point((center - radius).round(), center.round()), cv.Point(center.round(), (center + radius).round()), cv.Point(center.round(), (center - radius).round()), ]); final cv.Mat transform = cv.getPerspectiveTransform(srcPts, dstPts); final cv.Mat warped = cv.warpPerspective( src, transform, (outputSize, outputSize), flags: cv.INTER_LINEAR, borderMode: cv.BORDER_CONSTANT, ); cv.imwrite(outputPath, warped); return RectifyResult( outputPath: outputPath, rectified: true, estimatedTiltDegrees: tiltDeg, message: 'Cible redressée (inclinaison ${tiltDeg.toStringAsFixed(1)}°)', ); } catch (e) { debugPrint('TargetRectify erreur: $e'); // En cas de pépin, on renvoie l'original pour ne jamais perdre la photo return RectifyResult( outputPath: inputPath, rectified: false, estimatedTiltDegrees: 0, message: 'Erreur de traitement: $e', ); } } }