feat: target rectify service pour perfectionner la prise de photo avec detection

This commit is contained in:
qguillaume
2026-06-05 22:27:53 +02:00
parent c49c8b8c73
commit 9f9f9beb84
3 changed files with 361 additions and 58 deletions

View File

@@ -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<CaptureScreen>
StreamSubscription<List<DetectedObject2D>>? _objectSubscription;
List<DetectedObject2D> _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<CaptureScreen>
),
),
// 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<CaptureScreen>
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<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).
// 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<DetectedObject2D> 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;
}
old.objects != objects || old.color != color || old.primary != primary;
}

View File

@@ -49,7 +49,7 @@ class DetectedObject2D {
class ObjectDetectionService {
ObjectDetector? _detector;
final StreamController<List<DetectedObject2D>> _controller =
StreamController<List<DetectedObject2D>>.broadcast();
StreamController<List<DetectedObject2D>>.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<void> 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();
}
}
}

View File

@@ -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<RectifyResult> 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<Point2f> 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',
);
}
}
}