Cours | TD-TP

Introduction au traitement d'images avec OpenCV

C++ OpenCV 4 4 TD-TP

Le traitement d'images numériques est un domaine vaste, et les capteurs optiques produisent aujourd'hui des volumes d'images considérables. L'objectif de ce cours est d'apprendre les bases du traitement d'images, puis de les mettre en pratique dans un projet.

Ressources

Étape 0

Bases d'OpenCV

Une image est stockée dans un objet cv::Mat : une matrice de pixels. Son type combine la profondeur (nombre de bits et signe de chaque valeur) et le nombre de canaux (1 pour une image en niveaux de gris, 3 pour une image couleur). Par exemple, CV_8UC3 désigne 3 canaux d'entiers non signés sur 8 bits, soit des valeurs de 0 à 255.

Valeurs entières des types cv::Mat disponibles :

ProfondeurC1C2C3C4
CV_8U081624
CV_8S191725
CV_16U2101826
CV_16S3111927
CV_32S4122028
CV_32F5132129
CV_64F6142230

La valeur se calcule ainsi : type = profondeur + 8 × (canaux − 1). C'est l'entier renvoyé par img.type().

Étape 1

Lecture et affichage d'une image

cv::imread charge une image depuis un fichier. Si le chemin est faux, la fonction ne lève pas d'erreur : elle renvoie une matrice vide. Il faut donc toujours tester empty() avant d'utiliser l'image.

#include <opencv2/opencv.hpp>
#include <iostream>

using namespace cv;

int main() {
    Mat img = imread("Test_Images/lincoln.png", IMREAD_GRAYSCALE);
    if (img.empty()) {
        std::cerr << "Impossible de lire l'image" << std::endl;
        return 1;
    }

    std::cout << "Taille : " << img.rows << " lignes x " << img.cols << " colonnes" << std::endl;
    std::cout << "Canaux : " << img.channels() << ", type : " << img.type() << std::endl;

    imshow("Image", img);
    waitKey(0);  // attend l'appui sur une touche

    imwrite("copie.png", img);  // le format est déduit de l'extension
    return 0;
}

Le second argument de imread fixe la façon dont l'image est chargée :

OptionRésultat
IMREAD_COLORCouleur sur 3 canaux, dans l'ordre BGR (valeur par défaut)
IMREAD_GRAYSCALENiveaux de gris sur 1 canal (CV_8UC1)
IMREAD_UNCHANGEDImage telle quelle, canal alpha compris

Pour compiler sous Linux ou macOS :

g++ main.cpp -o main $(pkg-config --cflags --libs opencv4)
./main

Exercice

Chargez veggies.png en couleur, affichez sa taille et son nombre de canaux, puis enregistrez sa version en niveaux de gris avec cvtColor.

Étape 2

Accès aux pixels avec des coordonnées

Portrait de Lincoln en basse résolution, avec la valeur de gris de chaque pixel
Une image en niveaux de gris n'est qu'une matrice de valeurs entre 0 (noir) et 255 (blanc).

L'origine se trouve en haut à gauche. Un pixel est repéré par sa ligne i (de 0 à rows − 1, vers le bas) et sa colonne j (de 0 à cols − 1, vers la droite).

Piège classique : img.at<uchar>(i, j) prend (ligne, colonne), alors que Point(x, y) prend (colonne, ligne). Autrement dit img.at<uchar>(i, j) == img.at<uchar>(Point(j, i)).

Image en niveaux de gris (CV_8UC1)

uchar v = img.at<uchar>(10, 20);        // ligne 10, colonne 20
img.at<uchar>(10, 20) = 255;             // le pixel devient blanc
std::cout << (int) v << std::endl;       // cast en int, sinon uchar s'affiche comme un caractère

Image couleur (CV_8UC3)

Vec3b p = img.at<Vec3b>(i, j);
uchar blue  = p[0];   // OpenCV range les canaux en BGR
uchar green = p[1];
uchar red   = p[2];

img.at<Vec3b>(i, j) = Vec3b(0, 0, 255);  // pixel rouge

Parcourir toute l'image

On parcourt les lignes, puis les colonnes. Exemple : le négatif d'une image.

Mat negatif = img.clone();
for (int i = 0; i < img.rows; i++) {
    for (int j = 0; j < img.cols; j++) {
        negatif.at<uchar>(i, j) = 255 - img.at<uchar>(i, j);
    }
}

Pour des images volumineuses, récupérer un pointeur sur chaque ligne évite de recalculer l'adresse à chaque pixel :

for (int i = 0; i < img.rows; i++) {
    uchar* ligne = img.ptr<uchar>(i);
    for (int j = 0; j < img.cols; j++) {
        ligne[j] = 255 - ligne[j];
    }
}

Exercice

Écrivez une fonction qui calcule l'histogramme d'une image en niveaux de gris (tableau de 256 cases), puis une fonction qui dessine un carré blanc de 20 × 20 pixels centré dans l'image.

Étape 3

Binarisation

Binariser une image, c'est séparer les pixels en deux classes (objet et fond) selon un seuil T. Chaque pixel devient noir ou blanc :

g(i, j) = 255 si f(i, j) > T, sinon 0

Seuillage manuel

Mat binariser(const Mat& img, uchar seuil) {
    Mat out(img.rows, img.cols, CV_8UC1);
    for (int i = 0; i < img.rows; i++) {
        for (int j = 0; j < img.cols; j++) {
            out.at<uchar>(i, j) = img.at<uchar>(i, j) > seuil ? 255 : 0;
        }
    }
    return out;
}

Avec OpenCV

Mat bin;
threshold(img, bin, 128, 255, THRESH_BINARY);

// Otsu : le seuil est calculé automatiquement à partir de l'histogramme
double t = threshold(img, bin, 0, 255, THRESH_BINARY | THRESH_OTSU);
std::cout << "Seuil d'Otsu : " << t << std::endl;

// Seuil adaptatif : un seuil local par voisinage de 11 x 11 pixels
adaptiveThreshold(img, bin, 255, ADAPTIVE_THRESH_GAUSSIAN_C, THRESH_BINARY, 11, 2);

Un seuil global échoue dès que l'éclairage n'est pas uniforme, ce qui est fréquent sur les photos de documents. Le seuil adaptatif compare chaque pixel à la moyenne pondérée de son voisinage, moins une constante (ici 2). La taille du voisinage doit être impaire.

Exercice

Binarisez doc1.jpg à doc4.jpg avec les trois méthodes et comparez les résultats. Puis implémentez vous-même la méthode d'Otsu à partir de l'histogramme de l'étape 2.

Étape 4

Filtres et convolution

Un filtre linéaire remplace chaque pixel par une combinaison pondérée de ses voisins. Les poids forment un noyau h, une petite matrice de taille impaire (3 × 3, 5 × 5…). Cette opération s'appelle la convolution :

g(i, j) = Σu Σv h(u, v) · f(i − u, j − v)

Noyaux classiques

111
111
111
Moyenne × 1/9 : lisse et réduit le bruit
121
242
121
Gaussien × 1/16 : lissage qui préserve mieux les contours
−101
−202
−101
Sobel X : contours verticaux
010
1−41
010
Laplacien : contours dans toutes les directions

Convolution manuelle

Le résultat peut sortir de l'intervalle [0, 255] (valeurs négatives avec Sobel, par exemple). On calcule donc en float (CV_32F). Les bords, où le noyau dépasse de l'image, sont laissés à 0 dans cette version.

Mat convolution(const Mat& img, const Mat& noyau) {
    // img : CV_8UC1, noyau : CV_32F de taille impaire
    int ri = noyau.rows / 2, rj = noyau.cols / 2;
    Mat out = Mat::zeros(img.rows, img.cols, CV_32F);

    for (int i = ri; i < img.rows - ri; i++) {
        for (int j = rj; j < img.cols - rj; j++) {
            float somme = 0;
            for (int u = -ri; u <= ri; u++) {
                for (int v = -rj; v <= rj; v++) {
                    somme += noyau.at<float>(ri + u, rj + v) * img.at<uchar>(i - u, j - v);
                }
            }
            out.at<float>(i, j) = somme;
        }
    }
    return out;
}

Mat moyenne = Mat::ones(3, 3, CV_32F) / 9.0f;
Mat sobelX = (Mat_<float>(3, 3) << -1, 0, 1,
                                    -2, 0, 2,
                                    -1, 0, 1);

Mat lisse, contours;
convolution(img, moyenne).convertTo(lisse, CV_8U);  // arrondi et saturation dans [0, 255]
convertScaleAbs(convolution(img, sobelX), contours); // valeur absolue puis conversion en 8 bits

Avec OpenCV

Mat out;
filter2D(img, out, -1, noyau);           // -1 : même profondeur que l'image source
blur(img, out, Size(3, 3));              // moyenne
GaussianBlur(img, out, Size(5, 5), 1.5); // gaussien, écart-type 1.5
medianBlur(img, out, 3);                 // médian (filtre non linéaire)

Mat gx, gy;
Sobel(img, gx, CV_16S, 1, 0, 3);         // dérivée selon x
Sobel(img, gy, CV_16S, 0, 1, 3);         // dérivée selon y

À savoir : filter2D calcule en réalité une corrélation (le noyau n'est pas retourné). Le résultat est identique pour les noyaux symétriques (moyenne, gaussien, Laplacien). Pour Sobel, le signe est inversé : retournez le noyau avec flip(noyau, noyau, -1) pour obtenir une vraie convolution.

Exercice

Sur fountainSaltPepper.png, comparez le filtre moyenne et le filtre médian : lequel supprime le bruit « poivre et sel » sans flouter les contours ? Puis calculez la norme du gradient √(gx² + gy²) avec vos deux noyaux de Sobel et binarisez-la pour obtenir une carte de contours.

Astuces

Créer une matrice initialisée à zéro de type T. T peut être le nom du type (CV_8UC3) ou sa valeur entière (16) :

Mat m = Mat::zeros(nb_lignes, nb_colonnes, T);

Accéder à un pixel couleur d'une image en CV_32FC3 avec le type Vec3f :

Vec3f p = img.at<Vec3f>(i, j);
float blue  = p[0];
float green = p[1];
float red   = p[2];

Convertir une image couleur en niveaux de gris (attention à l'ordre BGR) :

cvtColor(img_source, img_destination, COLOR_BGR2GRAY);

Convertir une image 8 bits en float, avec ou sans normalisation dans [0, 1] :

img_source.convertTo(img_destination, CV_32F);
img_source.convertTo(img_destination, CV_32F, 1.0 / 255);