Aller au contenu

Auto-calibration UWB par relevé fiduciel

Ce document décrit le workflow d’auto-calibration des ancres Topos à partir d’un relevé UWB direct, sans mesures laser. Il est pensé pour qu’un opérateur ou un développeur reprenant le projet puisse comprendre la mécanique interne rapidement.

La calibration historique de Topos (PUT /api/anchors/calibrate) demande à l’opérateur de reporter à la main, dans un formulaire, une vingtaine de distances mesurées au laser. C’est long, chronophage et sujet à erreurs de saisie.

L’auto-calibration inverse le flux : les ancres mesurent elles-mêmes leurs distances à des tags posés sur des points connus. Aucune saisie, zéro laser. La précision finale est gouvernée par le bruit UWB (~5-15 mm par mesure) et la géométrie du motif de référence.

┌─────────────┐ ┌────────────────┐ ┌──────────────────┐ ┌─────────┐
│ 5 tags posés│ UWB │ TrackerStore │ │ Aggrégation │ MAP │ Ancres │
│ sur la croix├────►│ .start_survey()├────►│ MAD-filtered ├────►│ XYZ + │
│ (1-3 poses) │ │ buffer samples │ │ median par paire │ │ biais + │
└─────────────┘ └────────────────┘ └──────────────────┘ │ RMSE │
↑ └────┬────┘
└──────────── opérateur clique "Appliquer" après review ◄─────────┘

Trois étapes côté code :

  1. start_survey (TrackerStore) — ouvre une fenêtre d’acquisition (append=True pour accumuler une pose supplémentaire de la croix).
  2. finalise_survey (TrackerStore) — ferme la fenêtre, agrège par médiane robuste, fusionne les poses accumulées.
  3. calibrate_anchors_from_survey (engine.anchor_calibration) — solveur MAP global : positions d’ancres, positions de fiduciaires et biais de portée par ancre résolus ensemble (perte robuste de Cauchy, priors adaptatifs, pré-passe LOO + trim). Voir « Solveur » plus bas.

Cinq fiduciaires à positions connues — convention scène française et axes Capture (+X=cour, +Y=up, +Z=face) :

ID Label Position Capture
0 Centre (cx, yC, cz)
1 Jardin (cx − dJ, yJ, cz)
2 Cour (cx + dC, yCo, cz)
3 Lointain (cx, yL, cz − dL)
4 Face (cx, yF, cz + dF)
  • (cx, cz) : offset du centre de la croix par rapport au (0,0) Capture (X cour-jardin, Z face-lointain). Peut valoir plusieurs mètres si un décor empêche de poser la croix au zéro scène.
  • dJ, dC, dL, dF : longueur de chaque branche, déclarables individuellement (la croix n’a pas à être symétrique). d ≈ 2 m recommandé pour minimiser l’effet de levier contre des ancres placées à 5-8 m.
  • yC, yJ, yCo, yL, yF : hauteur réelle de chaque tag pendant le relevé. Typiquement 0 (tag au sol), mais tout offset compte — voir ci-dessous.

Le wizard permettait initialement uniquement des fiduciaires à Y=0 (tags au sol). C’était une erreur de design : les scènes de théâtre ont très souvent des praticables qui surélèvent le plateau par rangées, ou des câbles/ supports qui décollent le module UWB de 5 à 10 cm au-dessus du sol.

Une erreur de 20-60 cm sur Y de fiduciaire se propage fortement sur les positions d’ancres calculées (observé le 23/04/2026 : 3 rangées de praticables à 20/40/60 cm non déclarées → deux ancres de face à 3 m d’écart en hauteur alors qu’elles étaient physiquement à 50 cm près).

Règle : renseigne la hauteur réelle du module UWB pour chaque tag pendant le relevé (centre du boîtier au-dessus du sol). Tolérance : ~2 cm.

Les fiduciaires étant généralement tous au sol, ils sont coplanaires à Y=0. La résolution en Y d’une ancre très basse (radiateur à 30 cm, ancre au sol) devient mauvaise : σ ≈ 20-30 cm au lieu des 1-2 cm typiques pour une ancre à 2 m.

Remède : activer le 6ème fiduciaire sur un trépied à ~1,5 m de haut dans le wizard. Le point non-coplanaire lève l’ambiguïté et ramène σ_Y sous 5 cm même pour les ancres au sol. Testé dans tests/test_survey_calibration.py::test_elevated_fiducial_improves_z.

Une seule pose de croix ne rend observables ni les biais de portée individuels (antenna delay mal calibré) ni certaines erreurs de déclaration. Le remède est opérationnellement trivial : relever la croix en 2-3 poses (centre, puis côté cour, puis côté jardin — ~30 s par pose), en gardant le trépied. Chaque ancre voit alors des fiduciaires à courte ET longue portée, ce qui sépare biais et position (la courbure du front d’onde devient mesurable).

Côté API : chaque pose est déclarée avec des ids de fiduciaires décalés de +10 × pose (pose 0 → ids 0-5, pose 1 → ids 10-15…), et start est rappelé avec "append": true entre les poses. Le solve final reçoit la liste complète des fiduciaires de toutes les poses.

Côté dashboard : pendant l’acquisition, le bouton « + pose suivante » clôt la pose courante et ramène au step géométrie (mettre à jour cx/cz après avoir déplacé la croix, reposer les tags, relancer l’acquisition). Le bouton « finaliser + calculer » résout toutes les poses accumulées d’un coup. Le step résultat affiche les biais de portée estimés par ancre et les fiduciaires que le solveur a dû déplacer (saisie suspecte).

Gains mesurés en simulation (bruit 10 mm, scénarios rejoués dans tests/test_survey_calibration_robust.py) :

Erreur injectée Solveur historique 3 poses + trépied
Biais commun ~20 cm (antenna delay) ~26 cm ~7 cm
Biais de 25 cm sur une seule ancre ~27 cm ~10 cm
Hauteur de fiduciaire fausse de 20 cm 17-50 cm ~7 cm
Deux mesures NLOS 13-290 cm 6-13 cm
Méthode Route Rôle
POST /api/anchors/autocalibrate/start Démarre le relevé
GET /api/anchors/autocalibrate/status Progression (samples par paire)
POST /api/anchors/autocalibrate/cancel Annule sans résoudre
POST /api/anchors/autocalibrate/solve Finalise + résout (option apply)
{
"tag_assignments": { "0": 0, "1": 1, "2": 2, "3": 3, "4": 4 },
"append": false
}

Clé = tag_id UWB, valeur = fiducial_id. Les tags non listés continuent de servir comme performers normaux pendant le relevé.

append: true clôt la pose en cours (agrégation en médiane robuste) et ré-arme l’acquisition pour la pose suivante — les assignments pointent alors vers les ids décalés ({"0": 10, "1": 11, …}). La réponse et /status exposent poses_accumulated et accumulated_pairs.

Positions en convention Capture (+X=cour, +Y=up, +Z=face). Exemple : croix de 2 m centrée sur (0, 0) en XZ, tags posés sur trois rangées de praticables à 20 / 40 / 60 cm.

{
"fiducials": [
{ "id": 0, "label": "Centre", "position": { "x": 0.0, "y": 0.40, "z": 0.0 }},
{ "id": 1, "label": "Jardin", "position": { "x": -2.0, "y": 0.40, "z": 0.0 }},
{ "id": 2, "label": "Cour", "position": { "x": 2.0, "y": 0.40, "z": 0.0 }},
{ "id": 3, "label": "Lointain", "position": { "x": 0.0, "y": 0.60, "z": -2.0 }},
{ "id": 4, "label": "Face", "position": { "x": 0.0, "y": 0.20, "z": 2.0 }}
],
"apply": false
}
  • Positions en Capture, offset de centre + Y par fiduciaire déjà appliqués côté client (depuis le wizard).
  • apply: false → répond avec les positions calculées + quality, sans toucher à l’état en cours (mode preview).
  • apply: true → écrit dans state.store, state.config, puis persiste.
{
"anchors": [{ "id": 0, "position": {...} }, ...],
"quality": {
"rmse_mm": 7.3,
"max_residual_mm": 23.1,
"converged": true,
"iterations": 42,
"warnings": [],
"outliers": [...],
"residuals": [...],
"anchor_biases_mm": { "0": 12.4, "1": -3.1 },
"fiducial_corrections_mm": { "0": 2.1, "3": 187.5 }
},
"applied": true
}

Le quality suit le même shape que celui du calibrateur laser — la même fonction helper _calibration_quality_payload() les sérialise tous deux (les deux derniers champs restent vides pour le laser).

  • anchor_biases_mm : biais de portée estimé par ancre. Une valeur qui s’écarte durablement de 0 sur plusieurs relevés = antenna delay à recalibrer sur ce module.
  • fiducial_corrections_mm : de combien le solveur a déplacé chaque fiduciaire par rapport à sa position déclarée. Une grosse valeur isolée (ex. 187 mm ci-dessus) = erreur de déclaration probable (hauteur de praticable oubliée, branche mal mesurée) — le solveur l’a compensée, mais vérifie ta saisie.
  • _survey_samples: dict[int, dict[int, list[float]]] — buffer nested {tag_id: {anchor_id: [distance_m, ...]}}. Jamais exposé tel quel ; les routes passent par les méthodes publiques.
  • _robust_median(samples, mad_k=3.0) — filtre MAD (1.4826σ, 3σ par défaut), puis médiane du sous-ensemble nettoyé. Indulgent sur variance nulle.
  • finalise_survey(min_samples_per_pair=10) — filtre les paires avec moins de 10 samples (tail du MAD peu fiable sous ce seuil).

Pipeline en trois temps :

  1. Seed per-anchor + pré-passe LOO — chaque ancre est d’abord résolue seule (trilatération 3D, borne de hauteur [z_min, z_max] contre la solution miroir sous plancher). Le leave-one-out combinatoire historique flague un outlier franc par ancre (re-solve en excluant chaque fiduciaire ; exclusion qui divise le RMS par ≥ 3× = outlier). Plus fiable qu’un seuil continu quand l’ancre n’a que 5 mesures.
  2. Ajustement MAP global — toutes les ancres, les positions de fiduciaires ET un biais de portée par ancre (b = b_global + dev_ancre, hiérarchique) sont résolus ensemble. Les fiduciaires sont tirés vers leurs positions déclarées par des priors gaussiens ; la perte robuste de Cauchy s’applique aussi aux priors, donc un fiduciaire mal déclaré peut « s’échapper » si les mesures l’exigent — c’est ce qui compense les hauteurs de praticables oubliées.
  3. Trim — les paires à résidu > 4σ sont exclues et on re-résout (2 passes max, en gardant ≥ min_fiducials mesures par ancre). Attrape les NLOS multiples que le LOO (limité à 1 par ancre) ne voit pas.

Priors adaptatifs (_SurveyPriors / _adaptive_priors) : la liberté donnée aux biais et aux fiduciaires dépend de la richesse du relevé — relevé plat en 1 pose → priors serrés, comportement ≈ historique (aucune régression) ; trépied présent (spread Y > 0,5 m) → biais global libéré ; ≥ 10 fiduciaires (multi-pose) → biais par ancre libéré. Tous les σ sont surchargeables par kwargs.

Choix Raison
Survey additif au tracking Pas de coupure des sorties OSC/PSN/ArtNet pendant la calibration.
Pas d’inter-anchor ranging Firmware Makerfabs STM32 fermé, role tag↔ancre figé. Le multi-pose compense en partie.
Solver MAP global (depuis 2026-07) Modéliser fiduciaires et biais comme inconnues est le seul moyen de compenser leurs erreurs.
Priors adaptatifs Un relevé pauvre ne peut pas contraindre biais + fiduciaires : on resserre pour ne pas régresser.
LOO conservé en pré-passe À n=5, le rejet combinatoire bat le downweighting continu (validé au prototypage).
Perte Cauchy f_scale 2,5σ + trim 4σ Downweighting redescendant des NLOS + rejet dur final ; gère plusieurs outliers simultanés.
Biais hiérarchique (global + dev) L’erreur d’antenna delay est largement commune aux modules identiques ; mieux identifiable.
min_samples_per_pair=10 Seuil au-dessus duquel la médiane MAD-filtrée converge. 300 ms à 30 Hz — trivialement atteint.
  1. Inter-anchor ranging manquant — améliorerait encore la précision ; piste documentée (bascule de rôle via AT+SETCFG à la volée) non encore testée sur hardware.
  2. Biais individuel par ancre observable seulement en multi-pose — en 1 pose, biais et recul radial sont indiscernables au niveau du bruit ; le solveur garde alors le biais quasi nul (prior serré). Faire 2-3 poses, ou calibrer l’antenna delay au banc.
  3. Le décalage global de la croix reste invisible — si TOUTE la croix est posée 10 cm trop à cour, tout le repère glisse de 10 cm (erreur de jauge : aucune mesure ne peut la détecter). Seule la pose du zéro croix doit donc être soignée ; les erreurs internes (branches, hauteurs) sont compensées.
  4. Pas de retour visuel pendant l’acquisition — le front ne peut appeler /status qu’en polling. Un push WebSocket serait plus élégant.
Fichier Couvre
tests/test_survey_calibration.py Contrats historiques (12 cas) : exact, bruit, outlier, z-bound
tests/test_survey_calibration_robust.py Erreurs systématiques (8 cas) : biais, fiduciaires faux, NLOS
tests/test_autocalibrate_api.py TrackerStore lifecycle + API (17 cas), multi-pose end-to-end

Exécuter : python -m pytest tests/test_survey_calibration.py tests/test_survey_calibration_robust.py tests/test_autocalibrate_api.py -v

[1] firmware/topos_anchor/topos_anchor.ino:79 — la commande AT+SETCFG=id,1,... configure le rôle ancre (1) ; le protocole Makerfabs ne permet pas à une ancre de lancer un TWR vers une autre ancre.

topos.red — Real-time · Environment · Distance[email protected] · Code MIT · Matériel CERN-OHL-P · Docs CC BY-SA