fix: filtre historique, fuite mémoire OpenCV, export IA, tests

- 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 <noreply@anthropic.com>
This commit is contained in:
qguillaume
2026-07-04 10:16:49 +02:00
parent 972bfbe0e9
commit a3167fc2ba
8 changed files with 270 additions and 50 deletions

View File

@@ -238,8 +238,14 @@ class AnalysisProvider extends ChangeNotifier {
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots); _groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
} }
/// Exporte l'image et le json vers le backend IA /// Exporte l'image et le json vers le backend IA.
Future<bool> exportToAiBackend() async { /// [sessionId], [distance] et [weapon] proviennent du SessionProvider de
/// l'écran appelant, pour que le dataset contienne les vraies métadonnées.
Future<bool> exportToAiBackend({
String? sessionId,
int? distance,
String? weapon,
}) async {
if (_imagePath == null || _targetType == null) { if (_imagePath == null || _targetType == null) {
_errorMessage = "Impossible d'export : image ou type de cible manquant."; _errorMessage = "Impossible d'export : image ou type de cible manquant.";
notifyListeners(); notifyListeners();
@@ -253,12 +259,14 @@ class AnalysisProvider extends ChangeNotifier {
final success = await service.exportData( final success = await service.exportData(
imagePath: _imagePath!, imagePath: _imagePath!,
sessionId: 'export', sessionId: sessionId ?? 'export',
targetType: _targetType!, targetType: _targetType!,
targetCenterX: _targetCenterX, targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY, targetCenterY: _targetCenterY,
targetRadius: _targetRadius, targetRadius: _targetRadius,
shots: _shots, shots: _shots,
distanceMeters: distance ?? 25,
weaponName: weapon ?? 'Unknown',
); );
_state = AnalysisState.success; _state = AnalysisState.success;

View File

@@ -275,7 +275,14 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
const SnackBar(content: Text('Exportation en cours...')), 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<SessionProvider>();
final success = await p.exportToAiBackend(
sessionId: sp.activeSessionId,
distance: sp.distance,
weapon: sp.currentWeapon,
);
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar(); ScaffoldMessenger.of(context).hideCurrentSnackBar();

View File

@@ -43,6 +43,14 @@ class _HistoryScreenState extends State<HistoryScreen> {
setState(() { setState(() {
_sessions = sessions; _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 --- // --- LOGIQUE DE FILTRAGE PAR PÉRIODE ---
if (_selectedDateRange != null) { if (_selectedDateRange != null) {
_sessions = _sessions.where((s) { _sessions = _sessions.where((s) {
@@ -125,17 +133,29 @@ class _HistoryScreenState extends State<HistoryScreen> {
appBar: AppBar( appBar: AppBar(
title: const Text('Historique'), title: const Text('Historique'),
actions: [ actions: [
PopupMenuButton<TargetType?>( // NOTE : on passe par un String sentinelle ('all') car un
icon: const Icon(Icons.filter_list), // PopupMenuItem avec value null ne déclenche jamais onSelected
onSelected: (type) { // (Flutter l'interprète comme une annulation du menu).
setState(() => _filterType = type); PopupMenuButton<String>(
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(); _loadSessions();
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
const PopupMenuItem(value: null, child: Text('Tous')), const PopupMenuItem(value: 'all', child: Text('Tous')),
...TargetType.values.map( ...TargetType.values.map(
(type) => (type) => PopupMenuItem(
PopupMenuItem(value: type, child: Text(type.displayName)), value: type.name,
child: Text(type.displayName),
),
), ),
], ],
), ),

View File

@@ -53,6 +53,8 @@ class AiExportService {
required double targetCenterY, required double targetCenterY,
required double targetRadius, required double targetRadius,
required List<Shot> shots, required List<Shot> shots,
int distanceMeters = 25,
String weaponName = 'Unknown',
String? apiUrl, String? apiUrl,
}) async { }) async {
try { try {
@@ -109,8 +111,8 @@ class AiExportService {
"device_info": deviceData, "device_info": deviceData,
"target_metadata": { "target_metadata": {
"type": targetType.name, "type": targetType.name,
"distance_meters": 25, // Default/placeholder "distance_meters": distanceMeters,
"weapon": "Unknown", // Default/placeholder "weapon": weaponName,
// The backend could extract exact width/height from the image. // The backend could extract exact width/height from the image.
}, },
"plotting": { "plotting": {

View File

@@ -26,23 +26,34 @@ class TargetDetectionResult {
class OpenCVTargetService { class OpenCVTargetService {
/// Detect the main target (center and radius) from an image file /// 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<TargetDetectionResult> detectTarget(String imagePath) async { Future<TargetDetectionResult> detectTarget(String imagePath) async {
cv.Mat? img;
cv.Mat? gray;
cv.Mat? blurred;
cv.Mat? circles;
cv.Mat? looseCircles;
try { try {
// Read image // Read image
final img = cv.imread(imagePath, flags: cv.IMREAD_COLOR); img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (img.isEmpty) { if (img.isEmpty) {
return TargetDetectionResult.failure(); return TargetDetectionResult.failure();
} }
// Convert to grayscale // Convert to grayscale
final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY); gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
// Apply Gaussian blur to reduce noise // 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 // Detect circles using Hough Transform.
// Parameters need to be tuned for the specific target type // HoughCircles returns a Mat of shape (1, N) of Vec3f (x, y, r).
final circles = cv.HoughCircles( circles = cv.HoughCircles(
blurred, blurred,
cv.HOUGH_GRADIENT, cv.HOUGH_GRADIENT,
1, // dp 1, // dp
@@ -55,26 +66,9 @@ class OpenCVTargetService {
maxRadius: img.cols ~/ 2, 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<Vec3f>` likely failed compilation or runtime.
// Let's use a safer approach: assume standard memory layout (x, y, r, x, y, r...).
// Or use `at<double>` 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<double>` for flattened access OR `at<Vec3f>`.
// NOTE: `at<Vec3f>` 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) { if (circles.isEmpty) {
// Try with different parameters if first attempt fails (more lenient) // Try with different parameters if first attempt fails (more lenient)
final looseCircles = cv.HoughCircles( looseCircles = cv.HoughCircles(
blurred, blurred,
cv.HOUGH_GRADIENT, cv.HOUGH_GRADIENT,
1, 1,
@@ -93,8 +87,15 @@ class OpenCVTargetService {
return _findBestConcentricCircles(circles, img.cols, img.rows); return _findBestConcentricCircles(circles, img.cols, img.rows);
} catch (e) { } catch (e) {
// print('Error detecting target with OpenCV: $e');
return TargetDetectionResult.failure(); 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();
} }
} }

View File

@@ -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');
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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 // Le montage complet de BullyApp exige les providers globaux et une base
// utility in the flutter_test package. For example, you can send tap and scroll // SQLite initialisée ; pour un test widget rapide et stable, on valide ici
// gestures. You can also use WidgetTester to find child widgets in the widget // le rendu d'un composant autonome.
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:bully/app.dart'; import 'package:bully/features/home/widgets/stats_card.dart';
void main() { void main() {
testWidgets('App loads correctly', (WidgetTester tester) async { testWidgets('StatsCard affiche le titre, la valeur et l\'icône',
// Build our app and trigger a frame. (WidgetTester tester) async {
await tester.pumpWidget(const BullyApp()); 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('Tirs'), findsOneWidget);
expect(find.text('Bully'), findsOneWidget); expect(find.text('42'), findsOneWidget);
expect(find.byIcon(Icons.gps_fixed), findsOneWidget);
}); });
} }