Hacer un robot que reaccione a los gestos de la cara con visión por computador y Arduino

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:

Haz click para ver el vídeo en 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();
}

Elegir estructura (chasis) para un robot casero

Cuando me planteo hacer cualquier pequeño robot que se mueva siempre me encuentro con el problema de que no tengo más que una ligera idea mecánica. Hay montones de modelos caseros y no es fácil elegir cual se ajusta mejor a tus objetivos y capacidades. Así que voy a intentar explicar rápidamente que tipos de configuraciones son las más típicas en robots caseros. Aviso que es una clasificación basada en mi experiencia en robótica, por lo que es bastante informal.

Ruedas:

Vamos a empezar por que zapatos le calzamos, los tipos de ruedas mas usados.

  • Ruedas motrices: Son las que aportan movimiento al vehículo, van conectadas directamente al motor y al girar este estas giran moviendo el vehículo.
  • Ruedas directrices: son las que giran para dirigir el vehículo. Por ejemplo en un coche convencional son las que giran al girar el volante, vamos, las delanteras. A su vez pueden ser motrices por ejemplo en un 4×4.
  • Ruedas fijas: Son ruedas que giran libremente solo hacia delante y hacia atrás. Pueden ser motrices.
  • Ruedas locas: Se puede decir que estas se dejan llevar, son ruedas que ademas de girar sobre si mismas en el eje horizontal también permiten libre movimiento en el vertical. Aportan simplemente estabilidad evitando que el vehículo vuelque sin oponer muchas resistencia a los giros. Son típicas en los robots de tres ruedas. Un ejemplo son las ruedas de los carritos de supermercado. Otro tipo muy usado son una esfera que rota libremente en cualquier dirección.Un ejemplo lo tenéis en los antiguos ratones mecánicos o en las cabezas de los bolis Bic.
  • Omnidireccionales: Al igual que las anteriores son ruedas que pueden desplazarse en ambos ejes pero que a su vez pueden ser motrices. Ejemplos son las omni wheel o algunos robot que se desplazan sobre esferas. Tiene la ventaja de que pueden desplazarse en cualquier dirección, pero suelen ser complicados de realizar. Un caso especial y que generalmente queda bastante llamativo son los robots bola. Son bolas con el robot por dentro. El problema que les veo es que es complicado colocar sensores externos.

Configuraciones:

Una vistos los tipos de zapatos vamos a ver las configuraciones más usadas:

  • Dos ruedas motrices, una o dos ruedas locas: Habitual en los kits de iniciación. Es una configuración sencilla y barata. Dos ruedas motrices de gran tamaño a los lados con una rueda loca delante o detrás solo para evitar que esa parte toque el suelo. Es fácil de programar. Cambiarlo de dirección es tan solo hacer girar cada rueda en un sentido, lo que facilita enormemente los cálculos. Su punto débil es que las ruedas local pueden trabarse muy fácilmente ante los obstáculos.
  • Cuatro ruedas motrices: Distribuidas dos a dos como en un coche, solo que ninguna es directriz. Gira como el caso anterior haciendo girar las dos ruedas de un lateral en un sentido y las otras dos del otro en otro. Lo normal es que lleve cuatro motores lo que tiene la ventaja de que cada rueda puede girar a una velocidad diferente, lo cual suena muy bien pero es más difícil de aprovechar de lo que parece y si no se hace bien puede ocurrir que se entorpezcan entre ellas. No es mucho más complicado de programar que el anterior, pero a cambio si que requiere más electrónica y un montaje más complicado.
  • Dos ruedas motrices, dos ruedas fijas: A menos que vaya a ir la mayor parte del tiempo en linea recta o sobre raíles es una mala idea. La única ventaja sobre las ruedas locas del primer caso es que las fijas permiten moverse en terrenos más abruptos
  • Dos orugas: La forma correcta de hacer el caso anterior. La programación y electrónica es la de un vehículo de dos ruedas motrices sin ruedas de dirección. La complejidad viene en la parte de las orugas que mecánicamente es mucho más complicado que simplemente poner unas ruedas. En suelo liso posiblemente sea la opción que más batería consume.
  • Dos ruedas motrices: Es como el primer caso pero sin rueda loca. La idea es que si las ruedas son grandes y el cuerpo pequeño este no tocara el suelo. Es importante distribuir bien el peso a lo largo del eje de la ruedas.
  • Dos ruedas fijas motrices y dos directrices: Idéntica a cualquier coche de los que hay por la calle (también se conoce como configuración Ackerman). Requiere al menos un motor para las ruedas motrices y un servo para girar la directrices, aunque a veces se reemplaza por un motor a cambio de perder precisión en el giro que pasa a ser todo o nada sin poder elegir una posición intermedia. Posiblemente sea el más complicado para calcular los movimientos que tiene que hacer para llegar a un punto.
  • Dos ruedas fijas motrices y una directriz: La idea es muy parecida a la anterior. El resultado es un triciclo, con una rueda directriz. Al igual que la configuración Ackerman requiere al menos un motor para las ruedas motrices y un servo para girar la directriz. Su ventaja respecto al anterior modelo es que permite giros mucho mas cerrados

Distribuir el peso:

Un consejo para distribuir el peso. Lo más cerca del suelo y centrado posible. La distribución del peso afecta a muchas más cosas de las que parecen en un principio. Por ejemplo a la adherencia de las rueda, al centro de giro o a la estabilidad del vehículo.

Lo que más suele pesar son las baterías, así que su colocación es fundamental. Contra más bajas se pongan más estabilidad tendrá el vehículo y más difícil será que vuelque. Hay que tratar de colocarlas lo mas centradas posibles respecto respecto al centro de giro del vehículo y respecto a los ejes. Descompensar un lado puede traer problemas en los giros desplazando el centro de giro o restando adherencia a las ruedas.