From eec48362e5d4889fe2c4f0f2adc38fff9976edca Mon Sep 17 00:00:00 2001 From: Munka Date: Wed, 14 Oct 2026 20:17:54 -0500 Subject: [PATCH] v1.23.0: Rellenar (LaMa rellena lo pintado en el editor de revelado) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Para «Rellenar con IA» de Eliminar en Labstream OS: la página manda el trozo de la foto alrededor de lo pintado, en 8 bits con el hueco en el alfa, y la app lo devuelve con el hueco relleno por LaMa y lo demás byte a byte como estaba (src-tauri/src/ia/relleno.rs). - Comando nuevo ia_rellenar (permitido solo para el origen de la app). ia_estado e ia_preparar llevan { que: "relleno" }; sin él siguen hablando del sujeto, como la 1.22, y el estado dice ahora de qué IA habla (así la página sabe que esta app rellena). - LaMa (Apache-2.0) en la exportación ONNX de tamaño libre de g-ronimo, desde el servidor de la app con la sesión de la pestaña y fijada por huella (≈210 MB). Se rellena al tamaño del trozo en múltiplos de 32, con 1024 px de lado como mucho. - En la CPU: las dos exportaciones de LaMa probadas fallan con DirectML en una RTX 5080 (su FFT, sea el operador DFT o productos de matrices: 80070057, también con las formas fijadas y sin optimizar); en la CPU, ~3 s a 512 px y ~10 s a 1024, y no le quita memoria a la tarjeta. - Las piezas por IA: el motor (ONNX Runtime + DirectML) es de las dos; cada modelo se baja cuando se usa la suya. Cada modelo abierto se suelta a los 2 min sin uso. Pruebas nuevas: el tamaño al que se rellena y que fuera del hueco no cambia nada; y una de punta a punta en este equipo (ignorada en CI). Co-Authored-By: Claude Opus 5.5 --- README.md | 7 + package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/build.rs | 2 +- src-tauri/capabilities/default.json | 3 +- .../autogenerated/ia_rellenar.toml | 11 + src-tauri/src/ia/mod.rs | 208 +++++++++++++----- src-tauri/src/ia/motor.rs | 47 +++- src-tauri/src/ia/piezas.rs | 50 ++++- src-tauri/src/ia/relleno.rs | 123 +++++++++++ src-tauri/src/lib.rs | 2 +- src-tauri/tauri.conf.json | 2 +- 13 files changed, 393 insertions(+), 68 deletions(-) create mode 100644 src-tauri/permissions/autogenerated/ia_rellenar.toml create mode 100644 src-tauri/src/ia/relleno.rs diff --git a/README.md b/README.md index 9faf581..ae20419 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,13 @@ NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac: (`build.rs` + `capabilities/default.json`). El modelo se suelta de la tarjeta a los 2 min sin uso. Prueba de punta a punta en la tarjeta del equipo: `LS_IA_DIR= cargo test --lib de_punta_a_punta -- --ignored --nocapture`. +- **Rellenar** (v1.23.0, Windows): en Eliminar del editor de revelado, lo pintado lo + rellena LaMa (`src-tauri/src/ia/relleno.rs`, comando `ia_rellenar`) con lo que debería + haber detrás; la página lo guarda como un parche de la foto. Corre en la CPU (LaMa no + corre con DirectML: su FFT falla en la tarjeta): ~3 s a 512 px, ~10 s a 1024 (el tope). + La primera vez baja el motor (si no estaba) y el modelo (≈210 MB, del servidor de la + app con la sesión de la pestaña, fijado por huella). `ia_estado` / `ia_preparar` llevan + `{ que: "relleno" }`; sin él, hablan del sujeto (como la 1.22). - **Pestañas con nombre** (v1.21.0): `window.open(url, "ls-…")` o un enlace con `target="ls-…"` abre la pestaña la primera vez y las siguientes **vuelven a ella** en vez de abrir otra. La página recibe la URL nueva por diff --git a/package.json b/package.json index f2a4a35..b46a793 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "labstream-desktop", - "version": "1.22.0", + "version": "1.23.0", "private": true, "description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ffb2e24..5fe58c4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2367,7 +2367,7 @@ dependencies = [ [[package]] name = "labstream-desktop" -version = "1.22.0" +version = "1.23.0" dependencies = [ "active-win-pos-rs", "device_query", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 644093a..734696e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "labstream-desktop" -version = "1.22.0" +version = "1.23.0" description = "Cliente de escritorio de Labstream OS" authors = ["Labstream SAS"] edition = "2021" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 07213e6..01aff5b 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -3,7 +3,7 @@ fn main() { // permiso en capabilities/default.json, y es lo que deja que la página remota los llame. tauri_build::try_build( tauri_build::Attributes::new() - .app_manifest(tauri_build::AppManifest::new().commands(&["ia_estado", "ia_preparar", "ia_sujeto"])), + .app_manifest(tauri_build::AppManifest::new().commands(&["ia_estado", "ia_preparar", "ia_sujeto", "ia_rellenar"])), ) .expect("tauri-build"); } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c9532a3..a428897 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -21,6 +21,7 @@ "core:window:allow-start-dragging", "allow-ia-estado", "allow-ia-preparar", - "allow-ia-sujeto" + "allow-ia-sujeto", + "allow-ia-rellenar" ] } diff --git a/src-tauri/permissions/autogenerated/ia_rellenar.toml b/src-tauri/permissions/autogenerated/ia_rellenar.toml new file mode 100644 index 0000000..0bdfd14 --- /dev/null +++ b/src-tauri/permissions/autogenerated/ia_rellenar.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-ia-rellenar" +description = "Enables the ia_rellenar command without any pre-configured scope." +commands.allow = ["ia_rellenar"] + +[[permission]] +identifier = "deny-ia-rellenar" +description = "Denies the ia_rellenar command without any pre-configured scope." +commands.deny = ["ia_rellenar"] diff --git a/src-tauri/src/ia/mod.rs b/src-tauri/src/ia/mod.rs index 670c01a..13d8640 100644 --- a/src-tauri/src/ia/mod.rs +++ b/src-tauri/src/ia/mod.rs @@ -1,38 +1,44 @@ -// ── La IA EN EL EQUIPO: la máscara de sujeto de Labstream OS con la tarjeta NVIDIA (1.22) ────── +// ── La IA EN EL EQUIPO: la máscara de sujeto (1.22) y Rellenar (1.23) de Labstream OS ────────── // // Munka: «los pcs que usan esta función tienen Nvidia de 16 GB… el modelo se ejecuta en local y se -// conecta y sube el resultado a la app». En el editor de revelado (modo edición de un set), «Sujeto» -// y «Fondo» no los busca el navegador sino esta app: ONNX Runtime con DirectML sobre la tarjeta -// NVIDIA y BiRefNet en media precisión (piezas.rs): 0,3 s por foto en vez de 2,5–3 s, y sin depender -// del WebGPU de la pestaña. +// conecta y sube el resultado a la app». En el editor de revelado (modo edición de un set): +// - «Sujeto» y «Fondo» los busca esta app: ONNX Runtime con DirectML sobre la tarjeta NVIDIA y +// BiRefNet en media precisión (piezas.rs): 0,3 s por foto en vez de 2,5–3 s en el navegador. +// - «Rellenar» (Eliminar con IA): LaMa rellena lo pintado (relleno.rs), en la CPU (motor.rs: +// abrir_en_cpu; LaMa no corre con DirectML). // -// La página (lib/revelado/ia-local en labstream-os) llama a tres comandos, permitidos SOLO para el +// La página (lib/revelado/ia-local en labstream-os) llama a cuatro comandos, permitidos SOLO para el // origen de la app (capabilities/default.json + el manifiesto de build.rs): -// ia_estado → ¿se puede aquí?, ¿está ya bajado?, cuánto falta, qué tarjeta -// ia_preparar → baja lo que falte (eventos «ia-progreso» { fase, parte } a esa pestaña) y abre -// el modelo en la tarjeta +// ia_estado ({ que }) → ¿se puede aquí?, ¿está ya bajado?, cuánto falta, qué tarjeta +// ia_preparar ({ que }) → baja lo que falte (eventos «ia-progreso» { fase, parte } a esa pestaña) +// y abre el modelo // ia_sujeto ← RGBA en el cuerpo (binario) + x-ancho / x-alto // → [ancho u32][alto u32][un byte por punto] (lib/revelado/ia-local: leerRespuesta) -// El resultado vuelve a la página, que lo pone en la máscara y lo guarda con el revelado de la foto. +// ia_rellenar ← RGBA con el hueco en el alfa + x-ancho / x-alto +// → [ancho u32][alto u32][RGB, 3 bytes por punto] +// `que` es "sujeto" (o nada: la página de la 1.22) o "relleno". El resultado vuelve a la página, que lo +// pone en la máscara o en un parche de la foto y lo guarda con su revelado. // -// Nada se baja ni se carga al arrancar la app: solo cuando alguien pide la máscara. Lo bajado vive en -// los datos de la app del equipo (`carpeta`), comprobado con su huella (bajar.rs). El modelo abierto -// ocupa la tarjeta (~7 GB mientras busca: DirectML guarda lo que pidió): se suelta a los SOLTAR_TRAS -// sin usarlo, que los editores trabajan con Resolve abierto. +// Nada se baja ni se carga al arrancar la app: solo cuando alguien lo pide. Lo bajado vive en los +// datos de la app del equipo (`carpeta`), comprobado con su huella (bajar.rs). Cada modelo abierto +// ocupa memoria (el del sujeto, ~7 GB de la tarjeta mientras busca: DirectML guarda lo que pidió): +// se suelta a los SOLTAR_TRAS sin usarlo, que los editores trabajan con Resolve abierto. mod bajar; mod imagen; mod motor; mod piezas; +mod relleno; mod tarjeta; +use piezas::Que; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter, EventTarget, Manager, Runtime, Webview}; -/// Sin usarlo este rato, el modelo se suelta de la tarjeta (la siguiente foto tarda ~2 s más). +/// Sin usarlo este rato, el modelo se suelta (de la tarjeta, o de la memoria el de rellenar). const SOLTAR_TRAS: Duration = Duration::from_secs(2 * 60); struct Abierto { @@ -40,21 +46,32 @@ struct Abierto { usado: Instant, } -static MODELO: Mutex> = Mutex::new(None); +/// El modelo abierto de cada IA: el del sujeto (DirectML) y el de rellenar (CPU). +static SUJETO: Mutex> = Mutex::new(None); +static RELLENO: Mutex> = Mutex::new(None); /// Una preparación a la vez (dos pestañas que piden a la vez no bajan dos veces). static PREPARANDO: Mutex<()> = Mutex::new(()); static VIGILANTE: std::sync::Once = std::sync::Once::new(); +fn abierto(que: Que) -> &'static Mutex> { + match que { + Que::Sujeto => &SUJETO, + Que::Relleno => &RELLENO, + } +} + fn carpeta(app: &AppHandle) -> Result { app.path().app_local_data_dir().map(|d| d.join("ia")).map_err(|e| format!("No hay carpeta de datos de la app: {e}")) } -fn faltan(dir: &std::path::Path) -> u64 { - piezas::todas().iter().filter(|p| !bajar::esta(dir, p)).map(|p| p.bytes).sum() +fn faltan(dir: &std::path::Path, que: Que) -> u64 { + piezas::de(que).into_iter().filter(|p| !bajar::esta(dir, p)).map(|p| p.bytes).sum() } #[derive(serde::Serialize)] pub struct Estado { + /// de qué IA habla (así la página sabe que esta app conoce Rellenar) + que: &'static str, disponible: bool, motivo: Option, listo: bool, @@ -63,24 +80,34 @@ pub struct Estado { tarjeta: Option, } -#[tauri::command] -pub fn ia_estado(app: AppHandle) -> Estado { - let t = tarjeta::nvidia(); - let motivo = if !cfg!(all(windows, target_arch = "x86_64")) { - Some("La IA del equipo es para Windows con tarjeta NVIDIA.".to_string()) - } else if t.is_none() { +/// Por qué esta IA no puede correr aquí (None: sí puede). El sujeto pide NVIDIA; rellenar va en la CPU. +fn motivo(que: Que, tarjeta: Option<&tarjeta::Tarjeta>) -> Option { + if !cfg!(all(windows, target_arch = "x86_64")) { + Some("La IA del equipo es para la app de Windows.".to_string()) + } else if que == Que::Sujeto && tarjeta.is_none() { Some("Este equipo no tiene tarjeta NVIDIA.".to_string()) } else { None - }; + } +} + +#[tauri::command] +pub fn ia_estado(app: AppHandle, que: Option) -> Estado { + let que = Que::de(que.as_deref()); + let t = tarjeta::nvidia(); + let motivo = motivo(que, t.as_ref()); let dir = carpeta(&app).ok(); - let falta = dir.as_deref().map(faltan).unwrap_or(u64::MAX); + let falta = dir.as_deref().map(|d| faltan(d, que)).unwrap_or(u64::MAX); Estado { + que: que.nombre(), disponible: motivo.is_none() && dir.is_some(), motivo, listo: falta == 0, faltan: if falta == u64::MAX { 0 } else { falta }, - modelo: piezas::NOMBRE_MODELO, + modelo: match que { + Que::Sujeto => piezas::NOMBRE_MODELO, + Que::Relleno => piezas::NOMBRE_RELLENO, + }, tarjeta: t.map(|t| t.nombre), } } @@ -91,7 +118,8 @@ fn avisar(app: &AppHandle, label: &str, fase: &str, parte: f64) { /// Baja lo que falte y deja el modelo abierto (en un hilo aparte: puede tardar minutos). #[tauri::command] -pub async fn ia_preparar(app: AppHandle, webview: Webview) -> Result<(), String> { +pub async fn ia_preparar(app: AppHandle, webview: Webview, que: Option) -> Result<(), String> { + let que = Que::de(que.as_deref()); let label = webview.label().to_string(); // La sesión de la pestaña, para bajar el modelo del servidor de la app (solo viaja a él). Aquí y // no en el hilo de abajo: leer cookies de WebView2 desde un comando síncrono se queda colgado. @@ -99,23 +127,23 @@ pub async fn ia_preparar(app: AppHandle, webview: Webview) -> .ok() .and_then(|u| webview.cookies_for_url(u).ok()) .map(|cs| cs.iter().map(|c| format!("{}={}", c.name(), c.value())).collect::>().join("; ")); - tauri::async_runtime::spawn_blocking(move || preparar(&app, &label, sesion.as_deref())) + tauri::async_runtime::spawn_blocking(move || preparar(&app, &label, sesion.as_deref(), que)) .await .map_err(|e| format!("La IA no pudo prepararse: {e}"))? } -fn preparar(app: &AppHandle, label: &str, sesion: Option<&str>) -> Result<(), String> { - if tarjeta::nvidia().is_none() { - return Err("Este equipo no tiene tarjeta NVIDIA.".into()); +fn preparar(app: &AppHandle, label: &str, sesion: Option<&str>, que: Que) -> Result<(), String> { + if let Some(m) = motivo(que, tarjeta::nvidia().as_ref()) { + return Err(m); } let _uno = PREPARANDO.lock().unwrap_or_else(|e| e.into_inner()); let dir = carpeta(app)?; - let total = faltan(&dir); + let total = faltan(&dir, que); if total > 0 { let hecho = AtomicU64::new(0); let mut ultimo = 0u64; avisar(app, label, "bajando", 0.0); - for p in piezas::todas() { + for p in piezas::de(que) { bajar::bajar(&dir, p, sesion, &mut |n| { let h = hecho.fetch_add(n, Ordering::Relaxed) + n; // un aviso cada ~1 % @@ -129,40 +157,45 @@ fn preparar(app: &AppHandle, label: &str, sesion: Option<&str>) - e })?; } - crate::salud::anotar("INFO", "ia", &format!("IA bajada al equipo ({} MB)", total / 1_000_000)); + crate::salud::anotar("INFO", "ia", &format!("IA de {} bajada al equipo ({} MB)", que.nombre(), total / 1_000_000)); } - let mut m = MODELO.lock().unwrap_or_else(|e| e.into_inner()); + let mut m = abierto(que).lock().unwrap_or_else(|e| e.into_inner()); if m.is_none() { avisar(app, label, "preparando", 0.0); let t0 = Instant::now(); - let motor = motor::Motor::abrir(&dir).map_err(|e| { - crate::salud::anotar("ERROR", "ia", &format!("no se pudo abrir el modelo: {e}")); + let motor = match que { + Que::Sujeto => motor::Motor::abrir(&dir), + Que::Relleno => motor::Motor::abrir_en_cpu(&dir, piezas::ARCHIVO_RELLENO), + } + .map_err(|e| { + crate::salud::anotar("ERROR", "ia", &format!("no se pudo abrir el modelo de {}: {e}", que.nombre())); e })?; - crate::salud::anotar("INFO", "ia", &format!("modelo abierto en {:.1} s ({})", t0.elapsed().as_secs_f32(), motor.donde())); + crate::salud::anotar("INFO", "ia", &format!("modelo de {} abierto en {:.1} s ({})", que.nombre(), t0.elapsed().as_secs_f32(), motor.donde())); *m = Some(Abierto { motor, usado: Instant::now() }); vigilar(); } Ok(()) } -/// Suelta el modelo de la tarjeta cuando lleva SOLTAR_TRAS sin usarse. +/// Suelta cada modelo cuando lleva SOLTAR_TRAS sin usarse. fn vigilar() { VIGILANTE.call_once(|| { std::thread::spawn(|| loop { std::thread::sleep(Duration::from_secs(15)); - let mut m = MODELO.lock().unwrap_or_else(|e| e.into_inner()); - if m.as_ref().map(|a| a.usado.elapsed() >= SOLTAR_TRAS).unwrap_or(false) { - *m = None; - crate::salud::anotar("INFO", "ia", "modelo soltado de la tarjeta (sin uso)"); + for que in [Que::Sujeto, Que::Relleno] { + let mut m = abierto(que).lock().unwrap_or_else(|e| e.into_inner()); + if m.as_ref().map(|a| a.usado.elapsed() >= SOLTAR_TRAS).unwrap_or(false) { + *m = None; + crate::salud::anotar("INFO", "ia", &format!("modelo de {} soltado (sin uso)", que.nombre())); + } } }); }); } -/// Busca el sujeto en la foto que manda la página (RGBA en el cuerpo, medidas en las cabeceras). -#[tauri::command] -pub async fn ia_sujeto(request: tauri::ipc::Request<'_>) -> Result { +/// La foto que manda la página: RGBA en el cuerpo, medidas en las cabeceras. +fn foto_de(request: &tauri::ipc::Request<'_>) -> Result<(Vec, usize, usize), String> { let medida = |k: &str| request.headers().get(k).and_then(|v| v.to_str().ok()).and_then(|v| v.parse::().ok()).unwrap_or(0); let (ancho, alto) = (medida("x-ancho"), medida("x-alto")); let tauri::ipc::InvokeBody::Raw(rgba) = request.body() else { @@ -171,7 +204,13 @@ pub async fn ia_sujeto(request: tauri::ipc::Request<'_>) -> Result 16_384 || alto > 16_384 || rgba.len() != ancho * alto * 4 { return Err("La foto llegó con unas medidas que no cuadran.".into()); } - let rgba = rgba.clone(); + Ok((rgba.clone(), ancho, alto)) +} + +/// Busca el sujeto en la foto que manda la página. +#[tauri::command] +pub async fn ia_sujeto(request: tauri::ipc::Request<'_>) -> Result { + let (rgba, ancho, alto) = foto_de(&request)?; tauri::async_runtime::spawn_blocking(move || buscar(&rgba, ancho, alto)) .await .map_err(|e| format!("La IA falló: {e}"))? @@ -179,7 +218,7 @@ pub async fn ia_sujeto(request: tauri::ipc::Request<'_>) -> Result Result, String> { - let mut m = MODELO.lock().unwrap_or_else(|e| e.into_inner()); + let mut m = SUJETO.lock().unwrap_or_else(|e| e.into_inner()); let Some(a) = m.as_mut() else { return Err("La IA no está preparada.".into()); }; @@ -201,6 +240,40 @@ fn buscar(rgba: &[u8], ancho: usize, alto: usize) -> Result, String> { } } +/// Rellena el hueco (el alfa) de la foto que manda la página (relleno.rs). +#[tauri::command] +pub async fn ia_rellenar(request: tauri::ipc::Request<'_>) -> Result { + let (rgba, ancho, alto) = foto_de(&request)?; + tauri::async_runtime::spawn_blocking(move || rellenar(&rgba, ancho, alto)) + .await + .map_err(|e| format!("La IA falló: {e}"))? + .map(tauri::ipc::Response::new) +} + +fn rellenar(rgba: &[u8], ancho: usize, alto: usize) -> Result, String> { + let mut m = RELLENO.lock().unwrap_or_else(|e| e.into_inner()); + let Some(a) = m.as_mut() else { + return Err("La IA no está preparada.".into()); + }; + let (mw, mh) = relleno::tamano_modelo(ancho, alto); + let t0 = Instant::now(); + let r = a.motor.correr_forma(&[1, 4, mh, mw], relleno::entrada(rgba, ancho, alto, mw, mh)); + a.usado = Instant::now(); + match r { + Ok((_, planos)) if planos.len() == 3 * mw * mh => { + crate::salud::anotar("INFO", "ia", &format!("relleno de {ancho}×{alto} (a {mw}×{mh}) en {:.1} s", t0.elapsed().as_secs_f32())); + let rgb = relleno::salida(&planos, mw, mh, rgba, ancho, alto); + Ok(imagen::respuesta(ancho as u32, alto as u32, &rgb)) + } + Ok((forma, _)) => Err(format!("El modelo contestó {forma:?} y se esperaba [1, 3, {mh}, {mw}].")), + Err(e) => { + *m = None; + crate::salud::anotar("ERROR", "ia", &format!("no se pudo rellenar: {e}")); + Err(e) + } + } +} + #[cfg(all(test, windows))] mod pruebas { use super::*; @@ -214,7 +287,7 @@ mod pruebas { #[ignore] fn de_punta_a_punta_en_la_tarjeta() { let dir = PathBuf::from(std::env::var("LS_IA_DIR").expect("LS_IA_DIR")); - for p in piezas::todas() { + for p in piezas::de(Que::Sujeto) { let t = Instant::now(); bajar::bajar(&dir, p, None, &mut |_| {}).unwrap(); eprintln!("{}: listo en {:.1} s", p.nombre, t.elapsed().as_secs_f32()); @@ -259,4 +332,39 @@ mod pruebas { } } } + + /// Rellenar de punta a punta en ESTE equipo: LS_IA_DIR con el motor ya bajado y el modelo de + /// rellenar (piezas::ARCHIVO_RELLENO); unas rayas con un hueco cuadrado, al tamaño LS_IA_LADOS. + #[test] + #[ignore] + fn relleno_de_punta_a_punta() { + let dir = PathBuf::from(std::env::var("LS_IA_DIR").expect("LS_IA_DIR")); + let t = Instant::now(); + let mut m = motor::Motor::abrir_en_cpu(&dir, piezas::ARCHIVO_RELLENO).unwrap(); + eprintln!("lama abierto en {:.1} s ({})", t.elapsed().as_secs_f32(), m.donde()); + let lados: Vec = std::env::var("LS_IA_LADOS").unwrap_or("600,1400".into()).split(',').filter_map(|v| v.trim().parse().ok()).collect(); + for &lado in &lados { + let (w, h) = (lado, lado * 2 / 3); + let mut rgba = vec![0u8; w * h * 4]; + for y in 0..h { + for x in 0..w { + let i = (y * w + x) * 4; + let raya: u8 = if (x / 16) % 2 == 0 { 40 } else { 0 }; + rgba[i] = 80 + raya; + rgba[i + 1] = 120; + rgba[i + 2] = 50 + raya; + let hueco = x > w * 2 / 5 && x < w * 3 / 5 && y > h * 2 / 5 && y < h * 3 / 5; + rgba[i + 3] = if hueco { 255 } else { 0 }; + } + } + let (mw, mh) = relleno::tamano_modelo(w, h); + let t = Instant::now(); + let (_, planos) = m.correr_forma(&[1, 4, mh, mw], relleno::entrada(&rgba, w, h, mw, mh)).unwrap(); + let rgb = relleno::salida(&planos, mw, mh, &rgba, w, h); + let c = (h / 2 * w + w / 2) * 3; + eprintln!("{w}×{h} (a {mw}×{mh}): {:.2} s, centro {:?}, esquina {:?}", t.elapsed().as_secs_f32(), &rgb[c..c + 3], &rgb[..3]); + // fuera del hueco, lo que había + assert_eq!(&rgb[..3], &rgba[..3]); + } + } } diff --git a/src-tauri/src/ia/motor.rs b/src-tauri/src/ia/motor.rs index 7d8b3df..b503cfa 100644 --- a/src-tauri/src/ia/motor.rs +++ b/src-tauri/src/ia/motor.rs @@ -51,6 +51,7 @@ mod imp { pub struct Motor { sesion: Session, entrada: String, + donde: &'static str, } impl Motor { @@ -86,24 +87,48 @@ mod imp { .commit_from_file(dir.join(archivo)) .map_err(|e| format!("No se pudo abrir el modelo: {e}"))?; let entrada = sesion.inputs().first().map(|i| i.name().to_string()).ok_or("El modelo no tiene entrada.")?; - Ok(Motor { sesion, entrada }) + Ok(Motor { sesion, entrada, donde: "DirectML" }) + } + + /// En la CPU (Rellenar: LaMa). Las exportaciones de LaMa no corren con DirectML —su FFT, sea el + /// operador DFT o productos de matrices, falla con 80070057 en una RTX 5080 (14-oct-2026)— y en + /// la CPU sí: 2,7 s a 512 px y 10 s a 1024. Y no le quita memoria a la tarjeta (Resolve). + pub fn abrir_en_cpu(dir: &Path, archivo: &str) -> Result { + let env = entorno(dir)?; + let e = |e: ort::Error| format!("No se pudo abrir el modelo: {e}"); + let sesion = Session::builder(env) + .map_err(|e| format!("No se pudo abrir el modelo: {e}"))? + .with_optimization_level(GraphOptimizationLevel::Level3) + .map_err(e)? + .with_execution_providers([ep::CPU::default().build()]) + .map_err(e)? + .commit_from_file(dir.join(archivo)) + .map_err(|e| format!("No se pudo abrir el modelo: {e}"))?; + let entrada = sesion.inputs().first().map(|i| i.name().to_string()).ok_or("El modelo no tiene entrada.")?; + Ok(Motor { sesion, entrada, donde: "CPU" }) } pub fn donde(&self) -> &'static str { - "DirectML" + self.donde } /// La entrada [1, 3, lado, lado] → la salida [1, 1, lado, lado] (la última, si hay varias). pub fn correr(&mut self, entrada: Vec, lado: usize) -> Result, String> { - let t = Tensor::from_array(([1usize, 3, lado, lado], entrada)).map_err(|e| format!("La foto no entró al modelo: {e}"))?; - let nombre = self.entrada.clone(); - let salidas = self.sesion.run(ort::inputs![nombre => t]).map_err(|e| format!("La tarjeta no pudo buscar el sujeto: {e}"))?; - let ultima = salidas.len().checked_sub(1).ok_or("El modelo no contestó.")?; - let (forma, datos) = salidas[ultima].try_extract_tensor::().map_err(|e| format!("El modelo contestó algo raro: {e}"))?; + let (forma, datos) = self.correr_forma(&[1, 3, lado, lado], entrada)?; if datos.len() != lado * lado { return Err(format!("El modelo contestó {forma:?} y se esperaba {lado} × {lado}.")); } - Ok(datos.to_vec()) + Ok(datos) + } + + /// Su única entrada con esta forma → la última salida (su forma y sus datos). + pub fn correr_forma(&mut self, forma: &[usize], entrada: Vec) -> Result<(Vec, Vec), String> { + let t = Tensor::from_array((forma.to_vec(), entrada)).map_err(|e| format!("La foto no entró al modelo: {e}"))?; + let nombre = self.entrada.clone(); + let salidas = self.sesion.run(ort::inputs![nombre => t]).map_err(|e| format!("La tarjeta no pudo con la foto: {e}"))?; + let ultima = salidas.len().checked_sub(1).ok_or("El modelo no contestó.")?; + let (forma, datos) = salidas[ultima].try_extract_tensor::().map_err(|e| format!("El modelo contestó algo raro: {e}"))?; + Ok((forma.iter().copied().collect(), datos.to_vec())) } } } @@ -118,12 +143,18 @@ mod imp { pub fn abrir(_dir: &Path) -> Result { Err("La IA del equipo es para Windows con tarjeta NVIDIA.".into()) } + pub fn abrir_en_cpu(_dir: &Path, _archivo: &str) -> Result { + Err("La IA del equipo es para Windows.".into()) + } pub fn donde(&self) -> &'static str { "-" } pub fn correr(&mut self, _entrada: Vec, _lado: usize) -> Result, String> { Err("La IA del equipo es para Windows con tarjeta NVIDIA.".into()) } + pub fn correr_forma(&mut self, _forma: &[usize], _entrada: Vec) -> Result<(Vec, Vec), String> { + Err("La IA del equipo es para Windows con tarjeta NVIDIA.".into()) + } } } diff --git a/src-tauri/src/ia/piezas.rs b/src-tauri/src/ia/piezas.rs index 509f275..24c5648 100644 --- a/src-tauri/src/ia/piezas.rs +++ b/src-tauri/src/ia/piezas.rs @@ -1,4 +1,6 @@ -// ── Lo que la IA baja la primera vez (≈700 MB), de dónde, y con qué huella ── +// ── Lo que la IA baja la primera vez, de dónde, y con qué huella ── +// El motor (≈215 MB) es de las dos IA; cada modelo se baja solo cuando se usa la suya: el del sujeto +// (≈490 MB) o el de rellenar (≈210 MB). // // Fijado por versión y huella SHA-256 (comprobadas al bajarlo el 14-oct-2026): si algún día cambia // lo que hay en esa dirección, no se usa. @@ -11,6 +13,10 @@ // 5080: 0,3 s por foto y el mismo borde que el original en la CPU. // El HR (2048 px, matting) se probó y NO va: su convolución deformable, deshecha en GatherND en la // exportación oficial, ocupa 14–15 GB con DirectML y tarda de 6 a 45 s (14-oct-2026). +// - Rellenar (1.23): LaMa (Apache-2.0, advimman/big-lama), la exportación ONNX de tamaño libre de +// g-ronimo (Hugging Face g-ronimo/lama, `lama.onnx`, la que usa wipe.photos), copiada al servidor de +// la app (api/modelos/relleno-equipo). Entrada `input` [1, 4, alto, ancho] (múltiplos de 32), +// salida `output` [1, 3, alto, ancho], 0..1. Corre en la CPU (ver motor.rs: abrir_en_cpu). // - ONNX Runtime 1.24.4 con DirectML (NuGet de Microsoft): onnxruntime.dll para x64. // - DirectML 1.15.4 (NuGet de Microsoft): DirectML.dll para Windows x64 (el paquete trae también // las de Xbox y las de depurar: se saca por su ruta exacta). @@ -18,6 +24,8 @@ use super::bajar::{Forma, Pieza}; pub const NOMBRE_MODELO: &str = "BiRefNet"; +pub const NOMBRE_RELLENO: &str = "LaMa"; +pub const ARCHIVO_RELLENO: &str = "lama-g-ronimo-44bb92b2.onnx"; /// El lado de la entrada y de la salida del modelo. pub const LADO: usize = 1024; @@ -25,6 +33,38 @@ pub const ARCHIVO_MODELO: &str = "birefnet-general-epoch_244-fp16.onnx"; pub const DLL_ORT: &str = "onnxruntime.dll"; pub const DLL_DIRECTML: &str = "DirectML.dll"; +/// De qué IA se habla: la máscara de sujeto o rellenar. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Que { + Sujeto, + Relleno, +} + +impl Que { + pub fn de(v: Option<&str>) -> Que { + if v == Some("relleno") { + Que::Relleno + } else { + Que::Sujeto + } + } + pub fn nombre(self) -> &'static str { + match self { + Que::Sujeto => "sujeto", + Que::Relleno => "relleno", + } + } +} + +static RELLENO: Pieza = Pieza { + nombre: "lama-g-ronimo-44bb92b2", + url: "https://os.labstreamsas.com/api/modelos/relleno-equipo", + sha256: "44bb92b2693db40bb088dda226365a3aa8080a561584b8b0287ab11a97f9e9e7", + bytes: 209_181_361, + forma: Forma::Archivo(ARCHIVO_RELLENO), + con_sesion: true, +}; + static PIEZAS: [Pieza; 3] = [ Pieza { nombre: "onnxruntime-directml-1.24.4", @@ -52,6 +92,10 @@ static PIEZAS: [Pieza; 3] = [ }, ]; -pub fn todas() -> &'static [Pieza] { - &PIEZAS +/// Lo que necesita cada IA: el motor (los dos primeros) y su modelo. +pub fn de(que: Que) -> Vec<&'static Pieza> { + match que { + Que::Sujeto => PIEZAS.iter().collect(), + Que::Relleno => vec![&PIEZAS[0], &PIEZAS[1], &RELLENO], + } } diff --git a/src-tauri/src/ia/relleno.rs b/src-tauri/src/ia/relleno.rs new file mode 100644 index 0000000..218c153 --- /dev/null +++ b/src-tauri/src/ia/relleno.rs @@ -0,0 +1,123 @@ +// ── RELLENAR: el hueco pintado en el editor, rellenado por LaMa (1.23) ── +// +// La página (lib/revelado/relleno y revelado-relleno de labstream-os) manda el trozo de la foto +// alrededor de lo pintado, en 8 bits, con el hueco en el alfa (255 = rellenar). Aquí se lleva al +// tamaño del modelo —el del trozo, en múltiplos de 32, con 1024 px de lado como mucho: LaMa +// generaliza bien hasta ahí y en la CPU tarda 10 s; a 1536, 75—, se rellena, se vuelve a su tamaño y +// se devuelve el trozo con el hueco relleno y lo demás tal cual estaba (byte a byte). +// +// El modelo (LaMa, Apache-2.0; la exportación ONNX de tamaño libre de g-ronimo): una entrada +// [1, 4, alto, ancho] —RGB 0..1 con el hueco a cero, y el hueco 0/1— y una salida [1, 3, alto, ancho] +// RGB 0..1, la foto entera. + +/// El lado mayor al que se rellena. +pub const LADO_MAX: usize = 1024; + +/// El tamaño al que se rellena un trozo de `ancho` × `alto`: el suyo (o reducido a LADO_MAX de lado +/// mayor), en múltiplos de 32 y con 64 como mínimo. +pub fn tamano_modelo(ancho: usize, alto: usize) -> (usize, usize) { + let mayor = ancho.max(alto) as f64; + let k = if mayor > LADO_MAX as f64 { LADO_MAX as f64 / mayor } else { 1.0 }; + let a32 = |v: f64| ((((v * k) / 32.0).round() as usize).max(2)) * 32; + (a32(ancho as f64), a32(alto as f64)) +} + +/// Remuestrea `de` (`canales` por punto, `aw` × `ah`) a `bw` × `bh` entre sus cuatro vecinos. +pub fn remuestrear(de: &[f32], aw: usize, ah: usize, canales: usize, bw: usize, bh: usize) -> Vec { + let mut out = vec![0f32; bw * bh * canales]; + for y in 0..bh { + let fy = (((y as f32 + 0.5) / bh as f32) * ah as f32 - 0.5).clamp(0.0, (ah - 1) as f32); + let y0 = fy.floor() as usize; + let y1 = (y0 + 1).min(ah - 1); + let wy = fy - y0 as f32; + for x in 0..bw { + let fx = (((x as f32 + 0.5) / bw as f32) * aw as f32 - 0.5).clamp(0.0, (aw - 1) as f32); + let x0 = fx.floor() as usize; + let x1 = (x0 + 1).min(aw - 1); + let wx = fx - x0 as f32; + for c in 0..canales { + let p = |xx: usize, yy: usize| de[(yy * aw + xx) * canales + c]; + let arriba = p(x0, y0) + (p(x1, y0) - p(x0, y0)) * wx; + let abajo = p(x0, y1) + (p(x1, y1) - p(x0, y1)) * wx; + out[(y * bw + x) * canales + c] = arriba + (abajo - arriba) * wy; + } + } + } + out +} + +/// La entrada del modelo (planos [4, mh, mw]) a partir del RGBA de la página, y el hueco a su tamaño. +pub fn entrada(rgba: &[u8], ancho: usize, alto: usize, mw: usize, mh: usize) -> Vec { + let n = ancho * alto; + let mut rgb = Vec::with_capacity(n * 3); + let mut hueco = Vec::with_capacity(n); + for i in 0..n { + rgb.extend_from_slice(&[rgba[i * 4] as f32 / 255.0, rgba[i * 4 + 1] as f32 / 255.0, rgba[i * 4 + 2] as f32 / 255.0]); + hueco.push(if rgba[i * 4 + 3] >= 128 { 1.0 } else { 0.0 }); + } + let rgb = remuestrear(&rgb, ancho, alto, 3, mw, mh); + let hueco = remuestrear(&hueco, ancho, alto, 1, mw, mh); + let m = mw * mh; + let mut out = vec![0f32; 4 * m]; + for i in 0..m { + // lo que toca el hueco, aunque sea un poco al reducir, es hueco + let h = if hueco[i] > 0.02 { 1.0 } else { 0.0 }; + for c in 0..3 { + out[c * m + i] = if h > 0.0 { 0.0 } else { rgb[i * 3 + c] }; + } + out[3 * m + i] = h; + } + out +} + +/// La salida del modelo (planos [3, mh, mw], 0..1) de vuelta a `ancho` × `alto`: dentro del hueco lo +/// rellenado; fuera, el RGB que llegó, tal cual. RGB 8 bits. +pub fn salida(planos: &[f32], mw: usize, mh: usize, rgba: &[u8], ancho: usize, alto: usize) -> Vec { + let m = mw * mh; + let mut intercalado = vec![0f32; m * 3]; + for i in 0..m { + for c in 0..3 { + intercalado[i * 3 + c] = planos[c * m + i]; + } + } + let grande = remuestrear(&intercalado, mw, mh, 3, ancho, alto); + let n = ancho * alto; + let mut out = vec![0u8; n * 3]; + for i in 0..n { + let dentro = rgba[i * 4 + 3] >= 128; + for c in 0..3 { + out[i * 3 + c] = if dentro { (grande[i * 3 + c].clamp(0.0, 1.0) * 255.0).round() as u8 } else { rgba[i * 4 + c] }; + } + } + out +} + +#[cfg(test)] +mod pruebas { + use super::*; + + #[test] + fn el_tamano_del_modelo() { + assert_eq!(tamano_modelo(500, 300), (512, 288)); + assert_eq!(tamano_modelo(2048, 1024), (1024, 512)); + assert_eq!(tamano_modelo(20, 10), (64, 64)); + let (w, h) = tamano_modelo(1600, 1067); + assert!(w <= 1024 && w % 32 == 0 && h % 32 == 0); + } + + #[test] + fn fuera_del_hueco_queda_lo_que_habia() { + // 2 × 1: el primero es hueco + let rgba = [10, 20, 30, 255, 40, 50, 60, 0]; + let e = entrada(&rgba, 2, 1, 64, 64); + assert_eq!(e.len(), 4 * 64 * 64); + // a la izquierda (el hueco) el RGB va a cero y el canal del hueco a 1 + assert_eq!(e[0], 0.0); + assert_eq!(e[3 * 64 * 64], 1.0); + // lo de la red: todo gris + let planos = vec![0.5f32; 3 * 64 * 64]; + let s = salida(&planos, 64, 64, &rgba, 2, 1); + assert_eq!(&s[..3], &[128, 128, 128]); + assert_eq!(&s[3..], &[40, 50, 60]); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c9f01ba..20f1544 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1697,7 +1697,7 @@ pub fn run() { // builder para que TODAS las webviews de pestaña puedan usarlo. .register_asynchronous_uri_scheme_protocol("lsthumb", responder_lsthumb) // La IA del equipo (src/ia): la máscara de sujeto con la tarjeta NVIDIA, para la página. - .invoke_handler(tauri::generate_handler![ia::ia_estado, ia::ia_preparar, ia::ia_sujeto]); + .invoke_handler(tauri::generate_handler![ia::ia_estado, ia::ia_preparar, ia::ia_sujeto, ia::ia_rellenar]); let app = builder .manage(ShellState::default()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f2054c2..f8fdc5e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Labstream OS", - "version": "1.22.0", + "version": "1.23.0", "identifier": "co.labstream.os", "build": { "frontendDist": "../dist"