diff --git a/lib/features/capture/capture_screen.dart b/lib/features/capture/capture_screen.dart index 61713274..74a5b4b4 100644 --- a/lib/features/capture/capture_screen.dart +++ b/lib/features/capture/capture_screen.dart @@ -2,7 +2,6 @@ import 'dart:io'; import 'dart:math' as math; import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:device_info_plus/device_info_plus.dart'; @@ -18,7 +17,6 @@ import 'widgets/image_source_button.dart'; 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'; @@ -56,8 +54,9 @@ class _CaptureScreenState extends State late AnimationController _scanAnimationController; late Animation _scanAnimation; - // Détection OpenCV (inchangée) + // Détection OpenCV (cible circulaire) — on garde le résultat COMPLET bool? _alignmentStatus; + TargetDetectionResult? _targetResult; // NOUVEAU : centre + rayon de la cible Timer? _detectionTimer; bool _isAnalyzingFrame = false; @@ -65,12 +64,7 @@ class _CaptureScreenState extends State ParallelismData? _parallelismData; StreamSubscription? _parallelismSubscription; - // NOUVEAU : Détection d'objet ML Kit - final ObjectDetectionService _objectService = ObjectDetectionService(); - StreamSubscription>? _objectSubscription; - List _detectedObjects = []; - - // NOUVEAU : Service de redressement de cible (warp perspective) + // Service de redressement de cible (warp perspective) final TargetRectifyService _rectifyService = TargetRectifyService(); @override @@ -93,8 +87,6 @@ class _CaptureScreenState extends State _detectionTimer?.cancel(); _parallelismSubscription?.cancel(); // NOUVEAU _parallelismService.dispose(); // NOUVEAU - _objectSubscription?.cancel(); // NOUVEAU - _objectService.dispose(); // NOUVEAU super.dispose(); } @@ -122,52 +114,21 @@ class _CaptureScreenState extends State } // ───────────────────────────────────────────────────────────────────────── - // NOUVEAU : Démarre l'écoute de la détection d'objet ML Kit - // ───────────────────────────────────────────────────────────────────────── - void _startObjectDetection() { - _objectSubscription?.cancel(); - _objectService.start(); - - _objectSubscription = _objectService.stream.listen((objects) { - if (mounted && _showLiveCamera) { - setState(() { - _detectedObjects = objects; - }); - } - }); - } - - void _stopObjectDetection() { - _objectSubscription?.cancel(); - _objectSubscription = null; - _objectService.stop(); - _detectedObjects = []; - } - - // ───────────────────────────────────────────────────────────────────────── - // NOUVEAU : Calcule la couleur et le message depuis les données IMU + // Calcule la couleur et le message depuis l'IMU + la détection de cible // ───────────────────────────────────────────────────────────────────────── - /// Y a-t-il un objet détecté suffisamment grand et globalement centré ? - DetectedObject2D? get _primaryObject { - if (_detectedObjects.isEmpty) return null; - // On prend l'objet le plus grand (le sujet principal le plus probable). - final sorted = [..._detectedObjects] - ..sort((a, b) => b.area.compareTo(a.area)); - final obj = sorted.first; - // Filtre : doit occuper une part raisonnable de l'image et être centré. - final bool bigEnough = obj.area > 0.04; // ~20% × 20% - final bool centered = obj.centerX > 0.2 && - obj.centerX < 0.8 && - obj.centerY > 0.2 && - obj.centerY < 0.8; - return (bigEnough && centered) ? obj : null; + /// La cible circulaire est-elle détectée et raisonnablement cadrée ? + bool get _targetReady { + final t = _targetResult; + if (t == null || !t.success) return false; + final bool centered = + t.centerX > 0.15 && t.centerX < 0.85 && t.centerY > 0.15 && t.centerY < 0.85; + final bool bigEnough = t.radius > 0.12; + return centered && bigEnough; } - bool get _objectReady => _primaryObject != null; - /// Couleur du cadre : dépend UNIQUEMENT du parallélisme IMU. - /// (La détection d'objet sert seulement à dessiner une boîte, elle ne + /// (La détection de cible sert seulement à dessiner le cercle, elle ne /// bloque jamais le passage au vert.) Color get _frameColor { final bool imuAligned = @@ -182,10 +143,10 @@ class _CaptureScreenState extends State return 'ALIGNEZ LA CIBLE DANS LE CADRE'; } - // Aligné → message de validation (avec bonus si un objet est cadré) + // Aligné → message de validation (avec bonus si la cible est détectée) if (_parallelismData!.isAligned) { - return _objectReady - ? 'PARFAIT — OBJET CADRÉ, PRÊT' + return _targetReady + ? 'PARFAIT — CIBLE DÉTECTÉE, PRÊT' : 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER'; } @@ -241,12 +202,12 @@ class _CaptureScreenState extends State _isCameraInitialized = true; _showLiveCamera = true; _alignmentStatus = null; + _targetResult = null; // reset détection cible _parallelismData = null; // NOUVEAU : reset IMU }); _startAlignmentDetection(); _startParallelismDetection(); // NOUVEAU - _startObjectDetection(); // NOUVEAU } } catch (e) { debugPrint('Erreur caméra: $e'); @@ -265,18 +226,11 @@ class _CaptureScreenState extends State DateTime? _lastAnalysis; _cameraController!.startImageStream((CameraImage cameraImage) async { - // NOUVEAU : on alimente ML Kit à chaque frame (le service gère son propre - // throttle interne via _isBusy, donc pas de saturation). - _objectService.processCameraImage( - cameraImage, - _cameras![0], - DeviceOrientation.portraitUp, - ); - if (_isAnalyzingFrame) return; final now = DateTime.now(); + // Cadence ~1 s : assez réactif pour suivre la cible sans saturer le CPU. if (_lastAnalysis != null && - now.difference(_lastAnalysis!).inSeconds < 3) return; + now.difference(_lastAnalysis!).inMilliseconds < 1000) return; _lastAnalysis = now; _isAnalyzingFrame = true; @@ -300,6 +254,9 @@ class _CaptureScreenState extends State if (mounted && _showLiveCamera) { setState(() { + // On garde le résultat complet pour dessiner le cercle. + _targetResult = result.success ? result : null; + if (!result.success) { _alignmentStatus = null; } else { @@ -329,6 +286,7 @@ class _CaptureScreenState extends State } } catch (_) {} _alignmentStatus = null; + _targetResult = null; } img.Image? _convertCameraImage(CameraImage cameraImage) { @@ -510,16 +468,16 @@ class _CaptureScreenState extends State ), ), - // NOUVEAU : 1.bis Cadre dessiné autour de CHAQUE objet détecté - if (_detectedObjects.isNotEmpty) + // 1.bis Cercle dessiné autour de la cible circulaire détectée + if (_targetResult != null && _targetResult!.success) Center( child: AspectRatio( aspectRatio: 3 / 4, child: CustomPaint( - painter: _ObjectBoxPainter( - objects: _detectedObjects, - primary: _primaryObject, - color: frameColor, + painter: _TargetCirclePainter( + target: _targetResult!, + color: _targetReady ? frameColor : Colors.white, + highlighted: _targetReady, ), ), ), @@ -584,7 +542,6 @@ class _CaptureScreenState extends State onPressed: () { _stopAlignmentDetection(); _stopParallelismDetection(); // NOUVEAU - _stopObjectDetection(); // NOUVEAU setState(() { _showLiveCamera = false; }); @@ -793,7 +750,6 @@ class _CaptureScreenState extends State _stopAlignmentDetection(); _stopParallelismDetection(); // NOUVEAU - _stopObjectDetection(); // NOUVEAU final XFile photo = await _cameraController!.takePicture(); @@ -818,7 +774,7 @@ class _CaptureScreenState extends State duration: const Duration(seconds: 2), content: Text( 'Cible redressée automatiquement ' - '(inclinaison ${result.estimatedTiltDegrees.toStringAsFixed(1)}° corrigée)', + '(inclinaison ${result.estimatedTiltDegrees.toStringAsFixed(1)}° corrigée)', ), ), ); @@ -938,66 +894,66 @@ class _CaptureScreenState extends State } // ───────────────────────────────────────────────────────────────────────── -// 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. +// Painter qui dessine un cercle autour de la cible circulaire détectée. +// Le centre et le rayon viennent d'OpenCV (coordonnées normalisées 0..1). +// Blanc tant que la cible n'est pas bien cadrée, couleur d'état sinon. // ───────────────────────────────────────────────────────────────────────── -class _ObjectBoxPainter extends CustomPainter { - final List objects; - final DetectedObject2D? primary; +class _TargetCirclePainter extends CustomPainter { + final TargetDetectionResult target; final Color color; + final bool highlighted; - _ObjectBoxPainter({ - required this.objects, - required this.primary, + _TargetCirclePainter({ + required this.target, required this.color, + required this.highlighted, }); @override void paint(Canvas canvas, Size size) { - for (final obj in objects) { - final bool isPrimary = identical(obj, primary); - final Color boxColor = - isPrimary ? color : Colors.white.withValues(alpha: 0.6); + // Centre en pixels dans la zone de dessin + final double cx = target.centerX * size.width; + final double cy = target.centerY * size.height; + // radius est normalisé par min(largeur, hauteur) côté OpenCV + final double r = target.radius * math.min(size.width, size.height); - final Paint stroke = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = isPrimary ? 3.0 : 1.5 - ..color = boxColor; + final Paint stroke = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = highlighted ? 3.5 : 2.0 + ..color = color; - 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); + // Cercle principal autour de la cible + canvas.drawCircle(Offset(cx, cy), r, 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, - ), + // Petite croix au centre détecté + final Paint cross = Paint() + ..strokeWidth = 2.0 + ..color = color; + const double k = 10; + canvas.drawLine(Offset(cx - k, cy), Offset(cx + k, cy), cross); + canvas.drawLine(Offset(cx, cy - k), Offset(cx, cy + k), cross); + + // Étiquette "CIBLE" quand elle est bien cadrée + if (highlighted) { + final tp = TextPainter( + text: TextSpan( + text: ' CIBLE ', + 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)), - ); - } + ), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset(cx - tp.width / 2, (cy - r - 20).clamp(0.0, size.height))); } } @override - bool shouldRepaint(_ObjectBoxPainter old) => - old.objects != objects || old.color != color || old.primary != primary; -} + bool shouldRepaint(_TargetCirclePainter old) => + old.target != target || + old.color != color || + old.highlighted != highlighted; +} \ No newline at end of file diff --git a/lib/services/object_detection_service.dart b/lib/services/object_detection_service.dart deleted file mode 100644 index 3fd13554..00000000 --- a/lib/services/object_detection_service.dart +++ /dev/null @@ -1,186 +0,0 @@ -import 'dart:async'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:camera/camera.dart'; -import 'package:google_mlkit_object_detection/google_mlkit_object_detection.dart'; - -/// Un objet détecté, exprimé en coordonnées NORMALISÉES (0..1) par rapport -/// à l'image source. Indépendant de la résolution réelle de la caméra. -class DetectedObject2D { - /// Boîte englobante normalisée (left, top, right, bottom dans [0..1]). - final double left; - final double top; - final double right; - final double bottom; - - /// Label le plus probable (ex: "Fruit", "Plant"...) — peut être vide. - final String label; - - /// Confiance du label [0..1]. - final double confidence; - - const DetectedObject2D({ - required this.left, - required this.top, - required this.right, - required this.bottom, - required this.label, - required this.confidence, - }); - - double get centerX => (left + right) / 2; - double get centerY => (top + bottom) / 2; - double get width => right - left; - double get height => bottom - top; - double get area => width * height; -} - -/// Service de détection d'objets en temps réel basé sur Google ML Kit. -/// -/// Traite directement les frames `CameraImage` du flux caméra (pas de -/// passage par un fichier JPEG, donc beaucoup plus rapide qu'OpenCV ici). -/// -/// Usage : -/// final svc = ObjectDetectionService(); -/// svc.start(); -/// cameraController.startImageStream((frame) => -/// svc.processCameraImage(frame, cameraDescription, rotation)); -/// svc.stream.listen((objects) { ... }); -class ObjectDetectionService { - ObjectDetector? _detector; - final StreamController> _controller = - StreamController>.broadcast(); - - bool _isBusy = false; - bool _started = false; - - Stream> get stream => _controller.stream; - - void start() { - if (_started) return; - _started = true; - - // Mode STREAM : optimisé pour le flux vidéo temps réel. - // 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: true, - ); - _detector = ObjectDetector(options: options); - } - - /// À appeler depuis `startImageStream`. - Future processCameraImage( - 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; - - try { - final inputImage = _toInputImage(image, camera, deviceOrientation); - if (inputImage == null) { - _isBusy = false; - return; - } - - final objects = await _detector!.processImage(inputImage); - - final int imgW = image.width; - final int imgH = image.height; - - final List results = objects.map((o) { - final rect = o.boundingBox; - String label = ''; - double conf = 0; - if (o.labels.isNotEmpty) { - final best = o.labels.reduce( - (a, b) => a.confidence >= b.confidence ? a : b, - ); - label = best.text; - conf = best.confidence; - } - return DetectedObject2D( - left: (rect.left / imgW).clamp(0.0, 1.0), - top: (rect.top / imgH).clamp(0.0, 1.0), - right: (rect.right / imgW).clamp(0.0, 1.0), - bottom: (rect.bottom / imgH).clamp(0.0, 1.0), - label: label, - confidence: conf, - ); - }).toList(); - - if (!_controller.isClosed) _controller.add(results); - } catch (e) { - debugPrint('ObjectDetection erreur: $e'); - } finally { - _isBusy = false; - } - } - - /// Convertit une CameraImage en InputImage ML Kit. - InputImage? _toInputImage( - CameraImage image, - CameraDescription camera, - DeviceOrientation deviceOrientation, - ) { - // Rotation : combine l'orientation du capteur et celle de l'appareil. - final sensorOrientation = camera.sensorOrientation; - InputImageRotation? rotation; - - if (defaultTargetPlatform == TargetPlatform.iOS) { - rotation = InputImageRotationValue.fromRawValue(sensorOrientation); - } else { - // Android : table d'orientation - var rotationCompensation = _orientations[deviceOrientation] ?? 0; - if (camera.lensDirection == CameraLensDirection.front) { - rotationCompensation = - (sensorOrientation + rotationCompensation) % 360; - } else { - rotationCompensation = - (sensorOrientation - rotationCompensation + 360) % 360; - } - rotation = InputImageRotationValue.fromRawValue(rotationCompensation); - } - if (rotation == null) return null; - - final format = InputImageFormatValue.fromRawValue(image.format.raw); - if (format == null) return null; - - // ML Kit attend un seul plan contigu (NV21 sur Android, BGRA sur iOS). - if (image.planes.isEmpty) return null; - final plane = image.planes.first; - - return InputImage.fromBytes( - bytes: plane.bytes, - metadata: InputImageMetadata( - size: Size(image.width.toDouble(), image.height.toDouble()), - rotation: rotation, - format: format, - bytesPerRow: plane.bytesPerRow, - ), - ); - } - - static const Map _orientations = { - DeviceOrientation.portraitUp: 0, - DeviceOrientation.landscapeLeft: 90, - DeviceOrientation.portraitDown: 180, - DeviceOrientation.landscapeRight: 270, - }; - - void stop() { - _started = false; - } - - void dispose() { - stop(); - _detector?.close(); - _detector = null; - _controller.close(); - } -}