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,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,
});
}