Skip to content

gestes_main

gestes_main

Le moteur de gestes : des points articulaires à une intention.

Spatial Mesh, gestes — 25 août 2026. Ce module ne voit pas, ne capture rien et ne parle à personne : il reçoit vingt-et-un points par main et rend un état. C'est délibéré — la fiabilité d'un geste se joue ici, pas dans la caméra, et elle doit se tester sans matériel.

Le §11 du cahier des charges est la règle qui structure tout : ne jamais déclencher une action depuis une seule image. Une main qui passe devant l'objectif produit, pendant deux ou trois images, quelque chose qui ressemble à un poing. C'est ainsi qu'un document part tout seul.

D'où la chaîne : points bruts → normalisation → lissage → mesures → pose → état temporel. Et deux protections que l'expérience impose :

L'hystérésis. Le seuil pour ENTRER dans un état est plus exigeant que celui pour en SORTIR. Sans cela, une main qui hésite à la frontière fait osciller l'état dix fois par seconde.

Le temps de repos. Après un geste reconnu, un délai pendant lequel rien n'est reconnu. Sans lui, ouvrir la main après un « attraper » déclenche immédiatement un « relâcher », puis l'inverse.

Les seuils ne sont pas dispersés dans le code : ils vivent dans un objet, et ils se règlent (§13).

Classes

Pose

Bases: str, Enum

Ce qu'une main FAIT sur une image donnée — sans mémoire.

Etat

Bases: str, Enum

Ce que l'utilisateur est en train de faire — avec mémoire (§12).

Seuils dataclass

Seuils(
    confiance_minimale: float = 0.6,
    fermeture_entree: float = 1.14,
    fermeture_sortie: float = 1.44,
    ouverture_entree: float = 1.52,
    ouverture_sortie: float = 1.38,
    images_stables: int = 4,
    trou_de_suivi_ms: int = 400,
    repos_ms: int = 800,
    lissage: float = 0.4,
)

Tous les nombres qui décident, en un seul endroit (§13).

Aucun de ces nombres n'est magique : ce sont des points de départ raisonnables, à régler sur des vraies mains — et le §141 exige que les faux positifs soient MESURÉS avant de dire que les gestes sont finis.

Mesures dataclass

Mesures(repliement: float, confiance: float)

Ce qu'on lit d'une main, indépendamment de sa taille et sa distance.

MoteurDeGestes

MoteurDeGestes(seuils: Optional[Seuils] = None)

La machine à états (§12), avec hystérésis et temps de repos.

Une instance suit UNE main. Elle ne connaît ni caméra, ni fichier, ni appareil : elle rend un état, et c'est à l'appelant d'en faire quelque chose — ou rien, ce qui est le cas le plus fréquent et le plus sain.

Source code in src/diapason/desktop/gestes_main.py
def __init__(self, seuils: Optional[Seuils] = None) -> None:
    self.seuils = seuils or Seuils()
    self.etat = Etat.REPOS
    self._pose_stable: Pose = Pose.INCONNUE
    self._pose_candidate: Pose = Pose.INCONNUE
    self._images_candidates = 0
    self._lisse: Optional[Mesures] = None
    self._vue_a: float = 0.0
    self._repos_jusqua: float = 0.0
    self._ferme = False  # l'état d'hystérésis, pas la pose brute
Methods:
observer
observer(
    points: Optional[Sequence[Point]],
    *,
    maintenant: Optional[float] = None,
) -> Etat

Une image de plus. Rend l'état APRÈS cette image.

points vaut None quand aucune main n'est vue — et c'est une information, pas une absence d'information : une main qui disparaît au milieu d'un geste doit l'annuler, jamais le figer.

Source code in src/diapason/desktop/gestes_main.py
def observer(
    self, points: Optional[Sequence[Point]], *, maintenant: Optional[float] = None
) -> Etat:
    """Une image de plus. Rend l'état APRÈS cette image.

    ``points`` vaut None quand aucune main n'est vue — et c'est une
    information, pas une absence d'information : une main qui disparaît
    au milieu d'un geste doit l'annuler, jamais le figer.
    """
    maintenant = time.monotonic() if maintenant is None else maintenant

    if points is None:
        return self._sans_main(maintenant)

    mesures = mesurer(points)
    if mesures is None:
        return self._sans_main(maintenant)

    self._vue_a = maintenant
    lisse = self._lisser(mesures)
    pose = self._pose(lisse)

    # §11 : une pose n'est crue qu'après N images qui la confirment.
    if pose is self._pose_candidate:
        self._images_candidates += 1
    else:
        self._pose_candidate = pose
        self._images_candidates = 1
    if self._images_candidates >= self.seuils.images_stables:
        self._pose_stable = pose

    if maintenant < self._repos_jusqua:
        # Temps de repos : on suit la main, on ne conclut rien.
        return self.etat
    return self._avancer(self._pose_stable, maintenant)

Functions:

mesurer

mesurer(points: Sequence[Point]) -> Optional[Mesures]

Les mesures d'une main, ou None si elle est trop incomplète.

Tout est rapporté à la largeur de la paume : une main près de l'objectif et une main au fond de la pièce donnent les mêmes nombres. C'est ce qui rend les seuils tenables sans calibration par utilisateur.

Source code in src/diapason/desktop/gestes_main.py
def mesurer(points: Sequence[Point]) -> Optional[Mesures]:
    """Les mesures d'une main, ou None si elle est trop incomplète.

    Tout est rapporté à la largeur de la paume : une main près de
    l'objectif et une main au fond de la pièce donnent les mêmes nombres.
    C'est ce qui rend les seuils tenables sans calibration par utilisateur.
    """
    par_nom = {p.nom: p for p in points}
    poignet = _point(par_nom, POIGNET)
    if poignet is None:
        return None

    # La largeur de la paume : de la base de l'index à celle de l'auriculaire.
    base_index = _point(par_nom, "indexMCP")
    base_auriculaire = _point(par_nom, "littleMCP")
    if base_index is None or base_auriculaire is None:
        return None
    paume = _distance(base_index, base_auriculaire)
    if paume <= 1e-6:
        return None

    replis: list[float] = []
    # La confiance retenue est le MINIMUM, pas la moyenne. Constaté le
    # 25 août 2026 : Vision rend des « mains » dans du bruit — un visage,
    # une ombre, un objet — avec quelques points sûrs et beaucoup de points
    # douteux. Une moyenne tirée vers le haut par le poignet laissait
    # passer ces mains fantômes, qui déposaient des choses toutes seules.
    # Un seul point douteux rend la décision douteuse.
    confiances: list[float] = [poignet.confiance]
    for doigt in _DOIGTS:
        base_nom, _pip, _dip, bout_nom = _ARTICULATIONS[doigt]
        base = _point(par_nom, base_nom)
        bout = _point(par_nom, bout_nom)
        if base is None or bout is None:
            continue
        confiances.extend([base.confiance, bout.confiance])
        # La distance du bout à SA PROPRE BASE, et non au poignet. Le
        # poignet est loin, donc sa mesure change avec l'inclinaison de la
        # main : la même main fermée donnait des nombres différents selon
        # qu'elle était droite ou penchée — « tantôt c'était mieux ». Un
        # doigt, lui, est plié ou tendu indépendamment de l'orientation du
        # bras. Mesuré : le critère local sépare trois fois mieux.
        replis.append(_distance(base, bout) / paume)

    if len(replis) < 3:
        return None

    # Le pouce ment sur le repliement (il se replie de côté) : on mesure le
    # repliement sur les quatre doigts longs.
    longs = replis[1:] if len(replis) == 5 else replis
    repliement = sum(longs) / len(longs)  # ~0,3 fermé, ~1,0 ouvert

    return Mesures(
        repliement=max(0.0, min(2.0, repliement)),
        confiance=min(confiances),
    )

seuils_calibres

seuils_calibres(
    repliement_ouvert: float,
    repliement_ferme: float,
    *,
    base: Optional[Seuils] = None,
) -> Seuils

Des seuils dérivés de DEUX mesures réelles, pas d'une supposition.

L'hystérésis occupe le tiers central de l'écart mesuré : assez large pour qu'une main qui hésite ne fasse pas osciller l'état, assez étroite pour que le geste reste franc.

Source code in src/diapason/desktop/gestes_main.py
def seuils_calibres(
    repliement_ouvert: float,
    repliement_ferme: float,
    *,
    base: Optional[Seuils] = None,
) -> Seuils:
    """Des seuils dérivés de DEUX mesures réelles, pas d'une supposition.

    L'hystérésis occupe le tiers central de l'écart mesuré : assez large
    pour qu'une main qui hésite ne fasse pas osciller l'état, assez étroite
    pour que le geste reste franc.
    """
    from dataclasses import replace

    base = base or Seuils()
    ecart = repliement_ouvert - repliement_ferme
    if ecart < 0.15:
        # Les deux poses se ressemblent trop : calibrer là-dessus rendrait
        # les seuils ingouvernables. On le dit plutôt que de bricoler.
        raise ValueError(
            "Les deux poses sont trop proches pour en tirer des seuils. "
            "Ouvre bien la main, puis serre franchement le poing."
        )
    milieu = repliement_ferme + ecart / 2
    marge = ecart / 6
    return replace(
        base,
        fermeture_entree=round(milieu - marge, 3),
        fermeture_sortie=round(milieu + marge, 3),
        ouverture_entree=round(milieu + marge * 1.6, 3),
        ouverture_sortie=round(milieu + marge * 0.6, 3),
    )

charger_seuils

charger_seuils() -> Seuils

Les seuils de CETTE machine — ceux d'usine si rien n'est calibré.

Source code in src/diapason/desktop/gestes_main.py
def charger_seuils() -> Seuils:
    """Les seuils de CETTE machine — ceux d'usine si rien n'est calibré."""
    import json

    chemin = chemin_calibration()
    try:
        brut = json.loads(chemin.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return Seuils()
    connus = {c for c in Seuils.__dataclass_fields__}
    return Seuils(**{k: v for k, v in brut.items() if k in connus})