Angular y JSFeat (OpenCV.js) - Computer Vision en JavaScript
Es muy posible que, realizada una breve investigación por la web (especialmente sitios como GitHub), los programadores se encuentren con intentos de migración del famoso paquete de librerías OpenCV, especializada en procesamiento de imagen en tiempo real, a JavaScript. Muchos de los trabajos realizados son dignos de destacar, como el caso de js-objectdetect con su excelente rendimiento, o tracking.js con sus resultados finales. Sin embargo, es posible que al día de la fecha ninguno se asemeje a las posibilidades ofrecidas por JSFeat, una librería para JavaScript que ofrece Features similares a las de OpenCV. Y ahi radican sus resultados: no intenta ser una migración de OpenCV a JavaScript, sino que utiliza el lenguaje de JavaScript para conseguir resultados acordes al entorno de trabajo (browser). Y cómo no, si está en JavaScript en Moldeo Interactive queremos que funcione en Angular.
Pero para la correcta integración de JSFeat con Angular es necesario comprender el funcionamiento interno de esta librería, especialmente porque los ejemplos disponibles están un poco entorpecidos por otras dependencias que no vienen al caso (jQuery, gui, profiler, etc). JSFeat aplica sus funciones matemáticas sobre un Canvas, esto quiere decir que nuestra captura de video de la WebCam debe estar en un Canvas. Y para dicha tarea, vamos a tener que conseguir la imagen de nuestra WebCam desde la tag Video con Angular y, empleando el propio Angular, enviarla a un Canvas. Existen diversas formas de realizar esta tarea, pero en principio necesitamos incluir en el HTML del componente una tag video oculta y una tag canvas, que identificaremos para poder acceder a ambas:
<video width="640" height="480" #hardwareVideo [hidden]="true"></video>
<canvas id="canvas" width="640" height="480"></canvas>
En este caso, ya se definieron los valores de ancho y alto, no obstante es posible (y recomendable) hacerlo desde la clase de TypeScript correspondiente, la cual de todas formas necesitamos modificar. En primer lugar, vamos a dejar preparado el entorno, importando el OnInit y el ViewChild de Angular:
import { Component, OnInit, ViewChild } from '@angular/core';
Acto seguido, y teniendo acceso a todo lo necesario, vamos a declarar nuestras variables a utilizar en nuestra clase:
@ViewChild('hardwareVideo') hardwareVideo: any;
constraints: any;
canvas: any;
context: any;
La variable hardwareVideo
recupera el elemento HTML con ese mismo nombre (que si revisamos
previamente, es el nombre que le pusimos a nuestra tag video). Por otra
parte, la variable constraints
se utiliza para darle directivas al stream de video; canvas
será donde llamaremos nuestro elemento canvas; y context
la encargada de almacenar el contexto de canvas. Nada nuevo bajo el
sol, comencemos a ensamblar. Dejaremos el constructor vacío ya que nada
estará inicializado desde ahí. Entonces, armaremos el ngOnInit
de la siguiente forma:
ngOnInit(){
this.constraints = {
audio: false,
video: {
width: {ideal:640},
height: {ideal:480}
}
};
this.videoStart();
}
¿Qué pasa acá en el ngOnInit
? Le ponemos contenido a nuestra variable constraints
(que en este caso no aporta casi nada, pero podemos definirle los FPS o
el aspecto del video si nos interesa) e inicializamos la función videoStart
que contiene lo siguiente:
videoStart(){
let video = this.hardwareVideo.nativeElement;
let n = <any>navigator;
n.getUserMedia = ( n.getUserMedia || n.webkitGetUserMedia || n.mozGetUserMedia || n.msGetUserMedia );
n.mediaDevices.getUserMedia(this.constraints).then(function(stream) {
if ("srcObject" in video) {
video.srcObject = stream;
} else {
video.src = window.URL.createObjectURL(stream);
}
video.onloadedmetadata = function(e) {
video.play();
};
});
this.canvas = document.getElementById('canvas');
this.context = this.canvas.getContext('2d');
this.loop();
}
Acá hay parte de la magia. Ya que esta función se llama en el ngOnInit
y solo se ejecutará una vez, directamente se utiliza para el conocido objecto mediaDevices
de navigator
, y de este la función getUserMedia
,
que nos permite acceder a los dispositivos multimedia conectados a la
computadora. Lo lógico sería hacer unas condicionales de compatibilidad
con el navegador un poco más exhaustivas, pero para una primera prueba
es suficiente. Finalmente, se le asigna el elemento canvas a la variable
canvas
, y luego el contexto (en este caso es 2D) a la variable context
. Pero vemos al final un elemento desconocido, estamos llamando una función llamada loop
, ¿dónde está y que contiene?
loop = () =>{
this.context.drawImage(this.hardwareVideo.nativeElement, 0, 0, this.canvas.width, this.canvas.height);
requestAnimationFrame(this.loop);
}
Recordemos algo rápidamente, la función videoStart
solo se ejecuta una vez, lo cual es útil para inicializar ciertas
funciones. Pero tenemos un problema, necesitamos pasar constantemente la
imagen de la tag video al contexto de canvas. Para esto, llegó a
nuestro rescate la función requestAnimationFrame()
. Sin entrar en mucho detalle, la estructura de esta función loop
permite ejecutar constantemente su contenido, algo que nos viene
perfecto. Si compilamos el código, deberíamos poder ver la imagen de
nuestra WebCam, solo que no se trata de una imagen de la tag video, sino
que del canvas. Ahora sí, hora de integrar JSFeat, para esto vamos a instalarlo mediante npm:
npm install jsfeat --save
Simplemente nos limitaremos a importarlo a la clase de TypeScript que necesitemos (en este caso, la misma donde aplicamos el video al canvas):
import * as jsfeat from 'jsfeat';
Solo con esto ya podremos empezar a aplicar las funciones de JSFeat, siempre llamando al objecto jsfeat antes que nada. ¿Cómo se aplica? Lo ideal es utilizar la propia función loop
, y del mismo modo que con JavaScript se debe utilizar un getImageData()
del contexto del canvas. La función loop
quedaría entonces de este modo:
loop = () =>{
this.context.drawImage(this.hardwareVideo.nativeElement, 0, 0, this.canvas.width, this.canvas.height);
var imageData = this.context.getImageData(0, 0, 640, 480);
jsfeat.imgproc.grayscale(imageData.data, 640, 480, this.img_u8);
var data_u32 = new Uint32Array(imageData.data.buffer);
var alpha = (0xff << 24);
var i = this.img_u8.cols*this.img_u8.rows, pix = 0;
while(--i >= 0) {
pix = this.img_u8.data[i];
data_u32[i] = alpha | (pix << 16) | (pix << 8) | pix;
}
this.context.putImageData(imageData, 0, 0);
requestAnimationFrame(this.loop);
}
Aquí simplemente se aplica una función para pasar la imagen a escala de grises; lo que le sigue previo al requestAnimationFrame()
es la manera de aplicar la transformación denuevo al contexto del
canvas (sino por más que realice el proceso no podremos verlo). Nota: la variable this.img_u8
está creada también de formaglobal, e inicializada en el ngOnInit
de este modo this.img_u8 = new jsfeat.matrix_t(640, 480, jsfeat.U8C1_t)
, forma parte de una clase propia de jsfeat
(matrices) que se necesita para aplicar gran parte de sus funciones.
De esta manera tendremos funcionando JSFeat en un entorno Angular, permitiendo optimizar el rendimiento (más aún si cabe) en lo que producción de código se refiere. Actualmente estamos utilizando esta implementación como Resource en nuestra producción de MoldeoJS. ¿Siguiente paso? Quizás potenciar los resultados mediante el uso de GPU. Los mantendremos informados.
Repositorio del código ngJSFeat, con filtro grayscale y blur: https://github.com/ibuioli/ngJSFeatAcerca de:
Ignacio Buioli
Licenciado en Artes Multimediales. Ha desarrollado numerosos proyectos de Multimedia así como también escrito artículos y traducido textos del mencionado tema. En Moldeo Interactive es Socio y Programador; encargándose, además, de gran parte de las redes y los cursos online.