From d487c98e3d76e0bf01b94d2cc07c79e460f62c4a Mon Sep 17 00:00:00 2001 From: qguillaume Date: Fri, 5 Jun 2026 20:33:04 +0200 Subject: [PATCH] fix: parallelisme service permissif et correctif capture screen --- lib/features/capture/capture_screen.dart | 5 +- lib/services/parallelism_service.dart | 142 ++++++++++++----------- 2 files changed, 77 insertions(+), 70 deletions(-) diff --git a/lib/features/capture/capture_screen.dart b/lib/features/capture/capture_screen.dart index 2b6ae3cb..c0790382 100644 --- a/lib/features/capture/capture_screen.dart +++ b/lib/features/capture/capture_screen.dart @@ -36,7 +36,8 @@ class _CaptureScreenState extends State // NOUVEAU : Service IMU de parallélisme final ParallelismService _parallelismService = ParallelismService( - thresholdDegrees: 5.0, // ±5° max autorisés + alignThreshold: 15.0, // Seuil pour passer au vert + misalignThreshold: 22.0, // Seuil pour repasser à l'orange ); String? _selectedImagePath; @@ -815,4 +816,4 @@ class _CaptureScreenState extends State ), ); } -} \ No newline at end of file +} diff --git a/lib/services/parallelism_service.dart b/lib/services/parallelism_service.dart index ffa6dcc4..82122d74 100644 --- a/lib/services/parallelism_service.dart +++ b/lib/services/parallelism_service.dart @@ -7,10 +7,10 @@ enum ParallelismStatus { /// Pas encore de données capteur disponibles. unknown, - /// L'appareil est bien parallèle à la cible (pitch et roll dans les seuils). + /// L'appareil est bien parallèle à la cible. aligned, - /// L'appareil est trop incliné — l'utilisateur doit repositionner. + /// L'appareil est trop incliné. misaligned, } @@ -30,7 +30,6 @@ class ParallelismData { required this.rollDegrees, }); - /// Raccourci : vrai si l'appareil est bien aligné. bool get isAligned => status == ParallelismStatus.aligned; @override @@ -40,62 +39,69 @@ class ParallelismData { /// Service de détection du parallélisme par accéléromètre. /// -/// Utilise le package sensors_plus pour lire l'accéléromètre en continu. -/// Calcule le pitch (inclinaison avant/arrière) et le roll (inclinaison latérale). +/// Implémente une hystérésis à deux seuils pour éviter le clignotement : /// -/// Un appareil tenu **à la verticale face à la cible** a : -/// - pitch ≈ 0° (l'axe X est horizontal) -/// - roll ≈ 0° (l'axe Y est vertical, gravité vers le bas) +/// État actuel = misaligned → passe à aligned si angle < [alignThreshold] +/// État actuel = aligned → passe à misaligned si angle > [misalignThreshold] /// -/// En mode **paysage** (appareil à l'horizontale), les axes sont permutés — -/// le service gère les deux orientations automatiquement. +/// Cela crée une "zone de confort" entre les deux seuils où le statut +/// ne change pas — le vert reste vert même si la main tremble légèrement. +/// +/// Valeurs par défaut recommandées : +/// alignThreshold = 15° (seuil d'entrée dans le vert — assez souple) +/// misalignThreshold = 22° (seuil de sortie du vert — tolérant au tremblement) class ParallelismService { - /// Seuil d'angle maximum autorisé en degrés. - /// En dessous → aligned, au-dessus → misaligned. - final double thresholdDegrees; + /// Angle max pour passer de misaligned → aligned. + final double alignThreshold; + + /// Angle à dépasser pour passer de aligned → misaligned. + /// Doit être > alignThreshold pour créer la zone d'hystérésis. + final double misalignThreshold; StreamSubscription? _subscription; final StreamController _controller = - StreamController.broadcast(); + StreamController.broadcast(); - ParallelismService({this.thresholdDegrees = 5.0}); + /// État interne mémorisé entre deux frames (cœur de l'hystérésis). + ParallelismStatus _currentStatus = ParallelismStatus.unknown; + + ParallelismService({ + this.alignThreshold = 15.0, + this.misalignThreshold = 22.0, + }) : assert( + misalignThreshold > alignThreshold, + 'misalignThreshold doit être supérieur à alignThreshold', + ); - /// Stream de données de parallélisme mis à jour en temps réel. Stream get stream => _controller.stream; - /// Démarre l'écoute de l'accéléromètre. - /// Appeler au moment d'afficher la caméra live. void start() { - if (_subscription != null) return; // Déjà démarré + if (_subscription != null) return; _subscription = accelerometerEventStream( samplingPeriod: SensorInterval.normalInterval, // ~50 ms ).listen( _onAccelerometerEvent, - onError: (e) { - // En cas d'erreur capteur (simulateur sans IMU par exemple), - // on émet un statut "unknown" pour ne pas bloquer l'UI. + onError: (_) { + // Simulateur ou capteur absent — on reste en "unknown" sans bloquer l'UI if (!_controller.isClosed) { - _controller.add( - const ParallelismData( - status: ParallelismStatus.unknown, - pitchDegrees: 0, - rollDegrees: 0, - ), - ); + _currentStatus = ParallelismStatus.unknown; + _controller.add(const ParallelismData( + status: ParallelismStatus.unknown, + pitchDegrees: 0, + rollDegrees: 0, + )); } }, ); } - /// Stoppe l'écoute de l'accéléromètre. - /// Appeler quand l'écran caméra est fermé pour économiser la batterie. void stop() { _subscription?.cancel(); _subscription = null; + _currentStatus = ParallelismStatus.unknown; } - /// Libère toutes les ressources. void dispose() { stop(); _controller.close(); @@ -104,53 +110,53 @@ class ParallelismService { void _onAccelerometerEvent(AccelerometerEvent event) { if (_controller.isClosed) return; - // L'accéléromètre retourne (x, y, z) en m/s². - // Au repos, la magnitude vaut ~9.81 (gravité terrestre). - // - // Convention capteur Android/iOS (portrait) : - // x → axe horizontal (positif = droite) - // y → axe vertical (positif = haut) - // z → axe profondeur (positif = vers l'utilisateur) - // - // On normalise pour être indépendant de la gravité exacte. final double gx = event.x; final double gy = event.y; final double gz = event.z; final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz); - if (magnitude < 1.0) { - // Données aberrantes (quasi-nulles) — on ignore - return; - } + if (magnitude < 1.0) return; // Données aberrantes - // Normalisation + // Normalisation par la magnitude réelle (indépendant de g exact) final double nx = gx / magnitude; final double ny = gy / magnitude; final double nz = gz / magnitude; - // Pitch : inclinaison avant/arrière - // En portrait vertical face à l'utilisateur : ny ≈ -1, nz ≈ 0 → pitch ≈ 0° - // On calcule l'angle entre le vecteur gravité projeté et l'axe Y pur. - final double pitchRad = math.asin(nz.clamp(-1.0, 1.0)); - final double pitchDeg = pitchRad * (180.0 / math.pi); + // Pitch : inclinaison avant/arrière (téléphone qui pointe vers le haut/bas) + final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi); - // Roll : inclinaison latérale gauche/droite - // En portrait droit : nx ≈ 0 → roll ≈ 0° - final double rollRad = math.atan2(nx, -ny); - final double rollDeg = rollRad * (180.0 / math.pi); + // Roll : inclinaison latérale (téléphone penché à gauche/droite) + final double rollDeg = math.atan2(nx, -ny) * (180.0 / math.pi); - final bool isAligned = - pitchDeg.abs() <= thresholdDegrees && - rollDeg.abs() <= thresholdDegrees; + // Angle combiné = le pire des deux axes + final double worstAngle = math.max(pitchDeg.abs(), rollDeg.abs()); - _controller.add( - ParallelismData( - status: isAligned - ? ParallelismStatus.aligned - : ParallelismStatus.misaligned, - pitchDegrees: pitchDeg, - rollDegrees: rollDeg, - ), - ); + // ── Hystérésis ────────────────────────────────────────────────────────── + // Premier appel : on décide selon alignThreshold uniquement + if (_currentStatus == ParallelismStatus.unknown) { + _currentStatus = worstAngle <= alignThreshold + ? ParallelismStatus.aligned + : ParallelismStatus.misaligned; + } + // Déjà misaligned → devient aligned seulement si on passe sous alignThreshold + else if (_currentStatus == ParallelismStatus.misaligned) { + if (worstAngle <= alignThreshold) { + _currentStatus = ParallelismStatus.aligned; + } + } + // Déjà aligned → devient misaligned seulement si on dépasse misalignThreshold + else if (_currentStatus == ParallelismStatus.aligned) { + if (worstAngle > misalignThreshold) { + _currentStatus = ParallelismStatus.misaligned; + } + // Entre alignThreshold et misalignThreshold → on garde le vert, on ne change rien + } + // ──────────────────────────────────────────────────────────────────────── + + _controller.add(ParallelismData( + status: _currentStatus, + pitchDegrees: pitchDeg, + rollDegrees: rollDeg, + )); } -} \ No newline at end of file +}