Angular y JSFeat (OpenCV.js) - Computer Vision en JavaScript

Ignacio Buioli
- 17/11/2017

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/ngJSFeat