App web (PWA) solo para teléfono que mide las proporciones reales del rostro en una foto y recomienda qué cortes de pelo favorecen y cuáles evitar. También lee el tono de piel, el color de pelo y de ojos, y avisa cuando la luz no alcanza para dar un resultado confiable.
El análisis corre entero en el dispositivo. No hay servidor, no hay cuenta, no se sube ninguna foto y funciona sin conexión una vez instalada. La única excepción es la prueba virtual del corte (opcional), que sí manda la foto a un modelo de IA — la app lo avisa antes de hacerlo.
npm install
npm run dev # https://localhost:5173El servidor levanta con HTTPS y certificado autofirmado a propósito: los
navegadores solo dan acceso a la cámara sobre HTTPS o en localhost. Hay
que aceptar el aviso de certificado no confiable.
En una computadora la app no se abre: muestra el aviso de "abrila desde el celular" y oculta la interfaz. Para verla en el navegador de escritorio hay que activar la emulación de dispositivo móvil en las DevTools (Ctrl+Shift+M).
npm run dev -- --hostVite imprime una URL con la IP de la red local (https://192.168.x.x:5173).
Se abre esa URL en el teléfono y se acepta el aviso del certificado.
El repo ya trae vercel.json y la función serverless
api/tryon.js. El flujo es:
npm i -g vercel
vercel # primer despliegue, de prueba
vercel --prodDespués, una sola variable de entorno en el panel de Vercel (Settings → Environment Variables):
| Variable | Valor | Para qué |
|---|---|---|
GEMINI_API_KEY |
tu clave de Google AI Studio | Que la prueba virtual funcione sin que el usuario final configure nada |
Sin esa variable la app funciona igual: el análisis de rostro, los colores y las recomendaciones no dependen de ninguna API. Lo único que queda inactivo es el botón "probarlo en mi foto", que en ese caso ofrece pegar una clave propia que se guarda solo en el teléfono.
Tené en cuenta: con la clave en el servidor, cualquiera que tenga la URL consume tu cuota. Para uso familiar no es problema; si la dirección se hace pública, conviene sacar la variable o poner la app detrás de Vercel Authentication.
dist/ pesa ~38 MB, casi todo el modelo de detección facial. Está dentro de
los límites de hosting estático de Vercel y se cachea con immutable, así que
se descarga una sola vez.
No. La app no guarda nada: analiza la foto en memoria y muestra el resultado. No hay usuarios, ni sesiones, ni historial. Agregar Supabase significaría mandar fotos a un servidor, que es justamente lo que la app promete no hacer.
Solo tendría sentido si en el futuro se quisiera guardar el historial de análisis entre dispositivos o tener cuentas. Hoy sería complejidad y una filtración de privacidad a cambio de nada.
MediaPipe Face Landmarker corre por WebAssembly dentro del navegador y devuelve 478 puntos 3D del rostro, incluidos los del iris. El modelo está incluido en el repo: no se descarga nada de terceros en tiempo de ejecución.
2. Calidad de imagen (faceAttributes.js)
Antes de dar un resultado se mide la luz sobre la zona de la cara, no sobre la foto entera. Se detectan tres problemas distintos y se avisa de cada uno:
- poca luz (luminancia media baja o muchos píxeles negros),
- zonas quemadas (flash directo o contraluz),
- luz de un solo lado (comparando las dos mitades del rostro).
Con poca luz el detector pierde precisión y la lectura de color se vuelve directamente inventada — en penumbra todo tiende al gris y cualquier pelo castaño se lee negro. Por eso se avisa en vez de dar un resultado malo.
3. Medición (faceGeometry.js)
Largo del rostro, ancho de pómulos, de mandíbula, de frente, ángulo del mentón
y rasgos dimórficos (separación ceja–ojo, grosor del labio, ancho de nariz).
Antes de medir se corrige la inclinación de la cabeza rotando los puntos hasta
que la línea de los ojos queda horizontal. Los 10 puntos del contorno están
verificados contra FaceLandmarker.FACE_LANDMARKS_FACE_OVAL.
4. Forma del rostro (faceShape.js)
Las proporciones se convierten a z-scores contra una distribución medida sobre retratos frontales reales y se comparan con ocho prototipos: ovalado, redondo, cuadrado, alargado, rectangular, corazón, diamante y triangular. Devuelve ganador, segundo lugar y confianza: muchas caras están genuinamente entre dos formas y la app lo dice en vez de fingir certeza.
5. Color (faceAttributes.js)
Todo se mide en píxeles reales, en zonas ancladas a los landmarks:
- Piel — parches de mejillas y frente, mediana intercuartil (se descartan sombras y brillos por igual). Se clasifica con el ángulo ITA°, que es la medida estándar en dermatología, no una escala inventada.
- Pelo — varios parches sobre el nacimiento del pelo, pegados a la línea media, con mediana de medianas. Si la zona sale igual a la piel, se reporta "rapado" en lugar de inventar un color.
- Ojos — píxeles dentro del círculo del iris, descartando pupila y reflejos. Si el iris mide menos de ~5 px de radio se responde "no se pudo ver", porque a esa resolución no queda color que leer.
6. Género (gender.js)
Se estima a partir de rasgos con dimorfismo sexual documentado, solo para preseleccionar qué set de recomendaciones mostrar. La app nunca afirma el género de nadie: muestra "Recomendaciones para" con el botón ya elegido y se cambia con un toque. Cuando la estimación es floja, el texto invita explícitamente a corregirla.
Precisión medida: 76,1% (exactitud balanceada, validación cruzada en 5 pliegues sobre 756 rostros etiquetados desde Wikidata — 395 hombres y 361 mujeres, de varias ocupaciones y países). Margen de error ±4,4 puntos. Es claramente mejor que tirar una moneda y claramente peor que un dato: alrededor de 1 de cada 4 personas recibe la preselección equivocada. Por eso la interfaz siempre invita a corregirla y nunca la presenta como resuelta.
El modelo es una regresión logística sobre nueve proporciones. Se comparó contra dos alternativas más simples con la misma validación:
| modelo | precisión validada |
|---|---|
| regresión logística | 76,1% (H 75% / M 77%) |
| descenso coordenado | 74,2% (H 68% / M 80%) |
| centroides (LDA diagonal) | 71,1% (H 71% / M 71%) |
Los rasgos que más separan son la separación ceja–ojo y el grosor del labio, ambos mayores en mujeres.
Una versión anterior usaba 71 rostros, todos de políticos estadounidenses, y daba 66,5% con solo 13 mujeres (margen de error ±27 puntos). Sobre esa muestra el ancho de mandíbula parecía separar en la dirección contraria a lo que dice la literatura, y así quedó documentado en su momento. Con 756 rostros de fuentes variadas el rasgo se ordena como corresponde —mandíbula más ancha en hombres— y contribuye con peso positivo. Era ruido de una muestra chica y homogénea, no un hallazgo.
También conviene recordar que ajustar y medir sobre los mismos datos infla el resultado: en la muestra chica daba 79,8% ajustado contra 66,5% validado. Por eso el script reporta siempre el número validado.
Con el servidor de desarrollo levantado en otra terminal:
npm test # flujo completo + bloqueo en escritorio
npm run test:api # función serverless (no necesita clave)
npm run test:responsive # scroll y desbordes en 6 modelos de iPhone
node scripts/tilt-test.mjs # cabeza inclinada
node scripts/attrs-eval.mjs # distribución de piel, pelo y ojos
node scripts/calibrate.mjs # estadísticas y barrido de temperatura
node scripts/gender-eval.mjs # precisión del estimador de género
npm run build && npm run preview # y luego:
ORIGIN=https://localhost:4173 node scripts/offline-test.mjsLas pruebas manejan Chrome de verdad con el detector de verdad — no hay mocks.
Emulan un teléfono (hasTouch/isMobile), porque si no la app se bloquea sola.
Estos scripts encontraron errores reales que no se ven leyendo el código:
| Qué se rompía | Cómo se detectó |
|---|---|
| Todas las caras se clasificaban "redondo" | Los umbrales venían de la literatura de peluquería; los landmarks de MediaPipe no miden lo mismo que una cinta métrica |
| Las líneas de medición se iban de la cara | Se usaban los puntos rotados (los de medir) también para dibujar |
| Una cabeza ladeada se reportaba "cara girada" | La matriz de pose de MediaPipe es row-major, no column-major |
| El 18% de las caras salía "pelirroja" | Los parches de pelo caían fuera de la silueta y muestreaban el fondo (una bandera roja) |
| Toda la muestra salía un tono más pálida | La primera categoría de piel era inalcanzable y el recorte de percentiles era asimétrico |
| El 43% de los ojos salía "gris" | A esa resolución el iris se dessatura; el umbral de croma estaba demasiado flojo |
| El vidrio se veía gris plano | El ::before con z-index:-1 quedaba detrás del fondo opaco del body |
| El estimador de género daba vuelta a las personas | Los pesos elegidos a mano tenían el signo cambiado en mandíbula y mentón |
| Un cuarto de la muestra de género se perdía en silencio | El nombre de archivo se truncaba a 50 caracteres y colisionaba: 502 descargas, 382 archivos |
| La página se cortaba a lo ancho en 5 de 6 iPhones | Los chips medían 402px fijos: min-width:auto de grid + texto con nowrap |
responsive-test.mjs mide el alto de la página como pantallas de scroll
(scrollHeight ÷ alto del viewport) en seis modelos, del iPhone SE de 320px al
Pro Max de 430px, y falla si algo desborda a lo ancho o si el inicio pide
scroll. Resultado del rediseño:
| antes | después | |
|---|---|---|
| Peor caso (iPhone SE 320×568) | 6,0 pantallas | 2,3 |
| Típico (iPhone 14/15) | 3,8 pantallas | 1,7 |
| Desbordes horizontales | 5 de 6 modelos | 0 |
Lo que lo consiguió: pestañas (Te favorecen / Evitar / Detalles) en vez de una
columna única; tarjetas de corte plegables con <details>; avisos de una línea
que se expanden al tocar; la foto acotada a 34dvh; y tipografía y espaciado
fluidos con clamp().
NORM en src/faceShape.js sale de medir 50 retratos
frontales de dominio público (Congreso de EE.UU. y NASA) con estos mismos
landmarks. La temperatura de la softmax (T = 0.45) se eligió barriendo
valores: deja la confianza del ganador en una mediana del ~51% y marca como
"mixto" a ~1 de cada 5 rostros. Con el valor inicial la mediana daba 27% —
apenas por encima del azar de 1/8 — y 4 de cada 5 caras salían mixtas.
Para regenerar:
node scripts/fetch-calibration-set.mjs # muestra a public/_test/
PER_CLASS=500 node scripts/fetch-gender-set.mjs # muestra etiquetada a public/_gender/
npm run dev # en otra terminal
node scripts/calibrate.mjs # imprime el bloque NORM a pegar
node scripts/gender-eval.mjs # imprime los pesos de género a pegarDos detalles que hacen que bajar mil fotos tarde minutos y no horas
(medidos con scripts/probe-throughput.mjs):
- El User-Agent tiene que cumplir la política de Wikimedia — nombre, versión y un contacto. Sin contacto rechazan la mitad de los pedidos con 429; con contacto, ninguno. Fue la diferencia entre 22 y 44 img/min.
- La concurrencia óptima es 3–4. Con 8 en paralelo el servidor rechaza el 90% y el rendimiento cae por debajo del secuencial.
gender-eval.mjs cachea las mediciones en public/_gender/measurements.json
(~1000 fotos/min en el navegador). Medir es lo caro; probar modelos sobre datos
ya medidos es instantáneo, así que se puede iterar sin volver a medir todo.
tools/calibrar.html es una página de desarrollo para soltar fotos propias y
ver las proporciones y colores medidos.
Sesgo conocido de la muestra: son mayormente adultos, con predominio
masculino. Sirve como línea de base de cara adulta; una muestra más equilibrada
mejoraría la calibración. Reemplazar las fotos de public/_test/ y volver a
correr calibrate.mjs es todo lo que hace falta.
index.html interfaz (una sola página, cuatro pantallas)
vercel.json despliegue y cabeceras de caché
api/tryon.js función serverless: prueba virtual con la key del servidor
src/
main.js orquestación, cámara, canvas, bloqueo en escritorio
faceGeometry.js landmarks → medidas en píxeles → proporciones
faceShape.js proporciones → forma de rostro + confianza (NORM)
faceAttributes.js calidad de luz, piel (ITA°), pelo, ojos
gender.js estimación de género para preseleccionar recomendaciones
haircuts.js base de recomendaciones por forma y género
tryon.js prueba virtual (servidor, con respaldo de clave propia)
styles.css glassmorphism
fonts.css generado por scripts/fetch-fonts.mjs
public/
models/ wasm/ detector facial y su runtime
fonts/ Poppins auto-alojada (funciona sin conexión)
sw.js service worker
scripts/ íconos, muestras, calibración y pruebas
sync-wasm.mjs recopia el runtime de MediaPipe (correr al actualizarlo)
tools/calibrar.html herramienta de desarrollo
- Es una guía basada en proporciones, no una regla. La forma del rostro es un continuo, no ocho cajones.
- Necesita una foto frontal y con buena luz. La app avisa cuando no lo es, pero no puede corregirlo.
- El color de ojos es el dato menos confiable: a la resolución a la que se analiza, el iris ocupa pocos píxeles. Cuando es demasiado chico se dice "no se pudo ver" en lugar de adivinar.
- La estimación de género es estadística y falible (76% medido); por eso siempre es corregible con un toque. La muestra con la que se ajustó son retratos públicos de adultos: con caras de niños o selfies muy angulados va a andar peor.
- La prueba virtual depende del filtro de contenido del proveedor, que a veces rechaza fotos de personas reales.