Fusión de Imágenes Flash y No-Flash para Restauración Fotográfica

Francisco ZavalaFrancisco Zavala
10 min read

La correcta iluminación de una escena juega un papel importante en la obtención de una buena fotografía para transmitir sensaciones visuales a través de la atmósfera que crea: los matices de una vela pueden sugerir calidad, mientras que las paletas azuladas en penumbra evocan frío y misterio.

En contextos de baja iluminación, capturar esa atmósfera sin sacrificar calidad técnica representa un desafío importante. Para lograr una imagen adecuada en entornos con poca luz, el fotógrafo debe encontrar un equilibrio delicado entre la apertura del diafragma, el tiempo del obturador y la sensibilidad ISO. Aumentar el tiempo de exposición permite capturar más luz, pero puede producir desenfoques por movimiento (motion blur). Abrir más el diafragma reduce la necesidad de tiempos largos, pero disminuye la profundidad de campo. Elevar el ISO aumenta la sensibilidad del sensor, aunque también incrementa la presencia de ruido, especialmente en exposiciones cortas.

Una solución común es el uso del flash, que permite obtener imágenes nítidas y bien expuestas. Sin embargo, esta técnica introduce varios problemas: los objetos cercanos tienden a sobreexponerse, se pierden los matices de la luz ambiental, y aparecen artefactos como ojos rojos, sombras duras o brillos especulares indeseados.

En el trabajo "Digital Photography with Flash and No-Flash Image Pairs". propusieron una técnica , para combinar una imagen con flash y otra sin el, para crear una nueva que conserva la iluminación natural de la escena mientras incorpora el nivel de detalle de la imagen con flash

Explicación del algoritmo

El algoritmo propuesto por Eisemann y Durand se compone de los siguientes pasos principales:

  1. Reducción de ruido en la imagen sin flash
    Se utiliza la imagen con flash, que posee menor ruido, como referencia para eliminar el ruido de la imagen tomada con luz ambiente, preservando su iluminación original.

  2. Transferencia de detalle de alta frecuencia
    Se extraen texturas finas y bordes nítidos de la imagen con flash y se incorporan en la imagen sin flash ya filtrada, mejorando su nivel de detalle sin alterar su tonalidad global.

  3. Corrección de balance de blancos
    (opcional) A partir del color conocido del flash, se ajusta la temperatura de color de la imagen ambiental.

  4. Interpolación continua entre ambas imágenes
    Se ajusta la intensidad del efecto del flash, interpolando o incluso extrapolando entre las dos imágenes originales para obtener un resultado personalizado.

  5. Corrección de ojos rojos
    (opcional) Se detecta este artefacto comparando los colores de la pupila en ambas imágenes, aplicando una corrección precisa basada en el cambio producido por el flash.

def enhance_ambient_with_flash(ambient, flash):
    """
    Enhances the ambient image using the flash image.

    Parameters:
        ambient (numpy array): Ambient image.
        flash (numpy array): Flash image.

    Returns:
        numpy array: Enhanced ambient image.
    """
    ambient_lin = ambient.astype(np.float32) / 255
    flash_lin = flash.astype(np.float32) / 255

    # Compute ambient color and denoise
    denoised_ambient = joint_bilateral_filter(ambient, flash, sigma_d=10, sigma_r=0.2)

    # Compute detail layer
    detail_layer = compute_detail_layer(flash, sigma_d=30, sigma_r=0.9, epsilon=0.01)

    # Detect shadows and specular highlights
    specular_mask = detect_flash_specularities(flash_lin, threshold=0.95)
    mask = detect_flash_shadows(flash_lin, ambient_lin, tau=0.01)

    # Combine shadow and specular masks
    full_mask = np.clip(mask + specular_mask, 0, 1)
    full_mask = cv2.GaussianBlur(full_mask, (5, 5), 5)
    full_mask = np.repeat(full_mask[..., np.newaxis], 3, axis=2)

    # Final merge
    transferred = denoised_ambient * detail_layer
    final_image = apply_masked_merge(transferred, denoised_ambient, full_mask)

    return np.clip(final_image, 0, 255).astype(np.uint8)

Filtrado de ruido

La imagen sin flash suele presentar un nivel elevado de ruido, especialmente en condiciones de baja iluminación. Por esta razón, uno de los primeros pasos del algoritmo consiste en aplicar un proceso de reducción de ruido.

El filtrado es un área ampliamente estudiada en el procesamiento de imágenes, y existen numerosos filtros diseñados con este propósito. En esta técnica se emplea el filtro bilateral, debido a que ofrece una ventaja clave: suaviza la imagen sin destruir los bordes, preservando tanto la estructura como la información de iluminación.

El filtro bilateral funciona como un promedio ponderado de los píxeles vecinos, pero, a diferencia del promedio tradicional, asigna pesos no solo según la cercanía espacial, sino también según la similitud de intensidad. La fórmula general es:

$$h(x) = \sum_{i \in \Omega(x)} g_s(x - i) \cdot g_r(I(x) - I(i))$$

Sin embargo, en condiciones extremas —cuando la imagen sin flash tiene un nivel de ruido tan alto que incluso los bordes se vuelven poco distinguibles— es mejor utilizar una variante llamada Joint Bilateral Filter. Esta versión guía el filtrado de la imagen ruidosa utilizando otra imagen más confiable (en este caso, la imagen con flash G), lo cual permite preservar mejor los bordes reales.

$$h(x) = \sum_{i \in \Omega(x)} g_s(x - i) \cdot g_r(G(x) - G(i))$$

def joint_bilateral_filter(ambient, flash, sigma_d=15, sigma_r=0.1):
    """
    Applies a joint bilateral filter using the flash image as a guide.

    Parameters:
        ambient (numpy array): Ambient image (to be filtered).
        flash (numpy array): Flash image (used as a guide).
        sigma_d (float): Spatial sigma (controls the range of spatial smoothing).
        sigma_r (float): Range sigma (controls the range of intensity smoothing).

    Returns:
        numpy array: Filtered ambient image.
    """
    import cv2.ximgproc  # Ensure opencv-contrib-python is installed

    ambient_uint8 = ambient.astype(np.uint8)
    flash_uint8 = flash.astype(np.uint8)

    filtered = np.zeros_like(ambient_uint8)

    for i in range(3):  # Process each channel (BGR)
        filtered[..., i] = cv2.ximgproc.jointBilateralFilter(
            joint=flash_uint8[..., i],    # Guide image (flash)
            src=ambient_uint8[..., i],    # Image to be filtered (ambient)
            d=-1,
            sigmaColor=sigma_r * 255,
            sigmaSpace=sigma_d
        )

    return filtered.astype(np.float32)

Este enfoque permite reducir el ruido de manera más robusta, incluso cuando la imagen original está severamente degradada, ya que la información estructural proviene de una fuente externa más confiable.

Una vez que la imagen sin flash ha sido suavizada para aislar su iluminación global, el siguiente paso del algoritmo consiste en transferir los detalles de alta frecuencia —como bordes nítidos, contornos definidos y texturas finas— desde la imagen capturada con flash. Para lograr esto, se aplica nuevamente un filtro bilateral a la imagen con flash, obteniendo una versión suavizada que conserva la estructura general pero elimina las variaciones locales rápidas.

Transferencia de detalle.

La capa de detalle se calcula entonces como la relación entre la imagen original con flash y su versión filtrada, una operación que resalta los cambios relativos en intensidad y permite aislar las texturas de forma multiplicativa. Esta estrategia es especialmente efectiva porque es invariante a escalas de iluminación, lo que evita distorsiones cromáticas o de brillo al fusionar las imágenes.

def compute_detail_layer(flash, sigma_d=10, sigma_r=0.7, epsilon=0.5):
    """
    Computes the detail layer of the flash image.

    Parameters:
        flash (numpy array): Flash image.
        sigma_d (float): Spatial sigma for the bilateral filter.
        sigma_r (float): Range sigma for the bilateral filter.
        epsilon (float): Small constant to avoid division by zero.

    Returns:
        numpy array: Detail layer.
    """
    base = bilateral_filter(flash, sigma_d, sigma_r)
    detail = (flash + epsilon) / (base + epsilon)
    return detail

capa de detalle extraída de la imagen con flash.

Finalmente, esta capa de detalle se incorpora sobre la imagen sin flash suavizada, conservando su atmósfera natural pero enriquecida visualmente con la nitidez aportada por el flash.

Detección de sombras y especularidades

El siguiente paso es identificar y excluir regiones afectadas por artefactos del flash, como las sombras proyectadas y los reflejos especulares. En el algoritmo propuesto, la detección de sombras se basa en comparar las versiones linealizadas (sin corrección gamma) de ambas imágenes, lo que garantiza que las diferencias de luminancia reflejen fielmente las variaciones físicas de iluminación.

Dado que los píxeles en sombra no reciben luz directa del flash, su luminancia en ambas imágenes debería ser muy similar o apenas superior, por lo que se construye una máscara de sombras mediante un umbral aplicado a la diferencia por canal en el espacio RGB lineal. Este criterio requiere que todos los canales estén por debajo de un umbral, asegurando así una detección robusta frente a variaciones cromáticas.

Aunque esta detección es efectiva, puede verse afectada por factores como el ruido de la imagen, las interreflexiones entre superficies, objetos de albedo muy bajo (negros absolutos) y regiones alejadas que no reciben iluminación del flash. Sin embargo, las dos últimas no comprometen el resultado final, pues ambas imágenes contienen información similar en esas zonas, evitando falsas detecciones.

def detect_flash_shadows(flash_lin, ambient_lin, tau=0.09):
    """
    Detects shadows caused by the flash.

    Parameters:
        flash_lin (numpy array): Flash image (linearized).
        ambient_lin (numpy array): Ambient image (linearized).
        tau (float): Threshold for shadow detection.

    Returns:
        numpy array: Shadow mask.
    """
    diff = flash_lin - ambient_lin
    shadow_mask = np.all(diff < tau, axis=2).astype(np.float32)
    return cv2.dilate(shadow_mask, None)

Para mejorar la máscara y evitar bordes fragmentados o ruidosos, se aplican operaciones morfológicas como la dilatación, que expanden y suavizan la cobertura de las sombras, garantizando una segmentación conservadora y continua de las áreas sombreadas. Esto resulta crucial para evitar que las sombras generen artefactos durante la fusión de las imágenes.

Por otro lado, las especularidades —reflejos intensos que saturan el sensor— también deben identificarse, ya que en esas zonas la imagen con flash pierde completamente el detalle. Para detectarlas, el algoritmo analiza la luminancia de la imagen con flash Fₗᵢₙ​ y marca como especular cualquier píxel cuya intensidad supere el 95% del rango del sensor.

def detect_flash_specularities(flash_lin, threshold=0.95):
    """
    Detects specular highlights caused by the flash.

    Parameters:
        flash_lin (numpy array): Flash image (linearized).
        threshold (float): Threshold for specular highlight detection.

    Returns:
        numpy array: Specular highlight mask.
    """
    luminance = 0.2126 * flash_lin[..., 2] + 0.7152 * flash_lin[..., 1] + 0.0722 * flash_lin[..., 0]
    specular_mask = (luminance >= threshold).astype(np.float32)
    return cv2.dilate(specular_mask, None)

Esta máscara también se refina con operaciones morfológicas, asegurando que se cubran adecuadamente las zonas saturadas. Al evitar el uso de datos provenientes de regiones con sombras duras o saturación especular, el algoritmo preserva tanto la estética natural de la iluminación ambiental como la calidad estructural de la imagen final.

Etapa final.

La etapa final del proceso consiste en combinar la imagen enriquecida con detalles y la imagen base suavizada, de forma que se aprovechen las fortalezas de ambas. Esta combinación se realiza mediante una mezcla ponderada controlada por una máscara, la cual indica en cada píxel qué proporción de cada imagen debe utilizarse.

En las zonas donde no hay artefactos —como sombras duras o reflejos especulares—, la máscara tiene valores cercanos a cero, permitiendo que predomine la imagen con detalles. Por el contrario, en regiones problemáticas la máscara toma valores cercanos a uno, priorizando la imagen base para evitar introducir errores visuales.

def apply_masked_merge(a, b, mask):
    """
    Merges two images using a mask.

    Parameters:
        a (numpy array): First image.
        b (numpy array): Second image.
        mask (numpy array): Mask to control blending.

    Returns:
        numpy array: Merged image.
    """
    return (1 - mask) * a + mask * b

Los valores intermedios permiten transiciones suaves entre ambas imágenes, lo que ayuda a mantener una apariencia continua y libre de bordes notorios. Gracias a esta interpolación controlada, se logra una imagen final que conserva la iluminación natural y la atmósfera ambiental, pero con una mejora perceptible en textura, nitidez y contraste.

La combinación de imágenes con y sin flash representa una solución ingeniosa y eficaz para superar las limitaciones de la captura de fotografias en ambientes de baja iluminacion. A través de un pipeline cuidadosamente diseñado, se logra un equilibrio entre la fidelidad atmosférica de la luz ambiental y la riqueza estructural que aporta el flash.

Este enfoque no solo mitiga defectos típicos como ruido, sombras duras o saturación especular, sino que también permite preservar la sensación original de la escena, respetando la intención artística del fotógrafo. Gracias al uso de herramientas como el filtrado bilateral guiado, la separación multiplicativa de detalles y de máscaras, el método ofrece una alternativa robusta para producir imágenes técnicamente sólidas y visualmente agradables, incluso en condiciones de iluminación desafiantes. La técnica propuesta en Digital Photography with Flash and No-Flash Image Pairs demuestra así que la fotografía computacional no solo puede corregir deficiencias, sino también ampliar las capacidades expresivas del medio fotográfico.

Resultados

Bibliografia.

Elmar Eisemann and Frédo Durand. 2004. Flash photography enhancement via intrinsic relighting. ACM Trans. Graph. 23, 3 (August 2004), 673–678. https://doi.org/10.1145/1015706.1015778

C. Chen, Q. Chen, J. Xu and V. Koltun, "Learning to See in the Dark," in 2018 IEEE/CVF Conference on Computer Vision and Pattern Recognition (CVPR), Salt Lake City, UT, USA, 2018, pp. 3291-3300, doi: 10.1109/CVPR.2018.00347.

0
Subscribe to my newsletter

Read articles from Francisco Zavala directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Francisco Zavala
Francisco Zavala