MokaByte 88 - 7mbre 2004 
Creazione di un file video a partire da una sequenza d'immagini
di
Vincenzo
Viola

JMF e' un insieme di API che permette l'impiego di contenuti audio e video all'interno delle applicazioni. In questo articolo vedremo come, partendo da una sequenza d'immagini compresse in formato JPEG, si possa generare un file video in formato QuickTime (.mov).

Introduzione
Consideriamo lo schema illustrato nella Figura 1: l'input è costituito dalla serie di immagini, l'output è il file video. L'elaborazione è realizzata utilizzando le JMF API.


Figura 1
- Modello di elaborazione di dati multimediali

JMF è un package che estende le funzionalità multimediali della piattaforma Java. Le classi che utilizziamo sono:

  • javax.media.Processor: interfaccia per processare e controllare dati multimediali;
  • javax.media.protocol.DataSource: gestisce il ciclo di vita di un contenuto multimediale mediante un protocollo di connessione;
  • javax.media.Buffer: è un contenitore di dati multimediali;
  • javax.media.protocol.PullBufferStream: interfaccia per leggere i dati di un contenuto multimediale e trasferirli sotto forma di oggetti di tipo Buffer;
  • javax.media.protocol.PullBufferDataSource: data-source che contiene uno o più PullBufferStream;
  • javax.media.DataSink: interfaccia per oggetti che leggono dati da DataSource e li salvano in una certa destinazione.
  • javax.media.datasink.DataSinkListener: interfaccia per manipolare eventi asincroni generati da DataSink;
  • javax.media.format.VideoFormat: incapsula informazioni sul formato dei video.

 

Definizione delle risorse di Input ed Output del processo di creazione video
Per semplicità facciamo riferimento a immagini di tipo JPEG e ad un video di tipo QuickTime, ma le cose possono essere facilmente riadattate ad altri formati di immagini e video.
JMF prevede l'utilizzo della classe Format per descrivere le informazioni relative a contenuti multimediali. Più nello specifico tale classe raccoglie tre sottoclassi: AudioFormat, ContentDescriptor, VideoFormat. Tralasciando la prima delle tre, che viene utilizzata per dati di tipo audio, concentriamoci sulle altre due. Il ContentDescriptor è l'identificatore del content type, ovvero del formato in cui i dati multimediali sono memorizzati; utilizzando la sua sottoclasse FileTypeDescriptor è possibile descrivere il tipo del contenuto in base al formato di un file, visto che molto spesso i dati multimediali sono acquisiti da files locali. La classe VideoFormat invece fornisce informazioni rilevanti circa i dati video. Come possiamo vedere nella Figura 2, diversi formati sono stati derivati dalla classe VideoFormat per descrivere gli attributi dei comuni formati video.


Figura 2
- Formati dei dati in JMF

Poiché il nostro "video" in ingresso non è altro che un "pacchetto" di JPEG, il VideoFormat a cui faremo riferimento sarà il JPEGFormat.
Per gestire il trasferimento dei dati multimediali dall'input all'output, si utilizza un DataSource. Un DataSource incapsula sia la location del video sia il protocollo utilizzato per accedere ad esso.
La location è ottenuta mediante un MediaLocator o una URL. Un MediaLocator è simile ad una URL e può essere costruito da una URL anche se l'handler protocol di quest'ultima non è installato sul sistema.
Un DataSource gestisce un set di oggetti SourceStream, che sono delle interfacce per la lettura dei dati. Uno standard DataSource usa un array di byte come unità di trasferimento. Un BufferDataSource utilizza un Buffer come unità di trasferimento. JMF definisce diversi tipi di DataSource, come si pu; vedere nella Figura 3:


Figura 3
- Modello dei dati in JMF

Noi utilizziamo un PullBufferDataSource che prevede, per accedere ai dati, i protocolli Http e File e utilizza il Buffer come unità di trasferimento. Inoltre esso utilizza come SourceStream un PullBufferStream. Noi andiamo a passare al PullBufferStream la dimensione dei frame del video, il frame rate e il formato del video. Inoltre modifichiamo il suo metodo read in modo che legga i dati dai file immagini.
Tutte le informazioni riguardo all'input le inglobiamo quindi in un oggetto da noi definito, l' ImageDataSource:

 

class ImageDataSource extends PullBufferDataSource {
ImageSourceStream stream;
ImageDataSource(int width, int height, int frameRate, Vector images {
streams = new ImageSourceStream(width, height, frameRate, images);
}

class ImageSourceStream implements PullBufferStream {
Vector images;
int width, height;
VideoFormat format;
float frameRate;
long seqNo = 0;
int nextImage = 0;
boolean ended = false;
public ImageSourceStream(int width, int height, int frameRate,Vector images) {
this.width = width;
this.height = height;
this.images = images;
this.frameRate = (float) frameRate;


format = new VideoFormat(VideoFormat.JPEG, new Dimension(width,
height), Format.NOT_SPECIFIED,
Format.byteArray, frameRate);
}

ImageDataSource ids = new ImageDataSource(width, height, frameRate, inFiles);

Ora abbiamo tutto ciò che ci serve riguardo all'input. Passiamo alla definizione delle risorse relative all'output.
L'approccio da seguire è il seguente: supponiamo che il video che vogliamo ottenere sia un media stream, ovvero un flusso di dati multimediali. Siamo abituati a pensare che un oggetto di questo tipo debba essere acquisito da una rete, o catturato da una videocamera. In questo caso il nostro flusso lo otterremo da una serie di file.
Il media stream, come già visto, è identificato dalla sua location e dal protocollo utilizzato per accedere ad esso. Nel nostro caso, siccome ci riferiamo ad un file locale, il protocollo utilizzato è File. Un MediaLocator ci permette di identificare la location del media stream:

MediaLocator outML = new MediaLocator (outputURL);

 

Elaborazione di dati multimediali con JMF
A questo punto siamo in grado di passare alla fase di elaborazione dei dati.
Il primo passo consiste nella costruzione di un Processor, che è un Player che prende un DataSource in ingresso, effettua alcune elaborazioni sui dati definite dall'utente e restituisce i dati processati ad un device o ad un altro DataSource (vedi Figura 4). Se i dati vengono inviati ad un DataSource, esso può essere usato come ingresso per un Player o un altro Processor, oppure per un DataSink, di cui parleremo più avanti.


Figura 4
- Modello del Processor in JMF


Il Processor viene costruito utilizzando un Manager. Nel nostro caso il DataSource di ingresso è l'ImageDataSource visto prima:

Processor p = Manager.createProcessor(ids);

Rispetto al Player, il Processor ha due stati in più, Configuring e Configured, così come si può vedere nella Figura 5:


Figura 5
- Stati del Processor

Un Processor entra nello stato Configuring quando è chiamato il metodo configure. Mentre il Processor è in tale stato, esso si connette al DataSource, demultiplexa i dati in ingresso e accede alle informazioni sul loro formato. Terminata questa fase il Processor passa nello stato Configured e viene generato un ConfigureCompleteEvent. Mentre è in questo stato il metodo getTrackControls può essere chiamato per ottenere gli oggetti TrackControl relativi alle singole tracks del media stream. Le tracks sono i canali di dati di cui è composto il media stream, ad esempio audio e video. Nel nostro caso la traccia video è costituita dalle varie immagini e il formato è JPEG. Un TrackControl fornisce una serie di opzioni per elaborare la traccia, quale ad esempio la conversione di formato.
Quando viene chiamato il metodo realize il Processor passa nello stato Realized ed è completamente costruito. Invocando il metodo start vengono processati i dati:

p.configure();

TrackControl tcs[] = p.getTrackControls();
Format f[] = tcs[0].getSupportedFormats();
tcs[0].setFormat(f[0]);

p.realize();
p.start();

In questo modo i dati hanno subito l'elaborazione da noi richiesta e occorre solo scriverli su file. A tale scopo viene utilizzato il DataSink, un oggetto usato per leggere dati dal DataSource di output di un Processor e salvarli su un file.
Il DataSource di output si ottiene dal Processor invocando il metodo getDataOutput. Analogamente al Processor il DataSink viene costruito utilizzando un Manager, al quale vengono passati il DataSource di output e un MediaLocator che specifica la location del file che si vuole scrivere. A questo punto invochiamo prima il metodo open per aprire il file e poi il metodo start per iniziare a scrivere i dati. Il formato dei dati scritti sul file è controllato dal Processor attraverso il metodo setContentDescriptor (nel nostro esempio il formato scelto è QuickTime):

p.setContentDescriptor(new ContentDescriptor(FileTypeDescriptor.QUICKTIME));

DataSource ds = p.getDataOutput();
DataSink dsink = Manager.createDataSink(ds, outML);
dsink.open();
dsink.start();

Per poter gestire gli eventi generati dal Processor e dal DataSink, utilizziamo rispettivamente i metodi addControllerListener e addDataSinkListener. Quando i Controller generano eventi vengono chiamati i metodi controllerUpdate e dataSinkUpdate.

 

Implementazione
Riportiamo di seguito, il codice che esegue la realizzazione del video partendo dalla sequenza di immagini. Per l'esecuzione corretta bisogna passare come argomenti larghezza e altezza delle immagini, frame rate, url del file video, url delle immagini:

java GeneratingVideoFromImages -w <width> -h <height> -f <frame rate per sec.> -o <output URL> <input JPEG file 1> <input JPEG file 2> ...

java GeneratingVideoFromImages -w 320 -h 240 -f 2 -o c:/video/video.mov c:/images/*.jpg

importjava.io.*;
importjava.util.*;
importjava.awt.Dimension;
importjavax.media.*;
importjavax.media.control.*;
importjavax.media.protocol.*;
importjavax.media.protocol.DataSource;
importjavax.media.datasink.*;
importjavax.media.format.VideoFormat;

/**
* Questo programma prende una sequenza di immagini JPEG e le converte in un
* file video QuickTime.
*/
publicclass GeneratingVideoFromImages implements ControllerListener,
DataSinkListener {

public boolean doIt(int width, int height, int frameRate, Vector inFiles,
MediaLocator outML) {

ImageDataSource ids = new ImageDataSource(width, height, frameRate,
inFiles);
Processor p;
try {
System.err.println("Creazione del processor dal datasource…");
p = Manager.createProcessor(ids);
} catch (Exception e) {
System.err.println("Impossibile creare un processor dal " +
"datasource!");
return false;
}
p.addControllerListener(this);

// Mettiamo il Processor nello stato configurato in modo da poter
// settare le opzioni del Processor
p.configure();
if (!waitForState(p, p.Configured)) {
System.err.println("Configurazione del Processor fallita.");
return false;
}

// Poniamo l'uscita del Processor di tipo QuickTime.
p.setContentDescriptor(new ContentDescriptor(
FileTypeDescriptor.QUICKTIME));
// Settiamo il formato della traccia
TrackControl tcs[] = p.getTrackControls();
Format f[] = tcs[0].getSupportedFormats();
if (f == null || f.length <= 0) {
System.err.println("Questo formato non è supportato: "
+ tcs[0].getFormat());
return false;
}
tcs[0].setFormat(f[0]);
System.err.println("Il formato settato della traccia è: " + f[0]);

// Realizziamo il Processor
p.realize();
if (!waitForState(p, p.Realized)) {
System.err.println("Realizzazione del Processor fallita.");
return false;
}
// Creiamo il DataSink
DataSink dsink;
if ((dsink = createDataSink(p, outML)) == null) {
System.err.println("Creazione del DataSink fallita per il " +
"MediaLocator: " + outML);
return false;
}
dsink.addDataSinkListener(this);
fileDone = false;
System.err.println("Avviata la transcodifica..");
// Avviamo il processo di transcodifica
try {
p.start();
dsink.start();
} catch (IOException e) {
System.err.println("Errore di IO durante la transcodifica.");
return false;
}
waitForFileDone();
try {
dsink.close();
} catch (Exception e) {
}
p.removeControllerListener(this);
System.err.println("...transcodifica terminata.");
return true;
}

/**
* Creazione del DataSink
*/
DataSink createDataSink(Processor p, MediaLocator outML) {
DataSource ds;
if ((ds = p.getDataOutput()) == null) {
System.err.println("Il Processor non ha un DataSource di " +
"output. ");
return null;
}
DataSink dsink;
try {
System.err.println("DataSink creato per il MediaLocator: "
+ outML);
dsink = Manager.createDataSink(ds, outML);
dsink.open();
} catch (Exception e) {
System.err.println("Impossibile creare il DataSink: " + e);
return null;
}
return dsink;
}

Object waitSync = new Object();
boolean stateTransitionOK = true;
/**
* Aspetta finché il Processor non è transitato nello stato desiderato.
* Ritorna false se la transizione è fallita.
*/
boolean waitForState(Processor p, int state) {
synchronized (waitSync) {
try {
while (p.getState() < state && stateTransitionOK)
waitSync.wait();
} catch (Exception e) {
}
}
return stateTransitionOK;
}

/**
* Controller Listener.
*/
public void controllerUpdate(ControllerEvent evt) {
if (evt instanceof ConfigureCompleteEvent
|| evt instanceof RealizeCompleteEvent
|| evt instanceof PrefetchCompleteEvent) {
synchronized (waitSync) {
stateTransitionOK = true;
waitSync.notifyAll();
}
} else if (evt instanceof ResourceUnavailableEvent) {
synchronized (waitSync) {
stateTransitionOK = false;
waitSync.notifyAll();
}
} else if (evt instanceof EndOfMediaEvent) {
evt.getSourceController().stop();
evt.getSourceController().close();
}
}

Object waitFileSync = new Object();
boolean fileDone = false;
boolean fileSuccess = true;

/**
* Aspetta finché non è terminata la scrittura del file.
*/
boolean waitForFileDone() {
synchronized (waitFileSync) {
try {
while (!fileDone)
waitFileSync.wait();
} catch (Exception e) {
}
}
return fileSuccess;
}

/**
* Gestore degli eventi del file writer.
*/
public void dataSinkUpdate(DataSinkEvent evt) {
if (evt instanceof EndOfStreamEvent) {
synchronized (waitFileSync) {
fileDone = true;
waitFileSync.notifyAll();
}
} else if (evt instanceof DataSinkErrorEvent) {
synchronized (waitFileSync) {
fileDone = true;
fileSuccess = false;
waitFileSync.notifyAll();
}
}
}

public static void main(String args[]) {
if (args.length == 0)
prUsage();
// Parsing deli argomenti.
int i = 0;
int width = -1, height = -1, frameRate = 1;
Vector inputFiles = new Vector();
String outputURL = null;
while (i < args.length) {
if (args[i].equals("-w")) {
i++;
if (i >= args.length)
prUsage();
width = new Integer(args[i]).intValue();
} else if (args[i].equals("-h")) {
i++;
if (i >= args.length)
prUsage();
height = new Integer(args[i]).intValue();
} else if (args[i].equals("-f")) {
i++;
if (i >= args.length)
prUsage();
frameRate = new Integer(args[i]).intValue();
} else if (args[i].equals("-o")) {
i++;
if (i >= args.length)
prUsage();
outputURL = args[i];
} else {
inputFiles.addElement(args[i]);
}
i++;
}
if (outputURL == null || inputFiles.size() == 0)
prUsage();
// Controlla l'estensione del file video desiderato.
if (!outputURL.endsWith(".mov") && !outputURL.endsWith(".MOV")) {
System.err.println("L'estensione del file video dovrebbe " +
"essere .mov");
prUsage();
}
if (width < 0 || height < 0) {
System.err.println("Specificare la corretta dimensione dell'"
+ "immagine");
prUsage();
}
// Controlla il frame rate.
if (frameRate < 1)
frameRate = 1;
// Genera il media locator .
MediaLocator oml;
if ((oml = createMediaLocator(outputURL)) == null) {
System.err.println("Impossibile creare il Media Locator: "
+ outputURL);
System.exit(0);
}
GeneratingVideoFromImages imgToMovie = new
GeneratingVideoFromImages();
imgToMovie.doIt(width, height, frameRate, inputFiles, oml);
System.exit(0);
}
static void prUsage() {
System.err.println("Come avviare il programma: " +
"java GeneratingVideoFromImages -w " +
" <width> -h <height> -f <frame rate> " +
"-o <output URL> <input JPEG file 1> " +
"<input JPEG file 2> ...");
System.exit(-1);
}

/**
* Crea un MediaLocator a partire dalla url passatagli come argomento
*/
static MediaLocator createMediaLocator(String url) {
MediaLocator ml;
if (url.indexOf(":") > 0 && (ml = new MediaLocator(url)) != null)
return ml;
if (url.startsWith(File.separator)) {
if ((ml = new MediaLocator("file:" + url)) != null)
return ml;
} else {
String file = "file:" + System.getProperty("user.dir")
+ File.separator + url;
if ((ml = new MediaLocator(file)) != null)
return ml;
}
return null;
}
/**
* Un DataSource per leggere i dati da una sequenza di file immagini JPEG
* e memorizzarli in uno stream di buffer.
*/
class ImageDataSource extends PullBufferDataSource {
ImageSourceStream streams[];

ImageDataSource(int width, int height, int frameRate, Vector images)
{
streams = new ImageSourceStream[1];
streams[0] = new ImageSourceStream(width, height, frameRate,
images);
}

public void setLocator(MediaLocator source) {
}

public MediaLocator getLocator() {
return null;
}

public String getContentType() {
return ContentDescriptor.RAW;
}

public void connect() {
}

public void disconnect() {
}

public void start() {
}

public void stop() {
}

public PullBufferStream[] getStreams() {
return streams;
}

public Time getDuration() {
return DURATION_UNKNOWN;
}

public Object[] getControls() {
return new Object[0];
}

public Object getControl(String type) {
return null;
}
}
/**
* Lo stream sorgente per scorrere l'ImageDataSource.
*/
class ImageSourceStream implements PullBufferStream {
Vector images;
int width, height;
VideoFormat format;
float frameRate;
long seqNo = 0;
// indice della prossima immagine da leggere.
int nextImage = 0;
boolean ended = false;

public ImageSourceStream(int width, int height, int frameRate,
Vector images) {
this.width = width;
this.height = height;
this.images = images;
this.frameRate = (float) frameRate;
format = new VideoFormat(VideoFormat.JPEG,
new Dimension(width,height), Format.NOT_SPECIFIED, Format.byteArray,frameRate);
}

public boolean willReadBlock() {
return false;
}

/**
* Legge i frame del video.
*/
public void read(Buffer buf) throws IOException {
if (nextImage >= images.size()) {
System.err.println("Lette tutte le immagini.");
buf.setEOM(true);
buf.setOffset(0);
buf.setLength(0);
ended = true;
return;
}
String imageFile = (String) images.elementAt(nextImage);
nextImage++;
System.err.println("Lettura del file: " + imageFile);
// Apriamo un random access file per la prossima immagine.
RandomAccessFile raFile;
raFile = new RandomAccessFile(imageFile, "r");
byte data[] = null;
if (buf.getData() instanceof byte[])
data = (byte[]) buf.getData();
if (data == null || data.length < raFile.length()) {
data = new byte[(int) raFile.length()];
buf.setData(data);
}
long time = (long) (seqNo * (1000 / frameRate) * 1000000);
buf.setTimeStamp(time);
buf.setSequenceNumber(seqNo++);
raFile.readFully(data, 0, (int) raFile.length());
buf.setOffset(0);
buf.setLength((int) raFile.length());
buf.setFormat(format);
buf.setFlags(buf.getFlags() | buf.FLAG_KEY_FRAME);
raFile.close();
}

/**
* Ritorna il formato dell'immagine...che sarà JPEG
*/
public Format getFormat() {
return format;
}

public ContentDescriptor getContentDescriptor() {
return new ContentDescriptor(ContentDescriptor.RAW);
}

public long getContentLength() {
return 0;
}

public boolean endOfStream() {
return ended;
}

public Object[] getControls() {
return new Object[0];
}

public Object getControl(String type) {
return null;
}

}
}

 

Conclusioni
Come visto JMF è uno strumento molto potente per la gestione e l'elaborazione di contenuti multimediali. Oltre all'uso classico che se ne può fare, ovvero quello legato alla riproduzione dei contenuti multimediali, si possono pensare anche ad applicazione di editing, mixing ed encoding.
In questo articolo abbiamo visto come poter creare un video avendo a disposizione una serie di immagini. Un'applicazione di questo tipo può essere utile ad esempio per realizzare un filmato con le immagini acquisite da una web cam o da un'IP cam. Uno dei tanti scenari futuri che JMF preannuncia, è la possibilità di poter lavorare con formati multimediali sempre più aggiornati, come l'MPEG-4 e il 3GPP, che ci permetteranno di renderizzare i contenuti multimediali anche per terminali mobili.

Bibliografia
[1] "JMF API Specification", java.sun.com/products/java-media/jmf/index.jsp

 

Vincenzo Viola è nato a Formia (LT) il 03/11/1977, laureato in Ingegneria delle Telecomunicazioni presso l'Università Federico II di Napoli con una tesi sulla segmentazione automatica di video digitali in scene, con sviluppo software implementato su piattaforma Java - Oracle. Lavora come progettista software per una Mobile Company che offre la sua consulenza ai gestori di telefonia e alle major del settore ICT.


MokaByte® è un marchio registrato da MokaByte s.r.l. 
Java®, Jini® e tutti i nomi derivati sono marchi registrati da Sun Microsystems.
Tutti i diritti riservati. E' vietata la riproduzione anche parziale.
Per comunicazioni inviare una mail a info@mokabyte.it