2026-08-09 — retrato-hablado: scaffold y decisiones de diseño

Decisión persistida: proyecto retrato-hablado en ~/nef/projects/retrato-hablado. Composición facial estadística para retrato hablado pericial (México, LFPDPPP).

Contexto

  • El usuario pidió el proyecto con una especificación técnica dura (PCA 70 landmarks + textura 128×128, warp afín por triángulo, PNG lossless, cruce en coeficientes, blur primario con σ registrado, composite.json como fuente de verdad) y luego añadió el contexto pericial: prohibido reconocimiento/matching/padrones, sin telemetría, cifrado en reposo, flujo bloqueante en 4 fases, render.svg paramétrico + ficha.pdf con hash del JSON.

Decisiones tomadas (y por qué)

  1. Stack Python CLI (numpy/scipy/cryptography). “Sin dependencias externas en runtime” se interpretó como sin red/SaaS; numpy/scipy son librerías locales.
  2. Datos sintéticos primero: retrato synthetic genera 70 landmarks + texturas procedimentales; el pipeline queda validado end-to-end y el dataset real entra con el mismo formato (landmarks.npy, textures.npy, semantic.npy). Las sintéticas se marcan como NO biométricas.
  3. render.svg paramétrico: solo geometría (malla de Delaunay, landmarks, σ, hashes), sin bitmaps — reconcilia “prohibidos assets vectoriales por rasgo” con “render.svg sin assets binarios”. El PNG raster es derivado.
  4. Cruce en coeficientes PCA verificado por test (hijo ≠ promedio de bitmaps; el render del hijo = render de la mezcla de coeficientes).
  5. Integridad: manifest.json del modelo con SHA-256 por .bin (rechazo al cargar); composite.json fuente de verdad; ficha.pdf + render.svg llevan hash del JSON y del modelo; retrato verificar re-deriva y compara.
  6. Cifrado en reposo: Fernet (clave local 0600), composite.enc al finalizar; --borrar-plano deja solo la copia cifrada. Retención explícita (purge_at, 90 días por defecto) + retrato purge.
  7. Canonicalización de forma: Procrustes sin rescale (preserva tamaño)
    • transformación afín de la forma media al marco del render — sin esto el render salía fuera de canvas.
  8. Bug notable encontrado: la matriz afín del warp se aplicaba con la transpuesta incorrecta (resultaba identidad en los vértices → solo se llenaba la zona de solape). Se corrigió con pts_src = pts_dst @ inv(M).T.

Contratos duros (fijados por tests, 11 pasando)

  • PNG lossless codec propio; sin JPEG (verificado por cabecera y por test de imports de red: socket/http/urllib/etc. prohibidos).
  • Render determinista byte a byte.
  • Fases bloqueantes en orden; recuerdo sin estímulo visual; imágenes solo desde fase 3.
  • σ aplicado registrado en composite.json y en la ficha.
  • Modelo inmutable: alterar un .bin → rechazo al cargar.

Pendientes / próximos pasos

  • Dataset real (70 landmarks alineados) en el formato esperado.
  • Validación con perito/usuaria real (el “quién lo ve esta semana”).
  • Decisión pendiente: ¿la entrevista corre en terminal o se necesita UI (el flujo de sondeo pediría ciegos de categorías con opciones)?

Actualización (misma fecha): v1.1 — resolución y realismo

El usuario valoró el render inicial (256×256, parche 128) como “baja resolución y producto flojo”. Decisión: quedarse con el motor estadístico propio (los motores externos —FACES/EFIT-V cerrados, StyleGAN rompe “sin embeddings” y reproducibilidad) y subir parámetros, no rediseñar.

  • Parche de textura 128 → 512, salida 256 → 1024 (σ por defecto 8 px, equivalente al σ=2 anterior).
  • Generador sintético con más realismo: sombreado periorbital, modelado nasal (alas/fosas/punta con brillo), iris con pupila y brillo, pliegue de párpado, trazos de vello en cejas, labios con línea y brillo, sombra de mentón/cuello, brillos de frente/pómulos, bolsas de edad.
  • Formato de modelo v2 (v1 no se carga: versionado inmutable funciona).
  • ficha.pdf embebe el retrato a 430 pt (antes 204).
  • Optimización: candidata nítida se renderiza una vez y el blur se deriva (mitad de renders por iteración). El warp a 1024 cuesta ~0.1 s.
  • Resultado: PNG 1024×1024 (~90 KB), ficha ~90 KB, 231 mil px de rostro (antes 15 mil a 256). 11 tests pasando.

Rendimiento: build del modelo 60 muestras = 3.4 s; render 1024 = 0.1 s; sesión completa interactiva en segundos.

Actualización (misma fecha): rama benchmark-alternativas

Evaluación paralela de herramientas open source por componente (benchmark/criterios.md, benchmark/herramientas.md), con hechos verificados contra fuentes oficiales. Hallazgos:

  • scikit-image PiecewiseAffineTransform (PoC real): determinista pero ~10× más lento que nuestro warp (0.25 vs 0.024 s a 512), no byte-idéntico (rompería la regeneración de PNG antiguos) y API en deprecación. No se adopta; el warp propio queda validado de forma cruzada.
  • StyleGAN2-ADA: descartado — licencia NVIDIA no comercial, pesos FFHQ entrenados con fotos reales (C6 inviable), projector.py = inversión latente (riesgo R1), GPU ≥12 GB.
  • MediaPipe Tasks: Privacy Notice oficial declara que envía métricas de uso a Google (requiere consentimiento) → solo data prep tras auditoría; no se instala.
  • Menpo/menpofit: referente académico del mismo paradigma (AAM); su capa de fitting (SDM) se re-evaluará con dataset real.
  • dlib shape predictor (68): viable para data prep con restricción explícita de no usar face_recognition (embeddings, R1).
  • InsightFace/face_recognition: prohibidos por R1, documentados.

Decisión: el motor propio se mantiene; los PoC viven en la rama benchmark-alternativas (commit 098b92c).

Actualización (2026-08-10): v3 — realismo desde la anatomía

El usuario probó la v1.1 y declaró “las imágenes no se ven realistas”. Otra sesión de IA (claude) hizo una reescritura en una máquina distinta (repo claude/retrato-hablado-realistic-images-ofclhs, commit 01d42ff) que no pudimos traer localmente; del transcript extrajimos las piezas útiles y las re-implementamos con criterio propio en main:

  • Geometría (canon de quintos): la causa de la “paleta” era la proporción (alto/ancho 2–3.5:1) y la coronilla plana. Ahora ratio ≈ 1.4, ojo = 1/5 de la anchura facial, ojos a media altura craneal, nariz en el tercio inferior, boca entre nariz y mentón; perfil de contorno con bóveda (13 puntos de control) y distribución de puntos por curvatura.
  • Textura por campo de altura + luz direccional: se acabó pintar con discos planos; ahora hay un campo de altura (cráneo elipsoidal, crestas de ceja, cuencas, puente nasal creciente, masas labiales, mentón, nasolabiales, surco de mandíbula) sombreado con luz direccional a dos escalas (fina = rasgos, gruesa = volumen). Pelo con nacimiento suave, ojos con esclerótica/iris/pupila/brillo/pestañas, marcas de edad.
  • Variación: azimut de luz y tono de piel por muestra (determinista)
    • 150 muestras → forma k=41, textura k=80 (antes 20/54).
  • Supersampleo 2× en el render (antialiasing de silueta, determinista, ~0.6 s a 1024 vs 0.1 s).
  • Formato de modelo v3; test JPEG frágil reemplazado por verificación PNG+roundtrip lossless (escaneo de bytes dentro de DEFLATE era probabilístico).
  • 11/11 tests pasando.

Techo honesto: con datos 100 % procedimentales esto es el límite del realismo alcanzable. El siguiente salto requiere (a) dataset real en el formato ya listo, o (b) decisión explícita de pasar textura a color (hoy gris por spec original) y de redistribuir landmarks (las orejas necesitarían puntos fuera del contorno de 20).