Manipulación de imágenes con Javascript. Parte 2

Manipulación de imágenes con Javascript. Parte 2

 

Introducción

En el artículo anterior, aprendimos los conceptos básicos para manipular imágenes de forma prográmatica con Javascript. Repasamos cómo cargar un fichero en memoria a través del elemento ‘canvas‘, cómo leer sus datos a nivel de píxel, y cómo modificar esta información utilizando algoritmos. Elaboramos un ejemplo de aplicación sencilla con la que podíamos aplicar varios filtros además de guardar nuestros resultados.
En esta segunda parte vamos a investigar cómo mejorar el rendimiento cuando trabajamos con imágenes grandes o con filtros más complejos.

Mejorando el rendimiento

Hasta ahora, todo el proceso de cálculo que requerimos para cada filtro lo hemos realizado mediante fuerza bruta, píxel a píxel. Eso implica un coste alto en cuanto a cómputo por parte del intérprete que, como sabemos, independientemente de la máquina donde se ejecute, trabaja en monohilo.
Javascript es un entorno de subproceso único, es decir, que no se pueden ejecutar varias secuencias de comandos al mismo tiempo.
Esta limitación supone de entrada un aspecto delicado cuando trabajamos con imágenes: la relación entre su tamaño, la memoria necesaria y el tiempo de cómputo que supone una modificación de cada uno de sus píxeles.

Relación tamaño / memeoria de una imagen

Como vimos en el artículo anterior, la memoria necesaria para trabajar con una imagen es directamente proporcional a sus dimensiones. El cálculo se realiza mediante la siguiente fórmula:
LENGTH = width * height * 4
Hay que recordar que a cada píxel de la imagen (el resultado de multiplicar su anchura por altura), le corresponden cuatro elementos según sus valores RGBA. De ahí que la longitud final de su matriz sea la descrita la fórmula anterior.
rgba-pixel-model
Esquema RGBA para cada píxel tomado de aquí
Baste como ejemplo tomar las medidas de una imagen en Full HD para hacernos una idea de la longitud de su matriz de valores:
SIZE = 1920 * 1080 * 4 = 8.294.400
El array que contiene la información de una imagen en alta definición posee una longitud de más 8 millones de elementos. Si queremos además manipularla, tenemos que asumir el coste de aplicar una fórmula (un algoritmo) sobre cada uno de esos elementos mientras recorremos la matriz.
Por lo general, ese coste es inasumible ya que, mientras los cálculos se están procesando, el flujo de nuestro programa se interrumpe a la espera de concluir las operaciones necesarias. Es en este escenario cuando el navegador muestra un mensaje de error del siguiente tipo:
unresponsive-script
La aplicación tarda tanto en finalizar sus cálculos que el navegador nos advierte de un posible problema
La estrategia a seguir en estos casos es la del célebre ‘Divide et impera‘ (divide y vencerás) o, de forma más ajustada, ‘Divide y acabarás antes’…
En la cultura popular, divide y vencerás hace referencia a un refrán que implica resolver un problema difícil, dividiéndolo en partes más simples tantas veces como sea necesario, hasta que la resolución de las partes se torna obvia. La solución del problema principal se construye con las soluciones encontradas.
Este paradigma, cuando trabajamos en un entorno SIMD (single instruction multiple data), se aplica a través del concepto de la paralelización. Y éste, a su vez, se implementa en Javascript mediante los Web Workers.

Web Workers

Gracias a la API Web Workers, vamos a conseguir exactamente la idea anterior: paralelismo; dividir nuestro trabajo en partes más pequeñas para conseguir un mejor tiempo final de cálculo.
El paralelismo es una forma de computación en la cual varios cálculos pueden realizarse simultáneamente, basado en el principio de dividir los problemas grandes para obtener varios problemas pequeños, que son posteriormente solucionados en paralelo.
Implementar el poder de los Web Workers en nuestra aplicación implica extraer parte del código anterior a un fichero externo e independiente. Vamos con ello.

Los filtros

La carga importante de cálculo que tiene que soportar nuestra aplicación es la que realizan los filtros y son por tanto estos fragmentos los que tenemos que extraer.
Tomemos por ejemplo un par de ellos: el filtro para pasar una imagen a escala de grises y el de inversión de color. Sacamos ambos de nuestra aplicación y creamos con ellos un nuevo archivo al que llamaremos ‘filters.js‘:
// filters.js
var filterBW = ( pixels, numPixels ) => {
    for ( let i = 0; i < numPixels; i++ ) {
        let grey = (
            pixels[ i * 4 ] +       // r
            pixels[ i * 4 + 1 ] +   // g
            pixels[ i * 4 + 2 ]     // b
        ) / 3;
 
        pixels[ i * 4 ] = grey;
        pixels[ i * 4 + 1 ] = grey;
        pixels[ i * 4 + 2 ] = grey;
    }
};
 
var filterInvert = ( pixels, numPixels ) => {
    for ( let i = 0; i < numPixels; i++ ) {
        pixels[ i * 4 ] = 255 - pixels[ i * 4 ];
        pixels[ i * 4 + 1 ] = 255 - pixels[ i * 4 + 1 ];
        pixels[ i * 4 + 2 ] = 255 - pixels[ i * 4 + 2 ];
    }
};
NOTA: Para este artículo, sí utilizaremos la notación moderna ES5. No obstante, recordamos que usamos ‘var’ en lugar de ‘const‘ únicamente con fines prácticos para este artículo. El usuario, es libre de cambiar esta declaración de variables por su forma ‘const’ más correcta.
Con esto extraemos nuestros filtros y lo preparamos para la paralelización. Para evitar duplicidad, nuestras funciones ahora reciben por parámetro el array de píxeles (pixels) y su longitud (numPixels). Más adelante veremos cómo hacemos llegar esos datos.

Modificando la aplicación para que funcione con Web Workers

Debemos ahora cambiar el fichero original para que se beneficie de este nueva estructura. La idea aquí es crear N número de procesos para que cada uno se encargue de trabajar una porción de la imagen (el ‘divide’).
Una vez concluye todo este trabajo en paralelo, se utiliza el método Window.postMessage a modo de callback para devolver el resultado al script principal, quien reconstruye nuestra imagen a partir de los nuevos fragmentos computados (el ‘vencerás’).
El código, general quedaría como sigue. Aprovechamos también este paso para sanearlo y actualizarlo con respecto al artículo anterior:
var app = ( () => {
 
    let canvas = document.getElementById( 'canvas' ),
        context = canvas.getContext( '2d' ),
        numPixels = canvas.width * canvas.height * 4,
 
        // API
        public = {};
 
    // Web Workers
    let workersCount = 4,
        segmentLength = numPixels / workersCount, // This is the length of array sent to the worker
        blockSize = canvas.height / workersCount; // Height of the picture chunck for every worker
 
    // Function called when a job is finished
    let onWorkEnded = e => {
        let imageData = e.data.result,
            index = e.data.index;
 
        // Copying back canvas data to canvas
        // If the first webworker (index 0) returns data, apply it at pixel (0, 0) onwards
        // If the second webworker (index 1) returns data, apply it at pixel (0, canvas.height/4) onwards, and so on
        context.putImageData( imageData, 0, blockSize * index );
    };
 
    // --------------------------------------------------------------------
 
    // Public methods goes here...
    public.loadPicture = () => {
        let imageObj = new Image();
        imageObj.src = 'entropy.jpg';
 
        imageObj.onload = () => context.drawImage( imageObj, 0, 0 );
    };
 
 
    public.launchWorker = () => {
        // Launching every worker
        for ( let index = 0, worker, imageData; index < workersCount; index++ ) {
            worker = new Worker( 'imgProcessor.js' );
            worker.onmessage = onWorkEnded;
 
            // Getting the picture
            imageData = context.getImageData( 0, blockSize * index, canvas.width, blockSize );
 
            // Sending canvas data to the worker using a copy memory operation
            worker.postMessage( { data: imageData, index: index, length: segmentLength } );
        }
    };
 
    return public;
} ) ();
NOTA: Este código está inspirado en un viejo artículo de David Catuhe que podemos ver aquí.
El código está comentado para que resulte autoexplicativo, pero diseccionamos las partes clave:
// Web Workers
let workersCount = 4,
    segmentLength = numPixels / workersCount,
    blockSize = canvas.height / workersCount;
Con estas líneas preparatorias, establecemos el número de procesos en el que vamos a dividir nuestro trabajo. Más adelante, profundizaremos en esto, pero de entrada, fijamos 4 procesos. Con ese dato, podemos ya conocer la longitud del array que enviaremos a cada uno de los hilos y su correspondiente porción de la imagen.
let onWorkEnded = e => {
    /* ... */
};
Esta función recoge los datos ya computados por cada hilo externo. Su cometido es reconstruir la imagen con cada porción que le es devuelta.
public.launchWorker = ( ) => {
    for ( let index = 0, worker, imageData; index < workersCount; index++ ) {
        worker = new Worker( 'imgProcessor.js' );
        worker.onmessage = onWorkEnded;
 
        imageData = context.getImageData( 0, blockSize * index, canvas.width, blockSize );
 
        worker.postMessage( { data: imageData, index: index, length: segmentLength } );
    }
};
Este es el corazón de la nueva aplicación. El bucle se encarga de lanzar tantos procesos como hayamos establecidos previamente, llamando al Worker ‘imgProcessor.js‘ que definiremos a continuación.
A cada nuevo hilo, se le asigna una porción de la imagen y se añaden un par de argumentos útiles como son el índice (index) y su longitud (segmentLength). Estos valores se envían al worker utilizando postMessage.

Fichero Procesador

Para procesar las imágenes con los filtros anteriores, necesitamos un nuevo fichero. Éste, muy sencillo, lo llamaremos (como hemos referenciado arriba) ‘imgProcessor.js‘:
// imgProcessor.js
importScripts( 'filters.js' );
 
self.onmessage = e => {
    let imageData = e.data.data,
     pixels = imageData.data,
     numPixels = e.data.length,
     index = e.data.index;
 
    // Filters goes here...
    filterInvert( pixels, numPixels );
 
    self.postMessage( {
     result: imageData,
     index: index
    } );
};
Este pequeño script es el encargado de cargar los filtros y lanzar el que corresponda utilizando como argumentos los datos que ha recibido desde el general. Una vez finalizado el cómputo, devuelve un objeto utilizando de nuevo ‘postMessage‘. Por último, observamos que es desde este fichero desde donde se importa el archivo que contiene nuestros filtros (filters.js) a través del método importScripts.
Como no es el objeto de este artículo, hemos incluido el filtro que queremos ejecutar directamente a fuego en el fragmento anterior, aportándole los parámetros necesarios que establecimos como necesarios en ‘filters.js‘. En una aplicación real, la ejecución de uno u otro filtro se realizaría de forma programática, salvo en el caso de que siempre queramos correr el mismo (uno o varios) sobre una imagen dada.
Con todo ya en su sitio, para lanzar nuestro nuevo lote de procesos, utilizamos nuestra API actualizada desde la consola:
app.launchWorker();
El flujo de nuestra aplicación, de forma esquematizada, sería el siguiente:
  • Se crean 4 workers para procesar nuestra imagen en paralelo.
  • Los datos de la imagen se fraccionan en un número igual a los workers tenemos (4).
  • Cada worker recibe una porción de la imagen (debidamente parametrizada) para calcular sobre ésta el algoritmo correspondiente (nuestro filtro).
  • Una vez cada hilo concluye su cómputo, se envía un mensaje vía postMessage con los datos obtenidos al script general.
  • Con cada lote calculado, correspondiente a una porción de la imagen original, se va construyendo la nueva imagen modificada.
Esto supone que hemos repartido el trabajo que antes se realizaba en un solo hilo en cuatro paralelos, consiguiendo con esto una mejora significativa en el rendimiento además de un mejor aprovechamiento del hardware disponible.

Double buffering

Como no estamos utilizando ningún búfer de datos ad hoc, un ojo bien entrenado podría advertir cómo nuestra imagen no se reconstruye en bloque, sino a partir de trozos según éstos van llegando desde sus distintos procesos.
invert-partial
Instante en que el trozo 2 es recogido e inmediatamente volcado a la imagen. Los otros aún estarían en proceso.
Si quisieramos corregir esto y esperar a tener todas las porciones listas antes de redibujar nuestra imágen, tendríamos que recurrir a técnicas similares al double buffering, muy estudiada en Javascriptgracias a su importante papel para el desarrollo de vídeo juegos HTML5 basados en canvas.

Límite en hilos

Hemos visto que 4 hilos suponen una mejora de rendimiento, pero ¿y si ponemos 40? ¿mejora el global o existe un límite?
Pues en teoría no hay límite. Según la especificación del W3C (Working Draft 24 September 2015) no estamos limitados a un número concreto de hilos simultáneos. Sin embargo conviene no abusar e ir experimentado ya que la práctica demuestra que un número desproporcionado de hilos pueden suponer una caída radical del rendimiento no ser capaz el sistema de manejar un alto número de concurrencias.
Lo recomendable es utilizar workers cuando el cómputo por cada hilo se prevé mayor a los 100ms de cálculo. Tiempos menores comienzan a penalizar en lugar de ofrecer una mejora.
De nuevo, lo recomendable es experimentar. Una solución inteligente puede ser lanzar un número de procesos proporcional al tamaño de la imagen, habiendo definidos estos de antemano. Por ejemplo:
AnchoAltoWorkers
6403601
8006001
10247682
12807202
192010804 – 6
De este modo, podemos ir encontrando el mejor compromiso entre hilos y tiempos totales de post procesado.

Concepto de aplicación experimental

Los Web Workers permiten comunicarse con un servidor a través de XMLHttpRequest o la nueva API Fetch, lo cual nos permitiría en teoría un post procesado remoto de imágenes a través de una API pública dedicada.
NOTA: Para consultar todas las funciones y clases disponibles con los workers, podemos consultar esta tabla creada por Mozilla.
Desafortunadamente, no he encontrado ningún servicio que preste a día de hoy esa funcionalidad (noviembre, 2016), por lo que podría ser una herramienta interesante a desarrollar.
La idea con esto sería crear una API pública capaz de recibir una matriz de datos de imagen (nuestro array de píxeles) y que contase con un catálogo de filtros que podríamos aplicar sobre los mismos. La API retornaría el resultado (imagen o porción de imagen) una vez calculado dejando a nuestra aplicación la responsabilidad de reconstruir la imagen final.

Conclusión

En esta segunda parte sobre la manipulación de imágenes con Javascript, hemos abordado el problema del rendimiento con imágenes grandes o filtros complejos utilizando la paralelización.
Gracias a los Web Workers, podemos dividir un problema en partes más pequeñas para trabajarlas de forma independiente y paralela. Con esto, conseguimos una mejora general siempre y cuando se consiga un balance correcto entre sub procesos y cálculos.
SHARE

Oscar perez

Arquitecto especialista en gestion de proyectos si necesitas desarrollar algun proyecto en Bogota contactame en el 3006825874 o visita mi pagina en www.arquitectobogota.tk

  • Image
  • Image
  • Image
  • Image
  • Image
    Blogger Comment
    Facebook Comment

0 comentarios:

Publicar un comentario