feat: target rectify service pour perfectionner la prise de photo avec detection
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
259
lib/services/target_rectify_service.dart
Normal file
259
lib/services/target_rectify_service.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user