4  Morphologie mathématique et segmentation d’images

Ce chapitre présente deux thèmes fondamentaux du traitement numérique des images (TNI) : la morphologie mathématique et la segmentation d’images. La morphologie mathématique fournit un cadre théorique fondé sur la théorie des ensembles pour analyser, affiner et quantifier la forme des objets dans les images binaires et en niveaux de gris, au moyen d’opérateurs fondamentaux tels que l’érosion et la dilatation. La segmentation, quant à elle, vise à partitionner l’image en régions d’intérêt, en séparant les objets du fond et en produisant des représentations adaptées à l’analyse et à l’interprétation.

Le chapitre débute par le seuillage, l’une des techniques de segmentation les plus importantes, en introduisant la méthode automatique d’Otsu et en revisit l’analyse des histogrammes au moyen de la variance interclasses, présentée au chapitre 1. Ensuite, sont étudiés les principaux opérateurs de la morphologie mathématique, notamment l’érosion, la dilatation, l’ouverture, la fermeture et la reconstruction morphologique, qui permettent d’affiner les masques binaires et de préserver les structures pertinentes des objets. Enfin, sont présentées des techniques de segmentation fondée sur les régions, telles que l’étiquetage des composantes connexes, la transformée de distance et l’algorithme watershed basé sur les marqueurs, aboutissant à l’extraction de descripteurs géométriques et à la génération de bounding boxes compatibles avec les systèmes modernes de détection d’objets.

4.1 Objectifs

À la fin de ce chapitre, vous serez capable de :

  • Appliquer le seuillage : Comprendre le critère automatique d’Otsu par maximisation de la variance interclasse (\(\sigma_B^2\)) et sélectionner des stratégies de prétraitement appropriées pour faciliter la segmentation ;
  • Maîtriser la morphologie binaire : Comprendre et appliquer l’érosion (\(A\ominus B\)) et la dilatation (\(A\oplus B\)) comme opérateurs fondamentaux, en dérivant l’ouverture (\(A\circ B\)), la fermeture (\(A\bullet B\)) et les opérations basées sur la reconstruction morphologique, comme mm::clohole et mm::edgeoff ;
  • Appliquer la morphologie en niveaux de gris : Utiliser le gradient morphologique et les filtres top-hat pour le rehaussement et l’analyse de structures locales ;
  • Étiqueter les composantes connexes : Identifier et séparer les régions connectées dans des images binaires à l’aide d’algorithmes d’étiquetage ;
  • Appliquer la transformée de distance : Interpréter et calculer les distances au fond en utilisant des approches morphologiques et des métriques géométriques ;
  • Segmenter par régions : Construire des pipelines de segmentation basés sur des marqueurs utilisant la transformée de distance et l’algorithme watershed ;
  • Extraire des descripteurs géométriques : Calculer des propriétés telles que l’aire, le périmètre, le centroïde, la circularité et les bounding boxes à l’aide de mm::label0 et de l’extraction de contours ;
  • Relier le TNI et la vision par ordinateur : Comprendre comment les descripteurs extraits par segmentation peuvent être convertis en formats utilisés par les détecteurs modernes, comme YOLO.
import os, urllib.request

os.makedirs("tmp/state", exist_ok=True)  # artefacts de construction du parcours C++ (.cpp, binaire, PNGs)

url = "https://raw.githubusercontent.com/fzampirolli/pdi-vc/master/morph/config.py"
if not os.path.exists("config.py"):
    urllib.request.urlretrieve(url, "config.py")

# Le noyau est Python même dans le parcours C++ : `mm` (morph.py) est utilisé par les
# simulateurs, par l'affichage des figures que le binaire C++ génère et par
# l'état mm::Image entre les cellules. cpp=True télécharge aussi le parcours compilé
# (morph.hpp + stb_image*.h), utilisé dans le #include des cellules %%writefile *.cpp.
# Les cellules C++ de ce chapitre compilent AVEC OpenCV (-DMM_USE_OPENCV +
# pkg-config opencv4) : mm::dil/ero → cv::dilate/erode, donc open/close/asf/
# gradm/tophat/blackhat tournent en pleine résolution et correspondent bit à bit au parcours py.
import config
config.setup(cpp=True)
from morph import mm
import numpy as np
✅ Environnement prêt. Morph : 1.1.9 | OpenCV : 5.0.0

4.2 Seuilletage

Le seuilletage (thresholding) est l’une des formes les plus simples et efficaces de segmentation d’images. Son objectif est de classer chaque pixel en deux classes d’intensité, généralement associées à objet et fond :

\[ g(x,y) = \begin{cases} 255, & \text{si } f(x,y) > T \\ 0, & \text{sinon} \end{cases} \tag{4.1}\]

où \(f(x,y)\) représente l’intensité du pixel dans l’image originale et \(g(x,y)\) l’image binaire résultante.

Le choix du seuil \(T\) est important pour la qualité de la segmentation. La méthode d’Otsu détermine automatiquement le seuil optimal en maximisant la variance interclasses \(\sigma_B^2\) définie par :

\[ \sigma_B^2(T) = w_0(T)\,w_1(T)\, \bigl[\mu_0(T)-\mu_1(T)\bigr]^2 \tag{4.2}\]

où :

  • \(w_0(T)\) et \(w_1(T)\) sont les probabilités cumulées des classes fond et objet ;
  • \(\mu_0(T)\) et \(\mu_1(T)\) sont les moyennes d’intensité de ces classes ;
  • \(\sigma_B^2(T)\) représente la variance interclasses pour un seuil donné \(T\).

La méthode fonctionne mieux lorsque l’histogramme présente deux groupes d’intensités relativement séparés. Pour cela, l’algorithme évalue tous les seuils possibles de l’image — typiquement dans l’intervalle \([0,255]\) pour des images 8 bits — et sélectionne la valeur qui maximise la variance entre classes, notée \(\sigma_B^2\) :

\[ T^* = \arg\max_{T \in [0,255]} \sigma_B^2(T) \]

NoteOtsu suppose des histogrammes bimodaux

La méthode d’Otsu produit de meilleurs résultats lorsque l’histogramme présente deux pics bien définis (bimodalité), correspondant au fond et à l’objet. Plus la séparation entre ces pics est grande et plus le maximum de \(\sigma_B^2\) est prononcé, plus le seuil obtenu tend à être fiable.

Dans les images avec un éclairage non uniforme ou de multiples régions d’intensité, les techniques de seuilletage adaptatif — dans lesquelles le seuil est calculé localement — produisent généralement des segmentations plus robustes.

L’indice \(B\) dans \(\sigma_B^2\) signifie between classes (entre classes). Ainsi, \(\sigma_B^2\) représente la variance entre les classes (between-class variance).

4.2.1 Image de pièces de monnaie

L’image utilisée pour pratiquer la segmentation est une photographie d’une collection de pièces de monnaie de différents pays et époques (Figure 4.1). Crédit : GAZI.MD.AHAD (CC BY-SA 4.0). Elle présente des objets circulaires aux contours bien définis, ce qui la rend idéale pour illustrer le seuillage, les opérateurs morphologiques, la transformée de distance, le watershed et les descripteurs de forme.

%%writefile tmp/fig_04_coins.cpp
#define MM_OUT "tmp/fig_04_coins.png"
// Compile: g++ -std=c++17 -o program program.cpp -lopencv_core -lopencv_imgproc -lopencv_imgcodecs
#include "morph.hpp"
#include <iostream>
#include <filesystem>

int main() {
    //| label: fig-04-coins
    //| fig-cap: "Imagem com moedas de vários tipos. Crédito: GAZI.MD.AHAD (CC BY-SA 4.0)."
    //| echo: true

    // A imagem já está no diretório do capítulo (imagens/coins.png); a trilha
    // Python cuida do download/cache quando o notebook roda avulso.
    mm::Image img_coins_color = mm::read("imagens/coins.png");
    mm::Image img_coins_gray  = mm::gray(img_coins_color);

    std::cout << "Dimensões [y,x,c]: [" << img_coins_color.h << ", " 
              << img_coins_color.w << ", " << img_coins_color.channels << "]\n";
    mm::show(img_coins_color, MM_OUT);

    
// [pdi:state-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp/state");
mm::write(img_coins_gray, "tmp/state/img_coins_gray_8.png");
// [pdi:state-io:end]
return 0;
}
Overwriting tmp/fig_04_coins.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_coins.cpp -o tmp/fig_04_coins -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_coins \
  && test -f "tmp/fig_04_coins.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_coins.png"
Dimensões [y,x,c]: [2560, 1920, 3]
try:
    mm.show(mm.read("tmp/fig_04_coins.png"))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_coins.png (ver a versao Python)")
Figure 4.1: Imagem com moedas de vários tipos. Crédito: GAZI.MD.AHAD (CC BY-SA 4.0).

4.2.2 Prétraitement pour Otsu

La méthode d’Otsu repose sur un histogramme nettement bimodal. L’image des pièces présente un éclairage non uniforme et des pièces sombres proches du fond, ce qui gêne. Dans le parcours C++, on applique une égalisation globale d’histogramme (mm::equalize) avant le seuillage — la comparaison CLAHE × Gaussien et les courbes \(\sigma_B^2(T)\) se trouvent dans le parcours Python (Figure 4.2).

%%writefile tmp/fig_04_otsu_comparacao_histogramas.cpp
#define MM_OUT "tmp/fig_04_otsu_comparacao_histogramas.png"
// Compile: g++ -std=c++17 -o program program.cpp -I. -L. -lmorph

#include "morph.hpp"
#include <iostream>
#include <vector>
#include <string>
#include <filesystem>

int main() {
// [pdi:state-io] auto-generated — do not edit by hand
mm::Image img_coins_gray = mm::_read_state("tmp/state/img_coins_gray_8.png");
// [pdi:state-io:end]

    // img_coins_gray is already defined and valid here

    //| label: fig-04-otsu-comparacao-histogramas
    //| fig-cap: "CLAHE (realce adaptativo com limite de contraste) antes da limiarização de Otsu: original, realçado, histograma e binarização. (A trilha Python também traça as curvas de $\\sigma^2_B(T)$.)"
    //| echo: true
    //| output: true

    mm::Image img_clahe = mm::clahe(img_coins_gray, 2.0, 8);   // realce adaptativo com limite de contraste
    mm::Image img_gauss = mm::gaussian(img_clahe, 5, 0);

    mm::show(
        std::vector<mm::Image>{img_coins_gray, img_clahe, mm::histImg(img_clahe), mm::threshold(img_clahe)},
        MM_OUT,
        std::vector<std::string>{"Original", "CLAHE", "Histograma (CLAHE)", "Otsu"},
        4
    );

    
// [pdi:state-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp/state");
mm::write(img_clahe, "tmp/state/img_clahe_12.png");
// [pdi:state-io:end]
return 0;
}
Overwriting tmp/fig_04_otsu_comparacao_histogramas.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_otsu_comparacao_histogramas.cpp -o tmp/fig_04_otsu_comparacao_histogramas -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_otsu_comparacao_histogramas \
  && test -f "tmp/fig_04_otsu_comparacao_histogramas.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_otsu_comparacao_histogramas.png"
[1] Original
[2] CLAHE
[3] Histograma (CLAHE)
[4] Otsu
try:
    mm.show(mm.read("tmp/fig_04_otsu_comparacao_histogramas.png"))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_otsu_comparacao_histogramas.png (ver a versao Python)")
Figure 4.2: CLAHE (realce adaptativo com limite de contraste) antes da limiarização de Otsu: original, realçado, histograma e binarização. (A trilha Python também traça as curvas de \(\sigma^2_B(T)\).)

4.2.3 Résultat : CLAHE comme meilleur prétraitement

L’analyse de la Figure 4.2 indique que le CLAHE a obtenu la valeur la plus élevée de variance inter-classes (\(\sigma_B^2 \approx 2{,}47 \times 10^3\)), avec un seuil optimal \(T^* = 122\). Bien que la combinaison CLAHE+Gaussien ait produit un résultat très similaire (\(\sigma_B^2 \approx 2{,}43 \times 10^3\), \(T^* = 123\)), le critère quantitatif de la méthode d’Otsu favorise légèrement l’utilisation du CLAHE seul.

Sur le plan visuel, les images binarisées obtenues avec le CLAHE et le CLAHE+Gaussien sont pratiquement équivalentes. La différence entre les deux approches devient plus évidente dans l’analyse des histogrammes et des valeurs de \(\sigma_B^2(T)\) que dans l’inspection directe des segmentations résultantes. Ainsi, le choix du CLAHE repose principalement sur la maximisation de la séparation statistique entre les classes de fond et d’objet.

AstuceInterprétation des résultats

Remarquez que les prétraitements avec CLAHE et CLAHE+Gaussien produisent des histogrammes et des seuils optimaux très proches (\(T^*=122\) et \(T^*=123\)). Par conséquent, les images binarisées résultantes sont également très similaires. Dans ce cas, la décision ne repose pas sur des différences visuelles marquantes, mais sur le critère objectif de la méthode d’Otsu : la valeur la plus élevée de \(\sigma_B^2\) indique la meilleure séparation entre les classes.

4.3 Morphologie mathématique

La morphologie mathématique est une théorie basée sur les ensembles, utilisée pour analyser la forme et la structure des objets dans les images. Contrairement aux filtres linéaires présentés au chapitre 3, les opérateurs morphologiques sont non linéaires, car ils reposent sur des opérations de minimum, de maximum et d’inclusion spatiale, plutôt que sur des combinaisons linéaires d’intensités. Ces opérateurs agissent sur le voisinage de chaque pixel au moyen d’un élément structurant \(\mathbb{B}\), chargé de définir la forme et la taille de la région analysée.

Dans les images binaires et en niveaux de gris avec des éléments plans, l’élément structurant translaté à la position \(x\) est défini spatialement comme :

\[ \mathbb{B}_x = \{ x + b \mid b \in \mathbb{B} \} \]

Dans les régions de bord de l’image, une partie de l’ensemble \(\mathbb{B}_x\) peut dépasser le domaine physique de la scène (\(\mathbb{E}\)). Pour garantir la cohérence mathématique des opérateurs primitifs à ces frontières, on suppose théoriquement que l’espace extérieur au domaine de l’image est rempli avec l’élément neutre de l’opération correspondante (infini positif pour l’érosion et infini négatif pour la dilatation), empêchant ainsi que l’environnement externe corrompe les structures internes de l’objet.

Lorsque l’élément structurant associe des poids à ses éléments — c’est-à-dire \(b: \mathbb{B} \to \mathbb{Z}\) — il est appelé fonction structurante ou élément structurant non plan.

Développée par Matheron et Serra dans les années 1960 pour les images binaires, puis étendue aux niveaux de gris, la morphologie mathématique fonde des opérateurs tels que le gradient morphologique, le top-hat, le watershed et la transformée de distance, tous dérivés de deux primitifs : l’érosion et la dilatation [Matheron (1975); Serra (1982)].

4.3.1 Érosion et Dilatation

Les deux opérateurs primitifs sont définis de manière unifiée pour les images en niveaux de gris (\(f: \mathbb{E} \to \mathbb{Z}\)) et, par restriction au domaine \(\{0,1\}\), également pour les images binaires.

4.3.1.1 Érosion

L’érosion d’une image \(f\) par un élément structurant \(b: \mathbb{B} \to \mathbb{Z}\) est définie formellement par :

\[ \varepsilon_b(f)(x) = (f \ominus b)(x) = \min_{z \in \mathbb{B}}\{\, f(x + z) - b(z) \,\}, \quad \forall\, x \in \mathbb{E} \tag{4.3}\]

En pratique, l’érosion remplace l’intensité du pixel \(x\) par la valeur minimale résultant de la différence entre l’image et l’élément structurant dans le voisinage défini par le domaine \(\mathbb{B}\). Des valeurs positives dans les poids de \(b(z)\) forcent le résultat local vers le bas, « creusant » plus profondément le relief de l’image et intensifiant l’érosion.

Dans le cas plat (où les poids sont nuls à l’intérieur du domaine, c’est-à-dire \(b \equiv 0\)), l’expression se simplifie en le minimum local pur :

\[ \varepsilon_B(f)(x) = \min\{\, f(y) : y \in \mathbb{B}_x \,\} \]

Dans les images binaires, cette opération équivaut à exiger que l’ensemble \(\mathbb{B}\), translaté à la coordonnée \(x\), soit complètement contenu dans l’objet \(A\) :

\[ A \ominus \mathbb{B} = \{\, z \in \mathbb{E} \mid \mathbb{B}_z \subseteq A \,\} \]

Effet visuel : Rétrécit les objets et les structures claires, éliminant les protubérances, les pics brillants ou les bruits qui sont géométriquement plus petits que le domaine \(\mathbb{B}\).

4.3.1.2 Implémentation de l’érosion

La version didactique mm::ero0 implémente le cas particulier de l’érosion avec élément structurant plat. Pour chaque pixel \((y,x)\), la fonction parcourt les voisins spatiaux autorisés par \(B\) et stocke la plus petite valeur trouvée dans l’image d’entrée \(f\), reproduisant directement l’opération de minimum local décrite dans la Équation 4.3 pour \(b \equiv 0\).

Notez que les valeurs des voisins sont toujours lues de manière statique depuis l’image originale \(f\) ; la matrice de sortie \(g\) est utilisée exclusivement pour enregistrer le minimum accumulé du voisinage courant. Ainsi, le résultat final est invariant par rapport à l’ordre de balayage des pixels (que ce soit par lignes ou par colonnes).

La fonction auxiliaire _viz calcule les coordonnées des voisins valides dans les limites physiques de l’image. Sur les bords, l’initialisation de l’accumulateur à 255 imite avec exactitude le remplissage par élément neutre exigé par la théorie. Quant à la fonction d’interface mm::ero, elle recourt à l’implémentation native et optimisée d’OpenCV (mm::ero) lorsque l’élément structurant est plat, basculant vers la routine générale mm::ero1 si l’élément possède des poids topographiques.

L’exemple computationnel suivant illustre l’application d’un élément structurant en croix (mm::secross()) mis en évidence dans la Figure 4.3, en comparant l’exécution de la variante didactique en boucle (mm::ero0) avec le moteur computationnel d’OpenCV (mm::ero).

%%writefile tmp/fig_04_elemento_cruz.cpp
#define MM_OUT "tmp/fig_04_elemento_cruz.png"
//| label: fig-04-elemento-cruz
//| fig-cap: "Elemento estruturante em formato de cruz ($B_{\\text{cruz}}$) utilizado para conectividade-4."
//| echo: true
//| output: true

#include "morph.hpp"
#include <iostream>

int main() {
    mm::Image B_cruz = mm::secross();
    mm::drawImgPlt(B_cruz, MM_OUT, 40);
    return 0;
}
Overwriting tmp/fig_04_elemento_cruz.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_elemento_cruz.cpp -o tmp/fig_04_elemento_cruz -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_elemento_cruz \
  && test -f "tmp/fig_04_elemento_cruz.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_elemento_cruz.png"
0 1 0 
1 1 1 
0 1 0 
try:
    mm.show(mm.read("tmp/fig_04_elemento_cruz.png"))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_elemento_cruz.png (ver a versao Python)")
Figure 4.3: Elemento estruturante em formato de cruz (\(B_{\text{cruz}}\)) utilizado para conectividade-4.
# La morph.hpp est header-only ; ci-dessous, le helper _viz et le corps de mm::ero0().
import re, pathlib
hpp = pathlib.Path("morph.hpp").read_text()
for nome, pat in [("_viz", r"template <class F>\ninline void _viz\(.*?\n\}"),
                  ("ero0", r"inline Image ero0\(.*?\n\}")]:
    m = re.search(pat, hpp, re.S)
    print(f"// ---- mm::{nome} ----")
    print(m.group(0) if m else f"({nome} não encontrada)")
    print()
// ---- mm::_viz ----
template <class F>
inline void _viz(const Image& f, const SE& B, int y, int x, F&& cb) {
    double oh = -B.h / 2.0 + 0.5;
    double ow = -B.w / 2.0 + 0.5;
    for (int by = 0; by < B.h; ++by)
        for (int bx = 0; bx < B.w; ++bx) {
            int vy = (int)(y + by + oh);
            int vx = (int)(x + bx + ow);
            if (vy >= 0 && vy < f.h && vx >= 0 && vx < f.w)
                cb(vy, vx, B.at(by, bx));
        }
}

// ---- mm::ero0 ----
inline Image ero0(const Image& f, SE Bc = SE::box(3)) {
    _require_gray(f, "ero0");
    Image g(f.h, f.w, 1);
    for (int y = 0; y < f.h; ++y)
        for (int x = 0; x < f.w; ++x) {
            int mn = 255;
            _viz(f, Bc, y, x, [&](int vy, int vx, int bv) {
                if (bv != 0 && (int)f.at(vy, vx) < mn) mn = f.at(vy, vx);
            });
            g.at(y, x) = (unsigned char)mn;
        }
    return g;
}

4.3.1.3 Dilatation

La Dilatation d’une image \(f\) par une fonction structurante \(b: \mathbb{B} \to \mathbb{Z}\) est définie formellement par :

\[ \delta_b(f)(x) = (f \oplus b)(x) = \max_{z \in \mathbb{B}}\{\, f(x - z) + b(z) \,\}, \quad \forall\, x \in \mathbb{E} \tag{4.4}\]

En pratique, la dilatation remplace l’intensité du pixel \(x\) par la plus grande valeur résultant de la somme entre l’image et l’élément structurant dans le voisinage défini. L’argument d’inversion spatiale (\(x - z\)) indique que la dilatation évalue implicitement l’élément transposé (réfléchi) \(\hat{b}\), propriété fondamentale pour assurer la dualité mathématique par rapport à l’érosion.

Dans le cas plat (où les poids sont nuls à l’intérieur du domaine, c’est-à-dire \(b \equiv 0\)), l’expression se réduit au maximum local pur :

\[ \delta_B(f)(x) = \max\{\, f(y) : y \in \mathbb{B}_x \,\} \]

Dans les images binaires, cette opération équivaut à exiger que l’ensemble réfléchi \(\hat{\mathbb{B}}\), translaté à la coordonnée \(x\), possède une intersection non vide avec l’objet \(A\) :

\[ A \oplus \mathbb{B} = \{\, z \in \mathbb{E} \mid \hat{\mathbb{B}}_z \cap A \neq \varnothing \,\} \]

Effet visuel : Étend les structures claires de l’image, augmente le remplissage des objets, connecte les composantes proches et élimine les canaux, fosses sombres ou vallées qui sont géométriquement plus petits que le domaine \(\mathbb{B}\).

4.3.1.4 Implémentation de la dilatation

La version didactique mm::dil0 implémente le cas particulier de dilatation avec élément structurant plan. Pour chaque pixel \((y,x)\), la fonction parcourt les voisins spatiaux autorisés par \(B\) et stocke la plus grande valeur trouvée dans l’image d’entrée \(f\), reproduisant directement l’opération de maximum local pour \(b \equiv 0\).

Avant de lancer le balayage spatial, l’élément structurant subit une réflexion géométrique au moyen de l’instruction np.flip(Bc) afin de construire explicitement la matrice transposée \(\hat{B}\) exigée par la théorie. Pour des masques parfaitement symétriques (tels que des croix, des carrés et des disques centrés à l’origine), cette réflexion ne modifie pas l’arrangement des pixels ; toutefois, pour les éléments asymétriques, cette étape est strictement nécessaire afin de garantir l’équivalence avec les définitions formelles et de préserver les lois de dualité.

Comme constaté pour l’opérateur d’érosion, les valeurs des voisins sont toujours lues de manière statique à partir de la matrice originale \(f\), tandis que la matrice de sortie \(g\) agit uniquement comme le registre du maximum accumulé du voisinage. Aux frontières de l’image, l’initialisation de l’accumulateur à 0 émule avec exactitude le remplissage externe par élément neutre (\(-\infty\), ou zéro dans les représentations sur 8 bits), garantissant que les bords physiques de la scène soient dilatés en parfaite conformité avec le standard adopté par OpenCV.

L’exemple informatique ci-dessous illustre l’application pratique d’un élément en croix (mm::secross()), validant la cohérence entre la logique en boucles (mm::dil0) et la méthode native industrielle (mm::dil).

# Corps de mm::dil0() dans morph.hpp (reflète le SE avant de balayer, comme np.flip).
import re, pathlib
hpp = pathlib.Path("morph.hpp").read_text()
m = re.search(r"inline Image dil0\(.*?\n\}", hpp, re.S)
print(m.group(0) if m else "(dil0 não encontrada)")
inline Image dil0(const Image& f, SE Bc = SE::box(3)) {
    _require_gray(f, "dil0");
    SE B = Bc.reflected();
    Image g(f.h, f.w, 1);
    for (int y = 0; y < f.h; ++y)
        for (int x = 0; x < f.w; ++x) {
            int mx = 0;
            _viz(f, B, y, x, [&](int vy, int vx, int bv) {
                if (bv != 0 && (int)f.at(vy, vx) > mx) mx = f.at(vy, vx);
            });
            g.at(y, x) = (unsigned char)mx;
        }
    return g;
}
NoteNote : Le Confront des Signes (\(f(x+z)\) vs \(f(x-z)\))

Comparez les définitions formelles de l’érosion (Équation 4.3) et de la dilatation (Équation 4.4). Considérez un élément structurant asymétrique à droite \(\mathbb{B}=\{0,1\}\) (origine et un pixel à droite) appliqué à la position \(x=10\).

  1. Dans l’érosion (Équation 4.3) :

    \[ \min\{f(x+z)-b(z)\} \]

    • \(z=0 \Rightarrow f(10+0)=\mathbf{f(10)}\)
    • \(z=1 \Rightarrow f(10+1)=\mathbf{f(11)}\)

    L’opérateur consulte le pixel courant (\(10\)) et le pixel à droite (\(11\)), préservant l’orientation originale de \(\mathbb{B}\).

  2. Dans la dilatation (Équation 4.4) :

    \[ \max\{f(x-z)+b(z)\} \]

    • \(z=0 \Rightarrow f(10-0)=\mathbf{f(10)}\)
    • \(z=1 \Rightarrow f(10-1)=\mathbf{f(9)}\)

    En raison du signe négatif (\(-z\)), avancer dans l’élément structurant correspond à reculer dans l’image, ce qui fait que la dilatation consulte le pixel à gauche (\(9\)).

La fonction _viz, utilisée dans morph.py, génère des voisins par déplacements additifs de la forme \(x+z\). Pour cette raison, l’implémentation de mm::dil0 reflète au préalable l’élément structurant au moyen de np.flip(B). Après la réflexion, le balayage basé sur \(x+z\) accède alors exactement aux mêmes points définis par l’expression théorique \(f(x-z)\) de la dilatation dans Équation 4.4.

Pour les éléments structurants symétriques (comme les disques, les carrés et les croix centrées), la réflexion ne modifie pas le masque. En revanche, pour les éléments asymétriques, cette étape est indispensable pour que l’implémentation reproduise correctement la définition mathématique de la dilatation et préserve la dualité érosion–dilatation.

NoteDualité érosion–dilatation

L’érosion et la dilatation sont duales par complémentation. Cela signifie qu’un opérateur peut être entièrement obtenu à partir de l’autre, à condition d’agir sur le complément de l’image en utilisant l’élément structurant réfléchi \(\hat{B}\) :

\[ (A \ominus B)^c = A^c \oplus \hat{B} \quad \Longleftrightarrow \quad A \ominus B = (A^c \oplus \hat{B})^c \]

De manière analogue, la dilatation peut également être obtenue à partir de l’érosion :

\[ (A \oplus B)^c = A^c \ominus \hat{B} \quad \Longleftrightarrow \quad A \oplus B = (A^c \ominus \hat{B})^c \]

En termes pratiques, l’érosion d’un objet peut être obtenue par la dilatation de son complément, suivie de la complémentation du résultat (et vice-versa). Dans l’implémentation du paquet morph.py, les versions didactiques mm::ero0 et mm::dil0 rendent explicite cette structure au moyen de boucles (loops), tandis que mm::ero et mm::dil délèguent les opérations à OpenCV pour une plus grande efficacité computationnelle.

NoteConditions aux limites et images finies

En morphologie mathématique classique, définie sur un domaine infini (typiquement \(\mathbb{Z}^2\)), cette dualité est exacte. Dans les images numériques, cependant, on travaille avec des matrices finies, et le résultat dépend de la manière dont sont traités les pixels situés hors de l’image.

Pour que les identités de dualité restent valides, le complément doit être défini par rapport au même univers et les conditions aux limites adoptées pour l’érosion et pour la dilatation doivent être complémentaires entre elles. Par exemple, si l’érosion suppose que les pixels externes appartiennent à l’objet (\(255\)), alors la dilatation appliquée au complément doit supposer que ces mêmes pixels externes appartiennent au fond (\(0\)).

Lorsque différentes stratégies de remplissage sont utilisées (réplication, réflexion, valeur constante, etc.), la dualité théorique peut cesser d’être satisfaite exactement dans les régions proches des bords de l’image.

Pour illustrer numériquement les opérateurs morphologiques et la dualité érosion–dilatation, la Figure 4.4 présente une image binaire 10×10 traitée avec un élément structurant en forme de « L ». Dans l’implémentation de morph.py, l’origine de \(B\) est fixée au centre géométrique du masque — position \((1,1)\) pour un kernel 3×3 — et doit correspondre à un élément actif pour que l’érosion se comporte correctement (comme discuté précédemment). L’élément structurant \(B_L\) défini ci-dessous satisfait cette condition. La Figure 4.5 complète l’analyse avec un simulateur interactif de l’érosion, permettant de visualiser le déplacement de l’élément structurant sur l’image et d’identifier les positions où il reste complètement contenu dans l’objet.

%%writefile tmp/fig_04_ero_dil_didatico.cpp
#define MM_OUT "tmp/fig_04_ero_dil_didatico.png"
//| label: fig-04-ero-dil-didatico
//| fig-cap: "Erosão e dilatação em imagem binária 10×10 com elemento estruturante 'L' assimétrico 3×3. Validação da dualidade erosão–dilatação."
//| echo: true
//| output: true
#include "morph.hpp"
#include <iostream>
#include <vector>
#include <string>
#include <filesystem>

int main() {
    mm::Image A(10, 10);
    // Initialize A with the pattern * 255
    unsigned char pattern[10][10] = {
        {0,0,0,0,0,0,0,0,0,0},
        {0,0,0,1,1,1,0,0,0,0},
        {0,0,1,1,1,1,1,0,0,0},
        {0,1,1,1,1,1,1,1,0,0},
        {0,1,1,1,1,1,1,1,0,0},
        {0,1,1,1,1,1,1,0,0,0},
        {0,0,1,1,1,1,1,1,0,0},
        {0,0,0,1,1,1,1,0,0,0},
        {0,0,0,0,1,0,0,0,0,0},
        {0,0,0,0,0,0,0,0,0,0}
    };
    for (int y = 0; y < 10; y++) {
        for (int x = 0; x < 10; x++) {
            A.at(y, x) = pattern[y][x] * 255;
        }
    }

    // B_L: 'L' shape, origin at center
    mm::SE B_L{{1,0,0},{1,1,0},{1,1,0}};
    // B_hat: B_L rotated 180° (B̂)
    mm::SE B_hat{{0,1,1},{0,1,1},{0,0,1}};

    std::cout << "Elemento estruturante B_L:" << std::endl;
    mm::Image B_L_img(3, 3);
    for (int y = 0; y < 3; y++) {
        for (int x = 0; x < 3; x++) {
            B_L_img.at(y, x) = (B_L.at(y, x) != 0) ? 255 : 0;
        }
    }
    std::cout << mm::drawImg(B_L_img) << std::endl;
    std::cout << "Elemento estruturante refletido B̂:" << std::endl;
    mm::Image B_hat_img(3, 3);
    for (int y = 0; y < 3; y++) {
        for (int x = 0; x < 3; x++) {
            B_hat_img.at(y, x) = (B_hat.at(y, x) != 0) ? 255 : 0;
        }
    }
    std::cout << mm::drawImg(B_hat_img) << std::endl;

    mm::Image img_ero0 = mm::ero0(A, B_L);

    // Dualité : (A ⊖ B)ᶜ == Aᶜ ⊕ B̂
    mm::Image A_c = mm::neg(A);
    mm::Image ero_c = mm::neg(img_ero0);
    mm::Image dil_Ac = mm::dil0(A_c, B_hat);

    bool dual = true;
    for (int i = 0; i < ero_c.data.size(); i++) {
        if (ero_c.data[i] != dil_Ac.data[i]) {
            dual = false;
            break;
        }
    }
    std::cout << "Dualité (A ⊖ B)ᶜ == Aᶜ ⊕ B̂ : " << (dual ? "True" : "False") << std::endl;

    mm::Image B_L_disp(3, 3);
    for (int y = 0; y < 3; y++) {
        for (int x = 0; x < 3; x++) {
            B_L_disp.at(y, x) = (B_L.at(y, x) != 0) ? 255 : 0;
        }
    }
    mm::Image B_hat_disp(3, 3);
    for (int y = 0; y < 3; y++) {
        for (int x = 0; x < 3; x++) {
            B_hat_disp.at(y, x) = (B_hat.at(y, x) != 0) ? 255 : 0;
        }
    }
    mm::show(std::vector<mm::Image>{A, A_c, img_ero0, ero_c, dil_Ac},
             MM_OUT,
             std::vector<std::string>{"A", "Aᶜ", "A ⊖ B", "(A ⊖ B)ᶜ", "Aᶜ ⊕ B̂"},
             5);

    
// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(A, "tmp/fig_04_ero_dil_didatico_0.png");
mm::write(A_c, "tmp/fig_04_ero_dil_didatico_1.png");
mm::write(img_ero0, "tmp/fig_04_ero_dil_didatico_2.png");
mm::write(ero_c, "tmp/fig_04_ero_dil_didatico_3.png");
mm::write(dil_Ac, "tmp/fig_04_ero_dil_didatico_4.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_04_ero_dil_didatico.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_ero_dil_didatico.cpp -o tmp/fig_04_ero_dil_didatico -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_ero_dil_didatico \
  && test -f "tmp/fig_04_ero_dil_didatico.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_ero_dil_didatico.png"
Elemento estruturante B_L:
255   0   0 
255 255   0 
255 255   0 

Elemento estruturante refletido B̂:
  0 255 255 
  0 255 255 
  0   0 255 

Dualité (A ⊖ B)ᶜ == Aᶜ ⊕ B̂ : True
[1] A
[2] Aᶜ
[3] A ⊖ B
[4] (A ⊖ B)ᶜ
[5] Aᶜ ⊕ B̂
try:
    mm.show(
        [
            mm.read("tmp/fig_04_ero_dil_didatico_0.png"),
            mm.read("tmp/fig_04_ero_dil_didatico_1.png"),
            mm.read("tmp/fig_04_ero_dil_didatico_2.png"),
            mm.read("tmp/fig_04_ero_dil_didatico_3.png"),
            mm.read("tmp/fig_04_ero_dil_didatico_4.png"),
        ],
        titles=[
            'A',
            'Aᶜ',
            'A ⊖ B',
            '(A ⊖ B)ᶜ',
            'Aᶜ ⊕ B̂',
        ],
        cols=5,
        figsize=(15, 3),
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_ero_dil_didatico_0.png (ver a versao Python)")
Figure 4.4: Erosão e dilatação em imagem binária 10×10 com elemento estruturante ‘L’ assimétrico 3×3. Validação da dualidade erosão–dilatação.
🪨 Simulateur : Érosion morphologique A ⊖ B_L · décalages via _viz

Cliquez sur une cellule du canevas pour déplacer le noyau B_L ou utilisez les curseurs pour tester l'inclusion des contenus.

Position X (col)
4
Position Y (ligne)
4
Pixel Érosion
255
🖱️ Cliquez sur une cellule pour déplacer le noyau B_L
🟢
Succès : contenu !
Le pixel reçoit 1 (255) dans l'image érodée.
Contrôles de coordonnées
4
4
Noyau B_L (3×3)
1
0
0
1
★
0
1
1
0
Figure 4.5: Simulateur : Érosion Morphologique (A ⊖ B_L)

4.3.2 Ouverture et Fermeture

En combinant érosion et dilatation, on obtient deux opérateurs d’une grande utilité pratique : l’ouverture et la fermeture, définis par les Équations Équation 4.5 et Équation 4.6. Leurs principaux effets sont résumés dans la Table 4.1.

Ouverture (opening) — érosion suivie d’une dilatation par le même \(B\) :

\[ A \circ B = (A \ominus B) \oplus B \tag{4.5}\]

Fermeture (closing) — dilatation suivie d’une érosion par le même \(B\) :

\[ A \bullet B = (A \oplus B) \ominus B \tag{4.6}\]

En pratique, mm::open et mm::close appliquent le même élément structurant aux deux étapes. Pour les éléments structurants symétriques (les plus courants), cette implémentation coïncide avec la définition mathématique présentée ci-dessus.

Table 4.1: Propriétés de l’ouverture et de la fermeture.
Opérateur Séquence Effet principal
Ouverture \(A \circ B\) érosion → dilatation Supprime les structures incapables de contenir l’élément structurant ; lisse les contours externes
Fermeture \(A \bullet B\) dilatation → érosion Comble les trous plus petits que \(B\) ; lisse les contours internes

Propriété importante : les deux sont idempotents. Par exemple,

\[ (A \circ B) \circ B = A \circ B, \]

c’est-à-dire qu’après la première application, de nouvelles applications du même opérateur ne modifient plus le résultat.

4.3.2.1 Implémentation de l’ouverture et de la fermeture

Contrairement à l’érosion et à la dilatation, l’ouverture et la fermeture n’introduisent pas de nouveaux mécanismes computationnels. Les deux sont obtenus par la composition séquentielle des opérateurs primitifs déjà présentés :

def open0(f, B):
    return mm.dil0(mm.ero0(f, B), B)

def close0(f, B):
    return mm.ero0(mm.dil0(f, B), B)

La fonction mm::open délègue l’opération à mm::open(f, B), tandis que mm::close utilise mm::close(f, B), produisant le même résultat de manière plus efficace.

L’ouverture hérite de l’érosion la capacité de supprimer les structures plus petites que l’élément structurant et de la dilatation la restauration partielle des régions préservées. La fermeture réalise le processus inverse : elle expand d’abord les objets puis restaure leurs dimensions originales, comblant les lacunes et les trous plus petits que l’élément structurant.

4.3.2.2 Filtre Séquentiel Alterné

En pratique, l’ouverture et la fermeture sont souvent appliquées en séquence afin d’éliminer simultanément le bruit externe et de combler les trous internes. La fonction mm::asf (Alternating Sequential Filter) généralise cette stratégie en appliquant alternativement des ouvertures et des fermetures avec des éléments structurants progressivement plus grands. Les séquences disponibles sont présentées dans Table 4.2.

Table 4.2: Séquences du filtre séquentiel alterné mm.asf.
Séquence Ordre Utilisation typique
'OC' ouverture → fermeture élimine le bruit externe avant de combler les petits trous
'CO' fermeture → ouverture comble les petits trous avant d’éliminer le bruit externe
'OCO' ouverture → fermeture → ouverture met l’accent sur l’élimination du bruit externe
'COC' fermeture → ouverture → fermeture met l’accent sur le comblement des trous et des lacunes

Le paramètre n contrôle le nombre d’échelles utilisées par le filtre. À chaque itération \(i\), l’élément structurant est agrandi par somme de Minkowski (mm::sesum(b, i)), produisant une séquence de filtres morphologiques de plus en plus englobants. Contrairement à une ouverture ou une fermeture unique avec un grand élément structurant, l’ASF réalise un lissage progressif à plusieurs échelles, préservant mieux la géométrie des objets pertinents tout en éliminant les structures plus petites. Figure 4.6 présente un exemple d’application de l’ouverture, de la fermeture et de l’ASF sur l’image des pièces de monnaie.

%%writefile tmp/fig_04_open_close.cpp
#define MM_OUT "tmp/fig_04_open_close.png"
// Compile: g++ -std=c++17 -o program program.cpp -I. -lstdc++fs -lpthread -lcurl

#include "morph.hpp"
#include <iostream>
#include <vector>
#include <string>
#include <filesystem>

int main() {
// [pdi:state-io] auto-generated — do not edit by hand
mm::Image img_clahe = mm::_read_state("tmp/state/img_clahe_12.png");
// [pdi:state-io:end]

    //| label: fig-04-open-close
    //| fig-cap: "Abertura, fechamento, composição e filtro sequencial alternado aplicados à binarização Otsu das moedas (pré-processadas por realce de contraste). Elemento estruturante: disco 13×13."
    //| echo: true
    //| output: true

    mm::Image img_bin    = mm::threshold(img_clahe);
    mm::Image B_disk     = mm::sedisk(19);

    mm::Image img_open  = mm::open(img_bin, B_disk);             // erosão → dilatação
    mm::Image img_close = mm::close(img_bin, B_disk);            // dilatação → erosão
    mm::Image img_oc    = mm::close(img_open, B_disk);           // abertura seguida de fechamento
    mm::Image img_asf   = mm::asf(img_bin, "OC", mm::sedisk(3), 7);  
    // ASF: disco base 3×3, cresce a cada iteração

    mm::show(
        std::vector<mm::Image>{img_bin, img_open, img_close, img_oc, img_asf},
        MM_OUT,
        std::vector<std::string>{"Binarização Otsu", "Abertura (A∘B)", "Fechamento (A∙B)",
            "Abertura→Fechamento", "ASF-OC (n=7)"},
        5
    );

    
// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(img_bin, "tmp/fig_04_open_close_0.png");
mm::write(img_open, "tmp/fig_04_open_close_1.png");
mm::write(img_close, "tmp/fig_04_open_close_2.png");
mm::write(img_oc, "tmp/fig_04_open_close_3.png");
mm::write(img_asf, "tmp/fig_04_open_close_4.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_04_open_close.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_open_close.cpp -o tmp/fig_04_open_close -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_open_close \
  && test -f "tmp/fig_04_open_close.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_open_close.png"
[1] Binarização Otsu
[2] Abertura (A∘B)
[3] Fechamento (A∙B)
[4] Abertura→Fechamento
[5] ASF-OC (n=7)
try:
    mm.show(
        [
            mm.read("tmp/fig_04_open_close_0.png"),
            mm.read("tmp/fig_04_open_close_1.png"),
            mm.read("tmp/fig_04_open_close_2.png"),
            mm.read("tmp/fig_04_open_close_3.png"),
            mm.read("tmp/fig_04_open_close_4.png"),
        ],
        titles=[
            'Binarização Otsu',
            'Abertura (A∘B)',
            'Fechamento (A∙B)',
            'Abertura→Fechamento',
            'ASF-OC (n=7)',
        ],
        cols=5,
        figsize=(15, 12),
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_open_close_0.png (ver a versao Python)")
Figure 4.6: Abertura, fechamento, composição e filtro sequencial alternado aplicados à binarização Otsu das moedas (pré-processadas por realce de contraste). Elemento estruturante: disco 13×13.

4.3.3 Opérateurs Géodésiques

Les opérateurs géodésiques introduisent une contrainte supplémentaire aux opérateurs morphologiques classiques au moyen d’une image de contrôle appelée masque \(g\). Au lieu de permettre à l’érosion ou à la dilatation de se propager librement à travers l’image, le résultat de chaque itération est limité point par point par les valeurs du masque, restreignant l’évolution de l’opération aux régions autorisées.

4.3.3.1 Dilatation Géodésique

La dilatation géodésique d’une image marqueur \(f\) sous une image masque \(g\), à l’aide d’un élément structurant plat \(b\), est définie par :

\[ f \oplus_g b = (f \oplus b) \wedge g, \tag{4.7}\]

où \(\wedge\) représente le minimum point par point.

En d’autres termes, on effectue d’abord une dilatation conventionnelle sur le marqueur, puis le résultat est restreint par le masque \(g\). Ainsi, la propagation ne peut jamais dépasser les régions autorisées par le masque.

La formulation classique de la dilatation géodésique suppose que le marqueur est contenu dans le masque, c’est-à-dire \(f \le g\), garantissant que l’évolution de l’opération reste toujours limitée par le masque.

4.3.3.2 Implémentation de la dilatation géodésique

La fonction mm::cdil implémente directement cet opérateur et permet d’exécuter plusieurs itérations consécutives :

def cdil(f, g, b=np.zeros((3,3),dtype='uint8'), n=1):
    """Dilatação geodésica do marcador f sob a máscara g."""
    y = f.copy()
    for _ in range(n):
        y = np.minimum(mm.dil(y, b), g)
    return y

L’instruction np.minimum(mm::dil(y, b), g) implémente exactement la définition mathématique de la dilatation géodésique, c’est-à-dire \((y \oplus b)\wedge g\).

Lorsque \(n=1\), la fonction exécute une unique dilatation géodésique. Pour \(n>1\), le résultat de chaque étape devient le marqueur de l’étape suivante, produisant une propagation progressive contrôlée par le masque.

Le simulateur interactif Figure 4.7 permet de suivre, étape par étape, la propagation du marqueur \(f\) le long des couloirs du labyrinthe. À chaque itération de mm::cdil, le front de dilatation avance vers les cellules voisines libres — celles où \(g = 1\) —, tandis que les murs (\(g = 0\)) restent infranchissables. Le nombre de pas nécessaires pour que le marqueur atteigne la sortie correspond exactement à la longueur géodésique du chemin le plus court à l’intérieur du masque, mettant en évidence la connexion directe entre la dilatation géodésique itérée et la notion de distance dans les graphes.

🗺️ Simulateur : Dilatation géodésique dans le labyrinthe δ_g^(n)(f)
Mur
Chemin libre (g)
Marqueur f
Propagation
Sortie
Étape 0 — marqueur initial f (entrée)
Figure 4.7: Simulateur interactif de dilatation géodésique : le marqueur f (vert) se propage pas à pas le long des chemins libres du masque g, sans traverser les murs.

4.3.3.3 Érosion géodésique

De manière duale, l’érosion géodésique d’une image marqueur \(f\) sous une image masque \(g\) est définie par :

\[ f \ominus_g b = (f \ominus b) \vee g, \tag{4.8}\]

où \(\vee\) représente l’opérateur de maximum point par point.

Dans ce cas, l’érosion conventionnelle du marqueur est suivie d’une contrainte inférieure imposée par le masque. Ainsi, aucun pixel du résultat ne peut prendre une valeur inférieure à celle du pixel correspondant du masque.

La formulation classique de l’érosion géodésique suppose la condition duale

\[ f \ge g, \]

de sorte que le masque agisse comme une limite inférieure tout au long du processus.

4.3.3.4 Implémentation de l’érosion géodésique

La fonction statique mm::cero matérialise cet opérateur :

staticmethod
def cero(f, g, b=np.zeros((3,3),dtype='uint8'), n=1):
    """Érosion géodésique du marqueur f sous le masque g."""
    y = f.copy()
    for _ in range(n):
        y = np.maximum(mm.ero(y, b), g)
    return y

L’instruction np.maximum(mm::ero(y, b), g) implémente directement l’expression \((y \ominus b)\vee g\).

Tout comme pour la dilatation géodésique, le paramètre \(n\) définit combien d’érosions géodésiques successives seront calculées avant de renvoyer l’image finale.

NoteRelation avec la reconstruction morphologique

La reconstruction morphologique présentée dans la section suivante est obtenue par l’application itérative de la dilatation géodésique (mm::cdil) jusqu’à ce qu’un point fixe soit atteint, c’est-à-dire jusqu’à ce qu’aucune cellule ne change de valeur entre deux itérations consécutives. En d’autres termes, la reconstruction consiste en une séquence de dilatations géodésiques successives qui se propagent à l’intérieur du masque jusqu’à ce qu’il n’y ait plus de modifications.

De manière duale, il est également possible de définir des reconstructions basées sur l’érosion géodésique au moyen d’applications successives de mm::cero.

4.3.3.5 Exemple : propagation dans un labyrinthe via dualité

La Figure 4.8 illustre la résolution du problème de connectivité d’un labyrinthe en utilisant la dualité morphologique au moyen des fonctions mm::cero et mm::suprec.

Au lieu de propager un marqueur à travers les couloirs libres par des dilatations géodésiques, le problème est formulé dans le domaine complémentaire. Initialement, le masque original est inversé,

\[ g = 1 - g_{orig}, \]

ce qui fait que les murs prennent la valeur 1 et les couloirs la valeur 0. De manière analogue, le marqueur est construit dans ce même domaine complémentaire, contenant une unique valeur 0 à la position de l’entrée du labyrinthe et la valeur 1 sur les autres pixels.

En utilisant l’élément structurant en croix (mm::secross()), l’érosion géodésique agit sur le marqueur complémenté. À chaque itération de mm::cero, la région connectée au marqueur initial subit des érosions successives, tandis que le masque impose une borne inférieure qui empêche la propagation à travers les murs du labyrinthe.

Les images intermédiaires montrent des états de l’évolution après différents nombres d’itérations (n=5, n=12, et n=22). Le résultat final est obtenu par la reconstruction géodésique par érosion (mm::suprec), qui applique des érosions géodésiques successives jusqu’à atteindre un point fixe, c’est-à-dire une situation où aucune modification supplémentaire ne se produit entre deux itérations consécutives.

Dans le domaine complémentaire, la région reconstruite correspond exactement à l’ensemble des couloirs connectés à l’entrée du labyrinthe. Ainsi, la connectivité entre l’entrée et la sortie peut être déterminée directement à partir de l’image reconstruite.

%%writefile tmp/fig_04_cero_labirinto.cpp
#define MM_OUT "tmp/fig_04_cero_labirinto.png"
#include "morph.hpp"
#include <opencv2/opencv.hpp>
#include <iostream>
#include <vector>
#include <string>
#include <filesystem>

int main() {
    // Máscara já invertida (255 = parede, 0 = corredor)
    mm::Image g(10, 10);
    int g_data[10][10] = {
        {255,  0,255,255,255,255,255,255,255,255},
        {255,  0,  0,  0,  0,  0,255,  0,  0,  0},
        {255,255,255,255,255,  0,255,  0,255,  0},
        {255,  0,  0,  0,255,  0,  0,  0,255,  0},
        {255,  0,255,  0,255,255,255,255,255,  0},
        {255,  0,255,  0,  0,  0,  0,  0,  0,  0},
        {255,  0,255,255,255,255,255,255,  0,255},
        {255,  0,  0,  0,  0,  0,  0,255,  0,255},
        {255,255,255,255,255,255,  0,  0,  0,255},
        {255,255,255,255,255,255,255,255,  0,255}
    };
    for (int y = 0; y < 10; y++)
        for (int x = 0; x < 10; x++)
            g.at(y, x) = (unsigned char)g_data[y][x];

    mm::Image f(10, 10);
    std::fill(f.data.begin(), f.data.end(), 255);
    f.at(0, 1) = 0;                      // semente (0) no corredor de entrada
    mm::Image B_cruz = mm::secross();

    mm::Image passo_5    = mm::cero(f, g, B_cruz, 5);
    mm::Image passo_12   = mm::cero(f, g, B_cruz, 12);
    mm::Image passo_22   = mm::cero(f, g, B_cruz, 22);
    mm::Image ponto_fixo = mm::suprec(f, g, B_cruz);

    mm::show(std::vector<mm::Image>{g, f, passo_5, passo_12, passo_22, ponto_fixo},
             MM_OUT,
             std::vector<std::string>{"Mascara (~g)", "Marcador (~f)", "n=5", "n=12", "n=22", "~mm.suprec"},
             6);

    
// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(g, "tmp/fig_04_cero_labirinto_0.png");
mm::write(f, "tmp/fig_04_cero_labirinto_1.png");
mm::write(passo_5, "tmp/fig_04_cero_labirinto_2.png");
mm::write(passo_12, "tmp/fig_04_cero_labirinto_3.png");
mm::write(passo_22, "tmp/fig_04_cero_labirinto_4.png");
mm::write(ponto_fixo, "tmp/fig_04_cero_labirinto_5.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_04_cero_labirinto.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_cero_labirinto.cpp -o tmp/fig_04_cero_labirinto -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_cero_labirinto \
  && test -f "tmp/fig_04_cero_labirinto.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_cero_labirinto.png"
[1] Mascara (~g)
[2] Marcador (~f)
[3] n=5
[4] n=12
[5] n=22
[6] ~mm.suprec
try:
    mm.show(
        [
            mm.read("tmp/fig_04_cero_labirinto_0.png"),
            mm.read("tmp/fig_04_cero_labirinto_1.png"),
            mm.read("tmp/fig_04_cero_labirinto_2.png"),
            mm.read("tmp/fig_04_cero_labirinto_3.png"),
            mm.read("tmp/fig_04_cero_labirinto_4.png"),
            mm.read("tmp/fig_04_cero_labirinto_5.png"),
        ],
        titles=[
            'Mascara (~g)',
            'Marcador (~f)',
            'n=5',
            'n=12',
            'n=22',
            '~mm.suprec',
        ],
        cols=6,
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_cero_labirinto_0.png (ver a versao Python)")
Figure 4.8: Resolução de labirinto no domínio complementar: com máscara e marcador invertidos, mm::cero e mm::suprec propagam a onda geodésica pelos corredores.

4.3.4 Reconstruction morphologique

La reconstruction morphologique propage une image marqueur \(f\) à l’intérieur d’une image masque \(g\), en garantissant que le résultat ne dépasse jamais les valeurs d’intensité imposées par le masque. L’opérateur fondamental qui rend possible cette propagation contenue est la dilatation géodésique, définie par la Équation 4.7.

La reconstruction est obtenue par l’application itérative de cette dilatation conditionnée. Initialement, le marqueur est limité par le masque pour établir l’état initial :

\[ X^{(0)} = f \wedge g, \]

et les itérations suivantes sont définies de manière récursive par :

\[ X^{(k)} = (X^{(k-1)} \oplus b) \wedge g. \]

La séquence croît de manière monotone jusqu’à atteindre un point fixe, produisant la reconstruction morphologique par dilatation (également connue dans la littérature sous le nom d’inf-reconstruction) :

\[ R_g^\delta(f) = \lim_{k\to\infty} X^{(k)} = X^{(k)} \quad \text{quand} \quad X^{(k)} = X^{(k-1)}. \tag{4.9}\]

La montée itérative est interrompue dès que la stabilité est atteinte, c’est-à-dire lorsque deux itérations consécutives produisent des matrices avec des valeurs absolument identiques.

4.3.4.1 Implémentation de la reconstruction morphologique

La routine didactique mm::infrec implémente directement l’algorithme itératif de point fixe. Initialement, le marqueur effectif initial \(X^{(0)}\) est déterminé par l’opération np.minimum(f, g). Pour garantir que la boucle de vérification exécute la première itération sans déclencher de fausses convergences prématurées, la variable de contrôle de l’itération précédente (y1) est initialisée avec une valeur sentinelle hors du domaine des données (ou simplement avec une matrice qui force la première exécution).

def infrec(f, g, b=np.zeros((3,3), dtype='uint8')):
    """Inf-reconstruction : dilate le marqueur (f ∧ g) jusqu'à convergence sous le masque g."""
    y = np.minimum(f, g)
    # Initialise y1 avec des valeurs impossibles pour forcer l'entrée dans la boucle
    y1 = np.full_like(f, 256, dtype=np.int16) 
    while not np.array_equal(y, y1):
        y1 = y.copy()
        # Applique la dilatation géodésique : (y ⊕ b) ∧ g
        y = np.minimum(mm.dil(y, b), g)
    return y.astype('uint8')

À l’intérieur de la boucle while, la variable y stocke l’estimation courante de la reconstruction \(X^{(k)}\), tandis que y1 préserve l’image de l’étape immédiatement antérieure \(X^{(k-1)}\). L’instruction de contrôle conditionnel np.minimum(mm::dil(y, b), g) traduit fidèlement la dilatation géodésique théorique, où l’expansion morphologique conventionnelle pilotée par OpenCV est immédiatement « élaguée » et limitée par les barrières d’intensité du masque \(g\). La boucle cesse lorsqu’aucune modification de pixel n’est enregistrée entre les étapes.

4.3.4.2 Avantages de la reconstruction morphologique

La reconstruction morphologique est significativement plus robuste que l’ouverture conventionnelle, car elle est capable de supprimer les structures indésirables sans déformer ou modifier la morphologie des objets qui doivent être préservés.

Alors que l’ouverture classique adoucit les angles, élimine les pointes et déforme les contours en raison de l’imposition géométrique rigide de l’élément structurant, la reconstruction géodésique utilise le masque pour retrouver avec exactitude les limites et les formes originales des objets qui présentent une connectivité avec le marqueur initial.

En termes intuitifs, le marqueur agit comme une graine de contagion qui se propage progressivement, mais uniquement en traversant les régions autorisées par le masque. Les composantes qui n’ont aucune intersection avec le marqueur ne seront jamais reconstruites (étant ainsi éliminées), tandis que les composantes touchées par la graine s’étendent jusqu’à restaurer intégralement leur géométrie d’origine.

Ce comportement discriminatoire et conservateur est illustré à la Figure 4.9, en utilisant l’élément structurant en croix présenté à la Figure 4.3..

%%writefile tmp/fig_04_reconstrucao_didatica.cpp
#define MM_OUT "tmp/fig_04_reconstrucao_didatica.png"
//| label: fig-04-reconstrucao-didatica
//| fig-cap: "*Pipeline* de Reconstrução Morfológica por Dilatação Condicionada: a máscara contém dois objetos, o marcador isola apenas o núcleo do objeto principal, e as iterações reconstroem sua forma exata até a convergência."
//| echo: true
//| output: true
#include "morph.hpp"
#include <iostream>
#include <vector>
#include <string>

int main() {
    mm::Image g(10, 10);
    int g_data[10][10] = {
        {0,0,0,0,0,0,0,0,0,0},
        {0,1,1,1,0,0,0,1,1,0},
        {0,1,1,1,1,0,0,1,1,0},
        {0,1,1,1,1,0,0,0,0,0},
        {0,1,1,1,1,0,0,0,0,0},
        {0,1,1,1,0,0,0,0,0,0},
        {0,0,1,1,1,0,0,1,0,0},
        {0,0,1,1,1,0,0,1,1,0},
        {0,0,0,1,0,0,0,0,0,0},
        {0,0,0,0,0,0,0,0,0,0}
    };
    for (int y = 0; y < 10; ++y)
        for (int x = 0; x < 10; ++x)
            g.at(y, x) = g_data[y][x] * 255;

    mm::Image B_cruz = mm::secross();
    mm::Image f = mm::ero(g, mm::sebox());          // marcador: núcleo do objeto principal

    std::vector<mm::Image> iteracoes;
    std::vector<std::string> titulos;
    mm::Image img_atual = f;
    for (int i = 1; i <= 5; ++i) {
        img_atual = mm::cdil(img_atual, g, B_cruz);
        iteracoes.push_back(img_atual);
        titulos.push_back("Dilatação Cond. (n=" + std::to_string(i) + ")");
    }

    mm::Image img_reconstruida = mm::infrec(f, g, B_cruz);
    bool estavel = true;
    for (int i = 0; i < img_reconstruida.h * img_reconstruida.w * img_reconstruida.channels; ++i) {
        if (img_reconstruida.data[i] != iteracoes.back().data[i]) {
            estavel = false;
            break;
        }
    }
    std::cout << "✅ Estabilidade na iteração 5: " << (estavel ? "True" : "False") << "\n";

    std::vector<mm::Image> imgs = {g, f};
    imgs.insert(imgs.end(), iteracoes.begin(), iteracoes.end());
    imgs.push_back(img_reconstruida);

    std::vector<std::string> titles = {"Máscara (g)", "Marcador (f)"};
    titles.insert(titles.end(), titulos.begin(), titulos.end());
    titles.push_back("Reconstrução R_g(f)");

    mm::show(imgs, MM_OUT, titles, 8);
    return 0;
}
Overwriting tmp/fig_04_reconstrucao_didatica.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_reconstrucao_didatica.cpp -o tmp/fig_04_reconstrucao_didatica -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_reconstrucao_didatica \
  && test -f "tmp/fig_04_reconstrucao_didatica.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_reconstrucao_didatica.png"
✅ Estabilidade na iteração 5: True
[1] Máscara (g)
[2] Marcador (f)
[3] Dilatação Cond. (n=1)
[4] Dilatação Cond. (n=2)
[5] Dilatação Cond. (n=3)
[6] Dilatação Cond. (n=4)
[7] Dilatação Cond. (n=5)
[8] Reconstrução R_g(f)
try:
    mm.show(mm.read("tmp/fig_04_reconstrucao_didatica.png"), figsize=(18, 3))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_reconstrucao_didatica.png (ver a versao Python)")
Figure 4.9: Pipeline de Reconstrução Morfológica por Dilatação Condicionada: a máscara contém dois objetos, o marcador isola apenas o núcleo do objeto principal, e as iterações reconstroem sua forma exata até a convergência.

4.3.5 Remplissage des trous et suppression des bords

Deux opérateurs basés sur la reconstruction morphologique complètent le pipeline de nettoyage binaire. Leurs principales caractéristiques sont résumées dans la Table 4.3.

Remplissage des trous (mm::clohole) supprime les cavités entièrement entourées par l’objet, quelle que soit leur taille, sans modifier les contours externes. La procédure agit sur le complément de l’image, en utilisant comme marqueur une restriction du cadre (frame) au fond :

\[ \text{clohole}(f) = \bigl(R_{f^c}^\delta(\text{frame}(f) \wedge f^c)\bigr)^c \tag{4.10}\]

Sur le plan opérationnel, on reconstruit d’abord le fond externe, puis on applique la complémentation pour récupérer les objets dont les trous ont été remplis.

Suppression des objets de bord (mm::edgeoff) élimine tous les objets qui touchent le bord de l’image, en ne préservant que les composantes entièrement internes. Le marqueur est obtenu par l’intersection entre le cadre (frame) et les objets de l’image :

\[ \text{edgeoff}(f) = f \setminus R_f^\delta(\text{frame}(f) \wedge f) \tag{4.11}\]

Table 4.3: Comparaison entre les opérateurs clohole et edgeoff.
Opérateur Marqueur Masque Effet
mm::clohole frame restreint au fond (\(f^c\)) \(f^c\) Remplit les trous internes
mm::edgeoff frame restreint à l’objet (\(f\)) \(f\) Supprime les objets connectés au bord

L’évolution pas à pas de ces transformations géodésiques peut être suivie dans les figures suivantes. La Figure 4.10 illustre le mécanisme d’inondation contrôlée de l’opérateur mm::clohole, dans lequel la reconstruction se fait à partir du fond externe et empêche la propagation vers les régions internes non connectées à l’extérieur, ce qui aboutit au remplissage cohérent des cavités internes. En revanche, la Figure 4.11 détaille la dynamique de l’opérateur mm::edgeoff, où seules les composantes connectées au bord sont reconstruites puis supprimées, ne préservant que les objets entièrement contenus à l’intérieur de l’image.

La connectivité de la propagation géodésique est contrôlée par l’élément structurant : mm::sebox() (voisinage de 8) inclut les connexions diagonales, tandis que mm::secross() (voisinage de 4) les exclut. Par conséquent, le choix de l’élément structurant affecte les composantes atteintes par la reconstruction et, donc, celles qui seront préservées ou supprimées.

4.3.5.1 Conformité avec l’implémentation

Les définitions ci-dessus sont directement alignées avec l’implémentation dans morph.py, reproduite ci-dessous :

staticmethod
def clohole(f, b=np.ones((3,3),dtype='uint8')):
    # marqueur restreint au fond de l'image
    marqueur = mm.frame(f, border=1) & mm.neg(f)
    return mm.neg(mm.infrec(marqueur, mm.neg(f), b))

staticmethod
def edgeoff(f, b=np.ones((3,3),dtype='uint8')):
    # marqueur restreint aux objets de l'image
    marqueur = mm.frame(f, border=1) & f
    return mm.subm(f, mm.infrec(marqueur, f, b))

Ces implémentations rendent explicite le fait que les deux opérateurs sont des instances directes de la reconstruction morphologique par dilatation géodésique avec mm::infrec, ne différant que par le choix du marqueur et du masque : clohole agit sur le complément de l’image, tandis que edgeoff agit directement sur le domaine des objets.

%%writefile tmp/fig_04_clohole_didatico.cpp
#define MM_OUT "tmp/fig_04_clohole_didatico.png"
//| label: fig-04-clohole-didatico
//| fig-cap: "*Pipeline* de preenchimento de buracos (*clohole*): o marcador vem da borda da imagem, restrito ao complemento $f^c$. A dilatação geodésica reconstrói o fundo externo; após a complementação, os buracos internos ficam preenchidos."
//| echo: true
//| output: true

#include "morph.hpp"
#include <iostream>
#include <vector>
#include <string>

int main() {
    mm::Image f(10, 10);
    // Initialize f with the given pattern
    int pattern[10][10] = {
        {0,0,0,0,0,0,0,0,0,0},
        {0,1,1,1,1,0,0,0,0,1},
        {0,1,0,0,1,0,0,1,0,1},
        {0,1,0,0,1,0,0,1,0,1},
        {0,1,1,1,1,0,0,1,0,0},
        {0,0,0,0,0,0,0,0,0,0},
        {0,0,1,1,1,0,1,1,1,0},
        {0,0,1,1,1,0,1,0,1,0},
        {0,0,1,1,1,0,1,1,1,0},
        {0,0,0,0,0,0,0,0,0,1}
    };
    for (int y = 0; y < 10; y++)
        for (int x = 0; x < 10; x++)
            f.at(y, x) = pattern[y][x] * 255;

    mm::Image B_cruz = mm::secross();
    mm::Image f_c = mm::neg(f);
    mm::Image marcador_ch = mm::frame(f, 1);       // borda externa

    std::vector<mm::Image> iteracoes;
    std::vector<std::string> titulos;
    mm::Image img_atual = marcador_ch;
    for (int i = 1; i <= 5; i++) {
        img_atual = mm::cdil(img_atual, f_c, B_cruz);
        iteracoes.push_back(img_atual);
        titulos.push_back("Iter. (n=" + std::to_string(i) + ")");
    }

    mm::Image img_clohole = mm::neg(mm::infrec(marcador_ch, f_c, B_cruz));

    // Validate clohole
    mm::Image expected = mm::clohole(f);
    bool valid = true;
    for (int i = 0; i < img_clohole.h * img_clohole.w * img_clohole.channels; i++) {
        if (img_clohole.data[i] != expected.data[i]) {
            valid = false;
            break;
        }
    }
    std::cout << "✅ Validação clohole: " << (valid ? "true" : "false") << std::endl;

    std::vector<mm::Image> display_imgs = {f, marcador_ch};
    display_imgs.insert(display_imgs.end(), iteracoes.begin(), iteracoes.end());
    display_imgs.push_back(img_clohole);

    std::vector<std::string> display_titles = {"f original", "Marcador (borda)"};
    display_titles.insert(display_titles.end(), titulos.begin(), titulos.end());
    display_titles.push_back("clohole(f)");

    mm::show(display_imgs, MM_OUT, display_titles, 8);

    return 0;
}
Overwriting tmp/fig_04_clohole_didatico.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_clohole_didatico.cpp -o tmp/fig_04_clohole_didatico -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_clohole_didatico \
  && test -f "tmp/fig_04_clohole_didatico.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_clohole_didatico.png"
✅ Validação clohole: true
[1] f original
[2] Marcador (borda)
[3] Iter. (n=1)
[4] Iter. (n=2)
[5] Iter. (n=3)
[6] Iter. (n=4)
[7] Iter. (n=5)
[8] clohole(f)
try:
    mm.show(mm.read("tmp/fig_04_clohole_didatico.png"), figsize=(18, 3))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_clohole_didatico.png (ver a versao Python)")
Figure 4.10: Pipeline de preenchimento de buracos (clohole): o marcador vem da borda da imagem, restrito ao complemento \(f^c\). A dilatação geodésica reconstrói o fundo externo; após a complementação, os buracos internos ficam preenchidos.
%%writefile tmp/fig_04_edgeoff_didatico.cpp
#define MM_OUT "tmp/fig_04_edgeoff_didatico.png"
//| label: fig-04-edgeoff-didatico
//| fig-cap: "*Pipeline* de eliminação de estruturas de borda (*edgeoff*): o marcador captura as raízes conectadas às extremidades, a reconstrução delimita esses elementos e a subtração preserva só os objetos totalmente internos."
//| echo: true
//| output: true

#include "morph.hpp"
#include <iostream>
#include <vector>
#include <string>

int main() {
    mm::Image f(10, 10);
    std::vector<std::vector<int>> f_data = {
        {0,0,0,0,0,0,0,0,0,0},
        {0,1,1,1,1,0,0,0,0,1},
        {0,1,0,0,1,0,0,1,0,1},
        {0,1,0,0,1,0,0,1,0,1},
        {0,1,1,1,1,0,0,1,0,0},
        {0,0,0,0,0,0,0,0,0,0},
        {0,0,1,1,1,0,1,1,1,0},
        {0,0,1,1,1,0,1,0,1,0},
        {0,0,1,1,1,0,1,1,1,0},
        {0,0,0,0,0,0,0,0,0,1}
    };
    for (int y = 0; y < 10; y++)
        for (int x = 0; x < 10; x++)
            f.at(y, x) = f_data[y][x] * 255;

    mm::Image B_box = mm::sebox();
    mm::Image marcador_eo = mm::band(mm::frame(f, 1), f);   // borda ∩ f

    std::vector<mm::Image> iteracoes;
    std::vector<std::string> titulos;
    mm::Image img_atual = marcador_eo;
    for (int i = 1; i <= 5; i++) {
        img_atual = mm::cdil(img_atual, f, B_box);
        iteracoes.push_back(img_atual);
        titulos.push_back("Iter. (n=" + std::to_string(i) + ")");
    }

    mm::Image img_edgeoff = mm::edgeoff(f, B_box);

    std::vector<mm::Image> all_imgs = {f, marcador_eo};
    all_imgs.insert(all_imgs.end(), iteracoes.begin(), iteracoes.end());
    all_imgs.push_back(img_edgeoff);

    std::vector<std::string> all_titles = {"f original", "Marcador (borda)"};
    all_titles.insert(all_titles.end(), titulos.begin(), titulos.end());
    all_titles.push_back("edgeoff(f)");

    mm::show(all_imgs, MM_OUT, all_titles, 8);

    return 0;
}
Overwriting tmp/fig_04_edgeoff_didatico.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_edgeoff_didatico.cpp -o tmp/fig_04_edgeoff_didatico -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_edgeoff_didatico \
  && test -f "tmp/fig_04_edgeoff_didatico.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_edgeoff_didatico.png"
[1] f original
[2] Marcador (borda)
[3] Iter. (n=1)
[4] Iter. (n=2)
[5] Iter. (n=3)
[6] Iter. (n=4)
[7] Iter. (n=5)
[8] edgeoff(f)
try:
    mm.show(mm.read("tmp/fig_04_edgeoff_didatico.png"), figsize=(18, 3))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_edgeoff_didatico.png (ver a versao Python)")
Figure 4.11: Pipeline de eliminação de estruturas de borda (edgeoff): o marcador captura as raízes conectadas às extremidades, a reconstrução delimita esses elementos e a subtração preserva só os objetos totalmente internos.

4.3.6 Pipeline de Limpeza Binária com CLAHE

Com base na análise anterior — na qual o CLAHE produziu o maior valor da variância interclasses (\(\sigma_B^2 \approx 2{,}47 \times 10^3\)), ver Figure 4.2, e o operador mm::clohole mostrou-se eficaz no preenchimento das cavidades internas —, o pipeline final de segmentação, ilustrado na Figure 4.12, é estruturado pelo seguinte fluxo computacional:

\[ \text{gray} \xrightarrow{\text{CLAHE}} \xrightarrow{\text{Otsu}} \xrightarrow{\text{open}} \xrightarrow{\text{clohole}} \xrightarrow{\text{open}} \xrightarrow{\text{edgeoff}} \text{segmentação} \]

Após a etapa de mm::clohole, aplica-se uma segunda abertura morfológica com um elemento estruturante maior (mm::sedisk(33), disco de diâmetro 33). Essa operação remove pequenas regiões residuais e artefatos que possam ter permanecido após a segmentação. Em particular, o preenchimento geodésico pode transformar pequenas cavidades isoladas em componentes conectados ao objeto, tornando conveniente uma etapa adicional de filtragem baseada em tamanho. O diâmetro foi escolhido de modo que as moedas permaneçam capazes de conter o elemento estruturante, enquanto componentes significativamente menores sejam eliminados.

Nesta imagem, nenhuma moeda está conectada à borda da matriz. Consequentemente, a aplicação de mm::edgeoff não altera o resultado obtido após a segunda abertura. Ainda assim, essa etapa é mantida no pipeline por robustez, pois em outras imagens podem existir objetos parcialmente visíveis ou conectados às bordas, que devem ser removidos antes da etapa de análise.

AstucePourquoi l’ouverture après le clohole ?

L’opérateur mm::clohole remplit toutes les cavités fermées présentes dans les objets segmentés. Dans certaines situations, de petites régions indésirables peuvent subsister après cette étape ou devenir connectées aux objets principaux. L’ouverture morphologique subséquente supprime les composants plus petits que l’élément structurant, tout en préservant les pièces en raison de leur taille significativement plus grande.

%%writefile tmp/fig_04_pipeline_clahe.cpp
#define MM_OUT "tmp/fig_04_pipeline_clahe.png"
//| label: fig-04-pipeline-clahe
//| fig-cap: "*Pipeline* completo de segmentação com CLAHE: Otsu → abertura (r=9) → clohole → abertura (r=33) → edgeoff."
//| echo: true
//| output: true

#include "morph.hpp"
#include <iostream>
#include <vector>
#include <string>
#include <filesystem>

int main() {
// [pdi:state-io] auto-generated — do not edit by hand
mm::Image img_coins_gray = mm::_read_state("tmp/state/img_coins_gray_8.png");
// [pdi:state-io:end]

    // ── Pré-processamento: apenas CLAHE 
    mm::Image img_clahe0 = mm::clahe(img_coins_gray, 2.0, 8);

    // ── Etapa 1: Binarização Otsu
    mm::Image img_bin = mm::threshold(img_clahe0);

    // ── Etapa 2: Abertura — remove ruídos brancos de fundo
    mm::Image img_open = mm::open(img_bin, mm::sedisk(9));

    // ── Etapa 3: clohole — fecha todos os buracos internos
    mm::Image img_hole = mm::clohole(img_open);

    // ── Etapa 4: Abertura (kernel grande) — remove artefatos do clohole
    mm::Image img_limpo = mm::open(img_hole, mm::sedisk(33));

    // ── Etapa 5: edgeoff — remove objetos que tocam a borda 
    mm::Image img_final = mm::edgeoff(img_limpo, mm::SE::box(3), 1);

    mm::show(
        std::vector<mm::Image>{img_clahe0, img_bin, img_open, img_hole, img_limpo, img_final},
        MM_OUT,
        std::vector<std::string>{"CLAHE", "Otsu", "Abertura (r=9)",
                                 "clohole", "Abertura (r=33)", "Final (edgeoff)"},
        6
    );

    
// [pdi:state-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp/state");
mm::write(img_final, "tmp/state/img_final_55.png");
// [pdi:state-io:end]

// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(img_clahe0, "tmp/fig_04_pipeline_clahe_0.png");
mm::write(img_bin, "tmp/fig_04_pipeline_clahe_1.png");
mm::write(img_open, "tmp/fig_04_pipeline_clahe_2.png");
mm::write(img_hole, "tmp/fig_04_pipeline_clahe_3.png");
mm::write(img_limpo, "tmp/fig_04_pipeline_clahe_4.png");
mm::write(img_final, "tmp/fig_04_pipeline_clahe_5.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_04_pipeline_clahe.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_pipeline_clahe.cpp -o tmp/fig_04_pipeline_clahe -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_pipeline_clahe \
  && test -f "tmp/fig_04_pipeline_clahe.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_pipeline_clahe.png"
[1] CLAHE
[2] Otsu
[3] Abertura (r=9)
[4] clohole
[5] Abertura (r=33)
[6] Final (edgeoff)
try:
    mm.show(
        [
            mm.read("tmp/fig_04_pipeline_clahe_0.png"),
            mm.read("tmp/fig_04_pipeline_clahe_1.png"),
            mm.read("tmp/fig_04_pipeline_clahe_2.png"),
            mm.read("tmp/fig_04_pipeline_clahe_3.png"),
            mm.read("tmp/fig_04_pipeline_clahe_4.png"),
            mm.read("tmp/fig_04_pipeline_clahe_5.png"),
        ],
        titles=[
            'CLAHE',
            'Otsu',
            'Abertura (r=9)',
            'clohole',
            'Abertura (r=33)',
            'Final (edgeoff)',
        ],
        cols=6,
        figsize=(18, 6),
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_pipeline_clahe_0.png (ver a versao Python)")
Figure 4.12: Pipeline completo de segmentação com CLAHE: Otsu → abertura (r=9) → clohole → abertura (r=33) → edgeoff.

4.3.7 Morphologie en niveaux de gris

Les opérateurs morphologiques s’étendent naturellement aux images en niveaux de gris. Dans cette formulation, l’érosion et la dilatation agissent directement sur les niveaux d’intensité de l’image. Pour des éléments structurants plats (\(b \equiv 0\)), l’érosion correspond au minimum local et la dilatation au maximum local dans le voisinage défini par l’élément structurant.

L’interprétation intuitive est simple : l’érosion assombrit les régions en remplaçant chaque pixel par la plus petite valeur présente dans son voisinage, tandis que la dilatation éclaircit les régions en utilisant la plus grande valeur disponible. La combinaison de ces opérateurs permet de construire des transformations capables de rehausser les contours, de supprimer les tendances d’éclairage et de mettre en évidence les structures locales.

Trois opérateurs dérivés sont particulièrement utiles :

Gradient morphologique — met en évidence les contours comme la différence entre la dilatation et l’érosion :

\[ \text{grad}_B(f) = (f \oplus B) - (f \ominus B) \tag{4.12}\]

Top-hat — met en évidence les structures brillantes plus petites que l’élément structurant (différence entre l’image originale et son ouverture) :

\[ \text{top-hat}_B(f) = f - (f \circ B) \tag{4.13}\]

Black-hat — met en évidence les structures sombres plus petites que l’élément structurant (différence entre la fermeture et l’image originale) :

\[ \text{black-hat}_B(f) = (f \bullet B) - f \tag{4.14}\]

Le Top-hat extrait les détails brillants qui ne survivent pas à l’ouverture, tandis que le Black-hat révèle les détails sombres supprimés par la fermeture. Quant au gradient morphologique, il rehausse les transitions abruptes d’intensité, produisant une représentation similaire à celle d’un détecteur de contours.

Pour comprendre le mécanisme de ces opérateurs au niveau local, la Figure 4.13 présente un simulateur interactif de morphologie en tons de gris. Le simulateur permet de modifier librement l’élément structurant, de visualiser son déplacement sur l’image et de suivre simultanément le profil unidimensionnel des intensités. Ainsi, il devient possible d’observer directement comment l’érosion sélectionne les minima locaux, comment la dilatation sélectionne les maxima locaux et comment le gradient morphologique émerge de la différence entre ces deux opérateurs.

Le bouton situé dans le coin supérieur droit permet d’alterner entre la visualisation originale en tons de gris et une représentation en fausses couleurs (colormap) pour les trois types de gradients uniquement. La version colorée facilite la perception visuelle des variations d’intensité, rendant plus évidente l’action des opérateurs morphologiques sur les maxima, les minima et les transitions locales de l’image.

Les opérateurs présentés sont disponibles dans morph.py via les fonctions mm::gradm, mm::tophat et mm::blackhat.

🎛️ Simulateur avancé de morphologie mathématique
🖱️ Déplacez la souris pour mettre à jour le profil 1D de la ligne correspondante
X (col)
—
Y (ligne)
—
f(x,y)
—
valeur
—
Élément B (Cliquer pour modifier)
Ouverture (f∘B): dil(ero(f))
Fermeture (f•B): ero(dil(f))
Gradient: dil(f) − ero(f)
Top-hat: f − (f∘B)
Black-hat: (f•B) − f
Profil 1D de la ligne: Aucun (passez la souris sur l'image)
Figure 4.13: Simulateur interactif avancé de morphologie avec élément structurant modifiable et profil 1D.

La Figure 4.14 illustre les effets de ces opérateurs sur l’image des pièces et leurs histogrammes respectifs. On observe que l’érosion déplace la distribution vers des intensités plus faibles, tandis que la dilatation la déplace vers des intensités plus élevées. Le gradient concentre les valeurs dans les régions de contour, et les opérateurs Top-hat et Black-hat produisent des histogrammes fortement concentrés à de faibles niveaux d’intensité, car seules de petites structures locales sont mises en évidence.

%%writefile tmp/fig_04_morf_gc_histogramas.cpp
#define MM_OUT "tmp/fig_04_morf_gc_histogramas.png"
//| label: fig-04-morf-gc-histogramas
//| fig-cap: "Morfologia em tons de cinza e seus histogramas: erosão, dilatação, gradiente, top-hat e black-hat. Elemento estruturante: disco de diâmetro 19."
//| echo: true
//| output: true

#include "morph.hpp"
#include <iostream>
#include <vector>
#include <string>

int main() {
// [pdi:state-io] auto-generated — do not edit by hand
mm::Image img_coins_gray = mm::_read_state("tmp/state/img_coins_gray_8.png");
// [pdi:state-io:end]

    mm::Image B = mm::sedisk(19);
    mm::Image img_ero  = mm::ero(img_coins_gray, B);
    mm::Image img_dil  = mm::dil(img_coins_gray, B);
    mm::Image img_grad = mm::gradm(img_coins_gray, B);
    mm::Image img_th   = mm::tophat(img_coins_gray, B);
    mm::Image img_bh   = mm::blackhat(img_coins_gray, B);

    mm::show(
        std::vector<mm::Image>{img_coins_gray, mm::histImg(img_coins_gray), img_ero, mm::histImg(img_ero),
                               img_dil, mm::histImg(img_dil), img_grad, mm::histImg(img_grad),
                               img_th, mm::histImg(img_th), img_bh, mm::histImg(img_bh)},
        MM_OUT,
        std::vector<std::string>{"Original", "Hist", "Erosão", "Hist", "Dilatação", "Hist",
                                 "Gradiente", "Hist", "Top-hat", "Hist", "Black-hat", "Hist"},
        4
    );

    return 0;
}
Overwriting tmp/fig_04_morf_gc_histogramas.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_morf_gc_histogramas.cpp -o tmp/fig_04_morf_gc_histogramas -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_morf_gc_histogramas \
  && test -f "tmp/fig_04_morf_gc_histogramas.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_morf_gc_histogramas.png"
[1] Original
[2] Hist
[3] Erosão
[4] Hist
[5] Dilatação
[6] Hist
[7] Gradiente
[8] Hist
[9] Top-hat
[10] Hist
[11] Black-hat
[12] Hist
try:
    mm.show(mm.read("tmp/fig_04_morf_gc_histogramas.png"))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_morf_gc_histogramas.png (ver a versao Python)")
Figure 4.14: Morfologia em tons de cinza e seus histogramas: erosão, dilatação, gradiente, top-hat e black-hat. Elemento estruturante: disco de diâmetro 19.

Les opérateurs morphologiques présentés précédemment seront désormais utilisés comme outils de raffinement et de génération de marqueurs pour des méthodes de segmentation plus avancées, présentées ci-après.

4.4 Segmentation d’Images : Fondements et Taxinomie

La segmentation d’images consiste à diviser l’image en régions associées à des objets ou à des structures d’intérêt. En TNI, elle représente la transition entre le traitement de bas niveau — comme le filtrage et le rehaussement — et des étapes d’analyse plus avancées, telles que l’extraction de caractéristiques, la reconnaissance et l’interprétation de la scène.

Formellement, l’objectif de la segmentation consiste à décomposer le domaine spatial complet d’une image, noté \(\Omega\), en une partition de sous-ensembles \(\{R_1, R_2, \ldots, R_n\}\) qui satisfait simultanément les critères de complétude et de disjonction :

\[ \bigcup_{i=1}^{n} R_i = \Omega, \qquad R_i \cap R_j = \emptyset \quad \forall\, i \neq j \tag{4.15}\]

Outre les propriétés de complétude et de disjonction exprimées dans la Équation 4.15, chaque sous-région \(R_i\) doit constituer un domaine homogène selon un prédicat de similarité défini sur des propriétés locales — intensité, couleur ou texture — et, simultanément, être distincte des régions adjacentes.

Les techniques de segmentation peuvent être organisées en différentes familles. Dans ce chapitre, l’accent sera mis sur les approches résumées dans la Table 4.4, fondées principalement sur des critères d’intensité, de connectivité et de proximité spatiale.

Table 4.4: Taxinomie simplifiée des principales approches de segmentation et d’affinement étudiées dans ce chapitre.
Approche Critère de Segmentation Opérateurs de Référence
Seuillage Partitionnement de l’espace des intensités Critère d’Otsu, seuillage global et local
Morphologie Mathématique Relations spatiales définies par des éléments/fonctions structurants Érosion, dilatation, ouverture, fermeture et reconstruction
Basée sur les Régions Homogénéité locale et connectivité spatiale Étiquetage des composantes connexes, Transformée de Distance et Watershed

Jusqu’à ce point, le développement pratique s’est concentré sur le seuillage, au moyen de la combinaison entre l’égalisation adaptative CLAHE et la méthode globale d’Otsu. Cette étape a été complétée par des opérateurs de reconstruction morphologique basés sur des dilatations géodésiques, implémentés par les fonctions mm::infrec, mm::clohole et mm::edgeoff, produisant un masque binaire propre et adapté à l’analyse.

Cependant, dans les scénarios où des objets distincts apparaissent connectés dans le masque binaire — que ce soit par contact physique, chevauchement partiel ou par de minces ponts de pixels produits par la segmentation —, le seuillage cesse d’être suffisant pour individualiser chaque objet. Dans ces cas, de multiples objets composent une seule composante connexe, ce qui complique les étapes ultérieures de mesure et d’interprétation.

Pour surmonter cette limitation, les prochaines sections introduisent trois outils complémentaires : l’Étiquetage des Composantes Connexes, la Transformée de Distance et l’algorithme de segmentation par Watershed basé sur des marqueurs. Ensemble, ces techniques permettent de séparer les objets adjacents, d’identifier les régions individuellement et d’extraire des descripteurs géométriques cohérents pour une analyse quantitative.

4.4.1 Étiquetage

Le étiquetage de composantes connexes (connected component labeling) est l’opérateur qui attribue un identifiant entier unique à chaque ensemble de pixels appartenant à une même composante connexe dans une image binaire.

NoteDéfinition formelle

Étant donné une image binaire \(f\) et une relation de connectivité définie par un élément structurant \(B\) (typiquement connectivité-4 ou connectivité-8), l’algorithme d’étiquetage de la Figure 4.15 produit une image \(g\) dans laquelle tous les pixels appartenant à la même composante connexe reçoivent le même étiquette entier positif, tandis que les pixels appartenant à des composantes distinctes reçoivent des étiquettes différentes.

La connectivité définit quels pixels sont considérés comme voisins directs d’un pixel \((x,y)\). Les définitions les plus couramment utilisées sont :

  • Connectivité-4 : ne considère que les quatre voisins orthogonaux (nord, sud, est et ouest).
  • Connectivité-8 : considère les quatre voisins orthogonaux ainsi que les quatre voisins diagonaux, totalisant huit voisins.

Le choix de la connectivité influence directement la formation des composantes connexes et, par conséquent, le résultat de l’étiquetage, comme illustré dans la Figure 4.17. Un exemple supplémentaire peut être exploré de manière interactive dans le simulateur présenté dans la Figure 4.16.

L’implémentation dans morph.py propose deux versions de cet opérateur. La fonction mm::label0 reproduit explicitement l’algorithme de flood-fill à l’aide d’une pile et permet de contrôler la connectivité via l’élément structurant adopté. Quant à mm::label, elle délègue l’opération à l’implémentation optimisée d’OpenCV (mm::label0). Dans les deux cas, le résultat est une image étiquetée dans laquelle chaque composante connexe reçoit un identifiant entier distinct.

Algoritmo de rotulação por flood-fill com pilha — painel interativo com HTML e SVG

Passo a passo
Cards
Linha do tempo
Código Python
Fluxograma
Flood-fill com pilha
Rotulagem de componentes conexas
1
Criar imagem de saída g, inicializada com zeros, mesma dimensão de f.
2
Inicializar contador de rótulos (cor) cor ← 1.
3
Percorrer f em ordem raster (coordenadas x e y) até encontrar uma semente: pixel ativo (f[x,y] ≠ 0) ainda não rotulado (g[x,y] = 0).
4
Inserir a semente encontrada na pilha pilha ← [[x,y]].
5
Enquanto a pilha contiver coordenadas (while pilha):
Desempilhar pixel atual: i, j ← pilha.pop() e atribuir o rótulo: g[i,j] ← cor.
Buscar vizinhos usando o iterador mm._viz(f,b,i,j). Se o vizinho for ativo no elemento estruturante (bv ≠ 0), ativo na imagem (f[vy,vx] ≠ 0) e não rotulado (g[vy,vx] = 0), empilhá-lo.
6
Pilha vazia ⟹ Toda a componente conexa atual foi explorada e rotulada com sucesso.
7
Incrementar o rótulo para a próxima componente: cor ← cor + 1 e continuar a varredura raster.
A conectividade (4 ou 8 vizinhos) é definida unicamente pela matriz morfológica b passada como parâmetro, alterando os pixels retornados em mm._viz.
01
Inicialização
Criar matriz de rótulos g preenchida com zeros (fundo). Definir rótulo inicial cor ← 1.
02
Varredura Raster
Percorrer a matriz bidimensional linha por linha, localizando pixels pertencentes ao objeto que ainda não possuem rótulo.
03
Semente inicial
Ao achar um pixel válido, inicializar a estrutura LIFO de busca: pilha = [[x, y]].
04
Expansão por Flood-Fill
Enquanto houver elementos na pilha:
Extrair (i, j) via pop() e marcar g[i, j] = cor.
Inspecionar vizinhança geométrica e adicionar novos candidatos à pilha.
05
Próxima Componente
Pilha esvaziada ⟹ Incrementar indexador cor ← cor + 1 para diferenciar o próximo objeto isolado.
01
Alocação Espacial
g ← zeros_like(f) e definição do primeiro identificador: cor ← 1.
02
Varredura Bidimensional
Laços encadeados varrendo as dimensões h e w da imagem.
03
Descoberta de Objeto
Filtro condicional localiza pixel ativo não indexado e cria a pilha semente.
04–05
Preenchimento por Região (Flood-fill)
while pilha
Remover último da pilha (i,j) e aplicar rótulo atual.
Empilhar vizinhos conectados que atendam aos critérios morfológicos de b.
06
Fechamento do Objeto
Pilha vazia determina o fim do isolamento daquela componente.
07
Atualização do Rótulo
Incremento linear: cor ← cor + 1. A varredura raster continua do ponto onde parou.
label0.py flood-fill com pilha
def label0(f, b=np.ones((3,3),dtype='uint8')):
    """Rotulagem por flood-fill com pilha."""
    h, w = f.shape
    g = np.zeros(f.shape, dtype=int)
    cor = 1
    for x in range(h):
        for y in range(w):
            if f[x,y] and not g[x,y]:
                pilha = [[x,y]]
                while pilha:
                    i,j = pilha.pop(); g[i,j] = cor
                    for vy,vx,bv in mm._viz(f,b,i,j):
                        if bv and f[vy,vx] and not g[vy,vx]:
                            pilha.append([vy,vx])
                cor += 1
    return g
1
g = np.zeros(f.shape, dtype=int) — Inicializa a matriz de saída com zeros. Zeros representam o fundo invariável.
2
mm._viz(f, b, i, j) — O iterador morfológico avalia a conectividade. Passando B_cruz a busca expande em 4-vizinhança; passando quadrado (ones) expande em 8-vizinhança.
3
pilha.pop() — Remove o último par de coordenadas inserido, caracterizando um comportamento LIFO de busca em profundidade (DFS) para varrer o objeto de forma contígua.
4
cor += 1 — O incremento ocorre estritamente fora do laço while, garantindo que o mesmo número marque toda a extensão da componente concluída antes de passar para a próxima semente raster.
início inicializar saída g ← zeros(f.shape); cor ← 1 varredura raster próximo pixel (x, y) em f imagem toda varrida? fim f[x,y] ≠ 0 e g[x,y] = 0? inserir semente pilha ← [[x, y]] pilha vazia? i, j ← pilha.pop() g[i, j] ← cor inspecionar vizinhos se ativo e não rotulado → pilha.append([vy, vx]) cor ← cor + 1 próximo rótulo sim não sim não sim
Figure 4.15: Algorithme d’étiquetage par flood-fill avec pile.

L’auxiliaire _viz itère sur la fenêtre structurante b centrée en \((i,j)\), ne générant que les voisins valides à l’intérieur des limites de l’image — la connectivité souhaitée est entièrement déterminée par la forme de b passée à l’algorithme.

Exemple didactique — effet de la connectivité :

🪙 Simulateur : Étiquetage des Composantes Connexes remplissage par pile
Pixels Actifs
0
Composantes
0
Connectivité
4
Pas de Raster
–
🖱️ Cliquez pour activer/désactiver les pixels · Glissez pour peindre
Connectivité
4 voisins orthogonaux : N, S, E, O
Visualisation
Exemples Prédéfinis
Figure 4.16: Simulateur interactif d’étiquetage de composantes connexes (connected component labeling) : visualisation de l’expansion flood-fill, connectivité-4 et connectivité-8.
%%writefile tmp/fig_04_rotulacao_didatico.cpp
#define MM_OUT "tmp/fig_04_rotulacao_didatico.png"
//| label: fig-04-rotulacao-didatico
//| fig-cap: "Efeito da conectividade na rotulação (*mm::label0*): pixels diagonalmente adjacentes formam componentes distintas em conectividade-4 e se fundem em conectividade-8."
//| echo: true
//| output: true

#include "morph.hpp"
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>

// Funcionamento normal da rotulação com diferentes conectividades
int main() {
    mm::Image f(10, 10);
    // Construir f a partir do array
    int dados[10][10] = {
        {0,0,0,0,0,0,0,0,0,0},
        {0,1,0,0,0,0,0,0,0,0},
        {0,0,1,0,0,0,0,0,0,0},
        {0,0,0,1,0,0,1,1,0,0},
        {0,0,0,0,0,0,1,1,0,0},
        {0,0,0,0,0,0,0,0,0,0},
        {0,1,1,1,0,0,0,0,0,0},
        {0,1,0,1,0,0,0,1,0,0},
        {0,1,1,1,0,0,0,0,1,0},
        {0,0,0,0,0,0,0,0,0,0}
    };
    for (int y = 0; y < 10; y++)
        for (int x = 0; x < 10; x++)
            f.at(y, x) = dados[y][x] * 255;

    mm::Image B4 = mm::secross();   // conectividade-4
    mm::Image B8 = mm::sebox();     // conectividade-8

    mm::Image lbl4 = mm::label0(f, B4);
    mm::Image lbl8 = mm::label0(f, B8);

    // Calcular n4 e n8 como valores máximos
    int n4 = 0, n8 = 0;
    for (int i = 0; i < (int)lbl4.data.size(); i++) {
        if (lbl4.data[i] > n4) n4 = lbl4.data[i];
    }
    for (int i = 0; i < (int)lbl8.data.size(); i++) {
        if (lbl8.data[i] > n8) n8 = lbl8.data[i];
    }
    std::cout << "Componentes C4: " << n4 << "  |  C8: " << n8 << "\n";

    // Função norm_label
    auto norm_label = [](const mm::Image& lbl, int mx) {
        mm::Image out = lbl;
        for (int y = 0; y < lbl.h; y++) {
            for (int x = 0; x < lbl.w; x++) {
                if (lbl.at(y, x))
                    out.at(y, x) = (unsigned char)((int)lbl.at(y, x) * 255 / mx);
            }
        }
        return out;
    };

    int mx4 = std::max(n4, 1);
    int mx8 = std::max(n8, 1);

    mm::show(
        std::vector<mm::Image>{f, norm_label(lbl4, mx4), norm_label(lbl8, mx8)},
        MM_OUT,
        std::vector<std::string>{"f original", "C4 (" + std::to_string(n4) + " comp.)", "C8 (" + std::to_string(n8) + " comp.)"},
        3
    );

    return 0;
}
Overwriting tmp/fig_04_rotulacao_didatico.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_rotulacao_didatico.cpp -o tmp/fig_04_rotulacao_didatico -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_rotulacao_didatico \
  && test -f "tmp/fig_04_rotulacao_didatico.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_rotulacao_didatico.png"
Componentes C4: 7  |  C8: 4
[1] f original
[2] C4 (7 comp.)
[3] C8 (4 comp.)
try:
    mm.show(mm.read("tmp/fig_04_rotulacao_didatico.png"))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_rotulacao_didatico.png (ver a versao Python)")
Figure 4.17: Efeito da conectividade na rotulação (mm::label0): pixels diagonalmente adjacentes formam componentes distintas em conectividade-4 e se fundem em conectividade-8.

4.4.2 Transformée de Distance

La Transformée de Distance (TD) est un opérateur qui, appliqué à une image binaire \(f\), produit une image en niveaux de gris \(D\) dans laquelle chaque pixel appartenant à l’objet (\(f(x,y)\neq 0\)) reçoit comme valeur la distance géométrique jusqu’au pixel de fond (\(f(x',y')=0\)) le plus proche :

\[ D(x,y) = \min_{(x',y') \,:\, f(x',y')=0} \; d\bigl((x,y),\,(x',y')\bigr) \]

où \(d(\cdot,\cdot)\) est une métrique de distance — typiquement la distance euclidienne (\(L_2\)). Le résultat est une représentation topographique des objets : les pixels situés à l’intérieur prennent des valeurs élevées, tandis que les pixels proches des bords présentent de faibles valeurs de distance. Les maxima locaux de \(D\) correspondent aux points les plus éloignés du bord de l’objet, souvent proches de leurs centres géométriques ou de leurs centres d’inscription maximale — propriété particulièrement utile pour la génération automatique de marqueurs dans l’algorithme watershed.

NoteDéfinition formelle utilisant des érosions

La TD admet également une interprétation morphologique itérative, selon l’algorithme de la Figure 4.18. Considérons un élément structurant \(b\) dont la valeur centrale est nulle et dont les voisins possèdent des coûts négatifs associés au déplacement. En appliquant des érosions successives avec cet élément structurant particulier, les valeurs des pixels des objets (qui doivent prendre la distance maximale possible de l’image) sont progressivement réduites selon les coûts définis par \(b\). La valeur accumulée de cette propagation représente alors la distance au fond selon la métrique induite par l’élément structurant.

Cette interprétation est implémentée dans mm::dist1(), qui accumule des érosions successives en utilisant l’opération mm::ero1(). Quant à mm::dist(), elle délègue le calcul de la distance euclidienne à l’opérateur optimisé d’OpenCV mm::dist(f), où f est l’image binaire d’entrée, la distance L2 spécifie la métrique euclidienne (\(L_2\)) et 5 indique l’utilisation d’un masque 5×5 pour approximer la distance avec une grande précision.

La fonction dist1 produit une transformée de distance discrète dont la métrique est déterminée par la géométrie et les poids de l’élément structurant utilisé. Par exemple, en utilisant un élément structurant en croix avec un coût unitaire pour les quatre voisins orthogonaux, on obtient la distance de Manhattan (\(L_1\)). D’autres choix de voisinage et de poids induisent des métriques différentes. Quant à mm::dist(), elle calcule une approximation efficace de la distance euclidienne (\(L_2\)).

En raison de la nécessité d’érosions successives sur toute l’image, l’approche dist1 présente un coût computationnel significativement plus élevé que mm::dist(), étant utilisée dans ce livre principalement à des fins pédagogiques et pour mettre en évidence la relation entre la morphologie mathématique et les transformées de distance.

Transformada de distância por erosões numéricas sucessivas — painel interativo

Passo a passo
Cards
Linha do tempo
Código Python
Fluxograma
Transformada de distância por erosão numérica
Propagação matemática de distâncias via elemento estruturante com pesos
1
Inicializar a imagem de trabalho fazendo uma cópia da original: g ← f.copy(). Os pixels de fundo (0) servem como fontes de distância nula.
2
Entrar em um laço infinito de erosões com pesos (ponto fixo):
Salvar estado anterior: f ← g.copy().
Erodir: g ← ero1(g, b), aplicando a subtração local de pesos e computando o valor mínimo para cada vizinhança.
3
Verificar convergência: se f for idêntica a g (array_equal), a frente de onda de distâncias se estabilizou. Romper o laço (break).
4
Retornar a matriz modificada g contendo o mapa exato de distâncias.
Nesta abordagem morfológica numérica, não há incremento artificial ou contador. A distância propaga-se de fora para dentro porque a erosão contínua puxa o valor 0 do fundo e o decrementa matematicamente (subtraindo os pesos negativos como -1), fazendo com que os valores escalem radialmente.
Cruz — L₁ (Manhattan)
B_cruz [y,x]
Pesos: Centro=0, Lados=-1, Cantos=-inf
Erosão de Cinzas
f[vy,vx] - bv
Subtrai o peso e busca o valor mínimo local
Convergência
f == g
Para quando nenhum pixel muda de valor
01
Inicialização
Clonar imagem de entrada: g ← f.copy(). O objeto possui intensidade alta (255) e o fundo possui intensidade 0.
02
Mapeamento Local (ero1)
Para cada coordenada (y, x), buscar o mínimo valor da operação f[vy, vx] - bv aplicada à sua vizinhança estruturante.
03
Loop Iterativo
Atualizar sequencialmente: f = g.copy() seguido de g = ero1(g, b). Os valores nulos propagam-se para o interior do objeto.
04
Critério de Parada
Se np.array_equal(f, g), significa que o mapa de distâncias atingiu o equilíbrio estável e a propagação terminou.
01
Cópia de Trabalho
Prepara a matriz inicial `g`.
02
Loop de Erosão de Escala
while True
Guarda estado: f ← g.copy()
Aplica erosão com pesos: g ← ero1(g, b)
03
Estabilização Espacial
Condição de parada acionada assim que np.array_equal(f, g) se torna verdadeiro.
04
Retorno Numérico
Retorna g contendo as distâncias calculadas pela subtração cumulativa dos pesos.
morph_dist.py Erosão numérica iterativa
@staticmethod
def ero1(f, b):
    g = np.empty_like(f)
    for y in range(f.shape[0]):
        for x in range(f.shape[1]):
            g[y,x] = 255
            for vy,vx,bv in mm._viz(f,b,y,x):
                if np.isinf(bv): continue 
                val = int(f[vy,vx]) - int(bv)
                if g[y,x] > val: 
                    g[y,x] = max(0, val)
    return g

@staticmethod
def dist1(f, b):
    g = f.copy()
    while True:
        f = g.copy()
        g = mm.ero1(g, b)
        if np.array_equal(f, g): 
            break
    return g
1
g[y,x] = 255 — Inicializa o elemento com o valor máximo antes de computar o operador de mínimo da erosão.
2
f[vy,vx] - bv — Subtrai o peso associado da vizinhança. Como os pesos da cruz externa são negativos (ex: -1), a operação torna-se uma adição matemática (f[vy,vx] - (-1) = f[vy,vx] + 1) propagando a distância a partir das bordas zeradas.
3
np.array_equal(f, g) — Critério de convergência exato por estabilização de ponto fixo.
início inicializar mapa g ← f.copy() salvar estado anterior f ← g.copy() executar erosão com pesos g ← ero1(g, b) array_equal(f, g) estabilizou? não sim retornar g fim Ponto Fixo Numérico: A distância emerge da propagação matemática do valor zero.
Figure 4.18: Algorithme de la Transformée de Distance.

A Figure 4.19 apresenta um simulador interativo da TD: é possível posicionar o cursor sobre diferentes pixels do objeto e observar, em tempo real, o valor da distância associado àquela posição, isto é, a distância até o pixel de fundo mais próximo. A Figure 4.20 apresenta um exemplo prático dessa execução em ambiente Python.

A Figure 4.19 apresenta um simulador interativo da transformada de distância (TD): é possível posicionar o cursor sobre diferentes pixels do objeto e observar, em tempo real, o valor da distância associado a essa posição, ou seja, a distância até o pixel de fundo mais próximo. A Figure 4.20 apresenta um exemplo prático dessa execução em ambiente Python.

🗺️ Simulateur : Transformée de Distance (TD) Frontières à +∞ (144)
Pixels Actifs
0
Distance Max.
0
Métrique Actuelle
L∞ (Tchebychev)
Itération (k)
–
🖱️ Cliquez pour activer/désactiver les pixels · Glissez pour peindre
Élément Structurant (b)
Cliquez pour modifier les poids :
Visualisation
Exemples Prédéfinis
Figure 4.19: Simulateur interactif de la Transformée de Distance (TD) itérative via érosion en niveaux de gris. Les pixels hors de l’image prennent la valeur maximale (144), propageant les coûts depuis le fond interne.
%%writefile tmp/fig_04_distancia_didatico.cpp
#define MM_OUT "tmp/fig_04_distancia_didatico.png"
//| label: fig-04-distancia-didatico
//| fig-cap: "Transformada de Distância em imagem binária 10×10. Esquerda: original (*foreground* = 255). Centro: mm.dist1 iterativa (erosões com cruz). Direita: mm.dist (L2)."
//| echo: true
//| output: true

#include "morph.hpp"
#include <iostream>
#include <filesystem>

int main() {
    mm::Image f(10, 10);
    std::fill(f.data.begin(), f.data.end(), 255);
    f.at(0, 0) = 0;

    mm::SE B_cruz{{mm::SE_OUT, -1, mm::SE_OUT}, {-1, 0, -1}, {mm::SE_OUT, -1, mm::SE_OUT}};

    mm::Image d_iter = mm::dist1(f, B_cruz);
    mm::Image d_l2   = mm::dist(f);

    // Max values
    unsigned char max_iter = 0, max_l2 = 0;
    for (int i = 0; i < d_iter.h * d_iter.w; ++i) {
        if (d_iter.data[i] > max_iter) max_iter = d_iter.data[i];
        if (d_l2.data[i] > max_l2) max_l2 = d_l2.data[i];
    }
    std::cout << "Máx. dist1 (erosões) : " << (int)max_iter << " px\n";
    std::cout << "Máx. dist  (L2)      : " << (int)max_l2 << " px\n";

    mm::show({f, d_iter, d_l2}, MM_OUT, {"f original", "mm.dist1 (erosões com cruz)", "mm.dist (L2)"}, 3);

    
// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(f, "tmp/fig_04_distancia_didatico_0.png");
mm::write(d_iter, "tmp/fig_04_distancia_didatico_1.png");
mm::write(d_l2, "tmp/fig_04_distancia_didatico_2.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_04_distancia_didatico.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_distancia_didatico.cpp -o tmp/fig_04_distancia_didatico -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_distancia_didatico \
  && test -f "tmp/fig_04_distancia_didatico.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_distancia_didatico.png"
Máx. dist1 (erosões) : 18 px
Máx. dist  (L2)      : 13 px
[1] f original
[2] mm.dist1 (erosões com cruz)
[3] mm.dist (L2)
try:
    mm.show(
        [
            mm.read("tmp/fig_04_distancia_didatico_0.png"),
            mm.read("tmp/fig_04_distancia_didatico_1.png"),
            mm.read("tmp/fig_04_distancia_didatico_2.png"),
        ],
        titles=[
            'f original',
            'mm.dist1 (erosões com cruz)',
            'mm.dist (L2)',
        ],
        cols=3,
        axis=True,
        figsize=(12, 4),
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_distancia_didatico_0.png (ver a versao Python)")
Figure 4.20: Transformada de Distância em imagem binária 10×10. Esquerda: original (foreground = 255). Centro: mm.dist1 iterativa (erosões com cruz). Direita: mm.dist (L2).

L’annotation des valeurs numériques directement sur les pixels permet de vérifier comment dist1 propage les distances selon la métrique induite par la fonction structurante utilisée. Dans le cas de l’élément en croix à coût unitaire, les valeurs obtenues correspondent à la distance de Manhattan (\(L_1\)). Bien que dist1 et mm::dist produisent des valeurs numériques distinctes en adoptant des métriques différentes, les deux transformées préservent la structure topographique des objets, faisant en sorte que leurs maxima se produisent dans des régions centrales similaires. Cette propriété justifie l’utilisation de mm::dist dans des applications pratiques, en raison de sa grande efficacité computationnelle.

4.4.3 Transformada de Distância Euclidiana em quatro passos

Le simulateur de la Figure 4.21 implémente l’algorithme de la TDE de Lotufo (2001) en deux étapes. Dans la première étape, la fonction edt1 effectue une transformation unidimensionnelle verticale de manière séquentielle (in-place), parcourant chaque colonne en raster (↓) et en anti-raster (↑) pour calculer les distances dans la direction verticale (deux étapes : Sud et Nord). Dans la deuxième étape, la fonction edt2 utilise ce résultat comme entrée et effectue une propagation horizontale par files : pour chaque ligne de la matrice, deux files de priorité, Eq et Wq, sont initialisées en parcourant les indices de colonne dans des directions opposées (Eq de W-1 à 1, Wq de 2 à W), de sorte que chaque pixel mis à jour met immédiatement en file ses voisins pour retraitement dans la même itération (deux étapes supplémentaires : Est et Ouest). Ce mécanisme de file permet d’appliquer des érosions successives avec des poids impairs croissants (b = 1, 3, 5, ..., incrémenté à chaque itération de la boucle externe) sans que la propagation ne reste piégée dans des valeurs obsolètes, car chaque itération résout complètement la chaîne de dépendances horizontale avant l’incrément suivant de b. La convergence de cette propagation produit la Transformada de Distância Euclidiana sur toute la matrice, combinant l’information verticale obtenue dans edt1 avec la propagation horizontale par file réalisée dans edt2.

📐 EDT² 2D Corrigé · Matrice 4×4 Convergence Exacte
Étape 1 : edt1 Vertical In-place · Étape 2 : edt2 Horizontal In-place
Utilise la même structure raster/anti-raster décrite dans l'article pour la propagation de distances exactes (exemple de l'article, p. 103).
Étape Actuelle
–
Phase
–
b Actuel
–
Figure 4.21: Simulateur interactif 2D (4x4) avec synchronisation stricte des files de propagation horizontale (b) pour obtenir la convergence exacte décrite dans l’article.

4.4.3.1 Transformée de Distance Géodésique

La transformée de distance géodésique associe à chaque pixel la distance minimale jusqu’à un marqueur, sous la contrainte imposée par un masque. Ainsi, la propagation s’effectue exclusivement à travers les pixels autorisés, préservant la connectivité du domaine.

La Figure 4.22 illustre ce processus dans un labyrinthe : (a) le masque g ; (b) la distance géodésique D1 calculée depuis l’entrée ; (c) la distance D2 calculée depuis la sortie ; et (d) le chemin minimal obtenu à partir de ces deux transformées.

Le chemin optimal est déterminé par la somme des distances (D1 + D2). Les pixels appartenant à la trajectoire minimale sont ceux pour lesquels cette somme atteint sa plus petite valeur, définissant une connexion entre l’entrée et la sortie avec une longueur géodésique minimale.

Ce principe permet de résoudre des labyrinthes sans avoir à explorer explicitement toutes les possibilités de parcours. La solution émerge directement de la propagation des distances dans un domaine restreint. Cette approche est particulièrement pertinente pour les labyrinthes de haute complexité, comme ceux construits à partir de structures quasicristallines et de cycles hamiltoniens décrits par Singh (2024).. Dans Zampirolli (2025), ce même formalisme est employé pour résoudre un labyrinthe complexe ; ensuite, la méthode est illustrée sur une version simplifiée du problème.

%%writefile tmp/fig_04_gdist_menor_caminho.cpp
#define MM_OUT "tmp/fig_04_gdist_menor_caminho.png"
//| label: fig-04-gdist-menor-caminho
//| fig-cap: "Menor caminho geodésico em um labirinto. As distâncias geodésicas são calculadas a partir da entrada e da saída usando mm.gdist. Os pixels cujo somatório das duas distâncias é igual à distância mínima entre os marcadores pertencem a um caminho ótimo."
//| echo: true
//| output: true

#include "morph.hpp"
#include <iostream>
#include <vector>
#include <string>

int main() {

    // 1 = corredor, 0 = parede
    mm::Image g(10, 10);
    // Initialize g with the maze values
    int maze[10][10] = {
        {0,1,0,0,0,0,0,0,0,0},
        {0,1,1,1,1,1,0,1,1,1},
        {0,0,0,0,0,1,0,1,0,1},
        {0,1,1,1,0,1,1,1,0,1},
        {0,1,0,1,0,0,0,0,0,1},
        {0,1,0,1,1,1,1,1,1,1},
        {0,1,0,0,0,0,0,0,1,0},
        {0,1,1,1,1,1,1,0,1,0},
        {0,0,0,0,0,0,1,1,1,0},
        {0,0,0,0,0,0,0,0,1,0}
    };
    for (int y = 0; y < 10; y++) {
        for (int x = 0; x < 10; x++) {
            g.at(y, x) = maze[y][x];
        }
    }

    // marcador da entrada
    mm::Image entrada(10, 10);
    entrada.at(0, 1) = 1;

    // marcador da saída
    mm::Image saida(10, 10);
    saida.at(9, 8) = 1;

    // distâncias geodésicas
    mm::Image D1 = mm::gdist(g, entrada);
    mm::Image D2 = mm::gdist(g, saida);

    // soma das distâncias
    mm::Image S(10, 10);
    for (int i = 0; i < 100; i++) {
        S.data[i] = D1.data[i] + D2.data[i];
    }

    // menor valor válido da soma
    int dmin = 255;
    for (int i = 0; i < 100; i++) {
        if (S.data[i] > 0 && S.data[i] < dmin) {
            dmin = S.data[i];
        }
    }

    // pixels pertencentes a um caminho ótimo
    mm::Image caminho(10, 10);
    for (int i = 0; i < 100; i++) {
        caminho.data[i] = (S.data[i] == dmin) ? 255 : 0;
    }

    std::cout << "Distância geodésica mínima: " << dmin << "\n";

    mm::show(
        std::vector<mm::Image>{g, D1, D2, caminho},
        MM_OUT,
        std::vector<std::string>{
            "Labirinto",
            "Distância da Entrada",
            "Distância da Saída",
            "Menor Caminho\n(d=" + std::to_string(dmin) + ")"
        },
        4
    );

    return 0;
}
Overwriting tmp/fig_04_gdist_menor_caminho.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_gdist_menor_caminho.cpp -o tmp/fig_04_gdist_menor_caminho -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_gdist_menor_caminho \
  && test -f "tmp/fig_04_gdist_menor_caminho.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_gdist_menor_caminho.png"
Distância geodésica mínima: 15
[1] Labirinto
[2] Distância da Entrada
[3] Distância da Saída
[4] Menor Caminho
(d=15)
try:
    mm.show(mm.read("tmp/fig_04_gdist_menor_caminho.png"), figsize=(14, 4))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_gdist_menor_caminho.png (ver a versao Python)")
Figure 4.22: Menor caminho geodésico em um labirinto. As distâncias geodésicas são calculadas a partir da entrada e da saída usando mm.gdist. Os pixels cujo somatório das duas distâncias é igual à distância mínima entre os marcadores pertencem a um caminho ótimo.

4.4.4 Segmentation par Watershed

L’algorithme Watershed interprète une image en niveaux de gris comme une surface topographique, où les valeurs élevées correspondent à des montagnes et les valeurs faibles à des vallées ou bassins de drainage (catchment basins). Dans le contexte de la segmentation basée sur des marqueurs, les maxima de la Transformée de Distance sont souvent utilisés pour identifier des régions internes aux objets, fournissant ainsi des graines fiables pour le processus d’inondation.

La segmentation est ensuite réalisée par une simulation conceptuelle d’inondation progressive à partir de ces marqueurs. Au fur et à mesure que les bassins associés à différentes graines s’étendent, les régions voisines finissent par entrer en contact. À cet instant, des barrières virtuelles, appelées lignes de partage des eaux (watershed lines), sont construites et délimitent les objets de la scène. Ce mécanisme permet de séparer des objets adjacents ou partiellement superposés, même lorsqu’ils forment un seul composant connexe après le seuillage.

L’implémentation didactique présentée dans ce chapitre explore d’abord le concept de croissance de régions (region growing) confinée par un masque binaire, comme détaillé dans l’algorithme interactif de la Figure 4.23.

NoteVersion didactique versus implémentation classique

La fonction mm.watershed0` n’implémente pas l’algorithme watershed classique. Son objectif est d’illustrer, de manière simplifiée, la propagation des marqueurs par croissance de régions (region growing), permettant ainsi de visualiser comment différentes graines se disputent l’occupation de l’espace disponible. La croissance est délimitée par un masque binaire de support et contrôlée par un mécanisme de stagnation, générant un résultat semblable à une partition de Voronoi restreinte à la géométrie des objets d’entrée.

Quant à la fonction mm::watershed, elle utilise l’implémentation optimisée d’OpenCV (mm::watershed), qui réalise l’inondation sur une surface topographique définie par l’image d’entrée. Dans ce cas, la propagation des marqueurs est influencée par les valeurs des pixels, ce qui fait que les lignes de séparation se forment naturellement sur les crêtes du relief.

Algoritmo Didático do Watershed Limitado por Máscara — painel interativo

Passo a passo
Cards
Linha do tempo
Código Python
Fluxograma
Crescimento de Regiões Confinado por Máscara
Inundação concorrente com restrição geométrica de suporte e sincronização síncrona por malha
1
Rotular os marcadores sementes em f via mm.label0(f, b), instanciar a malha dinâmica g ← f.copy() e binarizar a mask.
2
Enquanto houver pixels não rotulados (while True), reiniciar o controle de atividade mudou ← False e varrer a imagem:
Identificar se a coordenada atual é um vazio contido no escopo: g[x,y] == 0 and mask[x,y].
Avaliar a vizinhança estrutural em mm._viz(f, b, x, y) baseada no estado síncrono estável f.
Se um vizinho possuir rótulo dominante (g[x,y] < f[vy,vx]), a célula em g absorve esse identificador e marca-se mudou ← True.
3
Verificar ponto fixo: caso uma varredura completa não expanda nenhuma fronteira (not mudou), interrompe-se o laço (break).
4
Atualizar o estado de referência de forma síncrona para a próxima iteração: f ← g.copy().
5
Se op == 'region', retornar o mapa de bacias g; caso contrário, extrair as cristas divisórias via mm.gradm(g).
A sincronização f = g.copy() ao final de cada ciclo impede o crescimento assimétrico ou dependente da ordem da varredura raster (propagação em estilo Jacobi).
Escopo Geométrico
mask[x,y] > 0
Restrição binária rígida impedindo o avanço periférico de rótulos.
Estabilização
if not mudou: break
Evita loops infinitos interrompendo ao saturar o domínio da máscara.
Mapeamento Jacobi
f = g.copy()
Sincronização em bloco após inspeção de todas as coordenadas.
01
Inicialização
Geração dos identificadores iniciais pelo mapeamento de componentes conexas e binarização da máscara de suporte.
02
Expansão Concorrente
Varredura 2D inspecionando vazios internos autorizados. A malha de trabalho g absorve os rótulos lidos da referência estável f.
03
Ponto Fixo Local
A flag mudou monitora mudanças estruturais. Se nenhuma frente avançar, o laço de inundação é finalizado via break.
04
Sincronização e Saída
Atualização em bloco do estado referencial. A saída pode ser moldada como partições regionais ou linhas de cristas (linhas de watershed).
01
Condicionamento Prévio
Rotulagem preliminar de marcadores e isolamento booleano do domínio.
02
Laço Síncrono Iterativo
while True
Redefinição de flag: mudou = False
Crescimento condicional: se g[x,y] == 0 e estiver na máscara, expande lendo f
Controle de estabilidade: if not mudou: break
Atualização síncrona: f = g.copy()
03
Extração Topológica
Retorno condicional das bacias preenchidas ou cálculo morfológico do gradiente de transição.
mm_watershed.py Algoritmo com Restrição de Máscara
def watershed0(f, mask=None, b=np.zeros((3,3),dtype='uint8'), op='region'):
    f = mm.label0(f, b)
    g = f.copy()
    mask = np.ones_like(f) if mask is None else (mask > 0)
    
    while True:
        mudou = False
        for x in range(f.shape[0]):
            for y in range(f.shape[1]):
                if g[x,y] == 0 and mask[x,y]:
                    for vy,vx,bv in mm._viz(f, b, x, y):
                        if bv and g[x,y] < f[vy,vx]: 
                            g[x,y] = f[vy,vx]
                            mudou = True
        if not mudou: 
            break
        f = g.copy()
        
    return g if op == 'region' else mm.gradm(g, mm.secross())
1
mask = (mask > 0) — Converte a imagem de suporte informada para um mapa Booleano indexável.
2
g[x,y] == 0 and mask[x,y] — Filtro ativo: pixels fora da máscara (fundo zero) são ignorados de imediato, confinando as frentes de expansão.
3
if not mudou: break — Mecanismo de escape. Quando todos os espaços internos permitidos forem preenchidos ou estabilizados contra a barreira, o laço aborta de forma limpa.
início rotular sementes e normalizar máscara f ← mm.label0(f, b) ; g ← f.copy() ; mask ← mask > 0 True? reiniciar ciclo: mudou ← False varredura espacial e expansão se g[x,y] == 0 e mask[x,y] e f[vy,vx] > g[x,y]: g[x,y] ← f[vy,vx] ; mudou ← True not mudou? sincronizar malha de ref: f ← g.copy() op == 'region'? retornar g ( bacias ) retornar mm.gradm(g) fim sim não sim (break) não sim não
Figure 4.23: Algorithme didactique de Watershed par croissance de régions limitée par masque.

Figure 4.24 présente un simulateur itératif qui illustre la propagation des marqueurs à travers la région d’intérêt. Chaque marqueur agit comme une source d’inondation qui étend sa zone d’influence jusqu’à rencontrer des régions provenant d’autres graines. Dans l’algorithme d’OpenCV, les pixels appartenant aux lignes de partage sont identifiés par la valeur -1, représentant les frontières entre bassins adjacents.

💧 Simulateur : Segmentation par Watershed Propagation avec élément structurant (b)
Zone (masque)
0
Marqueurs
0
Remplissage
0%
Itération (k)
–
🖱️ Faites glisser pour dessiner/effacer le masque ou les graines
Outils
Élément structurant (b)
Visualisation
Exemples initiaux
Figure 4.24: Simulateur interactif de l’algorithme Watershed par propagation morphologique. Dessinez le masque, positionnez les marqueurs et ajustez l’élément structurant pour observer l’inondation. Lorsque les bassins se rencontrent simultanément, l’égalité est résolue en supposant l’une des régions de manière aléatoire.

Pipeline morphologique du watershed

Le watershed basé sur des marqueurs intègre généralement un flux plus large de segmentation. Dans les images réelles, des étapes de prétraitement sont souvent nécessaires pour améliorer le contraste, réduire le bruit et générer des marqueurs fiables. Ce flux complet est résumé dans la Table 4.5.

Table 4.5: Pipeline complet du watershed basé sur des marqueurs pour les images réelles.
Étape Opération Finalité
1 CLAHE + Lissage Amélioration du contraste et réduction du bruit
2 Seuillage Séparation initiale entre l’objet et le fond
3 Ouverture/Fermeture Suppression du bruit et des petites imperfections
4 Dilatation du masque Identification du fond certain (Sure Background)
5 Transformée de distance + Seuil Identification de l’objet certain (Sure Foreground)
6 Région incertaine Différence entre le fond certain et l’objet certain
7 mm::watershed Propagation des marqueurs à travers la région incertaine

Pour mettre en avant exclusivement les concepts de transformée de distance, de marqueurs et d’inondation topographique, l’exemple de la Figure 4.25 utilise une image binaire synthétique et adopte un flux simplifié, résumé dans la Table 4.6.

Table 4.6: Pipeline simplifié utilisé dans l’exemple didactique de la Figure 4.25.
Étape Opération Finalité
1 Transformée de distance Construction de la surface topographique
2 Seuil de la TD Extraction des marqueurs (Sure Foreground)
3 Dilatation Détermination du fond certain (Sure Background)
4 Région incertaine Différence entre le fond et les marqueurs
5 mm::watershed Propagation des marqueurs et génération des frontières
%%writefile tmp/fig_04_watershed_didatico.cpp
#define MM_OUT "tmp/fig_04_watershed_didatico.png"
//| label: fig-04-watershed-didatico
//| fig-cap: "*Pipeline* *watershed* delimitado por máscara em imagem binária 20×20."
//| echo: true
//| output: true

#include "morph.hpp"
#include <iostream>
#include <string>
#include <vector>
#include <filesystem>

int main() {
    // 1. Imagem sintética e Transformada de Distância
    mm::Image f_sint(20, 20);
    f_sint = mm::circle(f_sint, 6, 10, 5, 255, -1);
    f_sint = mm::circle(f_sint, 14, 10, 5, 255, -1);
    mm::Image dist = mm::dist(f_sint);

    // 2. Marcadores (picos da distância)
    double max_dist = 0;
    for (int i = 0; i < dist.h * dist.w; i++) {
        if (dist.data[i] > max_dist) max_dist = dist.data[i];
    }
    mm::Image m(dist.h, dist.w);
    for (int y = 0; y < dist.h; y++) {
        for (int x = 0; x < dist.w; x++) {
            m.at(y, x) = (dist.at(y, x) > 0.8 * max_dist) ? 255 : 0;
        }
    }

    // 3. Execução do Watershed0 condicional (m=marcadores primeiro, mask=f_sint)
    mm::Image w_reg = mm::watershed0(m, f_sint, "region");
    mm::Image w_line = mm::watershed0(m, f_sint, "line");

    //w_reg  = mm::watershedB(m, mask=f_sint, op='region')
    //w_line = mm::watershedB(m, mask=f_sint, op='line')

    w_reg = mm::watershed(m, f_sint, "region");
    w_line = mm::watershed(m, f_sint, "line");

    // 4. Exibição dos resultados
    mm::show(std::vector<mm::Image>{f_sint, dist, m, w_reg, w_line}, MM_OUT,
             std::vector<std::string>{"Original", "Distância", "Marcadores", "Regiões", "Linhas"}, 5);

    
// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(f_sint, "tmp/fig_04_watershed_didatico_0.png");
mm::write(dist, "tmp/fig_04_watershed_didatico_1.png");
mm::write(m, "tmp/fig_04_watershed_didatico_2.png");
mm::write(w_reg, "tmp/fig_04_watershed_didatico_3.png");
mm::write(w_line, "tmp/fig_04_watershed_didatico_4.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_04_watershed_didatico.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_watershed_didatico.cpp -o tmp/fig_04_watershed_didatico -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_watershed_didatico \
  && test -f "tmp/fig_04_watershed_didatico.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_watershed_didatico.png"
[1] Original
[2] Distância
[3] Marcadores
[4] Regiões
[5] Linhas
try:
    mm.show(
        [
            mm.read("tmp/fig_04_watershed_didatico_0.png"),
            mm.read("tmp/fig_04_watershed_didatico_1.png"),
            mm.read("tmp/fig_04_watershed_didatico_2.png"),
            mm.read("tmp/fig_04_watershed_didatico_3.png"),
            mm.read("tmp/fig_04_watershed_didatico_4.png"),
        ],
        titles=[
            'Original',
            'Distância',
            'Marcadores',
            'Regiões',
            'Linhas',
        ],
        cols=5,
        figsize=(16, 4),
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_watershed_didatico_0.png (ver a versao Python)")
Figure 4.25: Pipeline watershed delimitado por máscara em imagem binária 20×20.

Application aux pièces superposées :

%%writefile tmp/fig_04_watershed_moedas.cpp
#define MM_OUT "tmp/fig_04_watershed_moedas.png"
//| label: fig-04-watershed-moedas
//| fig-cap: "*Pipeline* *Watershed* para separação de moedas sobrepostas: da máscara binária dilatada até os contornos finais anotados sobre a imagem original."
//| echo: true
//| output: true

#include "morph.hpp"
#include <opencv2/opencv.hpp>
#include <algorithm>
#include <iostream>
#include <numeric>
#include <vector>
#include <set>
#include <filesystem>

int main() {
// [pdi:state-io] auto-generated — do not edit by hand
mm::Image img_coins_gray = mm::_read_state("tmp/state/img_coins_gray_8.png");
mm::Image img_final = mm::_read_state("tmp/state/img_final_55.png");
// [pdi:state-io:end]

    mm::Image img_base = img_coins_gray;

    // 1. Simular moedas sobrepostas/conectadas (usando a máscara binária base)
    mm::Image img_sobrepostas = mm::dil(img_final, mm::sebox(40));

    // 2. Abertura morfológica para limpar ruídos
    mm::Image opening = mm::open(img_sobrepostas, mm::sebox(2));

    // 3. Transformada de Distância
    mm::Image dist = mm::dist(opening);
    mm::Image dist_vis;
    int max_dist = 0;
    for (int i = 0; i < dist.h * dist.w; i++) {
        max_dist = std::max(max_dist, (int)dist.data[i]);
    }
    if (max_dist > 0) {
        dist_vis = mm::Image(dist.h, dist.w);
        for (int i = 0; i < dist.h * dist.w; i++) {
            dist_vis.data[i] = (unsigned char)(255 * (double)dist.data[i] / max_dist);
        }
    } else {
        dist_vis = dist;
    }

    // 4. Picos seguros (Marcadores das moedas)
    int max_dist2 = 0;
    for (int i = 0; i < dist.h * dist.w; i++) {
        max_dist2 = std::max(max_dist2, (int)dist.data[i]);
    }
    mm::Image picos(dist.h, dist.w);
    for (int i = 0; i < dist.h * dist.w; i++) {
        if (dist.data[i] > 0.5 * max_dist2) {
            picos.data[i] = 255;
        }
    }

    // 5. Execução do Watershed (Ajustado para usar a nova assinatura)
    // Passamos 'opening' direto em mask, pois ela delimita o escopo de expansão das moedas
    mm::Image ws_region = mm::watershed(picos, opening, "region");
    mm::Image ws_line   = mm::watershed(picos, opening, "line");

    // 6. Contagem de objetos (Ignora fundo 0)
    std::set<int> unique_labels;
    for (int i = 0; i < ws_region.h * ws_region.w; i++) {
        if (ws_region.data[i] > 0) {
            unique_labels.insert(ws_region.data[i]);
        }
    }
    std::vector<int> labels(unique_labels.begin(), unique_labels.end());
    std::cout << "Objetos detectados: " << labels.size() << "\n";

    // 7. Anotação Final
    cv::Mat img_base_cv(img_base.h, img_base.w, CV_8UC1, img_base.data.data());
    cv::Mat img_annotated;
    cv::cvtColor(img_base_cv, img_annotated, cv::COLOR_GRAY2BGR);

    for (size_t idx = 0; idx < labels.size(); idx++) {
        int label_id = labels[idx];
        int count = 0;
        for (int i = 0; i < ws_region.h * ws_region.w; i++) {
            if (ws_region.data[i] == label_id) count++;
        }
        if (count < 500) continue;

        // calcular centroide
        double sum_y = 0, sum_x = 0;
        int npts = 0;
        for (int y = 0; y < ws_region.h; y++) {
            for (int x = 0; x < ws_region.w; x++) {
                if (ws_region.at(y, x) == label_id) {
                    sum_y += y;
                    sum_x += x;
                    npts++;
                }
            }
        }
        int cy = (int)(sum_y / npts);
        int cx = (int)(sum_x / npts);
        cv::putText(img_annotated, std::to_string(idx + 1), cv::Point(cx - 25, cy + 20),
                    cv::FONT_HERSHEY_SIMPLEX, 3.2, cv::Scalar(0, 255, 0), 8, cv::LINE_AA);
    }

    // Desenha as linhas de separação em vermelho
    mm::Image ones11(11, 11);
    std::fill(ones11.data.begin(), ones11.data.end(), 1);
    mm::Image mask_ann = mm::dil(ws_line, ones11);
    for (int y = 0; y < img_annotated.rows; y++) {
        for (int x = 0; x < img_annotated.cols; x++) {
            if (mask_ann.at(y, x) > 0) {
                img_annotated.at<cv::Vec3b>(y, x) = cv::Vec3b(255, 0, 0);
            }
        }
    }

    // converter de volta para mm::Image para exibição
    mm::Image img_annotated_mm(img_annotated.rows, img_annotated.cols, 3);
    std::memcpy(img_annotated_mm.data.data(), img_annotated.data, img_annotated_mm.data.size());

    // Exibição
    mm::show(std::vector<mm::Image>{img_base, img_sobrepostas, dist_vis, picos, ws_region, img_annotated_mm},
             MM_OUT, std::vector<std::string>{"Original", "Sobrepostas", "Distância", "Marcadores", "Watershed", "Anotado"}, 3);

    
// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(img_base, "tmp/fig_04_watershed_moedas_0.png");
mm::write(img_sobrepostas, "tmp/fig_04_watershed_moedas_1.png");
mm::write(dist_vis, "tmp/fig_04_watershed_moedas_2.png");
mm::write(picos, "tmp/fig_04_watershed_moedas_3.png");
mm::write(ws_region, "tmp/fig_04_watershed_moedas_4.png");
mm::write(img_annotated, "tmp/fig_04_watershed_moedas_5.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_04_watershed_moedas.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_watershed_moedas.cpp -o tmp/fig_04_watershed_moedas -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_watershed_moedas \
  && test -f "tmp/fig_04_watershed_moedas.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_watershed_moedas.png"
Objetos detectados: 12
[1] Original
[2] Sobrepostas
[3] Distância
[4] Marcadores
[5] Watershed
[6] Anotado
try:
    mm.show(
        [
            mm.read("tmp/fig_04_watershed_moedas_0.png"),
            mm.read("tmp/fig_04_watershed_moedas_1.png"),
            mm.read("tmp/fig_04_watershed_moedas_2.png"),
            mm.read("tmp/fig_04_watershed_moedas_3.png"),
            mm.read("tmp/fig_04_watershed_moedas_4.png"),
            mm.read("tmp/fig_04_watershed_moedas_5.png"),
        ],
        titles=[
            'Original',
            'Sobrepostas',
            'Distância',
            'Marcadores',
            'Watershed',
            'Anotado',
        ],
        cols=3,
        rows=2,
        figsize=(12, 8),
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_watershed_moedas_0.png (ver a versao Python)")
Figure 4.26: Pipeline Watershed para separação de moedas sobrepostas: da máscara binária dilatada até os contornos finais anotados sobre a imagem original.

4.5 Extraction de Composants et Descripteurs de Forme

Après la segmentation et le raffinement morphologique, l’étape suivante consiste à identifier individuellement chaque objet présent dans l’image et à extraire ses propriétés géométriques. Cette étape est fondamentale pour les tâches de mesure, de classification et de reconnaissance de formes.

Un composant connexe est un ensemble maximal de pixels appartenant à l’objet qui restent mutuellement connectés selon une relation de connectivité préalablement définie (connectivité 4 ou 8). Après l’étiquetage, chaque composant reçoit un identifiant unique, permettant d’analyser ses caractéristiques individuellement.

OpenCV propose deux approches complémentaires pour cette analyse, résumées dans la Table 4.7.

Table 4.7: Comparaison entre les approches basées sur les composants connexes et sur les contours.
connectedComponentsWithStats findContours
Retourne étiquette par pixel et statistiques par composant séquence de points décrivant le contour
Descripteurs directs aire, boîte englobante et centroïde périmètre, forme et hiérarchie
Objets en contact tend à fusionner les régions connectées tend à produire un unique contour externe
Usage typique comptage, filtrage et étiquetage analyse géométrique et descripteurs de forme

4.5.1 Étiquetage et statistiques de composantes

mm::label0 attribue une étiquette à chaque composante connexe. À partir de l’image étiquetée, on obtient, par balayage, la surface (comptage des pixels) et la boîte englobante de chaque objet (Figure 4.27). Le connectedComponentsWithStats d’OpenCV, qui renvoie ces statistiques prêtes à l’emploi, et les annotations colorées sur l’image se trouvent dans le parcours Python.

%%writefile tmp/fig_04_componentes.cpp
#define MM_OUT "tmp/fig_04_componentes.png"
//| label: fig-04-componentes
//| fig-cap: "Componentes conexos extraídos após o *pipeline* CLAHE → Otsu → limpeza morfológica. Cada objeto é colorido com cor distinta e anotado com sua área em pixels."
//| echo: true
//| output: true
#include <opencv2/opencv.hpp>
#include <iostream>
#include <iomanip>
#include <vector>
#include <string>
#include "morph.hpp"
#include <filesystem>

int main() {
// [pdi:state-io] auto-generated — do not edit by hand
mm::Image img_coins_gray = mm::_read_state("tmp/state/img_coins_gray_8.png");
mm::Image img_final = mm::_read_state("tmp/state/img_final_55.png");
// [pdi:state-io:end]

    // 1. Rotulagem e estatísticas
    cv::Mat labels;
    cv::Mat stats;
    cv::Mat centroids;
    cv::Mat img_final_cv(img_final.h, img_final.w, CV_8UC1, img_final.data.data());
    int n = cv::connectedComponentsWithStats(img_final_cv, labels, stats, centroids, 8, CV_32S);

    // 2. Coloração dos componentes — paleta FIXA (gerada uma vez com
    // np.random.seed(4)) para a trilha C++ reproduzir cor a cor sem
    // depender do gerador do numpy. Se n exceder len(PALETA), cicla.
    std::vector<std::vector<int>> PALETA = {
        {0, 0, 0}, {224, 82, 193}, {233, 155, 73}, {190, 247, 244},
        {103, 94, 179}, {51, 154, 59}, {137, 96, 232}, {250, 243, 205},
        {100, 141, 208}, {228, 187, 163}, {202, 108, 214}, {131, 105, 104},
        {86, 153, 81}};

    mm::Image img_colored(img_coins_gray.h, img_coins_gray.w, 3);
    // colors[0] = [0, 0, 0]  // fundo preto (already zero-initialized)
    for (int y = 0; y < img_final.h; y++) {
        for (int x = 0; x < img_final.w; x++) {
            int label = labels.at<int>(y, x);
            int idx = label % (int)PALETA.size();
            img_colored.at(y, x, 0) = PALETA[idx][0];
            img_colored.at(y, x, 1) = PALETA[idx][1];
            img_colored.at(y, x, 2) = PALETA[idx][2];
        }
    }
    img_colored.at(0, 0, 0) = 0;
    img_colored.at(0, 0, 1) = 0;
    img_colored.at(0, 0, 2) = 0;

    mm::Image img_annotated = img_colored;
    cv::Mat img_annotated_cv(img_annotated.h, img_annotated.w, CV_8UC3, img_annotated.data.data());

    // 3. Tabela de descritores
    std::cout << "Componentes detectados (excluindo fundo): " << n - 1 << "\n";
    std::cout << "\n" << std::setw(4) << "ID" << std::setw(8) << "Área" 
              << std::setw(6) << "cx" << std::setw(6) << "cy" 
              << std::setw(6) << "w" << std::setw(6) << "h" << "\n";
    std::cout << std::string(42, '-') << "\n";
    for (int i = 1; i < n; i++) {
        int area = stats.at<int>(i, cv::CC_STAT_AREA);
        int cx = (int)centroids.at<double>(i, 0);
        int cy = (int)centroids.at<double>(i, 1);
        int w = stats.at<int>(i, cv::CC_STAT_WIDTH);
        int h = stats.at<int>(i, cv::CC_STAT_HEIGHT);
        std::cout << std::setw(4) << i << std::setw(8) << area 
                  << std::setw(6) << cx << std::setw(6) << cy 
                  << std::setw(6) << w << std::setw(6) << h << "\n";
        for (const auto& [cor, esp] : std::vector<std::pair<cv::Scalar, int>>{
            {cv::Scalar(0, 0, 0), 10}, {cv::Scalar(255, 0, 0), 5}}) {
            cv::putText(img_annotated_cv, std::to_string(i) + ": " + std::to_string(area),
                        cv::Point(cx - 150, cy + 18),
                        cv::FONT_HERSHEY_SIMPLEX, 2.0, cor, esp, cv::LINE_AA);
        }
    }

    mm::show(
        std::vector<mm::Image>{img_coins_gray, img_final, img_colored, img_annotated},
        MM_OUT,
        std::vector<std::string>{"Original", "Segmentação Final", "Componentes Conexos", "Áreas Anotadas"},
        4);
    
// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(img_coins_gray, "tmp/fig_04_componentes_0.png");
mm::write(img_final, "tmp/fig_04_componentes_1.png");
mm::write(img_colored, "tmp/fig_04_componentes_2.png");
mm::write(img_annotated, "tmp/fig_04_componentes_3.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_04_componentes.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_componentes.cpp -o tmp/fig_04_componentes -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_componentes \
  && test -f "tmp/fig_04_componentes.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_componentes.png"
Componentes detectados (excluindo fundo): 12

  ID   Área    cx    cy     w     h
------------------------------------------
   1  231969  1484   280   553   542
   2  158967   917   250   453   468
   3  139229   250   312   433   419
   4  175550   857   821   474   465
   5  222882  1442   889   539   531
   6  210043   316   977   527   516
   7  343376   934  1559   667   665
   8  213641  1555  1574   530   515
   9  147880   328  1543   432   438
  10  187280   363  2111   487   492
  11  150110  1487  2215   433   444
  12  215387   932  2292   528   522
[1] Original
[2] Segmentação Final
[3] Componentes Conexos
[4] Áreas Anotadas
try:
    mm.show(
        [
            mm.read("tmp/fig_04_componentes_0.png"),
            mm.read("tmp/fig_04_componentes_1.png"),
            mm.read("tmp/fig_04_componentes_2.png"),
            mm.read("tmp/fig_04_componentes_3.png"),
        ],
        titles=[
            'Original',
            'Segmentação Final',
            'Componentes Conexos',
            'Áreas Anotadas',
        ],
        cols=4,
        figsize=(18, 6),
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_componentes_0.png (ver a versao Python)")
Figure 4.27: Componentes conexos extraídos após o pipeline CLAHE → Otsu → limpeza morfológica. Cada objeto é colorido com cor distinta e anotado com sua área em pixels.

4.5.2 Descripteurs de forme

Les descripteurs basés sur le contour vectoriel — périmètre (arcLength), aire du polygone (contourArea), circularité, moments et approximation polygonale (approxPolyDP) — dépendent du suivi de frontière (findContours), absent de la morph.hpp. Ils ne restent que dans le parcours Python. Dans le parcours C++, le gradient morphologique (mm::gradm) met en évidence le contour de chaque objet (Figure 4.28).

%%writefile tmp/fig_04_contornos.cpp
#define MM_OUT "tmp/fig_04_contornos.png"
// Compile: g++ -std=c++17 -o programa programa.cpp -I. -I/usr/include/opencv4 $(pkg-config --cflags --libs opencv4) -DMM_USE_OPENCV
#include "morph.hpp"
#include <opencv2/opencv.hpp>
#include <iostream>
#include <vector>
#include <cmath>
#include <iomanip>
#include <string>
#include <filesystem>

int main() {
// [pdi:state-io] auto-generated — do not edit by hand
mm::Image img_coins_gray = mm::_read_state("tmp/state/img_coins_gray_8.png");
mm::Image img_final = mm::_read_state("tmp/state/img_final_55.png");
// [pdi:state-io:end]

    //| label: fig-04-contornos
    //| fig-cap: "Contornos extraídos com *findContours*. Cada moeda é anotada com sua circularidade — valores próximos de 1 confirmam forma circular."
    //| echo: true
    //| output: true

    // Convertir mm::Image en cv::Mat pour findContours
    cv::Mat img_final_mat(img_final.h, img_final.w, CV_8UC1, img_final.data.data());
    std::vector<std::vector<cv::Point>> contornos;
    cv::findContours(img_final_mat, contornos, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);

    // Convertir l'image en couleur (BGR) pour l'affichage
    mm::Image img_coins_rgb(img_coins_gray.h, img_coins_gray.w, 3);
    for (int y = 0; y < img_coins_gray.h; y++) {
        for (int x = 0; x < img_coins_gray.w; x++) {
            unsigned char v = img_coins_gray.at(y, x);
            img_coins_rgb.at(y, x, 0) = v;
            img_coins_rgb.at(y, x, 1) = v;
            img_coins_rgb.at(y, x, 2) = v;
        }
    }
    mm::Image img_contornos = img_coins_rgb;
    mm::Image img_circulares = img_contornos;

    std::cout << "Contornos detectados: " << contornos.size() << "\n";
    std::cout << std::setw(4) << "ID" << std::setw(8) << "Área" 
              << std::setw(10) << "Perímetro" << std::setw(14) << "Circularidade" << "\n";
    std::cout << std::string(42, '-') << "\n";

    for (size_t i = 0; i < contornos.size(); i++) {
        const auto& cnt = contornos[i];
        int id = i + 1;

        double area = cv::contourArea(cnt);
        double perim = cv::arcLength(cnt, true);
        double circ = (perim > 0) ? (4 * M_PI * area / (perim * perim)) : 0;
        cv::Moments M = cv::moments(cnt);
        int cx = (M.m00 > 0) ? static_cast<int>(M.m10 / M.m00) : 0;
        int cy = (M.m00 > 0) ? static_cast<int>(M.m01 / M.m00) : 0;

        std::cout << std::setw(4) << id << std::setw(8) << static_cast<int>(area) 
                  << std::setw(10) << std::fixed << std::setprecision(1) << perim 
                  << std::setw(14) << std::setprecision(3) << circ << "\n";

        // Dessiner les contours sur l'image en couleur
        std::vector<std::vector<cv::Point>> contour_list = {cnt};
        cv::Mat img_contornos_mat(img_contornos.h, img_contornos.w, CV_8UC3, img_contornos.data.data());
        cv::drawContours(img_contornos_mat, contour_list, -1, cv::Scalar(0, 255, 0), 3);

        cv::Mat img_circulares_mat(img_circulares.h, img_circulares.w, CV_8UC3, img_circulares.data.data());
        cv::drawContours(img_circulares_mat, contour_list, -1, cv::Scalar(0, 255, 0), 3);

        // Texte avec bordure noire pour lisibilité
        cv::putText(img_circulares_mat, cv::format("%.2f", circ),
                    cv::Point(cx - 80, cy + 15),
                    cv::FONT_HERSHEY_SIMPLEX, 3.6, cv::Scalar(0, 0, 0), 8, cv::LINE_AA);
        cv::putText(img_circulares_mat, cv::format("%.2f", circ),
                    cv::Point(cx - 80, cy + 15),
                    cv::FONT_HERSHEY_SIMPLEX, 3.6, cv::Scalar(255, 0, 0), 3, cv::LINE_AA);
    }

    mm::show(std::vector<mm::Image>{img_final, img_contornos, img_circulares}, 
             MM_OUT,
             std::vector<std::string>{"Segmentação Final", "Contornos", "Circularidade"}, 
             3);

    
// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(img_final, "tmp/fig_04_contornos_0.png");
mm::write(img_contornos, "tmp/fig_04_contornos_1.png");
mm::write(img_circulares, "tmp/fig_04_contornos_2.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_04_contornos.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_contornos.cpp -o tmp/fig_04_contornos -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_contornos \
  && test -f "tmp/fig_04_contornos.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_contornos.png"
Contornos detectados: 12
  ID   ÁreaPerímetro Circularidade
------------------------------------------
   1  214626    1769.6         0.861
   2  149480    1465.1         0.875
   3  186570    1654.9         0.856
   4  147252    1458.6         0.870
   5  212885    1756.3         0.867
   6  342424    2232.5         0.863
   7  209282    1767.7         0.842
   8  222108    1809.7         0.852
   9  174854    1616.4         0.841
  10  138602    1471.5         0.804
  11  158306    1538.7         0.840
  12  231181    1847.4         0.851
[1] Segmentação Final
[2] Contornos
[3] Circularidade
try:
    mm.show(
        [
            mm.read("tmp/fig_04_contornos_0.png"),
            mm.read("tmp/fig_04_contornos_1.png"),
            mm.read("tmp/fig_04_contornos_2.png"),
        ],
        titles=[
            'Segmentação Final',
            'Contornos',
            'Circularidade',
        ],
        cols=3,
        figsize=(18, 6),
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_contornos_0.png (ver a versao Python)")
Figure 4.28: Contornos extraídos com findContours. Cada moeda é anotada com sua circularidade — valores próximos de 1 confirmam forma circular.

4.5.3 Connexion avec la détection d’objets moderne

Les descripteurs extraits dans les sections précédentes — notamment les bounding boxes, les centroïdes, les aires et les mesures de forme — établissent un pont naturel entre la segmentation morphologique classique et les systèmes modernes de détection d’objets. Bien que les techniques étudiées dans ce chapitre utilisent des opérations sur les pixels et les régions segmentées, bon nombre des représentations produites sont directement compatibles avec les formats employés dans les modèles contemporains de vision par ordinateur.

Les détecteurs basés sur l’apprentissage profond, tels que la famille YOLO (You Only Look Once) (REDMON, 2016), opèrent directement sur des images en couleur et produisent, pour chaque objet détecté, une bounding box décrite par le centre \((cx,cy)\) et les dimensions \((w,h)\), ainsi qu’une classe et un score de confiance. Cette représentation partage la même structure géométrique de base obtenue par connectedComponentsWithStats, bien qu’elle soit produite par un modèle appris et non par une segmentation explicite.

La Figure 4.29 illustre comment les bounding boxes obtenues par morphologie peuvent être exportées au format YOLO pour composer des ensembles de données utilisés lors de l’entraînement ou de l’évaluation de détecteurs.

%%writefile tmp/fig_04_bbox.cpp
#define MM_OUT "tmp/fig_04_bbox.png"
//| label: fig-04-bbox
//| fig-cap: "*Bounding boxes* derivadas dos componentes conexos sobrepostas à imagem original. As anotações são exportadas no formato YOLO (*classe cx cy w h*), com coordenadas normalizadas para o intervalo [0,1]."
//| echo: true
//| output: true
#include <opencv2/opencv.hpp>
#include <cstdio>
#include <string>
#include <vector>
#include <fstream>
#include <iostream>
#include <iomanip>
#include <sstream>
#include "morph.hpp"
#include <filesystem>

// Recalcula rótulos/estatísticas a partir da segmentação final
int main() {
// [pdi:state-io] auto-generated — do not edit by hand
mm::Image img_coins_gray = mm::_read_state("tmp/state/img_coins_gray_8.png");
mm::Image img_final = mm::_read_state("tmp/state/img_final_55.png");
// [pdi:state-io:end]

    // Recalcula rótulos/estatísticas a partir da segmentação final
    cv::Mat img_final_mat(img_final.h, img_final.w, CV_8UC1, img_final.data.data());
    cv::Mat labels, stats, centroids;
    int n = cv::connectedComponentsWithStats(img_final_mat, labels, stats, centroids, 8);

    int H_img = img_coins_gray.h;
    int W_img = img_coins_gray.w;

    cv::Mat img_coins_gray_mat(img_coins_gray.h, img_coins_gray.w, CV_8UC1, img_coins_gray.data.data());
    cv::Mat img_bbox;
    cv::cvtColor(img_coins_gray_mat, img_bbox, cv::COLOR_GRAY2BGR);

    const int CLASSE = 0;   // 0 = moeda (única categoria neste exemplo)

    std::cout << std::setw(4) << "cls" << std::setw(9) << "cx_n" << std::setw(9) << "cy_n" 
              << std::setw(9) << "w_n" << std::setw(9) << "h_n" << "  ← formato YOLO\n";
    std::cout << std::string(54, '-') << "\n";

    std::vector<std::string> yolo_linhas;
    for (int i = 1; i < n; i++) {
        int x0 = stats.at<int>(i, cv::CC_STAT_LEFT);
        int y0 = stats.at<int>(i, cv::CC_STAT_TOP);
        int w = stats.at<int>(i, cv::CC_STAT_WIDTH);
        int h = stats.at<int>(i, cv::CC_STAT_HEIGHT);

        double cx_n = (x0 + w / 2.0) / W_img;
        double cy_n = (y0 + h / 2.0) / H_img;
        double w_n = w / (double)W_img;
        double h_n = h / (double)H_img;

        std::ostringstream linha;
        linha << CLASSE << " " << std::fixed << std::setprecision(4) 
              << cx_n << " " << cy_n << " " << w_n << " " << h_n;
        yolo_linhas.push_back(linha.str());

        std::cout << std::setw(4) << CLASSE << std::setw(9) << std::fixed << std::setprecision(4)
                  << cx_n << std::setw(9) << cy_n << std::setw(9) << w_n << std::setw(9) << h_n << "\n";

        cv::rectangle(img_bbox, cv::Point(x0, y0), cv::Point(x0 + w, y0 + h), cv::Scalar(0, 255, 0), 4);
        cv::putText(img_bbox, "moeda", cv::Point(x0 + 8, y0 + 60),
                    cv::FONT_HERSHEY_SIMPLEX, 3.8, cv::Scalar(255, 0, 0), 5, cv::LINE_AA);
    }

    // Exportar arquivo de anotação no formato YOLO
    std::ofstream f("moedas.txt");
    for (size_t i = 0; i < yolo_linhas.size(); i++) {
        f << yolo_linhas[i];
        if (i < yolo_linhas.size() - 1) f << "\n";
    }
    f.close();
    std::cout << "\nAnotação salva em moedas.txt\n";

    // Converter de volta para mm::Image para exibição
    mm::Image img_bbox_mm(img_bbox.rows, img_bbox.cols, img_bbox.channels());
    std::memcpy(img_bbox_mm.data.data(), img_bbox.data, img_bbox_mm.data.size());

    mm::show(
        std::vector<mm::Image>{img_coins_gray, img_final, img_bbox_mm},
        MM_OUT,
        std::vector<std::string>{"Original", "Segmentação Final", "Bounding Boxes (formato YOLO)"},
        3
    );
    
// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(img_coins_gray, "tmp/fig_04_bbox_0.png");
mm::write(img_final, "tmp/fig_04_bbox_1.png");
mm::write(img_bbox, "tmp/fig_04_bbox_2.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_04_bbox.cpp
!g++ -I. -std=c++17 -DMM_USE_OPENCV -I/usr/include/opencv4 tmp/fig_04_bbox.cpp -o tmp/fig_04_bbox -lopencv_imgproc -lopencv_imgcodecs -lopencv_core \
  && ./tmp/fig_04_bbox \
  && test -f "tmp/fig_04_bbox.png" \
  || echo "⚠ mm::show não gravou tmp/fig_04_bbox.png"
 cls     cx_n     cy_n      w_n      h_n  ← formato YOLO
------------------------------------------------------
   0   0.7753   0.1102   0.2880   0.2117
   0   0.4784   0.0984   0.2359   0.1828
   0   0.1331   0.1225   0.2255   0.1637
   0   0.4464   0.3217   0.2469   0.1816
   0   0.7523   0.3471   0.2807   0.2074
   0   0.1664   0.3812   0.2745   0.2016
   0   0.4862   0.6104   0.3474   0.2598
   0   0.8115   0.6154   0.2760   0.2012
   0   0.1724   0.6023   0.2250   0.1711
   0   0.1893   0.8258   0.2536   0.1922
   0   0.7763   0.8652   0.2255   0.1734
   0   0.4854   0.8953   0.2750   0.2039

Anotação salva em moedas.txt
[1] Original
[2] Segmentação Final
[3] Bounding Boxes (formato YOLO)
try:
    mm.show(
        [
            mm.read("tmp/fig_04_bbox_0.png"),
            mm.read("tmp/fig_04_bbox_1.png"),
            mm.read("tmp/fig_04_bbox_2.png"),
        ],
        titles=[
            'Original',
            'Segmentação Final',
            'Bounding Boxes (formato YOLO)',
        ],
        cols=3,
        figsize=(18, 6),
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_bbox_0.png (ver a versao Python)")
Figure 4.29: Bounding boxes derivadas dos componentes conexos sobrepostas à imagem original. As anotações são exportadas no formato YOLO (classe cx cy w h), com coordenadas normalizadas para o intervalo [0,1].

Le format YOLO stocke chaque objet dans une ligne contenant cinq champs :

\[ \texttt{classe}\;\;\texttt{cx}\;\;\texttt{cy}\;\;\texttt{w}\;\;\texttt{h} \]

où \((cx,cy)\) représente le centre de la bounding box et \((w,h)\) ses dimensions. Toutes les valeurs géométriques sont normalisées dans l’intervalle \([0,1]\) par rapport à la largeur et à la hauteur de l’image. La classe est un identifiant entier associé à une catégorie définie par l’ensemble de données (par exemple, 0 → pièce de monnaie). Lorsqu’il y a plusieurs catégories — comme la pièce d’or (0), la pièce d’argent (1) et le disque en plastique (2) — il suffit d’attribuer l’identifiant correspondant à chaque objet avant l’exportation, en maintenant exactement le même format d’annotation. Dans l’exemple précédent, ces informations ont été stockées dans le fichier moedas.txt.

Le flux présenté dans ce chapitre — segmentation → étiquetage → extraction de bounding boxes — correspond conceptuellement à l’étape d’annotation (labeling) employée dans la construction d’ensembles d’entraînement pour les détecteurs modernes. Des outils spécialisés, comme Label Studio et Roboflow, automatisent ce processus sur des images complexes, mais la logique fondamentale reste la même : associer à chaque objet une région d’intérêt et une classe. Dans des scénarios contrôlés, avec un arrière-plan uniforme et des objets bien séparés, les techniques morphologiques peuvent même générer des annotations automatiquement ou servir de point de départ pour l’étiquetage manuel, réduisant considérablement l’effort de construction de l’ensemble de données. Dans des applications réelles plus complexes, cependant, la validation humaine reste nécessaire pour garantir la qualité des annotations.

NoteÉvaluation : IoU (Intersection over Union)

Une méthode simple pour évaluer la qualité d’une segmentation consiste à la comparer à un masque de référence (ground truth). La métrique la plus couramment utilisée à cette fin est la IoU (Intersection over Union) :

\[ \text{IoU}= \frac{|A\cap B|} {|A\cup B|} \tag{4.16}\]

où \(A\) représente la segmentation produite par l’algorithme et \(B\) la segmentation de référence.

La valeur de l’IoU varie entre 0 et 1. Plus la valeur est élevée, plus la superposition entre les masques est importante. Une IoU égale à 1 indique une correspondance parfaite entre la segmentation obtenue et la référence.

La même métrique est également largement utilisée en détection d’objets, où elle est appliquée aux bounding boxes prédites et annotées. Dans ce domaine, des valeurs d’IoU supérieures à 0,5 sont souvent adoptées comme critère minimum pour considérer une détection comme correcte.

AstuceAu-delà de la morphologie

Les techniques étudiées dans ce chapitre segmentent les objets en exploitant la connectivité spatiale, les opérations morphologiques et le relief topographique. Il existe cependant des approches alternatives basées sur le regroupement de caractéristiques, comme l’algorithme k-means, les modèles de mélange gaussien (GMM) et des méthodes plus récentes fondées sur l’apprentissage profond. Ces techniques seront reprises dans la Partie II de l’ouvrage, dédiée à la vision par ordinateur.

4.6 Résumé

Dans ce chapitre, les principales techniques de segmentation et de morphologie mathématique ont été présentées, concluant l’étude du TNI dans le domaine spatial.

  • Prétraitement et seuillage : La combinaison entre l’égalisation adaptative CLAHE et la méthode d’Otsu s’est révélée efficace pour induire une séparation bimodale dans l’histogramme et simplifier la binarisation d’images à éclairage non uniforme.
  • Érosion et dilatation : Opérateurs morphologiques fondamentaux basés sur la recherche de minima et de maxima locaux dans un voisinage défini par l’élément structurant \(B\). Ce sont des opérateurs duaux par complémentation, implémentés au moyen des fonctions mm::ero et mm::dil.
  • Ouverture et fermeture : Compositions d’érosion et de dilatation permettant de supprimer les bruits, de lisser les contours et de combler les petites lacunes, tout en préservant la structure globale des objets.
  • Reconstruction morphologique : Processus géodésique itératif qui propage un marqueur à l’intérieur des limites imposées par un masque, constituant la base d’opérateurs tels que mm::clohole et mm::edgeoff.
  • Pipeline de nettoyage binaire : Flux consolidé composé de CLAHE → Otsu → ouverture → mm::clohole → ouverture restreinte → mm::edgeoff, produisant des masques adaptés à l’analyse quantitative.
  • Morphologie en niveaux de gris : Extension algébrique fondée sur des minima et maxima pondérés, permettant des opérateurs tels que le gradient morphologique et les filtres top-hat pour le rehaussement de structures locales.
  • Transformée de distance et Watershed : La Transformée de distance a permis de générer des marqueurs automatiques pour l’algorithme watershed, rendant possible la séparation d’objets adjacents ou partiellement superposés.
  • Composantes connexes et descripteurs : L’étiquetage des régions (mm::label0) et l’extraction des contours ont permis de calculer des descripteurs géométriques tels que l’aire, le centroïde, le périmètre, la circularité et les bounding boxes.
  • Connexion avec la vision par ordinateur moderne : Les bounding boxes extraites par morphologie ont été exportées au format YOLO, mettant en évidence le lien entre les techniques classiques de segmentation et les systèmes modernes de détection d’objets.

Le chapitre 5 introduira les techniques de traitement dans le domaine fréquentiel, abordant la Transformée de Fourier, le filtrage spectral et les fondements de la compression d’images, y compris la DCT, JPEG et les wavelets.

4.7 🤖 Utilisation de Gemini Notebook comme tuteur complémentaire

Dans cette édition, l’utilisation de Gemini Notebook est encouragée comme outil d’apprentissage complémentaire. Fondé sur l’intelligence artificielle, le système utilise exclusivement les documents fournis par l’auteur comme source de connaissances, produisant des réponses alignées sur le contenu et l’approche adoptés tout au long de ce chapitre.

Important🎓 Étudiez avec le tuteur intelligent

🚀 ACCÉDER À GEMINI NOTEBOOK : CHAPITRE 04

🌐 Langue et langage de programmation

Le projet de ce chapitre dans Gemini Notebook a été construit uniquement avec le texte en portugais et les exemples de code en Python. Si vous étudiez à partir de l’édition en anglais ou en français, ou si vous suivez le parcours en C++, les réponses du tuteur peuvent ne pas correspondre exactement à la version que vous lisez.

⚠️ Avertissement concernant le contenu généré par l’IA

Bien qu’il s’agisse d’un outil d’aide à l’étude précieux, Gemini Notebook peut éventuellement produire des réponses incomplètes, imprécises ou incorrectes. Il est recommandé de valider les informations en consultant le matériel du chapitre, les livres, les articles scientifiques et d’autres sources académiques fiables. Chaque fois que possible, exécutez et expérimentez les exemples pratiques présentés tout au long du texte afin de consolider la compréhension des concepts.

4.8 Liste d’exercices

  1. (10 %) Implémentez manuellement le critère d’Otsu sans utiliser mm::threshold. Calculez la variance interclasse \(\sigma_B^2(T)\) pour tous les seuils \(T \in [0,255]\) en utilisant mm::hist, identifiez le seuil optimal \(T^*\) et comparez le résultat avec la valeur obtenue par OpenCV. Tracez \(\sigma_B^2\) en fonction de \(T\) et mettez en évidence le point de maximum.

  2. (15 %) Appliquez un seuillage adaptatif avec des blocs de taille 11, 31 et 51 à une image présentant un éclairage non uniforme. Comparez les résultats avec le seuillage global d’Otsu et discutez des avantages et des limites de chaque approche.

  3. (15 %) Exécutez le pipeline watershed sur l’image de pièces de monnaie en faisant varier le seuil appliqué à la transformée de distance (\(0,3\), \(0,5\) et \(0,7\) fois la valeur maximale). Expliquez comment ce paramètre influence la génération des marqueurs, la séparation des objets adjacents et l’apparition de sur-segmentation.

  4. (15 %) À l’aide de mm::drawImg, construisez une démonstration visuelle pas à pas de l’érosion d’une image binaire 7×7 avec un élément structurant carré de 3×3. Pour chaque position analysée, indiquez si l’élément structurant est entièrement contenu dans l’objet et justifiez la valeur attribuée au pixel de sortie.

  5. (15 %) Démontrez expérimentalement la dualité entre l’érosion et la dilatation en vérifiant l’identité \((A \ominus B)^c = A^c \oplus \hat{B}\) à l’aide de mm::ero, mm::dil et mm::bnot. Calculez la différence pixel par pixel entre les deux membres de l’équation et présentez le résultat en utilisant mm::histImg ou une visualisation équivalente.

  6. (15 %) Implémentez manuellement le gradient morphologique en utilisant uniquement mm::ero et mm::dil, en comparant le résultat avec mm::gradm(img, B). Évaluez l’effet de différents éléments structurants (carré 3×3, disque 5×5 et ligne 1×9) sur la détection de contours.

  7. (15 %) Construisez un pipeline complet pour le comptage et la classification de pièces de monnaie par taille (petite, moyenne et grande) en utilisant l’aire et la circularité comme descripteurs. Générez un masque de référence (ground truth) manuellement et calculez la métrique IoU (Intersection over Union) pour évaluer la qualité de la segmentation. Présentez les résultats dans un tableau et au moyen de visualisations produites avec mm::show.

Références du chapitre

La base théorique de ce chapitre s’appuie sur les ouvrages suivants :

  • Gonzalez (2018) pour les concepts de segmentation, de seuillage d’Otsu, de transformée de distance, de watershed, de morphologie mathématique et de descripteurs de forme.
  • Matheron (1975) et Serra (1982) pour la base théorique originale, la formulation algébrique et le développement de la morphologie mathématique.
  • Szeliski (2022) pour la segmentation fondée sur les régions, l’étiquetage des composantes connexes, le watershed basé sur des marqueurs et l’évaluation de la segmentation au moyen de la métrique IoU.
  • Bradski (2008) pour l’utilisation pratique de la bibliothèque OpenCV, y compris des fonctions telles que mm::dist, mm::watershed, mm::label0 et l’extraction de contours.
  • Redmon (2016) pour une introduction aux détecteurs modernes de la famille YOLO et leur lien avec les descripteurs géométriques comme les bounding boxes extraites par segmentation.
  • Singh (2024) pour la construction de labyrinthes complexes basés sur des cycles hamiltoniens sur des mosaïques quasi-cristallines, utilisés comme exemple d’application de la transformée de distance géodésique et des algorithmes de recherche de chemins.
  • Zampirolli (2025) pour l’implémentation des opérateurs morphologiques, des transformées géodésiques et la résolution de labyrinthes par propagation de distances dans des domaines restreints.

4.9 💻 Partie Pratique avec Exercices de Programmation

Cette liste transforme les concepts du Chapitre 4 en un parcours pratique de segmentation et de morphologie mathématique. Les exercices de programmation commencent par le seuillage et progressent jusqu’à l’étiquetage et les descripteurs de composants, toujours avec des matrices de petite taille afin que chaque pixel puisse être vérifié à la main.

ImportantRègle commune des exercices de programmation morphologiques

Dans les opérations avec voisinage, ne faites pas de padding. Pour chaque pixel, évaluez uniquement les positions de l’élément structurant qui se trouvent dans le domaine de l’image. C’est la même idée que les implémentations pédagogiques dans morph.py, comme mm::dil0, mm::ero0, mm::dil1 et mm::label0 : le voisinage est découpé par le domaine valide de l’image.

🎯 Objectif de ce carnet

Ce carnet permet de développer, valider, organiser et tester des solutions d’ Exercices de Programmation (EP) dans des environnements interactifs, tels que Colab, avec les mêmes cas de test que Moodle, en y copiant uniquement au moment d’enregistrer la note officielle.

Téléchargement

Téléchargez morph.py et testsuite.py en exécutant la cellule ci-dessous :

import os, urllib.request

os.makedirs("tmp/state", exist_ok=True)  # artefacts de build du parcours C++ (.cpp, binaire, PNGs)

url = "https://raw.githubusercontent.com/fzampirolli/pdi-vc/master/morph/config.py"
if not os.path.exists("config.py"):
    urllib.request.urlretrieve(url, "config.py")

# Le noyau est Python même dans le parcours C++ : `mm` (morph.py) est utilisé par les
# simulateurs, par l'affichage des figures que le binaire C++ génère et par
# l'état mm::Image entre cellules. cpp=True télécharge aussi le parcours compilé
# (morph.hpp + stb_image*.h), utilisé dans le #include des cellules %%writefile *.cpp.
import config
config.setup(testsuite=True, cpp=True)
from morph import mm
from testsuite import TestSuite
✅ Environnement prêt. Morph : 1.1.9 | OpenCV : 5.0.0 | TestSuite : 1.1.2

Exécution des tests

Pour évaluer les tests, exécutez TestSuite("EP04_01.extensão").run() dans une nouvelle cellule, en remplaçant l’extension par celle du langage utilisé (.py, .java, .c, .cpp, .js ou .r). Le système télécharge les cas de test depuis GitHub, exécute le programme et calcule automatiquement la note.

Pour tester directement du code Python, sans enregistrer de fichier, utilisez run_code(codigo) en passant le code comme chaîne de caractères dans une variable codigo :

codigo = """
from morph import mm
# ... votre code ici ...
"""
TestSuite("EP04_01").run_code(codigo)

4.9.1 EP04_01 🎚️ Seuillage global par seuil fixe

Dans les scanners de documents et les systèmes de lecture de codes-barres, la première étape du traitement consiste toujours à séparer ce qui est « objet » (encre, texte, barres) de ce qui est « fond » (papier, emballage). Le seuillage global fait exactement cela : il compare chaque pixel à un seuil unique \(T\) et décide, en temps réel, s’il appartient à la classe claire ou à la classe sombre. C’est l’opérateur de segmentation le plus simple — et pourtant, il est à l’origine d’une grande partie des pipelines industriels d’inspection visuelle. Voir la simulation de cet EP dans Figure 4.30.

4.9.1.1 📋 Directives d’implémentation

  1. Dimensions : Lire les entiers \(L\) (lignes) et \(C\) (colonnes).
  2. Seuil : Lire l’entier \(T\) (seuil de décision).
  3. Données : Lire les valeurs entières de la matrice originale ligne par ligne.
  4. Mappage : Pour chaque pixel \(p\), calculer la nouvelle valeur à l’aide de l’équation :

\[ p' = \begin{cases} 255, & \text{si } p > T \\ 0, & \text{si } p \le T \end{cases} \] 5. Sortie : Afficher la matrice binarisée avec les dimensions \(L \times C\).

4.9.1.2 📌 Contraintes computationnelles

  • Binarisation : La sortie contient uniquement les valeurs \(0\) ou \(255\).
  • Comparaison stricte : Le critère utilise \(> T\) (les pixels égaux à \(T\) deviennent du fond).
  • Type : Le résultat final doit être entier.
  • Remarque : Cet EP suit la convention d’OpenCV (cv2.THRESL_BINARY) : seuls les pixels avec une valeur supérieure à \(T\) deviennent blancs (255) ; les pixels avec une valeur égale à \(T\) restent noirs (0).

4.9.1.3 🧠 Fondement théorique

Paramètre Type Impact visuel
\(T\) petit Entier La plupart des pixels deviennent blancs
\(T\) grand Entier La plupart des pixels deviennent noirs
\(T\) bien choisi Entier Sépare nettement l’objet et le fond

4.9.1.4 📦 Spécification d’entrée et de sortie (VPL)

Entrée :

  • Ligne 1 : Entier \(L\).
  • Ligne 2 : Entier \(C\).
  • Ligne 3 : Entier \(T\).
  • Lignes suivantes : Éléments entiers de la matrice originale.

Sortie :

  • Matrice binarisée en \(L\) lignes et \(C\) colonnes, valeurs \(0\) ou \(255\) séparées par des espaces.

4.9.1.5 📌 Exemples

Entrée Sortie Remarque
2
4
100
0 99 100 180
255 30 120 80
0 0 0 255
255 0 255 0
\(T=100\) : seuls les pixels avec une valeur supérieure à 100 deviennent blancs ;
par conséquent, 99 et 100 deviennent noirs.
1
3
0
0 50 255
0 255 255 \(T=0\) : seuls les pixels avec une valeur strictement supérieure à 0 deviennent blancs.
🎚️ Simulateur EP04_01 : Seuillage global p' = (p > T) ? 255 : 0

👆 Cliquez sur une cellule de la Entrée originale pour assombrir le pixel (−30) et cliquez avec le bouton droit pour éclaircir (+30). Ajustez le seuil T pour la binarisation.

128
Entrée originale (cliquable)
Résultat binarisé (p')
Formule appliquée : (p > 128) ? 255 : 0
Figure 4.30: Simulateur EP04_01 : Seuillage global par seuil fixe (p’ = (p > T) ? 255 : 0)
%%writefile EP04_01.cpp
// your solution
Overwriting EP04_01.cpp
TestSuite("EP04_01.cpp").run()
✔️ EP04_01.cases existe déjà dans casos/
📋 7 cas chargé(s) depuis casos/EP04_01.cases

🔍 Test de C++ : EP04_01.cpp
⚠️ EP04_01.cpp : fichier vide (moins de 3 lignes). Tests ignorés.

4.9.2 EP04_02 📊 Seuillage automatique d’Otsu

Choisir manuellement le seuil \(T\) fonctionne lorsque l’éclairage est stable, mais en microscopie numérique et en inspection de lames de sang, chaque échantillon présente un contraste différent — un seuil fixe échouerait d’une image à l’autre. La méthode d’Otsu résout ce problème en trouvant, de manière autonome, le seuil qui maximise la séparation statistique entre les deux classes de pixels, rendant la segmentation automatique et adaptative. Voir dans Figure 4.31 une simulation de cet EP.

4.9.2.1 📋 Directives d’implémentation

  1. Dimensions : Lire les entiers \(L\) (lignes) et \(C\) (colonnes).

  2. Données : Lire les valeurs entières de la matrice originale ligne par ligne.

  3. Histogramme : Construire l’histogramme \(h[i]\), \(i=0,\dots,255\), en comptant combien de pixels ont la valeur \(i\).

  4. Recherche du seuil : Pour chaque candidat \(T\) de \(1\) à \(255\), calculer la variance inter-classes : \[ \sigma_B^2(T) = \frac{n_0 \cdot n_1}{N^2}\,(m_0 - m_1)^2 \] où \(n_0,n_1\) sont les quantités de pixels ayant une valeur \(<T\) et \(\geq T\), \(m_0,m_1\) sont leurs moyennes, et \(N=L\times C\).

  5. Choix : Le seuil optimal \(T^*\) est celui qui maximise \(\sigma_B^2(T)\) (en cas d’égalité, conserver le premier trouvé).

  6. Application : Binariser l’image en utilisant \(T^*\), en appliquant : \[ p' = \begin{cases} 255, & \text{si } p > T^* \\ 0, & \text{si } p \le T^* \end{cases} \]

4.9.2.2 📌 Contraintes computationnelles

  • Candidats valides : Ignorer \(T\) qui laisse \(n_0=0\) ou \(n_1=0\) (classe vide).
  • Égalité : Toujours conserver le premier \(T\) qui a atteint la valeur maximale de \(\sigma_B^2\).
  • Type : \(T^*\) et la matrice de sortie doivent être des entiers.
  • Convention OpenCV : La binarisation suit cv2.THRESL_BINARY ; les pixels ayant une valeur exactement égale à \(T^*\) deviennent noirs.

4.9.2.3 🧠 Fondements théoriques

Concept Signification Impact
\(\sigma_B^2(T)\) élevée Classes bien séparées en \(T\) \(T\) est un bon candidat comme seuil
Histogramme bimodal Deux « pics » distincts Otsu trouve le creux entre eux
Histogramme unimodal Un seul « pic » Otsu choisit toujours un \(T\), mais la segmentation est peu fiable

4.9.2.4 📦 Spécification d’entrée et de sortie (VPL)

Entrée :

  • Ligne 1 : Entier \(L\).
  • Ligne 2 : Entier \(C\).
  • Lignes suivantes : Éléments entiers de la matrice originale.

Sortie :

  • Matrice binarisée en \(L\) lignes et \(C\) colonnes, valeurs \(0\) ou \(255\).

4.9.2.5 📌 Exemples

Entrée Sortie Observation
4
4
12 12 12 200
12 12 200 200
12 200 200 200
200 200 200 200
0 0 0 255
0 0 255 255
0 255 255 255
255 255 255 255
Histogramme bimodal net : 12 et 200
1
2
10 250
0 250 Deux valeurs seulement : \(T^*\) reste sur la plus grande
📊 Simulateur EP04_02 : Otsu Automatique T* = argmax σ²_B(T)

👆 Clic gauche assombrit (−25) et clic droit éclaircit (+25) les pixels d'entrée. Observez le seuil optimal T* s'ajuster dynamiquement à l'histogramme.

T* = −

Entrée Originale (Cliquable)
Résultat Otsu (p')
Figure 4.31: Simulateur EP04_02 : Seuillage automatique d’Otsu (T* = argmax σ²_B(T))
%%writefile EP04_02.cpp
// your solution
Overwriting EP04_02.cpp
TestSuite("EP04_02.cpp").run()
✔️ EP04_02.cases existe déjà dans casos/
📋 5 cas chargé(s) depuis casos/EP04_02.cases

🔍 Test de C++ : EP04_02.cpp
⚠️ EP04_02.cpp : fichier vide (moins de 3 lignes). Tests ignorés.

4.9.3 EP04_03 🌱 Dilatation binaire plane (mm.dil0)

En microscopie de particules et en OCR de plaques d’immatriculation usées, les traits fins ou discontinus doivent être « épaissis » pour que la reconnaissance fonctionne. La dilatation morphologique fait exactement cela : elle étend les régions claires à l’aide d’un élément structurant \(B\) — la même opération implémentée dans morph.py comme mm::dil0(f, B), utilisée lorsque \(B\) est plat (sans poids, uniquement \(0\)/\(1\)). Voir dans Figure 4.32 une simulation de cet EP.

4.9.3.1 📋 Directives d’implémentation

  1. Dimensions de l’image : Lire les entiers \(L\) (lignes) et \(C\) (colonnes) de \(f\).
  2. Dimensions de \(B\) : Lire les entiers \(L_B\) (lignes) et \(C_B\) (colonnes) de l’élément structurant.
  3. Élément structurant : Lire la matrice \(B\) avec des valeurs \(0\) ou \(1\), ligne par ligne.
  4. Données : Lire la matrice \(f\) (l’image originale), ligne par ligne.
  5. Réflexion : Construire \(B_{ref}\), la version de \(B\) réfléchie à \(180°\) (lignes et colonnes inversées) — exactement comme le fait mm::dil0 en interne.
  6. Voisinage sans padding : Pour chaque pixel \((y,x)\), parcourir les positions \((by,bx)\) de \(B_{ref}\) centrées en \((y,x)\), en utilisant le décalage \[ v_y = y + by + o_y,\quad v_x = x + bx + o_x,\quad o_y=-\tfrac{L_B}{2}+0{,}5,\quad o_x=-\tfrac{C_B}{2}+0{,}5 \] Écarter tout \((v_y,v_x)\) hors de \([0,L)\times[0,C)\) — ne pas remplir avec des zéros.
  7. Mappage : Calculer chaque pixel de sortie comme le maximum entre \(f(y,x)\) et tous les \(f(v_y,v_x)\) valides dont la position correspondante dans \(B_{ref}\) vaut \(1\) : \[ g(y,x) = \max\Big(f(y,x),\ \max_{\substack{(v_y,v_x)\ \text{valide}\\ B_{ref}(by,bx)=1}} f(v_y,v_x)\Big) \]
  8. Sortie : Afficher la matrice \(g\) avec les dimensions \(L \times C\).

4.9.3.2 📌 Contraintes computationnelles

  • Sans padding : Ne jamais inventer de voisins hors de l’image ; n’utiliser que ceux qui existent réellement.
  • Réflexion obligatoire : \(B\) doit être réfléchi avant d’être appliqué (c’est ce qui distingue mm::dil0 d’une simple recherche de maximum).
  • Robustesse des bords : Si aucune position valide de \(B_{ref}=1\) ne tombe dans le domaine pour un pixel donné, celui-ci conserve sa valeur originale.

4.9.3.3 🧠 Fondement théorique

Concept Signification Impact visuel
Dilatation \(g \geq f\) toujours (extensive) Les régions claires croissent, les trous sombres rétrécissent
\(B\) plus grand Voisinage plus large Croissance plus agressive
Réflexion de \(B\) \(B_{ref}(y,x) = B(-y,-x)\) Garantit la définition formelle de Minkowski de la dilatation

4.9.3.4 📦 Spécification d’entrée et de sortie (VPL)

Entrée :

  • Ligne 1 : entier \(L\).
  • Ligne 2 : entier \(C\).
  • Ligne 3 : entier \(L_B\).
  • Ligne 4 : entier \(C_B\).
  • Les \(L_B\) lignes suivantes : éléments entiers (\(0\) ou \(1\)) de la matrice \(B\).
  • Les \(L\) lignes suivantes : éléments entiers de la matrice \(f\).

Sortie :

  • Matrice \(g\) en \(L\) lignes et \(C\) colonnes, valeurs entières séparées par des espaces.

4.9.3.5 📌 Exemples

Entrée Sortie Observation
3
3
3
3
0 1 0
1 1 1
0 1 0
0 0 0
0 9 0
0 0 0
0 9 0
9 9 9
0 9 0
\(B\) en croix symétrique : point isolé se dilate en croix
1
4
1
3
1 1 1
10 200 5 80
200 200 200 80 \(B\) horizontal : chaque pixel « attire » le maximum des voisins de la ligne
🌱 Simulateur EP04_03 : Dilatation plane (mm.dil0) g = f ⊕ B

Alternez l'élément structurant B (ou sélectionnez les préréglages) et cliquez sur les cellules de l'image originale f pour allumer ou éteindre les pixels.

Élément structurant B (Cliquez pour basculer 0/1)
Image originale f (5×5)
Dilatée g (f ⊕ B)
 
g(y,x) = max sur les voisins valides de B réfléchi
Figure 4.32: Simulateur EP04_03 : Dilatation binaire plane (g = f ⊕ B)
%%writefile EP04_03.cpp
// your solution
Overwriting EP04_03.cpp
TestSuite("EP04_03.cpp").run()
✔️ EP04_03.cases existe déjà dans casos/
📋 5 cas chargé(s) depuis casos/EP04_03.cases

🔍 Test de C++ : EP04_03.cpp
⚠️ EP04_03.cpp : fichier vide (moins de 3 lignes). Tests ignorés.

4.9.4 EP04_04 🪨 Érosion binaire plane (mm.ero0)

Si la dilatation épaissit, l’érosion affine. Dans les systèmes de comptage de cellules, elle est utilisée pour séparer les cellules qui se touchent : en « mangeant » les bords de chaque région, les connexions fines entre objets disparaissent avant même qu’un comptage soit effectué. Dans morph.py, c’est l’opération mm::ero0(f, B) — le dual exact de la dilatation, et la seule des deux qui ne réfléchit pas l’élément structurant. Voir dans Figure 4.33 une simulation de cet EP.

4.9.4.1 📋 Directives d’implémentation

  1. Dimensions de l’image : Lire les entiers \(L\) (lignes) et \(C\) (colonnes) de \(f\).
  2. Dimensions de \(B\) : Lire les entiers \(L_B\) (lignes) et \(C_B\) (colonnes) de l’élément structurant.
  3. Élément structurant : Lire la matrice \(B\) avec des valeurs \(0\) ou \(1\), ligne par ligne.
  4. Données : Lire la matrice \(f\) (l’image originale), ligne par ligne.
  5. Voisinage sans padding (sans réflexion !) : Pour chaque pixel \((y,x)\), parcourir les positions \((by,bx)\) de \(B\) dans l’ordre original (sans réfléchir), en utilisant le même décalage que dans l’EP04_03 : \[ v_y = y + by + o_y,\quad v_x = x + bx + o_x,\quad o_y=-\tfrac{L_B}{2}+0{,}5,\quad o_x=-\tfrac{C_B}{2}+0{,}5 \]

Écarter tout \((v_y,v_x)\) hors de \([0,L)\times[0,C)\). 6. Mappage : Calculer chaque pixel de sortie comme le minimum entre \(f(y,x)\) et tous les \(f(v_y,v_x)\) valides dont la position correspondante dans \(B\) vaut \(1\) : \[ g(y,x) = \min\Big(f(y,x),\ \min_{\substack{(v_y,v_x)\ \text{valide}\\ B(by,bx)=1}} f(v_y,v_x)\Big) \] 7. Sortie : Afficher la matrice \(g\) avec des dimensions \(L \times C\).

4.9.4.2 📌 Contraintes de calcul

  • Sans réflexion : Contrairement à la dilatation, \(B\) est utilisé exactement comme lu — réfléchir ici serait une erreur conceptuelle grave.
  • Sans padding : Les voisins hors de l’image sont simplement ignorés, jamais traités comme \(0\).
  • Robustesse de bord : Si aucune position valide de \(B=1\) ne tombe dans le domaine, le pixel conserve sa valeur originale.

4.9.4.3 🧠 Fondements théoriques

Concept Signification Impact visuel
Érosion \(g \leq f\) toujours (anti-extensive) Les régions claires rétrécissent, le bruit ponctuel disparaît
Dualité \(\text{ero}(f,B) = -\text{dil}(-f, B_{ref})\) Érosion et dilatation sont des « miroirs » mathématiques
\(B\) plus grand Érosion plus agressive Les objets fins disparaissent complètement

4.9.4.4 📦 Spécification d’entrée et de sortie (VPL)

Entrée :

  • Ligne 1 : Entier \(L\).
  • Ligne 2 : Entier \(C\).
  • Ligne 3 : Entier \(L_B\).
  • Ligne 4 : Entier \(C_B\).
  • \(L_B\) lignes suivantes : éléments entiers (\(0\) ou \(1\)) de la matrice \(B\).
  • \(L\) lignes suivantes : éléments entiers de la matrice \(f\).

Sortie :

  • Matrice \(g\) en \(L\) lignes et \(C\) colonnes, valeurs entières séparées par des espaces.

4.9.4.5 📌 Exemples

Entrée Sortie Observation
3
3
3
3
0 1 0
1 1 1
0 1 0
9 9 9
9 0 9
9 9 9
9 0 9
0 0 0
9 0 9
Le « trou » central (0) se propage en croix
1
4
1
3
1 1 1
10 200 5 80
10 5 5 80 \(B\) horizontal : chaque pixel « tire » le minimum des voisins de la ligne
🪨 Simulateur EP04_04 : Érosion plane (mm.ero0) g = f ⊖ B

Alternez l'élément structurant B (ou sélectionnez les préréglages) et cliquez sur les cellules de l'image originale f pour allumer ou éteindre des pixels.

Élément structurant B (Cliquer pour alterner 0/1)
Image originale f (5×5)
Érodée g (f ⊖ B)
 
g(y,x) = min sur les voisins valides de B (sans réflexion)
Figure 4.33: Simulateur EP04_04: Érosion Binaire Plane (g = f ⊖ B)
%%writefile EP04_04.cpp
// your solution
Overwriting EP04_04.cpp
TestSuite("EP04_04.cpp").run()
✔️ EP04_04.cases existe déjà dans casos/
📋 5 cas chargé(s) depuis casos/EP04_04.cases

🔍 Test de C++ : EP04_04.cpp
⚠️ EP04_04.cpp : fichier vide (moins de 3 lignes). Tests ignorés.

4.9.5 EP04_05 🧹 Ouverture Morphologique (Suppression de Bruit)

Les images capturées par des capteurs à faible coût, comme ceux des drones agricoles, sont souvent parsemées de petits points de bruit — des pixels isolés qui ne représentent rien de réel. Appliquer une érosion suivie d’une dilatation avec le même élément structurant produit l’ouverture : elle « nettoie » les points et les fines protubérances, mais rend à l’objet principal pratiquement sa taille d’origine. C’est la combinaison classique utilisée dans le pré-traitement d’images satellitaires avant tout comptage de surface cultivée. Voir dans Figure 4.34 une simulation de cet EP.

4.9.5.1 📋 Directives d’Implémentation

  1. Dimensions de l’image : Lire les entiers \(L\) (lignes) et \(C\) (colonnes) de \(f\).
  2. Dimensions de \(B\) : Lire les entiers \(L_B\) (lignes) et \(C_B\) (colonnes) de l’élément structurant.
  3. Élément structurant : Lire la matrice \(B\) avec des valeurs \(0\) ou \(1\), ligne par ligne.
  4. Données : Lire la matrice binaire \(f\) (valeurs \(0\) ou \(1\)), ligne par ligne.
  5. Érosion : Calculer \(e = f \ominus B\), en utilisant exactement l’algorithme de l’EP04_04 (sans réfléchir \(B\), sans padding).
  6. Dilatation : Calculer \(g = e \oplus B\), en utilisant exactement l’algorithme de l’EP04_03 (en réfléchissant \(B\), sans padding) — mais maintenant appliqué sur \(e\), pas sur \(f\).
  7. Sortie : Afficher la matrice résultante \(g\) (l’ouverture de \(f\) par \(B\)) avec les dimensions \(L \times C\).

4.9.5.2 📌 Contraintes Computationnelles

  • Ordre fixe : C’est toujours l’érosion d’abord, puis la dilatation — l’ordre inverse définit un autre opérateur (la fermeture, du prochain EP).
  • Même \(B\) : L’élément structurant utilisé pour l’érosion et la dilatation doit être identique.
  • Sans padding dans aucune des deux étapes.

4.9.5.3 🧠 Fondement Théorique

Concept Signification Impact Visuel
Anti-extensivité \(g \subseteq f\) toujours L’ouverture ne crée jamais de nouveau pixel, elle ne fait que supprimer
Idempotence \(\text{ouverture}(\text{ouverture}(f)) = \text{ouverture}(f)\) Réappliquer ne change plus rien
Points isolés Plus petits que \(B\) Ils sont complètement éliminés
Noyau de l’objet Plus grand que \(B\) Il est récupéré presque intact par la dilatation finale

4.9.5.4 📦 Spécification d’Entrée et de Sortie (VPL)

Entrée :

  • Ligne 1 : Entier \(L\).
  • Ligne 2 : Entier \(C\).
  • Ligne 3 : Entier \(L_B\).
  • Ligne 4 : Entier \(C_B\).
  • Les \(L_B\) lignes suivantes : éléments entiers (\(0\) ou \(1\)) de la matrice \(B\).
  • Les \(L\) lignes suivantes : éléments entiers (\(0\) ou \(1\)) de la matrice \(f\).

Sortie :

  • Matrice résultante en \(L\) lignes et \(C\) colonnes, valeurs \(0\) ou \(1\).

4.9.5.5 📌 Exemples

Entrée Sortie Observation
7
7
3
3
1 1 1
1 1 1
1 1 1
0 0 0 0 0 0 0
0 1 0 0 0 1 0
0 0 1 1 1 0 0
0 0 1 1 1 0 0
0 0 1 1 1 1 0
0 0 0 0 0 0 0
0 1 0 0 0 0 1
0 0 0 0 0 0 0
0 0 0 0 0 0 0
0 0 1 1 1 0 0
0 0 1 1 1 0 0
0 0 1 1 1 0 0
0 0 0 0 0 0 0
0 0 0 0 0 0 0
Les points isolés et la fine protubérance disparaissent ; le carré central survit
🧹 Simulateur EP04_05 : Ouverture morphologique g = (f ⊖ B) ⊕ B

Cliquez sur les cellules de f original pour allumer ou éteindre des pixels (créez votre propre bruit de fond !) et ajustez la taille de l'élément structurant B.


3×3
f Original (Cliquable)
e = f ⊖ B (Érosion)
g = e ⊕ B (Ouverture)
Figure 4.34: Simulateur EP04_05 : Ouverture morphologique (g = (f ⊖ B) ⊕ B)
%%writefile EP04_05.cpp
// your solution
Overwriting EP04_05.cpp
TestSuite("EP04_05.cpp").run()
✔️ EP04_05.cases existe déjà dans casos/
📋 5 cas chargé(s) depuis casos/EP04_05.cases

🔍 Test de C++ : EP04_05.cpp
⚠️ EP04_05.cpp : fichier vide (moins de 3 lignes). Tests ignorés.

4.9.6 EP04_06 🧩 Fermeture Morphologique (Remplissage des Lacunes)

Dans la numérisation d’empreintes digitales, les sillons de la peau sont parfois interrompus par de la saleté ou un dessèchement, créant de petites lacunes dans la courbe continue qui devrait exister. La fermeture — dilatation suivie d’une érosion avec le même élément structurant — est l’opérateur dual de l’ouverture : elle remplit les petits trous et les renfoncements étroits, sans modifier significativement le contour externe de l’objet. C’est l’étape standard avant d’extraire le squelette d’une empreinte digitale. Voir dans Figure 4.35 une simulation de cet EP.

4.9.6.1 📋 Directives d’implémentation

  1. Dimensions de l’image : Lire les entiers \(L\) (lignes) et \(C\) (colonnes) de \(f\).
  2. Dimensions de \(B\) : Lire les entiers \(L_B\) (lignes) et \(C_B\) (colonnes) de l’élément structurant.
  3. Élément structurant : Lire la matrice \(B\) avec les valeurs \(0\) ou \(1\), ligne par ligne.
  4. Données : Lire la matrice binaire \(f\) (valeurs \(0\) ou \(1\)), ligne par ligne.
  5. Dilatation : Calculer \(d = f \oplus B\), en utilisant exactement l’algorithme de l’EP04_03 (réflexion de \(B\), sans padding).
  6. Érosion : Calculer \(g = d \ominus B\), en utilisant exactement l’algorithme de l’EP04_04 (sans réflexion de \(B\), sans padding) — maintenant appliqué sur \(d\), et non sur \(f\).
  7. Sortie : Afficher la matrice résultante \(g\) (la fermeture de \(f\) par \(B\)) avec les dimensions \(L \times C\).

4.9.6.2 📌 Contraintes de calcul

  • Ordre fixe : C’est toujours la dilatation d’abord, puis l’érosion — l’ordre inverse est l’ouverture de l’EP04_05.
  • Même \(B\) : L’élément structurant utilisé dans la dilatation et dans l’érosion doit être identique.
  • Sans padding à aucune des deux étapes.

4.9.6.3 🧠 Fondement théorique

Concept Signification Impact visuel
Extensivité \(g \supseteq f\) toujours La fermeture ne supprime jamais de pixel, elle n’ajoute que
Idempotence \(\text{fermeture}(\text{fermeture}(f)) = \text{fermeture}(f)\) Réapplique ne change plus rien
Petits trous Plus petits que \(B\) Sont complètement remplis
Dualité \(\text{fermeture}(f) = \overline{\text{ouverture}(\bar f)}\) C’est l’ouverture appliquée au « négatif » de l’image

4.9.6.4 📦 Spécification d’entrée et de sortie (VPL)

Entrée :

  • Ligne 1 : Entier \(L\).
  • Ligne 2 : Entier \(C\).
  • Ligne 3 : Entier \(L_B\).
  • Ligne 4 : Entier \(C_B\).
  • Les \(L_B\) lignes suivantes : éléments entiers (\(0\) ou \(1\)) de la matrice \(B\).
  • Les \(L\) lignes suivantes : éléments entiers (\(0\) ou \(1\)) de la matrice \(f\).

Sortie :

  • Matrice résultante en \(L\) lignes et \(C\) colonnes, valeurs \(0\) ou \(1\).

4.9.6.5 📌 Exemples

Entrée Sortie Observation
8
8
3
3
1 1 1
1 1 1
1 1 1
0 0 0 0 0 0 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
0 0 1 0 1 1 0 0
0 0 1 1 0 1 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
0 0 0 0 0 0 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
Les deux trous internes non adjacents sont totalement remplis
🧩 Simulador EP04_06 : Fermeture morphologique g = (f ⊕ B) ⊖ B

Cliquez sur les cellules de f original pour allumer ou éteindre les pixels (remplissez les trous internes !) et ajustez la taille de l'élément structurant B.


3×3
f Original (Cliquable)
d = f ⊕ B (Dilatation)
g = d ⊖ B (Fermeture)
Figure 4.35: Simulateur EP04_06: Fermeture morphologique (g = (f ⊕ B) ⊖ B)
%%writefile EP04_06.cpp
// your solution
Overwriting EP04_06.cpp
TestSuite("EP04_06.cpp").run()
✔️ EP04_06.cases existe déjà dans casos/
📋 5 cas chargé(s) depuis casos/EP04_06.cases

🔍 Test de C++ : EP04_06.cpp
⚠️ EP04_06.cpp : fichier vide (moins de 3 lignes). Tests ignorés.

4.9.7 EP04_07 ⛰️ Dilatation et Érosion Pondérées (mm.dil1 / mm.ero1)

Jusqu’à présent, l’élément structurant disait simplement « ce voisin compte » ou « ne compte pas » — mais dans les modèles numériques de terrain (utilisés en SIG et en planification du drainage urbain), chaque voisin devrait avoir un poids différent selon la distance ou la direction du relief. Les versions pondérées de la dilatation et de l’érosion, implémentées dans morph.py comme mm::dil1(f, b) et mm::ero1(f, b), additionnent (ou soustraient) le poids de chaque voisin avant de prendre le maximum (ou le minimum) — généralisant ainsi tout ce qui a été fait dans les EP précédents. Voir dans Figure 4.36 une simulation de cet EP.

4.9.7.1 📋 Directives d’Implémentation

  1. Dimensions de l’image : Lire les entiers \(L\) (lignes) et \(C\) (colonnes) de \(f\).
  2. Dimensions de \(b\) : Lire les entiers \(L_B\) (lignes) et \(C_B\) (colonnes) de l’élément structurant pondéré.
  3. Poids : Lire la matrice \(b\) des poids entiers (ils peuvent être négatifs, nuls ou positifs), ligne par ligne.
  4. Données : Lire la matrice \(f\) (l’image d’origine), ligne par ligne.
  5. Voisinage sans padding : Pour chaque pixel \((y,x)\), parcourir toutes les positions \((by,bx)\) de \(b\) (pas seulement celles où la valeur serait \(1\) — ici tout poids participe), en utilisant le même décalage que dans les EP précédents : \[ v_y = y + by + o_y,\quad v_x = x + bx + o_x,\quad o_y=-\tfrac{L_B}{2}+0{,}5,\quad o_x=-\tfrac{C_B}{2}+0{,}5 \] Écarter tout \((v_y,v_x)\) hors de \([0,L)\times[0,C)\).
  6. Dilatation pondérée : Calculer \[ g_{dil}(y,x) = \max\Big(f(y,x),\ \max_{(v_y,v_x)\ \text{valide}} \big(f(v_y,v_x) + b(by,bx)\big)\Big) \]
  7. Érosion pondérée : Calculer, en utilisant le même \(b\) et sans réflexion : \[ g_{ero}(y,x) = \min\Big(f(y,x),\ \min_{(v_y,v_x)\ \text{valide}} \big(f(v_y,v_x) - b(by,bx)\big)\Big) \]
  8. Sortie : Afficher d’abord la matrice complète \(g_{dil}\), puis ensuite la matrice complète \(g_{ero}\).

4.9.7.2 📌 Contraintes Computationnelles

  • Aucune des deux ne réfléchit \(b\) — la version pondérée n’utilise pas la réflexion, même pour la dilatation (contrairement à mm::dil0).
  • Tous les poids participent : Il n’existe pas ici de filtre « \(B=1\) » ; même un poids \(0\) entre en compte.
  • Sans padding : les voisins hors de l’image sont ignorés, jamais virtuellement remplis.
  • Type : La sortie peut contenir des valeurs négatives ou supérieures à \(255\) — pas de clipping dans cet EP.
  • Astuce : Pour supprimer les messages de dépassement lors du dépassement des limites du type uint8, inclure au début du code :
import warnings
warnings.filterwarnings("ignore")

4.9.7.3 🧠 Fondement Théorique

Concept Signification Impact Visuel
Poids positif « Tire » la valeur du voisin vers le haut lors de la dilatation Simule un relief qui monte dans cette direction
Poids négatif Réduit la contribution du voisin Simule la distance ou une atténuation directionnelle
Dualité pondérée \(\text{ero1}(f,b) = -\text{dil1}(-f,b)\) La symétrie entre les deux opérations se maintient même avec des poids

4.9.7.4 📦 Spécification d’Entrée et de Sortie (VPL)

Entrée :

  • Ligne 1 : Entier \(L\).
  • Ligne 2 : Entier \(C\).
  • Ligne 3 : Entier \(L_B\).
  • Ligne 4 : Entier \(C_B\).
  • Les \(L_B\) lignes suivantes : éléments entiers (pouvant être négatifs) de la matrice \(b\).
  • Les \(L\) lignes suivantes : éléments entiers de la matrice \(f\).

Sortie :

  • D’abord la matrice \(g_{dil}\) en \(L\) lignes et \(C\) colonnes.
  • Ensuite la matrice \(g_{ero}\) en \(L\) lignes et \(C\) colonnes.

4.9.7.5 📌 Exemples

Entrée Sortie Observation
3
3
3
3
0 1 0
1 2 1
0 1 0
10 20 30
40 50 60
70 80 90
50 60 61
80 90 91
81 91 92
8 9 19
9 10 20
39 40 50
Le poids central \(2\) accélère la croissance lors de la dilatation et le rétrécissement lors de l’érosion
⛰️ Simulateur EP04_07 : Poids dans l'Élément Structurant dil1 / ero1

Ajustez les poids de l'élément structurant b avec les curseurs et observez l'effet de la dilatation et de l'érosion pondérées sur la matrice f.

Poids b (Ajustez les Curseurs par Cellule)
f Original
dil1(f, b) (Dilatation)
ero1(f, b) (Érosion)
Figure 4.36: Simulateur EP04_07 : Dilatation et Érosion avec Poids (mm.dil1 / mm.ero1)
%%writefile EP04_07.cpp
// your solution
Overwriting EP04_07.cpp
TestSuite("EP04_07.cpp").run()
✔️ EP04_07.cases existe déjà dans casos/
📋 3 cas chargé(s) depuis casos/EP04_07.cases

🔍 Test de C++ : EP04_07.cpp
⚠️ EP04_07.cpp : fichier vide (moins de 3 lignes). Tests ignorés.

4.9.8 EP04_08 🌋 Gradient morphologique, Top-hat et Black-hat

En inspection automatique de plaques de circuits, trois questions reviennent constamment : où se trouvent les bords des composants ? Quels détails clairs et petits (comme les points de soudure) se détachent du fond ? Quelles cavités sombres (comme les fissures) le fond dissimule-t-il ? Une seule paire érosion/dilatation répond aux trois : le gradient morphologique met en évidence les contours, le top-hat révèle les pics étroits, et le black-hat révèle les vallées étroites — trois outils, un seul voisinage. Voir dans Figure 4.37 une simulation de cet EP.

4.9.8.1 📋 Directives d’implémentation

  1. Dimensions de l’image : Lire les entiers \(L\) (lignes) et \(C\) (colonnes) de \(f\).
  2. Dimensions de \(B\) : Lire les entiers \(L_B\) (lignes) et \(C_B\) (colonnes) de l’élément structurant.
  3. Élément structurant : Lire la matrice \(B\) avec des valeurs \(0\) ou \(1\), ligne par ligne.
  4. Données : Lire la matrice \(f\) (l’image originale, en niveaux de gris), ligne par ligne.
  5. Opérateurs de base : Calculer, exactement comme dans les EP 04_03 à 04_06 :
    • \(d = f \oplus B\) (dilatation),
    • \(e = f \ominus B\) (érosion),
    • \(\text{ouverture} = e \oplus B\),
    • \(\text{fermeture} = d \ominus B\).
  6. Gradient morphologique : \(\text{grad}(y,x) = d(y,x) - e(y,x)\).
  7. Top-hat : \(\text{tophat}(y,x) = f(y,x) - \text{ouverture}(y,x)\).
  8. Black-hat : \(\text{blackhat}(y,x) = \text{fermeture}(y,x) - f(y,x)\).
  9. Sortie : Afficher, dans cet ordre, les trois matrices complètes : gradient, top-hat, black-hat.

4.9.8.2 📌 Contraintes computationnelles

  • Sans padding à aucune étape intermédiaire — dilatation, érosion, ouverture et fermeture suivent les mêmes règles de voisinage que les EP précédents.
  • Pas de clipping : les trois sorties peuvent contenir n’importe quelle valeur entière (le gradient est toujours \(\geq 0\), mais top-hat et black-hat le sont aussi).
  • Réutilisation : \(d\) et \(e\) doivent être calculés une seule fois et réutilisés pour construire ouverture, fermeture et gradient.

4.9.8.3 🧠 Fondements théoriques

Opérateur Formule Ce qu’il révèle
Gradient \(d - e\) Bords : zéro dans les régions planes, élevé aux transitions
Top-hat \(f - \text{ouverture}(f)\) Éléments clairs et fins, plus petits que \(B\)
Black-hat \(\text{fermeture}(f) - f\) Éléments sombres et fins, plus petits que \(B\)

4.9.8.4 📦 Spécification d’entrée et de sortie (VPL)

Entrée :

  • Ligne 1 : Entier \(L\).
  • Ligne 2 : Entier \(C\).
  • Ligne 3 : Entier \(L_B\).
  • Ligne 4 : Entier \(C_B\).
  • Lignes suivantes \(L_B\) : éléments entiers (\(0\) ou \(1\)) de la matrice \(B\).
  • Lignes suivantes \(L\) : éléments entiers de la matrice \(f\).

Sortie :

  • Matrice gradient en \(L\) lignes et \(C\) colonnes.
  • Matrice top-hat en \(L\) lignes et \(C\) colonnes.
  • Matrice black-hat en \(L\) lignes et \(C\) colonnes.

4.9.8.5 📌 Exemples

Entrée Sortie Observation
9
9
3
3
1 1 1
1 1 1
1 1 1
10 10 10 10 10 10 10 10 10
10 10 10 10 10 10 10 10 10
10 10 80 10 10 10 10 10 10
10 10 10 10 10 10 10 10 10
10 10 10 10 10 10 10 10 10
10 10 10 10 10 10 10 10 10
10 10 10 10 10 10 2 10 10
10 10 10 10 10 10 10 10 10
10 10 10 10 10 10 10 10 10
(gradient : halo \(3\times3=70\) autour de \((2,2)\) et halo \(3\times3=8\) autour de \((6,6)\), reste \(0\))
(top-hat : unique \(70\) en \((2,2)\), reste \(0\))
(black-hat : unique \(8\) en \((6,6)\), reste \(0\))
Pic isolé devient top-hat ; vallée isolée devient black-hat ; les deux apparaissent dans le gradient
🌋 Simulateur EP04_08 : Gradient / Top-hat / Black-hat 3 opérateurs, 1 voisinage

Ajoutez des pics ou des creux dans la matrice f et observez le comportement simultané des opérateurs de gradient, top-hat et black-hat.

f (Entrée)
Gradient
Top-hat
Black-hat
Figure 4.37: Simulateur EP04_08: Gradient morphologique, Top-hat et Black-hat
%%writefile EP04_08.cpp
// your solution
Overwriting EP04_08.cpp
TestSuite("EP04_08.cpp").run()
✔️ EP04_08.cases existe déjà dans casos/
📋 4 cas chargé(s) depuis casos/EP04_08.cases

🔍 Test de C++ : EP04_08.cpp
⚠️ EP04_08.cpp : fichier vide (moins de 3 lignes). Tests ignorés.

4.9.9 EP04_09 🗺️ Transformée de distance et le « cœur » de l’objet

En robotique mobile, lors de la planification d’un itinéraire dans un couloir, le robot souhaite savoir non seulement où se trouve l’espace libre, mais aussi à quelle distance chaque point libre se trouve du mur le plus proche. Les chemins les plus sûrs tendent à passer par le « cœur » du couloir, loin des obstacles.

La transformée de distance morphologique attribue à chaque pixel une valeur représentant sa distance jusqu’au bord le plus proche, selon la métrique définie par l’élément structurant. Les pixels proches du bord reçoivent des valeurs faibles, tandis que les pixels plus internes reçoivent des valeurs plus élevées. Le pixel de valeur maximale correspond à la région la plus protégée de l’objet, souvent associée à son centre morphologique.

Voir dans Figure 4.38 une simulation de cet EP.

4.9.9.1 📋 Directives d’implémentation

  1. Dimensions de l’image : lire les entiers \(L\) (lignes) et \(C\) (colonnes) de l’image \(f\).
  2. Dimensions de \(B\) : lire les entiers \(L_B\) (lignes) et \(C_B\) (colonnes) de l’élément structurant.
  3. Élément structurant : lire la matrice \(b\), contenant la valeur \(0\) au centre et des valeurs négatives aux autres positions.
  4. Image : lire la matrice binaire \(f\) (valeurs \(0\) ou \(1\)), ligne par ligne.
  5. Préparation : multiplier l’image par \(L\times C\), en garantissant que les pixels internes ont une valeur initiale suffisamment élevée pour la propagation des distances.
  6. Transformée de distance : calculer la matrice des distances en utilisant la méthode mm::dist1(f,b).
  7. Sortie : afficher la matrice résultante de la transformée de distance.

4.9.9.2 📌 Contraintes de calcul

  • Utiliser l’implémentation de l’érosion pondérée fournie par la bibliothèque.
  • L’élément structurant peut contenir des valeurs négatives arbitraires.
  • La transformée doit être obtenue par l’application itérative d’érosions pondérées jusqu’à atteindre un point fixe.

⚠️ Note cruciale sur la lecture des matrices : Comme l’élément structurant peut contenir des entiers négatifs (par exemple, -1 et -99), ne pas utiliser la fonction mm::readImg pour lire la matrice \(b\). Cette fonction convertit les données en type uint8, provoquant un underflow et corrompant les valeurs négatives. Lire les \(L_B\) lignes de \(b\) manuellement en utilisant le type standard int. L’image \(f\) peut continuer à être lue normalement par mm::readImg.

4.9.9.3 🧠 Fondement théorique

Concept Signification Impact visuel
\(\text{dist}(y,x)\) Distance morphologique jusqu’au bord le plus proche selon la métrique définie par \(b\) Les pixels plus internes reçoivent des valeurs plus élevées
Valeur maximale Pixel le plus éloigné du bord Se rapproche du centre morphologique de l’objet
Élément structurant pondéré Définit les coûts de déplacement entre pixels voisins Détermine la métrique de distance utilisée
Objets fins Régions étroites de l’objet Produisent des valeurs de distance faibles

4.9.9.4 📦 Spécification d’entrée et de sortie (VPL)

Entrée :

  • Ligne 1 : entier \(L\).
  • Ligne 2 : entier \(C\).
  • Ligne 3 : entier \(L_B\).
  • Ligne 4 : entier \(C_B\).
  • Les \(L_B\) lignes suivantes : éléments entiers de la matrice \(b\).
  • Les \(L\) lignes suivantes : éléments binaires (\(0\) ou \(1\)) de la matrice \(f\).

⚠️ Note d’implémentation : Les éléments de la matrice \(f\) (0 ou 1) doivent être multipliés par 255 pour générer une image binaire appropriée (\(0\) et \(255\)) avant d’appliquer la Transformée de Distance (TD).

Sortie :

  • Matrice de la transformée de distance en \(L\) lignes et \(C\) colonnes.

4.9.9.5 📌 Exemple

Entrée Sortie Observation
5
9
3
3
-99 -1 -99
-1 0 -1
-99 -1 -99
0 0 0 0 0 0 0 0 0
0 1 1 1 1 1 1 1 0
0 1 1 1 1 1 1 1 0
0 1 1 1 1 1 1 1 0
0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0
0 1 1 1 1 1 1 1 0
0 1 2 2 2 2 2 1 0
0 1 1 1 1 1 1 1 0
0 0 0 0 0 0 0 0 0
Résultat de la transformée de distance.

Note : la valeur -99 agit comme une approximation pratique de \(-\infty\), empêchant la propagation par les diagonales. Ainsi, seuls les voisins horizontaux et verticaux contribuent à la distance, produisant la distance de Manhattan.

🗺️ Simulateur EP04_09 : Transformée de distance Couches d'érosion

Cliquez sur les cellules pour dessiner votre propre objet ou sélectionnez une forme prédéfinie pour calculer la carte des distances en cascade.

Carte des distances calculée
Figure 4.38: Simulateur EP04_09: Transformée de Distance (Couches d’Érosion)
%%writefile EP04_09.cpp
// your solution
Overwriting EP04_09.cpp
TestSuite("EP04_09.cpp").run()
✔️ EP04_09.cases existe déjà dans casos/
📋 4 cas chargé(s) depuis casos/EP04_09.cases

🔍 Test de C++ : EP04_09.cpp
⚠️ EP04_09.cpp : fichier vide (moins de 3 lignes). Tests ignorés.

4.9.10 EP04_10 🪙 Séparation des blobs, étiquetage et descripteurs

Dans une ligne de production de pièces de monnaie, il est courant que les pièces se touchent sur le tapis roulant, formant une seule tache connectée dans l’image — un comptage naïf donnerait un nombre erroné. La solution classique combine des opérations morphologiques et une analyse de connectivité : d’abord, une érosion réduit ou rompt les connexions fragiles entre les objets, puis l’étiquetage des composantes connexes sépare chaque objet en une région distincte. Enfin, des descripteurs géométriques (aire et boîte englobante) résument chaque composante trouvée.

Voir Figure 4.39 pour une simulation de cet EP.

4.9.10.1 📋 Directives d’implémentation

  1. Dimensions de l’image : lire les entiers \(L\) (lignes) et \(C\) (colonnes) de \(f\).

  2. Dimensions de \(B\) : lire les entiers \(L_B\) (lignes) et \(C_B\) (colonnes) de l’élément structurant.

  3. Élément structurant : lire la matrice \(B\), contenant des valeurs \(0\) ou \(1\), ligne par ligne.

  4. Données : lire la matrice binaire \(f\) (valeurs \(0\) ou \(1\)), ligne par ligne.

  5. Séparation : calculer \[ f_{ero} = f \ominus B \] en utilisant une érosion binaire plane (comme dans l’EP04_04), en éliminant les connexions fragiles entre les objets.

  6. Étiquetage : sur \(f_{ero}\), identifier les composantes connexes en utilisant la connectivité définie par le voisinage \(B\). L’étiquetage doit suivre un balayage raster : lorsqu’un pixel \(1\) non encore étiqueté est trouvé, attribuer un nouveau label entier croissant à partir de 1 et propager ce label à toute la région connexe.

  7. Descripteurs : pour chaque label \(k\), calculer :

    • Aire : nombre de pixels appartenant au label ;
    • Boîte englobante : \[(y_{min}, x_{min}, y_{max}, x_{max})\]
  8. Sortie : afficher le nombre total de labels, puis une ligne par label au format : \[ k,\ \text{aire},\ y_{min},\ x_{min},\ y_{max},\ x_{max} \]

4.9.10.2 📌 Contraintes computationnelles

  • L’érosion doit être appliquée avant l’étiquetage.
  • La connectivité est fixe et définie par le voisinage ci-dessus.
  • L’élément structurant \(B\) n’interfère pas avec la connectivité de l’étiquetage.
  • Aucun padding à aucune étape.
  • L’ordre des labels suit la première découverte en balayage raster.

4.9.10.3 🧠 Fondement théorique

Concept Signification Impact
Pont fin Connexion étroite entre objets Peut être supprimé par l’érosion morphologique
Connectivité Définie par l’ensemble \[\mathcal{N}(y,x)\] Détermine quels pixels appartiennent à la même composante
Aire Nombre de pixels par composante Estimation directe de la taille de l’objet
Boîte englobante Extension spatiale du label Résumé géométrique de la composante

4.9.10.4 📦 Spécification d’entrée et de sortie (VPL)

Entrée :

  • Ligne 1 : entier \(L\)
  • Ligne 2 : entier \(C\)
  • Ligne 3 : entier \(L_B\)
  • Ligne 4 : entier \(C_B\)
  • Les \(L_B\) lignes suivantes : matrice \(B\)
  • Les \(L\) lignes suivantes : matrice \(f\)

Sortie :

  • Ligne 1 : nombre total de labels trouvés
  • Lignes suivantes : \[ k,\ \text{aire},\ y_{min},\ x_{min},\ y_{max},\ x_{max} \]
🪙 Simulateur EP04_10 : Pièces Collées → Séparées → Comptées érosion + étiquette + descripteurs

Ajustez l'épaisseur du pont entre les pièces et observez comment l'érosion morphologique sépare les objets pour le comptage et l'extraction de descripteurs (aire et boîte englobante).


1 px
Original (Collées)
Après Érosion + Étiquettes
Figure 4.39: Simulateur EP04_10: Séparation des Blobs, Étiquetage et Descripteurs
%%writefile EP04_10.cpp
// your solution
Overwriting EP04_10.cpp
TestSuite("EP04_10.cpp").run()
✔️ EP04_10.cases existe déjà dans casos/
📋 4 cas chargé(s) depuis casos/EP04_10.cases

🔍 Test de C++ : EP04_10.cpp
⚠️ EP04_10.cpp : fichier vide (moins de 3 lignes). Tests ignorés.