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 detectImpacts(String imagePath) { return detectImpactsWithSettings( imagePath, const ImpactDetectionSettings(), ); } /// Detect impacts with custom settings List 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 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 = []; final sizes = []; final circularities = []; final fillRatios = []; final thresholds = []; 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 = []; // 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> mask, List> 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 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 = []; 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> mask, List> 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, }); }