From 8804d8b6a17cae6d1a1613ea5273381750516a0f Mon Sep 17 00:00:00 2001 From: qlionbleusam Date: Sat, 29 Aug 2026 10:33:59 +0200 Subject: [PATCH 1/2] feat(capture): detect the flat pose for targets laid on the ground --- lib/features/capture/capture_screen.dart | 34 +++-- lib/services/parallelism_service.dart | 114 ++++++++++++++-- test/services/parallelism_service_test.dart | 136 ++++++++++++++++++++ 3 files changed, 262 insertions(+), 22 deletions(-) create mode 100644 test/services/parallelism_service_test.dart diff --git a/lib/features/capture/capture_screen.dart b/lib/features/capture/capture_screen.dart index 0818ecea..3db9182c 100644 --- a/lib/features/capture/capture_screen.dart +++ b/lib/features/capture/capture_screen.dart @@ -169,8 +169,9 @@ class _CaptureScreenState extends State final prev = _parallelismData; final bool changed = prev == null || prev.status != data.status || - prev.pitchDegrees.toStringAsFixed(1) != - data.pitchDegrees.toStringAsFixed(1) || + prev.pose != data.pose || + prev.pitchDeviation.toStringAsFixed(1) != + data.pitchDeviation.toStringAsFixed(1) || prev.rollDegrees.toStringAsFixed(1) != data.rollDegrees.toStringAsFixed(1); @@ -218,16 +219,21 @@ class _CaptureScreenState extends State return 'ALIGNEZ LA CIBLE DANS LE CADRE'; } + final bool onGround = _parallelismData!.pose == TargetPose.ground; + // Aligné → message de validation (avec bonus si la cible est détectée) if (_parallelismData!.isAligned) { - return _targetReady - ? 'PARFAIT — CIBLE DÉTECTÉE, PRÊT' + if (_targetReady) return 'PARFAIT — CIBLE DÉTECTÉE, PRÊT'; + return onGround + ? 'CIBLE AU SOL — PRÊT À PHOTOGRAPHIER' : 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER'; } - // Mal aligné → message directif selon l'axe le plus dévié - final double pitch = _parallelismData!.pitchDegrees; - final double roll = _parallelismData!.rollDegrees; + // Mal aligné → message directif selon l'axe le plus dévié. + // On raisonne sur les écarts à la pose détectée, pas sur le tangage brut : + // à plat, celui-ci vaut -90° alors que le cadrage peut être parfait. + final double pitch = _parallelismData!.pitchDeviation; + final double roll = _parallelismData!.rollDeviation; if (pitch.abs() >= roll.abs()) { return pitch > 0 @@ -776,15 +782,25 @@ class _CaptureScreenState extends State crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Text( + data.pose == TargetPose.ground ? 'CIBLE AU SOL' : 'CIBLE AU MUR', + style: TextStyle( + color: color, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 4), _buildAngleRow( label: 'Pitch', - value: data.pitchDegrees, + value: data.pitchDeviation, color: color, ), const SizedBox(height: 2), _buildAngleRow( label: 'Roll ', - value: data.rollDegrees, + value: data.rollDeviation, color: color, ), ], diff --git a/lib/services/parallelism_service.dart b/lib/services/parallelism_service.dart index 4dc909b0..b89d9c8b 100644 --- a/lib/services/parallelism_service.dart +++ b/lib/services/parallelism_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:flutter/foundation.dart'; import 'package:sensors_plus/sensors_plus.dart'; /// Statut de parallélisme retourné en temps réel. @@ -14,31 +15,67 @@ enum ParallelismStatus { misaligned, } +/// Pose de prise de vue déduite de l'inclinaison de l'appareil. +enum TargetPose { + /// Cible accrochée verticalement : l'appareil est tenu droit. + wall, + + /// Cible posée au sol : l'appareil est à plat, caméra vers le bas. + ground, +} + /// Données de parallélisme calculées à chaque frame capteur. class ParallelismData { final ParallelismStatus status; - /// Inclinaison avant/arrière en degrés (0° = parfaitement vertical). + /// Pose détectée automatiquement, qui sert de référence aux écarts. + final TargetPose pose; + + /// Inclinaison avant/arrière brute en degrés (0° = appareil vertical, + /// -90° = appareil à plat, caméra vers le sol). final double pitchDegrees; /// Inclinaison gauche/droite en degrés (0° = parfaitement droit). final double rollDegrees; + /// Écart de tangage par rapport à la pose détectée. + /// + /// En pose [TargetPose.wall] il vaut exactement [pitchDegrees] ; en pose + /// [TargetPose.ground] il mesure l'écart aux -90° de l'appareil à plat. + /// C'est cette valeur qu'il faut afficher : le tangage brut vaudrait -90° + /// alors que le cadrage est parfait. + final double pitchDeviation; + const ParallelismData({ required this.status, + required this.pose, required this.pitchDegrees, required this.rollDegrees, + required this.pitchDeviation, }); bool get isAligned => status == ParallelismStatus.aligned; + /// Écart latéral. Le roulis se mesure de la même façon dans les deux poses. + double get rollDeviation => rollDegrees; + @override String toString() => - 'ParallelismData(status: $status, pitch: ${pitchDegrees.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)'; + 'ParallelismData(status: $status, pose: $pose, pitch: ${pitchDegrees.toStringAsFixed(1)}°, ' + 'écart: ${pitchDeviation.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)'; } /// Service de détection du parallélisme par accéléromètre. /// +/// Deux poses de prise de vue sont reconnues, et celle dont l'appareil est le +/// plus proche est retenue automatiquement : +/// +/// [TargetPose.wall] — cible au mur, appareil vertical (tangage ≈ 0°) +/// [TargetPose.ground] — cible au sol, appareil à plat (tangage ≈ -90°) +/// +/// Seul le tangage négatif vaut pour la pose au sol : à plat écran vers le +/// haut, la caméra vise le plafond et le vert n'aurait aucun sens. +/// /// Implémente une hystérésis à deux seuils pour éviter le clignotement : /// /// État actuel = misaligned → passe à aligned si angle < [alignThreshold] @@ -58,6 +95,13 @@ class ParallelismService { /// Doit être > alignThreshold pour créer la zone d'hystérésis. final double misalignThreshold; + /// Écart minimum en faveur de l'autre pose pour basculer. + /// + /// Les deux poses sont séparées de 90°, donc la bascule se joue vers 45° — + /// très loin des deux zones vertes. Cette marge évite seulement que + /// l'étiquette de pose clignote pile à la frontière. + static const double poseSwitchMargin = 5.0; + StreamSubscription? _subscription; final StreamController _controller = StreamController.broadcast(); @@ -65,6 +109,9 @@ class ParallelismService { /// État interne mémorisé entre deux frames (cœur de l'hystérésis). ParallelismStatus _currentStatus = ParallelismStatus.unknown; + /// Pose retenue à la frame précédente, pour l'hystérésis de pose. + TargetPose? _currentPose; + ParallelismService({ this.alignThreshold = 25.0, this.misalignThreshold = 32.0, @@ -86,10 +133,13 @@ class ParallelismService { // Simulateur ou capteur absent — on reste en "unknown" sans bloquer l'UI if (!_controller.isClosed) { _currentStatus = ParallelismStatus.unknown; + _currentPose = null; _controller.add(const ParallelismData( status: ParallelismStatus.unknown, + pose: TargetPose.wall, pitchDegrees: 0, rollDegrees: 0, + pitchDeviation: 0, )); } }, @@ -100,6 +150,7 @@ class ParallelismService { _subscription?.cancel(); _subscription = null; _currentStatus = ParallelismStatus.unknown; + _currentPose = null; } void dispose() { @@ -110,12 +161,18 @@ class ParallelismService { void _onAccelerometerEvent(AccelerometerEvent event) { if (_controller.isClosed) return; - final double gx = event.x; - final double gy = event.y; - final double gz = event.z; + final data = evaluate(event.x, event.y, event.z); + if (data != null) _controller.add(data); + } + /// Calcule la pose et le statut depuis une mesure d'accéléromètre. + /// + /// Met à jour l'état d'hystérésis, donc l'ordre des appels compte. + /// Retourne null sur une mesure aberrante, qu'il faut alors ignorer. + @visibleForTesting + ParallelismData? evaluate(double gx, double gy, double gz) { final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz); - if (magnitude < 1.0) return; // Données aberrantes + if (magnitude < 1.0) return null; // Données aberrantes // Normalisation par la magnitude réelle (indépendant de g exact) final double nx = gx / magnitude; @@ -125,10 +182,39 @@ class ParallelismService { final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi); final double rollDeg = math.asin(nx.clamp(-1.0, 1.0)) * (180.0 / math.pi); - // Le critère de couleur = le PIRE des deux angles affichés à l'écran. - // Ainsi ce que voit l'utilisateur (Pitch / Roll) correspond exactement - // à la décision vert/orange : à 2° d'écart, on est largement dans le vert. - final double worstAngle = math.max(pitchDeg.abs(), rollDeg.abs()); + // ── Choix de la pose ──────────────────────────────────────────────────── + // Cible au mur : le tangage idéal est 0°. Formule d'origine, inchangée. + final double wallPitchDeviation = pitchDeg; + final double wallWorst = math.max(pitchDeg.abs(), rollDeg.abs()); + + // Cible au sol : le tangage idéal est -90° (caméra vers le bas). Écran vers + // le haut, le tangage vaut +90° et l'écart atteint 180° : la pose au sol ne + // peut alors jamais gagner, ce qui est exactement le comportement voulu. + final double groundPitchDeviation = pitchDeg + 90.0; + final double groundWorst = + math.max(groundPitchDeviation.abs(), rollDeg.abs()); + + if (_currentPose == null) { + _currentPose = + groundWorst < wallWorst ? TargetPose.ground : TargetPose.wall; + } else if (_currentPose == TargetPose.wall) { + if (groundWorst + poseSwitchMargin < wallWorst) { + _currentPose = TargetPose.ground; + } + } else { + if (wallWorst + poseSwitchMargin < groundWorst) { + _currentPose = TargetPose.wall; + } + } + + final bool isGround = _currentPose == TargetPose.ground; + final double pitchDeviation = + isGround ? groundPitchDeviation : wallPitchDeviation; + + // Le critère de couleur = le PIRE des deux écarts affichés à l'écran. + // Ainsi ce que voit l'utilisateur correspond exactement à la décision + // vert/orange : à 2° d'écart, on est largement dans le vert. + final double worstAngle = isGround ? groundWorst : wallWorst; // ── Hystérésis ────────────────────────────────────────────────────────── // Premier appel : on décide selon alignThreshold uniquement @@ -152,10 +238,12 @@ class ParallelismService { } // ──────────────────────────────────────────────────────────────────────── - _controller.add(ParallelismData( + return ParallelismData( status: _currentStatus, + pose: _currentPose!, pitchDegrees: pitchDeg, rollDegrees: rollDeg, - )); + pitchDeviation: pitchDeviation, + ); } -} \ No newline at end of file +} diff --git a/test/services/parallelism_service_test.dart b/test/services/parallelism_service_test.dart new file mode 100644 index 00000000..6849e3bf --- /dev/null +++ b/test/services/parallelism_service_test.dart @@ -0,0 +1,136 @@ +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:bully/services/parallelism_service.dart'; + +/// Vecteur de gravité mesuré par l'accéléromètre pour une pose donnée. +/// +/// [pitch] et [roll] sont exprimés comme dans le service : le tangage vaut 0° +/// appareil vertical et -90° appareil à plat caméra vers le sol. +({double x, double y, double z}) gravity({ + double pitch = 0, + double roll = 0, +}) { + const double g = 9.81; + final double p = pitch * math.pi / 180.0; + final double r = roll * math.pi / 180.0; + + final double nz = math.sin(p); + final double nx = math.sin(r); + // Ce qui reste va sur y, l'axe vertical de l'écran. + final double ny = math.sqrt(math.max(0.0, 1.0 - nz * nz - nx * nx)); + + return (x: nx * g, y: ny * g, z: nz * g); +} + +ParallelismData evaluatePose( + ParallelismService service, { + double pitch = 0, + double roll = 0, +}) { + final v = gravity(pitch: pitch, roll: roll); + final data = service.evaluate(v.x, v.y, v.z); + expect(data, isNotNull, reason: 'mesure jugée aberrante à tort'); + return data!; +} + +void main() { + group('pose cible au mur (comportement existant)', () { + test('appareil vertical → aligné', () { + final data = evaluatePose(ParallelismService()); + + expect(data.pose, TargetPose.wall); + expect(data.status, ParallelismStatus.aligned); + expect(data.pitchDeviation, closeTo(0, 0.5)); + }); + + test('l\'écart de tangage reste le tangage brut', () { + final data = evaluatePose(ParallelismService(), pitch: 10); + + expect(data.pose, TargetPose.wall); + expect(data.pitchDeviation, closeTo(data.pitchDegrees, 0.001)); + }); + + test('inclinaison au-delà du seuil → désaligné', () { + final data = evaluatePose(ParallelismService(), pitch: 40); + + expect(data.pose, TargetPose.wall); + expect(data.status, ParallelismStatus.misaligned); + }); + + test('roulis excessif → désaligné même si le tangage est bon', () { + final data = evaluatePose(ParallelismService(), roll: 35); + + expect(data.status, ParallelismStatus.misaligned); + }); + }); + + group('pose cible au sol', () { + test('appareil à plat caméra vers le bas → aligné', () { + final data = evaluatePose(ParallelismService(), pitch: -90); + + expect(data.pose, TargetPose.ground); + expect(data.status, ParallelismStatus.aligned); + expect(data.pitchDeviation, closeTo(0, 0.5)); + }); + + test('l\'écart se mesure par rapport à -90°, pas à 0°', () { + final data = evaluatePose(ParallelismService(), pitch: -80); + + expect(data.pose, TargetPose.ground); + expect(data.pitchDeviation, closeTo(10, 0.5)); + expect(data.status, ParallelismStatus.aligned); + }); + + test('à plat mais trop incliné → désaligné', () { + final data = evaluatePose(ParallelismService(), pitch: -50); + + expect(data.status, ParallelismStatus.misaligned); + }); + + test('à plat écran vers le haut → jamais aligné', () { + // La caméra vise le plafond : le vert n'aurait aucun sens. + final data = evaluatePose(ParallelismService(), pitch: 90); + + expect(data.pose, TargetPose.wall); + expect(data.status, ParallelismStatus.misaligned); + }); + }); + + group('hystérésis', () { + test('le vert survit à un léger tremblement entre les deux seuils', () { + final service = ParallelismService(); + + expect(evaluatePose(service).status, ParallelismStatus.aligned); + // 28° est au-dessus du seuil d'entrée (25°) mais sous celui de sortie (32°). + expect(evaluatePose(service, pitch: 28).status, ParallelismStatus.aligned); + expect(evaluatePose(service, pitch: 40).status, ParallelismStatus.misaligned); + // Repasser sous 32° ne suffit pas : il faut redescendre sous 25°. + expect(evaluatePose(service, pitch: 28).status, ParallelismStatus.misaligned); + expect(evaluatePose(service, pitch: 20).status, ParallelismStatus.aligned); + }); + + test('la pose ne bascule pas pour un écart négligeable', () { + final service = ParallelismService(); + + expect(evaluatePose(service).pose, TargetPose.wall); + // Pile à la frontière des deux poses : on garde celle déjà retenue. + expect(evaluatePose(service, pitch: -45).pose, TargetPose.wall); + // Franchement du côté du sol : on bascule. + expect(evaluatePose(service, pitch: -70).pose, TargetPose.ground); + }); + + test('stop() remet la pose et le statut à zéro', () { + final service = ParallelismService(); + + expect(evaluatePose(service, pitch: -90).pose, TargetPose.ground); + service.stop(); + expect(evaluatePose(service).pose, TargetPose.wall); + }); + }); + + test('une mesure aberrante est ignorée', () { + expect(ParallelismService().evaluate(0, 0, 0), isNull); + }); +} From 6ee0839db5c70b89f5692d9c5c1b91d9d26aa489 Mon Sep 17 00:00:00 2001 From: qlionbleusam Date: Sat, 29 Aug 2026 11:07:46 +0200 Subject: [PATCH 2/2] feat(capture): detect the flat pose for targets laid on the ground --- lib/services/parallelism_service.dart | 14 +++++++------- test/services/parallelism_service_test.dart | 20 ++++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/lib/services/parallelism_service.dart b/lib/services/parallelism_service.dart index b89d9c8b..6feb99fc 100644 --- a/lib/services/parallelism_service.dart +++ b/lib/services/parallelism_service.dart @@ -32,7 +32,7 @@ class ParallelismData { final TargetPose pose; /// Inclinaison avant/arrière brute en degrés (0° = appareil vertical, - /// -90° = appareil à plat, caméra vers le sol). + /// +90° = appareil à plat, caméra vers le sol). final double pitchDegrees; /// Inclinaison gauche/droite en degrés (0° = parfaitement droit). @@ -41,7 +41,7 @@ class ParallelismData { /// Écart de tangage par rapport à la pose détectée. /// /// En pose [TargetPose.wall] il vaut exactement [pitchDegrees] ; en pose - /// [TargetPose.ground] il mesure l'écart aux -90° de l'appareil à plat. + /// [TargetPose.ground] il mesure l'écart aux +90° de l'appareil à plat. /// C'est cette valeur qu'il faut afficher : le tangage brut vaudrait -90° /// alors que le cadrage est parfait. final double pitchDeviation; @@ -71,9 +71,9 @@ class ParallelismData { /// plus proche est retenue automatiquement : /// /// [TargetPose.wall] — cible au mur, appareil vertical (tangage ≈ 0°) -/// [TargetPose.ground] — cible au sol, appareil à plat (tangage ≈ -90°) +/// [TargetPose.ground] — cible au sol, appareil à plat (tangage ≈ +90°) /// -/// Seul le tangage négatif vaut pour la pose au sol : à plat écran vers le +/// Seul le tangage positif vaut pour la pose au sol : à plat écran vers le /// haut, la caméra vise le plafond et le vert n'aurait aucun sens. /// /// Implémente une hystérésis à deux seuils pour éviter le clignotement : @@ -187,10 +187,10 @@ class ParallelismService { final double wallPitchDeviation = pitchDeg; final double wallWorst = math.max(pitchDeg.abs(), rollDeg.abs()); - // Cible au sol : le tangage idéal est -90° (caméra vers le bas). Écran vers - // le haut, le tangage vaut +90° et l'écart atteint 180° : la pose au sol ne + // Cible au sol : le tangage idéal est +90° (caméra vers le bas). Écran vers + // le haut, le tangage vaut -90° et l'écart atteint 180° : la pose au sol ne // peut alors jamais gagner, ce qui est exactement le comportement voulu. - final double groundPitchDeviation = pitchDeg + 90.0; + final double groundPitchDeviation = pitchDeg - 90.0; final double groundWorst = math.max(groundPitchDeviation.abs(), rollDeg.abs()); diff --git a/test/services/parallelism_service_test.dart b/test/services/parallelism_service_test.dart index 6849e3bf..8e8a62f6 100644 --- a/test/services/parallelism_service_test.dart +++ b/test/services/parallelism_service_test.dart @@ -7,7 +7,7 @@ import 'package:bully/services/parallelism_service.dart'; /// Vecteur de gravité mesuré par l'accéléromètre pour une pose donnée. /// /// [pitch] et [roll] sont exprimés comme dans le service : le tangage vaut 0° -/// appareil vertical et -90° appareil à plat caméra vers le sol. +/// appareil vertical et +90° appareil à plat caméra vers le sol. ({double x, double y, double z}) gravity({ double pitch = 0, double roll = 0, @@ -68,30 +68,30 @@ void main() { group('pose cible au sol', () { test('appareil à plat caméra vers le bas → aligné', () { - final data = evaluatePose(ParallelismService(), pitch: -90); + final data = evaluatePose(ParallelismService(), pitch: 90); expect(data.pose, TargetPose.ground); expect(data.status, ParallelismStatus.aligned); expect(data.pitchDeviation, closeTo(0, 0.5)); }); - test('l\'écart se mesure par rapport à -90°, pas à 0°', () { - final data = evaluatePose(ParallelismService(), pitch: -80); + test('l\'écart se mesure par rapport à +90°, pas à 0°', () { + final data = evaluatePose(ParallelismService(), pitch: 80); expect(data.pose, TargetPose.ground); - expect(data.pitchDeviation, closeTo(10, 0.5)); + expect(data.pitchDeviation, closeTo(-10, 0.5)); expect(data.status, ParallelismStatus.aligned); }); test('à plat mais trop incliné → désaligné', () { - final data = evaluatePose(ParallelismService(), pitch: -50); + final data = evaluatePose(ParallelismService(), pitch: 50); expect(data.status, ParallelismStatus.misaligned); }); test('à plat écran vers le haut → jamais aligné', () { // La caméra vise le plafond : le vert n'aurait aucun sens. - final data = evaluatePose(ParallelismService(), pitch: 90); + final data = evaluatePose(ParallelismService(), pitch: -90); expect(data.pose, TargetPose.wall); expect(data.status, ParallelismStatus.misaligned); @@ -116,15 +116,15 @@ void main() { expect(evaluatePose(service).pose, TargetPose.wall); // Pile à la frontière des deux poses : on garde celle déjà retenue. - expect(evaluatePose(service, pitch: -45).pose, TargetPose.wall); + expect(evaluatePose(service, pitch: 45).pose, TargetPose.wall); // Franchement du côté du sol : on bascule. - expect(evaluatePose(service, pitch: -70).pose, TargetPose.ground); + expect(evaluatePose(service, pitch: 70).pose, TargetPose.ground); }); test('stop() remet la pose et le statut à zéro', () { final service = ParallelismService(); - expect(evaluatePose(service, pitch: -90).pose, TargetPose.ground); + expect(evaluatePose(service, pitch: 90).pose, TargetPose.ground); service.stop(); expect(evaluatePose(service).pose, TargetPose.wall); });