4  Morfología Matemática y Segmentación de Imágenes

Este capítulo presenta dos temas fundamentales del Procesamiento Digital de Imágenes (PDI): la morfología matemática y la segmentación de imágenes. La morfología matemática proporciona un marco teórico basado en la teoría de conjuntos para analizar, refinar y cuantificar la forma de objetos en imágenes binarias y en tonos de gris, mediante operadores fundamentales como la erosión y la dilatación. La segmentación, por su parte, tiene como objetivo particionar la imagen en regiones de interés, separando objetos del fondo y produciendo representaciones adecuadas para el análisis y la interpretación.

El capítulo comienza con la umbralización, una de las técnicas más importantes de segmentación, introduciendo el método automático de Otsu y revisitando el análisis de histogramas mediante la varianza interclases, presentada en el Capítulo 1. A continuación, se estudian los principales operadores de la morfología matemática, incluyendo erosión, dilatación, apertura, cierre y reconstrucción morfológica, que permiten refinar máscaras binarias y preservar estructuras relevantes de los objetos. Finalmente, se presentan técnicas de segmentación basada en regiones, como el etiquetado de componentes conexos, la transformada de distancia y el algoritmo watershed basado en marcadores, culminando en la extracción de descriptores geométricos y en la generación de bounding boxes compatibles con sistemas modernos de detección de objetos.

4.1 Objetivos

Al final de este capítulo, usted será capaz de:

  • Aplicar umbralización: Comprender el criterio automático de Otsu por maximización de la varianza interclases (\(\sigma_B^2\)) y seleccionar estrategias adecuadas de preprocesamiento para facilitar la segmentación;
  • Dominar la morfología binaria: Comprender y aplicar erosión (\(A\ominus B\)) y dilatación (\(A\oplus B\)) como operadores fundamentales, derivando apertura (\(A\circ B\)), cierre (\(A\bullet B\)) y operaciones basadas en reconstrucción morfológica, como mm::clohole y mm::edgeoff;
  • Aplicar morfología en tonos de gris: Utilizar gradiente morfológico y filtros top-hat para realce y análisis de estructuras locales;
  • Etiquetar componentes conexos: Identificar y separar regiones conectadas en imágenes binarias mediante algoritmos de etiquetado;
  • Aplicar transformada de distancia: Interpretar y calcular distancias al fondo utilizando enfoques morfológicos y métricas geométricas;
  • Segmentar por regiones: Construir pipelines de segmentación basados en marcadores utilizando Transformada de Distancia y el algoritmo watershed;
  • Extraer descriptores geométricos: Calcular propiedades como área, perímetro, centroide, circularidad y bounding boxes mediante mm::label0 y extracción de contornos;
  • Relacionar PDI y visión computacional: Comprender cómo los descriptores extraídos por segmentación pueden convertirse a formatos utilizados por detectores modernos, como YOLO.
import os, urllib.request

os.makedirs("tmp/state", exist_ok=True)  # artefactos de build de la trilha C++ (.cpp, binário, 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")

# O kernel é Python mesmo na trilha C++: `mm` (morph.py) é usado pelos
# simuladores, pela exibição das figuras que o binário C++ gera e pelo
# estado mm::Image entre células. cpp=True baixa também a trilha compilada
# (morph.hpp + stb_image*.h), usada no #include das células %%writefile *.cpp.
# As células C++ deste capítulo compilam COM OpenCV (-DMM_USE_OPENCV +
# pkg-config opencv4): mm::dil/ero → cv::dilate/erode, então open/close/asf/
# gradm/tophat/blackhat rodam full-res e batem bit a bit com a trilha py.
import config
config.setup(cpp=True)
from morph import mm
import numpy as np
✅ Entorno listo. Morph: 1.1.9 | OpenCV: 5.0.0

4.2 Umbralización

La umbralización (thresholding) es una de las formas más simples y eficientes de segmentación de imágenes. Su objetivo es clasificar cada píxel en dos clases de intensidad, normalmente asociadas a objeto y fondo:

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

donde \(f(x,y)\) representa la intensidad del píxel en la imagen original y \(g(x,y)\) la imagen binaria resultante.

La elección del umbral \(T\) es importante para la calidad de la segmentación. El método de Otsu determina automáticamente el umbral óptimo al maximizar la varianza entre clases \(\sigma_B^2\) definida por:

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

donde:

  • \(w_0(T)\) y \(w_1(T)\) son las probabilidades acumuladas de las clases fondo y objeto;
  • \(\mu_0(T)\) y \(\mu_1(T)\) son las medias de intensidad de esas clases;
  • \(\sigma_B^2(T)\) representa la varianza entre clases para un umbral dado \(T\).

El método funciona mejor cuando el histograma presenta dos grupos de intensidades relativamente separados. Para ello, el algoritmo evalúa todos los umbrales posibles de la imagen — típicamente en el intervalo \([0,255]\) para imágenes de 8 bits — y selecciona el valor que maximiza la varianza entre clases, denotada por \(\sigma_B^2\):

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

NotaOtsu asume histogramas bimodales

El método de Otsu produce mejores resultados cuando el histograma presenta dos picos bien definidos (bimodalidad), correspondientes al fondo y al objeto. Cuanto mayor sea la separación entre estos picos y más pronunciado sea el máximo de \(\sigma_B^2\), más confiable tiende a ser el umbral obtenido.

En imágenes con iluminación no uniforme o múltiples regiones de intensidad, las técnicas de umbralización adaptativa — en las cuales el umbral se calcula localmente — suelen producir segmentaciones más robustas.

El subíndice \(B\) en \(\sigma_B^2\) significa between classes (entre clases). Así, \(\sigma_B^2\) representa la varianza entre las clases (between-class variance).

4.2.1 Imagen de Monedas

La imagen utilizada para practicar la segmentación es una fotografía de una colección de monedas de diferentes países y épocas (Figura 4.1). Crédito: GAZI.MD.AHAD (CC BY-SA 4.0). Presenta objetos circulares con bordes bien definidos, siendo ideal para demostrar umbralización, operadores morfológicos, transformada de distancia, watershed y descriptores de forma.

%%writefile tmp/fig_04_coins.cpp
#define MM_OUT "tmp/fig_04_coins.png"
//| 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

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

int main() {
    // 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 << "]" << std::endl;
    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)")
Figura 4.1: Imagem com moedas de vários tipos. Crédito: GAZI.MD.AHAD (CC BY-SA 4.0).

4.2.2 Preprocesamiento para Otsu

El método de Otsu depende de un histograma bien bimodal. La imagen de las monedas tiene iluminación no uniforme y monedas oscuras cerca del fondo, lo cual dificulta el proceso. En la ruta C++ se aplica ecualización global del histograma (mm::equalize) antes de la umbralización — la comparación CLAHE × Gaussiano y las curvas \(\sigma_B^2(T)\) se encuentran en la ruta Python (Figura 4.2).

%%writefile tmp/fig_04_otsu_comparacao_histogramas.cpp
#define MM_OUT "tmp/fig_04_otsu_comparacao_histogramas.png"
//| 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

#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]

    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)")
Figura 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 Resultado: CLAHE como Mejor Preprocesamiento

El análisis de la Figura 4.2 indica que el CLAHE obtuvo el mayor valor de la varianza entre clases (\(\sigma_B^2 \approx 2{,}47 \times 10^3\)), con umbral óptimo \(T^* = 122\). Aunque la combinación CLAHE+Gaussiano haya producido un resultado muy similar (\(\sigma_B^2 \approx 2{,}43 \times 10^3\), \(T^* = 123\)), el criterio cuantitativo del método de Otsu favorece ligeramente el uso del CLAHE de forma aislada.

En términos visuales, las imágenes binarizadas obtenidas con CLAHE y CLAHE+Gaussiano son prácticamente equivalentes. La diferencia entre ambos enfoques se hace más evidente en el análisis de los histogramas y de los valores de \(\sigma_B^2(T)\) que en la inspección directa de las segmentaciones resultantes. Así, la elección del CLAHE se basa principalmente en la maximización de la separación estadística entre las clases de fondo y objeto.

TipInterpretación de los resultados

Observe que los preprocesamientos con CLAHE y CLAHE+Gaussiano producen histogramas y umbrales óptimos muy próximos (\(T^*=122\) y \(T^*=123\)). En consecuencia, las imágenes binarizadas resultantes también son bastante similares. En este caso, la decisión no se basa en diferencias visuales marcadas, sino en el criterio objetivo del método de Otsu: el mayor valor de \(\sigma_B^2\) indica la mejor separación entre las clases.

4.3 Morfología Matemática

La morfología matemática es una teoría basada en conjuntos utilizada para analizar la forma y la estructura de objetos en imágenes. A diferencia de los filtros lineales presentados en el Capítulo 3, los operadores morfológicos son no lineales, pues se basan en operaciones de mínimo, máximo e inclusión espacial, en lugar de combinaciones lineales de intensidades. Estos operadores actúan sobre la vecindad de cada píxel mediante un elemento estructurante \(\mathbb{B}\), responsable de definir la forma y el tamaño de la región analizada.

En imágenes binarias y en tonos de gris con elementos planos, el elemento estructurante trasladado a la posición \(x\) se define espacialmente como:

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

En las regiones de borde de la imagen, parte del conjunto \(\mathbb{B}_x\) puede extrapolar el dominio físico de la escena (\(\mathbb{E}\)). Para garantizar la consistencia matemática de los operadores primitivos en esas fronteras, se asume teóricamente que el espacio exterior al dominio de la imagen se rellena con el elemento neutro de la operación correspondiente (infinito positivo para la erosión e infinito negativo para la dilatación), impidiendo que el entorno externo corrompa las estructuras internas del objeto.

Cuando el elemento estructurante asocia pesos a sus elementos — es decir, \(b: \mathbb{B} \to \mathbb{Z}\) — se denomina función estructurante o elemento estructurante no plano.

Desarrollada por Matheron y Serra en la década de 1960 para imágenes binarias y posteriormente extendida a tonos de gris, la morfología matemática fundamenta operadores como el gradiente morfológico, el top-hat, el watershed y la transformada de distancia, todos derivados de dos primitivos: la erosión y la dilatación [Matheron (1975); Serra (1982)].

4.3.1 Erosión y Dilatación

Los dos operadores primitivos se definen de manera unificada para imágenes en tonos de gris (\(f: \mathbb{E} \to \mathbb{Z}\)) y, por restricción al dominio \(\{0,1\}\), también para imágenes binarias.

4.3.1.1 Erosión

La Erosión de una imagen \(f\) por una función estructurante \(b: \mathbb{B} \to \mathbb{Z}\) se define formalmente por:

\[ \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 la práctica, la erosión sustituye la intensidad del píxel \(x\) por el valor mínimo resultante de la diferencia entre la imagen y el elemento estructurante en la vecindad definida por el dominio \(\mathbb{B}\). Los valores positivos en los pesos de \(b(z)\) fuerzan el resultado local hacia abajo, “excavando” el relieve de la imagen más profundamente e intensificando la erosión.

En el caso plano (donde los pesos son nulos dentro del dominio, es decir, \(b \equiv 0\)), la expresión se simplifica al mínimo local puro:

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

En imágenes binarias, esta operación equivale a exigir que el conjunto \(\mathbb{B}\), trasladado a la coordenada \(x\), esté completamente contenido en el objeto \(A\):

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

Efecto Visual: Encoge objetos y estructuras claras, eliminando protuberancias, picos brillantes o ruidos que sean geométricamente menores que el dominio \(\mathbb{B}\).

4.3.1.2 Implementación de la erosión

La versión didáctica mm::ero0 implementa el caso particular de erosión con elemento estructurante plano. Para cada píxel \((y,x)\), la función recorre los vecinos espaciales permitidos por \(B\) y almacena el menor valor encontrado en la imagen de entrada \(f\), reproduciendo directamente la operación de mínimo local descrita en la Ecuación 4.3 para \(b \equiv 0\).

Observe que los valores de los vecinos siempre se leen de forma estática de la imagen original \(f\); la matriz de salida \(g\) se utiliza exclusivamente para registrar el mínimo acumulado de la vecindad actual. De esta forma, el resultado final es invariante respecto al orden de barrido de los píxeles (ya sea por filas o columnas).

La función auxiliar _viz calcula las coordenadas de los vecinos válidos dentro de los límites físicos de la imagen. En los bordes, la inicialización del acumulador en 255 emula con exactitud el relleno por elemento neutro exigido por la teoría. Por su parte, la función de interfaz mm::ero recurre a la implementación nativa y optimizada de OpenCV (mm::ero) cuando el elemento estructurante es plano, cambiando a la rutina general mm::ero1 en caso de que el elemento posea pesos topográficos.

El ejemplo computacional siguiente ilustra la aplicación de un elemento estructurante en cruz (mm::secross()) destacado en la Figura 4.3, comparando la ejecución de la variante didáctica en bucle (mm::ero0) con el motor computacional de 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"

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)")
Figura 4.3: Elemento estruturante em formato de cruz (\(B_{\text{cruz}}\)) utilizado para conectividade-4.
# La morph.hpp es header-only; a continuación, el helper _viz y el cuerpo 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 Dilatación

La Dilatación de una imagen \(f\) mediante una función estructurante \(b: \mathbb{B} \to \mathbb{Z}\) se define formalmente como:

\[ \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 la práctica, la dilatación reemplaza la intensidad del píxel \(x\) por el mayor valor resultante de la suma entre la imagen y el elemento estructurante en la vecindad definida. El argumento de inversión espacial (\(x - z\)) indica que la dilatación evalúa implícitamente el elemento transpuesto (reflejado) \(\hat{b}\), propiedad fundamental para asegurar la dualidad matemática respecto a la erosión.

En el caso plano (donde los pesos son nulos dentro del dominio, es decir, \(b \equiv 0\)), la expresión se reduce al máximo local puro:

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

En imágenes binarias, esta operación equivale a exigir que el conjunto reflejado \(\hat{\mathbb{B}}\), trasladado a la coordenada \(x\), tenga una intersección no vacía con el objeto \(A\):

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

Efecto Visual: Expande las estructuras claras de la imagen, aumentando el relleno de objetos, conectando componentes cercanos y eliminando canales, fosas oscuras o valles que sean geométricamente menores que el dominio \(\mathbb{B}\).

4.3.1.4 Implementación de la dilatación

La versión didáctica mm::dil0 implementa el caso particular de dilatación con elemento estructurante plano. Para cada píxel \((y,x)\), la función recorre los vecinos espaciales permitidos por \(B\) y almacena el mayor valor encontrado en la imagen de entrada \(f\), reproduciendo directamente la operación de máximo local para \(b \equiv 0\).

Antes de iniciar el barrido espacial, el elemento estructurante sufre una reflexión geométrica mediante la instrucción np.flip(Bc) para construir explícitamente la matriz transpuesta \(\hat{B}\) exigida por la teoría. En máscaras perfectamente simétricas (como cruces, cuadrados y discos centrados en el origen), esta reflexión no altera la disposición de los píxeles; no obstante, para elementos asimétricos, tal etapa es estrictamente necesaria para garantizar la equivalencia con las definiciones formales y salvaguardar las leyes de dualidad.

Así como se verificó en el operador de erosión, los valores de los vecinos siempre se leen de forma estática a partir de la matriz original \(f\), mientras que la matriz de salida \(g\) actúa puramente como el registrador del máximo acumulado de la vecindad. En las fronteras de la imagen, la inicialización del acumulador en 0 emula con exactitud el relleno externo por elemento neutro (\(-\infty\), o cero en representaciones de 8 bits), garantizando que los bordes físicos de la escena sean dilatados en perfecta conformidad con el estándar adoptado por OpenCV.

El ejemplo computacional siguiente ilustra la aplicación práctica de un elemento en cruz (mm::secross()), validando la consistencia entre la lógica en bucles (mm::dil0) y el método nativo industrial (mm::dil).

# Cuerpo de mm::dil0() en morph.hpp (refleja el SE antes de barrer, como 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;
}
NotaNota: El Enfrentamiento de los Signos (\(f(x+z)\) vs \(f(x-z)\))

Compare las definiciones formales de la erosión (Ecuación 4.3) y de la dilatación (Ecuación 4.4). Considere un elemento estructurante asimétrico a la derecha \(\mathbb{B}=\{0,1\}\) (origen y un píxel a la derecha) aplicado en la posición \(x=10\).

  1. En la Erosión (Ecuación 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)}\)

    El operador consulta el píxel actual (\(10\)) y el píxel a la derecha (\(11\)), preservando la orientación original de \(\mathbb{B}\).

  2. En la Dilatación (Ecuación 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)}\)

    Debido al signo negativo (\(-z\)), avanzar en el elemento estructurante corresponde a retroceder en la imagen, haciendo que la dilatación consulte el píxel a la izquierda (\(9\)).

La función _viz, utilizada en morph.py, genera vecinos mediante desplazamientos aditivos de la forma \(x+z\). Por ese motivo, la implementación de mm::dil0 refleja previamente el elemento estructurante mediante np.flip(B). Tras la reflexión, el barrido basado en \(x+z\) pasa a acceder exactamente a los mismos puntos definidos por la expresión teórica \(f(x-z)\) de la dilatación en Ecuación 4.4.

Para elementos estructurantes simétricos (como discos, cuadrados y cruces centradas), la reflexión no altera la máscara. En cambio, para elementos asimétricos, esta etapa es indispensable para que la implementación reproduzca correctamente la definición matemática de la dilatación y preserve la dualidad erosión–dilatación.

NotaDualidad erosión–dilatación

La erosión y la dilatación son duales por complemento. Esto significa que un operador puede obtenerse completamente a partir del otro, siempre que se actúe sobre el complemento de la imagen utilizando el elemento estructurante reflejado \(\hat{B}\):

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

De manera análoga, la dilatación también puede obtenerse a partir de la erosión:

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

En términos prácticos, la erosión de un objeto puede obtenerse mediante la dilatación de su complemento, seguida de la complementación del resultado (y viceversa). En la implementación del paquete morph.py, las versiones didácticas mm::ero0 y mm::dil0 hacen explícita esta estructura mediante bucles (loops), mientras que mm::ero y mm::dil delegan las operaciones a OpenCV buscando una mayor eficiencia computacional.

NotaCondiciones de contorno e imágenes finitas

En la morfología matemática clásica, definida sobre un dominio infinito (típicamente \(\mathbb{Z}^2\)), esta dualidad es exacta. En imágenes digitales, sin embargo, se trabaja con matrices finitas, y el resultado pasa a depender de la forma en que se tratan los píxeles ubicados fuera de la imagen.

Para que las identidades de dualidad permanezcan válidas, el complemento debe definirse con respecto al mismo universo y las condiciones de contorno adoptadas para la erosión y para la dilatación deben ser complementarias entre sí. Por ejemplo, si la erosión asume que los píxeles externos pertenecen al objeto (\(255\)), entonces la dilatación aplicada al complemento debe asumir que esos mismos píxeles externos pertenecen al fondo (\(0\)).

Cuando se utilizan diferentes estrategias de relleno (replicación, reflexión, valor constante, etc.), la dualidad teórica puede dejar de satisfacerse exactamente en las regiones cercanas a los bordes de la imagen.

Para ilustrar numéricamente los operadores morfológicos y la dualidad erosión–dilatación, la Figura 4.4 presenta una imagen binaria de 10×10 procesada con un elemento estructurante en forma de “L”. En la implementación de morph.py, el origen de \(B\) se fija en el centro geométrico de la máscara —posición \((1,1)\) para un kernel de 3×3— y debe corresponder a un elemento activo para que la erosión se comporte correctamente (según lo discutido anteriormente). El elemento estructurante \(B_L\) definido a continuación satisface esa condición. La Figura 4.5 complementa el análisis con un simulador interactivo de la erosión, permitiendo visualizar el desplazamiento del elemento estructurante sobre la imagen e identificar las posiciones en las que este permanece completamente contenido en el objeto.

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

int main() {
    //| 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

    // A = np.array([...], dtype=np.uint8) * 255
    mm::Image A(10, 10, 1);
    int A_data[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) = A_data[y][x] * 255;
        }
    }

    // B_L = np.array([[1,0,0],[1,1,0],[1,1,0]], dtype=np.uint8)   # 'L', origem no centro
    mm::SE B_L{{1,0,0},{1,1,0},{1,1,0}};
    // B_hat = np.array([[0,1,1],[0,1,1],[0,0,1]], dtype=np.uint8)   # B_L girado 180° (B̂)
    mm::SE B_hat{{0,1,1},{0,1,1},{0,0,1}};

    std::cout << "Elemento estruturante B_L:\n"; 
    // B_L is an SE; convert to Image for drawing
    mm::Image B_L_img = mm::sebox(0); // placeholder, will fix below
    B_L_img = mm::dil0(mm::Image(3, 3, 1), B_L); // get binary SE shape
    std::cout << mm::drawImg(B_L_img);
    std::cout << "Elemento estruturante refletido B̂:\n";
    mm::Image B_hat_img = mm::dil0(mm::Image(3, 3, 1), B_hat); // get binary SE shape
    std::cout << mm::drawImg(B_hat_img);

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

    // Dualidade: (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);

    // Check if ero_c and dil_Ac are equal
    bool equal = true;
    for (int i = 0; i < (int)ero_c.data.size() && equal; i++) {
        if (ero_c.data[i] != dil_Ac.data[i]) equal = false;
    }
    std::cout << "Dualidade (A ⊖ B)ᶜ == Aᶜ ⊕ B̂ : " << (equal ? "True" : "False") << "\n";

    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:
0 0 0 
0 0 0 
0 0 0 
Elemento estruturante refletido B̂:
0 0 0 
0 0 0 
0 0 0 
Dualidade (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)")
Figura 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.
🪨 Simulador: Erosión Morfológica A ⊖ B_L · offsets via _viz

Haz clic en una celda del lienzo para mover el kernel B_L o usa los controles deslizantes para probar la inclusión de contenidos.

Posición X (col)
4
Posición Y (fila)
4
Píxel Erosión
255
🖱️ Haz clic en una celda para mover el kernel B_L
🟢
Éxito: ¡contenido!
El píxel recibe 1 (255) en la imagen erosionada.
Controles de Coordenada
4
4
Kernel B_L (3×3)
1
0
0
1
★
0
1
1
0
Figura 4.5: Simulador: Erosión Morfológica (A ⊖ B_L)

4.3.2 Apertura y Cierre

Combinando erosión y dilatación se obtienen dos operadores de gran utilidad práctica: la apertura y el cierre, definidos por las Ecuaciones Ecuación 4.5 y Ecuación 4.6. Sus principales efectos se resumen en la Tabla 4.1.

Apertura (opening) — erosión seguida de dilatación por el mismo \(B\):

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

Cierre (closing) — dilatación seguida de erosión por el mismo \(B\):

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

En la práctica, mm::open y mm::close aplican el mismo elemento estructurante en las dos etapas. Para elementos estructurantes simétricos (los más comunes), esta implementación coincide con la definición matemática presentada anteriormente.

Tabla 4.1: Propiedades de apertura y cierre.
Operador Secuencia Efecto principal
Apertura \(A \circ B\) erosión → dilatación Elimina estructuras incapaces de contener el elemento estructurante; suaviza contornos externos
Cierre \(A \bullet B\) dilatación → erosión Rellena agujeros más pequeños que \(B\); suaviza contornos internos

Propiedad importante: ambos son idempotentes. Por ejemplo,

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

es decir, después de la primera aplicación, nuevas aplicaciones del mismo operador no alteran más el resultado.

4.3.2.1 Implementación de la apertura y el cierre

A diferencia de la erosión y la dilatación, la apertura y el cierre no introducen nuevos mecanismos computacionales. Ambos se obtienen mediante la composición secuencial de los operadores primitivos ya presentados:

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 función mm::open delega la operación a mm::open(f, B), mientras que mm::close utiliza mm::close(f, B), produciendo el mismo resultado de forma más eficiente.

La apertura hereda de la erosión la capacidad de eliminar estructuras más pequeñas que el elemento estructurante y de la dilatación la restauración parcial de las regiones preservadas. El cierre realiza el proceso inverso: primero expande los objetos y luego restaura sus dimensiones originales, rellenando huecos y agujeros más pequeños que el elemento estructurante.

4.3.2.2 Filtro Secuencial Alternado

En la práctica, la apertura y el cierre se aplican frecuentemente en secuencia para eliminar simultáneamente el ruido externo y rellenar los huecos internos. La función mm::asf (Filtro Secuencial Alternado) generaliza esta estrategia al aplicar aperturas y cierres alternadamente con elementos estructurantes progresivamente más grandes. Las secuencias disponibles se presentan en la Tabla 4.2.

Tabla 4.2: Secuencias del filtro secuencial alternado mm.asf.
Secuencia Orden Uso típico
'OC' apertura → cierre elimina el ruido externo antes de rellenar pequeños huecos
'CO' cierre → apertura rellena pequeños huecos antes de eliminar el ruido externo
'OCO' apertura → cierre → apertura enfatiza la eliminación del ruido externo
'COC' cierre → apertura → cierre enfatiza el relleno de huecos y lagunas

El parámetro n controla el número de escalas utilizadas por el filtro. En cada iteración \(i\), el elemento estructurante se amplía mediante la suma de Minkowski (mm::sesum(b, i)), produciendo una secuencia de filtros morfológicos cada vez más abarcativos. A diferencia de una única apertura o cierre con un elemento estructurante grande, el ASF realiza una suavización progresiva en múltiples escalas, preservando mejor la geometría de los objetos relevantes mientras elimina estructuras menores. La Figura 4.6 presenta un ejemplo de aplicación de la apertura, el cierre y el ASF en la imagen de las monedas.

%%writefile tmp/fig_04_open_close.cpp
#define MM_OUT "tmp/fig_04_open_close.png"
#include "morph.hpp"
#include <iostream>
#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]

    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)")
Figura 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 Operadores Geodésicos

Los operadores geodésicos introducen una restricción adicional a los operadores morfológicos clásicos mediante una imagen de control denominada máscara \(g\). En lugar de permitir que la erosión o la dilatación se propaguen libremente por la imagen, el resultado de cada iteración se limita punto a punto por los valores de la máscara, restringiendo la evolución de la operación a las regiones permitidas.

4.3.3.1 Dilatación Geodésica

La dilatación geodésica de una imagen marcador \(f\) bajo una imagen máscara \(g\), utilizando un elemento estructurante plano \(b\), se define por:

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

donde \(\wedge\) representa el mínimo punto a punto.

En otras palabras, se realiza inicialmente una dilatación convencional sobre el marcador y, a continuación, el resultado se restringe mediante la máscara \(g\). De esta manera, la propagación nunca puede superar las regiones permitidas por la máscara.

La formulación clásica de la dilatación geodésica presupone que el marcador esté contenido en la máscara, es decir, \(f \le g\), garantizando que la evolución de la operación permanezca siempre limitada por la máscara.

4.3.3.2 Implementación de la dilatación geodésica

La función mm::cdil implementa directamente este operador y permite ejecutar múltiples iteraciones consecutivas:

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

La instrucción np.minimum(mm::dil(y, b), g) implementa exactamente la definición matemática de la dilatación geodésica, es decir, \((y \oplus b)\wedge g\).

Cuando \(n=1\), la función ejecuta una única dilatación geodésica. Para \(n>1\), el resultado de cada etapa se convierte en el marcador de la etapa siguiente, produciendo una propagación progresiva controlada por la máscara.

El simulador interactivo Figura 4.7 permite seguir, paso a paso, la propagación del marcador \(f\) a lo largo de los corredores del laberinto. En cada iteración de mm::cdil, el frente de dilatación avanza hacia las celdas vecinas libres —aquellas en las que \(g = 1\)—, mientras que las paredes (\(g = 0\)) permanecen intransitables. El número de pasos necesarios para que el marcador alcance la salida corresponde exactamente a la longitud geodésica del camino más corto dentro de la máscara, evidenciando la conexión directa entre la dilatación geodésica iterada y la noción de distancia en grafos.

🗺️ Simulador: Dilatación Geodésica en el Laberinto δ_g^(n)(f)
Pared
Camino libre (g)
Marcador f
Propagación
Salida
Paso 0 — marcador inicial f (entrada)
Figura 4.7: Simulador interactivo de dilatación geodésica: el marcador f (verde) se propaga paso a paso por los caminos libres de la máscara g, sin atravesar paredes.

4.3.3.3 Erosión Geodésica

De forma dual, la erosión geodésica de una imagen marcador \(f\) bajo una imagen máscara \(g\) se define por:

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

donde \(\vee\) representa el operador de máximo punto a punto.

En este caso, la erosión convencional del marcador es seguida por una restricción inferior impuesta por la máscara. Así, ningún píxel del resultado puede asumir un valor inferior al correspondiente píxel de la máscara.

La formulación clásica de la erosión geodésica presupone la condición dual

\[ f \ge g, \]

de modo que la máscara actúe como límite inferior durante todo el proceso.

4.3.3.4 Implementación de la erosión geodésica

La función estática mm::cero materializa este operador:

staticmethod
def cero(f, g, b=np.zeros((3,3),dtype='uint8'), n=1):
    """Erosión geodésica del marcador f bajo la máscara g."""
    y = f.copy()
    for _ in range(n):
        y = np.maximum(mm.ero(y, b), g)
    return y

La instrucción np.maximum(mm::ero(y, b), g) implementa directamente la expresión \((y \ominus b)\vee g\).

Al igual que en la dilatación geodésica, el parámetro \(n\) define cuántas erosiones geodésicas sucesivas se calcularán antes de devolver la imagen final.

NotaRelación con la reconstrucción morfológica

La reconstrucción morfológica presentada en la siguiente sección se obtiene mediante la aplicación iterativa de la dilatación geodésica (mm::cdil) hasta alcanzar un punto fijo, es decir, hasta que ninguna celda cambie de valor entre dos iteraciones consecutivas. En otras palabras, la reconstrucción consiste en una secuencia de dilataciones geodésicas sucesivas que se propagan dentro de la máscara hasta que no haya más alteraciones.

De forma dual, también es posible definir reconstrucciones basadas en erosión geodésica mediante aplicaciones sucesivas de mm::cero.

4.3.3.5 Ejemplo: propagación en un laberinto mediante dualidad

La Figura 4.8 ilustra la resolución del problema de conectividad de un laberinto utilizando la dualidad morfológica por medio de las funciones mm::cero y mm::suprec.

En lugar de propagar un marcador por los corredores libres mediante dilataciones geodésicas, el problema se formula en el dominio complementario. Inicialmente, la máscara original se invierte,

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

de modo que las paredes pasan a tomar el valor 1 y los corredores el valor 0. De manera análoga, el marcador se construye en ese mismo dominio complementario, conteniendo un único valor 0 en la posición de entrada del laberinto y valor 1 en los demás píxeles.

Utilizando el elemento estructurante en cruz (mm::secross()), la erosión geodésica actúa sobre el marcador complementado. En cada iteración de mm::cero, la región conectada al marcador inicial sufre erosiones sucesivas, mientras que la máscara impone un límite inferior que impide la propagación a través de las paredes del laberinto.

Las imágenes intermediarias muestran estados de la evolución después de diferentes números de iteraciones (n=5, n=12 y n=22). El resultado final se obtiene mediante la reconstrucción geodésica por erosión (mm::suprec), que aplica erosiones geodésicas sucesivas hasta alcanzar un punto fijo, es decir, una situación en la que no se produce ninguna alteración adicional entre dos iteraciones consecutivas.

En el dominio complementario, la región reconstruida corresponde exactamente al conjunto de corredores conectados a la entrada del laberinto. Así, la conectividad entre la entrada y la salida puede determinarse directamente a partir de la imagen reconstruida.

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

int main() {
    // Máscara já invertida (255 = parede, 0 = corredor)
    mm::Image g(10, 10);
    unsigned char 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) = 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)")
Figura 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 Reconstrucción Morfológica

La reconstrucción morfológica propaga una imagen marcadora \(f\) dentro de una imagen máscara \(g\), garantizando que el resultado nunca supere los valores de intensidad impuestos por la máscara. El operador fundamental que posibilita esta propagación contenida es la dilatación geodésica, definida por la Ecuación 4.7.

La reconstrucción se obtiene mediante la aplicación iterativa de esta dilatación condicionada. Inicialmente, el marcador está limitado por la máscara para establecer el estado inicial:

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

y las iteraciones subsiguientes se definen de forma recursiva mediante:

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

La secuencia crece de manera monótona hasta alcanzar un punto fijo, produciendo la reconstrucción morfológica por dilatación (también conocida en la literatura como inf-reconstrucción):

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

El ascenso iterativo se detiene tan pronto como se logra la estabilidad, es decir, cuando dos iteraciones consecutivas producen matrices con valores absolutamente idénticos.

4.3.4.1 Implementação da reconstrucción morfológica

La rutina didáctica mm::infrec implementa directamente el algoritmo iterativo de punto fijo. Inicialmente, el marcador efectivo inicial \(X^{(0)}\) se determina mediante la operación np.minimum(f, g). Para garantizar que el bucle de verificación ejecute la primera pasada sin disparar falsas convergencias prematuras, la variable de control de la iteración anterior (y1) se inicializa rellenada con un valor centinela fuera del dominio de los datos (o simplemente con una matriz que fuerce la primera ejecución).

def infrec(f, g, b=np.zeros((3,3), dtype='uint8')):
    """Inf-reconstrucción: dilata el marcador (f ∧ g) hasta converger bajo la máscara g."""
    y = np.minimum(f, g)
    # Inicializa y1 con valores imposibles para forzar la entrada en el bucle
    y1 = np.full_like(f, 256, dtype=np.int16) 
    while not np.array_equal(y, y1):
        y1 = y.copy()
        # Aplica la dilatación geodésica: (y ⊕ b) ∧ g
        y = np.minimum(mm.dil(y, b), g)
    return y.astype('uint8')

En el interior del bucle while, la variable y almacena la estimación corriente de la reconstrucción \(X^{(k)}\), mientras que y1 preserva la imagen del estadio inmediatamente anterior \(X^{(k-1)}\). La instrucción de control condicional np.minimum(mm::dil(y, b), g) traduce fielmente la dilatación geodésica teórica, donde la expansión morfológica convencional comandada por OpenCV se “poda” inmediatamente y queda limitada por las barreras de intensidad de la máscara \(g\). El bucle cesa cuando no se registra ninguna modificación de píxel entre los pasos.

4.3.4.2 Ventajas de la reconstrucción morfológica

La reconstrucción morfológica es significativamente más robusta que la apertura convencional porque es capaz de eliminar estructuras no deseadas sin distorsionar ni alterar la morfología de los objetos que deben conservarse.

Mientras que la apertura clásica suaviza esquinas, elimina puntas y deforma contornos debido a la imposición geométrica rígida del elemento estructurante, la reconstrucción geodésica utiliza la máscara para recuperar con exactitud los límites y formatos originales de los objetos que poseen conectividad con el marcador original.

En términos intuitivos, el marcador actúa como una semilla de contagio que se expande progresivamente, pero solo transitando por las regiones permitidas por la máscara. Los componentes que no poseen ninguna intersección con el marcador jamás serán reconstruidos (siendo eliminados), mientras que los componentes tocados por la semilla se expanden hasta restaurar integralmente su geometría original.

Este comportamiento discriminatorio y conservativo se ilustra en la Figura 4.9, utilizando el elemento estructurante en cruz presentado en la Figura 4.3..

%%writefile tmp/fig_04_reconstrucao_didatica.cpp
#define MM_OUT "tmp/fig_04_reconstrucao_didatica.png"
// g++ -std=c++17 -DMORPH_USE_OPENCV snippet.cpp -o snippet $(pkg-config --cflags --libs opencv4) -lm

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

int main() {
    //| 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

    mm::Image g(10, 10);
    // Initialize g with the 0/1 pattern scaled by 255
    int vals_g[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) = (unsigned char)(vals_g[y][x] * 255);
        }
    }

    mm::Image B_cruz = mm::secross();
    mm::Image f = mm::ero(g, mm::sebox(0));          // 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);

    // Check if img_reconstruida equals iteracoes[-1]
    bool igual = true;
    if (img_reconstruida.h != iteracoes.back().h || img_reconstruida.w != iteracoes.back().w) {
        igual = false;
    } else {
        for (size_t i = 0; i < img_reconstruida.data.size(); i++) {
            if (img_reconstruida.data[i] != iteracoes.back().data[i]) {
                igual = false;
                break;
            }
        }
    }
    std::cout << "✅ Estabilidade na iteração 5: " << (igual ? "True" : "False") << std::endl;

    std::vector<mm::Image> todas_imagens;
    todas_imagens.push_back(g);
    todas_imagens.push_back(f);
    for (auto& img : iteracoes) {
        todas_imagens.push_back(img);
    }
    todas_imagens.push_back(img_reconstruida);

    std::vector<std::string> todos_titulos = {"Máscara (g)", "Marcador (f)"};
    for (auto& t : titulos) {
        todos_titulos.push_back(t);
    }
    todos_titulos.push_back("Reconstrução R_g(f)");

    mm::show(todas_imagens, MM_OUT, todos_titulos, 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)")
Figura 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 Relleno de Agujeros y Eliminación de Bordes

Dos operadores basados en reconstrucción morfológica completan el pipeline de limpieza binaria. Sus características principales se resumen en la Tabla 4.3.

Relleno de agujeros (mm::clohole) elimina cavidades completamente rodeadas por el objeto, independientemente del tamaño, sin alterar los contornos externos. El procedimiento actúa sobre el complemento de la imagen, utilizando como marcador una restricción del marco (frame) al fondo:

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

En términos operativos, primero se reconstruye el fondo externo y, a continuación, se aplica la complementación para recuperar los objetos con los agujeros rellenos.

Eliminación de objetos de borde (mm::edgeoff) elimina todos los objetos que tocan el borde de la imagen, preservando únicamente los componentes totalmente internos. El marcador se obtiene mediante la intersección entre el marco (frame) y los objetos de la imagen:

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

Tabla 4.3: Comparación entre los operadores clohole y edgeoff.
Operador Marcador Máscara Efecto
mm::clohole frame restringido al fondo (\(f^c\)) \(f^c\) Rellena agujeros internos
mm::edgeoff frame restringido al objeto (\(f\)) \(f\) Elimina objetos conectados al borde

La evolución paso a paso de estas transformaciones geodésicas puede seguirse en las figuras siguientes. La Figura 4.10 ilustra el mecanismo de inundación controlada del operador mm::clohole, en el cual la reconstrucción se produce a partir del fondo externo e impide la propagación hacia regiones internas no conectadas al exterior, dando como resultado el relleno consistente de las cavidades internas. En cambio, la Figura 4.11 detalla la dinámica del operador mm::edgeoff, en la que solo los componentes conectados al borde se reconstruyen y posteriormente se eliminan, preservando exclusivamente los objetos totalmente contenidos en el interior de la imagen.

La conectividad de la propagación geodésica se controla mediante el elemento estructurante: mm::sebox() (vecindad de 8) incluye conexiones diagonales, mientras que mm::secross() (vecindad de 4) las excluye. En consecuencia, la elección del elemento estructurante afecta a qué componentes se alcanzan mediante la reconstrucción y, por tanto, cuáles se preservarán o eliminarán.

4.3.5.1 Conformidad con la implementación

Las definiciones anteriores están directamente alineadas con la implementación en morph.py, reproducida a continuación:

staticmethod
def clohole(f, b=np.ones((3,3),dtype='uint8')):
    # marcador restrito ao fundo da imagem
    marcador = mm.frame(f, border=1) & mm.neg(f)
    return mm.neg(mm.infrec(marcador, mm.neg(f), b))

staticmethod
def edgeoff(f, b=np.ones((3,3),dtype='uint8')):
    # marcador restrito aos objetos da imagem
    marcador = mm.frame(f, border=1) & f
    return mm.subm(f, mm.infrec(marcador, f, b))

Estas implementaciones dejan explícito que ambos operadores son instancias directas de reconstrucción morfológica por dilatación geodésica con mm::infrec, diferenciándose únicamente en la elección del marcador y de la máscara: clohole actúa sobre el complemento de la imagen, mientras que edgeoff actúa directamente en el dominio de los objetos.

%%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>

int main() {
    mm::Image f(10, 10);
    // Matriz binária definida manualmente
    int dados[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) = dados[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));

    // Verificação
    mm::Image esperado = mm::clohole(f);
    bool igual = true;
    for (int y = 0; y < f.h && igual; y++)
        for (int x = 0; x < f.w; x++)
            if (img_clohole.at(y,x) != esperado.at(y,x)) { igual = false; break; }
    std::cout << "✅ Validação clohole: " << (igual ? "true" : "false") << "\n";

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

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

    mm::show(todas, MM_OUT, nomes, 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)")
Figura 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 <vector>
#include <string>

int main() {
    mm::Image f(10, 10);
    int data[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) = 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> imgs = {f, marcador_eo};
    imgs.insert(imgs.end(), iteracoes.begin(), iteracoes.end());
    imgs.push_back(img_edgeoff);

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

    mm::show(imgs, MM_OUT, 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)")
Figura 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 Limpieza Binaria con CLAHE

Con base en el análisis anterior —en el cual el CLAHE produjo el mayor valor de la varianza interclases (\(\sigma_B^2 \approx 2{,}47 \times 10^3\)), ver Figura 4.2, y el operador mm::clohole mostró ser eficaz en el relleno de las cavidades internas—, el pipeline final de segmentación, ilustrado en la Figura 4.12, se estructura mediante el siguiente flujo computacional:

\[ \text{gray} \xrightarrow{\text{CLAHE}} \xrightarrow{\text{Otsu}} \xrightarrow{\text{open}} \xrightarrow{\text{clohole}} \xrightarrow{\text{open}} \xrightarrow{\text{edgeoff}} \text{segmentación} \]

Tras la etapa de mm::clohole, se aplica una segunda apertura morfológica con un elemento estructurante mayor (mm::sedisk(33), disco de diámetro 33). Esta operación elimina pequeñas regiones residuales y artefactos que podrían haber permanecido después de la segmentación. En particular, el relleno geodésico puede transformar pequeñas cavidades aisladas en componentes conectados al objeto, haciendo conveniente una etapa adicional de filtrado basada en tamaño. El diámetro se eligió de modo que las monedas sigan siendo capaces de contener el elemento estructurante, mientras que los componentes significativamente más pequeños sean eliminados.

En esta imagen, ninguna moneda está conectada al borde de la matriz. En consecuencia, la aplicación de mm::edgeoff no altera el resultado obtenido tras la segunda apertura. Aun así, esta etapa se mantiene en el pipeline por robustez, pues en otras imágenes pueden existir objetos parcialmente visibles o conectados a los bordes, que deben eliminarse antes de la etapa de análisis.

Tip¿Por qué la apertura después del clohole?

El operador mm::clohole rellena todas las cavidades cerradas presentes en los objetos segmentados. En algunas situaciones, pequeñas regiones no deseadas pueden permanecer después de esta etapa o volverse conectadas a los objetos principales. La apertura morfológica subsiguiente elimina componentes más pequeños que el elemento estructurante, preservando las monedas debido a su tamaño significativamente mayor.

%%writefile tmp/fig_04_pipeline_clahe.cpp
#define MM_OUT "tmp/fig_04_pipeline_clahe.png"
// Compile: g++ -std=c++17 -O2 -o program program.cpp -I/usr/include/opencv4 $(pkg-config --cflags --libs opencv4) -DMM_USE_OPENCV -DMM_OUT="\"output.png\""
#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]

    mm::Image img_clahe0 = mm::clahe(img_coins_gray, 2.0, 8);
    mm::Image img_bin = mm::threshold(img_clahe0);
    mm::Image img_open = mm::open(img_bin, mm::sedisk(9));
    mm::Image img_hole = mm::clohole(img_open);
    mm::Image img_limpo = mm::open(img_hole, mm::sedisk(33));
    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)")
Figura 4.12: Pipeline completo de segmentação com CLAHE: Otsu → abertura (r=9) → clohole → abertura (r=33) → edgeoff.

4.3.7 Morfología en Tonos de Gris

Los operadores morfológicos se extienden naturalmente a imágenes en tonos de gris. En esta formulación, la erosión y la dilatación pasan a actuar directamente sobre los niveles de intensidad de la imagen. Para elementos estructurantes planos (\(b \equiv 0\)), la erosión corresponde al mínimo local y la dilatación al máximo local dentro de la vecindad definida por el elemento estructurante.

La interpretación intuitiva es sencilla: la erosión oscurece regiones al reemplazar cada píxel por el menor valor presente en su vecindad, mientras que la dilatación aclara regiones al utilizar el mayor valor disponible. La combinación de estos operadores permite construir transformaciones capaces de resaltar bordes, eliminar tendencias de iluminación y destacar estructuras locales.

Tres operadores derivados son especialmente útiles:

Gradiente morfológico — resalta bordes como la diferencia entre dilatación y erosión:

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

Top-hat — resalta estructuras brillantes más pequeñas que el elemento estructurante (diferencia entre la imagen original y su apertura):

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

Black-hat — resalta estructuras oscuras más pequeñas que el elemento estructurante (diferencia entre el cierre y la imagen original):

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

El Top-hat extrae detalles brillantes que no sobreviven a la apertura, mientras que el Black-hat evidencia detalles oscuros eliminados por el cierre. Por su parte, el gradiente morfológico resalta transiciones abruptas de intensidad, produciendo una representación similar a la de un detector de bordes.

Para comprender el mecanismo de estos operadores a nivel local, la Figura 4.13 presenta un simulador interactivo de morfología en tonos de gris. El simulador permite editar libremente el elemento estructurante, visualizar su desplazamiento sobre la imagen y acompañar simultáneamente el perfil unidimensional de las intensidades. De esta forma, se hace posible observar directamente cómo la erosión selecciona mínimos locales, cómo la dilatación selecciona máximos locales y cómo el gradiente morfológico emerge de la diferencia entre estos dos operadores.

El botón ubicado en la esquina superior derecha permite alternar entre la visualización original en tonos de gris y una representación pseudocoloreada (colormap) solo en los tres tipos de gradientes. La versión coloreada facilita la percepción visual de las variaciones de intensidad, haciendo más evidente la acción de los operadores morfológicos sobre máximos, mínimos y transiciones locales de la imagen.

Los operadores presentados están disponibles en morph.py mediante las funciones mm::gradm, mm::tophat y mm::blackhat.

🎛️ Simulador Avanzado de Morfología Matemática
🖱️ Mueva el mouse para actualizar el perfil 1D de la línea correspondiente
X (col)
—
Y (fil)
—
f(x,y)
—
valor
—
Elemento B (Haga clic para Editar)
Apertura (f∘B): dil(ero(f))
Cierre (f•B): ero(dil(f))
Gradiente: dil(f) − ero(f)
Top-hat: f − (f∘B)
Black-hat: (f•B) − f
Perfil 1D de la línea: Ninguno (pase el mouse sobre la imagen)
Figura 4.13: Simulador interactivo avanzado de morfología con elemento estructurante editable y perfil 1D.

La Figura 4.14 ilustra los efectos de estos operadores sobre la imagen de las monedas y sus respectivos histogramas. Observe que la erosión desplaza la distribución hacia intensidades más bajas, mientras que la dilatación la desplaza hacia intensidades más altas. El gradiente concentra valores en las regiones de contorno, y los operadores Top-hat y Black-hat producen histogramas fuertemente concentrados en bajos niveles de intensidad, ya que solo se resaltan pequeñas estructuras locales.

%%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]

    // img_coins_gray is already available as mm::Image

    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)")
Figura 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.

Los operadores morfológicos presentados anteriormente se utilizarán ahora como herramientas de refinamiento y generación de marcadores para métodos de segmentación más avanzados, que se presentan a continuación.

4.4 Segmentación de Imágenes: Fundamentación y Taxonomía

La segmentación de imágenes consiste en dividir la imagen en regiones asociadas a objetos o estructuras de interés. En PDI, representa la transición entre el procesamiento de bajo nivel —como filtrado y realce— y etapas de análisis más avanzadas, como extracción de características, reconocimiento e interpretación de la escena.

Formalmente, el objetivo de la segmentación consiste en descomponer el dominio espacial completo de una imagen, denotado por \(\Omega\), en una partición de subconjuntos \(\{R_1, R_2, \ldots, R_n\}\) que satisfaga simultáneamente los criterios de completitud y disyunción:

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

Además de las propiedades de completitud y disyunción expresadas en la Ecuación 4.15, cada subregión \(R_i\) debe constituir un dominio homogéneo según un predicado de similitud definido sobre propiedades locales —intensidad, color o textura— y, simultáneamente, ser distinta de las regiones adyacentes.

Las técnicas de segmentación pueden organizarse en diferentes familias. En este capítulo se enfatizarán los enfoques resumidos en la Tabla 4.4, fundamentados principalmente en criterios de intensidad, conectividad y proximidad espacial.

Tabla 4.4: Taxonomía simplificada de los principales enfoques de segmentación y refinamiento estudiados en este capítulo.
Enfoque Criterio de Segmentación Operadores de Referencia
Umbralización Particionamiento del espacio de intensidades Criterio de Otsu, umbralización global y local
Morfología Matemática Relaciones espaciales definidas por elementos/funciones estructurantes Erosión, dilatación, apertura, cierre y reconstrucción
Basada en Regiones Homogeneidad local y conectividad espacial Etiquetado de componentes conexos, Transformada de Distancia y Watershed

Hasta este punto, el desarrollo práctico se ha centrado en la umbralización, mediante la combinación entre ecualización adaptativa CLAHE y el método global de Otsu. Esta etapa se complementó con operadores de reconstrucción morfológica basados en dilataciones geodésicas, implementados por las funciones mm::infrec, mm::clohole y mm::edgeoff, produciendo una máscara binaria limpia y adecuada para el análisis.

Sin embargo, en escenarios donde objetos distintos aparecen conectados en la máscara binaria —ya sea por contacto físico, superposición parcial o por puentes estrechos de píxeles producidos por la segmentación—, la umbralización deja de ser suficiente para individualizar cada objeto. En estos casos, múltiples objetos pasan a componer un único componente conexo, dificultando etapas posteriores de medición e interpretación.

Para superar esta limitación, las próximas secciones introducen tres herramientas complementarias: el Etiquetado de Componentes Conexos, la Transformada de Distancia y el algoritmo de segmentación por Watershed basado en marcadores. En conjunto, estas técnicas permiten separar objetos adyacentes, identificar regiones individualmente y extraer descriptores geométricos consistentes para el análisis cuantitativo.

4.4.1 Etiquetado

El etiquetado de componentes conexas (connected component labeling) es el operador que asigna un identificador entero único a cada conjunto de píxeles pertenecientes a la misma componente conexa en una imagen binaria.

NotaDefinición formal

Dada una imagen binaria \(f\) y una relación de conectividad definida por un elemento estructurante \(B\) (típicamente conectividad-4 o conectividad-8), el algoritmo de etiquetado de la Figura 4.15 produce una imagen \(g\) en la cual todos los píxeles pertenecientes a la misma componente conexa reciben la misma etiqueta entera positiva, mientras que los píxeles pertenecientes a componentes distintas reciben etiquetas diferentes.

La conectividad define qué píxeles se consideran vecinos directos de un píxel \((x,y)\). Las definiciones más utilizadas son:

  • Conectividad-4: considera solo los cuatro vecinos ortogonales (norte, sur, este y oeste).
  • Conectividad-8: considera los cuatro vecinos ortogonales y los cuatro diagonales, totalizando ocho vecinos.

La elección de la conectividad influye directamente en la formación de las componentes conexas y, consecuentemente, en el resultado del etiquetado, como se ilustra en la Figura 4.17. Un ejemplo adicional puede explorarse de forma interactiva en el simulador presentado en la Figura 4.16.

La implementación en morph.py proporciona dos versiones de este operador. La función mm::label0 reproduce explícitamente el algoritmo de flood-fill utilizando una pila y permite controlar la conectividad mediante el elemento estructurante adoptado. En cambio, mm::label delega la operación a la implementación optimizada de OpenCV (mm::label0). En ambos casos, el resultado es una imagen etiquetada en la cual cada componente conexa recibe un identificador entero distinto.

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
Figura 4.15: Algoritmo de etiquetado por flood-fill con pila.

El auxiliar _viz itera sobre la ventana estructurante b centrada en \((i,j)\), generando únicamente los vecinos válidos dentro de los límites de la imagen — la conectividad deseada está enteramente determinada por la forma de b pasada al algoritmo.

Ejemplo didáctico — efecto de la conectividad:

🪙 Simulador: Etiquetado de Componentes Conexas flood-fill con pila
Píxeles Activos
0
Componentes
0
Conectividad
4
Paso Raster
–
🖱️ Haga clic para activar/desactivar píxeles · Arrastre para pintar
Conectividad
4 vecinos ortogonales: N, S, E, O
Visualización
Ejemplos Predefinidos
Figura 4.16: Simulador interactivo de etiquetado de componentes conexas (connected component labeling): visualización de la expansión flood-fill, conectividad-4 y conectividad-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>

int main() {
    mm::Image f(10, 10);
    unsigned char vals[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) = vals[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);
    int n4 = 0, n8 = 0;
    for (int i = 0; i < (int)lbl4.data.size(); i++)
        n4 = std::max(n4, (int)lbl4.data[i]);
    for (int i = 0; i < (int)lbl8.data.size(); i++)
        n8 = std::max(n8, (int)lbl8.data[i]);
    std::cout << "Componentes C4: " << n4 << "  |  C8: " << n8 << "\n";

    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) = (int)(lbl.at(y, x) * 255 / mx);
            }
        }
        return out;
    };

    mm::show(
        std::vector<mm::Image>{f, norm_label(lbl4, std::max(n4, 1)), norm_label(lbl8, std::max(n8, 1))},
        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)")
Figura 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 Transformada de Distancia

La Transformada de Distancia (TD) es un operador que, aplicado a una imagen binaria \(f\), produce una imagen en niveles de gris \(D\) en la cual cada píxel perteneciente al objeto (\(f(x,y)\neq 0\)) recibe como valor la distancia geométrica hasta el píxel de fondo (\(f(x',y')=0\)) más cercano:

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

donde \(d(\cdot,\cdot)\) es una métrica de distancia — típicamente la distancia Euclidiana (\(L_2\)). El resultado es una representación topográfica de los objetos: los píxeles ubicados en el interior asumen valores elevados, mientras que los píxeles próximos a los bordes presentan bajos valores de distancia. Los máximos locales de \(D\) corresponden a los puntos más alejados del borde del objeto, frecuentemente próximos a sus centros geométricos o centros de máxima inscripción — propiedad particularmente útil para la generación automática de marcadores en el algoritmo watershed.

NotaDefinición formal mediante erosiones

La TD admite además una interpretación morfológica iterativa, conforme al algoritmo de la Figura 4.18. Considérese una función estructurante \(b\) cuyo valor central es nulo y cuyos vecinos poseen costos negativos asociados al desplazamiento. Al aplicar erosiones sucesivas con esta función estructurante particular, los valores de los píxeles de los objetos (que deben asumir la distancia máxima posible de la imagen) se reducen progresivamente según los costos definidos por \(b\). El valor acumulado de esta propagación pasa entonces a representar la distancia al fondo según la métrica inducida por la función estructurante.

Esta interpretación se implementa en mm::dist1(), que acumula erosiones sucesivas utilizando la operación mm::ero1(). Por su parte, mm::dist() delega el cálculo de la distancia Euclidiana al operador optimizado de OpenCV mm::dist(f), donde f es la imagen binaria de entrada, la distancia L2 especifica la métrica Euclidiana (\(L_2\)) y 5 indica el uso de una máscara 5×5 para aproximar la distancia con elevada precisión.

La función dist1 produce una transformada de distancia discreta cuya métrica está determinada por la geometría y los pesos de la función estructurante utilizada. Por ejemplo, utilizando una función estructurante en cruz con costo unitario para los cuatro vecinos ortogonales, se obtiene la distancia de Manhattan (\(L_1\)). Otras elecciones de vecindad y pesos inducen métricas diferentes. En cambio, mm::dist() calcula una aproximación eficiente de la distancia Euclidiana (\(L_2\)).

Por exigir sucesivas erosiones sobre toda la imagen, el enfoque dist1 posee un costo computacional significativamente mayor que mm::dist(), siendo empleado en este libro principalmente con fines didácticos y para evidenciar la relación entre morfología matemática y transformadas de distancia.

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.
Figura 4.18: Algoritmo de la Transformada de Distancia.

La Figura 4.19 presenta un simulador interactivo de la TD: es posible posicionar el cursor sobre diferentes píxeles del objeto y observar, en tiempo real, el valor de la distancia asociado a esa posición, es decir, la distancia hasta el píxel de fondo más cercano. La Figura 4.20 presenta un ejemplo práctico de esta ejecución en entorno Python.

🗺️ Simulador: Transformada de Distancia (TD) Fronteras en +∞ (144)
Píxeles Activos
0
Dist. Máx.
0
Métrica Actual
L∞ (Chebyshev)
Iteración (k)
–
🖱️ Clic para activar/desactivar píxeles · Arrastra para pintar
Elemento Estructurante (b)
Clic para cambiar los pesos:
Visualización
Ejemplos Predefinidos
Figura 4.19: Simulador interactivo de la Transformada de Distancia (TD) iterativa mediante erosión en tonos de gris. Los píxeles fuera de la imagen asumen el valor máximo (144), propagando los costos a partir del fondo interno.
%%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 <algorithm>
#include <vector>
#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);

    std::cout << "Máx. dist1 (erosões) : " << (int)*std::max_element(d_iter.data.begin(), d_iter.data.end()) << " px\n";
    std::cout << "Máx. dist  (L2)      : " << (int)*std::max_element(d_l2.data.begin(), d_l2.data.end()) << " px\n";

    mm::show(
        std::vector<mm::Image>{f, d_iter, d_l2},
        MM_OUT,
        std::vector<std::string>{"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)")
Figura 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).

La anotación de los valores numéricos directamente sobre los píxeles permite verificar cómo dist1 propaga las distancias según la métrica inducida por la función estructural utilizada. En el caso del elemento cruz con costo unitario, los valores obtenidos corresponden a la distancia de Manhattan (\(L_1\)). Aunque dist1 y mm::dist producen valores numéricos distintos por adoptar métricas diferentes, ambas transformadas preservan la estructura topográfica de los objetos, haciendo que sus máximos ocurran en regiones centrales similares. Esta propiedad justifica el uso de mm::dist en aplicaciones prácticas, debido a su elevada eficiencia computacional.

4.4.3 Transformada de Distancia Euclidiana en cuatro pasos

El simulador de la Figura 4.21 implementa el algoritmo de la TDE de Lotufo (2001) en dos etapas. En la primera etapa, la función edt1 realiza una transformación unidimensional vertical de forma secuencial (in-place), recorriendo cada columna en raster (↓) y anti-raster (↑) para calcular las distancias en la dirección vertical (dos pasos: Sur y Norte). En la segunda etapa, la función edt2 utiliza ese resultado como entrada y realiza una propagación horizontal por colas: para cada fila de la matriz, dos colas de prioridad, Eq y Wq, se inicializan recorriendo los índices de columna en sentidos opuestos (Eq de W-1 hasta 1, Wq de 2 hasta W), de modo que cada píxel actualizado encola inmediatamente a sus vecinos para reprocesamiento dentro de la misma ronda (dos pasos más: Este y Oeste). Este mecanismo de cola permite aplicar erosiones sucesivas con pesos impares crecientes (b = 1, 3, 5, ..., incrementado en cada iteración del bucle externo) sin que la propagación quede atrapada en valores desactualizados, ya que cada ronda resuelve por completo la cadena de dependencias horizontales antes del siguiente incremento de b. La convergencia de esta propagación produce la Transformada de Distancia Euclidiana en toda la matriz, combinando la información vertical obtenida en edt1 con la propagación horizontal en cola realizada en edt2.

📐 EDT² 2D Corrigido · Matriz 4×4 Convergencia Exacta
Paso 1: edt1 Vertical In-place · Paso 2: edt2 Horizontal In-place
Usa la misma estructura raster/anti-raster descrita en el artículo para propagación de distancias exactas (ejemplo del artículo, pág. 103).
Paso Actual
–
Fase
–
b Actual
–
Figura 4.21: Simulador interactivo 2D (4x4) con sincronización estricta de colas de propagación horizontal (b) para obtener la convergencia exacta descrita en el artículo.

4.4.3.1 Transformada de Distancia Geodésica

La transformada de distancia geodésica asocia a cada píxel la menor distancia hasta un marcador, bajo la restricción impuesta por una máscara. De esta forma, la propagación ocurre exclusivamente por los píxeles permitidos, preservando la conectividad del dominio.

La Figura 4.22 ilustra este proceso en un laberinto: (a) la máscara g; (b) la distancia geodésica D1 calculada a partir de la entrada; (c) la distancia D2 calculada a partir de la salida; y (d) el camino mínimo obtenido a partir de estas dos transformadas.

El camino óptimo se determina mediante la suma de las distancias (D1 + D2). Los píxeles pertenecientes a la trayectoria mínima son aquellos para los cuales esta suma asume su menor valor, definiendo una conexión entre entrada y salida con longitud geodésica mínima.

Este principio permite resolver laberintos sin la necesidad de explorar explícitamente todas las posibilidades de recorrido. La solución emerge directamente de la propagación de distancias en un dominio restringido. Este enfoque es particularmente relevante en laberintos de elevada complejidad, como los construidos a partir de estructuras cuasicristalinas y ciclos hamiltonianos descritos por Singh (2024). En Zampirolli (2025), este mismo formalismo se emplea para resolver un laberinto complejo; a continuación, el método se ilustra en una versión simplificada del problema.

%%writefile tmp/fig_04_gdist_menor_caminho.cpp
#define MM_OUT "tmp/fig_04_gdist_menor_caminho.png"
// Compile with: g++ -std=c++17 -I. program.cpp -o program $(pkg-config --cflags --libs opencv4) -DMM_USE_OPENCV
//| 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>
#include <algorithm>

int main() {
    // 1 = corredor, 0 = parede
    mm::Image g(10, 10);
    unsigned char g_data[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) = g_data[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 = mm::addm(D1, D2);

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

    // pixels pertencentes a um caminho ótimo
    mm::Image caminho(S.h, S.w);
    for (int i = 0; i < S.h * S.w; 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)")
Figura 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 Segmentación por Watershed

El algoritmo Watershed interpreta una imagen en niveles de gris como una superficie topográfica, en la cual valores elevados corresponden a montañas y valores bajos corresponden a valles o cuencas de drenaje (catchment basins). En el contexto de la segmentación basada en marcadores, los máximos de la Transformada de Distancia se utilizan frecuentemente para identificar regiones internas de los objetos, proporcionando semillas confiables para el proceso de inundación.

La segmentación se realiza entonces mediante una simulación conceptual de inundación progresiva a partir de estos marcadores. A medida que las cuencas asociadas a diferentes semillas se expanden, las regiones vecinas eventualmente entran en contacto. En ese instante se construyen barreras virtuales, denominadas watershed lines, que pasan a delimitar los objetos de la escena. Este mecanismo permite separar objetos adyacentes o parcialmente superpuestos, incluso cuando forman una única componente conexa tras la umbralización.

La implementación didáctica presentada en este capítulo explora inicialmente el concepto de crecimiento de regiones (region growing) confinado por una máscara binaria, según se detalla en el algoritmo interactivo de la Figura 4.23.

NotaVersión didáctica versus implementación clásica

La función mm::watershed0 no implementa el algoritmo watershed clásico. Su objetivo es ilustrar, de forma simplificada, la propagación de marcadores por crecimiento de regiones (region growing), permitiendo visualizar cómo diferentes semillas compiten por la ocupación del espacio disponible. El crecimiento está delimitado por una máscara binaria de soporte y monitoreado por un control de estancamiento, generando un resultado similar a una partición de Voronoi restringida a la geometría de los objetos de entrada.

En cambio, la función mm::watershed utiliza la implementación optimizada de OpenCV (mm::watershed), que realiza la inundación sobre una superficie topográfica definida por la imagen de entrada. En este caso, la propagación de los marcadores está influenciada por los valores de los píxeles, haciendo que las líneas de separación se formen naturalmente sobre las crestas del relieve.

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
Figura 4.23: Algoritmo Didáctico de Watershed por Crecimiento de Regiones Limitado por Máscara.

A Figura 4.24 presenta un simulador iterativo que ilustra la propagación de los marcadores por la región de interés. Cada marcador actúa como una fuente de inundación que expande su área de influencia hasta encontrar regiones provenientes de otras semillas. En el algoritmo de OpenCV, los píxeles pertenecientes a las líneas divisorias se identifican por el valor -1, representando las fronteras entre cuencas adyacentes.

💧 Simulador: Segmentación por Watershed Propagación con Elemento Estructurante (b)
Área (Máscara)
0
Marcadores
0
Relleno
0%
Iteración (k)
–
🖱️ Arrastre para dibujar/borrar la máscara o las semillas
Herramientas
Elemento Estructurante (b)
Visualización
Ejemplos Iniciales
Figura 4.24: Simulador interactivo del Algoritmo Watershed por propagación morfológica. Dibuja la máscara, coloca los marcadores y ajusta el Elemento Estructurante para observar la inundación. Cuando las cuencas se encuentran simultáneamente, el empate se resuelve asumiendo una de las regiones de manera aleatoria.

Pipeline Morfológico del Watershed

El watershed basado en marcadores normalmente integra un flujo más amplio de segmentación. En imágenes reales, etapas de preprocesamiento son frecuentemente necesarias para mejorar el contraste, reducir ruidos y generar marcadores confiables. Este flujo completo está resumido en la Tabla 4.5.

Tabla 4.5: Pipeline completo del watershed basado en marcadores para imágenes reales.
Etapa Operación Finalidad
1 CLAHE + Suavizado Realce de contraste y reducción de ruido
2 Umbralización Separación inicial entre objeto y fondo
3 Apertura/Cierre Eliminación de ruidos y pequeñas imperfecciones
4 Dilatación de la Máscara Identificación del Fondo Seguro (Sure Background)
5 Transformada de Distancia + Umbral Identificación del Objeto Seguro (Sure Foreground)
6 Región Incierta Diferencia entre Fondo Seguro y Objeto Seguro
7 mm::watershed Propagación de los marcadores por la región incierta

Para enfatizar exclusivamente los conceptos de Transformada de Distancia, marcadores e inundación topográfica, el ejemplo de la Figura 4.25 utiliza una imagen binaria sintética y adopta un flujo simplificado, resumido en la Tabla 4.6.

Tabla 4.6: Pipeline simplificado utilizado en el ejemplo didáctico de la Figura 4.25.
Etapa Operación Finalidad
1 Transformada de Distancia Construcción de la superficie topográfica
2 Umbral de la TD Extracción de los marcadores (Sure Foreground)
3 Dilatación Determinación del Fondo Seguro (Sure Background)
4 Región Incierta Diferencia entre fondo y marcadores
5 mm::watershed Propagación de los marcadores y generación de las fronteras
%%writefile tmp/fig_04_watershed_didatico.cpp
#define MM_OUT "tmp/fig_04_watershed_didatico.png"
// g++ -std=c++17 snippet.cpp -o snippet $(pkg-config --cflags --libs opencv4) -DMM_USE_OPENCV
#include "morph.hpp"
#include <vector>
#include <string>
#include <algorithm>
#include <filesystem>

int main() {
    //| label: fig-04-watershed-didatico
    //| fig-cap: "*Pipeline* *watershed* delimitado por máscara em imagem binária 20×20."
    //| echo: true
    //| output: true

    // 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)
    mm::Image m(20, 20);
    unsigned char max_dist = 0;
    for (int y = 0; y < dist.h; y++) {
        for (int x = 0; x < dist.w; x++) {
            if (dist.at(y, x) > max_dist) {
                max_dist = dist.at(y, x);
            }
        }
    }
    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::watershed(m, f_sint, "region");
    mm::Image w_line = mm::watershed(m, f_sint, "line");

    //w_reg  = mm.watershedB(m, mask=f_sint, op='region')
    //w_line = mm.watershedB(m, mask=f_sint, op='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)")
Figura 4.25: Pipeline watershed delimitado por máscara em imagem binária 20×20.

Aplicación en monedas superpuestas:

%%writefile tmp/fig_04_watershed_moedas.cpp
#define MM_OUT "tmp/fig_04_watershed_moedas.png"
#include "morph.hpp"
#include <opencv2/opencv.hpp>
#include <vector>
#include <string>
#include <iostream>
#include <set>
#include <algorithm>
#include <numeric>
#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]

    // Variables img_coins_gray (mm::Image) and img_final (mm::Image) are provided

    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);

    // Normalize dist to [0, 255] for visualization
    mm::Image dist_vis(dist.h, dist.w);
    int max_val = 0;
    for (int i = 0; i < (int)dist.data.size(); i++) {
        max_val = std::max(max_val, (int)dist.data[i]);
    }
    if (max_val > 0) {
        for (int i = 0; i < (int)dist.data.size(); i++) {
            dist_vis.data[i] = (unsigned char)(255 * (double)dist.data[i] / max_val);
        }
    } else {
        dist_vis = dist;
    }

    // 4. Picos seguros (Marcadores das moedas)
    mm::Image picos(dist.h, dist.w);
    for (int i = 0; i < (int)dist.data.size(); i++) {
        if ((double)dist.data[i] > 0.5 * max_val) {
            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 < (int)ws_region.data.size(); i++) {
        int val = ws_region.data[i];
        if (val > 0) {
            unique_labels.insert(val);
        }
    }
    std::vector<int> labels(unique_labels.begin(), unique_labels.end());
    std::cout << "Objetos detectados: " << labels.size() << "\n";

    // 7. Anotação Final
    // Convert img_base (1-channel) to BGR for annotation
    cv::Mat img_base_mat(img_base.h, img_base.w, CV_8UC1, img_base.data.data());
    cv::Mat img_annotated_mat;
    cv::cvtColor(img_base_mat, img_annotated_mat, cv::COLOR_GRAY2BGR);

    for (size_t idx = 0; idx < labels.size(); idx++) {
        int label_id = labels[idx];

        // Build mask for this label
        cv::Mat mask_reg(img_base.h, img_base.w, CV_8UC1, cv::Scalar(0));
        int mask_sum = 0;
        for (int y = 0; y < img_base.h; y++) {
            for (int x = 0; x < img_base.w; x++) {
                if (ws_region.at(y, x) == label_id) {
                    mask_reg.at<unsigned char>(y, x) = 255;
                    mask_sum++;
                }
            }
        }
        if (mask_sum < 500) continue;

        // Compute centroid
        int sum_x = 0, sum_y = 0;
        for (int y = 0; y < img_base.h; y++) {
            for (int x = 0; x < img_base.w; x++) {
                if (ws_region.at(y, x) == label_id) {
                    sum_x += x;
                    sum_y += y;
                }
            }
        }
        int cx = sum_x / mask_sum;
        int cy = sum_y / mask_sum;

        cv::putText(img_annotated_mat, 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);
    }

    // Copy back to mm::Image
    mm::Image img_annotated(img_annotated_mat.rows, img_annotated_mat.cols, img_annotated_mat.channels());
    std::memcpy(img_annotated.data.data(), img_annotated_mat.data, img_annotated.data.size());

    // Desenha as linhas de separação em vermelho
    // Build 11x11 ones kernel as SE for dilation
    mm::SE kernel_11 = mm::SE::box(11);
    mm::Image mask_ann = mm::dil(ws_line, kernel_11);

    // Apply red color where mask has values > 0
    for (int y = 0; y < img_base.h; y++) {
        for (int x = 0; x < img_base.w; x++) {
            if (mask_ann.at(y, x) > 0) {
                img_annotated.at(y, x, 0) = 255;
                img_annotated.at(y, x, 1) = 0;
                img_annotated.at(y, x, 2) = 0;
            }
        }
    }

    // Exibição
    mm::show(
        std::vector<mm::Image>{img_base, img_sobrepostas, dist_vis, picos, ws_region, img_annotated},
        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)")
Figura 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 Extracción de Componentes y Descriptores de Forma

Tras la segmentación y el refinamiento morfológico, el siguiente paso consiste en identificar individualmente cada objeto presente en la imagen y extraer sus propiedades geométricas. Esta etapa es fundamental para tareas de medición, clasificación y reconocimiento de patrones.

Un componente conexo es un conjunto maximal de píxeles pertenecientes al objeto que permanecen mutuamente conectados según una relación de conectividad previamente definida (conectividad 4 u 8). Tras la rotulación, cada componente recibe un identificador único, permitiendo que sus características sean analizadas individualmente.

OpenCV ofrece dos enfoques complementarios para este análisis, resumidos en la Tabla 4.7.

Tabla 4.7: Comparación entre los enfoques basados en componentes conexos y en contornos.
connectedComponentsWithStats findContours
Devuelve etiqueta por píxel y estadísticas por componente secuencia de puntos que describe el borde
Descriptores directos área, bounding box y centroide perímetro, forma y jerarquía
Objetos en contacto tiende a fusionar regiones conectadas tiende a producir un único contorno externo
Uso típico conteo, filtrado y rotulación análisis geométrico y descriptores de forma

4.5.1 Etiquetado y Estadísticas de Componentes

mm::label0 asigna una etiqueta a cada componente conexo. A partir de la imagen etiquetada se obtienen, mediante un barrido, el área (conteo de píxeles) y la caja delimitadora de cada objeto (Figura 4.27). El connectedComponentsWithStats de OpenCV, que devuelve estas estadísticas ya calculadas, y las anotaciones coloreadas sobre la imagen quedan en el rastro de 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 "morph.hpp"
#include <opencv2/opencv.hpp>
#include <iostream>
#include <vector>
#include <iomanip>
#include <cstring>
#include <numeric>
#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]

    // img_coins_gray e img_final são fornecidos automaticamente

    // 1. Rotulagem e estatísticas
    cv::Mat mask_final(img_final.h, img_final.w, CV_8UC1, img_final.data.data());
    cv::Mat labels_mat, stats_mat, centroids_mat;
    int n = cv::connectedComponentsWithStats(mask_final, labels_mat, stats_mat, centroids_mat, 8, CV_32S);

    // Converter labels para mm::Image
    mm::Image labels_img(labels_mat.rows, labels_mat.cols);
    std::memcpy(labels_img.data.data(), labels_mat.data, labels_img.data.size());

    // 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.
    const std::vector<std::array<unsigned char, 3>> 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_final.h, img_final.w, 3);
    mm::Image img_annotated = img_colored;

    for (int y = 0; y < img_final.h; y++) {
        for (int x = 0; x < img_final.w; x++) {
            int label = labels_img.at(y, x);
            const auto& cor = PALETA[label % PALETA.size()];
            img_colored.at(y, x, 0) = cor[0];
            img_colored.at(y, x, 1) = cor[1];
            img_colored.at(y, x, 2) = cor[2];
        }
    }
    img_annotated = img_colored;

    // 3. Tabela de descritores
    std::cout << "Componentes detectados (excluindo fundo): " << (n - 1) << "\n";
    std::cout << 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_mat.at<int>(i, cv::CC_STAT_AREA);
        int cx = static_cast<int>(centroids_mat.at<double>(i, 0));
        int cy = static_cast<int>(centroids_mat.at<double>(i, 1));
        int w = stats_mat.at<int>(i, cv::CC_STAT_WIDTH);
        int h = stats_mat.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";

        // Anotar no image
        cv::Mat ann_mat(img_annotated.h, img_annotated.w, CV_8UC3, img_annotated.data.data());
        std::string texto = std::to_string(i) + ": " + std::to_string(area);
        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(ann_mat, texto, cv::Point(cx - 150, cy + 18),
                        cv::FONT_HERSHEY_SIMPLEX, 2.0, cor, esp, cv::LINE_AA);
        }
        std::memcpy(img_annotated.data.data(), ann_mat.data, img_annotated.data.size());
    }

    // Conversões cv::Mat → mm::Image para exibição
    mm::Image img_colored_mm(img_colored.h, img_colored.w, 3);
    std::memcpy(img_colored_mm.data.data(), img_colored.data.data(), img_colored.data.size());

    mm::show(std::vector<mm::Image>{img_coins_gray, img_final, img_colored_mm, 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)")
Figura 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 Descriptores de Forma

Los descriptores basados en contorno vectorial — perímetro (arcLength), área del polígono (contourArea), circularidad, momentos y aproximación poligonal (approxPolyDP) — dependen del rastreo de frontera (findContours), ausente en morph.hpp. Solo permanecen en la ruta de Python. En la ruta de C++, el gradiente morfológico (mm::gradm) resalta el contorno de cada objeto (Figura 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 -DMM_USE_OPENCV `pkg-config --cflags --libs opencv4`
#include "morph.hpp"
#include <opencv2/opencv.hpp>
#include <iostream>
#include <vector>
#include <cmath>
#include <iomanip>

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

    // Bridge mm::Image to cv::Mat for OpenCV operations
    cv::Mat img_final_mat(img_final.h, img_final.w, 
                          img_final.channels == 1 ? CV_8UC1 : CV_8UC3,
                          img_final.data.data());

    std::vector<std::vector<cv::Point>> contornos;
    std::vector<cv::Vec4i> hierarquia;
    cv::findContours(img_final_mat, contornos, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);

    // Bridge mm::Image to cv::Mat for OpenCV operations
    cv::Mat img_coins_gray_mat(img_coins_gray.h, img_coins_gray.w, 
                               img_coins_gray.channels == 1 ? CV_8UC1 : CV_8UC3,
                               img_coins_gray.data.data());

    cv::Mat img_contornos_mat;
    cv::cvtColor(img_coins_gray_mat, img_contornos_mat, cv::COLOR_GRAY2BGR);
    cv::Mat img_circulares_mat = img_contornos_mat.clone();

    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";

    int id = 1;
    for (const auto& cnt : contornos) {
        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";

        cv::drawContours(img_contornos_mat, std::vector<std::vector<cv::Point>>{cnt}, -1, cv::Scalar(0, 255, 0), 3);
        cv::drawContours(img_circulares_mat, std::vector<std::vector<cv::Point>>{cnt}, -1, cv::Scalar(0, 255, 0), 3);

        std::vector<std::pair<cv::Scalar, int>> cor_esp = {
            {cv::Scalar(0, 0, 0), 8}, {cv::Scalar(255, 0, 0), 3}
        };
        for (const auto& ce : cor_esp) {
            cv::putText(img_circulares_mat, std::to_string(circ).substr(0, std::to_string(circ).find('.') + 3),
                        cv::Point(cx - 80, cy + 15),
                        cv::FONT_HERSHEY_SIMPLEX, 3.6, ce.first, ce.second, cv::LINE_AA);
        }
        id++;
    }

    // Build result images and copy back to mm::Image
    mm::Image out_contornos(img_contornos_mat.rows, img_contornos_mat.cols, img_contornos_mat.channels());
    std::memcpy(out_contornos.data.data(), img_contornos_mat.data, out_contornos.data.size());

    mm::Image out_circulares(img_circulares_mat.rows, img_circulares_mat.cols, img_circulares_mat.channels());
    std::memcpy(out_circulares.data.data(), img_circulares_mat.data, out_circulares.data.size());

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

    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.png"), figsize=(18, 6))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_04_contornos.png (ver a versao Python)")
Figura 4.28: Contornos extraídos com findContours. Cada moeda é anotada com sua circularidade — valores próximos de 1 confirmam forma circular.

4.5.3 Conexión con la Detección de Objetos Moderna

Los descriptores extraídos en las secciones anteriores — especialmente bounding boxes, centroides, áreas y medidas de forma — establecen un puente natural entre la segmentación morfológica clásica y los sistemas modernos de detección de objetos. Aunque las técnicas estudiadas en este capítulo utilizan operaciones sobre píxeles y regiones segmentadas, muchas de las representaciones producidas son directamente compatibles con los formatos empleados en modelos contemporáneos de visión por computadora.

Los detectores basados en aprendizaje profundo, como la familia YOLO (You Only Look Once) (REDMON, 2016), operan directamente sobre imágenes en color y producen, para cada objeto detectado, una bounding box descrita por el centro \((cx,cy)\) y las dimensiones \((w,h)\), además de una clase y una puntuación de confianza. Esta representación comparte la misma estructura geométrica básica obtenida por connectedComponentsWithStats, aunque es producida por un modelo aprendido y no mediante segmentación explícita.

La Figura 4.29 ilustra cómo las bounding boxes obtenidas por morfología pueden exportarse en el formato YOLO para componer conjuntos de datos utilizados en el entrenamiento o la evaluación de detectores.

%%writefile tmp/fig_04_bbox.cpp
#define MM_OUT "tmp/fig_04_bbox.png"
// Compile with: g++ -std=c++17 -o program program.cpp -I. -L. -lmorph -lopencv_core -lopencv_imgproc -lopencv_imgcodecs -lopencv_highgui
#include "morph.hpp"
#include <opencv2/opencv.hpp>
#include <iostream>
#include <iomanip>
#include <string>
#include <vector>
#include <fstream>
#include <sstream>
#include <cstring>
#include <filesystem>

//| 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

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 labels, stats, centroids;
    int n = cv::connectedComponentsWithStats(
        cv::Mat(img_final.h, img_final.w, CV_8UC1, img_final.data.data()),
        labels, stats, centroids, 8, CV_32S
    );

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

    // Convert mm::Image to cv::Mat for operations
    cv::Mat gray_mat(H_img, W_img, CV_8UC1, img_coins_gray.data.data());
    cv::Mat img_bbox;
    cv::cvtColor(gray_mat, img_bbox, cv::COLOR_GRAY2BGR);

    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 oss;
        oss << CLASSE << " " << std::fixed << std::setprecision(4) 
            << cx_n << " " << cy_n << " " << w_n << " " << h_n;
        yolo_linhas.push_back(oss.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 j = 0; j < yolo_linhas.size(); j++) {
        if (j > 0) f << "\n";
        f << yolo_linhas[j];
    }
    f.close();
    std::cout << "\nAnotação salva em moedas.txt\n";

    // Convert result back to mm::Image
    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)")
Figura 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].

El formato YOLO almacena cada objeto en una línea que contiene cinco campos:

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

donde \((cx,cy)\) representa el centro de la bounding box y \((w,h)\) sus dimensiones. Todos los valores geométricos se normalizan al intervalo \([0,1]\) con respecto al ancho y al alto de la imagen. La clase es un identificador entero asociado a una categoría definida por el conjunto de datos (por ejemplo, 0 → moneda). Cuando hay múltiples categorías —como moneda de oro (0), moneda de plata (1) y disco plástico (2)— basta con asignar el identificador correspondiente a cada objeto antes de la exportación, manteniendo exactamente el mismo formato de anotación. En el ejemplo anterior, esta información se almacenó en el archivo monedas.txt.

El flujo presentado en este capítulo —segmentación → etiquetado → extracción de bounding boxes— corresponde conceptualmente a la etapa de anotación (labeling) empleada en la construcción de conjuntos de entrenamiento para detectores modernos. Herramientas especializadas, como Label Studio y Roboflow, automatizan este proceso en imágenes complejas, pero la lógica fundamental sigue siendo la misma: asociar a cada objeto una región de interés y una clase. En escenarios controlados, con fondo uniforme y objetos bien separados, las técnicas morfológicas pueden incluso generar anotaciones automáticamente o servir como punto de partida para el etiquetado manual, reduciendo significativamente el esfuerzo de construcción del conjunto de datos. En aplicaciones reales más complejas, sin embargo, la validación humana sigue siendo necesaria para garantizar la calidad de las anotaciones.

NotaEvaluación: IoU (Intersection over Union)

Una forma sencilla de evaluar la calidad de una segmentación consiste en compararla con una máscara de referencia (ground truth). La métrica más utilizada para este propósito es la IoU (Intersection over Union):

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

donde \(A\) representa la segmentación producida por el algoritmo y \(B\) la segmentación de referencia.

El valor de la IoU varía entre 0 y 1. Cuanto mayor sea el valor, mayor será la superposición entre las máscaras. Una IoU igual a 1 indica una correspondencia perfecta entre la segmentación obtenida y la referencia.

La misma métrica también se utiliza ampliamente en la detección de objetos, aplicándose a las bounding boxes previstas y anotadas. En esta área, valores de IoU superiores a 0,5 se adoptan frecuentemente como criterio mínimo para considerar una detección correcta.

TipMás Allá de la Morfología

Las técnicas estudiadas en este capítulo segmentan objetos explorando la conectividad espacial, las operaciones morfológicas y el relieve topográfico. Existen, sin embargo, enfoques alternativos basados en la agrupación de características, como el algoritmo k-means, los modelos de mezcla Gaussiana (GMM) y métodos más recientes basados en aprendizaje profundo. Estas técnicas se retomarán en la Parte II del libro, dedicada a la Visión Computacional.

4.6 Resumen

En este capítulo se presentaron las principales técnicas de segmentación y morfología matemática, concluyendo el estudio del PDI en el dominio espacial.

  • Preprocesamiento y umbralización: La combinación entre ecualización adaptativa CLAHE y el método de Otsu mostró ser eficaz para inducir separación bimodal en el histograma y simplificar la binarización de imágenes con iluminación no uniforme.
  • Erosión y dilatación: Operadores morfológicos fundamentales basados en la búsqueda de mínimos y máximos locales en una vecindad definida por el elemento estructurante \(B\). Son operadores duales por el complemento y fueron implementados mediante las funciones mm::ero y mm::dil.
  • Apertura y cierre: Composiciones de erosión y dilatación que permiten eliminar ruidos, suavizar contornos y rellenar pequeñas lagunas, preservando la estructura global de los objetos.
  • Reconstrucción morfológica: Proceso geodésico iterativo que propaga un marcador dentro de los límites impuestos por una máscara, constituyendo la base de operadores como mm::clohole y mm::edgeoff.
  • Pipeline de limpieza binaria: Flujo consolidado compuesto por CLAHE → Otsu → apertura → mm::clohole → apertura restringida → mm::edgeoff, produciendo máscaras adecuadas para análisis cuantitativo.
  • Morfología en tonos de gris: Extensión algebraica basada en mínimos y máximos ponderados, viabilizando operadores como gradiente morfológico y filtros top-hat para realce de estructuras locales.
  • Transformada de Distancia y Watershed: La Transformada de Distancia permitió generar marcadores automáticos para el algoritmo watershed, posibilitando la separación de objetos adyacentes o parcialmente superpuestos.
  • Componentes conexos y descriptores: La rotulación de regiones (mm::label0) y la extracción de contornos permitieron calcular descriptores geométricos como área, centroide, perímetro, circularidad y bounding boxes.
  • Conexión con Visión Computacional Moderna: Las bounding boxes extraídas por morfología fueron exportadas en el formato YOLO, evidenciando el vínculo entre técnicas clásicas de segmentación y sistemas modernos de detección de objetos.

El Capítulo 5 introducirá técnicas de procesamiento en el dominio de la frecuencia, abordando la Transformada de Fourier, filtrado espectral y los fundamentos de la compresión de imágenes, incluyendo DCT, JPEG y wavelets.

4.7 🤖 Uso de Gemini Notebook como Tutor Complementario

En esta edición, se incentiva el uso de Gemini Notebook como herramienta complementaria de aprendizaje. Basado en inteligencia artificial, el sistema utiliza exclusivamente los documentos proporcionados por el autor como fuente de conocimiento, produciendo respuestas alineadas con el contenido y el enfoque adoptado a lo largo de este capítulo.

Importante🎓 Estudia con el Tutor Inteligente

🚀 ACCEDER A Gemini Notebook: CAPÍTULO 04

🌐 Idioma y Lenguaje de Programación

El proyecto de este capítulo en Gemini Notebook fue construido únicamente con el texto en portugués y los ejemplos de código en Python. Si estás estudiando con la edición en inglés o francés, o siguiendo la ruta en C++, las respuestas del tutor pueden no corresponder exactamente a la versión que estás leyendo.

⚠️ Aviso sobre Contenido Generado por IA

Aunque es una herramienta valiosa de apoyo al estudio, Gemini Notebook puede eventualmente producir respuestas incompletas, imprecisas o incorrectas. Se recomienda validar la información consultando el material del capítulo, libros, artículos científicos y otras fuentes académicas confiables. Siempre que sea posible, ejecuta y experimenta con los ejemplos prácticos presentados a lo largo del texto para consolidar la comprensión de los conceptos.

4.8 Lista de Ejercicios

  1. (10%) Implemente manualmente el criterio de Otsu sin utilizar mm::threshold. Calcule la varianza entre clases \(\sigma_B^2(T)\) para todos los umbrales \(T \in [0,255]\) usando mm::hist, identifique el umbral óptimo \(T^*\) y compare el resultado con el valor obtenido por OpenCV. Grafique \(\sigma_B^2\) en función de \(T\) y resalte el punto de máximo.

  2. (15%) Aplique umbralización adaptativa con bloques de tamaño 11, 31 y 51 a una imagen que contenga iluminación no uniforme. Compare los resultados con la umbralización global de Otsu y discuta las ventajas y limitaciones de cada enfoque.

  3. (15%) Ejecute el pipeline de watershed de la imagen de monedas variando el umbral aplicado a la Transformada de Distancia (\(0.3\), \(0.5\) y \(0.7\) veces el valor máximo). Explique cómo este parámetro influye en la generación de los marcadores, la separación de objetos adyacentes y la ocurrencia de sobre-segmentación.

  4. (15%) Utilizando mm::drawImg, construya una demostración visual paso a paso de la erosión de una imagen binaria 7×7 con elemento estructurante cuadrado 3×3. Para cada posición analizada, indique si el elemento estructurante está completamente contenido en el objeto y justifique el valor asignado al píxel de salida.

  5. (15%) Demuestre experimentalmente la dualidad entre erosión y dilatación verificando la identidad \((A \ominus B)^c = A^c \oplus \hat{B}\) utilizando mm::ero, mm::dil y mm::bnot. Calcule la diferencia píxel a píxel entre los dos lados de la ecuación y presente el resultado utilizando mm::histImg o una visualización equivalente.

  6. (15%) Implemente manualmente el gradiente morfológico utilizando únicamente mm::ero y mm::dil, comparando el resultado con mm::gradm(img, B). Evalúe el efecto de diferentes elementos estructurantes (cuadrado 3×3, disco 5×5 y línea 1×9) sobre la detección de bordes.

  7. (15%) Construya un pipeline completo para el conteo y clasificación de monedas por tamaño (pequeña, mediana y grande) utilizando área y circularidad como descriptores. Genere una máscara de referencia (ground truth) manualmente y calcule la métrica IoU (Intersection over Union) para evaluar la calidad de la segmentación. Presente los resultados en una tabla y mediante visualizaciones producidas con mm::show.

Referencias del Capítulo

La fundamentación teórica de este capítulo se basa en las siguientes obras:

  • Gonzalez (2018) para los conceptos de segmentación, umbralización de Otsu, Transformada de Distancia, watershed, morfología matemática y descriptores de forma.
  • Matheron (1975) y Serra (1982) para la fundamentación teórica original, formulación algebraica y desarrollo de la Morfología Matemática.
  • Szeliski (2022) para segmentación basada en regiones, etiquetado de componentes conexos, watershed basado en marcadores y evaluación de segmentación mediante la métrica IoU.
  • Bradski (2008) para la utilización práctica de la biblioteca OpenCV, incluyendo funciones como mm::dist, mm::watershed, mm::label0 y extracción de contornos.
  • Redmon (2016) para la introducción a los detectores modernos de la familia YOLO y su relación con descriptores geométricos como bounding boxes extraídas por segmentación.
  • Singh (2024) para la construcción de laberintos complejos basados en ciclos hamiltonianos sobre mosaicos cuasicristalinos, utilizados como ejemplo de aplicación de la Transformada de Distancia Geodésica y de algoritmos de búsqueda de caminos.
  • Zampirolli (2025) para la implementación de los operadores morfológicos, transformadas geodésicas y resolución de laberintos por propagación de distancias en dominios restringidos.

4.9 💻 Parte Práctica con Ejercicios de Programación

Esta lista transforma los conceptos del Capítulo 4 en una ruta práctica de segmentación y morfología matemática. Los EPs comienzan con umbralización y avanzan hasta etiquetado y descriptores de componentes, siempre con matrices pequeñas para que cada píxel pueda verificarse a mano.

ImportanteRegla común de los EPs morfológicos

En las operaciones con vecindad, no haga padding. Para cada píxel, evalúe únicamente las posiciones del elemento estructurante que caen dentro del dominio de la imagen. Esta es la misma idea de las implementaciones didácticas en morph.py, como mm::dil0, mm::ero0, mm::dil1 y mm::label0: la vecindad se recorta por el dominio válido de la imagen.

🎯 Objetivo de este cuaderno

El cuaderno permite desarrollar, validar, organizar y probar soluciones de Ejercicios de Programación (EPs) en entornos interactivos, como Colab, con los mismos casos de prueba de Moodle, copiándolos allí solo al momento de registrar la nota oficial.

Download

Descargue morph.py y testsuite.py ejecutando la celda a continuación:

import os, urllib.request

os.makedirs("tmp/state", exist_ok=True)  # artefatos de build del trayecto C++ (.cpp, binario, 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")

# El kernel es Python incluso en el trayecto C++: `mm` (morph.py) es usado por los
# simuladores, por la exhibición de las figuras que el binario C++ genera y por el
# estado mm::Image entre celdas. cpp=True descarga también el trayecto compilado
# (morph.hpp + stb_image*.h), usado en el #include de las celdas %%writefile *.cpp.
import config
config.setup(testsuite=True, cpp=True)
from morph import mm
from testsuite import TestSuite
✅ Entorno listo. Morph: 1.1.9 | OpenCV: 5.0.0 | TestSuite: 1.1.2

Ejecutando las pruebas

Para evaluar las pruebas, ejecute TestSuite("EP04_01.extensión").run() en una nueva celda, reemplazando la extensión por la del lenguaje utilizado (.py, .java, .c, .cpp, .js o .r). El sistema descarga los casos de prueba de GitHub, ejecuta el programa y calcula la nota automáticamente.

Para probar código Python directamente, sin guardar archivo, use run_code(codigo) pasando el código como string en una variable codigo:

codigo = """
from morph import mm
# ... su código aquí ...
"""
TestSuite("EP04_01").run_code(codigo)

4.9.1 EP04_01 🎚️ Umbralización Global por Umbral Fijo

En los escáneres de documentos y en los sistemas de lectura de códigos de barras, la primera etapa del procesamiento siempre consiste en separar lo que es “objeto” (tinta, texto, barras) de lo que es “fondo” (papel, embalaje). La umbralización global hace exactamente esto: compara cada píxel con un único umbral \(T\) y decide, en tiempo real, si pertenece a la clase clara o a la clase oscura. Es el operador de segmentación más simple — y aun así, está detrás de buena parte de los pipelines industriales de inspección visual. Ver en Figura 4.30 una simulación de este EP.

4.9.1.1 📋 Directrices de Implementación

  1. Dimensiones: Leer los enteros \(L\) (filas) y \(C\) (columnas).
  2. Umbral: Leer el entero \(T\) (umbral de decisión).
  3. Datos: Leer los valores enteros de la matriz original fila a fila.
  4. Mapeo: Para cada píxel \(p\), calcular el nuevo valor mediante la ecuación:

\[ p' = \begin{cases} 255, & \text{si } p > T \\ 0, & \text{si } p \le T \end{cases} \] 5. Salida: Mostrar la matriz binarizada con dimensiones \(L \times C\).

4.9.1.2 📌 Restricciones Computacionales

  • Binarización: La salida contiene solo los valores \(0\) o \(255\).
  • Comparación estricta: El criterio usa \(> T\) (los píxeles iguales a \(T\) se convierten en fondo).
  • Tipo: El resultado final debe ser entero.
  • Observación: Este EP sigue la convención de OpenCV (cv2.THRESL_BINARY): solo los píxeles con valor mayor que \(T\) se convierten en blancos (255); los píxeles con valor igual a \(T\) permanecen negros (0).

4.9.1.3 🧠 Fundamentación Teórica

Parámetro Tipo Impacto Visual
\(T\) pequeño Entero La mayoría de los píxeles se vuelven blancos
\(T\) grande Entero La mayoría de los píxeles se vuelven negros
\(T\) bien elegido Entero Separa nítidamente objeto y fondo

4.9.1.4 📦 Especificación de Entrada y Salida (VPL)

Entrada:

  • Línea 1: Entero \(L\).
  • Línea 2: Entero \(C\).
  • Línea 3: Entero \(T\).
  • Líneas siguientes: Elementos enteros de la matriz original.

Salida:

  • Matriz binarizada en \(L\) filas y \(C\) columnas, valores \(0\) o \(255\) separados por espacio.

4.9.1.5 📌 Ejemplos

Entrada Salida Observación
2
4
100
0 99 100 180
255 30 120 80
0 0 0 255
255 0 255 0
\(T=100\): solo los píxeles con valor mayor que 100 se vuelven blancos;
por eso, 99 y 100 se vuelven negros.
1
3
0
0 50 255
0 255 255 \(T=0\): solo los píxeles con valor estrictamente mayor que 0 se vuelven blancos.
🎚️ Simulador EP04_01: Umbralización Global p' = (p > T) ? 255 : 0

👆 Haga clic en una celda de Entrada Original para oscurecer el píxel (−30) y haga clic con el botón derecho para aclarar (+30). Ajuste el umbral T para la binarización.

128
Entrada Original (Clicable)
Resultado Binarizado (p')
Fórmula aplicada: (p > 128) ? 255 : 0
Figura 4.30: Simulador EP04_01: Umbralización Global por Umbral Fijo (p’ = (p > T) ? 255 : 0)
%%writefile EP04_01.cpp
// your solution
Overwriting EP04_01.cpp
TestSuite("EP04_01.cpp").run()
✔️ EP04_01.cases ya existe en casos/
📋 7 caso(s) cargado(s) de casos/EP04_01.cases

🔍 Probando C++: EP04_01.cpp
⚠️ EP04_01.cpp: archivo vacío (menos de 3 líneas). Pruebas omitidas.

4.9.2 EP04_02 📊 Umbralización Automática de Otsu

Elegir manualmente el umbral \(T\) funciona cuando la iluminación es estable, pero en microscopía digital y en inspección de láminas de sangre, cada muestra tiene un contraste diferente — un umbral fijo fallaría de imagen en imagen. El método de Otsu resuelve esto encontrando, por sí solo, el umbral que maximiza la separación estadística entre las dos clases de píxeles, haciendo la segmentación automática y adaptativa. Ver en Figura 4.31 una simulación de este EP.

4.9.2.1 📋 Directrices de Implementación

  1. Dimensiones: Leer los enteros \(L\) (filas) y \(C\) (columnas).

  2. Datos: Leer los valores enteros de la matriz original fila por fila.

  3. Histograma: Construir el histograma \(h[i]\), \(i=0,\dots,255\), contando cuántos píxeles tienen valor \(i\).

  4. Búsqueda del umbral: Para cada candidato \(T\) de \(1\) a \(255\), calcular la varianza entre clases: \[ \sigma_B^2(T) = \frac{n_0 \cdot n_1}{N^2}\,(m_0 - m_1)^2 \] donde \(n_0,n_1\) son las cantidades de píxeles con valor \(<T\) y \(\geq T\), \(m_0,m_1\) son sus medias, y \(N=L\times C\).

  5. Elección: El umbral óptimo \(T^*\) es el que maximiza \(\sigma_B^2(T)\) (en caso de empate, mantener el primero encontrado).

  6. Aplicación: Binarizar la imagen usando \(T^*\), aplicando: \[ p' = \begin{cases} 255, & \text{si } p > T^* \\ 0, & \text{si } p \le T^* \end{cases} \]

4.9.2.2 📌 Restricciones Computacionales

  • Candidatos válidos: Ignorar \(T\) que deje \(n_0=0\) o \(n_1=0\) (clase vacía).
  • Empate: Mantener siempre el primer \(T\) que alcanzó el valor máximo de \(\sigma_B^2\).
  • Tipo: \(T^*\) y la matriz de salida deben ser enteros.
  • Convención OpenCV: La binarización sigue cv2.THRESL_BINARY; los píxeles con valor exactamente igual a \(T^*\) se vuelven negros.

4.9.2.3 🧠 Fundamentación Teórica

Concepto Significado Impacto
\(\sigma_B^2(T)\) alta Clases bien separadas en \(T\) \(T\) es un buen candidato a umbral
Histograma bimodal Dos “picos” distintos Otsu encuentra el valle entre ellos
Histograma unimodal Un único “pico” Otsu aún elige algún \(T\), pero la segmentación es poco fiable

4.9.2.4 📦 Especificación de Entrada y Salida (VPL)

Entrada:

  • Línea 1: Entero \(L\).
  • Línea 2: Entero \(C\).
  • Líneas siguientes: Elementos enteros de la matriz original.

Salida:

  • Matriz binarizada en \(L\) filas y \(C\) columnas, valores \(0\) o \(255\).

4.9.2.5 📌 Ejemplos

Entrada Salida Observación
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
Histograma bimodal claro: 12 y 200
1
2
10 250
0 250 Solo dos valores: \(T^*\) queda en el mayor
📊 Simulador EP04_02: Otsu Automático T* = argmax σ²_B(T)

👆 Clic izquierdo oscurece (−25) y clic con el botón derecho aclara (+25) los píxeles de la entrada. Observe el umbral óptimo T* ajustarse dinámicamente al histograma.

T* = −

Entrada Original (Clicable)
Resultado Otsu (p')
Figura 4.31: Simulador EP04_02: Limiarización Automática de Otsu (T* = argmax σ²_B(T))
%%writefile EP04_02.cpp
// your solution
Overwriting EP04_02.cpp
TestSuite("EP04_02.cpp").run()
✔️ EP04_02.cases ya existe en casos/
📋 5 caso(s) cargado(s) de casos/EP04_02.cases

🔍 Probando C++: EP04_02.cpp
⚠️ EP04_02.cpp: archivo vacío (menos de 3 líneas). Pruebas omitidas.

4.9.3 EP04_03 🌱 Dilatación Binaria Plana (mm.dil0)

En microscopía de partículas y en OCR de placas de automóvil desgastadas, los trazos finos o discontinuos deben “engrosarse” para que el reconocimiento funcione. La dilatación morfológica hace exactamente eso: expande regiones claras usando un elemento estructurante \(B\) — la misma operación implementada en morph.py como mm::dil0(f, B), usada cuando \(B\) es plano (sin pesos, solo \(0\)/\(1\)). Ver en Figura 4.32 una simulación de este EP.

4.9.3.1 📋 Directrices de Implementación

  1. Dimensiones de la imagen: Leer los enteros \(L\) (filas) y \(C\) (columnas) de \(f\).
  2. Dimensiones de \(B\): Leer los enteros \(L_B\) (filas) y \(C_B\) (columnas) del elemento estructurante.
  3. Elemento estructurante: Leer la matriz \(B\) con valores \(0\) o \(1\), fila a fila.
  4. Datos: Leer la matriz \(f\) (la imagen original), fila a fila.
  5. Reflexión: Construir \(B_{ref}\), la versión de \(B\) reflejada en \(180°\) (filas y columnas invertidas) — exactamente como hace mm::dil0 internamente.
  6. Vecindad sin padding: Para cada píxel \((y,x)\), recorrer las posiciones \((by,bx)\) de \(B_{ref}\) centradas en \((y,x)\), usando el desplazamiento \[ 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 \] Descartar todo \((v_y,v_x)\) fuera de \([0,L)\times[0,C)\) — no rellenar con ceros.
  7. Mapeo: Calcular cada píxel de salida como el máximo entre \(f(y,x)\) y todos los \(f(v_y,v_x)\) válidos cuya posición correspondiente en \(B_{ref}\) vale \(1\): \[ g(y,x) = \max\Big(f(y,x),\ \max_{\substack{(v_y,v_x)\ \text{válido}\\ B_{ref}(by,bx)=1}} f(v_y,v_x)\Big) \]
  8. Salida: Mostrar la matriz \(g\) con dimensiones \(L \times C\).

4.9.3.2 📌 Restricciones Computacionales

  • Sin padding: Nunca inventar vecinos fuera de la imagen; usar solo los que existen realmente.
  • Reflexión obligatoria: \(B\) debe reflejarse antes de aplicarse (es lo que diferencia mm::dil0 de una simple búsqueda de máximo).
  • Robustez de borde: Si ninguna posición válida de \(B_{ref}=1\) cae dentro del dominio para un píxel dado, este mantiene su valor original.

4.9.3.3 🧠 Fundamentación Teórica

Concepto Significado Impacto Visual
Dilatación \(g \geq f\) siempre (extensiva) Las regiones claras crecen, los huecos oscuros se encogen
\(B\) mayor Vecindad más amplia Crecimiento más agresivo
Reflexión de \(B\) \(B_{ref}(y,x) = B(-y,-x)\) Garantiza la definición formal de Minkowski de la dilatación

4.9.3.4 📦 Especificación de Entrada y Salida (VPL)

Entrada:

  • Línea 1: Entero \(L\).
  • Línea 2: Entero \(C\).
  • Línea 3: Entero \(L_B\).
  • Línea 4: Entero \(C_B\).
  • Siguientes \(L_B\) líneas: elementos enteros (\(0\) o \(1\)) de la matriz \(B\).
  • Siguientes \(L\) líneas: elementos enteros de la matriz \(f\).

Salida:

  • Matriz \(g\) en \(L\) filas y \(C\) columnas, valores enteros separados por espacio.

4.9.3.5 📌 Ejemplos

Entrada Salida Observación
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 cruz simétrico: punto aislado se expande en cruz
1
4
1
3
1 1 1
10 200 5 80
200 200 200 80 \(B\) horizontal: cada píxel “atrae” el máximo de los vecinos de la fila
🌱 Simulador EP04_03: Dilatación Plana (mm.dil0) g = f ⊕ B

Cambie el elemento estructurante B (o seleccione los preajustes) y haga clic en las celdas de la imagen original f para encender o apagar píxeles.

Elemento Estructurante B (Clic para Alternar 0/1)
Imagen Original f (5×5)
Dilatada g (f ⊕ B)
 
g(y,x) = máximo sobre vecinos válidos de B reflejado
Figura 4.32: Simulador EP04_03: Dilatación Binaria Plana (g = f ⊕ B)
%%writefile EP04_03.cpp
// your solution
Overwriting EP04_03.cpp
TestSuite("EP04_03.cpp").run()
✔️ EP04_03.cases ya existe en casos/
📋 5 caso(s) cargado(s) de casos/EP04_03.cases

🔍 Probando C++: EP04_03.cpp
⚠️ EP04_03.cpp: archivo vacío (menos de 3 líneas). Pruebas omitidas.

4.9.4 EP04_04 🪨 Erosión Binaria Plana (mm.ero0)

Si la dilatación engrosa, la erosión afina. En sistemas de conteo de células, se utiliza para separar células que se tocan: al “comer” los bordes de cada región, las conexiones finas entre objetos desaparecen incluso antes de que se realice cualquier conteo. En morph.py, esta es la operación mm::ero0(f, B) — la dual exacta de la dilatación, y la única de las dos que no refleja el elemento estructurante. Ver en Figura 4.33 una simulación de este EP.

4.9.4.1 📋 Directrices de Implementación

  1. Dimensiones de la imagen: Leer los enteros \(L\) (filas) y \(C\) (columnas) de \(f\).
  2. Dimensiones de \(B\): Leer los enteros \(L_B\) (filas) y \(C_B\) (columnas) del elemento estructurante.
  3. Elemento estructurante: Leer la matriz \(B\) con valores \(0\) o \(1\), fila a fila.
  4. Datos: Leer la matriz \(f\) (la imagen original), fila a fila.
  5. Vecindad sin padding (¡sin reflexión!): Para cada píxel \((y,x)\), recorrer las posiciones \((by,bx)\) de \(B\) en el orden original (sin reflejar), usando el mismo desplazamiento del 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 \]

Descartar todo \((v_y,v_x)\) fuera de \([0,L)\times[0,C)\). 6. Mapeo: Calcular cada píxel de salida como el mínimo entre \(f(y,x)\) y todos los \(f(v_y,v_x)\) válidos cuya posición correspondiente en \(B\) vale \(1\): \[ g(y,x) = \min\Big(f(y,x),\ \min_{\substack{(v_y,v_x)\ \text{válido}\\ B(by,bx)=1}} f(v_y,v_x)\Big) \] 7. Salida: Mostrar la matriz \(g\) con dimensiones \(L \times C\).

4.9.4.2 📌 Restricciones Computacionales

  • Sin reflexión: A diferencia de la dilatación, \(B\) se usa exactamente como se lee — reflejarlo aquí sería un error conceptual grave.
  • Sin padding: Los vecinos fuera de la imagen simplemente se ignoran, nunca se tratan como \(0\).
  • Robustez de borde: Si ninguna posición válida de \(B=1\) cae dentro del dominio, el píxel mantiene su valor original.

4.9.4.3 🧠 Fundamentación Teórica

Concepto Significado Impacto Visual
Erosión \(g \leq f\) siempre (anti-extensiva) Las regiones claras se encogen, el ruido puntual desaparece
Dualidad \(\text{ero}(f,B) = -\text{dil}(-f, B_{ref})\) La erosión y la dilatación son “espejos” matemáticos
\(B\) más grande Erosión más agresiva Los objetos finos desaparecen por completo

4.9.4.4 📦 Especificación de Entrada y Salida (VPL)

Entrada:

  • Línea 1: Entero \(L\).
  • Línea 2: Entero \(C\).
  • Línea 3: Entero \(L_B\).
  • Línea 4: Entero \(C_B\).
  • Siguientes \(L_B\) líneas: elementos enteros (\(0\) o \(1\)) de la matriz \(B\).
  • Siguientes \(L\) líneas: elementos enteros de la matriz \(f\).

Salida:

  • Matriz \(g\) en \(L\) filas y \(C\) columnas, valores enteros separados por espacios.

4.9.4.5 📌 Ejemplos

Entrada Salida Observación
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
El “agujero” central (0) se propaga en cruz
1
4
1
3
1 1 1
10 200 5 80
10 5 5 80 \(B\) horizontal: cada píxel “extrae” el mínimo de los vecinos de la fila
🪨 Simulador EP04_04: Erosión Plana (mm.ero0) g = f ⊖ B

Alterne el elemento estructurante B (o seleccione los presets) y haga clic en las celdas de la imagen original f para encender o apagar píxeles.

Elemento Estructurante B (Clic para Alternar 0/1)
Imagen Original f (5×5)
Erosionada g (f ⊖ B)
 
g(y,x) = mínimo sobre vecinos válidos de B (sin reflejar)
Figura 4.33: Simulador EP04_04: Erosión Binaria Plana (g = f ⊖ B)
%%writefile EP04_04.cpp
// your solution
Overwriting EP04_04.cpp
TestSuite("EP04_04.cpp").run()
✔️ EP04_04.cases ya existe en casos/
📋 5 caso(s) cargado(s) de casos/EP04_04.cases

🔍 Probando C++: EP04_04.cpp
⚠️ EP04_04.cpp: archivo vacío (menos de 3 líneas). Pruebas omitidas.

4.9.5 EP04_05 🧹 Apertura Morfológica (Eliminación de Ruido)

Las imágenes capturadas por sensores de bajo costo, como los de drones agrícolas, suelen venir salpicadas de pequeños puntos de ruido — píxeles aislados que no representan nada real. Aplicar erosión seguida de dilatación con el mismo elemento estructurante produce la apertura: esta “limpia” puntos y protuberancias finas, pero devuelve al objeto principal prácticamente su tamaño original. Es la combinación clásica utilizada en preprocesamiento de imágenes de satélite antes de cualquier conteo de área plantada. Ver en Figura 4.34 una simulación de este EP.

4.9.5.1 📋 Directrices de Implementación

  1. Dimensiones de la imagen: Leer los enteros \(L\) (filas) y \(C\) (columnas) de \(f\).
  2. Dimensiones de \(B\): Leer los enteros \(L_B\) (filas) y \(C_B\) (columnas) del elemento estructurante.
  3. Elemento estructurante: Leer la matriz \(B\) con valores \(0\) o \(1\), fila a fila.
  4. Datos: Leer la matriz binaria \(f\) (valores \(0\) o \(1\)), fila a fila.
  5. Erosión: Calcular \(e = f \ominus B\), usando exactamente el algoritmo del EP04_04 (sin reflejar \(B\), sin padding).
  6. Dilatación: Calcular \(g = e \oplus B\), usando exactamente el algoritmo del EP04_03 (reflejando \(B\), sin padding) — pero ahora aplicado sobre \(e\), no sobre \(f\).
  7. Salida: Mostrar la matriz resultante \(g\) (la apertura de \(f\) por \(B\)) con dimensiones \(L \times C\).

4.9.5.2 📌 Restricciones Computacionales

  • Orden fijo: Es siempre erosión primero, luego dilatación — el orden inverso define otro operador (cierre, del próximo EP).
  • Mismo \(B\): El elemento estructurante utilizado en la erosión y en la dilatación debe ser idéntico.
  • Sin padding en ninguna de las dos etapas.

4.9.5.3 🧠 Fundamentación Teórica

Concepto Significado Impacto Visual
Antiextensividad \(g \subseteq f\) siempre La apertura nunca crea un píxel nuevo, solo elimina
Idempotencia \(\text{apertura}(\text{apertura}(f)) = \text{apertura}(f)\) Aplicar de nuevo no cambia nada más
Puntos aislados Menores que \(B\) Son completamente eliminados
Núcleo del objeto Mayor que \(B\) Se recupera casi intacto mediante la dilatación final

4.9.5.4 📦 Especificación de Entrada y Salida (VPL)

Entrada:

  • Línea 1: Entero \(L\).
  • Línea 2: Entero \(C\).
  • Línea 3: Entero \(L_B\).
  • Línea 4: Entero \(C_B\).
  • Siguientes \(L_B\) líneas: elementos enteros (\(0\) o \(1\)) de la matriz \(B\).
  • Siguientes \(L\) líneas: elementos enteros (\(0\) o \(1\)) de la matriz \(f\).

Salida:

  • Matriz resultante en \(L\) filas y \(C\) columnas, valores \(0\) o \(1\).

4.9.5.5 📌 Ejemplos

Entrada Salida Observación
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
Los puntos aislados y la protuberancia fina desaparecen; el cuadrado central sobrevive
🧹 Simulador EP04_05: Apertura Morfológica g = (f ⊖ B) ⊕ B

Haz clic en las celdas de f original para encender o apagar píxeles (¡crea tu propio ruido de fondo!) y ajusta el tamaño del elemento estructurante B.


3×3
f Original (Clicable)
e = f ⊖ B (Erosión)
g = e ⊕ B (Apertura)
Figura 4.34: Simulador EP04_05: Apertura Morfológica (g = (f ⊖ B) ⊕ B)
%%writefile EP04_05.cpp
// your solution
Overwriting EP04_05.cpp
TestSuite("EP04_05.cpp").run()
✔️ EP04_05.cases ya existe en casos/
📋 5 caso(s) cargado(s) de casos/EP04_05.cases

🔍 Probando C++: EP04_05.cpp
⚠️ EP04_05.cpp: archivo vacío (menos de 3 líneas). Pruebas omitidas.

4.9.6 EP04_06 🧩 Cierre Morfológico (Relleno de Huecos)

En la digitalización de huellas dactilares, los surcos de la piel a veces se ven interrumpidos por suciedad o sequedad, creando pequeñas fallas en la curva continua que debería existir. El cierre —dilatación seguida de erosión con el mismo elemento estructurante— es el operador dual de la apertura: rellena huecos pequeños y entrantes estrechos, sin alterar significativamente el contorno externo del objeto. Es el paso estándar antes de extraer el esqueleto de una huella dactilar. Ver en Figura 4.35 una simulación de este EP.

4.9.6.1 📋 Directrices de Implementación

  1. Dimensiones de la imagen: Leer los enteros \(L\) (líneas) y \(C\) (columnas) de \(f\).
  2. Dimensiones de \(B\): Leer los enteros \(L_B\) (líneas) y \(C_B\) (columnas) del elemento estructurante.
  3. Elemento estructurante: Leer la matriz \(B\) con valores \(0\) o \(1\), línea por línea.
  4. Datos: Leer la matriz binaria \(f\) (valores \(0\) o \(1\)), línea por línea.
  5. Dilatación: Calcular \(d = f \oplus B\), usando exactamente el algoritmo del EP04_03 (reflejando \(B\), sin padding).
  6. Erosión: Calcular \(g = d \ominus B\), usando exactamente el algoritmo del EP04_04 (sin reflejar \(B\), sin padding) — ahora aplicado sobre \(d\), no sobre \(f\).
  7. Salida: Mostrar la matriz resultante \(g\) (el cierre de \(f\) por \(B\)) con dimensiones \(L \times C\).

4.9.6.2 📌 Restricciones Computacionales

  • Orden fijo: Es siempre dilatación primero, luego erosión — el orden inverso es la apertura del EP04_05.
  • Mismo \(B\): El elemento estructurante usado en la dilatación y en la erosión debe ser idéntico.
  • Sin padding en ninguna de las dos etapas.

4.9.6.3 🧠 Fundamentación Teórica

Concepto Significado Impacto Visual
Extensividad \(g \supseteq f\) siempre El cierre nunca elimina píxeles, solo añade
Idempotencia \(\text{cierre}(\text{cierre}(f)) = \text{cierre}(f)\) Aplicarlo de nuevo no cambia nada más
Huecos pequeños Menores que \(B\) Se rellenan completamente
Dualidad \(\text{cierre}(f) = \overline{\text{apertura}(\bar f)}\) Es la apertura aplicada al “negativo” de la imagen

4.9.6.4 📦 Especificación de Entrada y Salida (VPL)

Entrada:

  • Línea 1: Entero \(L\).
  • Línea 2: Entero \(C\).
  • Línea 3: Entero \(L_B\).
  • Línea 4: Entero \(C_B\).
  • Siguientes \(L_B\) líneas: elementos enteros (\(0\) o \(1\)) de la matriz \(B\).
  • Siguientes \(L\) líneas: elementos enteros (\(0\) o \(1\)) de la matriz \(f\).

Salida:

  • Matriz resultante en \(L\) líneas y \(C\) columnas, valores \(0\) o \(1\).

4.9.6.5 📌 Ejemplos

Entrada Salida Observación
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
Los dos huecos internos no adyacentes se rellenan totalmente
🧩 Simulador EP04_06: Cierre Morfológico g = (f ⊕ B) ⊖ B

Haz clic en las celdas de f original para encender o apagar píxeles (¡rellena huecos internos!) y ajusta el tamaño del elemento estructurante B.


3×3
f Original (Clickeable)
d = f ⊕ B (Dilatación)
g = d ⊖ B (Cierre)
Figura 4.35: Simulador EP04_06: Cierre Morfológico (g = (f ⊕ B) ⊖ B)
%%writefile EP04_06.cpp
// your solution
Overwriting EP04_06.cpp
TestSuite("EP04_06.cpp").run()
✔️ EP04_06.cases ya existe en casos/
📋 5 caso(s) cargado(s) de casos/EP04_06.cases

🔍 Probando C++: EP04_06.cpp
⚠️ EP04_06.cpp: archivo vacío (menos de 3 líneas). Pruebas omitidas.

4.9.7 EP04_07 ⛰️ Dilatación y Erosión con Pesos (mm.dil1 / mm.ero1)

Hasta ahora, el elemento estructurante solo decía “este vecino cuenta” o “no cuenta” — pero en modelos digitales de elevación (usados en SIG y en planificación de drenaje urbano), cada vecino debería tener un peso diferente dependiendo de la distancia o de la dirección del relieve. Las versiones ponderadas de la dilatación y de la erosión, implementadas en morph.py como mm::dil1(f, b) y mm::ero1(f, b), suman (o restan) el peso de cada vecino antes de tomar el máximo (o mínimo) — generalizando todo lo realizado en los EPs anteriores. Ver en Figura 4.36 una simulación de este EP.

4.9.7.1 📋 Directrices de Implementación

  1. Dimensiones de la imagen: Leer los enteros \(L\) (filas) y \(C\) (columnas) de \(f\).
  2. Dimensiones de \(b\): Leer los enteros \(L_B\) (filas) y \(C_B\) (columnas) del elemento estructurante ponderado.
  3. Pesos: Leer la matriz \(b\) de pesos enteros (pueden ser negativos, cero o positivos), fila a fila.
  4. Datos: Leer la matriz \(f\) (la imagen original), fila a fila.
  5. Vecindario sin padding: Para cada píxel \((y,x)\), recorrer todas las posiciones \((by,bx)\) de \(b\) (no solo donde valdría \(1\) — aquí todo peso participa), usando el mismo desplazamiento de los EPs anteriores: \[ 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 \] Descartar todo \((v_y,v_x)\) fuera de \([0,L)\times[0,C)\).
  6. Dilatación ponderada: Calcular \[ g_{dil}(y,x) = \max\Big(f(y,x),\ \max_{(v_y,v_x)\ \text{válido}} \big(f(v_y,v_x) + b(by,bx)\big)\Big) \]
  7. Erosión ponderada: Calcular, usando el mismo \(b\) y sin reflejar: \[ g_{ero}(y,x) = \min\Big(f(y,x),\ \min_{(v_y,v_x)\ \text{válido}} \big(f(v_y,v_x) - b(by,bx)\big)\Big) \]
  8. Salida: Mostrar primero la matriz \(g_{dil}\) completa, y después la matriz \(g_{ero}\) completa.

4.9.7.2 📌 Restricciones Computacionales

  • Ninguna de las dos refleja \(b\) — la versión ponderada no usa reflexión, incluso en la dilatación (diferente de mm::dil0).
  • Todos los pesos participan: No existe aquí el filtro “\(B=1\)”; incluso el peso \(0\) entra en la cuenta.
  • Sin padding: los vecinos fuera de la imagen se ignoran, nunca se rellenan virtualmente.
  • Tipo: La salida puede contener valores negativos o mayores que \(255\) — no hay clipping en este EP.
  • Consejo: Para eliminar mensajes de desbordamiento al superar los límites del tipo uint8, incluir al inicio del código:
import warnings
warnings.filterwarnings("ignore")

4.9.7.3 🧠 Fundamentación Teórica

Concepto Significado Impacto Visual
Peso positivo “Empuja” el valor del vecino hacia arriba en la dilatación Simula relieve que asciende en esa dirección
Peso negativo Reduce la contribución del vecino Simula distancia o atenuación direccional
Dualidad ponderada \(\text{ero1}(f,b) = -\text{dil1}(-f,b)\) La simetría entre las dos operaciones se mantiene incluso con pesos

4.9.7.4 📦 Especificación de Entrada y Salida (VPL)

Entrada:

  • Línea 1: Entero \(L\).
  • Línea 2: Entero \(C\).
  • Línea 3: Entero \(L_B\).
  • Línea 4: Entero \(C_B\).
  • Siguientes \(L_B\) líneas: elementos enteros (pueden ser negativos) de la matriz \(b\).
  • Siguientes \(L\) líneas: elementos enteros de la matriz \(f\).

Salida:

  • Primero la matriz \(g_{dil}\) en \(L\) líneas y \(C\) columnas.
  • A continuación la matriz \(g_{ero}\) en \(L\) líneas y \(C\) columnas.

4.9.7.5 📌 Ejemplos

Entrada Salida Observación
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
Peso central \(2\) acelera el crecimiento en la dilatación y la contracción en la erosión
⛰️ Simulador EP04_07: Pesos en el Elemento Estructurante dil1 / ero1

Ajuste los pesos del elemento estructurante b con los controles deslizantes y observe el efecto de la dilatación y erosión con pesos sobre la matriz f.

Pesos b (Ajuste los Sliders por Celda)
f Original
dil1(f, b) (Dilatación)
ero1(f, b) (Erosión)
Figura 4.36: Simulador EP04_07: Dilatación y Erosión con Pesos (mm.dil1 / mm.ero1)
%%writefile EP04_07.cpp
// your solution
Overwriting EP04_07.cpp
TestSuite("EP04_07.cpp").run()
✔️ EP04_07.cases ya existe en casos/
📋 3 caso(s) cargado(s) de casos/EP04_07.cases

🔍 Probando C++: EP04_07.cpp
⚠️ EP04_07.cpp: archivo vacío (menos de 3 líneas). Pruebas omitidas.

4.9.8 EP04_08 🌋 Gradiente morfológico, Top-hat y Black-hat

En la inspección automática de placas de circuito, tres preguntas aparecen todo el tiempo: ¿dónde están los bordes de los componentes? ¿Qué detalles claros y pequeños (como puntos de soldadura) se destacan del fondo? ¿Qué reentrancias oscuras (como fisuras) esconde el fondo? Un único par erosión/dilatación responde a las tres: el gradiente morfológico evidencia contornos, el top-hat revela picos estrechos, y el black-hat revela valles estrechos — tres herramientas, una sola vecindad. Ver en Figura 4.37 una simulación de este EP.

4.9.8.1 📋 Directrices de Implementación

  1. Dimensiones de la imagen: Leer los enteros \(L\) (filas) y \(C\) (columnas) de \(f\).
  2. Dimensiones de \(B\): Leer los enteros \(L_B\) (filas) y \(C_B\) (columnas) del elemento estructurante.
  3. Elemento estructurante: Leer la matriz \(B\) con valores \(0\) o \(1\), línea a línea.
  4. Datos: Leer la matriz \(f\) (la imagen original, en tonos de gris), línea a línea.
  5. Operadores de base: Calcular, exactamente como en los EPs 04_03 a 04_06:
    • \(d = f \oplus B\) (dilatación),
    • \(e = f \ominus B\) (erosión),
    • \(\text{abertura} = e \oplus B\),
    • \(\text{fechamento} = d \ominus B\).
  6. Gradiente morfológico: \(\text{grad}(y,x) = d(y,x) - e(y,x)\).
  7. Top-hat: \(\text{tophat}(y,x) = f(y,x) - \text{abertura}(y,x)\).
  8. Black-hat: \(\text{blackhat}(y,x) = \text{fechamento}(y,x) - f(y,x)\).
  9. Salida: Mostrar, en este orden, las tres matrices completas: gradiente, top-hat, black-hat.

4.9.8.2 📌 Restricciones Computacionales

  • Sin padding en ninguna etapa intermedia — dilatación, erosión, apertura y cierre siguen las mismas reglas de vecindad de los EPs anteriores.
  • No hay clipping: las tres salidas pueden contener cualquier valor entero (el gradiente es siempre \(\geq 0\), pero top-hat y black-hat también).
  • Reutilización: \(d\) y \(e\) deben calcularse una única vez y reutilizarse para montar apertura, cierre y gradiente.

4.9.8.3 🧠 Fundamentación Teórica

Operador Fórmula Qué revela
Gradiente \(d - e\) Bordes: cero en regiones planas, alto en las transiciones
Top-hat \(f - \text{abertura}(f)\) Elementos claros y finos, más pequeños que \(B\)
Black-hat \(\text{fechamento}(f) - f\) Elementos oscuros y finos, más pequeños que \(B\)

4.9.8.4 📦 Especificación de Entrada y Salida (VPL)

Entrada:

  • Línea 1: Entero \(L\).
  • Línea 2: Entero \(C\).
  • Línea 3: Entero \(L_B\).
  • Línea 4: Entero \(C_B\).
  • Siguientes \(L_B\) líneas: elementos enteros (\(0\) o \(1\)) de la matriz \(B\).
  • Siguientes \(L\) líneas: elementos enteros de la matriz \(f\).

Salida:

  • Matriz gradiente en \(L\) filas y \(C\) columnas.
  • Matriz top-hat en \(L\) filas y \(C\) columnas.
  • Matriz black-hat en \(L\) filas y \(C\) columnas.

4.9.8.5 📌 Ejemplos

Entrada Salida Observación
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
(gradiente: halo \(3\times3=70\) en torno a \((2,2)\) y halo \(3\times3=8\) en torno a \((6,6)\), resto \(0\))
(top-hat: único \(70\) en \((2,2)\), resto \(0\))
(black-hat: único \(8\) en \((6,6)\), resto \(0\))
Pico aislado se convierte en top-hat; valle aislado se convierte en black-hat; ambos aparecen en el gradiente
🌋 Simulador EP04_08: Gradiente / Top-hat / Black-hat 3 operadores, 1 vecindad

Agregue picos o valles en la matriz f y observe el comportamiento simultáneo de los operadores de gradiente, top-hat y black-hat.

f (Entrada)
Gradiente
Top-hat
Black-hat
Figura 4.37: Simulador EP04_08: Gradiente Morfológico, Top-hat y Black-hat
%%writefile EP04_08.cpp
// your solution
Overwriting EP04_08.cpp
TestSuite("EP04_08.cpp").run()
✔️ EP04_08.cases ya existe en casos/
📋 4 caso(s) cargado(s) de casos/EP04_08.cases

🔍 Probando C++: EP04_08.cpp
⚠️ EP04_08.cpp: archivo vacío (menos de 3 líneas). Pruebas omitidas.

4.9.9 EP04_09 🗺️ Transformada de Distancia y el “Centro” del Objeto

En robótica móvil, al planificar una ruta dentro de un pasillo, el robot quiere saber no solo dónde hay espacio libre, sino también qué tan lejos está cada punto libre de la pared más cercana. Las rutas más seguras tienden a pasar por el “centro” del pasillo, lejos de los obstáculos.

La transformada de distancia morfológica asigna a cada píxel un valor que representa su distancia hasta el borde más cercano, según la métrica definida por el elemento estructurante. Los píxeles cercanos al borde reciben valores bajos, mientras que los píxeles más internos reciben valores mayores. El píxel de valor máximo corresponde a la región más protegida del objeto, frecuentemente asociada a su centro morfológico.

Ver en Figura 4.38 una simulación de este EP.

4.9.9.1 📋 Directrices de Implementación

  1. Dimensiones de la imagen: leer los enteros \(L\) (filas) y \(C\) (columnas) de la imagen \(f\).
  2. Dimensiones de \(B\): leer los enteros \(L_B\) (filas) y \(C_B\) (columnas) del elemento estructurante.
  3. Elemento estructurante: leer la matriz \(b\), que contiene el valor \(0\) en el centro y valores negativos en las demás posiciones.
  4. Imagen: leer la matriz binaria \(f\) (valores \(0\) o \(1\)), fila a fila.
  5. Preparación: multiplicar la imagen por \(L\times C\), asegurando que los píxeles internos tengan un valor inicial suficientemente alto para la propagación de las distancias.
  6. Transformada de distancia: calcular la matriz de distancias utilizando el método mm::dist1(f,b).
  7. Salida: mostrar la matriz resultante de la transformada de distancia.

4.9.9.2 📌 Restricciones Computacionales

  • Utilizar la implementación de erosión ponderada proporcionada por la biblioteca.
  • El elemento estructurante puede contener valores negativos arbitrarios.
  • La transformada debe obtenerse mediante la aplicación iterativa de erosiones ponderadas hasta alcanzar un punto fijo.

⚠️ Nota Crucial sobre Lectura de Matrices: Como el elemento estructurante puede contener valores enteros negativos (por ejemplo, -1 y -99), no utilice la función mm::readImg para leer la matriz \(b\). Esa función convierte los datos al tipo uint8, provocando underflow y corrompiendo los valores negativos. Lea las \(L_B\) filas de \(b\) manualmente utilizando el tipo estándar int. La imagen \(f\) puede seguir leyéndose normalmente con mm::readImg.

4.9.9.3 🧠 Fundamentación Teórica

Concepto Significado Impacto Visual
\(\text{dist}(y,x)\) Distancia morfológica hasta el borde más cercano según la métrica definida por \(b\) Los píxeles más internos reciben valores mayores
Valor máximo Píxel más distante del borde Aproxima el centro morfológico del objeto
Elemento estructurante ponderado Define los costos de desplazamiento entre píxeles vecinos Determina la métrica de distancia utilizada
Objetos finos Regiones estrechas del objeto Producen valores bajos de distancia

4.9.9.4 📦 Especificación de Entrada y Salida (VPL)

Entrada:

  • Línea 1: entero \(L\).
  • Línea 2: entero \(C\).
  • Línea 3: entero \(L_B\).
  • Línea 4: entero \(C_B\).
  • Siguientes \(L_B\) líneas: elementos enteros de la matriz \(b\).
  • Siguientes \(L\) líneas: elementos binarios (\(0\) o \(1\)) de la matriz \(f\).

⚠️ Nota de implementación: Los elementos de la matriz \(f\) (0 o 1) deben multiplicarse por 255 para generar una imagen binaria adecuada (\(0\) y \(255\)) antes de aplicar la Transformada de Distancia (TD).

Salida:

  • Matriz de la transformada de distancia en \(L\) filas y \(C\) columnas.

4.9.9.5 📌 Ejemplo

Entrada Salida Observación
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
Resultado de la transformada de distancia.

Nota: el valor -99 actúa como una aproximación práctica de \(-\infty\), impidiendo la propagación por las diagonales. De esta forma, solo los vecinos horizontal y vertical contribuyen a la distancia, produciendo la distancia de Manhattan.

🗺️ Simulador EP04_09: Transformada de Distancia Capas de Erosión

Haz clic en las celdas para dibujar tu propio objeto o selecciona una forma predefinida para calcular el mapa de distancias en cascada.

Mapa de Distancias Calculado
Figura 4.38: Simulador EP04_09: Transformada de Distância (Camadas de Erosión)
%%writefile EP04_09.cpp
// your solution
Overwriting EP04_09.cpp
TestSuite("EP04_09.cpp").run()
✔️ EP04_09.cases ya existe en casos/
📋 4 caso(s) cargado(s) de casos/EP04_09.cases

🔍 Probando C++: EP04_09.cpp
⚠️ EP04_09.cpp: archivo vacío (menos de 3 líneas). Pruebas omitidas.

4.9.10 EP04_10 🪙 Separación de Blobs, Etiquetado y Descriptores

En una línea de producción de monedas, es común que las piezas se toquen entre sí en la cinta transportadora, formando una única mancha conectada en la imagen — un conteo ingenuo erraría el total. La solución clásica combina operaciones morfológicas y análisis de conectividad: primero una erosión reduce o rompe conexiones frágiles entre objetos, y luego el etiquetado de componentes conectados separa cada objeto en una región distinta. Finalmente, descriptores geométricos (área y caja delimitadora) resumen cada componente encontrado.

Ver en Figura 4.39 una simulación de este EP.

4.9.10.1 📋 Directrices de Implementación

  1. Dimensiones de la imagen: leer los enteros \(L\) (filas) y \(C\) (columnas) de \(f\).

  2. Dimensiones de \(B\): leer los enteros \(L_B\) (filas) y \(C_B\) (columnas) del elemento estructurante.

  3. Elemento estructurante: leer la matriz \(B\), que contiene valores \(0\) o \(1\), fila a fila.

  4. Datos: leer la matriz binaria \(f\) (valores \(0\) o \(1\)), fila a fila.

  5. Separación: calcular \[ f_{ero} = f \ominus B \] usando erosión binaria plana (como en el EP04_04), eliminando conexiones frágiles entre objetos.

  6. Etiquetado: sobre \(f_{ero}\), identificar componentes conectados usando conectividad definida por la vecindad \(B\). El etiquetado debe seguir un barrido raster: al encontrar un píxel \(1\) aún no etiquetado, asignar una nueva etiqueta entera creciente a partir de 1 y propagar esa etiqueta a toda la región conectada.

  7. Descriptores: para cada etiqueta \(k\), calcular:

    • Área: número de píxeles pertenecientes a la etiqueta;
    • Caja delimitadora: \[(y_{min}, x_{min}, y_{max}, x_{max})\]
  8. Salida: mostrar el número total de etiquetas y, a continuación, una línea por etiqueta en el formato: \[ k,\ \text{área},\ y_{min},\ x_{min},\ y_{max},\ x_{max} \]

4.9.10.2 📌 Restricciones Computacionales

  • La erosión debe aplicarse antes del etiquetado.
  • La conectividad es fija y está definida por la vecindad anterior.
  • El elemento estructurante \(B\) no interfiere en la conectividad del etiquetado.
  • Sin padding en ninguna etapa.
  • El orden de las etiquetas sigue la primera detección en el barrido raster.

4.9.10.3 🧠 Fundamentación Teórica

Concepto Significado Impacto
Puente fino Conexión estrecha entre objetos Puede ser eliminado por la erosión morfológica
Conectividad Definida por el conjunto \[\mathcal{N}(y,x)\] Determina qué píxeles pertenecen al mismo componente
Área Número de píxeles por componente Estimación directa del tamaño del objeto
Caja delimitadora Extensión espacial de la etiqueta Resumen geométrico del componente

4.9.10.4 📦 Especificación de Entrada y Salida (VPL)

Entrada:

  • Línea 1: entero \(L\)
  • Línea 2: entero \(C\)
  • Línea 3: entero \(L_B\)
  • Línea 4: entero \(C_B\)
  • Siguientes \(L_B\) líneas: matriz \(B\)
  • Siguientes \(L\) líneas: matriz \(f\)

Salida:

  • Línea 1: número total de etiquetas encontradas
  • Líneas siguientes: \[ k,\ \text{área},\ y_{min},\ x_{min},\ y_{max},\ x_{max} \]
🪙 Simulador EP04_10: Monedas Pegadas → Separadas → Contadas erosión + etiqueta + descriptores

Ajusta el grosor del puente entre las monedas y observa cómo la erosión morfológica separa los objetos para el conteo y la extracción de descriptores (área y cuadro delimitador).


1 píxel
f Original (Pegadas)
Tras Erosión + Etiquetas
Figura 4.39: Simulador EP04_10: Separación de Blobs, Etiquetado y Descriptores
%%writefile EP04_10.cpp
// your solution
Overwriting EP04_10.cpp
TestSuite("EP04_10.cpp").run()
✔️ EP04_10.cases ya existe en casos/
📋 4 caso(s) cargado(s) de casos/EP04_10.cases

🔍 Probando C++: EP04_10.cpp
⚠️ EP04_10.cpp: archivo vacío (menos de 3 líneas). Pruebas omitidas.