Table des matières
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 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.
// 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).
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 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 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 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).
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 | dbgrm
Et sur votre raspberry:
chrk paramètres_de_la commande | netcat ip_de_votre_poste le_même_numéro_de_port
Le programme exécuter sur votre machine (ci-dessus) se contentera simplement de lire les données de chaque image sur stdin, de les décoder et de les afficher (cf rubrique “Sources”).
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
"Émetteur"
#include <iostream> #include <chrono> #include <opencv2/opencv.hpp> #include <omp.h> #define WIDTH 1280 #define HEIGHT 720 #define FRAME_SIZE WIDTH*HEIGHT*3 #define NUMBER_OF_FRAMES 150 #define ALL_GOOD 0 #define INVALID_ARGUMENTS -1 #define INVALID_IMAGE -2 // For a bit of comfort using namespace cv; // Simple function to test if a channel of a pixel (B, G or R) is within the interval [value - offset; value + offset] inline bool inBounds(int value, int bound, int offset) { return abs(value - bound) <= offset; } // Most important part of the code: // Replaces the pixels of the frame close enough (depending of the offset) to the color B, G, R inline void chromakey(Mat& frame, int B, int G, int R, int offset, const Mat& background) { // Using openMP was definitely worth on a Raspberry Pi 3 model B #pragma omp parallel for // Iterating on the rows of our frame for(int i = 0; i < frame.rows; i++) { // Using a pointer to the rows is the fastest way to access a matrix Vec3b *Fi = frame.ptr<Vec3b>(i); const Vec3b *Bi = background.ptr<Vec3b>(i); // Iterating on the elements of each row for(int j = 0; j < frame.cols; j++) { // If the pixel (i, j) of our frame is close enough to B, G and R, we replace it with the color of the background (i, j) pixel if(inBounds(Fi[j][0], B, offset) && inBounds(Fi[j][1], G, offset) && inBounds(Fi[j][2], R, offset)) { for(int k = 0; k < 3; k++) { Fi[j][k] = Bi[j][k]; } } } } } int main(int argc, char** argv) { // Just a simple parameters check if(argc != 6) { std::cerr << "Usage: ./chrk R G B offset /path/to/background_image" << std::endl; return INVALID_ARGUMENTS; } // This Mat contains our background Mat background; background = imread(argv[5]); // Testing that the images size is the same as defined // We could also just reshape the image, it would be a bit less restrictive if(!background.data || background.cols != WIDTH || background.rows != HEIGHT) { std::cerr << "ERROR: Invalid background image" << std::endl; return INVALID_IMAGE; } int B, G, R, offset; // Getting the parameters from the command line std::istringstream rr(argv[1]); std::istringstream rg(argv[2]); std::istringstream rb(argv[3]); std::istringstream rt(argv[4]); rb >> B; rg >> G; rr >> R; rt >> offset; // This Mat will contain the images Mat frame; // Initializing the capture, should be equivalent to: // VideoCapture cam(0); VideoCapture cam(0); // Setting the width and height of the capture as defined // If not set, default resolution is 640*480 cam.set(3, WIDTH); cam.set(4, HEIGHT); // Adding the OpenCV JPEG parameters to a vector, this is used to encode a Mat with non-default settings std::vector<int> jpg_parameters; jpg_parameters.push_back(CV_IMWRITE_JPEG_QUALITY); jpg_parameters.push_back(75); std::vector<uchar> buffer; for(int frame_count = 0; frame_count < NUMBER_OF_FRAMES; frame_count++) { const auto start = std::chrono::high_resolution_clock::now(); do { cam >> frame; } while(frame.empty()); chromakey(frame, B, G, R, offset, background); // Encoding the frame imencode(".jpg", frame, buffer, jpg_parameters); const unsigned int size = buffer.size(); // Writing the encoded image to stdout // unsigned int and uchar size are the same on ARM and 64 bits regular computer fwrite(&size, sizeof(unsigned int), 1, stdout); fwrite(buffer.data(), sizeof(uchar), size, stdout); // We use stderr to print an FPS counter and not because we are already writing images on stdout const std::chrono::duration<float> elapsed = std::chrono::high_resolution_clock::now() - start; std::cerr << "\rFPS: " << 1/elapsed.count(); } std::cerr << std::endl; return ALL_GOOD; }
"Récepteur"
#include <iostream> #include <chrono> #include <opencv2/opencv.hpp> // WIDTH, HEIGHT and NUMBER_OF_FRAMES should always be set to the same value as in the transmitter #define WIDTH 1280 #define HEIGHT 720 // Maximum size of the received frame // In theory, if jpeg doesn't compress the image at all, it could be larger than that because of the format overhead // I'm not sure it can ever happen, so, as this program should not be used for critical stuff (it really should not), I'm just gonna assume it won't happen #define FRAME_SIZE WIDTH*HEIGHT*3 #define NUMBER_OF_FRAMES 150 #define ALL_GOOD 0 int main(int argc, char **argv) { // Actual size of the received frame unsigned int size; uchar buffer[FRAME_SIZE]; cv::Mat frame; for(int frame_count = 0; frame_count < NUMBER_OF_FRAMES; frame_count++) { // Reading the encoded image from stdin // unsigned int and uchar size are the same on ARM and 64 bits regular computer fread(&size, sizeof(unsigned int), 1, stdin); fread(buffer, sizeof(uchar), size, stdin); std::vector<uchar> data(buffer, buffer+size); // Decoding the received frame frame = cv::imdecode(cv::Mat(data), cv::IMREAD_UNCHANGED); // Displaying the received frame cv::imshow("frame", frame); // This wait is mandatory for the image to actually display cv::waitKey(1); } return ALL_GOOD; }
Auteur: S. Durand