Outils pour utilisateurs

Outils du site


diy:projets:chromakey

Ceci est une ancienne révision du document !


Chroma key

Préambule

Ce petit tutoriel de réalisation d'une chroma key en C++ se veut abordable par toute personne familière avec des notions basiques de C++ telle que la surcharge d'opérateurs (pour ne citer que ça). En effet, ce tutoriel n'est pas un tutoriel de C++ et par conséquent je ne m'attarderai pas sur les spécificités du langage, il est donc préférable que vous soyez déjà familier avec celui-ci. Vous n'avez cependant aucune raison de paniquer, le principe d'une chroma key reste relativement simple à comprendre, ce tutoriel ne demandera pas de connaissances très approfondies.

Il est important de noter que vous aurez besoin d'une installation d'OpenCV sur votre raspberry (j'ai utilisé tout au long du développement la version 3.3.0) et d'un compilateur C++ (de préférence à jour). De plus, si vous ne travaillez pas directement sur le raspberry (mais par exemple en ssh), je met à votre disposition un programme permettant de visualiser les images traitées aussi simplement que possible, ou, pour plus d'explications, la rubrique “Vous travaillez à distance” de la partie "Le reste du code"). Ce programme nécessite une installation d'OpenCV sur votre pc, et netcat sur votre pc et le raspberry.

Pour un rendu aussi fluide que possible, il est fortement recommandé d'utiliser si possible un raspberry 3. Vous devrez également posséder une caméra pour raspberry pi (veillez à ce que le module caméra soit activé sur votre raspberry).

A défaut d'avoir une caméra à disposition, vous pouvez traiter une vidéo déjà enregistrée.


Introduction

Définition

Une chroma key, ou incrustation en français, est une technique d'effets spéciaux utilisée notamment au cinéma et la télévision (notamment lors des présentations météo) qui consiste à incruster une image dans une autre.

Capture d'écran de deux flux vidéos, avec et sans traitement

Vous serez capable d'obtenir ce genre de résultats avec un minimum d'éclairage et un simple un mur blanc, cependant, pour un résultat proche de la perfection (on voit clairement que certaines parties n'ont pas été transformées comme on l'aurait voulu, parce que le fond n'est pas unis et que l'éclairage est moyen), vous aurez besoin de bonnes conditions d'éclairage et d'un vrai fond unicolore.

C++: le pourquoi du comment

Aujourd'hui, une bibliothèque comme OpenCV est utilisable dans de nombreux langages de programmation: le C++, le C, le java, le python et bien d'autres. Sachant cela et les facilités qu'on aurait eu pour écrire l'algorithme de la chroma key en python, pourquoi choisir le C++ ? La réponse est simple: les performances. En effet, il sera nécessaire dans notre programme d'itérer sur une image (qui est une matrice) et bien que ce soit réalisable très simplement en python (comme dans bien d'autres langages), le C++ nous offre les accès mémoires les plus rapides (équivalents à ceux du C) et nous permet d'itérer aussi rapidement que possible sur notre image, ce qui est primordial si on veut obtenir le rendu le plus fluide possible. De plus, on profite en C++ d'OpenMP (un moyen parmis d'autres de paralléliser du code), qui est extrêmement simple d'utilisation et qui va nous permettre de réduire drastiquement le temps nécessaire pour traiter une image (le raspberry utilisée possède 4 coeurs, utilisons les !).

Maitenant trêve de bavardages, passons à l'algorithme général.

Algorithme général

Vous allez voir, je ne vous ai pas menti en vous disant que le principe était très simple.

En effet, pour réaliser une incrustation, on a simplement besoin de remplacer dans l'image filmée par notre caméra (ou notre vidéo, peu importe) les pixels qui se rapproche suffisamment d'un code couleur choisis (traditionnelement du vert ou du bleu) par ceux de l'image qui servira de fond. On a donc simplement besoin de parcourir notre image (qui est une matrice de pixels), de vérifier la couleur contenue dans chaque case et de modifier celle-ci si nécessaire.

Notez que pour le fond, nous pourrions très bien utiliser une vidéo, mais ce serait au prix d'un nombre non négligeable de FPS (Frame Per Second, images par secondes).


Développement du programme

La compilation

Pour ne pas vous embêter avec la compilation de votre code, je vous recommande fortement d'utiliser le makefile mis à votre disposition ici, notamment parce qu'il inclut toutes les options de compilation nécessaire pour compiler un programme utilisant OpenCV. Ce makefile requiert que votre fichier soit nommé chroma_key.cpp, mais vous êtes évidemment libre de modifier ça à votre convenance. Vous devrez également veiller à ce que ce soit bien votre compilateur C++ qui soit renseigné dans le makefile (variable CC). L'exécutable généré sera nommé chrk.

La fonction de traitement

La fonction de traitement (qui est en quelque sorte le coeur de notre code) se contente de traduire scrupuleusement l'algorithme général (avec quelques subtilitées utiles pour les performances). Pour définir les paramètres votre fonction, sachez que dans OpenCV une image est de type Mat. (une version complète du code est disponible sur mon github) (fichier chroma_key.cpp).

// frame: notre image à traiter
// background: notre image de fond
// B, G et R: les valeurs des trois canaux qui correspondent a la couleur 
// qu'on veut supprimer de l'image à traiter
// Fi: un pointeur vers la ligne i de notre image
// Bi: un pointeur vers la ligne i de notre fond
// offset: une tolérance utilisée pour améliorer le rendu car, en pratique, 
// un fond qui peut nous sembler unis ne l'est pas totalement une fois numérisé.
 
// On parcourt les lignes de l'image
for(int i = 0; i < frame.rows; i++) {
    // On récupère les pointeurs sur les lignes de nos images
    Vec3b *Fi = frame.ptr<Vec3b>(i); // Vec3b, car chaque pixel est représenté par 3 composantes
    const Vec3b *Bi = background.ptr<Vec3b>(i);
 
    // On parcourt les colonnes de l'image
    for(int j = 0; j < frame.cols; j++) {
        // Si notre couleur (codée en BGR dans OpenCV) correspond à la couleur "cible"
        if(inBounds(Fi[j][0], B, offset) && inBounds(Fi[j][1], G, offset) && inBounds(Fi[j][2], R, offset)) {
            // On remplace les trois composantes du pixel de notre image par celles du pixel de notre fond
            for(int k = 0; k < 3; k++) {	
                Fi[j][k] = Bi[j][k];
            }
        }
    }
}

Je dois admettre qu'à première vue ça pique un peu, surtout si vous débutez en C/C++ et que le principe des pointeurs ne vous est pas familier. Mais je vous assure qu'en terme de performances, s'embêter avec des pointeurs en vaut la peine. Le seul “risque” qu'on court en les utilisant c'est de voir apparaître des erreurs de segmentation si on ne fait pas attention au parcourt qu'on effectue.

Dans le cas où les pointeurs vous rebuteraient sérieusement, vous pouvez jeter un oeil à la méthode at des matrices dans la documentation d'OpenCV, mais attention, vous mettez au placard un nombre non négligeable de FPS et c'est une des raisons qui nous a fait choisir le C++.

Vous pouvez également voir que j'utilise dans ma condition une fonction nommée inBounds. Cette fonction prend en paramètre la valeur d'une composante d'un pixel, la valeur de notre couleur “à remplacer” et une valeur de tolérance (l'offset). Cette fonction est également disponible dans le fichier chroma_key.cpp sur mon github au cas où vous ne souhaiteriez pas l'écrire vous même.

Le reste du code

Pour le moment nous n'avons pas fait grand chose, mais en fait c'est presque terminé ! Et oui déjà, il nous reste quand même quelques bricoles à écrire avant d'avoir quelque chose de fonctionnel et utilisable, mais la fin n'est pas loin.

Parmis les choses qu'il nous reste à faire, nous devons ouvrir l'image qui servira de fond. Pour cela, on procède comme suit.

// On déclare simplement une matrice, 
// et on utilise la fonction imread d'OpenCV (cv::imread si vous n'utilisez pas le namespace cv) 
// pour charger l'image dans background
Mat background;
background = imread(argv[5]);

Comme vous pouvez le constater, j'ai fait le choix de faire passer le chemin vers mon image de fond en paramètre de mon programme, libre à vous de faire comme il vous plaît.

Nous allons maintenant accéder à la caméra de notre raspberry. Vous devrez veiller à ce que la résolution de la caméra soit la même que la taille de l'image que vous avez choisie comme fond, sous peine de devoir redimensionner votre fond ou de vous hurter à des “segmentation fault”. J'ai personnellement choisi de définir la résolution avec un define et je compare ensuite les valeurs de largeur et de hauteur que j'ai défini avec le nombre de lignes et de colonnes de la matrice background (sachant que le nombre de lignes de la matrice est donné par background.rows et le nombre de colonnes par background.cols).

Mat frame;
VideoCapture cam(0);
cam.set(3, WIDTH); // Cette ligne permet de définir la largeur des images capturées
cam.set(4, HEIGHT); // Même chose que ci-dessus, mais cette fois pour la hauteur des images

Sur un raspberry 3, avec GCC (et donc g++) version 6.3.0, la norme C++14 et l'option de compilation -O3 j'obtiens des performances tout à fait satisfaisante en 1280*720.

Notre caméra est maintenant en train de filmer, et on a déclaré une matrice frame pour récupérer les images. D'ailleurs, pour récupérer les images, rien de plus simple.

do {
   cam >> frame;
} while(frame.empty());

Avec OpenCV, l'opérateur » permet de lire une image depuis une VideoCapture. Cet opérateur est strictement équivalent à la fonction read (cv::read, je vous invite à consulter la documentation d'OpenCV si vous souhaitez en savoir plus). Je fais appel à cet opérateur dans une boucle parce qu'il est possible si on lit les images trop rapidement qu'il n'y ait pas d'images disponible. Dans ce cas la, les données de la matrice frame ne serait pas allouées, et le programme planterait.

On doit maitenant traiter la frame qu'on à récupérer avec notre fonction de traitement (nommée chromakey dans mon code).

chromakey(frame, B, G, R, offset, background);

J'ai également choisi de faire passer les valeurs de B, G, R (la couleur à remplacer) et de l'offset en paramètres du programme. Cela permet de ne pas avoir à recompiler le programme quand on est en train de peaufiner nos réglages (et par réglages, je parle de la couleur et de la tolérence).

Vous n'avez plus qu'à répéter la lecture des frames et le traitement autant de fois que vous le souhaitez (vous utiliserez donc une boucle).

Si vous en êtes arrivé là vous avez quasiment terminé: votre programme peut lire des images et les traiter autant que vous le souhaitez. Il reste cependant un dernier petit détail à régler. Si vous lancez votre programme maintenant, vous ne verrez rien et c'est tout à fait normal parce que nous n'avons absolument rien fait pour visualiser nos images pour le moment.

Ce qui nous amène à la dernière étape du développement de la chroma key. Deux cas de figure s'offrent à nous:

  • Vous travaillez directement sur le raspberry:

Dans ce cas là il ne vous reste plus qu'à ajouter les 3 lignes permettant d'afficher une image dans une fenêtre avec OpenCV. Pour cela, on utilisera les fonctions:

  1. 1 cv::imshow: cette fonction prend en argument un string qui sera le nom de la fenêtre et une image qui sera dans celle-ci.
  2. 2 cv::waitKey: cette fonction prend en argument une durée (en milisecondes) pendant laquelle le programme attendra un input clavier (nous n'utiliserons pas cette fonctionnalité, mais il est nécessaire d'appeler cette fonction pour que les images apparaissent, contentez vous d'une durée courte, de 1 à 10 ms par exemple).
  3. 3 cv::destroyAllWindows: qui se chargera de détruire toutes les fenêtres ouvertes par votre programme.

Comme vous vous en doutez peut être, les deux premières fonctions citées ci-dessus doivent être appelées dans votre boucle après le traitement de votre image. La dernière doit simplement être appelée une fois en fin de programme pour “nettoyer”.

Vous devriez obtenir un main structuré de cette manière:

Tant que(votre condition d'arrêt):
|   Récupérer l'image;
|   Traiter l'image;
|   
|   cv::imshow(l'image);
|   cv::waitKey(un petit temps d'attente);
    
cv::destroyAllWindows();

Il est important de noter que votre raspberry doit être capable d'ouvrir des fenêtres graphiques.

  • Vous travaillez à distance:

Dans ce cas là, c'est un peu plus compliqué. Comme j'en ai brièvement parlé au début de ce tutoriel, vous aurez besoin pour vous simplifier la vie d'un petit programme qui s'appelle netcat qui va nous permettre de faire passer nos images sur le réseau. Vous aurez besoin qu'il soit installé sur votre poste et sur votre raspberry. Vous allez également devoir rajouter les lignes suivantes à la fin de votre boucle:

imencode(".jpg", frame, buffer, jpg_parameters);
const unsigned int size = buffer.size();
fwrite(&size, sizeof(unsigned int), 1, stdout);
fwrite(buffer.data(), sizeof(uchar), size, stdout);

Ainsi que celles-ci avant la boucle:

std::vector<int> jpg_parameters;
jpg_parameters.push_back(CV_IMWRITE_JPEG_QUALITY);
jpg_parameters.push_back(75);
std::vector<uchar> buffer; 

Sans rentrer dans les détails parce que ce n'est pas cette partie du programme qui nous intéresse, on compresse les images traitées en jpeg pour les transmettre sur le réseau et les afficher en exécutant un autre programme sur le poste sur lequel vous travaillez. Cette affichage nécessitera comme annoncé au début de ce tutoriel une installation d'OpenCV et un compilateur C++ sur votre machine (et n'oubliez pas netcat). Le code du programme à exécuter est disponible ici, il est accompagné du makefile permettant de le compiler.

Si vous êtes curieux de savoir ce qui se passe vraiment ici, je vous invite à consulter la documentation d'OpenCV.

Une fois vos deux programmes prêts à être exécuté, il ne vous reste plus qu'à ouvrir un terminal sur votre pc et un terminal sur votre raspberry et à exécuter sur votre poste:

netcat -l -p un_numéro_de_port | bin/dbgrm

Et sur votre raspberry:

bin/chrk paramètres_de_la commande | netcat ip_de_votre_poste le_même_numéro_de_port

Vous avez terminé ! Cependant, vous êtes probablement déçu de la fluidité de la vidéo si vous utilisez la 2ème méthode. Pour comprendre pourquoi le framerate est si faible (3 à 10 FPS chez moi), je vous invite à continuer votre lecture.

Les performances

En travaillant sur le raspberry

Dans un premier temps, intéressons nous à la première méthode. Je ne pourrai malheureusement pas tester rigoureusement cette méthode, parce qu'il me faudrait installer X sur mon raspberry, mais on peut cependant supposer que les performances en effectuant un simple affichage via OpenCV ne causerait pas de perte drastique de performance.

Si vous mesurez le temps nécessaire pour traiter une image sans rien afficher, vous observerez qu'on reste très proche des 30 images par secondes sans difficulté, sachant que par défaut le framerate de notre caméra est fixé a 30 FPS, on observe quasiment aucune perte de performance. Si on débloque le framerate de notre caméra (via la méthode set de cam, cam.set(CV_CAP_PROP_FPS, 90), on fixe le framerate suffisamment haut), on observe qu'en 1280×720 notre caméra est capable de sortir 50 à 60 FPS (il suffit de commenter le traitement pour obtenir cette valeur). Si on refait cette mesure avec le traitement, on observe un framerate de 45 à 50 FPS. Des performances tout à fait honorable et qui suffisent à avoir un rendu fluide.

On peut bien sûr imaginer qu'en affichant la vidéo (avec la première méthode) les performances seront légèrement en baisse, mais on aura sans aucun doute toujours un rendu fluide.

En travaillant à distance

C'est ici que les choses se gâtent. En effet, comme vous l'avez sans doute vu s'y vous avez lancé le flux, les performances sont grandements réduites avec cette méthode (on a de fortes oscillations entre 3 et 10 FPS sur mon raspberry). Cela est dû au fait que la compression en jpeg, bien que relativement rapide, n'est pas adaptée à du streaming vidéo.

Si vous tenez vraiment à rendre cette méthode d'affichage utilisable (par utilisable, j'entend capable de délivrer environ 30 FPS), vous pouvez vous tourner vers des librairies de compression jpeg telles que libjpeg-turbo. Attention cependant, rien ne vous garantie que de telles librairies seront suffisamment efficace pour obtenir un rendu correct. Il devrait également être possible d'utiliser des formats dédiés au streaming vidéo, mais on s'hurterai sans doute à de nombreuses difficultés en voulant intégrer ce genre de formats à notre programme et ce n'est pas le but premier de ce tutoriel. J'ai testé de nombreuses méthodes pour retransmettre un flux vidéo de mon raspberry à mon pc (notamment la redirection des fenêtres graphique avec l'option -X de ssh qui a été inefficace, à cause du chiffrement) et c'est cette façon de faire qui s'est avérée la plus efficace.

Pour résumer brièvement, vous ne serez pas capable en travaillant sur un raspberry d'obtenir un rendu réellement fluide en utilisant une méthode aussi simple que celle que je met à votre disposition pour déporter votre affichage. Il existe sans aucun doute de nombreux moyens bien plus efficaces pour créer un flux vidéo, mais ceci sont sans aucun doute beaucoup plus élaborés. Vous serez en revanche tout à fait capable d'avoir un rendu fluide si vous travaillez directement sur un raspberry et que celui-ci est capable d'afficher des interfaces graphiques. Vous pouvez cependant voir la seconde méthode comme un moyen pratique de visualiser votre travail.


Sources du projet

Sources du projet

Comme je l'ai répété à plusieurs reprises tout au long de ce tutoriel, les sources de tout le projet sont disponibles à cette adresse.

Cela inclus à la fois la partie capture/traitement de la vidéo et la partie affichage (étant donné que j'ai utilisé la seconde méthode d'affichage), respectivement dans les répertoires ChromaKey et RemoteDisplay.


Auteur: Sylvain D

diy/projets/chromakey.1529916648.txt.gz · Dernière modification : 2018/06/25 08:50 de sdurand