premier app version beta
This commit is contained in:
230
lib/services/grouping_analyzer_service.dart
Normal file
230
lib/services/grouping_analyzer_service.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
775
lib/services/image_processing_service.dart
Normal file
775
lib/services/image_processing_service.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
220
lib/services/score_calculator_service.dart
Normal file
220
lib/services/score_calculator_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
531
lib/services/statistics_service.dart
Normal file
531
lib/services/statistics_service.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
257
lib/services/target_detection_service.dart
Normal file
257
lib/services/target_detection_service.dart
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user