premier app version beta

This commit is contained in:
2026-01-18 13:38:09 +01:00
commit 031d4a4e17
164 changed files with 13698 additions and 0 deletions

View File

@@ -0,0 +1,230 @@
import 'dart:math' as math;
import '../data/models/shot.dart';
class GroupingResult {
final double centerX; // Center of the group (relative 0-1)
final double centerY;
final double diameter; // Maximum spread diameter (relative 0-1)
final double standardDeviation; // Dispersion metric
final double meanRadius; // Average distance from center
final int shotCount;
GroupingResult({
required this.centerX,
required this.centerY,
required this.diameter,
required this.standardDeviation,
required this.meanRadius,
required this.shotCount,
});
/// Grouping quality rating (1-5 stars)
int get qualityRating {
// Based on standard deviation relative to typical target size
if (standardDeviation < 0.02) return 5;
if (standardDeviation < 0.04) return 4;
if (standardDeviation < 0.06) return 3;
if (standardDeviation < 0.10) return 2;
return 1;
}
String get qualityDescription {
switch (qualityRating) {
case 5:
return 'Excellent';
case 4:
return 'Tres bien';
case 3:
return 'Bien';
case 2:
return 'Moyen';
default:
return 'A ameliorer';
}
}
factory GroupingResult.empty() {
return GroupingResult(
centerX: 0.5,
centerY: 0.5,
diameter: 0,
standardDeviation: 0,
meanRadius: 0,
shotCount: 0,
);
}
}
class GroupingAnalyzerService {
/// Analyze the grouping of a list of shots
GroupingResult analyzeGrouping(List<Shot> shots) {
if (shots.isEmpty) {
return GroupingResult.empty();
}
if (shots.length == 1) {
return GroupingResult(
centerX: shots.first.x,
centerY: shots.first.y,
diameter: 0,
standardDeviation: 0,
meanRadius: 0,
shotCount: 1,
);
}
// Calculate center of group (centroid)
double sumX = 0;
double sumY = 0;
for (final shot in shots) {
sumX += shot.x;
sumY += shot.y;
}
final centerX = sumX / shots.length;
final centerY = sumY / shots.length;
// Calculate distances from center
final distances = <double>[];
for (final shot in shots) {
final dx = shot.x - centerX;
final dy = shot.y - centerY;
distances.add(math.sqrt(dx * dx + dy * dy));
}
// Calculate mean radius
final meanRadius = distances.reduce((a, b) => a + b) / distances.length;
// Calculate standard deviation
double sumSquaredDiff = 0;
for (final distance in distances) {
sumSquaredDiff += math.pow(distance - meanRadius, 2);
}
final standardDeviation = math.sqrt(sumSquaredDiff / distances.length);
// Calculate maximum spread (diameter)
// Find the two points that are farthest apart
double maxDistance = 0;
for (int i = 0; i < shots.length; i++) {
for (int j = i + 1; j < shots.length; j++) {
final dx = shots[i].x - shots[j].x;
final dy = shots[i].y - shots[j].y;
final distance = math.sqrt(dx * dx + dy * dy);
if (distance > maxDistance) {
maxDistance = distance;
}
}
}
return GroupingResult(
centerX: centerX,
centerY: centerY,
diameter: maxDistance,
standardDeviation: standardDeviation,
meanRadius: meanRadius,
shotCount: shots.length,
);
}
/// Calculate offset from target center
(double, double) calculateOffset({
required double groupCenterX,
required double groupCenterY,
required double targetCenterX,
required double targetCenterY,
}) {
return (
groupCenterX - targetCenterX,
groupCenterY - targetCenterY,
);
}
/// Get directional offset description (e.g., "haut-gauche")
String getOffsetDescription(double offsetX, double offsetY) {
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
return 'Centre';
}
String vertical = '';
String horizontal = '';
if (offsetY < -0.02) {
vertical = 'Haut';
} else if (offsetY > 0.02) {
vertical = 'Bas';
}
if (offsetX < -0.02) {
horizontal = 'Gauche';
} else if (offsetX > 0.02) {
horizontal = 'Droite';
}
if (vertical.isNotEmpty && horizontal.isNotEmpty) {
return '$vertical-$horizontal';
}
return vertical.isNotEmpty ? vertical : horizontal;
}
/// Analyze trend across multiple sessions
GroupingTrend analyzeTrend(List<GroupingResult> results) {
if (results.length < 2) {
return GroupingTrend(
improving: false,
averageDiameter: results.isEmpty ? 0 : results.first.diameter,
recentDiameter: results.isEmpty ? 0 : results.first.diameter,
improvementPercentage: 0,
);
}
// Calculate averages for first half vs second half
final midpoint = results.length ~/ 2;
final firstHalf = results.sublist(0, midpoint);
final secondHalf = results.sublist(midpoint);
double firstHalfAvg = 0;
for (final r in firstHalf) {
firstHalfAvg += r.diameter;
}
firstHalfAvg /= firstHalf.length;
double secondHalfAvg = 0;
for (final r in secondHalf) {
secondHalfAvg += r.diameter;
}
secondHalfAvg /= secondHalf.length;
// Overall average
double totalAvg = 0;
for (final r in results) {
totalAvg += r.diameter;
}
totalAvg /= results.length;
final improvement = ((firstHalfAvg - secondHalfAvg) / firstHalfAvg) * 100;
return GroupingTrend(
improving: secondHalfAvg < firstHalfAvg,
averageDiameter: totalAvg,
recentDiameter: secondHalfAvg,
improvementPercentage: improvement,
);
}
}
class GroupingTrend {
final bool improving;
final double averageDiameter;
final double recentDiameter;
final double improvementPercentage;
GroupingTrend({
required this.improving,
required this.averageDiameter,
required this.recentDiameter,
required this.improvementPercentage,
});
}

View File

@@ -0,0 +1,775 @@
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
ImpactCharacteristics? analyzeReferenceImpacts(
String imagePath,
List<ReferenceImpact> references, {
int searchRadius = 30,
}) {
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
img.Image image;
double scale = 1.0;
final maxDimension = 1000;
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>[];
for (final ref in references) {
final centerX = (ref.x * width).round().clamp(0, width - 1);
final centerY = (ref.y * height).round().clamp(0, height - 1);
// Find the darkest point in the search area (the center of the impact)
int darkestX = centerX;
int darkestY = centerY;
double darkestLum = 255;
for (int dy = -searchRadius; dy <= searchRadius; dy++) {
for (int dx = -searchRadius; dx <= searchRadius; dx++) {
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;
}
}
}
// Now find the blob at the darkest point using adaptive threshold
// Start from the darkest point and expand until we find the boundary
final blobResult = _findBlobAtPoint(blurred, darkestX, darkestY, width, height);
if (blobResult != null) {
luminances.add(blobResult.avgLuminance);
sizes.add(blobResult.size.toDouble());
circularities.add(blobResult.circularity);
fillRatios.add(blobResult.fillRatio);
thresholds.add(blobResult.threshold);
}
}
if (luminances.isEmpty) 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);
final sizeStdDev = math.sqrt(sizeVariance / sizes.length);
return ImpactCharacteristics(
avgLuminance: avgLum,
luminanceStdDev: lumStdDev,
avgSize: avgSize,
sizeStdDev: sizeStdDev,
avgCircularity: avgCirc,
avgFillRatio: avgFill,
avgDarkThreshold: avgThreshold,
);
} catch (e) {
print('Error analyzing reference impacts: $e');
return null;
}
}
/// Find a blob at a specific point and extract its characteristics
_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();
// Find the threshold by looking at the luminance gradient around the point
// Sample in expanding circles to find where the blob ends
double sumLum = centerLum;
int pixelCount = 1;
double maxRadius = 0;
// Sample at different radii to find the edge
for (int r = 1; r <= 50; r++) {
double ringSum = 0;
int ringCount = 0;
// Sample points on a ring
for (int i = 0; i < 16; i++) {
final angle = (i / 16) * 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;
// If the ring is significantly brighter than the center, we've found the edge
if (avgRingLum > centerLum + 40) {
maxRadius = r.toDouble();
break;
}
sumLum += ringSum;
pixelCount += ringCount;
}
}
if (maxRadius < 3) return null; // Too small to be a valid blob
// Calculate threshold as the midpoint between center and edge luminance
final edgeRadius = (maxRadius * 1.2).round();
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;
}
final threshold = ((centerLum + edgeLum) / 2).round();
// Now do a flood fill with this threshold to get the actual blob
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 lum = img.getLuminance(pixel);
mask[y][x] = lum < threshold;
}
}
final visited = List.generate(height, (_) => List.filled(width, false));
// Find the blob containing the start point
if (!mask[startY][startX]) {
// Start point might not be in mask, find nearest point that is
for (int r = 1; r <= 10; r++) {
bool found = false;
for (int dy = -r; dy <= r && !found; dy++) {
for (int dx = -r; dx <= r && !found; dx++) {
final px = startX + dx;
final py = startY + dy;
if (px >= 0 && px < width && py >= 0 && py < height && mask[py][px]) {
final blob = _floodFill(mask, visited, px, py, width, height);
// 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: sumLum / pixelCount,
size: blob.size,
circularity: blob.circularity,
fillRatio: fillRatio,
threshold: threshold.toDouble(),
);
}
}
}
}
return null;
}
final blob = _floodFill(mask, visited, startX, startY, width, height);
// Calculate fill ratio
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: sumLum / pixelCount,
size: blob.size,
circularity: blob.circularity,
fillRatio: fillRatio,
threshold: threshold.toDouble(),
);
}
/// Detect impacts based on reference characteristics with tolerance
List<DetectedImpact> detectImpactsFromReferences(
String imagePath,
ImpactCharacteristics characteristics, {
double tolerance = 2.0, // Number of standard deviations
double minCircularity = 0.4,
}) {
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 = 1000;
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);
// Use the threshold learned from references
final threshold = characteristics.avgDarkThreshold.round();
// Calculate size range based on learned characteristics
final minSize = (characteristics.avgSize / (tolerance * 2)).clamp(5, 10000).round();
final maxSize = (characteristics.avgSize * tolerance * 2).clamp(10, 10000).round();
// Calculate minimum fill ratio based on learned characteristics
// Allow some variance but still filter out hollow shapes
final minFillRatio = (characteristics.avgFillRatio - 0.2).clamp(0.3, 0.9);
// Detect blobs using the learned threshold
final impacts = _detectDarkSpots(
blurred,
threshold,
minSize,
maxSize,
minCircularity: math.max(characteristics.avgCircularity - 0.2, minCircularity),
minFillRatio: minFillRatio,
);
// Convert to relative coordinates
final width = originalImage.width.toDouble();
final height = originalImage.height.toDouble();
return impacts.map((impact) {
return DetectedImpact(
x: impact.x / image.width,
y: impact.y / image.height,
radius: impact.radius / scale,
);
}).toList();
} catch (e) {
print('Error detecting impacts from references: $e');
return [];
}
}
/// 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,
});
}

View File

@@ -0,0 +1,220 @@
import 'dart:math' as math;
import '../data/models/shot.dart';
import '../data/models/target_type.dart';
import '../core/constants/app_constants.dart';
class ScoreResult {
final int totalScore;
final int maxPossibleScore;
final double percentage;
final Map<int, int> scoreDistribution; // score -> count
final int shotCount;
ScoreResult({
required this.totalScore,
required this.maxPossibleScore,
required this.percentage,
required this.scoreDistribution,
required this.shotCount,
});
}
class ScoreCalculatorService {
/// Calculate score for a single shot on a concentric target
/// ringCount determines the number of scoring zones (default 10)
/// Center is always 10 (bullseye), each ring decrements by 1
/// Example: 5 rings = 10, 9, 8, 7, 6
/// imageAspectRatio is width/height to account for non-square images
int calculateConcentricScore({
required double shotX,
required double shotY,
required double targetCenterX,
required double targetCenterY,
required double targetRadius,
int ringCount = 10,
double imageAspectRatio = 1.0,
List<double>? ringRadii, // Optional individual ring radii multipliers
}) {
final dx = shotX - targetCenterX;
final dy = shotY - targetCenterY;
// Account for aspect ratio to match visual representation
// The visual uses min(width, height) for the radius
double normalizedDistance;
if (imageAspectRatio >= 1.0) {
// Landscape or square: scale x by aspectRatio
normalizedDistance = math.sqrt(dx * dx * imageAspectRatio * imageAspectRatio + dy * dy) / targetRadius;
} else {
// Portrait: scale y by 1/aspectRatio
normalizedDistance = math.sqrt(dx * dx + dy * dy / (imageAspectRatio * imageAspectRatio)) / targetRadius;
}
// Use custom ring radii if provided, otherwise use equal spacing
if (ringRadii != null && ringRadii.length == ringCount) {
for (int i = 0; i < ringCount; i++) {
if (normalizedDistance <= ringRadii[i]) {
// Center = 10, decrement by 1 for each ring
return 10 - i;
}
}
} else {
// Generate dynamic zone radii based on ring count
// Each zone has equal width
for (int i = 0; i < ringCount; i++) {
final zoneRadius = (i + 1) / ringCount;
if (normalizedDistance <= zoneRadius) {
// Center = 10, decrement by 1 for each ring
return 10 - i;
}
}
}
return 0; // Outside target
}
/// Calculate score for a single shot on a silhouette target
int calculateSilhouetteScore({
required double shotX,
required double shotY,
required double targetCenterX,
required double targetCenterY,
required double targetWidth,
required double targetHeight,
}) {
// Check if shot is within silhouette bounds
final relativeX = (shotX - targetCenterX).abs() / (targetWidth / 2);
final relativeY = (shotY - (targetCenterY - targetHeight / 2)) / targetHeight;
// Outside horizontal bounds
if (relativeX > 1.0) return 0;
// Check vertical zones (from top of silhouette)
if (relativeY < 0) return 0; // Above silhouette
if (relativeY <= AppConstants.silhouetteZones['head']!) {
// Head zone - 5 points, but narrower
if (relativeX <= 0.4) return 5;
} else if (relativeY <= AppConstants.silhouetteZones['center']!) {
// Center mass - 5 points
if (relativeX <= 0.6) return 5;
} else if (relativeY <= AppConstants.silhouetteZones['body']!) {
// Body - 4 points
if (relativeX <= 0.7) return 4;
} else if (relativeY <= AppConstants.silhouetteZones['lower']!) {
// Lower body - 3 points
if (relativeX <= 0.5) return 3;
}
return 0; // Outside target
}
/// Calculate scores for all shots
ScoreResult calculateScores({
required List<Shot> shots,
required TargetType targetType,
required double targetCenterX,
required double targetCenterY,
double? targetRadius, // For concentric
double? targetWidth, // For silhouette
double? targetHeight, // For silhouette
int ringCount = 10, // For concentric
double imageAspectRatio = 1.0, // For concentric
List<double>? ringRadii, // For concentric with individual ring radii
}) {
final scoreDistribution = <int, int>{};
int totalScore = 0;
for (final shot in shots) {
int score;
if (targetType == TargetType.concentric) {
score = calculateConcentricScore(
shotX: shot.x,
shotY: shot.y,
targetCenterX: targetCenterX,
targetCenterY: targetCenterY,
targetRadius: targetRadius ?? 0.4,
ringCount: ringCount,
imageAspectRatio: imageAspectRatio,
ringRadii: ringRadii,
);
} else {
score = calculateSilhouetteScore(
shotX: shot.x,
shotY: shot.y,
targetCenterX: targetCenterX,
targetCenterY: targetCenterY,
targetWidth: targetWidth ?? 0.3,
targetHeight: targetHeight ?? 0.7,
);
}
totalScore += score;
scoreDistribution[score] = (scoreDistribution[score] ?? 0) + 1;
}
final maxScore = targetType == TargetType.concentric ? 10 : 5;
final maxPossibleScore = shots.length * maxScore;
final percentage = maxPossibleScore > 0
? (totalScore / maxPossibleScore) * 100
: 0.0;
return ScoreResult(
totalScore: totalScore,
maxPossibleScore: maxPossibleScore,
percentage: percentage,
scoreDistribution: scoreDistribution,
shotCount: shots.length,
);
}
/// Recalculate a shot's score
Shot recalculateShot({
required Shot shot,
required TargetType targetType,
required double targetCenterX,
required double targetCenterY,
double? targetRadius,
double? targetWidth,
double? targetHeight,
int ringCount = 10,
double imageAspectRatio = 1.0,
List<double>? ringRadii,
}) {
int newScore;
if (targetType == TargetType.concentric) {
newScore = calculateConcentricScore(
shotX: shot.x,
shotY: shot.y,
targetCenterX: targetCenterX,
targetCenterY: targetCenterY,
targetRadius: targetRadius ?? 0.4,
ringCount: ringCount,
imageAspectRatio: imageAspectRatio,
ringRadii: ringRadii,
);
} else {
newScore = calculateSilhouetteScore(
shotX: shot.x,
shotY: shot.y,
targetCenterX: targetCenterX,
targetCenterY: targetCenterY,
targetWidth: targetWidth ?? 0.3,
targetHeight: targetHeight ?? 0.7,
);
}
return shot.copyWith(score: newScore);
}
/// Get score color index (0-9 for zone colors)
int getScoreColorIndex(int score, TargetType targetType) {
if (targetType == TargetType.concentric) {
return (10 - score).clamp(0, 9);
} else {
// Map silhouette scores (0-5) to color indices
return ((5 - score) * 2).clamp(0, 9);
}
}
}

View File

@@ -0,0 +1,531 @@
import 'dart:math' as math;
import '../data/models/session.dart';
import '../data/models/shot.dart';
/// Time period for filtering statistics
enum StatsPeriod {
session, // Single session
week, // Last 7 days
month, // Last 30 days
all, // All time
}
/// Heat zone data for a region of the target
class HeatZone {
final int row;
final int col;
final int shotCount;
final double intensity; // 0-1, normalized
final double avgScore;
const HeatZone({
required this.row,
required this.col,
required this.shotCount,
required this.intensity,
required this.avgScore,
});
}
/// Heat map grid for the target
class HeatMap {
final int gridSize;
final List<List<HeatZone>> zones;
final int maxShotsInZone;
final int totalShots;
const HeatMap({
required this.gridSize,
required this.zones,
required this.maxShotsInZone,
required this.totalShots,
});
}
/// Precision statistics
class PrecisionStats {
/// Average distance from target center (0-1 normalized)
final double avgDistanceFromCenter;
/// Grouping diameter (spread of shots)
final double groupingDiameter;
/// Precision score (0-100, higher = better)
final double precisionScore;
/// Consistency score based on standard deviation (0-100)
final double consistencyScore;
const PrecisionStats({
required this.avgDistanceFromCenter,
required this.groupingDiameter,
required this.precisionScore,
required this.consistencyScore,
});
}
/// Standard deviation statistics
class StdDevStats {
/// Standard deviation of X positions
final double stdDevX;
/// Standard deviation of Y positions
final double stdDevY;
/// Combined standard deviation (radial)
final double stdDevRadial;
/// Standard deviation of scores
final double stdDevScore;
/// Mean X position
final double meanX;
/// Mean Y position
final double meanY;
/// Mean score
final double meanScore;
const StdDevStats({
required this.stdDevX,
required this.stdDevY,
required this.stdDevRadial,
required this.stdDevScore,
required this.meanX,
required this.meanY,
required this.meanScore,
});
}
/// Regional distribution (quadrants or sectors)
class RegionalStats {
/// Shot distribution by quadrant (top-left, top-right, bottom-left, bottom-right)
final Map<String, int> quadrantDistribution;
/// Shot distribution by sector (N, NE, E, SE, S, SW, W, NW, Center)
final Map<String, int> sectorDistribution;
/// Dominant direction (where most shots land)
final String dominantDirection;
/// Bias offset from center
final double biasX;
final double biasY;
const RegionalStats({
required this.quadrantDistribution,
required this.sectorDistribution,
required this.dominantDirection,
required this.biasX,
required this.biasY,
});
}
/// Complete statistics result
class SessionStatistics {
final int totalShots;
final int totalScore;
final double avgScore;
final int maxScore;
final int minScore;
final HeatMap heatMap;
final PrecisionStats precision;
final StdDevStats stdDev;
final RegionalStats regional;
final List<Session> sessions;
final StatsPeriod period;
const SessionStatistics({
required this.totalShots,
required this.totalScore,
required this.avgScore,
required this.maxScore,
required this.minScore,
required this.heatMap,
required this.precision,
required this.stdDev,
required this.regional,
required this.sessions,
required this.period,
});
}
/// Service for calculating shooting statistics
class StatisticsService {
/// Calculate statistics for given sessions
SessionStatistics calculateStatistics(
List<Session> sessions, {
StatsPeriod period = StatsPeriod.all,
double targetCenterX = 0.5,
double targetCenterY = 0.5,
}) {
// Filter sessions by period
final filteredSessions = _filterByPeriod(sessions, period);
// Collect all shots
final allShots = <Shot>[];
for (final session in filteredSessions) {
allShots.addAll(session.shots);
}
if (allShots.isEmpty) {
return _emptyStatistics(period, filteredSessions);
}
// Calculate basic stats
final totalShots = allShots.length;
final totalScore = allShots.fold<int>(0, (sum, shot) => sum + shot.score);
final avgScore = totalScore / totalShots;
final maxScore = allShots.map((s) => s.score).reduce(math.max);
final minScore = allShots.map((s) => s.score).reduce(math.min);
// Calculate heat map
final heatMap = _calculateHeatMap(allShots, gridSize: 5);
// Calculate precision
final precision = _calculatePrecision(allShots, targetCenterX, targetCenterY);
// Calculate standard deviation
final stdDev = _calculateStdDev(allShots);
// Calculate regional distribution
final regional = _calculateRegional(allShots, targetCenterX, targetCenterY);
return SessionStatistics(
totalShots: totalShots,
totalScore: totalScore,
avgScore: avgScore,
maxScore: maxScore,
minScore: minScore,
heatMap: heatMap,
precision: precision,
stdDev: stdDev,
regional: regional,
sessions: filteredSessions,
period: period,
);
}
/// Filter sessions by time period
List<Session> _filterByPeriod(List<Session> sessions, StatsPeriod period) {
if (period == StatsPeriod.all) return sessions;
final now = DateTime.now();
final cutoff = switch (period) {
StatsPeriod.session => now.subtract(const Duration(hours: 24)),
StatsPeriod.week => now.subtract(const Duration(days: 7)),
StatsPeriod.month => now.subtract(const Duration(days: 30)),
StatsPeriod.all => DateTime(1970),
};
return sessions.where((s) => s.createdAt.isAfter(cutoff)).toList();
}
/// Calculate heat map
HeatMap _calculateHeatMap(List<Shot> shots, {int gridSize = 5}) {
// Initialize grid
final grid = List.generate(
gridSize,
(_) => List.generate(gridSize, (_) => <Shot>[]),
);
// Assign shots to grid cells
for (final shot in shots) {
final col = (shot.x * gridSize).floor().clamp(0, gridSize - 1);
final row = (shot.y * gridSize).floor().clamp(0, gridSize - 1);
grid[row][col].add(shot);
}
// Find max count for normalization
int maxCount = 0;
for (final row in grid) {
for (final cell in row) {
if (cell.length > maxCount) maxCount = cell.length;
}
}
// Create heat zones
final zones = <List<HeatZone>>[];
for (int row = 0; row < gridSize; row++) {
final rowZones = <HeatZone>[];
for (int col = 0; col < gridSize; col++) {
final cellShots = grid[row][col];
final avgScore = cellShots.isEmpty
? 0.0
: cellShots.fold<int>(0, (sum, s) => sum + s.score) / cellShots.length;
rowZones.add(HeatZone(
row: row,
col: col,
shotCount: cellShots.length,
intensity: maxCount > 0 ? cellShots.length / maxCount : 0,
avgScore: avgScore,
));
}
zones.add(rowZones);
}
return HeatMap(
gridSize: gridSize,
zones: zones,
maxShotsInZone: maxCount,
totalShots: shots.length,
);
}
/// Calculate precision statistics
PrecisionStats _calculatePrecision(
List<Shot> shots,
double centerX,
double centerY,
) {
if (shots.isEmpty) {
return const PrecisionStats(
avgDistanceFromCenter: 0,
groupingDiameter: 0,
precisionScore: 0,
consistencyScore: 0,
);
}
// Calculate distances from center
final distances = shots.map((shot) {
final dx = shot.x - centerX;
final dy = shot.y - centerY;
return math.sqrt(dx * dx + dy * dy);
}).toList();
final avgDistance = distances.reduce((a, b) => a + b) / distances.length;
// Calculate grouping (spread between shots)
double maxSpread = 0;
for (int i = 0; i < shots.length; i++) {
for (int j = i + 1; j < shots.length; j++) {
final dx = shots[i].x - shots[j].x;
final dy = shots[i].y - shots[j].y;
final dist = math.sqrt(dx * dx + dy * dy);
if (dist > maxSpread) maxSpread = dist;
}
}
// Calculate standard deviation of distances (consistency)
final meanDist = avgDistance;
double variance = 0;
for (final d in distances) {
variance += math.pow(d - meanDist, 2);
}
final stdDevDist = math.sqrt(variance / distances.length);
// Precision score: based on average distance from center (0-100)
// 0 distance = 100 score, 0.5 distance = 0 score
final precisionScore = math.max(0, (1 - avgDistance * 2) * 100);
// Consistency score: based on grouping tightness (0-100)
// Lower spread = higher consistency
final consistencyScore = math.max(0, (1 - stdDevDist * 5) * 100);
return PrecisionStats(
avgDistanceFromCenter: avgDistance.toDouble(),
groupingDiameter: maxSpread.toDouble(),
precisionScore: precisionScore.clamp(0.0, 100.0).toDouble(),
consistencyScore: consistencyScore.clamp(0.0, 100.0).toDouble(),
);
}
/// Calculate standard deviation statistics
StdDevStats _calculateStdDev(List<Shot> shots) {
if (shots.isEmpty) {
return const StdDevStats(
stdDevX: 0,
stdDevY: 0,
stdDevRadial: 0,
stdDevScore: 0,
meanX: 0.5,
meanY: 0.5,
meanScore: 0,
);
}
// Calculate means
double sumX = 0, sumY = 0, sumScore = 0;
for (final shot in shots) {
sumX += shot.x;
sumY += shot.y;
sumScore += shot.score;
}
final meanX = sumX / shots.length;
final meanY = sumY / shots.length;
final meanScore = sumScore / shots.length;
// Calculate variances
double varianceX = 0, varianceY = 0, varianceScore = 0;
for (final shot in shots) {
varianceX += math.pow(shot.x - meanX, 2);
varianceY += math.pow(shot.y - meanY, 2);
varianceScore += math.pow(shot.score - meanScore, 2);
}
varianceX /= shots.length;
varianceY /= shots.length;
varianceScore /= shots.length;
final stdDevX = math.sqrt(varianceX);
final stdDevY = math.sqrt(varianceY);
final stdDevScore = math.sqrt(varianceScore);
// Radial standard deviation
final stdDevRadial = math.sqrt(varianceX + varianceY);
return StdDevStats(
stdDevX: stdDevX,
stdDevY: stdDevY,
stdDevRadial: stdDevRadial,
stdDevScore: stdDevScore,
meanX: meanX,
meanY: meanY,
meanScore: meanScore,
);
}
/// Calculate regional distribution
RegionalStats _calculateRegional(
List<Shot> shots,
double centerX,
double centerY,
) {
if (shots.isEmpty) {
return const RegionalStats(
quadrantDistribution: {},
sectorDistribution: {},
dominantDirection: 'Centre',
biasX: 0,
biasY: 0,
);
}
// Quadrant distribution
final quadrants = <String, int>{
'Haut-Gauche': 0,
'Haut-Droite': 0,
'Bas-Gauche': 0,
'Bas-Droite': 0,
};
// Sector distribution (8 sectors + center)
final sectors = <String, int>{
'N': 0,
'NE': 0,
'E': 0,
'SE': 0,
'S': 0,
'SO': 0,
'O': 0,
'NO': 0,
'Centre': 0,
};
double sumDx = 0, sumDy = 0;
for (final shot in shots) {
final dx = shot.x - centerX;
final dy = shot.y - centerY;
sumDx += dx;
sumDy += dy;
// Quadrant
if (dy < 0) {
quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite'] =
quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite']! + 1;
} else {
quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite'] =
quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite']! + 1;
}
// Sector
final distance = math.sqrt(dx * dx + dy * dy);
if (distance < 0.1) {
sectors['Centre'] = sectors['Centre']! + 1;
} else {
final angle = math.atan2(dy, dx) * 180 / math.pi;
final sector = _angleToSector(angle);
sectors[sector] = sectors[sector]! + 1;
}
}
// Calculate bias
final biasX = sumDx / shots.length;
final biasY = sumDy / shots.length;
// Find dominant direction
String dominant = 'Centre';
int maxCount = 0;
sectors.forEach((key, value) {
if (value > maxCount) {
maxCount = value;
dominant = key;
}
});
return RegionalStats(
quadrantDistribution: quadrants,
sectorDistribution: sectors,
dominantDirection: dominant,
biasX: biasX,
biasY: biasY,
);
}
String _angleToSector(double angle) {
// Angle is in degrees, -180 to 180
// 0 = East, 90 = South, -90 = North, 180/-180 = West
if (angle >= -22.5 && angle < 22.5) return 'E';
if (angle >= 22.5 && angle < 67.5) return 'SE';
if (angle >= 67.5 && angle < 112.5) return 'S';
if (angle >= 112.5 && angle < 157.5) return 'SO';
if (angle >= 157.5 || angle < -157.5) return 'O';
if (angle >= -157.5 && angle < -112.5) return 'NO';
if (angle >= -112.5 && angle < -67.5) return 'N';
if (angle >= -67.5 && angle < -22.5) return 'NE';
return 'Centre';
}
SessionStatistics _emptyStatistics(StatsPeriod period, List<Session> sessions) {
return SessionStatistics(
totalShots: 0,
totalScore: 0,
avgScore: 0,
maxScore: 0,
minScore: 0,
heatMap: const HeatMap(
gridSize: 5,
zones: [],
maxShotsInZone: 0,
totalShots: 0,
),
precision: const PrecisionStats(
avgDistanceFromCenter: 0,
groupingDiameter: 0,
precisionScore: 0,
consistencyScore: 0,
),
stdDev: const StdDevStats(
stdDevX: 0,
stdDevY: 0,
stdDevRadial: 0,
stdDevScore: 0,
meanX: 0.5,
meanY: 0.5,
meanScore: 0,
),
regional: const RegionalStats(
quadrantDistribution: {},
sectorDistribution: {},
dominantDirection: 'Centre',
biasX: 0,
biasY: 0,
),
sessions: sessions,
period: period,
);
}
}

View File

@@ -0,0 +1,257 @@
import 'dart:math' as math;
import '../data/models/target_type.dart';
import 'image_processing_service.dart';
export 'image_processing_service.dart' show ImpactDetectionSettings, ReferenceImpact, ImpactCharacteristics;
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;
TargetDetectionService({
ImageProcessingService? imageProcessingService,
}) : _imageProcessingService = imageProcessingService ?? ImageProcessingService();
/// Detect target and impacts from an image file
TargetDetectionResult detectTarget(
String imagePath,
TargetType targetType,
) {
try {
// Detect main target
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;
}
// Detect impacts
final impacts = _imageProcessingService.detectImpacts(imagePath);
// Convert impacts to relative coordinates and calculate scores
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,
) {
// Calculate distance from center (normalized to target radius)
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
// Score zones (10 zones)
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; // Outside target
}
int _calculateSilhouetteScore(
double impactX,
double impactY,
double centerX,
double centerY,
) {
// For silhouettes, scoring is typically based on zones
// Head and center mass = 5, body = 4, lower = 3
final dx = (impactX - centerX).abs();
final dy = impactY - centerY;
// Check if within silhouette bounds (approximate)
if (dx > 0.15) return 0; // Too far left/right
// Vertical zones
if (dy < -0.25) return 5; // Head zone (top)
if (dy < 0.0) return 5; // Center mass (upper body)
if (dy < 0.15) return 4; // Body
if (dy < 0.35) return 3; // Lower body
return 0; // Outside target
}
/// Detect only impacts with custom settings (doesn't affect target position)
List<DetectedImpactResult> detectImpactsOnly(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
ImpactDetectionSettings settings,
) {
try {
// Detect impacts with custom settings
final impacts = _imageProcessingService.detectImpactsWithSettings(
imagePath,
settings,
);
// Convert impacts to relative coordinates and calculate scores
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,
) {
// Calculate distance from center (normalized to target radius)
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
// Score zones based on ringCount
for (int i = 0; i < ringCount; i++) {
final zoneRadius = (i + 1) / ringCount;
if (distance <= zoneRadius) {
return 10 - i;
}
}
return 0; // Outside target
}
/// Analyze reference impacts to learn their characteristics
ImpactCharacteristics? analyzeReferenceImpacts(
String imagePath,
List<ReferenceImpact> references,
) {
return _imageProcessingService.analyzeReferenceImpacts(imagePath, references);
}
/// Detect impacts based on reference characteristics (calibrated detection)
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 [];
}
}
}