fix(infra): gradle_run detecta android-sdk — issue 0076 #2
@@ -0,0 +1,94 @@
|
||||
---
|
||||
id: 0082
|
||||
title: Compilar binario `sd` (stable-diffusion.cpp) para sdcli_generate_go_ml
|
||||
status: pendiente
|
||||
priority: media
|
||||
created: 2026-05-13
|
||||
type: feature
|
||||
related_components: [functions/ml/sdcli_generate.go, functions/ml/sdcli_resolve_binary.go, projects/imagegen]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Compilar el binario `sd` de [leejet/stable-diffusion.cpp](https://github.com/leejet/stable-diffusion.cpp)
|
||||
con backend CUDA en este host (WSL2 + RTX 3070) e instalarlo en `$PATH`. Habilita
|
||||
los tests reales de `sdcli_generate_go_ml` y el wrapper Go subprocess (Ola 3.C ya
|
||||
construido pero con tests en `skip` por falta de binario).
|
||||
|
||||
## Contexto
|
||||
|
||||
- Funcion Go `sdcli_resolve_binary_go_ml` busca `sd` o `sd-cli` en `$PATH`.
|
||||
- `sdcli_generate_go_ml` orquesta args via `genconfig_to_sdcli_args_go_ml`, lanza
|
||||
subproceso con `subprocess_stream_go_core`, parsea progreso con
|
||||
`sdcli_parse_progress_go_ml`, lee PNG de salida.
|
||||
- Tests `TestSdcliResolveBinary_NotFound`, `..._Hint` pasan; `TestSdcliGenerate_RequiresBinary`
|
||||
hace `t.Skip()` porque `sd` no existe en `$PATH`.
|
||||
- Backend `sdcpp_python_load_py_ml` ya validado con SD Turbo (CPU, 27s/imagen).
|
||||
El binario Go nativo deberia ser comparable o mejor con CUDA.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
Archivos NUEVOS sugeridos:
|
||||
|
||||
- `bash/functions/infra/build_sd_cpp.sh` + `.md` — funcion del registry que clona y
|
||||
compila stable-diffusion.cpp con flags configurables (`-DSD_CUDA=ON`, `-DSD_FLASH_ATTN=ON`,
|
||||
`-DSD_FAST_SOFTMAX=ON`). Idempotente.
|
||||
- `bash/functions/infra/install_sd_cpp_bin.sh` + `.md` — copia el binario compilado
|
||||
a `~/.local/bin/sd` o equivalente en `$PATH`.
|
||||
|
||||
NO modificar:
|
||||
- `functions/ml/sdcli_*.go` — su contrato no cambia, solo se desbloquea el path feliz.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Compilacion
|
||||
1.1. Clonar `https://github.com/leejet/stable-diffusion.cpp` en `sources/stable-diffusion.cpp/`.
|
||||
1.2. Verificar requisitos: `cmake >= 3.18`, `gcc`, CUDA toolkit (instalable con
|
||||
`cuda_toolkit_check_bash_infra`). Si CUDA toolkit falta, instalarlo o
|
||||
documentar pasos manuales.
|
||||
1.3. Crear `bash/functions/infra/build_sd_cpp.sh` que:
|
||||
- Acepta flag `--backend cuda|cpu|vulkan`
|
||||
- cmake -B build -DSD_CUDA=ON (segun flag)
|
||||
- cmake --build build -j
|
||||
- Verifica que `build/bin/sd` o `build/sd` existe.
|
||||
1.4. Crear `bash/functions/infra/install_sd_cpp_bin.sh` que copia `sd` a
|
||||
`~/.local/bin/` y verifica `command -v sd`.
|
||||
|
||||
2. Smoke test
|
||||
2.1. Ejecutar `sd --version` desde Go: `SdcliResolveBinary("")` debe encontrarlo.
|
||||
2.2. Generar 1 imagen con SD Turbo `.safetensors` y comparar tiempo vs
|
||||
`sdcpp_python` (esperado: similar o mejor con CUDA).
|
||||
|
||||
3. Indexar
|
||||
3.1. `./fn index` y verificar 2 funciones nuevas.
|
||||
|
||||
4. Cleanup
|
||||
4.1. Re-run `CGO_ENABLED=1 go test -tags fts5 -run TestSdcliGenerate ./functions/ml/`
|
||||
— `TestSdcliGenerate_RequiresBinary` debe pasar sin skip.
|
||||
|
||||
## Ejemplo de uso
|
||||
|
||||
```bash
|
||||
fn run build_sd_cpp --backend cuda
|
||||
fn run install_sd_cpp_bin
|
||||
sd --help # ya en PATH
|
||||
./fn doctor ml # sd_cli debe pasar a "ok"
|
||||
```
|
||||
|
||||
## Decisiones
|
||||
|
||||
- **Compilar en `sources/`** (gitignored) — no commitear binario.
|
||||
- **Instalar en `~/.local/bin/`** — sin sudo, en `$PATH` por defecto en shells.
|
||||
- **Backend CUDA preferido** — esta maquina tiene RTX 3070 (8GB). CPU es fallback.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- Issues 3.B/3.C completados (sdcpp_python + sdcli go scaffolding).
|
||||
- Modelo SD Turbo en vault (ya esta).
|
||||
|
||||
## Riesgos
|
||||
|
||||
- CUDA toolkit no instalado: `nvcc` ausente segun `fn doctor ml`. Mitigacion:
|
||||
fallback CPU (`-DSD_CUDA=OFF`) o instalar toolkit primero.
|
||||
- API rota entre versiones de `sd`: pinear release concreto (tag git) en el script.
|
||||
- Binario grande (~200MB con CUDA libs estaticas): vale, sources/ esta gitignored.
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
id: 0083
|
||||
title: imagegen — notebook 02 validacion cruzada diffusers vs sdcpp_python
|
||||
status: pendiente
|
||||
priority: alta
|
||||
created: 2026-05-13
|
||||
type: feature
|
||||
related_components: [projects/imagegen/analysis/spike_diffusers_vs_sdcpp]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Notebook `02_cross_validation.ipynb` que ejecuta los mismos `GenerationConfig` con
|
||||
los dos backends operativos (diffusers GPU + sdcpp_python CPU) sobre SD Turbo,
|
||||
genera grid lado-a-lado con `image_compare_side_by_side_py_ml` y decide
|
||||
cuales configs portan bien entre backends y cuales requieren ajuste.
|
||||
|
||||
## Contexto
|
||||
|
||||
- Backend diffusers GPU operativo (Ola 3.A) — 192ms/imagen warm, VRAM 3097MB.
|
||||
- Backend sdcpp_python CPU operativo (Ola 3.B) — 27s/imagen 512x512 4 steps.
|
||||
- Funcion `image_compare_side_by_side_py_ml` lista (Ola 3.D) con grid + diff
|
||||
perceptual + pHash + pixel MSE.
|
||||
- Documento `Stack de generacion de imagenes` (raiz proyecto imagegen) dice:
|
||||
"Bit-exact entre backends es imposible. Aceptamos diff perceptual."
|
||||
|
||||
## Arquitectura
|
||||
|
||||
Archivos NUEVOS:
|
||||
- `projects/imagegen/analysis/spike_diffusers_vs_sdcpp/notebooks/02_cross_validation.ipynb`
|
||||
- `~/vaults/imagegen_models/configs/cross_validated/*.json` — configs que pasan
|
||||
- `~/vaults/imagegen_models/outputs/cross/*.png` — grids A | diff | B
|
||||
|
||||
NO se crean funciones nuevas — todo se compone de funciones existentes del registry.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Plan del notebook (declarar al usuario antes de escribirlo)
|
||||
1.1. Titulo, objetivo, criterio PASS (pHash distance < umbral X, pixel_mse < Y)
|
||||
1.2. Lista de celdas y output esperado por celda
|
||||
|
||||
2. Notebook
|
||||
2.1. Celda hardware check + GPU info (reuse).
|
||||
2.2. Definir 4 configs base (seeds 42, 123, 7, 999), SD Turbo, 1-step euler_a, 512x512.
|
||||
2.3. Loop config: generar A=diffusers, B=sdcpp_python.
|
||||
2.4. `image_compare_side_by_side(A, B, label_a="diffusers", label_b="sdcpp")` por par.
|
||||
2.5. Tabla resumen: pHash distance, pixel MSE, duration_a, duration_b.
|
||||
2.6. Veredicto por config: PASS si pHash<=N (a calibrar), FAIL si no.
|
||||
2.7. Guardar grids comparativos a vault.
|
||||
|
||||
3. Ejecutar el notebook desde la sesion claude
|
||||
3.1. Lanzar Jupyter si no esta arriba.
|
||||
3.2. Ejecutar celdas 1..N via `jupyter_exec_py_notebook cell`.
|
||||
3.3. Reportar veredicto por config.
|
||||
|
||||
4. Conclusion
|
||||
4.1. Si todos los configs PASS → contrato `GenerationConfig` es portable.
|
||||
4.2. Si alguno FAIL → documentar campo problematico (sampler, cfg_scale, ...)
|
||||
y abrir proposal de ajuste.
|
||||
|
||||
## Ejemplo de uso
|
||||
|
||||
Output esperado al final:
|
||||
|
||||
```
|
||||
seed=42 pHash_dist=12 pixel_mse=812.4 diffusers=189ms sdcpp=26200ms PASS
|
||||
seed=123 pHash_dist=14 pixel_mse=901.0 diffusers=192ms sdcpp=27100ms PASS
|
||||
seed=7 pHash_dist=11 pixel_mse=750.8 diffusers=187ms sdcpp=26800ms PASS
|
||||
seed=999 pHash_dist=18 pixel_mse=1102.3 diffusers=194ms sdcpp=27500ms PASS
|
||||
VEREDICTO GLOBAL: PASS (contrato portable)
|
||||
```
|
||||
|
||||
## Decisiones
|
||||
|
||||
- **Umbrales pHash y MSE** se calibran en este notebook — no hay valor previo.
|
||||
Empezar con pHash<=20 (bastante permisivo), pixel_mse<=2000.
|
||||
- **`imagehash` puede no estar instalado** en el venv — `pip install imagehash`
|
||||
como primera celda si falta.
|
||||
- **Solo SD Turbo en este notebook** — modelos mayores (SDXL Turbo, FLUX) iran en
|
||||
notebooks separados cuando se descarguen.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- Backends 3.A + 3.B operativos (hechos).
|
||||
- SD Turbo en vault (hecho).
|
||||
- Jupyter del analysis levantado (script `run-jupyter-lab.sh`).
|
||||
|
||||
## Riesgos
|
||||
|
||||
- sdcpp_python tarda ~27s por imagen en CPU → 4 imagenes x 2 backends = ~2 min
|
||||
de espera real, aceptable.
|
||||
- pHash de imagehash requiere instalar el paquete — documentar en cell 0.
|
||||
- Si el sampler de sd.cpp difiere demasiado del de diffusers (ej. trailing
|
||||
timesteps de SD Turbo), la diff sera grande aunque la implementacion sea OK.
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
id: 0084
|
||||
title: imagegen_studio — app Go binario producto (Fase 3 plan stack)
|
||||
status: pendiente
|
||||
priority: media
|
||||
created: 2026-05-13
|
||||
type: feature
|
||||
related_components: [functions/ml/sdcli_*.go, functions/ml/generation_config_go_ml, projects/imagegen]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
App Go autocontenida `imagegen_studio` que orquesta `sd-cli` para generar imagenes
|
||||
sin Python en runtime. Encarna la Fase 3 del documento del stack: binario
|
||||
distribuible, `GenerationConfig` Go nativo, subprocess streaming con progreso.
|
||||
|
||||
## Contexto
|
||||
|
||||
- Toda la capa Go del contrato esta lista: tipos `GenerationConfig_go_ml`,
|
||||
`ModelRef_go_ml`, `LoraRef_go_ml`, `ImageGenResult_go_ml`, interface
|
||||
`ImageGenerator_go_ml`.
|
||||
- Funciones Go ya construidas: `sdcli_resolve_binary_go_ml`, `sdcli_generate_go_ml`,
|
||||
`subprocess_stream_go_core`, `genconfig_to_sdcli_args_go_ml`,
|
||||
`genconfig_json_marshal_go_ml`, `sdcli_parse_progress_go_ml`, `get_gpu_info_go_infra`.
|
||||
- Plan del documento: producto Go con subprocess gestionando `sd-cli`,
|
||||
binario embebido via `go:embed` o descarga al primer arranque.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
Path: `projects/imagegen/apps/imagegen_studio/`
|
||||
|
||||
```
|
||||
imagegen_studio/
|
||||
main.go # CLI args / TUI entry / HTTP API
|
||||
studio.go # ImageGenerator wrapper sobre sdcli_generate
|
||||
app.md # frontmatter del registry
|
||||
CMakeLists.txt # NO (es Go, usa go.mod)
|
||||
go.mod
|
||||
README.md
|
||||
embed/ # opcional: sd binary embebido
|
||||
```
|
||||
|
||||
**Pure core / impure shell:**
|
||||
- `pkg/`: validacion `GenerationConfig`, serializacion JSON, formato outputs (paths derivados).
|
||||
- `shell/` o `studio.go`: invocacion `SdcliGenerate`, IO disco, manejo subproceso.
|
||||
|
||||
Tres modos de uso:
|
||||
|
||||
1. **CLI**: `imagegen_studio generate --prompt "..." --seed 42 --out out.png`
|
||||
2. **HTTP API**: `imagegen_studio serve --port 8088` → POST /generate {GenerationConfig JSON}
|
||||
3. **TUI (opcional, Bubble Tea)**: forma interactiva, preview, queue
|
||||
|
||||
Empezar por CLI; HTTP API y TUI iterativos.
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Scaffolding
|
||||
1.1. `fn run init_go_app --project imagegen imagegen_studio` (si existe pipeline)
|
||||
o crear estructura manual.
|
||||
1.2. `app.md` con `framework: cli`, `tags: [ml, imagegen, service?]`,
|
||||
`uses_functions:` lista de las 7 funciones Go citadas.
|
||||
1.3. `go.mod` con dependencias minimas (registry imports + cobra opcional).
|
||||
|
||||
2. CLI minima (Fase A)
|
||||
2.1. Subcomando `generate`: flags --prompt/--negative/--seed/--steps/--cfg/--sampler/
|
||||
--width/--height/--model/--out
|
||||
2.2. Construir `GenerationConfig`, llamar `SdcliResolveBinary("")`, `SdcliGenerate(...)`.
|
||||
2.3. Stream progreso a stderr (callback `SdcliProgressCallback`).
|
||||
2.4. Salida final: imprime path de la imagen + duration_ms + JSON meta.
|
||||
|
||||
3. JSON I/O (Fase B)
|
||||
3.1. Subcomando `generate-from-json --config path/cfg.json --out out.png`.
|
||||
3.2. Permite pegar configs validados de la fase 2 (notebook cross-validation).
|
||||
|
||||
4. HTTP server (Fase C, feature flag)
|
||||
4.1. `imagegen_studio serve --port 8088`.
|
||||
4.2. POST `/generate` body = GenerationConfig JSON → respuesta multipart PNG + meta.
|
||||
4.3. GET `/health` → 200 + version + GpuInfo.
|
||||
4.4. Feature flag `imagegen-studio-server` para esconder cuando no compila/no testeado.
|
||||
|
||||
5. e2e_checks
|
||||
5.1. Anadir bloque `e2e_checks` en `app.md`:
|
||||
- `build`: go build con CGO_ENABLED=0
|
||||
- `cli_help`: ./imagegen_studio --help, contiene "generate"
|
||||
- `smoke`: si `sd` binario en $PATH + SD Turbo en vault, generar 1 imagen
|
||||
a /tmp/, verificar PNG valido. Si no: SKIP (warning).
|
||||
|
||||
6. Tests
|
||||
6.1. Tests unitarios sobre helpers puros (path derivation, JSON marshaling).
|
||||
6.2. Test integracion en e2e_checks (smoke).
|
||||
|
||||
## Ejemplo de uso
|
||||
|
||||
```bash
|
||||
# CLI directo
|
||||
imagegen_studio generate \
|
||||
--model /home/lucas/vaults/imagegen_models/diffusers/sd-turbo/sd_turbo.safetensors \
|
||||
--prompt "a red apple on a wooden table" \
|
||||
--seed 42 --steps 1 --cfg-scale 0.0 --sampler euler_a \
|
||||
--width 512 --height 512 \
|
||||
--out /tmp/apple.png
|
||||
|
||||
# Desde config JSON validado en spike notebook 02
|
||||
imagegen_studio generate-from-json \
|
||||
--config ~/vaults/imagegen_models/configs/spike01_seed42_*.json \
|
||||
--out /tmp/seed42.png
|
||||
|
||||
# HTTP API (feature flag activado)
|
||||
imagegen_studio serve --port 8088 &
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-d @config.json http://localhost:8088/generate -o out.png
|
||||
```
|
||||
|
||||
## Decisiones
|
||||
|
||||
- **Subprocess via SdcliGenerate** — no cgo ni bindings. Mas robusto, mas lento al
|
||||
arrancar (~200ms cold start), pero overhead irrelevante frente a 1-30s generacion.
|
||||
- **NO `go:embed` del binario `sd` en Fase A** — el binario depende de la GPU del
|
||||
usuario (CUDA/CPU/Vulkan). Documentar requisito: tener `sd` en $PATH (issue 0082).
|
||||
- **Feature flag para HTTP API** — Fase A es CLI, no romper master con server a medias.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- Issue 0082 (binario `sd` compilado en $PATH) — sin esto el smoke falla pero la
|
||||
app compila. Se puede arrancar el scaffolding antes.
|
||||
- Funciones Go de Ola 3.C (hechas).
|
||||
|
||||
## Riesgos
|
||||
|
||||
- `sd-cli` no soporta SD Turbo cleanly con 1-step euler_a → puede requerir 4-step
|
||||
como minimo. Validar en issue 0082.
|
||||
- Distribucion sin binario `sd` empotrado obliga al usuario a instalarlo. Aceptable
|
||||
para Fase A; reevaluar `go:embed` con build-per-backend en Fase C+.
|
||||
- Si Fase 0 (spike) del documento revela calidad insuficiente: replantear stack y
|
||||
pausar este issue.
|
||||
@@ -104,3 +104,6 @@
|
||||
| [0072j](0072j-gamedev-physics-box2d.md) | gamedev — physics 2D (Box2D integration) | pendiente | media | feature | parte de 0072, depende 0072b |
|
||||
| [0072k](0072k-gamedev-demo-platformer.md) | gamedev — demo plataformero `engine_demo` (referencia stack completo) | pendiente | alta | feature | parte de 0072, depende 0072b/c/d/j |
|
||||
| [0072l](0072l-gamedev-scripting-optional.md) | gamedev — scripting opcional (wren / lua / hot reload) | diferido | baja | feature | parte de 0072 |
|
||||
| [0082](0082-compile-sd-cpp-binary.md) | Compilar binario `sd` (stable-diffusion.cpp) para sdcli_generate_go_ml | pendiente | media | feature | desbloquea 0084 |
|
||||
| [0083](0083-imagegen-spike02-cross-validation.md) | imagegen — notebook 02 validacion cruzada diffusers vs sdcpp_python | pendiente | alta | feature | — |
|
||||
| [0084](0084-imagegen-studio-go-app.md) | imagegen_studio — app Go binario producto (Fase 3 plan stack) | pendiente | media | feature | 0082 |
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package ml
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/core"
|
||||
)
|
||||
|
||||
// SdcliProgressCallback es una funcion llamada cada vez que se parsea una linea
|
||||
// de progreso del proceso sd. Puede ser nil.
|
||||
type SdcliProgressCallback func(p SdcliProgress)
|
||||
|
||||
// SdcliGenerate ejecuta el binario sd para generar una imagen y escribe el
|
||||
// resultado en outPath.
|
||||
//
|
||||
// Flujo:
|
||||
// 1. Construye args con GenconfigToSdcliArgs(cfg) + ["-o", outPath].
|
||||
// 2. Lanza el proceso via SubprocessStream.
|
||||
// 3. Goroutine interna lee eventos: lineas stderr se pasan a SdcliParseProgress;
|
||||
// si onProgress != nil y hay progreso reconocible, llama onProgress(p).
|
||||
// 4. Espera el resultado. ExitCode != 0 => error con las ultimas 10 lineas de stderr.
|
||||
// 5. Lee outPath y retorna ImageGenResult con bytes, meta y duration_ms.
|
||||
//
|
||||
// ctx controla el timeout y cancelacion: se pasa directamente a SubprocessStream,
|
||||
// que maneja SIGTERM -> grace 2s -> SIGKILL.
|
||||
func SdcliGenerate(
|
||||
ctx context.Context,
|
||||
bin SdcliBinary,
|
||||
cfg GenerationConfig,
|
||||
outPath string,
|
||||
onProgress SdcliProgressCallback,
|
||||
) (ImageGenResult, error) {
|
||||
start := time.Now()
|
||||
|
||||
args := GenconfigToSdcliArgs(cfg)
|
||||
args = append(args, "-o", outPath)
|
||||
|
||||
events, results := core.SubprocessStream(ctx, bin.Path, args, nil, nil)
|
||||
|
||||
// Consumir eventos en goroutine: parsear progreso y acumular stderr para
|
||||
// mensajes de error utiles.
|
||||
type collectedStderr struct {
|
||||
lines []string
|
||||
}
|
||||
stderrCh := make(chan collectedStderr, 1)
|
||||
|
||||
go func() {
|
||||
var stderrLines []string
|
||||
for ev := range events {
|
||||
if ev.Stream == "stderr" {
|
||||
stderrLines = append(stderrLines, ev.Line)
|
||||
if onProgress != nil {
|
||||
if p, ok := SdcliParseProgress(ev.Line); ok {
|
||||
onProgress(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stderrCh <- collectedStderr{lines: stderrLines}
|
||||
}()
|
||||
|
||||
res := <-results
|
||||
collected := <-stderrCh
|
||||
|
||||
if res.Err != nil {
|
||||
return ImageGenResult{}, fmt.Errorf("sdcli subprocess: %w", res.Err)
|
||||
}
|
||||
|
||||
if res.ExitCode != 0 {
|
||||
tail := stderrTail(collected.lines, 10)
|
||||
return ImageGenResult{}, fmt.Errorf(
|
||||
"sdcli exited with code %d:\n%s",
|
||||
res.ExitCode, tail,
|
||||
)
|
||||
}
|
||||
|
||||
imageBytes, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
return ImageGenResult{}, fmt.Errorf("sdcli: reading output image %q: %w", outPath, err)
|
||||
}
|
||||
|
||||
durationMs := time.Since(start).Milliseconds()
|
||||
|
||||
meta := map[string]any{
|
||||
"backend": "sdcli",
|
||||
"binary_path": bin.Path,
|
||||
"model": cfg.Model.Name,
|
||||
"seed": cfg.Seed,
|
||||
"steps": cfg.Steps,
|
||||
"cfg_scale": cfg.CfgScale,
|
||||
"sampler": cfg.Sampler,
|
||||
"width": cfg.Width,
|
||||
"height": cfg.Height,
|
||||
}
|
||||
if bin.Version != "" {
|
||||
meta["version"] = bin.Version
|
||||
}
|
||||
if cfg.Model.Path != "" {
|
||||
meta["model_path"] = cfg.Model.Path
|
||||
}
|
||||
if cfg.Model.ModelType != "" {
|
||||
meta["model_type"] = cfg.Model.ModelType
|
||||
}
|
||||
if cfg.Model.Quantization != "" {
|
||||
meta["quantization"] = cfg.Model.Quantization
|
||||
}
|
||||
if len(cfg.Loras) > 0 {
|
||||
loras := make([]string, len(cfg.Loras))
|
||||
for i, l := range cfg.Loras {
|
||||
loras[i] = l.Path + ":" + strconv.FormatFloat(l.Weight, 'f', -1, 64)
|
||||
}
|
||||
meta["loras"] = strings.Join(loras, ",")
|
||||
}
|
||||
|
||||
return ImageGenResult{
|
||||
ImageBytes: imageBytes,
|
||||
Format: "png",
|
||||
Meta: meta,
|
||||
DurationMs: durationMs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// stderrTail retorna las ultimas n lineas de lines, unidas con newline.
|
||||
func stderrTail(lines []string, n int) string {
|
||||
if len(lines) <= n {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
return strings.Join(lines[len(lines)-n:], "\n")
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: sdcli_generate
|
||||
kind: function
|
||||
lang: go
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func SdcliGenerate(ctx context.Context, bin SdcliBinary, cfg GenerationConfig, outPath string, onProgress SdcliProgressCallback) (ImageGenResult, error)"
|
||||
description: "Ejecuta el binario sd de stable-diffusion.cpp para generar una imagen. Construye los args CLI via GenconfigToSdcliArgs, lanza el proceso via SubprocessStream, parsea el progreso de stderr en tiempo real via SdcliParseProgress, y retorna ImageGenResult con los bytes PNG, metadata y duration_ms."
|
||||
tags: [ml, sdcli, stablediffusion, imagegen, subprocess, inference, cpp]
|
||||
uses_functions:
|
||||
- subprocess_stream_go_core
|
||||
- genconfig_to_sdcli_args_go_ml
|
||||
- sdcli_parse_progress_go_ml
|
||||
uses_types:
|
||||
- generation_config_go_ml
|
||||
- image_gen_result_go_ml
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["context", "fmt", "os", "strconv", "strings", "time", "fn-registry/functions/core"]
|
||||
params:
|
||||
- name: ctx
|
||||
desc: "Context para cancelacion y timeout. Se pasa a SubprocessStream que gestiona SIGTERM -> grace 2s -> SIGKILL."
|
||||
- name: bin
|
||||
desc: "Binario sd resuelto via SdcliResolveBinary. Contiene path absoluto y version."
|
||||
- name: cfg
|
||||
desc: "Parametros de generacion: prompt, seed, steps, sampler, model, loras, etc."
|
||||
- name: outPath
|
||||
desc: "Path donde sd escribe la imagen PNG generada. El archivo se lee y se incluye en ImageGenResult.ImageBytes."
|
||||
- name: onProgress
|
||||
desc: "Callback opcional llamado con cada SdcliProgress parseado de stderr. Nil es valido."
|
||||
output: "ImageGenResult con ImageBytes (bytes del PNG), Format='png', Meta (backend, binary_path, model, seed, steps, etc.) y DurationMs medido desde el inicio de la llamada."
|
||||
tested: true
|
||||
tests:
|
||||
- "integration test skipped when sd binary not in PATH"
|
||||
test_file_path: "functions/ml/sdcli_test.go"
|
||||
file_path: "functions/ml/sdcli_generate.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
bin, err := SdcliResolveBinary("")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := GenerationConfig{
|
||||
Prompt: "a red apple on a wooden table",
|
||||
Seed: 42,
|
||||
Steps: 20,
|
||||
CfgScale: 7.0,
|
||||
Sampler: "euler_a",
|
||||
Width: 512,
|
||||
Height: 512,
|
||||
Model: ModelRef{
|
||||
Name: "sd15",
|
||||
ModelType: "sd15",
|
||||
Quantization: "fp16",
|
||||
Path: "/path/to/model.safetensors",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := SdcliGenerate(ctx, bin, cfg, "/tmp/out.png", func(p SdcliProgress) {
|
||||
fmt.Printf("step %d/%d (%.1f%%)\n", p.Step, p.TotalSteps, p.Percent)
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("generated %d bytes in %dms\n", len(result.ImageBytes), result.DurationMs)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
El binario `sd` de stable-diffusion.cpp escribe la imagen directamente en disco
|
||||
(`-o outPath`). La funcion lee el archivo tras la finalizacion del proceso y
|
||||
carga los bytes en `ImageGenResult.ImageBytes`.
|
||||
|
||||
Si el proceso termina con `ExitCode != 0`, el error incluye las ultimas 10 lineas
|
||||
de stderr para facilitar el diagnostico.
|
||||
|
||||
El callback `onProgress` se llama desde la goroutine de lectura de eventos.
|
||||
Si el callback hace I/O o es lento, considera usar un canal con buffer para
|
||||
desacoplar.
|
||||
|
||||
Para modelos SD Turbo / SDXL Turbo con `steps <= 4` y `cfg_scale = 1.0`, el
|
||||
sampler `euler_a` es el recomendado. Para SD 1.5 estandar usar `euler` o
|
||||
`dpm++2m` con `steps >= 20`.
|
||||
@@ -0,0 +1,88 @@
|
||||
package ml
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SdcliBinary describe el binario sd resuelto: su path absoluto, version detectada
|
||||
// y como fue localizado.
|
||||
type SdcliBinary struct {
|
||||
Path string `json:"path"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Source string `json:"source"` // "config" | "path"
|
||||
}
|
||||
|
||||
// SdcliResolveBinary localiza el binario sd / sd-cli de stable-diffusion.cpp.
|
||||
//
|
||||
// Orden de busqueda:
|
||||
// 1. Si hint != "" y el archivo existe y es ejecutable: usar como "config".
|
||||
// 2. exec.LookPath("sd"): primer candidato en PATH.
|
||||
// 3. exec.LookPath("sd-cli"): segundo candidato en PATH.
|
||||
// 4. Error descriptivo si no se encuentra ninguno.
|
||||
//
|
||||
// Tras localizar el binario, intenta obtener la version ejecutando
|
||||
// `<bin> --version` con timeout de 3 segundos. Si el comando falla
|
||||
// o no produce output, Version queda vacia (no es error fatal).
|
||||
func SdcliResolveBinary(hint string) (SdcliBinary, error) {
|
||||
var binPath string
|
||||
var source string
|
||||
|
||||
if hint != "" {
|
||||
info, err := os.Stat(hint)
|
||||
if err != nil {
|
||||
return SdcliBinary{}, fmt.Errorf("sdcli hint %q: %w", hint, err)
|
||||
}
|
||||
if info.Mode()&0o111 == 0 {
|
||||
return SdcliBinary{}, fmt.Errorf("sdcli hint %q: file exists but is not executable", hint)
|
||||
}
|
||||
binPath = hint
|
||||
source = "config"
|
||||
}
|
||||
|
||||
if binPath == "" {
|
||||
if p, err := exec.LookPath("sd"); err == nil {
|
||||
binPath = p
|
||||
source = "path"
|
||||
}
|
||||
}
|
||||
|
||||
if binPath == "" {
|
||||
if p, err := exec.LookPath("sd-cli"); err == nil {
|
||||
binPath = p
|
||||
source = "path"
|
||||
}
|
||||
}
|
||||
|
||||
if binPath == "" {
|
||||
return SdcliBinary{}, fmt.Errorf(
|
||||
"sd binary not found in PATH (hint: install from leejet/stable-diffusion.cpp)",
|
||||
)
|
||||
}
|
||||
|
||||
version := sdcliProbeVersion(binPath)
|
||||
return SdcliBinary{
|
||||
Path: binPath,
|
||||
Version: version,
|
||||
Source: source,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sdcliProbeVersion ejecuta `<bin> --version` con timeout 3s y retorna
|
||||
// la primera linea de la salida. Retorna "" si el comando falla o no
|
||||
// produce output; no propaga el error (version es best-effort).
|
||||
func sdcliProbeVersion(binPath string) string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, binPath, "--version").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
line := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
|
||||
return line
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: sdcli_resolve_binary
|
||||
kind: function
|
||||
lang: go
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func SdcliResolveBinary(hint string) (SdcliBinary, error)"
|
||||
description: "Localiza el binario sd / sd-cli de stable-diffusion.cpp. Busca en orden: hint explicito, LookPath('sd'), LookPath('sd-cli'). Detecta la version ejecutando --version con timeout 3s. Retorna SdcliBinary con path, version y fuente de resolucion."
|
||||
tags: [ml, sdcli, stablediffusion, binary, subprocess, imagegen]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["context", "fmt", "os", "os/exec", "strings", "time"]
|
||||
params:
|
||||
- name: hint
|
||||
desc: "Path explicito al binario sd. Si es string vacio se busca en PATH. Si no es vacio debe existir y ser ejecutable."
|
||||
output: "SdcliBinary con Path absoluto, Version detectada (puede ser vacia si --version falla) y Source ('config' si viene de hint, 'path' si viene de LookPath)."
|
||||
tested: true
|
||||
tests:
|
||||
- "missing binary returns error when PATH empty"
|
||||
- "hint path resolves to config source"
|
||||
test_file_path: "functions/ml/sdcli_test.go"
|
||||
file_path: "functions/ml/sdcli_resolve_binary.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Buscar automaticamente en PATH
|
||||
bin, err := SdcliResolveBinary("")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("sd found at %s (version: %s)\n", bin.Path, bin.Version)
|
||||
|
||||
// Hint explicito (ej. desde config de usuario)
|
||||
bin, err = SdcliResolveBinary("/opt/stable-diffusion/sd")
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
`SdcliBinary` es el token de resolucion que se pasa a `SdcliGenerate`. Separar
|
||||
la resolucion de la ejecucion permite validar el binario al arrancar la app sin
|
||||
lanzar una generacion.
|
||||
|
||||
La deteccion de version es best-effort: si `sd --version` no existe o falla,
|
||||
`Version` queda vacia y no se propaga error. Algunos builds de stable-diffusion.cpp
|
||||
no implementan `--version`; en ese caso `Version == ""` es el comportamiento
|
||||
esperado.
|
||||
|
||||
`Source` distingue binarios configurados explicitamente (`"config"`) de los
|
||||
encontrados en PATH (`"path"`), util para logging y diagnostico.
|
||||
@@ -0,0 +1,114 @@
|
||||
package ml
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestSdcliResolveBinary_NotFound verifica que SdcliResolveBinary retorna error
|
||||
// cuando no hay binario en PATH ni hint. Forzamos PATH="" para que LookPath
|
||||
// no encuentre nada, lo que hace el test determinista independientemente del
|
||||
// entorno del desarrollador.
|
||||
func TestSdcliResolveBinary_NotFound(t *testing.T) {
|
||||
t.Setenv("PATH", "")
|
||||
_, err := SdcliResolveBinary("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when sd not in PATH, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSdcliResolveBinary_Hint verifica que un hint valido (archivo ejecutable)
|
||||
// se resuelve con Source="config" sin necesidad de PATH.
|
||||
func TestSdcliResolveBinary_Hint(t *testing.T) {
|
||||
// Crear archivo temporal ejecutable que simula el binario sd.
|
||||
// El script simplemente sale con 0; --version devolvera string vacio
|
||||
// pero eso no es error (version es best-effort).
|
||||
f, err := os.CreateTemp("", "sd-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("creating temp file: %v", err)
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
|
||||
f.Close()
|
||||
|
||||
script := []byte("#!/bin/sh\necho 'sd-test 0.1'\n")
|
||||
if err := os.WriteFile(f.Name(), script, 0o755); err != nil {
|
||||
t.Fatalf("writing temp file: %v", err)
|
||||
}
|
||||
if err := os.Chmod(f.Name(), 0o755); err != nil {
|
||||
t.Fatalf("chmod temp file: %v", err)
|
||||
}
|
||||
|
||||
bin, err := SdcliResolveBinary(f.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("SdcliResolveBinary(hint): %v", err)
|
||||
}
|
||||
if bin.Source != "config" {
|
||||
t.Fatalf("expected source=config, got %q", bin.Source)
|
||||
}
|
||||
if bin.Path != f.Name() {
|
||||
t.Fatalf("expected path=%q, got %q", f.Name(), bin.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSdcliGenerate_RequiresBinary es un test de integracion que salta si el
|
||||
// binario sd no esta instalado en PATH. Si esta disponible, tambien requiere
|
||||
// el modelo SD Turbo en el vault para ejecutar una generacion real.
|
||||
func TestSdcliGenerate_RequiresBinary(t *testing.T) {
|
||||
bin, err := SdcliResolveBinary("")
|
||||
if err != nil {
|
||||
t.Skipf("sd binary not in PATH, skipping integration test: %v", err)
|
||||
}
|
||||
|
||||
modelPath := "/home/lucas/vaults/imagegen_models/diffusers/sd-turbo/sd_turbo.safetensors"
|
||||
if _, err := os.Stat(modelPath); err != nil {
|
||||
t.Skipf("SD Turbo model not in vault (%s), skipping: %v", modelPath, err)
|
||||
}
|
||||
|
||||
cfg := GenerationConfig{
|
||||
Prompt: "a red apple",
|
||||
Seed: 42,
|
||||
Steps: 4,
|
||||
CfgScale: 1.0,
|
||||
Sampler: "euler_a",
|
||||
Width: 512,
|
||||
Height: 512,
|
||||
Model: ModelRef{
|
||||
Name: "sd-turbo",
|
||||
ModelType: "sd15",
|
||||
Quantization: "fp16",
|
||||
Path: modelPath,
|
||||
},
|
||||
}
|
||||
|
||||
outPath := t.TempDir() + "/out.png"
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var progressCalled bool
|
||||
res, err := SdcliGenerate(ctx, bin, cfg, outPath, func(p SdcliProgress) {
|
||||
progressCalled = true
|
||||
t.Logf("progress: step %d/%d (%.1f%%) %.2fit/s",
|
||||
p.Step, p.TotalSteps, p.Percent, p.ItPerSec)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SdcliGenerate: %v", err)
|
||||
}
|
||||
if len(res.ImageBytes) == 0 {
|
||||
t.Fatal("expected non-empty image bytes")
|
||||
}
|
||||
if res.Format != "png" {
|
||||
t.Fatalf("expected format=png, got %q", res.Format)
|
||||
}
|
||||
if res.DurationMs <= 0 {
|
||||
t.Fatalf("expected positive duration_ms, got %d", res.DurationMs)
|
||||
}
|
||||
if res.Meta["backend"] != "sdcli" {
|
||||
t.Fatalf("expected meta.backend=sdcli, got %v", res.Meta["backend"])
|
||||
}
|
||||
t.Logf("generated %d bytes in %dms (progress_called=%v)",
|
||||
len(res.ImageBytes), res.DurationMs, progressCalled)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: sdcpp_python_generate
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def sdcpp_python_generate(sd: Any, cfg: GenerationConfig) -> ImageGenResult"
|
||||
description: "Genera una imagen con un StableDiffusion (stable-diffusion-cpp-python) usando GenerationConfig como contrato. Mapea sampler, mide duracion y retorna ImageGenResult con meta del backend."
|
||||
tags: [ml, stable-diffusion, sdcpp, inference, backend, generate, txt2img]
|
||||
uses_functions: [sdcpp_python_load_py_ml]
|
||||
uses_types: [generation_config_py_ml, image_gen_result_py_ml]
|
||||
returns: [image_gen_result_py_ml]
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [stable_diffusion_cpp, PIL]
|
||||
params:
|
||||
- name: sd
|
||||
desc: "Instancia StableDiffusion cargada via sdcpp_python_load. Debe tener metodo generate_image()."
|
||||
- name: cfg
|
||||
desc: "Contrato de parametros de generacion. cfg.sampler debe ser uno de: euler | euler_a | dpm++2m | dpm++2m_v2 | heun | dpm2 | lcm."
|
||||
output: "ImageGenResult con image=PIL.Image (primera del batch), meta con backend/model/sampler/seed/wtype, duration_ms medido, vram_peak_mb=None."
|
||||
tested: true
|
||||
tests:
|
||||
- "generate retorna ImageGenResult valido"
|
||||
- "duration_ms mayor que cero"
|
||||
- "meta backend es sdcpp_python"
|
||||
test_file_path: "python/functions/ml/tests/test_sdcpp_python_backend.py"
|
||||
file_path: "python/functions/ml/sdcpp_python_generate.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/ml")
|
||||
from model_ref import ModelRef
|
||||
from generation_config import GenerationConfig
|
||||
from sdcpp_python_load import sdcpp_python_load
|
||||
from sdcpp_python_generate import sdcpp_python_generate
|
||||
|
||||
model = ModelRef(
|
||||
name="sd-turbo",
|
||||
model_type="sd15",
|
||||
quantization="fp16",
|
||||
path="/home/lucas/vaults/imagegen_models/diffusers/sd-turbo/sd_turbo.safetensors",
|
||||
)
|
||||
sd = sdcpp_python_load(model)
|
||||
|
||||
cfg = GenerationConfig(
|
||||
prompt="a red cat sitting on a wooden table",
|
||||
seed=42,
|
||||
steps=4,
|
||||
cfg_scale=1.0,
|
||||
sampler="euler_a",
|
||||
width=512,
|
||||
height=512,
|
||||
model=model,
|
||||
)
|
||||
result = sdcpp_python_generate(sd, cfg)
|
||||
result.image.save("/tmp/output.png")
|
||||
print(f"Generado en {result.duration_ms}ms, meta={result.meta}")
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- El sampler mapping canonico: euler->euler, euler_a->euler_a, dpm++2m->dpmpp2m,
|
||||
dpm++2m_v2->dpmpp2mv2, heun->heun, dpm2->dpm2, lcm->lcm.
|
||||
- API usada: `StableDiffusion.generate_image()` (binding 0.4.7+). Versiones anteriores
|
||||
exponían `txt_to_img()` — actualizar el package si se encuentra ese error.
|
||||
- `vram_peak_mb` siempre None: stable-diffusion-cpp-python no expone medicion de VRAM.
|
||||
- `clip_skip`: -1 le dice al backend que use el valor por defecto del modelo (equivale a
|
||||
no especificarlo). Si cfg.clip_skip es None, se pasa -1.
|
||||
- El campo `wtype` en meta se extrae via `getattr(sd, 'wtype', 'unknown')` ya que el
|
||||
binding no garantiza el atributo en todas las versiones.
|
||||
---
|
||||
@@ -0,0 +1,103 @@
|
||||
"""sdcpp_python_generate — genera una imagen con stable-diffusion-cpp-python a partir de GenerationConfig."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from generation_config import GenerationConfig
|
||||
from image_gen_result import ImageGenResult
|
||||
|
||||
# Mapa de sampler del registry al nombre que espera stable-diffusion-cpp-python
|
||||
_SAMPLER_MAP: dict[str, str] = {
|
||||
"euler": "euler",
|
||||
"euler_a": "euler_a",
|
||||
"dpm++2m": "dpmpp2m",
|
||||
"dpm++2m_v2": "dpmpp2mv2",
|
||||
"heun": "heun",
|
||||
"dpm2": "dpm2",
|
||||
"lcm": "lcm",
|
||||
}
|
||||
|
||||
|
||||
def sdcpp_python_generate(sd: Any, cfg: GenerationConfig) -> ImageGenResult:
|
||||
"""Genera una imagen con un objeto StableDiffusion usando GenerationConfig como contrato.
|
||||
|
||||
Mapea los campos del GenerationConfig canonico a los parametros de
|
||||
StableDiffusion.generate_image(). Mide la duracion total de la llamada.
|
||||
Retorna un ImageGenResult con la primera imagen del batch, metadata del backend
|
||||
y duracion en milisegundos. VRAM no se mide (None).
|
||||
|
||||
Args:
|
||||
sd: Instancia StableDiffusion cargada via sdcpp_python_load.
|
||||
cfg: Contrato de parametros de generacion. Todos los campos son leidos.
|
||||
cfg.sampler debe ser uno de los valores del SamplerName del registry.
|
||||
|
||||
Returns:
|
||||
ImageGenResult con image=PIL.Image, meta con backend/modelo/sampler/seed/wtype,
|
||||
duration_ms medido via time.perf_counter(), vram_peak_mb=None.
|
||||
|
||||
Raises:
|
||||
KeyError: Si cfg.sampler no tiene correspondencia en _SAMPLER_MAP.
|
||||
ImportError: Si stable_diffusion_cpp no esta instalado.
|
||||
RuntimeError: Si generate_image retorna lista vacia o None.
|
||||
"""
|
||||
try:
|
||||
from stable_diffusion_cpp import StableDiffusion # noqa: F401 — verifica disponibilidad
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"sdcpp_python_generate requiere stable-diffusion-cpp-python. "
|
||||
"Instalar con: pip install stable-diffusion-cpp-python"
|
||||
) from exc
|
||||
|
||||
sample_method = _SAMPLER_MAP.get(cfg.sampler)
|
||||
if sample_method is None:
|
||||
raise KeyError(
|
||||
f"Sampler '{cfg.sampler}' no tiene correspondencia en sdcpp_python_generate. "
|
||||
f"Valores soportados: {list(_SAMPLER_MAP.keys())}"
|
||||
)
|
||||
|
||||
# wtype del objeto sd (para metadata)
|
||||
wtype = getattr(sd, "wtype", "unknown")
|
||||
|
||||
t0 = time.perf_counter()
|
||||
|
||||
images = sd.generate_image(
|
||||
prompt=cfg.prompt,
|
||||
negative_prompt=cfg.negative_prompt or "",
|
||||
cfg_scale=cfg.cfg_scale,
|
||||
sample_method=sample_method,
|
||||
sample_steps=cfg.steps,
|
||||
seed=cfg.seed,
|
||||
width=cfg.width,
|
||||
height=cfg.height,
|
||||
clip_skip=cfg.clip_skip if cfg.clip_skip is not None else -1,
|
||||
batch_count=1,
|
||||
)
|
||||
|
||||
duration_ms = int((time.perf_counter() - t0) * 1000)
|
||||
|
||||
if not images:
|
||||
raise RuntimeError(
|
||||
"sdcpp_python_generate: generate_image retorno lista vacia o None."
|
||||
)
|
||||
|
||||
meta: dict[str, Any] = {
|
||||
"backend": "sdcpp_python",
|
||||
"model": cfg.model.name,
|
||||
"sampler": cfg.sampler,
|
||||
"actual_steps": cfg.steps,
|
||||
"seed": cfg.seed,
|
||||
"wtype": wtype,
|
||||
}
|
||||
|
||||
return ImageGenResult(
|
||||
image=images[0],
|
||||
meta=meta,
|
||||
duration_ms=duration_ms,
|
||||
vram_peak_mb=None,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: sdcpp_python_load
|
||||
kind: function
|
||||
lang: py
|
||||
domain: ml
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def sdcpp_python_load(model: ModelRef, n_threads: int = -1, wtype: str = 'default', rng_type: str = 'cuda') -> Any"
|
||||
description: "Carga un StableDiffusion via stable-diffusion-cpp-python con cache global por (model_key, wtype, n_threads). Segunda llamada con mismos params retorna instancia cacheada sin recargar disco."
|
||||
tags: [ml, stable-diffusion, sdcpp, inference, backend, cache, load]
|
||||
uses_functions: []
|
||||
uses_types: [model_ref_py_ml]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [stable_diffusion_cpp]
|
||||
params:
|
||||
- name: model
|
||||
desc: "Referencia al modelo. model.path se usa si presente; si no, model.name como ruta local o HuggingFace hub."
|
||||
- name: n_threads
|
||||
desc: "Numero de hilos CPU para inferencia. -1 usa todos los disponibles."
|
||||
- name: wtype
|
||||
desc: "Tipo de pesos en memoria: 'default' | 'f32' | 'f16' | 'q8_0' | 'q5_1' | 'q5_0' | 'q4_1' | 'q4_0'. 'default' respeta el tipo original del checkpoint."
|
||||
- name: rng_type
|
||||
desc: "Generador de aleatorios: 'std_default' | 'cuda'. 'cuda' produce resultados compatibles con la implementacion CUDA incluso en CPU."
|
||||
output: "Instancia StableDiffusion (stable_diffusion_cpp.StableDiffusion) lista para llamar a generate_image()."
|
||||
tested: true
|
||||
tests:
|
||||
- "load retorna objeto StableDiffusion"
|
||||
- "segunda llamada retorna instancia cacheada"
|
||||
test_file_path: "python/functions/ml/tests/test_sdcpp_python_backend.py"
|
||||
file_path: "python/functions/ml/sdcpp_python_load.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/ml")
|
||||
from model_ref import ModelRef
|
||||
from sdcpp_python_load import sdcpp_python_load
|
||||
|
||||
model = ModelRef(
|
||||
name="sd-turbo",
|
||||
model_type="sd15",
|
||||
quantization="fp16",
|
||||
path="/home/lucas/vaults/imagegen_models/diffusers/sd-turbo/sd_turbo.safetensors",
|
||||
)
|
||||
sd = sdcpp_python_load(model, n_threads=-1, wtype="default")
|
||||
# sd listo para sd.generate_image(...)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- El cache evita recargas de disco en bucles de generacion con el mismo modelo.
|
||||
- `wtype="default"` respeta el tipo de cuantizacion del checkpoint; util para safetensors mixtos.
|
||||
- `rng_type="cuda"` produce seeds compatibles con la implementacion GPU aunque se corra en CPU.
|
||||
- Para limpiar el cache en tests: `sdcpp_python_load._clear_sd_cache()`.
|
||||
- Compilacion sin CUDA: `CMAKE_ARGS="-DSD_CUDA=OFF" pip install stable-diffusion-cpp-python`.
|
||||
- El binding 0.4.7 usa `generate_image()` (no `txt_to_img` que era la API de versiones anteriores).
|
||||
---
|
||||
@@ -0,0 +1,86 @@
|
||||
"""sdcpp_python_load — carga un StableDiffusion (stable-diffusion-cpp-python) con cache global."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from model_ref import ModelRef
|
||||
|
||||
# Cache global: (model_key, wtype, n_threads) -> StableDiffusion object
|
||||
_SD_CACHE: dict[tuple[str, str, int], Any] = {}
|
||||
|
||||
|
||||
def _get_model_key(model: ModelRef) -> str:
|
||||
"""Retorna la clave de cache para un ModelRef."""
|
||||
return model.path if model.path else model.name
|
||||
|
||||
|
||||
def sdcpp_python_load(
|
||||
model: ModelRef,
|
||||
n_threads: int = -1,
|
||||
wtype: str = "default",
|
||||
rng_type: str = "cuda",
|
||||
) -> Any:
|
||||
"""Carga un StableDiffusion via stable-diffusion-cpp-python con cache global.
|
||||
|
||||
Instancia StableDiffusion con el checkpoint indicado por model.path (o model.name
|
||||
si path es None). Los objetos se cachean en memoria por (model_key, wtype, n_threads)
|
||||
— una segunda llamada con los mismos parametros retorna la instancia cacheada sin
|
||||
recargar el modelo del disco.
|
||||
|
||||
Args:
|
||||
model: Referencia al modelo. model.path se usa si esta presente;
|
||||
si no, model.name se pasa como model_path (ruta local o hub).
|
||||
n_threads: Numero de hilos de CPU para inferencia. -1 usa todos los disponibles.
|
||||
wtype: Tipo de pesos / cuantizacion en memoria.
|
||||
Valores: "default" | "f32" | "f16" | "q8_0" | "q5_1" | "q5_0" | "q4_1" | "q4_0".
|
||||
"default" respeta el tipo original del checkpoint.
|
||||
rng_type: Tipo de generador de numeros aleatorios.
|
||||
Valores: "std_default" | "cuda".
|
||||
"cuda" produce resultados compatibles con la implementacion CUDA
|
||||
incluso en CPU.
|
||||
|
||||
Returns:
|
||||
Instancia StableDiffusion lista para llamar a generate_image().
|
||||
|
||||
Raises:
|
||||
ImportError: Si stable_diffusion_cpp no esta instalado.
|
||||
Instalar con: pip install stable-diffusion-cpp-python
|
||||
OSError: Si el path del modelo no existe o es invalido.
|
||||
"""
|
||||
try:
|
||||
from stable_diffusion_cpp import StableDiffusion
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"sdcpp_python_load requiere stable-diffusion-cpp-python. "
|
||||
"Instalar con: pip install stable-diffusion-cpp-python\n"
|
||||
"Para compilar sin CUDA: "
|
||||
"CMAKE_ARGS='-DSD_CUDA=OFF' pip install stable-diffusion-cpp-python"
|
||||
) from exc
|
||||
|
||||
model_key = _get_model_key(model)
|
||||
cache_key = (model_key, wtype, n_threads)
|
||||
|
||||
if cache_key in _SD_CACHE:
|
||||
return _SD_CACHE[cache_key]
|
||||
|
||||
load_path = model.path if model.path else model.name
|
||||
|
||||
sd = StableDiffusion(
|
||||
model_path=load_path,
|
||||
wtype=wtype,
|
||||
n_threads=n_threads,
|
||||
rng_type=rng_type,
|
||||
)
|
||||
|
||||
_SD_CACHE[cache_key] = sd
|
||||
return sd
|
||||
|
||||
|
||||
def _clear_sd_cache() -> None:
|
||||
"""Limpia el cache global de instancias StableDiffusion (uso interno y tests)."""
|
||||
_SD_CACHE.clear()
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Tests para el backend sdcpp_python: sdcpp_python_load y sdcpp_python_generate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
# Ajustar path para importar desde python/functions/ml/
|
||||
_ML_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
sys.path.insert(0, _ML_PATH)
|
||||
|
||||
# Importacion lazy — salta todos los tests si el package no esta instalado.
|
||||
pytest.importorskip(
|
||||
"stable_diffusion_cpp",
|
||||
reason="stable_diffusion_cpp no instalado — skip tests sdcpp_python backend",
|
||||
)
|
||||
|
||||
# El paquete usa modulos hermanos sin prefijo (model_ref, generation_config...).
|
||||
# Para evitar el double-import problem, mapeamos los aliases antes de importar
|
||||
# las funciones bajo test.
|
||||
import ml.model_ref as _mref_module
|
||||
import ml.generation_config as _gcfg_module
|
||||
import ml.image_gen_result as _igr_module
|
||||
|
||||
for _alias, _mod in [
|
||||
("model_ref", _mref_module),
|
||||
("generation_config", _gcfg_module),
|
||||
("image_gen_result", _igr_module),
|
||||
]:
|
||||
if _alias not in sys.modules:
|
||||
sys.modules[_alias] = _mod # type: ignore[assignment]
|
||||
|
||||
from ml.model_ref import ModelRef
|
||||
from ml.generation_config import GenerationConfig
|
||||
from ml.image_gen_result import ImageGenResult
|
||||
from ml.sdcpp_python_load import sdcpp_python_load, _clear_sd_cache
|
||||
from ml.sdcpp_python_generate import sdcpp_python_generate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constantes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SD_TURBO_SAFETENSORS = (
|
||||
"/home/lucas/vaults/imagegen_models/diffusers/sd-turbo/sd_turbo.safetensors"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def sd_turbo_model() -> ModelRef:
|
||||
"""ModelRef apuntando al safetensors de SD Turbo en local."""
|
||||
if not os.path.isfile(SD_TURBO_SAFETENSORS):
|
||||
pytest.skip(
|
||||
f"Modelo SD Turbo no encontrado en {SD_TURBO_SAFETENSORS}"
|
||||
)
|
||||
return ModelRef(
|
||||
name="sd-turbo",
|
||||
model_type="sd15",
|
||||
quantization="fp16",
|
||||
path=SD_TURBO_SAFETENSORS,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def loaded_sd(sd_turbo_model: ModelRef):
|
||||
"""StableDiffusion cargado una sola vez para toda la sesion de tests."""
|
||||
_clear_sd_cache()
|
||||
sd = sdcpp_python_load(sd_turbo_model, n_threads=-1, wtype="default")
|
||||
yield sd
|
||||
_clear_sd_cache()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def sd_turbo_cfg(sd_turbo_model: ModelRef) -> GenerationConfig:
|
||||
"""GenerationConfig para SD Turbo: 512x512, 4 steps, euler_a, seed=42."""
|
||||
return GenerationConfig(
|
||||
prompt="a simple red apple on a white table",
|
||||
negative_prompt=None,
|
||||
seed=42,
|
||||
steps=4,
|
||||
cfg_scale=1.0,
|
||||
sampler="euler_a",
|
||||
width=512,
|
||||
height=512,
|
||||
model=sd_turbo_model,
|
||||
loras=[],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_retorna_objeto(loaded_sd) -> None:
|
||||
"""load retorna objeto StableDiffusion"""
|
||||
from stable_diffusion_cpp import StableDiffusion
|
||||
|
||||
assert isinstance(loaded_sd, StableDiffusion), (
|
||||
f"Se esperaba StableDiffusion, se obtuvo {type(loaded_sd)}"
|
||||
)
|
||||
|
||||
|
||||
def test_load_caches(sd_turbo_model: ModelRef, loaded_sd) -> None:
|
||||
"""segunda llamada retorna instancia cacheada"""
|
||||
import time
|
||||
|
||||
t0 = time.perf_counter()
|
||||
sd2 = sdcpp_python_load(sd_turbo_model, n_threads=-1, wtype="default")
|
||||
elapsed = time.perf_counter() - t0
|
||||
|
||||
assert sd2 is loaded_sd, "Segunda llamada debe retornar la misma instancia cacheada"
|
||||
assert elapsed < 0.5, (
|
||||
f"Segunda llamada tardo {elapsed:.3f}s — deberia ser inmediata (cache hit)"
|
||||
)
|
||||
|
||||
|
||||
def test_generate_retorna_image_gen_result(
|
||||
loaded_sd, sd_turbo_cfg: GenerationConfig
|
||||
) -> None:
|
||||
"""generate retorna ImageGenResult valido"""
|
||||
result = sdcpp_python_generate(loaded_sd, sd_turbo_cfg)
|
||||
|
||||
assert isinstance(result, ImageGenResult), (
|
||||
f"Se esperaba ImageGenResult, se obtuvo {type(result)}"
|
||||
)
|
||||
assert result.image is not None, "result.image no debe ser None"
|
||||
|
||||
|
||||
def test_duration_ms_mayor_que_cero(
|
||||
loaded_sd, sd_turbo_cfg: GenerationConfig
|
||||
) -> None:
|
||||
"""duration_ms mayor que cero"""
|
||||
result = sdcpp_python_generate(loaded_sd, sd_turbo_cfg)
|
||||
assert result.duration_ms > 0, (
|
||||
f"duration_ms debe ser > 0, se obtuvo {result.duration_ms}"
|
||||
)
|
||||
|
||||
|
||||
def test_meta_backend_es_sdcpp_python(
|
||||
loaded_sd, sd_turbo_cfg: GenerationConfig
|
||||
) -> None:
|
||||
"""meta backend es sdcpp_python"""
|
||||
result = sdcpp_python_generate(loaded_sd, sd_turbo_cfg)
|
||||
assert result.meta.get("backend") == "sdcpp_python", (
|
||||
f"meta['backend'] debe ser 'sdcpp_python', se obtuvo {result.meta.get('backend')!r}"
|
||||
)
|
||||
assert result.meta.get("model") == sd_turbo_cfg.model.name
|
||||
assert result.meta.get("sampler") == sd_turbo_cfg.sampler
|
||||
assert result.meta.get("seed") == sd_turbo_cfg.seed
|
||||
|
||||
|
||||
def test_vram_peak_mb_es_none(
|
||||
loaded_sd, sd_turbo_cfg: GenerationConfig
|
||||
) -> None:
|
||||
"""vram_peak_mb es None — sdcpp no expone medicion de VRAM"""
|
||||
result = sdcpp_python_generate(loaded_sd, sd_turbo_cfg)
|
||||
assert result.vram_peak_mb is None, (
|
||||
f"vram_peak_mb debe ser None, se obtuvo {result.vram_peak_mb}"
|
||||
)
|
||||
Reference in New Issue
Block a user