Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02301aaed3 | |||
| 2729629f0a | |||
| 6cc90558d4 | |||
| 36a725ba10 | |||
| 1dd6c889e5 | |||
| 7aaac44a49 | |||
| ffcb69ce02 |
File diff suppressed because one or more lines are too long
@@ -44,8 +44,10 @@ from .trend_slope import trend_slope
|
|||||||
from .run_eda_models import run_eda_models
|
from .run_eda_models import run_eda_models
|
||||||
from .eda_llm_insights import eda_llm_insights
|
from .eda_llm_insights import eda_llm_insights
|
||||||
from .build_eda_notebook import build_eda_notebook
|
from .build_eda_notebook import build_eda_notebook
|
||||||
|
from .decode_qr_image import decode_qr_image
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"decode_qr_image",
|
||||||
"summarize_table_duckdb",
|
"summarize_table_duckdb",
|
||||||
"summarize_table_pg",
|
"summarize_table_pg",
|
||||||
"spearman_corr",
|
"spearman_corr",
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""
|
||||||
|
Decodificación robusta de códigos QR desde una imagen en disco.
|
||||||
|
|
||||||
|
Función del registry (grupo de capacidad `qr`, dominio `datascience`). Pensada para el caso real
|
||||||
|
en el que un lector básico (pyzbar, `cv2.QRCodeDetector` sobre la imagen cruda) NO capta el QR:
|
||||||
|
screenshots de pantalla con QR pálidos (bajo contraste) o pequeños. En vez de un único intento,
|
||||||
|
genera varias variantes preprocesadas de la imagen y prueba cada detector disponible sobre cada
|
||||||
|
variante, parando al primer acierto.
|
||||||
|
|
||||||
|
Impura: lee un archivo de disco y depende de OpenCV (`opencv-contrib-python-headless`). Degrada
|
||||||
|
limpio (devuelve `[]`) si la imagen no se puede leer o si ningún QR se decodifica; no lanza.
|
||||||
|
|
||||||
|
Detectores (se usan los que estén instalados; el import se envuelve en try/except para degradar):
|
||||||
|
- `cv2.QRCodeDetectorAruco` (preferido — OpenCV puro, sin libs de sistema)
|
||||||
|
- `cv2.QRCodeDetector` (fallback OpenCV puro)
|
||||||
|
- `cv2.wechat_qrcode.WeChatQRCode` (excelente con bajo contraste; SOLO si los modelos cargan)
|
||||||
|
- `pyzbar` (bonus opcional; requiere la lib de sistema `libzbar0`)
|
||||||
|
|
||||||
|
Cero dependencias de sistema obligatorias: con solo OpenCV la función ya funciona.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
# Detectores. Cada uno se normaliza a una función run(img) -> list[str] que nunca lanza.
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
def _make_opencv_runner(detector):
|
||||||
|
"""Envuelve un cv2.QRCodeDetector(Aruco) en run(img) -> list[str]."""
|
||||||
|
|
||||||
|
def run(img):
|
||||||
|
out: list[str] = []
|
||||||
|
# detectAndDecodeMulti: capta varios QR en la misma imagen.
|
||||||
|
try:
|
||||||
|
ok, decoded, _points, _ = detector.detectAndDecodeMulti(img)
|
||||||
|
if ok and decoded:
|
||||||
|
out = [s for s in decoded if s]
|
||||||
|
except cv2.error:
|
||||||
|
pass
|
||||||
|
if not out:
|
||||||
|
# Fallback al decodificador de un solo QR.
|
||||||
|
try:
|
||||||
|
s, _pts, _ = detector.detectAndDecode(img)
|
||||||
|
if s:
|
||||||
|
out = [s]
|
||||||
|
except cv2.error:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def _make_wechat_runner(wd):
|
||||||
|
"""Envuelve un cv2.wechat_qrcode.WeChatQRCode en run(img) -> list[str]."""
|
||||||
|
|
||||||
|
def run(img):
|
||||||
|
try:
|
||||||
|
texts, _points = wd.detectAndDecode(img)
|
||||||
|
return [t for t in texts if t]
|
||||||
|
except Exception:
|
||||||
|
# Si los modelos no están cargados o el detector falla, degradar sin romper.
|
||||||
|
return []
|
||||||
|
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pyzbar_runner(zbar_decode):
|
||||||
|
"""Envuelve pyzbar.decode en run(img) -> list[str]."""
|
||||||
|
|
||||||
|
def run(img):
|
||||||
|
out: list[str] = []
|
||||||
|
try:
|
||||||
|
for sym in zbar_decode(img):
|
||||||
|
try:
|
||||||
|
out.append(sym.data.decode("utf-8", "replace"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return out
|
||||||
|
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def _build_detectors(debug=False):
|
||||||
|
"""Construye la lista de (nombre, runner) de detectores disponibles, en orden de preferencia."""
|
||||||
|
detectors = []
|
||||||
|
|
||||||
|
# OpenCV Aruco (preferido): no requiere libs de sistema ni descarga de modelos.
|
||||||
|
if hasattr(cv2, "QRCodeDetectorAruco"):
|
||||||
|
try:
|
||||||
|
detectors.append(("opencv_aruco", _make_opencv_runner(cv2.QRCodeDetectorAruco())))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# OpenCV clásico (fallback puro).
|
||||||
|
if hasattr(cv2, "QRCodeDetector"):
|
||||||
|
try:
|
||||||
|
detectors.append(("opencv", _make_opencv_runner(cv2.QRCodeDetector())))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# WeChat QR (excelente con bajo contraste) — SOLO si los modelos cargan; opcional.
|
||||||
|
if hasattr(cv2, "wechat_qrcode"):
|
||||||
|
try:
|
||||||
|
wd = cv2.wechat_qrcode.WeChatQRCode()
|
||||||
|
detectors.append(("wechat", _make_wechat_runner(wd)))
|
||||||
|
except Exception:
|
||||||
|
# Modelos no presentes / build sin soporte → saltar sin romper.
|
||||||
|
pass
|
||||||
|
|
||||||
|
# pyzbar (bonus): requiere libzbar0 (lib de sistema). Degrada si falta.
|
||||||
|
try:
|
||||||
|
from pyzbar.pyzbar import decode as _zbar_decode # type: ignore
|
||||||
|
|
||||||
|
detectors.append(("pyzbar", _make_pyzbar_runner(_zbar_decode)))
|
||||||
|
except (ImportError, OSError, Exception): # noqa: B014 - OSError = libzbar0 ausente
|
||||||
|
pass
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
print(
|
||||||
|
f"[decode_qr_image] detectores disponibles: {[n for n, _ in detectors]}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return detectors
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
# Variantes preprocesadas de la imagen. Orden = prioridad; se para en el primer acierto.
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
def _load_bgr(image_path):
|
||||||
|
"""Carga la imagen como BGR (uint8). Devuelve None si no se puede leer."""
|
||||||
|
bgr = cv2.imread(image_path, cv2.IMREAD_COLOR)
|
||||||
|
if bgr is not None:
|
||||||
|
return bgr
|
||||||
|
# Fallback PIL para formatos que cv2.imread no maneja en esta build.
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
pil = Image.open(image_path).convert("RGB")
|
||||||
|
return cv2.cvtColor(np.asarray(pil), cv2.COLOR_RGB2BGR)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_variants(image_path, upscale):
|
||||||
|
"""Genera (nombre, ndarray) de variantes preprocesadas, en orden de prioridad."""
|
||||||
|
bgr = _load_bgr(image_path)
|
||||||
|
if bgr is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
|
# Contrast stretch (NORM_MINMAX): clave para QR de bajo contraste (gris sobre gris).
|
||||||
|
stretch = cv2.normalize(gray, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
|
||||||
|
|
||||||
|
# CLAHE: realce de contraste local.
|
||||||
|
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(gray)
|
||||||
|
|
||||||
|
# Upscale del stretch: QR pequeño es la causa #1 de fallo.
|
||||||
|
if upscale and upscale > 1:
|
||||||
|
up = cv2.resize(stretch, None, fx=upscale, fy=upscale, interpolation=cv2.INTER_CUBIC)
|
||||||
|
else:
|
||||||
|
up = stretch
|
||||||
|
|
||||||
|
# Binarizaciones sobre el stretch (mejor base que el gris crudo).
|
||||||
|
_, otsu = cv2.threshold(stretch, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||||||
|
adaptive = cv2.adaptiveThreshold(
|
||||||
|
stretch, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 5
|
||||||
|
)
|
||||||
|
|
||||||
|
variants = [
|
||||||
|
("original", bgr),
|
||||||
|
("gray", gray),
|
||||||
|
("contrast_stretch", stretch),
|
||||||
|
("clahe", clahe),
|
||||||
|
("upscale", up),
|
||||||
|
("otsu", otsu),
|
||||||
|
("adaptive_gaussian", adaptive),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Rotaciones sobre la mejor variante binarizada (Otsu).
|
||||||
|
for name, rot in (
|
||||||
|
("rot90", cv2.ROTATE_90_CLOCKWISE),
|
||||||
|
("rot180", cv2.ROTATE_180),
|
||||||
|
("rot270", cv2.ROTATE_90_COUNTERCLOCKWISE),
|
||||||
|
):
|
||||||
|
variants.append((f"otsu_{name}", cv2.rotate(otsu, rot)))
|
||||||
|
|
||||||
|
return variants
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
# API pública.
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
def decode_qr_image(image_path: str, upscale: int = 2, debug: bool = False) -> list[str]:
|
||||||
|
"""Decodifica los códigos QR de una imagen, robusto a bajo contraste y QR pequeños.
|
||||||
|
|
||||||
|
Genera varias variantes preprocesadas de la imagen (escala de grises, contrast stretch,
|
||||||
|
CLAHE, upscale, binarización Otsu/adaptativa, rotaciones) y prueba cada detector disponible
|
||||||
|
(OpenCV Aruco/clásico, WeChat si hay modelos, pyzbar si hay libzbar0) sobre cada variante,
|
||||||
|
parando al primer acierto.
|
||||||
|
|
||||||
|
Parámetros (`upscale` y `debug` pensados como opciones keyword):
|
||||||
|
image_path: ruta del archivo de imagen a leer (png/jpg/...).
|
||||||
|
upscale: factor de ampliación (INTER_CUBIC) aplicado a la variante de contraste estirado
|
||||||
|
para rescatar QR pequeños. Default 2. <=1 desactiva el upscale.
|
||||||
|
debug: si True, imprime a stderr qué variante/detector acertó (o que no se detectó nada).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de payloads de texto de los QR detectados (deduplicada, preservando orden). Lista
|
||||||
|
vacía si no se detecta ninguno o si la imagen no se puede leer. No lanza.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
variants = _build_variants(image_path, upscale)
|
||||||
|
except Exception as exc: # pragma: no cover - defensa ante imágenes corruptas
|
||||||
|
if debug:
|
||||||
|
print(f"[decode_qr_image] fallo construyendo variantes: {exc}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not variants:
|
||||||
|
if debug:
|
||||||
|
print(f"[decode_qr_image] no se pudo leer la imagen: {image_path}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
detectors = _build_detectors(debug=debug)
|
||||||
|
if not detectors:
|
||||||
|
if debug:
|
||||||
|
print("[decode_qr_image] ningún detector QR disponible", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
for vname, vimg in variants:
|
||||||
|
for dname, drun in detectors:
|
||||||
|
payloads = drun(vimg)
|
||||||
|
uniq = list(dict.fromkeys(p for p in payloads if p))
|
||||||
|
if uniq:
|
||||||
|
if debug:
|
||||||
|
print(
|
||||||
|
f"[decode_qr_image] acierto variante={vname} detector={dname} "
|
||||||
|
f"n={len(uniq)}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return uniq
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
print("[decode_qr_image] ningún QR decodificado en ninguna variante", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Demo CLI para `python3 decode_qr_image.py <image_path> [upscale] [debug]`.
|
||||||
|
# (fn run usa su propio runner generado; este bloque es para invocación manual directa.)
|
||||||
|
import json
|
||||||
|
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(json.dumps({"error": "uso: <image_path> [upscale] [debug]"}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
_path = sys.argv[1]
|
||||||
|
_upscale = int(sys.argv[2]) if len(sys.argv) > 2 else 2
|
||||||
|
_debug = (sys.argv[3].lower() in ("1", "true", "yes")) if len(sys.argv) > 3 else False
|
||||||
|
|
||||||
|
_result = decode_qr_image(_path, upscale=_upscale, debug=_debug)
|
||||||
|
print(json.dumps(_result))
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
name: assemble_animated_sprite
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: ml
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def assemble_animated_sprite(frame_paths: list, out_dir: str, *, name: str = \"anim\", fps: int = 8, fmt: str = \"webp\", loop: bool = True, spritesheet: bool = True, pad: int = 0) -> dict"
|
||||||
|
description: "Ensambla N frames PNG RGBA (p.ej. los frames de un walk cycle ya pixelizados a 32x32 con alpha) en DOS entregables: un sprite sheet horizontal (1 fila x N columnas) PNG RGBA con la transparencia intacta, y una animacion en loop WEBP lossless o GIF animado. Es la pieza de ensamblado final de cualquier animacion de sprite. Salta frames que falten o no abran (aviso en error, no aborta); normaliza tamano al primer frame valido reescalando con NEAREST. Solo PIL. No-throw. Devuelve {ok, spritesheet_path, animation_path, n_frames, frame_size, fmt, error}."
|
||||||
|
tags: [gamedev-2d, comfyui, sprite, animation]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: frame_paths
|
||||||
|
desc: "lista de rutas a PNG RGBA en orden de reproduccion; los que falten o no abran se saltan (aviso en error)."
|
||||||
|
- name: out_dir
|
||||||
|
desc: "directorio de salida; se crea si no existe. Se escriben '<name>_sheet.png' y '<name>.<ext>' dentro."
|
||||||
|
- name: name
|
||||||
|
desc: "nombre base de los ficheros generados (keyword-only, default 'anim')."
|
||||||
|
- name: fps
|
||||||
|
desc: "frames por segundo de la animacion; duration_ms = round(1000/max(1,fps)) por frame (keyword-only, default 8)."
|
||||||
|
- name: fmt
|
||||||
|
desc: "formato de la animacion: 'webp' (recomendado, lossless, alpha completo) o 'gif' (alpha binario) (keyword-only)."
|
||||||
|
- name: loop
|
||||||
|
desc: "si True la animacion se repite indefinidamente (loop=0); si False una sola vez (keyword-only, default True)."
|
||||||
|
- name: spritesheet
|
||||||
|
desc: "si True genera tambien el sprite sheet horizontal PNG RGBA (keyword-only, default True)."
|
||||||
|
- name: pad
|
||||||
|
desc: "pixeles de separacion transparente entre columnas del sheet (keyword-only, default 0)."
|
||||||
|
output: "dict con ok (bool, True si se produjo la animacion con >=1 frame valido), spritesheet_path (str, '' si spritesheet=False o fallo), animation_path (str, '' si fallo), n_frames (int, frames validos usados), frame_size ([w,h] del frame normalizado), fmt (str, 'webp'|'gif'), error (str, avisos y/o error; '' si limpio)."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/ml/assemble_animated_sprite.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from ml.assemble_animated_sprite import assemble_animated_sprite
|
||||||
|
|
||||||
|
# Frames de un walk cycle ya pixelizados a 32x32 RGBA (p.ej. salida del pipeline ComfyUI):
|
||||||
|
frames = [
|
||||||
|
"/tmp/walk/frame_00.png",
|
||||||
|
"/tmp/walk/frame_01.png",
|
||||||
|
"/tmp/walk/frame_02.png",
|
||||||
|
"/tmp/walk/frame_03.png",
|
||||||
|
]
|
||||||
|
res = assemble_animated_sprite(frames, "/tmp/walk_out", name="hero_walk", fps=8, fmt="webp")
|
||||||
|
# {'ok': True,
|
||||||
|
# 'spritesheet_path': '/tmp/walk_out/hero_walk_sheet.png',
|
||||||
|
# 'animation_path': '/tmp/walk_out/hero_walk.webp',
|
||||||
|
# 'n_frames': 4, 'frame_size': [32, 32], 'fmt': 'webp', 'error': ''}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Al final de cualquier pipeline de animacion de sprite, cuando ya tienes los frames
|
||||||
|
sueltos (pixelizados, con alpha) y necesitas (a) verlos animados en bucle para validar
|
||||||
|
el ciclo a ojo y (b) un sprite sheet horizontal listo para que un motor de juego lo
|
||||||
|
trocee por columnas. Tipico despues de generar un walk cycle frame a frame con ComfyUI
|
||||||
|
y pasarlo por el pixelizado: este es el paso de "juntarlo todo". Usa `fmt="webp"` por
|
||||||
|
defecto; `fmt="gif"` solo si necesitas compatibilidad con visores que no abren WEBP.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **GIF solo tiene alpha binario** (1 bit): cada pixel es opaco o totalmente
|
||||||
|
transparente, los pixeles con `alpha < 128` se vuelven transparentes y se pierde el
|
||||||
|
anti-aliasing del borde. **WEBP (lossless) es el formato recomendado** para sprites con
|
||||||
|
alpha — conserva el canal alpha completo y no ensucia el pixel-art. Usa GIF solo por
|
||||||
|
compatibilidad.
|
||||||
|
- Al guardar GIF, PIL **reoptimiza la paleta** y el indice de transparencia puede
|
||||||
|
cambiar (p.ej. de 255 a 1 al releer): es normal, los pixeles transparentes se
|
||||||
|
preservan (verificable convirtiendo el frame a RGBA y mirando el canal alpha).
|
||||||
|
- **Frames que faltan o no abren se SALTAN** (se anota en `error`), no se aborta: la
|
||||||
|
animacion se monta con los frames validos. Si quedan **0 frames validos** → `ok=False`.
|
||||||
|
- El campo `error` puede venir **no vacio aunque `ok=True`**: ahi van los avisos de
|
||||||
|
frames saltados. `ok` refleja si se genero la animacion, no la ausencia de avisos.
|
||||||
|
- El tamano se normaliza al **primer frame valido**; los frames de tamano distinto se
|
||||||
|
reescalan con **NEAREST** (sin interpolacion, preserva el pixel-art duro), lo que puede
|
||||||
|
deformarlos si su aspect ratio difiere. Asegurate de que todos los frames ya vienen al
|
||||||
|
mismo tamano.
|
||||||
|
- Escribe en disco: crea `out_dir` si no existe; si no hay permiso de escritura, el
|
||||||
|
fallo del sheet va a `error` como aviso y el de la animacion pone `ok=False`.
|
||||||
|
- `disposal=2` limpia el lienzo entre frames (transparencia correcta en cada paso); sin
|
||||||
|
el, los frames se acumularian unos sobre otros.
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""Ensambla frames PNG RGBA en un sprite sheet horizontal + una animacion en loop.
|
||||||
|
|
||||||
|
Funcion impura: lee N frames de disco (los frames ya pixelizados de un walk cycle,
|
||||||
|
por ejemplo) y escribe DOS entregables:
|
||||||
|
|
||||||
|
1. Un sprite sheet horizontal (1 fila x N columnas) PNG RGBA, con la transparencia
|
||||||
|
de cada frame intacta.
|
||||||
|
2. Una animacion en bucle (WEBP lossless o GIF animado) que reproduce los frames.
|
||||||
|
|
||||||
|
Es la pieza de ensamblado final de cualquier animacion de sprite: convierte una lista
|
||||||
|
de frames sueltos en algo que se ve animado (la .webp/.gif) y algo que un motor de
|
||||||
|
juego puede trocear (el sheet). Solo depende de PIL (Pillow), presente en el venv del
|
||||||
|
registry. No lanza excepciones: cualquier problema se reporta en el campo "error".
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def assemble_animated_sprite(
|
||||||
|
frame_paths: list,
|
||||||
|
out_dir: str,
|
||||||
|
*,
|
||||||
|
name: str = "anim",
|
||||||
|
fps: int = 8,
|
||||||
|
fmt: str = "webp",
|
||||||
|
loop: bool = True,
|
||||||
|
spritesheet: bool = True,
|
||||||
|
pad: int = 0,
|
||||||
|
) -> dict:
|
||||||
|
"""Monta un sprite sheet horizontal y una animacion en loop a partir de N frames.
|
||||||
|
|
||||||
|
Carga cada ruta de ``frame_paths`` como RGBA. Los frames que falten o no abran se
|
||||||
|
SALTAN (se anota un aviso en ``error``, no se aborta): se anima con los que haya.
|
||||||
|
El tamano se normaliza al del primer frame valido; los frames de tamano distinto se
|
||||||
|
reescalan con NEAREST a ese tamano (preserva el pixel-art duro, sin interpolacion).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame_paths: lista de rutas a PNG RGBA, en orden de reproduccion.
|
||||||
|
out_dir: directorio de salida; se crea si no existe.
|
||||||
|
name: nombre base de los ficheros generados (``<name>_sheet.png`` y
|
||||||
|
``<name>.<ext>``). keyword-only.
|
||||||
|
fps: frames por segundo de la animacion; duration_ms = round(1000/max(1,fps)).
|
||||||
|
keyword-only.
|
||||||
|
fmt: formato de la animacion, "webp" (recomendado) o "gif". keyword-only.
|
||||||
|
loop: si True la animacion se repite indefinidamente; si False se reproduce una
|
||||||
|
sola vez. keyword-only.
|
||||||
|
spritesheet: si True genera tambien el sprite sheet horizontal PNG RGBA.
|
||||||
|
keyword-only.
|
||||||
|
pad: pixeles de separacion transparente entre columnas del sheet (default 0).
|
||||||
|
keyword-only.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con:
|
||||||
|
- ok (bool): True si se produjo al menos la animacion con >=1 frame valido.
|
||||||
|
- spritesheet_path (str): ruta del PNG del sheet ("" si spritesheet=False o fallo).
|
||||||
|
- animation_path (str): ruta de la animacion WEBP/GIF ("" si fallo).
|
||||||
|
- n_frames (int): numero de frames validos efectivamente usados.
|
||||||
|
- frame_size ([w, h]): tamano del frame normalizado.
|
||||||
|
- fmt (str): formato real de la animacion ("webp" o "gif").
|
||||||
|
- error (str): avisos y/o mensaje de error; "" si todo fue limpio.
|
||||||
|
"""
|
||||||
|
out = {
|
||||||
|
"ok": False,
|
||||||
|
"spritesheet_path": "",
|
||||||
|
"animation_path": "",
|
||||||
|
"n_frames": 0,
|
||||||
|
"frame_size": [0, 0],
|
||||||
|
"fmt": "",
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
warnings: list = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
except ImportError:
|
||||||
|
out["error"] = "PIL (Pillow) no esta instalado en este interprete"
|
||||||
|
return out
|
||||||
|
|
||||||
|
if not frame_paths:
|
||||||
|
out["error"] = "frame_paths vacio: no hay nada que ensamblar"
|
||||||
|
return out
|
||||||
|
|
||||||
|
fmt = str(fmt).lower().strip()
|
||||||
|
if fmt not in ("webp", "gif"):
|
||||||
|
out["error"] = f"fmt invalido {fmt!r}: usa 'webp' o 'gif'"
|
||||||
|
return out
|
||||||
|
out["fmt"] = fmt
|
||||||
|
|
||||||
|
# --- Cargar y normalizar frames (saltando los invalidos) ---
|
||||||
|
frames: list = []
|
||||||
|
target = None # (w, h) del primer frame valido
|
||||||
|
for path in frame_paths:
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
warnings.append(f"falta: {path}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with Image.open(path) as src:
|
||||||
|
im = src.convert("RGBA")
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
warnings.append(f"no abre {path}: {exc}")
|
||||||
|
continue
|
||||||
|
if target is None:
|
||||||
|
target = (im.width, im.height)
|
||||||
|
elif (im.width, im.height) != target:
|
||||||
|
im = im.resize(target, Image.NEAREST)
|
||||||
|
frames.append(im)
|
||||||
|
|
||||||
|
if not frames:
|
||||||
|
out["error"] = "; ".join(["0 frames validos"] + warnings)
|
||||||
|
return out
|
||||||
|
|
||||||
|
w, h = target
|
||||||
|
out["frame_size"] = [w, h]
|
||||||
|
out["n_frames"] = len(frames)
|
||||||
|
n = len(frames)
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
out["error"] = "; ".join([f"no se pudo crear out_dir {out_dir!r}: {exc}"] + warnings)
|
||||||
|
return out
|
||||||
|
|
||||||
|
# --- Sprite sheet horizontal (1 fila x N columnas), RGBA transparente ---
|
||||||
|
if spritesheet:
|
||||||
|
pad = max(0, int(pad))
|
||||||
|
sheet_w = n * w + (n - 1) * pad if n > 0 else 0
|
||||||
|
sheet = Image.new("RGBA", (sheet_w, h), (0, 0, 0, 0))
|
||||||
|
for i, im in enumerate(frames):
|
||||||
|
x = i * (w + pad)
|
||||||
|
# Tercer arg = mascara alpha del propio frame: respeta su transparencia.
|
||||||
|
sheet.paste(im, (x, 0), im)
|
||||||
|
sheet_path = os.path.join(out_dir, f"{name}_sheet.png")
|
||||||
|
try:
|
||||||
|
sheet.save(sheet_path, format="PNG")
|
||||||
|
out["spritesheet_path"] = sheet_path
|
||||||
|
except OSError as exc:
|
||||||
|
warnings.append(f"sheet no guardado: {exc}")
|
||||||
|
|
||||||
|
# --- Animacion en loop (WEBP lossless o GIF con alpha binario) ---
|
||||||
|
duration_ms = round(1000 / max(1, int(fps)))
|
||||||
|
loop_count = 0 if loop else 1 # 0 = infinito
|
||||||
|
ext = fmt
|
||||||
|
anim_path = os.path.join(out_dir, f"{name}.{ext}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if fmt == "webp":
|
||||||
|
frames[0].save(
|
||||||
|
anim_path,
|
||||||
|
save_all=True,
|
||||||
|
append_images=frames[1:],
|
||||||
|
duration=duration_ms,
|
||||||
|
loop=loop_count,
|
||||||
|
format="WEBP",
|
||||||
|
lossless=True, # no ensucia el pixel-art
|
||||||
|
disposal=2, # limpia entre frames -> transparencia correcta
|
||||||
|
)
|
||||||
|
else: # gif
|
||||||
|
pal_frames = [_rgba_to_p_transparent(im) for im in frames]
|
||||||
|
pal_frames[0].save(
|
||||||
|
anim_path,
|
||||||
|
save_all=True,
|
||||||
|
append_images=pal_frames[1:],
|
||||||
|
duration=duration_ms,
|
||||||
|
loop=loop_count,
|
||||||
|
format="GIF",
|
||||||
|
transparency=255, # indice reservado para el pixel transparente
|
||||||
|
disposal=2,
|
||||||
|
)
|
||||||
|
out["animation_path"] = anim_path
|
||||||
|
out["ok"] = True
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
warnings.append(f"animacion no guardada: {exc}")
|
||||||
|
out["ok"] = False
|
||||||
|
|
||||||
|
out["error"] = "; ".join(warnings)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _rgba_to_p_transparent(im, alpha_threshold: int = 128):
|
||||||
|
"""Convierte un frame RGBA a modo P reservando el indice 255 como transparente.
|
||||||
|
|
||||||
|
GIF solo soporta 1 bit de alpha: cada pixel es opaco o totalmente transparente.
|
||||||
|
Los pixeles con alpha < alpha_threshold se mapean al indice 255 (transparente);
|
||||||
|
el resto se cuantiza a 255 colores (indices 0..254).
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
alpha = im.getchannel("A")
|
||||||
|
# Cuantiza el RGB a 255 colores -> indices 0..254 libres, 255 para transparencia.
|
||||||
|
p = im.convert("RGB").quantize(colors=255, method=Image.Quantize.MEDIANCUT)
|
||||||
|
# Mascara de los pixeles "transparentes" (alpha por debajo del umbral).
|
||||||
|
mask = alpha.point(lambda a: 255 if a < alpha_threshold else 0)
|
||||||
|
p.paste(255, (0, 0), mask)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from PIL import Image as _Image, ImageDraw as _ImageDraw
|
||||||
|
|
||||||
|
# --- Genera 4 frames de prueba: un cuadrado de color que se mueve de izquierda a
|
||||||
|
# derecha sobre un lienzo RGBA transparente de 32x32. ---
|
||||||
|
tmp = tempfile.mkdtemp(prefix="assemble_sprite_demo_")
|
||||||
|
demo_frames: list = []
|
||||||
|
box = 10
|
||||||
|
for i in range(4):
|
||||||
|
frame = _Image.new("RGBA", (32, 32), (0, 0, 0, 0)) # fondo transparente
|
||||||
|
d = _ImageDraw.Draw(frame)
|
||||||
|
x0 = 1 + i * 6 # se desplaza hacia la derecha cada frame
|
||||||
|
d.rectangle([x0, 11, x0 + box, 11 + box], fill=(40, 180, 230, 255))
|
||||||
|
fpath = os.path.join(tmp, f"frame_{i:02d}.png")
|
||||||
|
frame.save(fpath)
|
||||||
|
demo_frames.append(fpath)
|
||||||
|
|
||||||
|
result = assemble_animated_sprite(
|
||||||
|
demo_frames, tmp, name="walk_demo", fps=8, fmt="webp"
|
||||||
|
)
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
---
|
||||||
|
name: comfyui_build_walk_cycle_workflow
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: ml
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def comfyui_build_walk_cycle_workflow(subject: str, pose_skeletons: list, *, ckpt_name: str = \"IMG_dreamshaper_8.safetensors\", char_lora: str | None = None, lora_strength: float = 1.0, controlnet_name: str = \"control_v11p_sd15_openpose_fp16.safetensors\", controlnet_strength: float = 0.7, controlnet_start: float = 0.0, controlnet_end: float = 0.8, transparent: bool = True, rembg_model: str = \"u2net\", negative: str = \"blurry, lowres, extra limbs, deformed\", width: int = 512, height: int = 768, steps: int = 24, cfg: float = 7.0, seed: int = 0, sampler_name: str = \"dpmpp_2m\", scheduler: str = \"karras\", fps: int = 8, filename_prefix: str = \"walk_cycle\") -> dict"
|
||||||
|
description: "Construye el dict (API format) del workflow de un WALK CYCLE animado: genera N frames de un personaje en N poses OpenPose con la MISMA seed (identidad consistente), los combina en un batch encadenando ImageBatch, recorta el fondo a alpha con Rembg y los exporta como WEBP animado con SaveAnimatedWEBP. Caso 1 del report 0217 (animacion de sprite frame-by-frame pose-driven). Hermano animado de comfyui_build_sprite_sheet_workflow (frame estatico) y de comfyui_build_directional_sprite_workflow (rotacion 3D). Pura, sin red ni I/O. class_types e inputs verificados contra /object_info."
|
||||||
|
tags: [gamedev-2d, comfyui, sprite, animation, walk-cycle, controlnet, openpose]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: subject
|
||||||
|
desc: "Descripcion del personaje (ej. 'pixel art knight'). Se completa con ', full body, game sprite, simple background, walking'. No puede estar vacio."
|
||||||
|
- name: pose_skeletons
|
||||||
|
desc: "Lista (no vacia) de nombres de archivo de esqueletos OpenPose en el dir input/ del servidor, uno por frame del ciclo en orden de animacion. Cada uno debe ser string no vacio. La lista no se muta."
|
||||||
|
- name: ckpt_name
|
||||||
|
desc: "Checkpoint SD1.5 (OpenPose solo instalado en SD1.5; default 'IMG_dreamshaper_8.safetensors'). keyword-only."
|
||||||
|
- name: char_lora
|
||||||
|
desc: "LoRA de personaje/estilo opcional en models/loras (refuerza consistencia de ropa/cuerpo entre frames). None = sin LoRA. keyword-only."
|
||||||
|
- name: lora_strength
|
||||||
|
desc: "Fuerza del char_lora sobre model y clip. keyword-only."
|
||||||
|
- name: controlnet_name
|
||||||
|
desc: "ControlNet OpenPose (default SD1.5 'control_v11p_sd15_openpose_fp16.safetensors'). keyword-only."
|
||||||
|
- name: controlnet_strength
|
||||||
|
desc: "Fuerza del OpenPose (default 0.7). keyword-only."
|
||||||
|
- name: controlnet_start
|
||||||
|
desc: "Inicio de aplicacion del OpenPose (fraccion 0..1). keyword-only."
|
||||||
|
- name: controlnet_end
|
||||||
|
desc: "Fin de aplicacion del OpenPose (end<1.0 deja libres los ultimos pasos para pelo/ropa; default 0.8). keyword-only."
|
||||||
|
- name: transparent
|
||||||
|
desc: "Si True inyecta Rembg para alpha (recomendado para sprites de juego). False = fondo opaco. keyword-only."
|
||||||
|
- name: rembg_model
|
||||||
|
desc: "Modelo Rembg ('u2net' general, 'isnet-anime' para anime). keyword-only."
|
||||||
|
- name: negative
|
||||||
|
desc: "Prompt negativo. keyword-only."
|
||||||
|
- name: width
|
||||||
|
desc: "Ancho en px (512). keyword-only."
|
||||||
|
- name: height
|
||||||
|
desc: "Alto en px (768, vertical, encuadra cuerpo entero). keyword-only."
|
||||||
|
- name: steps
|
||||||
|
desc: "Pasos del KSampler. keyword-only."
|
||||||
|
- name: cfg
|
||||||
|
desc: "CFG del KSampler. keyword-only."
|
||||||
|
- name: seed
|
||||||
|
desc: "Semilla del KSampler, FIJA e identica para todos los frames (identidad consistente). keyword-only."
|
||||||
|
- name: sampler_name
|
||||||
|
desc: "Sampler del KSampler (default 'dpmpp_2m'). keyword-only."
|
||||||
|
- name: scheduler
|
||||||
|
desc: "Scheduler del KSampler (default 'karras'). keyword-only."
|
||||||
|
- name: fps
|
||||||
|
desc: "Frames por segundo del WEBP animado (default 8). keyword-only."
|
||||||
|
- name: filename_prefix
|
||||||
|
desc: "Prefijo del archivo WEBP en output/ (default 'walk_cycle'). keyword-only."
|
||||||
|
output: "dict en API format listo para comfyui_submit_workflow. Claves = node_ids (string); cada valor tiene class_type + inputs. Estructura: CheckpointLoaderSimple (+ LoraLoader si char_lora) + 2x CLIPTextEncode + ControlNetLoader compartido + N x (LoadImage + ControlNetApplyAdvanced + EmptyLatentImage + KSampler + VAEDecode) + cadena de ImageBatch que une los N frames + Rembg (si transparent) + SaveAnimatedWEBP."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/ml/comfyui_build_walk_cycle_workflow.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from ml.comfyui_build_walk_cycle_workflow import comfyui_build_walk_cycle_workflow
|
||||||
|
|
||||||
|
# Ciclo de andar de 4 frames: 4 esqueletos OpenPose (en input/ del servidor),
|
||||||
|
# misma seed -> el mismo personaje caminando, no 4 personajes distintos.
|
||||||
|
wf = comfyui_build_walk_cycle_workflow(
|
||||||
|
"pixel art knight",
|
||||||
|
pose_skeletons=[
|
||||||
|
"walk_pose_00.png",
|
||||||
|
"walk_pose_01.png",
|
||||||
|
"walk_pose_02.png",
|
||||||
|
"walk_pose_03.png",
|
||||||
|
],
|
||||||
|
transparent=True,
|
||||||
|
fps=8,
|
||||||
|
seed=0,
|
||||||
|
)
|
||||||
|
# wf es API format -> comfyui_submit_workflow(wf) genera el WEBP animado en output/.
|
||||||
|
```
|
||||||
|
|
||||||
|
O lanzable directo con: `./fn run comfyui_build_walk_cycle_workflow` (imprime nodos + class_types del ejemplo).
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites una **animacion** de un sprite de personaje 2D (no un frame suelto):
|
||||||
|
ciclo de andar, correr, atacar, idle... — cualquier secuencia donde el personaje conserva
|
||||||
|
su identidad y solo cambia la postura. Dibuja los N esqueletos OpenPose de la secuencia,
|
||||||
|
pasalos en orden, fija la `seed` y obtienes un WEBP animado de una sola pasada. Para UN
|
||||||
|
frame estatico usa `comfyui_build_sprite_sheet_workflow`; para rotar el personaje en 3D
|
||||||
|
(vistas direccionales) usa `comfyui_build_directional_sprite_workflow`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Solo SD1.5 hoy**: el ControlNet OpenPose esta instalado solo en SD1.5. Usa
|
||||||
|
`IMG_dreamshaper_8` u otro checkpoint SD1.5.
|
||||||
|
- **`pose_skeletons` son nombres de archivo en el dir `input/` del servidor**, no rutas
|
||||||
|
locales. Subelas antes (cada LoadImage las lee de ahi). El orden de la lista = el orden
|
||||||
|
de los frames de la animacion.
|
||||||
|
- **La `seed` es FIJA para todos los frames a proposito**: compartir seed + prompt +
|
||||||
|
checkpoint y variar solo el OpenPose es lo que mantiene al mismo personaje entre
|
||||||
|
fotogramas. Una seed por frame haria "parpadear" la identidad (cara/ropa/paleta derivan).
|
||||||
|
- **`ControlNetApplyAdvanced` con `end_percent` 0.8** deja los ultimos pasos libres para
|
||||||
|
que pelo/ropa no queden aplastados contra el esqueleto.
|
||||||
|
- **El batch se construye encadenando `ImageBatch`** (toma 2 imagenes): para N frames hay
|
||||||
|
N-1 nodos ImageBatch. Con N=1 no hay ImageBatch (el unico frame va directo al Save).
|
||||||
|
- `Image Rembg` da matting binario (silueta solida) — ideal para personajes, NO para
|
||||||
|
efectos translucidos (humo/fuego). Con `transparent=False` se omite (fondo opaco).
|
||||||
|
- **El WEBP animado** usa `lossless=True`, `quality=90`, `method="default"`; sube/baja
|
||||||
|
`fps` para la velocidad del ciclo. Verificado que `method` admite `default/fastest/slowest`.
|
||||||
|
- Funcion pura: construye el grafo, NO valida contra el server ni envia nada. El coste GPU
|
||||||
|
esta al enviar con `comfyui_submit_workflow`.
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""Construye el workflow ComfyUI de un WALK CYCLE animado (N frames pose-driven -> WEBP).
|
||||||
|
|
||||||
|
Caso 1 del report 0217 ("animacion de sprite frame-by-frame pose-driven"): a partir de
|
||||||
|
N esqueletos OpenPose que describen las poses sucesivas de un ciclo de andar, construye el
|
||||||
|
dict (API format) de un workflow que:
|
||||||
|
|
||||||
|
1. genera un frame por pose con la MISMA seed y el MISMO prompt/checkpoint/LoRA, de modo
|
||||||
|
que el personaje conserva su identidad de un frame al siguiente (la unica variable es
|
||||||
|
el esqueleto OpenPose que dicta la postura);
|
||||||
|
2. combina los N frames en un unico batch encadenando `ImageBatch`;
|
||||||
|
3. recorta el fondo a alpha con `Image Rembg (Remove Background)` (transparencia para el
|
||||||
|
motor del juego);
|
||||||
|
4. los exporta como WEBP animado con `SaveAnimatedWEBP` (un solo archivo reproducible).
|
||||||
|
|
||||||
|
Es el builder PURO equivalente a `comfyui_build_sprite_sheet_workflow` (que produce UN
|
||||||
|
frame estatico) pero orientado a ANIMACION: en vez de devolver un sprite suelto por pose y
|
||||||
|
montar un contact-sheet a posteriori, este grafo produce de una sola pasada el WEBP animado
|
||||||
|
del ciclo. Hermano direccional: `comfyui_build_directional_sprite_workflow` (rota el
|
||||||
|
personaje en 3D); aqui el personaje no rota, camina (mismo angulo de camara, poses 2D).
|
||||||
|
|
||||||
|
Por que ControlNetApplyAdvanced (y no el legacy ControlNetApply): `end_percent` < 1.0 deja
|
||||||
|
los ultimos pasos del sampler libres para que pelo y ropa no queden aplastados contra el
|
||||||
|
esqueleto OpenPose (mismo razonamiento que el sprite sheet, report 0137).
|
||||||
|
|
||||||
|
Por que la seed es FIJA para todos los frames: una seed distinta por frame haria que el
|
||||||
|
personaje "parpadee" de identidad entre fotogramas (ropa/cara/paleta derivan). Compartir la
|
||||||
|
seed + prompt + checkpoint y variar solo el OpenPose es lo que hace que sea el mismo
|
||||||
|
personaje andando, no N personajes distintos en N posturas.
|
||||||
|
|
||||||
|
Funcion PURA: sin red, sin I/O. No muta las entradas (no recibe dicts; copia la lista de
|
||||||
|
poses). Todos los class_types y sus inputs estan verificados contra /object_info del server
|
||||||
|
8GB (CheckpointLoaderSimple, LoraLoader, CLIPTextEncode, ControlNetLoader, LoadImage,
|
||||||
|
ControlNetApplyAdvanced, EmptyLatentImage, KSampler, VAEDecode, ImageBatch,
|
||||||
|
'Image Rembg (Remove Background)', SaveAnimatedWEBP).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def comfyui_build_walk_cycle_workflow(
|
||||||
|
subject: str,
|
||||||
|
pose_skeletons: list,
|
||||||
|
*,
|
||||||
|
ckpt_name: str = "IMG_dreamshaper_8.safetensors",
|
||||||
|
char_lora: str | None = None,
|
||||||
|
lora_strength: float = 1.0,
|
||||||
|
controlnet_name: str = "control_v11p_sd15_openpose_fp16.safetensors",
|
||||||
|
controlnet_strength: float = 0.7,
|
||||||
|
controlnet_start: float = 0.0,
|
||||||
|
controlnet_end: float = 0.8,
|
||||||
|
transparent: bool = True,
|
||||||
|
rembg_model: str = "u2net",
|
||||||
|
negative: str = "blurry, lowres, extra limbs, deformed",
|
||||||
|
width: int = 512,
|
||||||
|
height: int = 768,
|
||||||
|
steps: int = 24,
|
||||||
|
cfg: float = 7.0,
|
||||||
|
seed: int = 0,
|
||||||
|
sampler_name: str = "dpmpp_2m",
|
||||||
|
scheduler: str = "karras",
|
||||||
|
fps: int = 8,
|
||||||
|
filename_prefix: str = "walk_cycle",
|
||||||
|
) -> dict:
|
||||||
|
"""Construye el dict (API format) del workflow de un walk cycle animado.
|
||||||
|
|
||||||
|
Genera un frame por cada esqueleto OpenPose de ``pose_skeletons`` con identidad
|
||||||
|
consistente (misma seed/prompt/checkpoint), los combina en un batch, los recorta a
|
||||||
|
alpha (Rembg) y los guarda como WEBP animado.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subject: descripcion del personaje (ej. "pixel art knight"). Se completa con
|
||||||
|
", full body, game sprite, simple background, walking". No puede estar vacio.
|
||||||
|
pose_skeletons: lista (no vacia) de nombres de archivo de esqueletos OpenPose en el
|
||||||
|
dir ``input/`` del servidor (uno por frame del ciclo, en orden de animacion). El
|
||||||
|
grafo crea un LoadImage por entrada; cada uno debe ser un string no vacio. La
|
||||||
|
lista no se muta.
|
||||||
|
ckpt_name: checkpoint SD1.5 (OpenPose solo instalado en SD1.5; default
|
||||||
|
'IMG_dreamshaper_8.safetensors'). keyword-only.
|
||||||
|
char_lora: LoRA de personaje/estilo opcional en models/loras (refuerza la
|
||||||
|
consistencia de ropa/cuerpo entre frames). None = sin LoRA. keyword-only.
|
||||||
|
lora_strength: fuerza del char_lora sobre model y clip. keyword-only.
|
||||||
|
controlnet_name: ControlNet OpenPose (default SD1.5). keyword-only.
|
||||||
|
controlnet_strength: fuerza del OpenPose (default 0.7). keyword-only.
|
||||||
|
controlnet_start: inicio de aplicacion del OpenPose (fraccion 0..1). keyword-only.
|
||||||
|
controlnet_end: fin de aplicacion del OpenPose (end<1.0 deja libres los ultimos
|
||||||
|
pasos para pelo/ropa; default 0.8). keyword-only.
|
||||||
|
transparent: si True inyecta Rembg para alpha (recomendado para sprites de juego).
|
||||||
|
False = fondo opaco. keyword-only.
|
||||||
|
rembg_model: modelo Rembg ('u2net' general, 'isnet-anime' para anime). keyword-only.
|
||||||
|
negative: prompt negativo. keyword-only.
|
||||||
|
width: ancho en px (512). keyword-only.
|
||||||
|
height: alto en px (768, vertical, encuadra cuerpo entero). keyword-only.
|
||||||
|
steps: pasos del KSampler. keyword-only.
|
||||||
|
cfg: CFG del KSampler. keyword-only.
|
||||||
|
seed: semilla del KSampler, FIJA e identica para todos los frames (identidad
|
||||||
|
consistente). keyword-only.
|
||||||
|
sampler_name: sampler del KSampler (default 'dpmpp_2m'). keyword-only.
|
||||||
|
scheduler: scheduler del KSampler (default 'karras'). keyword-only.
|
||||||
|
fps: frames por segundo del WEBP animado (default 8). keyword-only.
|
||||||
|
filename_prefix: prefijo del archivo WEBP en output/ (default 'walk_cycle').
|
||||||
|
keyword-only.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict en API format listo para comfyui_submit_workflow. Las claves son node_ids
|
||||||
|
(string) y cada valor tiene class_type + inputs. Estructura: CheckpointLoaderSimple
|
||||||
|
(+ LoraLoader si char_lora) + 2x CLIPTextEncode + ControlNetLoader compartido +
|
||||||
|
N x (LoadImage + ControlNetApplyAdvanced + EmptyLatentImage + KSampler + VAEDecode)
|
||||||
|
+ cadena de ImageBatch que une los N frames + Rembg (si transparent) +
|
||||||
|
SaveAnimatedWEBP.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: si subject esta vacio, pose_skeletons esta vacio, o alguna pose no es un
|
||||||
|
string no vacio.
|
||||||
|
"""
|
||||||
|
if not subject or not subject.strip():
|
||||||
|
raise ValueError("comfyui_build_walk_cycle_workflow: 'subject' no puede estar vacio")
|
||||||
|
if not isinstance(pose_skeletons, (list, tuple)) or len(pose_skeletons) == 0:
|
||||||
|
raise ValueError(
|
||||||
|
"comfyui_build_walk_cycle_workflow: 'pose_skeletons' debe ser una lista no vacia "
|
||||||
|
"de nombres de esqueletos OpenPose en input/ (uno por frame del ciclo)."
|
||||||
|
)
|
||||||
|
poses = list(pose_skeletons)
|
||||||
|
for i, p in enumerate(poses):
|
||||||
|
if not isinstance(p, str) or not p.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"comfyui_build_walk_cycle_workflow: pose_skeletons["
|
||||||
|
f"{i}] debe ser un string no vacio (nombre de archivo en input/)."
|
||||||
|
)
|
||||||
|
|
||||||
|
positive = f"{subject}, full body, game sprite, simple background, walking"
|
||||||
|
|
||||||
|
wf: dict = {}
|
||||||
|
counter = [0]
|
||||||
|
|
||||||
|
def nid() -> str:
|
||||||
|
counter[0] += 1
|
||||||
|
return str(counter[0])
|
||||||
|
|
||||||
|
# 1. Checkpoint -> MODEL(0), CLIP(1), VAE(2).
|
||||||
|
ckpt_id = nid()
|
||||||
|
wf[ckpt_id] = {
|
||||||
|
"class_type": "CheckpointLoaderSimple",
|
||||||
|
"inputs": {"ckpt_name": ckpt_name},
|
||||||
|
}
|
||||||
|
model_src = [ckpt_id, 0]
|
||||||
|
clip_src = [ckpt_id, 1]
|
||||||
|
vae_src = [ckpt_id, 2]
|
||||||
|
|
||||||
|
# 2. LoRA opcional -> reapunta MODEL/CLIP a su salida.
|
||||||
|
if char_lora:
|
||||||
|
lora_id = nid()
|
||||||
|
wf[lora_id] = {
|
||||||
|
"class_type": "LoraLoader",
|
||||||
|
"inputs": {
|
||||||
|
"model": model_src,
|
||||||
|
"clip": clip_src,
|
||||||
|
"lora_name": char_lora,
|
||||||
|
"strength_model": lora_strength,
|
||||||
|
"strength_clip": lora_strength,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
model_src = [lora_id, 0]
|
||||||
|
clip_src = [lora_id, 1]
|
||||||
|
|
||||||
|
# 3. Prompts positivo y negativo (compartidos por todos los frames).
|
||||||
|
pos_clip_id = nid()
|
||||||
|
wf[pos_clip_id] = {
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"inputs": {"text": positive, "clip": clip_src},
|
||||||
|
}
|
||||||
|
neg_clip_id = nid()
|
||||||
|
wf[neg_clip_id] = {
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"inputs": {"text": negative, "clip": clip_src},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. ControlNetLoader compartido (uno solo para todas las poses).
|
||||||
|
cn_loader_id = nid()
|
||||||
|
wf[cn_loader_id] = {
|
||||||
|
"class_type": "ControlNetLoader",
|
||||||
|
"inputs": {"control_net_name": controlnet_name},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. Por cada pose: LoadImage -> ControlNetApplyAdvanced -> EmptyLatent -> KSampler -> VAEDecode.
|
||||||
|
frame_image_srcs: list = []
|
||||||
|
for pose in poses:
|
||||||
|
load_id = nid()
|
||||||
|
wf[load_id] = {"class_type": "LoadImage", "inputs": {"image": pose}}
|
||||||
|
|
||||||
|
apply_id = nid()
|
||||||
|
wf[apply_id] = {
|
||||||
|
"class_type": "ControlNetApplyAdvanced",
|
||||||
|
"inputs": {
|
||||||
|
"positive": [pos_clip_id, 0],
|
||||||
|
"negative": [neg_clip_id, 0],
|
||||||
|
"control_net": [cn_loader_id, 0],
|
||||||
|
"image": [load_id, 0],
|
||||||
|
"strength": controlnet_strength,
|
||||||
|
"start_percent": controlnet_start,
|
||||||
|
"end_percent": controlnet_end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
latent_id = nid()
|
||||||
|
wf[latent_id] = {
|
||||||
|
"class_type": "EmptyLatentImage",
|
||||||
|
"inputs": {"width": width, "height": height, "batch_size": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
ksampler_id = nid()
|
||||||
|
wf[ksampler_id] = {
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"inputs": {
|
||||||
|
"seed": seed, # FIJA: misma seed para todos los frames (identidad consistente).
|
||||||
|
"steps": steps,
|
||||||
|
"cfg": cfg,
|
||||||
|
"sampler_name": sampler_name,
|
||||||
|
"scheduler": scheduler,
|
||||||
|
"denoise": 1.0,
|
||||||
|
"model": model_src,
|
||||||
|
"positive": [apply_id, 0],
|
||||||
|
"negative": [apply_id, 1],
|
||||||
|
"latent_image": [latent_id, 0],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
vae_id = nid()
|
||||||
|
wf[vae_id] = {
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"inputs": {"samples": [ksampler_id, 0], "vae": vae_src},
|
||||||
|
}
|
||||||
|
frame_image_srcs.append([vae_id, 0])
|
||||||
|
|
||||||
|
# 6. Combinar los N frames en un solo batch encadenando ImageBatch.
|
||||||
|
if len(frame_image_srcs) == 1:
|
||||||
|
batch_src = frame_image_srcs[0]
|
||||||
|
else:
|
||||||
|
batch_src = frame_image_srcs[0]
|
||||||
|
for next_src in frame_image_srcs[1:]:
|
||||||
|
ib_id = nid()
|
||||||
|
wf[ib_id] = {
|
||||||
|
"class_type": "ImageBatch",
|
||||||
|
"inputs": {"image1": batch_src, "image2": next_src},
|
||||||
|
}
|
||||||
|
batch_src = [ib_id, 0]
|
||||||
|
|
||||||
|
# 7. Rembg opcional sobre el batch (alpha para el motor del juego).
|
||||||
|
save_images_src = batch_src
|
||||||
|
if transparent:
|
||||||
|
rembg_id = nid()
|
||||||
|
wf[rembg_id] = {
|
||||||
|
"class_type": "Image Rembg (Remove Background)",
|
||||||
|
"inputs": {
|
||||||
|
"images": batch_src,
|
||||||
|
"transparency": True,
|
||||||
|
"model": rembg_model,
|
||||||
|
"post_processing": False,
|
||||||
|
"only_mask": False,
|
||||||
|
"alpha_matting": False,
|
||||||
|
"alpha_matting_foreground_threshold": 240,
|
||||||
|
"alpha_matting_background_threshold": 10,
|
||||||
|
"alpha_matting_erode_size": 10,
|
||||||
|
"background_color": "none",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
save_images_src = [rembg_id, 0]
|
||||||
|
|
||||||
|
# 8. Exportar el ciclo como WEBP animado.
|
||||||
|
save_id = nid()
|
||||||
|
wf[save_id] = {
|
||||||
|
"class_type": "SaveAnimatedWEBP",
|
||||||
|
"inputs": {
|
||||||
|
"images": save_images_src,
|
||||||
|
"filename_prefix": filename_prefix,
|
||||||
|
"fps": float(fps),
|
||||||
|
"lossless": True,
|
||||||
|
"quality": 90,
|
||||||
|
"method": "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return wf
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
|
||||||
|
wf = comfyui_build_walk_cycle_workflow(
|
||||||
|
"pixel art knight",
|
||||||
|
pose_skeletons=[
|
||||||
|
"walk_pose_00.png",
|
||||||
|
"walk_pose_01.png",
|
||||||
|
"walk_pose_02.png",
|
||||||
|
"walk_pose_03.png",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
print(json.dumps({
|
||||||
|
"nodes": list(wf),
|
||||||
|
"classes": sorted({n["class_type"] for n in wf.values()}),
|
||||||
|
}, indent=2))
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
---
|
||||||
|
name: comfyui_pixelize_sprite_png
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: ml
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def comfyui_pixelize_sprite_png(src_path: str, dst_path: str, *, size: int = 32, colors: int = 16, engine: str = 'pixeloe', palette=None, transparent: bool = True, autocrop: bool = True, crop_pad_ratio: float = 0.02, mode: str = 'contrast', patch_size: int = 16, thickness: int = 2, alpha_threshold: int = 128, comfy_python: str | None = None) -> dict"
|
||||||
|
description: "Pixeliza un PNG existente (un render a alta resolucion, p.ej. 512x768 RGBA con fondo transparente) a un sprite pixel-art REAL de size x size RGBA. Extrae la logica de pixelizado de un PNG existente: la misma secuencia que comfyui_pixelart_real_oneshot aplica internamente (fases 1b/2a/2a-bis/2b), pero desacoplada de la generacion -> sirve para pixelizar cada frame de una animacion, una hoja de sprites o cualquier render existente sin volver a pasar por la difusion. Compone tres funciones del registry: crop_to_content (autocrop al contenido + cuadrar para llenar el frame) -> pixeloe_downscale (downscale contrast-aware que conserva la silueta, engine='pixeloe', con fallback automatico a nearest) -> comfyui_pixelize_image (cuantizacion dura a N colores libres o paleta fija pico-8/nes/game-boy, alpha-aware). PixelOE trabaja en RGB y pierde el alpha, asi que se downscalea el canal alpha aparte (nearest) y se reaplica al grid antes de cuantizar. Impura: lectura/escritura de disco + subprocess del bridge de pixeloe. No-throw: todo error viaja en el campo error del dict. Devuelve {ok, out_path, size, colors_final, has_alpha, engine_used, autocrop_applied, error}."
|
||||||
|
tags: [gamedev-2d, comfyui, pixelart, sprite, ml, downscale, quantize, palette, alpha, transparent, animation]
|
||||||
|
uses_functions: [crop_to_content_py_ml, pixeloe_downscale_py_ml, comfyui_pixelize_image_py_ml]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_py_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: src_path
|
||||||
|
desc: "ruta del PNG de entrada (un render a alta resolucion, p.ej. 512x768 RGBA con fondo transparente). Debe existir."
|
||||||
|
- name: dst_path
|
||||||
|
desc: "ruta del PNG de salida size x size (se crea el directorio si falta)."
|
||||||
|
- name: size
|
||||||
|
desc: "lado del grid final en pixeles (32 iconos/objetos simples, 64 personajes/sprites). Debe ser >= 1. keyword-only."
|
||||||
|
- name: colors
|
||||||
|
desc: "numero de colores de la paleta libre cuando palette es None (cuantizacion MEDIANCUT). keyword-only."
|
||||||
|
- name: engine
|
||||||
|
desc: "'pixeloe' (downscale contrast-aware, para sujetos con silueta: personajes/criaturas/iconos) o 'nearest' (downscale nearest simple, mas barato, para tiles/texturas/fondos sin contorno). Si 'pixeloe' falla o la lib no esta disponible, cae automaticamente a 'nearest' y lo refleja en engine_used. keyword-only."
|
||||||
|
- name: palette
|
||||||
|
desc: "None (paleta libre a 'colors'), nombre builtin ('pico-8','nes','game-boy') o lista de hex. Una paleta fija ignora 'colors'. keyword-only."
|
||||||
|
- name: transparent
|
||||||
|
desc: "si True (default) trata la entrada como RGBA y produce un sprite RGBA con transparencia real (el fondo transparente no entra en la paleta). Para tiles/texturas opacas, False produce salida RGB. keyword-only."
|
||||||
|
- name: autocrop
|
||||||
|
desc: "si True (default) recorta el PNG al bounding box de su contenido y lo cuadra antes del downscale, para que el sujeto llene el frame (evita el sprite diminuto). Usa el alpha si transparent, o el color de fondo si RGB. keyword-only."
|
||||||
|
- name: crop_pad_ratio
|
||||||
|
desc: "margen relativo que deja el autocrop alrededor del sujeto (0.02 = 2% del lado). keyword-only."
|
||||||
|
- name: mode
|
||||||
|
desc: "modo de downscale de PixelOE ('contrast' SOTA, 'k-centroid', 'nearest', 'center', 'bicubic'); solo aplica con engine='pixeloe'. keyword-only."
|
||||||
|
- name: patch_size
|
||||||
|
desc: "tamano de patch de PixelOE (default 16). keyword-only."
|
||||||
|
- name: thickness
|
||||||
|
desc: "grosor del outline expansion de PixelOE (default 2). keyword-only."
|
||||||
|
- name: alpha_threshold
|
||||||
|
desc: "umbral (0..255) para binarizar el alpha en opaco (255) o transparente (0) en la cuantizacion final. Solo aplica si transparent. keyword-only."
|
||||||
|
- name: comfy_python
|
||||||
|
desc: "ruta al interprete de ComfyUI (con la lib pixeloe) para el bridge; None autodetecta COMFY_PYTHON y luego ~/ComfyUI/.venv/bin/python3. keyword-only."
|
||||||
|
output: "dict con ok (bool, True si se produjo el PNG final), out_path (str, ruta del PNG final size x size; vacio si fallo), size (int, lado real del PNG final), colors_final (int, colores distintos en el resultado; en la zona opaca si es RGBA), has_alpha (bool, True si el PNG es RGBA con transparencia), engine_used (str, 'pixeloe' o 'nearest' reflejando el fallback real), autocrop_applied (bool, True si el autocrop recorto/cuadro la imagen), error (str, vacio si todo OK)."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/ml/comfyui_pixelize_sprite_png.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from ml.comfyui_pixelize_sprite_png import comfyui_pixelize_sprite_png
|
||||||
|
|
||||||
|
# Un render existente de 512x768 RGBA con fondo transparente -> sprite pixel-art 32x32
|
||||||
|
res = comfyui_pixelize_sprite_png(
|
||||||
|
os.path.expanduser("~/ComfyUI/output/knight_hi_res.png"),
|
||||||
|
"/tmp/knight_32.png",
|
||||||
|
size=32, colors=16, transparent=True,
|
||||||
|
)
|
||||||
|
# {'ok': True, 'out_path': '/tmp/knight_32.png', 'size': 32, 'colors_final': 16,
|
||||||
|
# 'has_alpha': True, 'engine_used': 'pixeloe', 'autocrop_applied': True, 'error': ''}
|
||||||
|
|
||||||
|
# Pixelizar cada frame de una animacion a 48px con paleta fija PICO-8
|
||||||
|
for i, frame in enumerate(["walk_0.png", "walk_1.png", "walk_2.png", "walk_3.png"]):
|
||||||
|
comfyui_pixelize_sprite_png(
|
||||||
|
f"/tmp/anim/{frame}", f"/tmp/anim/px_{i}.png",
|
||||||
|
size=48, palette="pico-8", transparent=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Un tile/textura sin silueta -> downscale nearest barato, sin transparencia
|
||||||
|
comfyui_pixelize_sprite_png(
|
||||||
|
"/tmp/grass_tile.png", "/tmp/grass_16.png",
|
||||||
|
size=16, colors=8, engine="nearest", transparent=False,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando ya tienes un PNG renderizado a alta resolucion y necesitas su version
|
||||||
|
pixel-art REAL (grid duro + paleta limitada) **sin regenerar** con la difusion: cada
|
||||||
|
frame de una animacion, una hoja de sprites entera, un render externo, o el resultado
|
||||||
|
de cualquier otra funcion que produzca PNGs. Es la pieza desacoplada del pixelizado
|
||||||
|
que `comfyui_pixelart_real_oneshot` usa por dentro tras generar — usala directamente
|
||||||
|
cuando la generacion no es parte del trabajo. Usa `engine="pixeloe"` para sujetos con
|
||||||
|
silueta (personajes, criaturas, iconos con contorno) y `engine="nearest"` para
|
||||||
|
tiles/texturas/fondos planos sin contorno (mas barato). Para llevar el resultado a
|
||||||
|
Godot con filtro Nearest, encadena con `comfyui_export_asset_to_godot`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Necesita la lib `pixeloe`** (en `~/ComfyUI/.venv`) para `engine="pixeloe"`: se
|
||||||
|
invoca via bridge de subprocess (`pixeloe_downscale`). Si la lib no esta o falla,
|
||||||
|
cae automaticamente a `engine="nearest"` y lo refleja en `engine_used` + deja la
|
||||||
|
nota del fallo en `error` (el resultado sigue siendo valido). Pasa `comfy_python`
|
||||||
|
para apuntar a otro interprete con pixeloe.
|
||||||
|
- **Todo error es dict `ok=False`** (no excepcion): `src_path` inexistente, `size < 1`,
|
||||||
|
`engine` distinto de pixeloe/nearest -> `error` lo explica. No crashea ni borra nada.
|
||||||
|
- **`autocrop` es best-effort**: si el recorte falla (PIL/lectura), se sigue con el PNG
|
||||||
|
original sin recortar, `autocrop_applied=False` y la nota va en `error` (no critico).
|
||||||
|
`crop_to_content` cuadra el sujeto para que llene el frame — sin esto un sujeto que
|
||||||
|
ocupa el 25% del lienzo queda diminuto a 32px.
|
||||||
|
- **`transparent` espera entrada RGBA**: con `transparent=True` el alpha se preserva y
|
||||||
|
el fondo transparente NO entra en la paleta. PixelOE trabaja en RGB y perderia el
|
||||||
|
alpha, asi que se downscalea el canal alpha aparte (nearest) y se reaplica al grid
|
||||||
|
antes de cuantizar (fase 2a-bis). Con `transparent=False` la salida es RGB opaca.
|
||||||
|
- **`palette` fija (pico-8/nes/game-boy o lista de hex) ignora `colors`**. `colors_final`
|
||||||
|
cuenta colores RGB distintos REALES de la zona opaca: puede ser **menor** que `colors`
|
||||||
|
o que el tamano de la paleta si el sprite no usa todos (un sprite de un solo color
|
||||||
|
solido devuelve `colors_final=1`, correcto).
|
||||||
|
- **CPU-only en la cuantizacion**; el unico coste GPU/red es nulo (PixelOE es CPU via
|
||||||
|
bridge). Los intermedios (crop, mid) se escriben en un directorio temporal y se
|
||||||
|
limpian siempre, incluso si la cuantizacion falla.
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""comfyui_pixelize_sprite_png — pixeliza un PNG existente a un sprite pixel-art REAL.
|
||||||
|
|
||||||
|
Toma un PNG YA renderizado a alta resolucion (p.ej. 512x768 RGBA con fondo
|
||||||
|
transparente) y lo convierte en un sprite pixel-art de verdad de `size` x `size`.
|
||||||
|
Es la pieza reutilizable que extrae la logica de pixelizado de un PNG existente: la
|
||||||
|
MISMA secuencia que `comfyui_pixelart_real_oneshot` aplica internamente en sus fases
|
||||||
|
1b/2a/2a-bis/2b, pero desacoplada de la generacion. Sirve para pixelizar cada frame
|
||||||
|
de una animacion, una hoja de sprites, o cualquier render existente sin volver a
|
||||||
|
pasar por la difusion.
|
||||||
|
|
||||||
|
El metodo (report 0215) tiene tres etapas de post-proceso encadenadas:
|
||||||
|
|
||||||
|
1. crop al contenido (`crop_to_content`): recorta al bounding box del sujeto y lo
|
||||||
|
cuadra para que llene el frame; si el sujeto ocupa el 25% del lienzo, a 32px
|
||||||
|
quedaria diminuto. Best-effort: si falla, se sigue con el PNG original.
|
||||||
|
2. downscale a un grid `size` x `size`:
|
||||||
|
- engine="pixeloe": downscale contrast-aware (`pixeloe_downscale`, no_upscale)
|
||||||
|
que conserva la silueta — para sujetos con contorno (personajes, iconos).
|
||||||
|
Si la lib no esta o falla, cae automaticamente a "nearest".
|
||||||
|
- engine="nearest": downscale nearest simple (PIL) — mas barato, para
|
||||||
|
tiles/texturas sin contorno.
|
||||||
|
PixelOE trabaja en RGB y pierde el alpha, asi que tras el (fase 2a-bis) se
|
||||||
|
downscalea el canal alpha por separado (nearest) y se reaplica al grid.
|
||||||
|
3. cuantizacion dura (`comfyui_pixelize_image`, downscale=1): clava la paleta
|
||||||
|
exacta (N colores libres MEDIANCUT, o paleta fija pico-8 / nes / game-boy) sobre
|
||||||
|
el grid ya hecho -> N colores + 100% grid duro, preservando el alpha.
|
||||||
|
|
||||||
|
Compone funciones del registry, no reescribe su logica:
|
||||||
|
crop_to_content_py_ml (autocrop al contenido + cuadrar; pura)
|
||||||
|
pixeloe_downscale_py_ml (downscale contrast-aware, engine pixeloe)
|
||||||
|
comfyui_pixelize_image_py_ml (cuantizacion dura + alpha-aware)
|
||||||
|
|
||||||
|
Funcion impura: lectura/escritura de disco (+ subprocess del bridge de pixeloe).
|
||||||
|
No-throw: cualquier fallo se captura y viaja en el campo `error` del dict.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# Importa las funciones del registry (mismo arbol python/functions).
|
||||||
|
_FUNCTIONS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if _FUNCTIONS_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _FUNCTIONS_ROOT)
|
||||||
|
|
||||||
|
from ml.comfyui_pixelize_image import comfyui_pixelize_image
|
||||||
|
from ml.crop_to_content import crop_to_content
|
||||||
|
from ml.pixeloe_downscale import pixeloe_downscale
|
||||||
|
|
||||||
|
|
||||||
|
def comfyui_pixelize_sprite_png(
|
||||||
|
src_path: str,
|
||||||
|
dst_path: str,
|
||||||
|
*,
|
||||||
|
size: int = 32,
|
||||||
|
colors: int = 16,
|
||||||
|
engine: str = "pixeloe",
|
||||||
|
palette=None,
|
||||||
|
transparent: bool = True,
|
||||||
|
autocrop: bool = True,
|
||||||
|
crop_pad_ratio: float = 0.02,
|
||||||
|
mode: str = "contrast",
|
||||||
|
patch_size: int = 16,
|
||||||
|
thickness: int = 2,
|
||||||
|
alpha_threshold: int = 128,
|
||||||
|
comfy_python: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Pixeliza un PNG existente a un sprite pixel-art REAL de `size` x `size`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
src_path: ruta del PNG de entrada (un render a alta resolucion, p.ej.
|
||||||
|
512x768 RGBA con fondo transparente). Debe existir.
|
||||||
|
dst_path: ruta del PNG de salida size x size (se crea el directorio si falta).
|
||||||
|
size: lado del grid final en pixeles (32 iconos/objetos simples, 64
|
||||||
|
personajes/sprites). Debe ser >= 1. keyword-only.
|
||||||
|
colors: numero de colores de la paleta libre cuando palette es None
|
||||||
|
(cuantizacion MEDIANCUT). keyword-only.
|
||||||
|
engine: "pixeloe" (downscale contrast-aware, para sujetos con silueta:
|
||||||
|
personajes/criaturas/iconos) o "nearest" (downscale nearest simple, mas
|
||||||
|
barato, para tiles/texturas/fondos sin contorno). Si "pixeloe" falla o la
|
||||||
|
lib no esta disponible, cae automaticamente a "nearest" y lo refleja en
|
||||||
|
engine_used. keyword-only.
|
||||||
|
palette: None (paleta libre a `colors`), nombre builtin ("pico-8", "nes",
|
||||||
|
"game-boy") o lista de hex. Una paleta fija ignora `colors`. keyword-only.
|
||||||
|
transparent: si True (default) trata la entrada como RGBA y produce un sprite
|
||||||
|
RGBA con transparencia real (el fondo transparente no entra en la paleta).
|
||||||
|
Para tiles/texturas opacas, False produce salida RGB. keyword-only.
|
||||||
|
autocrop: si True (default) recorta el PNG al bounding box de su contenido y lo
|
||||||
|
cuadra antes del downscale, para que el sujeto llene el frame (evita el
|
||||||
|
sprite diminuto). Usa el alpha si transparent, o el color de fondo si RGB.
|
||||||
|
keyword-only.
|
||||||
|
crop_pad_ratio: margen relativo que deja el autocrop alrededor del sujeto
|
||||||
|
(0.02 = 2% del lado). keyword-only.
|
||||||
|
mode: modo de downscale de PixelOE ("contrast" SOTA, "k-centroid", "nearest",
|
||||||
|
"center", "bicubic"); solo aplica con engine="pixeloe". keyword-only.
|
||||||
|
patch_size: tamano de patch de PixelOE (default 16). keyword-only.
|
||||||
|
thickness: grosor del outline expansion de PixelOE (default 2). keyword-only.
|
||||||
|
alpha_threshold: umbral (0..255) para binarizar el alpha en opaco (255) o
|
||||||
|
transparente (0) en la cuantizacion final. Solo aplica si transparent.
|
||||||
|
keyword-only.
|
||||||
|
comfy_python: ruta al interprete de ComfyUI (con la lib pixeloe) para el
|
||||||
|
bridge; None autodetecta COMFY_PYTHON y luego ~/ComfyUI/.venv/bin/python3.
|
||||||
|
keyword-only.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con:
|
||||||
|
- ok (bool): True si se produjo el PNG final pixelizado.
|
||||||
|
- out_path (str): ruta del PNG final size x size (vacio si fallo).
|
||||||
|
- size (int): lado real del PNG final.
|
||||||
|
- colors_final (int): numero de colores distintos en el resultado (en la zona
|
||||||
|
opaca si es RGBA).
|
||||||
|
- has_alpha (bool): True si el PNG final es RGBA con transparencia.
|
||||||
|
- engine_used (str): "pixeloe" o "nearest" (refleja el fallback real).
|
||||||
|
- autocrop_applied (bool): True si el autocrop recorto/cuadro la imagen.
|
||||||
|
- error (str): mensaje de error; cadena vacia si todo OK.
|
||||||
|
"""
|
||||||
|
out = {
|
||||||
|
"ok": False,
|
||||||
|
"out_path": "",
|
||||||
|
"size": int(size),
|
||||||
|
"colors_final": 0,
|
||||||
|
"has_alpha": False,
|
||||||
|
"engine_used": engine,
|
||||||
|
"autocrop_applied": False,
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Validaciones (no-throw). ---
|
||||||
|
if not src_path or not os.path.isfile(src_path):
|
||||||
|
out["error"] = f"src_path no existe: {src_path!r}"
|
||||||
|
return out
|
||||||
|
if int(size) < 1:
|
||||||
|
out["error"] = f"size debe ser >= 1, recibido {size!r}"
|
||||||
|
return out
|
||||||
|
if engine not in ("pixeloe", "nearest"):
|
||||||
|
out["error"] = f"engine invalido: {engine!r} (usa 'pixeloe' o 'nearest')"
|
||||||
|
return out
|
||||||
|
|
||||||
|
# Directorio temporal para los intermedios (crop + mid); se limpia al final.
|
||||||
|
try:
|
||||||
|
tmp_dir = tempfile.mkdtemp(prefix="pixelize_sprite_")
|
||||||
|
except OSError as exc:
|
||||||
|
out["error"] = f"no se pudo crear directorio temporal: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
crop_path = os.path.join(tmp_dir, "crop.png")
|
||||||
|
mid_path = os.path.join(tmp_dir, "mid.png")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# --- Fase 1b (opcional): autocrop al contenido + cuadrar. ---
|
||||||
|
# La imagen sobre la que se hace el downscale: la recortada si autocrop, o la
|
||||||
|
# original sin tocar.
|
||||||
|
pre_ds_path = src_path
|
||||||
|
if autocrop:
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(src_path) as base_im:
|
||||||
|
src_im = (
|
||||||
|
base_im.convert("RGBA") if transparent else base_im.convert("RGB")
|
||||||
|
)
|
||||||
|
before = src_im.size
|
||||||
|
cropped = crop_to_content(
|
||||||
|
src_im, pad_ratio=float(crop_pad_ratio), square=True,
|
||||||
|
)
|
||||||
|
cropped.save(crop_path)
|
||||||
|
pre_ds_path = crop_path
|
||||||
|
out["autocrop_applied"] = cropped.size != before
|
||||||
|
except (ImportError, OSError, ValueError) as exc:
|
||||||
|
# Autocrop es best-effort: si falla, se sigue con el src sin recortar.
|
||||||
|
pre_ds_path = src_path
|
||||||
|
out["autocrop_applied"] = False
|
||||||
|
if not out["error"]:
|
||||||
|
out["error"] = f"autocrop fallo (no critico): {exc}"
|
||||||
|
|
||||||
|
# --- Fase 2a: downscale a un grid `size` x `size` (mid). ---
|
||||||
|
engine_used = engine
|
||||||
|
if engine == "pixeloe":
|
||||||
|
ds = pixeloe_downscale(
|
||||||
|
pre_ds_path, mid_path, mode=mode, target_size=int(size),
|
||||||
|
patch_size=int(patch_size), thickness=int(thickness),
|
||||||
|
no_upscale=True, comfy_python=comfy_python,
|
||||||
|
)
|
||||||
|
if not ds.get("ok"):
|
||||||
|
# Fallback limpio: PixelOE no disponible / fallo -> nearest.
|
||||||
|
engine_used = "nearest"
|
||||||
|
out["error"] = f"pixeloe fallo ({ds.get('error')}); fallback a nearest"
|
||||||
|
|
||||||
|
if engine_used == "nearest":
|
||||||
|
# Downscale nearest simple a size x size (PIL del venv del registry).
|
||||||
|
# nearest preserva el alpha por canal: si transparent, conserva la silueta.
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(pre_ds_path) as src:
|
||||||
|
target_mode = "RGBA" if transparent else "RGB"
|
||||||
|
small = src.convert(target_mode).resize(
|
||||||
|
(int(size), int(size)), Image.NEAREST
|
||||||
|
)
|
||||||
|
small.save(mid_path)
|
||||||
|
except (ImportError, OSError) as exc:
|
||||||
|
out["error"] = f"downscale nearest fallo: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
if not os.path.isfile(mid_path):
|
||||||
|
out["error"] = "no se genero la imagen intermedia (mid)"
|
||||||
|
return out
|
||||||
|
|
||||||
|
# --- Fase 2a-bis: recombinar alpha tras pixeloe (PixelOE trabaja en RGB). ---
|
||||||
|
# El nucleo de PixelOE convierte a RGB: el grid `mid` sale sin transparencia.
|
||||||
|
# Se downscalea el alpha de la imagen pre-downscale por separado (nearest al
|
||||||
|
# mismo size) y se reaplica al grid para no perder el recorte ni la
|
||||||
|
# transparencia. (engine="nearest" ya conserva su alpha, no hace falta.)
|
||||||
|
if transparent and engine_used == "pixeloe":
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(pre_ds_path) as src_im:
|
||||||
|
alpha = src_im.convert("RGBA").getchannel("A").resize(
|
||||||
|
(int(size), int(size)), Image.NEAREST
|
||||||
|
)
|
||||||
|
with Image.open(mid_path) as mid_im:
|
||||||
|
mid_rgba = mid_im.convert("RGBA")
|
||||||
|
mid_rgba.putalpha(alpha)
|
||||||
|
mid_rgba.save(mid_path)
|
||||||
|
except (ImportError, OSError) as exc:
|
||||||
|
if not out["error"]:
|
||||||
|
out["error"] = f"recombinacion de alpha fallo (no critico): {exc}"
|
||||||
|
|
||||||
|
# --- Fase 2b: cuantizacion dura (paleta exacta) sobre el grid ya hecho. ---
|
||||||
|
quant = comfyui_pixelize_image(
|
||||||
|
mid_path, dst_path, downscale=1, colors=int(colors), palette=palette,
|
||||||
|
upscale_back=False, keep_alpha=bool(transparent),
|
||||||
|
alpha_threshold=int(alpha_threshold),
|
||||||
|
)
|
||||||
|
if not quant.get("ok"):
|
||||||
|
out["error"] = f"cuantizacion fallo: {quant.get('error')}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
out["out_path"] = dst_path
|
||||||
|
out["size"] = quant["size"][0] if quant.get("size") else int(size)
|
||||||
|
out["colors_final"] = quant.get("n_colors_final", 0)
|
||||||
|
out["has_alpha"] = bool(quant.get("has_alpha", False))
|
||||||
|
out["engine_used"] = engine_used
|
||||||
|
out["ok"] = True
|
||||||
|
return out
|
||||||
|
finally:
|
||||||
|
# Limpieza de intermedios + directorio temporal.
|
||||||
|
for tmp in (crop_path, mid_path):
|
||||||
|
try:
|
||||||
|
os.remove(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
os.rmdir(tmp_dir)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Demo autosuficiente: genera un PNG de prueba (circulo de color sobre fondo
|
||||||
|
# transparente 512x512) y lo pixeliza a 32x32 con 16 colores y transparencia.
|
||||||
|
demo_dir = tempfile.mkdtemp(prefix="pixelize_sprite_demo_")
|
||||||
|
demo_src = os.path.join(demo_dir, "demo_src.png")
|
||||||
|
demo_dst = os.path.join(demo_dir, "demo_sprite.png")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
im = Image.new("RGBA", (512, 512), (0, 0, 0, 0)) # fondo transparente
|
||||||
|
draw = ImageDraw.Draw(im)
|
||||||
|
draw.ellipse((96, 96, 416, 416), fill=(220, 60, 60, 255)) # circulo rojo
|
||||||
|
draw.ellipse((176, 160, 256, 240), fill=(250, 230, 120, 255)) # ojo amarillo
|
||||||
|
draw.ellipse((280, 160, 360, 240), fill=(60, 120, 220, 255)) # ojo azul
|
||||||
|
im.save(demo_src)
|
||||||
|
except ImportError as exc:
|
||||||
|
print(json.dumps({"ok": False, "error": f"PIL no disponible: {exc}"}))
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
res = comfyui_pixelize_sprite_png(
|
||||||
|
demo_src, demo_dst, size=32, colors=16, engine="pixeloe",
|
||||||
|
transparent=True, autocrop=True,
|
||||||
|
)
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
@@ -77,7 +77,7 @@ def _content_bbox(img, alpha_threshold: int, bg_tolerance: int):
|
|||||||
def crop_to_content(
|
def crop_to_content(
|
||||||
img,
|
img,
|
||||||
*,
|
*,
|
||||||
pad_ratio: float = 0.06,
|
pad_ratio: float = 0.02,
|
||||||
square: bool = True,
|
square: bool = True,
|
||||||
alpha_threshold: int = 10,
|
alpha_threshold: int = 10,
|
||||||
bg_tolerance: int = 16,
|
bg_tolerance: int = 16,
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
name: render_openpose_walk_skeletons
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: ml
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def render_openpose_walk_skeletons(out_dir: str, *, frames: int = 4, width: int = 512, height: int = 768, facing: str = 'right', line_width: int = 4, point_radius: int = 6, filename_prefix: str = 'walk_pose') -> dict"
|
||||||
|
description: "Dibuja con PIL una secuencia de N esqueletos OpenPose COCO-18 (18 keypoints, 17 limbs, colores canonicos) de un ciclo de caminar lateral, una fase del paso por frame, sobre fondo negro, y los guarda como PNG. Son la ENTRADA fija del ControlNet OpenPose (control_v11p_sd15_openpose_fp16) para animar un personaje frame-by-frame: el esqueleto NO lo genera la IA, lo aportas dibujado. Para frames=4 produce las 4 fases canonicas (contact-izq, passing, contact-der, passing); para mas frames muestrea el ciclo parametrico continuo. Piernas en oposicion a los brazos + rebote vertical del cuerpo (walk cycle de manual de animacion). facing='right'|'left' espeja en X. Impura: escribe N PNGs. Devuelve {ok, skeleton_paths, frames, width, height, error}."
|
||||||
|
tags: [gamedev-2d, comfyui, controlnet, openpose, pose, animation]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: out_dir
|
||||||
|
desc: "directorio destino de los PNG; se crea si no existe. None lanza ValueError (unico caso que lanza)."
|
||||||
|
- name: frames
|
||||||
|
desc: "numero de fases del ciclo a renderizar (default 4 = las 4 fases canonicas contact/passing/contact/passing); con mas frames se muestrea el ciclo parametrico de forma continua interpolando las fases intermedias down/up. keyword-only."
|
||||||
|
- name: width
|
||||||
|
desc: "ancho en pixeles de cada PNG (default 512, el tamaño nativo de SD1.5). keyword-only."
|
||||||
|
- name: height
|
||||||
|
desc: "alto en pixeles de cada PNG (default 768, retrato para personaje de cuerpo entero). keyword-only."
|
||||||
|
- name: facing
|
||||||
|
desc: "'right' (el personaje mira a +x) o 'left' (espeja el esqueleto en X). Cualquier otro valor devuelve ok=False con error. keyword-only."
|
||||||
|
- name: line_width
|
||||||
|
desc: "grosor en pixeles de las lineas de los limbs (default 4). keyword-only."
|
||||||
|
- name: point_radius
|
||||||
|
desc: "radio en pixeles de los circulos rellenos de cada keypoint (default 6). keyword-only."
|
||||||
|
- name: filename_prefix
|
||||||
|
desc: "prefijo de los archivos; se nombran '<prefix>_<NN>.png' con NN de dos digitos en orden de fase (default 'walk_pose'). keyword-only."
|
||||||
|
output: "dict con ok (bool, True si todos los PNG se generaron), skeleton_paths (list[str], rutas de los PNG en orden de fase), frames (int, frames generados), width (int), height (int), error (str, vacio si OK)."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/ml/render_openpose_walk_skeletons.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from ml.render_openpose_walk_skeletons import render_openpose_walk_skeletons
|
||||||
|
|
||||||
|
res = render_openpose_walk_skeletons("/tmp/walk_skeletons_demo", frames=4)
|
||||||
|
# {'ok': True,
|
||||||
|
# 'skeleton_paths': ['/tmp/walk_skeletons_demo/walk_pose_00.png', ..._03.png],
|
||||||
|
# 'frames': 4, 'width': 512, 'height': 768, 'error': ''}
|
||||||
|
|
||||||
|
# 8 fases mirando a la izquierda, lineas/puntos mas finos:
|
||||||
|
res8 = render_openpose_walk_skeletons(
|
||||||
|
"/tmp/walk_poses_left", frames=8, facing="left",
|
||||||
|
line_width=3, point_radius=5,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Los PNG resultantes se conectan luego con `comfyui_build_controlnet_workflow`
|
||||||
|
(uno por frame, `control_net_name="control_v11p_sd15_openpose_fp16.safetensors"`)
|
||||||
|
para generar el personaje animado fotograma a fotograma.
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Usala cuando vayas a animar un sprite/personaje 2D con ComfyUI + ControlNet
|
||||||
|
OpenPose y necesites el insumo que la IA NO genera: la pose-map del esqueleto.
|
||||||
|
Llamala ANTES de montar el workflow ControlNet — produce las N pose-maps del
|
||||||
|
walk cycle (el caso mas comun de animacion de personaje) que el modelo seguira
|
||||||
|
frame a frame. Tambien sirve como base para otras acciones ciclicas si ajustas
|
||||||
|
las fases. Si necesitas una pose suelta (idle, ataque) en vez de un ciclo,
|
||||||
|
extrae el patron a una funcion hermana — esta es especifica de caminar.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Escribe N PNGs en disco (impura): si `out_dir` no es escribible devuelve
|
||||||
|
`ok=False` con el error; si `out_dir` es `None` lanza `ValueError` (unico caso
|
||||||
|
que lanza — el resto de fallos se capturan en `error`).
|
||||||
|
- El orden de los 18 keypoints es COCO-18 EXACTO (0 nose ... 17 left_ear) y los
|
||||||
|
colores son los canonicos de OpenPose/controlnet_aux. NO cambies el orden ni la
|
||||||
|
paleta: el preprocesador/ControlNet identifica las articulaciones por color y
|
||||||
|
posicion; alterarlos degrada o rompe el guiado de pose.
|
||||||
|
- Es un esqueleto sintetico parametrico, no una captura real: las proporciones
|
||||||
|
son humanas estandar y la vista es estrictamente lateral. Para vistas 3/4 o
|
||||||
|
proporciones no humanas (chibi, criaturas) habria que reparametrizar.
|
||||||
|
- Fondo NEGRO solido (RGB 0,0,0) por diseño — es lo que el ControlNet OpenPose
|
||||||
|
espera como lienzo. No lo compongas sobre otra imagen.
|
||||||
|
- `frames=4` da exactamente las 4 fases canonicas; valores que no dividan bien el
|
||||||
|
ciclo (p.ej. 3, 5) siguen muestreando t=i/frames de forma uniforme y producen
|
||||||
|
fases validas pero no necesariamente las "de manual". Para animacion fluida usa
|
||||||
|
multiplos de 4 (8, 12, 16).
|
||||||
|
- Necesita Pillow (PIL); si no esta instalado devuelve `ok=False` con error en vez
|
||||||
|
de lanzar.
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""Dibuja una secuencia de esqueletos OpenPose (COCO-18) de un ciclo de caminar.
|
||||||
|
|
||||||
|
Funcion impura: rasteriza con PIL (Pillow) N frames de un walk cycle lateral y
|
||||||
|
escribe cada uno como PNG sobre fondo negro. Cada PNG es una pose-map valida para
|
||||||
|
el ControlNet OpenPose de SD1.5 (control_v11p_sd15_openpose_fp16): el esqueleto NO
|
||||||
|
lo genera la IA, lo aportas tu dibujado y el modelo anima el personaje sobre el.
|
||||||
|
|
||||||
|
El formato es el render clasico de OpenPose: 18 keypoints (COCO-18), 17 limbs,
|
||||||
|
colores canonicos por articulacion/par. Las piernas oscilan en oposicion a los
|
||||||
|
brazos y el cuerpo "bota" (sube en passing, baja en contact) — un walk cycle de
|
||||||
|
manual de animacion. Para frames=4 produce las 4 fases canonicas
|
||||||
|
(contact-izq, passing, contact-der, passing); para mas frames muestrea el ciclo
|
||||||
|
parametrico de forma continua (interpolando las fases intermedias down/up).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
# Orden de indice COCO-18 que espera el ControlNet OpenPose.
|
||||||
|
COCO18_NAMES = [
|
||||||
|
"nose", "neck", "right_shoulder", "right_elbow", "right_wrist",
|
||||||
|
"left_shoulder", "left_elbow", "left_wrist", "right_hip", "right_knee",
|
||||||
|
"right_ankle", "left_hip", "left_knee", "left_ankle",
|
||||||
|
"right_eye", "left_eye", "right_ear", "left_ear",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Pares (limbs) — formato OpenPose clasico (17 limbs sobre 18 keypoints).
|
||||||
|
LIMB_SEQ = [
|
||||||
|
(1, 2), (1, 5), (2, 3), (3, 4), (5, 6), (6, 7), (1, 8), (8, 9), (9, 10),
|
||||||
|
(1, 11), (11, 12), (12, 13), (1, 0), (0, 14), (14, 16), (0, 15), (15, 17),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Colores canonicos (RGB) por indice, identicos a los del render original de
|
||||||
|
# OpenPose / controlnet_aux. El keypoint i usa COLORS[i]; el limb i usa COLORS[i].
|
||||||
|
COLORS = [
|
||||||
|
[255, 0, 0], [255, 85, 0], [255, 170, 0], [255, 255, 0], [170, 255, 0],
|
||||||
|
[85, 255, 0], [0, 255, 0], [0, 255, 85], [0, 255, 170], [0, 255, 255],
|
||||||
|
[0, 170, 255], [0, 85, 255], [0, 0, 255], [85, 0, 255], [170, 0, 255],
|
||||||
|
[255, 0, 255], [255, 0, 170], [255, 0, 85],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_pose_norm(t: float) -> dict:
|
||||||
|
"""Devuelve los 18 keypoints normalizados (0..1, y hacia abajo) de un walk
|
||||||
|
cycle lateral mirando a la derecha, para la fase t in [0, 1).
|
||||||
|
|
||||||
|
t=0.0 -> contact (pierna izq adelante), t=0.25 -> passing (cuerpo arriba),
|
||||||
|
t=0.5 -> contact (pierna der adelante), t=0.75 -> passing.
|
||||||
|
"""
|
||||||
|
two_pi = 2.0 * math.pi
|
||||||
|
cx = 0.50
|
||||||
|
neck_y = 0.245
|
||||||
|
sh_y = 0.265
|
||||||
|
hip_y0 = 0.55
|
||||||
|
ground_y = 0.885
|
||||||
|
thigh_drop = 0.165 # caida vertical hip->knee
|
||||||
|
stride = 0.105 # desplazamiento horizontal max del pie respecto al hip
|
||||||
|
bob_amp = 0.025 # rebote vertical del cuerpo
|
||||||
|
lift_amp = 0.05 # cuanto se levanta el pie en swing
|
||||||
|
sh_dx = 0.024 # media separacion horizontal de hombros (profundidad)
|
||||||
|
hip_dx = 0.026 # media separacion horizontal de caderas
|
||||||
|
|
||||||
|
# Rebote: bajo en contact (t=0, .5), alto en passing (t=.25, .75).
|
||||||
|
rise = bob_amp * (1.0 - math.cos(2.0 * two_pi * t)) / 2.0
|
||||||
|
nx = cx
|
||||||
|
ny = neck_y - rise
|
||||||
|
hip_cy = hip_y0 - rise
|
||||||
|
|
||||||
|
pts = {}
|
||||||
|
pts[1] = (nx, ny) # neck
|
||||||
|
pts[0] = (nx + 0.055, ny - 0.105) # nose (mira a la derecha)
|
||||||
|
pts[14] = (nx + 0.045, ny - 0.120) # right_eye
|
||||||
|
pts[15] = (nx + 0.026, ny - 0.120) # left_eye
|
||||||
|
pts[16] = (nx + 0.002, ny - 0.103) # right_ear (atras)
|
||||||
|
pts[17] = (nx - 0.012, ny - 0.097) # left_ear (atras)
|
||||||
|
pts[2] = (nx - sh_dx, sh_y - rise) # right_shoulder
|
||||||
|
pts[5] = (nx + sh_dx, sh_y - rise) # left_shoulder
|
||||||
|
|
||||||
|
r_hip = (cx - hip_dx, hip_cy)
|
||||||
|
l_hip = (cx + hip_dx, hip_cy)
|
||||||
|
pts[8] = r_hip
|
||||||
|
pts[11] = l_hip
|
||||||
|
|
||||||
|
# Factores de oscilacion de las piernas (opuestas entre si).
|
||||||
|
fwd_l = math.cos(two_pi * t) # pierna izq adelante en t=0
|
||||||
|
fwd_r = -fwd_l # pierna der opuesta
|
||||||
|
lift_l = lift_amp * max(0.0, -math.sin(two_pi * t)) # izq levanta t in (.5,1)
|
||||||
|
lift_r = lift_amp * max(0.0, math.sin(two_pi * t)) # der levanta t in (0,.5)
|
||||||
|
|
||||||
|
def _leg(hip, fwd, lift):
|
||||||
|
hx, hy = hip
|
||||||
|
ax = hx + stride * fwd
|
||||||
|
ay = ground_y - lift
|
||||||
|
bend = 0.012 + 0.06 * (lift / lift_amp if lift_amp else 0.0)
|
||||||
|
kx = (hx + ax) / 2.0 + bend # rodilla apunta hacia delante
|
||||||
|
ky = hy + thigh_drop
|
||||||
|
return (kx, ky), (ax, ay)
|
||||||
|
|
||||||
|
rk, ra = _leg(r_hip, fwd_r, lift_r)
|
||||||
|
lk, la = _leg(l_hip, fwd_l, lift_l)
|
||||||
|
pts[9], pts[10] = rk, ra # right_knee, right_ankle
|
||||||
|
pts[12], pts[13] = lk, la # left_knee, left_ankle
|
||||||
|
|
||||||
|
# Brazos en oposicion: brazo der adelante cuando pierna izq adelante.
|
||||||
|
arm_fwd_r = fwd_l
|
||||||
|
arm_fwd_l = fwd_r
|
||||||
|
sh_to_el = 0.105
|
||||||
|
el_to_wr = 0.110
|
||||||
|
arm_sw = 0.05
|
||||||
|
|
||||||
|
def _arm(sh, fwd):
|
||||||
|
sx, sy = sh
|
||||||
|
ex = sx + arm_sw * fwd + 0.008
|
||||||
|
ey = sy + sh_to_el
|
||||||
|
wx = sx + arm_sw * 1.7 * fwd + 0.016
|
||||||
|
wy = ey + el_to_wr
|
||||||
|
return (ex, ey), (wx, wy)
|
||||||
|
|
||||||
|
re, rw = _arm(pts[2], arm_fwd_r)
|
||||||
|
le, lw = _arm(pts[5], arm_fwd_l)
|
||||||
|
pts[3], pts[4] = re, rw # right_elbow, right_wrist
|
||||||
|
pts[6], pts[7] = le, lw # left_elbow, left_wrist
|
||||||
|
|
||||||
|
return pts
|
||||||
|
|
||||||
|
|
||||||
|
def render_openpose_walk_skeletons(
|
||||||
|
out_dir: str,
|
||||||
|
*,
|
||||||
|
frames: int = 4,
|
||||||
|
width: int = 512,
|
||||||
|
height: int = 768,
|
||||||
|
facing: str = "right",
|
||||||
|
line_width: int = 4,
|
||||||
|
point_radius: int = 6,
|
||||||
|
filename_prefix: str = "walk_pose",
|
||||||
|
) -> dict:
|
||||||
|
"""Dibuja N esqueletos OpenPose COCO-18 de un walk cycle y los guarda como PNG.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
out_dir: directorio destino de los PNG; se crea si no existe.
|
||||||
|
frames: numero de fases del ciclo a renderizar (default 4 = las 4
|
||||||
|
fases canonicas contact/passing/contact/passing). keyword-only.
|
||||||
|
width: ancho de cada PNG en pixeles (default 512). keyword-only.
|
||||||
|
height: alto de cada PNG en pixeles (default 768, retrato). keyword-only.
|
||||||
|
facing: "right" (mira a +x) o "left" (espeja en X). keyword-only.
|
||||||
|
line_width: grosor en px de las lineas de los limbs (default 4).
|
||||||
|
keyword-only.
|
||||||
|
point_radius: radio en px de los circulos de cada keypoint (default 6).
|
||||||
|
keyword-only.
|
||||||
|
filename_prefix: prefijo de los archivos; se nombran
|
||||||
|
"<prefix>_<NN>.png" con NN de dos digitos. keyword-only.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con:
|
||||||
|
- ok (bool): True si todos los PNG se generaron.
|
||||||
|
- skeleton_paths (list[str]): rutas de los PNG creados, en orden de fase.
|
||||||
|
- frames (int): numero de frames generados.
|
||||||
|
- width (int): ancho usado.
|
||||||
|
- height (int): alto usado.
|
||||||
|
- error (str): mensaje de error; cadena vacia si todo OK.
|
||||||
|
|
||||||
|
No lanza salvo que out_dir sea None: cualquier otro fallo se captura en
|
||||||
|
el campo "error" con ok=False.
|
||||||
|
"""
|
||||||
|
if out_dir is None:
|
||||||
|
raise ValueError("out_dir no puede ser None")
|
||||||
|
|
||||||
|
out = {
|
||||||
|
"ok": False, "skeleton_paths": [], "frames": int(frames or 0),
|
||||||
|
"width": int(width or 0), "height": int(height or 0), "error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
except ImportError:
|
||||||
|
out["error"] = "PIL (Pillow) no esta instalado en este interprete"
|
||||||
|
return out
|
||||||
|
|
||||||
|
try:
|
||||||
|
frames = int(frames)
|
||||||
|
width = int(width)
|
||||||
|
height = int(height)
|
||||||
|
line_width = max(1, int(line_width))
|
||||||
|
point_radius = max(1, int(point_radius))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
out["error"] = f"argumento numerico invalido: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
if frames <= 0:
|
||||||
|
out["error"] = "frames debe ser >= 1"
|
||||||
|
return out
|
||||||
|
if width < 16 or height < 16:
|
||||||
|
out["error"] = "width y height deben ser >= 16"
|
||||||
|
return out
|
||||||
|
if facing not in ("right", "left"):
|
||||||
|
out["error"] = f"facing debe ser 'right' o 'left', no {facing!r}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
out["frames"] = frames
|
||||||
|
out["width"] = width
|
||||||
|
out["height"] = height
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
out["error"] = f"no se pudo crear out_dir {out_dir!r}: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
paths = []
|
||||||
|
try:
|
||||||
|
for i in range(frames):
|
||||||
|
t = i / float(frames)
|
||||||
|
norm = _walk_pose_norm(t)
|
||||||
|
|
||||||
|
# Mirror en X para facing izquierda (el esqueleto sigue siendo valido).
|
||||||
|
kp = {}
|
||||||
|
for idx, (x, y) in norm.items():
|
||||||
|
if facing == "left":
|
||||||
|
x = 1.0 - x
|
||||||
|
kp[idx] = (x * width, y * height)
|
||||||
|
|
||||||
|
img = Image.new("RGB", (width, height), (0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Limbs primero (lineas gruesas del color del par).
|
||||||
|
for li, (a, b) in enumerate(LIMB_SEQ):
|
||||||
|
if a in kp and b in kp:
|
||||||
|
col = tuple(COLORS[li % len(COLORS)])
|
||||||
|
draw.line([kp[a], kp[b]], fill=col, width=line_width)
|
||||||
|
|
||||||
|
# Keypoints encima (circulos rellenos del color de la articulacion).
|
||||||
|
for idx in range(len(COCO18_NAMES)):
|
||||||
|
if idx not in kp:
|
||||||
|
continue
|
||||||
|
cx, cy = kp[idx]
|
||||||
|
col = tuple(COLORS[idx % len(COLORS)])
|
||||||
|
draw.ellipse(
|
||||||
|
[cx - point_radius, cy - point_radius,
|
||||||
|
cx + point_radius, cy + point_radius],
|
||||||
|
fill=col,
|
||||||
|
)
|
||||||
|
|
||||||
|
path = os.path.join(out_dir, f"{filename_prefix}_{i:02d}.png")
|
||||||
|
img.save(path)
|
||||||
|
paths.append(path)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
out["error"] = f"fallo al rasterizar/guardar el frame {len(paths)}: {exc}"
|
||||||
|
out["skeleton_paths"] = paths
|
||||||
|
return out
|
||||||
|
|
||||||
|
out["ok"] = True
|
||||||
|
out["skeleton_paths"] = paths
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
res = render_openpose_walk_skeletons("/tmp/walk_skeletons_demo", frames=4)
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
---
|
||||||
|
name: comfyui_walk_cycle_oneshot
|
||||||
|
kind: pipeline
|
||||||
|
lang: py
|
||||||
|
domain: pipelines
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def comfyui_walk_cycle_oneshot(character: str, *, frames: int = 4, size: int = 32, colors: int = 16, fps: int = 8, checkpoint: str = 'IMG_dreamshaper_8.safetensors', ref_image: str | None = None, server: str = '127.0.0.1:8188', dest_dir: str = '~/ComfyUI/output', seed: int = 0, pose_method: str = 'auto', controlnet_strength: float = 0.7, engine: str = 'pixeloe', palette=None, fmt: str = 'webp', **gen_kwargs) -> dict"
|
||||||
|
description: "Pipeline one-shot: de un prompt de personaje a una animacion de walk cycle en pixel-art (sprite sheet + GIF/WEBP en loop). Genera N frames frame-by-frame dirigidos por pose (ControlNet OpenPose con esqueletos del walk cycle, o fase del paso por prompt como fallback), con seed fija para identidad consistente y Rembg para alpha, y pixeliza cada frame a un grid duro size x size RGBA. Materializa el caso 1 de la investigacion de animacion de sprites (report 0217): personaje = frame-by-frame pose-driven, NUNCA modelos de video. Compone render_openpose_walk_skeletons + comfyui_build_sprite_sheet_workflow + submit/wait/fetch + comfyui_pixelize_sprite_png + assemble_animated_sprite. Impuro: red + GPU + disco. No-throw, salta frames que fallan."
|
||||||
|
tags: [gamedev-2d, comfyui, pixelart, sprite, animation, walk-cycle, controlnet, openpose, launcher]
|
||||||
|
uses_functions: ["render_openpose_walk_skeletons_py_ml", "comfyui_build_sprite_sheet_workflow_py_ml", "comfyui_submit_workflow_py_ml", "comfyui_wait_result_py_ml", "comfyui_fetch_output_image_py_ml", "comfyui_pixelize_sprite_png_py_ml", "assemble_animated_sprite_py_ml"]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: ["os", "sys"]
|
||||||
|
params:
|
||||||
|
- name: character
|
||||||
|
desc: "Prompt del personaje (ej. 'pixel art knight, full body, side view'). No vacio. La identidad se mantiene entre frames con seed fija."
|
||||||
|
- name: frames
|
||||||
|
desc: "Numero de frames del ciclo (>=2, recomendado 4-8). 4 = las 4 fases canonicas contact-L / passing / contact-R / passing."
|
||||||
|
- name: size
|
||||||
|
desc: "Lado del grid pixel-art final por frame en pixeles (32 sprites pequenos, 64 personajes con mas detalle)."
|
||||||
|
- name: colors
|
||||||
|
desc: "Numero de colores de la paleta libre por frame (cuantizacion MEDIANCUT) cuando palette es None."
|
||||||
|
- name: fps
|
||||||
|
desc: "Cadencia de la animacion en frames por segundo (duration = 1000/fps ms por frame)."
|
||||||
|
- name: checkpoint
|
||||||
|
desc: "Checkpoint SD1.5 (ControlNet OpenPose + IPAdapter-FaceID solo instalados en SD1.5; default 'IMG_dreamshaper_8.safetensors')."
|
||||||
|
- name: ref_image
|
||||||
|
desc: "Imagen de cara de referencia en el input/ del servidor para IPAdapter-FaceID (segunda ancla de identidad). None = solo seed + prompt."
|
||||||
|
- name: server
|
||||||
|
desc: "host:port del servidor ComfyUI (sin esquema). Default 127.0.0.1:8188."
|
||||||
|
- name: dest_dir
|
||||||
|
desc: "Directorio donde guardar frames + sprite sheet + animacion (se expande ~)."
|
||||||
|
- name: seed
|
||||||
|
desc: "Semilla FIJA del KSampler para TODOS los frames (identidad estable entre poses)."
|
||||||
|
- name: pose_method
|
||||||
|
desc: "'openpose' (esqueletos OpenPose -> ControlNet, control exacto), 'prompt' (fase del paso descrita en el prompt, sin esqueletos) o 'auto' (intenta openpose, cae a prompt si el render falla)."
|
||||||
|
- name: controlnet_strength
|
||||||
|
desc: "Fuerza del ControlNet OpenPose (0.7 da buen control sin aplastar el estilo). Solo aplica en modo openpose."
|
||||||
|
- name: engine
|
||||||
|
desc: "Motor de downscale del pixelizado: 'pixeloe' (contrast-aware, conserva silueta) o 'nearest'."
|
||||||
|
- name: palette
|
||||||
|
desc: "None (paleta libre a colors), nombre builtin ('pico-8','nes','game-boy') o lista de hex. Fija ignora colors."
|
||||||
|
- name: fmt
|
||||||
|
desc: "Formato de la animacion: 'webp' (recomendado, alpha real) o 'gif' (alpha binario)."
|
||||||
|
output: "dict {ok, frames:[paths], spritesheet_path, animation_path, size, n_frames, seed, pose_method_used, skipped:[idx], error}. ok=True si se produjo la animacion con >=1 frame. n_frames puede ser < frames si alguno fallo (se salta y se sigue)."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/pipelines/comfyui_walk_cycle_oneshot.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from pipelines.comfyui_walk_cycle_oneshot import comfyui_walk_cycle_oneshot
|
||||||
|
|
||||||
|
# Requiere el servidor ComfyUI vivo en 127.0.0.1:8188 (GPU).
|
||||||
|
res = comfyui_walk_cycle_oneshot(
|
||||||
|
"pixel art knight, full body, side view",
|
||||||
|
frames=4, size=32, colors=16, fps=8, seed=42,
|
||||||
|
dest_dir="/tmp/comfy_walk_cycle",
|
||||||
|
)
|
||||||
|
print(res["ok"], res["n_frames"], res["animation_path"], res["pose_method_used"])
|
||||||
|
# -> True 4 /tmp/comfy_walk_cycle/walk_cycle.webp openpose
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando quieras una animacion de un personaje en pixel-art (caminar, correr) lista
|
||||||
|
para un juego 2D, en un solo paso: das el prompt del personaje y recibes el sprite
|
||||||
|
sheet + el GIF/WEBP en loop. Es la promocion a pipeline (issue 0087) de la receta
|
||||||
|
del caso 1 del report 0217 — el camino correcto para sprites limpios de personaje
|
||||||
|
con alpha, frente a AnimateDiff o modelos de video (que ensucian el alpha y no
|
||||||
|
clavan la pose). Para una sola pose estatica usa `comfyui_pixelart_real_oneshot`;
|
||||||
|
para varias vistas direccionales (8-way) usa
|
||||||
|
`comfyui_build_directional_sprite_workflow`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Server vivo + GPU**: requiere ComfyUI en `server` con la GPU libre. El report
|
||||||
|
recomienda `POST /free` antes de cargas pesadas de modelo. Cada frame reusa el
|
||||||
|
mismo checkpoint, asi que el modelo solo se carga una vez.
|
||||||
|
- **Poses OpenPose**: en modo `openpose` los esqueletos se escriben en el `input/`
|
||||||
|
del servidor (asume server local; para un server remoto haria falta subirlos con
|
||||||
|
`POST /upload/image`). Si el ControlNet no produce variacion de piernas
|
||||||
|
reconocible, usa `pose_method="prompt"`: a 32x32 el detalle de pose se simplifica
|
||||||
|
y la fase del paso por prompt + seed fija da un walk reconocible.
|
||||||
|
- **Identidad**: la `seed` es FIJA para todos los frames — esa es la ancla de
|
||||||
|
identidad. Cambiar la seed entre frames rompe la consistencia del personaje.
|
||||||
|
`ref_image` (IPAdapter-FaceID) es una segunda ancla opcional; sobre un sprite de
|
||||||
|
cuerpo entero pequeno aporta sobre todo paleta/ropa (ver report 0217).
|
||||||
|
- **No-throw, salta frames**: si un frame falla (red, GPU, build) se anade a
|
||||||
|
`skipped` y la animacion se monta con los que queden. ok=False solo si NINGUN
|
||||||
|
frame sale.
|
||||||
|
- **Loop suave**: con `frames=4` el ciclo (contact-L, passing, contact-R, passing)
|
||||||
|
ya cierra el bucle — el frame siguiente al ultimo vuelve a la primera fase.
|
||||||
|
- **WEBP vs GIF**: `fmt="webp"` conserva alpha real (lossless); `fmt="gif"` solo
|
||||||
|
tiene alpha binario (1 bit). Para sprites con transparencia, usa WEBP.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.0.0 (2026-06-28) — version inicial. Caso 1 del report 0217 promovido a
|
||||||
|
pipeline one-shot: walk cycle pixel-art con poses OpenPose (o fallback prompt),
|
||||||
|
seed fija para identidad, Rembg para alpha, pixelizado a NxN RGBA, sprite sheet +
|
||||||
|
WEBP/GIF en loop.
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
"""comfyui_walk_cycle_oneshot — personaje andando -> sprite pixel-art animado (GIF/WEBP).
|
||||||
|
|
||||||
|
Pipeline one-shot (issue 0087) que materializa el caso 1 de la investigacion de
|
||||||
|
animacion de sprites (report 0217): un ciclo de caminar de personaje se anima
|
||||||
|
**frame-by-frame dirigido por pose**, NO con modelos de video. Por cada fase del
|
||||||
|
paso se genera el MISMO personaje cambiando solo la pose (ControlNet OpenPose +
|
||||||
|
seed fija + IPAdapter opcional), se recorta el fondo a alpha (Rembg) y se pixeliza
|
||||||
|
a un grid duro de `size` x `size` RGBA. Los N frames se ensamblan en un sprite
|
||||||
|
sheet horizontal + una animacion en loop (WEBP/GIF).
|
||||||
|
|
||||||
|
Dos metodos para las poses, seleccionables con `pose_method`:
|
||||||
|
- "openpose" (preferido): se dibujan N esqueletos OpenPose del walk cycle
|
||||||
|
(render_openpose_walk_skeletons) y se alimentan al ControlNet OpenPose, un
|
||||||
|
frame por pose. El timing del paso es exactamente el dibujado.
|
||||||
|
- "prompt" (fallback): sin esqueletos, la fase del paso se describe en el prompt
|
||||||
|
("left leg forward", "passing pose", ...) con seed fija. A 32x32 el detalle de
|
||||||
|
pose se simplifica, asi que un walk de 4 frames reconocible basta. Se usa
|
||||||
|
cuando el ControlNet no da poses utilizables o el caller lo pide.
|
||||||
|
- "auto" (default): intenta "openpose"; si el render de esqueletos falla, cae a
|
||||||
|
"prompt" y lo refleja en pose_method_used.
|
||||||
|
|
||||||
|
La identidad consistente entre frames se ancla con **seed fija** (mismo personaje,
|
||||||
|
misma semilla) + prompt base fijo, y opcionalmente con `ref_image` (IPAdapter-FaceID
|
||||||
|
en comfyui_build_sprite_sheet_workflow).
|
||||||
|
|
||||||
|
Compone funciones del registry, no reescribe su logica:
|
||||||
|
render_openpose_walk_skeletons_py_ml (esqueletos OpenPose del walk cycle)
|
||||||
|
comfyui_build_sprite_sheet_workflow_py_ml(1 frame: identidad + pose + alpha)
|
||||||
|
comfyui_submit_workflow_py_ml (POST /prompt)
|
||||||
|
comfyui_wait_result_py_ml (poll /history)
|
||||||
|
comfyui_fetch_output_image_py_ml (GET /view -> disco)
|
||||||
|
comfyui_pixelize_sprite_png_py_ml (PNG alta-res -> NxN RGBA pixel-art)
|
||||||
|
assemble_animated_sprite_py_ml (frames -> sprite sheet + WEBP/GIF loop)
|
||||||
|
|
||||||
|
Pipeline impuro: red (HTTP a ComfyUI), GPU (generacion), escritura en disco.
|
||||||
|
No-throw: cualquier fallo se captura y viaja en el dict de estado (campo error).
|
||||||
|
Si un frame concreto falla se salta y la animacion se monta con los que haya.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Importa las funciones del registry (mismo arbol python/functions).
|
||||||
|
_FUNCTIONS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if _FUNCTIONS_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _FUNCTIONS_ROOT)
|
||||||
|
|
||||||
|
from ml.assemble_animated_sprite import assemble_animated_sprite
|
||||||
|
from ml.comfyui_build_sprite_sheet_workflow import comfyui_build_sprite_sheet_workflow
|
||||||
|
from ml.comfyui_fetch_output_image import comfyui_fetch_output_image
|
||||||
|
from ml.comfyui_pixelize_sprite_png import comfyui_pixelize_sprite_png
|
||||||
|
from ml.comfyui_submit_workflow import comfyui_submit_workflow
|
||||||
|
from ml.comfyui_wait_result import comfyui_wait_result
|
||||||
|
from ml.render_openpose_walk_skeletons import render_openpose_walk_skeletons
|
||||||
|
|
||||||
|
# Descriptores de fase del paso para el modo "prompt" (y como apoyo). El ciclo
|
||||||
|
# canonico de 4 fases de un walk lateral: contacto pierna izquierda, paso (piernas
|
||||||
|
# juntas, cuerpo arriba), contacto pierna derecha, paso. Para N != 4 se reparten
|
||||||
|
# ciclicamente sobre estas cuatro.
|
||||||
|
_WALK_PHASES = [
|
||||||
|
"walking, left leg forward, mid stride, dynamic walk pose",
|
||||||
|
"walking, legs together passing position, standing tall",
|
||||||
|
"walking, right leg forward, mid stride, dynamic walk pose",
|
||||||
|
"walking, legs together passing position, standing tall",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _phase_for(index: int, total: int) -> str:
|
||||||
|
"""Devuelve el descriptor de fase del paso para el frame `index` de `total`.
|
||||||
|
|
||||||
|
Mapea el frame al ciclo de 4 fases base de forma uniforme, de modo que el
|
||||||
|
primer y el ultimo frame cierren un loop continuo (el frame `total` volveria a
|
||||||
|
la fase del frame 0).
|
||||||
|
"""
|
||||||
|
pos = (index / max(1, total)) * len(_WALK_PHASES)
|
||||||
|
return _WALK_PHASES[int(pos) % len(_WALK_PHASES)]
|
||||||
|
|
||||||
|
|
||||||
|
def comfyui_walk_cycle_oneshot(
|
||||||
|
character: str,
|
||||||
|
*,
|
||||||
|
frames: int = 4,
|
||||||
|
size: int = 32,
|
||||||
|
colors: int = 16,
|
||||||
|
fps: int = 8,
|
||||||
|
checkpoint: str = "IMG_dreamshaper_8.safetensors",
|
||||||
|
ref_image: str | None = None,
|
||||||
|
server: str = "127.0.0.1:8188",
|
||||||
|
dest_dir: str = "~/ComfyUI/output",
|
||||||
|
seed: int = 0,
|
||||||
|
pose_method: str = "auto",
|
||||||
|
comfy_input_dir: str = "~/ComfyUI/input",
|
||||||
|
controlnet_strength: float = 0.7,
|
||||||
|
controlnet_end: float = 0.8,
|
||||||
|
facing: str = "right",
|
||||||
|
engine: str = "pixeloe",
|
||||||
|
palette=None,
|
||||||
|
fmt: str = "webp",
|
||||||
|
negative: str = "blurry, lowres, extra limbs, deformed, multiple characters",
|
||||||
|
width: int = 512,
|
||||||
|
height: int = 768,
|
||||||
|
steps: int = 24,
|
||||||
|
cfg: float = 7.0,
|
||||||
|
comfy_python: str | None = None,
|
||||||
|
wait_timeout: float = 300.0,
|
||||||
|
filename_prefix: str = "walk_cycle",
|
||||||
|
keep_frames: bool = True,
|
||||||
|
**gen_kwargs,
|
||||||
|
) -> dict:
|
||||||
|
"""Genera una animacion de personaje andando en pixel-art, end-to-end.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: prompt del personaje ("pixel art knight, full body, side view").
|
||||||
|
No puede estar vacio. La identidad se mantiene entre frames con seed fija.
|
||||||
|
frames: numero de frames del ciclo (>=2, recomendado 4-8). keyword-only.
|
||||||
|
size: lado del grid pixel-art final por frame en pixeles (32 sprites
|
||||||
|
pequenos, 64 personajes con mas detalle). keyword-only.
|
||||||
|
colors: numero de colores de la paleta libre por frame (cuantizacion
|
||||||
|
MEDIANCUT) cuando palette es None. keyword-only.
|
||||||
|
fps: cadencia de la animacion en frames por segundo. keyword-only.
|
||||||
|
checkpoint: checkpoint SD1.5 (ControlNet OpenPose + IPAdapter-FaceID solo
|
||||||
|
instalados en SD1.5; default 'IMG_dreamshaper_8.safetensors'). keyword-only.
|
||||||
|
ref_image: imagen de cara de referencia en el input/ del servidor para
|
||||||
|
IPAdapter-FaceID (segunda ancla de identidad). None usa solo seed +
|
||||||
|
prompt. keyword-only.
|
||||||
|
server: host:port del servidor ComfyUI (sin esquema). keyword-only.
|
||||||
|
dest_dir: directorio donde guardar frames + sprite sheet + animacion.
|
||||||
|
keyword-only.
|
||||||
|
seed: semilla FIJA del KSampler para todos los frames (identidad estable).
|
||||||
|
keyword-only.
|
||||||
|
pose_method: "openpose" (esqueletos OpenPose -> ControlNet, control exacto),
|
||||||
|
"prompt" (fase del paso descrita en el prompt, sin esqueletos), o "auto"
|
||||||
|
(intenta openpose, cae a prompt si el render falla). keyword-only.
|
||||||
|
comfy_input_dir: directorio input/ del servidor ComfyUI donde se escriben
|
||||||
|
los esqueletos para que el ControlNet los lea (server local).
|
||||||
|
keyword-only.
|
||||||
|
controlnet_strength: fuerza del ControlNet OpenPose (0.7 da buen control de
|
||||||
|
la pose sin aplastar el estilo). keyword-only.
|
||||||
|
controlnet_end: fraccion de pasos en que el OpenPose deja de aplicarse
|
||||||
|
(end<1.0 libera los ultimos pasos para pelo/ropa). keyword-only.
|
||||||
|
facing: direccion a la que mira el personaje en los esqueletos ("right" o
|
||||||
|
"left"). keyword-only.
|
||||||
|
engine: motor de downscale del pixelizado ("pixeloe" contrast-aware o
|
||||||
|
"nearest"). keyword-only.
|
||||||
|
palette: None (paleta libre a `colors`), nombre builtin ("pico-8", "nes",
|
||||||
|
"game-boy") o lista de hex. keyword-only.
|
||||||
|
fmt: formato de la animacion ("webp" recomendado para alpha, o "gif").
|
||||||
|
keyword-only.
|
||||||
|
negative: prompt negativo de generacion. keyword-only.
|
||||||
|
width, height: resolucion de generacion por frame (512x768 vertical encuadra
|
||||||
|
cuerpo entero). keyword-only.
|
||||||
|
steps, cfg: parametros del KSampler. keyword-only.
|
||||||
|
comfy_python: interprete con pixeloe para el pixelizado (None autodetecta
|
||||||
|
~/ComfyUI/.venv). keyword-only.
|
||||||
|
wait_timeout: segundos maximos esperando cada frame al server. keyword-only.
|
||||||
|
filename_prefix: prefijo de los archivos de salida. keyword-only.
|
||||||
|
keep_frames: si True conserva los PNG de cada frame pixelizado; si False los
|
||||||
|
borra tras montar la animacion. keyword-only.
|
||||||
|
**gen_kwargs: params extra para comfyui_build_sprite_sheet_workflow
|
||||||
|
(sampler_name, scheduler, char_lora, lora_strength, weight, ...).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con:
|
||||||
|
- ok (bool): True si se produjo la animacion con >=1 frame.
|
||||||
|
- frames (list[str]): rutas de los PNG pixelizados (size x size RGBA).
|
||||||
|
- spritesheet_path (str): ruta del sprite sheet horizontal.
|
||||||
|
- animation_path (str): ruta de la animacion WEBP/GIF en loop.
|
||||||
|
- size (int): lado real de cada frame pixelizado.
|
||||||
|
- n_frames (int): numero de frames producidos (puede ser < `frames` si
|
||||||
|
alguno fallo y se salto).
|
||||||
|
- seed (int): semilla usada.
|
||||||
|
- pose_method_used (str): "openpose" o "prompt" (refleja el fallback).
|
||||||
|
- skipped (list[int]): indices de frames que fallaron y se saltaron.
|
||||||
|
- error (str): mensaje de error; vacio si todo OK.
|
||||||
|
"""
|
||||||
|
out = {
|
||||||
|
"ok": False, "frames": [], "spritesheet_path": "", "animation_path": "",
|
||||||
|
"size": int(size), "n_frames": 0, "seed": int(seed),
|
||||||
|
"pose_method_used": "", "skipped": [], "error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not character or not character.strip():
|
||||||
|
out["error"] = "character vacio"
|
||||||
|
return out
|
||||||
|
if int(frames) < 2:
|
||||||
|
out["error"] = f"frames debe ser >= 2, recibido {frames!r}"
|
||||||
|
return out
|
||||||
|
if int(size) < 1:
|
||||||
|
out["error"] = f"size debe ser >= 1, recibido {size!r}"
|
||||||
|
return out
|
||||||
|
if pose_method not in ("auto", "openpose", "prompt"):
|
||||||
|
out["error"] = f"pose_method invalido: {pose_method!r}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
n = int(frames)
|
||||||
|
dest = os.path.expanduser(dest_dir)
|
||||||
|
frames_dir = os.path.join(dest, f"{filename_prefix}_frames")
|
||||||
|
try:
|
||||||
|
os.makedirs(frames_dir, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
out["error"] = f"no se pudo crear dest_dir {frames_dir!r}: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
# --- Fase 0: resolver el metodo de poses + esqueletos OpenPose si aplica. ---
|
||||||
|
skeleton_names: list[str | None] = [None] * n
|
||||||
|
method = "prompt" if pose_method == "prompt" else "openpose"
|
||||||
|
if method == "openpose":
|
||||||
|
input_dir = os.path.expanduser(comfy_input_dir)
|
||||||
|
sk = render_openpose_walk_skeletons(
|
||||||
|
input_dir, frames=n, width=int(width), height=int(height),
|
||||||
|
facing=facing, filename_prefix=f"{filename_prefix}_pose",
|
||||||
|
)
|
||||||
|
if sk.get("ok") and sk.get("skeleton_paths"):
|
||||||
|
paths = sk["skeleton_paths"]
|
||||||
|
# El builder espera el basename relativo al input/ del servidor.
|
||||||
|
skeleton_names = [os.path.basename(p) for p in paths][:n]
|
||||||
|
# Si se generaron menos esqueletos que frames, rellena con None.
|
||||||
|
while len(skeleton_names) < n:
|
||||||
|
skeleton_names.append(None)
|
||||||
|
else:
|
||||||
|
# Fallback limpio: no hay esqueletos -> modo prompt.
|
||||||
|
method = "prompt"
|
||||||
|
out["error"] = (
|
||||||
|
f"render de esqueletos OpenPose fallo ({sk.get('error')}); "
|
||||||
|
f"fallback a pose por prompt"
|
||||||
|
)
|
||||||
|
if pose_method == "openpose":
|
||||||
|
# El caller forzo openpose y no hay esqueletos: aun asi seguimos en
|
||||||
|
# prompt para no abortar, pero queda anotado en el error.
|
||||||
|
pass
|
||||||
|
out["pose_method_used"] = method
|
||||||
|
|
||||||
|
# --- Fase 1..N: generar + pixelizar cada frame del ciclo. ---
|
||||||
|
pixel_frames: list[str] = []
|
||||||
|
for i in range(n):
|
||||||
|
pose_skeleton = skeleton_names[i] if method == "openpose" else None
|
||||||
|
# En modo openpose la pose la fija el esqueleto: no se mete la fase en el
|
||||||
|
# prompt para no competir con el ControlNet. En modo prompt, la fase guia.
|
||||||
|
if method == "prompt":
|
||||||
|
subject_i = f"{character}, {_phase_for(i, n)}"
|
||||||
|
else:
|
||||||
|
subject_i = f"{character}, walking"
|
||||||
|
|
||||||
|
try:
|
||||||
|
wf = comfyui_build_sprite_sheet_workflow(
|
||||||
|
subject_i,
|
||||||
|
ref_image=ref_image,
|
||||||
|
pose_skeleton=pose_skeleton,
|
||||||
|
ckpt_name=checkpoint,
|
||||||
|
controlnet_strength=controlnet_strength,
|
||||||
|
controlnet_end=controlnet_end,
|
||||||
|
transparent=True,
|
||||||
|
negative=negative,
|
||||||
|
width=int(width),
|
||||||
|
height=int(height),
|
||||||
|
steps=int(steps),
|
||||||
|
cfg=float(cfg),
|
||||||
|
seed=int(seed), # FIJA: identidad consistente entre frames.
|
||||||
|
filename_prefix=f"{filename_prefix}_f{i}",
|
||||||
|
**gen_kwargs,
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
out["skipped"].append(i)
|
||||||
|
if not out["error"]:
|
||||||
|
out["error"] = f"build workflow frame {i} fallo: {exc}"
|
||||||
|
continue
|
||||||
|
|
||||||
|
# submit -> wait -> fetch (alta resolucion RGBA). Cualquier fallo de red/
|
||||||
|
# GPU salta este frame y sigue (error path del DoD).
|
||||||
|
try:
|
||||||
|
sub = comfyui_submit_workflow(wf, server=server)
|
||||||
|
prompt_id = sub["prompt_id"]
|
||||||
|
outputs = comfyui_wait_result(prompt_id, server=server, timeout=wait_timeout)
|
||||||
|
except (RuntimeError, KeyError, OSError, TimeoutError) as exc:
|
||||||
|
out["skipped"].append(i)
|
||||||
|
if not out["error"]:
|
||||||
|
out["error"] = f"frame {i} fallo en submit/wait: {exc}"
|
||||||
|
continue
|
||||||
|
|
||||||
|
img = None
|
||||||
|
for node_out in outputs.values():
|
||||||
|
images = node_out.get("images") if isinstance(node_out, dict) else None
|
||||||
|
if images:
|
||||||
|
img = images[0]
|
||||||
|
break
|
||||||
|
if img is None:
|
||||||
|
out["skipped"].append(i)
|
||||||
|
continue
|
||||||
|
|
||||||
|
fetched = comfyui_fetch_output_image(
|
||||||
|
img["filename"], subfolder=img.get("subfolder", ""),
|
||||||
|
type_=img.get("type", "output"), server=server, dest_dir=frames_dir,
|
||||||
|
)
|
||||||
|
if not fetched.get("ok"):
|
||||||
|
out["skipped"].append(i)
|
||||||
|
if not out["error"]:
|
||||||
|
out["error"] = f"frame {i} fetch fallo: {fetched.get('error')}"
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Pixelizar el PNG alta-res a un grid duro size x size RGBA.
|
||||||
|
frame_px = os.path.join(frames_dir, f"{filename_prefix}_px_{i:02d}.png")
|
||||||
|
px = comfyui_pixelize_sprite_png(
|
||||||
|
fetched["path"], frame_px, size=int(size), colors=int(colors),
|
||||||
|
engine=engine, palette=palette, transparent=True, autocrop=True,
|
||||||
|
comfy_python=comfy_python,
|
||||||
|
)
|
||||||
|
if not px.get("ok"):
|
||||||
|
out["skipped"].append(i)
|
||||||
|
if not out["error"]:
|
||||||
|
out["error"] = f"frame {i} pixelizado fallo: {px.get('error')}"
|
||||||
|
continue
|
||||||
|
|
||||||
|
pixel_frames.append(frame_px)
|
||||||
|
|
||||||
|
if not pixel_frames:
|
||||||
|
if not out["error"]:
|
||||||
|
out["error"] = "ningun frame se genero correctamente"
|
||||||
|
return out
|
||||||
|
|
||||||
|
# --- Ensamblado: sprite sheet horizontal + animacion en loop. ---
|
||||||
|
asm = assemble_animated_sprite(
|
||||||
|
pixel_frames, dest, name=filename_prefix, fps=int(fps), fmt=fmt,
|
||||||
|
loop=True, spritesheet=True,
|
||||||
|
)
|
||||||
|
if not asm.get("ok"):
|
||||||
|
out["frames"] = pixel_frames
|
||||||
|
out["n_frames"] = len(pixel_frames)
|
||||||
|
if not out["error"]:
|
||||||
|
out["error"] = f"ensamblado fallo: {asm.get('error')}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
out["frames"] = pixel_frames
|
||||||
|
out["spritesheet_path"] = asm.get("spritesheet_path", "")
|
||||||
|
out["animation_path"] = asm.get("animation_path", "")
|
||||||
|
out["n_frames"] = len(pixel_frames)
|
||||||
|
# El size real lo confirma el primer frame ensamblado.
|
||||||
|
fs = asm.get("frame_size") or [int(size), int(size)]
|
||||||
|
out["size"] = int(fs[0])
|
||||||
|
|
||||||
|
if not keep_frames:
|
||||||
|
for fp in pixel_frames:
|
||||||
|
try:
|
||||||
|
os.remove(fp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
out["ok"] = True
|
||||||
|
# El error de fallback de poses (si lo hubo) es informativo, no invalida el ok.
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
|
||||||
|
res = comfyui_walk_cycle_oneshot(
|
||||||
|
"pixel art knight, full body, side view",
|
||||||
|
frames=4, size=32, colors=16, fps=8, seed=42,
|
||||||
|
dest_dir="/tmp/comfy_walk_cycle",
|
||||||
|
)
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
@@ -19,7 +19,9 @@ dependencies = [
|
|||||||
"google-cloud-storage>=3.10.1",
|
"google-cloud-storage>=3.10.1",
|
||||||
"httpx",
|
"httpx",
|
||||||
"matplotlib>=3.10.9",
|
"matplotlib>=3.10.9",
|
||||||
|
"opencv-contrib-python-headless>=4.13.0.92",
|
||||||
"openpyxl>=3.1.5",
|
"openpyxl>=3.1.5",
|
||||||
|
"pillow>=12.2.0",
|
||||||
"polars>=1.40.1",
|
"polars>=1.40.1",
|
||||||
"pymeshlab>=2025.7.post1",
|
"pymeshlab>=2025.7.post1",
|
||||||
"pymssql>=2.3.13",
|
"pymssql>=2.3.13",
|
||||||
@@ -27,6 +29,7 @@ dependencies = [
|
|||||||
"pyproj>=3.7.2",
|
"pyproj>=3.7.2",
|
||||||
"python-docx>=1.2.0",
|
"python-docx>=1.2.0",
|
||||||
"pyyaml>=6.0.3",
|
"pyyaml>=6.0.3",
|
||||||
|
"qrcode[pil]>=8.2",
|
||||||
"rapidfuzz>=3.14.5",
|
"rapidfuzz>=3.14.5",
|
||||||
"reportlab>=4.5.0",
|
"reportlab>=4.5.0",
|
||||||
"scikit-image>=0.26.0",
|
"scikit-image>=0.26.0",
|
||||||
|
|||||||
Generated
+41
@@ -900,7 +900,9 @@ dependencies = [
|
|||||||
{ name = "google-cloud-storage" },
|
{ name = "google-cloud-storage" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "matplotlib" },
|
{ name = "matplotlib" },
|
||||||
|
{ name = "opencv-contrib-python-headless" },
|
||||||
{ name = "openpyxl" },
|
{ name = "openpyxl" },
|
||||||
|
{ name = "pillow" },
|
||||||
{ name = "polars" },
|
{ name = "polars" },
|
||||||
{ name = "pymeshlab" },
|
{ name = "pymeshlab" },
|
||||||
{ name = "pymssql" },
|
{ name = "pymssql" },
|
||||||
@@ -908,6 +910,7 @@ dependencies = [
|
|||||||
{ name = "pyproj" },
|
{ name = "pyproj" },
|
||||||
{ name = "python-docx" },
|
{ name = "python-docx" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
|
{ name = "qrcode", extra = ["pil"] },
|
||||||
{ name = "rapidfuzz" },
|
{ name = "rapidfuzz" },
|
||||||
{ name = "reportlab" },
|
{ name = "reportlab" },
|
||||||
{ name = "scikit-image" },
|
{ name = "scikit-image" },
|
||||||
@@ -956,7 +959,9 @@ requires-dist = [
|
|||||||
{ name = "jupyter-mcp-server", marker = "extra == 'jupyter'" },
|
{ name = "jupyter-mcp-server", marker = "extra == 'jupyter'" },
|
||||||
{ name = "jupyterlab", marker = "extra == 'jupyter'", specifier = ">=4.0" },
|
{ name = "jupyterlab", marker = "extra == 'jupyter'", specifier = ">=4.0" },
|
||||||
{ name = "matplotlib", specifier = ">=3.10.9" },
|
{ name = "matplotlib", specifier = ">=3.10.9" },
|
||||||
|
{ name = "opencv-contrib-python-headless", specifier = ">=4.13.0.92" },
|
||||||
{ name = "openpyxl", specifier = ">=3.1.5" },
|
{ name = "openpyxl", specifier = ">=3.1.5" },
|
||||||
|
{ name = "pillow", specifier = ">=12.2.0" },
|
||||||
{ name = "polars", specifier = ">=1.40.1" },
|
{ name = "polars", specifier = ">=1.40.1" },
|
||||||
{ name = "pymeshlab", specifier = ">=2025.7.post1" },
|
{ name = "pymeshlab", specifier = ">=2025.7.post1" },
|
||||||
{ name = "pymssql", specifier = ">=2.3.13" },
|
{ name = "pymssql", specifier = ">=2.3.13" },
|
||||||
@@ -964,6 +969,7 @@ requires-dist = [
|
|||||||
{ name = "pyproj", specifier = ">=3.7.2" },
|
{ name = "pyproj", specifier = ">=3.7.2" },
|
||||||
{ name = "python-docx", specifier = ">=1.2.0" },
|
{ name = "python-docx", specifier = ">=1.2.0" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||||
|
{ name = "qrcode", extras = ["pil"], specifier = ">=8.2" },
|
||||||
{ name = "rapidfuzz", specifier = ">=3.14.5" },
|
{ name = "rapidfuzz", specifier = ">=3.14.5" },
|
||||||
{ name = "reportlab", specifier = ">=4.5.0" },
|
{ name = "reportlab", specifier = ">=4.5.0" },
|
||||||
{ name = "scikit-image", specifier = ">=0.26.0" },
|
{ name = "scikit-image", specifier = ">=0.26.0" },
|
||||||
@@ -2945,6 +2951,24 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/df/4e/1c9df57496409dc86b320bd38f29ad7a34b7115e4f35b8fca44a827568a7/onnxruntime-1.25.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e79fd5ce7db10ebcc24e020e2ed0159476e69e2326b9b7828e5aadcf6184212", size = 18021249, upload-time = "2026-04-27T22:00:18.954Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/4e/1c9df57496409dc86b320bd38f29ad7a34b7115e4f35b8fca44a827568a7/onnxruntime-1.25.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e79fd5ce7db10ebcc24e020e2ed0159476e69e2326b9b7828e5aadcf6184212", size = 18021249, upload-time = "2026-04-27T22:00:18.954Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opencv-contrib-python-headless"
|
||||||
|
version = "4.13.0.92"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "numpy" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/b5/9af5b81d9279e9982e21dad52f8a6aec10f7c891ae1e3d3d1b3ce111f8e7/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:b0467988c2d56c283b00fb808e0b57f5db2e3ca7743164a3b3efc733bfa03d3a", size = 52041681, upload-time = "2026-02-05T07:01:39.651Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/42/f0aef27baf1f376007b018b00f6c304c42c20d31aa8491633c53b18912cb/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:79e503b77880d806a1b106ff8182c6f898347ccfd1db58ffc9a6369acc236c4c", size = 38830456, upload-time = "2026-02-05T07:01:56.47Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/84/e6b3568f9147b4f114e881fb0e733fd97bdca15452feba78b510351584d1/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:449c1f00a685a3a7dff8d6fa93a70fbfe0de5537c24358ea03a1d996d12b33e8", size = 39355323, upload-time = "2026-02-05T10:17:31.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/09/89714580c617cf6e9f66eed9137759fc017ab6ab093c2a03227e8ee19578/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c9b028adc04f6579f37227eb1d648bead14fd6fefc58da86df37c8320351f7bd", size = 62147375, upload-time = "2026-02-05T10:20:03.076Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/29/abdd2ff2f8f07e9aa37c70edc9987b8aa63730ae70957c378f6f2e9d72d2/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:cdd974b34801f24735d18b1057cfaab1698d5cb02c9bba01dab7dc47201f2ef6", size = 40840722, upload-time = "2026-02-05T10:21:27.877Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/0a/3194fdf035ef5123bd8cc3e3ad1a96c1ddeeedd0fdd12aaa0d2cfeb1649a/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9e26469baed9069f627ea56fa46819690c4545580362071dd09f1dcf47a40f2f", size = 66610130, upload-time = "2026-02-05T10:23:32.427Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/4b/afe9b43c02b86b675a3d3ac6fc220473a88016e1acb487f5138efd2d2630/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:696a6dd84d309a499efc63644e375f035447b1da777faa2954f2dea7626cc0e7", size = 36708602, upload-time = "2026-02-05T07:02:36.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/22/9fdc70520eb915b46d816f9cc5415458b1bd114a65d7a7e657cbd9b863e5/opencv_contrib_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:dcbb12d04ae74f5dcd782e3b166e1894c6fbdfaaf30866588746205d2a0cde5a", size = 46345416, upload-time = "2026-02-05T07:02:33.446Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpyxl"
|
name = "openpyxl"
|
||||||
version = "3.1.5"
|
version = "3.1.5"
|
||||||
@@ -4020,6 +4044,23 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode"
|
||||||
|
version = "8.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
pil = [
|
||||||
|
{ name = "pillow" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rapidfuzz"
|
name = "rapidfuzz"
|
||||||
version = "3.14.5"
|
version = "3.14.5"
|
||||||
|
|||||||
Reference in New Issue
Block a user