feat: object detection service

This commit is contained in:
qguillaume
2026-06-05 21:18:48 +02:00
parent d487c98e3d
commit f65c5e6a3f
4 changed files with 196 additions and 22 deletions

View File

@@ -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<CaptureScreen>
// 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<CaptureScreen>
ParallelismData? _parallelismData;
StreamSubscription<ParallelismData>? _parallelismSubscription;
// NOUVEAU : Détection d'objet ML Kit
final ObjectDetectionService _objectService = ObjectDetectionService();
StreamSubscription<List<DetectedObject2D>>? _objectSubscription;
List<DetectedObject2D> _detectedObjects = [];
@override
void initState() {
super.initState();
@@ -82,6 +89,8 @@ class _CaptureScreenState extends State<CaptureScreen>
_detectionTimer?.cancel();
_parallelismSubscription?.cancel(); // NOUVEAU
_parallelismService.dispose(); // NOUVEAU
_objectSubscription?.cancel(); // NOUVEAU
_objectService.dispose(); // NOUVEAU
super.dispose();
}
@@ -108,24 +117,80 @@ class _CaptureScreenState extends State<CaptureScreen>
_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<CaptureScreen>
_startAlignmentDetection();
_startParallelismDetection(); // NOUVEAU
_startObjectDetection(); // NOUVEAU
}
} catch (e) {
debugPrint('Erreur caméra: $e');
@@ -202,6 +268,14 @@ class _CaptureScreenState extends State<CaptureScreen>
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<CaptureScreen>
),
),
// 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<CaptureScreen>
onPressed: () {
_stopAlignmentDetection();
_stopParallelismDetection(); // NOUVEAU
_stopObjectDetection(); // NOUVEAU
setState(() {
_showLiveCamera = false;
});
@@ -706,6 +795,7 @@ class _CaptureScreenState extends State<CaptureScreen>
_stopAlignmentDetection();
_stopParallelismDetection(); // NOUVEAU
_stopObjectDetection(); // NOUVEAU
final XFile photo = await _cameraController!.takePicture();
@@ -817,3 +907,60 @@ class _CaptureScreenState extends State<CaptureScreen>
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// 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;
}

View File

@@ -60,18 +60,18 @@ class ParallelismService {
StreamSubscription<AccelerometerEvent>? _subscription;
final StreamController<ParallelismData> _controller =
StreamController<ParallelismData>.broadcast();
StreamController<ParallelismData>.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<ParallelismData> 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

View File

@@ -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:

View File

@@ -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