::introtext::
In quest'articolo vedremo come realizzare applicazioni di visione robotica, realizzeremo una classe riutilizzabile che preso in input uno stream video rileva oggetti in movimento,e notifica tale evento ad altre classi interessate a gestirlo, come un robot.
Nella seconda parte dell'articolo vedremo invece un ulteriore algoritmo di visione robotica che ci permetterà di rilevare oggetti di un determinato colore.
Esempi di visione robotica in Java parte I
di Giuseppe Giannini
::fulltext::
In questo articolo ci occuperemo dell'elaborazione delle immagini per estrapolarne informazioni che un robot può utilizzare nei suoi compiti. Vedremo alcuni algoritmi di visione robotica, e come possono essere implementati in Java nel dettaglio. Prima di procedere però è opportuno richiamare alcuni concetti fondamentali, e precisamente che cos'è e come viene rappresentata un'immagine.
Un'immagine f è una matrice n*m di punti colorati. Un punto colorato (su un monitor) prende il nome di pixel.
Perché un pixel ci appaia colorato occorre che sia definito da almeno tre componenti di colore separate che, quando fuse tra loro, generino tutti i colori visibili. Tali componenti sono il rosso, il verde, il blu. In alcuni casi un ulteriore valore viene mantenuto per rappresentare la trasparenza (alfa) di quel pixel rispetto allo sfondo, ma per semplicità lo tralasceremo per ora. Per rappresentare un pixel quindi ci serve un vettore di c=3 elementi o un altro tipo di contenitore. Nel caso decidessimo di usare un vettore di 3 elementi, f diventa una matrice tridimensionale n*m*c. Se invece vogliamo usare un contenitore alternativo, possiamo considerare che una singola componente di colore può essere agevolmente rappresentata da un byte, e che disponendo di un intero a 24 bit possiamo inserirvici tre ottetti, ognuno rappresentante una componente diversa. Se prendiamo questa decisione, allora possiamo rappresentare f come una matrice bidimensionale n*m di interi più agevole e conveniente da mantenere, ma dobbiamo ricordarci di estrarre da ogni intero i tre byte per le tre componenti rosso, verde e blu (da ora in poi r,g,b), vedremo a tal scopo come usare l'operatore shift. Un'ulteriore rappresentazione di f potrebbe essere quella di mantenerla in un array monodimensionale di byte, dove ogni tre byte consecutivi rappresentano un pixel. In questo caso dobbiamo mantenere separata l'informazione della larghezza dell'immagine e usare una semplice formula per convertire l'indice dell'array nella coordinata x,y di un pixel. La scelta su quale rappresentazione usare dipende dallo scopo. Nel nostro caso per rendere semplice la spiegazione useremo ,per il momento, una rappresentazione di f come matrice n*m.
Fatte queste preliminari considerazioni, nel paragrafo seguente vengono approfonditi alcuni concetti fondamentali sull'elaborazione di un'immagine prima di procedere con la spiegazione degli algoritmi.
Trasformazioni e Filtri
Un’immagine può contenere molte informazioni, e per estrarle, occorre elaborarla sottoponendola a delle trasformazioni e applicandovi dei filtri.
Una trasformazione T è un operatore che prende in input un'immagine f e restituisce in output una nuova immagine g.
g(x,y)=T[f(x,y)]
L’operatore T viene progettato a seconda del risultato che si vuole ottenere. Esistono trasformazioni che operano nel dominio delle frequenze di f, e trasformazioni che operano nel dominio spaziale di f cioè sui suoi pixel. Qui vedremo operatori T del secondo tipo.
Le tecniche utilizzate per la trasformazione dell’immagine f sono diverse, ne elenco le principali, e sottolineo quelle che affronteremo in questo articolo per la realizzazione degli algoritmi di visione artificiale.
⦁ Pixel Operation:
g(x,y)=T[f(x,y)]
Il pixel elaborato g(x,y) è funzione dell’operatore T che agisce su un singolo pixel di f.
⦁ Local Operation:
g(x,y)=T[f(x1,y1)...(xm,ym)]
Il pixel elaborato g(x,y) è funzione di T che agisce su di un intorno del pixel f(x,y)
⦁ Object Operation
G=T[f(xi,yj)!(xm,ym) E Oggetto]
G è un oggetto e non un'immagine. G è generato dall'operatore T che opera su un sottoinsieme di pixel di f che definiscono un oggetto appartenente a una categoria.
⦁ Global Operation
g(x,y)=T[f(x,y)...f(xn,ym)]
Il pixel g(x,y) è ottenuto da tutta l'immagine f.
Per quanto riguarda la Local Operation, occorre approfondire il concetto di intorno di un pixel, e di come può essere usato da T nelle elaborazioni su f nel dominio spaziale.
Sia = f(x,y) un pixel. Un suo intorno può essere definito dai pixel a esso adiacenti per esempio nella tabella 1
=f(x-1,y-1) | =f(x,y-1) | =f(x+1,y-1) |
= f(x-1,y) | = f(x,y) | = f(x+1,y) |
= f(x-1,y+1) | = f(x,y+1) | = f(x+1,y+1) |
possiamo osservare un possibile intorno.
L'operatore T, nel caso della Local Operation, usa un filtro che applica a tale intorno per generare il nuovo pixel g(x,y).
Un filtro è una matrice W in cui ogni elemento rappresenta un peso da associare al rispettivo pixel dell'intorno di f. Nella tabella 2 è mostrato un esempio di filtro costituito da una maschera n*n.
w0 | w1 | w2 |
w3 | w4 | w5 |
w6 | w7 | w8 |
Tale maschera viene fatta scorrere su tutta l'immagine f centrandola sul pixel f(x,y) e applicandola al suo intorno. Per ogni pixel di f viene quindi generato un pixel in g. Tale pixel viene settato al valore R, ottenuto come segue:
<img null>
O in forma esplicita:
Questo procedimento è noto con il nome di Convoluzione Matematica, e a seconda dei valori che si assegnano ai pesi si può elaborare l'immagine in maniera diversa. Per esempio il filtro per rilevare i contorni di un oggetto è visibile in Tabella 3.
-1 | 0 | -1 |
0 | 4 | 0 |
-1 | 0 | -1 |
Mentre un filtro per aumentarne il contrasto è visibile in tabella 4:
-1 | 0 | -1 |
0 | 5 | 0 |
-1 | 0 | -1 |
Gli algoritmi che vedremo tra breve adottano, di base, questa tecnica.
JMF
Per scrivere un componente di visione robotica, ci serve prima di tutto un flusso video da elaborare. Java Media Framework è un package Java che consente, tra le altre cose, di riconoscere ed utilizzare nelle proprie applicazioni Java (stand-alone e Applet) una web-cam e catturarne il flusso video. Una volta scaricato il pacchetto da internet (www.sun.com), l'installazione è molto semplice sotto qualsiasi sistema operativo. L'unica procedura da eseguire manualmente, se si utilizzano ambienti di sviluppo integrati, è quella di settare il path dove i file *jar (contenenti le classi di JMF) sono stati installati, per poterle cosi importare nei propri sorgenti.
Per esempio in JBuilder bisogna, a tal scopo, settare le proprietà del progetto in corso e creare una nuova libreria contenente tali archivi jar.
Concettualmente uno stream video è come un torrente di byte che dalla sorgente (la web-cam) arriva alla destinazione (il render GDI) come visibile in Figura 1.
Noi possiamo realizzare classi che prendono in input tale flusso di byte, lo elaborano o lo analizzano e lo riversano in output. Sempre in Figura 1 la classe intermedia è il nostro primo componente. Tali classi per potersi inserire nel flusso video devono implementare l'interface Effect, che ha molti metodi (per i quali rimando alle api JavaDoc di JMF), il metodo da implementare che ci interessa particolarmente è :
public int process(Buffer inBuffer,Buffer outBuffer)
I parametri inBuffer e outBuffer sono rispettivamente la nostra traccia video sorgente e destinazione. Possiamo ottenere facilmente un array monodimensionale di byte contenenti, in ogni elemento, una singola componente di colore di un pixel invocando il metodo inBuffer.getData(). Ogni tre elementi dell'array restituito viene definito un singolo pixel. Il seguente frammento di codice mostra come ottenere un array monodimensionale di singole componenti di colore e come convertirlo in una matrice bidimensionale di pixel.
public static int[][] buffer2IntArray(Buffer data){
byte[] dati=(byte[])data.getData();
RGBFormat formatoVideo=(RGBFormat)data.getFormat();
Dimension size=formatoVideo.getSize();
int[][] returnData=new int[size.width][size.height];
int i=0;
for(int y=size.height-1;y>=0;y--){
for(int x=0;x<size.width;x++){
returnData[x][y]=255 << 24 |
(int) (dati[i] & 0xff) << 16 |
(int) (dati[i + 1] & 0xff) << 8 |
(int) (dati[i + 2] & 0xff);
i+=3;
}
}
return returnData;
}
Le dimensioni (width,height) dell'immagine vengono restituite dalla classe RGBFormat. Da notare che nel ciclo for (più esterno) l'immagine viene capovolta per poterla vedere correttamente nel render, e un pixel viene costruito usando i valori di tre elementi consecutivi dell'array dati usando l'operatore shift <<.
Nel seguente frammento di codice è invece possibile vedere un esempio di un possibile uso delle varie classi di JMF ottenere uno stream nel quale inserire nostre classi che elaborano inBuffer e lo copiano su outBuffer. Il seguente esempio non è compilabile come invece lo sono gli altri esempi di quest'articolo perché, per motivi di spazio, sono stati omessi i blocchi try/catch per gestire le eccezioni ed è finalizzato a mostrarvi le classi che occorrono, i cui dettagli d'uso sono dettagliatamente documentati nel Java Doc di JMF.
String cam = "vfw:Logitech USB Video Camera:0";
CaptureDeviceInfo dev=CaptureDeviceManager.getDevice(cam);
MediaLocator ml = dev.getLocator();
DataSource ds = Manager.createCloneableDataSource(
Manager.createDataSource(ml)
);
Processor p = Manager.createProcessor(ds);
p.configure();
/*se abbiamo realizzato degli effeti da applicare
al flusso video implementando l'interface Effecs possiamo
inserirli nel flusso come segue*/
if(listaEffetti.size()>0){
Codec codec[] = new Codec[listaEffetti.size()];
for(int i=0;i<codec.length;i++){
codec[i]=(Effect)listaEffetti.elementAt(i);
}
videoTrack.setCodecChain(codec);
}
In questo frammento il nome del driver della web-cam è una stringa statica, ma potete usare un file di proprietà o XML per caricarlo dinamicamente, l'unica vera parte interessante e quella che riguarda l'inserimento di effetti nel flusso video utilizzando la classe Codec che è un array di riferimenti a oggetti di tipo Effect. L'array Codec va inserito nella catena delle tracce video, ma noi nel seguente paragrafo non ci limiteremo a realizzare una classe che elabora un effetto, bensì che analizza due fotogrammi consecutivi, rileva gli oggetti che si sono mossi, e li evidenzia disegnandogli intorno un mirino, o visualizzandoli su uno sfondo nero, e che lancia eventi ad altri oggetti interessati a riceverli, come per esempio un programma che pilota un robot remoto.
Rilevatore Di Movimenti
Il componente riutilizzabile che andiamo a sviluppare, permette di rilevare e marcare oggetti in movimento. Chiameremo questo componente RilevatoreMovimenti e lo inseriamo nella lista degli effetti della traccia video (Figura1) che abbiamo visto nel paragrafo precedente. RilevatoreMovimenti deve implementare l'interface Effect per prendere in input tramite il metodo processes un flusso video. Userà anche un valore di soglia (settato dall'utente tramite interfaccia grafica che possiamo facilmente realizzare con Javax.Swing) e tra breve vedremo a cosa serve. Nel metodo process che dobbiamo implementare, il rilevatore estrapola, in tempo reale, dal flusso video due immagini consecutive e se nelle due immagini (o in porzioni di esse) si verifica un cambiamento dei pixel, superiore alla soglia, il componente notifica l'evento a tutti gli oggetti interessati. Gli oggetti interessati alla notifica (per esempio robot o applicazioni di video sorveglianza) dovranno quindi implementare una nostra interfaccia software di comunicazione che permetta il passaggio dell'evento generato. Tale interface la chiameremo RilevatoreMovimentiListener, e passerà un oggetto che chiameremo MovimentoEvento. nel seguente frammento di codice possiamo vedere come sia semplice tale realizzazione:
public interface RilevatoreMovimentiListener{
public void MovimentoRilevato(MovimentoEvento);
public void MovimentoNoNRilevato(MovimentoEvento);
}
La notifica dell'evento avviene, quindi, tramite la tecnologia dei listener di Java. Gli oggetti interessati alla notifica possono essere altre classi (per esempio controller di robot) che reagiscono all'evento in qualche modo personalizzato.
Infatti l'interfaccia software (RilevatoreMovimentiListener) fornisce le firme dei metodi che vengono invocati dal nostro RilevatoreMovimenti quando questi intercetta un oggetto in movimento (MovimentoRilevato) e quando nulla si muove (MovimentoNoNRilevato). Chi implementa tali metodi ereditati dall'interfaccia, e si inserisce nella lista dei listener del RilevatoreMovimenti, può eseguire il proprio codice quando l'evento viene notificato per effetto del polimorfismo. Possiamo dotare la classe, inoltre, di un pannello di comando per regolare manualmente alcuni parametri come la soglia e monitorare ciò che viene rilevato (un esempio è in Figura2d). Questo è lo scheletro del componente, ora passiamo al funzionamento dell'algoritmo di rilevamento movimenti nei suoi dettagli, il primo passo che compie è calcolare le intensità delle due immagini consecutive catturate dal flusso video nel modo riportato nel Listato1, dove potete notare anche la formula matematica per tale calcolo.
Nella formula che precede il Listato1, n rappresenta la dimensione di f e si ottiene da n=larghezza(f)*altezza(f) ; mentre c rappresenta il numero di componenti di colore che formano un singolo pixel (nel nostro caso c=3 perché un pixel è formato da tre elementi di colore distinti come accennato nell'introduzione ); e infine non rappresenta, in questo caso, un singolo pixel dell'immagine f ma la somma dei valori rosso verde e blu che compongono tale pixel.
Se rappresentiamo l'immagine f come una matrice tridimensionale, possiamo ottenere p nel seguente modo:
Dove x e y sono gli indici della posizione del pixel e l'indice della componente di colore. In alternativa, sapendo che per rappresentare una componente di colore bastano 8 bit (un byte) per avere ben gradazioni distinte, e che un intero di 24 bit può contenere 3 ottetti (byte), possiamo continuare a gestire f come una matrice bidimensionale, ma dobbiamo, in fase d'implementazione, lavorare sui singoli byte per ottenere il valore corretto di p. Nel Riquadro 1 è possibile notare un esempio per estrarre da un intero, che rappresenta un pixel, le tre componenti di colore distinte badando a non perdere l'informazione sul segno.
In seguito, per avere p come valore di intensità del pixel si sommeranno i valori r,g,b. I valori r,g,b, inoltre, devono essere tutti unsigned ed è per questo che l'AND binario è necessario, ancora nel Riquadro 1 si può notare l'uso dell'operatore.
L'algoritmo dopo aver calcolato le intensità delle due immagini consecutive da confrontare, calcola un valore di correzione ottenuto dalla differenza in valore assoluto delle due intensità, visibile ancora verso la fine del Listato 1. Tale valore di correzione viene utilizzato dall'algoritmo di rilevamento movimenti per ritoccare i valori rosso, verde e blu di un singolo pixel per poi utilizzarli per generare il nuovo pixel nell'immagine in output g(x,y). La formula per la generazione del nuovo pixel è la seguente e viene realizzata sempre nel Listato 1 tramite la funzione java.lang.Math.sqrt che implementa quanto segue:
Dove r,g,b sono le componenti di colore di un singolo pixel opportunamente corrette con il valore di correzione precedentemente calcolato. Tale correzione è avvenuta nel Listato 1 nel modo seguente:
⦁ componente colore = 0 se componente colore < (del fattore di correzione)
⦁ componente colore = componente colore - (fattore di correzione) altrimenti
Ora che l'algoritmo ha il risultato R dell'elaborazione, lo confronta (vedi frammento codice nel Listato 2) con il valore di soglia che l'utente gli ha passato come parametro in input. Se R > soglia allora il nuovo pixel g(x,y) verrà colorato di bianco, altrimenti gli sarà assegnato proprio il valore di R (naturalmente R sarà numericamente minore del bianco=0xffffff). Abbiamo così un'immagine in memoria (e non verrà visualizzata così com'è adesso) che contiene la seguente informazione:
⦁ Tutti i pixel bianchi sono pixel che rappresentano oggetti immobili nella scena;
⦁ Tutti i pixel di colore diverso (numericamente minori del bianco=0xffffff) sono pixel di oggetti che si sono mossi.
A questo punto l'algoritmo deve eseguire un'ultima operazione; contare il numero di pixel che risultano cambiati e, se tale numero, è superiore alla soglia notificare l'evento agli oggetti registrati nel proprio listener.
Tale conteggio è molto semplice e viene eseguito considerando il fatto che tutti i pixel g(x,y) di colore minore del bianco sono cambiamenti nelle immagini confrontate. Basta contare quindi tali pixel (e magari cambiare il colore degli altri in nero per far risaltare solo i pixel di colore R vedi Figura 2b). Opzionalmente possiamo usare contemporaneamente la convoluzione matematica ed evidenziare l'oggetto che si è spostato colorandone l'interno di un colore particolare, magari di blu o di giallo, per comunicare anche visivamente tale evento e disegnarvi poi un rettangolo o mirino di contenimento, vedi Figura 2c e il codice nel Listato 2.
La conseguenza di questa procedura è molto interessante. Avremo, infatti, un'immagine g totalmente nera se non vi sono oggetti in movimento nel video (tutti i pixel bianchi appartenenti ad oggetti immobili vengono settati a nero). Mentre avremo degli aloni luminescenti intorno e all'interno degli oggetti in movimento (i loro pixel hanno valore R Figura 2b), che però non si muovono abbastanza da superare il valore di soglia, mentre, contemporaneamente, avremo oggetti colorati di blu e con contorni bianchi (conseguenza della convoluzione Listato 2) se il loro movimento supera il valore di soglia (Figura 2c). In quest'ultimo caso l'evento verrà notificato a tutte le classi interessate tramite la seguente procedura:
for(int i=0;i<listererSize;i++){
RilevatoreMovimenti x=(RilevatoreMovimenti)listener[i];
MovimentoEvento evt=new MovomentoEvento( ... );
x.movimentoRilevato(evt);
}
Nella Figura 2a è visibile l'oggetto in movimento quando nel rilevatore l'utente disabilita la visione dell'immagine elaborata, mentre nella Figura 2d notiamo l'interfaccia con una barra orizzontale che evidenzia la quantità dei pixel cambiati.
Un'applicazione d'esempio
In questo paragrafo verrà illustrato, per mezzo di un esempio, come usare il rilevatore di movimenti per applicazioni robotiche. L'applicazione consiste nel programmare un robot (reale) a rilevare oggetti in movimento nel suo campo visivo, capirne la posizione, e voltarsi verso l'oggetto fino a centrarlo nel suo campo visivo. Il robot verrà pilotato (tramite comunicazione seriale) dal computer sul quale è in esecuzione un'applicazione che istanzia Rilevatori di Movimenti. La web cam ovviamente sarà montata sul robot. Il robot dell'esempio è stato assemblato usando componenti facilmente reperibili in commercio, in particolare si consiglia di dare uno sguardo al sito della Parallax (www.parallax.com) dove è possibile trovare kit robotici programmabili sia in Java che in altri linguaggi. Il mostro robot è costituito da una scheda elettronica Basic Stamp, una web-cam e due servo motori che muovono la web-cam. Il programma che risiede sul nostro robot si limita ad attendere comandi tramite la porta seriale per poi eseguirli. I comandi sono inviati dalla nostra applicazione remota che risiede sul computer. Essa invierà i seguenti comandi: muovi la camera a destra, a sinistra, in alto, in basso. Per questo esempio non è necessario implementare un protocollo di comunicazione per il controllo del flusso dei dati inviati, come per esempio lo stop-and-wait oppure go-back-n o altri, ma potrebbe essere un'ottima idea per progetti specifici. Per approfondire il tema consultare bibliografia[3].
Nella Figura 3 possiamo intuire il funzionamento dell'applicazione.
In questa figura infatti troviamo al centro l'immagine percepita dal robot, suddivisa in cinque aree ( le zone scure ). L'area al centro è abilitata a rilevare i contorni degli oggetti e ne parleremo un prossimo articolo, in questo momento rileva i contorni di un piccolo veicolo. Le quattro zone laterali (in nero) sono quattro Rilevatori di Movimento distinti assegnati a Thread in esecuzione concorrente. Tali aree sono nere perché nessun oggetto è in movimento. Che nessun oggetto sia in movimento si può notare anche osservando i quattro pannelli che circondano la finestra principale. Ogni uno di questi pannelli appartiene ad un rilevatore di movimento, la barra nera sul loro fondo dei pannelli indica un eventuale movimento percepito.
Nella Figura 4 è possibile notare ciò che accade quando il veicolo che si trovava nell'area centrale si sposta verso il robot.
Esso infatti viene percepito dal rilevatore di movimenti inferiore, che evidenzia il veicolo con un alone esterno e colorandolo internamente in blu (se il movimento come in questo esempio supera la soglia). Nel rispettivo pannello si nota l'illuminazione della barra posta sul fondo in giallo, che indica il superamento della soglia, e la quantità di pixel cambiati in azzurro. A questo punto il rilevatore notifica l'evento ai listener collegati, in questo esempio l'applicazione che controlla il robot tramite la porta seriale, sospende temporaneamente l'esecuzione dei rilevatori e comanderà il robot di attivare il motore che permette alla telecamera di abbassarsi, al fine di centrare nuovamente l'oggetto in movimento nel campo visivo per poi riattivare i rilevatori. Appare chiaro il comportamento che avrà il nostro robot:
⦁ Abbasserà la camera se l'oggetto muove verso il robot.
⦁ Alzerà la camera se l'oggetto si allontana.
⦁ Ruoterà a sinistra se sarà il rilevatore sinistro a percepire il movimento.
⦁ Ruoterà a destra se sarà il rilevatore destro a notificare l'evento.
Questo comportamento fa rientrare la nostra macchina nella categoria delle macchine S-R (stimolo risposta), per approfondire questo argomento vedi bibliografia[4].
Seguono alcuni frammenti di codice che illustrano come istanziare i rilevatori e come gestirne gli eventi.
Per creare un nuovo rilevatore in un Thread indipendente, definirne la zona dell'immagine su cui lavorare, e collegargli un gestore eventi, è sufficiente scrivere nell'applicazione preposta alla comunicazione con il robot quanto segue:
RilevatoreMovimenti[] rme=new RilevatoreMovimenti[4];
rme[0]=new RilevatoreMovimenti(0,240/3,320/3,240/3);
rme[0].nome="Rilevatore Movimenti SX";//opzionale
rme[0].addRilevatoreMovimentiListener(this);
Thread t=new Thread(rme[0]);
t.start();
...
Questo codice ovviamente va inserito in un metodo (magari il costruttore) di una classe che implementa un listener per il Rilevatore movimenti (RilevatoreMovimentiListener), come nell'esempio seguente:
public class VideoSorveglianzaRobotica
implements RilevatoreMovimentiListener{
...
}
Tale classe quindi deve implementare i metodi dell'interfaccia, come nell'esempio seguente:
public void movimentoRilevato(MovimentiEvento e){
Object sorg=e.getRilevatoreAttivo();
...//codice di temporizzazione o sincronizzazione opzionale
if(sorg==rme[i]){
System.out.println("Movimento rilevato a sx");
}
...
else if(sorg==rmeGIU){
System.out.println("Movimento rilevato giu");
}
}
}
public void movimentoNonRilevato(MovimentiEvento e){}
L'ultimo aspetto da considerare e come inviare i comandi dal pc al robot. Nel Listato 3 e possibile vedere un frammento di codice (commentato per riga per riga) che apre una connessione con un robot tramite la porta seriale e invia un comando sotto forma di stringa. Il robot può essere perciò dotato di qualsiasi tipo di scheda (per es. una Basic Stamp della Parallax) che possa essere programmata a ricevere comandi da seriale e quindi esegue l'azione adeguata attivando i servo motori.
Conclusioni
Naturalmente ci sarebbero molti altri aspetti da approfondire, ma per il momento questi concetti di base e questi esempi possono darvi buoni spunti di partenza.
Nel prossimo articolo vedremo come realizzare un'applicazione che invece di oggetti in movimento rileva oggetti di un determinato colore, e modificheremo questa applicazione perché il robot tracci tali oggetti. Inoltre parleremo più in dettaglio di alcuni kit robotici e della loro programmazione.
Comments powered by CComment