Detekcija lica pomoću HTML5, JavaScript, webrtc, websockets, Jetty i OpenCV

Kroz HTML5 i odgovarajuće standarde, moderni pretraživači dobijaju više standarizovanih karakteristika sa svakim izdanjem. Većina ljudi je čula za websockets koji vam omogućava da lako podesite dvosmerni kanal komunikacije sa serverom, ali jedna od specifikacija koja nije dobila mnogo pokrivenosti je webrtc specifikacija.

Sa webrtc specifikacijom će biti lakše da kreirate čiste HTML / Javascript video / audio srodne aplikacije u realnom vremenu, gde možete pristupiti korisnikovom mikrofonu ili veb kameri i deliti ove podatke sa ostalim kolegama na internetu. Na primer, možete da kreirate softver za video konferencije koji ne zahteva priključak, napravite monitor za bebe koristeći svoj mobilni telefon ili olakšati veb obuku. Sve korišćenjem cross-browser funkcija bez upotrebe ijednog priključka.

Ažuriranje: u najnovijim verzijama webrtc specifikacija možemo pristupiti mikrofonu! Pogledajte ovde objašnjenje!

Kao i puno HTML5 specifikacija, ni webrtc nije još uvek završen, a podrška među pretraživačima je minimalna. Međutim, još uvek možete da uradite veoma kul stvari uz podršku koja je trenutno dostupna u razvoju Opera-e i najnovijeg Chrome-a. U ovom članku ću vam pokazati kako da koristite webrtc i nekoliko drugih HTML5 standarda za ostvarivanje sledećeg::

Face detected

Za ovo treba da preduzmemo sledeće korake:

  • Pristupite kameri korisnika kroz getUserMedia funkciju
  • Pošaljite podatke o kameri pomoću websockets-a na server
  • Na serveru, analiziramo dobijene podatke, koristeći JavaCV / OpenCV da otkrijemo i obeležimo bilo koje lice koje je prepoznato
  • Koristite websockets za slanje podataka nazad, sa servera ka klijentu
  • Prikažite primljene informacije od servera ka klijentu

Drugim rečima, mi ćemo stvoriti sistem detekcije lica u realnom vremenu, gde je frontend u potpunosti omogućen pomoću “standardne” HTML5/Javascript funkcionalnosti. Kao što ćete videti u ovom članku, mi ćemo morati da koristimo nekoliko zaobilaznica, jer neke funkcije nisu još uvek implementirane..

Koje alatke i tehnologije koristimo

Počnimo gledajući alate i tehnologije koje ćemo koristiti da kreiramo svoj HTML5 sistem detekcije lica. Počećemo sa frontend tehnologijama.

  • Webrtc: Strana specifikacija kaže sledeće. Ove API bi trebalo da omoguće izgradnju aplikacija koje se mogu pokrenuti unutar pretraživača, ne zahtevajući dodatna preuzimanja ili dodatke, koje omogućavaju komunikaciju između stranaka koje koriste audio, video i dopunsku komunikaciju u realnom vremenu, bez potrebe da koriste intervenisanje servera (osim ako je potrebno za firewall prolaze ili za pružanje posredničkih usluga).
  • Websockets: Opet, sa strane specifikacija. Da biste omogućili veb aplikacijama održavanje dvosmernih komunikacija sa serverske strane procesa, ova specifikacija uvodi WebSocket interfejs.
  • Canvas: Takođe sa strane specifikacije: Element pruža skripte sa bitmap platnom zavisnim od rezolucije, koje se mogu koristiti za renderovanje grafikona, grafika igre ili drugih vizuelnih slika u letu

Šta mi koristimo u backend-u:

  • Jetty: Pruža nam odličnu WebSocket implementaciju
  • OpenCV: Biblioteka koja ima sve vrste algoritama za manipulaciju slikama. Mi koristimo njihovu podršku prepoznavanja lica.
  • JavaCV: Mi želimo da koristimo OpenCV direktno od Jetty za detekciju slika na osnovu podataka koje dobijamo. Sa JavaCV možemo koristiti funkcije OpenCV kroz Java omotač..

Pristupni korak 1: Omogućite mediastream u Chrome-u i pristup veb kameri

Počnimo sa pristupom veb kameri. Na mom primeru koristio sam poslednju verziju Chrome-a (canary) koji podržava ovaj deo webrtc specifikacije. Pre nego što počnete da ga koristite, morate prvo da ga omogućite. To možete da uradite tako što ćete otvoriti “chrome://flags/”URL i omogućite mediastream funkciju.

Chrome flags

Kada ste ga omogućili (i restartovali pretraživač), možete da koristite neke od karakteristika webrtc da pristupite veb kameri direktno sa pretraživača bez korišćenja priključaka. Sve što treba da uradite da biste pristupili veb kameri je da koristite sledeće parče HTML i JavaScript:

<div>
    <video id="live" width="320" height="240" autoplay></video>
</div>

I sledeći javascript:

 video = document.getElementById("live")
    var ctx;
    // use the chrome specific GetUserMedia function
    navigator.webkitGetUserMedia("video",
            function(stream) {
                video.src = webkitURL.createObjectURL(stream);
            },
            function(err) {
                console.log("Unable to get video stream!")
            }
    )

Sa ovim malim parčetom HTML i JavaScript možemo pristupiti veb kameri korisnika i pokazati tok u HTML5 video elementu. Mi ovo radimo prvo tražeći pristup veb kameri pomoću getUserMedia funkcije ( sa specifičnim VebKit prefiksom). U povratnom pozivu, dobijamo pristup do stream objekta. Ovaj stream objekat je stream sa veb kamere korisnika. Da bismo prikazali ovaj stream, moramo da ga priključimo na video element. SRC atribut video elementa nam omogućava da specificiramo URL za igru. Sa drugom novom HTML5 funkcijom možemo konvertovati stream na URL. Ovo se radi korišćenjem URL.CreateObjectURL funkcije (opet prefiks). Rezultat ove funkcije je URL adresa koju pridajemo video elementu. I to je sve što je potrebno da dobijete pristup stream-u veb kamere korisnika:

Step 1 complete, time for a beer

Sledeće što želimo da uradimo je da pošaljemo ovaj stream, koristeći websockets, na jetty server..

Pristupni korak 2: Pošalji stream na Jetty server preko websockets

Na ovom koraku želimo da uzmemo podatke iz stream-a i pošaljemo ih kao binarne podatke preko websockets na Jetty server za slušanje. U teoriji, ovo zvuči jednostavno. Imamo binarni tok video informacija, tako da bi trebalo da budemo u stanju da samo pristupimo bajtovima i umesto streaming-a podataka na video elementu, pustimo preko websocket-a na naš udaljeni server. U praksi ipak, ovo ne radi. Stream objekat koji dobijate od getUserMedia funkcije, nema mogućnost pristupa njegovim podacima kao protoku. Ili, bolje rečeno, ne još. Ako pogledate specifikacije trebalo bi da ste u mogućnosti da pozovete snimak() da dobijete pristup rekorderu. Ovaj rekorder se zatim može koristiti za pristup sirovim podacima. Nažalost, ova funkcija još uvek nije podržana u bilo kom pretraživaču. Zato moramo da nađemo alternativu. Za ovo imamo samo jednu opciju:

  1. Napravite snimak trenutnog videa.
  2. Preslikajte ovo na canvas element.
  3. Uzmite podatke sa canvas-a kao sliku.
  4. Pošaljite sliku preko websockets.

Malo zaobilazno rešenje koje iziskuje mnogo dodatne obrade na strani klijenta i rezultuje mnogo većom količinom podataka koji se šalju na server, ali radi. Primena ovoga nije tako teška:

<div>
    <video id="live" width="320" height="240" autoplay style="display: inline;"></video>
    <canvas width="320" id="canvas" height="240" style="display: inline;"></canvas>
</div>
 
 <script type="text/javascript">
    var video = $("#live").get()[0];
    var canvas = $("#canvas");
    var ctx = canvas.get()[0].getContext('2d');
 
    navigator.webkitGetUserMedia("video",
            function(stream) {
                video.src = webkitURL.createObjectURL(stream);
            },
            function(err) {
                console.log("Unable to get video stream!")
            }
    )
 
    timer = setInterval(
            function () {
                ctx.drawImage(video, 0, 0, 320, 240);
            }, 250);
</script>

Nije mnogo složenije od prethodnog dela koda. Ono što smo dodali je tajmer i canvas na kojima možemo crtati. Ovaj tajmer se pokreće svakih 250ms i crta trenutnu video sliku na canvas (kao što možete videti u narednom snimku):

Step 2: Copy image to canvas
Kao što možete da vidite canvas ima malo zakašnjenje.

Možete ga podesiti tako što ćete sniziti interval, ali ovo zahteva mnogo više resursa. Sledeći korak je da uhvatite sliku sa canvasa, konvertovati u binarni kod, i poslati je preko websocket-a. Pre nego što pogledamo websocket deo, prvo pogledajmo deo sa podacima. Da bismo dobili podatke mi proširujemo funkciju tajmera sa sledećim delom koda:

    timer = setInterval(
            function () {
                ctx.drawImage(video, 0, 0, 320, 240);
                var data = canvas.get()[0].toDataURL('image/jpeg', 1.0);
                newblob = dataURItoBlob(data);
            }, 250);
    }

ToDataURL funkcija kopira sadržaj iz tekućeg canvasa i skladišti ga u dataurl. Dataurl je niz koji sadrži base64 kodirane binarne podatke. Na našem primeru to izgleda ovako:

data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEB
AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB
AQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBA
QEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCADwAUADASIAAhEBAxEB/8QAHwAAAQU
.. snip ..
QxL7FDBd+0sxJYZ3Ma5WOSYxwMEBViFlRvmIUFmwUO7O75Q4OSByS5L57xcoJuVaSTTpyfJ
RSjKfxayklZKpzXc1zVXVpxlGRKo1K8pPlje6bs22oxSau4R9289JNJuLirpqL4p44FcQMkYMjrs+z
vhpNuzDBjlmJVADuwMLzsIy4OTMBvAxuDM+AQW2vsVzIoyQQwG1j8hxt6VELxd7L5caoT5q4kj
uc4rku4QjOPI4tNXxkua01y8uijJtSTS80le0Z6WjJuz5pXaa//2Q== 

Možemo ovo poslati kao tekstualnu poruku i pustiti da ga server dekodira, ali pošto nam websockets takođe omogućava da šaljemo binarne podatke, mi ćemo ovo konvertovati u binarni kod. Moramo da uradimo ovo u dva koraka, pošto nam canvas ne dozvoljava (ili ja ne znam kako) direktan pristup binarnim podacima. Srećom neko u Stackoverflow-u je kreirao lep pomoćni metod za to (dataURItoBlob) koji radi upravo ono što nam je potrebno (za više informacija i kod vidi ovaj post). U ovom trenutku imamo niz podataka koji sadrže snimak trenutnog videa koji je napravljen u određenom intervalu. Sledeći korak, i za sada poslednji korak od strane klijenta je da pošalje ovo koristeći websockets.

Korišćenje websockets iz Javascript-a je zapravo vrlo lako. Vi samo treba da odredite websockets url i implementirate nekoliko povratnih funkcija. Prva stvar koju treba da uradite je da otvorite vezu:

    var ws = new WebSocket("ws://127.0.0.1:9999");
    ws.onopen = function () {
              console.log("Openened connection to websocket");
    }

Pod pretpostavkom da je sve prošlo uredu, sada imamo dvosmernu websockets vezu. Slanje podataka preko ove veze je lako kao pozivanje ws.send:

    timer = setInterval(
            function () {
                ctx.drawImage(video, 0, 0, 320, 240);
                var data = canvas.get()[0].toDataURL('image/jpeg', 1.0);
                newblob = dataURItoBlob(data);
                ws.send(newblob);
            }, 250);
    }

To je to od strane klijenta. Ako otvorimo ovu stranicu, mi ćemo dobiti/tražiti pristup korisničkoj kameri, pokazati stream iz kamere u video element, uhvatiti snimanje video zapisa u određenom intervalu, i poslati podatke koristeći websockets na backend server za dalju obradu.

Podešavanje backend okruženja

Pozadina za ovaj primer je napravljena korišćenjem Jetty websocket podrške (u kasnijem članku ću da vidim da li ga mogu pokrenuti korišćenjem Play 2,0 websockets podrške). Sa Jetty je zaista lako lansirati server za websocket slušaoca. Ja obično pokrećem ugrađen Jetty, i da bi pokrenuo websockets, koristim sledeći jednostavni Jetty pokretač.

public class WebsocketServer extends Server {
 
	private final static Logger LOG = Logger.getLogger(WebsocketServer.class);
 
	public WebsocketServer(int port) {
		SelectChannelConnector connector = new SelectChannelConnector();
		connector.setPort(port);
		addConnector(connector);
 
		WebSocketHandler wsHandler = new WebSocketHandler() {
			public WebSocket doWebSocketConnect(HttpServletRequest request,	String protocol) {
				return new FaceDetectWebSocket();
			}
		};
		setHandler(wsHandler);
	}
 
	/**
	 * Simple innerclass that is used to handle websocket connections.
	 * 
	 * @author jos
	 */
	private static class FaceDetectWebSocket implements WebSocket,
			WebSocket.OnBinaryMessage, WebSocket.OnTextMessage {
 
		private Connection connection;
		private FaceDetection faceDetection = new FaceDetection();
 
		public FaceDetectWebSocket() {
			super();
		}
 
		/**
		 * On open we set the connection locally, and enable
		 * binary support
		 */
		public void onOpen(Connection connection) {
			this.connection = connection;
			this.connection.setMaxBinaryMessageSize(1024 * 512);
		}
 
		/**
		 * Cleanup if needed. Not used for this example
		 */
		public void onClose(int code, String message) {}
 
		/**
		 * When we receive a binary message we assume it is an image. We then run this
		 * image through our face detection algorithm and send back the response.
		 */
		public void onMessage(byte[] data, int offset, int length) {
 
			ByteArrayOutputStream bOut = new ByteArrayOutputStream();
			bOut.write(data, offset, length);
			try {
				byte[] result = faceDetection.convert(bOut.toByteArray());				
				this.connection.sendMessage(result, 0, result.length);
			} catch (IOException e) {
				LOG.error("Error in facedetection, ignoring message:" + e.getMessage());
			}
		}
 
 
	}
 
	/**
	 * Start the server on port 999
	 */
	public static void main(String[] args) throws Exception {
		WebsocketServer server = new WebsocketServer(9999);
		server.start();
		server.join();
	}	
}

Veliki izvorni fajl, ali ne tako težak za razumevanje. Uvozni delovi stvaraju držač koji podržava websocket protokol. U ovom listingu kreiramo WebSocketHandler koji uvek vraća isti WebSocket. U stvarnom svetu bi determinisali tip WebSocket zasnovan na svojstvima ili URL adresi, u ovom primeru uvek nam se vraća isti tip.
Sam websocket nije tako kompleksan, ali treba da konfigurišemo nekoliko stvari da bi sve ispravno radilo. U onOpen metodi radimo sledeće:

		public void onOpen(Connection connection) {
			this.connection = connection;
			this.connection.setMaxBinaryMessageSize(1024 * 512);
		}

Ovo omogućava podršku za binarne poruke. Naš WebSocket sada može primiti binarne poruke do 512KB, pošto ne emitujemo direktno podatke, nego šaljemo canvas renderovane slike, veličina poruke je prilično velika. 512KB ipak je više nego dovoljno za poruke veličine 640x480. Naša detekcija lica, takođe odlično funkcioniše sa rezolucijom od 320x240, tako da ovo treba da bude dovoljno. Obrada primljene binarne slike se vrši u onMessage metodi:

		public void onMessage(byte[] data, int offset, int length) {
 
			ByteArrayOutputStream bOut = new ByteArrayOutputStream();
			bOut.write(data, offset, length);
			try {
				byte[] result = faceDetection.convert(bOut.toByteArray());				
				this.connection.sendMessage(result, 0, result.length);
			} catch (IOException e) {
				LOG.error("Error in facedetection, ignoring message:" + e.getMessage());
			}
		}

Ovo nije stvarno optimizovan kod, ali njegove namere bi trebalo da budu jasne. Dobijamo podatke poslate od klijenta, napišemo ih u bytearray sa fiksnom veličinom i prenosimo ih na faceDetection klasu. Ova FaceDetection klasa čini svoju magiju i vraća obrađene slike. Ova obrađena slika je ista kao i original, ali sa žutim pravougaonikom koji indicira na pronađeno lice.

Face Detection result

Ova obrađena slika je poslata nazad preko iste websocket veze da bi bila obrađena od strane HTML klijenta. Pre nego što pogledamo kako možemo prikazati ove podatke koristeći JavaScript, bacićemo pogled na FaceDetection klasu.

FaceDetection klasa koristi CvHaarClassifierCascade iz JavaCV-a, java omotače za OpenCV, za detektovanje lica. Neću ulaziti u previše detalja kako detekcija lica radi, jer je to veoma obimna tema.

public class FaceDetection {
 
	private static final String CASCADE_FILE = "resources/haarcascade_frontalface_alt.xml";
 
	private int minsize = 20;
	private int group = 0;
	private double scale = 1.1;
 
	/**
	 * Based on FaceDetection example from JavaCV.
	 */
	public byte[] convert(byte[] imageData) throws IOException {
		// create image from supplied bytearray
		IplImage originalImage = cvDecodeImage(cvMat(1, imageData.length,CV_8UC1, new BytePointer(imageData)));
 
		// Convert to grayscale for recognition
		IplImage grayImage = IplImage.create(originalImage.width(), originalImage.height(), IPL_DEPTH_8U, 1);
		cvCvtColor(originalImage, grayImage, CV_BGR2GRAY);
 
		// storage is needed to store information during detection
		CvMemStorage storage = CvMemStorage.create();
 
		// Configuration to use in analysis
		CvHaarClassifierCascade cascade = new CvHaarClassifierCascade(cvLoad(CASCADE_FILE));
 
		// We detect the faces.
		CvSeq faces = cvHaarDetectObjects(grayImage, cascade, storage, scale, group, minsize);
 
		// We iterate over the discovered faces and draw yellow rectangles around them.
		for (int i = 0; i < faces.total(); i++) {
			CvRect r = new CvRect(cvGetSeqElem(faces, i));
			cvRectangle(originalImage, cvPoint(r.x(), r.y()),
					cvPoint(r.x() + r.width(), r.y() + r.height()),
					CvScalar.YELLOW, 1, CV_AA, 0);
		}
 
		// convert the resulting image back to an array
		ByteArrayOutputStream bout = new ByteArrayOutputStream();
		BufferedImage imgb = originalImage.getBufferedImage();
		ImageIO.write(imgb, "png", bout);
		return bout.toByteArray();
	}
}

Kod bi trebalo barem da objasni korake. Za više informacija o tome kako to zaista funkcioniše trebalo bi da pogledate OpenCV i JavaCV sajtove. Izmenom kaskadne datoteke i igranjem sa minsize, grupnim i skala osobinama, takođe možete da koristite ovo za detekciju očiju, ušiju, nosa, zenica itd... Za detekciju oka, primer izgleda otprilike ovako:

Eye Detection result

Frontend, prikazivanje detektovanog lica

Poslednji korak je primanje poruke poslate od Jetty u našoj veb aplikaciji, i renderovanje na element slike. Mi ovo radimo tako što podesimo onmessage funkciju na našem websocket-u. U narednom kodu, primamo binarnu poruku. Konvertujemo ove podatke na objectURL (vidi ovo kao lokalni, privremeni URL), i podesimo ovu vrednost kao izvor slike. Kada je slika učitana, mi opozovemo objectURL jer više nije potreban. Sada samo treba da ažuriramo naš HTML na sledeće:

    ws.onmessage = function (msg) {
        var target = document.getElementById("target");
        url=window.webkitURL.createObjectURL(msg.data);
 
        target.onload = function() {
            window.webkitURL.revokeObjectURL(url);
        };
        target.src = url;
    }

We now only need to update our html to the following:

<div style="visibility: hidden;  width:0; height:0;">
     <canvas width="320" id="canvas" height="240"></canvas>
</div>
 
<div>
    <video id="live" width="320" height="240" autoplay style="display: inline;"></video>
    <img id="target" style="display: inline;"/>
</div>

I imamo prepoznavanje lica koje radi:

Face detected

Kao što ste videli da možemo mnogo da uradimo sa novim HTML5 API. Šteta što nije sve završeno i podrška preko pretraživača u nekim slučajevima nedostaje. Ali nam ipak nudi lepe i moćne funkcije. Testirao sam ovaj primer na najnovijoj verziji chrome-a i na Safari (za Safari uklonite webkit prefikse). Trebalo bi takođe da radi na "userMedia"omogućnom mobilnom safari pretraživaču. Uverite se, da ste na visokom protoku WIFI, jer kod nije optimizovan za propusni opseg. Ja ću revidirati ponovo ovaj članak za par nedelja, kada budem imao vremena da napravim Play2/Scala verziju backend-a.