- Supprime 5 services inatteignables depuis l'UI (~3 000 lignes) : distortion_correction, image_processing, target_detection, opencv_impact_detection, target_rectify - AnalysisProvider allégé (835 -> ~360 lignes) : retrait de la détection par références, de la détection auto d'impacts, du workflow distorsion et du doublon moveShot - Retire les dépendances inutilisées google_mlkit_object_detection et google_mlkit_document_scanner du pubspec - Le bouton ↻ du Plotting efface désormais tous les impacts en un clic (clearShots) sans relancer la détection auto ni toucher la calibration - Nettoie les paramètres morts de TargetOverlay (referenceImpacts, onAddShot), le flag _isSelectingReferences, _buildActionButtons vide et le résidu _detectionTimer de capture_screen - Supprime le dossier tests/ (brouillons d'expérimentation OpenCV) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
376 lines
11 KiB
Dart
376 lines
11 KiB
Dart
/// Gestionnaire d'état pour l'analyse des cibles (ChangeNotifier).
|
|
///
|
|
/// Gère le workflow complet d'analyse : chargement d'image, gestion des
|
|
/// impacts placés manuellement, calcul des scores, analyse de groupement
|
|
/// et sauvegarde des sessions.
|
|
library;
|
|
|
|
import 'dart:io';
|
|
import 'dart:ui' as ui;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
import '../../data/models/target_analysis.dart';
|
|
import '../../data/models/shot.dart';
|
|
import '../../data/models/target_type.dart';
|
|
import '../../data/repositories/session_repository.dart';
|
|
import '../../services/score_calculator_service.dart';
|
|
import '../../services/grouping_analyzer_service.dart';
|
|
import '../../services/ai_export_service.dart';
|
|
|
|
enum AnalysisState { initial, loading, success, error }
|
|
|
|
class AnalysisProvider extends ChangeNotifier {
|
|
final ScoreCalculatorService _scoreCalculatorService;
|
|
final GroupingAnalyzerService _groupingAnalyzerService;
|
|
final SessionRepository _sessionRepository;
|
|
final Uuid _uuid = const Uuid();
|
|
|
|
AnalysisProvider({
|
|
required ScoreCalculatorService scoreCalculatorService,
|
|
required GroupingAnalyzerService groupingAnalyzerService,
|
|
required SessionRepository sessionRepository,
|
|
}) : _scoreCalculatorService = scoreCalculatorService,
|
|
_groupingAnalyzerService = groupingAnalyzerService,
|
|
_sessionRepository = sessionRepository;
|
|
|
|
AnalysisState _state = AnalysisState.initial;
|
|
String? _errorMessage;
|
|
String? _imagePath;
|
|
TargetType? _targetType;
|
|
|
|
// AJOUT PROTECTION DU PLOTTING : Stockage permanent de la rotation du Crop
|
|
double _cropRotation = 0.0;
|
|
|
|
// Target calibration
|
|
double _targetCenterX = 0.5;
|
|
double _targetCenterY = 0.5;
|
|
double _targetRadius = 0.4;
|
|
double _targetInnerRadius = 0.04;
|
|
int _ringCount = 10;
|
|
List<double>? _ringRadii; // Individual ring radii multipliers
|
|
double _imageAspectRatio = 1.0; // width / height
|
|
|
|
// Shots
|
|
List<Shot> _shots = [];
|
|
|
|
// Score results
|
|
ScoreResult? _scoreResult;
|
|
|
|
// Grouping results
|
|
GroupingResult? _groupingResult;
|
|
|
|
// Getters
|
|
AnalysisState get state => _state;
|
|
String? get errorMessage => _errorMessage;
|
|
String? get imagePath => _imagePath;
|
|
TargetType? get targetType => _targetType;
|
|
double get cropRotation => _cropRotation; // Getter pour le Plotting
|
|
double get targetCenterX => _targetCenterX;
|
|
double get targetCenterY => _targetCenterY;
|
|
double get targetRadius => _targetRadius;
|
|
double get targetInnerRadius => _targetInnerRadius;
|
|
int get ringCount => _ringCount;
|
|
List<double>? get ringRadii =>
|
|
_ringRadii != null ? List.unmodifiable(_ringRadii!) : null;
|
|
double get imageAspectRatio => _imageAspectRatio;
|
|
List<Shot> get shots => List.unmodifiable(_shots);
|
|
ScoreResult? get scoreResult => _scoreResult;
|
|
GroupingResult? get groupingResult => _groupingResult;
|
|
|
|
int get totalScore => _scoreResult?.totalScore ?? 0;
|
|
int get shotCount => _shots.length;
|
|
|
|
/// Modifie et mémorise la rotation de l'image pour le Plotting
|
|
void setCropRotation(double rotation) {
|
|
_cropRotation = rotation;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Charge l'image et initialise les paramètres de cible par défaut.
|
|
/// Le placement des impacts et la calibration se font ensuite manuellement.
|
|
Future<void> analyzeImage(
|
|
String imagePath,
|
|
TargetType targetType, {
|
|
Offset? manualCenter,
|
|
}) async {
|
|
_state = AnalysisState.loading;
|
|
_imagePath = imagePath;
|
|
_targetType = targetType;
|
|
_errorMessage = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
// Load image to get dimensions
|
|
final file = File(imagePath);
|
|
final bytes = await file.readAsBytes();
|
|
final codec = await ui.instantiateImageCodec(bytes);
|
|
final frame = await codec.getNextFrame();
|
|
_imageAspectRatio = frame.image.width / frame.image.height;
|
|
frame.image.dispose();
|
|
|
|
_targetCenterX = manualCenter?.dx ?? 0.5;
|
|
_targetCenterY = manualCenter?.dy ?? 0.5;
|
|
_targetRadius = 0.4;
|
|
_targetInnerRadius = 0.04;
|
|
|
|
_shots = [];
|
|
|
|
_state = AnalysisState.success;
|
|
notifyListeners();
|
|
} catch (e) {
|
|
_state = AnalysisState.error;
|
|
_errorMessage = 'Erreur d\'analyse: $e';
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// Add a manual shot
|
|
void addShot(double x, double y) {
|
|
final score = _calculateShotScore(x, y);
|
|
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: '');
|
|
|
|
_shots.add(shot);
|
|
_recalculateScores();
|
|
_recalculateGrouping();
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Efface tous les impacts en un clic (bouton ↻ de l'écran Plotting).
|
|
/// La calibration (centre, rayon, anneaux) n'est pas touchée.
|
|
void clearShots() {
|
|
_shots.clear();
|
|
_recalculateScores();
|
|
_recalculateGrouping();
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Remove a shot
|
|
void removeShot(String shotId) {
|
|
_shots.removeWhere((shot) => shot.id == shotId);
|
|
_recalculateScores();
|
|
_recalculateGrouping();
|
|
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();
|
|
}
|
|
|
|
/// Adjust target position
|
|
void adjustTargetPosition(
|
|
double centerX,
|
|
double centerY,
|
|
double innerRadius,
|
|
double radius, {
|
|
int? ringCount,
|
|
List<double>? ringRadii,
|
|
double zoomScale = 1.0,
|
|
Offset offset = Offset.zero,
|
|
}) {
|
|
_targetCenterX = (centerX - offset.dx) / zoomScale;
|
|
_targetCenterY = (centerY - offset.dy) / zoomScale;
|
|
_targetRadius = radius / zoomScale;
|
|
_targetInnerRadius = innerRadius / zoomScale;
|
|
|
|
if (ringCount != null) {
|
|
_ringCount = ringCount;
|
|
}
|
|
|
|
// CORRECTION : On accepte désormais la valeur null pour pouvoir réinitialiser l'espacement !
|
|
_ringRadii = ringRadii;
|
|
|
|
// Recalculate all shot scores based on new target position
|
|
_shots = _shots.map((shot) {
|
|
final newScore = _calculateShotScore(shot.x, shot.y);
|
|
return shot.copyWith(score: newScore);
|
|
}).toList();
|
|
|
|
_recalculateScores();
|
|
notifyListeners();
|
|
}
|
|
|
|
int _calculateShotScore(double x, double y) {
|
|
if (_targetType == TargetType.concentric) {
|
|
return _scoreCalculatorService.calculateConcentricScore(
|
|
shotX: x,
|
|
shotY: y,
|
|
targetCenterX: _targetCenterX,
|
|
targetCenterY: _targetCenterY,
|
|
targetRadius: _targetRadius,
|
|
ringCount: _ringCount,
|
|
imageAspectRatio: _imageAspectRatio,
|
|
ringRadii: _ringRadii,
|
|
);
|
|
} else {
|
|
return _scoreCalculatorService.calculateSilhouetteScore(
|
|
shotX: x,
|
|
shotY: y,
|
|
targetCenterX: _targetCenterX,
|
|
targetCenterY: _targetCenterY,
|
|
targetWidth: _targetRadius * 0.8,
|
|
targetHeight: _targetRadius * 2,
|
|
);
|
|
}
|
|
}
|
|
|
|
void _recalculateScores() {
|
|
if (_targetType == null) return;
|
|
|
|
_scoreResult = _scoreCalculatorService.calculateScores(
|
|
shots: _shots,
|
|
targetType: _targetType!,
|
|
targetCenterX: _targetCenterX,
|
|
targetCenterY: _targetCenterY,
|
|
targetRadius: _targetRadius,
|
|
ringCount: _ringCount,
|
|
imageAspectRatio: _imageAspectRatio,
|
|
ringRadii: _ringRadii,
|
|
);
|
|
}
|
|
|
|
void _recalculateGrouping() {
|
|
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
|
|
}
|
|
|
|
/// Exporte l'image et le json vers le backend IA
|
|
Future<bool> exportToAiBackend() async {
|
|
if (_imagePath == null || _targetType == null) {
|
|
_errorMessage = "Impossible d'export : image ou type de cible manquant.";
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
|
|
final service = AiExportService();
|
|
|
|
_state = AnalysisState.loading;
|
|
notifyListeners();
|
|
|
|
final success = await service.exportData(
|
|
imagePath: _imagePath!,
|
|
sessionId: 'export',
|
|
targetType: _targetType!,
|
|
targetCenterX: _targetCenterX,
|
|
targetCenterY: _targetCenterY,
|
|
targetRadius: _targetRadius,
|
|
shots: _shots,
|
|
);
|
|
|
|
_state = AnalysisState.success;
|
|
if (!success) {
|
|
_errorMessage = "Échec de l'export vers le serveur IA.";
|
|
}
|
|
notifyListeners();
|
|
return success;
|
|
}
|
|
|
|
/// Save the session
|
|
Future<TargetAnalysis> saveSession({
|
|
String? notes,
|
|
String? sessionId,
|
|
String? weaponName,
|
|
String? weaponId,
|
|
int? distance,
|
|
DateTime? date,
|
|
}) async {
|
|
if (_imagePath == null || _targetType == null) {
|
|
throw Exception('Cannot save: missing image or target type');
|
|
}
|
|
|
|
final targetAnalysis = await _sessionRepository.prepareTargetAnalysis(
|
|
imagePath: _imagePath!,
|
|
sessionId: sessionId ?? 'standalone',
|
|
targetType: _targetType!,
|
|
shots: _shots,
|
|
totalScore: totalScore,
|
|
groupingDiameter: _groupingResult?.diameter,
|
|
groupingCenterX: _groupingResult?.centerX,
|
|
groupingCenterY: _groupingResult?.centerY,
|
|
notes: notes,
|
|
targetCenterX: _targetCenterX,
|
|
targetCenterY: _targetCenterY,
|
|
targetRadius: _targetRadius,
|
|
);
|
|
|
|
if (sessionId != null && sessionId != 'standalone') {
|
|
final existingSession = await _sessionRepository.getSession(sessionId);
|
|
if (existingSession != null) {
|
|
await _sessionRepository.addAnalysisToSession(
|
|
sessionId,
|
|
targetAnalysis,
|
|
);
|
|
} else {
|
|
// CORRECTION : Utilise la date injectée plutôt que DateTime.now()
|
|
await _sessionRepository.createSession(
|
|
id: sessionId,
|
|
weapon: weaponName ?? 'Inconnue',
|
|
weaponId: weaponId,
|
|
maxShotsPerTarget: shotCount,
|
|
analyses: [targetAnalysis],
|
|
notes: notes,
|
|
distance: distance ?? 25,
|
|
date: date ?? DateTime.now(),
|
|
);
|
|
}
|
|
} else {
|
|
// CORRECTION : S'applique aussi au flux alternatif sans ID préalable
|
|
await _sessionRepository.createSession(
|
|
weapon: weaponName ?? 'Inconnue',
|
|
weaponId: weaponId,
|
|
maxShotsPerTarget: shotCount,
|
|
analyses: [targetAnalysis],
|
|
notes: notes,
|
|
distance: distance ?? 25,
|
|
date: date ?? DateTime.now(),
|
|
);
|
|
}
|
|
|
|
notifyListeners();
|
|
return targetAnalysis;
|
|
}
|
|
|
|
/// Reset the provider
|
|
void reset() {
|
|
_state = AnalysisState.initial;
|
|
_errorMessage = null;
|
|
_imagePath = null;
|
|
_targetType = null;
|
|
_cropRotation = 0.0;
|
|
_targetCenterX = 0.5;
|
|
_targetCenterY = 0.5;
|
|
_targetRadius = 0.4;
|
|
_targetInnerRadius = 0.04;
|
|
_ringCount = 10;
|
|
_ringRadii = null;
|
|
_imageAspectRatio = 1.0;
|
|
_shots = [];
|
|
_scoreResult = null;
|
|
_groupingResult = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Met à jour la position d'un impact de tir en direct (Glisser-Déposer).
|
|
void updateShotPosition(String shotId, double newX, double newY) {
|
|
final index = _shots.indexWhere((s) => s.id == shotId);
|
|
if (index != -1) {
|
|
// 1. On calcule dynamiquement le nouveau score de la zone survolée
|
|
final newScore = _calculateShotScore(newX, newY);
|
|
|
|
// 2. On remplace l'ancien impact par le nouveau avec ses coordonnées et son score ajustés
|
|
_shots[index] = _shots[index].copyWith(x: newX, y: newY, score: newScore);
|
|
|
|
// 3. On applique les deux méthodes natives de recalcul de ton application
|
|
_recalculateScores();
|
|
_recalculateGrouping();
|
|
|
|
// 4. On demande à l'écran de se redessiner en direct
|
|
notifyListeners();
|
|
}
|
|
}
|
|
}
|