From a3167fc2ba05cf7e624b9a009b2afe4e00e62438 Mon Sep 17 00:00:00 2001 From: qguillaume Date: Sat, 4 Jul 2026 10:16:49 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20filtre=20historique,=20fuite=20m=C3=A9mo?= =?UTF-8?q?ire=20OpenCV,=20export=20IA,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Historique : le filtre par type de cible est désormais appliqué au chargement ; correction du piège PopupMenuItem(value: null) qui empêchait l'option « Tous » de réinitialiser le filtre ; icône colorée quand un filtre est actif - OpenCV : libération des Mat natifs (img, gray, blurred, circles) dans un finally — detectTarget tourne toutes les secondes pendant l'aperçu caméra et faisait grimper la mémoire native en continu - Export IA : distance, arme et id de session réels transmis depuis SessionProvider au lieu des placeholders (25 m / "Unknown") - Tests : remplacement du test widget cassé (BullyApp sans providers) par 14 tests qui passent — calcul de score concentrique (centre, hors cible, ratio d'image, anneaux personnalisés), agrégation des scores, analyse de groupement, et rendu du widget StatsCard Co-Authored-By: Claude Fable 5 --- lib/features/analysis/analysis_provider.dart | 14 ++- lib/features/analysis/analysis_screen.dart | 9 +- lib/features/history/history_screen.dart | 34 ++++-- lib/services/ai_export_service.dart | 8 +- lib/services/opencv_target_service.dart | 51 ++++---- .../grouping_analyzer_service_test.dart | 60 ++++++++++ .../score_calculator_service_test.dart | 110 ++++++++++++++++++ test/widget_test.dart | 34 ++++-- 8 files changed, 270 insertions(+), 50 deletions(-) create mode 100644 test/services/grouping_analyzer_service_test.dart create mode 100644 test/services/score_calculator_service_test.dart diff --git a/lib/features/analysis/analysis_provider.dart b/lib/features/analysis/analysis_provider.dart index fd021bcb..db19f6ab 100644 --- a/lib/features/analysis/analysis_provider.dart +++ b/lib/features/analysis/analysis_provider.dart @@ -238,8 +238,14 @@ class AnalysisProvider extends ChangeNotifier { _groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots); } - /// Exporte l'image et le json vers le backend IA - Future exportToAiBackend() async { + /// Exporte l'image et le json vers le backend IA. + /// [sessionId], [distance] et [weapon] proviennent du SessionProvider de + /// l'écran appelant, pour que le dataset contienne les vraies métadonnées. + Future exportToAiBackend({ + String? sessionId, + int? distance, + String? weapon, + }) async { if (_imagePath == null || _targetType == null) { _errorMessage = "Impossible d'export : image ou type de cible manquant."; notifyListeners(); @@ -253,12 +259,14 @@ class AnalysisProvider extends ChangeNotifier { final success = await service.exportData( imagePath: _imagePath!, - sessionId: 'export', + sessionId: sessionId ?? 'export', targetType: _targetType!, targetCenterX: _targetCenterX, targetCenterY: _targetCenterY, targetRadius: _targetRadius, shots: _shots, + distanceMeters: distance ?? 25, + weaponName: weapon ?? 'Unknown', ); _state = AnalysisState.success; diff --git a/lib/features/analysis/analysis_screen.dart b/lib/features/analysis/analysis_screen.dart index 97550a6e..c4df706a 100644 --- a/lib/features/analysis/analysis_screen.dart +++ b/lib/features/analysis/analysis_screen.dart @@ -275,7 +275,14 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { const SnackBar(content: Text('Exportation en cours...')), ); - final success = await p.exportToAiBackend(); + // Métadonnées réelles de la session en cours (distance, + // arme) plutôt que les placeholders par défaut. + final sp = context.read(); + final success = await p.exportToAiBackend( + sessionId: sp.activeSessionId, + distance: sp.distance, + weapon: sp.currentWeapon, + ); if (!context.mounted) return; ScaffoldMessenger.of(context).hideCurrentSnackBar(); diff --git a/lib/features/history/history_screen.dart b/lib/features/history/history_screen.dart index 5ad0f4d2..9813f398 100644 --- a/lib/features/history/history_screen.dart +++ b/lib/features/history/history_screen.dart @@ -43,6 +43,14 @@ class _HistoryScreenState extends State { setState(() { _sessions = sessions; + // --- FILTRAGE PAR TYPE DE CIBLE --- + // Une session est retenue si au moins une de ses cibles est du type choisi. + if (_filterType != null) { + _sessions = _sessions + .where((s) => s.analyses.any((a) => a.targetType == _filterType)) + .toList(); + } + // --- LOGIQUE DE FILTRAGE PAR PÉRIODE --- if (_selectedDateRange != null) { _sessions = _sessions.where((s) { @@ -125,17 +133,29 @@ class _HistoryScreenState extends State { appBar: AppBar( title: const Text('Historique'), actions: [ - PopupMenuButton( - icon: const Icon(Icons.filter_list), - onSelected: (type) { - setState(() => _filterType = type); + // NOTE : on passe par un String sentinelle ('all') car un + // PopupMenuItem avec value null ne déclenche jamais onSelected + // (Flutter l'interprète comme une annulation du menu). + PopupMenuButton( + icon: Icon( + Icons.filter_list, + // Icône colorée quand un filtre est actif, pour le rendre visible. + color: _filterType != null ? AppTheme.primaryColor : null, + ), + onSelected: (value) { + setState(() { + _filterType = + value == 'all' ? null : TargetType.fromString(value); + }); _loadSessions(); }, itemBuilder: (context) => [ - const PopupMenuItem(value: null, child: Text('Tous')), + const PopupMenuItem(value: 'all', child: Text('Tous')), ...TargetType.values.map( - (type) => - PopupMenuItem(value: type, child: Text(type.displayName)), + (type) => PopupMenuItem( + value: type.name, + child: Text(type.displayName), + ), ), ], ), diff --git a/lib/services/ai_export_service.dart b/lib/services/ai_export_service.dart index a186d2b2..2d51f543 100644 --- a/lib/services/ai_export_service.dart +++ b/lib/services/ai_export_service.dart @@ -53,6 +53,8 @@ class AiExportService { required double targetCenterY, required double targetRadius, required List shots, + int distanceMeters = 25, + String weaponName = 'Unknown', String? apiUrl, }) async { try { @@ -109,9 +111,9 @@ class AiExportService { "device_info": deviceData, "target_metadata": { "type": targetType.name, - "distance_meters": 25, // Default/placeholder - "weapon": "Unknown", // Default/placeholder - // The backend could extract exact width/height from the image. + "distance_meters": distanceMeters, + "weapon": weaponName, + // The backend could extract exact width/height from the image. }, "plotting": { "target_corners": corners, diff --git a/lib/services/opencv_target_service.dart b/lib/services/opencv_target_service.dart index 9719724c..71186a69 100644 --- a/lib/services/opencv_target_service.dart +++ b/lib/services/opencv_target_service.dart @@ -26,23 +26,34 @@ class TargetDetectionResult { class OpenCVTargetService { /// Detect the main target (center and radius) from an image file + /// + /// IMPORTANT : les Mat OpenCV sont de la mémoire NATIVE, invisible pour le + /// garbage collector Dart. Cette méthode est appelée en boucle (~1 s) + /// pendant l'aperçu caméra : sans dispose() explicite dans le finally, la + /// mémoire native grimpe en continu tant que l'utilisateur vise. Future detectTarget(String imagePath) async { + cv.Mat? img; + cv.Mat? gray; + cv.Mat? blurred; + cv.Mat? circles; + cv.Mat? looseCircles; + try { // Read image - final img = cv.imread(imagePath, flags: cv.IMREAD_COLOR); + img = cv.imread(imagePath, flags: cv.IMREAD_COLOR); if (img.isEmpty) { return TargetDetectionResult.failure(); } // Convert to grayscale - final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY); + gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY); // Apply Gaussian blur to reduce noise - final blurred = cv.gaussianBlur(gray, (9, 9), 2, sigmaY: 2); + blurred = cv.gaussianBlur(gray, (9, 9), 2, sigmaY: 2); - // Detect circles using Hough Transform - // Parameters need to be tuned for the specific target type - final circles = cv.HoughCircles( + // Detect circles using Hough Transform. + // HoughCircles returns a Mat of shape (1, N) of Vec3f (x, y, r). + circles = cv.HoughCircles( blurred, cv.HOUGH_GRADIENT, 1, // dp @@ -55,26 +66,9 @@ class OpenCVTargetService { maxRadius: img.cols ~/ 2, ); - // HoughCircles returns a Mat of shape (1, N, 3) where N is number of circles. - // In opencv_dart, we cannot iterate easily. - // However, we can access data via pointer if needed, or check if Vec3f is supported. - // Given the user report, `at` likely failed compilation or runtime. - // Let's use a safer approach: assume standard memory layout (x, y, r, x, y, r...). - // Or use `at` carefully. - - // Better yet: try to use `circles.data` if available, but it returns a Pointer. - // Let's stick to `at` but use `double` and manual offset if Vec3f fails. - // actually, let's try to trust `at` for flattened access OR `at`. - // NOTE: `at` was reported as "method at not defined for VecPoint2f" earlier, NOT for Mat. - // The user error was for `VecPoint2f`. `Mat` definitely has `at`. - // BUT `VecPoint2f` is a List-like structure in Dart wrapper. - // usage of `at` on `VecPoint2f` was the error. - // Here `circles` IS A MAT. So `at` IS defined. - // However, to be safe and robust, and to implement clustering... - if (circles.isEmpty) { // Try with different parameters if first attempt fails (more lenient) - final looseCircles = cv.HoughCircles( + looseCircles = cv.HoughCircles( blurred, cv.HOUGH_GRADIENT, 1, @@ -93,8 +87,15 @@ class OpenCVTargetService { return _findBestConcentricCircles(circles, img.cols, img.rows); } catch (e) { - // print('Error detecting target with OpenCV: $e'); return TargetDetectionResult.failure(); + } finally { + // _findBestConcentricCircles a déjà extrait les données dans des listes + // Dart avant qu'on arrive ici : libérer les Mat est donc toujours sûr. + img?.dispose(); + gray?.dispose(); + blurred?.dispose(); + circles?.dispose(); + looseCircles?.dispose(); } } diff --git a/test/services/grouping_analyzer_service_test.dart b/test/services/grouping_analyzer_service_test.dart new file mode 100644 index 00000000..08f290c9 --- /dev/null +++ b/test/services/grouping_analyzer_service_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:bully/data/models/shot.dart'; +import 'package:bully/services/grouping_analyzer_service.dart'; + +Shot _shot(String id, double x, double y) => + Shot(id: id, x: x, y: y, score: 0, analysisId: ''); + +void main() { + final service = GroupingAnalyzerService(); + + group('analyzeGrouping', () { + test('liste vide → résultat vide', () { + final result = service.analyzeGrouping([]); + expect(result.shotCount, 0); + expect(result.diameter, 0); + }); + + test('un seul tir → diamètre nul, centre sur le tir', () { + final result = service.analyzeGrouping([_shot('a', 0.3, 0.7)]); + expect(result.shotCount, 1); + expect(result.centerX, 0.3); + expect(result.centerY, 0.7); + expect(result.diameter, 0); + }); + + test('deux tirs → centroïde au milieu et diamètre = écart', () { + final result = service.analyzeGrouping([ + _shot('a', 0.4, 0.5), + _shot('b', 0.6, 0.5), + ]); + + expect(result.centerX, closeTo(0.5, 1e-9)); + expect(result.centerY, closeTo(0.5, 1e-9)); + expect(result.diameter, closeTo(0.2, 1e-9)); + expect(result.meanRadius, closeTo(0.1, 1e-9)); + // Deux tirs équidistants du centroïde → dispersion nulle. + expect(result.standardDeviation, closeTo(0.0, 1e-9)); + }); + + test('le diamètre est la plus grande distance entre deux tirs', () { + final result = service.analyzeGrouping([ + _shot('a', 0.2, 0.5), + _shot('b', 0.5, 0.5), + _shot('c', 0.8, 0.5), + ]); + expect(result.diameter, closeTo(0.6, 1e-9)); + }); + }); + + group('getOffsetDescription', () { + test('décalage négligeable → Centre', () { + expect(service.getOffsetDescription(0.01, -0.01), 'Centre'); + }); + + test('décalage combiné → direction composée', () { + expect(service.getOffsetDescription(0.1, -0.1), 'Haut-Droite'); + }); + }); +} diff --git a/test/services/score_calculator_service_test.dart b/test/services/score_calculator_service_test.dart new file mode 100644 index 00000000..6304eaa5 --- /dev/null +++ b/test/services/score_calculator_service_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:bully/data/models/shot.dart'; +import 'package:bully/data/models/target_type.dart'; +import 'package:bully/services/score_calculator_service.dart'; + +void main() { + final service = ScoreCalculatorService(); + + group('calculateConcentricScore', () { + test('un tir au centre exact vaut 10', () { + final score = service.calculateConcentricScore( + shotX: 0.5, + shotY: 0.5, + targetCenterX: 0.5, + targetCenterY: 0.5, + targetRadius: 0.4, + ); + expect(score, 10); + }); + + test('un tir hors de la cible vaut 0', () { + final score = service.calculateConcentricScore( + shotX: 0.95, + shotY: 0.5, + targetCenterX: 0.5, + targetCenterY: 0.5, + targetRadius: 0.4, + ); + expect(score, 0); + }); + + test('un tir à mi-rayon tombe dans la zone attendue', () { + // Distance normalisée ≈ 0.475 → 5e anneau (zone 0.5) → score 6. + final score = service.calculateConcentricScore( + shotX: 0.5, + shotY: 0.69, + targetCenterX: 0.5, + targetCenterY: 0.5, + targetRadius: 0.4, + ); + expect(score, 6); + }); + + test('le ratio d\'image est pris en compte (image paysage 2:1)', () { + // dx = 0.15 mais scaled ×2 → distance normalisée 0.75 → score 3. + final score = service.calculateConcentricScore( + shotX: 0.65, + shotY: 0.5, + targetCenterX: 0.5, + targetCenterY: 0.5, + targetRadius: 0.4, + imageAspectRatio: 2.0, + ); + expect(score, 3); + }); + + test('les rayons d\'anneaux personnalisés sont respectés', () { + // Distance normalisée 0.3 avec anneaux [0.2, 0.5, 1.0] → 2e anneau → 9. + final score = service.calculateConcentricScore( + shotX: 0.5, + shotY: 0.62, + targetCenterX: 0.5, + targetCenterY: 0.5, + targetRadius: 0.4, + ringCount: 3, + ringRadii: [0.2, 0.5, 1.0], + ); + expect(score, 9); + }); + }); + + group('calculateScores', () { + test('agrège le total, le max possible et le pourcentage', () { + final shots = [ + Shot(id: 'a', x: 0.5, y: 0.5, score: 0, analysisId: ''), // centre → 10 + Shot(id: 'b', x: 0.95, y: 0.5, score: 0, analysisId: ''), // dehors → 0 + ]; + + final result = service.calculateScores( + shots: shots, + targetType: TargetType.concentric, + targetCenterX: 0.5, + targetCenterY: 0.5, + targetRadius: 0.4, + ); + + expect(result.totalScore, 10); + expect(result.maxPossibleScore, 20); + expect(result.percentage, closeTo(50.0, 0.001)); + expect(result.shotCount, 2); + expect(result.scoreDistribution[10], 1); + expect(result.scoreDistribution[0], 1); + }); + + test('une liste vide donne un résultat neutre', () { + final result = service.calculateScores( + shots: [], + targetType: TargetType.concentric, + targetCenterX: 0.5, + targetCenterY: 0.5, + targetRadius: 0.4, + ); + + expect(result.totalScore, 0); + expect(result.maxPossibleScore, 0); + expect(result.percentage, 0.0); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 5e6a1db2..5d392414 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,20 +1,32 @@ -// This is a basic Flutter widget test. +// Test de fumée sur un widget pur (sans providers ni base de données). // -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. +// Le montage complet de BullyApp exige les providers globaux et une base +// SQLite initialisée ; pour un test widget rapide et stable, on valide ici +// le rendu d'un composant autonome. +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:bully/app.dart'; +import 'package:bully/features/home/widgets/stats_card.dart'; void main() { - testWidgets('App loads correctly', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const BullyApp()); + testWidgets('StatsCard affiche le titre, la valeur et l\'icône', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: StatsCard( + icon: Icons.gps_fixed, + title: 'Tirs', + value: '42', + color: Colors.blue, + ), + ), + ), + ); - // Verify that the app title is displayed. - expect(find.text('Bully'), findsOneWidget); + expect(find.text('Tirs'), findsOneWidget); + expect(find.text('42'), findsOneWidget); + expect(find.byIcon(Icons.gps_fixed), findsOneWidget); }); }