//! **Rasgos desde una foto** para parametrizar un personaje ESTILIZADO (capa 3 del //! detalle sub-voxel — ver [[detalle-sub-voxel-rugosidad]]). //! //! Lección clave (2026-07-18): voxelizar la foto LITERAL (busto fotográfico) cae de //! lleno en el valle de inquietud — a tamaño grande da terror. La especie tiene un //! **estilo propio** (el [`CharSpec`](llimphi_voxel::CharSpec)/Actor estilizado: gestos, //! personalidad, proporciones cuantizadas); la foto **sólo regula RASGOS** (color de //! piel, ropa). Este módulo NO fabrica geometría: sólo mira la persona recortada y //! promedia los colores que definen su look, para asignárselos a un personaje del kit. //! //! El recorte de la persona (para no promediar el fondo) llega por un [`Proveedor`] de //! `pixel-verbo` (u2net por el daemon); sin él, se muestrea el centro de la imagen. use pixel_verbo_core::{Imagen, OpPixel, Proveedor}; /// Socket por defecto del `pixel-verbo-daemon` (mismo que el binario): permite al /// studio pedirle un recorte de persona (u2net) sin linkear `ort`. pub fn socket_por_defecto() -> std::path::PathBuf { if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") { if !xdg.is_empty() { return std::path::PathBuf::from(xdg).join("pixel-verbo.sock"); } } std::path::PathBuf::from("/tmp/pixel-verbo.sock") } /// Error del pipeline de rasgos. #[derive(Debug)] pub enum RostroError { Decode(String), } impl std::fmt::Display for RostroError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { RostroError::Decode(m) => write!(f, "decodificar la foto: {m}"), } } } /// **Rasgos de color** que la foto aporta a un personaje estilizado. Son los tres /// campos de color del [`CharSpec`](llimphi_voxel::CharSpec)/Actor — nada de geometría. #[derive(Debug, Clone, Copy)] pub struct Rasgos { /// Piel (cara) `[r,g,b]` en `[0,1]`. pub skin: [f32; 3], /// **Pelo** `[r,g,b]` en `[0,1]`, muestreado de la banda superior de la cabeza (fiel al /// original, no derivado de la piel). pub hair: [f32; 3], /// Torso/remera `[r,g,b]` en `[0,1]`. pub shirt: [f32; 3], /// Pantalón `[r,g,b]` en `[0,1]` (derivado: retrato no lo muestra). pub pants: [f32; 3], /// Si el recorte de persona lo dio un modelo real (`true`) o se muestreó al centro. pub recorte_real: bool, } /// Decodifica `bytes` (PNG/JPEG/…) a `(rgba, w, h)`, reescalado a `lado_max` de ancho. fn decodificar(bytes: &[u8], lado_max: u32) -> Result<(Vec, u32, u32), RostroError> { let img = image::load_from_memory(bytes).map_err(|e| RostroError::Decode(e.to_string()))?; let rgba = img.to_rgba8(); let (w0, h0) = (rgba.width(), rgba.height()); if w0 <= lado_max { return Ok((rgba.into_raw(), w0, h0)); } let h = (h0 * lado_max / w0).max(1); let chica = image::imageops::resize(&rgba, lado_max, h, image::imageops::FilterType::Triangle); Ok((chica.into_raw(), lado_max, h)) } /// **Foto (bytes) → rasgos de un personaje.** Si `recorte` responde una máscara del /// mismo tamaño (u2net), se muestrean SÓLO los píxeles de la persona (piel del tercio /// superior, ropa del inferior); si no, se muestrea el centro de la imagen. Devuelve los /// tres colores para poblar un `CharSpec` estilizado (la especie pone su estilo). pub fn rasgos_desde_bytes( bytes: &[u8], recorte: Option<&dyn Proveedor>, ) -> Result { let (rgba, w, h) = decodificar(bytes, 384)?; // Alpha de persona: del recorte real (u2net) o todo opaco (centro). let (alpha, recorte_real) = match recorte { Some(prov) => match Imagen::nueva(w, h, rgba.clone()) .ok() .and_then(|img| prov.aplicar(&OpPixel::Segmentar { prompt: None }, Some(img)).ok()) { Some(seg) if seg.ancho == w && seg.alto == h => { (seg.bytes.chunks_exact(4).map(|p| p[3]).collect::>(), true) } _ => (vec![255u8; (w * h) as usize], false), }, None => (vec![255u8; (w * h) as usize], false), }; Ok(muestrear_rasgos(&rgba, &alpha, w, h, recorte_real)) } /// Promedia los colores característicos de la persona (`alpha > 128`), separando bien las /// zonas para que **piel y pelo salgan FIELES** (no derivados): **pelo** en la banda superior /// de la cabeza, **piel** en la franja de la cara (debajo del pelo, píxeles cálidos), **ropa** /// en la mitad inferior; pantalón derivado. Función pura → testeable sin red. pub fn muestrear_rasgos(rgba: &[u8], alpha: &[u8], w: u32, h: u32, recorte_real: bool) -> Rasgos { let (w, h) = (w as usize, h as usize); // Bbox de la persona. let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); let mut hay = false; for y in 0..h { for x in 0..w { if alpha[y * w + x] > 128 { hay = true; x0 = x0.min(x); y0 = y0.min(y); x1 = x1.max(x); y1 = y1.max(y); } } } if !hay { // Sin persona → centro de la imagen entera. x0 = w / 4; x1 = 3 * w / 4; y0 = h / 4; y1 = 3 * h / 4; } let bh = (y1 - y0).max(1); let xr = x0..=x1.min(w - 1); // Promedio simple de una banda vertical `[ya,yb)` (sólo persona), franja central en X // (evita bordes/orejas) → color fiel de esa zona. `None` si no hubo píxeles. let banda = |ya: usize, yb: usize, cx0: usize, cx1: usize| -> Option<[f32; 3]> { let (mut r, mut g, mut b, mut n) = (0u64, 0u64, 0u64, 0u64); for y in ya..yb.min(h) { for x in cx0..=cx1.min(w - 1) { if alpha[y * w + x] <= 128 { continue; } let o = (y * w + x) * 4; r += rgba[o] as u64; g += rgba[o + 1] as u64; b += rgba[o + 2] as u64; n += 1; } } (n > 0).then(|| [r as f32 / n as f32, g as f32 / n as f32, b as f32 / n as f32]) }; // Franja central en X (60% del ancho de la cara) para pelo/piel. let bw = (x1 - x0).max(1); let cx0 = x0 + bw * 20 / 100; let cx1 = x0 + bw * 80 / 100; // **Pelo**: banda superior de la cabeza (top 18% del bbox) — FIEL al original. let hair = banda(y0, y0 + bh * 18 / 100, cx0, cx1).unwrap_or([60.0, 44.0, 36.0]); // **Piel**: franja de la cara (22%–52% del bbox, debajo del pelo). Prioriza píxeles // cálidos (R≥G≥B) para no tomar sombra/pelo; si no hay suficientes, promedio fiel de la // franja entera (no un default inventado → fidelidad). let cara_y0 = y0 + bh * 22 / 100; let cara_y1 = y0 + bh * 52 / 100; let (mut sr, mut sg, mut sb, mut sn) = (0u64, 0u64, 0u64, 0u64); for y in cara_y0..cara_y1.min(h) { for x in cx0..=cx1.min(w - 1) { if alpha[y * w + x] <= 128 { continue; } let o = (y * w + x) * 4; let (r, g, b) = (rgba[o] as i32, rgba[o + 1] as i32, rgba[o + 2] as i32); let bri = r + g + b; // Piel: cálida (R≥G≥B) y ni muy oscura ni saturada — sin exigir contraste fuerte. if r >= g && g >= b && (r - b) > 6 && bri > 140 { sr += r as u64; sg += g as u64; sb += b as u64; sn += 1; } } } let skin = if sn > 30 { [sr as f32 / sn as f32, sg as f32 / sn as f32, sb as f32 / sn as f32] } else { banda(cara_y0, cara_y1, cx0, cx1).unwrap_or([220.0, 174.0, 138.0]) }; // Ropa: color medio de la mitad inferior del bbox (torso). let torso_y0 = y0 + bh * 50 / 100; let (mut cr, mut cg, mut cb, mut cn) = (0u64, 0u64, 0u64, 0u64); for y in torso_y0..=y1.min(h - 1) { for x in xr.clone() { if alpha[y * w + x] <= 128 { continue; } let o = (y * w + x) * 4; cr += rgba[o] as u64; cg += rgba[o + 1] as u64; cb += rgba[o + 2] as u64; cn += 1; } } let shirt = if cn > 0 { [cr as f32 / cn as f32, cg as f32 / cn as f32, cb as f32 / cn as f32] } else { [70.0, 120.0, 150.0] }; let n = |v: [f32; 3]| [v[0] / 255.0, v[1] / 255.0, v[2] / 255.0]; let skin = n(skin); let hair = n(hair); let shirt = n(shirt); // Pantalón: derivado (más oscuro y frío que la remera) — el retrato no lo muestra. let pants = [shirt[0] * 0.45, shirt[1] * 0.5, (shirt[2] * 0.7).min(1.0)]; Rasgos { skin, hair, shirt, pants, recorte_real } } #[cfg(test)] mod tests { use super::*; /// Imagen de prueba: **pelo oscuro** en la banda superior, cara clara (piel cálida) /// debajo, torso azul abajo, sobre un fondo que NO debe contaminar (recorte lo excluye). fn retrato(w: u32, h: u32) -> (Vec, Vec) { let mut rgba = vec![0u8; (w * h * 4) as usize]; let mut alpha = vec![0u8; (w * h) as usize]; let cx = w / 2; for y in 0..h { for x in 0..w { let i = (y * w + x) as usize; let o = i * 4; let dx = x as i32 - cx as i32; let dentro = (dx.unsigned_abs()) < w / 3; // persona = franja central if !dentro { rgba[o] = 20; rgba[o + 1] = 220; rgba[o + 2] = 20; rgba[o + 3] = 255; // fondo verde continue; } alpha[i] = 255; if y < h * 15 / 100 { rgba[o] = 70; rgba[o + 1] = 46; rgba[o + 2] = 30; // pelo castaño oscuro } else if y < h / 2 { rgba[o] = 220; rgba[o + 1] = 170; rgba[o + 2] = 140; // piel cálida } else { rgba[o] = 40; rgba[o + 1] = 70; rgba[o + 2] = 190; // torso azul } rgba[o + 3] = 255; } } (rgba, alpha) } #[test] fn extrae_piel_calida_y_ropa_sin_contaminar_el_fondo() { let (w, h) = (64, 96); let (rgba, alpha) = retrato(w, h); let r = muestrear_rasgos(&rgba, &alpha, w, h, true); assert!(r.skin[0] > r.skin[1] && r.skin[1] > r.skin[2], "piel cálida: {:?}", r.skin); assert!(r.skin[0] > 0.6, "piel clara: {:?}", r.skin); assert!(r.shirt[2] > r.shirt[0] && r.shirt[2] > r.shirt[1], "remera azul: {:?}", r.shirt); assert!(r.shirt[1] < 0.5, "el fondo verde NO contaminó la remera: {:?}", r.shirt); assert!(r.pants[2] < r.shirt[2], "pantalón más oscuro: {:?} vs {:?}", r.pants, r.shirt); } #[test] fn muestrea_pelo_fiel_y_distinto_de_la_piel() { // El pelo sale de la banda superior (castaño oscuro), NO derivado de la piel: más // oscuro que la piel y en su tono real. let (w, h) = (64, 96); let (rgba, alpha) = retrato(w, h); let r = muestrear_rasgos(&rgba, &alpha, w, h, true); let lum = |c: [f32; 3]| c[0] + c[1] + c[2]; assert!(lum(r.hair) < lum(r.skin), "pelo más oscuro que piel: {:?} vs {:?}", r.hair, r.skin); assert!(r.hair[0] > r.hair[2], "pelo cálido/castaño: {:?}", r.hair); assert!(r.hair[0] < 0.5, "pelo oscuro fiel (no piel clara): {:?}", r.hair); } #[test] fn sin_recorte_muestrea_el_centro_sin_panic() { let (w, h) = (40, 40); let (rgba, _) = retrato(w, h); let alpha = vec![255u8; (w * h) as usize]; let r = muestrear_rasgos(&rgba, &alpha, w, h, false); assert!(!r.recorte_real); for c in [r.skin, r.hair, r.shirt, r.pants] { for v in c { assert!((0.0..=1.0).contains(&v), "color en rango: {v}"); } } } #[test] fn bytes_corruptos_dan_error_decode() { let r = rasgos_desde_bytes(&[1, 2, 3, 4], None); assert!(matches!(r, Err(RostroError::Decode(_)))); } }