chore: suppression du code mort (détection auto, distorsion, ML Kit)

- Supprime 5 services inatteignables depuis l'UI (~3 000 lignes) :
  distortion_correction, image_processing, target_detection,
  opencv_impact_detection, target_rectify
- AnalysisProvider allégé (835 -> ~360 lignes) : retrait de la détection
  par références, de la détection auto d'impacts, du workflow distorsion
  et du doublon moveShot
- Retire les dépendances inutilisées google_mlkit_object_detection et
  google_mlkit_document_scanner du pubspec
- Le bouton ↻ du Plotting efface désormais tous les impacts en un clic
  (clearShots) sans relancer la détection auto ni toucher la calibration
- Nettoie les paramètres morts de TargetOverlay (referenceImpacts,
  onAddShot), le flag _isSelectingReferences, _buildActionButtons vide
  et le résidu _detectionTimer de capture_screen
- Supprime le dossier tests/ (brouillons d'expérimentation OpenCV)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
qguillaume
2026-07-04 10:04:09 +02:00
parent f65f65112c
commit 972bfbe0e9
15 changed files with 44 additions and 3678 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,228 +0,0 @@
/// Service de détection d'impacts utilisant OpenCV.
library;
import 'dart:math' as math;
import 'package:opencv_dart/opencv_dart.dart' as cv;
/// Paramètres de détection d'impacts OpenCV
class OpenCVDetectionSettings {
/// Seuil Canny bas pour la détection de contours
final double cannyThreshold1;
/// Seuil Canny haut pour la détection de contours
final double cannyThreshold2;
/// Distance minimale entre les centres des cercles détectés
final double minDist;
/// Paramètre 1 de HoughCircles (seuil Canny interne)
final double param1;
/// Paramètre 2 de HoughCircles (seuil d'accumulation)
final double param2;
/// Rayon minimum des cercles en pixels
final int minRadius;
/// Rayon maximum des cercles en pixels
final int maxRadius;
/// Taille du flou gaussien (doit être impair)
final int blurSize;
/// Utiliser la détection de contours en plus de Hough
final bool useContourDetection;
/// Circularité minimale pour la détection par contours (0-1)
final double minCircularity;
/// Surface minimale des contours
final double minContourArea;
/// Surface maximale des contours
final double maxContourArea;
const OpenCVDetectionSettings({
this.cannyThreshold1 = 50,
this.cannyThreshold2 = 150,
this.minDist = 20,
this.param1 = 100,
this.param2 = 30,
this.minRadius = 5,
this.maxRadius = 50,
this.blurSize = 5,
this.useContourDetection = true,
this.minCircularity = 0.6,
this.minContourArea = 50,
this.maxContourArea = 5000,
});
}
/// Résultat de détection d'impact
class OpenCVDetectedImpact {
/// Position X normalisée (0-1)
final double x;
/// Position Y normalisée (0-1)
final double y;
/// Rayon en pixels
final double radius;
/// Score de confiance (0-1)
final double confidence;
/// Méthode de détection utilisée
final String method;
const OpenCVDetectedImpact({
required this.x,
required this.y,
required this.radius,
this.confidence = 1.0,
this.method = 'unknown',
});
}
/// Service de détection d'impacts utilisant OpenCV
class OpenCVImpactDetectionService {
/// Détecte les impacts dans une image en utilisant OpenCV
List<OpenCVDetectedImpact> detectImpacts(
String imagePath, {
OpenCVDetectionSettings settings = const OpenCVDetectionSettings(),
}) {
try {
final img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (img.isEmpty) return [];
final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
// Apply blur to reduce noise
final blurKSize = (settings.blurSize, settings.blurSize);
final blurred = cv.gaussianBlur(gray, blurKSize, 2, sigmaY: 2);
final List<OpenCVDetectedImpact> detectedImpacts = [];
final circles = cv.HoughCircles(
blurred,
cv.HOUGH_GRADIENT,
1,
settings.minDist,
param1: settings.param1,
param2: settings.param2,
minRadius: settings.minRadius,
maxRadius: settings.maxRadius,
);
if (circles.rows > 0 && circles.cols > 0) {
// Mat shape: (1, N, 3) usually for HoughCircles (CV_32FC3)
// We use at<Vec3f> directly.
for (int i = 0; i < circles.cols; i++) {
final vec = circles.at<cv.Vec3f>(0, i);
final x = vec.val1;
final y = vec.val2;
final r = vec.val3;
detectedImpacts.add(
OpenCVDetectedImpact(
x: x / img.cols,
y: y / img.rows,
radius: r,
confidence: 0.8,
method: 'hough',
),
);
}
}
// 2. Contour Detection (if enabled)
if (settings.useContourDetection) {
// Canny edge detection
final edges = cv.canny(
blurred,
settings.cannyThreshold1,
settings.cannyThreshold2,
);
// Find contours
final contoursResult = cv.findContours(
edges,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE,
);
final contours = contoursResult.$1;
// hierarchy is $2
for (int i = 0; i < contours.length; i++) {
final contour = contours[i];
// Filter by area
final area = cv.contourArea(contour);
if (area < settings.minContourArea ||
area > settings.maxContourArea) {
continue;
}
// Filter by circularity
final perimeter = cv.arcLength(contour, true);
if (perimeter == 0) continue;
final circularity = 4 * math.pi * area / (perimeter * perimeter);
if (circularity < settings.minCircularity) continue;
// Get bounding circle
final enclosingCircle = cv.minEnclosingCircle(contour);
final center = enclosingCircle.$1;
final radius = enclosingCircle.$2;
// Avoid duplicates (simple distance check against Hough results)
bool isDuplicate = false;
for (final existing in detectedImpacts) {
final dx = existing.x * img.cols - center.x;
final dy = existing.y * img.rows - center.y;
final dist = math.sqrt(dx * dx + dy * dy);
if (dist < radius) {
isDuplicate = true;
break;
}
}
if (!isDuplicate) {
detectedImpacts.add(
OpenCVDetectedImpact(
x: center.x / img.cols,
y: center.y / img.rows,
radius: radius,
confidence: circularity, // Use circularity as confidence
method: 'contour',
),
);
}
}
}
return detectedImpacts;
} catch (e) {
// print('OpenCV Error: $e');
return [];
}
}
/// Détecte les impacts en utilisant une image de référence
List<OpenCVDetectedImpact> detectFromReferences(
String imagePath,
List<({double x, double y})> referencePoints, {
double tolerance = 2.0,
}) {
// Basic implementation: use average color/brightness of reference points
// This is a placeholder for a more complex template matching or feature matching
// For now, we can just run the standard detection but filter results
// based on properties of the reference points (e.g. size/radius if we had it).
// Returning standard detection for now to enable the feature.
return detectImpacts(imagePath);
}
}

View File

@@ -1,461 +0,0 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import '../data/models/target_type.dart';
import 'image_processing_service.dart';
import 'opencv_impact_detection_service.dart';
export 'image_processing_service.dart'
show ImpactDetectionSettings, ReferenceImpact, ImpactCharacteristics;
export 'opencv_impact_detection_service.dart'
show OpenCVDetectionSettings, OpenCVDetectedImpact;
// ============================================================================
// STRUCT ET FONCTION GLOBALE POUR LE PARALLÉLISME (THREAD SECONDAIRE)
// ============================================================================
/// Conteneur de données pour envoyer les paramètres à l'Isolate d'arrière-plan.
class DetectionPayload {
final String imagePath;
final TargetType targetType;
// On recrée les instances à l'intérieur du thread isolé car les objets ne se partagent pas entre threads.
DetectionPayload({
required this.imagePath,
required this.targetType,
});
}
/// FONCTION EXÉCUTÉE EN PARALLÈLE : Tourne sur un autre cœur du processeur.
/// L'interface graphique reste à 120 FPS et totalement fluide.
TargetDetectionResult runParallelTargetDetection(DetectionPayload payload) {
// 1. Initialisation locale des services dans le sous-thread
final imageProcessingService = ImageProcessingService();
try {
// 2. Détection de la cible principale (Calcul lourd)
final mainTarget = imageProcessingService.detectMainTarget(payload.imagePath);
double centerX = 0.5;
double centerY = 0.5;
double radius = 0.4;
if (mainTarget != null) {
centerX = mainTarget.centerX;
centerY = mainTarget.centerY;
radius = mainTarget.radius;
}
// 3. Détection des impacts (Calcul lourd)
final impacts = imageProcessingService.detectImpacts(payload.imagePath);
// 4. Calcul mathématique des scores relatifs
final detectedImpacts = impacts.map((impact) {
final score = payload.targetType == TargetType.concentric
? _staticCalculateConcentricScore(
impact.x,
impact.y,
centerX,
centerY,
radius,
)
: _staticCalculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
return TargetDetectionResult(
centerX: centerX,
centerY: centerY,
radius: radius,
impacts: detectedImpacts,
);
} catch (e) {
return TargetDetectionResult.error('Erreur de detection parallèle: $e');
}
}
// Fonctions mathématiques pures nécessaires à l'Isolate (statiques)
int _staticCalculateConcentricScore(double impactX, double impactY, double centerX, double centerY, double targetRadius) {
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
if (distance <= 0.1) return 10;
if (distance <= 0.2) return 9;
if (distance <= 0.3) return 8;
if (distance <= 0.4) return 7;
if (distance <= 0.5) return 6;
if (distance <= 0.6) return 5;
if (distance <= 0.7) return 4;
if (distance <= 0.8) return 3;
if (distance <= 0.9) return 2;
if (distance <= 1.0) return 1;
return 0;
}
int _staticCalculateSilhouetteScore(double impactX, double impactY, double centerX, double centerY) {
final dx = (impactX - centerX).abs();
final dy = impactY - centerY;
if (dx > 0.15) return 0;
if (dy < -0.25) return 5;
if (dy < 0.0) return 5;
if (dy < 0.15) return 4;
if (dy < 0.35) return 3;
return 0;
}
// ============================================================================
// FIN DU BLOC DE PARALLÉLISME
// ============================================================================
class TargetDetectionResult {
final double centerX; // Relative (0-1)
final double centerY; // Relative (0-1)
final double radius; // Relative (0-1)
final List<DetectedImpactResult> impacts;
final bool success;
final String? errorMessage;
TargetDetectionResult({
required this.centerX,
required this.centerY,
required this.radius,
required this.impacts,
this.success = true,
this.errorMessage,
});
factory TargetDetectionResult.error(String message) {
return TargetDetectionResult(
centerX: 0.5,
centerY: 0.5,
radius: 0.4,
impacts: [],
success: false,
errorMessage: message,
);
}
}
class DetectedImpactResult {
final double x; // Relative (0-1)
final double y; // Relative (0-1)
final double radius; // Absolute pixels
final int suggestedScore;
DetectedImpactResult({
required this.x,
required this.y,
required this.radius,
required this.suggestedScore,
});
}
class TargetDetectionService {
final ImageProcessingService _imageProcessingService;
final OpenCVImpactDetectionService _opencvService;
TargetDetectionService({
ImageProcessingService? imageProcessingService,
OpenCVImpactDetectionService? opencvService,
}) : _imageProcessingService =
imageProcessingService ?? ImageProcessingService(),
_opencvService = opencvService ?? OpenCVImpactDetectionService();
/// Detect target and impacts from an image file ASYNCHRONOUSLY in a separate Thread.
/// CORRECTION : Utilise désormais 'compute' pour basculer en arrière-plan immédiat.
Future<TargetDetectionResult> detectTargetAsync(String imagePath, TargetType targetType) async {
final payload = DetectionPayload(imagePath: imagePath, targetType: targetType);
// Déclenche l'exécution isolée en tâche de fond
return await compute(runParallelTargetDetection, payload);
}
/// Gardée pour rétrocompatibilité synchrone si nécessaire
TargetDetectionResult detectTarget(String imagePath, TargetType targetType) {
try {
final mainTarget = _imageProcessingService.detectMainTarget(imagePath);
double centerX = 0.5;
double centerY = 0.5;
double radius = 0.4;
if (mainTarget != null) {
centerX = mainTarget.centerX;
centerY = mainTarget.centerY;
radius = mainTarget.radius;
}
final impacts = _imageProcessingService.detectImpacts(imagePath);
final detectedImpacts = impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScore(
impact.x,
impact.y,
centerX,
centerY,
radius,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
return TargetDetectionResult(
centerX: centerX,
centerY: centerY,
radius: radius,
impacts: detectedImpacts,
);
} catch (e) {
return TargetDetectionResult.error('Erreur de detection: $e');
}
}
int _calculateConcentricScore(
double impactX,
double impactY,
double centerX,
double centerY,
double targetRadius,
) {
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
if (distance <= 0.1) return 10;
if (distance <= 0.2) return 9;
if (distance <= 0.3) return 8;
if (distance <= 0.4) return 7;
if (distance <= 0.5) return 6;
if (distance <= 0.6) return 5;
if (distance <= 0.7) return 4;
if (distance <= 0.8) return 3;
if (distance <= 0.9) return 2;
if (distance <= 1.0) return 1;
return 0;
}
int _calculateSilhouetteScore(
double impactX,
double impactY,
double centerX,
double centerY,
) {
final dx = (impactX - centerX).abs();
final dy = impactY - centerY;
if (dx > 0.15) return 0;
if (dy < -0.25) return 5;
if (dy < 0.0) return 5;
if (dy < 0.15) return 4;
if (dy < 0.35) return 3;
return 0;
}
List<DetectedImpactResult> detectImpactsOnly(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
ImpactDetectionSettings settings,
) {
try {
final impacts = _imageProcessingService.detectImpactsWithSettings(
imagePath,
settings,
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
return [];
}
}
int _calculateConcentricScoreWithRings(
double impactX,
double impactY,
double centerX,
double centerY,
double targetRadius,
int ringCount,
) {
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
for (int i = 0; i < ringCount; i++) {
final zoneRadius = (i + 1) / ringCount;
if (distance <= zoneRadius) {
return 10 - i;
}
}
return 0;
}
ImpactCharacteristics? analyzeReferenceImpacts(
String imagePath,
List<ReferenceImpact> references,
) {
return _imageProcessingService.analyzeReferenceImpacts(
imagePath,
references,
);
}
List<DetectedImpactResult> detectImpactsFromReferences(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
ImpactCharacteristics characteristics, {
double tolerance = 2.0,
}) {
try {
final impacts = _imageProcessingService.detectImpactsFromReferences(
imagePath,
characteristics,
tolerance: tolerance,
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
return [];
}
}
List<DetectedImpactResult> detectImpactsWithOpenCV(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount, {
OpenCVDetectionSettings? settings,
}) {
try {
final impacts = _opencvService.detectImpacts(
imagePath,
settings: settings ?? const OpenCVDetectionSettings(),
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
debugPrint('Erreur détection OpenCV: $e');
return [];
}
}
List<DetectedImpactResult> detectImpactsWithOpenCVFromReferences(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
List<ReferenceImpact> references, {
double tolerance = 2.0,
}) {
try {
final refPoints = references.map((r) => (x: r.x, y: r.y)).toList();
final impacts = _opencvService.detectFromReferences(
imagePath,
refPoints,
tolerance: tolerance,
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
debugPrint('Erreur détection OpenCV depuis références: $e');
return [];
}
}
}

View File

@@ -1,259 +0,0 @@
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',
);
}
}
}