diff --git a/lib/features/capture/capture_screen.dart b/lib/features/capture/capture_screen.dart index 5385d0f3..61713274 100644 --- a/lib/features/capture/capture_screen.dart +++ b/lib/features/capture/capture_screen.dart @@ -19,6 +19,7 @@ import '../../services/image_crop_service.dart'; import '../../services/opencv_target_service.dart'; import '../../services/parallelism_service.dart'; // NOUVEAU import '../../services/object_detection_service.dart'; // NOUVEAU : ML Kit +import '../../services/target_rectify_service.dart'; // NOUVEAU : redressement import 'package:image/image.dart' as img; import 'package:path_provider/path_provider.dart'; @@ -69,6 +70,9 @@ class _CaptureScreenState extends State StreamSubscription>? _objectSubscription; List _detectedObjects = []; + // NOUVEAU : Service de redressement de cible (warp perspective) + final TargetRectifyService _rectifyService = TargetRectifyService(); + @override void initState() { super.initState(); @@ -506,14 +510,15 @@ class _CaptureScreenState extends State ), ), - // NOUVEAU : 1.bis Cadre dessiné autour de l'objet détecté - if (_primaryObject != null) + // NOUVEAU : 1.bis Cadre dessiné autour de CHAQUE objet détecté + if (_detectedObjects.isNotEmpty) Center( child: AspectRatio( aspectRatio: 3 / 4, child: CustomPaint( painter: _ObjectBoxPainter( - object: _primaryObject!, + objects: _detectedObjects, + primary: _primaryObject, color: frameColor, ), ), @@ -792,8 +797,39 @@ class _CaptureScreenState extends State final XFile photo = await _cameraController!.takePicture(); + // NOUVEAU : redressement automatique de la cible (warp perspective). + // On écrit le résultat dans un fichier dédié, à côté de l'original. + String finalPath = photo.path; + try { + final tempDir = await getTemporaryDirectory(); + final rectifiedPath = + '${tempDir.path}/rectified_${DateTime.now().millisecondsSinceEpoch}.jpg'; + + final result = await _rectifyService.rectify( + inputPath: photo.path, + outputPath: rectifiedPath, + ); + + finalPath = result.outputPath; + + if (mounted && result.rectified) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + 'Cible redressée automatiquement ' + '(inclinaison ${result.estimatedTiltDegrees.toStringAsFixed(1)}° corrigée)', + ), + ), + ); + } + } catch (e) { + debugPrint('Redressement ignoré: $e'); + finalPath = photo.path; // on garde la photo originale en secours + } + setState(() { - _selectedImagePath = photo.path; + _selectedImagePath = finalPath; _showLiveCamera = false; }); @@ -902,58 +938,66 @@ class _CaptureScreenState extends State } // ───────────────────────────────────────────────────────────────────────── -// NOUVEAU : Painter qui dessine la boîte englobante de l'objet détecté. -// Les coordonnées de l'objet sont normalisées (0..1) → on les multiplie -// par la taille réelle de la zone de dessin (qui suit l'AspectRatio 3/4). +// Painter qui dessine la boîte englobante de CHAQUE objet détecté. +// L'objet principal (le plus grand et centré) est mis en évidence avec +// la couleur d'état (vert/orange) ; les autres en blanc translucide. // ───────────────────────────────────────────────────────────────────────── class _ObjectBoxPainter extends CustomPainter { - final DetectedObject2D object; + final List objects; + final DetectedObject2D? primary; final Color color; - _ObjectBoxPainter({required this.object, required this.color}); + _ObjectBoxPainter({ + required this.objects, + required this.primary, + required this.color, + }); @override void paint(Canvas canvas, Size size) { - final Paint stroke = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 3.0 - ..color = color; + for (final obj in objects) { + final bool isPrimary = identical(obj, primary); + final Color boxColor = + isPrimary ? color : Colors.white.withValues(alpha: 0.6); - final Rect rect = Rect.fromLTRB( - object.left * size.width, - object.top * size.height, - object.right * size.width, - object.bottom * size.height, - ); + final Paint stroke = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = isPrimary ? 3.0 : 1.5 + ..color = boxColor; - // Boîte à coins arrondis - final RRect rrect = - RRect.fromRectAndRadius(rect, const Radius.circular(8)); - canvas.drawRRect(rrect, stroke); - - // Petit label si disponible - if (object.label.isNotEmpty) { - final textPainter = TextPainter( - text: TextSpan( - text: ' ${object.label} ' - '${(object.confidence * 100).toStringAsFixed(0)}% ', - style: TextStyle( - color: Colors.black, - fontSize: 12, - fontWeight: FontWeight.bold, - backgroundColor: color, - ), - ), - textDirection: TextDirection.ltr, - )..layout(); - textPainter.paint( - canvas, - Offset(rect.left, (rect.top - 18).clamp(0.0, size.height)), + final Rect rect = Rect.fromLTRB( + obj.left * size.width, + obj.top * size.height, + obj.right * size.width, + obj.bottom * size.height, ); + final RRect rrect = + RRect.fromRectAndRadius(rect, const Radius.circular(8)); + canvas.drawRRect(rrect, stroke); + + if (obj.label.isNotEmpty) { + final textPainter = TextPainter( + text: TextSpan( + text: ' ${obj.label} ' + '${(obj.confidence * 100).toStringAsFixed(0)}% ', + style: TextStyle( + color: Colors.black, + fontSize: 12, + fontWeight: FontWeight.bold, + backgroundColor: boxColor, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + textPainter.paint( + canvas, + Offset(rect.left, (rect.top - 18).clamp(0.0, size.height)), + ); + } } } @override bool shouldRepaint(_ObjectBoxPainter old) => - old.object != object || old.color != color; -} \ No newline at end of file + old.objects != objects || old.color != color || old.primary != primary; +} diff --git a/lib/services/object_detection_service.dart b/lib/services/object_detection_service.dart index d21a5962..3fd13554 100644 --- a/lib/services/object_detection_service.dart +++ b/lib/services/object_detection_service.dart @@ -49,7 +49,7 @@ class DetectedObject2D { class ObjectDetectionService { ObjectDetector? _detector; final StreamController> _controller = - StreamController>.broadcast(); + StreamController>.broadcast(); bool _isBusy = false; bool _started = false; @@ -61,22 +61,22 @@ class ObjectDetectionService { _started = true; // Mode STREAM : optimisé pour le flux vidéo temps réel. - // classifyObjects: true => on récupère un label grossier + confiance. - // multipleObjects: false => on suit l'objet principal (plus stable). + // classifyObjects: true => label grossier + confiance par objet. + // multipleObjects: true => on détecte et encadre TOUS les objets visibles. final options = ObjectDetectorOptions( mode: DetectionMode.stream, classifyObjects: true, - multipleObjects: false, + multipleObjects: true, ); _detector = ObjectDetector(options: options); } /// À appeler depuis `startImageStream`. Future processCameraImage( - CameraImage image, - CameraDescription camera, - DeviceOrientation deviceOrientation, - ) async { + CameraImage image, + CameraDescription camera, + DeviceOrientation deviceOrientation, + ) async { if (!_started || _detector == null) return; if (_isBusy) return; // On saute la frame si la précédente n'est pas finie _isBusy = true; @@ -99,7 +99,7 @@ class ObjectDetectionService { double conf = 0; if (o.labels.isNotEmpty) { final best = o.labels.reduce( - (a, b) => a.confidence >= b.confidence ? a : b, + (a, b) => a.confidence >= b.confidence ? a : b, ); label = best.text; conf = best.confidence; @@ -124,10 +124,10 @@ class ObjectDetectionService { /// Convertit une CameraImage en InputImage ML Kit. InputImage? _toInputImage( - CameraImage image, - CameraDescription camera, - DeviceOrientation deviceOrientation, - ) { + CameraImage image, + CameraDescription camera, + DeviceOrientation deviceOrientation, + ) { // Rotation : combine l'orientation du capteur et celle de l'appareil. final sensorOrientation = camera.sensorOrientation; InputImageRotation? rotation; @@ -183,4 +183,4 @@ class ObjectDetectionService { _detector = null; _controller.close(); } -} \ No newline at end of file +} diff --git a/lib/services/target_rectify_service.dart b/lib/services/target_rectify_service.dart new file mode 100644 index 00000000..b7660e7d --- /dev/null +++ b/lib/services/target_rectify_service.dart @@ -0,0 +1,259 @@ +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', + ); + } + } +}