Introduzione all'elaborazione delle immagini Python nella fotografia computazionale
Pubblicato: 2022-03-11La fotografia computazionale riguarda il miglioramento del processo fotografico con il calcolo. Mentre normalmente tendiamo a pensare che ciò si applichi solo alla post-elaborazione del risultato finale (simile al fotoritocco), le possibilità sono molto più ricche poiché il calcolo può essere abilitato in ogni fase del processo fotografico, a partire dall'illuminazione della scena, continuando con l'obiettivo, ed eventualmente anche alla visualizzazione dell'immagine catturata.
Questo è importante perché permette di fare molto di più e in modi diversi rispetto a quanto si può ottenere con una normale fotocamera. È anche importante perché il tipo di fotocamera più diffuso al giorno d'oggi, la fotocamera mobile, non è particolarmente potente rispetto alla sorella maggiore (la DSLR), ma riesce a fare un buon lavoro sfruttando la potenza di calcolo che ha a disposizione sul dispositivo .
Daremo un'occhiata a due esempi in cui il calcolo può migliorare la fotografia: più precisamente, vedremo come semplicemente scattare più scatti e usare un po' di Python per combinarli può creare buoni risultati in due situazioni in cui l'hardware della fotocamera mobile non lo fa brillano davvero: scarsa illuminazione e gamma dinamica elevata.
Fotografia in condizioni di scarsa illuminazione
Diciamo che vogliamo scattare una fotografia di una scena in condizioni di scarsa illuminazione, ma la fotocamera ha una piccola apertura (obiettivo) e un tempo di esposizione limitato. Questa è una situazione tipica per le fotocamere dei telefoni cellulari che, data una scena in condizioni di scarsa illuminazione, potrebbero produrre un'immagine come questa (scattata con una fotocamera iPhone 6):
Se proviamo a migliorare il contrasto il risultato è il seguente, che è anche piuttosto negativo:
Che succede? Da dove viene tutto questo rumore?
La risposta è che il rumore proviene dal sensore, il dispositivo che cerca di determinare quando la luce lo colpisce e quanto sia intensa quella luce. In condizioni di scarsa illuminazione, tuttavia, deve aumentare notevolmente la sua sensibilità per registrare qualsiasi cosa, e quell'elevata sensibilità significa che inizia anche a rilevare falsi positivi, fotoni che semplicemente non ci sono. (Come nota a margine, questo problema non riguarda solo i dispositivi, ma anche noi umani: la prossima volta che ti trovi in una stanza buia, prenditi un momento per notare il rumore presente nel tuo campo visivo.)
Una certa quantità di rumore sarà sempre presente in un dispositivo di imaging; tuttavia, se il segnale (informazione utile) ha un'intensità elevata, il rumore sarà trascurabile (rapporto segnale/rumore elevato). Quando il segnale è basso, ad esempio in condizioni di scarsa illuminazione, il rumore risalterà (basso segnale a rumore).
Tuttavia, possiamo superare il problema del rumore, anche con tutti i limiti della fotocamera, in modo da ottenere scatti migliori di quello sopra.
Per fare ciò, dobbiamo tenere conto di ciò che accade nel tempo: il segnale rimarrà lo stesso (stessa scena e assumiamo che sia statica) mentre il rumore sarà completamente casuale. Ciò significa che, se prendiamo molte riprese della scena, avranno diverse versioni del rumore, ma le stesse informazioni utili.
Quindi, se facciamo la media di molte immagini scattate nel tempo, il rumore si annullerà mentre il segnale non sarà influenzato.
L'illustrazione seguente mostra un esempio semplificato: abbiamo un segnale (triangolo) influenzato dal rumore e proviamo a recuperare il segnale calcolando la media di più istanze dello stesso segnale interessate da rumore diverso.
Vediamo che, sebbene il rumore sia abbastanza forte da distorcere completamente il segnale in ogni singolo caso, la media riduce progressivamente il rumore e recuperiamo il segnale originale.
Vediamo come questo principio si applica alle immagini: in primo luogo, dobbiamo scattare più scatti del soggetto con la massima esposizione consentita dalla fotocamera. Per risultati ottimali, utilizza un'app che consenta lo scatto manuale. È importante che gli scatti vengano effettuati dalla stessa posizione, quindi un treppiede (improvvisato) sarà d'aiuto.
Più scatti generalmente significano una migliore qualità, ma il numero esatto dipende dalla situazione: quanta luce c'è, quanto è sensibile la fotocamera, ecc. Una buona gamma potrebbe essere compresa tra 10 e 100.
Una volta che abbiamo queste immagini (in formato raw se possibile), possiamo leggerle ed elaborarle in Python.
Per coloro che non hanno familiarità con l'elaborazione delle immagini in Python, dovremmo menzionare che un'immagine è rappresentata come un array 2D di valori di byte (0-255), ovvero per un'immagine monocromatica o in scala di grigi. Un'immagine a colori può essere pensata come un insieme di tre di queste immagini, una per ciascun canale colore (R, G, B), o effettivamente un array 3D indicizzato per posizione verticale, posizione orizzontale e canale colore (0, 1, 2) .
Utilizzeremo due librerie: NumPy (http://www.numpy.org/) e OpenCV (https://opencv.org/). Il primo ci consente di eseguire calcoli su array in modo molto efficace (con un codice sorprendentemente breve), mentre OpenCV gestisce la lettura/scrittura dei file di immagine in questo caso, ma è molto più capace, fornendo molte procedure grafiche avanzate, alcune delle quali utilizzare più avanti nell'articolo.
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)
Il risultato (con il contrasto automatico applicato) mostra che il rumore è scomparso, un notevole miglioramento rispetto all'immagine originale.
Tuttavia, notiamo ancora alcuni strani artefatti, come la cornice verdastra e il motivo a griglia. Questa volta, non è un rumore casuale, ma un rumore di pattern fisso. Quello che è successo?
Ancora una volta, possiamo dare la colpa al sensore. In questo caso, vediamo che diverse parti del sensore reagiscono in modo diverso alla luce, risultando in uno schema visibile. Alcuni elementi di questi modelli sono regolari e sono molto probabilmente correlati al substrato del sensore (metallo/silicio) e al modo in cui riflette/assorbe i fotoni in arrivo. Altri elementi, come il pixel bianco, sono semplicemente pixel del sensore difettosi, che possono essere eccessivamente sensibili o eccessivamente insensibili alla luce.
Fortunatamente, c'è un modo per sbarazzarsi anche di questo tipo di rumore. Si chiama sottrazione del frame scuro.
Per fare ciò, abbiamo bisogno di un'immagine del pattern noise stesso, e questo può essere ottenuto fotografando l'oscurità. Sì, è vero: basta coprire il foro della fotocamera e scattare molte foto (diciamo 100) con il tempo di esposizione massimo e il valore ISO, ed elaborarle come descritto sopra.
Quando si calcola la media su molti fotogrammi neri (che in realtà non sono neri, a causa del rumore casuale) ci ritroveremo con il rumore del pattern fisso. Possiamo presumere che questo rumore fisso rimarrà costante, quindi questo passaggio è necessario solo una volta: l'immagine risultante può essere riutilizzata per tutti i futuri scatti in condizioni di scarsa illuminazione.
Ecco come appare la parte in alto a destra del pattern noise (regolato per il contrasto) per un iPhone 6:
Ancora una volta, notiamo la trama simile a una griglia e persino quello che sembra essere un pixel bianco bloccato.

Una volta che abbiamo il valore di questo rumore del fotogramma scuro (nella variabile average_noise
), possiamo semplicemente sottrarlo dal nostro scatto finora, prima di normalizzare:
average -= average_noise output = cv2.normalize(average, None, 0, 255, cv2.NORM_MINMAX) cv2.imwrite('output.png', output)
Ecco la nostra foto finale:
Ampia gamma dinamica
Un'altra limitazione che una piccola fotocamera (mobile) ha è la sua piccola gamma dinamica, il che significa che la gamma di intensità della luce a cui può catturare i dettagli è piuttosto piccola.
In altre parole, la fotocamera è in grado di catturare solo una banda ristretta delle intensità luminose di una scena; le intensità al di sotto di quella banda appaiono come nero puro, mentre le intensità al di sopra appaiono come bianco puro e tutti i dettagli vengono persi da quelle regioni.
Tuttavia, c'è un trucco che la fotocamera (o il fotografo) può utilizzare, e cioè regolare il tempo di esposizione (il tempo in cui il sensore è esposto alla luce) per controllare la quantità totale di luce che arriva al sensore, in modo efficace spostando l'intervallo verso l'alto o verso il basso per acquisire l'intervallo più appropriato per una determinata scena.
Ma questo è un compromesso. Molti dettagli non riescono a farcela nella foto finale. Nelle due immagini sottostanti, vediamo la stessa scena catturata con tempi di esposizione diversi: un'esposizione molto breve (1/1000 sec), un'esposizione media (1/50 sec) e un'esposizione lunga (1/4 sec).
Come puoi vedere, nessuna delle tre immagini è in grado di catturare tutti i dettagli disponibili: il filamento della lampada è visibile solo nel primo scatto, e alcuni dettagli del fiore sono visibili al centro o nell'ultimo scatto ma non entrambi.
La buona notizia è che c'è qualcosa che possiamo fare al riguardo, e ancora una volta si tratta di costruire su più riprese con un po' di codice Python.
L'approccio che adotteremo si basa sul lavoro di Paul Debevec et al., che descrive qui il metodo nel suo articolo. Il metodo funziona così:
Innanzitutto, richiede più scatti della stessa scena (ferma) ma con tempi di esposizione diversi. Anche in questo caso, come nel caso precedente, abbiamo bisogno di un treppiede o di un supporto per assicurarci che la fotocamera non si muova affatto. Abbiamo anche bisogno di un'app di scatto manuale (se si utilizza un telefono) in modo da poter controllare il tempo di esposizione e prevenire le regolazioni automatiche della fotocamera. Il numero di scatti necessari dipende dalla gamma di intensità presenti nell'immagine (da tre in su) e i tempi di esposizione devono essere distanziati su tale gamma in modo che i dettagli che siamo interessati a preservare vengano visualizzati chiaramente in almeno uno scatto.
Successivamente, viene utilizzato un algoritmo per ricostruire la curva di risposta della fotocamera in base al colore degli stessi pixel nei diversi tempi di esposizione. Questo sostanzialmente ci consente di stabilire una mappa tra la luminosità reale della scena di un punto, il tempo di esposizione e il valore che il pixel corrispondente avrà nell'immagine catturata. Useremo l'implementazione del metodo di Debevec dalla libreria 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 di risposta è simile a questa:
Sull'asse verticale abbiamo l'effetto cumulativo della luminosità della scena di un punto e del tempo di esposizione, mentre sull'asse orizzontale abbiamo il valore (da 0 a 255 per canale) che avrà il pixel corrispondente.
Questa curva ci consente di eseguire l'operazione inversa (che è il passaggio successivo del processo): dato il valore dei pixel e il tempo di esposizione, possiamo calcolare la luminosità reale di ciascun punto della scena. Questo valore di luminosità è chiamato irraggiamento e misura la quantità di energia luminosa che cade su un'unità di area del sensore. A differenza dei dati dell'immagine, viene rappresentato utilizzando numeri in virgola mobile perché riflette un intervallo di valori molto più ampio (quindi, un intervallo dinamico elevato). Una volta che abbiamo l'immagine di irradianza (l'immagine HDR) possiamo semplicemente salvarla:
# Compute the HDR image merge = cv2.createMergeDebevec() hdr = merge.process(images, exposures, response) # Save it to disk cv2.imwrite('hdr_image.hdr', hdr)
Per quelli di noi abbastanza fortunati da avere un display HDR (che sta diventando sempre più comune), potrebbe essere possibile visualizzare questa immagine direttamente in tutto il suo splendore. Sfortunatamente, gli standard HDR sono ancora agli inizi, quindi il processo per farlo potrebbe essere leggermente diverso per i diversi display.
Per il resto di noi, la buona notizia è che possiamo ancora sfruttare questi dati, sebbene una visualizzazione normale richieda che l'immagine abbia canali con valore byte (0-255). Anche se dobbiamo rinunciare a parte della ricchezza della mappa di irradianza, almeno abbiamo il controllo su come farlo.
Questo processo è chiamato mappatura dei toni e comporta la conversione della mappa di irradianza in virgola mobile (con un intervallo di valori elevato) in un'immagine con valore di byte standard. Esistono tecniche per farlo in modo da preservare molti dei dettagli extra. Giusto per darti un esempio di come questo può funzionare, immagina che prima di comprimere l'intervallo in virgola mobile in valori di byte, miglioriamo (affiliamo) i bordi che sono presenti nell'immagine HDR. Il miglioramento di questi bordi aiuterà a preservarli (e implicitamente i dettagli che forniscono) anche nell'immagine a bassa gamma dinamica.
OpenCV fornisce una serie di questi operatori di mappatura dei toni, come Drago, Durand, Mantiuk o Reinhardt. Ecco un esempio di come uno di questi operatori (Durand) può essere utilizzato e del risultato che 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)
Usando Python, puoi anche creare i tuoi operatori se hai bisogno di un maggiore controllo sul processo. Ad esempio, questo è il risultato ottenuto con un operatore personalizzato che rimuove le intensità che sono rappresentate in pochissimi pixel prima di ridurre l'intervallo di valori a 8 bit (seguito da un passaggio di contrasto automatico):
Ed ecco il codice per l'operatore sopra:
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)
Conclusione
Abbiamo visto come con un po' di Python e un paio di librerie di supporto, possiamo spingere i limiti della fotocamera fisica per migliorare il risultato finale. Entrambi gli esempi che abbiamo discusso utilizzano più scatti di bassa qualità per creare qualcosa di meglio, ma ci sono molti altri approcci per problemi e limiti diversi.
Sebbene molti telefoni con fotocamera dispongano di app store o integrate che affrontano questi esempi particolari, chiaramente non è affatto difficile programmarle manualmente e godere del livello più elevato di controllo e comprensione che si può ottenere.
Se sei interessato ai calcoli delle immagini su un dispositivo mobile, dai un'occhiata al tutorial OpenCV: rilevamento di oggetti in tempo reale utilizzando MSER in iOS del collega Toptaler e sviluppatore d'élite OpenCV Altaibayar Tseveenbayar.