MokaByte 104 - Febbraio 2006
 
MokaByte 104 - Febbraio 2006 Prima pagina Cerca Home Page

 

 

 

Realizzare un plugin custom di Image I/O
I parte

Con questo articolo iniziamo una serie di puntate dedicate all'elaborazione avanzata di immagini in Java™, in particolare focalizzata sulla fotografia digitale. Le prime puntate si focalizzano sui sottosistemi di input/output che ci permettono di caricare in memoria un'immagine digitale a partire da un file di formato arbitrario. Studieremo questi concetti seguendo la realizzazione di un modulo di lettura ad hoc.

La Image I/O API
La Image I/O API è la libreria di riferimento per la lettura e scrittura di immagini in una molteplicità di formati. Il supporto originale di Java™ per queste funzioni era di fatto inesistente, limitandosi alla capacità di leggere i formati GIF, PNG e JPEG mediante le classi MediaTracker , Image ed ImageIcon. Di fatto, pur non avendo effettivi limiti alle dimensioni di immagini che possono essere manipolate, queste classi sono state progettate sostanzialmente per la manipolazione di icone o piccole bitmap da utilizzare nel contesto di una interfaccia grafica. L'uso di queste classi è semplicissimo (in particolare ImageIcon si carica in un passaggio da una URL) come dev'essere il caricamento di un'icona da disegnare, per esempio, in un pulsante. Questo spiega anche perché il supporto dei formati era originariamente limitato a quelli citati.
Con la JSR-15, “Image I/O Framework Specification”, i cui lavori iniziarono nel lontano 1999 per concludersi nel 2002, fu definita una specifica più evoluta, dedicata alla lettura e scrittura di immagini digitali vere e proprie, con una piattaforma basata su plug-in per estendere il numero di formati disponibili, e con tutte le operazioni al corredo necessarie per la gestione di metadati (con metadati si intende un insieme di proprietà associate ad un'immagine digitale – un esempio comune di metadati è costituito dalla specifica EXIF, che permette alle macchine fotografiche digitali di registrare dentro ogni fotografia le informazioni relative al diaframma, alla velocità di otturatore, eccetera).
Il risultato concreto della JSR-15 è stata la Image I/O API, introdotta con il JDK 1.4. Parallelamente ad essa il JDK 1.4 ha introdotto in Java2D tutta una serie di classi per l'imaging più avanzate delle originali Image ed ImageIcon, in particolare la BufferedImage. Mentre Image sostanzialmente permetteva poco più che il rendering ed il semplice accesso ai pixel, BufferedImage contiene il supporto per operazioni più avanzate come il filtraggio, la modifica dei colori, il ridimensionamento e trasformazioni geometriche. In aggiunta a tutto ciò esiste un'estensione di Java2D, nota con il nome di JAI (Java Advanced API for Imaging) che fornisce strumenti di un ulteriore livello di sofisticazione, particolarmente mirati alla manipolazioni di immagini tanto grandi da non essere agevolmente contenute in memoria.
Non è semplicissimo districarsi in tutte queste classi e nelle loro interazioni, per cui in questi primi articoli non prenderemo in considerazione JAI, che verrà studiata nelle prossime puntate.
La Image I/O API, dal punto di vista del programmatore che la usa, è molto semplice. Per caricare un'immagine in memoria basta uno statement:

BufferedImage image = ImageIO.read(new File(“MyPhoto.JPG”));

Per memorizzarla su disco – ad esempio in formato PNG:

ImageIO.write(image, “png”, new File(“MyPhoto.PNG”));

Dietro le quinte accadono cose abbastanza complesse, come ad esempio la ricerca di un plugin adatto al caricamento dell'immagine, la cui scelta viene effettuata automaticamente analizzando il file, utilizzando un registro generale che può essere esteso a volontà. L'estensione avviene semplicemente aggiungendo nel classpath i jar che contengono i plugin che ci interessano.
Image I/O può essere interrogato per sapere quali sono i formati riconosciuti. Ad esempio, il seguente codice:

String readFormats[] = ImageIO.getReaderMIMETypes();
String writeFormats[] = ImageIO.getWriterMIMETypes();

ci dice quali sono i tipi MIME che siamo in grado di leggere e scrivere.
Image I/O può essere usato in modo molto più complesso. Ad esempio il codice seguente restituisce le dimensioni di un'immagine JPEG senza caricarla in memoria:

ImageReader ir = (ImageReader)(ImageIO.getImageReadersByFormatName("JPEG").next());
ir.setInput(ImageIO.createImageInputStream(new FileInputStream(...)));
int width = ir.getWidth(0);
int height = ir.getHeight(0);
int tWidth = ir.getThumbnailWidth(0, 0);
int tHeight = ir.getThumbnailHeight(0, 0);

Le ultime due righe leggono le dimensioni di un thumbnail, cioé una piccola immagine di preview che, ad esempio, quasi tutte le macchine fotografiche includono nei file generati per facilitare il lavoro del software di navigazione. Gli indici usati nei vari metodi ci fanno capire che la API è in grado di gestire immagini multiple contenute nello stesso file – questo non capita quasi mai nelle foto digitali, ma è una cosa frequente in altri contesti, come ad esempio i fax multipagina codificati in TIFF.
L'idea alla base di questa serie di articoli è di analizzare piuttosto in dettaglio Image I/O vedendo come sia possibile realizzare un nuovo plugin, cioè un estensione in grado di manipolare formati non supportati dal JDK. Faremo riferimento a jrawio, un progetto opensource realizzato da chi sta scrivendo in grado di leggere i formati Camera Raw generati da vari modelli di macchine fotografiche digitali (Nikon, Canon, Sony, Pentax, Minolta) e il formato Adobe Digital Negative. Questo plugin non implementa operazioni di scrittura e quindi ci concentreremo solo sulla parte relativa alla lettura.
Il plugin è stato recentemente ospitato su java.net come progetto-figlio di imageio, che è il contenitore ufficiale di Sun per tutte le API relative all'I/O di immagini digitali. Alla url http://jrawio.dev.java.net potete trovare documentazione e scaricare i sorgenti.
Se a questo punto non sapete cosa sono i formati Camera Raw (che chiameremo “Raw” per brevità)... ve lo spieghiamo noi.

 

I formati Camera Raw
Praticamente tutte le fotocamere digitali esistenti al mondo sono in grado di generare file JPEG pronti per l'uso (stampa e/o pubblicazione sul web). Eventuali settaggi di preferenze (intensità dei colori, contrasto, ecc...) vengono impostati sul corpo macchina e l'elaborazione avviene direttamente a carico del microprocessore integrato. Questo è il modo più ovvio di fornire il risultato finale a chi scatta fotografie senza pensarci su troppo (come dicono gli inglesi, “point and shoot”).
Con un qualsiasi programma di fotoritocco si può effettuare un editing ulteriore, come pure una correzione di eventuali errori di esposizione. Tuttavia il formato JPEG ci pone pesanti limitazioni: prima di tutto usa uno schema di compressione lossy, cioè l'immagine salvata su disco non è identica all'originale, ma alcuni dettagli vengono persi per ridurre l'ingombro in bytes, e in secondo luogo prevede solo 8 bit per pixel per canale – cioè è un formato a 24 bit per pixel. Sembrano tanti, ma i due problemi messi insieme fanno sì che ogni ritocco ed ogni salvataggio degradano l'immagine, fino a farla diventare scadente.
Questo fenomeno non è tollerabile dai fotografi professionisti, i quali fanno sicuramente meno errori sul campo dei dilettanti, ma comunque vogliono un'ampia possibilità di intervenire in sede di fotoritocco per esprimere la propria creatività. Questo d'altronde avveniva già con la foto analogica, in quanto nella camera oscura il negativo permetteva vari modi di essere sviluppato.
In linea di principio il problema sarebbe risolvibile utilizzando un formato con uno schema di compressione senza perdita: ad esempio il TIFF o il lossless JPG, che oltretutto permettono di lavorare a 16-bit per pixel (più che sufficienti dal momento che le fotocamere campionano generalmente a 12-14 bit per pixel).

NOTA: quando si parla di campionamento su un sensore digitale bisognerebbe parlare di photosite e non di pixel, ma in questo articolo non ci formalizzeremo troppo.

C'è però un altro problema. Per ragioni di progettazione, i sensori digitali non producono un'immagine già pronta per essere visualizzata. Producono invece una serie di dati crudi o raw (ed ecco spiegata l'origine del termine “Camera Raw”) che devono essere postprocessati, ad esempio per l'eliminazione del rumore, oppure per calibrare il livello del nero (che non necessariamente corrisponde allo zero, a causa di fenomeni come correnti di origine termica, eccetera).

Ma le complicazioni non sono finite. Il numero di pixel in un sensore digitale per fotocamere di medio livello in su varia dai 6 ai 12 milioni (lasciamo perdere macchine estremamente particolari che arrivano a 50 megapixel e costano come un'auto di lusso!). Siccome ogni pixel è composto da tre canali, rosso-verde-blu, questo vorrebbe dire realizzare sensori con un numero di fotodiodi che varia da 18 a 36 milioni. Oggi questo non è possibile a costi ragionevoli. Per questo motivo, un certo Bayer, ricercatore della Kodak, ha inventato il cosiddetto “filtro di Bayer”, o Bayer Array, che consiste in una scacchiera di materiale translucente di colori rosso, verde e blu come in figura:
Ne esistono diverse configurazioni (quella in figura è una GRBG), tutte con due sensori verdi, uno rosso ed uno blu.

NOTA: esistono anche Bayer Array non RGB o a quattro colori diversi, ma sono poco comuni.

L'idea è che ogni pixel campiona un solo canale e non tutti e tre i colori. In questo modo si può far sì che una fotocamera da 10 megapixel contenga effettivamente 10 milioni di sensori e non 30, permettendo il taglio dei costi di produzione.

NOTA: la Foveon ha messo in commercio un sensore in grado di campionare tutti e tre i canali per ogni pixel, ma non sta avendo molto successo.

Ovviamente però si perde dell'informazione: questa deve essere ricostruita via software con un'interpolazione (operazione detta demosaicing). Tutte queste elaborazioni possono essere benissimo compiute a bordo della fotocamera, perché ormai i processori imbarcati sono abbastanza potenti. I produttori di fotocamere, tuttavia, si sono resi conto che è molto meglio delegare queste operazioni al software di visualizzazione che installiamo sul PC, perché questo può evolvere in modo più sofisticato ed è più semplice da realizzare che non il firmware di bordo. Per esempio, un algoritmo di demosaicing inventato di recente può migliorare la qualità di fotografie scattate con una vecchia fotocamera. Inoltre in questo modo si apre la possibilità di avere diversi software in competizione.

NOTA: questo in realtà pare che non sia un desiderio dei produttori. Per chi è interessato a questo problema si veda la OpenRAW initiative (www.openraw.org).

In definitiva, se abilitate la modalità ad alta qualità su una fotocamera di medio alto livello (recentemente anche alcuni modelli compatti hanno iniziato ad offrire questa possibilità) quello che vi ritrovate su disco altro non è che la copia non elaborata dei dati letti dal sensore. Nei metadati vengono inseriti, oltre le informazioni usuali, anche i settaggi impostati dal fotografo (ad esempio incremento della saturazione, vari livelli di contrasto, eccetera). Questi verranno onorati dal programma di visualizzazione ogni volta che aprite la foto. Il vantaggio è che potete completamente cambiare idea sui settaggi al momento dello scatto e rielaborare la foto senza aggiungere rumore di quantizzazione (praticamente inevitabile quando si elaborano dati digitali), perché ogni volta si riparte dall'originale. Qualcuno dice che i formati Raw sono di fatto una specie di “negativo digitale”, in quanto può essere elaborato più volte in modi diversi.

 

Struttura dei formati RAW
A questo punto la situazione diventa caotica, perché ogni produttore si è inventato un suo formato proprietario (tra i più comuni NEF di Nikon, CRW e CR2 di Canon, SRF di Sony, PEF di Pentax, MRW di Minolta – questi sono i formati supportati da jrawio al momento di scrivere questo articolo). Oltretutto, salvo rarissime eccezioni, i formati non sono documentati ufficialmente, anche se si trova qualche spunto e soprattuto codice funzionante opensource, scritto da smanettoni, che può fornire come riferimento (è impossibile non citare a questo punto il pioniere della decodifica dei formati Raw, Dave Coffin con il suo programma dcraw).
La maggior parte dei formati sono variazioni più o meno fantasiose del formato TIFF di Adobe, quindi fortunatamente è possibile riusare buona parte del codice. A questo punto è opportuno ricordare i concetti base che descrivono questo formato, rimandando i dettagli alla documentazione specializzata. I componenti sono: un header, alcune directory di dati, tags, uno o più thumbnail e i dati raster:

  • l'header è una breve sequenza di byte memorizzati all'inizio del file. Contiene un “magic cookie”, cioè una manciata di byte che contengono un particolare valore, la cui presenza è un indicatore del formato utilizzato. L'header contiene anche l'informazione sull'ordinamento dei byte (basso-alto o alto-basso), la versione del protocollo ed il puntatore alla prossima sezione di dati.
  • Un tag è un pezzo di informazione elementare identificato da un codice numerico univoco (codice del tag) che definisce il significato dell'informazione (ad esempio, se trovate il codice 256, il valore associato è la larghezza dell'immagine). Le informazioni elementari possono essere interi di vari formati, da 8 a 32 bit, numeri floating point oppure numeri razionali, cioè coppie di interi a 32 bit che fungono da numeratore e denominatore.
  • una directory (lo standard TIFF le designa con l'acronimo IFD) è un insieme di tag relazionati tra loro. In ogni file in generale ci sono più directory: una descrive l'immagine principale, altre descrivono i thumbnail, se presenti, i dati EXIF sono sempre codificati in una directory a parte. Il cosiddetto “makernote” è un'ulteriore blocco di dati codificato in modo proprietario.
  • I dati raster sono la bitmap dell'immagine. Possono essere memorizzati in tanti modi diversi: riga per riga (organizzazione a strip), oppure in tasselli (organizzazione a tile). Degli opportuni tag forniscono le informazioni relative.

Le informazioni contenute nell'header puntano alla prima directory. Ogni directory poi può contenere un ulteriore puntatore ad altre directory dello stesso livello, oppure puntatori a sottodirectory. In generale possiamo immaginarci un albero di directory un po' come un (semplice) file system. I puntatori non sono altro che l'offset all'interno del file della struttura puntata.
I formati Raw seguono questo schema salvo alcune eccezioni (completamente proprietarie), introducendo però delle varianti: qualche byte extra nello header, qualche byte extra all'inizio degli IFD, e se per tutti gli IFD viene usata una codifica standard dei tag, il makernote è diverso da produttore a produttore. In certi casi i puntatori sono tutti sfasati di un certo offset, in alcuni casi le informazioni sono offuscate, evidentemente per confondere le idee a chi prova ad analizzare questi formati (Peraltro con poco successo, dal momento che Dave Coffin dimostra di essere capace di decodificare un nuovo formato entro un paio di settimane). Inoltre i dati raster possono essere compressi con schemi proprietari e con dettagli fantasiosi.

NOTA: praticamente tutti gli articoli che trovate su internet parlano di dati criptati, ma io mi ostino ad usare il termine “offuscati” (in inglese direi ”mangled”). Se è vero che vengono usati degli algoritmi crittografici (peraltro molto blandi) con tanto di chiavi, queste sono contenute all'interno del file, in chiaro. Ma ogni cifratura che si rispetti si assicura di non trasmettere in chiaro il valore della chiave

Ora ne sappiamo abbastanza per passare al design.

L'interfaccia ImageInputStream

Sappiamo bene che tutte le operazioni di input effettuate in Java™ passano attraverso un DataInputStream, che fornisce le primitive di lettura di singoli byte (ad esempio read()) e di tipi primitivi (ad esempio readInt(), readShort(), readFloat(), eccetera). Image I/O definisce un particolare tipo di stream, ImageInputStream (ed ImageInputStreamImpl che ne è un'implementazione di default), che oltre alle operazioni fondamentali è in grado di leggere singoli bit (con il metodo readBits()). Questo si rivela molto utile dal momento che nel maggior parte dei casi il caricamento dei dati raster avviene leggendo un numero variabile di bit, raramente multiplo di 8 o 16.

Viste le specifiche descritte precedentemente, è necessario estendere ImageInputStream con un paio di nuove funzionalità:

  1. la capacità di usare un offset 'sfalsato';
  2. la capacità di deoffuscare alcune sezioni del file.

Per questo scopo usiamo il pattern Chain Of Responsibility (CoR), che è d'altronde ampiamente usato dal runtime Java™ proprio con gli stream. In pratica implementiamo una nuova classe RAWImageInputStream che estende ImageInputStreamImpl , delega l'implementazione dei propri metodi ad un altro ImageInputStream ed ne aggiunge alcuni:

public void setBaseOffset (long baseOffset);
public long getBaseOffset();

L'implementazione del metodo seek() viene ridefinita in questo modo:

public void seek (long pos) throws IOException{
  delegate.seek(pos + baseOffset);
}

La deoffuscatura del file, essendo dipendente dal formato, verrà implementata analogamente con eventuali sottoclassi ad hoc (se guardate il codice della versione attuale di jrawio (1.0.RC3), vedrete che RAWImageInputStream fornisce anche implementazioni ottimizzate di readBits() per alcuni casi particolari. L'esempio è didatticamente utile, in quanto è proprio in questo modo che bisogna intervenire se si vogliono eseguire ottimizzazioni. Tuttavia le ottimizzazioni che erano significative un paio di anni fa oggi sono praticamente impercettibili, grazie al fatto che Sun ha migliorato le performance di Image I/O. Pertanto questa parte verrà presto eliminata.). Altri due metodi implementano due modalità di lettura che saranno utili in molti casi nella lettura dei dati raster:

public void setSkipZeroAfterFF (boolean enabled);
public int readComplementedBits (int bitsToGet);

Curiosamente alcuni formati inseriscono un inutile byte a zero, che va ignorato, dopo ogni byte valorizzato a 0xff. La lettura di bit complementati è parimenti comune in certe forme di compressione.


Classi AbstractTag, Directory, TagRegistry, TIFFTag, IFD

Non c'è molto di significativo su queste classi che rappresentano i componenti dei metadati. AbstractTag contiene sostanzialmente vari metodi getXXX() per ogni tipo primitivo che permettono di accedere ai valori contenuti. TIFFTag ne è una sottoclasse concreta che implementa la lettura da stream secondo le specifiche TIFF. Directory è un insieme di tag con metodi per aggiungere e cercare un tag dato il suo codice. Una directory è di fatto il nodo di un albero, dal momento che, come già detto, la specifica TIFF permette di includere più directory all'interno dello stesso file. Essa contiene quindi metodi per accedere ad altre IFD sullo stesso livello gerarchico o su quello inferiore:

public Iterator subDirectories()
public Directory getNextDirectory();

IFD è una sottoclasse concreta che implementa la lettura di una directory da stream secondo le specifiche TIFF. Infine TagRegistry è il descrittore di una famiglia di tag secondo una certa specifica. Di fatto questa classe assegna ad ogni codice di tag un tipo ed una descrizione. Diverse istanze rappresentano la famiglia di tag TIFF, EXIF, varie estensioni, e in particolare i makernote di ogni formato. Ne parleremo più in dettaglio nella prossima puntata, ma vale la pena anticipare che alcune di queste classi vengono generate automaticamente a partire da una descrizione dei codici di tag in formato XML


La classe ImageReaderSpi
ImageReaderSpi (dove Spi sta per Service Provider Interface) è un descrittore di un plugin. Al suo interno contiene varie informazioni, come il nome del plugin, il formato MIME supportato, i nomi dei formati di metadata supportati (ne parliamo più avanti); inoltre il metodo:

public boolean canDecodeInput (Object source);

viene richiamato dal runtime passando come parametro l'oggetto da cui ci accingiamo a leggere l'immagine per chiederci se il nostro plugin è in grado di leggerla.
Una prima generica sottoclasse RAWImageReaderSpi si preoccupa di specificare tutte le proprietà comuni ai formati RAW che intendiamo gestire e implementa canDecodeInput() in modo che richiami direttamente un nuovo metodo che usa un RAWImageInputStream:

protected abstract boolean canDecodeInput (RAWImageInputStream iis);

Inoltre, dal momento che la verifica di capacità a decodificare richiede spesso di leggere varie parti del file, a partire dall'header, a cavallo dell'invocazione di canDecodeInput() viene salvata l'attuale posizione corrente di lettura all'interno dello stream.

 

La classe IIOMetadata
IIOMetadata rappresenta il contenitore dei metadati associati ad un'immagine. Se per quanto riguarda i formati Raw siamo fortunati dal momento che la maggioranza di essi, essendo basati sullo standard TIFF, condividono la stessa struttura, in generale diversi formati di file possono contenere dati strutturati in modo molto diverso. Questo è un problema perché questa classe deve permettere l'accesso a tutti i metadati (e anche la manipolazione nel caso si implementi un ImageWriter oltre che un ImageReader). Per tagliare la testa al toro ed evitare di definire un'interfaccia troppo complessa, Sun ha pensato che in generale i metadati di un'immagine possono essere rappresentati da XML. Così il metodo più importante di questa classe è:

public abstract Node getAsTree(String formatName);

e restituisce un nodo DOM che rappresenta i metadati contenuti nel file. È possibile fornire diverse rappresentazioni alternative, che il programmatore seleziona attraverso il parametro formatName. Ecco un esempio di metadati in XML:

<?xml version="1.0" encoding="UTF-8"?>
<it_tidalwave_imageio_tiff_image_1.0>
<TIFFIFD name="IFD0" start="8" end="229">
<TIFFField number="256" name="ImageWidth">
<TIFFLongs>
<TIFFLong value="3040"/> </TIFFLongs>
</TIFFField>
<TIFFField number="257" name="ImageLength">
<TIFFLongs>
<TIFFLong value="2024"/>
</TIFFLongs>
</TIFFField>
...

La classe TIFFMetadataSupport altro non fa che implementare il codice in grado di trasformare una struttura di IFD nell'opportuno DOM.

 

La classe RasterReader
RasterReader è la classe deputata a caricare in memoria i dati raster del file. Per prima cosa vanno valorizzate una serie di proprietà che descrivono il formato raster:

public void setWidth (int width)
public void setHeight (int height)
public void setCFAPattern (byte[] cfaPattern)
public void setBitsPerSample (int bitsPerSample)
public void setTileWidth (int tileWidth)
public void setTileHeight (int tileHeight)
public void setTilesAcross (int tilesAcross)
public void setTilesDown (int tilesDown)
public void setTileOffsets (int[] tileOffsets)
public void setRasterOffset (long rasterOffset)
public void setStripByteCount (int stripByteCount)
public void setCompression (int compression)
public void setLinearizationTable (int[] linearizationTable)

Il CFA pattern altro non è che la descrizione del Bayer Array (ad esempio la sequenza 0 1 1 2 indica un filtro RGGB). La LinearizationTable è una tabella di lookup che va usata in certi casi, indicizzandola con i valori letti dal raster. Per esempio, il formato Nikon NEF compressed memorizza nel raster valori da 9.5 bit e usa una tabella per espanderli a 12 bit, con un campionamento più fitto per i valori che rappresentano bassa luminosità.

NOTA: CFA sta per Color Filter Array, perché per essere pignoli il termine Bayer Array è limitato ai filtri RGB

I raster possono essere compressi o non compressi, e questo lo stabilisce il metodo:

protected boolean isCompressedRaster();

Per i due casi esistono due metodi diversi che implementano il caricamento dei dati:

protected void loadUncompressedRaster (RAWImageInputStream iis, WritableRaster raster, RAWImageReaderSupport ir);
protected void loadCompressedRaster (RAWImageInputStream iis, WritableRaster raster, RAWImageReaderSupport ir) ;

Il primo metodo contiene un'implementazione universale, dal momento che un formato non compresso è semplicemente la sequenza di pacchetti di bit, a partire da quello che rappresenta il pixel in alto a sinistra, spostandosi poi verso sinistra e poi riga per riga. Il secondo metodo è vuoto, e andrà implementato dalle sottoclassi per gli specifici schemi di compressione di ciascun vendor.
Dall'esterno si invoca semplicemente;

public WritableRaster loadRaster (RAWImageInputStream iis, RAWImageReaderSupport ir);

che in base al risultato di isCompressed() attiva la lettura senza o con compressione.

 

La classe ImageReader
A questo punto dovremmo finalmente implementare una opportuna sottoclasse di ImageReader, che poi è il prodotto finale che vogliamo fornire al programmatore. Ma ci pare di aver messo già abbastanza carne al fuoco per questa puntata.
Nella prossima vedremo come una struttura basata sul polimormismo ci renderà possibile implementare plugin per sei formati Raw diversi, tutti basati su TIFF, scrivendo relativamente poco codice.