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:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user