//! Foto → **relieve voxel** (medallón/placa) — herramienta de PROP/decoración. //! //! **OJO (lección 2026-07-18):** esto NO es el camino para PERSONAJES. Voxelizar una //! foto de una persona en 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`/Actor estilizado, con gestos y personalidad); la foto de un personaje //! **sólo debe regular RASGOS** (color de piel/ropa), no volverse geometría (ver //! `llimphi-voxel-studio::rostro`). Este módulo queda para RELIEVES de props/decoración //! (un medallón, un bajorrelieve), donde una foto cuantizada sí funciona como textura //! con volumen. Ver [[detalle-sub-voxel-rugosidad]]. //! //! Puro y `std`: recibe un buffer **RGBA ya decodificado** (el decode y la profundidad //! monocular por ONNX viven afuera, en el borde). El relieve por defecto es un **domo //! por transformada de distancia** al borde del recorte, con modos Luminancia y Plano. //! //! Puro y `std`: recibe un buffer **RGBA ya decodificado** (el decode del PNG/JPEG y, //! a futuro, la profundidad monocular por ONNX viven afuera, en el borde). El relieve //! por defecto es un **domo por transformada de distancia** al borde del recorte //! (el centro de la masa sobresale, los bordes se afinan → medallón/busto redondeado), //! con modos alternativos Luminancia (brillo = cercanía) y Plano (placa recortada). use llimphi_3d::VoxelGrid; use serde::{Deserialize, Serialize}; /// Cómo derivar la profundidad (grosor en Z) de cada píxel del recorte. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Relieve { /// Bulto redondeado por transformada de distancia al borde del recorte. Da un /// relieve creíble desde la sola silueta, sin depth real. Es el default. Domo, /// Brillo del píxel = cercanía (heightmap). Útil si la foto ya trae una pista de /// profundidad en su sombreado. Luminancia, /// Grosor uniforme (placa recortada, tipo sticker grueso). Plano, /// **Profundidad monocular real**: el grosor sale de un mapa de profundidad /// externo (blanco = cerca = grueso), estimado por un modelo tipo MiDaS/DPT en /// el borde (`pixel-verbo-onnx`, [`OpPixel::Profundidad`]). Requiere pasar el /// mapa a [`photo_to_grid_depth`]; con [`photo_to_grid`] (sin mapa) cae al domo. Profundidad, } /// Opciones de la voxelización de una foto. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct FotoOpts { /// Resolución objetivo en X (se downsamplea; el alto sale del aspecto). pub ancho: u32, /// Grosor máximo del relieve, en voxels. pub profundidad: u32, /// Cómo derivar la profundidad. pub relieve: Relieve, /// Píxeles con `alpha < alpha_min` = fondo (se recortan). `0` = sin recorte. pub alpha_min: u8, /// Jitter de color determinista `[0,1]` para dar grano (0 = color plano). pub grano: f32, } impl Default for FotoOpts { fn default() -> Self { Self { ancho: 56, profundidad: 12, relieve: Relieve::Domo, alpha_min: 128, grano: 0.06 } } } /// Resumen de la voxelización (para certificar sin introspección de la grilla). #[derive(Debug, Clone, Copy, PartialEq)] pub struct FotoStats { /// Voxels sólidos colocados. pub voxeles: u32, /// Píxeles del recorte (foreground) tras el downsample. pub recorte: u32, /// Grosor mínimo y máximo del relieve (en voxels) sobre el recorte. pub grosor_min: u32, pub grosor_max: u32, /// Color promedio de los voxels colocados (fidelidad al original). pub color_medio: [u8; 3], /// Dimensiones de la grilla resultante. pub dim: [u32; 3], } /// Luminancia Rec.709 `[0,1]`. #[inline] fn lum(rgb: [u8; 3]) -> f32 { (0.2126 * rgb[0] as f32 + 0.7152 * rgb[1] as f32 + 0.0722 * rgb[2] as f32) / 255.0 } /// Hash determinista → `[-1,1]` para el grano de color (sin RNG). #[inline] fn jitter(x: u32, y: u32, z: u32) -> f32 { let mut h = x.wrapping_mul(374761393).wrapping_add(y.wrapping_mul(668265263)); h = h.wrapping_add(z.wrapping_mul(2246822519)); h ^= h >> 13; h = h.wrapping_mul(1274126177); ((h & 0xffff) as f32 / 65535.0) * 2.0 - 1.0 } /// Transformada de distancia (chamfer 3-4, dos pasadas): a cada píxel del recorte le /// asigna la distancia (aprox euclídea) al fondo más cercano. Fondo = 0. fn distance_transform(mask: &[bool], w: usize, h: usize) -> Vec { let big = (w + h) as f32 * 2.0; let mut d = vec![0.0f32; w * h]; for i in 0..w * h { d[i] = if mask[i] { big } else { 0.0 }; } // Pasada hacia adelante (vecinos arriba/izquierda). for y in 0..h { for x in 0..w { let i = y * w + x; if !mask[i] { continue; } let mut m = d[i]; if x > 0 { m = m.min(d[i - 1] + 1.0); } if y > 0 { m = m.min(d[i - w] + 1.0); if x > 0 { m = m.min(d[i - w - 1] + 1.41421); } if x + 1 < w { m = m.min(d[i - w + 1] + 1.41421); } } d[i] = m; } } // Pasada hacia atrás (vecinos abajo/derecha). for y in (0..h).rev() { for x in (0..w).rev() { let i = y * w + x; if !mask[i] { continue; } let mut m = d[i]; if x + 1 < w { m = m.min(d[i + 1] + 1.0); } if y + 1 < h { m = m.min(d[i + w] + 1.0); if x + 1 < w { m = m.min(d[i + w + 1] + 1.41421); } if x > 0 { m = m.min(d[i + w - 1] + 1.41421); } } d[i] = m; } } d } /// **Voxeliza una foto** a un busto/medallón `VoxelGrid`, con el color del original y /// un relieve por [`Relieve`]. `rgba` = buffer fila-por-fila `w×h` (4 bytes/píxel, /// origen arriba-izquierda). Devuelve la grilla + un [`FotoStats`] para certificar. /// /// Sin mapa de profundidad: [`Relieve::Profundidad`] cae al domo (necesita el mapa). /// Usa [`photo_to_grid_depth`] para el relieve por profundidad real. pub fn photo_to_grid(rgba: &[u8], w: u32, h: u32, opts: &FotoOpts) -> (VoxelGrid, FotoStats) { photo_to_grid_inner(rgba, w, h, None, opts) } /// Como [`photo_to_grid`] pero con un **mapa de profundidad** real (`depth`, mismo /// `w×h` que `rgba`, RGBA; se lee el canal R = cercanía: blanco = cerca = grueso). /// Con [`Relieve::Profundidad`] el grosor sale de ese mapa (estimado por un modelo /// tipo MiDaS en el borde, `pixel-verbo-onnx`); con otros relieves el mapa se ignora. /// El mapa vive AFUERA a propósito: `llimphi-voxel` es `std` puro y no sabe de ONNX. pub fn photo_to_grid_depth( rgba: &[u8], w: u32, h: u32, depth: &[u8], opts: &FotoOpts, ) -> (VoxelGrid, FotoStats) { photo_to_grid_inner(rgba, w, h, Some(depth), opts) } fn photo_to_grid_inner( rgba: &[u8], w: u32, h: u32, depth: Option<&[u8]>, opts: &FotoOpts, ) -> (VoxelGrid, FotoStats) { let gw = opts.ancho.max(1); let gh = ((h.max(1) * gw) / w.max(1)).max(1); let prof = opts.profundidad.max(1); let gwz = gw as usize; let ghz = gh as usize; // Downsample nearest → color + máscara de recorte (+ profundidad si vino mapa). let mut color = vec![[0u8; 3]; gwz * ghz]; let mut mask = vec![false; gwz * ghz]; let depth_ok = depth.map(|d| d.len() >= (w * h * 4) as usize).unwrap_or(false); let mut depth_ds = vec![0.0f32; gwz * ghz]; if w > 0 && h > 0 && rgba.len() >= (w * h * 4) as usize { for gy in 0..ghz { for gx in 0..gwz { let sx = ((gx as u32 * w) / gw).min(w - 1); let sy = ((gy as u32 * h) / gh).min(h - 1); let si = ((sy * w + sx) * 4) as usize; let a = rgba[si + 3]; color[gy * gwz + gx] = [rgba[si], rgba[si + 1], rgba[si + 2]]; mask[gy * gwz + gx] = a >= opts.alpha_min; if depth_ok { // Canal R del mapa = cercanía [0,1] (blanco = cerca = grueso). depth_ds[gy * gwz + gx] = depth.unwrap()[si] as f32 / 255.0; } } } } // Campo de profundidad normalizado [0,1] por píxel del recorte. let mut profile = vec![0.0f32; gwz * ghz]; // Sin mapa real, Profundidad cae al domo (mejor que placa plana). let relieve = match (opts.relieve, depth_ok) { (Relieve::Profundidad, false) => Relieve::Domo, (r, _) => r, }; match relieve { Relieve::Profundidad => { // Grosor = profundidad estimada (MiDaS): blanco=cerca=grueso. Se re-normaliza // min-max SOBRE EL RECORTE para que el sujeto use todo el rango (el fondo ya // se fue por el alpha), y no quede aplastado si el mapa venía comprimido. let (mut mn, mut mx) = (f32::INFINITY, f32::NEG_INFINITY); for i in 0..gwz * ghz { if mask[i] { mn = mn.min(depth_ds[i]); mx = mx.max(depth_ds[i]); } } let rango = (mx - mn).max(1e-6); for i in 0..gwz * ghz { if mask[i] { profile[i] = ((depth_ds[i] - mn) / rango).clamp(0.0, 1.0); } } } Relieve::Domo => { let dt = distance_transform(&mask, gwz, ghz); let maxd = dt.iter().cloned().fold(0.0f32, f32::max).max(1.0); for i in 0..gwz * ghz { if mask[i] { let nd = (dt[i] / maxd).clamp(0.0, 1.0); // Perfil hemisférico: bulto redondo (borde→0, centro→1). profile[i] = (nd * (2.0 - nd)).sqrt(); } } } Relieve::Luminancia => { for i in 0..gwz * ghz { if mask[i] { profile[i] = lum(color[i]).clamp(0.0, 1.0); } } } Relieve::Plano => { for i in 0..gwz * ghz { if mask[i] { profile[i] = 1.0; } } } } let dz = prof + 1; let mut grid = VoxelGrid::new([gw, gh, dz]); let mut voxeles = 0u32; let mut recorte = 0u32; let mut grosor_min = u32::MAX; let mut grosor_max = 0u32; let mut acc = [0u64; 3]; for gy in 0..ghz { for gx in 0..gwz { let i = gy * gwz + gx; if !mask[i] { continue; } recorte += 1; // Grosor ≥1: aun el borde deja una lámina fina (no desaparece). let grosor = (1.0 + (prof - 1) as f32 * profile[i]).round() as u32; let grosor = grosor.clamp(1, prof); grosor_min = grosor_min.min(grosor); grosor_max = grosor_max.max(grosor); // Foto arriba-izquierda → voxel Y hacia arriba: flip vertical. let vy = gh - 1 - gy as u32; let base = color[i]; for z in 0..grosor { let j = if opts.grano > 0.0 { opts.grano * jitter(gx as u32, gy as u32, z) } else { 0.0 }; let c = [ ((base[0] as f32 * (1.0 + j)).clamp(0.0, 255.0)) as u8, ((base[1] as f32 * (1.0 + j)).clamp(0.0, 255.0)) as u8, ((base[2] as f32 * (1.0 + j)).clamp(0.0, 255.0)) as u8, ]; grid.set(gx as u32, vy, z, c); voxeles += 1; acc[0] += base[0] as u64; acc[1] += base[1] as u64; acc[2] += base[2] as u64; } } } if grosor_min == u32::MAX { grosor_min = 0; } let n = voxeles.max(1) as u64; let color_medio = [(acc[0] / n) as u8, (acc[1] / n) as u8, (acc[2] / n) as u8]; let stats = FotoStats { voxeles, recorte, grosor_min, grosor_max, color_medio, dim: [gw, gh, dz], }; (grid, stats) } /// Rotación en XZ por `facing` (0=+Z, 1=+X, 2=−Z, 3=−X), misma convención que la fauna. #[inline] fn rot(dx: i32, dz: i32, facing: u8) -> (i32, i32) { match facing & 3 { 0 => (dx, dz), 1 => (-dz, dx), 2 => (-dx, -dz), _ => (dz, -dx), } } /// **Planta un busto** (una grilla voxel de [`photo_to_grid`]/[`photo_to_grid_depth`]) /// DENTRO de una grilla destino, con la base en `(x, y, z)` y orientado por `facing` /// (0=+Z…). Es el puente que faltaba entre "una foto produce un busto suelto" y "ese /// ser vive en la escena junto al terreno y la fauna": aplica el pincel /// [`DETALLE_SER_VIVO`](crate::bicho::DETALLE_SER_VIVO) → el busto comparte la gramática /// sub-voxel del mundo (redondeo orgánico), como cualquier ser. Rota en XZ alrededor /// del centro del busto; los voxels que caen fuera de la grilla destino se descartan. pub fn stamp_busto(dst: &mut VoxelGrid, busto: &VoxelGrid, x: u32, y: u32, z: u32, facing: u8) { let [bw, bh, bd] = busto.dim(); let (cx, cz) = (bw as i32 / 2, bd as i32 / 2); let (r, b) = crate::bicho::DETALLE_SER_VIVO; dst.set_detail_brush(r, b); for by in 0..bh { for bz in 0..bd { for bx in 0..bw { let c = match busto.get(bx, by, bz) { Some(c) if c[3] > 127 => c, _ => continue, }; let (rx, rz) = rot(bx as i32 - cx, bz as i32 - cz, facing); let px = x as i32 + rx; let pz = z as i32 + rz; let py = y as i32 + by as i32; if px >= 0 && py >= 0 && pz >= 0 { // set_rgba estampa también el pincel de ser vivo (detalle por voxel). dst.set_rgba(px as u32, py as u32, pz as u32, [c[0], c[1], c[2]], c[3]); } } } } dst.clear_detail_brush(); } #[cfg(test)] mod tests { use super::*; /// Construye una imagen RGBA de prueba: un disco opaco (con un degradé de brillo /// horizontal) sobre fondo transparente. fn disco(w: u32, h: u32) -> Vec { let cx = w as f32 / 2.0; let cy = h as f32 / 2.0; let r = (w.min(h) as f32) * 0.4; let mut px = vec![0u8; (w * h * 4) as usize]; for y in 0..h { for x in 0..w { let i = ((y * w + x) * 4) as usize; let d = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt(); if d <= r { let b = (x as f32 / w as f32 * 255.0) as u8; // degradé horizontal px[i] = b; px[i + 1] = 120; px[i + 2] = 200; px[i + 3] = 255; } else { px[i + 3] = 0; // fondo transparente } } } px } #[test] fn recorta_y_da_relieve() { let (w, h) = (64, 64); let img = disco(w, h); let opts = FotoOpts { ancho: 48, profundidad: 14, relieve: Relieve::Domo, alpha_min: 128, grano: 0.0 }; let (grid, s) = photo_to_grid(&img, w, h, &opts); // Recorte: sólo el disco (~π·0.4²≈50% del área del bbox, pero el disco ocupa // ~π/4 del cuadrado → algo menos de la mitad). assert!(s.recorte > 0, "recortó algo"); let area = (s.dim[0] * s.dim[1]) as f32; assert!((s.recorte as f32) < area * 0.85, "recortó el fondo (no llenó todo)"); // Relieve: el domo tiene grosor variable (borde fino, centro grueso). assert!(s.grosor_max > s.grosor_min, "el domo varía en grosor: {} → {}", s.grosor_min, s.grosor_max); assert!(s.grosor_max >= 10, "el centro sobresale cerca del máximo ({})", s.grosor_max); assert!(s.voxeles > s.recorte, "hay volumen (más voxels que píxeles de recorte)"); assert_eq!(s.dim, grid.dim()); } #[test] fn fidelidad_de_color() { let (w, h) = (48, 48); let img = disco(w, h); let (_grid, s) = photo_to_grid(&img, w, h, &FotoOpts::default()); // El disco es azulado (g=120, b=200) con r variable: el color medio debe tener // b > r y b > g claramente (no se perdió el color del original). assert!(s.color_medio[2] > s.color_medio[1], "conserva el azul dominante: {:?}", s.color_medio); assert!(s.color_medio[2] > 120, "el azul sigue fuerte: {:?}", s.color_medio); } #[test] fn plano_es_grosor_uniforme() { let (w, h) = (32, 32); let img = disco(w, h); let opts = FotoOpts { relieve: Relieve::Plano, profundidad: 8, ..FotoOpts::default() }; let (_g, s) = photo_to_grid(&img, w, h, &opts); assert_eq!(s.grosor_min, s.grosor_max, "plano = grosor uniforme"); assert_eq!(s.grosor_max, 8, "grosor = profundidad"); } #[test] fn imagen_vacia_no_panica() { let (g, s) = photo_to_grid(&[], 0, 0, &FotoOpts::default()); assert_eq!(s.voxeles, 0); assert!(g.dim()[0] >= 1); } /// Mapa de profundidad en RAMPA horizontal (izq negro=lejos, der blanco=cerca) /// como el que emite MiDaS por el borde. Con `Relieve::Profundidad` el grosor /// debe SEGUIR la profundidad: la mitad cercana (derecha) más gruesa que la lejana. #[test] fn profundidad_real_manda_el_grosor() { let (w, h) = (64, 64); let img = disco(w, h); // sujeto opaco centrado // Depth RGBA: R = x/(w-1)*255 (rampa izq→der), gris, opaco. let mut depth = vec![0u8; (w * h * 4) as usize]; for y in 0..h { for x in 0..w { let i = ((y * w + x) * 4) as usize; let v = (x as f32 / (w - 1) as f32 * 255.0) as u8; depth[i] = v; depth[i + 1] = v; depth[i + 2] = v; depth[i + 3] = 255; } } let opts = FotoOpts { ancho: 48, profundidad: 14, relieve: Relieve::Profundidad, alpha_min: 128, grano: 0.0 }; let (grid, s) = photo_to_grid_depth(&img, w, h, &depth, &opts); assert!(s.recorte > 0); assert!(s.grosor_max > s.grosor_min, "la profundidad varía el grosor: {}→{}", s.grosor_min, s.grosor_max); // Grosor (voxels sólidos en Z) por mitad izquierda/derecha, sobre el recorte. let [gw, gh, dz] = grid.dim(); let (mut sum_izq, mut n_izq, mut sum_der, mut n_der) = (0u64, 0u64, 0u64, 0u64); for gx in 0..gw { for vy in 0..gh { let mut grosor = 0u64; for z in 0..dz { if grid.get(gx, vy, z).map(|c| c[3] > 127).unwrap_or(false) { grosor += 1; } } if grosor == 0 { continue; } if gx < gw / 2 { sum_izq += grosor; n_izq += 1; } else { sum_der += grosor; n_der += 1; } } } let prom_izq = sum_izq as f32 / n_izq.max(1) as f32; let prom_der = sum_der as f32 / n_der.max(1) as f32; assert!( prom_der > prom_izq + 2.0, "la mitad cercana (der) debe ser más gruesa que la lejana (izq): {prom_der:.1} vs {prom_izq:.1}" ); } /// Sin mapa, `Relieve::Profundidad` cae al domo (no plano) → grosor variable. #[test] fn profundidad_sin_mapa_cae_al_domo() { let (w, h) = (48, 48); let img = disco(w, h); let opts = FotoOpts { relieve: Relieve::Profundidad, ..FotoOpts::default() }; let (_g, s) = photo_to_grid(&img, w, h, &opts); assert!(s.grosor_max > s.grosor_min, "cae al domo (grosor variable), no a placa plana"); } /// El busto se planta en una escena junto al terreno, con detalle de ser vivo por /// voxel (comparte la gramática sub-voxel del mundo) y sin pisar el resto. #[test] fn stamp_busto_planta_el_ser_con_detalle() { let (w, h) = (48, 48); let img = disco(w, h); let (busto, s) = photo_to_grid(&img, w, h, &FotoOpts { grano: 0.0, ..FotoOpts::default() }); let [bw, bh, bd] = busto.dim(); // Escena destino más grande, con un "suelo" previo que no debe tocarse. let mut dst = VoxelGrid::new([bw + 20, bh + 8, bd + 20]); for gx in 0..dst.dim()[0] { for gz in 0..dst.dim()[2] { dst.set(gx, 0, gz, [80, 90, 70]); // suelo } } dst.reset_dirty(); let (ox, oz) = (10u32, 10u32); stamp_busto(&mut dst, &busto, ox, 1, oz, 0); // Cuenta voxels del busto plantados (por encima del suelo) y cuántos con detalle. let mut plantados = 0u32; let mut con_detalle = 0u32; for gx in 0..dst.dim()[0] { for gy in 1..dst.dim()[1] { for gz in 0..dst.dim()[2] { if dst.get(gx, gy, gz).map(|c| c[3] > 127).unwrap_or(false) { plantados += 1; if dst.detail_at(gx, gy, gz) != Some([0, 0]) { con_detalle += 1; } } } } } assert!(plantados > s.recorte, "plantó el volumen del busto ({plantados} vs recorte {})", s.recorte); assert_eq!(plantados, con_detalle, "todo el busto lleva el detalle de ser vivo"); // El suelo intacto (sin detalle autorado en y=0). assert_eq!(dst.detail_at(0, 0, 0), Some([0, 0]), "el suelo no recibió el pincel"); // Facing distinto ⇒ colocación distinta (no es no-op). let mut dst2 = VoxelGrid::new([bw + 20, bh + 8, bd + 20]); stamp_busto(&mut dst2, &busto, ox, 1, oz, 1); let mut difieren = false; 'outer: for gx in 0..dst.dim()[0] { for gy in 1..dst.dim()[1] { for gz in 0..dst.dim()[2] { let a = dst.get(gx, gy, gz).map(|c| c[3] > 127).unwrap_or(false); let b = dst2.get(gx, gy, gz).map(|c| c[3] > 127).unwrap_or(false); if a != b { difieren = true; break 'outer; } } } } assert!(difieren, "el facing rota el busto"); } }