3  Operaciones Espaciales: Intensidad, Histograma y Filtrado

Este capítulo profundiza en el procesamiento de imágenes en el dominio espacial, partiendo de la manipulación directa de píxeles e histogramas para el realce de contraste, hasta la aplicación de filtros locales por convolución para suavizado, reducción de ruido y detección de bordes. El objetivo es desarrollar la intuición matemática y computacional que sustenta gran parte de los algoritmos modernos de Visión Computacional.

3.1 Objetivos

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

  • Manipular intensidad y píxeles: Ejecutar operaciones aritméticas saturadas (mm::addm, mm::subm) y lógicas bit a bit (mm::band, mm::bor, mm::bnot) para combinación y selección de regiones de interés (ROI), y aplicar alpha blending (mm::blend) para fusión ponderada de imágenes;
  • Procesar histogramas: Interpretar el histograma como diagnóstico tonal y aplicar ecualización global mediante CDF (mm::equalize); en la ruta Python, también la ecualización adaptativa (CLAHE) y la especificación de histograma para transferencia de perfil tonal entre imágenes;
  • Comprender fundamentos espaciales: Entender vecindad, padding de borde (mm::pad) y la diferencia entre correlación cruzada (mm::conv) y convolución — incluyendo por qué kernels asimétricos como el de Sobel producen resultados distintos en las dos operaciones;
  • Aplicar filtrado de suavizado: Usar el filtro de media (mm::blur, o mm::conv con kernel uniforme) y el filtro Gaussiano (mm::gaussian) para reducción de ruido, comprendiendo la ventaja de la ponderación radial y de la separabilidad Gaussiana;
  • Aplicar filtrado de realce: Usar el Laplaciano \(w_4\) y \(w_8\) (mm::laplacian) para realce isotrópico de bordes, el operador de Sobel (mm::sobel) para la magnitud del gradiente — y, en la ruta Python, la descomposición direccional \(G_x\), \(G_y\) y ángulo —, y el Unsharp Masking (mm::usm) para amplificación de alta frecuencia controlada por el parámetro \(k\);
  • Utilizar filtros de orden: Aplicar el filtro de la mediana (mm::median) para eliminación de ruido sal y pimienta, comprendiendo por qué su naturaleza no lineal y la robustez a outliers lo hacen superior a los filtros lineales en ese escenario;
  • Resolver problemas prácticos: Encadenar técnicas en pipelines de preprocesamiento (ecualización → Gaussiano → Canny; con CLAHE en lugar de la ecualización en la ruta Python) y usar las funciones de morph (mm::conv, mm::histImg, mm::equalize, mm::drawImgKernel) para análisis y visualización didáctica de cada etapa.

3.2 Operaciones a Nivel de Intensidad

El nivel más elemental de procesamiento de imágenes actúa directamente sobre los valores de los píxeles, sin considerar la vecindad. Estas operaciones —llamadas transformaciones de punto (point operations)— son las más rápidas computacionalmente y forman la base para técnicas más complejas.

Formalmente, una transformación de punto puede describirse como:

\[ g(x,y) = T[f(x,y)] \tag{3.1}\]

donde \(f(x,y)\) es la imagen de entrada, \(g(x,y)\) es la salida y \(T\) es una función aplicada a cada píxel individualmente.

3.2.1 Preparando el Entorno Práctico

El siguiente bloque carga la biblioteca morph del repositorio (el módulo morph.py y, en la ruta C++, también el morph.hpp utilizado en el #include de las celdas compiladas).

import os, urllib.request

os.makedirs("tmp/state", exist_ok=True)  # artefatos de build de la pista 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 la pista C++: `mm` (morph.py) es usado por los
# simuladores, por la visualización de las figuras que el binario C++ genera y por el
# estado mm::Image entre celdas. cpp=True también descarga la pista compilada
# (morph.hpp + stb_image*.h), usada en el #include de las celdas %%writefile *.cpp.
import config
config.setup(cpp=True)
from morph import mm
import numpy as np
✅ Entorno listo. Morph: 1.1.9 | OpenCV: 5.0.0

Como objeto de estudio a lo largo de este capítulo, utilizaremos las imágenes de vida silvestre presentadas en las Figura 3.1 y Figura 3.3.. A partir de ellas, exploraremos operaciones espaciales sobre intensidad, histogramas y filtrado, analizando sus efectos en el realce, la suavización, la reducción de ruido y la detección de bordes, con el fin de comprender los fundamentos matemáticos y computacionales del PDI.

%%writefile tmp/fig_03_mandrill.cpp
#define MM_OUT "tmp/fig_03_mandrill.png"
//| label: fig-03-mandrill
//| fig-cap: "*Mandrill* (*Mandrillus sphinx*) fotografado em ambiente natural na África do Sul. Crédito: Carlos Guilherme Rodrigues (CC BY-SA 3.0)."
//| echo: true

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

int main() {
    std::string base    = "https://upload.wikimedia.org/wikipedia/commons";
    std::string arquivo = "Carlos_Guilherme_Rodrigues_%2876515283%29.jpeg";
    std::string url     = base + "/9/9b/" + arquivo;

    mm::Image img_color = mm::read(url);
    mm::Image img_gray  = mm::gray(img_color);

    mm::show(img_color, MM_OUT);

    
// [pdi:state-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp/state");
mm::write(img_gray, "tmp/state/img_gray_8.png");
// [pdi:state-io:end]
return 0;
}
Overwriting tmp/fig_03_mandrill.cpp
!g++ -I. -std=c++17 tmp/fig_03_mandrill.cpp -o tmp/fig_03_mandrill \
  && ./tmp/fig_03_mandrill \
  && test -f "tmp/fig_03_mandrill.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_mandrill.png"
try:
    mm.show(mm.read("tmp/fig_03_mandrill.png"))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_mandrill.png (ver a versao Python)")
Figura 3.1: Mandrill (Mandrillus sphinx) fotografado em ambiente natural na África do Sul. Crédito: Carlos Guilherme Rodrigues (CC BY-SA 3.0).

3.2.2 Operaciones Aritméticas

Las operaciones aritméticas entre imágenes se utilizan ampliamente en PDI para combinar, comparar o realzar información. La resta de imágenes es especialmente poderosa para detectar diferencias entre dos cuadros — por ejemplo, en la eliminación del fondo estático en cámaras de vigilancia:

\[ g(x,y) = f_1(x,y) - f_2(x,y) \tag{3.2}\]

La suma saturada limita el resultado al intervalo \([0, 255]\): los valores superiores a 255 se fijan en 255, evitando el overflow silencioso del tipo uint8 (p. ej., \(200 + 100 = 44\) en lugar de 300). La resta saturada aplica el mismo principio por el lado inferior: los valores negativos se fijan en 0.

AdvertenciaSaturación y overflow

Las operaciones aritméticas en uint8 sufren overflow silencioso: \(200 + 100 = 44\) (no 300). mm::addm y mm::subm realizan la saturación automática, fijando el resultado en \([0, 255]\). El blending utiliza pesos fraccionarios: mm::blend opera internamente en punto flotante y solo entonces redondea y satura a uint8.

La Figura 3.2 demuestra la suma de una constante (aclarado) y la resta de una constante (oscurecimiento con saturación en 0).

%%writefile tmp/fig_03_aritmetica.cpp
#define MM_OUT "tmp/fig_03_aritmetica.png"
//| label: fig-03-aritmetica
//| fig-cap: "Operações aritméticas saturadas: adição de constante (clareamento) e subtração de constante (escurecimento com saturação em 0)."
//| 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_gray = mm::_read_state("tmp/state/img_gray_8.png");
// [pdi:state-io:end]

    int fundo = 60;

    mm::Image img_add = mm::addm(img_gray, fundo);
    mm::Image img_sub = mm::subm(img_gray, fundo);

    mm::show(
        std::vector<mm::Image>{img_gray, img_add, img_sub},
        MM_OUT,
        std::vector<std::string>{"Original", "addm (+60)", "subm (−60)"},
        3
    );

    
// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(img_gray, "tmp/fig_03_aritmetica_0.png");
mm::write(img_add, "tmp/fig_03_aritmetica_1.png");
mm::write(img_sub, "tmp/fig_03_aritmetica_2.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_03_aritmetica.cpp
!g++ -I. -std=c++17 tmp/fig_03_aritmetica.cpp -o tmp/fig_03_aritmetica \
  && ./tmp/fig_03_aritmetica \
  && test -f "tmp/fig_03_aritmetica.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_aritmetica.png"
[1] Original
[2] addm (+60)
[3] subm (−60)
try:
    mm.show(
        [
            mm.read("tmp/fig_03_aritmetica_0.png"),
            mm.read("tmp/fig_03_aritmetica_1.png"),
            mm.read("tmp/fig_03_aritmetica_2.png"),
        ],
        titles=[
            'Original',
            'addm (+60)',
            'subm (−60)',
        ],
        cols=3,
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_aritmetica_0.png (ver a versao Python)")
Figura 3.2: Operações aritméticas saturadas: adição de constante (clareamento) e subtração de constante (escurecimento com saturação em 0).

3.2.3 Mezcla Ponderada (Alpha Blending)

La mezcla ponderada (alpha blending) combina dos imágenes utilizando pesos complementarios \(\alpha\) y \((1-\alpha)\):

\[ g(x,y) = \alpha\,f_1(x,y) + (1-\alpha)\,f_2(x,y), \quad \alpha \in [0,1] \tag{3.3}\]

Cuando \(\alpha = 1\), se obtiene únicamente la imagen \(f_1\); cuando \(\alpha = 0\), solo \(f_2\). Los valores intermedios producen una transición suave entre ambas, siendo ampliamente utilizados en composición de imágenes, superposición de capas, marcas de agua y efectos de fusión visual.

Para que la combinación produzca un resultado coherente, es necesario alinear previamente las regiones de interés. En la Figura 3.4, se recorta el rostro del leopardo con mm::crop(img_leop_gray, 250, H-300, 100, W-200) y la región facial del mandril con mm::crop(img_gray, 100, 400, 380, 530), de modo que los ojos y la estructura facial queden aproximadamente alineados. El recorte del leopardo se redimensiona entonces (mm::resize) a las dimensiones del mandril antes de la mezcla.

mm::blend realiza la operación en punto flotante — evitando overflow en los cálculos con pesos fraccionarios — y solo entonces redondea y satura el resultado a uint8.

%%writefile tmp/fig_03_leopardo.cpp
#define MM_OUT "tmp/fig_03_leopardo.png"
// Compile: g++ -std=c++17 -o programa programa.cpp -I. -lcurl -lpng -ljpeg

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

int main() {
    //| label: fig-03-leopardo
    //| fig-cap: "Retrato de um leopardo (*Panthera pardus*) em ambiente natural. Crédito: C. Brück (CC BY-SA 4.0)."
    //| echo: true

    mm::Image img_leop = mm::read("https://upload.wikimedia.org/wikipedia/commons/9/92/Leopard_%28Panthera_pardus%29_portrait.jpg");
    mm::Image img_leop_gray = mm::gray(img_leop);

    mm::show(img_leop, MM_OUT);

    
// [pdi:state-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp/state");
mm::write(img_leop, "tmp/state/img_leop_12.png");
mm::write(img_leop_gray, "tmp/state/img_leop_gray_12.png");
// [pdi:state-io:end]
return 0;
}
Overwriting tmp/fig_03_leopardo.cpp
!g++ -I. -std=c++17 tmp/fig_03_leopardo.cpp -o tmp/fig_03_leopardo \
  && ./tmp/fig_03_leopardo \
  && test -f "tmp/fig_03_leopardo.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_leopardo.png"
try:
    mm.show(mm.read("tmp/fig_03_leopardo.png"))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_leopardo.png (ver a versao Python)")
Figura 3.3: Retrato de um leopardo (Panthera pardus) em ambiente natural. Crédito: C. Brück (CC BY-SA 4.0).
%%writefile tmp/fig_03_blend.cpp
#define MM_OUT "tmp/fig_03_blend.png"
//| label: fig-03-blend
//| fig-cap: "*Alpha blending* entre recortes alinhados de mandrill e do leopardo (@fig-03-leopardo) para diferentes valores de α. Em α=1 vê-se apenas mandrill; em α=0, apenas o leopardo; valores intermediários fundem os olhares das duas imagens proporcionalmente."
//| echo: true
//| output: true

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

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

    // recortes alinhados: rosto do leopardo e região facial do mandril
    mm::Image leo = mm::crop(img_leop_gray, 250, img_leop_gray.h - 300, 100, img_leop_gray.w - 200);
    mm::Image mandrill = mm::crop(img_gray, 100, 400, 380, 530);
    mm::Image leo_r = mm::resize(leo, mandrill.w, mandrill.h, "bilinear");

    mm::show(
        std::vector<mm::Image>{mm::blend(mandrill, leo_r, 1.0), mm::blend(mandrill, leo_r, 0.8),
                               mm::blend(mandrill, leo_r, 0.6), mm::blend(mandrill, leo_r, 0.4),
                               mm::blend(mandrill, leo_r, 0.2), mm::blend(mandrill, leo_r, 0.0)},
        MM_OUT,
        std::vector<std::string>{"α=1.0", "α=0.8", "α=0.6", "α=0.4", "α=0.2", "α=0.0"},
        6
    );

    return 0;
}
Overwriting tmp/fig_03_blend.cpp
!g++ -I. -std=c++17 tmp/fig_03_blend.cpp -o tmp/fig_03_blend \
  && ./tmp/fig_03_blend \
  && test -f "tmp/fig_03_blend.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_blend.png"
[1] α=1.0
[2] α=0.8
[3] α=0.6
[4] α=0.4
[5] α=0.2
[6] α=0.0
try:
    mm.show(mm.read("tmp/fig_03_blend.png"))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_blend.png (ver a versao Python)")
Figura 3.4: Alpha blending entre recortes alinhados de mandrill e do leopardo (Figura 3.3) para diferentes valores de α. Em α=1 vê-se apenas mandrill; em α=0, apenas o leopardo; valores intermediários fundem os olhares das duas imagens proporcionalmente.

3.2.4 Operaciones Lógicas y Máscaras Bit a Bit

Las operaciones lógicas bit a bit (AND, OR y NOT) actúan directamente sobre los bits de cada píxel y son la base para la creación y aplicación de máscaras (masks) —imágenes binarias con solo 0 (negro) y 255 (blanco) utilizadas para aislar Regiones de Interés (ROI).

El comportamiento de cada operación se deriva de la representación binaria del 255 (11111111) y del 0 (00000000):

  • AND con la máscara: donde \(m = 255\), los bits originales se preservan; donde \(m = 0\), el píxel se pone a cero. Resultado: recorte de la ROI. \[g(x,y) = f(x,y) \;\text{AND}\; m(x,y) \tag{3.4}\]
  • OR con la máscara: donde \(m = 255\), el píxel se fuerza a blanco; donde \(m = 0\), se mantiene el valor original. Resultado: iluminación de la ROI.
  • NOT (sin máscara): invierte todos los bits (\(g = 255 - f\)), produciendo el negativo fotográfico de la imagen.

La Figura 3.5 ilustra las tres operaciones aplicadas a la imagen del mandril con una máscara circular.

%%writefile tmp/fig_03_logica.cpp
#define MM_OUT "tmp/fig_03_logica.png"
//| label: fig-03-logica
//| fig-cap: "Operações lógicas bit a bit com máscara circular: AND (isolamento da ROI), OR (iluminação da ROI) e NOT (negativo)."
//| 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_gray = mm::_read_state("tmp/state/img_gray_8.png");
// [pdi:state-io:end]

    // img_gray ya está inicializado (proporcionado automáticamente)

    int h = img_gray.h;
    int w = img_gray.w;

    // Máscara circular preenchida, centrada na imagem (mm.circle desenha o
    // disco; a versão didática, teste de raio pixel a pixel, é mm.circle0).
    mm::Image mask_circ(h, w);
    int radius = std::min(h, w) / 3 - 10;
    mask_circ = mm::circle(mask_circ, w / 2, h / 2, radius, 255, -1);

    // Operações via morph
    mm::Image img_not = mm::bnot(img_gray);            // NOT: negativo fotográfico
    mm::Image img_and = mm::band(img_gray, mask_circ); // preserva apenas a ROI circular
    mm::Image img_or  = mm::bor(img_gray, mask_circ);  // ilumina a região da máscara

    mm::show(
        std::vector<mm::Image>{img_gray, img_and, img_or, img_not},
        MM_OUT,
        std::vector<std::string>{"Original", "AND (ROI circular)", "OR (ilumina ROI)", "NOT (negativo)"},
        4
    );

    
// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(img_gray, "tmp/fig_03_logica_0.png");
mm::write(img_and, "tmp/fig_03_logica_1.png");
mm::write(img_or, "tmp/fig_03_logica_2.png");
mm::write(img_not, "tmp/fig_03_logica_3.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_03_logica.cpp
!g++ -I. -std=c++17 tmp/fig_03_logica.cpp -o tmp/fig_03_logica \
  && ./tmp/fig_03_logica \
  && test -f "tmp/fig_03_logica.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_logica.png"
[1] Original
[2] AND (ROI circular)
[3] OR (ilumina ROI)
[4] NOT (negativo)
try:
    mm.show(
        [
            mm.read("tmp/fig_03_logica_0.png"),
            mm.read("tmp/fig_03_logica_1.png"),
            mm.read("tmp/fig_03_logica_2.png"),
            mm.read("tmp/fig_03_logica_3.png"),
        ],
        titles=[
            'Original',
            'AND (ROI circular)',
            'OR (ilumina ROI)',
            'NOT (negativo)',
        ],
        cols=4,
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_logica_0.png (ver a versao Python)")
Figura 3.5: Operações lógicas bit a bit com máscara circular: AND (isolamento da ROI), OR (iluminação da ROI) e NOT (negativo).

3.3 Histograma de Imágenes

El histograma de una imagen en tonos de gris es una función discreta que describe la distribución de frecuencias de las intensidades:

\[ h(r_k) = n_k, \quad k = 0, 1, \ldots, L-1 \tag{3.5}\]

donde \(r_k\) es el \(k\)-ésimo nivel de intensidad, \(n_k\) es el número de píxeles con esa intensidad y \(L\) es el total de niveles (típicamente 256 para 8 bits). El histograma normalizado estima la probabilidad de cada nivel:

\[ p(r_k) = \frac{n_k}{MN} \tag{3.6}\]

donde \(MN\) es el total de píxeles. Por ser una estadística global, el histograma no contiene información posicional, pero revela características esenciales como brillo medio, contraste y distribución tonal. En la práctica, mm::hist(img) devuelve el vector de conteos \(h(r_k)\), que sirve tanto para visualización (mediante mm::histImg) como para cálculos como función de distribución acumulada (CDF) y ecualización.

NotaInterpretación del Histograma
  • Estrecho a la izquierda: imagen subexpuesta (oscura).
  • Estrecho a la derecha: imagen sobreexpuesta (clara).
  • Concentrado en el centro: bajo contraste.
  • Distribuido por todo el rango: alto contraste, buena utilización de los tonos disponibles.

La Figura 3.6 presenta el histograma de la imagen del mandril, así como versiones oscurecida (mm::subm) y aclarada (mm::addm). Se observa el desplazamiento de la distribución de intensidades hacia la izquierda y hacia la derecha, respectivamente. Nótese que el intervalo representado en el eje \(x\) no corresponde necesariamente a todo el rango de 0 a 255.

%%writefile tmp/fig_03_histograma.cpp
#define MM_OUT "tmp/fig_03_histograma.png"
//| label: fig-03-histograma
//| fig-cap: "Histogramas da imagem original, de uma versão escurecida (−80) e de uma clareada (+80). A subtração/adição satura em 0 e 255."
//| 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_gray = mm::_read_state("tmp/state/img_gray_8.png");
// [pdi:state-io:end]

    // img_gray is provided already initialized

    mm::Image img_dark = mm::subm(img_gray, 80);
    mm::Image img_high = mm::addm(img_gray, 80);

    mm::show(
        std::vector<mm::Image>{img_gray, img_dark, img_high,
             mm::histImg(img_gray), mm::histImg(img_dark), mm::histImg(img_high)},
        MM_OUT,
        std::vector<std::string>{"Original", "Escurecida (-80)", "Clareada (+80)",
            "Histograma - original", "Histograma - escurecida", "Histograma - clareada"},
        3
    );

    return 0;
}
Overwriting tmp/fig_03_histograma.cpp
!g++ -I. -std=c++17 tmp/fig_03_histograma.cpp -o tmp/fig_03_histograma \
  && ./tmp/fig_03_histograma \
  && test -f "tmp/fig_03_histograma.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_histograma.png"
[1] Original
[2] Escurecida (-80)
[3] Clareada (+80)
[4] Histograma - original
[5] Histograma - escurecida
[6] Histograma - clareada
try:
    mm.show(mm.read("tmp/fig_03_histograma.png"))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_histograma.png (ver a versao Python)")
Figura 3.6: Histogramas da imagem original, de uma versão escurecida (−80) e de uma clareada (+80). A subtração/adição satura em 0 e 255.

3.3.1 Ecualización de Histograma

La ecualización de histograma redistribuye las intensidades para que el histograma resultante sea lo más uniforme posible. El mapeo está dado por la función de distribución acumulada (CDF):

\[ s_k = T(r_k) = (L-1)\sum_{j=0}^{k} p(r_j) = \frac{L-1}{MN}\sum_{j=0}^{k} n_j \tag{3.7}\]

La transformación es monótona: los niveles frecuentes reciben intervalos mayores en el dominio de salida (mayor separación → más contraste), mientras que los niveles raros se comprimen.

El algoritmo completo, en cinco etapas, se presenta en la Tabla 3.1.

Tabla 3.1: Algoritmo de ecualización de histograma.
Etapa Operación Fórmula
1 Histograma \(h[k] \leftarrow\) número de píxeles con intensidad \(k\), \(k=0\ldots L-1\)
2 Probabilidad \(p[k] \leftarrow h[k] / MN\)
3 CDF \(\text{cdf}[k] \leftarrow \sum_{j=0}^{k} p[j]\) (suma acumulada)
4 Look-Up Table (mapeo) \(\text{lut}[k] \leftarrow \text{round}(\text{cdf}[k] \times (L-1))\)
5 Aplicación \(g[i,j] \leftarrow \text{lut}[f[i,j]]\) (para todo píxel)

Observe en la Figura 3.7 que la ecualización redistribuye los tonos existentes a posiciones más espaciadas en el rango \([0, L-1]\), pero no crea tonos nuevos — la imagen ecualizada continúa con exactamente 3 tonos distintos, ahora en \(\{1, 5, 7\}\) en lugar de \(\{2, 3, 4\}\).

%%writefile tmp/fig_03_equalizacao_didatica.cpp
#define MM_OUT "tmp/fig_03_equalizacao_didatica.png"
//| label: fig-03-equalizacao-didatica
//| fig-cap: "Equalização de histograma numa imagem 5×5 de 3 bits (L=8): tons concentrados em {2,3,4} são redistribuídos pela CDF. *mm::equalize(img, 3)* faz o mapeamento."
//| echo: true
//| output: true
#include "morph.hpp"
#include <iostream>
#include <vector>

int main() {
    mm::Image img5(5, 5);
    int vals[5][5] = {{3, 4, 2, 3, 4},
                      {4, 3, 3, 4, 3},
                      {2, 3, 4, 3, 2},
                      {3, 4, 3, 2, 3},
                      {4, 3, 2, 3, 4}};
    for (int y = 0; y < 5; y++) {
        for (int x = 0; x < 5; x++) {
            img5.at(y, x) = vals[y][x];
        }
    }

    mm::Image img5_eq = mm::equalize(img5, 3);   // L = 2^3 = 8

    std::cout << "Imagem original 5x5 (3 bits):\n";
    std::cout << mm::drawImg(img5);
    std::cout << "Imagem equalizada 5x5:\n";
    std::cout << mm::drawImg(img5_eq);

    mm::show(std::vector<mm::Image>{img5, img5_eq, mm::histImg(img5), mm::histImg(img5_eq)},
             MM_OUT,
             std::vector<std::string>{"Original", "Equalizada", "Histograma - original", "Histograma - equalizada"},
             2);
    return 0;
}
Overwriting tmp/fig_03_equalizacao_didatica.cpp
!g++ -I. -std=c++17 tmp/fig_03_equalizacao_didatica.cpp -o tmp/fig_03_equalizacao_didatica \
  && ./tmp/fig_03_equalizacao_didatica \
  && test -f "tmp/fig_03_equalizacao_didatica.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_equalizacao_didatica.png"
Imagem original 5x5 (3 bits):
3 4 2 3 4 
4 3 3 4 3 
2 3 4 3 2 
3 4 3 2 3 
4 3 2 3 4 
Imagem equalizada 5x5:
5 7 1 5 7 
7 5 5 7 5 
1 5 7 5 1 
5 7 5 1 5 
7 5 1 5 7 
[1] Original
[2] Equalizada
[3] Histograma - original
[4] Histograma - equalizada
try:
    mm.show(mm.read("tmp/fig_03_equalizacao_didatica.png"))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_equalizacao_didatica.png (ver a versao Python)")
Figura 3.7: Equalização de histograma numa imagem 5×5 de 3 bits (L=8): tons concentrados em {2,3,4} são redistribuídos pela CDF. mm::equalize(img, 3) faz o mapeamento.

Limitación: la ecualización global puede realzar en exceso los ruidos y producir contraste excesivo en regiones homogéneas. El CLAHE (Contrast Limited Adaptive Histogram Equalization) reduce este problema al aplicar la ecualización en bloques locales (tiles) y limitar la altura de los picos del histograma antes de la ecualización.

La Figura 3.8 compara la imagen original, la ecualización global mediante mm::equalize y el CLAHE de OpenCV, mostrando también los histogramas resultantes. A diferencia de la ecualización global, que utiliza una única transformación basada en la CDF de toda la imagen, el CLAHE adapta el contraste a cada región, siendo particularmente útil en imágenes con iluminación no uniforme.

En el ejemplo, se utilizó clipLimit=2.0 y tileGridSize=(32,32). El parámetro clipLimit define cuánto pueden crecer los picos del histograma local antes de ser recortados (clipped). En OpenCV, este valor es un factor relativo: el límite real se calcula aproximadamente como clipLimit × (número de píxeles del bloque / número de niveles de gris). Por ejemplo, en un bloque con 4096 píxeles y una imagen de 8 bits (256 niveles de gris), la frecuencia media por nivel es \(4096/256=16\). Así, clipLimit=2.0 permite picos de aproximadamente \(2\times16=32\) ocurrencias antes del recorte. Las ocurrencias excedentes no se descartan: se redistribuyen entre los demás niveles de gris del histograma, reduciendo la concentración excesiva en pocos niveles y evitando una amplificación exagerada del contraste local. Valores menores limitan más el contraste y reducen la amplificación del ruido, mientras que valores mayores permiten un realce más intenso, pero pueden introducir artefactos.

  • clipLimit=1.0: realce suave y conservador;
  • clipLimit=2.0: buen equilibrio entre contraste y naturalidad;
  • clipLimit=4.0: mayor énfasis en los detalles locales;
  • clipLimit=8.0: contraste agresivo, con posible amplificación del ruido.

Así, el CLAHE suele producir resultados más naturales que la ecualización global, especialmente en imágenes con sombras, reflejos o iluminación desigual.

%%writefile tmp/fig_03_equalizacao.cpp
#define MM_OUT "tmp/fig_03_equalizacao.png"
//| label: fig-03-equalizacao
//| fig-cap: "Equalização de histograma global (mm::equalize, via CDF) e os histogramas antes/depois. CLAHE (adaptativa) fica só na trilha Python — não tem equivalente em morph.hpp."
//| 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_gray = mm::_read_state("tmp/state/img_gray_8.png");
// [pdi:state-io:end]

    mm::Image img_eq = mm::equalize(img_gray);

    mm::show(
        std::vector<mm::Image>{img_gray, img_eq, mm::histImg(img_gray), mm::histImg(img_eq)},
        MM_OUT,
        std::vector<std::string>{"Original", "mm.equalize (CDF)", "Histograma - original", "Histograma - equalizado"},
        2
    );

    return 0;
}
Overwriting tmp/fig_03_equalizacao.cpp
!g++ -I. -std=c++17 tmp/fig_03_equalizacao.cpp -o tmp/fig_03_equalizacao \
  && ./tmp/fig_03_equalizacao \
  && test -f "tmp/fig_03_equalizacao.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_equalizacao.png"
[1] Original
[2] mm.equalize (CDF)
[3] Histograma - original
[4] Histograma - equalizado
try:
    mm.show(mm.read("tmp/fig_03_equalizacao.png"))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_equalizacao.png (ver a versao Python)")
Figura 3.8: Equalização de histograma global (mm::equalize, via CDF) e os histogramas antes/depois. CLAHE (adaptativa) fica só na trilha Python — não tem equivalente em morph.hpp.

3.3.2 Especificación de Histograma

Mientras que la ecualización impone una distribución uniforme, la especificación de histograma (histogram matching) permite que el histograma de la imagen de salida siga una distribución arbitraria — por ejemplo, el histograma de otra imagen de referencia.

El procedimiento involucra tres etapas:

  1. Calcular la CDF de la imagen de entrada: \(P_r(r_k)\).
  2. Calcular la CDF de la imagen de referencia: \(P_z(z_k)\).
  3. Para cada nivel \(r_k\), encontrar el nivel \(z\) que minimiza \(|P_z(z) - P_r(r_k)|\).

\[ T(r_k) = \arg\min_{z}\,|P_z(z) - P_r(r_k)| \tag{3.8}\]

En la Figura 3.9, transferimos el perfil tonal del leopardo (Figura 3.3) a la imagen del mandril — una aplicación directa del concepto visto en el blending: en lugar de fusionar píxeles, aquí fusionamos distribuciones tonales.

%%writefile tmp/fig_03_especificacao.cpp
#define MM_OUT "tmp/fig_03_especificacao.png"
//| label: fig-03-especificacao
//| fig-cap: "Especificação de histograma (mapear o mandril para o perfil tonal do leopardo) precisa da CDF inversa da referência — fica só na trilha Python. Aqui, a equalização global do mandril, como comparação."
//| 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_gray = mm::_read_state("tmp/state/img_gray_8.png");
mm::Image img_leop_gray = mm::_read_state("tmp/state/img_leop_gray_12.png");
// [pdi:state-io:end]

    mm::Image img_eq = mm::equalize(img_gray);

    mm::show(std::vector<mm::Image>{img_gray, img_leop_gray, img_eq},
             MM_OUT,
             std::vector<std::string>{"Mandril (original)", "Leopardo (referencia)", "Mandril equalizado"},
             3);

    
// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(img_gray, "tmp/fig_03_especificacao_0.png");
mm::write(img_leop_gray, "tmp/fig_03_especificacao_1.png");
mm::write(img_eq, "tmp/fig_03_especificacao_2.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_03_especificacao.cpp
!g++ -I. -std=c++17 tmp/fig_03_especificacao.cpp -o tmp/fig_03_especificacao \
  && ./tmp/fig_03_especificacao \
  && test -f "tmp/fig_03_especificacao.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_especificacao.png"
[1] Mandril (original)
[2] Leopardo (referencia)
[3] Mandril equalizado
try:
    mm.show(
        [
            mm.read("tmp/fig_03_especificacao_0.png"),
            mm.read("tmp/fig_03_especificacao_1.png"),
            mm.read("tmp/fig_03_especificacao_2.png"),
        ],
        titles=[
            'Mandril (original)',
            'Leopardo (referencia)',
            'Mandril equalizado',
        ],
        cols=3,
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_especificacao_0.png (ver a versao Python)")
Figura 3.9: Especificação de histograma (mapear o mandril para o perfil tonal do leopardo) precisa da CDF inversa da referência — fica só na trilha Python. Aqui, a equalização global do mandril, como comparação.

3.4 Fundamentos Espaciales: Vecindad, Convolución y Kernels

Las operaciones de filtrado espacial no actúan sobre un píxel aislado, sino sobre una vecindad que lo rodea. Para ello, se utiliza una pequeña matriz de coeficientes denominada kernel (o máscara), que recorre toda la imagen mediante una ventana deslizante (sliding window).

Las ventanas más comunes son de 3×3, 5×5 y 7×7. En una ventana de 3×3, por ejemplo, el píxel central se procesa junto con sus ocho vecinos inmediatos. En cada posición de la ventana, los valores de los píxeles se combinan con los coeficientes del kernel, produciendo un nuevo valor para el píxel central.

3.4.1 Vecindad

Considere una ventana de 3×3 centrada en el píxel \((x,y)\):

\[ \begin{bmatrix} (x-1,y-1) & (x,y-1) & (x+1,y-1) \\ (x-1,y) & (x,y) & (x+1,y) \\ (x-1,y+1) & (x,y+1) & (x+1,y+1) \end{bmatrix} \tag{3.9}\]

De forma general, una ventana de tamaño \((2a+1)\times(2b+1)\) abarca todos los píxeles situados hasta \(a\) posiciones en horizontal y hasta \(b\) posiciones en vertical con respecto al píxel central. Así, una ventana de 3×3 corresponde a \(a=b=1\), una ventana de 5×5 a \(a=b=2\), y así sucesivamente.

Matemáticamente, la vecindad se define como

\[ \mathcal{V}(x,y)= \{(x+s,\,y+t): -a\le s\le a,\,-b\le t\le b\} \tag{3.10}\]

3.4.2 Tratamiento de Bordes

Los píxeles cercanos a los bordes tienen parte de su vecindad fuera de la imagen. Para aplicar filtros en estas regiones, es necesario definir cómo se obtendrán los valores externos. Las tres estrategias más comunes (con la constante equivalente de OpenCV entre paréntesis) son:

  • Zero-padding (BORDER_CONSTANT): completa la región externa con ceros.
  • Replicación (BORDER_REPLICATE): repite el valor del píxel del borde.
  • Reflexión (BORDER_REFLECT_101): refleja los píxeles vecinos, sin repetir el del borde.

mm::conv usa la reflexión por defecto, ya que preserva mejor la continuidad de los niveles de gris y reduce artefactos en el tratamiento de los bordes.

El siguiente ejemplo compara las tres estrategias con mm::pad en una matriz 3×3. Observa cómo cada una rellena los píxeles externos necesarios para aplicar un filtro 3×3 también en las esquinas.

%%writefile tmp/mm_out_1.cpp
// Compile with: g++ -std=c++17 -o program program.cpp -I. -lm

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

int main() {
    // Construir la imagen 3x3
    mm::Image img(3, 3);
    int valores[3][3] = {{1,2,3},{4,5,6},{7,8,9}};
    for (int y = 0; y < 3; y++)
        for (int x = 0; x < 3; x++)
            img.at(y, x) = valores[y][x];

    std::vector<std::string> bordas = {"constant", "replicate", "reflect101"};

    std::cout << "Imagem original:\n";
    std::cout << mm::drawImg(img) << "\n";

    for (int k : {3, 5}) {
        int b = k / 2;
        std::cout << "=== Kernel " << k << "x" << k << " (padding b=" << b << ") ===\n";
        for (const auto& nome : bordas) {
            std::cout << nome << "\n";
            mm::Border border;
            if (nome == "constant")
                border = mm::Border::CONSTANT;
            else if (nome == "replicate")
                border = mm::Border::REPLICATE;
            else
                border = mm::Border::REFLECT101;
            std::cout << mm::drawImg(mm::pad(img, b, border)) << "\n";
        }
    }

    return 0;
}
Overwriting tmp/mm_out_1.cpp
!g++ -I. -std=c++17 tmp/mm_out_1.cpp -o tmp/mm_out_1 \
  && ./tmp/mm_out_1
Imagem original:
1 2 3 
4 5 6 
7 8 9 

=== Kernel 3x3 (padding b=1) ===
constant
0 0 0 0 0 
0 1 2 3 0 
0 4 5 6 0 
0 7 8 9 0 
0 0 0 0 0 

replicate
1 1 2 3 3 
1 1 2 3 3 
4 4 5 6 6 
7 7 8 9 9 
7 7 8 9 9 

reflect101
5 4 5 6 5 
2 1 2 3 2 
5 4 5 6 5 
8 7 8 9 8 
5 4 5 6 5 

=== Kernel 5x5 (padding b=2) ===
constant
0 0 0 0 0 0 0 
0 0 0 0 0 0 0 
0 0 1 2 3 0 0 
0 0 4 5 6 0 0 
0 0 7 8 9 0 0 
0 0 0 0 0 0 0 
0 0 0 0 0 0 0 

replicate
1 1 1 2 3 3 3 
1 1 1 2 3 3 3 
1 1 1 2 3 3 3 
4 4 4 5 6 6 6 
7 7 7 8 9 9 9 
7 7 7 8 9 9 9 
7 7 7 8 9 9 9 

reflect101
9 8 7 8 9 8 7 
6 5 4 5 6 5 4 
3 2 1 2 3 2 1 
6 5 4 5 6 5 4 
9 8 7 8 9 8 7 
6 5 4 5 6 5 4 
3 2 1 2 3 2 1 

Note que el resultado de un filtro puede variar significativamente según el tratamiento adoptado para los bordes de la imagen.

En morph.hpp, las funciones de filtrado (mm::conv, mm::blur, mm::gaussian, mm::laplacian, mm::usm) aplican padding por reflexión (mm::Border::REFLECT101) por defecto — el mismo comportamiento que cv2.filter2D. La variante didáctica mm::conv0 usa mm::Border::KEEP: los píxeles del borde mantienen el valor original, sin el filtro. En cambio, mm::sobel y mm::prewitt dejan el borde en cero (calculan solo el interior).

3.4.3 Correlación vs. Convolución

Existen dos mecanismos matemáticamente relacionados.

Correlación cruzada (cross-correlation) — el kernel se aplica directamente:

\[ g(x,y) = \sum_{s=-a}^{a}\sum_{t=-b}^{b} w(s,t)\,f(x+s,\,y+t) \tag{3.11}\]

Convolución bidimensional — el kernel se rota 180° antes de su aplicación:

\[ g(x,y) = \sum_{s=-a}^{a}\sum_{t=-b}^{b} w(s,t)\,f(x-s,\,y-t) \tag{3.12}\]

Para kernels simétricos (Gaussiano, Laplaciano, media) las dos operaciones producen resultados idénticos. Para kernels asimétricos (Sobel, Prewitt) la diferencia es significativa, como lo muestran los ejemplos a continuación.

3.4.3.1 Correlación (mm::conv)

%%writefile tmp/mm_out_2.cpp
// Converte código Python para C++ (usando morph.hpp)
// Compile com: g++ -std=c++17 programa.cpp -o programa $(pkg-config --cflags --libs opencv4)

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

int main() {
    // imagem 4x4 (uint8) e kernel assimétrico 3x3
    mm::Image img(4, 4);
    // Preenche com os valores da matriz
    {
        int valores[4][4] = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}, {13, 14, 15, 16}};
        for (int y = 0; y < 4; y++) {
            for (int x = 0; x < 4; x++) {
                img.at(y, x) = static_cast<unsigned char>(valores[y][x]);
            }
        }
    }

    mm::Kernel w{{0,1,2},{0,0,0},{0,0,0}};

    mm::Image corr = mm::conv(img, w, mm::Border::CONSTANT);  // zero fora da imagem

    std::cout << "Imagem original:\n";         std::cout << mm::drawImg(img);
    std::cout << "Kernel:\n";                  std::cout << mm::drawImg(w);
    std::cout << "Resultado da correlação:\n"; std::cout << mm::drawImg(corr);

    return 0;
}
Overwriting tmp/mm_out_2.cpp
!g++ -I. -std=c++17 tmp/mm_out_2.cpp -o tmp/mm_out_2 \
  && ./tmp/mm_out_2
Imagem original:
 1  2  3  4 
 5  6  7  8 
 9 10 11 12 
13 14 15 16 
Kernel:
   0    1    2 
   0    0    0 
   0    0    0 
Resultado da correlação:
 0  0  0  0 
 5  8 11  4 
17 20 23  8 
29 32 35 12 

mm::conv realiza correlación, es decir, aplica el kernel exactamente en la orientación proporcionada.

3.4.3.2 Convolución

%%writefile tmp/mm_out_3.cpp
// Compile: g++ -std=c++17 -o program program.cpp -I. && ./program
#include "morph.hpp"
#include <iostream>

int main() {
    // repetidos aqui para a célula ser independente
    mm::Image img(4, 4);
    img.at(0,0) = 1;   img.at(0,1) = 2;   img.at(0,2) = 3;   img.at(0,3) = 4;
    img.at(1,0) = 5;   img.at(1,1) = 6;   img.at(1,2) = 7;   img.at(1,3) = 8;
    img.at(2,0) = 9;   img.at(2,1) = 10;  img.at(2,2) = 11;  img.at(2,3) = 12;
    img.at(3,0) = 13;  img.at(3,1) = 14;  img.at(3,2) = 15;  img.at(3,3) = 16;

    mm::Kernel w{{0,1,2},{0,0,0},{0,0,0}};

    // kernel rotacionado 180° (equivale a np.rot90(w, 2))
    mm::Kernel w_conv{{0,0,0},{0,0,0},{2,1,0}};

    mm::Image conv = mm::conv(img, w_conv, mm::Border::CONSTANT);

    std::cout << "Imagem original:";            std::cout << mm::drawImg(img) << "\n";
    std::cout << "Kernel original:";            std::cout << mm::drawImg(w) << "\n";
    std::cout << "Kernel rotacionado 180°:";    std::cout << mm::drawImg(w_conv) << "\n";
    std::cout << "Resultado da convolução:";    std::cout << mm::drawImg(conv) << "\n";

    return 0;
}
Overwriting tmp/mm_out_3.cpp
!g++ -I. -std=c++17 tmp/mm_out_3.cpp -o tmp/mm_out_3 \
  && ./tmp/mm_out_3
Imagem original: 1  2  3  4 
 5  6  7  8 
 9 10 11 12 
13 14 15 16 

Kernel original:   0    1    2 
   0    0    0 
   0    0    0 

Kernel rotacionado 180°:   0    0    0 
   0    0    0 
   2    1    0 

Resultado da convolução: 5 16 19 22 
 9 28 31 34 
13 40 43 46 
 0  0  0  0 

La convolución utiliza el kernel rotado 180°. Para reproducir la definición matemática de convolución, se rota el kernel (aquí, [[0,1,2],[0,0,0],[0,0,0]] → [[0,0,0],[0,0,0],[2,1,0]]) antes de aplicar mm::conv.

3.4.4 El Papel del Kernel

Los coeficientes del kernel determinan completamente el efecto producido por el filtro, tal como se resume en la Tabla 3.2.

Tabla 3.2: Interpretación típica de los coeficientes del kernel.
Característica Efecto típico
Coeficientes positivos con suma 1 Suavizado (pasa-baja)
Suma igual a 0, con valores positivos y negativos Detección de bordes (pasa-alta)
Coeficiente central positivo dominante y vecinos negativos Realce de nitidez
Coeficientes asimétricos Gradiente direccional

Ejemplos:

Suavizado: \[ \frac{1}{9} \begin{bmatrix} 1&1&1\\ 1&1&1\\ 1&1&1 \end{bmatrix} \]

Detección de bordes: \[ \begin{bmatrix} -1&-1&-1\\ -1&8&-1\\ -1&-1&-1 \end{bmatrix} \]

Realce de nitidez: \[ \begin{bmatrix} 0&-1&0\\ -1&5&-1\\ 0&-1&0 \end{bmatrix} \]

Gradiente direccional (Sobel): \[ \begin{bmatrix} -1&0&1\\ -2&0&2\\ -1&0&1 \end{bmatrix} \]

La Figura 3.10 demuestra el mecanismo paso a paso: para cada posición de la ventana, se multiplica cada coeficiente del kernel por el píxel correspondiente de la vecindad y se suman los productos obtenidos. El resultado es exactamente el valor definido por la Ecuación 3.11 para esa posición de la imagen. Aunque las imágenes producidas por mm::conv0 y cv2.filter2D (o mm::conv) sean visualmente muy similares, la implementación basada en OpenCV es miles de veces más rápida, como se muestra a continuación.

AdvertenciaRendimiento: bucles de Python vs. operaciones vectorizadas

La función mm::conv0 implementa la correlación directamente en Python mediante bucles anidados. Aunque este enfoque es adecuado para fines didácticos, ejecuta un gran número de operaciones y se vuelve lento para imágenes más grandes.

En cambio, mm::conv utiliza cv2.filter2D, implementado en C++ y optimizado para operaciones matriciales. En el ejemplo presentado, la versión vectorizada fue más de 3000 veces más rápida que la implementación didáctica, produciendo un resultado visualmente equivalente.

Las diferencias numéricas observadas se concentran principalmente en los bordes de la imagen. En mm::conv0, los píxeles del borde permanecen inalterados, mientras que mm::conv utiliza una estrategia de reflexión de bordes (cv2.BORDER_REFLECT_101, estándar de cv2.filter2D).

Por ello, mm::conv0 debe utilizarse para comprender el algoritmo, mientras que mm::conv es la opción recomendada para aplicaciones prácticas.

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

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

    //| label: fig-03-convolucao-passo
    //| fig-cap: "Correlação com *kernel* de média 3×3: versão didática mm::conv0 (bordas preservadas) vs. mm::conv (borda refletida). A diferença se concentra nas bordas."
    //| echo: true
    //| output: true

    mm::Kernel w_mean = mm::Kernel::mean(3);
    mm::Image img_gray = img_leop_gray;

    mm::Image img_conv0 = mm::conv0(img_gray, w_mean);   // laços, bordas preservadas
    mm::Image img_conv  = mm::conv(img_gray, w_mean);    // borda refletida

    std::cout << "Correlacao no pixel central [251,251]:" << "\n";
    std::cout << "  original = " << (int)img_gray.at(251, 251) << "\n";
    std::cout << "  conv0    = " << (int)img_conv0.at(251, 251) << "\n";
    std::cout << "  conv     = " << (int)img_conv.at(251, 251) << "\n";

    mm::show(std::vector<mm::Image>{img_gray, img_conv0, img_conv},
            MM_OUT,
            std::vector<std::string>{"Original", "conv0 (laços)", "conv (vetorizado)"}, 3);

    
// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(img_gray, "tmp/fig_03_convolucao_passo_0.png");
mm::write(img_conv0, "tmp/fig_03_convolucao_passo_1.png");
mm::write(img_conv, "tmp/fig_03_convolucao_passo_2.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_03_convolucao_passo.cpp
!g++ -I. -std=c++17 tmp/fig_03_convolucao_passo.cpp -o tmp/fig_03_convolucao_passo \
  && ./tmp/fig_03_convolucao_passo \
  && test -f "tmp/fig_03_convolucao_passo.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_convolucao_passo.png"
Correlacao no pixel central [251,251]:
  original = 104
  conv0    = 102
  conv     = 102
[1] Original
[2] conv0 (laços)
[3] conv (vetorizado)
try:
    mm.show(
        [
            mm.read("tmp/fig_03_convolucao_passo_0.png"),
            mm.read("tmp/fig_03_convolucao_passo_1.png"),
            mm.read("tmp/fig_03_convolucao_passo_2.png"),
        ],
        titles=[
            'Original',
            'conv0 (laços)',
            'conv (vetorizado)',
        ],
        cols=3,
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_convolucao_passo_0.png (ver a versao Python)")
Figura 3.10: Correlação com kernel de média 3×3: versão didática mm::conv0 (bordas preservadas) vs. mm::conv (borda refletida). A diferença se concentra nas bordas.
%%writefile tmp/mm_out_4.cpp
#include "morph.hpp"
#include <iostream>
#include <filesystem>

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

    //| echo: false
    // A partir daqui o "sujeito" dos exemplos de filtragem passa a ser o
    // leopardo (mais textura e bordas que o mandril).
    mm::Image img_gray = mm::gray(img_leop);

    
// [pdi:state-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp/state");
mm::write(img_gray, "tmp/state/img_gray_45.png");
// [pdi:state-io:end]
return 0;
}
Overwriting tmp/mm_out_4.cpp
!g++ -I. -std=c++17 tmp/mm_out_4.cpp -o tmp/mm_out_4 \
  && ./tmp/mm_out_4

3.4.5 Ejemplo Numérico: Correlación Paso a Paso

Para hacer concreto el mecanismo de la Ecuación 3.11, considere el kernel de media 3×3 (\(a=b=1\), todos los coeficientes \(= 1/9 \approx 0{,}111\)) aplicado al patch 5×5 extraído de la imagen del leopardo. La Figura 3.11 muestra el patch con la cuadrícula y resalta en amarillo la ventana 3×3 centrada en el píxel \([1,1]\):

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

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

    //| label: fig-03-patch
    //| fig-cap: "*Patch* 5×5 extraído da imagem do leopardo (posição [250:255, 250:255]). A janela amarela destaca a vizinhança 3×3 centrada no pixel [1,1] onde a correlação será calculada."
    //| echo: true
    //| output: true

    mm::Image patch = mm::crop(img_gray, 250, 255, 250, 255);
    mm::Kernel B = mm::Kernel::ones(3);

    std::cout << "Patch 5×5 (intensidades):" << std::endl;
    std::cout << mm::drawImg(patch);

    mm::drawImgKernel(patch, B, 1, 1, MM_OUT, 40);

    return 0;
}
Overwriting tmp/fig_03_patch.cpp
!g++ -I. -std=c++17 tmp/fig_03_patch.cpp -o tmp/fig_03_patch \
  && ./tmp/fig_03_patch \
  && test -f "tmp/fig_03_patch.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_patch.png"
Patch 5×5 (intensidades):
 94  96 103 113 115 
107 104 103 107 114 
 98 102 112 117 116 
 81  91 112 122 118 
 85  91 110 119 121 
Processando pixel (x,y)=(1,1)  |  janela do kernel 3x3
 94  96 103 113 115 
107 104 103 107 114 
 98 102 112 117 116 
 81  91 112 122 118 
 85  91 110 119 121 
try:
    mm.show(mm.read("tmp/fig_03_patch.png"))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_patch.png (ver a versao Python)")
Figura 3.11: Patch 5×5 extraído da imagem do leopardo (posição [250:255, 250:255]). A janela amarela destaca a vizinhança 3×3 centrada no pixel [1,1] onde a correlação será calculada.

Para ilustrar el cálculo de la correlación, considere el píxel en la posición \([1,1]\) del patch 5×5 mostrado en la Figura 3.11.. Esa posición fue elegida solo por conveniencia didáctica, ya que posee una vecindad 3×3 completa a su alrededor.

Los valores de esa vecindad corresponden a la submatriz superior izquierda del patch:

\[ \text{vecindad} = \begin{bmatrix} 91 & 95 & 108 \\ 106 & 107 & 108 \\ 102 & 103 & 107 \end{bmatrix} \]

Aplicando la Ecuación 3.11 con el kernel de media:

\[ g[1,1] = \frac{ 91+95+108+106+107+108+102+103+107}{9} = \frac{927}{9} = 103 \]

El resultado (103) es ligeramente menor que el valor original del píxel central (107), pues la media incorpora vecinos de menor intensidad, produciendo el efecto de suavizado. En la práctica, el algoritmo inicia el procesamiento en \([0,0]\) y repite ese mismo cálculo para cada posición de la imagen, desplazando la ventana hasta cubrir todo el dominio.

3.5 Filtrado Espacial de Suavizado

Los filtros de suavizado (smoothing filters) atenúan variaciones bruscas de intensidad, reduciendo ruido y detalles de alta frecuencia. Son filtros paso-bajo — preservan las componentes de baja frecuencia (estructuras grandes) y atenúan las de alta frecuencia (ruido, bordes).

3.5.1 Filtro de Media (Box Filter)

El filtro de media utiliza un kernel uniforme de tamaño \(n \times n\), donde todos los coeficientes valen \(1/n^2\):

\[ w_{\text{media}} = \frac{1}{n^2} \begin{bmatrix} 1 & \cdots & 1 \\ \vdots & \ddots & \vdots \\ 1 & \cdots & 1 \end{bmatrix}_{n \times n} \tag{3.13}\]

Cada píxel de salida es la media aritmética de los \(n^2\) píxeles de su vecindario. Nótese que la suma de los coeficientes es siempre 1 — el brillo medio de la imagen se preserva. Los kernels más grandes producen un suavizado más agresivo, pero desenfocan progresivamente los bordes.

Figura 3.12 muestra el efecto del filtro de media con kernels \(3\times3\), \(7\times7\) y \(15\times15\) sobre un detalle de la imagen del leopardo. Los resultados se obtuvieron con mm::blur, que implementa el filtro de media mediante la función cv2.blur, equivalente a la convolución de la imagen con un kernel uniforme cuyos coeficientes son \(h(x,y)=1/N^2\); de forma equivalente, el mismo resultado puede obtenerse con mm::conv, calculando (\(g=f*h\)). A medida que el kernel aumenta, más píxeles contribuyen a cada valor de salida, intensificando el suavizado, reduciendo el ruido y haciendo que los detalles finos y los bordes se vuelvan progresivamente más borrosos.

%%writefile tmp/fig_03_media.cpp
#define MM_OUT "tmp/fig_03_media.png"
#include "morph.hpp"
#include <iostream>
#include <vector>
#include <string>

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

    // Detalhe da região do olho
    int y0 = 580, y1 = 740, x0 = 680, x1 = 900;
    mm::Image img_gray_crop = mm::crop(img_gray, y0, y1, x0, x1);

    std::vector<int> sizes = {3, 7, 15};
    std::vector<mm::Image> imgs;
    imgs.push_back(img_gray_crop);
    for (int k : sizes) {
        imgs.push_back(mm::blur(img_gray_crop, k)); // ou
        // mm::Kernel kernel = mm::Kernel::mean(k);
        // imgs.push_back(mm::conv(img_gray_crop, kernel));
    }
    std::vector<std::string> titles = {"Original"};
    for (int k : sizes) {
        titles.push_back("Média " + std::to_string(k) + "×" + std::to_string(k));
    }

    mm::show(imgs, MM_OUT, titles, 4);

    return 0;
}
Overwriting tmp/fig_03_media.cpp
!g++ -I. -std=c++17 tmp/fig_03_media.cpp -o tmp/fig_03_media \
  && ./tmp/fig_03_media \
  && test -f "tmp/fig_03_media.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_media.png"
[1] Original
[2] Média 3×3
[3] Média 7×7
[4] Média 15×15
try:
    mm.show(mm.read("tmp/fig_03_media.png"))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_media.png (ver a versao Python)")
Figura 3.12: Filtro de média com kernels de tamanho crescente (3×3, 7×7, 15×15). O borramento das bordas aumenta com o tamanho do kernel.

3.5.2 Filtro Gaussiano

El filtro Gaussiano pondera los píxeles de la vecindad de acuerdo con una función Gaussiana bidimensional:

\[ G(s,t) = \frac{1}{2\pi\sigma^2}\,e^{-\frac{s^2+t^2}{2\sigma^2}} \tag{3.14}\]

donde \(\sigma\) es la desviación estándar y controla el radio de influencia. Los píxeles más cercanos al centro tienen un peso mayor; los píxeles distantes se ignoran progresivamente.

La Figura 3.13 presenta el kernel Gaussiano \(5\times5\) generado para \(\sigma=1\). El kernel fue construido a partir del producto externo de dos vectores Gaussianos unidimensionales y posteriormente normalizado para que la suma de sus coeficientes sea igual a \(1\). Se observa que los mayores pesos se concentran en el centro de la matriz, decreciendo radialmente hacia los bordes. Esta distribución hace que los píxeles centrales tengan mayor influencia en el resultado del filtrado, contribuyendo a una suavización más natural y con mejor preservación de bordes que el filtro de media.

%%writefile tmp/fig_03_gauss_kernel.cpp
#define MM_OUT "tmp/fig_03_gauss_kernel.png"
#include "morph.hpp"
#include <iostream>
#include <vector>
#include <string>
#include <numeric>
#include <iomanip>

int main() {
    //| label: fig-03-gauss-kernel
    //| fig-cap: "*Kernel* Gaussiano 5×5 (σ=1): resposta ao impulso do filtro — pesos maiores no centro, decrescendo radialmente."
    //| echo: true
    //| output: true

    mm::Kernel w = mm::Kernel::gaussian(5, 1.0);   // mm::Kernel::gaussian(5, 1.0) na morph.hpp

    std::cout << "Kernel Gaussiano 5x5 (s=1), normalizado:" << "\n";
    for (int y = 0; y < 5; y++) {
        std::cout << "  ";
        for (int x = 0; x < 5; x++) {
            std::cout << std::fixed << std::setprecision(4) << w.at(y, x);
            if (x < 4) std::cout << "  ";
        }
        std::cout << "\n";
    }
    std::cout << "Peso central [2,2] = " << std::fixed << std::setprecision(4) << w.at(2, 2) << "   |   canto [0,0] = " << w.at(0, 0) << "\n";

    // Visualização: resposta do filtro Gaussiano a um impulso central
    mm::Image impulso(5, 5);
    impulso.at(2, 2) = 255;
    mm::drawImgPlt(mm::gaussian(impulso, 5, 1.0), MM_OUT);

    return 0;
}
Overwriting tmp/fig_03_gauss_kernel.cpp
!g++ -I. -std=c++17 tmp/fig_03_gauss_kernel.cpp -o tmp/fig_03_gauss_kernel \
  && ./tmp/fig_03_gauss_kernel \
  && test -f "tmp/fig_03_gauss_kernel.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_gauss_kernel.png"
Kernel Gaussiano 5x5 (s=1), normalizado:
  0.0030  0.0133  0.0219  0.0133  0.0030
  0.0133  0.0596  0.0983  0.0596  0.0133
  0.0219  0.0983  0.1621  0.0983  0.0219
  0.0133  0.0596  0.0983  0.0596  0.0133
  0.0030  0.0133  0.0219  0.0133  0.0030
Peso central [2,2] = 0.1621   |   canto [0,0] = 0.0030
 3  7 11  7  3 
 7 15 25 15  7 
11 25 41 25 11 
 7 15 25 15  7 
 3  7 11  7  3 
try:
    mm.show(mm.read("tmp/fig_03_gauss_kernel.png"))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_gauss_kernel.png (ver a versao Python)")
Figura 3.13: Kernel Gaussiano 5×5 (σ=1): resposta ao impulso do filtro — pesos maiores no centro, decrescendo radialmente.
NotaVentaja computacional de la separabilidad

Considere un kernel cuadrado de tamaño \(n \times n\). Si este filtro es separable (como el Gaussiano), la convolución 2D puede descomponerse en dos convoluciones 1D: una horizontal y otra vertical.

En ese caso, el costo por píxel pasa de aproximadamente \(O(n^2)\) operaciones (convolución 2D directa) a \(O(2n)\) operaciones (dos convoluciones 1D). Así, la complejidad se reduce de forma significativa, haciendo el procesamiento más eficiente.

En comparación con el filtro de media, el Gaussiano:

  • Preserva mejor los bordes — la ponderación radial suaviza sin crear transiciones abruptas;
  • No introduce anillos (ringing) en el dominio de la frecuencia, pues la Gaussiana es su propia transformada de Fourier (Capítulo 5);
  • Está controlado por \(\sigma\) — aumentar \(\sigma\) equivale a aumentar el radio de suavizado de forma continua y predecible.

La Figura 3.14 compara los filtros de media y gaussiano aplicados a la imagen del leopardo usando una ventana \(9\times9\). El filtro de media se implementó mediante convolución con un kernel uniforme, donde todos los \(81\) píxeles de la vecindad poseen el mismo peso (\(1/81\)), mientras que el filtro gaussiano se obtuvo con cv2.GaussianBlur, utilizando pesos definidos por una distribución gaussiana. Ambos reducen ruido y suavizan la imagen, pero el filtro gaussiano preserva mejor los bordes y los detalles locales, como puede observarse en la región ampliada del ojo.

La Figura 3.14 compara los filtros de media y gaussiano aplicados a un detalle de la imagen del leopardo con kernels \(9\times 9\). El filtro de media se obtuvo con mm::blur, equivalente a la convolución con un kernel uniforme cuyos coeficientes valen \(1/81\), mientras que el filtro gaussiano se obtuvo con mm::gaussian, equivalente a la convolución con un kernel generado a partir de una distribución gaussiana. Ambos promueven suavización y reducción de ruido, pero el filtro gaussiano asigna mayor peso a los píxeles centrales de la vecindad, preservando mejor los bordes y los detalles locales, como puede observarse en la región ampliada del ojo.

%%writefile tmp/fig_03_gauss.cpp
#define MM_OUT "tmp/fig_03_gauss.png"
// Compile: g++ -std=c++17 -O2 -o prog prog.cpp -lm && ./prog
#include "morph.hpp"
#include <iostream>
#include <vector>
#include <string>
#include <filesystem>

//| label: fig-03-gauss
//| fig-cap: "Comparação entre filtro de média e Gaussiano (*kernel* 9×9, σ=0). O Gaussiano preserva melhor as bordas, visível no detalhe do rosto."
//| echo: true
//| output: true

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

    // img_gray é fornecido automaticamente (não declarar)

    mm::Image img_media9 = mm::blur(img_gray, 9);
    mm::Image img_gauss9 = mm::gaussian(img_gray, 9, 0);

    // Detalhe da região do olho
    mm::Image img_gray_crop   = mm::crop(img_gray,    580, 740, 680, 900);
    mm::Image img_media9_crop = mm::crop(img_media9,  580, 740, 680, 900);
    mm::Image img_gauss9_crop = mm::crop(img_gauss9,  580, 740, 680, 900);

    mm::show(
        std::vector<mm::Image>{img_gray_crop, img_media9_crop, img_gauss9_crop},
        MM_OUT,
        std::vector<std::string>{"Detalhe: Original", "Média 9×9", "Gaussiano 9×9"},
        3
    );

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

// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(img_gray_crop, "tmp/fig_03_gauss_0.png");
mm::write(img_media9_crop, "tmp/fig_03_gauss_1.png");
mm::write(img_gauss9_crop, "tmp/fig_03_gauss_2.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_03_gauss.cpp
!g++ -I. -std=c++17 tmp/fig_03_gauss.cpp -o tmp/fig_03_gauss \
  && ./tmp/fig_03_gauss \
  && test -f "tmp/fig_03_gauss.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_gauss.png"
[1] Detalhe: Original
[2] Média 9×9
[3] Gaussiano 9×9
try:
    mm.show(
        [
            mm.read("tmp/fig_03_gauss_0.png"),
            mm.read("tmp/fig_03_gauss_1.png"),
            mm.read("tmp/fig_03_gauss_2.png"),
        ],
        titles=[
            'Detalhe: Original',
            'Média 9×9',
            'Gaussiano 9×9',
        ],
        cols=3,
        figsize=(12, 8),
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_gauss_0.png (ver a versao Python)")
Figura 3.14: Comparação entre filtro de média e Gaussiano (kernel 9×9, σ=0). O Gaussiano preserva melhor as bordas, visível no detalhe do rosto.

3.6 Filtrado Espacial de Realce

Los filtros de realce (sharpening filters) enfatizan transiciones abruptas de intensidad, aumentando la nitidez y la visibilidad de bordes. Son filtros paso-alto — amplifican los componentes de alta frecuencia (bordes, textura) y suprimen los de baja frecuencia (regiones uniformes).

La intuición es simple: si restamos de una imagen su versión suavizada (que contiene solo las bajas frecuencias), lo que queda son las altas frecuencias — bordes y detalles. Sumando ese residuo de vuelta a la imagen original, el contraste local aumenta:

\[ g = f + k\,(f - f_{\text{suave}}), \quad k > 0 \tag{3.15}\]

La Figura 3.15 ilustra este proceso en una señal 1D sintética con tres estructuras distintas: un escalón ancho, un pico fino y una rampa suave. En el panel ①, la señal original \(f(x)\); en el ②, la versión suavizada \(f_{\text{suave}}(x)\) obtenida por media móvil — nótese cómo el pico fino se atenúa. El panel ③ muestra el residuo \(f - f_{\text{suave}}\), que retiene solo las transiciones abruptas. Finalmente, el panel ④ muestra \(g(x)\): el pico, antes atenuado, se restaura y amplifica en relación con el original. Ajuste \(k\) y el tamaño de la ventana para observar el trade-off entre nitidez y amplificación de ruido.

Los filtros de realce formalizan esta idea directamente en el kernel, sin necesidad de dos etapas separadas.

🎮 Simulador: Filtrado Espacial de Realce 1D g = f + k·(f − f_suave)
k = 1.5
ventana = 9
σ = 0.04
① f(x) — Señal Original
↓ filtro pasa-bajas (media móvil)
② f_suave(x) — Pico Atenuado por el Filtro
↓ sustracción: f − f_suave
③ Residuo (f − f_suave) — Altas Frecuencias / Bordes
↓ suma: f + k · residuo
④ g(x) — Señal con Pico Realzado
Figura 3.15: Simulador: Filtrado Espacial de Realce 1D (Unsharp Masking y High-Boost)

3.6.1 Laplaciano

El Laplaciano es un operador de segunda derivada isotrópico, es decir, responde de igual manera a las variaciones en todas las direcciones, a diferencia de los operadores de primera derivada, como Sobel y Prewitt, que son direccionales:

\[ \nabla^2 f = \frac{\partial^2 f}{\partial x^2} + \frac{\partial^2 f}{\partial y^2} \tag{3.16}\]

Una propiedad importante de la segunda derivada es que su valor es cercano a cero en regiones uniformes y elevado en las transiciones de intensidad. Así, al restar el Laplaciano de la imagen original, se refuerzan los bordes y detalles, aumentando el contraste local:

\[ g(x,y) = f(x,y) - \nabla^2 f(x,y) \tag{3.17}\]

En la forma discreta, la segunda derivada en \(x\) se aproxima por \(f(x+1,y) - 2f(x,y) + f(x-1,y)\), y de manera análoga en \(y\). Sumando ambas direcciones, se obtiene el kernel \(w_4\) (4-vecinos) o \(w_8\) (8-vecinos, incluyendo diagonales):

\[ w_4 = \begin{bmatrix} 0 & 1 & 0 \\ 1 & -4 & 1 \\ 0 & 1 & 0 \end{bmatrix}, \qquad w_8 = \begin{bmatrix} 1 & 1 & 1 \\ 1 & -8 & 1 \\ 1 & 1 & 1 \end{bmatrix} \tag{3.18}\]

NotaSuma cero y centro negativo

Ambos kernels tienen suma de coeficientes igual a cero: en regiones uniformes, la salida es 0 — el Laplaciano no altera el brillo medio, solo detecta variaciones. El centro negativo indica que el píxel se compara con sus vecinos: cuanto más se destaque (hacia arriba o hacia abajo), mayor será el valor absoluto del Laplaciano en ese punto.

En el siguiente ejemplo, el píxel central \([1,1]=107\) posee vecinos \(\{95, 106, 108, 103\}\). Como estos valores son cercanos entre sí, la región es casi uniforme y el Laplaciano devuelve un valor bajo, produciendo poco realce. En regiones de borde, donde hay diferencias mayores entre el píxel central y sus vecinos, el Laplaciano asume valores más elevados (positivos o negativos), y la operación de Ecuación 3.17 intensifica esas transiciones.

La Figura 3.16 ilustra el cálculo del Laplaciano con el kernel \(w_4\) en una vecindad \(3\times3\) destacada dentro de un patch \(5\times5\). El ejemplo muestra el valor obtenido por el operador y el correspondiente píxel realzado en la imagen de salida, que pasa de 107 a 123.

%%writefile tmp/fig_03_laplaciano_patch.cpp
#define MM_OUT "tmp/fig_03_laplaciano_patch.png"
// Compile with: g++ -std=c++17 -o program program.cpp -lmorph
#include "morph.hpp"
#include <iostream>

//| label: fig-03-laplaciano-patch
//| fig-cap: "*Kernel* Laplaciano w4 sobre el *patch* 5×5: la ventana amarilla destaca la vecindad 3×3 donde el operador de segunda derivada se calcula."
//| echo: true
//| output: true

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

    mm::Image patch = mm::crop(img_gray, 250, 255, 250, 255);
    mm::Kernel B = mm::Kernel::ones(3);

    std::cout << "Patch 5x5 (intensidades):\n";
    std::cout << mm::drawImg(patch) << "\n";

    mm::drawImgKernel(patch, B, 1, 1, MM_OUT, 40);

    return 0;
}
Overwriting tmp/fig_03_laplaciano_patch.cpp
!g++ -I. -std=c++17 tmp/fig_03_laplaciano_patch.cpp -o tmp/fig_03_laplaciano_patch \
  && ./tmp/fig_03_laplaciano_patch \
  && test -f "tmp/fig_03_laplaciano_patch.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_laplaciano_patch.png"
Patch 5x5 (intensidades):
 94  96 103 113 115 
107 104 103 107 114 
 98 102 112 117 116 
 81  91 112 122 118 
 85  91 110 119 121 

Processando pixel (x,y)=(1,1)  |  janela do kernel 3x3
 94  96 103 113 115 
107 104 103 107 114 
 98 102 112 117 116 
 81  91 112 122 118 
 85  91 110 119 121 
try:
    mm.show(mm.read("tmp/fig_03_laplaciano_patch.png"))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_laplaciano_patch.png (ver a versao Python)")
Figura 3.16: Kernel Laplaciano w4 sobre o patch 5×5: a janela amarela destaca a vizinhança 3×3 onde o operador de segunda derivada é calculado.

A Figura 3.17 compara a aplicação de los kernels laplacianos \(w_4\) y \(w_8\) en un recorte más grande de la imagen del leopardo. Para cada caso, se muestran la respuesta bruta del operador, que evidencia los bordes y las transiciones de intensidad, y la imagen obtenida tras el realce por sustracción del laplaciano. Se observa que el kernel \(w_8\), al considerar también los vecinos diagonales, produce una respuesta más intensa y detecta variaciones en más direcciones, resultando en un realce ligeramente más acentuado.

%%writefile tmp/fig_03_laplaciano.cpp
#define MM_OUT "tmp/fig_03_laplaciano.png"
//| label: fig-03-laplaciano
//| fig-cap: "Laplaciano aplicado à imagem do leopardo: resposta bruta (bordas) com w4 e w8, e imagens realçadas pela subtração do Laplaciano. w8 é mais sensível às diagonais."
//| 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_gray_crop = mm::_read_state("tmp/state/img_gray_crop_58.png");
// [pdi:state-io:end]

    // img_gray_crop is provided automatically

    mm::Kernel w4{{0,1,0},{1,-4,1},{0,1,0}};
    mm::Kernel w8{{1,1,1},{1,-8,1},{1,1,1}};

    mm::show(
        {img_gray_crop, mm::laplacian_viz(img_gray_crop, w4), mm::laplacian(img_gray_crop, w4),
         img_gray_crop, mm::laplacian_viz(img_gray_crop, w8), mm::laplacian(img_gray_crop, w8)},
        MM_OUT,
        {"Original", "Laplaciano w4", "Realce w4",
         "Original", "Laplaciano w8", "Realce w8"},
        3
    );

    return 0;
}
Overwriting tmp/fig_03_laplaciano.cpp
!g++ -I. -std=c++17 tmp/fig_03_laplaciano.cpp -o tmp/fig_03_laplaciano \
  && ./tmp/fig_03_laplaciano \
  && test -f "tmp/fig_03_laplaciano.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_laplaciano.png"
[1] Original
[2] Laplaciano w4
[3] Realce w4
[4] Original
[5] Laplaciano w8
[6] Realce w8
try:
    mm.show(mm.read("tmp/fig_03_laplaciano.png"), figsize=(14, 8))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_laplaciano.png (ver a versao Python)")
Figura 3.17: Laplaciano aplicado à imagem do leopardo: resposta bruta (bordas) com w4 e w8, e imagens realçadas pela subtração do Laplaciano. w8 é mais sensível às diagonais.

3.6.2 Operador de Sobel

El operador de Sobel estima las derivadas parciales de primer orden en las direcciones horizontal y vertical. A diferencia del Laplaciano (segunda derivada), el Sobel es direccional y más robusto al ruido, pues cada kernel combina una derivada con un suavizado Gaussiano perpendicular:

\[ G_x = \begin{bmatrix} -1 & 0 & 1 \\ -2 & 0 & 2 \\ -1 & 0 & 1 \end{bmatrix} * f, \qquad G_y = \begin{bmatrix} -1 & -2 & -1 \\ 0 & 0 & 0 \\ 1 & 2 & 1 \end{bmatrix} * f \tag{3.19}\]

\(G_x\) detecta bordes verticales (variación en la dirección \(x\)); \(G_y\) detecta bordes horizontales (variación en la dirección \(y\)). Los pesos \(\{1,2,1\}\) en la dirección perpendicular corresponden al suavizado Gaussiano 1D, que reduce la sensibilidad al ruido.

NotaSobel es correlación, no convolución

Los kernels de Sobel son asimétricos — la rotación de 180° altera el resultado. cv2.Sobel implementa correlación cruzada (como cv2.filter2D). Para obtener la derivada direccional correcta, las señales ya están definidas para correlación: \(G_x\) devuelve valores positivos donde la intensidad crece de izquierda a derecha.

La magnitud del gradiente combina los dos componentes, representando la fuerza del borde independientemente de la dirección:

\[ |\nabla f| = \sqrt{G_x^2 + G_y^2} \tag{3.20}\]

Y la dirección del gradiente (perpendicular al borde) es:

\[ \theta = \arctan\left(\frac{G_y}{G_x}\right) \tag{3.21}\]

Para ilustrar numéricamente, se calculan \(G_x\) y \(G_y\) manualmente en el píxel central \([1,1]\) del patch de 5×5:

El valor reducido de \(|{\nabla f}|\) en ese patch confirma que la región es casi uniforme, pues el gradiente asume valores elevados solo donde hay cambios significativos de intensidad. La Figura 3.18 aplica el operador de Sobel a un recorte mayor de la imagen del leopardo. Se presentan las respuestas horizontal (\(G_x\)) y vertical (\(G_y\)), obtenidas por convolución con los respectivos kernels de Sobel, además de la magnitud \(|{\nabla f}|\), calculada a partir de la combinación de ambas. Mientras que \(G_x\) destaca bordes verticales y \(G_y\) bordes horizontales, la magnitud evidencia bordes en cualquier dirección.

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

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

    //| label: fig-03-sobel
    //| fig-cap: "Operador de Sobel na imagem do leopardo: a magnitude |∇f| combina os gradientes horizontal e vertical, revelando todas as bordas. A decomposição Gx/Gy com sinal fica na trilha Python — mm::sobel devolve a magnitude já com clip."
    //| echo: true
    //| output: true

    mm::Image mag = mm::sobel(img_gray_crop);

    mm::show(std::vector<mm::Image>{img_gray_crop, mag},
             MM_OUT,
             std::vector<std::string>{"Original", "Magnitude |grad f| (mm.sobel)"}, 2);

    
// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(img_gray_crop, "tmp/fig_03_sobel_0.png");
mm::write(mag, "tmp/fig_03_sobel_1.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_03_sobel.cpp
!g++ -I. -std=c++17 tmp/fig_03_sobel.cpp -o tmp/fig_03_sobel \
  && ./tmp/fig_03_sobel \
  && test -f "tmp/fig_03_sobel.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_sobel.png"
[1] Original
[2] Magnitude |grad f| (mm.sobel)
try:
    mm.show(
        [
            mm.read("tmp/fig_03_sobel_0.png"),
            mm.read("tmp/fig_03_sobel_1.png"),
        ],
        titles=[
            'Original',
            'Magnitude |grad f| (mm.sobel)',
        ],
        cols=2,
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_sobel_0.png (ver a versao Python)")
Figura 3.18: Operador de Sobel na imagem do leopardo: a magnitude |∇f| combina os gradientes horizontal e vertical, revelando todas as bordas. A decomposição Gx/Gy com sinal fica na trilha Python — mm::sobel devolve a magnitude já com clip.

3.6.3 Operador de Prewitt

El operador de Prewitt es estructuralmente idéntico al de Sobel, pero sustituye la ponderación gaussiana \(\{1,2,1\}\) por pesos uniformes \(\{1,1,1\}\):

\[ G_x = \begin{bmatrix} -1 & 0 & 1 \\ -1 & 0 & 1 \\ -1 & 0 & 1 \end{bmatrix} * f, \qquad G_y = \begin{bmatrix} -1 & -1 & -1 \\ 0 & 0 & 0 \\ 1 & 1 & 1 \end{bmatrix} * f \tag{3.22}\]

La magnitud y la dirección del gradiente siguen las mismas ecuaciones que las de Sobel (Ecuación 3.20 y Ecuación 3.21). La diferencia práctica es que Prewitt es ligeramente más sensible al ruido — el suavizado perpendicular uniforme pondera menos el píxel central de la línea — pero computacionalmente más simple. En imágenes con bajo ruido los resultados son equivalentes.

%%writefile tmp/fig_03_prewitt.cpp
#define MM_OUT "tmp/fig_03_prewitt.png"
// Compile with: g++ -std=c++17 -o program program.cpp morph.hpp
#include "morph.hpp"
#include <iostream>
#include <vector>

//| label: fig-03-prewitt
//| fig-cap: "Operador de Prewitt: magnitude do gradiente, comparável ao Sobel mas sem a ponderação central. mm::prewitt devolve |∇f| com clip."
//| echo: true
//| output: true

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

    mm::show(std::vector<mm::Image>{img_gray_crop, mm::prewitt(img_gray_crop)},
             MM_OUT,
             std::vector<std::string>{"Original", "Magnitude |grad f| (mm.prewitt)"}, 2);
    return 0;
}
Overwriting tmp/fig_03_prewitt.cpp
!g++ -I. -std=c++17 tmp/fig_03_prewitt.cpp -o tmp/fig_03_prewitt \
  && ./tmp/fig_03_prewitt \
  && test -f "tmp/fig_03_prewitt.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_prewitt.png"
[1] Original
[2] Magnitude |grad f| (mm.prewitt)
try:
    mm.show(mm.read("tmp/fig_03_prewitt.png"))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_prewitt.png (ver a versao Python)")
Figura 3.19: Operador de Prewitt: magnitude do gradiente, comparável ao Sobel mas sem a ponderação central. mm::prewitt devolve |∇f| com clip.

3.6.4 Unsharp Masking (USM)

El Unsharp Masking es una técnica clásica de realce de nitidez originaria de la fotografía analógica, hoy ampliamente utilizada en software de edición de imágenes. La idea central es extraer los componentes de alta frecuencia de la imagen (bordes y detalles) y sumarlos de vuelta a la original con un peso \(k\):

Tabla 3.3: Etapas del Unsharp Masking.
Etapa Operación Descripción
1 \(\bar{f} = f * G_\sigma\) Suaviza con gaussiana — retiene bajas frecuencias
2 \(m = f - \bar{f}\) Máscara: diferencia = altas frecuencias (bordes)
3 \(g = f + k \cdot m\) Suma ponderada de la máscara a la original

Sustituyendo la etapa 2 en la etapa 3, se obtiene la expresión compacta:

\[ g = f + k\,(f - f*G_\sigma) = (1+k)\,f - k\,(f*G_\sigma) \tag{3.23}\]

El parámetro \(k\) controla la intensidad del realce:

  • \(k = 0\): sin realce (\(g = f\));
  • \(k = 1\): USM clásico — duplica la contribución de las altas frecuencias;
  • \(k > 1\): High Boost Filtering — amplificación más allá del doble, útil para imágenes muy borrosas.
AdvertenciaAmplificación de ruido

El USM no distingue bordes de ruido — ambos son componentes de alta frecuencia. Para \(k\) elevado, el ruido presente en la imagen se amplifica junto con los bordes. Por ello, se recomienda aplicar una leve suavización antes del USM en imágenes ruidosas, o usar un \(\sigma\) pequeño en la gaussiana.

Para ilustrar las etapas del USM, la Figura 3.20 aplica el método a un parche \(30\times30\) de la imagen del leopardo, utilizando \(\sigma=1\) y \(k=1\). Inicialmente, la imagen se suaviza mediante un filtro gaussiano. A continuación, la máscara de alta frecuencia se obtiene como la diferencia entre la imagen original y la suavizada. Finalmente, esta máscara se suma a la imagen original, reforzando bordes y detalles. La figura presenta las tres etapas del proceso y el resultado final del realce.

%%writefile tmp/fig_03_usm2.cpp
#define MM_OUT "tmp/fig_03_usm2.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_gray_crop = mm::_read_state("tmp/state/img_gray_crop_58.png");
// [pdi:state-io:end]

    //| label: fig-03-usm2
    //| fig-cap: "Realce por *Unsharp Masking* num *patch* do leopardo: mm::usm faz suavização Gaussiana, subtrai da original (máscara de alta frequência) e reintroduz a máscara realçada."
    //| echo: true
    //| output: true

    mm::Image patch = mm::crop(img_gray_crop, 35, 65, 45, 75);

    mm::Image p_suave = mm::gaussian(patch, 7, 1.0);   // suavização Gaussiana
    mm::Image p_usm   = mm::usm(patch, 1.0);           // realce completo (k = 1.0)

    mm::show(std::vector<mm::Image>{patch, p_suave, p_usm},
             MM_OUT,
             std::vector<std::string>{"Patch original", "Suavizado (σ=1)", "Realçado USM (k=1)"}, 3);

    
// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(patch, "tmp/fig_03_usm2_0.png");
mm::write(p_suave, "tmp/fig_03_usm2_1.png");
mm::write(p_usm, "tmp/fig_03_usm2_2.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_03_usm2.cpp
!g++ -I. -std=c++17 tmp/fig_03_usm2.cpp -o tmp/fig_03_usm2 \
  && ./tmp/fig_03_usm2 \
  && test -f "tmp/fig_03_usm2.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_usm2.png"
[1] Patch original
[2] Suavizado (σ=1)
[3] Realçado USM (k=1)
try:
    mm.show(
        [
            mm.read("tmp/fig_03_usm2_0.png"),
            mm.read("tmp/fig_03_usm2_1.png"),
            mm.read("tmp/fig_03_usm2_2.png"),
        ],
        titles=[
            'Patch original',
            'Suavizado (σ=1)',
            'Realçado USM (k=1)',
        ],
        cols=3,
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_usm2_0.png (ver a versao Python)")
Figura 3.20: Realce por Unsharp Masking num patch do leopardo: mm::usm faz suavização Gaussiana, subtrai da original (máscara de alta frequência) e reintroduz a máscara realçada.

La Figura 3.21 aplica el método USM a un recorte más grande de la imagen del leopardo utilizando \(\sigma=1\) y diferentes valores del factor de ganancia \(k\). En todos los casos, la máscara de alta frecuencia se obtiene mediante la diferencia entre la imagen original y su versión suavizada por filtro gaussiano. El parámetro \(k\) controla la intensidad del realce: valores menores producen un aumento sutil de nitidez, mientras que valores mayores refuerzan progresivamente bordes y detalles. Se observa que, para valores elevados de \(k\), surgen halos alrededor de los bordes y el ruido presente en la imagen comienza a amplificarse.

%%writefile tmp/fig_03_usm.cpp
#define MM_OUT "tmp/fig_03_usm.png"
// Compile with: g++ -std=c++17 -o program program.cpp -lmorph
#include "morph.hpp"
#include <iostream>
#include <vector>
#include <string>

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

    //| label: fig-03-usm
    //| fig-cap: "*Unsharp Masking* na imagem do leopardo com σ=1 e k de 0.5 a 8.0. Para k>2 surgem halos nas bordas e o ruído de fundo aparece."
    //| echo: true
    //| output: true

    mm::show(
        std::vector<mm::Image>{img_gray_crop,
             mm::usm(img_gray_crop, 0.5), mm::usm(img_gray_crop, 1.0),
             mm::usm(img_gray_crop, 3.0), mm::usm(img_gray_crop, 5.0),
             mm::usm(img_gray_crop, 8.0)},
        MM_OUT,
        std::vector<std::string>{"Original", "USM k=0.5", "USM k=1.0", "USM k=3.0", "USM k=5.0", "USM k=8.0"},
        3
    );

    return 0;
}
Overwriting tmp/fig_03_usm.cpp
!g++ -I. -std=c++17 tmp/fig_03_usm.cpp -o tmp/fig_03_usm \
  && ./tmp/fig_03_usm \
  && test -f "tmp/fig_03_usm.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_usm.png"
[1] Original
[2] USM k=0.5
[3] USM k=1.0
[4] USM k=3.0
[5] USM k=5.0
[6] USM k=8.0
try:
    mm.show(mm.read("tmp/fig_03_usm.png"))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_usm.png (ver a versao Python)")
Figura 3.21: Unsharp Masking na imagem do leopardo com σ=1 e k de 0.5 a 8.0. Para k>2 surgem halos nas bordas e o ruído de fundo aparece.

3.6.5 Detector de Canny

Canny combina cuatro etapas en secuencia — suavizado Gaussiano, gradiente de Sobel, supresión de no-máximos e histéresis por doble umbral — para producir bordes finos, binarios y conectados. A diferencia de Sobel y Prewitt, el resultado no es un mapa de gradiente continuo, sino una máscara donde cada píxel es borde o no.

El parámetro central es el par de umbrales \((T_{low}, T_{high})\). Los píxeles con gradiente por encima de \(T_{high}\) son bordes seguros; por debajo de \(T_{low}\), se descartan. Los píxeles ambiguos — entre los dos umbrales — se deciden mediante histéresis: se convierten en borde si están conectados a un borde seguro, y se descartan en caso contrario. Esto evita tanto la pérdida de tramos débiles de bordes reales como la inclusión de ruido aislado. Una heurística común es \(T_{high} = 3 \times T_{low}\).

%%writefile tmp/fig_03_canny.cpp
#define MM_OUT "tmp/fig_03_canny.png"
// Compile with: g++ -std=c++17 -o output code.cpp -I<include_path>
#include "morph.hpp"
#include <iostream>
#include <vector>
#include <string>

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

    //| label: fig-03-canny
    //| fig-cap: "Detector de Canny con diferentes pares de umbral: umbrales bajos capturan más bordes (incluso ruido); umbrales altos retienen solo los bordes más fuertes."
    //| echo: true
    //| output: true

    mm::show(
        std::vector<mm::Image>{img_gray_crop,
             mm::canny(img_gray_crop, 30,  90),
             mm::canny(img_gray_crop, 60,  180),
             mm::canny(img_gray_crop, 120, 240)},
        MM_OUT,
        std::vector<std::string>{"Original", "Canny (30/90)", "Canny (60/180)", "Canny (120/240)"},
        4
    );

    return 0;
}
Overwriting tmp/fig_03_canny.cpp
!g++ -I. -std=c++17 tmp/fig_03_canny.cpp -o tmp/fig_03_canny \
  && ./tmp/fig_03_canny \
  && test -f "tmp/fig_03_canny.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_canny.png"
[1] Original
[2] Canny (30/90)
[3] Canny (60/180)
[4] Canny (120/240)
try:
    mm.show(mm.read("tmp/fig_03_canny.png"))
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_canny.png (ver a versao Python)")
Figura 3.22: Detector de Canny com diferentes pares de limiar: limiares baixos capturam mais bordas (inclusive ruído); limiares altos retêm apenas as bordas mais fortes.
NotaElección de los umbrales

Una heurística común es \(T_{high} = 3 \times T_{low}\). Los valores típicos dependen del rango de gradiente de la imagen — cv2.Canny acepta valores absolutos en \([0, 255]\). Para imágenes con contraste variable, calcular los umbrales a partir de percentiles de la magnitud de Sobel es más robusto que usar valores fijos.

3.7 Filtros de Orden: Filtro de la Mediana

Los filtros de orden (order-statistic filters) reemplazan el píxel central por el valor de un percentil de la distribución de intensidades de la vecindad — a diferencia de los filtros lineales, que calculan combinaciones ponderadas. El más importante es el filtro de la mediana.

3.7.1 Ruido Impulsivo: Sal y Pimienta

El ruido sal y pimienta (salt-and-pepper noise) reemplaza píxeles aleatorios por valores extremos: 0 (pimienta, negro) o 255 (sal, blanco). Es común en la transmisión de imágenes con errores de bit y en cámaras con sensores defectuosos.

Para entender por qué fallan los filtros lineales, considere una vecindad 3×3 donde un único píxel ha sido corrompido a 255:

\[ \text{vecindad} = \begin{bmatrix} 102 & 98 & 105 \\ 100 & \mathbf{255} & 97 \\ 103 & 99 & 101 \end{bmatrix} \]

Tabla 3.4: Media vs. mediana con un píxel corrompido. La mediana ignora el valor atípico; la media se desplaza ~40 niveles.
Método Cálculo Resultado
Media (102+98+…+255+…+101)/9 ≈ 140
Mediana {97,98,99,100,101,102,103,105,255} 101
Advertencia¿Por qué fallan los filtros de media con ruido impulsivo?

La media es sensible a los valores atípicos — un único píxel con valor 255 en una vecindad de valor ≈ 100 eleva la salida a ≈ 140, propagando el ruido por la imagen. La mediana, por ser un estimador robusto, selecciona el valor central de la distribución ordenada, descartando naturalmente los extremos sin ningún ajuste especial.

El siguiente ejemplo ilustra el comportamiento de la media y de la mediana en presencia de un píxel corrupto por ruido impulsivo. Se observa que la media está fuertemente influenciada por el valor extremo (255), produciendo una estimación distante de los valores predominantes de la vecindad. En cambio, la mediana permanece cercana al valor original de la región, evidenciando su mayor robustez frente a valores atípicos y justificando su uso en la eliminación de ruido de sal y pimienta.

La Figura 3.23 presenta el efecto del ruido sal y pimienta en diferentes densidades. El ruido fue generado reemplazando aleatoriamente una fracción de los píxeles por valores mínimos (0, pimienta) y máximos (255, sal). A medida que la densidad aumenta del 2% al 10%, crece la cantidad de píxeles corruptos, haciendo que la degradación visual sea más evidente y dificultando la percepción de los detalles de la imagen.

%%writefile tmp/fig_03_ruido.cpp
#define MM_OUT "tmp/fig_03_ruido.png"
#include "morph.hpp"
#include <iostream>
#include <vector>
#include <string>
#include <random>
#include <algorithm>
#include <filesystem>

//| label: fig-03-ruido
//| fig-cap: "Ruído sal e pimenta com densidades crescentes (2%, 5%, 10%): metade dos pixels corrompidos vira sal (255), metade pimenta (0)."
//| echo: true
//| output: true

mm::Image salt_pepper(const mm::Image& img, double prob) {
    mm::Image out = img;
    int h = out.h;
    int w = out.w;
    std::mt19937 rng(42);
    std::uniform_int_distribution<int> dist_y(0, h - 1);
    std::uniform_int_distribution<int> dist_x(0, w - 1);
    std::uniform_real_distribution<double> dist_rand(0.0, 1.0);
    int n = static_cast<int>(prob * h * w);
    for (int i = 0; i < n; i++) {
        int y = dist_y(rng);
        int x = dist_x(rng);
        if (dist_rand(rng) < 0.5)
            out.at(y, x) = 0;
        else
            out.at(y, x) = 255;
    }
    return out;
}

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

    mm::Image n2 = salt_pepper(img_gray_crop, 0.02);
    mm::Image n5 = salt_pepper(img_gray_crop, 0.05);
    mm::Image n10 = salt_pepper(img_gray_crop, 0.10);

    mm::show(std::vector<mm::Image>{img_gray_crop, n2, n5, n10},
             MM_OUT,
             std::vector<std::string>{"Original", "Ruido 2%", "Ruido 5%", "Ruido 10%"},
             4);
    
// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(img_gray_crop, "tmp/fig_03_ruido_0.png");
mm::write(n2, "tmp/fig_03_ruido_1.png");
mm::write(n5, "tmp/fig_03_ruido_2.png");
mm::write(n10, "tmp/fig_03_ruido_3.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_03_ruido.cpp
!g++ -I. -std=c++17 tmp/fig_03_ruido.cpp -o tmp/fig_03_ruido \
  && ./tmp/fig_03_ruido \
  && test -f "tmp/fig_03_ruido.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_ruido.png"
[1] Original
[2] Ruido 2%
[3] Ruido 5%
[4] Ruido 10%
try:
    mm.show(
        [
            mm.read("tmp/fig_03_ruido_0.png"),
            mm.read("tmp/fig_03_ruido_1.png"),
            mm.read("tmp/fig_03_ruido_2.png"),
            mm.read("tmp/fig_03_ruido_3.png"),
        ],
        titles=[
            'Original',
            'Ruido 2%',
            'Ruido 5%',
            'Ruido 10%',
        ],
        cols=4,
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_ruido_0.png (ver a versao Python)")
Figura 3.23: Ruído sal e pimenta com densidades crescentes (2%, 5%, 10%): metade dos pixels corrompidos vira sal (255), metade pimenta (0).

3.7.2 Filtro de la Mediana

El filtro de la mediana sustituye cada píxel por el valor mediano de los píxeles de su vecindad \(n \times n\):

\[ g(x,y) = \text{med}_{(s,t) \in \mathcal{V}_{n}} \{f(x+s, y+t)\} \tag{3.24}\]

El valor mediano es aquel que ocupa la posición central cuando los \(n^2\) valores de la vecindad se ordenan. Para una ventana \(3\times3\) (\(n^2=9\) píxeles), la mediana es el 5º valor de la secuencia ordenada.

Para ilustrar, considere el mismo patch 5×5 con un píxel corrompido artificialmente en \([1,1]\):

El ejemplo confirma: incluso con el píxel corrompido a 255, la mediana devuelve el valor central correcto — el outlier ocupa la última posición en la ordenación y se descarta de forma natural.

Por basarse en la ordenación y no en la suma, la mediana posee tres propiedades fundamentales que la diferencian de los filtros lineales:

  • Robusta ante el ruido impulsivo — los outliers van a los extremos de la secuencia ordenada y no afectan el valor central;
  • Preservadora de bordes — las transiciones abruptas de intensidad se mantienen, pues la mediana selecciona un valor que ya existe en la vecindad, sin crear nuevos niveles intermedios;
  • No lineal — no puede expresarse como convolución, por lo que mm::conv no se aplica; se utiliza cv2.medianBlur.

A Figura 3.24 compara diferentes técnicas de remoção de ruido sal y pimienta aplicadas a una imagen con un 10% de píxeles corruptos. Se evaluaron los filtros Gaussiano, Media, Mediana, Bilateral y Morfológico (próximo capítulo), lo que permite observar la relación entre la eliminación de ruido y la preservación de detalles. En general, los filtros de media y Gaussiano reducen el ruido, pero tienden a desenfocar los bordes, mientras que la mediana presenta un mejor rendimiento para ruido impulsivo. El filtro bilateral preserva mejor los bordes, y el filtro morfológico elimina gran parte de los píxeles corruptos sin degradar excesivamente la estructura de la imagen.

%%writefile tmp/fig_03_ruido_filtros.cpp
#define MM_OUT "tmp/fig_03_ruido_filtros.png"
#include "morph.hpp"
#include <random>
#include <vector>
#include <string>
#include <filesystem>

//| label: fig-03-ruido-filtros
//| fig-cap: "Filtros para ruído sal e pimenta (10%): Gaussiano, Média e Mediana. Bilateral e morfológico (open+close) ficam só na trilha Python — bilateral não tem equivalente em morph.hpp e morfologia é do próximo capítulo."
//| echo: true
//| output: true

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

    // Função para adicionar ruído sal e pimenta
    auto salt_pepper = [](const mm::Image& img, double prob) {
        mm::Image out = img;
        int h = out.h, w = out.w;
        std::mt19937 rng(42);
        std::uniform_int_distribution<int> dist_y(0, h - 1);
        std::uniform_int_distribution<int> dist_x(0, w - 1);
        std::uniform_real_distribution<double> dist_p(0.0, 1.0);

        for (int i = 0; i < static_cast<int>(prob * h * w); ++i) {
            int y = dist_y(rng);
            int x = dist_x(rng);
            out.at(y, x) = (dist_p(rng) < 0.5) ? 0 : 255;
        }
        return out;
    };

    mm::Image noisy = salt_pepper(img_gray_crop, 0.10);

    mm::Image f_gauss   = mm::gaussian(noisy, 5, 1.0);
    mm::Image f_media   = mm::blur(noisy, 5);
    mm::Image f_median3 = mm::median(noisy, 3);
    mm::Image f_median5 = mm::median(noisy, 5);

    mm::show(
        std::vector<mm::Image>{img_gray_crop, noisy, f_gauss, f_media, f_median3, f_median5},
        MM_OUT,
        std::vector<std::string>{"Original", "Ruido 10%", "Gaussiano 5x5", "Media 5x5",
                                 "Mediana 3x3", "Mediana 5x5"},
        3
    );

    
// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(img_gray_crop, "tmp/fig_03_ruido_filtros_0.png");
mm::write(noisy, "tmp/fig_03_ruido_filtros_1.png");
mm::write(f_gauss, "tmp/fig_03_ruido_filtros_2.png");
mm::write(f_media, "tmp/fig_03_ruido_filtros_3.png");
mm::write(f_median3, "tmp/fig_03_ruido_filtros_4.png");
mm::write(f_median5, "tmp/fig_03_ruido_filtros_5.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_03_ruido_filtros.cpp
!g++ -I. -std=c++17 tmp/fig_03_ruido_filtros.cpp -o tmp/fig_03_ruido_filtros \
  && ./tmp/fig_03_ruido_filtros \
  && test -f "tmp/fig_03_ruido_filtros.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_ruido_filtros.png"
[1] Original
[2] Ruido 10%
[3] Gaussiano 5x5
[4] Media 5x5
[5] Mediana 3x3
[6] Mediana 5x5
try:
    mm.show(
        [
            mm.read("tmp/fig_03_ruido_filtros_0.png"),
            mm.read("tmp/fig_03_ruido_filtros_1.png"),
            mm.read("tmp/fig_03_ruido_filtros_2.png"),
            mm.read("tmp/fig_03_ruido_filtros_3.png"),
            mm.read("tmp/fig_03_ruido_filtros_4.png"),
            mm.read("tmp/fig_03_ruido_filtros_5.png"),
        ],
        titles=[
            'Original',
            'Ruido 10%',
            'Gaussiano 5x5',
            'Media 5x5',
            'Mediana 3x3',
            'Mediana 5x5',
        ],
        cols=3,
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_ruido_filtros_0.png (ver a versao Python)")
Figura 3.24: Filtros para ruído sal e pimenta (10%): Gaussiano, Média e Mediana. Bilateral e morfológico (open+close) ficam só na trilha Python — bilateral não tem equivalente em morph.hpp e morfologia é do próximo capítulo.

3.8 Aplicación Práctica: Preprocesamiento para Segmentación

En la práctica, las técnicas de este capítulo rara vez se utilizan de forma aislada. Un pipeline de preprocesamiento típico combina varias etapas en secuencia, adaptándose al tipo de imagen y a la aplicación. La Figura 3.25 ilustra un pipeline completo:

  1. Ecualización de histograma (CLAHE): normaliza el contraste independientemente de las condiciones de iluminación;
  2. Filtro Gaussiano: suaviza el ruido de adquisición sin destruir bordes;
  3. Detección de bordes (Sobel/Canny): extrae estructuras relevantes para la segmentación.
NotaEl orden importa

El orden de las operaciones afecta el resultado final. En general: (1) normalización de intensidad → (2) reducción de ruido → (3) realce/segmentación. Invertir el orden puede amplificar el ruido o perder bordes antes de detectarlos.

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

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

    //| label: fig-03-pipeline
    //| fig-cap: "*Pipeline* de pré-processamento: equalização → Gaussiano → Canny. A trilha Python usa CLAHE no lugar da equalização global; CLAHE não tem equivalente em morph.hpp."
    //| echo: true
    //| output: true

    mm::Image img_eq    = mm::equalize(img_gray_crop);     // Etapa 1: equalização global
    mm::Image img_gauss = mm::gaussian(img_eq, 5, 0);      // Etapa 2: Gaussiano
    mm::Image edges     = mm::canny(img_gauss, 50, 150);   // Etapa 3: Canny

    mm::Image edges_direct = mm::canny(img_gray_crop, 50, 150);   // Canny direto, sem pré-processo

    mm::show(
        std::vector<mm::Image>{img_gray_crop, img_eq, img_gauss, edges, edges_direct},
        MM_OUT,
        std::vector<std::string>{"Original", "1. Equalizado", "2. Gaussiano", "3. Canny (pipeline)", "Canny (direto)"},
        5
    );

    
// [pdi:panel-io] auto-generated — do not edit by hand
std::filesystem::create_directories("tmp");
mm::write(img_gray_crop, "tmp/fig_03_pipeline_0.png");
mm::write(img_eq, "tmp/fig_03_pipeline_1.png");
mm::write(img_gauss, "tmp/fig_03_pipeline_2.png");
mm::write(edges, "tmp/fig_03_pipeline_3.png");
mm::write(edges_direct, "tmp/fig_03_pipeline_4.png");
// [pdi:panel-io:end]
return 0;
}
Overwriting tmp/fig_03_pipeline.cpp
!g++ -I. -std=c++17 tmp/fig_03_pipeline.cpp -o tmp/fig_03_pipeline \
  && ./tmp/fig_03_pipeline \
  && test -f "tmp/fig_03_pipeline.png" \
  || echo "⚠ mm::show não gravou tmp/fig_03_pipeline.png"
[1] Original
[2] 1. Equalizado
[3] 2. Gaussiano
[4] 3. Canny (pipeline)
[5] Canny (direto)
try:
    mm.show(
        [
            mm.read("tmp/fig_03_pipeline_0.png"),
            mm.read("tmp/fig_03_pipeline_1.png"),
            mm.read("tmp/fig_03_pipeline_2.png"),
            mm.read("tmp/fig_03_pipeline_3.png"),
            mm.read("tmp/fig_03_pipeline_4.png"),
        ],
        titles=[
            'Original',
            '1. Equalizado',
            '2. Gaussiano',
            '3. Canny (pipeline)',
            'Canny (direto)',
        ],
        cols=5,
    )
except Exception as _e:
    print("figura indisponivel nesta trilha (C++): " + repr(_e) + " tmp/fig_03_pipeline_0.png (ver a versao Python)")
Figura 3.25: Pipeline de pré-processamento: equalização → Gaussiano → Canny. A trilha Python usa CLAHE no lugar da equalização global; CLAHE não tem equivalente em morph.hpp.

3.9 Resumen

En este capítulo se presentaron las principales técnicas de procesamiento en el dominio espacial, desde la manipulación directa de píxeles hasta el filtrado por vecindad:

  • Operaciones de punto: aritméticas saturadas (mm::addm, mm::subm) y lógicas bit a bit (mm::band, mm::bor, mm::bnot) para recorte de ROI y combinación de imágenes; alpha blending (mm::blend) para fusión ponderada con peso \(\alpha \in [0,1]\).
  • Histograma: función discreta de distribución de intensidades; visualizado con mm::histImg y calculado con mm::hist; base para el diagnóstico tonal y para las técnicas de ecualización y especificación.
  • Ecualización: redistribución automática de las intensidades mediante la CDF (mm::equalize), con variante adaptativa CLAHE para el control local del contraste.
  • Especificación de histograma: transferencia del perfil tonal de una imagen de referencia mediante mapeo inverso de la CDF — generalización de la ecualización para distribuciones arbitrarias.
  • Correlación y convolución: mecanismo de ventana deslizante implementado en mm::conv (cv2.filter2D); diferenciados por la rotación de 180° del kernel — relevante solo para kernels asimétricos.
  • Filtros de suavizado: media (kernel uniforme, desenfoca bordes proporcionalmente al tamaño) y Gaussiano (ponderación radial, separable, sin ringing, conserva mejor los bordes).
  • Filtros de realce: Laplaciano (\(w_4\)/\(w_8\), segunda derivada isotrópica), Sobel (gradiente direccional de primer orden, con magnitud \(|\nabla f|\) y dirección \(\theta\)) y Unsharp Masking (amplificación de las altas frecuencias con parámetro \(k\)).
  • Filtro de la mediana: no lineal, robusto a valores atípicos, conserva bordes — superior a los filtros lineales para ruido sal y pimienta.
  • Pipeline práctico: encadenamiento CLAHE → Gaussiano → Canny como estrategia de preprocesamiento; mm::drawImgKernel para visualización didáctica de la ventana deslizante.

El Capítulo 4 abordará la morfología matemática (erosión, dilatación, apertura y cierre), explorando en profundidad las funciones mm::ero y mm::dil de la biblioteca morph.py. A continuación, el Capítulo 5 presentará el procesamiento en el dominio de la frecuencia, con enfoque en la Transformada de Fourier y en técnicas de filtrado espectral.

3.10 🤖 Uso del Gemini Notebook como Tutor Complementario

En esta edición, incentivamos el uso del Gemini Notebook como herramienta complementaria de aprendizaje. Esta herramienta de IA utiliza exclusivamente los documentos proporcionados por el autor como base de conocimiento, garantizando respuestas coherentes con el contenido del libro — incluyendo las funciones de la biblioteca morph.py y los experimentos realizados en este capítulo.

Para cada capítulo, hemos preparado un proyecto específico en la plataforma con el PDF del capítulo, los notebooks y materiales auxiliares. Sugerimos explorar especialmente:

  • Guía de Estudio: resumen estructurado de los conceptos, ideal para repasar antes de los exámenes;
  • Conversación: resuelve dudas sobre ecualización, convolución, filtros y pipelines directamente con el tutor;
  • Preguntas frecuentes: cuestiones típicas sobre la diferencia entre media y mediana, USM, Lapaciano vs. Sobel.
Importante🎓 Estudia con el Tutor Inteligente

Para interactuar con el contenido de este capítulo, accede al siguiente enlace. El entorno contiene materiales didácticos en diferentes formatos, generados a partir del PDF del capítulo. En la plataforma, explora especialmente las opciones Guía de Estudio y Conversación para profundizar tu comprensión.

🚀 ACCEDER A GEMINI NOTEBOOK: CAPÍTULO 03

🌐 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 con la versión que estás leyendo.

⚠️ Aviso sobre Contenido Generado por IA

La IA es una poderosa aliada en los estudios, pero el contenido generado puede contener errores o imprecisiones. Consulta siempre libros, artículos científicos y otras fuentes académicas confiables para validar la información. Siempre que sea posible, ejecuta los ejemplos prácticos proporcionados en este capítulo para verificar los resultados.

3.11 Lista de Ejercicios

  1. (10%) Explique la diferencia entre convolución y correlación cruzada. ¿Para qué tipos de kernel los resultados son idénticos? Dé un ejemplo de kernel asimétrico (como Sobel \(G_x\)) y muestre numéricamente que los resultados difieren aplicándolo al parche 5×5 del capítulo de las dos formas.

  2. (15%) Considere una imagen 5×5 con intensidades concentradas entre los niveles 3 y 5 (bajo contraste, 3 bits). Aplique manualmente el algoritmo de ecualización de la Tabla 3.1, completando todas las columnas de la tabla (\(k\), \(h[k]\), \(p[k]\), \(\text{cdf}[k]\), \(\text{lut}[k]\)). Verifique el resultado con mm::equalize.

  3. (15%) Usando mm::conv, aplique el filtro de media con kernels de tamaño 3×3, 9×9 y 21×21 a la imagen del mandril. Para cada versión, calcule el PSNR (Peak Signal-to-Noise Ratio) con respecto a la original: \[\text{PSNR} = 10\log_{10}\!\left(\frac{255^2}{\text{MSE}}\right), \quad \text{MSE} = \frac{1}{MN}\sum_{i,j}(f-g)^2\] Grafique el PSNR en función del tamaño del kernel y explique qué indica la caída progresiva sobre la relación entre suavizado y pérdida de información.

  4. (15%) Usando add_salt_pepper con densidad del 5%, aplique y compare: (a) mm::conv con media 3×3, (b) cv2.GaussianBlur con \(\sigma=1\), (c) cv2.medianBlur con ventana 3×3 y (d) cv2.medianBlur con ventana 5×5. Muestre las imágenes con mm::show en una cuadrícula 2×4 (fila 1: imágenes, fila 2: histogramas mediante mm::histImg). Explique por qué la mediana supera a los filtros lineales usando el argumento de la Tabla 3.4.

  5. (15%) Implemente mm::conv0 usando solo operaciones NumPy vectorizadas — sin bucles en Python y sin cv2.filter2D — con el operador de stride tricks (np.lib.stride_tricks.sliding_window_view). Compare el resultado y el tiempo de ejecución con mm::conv0 (bucles) y mm::conv (cv2) para kernels 3×3 y 15×15 en la imagen del mandril.

  6. (15%) Aplique el Unsharp Masking con \(\sigma=1\) y \(k \in \{0.5, 1.0, 2.0, 4.0\}\) usando la función usm del capítulo. Para cada valor de \(k\): (a) calcule la diferencia absoluta \(|g - f|\), (b) muestre las imágenes y las diferencias con mm::show, y (c) grafique el histograma de las diferencias con mm::histImg. Identifique a partir de cuál \(k\) los artefactos (halos y amplificación de ruido) se vuelven visualmente inaceptables.

  7. (15%) Elija una imagen de rayos X o tomografía disponible públicamente (ej.: mediante mm::read de URL) y diseñe un pipeline de preprocesamiento con al menos 4 etapas secuenciales, justificando cada elección con base en los conceptos del capítulo. Muestre con mm::show en una cuadrícula: imagen original, cada etapa intermedia y el resultado final con sus histogramas (mm::histImg).

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 operaciones de intensidad, histograma, convolución y filtrado espacial.
  • Szeliski (2022) para la visión por computadora y aplicaciones prácticas de filtrado.
  • Bradski (2008) para la implementación práctica con OpenCV y morph.py.

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

🎯 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 los Tests

Para evaluar los tests, ejecuta TestSuite("EP03_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 un archivo, usa run_code(codigo) pasando el código como string en una variable codigo:

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

3.12.1 EP03_01 ➕ Adición Saturada de Constante

En sistemas de vigilancia por video, las cámaras en entornos con iluminación variable producen imágenes subexpuestas. El ajuste de brillo mediante adición saturada de una constante es la operación más simple para la corrección inmediata, aplicándose en tiempo real en los chips de cámaras embebidas y en pipelines de preprocesamiento de robots móviles.

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

3.12.1.1 📋 Directrices de Implementación

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

\[p' = \text{clip}(p + k)\]

  1. Salida: Mostrar la matriz resultante con dimensiones \(L \times C\).

3.12.1.2 📌 Restricciones Computacionales

  • Saturación (Clipping): Los valores deben confinarse al intervalo \([0, 255]\): \[\text{clip}(x) = \max(0, \min(255, x))\]
  • Tipo: El resultado final debe ser entero (sin decimales).
  • \(k\) puede ser negativo: los valores negativos oscurecen la imagen; los positivos la aclaran.

3.12.1.3 🧠 Fundamentación Teórica

Parámetro Tipo Impacto Visual
\(k > 0\) Entero Aclara la imagen; los píxeles cercanos a 255 saturan en blanco
\(k < 0\) Entero Oscurece la imagen; los píxeles cercanos a 0 saturan en negro
\(k = 0\) Entero Imagen sin cambios

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

Entrada:

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

Salida:

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

3.12.1.5 📌 Ejemplos

Entrada Salida Observación
2
3
50
0 100 200
210 240 255
50 150 250
255 255 255
Saturación en 255 en los píxeles altos
1
4
-30
0 20 200 255
0 0 170 225 Saturación en 0 en los píxeles bajos
➕ Simulador EP03_01: Adición Saturada de Constante p' = clip(p + k)

Ajuste el valor de la constante k para observar el desplazamiento de brillo de la imagen y el truncamiento por saturación en el intervalo [0, 255].

0
Entrada Original (p)
Resultado Transformado (p')
Fórmula aplicada: clip(p + (0))
Figura 3.26: Simulador EP03_01: Adición Saturada de Constante (p’ = clip(p + k))
%%writefile EP03_01.cpp
// your solution
Overwriting EP03_01.cpp
TestSuite("EP03_01.cpp").run()
✔️ EP03_01.cases ya existe en casos/
📋 5 caso(s) cargado(s) de casos/EP03_01.cases

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

3.12.2 EP03_02 🔀 Alpha Blending de Dos Imágenes

En medicina nuclear, imágenes de diferentes modalidades (tomografía computarizada y resonancia magnética) se fusionan para ayudar en el diagnóstico. La mezcla ponderada (alpha blending) es la operación fundamental de este proceso, permitiendo al radiólogo controlar interactivamente el peso de cada modalidad en la imagen mostrada.

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

3.12.2.1 📋 Directrices de Implementación

  1. Dimensiones: Leer los enteros \(L\) (filas) y \(C\) (columnas).
  2. Parámetro: Leer el valor real \(\alpha \in [0, 1]\).
  3. Datos: Leer los valores enteros de la matriz \(f_1\) (imagen 1) y luego de la matriz \(f_2\) (imagen 2).
  4. Mapeo: Para cada posición \((i, j)\), calcular:

\[g(i,j) = \text{clip}\left(\text{round}\left(\alpha \cdot f_1(i,j) + (1-\alpha) \cdot f_2(i,j)\right)\right)\]

  1. Salida: Mostrar la matriz resultante \(L \times C\).

3.12.2.2 📌 Restricciones Computacionales

  • Redondeo: Aplicar round antes de la conversión a entero.
  • Saturación: Confinar al intervalo \([0, 255]\) con \(\text{clip}(x) = \max(0, \min(255, x))\).
  • Operación en float: Realizar la operación en punto flotante antes de redondear.

3.12.2.3 🧠 Fundamentación Teórica

Valor de \(\alpha\) Resultado
\(\alpha = 1.0\) Solo \(f_1\)
\(\alpha = 0.5\) Media aritmética de \(f_1\) y \(f_2\)
\(\alpha = 0.0\) Solo \(f_2\)

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

Entrada:

  • Línea 1: Entero \(L\).
  • Línea 2: Entero \(C\).
  • Línea 3: Real \(\alpha\).
  • Líneas siguientes: Elementos de \(f_1\) (\(L\) líneas con \(C\) valores cada una).
  • Líneas siguientes: Elementos de \(f_2\) (\(L\) líneas con \(C\) valores cada una).

Salida:

  • Matriz resultante \(L \times C\).

3.12.2.5 📌 Ejemplos

Entrada Salida Observación
1
3
0.5
0 100 200
100 200 50
50 150 125 Media entre las dos imágenes
1
3
1.0
10 20 30
90 80 70
10 20 30 Solo \(f_1\) (alpha=1)
🔀 Simulador EP03_02: Alpha Blending de Dos Imágenes g = α·f1 + (1−α)·f2

Ajusta el parámetro de transparencia α para observar la combinación lineal ponderada píxel a píxel entre las imágenes f1 y f2.

0.50
α = 0.00 → Solo f2  |  α = 0.50 → Media Ponderada Igual  |  α = 1.00 → Solo f1
Imagen f1
Imagen f2
Resultado g
Fórmula: clip(round(0.50 · f1 + 0.50 · f2))
Figura 3.27: Simulador EP03_02: Mezcla alfa de dos imágenes (g = α·f1 + (1−α)·f2)
%%writefile EP03_02.cpp
// your solution
Overwriting EP03_02.cpp
TestSuite("EP03_02.cpp").run()
✔️ EP03_02.cases ya existe en casos/
📋 6 caso(s) cargado(s) de casos/EP03_02.cases

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

3.12.3 EP03_03 🎭 Inversión de Imagen (Negativo Fotográfico)

En radiología, las imágenes de rayos X se visualizan tradicionalmente en negativo: los huesos aparecen en negro sobre fondo blanco. La operación de negativo fotográfico se aplica rutinariamente en PACS (Picture Archiving and Communication Systems) para facilitar la detección de fracturas y densidades óseas.

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

3.12.3.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.
  3. Mapeo: Para cada píxel \(p\), calcular el negativo:

\[p' = 255 - p\]

  1. Salida: Mostrar la matriz resultante de \(L \times C\).

3.12.3.2 📌 Restricciones Computacionales

  • Sin necesidad de recorte: El resultado de \(255 - p\) con \(p \in [0, 255]\) siempre está \(\in [0, 255]\).
  • Tipo entero: La salida debe ser valores enteros.
  • Equivalencia lógica: La operación es idéntica al NOT bit a bit (mm::bnot) en imágenes de 8 bits.

3.12.3.3 🧠 Fundamentación Teórica

Píxel Original \(p\) Píxel Negativo \(p'\) Observación
0 (negro) 255 (blanco) Inversión total
128 (gris medio) 127 (gris medio) Valor central
255 (blanco) 0 (negro) Inversión total

3.12.3.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 negativa en \(L\) filas y \(C\) columnas.

3.12.3.5 📌 Ejemplos

Entrada Salida Observación
1
4
0 128 200 255
255 127 55 0 Inversión de cada píxel
2
2
10 20
30 40
245 235
225 215
Matriz 2x2 invertida
🎭 Simulador EP03_03: Negativo Fotográfico (Inversión) p' = 255 − p

Observe la inversión complementaria de intensidad: los tonos oscuros se vuelven claros y los tonos claros se vuelven oscuros restando cada píxel del valor máximo de 255.

Entrada Original (p)
Negativo (p' = 255 − p)
 
Fórmula aplicada: p' = 255 − p
Figura 3.28: Simulador EP03_03: Inversión de Imagen — Negativo Fotográfico (p’ = 255 − p)
%%writefile EP03_03.cpp
// your solution
Overwriting EP03_03.cpp
TestSuite("EP03_03.cpp").run()
✔️ EP03_03.cases ya existe en casos/
📋 5 caso(s) cargado(s) de casos/EP03_03.cases

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

3.12.4 EP03_04 📊 Equalización de Histograma (L bits)

En imágenes de satélite de teleobservación, la variación de iluminación a lo largo del día produce imágenes de bajo contraste. La equalización de histograma se aplica automáticamente en satélites como el Landsat para redistribuir los tonos, revelando detalles de vegetación, relieve y zonas urbanas invisibles en la imagen original.

Ver en la Figura 3.29 una simulación de este EP.

3.12.4.1 📋 Directrices de Implementación

  1. Dimensiones: Leer los enteros \(L\) (líneas), \(C\) (columnas) y \(B\) (número de bits, con \(L_{\max} = 2^B\)).
  2. Datos: Leer la matriz de píxeles \(f\) con valores en \([0, 2^B - 1]\).
  3. Histograma: Calcular \(h[k]\) = número de píxeles con intensidad \(k\), para \(k = 0 \ldots 2^B-1\).
  4. Probabilidad: \(p[k] = h[k] / (L \cdot C)\).
  5. CDF: \(\text{cdf}[k] = \sum_{j=0}^{k} p[j]\); función de distribución acumulada.
  6. LUT: \(\text{lut}[k] = \text{round}\left(\text{cdf}[k] \cdot (2^B - 1)\right)\); Look-Up Table (tabla de consulta).
  7. Aplicación: \(g[i,j] = \text{lut}[f[i,j]]\).
  8. Salida: Mostrar la matriz equalizada \(L \times C\).

3.12.4.2 📌 Restricciones Computacionales

  • Redondeo: Usar redondeo matemático (round) en la LUT.
  • Bits: El número de niveles es \(2^B\) (ej.: \(B=3 \Rightarrow 8\) niveles, \(B=8 \Rightarrow 256\) niveles).
  • CDF acumulada: \(\text{cdf}[k] = \sum_{j=0}^{k} p[j]\), con \(\text{cdf}[2^B-1] = 1.0\).

3.12.4.3 🧠 Fundamentación Teórica

Etapa Operación Fórmula
1 Histograma \(h[k] \leftarrow\) nº píxeles con intensidad \(k\)
2 Probabilidad \(p[k] = h[k] / (L \cdot C)\)
3 CDF \(\text{cdf}[k] = \sum_{j=0}^{k} p[j]\)
4 LUT \(\text{lut}[Look-Up Table (tabla de consulta)k] = \text{round}(\text{cdf}[k] \cdot (2^B-1))\)
5 Aplicación \(g[i,j] = \text{lut}[f[i,j]]\)

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

Entrada:

  • Línea 1: Entero \(L\).
  • Línea 2: Entero \(C\).
  • Línea 3: Entero \(B\) (número de bits).
  • Líneas siguientes: Elementos enteros de la matriz.

Salida:

  • Matriz equalizada en \(L\) líneas y \(C\) columnas.

3.12.4.5 📌 Ejemplos

Entrada Salida Observación
5
5
3
3 4 2 3 4
4 3 3 4 3
2 3 4 3 2
3 4 3 2 3
4 3 2 3 4
5 7 1 5 7
7 5 5 7 5
1 5 7 5 1
5 7 5 1 5
7 5 1 5 7
Ejemplo 3 bits del capítulo
1
4
3
0 0 7 7
0 0 7 7 Histograma bimodal extremo
📊 Simulador EP03_04: Ecualización de Histograma lut[k] = round(cdf[k] · (L − 1))

Elija la profundidad de bits (B) y genere imágenes para analizar la dispersión dinámica del histograma y la tabla de remapeo (LUT) en tiempo real.

3 bits → 8 niveles
1 bit (2 niveles) 4 bits (16 niveles) 8 bits (256 niveles)
Entrada Original
Resultado Ecualizado
Histograma Original
Histograma Ecualizado
LUT (Tabla de Remapeo k → v)
lut[k] = round(cdf[k] · 7) | B=3, niveles=8
Figura 3.29: Simulador EP03_04: Ecualización de Histograma (Niveles L = 2^B)
%%writefile EP03_04.cpp
// your solution
Overwriting EP03_04.cpp
TestSuite("EP03_04.cpp").run()
✔️ EP03_04.cases ya existe en casos/
📋 5 caso(s) cargado(s) de casos/EP03_04.cases

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

3.12.5 EP03_05 🔲 Aplicación de Máscara AND Binaria

En sistemas de inspección industrial por visión por computadora, es necesario aislar regiones de interés (ROI) en imágenes de piezas para verificar defectos de fabricación. La operación AND bit a bit con una máscara binaria es el mecanismo fundamental para recortar exactamente el área de inspección, poniendo a cero todos los píxeles fuera de ella.

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

3.12.5.1 📋 Directrices de Implementación

  1. Dimensiones: Leer los enteros \(L\) (filas) y \(C\) (columnas).
  2. Datos: Leer la matriz de píxeles \(f\) (valores \(\in [0, 255]\)).
  3. Máscara: Leer la matriz binaria \(m\) (valores: solo 0 o 255).
  4. Mapeo: Para cada píxel \((i,j)\), aplicar el AND bit a bit:

\[ g(i,j) = f(i,j) \;\text{AND}\; m(i,j) \]

donde \(255 =\) 11111111 y \(0 =\) 00000000 en binario.

  1. Salida: Mostrar la matriz resultante \(L \times C\).

3.12.5.2 📌 Restricciones Computacionales

  • AND con 255: \(p \; \text{AND} \; 255 = p\) (todos los bits preservados).
  • AND con 0: \(p \; \text{AND} \; 0 = 0\) (todos los bits puestos a cero).
  • Máscara: Los únicos valores posibles en la máscara son 0 y 255.
  • Implementación: En Python, el AND bit a bit entre enteros usa el operador &.

3.12.5.3 🧠 Fundamentación Teórica

Píxel \(f\) Máscara \(m\) Resultado \(f\) AND \(m\)
cualquier \(v\) 255 (11111111) \(v\) (preservado)
cualquier \(v\) 0 (00000000) 0 (puesto a cero)

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

Entrada:

  • Línea 1: Entero \(L\).
  • Línea 2: Entero \(C\).
  • Líneas siguientes: Elementos de \(f\) (\(L\) líneas).
  • Líneas siguientes: Elementos de \(m\) (\(L\) líneas con valores 0 o 255).

Salida:

  • Matriz resultante \(L \times C\).

3.12.5.5 📌 Ejemplos

Entrada Salida Observación
2
3
100 150 200
50 80 120
255 255 0
0 255 255
100 150 0
0 80 120
La máscara selecciona la región
1
4
10 20 30 40
255 0 255 0
10 0 30 0 Alternado preservado/puesto a cero
⬛ Simulador EP03_05: Máscara AND Binaria g = f AND m

Haz clic en las celdas de la Máscara m para alternar entre transparente (255) y bloqueante (0), aplicando la operación lógica píxel a píxel.

Imagen f (0–255)
Máscara m (Clic para Alternar)
Resultado g = f AND m
—
—conservados
—puestos a cero
—visible
Leyenda:
255
Transparente (conservado)
0
Bloqueante (puesto a cero)
g(i,j) = f(i,j) & m(i,j)
Figura 3.30: Simulador EP03_05: Aplicación de Máscara AND Binaria
%%writefile EP03_05.cpp
// your solution
Overwriting EP03_05.cpp
TestSuite("EP03_05.cpp").run()
✔️ EP03_05.cases ya existe en casos/
📋 5 caso(s) cargado(s) de casos/EP03_05.cases

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

3.12.6 EP03_06 🌫️ Filtro de Media con Kernel N×N

En cámaras de vehículos autónomos, las imágenes capturadas bajo lluvia o niebla presentan ruido gaussiano. El filtro de media se utiliza ampliamente para su reducción en tiempo real, implementándose directamente en el ISP (Image Signal Processor) de los sensores CMOS (Complementary Metal-Oxide-Semiconductor).

Los sensores CMOS son los sensores de imagen utilizados en la mayoría de las cámaras modernas (smartphones, webcams, cámaras automotrices, etc.). Convierten la luz en señales eléctricas, y el ISP procesa estas señales en tiempo real — aplicando operaciones como reducción de ruido, balance de blancos y otros ajustes de imagen.

Ver en la Figura 3.31 una simulación de este EP.

3.12.6.1 📋 Directrices de Implementación

  1. Dimensiones: Leer los enteros \(L\) (filas), \(C\) (columnas) y \(N\) (tamaño del kernel, siempre impar).
  2. Datos: Leer la matriz de píxeles \(f\).
  3. Filtro de Media: Para cada píxel \((i,j)\) interno (sin bordes), calcular:

\[g(i,j) = \text{round}\left(\frac{1}{N^2} \sum_{s=-(r)}^{r} \sum_{t=-(r)}^{r} f(i+s,\, j+t)\right), \quad r = \lfloor N/2 \rfloor\]

  1. Tratamiento de Bordes: Los píxeles en el borde (donde la ventana \(N \times N\) sobrepasa los límites) deben copiarse directamente del original sin modificación.
  2. Salida: Mostrar la matriz resultante \(L \times C\).

3.12.6.2 📌 Restricciones Computacionales

  • Radio: \(r = \lfloor N/2 \rfloor\) (mitad del kernel, entero).
  • Píxeles internos: \((i,j)\) con \(r \le i < L-r\) y \(r \le j < C-r\).
  • Redondeo: Usar redondeo matemático antes de convertir a entero.
  • Sin clipping: El promedio de valores \(\in [0,255]\) permanece en \([0,255]\).

3.12.6.3 🧠 Fundamentación Teórica

Tamaño \(N\) Coeficiente Píxeles en la ventana Efecto
3 \(1/9 \approx 0.111\) 9 Suave
5 \(1/25 = 0.04\) 25 Medio
7 \(1/49 \approx 0.020\) 49 Fuerte

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

Entrada:

  • Línea 1: Entero \(L\).
  • Línea 2: Entero \(C\).
  • Línea 3: Entero \(N\) (impar, \(N \ge 3\)).
  • Líneas siguientes: Elementos de la matriz original.

Salida:

  • Matriz filtrada \(L \times C\).

3.12.6.5 📌 Ejemplos

Entrada Salida Observación
3
3
3
10 20 30
40 50 60
70 80 90
10 20 30
40 50 60
70 80 90
Solo borde (3×3 = borde total)
5
5
3
0 0 0 0 0
0 0 0 0 0
0 0 100 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 11 11 11 0
0 11 11 11 0
0 11 11 11 0
0 0 0 0 0
Píxel aislado: todos los 9 píxeles internos cuya ventana 3×3 incluye el valor 100 reciben round(100/9)=11
🔲 Simulador EP03_06: Filtro de Media con Kernel N×N g = Media(Vecinos)

Seleccione el tamaño del kernel y pase el mouse sobre los píxeles del resultado para inspeccionar la vecindad y el cálculo de la media aritmética.

Tamaño del kernel:
Imagen Original f (7×7) Con ruido sal y pimienta
Resultado g (Filtro Suavizado) Pase el mouse para inspeccionar
Leyenda:
Ventana del Kernel
Borde (Copiado)
Píxel Inspeccionado
Pase el mouse sobre un píxel interno del resultado para ver el cálculo de la media.
Figura 3.31: Simulador EP03_06: Filtro de Media con Kernel N×N
%%writefile EP03_06.cpp
// your solution
Overwriting EP03_06.cpp
TestSuite("EP03_06.cpp").run()
✔️ EP03_06.cases ya existe en casos/
📋 5 caso(s) cargado(s) de casos/EP03_06.cases

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

3.12.7 EP03_07 🔍 Operador Laplaciano (w4) para Realce de Bordas

En tomografías de alta resolución, la nitidez de los bordes entre tejidos es crítica para el diagnóstico. El operador Laplaciano se utiliza ampliamente en pipelines de preprocesamiento de imágenes médicas para resaltar automáticamente los contornos anatómicos antes de la segmentación, evitando la intervención manual del radiólogo.

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

3.12.7.1 📋 Directrices de Implementación

  1. Dimensiones: Leer los enteros \(L\) (filas) y \(C\) (columnas).
  2. Datos: Leer la matriz de píxeles \(f\).
  3. Laplaciano (w4): Para cada píxel interno \((i,j)\) con \(1 \le i < L-1\), \(1 \le j < C-1\), calcular:

\[\nabla^2 f(i,j) = f(i-1,j) + f(i+1,j) + f(i,j-1) + f(i,j+1) - 4 \cdot f(i,j)\]

  1. Realce: Calcular la imagen realzada:

\[g(i,j) = \text{clip}(f(i,j) - \nabla^2 f(i,j))\]

  1. Borde: Los píxeles en el borde se copian directamente: \(g(i,j) = f(i,j)\).
  2. Salida: Mostrar la matriz realzada \(L \times C\).

3.12.7.2 📌 Restricciones Computacionales

  • Kernel w4: \(\begin{bmatrix} 0 & 1 & 0 \\ 1 & -4 & 1 \\ 0 & 1 & 0 \end{bmatrix}\) — solo vecinos-4.
  • Saturación: \(\text{clip}(x) = \max(0, \min(255, x))\) aplicado al resultado del realce.
  • Sin redondeo: El Laplaciano utiliza solo sumas/restas de enteros.

3.12.7.3 🧠 Fundamentación Teórica

Región \(\nabla^2 f\) Efecto del Realce
Uniforme \(\approx 0\) Sin alteración
Borde creciente \(< 0\) Píxel aclarado
Borde decreciente \(> 0\) Píxel oscurecido

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

Entrada:

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

Salida:

  • Matriz realzada \(L \times C\).

3.12.7.5 📌 Ejemplos

Entrada Salida Observación
3
3
0 0 0
0 100 0
0 0 0
0 0 0
0 255 0
0 0 0
Pico aislado: lap=−400, g=100−(−400)=500 → clip=255
3
3
50 50 50
50 50 50
50 50 50
50 50 50
50 50 50
50 50 50
Región uniforme: Laplaciano=0, sin alteración
📐 Simulador EP03_07: Operador Laplaciano (w4) g = f ∓ ∇²f

Seleccione la variante de realce y pase el mouse sobre los píxeles internos del resultado para inspeccionar la vecindad de 4 puntos y la ecuación del Laplaciano.

Variante:
① Imagen Original f Escalón con ruido leve
② Laplaciano ∇²f Bordes detectados (±128 shift)
③ Resultado g = f − ∇²f Pase el mouse para inspeccionar
Kernel w4 (4-Vecinos)
0
+1
0
+1
−4
+1
0
+1
0
∇²f = T + B + L + R − 4·f
Leyenda:
4-Vecinos del Kernel
Píxel Central
Borde (Copiado)
Pase el mouse sobre un píxel interno del resultado para detallar la ecuación.
Figura 3.32: Simulador EP03_07: Operador Laplaciano (w4) para Realce de Bordas
%%writefile EP03_07.cpp
// your solution
Overwriting EP03_07.cpp
TestSuite("EP03_07.cpp").run()
✔️ EP03_07.cases ya existe en casos/
📋 5 caso(s) cargado(s) de casos/EP03_07.cases

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

3.12.8 EP03_08 🧭 Gradiente de Sobel: Gx y Gy

En robots exploradores de Marte (como el Perseverance), la detección de obstáculos se realiza en tiempo real mediante cámaras estereoscópicas. El operador de Sobel calcula el gradiente direccional de la escena y se utiliza en el algoritmo de detección de bordes para identificar rocas, fisuras y desniveles del terreno que puedan comprometer la navegación.

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

3.12.8.1 📋 Directrices de Implementación

  1. Dimensiones: Leer los enteros \(L\) (filas) y \(C\) (columnas).
  2. Datos: Leer la matriz \(f\).
  3. Gx y Gy: Para cada píxel interno \((i,j)\) con \(1 \le i < L-1\), \(1 \le j < C-1\):

\[G_x(i,j) = [f(i-1,j+1) + 2f(i,j+1) + f(i+1,j+1)] - [f(i-1,j-1) + 2f(i,j-1) + f(i+1,j-1)]\]

\[G_y(i,j) = [f(i+1,j-1) + 2f(i+1,j) + f(i+1,j+1)] - [f(i-1,j-1) + 2f(i-1,j) + f(i-1,j+1)]\]

  1. Magnitud: \(|\nabla f(i,j)| = \text{clip}(\text{round}(\sqrt{G_x^2 + G_y^2}))\).
  2. Borde: Los píxeles de borde reciben magnitud 0.
  3. Salida: Mostrar la magnitud \(L \times C\).

3.12.8.2 📌 Restricciones Computacionales

  • Redondeo: Aplicar round antes de convertir a entero.
  • Saturación: \(\text{clip}(x) = \max(0, \min(255, x))\).
  • Raíz cuadrada: Usar \(\sqrt{G_x^2 + G_y^2}\) (no la aproximación \(|G_x| + |G_y|\)).

3.12.8.3 🧠 Fundamentación Teórica

Operador Detecta Coeficientes diagonales
\(G_x\) Bordes verticales \(\pm 1\)
\(G_y\) Bordes horizontales \(\pm 1\)
\(|\nabla f|\) Todos los bordes Combinado

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

Entrada:

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

Salida:

  • Magnitud del gradiente, matriz \(L \times C\).

3.12.8.5 📌 Ejemplos

Entrada Salida Observación
3
3
0 0 0
0 0 0
0 0 0
0 0 0
0 0 0
0 0 0
Imagen nula: gradiente cero
3
3
0 0 255
0 0 255
0 0 255
0 0 0
0 255 0
0 0 0
Borde vertical central: Gx alto
🧭 Simulador EP03_08: Gradiente de Sobel (Gx y Gy) |∇f| = √(Gx² + Gy²)

Analice la descomposición horizontal (Gx) y vertical (Gy) del operador de Sobel y pase el mouse sobre los píxeles de la magnitud para inspeccionar la vecindad 3×3.

Kernels de Sobel:
−1
0
+1
−2
0
+2
−1
0
+1
Gx
−1
−2
−1
0
0
0
+1
+2
+1
Gy
Imagen Original f Matriz 5×5 píxeles
Magnitud |∇f| √(Gx² + Gy²)
Gx — Gradiente Horizontal Azul = Negativo · Blanco = Cero · Azul Vivo = Positivo
Gy — Gradiente Vertical Ámbar = Negativo · Blanco = Cero · Ámbar Vivo = Positivo
Leyenda:
Vecindad 3×3 Inspeccionada
Píxel Central
Borde (Forzado a 0)
Pase el mouse sobre un píxel interno de la magnitud para ver la descomposición Gx y Gy.
Figura 3.33: Simulador EP03_08: Gradiente de Sobel (Gx y Gy)
%%writefile EP03_08.cpp
// your solution
Overwriting EP03_08.cpp
TestSuite("EP03_08.cpp").run()
✔️ EP03_08.cases ya existe en casos/
📋 5 caso(s) cargado(s) de casos/EP03_08.cases

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

3.12.9 EP03_09 📡 Filtro de la Mediana 3×3

Las imágenes de radar de apertura sintética (SAR) utilizadas en monitoreo ambiental y militar sufren de un tipo específico de ruido llamado speckle, que posee características similares al ruido sal y pimienta. El filtro de la mediana es el método estándar para eliminar este ruido porque preserva los bordes de las estructuras mientras elimina los puntos espurios.

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

3.12.9.1 📋 Directrices de Implementación

  1. Dimensiones: Leer los enteros \(L\) (filas) y \(C\) (columnas).
  2. Datos: Leer la matriz de píxeles \(f\).
  3. Filtro de la Mediana 3×3: Para cada píxel interno \((i,j)\) con \(1 \le i < L-1\), \(1 \le j < C-1\):
    • Recolectar los 9 píxeles de la vecindad \(3 \times 3\): \(\{f(i+s, j+t) : s,t \in \{-1,0,1\}\}\).
    • Ordenar los 9 valores en orden creciente.
    • Asignar \(g(i,j)\) al valor central (posición índice 4, considerando índice 0).

\[g(i,j) = \text{mediana}\{f(i+s, j+t) : s,t \in \{-1,0,1\}\}\]

  1. Borde: Copiar directamente: \(g(i,j) = f(i,j)\).
  2. Salida: Mostrar la matriz filtrada \(L \times C\).

3.12.9.2 📌 Restricciones Computacionales

  • Ventana: Siempre \(3 \times 3 = 9\) elementos.
  • Mediana: El elemento central de la secuencia ordenada (índice 4 de 0 a 8).
  • Sin recorte: La mediana de valores en \([0, 255]\) permanece en \([0, 255]\).
  • No lineal: El filtro de mediana no puede expresarse como convolución lineal.

3.12.9.3 🧠 Fundamentación Teórica

Ruido Filtro de Media Filtro de Mediana
Sal y pimienta (0 o 255) Dispersa el ruido Elimina sin distorsionar bordes
Gaussiano Reduce eficazmente Reduce parcialmente

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

Entrada:

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

Salida:

  • Matriz filtrada \(L \times C\).

3.12.9.5 📌 Ejemplos

Entrada Salida Observación
3
3
100 100 100
100 0 100
100 100 100
100 100 100
100 100 100
100 100 100
Punto negro eliminado: mediana de 8×100+1×0 = 100
3
3
50 50 50
50 255 50
50 50 50
50 50 50
50 50 50
50 50 50
Punto blanco (sal) eliminado
📉 Simulador EP03_09: Filtro de la Mediana 3×3 g = Mediana(Vecinos)

Inyecte ruido impulsivo (sal y pimienta) y pase el mouse sobre los píxeles internos del resultado para inspeccionar la ordenación del vector de vecindad y la eliminación del ruido.

Ruido de sal (255) y pimienta (0) — ~30% de los píxeles internos afectados
Imagen f — Con Ruido Sal (255) y pimienta (0) visibles
Resultado g — Sin Ruido Pase el mouse para inspeccionar
Vector de Vecindad 3×3 — Ordenado Pase el mouse sobre un píxel interno del resultado para visualizar
—
Leyenda:
Ventana 3×3
Píxel Central
Borde (Copiado)
Mediana
Ruido (Eliminado)
Pase el mouse sobre un píxel interno del resultado para ver el proceso de ordenación.
Figura 3.34: Simulador EP03_09: Filtro de la Mediana 3×3
%%writefile EP03_09.cpp
// your solution
Overwriting EP03_09.cpp
TestSuite("EP03_09.cpp").run()
✔️ EP03_09.cases ya existe en casos/
📋 5 caso(s) cargado(s) de casos/EP03_09.cases

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

3.12.10 EP03_10 ✨ Unsharp Masking (USM)

En los sistemas de digitalización de documentos históricos y obras de arte, la nitidez de las imágenes es fundamental para la lectura de textos manuscritos y detalles ornamentales. El Unsharp Masking (USM) es el algoritmo de realce de nitidez estándar utilizado en escáneres profesionales y software como Adobe Photoshop, controlado por el parámetro \(k\) que determina la intensidad del realce.

Ver en la Figura 3.35 una simulación de este EP.

3.12.10.1 📋 Directrices de Implementación

  1. Dimensiones: Leer los enteros \(L\) (líneas) y \(C\) (columnas).
  2. Parámetro: Leer el valor real \(k\) (intensidad del realce, \(k \ge 0\)).
  3. Datos: Leer la matriz de píxeles \(f\).
  4. Suavizado: Calcular \(\bar{f}\) con el filtro de promedio \(3\times3\) (solo píxeles internos; bordes mantenidos):

\[\bar{f}(i,j) = \frac{1}{9} \sum_{s=-1}^{1} \sum_{t=-1}^{1} f(i+s, j+t)\]

  1. Máscara de alta frecuencia: \(m(i,j) = f(i,j) - \bar{f}(i,j)\).
  2. Realce USM: Para cada píxel interno:

\[g(i,j) = \text{clip}\left(\text{round}\left(f(i,j) + k \cdot m(i,j)\right)\right)\]

  1. Borde: \(g(i,j) = f(i,j)\) (copia directa).
  2. Salida: Mostrar la matriz realzada \(L \times C\).

3.12.10.2 📌 Restricciones Computacionales

  • Redondeo: Aplicar round antes del clip.
  • Saturación: \(\text{clip}(x) = \max(0, \min(255, x))\).
  • Operaciones en float: Calcular \(\bar{f}\) y \(m\) en punto flotante antes de redondear el resultado final.
  • \(k = 0\): Sin realce — la salida es idéntica a la entrada (excepto en los bordes).

3.12.10.3 🧠 Fundamentación Teórica

Etapa Operación Descripción
1 \(\bar{f} = f * \frac{1}{9}\mathbf{1}_{3\times3}\) Suavizado (bajas frecuencias)
2 \(m = f - \bar{f}\) Máscara (altas frecuencias)
3 \(g = \text{clip}(\text{round}(f + k \cdot m))\) Realce ponderado

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

Entrada:

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

Salida:

  • Matriz realzada \(L \times C\).

3.12.10.5 📌 Ejemplos

Entrada Salida Observación
3
3
0.0
100 100 100
100 100 100
100 100 100
100 100 100
100 100 100
100 100 100
k=0: sin realce
3
3
1.0
50 50 50
50 200 50
50 50 50
50 50 50
50 255 50
50 50 50
k=1: píxel central realzado y saturado
✨ Simulador EP03_10: Enmascaramiento Unsharp (USM) g = f + k · m

Ajusta el factor de ganancia k, observa el pipeline completo de realce (desenfoque, máscara de alta frecuencia) y pasa el mouse sobre el resultado.

Factor de ganancia k: k = 1.0
① Imagen Original f Matriz 5×5 píxeles
② Desenfocado f̄ Promedio 3 × 3
③ Máscara m m = f − f̄ (Altas Frecuencias)
④ Resultado g = f + 1.0·m Pasa el mouse para inspeccionar
Leyenda:
Vecindario 3×3
Píxel Central
Borde (Copiado)
Máscara Positiva/Negativa
Pasa el mouse sobre un píxel interno del resultado para rastrear el pipeline completo.
Figura 3.35: Simulador EP03_10: Máscara de enfoque (USM)
%%writefile EP03_10.cpp
// your solution
Overwriting EP03_10.cpp
TestSuite("EP03_10.cpp").run()
✔️ EP03_10.cases ya existe en casos/
📋 5 caso(s) cargado(s) de casos/EP03_10.cases

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