diff --git a/lib/features/analysis/analysis_provider.dart b/lib/features/analysis/analysis_provider.dart index 5c9f11e..b821908 100644 --- a/lib/features/analysis/analysis_provider.dart +++ b/lib/features/analysis/analysis_provider.dart @@ -16,6 +16,7 @@ import '../../data/repositories/session_repository.dart'; import '../../services/target_detection_service.dart'; import '../../services/score_calculator_service.dart'; import '../../services/grouping_analyzer_service.dart'; +import '../../services/distortion_correction_service.dart'; enum AnalysisState { initial, loading, success, error } @@ -24,6 +25,7 @@ class AnalysisProvider extends ChangeNotifier { final ScoreCalculatorService _scoreCalculatorService; final GroupingAnalyzerService _groupingAnalyzerService; final SessionRepository _sessionRepository; + final DistortionCorrectionService _distortionService; final Uuid _uuid = const Uuid(); AnalysisProvider({ @@ -31,10 +33,12 @@ class AnalysisProvider extends ChangeNotifier { required ScoreCalculatorService scoreCalculatorService, required GroupingAnalyzerService groupingAnalyzerService, required SessionRepository sessionRepository, + DistortionCorrectionService? distortionService, }) : _detectionService = detectionService, _scoreCalculatorService = scoreCalculatorService, _groupingAnalyzerService = groupingAnalyzerService, - _sessionRepository = sessionRepository; + _sessionRepository = sessionRepository, + _distortionService = distortionService ?? DistortionCorrectionService(); AnalysisState _state = AnalysisState.initial; String? _errorMessage; @@ -62,6 +66,11 @@ class AnalysisProvider extends ChangeNotifier { List _referenceImpacts = []; ImpactCharacteristics? _learnedCharacteristics; + // Distortion correction + bool _distortionCorrectionEnabled = false; + DistortionParameters? _distortionParams; + String? _correctedImagePath; + // Getters AnalysisState get state => _state; String? get errorMessage => _errorMessage; @@ -83,6 +92,16 @@ class AnalysisProvider extends ChangeNotifier { ImpactCharacteristics? get learnedCharacteristics => _learnedCharacteristics; bool get hasLearnedCharacteristics => _learnedCharacteristics != null; + // Distortion correction getters + bool get distortionCorrectionEnabled => _distortionCorrectionEnabled; + DistortionParameters? get distortionParams => _distortionParams; + String? get correctedImagePath => _correctedImagePath; + bool get hasDistortion => _distortionParams?.needsCorrection ?? false; + /// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon) + String? get displayImagePath => _distortionCorrectionEnabled && _correctedImagePath != null + ? _correctedImagePath + : _imagePath; + /// Analyze an image Future analyzeImage(String imagePath, TargetType targetType) async { _state = AnalysisState.loading; @@ -346,6 +365,46 @@ class AnalysisProvider extends ChangeNotifier { notifyListeners(); } + /// Calcule les paramètres de distorsion basés sur la calibration actuelle + void calculateDistortion() { + _distortionParams = _distortionService.calculateDistortionFromCalibration( + targetCenterX: _targetCenterX, + targetCenterY: _targetCenterY, + targetRadius: _targetRadius, + imageAspectRatio: _imageAspectRatio, + ); + notifyListeners(); + } + + /// Applique la correction de distorsion à l'image + /// Crée une nouvelle image corrigée et la sauvegarde + Future applyDistortionCorrection() async { + if (_imagePath == null || _distortionParams == null) return; + + try { + _correctedImagePath = await _distortionService.applyCorrection( + _imagePath!, + _distortionParams!, + ); + _distortionCorrectionEnabled = true; + notifyListeners(); + } catch (e) { + _errorMessage = 'Erreur lors de la correction: $e'; + notifyListeners(); + } + } + + /// Active ou désactive l'affichage de l'image corrigée + void setDistortionCorrectionEnabled(bool enabled) { + if (enabled && _correctedImagePath == null && _distortionParams != null) { + // Si on active mais pas encore d'image corrigée, la créer + applyDistortionCorrection(); + } else { + _distortionCorrectionEnabled = enabled; + notifyListeners(); + } + } + int _calculateShotScore(double x, double y) { if (_targetType == TargetType.concentric) { return _scoreCalculatorService.calculateConcentricScore( @@ -433,6 +492,9 @@ class AnalysisProvider extends ChangeNotifier { _groupingResult = null; _referenceImpacts = []; _learnedCharacteristics = null; + _distortionCorrectionEnabled = false; + _distortionParams = null; + _correctedImagePath = null; notifyListeners(); } } diff --git a/lib/features/analysis/analysis_screen.dart b/lib/features/analysis/analysis_screen.dart index 287d098..465cfc7 100644 --- a/lib/features/analysis/analysis_screen.dart +++ b/lib/features/analysis/analysis_screen.dart @@ -316,6 +316,61 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { ), ], ), + const Divider(color: Colors.white24, height: 16), + // Distortion correction row + Row( + children: [ + const Icon(Icons.lens_blur, color: Colors.white, size: 20), + const SizedBox(width: 8), + const Expanded( + child: Text('Correction distorsion:', style: TextStyle(color: Colors.white)), + ), + if (provider.distortionParams == null) + ElevatedButton.icon( + onPressed: () { + provider.calculateDistortion(); + }, + icon: const Icon(Icons.calculate, size: 16), + label: const Text('Calculer'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blueGrey, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + ), + ) + else ...[ + if (provider.correctedImagePath == null) + ElevatedButton.icon( + onPressed: () { + provider.applyDistortionCorrection(); + }, + icon: const Icon(Icons.auto_fix_high, size: 16), + label: const Text('Appliquer'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + ), + ) + else + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.check_circle, color: Colors.green, size: 16), + const SizedBox(width: 4), + const Text('Corrigée', style: TextStyle(color: Colors.green, fontSize: 12)), + const SizedBox(width: 8), + Switch( + value: provider.distortionCorrectionEnabled, + onChanged: (value) => provider.setDistortionCorrectionEnabled(value), + activeTrackColor: AppTheme.primaryColor.withValues(alpha: 0.5), + activeThumbColor: AppTheme.primaryColor, + ), + ], + ), + ], + ], + ), ], ), ), @@ -465,7 +520,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { fit: StackFit.expand, children: [ Image.file( - File(provider.imagePath!), + File(provider.displayImagePath!), fit: BoxFit.fill, key: _imageKey, ), @@ -534,7 +589,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { fit: StackFit.expand, children: [ Image.file( - File(provider.imagePath!), + File(provider.displayImagePath!), fit: BoxFit.fill, key: _imageKey, ), diff --git a/lib/services/distortion_correction_service.dart b/lib/services/distortion_correction_service.dart new file mode 100644 index 0000000..38ea2e6 --- /dev/null +++ b/lib/services/distortion_correction_service.dart @@ -0,0 +1,423 @@ +/// Service de correction de distorsion d'objectif. +/// +/// Utilise la calibration des cercles de la cible pour calculer et appliquer +/// une transformation qui corrige la distorsion de l'objectif. L'image est +/// transformée pour que les cercles calibrés deviennent parfaitement circulaires. +library; + +import 'dart:io'; +import 'dart:math' as math; +import 'package:image/image.dart' as img; +import 'package:path_provider/path_provider.dart'; + +/// Paramètres de distorsion calculés à partir de la calibration +class DistortionParameters { + /// Ratio d'aplatissement horizontal (1.0 = pas de correction) + final double scaleX; + + /// Ratio d'aplatissement vertical (1.0 = pas de correction) + final double scaleY; + + /// Angle de rotation de l'axe principal (en radians) + final double rotation; + + /// Centre de la distorsion en coordonnées normalisées (0-1) + final double centerX; + final double centerY; + + /// Ratio de circularité détecté (1.0 = cercle parfait) + final double circularityRatio; + + const DistortionParameters({ + required this.scaleX, + required this.scaleY, + required this.rotation, + required this.centerX, + required this.centerY, + required this.circularityRatio, + }); + + /// Paramètres par défaut (pas de correction) + static const identity = DistortionParameters( + scaleX: 1.0, + scaleY: 1.0, + rotation: 0.0, + centerX: 0.5, + centerY: 0.5, + circularityRatio: 1.0, + ); + + /// La correction est-elle nécessaire ? + bool get needsCorrection => circularityRatio < 0.95; + + @override + String toString() { + return 'DistortionParameters(scaleX: ${scaleX.toStringAsFixed(3)}, ' + 'scaleY: ${scaleY.toStringAsFixed(3)}, ' + 'rotation: ${(rotation * 180 / math.pi).toStringAsFixed(1)}°, ' + 'circularity: ${(circularityRatio * 100).toStringAsFixed(1)}%)'; + } +} + +/// Service pour détecter et corriger la distorsion d'objectif +class DistortionCorrectionService { + /// Calcule les paramètres de distorsion à partir de la calibration des cercles + /// + /// [targetCenterX], [targetCenterY] : Centre de la cible calibré (0-1) + /// [targetRadius] : Rayon de la cible calibré + /// [imageAspectRatio] : Ratio largeur/hauteur de l'image + /// + /// Cette méthode analyse la forme attendue (cercle) vs la forme observée + /// pour déterminer les paramètres de correction. + DistortionParameters calculateDistortionFromCalibration({ + required double targetCenterX, + required double targetCenterY, + required double targetRadius, + required double imageAspectRatio, + }) { + // En théorie, si la cible est un cercle parfait et que l'utilisateur + // a calibré les anneaux pour qu'ils correspondent à ce qu'il voit, + // on peut déduire la distorsion. + + // Pour l'instant, on utilise une approche simplifiée basée sur l'aspect ratio + // et la position du centre par rapport au centre de l'image. + + // Calcul de l'excentricité basée sur la position du centre + final offsetX = targetCenterX - 0.5; + final offsetY = targetCenterY - 0.5; + final offsetDistance = math.sqrt(offsetX * offsetX + offsetY * offsetY); + + // Plus le centre est éloigné du centre de l'image, plus la distorsion est probable + // Estimation simplifiée de la distorsion radiale + final distortionFactor = 1.0 + offsetDistance * 0.2; + + // Calculer l'angle de l'axe principal de déformation + final angle = math.atan2(offsetY, offsetX); + + // Si l'image n'est pas carrée, tenir compte de l'aspect ratio + double scaleX = 1.0; + double scaleY = 1.0; + + if (imageAspectRatio > 1.0) { + // Image plus large que haute + scaleY = distortionFactor; + } else if (imageAspectRatio < 1.0) { + // Image plus haute que large + scaleX = distortionFactor; + } + + final circularityRatio = 1.0 / distortionFactor; + + return DistortionParameters( + scaleX: scaleX, + scaleY: scaleY, + rotation: angle, + centerX: targetCenterX, + centerY: targetCenterY, + circularityRatio: circularityRatio, + ); + } + + /// Calcule les paramètres de distorsion en comparant un cercle théorique + /// avec les points de calibration fournis par l'utilisateur + /// + /// [calibrationPoints] : Points sur l'ellipse visible (coordonnées 0-1) + /// [expectedRadius] : Rayon du cercle théorique + /// [centerX], [centerY] : Centre du cercle théorique + DistortionParameters calculateDistortionFromPoints({ + required List<({double x, double y})> calibrationPoints, + required double expectedRadius, + required double centerX, + required double centerY, + }) { + if (calibrationPoints.length < 4) { + return DistortionParameters.identity; + } + + // Calculer les distances de chaque point au centre + final distances = []; + final angles = []; + + for (final point in calibrationPoints) { + final dx = point.x - centerX; + final dy = point.y - centerY; + distances.add(math.sqrt(dx * dx + dy * dy)); + angles.add(math.atan2(dy, dx)); + } + + // Trouver les axes majeur et mineur de l'ellipse + double maxDist = 0, minDist = double.infinity; + double maxAngle = 0; + + for (int i = 0; i < distances.length; i++) { + if (distances[i] > maxDist) { + maxDist = distances[i]; + maxAngle = angles[i]; + } + if (distances[i] < minDist) { + minDist = distances[i]; + } + } + + // Calculer les facteurs de correction + // On veut que maxDist et minDist deviennent égaux à expectedRadius + final scaleX = expectedRadius / maxDist; + final scaleY = expectedRadius / minDist; + final circularityRatio = minDist / maxDist; + + return DistortionParameters( + scaleX: scaleX.clamp(0.5, 2.0), + scaleY: scaleY.clamp(0.5, 2.0), + rotation: maxAngle, + centerX: centerX, + centerY: centerY, + circularityRatio: circularityRatio, + ); + } + + /// Applique la correction de distorsion à une image + /// + /// [imagePath] : Chemin de l'image source + /// [params] : Paramètres de distorsion calculés + /// + /// Retourne le chemin de l'image corrigée + Future applyCorrection( + String imagePath, + DistortionParameters params, + ) async { + final file = File(imagePath); + final bytes = await file.readAsBytes(); + final image = img.decodeImage(bytes); + + if (image == null) { + throw Exception('Impossible de décoder l\'image'); + } + + final correctedImage = _transformImage(image, params); + + // Sauvegarder l'image corrigée + final tempDir = await getTemporaryDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + final outputPath = '${tempDir.path}/corrected_$timestamp.jpg'; + + final outputFile = File(outputPath); + await outputFile.writeAsBytes(img.encodeJpg(correctedImage, quality: 95)); + + return outputPath; + } + + /// Transforme l'image pour corriger la distorsion + img.Image _transformImage(img.Image source, DistortionParameters params) { + final width = source.width; + final height = source.height; + + // Créer une nouvelle image de même taille + final result = img.Image(width: width, height: height); + + // Centre de transformation en pixels + final cx = params.centerX * width; + final cy = params.centerY * height; + + // Précalculer cos/sin de la rotation + final cosR = math.cos(params.rotation); + final sinR = math.sin(params.rotation); + + // Pour chaque pixel de l'image destination, trouver le pixel source correspondant + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + // Coordonnées relatives au centre + final dx = x - cx; + final dy = y - cy; + + // Rotation inverse + final rx = dx * cosR + dy * sinR; + final ry = -dx * sinR + dy * cosR; + + // Mise à l'échelle inverse (pour trouver d'où vient le pixel) + final sx = rx / params.scaleX; + final sy = ry / params.scaleY; + + // Rotation dans l'autre sens + final fx = sx * cosR - sy * sinR; + final fy = sx * sinR + sy * cosR; + + // Coordonnées source + final srcX = fx + cx; + final srcY = fy + cy; + + // Interpolation bilinéaire + final pixel = _bilinearInterpolate(source, srcX, srcY); + result.setPixel(x, y, pixel); + } + } + + return result; + } + + /// Interpolation bilinéaire pour un échantillonnage de qualité + img.Color _bilinearInterpolate(img.Image image, double x, double y) { + final x0 = x.floor(); + final y0 = y.floor(); + final x1 = x0 + 1; + final y1 = y0 + 1; + + // Vérifier les limites + if (x0 < 0 || y0 < 0 || x1 >= image.width || y1 >= image.height) { + // Retourner le pixel le plus proche pour les zones hors limites + return image.getPixel( + x.round().clamp(0, image.width - 1), + y.round().clamp(0, image.height - 1), + ); + } + + // Poids pour l'interpolation + final wx = x - x0; + final wy = y - y0; + + // Récupérer les 4 pixels voisins + final p00 = image.getPixel(x0, y0); + final p10 = image.getPixel(x1, y0); + final p01 = image.getPixel(x0, y1); + final p11 = image.getPixel(x1, y1); + + // Interpoler chaque canal + final r = _lerp2D(p00.r.toDouble(), p10.r.toDouble(), p01.r.toDouble(), p11.r.toDouble(), wx, wy); + final g = _lerp2D(p00.g.toDouble(), p10.g.toDouble(), p01.g.toDouble(), p11.g.toDouble(), wx, wy); + final b = _lerp2D(p00.b.toDouble(), p10.b.toDouble(), p01.b.toDouble(), p11.b.toDouble(), wx, wy); + final a = _lerp2D(p00.a.toDouble(), p10.a.toDouble(), p01.a.toDouble(), p11.a.toDouble(), wx, wy); + + return img.ColorRgba8(r.round().clamp(0, 255), g.round().clamp(0, 255), b.round().clamp(0, 255), a.round().clamp(0, 255)); + } + + /// Interpolation linéaire 2D + double _lerp2D(double v00, double v10, double v01, double v11, double wx, double wy) { + final top = v00 * (1 - wx) + v10 * wx; + final bottom = v01 * (1 - wx) + v11 * wx; + return top * (1 - wy) + bottom * wy; + } + + /// Applique une correction de perspective simple basée sur 4 points + /// + /// Cette méthode est utile quand la photo est prise en angle. + /// [corners] : Les 4 coins de la cible dans l'ordre (haut-gauche, haut-droite, bas-droite, bas-gauche) + Future applyPerspectiveCorrection( + String imagePath, + List<({double x, double y})> corners, + ) async { + if (corners.length != 4) { + throw ArgumentError('4 points de coin sont requis'); + } + + final file = File(imagePath); + final bytes = await file.readAsBytes(); + final image = img.decodeImage(bytes); + + if (image == null) { + throw Exception('Impossible de décoder l\'image'); + } + + final width = image.width; + final height = image.height; + + // Convertir les coordonnées normalisées en pixels + final srcCorners = corners.map((c) => (x: c.x * width, y: c.y * height)).toList(); + + // Calculer la taille du rectangle destination + // On prend la moyenne des largeurs et hauteurs + final topWidth = _distance(srcCorners[0], srcCorners[1]); + final bottomWidth = _distance(srcCorners[3], srcCorners[2]); + final leftHeight = _distance(srcCorners[0], srcCorners[3]); + final rightHeight = _distance(srcCorners[1], srcCorners[2]); + + final dstWidth = ((topWidth + bottomWidth) / 2).round(); + final dstHeight = ((leftHeight + rightHeight) / 2).round(); + + // Créer l'image destination + final result = img.Image(width: dstWidth, height: dstHeight); + + // Calculer la matrice de transformation perspective + final matrix = _computePerspectiveMatrix( + srcCorners, + [ + (x: 0.0, y: 0.0), + (x: dstWidth.toDouble(), y: 0.0), + (x: dstWidth.toDouble(), y: dstHeight.toDouble()), + (x: 0.0, y: dstHeight.toDouble()), + ], + ); + + // Appliquer la transformation + for (int y = 0; y < dstHeight; y++) { + for (int x = 0; x < dstWidth; x++) { + final src = _applyPerspectiveTransform(matrix, x.toDouble(), y.toDouble()); + + if (src.x >= 0 && src.x < width && src.y >= 0 && src.y < height) { + final pixel = _bilinearInterpolate(image, src.x, src.y); + result.setPixel(x, y, pixel); + } + } + } + + // Sauvegarder + final tempDir = await getTemporaryDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + final outputPath = '${tempDir.path}/perspective_$timestamp.jpg'; + + final outputFile = File(outputPath); + await outputFile.writeAsBytes(img.encodeJpg(result, quality: 95)); + + return outputPath; + } + + double _distance(({double x, double y}) p1, ({double x, double y}) p2) { + final dx = p2.x - p1.x; + final dy = p2.y - p1.y; + return math.sqrt(dx * dx + dy * dy); + } + + /// Calcule la matrice de transformation perspective (homographie) + List _computePerspectiveMatrix( + List<({double x, double y})> src, + List<({double x, double y})> dst, + ) { + // Résolution du système linéaire pour trouver la matrice 3x3 + // Utilisation de la méthode DLT (Direct Linear Transform) + + final a = List>.generate(8, (_) => List.filled(9, 0.0)); + + for (int i = 0; i < 4; i++) { + final sx = src[i].x; + final sy = src[i].y; + final dx = dst[i].x; + final dy = dst[i].y; + + a[i * 2] = [-sx, -sy, -1, 0, 0, 0, dx * sx, dx * sy, dx]; + a[i * 2 + 1] = [0, 0, 0, -sx, -sy, -1, dy * sx, dy * sy, dy]; + } + + // Résolution par SVD simplifiée (on utilise une approximation) + // Pour une implémentation complète, il faudrait une vraie décomposition SVD + final h = _solveHomography(a); + + return h; + } + + List _solveHomography(List> a) { + // Implémentation simplifiée - normalisation et résolution + // En pratique, on devrait utiliser une vraie décomposition SVD + + // Pour l'instant, retourner une matrice identité + // TODO: Implémenter une vraie résolution + return [1, 0, 0, 0, 1, 0, 0, 0, 1]; + } + + ({double x, double y}) _applyPerspectiveTransform(List h, double x, double y) { + final w = h[6] * x + h[7] * y + h[8]; + if (w.abs() < 1e-10) { + return (x: x, y: y); + } + final nx = (h[0] * x + h[1] * y + h[2]) / w; + final ny = (h[3] * x + h[4] * y + h[5]) / w; + return (x: nx, y: ny); + } +}