gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ main llimphi-voxel-studio/src/rostro.rs Rust · 283 siq'ikuna · 11.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! **Rasgos desde una foto** para parametrizar un personaje ESTILIZADO (capa 3 del
2//! detalle sub-voxel — ver [[detalle-sub-voxel-rugosidad]]).
3//!
4//! Lección clave (2026-07-18): voxelizar la foto LITERAL (busto fotográfico) cae de
5//! lleno en el valle de inquietud — a tamaño grande da terror. La especie tiene un
6//! **estilo propio** (el [`CharSpec`](llimphi_voxel::CharSpec)/Actor estilizado: gestos,
7//! personalidad, proporciones cuantizadas); la foto **sólo regula RASGOS** (color de
8//! piel, ropa). Este módulo NO fabrica geometría: sólo mira la persona recortada y
9//! promedia los colores que definen su look, para asignárselos a un personaje del kit.
10//!
11//! El recorte de la persona (para no promediar el fondo) llega por un [`Proveedor`] de
12//! `pixel-verbo` (u2net por el daemon); sin él, se muestrea el centro de la imagen.
13
14use pixel_verbo_core::{Imagen, OpPixel, Proveedor};
15
16/// Socket por defecto del `pixel-verbo-daemon` (mismo que el binario): permite al
17/// studio pedirle un recorte de persona (u2net) sin linkear `ort`.
18pub fn socket_por_defecto() -> std::path::PathBuf {
19    if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
20        if !xdg.is_empty() {
21            return std::path::PathBuf::from(xdg).join("pixel-verbo.sock");
22        }
23    }
24    std::path::PathBuf::from("/tmp/pixel-verbo.sock")
25}
26
27/// Error del pipeline de rasgos.
28#[derive(Debug)]
29pub enum RostroError {
30    Decode(String),
31}
32
33impl std::fmt::Display for RostroError {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match self {
36            RostroError::Decode(m) => write!(f, "decodificar la foto: {m}"),
37        }
38    }
39}
40
41/// **Rasgos de color** que la foto aporta a un personaje estilizado. Son los tres
42/// campos de color del [`CharSpec`](llimphi_voxel::CharSpec)/Actor — nada de geometría.
43#[derive(Debug, Clone, Copy)]
44pub struct Rasgos {
45    /// Piel (cara) `[r,g,b]` en `[0,1]`.
46    pub skin: [f32; 3],
47    /// **Pelo** `[r,g,b]` en `[0,1]`, muestreado de la banda superior de la cabeza (fiel al
48    /// original, no derivado de la piel).
49    pub hair: [f32; 3],
50    /// Torso/remera `[r,g,b]` en `[0,1]`.
51    pub shirt: [f32; 3],
52    /// Pantalón `[r,g,b]` en `[0,1]` (derivado: retrato no lo muestra).
53    pub pants: [f32; 3],
54    /// Si el recorte de persona lo dio un modelo real (`true`) o se muestreó al centro.
55    pub recorte_real: bool,
56}
57
58/// Decodifica `bytes` (PNG/JPEG/…) a `(rgba, w, h)`, reescalado a `lado_max` de ancho.
59fn decodificar(bytes: &[u8], lado_max: u32) -> Result<(Vec<u8>, u32, u32), RostroError> {
60    let img = image::load_from_memory(bytes).map_err(|e| RostroError::Decode(e.to_string()))?;
61    let rgba = img.to_rgba8();
62    let (w0, h0) = (rgba.width(), rgba.height());
63    if w0 <= lado_max {
64        return Ok((rgba.into_raw(), w0, h0));
65    }
66    let h = (h0 * lado_max / w0).max(1);
67    let chica = image::imageops::resize(&rgba, lado_max, h, image::imageops::FilterType::Triangle);
68    Ok((chica.into_raw(), lado_max, h))
69}
70
71/// **Foto (bytes) → rasgos de un personaje.** Si `recorte` responde una máscara del
72/// mismo tamaño (u2net), se muestrean SÓLO los píxeles de la persona (piel del tercio
73/// superior, ropa del inferior); si no, se muestrea el centro de la imagen. Devuelve los
74/// tres colores para poblar un `CharSpec` estilizado (la especie pone su estilo).
75pub fn rasgos_desde_bytes(
76    bytes: &[u8],
77    recorte: Option<&dyn Proveedor>,
78) -> Result<Rasgos, RostroError> {
79    let (rgba, w, h) = decodificar(bytes, 384)?;
80
81    // Alpha de persona: del recorte real (u2net) o todo opaco (centro).
82    let (alpha, recorte_real) = match recorte {
83        Some(prov) => match Imagen::nueva(w, h, rgba.clone())
84            .ok()
85            .and_then(|img| prov.aplicar(&OpPixel::Segmentar { prompt: None }, Some(img)).ok())
86        {
87            Some(seg) if seg.ancho == w && seg.alto == h => {
88                (seg.bytes.chunks_exact(4).map(|p| p[3]).collect::<Vec<u8>>(), true)
89            }
90            _ => (vec![255u8; (w * h) as usize], false),
91        },
92        None => (vec![255u8; (w * h) as usize], false),
93    };
94
95    Ok(muestrear_rasgos(&rgba, &alpha, w, h, recorte_real))
96}
97
98/// Promedia los colores característicos de la persona (`alpha > 128`), separando bien las
99/// zonas para que **piel y pelo salgan FIELES** (no derivados): **pelo** en la banda superior
100/// de la cabeza, **piel** en la franja de la cara (debajo del pelo, píxeles cálidos), **ropa**
101/// en la mitad inferior; pantalón derivado. Función pura → testeable sin red.
102pub fn muestrear_rasgos(rgba: &[u8], alpha: &[u8], w: u32, h: u32, recorte_real: bool) -> Rasgos {
103    let (w, h) = (w as usize, h as usize);
104    // Bbox de la persona.
105    let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize);
106    let mut hay = false;
107    for y in 0..h {
108        for x in 0..w {
109            if alpha[y * w + x] > 128 {
110                hay = true;
111                x0 = x0.min(x);
112                y0 = y0.min(y);
113                x1 = x1.max(x);
114                y1 = y1.max(y);
115            }
116        }
117    }
118    if !hay {
119        // Sin persona → centro de la imagen entera.
120        x0 = w / 4;
121        x1 = 3 * w / 4;
122        y0 = h / 4;
123        y1 = 3 * h / 4;
124    }
125    let bh = (y1 - y0).max(1);
126    let xr = x0..=x1.min(w - 1);
127
128    // Promedio simple de una banda vertical `[ya,yb)` (sólo persona), franja central en X
129    // (evita bordes/orejas) → color fiel de esa zona. `None` si no hubo píxeles.
130    let banda = |ya: usize, yb: usize, cx0: usize, cx1: usize| -> Option<[f32; 3]> {
131        let (mut r, mut g, mut b, mut n) = (0u64, 0u64, 0u64, 0u64);
132        for y in ya..yb.min(h) {
133            for x in cx0..=cx1.min(w - 1) {
134                if alpha[y * w + x] <= 128 {
135                    continue;
136                }
137                let o = (y * w + x) * 4;
138                r += rgba[o] as u64; g += rgba[o + 1] as u64; b += rgba[o + 2] as u64; n += 1;
139            }
140        }
141        (n > 0).then(|| [r as f32 / n as f32, g as f32 / n as f32, b as f32 / n as f32])
142    };
143
144    // Franja central en X (60% del ancho de la cara) para pelo/piel.
145    let bw = (x1 - x0).max(1);
146    let cx0 = x0 + bw * 20 / 100;
147    let cx1 = x0 + bw * 80 / 100;
148
149    // **Pelo**: banda superior de la cabeza (top 18% del bbox) — FIEL al original.
150    let hair = banda(y0, y0 + bh * 18 / 100, cx0, cx1).unwrap_or([60.0, 44.0, 36.0]);
151
152    // **Piel**: franja de la cara (22%–52% del bbox, debajo del pelo). Prioriza píxeles
153    // cálidos (R≥G≥B) para no tomar sombra/pelo; si no hay suficientes, promedio fiel de la
154    // franja entera (no un default inventado → fidelidad).
155    let cara_y0 = y0 + bh * 22 / 100;
156    let cara_y1 = y0 + bh * 52 / 100;
157    let (mut sr, mut sg, mut sb, mut sn) = (0u64, 0u64, 0u64, 0u64);
158    for y in cara_y0..cara_y1.min(h) {
159        for x in cx0..=cx1.min(w - 1) {
160            if alpha[y * w + x] <= 128 {
161                continue;
162            }
163            let o = (y * w + x) * 4;
164            let (r, g, b) = (rgba[o] as i32, rgba[o + 1] as i32, rgba[o + 2] as i32);
165            let bri = r + g + b;
166            // Piel: cálida (R≥G≥B) y ni muy oscura ni saturada — sin exigir contraste fuerte.
167            if r >= g && g >= b && (r - b) > 6 && bri > 140 {
168                sr += r as u64; sg += g as u64; sb += b as u64; sn += 1;
169            }
170        }
171    }
172    let skin = if sn > 30 {
173        [sr as f32 / sn as f32, sg as f32 / sn as f32, sb as f32 / sn as f32]
174    } else {
175        banda(cara_y0, cara_y1, cx0, cx1).unwrap_or([220.0, 174.0, 138.0])
176    };
177
178    // Ropa: color medio de la mitad inferior del bbox (torso).
179    let torso_y0 = y0 + bh * 50 / 100;
180    let (mut cr, mut cg, mut cb, mut cn) = (0u64, 0u64, 0u64, 0u64);
181    for y in torso_y0..=y1.min(h - 1) {
182        for x in xr.clone() {
183            if alpha[y * w + x] <= 128 {
184                continue;
185            }
186            let o = (y * w + x) * 4;
187            cr += rgba[o] as u64; cg += rgba[o + 1] as u64; cb += rgba[o + 2] as u64; cn += 1;
188        }
189    }
190    let shirt = if cn > 0 {
191        [cr as f32 / cn as f32, cg as f32 / cn as f32, cb as f32 / cn as f32]
192    } else {
193        [70.0, 120.0, 150.0]
194    };
195
196    let n = |v: [f32; 3]| [v[0] / 255.0, v[1] / 255.0, v[2] / 255.0];
197    let skin = n(skin);
198    let hair = n(hair);
199    let shirt = n(shirt);
200    // Pantalón: derivado (más oscuro y frío que la remera) — el retrato no lo muestra.
201    let pants = [shirt[0] * 0.45, shirt[1] * 0.5, (shirt[2] * 0.7).min(1.0)];
202    Rasgos { skin, hair, shirt, pants, recorte_real }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    /// Imagen de prueba: **pelo oscuro** en la banda superior, cara clara (piel cálida)
210    /// debajo, torso azul abajo, sobre un fondo que NO debe contaminar (recorte lo excluye).
211    fn retrato(w: u32, h: u32) -> (Vec<u8>, Vec<u8>) {
212        let mut rgba = vec![0u8; (w * h * 4) as usize];
213        let mut alpha = vec![0u8; (w * h) as usize];
214        let cx = w / 2;
215        for y in 0..h {
216            for x in 0..w {
217                let i = (y * w + x) as usize;
218                let o = i * 4;
219                let dx = x as i32 - cx as i32;
220                let dentro = (dx.unsigned_abs()) < w / 3; // persona = franja central
221                if !dentro {
222                    rgba[o] = 20; rgba[o + 1] = 220; rgba[o + 2] = 20; rgba[o + 3] = 255; // fondo verde
223                    continue;
224                }
225                alpha[i] = 255;
226                if y < h * 15 / 100 {
227                    rgba[o] = 70; rgba[o + 1] = 46; rgba[o + 2] = 30; // pelo castaño oscuro
228                } else if y < h / 2 {
229                    rgba[o] = 220; rgba[o + 1] = 170; rgba[o + 2] = 140; // piel cálida
230                } else {
231                    rgba[o] = 40; rgba[o + 1] = 70; rgba[o + 2] = 190; // torso azul
232                }
233                rgba[o + 3] = 255;
234            }
235        }
236        (rgba, alpha)
237    }
238
239    #[test]
240    fn extrae_piel_calida_y_ropa_sin_contaminar_el_fondo() {
241        let (w, h) = (64, 96);
242        let (rgba, alpha) = retrato(w, h);
243        let r = muestrear_rasgos(&rgba, &alpha, w, h, true);
244        assert!(r.skin[0] > r.skin[1] && r.skin[1] > r.skin[2], "piel cálida: {:?}", r.skin);
245        assert!(r.skin[0] > 0.6, "piel clara: {:?}", r.skin);
246        assert!(r.shirt[2] > r.shirt[0] && r.shirt[2] > r.shirt[1], "remera azul: {:?}", r.shirt);
247        assert!(r.shirt[1] < 0.5, "el fondo verde NO contaminó la remera: {:?}", r.shirt);
248        assert!(r.pants[2] < r.shirt[2], "pantalón más oscuro: {:?} vs {:?}", r.pants, r.shirt);
249    }
250
251    #[test]
252    fn muestrea_pelo_fiel_y_distinto_de_la_piel() {
253        // El pelo sale de la banda superior (castaño oscuro), NO derivado de la piel: más
254        // oscuro que la piel y en su tono real.
255        let (w, h) = (64, 96);
256        let (rgba, alpha) = retrato(w, h);
257        let r = muestrear_rasgos(&rgba, &alpha, w, h, true);
258        let lum = |c: [f32; 3]| c[0] + c[1] + c[2];
259        assert!(lum(r.hair) < lum(r.skin), "pelo más oscuro que piel: {:?} vs {:?}", r.hair, r.skin);
260        assert!(r.hair[0] > r.hair[2], "pelo cálido/castaño: {:?}", r.hair);
261        assert!(r.hair[0] < 0.5, "pelo oscuro fiel (no piel clara): {:?}", r.hair);
262    }
263
264    #[test]
265    fn sin_recorte_muestrea_el_centro_sin_panic() {
266        let (w, h) = (40, 40);
267        let (rgba, _) = retrato(w, h);
268        let alpha = vec![255u8; (w * h) as usize];
269        let r = muestrear_rasgos(&rgba, &alpha, w, h, false);
270        assert!(!r.recorte_real);
271        for c in [r.skin, r.hair, r.shirt, r.pants] {
272            for v in c {
273                assert!((0.0..=1.0).contains(&v), "color en rango: {v}");
274            }
275        }
276    }
277
278    #[test]
279    fn bytes_corruptos_dan_error_decode() {
280        let r = rasgos_desde_bytes(&[1, 2, 3, 4], None);
281        assert!(matches!(r, Err(RostroError::Decode(_))));
282    }
283}