1054 lines
34 KiB
Dart
1054 lines
34 KiB
Dart
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
import 'package:image/image.dart' as img;
|
|
|
|
class DetectedCircle {
|
|
final double centerX;
|
|
final double centerY;
|
|
final double radius;
|
|
|
|
DetectedCircle({
|
|
required this.centerX,
|
|
required this.centerY,
|
|
required this.radius,
|
|
});
|
|
}
|
|
|
|
class DetectedImpact {
|
|
final double x;
|
|
final double y;
|
|
final double radius;
|
|
|
|
DetectedImpact({
|
|
required this.x,
|
|
required this.y,
|
|
required this.radius,
|
|
});
|
|
}
|
|
|
|
/// Image processing settings for impact detection
|
|
class ImpactDetectionSettings {
|
|
/// Threshold for dark spot detection (0-255, lower = darker)
|
|
final int darkThreshold;
|
|
|
|
/// Minimum impact size in pixels
|
|
final int minImpactSize;
|
|
|
|
/// Maximum impact size in pixels
|
|
final int maxImpactSize;
|
|
|
|
/// Blur radius for noise reduction
|
|
final int blurRadius;
|
|
|
|
/// Contrast enhancement factor
|
|
final double contrastFactor;
|
|
|
|
/// Minimum circularity (0-1, 1 = perfect circle)
|
|
/// Used to filter out non-circular shapes like numbers
|
|
final double minCircularity;
|
|
|
|
/// Maximum aspect ratio (width/height or height/width)
|
|
/// Used to filter out elongated shapes like numbers
|
|
final double maxAspectRatio;
|
|
|
|
/// Minimum fill ratio (0-1, ~0.7 for filled circle, lower for rings/hollow shapes)
|
|
/// A bullet hole is FILLED, numbers on target are hollow rings
|
|
final double minFillRatio;
|
|
|
|
const ImpactDetectionSettings({
|
|
this.darkThreshold = 80,
|
|
this.minImpactSize = 20,
|
|
this.maxImpactSize = 500,
|
|
this.blurRadius = 2,
|
|
this.contrastFactor = 1.2,
|
|
this.minCircularity = 0.6,
|
|
this.maxAspectRatio = 2.0,
|
|
this.minFillRatio = 0.5, // Filled circles should have ratio > 0.5
|
|
});
|
|
}
|
|
|
|
/// Reference impact for calibrated detection
|
|
class ReferenceImpact {
|
|
final double x; // Normalized 0-1
|
|
final double y; // Normalized 0-1
|
|
|
|
const ReferenceImpact({required this.x, required this.y});
|
|
}
|
|
|
|
/// Characteristics learned from reference impacts
|
|
class ImpactCharacteristics {
|
|
final double avgLuminance;
|
|
final double luminanceStdDev;
|
|
final double avgSize;
|
|
final double sizeStdDev;
|
|
final double avgCircularity;
|
|
final double avgFillRatio; // How filled is the blob vs its bounding circle
|
|
final double avgDarkThreshold; // The threshold used to detect the blob
|
|
|
|
const ImpactCharacteristics({
|
|
required this.avgLuminance,
|
|
required this.luminanceStdDev,
|
|
required this.avgSize,
|
|
required this.sizeStdDev,
|
|
required this.avgCircularity,
|
|
required this.avgFillRatio,
|
|
required this.avgDarkThreshold,
|
|
});
|
|
|
|
@override
|
|
String toString() {
|
|
return 'ImpactCharacteristics(lum: ${avgLuminance.toStringAsFixed(1)} ± ${luminanceStdDev.toStringAsFixed(1)}, '
|
|
'size: ${avgSize.toStringAsFixed(1)} ± ${sizeStdDev.toStringAsFixed(1)}, '
|
|
'circ: ${avgCircularity.toStringAsFixed(2)}, fill: ${avgFillRatio.toStringAsFixed(2)})';
|
|
}
|
|
}
|
|
|
|
/// Service for image processing and impact detection
|
|
class ImageProcessingService {
|
|
/// Detect the main target circle from an image
|
|
DetectedCircle? detectMainTarget(String imagePath) {
|
|
// Return center of image as target (basic implementation)
|
|
// Could be enhanced with circle detection algorithm
|
|
return DetectedCircle(
|
|
centerX: 0.5,
|
|
centerY: 0.5,
|
|
radius: 0.4,
|
|
);
|
|
}
|
|
|
|
/// Detect impacts (bullet holes) from an image file
|
|
List<DetectedImpact> detectImpacts(String imagePath) {
|
|
return detectImpactsWithSettings(
|
|
imagePath,
|
|
const ImpactDetectionSettings(),
|
|
);
|
|
}
|
|
|
|
/// Detect impacts with custom settings
|
|
List<DetectedImpact> detectImpactsWithSettings(
|
|
String imagePath,
|
|
ImpactDetectionSettings settings,
|
|
) {
|
|
try {
|
|
// Load the image
|
|
final file = File(imagePath);
|
|
final bytes = file.readAsBytesSync();
|
|
final originalImage = img.decodeImage(bytes);
|
|
|
|
if (originalImage == null) {
|
|
return [];
|
|
}
|
|
|
|
// Resize for faster processing if image is too large
|
|
img.Image image;
|
|
final maxDimension = 1000;
|
|
if (originalImage.width > maxDimension || originalImage.height > maxDimension) {
|
|
final scale = maxDimension / math.max(originalImage.width, originalImage.height);
|
|
image = img.copyResize(
|
|
originalImage,
|
|
width: (originalImage.width * scale).round(),
|
|
height: (originalImage.height * scale).round(),
|
|
);
|
|
} else {
|
|
image = originalImage;
|
|
}
|
|
|
|
// Convert to grayscale
|
|
final grayscale = img.grayscale(image);
|
|
|
|
// Apply gaussian blur to reduce noise
|
|
final blurred = img.gaussianBlur(grayscale, radius: settings.blurRadius);
|
|
|
|
// Enhance contrast
|
|
final enhanced = img.adjustColor(
|
|
blurred,
|
|
contrast: settings.contrastFactor,
|
|
);
|
|
|
|
// Detect dark spots (potential impacts)
|
|
// Filter by circularity and fill ratio to avoid detecting numbers (hollow rings)
|
|
final impacts = _detectDarkSpots(
|
|
enhanced,
|
|
settings.darkThreshold,
|
|
settings.minImpactSize,
|
|
settings.maxImpactSize,
|
|
minCircularity: settings.minCircularity,
|
|
maxAspectRatio: settings.maxAspectRatio,
|
|
minFillRatio: settings.minFillRatio,
|
|
);
|
|
|
|
// Convert to relative coordinates
|
|
final width = originalImage.width.toDouble();
|
|
final height = originalImage.height.toDouble();
|
|
|
|
return impacts.map((impact) {
|
|
return DetectedImpact(
|
|
x: impact.x / width,
|
|
y: impact.y / height,
|
|
radius: impact.radius,
|
|
);
|
|
}).toList();
|
|
} catch (e) {
|
|
print('Error detecting impacts: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Analyze reference impacts to learn their characteristics
|
|
/// This actually finds the blob at each reference point and extracts its real properties
|
|
/// AMÉLIORÉ : Recherche plus large et analyse plus robuste
|
|
ImpactCharacteristics? analyzeReferenceImpacts(
|
|
String imagePath,
|
|
List<ReferenceImpact> references, {
|
|
int searchRadius = 50, // Augmenté de 30 à 50
|
|
}) {
|
|
if (references.length < 2) return null;
|
|
|
|
try {
|
|
final file = File(imagePath);
|
|
final bytes = file.readAsBytesSync();
|
|
final originalImage = img.decodeImage(bytes);
|
|
if (originalImage == null) return null;
|
|
|
|
// Resize for faster processing - taille augmentée
|
|
img.Image image;
|
|
double scale = 1.0;
|
|
final maxDimension = 1200; // Augmenté pour plus de précision
|
|
if (originalImage.width > maxDimension || originalImage.height > maxDimension) {
|
|
scale = maxDimension / math.max(originalImage.width, originalImage.height);
|
|
image = img.copyResize(
|
|
originalImage,
|
|
width: (originalImage.width * scale).round(),
|
|
height: (originalImage.height * scale).round(),
|
|
);
|
|
} else {
|
|
image = originalImage;
|
|
}
|
|
|
|
final grayscale = img.grayscale(image);
|
|
final blurred = img.gaussianBlur(grayscale, radius: 2);
|
|
final width = image.width;
|
|
final height = image.height;
|
|
|
|
final luminances = <double>[];
|
|
final sizes = <double>[];
|
|
final circularities = <double>[];
|
|
final fillRatios = <double>[];
|
|
final thresholds = <double>[];
|
|
|
|
print('Analyzing ${references.length} reference impacts...');
|
|
|
|
for (int refIndex = 0; refIndex < references.length; refIndex++) {
|
|
final ref = references[refIndex];
|
|
final centerX = (ref.x * width).round().clamp(0, width - 1);
|
|
final centerY = (ref.y * height).round().clamp(0, height - 1);
|
|
|
|
print('Reference $refIndex at ($centerX, $centerY)');
|
|
|
|
// AMÉLIORATION : Recherche du point le plus sombre dans une zone plus large
|
|
int darkestX = centerX;
|
|
int darkestY = centerY;
|
|
double darkestLum = 255;
|
|
|
|
// Recherche en spirale du point le plus sombre
|
|
for (int r = 0; r <= searchRadius; r++) {
|
|
for (int dy = -r; dy <= r; dy++) {
|
|
for (int dx = -r; dx <= r; dx++) {
|
|
// Seulement le périmètre du carré pour éviter les doublons
|
|
if (r > 0 && math.max(dx.abs(), dy.abs()) < r) continue;
|
|
|
|
final px = centerX + dx;
|
|
final py = centerY + dy;
|
|
if (px < 0 || px >= width || py < 0 || py >= height) continue;
|
|
|
|
final pixel = blurred.getPixel(px, py);
|
|
final lum = img.getLuminance(pixel).toDouble();
|
|
if (lum < darkestLum) {
|
|
darkestLum = lum;
|
|
darkestX = px;
|
|
darkestY = py;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Si on a trouvé un point très sombre, on peut s'arrêter
|
|
if (darkestLum < 50 && r > 5) break;
|
|
}
|
|
|
|
print(' Darkest point at ($darkestX, $darkestY), lum=$darkestLum');
|
|
|
|
// Now find the blob at the darkest point using adaptive threshold
|
|
final blobResult = _findBlobAtPoint(blurred, darkestX, darkestY, width, height);
|
|
|
|
if (blobResult != null && blobResult.size >= 10) { // Au moins 10 pixels
|
|
luminances.add(blobResult.avgLuminance);
|
|
sizes.add(blobResult.size.toDouble());
|
|
circularities.add(blobResult.circularity);
|
|
fillRatios.add(blobResult.fillRatio);
|
|
thresholds.add(blobResult.threshold);
|
|
print(' Found blob: size=${blobResult.size}, circ=${blobResult.circularity.toStringAsFixed(2)}, '
|
|
'fill=${blobResult.fillRatio.toStringAsFixed(2)}, threshold=${blobResult.threshold.toStringAsFixed(0)}');
|
|
} else {
|
|
print(' No valid blob found at this reference');
|
|
}
|
|
}
|
|
|
|
if (luminances.isEmpty) {
|
|
print('ERROR: No valid blobs found from any reference!');
|
|
return null;
|
|
}
|
|
|
|
// Calculate statistics
|
|
final avgLum = luminances.reduce((a, b) => a + b) / luminances.length;
|
|
final avgSize = sizes.reduce((a, b) => a + b) / sizes.length;
|
|
final avgCirc = circularities.reduce((a, b) => a + b) / circularities.length;
|
|
final avgFill = fillRatios.reduce((a, b) => a + b) / fillRatios.length;
|
|
final avgThreshold = thresholds.reduce((a, b) => a + b) / thresholds.length;
|
|
|
|
// Calculate standard deviations
|
|
double lumVariance = 0;
|
|
double sizeVariance = 0;
|
|
for (int i = 0; i < luminances.length; i++) {
|
|
lumVariance += math.pow(luminances[i] - avgLum, 2);
|
|
sizeVariance += math.pow(sizes[i] - avgSize, 2);
|
|
}
|
|
final lumStdDev = math.sqrt(lumVariance / luminances.length);
|
|
// AMÉLIORATION : Écart-type minimum pour éviter des plages trop étroites
|
|
final sizeStdDev = math.max(
|
|
math.sqrt(sizeVariance / sizes.length),
|
|
avgSize * 0.3, // Au moins 30% de variance
|
|
);
|
|
|
|
final result = ImpactCharacteristics(
|
|
avgLuminance: avgLum,
|
|
luminanceStdDev: math.max(lumStdDev, 10), // Minimum 10 de variance
|
|
avgSize: avgSize,
|
|
sizeStdDev: sizeStdDev,
|
|
avgCircularity: avgCirc,
|
|
avgFillRatio: avgFill,
|
|
avgDarkThreshold: avgThreshold,
|
|
);
|
|
|
|
print('Learned characteristics: $result');
|
|
|
|
return result;
|
|
} catch (e) {
|
|
print('Error analyzing reference impacts: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Find a blob at a specific point and extract its characteristics
|
|
/// AMÉLIORÉ : Utilise plusieurs méthodes de détection et retourne le meilleur résultat
|
|
_BlobAnalysis? _findBlobAtPoint(img.Image image, int startX, int startY, int width, int height) {
|
|
// Get the luminance at the center point
|
|
final centerPixel = image.getPixel(startX, startY);
|
|
final centerLum = img.getLuminance(centerPixel).toDouble();
|
|
|
|
// MÉTHODE 1 : Expansion radiale pour trouver le bord
|
|
double sumLum = centerLum;
|
|
int pixelCount = 1;
|
|
double maxRadius = 0;
|
|
|
|
// Collecter les luminances à différents rayons pour une analyse plus robuste
|
|
final radialLuminances = <double>[];
|
|
|
|
// Sample at different radii to find the edge - LIMITE RAISONNABLE pour impacts de balle
|
|
final maxSearchRadius = 60; // Un impact de balle ne fait pas plus de 60 pixels de rayon
|
|
for (int r = 1; r <= maxSearchRadius; r++) {
|
|
double ringSum = 0;
|
|
int ringCount = 0;
|
|
|
|
// Sample points on a ring
|
|
final numSamples = math.max(12, r ~/ 2);
|
|
for (int i = 0; i < numSamples; i++) {
|
|
final angle = (i / numSamples) * 2 * math.pi;
|
|
final px = startX + (r * math.cos(angle)).round();
|
|
final py = startY + (r * math.sin(angle)).round();
|
|
if (px < 0 || px >= width || py < 0 || py >= height) continue;
|
|
|
|
final pixel = image.getPixel(px, py);
|
|
final lum = img.getLuminance(pixel).toDouble();
|
|
ringSum += lum;
|
|
ringCount++;
|
|
}
|
|
|
|
if (ringCount > 0) {
|
|
final avgRingLum = ringSum / ringCount;
|
|
radialLuminances.add(avgRingLum);
|
|
|
|
// Détection du bord : gradient de luminosité significatif
|
|
// Seuil adaptatif basé sur la différence avec le centre
|
|
final luminanceDiff = avgRingLum - centerLum;
|
|
|
|
// Le bord est trouvé quand on a une augmentation significative de luminosité
|
|
if (luminanceDiff > 30 && maxRadius == 0) {
|
|
maxRadius = r.toDouble();
|
|
break; // Arrêter dès qu'on trouve le bord
|
|
}
|
|
|
|
if (maxRadius == 0) {
|
|
sumLum += ringSum;
|
|
pixelCount += ringCount;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Si aucun bord trouvé, chercher le gradient maximum
|
|
if (maxRadius < 2 && radialLuminances.length > 3) {
|
|
double maxGradient = 0;
|
|
int maxGradientIndex = 0;
|
|
for (int i = 1; i < radialLuminances.length; i++) {
|
|
final gradient = radialLuminances[i] - radialLuminances[i - 1];
|
|
if (gradient > maxGradient) {
|
|
maxGradient = gradient;
|
|
maxGradientIndex = i;
|
|
}
|
|
}
|
|
if (maxGradient > 10) {
|
|
maxRadius = (maxGradientIndex + 1).toDouble();
|
|
}
|
|
}
|
|
|
|
// Rayon minimum de 3 pixels, maximum de 50 pour un impact de balle
|
|
if (maxRadius < 3) maxRadius = 3;
|
|
if (maxRadius > 50) maxRadius = 50;
|
|
|
|
// Calculate threshold as weighted average between center and edge luminance
|
|
final edgeRadius = math.min((maxRadius * 1.2).round(), maxSearchRadius - 1);
|
|
double edgeLum = 0;
|
|
int edgeCount = 0;
|
|
for (int i = 0; i < 16; i++) {
|
|
final angle = (i / 16) * 2 * math.pi;
|
|
final px = startX + (edgeRadius * math.cos(angle)).round();
|
|
final py = startY + (edgeRadius * math.sin(angle)).round();
|
|
if (px < 0 || px >= width || py < 0 || py >= height) continue;
|
|
final pixel = image.getPixel(px, py);
|
|
edgeLum += img.getLuminance(pixel).toDouble();
|
|
edgeCount++;
|
|
}
|
|
if (edgeCount > 0) {
|
|
edgeLum /= edgeCount;
|
|
} else {
|
|
edgeLum = centerLum + 50;
|
|
}
|
|
|
|
// Calculer le seuil optimal
|
|
final threshold = ((centerLum + edgeLum) / 2).round().clamp(20, 200);
|
|
|
|
// Utiliser une zone de recherche locale limitée autour du point
|
|
final analysis = _tryFindBlobWithThresholdLocal(
|
|
image, startX, startY, width, height, threshold, sumLum / pixelCount,
|
|
maxRadius.round() + 10, // Zone de recherche légèrement plus grande que le rayon détecté
|
|
);
|
|
|
|
return analysis;
|
|
}
|
|
|
|
/// Trouve un blob avec un seuil dans une zone locale limitée
|
|
_BlobAnalysis? _tryFindBlobWithThresholdLocal(
|
|
img.Image image,
|
|
int startX,
|
|
int startY,
|
|
int width,
|
|
int height,
|
|
int threshold,
|
|
double avgLuminance,
|
|
int maxSearchRadius,
|
|
) {
|
|
// Limiter la zone de recherche
|
|
final minX = math.max(0, startX - maxSearchRadius);
|
|
final maxX = math.min(width - 1, startX + maxSearchRadius);
|
|
final minY = math.max(0, startY - maxSearchRadius);
|
|
final maxY = math.min(height - 1, startY + maxSearchRadius);
|
|
|
|
final localWidth = maxX - minX + 1;
|
|
final localHeight = maxY - minY + 1;
|
|
|
|
// Create binary mask ONLY for the local region
|
|
final mask = List.generate(localHeight, (_) => List.filled(localWidth, false));
|
|
for (int y = 0; y < localHeight; y++) {
|
|
for (int x = 0; x < localWidth; x++) {
|
|
final globalX = minX + x;
|
|
final globalY = minY + y;
|
|
final pixel = image.getPixel(globalX, globalY);
|
|
final lum = img.getLuminance(pixel);
|
|
mask[y][x] = lum < threshold;
|
|
}
|
|
}
|
|
|
|
final visited = List.generate(localHeight, (_) => List.filled(localWidth, false));
|
|
|
|
// Find the blob containing the start point (in local coordinates)
|
|
final localStartX = startX - minX;
|
|
final localStartY = startY - minY;
|
|
|
|
int searchX = localStartX;
|
|
int searchY = localStartY;
|
|
|
|
if (!mask[localStartY][localStartX]) {
|
|
// Start point might not be in mask, find nearest point that is
|
|
bool found = false;
|
|
for (int r = 1; r <= 15 && !found; r++) {
|
|
for (int dy = -r; dy <= r && !found; dy++) {
|
|
for (int dx = -r; dx <= r && !found; dx++) {
|
|
final px = localStartX + dx;
|
|
final py = localStartY + dy;
|
|
if (px >= 0 && px < localWidth && py >= 0 && py < localHeight && mask[py][px]) {
|
|
searchX = px;
|
|
searchY = py;
|
|
found = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!found) return null;
|
|
}
|
|
|
|
final blob = _floodFillLocal(mask, visited, searchX, searchY, localWidth, localHeight);
|
|
|
|
// Vérifier que le blob est valide - taille raisonnable pour un impact
|
|
if (blob.size < 10 || blob.size > 5000) return null; // Entre 10 et 5000 pixels
|
|
|
|
// Calculate fill ratio: actual pixels / bounding circle area
|
|
final boundingRadius = math.max(blob.radius, 1);
|
|
final boundingCircleArea = math.pi * boundingRadius * boundingRadius;
|
|
final fillRatio = (blob.size / boundingCircleArea).clamp(0.0, 1.0);
|
|
|
|
return _BlobAnalysis(
|
|
avgLuminance: avgLuminance,
|
|
size: blob.size,
|
|
circularity: blob.circularity,
|
|
fillRatio: fillRatio,
|
|
threshold: threshold.toDouble(),
|
|
);
|
|
}
|
|
|
|
/// Flood fill pour une zone locale
|
|
_Blob _floodFillLocal(
|
|
List<List<bool>> mask,
|
|
List<List<bool>> visited,
|
|
int startX,
|
|
int startY,
|
|
int width,
|
|
int height,
|
|
) {
|
|
final stack = <_Point>[_Point(startX, startY)];
|
|
final points = <_Point>[];
|
|
|
|
int minX = startX, maxX = startX;
|
|
int minY = startY, maxY = startY;
|
|
int perimeterCount = 0;
|
|
|
|
while (stack.isNotEmpty) {
|
|
final point = stack.removeLast();
|
|
final x = point.x;
|
|
final y = point.y;
|
|
|
|
if (x < 0 || x >= width || y < 0 || y >= height) continue;
|
|
if (visited[y][x] || !mask[y][x]) continue;
|
|
|
|
visited[y][x] = true;
|
|
points.add(point);
|
|
|
|
minX = math.min(minX, x);
|
|
maxX = math.max(maxX, x);
|
|
minY = math.min(minY, y);
|
|
maxY = math.max(maxY, y);
|
|
|
|
// Check if this is a perimeter pixel
|
|
bool isPerimeter = false;
|
|
for (final delta in [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
|
|
final nx = x + delta[0];
|
|
final ny = y + delta[1];
|
|
if (nx < 0 || nx >= width || ny < 0 || ny >= height || !mask[ny][nx]) {
|
|
isPerimeter = true;
|
|
break;
|
|
}
|
|
}
|
|
if (isPerimeter) perimeterCount++;
|
|
|
|
// Add neighbors (4-connectivity)
|
|
stack.add(_Point(x + 1, y));
|
|
stack.add(_Point(x - 1, y));
|
|
stack.add(_Point(x, y + 1));
|
|
stack.add(_Point(x, y - 1));
|
|
}
|
|
|
|
// Calculate centroid
|
|
double sumX = 0, sumY = 0;
|
|
for (final p in points) {
|
|
sumX += p.x;
|
|
sumY += p.y;
|
|
}
|
|
|
|
final centerX = points.isNotEmpty ? sumX / points.length : startX.toDouble();
|
|
final centerY = points.isNotEmpty ? sumY / points.length : startY.toDouble();
|
|
|
|
// Calculate bounding box dimensions
|
|
final blobWidth = (maxX - minX + 1).toDouble();
|
|
final blobHeight = (maxY - minY + 1).toDouble();
|
|
|
|
// Calculate approximate radius based on bounding box
|
|
final radius = math.max(blobWidth, blobHeight) / 2.0;
|
|
|
|
// Calculate circularity
|
|
final area = points.length.toDouble();
|
|
final perimeter = perimeterCount.toDouble();
|
|
final circularity = perimeter > 0
|
|
? (4 * math.pi * area) / (perimeter * perimeter)
|
|
: 0.0;
|
|
|
|
// Calculate aspect ratio
|
|
final aspectRatio = blobWidth > blobHeight
|
|
? blobWidth / blobHeight
|
|
: blobHeight / blobWidth;
|
|
|
|
// Calculate fill ratio
|
|
final boundingCircleArea = math.pi * radius * radius;
|
|
final fillRatio = boundingCircleArea > 0 ? (area / boundingCircleArea).clamp(0.0, 1.0) : 0.0;
|
|
|
|
return _Blob(
|
|
x: centerX,
|
|
y: centerY,
|
|
radius: radius,
|
|
size: points.length,
|
|
circularity: circularity.clamp(0.0, 1.0),
|
|
aspectRatio: aspectRatio,
|
|
fillRatio: fillRatio,
|
|
);
|
|
}
|
|
|
|
|
|
/// Detect impacts based on reference characteristics with tolerance
|
|
///
|
|
/// Utilise une approche multi-seuils adaptative pour une meilleure détection
|
|
List<DetectedImpact> detectImpactsFromReferences(
|
|
String imagePath,
|
|
ImpactCharacteristics characteristics, {
|
|
double tolerance = 2.0, // Number of standard deviations
|
|
double minCircularity = 0.3,
|
|
}) {
|
|
try {
|
|
final file = File(imagePath);
|
|
final bytes = file.readAsBytesSync();
|
|
final originalImage = img.decodeImage(bytes);
|
|
if (originalImage == null) return [];
|
|
|
|
// Resize for faster processing
|
|
img.Image image;
|
|
double scale = 1.0;
|
|
final maxDimension = 1200; // Augmenté pour plus de précision
|
|
if (originalImage.width > maxDimension || originalImage.height > maxDimension) {
|
|
scale = maxDimension / math.max(originalImage.width, originalImage.height);
|
|
image = img.copyResize(
|
|
originalImage,
|
|
width: (originalImage.width * scale).round(),
|
|
height: (originalImage.height * scale).round(),
|
|
);
|
|
} else {
|
|
image = originalImage;
|
|
}
|
|
|
|
final grayscale = img.grayscale(image);
|
|
final blurred = img.gaussianBlur(grayscale, radius: 2);
|
|
|
|
// AMÉLIORATION : Utiliser plusieurs seuils autour du seuil appris
|
|
final baseThreshold = characteristics.avgDarkThreshold.round();
|
|
|
|
// Générer une plage de seuils plus ciblée
|
|
final thresholds = <int>[];
|
|
final thresholdRange = (15 * tolerance).round(); // Plage modérée
|
|
for (int offset = -thresholdRange; offset <= thresholdRange; offset += 8) {
|
|
final t = (baseThreshold + offset).clamp(30, 150);
|
|
if (!thresholds.contains(t)) thresholds.add(t);
|
|
}
|
|
|
|
// Calculate size range based on learned characteristics
|
|
// Utiliser la variance mais avec des limites raisonnables
|
|
final sizeVariance = math.max(characteristics.sizeStdDev * tolerance, characteristics.avgSize * 0.4);
|
|
final minSize = math.max(20, (characteristics.avgSize - sizeVariance).round()); // Minimum 20 pixels
|
|
final maxSize = math.min(3000, (characteristics.avgSize + sizeVariance * 2).round()); // Maximum 3000 pixels
|
|
|
|
// Calculate minimum circularity - équilibré
|
|
final circularityTolerance = 0.2 * tolerance;
|
|
final effectiveMinCircularity = math.max(
|
|
characteristics.avgCircularity - circularityTolerance,
|
|
minCircularity,
|
|
).clamp(0.35, 0.85);
|
|
|
|
// Calculate minimum fill ratio - impacts pleins
|
|
final minFillRatio = (characteristics.avgFillRatio - 0.2).clamp(0.35, 0.85);
|
|
|
|
print('Detection params: thresholds=$thresholds, size=$minSize-$maxSize, '
|
|
'circ>=$effectiveMinCircularity, fill>=$minFillRatio');
|
|
|
|
// Détecter avec plusieurs seuils et combiner les résultats
|
|
final allBlobs = <_Blob>[];
|
|
|
|
for (final threshold in thresholds) {
|
|
final blobs = _detectDarkSpots(
|
|
blurred,
|
|
threshold,
|
|
minSize,
|
|
maxSize,
|
|
minCircularity: effectiveMinCircularity,
|
|
maxAspectRatio: 2.5, // Plus permissif
|
|
minFillRatio: minFillRatio,
|
|
);
|
|
allBlobs.addAll(blobs);
|
|
}
|
|
|
|
// Fusionner les blobs qui se chevauchent (même impact détecté à différents seuils)
|
|
final mergedBlobs = _mergeOverlappingBlobs(allBlobs);
|
|
|
|
// FILTRE POST-DÉTECTION : Garder seulement les blobs similaires aux références
|
|
// Le filtre est plus ou moins strict selon la tolérance
|
|
final sizeToleranceFactor = 0.3 + (tolerance - 1) * 0.3; // 0.3 à 1.5 selon tolérance
|
|
final minSizeRatio = math.max(0.15, 1 / (1 + sizeToleranceFactor * 3));
|
|
final maxSizeRatio = 1 + sizeToleranceFactor * 4;
|
|
|
|
final filteredBlobs = mergedBlobs.where((blob) {
|
|
// Vérifier la taille par rapport aux caractéristiques apprises
|
|
final sizeRatio = blob.size / characteristics.avgSize;
|
|
if (sizeRatio < minSizeRatio || sizeRatio > maxSizeRatio) return false;
|
|
|
|
// Vérifier la circularité (légèrement relaxée)
|
|
if (blob.circularity < effectiveMinCircularity * 0.85) return false;
|
|
|
|
// Vérifier le fill ratio
|
|
if (blob.fillRatio < minFillRatio * 0.9) return false;
|
|
|
|
return true;
|
|
}).toList();
|
|
|
|
print('Found ${filteredBlobs.length} impacts after filtering (from ${mergedBlobs.length} merged)');
|
|
|
|
// Convert to relative coordinates
|
|
return filteredBlobs.map((blob) {
|
|
return DetectedImpact(
|
|
x: blob.x / image.width,
|
|
y: blob.y / image.height,
|
|
radius: blob.radius / scale,
|
|
);
|
|
}).toList();
|
|
} catch (e) {
|
|
print('Error detecting impacts from references: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Fusionne les blobs qui se chevauchent en gardant le meilleur représentant
|
|
List<_Blob> _mergeOverlappingBlobs(List<_Blob> blobs) {
|
|
if (blobs.isEmpty) return [];
|
|
|
|
// Trier par score de qualité (circularité * fillRatio)
|
|
final sortedBlobs = List<_Blob>.from(blobs);
|
|
sortedBlobs.sort((a, b) {
|
|
final scoreA = a.circularity * a.fillRatio * a.size;
|
|
final scoreB = b.circularity * b.fillRatio * b.size;
|
|
return scoreB.compareTo(scoreA);
|
|
});
|
|
|
|
final merged = <_Blob>[];
|
|
|
|
for (final blob in sortedBlobs) {
|
|
bool shouldAdd = true;
|
|
|
|
for (final existing in merged) {
|
|
final dx = blob.x - existing.x;
|
|
final dy = blob.y - existing.y;
|
|
final distance = math.sqrt(dx * dx + dy * dy);
|
|
final minDist = math.min(blob.radius, existing.radius);
|
|
|
|
// Si les centres sont proches, c'est le même impact
|
|
if (distance < minDist * 1.5) {
|
|
shouldAdd = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (shouldAdd) {
|
|
merged.add(blob);
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
/// Detect dark spots with adaptive luminance range
|
|
List<_Blob> _detectDarkSpotsAdaptive(
|
|
img.Image image,
|
|
int minLuminance,
|
|
int maxLuminance,
|
|
int minSize,
|
|
int maxSize, {
|
|
double minCircularity = 0.5,
|
|
double minFillRatio = 0.5,
|
|
}) {
|
|
final width = image.width;
|
|
final height = image.height;
|
|
|
|
// Create binary mask of pixels within luminance range
|
|
final mask = List.generate(height, (_) => List.filled(width, false));
|
|
|
|
for (int y = 0; y < height; y++) {
|
|
for (int x = 0; x < width; x++) {
|
|
final pixel = image.getPixel(x, y);
|
|
final luminance = img.getLuminance(pixel);
|
|
mask[y][x] = luminance >= minLuminance && luminance <= maxLuminance;
|
|
}
|
|
}
|
|
|
|
// Find connected components
|
|
final visited = List.generate(height, (_) => List.filled(width, false));
|
|
final blobs = <_Blob>[];
|
|
|
|
for (int y = 0; y < height; y++) {
|
|
for (int x = 0; x < width; x++) {
|
|
if (mask[y][x] && !visited[y][x]) {
|
|
final blob = _floodFill(mask, visited, x, y, width, height);
|
|
if (blob.size >= minSize &&
|
|
blob.size <= maxSize &&
|
|
blob.circularity >= minCircularity &&
|
|
blob.fillRatio >= minFillRatio) {
|
|
blobs.add(blob);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return _filterOverlappingBlobs(blobs);
|
|
}
|
|
|
|
/// Detect dark spots in a grayscale image using blob detection
|
|
List<_Blob> _detectDarkSpots(
|
|
img.Image image,
|
|
int threshold,
|
|
int minSize,
|
|
int maxSize, {
|
|
double minCircularity = 0.6,
|
|
double maxAspectRatio = 2.0,
|
|
double minFillRatio = 0.5,
|
|
}) {
|
|
final width = image.width;
|
|
final height = image.height;
|
|
|
|
// Create binary mask of dark pixels
|
|
final mask = List.generate(height, (_) => List.filled(width, false));
|
|
|
|
for (int y = 0; y < height; y++) {
|
|
for (int x = 0; x < width; x++) {
|
|
final pixel = image.getPixel(x, y);
|
|
final luminance = img.getLuminance(pixel);
|
|
mask[y][x] = luminance < threshold;
|
|
}
|
|
}
|
|
|
|
// Find connected components (blobs)
|
|
final visited = List.generate(height, (_) => List.filled(width, false));
|
|
final blobs = <_Blob>[];
|
|
|
|
for (int y = 0; y < height; y++) {
|
|
for (int x = 0; x < width; x++) {
|
|
if (mask[y][x] && !visited[y][x]) {
|
|
final blob = _floodFill(mask, visited, x, y, width, height);
|
|
|
|
// Filter by size
|
|
if (blob.size < minSize || blob.size > maxSize) continue;
|
|
|
|
// Filter by circularity (reject non-circular shapes like numbers)
|
|
if (blob.circularity < minCircularity) continue;
|
|
|
|
// Filter by aspect ratio (reject elongated shapes)
|
|
if (blob.aspectRatio > maxAspectRatio) continue;
|
|
|
|
// Filter by fill ratio (reject hollow rings - numbers on target)
|
|
// A filled bullet hole should have fill ratio > 0.5
|
|
// A hollow ring (like number "0" or "8") has a much lower fill ratio
|
|
if (blob.fillRatio < minFillRatio) continue;
|
|
|
|
blobs.add(blob);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filter overlapping blobs (keep larger ones)
|
|
final filteredBlobs = _filterOverlappingBlobs(blobs);
|
|
|
|
return filteredBlobs;
|
|
}
|
|
|
|
/// Flood fill to find connected component
|
|
_Blob _floodFill(
|
|
List<List<bool>> mask,
|
|
List<List<bool>> visited,
|
|
int startX,
|
|
int startY,
|
|
int width,
|
|
int height,
|
|
) {
|
|
final stack = <_Point>[_Point(startX, startY)];
|
|
final points = <_Point>[];
|
|
|
|
int minX = startX, maxX = startX;
|
|
int minY = startY, maxY = startY;
|
|
int perimeterCount = 0;
|
|
|
|
while (stack.isNotEmpty) {
|
|
final point = stack.removeLast();
|
|
final x = point.x;
|
|
final y = point.y;
|
|
|
|
if (x < 0 || x >= width || y < 0 || y >= height) continue;
|
|
if (visited[y][x] || !mask[y][x]) continue;
|
|
|
|
visited[y][x] = true;
|
|
points.add(point);
|
|
|
|
minX = math.min(minX, x);
|
|
maxX = math.max(maxX, x);
|
|
minY = math.min(minY, y);
|
|
maxY = math.max(maxY, y);
|
|
|
|
// Check if this is a perimeter pixel (has at least one non-blob neighbor)
|
|
bool isPerimeter = false;
|
|
for (final delta in [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
|
|
final nx = x + delta[0];
|
|
final ny = y + delta[1];
|
|
if (nx < 0 || nx >= width || ny < 0 || ny >= height || !mask[ny][nx]) {
|
|
isPerimeter = true;
|
|
break;
|
|
}
|
|
}
|
|
if (isPerimeter) perimeterCount++;
|
|
|
|
// Add neighbors (4-connectivity)
|
|
stack.add(_Point(x + 1, y));
|
|
stack.add(_Point(x - 1, y));
|
|
stack.add(_Point(x, y + 1));
|
|
stack.add(_Point(x, y - 1));
|
|
}
|
|
|
|
// Calculate centroid
|
|
double sumX = 0, sumY = 0;
|
|
for (final p in points) {
|
|
sumX += p.x;
|
|
sumY += p.y;
|
|
}
|
|
|
|
final centerX = points.isNotEmpty ? sumX / points.length : startX.toDouble();
|
|
final centerY = points.isNotEmpty ? sumY / points.length : startY.toDouble();
|
|
|
|
// Calculate bounding box dimensions
|
|
final blobWidth = (maxX - minX + 1).toDouble();
|
|
final blobHeight = (maxY - minY + 1).toDouble();
|
|
|
|
// Calculate approximate radius based on bounding box
|
|
final radius = math.max(blobWidth, blobHeight) / 2.0;
|
|
|
|
// Calculate circularity: 4 * pi * area / perimeter^2
|
|
// For a perfect circle, this equals 1
|
|
final area = points.length.toDouble();
|
|
final perimeter = perimeterCount.toDouble();
|
|
final circularity = perimeter > 0
|
|
? (4 * math.pi * area) / (perimeter * perimeter)
|
|
: 0.0;
|
|
|
|
// Calculate aspect ratio (always >= 1)
|
|
final aspectRatio = blobWidth > blobHeight
|
|
? blobWidth / blobHeight
|
|
: blobHeight / blobWidth;
|
|
|
|
// Calculate fill ratio: actual area vs bounding circle area
|
|
// A filled circle has fill ratio ~0.78 (pi/4), a ring/hollow circle has much lower
|
|
final boundingCircleArea = math.pi * radius * radius;
|
|
final fillRatio = boundingCircleArea > 0 ? (area / boundingCircleArea).clamp(0.0, 1.0) : 0.0;
|
|
|
|
return _Blob(
|
|
x: centerX,
|
|
y: centerY,
|
|
radius: radius,
|
|
size: points.length,
|
|
circularity: circularity.clamp(0.0, 1.0),
|
|
aspectRatio: aspectRatio,
|
|
fillRatio: fillRatio,
|
|
);
|
|
}
|
|
|
|
/// Filter overlapping blobs, keeping the larger ones
|
|
List<_Blob> _filterOverlappingBlobs(List<_Blob> blobs) {
|
|
if (blobs.isEmpty) return [];
|
|
|
|
// Sort by size (largest first)
|
|
blobs.sort((a, b) => b.size.compareTo(a.size));
|
|
|
|
final filtered = <_Blob>[];
|
|
|
|
for (final blob in blobs) {
|
|
bool overlaps = false;
|
|
|
|
for (final existing in filtered) {
|
|
final dx = blob.x - existing.x;
|
|
final dy = blob.y - existing.y;
|
|
final distance = math.sqrt(dx * dx + dy * dy);
|
|
|
|
// Check if blobs overlap
|
|
if (distance < (blob.radius + existing.radius) * 0.8) {
|
|
overlaps = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!overlaps) {
|
|
filtered.add(blob);
|
|
}
|
|
}
|
|
|
|
return filtered;
|
|
}
|
|
}
|
|
|
|
class _Point {
|
|
final int x;
|
|
final int y;
|
|
|
|
_Point(this.x, this.y);
|
|
}
|
|
|
|
class _Blob {
|
|
final double x;
|
|
final double y;
|
|
final double radius;
|
|
final int size;
|
|
final double circularity; // 0-1, 1 = perfect circle
|
|
final double aspectRatio; // width/height ratio
|
|
final double fillRatio; // How filled vs hollow the blob is
|
|
|
|
_Blob({
|
|
required this.x,
|
|
required this.y,
|
|
required this.radius,
|
|
required this.size,
|
|
required this.circularity,
|
|
required this.aspectRatio,
|
|
this.fillRatio = 1.0,
|
|
});
|
|
}
|
|
|
|
class _BlobAnalysis {
|
|
final double avgLuminance;
|
|
final int size;
|
|
final double circularity;
|
|
final double fillRatio;
|
|
final double threshold;
|
|
|
|
_BlobAnalysis({
|
|
required this.avgLuminance,
|
|
required this.size,
|
|
required this.circularity,
|
|
required this.fillRatio,
|
|
required this.threshold,
|
|
});
|
|
}
|