# Du tracking vidéo pour un baby-foot — Cinder, OpenCV et le plaisir de refaire du C++ > Comment on a construit un module de tracking vidéo pour le baby-foot Tekbak : détection de balle par segmentation couleur, effets particules OpenGL via Cinder, enregistrement des buts en slow motion, et les joies du C++ moderne cross-platform. Date : 28/12/2017 Auteur : Aurélien N. Tags : C++, OpenCV, OpenGL, Vision, IoT --- Il y a quatre ans, on a construit le [cerveau Ruby du baby-foot Tekbak](/blog/extension-c-ruby-embarque-i2c) : extension C pour l'I2C, [rendu typographique sur des écrans LED](/blog/affichage-texte-led-custom-ruby-freetype), [moteur de règles configurable](/blog/moteur-regles-baby-foot-ast-ruby). Tout tournait sur un BeagleBone avec des capteurs physiques (piézo, barrières IR, RFID). Cette fois, Tekbak nous demande autre chose. Ils veulent un module vidéo. Une caméra au-dessus du terrain, du traitement d'image pour détecter la balle en temps réel, et trois usages : un prototype d'arbitrage vidéo pour des babyfoot non-équipés (sans l'électronique Tekbak), un module d'effets visuels pour la diffusion live des matchs, et la génération automatique de GIFs des buts pour les réseaux sociaux. Pour moi, c'est aussi un retour au C++ après des années de Ruby, Swift, TypeScript. Ma thèse portait sur la robotique et la vision par ordinateur, en C++. Retrouver OpenCV et un compilateur C++ m'a fait un plaisir un peu coupable. ## L'architecture du projet Le projet se découpe en trois couches : la vision par ordinateur (OpenCV), le rendu (Cinder/OpenGL) et les sorties (écran live, GIFs, injection dans le système Tekbak). Le tout tient dans une app Cinder qui tourne en boucle à 60fps, avec un `shared_ptr` qui traverse toutes les couches pour permettre le réglage en temps réel. ## Cinder : le framework créatif en C++ On a choisi [Cinder](https://libcinder.org/) (v0.9.1, sorti en mars) plutôt qu'openFrameworks. Les deux sont des frameworks de creative coding en C++, mais Cinder est plus idiomatique C++ moderne, plus orienté performance, et plus propre dans sa gestion OpenGL. Le 0.9.1 est aussi la première version avec un support Linux officiel et une intégration CMake, ce qui nous arrange pour le déploiement. Cinder gère le cycle de vie de l'application (`setup()` / `update()` / `draw()`), la capture vidéo, le rendu OpenGL 3.2+, les polices de caractères pour l'affichage du score, et le panel de paramètres (`params::InterfaceGl`) pour régler la détection en live. Le CinderBlock `Cinder-OpenCV3` fait le pont entre les types Cinder (`Surface`, `gl::Texture2d`) et les types OpenCV (`cv::Mat`) via `toOcv()` / `fromOcv()`. ## La détection de balle Le pipeline de détection fait trois étapes : segmentation couleur, extraction de contours, sélection du meilleur candidat. La balle est jaune. On convertit chaque frame en HSV (plus stable que RGB face aux variations de lumière), on garde les pixels dans la plage de teinte/saturation/valeur configurée, on nettoie le masque binaire par érosion+dilatation, et on extrait les contours. Parmi les contours, on cherche le cercle englobant dont le rayon correspond à la taille attendue de la balle : ```cpp optional BallDetector::selectMostPlausibleContour( const std::vector& contours) { struct Candidate ; auto candidates = std::vector(); for (auto& contour : contours) { cv::Point2f center; float radius; cv::minEnclosingCircle(contour, center, radius); if (radius >= config_->minRadius() && radius < config_->maxRadius()) { candidates.push_back(Candidate); } } if (previousPosition_) { // Cohérence temporelle : le candidat le plus proche auto it = std::min_element(begin(candidates), end(candidates), [&](auto l, auto r) ); if (it != end(candidates)) return it->center; } else { // Premier frame : le plus gros candidat auto it = std::max_element(begin(candidates), end(candidates), [&](auto l, auto r) ); if (it != end(candidates)) return it->center; } return nullopt; } ``` Deux stratégies de sélection. Au premier frame, on prend le plus gros blob qui ressemble à une balle. Ensuite, on prend le candidat le plus proche de la position précédente. C'est ce qui permet de ne pas "sauter" sur un reflet ou un maillot jaune dans le public. ### Le filtre de position : prédire quand on ne voit rien La balle passe régulièrement sous les joueurs (les barres de babyfoot, pas les humains). Pendant ces frames, le détecteur ne trouve rien. Le `BallPositionFilter` compense en extrapolant la trajectoire à partir de la dernière vélocité connue : ```cpp optional BallPositionFilter::filterPosition( const optional detectedPosition) { if (detectedPosition) { // Détection OK : calcul de la vélocité if (previousDetectedPosition_) previousDetectedPosition_ = detectedPosition; return FilteredPosition; } // Détection échouée : simulation de mouvement if (numComputedFrames_ > config_->maxComputedFrames()) if (!currentVelocity_) return nullopt; if (computedPosition_) else if (previousDetectedPosition_) numComputedFrames_++; return FilteredPosition; } ``` Le `FilteredPosition` a un flag `detected` qui permet au rendu de distinguer une vraie détection d'une extrapolation. On tolère jusqu'à 10 frames d'extrapolation (configurable), ce qui couvre les occlusions courantes. Au-delà, on considère que la balle est sortie du terrain. ### Stabiliser le tracking avec un filtre de Kalman L'extrapolation linéaire du `BallPositionFilter` fonctionne pour les occlusions courtes, mais elle a un problème : la position brute du détecteur saute d'un pixel à l'autre à chaque frame, même quand la balle est immobile. Et quand la balle accélère ou change de direction après un choc sur une barre, la vélocité constante donne une prédiction aberrante. Le filtre de Kalman résout les deux problèmes. C'est un estimateur récursif qui maintient un modèle de l'état du système (position + vélocité) et le met à jour à chaque observation. L'idée, en simplifié : à chaque frame, le filtre fait deux choses. D'abord une **prédiction** ("d'après ce que je sais de la vitesse, la balle devrait être ici"), puis une **correction** quand la mesure arrive ("le détecteur dit qu'elle est là, je fais un compromis entre ma prédiction et la mesure"). Le compromis dépend de la confiance qu'on accorde à chaque source. Le gain de Kalman (le `K` dans le diagramme) est le coeur du mécanisme. Quand le filtre est sûr de sa prédiction (la balle va en ligne droite depuis 10 frames), `K` est petit et la mesure brute a peu d'influence. Quand la prédiction est incertaine (après un rebond, par exemple), `K` est grand et le filtre se fie davantage au détecteur. Tout ça se recalcule automatiquement à chaque frame. OpenCV fournit `cv::KalmanFilter` clé en main. On modélise l'état comme un vecteur 4D `[x, y, vx, vy]` et la mesure comme `[x, y]` (ce que le `BallDetector` retourne). La matrice de transition encode le modèle physique (position = position + vélocité × dt) : ```cpp class KalmanBallFilter { cv::KalmanFilter kf_; bool initialized_ = false; int framesSinceLastDetection_ = 0; public: KalmanBallFilter() : kf_(4, 2, 0) ``` `processNoiseCov` et `measurementNoiseCov` sont les deux boutons qu'on tourne pour régler le comportement. Un bruit de processus bas dit "la balle va en ligne droite, fais confiance au modèle" : la trajectoire est très lisse mais le filtre met du temps à réagir aux changements de direction. Un bruit de mesure bas dit "le détecteur est précis, suis-le" : la trajectoire est réactive mais plus nerveuse. On est à `1e-2` / `1e-1`, un ratio de 1:10 en faveur du modèle. C'est ce qui donne un tracking fluide à l'écran sans trop de latence sur les changements de direction. ```cpp optional update(const optional& detected) { // Prédiction : toujours, que la mesure arrive ou non cv::Mat prediction = kf_.predict(); cv::Point2f predicted(prediction.at(0), prediction.at(1)); if (detected) { // La mesure est disponible → on corrige cv::Mat measurement = (cv::Mat_(2, 1) << detected->x, detected->y); kf_.correct(measurement); framesSinceLastDetection_ = 0; if (!initialized_) return cv::Point2f( kf_.statePost.at(0), kf_.statePost.at(1)); } // Pas de mesure → on retourne la prédiction pure framesSinceLastDetection_++; if (framesSinceLastDetection_ > MAX_FRAMES_WITHOUT_DETECTION) return predicted; } ``` Le `predict()` tourne à chaque frame, que le détecteur ait trouvé la balle ou non. C'est une multiplication matricielle 4×4, ça prend quelques microsecondes. Le `correct()` ne coûte guère plus. Sur notre budget de 16ms par frame à 60fps, le Kalman est invisible, et la différence à l'écran est flagrante : la balle ne tremble plus quand elle est lente, le calcul de vitesse est stable, et les prédictions pendant les occlusions suivent une courbe crédible au lieu d'une ligne droite. Pour montrer la différence concrètement, voici ce que donnent trois frames successives avec un rebond sur une barre, comparé entre le tracking brut et le Kalman : Les points rouges sont les détections brutes du `BallDetector` : elles disparaissent derrière la barre, et reprennent après le rebond à un endroit inattendu. L'extrapolation linéaire naïve (en gris) continue tout droit comme si la barre n'existait pas. Le filtre de Kalman (en bleu) fait quelque chose de plus malin : il prédit pendant l'occlusion en suivant le modèle, et quand la mesure revient après le rebond, il corrige en douceur vers la nouvelle position. La courbe bleue est continue, lissée, et visuellement crédible. ```cpp cv::Point2f velocity() const }; ``` Un bonus gratuit : la vélocité sort directement de l'état du filtre (`vx`, `vy`). Pas besoin de la calculer séparément comme dans le `BallPositionFilter` original. Et comme elle est lissée par le filtre, le calcul de vitesse en km/h ne fait plus de sauts aberrants quand le détecteur flotte d'un pixel entre deux frames. ## L'effet de feu : trois essais avant de trouver le bon Quand la balle accélère, un effet de flammes apparaît dans son sillage. On a mis du temps à trouver le bon algorithme. Je vais raconter les étapes parce qu'on a appris des choses à chaque essai. ### Premier essai : des points qui bougent au hasard La version la plus naïve. On spawn des particules à la position de la balle, on leur donne une vélocité aléatoire, et on les affiche en additive blending. Ça marche en 20 minutes de code. Et ça ressemble à une fontaine d'étincelles, pas du tout à du feu. Le problème : les particules partent dans des directions incohérentes. Le feu, ça a de la structure, des volutes, une cohérence spatiale. Le hasard pur ne donne pas ça. ### Deuxième essai : bruit de Perlin brut J'ai relu le chapitre 5 de GPU Gems (Ken Perlin, "Implementing Improved Perlin Noise") et le chapitre 26 de GPU Gems 2 qui donne une implémentation GPU. L'idée : au lieu de donner une vélocité aléatoire à chaque particule, on sample un champ de bruit 3D `noise(x, y, time)` à sa position pour obtenir une direction. Les particules proches vont dans la même direction, ce qui crée de la cohérence spatiale. Et comme le bruit évolue dans le temps, les motifs bougent. On a commencé par ça dans le `ParticleController` : ```cpp void Particle::update(float fluidity, float turbulence, float strength) ``` C'est mieux. Les particules suivent des courants, ça ondule. Mais il y a un défaut : le champ de Perlin brut est **compressible**. Les particules se regroupent aux "puits" du bruit (les endroits où les vecteurs convergent) et laissent des trous ailleurs. Le résultat a des amas denses entourés de zones vides, ce qui ne ressemble pas du tout à une flamme homogène. ### Troisième essai : curl noise (le bon) En cherchant comment résoudre ce problème de convergence, je suis tombé sur le papier de Robert Bridson (SIGGRAPH 2007) : "Curl-Noise for Procedural Fluid Flow". Au lieu d'utiliser le bruit de Perlin directement comme champ de vélocité, on prend son **rotationnel** (curl). Le rotationnel d'un champ scalaire est mathématiquement garanti d'être à divergence nulle, c'est-à-dire incompressible. Les particules ne s'accumulent plus, elles circulent. En 2D, le curl est simple : on évalue le bruit de Perlin en trois points (position, position+ε en x, position+ε en y) et on calcule les dérivées partielles par différences finies. La vélocité résultante est perpendiculaire au gradient du bruit, ce qui crée des mouvements rotatifs naturels : ```cpp vec2 curlNoise(const Perlin& perlin, vec2 position, float time, float frequency, float epsilon = 0.001f) ``` Trois évaluations de Perlin 3D par particule par frame. C'est trois fois plus cher que l'évaluation unique du deuxième essai, mais le bruit de Perlin de Cinder est implémenté entièrement en arithmétique (pas de lookup texture, suivant le papier de McEwan & Gustavson), et à 15 000 particules ça reste sous la milliseconde. ### Le système complet : spawn, advection, rendu Le `ParticleController` final combine le curl noise avec quelques astuces tirées du chapitre 6 de GPU Gems ("Fire in the Vulcan Demo") : ```cpp void Particle::update(const Perlin& perlin, float fluidity, float turbulence, float strength) ``` L'astuce du `ageRatio` vient de l'observation du vrai feu : la base d'une flamme est stable, les extrémités sont agitées. On module l'intensité du curl par l'âge normalisé. Une particule jeune (proche de la balle) est presque pas perturbée. Une particule vieille (loin de la balle, sur le point de disparaître) est très agitée. Ça donne des volutes qui se désagrègent de manière crédible. Le spawn disperse les particules le long du vecteur vitesse de la balle pour créer une traînée continue : ```cpp void ParticleController::addFireTrail( unsigned amount, vec2& sourcePosition, vec2& sourceVelocity, float ballSize, unsigned lifeSpan) { for (unsigned i = 0; i < amount; ++i) } void ParticleController::addFireParticle( vec2& position, vec2& velocity, float ballSize, unsigned initialAge, unsigned lifeSpan) { vec2 rPosition = Rand::randVec2() * Rand::randFloat() * ballSize; vec2 pPosition = position + rPosition; vec2 pVelocity = velocity * 0.05f; addParticle(pPosition, pVelocity, lifeSpan, initialAge); if (particles_->size() >= config_->maxSimultaneousParticles()) } ``` ### Le rendu : additive blending et rampe de couleur Le rendu suit les recommandations de GPU Gems : additive blending (`GL_SRC_ALPHA, GL_ONE`) pour l'effet incandescent. Les particules qui se chevauchent s'additionnent en luminosité, ce qui crée un coeur brillant qui s'estompe vers les bords. La couleur suit une rampe liée à l'âge : blanc-jaune au centre (particule jeune), orange, puis rouge sombre (particule mourante) : ```cpp void ParticleController::draw() { if (config_->alphaBlendingEnabled()) glEnable(GL_PROGRAM_POINT_SIZE); gl::begin(GL_POINTS); for (auto& p : *particles_) gl::end(); gl::disableAlphaBlending(); } ``` 70 particules spawnées par frame, 15 000 simultanées max, durée de vie de 24 frames. On est en `GL_POINTS` avec `GL_PROGRAM_POINT_SIZE` pour que le vertex shader contrôle la taille de chaque point. Pour un rendu plus riche, on peut passer en mode texture : chaque point sample une texture circulaire floue, ce qui donne des disques lumineux qui se fondent entre eux. La couleur est configurable en live via le panel de paramètres (orange feu par défaut, bleu pour les events corporate, vert pour les tournois). ## La détection de but et l'enregistrement Le but est détecté quand la balle disparaît du champ de la caméra pendant plus de 2,3 secondes. Le côté (bleu/vert) est déterminé par la dernière position X connue de la balle (gauche ou droite du terrain). C'est plus simple que ce qu'on fait avec les capteurs physiques sur le vrai babyfoot Tekbak, mais suffisant pour le prototype d'arbitrage vidéo. À chaque frame, un `CaptureCircularRecorder` garde les 60 derniers frames dans un buffer circulaire. Quand un but est détecté, on sauvegarde ce buffer en vidéo. 60 frames à 24fps, ça donne 2,5 secondes de slow motion. Le fichier est ensuite convertible en GIF pour le partage social. ```cpp // Buffer circulaire templaté par sa taille CaptureCircularRecorder captureSlowmo; ``` Le calcul de vitesse échantillonne la position toutes les 100ms, convertit le déplacement en pixels en km/h via un ratio focal calibré aux dimensions du terrain (122cm x 70cm), et applique un lissage exponentiel (80% valeur courante, 20% valeur précédente) pour éviter les à-coups. ## Le C++ moderne, pour de vrai Refaire du C++ après des années de langages haut niveau, c'est retrouver un vieux ami qui a bien changé. Le C++ de 2017 n'a plus grand-chose à voir avec celui de ma thèse. Pas un seul `new` / `delete` dans tout le projet. Les ressources sont gérées par `unique_ptr` (propriété exclusive) et `shared_ptr` (la configuration partagée entre tous les composants). Le `Tracker` possède son `BallDetector` et son `BallPositionFilter`, le `ParticleController` est possédé par l'app principale. La durée de vie de chaque objet est claire dans le code. Les lambdas simplifient le code algorithmique. Le `min_element` avec un comparateur custom pour trouver le contour le plus proche de la position précédente : en C++03, c'était un foncteur séparé ou un `bind2nd` illisible. En C++14, c'est une lambda inline avec `auto` : ```cpp auto it = std::min_element(begin(candidates), end(candidates), [&](auto l, auto r) ); ``` Le `static_assert` avec `is_base_of` pour les factory methods : ```cpp template void setRenderingMode(Args&&... args) ``` Si quelqu'un essaie de passer un type qui n'hérite pas de `RenderingMode`, ça ne compile pas, avec un message d'erreur lisible. C'est du C++ générique qui protège sans coûter quoi que ce soit au runtime. Et les `constexpr` pour les constantes du projet : ```cpp constexpr auto FOOSBALL_TABLE_LENGTH_CM = 122; constexpr auto FOOSBALL_TABLE_WIDTH_CM = 70; constexpr auto DELAY_FOR_GOAL_TO_BE_DETECTED_MILLISECONDS = 2300; constexpr auto GOAL_TIMER_SECONDS = 3.0; constexpr unsigned SLOWMODE_TIME = 60; ``` Des vrais entiers évalués à la compilation, pas des `#define` du préprocesseur. Le debugger sait ce que c'est, le compilateur peut les optimiser, et on n'a pas de problème de parenthèses manquantes. ## La croix du cross-platform Le CMake est configuré en C++14 (`set(CMAKE_CXX_STANDARD 14)`), mais on utilise `optional` de C++17 via le header de compatibilité. Sur Mac, Clang accepte `-std=c++1z` pour les features expérimentales. Sur Linux, GCC 7 est conforme. Sur Windows, MSVC 2017 supporte environ 75% du C++17, et pas toujours les mêmes 75%. Le vrai problème, c'est les `.mm`. Sur macOS, le point d'entrée est en Objective-C++ (`main.mm`) parce que Cinder utilise AVFoundation pour la capture vidéo, et AVFoundation est une API Objective-C. On ne peut pas l'éviter. Ça veut dire que le même fichier source mélange du C++ moderne (lambdas, templates, smart pointers) et de l'Objective-C (AVCaptureDevice, NSLog). Le compilateur s'en sort, mais l'IDE moins bien. La compilation de Cinder lui-même est un chantier. Le `setup.sh` clone le repo Cinder, applique un patch (`fix_format.patch`), compile la librairie, puis compile OpenCV à côté. Sur Mac c'est Xcode + CMake. Sur Linux c'est CMake seul. Sur Windows, c'est Visual Studio avec des fichiers `.sln` générés par CMake. Trois toolchains, trois séries de flags, trois façons de linker OpenCV. On a aussi buté sur le plafond OpenGL de macOS. Apple bloque à OpenGL 4.1 et ne bougera probablement pas (les rumeurs parlent d'un remplacement complet par Metal). Cinder 0.9 cible OpenGL 3.2 Core Profile, ce qui passe, mais certaines features GL 4.x qu'on aurait voulu utiliser pour les particules (compute shaders, SSBO) ne sont pas disponibles sur Mac. On s'en tient aux geometry shaders et à l'additive blending classique. ## Ce qu'on livre Le prototype d'arbitrage vidéo détecte les buts par vision et les injecte dans le système Tekbak via l'API existante. Ça permet d'équiper des babyfoot standards (sans l'électronique Tekbak) pour les tournois. La fiabilité n'est pas au niveau des capteurs physiques, les occlusions et les conditions d'éclairage compliquent les choses, mais pour un contexte contrôlé avec une bonne caméra et un éclairage stable, ça tient. Le module d'effets fait la diffusion live des matchs : la caméra filme le terrain vu de dessus, le tracker suit la balle, Cinder affiche les flammes quand elle accélère, un flash au moment du but, le score incrusté. Le tout sort sur un projecteur ou un écran à côté du terrain. Et à chaque but, les 2,5 secondes précédentes sont sauvegardées en slow motion pour générer des GIFs. Tekbak peut les publier sur les réseaux sociaux, taguer les joueurs, alimenter le côté communautaire de leur plateforme. Pour moi, ce projet a surtout été l'occasion de remettre les mains dans du C++ après des années. Le langage a tellement changé depuis ma thèse que ça ressemble presque à un nouveau langage. On écrit du C++ qui ressemble à du code de haut niveau, mais qui compile en natif et tourne à 120fps sur un flux vidéo. Retrouver ça m'a donné envie de refaire du C++ plus souvent.