Comparar commits

..

2 Commits

Autor SHA1 Mensaje Fecha
Munka
ed4c0ed529 Rellenar: lados en potencias de 2, el resto en espejo (la FFT de la CPU solo es rápida así)
ONNX Runtime en la CPU solo hace rápida la FFT de LaMa con lados que son potencias de 2:
512 × 512 son 2,7 s y 704 × 448, casi los mismos píxeles, 10 s. El trozo se reduce lo justo
para caber en 512 (o en 1024 si pasa de 768 px) y se completa hasta la potencia de 2 con su
propio borde en espejo, sin hueco; al volver se descarta. Un trozo de 700 px pasa de 10 s
a 4 s; uno de 300 × 200, 2 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:21:16 -05:00
Munka
eec48362e5 v1.23.0: Rellenar (LaMa rellena lo pintado en el editor de revelado)
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 <noreply@anthropic.com>
2026-10-14 20:17:54 -05:00
Se han modificado 13 ficheros con 433 adiciones y 68 borrados

Ver fichero

@@ -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=<carpeta> 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

Ver fichero

@@ -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": {

Ver fichero

@@ -2367,7 +2367,7 @@ dependencies = [
[[package]]
name = "labstream-desktop"
version = "1.22.0"
version = "1.23.0"
dependencies = [
"active-win-pos-rs",
"device_query",

Ver fichero

@@ -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"

Ver fichero

@@ -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");
}

Ver fichero

@@ -21,6 +21,7 @@
"core:window:allow-start-dragging",
"allow-ia-estado",
"allow-ia-preparar",
"allow-ia-sujeto"
"allow-ia-sujeto",
"allow-ia-rellenar"
]
}

Ver fichero

@@ -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"]

Ver fichero

@@ -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<Option<Abierto>> = Mutex::new(None);
/// El modelo abierto de cada IA: el del sujeto (DirectML) y el de rellenar (CPU).
static SUJETO: Mutex<Option<Abierto>> = Mutex::new(None);
static RELLENO: Mutex<Option<Abierto>> = 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<Option<Abierto>> {
match que {
Que::Sujeto => &SUJETO,
Que::Relleno => &RELLENO,
}
}
fn carpeta<R: Runtime>(app: &AppHandle<R>) -> Result<PathBuf, String> {
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<String>,
listo: bool,
@@ -63,24 +80,34 @@ pub struct Estado {
tarjeta: Option<String>,
}
#[tauri::command]
pub fn ia_estado<R: Runtime>(app: AppHandle<R>) -> 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<String> {
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<R: Runtime>(app: AppHandle<R>, que: Option<String>) -> 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<R: Runtime>(app: &AppHandle<R>, 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<R: Runtime>(app: AppHandle<R>, webview: Webview<R>) -> Result<(), String> {
pub async fn ia_preparar<R: Runtime>(app: AppHandle<R>, webview: Webview<R>, que: Option<String>) -> 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<R: Runtime>(app: AppHandle<R>, webview: Webview<R>) ->
.ok()
.and_then(|u| webview.cookies_for_url(u).ok())
.map(|cs| cs.iter().map(|c| format!("{}={}", c.name(), c.value())).collect::<Vec<_>>().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<R: Runtime>(app: &AppHandle<R>, label: &str, sesion: Option<&str>) -> Result<(), String> {
if tarjeta::nvidia().is_none() {
return Err("Este equipo no tiene tarjeta NVIDIA.".into());
fn preparar<R: Runtime>(app: &AppHandle<R>, 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<R: Runtime>(app: &AppHandle<R>, 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<tauri::ipc::Response, String> {
/// La foto que manda la página: RGBA en el cuerpo, medidas en las cabeceras.
fn foto_de(request: &tauri::ipc::Request<'_>) -> Result<(Vec<u8>, usize, usize), String> {
let medida = |k: &str| request.headers().get(k).and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<usize>().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<tauri::ipc::R
if ancho < 2 || alto < 2 || ancho > 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<tauri::ipc::Response, String> {
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<tauri::ipc::R
}
fn buscar(rgba: &[u8], ancho: usize, alto: usize) -> Result<Vec<u8>, 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<Vec<u8>, 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<tauri::ipc::Response, String> {
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<Vec<u8>, 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 p = relleno::plan(ancho, alto);
let t0 = Instant::now();
let r = a.motor.correr_forma(&[1, 4, p.ph, p.pw], relleno::entrada(rgba, ancho, alto, p));
a.usado = Instant::now();
match r {
Ok((_, planos)) if planos.len() == 3 * p.pw * p.ph => {
crate::salud::anotar("INFO", "ia", &format!("relleno de {ancho}×{alto} (a {}×{} en {}×{}) en {:.1} s", p.sw, p.sh, p.pw, p.ph, t0.elapsed().as_secs_f32()));
let rgb = relleno::salida(&planos, p, 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, {}, {}].", p.ph, p.pw)),
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<usize> = 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 p = relleno::plan(w, h);
let t = Instant::now();
let (_, planos) = m.correr_forma(&[1, 4, p.ph, p.pw], relleno::entrada(&rgba, w, h, p)).unwrap();
let rgb = relleno::salida(&planos, p, &rgba, w, h);
let c = (h / 2 * w + w / 2) * 3;
eprintln!("{w}×{h} ({p:?}): {:.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]);
}
}
}

Ver fichero

@@ -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<Motor, String> {
let env = entorno(dir)?;
let e = |e: ort::Error<ort::session::builder::SessionBuilder>| 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<f32>, lado: usize) -> Result<Vec<f32>, 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::<f32>().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<f32>) -> Result<(Vec<i64>, Vec<f32>), 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::<f32>().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<Motor, String> {
Err("La IA del equipo es para Windows con tarjeta NVIDIA.".into())
}
pub fn abrir_en_cpu(_dir: &Path, _archivo: &str) -> Result<Motor, String> {
Err("La IA del equipo es para Windows.".into())
}
pub fn donde(&self) -> &'static str {
"-"
}
pub fn correr(&mut self, _entrada: Vec<f32>, _lado: usize) -> Result<Vec<f32>, String> {
Err("La IA del equipo es para Windows con tarjeta NVIDIA.".into())
}
pub fn correr_forma(&mut self, _forma: &[usize], _entrada: Vec<f32>) -> Result<(Vec<i64>, Vec<f32>), String> {
Err("La IA del equipo es para Windows con tarjeta NVIDIA.".into())
}
}
}

Ver fichero

@@ -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],
}
}

163
src-tauri/src/ia/relleno.rs Archivo normal
Ver fichero

@@ -0,0 +1,163 @@
// ── 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, 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 tamaño (`plan`): LaMa hace FFT de lo que ve, y la de ONNX Runtime en la CPU solo es rápida con
// lados que son potencias de 2: 512 × 512 son 2,7 s y 704 × 448 —casi los mismos píxeles— 10 s
// (14-oct-2026). Así que el trozo se reduce lo justo para caber en 512 (o en 1024 si pasa de 768 px:
// LaMa generaliza bien hasta ahí, 10 s) y se completa hasta la potencia de 2 con su propio borde en
// espejo (sin hueco: es contexto). Lo de fuera de la foto reducida se descarta al volver.
//
// 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;
/// Cómo entra un trozo de `ancho` × `alto` al modelo: reducido a `sw` × `sh` y completado en espejo
/// hasta `pw` × `ph` (potencias de 2).
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub struct Plan {
pub sw: usize,
pub sh: usize,
pub pw: usize,
pub ph: usize,
}
pub fn plan(ancho: usize, alto: usize) -> Plan {
let mayor = ancho.max(alto);
let lado = if mayor <= 768 { 512 } else { LADO_MAX };
let k = (lado as f64 / mayor as f64).min(1.0);
let sw = ((ancho as f64 * k).round() as usize).clamp(8, lado);
let sh = ((alto as f64 * k).round() as usize).clamp(8, lado);
Plan { sw, sh, pw: sw.next_power_of_two().max(64), ph: sh.next_power_of_two().max(64) }
}
/// 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<f32> {
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
}
/// El índice en espejo de `i` dentro de 0..n (…2 1 0 1 2…n-2 n-1 n-2…).
fn espejo(i: usize, n: usize) -> usize {
if n <= 1 {
return 0;
}
let periodo = 2 * (n - 1);
let r = i % periodo;
if r < n { r } else { periodo - r }
}
/// La entrada del modelo (planos [4, ph, pw]) a partir del RGBA de la página.
pub fn entrada(rgba: &[u8], ancho: usize, alto: usize, p: Plan) -> Vec<f32> {
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, p.sw, p.sh);
let hueco = remuestrear(&hueco, ancho, alto, 1, p.sw, p.sh);
let m = p.pw * p.ph;
let mut out = vec![0f32; 4 * m];
for y in 0..p.ph {
let dentro_y = y < p.sh;
let sy = espejo(y, p.sh);
for x in 0..p.pw {
let dentro = dentro_y && x < p.sw;
let sx = espejo(x, p.sw);
let s = sy * p.sw + sx;
// lo que toca el hueco, aunque sea un poco al reducir, es hueco; el espejo, nunca
let h = if dentro && hueco[s] > 0.02 { 1.0 } else { 0.0 };
let i = y * p.pw + x;
for c in 0..3 {
out[c * m + i] = if h > 0.0 { 0.0 } else { rgb[s * 3 + c] };
}
out[3 * m + i] = h;
}
}
out
}
/// La salida del modelo (planos [3, ph, pw], 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], p: Plan, rgba: &[u8], ancho: usize, alto: usize) -> Vec<u8> {
let m = p.pw * p.ph;
let mut intercalado = vec![0f32; p.sw * p.sh * 3];
for y in 0..p.sh {
for x in 0..p.sw {
for c in 0..3 {
intercalado[(y * p.sw + x) * 3 + c] = planos[c * m + y * p.pw + x];
}
}
}
let grande = remuestrear(&intercalado, p.sw, p.sh, 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_plan_reduce_lo_justo_y_completa_hasta_potencias_de_2() {
assert_eq!(plan(700, 460), Plan { sw: 512, sh: 336, pw: 512, ph: 512 });
assert_eq!(plan(500, 300), Plan { sw: 500, sh: 300, pw: 512, ph: 512 });
assert_eq!(plan(1400, 933), Plan { sw: 1024, sh: 682, pw: 1024, ph: 1024 });
assert_eq!(plan(200, 100), Plan { sw: 200, sh: 100, pw: 256, ph: 128 });
assert_eq!(plan(20, 10), Plan { sw: 20, sh: 10, pw: 64, ph: 64 });
assert_eq!(espejo(5, 4), 1);
assert_eq!(espejo(3, 4), 3);
}
#[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 p = plan(2, 1);
let e = entrada(&rgba, 2, 1, p);
let m = p.pw * p.ph;
assert_eq!(e.len(), 4 * m);
// el hueco (0,0): RGB a cero y el canal del hueco a 1; el espejo (fuera de 2 × 1) no es hueco
assert_eq!(e[0], 0.0);
assert_eq!(e[3 * m], 1.0);
assert_eq!(e[3 * m + 10], 0.0);
// lo de la red: todo gris
let planos = vec![0.5f32; 3 * m];
let s = salida(&planos, p, &rgba, 2, 1);
assert_eq!(&s[..3], &[128, 128, 128]);
assert_eq!(&s[3..], &[40, 50, 60]);
}
}

Ver fichero

@@ -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())

Ver fichero

@@ -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"