En otros posts ya hemos visto como controlar un robot (o un brazo robótico en nuestro caso) con Arduino desde el navegador usando distintos medios de entrada como voz o gestos. La intención de este post es controlarlo con los gestos de la cara. Que reaccione a nosotros de diversas formas. Para ello, como en casos anteriores, usaremos Processing, en concreto su versión en Javascript, P5.js con la librería ML5.js que integra varios modelos de Tensorflow.js.
Para ver cómo funciona la parte de Arduino podéis ver este post y para el control desde el navegador este otro.
La idea
Para este proyecto vamos a usar facemesh, esta red neuronal estima la posición de distintos puntos clave de la cara (468 puntos) en tres dimensiones a partir de una imagen en dos dimensiones. Estos puntos forman una malla sobre la cara. Los puntos se concentran en zonas representativas de la cara. Delimitan zonas como las cejas, nariz, labios, ojos, …. Calculando las posiciones relativas de estos puntos uno respecto al otro
deducimos que expresión facial tienes, una vez veamos la expresión lo que haremos será que el robot reaccione a la misma.
El primer gesto es que el robot te siga con la mirada. ¿Mirada? ¿No era un brazo? Si, y como podéis ver en la foto inferior le he puesto ojitos de cartón. Para ello tomara como referencia el punto medio entre los dos ojos midwayBetweenEyes y según se desplace por la pantalla girara la base del brazo en una dirección u otra. Para hacerlo más sencillo la cámara va a estar justo encima del brazo por lo que tomara el punto central de la pantalla como posición inicial, a partir de ahí girará a derecha o izquierda. La conversación de grados a pixels habrá que ajustarla para cada cámara. Ya que dependerá del ángulo de visión de la misma. Trabajaremos en bloques de 20 pixeles. Así evitaremos temblores en el movimiento. Si intentamos ajustar «al pixel» se producirán temblores ya que debido al ruido el punto dónde lo detecta puede variar.
[centroOjosX, centroOjosY] = predictions[i].annotations.midwayBetweenEyes[0];
...
function gestoCentroCara(){
//ang.del brazo con la cara a la izquerda
let minAng = 30;
//ang.del brazo con la cara a la derecha
let maxAng = 150;
//pixeles por parte
let pixelsParte = 20;
//partes en laimagen
let partes = 640 / pixelsParte;
//En que parte esta la cara
let parteCentroOjos = Math.floor(centroOjosX/pixelsParte);
//Grados que mueve el brazo por parte
let gradosParte = (maxAng-minAng) / partes;
//Grados que hay que mover el brazo
anguloBase = maxAng - Math.floor(gradosParte * parteCentroOjos);
}

El siguiente gesto es acercar «la cabeza» cuando te acerques, ¿cómo haremos esto? Con el tamaño de la cabeza (cuidado con los cabezones), lo que haremos será medir el ancho del cuadrado que contiene la cabeza y según la misma calcularemos aproximadamente la distancia a la que está. De tal forma que si el cuadrado se incrementa el robot «sentirá» que te estás acercando.
[topLeftX, topLeftY] = predictions[i].boundingBox.topLeft[0];
[bottomRightX, bottomRightY] = predictions[i].boundingBox.bottomRight[0];
faceWidth = bottomRightX - topLeftX;
faceHeight = bottomRightY - topLeftY;
...
function gestoDistanciaCabeza(){
if(faceWidth > 320){
anguloHombro = 140;
} else if(faceWidth > 200){
anguloHombro = 110;
} else {
anguloHombro = 90;
}
}
El último gesto es imitar los movimientos de la boca, de tal manera que cuando abres la boca abre la pinza y cuando cierras la boca cierra la pinza. Para ello miraremos los puntos que hay inferior y superior de los labios y calcularemos su distancia, pasado cierto límite se considera abierta.
[labioArribaX, labioArribaY] = predictions[i].annotations.lipsUpperInner[5];
[labioAbajoX, labioAbajoY] = predictions[i].annotations.lipsLowerInner[5];
...
function gestoBoca(){
if(labioAbajoY - labioArribaY > 15){
anguloMano = 10; //abierta
} else {
anguloMano = 35; //cerrada
}
}
Ejemplo de detección

Puedes ver cómo funciona todo esto en el siguiente video de mi canal de Youtube:
El código
La estructura de datos que nos devuelve faceMesh ante cada detección (puede detectar más de una cara pero nosotros leeremos solo la primera) es la siguiente:
faceInViewConfidence: 1 //confianza en el resultado
boundingBox: Object //esquinas del cuadrado de la cara
mesh: Array(468) //malla
scaledMesh: Array(468) //mala normalizada
annotations: Object //datos estructurados
Nosotros vamos a usar dos campos annotations, que nos devuelve las coordenadas de cada parte de la cara y boundingBox que nos define un cuadrado donde se encuentra la cara.
boundingBox: Object
topLeft: Array(1)
bottomRight: Array(1)
annotations:
silhouette: Array(36)
lipsUpperOuter: Array(11)
lipsLowerOuter: Array(10)
lipsUpperInner: Array(11)
lipsLowerInner: Array(11)
rightEyeUpper0: Array(7)
rightEyeLower0: Array(9)
rightEyeUpper1: Array(7)
rightEyeLower1: Array(9)
rightEyeUpper2: Array(7)
rightEyeLower2: Array(9)
rightEyeLower3: Array(9)
rightEyebrowUpper: Array(8)
rightEyebrowLower: Array(6)
leftEyeUpper0: Array(7)
leftEyeLower0: Array(9)
leftEyeUpper1: Array(7)
leftEyeLower1: Array(9)
leftEyeUpper2: Array(7)
leftEyeLower2: Array(9)
leftEyeLower3: Array(9)
leftEyebrowUpper: Array(8)
leftEyebrowLower: Array(6)
midwayBetweenEyes: Array(1)
noseTip: Array(1)
noseBottom: Array(1)
noseRightCorner: Array(1)
noseLeftCorner: Array(1)
rightCheek: Array(1)
leftCheek: Array(1)
Para calcular la distancia solo tomaremos los puntos X eY, ignoraremos el Z. Si OS preguntáis porqué no usamos esa Z, es porque en mis pruebas no da la profundidad respecto a la cámara, sino respecto al resto de puntos de la cara.
Código HTML:
<html>
<head>
<meta charset="UTF-8" />
<title></title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.9.0/p5.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.9.0/addons/p5.dom.min.js"></script>
<script src="https://unpkg.com/p5-webserial@0.1.1/build/p5.webserial.js"></script>
<script src="https://unpkg.com/ml5@latest/dist/ml5.min.js"></script>
<style></style>
</head>
<script src="sketch.js"></script>
<body>
<h1>Control usando gestos</h1>
</body>
</html>
Código JS:
let facemesh;
let video;
let predictions = [];
let labioArribaX, labioArribaY;
let labioAbajoX, labioAbajoY;
let centroOjosX, centroOjosY;
let faceWidth, faceHeight;
let anguloBase = 90;
let anguloHombro = 90;
let anguloMano = 35;
let terminado = false;
let hayPrediccion = false;
//puerto serie
const serial = new p5.WebSerial();
function setup() {
createCanvas(640, 480);
portButton = createButton("Elegir puerto");
portButton.position(5, 5);
portButton.mousePressed(choosePort);
let botonApagar = createButton("Terminar");
botonApagar.position(300, 5);
botonApagar.mousePressed(() => {
terminado = true;
anguloBase = 90;
anguloHombro = 90;
anguloMano = 35;
send("Q");
console.log("Brazo en posisicion incial");
});
video = createCapture(VIDEO);
video.size(width, height);
inicializarSerial();
facemesh = ml5.facemesh(video, modelReady);
//si hay datos de una cara actualizar predictions
facemesh.on("predict", results => {
predictions = results;
});
video.hide();
//enviar datos al brazo cada 250ml
setInterval(actualizarAngulos, 250);
}
function modelReady() {
console.log("Model ready!");
}
function draw() {
image(video, 0, 0, width, height);
dibujarPuntos()
if(hayPrediccion){
gestoCentroCara()
gestoDistanciaCabeza()
gestoBoca()
hayPrediccion = false
}
}
function dibujarPuntos() {
if(predictions.length > 0) {
let i = 0;//solo la primera prediccion
hayPrediccion = true;
[labioArribaX, labioArribaY] = predictions[i].annotations.lipsUpperInner[5];
[labioAbajoX, labioAbajoY] = predictions[i].annotations.lipsLowerInner[5];
[centroOjosX, centroOjosY] = predictions[i].annotations.midwayBetweenEyes[0];
[topLeftX, topLeftY] = predictions[i].boundingBox.topLeft[0];
[bottomRightX, bottomRightY] = predictions[i].boundingBox.bottomRight[0];
faceWidth = bottomRightX - topLeftX;
faceHeight = bottomRightY - topLeftY;
fill(0, 255, 0);
ellipse(labioArribaX, labioArribaY, 5, 5);
ellipse(labioAbajoX, labioAbajoY, 5, 5);
ellipse(centroOjosX, centroOjosY, 5, 5);
ellipse(centroOjosX, centroOjosY, 5, 5);
noFill();
rect(topLeftX, topLeftY, faceWidth, faceHeight);
}
}
function gestoCentroCara(){
//ang.del brazo con la cara a la izquerda
let minAng = 30;
//ang.del brazo con la cara a la derecha
let maxAng = 150;
//pixeles por parte
let pixelsParte = 20;
//partes en laimagen
let partes = 640 / pixelsParte;
//En que parte esta la cara
let parteCentroOjos = Math.floor(centroOjosX/pixelsParte);
//Grados que mueve el brazo por parte
let gradosParte = (maxAng-minAng) / partes;
//Grados que hay que mover el brazo
anguloBase = maxAng - Math.floor(gradosParte * parteCentroOjos);
}
function gestoDistanciaCabeza(){
if(faceWidth > 320){
anguloHombro = 140; //cerca
} else if(faceWidth > 200){
anguloHombro = 110; //medio
} else {
anguloHombro = 90; //lejos
}
}
function gestoBoca(){
if(labioAbajoY - labioArribaY > 15){
anguloMano = 10; //abierta
} else {
anguloMano = 35; //cerrada
}
}
function actualizarAngulos(){
if(!terminado){
send("S1:"+anguloBase);
send("S3:"+anguloHombro);
send("S4:"+anguloMano);
console.log(anguloBase, anguloHombro, anguloMano);
}
}
//-----PUERTO SERIE-------
//enviar datos al puerto serie
function send(cmd) {
serial.write(cmd+"\n");
}
//leer datos del puerto serie
function serialEvent() {
let readSerialStr = serial.readLine();
trim(readSerialStr);
if (readSerialStr) {
console.log(readSerialStr);
}
}
//incializar la conexion serie
function inicializarSerial() {
if (!navigator.serial) {
alert("WebSerial no sorportado. Prueba Chrome o Edge.");
}
serial.getPorts();
serial.on("noport", showPortButton);
serial.on("portavailable", openPort);
serial.on("requesterror", portError);
serial.on("data", serialEvent);
serial.on("close", closePort);
navigator.serial.addEventListener("connect", portConnect);
navigator.serial.addEventListener("disconnect", portDisconnect);
let statusText = "Puerto serie incializado<br>"
select('#status').html(statusText);
}
// Muestra la ventana de seleccion de puerto
function choosePort() {
console.log("ChoosePort");
showPortButton();
serial.requestPort();
}
//abrir conexion con puerto serie
function openPort() {
console.log("Abriendo puerto serie");
serial.open().then(initiateSerial);
function initiateSerial() {
console.log("Puerto serie abierto");
}
hidePortButton();
}
//Cerrar conexion con puerto serie
function closePort() {
console.log("Puerto serie cerrado");
serial.close();
showPortButton();
}
//Error con el puerto serie
function portError(err) {
alert("Serial port error: " + err);
showPortButton();
}
//Evento puerto serie conectado
function portConnect() {
console.log("Puerto serie conectado");
serial.getPorts();
hidePortButton()
}
//Evento puerto serie desconectado
function portDisconnect() {
serial.close();
console.log("Puerto serie desconectado");
showPortButton();
}
function showPortButton() {
portButton.show();
}
function hidePortButton() {
portButton.hide();
}
