Introducción al procesamiento de imágenes de Python en fotografía computacional
Publicado: 2022-03-11La fotografía computacional se trata de mejorar el proceso fotográfico con la computación. Si bien normalmente tendemos a pensar que esto se aplica solo al procesamiento posterior del resultado final (similar a la edición de fotografías), las posibilidades son mucho más ricas ya que se puede habilitar el cálculo en cada paso del proceso fotográfico, comenzando con la iluminación de la escena, continuando con la lente, y eventualmente incluso en la visualización de la imagen capturada.
Esto es importante porque permite hacer mucho más y de diferentes maneras que lo que se puede lograr con una cámara normal. También es importante porque el tipo de cámara más frecuente en la actualidad, la cámara móvil, no es particularmente poderosa en comparación con su hermana mayor (la DSLR), pero logra hacer un buen trabajo al aprovechar la potencia de cómputo que tiene disponible en el dispositivo. .
Echaremos un vistazo a dos ejemplos en los que la computación puede mejorar la fotografía; más precisamente, veremos cómo simplemente tomar más fotos y usar un poco de Python para combinarlas puede crear buenos resultados en dos situaciones en las que el hardware de la cámara móvil no lo hace. realmente brille: poca luz y alto rango dinámico.
Fotografía con poca luz
Digamos que queremos tomar una fotografía de una escena con poca luz, pero la cámara tiene una pequeña apertura (lente) y un tiempo de exposición limitado. Esta es una situación típica de las cámaras de los teléfonos móviles que, dada una escena con poca luz, podría producir una imagen como esta (tomada con la cámara de un iPhone 6):
Si intentamos mejorar el contraste el resultado es el siguiente, que además es bastante malo:
¿Lo que sucede? ¿De dónde viene todo este ruido?
La respuesta es que el ruido proviene del sensor, el dispositivo que trata de determinar cuándo incide la luz y qué tan intensa es esa luz. Sin embargo, con poca luz, tiene que aumentar mucho su sensibilidad para registrar cualquier cosa, y esa alta sensibilidad significa que también comienza a detectar falsos positivos, fotones que simplemente no están allí. (Como nota al margen, este problema no afecta solo a los dispositivos, sino también a los humanos: la próxima vez que esté en una habitación oscura, tómese un momento para notar el ruido presente en su campo visual).
Cierta cantidad de ruido siempre estará presente en un dispositivo de imagen; sin embargo, si la señal (información útil) tiene una intensidad alta, el ruido será insignificante (alta relación señal/ruido). Cuando la señal es baja, como cuando hay poca luz, el ruido se destacará (señal baja a ruido).
Aún así, podemos superar el problema del ruido, incluso con todas las limitaciones de la cámara, para obtener mejores tomas que la anterior.
Para hacer eso, debemos tener en cuenta lo que sucede con el tiempo: la señal seguirá siendo la misma (la misma escena y asumimos que es estática) mientras que el ruido será completamente aleatorio. Esto quiere decir que, si hacemos muchas tomas de la escena, tendrán distintas versiones del ruido, pero la misma información útil.
Entonces, si promediamos muchas imágenes tomadas a lo largo del tiempo, el ruido se cancelará mientras que la señal no se verá afectada.
La siguiente ilustración muestra un ejemplo simplificado: tenemos una señal (triángulo) afectada por ruido e intentamos recuperar la señal promediando varias instancias de la misma señal afectada por diferentes ruidos.
Vemos que, aunque el ruido es lo suficientemente fuerte como para distorsionar completamente la señal en cualquier caso, promediar reduce progresivamente el ruido y recuperamos la señal original.
Veamos cómo se aplica este principio a las imágenes: Primero, necesitamos tomar varias tomas del sujeto con la exposición máxima que permite la cámara. Para obtener los mejores resultados, use una aplicación que permita disparar manualmente. Es importante que las tomas se tomen desde el mismo lugar, por lo que un trípode (improvisado) ayudará.
Más disparos generalmente significarán una mejor calidad, pero el número exacto depende de la situación: cuánta luz hay, qué tan sensible es la cámara, etc. Un buen rango podría estar entre 10 y 100.
Una vez que tengamos estas imágenes (en formato raw si es posible), podemos leerlas y procesarlas en Python.
Para aquellos que no estén familiarizados con el procesamiento de imágenes en Python, debemos mencionar que una imagen se representa como una matriz 2D de valores de bytes (0-255), es decir, para una imagen monocromática o en escala de grises. Una imagen en color se puede considerar como un conjunto de tres imágenes, una para cada canal de color (R, G, B) o, de hecho, como una matriz 3D indexada por posición vertical, posición horizontal y canal de color (0, 1, 2). .
Haremos uso de dos bibliotecas: NumPy (http://www.numpy.org/) y OpenCV (https://opencv.org/). El primero nos permite realizar cálculos en matrices de manera muy efectiva (con un código sorprendentemente corto), mientras que OpenCV maneja la lectura/escritura de los archivos de imagen en este caso, pero es mucho más capaz y proporciona muchos procedimientos gráficos avanzados, algunos de los cuales veremos. utilizar más adelante en el artículo.
import os import numpy as np import cv2 folder = 'source_folder' # We get all the image files from the source folder files = list([os.path.join(folder, f) for f in os.listdir(folder)]) # We compute the average by adding up the images # Start from an explicitly set as floating point, in order to force the # conversion of the 8-bit values from the images, which would otherwise overflow average = cv2.imread(files[0]).astype(np.float) for file in files[1:]: image = cv2.imread(file) # NumPy adds two images element wise, so pixel by pixel / channel by channel average += image # Divide by count (again each pixel/channel is divided) average /= len(files) # Normalize the image, to spread the pixel intensities across 0..255 # This will brighten the image without losing information output = cv2.normalize(average, None, 0, 255, cv2.NORM_MINMAX) # Save the output cv2.imwrite('output.png', output)
El resultado (con el contraste automático aplicado) muestra que el ruido ha desaparecido, una gran mejora con respecto a la imagen original.
Sin embargo, todavía notamos algunos artefactos extraños, como el marco verdoso y el patrón en forma de cuadrícula. Esta vez, no es un ruido aleatorio, sino un ruido de patrón fijo. ¿Qué sucedió?
Una vez más, podemos echarle la culpa al sensor. En este caso, vemos que diferentes partes del sensor reaccionan de manera diferente a la luz, lo que da como resultado un patrón visible. Algunos elementos de estos patrones son regulares y probablemente estén relacionados con el sustrato del sensor (metal/silicio) y cómo refleja/absorbe los fotones entrantes. Otros elementos, como el píxel blanco, son simplemente píxeles del sensor defectuosos, que pueden ser demasiado sensibles o demasiado insensibles a la luz.
Afortunadamente, también hay una manera de deshacerse de este tipo de ruido. Se llama sustracción de cuadros oscuros.
Para hacer eso, necesitamos una imagen del patrón de ruido en sí, y esto se puede obtener si fotografiamos la oscuridad. Sí, así es, simplemente cubra el orificio de la cámara y tome muchas fotografías (digamos 100) con el tiempo de exposición y el valor ISO máximos, y procéselas como se describe anteriormente.
Al promediar muchos fotogramas negros (que de hecho no son negros, debido al ruido aleatorio), terminaremos con el patrón de ruido fijo. Podemos suponer que este ruido fijo se mantendrá constante, por lo que este paso solo es necesario una vez: la imagen resultante se puede reutilizar para todas las futuras tomas con poca luz.
Así es como se ve la parte superior derecha del patrón de ruido (contraste ajustado) para un iPhone 6:

Una vez más, notamos la textura en forma de cuadrícula e incluso lo que parece ser un píxel blanco atascado.
Una vez que tengamos el valor de este ruido de cuadro oscuro (en la variable average_noise
), podemos simplemente restarlo de nuestra toma hasta el momento, antes de normalizar:
average -= average_noise output = cv2.normalize(average, None, 0, 255, cv2.NORM_MINMAX) cv2.imwrite('output.png', output)
Aquí está nuestra foto final:
Alto rango dinámico
Otra limitación que tiene una cámara pequeña (móvil) es su pequeño rango dinámico, lo que significa que el rango de intensidades de luz en el que puede capturar detalles es bastante pequeño.
En otras palabras, la cámara puede capturar solo una banda estrecha de las intensidades de luz de una escena; las intensidades por debajo de esa banda aparecen como negro puro, mientras que las intensidades por encima aparecen como blanco puro, y cualquier detalle se pierde en esas regiones.
Sin embargo, hay un truco que la cámara (o el fotógrafo) puede usar, y es ajustar el tiempo de exposición (el tiempo que el sensor está expuesto a la luz) para controlar la cantidad total de luz que llega al sensor, de manera efectiva. cambiando el rango hacia arriba o hacia abajo para capturar el rango más apropiado para una escena determinada.
Pero esto es un compromiso. Muchos detalles no logran llegar a la foto final. En las dos imágenes siguientes, vemos la misma escena capturada con diferentes tiempos de exposición: una exposición muy corta (1/1000 seg), una exposición media (1/50 seg) y una exposición larga (1/4 seg).
Como puede ver, ninguna de las tres imágenes es capaz de capturar todos los detalles disponibles: el filamento de la lámpara es visible solo en el primer plano, y algunos de los detalles de la flor son visibles en el medio o en el último plano pero no ambas cosas.
La buena noticia es que hay algo que podemos hacer al respecto y, de nuevo, se trata de construir varias tomas con un poco de código de Python.
El enfoque que tomaremos se basa en el trabajo de Paul Debevec et al., quien describe el método en su artículo aquí. El método funciona así:
Primero, requiere múltiples tomas de la misma escena (estacionaria) pero con diferentes tiempos de exposición. Nuevamente, como en el caso anterior, necesitamos un trípode o soporte para asegurarnos de que la cámara no se mueva en absoluto. También necesitamos una aplicación de disparo manual (si usamos un teléfono) para que podamos controlar el tiempo de exposición y evitar los ajustes automáticos de la cámara. El número de disparos necesarios depende del rango de intensidades presentes en la imagen (de tres en adelante), y los tiempos de exposición deben espaciarse en ese rango para que los detalles que nos interesa conservar se vean claramente en al menos un disparo.
A continuación, se utiliza un algoritmo para reconstruir la curva de respuesta de la cámara en función del color de los mismos píxeles en los diferentes tiempos de exposición. Básicamente, esto nos permite establecer un mapa entre el brillo real de la escena de un punto, el tiempo de exposición y el valor que tendrá el píxel correspondiente en la imagen capturada. Usaremos la implementación del método Debevec de la biblioteca OpenCV.
# Read all the files with OpenCV files = ['1.jpg', '2.jpg', '3.jpg', '4.jpg', '5.jpg'] images = list([cv2.imread(f) for f in files]) # Compute the exposure times in seconds exposures = np.float32([1. / t for t in [1000, 500, 100, 50, 10]]) # Compute the response curve calibration = cv2.createCalibrateDebevec() response = calibration.process(images, exposures)
La curva de respuesta se parece a esto:
En el eje vertical tenemos el efecto acumulativo del brillo de la escena de un punto y el tiempo de exposición, mientras que en el eje horizontal tenemos el valor (0 a 255 por canal) que tendrá el píxel correspondiente.
Esta curva nos permite realizar la operación inversa (que es el siguiente paso en el proceso): dado el valor del píxel y el tiempo de exposición, podemos calcular el brillo real de cada punto de la escena. Este valor de brillo se denomina irradiancia y mide la cantidad de energía luminosa que cae sobre una unidad de área del sensor. A diferencia de los datos de imagen, se representa mediante números de punto flotante porque refleja un rango de valores mucho más amplio (por lo tanto, alto rango dinámico). Una vez que tenemos la imagen de irradiancia (la imagen HDR) simplemente podemos guardarla:
# Compute the HDR image merge = cv2.createMergeDebevec() hdr = merge.process(images, exposures, response) # Save it to disk cv2.imwrite('hdr_image.hdr', hdr)
Para aquellos de nosotros que tenemos la suerte de tener una pantalla HDR (que se está volviendo cada vez más común), es posible que podamos visualizar esta imagen directamente en todo su esplendor. Desafortunadamente, los estándares HDR aún están en pañales, por lo que el proceso para hacerlo puede ser algo diferente para diferentes pantallas.
Para el resto de nosotros, la buena noticia es que aún podemos aprovechar estos datos, aunque una pantalla normal requiere que la imagen tenga canales de valor de byte (0-255). Si bien debemos renunciar a parte de la riqueza del mapa de irradiancia, al menos tenemos el control sobre cómo hacerlo.
Este proceso se denomina mapeo de tonos e implica convertir el mapa de irradiancia de punto flotante (con un rango alto de valores) en una imagen de valor de byte estándar. Existen técnicas para hacer eso de modo que se conserven muchos de los detalles adicionales. Solo para darle un ejemplo de cómo puede funcionar esto, imagine que antes de comprimir el rango de punto flotante en valores de bytes, mejoramos (afilamos) los bordes que están presentes en la imagen HDR. Mejorar estos bordes ayudará a preservarlos (e implícitamente el detalle que brindan) también en la imagen de bajo rango dinámico.
OpenCV proporciona un conjunto de estos operadores de mapeo de tonos, como Drago, Durand, Mantiuk o Reinhardt. Aquí hay un ejemplo de cómo se puede usar uno de estos operadores (Durand) y del resultado que produce.
durand = cv2.createTonemapDurand(gamma=2.5) ldr = durand.process(hdr) # Tonemap operators create floating point images with values in the 0..1 range # This is why we multiply the image with 255 before saving cv2.imwrite('durand_image.png', ldr * 255)
Con Python, también puede crear sus propios operadores si necesita más control sobre el proceso. Por ejemplo, este es el resultado obtenido con un operador personalizado que elimina las intensidades que se representan en muy pocos píxeles antes de reducir el rango de valores a 8 bits (seguido de un paso de contraste automático):
Y aquí está el código para el operador anterior:
def countTonemap(hdr, min_fraction=0.0005): counts, ranges = np.histogram(hdr, 256) min_count = min_fraction * hdr.size delta_range = ranges[1] - ranges[0] image = hdr.copy() for i in range(len(counts)): if counts[i] < min_count: image[image >= ranges[i + 1]] -= delta_range ranges -= delta_range return cv2.normalize(image, None, 0, 1, cv2.NORM_MINMAX)
Conclusión
Hemos visto cómo con un poco de Python y un par de bibliotecas compatibles, podemos superar los límites de la cámara física para mejorar el resultado final. Ambos ejemplos que hemos discutido usan múltiples tomas de baja calidad para crear algo mejor, pero hay muchos otros enfoques para diferentes problemas y limitaciones.
Si bien muchos teléfonos con cámara tienen una tienda o aplicaciones integradas que abordan estos ejemplos particulares, claramente no es nada difícil programarlos a mano y disfrutar del mayor nivel de control y comprensión que se puede obtener.
Si está interesado en los cálculos de imágenes en un dispositivo móvil, consulte el tutorial de OpenCV: Detección de objetos en tiempo real con MSER en iOS por parte de Toptaler y el desarrollador de élite de OpenCV, Altaibayar Tseveenbayar.