Anchor auto-calibration (UWB survey)
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.
Pourquoi
Section titled “Pourquoi”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.
Vue d’ensemble
Section titled “Vue d’ensemble”┌─────────────┐ ┌────────────────┐ ┌──────────────────┐ ┌─────────┐│ 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 :
start_survey(TrackerStore) — ouvre une fenêtre d’acquisition (append=Truepour accumuler une pose supplémentaire de la croix).finalise_survey(TrackerStore) — ferme la fenêtre, agrège par médiane robuste, fusionne les poses accumulées.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.
Motif de référence (cross)
Section titled “Motif de référence (cross)”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 mrecommandé 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.
⚠ Hauteur des fiduciaires — critique
Section titled “⚠ Hauteur des fiduciaires — critique”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.
Ancres basses (Y < 1 m)
Section titled “Ancres basses (Y < 1 m)”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.
Relevé multi-pose — le protocole de précision
Section titled “Relevé multi-pose — le protocole de précision”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) |
Payload start
Section titled “Payload start”{ "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.
Payload solve
Section titled “Payload solve”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 dansstate.store,state.config, puis persiste.
Réponse
Section titled “Réponse”{ "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.
Détails d’implémentation
Section titled “Détails d’implémentation”TrackerStore
Section titled “TrackerStore”_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).
Solveur (calibrate_anchors_from_survey)
Section titled “Solveur (calibrate_anchors_from_survey)”Pipeline en trois temps :
- 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. - 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. - Trim — les paires à résidu > 4σ sont exclues et on re-résout (2
passes max, en gardant ≥
min_fiducialsmesures 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 de design à connaître
Section titled “Choix de design à connaître”| 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. |
Limites connues
Section titled “Limites connues”- 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.
- 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.
- 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.
- Pas de retour visuel pendant l’acquisition — le front ne peut
appeler
/statusqu’en polling. Un push WebSocket serait plus élégant.
Tests pertinents
Section titled “Tests pertinents”| 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
Références
Section titled “Références”[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.