Making of "ESP32 Deus Ex"


Hoy comparto un proyecto personal con un ESP32 y una pantalla TFT de 320x240px, consistente en hacer un avatar para interactuar con el usuario a partir de partículas, que puede manifestar expresiones, parpadear, desintegrarse y volverse a formar.

Existen muchos proyectos para dotar de cara o expresiones a un robot mediante alguna combinación de pantalla y procesador como Arduino, ESP32 o similar. Yo mismo tengo alguna librería

Pero, cansado de "librerías graciosas" me apetecía hacer algo diferente y más impresionante. Así que, inspirado en el Deus Ex Machine, el "enemigo" final de la película de "Matrix"

Este personaje es el interlocutor que habla en nombre de las máquinas. Consiste en una cara (en teoría, una cara de bebe) formado por muchas máquinas individuales. Puede mostrar emociones, hablar, mover los ojos, etc. Y tiene el efecto genial de poder formarse o deshacerse a partir de las máquinas que lo forman.

Anuncio:

El objetivo es hacer algo similar, dentro de las capacidades de un microprocesador como el ESP32, y que funcione "de verdad". Lógicamente la cara de la película es una animación hecha con mucho trabajo por los artistas, y que no funciona "de verdad". Es únicamente un video.

Es el típico proyecto de prueba, en el que es más difícil descubrir como conseguir un efecto como el que queremos, que programarlo. Aquí tenéis el resultado final del proyecto (que, por cierto, queda más chulo en directo que en vídeo)

Así que vamos con el "making of" del proyecto, con las pautas y pasos para hacerlo. También comparto una gran parte del código, por si alguno se anima y quiere replicar el proyecto o hacer algo similar.

Estado de la máquina

Los primero que tenemos que tener en cuenta es que la máquina tiene tres estados distintos.

  • Mantener cara, corresponde a la cara formada y animándose
  • Formando, las partículas se están desplazando para formar la cámara
  • Movimiento libre, las partículas vuelan por la pantalla

Para eso tenemos una enumeración y una variable de estado. Veremos más adelante donde afecta en el comportamiento del programa.

enum PARTICLE_STATUS
{
	MANTAIN_FACE,
	FORMING,
	FREE_MOVE,
};

PARTICLE_STATUS particle_status = PARTICLE_STATUS::MANTAIN_FACE;

Estructura básica

La estructura básica del programa son dos funciones. Una de render, que se ejecuta en el Core 0, y una de actualización , que se ejecuta en el Core 1.

Por tanto, tenemos algo así.

void taskUpdate(void* pvParameters)
{
    while(true)
    {
        
        Update();
    }
}

void Setup()
{
    // .. Inicializar hardware

    Init();

    xTaskCreatePinnedToCore(taskUpdate, "update", 10000, NULL, 0, NULL, 1);  
}

La función de render es muy sencilla. Para cada partícula, dibujamos un pequeño circulo en las coordenadas de la partícula. Algo así.

	void Render(MultiSpriteBand& sprite, uint8_t spriteNum)
	{
		sprite.clear();
		for (auto i = 0; i < NUM_PARTICLES; i++)
		{
			if (particles[i].Lifetime > 0)
				if (sprite.fillCircle(particles[i].Render.X, particles[i].Render.Y, 1, particles[i].Color);
		}
	}
	

Por su parte, en el loop tenemos únicamente la función de actualizar el dispositivo, que básicamente lee las pulsaciones en los botones botones del dispositivo.

A continuación, si hemos pulsado un botón, cambiamos el estado de la cara. Esto lo hago únicamente a efectos de la demo. En un robot de verdad, estas transiciones se dispararían cuando quisiéramos (cuando el robot se cayera, al encender, al recibir una señal, etc).

void loop()
{
	M5.update();

	if (M5.BtnA.wasPressed())
	{
		if (particle_status == PARTICLE_STATUS::MANTAIN_FACE)
		{
		    //..
		}
		else if (particle_status == PARTICLE_STATUS::FREE_MOVE)
		{
			//..
			particle_status = PARTICLE_STATUS::FORMING;			
		}
	}

    Render();
}

Definición de partícula

La parte fundamental del programa va a ser una particula. Que es un elemento sencillo que tiene la siguiente definición.

class Particle
{
public:
	Point2D Origin;
	Point2D Position;
	Point2D Render;

	Vector2D Speed;
	Vector2D Acceleration;

	uint16_t Color;
	uint8_t Lifetime = random(5, 20);

	void Update()
	{
		Speed += Acceleration;
		Position += Speed;

		if (particle_status == PARTICLE_STATUS::MANTAIN_FACE)
		{
			if (Lifetime > 0) Lifetime--;
		}
	}
};

Cómo vemos, tenemos tres "posiciones" representadas por puntos en 2D. Veremos cuál es la función de cada una de ellas más adelante. Pero, a modo de resumen.

  • Origen, es la posición que ocupa dentro de la cara
  • Posición, la posición que ocupa actualmente
  • Render, la posición en la que se va a mostrar en la pantalla

Por supuesto, también tenemos la velocidad, aceleración, color, y vida de la partícula. Por último tenemos una función Update, que simplemente actualiza la cinemática de la partícula.

Crear particulas

En el proyecto tenemos un vector de 2000 partículas. Para conseguir el efecto que queremos, debemos posicionar estar partículas para que formen la cara.

Para ello, en primer lugar he generado un mapa de color, superponiendo 5 fotogramas del video, con opacidad al 20%. De esta forma calculamos la media de los frames. Posteriormente, filtro de mediana y rescalado a 320x240px. Queda algo así.

Por otro lado, hacemos una imagen similar en blanco y negro. Esta imagen corresponde con el mapa de densidad de partículas. Donde sea más blanco habrá más particulas, y donde sea negro ninguna.

Podría haber empleado la misma imagen para color y densidad. Pero tenerlas separadas me permite jugar con la densidad de partículas de forma independiente al color.

Ahora tenemos que generar las 2000 partículas, según el mapa de densidad. Para ello, hacemos una función que crea particulas de forma aleatoria por la pantalla. A continuación, genera un número aleatorio de 0 a 255. Si el valor del pixel del mapa de densidad es superior al número aleatorio, se crea la partícula. Si no, se repite el proceso.

void GenerateParticle(int index)
{
	bool generated = false;
	while (generated == false)
	{
		auto x = random(0, 319);
		auto y = random(0, 239);

		auto target = depth_map_data[x + 320 * y];
		auto dice = random(0, 255);

		if (target > dice)
		{
			particles[index].Origin.X = x;
			particles[index].Origin.Y = y;
			particles[index].Position.X = x;
			particles[index].Position.Y = y;
			particles[index].Acceleration = 0;
			particles[index].Color = color_data[x + 320 * y];
			particles[index].Lifetime = random(15, 45);
			generated = true;
		}
	}
}

void Init()
{
	ComputeSpeedMap();

	int count = 0;

	for (auto i = 0; i < NUM_PARTICLES; i++)
	{
		GenerateParticle(i);
	}
}

Y con esto, conseguimos dibujar la imagen original mediante partículas, cuya densidad viene dada por el mapa de densidad (obvio) y el color por el mapa de color (obvio again). Una cosa así.

Calculando velocidad particulas

Una parte del efecto es que, al estar la cara formada, las partículas deben desplazarse según los contornos de la cara. De esta forma consiguen la sensación de que la cara está formado por partículas.

Probe varias formas para calcular este movimiento, que no supusiera una gran carga para el procesador, y a la vez el efecto quedara bien. Al final el mejor compromiso entre calidad de efecto / tiempo de cálculo fue que cada partícula se desplazara a su vecina con mayor densidad.

Para evitar tener que realizar esta cálculo cada frame, este mapa de velocidades se calcula una única vez al iniciar el dispositivo.

void ComputeSpeedMap()
{
	for (auto x = 1; x < 320 - 1; x++)
	{
		for (auto y = 1; y < 240 - 1; y++)
		{
			uint8_t direction = 0;
			uint8_t max = depth_map_data[(x + 1) + 320 * y];

			if (depth_map_data[(x + 1) + 320 * (y + 0)] > max)
			{
				direction = 1;
				max = depth_map_data[(x + 1) + 320 * (y + 0)];
			}

			if (depth_map_data[(x + 0) + 320 * (y + 1)] > max)
			{
				direction = 2;
				max = depth_map_data[(x + 0) + 320 * (y + 1)];
			}

			if (depth_map_data[(x - 1) + 320 * (y + 1)] > max)
			{
				direction = 3;
				max = depth_map_data[(x - 1) + 320 * (y + 1)];
			}

			if (depth_map_data[(x - 1) + 320 * (y + 0)] > max)
			{
				direction = 4;
				max = depth_map_data[(x - 1) + 320 * (y + 0)];
			}

			if (depth_map_data[(x - 1) + 320 * (y - 1)] > max)
			{
				direction = 5;
				max = depth_map_data[(x - 1) + 320 * (y - 1)];
			}

			if (depth_map_data[(x + 0) + 320 * (y - 1)] > max)
			{
				direction = 6;
				max = depth_map_data[(x + 0) + 320 * (y - 1)];
			}

			if (depth_map_data[(x + 1) + 320 * (y - 1)] > max)
			{
				direction = 7;
				max = depth_map_data[(x + 1) + 320 * (y - 1)];
			}

			speed_map[x + 320 * y] = rst;
		}
	}
}

Ahora en cada frame, mientras la cara se mantiene, cada partícula coge la velocidad del mapa de velocidades calculado. (el código no es especialmente limpio pero ¡eh! era domingo, y tampoco merece la pena mucho más)

void SetSpeed(Particle& particle)
{
	int x = particle.Position.X;
	int y = particle.Position.Y;

	auto direction = speed_map[x + 320 * y];

	if (direction == 0)
	{
		particle.Speed.X = 1.0f;
		particle.Speed.Y = 0.0f;
	}
	if (direction == 1)
	{
		particle.Speed.X = 0.7f;
		particle.Speed.Y = 0.7f;
	}
	if (direction == 2)
	{
		particle.Speed.X = 0.0f;
		particle.Speed.Y = 1.0f;
	}
	if (direction == 3)
	{
		particle.Speed.X = -0.7f;
		particle.Speed.Y = 0.7f;
	}
	if (direction == 4)
	{
		particle.Speed.X = -1.0f;
		particle.Speed.Y = 0.0f;
	}
	if (direction == 5)
	{
		particle.Speed.X = -0.7f;
		particle.Speed.Y = -0.7f;
	}
	if (direction == 6)
	{
		particle.Speed.X = 0.0f;
		particle.Speed.Y = -1.0f;
	}
	if (direction == 7)
	{
		particle.Speed.X = 0.7f;
		particle.Speed.Y = -0.7f;
	}
}

Esto tiene la parte negativa de que, pasado unos cuantos frames, las partículas se habrán desplazado a los máximos locales del mapa de densidad, dejando el resto de la cara sin partículas.

Este es el motivo por el que cada partícula tiene una vida, con un cierto grado de aleatoriedad. Las particulas se desplazan según el mapa de velocidad pero, pasado unos cuantos frames, son eliminadas y reaparecen en una nueva posición de la cara.

Al ser tantas partículas y las vidas aleatorias, la desaparición y aparición no se percibe. En su lugar, la sensación es de un flujo continuo de particulas a lo largo de los contornos de la cara. Aquí tenéis un detalle del efecto.

La función Update

Con todo lo dicho anteriormente, ya podemos esbozar la función principal de Update del proyecto.

void Update()
{
	for (auto i = 0; i < NUM_PARTICLES; i++)
	{
        auto particle = particle[i];
        
		if (particle_status == PARTICLE_STATUS::MANTAIN_FACE)
		{
			SetSpeed(particle);
		}
		else if (particle_status == PARTICLE_STATUS::FORMING)
		{
			auto vec = particle.Origin - particle.Position;
			auto normalized = vec.Normalize();

			particle.Acceleration = normalized * 0.5f;
			particle.Speed *= SPEED_DISSIPATION;
        }
		else if (particle_status == PARTICLE_STATUS::FREE_MOVE)
		{
			particle.Acceleration = 0.0f;
			centerForce.Apply(particle);
			force.Apply(particle);
			force2.Apply(particle);
			force3.Apply(particle);
		}

		particle.Update();
		if (particle.Lifetime == 0)
		{
			GenerateParticle(i);
		}
	}

	for (auto i = 0; i < NUM_PARTICLES; i++)
	{
		particle.Render = particle.Position;

		ComputerWrapper(particle, MouthLeft);
		ComputerWrapper(particle, MouthRight);
		ComputerWrapper(particle, EyeLeft);
		ComputerWrapper(particle, EyeRight);
	}
}

Como vemos, si la cara están formada y manteniéndose, simplemente actualizamos la velocidad de partícula según el mapa de velocidades precalculado.

Faltan los dos modos de formación y modo libre, en los que la velocidad de la particula es, para resumir, es "la que le da la gana a la partícula" (para entendernos).

Para conseguir el efecto de formarse de la cara, la particula está desplazándose libremente y es atraida por el punto que debería ocupar en la cara. No obstante, si solo fuera atraida por la misma, acabaría haciendo órbitas en torno al punto.

De forma que, para que converja, metemos un factor de disipación de velocidad. Este factor reduce la velocidad de la partícula cada frame, a la vez que sumamos una velocidad apuntando a su origen. De esta forma "cae" en espiral hacía su posición final.

Por último, el modo libre o de "deshacer" la cara es el más sencillo. Simplemente dejamos que la particula vaya a su velocidad, y la atraemos con unas fuerzas puntuales.

Fuerzas

La fuerza para el modo libre es muy sencilla, y la tenemos en la siguiente clase. Básicamente es un atractor tipo gravedad normal y corriente.

class ForcePuntual
{
public:
	Point2D Origin;

	float Force;
	bool IsActive;
	bool IsVisible = true;

	ForcePuntual(float x, float y, float force)
	{
		Origin.X = x;
		Origin.Y = y;
		Force = force;
	}

	void Apply(Particle& particle)
	{
		Vector2D vector = particle.Position - Origin;
		float dist = vector.Length();
		float force_scaler = Force / dist / dist;
		particle.Acceleration += vector * force_scaler;
	}
};

En el modo libre tengo cuatro fuerzas puntuales, tres de atracción puestas en puntos "cómo me ha dado" de la pantalla. El último es un punto central en la cara, que favorece la desintegración de la cara, y que hacía que el efecto quedara mejor.

Wrappers

Nos falta ver un último elemento, empleado para la animación de la cara. Mientras la cara está formada, queremos que pueda abrir y cerrar los ojos y la boca para articular expresiones.

Para eso tenemos una clase Wrapper, que es la siguiente.

class Wrapper
{
public:
	Point2D Position;

	float RangeNear;
	float RangeFar;

	Point2D Scaled;
	Point2D Displacement;

	void Apply(Particle& particle)
	{
		if (particle_status != PARTICLE_STATUS::MANTAIN_FACE) return;

		float delta = particle.Render - this.Position;
		float dist = delta.Length();

		if (dist < RangeFar)
		{
			auto mix = 1.0f;
			if(dist > RangeNear) mix = (dist - RangeNear) / (RangeFar - RangeNear);
			mix = mix * mix;

			particle.Render = (1 - mix) * (this.Scaled * dist + this.Position) + mix * particle.Render + Displacement;
		}
	}
};

En resumen, el Wrapper modifica la posición en la que se representan las particulas. El Wrapper tiene un centro, dos áreas de influencia, y escala y/o desplaza las particulas. Aquí tenéis un detalle del efecto.

La fuerza del efecto (escalado y/o desplazamiento) está dado por la distancia de la particula al centro del Wrapper, y los rangos. Por debajo RangeNear el efecto es 1.0, y de ahí varia linealmente hasta RangeFar.

En la cara tenemos dos Wrapper, uno en cada ojo, y otros dos en la boca ligeramente separados entre sí. Si ponía solo Wrapper en la boca me hacía "morritos de pato", y el efecto quedaba mejor con dos.

Lo último a destacar, el Wrapper afecta a la posición donde se renderiza la particula, no a su posición real. De esta forma se puede animar y deformar la cara, sin realmente alterar la dinámica de las partículas.

Hasta aquí el making of. Espero que os haya gustado y os animo a que hagáis vuestra propia versión. Nos vemos en el próximo proyecto 😉

Anuncio:

Previous Sensor de calidad ambiental con Arduino y BME680
Next Convertir imagenes para Arduino con Lcd Image Conveter