Tout a commencé par une question qui ne méritait sans doute pas deux jours de travail :
Est-ce que je pourrais contrôler les lampes de ma chambre à distance, avec mes doigts ?
Je ne connaissais pas grand-chose à la vision par ordinateur. J'avais à peine touché à AVFoundation, Vision ou Core Media. Je n'avais jamais construit de système de reconnaissance de gestes.
Ce qui, évidemment, m'a donné envie d'essayer.
L'idée de HandKit était simple : pointer la caméra frontale de mon iPhone vers moi, reconnaître quelques gestes de la main, et les traduire en actions dans ma maison HomeKit.
Les deux premières interactions que je voulais :
Pincer le pouce et l'index pour allumer ou éteindre une lampe.
Déplacer l'index horizontalement pour régler la luminosité de 0 à 100 %.
L'interaction finale paraît presque triviale.
Un pincement. Une lampe s'allume.
Mais passer d'un flux brut de caméra à un geste intentionnel et fiable a demandé de passer par des pipelines de capture, des pixel buffers, des systèmes de coordonnées, des mesures bruitées, un peu de géométrie, du filtrage de signal, des machines à états, de l'hystérésis, Swift Concurrency et, pour finir, HomeKit.
Cet article raconte comment je l'ai construit, mais surtout pourquoi l'architecture et les algorithmes ont évolué comme ils l'ont fait.
Commencer par la caméra
Avant de détecter une main, j'avais besoin de quelque chose de bien plus simple : des images.
Sur iOS, ça commence avec AVFoundation.
Le pipeline de capture peut se résumer à :
AVCaptureDevice
↓
AVCaptureDeviceInput
↓
AVCaptureSession
│
├────────→ AVCaptureVideoPreviewLayer
│ ↓
│ Preview
│
└────────→ AVCaptureVideoDataOutput
↓
Video framesChaque composant a un rôle différent.
AVCaptureDevice représente le périphérique de capture physique. Dans mon cas, la caméra frontale de l'iPhone.
AVCaptureDeviceInput transforme ce périphérique en entrée qu'on peut brancher sur une session de capture.
AVCaptureSession coordonne le pipeline de capture.
À partir de là, j'avais besoin de la vidéo à deux endroits différents.
Le premier chemin mène à une AVCaptureVideoPreviewLayer, pour voir le flux de la caméra à l'écran.
Le second mène à une AVCaptureVideoDataOutput, parce qu'afficher la vidéo ne suffit pas. Il me faut les images une par une si je veux analyser ce que fait ma main.
Cette distinction est devenue la base de HandKit :
Caméra
│
├──→ l'humain voit l'aperçu
│
└──→ l'application analyse les imagesGérer la session de capture avec Swift Concurrency
Je ne voulais pas que les opérations liées au cycle de vie de la caméra soient éparpillées dans l'interface, alors j'ai créé un CameraManager.
Il possède la session de capture et se charge de la configurer, de la démarrer et de l'arrêter.
Je l'ai implémenté sous forme d'actor.
actor CameraManager {
let session = AVCaptureSession()
// ...
}Les actors sont utiles ici parce qu'ils isolent l'état mutable.
En le construisant, j'ai dû bien comprendre une chose : un actor n'est pas un thread.
Un actor définit un domaine d'isolation. C'est son executor qui détermine où s'exécutent les tâches isolées à cet actor.
Pour la session de capture, je voulais une exécution sérialisée sur une queue dédiée, alors j'ai donné à CameraManager un serial executor personnalisé.
Schématiquement :
CameraManager actor
↓
Custom SerialExecutor
↓
camera.session queue
↓
AVCaptureSessionUne version simplifiée ressemble à ceci :
actor CameraManager {
private let sessionQueue =
DispatchSerialQueue(label: "camera.session")
nonisolated var unownedExecutor: UnownedSerialExecutor {
sessionQueue.asUnownedSerialExecutor()
}
// ...
}Le pipeline de la caméra avait ainsi un contexte d'exécution clair, sans faire du MainActor l'endroit où se déroule le travail de capture.
J'ai aussi gardé la configuration à l'intérieur du manager.
Vu de l'extérieur, démarrer la caméra ne devrait pas obliger à savoir qu'il faut :
configurer d'abord
puis démarrer
mais ne pas configurer deux fois
C'est un invariant interne.
CameraManager retient donc si la session a déjà été configurée et expose une opération de plus haut niveau, start().
C'est l'un des premiers principes d'architecture qui est revenu tout au long du projet :
L'appelant doit exprimer une intention, pas reproduire la procédure interne nécessaire pour la réaliser.
Séparer la gestion de la session et le traitement des images
L'étape suivante était de recevoir les images de AVCaptureVideoDataOutput.
AVFoundation les fournit via AVCaptureVideoDataOutputSampleBufferDelegate.
J'ai d'abord envisagé de faire aussi de CameraManager le delegate, mais il y avait une raison technique et une raison d'architecture de ne pas le faire.
Le delegate relève du NSObjectProtocol d'Objective-C, alors que mon manager de caméra est un actor. Surtout, gérer le cycle de vie de la caméra et traiter chaque image vidéo sont deux responsabilités différentes.
J'ai donc créé VideoOutputDelegate.
CameraManager
│
├── configure / start / stop
├── AVCaptureSession
│
└── VideoOutputDelegate
↓
frames
↓
VisionJ'ai aussi séparé leurs queues d'exécution.
camera.session
├── configure
├── start
└── stop
frame.session
├── frame
├── frame
├── frame
└── Vision processingC'est important, parce qu'analyser une image peut prendre du temps. Je ne veux pas que ce travail bloque inutilement les opérations sur la session de capture elle-même.
Le code y a aussi gagné une frontière conceptuelle bien plus nette :
CameraManager gère la capture.
VideoOutputDelegate interprète ce qui a été capturé.
Une vidéo, c'est juste beaucoup d'images qui arrivent très vite
Le delegate finit par recevoir :
func captureOutput(
_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection
)La valeur importante ici, c'est le CMSampleBuffer.
Au départ, je voyais une image de caméra comme une simple image, mais le pipeline média est plus riche que ça.
Un CMSampleBuffer est un conteneur pour un échantillon média. Pour la vidéo, il transporte l'échantillon et les informations associées, comme le timing et les métadonnées de format.
Pour Vision, ce dont j'avais vraiment besoin, c'était le buffer d'image qu'il contient :
guard let pixelBuffer =
CMSampleBufferGetImageBuffer(sampleBuffer)
else {
return
}On obtient un CVPixelBuffer.
Le pipeline était devenu :
Camera
↓
AVCaptureVideoDataOutput
↓
CMSampleBuffer
↓
CVPixelBuffer
↓
VisionInutile de transformer chaque image en UIImage.
Le pixel buffer peut aller directement dans Vision.
Demander à Vision de trouver une main
Le framework Vision d'Apple fournit VNDetectHumanHandPoseRequest.
Plutôt que de recréer la requête pour chaque image, je garde une seule instance et je lui demande de détecter au plus une main.
private let handPoseRequest: VNDetectHumanHandPoseRequest = {
let request = VNDetectHumanHandPoseRequest()
request.maximumHandCount = 1
return request
}()Pour chaque pixel buffer, je crée un request handler :
let handler = VNImageRequestHandler(
cvPixelBuffer: pixelBuffer,
orientation: .right,
options: [:]
)
try handler.perform([handPoseRequest])Je reviendrai sur ce .right, parce qu'y arriver m'a valu l'un de mes moments de débogage de toute l'expérience.
Quand Vision détecte une main, il renvoie une VNHumanHandPoseObservation.
À partir de cette observation, je peux demander des points précis de la main :
let indexTip = try hand.recognizedPoint(.indexTip)
let thumbTip = try hand.recognizedPoint(.thumbTip)Vision ne renvoie pas des pixels d'écran.
Les positions sont des coordonnées normalisées, ce qui les rend indépendantes de la résolution de l'image.
J'avais donc quelque chose d'extrêmement utile :
bout de l'index → (x, y)
bout du pouce → (x, y)Ça semble suffire pour construire des gestes.
Spoiler: ça ne suffisait pas.
Vision donne des estimations, pas la vérité
Chaque point reconnu a aussi une valeur de confiance.
J'ai très vite compris pourquoi.
J'ai affiché les coordonnées de l'index en bougeant le doigt. Quand Vision était confiant, j'obtenais des valeurs comme :
x: 0.578
y: 0.300
confidence: 0.872
x: 0.577
y: 0.311
confidence: 0.867
x: 0.575
y: 0.328
confidence: 0.855Mais de temps en temps, le suivi produisait un grand saut avec une confiance très faible.
Si je traitais chaque coordonnée comme également fiable, une erreur de suivi pouvait ressembler à un geste extrêmement rapide.
Mon premier filtre est donc devenu :
guard indexTip.confidence >= 0.7 else {
return
}0.7 n'est pas une constante universelle de Vision.
C'était un point de départ expérimental, basé sur les données que j'observais.
Cette distinction compte dans tout HandKit : la plupart des seuils ne sont pas présentés comme des valeurs magiques qui marcheraient pour tout le monde. Ce sont des valeurs que j'ai mesurées et ajustées en expérimentant avec ma propre installation.
Mais filtrer sur la confiance ne suffisait pas.
Même quand Vision était confiant et que mon doigt me paraissait parfaitement immobile, ses coordonnées continuaient de bouger légèrement.
C'est là qu'est apparu le premier problème algorithmique vraiment intéressant.
Comment mesurer un mouvement quand « ne pas bouger » bouge encore ?
Pour savoir si mon doigt bouge, il me faut plus que sa position actuelle.
Il me faut aussi la précédente.
Pour deux mesures consécutives :
previous = (x₁, y₁)
current = (x₂, y₂)Je calcule :
Δ x = x_2 - x_1Δ y = y_2 - y_1Mais je voulais aussi une seule valeur qui représente de combien le point s'est déplacé, quelle que soit la direction.
C'est simplement l'hypoténuse du triangle formé par les deux écarts :
d = sqrt((Δ x)^2 + (Δ y)^2)Swift fournit exactement cette opération :
let movementDistance = hypot(deltaX, deltaY)Premier petit retour au théorème de Pythagore, pour un projet de lampes connectées.
La découverte intéressante est venue quand j'ai gardé l'index le plus immobile possible.
movementDistance ne valait pas zéro.
Je voyais des valeurs autour de :
0.001
0.003
0.002
0.005
0.001
...avec de temps en temps des pics plus grands.
Le système devait distinguer un vrai mouvement intentionnel de la gigue naturelle du suivi. Une seule image ne suffisait pas pour trancher.
Filtrer le signal de suivi
Au lieu de considérer une mesure de mouvement à la fois, j'ai commencé à garder une fenêtre glissante contenant les cinq distances les plus récentes.
[d1, d2, d3, d4, d5]Quand une sixième arrive :
[d1, d2, d3, d4, d5, d6]je retire la plus ancienne :
[d2, d3, d4, d5, d6]En Swift :
recentMovementDistances.append(movementDistance)
if recentMovementDistances.count > 5 {
recentMovementDistances.removeFirst()
}Ensuite, je calcule la médiane.
Pour cinq valeurs triées la médiane vaut 0.003.
[0.001, 0.002, 0.003, 0.005, 0.050]
↑
médianeC'était utile, parce qu'une mesure aberrante isolée comme 0.050 tire beaucoup moins le résultat vers le haut qu'elle ne le ferait avec une moyenne.
C'est volontairement un filtre tout petit et tout simple. HandKit n'essaie pas d'être une bibliothèque sophistiquée de traitement du signal.
Mais ça a suffi à introduire une idée importante :
Une mesure bruitée devient beaucoup plus utile quand on raisonne dessus dans le temps, au lieu de traiter chaque échantillon séparément.
Et cette observation a mené directement aux machines à états.
Détecter le mouvement avec de l'hystérésis
Mon premier réflexe aurait pu être :
mouvement > seuil → en mouvement
mouvement < seuil → immobileMais imaginons un signal qui oscille autour de ce seuil :
0.0069
0.0071
0.0068
0.0072L'état pourrait très vite devenir :
IDLE
MOVING
IDLE
MOVING
IDLEÀ la place, j'ai introduit deux seuils différents.
D'après mes expériences, j'ai commencé avec :
médiane > 0.01 → le mouvement commence
médiane < 0.005 → retour à l'immobilitéEntre les deux, je garde l'état actuel.
> 0.01
┌───────────────────────┐
│ ↓
IDLE MOVING
↑ │
└───────────────────────┘
< 0.005C'est de l'hystérésis. L'écart entre les deux seuils empêche l'état de basculer sans arrêt quand le signal reste près d'une limite.
Dans le code, je représente l'état explicitement plutôt que de cacher le modèle derrière un booléen.
enum MovementState {
case idle
case moving
}Ce petit choix s'est révélé utile, parce que la question à laquelle je cherchais vraiment à répondre n'était pas :
Y a-t-il du mouvement dans cette image précise ?
C'était :
Un mouvement intentionnel a-t-il commencé, et sommes-nous en train de le suivre ?
C'est une question temporelle.
Un geste, c'est quelque chose qui se passe dans le temps
Au départ, j'ai commencé à réfléchir à la reconnaissance d'un swipe.
Une seule position ne peut évidemment pas représenter un swipe.
Même deux positions consécutives ne suffisent pas.
Un geste a un début, une trajectoire et une fin.
Donc, au passage de .idle à .moving, j'enregistre :
startPosition
startTimeen utilisant une ContinuousClock pour mesurer le temps écoulé.
Quand le mouvement finit par s'arrêter, je peux comparer :
startPosition → endPosition
startTime → endTimeet en déduire :
totalDeltaX = endX - startXtotalDeltaY = endY - startYainsi que la durée du geste.
J'avais ainsi les informations nécessaires pour raisonner sur un swipe :
A-t-il parcouru assez de distance horizontalement ?
Est-il resté à peu près horizontal ?
A-t-il été assez rapide ?
Mais en le testant, j'ai mis au jour un bug complètement différent.
Le geste horizontal qui bougeait verticalement
J'ai fait des mouvements horizontaux et j'ai regardé mes données.
Au lieu d'obtenir :
|Δx| >> |Δy|j'obtenais sans arrêt de grandes variations sur Y.
J'ai alors fait un geste volontairement vertical.
Cette fois, c'est X qui variait fortement.
Mon système de coordonnées était en fait tourné par rapport à ce que je voyais.
Le coupable, c'était ceci :
orientation: .upJ'avais d'abord dit à Vision d'interpréter le pixel buffer comme s'il était déjà à l'endroit.
L'aperçu s'affichait correctement, ce qui rendait l'erreur facile à rater.
Mais l'orientation de ce que voit l'utilisateur et l'orientation dans laquelle Vision interprète le buffer d'image brut ne sont pas forcément les mêmes.
J'ai essayé .right.
Puis j'ai refait les gestes horizontaux.
Cette fois, j'ai obtenu des mesures comme :
Δx = -0.217
Δy = +0.013
Δx = +0.263
Δy = -0.040
Δx = -0.278
Δy = +0.018Exactement ce que je voulais :
|Δx| >> |Δy|Ce bug m'a laissé l'une de mes leçons du projet :
Avant d'ajuster un algorithme autour de résultats bizarres, il faut vérifier que le système de coordonnées qui l'alimente est vraiment correct.
Sinon, j'aurais pu passer beaucoup de temps à construire une logique de gestes de plus en plus compliquée pour compenser des données d'entrée fausses.
J'ai aussi testé explicitement la direction avec la caméra frontale.
Dans ma configuration actuelle :
Δx > 0 → mouvement visuel vers la gauche
Δx < 0 → mouvement visuel vers la droiteLà encore, j'ai préféré mesurer le comportement plutôt que de supposer ce que ferait l'effet miroir.
Comprendre que je ne voulais pas vraiment un swipe
En concevant le swipe, je me suis rendu compte d'autre chose.
Pour contrôler la luminosité, le swipe est la mauvaise interaction.
Un swipe est un geste discret :
geste
↓
reconnu
↓
action exécutée une foisC'est parfait pour quelque chose comme :
swipe → morceau suivantLa luminosité, elle, est continue.
Ce que je voulais vraiment, c'était un slider contrôlé par le geste :
le doigt commence à bouger
↓
on suit sa position horizontale
↓
la luminosité change en continu
↓
le doigt s'arrêteLa même machine à états .idle / .moving restait donc utile, mais le sens de l'état « en mouvement » changeait.
Au lieu d'attendre la fin du geste pour agir, je calcule la luminosité en continu tant que le mouvement est actif.
Il y a une autre décision subtile ici.
Je pourrais mettre à jour la luminosité avec l'écart de chaque image :
brightness += currentDeltaMais les erreurs de suivi peuvent alors s'accumuler au fil du temps.
À la place, au début du geste, j'enregistre :
startPosition
startBrightnessChaque valeur suivante est calculée par rapport à ces références fixes :
Δ x = currentX - startXbrightness = startBrightness + f(Δ x)Ça veut dire que si je m'éloigne puis que je reviens exactement à ma position de départ, je dois retrouver la luminosité d'origine, quel que soit le chemin intermédiaire.
On évite ainsi toute dérive cumulative.
Le prototype actuel convertit le déplacement horizontal normalisé en une valeur entre 0 et 100, et borne le résultat :
let newBrightness = startBrightness + brightnessDelta
brightness = min(max(newBrightness, 0), 100)J'ai aussi construit un petit indicateur visuel en SwiftUI pour tester l'interaction avant de la brancher sur une vraie lampe.
Cette séparation était volontaire.
D'abord prouver :
geste → bonne valeur numérique
Ensuite brancher :
valeur numérique → appareil physique
Une inconnue à la fois.
Détecter un pincement avec de la géométrie
Le second geste était plus simple sur le principe.
J'avais déjà le bout de l'index.
Vision peut aussi me donner le bout du pouce.
let indexTip = try hand.recognizedPoint(.indexTip)
let thumbTip = try hand.recognizedPoint(.thumbTip)Après le même filtrage sur la confiance, j'ai deux points normalisés.
Mesurer à quel point les doigts sont proches redevient donc un problème de géométrie :
Δ x = indexX - thumbXΔ y = indexY - thumbYdistance = sqrt((Δ x)^2 + (Δ y)^2)ou :
let distance = hypot(deltaX, deltaY)La vérification de confiance doit porter sur les deux points. Une position fiable de l'index ne sert à rien si celle du pouce est incertaine :
guard indexTip.confidence >= 0.7,
thumbTip.confidence >= 0.7 else {
return
}
let deltaX = indexTip.location.x - thumbTip.location.x
let deltaY = indexTip.location.y - thumbTip.location.y
let distance = hypot(deltaX, deltaY)Cette fois, la distance compare deux points de la main dans la même image. Plus tôt, j'utilisais le même calcul pour comparer un point sur des images consécutives. La géométrie est identique ; la question est différente.
Avant de décider ce qui comptait comme un pincement, j'ai mesuré la distance en ouvrant et en fermant les doigts. Dans ma configuration, les observations étaient à peu près :
Doigts écartés : 0.15 à 0.21
Doigts en contact : 0.005 à 0.015Le contact ne donnait pas un zéro parfait. Vision estime la position des points de la main dans une image en deux dimensions, et ces estimations restent bruitées même quand les doigts se touchent.
Ce sont des distances en coordonnées d'image normalisées, pas des centimètres ni une mesure de contact physique. La normalisation supprime la dépendance à la résolution en pixels ; elle ne rend pas la mesure indépendante de la taille de la main, de la distance à la caméra ou de la géométrie de l'image. Si j'éloigne ma main de la caméra, sa taille apparente change, et donc la distance entre les points détectés aussi.
Ces mesures suffisaient à fixer un seuil utile pour ce prototype, mais ce n'était pas une calibration générale pour toutes les mains et toutes les installations de caméra.
Un pincement a lui aussi besoin de mémoire
Le détecteur de mouvement m'avait déjà appris ce qui se passe quand une mesure bruitée reste près d'un seuil.
Pour le pincement, j'ai réutilisé l'hystérésis avec une machine à états séparée :
enum PinchState {
case open
case pinched
}Les règles sont devenues :
OPEN -- distance < 0.015 --> PINCHED
PINCHED -- distance > 0.03 --> OPENEntre les deux seuils, je garde l'état actuel. Exactement sur l'une des deux limites, les comparaisons strictes laissent aussi l'état inchangé.
Cet écart compte. Après avoir reconnu un pincement, je ne veux pas qu'une petite hausse de 0.014 à 0.016 signifie que la main s'est rouverte. Les doigts doivent s'écarter plus nettement avant qu'un nouveau pincement puisse commencer.
Mais il restait une distinction à faire : l'état et l'événement sont deux choses différentes.
Si j'appelais l'action d'allumage à chaque image où la distance était assez petite, garder les doigts serrés allumerait et éteindrait la lampe en boucle.
L'événement qui m'intéresse, c'est la transition :
OPEN -> PINCHED -> émettre un seul toggleLa logique principale peut s'écrire ainsi :
switch pinchState {
case .open:
if distance < 0.015 {
pinchState = .pinched
onToggle()
}
case .pinched:
if distance > 0.03 {
pinchState = .open
}
}Une fois l'état passé à .pinched, les images suivantes restent dans cette branche jusqu'à ce que les doigts se rouvrent. Relâcher le pincement réarme l'interaction ; ça n'allume ni n'éteint la lampe.
J'avais ainsi un comportement précis à tester : pincer, maintenir, relâcher, pincer à nouveau. Le maintien ne doit produire aucune action supplémentaire, et le second pincement doit produire exactement une nouvelle action.
L'hystérésis stabilise la transition, mais elle ne règle pas tous les problèmes de suivi. Par exemple, perdre la main pendant un pincement pose une autre question : quand réinitialiser ou réarmer le détecteur ? Ça fait partie du travail pour rendre l'expérience plus robuste, au-delà de cette première interaction qui fonctionne.
Trouver la vraie lampe avec HomeKit
À ce stade, le pipeline de gestes pouvait produire un événement de toggle. Il restait à relier cet événement à quelque chose en dehors du téléphone.
HomeKit a amené une autre hiérarchie à comprendre :
HMHomeManager
|
+-- HMHome
|
+-- HMAccessory
|
+-- HMService (Lightbulb)
|
+-- HMCharacteristic (PowerState)
+-- HMCharacteristic (Brightness)HMHomeManager donne accès aux maisons configurées. Chaque maison contient des accessoires, et chaque accessoire expose des services. Les valeurs que je peux lire ou modifier appartiennent aux caractéristiques de ces services. La documentation d'Apple sur HMCharacteristic décrit cette dernière couche.
J'ai commencé par inspecter ce que ma propre maison exposait vraiment. Parmi les accessoires découverts, il y avait Lamp, Desktop et Lanterne. Pour cette expérience, j'ai choisi Lamp et j'ai cherché son service d'ampoule.
Ce choix était volontairement propre à mon installation. Une version destinée à des utilisateurs aurait besoin d'un sélecteur d'accessoire et d'un identifiant stable, plutôt que de dépendre d'un nom d'affichage.
Dans ce service, les deux types de caractéristiques dont j'avais besoin étaient :
HMCharacteristicTypePowerState
HMCharacteristicTypeBrightnessPowerState indique si la lampe est allumée. Brightness représente sa luminosité en pourcentage entier du maximum, comme le définit la documentation d'Apple sur la caractéristique de luminosité.
Trouver ces caractéristiques ne prouvait pas encore que je pouvais contrôler la lampe. J'ai d'abord demandé à HomeKit de lire leurs valeurs.
La sortie réelle était :
BRIGHTNESS: 100
POWER: 1Je savais maintenant que j'avais trouvé le bon accessoire et que je pouvais lire son état : la lampe était allumée, avec une luminosité de 100 %.
[IMAGE: Sortie de la découverte HomeKit montrant Lamp, Desktop et Lanterne, suivie des valeurs PowerState et Brightness de Lamp]
Tester une couche à la fois
C'était tentant de brancher le pincement tout de suite. À la place, j'ai avancé en quatre petites étapes :
Lire l'état -> Écriture manuelle -> Bouton temporaire -> GesteLa première lecture a établi que la découverte fonctionnait.
Le test suivant a écrit false dans la caractéristique d'alimentation. La vraie lampe s'est éteinte. Ça a établi que l'app pouvait envoyer une commande à l'accessoire, indépendamment de Vision ou de la reconnaissance de gestes.
J'ai retiré cette écriture temporaire après le test. Laisser une commande dans le chemin de découverte aurait fait changer l'état de la lampe au chargement de la maison, et les callbacks de découverte peuvent se déclencher plus d'une fois.
Ensuite, j'ai ajouté un bouton SwiftUI temporaire :
Button("Toggle Lamp") {
lightController.toggle()
}Le bouton m'a donné un moyen contrôlé de tester l'opération de toggle complète. S'il échouait, je savais qu'il fallait regarder du côté de HomeKit, de la découverte des caractéristiques ou de la séquence lecture/écriture. Je n'avais pas à me demander si Vision avait raté mes doigts.
C'est seulement une fois que ça fonctionnait que j'ai remplacé le bouton par l'événement de pincement comme déclencheur.
Cet enchaînement a été l'une des décisions d'ingénierie les plus utiles du projet. Chaque étape réduisait le nombre d'explications possibles à un échec, avant que j'ajoute un autre sous-système.
Donner à HomeKit son propre contrôleur
J'ai mis la découverte et le contrôle HomeKit dans LightController.
Son rôle : trouver Lamp, garder les caractéristiques dont l'app a besoin, et exposer au reste de l'application une opération comme toggle().
Les références sont d'abord optionnelles, parce que la découverte HomeKit est asynchrone :
private var powerCharacteristic: HMCharacteristic?
private var brightnessCharacteristic: HMCharacteristic?Elles pointent vers des objets HomeKit vivants. Ce ne sont pas des préférences à enregistrer dans UserDefaults, et les garder évite de reparcourir toute la hiérarchie de la maison à chaque geste.
Une fois la caractéristique d'alimentation trouvée, toggle() suivait cette séquence :
Caractéristique d'alimentation disponible ?
|
v
Lire la valeur actuelle
|
v
Convertir en Bool et inverser
|
v
Écrire la nouvelle valeur
|
v
Vérifier l'erreur éventuelle à la finJe lis la valeur avant de basculer, parce que la lampe peut aussi être modifiée depuis l'app Maison ou un autre contrôleur. Un booléen local ne décrirait que ce que HandKit croyait en dernier, ce qui ne correspond plus forcément à l'accessoire.
Il y avait aussi un détail d'interopérabilité avec Objective-C : HMCharacteristic.value est exposé en Any?. Dans mes tests, la valeur d'alimentation arrivait sous forme de NSNumber, alors j'ai utilisé son boolValue avant de l'inverser :
guard let value = powerCharacteristic.value as? NSNumber else {
return
}
let newState = !value.boolValueCette conversion vient après une lecture réussie, suivie de l'écriture et de sa propre gestion d'erreur. Le prototype journalisait les échecs au lieu de considérer une commande tentée comme réussie.
Une lecture suivie d'une écriture ne constitue toujours pas un toggle atomique. Une autre commande peut arriver entre les deux, et des gestes rapides peuvent faire se chevaucher les requêtes. Sérialiser les commandes envoyées à l'appareil est une étape de robustesse supplémentaire ; la démo qui fonctionne ne prouve pas que tous les cas de contrôle concurrent sont gérés.
À ce stade, garder la caractéristique de luminosité préparait la seconde interaction. Ça ne voulait pas dire que la luminosité pilotée par le geste était déjà écrite dans la lampe.
import HomeKit
final class LightController: NSObject, HMHomeManagerDelegate {
private let homeManager = HMHomeManager()
private var powerCharacteristic: HMCharacteristic?
private var brightnessCharacteristic: HMCharacteristic?
override init() {
super.init()
homeManager.delegate = self
}
func homeManagerDidUpdateHomes(_ manager: HMHomeManager) {
// Find the accessory named "Lamp"
guard let accessory = manager.homes
.flatMap(\.accessories)
.first(where: { $0.name == "Lamp" })
else {
print("Lamp not found")
return
}
// Find the light service
guard let lightService = accessory.services.first(
where: { $0.serviceType == HMServiceTypeLightbulb }
) else {
print("Light service not found")
return
}
// Find the characteristics we need
for characteristic in lightService.characteristics {
if characteristic.characteristicType == HMCharacteristicTypePowerState {
powerCharacteristic = characteristic
}
if characteristic.characteristicType == HMCharacteristicTypeBrightness {
brightnessCharacteristic = characteristic
}
}
}
private func setPower(
_ isOn: Bool,
characteristic: HMCharacteristic
) {
characteristic.writeValue(isOn) { error in
if let error {
print("Failed to change power state:", error)
return
}
print("Power changed:", isOn)
}
}
func toggle() {
guard let powerCharacteristic else {
return
}
powerCharacteristic.readValue { error in
if let error {
print("Failed to read power state:", error)
return
}
guard let value = powerCharacteristic.value as? NSNumber else {
return
}
let isOn = value.boolValue
let newState = !isOn
powerCharacteristic.writeValue(newState) { error in
if let error {
print("Failed to change power state:", error)
return
}
print("Power changed:", newState)
}
}
}
}Garder la reconnaissance indépendante de l'action
Le code de traitement des images connaît les points de la main, les distances et les états des gestes. Il n'a pas besoin de savoir ce qu'est un accessoire HomeKit.
VideoOutputDelegate communique par deux callbacks :
private let onToggle: @Sendable () -> Void
private let onBrightnessChanged: @Sendable (CGFloat) -> VoidL'un émet un événement discret. L'autre émet une valeur mise à jour en continu.
CameraManager reçoit le callback de toggle de l'application et le transmet au delegate. C'est l'application qui décide que cet événement doit appeler LightController.toggle().
Grâce à cette frontière, je peux inspecter ou journaliser la sortie des gestes sans lampe connectée. Ça veut aussi dire que le même détecteur de pincement pourrait un jour déclencher autre chose, sans toucher à la géométrie ni aux seuils.
Il reste un couplage volontaire, à l'échelle du prototype : onBrightnessChanged nomme la valeur selon l'interaction actuelle. Il fait abstraction de HomeKit, mais ce n'est pas une API de gestes complètement générique. Si j'en faisais un composant réutilisable, une valeur de slider normalisée ou un événement de déplacement ferait sans doute un meilleur contrat.
Pour HandKit, la séparation utile était déjà claire :
Reconnaissance : qu'a fait la main ?
Application : qu'est-ce que ça doit vouloir dire ?
HomeKit : comment demander à la lampe de le faire ?Passer de la queue des images au MainActor
Ces callbacks ont aussi révélé une frontière de concurrence.
Les images arrivent sur frameQueue. Le modèle côté interface et, dans ce projet, LightController sont isolés au MainActor.
Appeler lightController.toggle() directement depuis le callback synchrone de toggle provoquait une erreur d'isolation. Swift signalait un vrai décalage dans l'architecture : du code qui tournait dans le contexte de traitement des images essayait d'accéder directement à un comportement isolé au main actor.
Le callback avait besoin d'un passage de relais explicite :
onToggle: {
Task { @MainActor in
lightController.toggle()
}
}Le callback de luminosité suit le même schéma :
onBrightnessChanged: { newBrightness in
Task { @MainActor in
viewModel.brightness = newBrightness
}
}@Sendable décrit la capacité d'une closure à traverser des frontières de concurrence avec des captures sûres. Ça n'exécute pas la closure sur le main actor. C'est le corps Task { @MainActor in ... } qui établit explicitement ce contexte d'exécution. Cette distinction découle du modèle de concurrence et d'isolation de Swift.
Les valeurs qui traversent cette frontière sont petites : un événement ou un nombre de luminosité. Les pixel buffers, la requête Vision et l'état de la reconnaissance restent dans le chemin de traitement des images.
Déplacer le callback sur le main actor ne rend pas non plus l'opération sur l'appareil physique synchrone. HomeKit termine toujours sa lecture et son écriture de façon asynchrone. L'isolation d'actor protège l'accès à l'état de l'application ; elle ne garantit pas que la lampe a déjà réagi.
Pour ce prototype, ce passage de relais a rendu les deux chemins explicites. Si les mises à jour deviennent plus fréquentes que ce que l'interface ou l'accessoire peut absorber, l'étape suivante est de les regrouper plutôt que de créer un flux toujours plus long de travail en attente. Des tâches indépendantes ne doivent pas non plus servir de garantie sur l'ordre des événements.
Un modèle partagé entre la caméra et l'interface
L'indicateur de luminosité à l'écran lit un CameraViewModel à la fois observable et isolé au main actor :
@MainActor
@Observable
final class CameraViewModel {
var brightness: CGFloat = 0
}Cet exemple réduit montre les deux responsabilités séparées. @Observable permet à SwiftUI de suivre les changements de l'état qu'il lit. @MainActor définit où cet état mutable est accédé. L'observation seule n'établit pas d'isolation de concurrence.
Il y avait un détail de propriété tout aussi important : ContentView et CameraManager devaient utiliser la même instance du modèle.
Si chacun créait son propre CameraViewModel, le callback de la caméra pourrait mettre à jour un objet pendant que SwiftUI en observerait un autre. Les calculs du geste seraient justes, mais l'indicateur ne les refléterait jamais.
Dans l'initialisation de la vue, le branchement suit donc cette forme :
init() {
let viewModel = CameraViewModel()
let lightController = LightController()
self._viewModel = State(initialValue: viewModel)
self.lightController = lightController
self.cameraManager = CameraManager(
viewModel: viewModel,
onToggle: {
Task { @MainActor in
lightController.toggle()
}
}
)
}C'est un extrait du branchement, sans les propriétés stockées autour ni le body de la vue. L'important, c'est que le viewModel local passé à CameraManager est la même référence que celle gardée pour l'état de la vue. Dans le manager, le callback de luminosité met à jour cette référence sur le main actor.
Le chemin de retour qui en résulte :
Vision -> slider value -> callback -> MainActor
|
v
CameraViewModel
|
v
SwiftUI indicatorC'est cette instance partagée qui relie un calcul correct à quelque chose de visible à l'écran.
L'architecture complète
À la fin de cette itération, le système ressemblait à ceci :
ContentView
creates and connects objects
|
v
CameraManager actor
|
custom SerialExecutor
|
sessionQueue
|
AVCaptureSession
|
+----------------+------------------+
| |
v v
AVCaptureVideoPreviewLayer AVCaptureVideoDataOutput
| |
v v
Camera preview frameQueue
|
v
VideoOutputDelegate
|
v
CMSampleBuffer -> CVPixelBuffer
|
v
Vision hand-pose request
|
v
Confidence-filtered points
|
+------------------+----------------+
| |
v v
Movement / slider Pinch state
| |
v v
onBrightnessChanged(value) onToggle()
| |
v v
Task { @MainActor in ... } Task { @MainActor in ... }
| |
v v
CameraViewModel LightController
shared with ContentView |
| v
v HomeKit PowerState
SwiftUI 0-100 indicator |
v
Physical Lamp
Next connection, not yet implemented at this snapshot:
slider value -> HomeKit Brightness -> physical light intensityChaque frontière répond à une question concrète. Le manager de caméra possède le cycle de vie de la capture. Le delegate traite les images et maintient l'état de la reconnaissance sur sa queue série. Le view model expose l'état de l'interface. Le contrôleur de lumière gère la découverte des accessoires et les commandes.
L'architecture rend aussi visible la partie inachevée : le chemin de la luminosité atteint l'interface, tandis que le chemin du toggle atteint la vraie lampe.
Où en est l'expérience
Le pincement contrôle désormais une vraie lampe HomeKit. Rapprocher le pouce et l'index déclenche un seul toggle, et maintenir le pincement ne la fait pas basculer en boucle.
Le geste horizontal continu fonctionne aussi comme un slider à l'écran. Il produit et affiche des valeurs de luminosité de 0 à 100, à partir d'une position et d'une luminosité de départ fixes.
Au moment couvert par cet article, ce slider n'était pas encore relié à la caractéristique Brightness de HomeKit. J'avais découvert et lu la caractéristique, mais le chemin de contrôle de la luminosité physique restait inachevé.
Ce sont deux étapes différentes : reconnaître et afficher la valeur voulue, et réussir à l'appliquer à un accessoire.
La vidéo capture ce qu'un log de console ne pouvait pas montrer. Après avoir passé autant de temps à regarder des coordonnées et des transitions d'état, j'ai rapproché deux doigts et quelque chose a changé dans la pièce.
J'ai ri. Beaucoup.
Ce que j'ai appris
Ce projet m'a rendu plusieurs notions très concrètes.
L'isolation d'un actor et son exécution sont deux sujets distincts. Faire de CameraManager un actor a donné une frontière d'isolation à son état mutable ; choisir un serial executor a donné à son travail un contexte d'exécution délibéré. Aucun de ces choix n'a dispensé de réfléchir aux callbacks du delegate et aux autres queues.
Un aperçu correctement affiché ne prouve pas que l'analyse d'image utilise la bonne orientation. Le passage de .up à .right est venu de la comparaison entre le mouvement attendu et les écarts observés. Dans cette configuration, ça a réglé le décalage. Ce n'est pas un réglage d'orientation universel pour toutes les caméras et toutes les positions de l'appareil.
Le filtrage sur la confiance, le filtrage par médiane et l'hystérésis résolvent des problèmes différents. La confiance écarte les points incertains. La médiane sur cinq échantillons limite l'influence des pics de mouvement isolés. L'hystérésis empêche les changements d'état répétés près d'une limite. Les combiner était utile, parce qu'aucun seuil unique ne pouvait faire les trois.
Le filtrage a aussi un coût. Une fenêtre de mesures récentes ajoute de l'inertie, et le déplacement par image dépend de la cadence d'échantillonnage. Les valeurs qui ont marché dans mes tests ne remplacent pas des tests avec d'autres fréquences d'images, d'autres distances à la caméra et d'autres conditions d'éclairage.
La reconnaissance de gestes demande de la mémoire. Le chemin du mouvement a besoin d'un début et d'une trajectoire qui évolue ; celui du pincement doit savoir si les doigts étaient déjà serrés. Une fois ces états modélisés explicitement, le comportement est devenu plus facile à raisonner.
La transition peut être l'événement utile. Un pincement maintenu décrit une condition. Entrer dans cette condition décrit une action à laquelle l'application peut réagir une seule fois.
Une référence fixe simplifie le contrôle continu. Ramener chaque position à startPosition et startBrightness évite d'accumuler les erreurs d'une image à l'autre, et donne à l'interaction un point de retour prévisible.
L'identité des objets compte autant que le flux de données. Le callback de luminosité et SwiftUI devaient pointer vers la même instance du modèle. Des valeurs correctes envoyées à un objet que personne n'observe restent invisibles pour l'utilisateur.
Enfin, séparer les couches a rendu le débogage gérable. Un bouton m'a permis de valider HomeKit sans geste devant la caméra. Un indicateur visuel m'a permis de valider le calcul de luminosité sans toucher à la lampe. Les callbacks ont relié ces morceaux déjà testés.
Apprendre avec l'IA comme mentor
Tout au long de l'expérience, j'ai utilisé l'IA, dont Codex, comme mentor : pour m'orienter vers la documentation, m'expliquer des notions que je ne connaissais pas, et bousculer mon raisonnement quand je n'arrivais pas à expliquer ce que je voyais.
Ma règle d'apprentissage : relever les défis moi-même. Je voulais inspecter les mesures, raisonner sur l'algorithme, écrire et tester le comportement, et comprendre les décisions d'architecture. Les explications et les petits exemples m'ont permis d'avancer, mais me faire résoudre les défis aurait supprimé justement ce que je voulais vivre avec ce projet.
Les moments les plus utiles, ce sont les questions qui m'ont fait regarder à nouveau : qu'y a-t-il vraiment dans ce buffer ? Pourquoi un doigt immobile bouge-t-il encore ? Dans quel système de coordonnées est-ce que je mesure ? Que doit-il se passer à l'image suivante si les doigts se touchent encore ?
C'est comme ça qu'une petite expérience avec une lampe est devenue un prétexte pour comprendre une bien plus grande partie de la stack iOS.
La suite
La prochaine étape immédiate est de relier la valeur du slider à la vraie caractéristique de luminosité. Il faut pour ça partir de la luminosité actuelle de la lampe, convertir la valeur calculée dans la représentation entière attendue par la caractéristique, et gérer les échecs d'écriture.
Je veux aussi contrôler la fréquence de ces écritures. Les images de la caméra peuvent produire des mises à jour bien plus vite qu'une lampe n'en a besoin. Garder la dernière valeur voulue, limiter les commandes intermédiaires et s'assurer que la valeur finale est appliquée rendrait ce branchement plus utile que d'envoyer chaque image directement à HomeKit.
Au-delà, les prochaines expériences portent sur la robustesse :
Tester les seuils avec différentes tailles de main, distances à la caméra et conditions d'éclairage, et explorer une mesure de distance relative à la taille de la main.
Définir ce qui se passe quand la confiance du suivi baisse ou que la main sort du cadre, y compris comment réinitialiser l'historique de mouvement et réarmer un pincement.
Décider comment le pincement et le mouvement horizontal doivent interagir quand les deux arrivent en même temps.
Sérialiser les commandes envoyées à l'appareil, et rendre visibles dans l'interface la découverte, les accessoires indisponibles et les échecs de commande.
Remplacer la sélection codée en dur de
Lamppar un sélecteur d'accessoire.Explorer la prise en charge de la caméra sur macOS en réutilisant la logique des gestes, et en revoyant les hypothèses d'orientation et de capture.
Ce sont les prochaines étapes, pas des fonctionnalités déjà démontrées par le prototype actuel.
D'une petite question à une lampe qui fonctionne
J'ai commencé HandKit parce que je voulais savoir si je pouvais contrôler mes lampes avec mes doigts. J'ai terminé cette itération avec un pincement qui fonctionne, une interaction de luminosité visible à l'écran, et une compréhension bien plus claire de tout ce qui se passe entre une image de caméra et une action physique.
Le plus satisfaisant, c'était de pouvoir expliquer ce chemin. Je pouvais montrer où arrivaient les pixels, où les points de la main devenaient des mesures, où une transition d'état devenait un événement, et où cet événement atteignait enfin HomeKit.
Il reste du travail. Le chemin de la luminosité a besoin de son dernier branchement, et la logique des gestes doit tenir au-delà des conditions dans lesquelles je l'ai réglée la première fois.
Mais le moment capturé dans cette première vidéo réussie dit déjà pourquoi je voulais construire ça. J'ai pincé les doigts, la lampe a réagi, et je n'arrivais plus à m'arrêter de rire. Une question qui paraissait un peu ridicule m'avait fait traverser des frameworks inconnus, revenir à Pythagore, et arriver à une pièce qui répondait désormais à quelque chose que j'avais construit.
C'était une très bonne raison d'y passer deux jours.


