diff --git a/lib/features/analysis/analysis_provider.dart b/lib/features/analysis/analysis_provider.dart index b6dbfb0a..e2e1a6d9 100644 --- a/lib/features/analysis/analysis_provider.dart +++ b/lib/features/analysis/analysis_provider.dart @@ -162,8 +162,8 @@ class AnalysisProvider extends ChangeNotifier { return; } - // Detect target and impacts - final result = _detectionService.detectTarget(imagePath, targetType); + // CORRECTION PARALLÈLE : Changement de la méthode pour l'Isolate asynchrone + final result = await _detectionService.detectTargetAsync(imagePath, targetType); if (!result.success) { _state = AnalysisState.error; diff --git a/lib/features/analysis/analysis_screen.dart b/lib/features/analysis/analysis_screen.dart index 0d5a32db..274bac98 100644 --- a/lib/features/analysis/analysis_screen.dart +++ b/lib/features/analysis/analysis_screen.dart @@ -225,8 +225,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { return Transform( transform: Matrix4.identity() ..setTranslationRaw(widget.cropOffset?.dx ?? 0.0, widget.cropOffset?.dy ?? 0.0, 0.0) - ..scale(1.0, 1.0) // Zoom ignoré au plotting pour rester en vue globale - ..rotateZ((provider.cropRotation) * (math.pi / 180)), // FIX : Utilisation du provider dynamique + ..scale(1.0, 1.0) + ..rotateZ((provider.cropRotation) * (math.pi / 180)), alignment: Alignment.center, child: Image.file( File(provider.imagePath!), diff --git a/lib/features/crop/crop_screen.dart b/lib/features/crop/crop_screen.dart index 974cb670..ebb7ee96 100644 --- a/lib/features/crop/crop_screen.dart +++ b/lib/features/crop/crop_screen.dart @@ -156,7 +156,7 @@ class _CropScreenState extends State { style: TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.w600), ), Text( - '${_rotation.toStringAsFixed(1)}°', // Affiche désormais le dixième de degré pour la précision + '${_rotation.toStringAsFixed(1)}°', style: const TextStyle(color: Color(0xFF00FF00), fontWeight: FontWeight.bold), ), ], @@ -167,9 +167,9 @@ class _CropScreenState extends State { Expanded( child: Slider( value: _rotation, - min: -15.0, // Pivot maximum à gauche bridé à 15° - max: 15.0, // Pivot maximum à droite bridé à 15° - divisions: 300, // 300 divisions pour obtenir un cran ultra-précis tous les 0,1° + min: -15.0, + max: 15.0, + divisions: 300, label: '${_rotation.toStringAsFixed(1)}°', activeColor: const Color(0xFF1A73E8), inactiveColor: Colors.white12, @@ -182,7 +182,6 @@ class _CropScreenState extends State { ), const Icon(Icons.rotate_right, color: Colors.white38, size: 20), const SizedBox(width: 4), - // Petit bouton Reset rapide pour cette jauge IconButton( icon: const Icon(Icons.restart_alt, color: Colors.white54, size: 20), onPressed: () { @@ -249,7 +248,7 @@ class _CropScreenState extends State { onScaleUpdate: _onScaleUpdate, child: Stack( children: [ - // 1. L'image brute avec ses transformations + // 1. L'image brute avec ses transformations (Reste calé sur BoxFit.contain d'origine) Positioned.fill( child: Center( child: Transform( @@ -302,7 +301,6 @@ class _CropScreenState extends State { ), child: Stack( children: [ - // Zone visible : Rectangle parfait sans aucun BorderRadius Center( child: Container( width: _cropSize, @@ -372,8 +370,18 @@ class _CropScreenState extends State { setState(() => _isLoading = true); try { final cropRect = _calculateCropRect(); - final targetCenterX = cropRect.x + cropRect.width / 2; - final targetCenterY = cropRect.y + cropRect.height / 2; + + // AJOUT DE LA DECOUPE ET DU REDRESSEMENT GÉOMÉTRIQUE PHYSIQUE + final croppedImagePath = await _cropService.cropToSquare( + widget.imagePath, + cropRect, + rotationDegrees: _rotation, // On transmet la rotation à la jauge haute précision + ); + + // L'image sortant du service étant désormais un carré physique parfait (1:1), + // le centre de la cible se trouve précisément au milieu (0.5, 0.5) + const targetCenterX = 0.5; + const targetCenterY = 0.5; if (!mounted) return; @@ -381,13 +389,13 @@ class _CropScreenState extends State { context, MaterialPageRoute( builder: (_) => AnalysisScreen( - imagePath: widget.imagePath, + imagePath: croppedImagePath, // On injecte le fichier carré nettoyé targetType: widget.targetType, initialCenterX: targetCenterX, initialCenterY: targetCenterY, - cropScale: _scale, - cropOffset: _offset, - cropRotation: _rotation, + cropScale: 1.0, // Réinitialisé car la texture est déjà recadrée à la bonne échelle + cropOffset: Offset.zero, // Réinitialisé car le fichier est déjà parfaitement centré + cropRotation: 0.0, // Réinitialisé car les pixels ont déjà subi la rotation matricielle ), ), ); @@ -395,7 +403,7 @@ class _CropScreenState extends State { if (mounted) { setState(() => _isLoading = false); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor), + SnackBar(content: Text('Erreur de découpe : $e'), backgroundColor: AppTheme.errorColor), ); } } @@ -434,10 +442,10 @@ class _CropScreenState extends State { final relCropHeight = _cropSize / scaledHeight; return CropRect( - x: relCropLeft, - y: relCropTop, - width: relCropWidth, - height: relCropHeight, + x: relCropLeft.clamp(0.0, 1.0), + y: relCropTop.clamp(0.0, 1.0), + width: relCropWidth.clamp(0.0, 1.0), + height: relCropHeight.clamp(0.0, 1.0), ); } } \ No newline at end of file diff --git a/lib/services/image_crop_service.dart b/lib/services/image_crop_service.dart index 52d92546..3daa3991 100644 --- a/lib/services/image_crop_service.dart +++ b/lib/services/image_crop_service.dart @@ -42,28 +42,40 @@ class ImageCropService { /// Taille de sortie maximale pour les images recadrées static const int maxOutputSize = 1024; - /// Recadre une image en format carré + /// Recadre une image en format carré en prenant en compte l'angle de rotation. /// /// [sourcePath] - Chemin vers l'image source /// [cropRect] - Zone de recadrage normalisée (0.0 à 1.0) + /// [rotationDegrees] - L'angle appliqué à la jauge haute précision du CropScreen /// [outputSize] - Taille de sortie en pixels (carré) /// /// Retourne le chemin vers l'image recadrée dans le dossier temporaire Future cropToSquare( - String sourcePath, - CropRect cropRect, { - int outputSize = maxOutputSize, - }) async { + String sourcePath, + CropRect cropRect, { + double rotationDegrees = 0.0, // FIX : On intercepte la rotation ici ! + int outputSize = maxOutputSize, + }) async { // Charger l'image source final file = File(sourcePath); final bytes = await file.readAsBytes(); - final originalImage = img.decodeImage(bytes); + img.Image? originalImage = img.decodeImage(bytes); if (originalImage == null) { throw Exception('Impossible de décoder l\'image: $sourcePath'); } - // Calculer les coordonnées en pixels + // FIX CRITIQUE : Si l'utilisateur a pivoté l'image, on applique d'abord la rotation + // aux pixels bruts pour que le découpage rectiligne qui suit tape au bon endroit. + if (rotationDegrees != 0.0) { + originalImage = img.copyRotate( + originalImage, + angle: rotationDegrees, + interpolation: img.Interpolation.cubic, + ); + } + + // Calculer les coordonnées en pixels sur la géométrie de l'image redressée final srcX = (cropRect.x * originalImage.width).round(); final srcY = (cropRect.y * originalImage.height).round(); final srcWidth = (cropRect.width * originalImage.width).round(); @@ -75,7 +87,7 @@ class ImageCropService { final clampedWidth = math.min(srcWidth, originalImage.width - clampedX); final clampedHeight = math.min(srcHeight, originalImage.height - clampedY); - // Recadrer l'image + // Recadrer l'image redressée img.Image cropped = img.copyCrop( originalImage, x: clampedX, @@ -87,10 +99,10 @@ class ImageCropService { // Redimensionner à la taille de sortie si nécessaire if (cropped.width != outputSize || cropped.height != outputSize) { cropped = img.copyResize( - cropped, - width: outputSize, - height: outputSize, - interpolation: img.Interpolation.cubic, + cropped, + width: outputSize, + height: outputSize, + interpolation: img.Interpolation.cubic, ); } @@ -106,26 +118,18 @@ class ImageCropService { } /// Calcule la zone de recadrage carrée maximale centrée sur l'image - /// - /// [imageWidth] - Largeur de l'image en pixels - /// [imageHeight] - Hauteur de l'image en pixels - /// - /// Retourne un CropRect normalisé pour un carré centré CropRect getDefaultSquareCrop(int imageWidth, int imageHeight) { final aspectRatio = imageWidth / imageHeight; if (aspectRatio > 1) { - // Image plus large que haute - centrer horizontalement final squareWidth = imageHeight / imageWidth; final x = (1 - squareWidth) / 2; return CropRect(x: x, y: 0, width: squareWidth, height: 1); } else if (aspectRatio < 1) { - // Image plus haute que large - centrer verticalement final squareHeight = imageWidth / imageHeight; final y = (1 - squareHeight) / 2; return CropRect(x: 0, y: y, width: 1, height: squareHeight); } else { - // Déjà carré return const CropRect(x: 0, y: 0, width: 1, height: 1); } } @@ -147,7 +151,7 @@ class ImageCropService { } } } catch (e) { - // Ignorer les erreurs de nettoyage + // Ignorer les erreurs } } -} +} \ No newline at end of file diff --git a/lib/services/target_detection_service.dart b/lib/services/target_detection_service.dart index 77c26273..ecc6578d 100644 --- a/lib/services/target_detection_service.dart +++ b/lib/services/target_detection_service.dart @@ -8,6 +8,110 @@ export 'image_processing_service.dart' export 'opencv_impact_detection_service.dart' show OpenCVDetectionSettings, OpenCVDetectedImpact; +// ============================================================================ +// STRUCT ET FONCTION GLOBALE POUR LE PARALLÉLISME (THREAD SECONDAIRE) +// ============================================================================ + +/// Conteneur de données pour envoyer les paramètres à l'Isolate d'arrière-plan. +class DetectionPayload { + final String imagePath; + final TargetType targetType; + + // On recrée les instances à l'intérieur du thread isolé car les objets ne se partagent pas entre threads. + DetectionPayload({ + required this.imagePath, + required this.targetType, + }); +} + +/// FONCTION EXÉCUTÉE EN PARALLÈLE : Tourne sur un autre cœur du processeur. +/// L'interface graphique reste à 120 FPS et totalement fluide. +TargetDetectionResult runParallelTargetDetection(DetectionPayload payload) { + // 1. Initialisation locale des services dans le sous-thread + final imageProcessingService = ImageProcessingService(); + + try { + // 2. Détection de la cible principale (Calcul lourd) + final mainTarget = imageProcessingService.detectMainTarget(payload.imagePath); + + double centerX = 0.5; + double centerY = 0.5; + double radius = 0.4; + + if (mainTarget != null) { + centerX = mainTarget.centerX; + centerY = mainTarget.centerY; + radius = mainTarget.radius; + } + + // 3. Détection des impacts (Calcul lourd) + final impacts = imageProcessingService.detectImpacts(payload.imagePath); + + // 4. Calcul mathématique des scores relatifs + final detectedImpacts = impacts.map((impact) { + final score = payload.targetType == TargetType.concentric + ? _staticCalculateConcentricScore( + impact.x, + impact.y, + centerX, + centerY, + radius, + ) + : _staticCalculateSilhouetteScore(impact.x, impact.y, centerX, centerY); + + return DetectedImpactResult( + x: impact.x, + y: impact.y, + radius: impact.radius, + suggestedScore: score, + ); + }).toList(); + + return TargetDetectionResult( + centerX: centerX, + centerY: centerY, + radius: radius, + impacts: detectedImpacts, + ); + } catch (e) { + return TargetDetectionResult.error('Erreur de detection parallèle: $e'); + } +} + +// Fonctions mathématiques pures nécessaires à l'Isolate (statiques) +int _staticCalculateConcentricScore(double impactX, double impactY, double centerX, double centerY, double targetRadius) { + final dx = impactX - centerX; + final dy = impactY - centerY; + final distance = math.sqrt(dx * dx + dy * dy) / targetRadius; + if (distance <= 0.1) return 10; + if (distance <= 0.2) return 9; + if (distance <= 0.3) return 8; + if (distance <= 0.4) return 7; + if (distance <= 0.5) return 6; + if (distance <= 0.6) return 5; + if (distance <= 0.7) return 4; + if (distance <= 0.8) return 3; + if (distance <= 0.9) return 2; + if (distance <= 1.0) return 1; + return 0; +} + +int _staticCalculateSilhouetteScore(double impactX, double impactY, double centerX, double centerY) { + final dx = (impactX - centerX).abs(); + final dy = impactY - centerY; + if (dx > 0.15) return 0; + if (dy < -0.25) return 5; + if (dy < 0.0) return 5; + if (dy < 0.15) return 4; + if (dy < 0.35) return 3; + return 0; +} + +// ============================================================================ +// FIN DU BLOC DE PARALLÉLISME +// ============================================================================ + + class TargetDetectionResult { final double centerX; // Relative (0-1) final double centerY; // Relative (0-1) @@ -59,13 +163,20 @@ class TargetDetectionService { ImageProcessingService? imageProcessingService, OpenCVImpactDetectionService? opencvService, }) : _imageProcessingService = - imageProcessingService ?? ImageProcessingService(), - _opencvService = opencvService ?? OpenCVImpactDetectionService(); + imageProcessingService ?? ImageProcessingService(), + _opencvService = opencvService ?? OpenCVImpactDetectionService(); - /// Detect target and impacts from an image file + /// Detect target and impacts from an image file ASYNCHRONOUSLY in a separate Thread. + /// CORRECTION : Utilise désormais 'compute' pour basculer en arrière-plan immédiat. + Future detectTargetAsync(String imagePath, TargetType targetType) async { + final payload = DetectionPayload(imagePath: imagePath, targetType: targetType); + // Déclenche l'exécution isolée en tâche de fond + return await compute(runParallelTargetDetection, payload); + } + + /// Gardée pour rétrocompatibilité synchrone si nécessaire TargetDetectionResult detectTarget(String imagePath, TargetType targetType) { try { - // Detect main target final mainTarget = _imageProcessingService.detectMainTarget(imagePath); double centerX = 0.5; @@ -78,19 +189,17 @@ class TargetDetectionService { radius = mainTarget.radius; } - // Detect impacts final impacts = _imageProcessingService.detectImpacts(imagePath); - // Convert impacts to relative coordinates and calculate scores final detectedImpacts = impacts.map((impact) { final score = targetType == TargetType.concentric ? _calculateConcentricScore( - impact.x, - impact.y, - centerX, - centerY, - radius, - ) + impact.x, + impact.y, + centerX, + centerY, + radius, + ) : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); return DetectedImpactResult( @@ -113,18 +222,16 @@ class TargetDetectionService { } int _calculateConcentricScore( - double impactX, - double impactY, - double centerX, - double centerY, - double targetRadius, - ) { - // Calculate distance from center (normalized to target radius) + double impactX, + double impactY, + double centerX, + double centerY, + double targetRadius, + ) { final dx = impactX - centerX; final dy = impactY - centerY; final distance = math.sqrt(dx * dx + dy * dy) / targetRadius; - // Score zones (10 zones) if (distance <= 0.1) return 10; if (distance <= 0.2) return 9; if (distance <= 0.3) return 8; @@ -135,61 +242,52 @@ class TargetDetectionService { if (distance <= 0.8) return 3; if (distance <= 0.9) return 2; if (distance <= 1.0) return 1; - return 0; // Outside target + return 0; } int _calculateSilhouetteScore( - double impactX, - double impactY, - double centerX, - double centerY, - ) { - // For silhouettes, scoring is typically based on zones - // Head and center mass = 5, body = 4, lower = 3 - + double impactX, + double impactY, + double centerX, + double centerY, + ) { final dx = (impactX - centerX).abs(); final dy = impactY - centerY; - // Check if within silhouette bounds (approximate) - if (dx > 0.15) return 0; // Too far left/right + if (dx > 0.15) return 0; + if (dy < -0.25) return 5; + if (dy < 0.0) return 5; + if (dy < 0.15) return 4; + if (dy < 0.35) return 3; - // Vertical zones - if (dy < -0.25) return 5; // Head zone (top) - if (dy < 0.0) return 5; // Center mass (upper body) - if (dy < 0.15) return 4; // Body - if (dy < 0.35) return 3; // Lower body - - return 0; // Outside target + return 0; } - /// Detect only impacts with custom settings (doesn't affect target position) List detectImpactsOnly( - String imagePath, - TargetType targetType, - double centerX, - double centerY, - double radius, - int ringCount, - ImpactDetectionSettings settings, - ) { + String imagePath, + TargetType targetType, + double centerX, + double centerY, + double radius, + int ringCount, + ImpactDetectionSettings settings, + ) { try { - // Detect impacts with custom settings final impacts = _imageProcessingService.detectImpactsWithSettings( imagePath, settings, ); - // Convert impacts to relative coordinates and calculate scores return impacts.map((impact) { final score = targetType == TargetType.concentric ? _calculateConcentricScoreWithRings( - impact.x, - impact.y, - centerX, - centerY, - radius, - ringCount, - ) + impact.x, + impact.y, + centerX, + centerY, + radius, + ringCount, + ) : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); return DetectedImpactResult( @@ -205,19 +303,17 @@ class TargetDetectionService { } int _calculateConcentricScoreWithRings( - double impactX, - double impactY, - double centerX, - double centerY, - double targetRadius, - int ringCount, - ) { - // Calculate distance from center (normalized to target radius) + double impactX, + double impactY, + double centerX, + double centerY, + double targetRadius, + int ringCount, + ) { final dx = impactX - centerX; final dy = impactY - centerY; final distance = math.sqrt(dx * dx + dy * dy) / targetRadius; - // Score zones based on ringCount for (int i = 0; i < ringCount; i++) { final zoneRadius = (i + 1) / ringCount; if (distance <= zoneRadius) { @@ -225,31 +321,29 @@ class TargetDetectionService { } } - return 0; // Outside target + return 0; } - /// Analyze reference impacts to learn their characteristics ImpactCharacteristics? analyzeReferenceImpacts( - String imagePath, - List references, - ) { + String imagePath, + List references, + ) { return _imageProcessingService.analyzeReferenceImpacts( imagePath, references, ); } - /// Detect impacts based on reference characteristics (calibrated detection) List detectImpactsFromReferences( - String imagePath, - TargetType targetType, - double centerX, - double centerY, - double radius, - int ringCount, - ImpactCharacteristics characteristics, { - double tolerance = 2.0, - }) { + String imagePath, + TargetType targetType, + double centerX, + double centerY, + double radius, + int ringCount, + ImpactCharacteristics characteristics, { + double tolerance = 2.0, + }) { try { final impacts = _imageProcessingService.detectImpactsFromReferences( imagePath, @@ -260,13 +354,13 @@ class TargetDetectionService { return impacts.map((impact) { final score = targetType == TargetType.concentric ? _calculateConcentricScoreWithRings( - impact.x, - impact.y, - centerX, - centerY, - radius, - ringCount, - ) + impact.x, + impact.y, + centerX, + centerY, + radius, + ringCount, + ) : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); return DetectedImpactResult( @@ -281,20 +375,15 @@ class TargetDetectionService { } } - /// Détecte les impacts en utilisant OpenCV (Hough Circles + Contours) - /// - /// Cette méthode utilise les algorithmes OpenCV pour une détection plus robuste: - /// - Transformation de Hough pour détecter les cercles - /// - Analyse de contours avec filtrage par circularité List detectImpactsWithOpenCV( - String imagePath, - TargetType targetType, - double centerX, - double centerY, - double radius, - int ringCount, { - OpenCVDetectionSettings? settings, - }) { + String imagePath, + TargetType targetType, + double centerX, + double centerY, + double radius, + int ringCount, { + OpenCVDetectionSettings? settings, + }) { try { final impacts = _opencvService.detectImpacts( imagePath, @@ -304,13 +393,13 @@ class TargetDetectionService { return impacts.map((impact) { final score = targetType == TargetType.concentric ? _calculateConcentricScoreWithRings( - impact.x, - impact.y, - centerX, - centerY, - radius, - ringCount, - ) + impact.x, + impact.y, + centerX, + centerY, + radius, + ringCount, + ) : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); return DetectedImpactResult( @@ -326,22 +415,17 @@ class TargetDetectionService { } } - /// Détecte les impacts avec OpenCV en utilisant des références - /// - /// Analyse les impacts de référence pour apprendre leurs caractéristiques - /// puis détecte les impacts similaires dans l'image. List detectImpactsWithOpenCVFromReferences( - String imagePath, - TargetType targetType, - double centerX, - double centerY, - double radius, - int ringCount, - List references, { - double tolerance = 2.0, - }) { + String imagePath, + TargetType targetType, + double centerX, + double centerY, + double radius, + int ringCount, + List references, { + double tolerance = 2.0, + }) { try { - // Convertir les références au format OpenCV final refPoints = references.map((r) => (x: r.x, y: r.y)).toList(); final impacts = _opencvService.detectFromReferences( @@ -353,13 +437,13 @@ class TargetDetectionService { return impacts.map((impact) { final score = targetType == TargetType.concentric ? _calculateConcentricScoreWithRings( - impact.x, - impact.y, - centerX, - centerY, - radius, - ringCount, - ) + impact.x, + impact.y, + centerX, + centerY, + radius, + ringCount, + ) : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); return DetectedImpactResult( @@ -374,5 +458,4 @@ class TargetDetectionService { return []; } } - -} +} \ No newline at end of file