======= 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 [[https://wiki.ensfea.fr/doku.php?id=diy:projets:chromakey#le_reste_du_code|"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.
{{:diy:projets:chromakey_beforeafter.png?600|}}
// 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'[[https://fr.wikipedia.org/wiki/OpenMP|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(i); // Vec3b, car chaque pixel est représenté par 3 composantes
const Vec3b *Bi = background.ptr(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 jpg_parameters;
jpg_parameters.push_back(CV_IMWRITE_JPEG_QUALITY);
jpg_parameters.push_back(75);
std::vector 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 1280x720 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 [[https://libjpeg-turbo.org/|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
#include
#include
#include
#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(i);
const Vec3b *Bi = background.ptr(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 jpg_parameters;
jpg_parameters.push_back(CV_IMWRITE_JPEG_QUALITY);
jpg_parameters.push_back(75);
std::vector 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 elapsed = std::chrono::high_resolution_clock::now() - start;
std::cerr << "\rFPS: " << 1/elapsed.count();
}
std::cerr << std::endl;
return ALL_GOOD;
}
=== "Récepteur" ===
#include
#include
#include
// 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 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//