tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
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}