<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://hounfodji.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://hounfodji.github.io/" rel="alternate" type="text/html" /><updated>2026-08-05T01:16:08+01:00</updated><id>https://hounfodji.github.io/feed.xml</id><title type="html">Hospice</title><subtitle>Notes techniques depuis Cotonou : vision par ordinateur, modèles embarqués, et l&apos;IA pensée pour le contexte africain.</subtitle><author><name>Hospice</name></author><entry><title type="html">Comment fait-on entrer du son dans un modèle de langage</title><link href="https://hounfodji.github.io/blog/son-dans-un-modele-de-langage/" rel="alternate" type="text/html" title="Comment fait-on entrer du son dans un modèle de langage" /><published>2026-08-02T09:00:00+01:00</published><updated>2026-08-02T09:00:00+01:00</updated><id>https://hounfodji.github.io/blog/son-dans-un-modele-de-langage</id><content type="html" xml:base="https://hounfodji.github.io/blog/son-dans-un-modele-de-langage/"><![CDATA[<p>Ceci est une note de lecture. Le raisonnement, les exemples et la plupart des
chiffres viennent de <a href="https://kyutai.org/codec-explainer">l’article de Václav Volhejn publié par Kyutai</a>,
qui explique comment on transforme du son en jetons pour un modèle de langage. Je
l’ai lu, puis j’ai réimplémenté la mécanique pour vérifier que je l’avais comprise.
Ce que j’ajoute est là : les ablations à la fin de chaque section, où je casse un
mécanisme pour voir ce qu’il tenait. 
<!-- [Le code est ici](https://github.com/hounfodji/codec-audio-lab). --></p>

<!--
L'article suivant, [Quatre bogues soupçonnés, un seul coupable](/blog/quatre-bogues-un-coupable/),
racontera ce qui s'est passé quand j'ai construit le codec pour de bon.
-->

<h2 id="le-son-résiste">Le son résiste</h2>

<p>En 2025, la plupart des assistants vocaux ne comprennent pas vraiment la voix. Ils
transcrivent, réfléchissent en texte, puis lisent leur réponse à voix haute. C’est
efficace, mais c’est un emballage autour d’un modèle de texte, pas une écoute.</p>

<p>Volhejn propose un test cruel : parlez à un modèle nativement audio avec une voix
très aiguë, et demandez-lui si votre voix est grave ou aiguë. Aucun ne sait
répondre. L’information était pourtant dans le signal.</p>

<p>Du côté du texte, tout est réglé depuis longtemps. En 2015 déjà, Karpathy
entraînait un réseau récurrent de trois couches sur un seul GPU et obtenait du
code et du LaTeX plausibles, caractère par caractère.</p>

<p>Le son ne se laisse pas faire, et la raison tient en un nombre. Dix secondes
d’audio, c’est 160 000 échantillons. WaveNet les prédit un par un : le résultat
sonne comme une vraie voix humaine, avec le bon grain et la bonne respiration,
mais ne produit presque jamais un mot correct.</p>

<p>Volhejn mène l’expérience naïve jusqu’au bout, en donnant du son brut à nanoGPT.
Deux chiffres suffisent à expliquer l’échec : la fenêtre de contexte de 2 048
jetons représente <strong>128 millisecondes</strong> — le modèle ne voit même pas un mot entier
— et générer dix secondes demande <strong>trente minutes sur une H100</strong>.</p>

<blockquote>
  <p>Le texte, c’est une boîte de LEGO : les briques existent déjà, en nombre limité,
on assemble. Le son, c’est du sable. Chaque grain ne veut rien dire tout seul, et
pour écrire le mot <em>bonjour</em> il faut en placer seize mille par seconde.</p>
</blockquote>

<p>La conclusion est immédiate : il faut réduire le nombre de décisions. Il faut
compresser.</p>

<h2 id="comprimer-avant-de-modéliser">Comprimer avant de modéliser</h2>

<figure>
  <img src="/assets/images/son-dans-un-modele-de-langage/sandwich.png" alt="Schéma : audio entrant, encodeur + RVQ, jetons entiers, modèle de langage Transformer, jetons prédits, décodeur, audio sortant." />
  <figcaption>Le modèle de langage en sandwich. Pour lui, rien ne change : ce sont des nombres qui entrent et des nombres qui sortent.</figcaption>
</figure>

<p>L’outil qui comprime et décomprime s’appelle un <strong>codec audio neuronal</strong>. Le mot
est emprunté au MP3, mais ici il désigne simplement un découpeur de son en jetons.</p>

<h3 id="lautoencodeur">L’autoencodeur</h3>

<figure>
  <img src="/assets/images/son-dans-un-modele-de-langage/autoencodeur.png" alt="Schéma d'un autoencodeur : une entrée est comprimée par l'encodeur dans un espace latent, puis reconstruite par le décodeur." />
  <figcaption>Comprimer, puis reconstruire, et se juger sur la ressemblance.</figcaption>
</figure>

<p>Personne ne dit au modèle ce qu’il doit mettre dans son résumé. Il doit le
découvrir seul, sous la contrainte que le résumé est trop petit pour tout contenir.</p>

<blockquote>
  <p>On vous demande de résumer un roman de trois cents pages sur une fiche bristol.
Puis on donne votre fiche à quelqu’un qui doit réécrire le roman entier. Si le
résultat ressemble à l’original, c’est que votre fiche contenait ce qui compte.</p>
</blockquote>

<h3 id="pourquoi-il-faut-quantifier">Pourquoi il faut quantifier</h3>

<p>Un autoencodeur ordinaire produit des nombres à virgule, en quantité infinie. Un
modèle de langage ne sait pas faire ça : il sait choisir un élément dans une liste
finie. Il lui faut un vocabulaire.</p>

<figure>
  <img src="/assets/images/son-dans-un-modele-de-langage/quantifier.png" alt="Schéma de la quantification : des points continus sont remplacés par le numéro du centre le plus proche." />
  <figcaption>Remplacer une infinité de positions possibles par le numéro du centre le plus proche.</figcaption>
</figure>

<blockquote>
  <p>On ne vous donne pas de la peinture liquide, mais une boîte de 1 024 crayons
numérotés. Vous perdez un peu de fidélité, mais vous pouvez désormais décrire
votre dessin entier au téléphone, en récitant une suite de numéros.</p>
</blockquote>

<p>Les centres ne sont pas décidés à l’avance : ils sont appris, par une procédure
proche des k-moyennes. Et il y a un détail malin — si un centre n’est utilisé par
personne pendant un moment, on le <strong>téléporte</strong> à la position d’un vecteur pris au
hasard. Sans cela, un centre mal placé au départ resterait mort à jamais.</p>

<h2 id="lestimateur-direct-ou-le-mensonge-assumé">L’estimateur direct, ou le mensonge assumé</h2>

<p>Voici le vrai obstacle. On veut quantifier <em>pendant</em> l’entraînement, pour que
l’encodeur apprenne à produire des vecteurs faciles à arrondir :</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">z</span> <span class="o">=</span> <span class="n">encoder</span><span class="p">(</span><span class="n">x</span><span class="p">)</span>
<span class="n">z_quantized</span> <span class="o">=</span> <span class="n">to_nearest_cluster</span><span class="p">(</span><span class="n">z</span><span class="p">)</span>          <span class="c1"># on arrondit
</span><span class="n">loss</span> <span class="o">=</span> <span class="n">reconstruction_loss</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">decoder</span><span class="p">(</span><span class="n">z_quantized</span><span class="p">))</span>
</code></pre></div></div>

<p>Mais prendre le centre le plus proche n’est pas dérivable. Aucun gradient ne
remonte jusqu’à l’encodeur. La solution est d’une honnêteté rafraîchissante : on
règle le problème en faisant comme s’il n’existait pas.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">residual</span> <span class="o">=</span> <span class="n">z</span> <span class="o">-</span> <span class="n">to_nearest_cluster</span><span class="p">(</span><span class="n">z</span><span class="p">)</span>
<span class="n">z_quantized</span> <span class="o">=</span> <span class="n">z</span> <span class="o">-</span> <span class="n">residual</span><span class="p">.</span><span class="n">detach</span><span class="p">()</span>          <span class="c1"># .detach() : oublie le gradient
</span></code></pre></div></div>

<p>À l’aller, la valeur transmise au décodeur est exactement la même. Au retour, le
gradient traverse l’arrondi comme s’il n’était pas là. C’est l’<strong>estimateur
direct</strong>.</p>

<p>Ce raisonnement ne tient que si les vecteurs restent proches de leur centre. On
ajoute donc une pénalité proportionnelle à cette distance : la <strong>perte
d’engagement</strong>. L’ensemble s’appelle un VQ-VAE — dont le <em>variationnel</em> est un
vestige historique qui ne signifie plus rien.</p>

<h3 id="ce-qui-casse-quand-on-retire-chaque-pièce">Ce qui casse quand on retire chaque pièce</h3>

<p>Voilà ma part du travail. J’ai entraîné un VQ-VAE en dimension latente 2 sur trois
classes de Fashion MNIST, puis j’ai désactivé chaque mécanisme, un par un.</p>

<table>
  <thead>
    <tr>
      <th>Configuration</th>
      <th>MSE test</th>
      <th>Centres actifs</th>
      <th>Perplexité</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>référence</td>
      <td><strong>0,02707</strong></td>
      <td>32/32</td>
      <td>25,2</td>
    </tr>
    <tr>
      <td>sans estimateur direct</td>
      <td>0,06844</td>
      <td>32/32</td>
      <td>21,7</td>
    </tr>
    <tr>
      <td>sans perte d’engagement</td>
      <td>0,03310</td>
      <td>32/32</td>
      <td>16,1</td>
    </tr>
    <tr>
      <td>sans téléportation</td>
      <td>0,02866</td>
      <td><strong>24/32</strong></td>
      <td>19,2</td>
    </tr>
    <tr>
      <td>codebook de 4 centres</td>
      <td>0,04227</td>
      <td>4/4</td>
      <td>3,9</td>
    </tr>
  </tbody>
</table>

<p>Trois lectures.</p>

<p><strong>L’estimateur direct est le mécanisme indispensable.</strong> Sans lui, l’erreur est
multipliée par 2,5. C’est cohérent : l’encodeur n’apprend plus que par la perte
d’engagement, ce qui est un signal bien plus pauvre.</p>

<p><strong>La téléportation ne change presque rien à l’erreur, mais tue un quart du
codebook.</strong> Vingt-quatre centres sur trente-deux. C’est exactement le symptôme
annoncé : un centre jamais choisi ne reçoit aucune mise à jour, donc ne bouge pas,
donc ne sera jamais choisi. Le coût est invisible dans la MSE et bien réel dans le
débit — on paie huit centres pour rien.</p>

<p><strong>La perte d’engagement se voit surtout dans la perplexité</strong>, qui tombe de 25 à 16.
L’erreur bouge peu, mais le codebook est nettement moins bien réparti.</p>

<figure>
  <img src="/assets/images/son-dans-un-modele-de-langage/run-ablation-ref.png" alt="Trois panneaux : l'espace latent en dimension 2 avec les centres du codebook, l'histogramme d'utilisation des centres, et les courbes d'entraînement." />
  <figcaption>La configuration de référence. En dimension 2, on voit littéralement où sont les centres.</figcaption>
</figure>

<figure>
  <img src="/assets/images/son-dans-un-modele-de-langage/run-ablation-sans-teleportation.png" alt="Mêmes trois panneaux sans téléportation : l'histogramme montre plusieurs centres à zéro." />
  <figcaption>Sans téléportation. L'histogramme du milieu raconte tout : huit barres à zéro, huit centres morts.</figcaption>
</figure>

<h2 id="la-quantification-résiduelle">La quantification résiduelle</h2>

<p>Pour gagner en fidélité, on pourrait augmenter le nombre de centres. Mais il faut
comparer chaque vecteur à chacun d’eux, et ça devient vite prohibitif.</p>

<p>L’astuce est combinatoire. Plutôt qu’un million de centres, on en crée <strong>deux
ensembles de 1 024</strong>. Chaque vecteur est décrit par un couple d’entiers, ce qui
donne plus d’un million de combinaisons pour seulement 2 048 centres stockés.</p>

<blockquote>
  <p>Pour identifier tous les élèves d’une école, on n’invente pas un identifiant par
élève : on combine un prénom et un nom.</p>
</blockquote>

<p>Reste à savoir comment répartir l’information. La réponse est déjà dans le code
plus haut, sous le nom de <strong>résidu</strong> : l’écart entre le vecteur exact et sa version
arrondie, c’est-à-dire ce que le premier arrondi n’a pas su capturer. On le
quantifie à son tour, puis on quantifie le résidu du résidu.</p>

<figure>
  <img src="/assets/images/son-dans-un-modele-de-langage/rvq.png" alt="Schéma : un premier vecteur approche la cible grossièrement, un second corrige l'erreur restante." />
  <figcaption>Chaque niveau corrige uniquement l'erreur laissée par le précédent.</figcaption>
</figure>

<blockquote>
  <p>Une balance à plateaux. Pour peser 780 g, vous posez le poids de 500 g, il manque
280 g ; celui de 200 g, il manque 80 g ; puis 50, puis 20, puis 10. Chaque poids
ne corrige pas le total, il corrige l’erreur des précédents. Et vous vous arrêtez
quand la précision vous suffit.</p>
</blockquote>

<p>Sur mes images, quatre niveaux de 64 centres donnent ceci :</p>

<table>
  <thead>
    <tr>
      <th>Niveaux</th>
      <th>MSE</th>
      <th>Norme du résidu restant</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>0,02280</td>
      <td>2,31 → 0,094</td>
    </tr>
    <tr>
      <td>2</td>
      <td>0,01807</td>
      <td>→ 0,024</td>
    </tr>
    <tr>
      <td>3</td>
      <td>0,01712</td>
      <td>→ 0,0076</td>
    </tr>
    <tr>
      <td>4</td>
      <td>0,01688</td>
      <td>→ 0,0033</td>
    </tr>
  </tbody>
</table>

<p>Le résidu est divisé par 700 entre le début et la fin, et chaque niveau rapporte
environ la moitié du gain précédent. C’est la signature d’une RVQ en bonne santé —
je vais y revenir longuement dans l’article suivant, parce que c’est précisément
cette signature qui m’a manqué pendant des heures.</p>

<figure>
  <img src="/assets/images/son-dans-un-modele-de-langage/run-progression-niveaux.png" alt="Grille d'images : la première ligne montre les originaux, chaque ligne suivante ajoute un niveau de quantification." />
  <figcaption>La balance à plateaux, en images. Chaque ligne ajoute un niveau de correction.</figcaption>
</figure>

<p>Une limite à garder en tête : <strong>la qualité maximale reste celle de la
reconstruction sans quantification du tout</strong>. Si l’autoencodeur est mauvais, aucune
quantification ne le sauvera. On améliore d’abord l’autoencodeur.</p>

<p>L’idée n’est pas neuve — SoundStream l’a appliquée aux codecs audio en 2021, et
elle circule dans le traitement du signal depuis les années quatre-vingt.</p>

<h2 id="le-multi-niveaux-na-pas-de-réponse-canonique">Le multi-niveaux n’a pas de réponse canonique</h2>

<p>Avec un codec à huit niveaux, chaque extrait devient un tableau à deux dimensions :
le temps d’un côté, les niveaux de l’autre. Or un modèle de langage attend une
seule file. Comment gérer huit jetons par instant ?</p>

<div class="table-scroll">

  <table>
    <thead>
      <tr>
        <th>Stratégie</th>
        <th>Principe</th>
        <th>Avantage</th>
        <th>Inconvénient</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>Aplatissement</td>
        <td>un jeton par niveau et par instant, à la queue leu leu</td>
        <td>très simple, le modèle reste un modèle de texte ordinaire</td>
        <td>la séquence est multipliée par le nombre de niveaux</td>
      </tr>
      <tr>
        <td>Motif parallèle</td>
        <td>tous les niveaux d’un instant prédits en même temps</td>
        <td>aucune perte de compression temporelle</td>
        <td>le modèle doit tout décider d’un coup, sans hiérarchie</td>
      </tr>
      <tr>
        <td>Motifs décalés</td>
        <td>les niveaux sont retardés les uns par rapport aux autres</td>
        <td>compromis, testé dans MusicGen</td>
        <td>réglage délicat</td>
      </tr>
      <tr>
        <td>Motifs hiérarchiques</td>
        <td>un modèle par échelle, ou entrelacement complexe</td>
        <td>meilleurs résultats rapportés en 2025</td>
        <td>complexité importante</td>
      </tr>
    </tbody>
  </table>

</div>

<p>En 2025, aucune ne s’est imposée. Pour qui débute, c’est une bonne nouvelle : il
n’y a pas de solution canonique qu’on serait censé connaître, il y a un espace de
conception encore ouvert.</p>

<h2 id="mimi-et-le-jeton-qui-porte-le-sens">Mimi, et le jeton qui porte le sens</h2>

<p>Quatre années de recherche ont produit bien mieux que le codec artisanal. Mimi,
développé chez Kyutai pour Moshi, apporte trois choses.</p>

<p>Une <strong>perte adverse</strong> plutôt que spectrale : un discriminateur apprend à repérer
les reconstructions, le codec apprend à le tromper. Comme le juge s’améliore en
même temps que l’élève, il n’y a pas de recette figée à exploiter.</p>

<p>Un <strong>abandon aléatoire de niveaux</strong> pendant l’entraînement — le RVQ dropout. Le
codec apprend à ne pas dépendre de tous ses niveaux, et l’on peut donc tronquer
librement à l’usage. J’ai mesuré ce que ça coûte : avec dropout, 0,01688 de MSE à
quatre niveaux ; sans, 0,01560. On paie environ 8 % de qualité pour la liberté de
tronquer. Sans lui, il faudrait réentraîner un modèle par nombre de niveaux.</p>

<p>Et surtout <strong>12,5 vecteurs par seconde au lieu de 125</strong>, soit dix fois moins. Le
corpus de 10 000 heures tombe de 134 Go à 54 Go. À fenêtre de contexte égale, le
modèle voit dix fois plus loin : là où il apercevait une fraction de mot, il voit
une phrase.</p>

<h3 id="le-fond-et-la-forme">Le fond et la forme</h3>

<p>Le tout premier niveau de Mimi n’est pas de même nature que les autres. C’est le
<strong>jeton sémantique</strong>, distillé depuis WavLM, et son rôle est de porter le sens sans
nécessairement contribuer à la qualité de reconstruction.</p>

<p>L’expérience qui le démontre est élégante. On entraîne un modèle sur les 32
niveaux, puis à la génération on impose de force les jetons sémantiques de l’audio
d’origine, en laissant le modèle inventer tout le reste. Le résultat : <strong>la voix
obtenue est complètement différente, mais elle dit exactement la même chose</strong>.</p>

<figure>
  <img src="/assets/images/son-dans-un-modele-de-langage/jeton-semantique.png" alt="Schéma : le premier niveau porte le contenu de la parole, les niveaux suivants portent la voix et le timbre." />
  <figcaption>Le premier niveau porte ce qui est dit, les autres portent la manière dont c'est dit.</figcaption>
</figure>

<blockquote>
  <p>Le jeton sémantique est le scénario du film. Les jetons acoustiques sont le
casting, les décors et la lumière. On peut refaire le film avec d’autres acteurs
sans changer une ligne du scénario.</p>
</blockquote>

<h2 id="le-fossé-entre-modalités">Le fossé entre modalités</h2>

<p>Le bilan de Volhejn est mesuré. Avec un codec, un modèle de langage tout à fait
ordinaire produit de la parole partiellement cohérente ; sans codec, le même modèle
exactement produit du grésillement. La démonstration est faite. Mais on reste très
loin des modèles de texte.</p>

<p>Moshi modélise trois flux en parallèle : l’audio de l’utilisateur, son propre
audio, et un flux de texte qui joue le rôle de monologue intérieur. Les ablations
montrent que ce flux textuel aide massivement — et c’est là que l’article devient
un peu mélancolique. L’essentiel du raisonnement semble délégué au texte, tandis
que les flux audio finissent par ne servir que de reconnaissance et de synthèse
vocales intégrées.</p>

<p>Plus d’un an après Moshi, personne n’explique vraiment pourquoi les modèles audio
restent en retard. Volhejn appelle ça le <strong>fossé entre modalités</strong>, et conclut que
c’est ce mystère non résolu qui rend le domaine passionnant.</p>

<p>Une piste à surveiller : certains travaux récents se passent entièrement de jetons
discrets et travaillent dans un espace latent continu. Si cette approche mûrit,
toute la machinerie décrite ici deviendrait un détour historique.</p>

<hr />

<p>Voilà pour la mécanique telle qu’elle est censée fonctionner.
 <!-- Dans
[l'article suivant](/blog/quatre-bogues-un-coupable/), je la
construis pour de bon, elle échoue, et je découvre en cherchant les coupables que
j'en avais accusé trois de trop. --></p>]]></content><author><name>Hospice</name></author><summary type="html"><![CDATA[Un modèle de langage attend une file de nombres entiers. Le son en fournit seize mille par seconde. Voici la machinerie qui réconcilie les deux.]]></summary></entry><entry><title type="html">Pourquoi ce carnet</title><link href="https://hounfodji.github.io/blog/pourquoi-ce-carnet/" rel="alternate" type="text/html" title="Pourquoi ce carnet" /><published>2026-06-02T00:00:00+01:00</published><updated>2026-06-02T00:00:00+01:00</updated><id>https://hounfodji.github.io/blog/pourquoi-ce-carnet</id><content type="html" xml:base="https://hounfodji.github.io/blog/pourquoi-ce-carnet/"><![CDATA[<p>J’écris du code pour gagner ma vie, mais je n’écris presque jamais <em>sur</em> ce code. Or c’est en écrivant qu’on découvre ce qu’on a réellement compris — le reste n’est qu’une intuition qui n’a pas encore été mise à l’épreuve d’une phrase claire.</p>

<p>Ce carnet est donc un atelier autant qu’une vitrine. J’y noterai les décisions techniques que je prends au quotidien : pourquoi tel modèle plutôt qu’un autre, pourquoi telle contrainte matérielle change tout, pourquoi une solution élégante sur le papier s’effondre dès qu’on la branche à une caméra réelle.</p>

<h2 id="ce-que-vous-y-trouverez">Ce que vous y trouverez</h2>

<ul>
  <li><strong>Des notes de terrain.</strong> Vision par ordinateur, LLM embarqués, fusion de capteurs. Du concret, avec les pièges.</li>
  <li><strong>Des post-mortem honnêtes.</strong> Ce qui a cassé, et ce que j’aurais dû voir venir.</li>
  <li><strong>Le contexte africain comme contrainte première.</strong> Connexion intermittente, matériel modeste, déploiement hors-ligne.</li>
</ul>

<h2 id="ce-que-vous-ny-trouverez-pas">Ce que vous n’y trouverez pas</h2>

<p>Pas de tutoriels recopiés, pas de hype. Si je n’ai pas mis les mains dedans, je n’en parle pas.</p>

<blockquote>
  <p>Écrire, c’est penser deux fois.</p>
</blockquote>

<p>À bientôt dans le prochain article.</p>]]></content><author><name>Hospice</name></author><summary type="html"><![CDATA[Un endroit à moi pour écrire l'IA telle que je la pratique.]]></summary></entry><entry><title type="html">Calibrer une caméra avec des marqueurs ArUco</title><link href="https://hounfodji.github.io/blog/calibration-camera-aruco/" rel="alternate" type="text/html" title="Calibrer une caméra avec des marqueurs ArUco" /><published>2026-05-20T00:00:00+01:00</published><updated>2026-05-20T00:00:00+01:00</updated><id>https://hounfodji.github.io/blog/calibration-camera-aruco</id><content type="html" xml:base="https://hounfodji.github.io/blog/calibration-camera-aruco/"><![CDATA[<p>Sur un système de contrôle d’accès, la question n’est pas seulement <em>« est-ce qu’il y a une personne ? »</em> mais <em>« où est-elle, en mètres, dans la pièce ? »</em>. Une détection YOLO me donne une boîte en pixels ; il me faut une transformation vers le plan réel. Les marqueurs ArUco rendent ça presque trivial.</p>

<h2 id="lidée">L’idée</h2>

<p>Un marqueur ArUco est un motif noir et blanc dont OpenCV connaît la taille physique. En plaçant quelques marqueurs à des positions connues du sol, j’obtiens une correspondance entre pixels et coordonnées réelles. C’est une homographie — une matrice 3×3 qui projette un plan sur un autre.</p>

<p>Concrètement, j’imprime une planche de quatre marqueurs du dictionnaire <code class="language-plaintext highlighter-rouge">DICT_4X4_50</code>, que je découpe et pose au sol en rectangle :</p>

<figure>
  <img src="/assets/images/calibration-aruco/marqueurs_1.png" alt="Planche de calibration ArUco 4×4 : marqueur ID=0 à l'origine (0, 0) et marqueur ID=1 à (50, 0) cm." />
  <figcaption>Les marqueurs 0 et 1 : origine du repère et axe X.</figcaption>
</figure>

<figure>
  <img src="/assets/images/calibration-aruco/marqueurs_2.png" alt="Marqueurs ArUco ID=2 à (0, 50) et ID=3 à (50, 50) cm, avec le schéma de placement vu du dessus." />
  <figcaption>Les marqueurs 2 et 3, et le schéma de placement vu du dessus.</figcaption>
</figure>

<h2 id="le-code">Le code</h2>

<p>D’abord, détecter les marqueurs dans l’image :</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">cv2</span>
<span class="kn">import</span> <span class="nn">numpy</span> <span class="k">as</span> <span class="n">np</span>

<span class="n">aruco_dict</span> <span class="o">=</span> <span class="n">cv2</span><span class="p">.</span><span class="n">aruco</span><span class="p">.</span><span class="n">getPredefinedDictionary</span><span class="p">(</span><span class="n">cv2</span><span class="p">.</span><span class="n">aruco</span><span class="p">.</span><span class="n">DICT_4X4_50</span><span class="p">)</span>
<span class="n">params</span> <span class="o">=</span> <span class="n">cv2</span><span class="p">.</span><span class="n">aruco</span><span class="p">.</span><span class="n">DetectorParameters</span><span class="p">()</span>
<span class="n">detector</span> <span class="o">=</span> <span class="n">cv2</span><span class="p">.</span><span class="n">aruco</span><span class="p">.</span><span class="n">ArucoDetector</span><span class="p">(</span><span class="n">aruco_dict</span><span class="p">,</span> <span class="n">params</span><span class="p">)</span>

<span class="n">frame</span> <span class="o">=</span> <span class="n">cv2</span><span class="p">.</span><span class="n">imread</span><span class="p">(</span><span class="s">"scene.jpg"</span><span class="p">)</span>
<span class="n">corners</span><span class="p">,</span> <span class="n">ids</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">detector</span><span class="p">.</span><span class="n">detectMarkers</span><span class="p">(</span><span class="n">frame</span><span class="p">)</span>
</code></pre></div></div>

<p>Ensuite, on associe chaque marqueur détecté (en pixels) à sa position réelle connue (en mètres), puis on calcule l’homographie :</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Coins image (pixels) -&gt; coins monde (mètres)
</span><span class="n">image_pts</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">array</span><span class="p">([</span><span class="n">c</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="n">mean</span><span class="p">(</span><span class="n">axis</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span> <span class="k">for</span> <span class="n">c</span> <span class="ow">in</span> <span class="n">corners</span><span class="p">],</span> <span class="n">dtype</span><span class="o">=</span><span class="n">np</span><span class="p">.</span><span class="n">float32</span><span class="p">)</span>
<span class="n">world_pts</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">array</span><span class="p">([</span>
    <span class="p">[</span><span class="mf">0.0</span><span class="p">,</span> <span class="mf">0.0</span><span class="p">],</span>   <span class="c1"># marqueur id 0 au coin de la pièce
</span>    <span class="p">[</span><span class="mf">3.0</span><span class="p">,</span> <span class="mf">0.0</span><span class="p">],</span>   <span class="c1"># 3 m sur l'axe X
</span>    <span class="p">[</span><span class="mf">3.0</span><span class="p">,</span> <span class="mf">4.0</span><span class="p">],</span>
    <span class="p">[</span><span class="mf">0.0</span><span class="p">,</span> <span class="mf">4.0</span><span class="p">],</span>
<span class="p">],</span> <span class="n">dtype</span><span class="o">=</span><span class="n">np</span><span class="p">.</span><span class="n">float32</span><span class="p">)</span>

<span class="n">H</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">cv2</span><span class="p">.</span><span class="n">findHomography</span><span class="p">(</span><span class="n">image_pts</span><span class="p">,</span> <span class="n">world_pts</span><span class="p">)</span>
</code></pre></div></div>

<p>Enfin, projeter n’importe quel point de l’image vers le monde réel :</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">pixel_to_world</span><span class="p">(</span><span class="n">px</span><span class="p">,</span> <span class="n">py</span><span class="p">,</span> <span class="n">H</span><span class="p">):</span>
    <span class="n">pt</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">array</span><span class="p">([</span><span class="n">px</span><span class="p">,</span> <span class="n">py</span><span class="p">,</span> <span class="mf">1.0</span><span class="p">])</span>
    <span class="n">world</span> <span class="o">=</span> <span class="n">H</span> <span class="o">@</span> <span class="n">pt</span>
    <span class="n">world</span> <span class="o">/=</span> <span class="n">world</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span>          <span class="c1"># normalisation homogène
</span>    <span class="k">return</span> <span class="n">world</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">world</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span>  <span class="c1"># x, y en mètres
</span></code></pre></div></div>

<h2 id="précision-en-pratique">Précision en pratique</h2>

<p>Sur mon installation, voici l’erreur médiane mesurée selon la distance à la caméra :</p>

<table>
  <thead>
    <tr>
      <th>Distance</th>
      <th>Erreur médiane</th>
      <th>Note</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>&lt; 2 m</td>
      <td>~4 cm</td>
      <td>excellent</td>
    </tr>
    <tr>
      <td>2–4 m</td>
      <td>~9 cm</td>
      <td>acceptable</td>
    </tr>
    <tr>
      <td>&gt; 4 m</td>
      <td>~22 cm</td>
      <td>à recouper avec le RFID</td>
    </tr>
  </tbody>
</table>

<p>La leçon : <strong>un seul capteur ne suffit jamais</strong>. Au-delà de quatre mètres, je fusionne la position visuelle avec celle des balises RFID. Mais ça, ce sera le sujet d’un prochain article.</p>

<h2 id="à-retenir">À retenir</h2>

<ul>
  <li>Imprime tes marqueurs en grand : un ArUco minuscule à 4 m, c’est du bruit.</li>
  <li>Mesure les positions réelles au cordeau, pas à l’œil.</li>
  <li>Garde toujours un capteur de secours pour les zones lointaines.</li>
</ul>]]></content><author><name>Hospice</name></author><summary type="html"><![CDATA[Passer des pixels aux coordonnées du monde réel, sans matériel de calibration coûteux — juste du carton imprimé et un peu de géométrie.]]></summary></entry></feed>