diff --git a/lib/features/capture/capture_screen.dart b/lib/features/capture/capture_screen.dart index c0790382..80bbaa80 100644 --- a/lib/features/capture/capture_screen.dart +++ b/lib/features/capture/capture_screen.dart @@ -2,6 +2,7 @@ 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'; @@ -17,6 +18,7 @@ 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 'package:image/image.dart' as img; import 'package:path_provider/path_provider.dart'; @@ -36,8 +38,8 @@ class _CaptureScreenState extends State // NOUVEAU : Service IMU de parallélisme final ParallelismService _parallelismService = ParallelismService( - alignThreshold: 15.0, // Seuil pour passer au vert - misalignThreshold: 22.0, // Seuil pour repasser à l'orange + alignThreshold: 25.0, // Seuil pour passer au vert (permissif) + misalignThreshold: 32.0, // Seuil pour repasser à l'orange ); String? _selectedImagePath; @@ -62,6 +64,11 @@ class _CaptureScreenState extends State ParallelismData? _parallelismData; StreamSubscription? _parallelismSubscription; + // NOUVEAU : Détection d'objet ML Kit + final ObjectDetectionService _objectService = ObjectDetectionService(); + StreamSubscription>? _objectSubscription; + List _detectedObjects = []; + @override void initState() { super.initState(); @@ -82,6 +89,8 @@ class _CaptureScreenState extends State _detectionTimer?.cancel(); _parallelismSubscription?.cancel(); // NOUVEAU _parallelismService.dispose(); // NOUVEAU + _objectSubscription?.cancel(); // NOUVEAU + _objectService.dispose(); // NOUVEAU super.dispose(); } @@ -108,24 +117,80 @@ class _CaptureScreenState extends State _parallelismData = null; } + // ───────────────────────────────────────────────────────────────────────── + // 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 // ───────────────────────────────────────────────────────────────────────── - /// Couleur du cadre selon le parallélisme détecté par l'IMU. - Color get _frameColor { - if (_parallelismData == null) return const Color(0xFF00FF00); - return _parallelismData!.isAligned ? const Color(0xFF00FF00) : Colors.orange; + /// 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; } - /// Message d'aide selon le parallélisme détecté. + bool get _objectReady => _primaryObject != null; + + /// Couleur du cadre selon le parallélisme détecté par l'IMU. + Color get _frameColor { + final bool imuAligned = + _parallelismData == null || _parallelismData!.isAligned; + // Vert UNIQUEMENT si l'appareil est parallèle ET un objet est bien cadré. + if (imuAligned && _objectReady) return const Color(0xFF00FF00); + return Colors.orange; + } + + /// Message d'aide selon le parallélisme + détection d'objet. String get _alignmentMessage { + final bool imuAligned = + _parallelismData == null || _parallelismData!.isAligned; + + // Cas idéal : tout est bon + if (imuAligned && _objectReady) { + return 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER'; + } + + // IMU bon mais pas d'objet correctement cadré + if (imuAligned && !_objectReady) { + return _detectedObjects.isEmpty + ? 'AUCUN OBJET DÉTECTÉ — APPROCHEZ-VOUS' + : 'CENTREZ L\'OBJET DANS LE CADRE'; + } + if (_parallelismData == null) { return 'ALIGNEZ LA CIBLE DANS LE CADRE'; } - if (_parallelismData!.status == ParallelismStatus.aligned) { - return 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER'; - } // Message directif selon l'axe le plus dévié final double pitch = _parallelismData!.pitchDegrees; @@ -184,6 +249,7 @@ class _CaptureScreenState extends State _startAlignmentDetection(); _startParallelismDetection(); // NOUVEAU + _startObjectDetection(); // NOUVEAU } } catch (e) { debugPrint('Erreur caméra: $e'); @@ -202,6 +268,14 @@ 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(); if (_lastAnalysis != null && @@ -439,6 +513,20 @@ class _CaptureScreenState extends State ), ), + // NOUVEAU : 1.bis Cadre dessiné autour de l'objet détecté + if (_primaryObject != null) + Center( + child: AspectRatio( + aspectRatio: 3 / 4, + child: CustomPaint( + painter: _ObjectBoxPainter( + object: _primaryObject!, + color: frameColor, + ), + ), + ), + ), + // 2. Cadre de visée avec coins et mire centrale Center( child: Container( @@ -498,6 +586,7 @@ class _CaptureScreenState extends State onPressed: () { _stopAlignmentDetection(); _stopParallelismDetection(); // NOUVEAU + _stopObjectDetection(); // NOUVEAU setState(() { _showLiveCamera = false; }); @@ -706,6 +795,7 @@ class _CaptureScreenState extends State _stopAlignmentDetection(); _stopParallelismDetection(); // NOUVEAU + _stopObjectDetection(); // NOUVEAU final XFile photo = await _cameraController!.takePicture(); @@ -817,3 +907,60 @@ 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). +// ───────────────────────────────────────────────────────────────────────── +class _ObjectBoxPainter extends CustomPainter { + final DetectedObject2D object; + final Color color; + + _ObjectBoxPainter({required this.object, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final Paint stroke = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3.0 + ..color = color; + + final Rect rect = Rect.fromLTRB( + object.left * size.width, + object.top * size.height, + object.right * size.width, + object.bottom * size.height, + ); + + // 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)), + ); + } + } + + @override + bool shouldRepaint(_ObjectBoxPainter old) => + old.object != object || old.color != color; +} \ No newline at end of file diff --git a/lib/services/parallelism_service.dart b/lib/services/parallelism_service.dart index 82122d74..67ec69c0 100644 --- a/lib/services/parallelism_service.dart +++ b/lib/services/parallelism_service.dart @@ -60,18 +60,18 @@ class ParallelismService { StreamSubscription? _subscription; final StreamController _controller = - StreamController.broadcast(); + StreamController.broadcast(); /// État interne mémorisé entre deux frames (cœur de l'hystérésis). ParallelismStatus _currentStatus = ParallelismStatus.unknown; ParallelismService({ - this.alignThreshold = 15.0, - this.misalignThreshold = 22.0, + this.alignThreshold = 25.0, + this.misalignThreshold = 32.0, }) : assert( - misalignThreshold > alignThreshold, - 'misalignThreshold doit être supérieur à alignThreshold', - ); + misalignThreshold > alignThreshold, + 'misalignThreshold doit être supérieur à alignThreshold', + ); Stream get stream => _controller.stream; @@ -122,14 +122,24 @@ class ParallelismService { final double ny = gy / magnitude; final double nz = gz / magnitude; - // Pitch : inclinaison avant/arrière (téléphone qui pointe vers le haut/bas) + // ── Mesure de l'écart par rapport à la VERTICALE (cible sur un mur) ── + // Quand le téléphone est vertical face au mur, la gravité est portée par + // l'axe -Y (ny ≈ -1) et l'axe Z est proche de 0 (nz ≈ 0). + // + // Pitch = bascule avant/arrière (le téléphone "se couche") → vient de nz. + // Roll = penché à gauche/droite → vient de nx. final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi); + final double rollDeg = math.asin(nx.clamp(-1.0, 1.0)) * (180.0 / math.pi); - // Roll : inclinaison latérale (téléphone penché à gauche/droite) - final double rollDeg = math.atan2(nx, -ny) * (180.0 / math.pi); + // Écart angulaire TOTAL par rapport à la verticale parfaite, calculé en 3D. + // C'est l'angle entre l'axe -Y du téléphone et le vecteur gravité réel. + // -> Vaut 0° quand l'écran est parfaitement parallèle au mur, + // indépendamment d'une rotation autour de l'axe de visée. + final double tiltAngle = + math.acos((-ny).clamp(-1.0, 1.0)) * (180.0 / math.pi); - // Angle combiné = le pire des deux axes - final double worstAngle = math.max(pitchDeg.abs(), rollDeg.abs()); + // On utilise l'écart 3D comme critère principal (plus permissif et stable). + final double worstAngle = tiltAngle; // ── Hystérésis ────────────────────────────────────────────────────────── // Premier appel : on décide selon alignThreshold uniquement @@ -159,4 +169,4 @@ class ParallelismService { rollDegrees: rollDeg, )); } -} +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 398eded0..9cc0f496 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -293,6 +293,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + google_mlkit_commons: + dependency: transitive + description: + name: google_mlkit_commons + sha256: "3e69fea4211727732cc385104e675ad1e40b29f12edd492ee52fa108423a6124" + url: "https://pub.dev" + source: hosted + version: "0.11.1" google_mlkit_document_scanner: dependency: "direct main" description: @@ -301,6 +309,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.1" + google_mlkit_object_detection: + dependency: "direct main" + description: + name: google_mlkit_object_detection + sha256: "9dd35886972e18747e22098f8ebee78d30716a99a789bb2e3a65a24229e031e7" + url: "https://pub.dev" + source: hosted + version: "0.15.1" hooks: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 51fc128f..a482fb2d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: cupertino_icons: ^1.0.8 sensors_plus: ^4.0.2 opencv_dart: ^2.1.0 + google_mlkit_object_detection: ^0.15.0 # Image capture from camera/gallery image_picker: ^1.2.1