feat: object detection service
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -66,8 +66,8 @@ class ParallelismService {
|
||||
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',
|
||||
@@ -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
|
||||
|
||||
16
pubspec.lock
16
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user