feat(ml): auto-commit con 14 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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