Programación de efectos en imágenes y vídeos en Processing

  • Francesc Martí Pérez

PID_00258145
Ninguna parte de esta publicación, incluido el diseño general y la cubierta, puede ser copiada, reproducida, almacenada o transmitida de ninguna forma, ni por ningún medio, sea este eléctrico, mecánico, óptico, grabación, fotocopia, o cualquier otro, sin la previa autorización escrita del titular de los derechos.

Introducción

En este módulo volveremos a trabajar con algunas de las transformaciones estudiadas en los módulos anteriores, pero usando el lenguaje de programación y entorno integrado de desarrollo Processing. El análisis de estas operaciones desde este nuevo punto de vista proporcionará al alumno una excelente oportunidad para reforzar los conocimientos hasta ahora adquiridos y profundizar en ellos.
En este material, presupondremos que el alumno ya está familiarizado con el lenguaje de programación Processing y nos centraremos directamente en el estudio de los conceptos de la asignatura en este entorno. Para una exhaustiva introducción a Processing, el alumno tiene a su disposición el libro Processing, editado por la UOC, en el área de materiales de la asignatura.
Empezaremos este módulo introduciendo cómo cargar, modificar, visualizar y guardar imágenes en Processing. En particular, veremos en detalle cómo acceder a los píxeles de una imagen e interactuar con ellos. Para acabar este apartado introductorio, también examinaremos brevemente algunos de los filtros que ya vienen implementados en Processing y que nos permitirán realizar algunas de las transformaciones que hay que estudiar en unas pocas líneas de código.
Una vez introducidos estos conceptos básicos de Processing, ya estaremos en disposición de abordar la programación de las transformaciones vistas en los cuatro primeros módulos de la asignatura en este nuevo entorno. Es decir, las transformaciones estudiadas en los módulos «Histogramas y transformaciones puntuales», «Transformaciones espaciales lineales», «Transformaciones espaciales no lineales» y «Transformaciones geométricas».
A lo largo de los cuatro apartados siguientes, iremos describiendo los algoritmos que nos permitirán implementar muchas de las transformaciones hasta ahora vistas. Estudiaremos en detalle estos programas, compararemos los resultados obtenidos con los resultados obtenidos previamente con Photoshop y llegaremos a la conclusión de que el resultado final no depende del software que se usa, sino del algoritmo utilizado. Este hecho sorprende a algunos alumnos, que presuponen que el resultado de un software de pago tiene que ser a la fuerza mejor que el resultado de un software gratuito. Photoshop, al aplicar todas las transformaciones estudiadas, no usa fórmulas secretas o procedimientos ocultos. Usa las operaciones matemáticas que hemos analizado durante el curso. Implementando en Processing el mismo algoritmo que en Photoshop, con las mismas sumas, multiplicaciones o técnicas de comparación del nivel de gris de los píxeles, obtenemos el mismo resultado.
Para finalizar, en el último apartado del módulo veremos cómo podemos aplicar todas las transformaciones estudiadas sobre archivos de vídeo. Básicamente, veremos dos formas de hacerlo: trabajando los vídeos como secuencia de imágenes y con las librerías de vídeo Video y GLVideo de Processing.
Todas las imágenes que se utilizan o mencionan en este módulo se adjuntan con los materiales del curso. Huelga decir que se aconseja encarecidamente a los alumnos reproducir y estudiar en su equipo todos los ejemplos aquí presentados. Introducir cambios en el código o estudiar los resultados sobre otras imágenes suelen ser buenas tácticas para llegar a una perfecta comprensión de los conceptos en ellos desarrollados.

Objetivos

Los principales objetivos de este módulo son:
  1. Introducir la programación de las transformaciones puntuales, espaciales lineales, espaciales no lineales y geométricas.

  2. Explorar las posibilidades del lenguaje Processing en cuanto a estas programaciones.

  3. Relacionar, mediante experimentos dirigidos, los conceptos introducidos con la transformación de imágenes.

Estos objetivos están relacionados con las siguientes competencias de la asignatura:
A. Capacidad de modificar una imagen digital sobre la base de unos requisitos previos.
B. Capacidad de cambiar la resolución, relación de aspecto y forma de una imagen.
C. Capacidad de discriminar las opciones factibles de las que no lo son en un estudio de especificaciones de un proyecto, sistema o tarea.
G. Capacidad de insertar contenido visual en una aplicación en Processing.
Y con las siguientes competencias generales del grado:
11. Capturar, almacenar y modificar información de audio, imagen y vídeo digitales aplicando principios y métodos de realización y composición del lenguaje audiovisual.
12. Capacidad para integrar y gestionar contenidos digitales en aplicaciones multimodales de acuerdo con criterios estéticos, técnicos y funcionales.
13. Capacidad para utilizar de forma apropiada los lenguajes de programación y las herramientas de desarrollo para el análisis, diseño e implementación de aplicaciones.
23. Capacidad de analizar un problema en el nivel de abstracción adecuado a cada situación y aplicar las habilidades y conocimientos adquiridos para abordarlo y resolverlo.

1.Imágenes en Processing

1.1.Carga y visualización de imágenes en Processing

Empezaremos este módulo con un sencillo ejemplo, que nos servirá de excusa para presentar la clase PImage de Processing. Esta clase pertenece al núcleo de Processing –por lo tanto, no es necesario instalar ninguna biblioteca adicional para utilizarla– y es la que nos permite trabajar con imágenes.
La clase PImage incluye varios campos y métodos para cargar, crear, tratar o guardar imágenes. En estas páginas estudiaremos solo algunos de ellos, los relacionados con las transformaciones de imágenes que nos conciernen.
Así pues, empezamos con un primer ejemplo que nos mostrará cómo cargar y visualizar una imagen en la ventana de la aplicación de Processing. En este y próximos ejemplos, el alumno tiene que advertir que todos los programas giran en torno a los objetos PImage, y prestar atención a los campos y métodos de esta clase necesarios para implementar las transformaciones examinadas en módulos precedentes.
/**
* Ejemplo 1: Carga y visualización de una imagen
* Francesc Martí, martifrancesc@uoc.edu, 15-04-2016
*/

// Declaramos un objeto de tipo PImage
PImage img; 

void setup() {
// Asignamos a la ventana de trabajo las mismas medidas que la imagen
size(696,696);

// Cargamos la imagen
img = loadImage("geranidolor.jpg");

// La función draw() se ejecutará solo una vez
noLoop();
}

void draw() {
   //La función image() permite visualizar la imagen en la ventana de la aplicación
image(img,0,0);
}
Como hemos comentado, este código permite cargar en Processing una imagen almacenada en el disco duro del ordenador y visualizarla por pantalla y, al ser el primer ejemplo, estudiaremos en detalle su código.
1) Declaración del objeto. Con el siguiente código se declara el objeto img de la clase PImage.
PImage img;
Como ya hemos comentado, la declaración de un objeto de tipo PImage es imprescindible para trabajar con imágenes en Processing, y la declaración es igual a como declararíamos una variable de tipo float o int, por ejemplo.
2) Inicialización del objeto. Como ya sabemos, siempre que se declara un objeto es necesario inicializarlo, construirlo. En este caso, este trabajo se realiza llamando a la función loadImage(). Este método permite cargar una imagen en un objeto PImage y tiene como parámetro el nombre de la imagen a cargar (más adelante ya veremos cómo se puede también crear una imagen nueva imagen «vacía»).
img = loadImage("geranidolor.jpg");
Es necesario comentar que, por defecto, si no indicamos la ruta de la imagen, Processing buscará esta imagen en una carpeta de nombre «data», dentro de la carpeta del sketch. Y, en el supuesto de que la ruta o el nombre de la imagen sean incorrectos, nos aparecerá un mensaje de error advirtiendo el problema.
Processing acepta los formatos de imagen GIF, JPG, TGA y PNG. También es capaz de leer las imágenes en formato TIFF, pero –y esto es importante– solo si se han generado previamente en Processing.
Para acabar el análisis de este primer ejemplo, solo nos falta hablar de la función image(). Esta función nos permite visualizar la imagen en la ventana de la aplicación y, en este ejemplo, con las coordenadas (0,0) le indicamos a Processing que sitúe la esquina superior izquierda de la imagen en las coordenadas (0,0) de la ventana de la aplicación.
image(img,0,0);
Otros parámetros de la función image()
La función image() también acepta dos parámetros más, que permiten cambiar la resolución de la impresión de la imagen en la ventana de la aplicación. Por ejemplo, image(img,0,0,200,200) mostraría la imagen con una resolución de 200 × 200 píxeles. Hagamos notar, no obstante, que las dimensiones de toda la ventana de la aplicación continuarían siendo 696 × 296 píxeles.
Así pues, si ejecutamos el sketch anterior el resultado es el que se muestra en la figura 1.
Figura 1. Ventana de la aplicación del ejemplo 1
Figura 1. Ventana de la aplicación del ejemplo 1

1.2.Imágenes y píxeles

Como ya vimos en el módulo «Histogramas y transformaciones puntuales», podemos interpretar una imagen digital como una cuadrícula, donde la intersección de cada fila y cada columna es un píxel de la imagen. Esto funciona exactamente igual en Processing. Cada vez que definimos un objeto de tipo PImage, automáticamente creamos una cuadrícula de píxeles.
Técnicamente hablando, los objetos PImage guardan los píxeles de esta cuadrícula en un array de píxeles, denominado pixels[], con la relación que mostramos en la figura 2.
Figura 2. Relación entre la cuadrícula de píxeles y el array pixels[].
Figura 2. Relación entre la cuadrícula de píxeles y el array pixels[].
Por ejemplo, el píxel de la cuadrícula situado en la columna 0 y la fila 2 se guarda en la posición 10 del array de píxeles, y el píxel situado en la columna 1 y fila 3, en la 16.
En general, en una imagen de dimensiones m × n, el píxel situado en la columna i y la fila j, se guarda en el array de píxeles en la posición
i + j · m
Volviendo a la figura 2, como esta cuadrícula corresponde a una imagen de dimensiones 5 × 5, por ejemplo, el píxel situado en la columna 4 y fila 3 se guarda en la posición 4 + 3 · 5 = 19 del array de píxeles.
Pongamos ahora en práctica este concepto desarrollando una aplicación que nos permita acceder a los píxeles de una imagen y consultar su valor RGB. Los píxeles los seleccionaremos haciendo clic con el ratón sobre la imagen.
/**
* Ejemplo 2: Imágenes y píxeles (I)
* Francesc Martí, martifrancesc@uoc.edu, 15-04-2016
*
*/

// Declaramos un objeto de tipo PImage
PImage img;

void setup() {
// Asignamos a la ventana de trabajo las mismas medidas que la imagen
size(696, 696);

// Cargamos la imagen
img = loadImage("geranidolor.jpg");

// Siempre hemos de llamar a esta función antes de acceder al array de píxeles
img.loadPixels();
}

void draw() {
//La función image() permite visualizar la imagen
image(img, 0, 0);
}

void mousePressed() {
// La variable loc sirve para "localizar" el píxel seleccionado dentro
// del array de píxeles
int loc = mouseX + mouseY * img.width;

// Extraemos el color del píxel 
color c = img.pixels[loc];

// Estas funciones permiten consultar las componentes R, G y B de un color
float r = red(c);
float g = green(c);
float b = blue(c);

// Finalmente, imprimimos el resultado en la ventana de la Console
println("El valor RGB del píxel (" + mouseX + ", " + mouseY + ") es (" + r + ", " + g + ", " + b + ")");
}
Analicemos ahora los nuevos conceptos introducidos en este código, empezando por el llamamiento a la función
img.loadPixels();
Como ya hemos comentado, al crear un objeto de tipo PImage, automáticamente creamos una cuadrícula de píxeles. Para poder trabajar con estos píxeles, siempre tenemos que llamar antes al método loadPixels(). Es una forma de decirle a Processing: «prepárame los píxeles de la imagen, que necesito trabajar con ellos».
Como se puede ver, la mayor parte del código de este programa se encuentra dentro de la función mousePressed(), puesto que queremos consultar los niveles RGB de un píxel cada vez que hacemos clic con el mouse sobre la imagen.
Loprimero que hacemos es localizar el array de puntos sobre cuyo punto hemos clicado (mouseX, mouseY) con la fórmula que hemos visto anteriormente.
int loc = mouseX + mouseY * img.width;
Como es fácilmente deducible, el campo img.width nos proporciona la anchura de la imagen y, si quisiéramos consultar la altura, tendríamos que escribir img.height. Sí que es cierto que en este caso ya sabíamos cuál es la anchura y la altura de la imagen, pero siempre es aconsejable consultar las dimensiones de una imagen de este modo: evitamos errores y podemos usar otras imágenes con otras medidas, sin tener que estar editando esta parte del código continuamente.
Una vez que sabemos «dónde está» el punto que nos interesa, consultamos su color, y extraemos sus niveles R, G y B.
// Extraemos el color del píxel 
color c = img.pixels[loc];

// Estas funciones permiten consultar las componentes R, G y B de un color
float r = red(c);
float g = green(c);
float b = blue(c);
Por razones pedagógicas, en este ejemplo usamos las funciones red(), green() y blue() puesto que es muy sencillo entender qué hacen. De todos modos, el siguiente código sería equivalente y más rápido de ejecución:
// Estas funciones permiten consultar las componentes R, G y B de un color
float r = img.pixels[loc] >> 16 & 0xFF;
float g = img.pixels[loc] >> 8 & 0xFF;
float b = img.pixels[loc] & 0xFF;
Sin entrar en detalles, Processing guarda cada componente R, G y B en 1 byte (8 bits) y en una posición determinada. Con los operadores >> (right shift) y & (and) es posible ir directamente al byte que guarda el valor de cada componente y consultar su valor, operación más rápida que usar las funciones red(), green() y blue().
Finalmente, una vez extraídos los valores que buscábamos, solo los tenemos que imprimir en la ventana de la consola con la función println().
Para finalizar este subapartado, veremos cómo –además de poder leer la información de los píxeles de una imagen– también los podemos modificar y crear una nueva imagen con ellos. Aprovecharemos este ejemplo para ver también cómo podemos guardar la nueva imagen creada en nuestro disco duro.
Así pues, en el siguiente ejemplo, filtraremos la imagen «geranidolor.jpg», respetando en cada píxel el valor de su componente rojo (R), pero asignando el valor 0 a los componentes verde (G) y azul (B).
/**
* Ejemplo 3: Imágenes y píxeles (II)
* Francesc Martí, martifrancesc@uoc.edu, 15-04-2016
*
*/

// Declaramos dos objetos de tipo PImage, uno para la imagen original
// y otro para la imagen filtrada
PImage imgOriginal;
PImage imgFilter;

void setup() {
// Cargamos la imagen
imgOriginal = loadImage("geranidolor.jpg");

// Creamos una nueva imagen con las mismas dimensiones que la imagen original
imgFilter = createImage(imgOriginal.width, imgOriginal.height, RGB);

// Asignamos a la ventana de trabajo las mismas medidas que la imagen
surface.setSize(imgOriginal.width, imgOriginal.height);

// La función draw() se ejecutará solo una vez
noLoop();
}

void draw() {
// Como siempre, hemos de llamar a estas funciones antes de acceder al array
// de píxeles de las imágenes
imgOriginal.loadPixels();
imgFilter.loadPixels();

int loc= 0;

// Recorremos todos los píxeles de la imagen
while (loc < imgOriginal.pixels.length) {
// Estas funciones permiten consultar las componentes R, G y B de un color
float r = imgOriginal.pixels[loc] >> 16 & 0xFF;
float g = imgOriginal.pixels[loc] >> 8 & 0xFF;
float b = imgOriginal.pixels[loc] & 0xFF;

// En la imagen filtrada solo importamos el valor del nivel R (rojo)
// y los otros dos los dejamos con nivel 0
imgFilter.pixels[loc] = color(r, 0, 0);

loc++;
}

// Si hacemos modificaciones sobre los píxeles, siempre hemos de actualizar el array
// de píxeles con la función updatePixels()
imgFilter.updatePixels();

// La función image() permite visualizar la imagen filtrada
image(imgFilter, 0, 0);

// Guardamos el resultado en la carpeta "data" del proyecto
imgFilter.save(dataPath("geranidolor2.jpg"));
}
Como podemos ver, en este ejemplo creamos dos objetos de tipo PImage, uno para cargar la imagen original y otro para guardar las modificaciones y el resultado final en el disco duro.
PImage imgOriginal;
PImage imgFilter;
Ya hemos estudiado cómo inicializar un objeto llamando a la función loadImage() y pasándole el nombre de la imagen que queremos cargar. Ahora, para crear una nueva imagen «vacía», tenemos que llamar a la función createImage() y pasarle las medidas que queremos que tenga y el formato. De hecho, técnicamente, la imagen no está vacía. Por defecto, Processing crea una nueva imagen con todos sus píxeles con color negro (0, 0, 0).
En nuestro caso, queremos crear una imagen con las mismas medidas que la imagen original y en formato RGB. Por lo tanto, el código tiene que ser:
imgFilter = createImage(imgOriginal.width, imgOriginal.height, RGB);
Seguidamente, los píxeles de esta nueva imagen los vamos llenando con el valor de rojo de la imagen original y con 0 los otros dos componentes. Esto lo hacemos con
imgFilter.pixels[loc] = color(r, 0, 0);
Un punto muy importante del código es
imgFilter.updatePixels();
Como ya hemos visto, con la función loadPixels() preparamos los píxeles de una imagen para leerlos. Sin embargo, si los modificamos, al acabar, tenemos que llamar a la función updatePixels(). En este programa hemos modificado solo los valores de los píxeles de imgFilter, que por defecto todos valían (0, 0, 0), así que ahora tenemos que actualizar sus valores con el llamamiento imgFilter.updatePixels().
Finalmente, si, además de mostrar el resultado en la ventana de la aplicación, queremos guardar la imagen filtrada en el disco duro, tendremos que usar el método save(), asignando un nombre y una ruta de almacenamiento a la nueva imagen, como se puede ver en este código:
imgFilter.save(dataPath("geranidolor2.jpg"));
Aquí, la extensión indicará el formato de la imagen de salida. Si quisiéramos guardar el resultado en formato TIFF, tendríamos que añadir «.tif» al nombre de la imagen. Es decir,
imgFilter.save(dataPath("geranidolor2"));
Análogamente, tendríamos que añadir las terminaciones «.tga», «.jpg» o «.png» para guardar la imagen en formato TARGA, JPEG o PNG, respectivamente. Si no especificamos ningún formato, Processing guarda la imagen en formato TIFF.
También conviene comentar que siempre es recomendable indicar una ruta absoluta donde guardar la imagen en la función save(). En este ejemplo esto lo hacemos con la función dataPath(""), que es una forma de indicar la carpeta «data» del proyecto. Si no lo hacemos así, es posible que en algunos casos recibamos el mensaje de error «Pimage.save() requires an absolue path. Use createImage(), or pass savePath() to save()».
Sobre la impresión de la imagen en la ventana de la aplicación, hacemos notar que, en este ejemplo, para definir las medidas de la ventana, al inicio hemos usado el código
surface.setSize(imgOriginal.width, imgOriginal.height);
Este código nos permite definir las medidas de la ventana de la aplicación sin saber previamente las medidas de la imagen y –además– no lo tenemos que cambiar aunque cargáramos imágenes de diferentes medidas. Advertimos que la función que hemos usado previamente, size(), no admite variables (a partir de la versión 3 de Processing), por lo que el código
size(imgOriginal.width, imgOriginal.height);
no sería correcto, y recibiríamos el correspondiente mensaje de error al intentar ejecutarlo.
Acabamos, pues, mostrando el resultado de este programa. En la siguiente figura, podemos ver la imagen que genera el código anterior: una imagen monocromática compuesta por varias intensidades de color rojo.
Figura 3. Ventana de la aplicación del ejemplo 3
Figura 3. Ventana de la aplicación del ejemplo 3

1.3.El método filter() de PImage

Como hemos comentado antes, la clase PImage incluye varios campos y métodos, de los cuales solo estudiaremos algunos de ellos. En el grupo de los que analizaremos se encuentra la función filter(), una función que permite filtrar una imagen con diferentes métodos.
Su sintaxis es muy sencilla. Si img es un objeto de tipo PImage, podemos filtrar esta imagen con el código
img.filter(filterName, parametre);
Los métodos del filtro disponibles incluyen THRESHOLD, GRAY, OPAQUE, INVERT, POSTERIZE, BLUR, ERODE y DILATE, y algunos de ellos necesitan el paso de un parámetro. Por ejemplo, el filtro GRAY no necesita parámetros
img.filter(GRAY);
en cambio, el filtro THRESHOLD, sí.
img.filter(THRESHOLD, 0.3);
Muy resumidamente, los diferentes métodos disponibles en la función filter() son los siguientes:
  • THRESHOLD: es la transformación de binarización, donde el valor del umbral va de 0.0 (0) a 1.0 (255). Este método necesita parámetro.

  • GRAY: convierte una imagen en color a escala de grises. No necesita parámetro.

  • OPAQUE: hace completamente opaca una imagen. No necesita parámetro.

  • INVERT: es la transformación en negativo. No necesita parámetro.

  • POSTERIZE: limita el número de colores de cada canal. El valor del parámetro establece este límite, y puede variar entre 2 y 255.

  • BLUR: aplica un filtro de tipo Guassianblur, con el radio especificado en el segundo parámetro. Si no se incluye el segundo parámetro, el valor del radio del filtro es 1. Cuanto mayor sea el radio, más acusado será el filtrado.

  • ERODE: aplica la transformación espacial no lineal erosión. No necesita parámetro.

  • DILATE: aplica la transformación espacial no lineal dilatación. No necesita parámetro.

Para entender mejor el funcionamiento de estos filtros, vemos un sencillo ejemplo, con el filtro GRAY.
/**
* Ejemplo 4: Filtro GRAY
* Francesc Martí, martifrancesc@uoc.edu, 15-04-2016
*
*/

// Declaramos un objeto de tipo PImage
PImage img; 

void setup() {
// Cargamos la imagen
img = loadImage("geranidolor.jpg");

// Asignamos a la ventana de trabajo las mismas medidas que la imagen
surface.setSize(img.width, img.height);

// La función draw() se ejecutará solo una vez
noLoop();
}

void draw() {
// Aplicamos el filtro GRAY a la imagen
img.filter(GRAY);

//La función image() permite visualizar la imagen filtrada
image(img, 0, 0);

// Guardamos el resultado en la carpeta "data" del proyecto
img.save(dataPath("geranidolor2.jpg"));
}
Ejecutando este código –como esperábamos– obtenemos una imagen en escala de grises.
Figura 4. Ventana de la aplicación del ejemplo 4
Figura 4. Ventana de la aplicación del ejemplo 4
En el último programa de este subapartado, vemos un ejemplo muy similar, pero ahora con un filtro que sí necesita parámetro: un filtro de tipo POSTERIZE.
/**
* Ejemplo 5: Filtro POSTERIZE
* Francesc Martí, martifrancesc@uoc.edu, 15-04-2016
*
*/

// Declaramos un objeto de tipo PImage
PImage img; 

void setup() {
// Cargamos la imagen
img = loadImage("geranidolor.jpg");

// Asignamos a la ventana de trabajo las mismas medidas que la imagen
surface.setSize(img.width, img.height);

// La función draw() se ejecutará solo una vez
noLoop();
}

void draw() {
// Aplicamos el filtro POSTERIZE a la imagen
img.filter(POSTERIZE, 6);

//La función image() permite visualizar la imagen filtrada
image(img, 0, 0);

// Guardamos el resultado en la carpeta "data" del proyecto
img.save(dataPath("geranidolor2.jpg"));
}
En la siguiente figura, podemos ver cuál sería el resultado de ejecutar este programa, donde hemos configurado un filtro de tipo POSTERIZE, con un número máximo de colores por canal de 6.
Figura 5. Ventana de la aplicación del ejemplo 5
Figura 5. Ventana de la aplicación del ejemplo 5
Así pues, en este subapartado hemos examinado el método filter() de la clase PImage, y hemos visto cómo es de fácil aplicar uno de estos filtros a una imagen. Volveremos a usar estos filtros en el apartado 4, en particular los filtros ERODE y DILATE, pero antes, en los próximos dos apartados, de forma análoga a como hemos hecho en el ejemplo 3, veremos cómo aplicar transformaciones puntuales y transformaciones espaciales lineales a una imagen, accediendo a sus píxeles y aplicando unos algoritmos.
Si bien, con este método, la implementación de estas transformaciones será algo más complicada, su estudio proporcionará al alumno un sólido conocimiento del funcionamiento de las transformaciones de imágenes, de «qué sucede» cada vez que aplicamos un filtro en Photoshop, o usamos el método filter() en Processing. Además, la comprensión por parte del alumno de estos mecanismos, automáticamente, irá unida a la adquisición de poderosas herramientas que le permitirán desarrollar sus propias transformaciones.

2.Transformaciones puntuales

Las transformaciones puntuales se caracterizan por tratar la imagen como un conjunto de píxeles independientes. Con la ayuda de la función que define cada transformación, cada nuevo píxel de la imagen transformada se obtiene a partir del píxel con las mismas coordenadas de la imagen original.
En este apartado, básicamente, volveremos a ver cuáles son estas funciones, cómo programarlas y cómo aplicarlas sobre una imagen dada para generar la imagen transformada.
Empezaremos programando la transformación puntual más sencilla, la identidad, y la tomaremos como modelo para generar el resto de las transformaciones, pues, como veremos, el código será muy similar.
De forma análoga a como hicimos con el módulo «Histogramas y transformaciones puntuales», en este apartado, los programas que desarrollaremos están pensados para ser aplicados sobre imágenes en escala de grises. En este punto, es necesario comentar que Processing, cuando carga una imagen, por ejemplo, JPEG o PNG en escala de grises, por defecto, la trata como si fuera una imagen RGB, asignando el mismo valor a cada uno de los canales. Aunque técnicamente nos encontramos con una imagen RGB, si los valores de los tres canales son siempre iguales, R = G = B, podemos continuar pensando que se trata de una imagen en escala de grises, puesto que son imágenes equivalentes.

2.1.Identidad

Para cada una de las transformaciones de este apartado, recordaremos brevemente su definición, pero no entraremos a estudiarlas en detalle.
La transformación puntual identidad consiste en asignar a cada píxel de la imagen transformada el mismo valor de nivel de gris que el píxel con las mismas coordenadas de la imagen original. Por lo tanto, para un píxel dado X de nivel de gris l de la imagen original, el nivel de gris del píxel Y con las mismas coordenadas de la imagen transformada vendrá determinado por la función:
T(l) = l
Pasemos, pues, a ver directamente cómo se puede programar esta transformación en Processing, y estudiaremos su código en detalle más adelante.
/**
* Ejemplo 6: Transformación Puntual Identidad,
* para imágenes en escala de grises
* Francesc Martí, martifrancesc@uoc.edu, 15-04-2016
*
*/

// Declaramos dos objetos de tipo PImage, uno para la imagen original
// y otro para la imagen filtrada
PImage imgOriginal;
PImage imgFilter;

void setup() {
// Cargamos la imagen
imgOriginal = loadImage("4.1.02.png");

// Creamos una nueva imagen con las mismas dimensiones que
// la imagen original
imgFilter = createImage(imgOriginal.width, imgOriginal.height, RGB);

// Las medidas de la ventana de trabajo permitirán visualizar
// las dos imágenes a la vez
surface.setSize(2*imgOriginal.width, imgOriginal.height);

// La función draw() se ejecutará solo una vez
noLoop();
}

void draw() {
// Como siempre, hemos de llamar a estas funciones antes de acceder al
// array de píxeles de las imágenes
imgOriginal.loadPixels();
imgFilter.loadPixels();

int loc= 0;

// Recorremos todos los píxeles de la imagen
while (loc < imgOriginal.pixels.length) {
// Al ser una imagen en escala de grises, R = G = B
// Así que tenemos suficiente consultando uno de los canales RGB
float b = imgOriginal.pixels[loc] & 0xFF;

// Algoritmo - Transformación Puntual (Identidad)
imgFilter.pixels[loc] = color(b);

loc++;
}

// Si hacemos modificaciones sobre los píxeles, siempre hemos de actualizar
// el array con la función updatePixels()
imgFilter.updatePixels();

// La función image() permite visualizar las dos imágenes
image(imgOriginal, 0, 0);
image(imgFilter, imgOriginal.width, 0);

// Guardamos la imagen transformada en la carpeta "data" del proyecto
imgFilter.save(dataPath("imgFilter.png"));
}
Empezamos comentando que en los siguientes ejemplos siempre mostraremos a la vez, en la ventana de la aplicación, la imagen original y la imagen modificada. Por lo tanto, definimos las medidas de la ventana de forma que puedan mostrar las dos imágenes simultáneamente.
surface.setSize(2*imgOriginal.width, imgOriginal.height);
Como ya hemos comentado antes, al ser una imagen en escala de grises, tenemos que el nivel de gris de un píxel viene determinado por cualquiera de los valores R, G o B, puesto que R = G = B. En nuestro caso, utilizamos el canal B (azul) para consultar el valor del nivel de gris de un píxel.
float b = imgOriginal.pixels[loc] & 0xFF;
Finalmente, el algoritmo de la transformación puntual identidad viene definido por
imgFilter.pixels[loc] = color(b);
donde, como se puede ver, a cada píxel de la nueva imagen le asignamos el mismo valor de gris que el píxel correspondiente de la imagen original.
En la siguiente imagen, podemos ver cuál sería el resultado de esta transformación. Como esperábamos, las dos imágenes, la original y la transformada, son iguales.
Figura 6. Ventana de la aplicación del ejemplo 6
Figura 6. Ventana de la aplicación del ejemplo 6

2.2.Negativo

La transformación puntual negativo, o inversa, consiste en asignar a cada píxel de la imagen transformada el valor de nivel de gris invertido que el píxel con las mismas coordenadas de la imagen original. Por lo tanto, si l es este valor de gris, la función de la transformación puntual negativo es:
T(l) = 255 - l
Como ya hemos dicho, el código de todas las transformaciones puntuales es muy parecido y, básicamente, solo cambia el algoritmo que se aplica. Por lo tanto, el código de la transformación puntual negativo es igual que el código de la transformación puntual identidad, excepto por la línea donde se define su algoritmo. Sustituyendo la línea del algoritmo del ejemplo identidad por
// Algoritmo - Transformación Puntual (Negativo)
imgFilter.pixels[loc] = color(255-b);
ya tendríamos el código de la transformación negativo.
Como podemos ver, aquí la variable b guarda el nivel de gris de cada uno de los píxeles de la imagen original. Y el píxel correspondiente de la imagen transformada tendrá un nivel de gris 255-b.
En la siguiente imagen, podemos ver cuál sería el resultado de aplicar esta transformación. Como podemos ver, es el mismo resultado que obteníamos al aplicar esta transformación con Photoshop.
Figura 7. Ventana de la aplicación del ejemplo 7
Figura 7. Ventana de la aplicación del ejemplo 7

2.3.Binarización

La transformación puntual binarización genera una imagen con dos niveles de gris: blanco (255) y negro (0). Esta transformación queda definida por un umbral o threshold y la función:
T ( l ) = { 255 l umbral 0 l < umbral (1)
donde, como antes, l es el nivel de gris de un píxel X.
El código en Processing de esta transformación también será prácticamente igual a los anteriores ejemplos. Aquí, la única diferencia es que la función de la transformación necesita la variable umbral, que definiremos al inicio del programa.
// Declaramos dos objetos de tipo PImage, uno para la imagen original
// y otro para la imagen filtrada
PImage imgOriginal;
PImage imgFilter;

// Umbral
int llindarValue = 20;
Como hemos estudiado en el módulo «Histogramas y transformaciones puntuales», el valor del umbral puede ser cualquier número entre 0 y 255 (suponiendo que, como hacemos habitualmente, trabajemos con 256 niveles de gris). En este caso, al ser una imagen bastante oscura, escogemos un valor de umbral bajo, pero sugerimos a los alumnos que experimenten con otros valores.
Análogamente a los casos anteriores, sustituimos la línea del algoritmo. En este caso, la fórmula de la binarización sería
// Algoritmo - Transformación Puntual (Binarización)
if (b < llindarValue) {
imgFilter.pixels[loc] = color(0);
} else {
imgFilter.pixels[loc] = color(255);
}
En la figura 8, podemos ver cuál sería el resultado de la transformación binarización con umbral 20.
Figura 8. Ventana de la aplicación del ejemplo 8
Figura 8. Ventana de la aplicación del ejemplo 8

2.4.Transformaciones de aclaramiento y oscurecimiento de la imagen

Unas de las transformaciones estudiadas con más detalle en el módulo «Histogramas y transformaciones puntuales» han sido las transformaciones de aclaramiento y oscurecimiento de la imagen. Como hemos visto, es posible definir curvas que aclaren una imagen y es posible definir curvas que oscurezcan una imagen. También hemos visto que es posible definir una transformación de aclaramiento y oscurecimiento por partes, que combina las dos transformaciones, y tiene la finalidad de modificar el contraste de una imagen.
En este subapartado vamos a estudiar directamente las transformaciones de aclaramiento y oscurecimiento por partes, pues ya incluyen las versiones simples de estas transformaciones.
El código de esta transformación es muy parecido al código de la transformación puntual binarización, pero ahora la transformación viene dada por una función por partes, como la de la figura 9, que estudiamos en el módulo «Histogramas y transformaciones puntuales».
Figura 9. Ejemplo de transformación por partes
Figura 9. Ejemplo de transformación por partes
Tiene que quedar claro que la curva de la transformación podría tener más o menos partes, con otras proporciones, y que estas podrían ser lineales (como las de la figura 9) o usar otros tipos de funciones. En el ejemplo de este subapartado vamos a implementar una transformación por partes con una función con una gráfica parecida a la de la figura 9.
Como se puede ver, lo primero que necesitamos es definir los valores x0, x1, y0 e y1, para definir la forma de la curva. Análogamente a la transformación binarización, esta asignación de valores la hacemos al inicio del código.
// Con estos valores, definimos la forma de la curva de aclaramiento y oscurecimiento
int x0 = 23;
int x1 = 250;
int y0 = 65;
int y1 = 240;
No hay ninguna fórmula matemática que indique cuáles son los valores que tiene que tener la curva de la transformación por partes. Al ser la imagen original una imagen bastante oscura, se han cogido unos valores que aclaran bastante la imagen, pero se invita al alumno a experimentar con otros valores.
Una vez definida la curva, el segundo paso es definir el algoritmo de la transformación.
// Algoritmo - Transformación Puntual (Aclaramiento y Oscurecimiento)
if (b < x0 ) {
imgFilter.pixels[loc] = color(int(map(b, 0, x0, 0, y0)));
} elseif ( b < x1 ) {
imgFilter.pixels[loc] = color(int(map(b, x0, x1, y0, y1)));
} else {
imgFilter.pixels[loc] = color(int(map(b, x1, 255, y1, 255)));
}
Lo que hacemos en este algoritmo, básicamente, es, primero, determinar en qué «parte» se encuentra el nivel de gris a evaluar y, después, aplicar la función que le corresponde. Como se puede ver, en este algoritmo usamos la función map(), que realiza una correspondencia lineal entre dos segmentos de valores.
Vamos a ver, pues, todo el código de esta transformación puntual de aclaramiento y oscurecimiento por partes.
/**
* Ejemplo 9: Transformación Puntual Aclaramiento y Oscurecimiento por partes,
* para imágenes en escala de grises
* Francesc Martí, martifrancesc@uoc.edu, 15-04-2016
*
*/

// Declaramos dos objetos de tipo PImage, uno para la imagen original
// y otro para la imagen filtrada
PImage imgOriginal;
PImage imgFilter;

// Con estos valores, definimos la forma de la curva de aclaramiento y oscurecimiento
int x0 = 23;
int x1 = 250;
int y0 = 65;
int y1 = 240; 

void setup() {
// Cargamos la imagen
imgOriginal = loadImage("4.1.02.png");

// Creamos una nueva imagen con las mismas dimensiones que la imagen original
imgFilter = createImage(imgOriginal.width, imgOriginal.height, RGB);

// Las medidas de la ventana de trabajo permitirán visualizar
// las dos imágenes a la vez
surface.setSize(2*imgOriginal.width, imgOriginal.height);

// La función draw() se ejecutará solo una vez
noLoop();
}

void draw() {
// Como siempre, hemos de llamar a estas funciones antes de acceder al
// array de píxeles de las imágenes
imgOriginal.loadPixels();
imgFilter.loadPixels();

int loc= 0;

// Recorremos todos los píxeles de la imagen
while (loc < imgOriginal.pixels.length) {
// Al ser una imagen en escala de grises, R = G = B
// Así que tenemos suficiente consultando uno de los canales RGB
float b = imgOriginal.pixels[loc] & 0xFF;

// Algoritmo - Transformación Puntual (Aclaramiento y Oscurecimiento)
if ( b < x0 ) {
imgFilter.pixels[loc] = color(int(map(b, 0, x0, 0, y0)));
} elseif ( b < x1 ) {
imgFilter.pixels[loc] = color(int(map(b, x0, x1, y0, y1)));
} else {
imgFilter.pixels[loc] = color(int(map(b, x1, 255, y1, 255)));
}

loc++;
}

// Si hacemos modificaciones sobre los píxeles, siempre hemos de actualizar
// el array con la función updatePixels()
imgFilter.updatePixels();

// La función image() permite visualizar las dos imágenes
image(imgOriginal, 0, 0);
image(imgFilter, imgOriginal.width, 0);

// Guardamos el resultado en la carpeta "data" del proyecto
imgFilter.save(dataPath("imgFilter.png"));
}
En la figura 10, podemos ver cuál sería el resultado de aplicar una transformación puntual de aclaramiento y oscurecimiento por partes sobre la imagen «4.1.02.png». Como se aprecia, la imagen se ha aclarado sensiblemente. De todos modos, como ya se ha comentado, se invita al alumno a experimentar con otros valores y comparar los resultados.
Figura 10. Ventana de la aplicación del ejemplo 9
Figura 10. Ventana de la aplicación del ejemplo 9

3.Transformaciones espaciales lineales

Las transformaciones espaciales son aquellas en las que el valor de cada píxel de la imagen transformada depende del píxel con las mismas coordenadas de la imagen original y sus vecinos. Dentro de las transformaciones espaciales, en este apartado hablaremos de las transformaciones espaciales lineales.
El primer cambio que tendremos que introducir en el código es la forma en que recorremos los píxeles de la imagen. Hasta ahora, hemos usado el código
// Recorremos todos los píxeles de la imagen
while (loc < imgOriginal.pixels.length) {
En las transformaciones puntuales, la imagen es tratada como un conjunto de píxeles independientes, así que solo nos tenemos que asegurar de que recorremos todos los píxeles de la imagen, sin importar el orden.
En cambio, en las transformaciones espaciales necesitamos operar sobre un píxel y sus vecinos. Si volvemos a consultar la figura 2, podemos ver que el píxel 9 y el píxel 10 se encuentran en cada punta de la imagen. Si accedemos al array de píxeles de forma secuencial, nos encontraremos con que será muy complicado calcular los píxeles vecinos de un píxel dado.
Así que nos interesa recorrer la imagen de forma que, una vez determinado el píxel sobre el cual hacer las operaciones, determinar cuáles son sus vecinos resulte sencillo. Esta forma es recorriendo la imagen fila por fila con el siguiente código:
// Recorremos todos los píxeles de la imagen
for (int x = 0; x < imgOriginal.width; x++) {
for (int y = 0; y < imgOriginal.height; y++) {
Una vez determinado un píxel (x,y), su posición dentro del array de píxeles vendrá determinada –como ya hemos visto– por la fórmula
int loc = x + y * imgOriginal.width;
Análogamente a lo que hemos hecho en el anterior apartado, empezaremos programando la transformación espacial lineal más sencilla, la identidad, y la tomaremos como modelo para generar el resto de transformaciones. Como veremos, el código será el mismo en todos los subapartados, y solo tendremos que modificar los valores de la máscara y el desplazamiento (offset) para aplicar las diferentes transformaciones espaciales lineales.

3.1.Identidad

Empezamos con la transformación espacial lineal identidad. Introducimos directamente el código que permite efectuar esta transformación y que analizaremos en detalle más adelante. Igual que en el anterior apartado, los programas son para imágenes en escala de grises.
/**
* Ejemplo 10: Transformación espacial lineal Identidad,
*para imágenes en escala de grises
* Francesc Martí, martifrancesc@uoc.edu, 15-04-2016
*
*/

// Declaramos dos objetos de tipo PImage, uno para la imagen original
// y otro para la imagen filtrada
PImage imgOriginal;
PImage imgFilter;

// Máscara de convolución (Identidad) expresada como matriz
float[][] matrix = { 
{ 0, 0, 0 }, 
{ 0, 1, 0 }, 
{ 0, 0, 0 } }; 

// Dimensión de la máscara de convolución
int matrixsize = 3;

// Corrección desplazamiento
int offset = 0;

void setup() {
// Cargamos la imagen
imgOriginal = loadImage("4.2.06.png");

// Creamos una nueva imagen con las mismas dimensiones que la imagen original
imgFilter = createImage(imgOriginal.width, imgOriginal.height, RGB);

// Las medidas de la ventana de trabajo permitirán visualizar
// las dos a la vez
surface.setSize(2*imgOriginal.width, imgOriginal.height);

// La función draw() se ejecutará solo una vez
noLoop();
}

void draw() {
// Como siempre, hemos de llamar a estas funciones antes de acceder al
// array de píxeles de las imágenes
imgOriginal.loadPixels();
imgFilter.loadPixels();

// Recorremos todos los píxeles de la imagen
for (int x = 0; x < imgOriginal.width; x++) {
for (int y = 0; y < imgOriginal.height; y++) {
// Cálculo de la convolución espacial
int c = convolution(x, y, matrix, matrixsize, offset, imgOriginal);

// Generamos un nuevo píxel en la imagen filtrada
int loc = x + y * imgOriginal.width;
imgFilter.pixels[loc] = color(c);
}
}

// Si hacemos modificaciones sobre los píxeles, siempre hemos de actualizar
// el array con la función updatePixels()
imgFilter.updatePixels();

// La función image() permite visualizar las dos imágenes
image(imgOriginal, 0, 0);
image(imgFilter, imgOriginal.width, 0);

// Guardamos el resultado en la carpeta "data" del proyecto
imgFilter.save(dataPath("imgFilter.png"));
}

// Función que calcula la convolución espacial
int convolution(int x, int y, float[][] matrix, int matrixsize, int offset, PImage img) {
float result = 0.0;
int half = matrixsize / 2;

// Recorremos la matriz de convolución
for (int i = 0; j < matrixsize; i++) {
for (int j = 0; i < matrixsize; j++) {
// Cálculo del píxel sobre el que estamos trabajando
int xloc = x + i - half;
int yloc = y + j - half;
int loc = xloc + img.width * yloc;

// Nos aseguramos de que tomamos un píxel dentro del rango válido. En este caso estamos 
// aplicando la replicación de valores de píxeles próximos por localizaciones de píxeles 
// que salen de la imagen
loc = constrain(loc, 0, img.pixels.length-1);

// Cálculo de la operación convolución
// Consultamos el valor del canal blue (B)
result += ((imgOriginal.pixels[loc] & 0xFF) * matrix[i][j]);
}
}

// Aplicamos el desplazamiento
result += offset;

// Nos aseguramosde de que el nivel de gris está en el rango (0, 255)
result = constrain(result, 0, 255);

// Retornamos el nivel de gris
return (int)result;
}
Vamos a analizar ahora este código. Como podemos ver, empezamos definiendo las variables que necesitaremos para poder realizar la transformación: la matriz de convolución (matrix), la dimensión de la matriz (matrixsize) y el desplazamiento (offset). En este ejemplo, tenemos la matriz de convolución identidad, su dimensión es 3 (3 × 3) y –como ya sabemos–, si la suma de los coeficientes de la matriz es 1, el desplazamiento tiene que ser 0.
// Máscara de convolución (Identidad) expresada como matriz
float[][] matrix = { 
{ 0, 0, 0 }, 
{ 0, 1, 0 }, 
{ 0, 0, 0 } }; 

// Dimensión de la máscara de convolución
int matrixsize = 3;

// Corrección desplazamiento
int offset = 0;
Como ya hemos visto, recorremos los píxeles de la imagen fila por fila con el código
for (int x = 0; x < imgOriginal.width; x++) {
for (int y = 0; y < imgOriginal.height; y++) {
Y calculamos la operación convolución espacial con la función convolution().
int c = convolution(x, y, matrix, matrixsize, offset, imgOriginal);
Una vez calculado el valor de la convolución, ya podemos asignar este valor de gris al píxel correspondiente de la imagen transformada.
int loc = x + y * imgOriginal.width;
imgFilter.pixels[loc] = color(c);
En este código, la función convolution() contiene el algoritmo encargado de hacer las operaciones que hemos descrito en detalle en el módulo «Transformaciones espaciales lineales». Le enviamos las coordenadas del píxel de la imagen original, los valores de la matriz de convolución, su dimensión, su desplazamiento y la imagen, y nos devuelve el nivel de gris que debe tener el píxel de la imagen transformada con las mismas coordenadas.
int convolution(int x, int y, float[][] matrix, int matrixsize, int offset, PImage img)
En la figura 11, podemos ver cuál sería el resultado de aplicar la máscara de convolución identidad a la imagen «4.2.06.png». Como no podía ser de otra forma, las dos imágenes son idénticas.
Figura 11. Ventana de la aplicación del ejemplo 10
Figura 11. Ventana de la aplicación del ejemplo 10

3.2.Negativo

A partir de ahora, el código de todas las transformaciones espaciales lineales es igual, excepto por la matriz de convolución y el desplazamiento.
Por ejemplo, el código de la transformación espacial lineal negativo será igual que el código de la transformación espacial lineal identidad, pero sustituyendo la matriz de convolución y el desplazamiento por los valores:
// Máscara de convolución Negativo expresada como matriz
float[][] matrix = { 
{ 0, 0, 0 }, 
{ 0, -1, 0 }, 
{ 0, 0, 0 } }; 

// Dimensión de la máscara de convolución
int matrixsize = 3;

// Corrección desplazamiento
int offset = 255;
Si ejecutamos el código, en la figura 12 tenemos el resultado de esta transformación.
Figura 12. Ventana de la aplicación del ejemplo 11
Figura 12. Ventana de la aplicación del ejemplo 11
Como podemos ver, el resultado es equivalente a efectuar transformación puntual negativo.

3.3.Suavización

Como ejemplo de suavización, aplicaremos una matriz de convolución ya estudiada en el módulo «Transformaciones espaciales lineales».
// Máscara de convolución Suavización, expresada como matriz
float[][] matrix = { 
{ 1/9f, 1/9f, 1/9f }, 
{ 1/9f, 1/9f, 1/9f }, 
{ 1/9f, 1/9f, 1/9f } }; 

// Dimensión de la máscara de convolución
int matrixsize = 3;

// Corrección desplazamiento
int offset = 0;
En la figura 13 tenemos el resultado de esta transformación. Como apreciamos claramente, la máscara de suavización tiende a difuminar la imagen.
Figura 13. Ventana de la aplicación del ejemplo 12
Figura 13. Ventana de la aplicación del ejemplo 12

3.4.Contornos

Acabaremos este subapartado con dos ejemplos de código para detectar y realzar los contornos de una imagen.
// Máscara de convolución Contornos (detección) expresada como matriz
float[][] matrix = { 
{ -1, -1, -1 }, 
{ -1, 8, -1 }, 
{ -1, -1, -1 } }; 

// Dimensión de la máscara de convolución
int matrixsize = 3;

// Corrección desplazamiento
int offset = 128;
En la figura 14 tenemos el resultado de esta transformación.
Figura 14. Ventana de la aplicación del ejemplo 13
Figura 14. Ventana de la aplicación del ejemplo 13
Finalmente, un código que nos permite realzar los contornos de una imagen podría ser este:
// Máscara de convolución Contornos (realce) expresada como matriz
float[][] matrix = { 
{ -1, -1, -1 }, 
{ -1, 9, -1 }, 
{ -1, -1, -1 } }; 

// Dimensión de la máscara de convolución
int matrixsize = 3;

// Corrección desplazamiento
int offset = 0;
En la figura 15 tenemos el resultado de esta transformación.
Figura 15. Ventana de la aplicación del ejemplo 14
Figura 15. Ventana de la aplicación del ejemplo 14
Como observamos, en todos estos ejemplos, simplemente tenemos que ir cambiando los coeficientes de la máscara de convolución y corrigiendo el valor del desplazamiento.

4.Transformaciones espaciales no lineales

Como ya vimos en el módulo «Transformaciones espaciales no lineales», hay transformaciones espaciales que no cumplen las condiciones de linealidad y, por lo tanto, no pueden calcularse mediante una máscara de convolución.
En este apartado veremos cómo aplicar las transformaciones espaciales no lineales erosión, dilatación, apertura y cierre. Como ya avanzamos en el apartado 1, lo haremos usando el método filter() de la clase PImage, en vez de hacer las operaciones píxel a píxel, como hemos hecho en los dos últimos apartados.

4.1.Erosión

Ya sabemos que el operador erosión, para cada píxel de la imagen original, busca el nivel de gris más pequeño presente en la ventana de trabajo y asigna este nivel de gris al píxel correspondiente de la imagen transformada. Por lo tanto, esta transformación genera una imagen más oscura que la imagen original.
Vamos a ver en detalle cómo podemos implementar esta transformación en Processing con el método filtre(), aunque el código es prácticamente igual que el código que hemos visto en el ejemplo 4.
/**
* Ejemplo 15: Transformación espacial no lineal Erosión
* Francesc Martí, martifrancesc@uoc.edu, 15-04-2016
*
*/

// Declaramos un objeto de tipo PImage
PImage img;

void setup() {
// Cargamos la imagen
img = loadImage("faces.png");

// Las medidas de la ventana de trabajo permitirán visualizar
// la imagen original y la imagen filtrada
surface.setSize(2*img.width, img.height);

// La función draw() se ejecutará solo una vez
noLoop();
}

void draw() {

// Visualizaremos la imagen antes de filtrar 
image(img, 0, 0);

// Aplicamos el filtro, con el método Erosión
img.filter(ERODE);

// Visualizamos la imagen después de filtrar 
image(img, img.width, 0);

// Guardamos el resultado de la transformación en la carpeta "data" del proyecto
img.save(dataPath("imgFilter2.png"));
}
En la figura 16 tenemos el resultado de esta transformación con la imagen original a la izquierda y la imagen erosionada –y, por lo tanto, más oscura– a la derecha.
Figura 16. Ventana de la aplicación del ejemplo 15
Figura 16. Ventana de la aplicación del ejemplo 15

4.2.Dilatación

Por su parte, el operador dilatación realiza la operación contraria, es decir, para cada píxel de la imagen original busca el nivel de gris más grande presente en la ventana de trabajo y asigna este nivel de gris al píxel con las mismas coordenadas de la imagen transformada. Por lo tanto, esta transformación genera una imagen más clara que la imagen original.
Vemos también un ejemplo de cómo podemos implementar esta transformación en Processing usando la función filter(). De hecho, el código es el mismo que el código del operador erosión, pero sustituyendo la línea donde especificamos el método del filtro.
// Aplicamos el filtro, con el método Dilatación
img.filter(DILATE);
En la figura 17 tenemos el resultado de esta transformación. Como podemos ver, la imagen transformada es sensiblemente más clara que la imagen original.
Figura 17. Ventana de la aplicación del ejemplo 16
Figura 17. Ventana de la aplicación del ejemplo 16

4.3.Apertura y cierre

Para acabar la teoría de este apartado, veremos cómo programar los filtros apertura y cierre en Processing.
Como hemos estudiado, el filtro apertura consiste en aplicar primero un filtro erosión seguido de un filtro dilatación, respetando el elemento estructural. El objetivo de esta transformación es eliminar los objetos claros más pequeños que el elemento estructural, pero intentando preservar el resto de información de la imagen.
El código de esta transformación es muy sencillo, puesto que solo tenemos que aplicar el filtro de Processing dos veces, la primera con el parámetro ERODE y la segunda con el parámetro DILATE, como podemos ver en el siguiente programa.
/**
* Ejemplo 17: Transformación espacial no lineal Obertura
* Francesc Martí, martifrancesc@uoc.edu, 15-04-2016
*
*/

// Declaramos un objeto de tipo PImage
PImage img;

void setup() {
// Cargamos la imagen
img = loadImage("faces.png");


// Las medidas de la ventana de trabajo permitirán visualizar
// la imagen original y la imagen filtrada
surface.setSize(2*img.width, img.height);

// La función draw() se ejecutará solo una vez
noLoop();
}

void draw() {

// Visualizaremos la imagen antes de filtrar 
image(img, 0, 0);

// Aplicamos el filtro, con el método Erosión
img.filter(ERODE);

// Aplicamos el filtro, con el método Dilatación
img.filter(DILATE);

// Visualizamos la imagen después de filtrar 
image(img, img.width, 0);

// Guardamos el resultado de la transformación en la carpeta "data" del proyecto
img.save(dataPath("imgFilter2.png"));
}
En la figura 18 podemos ver el resultado de aplicar una transformación apertura a la imagen «faces.png» usando este programa.
Figura 18. Ventana de la aplicación del ejemplo 17
Figura 18. Ventana de la aplicación del ejemplo 17
El filtro cierre, por su parte, se consigue aplicando primero un filtro dilatación seguido de un filtro erosión. Como sabemos, el objetivo de este filtro es el contrario del filtro apertura: eliminar los objetos oscuros más pequeños que el elemento estructural, pero –de nuevo– intentando preservar el resto de información de la imagen.
El código de este filtro es exactamente igual que el del programa que acabamos de ver, pero cambiando el orden de los filtros.
// Aplicamos el filtro, con el método Dilatación
img.filter(DILATE);

// Aplicamos el filtro, con el método Erosión
img.filter(ERODE);
Así pues, en la figura 19 tenemos el resultado de aplicar una transformación espacial no lineal cierre a la imagen «faces.png» usando este código.
Figura 19. Ventana de la aplicación del ejemplo 18
Figura 19. Ventana de la aplicación del ejemplo 18

5.Transformaciones geométricas

El último apartado de este módulo lo dedicaremos a las transformaciones geométricas.
En los apartados anteriores, nos hemos centrado en desarrollar programas que transformaban los niveles de gris de los píxeles de una imagen (y los colores en algunos casos) para generar una nueva imagen. Pero en ningún caso hemos modificado la posición de los píxeles de la imagen.
Las transformaciones que modifican la posición de los píxeles de la imagen original reciben el nombre de transformaciones geométricas, y en este apartado veremos cómo programar algunas de ellas en Processing.

5.1.Zoom y diezmado

Empezaremos el análisis de las transformaciones geométricas en Processing con el zoom y el diezmado. Estas dos transformaciones permiten aumentar o disminuir el número de píxeles de una imagen, provocando un aumento o disminución de sus dimensiones, respectivamente.
Para realizar estas operaciones, en Processing disponemos de la función resize(), que, en función de su parámetro, aplicará un zoom o un diezmado sobre la imagen. Para valores más pequeños que 1 aplicará un diezmado, y para valores mayores que 1, un zoom.
Veamos esto con un ejemplo.
/**
* Ejemplo 19: Transformación geométrica Zoom y Diezmado
* Francesc Martí, martifrancesc@uoc.edu, 15-04-2016
*
*/

// Declaramos un objeto de tipo PImage
PImage img;

// Para valores > 1 aplicamos un zoom, valores < 1 un diezmado
float resizeValue = 1.5;

void setup() {
// Cargamos la imagen
img = loadImage("4.1.04.png");

// Las medidas de la ventana de trabajo han de permitir visualizar
// la imagen original y la imagen transformada
surface.setSize(int((resizeValue+1)*img.width), int(max(resizeValue,1)*img.height));

// La función draw() se ejecutará solo una vez
noLoop();
}

void draw() {

// Visualizamos la imagen antes de ampliar 
image(img, 0, 0);

// Aplicamos la transformación geométrica "resize" 
img.resize(int(resizeValue*img.width), int(resizeValue*img.height));

// Visualizamos la imagen después de aplicarle la transformación
image(img, img.width/resizeValue, 0);

// Guardamos el resultado de la transformación en la carpeta "data" del proyecto
img.save(dataPath("imgFilter2.png"));
}
En este código, el valor del parámetro de la función resize() es 1.5 y, por lo tanto, las dimensiones de la imagen transformada es 1,5 veces más grande que las de la imagen original.
En la figura 20 tenemos el resultado de esta transformación, con la imagen original a la izquierda y la imagen transformada a la derecha.
Figura 20. Ventana de la aplicación del ejemplo 19
Figura 20. Ventana de la aplicación del ejemplo 19
Como queda remarcado, si quisiéramos aplicar un diezmado, simplemente tendríamos que usar un parámetro con un valor más pequeño que 1. Por ejemplo:
float resizeValue = 0.5;
En la figura 21 podemos ver el resultado de aplicar una función resize() con un valor de 0.5 sobre la imagen «4.1.04.png», de nuevo con la imagen original a la izquierda y la imagen transformada a la derecha.
Figura 21. Ventana de la aplicación del ejemplo 19
Figura 21. Ventana de la aplicación del ejemplo 19
Antes de finalizar este apartado, conviene remarcar que muchos estudiantes confunden las funciones resize() y scale(), puesto que parecen hacer lo mismo.
Si ejecutamos el siguiente código, podemos observar que la ventana de la aplicación del programa también muestra la imagen reducida. Aparentemente, hemos hecho la misma operación que hemos efectuado con el ejemplo anterior.
/**
* Ejemplo 20: Transformación Scale
* Francesc Martí, martifrancesc@uoc.edu, 15-04-2016
*
*/

// Declaramos un objeto de tipo PImage
PImage img;

// Valor de la escala
float scaleValue = 0.5;

void setup() {
// Cargamos la imagen
img = loadImage("4.1.04.png");

// Medidas de la ventana de trabajo
surface.setSize(int(scaleValue*img.width), int(scaleValue*img.height));

// La función draw() se ejecutará solo una vez
noLoop();
}

void draw() {

// Modifica las dimensiones del sistema de coordenadas, pero no modifica
// las dimensiones de la imagen
scale(scaleValue);

// Visualizamos la imagen
image(img, 0, 0);

// Guardamos la imagen en la carpeta "data" del proyecto
img.save(dataPath("img2.png"));
}
En cambio, si comparamos las imágenes que hemos guardado con los dos programas, podemos ver que el primer programa guarda la imagen transformada, pero en cambio, el segundo programa vuelve a guardar la imagen original, sin reducir.
La razón es que la función scale() realiza los cambios sobre el sistema de coordenadas de la ventana de la aplicación, no sobre la imagen. Sería equivalente a modificar el valor del zoom al trabajar en un documento de texto: estamos modificando el espacio de trabajo, la forma de ver ese documento, no estamos cambiando las medidas de la fuente ni ningún otro elemento.

5.2.Traslación

Análogamente a la transformación scale() que acabamos de ver, tanto la traslación como la rotación son transformaciones que, en Processing, modifican las coordenadas del sistema, no la imagen en sí. Como hemos visto, este hecho lo hemos de tener siempre presente, sobre todo si queremos guardar el resultado de las transformaciones efectuadas.
La función encargada de aplicar traslaciones en Processing es la función translate(). Básicamente, lo que hace esta función es trasladar el origen del sistema de coordenadas (0,0), que, por defecto, se encuentra en la esquina superior izquierda de la ventana de la aplicación, a otra posición de la ventana.
Veámoslo con un sencillo ejemplo, donde desplazamos el origen del sistema de coordenadas 40 píxeles hacia abajo y 40 píxeles hacia la derecha.
/**
* Ejemplo 21: Transformación Translación
* Francesc Martí, martifrancesc@uoc.edu, 15-04-2016
*
*/

// Declaramos un objeto de tipo PImage
PImage img;

// Coordenadas del nuevo origen
int coorX = 40;
int coorY = 40;

void setup() {
// Cargamos la imagen
img = loadImage("4.1.04.png");

// Medidas de la ventana de trabajo
surface.setSize(img.width, img.height);

// La función draw() se ejecutará solo una vez
noLoop();
}

void draw() {

// Movemos el sistema de coordenadas
translate(coorX, coorY);

// Visualizamos la imagen
image(img, 0, 0);

// Guardamos el contenido de la ventana de la aplicación como imagen
// en la carpeta "data" del proyecto
save(dataPath("img2.png"));
}
En la figura 22 tenemos el resultado de esta transformación.
Figura 22. Ventana de la aplicación del ejemplo 21
Figura 22. Ventana de la aplicación del ejemplo 21
Como podemos ver, ahora la imagen no se muestra completa, puesto que –al trasladar el sistema de coordenadas– parte de la imagen cae fuera de las dimensiones de la ventana de la aplicación. Un modo de solucionar esto sería ampliando las dimensiones de la ventana
// Medidas de la ventana de trabajo
surface.setSize(img.width+ coorX, img.height+ coorY);
Pero ahora la pregunta sería: ¿cómo guardaríamos ahora esta transformación en nuestro disco duro? Como hemos visto antes, con
img.save(dataPath("img2.png"));
no guardaríamos el cambio de posición, puesto que la transformación se efectúa sobre el sistema de coordenadas, no sobre la imagen. Si lo que queremos es guardar lo que se ve en la ventana de la aplicación tenemos que usar simplemente save().
save(dataPath("img2.png"));
Por lo tanto, con el código img.save() guardamos el contenido de la imagen –aunque esta ni siquiera aparezca en la ventana de la aplicación– y con save() guardamos el contenido de la ventana de la aplicación.
img.save(dataPath("img2.png"));
Algo parecido sucede con la función filter() explicada anteriormente, y que suele provocar errores en el código de los estudiantes. Si, por ejemplo, en un programa aplico un filtro con el código
filter(GRAY);
estoy aplicando este filtro al contenido de la ventana de la aplicación. No estoy aplicando el filtro sobre ninguna imagen de tipo PImage. Aunque la ventana muestre la imagen, son dos cosas diferentes. Una cosa es la imagen en sí y otra cosa es lo que se visualiza en la ventana de la aplicación. Si guardamos la imagen con
img.save(dataPath("img2.png"));
veremos que la guardamos sin haberle aplicado el filtro anterior.

5.3.Rotación

La última transformación geométrica que veremos es la rotación, que, como su nombre ya indica, lo que hace es rotar el sistema de coordenadas de la ventana de la aplicación.
La función que en Processing realiza esta operación recibe el nombre de rotate() y su parámetro indica el ángulo de rotación expresado en radianes.
Podemos ver un sencillo ejemplo en el siguiente código.
/**
* Ejemplo 22: Transformación Rotación
* Francesc Martí, martifrancesc@uoc.edu, 15-04-2016
*
*/

// Declaramos un objeto de tipo PImage
PImage img;

// Valor de la rotación
float rotateValue = PI/10;

void setup() {
// Cargamos la imagen
img = loadImage("4.1.04.png");

// Medidas de la ventana de trabajo
surface.setSize(img.width, img.height);

// La función draw() se ejecutará solo una vez
noLoop();
}

void draw() {

// Rotamos el sistema de coordenadas
rotate(rotateValue);

// Visualizamos la imagen
image(img, 0, 0);

// Guardamos el contenido de la ventana de la aplicación como imagen
// en la carpeta "data" del proyecto
save(dataPath("img2.png"));
}
En la figura 23 tenemos el resultado de ejecutar el código del ejemplo 22.
Figura 23. Ventana de la aplicación del ejemplo 22
Figura 23. Ventana de la aplicación del ejemplo 22
Como podemos ver, igual que nos pasó con la traslación, ahora la imagen no se muestra completa, puesto que al hacer la rotación, tomando el origen de coordenadas como punto fijo, un fragmento de la imagen queda fuera de los límites de la ventana de la aplicación.

5.4.Composición de transformaciones

Para acabar este apartado, introduciremos el concepto de composición de transformaciones y veremos cómo nos permiten solucionar problemas como el del ejemplo anterior.
En este ejemplo, efectuamos un cambio de escala, una traslación y una rotación. Rotaremos y trasladaremos la imagen, pero la mostraremos entera en la ventana de la aplicación aplicando también un cambio de escala.
/**
* Ejemplo 23: Composición de Transformaciones (I)
* Francesc Martí, martifrancesc@uoc.edu, 15-04-2016
*
*/

// Declaramos un objeto de tipo PImage
PImage img;

// Valor de la escala
float scaleValue = 0.5;

// Coordenadas del nuevo origen
int coorX = 200;
int coorY = 100;

// Valor de la rotación
float rotateValue = PI/10;

void setup() {
// Cargamos la imagen
img = loadImage("4.1.04.png");

// Medidas de la ventana de trabajo
surface.setSize(img.width, img.height);

// La función draw() se ejecutará solo una vez
noLoop();
}

void draw() {

// Modifica las dimensiones del sistema de coordenadas, pero no modifica
// las dimensiones de la imagen
scale(scaleValue);

// Movemos el sistema de coordenadas
translate(coorX, coorY);

// Rotamos el sistema de coordenadas
rotate(rotateValue);

// Visualizamos la imagen
image(img, 0, 0);

// Guardamos el contenido de la ventana de la aplicación como imagen
// en la carpeta "data" del proyecto
save(dataPath("img2.png"));
}
En la figura 24 tenemos el resultado de esta composición de transformaciones.
Figura 24. Ventana de la aplicación del ejemplo 23
Figura 24. Ventana de la aplicación del ejemplo 23
Para acabar, hablaremos brevemente de las funciones pushMatrix() y popMatrix(), puesto que son funciones imprescindibles para controlar las composiciones de transformaciones geométricas. Como hemos visto, con las funciones scale(), translate() y rotate() modificamos el sistema de coordenadas de la ventana de la aplicación. Esto puede provocar que, después de realizar varias transformaciones, no acabemos de tener clara cuál es la configuración del sistema, o se necesiten varios cálculos para volver al estado inicial.
Las funciones pushMatrix() y popMatrix(), permiten controlar las transformaciones geométricas aislando sus efectos. Cualquier transformación que se aplique después de una función pushMatrix() dejará de estar activa después de una función popMatrix(). Cada función pushMatrix() debe tener su correspondiente función popMatrix(), para determinar correctamente el ámbito de acción de las transformaciones.
Vamos a ver cómo funcionan con un ejemplo. Como podemos ver en el ejemplo 23, los efectos de las transformaciones translate() y rotate() están aislados. Solo tienen efecto sobre la primera visualización que efectuamos, image(img, 0, 0), no sobre la segunda. Esto nos permite restablecer el sistema de coordenadas original, con la excepción de la escala, que, como no está entre las funciones pushMatrix() y popMatrix(), sí que tiene efectos permanentes.
/**
* Ejemplo 24: Composición de Transformaciones (II)
* Francesc Martí, martifrancesc@uoc.edu, 15-04-2016
*
*/

// Declaramos un objeto de tipo PImage
PImage img;

// Valor de la escala
float scaleValue = 0.5;

// Coordenadas del nuevo origen
int coorX = 200;
int coorY = 100;

// Valor de la rotación
float rotateValue = PI/10;

void setup() {
// Cargamos la imagen
img = loadImage("4.1.04.png");

// Medidas de la ventana de trabajo
surface.setSize(img.width, img.height);

// La función draw() se ejecutará solo una vez
noLoop();
}

void draw() {

// Esta transformación afecta a las dos imágenes que visualizaremos
scale(scaleValue);

pushMatrix();

// Movemos el sistema de coordenadas
translate(coorX, coorY);

// Rotamos el sistema de coordenadas
rotate(rotateValue);

// Visualizamos la imagen
image(img, 0, 0);

popMatrix();

// Visualizamos otra vez la imagen
image(img, 0, 0);

// Guardamos el contenido de la ventana de la aplicación como imagen
// en la carpeta "data" del proyecto
save(dataPath("img2.png"));
}

6.Programación de efectos en vídeos en Processing

Acabaremos este módulo dedicado a la programación de efectos en Processing viendo cómo aplicar cualquier efecto que se pueda aplicar sobre una imagen o sobre un vídeo.
Veremos dos métodos, dos formas de realizar esta acción. Resumidamente, el primer método consiste en convertir el fichero de vídeo en una secuencia de imágenes, aplicar el efecto o efectos sobre cada una de las imágenes de la secuencia y, finalmente, volver a generar un archivo de vídeo a partir de la secuencia de imágenes filtradas.
El segundo método es bastante parecido. La única diferencia es que, en vez de convertir inicialmente el fichero de vídeo en una secuencia de imágenes, usamos las librerías Video y GLVideo de Processing para leer el vídeo fotograma a fotograma. Análogamente al primer método, estos fotogramas son filtrados uno a uno, guardados en una secuencia de imágenes y convertidos en un archivo de vídeo en el último paso.

6.1.Aplicación de efectos en secuencias de imágenes

El concepto clave que entender en este último apartado del módulo es que un vídeo –su parte «visual», de hecho– solo es una sucesión de imágenes que han de ser visualizadas a una velocidad dada (velocidad fijada por el frame rate).
Como ya sabemos, todos estos fotogramas, todas estas imágenes, están «empaquetados» en un archivo de tipo vídeo y no es posible acceder directamente a un solo fotograma del vídeo en formato PNG o JPEG, por ejemplo.
De todas maneras, la mayoría de editores de vídeo dan la opción de exportar cualquier fotograma de un vídeo como imagen o incluso exportar todo el vídeo como sucesión de imágenes en formato PNG, TIFF, Targa, JPEG, etc. Por ejemplo, con AdobePremiere Pro, si importamos un vídeo y creamos una secuencia respetando su formato (por ejemplo, en el panel «Proyecto», podemos hacer clic con el botón derecho sobre el vídeo y seleccionar la opción «Nueva secuencia desde clip» y podemos exportarlo como secuencia de imágenes.
En la figura 25 se puede ver cómo realizar esta exportación en AdobePremiere Pro: en el campo «Format» (‘contenedor’) tenemos que seleccionar alguna de las opciones PNG, TIFF, Targa o JPEG y asegurarnos de que la casilla «Exportar como secuencia» está seleccionada.
Figura 25. Exportación de un vídeo como secuencia de imágenes a AdobePremiere Pro
Figura 25. Exportación de un vídeo como secuencia de imágenes a AdobePremiere Pro
Una vez finalizada la exportación, en la carpeta seleccionada se habrá creado la secuencia de imágenes con el nombre asignado y un sufijo que permite numerar y ordenar los fotogramas del vídeo.
Figura 26. El vídeo exportado en formato secuencia de imágenes PNG
Figura 26. El vídeo exportado en formato secuencia de imágenes PNG
Una vez convertido el vídeo en una secuencia de imágenes, ya podríamos ejecutar sobre las imágenes los efectos que hemos ido programando en los apartados anteriores. Como sería un proceso muy lento y laborioso ejecutar los programas para cada imagen, añadiremos unas líneas de código que permitan aplicar los efectos a todas las imágenes de una carpeta.
Empezamos con un ejemplo. Cogeremos como referencia el código del ejemplo 7 y aplicaremos una transformación puntual negativa a todas las imágenes en color de una secuencia de imágenes. Para ejecutar este código, la secuencia de imágenes originales tiene que estar en una carpeta de nombre «Original_Files», dentro de la carpeta del proyecto de Processing.
Huelga decir que este código se puede adaptar fácilmente para aplicar sobre la secuencia de imágenes cualquiera de los efectos que hemos ido viendo durante este módulo.
/**
 * Ejemplo 25: Transformación Puntual Negativo, por imágenes en color.
 * El efecto se aplicará a todas las imágenes que se encuentren en la carpeta seleccionada
 * y se guardarán en una nueva carpeta.
 * Este programa no muestra las imágenes en la ventana de la aplicación.
 *
 * Francesc Martí, martifrancesc@uoc.edu, 08-04-2018
 *
 */

// La variable originalFolder contiene el nombre del directorio donde se encuentran las imágenes
originales.
// La variable filterFolder contiene el nombre del directorio donde se guardarán las imágenes
filtradas.
// Los dos directorios tienen que estar dentro de la carpeta del proyecto de Processing. 
StringoriginalFolder = "Original_Files";
StringfilterFolder = "Filtered_Files";

voidsetup() {
  // Medidas de la ventana de trabajo
size (800, 260);
background(0);
noSmooth();

  // Configuración de la fuente del texto
textSize(12);
  fill(255, 255, 0);

  // Información en la ventana de la aplicación
  texto("Filtrando todas las imágenes con extensión *.png contenidas en el directorio", 10, 50);
  texto(sketchPath("") + originalFolder, 10, 68);
  texto("Las imágenes se están guardando en el directorio", 10, 100);
  texto(sketchPath("") + filterFolder, 10, 118);
  texto("Consultar la ventana de la consola para ver los detalles sobre el proceso de
  filtraje...", 10, 150);

  // La función draw() se ejecutará solo una vez
noLoop();
}

void draw() {
  // Este código permite hacer una lista con todos los archivos que se encuentran en la carpeta
  de las imágenes originales
  File dir = new Hile(sketchPath("") + originalFolder);
  File[] listOfFiles = decir.listFiles();
if (listOfFiles != null) {
    for (File aFile : listOfFiles) {
      // Para cada elemento encontrado dentro del directorio, si es una imagen png, ejecutamos el
      filtro
      // y la guardamos en el directorio con las imágenes filtradas 
if (aFile.isFile() &&afile.getName().endsWith(".png") ) {

        // Información en la ventana de la consola
println("Filtrando la imagen " + aFile.getName());

        // Declaramos dos objetos de tipos PImage, uno por la imagen original
        // y otro por la imagen filtrada
PImageimgOriginal;
PImageimgFilter;

        // Cargamos la imagen original
imgOriginal = loadImage(sketchPath("") + originalFolder + "/" + aFile.getName());

        // Creamos una nueva imagen con las mismas dimensiones que la imagen original
imgFilter = createImage(imgOriginal.width, imgOriginal.height, RGB);

        // Carga de los píxeles de las imágenes
imgOriginal.loadPixels();  
imgFilter.loadPixels();

intloc= 0;

        // Recorremos todos los píxeles de la imagen original
while (loc<imgOriginal.pixels.length) {
          // Estas funciones permiten consultar las componentes R, G y B de un color
float r = imgOriginal.pixels[loc] >> 16 & 0xFF;
float g = imgOriginal.pixels[loc] >> 8 & 0xFF;
float b = imgOriginal.pixels[loc] & 0xFF;

          // Algoritmo - Transformación Puntual (Negativo)
imgFilter.pixels[loc] = color(255-r, 255-g, 255-b);

loc++;
        }

        // Si hacemos modificaciones sobre los píxeles, siempre tenemos que actualizar
        // el array con la función updatePixels()
imgFilter.updatePixels();

        // Guardamos la imagen transformada en su carpeta 
imgFilter.save(sketchPath("") + filterFolder + "/" + aFile.getName());
      }
    }
  }
  // Todas las imágenes han sido filtradas
    texto("Proceso de filtraje finalizado.", 10, 168);
}
Después de ejecutar este código, toda la secuencia de imágenes filtradas se habrá guardado en la carpeta «Filtered_Filters», dentro del proyecto de Processing. Es importante hacer notar que si el número de imágenes que se quieren filtrar es elevado, es posible que el programa necesite unos minutos para realizar todas las operaciones.
El último paso es volver a generar un archivo de vídeo con la secuencia de imágenes, esta vez filtradas. Del mismo modo que los programas de edición de vídeo permiten crear una secuencia de imágenes a partir de un archivo de vídeo, también permiten crear un archivo de vídeo a partir de una secuencia de imágenes.
Volvemos a ver cómo se realiza esta acción en el editor de vídeo AdobePremiere Pro. Primero, importamos al editor la secuencia de imágenes («Archivo > Importar»). Para estar seguros de que importamos toda la secuencia de imágenes y no solo una imagen, tenemos que seleccionar la primera imagen de la secuencia, clicar «Opciones» y seleccionar la opción «Secuencia de Imágenes» («Image Sequence»).
Figura 27. Importación de una secuencia de imágenes en Premiere Pro
Figura 27. Importación de una secuencia de imágenes en Premiere Pro
Como podremos ver, en el panel «Proyecto» se generará un único ítem o clip, con el nombre de la primera imagen de la secuencia, que agrupará todas las imágenes. Por lo tanto, no es necesario importar todas las imágenes: basta importar la primera imagen de la secuencia seleccionando la opción Secuencia de Imágenes.
Llegados a este punto, hay un detalle importante que no tenemos que pasar por alto. Cuando exportamos un vídeo como secuencia de imágenes, perdemos una información esencial: el número de fotogramas por segundo. Una secuencia de imágenes con 600 fotogramas podría corresponder a un vídeo de 10 segundos a 60 fps, pero también podría corresponder a un vídeo de 20 segundos a 30 fps, o a uno de 15 segundos a 40 fps, etc.
Por lo tanto, es importante que el editor de vídeo interprete correctamente a qué velocidad tiene que reproducir la secuencia de imágenes. Volviendo a Premiere Pro, para hacer esto, en el panel «Proyecto», tenemos que clicar con el botón derecho sobre el clip de la secuencia y abrir el cuadro de diálogo «Modificar > Interpretar material de archivo» («Modify > Interpret Footage»), ir a la pestaña «Interpretar material de archivo» y asignar a la secuencia de imágenes el mismo frame rate que tenía el vídeo original.
Figura 28. Configuración del número de fotogramas por segundo de un clip en Premiere Pro
Figura 28. Configuración del número de fotogramas por segundo de un clip en Premiere Pro
Finalmente, si el vídeo original tenía pista de audio y la queremos conservar, simplemente tenemos que importar la secuencia de imágenes dentro del proyecto inicial y exportarlo en el formato de vídeo que más nos convenga. El resultado será el vídeo original con el efecto de Processing aplicado.
Figura 29. Secuencia de Premiere Pro, con una secuencia de imágenes en la pista 2 que sustituirá el vídeo original de la pista 1
Figura 29. Secuencia de Premiere Pro, con una secuencia de imágenes en la pista 2 que sustituirá el vídeo original de la pista 1

6.2.La librería Video de Processing

Processing también dispone de bibliotecas de vídeo que permiten visualizar archivos de vídeo o capturar las imágenes provenientes de una cámara.
Una de estas bibliotecas es la biblioteca Video, desarrollada por la misma Processing Foundation. Esta biblioteca permite realizar operaciones básicas sobre un archivo de vídeo, como por ejemplo reproducirlo y mostrarlo en la ventana de la aplicación, cambiar la velocidad de su reproducción, saltar entre diferentes posiciones del vídeo, etc. Además, permite capturar vídeo desde una cámara conectada por USB o Firewire, así como señales de vídeo compuesto o S-video. Se pueden consultar sus características con detalle en: https://processing.org/reference/libraries/video/index.html.
A pesar de ser una biblioteca oficial, no está incluida con el programa y, por lo tanto, se tiene que descargar e instalar aparte; tenemos que ir al menú «Sketch > Import Library... > AddLibrary», buscar la biblioteca en el listado de bibliotecas e instalarla haciendo clic en el botón «Install».
Aunque no lo estudiaremos en este apartado, otra biblioteca de vídeo muy interesante es la biblioteca GL Video, desarrollada para Raspberry Pi, Linux y Mac OS y que soporta la reproducción de vídeo por hardware.
Empezamos con un sencillo ejemplo sobre cómo reproducir un vídeo en modo loop en Processing usando la librería Video. Es importante advertir que para usar los objetos de la librería Video, además de instalarla, también la tenemos que importar al programa –import processing.video.*–, puesto que esta librería no forma parte del core de Processing.
/**
 * Ejemplo 26: Reproducción de un vídeo en modo "loop"
 *
 * Francesc Martí, martifrancesc@uoc.edu, 09-04-2018
 *
 */  

// Importamos la librería Video
import processing.video.*;
Movie myMovie;

voidsetup() {
size(1280, 720);
  // Cargamos el fichero de vídeo
myMovie = new Movie(this, "Traffic.mp4");
  // Reproducimos el fichero de vídeo en modo "loop"
myMovie.loop();
}

void draw() {
  // Visualizamos los fotogramas en la ventana de la aplicación
image(myMovie, 0, 0);
}

// Esta función es llamada cada vez que un nuevo fotograma está disponible para leer
voidmovieEvent(Movie m) {
m.read();
}
Vamos a ver ahora cómo podemos aplicar un efecto sobre un vídeo y generar una secuencia de imágenes con los fotogramas filtrados, sin tener que convertir previamente el vídeo a una secuencia de imágenes.
Un error muy común al intentar realizar esta acción es aplicar el filtro y capturar los fotogramas del vídeo filtrado dentro de la función draw() con la función saveFrame(), con un código parecido a este:
void draw() {
myMovie.filter(INVERT);
image(myMovie, 0, 0);
saveFrame("Filtered_Files/frame-####.png");
}
Aunque puede parecer una buena idea filtrar un fotograma antes de mostrarlo por pantalla, para después capturarlo y guardarlo en el disco duro, bien es verdad que esta táctica no acaba de funcionar del todo bien. draw() y movieEvent() son dos procesos independientes, que se ejecutan a velocidades independientes. Si implementamos un programa con un código similar al anterior, al consultar la secuencia de imágenes generada, probablemente veríamos que se han generado menos imágenes de las previstas. La razón es que el proceso de filtrar y guardar un fotograma es más lento que el de ir leyendo los fotogramas y, por lo tanto, sin sincronía entre estas dos acciones, el resultado no puede ser correcto.
Una forma muy sencilla de resolver este problema consiste en realizar todas las tareas dentro de la función movieEvent(), y hacer que la función draw() simplemente muestre en la ventana de la aplicación por qué fotograma va el proceso, sin que tenga que filtrar o guardar. Como se puede ver al ejecutar este código, como las acciones de filtrar y guardar necesitan tiempo, los fotogramas del vídeo se van leyendo más lentamente.
/**
 * Ejemplo 27: Filtro INVERT sobre un archivo de vídeo.
 * El programa aplica el efecto sobre los fotogramas del vídeo
 * y genera una secuencia de imágenes.
 *
 * Francesc Martí, martifrancesc@uoc.edu, 09-04-2018
 *
 */

// Importamos la librería Video
import processing.video.*;
Movie myMovie;

// Sirve para numerar las imágenes de la secuencia de imágenes.
intcont =0;

voidsetup() {
size(1280, 720);
  // Cargamos el fichero de vídeo
myMovie = new Movie(this, "Traffic.mp4");
  // Reproducimos el fichero de vídeo
myMovie.play();
}

void draw() {
  // Visualizamos los fotogramas en la ventana de la aplicación
image(myMovie, 0, 0);
}

// Esta función es llamada cada vez que un nuevo fotograma está disponible para leer
voidmovieEvent(Movie m) {
m.read();

  // Aplicamos un filtro de tipo INVERT al fotograma que vamos a visualizar
  // En este ejemplo usamos un filtro de tipo INVERT, pero se podría
  // implementar cualquiera de los efectos vistos durante el módulo
myMovie.filter(INVERT);

  // Guardamos en el disco duro el fotograma actual filtrado
myMovie.save(sketchPath("")+ "Filtered_Files/test" + nf(cont, 3) + ".png");
cont++;
}
Al finalizar el programa, si consultamos el directorio «Filtered_Files», podremos ver que se ha generado toda la secuencia de imágenes correctamente.
Para acabar, análogamente al primer método que hemos descrito, tendríamos que importar la secuencia dentro del editor de vídeo, asegurarnos de que la velocidad de reproducción de la secuencia es correcta y exportarlo de nuevo –añadiendo la pista de audio– en el formato de vídeo que más nos interese. Nuevamente, el resultado obtenido es un archivo de vídeo con el efecto de Processing aplicado.

Actividades

Actividades del apartado 1
1. Escribid un programa en Processing que muestre en la ventana de la aplicación cuatro imágenes, todas con las mismas dimensiones (podéis tomar libremente cuatro imágenes de la teoría). Las imágenes se tendrán que mostrar de una en una y, al clicar sobre una imagen, mostraremos la siguiente, de forma indefinida.
Además, la ventana de la aplicación se tendrá que ajustar a las dimensiones de las imágenes. Es decir, si las dimensiones de la imagen son 300 × 200 píxeles, el tamaño de la ventana también tendrá que ser 300 × 200 píxeles.
2. Escribid un programa en Processing que muestre en la ventana de la aplicación cuatro imágenes, todas con las mismas dimensiones. La primera imagen se mostrará al pulsar la tecla [1], la segunda imagen al pulsar la tecla [2], etc.
Análogamente al ejercicio anterior, la ventana de la aplicación se tendrá que ajustar a las dimensiones de las imágenes.
3. Sobre la imagen dada «car.png», ¿cuáles son los valores RGB del punto (230, 50)? Escribid un programa en Processing que calcule y muestre por pantalla este valor.
4. Buscad información sobre la función brightness() de Processing y calculad el nivel de gris del píxel (100, 93) de la imagen «faces.png» usando esta función.
5. Escribid un programa en Processing que detecte todos los puntos de la imagen «car.png» que tengan nivel de rojo más pequeño de 100. Cambiad el valor de rojo de estos puntos a 0 y mostrad el resultado en la ventana del programa.
Por ejemplo, si detectamos que los valores RGB de un punto de la imagen «car.png» es (90, 250, 48) tenemos que modificar el valor a (0, 250, 48).
Al pulsar la tecla [A] el programa tendrá que guardar el resultado en un nuevo archivo de nombre «car2.png» en el disco duro.
6. Utilizando la función filter() de Processing, escribid un programa en Processing que realice las siguientes transformaciones sobre la imagen «car.png»:
a) Binarización b) Dilatación c) Erosión d) Inversión
Inicialmente, el programa tiene que mostrar la imagen «car.png» ajustada a la ventana de la aplicación, de modo que cada vez que se haga clic sobre esta ventana se muestre la imagen con uno de los filtros.
7. Con ayuda del código de la página https://processing.org/examples/histogram.html, ampliad el código del ejemplo 4, de manrta que también se muestre en la ventana de la aplicación el histograma de la imagen transformada a escala de grises.
Actividades del apartado 2
1. a) Escribid un programa en Processing que muestre por pantalla el resultado de restar píxel a píxel la imagen «30.png» a la imagen «28.png». Haced que la ventana de la aplicación muestre solo la nueva imagen que se genera.
b) Realizad la misma operación en Photoshop y comparad los resultados.
2. a) Escribid un programa en Processing que convierta la imagen RGB «car.png» a escala de grises usando los tres métodos descritos en https://www.rapidtables.com/convert/image/rgb-to-grayscale.htm. El programa tendrá que mostrar las cuatro imágenes (original y en escala de grises) en la ventana de trabajo a la vez para poder comparar las diferencias.
b) En Photoshop, cambiad la imagen original «car.png» a modo escala de grises con «Imagen > Modo > Escala de grises» y comparad el resultado con los resultados del apartado anterior. ¿Cuál de los métodos anteriores genera un resultado más parecido al de Photoshop?
3. Implementad un programa en Processing que aplique sobre la imagen «27.png» la transformación puntual determinada por la curva
17067_m5_026.jpg
donde el valor de «Gris 1» es 30 y el valor de «Gris 2» es 127.
4. Modificad el código del ejemplo 9 de forma que la curva que define la transformación puntual de aclaramiento y oscurecimiento por partes tenga 4 «partes». Aplicad el programa sobre la imagen «4.1.02.png» y mejorad su contraste.
Actividades del apartado 3
1. Implementad un programa en Processing que sea capaz de aplicar todas las máscaras vistas en el apartado 3 en la imagen «4.2.06.png», de manera que, si el usuario pulsa la tecla [1], la ventana de la aplicación muestre la imagen de la figura 11, si pulsa [2], la imagen de la figura 12, si pulsa [3] la de la figura 13, etc.
2. Haced que el programa anterior guarde todas las imágenes de las transformaciones efectuadas y comparad los resultados obtenidos con los resultados de Photoshop aplicando las mismas máscaras de convolución. Realizad estas comparaciones usando histogramas.
Actividades del apartado 4
1. En Photoshop, realizad las mismas operaciones de erosión y dilatación que las efectuadas sobre la imagen «faces.png» en los ejemplos 15 y 16, y deducid cuál es la forma y dimensión del elemento estructurante utilizado en Processing.
2. Programad un filtro de erosión con Processing, con un elemento estructurante cuadrado de dimensiones 3x3, de manera análoga a como hemos programado las transformaciones puntuales y las espaciales lineales en los apartados 1, 2 y 3 (es decir, sin usar la función filtre()).
3. Desarrollad un programa similar al programa del ejercicio 2, pero basado en una transformación espacial no lineal dilatación.
4. Haced un programa en Processing que aplique un filtro de mediana a una imagen dada. Para programar el algoritmo de esta transformación, y poder ordenar un conjunto de píxeles por nivel de gris, puedes usar la función sort() de Processing.
Actividades del apartado 5
1. a) En Photoshop, sobre la imagen «grid.png», aplicad los tres métodos de diezmado estudiados, de forma que se reduzca a la mitad el tamaño de la imagen. ¿Qué método ofrece mejores resultados?
b) Escribid un programa en Processing que haga la misma operación con la función scale(). ¿Qué método de diezmado usa esta función?
c) Repetid el punto anterior utilizando el método resize(). ¿Qué método de diezmado usa esta función? ¿Es el mismo que el de la función scale()?
2. Escribid un programa en Processing que muestre por pantalla la imagen «peppers.png» y que efectúe una rotación de 45º de la imagen cada vez que se haga clic sobre ella. Aplicadle también una transformación scale(), cuando sea necesario, para hacer que la imagen siempre se muestre entera en la ventana de la aplicación.
3. Escribid un programa en Processing que efectúe las transformaciones necesarias en las imágenes «28.png» y «27.png» para que se muestren por pantalla de manera similar a la imagen que vemos a continuación.
17067_m5_025.jpg
Las medidas reales de la imagen grande tienen que ser 768×768 píxeles y las de la imagen pequeña 128×128, y la imagen interior tiene que estar a 20 píxeles de los bordes más cercanos.

Bibliografía

Greenberg, I.; Xu, D.; Kumar, D. (2013). Processing: creative coding and generative art in processing 2. Berkeley, California: Friends of ED.
Reas, C.; Fry, B. (2014). Processing: A programming handbook for visual designers and artists. Cambridge, Massachusetts: MIT Press.
Shiffman, D. (2015). Learning processing: A beginner's guide to programming images, animation, and interaction. Burlington, Massachusetts: Morgan Kaufmann.