Chismes del Lince 42: La neurona aprende tus gestos
El Lince de la Noche llegó a la roca de siempre con el móvil en una mano y la Heltec en la otra, muy satisfecho.
—Ya tengo mi neurona —anunció—. Sabe separar puntos rojos de azules. Ahora, puntos de verdad.
—¿Y de dónde los sacas? —preguntó la Gata Montesa.
—Del móvil. Lo muevo, y el movimiento se convierte en puntos.
La Gata se quedó pensando.
—Entonces cada gesto necesita dos números. ¿Cuáles?
El Lince miró el móvil como si fuera a contestarle.
—Cuánto se mueve y cuánto se inclina —dijo ella, para ahorrarle el trago—. Un vaivén se mueve mucho y se inclina poco. Un giro, al revés.
Introducción
En este chisme construimos una neurona con gestos: la misma neurona del chisme 41, pero con datos reales del acelerómetro del móvil.
Grabas varios vaivenes y varios giros, la neurona aprende a distinguirlos y la placa Heltec muestra en su pantalla la vuelta de entrenamiento, los errores, los pesos y lo que contesta.
Si no has hecho el chisme 41, empieza por él: allí se explica con calma qué es un peso, qué es el error y cómo se corrige la neurona. Aquí solo la aplicamos.
Cómo funciona la neurona con gestos
Todo ocurre en tres pasos.
- Del gesto a dos números. Al pulsar un botón de grabar suena un pitido y el móvil registra 1,5 segundos de acelerómetro. De ahí salen dos números entre 0 y 1: movimiento (cuánto varía la aceleración en el eje que más se mueve) e inclinación (cuánto cambia la dirección de la gravedad entre el principio y el final).
- La neurona del chisme 41. Cada gesto es un punto con esas dos coordenadas. La neurona corrige sus pesos igual que allí: vaivén es rojo y giro es azul.
- La placa solo muestra. El móvil hace todo el trabajo y le manda a la Heltec por Bluetooth mensajes cortos: la vuelta y los errores, los pesos y la respuesta.
Privacidad: los datos del movimiento no salen del móvil. Solo viajan a la placa esos mensajes de unos pocos caracteres.
La escala que convierte los m/s² en números entre 0 y 1 es una estimación: con otro móvil los puntos pueden salir algo más pegados a un lado. Se puede ajustar en el código.
Qué necesitamos
- Una placa Heltec LoRa 32 (ESP32) con su pantalla OLED y un cable USB. No hay que conectar nada más.
- Arduino IDE con las librerías Adafruit SSD1306 y Adafruit GFX, y la placa «ESP32 Dev Module» (paquete de Espressif 2.0.x).
- Un móvil Android con Chrome, que permite Bluetooth desde la página. En iPhone no funciona.
- Si al compilar sale «Sketch too big»: Herramientas, Partition Scheme, «Huge APP (3MB No OTA/1MB SPIFFS)».
El código de la placa
Solo la PARTE 1 es el chisme: qué mensajes llegan del móvil y qué guarda la placa. La pantalla y el Bluetooth no hace falta tocarlos. Guárdalo como chisme42-neurona-gestos.ino, cárgalo en la placa y abre el Monitor Serie a 115200 baudios si quieres ver los mensajes.
/*
Chisme 42 · La neurona aprende tus gestos
La neurona vive en el MOVIL: ahi se graban los gestos, se entrena y se adivina.
La placa NO sabe nada de neuronas: solo recibe lo que pasa y lo ensena en su
pantalla: la vuelta de entrenamiento, los errores, los pesos y la respuesta.
Este programa tiene 3 partes. Solo la PARTE 1 es el chisme:
PARTE 1 · RECIBIR LO QUE HACE LA NEURONA <- LEE ESTA. Que mensajes llegan.
PARTE 2 · LA PANTALLA <- NO ES NECESARIA. Solo escribe en la OLED.
PARTE 3 · EL BLUETOOTH <- Como en el chisme 40. No hace falta tocarla.
(y al final, setup() y loop())
NO HAY QUE CONECTAR NADA: solo la placa y el movil.
PUERTO SERIE: Herramientas -> Monitor Serie, a 115200 baudios.
Librerias: Adafruit SSD1306 y Adafruit GFX.
Placa en el Arduino IDE: "ESP32 Dev Module".
Pensado para el paquete de placas ESP32 de Espressif version 2.0.x.
Si al compilar dice "Sketch too big":
Herramientas -> Partition Scheme -> "Huge APP (3MB No OTA/1MB SPIFFS)".
*/
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
/* ============================================================
PARTE 1 · RECIBIR LO QUE HACE LA NEURONA (lo importante)
El movil hace todo el trabajo. A la placa le manda mensajes muy cortos:
"E12,3" = vuelta de entrenamiento 12, y la neurona aun falla 3 puntos
"W0.82,-0.40,0.10" = los pesos de la neurona: w1, w2 y el sesgo
"CAZUL" = la neurona ha contestado AZUL a la ultima pregunta
"R" = empieza de cero
La placa solo guarda los ultimos valores y los ensena.
============================================================ */
int vuelta = 0; // ultima vuelta de entrenamiento
int errores = -1; // puntos que aun falla (-1 = aun no se sabe)
float w1 = 0, w2 = 0, sesgo = 0; // los pesos de la neurona
bool hayPesos = false; // ¿ya hemos recibido los pesos?
String respuesta = ""; // lo ultimo que contesto la neurona
void atenderMensaje(String m) {
m.trim();
if (m.length() == 0) return;
char tipo = m[0];
String resto = m.substring(1);
if (tipo == 'R') { // R = vuelta a cero
vuelta = 0; errores = -1; hayPesos = false; respuesta = "";
} else if (tipo == 'E') { // E12,3
int coma = resto.indexOf(',');
if (coma > 0) {
vuelta = resto.substring(0, coma).toInt();
errores = resto.substring(coma + 1).toInt();
}
} else if (tipo == 'W') { // W0.82,-0.40,0.10
int c1 = resto.indexOf(',');
int c2 = resto.indexOf(',', c1 + 1);
if (c1 > 0 && c2 > c1) {
w1 = resto.substring(0, c1).toFloat();
w2 = resto.substring(c1 + 1, c2).toFloat();
sesgo = resto.substring(c2 + 1).toFloat();
hayPesos = true;
}
} else if (tipo == 'C') { // CAZUL
respuesta = resto;
}
Serial.println(String("Mensaje: ") + m);
}
/* ============================================================
PARTE 2 · LA PANTALLA (NO ES NECESARIA: lo importante llega por Bluetooth)
Arriba, la respuesta en grande. Debajo, vuelta, errores y pesos.
============================================================ */
#define OLED_SDA 21
#define OLED_SCL 22
#define OLED_RST 16
#define OLED_ADDR 0x3C
Adafruit_SSD1306 display(128, 64, &Wire, OLED_RST);
volatile bool conectado = false; // ¿hay un movil conectado?
void pintarPantalla() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(0, 0);
display.print(conectado ? "NEURONA - conectado" : "NEURONA - esperando");
display.setTextSize(2);
display.setCursor(0, 12);
display.print(respuesta.length() > 0 ? respuesta : String("..."));
display.setTextSize(1);
display.setCursor(0, 34);
if (errores >= 0) display.print(String("Vuelta ") + vuelta + " Errores " + errores);
else display.print("Sin entrenar");
display.setCursor(0, 44);
if (hayPesos) display.print(String("w1 ") + String(w1, 2) + " w2 " + String(w2, 2));
display.setCursor(0, 54);
if (hayPesos) display.print(String("sesgo ") + String(sesgo, 2));
display.display();
}
void arrancarPantalla() {
Wire.begin(OLED_SDA, OLED_SCL);
pinMode(OLED_RST, OUTPUT);
digitalWrite(OLED_RST, LOW); delay(20); digitalWrite(OLED_RST, HIGH);
display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR);
}
/* ============================================================
PARTE 3 · EL BLUETOOTH (como en el chisme 40)
La placa se anuncia como "Heltec-Neurona". Solo tiene un "buzon":
RX: por aqui LLEGAN los mensajes de la pagina ("E12,3", "W0.82,-0.40,0.10", "CAZUL", "R").
============================================================ */
#define SERVICE_UUID "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
#define UUID_RX "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
String mensajePendiente = ""; // lo llegado del movil (lo atiende loop())
volatile bool hayMensaje = false;
class AlConectar : public BLEServerCallbacks {
void onConnect(BLEServer *s) { conectado = true; }
void onDisconnect(BLEServer *s) { conectado = false; BLEDevice::startAdvertising(); }
};
class AlEscribir : public BLECharacteristicCallbacks { // llega algo desde la pagina
void onWrite(BLECharacteristic *c) {
mensajePendiente = String(c->getValue().c_str()); // lo guardamos; loop() lo atendera
hayMensaje = true;
}
};
void arrancarBluetooth() {
BLEDevice::init("Heltec-Neurona");
BLEServer *servidor = BLEDevice::createServer();
servidor->setCallbacks(new AlConectar());
BLEService *svc = servidor->createService(SERVICE_UUID);
BLECharacteristic *rx = svc->createCharacteristic(UUID_RX,
BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR);
rx->setCallbacks(new AlEscribir());
svc->start();
BLEAdvertising *pub = BLEDevice::getAdvertising();
pub->addServiceUUID(SERVICE_UUID);
pub->setScanResponse(true);
BLEDevice::startAdvertising();
}
/* ============================================================
setup() se ejecuta UNA vez al encender. loop() se repite sin parar.
============================================================ */
void setup() {
Serial.begin(115200);
arrancarPantalla();
arrancarBluetooth();
pintarPantalla();
Serial.println("Listo. Conecta el movil desde la pagina.");
}
unsigned long ultimaPantalla = 0;
void loop() {
if (hayMensaje) { // ¿ha llegado algo del movil?
hayMensaje = false;
atenderMensaje(mensajePendiente);
pintarPantalla(); // y lo ensenamos enseguida
ultimaPantalla = millis();
}
if (millis() - ultimaPantalla >= 500) { // si no, refrescamos cada medio segundo
ultimaPantalla = millis();
pintarPantalla();
}
delay(5);
}
La página web
La PARTE 1 es la neurona del chisme 41, sin cambios. La PARTE 2 convierte cada gesto en dos números. Las partes 3 y 4 son el dibujo y el Bluetooth y no hace falta tocarlas.
Guárdala como chisme42-neurona-gestos.html y súbela a un sitio con https (por ejemplo, el de tu centro), porque Chrome solo permite Bluetooth y sensores desde páginas seguras.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chisme 42 · La neurona aprende tus gestos</title>
<style>
/* Los colores, en un solo sitio: cámbialos y cambia toda la página */
:root { --fondo:#eef2ef; --texto:#10201c; --suave:#4d625b; --verde:#1f5b4a; --linea:#7b3fa0;
--aviso:#ffd23f; --tarjeta:#ffffff; --borde:#c5d2cc; --rojo:#d9480f; --azul:#1971c2; }
@media (prefers-color-scheme: dark) {
:root { --fondo:#0f1a17; --texto:#e8f1ed; --suave:#9db3aa; --verde:#5fc4a2; --linea:#c79bff;
--tarjeta:#16251f; --borde:#2d443b; --rojo:#ff8c5a; --azul:#5aa9ff; }
}
body { margin:0; padding:1.5rem 1rem; background:var(--fondo); color:var(--texto);
font:17px/1.4 system-ui, sans-serif; text-align:center; }
h1 { font-size:1.5rem; margin:0 0 .3rem; }
h2 { font-size:1.15rem; margin:1.6rem 0 .4rem; }
p { color:var(--suave); margin:.3rem auto; max-width:34rem; }
button { font:inherit; font-weight:700; padding:.65rem 1rem; margin:.25rem;
border:2px solid var(--verde); background:var(--verde); color:var(--fondo);
border-radius:.9rem; cursor:pointer; touch-action:manipulation; }
button.claro { background:transparent; color:var(--verde); }
button.marcado { outline:3px solid var(--linea); outline-offset:2px; }
button:disabled { opacity:.45; cursor:not-allowed; }
select { font:inherit; padding:.3rem; border-radius:.5rem; }
canvas#plano { width:100%; max-width:20rem; aspect-ratio:1; display:block; margin:.6rem auto;
background:var(--tarjeta); border:2px solid var(--borde); border-radius:.8rem;
touch-action:manipulation; }
details { max-width:34rem; margin:1.2rem auto; color:var(--suave); }
#simulado { display:none; background:var(--aviso); color:#10201c; font-weight:700; padding:.6rem 1rem;
border-radius:.9rem; max-width:34rem; margin:.8rem auto; }
#simulado.visible { display:block; }
#mensaje { font-weight:700; min-height:1.6rem; color:var(--texto); }
#respuesta { font-size:2.2rem; font-weight:700; color:var(--linea); min-height:3rem; }
#estadoNeurona { font-weight:700; color:var(--texto); }
.fila { max-width:34rem; margin:.5rem auto; display:flex; align-items:center; gap:.5rem;
justify-content:center; flex-wrap:wrap; }
.fila label { color:var(--suave); }
.oculto { display:none; }
.rojo { color:var(--rojo); font-weight:700; } .azul { color:var(--azul); font-weight:700; }
</style>
</head>
<body>
<h1>La neurona aprende tus gestos</h1>
<p>Graba varios vaivenes y varios giros con el móvil; la neurona aprende a distinguirlos y la Heltec te enseña lo que decide.</p>
<canvas id="plano" width="520" height="520" aria-label="Gráfico: cada gesto es un punto; la recta separa vaivén de giro"></canvas>
<p>Derecha: más movimiento. Arriba: más inclinación.</p>
<p id="sensor">Sensor de movimiento: comprobando…</p>
<div class="fila">
<button id="grabar0" class="claro">Grabar <span class="rojo">vaivén</span></button>
<button id="grabar1" class="claro">Grabar <span class="azul">giro</span></button>
</div>
<div class="fila">
<button id="unaVuelta">Una vuelta</button>
<button id="entrenar">Entrenar</button>
</div>
<div class="fila">
<button id="probarGesto" class="claro">Probar un gesto</button>
<button id="nuevaNeurona" class="claro">Empezar de cero</button>
</div>
<p id="mensaje"></p>
<div id="respuesta" aria-live="polite"></div>
<p id="estadoNeurona">Sin entrenar.</p>
<h2>Heltec</h2>
<p id="estado">Sin conectar.</p>
<button id="botonConectar">Conectar Heltec</button>
<details>
<summary>Sin móvil (para probar en un ordenador)</summary>
<button id="botonSimular" class="claro">Usar datos simulados</button>
<div id="simulado">DATOS SIMULADOS: los gestos los inventa la página.</div>
<div class="fila oculto" id="filaSimulada">
<label for="gestoSimulado">Gesto simulado al probar</label>
<select id="gestoSimulado"><option value="0">Vaivén</option><option value="1">Giro</option></select>
</div>
</details>
<script>
/* ============================================================
PARTE 1 · LA NEURONA (léela: son unas 25 líneas, no hay magia)
Una neurona artificial hace tres cosas:
1. MULTIPLICA cada entrada por su peso: w1·x1 y w2·x2
2. SUMA todo, y suma también el sesgo (b): suma = w1·x1 + w2·x2 + b
3. DECIDE: si la suma es mayor que 0, contesta 1 (azul); si no, contesta 0 (rojo).
Aprender es corregir los pesos cuando se equivoca (la regla del perceptrón):
error = lo que debía contestar − lo que contestó (vale −1, 0 o +1)
cada peso cambia en: tasa · error · su entrada
La recta que ves es el sitio donde la suma vale exactamente 0.
============================================================ */
let tasa = 0.1; // cuánto corrige cada vez (tasa de aprendizaje)
const MAX_VUELTAS = 200; // si en 200 vueltas no acierta, nos rendimos
const azar = () => Math.round((Math.random() * 2 - 1) * 100) / 100;
function crearNeurona() { return { w1: azar(), w2: azar(), b: azar(), vuelta: 0, historial: [] }; }
// Una neurona nueva con pesos al azar. Si por casualidad ya acertara todo, le damos la vuelta a los pesos
// para que tenga algo que aprender (si no, no veríais cómo se corrige).
function neuronaConFallos(lista) {
const n = crearNeurona();
if (lista.length > 0 && contarErrores(n, lista) === 0) { n.w1 = -n.w1; n.w2 = -n.w2; n.b = -n.b; }
return n;
}
function suma(n, x1, x2) { return n.w1 * x1 + n.w2 * x2 + n.b; }
function contestar(n, x1, x2) { return suma(n, x1, x2) > 0 ? 1 : 0; }
function contarErrores(n, puntos) { return puntos.filter(p => contestar(n, p.x1, p.x2) !== p.c).length; }
function unaVueltaDeEntrenamiento(n, puntos) { // repasa todos los puntos una vez
for (const p of puntos) {
const error = p.c - contestar(n, p.x1, p.x2);
n.w1 += tasa * error * p.x1;
n.w2 += tasa * error * p.x2;
n.b += tasa * error;
}
n.vuelta++;
n.historial.push(contarErrores(n, puntos));
}
/* ============================================================
PARTE 2 · LOS DATOS
Cada punto es { x1, x2, c }: dos números entre 0 y 1 y su color (0 rojo, 1 azul).
Salen de gestos del móvil (el acelerómetro): x1 = cuánto se mueve y x2 = cuánto se inclina.
============================================================ */
const ESCALA_MOV = 20, ESCALA_INCL = 15; // con qué número de m/s² vale 1 en cada eje
const DURACION = 1500; // ms que dura cada gesto
const NOMBRES = ["Vaivén", "Giro"];
const puntos = []; // los gestos grabados
let neurona = crearNeurona();
let pregunta = null; // el último gesto probado: { x1, x2, c }
const media = a => a.reduce((s, v) => s + v, 0) / a.length;
const desviacion = a => { const m = media(a); return Math.sqrt(media(a.map(v => (v - m) ** 2))); };
const entre01 = v => Math.min(1, Math.max(0, v));
// De ~90 lecturas del acelerómetro a dos números entre 0 y 1. d = [[ms, x, y, z], ...]
function puntoDeGesto(d, clase) {
const ejes = [1, 2, 3].map(i => d.map(f => f[i]));
const movimiento = Math.max(...ejes.map(desviacion)); // el eje que más se mueve
const k = Math.max(1, Math.round(d.length * 0.15));
const ini = ejes.map(e => media(e.slice(0, k))), fin = ejes.map(e => media(e.slice(-k)));
const inclinacion = Math.hypot(fin[0] - ini[0], fin[1] - ini[1], fin[2] - ini[2]); // cuánto cambia la gravedad
return { x1: entre01(movimiento / ESCALA_MOV), x2: entre01(inclinacion / ESCALA_INCL), c: clase };
}
let capturando = null, hayMovimiento = false, ocupado = false;
window.addEventListener("devicemotion", function (e) {
const a = e.accelerationIncludingGravity;
if (!a || a.x === null || a.x === undefined) return;
hayMovimiento = true;
if (capturando) capturando.datos.push([performance.now() - capturando.t0, a.x, a.y, a.z]);
});
async function permisoMovimiento() { // iPhone: hay que pedir permiso al pulsar un botón
if (typeof DeviceMotionEvent !== "undefined" && typeof DeviceMotionEvent.requestPermission === "function") {
const r = await DeviceMotionEvent.requestPermission();
if (r !== "granted") throw new Error("Sin permiso para leer el movimiento.");
}
}
async function capturar(gestoSimulado) {
prepararAudio();
await permisoMovimiento();
decir("Prepárate: coloca el móvil…");
await espera(1500);
pitar(880, 0.12); // pitido agudo: ¡ya!
decir("¡YA! Haz el gesto");
let datos;
if (simulando) { await espera(DURACION); datos = simular(gestoSimulado); }
else {
capturando = { t0: performance.now(), datos: [] };
await espera(DURACION);
datos = capturando.datos; capturando = null;
}
pitar(440, 0.2); // pitido grave: se acabó
if (datos.length < 15) throw new Error("El móvil no ha dado datos de movimiento. ¿Usas Chrome en un móvil? En un ordenador, prueba «Sin móvil» al final de la página.");
return datos;
}
/* ============================================================
PARTE 3 · EL DIBUJO Y LOS BOTONES (NO ES NECESARIA para que la neurona funcione)
============================================================ */
const espera = ms => new Promise(r => setTimeout(r, ms));
const decir = t => document.getElementById("mensaje").textContent = t;
const coma = (v, d = 2) => v.toFixed(d).replace(".", ",");
const sinTildes = s => s.normalize("NFD").replace(/[̀-ͯ]/g, "").toUpperCase();
// Un pitido corto (Web Audio): así sabes cuándo empezar sin mirar la pantalla.
let audio = null;
function prepararAudio() {
if (!audio && window.AudioContext) audio = new AudioContext();
if (audio && audio.state === "suspended") audio.resume();
}
function pitar(hz, seg) {
if (!audio) return;
const o = audio.createOscillator(), g = audio.createGain();
o.frequency.value = hz; g.gain.value = 0.2;
o.connect(g); g.connect(audio.destination);
o.start(); o.stop(audio.currentTime + seg);
}
function dibujar() {
const lienzo = document.getElementById("plano"), c = lienzo.getContext("2d");
const W = lienzo.width, H = lienzo.height, estilo = getComputedStyle(document.body);
const rojo = estilo.getPropertyValue("--rojo"), azul = estilo.getPropertyValue("--azul");
const texto = estilo.getPropertyValue("--texto"), suave = estilo.getPropertyValue("--suave");
const n = neurona, lista = puntos;
const X = x1 => x1 * W, Y = x2 => (1 - x2) * H;
c.clearRect(0, 0, W, H);
// Fondo: en cada cuadradito, el color que contestaría la neurona
const celda = 20;
c.globalAlpha = 0.14;
for (let i = 0; i < W / celda; i++) for (let j = 0; j < H / celda; j++) {
c.fillStyle = contestar(n, (i + 0.5) * celda / W, 1 - (j + 0.5) * celda / H) ? azul : rojo;
c.fillRect(i * celda, j * celda, celda, celda);
}
c.globalAlpha = 1;
// La recta donde la suma vale 0: w1·x1 + w2·x2 + b = 0
c.save();
c.beginPath(); c.rect(0, 0, W, H); c.clip();
if (Math.abs(n.w1) > 1e-6 || Math.abs(n.w2) > 1e-6) {
c.beginPath(); c.strokeStyle = texto; c.lineWidth = 3;
if (Math.abs(n.w2) > Math.abs(n.w1)) {
c.moveTo(X(-1), Y(-(n.b + n.w1 * -1) / n.w2)); c.lineTo(X(2), Y(-(n.b + n.w1 * 2) / n.w2));
} else {
c.moveTo(X(-(n.b + n.w2 * -1) / n.w1), Y(-1)); c.lineTo(X(-(n.b + n.w2 * 2) / n.w1), Y(2));
}
c.stroke();
}
c.restore();
// Los puntos; con aro negro los que la neurona falla
lista.forEach(function (p) {
c.beginPath(); c.arc(X(p.x1), Y(p.x2), 9, 0, 6.2832);
c.fillStyle = p.c ? azul : rojo; c.fill();
if (contestar(n, p.x1, p.x2) !== p.c) { c.lineWidth = 3; c.strokeStyle = texto; c.stroke(); }
});
// El punto de la pregunta: hueco, con el borde del color que contesta la neurona
if (pregunta) {
c.beginPath(); c.arc(X(pregunta.x1), Y(pregunta.x2), 11, 0, 6.2832);
c.fillStyle = estilo.getPropertyValue("--tarjeta"); c.fill();
c.lineWidth = 4; c.strokeStyle = pregunta.c ? azul : rojo; c.stroke();
}
c.fillStyle = suave; c.font = "20px system-ui";
c.textAlign = "right"; c.fillText("movimiento →", W - 8, H - 8);
c.textAlign = "left"; c.fillText("↑ inclinación", 8, 24);
escribirEstado();
}
function escribirEstado() {
const n = neurona, lista = puntos;
const errores = contarErrores(n, lista);
document.getElementById("estadoNeurona").textContent =
"Vuelta " + n.vuelta + " · errores: " + errores + " de " + lista.length +
" · w1 = " + coma(n.w1) + " · w2 = " + coma(n.w2) + " · sesgo = " + coma(n.b);
}
function preguntarA(x1, x2) {
const n = neurona;
pregunta = { x1: x1, x2: x2, c: contestar(n, x1, x2) };
const nombre = NOMBRES[pregunta.c];
document.getElementById("respuesta").textContent = "La neurona dice: " + nombre;
dibujar();
enviar("C" + sinTildes(nombre));
}
function avisoDePuntos() {
const l = puntos;
if (l.filter(p => p.c === 0).length === 0 || l.filter(p => p.c === 1).length === 0) {
decir("Necesito puntos de los dos colores para poder entrenar.");
return false;
}
return true;
}
// Entrenar
let reloj = null;
function pararEntrenamiento() { if (reloj) { clearInterval(reloj); reloj = null; } document.getElementById("entrenar").textContent = "Entrenar"; }
function unPaso() { // una vuelta + dibujar + contárselo a la placa
const n = neurona;
unaVueltaDeEntrenamiento(n, puntos);
const errores = n.historial[n.historial.length - 1];
dibujar();
contarleALaPlaca(n, errores);
if (errores === 0) { pararEntrenamiento(); decir("¡Aprendido! Ha necesitado " + n.vuelta + " vueltas."); return true; }
if (n.vuelta >= MAX_VUELTAS) { pararEntrenamiento(); decir("Sigue fallando después de " + MAX_VUELTAS + " vueltas: no hay ninguna recta que separe estos puntos."); return true; }
return false;
}
document.getElementById("unaVuelta").onclick = function () { pararEntrenamiento(); if (avisoDePuntos()) { decir(""); unPaso(); } };
document.getElementById("entrenar").onclick = function () {
if (reloj) { pararEntrenamiento(); return; }
if (!avisoDePuntos()) return;
decir("Entrenando…");
this.textContent = "Parar";
reloj = setInterval(unPaso, 250);
};
document.getElementById("nuevaNeurona").onclick = function () {
pararEntrenamiento(); neurona = neuronaConFallos(puntos); pregunta = null;
document.getElementById("respuesta").textContent = "";
decir("Neurona nueva, con pesos al azar."); dibujar(); enviar("R");
};
// Grabar y probar gestos
document.getElementById("grabar0").onclick = () => grabarGesto(0);
document.getElementById("grabar1").onclick = () => grabarGesto(1);
async function grabarGesto(clase) {
if (ocupado) return;
ocupado = true;
try {
const p = puntoDeGesto(await capturar(clase), clase);
puntos.push(p); pregunta = null; document.getElementById("respuesta").textContent = "";
decir("Guardado un punto de «" + NOMBRES[clase] + "».");
} catch (e) { decir(e.message); }
ocupado = false;
dibujar();
}
document.getElementById("probarGesto").onclick = async function () {
if (ocupado) return;
ocupado = true;
try {
const p = puntoDeGesto(await capturar(parseInt(document.getElementById("gestoSimulado").value, 10)), 0);
preguntarA(p.x1, p.x2);
decir("");
} catch (e) { decir(e.message); }
ocupado = false;
};
setTimeout(function () {
document.getElementById("sensor").textContent = hayMovimiento
? "Sensor de movimiento: ✔ recibiendo datos."
: "Sensor de movimiento: no detecto datos (normal en un ordenador). Abre «Sin móvil» al final de la página.";
}, 1500);
// Modo SIN MÓVIL: inventamos gestos (0 = vaivén, 1 = giro) con un poco de ruido.
let simulando = false;
const ruido = s => s * Math.sqrt(-2 * Math.log(1 - Math.random())) * Math.cos(2 * Math.PI * Math.random());
const entreA = (a, b) => a + Math.random() * (b - a);
function simular(g) {
const G = 9.81, datos = [], dt = 1000 / 60;
const inc = entreA(-0.35, 0.35), lado = entreA(-0.35, 0.35);
const A = entreA(8, 25), f = entreA(3, 4.5), fase = entreA(0, 6.28); // vaivén
const t0 = entreA(0.2, 0.5), dur = entreA(0.6, 0.9); // giro
for (let t = 0; t <= DURACION / 1000; t += dt / 1000) {
let ang = inc;
if (g === 1) { const u = Math.min(1, Math.max(0, (t - t0) / dur)); ang = inc + (u * u * (3 - 2 * u)) * Math.PI / 2; }
let y = G * Math.sin(ang);
if (g === 0) y += A * Math.sin(2 * Math.PI * f * t + fase);
datos.push([t * 1000, G * Math.sin(lado) + ruido(0.3), y + ruido(0.3), G * Math.cos(ang) + ruido(0.3)]);
}
return datos;
}
document.getElementById("botonSimular").onclick = function () {
simulando = !simulando;
document.getElementById("simulado").classList.toggle("visible", simulando);
document.getElementById("filaSimulada").classList.toggle("oculto", !simulando);
this.textContent = simulando ? "Dejar de simular" : "Usar datos simulados";
};
/* ============================================================
PARTE 4 · EL BLUETOOTH (casi igual que en el chisme 40; no hace falta tocarlo)
Solo ENVIAMOS: "E12,3" (vuelta y errores), "W0.82,-0.40,0.10" (pesos),
"CAZUL" (la respuesta de la neurona) y "R" (empezar de cero).
============================================================ */
const SERVICIO = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
const UUID_RX = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"; // aquí ESCRIBIMOS
let dispositivo, canalOrdenes, enviando = false;
const estado = document.getElementById("estado");
async function enviar(texto) {
if (!canalOrdenes) return; // sin placa conectada, no pasa nada
const datos = new TextEncoder().encode(texto);
try {
if (canalOrdenes.writeValueWithoutResponse) await canalOrdenes.writeValueWithoutResponse(datos);
else await canalOrdenes.writeValue(datos);
} catch (e) { estado.textContent = "No se pudo enviar: " + e.message; }
}
async function contarleALaPlaca(n, errores) { // dos mensajes seguidos, con un respiro entre ellos
if (!canalOrdenes || enviando) return;
enviando = true;
await enviar("E" + n.vuelta + "," + errores);
await espera(40);
await enviar("W" + n.w1.toFixed(2) + "," + n.w2.toFixed(2) + "," + n.b.toFixed(2));
enviando = false;
}
async function abrirCanales() {
for (let intento = 1; intento <= 3; intento++) { // a veces falla la primera vez: reintentamos
try {
const servidor = await dispositivo.gatt.connect();
await espera(600);
const servicio = await servidor.getPrimaryService(SERVICIO);
canalOrdenes = await servicio.getCharacteristic(UUID_RX);
estado.textContent = "Conectada a " + dispositivo.name + ".";
enviar("R");
return;
} catch (e) {
if (intento === 3) throw e;
await espera(800);
}
}
}
document.getElementById("botonConectar").onclick = async function () {
if (!navigator.bluetooth) { estado.textContent = "Este navegador no permite Bluetooth. Usa Chrome."; return; }
try {
estado.textContent = "Buscando la placa...";
dispositivo = await navigator.bluetooth.requestDevice({ acceptAllDevices: true, optionalServices: [SERVICIO] });
dispositivo.addEventListener("gattserverdisconnected", () => {
estado.textContent = "Conexión perdida. Pulsa «Conectar Heltec».";
canalOrdenes = null;
});
await abrirCanales();
} catch (e) {
estado.textContent = "Error: " + e.message + " (elige «Heltec-Neurona» en la lista).";
}
};
dibujar();
</script>
</body>
</html>
Cómo probarlo
- Sube el
.inoa la Heltec y comprueba que la pantalla dice que espera conexión. - En el móvil Android, abre la página con Chrome y pulsa Conectar Heltec.
- Pulsa Grabar vaivén y, tras el pitido, mueve el móvil de lado a lado durante 1,5 segundos. Repite 4 o 5 veces.
- Pulsa Grabar giro y gira el móvil como si abrieras un grifo. Otras 4 o 5 veces.
- Pulsa Entrenar y mira cómo bajan los errores hasta cero.
- Pulsa Probar un gesto, haz uno de los dos y mira qué color dice la página y qué palabra escribe la Heltec.
Lee y predice. Si grabas un vaivén muy suave y un giro muy brusco, ¿qué pasa con los puntos? ¿Los separa la recta?
Cambios guiados. Prueba a cambiar ESCALA_MOV (por ejemplo, de 20 a 10) y MAX_VUELTAS, y mira qué cambia en los dos números de cada gesto. Si tu móvil se mueve poco, la escala de movimiento es la primera que hay que ajustar.
Qué estamos midiendo realmente
Cada gesto se convierte en dos números: cuánto se mueve el móvil y cuánto se inclina. Son medidas del acelerómetro, que cambian de un móvil a otro, así que no son instrumentos de precisión.
Las escalas que dividen esos números son estimaciones: las ajustamos con datos simulados y puede que tu móvil necesite otras.
Cuando la neurona llega a 0 errores, solo ha memorizado tus ejemplos. No sabemos si acertará con gestos nuevos hasta que los probamos.
Además, una neurona solo traza una recta. Si dos gestos se mezclan en el plano, ninguna recta los separa. Medir no significa medir bien, y aprender tampoco significa aprender bien.
Para el aula y retos
- Reto 1. Haz que funcione con vaivén y giro.
- Reto 2. Cambia la tasa de aprendizaje y cuenta cuántas vueltas necesita.
- Reto 3. Pide a un compañero que haga los gestos y mira si acierta la neurona entrenada por ti.
- Reto 4. Prueba en dos móviles distintos y compara los dos números del mismo gesto.
- Reto 5. Inventa un tercer gesto. ¿Por qué una sola neurona no puede separar tres?
Pregunta para debatir: ¿cuántos ejemplos hacen falta para fiarse de la neurona?
- Simón dice con gestos (Chisme 22) es el de los gestos con acelerómetro.
- El letrero del Lince: Heltec LoRa 32, Bluetooth y móvil (Chisme 25) es el de la Heltec que recibe texto del móvil por Bluetooth.
- El sismógrafo de la mano (Chisme 11) es el que usa el acelerómetro del móvil para medir el movimiento.
Qué hemos aprendido
Una neurona puede aprender a distinguir gestos reales con solo dos números y una recta. Todo ocurre en tu móvil y no sale ningún dato de él. La Heltec solo muestra el resultado.
Para seguir investigando: si quieres ver la neurona dibujada con sus pesos, mira el Chisme del Lince 41. También puedes leer sobre el perceptrón en Wikipedia o jugar con más capas en TensorFlow Playground.

Etiqueta:acelerómetro, Heltec LoRa 32, neurona artificial, perceptrón, Web Bluetooth


