Files
impact/lib/services/parallelism_service.dart
T

250 lines
9.4 KiB
Dart

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.
enum ParallelismStatus {
/// Pas encore de données capteur disponibles.
unknown,
/// L'appareil est bien parallèle à la cible.
aligned,
/// L'appareil est trop incliné.
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;
/// 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, 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]
/// État actuel = aligned → passe à misaligned si angle > [misalignThreshold]
///
/// 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 {
/// 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;
/// É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<AccelerometerEvent>? _subscription;
final StreamController<ParallelismData> _controller =
StreamController<ParallelismData>.broadcast();
/// É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,
}) : assert(
misalignThreshold > alignThreshold,
'misalignThreshold doit être supérieur à alignThreshold',
);
Stream<ParallelismData> get stream => _controller.stream;
void start() {
if (_subscription != null) return;
_subscription = accelerometerEventStream(
samplingPeriod: SensorInterval.normalInterval, // ~50 ms
).listen(
_onAccelerometerEvent,
onError: (_) {
// 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,
));
}
},
);
}
void stop() {
_subscription?.cancel();
_subscription = null;
_currentStatus = ParallelismStatus.unknown;
_currentPose = null;
}
void dispose() {
stop();
_controller.close();
}
void _onAccelerometerEvent(AccelerometerEvent event) {
if (_controller.isClosed) return;
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 null; // Données aberrantes
// Normalisation par la magnitude réelle (indépendant de g exact)
final double nx = gx / magnitude;
final double nz = gz / magnitude;
// Pitch et Roll mesurent l'inclinaison autour de chaque axe.
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);
// ── 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
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
}
// ────────────────────────────────────────────────────────────────────────
return ParallelismData(
status: _currentStatus,
pose: _currentPose!,
pitchDegrees: pitchDeg,
rollDegrees: rollDeg,
pitchDeviation: pitchDeviation,
);
}
}