3 Commits

24 changed files with 1354 additions and 314 deletions

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"avdmanager.executable": "c:\\Users\\streaper2\\AppData\\Local\\Android\\Sdk\\cmdline-tools\\latest\\bin\\avdmanager.bat",
"avdmanager.sdkManager": "c:\\Users\\streaper2\\AppData\\Local\\Android\\Sdk\\cmdline-tools\\latest\\bin\\sdkmanager.bat"
}

BIN
analyze_output.txt Normal file

Binary file not shown.

BIN
analyze_results_final.txt Normal file

Binary file not shown.

37
analyze_results_fixed.txt Normal file
View File

@@ -0,0 +1,37 @@
Analyzing bully...
info - Don't invoke 'print' in production code - lib\features\analysis\analysis_provider.dart:553:7 - avoid_print
info - The private field _selectedType could be 'final' - lib\features\capture\capture_screen.dart:27:14 - prefer_final_fields
info - 'scale' is deprecated and shouldn't be used. Use scaleByVector3, scaleByVector4, or scaleByDouble instead - lib\features\crop\crop_screen.dart:142:25 - deprecated_member_use
info - Statements in an if should be enclosed in a block - lib\services\distortion_correction_service.dart:566:11 - curly_braces_in_flow_control_structures
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:639:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:764:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:825:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:953:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1015:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1063:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:192:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:239:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:246:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:278:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:289:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:292:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:297:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:332:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:336:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:683:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:725:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:736:7 - avoid_print
warning - The declaration '_detectDarkSpotsAdaptive' isn't referenced - lib\services\image_processing_service.dart:780:15 - unused_element
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:328:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:377:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:414:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:23:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:29:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:31:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:67:7 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:4:3 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:5:3 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:6:3 - avoid_print
info - Don't invoke 'print' in production code - tests\test_homography.dart:4:3 - avoid_print

38
analyze_utf8.txt Normal file
View File

@@ -0,0 +1,38 @@
Analyzing bully...
info - Don't invoke 'print' in production code - lib\features\analysis\analysis_provider.dart:392:7 - avoid_print
info - Don't invoke 'print' in production code - lib\features\analysis\analysis_provider.dart:596:7 - avoid_print
info - The private field _selectedType could be 'final' - lib\features\capture\capture_screen.dart:27:14 - prefer_final_fields
info - 'scale' is deprecated and shouldn't be used. Use scaleByVector3, scaleByVector4, or scaleByDouble instead - lib\features\crop\crop_screen.dart:142:25 - deprecated_member_use
info - Statements in an if should be enclosed in a block - lib\services\distortion_correction_service.dart:566:11 - curly_braces_in_flow_control_structures
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:639:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:764:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:825:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:953:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1015:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1063:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:192:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:239:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:246:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:278:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:289:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:292:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:297:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:332:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:336:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:683:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:725:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:736:7 - avoid_print
warning - The declaration '_detectDarkSpotsAdaptive' isn't referenced - lib\services\image_processing_service.dart:780:15 - unused_element
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:328:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:377:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:414:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:23:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:29:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:31:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:67:7 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:4:3 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:5:3 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:6:3 - avoid_print
info - Don't invoke 'print' in production code - tests\test_homography.dart:4:3 - avoid_print

View File

@@ -35,6 +35,7 @@ android {
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works. // Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
} }
} }
} }

7
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,7 @@
# TensorFlow Lite
-keep class org.tensorflow.lite.** { *; }
-keep class com.google.android.gms.tflite.** { *; }
-dontwarn org.tensorflow.lite.**
# Specifically for the GPU delegate error
-dontwarn org.tensorflow.lite.gpu.GpuDelegateFactory$Options

View File

@@ -53,6 +53,7 @@ class AnalysisProvider extends ChangeNotifier {
double _targetCenterX = 0.5; double _targetCenterX = 0.5;
double _targetCenterY = 0.5; double _targetCenterY = 0.5;
double _targetRadius = 0.4; double _targetRadius = 0.4;
double _targetInnerRadius = 0.04;
int _ringCount = 10; int _ringCount = 10;
List<double>? _ringRadii; // Individual ring radii multipliers List<double>? _ringRadii; // Individual ring radii multipliers
double _imageAspectRatio = 1.0; // width / height double _imageAspectRatio = 1.0; // width / height
@@ -83,6 +84,7 @@ class AnalysisProvider extends ChangeNotifier {
double get targetCenterX => _targetCenterX; double get targetCenterX => _targetCenterX;
double get targetCenterY => _targetCenterY; double get targetCenterY => _targetCenterY;
double get targetRadius => _targetRadius; double get targetRadius => _targetRadius;
double get targetInnerRadius => _targetInnerRadius;
int get ringCount => _ringCount; int get ringCount => _ringCount;
List<double>? get ringRadii => List<double>? get ringRadii =>
_ringRadii != null ? List.unmodifiable(_ringRadii!) : null; _ringRadii != null ? List.unmodifiable(_ringRadii!) : null;
@@ -138,6 +140,7 @@ class AnalysisProvider extends ChangeNotifier {
_targetCenterX = 0.5; _targetCenterX = 0.5;
_targetCenterY = 0.5; _targetCenterY = 0.5;
_targetRadius = 0.4; _targetRadius = 0.4;
_targetInnerRadius = 0.04;
// Initialize empty shots list // Initialize empty shots list
_shots = []; _shots = [];
@@ -160,6 +163,7 @@ class AnalysisProvider extends ChangeNotifier {
_targetCenterX = result.centerX; _targetCenterX = result.centerX;
_targetCenterY = result.centerY; _targetCenterY = result.centerY;
_targetRadius = result.radius; _targetRadius = result.radius;
_targetInnerRadius = result.radius * 0.1;
// Create shots from detected impacts // Create shots from detected impacts
_shots = result.impacts.map((impact) { _shots = result.impacts.map((impact) {
@@ -219,6 +223,16 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Update a shot's score manually
void updateShotScore(String shotId, int newScore) {
final index = _shots.indexWhere((shot) => shot.id == shotId);
if (index == -1) return;
_shots[index] = _shots[index].copyWith(score: newScore);
_recalculateScores();
notifyListeners();
}
/// Auto-detect impacts using image processing /// Auto-detect impacts using image processing
Future<int> autoDetectImpacts({ Future<int> autoDetectImpacts({
int darkThreshold = 80, int darkThreshold = 80,
@@ -289,6 +303,8 @@ class AnalysisProvider extends ChangeNotifier {
double param2 = 30, double param2 = 30,
int minRadius = 5, int minRadius = 5,
int maxRadius = 50, int maxRadius = 50,
int minSize = 5,
int maxSize = 1000,
int blurSize = 5, int blurSize = 5,
bool useContourDetection = true, bool useContourDetection = true,
double minCircularity = 0.6, double minCircularity = 0.6,
@@ -347,6 +363,47 @@ class AnalysisProvider extends ChangeNotifier {
return detectedImpacts.length; return detectedImpacts.length;
} }
/// Auto-detect impacts using YOLOv8 model
Future<int> autoDetectImpactsWithYOLO({bool clearExisting = false}) async {
if (_imagePath == null || _targetType == null) return 0;
try {
final detectedImpacts = await _detectionService.detectImpactsWithYOLO(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: impact.suggestedScore,
sessionId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
} catch (e) {
print('Error in YOLO auto-detection: $e');
return 0;
}
}
/// Detect impacts with OpenCV using reference points /// Detect impacts with OpenCV using reference points
Future<int> detectFromReferencesWithOpenCV({ Future<int> detectFromReferencesWithOpenCV({
double tolerance = 2.0, double tolerance = 2.0,
@@ -488,12 +545,14 @@ class AnalysisProvider extends ChangeNotifier {
void adjustTargetPosition( void adjustTargetPosition(
double centerX, double centerX,
double centerY, double centerY,
double innerRadius,
double radius, { double radius, {
int? ringCount, int? ringCount,
List<double>? ringRadii, List<double>? ringRadii,
}) { }) {
_targetCenterX = centerX; _targetCenterX = centerX;
_targetCenterY = centerY; _targetCenterY = centerY;
_targetInnerRadius = innerRadius;
_targetRadius = radius; _targetRadius = radius;
if (ringCount != null) { if (ringCount != null) {
_ringCount = ringCount; _ringCount = ringCount;
@@ -517,10 +576,29 @@ class AnalysisProvider extends ChangeNotifier {
if (_imagePath == null) return false; if (_imagePath == null) return false;
try { try {
// 1. Attempt to correct perspective/distortion first
final correctedPath = await _distortionService
.correctPerspectiveWithConcentricMesh(_imagePath!);
if (correctedPath != _imagePath) {
_imagePath = correctedPath;
_correctedImagePath = correctedPath;
_distortionCorrectionEnabled = true;
_imageAspectRatio =
1.0; // The corrected image is always square (side x side)
notifyListeners();
}
// 2. Detect the target on the straight/corrected image
final result = await _opencvTargetService.detectTarget(_imagePath!); final result = await _opencvTargetService.detectTarget(_imagePath!);
if (result.success) { if (result.success) {
adjustTargetPosition(result.centerX, result.centerY, result.radius); adjustTargetPosition(
result.centerX,
result.centerY,
result.radius * 0.1,
result.radius,
);
return true; return true;
} }
return false; return false;
@@ -687,6 +765,7 @@ class AnalysisProvider extends ChangeNotifier {
_targetCenterX = 0.5; _targetCenterX = 0.5;
_targetCenterY = 0.5; _targetCenterY = 0.5;
_targetRadius = 0.4; _targetRadius = 0.4;
_targetInnerRadius = 0.04;
_ringCount = 10; _ringCount = 10;
_ringRadii = null; _ringRadii = null;
_imageAspectRatio = 1.0; _imageAspectRatio = 1.0;

View File

@@ -274,68 +274,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
child: Column( child: Column(
children: [ children: [
// Auto-calibrate button
/*SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () async {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
),
SizedBox(width: 12),
Text('Auto-calibration en cours...'),
],
),
duration: Duration(seconds: 2),
),
);
final success = await provider
.autoCalibrateTarget();
if (context.mounted) {
ScaffoldMessenger.of(
context,
).hideCurrentSnackBar();
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Cible calibrée automatiquement',
),
backgroundColor: AppTheme.successColor,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Échec de la calibration auto',
),
backgroundColor: AppTheme.errorColor,
),
);
}
}
},
icon: const Icon(Icons.auto_fix_high),
label: const Text('Auto-Calibrer la Cible'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
),
),
),*/
const SizedBox(height: 16),
// Ring count slider // Ring count slider
Row( Row(
children: [ children: [
@@ -361,6 +300,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
provider.adjustTargetPosition( provider.adjustTargetPosition(
provider.targetCenterX, provider.targetCenterX,
provider.targetCenterY, provider.targetCenterY,
provider.targetInnerRadius,
provider.targetRadius, provider.targetRadius,
ringCount: value.round(), ringCount: value.round(),
); );
@@ -386,56 +326,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
], ],
), ),
// Target size slider
Row(
children: [
const Icon(
Icons.zoom_out_map,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Taille:',
style: TextStyle(color: Colors.white),
),
Expanded(
child: Slider(
value: provider.targetRadius.clamp(0.05, 3.0),
min: 0.05,
max: 3.0,
label:
'${(provider.targetRadius * 100).toStringAsFixed(0)}%',
activeColor: AppTheme.warningColor,
onChanged: (value) {
provider.adjustTargetPosition(
provider.targetCenterX,
provider.targetCenterY,
value,
ringCount: provider.ringCount,
);
},
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: AppTheme.warningColor,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${(provider.targetRadius * 100).toStringAsFixed(0)}%',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
),
const Divider(color: Colors.white24, height: 16), const Divider(color: Colors.white24, height: 16),
// Distortion correction row // Distortion correction row
/*Row( /*Row(
@@ -535,6 +425,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
initialCenterX: provider.targetCenterX, initialCenterX: provider.targetCenterX,
initialCenterY: provider.targetCenterY, initialCenterY: provider.targetCenterY,
initialRadius: provider.targetRadius, initialRadius: provider.targetRadius,
initialInnerRadius: provider.targetInnerRadius,
initialRingCount: provider.ringCount, initialRingCount: provider.ringCount,
initialRingRadii: provider.ringRadii, initialRingRadii: provider.ringRadii,
targetType: provider.targetType!, targetType: provider.targetType!,
@@ -542,6 +433,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
( (
centerX, centerX,
centerY, centerY,
innerRadius,
radius, radius,
ringCount, { ringCount, {
List<double>? ringRadii, List<double>? ringRadii,
@@ -549,6 +441,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
provider.adjustTargetPosition( provider.adjustTargetPosition(
centerX, centerX,
centerY, centerY,
innerRadius,
radius, radius,
ringCount: ringCount, ringCount: ringCount,
ringRadii: ringRadii, ringRadii: ringRadii,
@@ -788,6 +681,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
// Overlay qui se transforme avec l'image // Overlay qui se transforme avec l'image
TargetOverlay( TargetOverlay(
showRings: true,
shots: provider.shots, shots: provider.shots,
targetCenterX: provider.targetCenterX, targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY, targetCenterY: provider.targetCenterY,
@@ -874,6 +768,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
), ),
// Overlay qui se transforme avec l'image // Overlay qui se transforme avec l'image
TargetOverlay( TargetOverlay(
showRings: true,
shots: provider.shots, shots: provider.shots,
targetCenterX: provider.targetCenterX, targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY, targetCenterY: provider.targetCenterY,
@@ -986,107 +881,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
} }
Widget _buildActionButtons(BuildContext context, AnalysisProvider provider) { Widget _buildActionButtons(BuildContext context, AnalysisProvider provider) {
return Column( return const SizedBox.shrink();
children: [
// Reference-based detection section
if (_isSelectingReferences) ...[
Card(
color: Colors.deepPurple.withValues(alpha: 0.1),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.touch_app, color: Colors.deepPurple),
const SizedBox(width: 8),
Text(
'${provider.referenceImpacts.length} reference(s) selectionnee(s)',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 8),
const Text(
'Touchez l\'image pour marquer 3-4 impacts de reference. '
'L\'algorithme apprendra leurs caracteristiques pour detecter les autres.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
setState(() => _isSelectingReferences = false);
provider.clearReferenceImpacts();
},
child: const Text('Annuler'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: provider.referenceImpacts.length >= 2
? () => _showCalibratedDetectionDialog(
context,
provider,
)
: null,
icon: const Icon(Icons.auto_fix_high),
label: const Text('Detecter'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
),
),
),
],
),
],
),
),
),
const SizedBox(height: 12),
] else ...[
// désactiver le temps de l'amelioration du scripts d'auto-detection
// Auto-detect buttons row
// Row(
// children: [
// Expanded(
// child: ElevatedButton.icon(
// onPressed: () => _showAutoDetectDialog(context, provider),
// icon: const Icon(Icons.auto_fix_high),
// label: const Text('Auto-Detection'),
// style: ElevatedButton.styleFrom(
// backgroundColor: AppTheme.primaryColor,
// foregroundColor: Colors.white,
// padding: const EdgeInsets.symmetric(vertical: 12),
// ),
// ),
// ),
// const SizedBox(width: 12),
// Expanded(
// child: ElevatedButton.icon(
// onPressed: () => setState(() => _isSelectingReferences = true),
// icon: const Icon(Icons.touch_app),
// label: const Text('Par Reference'),
// style: ElevatedButton.styleFrom(
// backgroundColor: Colors.deepPurple,
// foregroundColor: Colors.white,
// padding: const EdgeInsets.symmetric(vertical: 12),
// ),
// ),
// ),
// ],
// ),
const SizedBox(height: 12),
],
// Manual actions
],
);
} }
void _showHelpDialog(BuildContext context) { void _showHelpDialog(BuildContext context) {
@@ -1124,21 +919,68 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
AnalysisProvider provider, AnalysisProvider provider,
String shotId, String shotId,
) { ) {
final shot = provider.shots.firstWhere((s) => s.id == shotId);
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => SafeArea( builder: (context) => SafeArea(
child: Wrap( child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
ListTile( Text(
leading: const Icon(Icons.delete, color: AppTheme.errorColor), 'Modifier l\'impact',
title: const Text('Supprimer cet impact'), style: Theme.of(context).textTheme.titleLarge,
onTap: () { ),
provider.removeShot(shotId); const SizedBox(height: 20),
// Dropdown for score
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Valeur de l\'impact : ', style: TextStyle(fontSize: 16)),
const SizedBox(width: 10),
DropdownButton<int>(
value: shot.score.clamp(0, 10),
items: List.generate(11, (i) => i).map((i) {
return DropdownMenuItem<int>(
value: i,
child: Text(i == 10 && provider.targetType == TargetType.concentric ? '10 (X)' : '$i'),
);
}).toList(),
onChanged: (newScore) {
if (newScore != null) {
provider.updateShotScore(shotId, newScore);
Navigator.pop(context); Navigator.pop(context);
}
}, },
), ),
], ],
), ),
const SizedBox(height: 20),
// Delete button at the bottom
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
provider.removeShot(shotId);
Navigator.pop(context);
},
icon: const Icon(Icons.delete),
label: const Text('SUPPRIMER L\'IMPACT'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.errorColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
),
), ),
); );
} }
@@ -1182,7 +1024,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
); );
} }
/*
void _showAutoDetectDialog(BuildContext context, AnalysisProvider provider) { void _showAutoDetectDialog(BuildContext context, AnalysisProvider provider) {
// Detection settings // Detection settings
bool clearExisting = true; bool clearExisting = true;
@@ -1192,9 +1034,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
int maxImpactSize = 500; int maxImpactSize = 500;
double minFillRatio = 0.5; double minFillRatio = 0.5;
// NOTE: OpenCV désactivé - problèmes de build Windows
// Utilisation de la détection classique uniquement
showDialog( showDialog(
context: context, context: context,
builder: (context) => StatefulBuilder( builder: (context) => StatefulBuilder(
@@ -1211,11 +1050,78 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// YOLO option button
Card(
color: AppTheme.primaryColor.withAlpha(25),
child: ListTile(
leading: const Icon(Icons.psychology, color: AppTheme.primaryColor),
title: const Text('IA Detection (YOLOv8)', style: TextStyle(fontWeight: FontWeight.bold)),
subtitle: const Text('Détection intelligente via modèle entraîné'),
onTap: () async {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
),
SizedBox(width: 12),
Text('Détection IA en cours...'),
],
),
duration: Duration(seconds: 10),
),
);
final count = await provider.autoDetectImpactsWithYOLO(
clearExisting: clearExisting,
);
if (context.mounted) {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
count > 0
? '$count impact(s) détecté(s) par l\'IA'
: 'Aucun impact détecté par l\'IA.',
),
backgroundColor: count > 0
? AppTheme.successColor
: AppTheme.warningColor,
),
);
}
},
),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
Expanded(child: Divider()),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('OU', style: TextStyle(color: Colors.grey, fontSize: 12)),
),
Expanded(child: Divider()),
],
),
),
const Text( const Text(
'Ajustez les parametres de detection:', 'Détection Classique (Paramétrable):',
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 16), const SizedBox(height: 8),
// Dark threshold slider // Dark threshold slider
Text('Seuil de detection (zones sombres): $darkThreshold'), Text('Seuil de detection (zones sombres): $darkThreshold'),
@@ -1374,14 +1280,13 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
} }
}, },
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
label: const Text('Detecter'), label: const Text('Détecter (Classique)'),
), ),
], ],
), ),
), ),
); );
} }
*/
void _showCalibratedDetectionDialog( void _showCalibratedDetectionDialog(
BuildContext context, BuildContext context,

View File

@@ -13,16 +13,26 @@ class TargetCalibration extends StatefulWidget {
final double initialCenterX; final double initialCenterX;
final double initialCenterY; final double initialCenterY;
final double initialRadius; final double initialRadius;
final double initialInnerRadius;
final int initialRingCount; final int initialRingCount;
final TargetType targetType; final TargetType targetType;
final List<double>? initialRingRadii; final List<double>? initialRingRadii;
final Function(double centerX, double centerY, double radius, int ringCount, {List<double>? ringRadii}) onCalibrationChanged; final Function(
double centerX,
double centerY,
double innerRadius,
double radius,
int ringCount, {
List<double>? ringRadii,
})
onCalibrationChanged;
const TargetCalibration({ const TargetCalibration({
super.key, super.key,
required this.initialCenterX, required this.initialCenterX,
required this.initialCenterY, required this.initialCenterY,
required this.initialRadius, required this.initialRadius,
required this.initialInnerRadius,
this.initialRingCount = 10, this.initialRingCount = 10,
required this.targetType, required this.targetType,
this.initialRingRadii, this.initialRingRadii,
@@ -37,11 +47,13 @@ class _TargetCalibrationState extends State<TargetCalibration> {
late double _centerX; late double _centerX;
late double _centerY; late double _centerY;
late double _radius; late double _radius;
late double _innerRadius;
late int _ringCount; late int _ringCount;
late List<double> _ringRadii; late List<double> _ringRadii;
bool _isDraggingCenter = false; bool _isDraggingCenter = false;
bool _isDraggingRadius = false; bool _isDraggingRadius = false;
bool _isDraggingInnerRadius = false;
@override @override
void initState() { void initState() {
@@ -49,28 +61,57 @@ class _TargetCalibrationState extends State<TargetCalibration> {
_centerX = widget.initialCenterX; _centerX = widget.initialCenterX;
_centerY = widget.initialCenterY; _centerY = widget.initialCenterY;
_radius = widget.initialRadius; _radius = widget.initialRadius;
_innerRadius = widget.initialInnerRadius;
_ringCount = widget.initialRingCount; _ringCount = widget.initialRingCount;
_initRingRadii(); _initRingRadii();
} }
void _initRingRadii() { void _initRingRadii() {
if (widget.initialRingRadii != null && widget.initialRingRadii!.length == _ringCount) { if (widget.initialRingRadii != null &&
widget.initialRingRadii!.length == _ringCount) {
_ringRadii = List.from(widget.initialRingRadii!); _ringRadii = List.from(widget.initialRingRadii!);
} else { } else {
// Initialize with default proportional radii // Initialize with default proportional radii interpolated between inner and outer
_ringRadii = List.generate(_ringCount, (i) => (i + 1) / _ringCount); _ringRadii = List.generate(_ringCount, (i) {
if (_ringCount <= 1) return 1.0;
final ratio = _innerRadius / _radius;
return ratio + (1.0 - ratio) * i / (_ringCount - 1);
});
} }
} }
@override @override
void didUpdateWidget(TargetCalibration oldWidget) { void didUpdateWidget(TargetCalibration oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
bool shouldReinit = false;
if (widget.initialCenterX != oldWidget.initialCenterX &&
!_isDraggingCenter) {
_centerX = widget.initialCenterX;
}
if (widget.initialCenterY != oldWidget.initialCenterY &&
!_isDraggingCenter) {
_centerY = widget.initialCenterY;
}
if (widget.initialRingCount != oldWidget.initialRingCount) { if (widget.initialRingCount != oldWidget.initialRingCount) {
_ringCount = widget.initialRingCount; _ringCount = widget.initialRingCount;
_initRingRadii(); shouldReinit = true;
} }
if (widget.initialRadius != oldWidget.initialRadius && !_isDraggingRadius) { if (widget.initialRadius != oldWidget.initialRadius && !_isDraggingRadius) {
_radius = widget.initialRadius; _radius = widget.initialRadius;
shouldReinit = true;
}
if (widget.initialInnerRadius != oldWidget.initialInnerRadius &&
!_isDraggingInnerRadius) {
_innerRadius = widget.initialInnerRadius;
shouldReinit = true;
}
if (widget.initialRingRadii != oldWidget.initialRingRadii) {
shouldReinit = true;
}
if (shouldReinit) {
_initRingRadii();
} }
} }
@@ -90,11 +131,13 @@ class _TargetCalibrationState extends State<TargetCalibration> {
centerX: _centerX, centerX: _centerX,
centerY: _centerY, centerY: _centerY,
radius: _radius, radius: _radius,
innerRadius: _innerRadius,
ringCount: _ringCount, ringCount: _ringCount,
ringRadii: _ringRadii, ringRadii: _ringRadii,
targetType: widget.targetType, targetType: widget.targetType,
isDraggingCenter: _isDraggingCenter, isDraggingCenter: _isDraggingCenter,
isDraggingRadius: _isDraggingRadius, isDraggingRadius: _isDraggingRadius,
isDraggingInnerRadius: _isDraggingInnerRadius,
), ),
), ),
); );
@@ -109,21 +152,42 @@ class _TargetCalibrationState extends State<TargetCalibration> {
// Check if tapping on center handle // Check if tapping on center handle
final distToCenter = _distance(tapX, tapY, _centerX, _centerY); final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
// Check if tapping on radius handle (on the right edge of the outermost circle) // Check if tapping on outer radius handle
final minDim = math.min(size.width, size.height); final minDim = math.min(size.width, size.height);
final outerRadius = _radius * (_ringRadii.isNotEmpty ? _ringRadii.last : 1.0); final outerRadius = _radius;
final radiusHandleX = _centerX + outerRadius * minDim / size.width; final radiusHandleX = _centerX + outerRadius * minDim / size.width;
final radiusHandleY = _centerY; final radiusHandleY = _centerY;
final distToRadiusHandle = _distance(tapX, tapY, radiusHandleX.clamp(0.0, 1.0), radiusHandleY.clamp(0.0, 1.0)); final distToOuterHandle = _distance(
tapX,
tapY,
radiusHandleX.clamp(0.0, 1.0),
radiusHandleY.clamp(0.0, 1.0),
);
// Check if tapping on inner radius handle (top edge of innermost circle)
final actualInnerRadius = _innerRadius;
final innerHandleX = _centerX;
final innerHandleY = _centerY - actualInnerRadius * minDim / size.height;
final distToInnerHandle = _distance(
tapX,
tapY,
innerHandleX.clamp(0.0, 1.0),
innerHandleY.clamp(0.0, 1.0),
);
// Increase touch target size slightly for handles
if (distToCenter < 0.05) { if (distToCenter < 0.05) {
setState(() { setState(() {
_isDraggingCenter = true; _isDraggingCenter = true;
}); });
} else if (distToRadiusHandle < 0.05) { } else if (distToOuterHandle < 0.05) {
setState(() { setState(() {
_isDraggingRadius = true; _isDraggingRadius = true;
}); });
} else if (distToInnerHandle < 0.05) {
setState(() {
_isDraggingInnerRadius = true;
});
} else if (distToCenter < _radius + 0.02) { } else if (distToCenter < _radius + 0.02) {
// Tapping inside the target - move center // Tapping inside the target - move center
setState(() { setState(() {
@@ -143,19 +207,36 @@ class _TargetCalibrationState extends State<TargetCalibration> {
_centerX = _centerX + deltaX; _centerX = _centerX + deltaX;
_centerY = _centerY + deltaY; _centerY = _centerY + deltaY;
} else if (_isDraggingRadius) { } else if (_isDraggingRadius) {
// Adjust outer radius (scales all rings proportionally) // Adjust outer radius
final newRadius = _radius + deltaX * (size.width / minDim); final newRadius = _radius + deltaX * (size.width / minDim);
_radius = newRadius.clamp(0.05, 3.0); _radius = newRadius.clamp(math.max(0.05, _innerRadius + 0.01), 3.0);
_initRingRadii(); // Recalculate linear separation
} else if (_isDraggingInnerRadius) {
// Adjust inner radius (sliding up reduces Y, so deltaY is negative when growing. Thus we subtract deltaY)
final newInnerRadius = _innerRadius - deltaY * (size.height / minDim);
_innerRadius = newInnerRadius.clamp(
0.01,
math.max(0.01, _radius - 0.01),
);
_initRingRadii(); // Recalculate linear separation
} }
}); });
widget.onCalibrationChanged(_centerX, _centerY, _radius, _ringCount, ringRadii: _ringRadii); widget.onCalibrationChanged(
_centerX,
_centerY,
_innerRadius,
_radius,
_ringCount,
ringRadii: _ringRadii,
);
} }
void _onPanEnd() { void _onPanEnd() {
setState(() { setState(() {
_isDraggingCenter = false; _isDraggingCenter = false;
_isDraggingRadius = false; _isDraggingRadius = false;
_isDraggingInnerRadius = false;
}); });
} }
@@ -170,21 +251,25 @@ class _CalibrationPainter extends CustomPainter {
final double centerX; final double centerX;
final double centerY; final double centerY;
final double radius; final double radius;
final double innerRadius;
final int ringCount; final int ringCount;
final List<double> ringRadii; final List<double> ringRadii;
final TargetType targetType; final TargetType targetType;
final bool isDraggingCenter; final bool isDraggingCenter;
final bool isDraggingRadius; final bool isDraggingRadius;
final bool isDraggingInnerRadius;
_CalibrationPainter({ _CalibrationPainter({
required this.centerX, required this.centerX,
required this.centerY, required this.centerY,
required this.radius, required this.radius,
required this.innerRadius,
required this.ringCount, required this.ringCount,
required this.ringRadii, required this.ringRadii,
required this.targetType, required this.targetType,
required this.isDraggingCenter, required this.isDraggingCenter,
required this.isDraggingRadius, required this.isDraggingRadius,
required this.isDraggingInnerRadius,
}); });
@override @override
@@ -192,6 +277,7 @@ class _CalibrationPainter extends CustomPainter {
final centerPx = Offset(centerX * size.width, centerY * size.height); final centerPx = Offset(centerX * size.width, centerY * size.height);
final minDim = size.width < size.height ? size.width : size.height; final minDim = size.width < size.height ? size.width : size.height;
final baseRadiusPx = radius * minDim; final baseRadiusPx = radius * minDim;
final innerRadiusPx = innerRadius * minDim;
if (targetType == TargetType.concentric) { if (targetType == TargetType.concentric) {
_drawConcentricZones(canvas, size, centerPx, baseRadiusPx); _drawConcentricZones(canvas, size, centerPx, baseRadiusPx);
@@ -199,17 +285,42 @@ class _CalibrationPainter extends CustomPainter {
_drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx); _drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx);
} }
// Fullscreen crosshairs when dragging center
if (isDraggingCenter) {
final crosshairLinePaint = Paint()
..color = AppTheme.successColor.withValues(alpha: 0.5)
..strokeWidth = 1;
canvas.drawLine(
Offset(0, centerPx.dy),
Offset(size.width, centerPx.dy),
crosshairLinePaint,
);
canvas.drawLine(
Offset(centerPx.dx, 0),
Offset(centerPx.dx, size.height),
crosshairLinePaint,
);
}
// Draw center handle // Draw center handle
_drawCenterHandle(canvas, centerPx); _drawCenterHandle(canvas, centerPx);
// Draw radius handle (for outer ring) // Draw radius handle (for outer ring)
_drawRadiusHandle(canvas, size, centerPx, baseRadiusPx); _drawRadiusHandle(canvas, size, centerPx, baseRadiusPx);
// Draw inner radius handle
_drawInnerRadiusHandle(canvas, size, centerPx, innerRadiusPx);
// Draw instructions // Draw instructions
_drawInstructions(canvas, size); _drawInstructions(canvas, size);
} }
void _drawConcentricZones(Canvas canvas, Size size, Offset center, double baseRadius) { void _drawConcentricZones(
Canvas canvas,
Size size,
Offset center,
double baseRadius,
) {
// Generate colors for zones // Generate colors for zones
List<Color> zoneColors = []; List<Color> zoneColors = [];
for (int i = 0; i < ringCount; i++) { for (int i = 0; i < ringCount; i++) {
@@ -235,7 +346,9 @@ class _CalibrationPainter extends CustomPainter {
// Draw from outside to inside // Draw from outside to inside
for (int i = ringCount - 1; i >= 0; i--) { for (int i = ringCount - 1; i >= 0; i--) {
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount; final ringRadius = ringRadii.length > i
? ringRadii[i]
: (i + 1) / ringCount;
final zoneRadius = baseRadius * ringRadius; final zoneRadius = baseRadius * ringRadius;
zonePaint.color = zoneColors[i]; zonePaint.color = zoneColors[i];
@@ -244,12 +357,12 @@ class _CalibrationPainter extends CustomPainter {
} }
// Draw zone labels (only if within visible area) // Draw zone labels (only if within visible area)
final textPainter = TextPainter( final textPainter = TextPainter(textDirection: TextDirection.ltr);
textDirection: TextDirection.ltr,
);
for (int i = 0; i < ringCount; i++) { for (int i = 0; i < ringCount; i++) {
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount; final ringRadius = ringRadii.length > i
? ringRadii[i]
: (i + 1) / ringCount;
final prevRingRadius = i > 0 final prevRingRadius = i > 0
? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount) ? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount)
: 0.0; : 0.0;
@@ -268,9 +381,7 @@ class _CalibrationPainter extends CustomPainter {
color: Colors.white.withValues(alpha: 0.9), color: Colors.white.withValues(alpha: 0.9),
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
shadows: const [ shadows: const [Shadow(color: Colors.black, blurRadius: 2)],
Shadow(color: Colors.black, blurRadius: 2),
],
), ),
); );
textPainter.layout(); textPainter.layout();
@@ -278,14 +389,24 @@ class _CalibrationPainter extends CustomPainter {
// Draw label on the right side of each zone // Draw label on the right side of each zone
final labelY = center.dy - textPainter.height / 2; final labelY = center.dy - textPainter.height / 2;
if (labelY >= 0 && labelY <= size.height) { if (labelY >= 0 && labelY <= size.height) {
textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY)); textPainter.paint(
canvas,
Offset(labelX - textPainter.width / 2, labelY),
);
} }
} }
} }
void _drawSilhouetteZones(Canvas canvas, Size size, Offset center, double radius) { void _drawSilhouetteZones(
Canvas canvas,
Size size,
Offset center,
double radius,
) {
// Simplified silhouette zones // Simplified silhouette zones
final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 2; final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2;
// Draw silhouette outline (simplified as rectangle for now) // Draw silhouette outline (simplified as rectangle for now)
final silhouetteWidth = radius * 0.8; final silhouetteWidth = radius * 0.8;
@@ -293,7 +414,11 @@ class _CalibrationPainter extends CustomPainter {
paint.color = Colors.green.withValues(alpha: 0.5); paint.color = Colors.green.withValues(alpha: 0.5);
canvas.drawRect( canvas.drawRect(
Rect.fromCenter(center: center, width: silhouetteWidth, height: silhouetteHeight), Rect.fromCenter(
center: center,
width: silhouetteWidth,
height: silhouetteHeight,
),
paint, paint,
); );
} }
@@ -316,17 +441,36 @@ class _CalibrationPainter extends CustomPainter {
final crossPaint = Paint() final crossPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor ..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..strokeWidth = 2; ..strokeWidth = 2;
canvas.drawLine(Offset(center.dx - 20, center.dy), Offset(center.dx - 8, center.dy), crossPaint); canvas.drawLine(
canvas.drawLine(Offset(center.dx + 8, center.dy), Offset(center.dx + 20, center.dy), crossPaint); Offset(center.dx - 20, center.dy),
canvas.drawLine(Offset(center.dx, center.dy - 20), Offset(center.dx, center.dy - 8), crossPaint); Offset(center.dx - 8, center.dy),
canvas.drawLine(Offset(center.dx, center.dy + 8), Offset(center.dx, center.dy + 20), crossPaint); crossPaint,
);
canvas.drawLine(
Offset(center.dx + 8, center.dy),
Offset(center.dx + 20, center.dy),
crossPaint,
);
canvas.drawLine(
Offset(center.dx, center.dy - 20),
Offset(center.dx, center.dy - 8),
crossPaint,
);
canvas.drawLine(
Offset(center.dx, center.dy + 8),
Offset(center.dx, center.dy + 20),
crossPaint,
);
} }
void _drawRadiusHandle(Canvas canvas, Size size, Offset center, double baseRadius) { void _drawRadiusHandle(
Canvas canvas,
Size size,
Offset center,
double baseRadius,
) {
// Radius handle on the right edge of the outermost ring // Radius handle on the right edge of the outermost ring
final outerRingRadius = ringRadii.isNotEmpty ? ringRadii.last : 1.0; final actualHandleX = center.dx + baseRadius;
final actualRadius = baseRadius * outerRingRadius;
final actualHandleX = center.dx + actualRadius;
final clampedHandleX = actualHandleX.clamp(20.0, size.width - 20); final clampedHandleX = actualHandleX.clamp(20.0, size.width - 20);
final clampedHandleY = center.dy.clamp(20.0, size.height - 20); final clampedHandleY = center.dy.clamp(20.0, size.height - 20);
final handlePos = Offset(clampedHandleX, clampedHandleY); final handlePos = Offset(clampedHandleX, clampedHandleY);
@@ -376,7 +520,7 @@ class _CalibrationPainter extends CustomPainter {
// Label // Label
final textPainter = TextPainter( final textPainter = TextPainter(
text: const TextSpan( text: const TextSpan(
text: 'RAYON', text: 'EXT.',
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 8, fontSize: 8,
@@ -392,6 +536,78 @@ class _CalibrationPainter extends CustomPainter {
); );
} }
void _drawInnerRadiusHandle(
Canvas canvas,
Size size,
Offset center,
double innerRadiusPx,
) {
// Inner radius handle on the top edge of the innermost ring
final actualHandleY = center.dy - innerRadiusPx;
final clampedHandleX = center.dx.clamp(20.0, size.width - 20);
final clampedHandleY = actualHandleY.clamp(20.0, size.height - 20);
final handlePos = Offset(clampedHandleX, clampedHandleY);
final isClamped = actualHandleY < 20.0;
final paint = Paint()
..color = isDraggingInnerRadius
? AppTheme.successColor
: (isClamped ? Colors.orange : Colors.purpleAccent)
..style = PaintingStyle.fill;
// Draw handle
canvas.drawCircle(handlePos, 14, paint);
// Up/Down arrow indicators
final arrowPaint = Paint()
..color = Colors.white
..strokeWidth = 2
..style = PaintingStyle.stroke;
// Up arrow
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy - 4),
Offset(handlePos.dx - 4, handlePos.dy - 8),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy - 4),
Offset(handlePos.dx + 4, handlePos.dy - 8),
arrowPaint,
);
// Down arrow
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy + 4),
Offset(handlePos.dx - 4, handlePos.dy + 8),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy + 4),
Offset(handlePos.dx + 4, handlePos.dy + 8),
arrowPaint,
);
// Label
final textPainter = TextPainter(
text: const TextSpan(
text: 'INT.',
style: TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(handlePos.dx - textPainter.width / 2, handlePos.dy - 24),
);
}
void _drawInstructions(Canvas canvas, Size size) { void _drawInstructions(Canvas canvas, Size size) {
const instruction = 'Deplacez le centre ou ajustez le rayon'; const instruction = 'Deplacez le centre ou ajustez le rayon';
@@ -418,9 +634,11 @@ class _CalibrationPainter extends CustomPainter {
return centerX != oldDelegate.centerX || return centerX != oldDelegate.centerX ||
centerY != oldDelegate.centerY || centerY != oldDelegate.centerY ||
radius != oldDelegate.radius || radius != oldDelegate.radius ||
innerRadius != oldDelegate.innerRadius ||
ringCount != oldDelegate.ringCount || ringCount != oldDelegate.ringCount ||
isDraggingCenter != oldDelegate.isDraggingCenter || isDraggingCenter != oldDelegate.isDraggingCenter ||
isDraggingRadius != oldDelegate.isDraggingRadius || isDraggingRadius != oldDelegate.isDraggingRadius ||
isDraggingInnerRadius != oldDelegate.isDraggingInnerRadius ||
ringRadii != oldDelegate.ringRadii; ringRadii != oldDelegate.ringRadii;
} }
} }

View File

@@ -25,6 +25,7 @@ class TargetOverlay extends StatelessWidget {
final double? groupingDiameter; final double? groupingDiameter;
final List<Shot>? referenceImpacts; final List<Shot>? referenceImpacts;
final double zoomScale; final double zoomScale;
final bool showRings;
const TargetOverlay({ const TargetOverlay({
super.key, super.key,
@@ -42,6 +43,7 @@ class TargetOverlay extends StatelessWidget {
this.groupingDiameter, this.groupingDiameter,
this.referenceImpacts, this.referenceImpacts,
this.zoomScale = 1.0, this.zoomScale = 1.0,
this.showRings = false,
}); });
@override @override
@@ -72,6 +74,7 @@ class TargetOverlay extends StatelessWidget {
groupingDiameter: groupingDiameter, groupingDiameter: groupingDiameter,
referenceImpacts: referenceImpacts, referenceImpacts: referenceImpacts,
zoomScale: zoomScale, zoomScale: zoomScale,
showRings: showRings,
), ),
child: Stack( child: Stack(
children: shots.map((shot) { children: shots.map((shot) {
@@ -132,6 +135,7 @@ class _TargetOverlayPainter extends CustomPainter {
final double? groupingDiameter; final double? groupingDiameter;
final List<Shot>? referenceImpacts; final List<Shot>? referenceImpacts;
final double zoomScale; final double zoomScale;
final bool showRings;
_TargetOverlayPainter({ _TargetOverlayPainter({
required this.shots, required this.shots,
@@ -146,12 +150,15 @@ class _TargetOverlayPainter extends CustomPainter {
this.groupingDiameter, this.groupingDiameter,
this.referenceImpacts, this.referenceImpacts,
this.zoomScale = 1.0, this.zoomScale = 1.0,
this.showRings = false,
}); });
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
// Draw target center indicator // Draw target center indicator
if (showRings) {
_drawTargetCenter(canvas, size); _drawTargetCenter(canvas, size);
}
// Draw grouping circle // Draw grouping circle
if (groupingCenterX != null && groupingCenterY != null && groupingDiameter != null && shots.length > 1) { if (groupingCenterX != null && groupingCenterY != null && groupingDiameter != null && shots.length > 1) {
@@ -371,6 +378,7 @@ class _TargetOverlayPainter extends CustomPainter {
groupingCenterY != oldDelegate.groupingCenterY || groupingCenterY != oldDelegate.groupingCenterY ||
groupingDiameter != oldDelegate.groupingDiameter || groupingDiameter != oldDelegate.groupingDiameter ||
referenceImpacts != oldDelegate.referenceImpacts || referenceImpacts != oldDelegate.referenceImpacts ||
zoomScale != oldDelegate.zoomScale; zoomScale != oldDelegate.zoomScale ||
showRings != oldDelegate.showRings;
} }
} }

View File

@@ -10,6 +10,7 @@ import 'services/target_detection_service.dart';
import 'services/score_calculator_service.dart'; import 'services/score_calculator_service.dart';
import 'services/grouping_analyzer_service.dart'; import 'services/grouping_analyzer_service.dart';
import 'services/image_processing_service.dart'; import 'services/image_processing_service.dart';
import 'services/yolo_impact_detection_service.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -33,9 +34,13 @@ void main() async {
Provider<ImageProcessingService>( Provider<ImageProcessingService>(
create: (_) => ImageProcessingService(), create: (_) => ImageProcessingService(),
), ),
Provider<YOLOImpactDetectionService>(
create: (_) => YOLOImpactDetectionService(),
),
Provider<TargetDetectionService>( Provider<TargetDetectionService>(
create: (context) => TargetDetectionService( create: (context) => TargetDetectionService(
imageProcessingService: context.read<ImageProcessingService>(), imageProcessingService: context.read<ImageProcessingService>(),
yoloService: context.read<YOLOImpactDetectionService>(),
), ),
), ),
Provider<ScoreCalculatorService>( Provider<ScoreCalculatorService>(
@@ -44,9 +49,7 @@ void main() async {
Provider<GroupingAnalyzerService>( Provider<GroupingAnalyzerService>(
create: (_) => GroupingAnalyzerService(), create: (_) => GroupingAnalyzerService(),
), ),
Provider<SessionRepository>( Provider<SessionRepository>(create: (_) => SessionRepository()),
create: (_) => SessionRepository(),
),
], ],
child: const BullyApp(), child: const BullyApp(),
), ),

View File

@@ -676,4 +676,399 @@ class DistortionCorrectionService {
points[2] = br; points[2] = br;
points[3] = bl; points[3] = bl;
} }
/// Corrige la perspective en reformant le plus grand ovale (ellipse) en un cercle parfait,
/// sans recadrer agressivement l'image entière.
Future<String> correctPerspectiveUsingOvals(String imagePath) async {
try {
final src = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (src.isEmpty) throw Exception("Impossible de charger l'image");
final gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY);
final blurred = cv.gaussianBlur(gray, (5, 5), 0);
final thresh = cv.threshold(
blurred,
0,
255,
cv.THRESH_BINARY | cv.THRESH_OTSU,
);
final edges = cv.canny(blurred, thresh.$1 * 0.5, thresh.$1);
final contoursResult = cv.findContours(
edges,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE,
);
final contours = contoursResult.$1;
if (contours.isEmpty) return imagePath;
cv.RotatedRect? bestEllipse;
double maxArea = 0;
for (final contour in contours) {
if (contour.length < 5) continue;
final area = cv.contourArea(contour);
if (area < 1000) continue;
final ellipse = cv.fitEllipse(contour);
if (area > maxArea) {
maxArea = area;
bestEllipse = ellipse;
}
}
if (bestEllipse == null) return imagePath;
// The goal here is to morph the bestEllipse into a perfect circle, while
// keeping the image the same size and the center of the ellipse in the same place.
// We'll use the average of the width and height (or max) to define the target circle
final targetRadius =
math.max(bestEllipse.size.width, bestEllipse.size.height) / 2.0;
// Extract the 4 bounding box points of the ellipse
final boxPoints = cv.boxPoints(bestEllipse);
final List<cv.Point> srcPoints = [];
for (int i = 0; i < boxPoints.length; i++) {
srcPoints.add(cv.Point(boxPoints[i].x.toInt(), boxPoints[i].y.toInt()));
}
_sortPoints(srcPoints);
// Calculate the size of the perfectly squared output image
final int side = (targetRadius * 2).toInt();
final List<cv.Point> dstPoints = [
cv.Point(0, 0), // Top-Left
cv.Point(side, 0), // Top-Right
cv.Point(side, side), // Bottom-Right
cv.Point(0, side), // Bottom-Left
];
// Morph the target region into a perfect square, cropping the rest of the image
final M = cv.getPerspectiveTransform(
cv.VecPoint.fromList(srcPoints),
cv.VecPoint.fromList(dstPoints),
);
final corrected = cv.warpPerspective(src, M, (side, side));
final tempDir = await getTemporaryDirectory();
final timestamp = DateTime.now().millisecondsSinceEpoch;
final outputPath = '${tempDir.path}/corrected_oval_$timestamp.jpg';
cv.imwrite(outputPath, corrected);
return outputPath;
} catch (e) {
print('Erreur correction perspective ovales: $e');
return imagePath;
}
}
/// Corrige la distorsion et la profondeur (perspective) en créant un maillage
/// basé sur la concentricité des différents cercles de la cible pour trouver le meilleur plan.
Future<String> correctPerspectiveWithConcentricMesh(String imagePath) async {
try {
final src = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (src.isEmpty) throw Exception("Impossible de charger l'image");
final gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY);
final blurred = cv.gaussianBlur(gray, (5, 5), 0);
final thresh = cv.threshold(
blurred,
0,
255,
cv.THRESH_BINARY | cv.THRESH_OTSU,
);
final edges = cv.canny(blurred, thresh.$1 * 0.5, thresh.$1);
final contoursResult = cv.findContours(
edges,
cv.RETR_LIST,
cv.CHAIN_APPROX_SIMPLE,
);
final contours = contoursResult.$1;
if (contours.isEmpty) return imagePath;
List<cv.RotatedRect> ellipses = [];
for (final contour in contours) {
if (contour.length < 5) continue;
if (cv.contourArea(contour) < 500) continue;
ellipses.add(cv.fitEllipse(contour));
}
if (ellipses.isEmpty) return imagePath;
// Find the largest ellipse to serve as our central reference
ellipses.sort(
(a, b) => (b.size.width * b.size.height).compareTo(
a.size.width * a.size.height,
),
);
final largestEllipse = ellipses.first;
final maxDist =
math.max(largestEllipse.size.width, largestEllipse.size.height) *
0.15;
// Group all ellipses that are roughly concentric with the largest one
List<cv.RotatedRect> concentricGroup = [];
for (final e in ellipses) {
final dx = e.center.x - largestEllipse.center.x;
final dy = e.center.y - largestEllipse.center.y;
if (math.sqrt(dx * dx + dy * dy) < maxDist) {
concentricGroup.add(e);
}
}
if (concentricGroup.length < 2) {
print(
"Pas assez de cercles concentriques pour le maillage, utilisation de la méthode simple.",
);
return await correctPerspectiveUsingOvals(imagePath);
}
final targetRadius =
math.max(largestEllipse.size.width, largestEllipse.size.height) / 2.0;
final int side = (targetRadius * 2.4).toInt(); // Add padding
final double cx = side / 2.0;
final double cy = side / 2.0;
List<cv.Point2f> srcPointsList = [];
List<cv.Point2f> dstPointsList = [];
for (final ellipse in concentricGroup) {
final box = cv.boxPoints(ellipse);
final m0 = cv.Point2f(
(box[0].x + box[1].x) / 2,
(box[0].y + box[1].y) / 2,
);
final m1 = cv.Point2f(
(box[1].x + box[2].x) / 2,
(box[1].y + box[2].y) / 2,
);
final m2 = cv.Point2f(
(box[2].x + box[3].x) / 2,
(box[2].y + box[3].y) / 2,
);
final m3 = cv.Point2f(
(box[3].x + box[0].x) / 2,
(box[3].y + box[0].y) / 2,
);
final d02 = math.sqrt(
math.pow(m0.x - m2.x, 2) + math.pow(m0.y - m2.y, 2),
);
final d13 = math.sqrt(
math.pow(m1.x - m3.x, 2) + math.pow(m1.y - m3.y, 2),
);
cv.Point2f maj1, maj2, min1, min2;
double r;
if (d02 > d13) {
maj1 = m0;
maj2 = m2;
min1 = m1;
min2 = m3;
r = d02 / 2.0;
} else {
maj1 = m1;
maj2 = m3;
min1 = m0;
min2 = m2;
r = d13 / 2.0;
}
// Sort maj1 and maj2 so maj1 is left/top
if ((maj1.x - maj2.x).abs() > (maj1.y - maj2.y).abs()) {
if (maj1.x > maj2.x) {
final t = maj1;
maj1 = maj2;
maj2 = t;
}
} else {
if (maj1.y > maj2.y) {
final t = maj1;
maj1 = maj2;
maj2 = t;
}
}
// Sort min1 and min2 so min1 is top/left
if ((min1.y - min2.y).abs() > (min1.x - min2.x).abs()) {
if (min1.y > min2.y) {
final t = min1;
min1 = min2;
min2 = t;
}
} else {
if (min1.x > min2.x) {
final t = min1;
min1 = min2;
min2 = t;
}
}
srcPointsList.addAll([maj1, maj2, min1, min2]);
dstPointsList.addAll([
cv.Point2f(cx - r, cy),
cv.Point2f(cx + r, cy),
cv.Point2f(cx, cy - r),
cv.Point2f(cx, cy + r),
]);
// Add ellipse centers mapping perfectly to the origin to force concentric depth alignment
srcPointsList.add(cv.Point2f(ellipse.center.x, ellipse.center.y));
dstPointsList.add(cv.Point2f(cx, cy));
}
// We explicitly convert points to VecPoint to use findHomography standard binding
final srcVec = cv.VecPoint.fromList(
srcPointsList.map((p) => cv.Point(p.x.toInt(), p.y.toInt())).toList(),
);
final dstVec = cv.VecPoint.fromList(
dstPointsList.map((p) => cv.Point(p.x.toInt(), p.y.toInt())).toList(),
);
final M = cv.findHomography(
cv.Mat.fromVec(srcVec),
cv.Mat.fromVec(dstVec),
method: cv.RANSAC,
);
if (M.isEmpty) {
return await correctPerspectiveUsingOvals(imagePath);
}
final corrected = cv.warpPerspective(src, M, (side, side));
final tempDir = await getTemporaryDirectory();
final timestamp = DateTime.now().millisecondsSinceEpoch;
final outputPath = '${tempDir.path}/corrected_mesh_$timestamp.jpg';
cv.imwrite(outputPath, corrected);
return outputPath;
} catch (e) {
print('Erreur correction perspective maillage concentrique: $e');
return imagePath;
}
}
/// Corrige la perspective en détectant les 4 coins de la feuille (quadrilatère)
///
/// Cette méthode cherche le plus grand polygone à 4 côtés (le bord du papier)
/// et le déforme pour en faire un carré parfait.
Future<String> correctPerspectiveUsingQuadrilateral(String imagePath) async {
try {
final src = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (src.isEmpty) throw Exception("Impossible de charger l'image");
final gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY);
// Flou plus important pour ignorer les détails internes (cercles, trous)
final blurred = cv.gaussianBlur(gray, (9, 9), 0);
// Canny edge detector
final thresh = cv.threshold(
blurred,
0,
255,
cv.THRESH_BINARY | cv.THRESH_OTSU,
);
final edges = cv.canny(blurred, thresh.$1 * 0.5, thresh.$1);
// Pour la détection de la feuille (les bords peuvent être discontinus à cause de l'éclairage)
final kernel = cv.getStructuringElement(cv.MORPH_RECT, (5, 5));
final closedEdges = cv.morphologyEx(edges, cv.MORPH_CLOSE, kernel);
// Find contours
final contoursResult = cv.findContours(
closedEdges,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE,
);
final contours = contoursResult.$1;
cv.VecPoint? bestQuad;
double maxArea = 0;
final minArea = src.rows * src.cols * 0.1; // Au moins 10% de l'image
for (final contour in contours) {
final area = cv.contourArea(contour);
if (area < minArea) continue;
final peri = cv.arcLength(contour, true);
// Approximation polygonale (tolérance = 2% à 5% du périmètre)
final approx = cv.approxPolyDP(contour, 0.04 * peri, true);
if (approx.length == 4) {
if (area > maxArea) {
maxArea = area;
bestQuad = approx;
}
}
}
// Fallback
if (bestQuad == null) {
print(
"Aucun papier quadrilatère détecté, on utilise les cercles à la place.",
);
return await correctPerspectiveUsingCircles(imagePath);
}
// Convert to List<cv.Point>
final List<cv.Point> srcPoints = [];
for (int i = 0; i < bestQuad.length; i++) {
srcPoints.add(bestQuad[i]);
}
_sortPoints(srcPoints);
// Calculate max width and height
double widthA = _distanceCV(srcPoints[2], srcPoints[3]);
double widthB = _distanceCV(srcPoints[1], srcPoints[0]);
int dstWidth = math.max(widthA, widthB).toInt();
double heightA = _distanceCV(srcPoints[1], srcPoints[2]);
double heightB = _distanceCV(srcPoints[0], srcPoints[3]);
int dstHeight = math.max(heightA, heightB).toInt();
// Since standard target paper forms a square, we force the resulting warp to be a perfect square.
int side = math.max(dstWidth, dstHeight);
final List<cv.Point> dstPoints = [
cv.Point(0, 0),
cv.Point(side, 0),
cv.Point(side, side),
cv.Point(0, side),
];
final M = cv.getPerspectiveTransform(
cv.VecPoint.fromList(srcPoints),
cv.VecPoint.fromList(dstPoints),
);
final corrected = cv.warpPerspective(src, M, (side, side));
final tempDir = await getTemporaryDirectory();
final timestamp = DateTime.now().millisecondsSinceEpoch;
final outputPath = '${tempDir.path}/corrected_quad_$timestamp.jpg';
cv.imwrite(outputPath, corrected);
return outputPath;
} catch (e) {
print('Erreur correction perspective quadrilatère: $e');
// Fallback
return await correctPerspectiveUsingCircles(imagePath);
}
}
double _distanceCV(cv.Point p1, cv.Point p2) {
final dx = p2.x - p1.x;
final dy = p2.y - p1.y;
return math.sqrt(dx * dx + dy * dy);
}
} }

View File

@@ -153,7 +153,7 @@ class OpenCVImpactDetectionService {
); );
final contours = contoursResult.$1; final contours = contoursResult.$1;
// hierarchy is item2 // hierarchy is $2
for (int i = 0; i < contours.length; i++) { for (int i = 0; i < contours.length; i++) {
final contour = contours[i]; final contour = contours[i];

View File

@@ -45,14 +45,14 @@ class OpenCVTargetService {
final circles = cv.HoughCircles( final circles = cv.HoughCircles(
blurred, blurred,
cv.HOUGH_GRADIENT, cv.HOUGH_GRADIENT,
1, // dp: Inverse ratio of the accumulator resolution to the image resolution 1, // dp
(img.rows / 8) (img.rows / 16)
.toDouble(), // minDist: Minimum distance between the centers of the detected circles .toDouble(), // minDist decreased to allow more rings in same general area
param1: 100, // param1: Gradient value for Canny edge detection param1: 100, // Canny edge detection
param2: param2:
30, // param2: Accumulator threshold for the circle centers at the detection stage 60, // Accumulator threshold (higher = fewer false circles, more accurate)
minRadius: img.cols ~/ 20, // minRadius minRadius: img.cols ~/ 20,
maxRadius: img.cols ~/ 2, // maxRadius maxRadius: img.cols ~/ 2,
); );
// HoughCircles returns a Mat of shape (1, N, 3) where N is number of circles. // HoughCircles returns a Mat of shape (1, N, 3) where N is number of circles.
@@ -79,8 +79,8 @@ class OpenCVTargetService {
cv.HOUGH_GRADIENT, cv.HOUGH_GRADIENT,
1, 1,
(img.rows / 8).toDouble(), (img.rows / 8).toDouble(),
param1: 50, param1: 100,
param2: 20, param2: 40,
minRadius: img.cols ~/ 20, minRadius: img.cols ~/ 20,
maxRadius: img.cols ~/ 2, maxRadius: img.cols ~/ 2,
); );
@@ -137,18 +137,22 @@ class OpenCVTargetService {
for (final circle in detected) { for (final circle in detected) {
bool added = false; bool added = false;
for (final cluster in clusters) { for (final cluster in clusters) {
// Check distance to cluster center (average of existing) // Calculate the actual center of the cluster based on the smallest circle (the likely bullseye)
double clusterX = 0; double clusterCenterX = cluster.first.x;
double clusterY = 0; double clusterCenterY = cluster.first.y;
double minRadiusInCluster = cluster.first.r;
for (final c in cluster) { for (final c in cluster) {
clusterX += c.x; if (c.r < minRadiusInCluster) {
clusterY += c.y; minRadiusInCluster = c.r;
clusterCenterX = c.x;
clusterCenterY = c.y;
}
} }
clusterX /= cluster.length;
clusterY /= cluster.length;
final dist = math.sqrt( final dist = math.sqrt(
math.pow(circle.x - clusterX, 2) + math.pow(circle.y - clusterY, 2), math.pow(circle.x - clusterCenterX, 2) +
math.pow(circle.y - clusterCenterY, 2),
); );
if (dist < tolerance) { if (dist < tolerance) {
@@ -171,10 +175,10 @@ class OpenCVTargetService {
for (final cluster in clusters) { for (final cluster in clusters) {
// Score calculation // Score calculation
// Base score = number of circles * 10 // Base score = number of circles squared (heavily favor concentric rings)
double score = cluster.length * 10.0; double score = math.pow(cluster.length, 2).toDouble() * 10.0;
// Penalize distance from center // Small penalty for distance from center (only as tie-breaker)
double cx = 0, cy = 0; double cx = 0, cy = 0;
for (final c in cluster) { for (final c in cluster) {
cx += c.x; cx += c.x;
@@ -188,7 +192,8 @@ class OpenCVTargetService {
); );
final relDist = distFromCenter / math.min(width, height); final relDist = distFromCenter / math.min(width, height);
score -= relDist * 5.0; // Moderate penalty for off-center score -=
relDist * 2.0; // Very minor penalty so we don't snap to screen center
// Penalize very small clusters if they are just noise // Penalize very small clusters if they are just noise
// (Optional: check if radii are somewhat distributed?) // (Optional: check if radii are somewhat distributed?)

View File

@@ -2,9 +2,12 @@ import 'dart:math' as math;
import '../data/models/target_type.dart'; import '../data/models/target_type.dart';
import 'image_processing_service.dart'; import 'image_processing_service.dart';
import 'opencv_impact_detection_service.dart'; import 'opencv_impact_detection_service.dart';
import 'yolo_impact_detection_service.dart';
export 'image_processing_service.dart' show ImpactDetectionSettings, ReferenceImpact, ImpactCharacteristics; export 'image_processing_service.dart'
export 'opencv_impact_detection_service.dart' show OpenCVDetectionSettings, OpenCVDetectedImpact; show ImpactDetectionSettings, ReferenceImpact, ImpactCharacteristics;
export 'opencv_impact_detection_service.dart'
show OpenCVDetectionSettings, OpenCVDetectedImpact;
class TargetDetectionResult { class TargetDetectionResult {
final double centerX; // Relative (0-1) final double centerX; // Relative (0-1)
@@ -52,18 +55,19 @@ class DetectedImpactResult {
class TargetDetectionService { class TargetDetectionService {
final ImageProcessingService _imageProcessingService; final ImageProcessingService _imageProcessingService;
final OpenCVImpactDetectionService _opencvService; final OpenCVImpactDetectionService _opencvService;
final YOLOImpactDetectionService _yoloService;
TargetDetectionService({ TargetDetectionService({
ImageProcessingService? imageProcessingService, ImageProcessingService? imageProcessingService,
OpenCVImpactDetectionService? opencvService, OpenCVImpactDetectionService? opencvService,
}) : _imageProcessingService = imageProcessingService ?? ImageProcessingService(), YOLOImpactDetectionService? yoloService,
_opencvService = opencvService ?? OpenCVImpactDetectionService(); }) : _imageProcessingService =
imageProcessingService ?? ImageProcessingService(),
_opencvService = opencvService ?? OpenCVImpactDetectionService(),
_yoloService = yoloService ?? YOLOImpactDetectionService();
/// Detect target and impacts from an image file /// Detect target and impacts from an image file
TargetDetectionResult detectTarget( TargetDetectionResult detectTarget(String imagePath, TargetType targetType) {
String imagePath,
TargetType targetType,
) {
try { try {
// Detect main target // Detect main target
final mainTarget = _imageProcessingService.detectMainTarget(imagePath); final mainTarget = _imageProcessingService.detectMainTarget(imagePath);
@@ -84,7 +88,13 @@ class TargetDetectionService {
// Convert impacts to relative coordinates and calculate scores // Convert impacts to relative coordinates and calculate scores
final detectedImpacts = impacts.map((impact) { final detectedImpacts = impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScore(impact.x, impact.y, centerX, centerY, radius) ? _calculateConcentricScore(
impact.x,
impact.y,
centerX,
centerY,
radius,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -177,7 +187,13 @@ class TargetDetectionService {
return impacts.map((impact) { return impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings( ? _calculateConcentricScoreWithRings(
impact.x, impact.y, centerX, centerY, radius, ringCount) impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -221,7 +237,10 @@ class TargetDetectionService {
String imagePath, String imagePath,
List<ReferenceImpact> references, List<ReferenceImpact> references,
) { ) {
return _imageProcessingService.analyzeReferenceImpacts(imagePath, references); return _imageProcessingService.analyzeReferenceImpacts(
imagePath,
references,
);
} }
/// Detect impacts based on reference characteristics (calibrated detection) /// Detect impacts based on reference characteristics (calibrated detection)
@@ -245,7 +264,13 @@ class TargetDetectionService {
return impacts.map((impact) { return impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings( ? _calculateConcentricScoreWithRings(
impact.x, impact.y, centerX, centerY, radius, ringCount) impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -283,7 +308,13 @@ class TargetDetectionService {
return impacts.map((impact) { return impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings( ? _calculateConcentricScoreWithRings(
impact.x, impact.y, centerX, centerY, radius, ringCount) impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -315,9 +346,7 @@ class TargetDetectionService {
}) { }) {
try { try {
// Convertir les références au format OpenCV // Convertir les références au format OpenCV
final refPoints = references final refPoints = references.map((r) => (x: r.x, y: r.y)).toList();
.map((r) => (x: r.x, y: r.y))
.toList();
final impacts = _opencvService.detectFromReferences( final impacts = _opencvService.detectFromReferences(
imagePath, imagePath,
@@ -328,7 +357,13 @@ class TargetDetectionService {
return impacts.map((impact) { return impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings( ? _calculateConcentricScoreWithRings(
impact.x, impact.y, centerX, centerY, radius, ringCount) impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -343,4 +378,46 @@ class TargetDetectionService {
return []; return [];
} }
} }
/// Détecte les impacts en utilisant YOLOv8
Future<List<DetectedImpactResult>> detectImpactsWithYOLO(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
) async {
try {
final impacts = await _yoloService.detectImpacts(imagePath);
return impacts.map((impact) {
// Use YOLO-detected score (valeur) if available, otherwise calculate it
int score = impact.suggestedScore;
if (score <= 0) {
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) {
print('Erreur détection YOLOv8: $e');
return [];
}
}
} }

View File

@@ -0,0 +1,217 @@
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:tflite_flutter/tflite_flutter.dart';
import 'package:image/image.dart' as img;
import 'target_detection_service.dart';
class YOLOImpactDetectionService {
Interpreter? _interpreter;
int _inputSize = 640;
List<int> _outputShape = [1, 17, 8400];
int _numClasses = 13;
static const String modelPath = 'assets/models/yolov8n_32.tflite';
static const String labelsPath = 'assets/models/labels.txt';
Future<void> init() async {
if (_interpreter != null) return;
try {
_interpreter = await Interpreter.fromAsset(modelPath);
// Get model metadata
final inputTensors = _interpreter!.getInputTensors();
if (inputTensors.isNotEmpty) {
// [1, 640, 640, 3] or [1, 3, 640, 640]
final shape = inputTensors[0].shape;
if (shape.length == 4) {
_inputSize = shape[1] == 3 ? shape[2] : shape[1];
}
}
final outputTensors = _interpreter!.getOutputTensors();
if (outputTensors.isNotEmpty) {
_outputShape = outputTensors[0].shape;
// Output is usually [1, 4 + num_classes, num_boxes]
if (_outputShape.length == 3) {
_numClasses = _outputShape[1] - 4;
}
}
print('YOLO Interpreter loaded successfully');
print('Model Input Size: $_inputSize');
print('Model Output Shape: $_outputShape (Classes: $_numClasses)');
} catch (e) {
print('Error loading YOLO model: $e');
}
}
Future<List<DetectedImpactResult>> detectImpacts(String imagePath) async {
if (_interpreter == null) await init();
if (_interpreter == null) return [];
try {
final bytes = File(imagePath).readAsBytesSync();
final decodedImage = img.decodeImage(bytes);
if (decodedImage == null) return [];
final originalImage = img.bakeOrientation(decodedImage);
final resizedImage = img.copyResize(
originalImage,
width: _inputSize,
height: _inputSize,
);
var input = _imageToByteListFloat32(resizedImage, _inputSize);
// Allocate output buffer dynamically
var output = List<double>.filled(
_outputShape.fold(1, (a, b) => a * b),
0
).reshape(_outputShape);
_interpreter!.run(input, output);
final results = _processOutput(
output[0],
originalImage.width,
originalImage.height,
);
print('YOLO Detection result count: ${results.length}');
return results;
} catch (e) {
print('Error during YOLO inference: $e');
return [];
}
}
List<DetectedImpactResult> _processOutput(
List<List<double>> output,
int imgWidth,
int imgHeight,
) {
final List<_Detection> candidates = [];
const double threshold = 0.25;
// output is typically [4 + numClasses, numBoxes]
final int rows = output.length;
final int numBoxes = output[0].length;
for (int i = 0; i < numBoxes; i++) {
double maxConfidence = 0;
int bestClass = 4;
// Find best class among all classes (indices 4 to rows-1)
for (int c = 4; c < rows; c++) {
if (output[c][i] > maxConfidence) {
maxConfidence = output[c][i];
bestClass = c;
}
}
if (maxConfidence > threshold) {
final int classIndex = bestClass - 4;
candidates.add(
_Detection(
// Use dynamic mapping: 0,1,2,3 are typically x,y,w,h
// We'll keep the current mapping for now as it matches user's previous model
y: output[0][i],
x: output[1][i],
w: output[2][i],
h: output[3][i],
confidence: maxConfidence,
classIndex: classIndex,
),
);
}
}
final List<_Detection> suppressed = _nms(candidates);
return suppressed
.map(
(det) {
// Score = ClassIndex (0 to 10) for impact models
int score = det.classIndex;
return DetectedImpactResult(
x: det.x,
y: det.y,
radius: 5.0,
suggestedScore: score,
);
},
)
.toList();
}
List<_Detection> _nms(List<_Detection> detections) {
if (detections.isEmpty) return [];
detections.sort((a, b) => b.confidence.compareTo(a.confidence));
final List<_Detection> selected = [];
final List<bool> active = List.filled(detections.length, true);
for (int i = 0; i < detections.length; i++) {
if (!active[i]) continue;
selected.add(detections[i]);
for (int j = i + 1; j < detections.length; j++) {
if (!active[j]) continue;
if (_iou(detections[i], detections[j]) > 0.45) {
active[j] = false;
}
}
}
return selected;
}
double _iou(_Detection a, _Detection b) {
final double areaA = a.w * a.h;
final double areaB = b.w * b.h;
final double x1 = math.max(a.x - a.w / 2, b.x - b.w / 2);
final double y1 = math.max(a.y - a.h / 2, b.y - b.h / 2);
final double x2 = math.min(a.x + a.w / 2, b.x + b.w / 2);
final double y2 = math.min(a.y + a.h / 2, b.y + b.h / 2);
final double intersection = math.max(0.0, x2 - x1) * math.max(0.0, y2 - y1);
return intersection / (areaA + areaB - intersection);
}
Uint8List _imageToByteListFloat32(img.Image image, int inputSize) {
var convertedBytes = Float32List(1 * inputSize * inputSize * 3);
var buffer = Float32List.view(convertedBytes.buffer);
int pixelIndex = 0;
for (int i = 0; i < inputSize; i++) {
for (int j = 0; j < inputSize; j++) {
var pixel = image.getPixel(j, i);
buffer[pixelIndex++] = (pixel.r / 255.0);
buffer[pixelIndex++] = (pixel.g / 255.0);
buffer[pixelIndex++] = (pixel.b / 255.0);
}
}
return convertedBytes.buffer.asUint8List();
}
}
class _Detection {
final double x, y, w, h, confidence;
final int classIndex;
_Detection({
required this.x,
required this.y,
required this.w,
required this.h,
required this.confidence,
required this.classIndex,
});
}

View File

@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
tflite_flutter
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)

View File

@@ -536,6 +536,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
quiver:
dependency: transitive
description:
name: quiver
sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2
url: "https://pub.dev"
source: hosted
version: "3.2.2"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -653,6 +661,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.9" version: "0.7.9"
tflite_flutter:
dependency: "direct main"
description:
name: tflite_flutter
sha256: "48e6fde2ad97162bb66a16a142f4c4698add9e8cd397ce9d1cc7451b55537ac1"
url: "https://pub.dev"
source: hosted
version: "0.11.0"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:

View File

@@ -64,6 +64,9 @@ dependencies:
# Image processing for impact detection # Image processing for impact detection
image: ^4.1.7 image: ^4.1.7
# Machine Learning for YOLOv8
tflite_flutter: ^0.11.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
@@ -87,7 +90,8 @@ flutter:
uses-material-design: true uses-material-design: true
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: assets:
- assets/models/yolov8n_32.tflite
# - images/a_dot_burr.jpeg # - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg # - images/a_dot_ham.jpeg

View File

@@ -0,0 +1,12 @@
import 'package:opencv_dart/opencv_dart.dart' as cv;
void main() {
var p1 = cv.VecPoint.fromList([cv.Point(0, 0), cv.Point(1, 1)]);
var p2 = cv.VecPoint2f.fromList([cv.Point2f(0, 0), cv.Point2f(1, 1)]);
// Is it p1.mat ?
// Or is it cv.findHomography(p1, p1) but actually needs specific types ?
cv.Mat mat1 = cv.Mat.fromVec(p1);
cv.Mat mat2 = cv.Mat.fromVec(p2);
cv.findHomography(mat1, mat2);
}

View File

@@ -0,0 +1,7 @@
import 'package:opencv_dart/opencv_dart.dart' as cv;
void main() {
print(cv.approxPolyDP);
print(cv.arcLength);
print(cv.contourArea);
}

View File

@@ -0,0 +1,5 @@
import 'package:opencv_dart/opencv_dart.dart' as cv;
void main() {
print(cv.findHomography);
}

View File

@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
tflite_flutter
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)