Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4cff5ed5b | |||
| caf8c25d99 | |||
| 7ac69ab4fb | |||
| 02301aaed3 | |||
| 2729629f0a | |||
| 6cc90558d4 | |||
| 36a725ba10 | |||
| 1dd6c889e5 | |||
| 7aaac44a49 | |||
| ffcb69ce02 | |||
| c79f33265e | |||
| 31c2f6ac7f | |||
| 3bc97828e3 | |||
| ccdd529bdc | |||
| 741724f633 | |||
| 2be62f6ef6 | |||
| 8e9e1e6c8a | |||
| ec46aae04c | |||
| b173ac2703 | |||
| ec0a5e53ac | |||
| 5280499df5 | |||
| 346f859b86 | |||
| 604d3d4feb | |||
| 287abbd6ee | |||
| f8793f96ac | |||
| 643ebfb849 | |||
| 537516e32e | |||
| ca07b25297 | |||
| fbbff7d5e7 | |||
| bdd841d9af | |||
| 7d33b39859 | |||
| a1074d32e7 | |||
| fd16453691 | |||
| 5494507c39 | |||
| dfb3eda087 | |||
| 83738d4035 | |||
| e178ab8d2d |
@@ -0,0 +1,204 @@
|
|||||||
|
---
|
||||||
|
description: Genera en un vault Obsidian un resumen capítulo a capítulo de uno o varios libros, siguiendo el formato de notas del vault captacion_clientes (MOC de libro + una nota por capítulo + MOC de categoría, todo enlazado con wikilinks).
|
||||||
|
---
|
||||||
|
|
||||||
|
# /capitulos — resumen de libros capítulo a capítulo en Obsidian
|
||||||
|
|
||||||
|
Genera notas de estudio de un libro (o varios) en un vault Obsidian, replicando el formato
|
||||||
|
canónico del vault `captacion_clientes`: una nota MOC por libro, una nota por capítulo, y una
|
||||||
|
nota MOC de categoría que agrupa los libros. Todo enlazado con wikilinks `[[ ]]` para que
|
||||||
|
Obsidian construya el grafo.
|
||||||
|
|
||||||
|
## Argumentos
|
||||||
|
|
||||||
|
`$ARGUMENTS` contiene, en lenguaje natural, los libros a procesar y opcionalmente el destino.
|
||||||
|
Interpreta:
|
||||||
|
|
||||||
|
- **Libros** — uno o varios títulos. Pueden venir con autor ("Forecasting de Hyndman"). Si el
|
||||||
|
usuario dice "los libros que me has dicho" o similar, usa los que se recomendaron en la
|
||||||
|
conversación previa.
|
||||||
|
- **Vault destino** — si no se especifica, **PREGUNTA** antes de escribir (ver Decisiones).
|
||||||
|
Vault por defecto de ejemplo de formato: `/home/enmanuel/Obsidian/captacion_clientes`.
|
||||||
|
- **Categoría** — la subcarpeta bajo `Libros/` que agrupa los libros (ej. "Marca y Mercado",
|
||||||
|
"Datos e Inversión"). Si no se da, propón una coherente con el tema de los libros y confírmala.
|
||||||
|
- **Profundidad** — `completo` (default, como The Mom Test: idea central + puntos clave +
|
||||||
|
citas + aplicación por capítulo) o `breve` (idea central + 3 bullets por capítulo).
|
||||||
|
|
||||||
|
## Decisiones a confirmar antes de escribir (si faltan en los argumentos)
|
||||||
|
|
||||||
|
Usa `AskUserQuestion` para resolver lo que cambie el trabajo, NO inventes:
|
||||||
|
|
||||||
|
1. **Vault y categoría destino** — dónde se crean las notas.
|
||||||
|
2. **Alcance** — qué libros exactamente y cuántos (si la lista es grande, confirma si son
|
||||||
|
todos o un subconjunto; cada libro es trabajo no trivial).
|
||||||
|
3. **Enfoque de "Aplicación"** — el ángulo desde el que se escribe la sección "Aplicación a mi
|
||||||
|
negocio / a mi caso" de cada capítulo (ej. inversión cuantitativa, data-analyst, SaaS…).
|
||||||
|
El vault de captación lo orienta al negocio del usuario; mantén ese espíritu pero ajustado
|
||||||
|
al tema real de los libros.
|
||||||
|
|
||||||
|
## Estructura de archivos a crear
|
||||||
|
|
||||||
|
```
|
||||||
|
<vault>/Libros/<Categoría>/
|
||||||
|
<Categoría> - MOC.md # MOC de categoría (crear o ACTUALIZAR, no sobrescribir)
|
||||||
|
<Libro>/
|
||||||
|
<Libro> - MOC.md # MOC del libro
|
||||||
|
01 - <Título capítulo>.md # una nota por capítulo, NN zero-padded a 2 dígitos
|
||||||
|
02 - <Título capítulo>.md
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- Carpeta por libro, archivo por capítulo. Nombre de capítulo: `NN - <Título>.md` con `NN`
|
||||||
|
empezando en `01`. Si el capítulo tiene título original en otro idioma, puedes incluir la
|
||||||
|
traducción entre paréntesis como en el vault (`01 - The Mom Test (El test de la madre).md`).
|
||||||
|
- Nombres de archivo sin caracteres que rompan en Obsidian (evita `/`, `:`; los paréntesis y
|
||||||
|
acentos son válidos).
|
||||||
|
|
||||||
|
## Determinar los capítulos de cada libro
|
||||||
|
|
||||||
|
Para listar los capítulos reales de un libro:
|
||||||
|
|
||||||
|
1. Usa tu conocimiento del libro si lo conoces con fiabilidad (índice real, no inventado).
|
||||||
|
2. Si no estás seguro del índice exacto, **búscalo en la web** (`WebSearch` / `WebFetch` sobre
|
||||||
|
la tabla de contenidos del libro) antes de escribir. No inventes capítulos.
|
||||||
|
3. Indica en el MOC del libro si el índice procede de una edición concreta.
|
||||||
|
|
||||||
|
**Regla dura:** nunca te inventes el número o los títulos de los capítulos. Si no puedes
|
||||||
|
verificarlos, dilo y pregunta al usuario en vez de fabricar un índice plausible.
|
||||||
|
|
||||||
|
## Plantilla — MOC del libro (`<Libro> - MOC.md`)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: <Libro> - MOC
|
||||||
|
book: <Libro>
|
||||||
|
author: <Autor>
|
||||||
|
year: <Año>
|
||||||
|
type: book-moc
|
||||||
|
tags:
|
||||||
|
- <slug-libro>
|
||||||
|
- <tema-1>
|
||||||
|
- moc
|
||||||
|
---
|
||||||
|
|
||||||
|
# <Libro> — Mapa de contenidos (MOC)
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
- **Autor:** <Autor>
|
||||||
|
- **Año:** <Año> (<edición si aplica>)
|
||||||
|
- **Subtítulo:** *<subtítulo original>* (<traducción>)
|
||||||
|
- **Tema:** <de qué va en una frase>
|
||||||
|
- **Por qué importa:** <2-3 frases sobre qué problema resuelve y para quién>
|
||||||
|
|
||||||
|
## Resumen global
|
||||||
|
<Un párrafo denso (8-15 líneas) que sintetiza la tesis del libro y recorre el hilo de los
|
||||||
|
capítulos sin enumerarlos uno a uno: cuenta el argumento completo en prosa.>
|
||||||
|
|
||||||
|
## Capítulos
|
||||||
|
1. [[01 - <Título capítulo>]]
|
||||||
|
2. [[02 - <Título capítulo>]]
|
||||||
|
...
|
||||||
|
|
||||||
|
## Aplicación a mi caso (visión transversal)
|
||||||
|
<Párrafo que conecta el libro entero con el objetivo concreto del usuario (el enfoque
|
||||||
|
confirmado en las Decisiones): qué capítulos son los más relevantes y por qué.>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plantilla — nota de capítulo (`NN - <Título>.md`)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: <Título capítulo>
|
||||||
|
book: <Libro>
|
||||||
|
author: <Autor>
|
||||||
|
chapter: <N>
|
||||||
|
type: chapter-summary
|
||||||
|
tags:
|
||||||
|
- <slug-libro>
|
||||||
|
- <tema>
|
||||||
|
---
|
||||||
|
|
||||||
|
# NN. <Título capítulo>
|
||||||
|
|
||||||
|
> Libro: [[<Libro> - MOC]]
|
||||||
|
|
||||||
|
## Idea central
|
||||||
|
<1-3 frases con la tesis del capítulo.>
|
||||||
|
|
||||||
|
## Puntos clave
|
||||||
|
- <bullet sustantivo, no genérico>
|
||||||
|
- <…>
|
||||||
|
- <…>
|
||||||
|
|
||||||
|
## Ejemplos / citas
|
||||||
|
- <ejemplo concreto del capítulo o cita textual con su traducción si es en otro idioma>
|
||||||
|
- <…>
|
||||||
|
|
||||||
|
## Aplicación a mi caso
|
||||||
|
<Párrafo concreto: cómo aplicar la idea del capítulo al caso del usuario.>
|
||||||
|
|
||||||
|
---
|
||||||
|
Anterior: [[NN-1 - <Título anterior>]] · Siguiente: [[NN+1 - <Título siguiente>]] · Índice: [[<Libro> - MOC]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Notas de la plantilla:
|
||||||
|
- El primer capítulo: `Anterior: —`. El último: `Siguiente: —`. (Ver patrón en el vault.)
|
||||||
|
- La sección "Aplicación" es obligatoria y debe ser específica del caso del usuario, no un
|
||||||
|
consejo genérico. Es lo que da valor a estas notas frente a un resumen cualquiera.
|
||||||
|
- En profundidad `breve`, omite "Ejemplos / citas" y deja "Puntos clave" en 3 bullets.
|
||||||
|
|
||||||
|
## Plantilla — MOC de categoría (`<Categoría> - MOC.md`)
|
||||||
|
|
||||||
|
Si ya existe, **ACTUALÍZALO** añadiendo los libros nuevos a la sección que corresponda (no lo
|
||||||
|
reescribas perdiendo lo previo). Si no existe, créalo:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: <Categoría> — MOC
|
||||||
|
type: moc
|
||||||
|
tags:
|
||||||
|
- libros
|
||||||
|
- <tema-categoría>
|
||||||
|
---
|
||||||
|
|
||||||
|
# <Categoría> — Mapa de contenidos
|
||||||
|
|
||||||
|
<Frase que describe el tema común de los libros de esta categoría.>
|
||||||
|
|
||||||
|
Cada libro tiene su propia nota MOC con el índice de capítulos enlazados.
|
||||||
|
|
||||||
|
## <Sub-tema 1>
|
||||||
|
- [[<Libro A> - MOC]] — <Autor>. <una línea de qué aporta>.
|
||||||
|
- [[<Libro B> - MOC]] — <Autor>. <…>.
|
||||||
|
|
||||||
|
## Orden de lectura recomendado
|
||||||
|
1. **<Libro>** — <por qué primero>.
|
||||||
|
2. ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flujo de ejecución
|
||||||
|
|
||||||
|
1. Parsear `$ARGUMENTS`: libros, vault, categoría, profundidad, enfoque.
|
||||||
|
2. Resolver decisiones faltantes con `AskUserQuestion`.
|
||||||
|
3. Para cada libro: verificar el índice real de capítulos (conocimiento fiable o WebSearch).
|
||||||
|
4. Crear carpeta del libro. Escribir el MOC del libro y todas las notas de capítulo con
|
||||||
|
wikilinks y navegación correctos.
|
||||||
|
5. Crear o actualizar el MOC de categoría enlazando los libros nuevos.
|
||||||
|
6. **Paralelización:** si son varios libros, cada libro es independiente (carpetas disjuntas).
|
||||||
|
En modo orquestador, lanza un ejecutor por libro (o por lote de libros) escribiendo en
|
||||||
|
carpetas distintas del mismo vault. Cada ejecutor escribe SOLO su carpeta de libro; el MOC
|
||||||
|
de categoría lo actualiza UN único agente al final (o el orquestador) para evitar que dos
|
||||||
|
ejecutores editen el mismo archivo a la vez.
|
||||||
|
7. Reportar: lista de archivos creados (MOC + nº de capítulos por libro) y la ruta del vault
|
||||||
|
para abrirlo en Obsidian.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **El vault es artefacto local** (gitignored en fn_registry, symlink a `~/Obsidian/<vault>`).
|
||||||
|
Escribir notas NO toca el repo `fn_registry`. Si el vault es su propio repo git, NO commitees
|
||||||
|
desde varios ejecutores a la vez (race): deja el commit/sync al usuario o a un único paso final.
|
||||||
|
- **No sobrescribas** un MOC de categoría existente ni notas de capítulo ya escritas a mano sin
|
||||||
|
confirmarlo. Ante colisión de nombre, pregunta.
|
||||||
|
- **Índices inventados = bug.** Verifica los capítulos reales antes de escribir.
|
||||||
|
- **Wikilinks deben resolver:** el texto dentro de `[[ ]]` debe coincidir exactamente con el
|
||||||
|
nombre de archivo (sin extensión). Un typo rompe el enlace en Obsidian.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
description: EDA (exploratory data analysis) de una tabla o de una base entera con el grupo `eda` del registry. Perfila, escribe el report (JSON + Markdown + PDF móvil) y monta un analysis Jupyter lanzado en el navegador colaborativo y ejecutado en vivo por Claude.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /eda — Exploratory Data Analysis con el grupo `eda`
|
||||||
|
|
||||||
|
Cuando Enmanuel pide un EDA ("hazme un EDA de X", "analiza esta tabla", "qué hay en estos datos"), **no escribas análisis inline**: usa el grupo de capacidad `eda` del registry, escribe los reports y monta el analysis Jupyter en su navegador colaborativo, ejecutando las celdas tú mismo en vivo. Respeta la memoria `eda-workflow-registry` y la regla `.claude/rules/notebook_collaboration.md`.
|
||||||
|
|
||||||
|
Página madre del grupo: `docs/capabilities/eda.md` (léela primero para cargar el cluster entero).
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
```
|
||||||
|
/eda /ruta/datos.duckdb tabla # EDA de una tabla DuckDB
|
||||||
|
/eda /ruta/datos.csv # CSV/Parquet → cargar a DuckDB y perfilar
|
||||||
|
/eda postgresql://user:pass@host:5432/db tabla # EDA de una tabla PostgreSQL (backend="postgres")
|
||||||
|
/eda /ruta/datos.duckdb --all # EDA de TODA la base (todas las tablas + FK + join graph)
|
||||||
|
/eda /ruta/datos.duckdb ventas --series --pdf # con análisis de serie temporal + PDF móvil
|
||||||
|
```
|
||||||
|
|
||||||
|
`$ARGUMENTS` lleva la fuente y, opcionalmente, la tabla y flags. Interpreta:
|
||||||
|
- **Fuente**: ruta a `.duckdb`/`.csv`/`.parquet`, o un DSN PostgreSQL (`postgresql://...` o `postgres://...`).
|
||||||
|
- **Tabla**: nombre de la tabla. Si no se da y la fuente es un único archivo CSV/Parquet, usa su nombre base. Si se pide "toda la base" / `--all`, usa `profile_database`.
|
||||||
|
- **Flags** (actívalos según lo que pida el usuario; pregunta solo si es ambiguo y costoso):
|
||||||
|
- `--models` → `run_models=True` (PCA/KMeans/IsolationForest/normalidad).
|
||||||
|
- `--llm` → `run_llm=True` (1 call LLM sobre el perfil agregado).
|
||||||
|
- `--series` → `run_series=True` (estacionariedad ADF+KPSS, ACF/PACF, STL, retornos por columna numérica).
|
||||||
|
- `--pdf` → `emit_pdf=True` (PDF A5 vertical legible en móvil).
|
||||||
|
|
||||||
|
Por defecto, para un EDA "completo" cuando el usuario no especifica, activa `run_models`, `run_series` y `emit_pdf`; deja `run_llm` para cuando lo pida o cuando interese la interpretación semántica (es la única parte que gasta tokens del modelo).
|
||||||
|
|
||||||
|
## Reglas duras
|
||||||
|
|
||||||
|
1. **Registry-first**: invoca las funciones del grupo `eda`, no reescribas lógica de perfilado ni de gráficos inline (regla `registry_first.md`).
|
||||||
|
2. **CSV/Parquet/Excel** entran cargándolos antes a DuckDB (`read_csv_auto`/`read_parquet`/`read_xlsx`) — DuckDB es el motor por defecto. No traigas la tabla entera a RAM.
|
||||||
|
3. **Secretos**: si la fuente es un DSN PostgreSQL con credenciales, NO las imprimas en los reports ni en el notebook; resuélvelas vía `resolve_pg_dsn`/`pass` cuando aplique.
|
||||||
|
4. **El report es un artefacto local**: vive en `reports/` (gitignored), no se sube a Gitea ni se versiona. Compartir = pasar la ruta (regla `reports.md`).
|
||||||
|
5. **Entrega las 4 salidas**: JSON sidecar + Markdown + **PDF móvil** + **notebook Jupyter colaborativo ejecutado en vivo**.
|
||||||
|
|
||||||
|
## Paso 1 — Perfilar y escribir los reports
|
||||||
|
|
||||||
|
Una tabla (caso normal):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=python/functions python/.venv/bin/python3 - <<'PYEOF'
|
||||||
|
from pipelines.profile_table import profile_table
|
||||||
|
r = profile_table(
|
||||||
|
"/ruta/datos.duckdb", "ventas",
|
||||||
|
run_models=True, run_series=True, emit_pdf=True, run_llm=False,
|
||||||
|
)
|
||||||
|
print("status:", r["status"])
|
||||||
|
print("md: ", r["report_md_path"])
|
||||||
|
print("json: ", r["report_json_path"])
|
||||||
|
print("pdf: ", r["pdf_path"])
|
||||||
|
PYEOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Una base entera (todas las tablas + relaciones FK):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=python/functions python/.venv/bin/python3 - <<'PYEOF'
|
||||||
|
from pipelines.profile_database import profile_database
|
||||||
|
r = profile_database("/ruta/datos.duckdb")
|
||||||
|
print(r["db_profile"]["join_graph"]["mermaid"])
|
||||||
|
PYEOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Lee el Markdown resultante y resume a Enmanuel lo esencial: forma, calidad, correlaciones fuertes (ya corregidas por FDR), series no estacionarias, transformaciones sugeridas y avisos exploratorios.
|
||||||
|
|
||||||
|
## Paso 2 — Notebook Jupyter colaborativo, ejecutado en vivo por Claude
|
||||||
|
|
||||||
|
Sigue la memoria `eda-workflow-registry` y la regla `notebook_collaboration.md`:
|
||||||
|
|
||||||
|
1. Genera el notebook con `build_eda_notebook` (mismo perfil de la tabla):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=python/functions python/.venv/bin/python3 - <<'PYEOF'
|
||||||
|
from datascience import build_eda_notebook
|
||||||
|
build_eda_notebook("/ruta/datos.duckdb", "ventas",
|
||||||
|
"analysis/eda_ventas/notebooks/01_eda.ipynb", run_models=True)
|
||||||
|
PYEOF
|
||||||
|
```
|
||||||
|
|
||||||
|
(o crea un analysis dedicado con `fn run init_jupyter_analysis eda_ventas duckdb` y escribe el notebook dentro de `notebooks/`).
|
||||||
|
|
||||||
|
2. Confirma que hay Jupyter colaborativo activo con `jupyter_discover` (o lánzalo con el `run-jupyter-lab.sh` del analysis) y **ábrelo en el navegador colaborativo** para que Enmanuel lo vea en vivo.
|
||||||
|
|
||||||
|
3. **Ejecuta tú las celdas** (no se las dejes para que las corra él): usa las funciones del dominio `notebook` (`jupyter_exec` append+execute / `jupyter_read`) descritas en `notebook_collaboration.md`, o el MCP `jupyter` si está conectado en la sesión del analysis. Ejecuta de arriba a abajo, comenta cada bloque relevante y deja el notebook navegable.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- El `TableProfile` lleva ahora, además del perfilado base y las correlaciones con FDR: `series` (por columna numérica, con `run_series`), `reexpression` por columna numérica (escalera de Tukey) y `caveats` (siempre, avisos exploratorios). El Markdown y el PDF renderizan estas secciones automáticamente cuando están presentes.
|
||||||
|
- El PDF (`emit_pdf`) está pensado para leerse en el móvil (A5 vertical, tipografía grande, gráficos Tufte). Se escribe junto al Markdown en `reports/`.
|
||||||
|
- `run_series` ordena por la primera columna datetime si existe; si no, por el orden físico de filas. Necesita ≥8 puntos válidos por columna.
|
||||||
|
- Fuentes: DuckDB (CSV/Parquet/Excel cargados antes) y PostgreSQL (`backend="postgres"`). `profile_database` (multi-tabla + FK) es solo DuckDB por ahora.
|
||||||
@@ -15,6 +15,10 @@
|
|||||||
"godot": {
|
"godot": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "http://127.0.0.1:8000/mcp"
|
"url": "http://127.0.0.1:8000/mcp"
|
||||||
|
},
|
||||||
|
"ardour": {
|
||||||
|
"command": "/home/enmanuel/audio-tools/ardour-mcp/target/release/ardour_mcp_server",
|
||||||
|
"args": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
id: "0173"
|
||||||
|
title: "EDA: bugs críticos de correctitud estadística (outlier_pct ×100, distribution_type por-skew)"
|
||||||
|
status: pendiente
|
||||||
|
type: bugfix
|
||||||
|
domain:
|
||||||
|
- registry-quality
|
||||||
|
scope: registry-only
|
||||||
|
priority: alta
|
||||||
|
depends: []
|
||||||
|
blocks: []
|
||||||
|
related: ["0174", "0175", "0176", "0177", "0068"]
|
||||||
|
created: 2026-06-29
|
||||||
|
updated: 2026-06-29
|
||||||
|
tags: [eda, datascience, profile_table, render_eda_markdown, describe_numeric, benchmark]
|
||||||
|
---
|
||||||
|
# 0173 — EDA: bugs críticos de correctitud estadística
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
Un benchmark adversarial del workflow `/eda` sobre 12 datasets reales (29/06/2026,
|
||||||
|
`temp/eda_benchmark/EVALUATION.md`) detectó que los estadísticos descriptivos base son
|
||||||
|
correctos, pero el **porcentaje de outliers que el report markdown muestra es imposible**
|
||||||
|
(supera el 100%, hasta 336%), engañando a un lector no experto con apariencia de autoridad.
|
||||||
|
|
||||||
|
Hallazgos cubiertos por este issue:
|
||||||
|
|
||||||
|
| Hallazgo | Severidad | Evidencia del benchmark |
|
||||||
|
|---|---|---|
|
||||||
|
| H1 — `outlier_pct` por-columna >100% en el report markdown | crítico | wine-red `chlorides` 193.87%, `density` 112.57% (skew 0.07); titanic `SibSp` 336.70%, `Fare` 224.47%; seattle `precipitation` 253.25% |
|
||||||
|
| H11 — `distribution_type` por-skew etiqueta mal discretas/ordinales/multimodales | bajo | wine `quality` (6 valores) → "normal-ish"; precios BTC multimodales → "normal-ish" (skew 0.45) |
|
||||||
|
|
||||||
|
### Causa raíz de H1 (verificada en código, READ-ONLY)
|
||||||
|
|
||||||
|
`EVALUATION.md` propuso "corregir la fórmula en `describe_numeric`". **Eso es incorrecto.** Al
|
||||||
|
leer el código:
|
||||||
|
|
||||||
|
- `python/functions/datascience/describe_numeric.py:113` calcula
|
||||||
|
`outlier_pct = 100.0 * n_outliers / n` — ya en escala 0-100 y acotado a [0,100]. **Está bien.**
|
||||||
|
- `python/functions/datascience/render_eda_markdown.py:203-204` renderiza ese valor con
|
||||||
|
`_fmt_pct(val)`, y `_fmt_pct` (líneas 31-44) hace `num * 100` porque **asume que su input es
|
||||||
|
una fracción 0-1**. Resultado: **doble ×100** (un 1.94 real se muestra como 193.87%).
|
||||||
|
- El PDF (`render_eda_pdf.py:296`) usa `_fmt_num(outlier_pct, 1) + "%"` sin multiplicar — por eso
|
||||||
|
el PDF muestra el outlier_pct correcto y el markdown no. El bug es **exclusivo del renderer
|
||||||
|
markdown**.
|
||||||
|
|
||||||
|
El factor "19-40×" que observó el evaluador se debe a que comparaba contra outliers IQR (3-10%),
|
||||||
|
mientras `describe_numeric` usa z-score (umbral 3.0, da menos outliers); pero el mecanismo del bug
|
||||||
|
es el doble ×100, no la fórmula.
|
||||||
|
|
||||||
|
## Tareas
|
||||||
|
|
||||||
|
1. **H1 (fix de 1 línea):** en `python/functions/datascience/render_eda_markdown.py:203-204`,
|
||||||
|
sustituir `_fmt_pct(val)` por un formateo que NO multiplique (p.ej. `f"{_fmt_num(val, 2)}%"`),
|
||||||
|
porque `numeric.outlier_pct` ya viene en escala 0-100. **No tocar** `describe_numeric.py` (su
|
||||||
|
fórmula es correcta).
|
||||||
|
2. Auditar el resto de `render_eda_markdown.py` por si otro campo en escala 0-100 pasa por
|
||||||
|
`_fmt_pct` (los `*_pct` del perfil base sí son fracciones 0-1 y deben seguir con `_fmt_pct`;
|
||||||
|
solo `numeric.outlier_pct` está en escala 0-100). Documentar en el docstring de `describe_numeric`
|
||||||
|
que `outlier_pct` está en 0-100 para evitar la confusión a futuro.
|
||||||
|
3. **H11:** en `python/functions/datascience/detect_distribution_type.py`, no etiquetar por skew
|
||||||
|
solamente: usar también nº de modos / cardinalidad y, cuando esté disponible, el test de
|
||||||
|
normalidad Jarque-Bera (`normality_tests.py`, ya expuesto en `models.normality` vía
|
||||||
|
`run_eda_models`). Una variable discreta/ordinal/multimodal no debe salir "normal-ish".
|
||||||
|
4. Añadir/extender tests unitarios: `describe_numeric_test.py` (outlier_pct en [0,100]),
|
||||||
|
`render_eda_markdown_test.py` (un perfil con `outlier_pct=7.0` renderiza `"7.00%"`, no `"700%"`),
|
||||||
|
y un test de `detect_distribution_type` (discreta de 6 valores no se etiqueta "normal-ish"). Nota:
|
||||||
|
hoy NO existe `detect_distribution_type_test.py` en `python/functions/datascience/` — hay que
|
||||||
|
crearlo (a confirmar el nombre canónico al implementar; el resto de tests citados sí existen).
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Golden: outlier_pct en rango | e2e | re-correr `profile_table` sobre `temp/eda_benchmark/datasets/.../wine-red` y leer el `.md` | `chlorides`/`density` muestran `outlier_pct` en [0,100]% (no 193.87% / 112.57%) |
|
||||||
|
| Edge: skew alto real | unit | `describe_numeric_test.py` con datos de cola fuerte | `outlier_pct` ≤ 100 y coherente con n_outliers/n |
|
||||||
|
| Edge: discreta ordinal | unit | `detect_distribution_type_test.py` con 6 valores discretos | NO etiqueta "normal-ish" |
|
||||||
|
| Error: input vacío/no numérico | unit | `describe_numeric([])` | claves None, sin crash (contrato actual preservado) |
|
||||||
|
| Mecánica | — | `./fn run describe_numeric_py_datascience`, `./fn run render_eda_markdown_py_datascience` | tests verdes; `fn index` limpio |
|
||||||
|
|
||||||
|
Re-correr el benchmark sobre wine-red y titanic y confirmar que ningún `outlier_pct` supera 100%.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Issue derivado de `temp/eda_benchmark/EDA_ISSUES.md` (consolidación del benchmark). H1 es el fix de
|
||||||
|
mayor ratio impacto/esfuerzo del lote (una línea elimina los números imposibles que más minan la
|
||||||
|
confianza del report). Hermanos: 0174 (series), 0175 (relational), 0176 (render), 0177 (tipos).
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
---
|
||||||
|
id: "0174"
|
||||||
|
title: "EDA series temporales: período estacional roto + correlación de niveles + to_returns ciego"
|
||||||
|
status: pendiente
|
||||||
|
type: bugfix
|
||||||
|
domain:
|
||||||
|
- registry-quality
|
||||||
|
scope: registry-only
|
||||||
|
priority: alta
|
||||||
|
depends: []
|
||||||
|
blocks: []
|
||||||
|
related: ["0173", "0175", "0176", "0177"]
|
||||||
|
created: 2026-06-29
|
||||||
|
updated: 2026-06-29
|
||||||
|
tags: [eda, datascience, stl_decompose, profile_table, to_returns, series, benchmark]
|
||||||
|
---
|
||||||
|
# 0174 — EDA series temporales: período estacional + correlación de niveles
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
El benchmark `/eda` (29/06/2026, `temp/eda_benchmark/EVALUATION.md`) confirmó que la
|
||||||
|
estacionariedad (ADF+KPSS), la autocorrelación (Ljung-Box) y el aviso de espuriedad
|
||||||
|
Granger-Newbold están **bien** (verificados a mano con `statsmodels`). Pero el **detector de
|
||||||
|
período estacional está roto**, lo que produce falsos negativos de estacionalidad, y la
|
||||||
|
correlación de precios se calcula sobre niveles (espuria para uso financiero).
|
||||||
|
|
||||||
|
Hallazgos cubiertos:
|
||||||
|
|
||||||
|
| Hallazgo | Severidad | Evidencia del benchmark |
|
||||||
|
|---|---|---|
|
||||||
|
| H2 — período estacional sale `2` casi siempre → `seasonal_strength=0` | crítico | seattle `temp_max` reporta "sin estacionalidad" (`period=2`); STL real con `period=365` da fuerza estacional **0.843**. UNRATE (mensual) debería usar 12, no 2 |
|
||||||
|
| H8 — correlación de precios sobre niveles marcada `sig=sí` | medio-alto | aapl/btc `Close–Open=0.998 sig=sí`: espuria por construcción (niveles autocorrelados no estacionarios) |
|
||||||
|
| H13 — `to_returns` sugerido ciegamente a temperatura (sin sentido físico) | bajo | seattle `temp_max`: "convertir a retornos"; debería ser "diferencias" |
|
||||||
|
|
||||||
|
### Causa raíz H2 (verificada en código, READ-ONLY)
|
||||||
|
|
||||||
|
`python/functions/datascience/stl_decompose.py:34-58` (`_infer_period`) busca el lag entre 2 y
|
||||||
|
`max_period` que maximiza la autocorrelación **cruda** de la serie. En cualquier serie con
|
||||||
|
tendencia (precios, temperatura), la autocorrelación decae monótonamente desde el lag mínimo, así
|
||||||
|
que **el lag 2 casi siempre gana** → `period=2` espurio y un STL con componente estacional que es
|
||||||
|
ruido (`seasonal_strength≈0`). Además, `python/functions/pipelines/profile_table.py:175`
|
||||||
|
(`_build_series_block`) llama `stl_decompose(series_vals)` **sin pasar el período**, pese a que el
|
||||||
|
pipeline ya conoce la columna de orden temporal (`order_col`) y podría derivar la frecuencia.
|
||||||
|
|
||||||
|
## Tareas
|
||||||
|
|
||||||
|
1. **H2 — arreglar la inferencia de período** en `stl_decompose.py:34-58`. Opciones (preferir la
|
||||||
|
robusta): (a) detrend antes de autocorrelar; (b) buscar picos en el periodograma/FFT en vez del
|
||||||
|
primer lag; (c) **derivar el período de la frecuencia del índice datetime** (mensual→12,
|
||||||
|
diario→7 y/o 365) — la señal más fiable.
|
||||||
|
2. **H2 — pasar el período desde el pipeline:** en `profile_table.py:_build_series_block`, cuando
|
||||||
|
exista `order_col` datetime, inferir la frecuencia del índice y pasar `period=` explícito a
|
||||||
|
`stl_decompose`. Si no se puede determinar un período fiable, que `stl_decompose` **no reporte
|
||||||
|
`seasonal_strength=0`** como conclusión: devolver `note` "período no determinado" (ya hay una
|
||||||
|
rama así en `:139-145`; extenderla a los casos que hoy caen en `period=2`).
|
||||||
|
3. **H8 — correlación sobre retornos para series no estacionarias:** en la sección de correlaciones
|
||||||
|
de `profile_table.py:346-384`, cuando una columna sea una serie no estacionaria de niveles
|
||||||
|
(verdict `non_stationary`/`inconclusive`, ya detectado), correlacionar sobre retornos/diferencias
|
||||||
|
(`to_returns`, ya importado) o marcar esos pares de niveles como "posible espuria" junto a la
|
||||||
|
tabla. El aviso global existe pero está lejos de los números.
|
||||||
|
4. **H13 — retornos vs diferencias por semántica:** en `profile_table.py:189` / `to_returns.py`,
|
||||||
|
elegir "retornos" (financiero, estrictamente positivo multiplicativo) vs "diferencias" (físico,
|
||||||
|
aditivo) según la naturaleza, o usar "diferencias" por defecto cuando no haya señal financiera.
|
||||||
|
5. Tests: `stl_decompose_test.py` (serie sintética mensual con estacionalidad anual → período
|
||||||
|
correcto y `seasonal_strength` alta; serie con tendencia sin estacionalidad → nota, no
|
||||||
|
`period=2`); cobertura de `_build_series_block` con `order_col` datetime.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Golden: estacionalidad anual | e2e | re-correr `profile_table` con `run_series=True` sobre seattle `temp_max` | `seasonal_strength ≈ 0.84` con período ≈ 365 (NO "sin estacionalidad", NO `period=2`) |
|
||||||
|
| Edge: serie mensual | unit | `stl_decompose_test.py` serie mensual sintética con ciclo 12 | período inferido 12 y fuerza estacional alta |
|
||||||
|
| Edge: sin estacionalidad | unit | `stl_decompose_test.py` serie con solo tendencia | `note` "período no determinado", NO `seasonal_strength=0` como conclusión |
|
||||||
|
| Error: serie corta | unit | `stl_decompose([...]<2*period)` | nota "serie corta", sin crash (contrato actual) |
|
||||||
|
| H8 | e2e | re-correr `profile_table` sobre aapl/btc | pares de niveles no estacionarios marcados como posible espuria o correlación sobre retornos |
|
||||||
|
| Mecánica | — | `./fn run stl_decompose_py_datascience`; `fn index` | tests verdes; índice limpio |
|
||||||
|
|
||||||
|
Re-correr el benchmark sobre seattle, fred-unrate, aapl y btc y confirmar que la estacionalidad se
|
||||||
|
detecta donde existe y no se inventa donde no.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Issue derivado de `temp/eda_benchmark/EDA_ISSUES.md`. H2 es el segundo bloqueante de fiabilidad: un
|
||||||
|
"sin estacionalidad" donde la hay es un falso negativo que un decisor creería. La estacionariedad ya
|
||||||
|
funciona — no tocarla. Hermanos: 0173, 0175, 0176, 0177.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
id: "0175"
|
||||||
|
title: "EDA relational: precisión de FK inference (falsos positivos) + filtrar VIEWs + test ATTACH"
|
||||||
|
status: pendiente
|
||||||
|
type: bugfix
|
||||||
|
domain:
|
||||||
|
- registry-quality
|
||||||
|
scope: registry-only
|
||||||
|
priority: alta
|
||||||
|
depends: []
|
||||||
|
blocks: []
|
||||||
|
related: ["0173", "0174", "0176", "0177"]
|
||||||
|
created: 2026-06-29
|
||||||
|
updated: 2026-06-29
|
||||||
|
tags: [eda, datascience, infer_fk_containment_duckdb, build_join_graph, profile_database, duckdb, benchmark]
|
||||||
|
---
|
||||||
|
# 0175 — EDA relational: precisión de FK inference + filtrar VIEWs
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
El benchmark `/eda` (29/06/2026, `temp/eda_benchmark/EVALUATION.md`) confirmó que la inferencia de
|
||||||
|
claves foráneas a nivel de base es **inútil por falsos positivos masivos** y que las VISTAS se
|
||||||
|
perfilan como tablas base. El join graph resultante necesita filtrado manual para ser legible.
|
||||||
|
|
||||||
|
Hallazgos cubiertos:
|
||||||
|
|
||||||
|
| Hallazgo | Severidad | Evidencia del benchmark |
|
||||||
|
|---|---|---|
|
||||||
|
| H3 — FK inference por contención: 10-20× falsos positivos | crítico | chinook 111 candidatas vs ~11 reales; sakila 565 vs ~30. Casos absurdos: `InvoiceLine.Quantity→Album.AlbumId`, `Genre.GenreId→{Album,Artist,Customer,…}` |
|
||||||
|
| H5 — VIEWs perfiladas como tablas base | alto | sakila `n_tables=21` incluye 5 VISTAS (`customer_list`, `film_list` 5462 filas, `staff_list`, `sales_by_store`, `sales_by_film_category`) + `film_text` (FTS, 0 filas) |
|
||||||
|
| H10 — coste relacional gastado en computar FK falsas | medio | sakila 31.82s: la mayoría en INTERSECT de los 565 pares candidatos, casi todos falsos |
|
||||||
|
| H14 — bug `sqlite_master does not exist` tras ATTACH (ya parcheado, falta test) | bajo (resuelto) | `_run.log`: `profile_database` falló con `Catalog Error: src.sqlite_master`; re-run posterior `ok` |
|
||||||
|
|
||||||
|
### Causa raíz (verificada en código, READ-ONLY)
|
||||||
|
|
||||||
|
- `python/functions/datascience/infer_fk_containment_duckdb.py:217-285` emite una FK candidata si
|
||||||
|
`inclusion(A⊆B) ≥ min_inclusion` **y** B "parece clave" (unicidad ≥0.95). **No usa el nombre de
|
||||||
|
la columna**, que es la señal más fuerte de FK (`AlbumId→Album.AlbumId`), ni excluye columnas
|
||||||
|
no-clave (cantidades, importes) como ORIGEN. Enteros pequeños (`GenreId` 1..25) están contenidos
|
||||||
|
en casi todo → ruido.
|
||||||
|
- `python/functions/pipelines/profile_database.py:155-159` lista tablas con `duckdb_list_tables`
|
||||||
|
sin filtrar `table_type` → perfila VIEWs y tablas FTS como base (H5), lo que infla el universo de
|
||||||
|
pares y multiplica las FK falsas (relaciona H10).
|
||||||
|
- H10 es el **mismo cambio** que H3: filtrar candidatos por nombre **antes** del INTERSECT reduce
|
||||||
|
pares (más rápido) y falsos positivos (más preciso) a la vez.
|
||||||
|
|
||||||
|
## Tareas
|
||||||
|
|
||||||
|
1. **H3+H10 — señal de nombre en `infer_fk_containment_duckdb.py:217-285`:** antes de lanzar el
|
||||||
|
INTERSECT, exigir coincidencia/patrón de nombre entre origen y destino (`from_col` casa con
|
||||||
|
`to_table`/`to_col`, patrón `<X>Id → <X>.<X>Id`; case-insensitive). Excluir como ORIGEN columnas
|
||||||
|
claramente no-clave (cantidades, importes, flags) por heurística de nombre/tipo. Esto poda el
|
||||||
|
O(tablas²×columnas²) y elimina la mayoría de los falsos positivos. Validar mejor la cardinalidad
|
||||||
|
(los `1:1` imposibles del benchmark).
|
||||||
|
2. **H5 — filtrar VIEWs** antes de perfilar e inferir FK: filtrar `table_type='BASE TABLE'` vía
|
||||||
|
`information_schema.tables` / `duckdb_tables()`. Decidir (a confirmar al implementar) si el filtro
|
||||||
|
va como flag nuevo en `duckdb_list_tables` (infra, reutilizable) o en `profile_database.py` tras
|
||||||
|
listar. Preferir el flag en `duckdb_list_tables` si no rompe consumidores.
|
||||||
|
3. **H3 — propagar al join graph:** verificar que `build_join_graph.py` recibe la lista ya filtrada
|
||||||
|
y que el diagrama Mermaid resultante es legible (sin nodos VIEW ni aristas espurias).
|
||||||
|
4. **H14 — test de regresión:** añadir test (en `profile_database_test.py` o
|
||||||
|
`infer_fk_containment_duckdb_test.py`) que haga `ATTACH` de una base SQLite pequeña en DuckDB y
|
||||||
|
perfile, confirmando que se usa `information_schema`/`duckdb_tables()` y nunca `sqlite_master`.
|
||||||
|
(A confirmar: localizar la función que hace el ATTACH —probablemente `summarize_table_duckdb.py`
|
||||||
|
o una primitiva infra `duckdb_*`— para cubrirla.)
|
||||||
|
5. Tests: casos sintéticos con tablas que tengan columnas tipo `XId` (FK real) y columnas de
|
||||||
|
cantidad contenidas en claves (falso positivo) → confirmar que solo emite las reales.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Golden: FK reales sin ruido | e2e | re-correr `profile_database` sobre chinook | ~11 FK candidatas (no 111); incluyen `Album.ArtistId→Artist.ArtistId`, `Invoice.CustomerId→Customer.CustomerId`; NO incluyen `InvoiceLine.Quantity→Album.AlbumId` |
|
||||||
|
| Edge: VIEWs excluidas | e2e | re-correr `profile_database` sobre sakila | `n_tables` cuenta solo BASE TABLE (sin `customer_list`/`film_list`/…); FK candidatas ≪ 565 |
|
||||||
|
| Edge: cantidad vs clave | unit | `infer_fk_containment_duckdb_test.py` con columna `Quantity` contenida en una clave | NO emite FK desde `Quantity` |
|
||||||
|
| Error: ATTACH SQLite | unit | test de regresión ATTACH SQLite→DuckDB | perfila sin `sqlite_master does not exist`; usa information_schema |
|
||||||
|
| Rendimiento (H10) | e2e | medir duración de `profile_database` sobre sakila | menor que el baseline 31.82s (menos INTERSECT) |
|
||||||
|
| Mecánica | — | `./fn run infer_fk_containment_duckdb_py_datascience`, `./fn run profile_database_py_pipelines`; `fn index` | tests verdes; índice limpio |
|
||||||
|
|
||||||
|
Re-correr el benchmark sobre chinook y sakila y confirmar que las FK reales son distinguibles del
|
||||||
|
ruido y que las VIEWs no se cuentan como tablas.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Issue derivado de `temp/eda_benchmark/EDA_ISSUES.md`. Tres síntomas (H3/H5/H10) con un núcleo común:
|
||||||
|
la capa de inferencia de relaciones inter-tabla. Atacarlos juntos en una rama; filtrar VIEWs reduce
|
||||||
|
el universo de pares y filtrar candidatos por nombre arregla precisión y velocidad a la vez. H14 ya
|
||||||
|
está parcheado en producción; este issue solo añade el test de regresión que faltaba.
|
||||||
|
Hermanos: 0173, 0174, 0176, 0177.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
id: "0176"
|
||||||
|
title: "EDA render: models/series/caveats en markdown+PDF + PDF para profile_database"
|
||||||
|
status: pendiente
|
||||||
|
type: feature
|
||||||
|
domain:
|
||||||
|
- registry-quality
|
||||||
|
scope: registry-only
|
||||||
|
priority: media
|
||||||
|
depends: []
|
||||||
|
blocks: []
|
||||||
|
related: ["0173", "0174", "0175", "0177"]
|
||||||
|
created: 2026-06-29
|
||||||
|
updated: 2026-06-29
|
||||||
|
tags: [eda, datascience, render_eda_markdown, render_eda_pdf, profile_database, pdf, benchmark]
|
||||||
|
---
|
||||||
|
# 0176 — EDA render: models/series/caveats en markdown+PDF + PDF para profile_database
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
El benchmark `/eda` (29/06/2026, `temp/eda_benchmark/EVALUATION.md`) confirmó que la información de
|
||||||
|
modelos (PCA/KMeans) está completa en el JSON pero **no llega legible a ningún formato**, y que el
|
||||||
|
análisis relacional no tiene salida móvil (PDF). El tercio final del PDF queda ilegible.
|
||||||
|
|
||||||
|
Hallazgos cubiertos:
|
||||||
|
|
||||||
|
| Hallazgo | Severidad | Evidencia del benchmark |
|
||||||
|
|---|---|---|
|
||||||
|
| H4 — `models` omitido en Markdown; `models`/`series`/`caveats` como dict crudo truncado en PDF | alto | wine-red `.md` (12 numéricas, PCA valioso) → cero menciones de models. PDF aapl: `- pca: {'n_components': 2, …` cortado a media línea |
|
||||||
|
| H9 — `profile_database` no genera PDF | medio | chinook y sakila con `pdf=null`; análisis relacional solo en Markdown |
|
||||||
|
|
||||||
|
### Causa raíz (verificada en código, READ-ONLY)
|
||||||
|
|
||||||
|
- `python/functions/datascience/render_eda_markdown.py`: tiene formatters para `series` (`:337`) y
|
||||||
|
`caveats` (`:407`), pero **no para `models`** → el bloque PCA/KMeans nunca se renderiza en MD.
|
||||||
|
- `python/functions/datascience/render_eda_pdf.py:50-55`: `_KNOWN_TOP_KEYS` **no incluye** `models`,
|
||||||
|
`series` ni `caveats`, así que caen en `_generic_pages` (`:479-495`) → `_wrap_value` →
|
||||||
|
`str(dict)` truncado a 60-64 chars. Por eso esas tres secciones salen como dict crudo en el PDF.
|
||||||
|
- `python/functions/pipelines/profile_database.py:205-218`: solo escribe MD+JSON, nunca invoca
|
||||||
|
`render_eda_pdf`; no tiene param `emit_pdf`.
|
||||||
|
|
||||||
|
## Tareas
|
||||||
|
|
||||||
|
1. **H4 — markdown:** añadir una sección `## Modelos` (PCA/KMeans/outliers/normalidad) a
|
||||||
|
`render_eda_markdown.py`, formateando `models.pca` (varianza explicada, top loadings, acumulada),
|
||||||
|
`models.kmeans` (best_k, silhouette, tamaños de cluster) y `models.outliers` como tablas legibles.
|
||||||
|
2. **H4 — PDF:** en `render_eda_pdf.py`, añadir builders dedicados para `models`, `series` y
|
||||||
|
`caveats` (tablas/listas, no `str(dict)`) y registrarlos en `_KNOWN_TOP_KEYS` + en la lista
|
||||||
|
`builders` (`:595-604`) para sacarlos del volcado genérico. Mantener el contrato dict-no-throw
|
||||||
|
(una sección que falle no aborta el PDF).
|
||||||
|
3. **Unificar renderers:** asegurar que MD y PDF cubren el mismo conjunto de secciones (`models`,
|
||||||
|
`series`, `caveats`) para que no diverjan otra vez.
|
||||||
|
4. **H9 — PDF relational:** añadir un renderer PDF DB-level (puede ser una variante en
|
||||||
|
`render_eda_pdf.py` o una función nueva) con: portada de la base, resumen de tablas, join graph
|
||||||
|
filtrado (tras 0175), y FK candidatas. Añadir param `emit_pdf` a `profile_database.py` que lo
|
||||||
|
invoque y devuelva `pdf_path`.
|
||||||
|
5. Tests: `render_eda_markdown_test.py` (perfil con `models` → aparece sección Modelos);
|
||||||
|
`render_eda_pdf_test.py` (perfil con `models`/`series`/`caveats` → NO aparecen como `str(dict)`;
|
||||||
|
`n_pages` incrementa); test de `profile_database(emit_pdf=True)` → `pdf_path` no nulo, PDF válido.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Golden: models en MD | e2e | re-correr `profile_table(run_models=True)` sobre wine-red y leer el `.md` | sección `## Modelos` con PCA (varianza explicada) y KMeans (silhouette) legibles |
|
||||||
|
| Golden: PDF legible | e2e | re-correr sobre aapl y `pdftotext` del PDF | `models`/`series`/`caveats` como tablas, sin `{'n_components': 2, …` truncado |
|
||||||
|
| Edge: perfil sin models | unit | `render_eda_markdown_test.py`/`render_eda_pdf_test.py` con `models=None` | sección omitida limpiamente, sin crash |
|
||||||
|
| Edge: PDF relational | e2e | `profile_database(emit_pdf=True)` sobre chinook | `pdf_path` no nulo; PDF con resumen de tablas + join graph |
|
||||||
|
| Error: sección corrupta | unit | `render_eda_pdf` con una sección con tipo inesperado | esa sección se omite con nota; PDF sigue válido (≥1 página) |
|
||||||
|
| Mecánica | — | `./fn run render_eda_markdown_py_datascience`, `./fn run render_eda_pdf_py_datascience`; `fn index` | tests verdes; índice limpio |
|
||||||
|
|
||||||
|
Re-correr el benchmark sobre un single-table con modelos (wine-red) y sobre un relational (chinook)
|
||||||
|
y confirmar que models llega al MD y al PDF, y que `profile_database` emite PDF.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Issue derivado de `temp/eda_benchmark/EDA_ISSUES.md`. Tipo `feature` porque, además de arreglar el
|
||||||
|
volcado crudo (H4, fix), añade un renderer PDF relational nuevo (H9). La información ya existe en el
|
||||||
|
JSON; este issue solo la hace legible en las dos salidas pensadas para humanos. Hermanos: 0173, 0174,
|
||||||
|
0175, 0177.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
---
|
||||||
|
id: "0177"
|
||||||
|
title: "EDA tipos: id secuencial fuera de correlación/PCA + η² espurio por cardinalidad + re-expresión no-continuas"
|
||||||
|
status: pendiente
|
||||||
|
type: bugfix
|
||||||
|
domain:
|
||||||
|
- registry-quality
|
||||||
|
scope: registry-only
|
||||||
|
priority: media
|
||||||
|
depends: []
|
||||||
|
blocks: []
|
||||||
|
related: ["0173", "0174", "0175", "0176"]
|
||||||
|
created: 2026-06-29
|
||||||
|
updated: 2026-06-29
|
||||||
|
tags: [eda, datascience, profile_table, association_matrix, correlation_ratio, run_eda_models, suggest_reexpression, benchmark]
|
||||||
|
---
|
||||||
|
# 0177 — EDA tipos: id secuencial fuera de correlación/PCA + η² espurio
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
El benchmark `/eda` (29/06/2026, `temp/eda_benchmark/EVALUATION.md`) **refutó** el riesgo temido
|
||||||
|
(que el EDA excluyera columnas financieras `Open/Close/High/Low/Volume` por marcarlas id-like: NO
|
||||||
|
ocurre, aparecen en todo). Pero detectó el **problema inverso**: el flag `possible_id` es cosmético
|
||||||
|
y no excluye lo que sí debería (índices secuenciales), y la razón de correlación η² da artefactos
|
||||||
|
≈1 por cardinalidad.
|
||||||
|
|
||||||
|
Hallazgos cubiertos:
|
||||||
|
|
||||||
|
| Hallazgo | Severidad | Evidencia del benchmark |
|
||||||
|
|---|---|---|
|
||||||
|
| H7 — `possible_id` no excluye id secuencial (`PassengerId`) de correlación ni de PCA/KMeans | medio-alto | titanic `PassengerId–Cabin` η²=0.897 `sig=sí`; `models.pca.n_features=7` incluye `PassengerId`, `Survived`, `Pclass` |
|
||||||
|
| H6 — `correlation_ratio` (η²) ≈1 espurio cuando la categórica tiene cardinalidad ≈ n | alto | titanic `Ticket–Fare=1 sig=sí` (`Ticket` 681 distintos/891); aapl/btc/seattle/fred `Date–* =1` |
|
||||||
|
| H12 — `suggest_reexpression` sugiere fila para binarias/ordinales/ids (aunque sea `none`) | bajo | titanic `Survived` (0/1), `Pclass` (ordinal), `PassengerId` (id) listadas |
|
||||||
|
|
||||||
|
### Causa raíz (verificada en código, READ-ONLY)
|
||||||
|
|
||||||
|
- `python/functions/pipelines/profile_table.py:356-361` (`_skip_for_assoc`) excluye de la matriz de
|
||||||
|
asociación las columnas id-like **categóricas/text** (`possible_id`/`high_cardinality`), pero **no**
|
||||||
|
excluye numéricas secuenciales (`PassengerId` es numérica con `possible_id`) ni columnas datetime.
|
||||||
|
El `assoc_input` resultante se pasa tal cual a `run_eda_models` (`:391`), así que el id secuencial,
|
||||||
|
el target binario y el ordinal entran como features de PCA/KMeans.
|
||||||
|
- H6: `correlation_ratio.py` calcula η² sin guard de cardinalidad; cuando cada grupo tiene ~1
|
||||||
|
observación (categórica de cardinalidad ≈ n), la varianza intra-grupo ≈0 → η²≈1 trivialmente. El
|
||||||
|
FDR no protege (artefacto determinista, no azar).
|
||||||
|
- H12: `suggest_reexpression` (llamado en `profile_table.py:300` para toda numérica) no salta
|
||||||
|
binarias/ordinales/ids.
|
||||||
|
|
||||||
|
## Tareas
|
||||||
|
|
||||||
|
1. **H7 — distinguir id secuencial de float continuo:** en la detección de tipos
|
||||||
|
(`summarize_table_duckdb.py` / lógica de `possible_id`) o en `profile_table.py`, marcar
|
||||||
|
"índice entero secuencial/monótono" distinto de "float continuo de alta cardinalidad". El primero
|
||||||
|
se excluye de correlación y de PCA/KMeans; el segundo se mantiene (precios). **Nunca** excluir
|
||||||
|
floats continuos.
|
||||||
|
2. **H7 — excluir no-features de los modelos:** en `_skip_for_assoc` (y/o en `run_eda_models.py`)
|
||||||
|
excluir de PCA/KMeans los ids secuenciales, binarias, ordinales y el target evidente, además de
|
||||||
|
las categóricas id-like que ya se excluyen.
|
||||||
|
3. **H6 — guard de cardinalidad en η²:** en `correlation_ratio.py` (y/o al construir los pares en
|
||||||
|
`association_matrix.py`/`profile_table.py`), no computar η² si la categórica tiene cardinalidad
|
||||||
|
cercana a `n` o tamaño de grupo medio ≈1; excluir columnas datetime/id de los pares categóricos.
|
||||||
|
4. **H12 — saltar no-continuas en re-expresión:** en `suggest_reexpression.py` (o en la llamada de
|
||||||
|
`profile_table.py:300`), no emitir fila de re-expresión para binarias/ordinales/ids.
|
||||||
|
5. Tests: `correlation_ratio_test.py` (categórica cardinalidad≈n → no η²≈1 espurio);
|
||||||
|
`run_eda_models_test.py` (id secuencial/target/ordinal no entran como features);
|
||||||
|
`suggest_reexpression_test.py` (binaria/ordinal/id → sin sugerencia).
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Golden: id secuencial fuera | e2e | re-correr `profile_table(run_models=True)` sobre titanic | `PassengerId` NO aparece en correlaciones ni en `models.pca.features`; floats continuos (precios en aapl/btc) SÍ se conservan |
|
||||||
|
| Golden: η² sin artefacto | e2e | re-correr sobre titanic | `Ticket–Fare` y `Date–*` NO aparecen como par fuerte η²=1 |
|
||||||
|
| Edge: float continuo | unit | `correlation_ratio_test.py` / detección de tipos | columna float de alta cardinalidad (precio) se mantiene en correlación |
|
||||||
|
| Edge: re-expresión | unit | `suggest_reexpression_test.py` con binaria/ordinal/id | sin fila de re-expresión |
|
||||||
|
| Error: solo numéricas | unit | `run_eda_models` con assoc_input vacío tras filtrar | sin crash; bloque models coherente |
|
||||||
|
| Mecánica | — | `./fn run correlation_ratio_py_datascience`, `./fn run run_eda_models_py_datascience`, `./fn run suggest_reexpression_py_datascience`; `fn index` | tests verdes; índice limpio |
|
||||||
|
|
||||||
|
Re-correr el benchmark sobre titanic (id secuencial + η² espurio) y sobre aapl/btc (confirmar que
|
||||||
|
los floats financieros NO se excluyen) y verificar ambos comportamientos.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Issue derivado de `temp/eda_benchmark/EDA_ISSUES.md`. El warning "grave" del benchmark (excluir
|
||||||
|
columnas financieras) quedó **refutado**: este issue arregla el problema inverso real (no excluir
|
||||||
|
ids secuenciales) sin tocar el tratamiento correcto de los floats continuos. Hermanos: 0173, 0174,
|
||||||
|
0175, 0176.
|
||||||
@@ -14,7 +14,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
|||||||
|
|
||||||
| Grupo | N | Que cubre |
|
| Grupo | N | Que cubre |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [gamedev-2d](gamedev-2d.md) | 36 | Assets 2D para Godot via ComfyUI: 31 builders de workflow (pixelart/seamless/iso/sprite/topdown/card/enemy/prop/structure/foliage/trap/projectile/decal/particle/rune/weather/badge/skill-tree/dialogue/icon/portrait/VFX...) + 5 de apoyo: post-proceso (pixelize, luma->alpha) + puente de assets a Godot 4 (.import + reimport headless). Tag canonico `gamedev-2d` (antes `gamedev`, ya unificado) |
|
| [gamedev-2d](gamedev-2d.md) | 47 | Assets 2D para Godot via ComfyUI: 36 builders de workflow (31 de generación desde texto: pixelart/seamless/iso/sprite/topdown/card/enemy/prop/structure/foliage/trap/projectile/decal/particle/rune/weather/badge/skill-tree/dialogue/icon/portrait/VFX... + 5 de transformación desde imagen: asset_variant/sprite_from_sketch/inpaint_asset/outpaint_asset/directional_sprite) + 11 de apoyo: post-proceso (pixelize, luma->alpha, flatten_alpha), puente de assets a Godot 4 (.import + reimport headless), style presets (get/apply_gamedev_style_preset) y pipelines one-shot (asset_pack/character_set/styled_asset). Tag canonico `gamedev-2d` (antes `gamedev`, ya unificado) |
|
||||||
| [gamedev-engine](gamedev-engine.md) | 8 | Runtime de juego C++ multiplataforma (PC + WebAssembly): SDL3 + sokol_gfx + miniaudio. Game loop fixed-timestep, camara 2D, input unificado (teclado/gamepad/touch), sprite batch, setup de render/audio y build a wasm. Grupo hermano de `gamedev-2d` (este ejecuta el juego, aquel genera los assets) |
|
| [gamedev-engine](gamedev-engine.md) | 8 | Runtime de juego C++ multiplataforma (PC + WebAssembly): SDL3 + sokol_gfx + miniaudio. Game loop fixed-timestep, camara 2D, input unificado (teclado/gamepad/touch), sprite batch, setup de render/audio y build a wasm. Grupo hermano de `gamedev-2d` (este ejecuta el juego, aquel genera los assets) |
|
||||||
| [registry](registry.md) | 17 | Auditoria y monitorizacion del propio registry: copied-code, uses-functions, unused, proposals, telemetria |
|
| [registry](registry.md) | 17 | Auditoria y monitorizacion del propio registry: copied-code, uses-functions, unused, proposals, telemetria |
|
||||||
| [systemd](systemd.md) | 14 | Generar, instalar, restart y status de unit files systemd via SSH (deploys a VPS) |
|
| [systemd](systemd.md) | 14 | Generar, instalar, restart y status de unit files systemd via SSH (deploys a VPS) |
|
||||||
@@ -72,8 +72,9 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
|||||||
| [seo](seo.md) | 3 | SEO orientado a datos sobre Google Search Console: autenticar con service account (`gsc_auth`), extraer Search Analytics paginado (`pull_gsc_search_analytics`) y el pipeline de ingesta a DuckDB + espejo Postgres para Metabase (`ingest_gsc_search_analytics`). Cadena de ingesta del proyecto `seo_analytics`; alimenta dashboards de striking distance, CTR opportunities y content decay |
|
| [seo](seo.md) | 3 | SEO orientado a datos sobre Google Search Console: autenticar con service account (`gsc_auth`), extraer Search Analytics paginado (`pull_gsc_search_analytics`) y el pipeline de ingesta a DuckDB + espejo Postgres para Metabase (`ingest_gsc_search_analytics`). Cadena de ingesta del proyecto `seo_analytics`; alimenta dashboards de striking distance, CTR opportunities y content decay |
|
||||||
| [local-hub](local-hub.md) | 4 | Exponer los procesos locales como subdominios `*.localhost` (via Caddy, sin DNS) y reunirlos en una pantalla principal Glance con estado en vivo, refrescada a diario por dag_engine. Descubre servicios (manifiesto + registry), renderiza Caddyfile + config Glance (puras), y el pipeline `refresh_local_hub` regenera+recarga. Fuente de verdad: `apps/local_hub/local_services.yaml` |
|
| [local-hub](local-hub.md) | 4 | Exponer los procesos locales como subdominios `*.localhost` (via Caddy, sin DNS) y reunirlos en una pantalla principal Glance con estado en vivo, refrescada a diario por dag_engine. Descubre servicios (manifiesto + registry), renderiza Caddyfile + config Glance (puras), y el pipeline `refresh_local_hub` regenera+recarga. Fuente de verdad: `apps/local_hub/local_services.yaml` |
|
||||||
| [comfyui-judge](comfyui-judge.md) | 4 | Panel multi-juez de calidad de imagen: estético LAION-V2 (`comfyui_score_aesthetic`, 0-10) + fidelidad CLIP prompt↔imagen (`comfyui_score_clip_alignment`, 0-1) + crítica LLM-vision (`comfyui_critique_image_llm`, good/bad). Agregados por voto mayoría en `comfyui_judge_image`. Gate objetivo para tests/DoD y el bucle de mejora de skills ComfyUI; degrada con gracia si un juez cae. Jueces estético/fidelidad por subproceso al venv ComfyUI (torch+open_clip), crítico via claude-direct |
|
| [comfyui-judge](comfyui-judge.md) | 4 | Panel multi-juez de calidad de imagen: estético LAION-V2 (`comfyui_score_aesthetic`, 0-10) + fidelidad CLIP prompt↔imagen (`comfyui_score_clip_alignment`, 0-1) + crítica LLM-vision (`comfyui_critique_image_llm`, good/bad). Agregados por voto mayoría en `comfyui_judge_image`. Gate objetivo para tests/DoD y el bucle de mejora de skills ComfyUI; degrada con gracia si un juez cae. Jueces estético/fidelidad por subproceso al venv ComfyUI (torch+open_clip), crítico via claude-direct |
|
||||||
| [comfyui](comfyui.md) | 29 | Controlar ComfyUI (Stable Diffusion por grafos) de dos formas: por API HTTP (build_txt2img_workflow puro → submit → wait → object_info; download_model con validación Civitai/HF) y por la UI web vía CDP sobre la pestaña abierta (load_workflow_ui, set_node_widget_ui para tunear prompt/steps/seed en vivo, queue_prompt_ui = botón Queue Prompt, export_workflow_ui, refresh_nodes_ui). El API format es el puente entre ambos caminos. Las funciones de UI componen `cdp_eval`. Incluye imagen→3D nativo (Hunyuan3D-2, tag `img-to-3d`): build_image_to_3d_workflow + fetch_output_mesh + install_3d_model + pipeline image_to_3d_oneshot |
|
| [comfyui](comfyui.md) | 126 | Controlar ComfyUI (Stable Diffusion por grafos) de dos formas: por API HTTP (build_txt2img_workflow puro → submit → wait → object_info; download_model con validación Civitai/HF) y por la UI web vía CDP sobre la pestaña abierta (load_workflow_ui, set_node_widget_ui para tunear prompt/steps/seed en vivo, queue_prompt_ui = botón Queue Prompt, export_workflow_ui, refresh_nodes_ui). El API format es el puente entre ambos caminos. Las funciones de UI componen `cdp_eval`. Cubre txt2img/img2img/inpaint/controlnet/sdxl-refiner/flux, upscale + hires-fix + facedetailer, vídeo (LTX/Wan/SVD), audio (ACE-Step), imagen→3D nativo (Hunyuan3D-2) + post-proceso de malla, templates oficiales, civitai harvest y control de cola. N = funciones con tag `comfyui` (incluye los sub-grupos `comfyui-skill`/`comfyui-styles` y 45 de `gamedev-2d`); las páginas madre de cada sub-grupo desglosan su parte |
|
||||||
| [comfyui-skill](comfyui-skill.md) | 11 | Tratar una configuración de generación ComfyUI como una skill: receta versionada en disco (checkpoint + LoRAs + params + scaffold de prompt + post-proceso) que se compila a un workflow cambiando solo el subject. Save/load/list de recetas, bucle de mejora genera→juzga→bump con gate objetivo (el score del juez decide qué se promueve), export de la skill a grafo cargable en el navegador, y cosecha de Civitai (extract_recipe_from_png + harvest oneshot) que destila el workflow embebido de una imagen pública en una skill candidata |
|
| [comfyui-skill](comfyui-skill.md) | 17 | Tratar una configuración de generación ComfyUI como una skill: receta versionada en disco (checkpoint + LoRAs + params + scaffold de prompt + post-proceso) que se compila a un workflow cambiando solo el subject. Save/load/list de recetas, bucle de mejora genera→juzga→bump con gate objetivo (el score del juez decide qué se promueve), export de la skill a grafo cargable en el navegador, y cosecha de Civitai (extract_recipe_from_png + harvest oneshot) que destila el workflow embebido de una imagen pública en una skill candidata |
|
||||||
|
| [comfyui-styles](comfyui-styles.md) | 5 | Capa de estilo reutilizable sobre los builders ComfyUI. Catálogo WAS (tag `comfyui-styles`): `curated_styles_catalog` (~190 estilos), `generate_styles_llm` (genera estilos por LLM via ask_llm), `append_styles` (merge+dedup+backup sobre el styles.json del selector WAS). Style presets gamedev (tag `gamedev-2d`): `get_gamedev_style_preset` (gameboy/ghibli/pixel-art-retro como datos puros) + `apply_style_preset` (preset+subject → kwargs de un builder gamedev-2d). El estilo se trata como dato curado, no como prompt repetido |
|
||||||
| [comfyui-overview](comfyui-overview.md) | — | Mapa cross-grupo de las capacidades de generación ComfyUI (txt2img, img2img/inpaint, controlnet, skills/multiestilo-LoRA, video, upscale/detail, 3D, juez, operación): cada capacidad → builders/pipelines del registry + grafos UI + skills que la cubren. Índice de entrada al stack ComfyUI; las firmas y gotchas viven en `comfyui.md`/`comfyui-skill.md`/`comfyui-judge.md`. Catálogo navegable de los grafos en disco (subcarpetas por capacidad) en `~/ComfyUI/CAPABILITIES.md` |
|
| [comfyui-overview](comfyui-overview.md) | — | Mapa cross-grupo de las capacidades de generación ComfyUI (txt2img, img2img/inpaint, controlnet, skills/multiestilo-LoRA, video, upscale/detail, 3D, juez, operación): cada capacidad → builders/pipelines del registry + grafos UI + skills que la cubren. Índice de entrada al stack ComfyUI; las firmas y gotchas viven en `comfyui.md`/`comfyui-skill.md`/`comfyui-judge.md`. Catálogo navegable de los grafos en disco (subcarpetas por capacidad) en `~/ComfyUI/CAPABILITIES.md` |
|
||||||
|
|
||||||
## Como anadir grupo
|
## Como anadir grupo
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ Las tres páginas madre detalladas siguen siendo la fuente de verdad por grupo:
|
|||||||
|
|
||||||
- [comfyui.md](comfyui.md) — grupo `comfyui`: builders de workflow, ejecución HTTP, UI vía CDP, I/O.
|
- [comfyui.md](comfyui.md) — grupo `comfyui`: builders de workflow, ejecución HTTP, UI vía CDP, I/O.
|
||||||
- [comfyui-skill.md](comfyui-skill.md) — grupo `comfyui-skill`: recetas de estilo versionadas.
|
- [comfyui-skill.md](comfyui-skill.md) — grupo `comfyui-skill`: recetas de estilo versionadas.
|
||||||
|
- [comfyui-styles.md](comfyui-styles.md) — grupo `comfyui-styles`: presets + catálogo de estilo (selector WAS).
|
||||||
- [comfyui-judge.md](comfyui-judge.md) — grupo `comfyui-judge`: panel multi-juez de calidad.
|
- [comfyui-judge.md](comfyui-judge.md) — grupo `comfyui-judge`: panel multi-juez de calidad.
|
||||||
|
- [gamedev-2d.md](gamedev-2d.md) — grupo `gamedev-2d`: 47 builders de assets 2D para Godot (45 también `comfyui`).
|
||||||
|
|
||||||
El catálogo navegable con los grafos concretos en disco (subcarpetas por capacidad, cómo cargar
|
El catálogo navegable con los grafos concretos en disco (subcarpetas por capacidad, cómo cargar
|
||||||
cada uno) vive **fuera del repo**, junto a la instalación: `~/ComfyUI/CAPABILITIES.md`. Este doc es
|
cada uno) vive **fuera del repo**, junto a la instalación: `~/ComfyUI/CAPABILITIES.md`. Este doc es
|
||||||
@@ -25,7 +27,9 @@ Filtros MCP: `mcp__registry__fn_search query="" tag="comfyui"` (y `tag="comfyui-
|
|||||||
| 02 | **img2img / inpaint** | imagen → imagen, regenerar zona enmascarada | `build_img2img`, `build_inpaint` | ✅ | — |
|
| 02 | **img2img / inpaint** | imagen → imagen, regenerar zona enmascarada | `build_img2img`, `build_inpaint` | ✅ | — |
|
||||||
| 03 | **controlnet** | generación guiada por mapa (depth/pose/canny) | `build_controlnet` | ✅ | — |
|
| 03 | **controlnet** | generación guiada por mapa (depth/pose/canny) | `build_controlnet` | ✅ | — |
|
||||||
| 04 | **skills (multiestilo/LoRA)** | recetas de estilo reproducibles con `{subject}` | `build_skill_workflow`, `inject_lora`, `generate_with_skill_oneshot`, `harvest_civitai_skill_oneshot` | ✅ ×2 | ✅ ×2 |
|
| 04 | **skills (multiestilo/LoRA)** | recetas de estilo reproducibles con `{subject}` | `build_skill_workflow`, `inject_lora`, `generate_with_skill_oneshot`, `harvest_civitai_skill_oneshot` | ✅ ×2 | ✅ ×2 |
|
||||||
|
| 04b | **styles (presets/catálogo)** | estilo reutilizable: catálogo WAS + presets gamedev | `curated_styles_catalog`, `generate_styles_llm`, `append_styles`, `get_gamedev_style_preset`, `apply_style_preset` | — | — |
|
||||||
| 05 | **video** | imagen/texto → vídeo (SVD, LTX, Wan) | `build_img2vid`, `build_video` | ✅ | — |
|
| 05 | **video** | imagen/texto → vídeo (SVD, LTX, Wan) | `build_img2vid`, `build_video` | ✅ | — |
|
||||||
|
| 05b | **audio** | texto → música/SFX/voz (ACE-Step) | `build_audio_workflow`, `fetch_output_audio` | — | — |
|
||||||
| 06 | **upscale / detail** | ampliar y recuperar detalle (ESRGAN, hires-fix, FaceDetailer) | `build_upscale`, `build_hires_fix`, `inject_hires_fix`, `build_facedetailer` | — | — |
|
| 06 | **upscale / detail** | ampliar y recuperar detalle (ESRGAN, hires-fix, FaceDetailer) | `build_upscale`, `build_hires_fix`, `inject_hires_fix`, `build_facedetailer` | — | — |
|
||||||
| 07 | **3D** | imagen/texto → malla 3D (Hunyuan3D) + limpieza | `build_image_to_3d`, `build_textured_3d_multiview`, `image_to_3d_oneshot`, `text_to_3d_oneshot`, `mesh_cleanup_oneshot` | — | — |
|
| 07 | **3D** | imagen/texto → malla 3D (Hunyuan3D) + limpieza | `build_image_to_3d`, `build_textured_3d_multiview`, `image_to_3d_oneshot`, `text_to_3d_oneshot`, `mesh_cleanup_oneshot` | — | — |
|
||||||
| 08 | **juez / calidad** | puntuar lo generado (gate de DoD y bucle de mejora) | `judge_image`, `score_aesthetic`, `score_clip_alignment`, `critique_image_llm` | — | — |
|
| 08 | **juez / calidad** | puntuar lo generado (gate de DoD y bucle de mejora) | `judge_image`, `score_aesthetic`, `score_clip_alignment`, `critique_image_llm` | — | — |
|
||||||
@@ -67,11 +71,25 @@ sus IDs reales cuando se ejecute `fn index`.
|
|||||||
- `comfyui_extract_recipe_from_png_py_ml` — destila un PNG de Civitai en receta candidata.
|
- `comfyui_extract_recipe_from_png_py_ml` — destila un PNG de Civitai en receta candidata.
|
||||||
- CRUD + telemetría: `comfyui_list_skills_py_ml`, `comfyui_load_skill_py_ml`, `comfyui_save_skill_py_ml`, `comfyui_update_skill_score_py_ml`, `comfyui_bump_skill_version_py_ml`.
|
- CRUD + telemetría: `comfyui_list_skills_py_ml`, `comfyui_load_skill_py_ml`, `comfyui_save_skill_py_ml`, `comfyui_update_skill_score_py_ml`, `comfyui_bump_skill_version_py_ml`.
|
||||||
|
|
||||||
|
### 04b · styles (presets / catálogo)
|
||||||
|
|
||||||
|
Página madre: [comfyui-styles.md](comfyui-styles.md). Estilo reutilizable como dato, no como prompt repetido.
|
||||||
|
|
||||||
|
- `comfyui_curated_styles_catalog_py_ml` (pura) — catálogo curado (~190 estilos) para el selector WAS.
|
||||||
|
- `comfyui_generate_styles_llm_py_ml` (impura) — genera N estilos de una categoría vía `ask_llm`.
|
||||||
|
- `comfyui_append_styles_py_ml` (impura) — fusiona estilos sobre el `styles.json` WAS (merge+dedup+backup).
|
||||||
|
- `comfyui_get_gamedev_style_preset_py_ml` (pura) — receta de *style preset* gamedev (gameboy/ghibli/pixel-art-retro).
|
||||||
|
- `comfyui_apply_style_preset_py_ml` (pura) — traduce un preset + subject a los kwargs de un builder gamedev-2d.
|
||||||
|
|
||||||
### 05 · video
|
### 05 · video
|
||||||
|
|
||||||
- `comfyui_build_img2vid_workflow_py_ml` (pura) — SVD: condicionamiento por CLIP_VISION (sin prompt de texto).
|
- `comfyui_build_img2vid_workflow_py_ml` (pura) — SVD: condicionamiento por CLIP_VISION (sin prompt de texto).
|
||||||
- `comfyui_build_video_workflow_py_ml` (pura) — txt2video LTX-Video 2B o Wan2.1 1.3B.
|
- `comfyui_build_video_workflow_py_ml` (pura) — txt2video LTX-Video 2B o Wan2.1 1.3B.
|
||||||
|
|
||||||
|
### 05b · audio
|
||||||
|
|
||||||
|
- `comfyui_build_audio_workflow_py_ml` (pura) — txt2audio ACE-Step: TextEncodeAceStepAudio (tags + lyrics) → EmptyAceStepLatentAudio → KSampler → VAEDecodeAudio → SaveAudio(.flac).
|
||||||
|
|
||||||
### 06 · upscale / detail
|
### 06 · upscale / detail
|
||||||
|
|
||||||
- `comfyui_build_upscale_workflow_py_ml` (pura) — ESRGAN (`model`) o reescalado pixel (`latent`).
|
- `comfyui_build_upscale_workflow_py_ml` (pura) — ESRGAN (`model`) o reescalado pixel (`latent`).
|
||||||
@@ -102,9 +120,10 @@ sus IDs reales cuando se ejecute `fn index`.
|
|||||||
- Modelos: `comfyui_download_model_py_ml`, `comfyui_list_installed_models_py_ml`, `comfyui_install_custom_node_py_ml`.
|
- Modelos: `comfyui_download_model_py_ml`, `comfyui_list_installed_models_py_ml`, `comfyui_install_custom_node_py_ml`.
|
||||||
- Ejecución: `comfyui_submit_workflow_py_ml`, `comfyui_wait_result_py_ml`, `comfyui_stream_progress_py_ml`, `comfyui_validate_workflow_py_ml`, `comfyui_object_info_py_ml`.
|
- Ejecución: `comfyui_submit_workflow_py_ml`, `comfyui_wait_result_py_ml`, `comfyui_stream_progress_py_ml`, `comfyui_validate_workflow_py_ml`, `comfyui_object_info_py_ml`.
|
||||||
- Cola: `comfyui_queue_manage_py_ml`, `comfyui_interrupt_queue_py_ml`.
|
- Cola: `comfyui_queue_manage_py_ml`, `comfyui_interrupt_queue_py_ml`.
|
||||||
- Outputs: `comfyui_fetch_output_image_py_ml`, `comfyui_fetch_output_video_py_ml`, `comfyui_fetch_output_mesh_py_ml`.
|
- Outputs: `comfyui_fetch_output_image_py_ml`, `comfyui_fetch_output_video_py_ml`, `comfyui_fetch_output_mesh_py_ml`, `comfyui_fetch_output_audio_py_ml`.
|
||||||
- Barridos: `comfyui_batch_generate_py_ml`, `comfyui_build_grid_py_ml`.
|
- Barridos: `comfyui_batch_generate_py_ml`, `comfyui_build_grid_py_ml`.
|
||||||
- Workflows I/O: `comfyui_import_workflow_json_py_ml`, `comfyui_import_workflow_png_py_ml`, `comfyui_read_png_metadata_py_ml`, `comfyui_download_workflow_py_ml`, `comfyui_run_foreign_workflow_oneshot_py_pipelines`.
|
- Workflows I/O: `comfyui_import_workflow_json_py_ml`, `comfyui_import_workflow_png_py_ml`, `comfyui_read_png_metadata_py_ml`, `comfyui_download_workflow_py_ml`, `comfyui_run_foreign_workflow_oneshot_py_pipelines`.
|
||||||
|
- Templates oficiales (paquete `comfyui-workflow-templates`): `comfyui_list_templates_py_ml`, `comfyui_extract_template_py_ml`.
|
||||||
- UI vía CDP: `comfyui_load_workflow_ui_py_browser`, `comfyui_export_workflow_ui_py_browser`, `comfyui_queue_prompt_ui_py_browser`, `comfyui_clear_node_outputs_ui_py_browser`.
|
- UI vía CDP: `comfyui_load_workflow_ui_py_browser`, `comfyui_export_workflow_ui_py_browser`, `comfyui_queue_prompt_ui_py_browser`, `comfyui_clear_node_outputs_ui_py_browser`.
|
||||||
|
|
||||||
## Librería de grafos en disco
|
## Librería de grafos en disco
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ submit/wait). Una skill no es un workflow: es la *receta* que compila a uno.
|
|||||||
|
|
||||||
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui-skill"`.
|
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui-skill"`.
|
||||||
|
|
||||||
|
> **Tamaño del grupo (al 28/06/2026):** 17 funciones con tag `comfyui-skill` — CRUD de recetas
|
||||||
|
> (save/load/list), compilación a workflow (`build_skill_workflow`), inyectores encadenables
|
||||||
|
> (`inject_hires_fix`/`inject_multi_lora`, `build_ipadapter_workflow`), bucle de mejora
|
||||||
|
> genera→juzga→bump (`generate_with_skill_oneshot` + `update_skill_score` + `bump_skill_version`),
|
||||||
|
> export a grafo (`export_skill_template`), mixer de capacidades (`compose_capabilities` +
|
||||||
|
> `generate_mixed_oneshot`) y cosecha de Civitai (`extract_recipe_from_png` + `harvest_civitai_skill_oneshot`).
|
||||||
|
|
||||||
## Qué es una skill
|
## Qué es una skill
|
||||||
|
|
||||||
Una receta vive en `~/ComfyUI/skills_library/<slug>/` y la manipulan las funciones de este grupo:
|
Una receta vive en `~/ComfyUI/skills_library/<slug>/` y la manipulan las funciones de este grupo:
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# ComfyUI Styles — presets y catálogo de estilo
|
||||||
|
|
||||||
|
Tag: `comfyui-styles` (+ `gamedev-2d` para los dos presets gamedev). Sub-grupo de
|
||||||
|
[`comfyui`](comfyui.md) que añade una **capa de estilo reutilizable** sobre los builders de
|
||||||
|
workflow: en vez de repetir a mano los mismos modificadores de cámara/iluminación/render en cada
|
||||||
|
prompt, el estilo se trata como un dato curado y reusable.
|
||||||
|
|
||||||
|
Dos vertientes complementarias:
|
||||||
|
|
||||||
|
- **Catálogo WAS** (`comfyui-styles`): ~190 estilos curados en el formato exacto del selector WAS de
|
||||||
|
ComfyUI (*Prompt Styles Selector* / *Prompt Multiple Styles Selector*), generación de estilos
|
||||||
|
nuevos por LLM, y fusión segura sobre el `styles.json` del usuario.
|
||||||
|
- **Style presets gamedev** (`gamedev-2d`): recetas que empaquetan como datos puros el *look* de un
|
||||||
|
juego entero (prefijo/sufijo de prompt, checkpoint, LoRA, negative, tamaño, post-proceso) y se
|
||||||
|
traducen a los kwargs que consume un builder de sujeto del grupo [`gamedev-2d`](gamedev-2d.md).
|
||||||
|
|
||||||
|
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui-styles"` (catálogo WAS) y
|
||||||
|
`mcp__registry__fn_search query="style preset" tag="gamedev-2d"` (presets gamedev).
|
||||||
|
|
||||||
|
## Funciones del grupo
|
||||||
|
|
||||||
|
### Catálogo WAS — dominio `ml` (tag `comfyui-styles`)
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_curated_styles_catalog_py_ml](../../python/functions/ml/comfyui_curated_styles_catalog.md) | `curated_styles_catalog(category=None) -> dict` | Catálogo curado (~190 estilos) en el formato exacto `{nombre: {prompt, negative_prompt}}` que consume el selector WAS. Cada `prompt` son modificadores de estilo potentes (cámara, lente, iluminación, render engine, medio artístico, paleta, mood), no descripciones de escena. Filtra por `category`. **Pura**. |
|
||||||
|
| [comfyui_generate_styles_llm_py_ml](../../python/functions/ml/comfyui_generate_styles_llm.md) | `generate_styles_llm(category, n=8, prefix='', avoid=None, model='claude-haiku-4-5-20251001') -> dict` | Genera N estilos de una categoría temática usando `ask_llm` (grupo claude-direct, API directa, arranque 0), en el mismo formato `{nombre: {prompt, negative_prompt}}`. `avoid` evita duplicar nombres ya existentes. **Impura** (LLM). |
|
||||||
|
| [comfyui_append_styles_py_ml](../../python/functions/ml/comfyui_append_styles.md) | `append_styles(new_styles, styles_path=DEFAULT_STYLES_PATH, overwrite=False, backup=True, dry_run=False) -> dict` | Fusiona (merge + dedup por nombre) un dict de estilos sobre el `styles.json` del selector WAS de forma SEGURA y NO destructiva: preserva todos los existentes (ganan salvo `overwrite=True`), hace backup con timestamp antes de escribir. `dry_run=True` previsualiza sin tocar disco. **Impura** (I/O disco). |
|
||||||
|
|
||||||
|
### Style presets gamedev — dominio `ml` (tag `gamedev-2d`)
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_get_gamedev_style_preset_py_ml](../../python/functions/ml/comfyui_get_gamedev_style_preset.md) | `get_gamedev_style_preset(name=None) -> dict` | Devuelve la receta de un *style preset* gamedev curado (`gameboy`, `ghibli`, `pixel-art-retro`) o el catálogo de nombres si `name=None`. Un preset empaqueta como DATOS puros el look de un juego entero: `subject_prefix`/`suffix`, `style`, `negative`, checkpoint recomendado, LoRA + strength, `size`, `transparent`, post-proceso. **Pura**. |
|
||||||
|
| [comfyui_apply_style_preset_py_ml](../../python/functions/ml/comfyui_apply_style_preset.md) | `apply_style_preset(preset, subject, *, style=None, negative=None) -> dict` | Traduce un *style preset* gamedev (de `get_gamedev_style_preset`) + un `subject` del usuario a lo que necesita un builder de sujeto del grupo gamedev-2d: el subject combinado con el prefijo/sufijo del estilo y los kwargs comunes (`style`, `checkpoint`, `lora`, `lora_strength`, `negative`, resolución) listos para `**spread`. `style`/`negative` permiten override puntual. **Pura**. |
|
||||||
|
|
||||||
|
## Ejemplo canónico — generar un estilo, fusionarlo y aplicarlo
|
||||||
|
|
||||||
|
Dos flujos típicos: (1) ampliar el catálogo del selector WAS, y (2) usar un preset gamedev para
|
||||||
|
generar un asset con look consistente.
|
||||||
|
|
||||||
|
### A) Ampliar el catálogo WAS con estilos nuevos por LLM
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from ml.comfyui_generate_styles_llm import comfyui_generate_styles_llm
|
||||||
|
from ml.comfyui_append_styles import comfyui_append_styles
|
||||||
|
|
||||||
|
# 1. Pedir 6 estilos de una categoría. Devuelve el dict {nombre: {prompt, negative_prompt}}
|
||||||
|
# directo (best-effort: {} si el LLM falla).
|
||||||
|
nuevos = comfyui_generate_styles_llm("film noir cinematic", n=6, prefix="noir-")
|
||||||
|
|
||||||
|
# 2. Previsualizar la fusión (no escribe), luego aplicar con backup.
|
||||||
|
if nuevos:
|
||||||
|
print(comfyui_append_styles(nuevos, dry_run=True)["total_after"]) # nº tras fusionar, sin tocar disco
|
||||||
|
res = comfyui_append_styles(nuevos) # backup + merge + dedup + escritura
|
||||||
|
print(res["total_before"], "->", res["total_after"], "añadidos:", len(res["added"]))
|
||||||
|
```
|
||||||
|
|
||||||
|
### B) Aplicar un style preset gamedev a un sujeto
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from ml.comfyui_get_gamedev_style_preset import comfyui_get_gamedev_style_preset
|
||||||
|
from ml.comfyui_apply_style_preset import comfyui_apply_style_preset
|
||||||
|
from ml.comfyui_build_enemy_creature_workflow import comfyui_build_enemy_creature_workflow
|
||||||
|
|
||||||
|
preset = comfyui_get_gamedev_style_preset("gameboy") # receta pura del look Game Boy
|
||||||
|
ap = comfyui_apply_style_preset(preset, "a wizard casting a spell")
|
||||||
|
# ap = {subject, builder_kwargs, size, transparent, post, ...} listo para un builder gamedev-2d:
|
||||||
|
wf = comfyui_build_enemy_creature_workflow(
|
||||||
|
ap["subject"], size=ap["size"], transparent=ap["transparent"], **ap["builder_kwargs"]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
El catálogo curado completo se consulta sin red (devuelve el dict plano directo):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ml.comfyui_curated_styles_catalog import comfyui_curated_styles_catalog
|
||||||
|
print(comfyui_curated_styles_catalog("__categories__")) # {'categories': {...}, 'total': 190}
|
||||||
|
todos = comfyui_curated_styles_catalog() # dict {nombre: {prompt, negative_prompt}}
|
||||||
|
print(len(todos), list(todos)[:5])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fronteras
|
||||||
|
|
||||||
|
- **No genera imágenes**: este sub-grupo produce y gestiona DATOS de estilo (dicts de prompt /
|
||||||
|
negative, presets). Generar el asset es trabajo de los builders del grupo [`comfyui`](comfyui.md)
|
||||||
|
y [`gamedev-2d`](gamedev-2d.md), o de los pipelines oneshot (p.ej.
|
||||||
|
`comfyui_generate_styled_asset_oneshot_py_pipelines`, que compone un preset + un builder + submit).
|
||||||
|
- **El catálogo WAS asume el custom node WAS instalado**: `append_styles` escribe sobre el
|
||||||
|
`styles.json` que lee el selector WAS en la UI. Sin ese node, el catálogo sigue siendo usable como
|
||||||
|
dict de modificadores, pero el selector no aparecerá en el grafo.
|
||||||
|
- **Los dos presets gamedev (`get`/`apply`) llevan tag `gamedev-2d`**, no `comfyui-styles`: son la
|
||||||
|
vía de estilo para los builders de assets de juego, no para el selector WAS genérico. Se listan
|
||||||
|
aquí por afinidad de capacidad (estilo reutilizable).
|
||||||
|
- **Formato exacto**: el dict de estilos es `{nombre: {prompt, negative_prompt}}`. Los prompts son
|
||||||
|
modificadores (cámara/lente/luz/render/medio/paleta/mood), no descripciones de escena — la escena
|
||||||
|
la pone el `subject` del usuario.
|
||||||
@@ -13,6 +13,17 @@ Tag: `comfyui`. Grupo de funciones para controlar [ComfyUI](https://github.com/c
|
|||||||
|
|
||||||
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui"`.
|
Filtro MCP: `mcp__registry__fn_search query="" tag="comfyui"`.
|
||||||
|
|
||||||
|
> **Tamaño del grupo (al 28/06/2026):** 126 funciones con tag `comfyui` (63 puras, 50 impuras,
|
||||||
|
> 13 pipelines). El grupo se reparte en sub-grupos con página madre propia:
|
||||||
|
> [`comfyui-skill`](comfyui-skill.md) (recetas de estilo versionadas),
|
||||||
|
> [`comfyui-styles`](comfyui-styles.md) (presets + catálogo de estilo para el selector WAS),
|
||||||
|
> [`comfyui-judge`](comfyui-judge.md) (panel de calidad) y
|
||||||
|
> [`gamedev-2d`](gamedev-2d.md) (assets 2D para Godot: 47 funciones, 45 de ellas también `comfyui`).
|
||||||
|
> Esta página documenta el **núcleo** (lifecycle del server, API HTTP, builders, I/O de workflows,
|
||||||
|
> imagen→3D, UI por CDP, audio, templates); los builders específicos de gamedev-2d viven en su
|
||||||
|
> propia página. El mapa cross-grupo de capacidades está en
|
||||||
|
> [comfyui-overview.md](comfyui-overview.md).
|
||||||
|
|
||||||
## Dos caminos, mismo motor
|
## Dos caminos, mismo motor
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -44,7 +55,7 @@ El **API format** (dict de nodos numerados que produce `build_txt2img_workflow`
|
|||||||
| ID | Firma corta | Qué hace |
|
| ID | Firma corta | Qué hace |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [comfyui_build_txt2img_workflow_py_ml](../../python/functions/ml/comfyui_build_txt2img_workflow.md) | `build_txt2img_workflow(ckpt_name, positive, negative='', *, steps, cfg, width, height, seed, ...) -> dict` | Construye el dict del workflow txt2img básico (Checkpoint → CLIPTextEncode×2 + EmptyLatent → KSampler → VAEDecode → SaveImage) en API format. **Pura**. |
|
| [comfyui_build_txt2img_workflow_py_ml](../../python/functions/ml/comfyui_build_txt2img_workflow.md) | `build_txt2img_workflow(ckpt_name, positive, negative='', *, steps, cfg, width, height, seed, ...) -> dict` | Construye el dict del workflow txt2img básico (Checkpoint → CLIPTextEncode×2 + EmptyLatent → KSampler → VAEDecode → SaveImage) en API format. **Pura**. |
|
||||||
| [comfyui_build_flux_workflow_py_ml](../../python/functions/ml/comfyui_build_flux_workflow.md) | `build_flux_workflow(prompt, *, unet='flux1-schnell-fp8-e4m3fn.safetensors', clip_l, t5xxl, vae='ae.safetensors', width=1024, height=1024, steps=4, guidance=3.5, seed, weight_dtype='fp8_e4m3fn', ...) -> dict` | Builder txt2img para **Flux** (schnell/dev): UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader → CLIPTextEncode → FluxGuidance + EmptySD3LatentImage → KSampler (cfg fijo 1.0) → VAEDecode → SaveImage. La guía va por FluxGuidance, no por el cfg. fp8 + ~4 pasos para 8 GB. **Pura**. |
|
| [comfyui_build_flux_workflow_py_ml](../../python/functions/ml/comfyui_build_flux_workflow.md) | `build_flux_workflow(prompt, *, variant='schnell', width=1024, height=1024, steps=None, guidance=3.5, seed=0, unet_name=None, clip_l_name='clip_l.safetensors', t5xxl_name='t5xxl_fp8_e4m3fn_scaled.safetensors', vae_name='ae.safetensors', weight_dtype='default', sampler_name='euler', scheduler='simple', ...) -> dict` | Builder txt2img para **Flux** (`variant='schnell'` o `'dev'`): UNETLoader + DualCLIPLoader (clip_l + t5xxl, type flux) + VAELoader → CLIPTextEncode → FluxGuidance + EmptySD3LatentImage → camino custom-advanced (RandomNoise + KSamplerSelect + BasicScheduler → BasicGuider → SamplerCustomAdvanced) → VAEDecode → SaveImage. La guía va por FluxGuidance, no por el cfg. `steps=None` autoselecciona por variante (~4 schnell); `unet_name=None` deduce el checkpoint de la variante; `weight_dtype='default'`. **Pura**. |
|
||||||
| [comfyui_object_info_py_ml](../../python/functions/ml/comfyui_object_info.md) | `object_info(server='127.0.0.1:8188', node_class=None, timeout) -> dict` | Catálogo de nodos del server: inputs, tipos y enums (lista de checkpoints/samplers visibles). Para validar antes de enviar. Impura. |
|
| [comfyui_object_info_py_ml](../../python/functions/ml/comfyui_object_info.md) | `object_info(server='127.0.0.1:8188', node_class=None, timeout) -> dict` | Catálogo de nodos del server: inputs, tipos y enums (lista de checkpoints/samplers visibles). Para validar antes de enviar. Impura. |
|
||||||
| [comfyui_submit_workflow_py_ml](../../python/functions/ml/comfyui_submit_workflow.md) | `submit_workflow(workflow, server, client_id, timeout) -> dict` | Encola un workflow API format vía POST /prompt; devuelve `prompt_id` + posición en cola. HTTP 400 propaga la validación por nodo. Impura. |
|
| [comfyui_submit_workflow_py_ml](../../python/functions/ml/comfyui_submit_workflow.md) | `submit_workflow(workflow, server, client_id, timeout) -> dict` | Encola un workflow API format vía POST /prompt; devuelve `prompt_id` + posición en cola. HTTP 400 propaga la validación por nodo. Impura. |
|
||||||
| [comfyui_wait_result_py_ml](../../python/functions/ml/comfyui_wait_result.md) | `wait_result(prompt_id, server, timeout, poll_interval) -> dict` | Sondea GET /history/{prompt_id} hasta que termina; devuelve los outputs (PNGs con filename/subfolder/type). Impura. |
|
| [comfyui_wait_result_py_ml](../../python/functions/ml/comfyui_wait_result.md) | `wait_result(prompt_id, server, timeout, poll_interval) -> dict` | Sondea GET /history/{prompt_id} hasta que termina; devuelve los outputs (PNGs con filename/subfolder/type). Impura. |
|
||||||
@@ -142,6 +153,19 @@ canónica). El resultado es un `.mp4` vía `CreateVideo → SaveVideo`.
|
|||||||
| [comfyui_build_video_workflow_py_ml](../../python/functions/ml/comfyui_build_video_workflow.md) | `build_video_workflow(prompt, *, model='ltx', negative='', width=512, height=320, num_frames=65, steps=20, seed=0, fps=24) -> dict` | Builder txt2video para LTX-Video 2B (`model='ltx'`, 12 nodos LTXV*) o Wan2.1 1.3B (`model='wan'`, UNETLoader+VAELoader+ModelSamplingSD3). Nombres de modelo reales, defaults conservadores 8 GB. **Pura**. |
|
| [comfyui_build_video_workflow_py_ml](../../python/functions/ml/comfyui_build_video_workflow.md) | `build_video_workflow(prompt, *, model='ltx', negative='', width=512, height=320, num_frames=65, steps=20, seed=0, fps=24) -> dict` | Builder txt2video para LTX-Video 2B (`model='ltx'`, 12 nodos LTXV*) o Wan2.1 1.3B (`model='wan'`, UNETLoader+VAELoader+ModelSamplingSD3). Nombres de modelo reales, defaults conservadores 8 GB. **Pura**. |
|
||||||
| [comfyui_build_img2vid_workflow_py_ml](../../python/functions/ml/comfyui_build_img2vid_workflow.md) | `build_img2vid_workflow(image, *, ckpt='svd.safetensors', width=1024, height=576, video_frames=14, motion_bucket_id=127, fps=6, augmentation_level=0.0, steps=20, cfg=2.5, min_cfg=1.0, seed=0, sampler_name='euler', scheduler='karras', filename_prefix='comfy_svd') -> dict` | Builder img2vid (Stable Video Diffusion): anima una imagen estática a clip corto. ImageOnlyCheckpointLoader(`svd.safetensors`, todo-en-uno) + LoadImage → SVD_img2vid_Conditioning → VideoLinearCFGGuidance → KSampler (denoise 1.0) → VAEDecode → SaveAnimatedWEBP. SVD no usa prompt de texto: condiciona por CLIP_VISION de la imagen; movimiento vía `motion_bucket_id`. **Pura**. |
|
| [comfyui_build_img2vid_workflow_py_ml](../../python/functions/ml/comfyui_build_img2vid_workflow.md) | `build_img2vid_workflow(image, *, ckpt='svd.safetensors', width=1024, height=576, video_frames=14, motion_bucket_id=127, fps=6, augmentation_level=0.0, steps=20, cfg=2.5, min_cfg=1.0, seed=0, sampler_name='euler', scheduler='karras', filename_prefix='comfy_svd') -> dict` | Builder img2vid (Stable Video Diffusion): anima una imagen estática a clip corto. ImageOnlyCheckpointLoader(`svd.safetensors`, todo-en-uno) + LoadImage → SVD_img2vid_Conditioning → VideoLinearCFGGuidance → KSampler (denoise 1.0) → VAEDecode → SaveAnimatedWEBP. SVD no usa prompt de texto: condiciona por CLIP_VISION de la imagen; movimiento vía `motion_bucket_id`. **Pura**. |
|
||||||
|
|
||||||
|
### Audio (txt2audio, ACE-Step) — dominio `ml` (tag `audio-generation`)
|
||||||
|
|
||||||
|
ComfyUI ≥ 0.26.0 trae nodos de **audio nativos**. `build_audio_workflow` cubre **ACE-Step v1**
|
||||||
|
(`AUDIO_ace_step_v1_3.5b.safetensors`, Apache 2.0): música y SFX por texto, con `lyrics` opcional
|
||||||
|
para voz cantada. El resultado es un `.flac` vía `VAEDecodeAudio → SaveAudio`, que `fetch_output_audio`
|
||||||
|
localiza y baja a disco (los nodos de audio exponen su salida bajo la clave `"audio"` de `/history`,
|
||||||
|
no `"images"`).
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_build_audio_workflow_py_ml](../../python/functions/ml/comfyui_build_audio_workflow.md) | `build_audio_workflow(ckpt_name, prompt, *, lyrics='', seconds=10.0, seed=0, steps=50, cfg=5.0, sampler_name='euler', scheduler='simple', shift=5.0, lyrics_strength=1.0, filename_prefix='audio/comfy_audio') -> dict` | Builder **txt2audio (ACE-Step)** en API format: CheckpointLoaderSimple → TextEncodeAceStepAudio (tags=prompt + lyrics) como positive + ConditioningZeroOut como negative + EmptyAceStepLatentAudio(seconds) → ModelSamplingSD3(shift) → KSampler → VAEDecodeAudio → SaveAudio(.flac). La guía va por `cfg`; `lyrics` opcional para voz cantada. **Pura**. |
|
||||||
|
| [comfyui_fetch_output_audio_py_ml](../../python/functions/ml/comfyui_fetch_output_audio.md) | `fetch_output_audio(prompt_id, *, server='127.0.0.1:8188', dest=None, outputs=None, timeout=120.0) -> dict` | Localiza y descarga el output de **audio** (`.flac`/`.wav`/`.mp3`/`.opus`/`.ogg`/`.m4a`) de `/history` vía GET `/view`. Cubre SaveAudio/SaveAudioMP3/Opus/Advanced (bajo la clave `"audio"`). Hermana de `fetch_output_image`/`video`/`mesh`. Acepta `outputs=` de `wait_result` para no re-consultar `/history`. Impura. |
|
||||||
|
|
||||||
### Imagen → 3D (Hunyuan3D-2 nativo) — dominio `ml` + `pipelines` (tag `img-to-3d`)
|
### Imagen → 3D (Hunyuan3D-2 nativo) — dominio `ml` + `pipelines` (tag `img-to-3d`)
|
||||||
|
|
||||||
ComfyUI ≥ 0.26.0 trae **soporte nativo de Hunyuan3D-2** (sin custom node): una imagen se
|
ComfyUI ≥ 0.26.0 trae **soporte nativo de Hunyuan3D-2** (sin custom node): una imagen se
|
||||||
@@ -179,6 +203,37 @@ report `0079`).
|
|||||||
| [comfyui_export_workflow_ui_py_browser](../../python/functions/browser/comfyui_export_workflow_ui.md) | `export_workflow_ui(*, port, server_url_substr, api_format=True, save_path, timeout_s) -> dict` | Exporta el grafo actual: API format (`graphToPrompt().output`) o UI graph (`graph.serialize()`); opcional a disco. Impura. |
|
| [comfyui_export_workflow_ui_py_browser](../../python/functions/browser/comfyui_export_workflow_ui.md) | `export_workflow_ui(*, port, server_url_substr, api_format=True, save_path, timeout_s) -> dict` | Exporta el grafo actual: API format (`graphToPrompt().output`) o UI graph (`graph.serialize()`); opcional a disco. Impura. |
|
||||||
| [comfyui_refresh_nodes_ui_py_browser](../../python/functions/browser/comfyui_refresh_nodes_ui.md) | `refresh_nodes_ui(*, port, server_url_substr, timeout_s) -> dict` | Refresca los combos (checkpoints/loras/vae) sin recargar la página (`app.refreshComboInNodes`). Impura. |
|
| [comfyui_refresh_nodes_ui_py_browser](../../python/functions/browser/comfyui_refresh_nodes_ui.md) | `refresh_nodes_ui(*, port, server_url_substr, timeout_s) -> dict` | Refresca los combos (checkpoints/loras/vae) sin recargar la página (`app.refreshComboInNodes`). Impura. |
|
||||||
|
|
||||||
|
### Templates oficiales — dominio `ml` (tag `templates`)
|
||||||
|
|
||||||
|
Los workflows del menú **"Browse Templates"** del frontend se distribuyen en el paquete pip
|
||||||
|
`comfyui-workflow-templates` (desde la 0.10.x un meta-paquete multi-bundle con API en
|
||||||
|
`comfyui_workflow_templates_core`). Estas dos funciones leen ese catálogo localizando el intérprete
|
||||||
|
de ComfyUI y usando su API oficial vía subprocess (el paquete vive en el venv de ComfyUI, no en el
|
||||||
|
del registry). Sirven para descubrir grafos oficiales y arrancar un workflow desde una plantilla
|
||||||
|
probada en vez de construirlo a mano. Si no hay un ComfyUI con el paquete, devuelven `ok=False` con
|
||||||
|
un error accionable, sin lanzar.
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_list_templates_py_ml](../../python/functions/ml/comfyui_list_templates.md) | `list_templates(comfyui_python=None, bundle=None, name_filter=None, with_nodes=True, workflows_only=True, limit=0) -> dict` | Lista los templates oficiales con su grafo: nombre, bundle/categoría, path en disco, `n_nodes` y `node_types` (class_types reales, aplanando subgrafos y descartando UUID de instancia). Filtra por bundle/nombre; excluye entradas no-workflow por defecto. Impura (lee disco vía el intérprete de ComfyUI). |
|
||||||
|
| [comfyui_extract_template_py_ml](../../python/functions/ml/comfyui_extract_template.md) | `extract_template(name, comfyui_python=None, to_api=False, server='127.0.0.1:8188') -> dict` | Extrae el grafo completo (formato UI) + `class_types` de un template por su `template_id`. `to_api=True` lo convierte a API format vía `comfyui_import_workflow_json` (requiere servidor ComfyUI vivo). Nombre inexistente → `ok=False` con sugerencias cercanas, sin traceback. Impura. |
|
||||||
|
|
||||||
|
### Estilos — presets y catálogo (sub-grupo `comfyui-styles`)
|
||||||
|
|
||||||
|
Capa de **estilo reutilizable** sobre los builders: un catálogo curado de ~190 modificadores de
|
||||||
|
estilo para el selector WAS (Prompt Styles Selector), generación de estilos por LLM, y *style
|
||||||
|
presets* gamedev (gameboy, ghibli, pixel-art-retro) que empaquetan como datos puros el look de un
|
||||||
|
juego entero (prefijo/sufijo de prompt, checkpoint, LoRA, negative, tamaño). Página madre dedicada:
|
||||||
|
[comfyui-styles.md](comfyui-styles.md). Las 5 funciones:
|
||||||
|
|
||||||
|
| ID | Firma corta | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| [comfyui_curated_styles_catalog_py_ml](../../python/functions/ml/comfyui_curated_styles_catalog.md) | `curated_styles_catalog(category=None) -> dict` | Catálogo curado (~190 estilos) en formato `{nombre: {prompt, negative_prompt}}` para el selector WAS. Cada prompt son modificadores potentes (cámara, lente, iluminación, render, medio, paleta). **Pura**. |
|
||||||
|
| [comfyui_generate_styles_llm_py_ml](../../python/functions/ml/comfyui_generate_styles_llm.md) | `generate_styles_llm(category, n=8, prefix='', avoid=None, model='claude-haiku-4-5-...') -> dict` | Genera N estilos nuevos de una categoría temática vía `ask_llm` (grupo claude-direct), en el mismo formato del selector WAS. **Impura**. |
|
||||||
|
| [comfyui_append_styles_py_ml](../../python/functions/ml/comfyui_append_styles.md) | `append_styles(new_styles, styles_path=..., overwrite=False, backup=True, dry_run=False) -> dict` | Fusiona (merge + dedup) estilos nuevos sobre el `styles.json` del selector WAS de forma NO destructiva: preserva los existentes (salvo `overwrite`), backup con timestamp. **Impura**. |
|
||||||
|
| [comfyui_get_gamedev_style_preset_py_ml](../../python/functions/ml/comfyui_get_gamedev_style_preset.md) | `get_gamedev_style_preset(name=None) -> dict` | Devuelve la receta de un *style preset* gamedev curado (gameboy, ghibli, pixel-art-retro) o el catálogo de nombres si `name=None`. Empaqueta el look como datos puros. **Pura**. |
|
||||||
|
| [comfyui_apply_style_preset_py_ml](../../python/functions/ml/comfyui_apply_style_preset.md) | `apply_style_preset(preset, subject, *, style=None, negative=None) -> dict` | Traduce un *style preset* gamedev + un subject del usuario a los kwargs que consume un builder de sujeto del grupo gamedev-2d (subject combinado + `**kwargs` listos para spread). **Pura**. |
|
||||||
|
|
||||||
## Ejemplo canónico end-to-end (build → load → tune → queue → resultado)
|
## Ejemplo canónico end-to-end (build → load → tune → queue → resultado)
|
||||||
|
|
||||||
Combina API + UI: construyes el workflow por API, lo cargas en la UI del usuario, ajustas el
|
Combina API + UI: construyes el workflow por API, lo cargas en la UI del usuario, ajustas el
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Grupo de capacidad para perfilar tablas y bases de datos completas y entender datasets nuevos rápido, repetible y sin reinventar lógica. Motor **DuckDB SQL push-down**: los agregados (`SUMMARIZE`, `COUNT DISTINCT`, `corr()`, percentiles) se calculan en SQL sin traer las filas a RAM; solo una muestra pequeña baja a Python para lo estadístico fino (skew, kurtosis, histograma, correlación mixta, modelos).
|
Grupo de capacidad para perfilar tablas y bases de datos completas y entender datasets nuevos rápido, repetible y sin reinventar lógica. Motor **DuckDB SQL push-down**: los agregados (`SUMMARIZE`, `COUNT DISTINCT`, `corr()`, percentiles) se calculan en SQL sin traer las filas a RAM; solo una muestra pequeña baja a Python para lo estadístico fino (skew, kurtosis, histograma, correlación mixta, modelos).
|
||||||
|
|
||||||
Orquestadores one-shot:
|
Orquestadores one-shot:
|
||||||
- `profile_table_py_pipelines` — "hazme un EDA de esta tabla" → `TableProfile` completo + report markdown + JSON. Flags `run_models` (modelos baratos) y `run_llm` (interpretación LLM).
|
- `profile_table_py_pipelines` — "hazme un EDA de esta tabla" → `TableProfile` completo + report markdown + JSON (+ PDF móvil con `emit_pdf`). Flags `run_models` (modelos baratos), `run_llm` (interpretación LLM), `run_series` (análisis de serie temporal por columna numérica) y `emit_pdf` (PDF vertical legible en móvil). Re-expresión sugerida por columna y avisos exploratorios se añaden siempre.
|
||||||
- `profile_database_py_pipelines` — "hazme un EDA de esta base" → perfila todas las tablas + infiere FK + join graph (mermaid).
|
- `profile_database_py_pipelines` — "hazme un EDA de esta base" → perfila todas las tablas + infiere FK + join graph (mermaid).
|
||||||
|
|
||||||
> Cuando Enmanuel pide un EDA, el flujo acordado es: perfilar con este grupo, escribir el report, y **generar un analysis Jupyter lanzado en el navegador colaborativo y ejecutado por Claude** para verlo en vivo. Ver la memoria `eda-workflow-registry` y la regla `notebook_collaboration.md`.
|
> Cuando Enmanuel pide un EDA, el flujo acordado es: perfilar con este grupo, escribir el report, y **generar un analysis Jupyter lanzado en el navegador colaborativo y ejecutado por Claude** para verlo en vivo. Ver la memoria `eda-workflow-registry` y la regla `notebook_collaboration.md`.
|
||||||
@@ -50,16 +50,32 @@ Orquestadores one-shot:
|
|||||||
| `trend_slope_py_datascience` | pure | Tendencia de una serie (up/down/flat) por regresión lineal. |
|
| `trend_slope_py_datascience` | pure | Tendencia de una serie (up/down/flat) por regresión lineal. |
|
||||||
| `run_eda_models_py_datascience` | pure | Wrapper: compone PCA + KMeans + IsolationForest + normalidad → bloque `models`. |
|
| `run_eda_models_py_datascience` | pure | Wrapper: compone PCA + KMeans + IsolationForest + normalidad → bloque `models`. |
|
||||||
|
|
||||||
|
### Series temporales (flag `run_series`)
|
||||||
|
| ID | Pureza | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `adf_kpss_stationarity_py_datascience` | pure | Estacionariedad por consenso ADF + KPSS (hipótesis nulas opuestas) → veredicto `stationary`/`non_stationary`/`inconclusive` + aviso de correlación espuria. |
|
||||||
|
| `acf_pacf_py_datascience` | pure | ACF + PACF con bandas de confianza + lags significativos + Ljung-Box (¿ruido blanco?). Detecta autocorrelación que infla los p-valores OLS. |
|
||||||
|
| `stl_decompose_py_datascience` | pure | Descomposición STL (tendencia/estacional/resto) + fuerza de tendencia y estacional de Hyndman. Auto-infiere el periodo por autocorrelación. |
|
||||||
|
| `to_returns_py_datascience` | pure | Convierte una serie de niveles (precios) a retornos log/simples. Los niveles no son estacionarios; los retornos sí (unidad correcta para correlacionar/modelar). |
|
||||||
|
|
||||||
|
### Rigor y disciplina exploratoria
|
||||||
|
| ID | Pureza | Qué hace |
|
||||||
|
|---|---|---|
|
||||||
|
| `fdr_correction_py_datascience` | pure | Corrige p-valores por comparaciones múltiples (Benjamini-Hochberg FDR / Bonferroni FWER) → controla el data-mining bias. Ya integrada en `association_matrix`. |
|
||||||
|
| `suggest_reexpression_py_datascience` | pure | Escalera de potencias de Tukey: qué transformación (log/sqrt/Yeo-Johnson/...) simetriza mejor una columna numérica según su skew y dominio. No la ejecuta, la sugiere. |
|
||||||
|
| `exploratory_caveats_py_datascience` | pure | Genera las advertencias de que el EDA es exploratorio (correlación≠causalidad, overfitting in-sample, comparaciones múltiples, outliers, muestra pequeña, MNAR) según lo que el perfil realmente contiene. |
|
||||||
|
|
||||||
### Capa LLM y entrega
|
### Capa LLM y entrega
|
||||||
| ID | Pureza | Qué hace |
|
| ID | Pureza | Qué hace |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `eda_llm_insights_py_datascience` | impure | 1 call LLM sobre el perfil agregado (no filas crudas): data dictionary, resumen, granularidad de fila, PII/RGPD, limpieza, análisis sugeridos. |
|
| `eda_llm_insights_py_datascience` | impure | 1 call LLM sobre el perfil agregado (no filas crudas): data dictionary, resumen, granularidad de fila, PII/RGPD, limpieza, análisis sugeridos. |
|
||||||
| `build_eda_notebook_py_datascience` | impure | Genera un `.ipynb` (nbformat v4) que perfila la tabla, listo para lanzar en Jupyter colaborativo. |
|
| `build_eda_notebook_py_datascience` | impure | Genera un `.ipynb` (nbformat v4) que perfila la tabla, listo para lanzar en Jupyter colaborativo. |
|
||||||
|
| `render_eda_pdf_py_datascience` | impure | Renderiza el `TableProfile` a un PDF multipágina **vertical (A5), legible en móvil** (estilo Tufte: histogramas como small multiples, top-k, heatmap de asociación). 4ª salida del workflow, junto a JSON/Markdown/notebook. |
|
||||||
|
|
||||||
### Orquestadores (pipelines)
|
### Orquestadores (pipelines)
|
||||||
| ID | Qué hace |
|
| ID | Qué hace |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `profile_table_py_pipelines` | EDA de una tabla end-to-end, `backend="duckdb"` (default) o `"postgres"` (base + correlación + `run_models` + `run_llm`) → JSON + markdown. |
|
| `profile_table_py_pipelines` | EDA de una tabla end-to-end, `backend="duckdb"` (default) o `"postgres"` (base + correlación con FDR + `run_models` + `run_llm` + `run_series` + re-expresión + caveats) → JSON + markdown (+ PDF móvil con `emit_pdf`). |
|
||||||
| `profile_database_py_pipelines` | EDA de una base entera: todas las tablas + FK + join graph. |
|
| `profile_database_py_pipelines` | EDA de una base entera: todas las tablas + FK + join graph. |
|
||||||
|
|
||||||
## Contrato de datos
|
## Contrato de datos
|
||||||
@@ -68,15 +84,26 @@ Orquestadores one-shot:
|
|||||||
TableProfile = {table, source, profiled_at, n_rows, n_cols, size_bytes,
|
TableProfile = {table, source, profiled_at, n_rows, n_cols, size_bytes,
|
||||||
duplicate_rows, duplicate_pct, constant_cols, all_null_cols, null_cell_pct,
|
duplicate_rows, duplicate_pct, constant_cols, all_null_cols, null_cell_pct,
|
||||||
type_breakdown:{numeric,categorical,datetime,text,boolean},
|
type_breakdown:{numeric,categorical,datetime,text,boolean},
|
||||||
columns:[ColumnProfile], correlations, key_candidates, quality_score, llm, models}
|
columns:[ColumnProfile], correlations, key_candidates, quality_score, llm, models,
|
||||||
|
series:{<col>:SeriesBlock}|None, # solo con run_series
|
||||||
|
caveats:{n, caveats:[{id,topic,message,reference}], note}} # siempre
|
||||||
|
|
||||||
ColumnProfile = {name, physical_type, inferred_type, semantic_type, count, n_rows,
|
ColumnProfile = {name, physical_type, inferred_type, semantic_type, count, n_rows,
|
||||||
null_count, null_pct, empty_count, empty_pct, distinct_count, unique_pct,
|
null_count, null_pct, empty_count, empty_pct, distinct_count, unique_pct,
|
||||||
flags:[constant|possible_id|high_cardinality|mostly_null], quality_score,
|
flags:[constant|possible_id|high_cardinality|mostly_null], quality_score,
|
||||||
numeric:{...}|None, categorical:{...}|None, datetime:{...}|None}
|
numeric:{...}|None, categorical:{...}|None, datetime:{...}|None,
|
||||||
|
reexpression:{recommended,ladder_power,reason,alternatives,skew}|None, # cols numéricas
|
||||||
|
series:SeriesBlock|None} # solo con run_series
|
||||||
# *_pct son FRACCIONES 0-1; el render las muestra ×100
|
# *_pct son FRACCIONES 0-1; el render las muestra ×100
|
||||||
|
|
||||||
correlations = {pairs:[{a,b,a_type,b_type,method,value,extra}], strong:[...], methods_legend}
|
SeriesBlock = {order_col, ordered, n, stationarity:{adf,kpss,verdict,warning},
|
||||||
|
acf_pacf:{acf,pacf,significant_acf_lags,ljung_box,is_autocorrelated},
|
||||||
|
stl:{period,trend_strength,seasonal_strength,...},
|
||||||
|
to_returns:{...}|absent, levels_suggested:bool}
|
||||||
|
|
||||||
|
correlations = {pairs:[{a,b,a_type,b_type,method,value,extra,p_value,
|
||||||
|
p_value_adjusted,significant}], strong:[...], methods_legend,
|
||||||
|
multiple_testing:{method,alpha,n_tests,n_rejected}} # p-valores corregidos por FDR
|
||||||
models = {n_numeric_cols, pca, kmeans, outliers, normality, note}
|
models = {n_numeric_cols, pca, kmeans, outliers, normality, note}
|
||||||
llm = {summary, row_meaning, dictionary:[{column,description,business_meaning,unit}],
|
llm = {summary, row_meaning, dictionary:[{column,description,business_meaning,unit}],
|
||||||
pii:[{column,kind,severity}], cleaning:[str], analyses:[str]}
|
pii:[{column,kind,severity}], cleaning:[str], analyses:[str]}
|
||||||
@@ -91,11 +118,18 @@ import sys, os
|
|||||||
sys.path.insert(0, os.path.join("python", "functions"))
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
from pipelines.profile_table import profile_table
|
from pipelines.profile_table import profile_table
|
||||||
|
|
||||||
r = profile_table("/ruta/datos.duckdb", "clientes", run_models=True, run_llm=True)
|
r = profile_table(
|
||||||
|
"/ruta/datos.duckdb", "clientes",
|
||||||
|
run_models=True, run_llm=True, run_series=True, emit_pdf=True,
|
||||||
|
)
|
||||||
prof = r["profile"]
|
prof = r["profile"]
|
||||||
print(r["report_md_path"]) # reports/eda_clientes_<ts>.md
|
print(r["report_md_path"]) # reports/eda_clientes_<ts>.md
|
||||||
print(prof["correlations"]["strong"]) # pares correlacionados
|
print(r["pdf_path"]) # reports/eda_clientes_<ts>.pdf (móvil)
|
||||||
|
print(prof["correlations"]["strong"]) # pares fuertes Y significativos tras FDR
|
||||||
print(prof["models"]["kmeans"]["best_k"]) # segmentos
|
print(prof["models"]["kmeans"]["best_k"]) # segmentos
|
||||||
|
print(prof["series"]["precio"]["stationarity"]["verdict"]) # ¿serie estacionaria?
|
||||||
|
print(prof["columns"][0]["reexpression"]["recommended"]) # transformación sugerida
|
||||||
|
print(prof["caveats"]["caveats"][0]["message"]) # aviso exploratorio general
|
||||||
print(prof["llm"]["row_meaning"]) # qué representa 1 fila
|
print(prof["llm"]["row_meaning"]) # qué representa 1 fila
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -121,6 +155,9 @@ build_eda_notebook("/ruta/datos.duckdb", "clientes", "/tmp/eda.ipynb", run_model
|
|||||||
- **Correlación de tabla** se calcula sobre la muestra de filas alineadas; excluye columnas id-like (alta cardinalidad) para evitar asociación espuria. `correlation_matrix_duckdb` ofrece Pearson push-down exacto a escala si hace falta.
|
- **Correlación de tabla** se calcula sobre la muestra de filas alineadas; excluye columnas id-like (alta cardinalidad) para evitar asociación espuria. `correlation_matrix_duckdb` ofrece Pearson push-down exacto a escala si hace falta.
|
||||||
- **Modelos** (`run_models`) requieren ≥2 columnas numéricas para PCA/KMeans/IsolationForest; normalidad funciona con 1.
|
- **Modelos** (`run_models`) requieren ≥2 columnas numéricas para PCA/KMeans/IsolationForest; normalidad funciona con 1.
|
||||||
- **LLM** (`run_llm`) hace 1 llamada (haiku) y envía solo el perfil agregado, nunca filas crudas; requiere token OAuth de Claude.
|
- **LLM** (`run_llm`) hace 1 llamada (haiku) y envía solo el perfil agregado, nunca filas crudas; requiere token OAuth de Claude.
|
||||||
|
- **Series** (`run_series`) trata cada columna numérica como serie temporal: si hay una columna datetime se ordena por ella, si no por el orden físico de filas. Necesita ≥8 puntos válidos por columna; STL exige ≥2 periodos. La sugerencia de retornos (`to_returns`) solo aparece en columnas estrictamente positivas y no claramente estacionarias (series de niveles/precios).
|
||||||
|
- **PDF** (`emit_pdf`) genera un PDF A5 vertical legible en móvil junto al report markdown vía `render_eda_pdf` (matplotlib `PdfPages`, sin dependencias nuevas).
|
||||||
|
- **Correlaciones**: los p-valores de cada par se corrigen por comparaciones múltiples (FDR Benjamini-Hochberg) dentro de `association_matrix`; un par solo entra en `strong` si supera el umbral de magnitud Y es significativo tras la corrección.
|
||||||
- **Fuentes**: DuckDB nativo (CSV/Parquet/Excel cargándolos antes a DuckDB) y **PostgreSQL** (`backend="postgres"`, DSN vía `resolve_pg_dsn`). BigQuery pendiente. `profile_database` (multi-tabla + FK) es solo DuckDB por ahora.
|
- **Fuentes**: DuckDB nativo (CSV/Parquet/Excel cargándolos antes a DuckDB) y **PostgreSQL** (`backend="postgres"`, DSN vía `resolve_pg_dsn`). BigQuery pendiente. `profile_database` (multi-tabla + FK) es solo DuckDB por ahora.
|
||||||
|
|
||||||
## Estado
|
## Estado
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ Cluster de funciones para producir y mover assets 2D de juego entre **ComfyUI**
|
|||||||
3. **Puente de assets** (CPU): coloca el resultado en un proyecto Godot
|
3. **Puente de assets** (CPU): coloca el resultado en un proyecto Godot
|
||||||
con sus import settings.
|
con sus import settings.
|
||||||
|
|
||||||
Tag único del grupo: `gamedev-2d` (los 31 builders de workflow + las 5 funciones de
|
Tag único del grupo: `gamedev-2d` — **47 funciones**: 36 builders de workflow (31 de
|
||||||
apoyo de post-proceso y puente). El tag plano `gamedev` quedó deprecado y unificado a
|
generación desde texto + 5 de transformación desde una imagen de entrada) + 11 de apoyo
|
||||||
|
(post-proceso, puente a Godot, style presets y pipelines one-shot). El tag plano `gamedev` quedó deprecado y unificado a
|
||||||
`gamedev-2d`. El **runtime de juego C++** (el motor que ejecuta el juego: game loop,
|
`gamedev-2d`. El **runtime de juego C++** (el motor que ejecuta el juego: game loop,
|
||||||
cámara, input, render por lotes, audio) vive en el grupo hermano `gamedev-engine`.
|
cámara, input, render por lotes, audio) vive en el grupo hermano `gamedev-engine`.
|
||||||
Filtro: `mcp__registry__fn_search query="" tag="gamedev-2d"`.
|
Filtro: `mcp__registry__fn_search query="" tag="gamedev-2d"`.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -44,8 +44,27 @@ 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
|
||||||
|
from .adf_kpss_stationarity import adf_kpss_stationarity
|
||||||
|
from .acf_pacf import acf_pacf
|
||||||
|
from .stl_decompose import stl_decompose
|
||||||
|
from .to_returns import to_returns
|
||||||
|
from .fdr_correction import fdr_correction
|
||||||
|
from .suggest_reexpression import suggest_reexpression
|
||||||
|
from .exploratory_caveats import exploratory_caveats
|
||||||
|
from .render_eda_pdf import render_eda_pdf, render_eda_pdf_relational
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"decode_qr_image",
|
||||||
|
"adf_kpss_stationarity",
|
||||||
|
"acf_pacf",
|
||||||
|
"stl_decompose",
|
||||||
|
"to_returns",
|
||||||
|
"fdr_correction",
|
||||||
|
"suggest_reexpression",
|
||||||
|
"exploratory_caveats",
|
||||||
|
"render_eda_pdf",
|
||||||
|
"render_eda_pdf_relational",
|
||||||
"summarize_table_duckdb",
|
"summarize_table_duckdb",
|
||||||
"summarize_table_pg",
|
"summarize_table_pg",
|
||||||
"spearman_corr",
|
"spearman_corr",
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: acf_pacf
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def acf_pacf(values: list, nlags: int = 40, alpha: float = 0.05) -> dict"
|
||||||
|
description: "Autocorrelacion (ACF) y autocorrelacion parcial (PACF) de una serie temporal con sus bandas de confianza (statsmodels), mas el test Ljung-Box de autocorrelacion global. Devuelve listas acf/pacf, sus intervalos, los lags significativos y un flag is_autocorrelated. Clave: una serie autocorrelacionada viola IID, asi que los p-valores de una regresion OLS estandar sobre ella estan inflados (Lopez de Prado). Descarta None/NaN; <8 puntos validos -> nota."
|
||||||
|
tags: [statistics, timeseries, autocorrelation, acf, pacf, ljung-box, arima, eda, forecasting, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [math, numpy, statsmodels]
|
||||||
|
params:
|
||||||
|
- name: values
|
||||||
|
desc: "serie temporal de valores numericos en orden cronologico. None/NaN/infinitos/no-numericos se descartan antes del calculo."
|
||||||
|
- name: nlags
|
||||||
|
desc: "numero maximo de retardos a calcular (default 40). Se recorta a los limites de statsmodels: n-1 para ACF, (n//2)-1 para PACF."
|
||||||
|
- name: alpha
|
||||||
|
desc: "nivel de significancia para las bandas de confianza y el test de Ljung-Box (default 0.05)."
|
||||||
|
output: "dict con 'acf' y 'pacf' (listas, indice 0 = lag 0), 'acf_confint'/'pacf_confint' (banda por lag), 'significant_acf_lags'/'significant_pacf_lags' (lags >=1 fuera de banda), 'ljung_box' (stat, p_value, lags) e 'is_autocorrelated' (bool: Ljung-Box rechaza independencia). Con <8 puntos: {'n', 'note', 'is_autocorrelated': None}. Nunca lanza excepcion."
|
||||||
|
tested: true
|
||||||
|
tests: ["test_ruido_blanco_no_autocorrelado", "test_ar1_es_autocorrelado", "test_lag1_significativo_en_ar1", "test_muestra_insuficiente_devuelve_nota", "test_descarta_none_y_nan", "test_recorta_nlags_a_limites", "test_acf_lag0_es_uno"]
|
||||||
|
test_file_path: "python/functions/datascience/acf_pacf_test.py"
|
||||||
|
file_path: "python/functions/datascience/acf_pacf.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import acf_pacf
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Ruido blanco: sin autocorrelacion (Ljung-Box no rechaza independencia)
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
ruido = rng.normal(0, 1, 500).tolist()
|
||||||
|
acf_pacf(ruido)["is_autocorrelated"] # -> False
|
||||||
|
|
||||||
|
# Proceso AR(1) fuerte: autocorrelado, lag 1 significativo en PACF
|
||||||
|
ar = [0.0]
|
||||||
|
for _ in range(500):
|
||||||
|
ar.append(0.8 * ar[-1] + rng.normal(0, 1))
|
||||||
|
res = acf_pacf(ar)
|
||||||
|
res["is_autocorrelated"] # -> True
|
||||||
|
res["significant_pacf_lags"][:1] # -> [1]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Para diagnosticar la estructura de dependencia temporal de una serie: identificar
|
||||||
|
el orden de un modelo ARIMA (PACF corta en el orden AR, ACF corta en el orden MA),
|
||||||
|
o detectar estacionalidad (picos en lags estacionales). Y, critico para EDA: antes
|
||||||
|
de meter una variable temporal en una regresion, comprueba `is_autocorrelated`. Si
|
||||||
|
es `True`, la serie no es IID y los p-valores de OLS estandar estan inflados — hay
|
||||||
|
que usar errores estandar robustos (Newey-West) o modelar la dinamica
|
||||||
|
explicitamente (Lopez de Prado).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Es pura pero importa `statsmodels` y `numpy` (ambos en `python/.venv`).
|
||||||
|
- `acf[0]` y `pacf[0]` valen siempre 1.0 (autocorrelacion de la serie consigo
|
||||||
|
misma en lag 0). Los lags interesantes empiezan en el indice 1.
|
||||||
|
- `nlags` se recorta automaticamente: PACF exige `nlags < n/2`. Si pides 40 lags
|
||||||
|
sobre una serie de 30 puntos, `nlags` efectivo baja — mira el campo `nlags`
|
||||||
|
del resultado para saber cuantos se calcularon.
|
||||||
|
- Las bandas de confianza asumen ruido blanco bajo H0; en una serie con
|
||||||
|
tendencia muchos lags saldran "significativos" por la propia tendencia, no por
|
||||||
|
estructura ARMA. Estaciona primero (ver adf_kpss_stationarity / to_returns).
|
||||||
|
- Ljung-Box es un test global (todos los lags juntos); los lags individuales
|
||||||
|
significativos te dicen DONDE esta la autocorrelacion.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Autocorrelacion (ACF) y autocorrelacion parcial (PACF) de una serie (grupo eda).
|
||||||
|
|
||||||
|
Funcion pura y determinista que calcula la funcion de autocorrelacion y la
|
||||||
|
parcial con sus bandas de confianza, mas el test de Ljung-Box de autocorrelacion
|
||||||
|
global. Motivada por Hyndman ("Forecasting") para identificar el orden de un
|
||||||
|
modelo ARIMA, y por Lopez de Prado ("Advances in Financial ML"): una serie
|
||||||
|
autocorrelacionada viola el supuesto IID, de modo que los p-valores de una
|
||||||
|
regresion OLS estandar sobre ella estan inflados (falsos positivos).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from statsmodels.stats.diagnostic import acorr_ljungbox
|
||||||
|
from statsmodels.tsa.stattools import acf, pacf
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(values: list) -> list[float]:
|
||||||
|
"""Conserva solo valores numericos finitos, descartando None/NaN/no-numericos.
|
||||||
|
|
||||||
|
Los booleanos se excluyen explicitamente (en Python ``bool`` es subclase de
|
||||||
|
``int``, pero no es un valor de serie temporal valido).
|
||||||
|
"""
|
||||||
|
out: list[float] = []
|
||||||
|
for v in values:
|
||||||
|
if v is None or isinstance(v, bool):
|
||||||
|
continue
|
||||||
|
if not isinstance(v, (int, float)):
|
||||||
|
continue
|
||||||
|
x = float(v)
|
||||||
|
if math.isnan(x) or math.isinf(x):
|
||||||
|
continue
|
||||||
|
out.append(x)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def acf_pacf(values: list, nlags: int = 40, alpha: float = 0.05) -> dict:
|
||||||
|
"""Calcula ACF, PACF y el test Ljung-Box de una serie temporal.
|
||||||
|
|
||||||
|
Computa la funcion de autocorrelacion (ACF) y la autocorrelacion parcial
|
||||||
|
(PACF) hasta ``nlags`` retardos, con sus bandas de confianza al nivel
|
||||||
|
``1 - alpha``, e identifica que retardos son significativos (cuyo intervalo
|
||||||
|
de confianza no contiene 0). Ademas corre el test de **Ljung-Box** sobre el
|
||||||
|
conjunto de retardos: H0 = "los datos son independientes" (sin
|
||||||
|
autocorrelacion); si ``p < alpha`` se rechaza -> la serie esta
|
||||||
|
autocorrelacionada.
|
||||||
|
|
||||||
|
Funcion pura y determinista: no hace I/O, no muta los inputs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
values: serie temporal de valores numericos en orden cronologico.
|
||||||
|
None/NaN/infinitos/no-numericos se descartan antes del calculo.
|
||||||
|
nlags: numero maximo de retardos a calcular (default 40). Se recorta
|
||||||
|
automaticamente a ``n // 2`` para PACF (statsmodels exige
|
||||||
|
``nlags < n/2``) y a ``n - 1`` para ACF.
|
||||||
|
alpha: nivel de significancia para las bandas de confianza y para el
|
||||||
|
test de Ljung-Box (default 0.05).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Con menos de 8 puntos validos devuelve
|
||||||
|
``{"n": n, "note": "datos insuficientes", "is_autocorrelated": None}``.
|
||||||
|
|
||||||
|
En otro caso un dict con::
|
||||||
|
|
||||||
|
{
|
||||||
|
"n": int,
|
||||||
|
"nlags": int, # retardos efectivamente calculados
|
||||||
|
"acf": [float, ...], # incluye lag 0 (=1.0) en el indice 0
|
||||||
|
"pacf": [float, ...],
|
||||||
|
"acf_confint": [[low, high], ...], # banda por lag
|
||||||
|
"pacf_confint": [[low, high], ...],
|
||||||
|
"significant_acf_lags": [int, ...], # lags (>=1) significativos
|
||||||
|
"significant_pacf_lags": [int, ...],
|
||||||
|
"ljung_box": {"stat": float, "p_value": float, "lags": int},
|
||||||
|
"is_autocorrelated": bool, # Ljung-Box rechaza independencia
|
||||||
|
}
|
||||||
|
|
||||||
|
``is_autocorrelated = True`` significa que la serie NO es ruido blanco:
|
||||||
|
cuidado al aplicarle inferencia OLS clasica (p-valores inflados).
|
||||||
|
"""
|
||||||
|
clean = _clean(values)
|
||||||
|
n = len(clean)
|
||||||
|
|
||||||
|
if n < 8:
|
||||||
|
return {"n": n, "note": "datos insuficientes", "is_autocorrelated": None}
|
||||||
|
|
||||||
|
arr = np.asarray(clean, dtype=float)
|
||||||
|
|
||||||
|
# Recorta nlags a los limites de statsmodels: ACF admite hasta n-1, PACF < n/2.
|
||||||
|
eff_lags = min(nlags, n - 1, (n // 2) - 1)
|
||||||
|
eff_lags = max(eff_lags, 1)
|
||||||
|
|
||||||
|
acf_vals, acf_confint = acf(arr, nlags=eff_lags, alpha=alpha, fft=False)
|
||||||
|
pacf_vals, pacf_confint = pacf(arr, nlags=eff_lags, alpha=alpha)
|
||||||
|
|
||||||
|
# Un lag es significativo si su banda de confianza (centrada en el valor) no
|
||||||
|
# contiene 0. statsmodels devuelve confint como intervalos centrados en el
|
||||||
|
# estimador, asi que comparamos el intervalo desplazado al origen.
|
||||||
|
def _significant(vals, confint) -> list[int]:
|
||||||
|
out: list[int] = []
|
||||||
|
for lag in range(1, len(vals)):
|
||||||
|
low = confint[lag][0] - vals[lag]
|
||||||
|
high = confint[lag][1] - vals[lag]
|
||||||
|
if vals[lag] < low or vals[lag] > high:
|
||||||
|
out.append(lag)
|
||||||
|
return out
|
||||||
|
|
||||||
|
significant_acf = _significant(acf_vals, acf_confint)
|
||||||
|
significant_pacf = _significant(pacf_vals, pacf_confint)
|
||||||
|
|
||||||
|
# Ljung-Box sobre el maximo retardo calculado.
|
||||||
|
lb = acorr_ljungbox(arr, lags=[eff_lags], return_df=True)
|
||||||
|
lb_stat = float(lb["lb_stat"].iloc[0])
|
||||||
|
lb_p = float(lb["lb_pvalue"].iloc[0])
|
||||||
|
is_autocorrelated = bool(lb_p < alpha)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"n": n,
|
||||||
|
"nlags": int(eff_lags),
|
||||||
|
"acf": [float(v) for v in acf_vals],
|
||||||
|
"pacf": [float(v) for v in pacf_vals],
|
||||||
|
"acf_confint": [[float(lo), float(hi)] for lo, hi in acf_confint],
|
||||||
|
"pacf_confint": [[float(lo), float(hi)] for lo, hi in pacf_confint],
|
||||||
|
"significant_acf_lags": significant_acf,
|
||||||
|
"significant_pacf_lags": significant_pacf,
|
||||||
|
"ljung_box": {
|
||||||
|
"stat": lb_stat,
|
||||||
|
"p_value": lb_p,
|
||||||
|
"lags": int(eff_lags),
|
||||||
|
},
|
||||||
|
"is_autocorrelated": is_autocorrelated,
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Tests para acf_pacf."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from acf_pacf import acf_pacf
|
||||||
|
|
||||||
|
|
||||||
|
def _ar1(phi: float, n: int, seed: int) -> list:
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
series = [0.0]
|
||||||
|
for _ in range(n):
|
||||||
|
series.append(phi * series[-1] + rng.normal(0, 1))
|
||||||
|
return series
|
||||||
|
|
||||||
|
|
||||||
|
def test_ruido_blanco_no_autocorrelado():
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
ruido = rng.normal(0, 1, 500).tolist()
|
||||||
|
res = acf_pacf(ruido)
|
||||||
|
assert res["is_autocorrelated"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ar1_es_autocorrelado():
|
||||||
|
ar = _ar1(0.8, 500, seed=1)
|
||||||
|
res = acf_pacf(ar)
|
||||||
|
assert res["is_autocorrelated"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_lag1_significativo_en_ar1():
|
||||||
|
# En un AR(1) la PACF corta tras el lag 1: lag 1 debe ser significativo.
|
||||||
|
ar = _ar1(0.8, 500, seed=2)
|
||||||
|
res = acf_pacf(ar)
|
||||||
|
assert 1 in res["significant_pacf_lags"]
|
||||||
|
assert 1 in res["significant_acf_lags"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_muestra_insuficiente_devuelve_nota():
|
||||||
|
res = acf_pacf([1, 2, 3, 4, 5])
|
||||||
|
assert res["n"] == 5
|
||||||
|
assert res["note"] == "datos insuficientes"
|
||||||
|
assert res["is_autocorrelated"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_descarta_none_y_nan():
|
||||||
|
rng = np.random.default_rng(3)
|
||||||
|
base = rng.normal(0, 1, 200).tolist()
|
||||||
|
sucio = []
|
||||||
|
for i, v in enumerate(base):
|
||||||
|
sucio.append(v)
|
||||||
|
if i % 25 == 0:
|
||||||
|
sucio.append(None)
|
||||||
|
sucio.append(float("nan"))
|
||||||
|
res = acf_pacf(sucio)
|
||||||
|
assert res["n"] == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_recorta_nlags_a_limites():
|
||||||
|
# Serie de 20 puntos con nlags=40: debe recortar a < n/2.
|
||||||
|
rng = np.random.default_rng(4)
|
||||||
|
serie = rng.normal(0, 1, 20).tolist()
|
||||||
|
res = acf_pacf(serie, nlags=40)
|
||||||
|
assert res["nlags"] < 20 // 2
|
||||||
|
assert len(res["acf"]) == res["nlags"] + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_acf_lag0_es_uno():
|
||||||
|
rng = np.random.default_rng(5)
|
||||||
|
serie = rng.normal(0, 1, 100).tolist()
|
||||||
|
res = acf_pacf(serie)
|
||||||
|
assert abs(res["acf"][0] - 1.0) < 1e-9
|
||||||
|
assert abs(res["pacf"][0] - 1.0) < 1e-9
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
name: adf_kpss_stationarity
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def adf_kpss_stationarity(values: list, alpha: float = 0.05) -> dict"
|
||||||
|
description: "Test de estacionariedad de una serie temporal combinando ADF (H0=raiz unitaria/no estacionaria) y KPSS (H0=estacionaria) de statsmodels. Devuelve por test estadistico, p_value, lags y conclusion, mas un veredicto de consenso ('stationary'|'non_stationary'|'inconclusive'). Avisa de correlacion espuria (Granger-Newbold) cuando la serie no es estacionaria. Descarta None/NaN/infinitos; <8 puntos validos -> nota 'datos insuficientes'."
|
||||||
|
tags: [statistics, timeseries, stationarity, adf, kpss, unit-root, eda, forecasting, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [math, warnings, statsmodels]
|
||||||
|
params:
|
||||||
|
- name: values
|
||||||
|
desc: "serie temporal de valores numericos en orden cronologico. None/NaN/infinitos/no-numericos se descartan antes del test."
|
||||||
|
- name: alpha
|
||||||
|
desc: "nivel de significancia para ambos contrastes (default 0.05). p<alpha rechaza la hipotesis nula del test correspondiente."
|
||||||
|
output: "dict con 'adf' y 'kpss' (cada uno: stat, p_value, lags, stationary bool, conclusion), un 'verdict' de consenso ('stationary'|'non_stationary'|'inconclusive'), y 'warning' (texto sobre correlacion espuria si el veredicto no es stationary, si no None). Con <8 puntos validos: {'n', 'note': 'datos insuficientes', 'verdict': None}. Nunca lanza excepcion."
|
||||||
|
tested: true
|
||||||
|
tests: ["test_random_walk_es_no_estacionario", "test_ruido_blanco_es_estacionario", "test_serie_con_tendencia_no_es_estacionaria", "test_muestra_insuficiente_devuelve_nota", "test_descarta_none_y_nan", "test_warning_presente_si_no_estacionaria", "test_estructura_basica_del_dict"]
|
||||||
|
test_file_path: "python/functions/datascience/adf_kpss_stationarity_test.py"
|
||||||
|
file_path: "python/functions/datascience/adf_kpss_stationarity.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import adf_kpss_stationarity
|
||||||
|
|
||||||
|
# Ruido blanco: estacionario (ADF rechaza raiz unitaria, KPSS no rechaza estacionariedad)
|
||||||
|
import numpy as np
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
ruido = rng.normal(0, 1, 300).tolist()
|
||||||
|
adf_kpss_stationarity(ruido)["verdict"] # -> "stationary"
|
||||||
|
|
||||||
|
# Random walk (suma acumulada): NO estacionario
|
||||||
|
paseo = np.cumsum(rng.normal(0, 1, 300)).tolist()
|
||||||
|
res = adf_kpss_stationarity(paseo)
|
||||||
|
res["verdict"] # -> "non_stationary"
|
||||||
|
res["warning"] # -> aviso de correlacion espuria
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Antes de correlacionar, regresionar o modelar (ARIMA, VAR) una serie temporal,
|
||||||
|
para saber si es estacionaria. Es el primer paso obligatorio del analisis de
|
||||||
|
series: una serie no estacionaria (con tendencia o raiz unitaria) rompe los
|
||||||
|
supuestos de la regresion OLS clasica y, si la correlacionas con otra serie no
|
||||||
|
estacionaria, obtienes una correlacion alta pero **espuria** (Granger-Newbold).
|
||||||
|
Si el veredicto no es `"stationary"`, diferencia la serie o pasala a retornos
|
||||||
|
(`to_returns`) y vuelve a testear.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Es pura pero importa `statsmodels.tsa.stattools` (instalado en `python/.venv`).
|
||||||
|
- ADF y KPSS tienen hipotesis nulas OPUESTAS: en ADF `p<alpha` significa
|
||||||
|
estacionaria; en KPSS `p<alpha` significa NO estacionaria. La funcion ya
|
||||||
|
normaliza ambos a un campo `stationary` coherente — no inviertas tu la logica.
|
||||||
|
- KPSS interpola el p-valor sobre una tabla acotada `[0.01, 0.10]`: si el
|
||||||
|
estadistico cae fuera, statsmodels recorta el p-valor al extremo y lo marca en
|
||||||
|
`kpss.p_value_clipped = True`. Un p recortado a 0.01 o 0.10 es un limite, no un
|
||||||
|
valor exacto.
|
||||||
|
- El veredicto `"inconclusive"` suele indicar serie estacionaria-en-tendencia o
|
||||||
|
que necesita diferenciacion; no es un fallo, es informacion.
|
||||||
|
- Necesita al menos 8 puntos validos tras limpiar; con menos devuelve una nota.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Tests de estacionariedad de una serie temporal: ADF + KPSS (grupo eda).
|
||||||
|
|
||||||
|
Funcion pura y determinista que combina dos contrastes de estacionariedad con
|
||||||
|
hipotesis nulas opuestas y emite un veredicto de consenso. Motivada por la
|
||||||
|
necesidad (Hyndman "Forecasting", Hamilton "Time Series Analysis") de saber si
|
||||||
|
una serie es estacionaria ANTES de correlacionarla o modelarla: correlacionar
|
||||||
|
niveles no estacionarios produce correlacion espuria (Granger-Newbold 1974).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
from statsmodels.tsa.stattools import adfuller, kpss
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(values: list) -> list[float]:
|
||||||
|
"""Conserva solo valores numericos finitos, descartando None/NaN/no-numericos.
|
||||||
|
|
||||||
|
Los booleanos se excluyen explicitamente: en Python ``bool`` es subclase de
|
||||||
|
``int``, pero tratar True/False como numeros en una serie temporal es casi
|
||||||
|
siempre un error de tipado.
|
||||||
|
"""
|
||||||
|
out: list[float] = []
|
||||||
|
for v in values:
|
||||||
|
if v is None or isinstance(v, bool):
|
||||||
|
continue
|
||||||
|
if not isinstance(v, (int, float)):
|
||||||
|
continue
|
||||||
|
x = float(v)
|
||||||
|
if math.isnan(x) or math.isinf(x):
|
||||||
|
continue
|
||||||
|
out.append(x)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def adf_kpss_stationarity(values: list, alpha: float = 0.05) -> dict:
|
||||||
|
"""Evalua la estacionariedad de una serie combinando ADF y KPSS.
|
||||||
|
|
||||||
|
Aplica dos contrastes con hipotesis nulas opuestas:
|
||||||
|
|
||||||
|
- **ADF** (Augmented Dickey-Fuller): H0 = "la serie tiene raiz unitaria"
|
||||||
|
(es NO estacionaria). Si ``p < alpha`` se rechaza H0 -> evidencia de
|
||||||
|
estacionariedad.
|
||||||
|
- **KPSS** (Kwiatkowski-Phillips-Schmidt-Shin): H0 = "la serie es
|
||||||
|
estacionaria (en torno a una tendencia)". Si ``p < alpha`` se rechaza H0
|
||||||
|
-> evidencia de NO estacionariedad.
|
||||||
|
|
||||||
|
Combinar ambos da mas robustez que cualquiera por separado, porque sus
|
||||||
|
hipotesis nulas son contrarias. El veredicto de consenso sigue la
|
||||||
|
interpretacion estandar (Hyndman, "Forecasting: Principles and Practice"):
|
||||||
|
|
||||||
|
- ADF rechaza H0 **y** KPSS no rechaza H0 -> ``"stationary"``.
|
||||||
|
- ADF no rechaza H0 **y** KPSS rechaza H0 -> ``"non_stationary"``.
|
||||||
|
- Ambos coinciden en lo contrario o se contradicen -> ``"inconclusive"``
|
||||||
|
(a menudo indica serie diferenciable o estacionaria en tendencia).
|
||||||
|
|
||||||
|
Funcion pura y determinista: no hace I/O, no muta los inputs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
values: serie temporal de valores numericos en orden cronologico.
|
||||||
|
None/NaN/infinitos/no-numericos se descartan antes del test.
|
||||||
|
alpha: nivel de significancia para ambos contrastes (default 0.05).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Con menos de 8 puntos validos (muestra insuficiente para un test de
|
||||||
|
raiz unitaria fiable) devuelve
|
||||||
|
``{"n": n, "note": "datos insuficientes", "verdict": None}``.
|
||||||
|
|
||||||
|
En otro caso un dict con::
|
||||||
|
|
||||||
|
{
|
||||||
|
"n": int,
|
||||||
|
"alpha": float,
|
||||||
|
"adf": {"stat": float, "p_value": float, "lags": int,
|
||||||
|
"stationary": bool, # rechaza H0 de raiz unitaria
|
||||||
|
"conclusion": str},
|
||||||
|
"kpss": {"stat": float, "p_value": float, "lags": int,
|
||||||
|
"stationary": bool, # NO rechaza H0 de estacionariedad
|
||||||
|
"conclusion": str,
|
||||||
|
"p_value_clipped": bool}, # p en limite de tabla KPSS
|
||||||
|
"verdict": "stationary" | "non_stationary" | "inconclusive",
|
||||||
|
"warning": str | None, # aviso de correlacion espuria si procede
|
||||||
|
}
|
||||||
|
|
||||||
|
``warning`` se rellena cuando el veredicto NO es ``"stationary"`` para
|
||||||
|
recordar que correlacionar/regresionar niveles no estacionarios produce
|
||||||
|
relaciones espurias; conviene pasar a retornos o diferencias.
|
||||||
|
"""
|
||||||
|
clean = _clean(values)
|
||||||
|
n = len(clean)
|
||||||
|
|
||||||
|
if n < 8:
|
||||||
|
return {"n": n, "note": "datos insuficientes", "verdict": None}
|
||||||
|
|
||||||
|
# ADF: H0 = raiz unitaria (no estacionaria). p < alpha => estacionaria.
|
||||||
|
adf_stat, adf_p, adf_lags, _adf_nobs, _adf_crit, _adf_icbest = adfuller(
|
||||||
|
clean, autolag="AIC"
|
||||||
|
)
|
||||||
|
adf_stationary = bool(adf_p < alpha)
|
||||||
|
adf = {
|
||||||
|
"stat": float(adf_stat),
|
||||||
|
"p_value": float(adf_p),
|
||||||
|
"lags": int(adf_lags),
|
||||||
|
"stationary": adf_stationary,
|
||||||
|
"conclusion": (
|
||||||
|
"rechaza H0 de raiz unitaria: evidencia de estacionariedad"
|
||||||
|
if adf_stationary
|
||||||
|
else "no rechaza H0 de raiz unitaria: posible no estacionaria"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# KPSS: H0 = estacionaria en torno a tendencia. p < alpha => NO estacionaria.
|
||||||
|
# statsmodels emite InterpolationWarning cuando el p-valor cae fuera de la
|
||||||
|
# tabla [0.01, 0.10]; lo capturamos para saber si quedo recortado.
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
kpss_stat, kpss_p, kpss_lags, _kpss_crit = kpss(
|
||||||
|
clean, regression="c", nlags="auto"
|
||||||
|
)
|
||||||
|
p_clipped = any("InterpolationWarning" in str(w.category) for w in caught) or any(
|
||||||
|
"p-value" in str(w.message).lower() for w in caught
|
||||||
|
)
|
||||||
|
kpss_stationary = bool(kpss_p >= alpha) # NO rechaza H0 => estacionaria
|
||||||
|
kpss_result = {
|
||||||
|
"stat": float(kpss_stat),
|
||||||
|
"p_value": float(kpss_p),
|
||||||
|
"lags": int(kpss_lags),
|
||||||
|
"stationary": kpss_stationary,
|
||||||
|
"conclusion": (
|
||||||
|
"no rechaza H0 de estacionariedad: evidencia de estacionariedad"
|
||||||
|
if kpss_stationary
|
||||||
|
else "rechaza H0 de estacionariedad: posible no estacionaria"
|
||||||
|
),
|
||||||
|
"p_value_clipped": bool(p_clipped),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Consenso de los dos contrastes.
|
||||||
|
if adf_stationary and kpss_stationary:
|
||||||
|
verdict = "stationary"
|
||||||
|
elif (not adf_stationary) and (not kpss_stationary):
|
||||||
|
verdict = "non_stationary"
|
||||||
|
else:
|
||||||
|
verdict = "inconclusive"
|
||||||
|
|
||||||
|
warning: str | None = None
|
||||||
|
if verdict != "stationary":
|
||||||
|
warning = (
|
||||||
|
"serie no claramente estacionaria: correlacionar o regresionar sus "
|
||||||
|
"niveles puede dar relaciones espurias (Granger-Newbold). Considera "
|
||||||
|
"trabajar sobre retornos o diferencias (ver to_returns)."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"n": n,
|
||||||
|
"alpha": float(alpha),
|
||||||
|
"adf": adf,
|
||||||
|
"kpss": kpss_result,
|
||||||
|
"verdict": verdict,
|
||||||
|
"warning": warning,
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Tests para adf_kpss_stationarity."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from adf_kpss_stationarity import adf_kpss_stationarity
|
||||||
|
|
||||||
|
|
||||||
|
def test_random_walk_es_no_estacionario():
|
||||||
|
# Random walk = suma acumulada de ruido: tiene raiz unitaria.
|
||||||
|
rng = np.random.default_rng(123)
|
||||||
|
paseo = np.cumsum(rng.normal(0.0, 1.0, 400)).tolist()
|
||||||
|
res = adf_kpss_stationarity(paseo)
|
||||||
|
assert res["verdict"] == "non_stationary"
|
||||||
|
assert res["adf"]["stationary"] is False
|
||||||
|
assert res["kpss"]["stationary"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ruido_blanco_es_estacionario():
|
||||||
|
# Ruido blanco gaussiano: estacionario por construccion.
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
ruido = rng.normal(0.0, 1.0, 400).tolist()
|
||||||
|
res = adf_kpss_stationarity(ruido)
|
||||||
|
assert res["verdict"] == "stationary"
|
||||||
|
assert res["adf"]["stationary"] is True
|
||||||
|
assert res["kpss"]["stationary"] is True
|
||||||
|
assert res["warning"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_serie_con_tendencia_no_es_estacionaria():
|
||||||
|
# Tendencia lineal determinista + ruido pequeno: KPSS la marca no estacionaria.
|
||||||
|
rng = np.random.default_rng(7)
|
||||||
|
serie = [0.1 * i + rng.normal(0, 0.5) for i in range(300)]
|
||||||
|
res = adf_kpss_stationarity(serie)
|
||||||
|
assert res["verdict"] != "stationary"
|
||||||
|
assert res["warning"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_muestra_insuficiente_devuelve_nota():
|
||||||
|
res = adf_kpss_stationarity([1, 2, 3, 4, 5])
|
||||||
|
assert res["n"] == 5
|
||||||
|
assert res["note"] == "datos insuficientes"
|
||||||
|
assert res["verdict"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_descarta_none_y_nan():
|
||||||
|
rng = np.random.default_rng(1)
|
||||||
|
base = rng.normal(0, 1, 200).tolist()
|
||||||
|
sucio = []
|
||||||
|
for i, v in enumerate(base):
|
||||||
|
sucio.append(v)
|
||||||
|
if i % 20 == 0:
|
||||||
|
sucio.append(None)
|
||||||
|
sucio.append(float("nan"))
|
||||||
|
res = adf_kpss_stationarity(sucio)
|
||||||
|
assert res["n"] == 200 # las None/NaN no cuentan
|
||||||
|
|
||||||
|
|
||||||
|
def test_warning_presente_si_no_estacionaria():
|
||||||
|
# Tendencia lineal fuerte: garantiza no estacionariedad (verdict != stationary).
|
||||||
|
rng = np.random.default_rng(99)
|
||||||
|
serie = [0.5 * i + rng.normal(0, 0.3) for i in range(300)]
|
||||||
|
res = adf_kpss_stationarity(serie)
|
||||||
|
assert res["verdict"] != "stationary"
|
||||||
|
assert res["warning"] is not None
|
||||||
|
assert "espuria" in res["warning"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_estructura_basica_del_dict():
|
||||||
|
rng = np.random.default_rng(5)
|
||||||
|
ruido = rng.normal(0, 1, 100).tolist()
|
||||||
|
res = adf_kpss_stationarity(ruido)
|
||||||
|
for key in ("n", "alpha", "adf", "kpss", "verdict"):
|
||||||
|
assert key in res
|
||||||
|
for sub in ("stat", "p_value", "lags", "stationary", "conclusion"):
|
||||||
|
assert sub in res["adf"]
|
||||||
|
assert sub in res["kpss"]
|
||||||
@@ -3,19 +3,23 @@ name: association_matrix
|
|||||||
kind: function
|
kind: function
|
||||||
lang: py
|
lang: py
|
||||||
domain: datascience
|
domain: datascience
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: pure
|
purity: pure
|
||||||
signature: "def association_matrix(columns: dict, strong_threshold: float = 0.5, top_n: int = 20) -> dict"
|
signature: "def association_matrix(columns: dict, strong_threshold: float = 0.5, top_n: int = 20, alpha: float = 0.05, fdr_method: str = \"bh\") -> dict"
|
||||||
description: "Matriz de asociacion unificada de una tabla con tipos mezclados: elige la metrica correcta por par de tipos (Pearson/Spearman num-num, Cramer's V cat-cat, correlation ratio num-cat) y calcula informacion mutua normalizada comun para todos los pares. Devuelve pares evaluados, pares fuertes y leyenda de metodos."
|
description: "Matriz de asociacion unificada de una tabla con tipos mezclados: elige la metrica correcta por par de tipos (Pearson/Spearman num-num, Cramer's V cat-cat, correlation ratio num-cat) y calcula informacion mutua normalizada comun para todos los pares. Cada par lleva su p-valor (test de correlacion / chi-cuadrado / ANOVA) y se corrige por comparaciones multiples (FDR) para combatir el sesgo de mineria de datos: el subconjunto fuerte se basa en la significancia corregida, no solo en superar el umbral de magnitud."
|
||||||
tags: [eda, correlation, association, statistics, mixed-types, mutual-information]
|
tags: [eda, correlation, association, statistics, mixed-types, mutual-information, multiple-testing, p-value, fdr]
|
||||||
params:
|
params:
|
||||||
- name: columns
|
- name: columns
|
||||||
desc: "dict {nombre_columna: {\"values\": list, \"type\": \"numeric\"|\"categorical\"|\"datetime\"|\"boolean\"|\"text\"}}. datetime/boolean/text se tratan como categoricas; text de cardinalidad ~ n se salta como ruido."
|
desc: "dict {nombre_columna: {\"values\": list, \"type\": \"numeric\"|\"categorical\"|\"datetime\"|\"boolean\"|\"text\"}}. datetime/boolean/text se tratan como categoricas; text de cardinalidad ~ n se salta como ruido."
|
||||||
- name: strong_threshold
|
- name: strong_threshold
|
||||||
desc: "Umbral en [0, 1]. Un par es fuerte si abs(value) >= umbral o extra.mi >= umbral. Default 0.5."
|
desc: "Umbral de magnitud en [0, 1]. Condicion necesaria (ya no suficiente) para ser fuerte: abs(value) >= umbral o extra.mi >= umbral. Default 0.5."
|
||||||
- name: top_n
|
- name: top_n
|
||||||
desc: "Maximo de pares fuertes a devolver, ordenados por relevancia (max(abs(value), mi)) desc. Default 20."
|
desc: "Maximo de pares fuertes a devolver, ordenados por relevancia (max(abs(value), mi)) desc. Default 20."
|
||||||
output: "dict {pairs: lista de todos los pares {a, b, a_type, b_type, method, value, extra}; strong: subconjunto fuerte ordenado por relevancia desc truncado a top_n; methods_legend: dict metodo->descripcion}. Pura: con dict vacio o 1 columna devuelve pairs=[] y strong=[]."
|
- name: alpha
|
||||||
|
desc: "Nivel de significancia tras la correccion FDR (default 0.05). Un par con p-valor disponible solo es fuerte si ademas su p-valor ajustado <= alpha."
|
||||||
|
- name: fdr_method
|
||||||
|
desc: "Metodo de correccion de comparaciones multiples: 'bh' (Benjamini-Hochberg, FDR; default) o 'bonferroni' (FWER, mas conservador)."
|
||||||
|
output: "dict {pairs: lista de todos los pares {a, b, a_type, b_type, method, value, extra, p_value, p_value_adjusted, significant}; strong: subconjunto con magnitud >= umbral Y significativo tras FDR (pares sin test se admiten por magnitud), ordenado por relevancia desc truncado a top_n; methods_legend: dict metodo->descripcion; n_tests: nº total de pares evaluados (== len(pairs)); multiple_testing: {method, alpha, n_tests, n_rejected}}. Pura: con dict vacio o 1 columna devuelve pairs=[] y strong=[]."
|
||||||
uses_functions:
|
uses_functions:
|
||||||
- pearson_py_datascience
|
- pearson_py_datascience
|
||||||
- spearman_corr_py_datascience
|
- spearman_corr_py_datascience
|
||||||
@@ -23,13 +27,14 @@ uses_functions:
|
|||||||
- theils_u_py_datascience
|
- theils_u_py_datascience
|
||||||
- correlation_ratio_py_datascience
|
- correlation_ratio_py_datascience
|
||||||
- mutual_info_columns_py_datascience
|
- mutual_info_columns_py_datascience
|
||||||
|
- fdr_correction_py_datascience
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
returns_optional: false
|
returns_optional: false
|
||||||
error_type: ""
|
error_type: ""
|
||||||
imports: []
|
imports: [scipy]
|
||||||
tested: true
|
tested: true
|
||||||
tests: ["test_two_correlated_numerics_strong_pearson", "test_numeric_explained_by_category_strong_correlation_ratio", "test_independent_pair_not_strong", "test_empty_dict_does_not_crash", "test_single_column_returns_empty"]
|
tests: ["test_two_correlated_numerics_strong_pearson", "test_numeric_explained_by_category_strong_correlation_ratio", "test_independent_pair_not_strong", "test_empty_dict_does_not_crash", "test_single_column_returns_empty", "test_pairs_carry_significance_fields", "test_result_reports_multiple_testing_summary", "test_strong_requires_corrected_significance", "test_bonferroni_method_is_accepted"]
|
||||||
test_file_path: "python/functions/datascience/association_matrix_test.py"
|
test_file_path: "python/functions/datascience/association_matrix_test.py"
|
||||||
file_path: "python/functions/datascience/association_matrix.py"
|
file_path: "python/functions/datascience/association_matrix.py"
|
||||||
---
|
---
|
||||||
@@ -84,3 +89,36 @@ no-lineal a todos los pares.
|
|||||||
categorica como primer argumento y la numerica como segundo.
|
categorica como primer argumento y la numerica como segundo.
|
||||||
- Se saltan columnas con menos de 3 valores validos, y columnas `text` cuya
|
- Se saltan columnas con menos de 3 valores validos, y columnas `text` cuya
|
||||||
cardinalidad sea >= 90% del numero de filas (identificadores / free-text).
|
cardinalidad sea >= 90% del numero de filas (identificadores / free-text).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Ahora corrige multiple-testing (v1.1.0).** El subconjunto `strong` ya no
|
||||||
|
depende solo de la magnitud: un par con magnitud alta pero p-valor ajustado
|
||||||
|
> `alpha` NO entra en `strong`. Esto combate el sesgo de mineria de datos
|
||||||
|
(data-mining bias, Aronson cap. 6): al evaluar todos los pares a la vez, el
|
||||||
|
azar produce correlaciones espurias que el umbral de magnitud por si solo
|
||||||
|
dejaria pasar.
|
||||||
|
- Cada par lleva `p_value` (test del metodo principal: correlacion de
|
||||||
|
Pearson/Spearman, chi-cuadrado de independencia para Cramer's V, ANOVA de una
|
||||||
|
via para correlation ratio) y `p_value_adjusted` (tras `fdr_correction`). La
|
||||||
|
informacion mutua no tiene test asociado, por lo que un par cuyo metodo
|
||||||
|
principal sea degenerado puede tener `p_value = None`; esos pares se admiten en
|
||||||
|
`strong` por magnitud (no hay p-valor que corregir).
|
||||||
|
- `n_tests` (top-level) es el numero total de pares evaluados (`len(pairs)`),
|
||||||
|
mientras que `multiple_testing.n_tests` es el numero de p-valores **validos**
|
||||||
|
que entraron en la correccion (puede ser menor si algun par no tiene test).
|
||||||
|
- Sigue siendo pura, pero ahora importa `scipy.stats` (`pearsonr`, `spearmanr`,
|
||||||
|
`chi2_contingency`, `f_oneway`) para los p-valores; scipy ya vive en
|
||||||
|
`python/.venv`.
|
||||||
|
- Sube `alpha` o usa `fdr_method="bonferroni"` segun lo costoso que sea un falso
|
||||||
|
positivo: BH controla la tasa de falsos descubrimientos (mas potencia),
|
||||||
|
Bonferroni la probabilidad de cualquier falso positivo (mas cautela).
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (28/06/2026) — anade p-valor por par (Pearson/Spearman, chi-cuadrado,
|
||||||
|
ANOVA) + correccion de comparaciones multiples via `fdr_correction` (BH /
|
||||||
|
Bonferroni). `strong` pasa a basarse en la significancia corregida, no solo en
|
||||||
|
el umbral de magnitud. Nuevos parametros `alpha` y `fdr_method`; nuevas claves
|
||||||
|
`p_value`/`p_value_adjusted`/`significant` por par y `n_tests`/
|
||||||
|
`multiple_testing` en el resultado. Retrocompatible: no quita claves previas.
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ metodos. Compone las funciones atomicas del registry; no reimplementa metricas.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
|
||||||
|
from scipy.stats import chi2_contingency, f_oneway, pearsonr, spearmanr
|
||||||
|
|
||||||
from datascience import (
|
from datascience import (
|
||||||
correlation_ratio,
|
correlation_ratio,
|
||||||
@@ -19,6 +22,10 @@ from datascience import (
|
|||||||
theils_u,
|
theils_u,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Modulo hoja directo: no depende de que el paquete reexporte la funcion en su
|
||||||
|
# __init__ (lo integra el orquestador al cerrar el grupo eda).
|
||||||
|
from datascience.fdr_correction import fdr_correction
|
||||||
|
|
||||||
# Tipos que, para efectos de asociacion, se tratan como categoricos.
|
# Tipos que, para efectos de asociacion, se tratan como categoricos.
|
||||||
_CATEGORICAL_LIKE = {"categorical", "datetime", "boolean", "text"}
|
_CATEGORICAL_LIKE = {"categorical", "datetime", "boolean", "text"}
|
||||||
|
|
||||||
@@ -59,10 +66,83 @@ def _clean_numeric_pairs(xs: list, ys: list) -> tuple[list, list]:
|
|||||||
return cx, cy
|
return cx, cy
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_pvalue(value) -> float | None:
|
||||||
|
"""Convierte un p-valor de scipy a float, devolviendo None si es NaN/invalido."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
pv = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if math.isnan(pv) or math.isinf(pv):
|
||||||
|
return None
|
||||||
|
return pv
|
||||||
|
|
||||||
|
|
||||||
|
def _pearson_pvalue(cx: list, cy: list) -> float | None:
|
||||||
|
"""p-valor del test de correlacion de Pearson (H0: r == 0). None si degenerado."""
|
||||||
|
if len(cx) < 3 or len(set(cx)) < 2 or len(set(cy)) < 2:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return _safe_pvalue(pearsonr(cx, cy).pvalue)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _spearman_pvalue(cx: list, cy: list) -> float | None:
|
||||||
|
"""p-valor del test de correlacion de Spearman (H0: rho == 0). None si degenerado."""
|
||||||
|
if len(cx) < 3 or len(set(cx)) < 2 or len(set(cy)) < 2:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return _safe_pvalue(spearmanr(cx, cy).pvalue)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _chi2_pvalue(a_vals: list, b_vals: list) -> float | None:
|
||||||
|
"""p-valor del test chi-cuadrado de independencia (cat-cat). None si degenerado."""
|
||||||
|
pairs = [(x, y) for x, y in zip(a_vals, b_vals) if x is not None and y is not None]
|
||||||
|
if len(pairs) < 2:
|
||||||
|
return None
|
||||||
|
rows = sorted({x for x, _ in pairs}, key=repr)
|
||||||
|
cols = sorted({y for _, y in pairs}, key=repr)
|
||||||
|
if len(rows) < 2 or len(cols) < 2:
|
||||||
|
return None
|
||||||
|
row_idx = {v: i for i, v in enumerate(rows)}
|
||||||
|
col_idx = {v: j for j, v in enumerate(cols)}
|
||||||
|
counts = Counter((row_idx[x], col_idx[y]) for x, y in pairs)
|
||||||
|
table = [
|
||||||
|
[counts.get((i, j), 0) for j in range(len(cols))]
|
||||||
|
for i in range(len(rows))
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
return _safe_pvalue(chi2_contingency(table).pvalue)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _anova_pvalue(cat_vals: list, num_vals: list) -> float | None:
|
||||||
|
"""p-valor del ANOVA de una via (H0: misma media numerica por categoria). None si degenerado."""
|
||||||
|
groups: dict = defaultdict(list)
|
||||||
|
for c, x in zip(cat_vals, num_vals):
|
||||||
|
if c is None or not _is_num(x):
|
||||||
|
continue
|
||||||
|
groups[c].append(float(x))
|
||||||
|
valid = [g for g in groups.values() if len(g) >= 2]
|
||||||
|
if len(valid) < 2:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return _safe_pvalue(f_oneway(*valid).pvalue)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def association_matrix(
|
def association_matrix(
|
||||||
columns: dict,
|
columns: dict,
|
||||||
strong_threshold: float = 0.5,
|
strong_threshold: float = 0.5,
|
||||||
top_n: int = 20,
|
top_n: int = 20,
|
||||||
|
alpha: float = 0.05,
|
||||||
|
fdr_method: str = "bh",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Construye la matriz de asociacion de una tabla con tipos mezclados.
|
"""Construye la matriz de asociacion de una tabla con tipos mezclados.
|
||||||
|
|
||||||
@@ -81,22 +161,48 @@ def association_matrix(
|
|||||||
asociacion util). Es una funcion pura: no falla con dict vacio o una sola
|
asociacion util). Es una funcion pura: no falla con dict vacio o una sola
|
||||||
columna (devuelve `pairs=[]`, `strong=[]`).
|
columna (devuelve `pairs=[]`, `strong=[]`).
|
||||||
|
|
||||||
|
Ademas de la magnitud de la asociacion, cada par evaluado lleva un p-valor
|
||||||
|
del test de hipotesis adecuado a su metodo (Pearson/Spearman: test de
|
||||||
|
correlacion; Cramer's V: chi-cuadrado de independencia; correlation ratio:
|
||||||
|
ANOVA de una via; informacion mutua: sin test, p-valor None). Como se evaluan
|
||||||
|
todos los pares a la vez, esos p-valores se corrigen por comparaciones
|
||||||
|
multiples con `fdr_correction` (data-mining bias, Aronson cap. 6) y el
|
||||||
|
subconjunto `strong` se basa en la **significancia corregida**, no solo en
|
||||||
|
superar el umbral de magnitud: un par con magnitud alta pero p-valor ajustado
|
||||||
|
> alpha NO entra en `strong`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
columns: dict {nombre_columna: {"values": list, "type": str}} donde type
|
columns: dict {nombre_columna: {"values": list, "type": str}} donde type
|
||||||
es uno de "numeric", "categorical", "datetime", "boolean", "text".
|
es uno de "numeric", "categorical", "datetime", "boolean", "text".
|
||||||
Los tipos datetime/boolean/text se tratan como categoricos.
|
Los tipos datetime/boolean/text se tratan como categoricos.
|
||||||
strong_threshold: umbral en [0, 1]. Un par es "fuerte" si
|
strong_threshold: umbral en [0, 1]. Condicion de magnitud para ser
|
||||||
abs(value) >= umbral o extra["mi"] >= umbral.
|
"fuerte": abs(value) >= umbral o extra["mi"] >= umbral. Necesaria pero
|
||||||
|
ya no suficiente (ver alpha).
|
||||||
top_n: numero maximo de pares fuertes a devolver, ordenados por
|
top_n: numero maximo de pares fuertes a devolver, ordenados por
|
||||||
relevancia (max(abs(value), mi)) descendente.
|
relevancia (max(abs(value), mi)) descendente.
|
||||||
|
alpha: nivel de significancia tras la correccion FDR (default 0.05). Un
|
||||||
|
par con p-valor disponible solo es fuerte si ademas su p-valor
|
||||||
|
ajustado <= alpha.
|
||||||
|
fdr_method: metodo de correccion de comparaciones multiples,
|
||||||
|
"bh" (Benjamini-Hochberg, FDR; default) o "bonferroni" (FWER).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict con claves:
|
dict con claves:
|
||||||
pairs: lista de todos los pares evaluados, cada uno
|
pairs: lista de todos los pares evaluados, cada uno
|
||||||
{a, b, a_type, b_type, method, value, extra}.
|
{a, b, a_type, b_type, method, value, extra, p_value,
|
||||||
strong: subconjunto de pairs por encima del umbral, ordenado por
|
p_value_adjusted, significant}. `p_value` es el del test del
|
||||||
relevancia descendente y truncado a top_n.
|
metodo principal (None si no aplica / degenerado);
|
||||||
|
`p_value_adjusted` el p-valor tras FDR; `significant` True si
|
||||||
|
p_value_adjusted <= alpha.
|
||||||
|
strong: subconjunto de pairs que cumplen magnitud >= umbral Y son
|
||||||
|
significativos tras la correccion (los pares sin test disponible
|
||||||
|
se admiten por magnitud), ordenado por relevancia descendente y
|
||||||
|
truncado a top_n.
|
||||||
methods_legend: dict {metodo: descripcion}.
|
methods_legend: dict {metodo: descripcion}.
|
||||||
|
n_tests: numero total de pares evaluados (== len(pairs)).
|
||||||
|
multiple_testing: dict {method, alpha, n_tests, n_rejected} con el
|
||||||
|
resumen de la correccion (n_tests aqui = p-valores validos
|
||||||
|
corregidos, puede ser < len(pairs) si algun par no tiene test).
|
||||||
"""
|
"""
|
||||||
legend = {
|
legend = {
|
||||||
"pearson": "num-num lineal (Pearson r), signo indica direccion, [-1, 1]",
|
"pearson": "num-num lineal (Pearson r), signo indica direccion, [-1, 1]",
|
||||||
@@ -168,20 +274,32 @@ def association_matrix(
|
|||||||
s = spearman_corr(a_vals, b_vals)
|
s = spearman_corr(a_vals, b_vals)
|
||||||
extra["pearson"] = p
|
extra["pearson"] = p
|
||||||
extra["spearman"] = s
|
extra["spearman"] = s
|
||||||
value = p if abs(p) >= abs(s) else s
|
pearson_p = _pearson_pvalue(cx, cy)
|
||||||
|
spearman_p = _spearman_pvalue(cx, cy)
|
||||||
|
extra["pearson_p"] = pearson_p
|
||||||
|
extra["spearman_p"] = spearman_p
|
||||||
|
if abs(p) >= abs(s):
|
||||||
|
value = p
|
||||||
|
p_value = pearson_p
|
||||||
|
else:
|
||||||
|
value = s
|
||||||
|
p_value = spearman_p
|
||||||
elif (not a_numeric) and (not b_numeric):
|
elif (not a_numeric) and (not b_numeric):
|
||||||
method = "cramers_v"
|
method = "cramers_v"
|
||||||
value = cramers_v(a_vals, b_vals)
|
value = cramers_v(a_vals, b_vals)
|
||||||
extra["u_ab"] = theils_u(a_vals, b_vals)
|
extra["u_ab"] = theils_u(a_vals, b_vals)
|
||||||
extra["u_ba"] = theils_u(b_vals, a_vals)
|
extra["u_ba"] = theils_u(b_vals, a_vals)
|
||||||
|
p_value = _chi2_pvalue(a_vals, b_vals)
|
||||||
else:
|
else:
|
||||||
method = "correlation_ratio"
|
method = "correlation_ratio"
|
||||||
if a_numeric:
|
if a_numeric:
|
||||||
# a numerica, b categorica.
|
# a numerica, b categorica.
|
||||||
value = correlation_ratio(b_vals, a_vals)
|
value = correlation_ratio(b_vals, a_vals)
|
||||||
|
p_value = _anova_pvalue(b_vals, a_vals)
|
||||||
else:
|
else:
|
||||||
# a categorica, b numerica.
|
# a categorica, b numerica.
|
||||||
value = correlation_ratio(a_vals, b_vals)
|
value = correlation_ratio(a_vals, b_vals)
|
||||||
|
p_value = _anova_pvalue(a_vals, b_vals)
|
||||||
|
|
||||||
pairs.append(
|
pairs.append(
|
||||||
{
|
{
|
||||||
@@ -192,19 +310,55 @@ def association_matrix(
|
|||||||
"method": method,
|
"method": method,
|
||||||
"value": value,
|
"value": value,
|
||||||
"extra": extra,
|
"extra": extra,
|
||||||
|
"p_value": p_value,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Correccion de comparaciones multiples sobre los p-valores disponibles.
|
||||||
|
# Se pasa la lista completa (incluidos los None de pares sin test): la
|
||||||
|
# correccion devuelve un mapeo alineado 1:1 y los None no cuentan como prueba.
|
||||||
|
fdr = fdr_correction(
|
||||||
|
[pair["p_value"] for pair in pairs],
|
||||||
|
alpha=alpha,
|
||||||
|
method=fdr_method,
|
||||||
|
)
|
||||||
|
for pair, padj, rej in zip(
|
||||||
|
pairs, fdr["p_values_adjusted"], fdr["reject"]
|
||||||
|
):
|
||||||
|
pair["p_value_adjusted"] = padj
|
||||||
|
pair["significant"] = bool(rej)
|
||||||
|
|
||||||
def _relevance(pair: dict) -> float:
|
def _relevance(pair: dict) -> float:
|
||||||
return max(abs(pair["value"]), pair["extra"].get("mi", 0.0))
|
return max(abs(pair["value"]), pair["extra"].get("mi", 0.0))
|
||||||
|
|
||||||
strong = [
|
def _is_strong(pair: dict) -> bool:
|
||||||
pair
|
# Condicion 1: magnitud por encima del umbral (necesaria).
|
||||||
for pair in pairs
|
magnitude_ok = (
|
||||||
if abs(pair["value"]) >= strong_threshold
|
abs(pair["value"]) >= strong_threshold
|
||||||
or pair["extra"].get("mi", 0.0) >= strong_threshold
|
or pair["extra"].get("mi", 0.0) >= strong_threshold
|
||||||
]
|
)
|
||||||
|
if not magnitude_ok:
|
||||||
|
return False
|
||||||
|
# Condicion 2: significancia tras la correccion FDR. Los pares sin test
|
||||||
|
# disponible (p_value None, p.ej. informacion mutua o caso degenerado) se
|
||||||
|
# admiten por magnitud, ya que no hay p-valor que corregir.
|
||||||
|
if pair["p_value"] is None:
|
||||||
|
return True
|
||||||
|
return pair["significant"]
|
||||||
|
|
||||||
|
strong = [pair for pair in pairs if _is_strong(pair)]
|
||||||
strong.sort(key=_relevance, reverse=True)
|
strong.sort(key=_relevance, reverse=True)
|
||||||
strong = strong[:top_n]
|
strong = strong[:top_n]
|
||||||
|
|
||||||
return {"pairs": pairs, "strong": strong, "methods_legend": legend}
|
return {
|
||||||
|
"pairs": pairs,
|
||||||
|
"strong": strong,
|
||||||
|
"methods_legend": legend,
|
||||||
|
"n_tests": len(pairs),
|
||||||
|
"multiple_testing": {
|
||||||
|
"method": fdr_method,
|
||||||
|
"alpha": alpha,
|
||||||
|
"n_tests": fdr["n_tests"],
|
||||||
|
"n_rejected": fdr["n_rejected"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,3 +80,79 @@ def test_single_column_returns_empty():
|
|||||||
result = association_matrix(columns)
|
result = association_matrix(columns)
|
||||||
assert result["pairs"] == []
|
assert result["pairs"] == []
|
||||||
assert result["strong"] == []
|
assert result["strong"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_pairs_carry_significance_fields():
|
||||||
|
# Tras la correccion FDR cada par evaluado lleva p_value, p_value_adjusted y
|
||||||
|
# significant. Un par num-num fuertemente correlado es significativo.
|
||||||
|
columns = {
|
||||||
|
"size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"},
|
||||||
|
"price": {
|
||||||
|
"values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1],
|
||||||
|
"type": "numeric",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = association_matrix(columns, strong_threshold=0.5)
|
||||||
|
pair = _find_pair(result["pairs"], "size", "price")
|
||||||
|
assert "p_value" in pair and "p_value_adjusted" in pair and "significant" in pair
|
||||||
|
assert pair["p_value"] is not None and pair["p_value"] < 0.05
|
||||||
|
assert pair["significant"] is True
|
||||||
|
# p ajustado nunca por debajo del crudo.
|
||||||
|
assert pair["p_value_adjusted"] >= pair["p_value"] - 1e-12
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_reports_multiple_testing_summary():
|
||||||
|
columns = {
|
||||||
|
"size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"},
|
||||||
|
"price": {
|
||||||
|
"values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1],
|
||||||
|
"type": "numeric",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = association_matrix(columns)
|
||||||
|
# n_tests = total de pares evaluados.
|
||||||
|
assert result["n_tests"] == len(result["pairs"])
|
||||||
|
mt = result["multiple_testing"]
|
||||||
|
assert mt["method"] == "bh"
|
||||||
|
assert mt["alpha"] == 0.05
|
||||||
|
assert mt["n_rejected"] >= 1
|
||||||
|
assert mt["n_tests"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_strong_requires_corrected_significance():
|
||||||
|
# Par num-num con magnitud alta pero p-valor no diminuto. Con alpha normal es
|
||||||
|
# fuerte; con un alpha mas estricto que su p-valor, deja de ser significativo
|
||||||
|
# y sale de strong AUNQUE la magnitud siga por encima del umbral. Esto prueba
|
||||||
|
# que strong se basa en la significancia corregida, no solo en el umbral.
|
||||||
|
columns = {
|
||||||
|
"a": {"values": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], "type": "numeric"},
|
||||||
|
"b": {"values": [2, 1, 3, 4, 6, 5, 7, 8, 10, 9, 11, 12], "type": "numeric"},
|
||||||
|
}
|
||||||
|
relaxed = association_matrix(columns, strong_threshold=0.5, alpha=0.05)
|
||||||
|
pair = _find_pair(relaxed["pairs"], "a", "b")
|
||||||
|
assert pair["p_value"] is not None and pair["p_value"] < 0.05
|
||||||
|
assert abs(pair["value"]) >= 0.5
|
||||||
|
assert _find_pair(relaxed["strong"], "a", "b") is not None
|
||||||
|
|
||||||
|
# alpha mas estricto que el p-valor del par -> ya no significativo.
|
||||||
|
strict = association_matrix(
|
||||||
|
columns, strong_threshold=0.5, alpha=pair["p_value"] / 10.0
|
||||||
|
)
|
||||||
|
sp = _find_pair(strict["pairs"], "a", "b")
|
||||||
|
assert abs(sp["value"]) >= 0.5 # magnitud intacta
|
||||||
|
assert sp["significant"] is False
|
||||||
|
assert _find_pair(strict["strong"], "a", "b") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bonferroni_method_is_accepted():
|
||||||
|
columns = {
|
||||||
|
"size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"},
|
||||||
|
"price": {
|
||||||
|
"values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1],
|
||||||
|
"type": "numeric",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = association_matrix(columns, fdr_method="bonferroni")
|
||||||
|
assert result["multiple_testing"]["method"] == "bonferroni"
|
||||||
|
pair = _find_pair(result["pairs"], "size", "price")
|
||||||
|
assert pair["p_value_adjusted"] is not None
|
||||||
|
|||||||
@@ -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,77 @@
|
|||||||
|
---
|
||||||
|
name: exploratory_caveats
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def exploratory_caveats(profile: dict) -> dict"
|
||||||
|
description: "Genera las advertencias que recuerdan que un EDA es EXPLORATORIO (genera hipotesis), no confirmatorio. Inspecciona un TableProfile del grupo eda y devuelve solo los caveats que aplican a lo calculado: correlacion!=causalidad, overfitting in-sample, p-values no son confirmacion, comparaciones multiples, outliers!=errores, muestra pequena, datos faltantes. El caveat general va siempre. Pura."
|
||||||
|
tags: [eda, exploratory, caveats, hypotheses, overfitting, correlation-causation, p-values, tukey, lopez-de-prado, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: profile
|
||||||
|
desc: "TableProfile dict del grupo eda. Se leen defensivamente `correlations` (pares), `models` (pca/kmeans/outliers/normality), `columns` (sub-bloques `numeric` con n_outliers/outlier_pct y `trend` con p_value), `n_rows`, `null_cell_pct` y `all_null_cols`. Cualquier clave puede faltar."
|
||||||
|
output: "dict con `n` (numero de caveats), `caveats` (lista de {id, topic, message, reference} empezando por el general `exploratory_nature`) y `note` (vacio en caso normal; mensaje si el perfil esta vacio y solo se devuelve el caveat general). Nunca lanza excepcion."
|
||||||
|
tested: true
|
||||||
|
tests: ["test_perfil_vacio_solo_caveat_general", "test_none_no_lanza_y_da_general", "test_caveat_general_siempre_primero", "test_correlaciones_disparan_causalidad_y_overfitting", "test_dos_o_mas_pares_disparan_comparaciones_multiples", "test_modelos_disparan_overfitting_y_pvalues", "test_outliers_por_columna_disparan_caveat", "test_outliers_multivariantes_disparan_caveat", "test_trend_pvalue_dispara_caveat_pvalues", "test_muestra_pequena_dispara_caveat", "test_muestra_grande_no_dispara_small_sample", "test_muchos_faltantes_disparan_missing_data", "test_columnas_all_null_disparan_missing_data", "test_pocos_faltantes_no_disparan_missing_data", "test_estructura_de_cada_caveat"]
|
||||||
|
test_file_path: "python/functions/datascience/exploratory_caveats_test.py"
|
||||||
|
file_path: "python/functions/datascience/exploratory_caveats.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import exploratory_caveats
|
||||||
|
|
||||||
|
profile = {
|
||||||
|
"n_rows": 5000,
|
||||||
|
"correlations": {"pairs": [
|
||||||
|
{"a": "precio", "b": "ventas", "value": 0.82},
|
||||||
|
{"a": "precio", "b": "margen", "value": -0.61},
|
||||||
|
]},
|
||||||
|
"models": {"pca": {"explained": [0.6, 0.3]}, "normality": {"precio": {"is_normal": False}}},
|
||||||
|
"columns": [{"name": "precio", "numeric": {"n_outliers": 4, "outlier_pct": 0.8}}],
|
||||||
|
}
|
||||||
|
out = exploratory_caveats(profile)
|
||||||
|
out["n"] # -> 6
|
||||||
|
[c["id"] for c in out["caveats"]]
|
||||||
|
# -> ['exploratory_nature', 'correlation_not_causation', 'in_sample_overfitting',
|
||||||
|
# 'p_values_not_confirmation', 'multiple_comparisons', 'outliers_not_errors']
|
||||||
|
|
||||||
|
# Perfil vacio -> solo la advertencia general.
|
||||||
|
exploratory_caveats({})["caveats"][0]["id"] # -> "exploratory_nature"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Al cerrar un EDA, antes de entregar el reporte o de tomar decisiones sobre lo que
|
||||||
|
muestra. Convierte la disciplina exploratoria (Tukey: el EDA da hipotesis, no
|
||||||
|
conclusiones) en una lista accionable de advertencias adaptada a lo que realmente se
|
||||||
|
calculo en ese perfil. Pensada para inyectar una seccion "Advertencias / esto es
|
||||||
|
exploratorio" en el markdown de un reporte EDA, o para que un agente recuerde no
|
||||||
|
tratar una correlacion o una "significancia" como confirmacion. NO la uses para
|
||||||
|
calcular estadisticos: solo razona sobre el contenido de un TableProfile ya hecho.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Es **pura**: no recalcula nada, solo decide que advertencias aplican a partir de
|
||||||
|
las claves presentes en el `profile`. Si una fase del EDA no se corrio (p.ej. sin
|
||||||
|
`models`), su caveat no aparece — es deliberado.
|
||||||
|
- El caveat `exploratory_nature` (general) va SIEMPRE, incluso con perfil vacio o
|
||||||
|
`None` (en ese caso `note` lo avisa). No lanza excepcion ante entradas raras.
|
||||||
|
- `correlations` se tolera como lista de pares o como dict con `pairs`/`strongest`
|
||||||
|
(mismo shape que consume `render_eda_markdown`). Un solo par dispara
|
||||||
|
`correlation_not_causation` + `in_sample_overfitting`; >=2 anaden ademas
|
||||||
|
`multiple_comparisons`.
|
||||||
|
- Umbrales: muestra pequena si `n_rows < 30`; faltantes notables si
|
||||||
|
`null_cell_pct > 0.2` (fraccion) o si hay `all_null_cols`. Son convenciones
|
||||||
|
prudentes, ajustables si el caller lo necesita (recomputando sobre el mismo
|
||||||
|
profile).
|
||||||
|
- `null_cell_pct` se asume fraccion 0-1 (como en el resto del grupo eda). Si tu
|
||||||
|
pipeline lo guarda como porcentaje 0-100, el umbral se dispara casi siempre.
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"""Genera las advertencias que recuerdan que un EDA es EXPLORATORIO, no confirmatorio.
|
||||||
|
|
||||||
|
Funcion pura y determinista: dict (TableProfile del grupo ``eda``) entra, dict con
|
||||||
|
una lista de caveats sale. No hace I/O, no muta el input, no lanza excepciones.
|
||||||
|
|
||||||
|
Doctrina (Tukey, *EDA* 1977; Aronson; López de Prado 2018): el análisis exploratorio
|
||||||
|
sirve para GENERAR hipótesis, no para confirmarlas. Lo que se ve mirando todo el
|
||||||
|
dataset a la vez —correlaciones, clusters, "significancias", outliers— es un punto de
|
||||||
|
partida, no una conclusión: hay que validarlo fuera de muestra con un análisis dirigido.
|
||||||
|
Esta función inspecciona qué contiene el perfil y devuelve solo las advertencias que
|
||||||
|
aplican a lo que realmente se ha calculado (si hay correlaciones → caveat de
|
||||||
|
causalidad; si hay modelos → caveat de overfitting; etc.), además de una advertencia
|
||||||
|
general que siempre acompaña a un EDA.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Umbrales para disparar caveats dependientes de magnitud.
|
||||||
|
_SMALL_SAMPLE_ROWS = 30 # n_rows por debajo de esto -> baja potencia.
|
||||||
|
_HIGH_MISSING_FRACTION = 0.2 # null_cell_pct (fracción) por encima -> sesgo MNAR.
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(v):
|
||||||
|
"""Parsea a float; None si es None/bool/no parseable (NaN incluido)."""
|
||||||
|
if v is None or isinstance(v, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
f = float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if f != f: # NaN
|
||||||
|
return None
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def _correlation_pairs(profile: dict) -> list:
|
||||||
|
"""Extrae la lista de pares de correlación del perfil, tolerando varios shapes.
|
||||||
|
|
||||||
|
``correlations`` puede ser una lista de pares o un dict con ``pairs`` /
|
||||||
|
``strongest``. Devuelve siempre una lista (vacía si no hay nada usable).
|
||||||
|
"""
|
||||||
|
correlations = profile.get("correlations")
|
||||||
|
if not correlations:
|
||||||
|
return []
|
||||||
|
if isinstance(correlations, dict):
|
||||||
|
pairs = correlations.get("pairs") or correlations.get("strongest") or []
|
||||||
|
else:
|
||||||
|
pairs = correlations
|
||||||
|
return list(pairs) if isinstance(pairs, (list, tuple)) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _has_models(profile: dict) -> bool:
|
||||||
|
"""True si el perfil contiene un bloque de modelos multivariantes ajustados."""
|
||||||
|
models = profile.get("models")
|
||||||
|
if not isinstance(models, dict):
|
||||||
|
return False
|
||||||
|
return any(models.get(k) for k in ("pca", "kmeans", "outliers"))
|
||||||
|
|
||||||
|
|
||||||
|
def _has_pvalues(profile: dict) -> bool:
|
||||||
|
"""True si el perfil contiene p-values (tests de normalidad o de tendencia)."""
|
||||||
|
models = profile.get("models")
|
||||||
|
if isinstance(models, dict) and models.get("normality"):
|
||||||
|
return True
|
||||||
|
# Tests de tendencia adjuntados por columna (trend_slope) también traen p-value.
|
||||||
|
for col in profile.get("columns") or []:
|
||||||
|
if isinstance(col, dict) and isinstance(col.get("trend"), dict):
|
||||||
|
if col["trend"].get("p_value") is not None:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _has_outliers(profile: dict) -> bool:
|
||||||
|
"""True si se han detectado outliers (multivariantes o por columna numérica)."""
|
||||||
|
models = profile.get("models")
|
||||||
|
if isinstance(models, dict) and models.get("outliers"):
|
||||||
|
return True
|
||||||
|
for col in profile.get("columns") or []:
|
||||||
|
if not isinstance(col, dict):
|
||||||
|
continue
|
||||||
|
num = col.get("numeric")
|
||||||
|
if isinstance(num, dict):
|
||||||
|
n_out = _to_float(num.get("n_outliers"))
|
||||||
|
opct = _to_float(num.get("outlier_pct"))
|
||||||
|
if (n_out is not None and n_out > 0) or (opct is not None and opct > 0):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def exploratory_caveats(profile: dict) -> dict:
|
||||||
|
"""Devuelve las advertencias de que el EDA es exploratorio según lo que contiene.
|
||||||
|
|
||||||
|
Inspecciona un TableProfile (dict del grupo ``eda``) y arma la lista de caveats
|
||||||
|
relevantes. Una advertencia general (la naturaleza exploratoria del EDA) se
|
||||||
|
incluye SIEMPRE; el resto solo se añaden cuando el perfil contiene aquello a lo
|
||||||
|
que aplican:
|
||||||
|
|
||||||
|
- correlaciones presentes -> correlación ≠ causalidad.
|
||||||
|
- modelos / correlaciones -> riesgo de overfitting in-sample (validar OOS).
|
||||||
|
- p-values (normalidad/tendencia) -> no son confirmación sin corregir / IID.
|
||||||
|
- ≥2 pares de correlación -> comparaciones múltiples (falsos positivos).
|
||||||
|
- outliers detectados -> no implican errores.
|
||||||
|
- n_rows pequeño -> baja potencia, estimaciones inestables.
|
||||||
|
- muchos faltantes -> posible sesgo si no son aleatorios (MNAR).
|
||||||
|
|
||||||
|
Es pura, determinista y no lanza excepciones. Un perfil vacío o ``None`` devuelve
|
||||||
|
solo el caveat general con una nota.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: TableProfile dict del grupo ``eda``. Se lee todo defensivamente con
|
||||||
|
``.get(...)`` porque casi cualquier fase puede faltar.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con:
|
||||||
|
- ``n``: número de caveats devueltos (int).
|
||||||
|
- ``caveats``: lista de dicts ``{"id", "topic", "message", "reference"}``,
|
||||||
|
empezando por el general ``exploratory_nature``.
|
||||||
|
- ``note``: cadena vacía en el caso normal; mensaje cuando el perfil está
|
||||||
|
vacío y solo se devuelve la advertencia general.
|
||||||
|
"""
|
||||||
|
if not isinstance(profile, dict):
|
||||||
|
profile = {}
|
||||||
|
|
||||||
|
caveats: list = []
|
||||||
|
|
||||||
|
# Caveat general: SIEMPRE presente. El EDA genera hipótesis, no conclusiones.
|
||||||
|
caveats.append({
|
||||||
|
"id": "exploratory_nature",
|
||||||
|
"topic": "naturaleza exploratoria",
|
||||||
|
"message": (
|
||||||
|
"El EDA genera HIPÓTESIS, no conclusiones. Cada patrón que veas aquí es un "
|
||||||
|
"punto de partida para confirmarlo con un análisis dirigido sobre datos "
|
||||||
|
"nuevos, no una verdad ya establecida."
|
||||||
|
),
|
||||||
|
"reference": "Tukey (1977), Exploratory Data Analysis; Aronson",
|
||||||
|
})
|
||||||
|
|
||||||
|
if not profile:
|
||||||
|
return {
|
||||||
|
"n": len(caveats),
|
||||||
|
"caveats": caveats,
|
||||||
|
"note": "perfil vacío: solo se devuelve la advertencia general",
|
||||||
|
}
|
||||||
|
|
||||||
|
corr_pairs = _correlation_pairs(profile)
|
||||||
|
has_corr = len(corr_pairs) > 0
|
||||||
|
has_models = _has_models(profile)
|
||||||
|
|
||||||
|
# Correlación ≠ causalidad.
|
||||||
|
if has_corr:
|
||||||
|
caveats.append({
|
||||||
|
"id": "correlation_not_causation",
|
||||||
|
"topic": "correlación vs causalidad",
|
||||||
|
"message": (
|
||||||
|
"Las correlaciones son asociaciones, no relaciones causales. Una "
|
||||||
|
"correlación fuerte puede venir de una variable de confusión o del "
|
||||||
|
"azar; valídala out-of-sample o con un diseño experimental antes de "
|
||||||
|
"actuar sobre ella."
|
||||||
|
),
|
||||||
|
"reference": "Tukey (1977), EDA",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Overfitting in-sample: cualquier patrón ajustado sobre todo el dataset.
|
||||||
|
if has_models or has_corr:
|
||||||
|
caveats.append({
|
||||||
|
"id": "in_sample_overfitting",
|
||||||
|
"topic": "overfitting in-sample",
|
||||||
|
"message": (
|
||||||
|
"Los patrones (modelos, clusters, correlaciones) se han extraído sobre "
|
||||||
|
"TODO el dataset. Lo aprendido in-sample puede no replicar fuera de "
|
||||||
|
"muestra (overfitting / selección por backtest). Valida con holdout o "
|
||||||
|
"walk-forward antes de confiar en ellos."
|
||||||
|
),
|
||||||
|
"reference": "López de Prado (2018), Advances in Financial Machine Learning",
|
||||||
|
})
|
||||||
|
|
||||||
|
# p-values: no son confirmación sin corregir multiplicidad / sobre datos no-IID.
|
||||||
|
if _has_pvalues(profile):
|
||||||
|
caveats.append({
|
||||||
|
"id": "p_values_not_confirmation",
|
||||||
|
"topic": "p-values",
|
||||||
|
"message": (
|
||||||
|
"Los p-values sin corregir por comparaciones múltiples, o calculados "
|
||||||
|
"sobre datos no-IID (series temporales, datos agrupados), no son "
|
||||||
|
"confirmación. Trata cualquier 'significancia' vista en exploración "
|
||||||
|
"como provisional."
|
||||||
|
),
|
||||||
|
"reference": "Tukey (1977), EDA",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Comparaciones múltiples: cuantos más pares/columnas miras, más falsos positivos.
|
||||||
|
if len(corr_pairs) >= 2:
|
||||||
|
caveats.append({
|
||||||
|
"id": "multiple_comparisons",
|
||||||
|
"topic": "comparaciones múltiples",
|
||||||
|
"message": (
|
||||||
|
"Al examinar muchos pares/columnas a la vez, algunos parecerán "
|
||||||
|
"'significativos' solo por azar (problema de comparaciones múltiples). "
|
||||||
|
"Cuantas más combinaciones miras, más falsos positivos esperas."
|
||||||
|
),
|
||||||
|
"reference": "López de Prado (2018), AFML",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Outliers detectados no implican errores.
|
||||||
|
if _has_outliers(profile):
|
||||||
|
caveats.append({
|
||||||
|
"id": "outliers_not_errors",
|
||||||
|
"topic": "outliers",
|
||||||
|
"message": (
|
||||||
|
"Los outliers detectados son puntos estadísticamente atípicos, NO "
|
||||||
|
"necesariamente errores. Pueden ser el dato más interesante (fraude, "
|
||||||
|
"evento raro). Investígalos antes de eliminarlos."
|
||||||
|
),
|
||||||
|
"reference": "Tukey (1977), EDA",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Muestra pequeña: baja potencia, estimaciones inestables.
|
||||||
|
n_rows = _to_float(profile.get("n_rows"))
|
||||||
|
if n_rows is not None and n_rows < _SMALL_SAMPLE_ROWS:
|
||||||
|
caveats.append({
|
||||||
|
"id": "small_sample",
|
||||||
|
"topic": "muestra pequeña",
|
||||||
|
"message": (
|
||||||
|
f"Pocas filas (n={int(n_rows)}): la potencia estadística es baja y las "
|
||||||
|
"estimaciones (media, correlación, forma de la distribución) son "
|
||||||
|
"inestables. Los patrones pueden cambiar con más datos."
|
||||||
|
),
|
||||||
|
"reference": "Tukey (1977), EDA",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Datos faltantes: posible sesgo si no son aleatorios (MNAR).
|
||||||
|
null_frac = _to_float(profile.get("null_cell_pct"))
|
||||||
|
all_null_cols = profile.get("all_null_cols") or []
|
||||||
|
if (null_frac is not None and null_frac > _HIGH_MISSING_FRACTION) or all_null_cols:
|
||||||
|
caveats.append({
|
||||||
|
"id": "missing_data_bias",
|
||||||
|
"topic": "datos faltantes",
|
||||||
|
"message": (
|
||||||
|
"Hay un volumen notable de datos faltantes. Si los ausentes no son "
|
||||||
|
"aleatorios (MNAR), los estadísticos calculados sobre lo presente "
|
||||||
|
"están sesgados; no extrapoles sin entender por qué faltan."
|
||||||
|
),
|
||||||
|
"reference": "Tukey (1977), EDA",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"n": len(caveats), "caveats": caveats, "note": ""}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Tests para exploratory_caveats."""
|
||||||
|
|
||||||
|
from exploratory_caveats import exploratory_caveats
|
||||||
|
|
||||||
|
|
||||||
|
def _ids(out):
|
||||||
|
return {c["id"] for c in out["caveats"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_perfil_vacio_solo_caveat_general():
|
||||||
|
out = exploratory_caveats({})
|
||||||
|
assert out["n"] == 1
|
||||||
|
assert _ids(out) == {"exploratory_nature"}
|
||||||
|
assert out["note"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_no_lanza_y_da_general():
|
||||||
|
out = exploratory_caveats(None)
|
||||||
|
assert _ids(out) == {"exploratory_nature"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_caveat_general_siempre_primero():
|
||||||
|
out = exploratory_caveats({"n_rows": 1000, "columns": []})
|
||||||
|
assert out["caveats"][0]["id"] == "exploratory_nature"
|
||||||
|
|
||||||
|
|
||||||
|
def test_correlaciones_disparan_causalidad_y_overfitting():
|
||||||
|
profile = {
|
||||||
|
"n_rows": 5000,
|
||||||
|
"correlations": {"pairs": [{"a": "x", "b": "y", "value": 0.8}]},
|
||||||
|
}
|
||||||
|
ids = _ids(exploratory_caveats(profile))
|
||||||
|
assert "correlation_not_causation" in ids
|
||||||
|
assert "in_sample_overfitting" in ids
|
||||||
|
# un solo par -> NO dispara comparaciones múltiples
|
||||||
|
assert "multiple_comparisons" not in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_dos_o_mas_pares_disparan_comparaciones_multiples():
|
||||||
|
profile = {
|
||||||
|
"correlations": [
|
||||||
|
{"a": "x", "b": "y", "value": 0.8},
|
||||||
|
{"a": "x", "b": "z", "value": -0.6},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert "multiple_comparisons" in _ids(exploratory_caveats(profile))
|
||||||
|
|
||||||
|
|
||||||
|
def test_modelos_disparan_overfitting_y_pvalues():
|
||||||
|
profile = {
|
||||||
|
"models": {
|
||||||
|
"pca": {"explained": [0.6, 0.3]},
|
||||||
|
"normality": {"col_a": {"is_normal": False}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ids = _ids(exploratory_caveats(profile))
|
||||||
|
assert "in_sample_overfitting" in ids
|
||||||
|
assert "p_values_not_confirmation" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_outliers_por_columna_disparan_caveat():
|
||||||
|
profile = {
|
||||||
|
"columns": [
|
||||||
|
{"name": "precio", "numeric": {"n_outliers": 3, "outlier_pct": 1.5}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert "outliers_not_errors" in _ids(exploratory_caveats(profile))
|
||||||
|
|
||||||
|
|
||||||
|
def test_outliers_multivariantes_disparan_caveat():
|
||||||
|
profile = {"models": {"outliers": {"flags": [True, False, True]}}}
|
||||||
|
assert "outliers_not_errors" in _ids(exploratory_caveats(profile))
|
||||||
|
|
||||||
|
|
||||||
|
def test_trend_pvalue_dispara_caveat_pvalues():
|
||||||
|
profile = {
|
||||||
|
"columns": [
|
||||||
|
{"name": "ventas", "trend": {"direction": "up", "p_value": 0.01}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert "p_values_not_confirmation" in _ids(exploratory_caveats(profile))
|
||||||
|
|
||||||
|
|
||||||
|
def test_muestra_pequena_dispara_caveat():
|
||||||
|
out = exploratory_caveats({"n_rows": 12})
|
||||||
|
assert "small_sample" in _ids(out)
|
||||||
|
msg = next(c["message"] for c in out["caveats"] if c["id"] == "small_sample")
|
||||||
|
assert "12" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_muestra_grande_no_dispara_small_sample():
|
||||||
|
assert "small_sample" not in _ids(exploratory_caveats({"n_rows": 5000}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_muchos_faltantes_disparan_missing_data():
|
||||||
|
assert "missing_data_bias" in _ids(exploratory_caveats({"null_cell_pct": 0.35}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_columnas_all_null_disparan_missing_data():
|
||||||
|
assert "missing_data_bias" in _ids(exploratory_caveats({"all_null_cols": ["x"]}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_pocos_faltantes_no_disparan_missing_data():
|
||||||
|
assert "missing_data_bias" not in _ids(exploratory_caveats({"null_cell_pct": 0.05}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_estructura_de_cada_caveat():
|
||||||
|
out = exploratory_caveats({"correlations": [{"a": "x", "b": "y", "value": 0.9}]})
|
||||||
|
for c in out["caveats"]:
|
||||||
|
assert set(c.keys()) == {"id", "topic", "message", "reference"}
|
||||||
|
assert all(isinstance(c[k], str) and c[k] for k in c)
|
||||||
|
assert out["n"] == len(out["caveats"])
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
name: fdr_correction
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = \"bh\") -> dict"
|
||||||
|
description: "Correccion de comparaciones multiples (multiple-testing) sobre una lista de p-valores: Benjamini-Hochberg (FDR, 'bh') o Bonferroni (FWER, 'bonferroni'). Antidoto al sesgo de mineria de datos (data-mining bias): al evaluar muchas hipotesis a la vez (todos los pares de una matriz), el azar produce falsos positivos; esta funcion ajusta los p-valores y marca cuales siguen siendo significativos tras corregir. Pura, sin dependencias externas, alineada 1:1 con la entrada (admite None en posiciones sin test)."
|
||||||
|
tags: [eda, statistics, multiple-testing, fdr, benjamini-hochberg, bonferroni, p-value, data-mining-bias, python]
|
||||||
|
params:
|
||||||
|
- name: pvalues
|
||||||
|
desc: "lista de p-valores (floats en [0, 1]). Se admiten None u otros valores no validos en posiciones sin test disponible; se propagan como None en la salida y no cuentan como prueba (m)."
|
||||||
|
- name: alpha
|
||||||
|
desc: "nivel de significancia objetivo tras la correccion (default 0.05). Para BH es el umbral del FDR; para Bonferroni, del FWER (tasa de error por familia)."
|
||||||
|
- name: method
|
||||||
|
desc: "'bh' = Benjamini-Hochberg (controla FDR, menos conservador, mas potencia); 'bonferroni' = controla FWER (mas conservador). Cualquier otro valor devuelve un dict con note."
|
||||||
|
output: "dict {p_values_adjusted: lista alineada con pvalues (float ajustado o None), reject: lista de bool (True = significativo tras corregir), n_tests: nº de p-valores validos (m), n_rejected: nº de hipotesis rechazadas, alpha: float aplicado, method: str}. Casos degenerados (vacio, sin p validos, metodo desconocido) anaden clave note. Nunca None ni excepcion."
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [math]
|
||||||
|
tested: true
|
||||||
|
tests: ["test_bh_golden_rechaza_dos_de_tres", "test_bonferroni_mas_conservador_que_bh", "test_p_values_adjusted_alineados_y_en_rango", "test_none_se_propaga_alineado", "test_lista_vacia_devuelve_note", "test_solo_none_devuelve_note", "test_metodo_desconocido_devuelve_note", "test_todos_significativos"]
|
||||||
|
test_file_path: "python/functions/datascience/fdr_correction_test.py"
|
||||||
|
file_path: "python/functions/datascience/fdr_correction.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import fdr_correction
|
||||||
|
|
||||||
|
# Tres pruebas: dos muy significativas, una claramente no.
|
||||||
|
pvalues = [0.01, 0.02, 0.5]
|
||||||
|
|
||||||
|
bh = fdr_correction(pvalues, alpha=0.05, method="bh")
|
||||||
|
print(bh["reject"]) # -> [True, True, False]
|
||||||
|
print(bh["n_rejected"]) # -> 2
|
||||||
|
|
||||||
|
# Bonferroni es mas conservador: solo sobrevive la mas fuerte.
|
||||||
|
bon = fdr_correction(pvalues, alpha=0.05, method="bonferroni")
|
||||||
|
print(bon["reject"]) # -> [True, False, False]
|
||||||
|
print(bon["p_values_adjusted"]) # -> [0.03, 0.06, 1.0]
|
||||||
|
|
||||||
|
# Posiciones sin test (None) se propagan alineadas: el llamador puede pasar la
|
||||||
|
# lista completa de pares y recuperar el mapeo 1:1.
|
||||||
|
mix = fdr_correction([0.001, None, 0.9])
|
||||||
|
print(mix["reject"]) # -> [True, False, False]
|
||||||
|
print(mix["n_tests"]) # -> 2 (el None no cuenta como prueba)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando evalues **muchas hipotesis a la vez** y vayas a declarar "significativos"
|
||||||
|
los resultados por debajo de un umbral de p-valor: matriz de asociacion entre
|
||||||
|
todas las columnas, barrido de reglas/senales, cualquier busqueda que pruebe N
|
||||||
|
combinaciones y se quede con las que "pasan". Sin corregir, con N pruebas y
|
||||||
|
alpha=0.05 esperas ~5% de falsos positivos *por azar*: cuantas mas pruebas, mas
|
||||||
|
correlaciones espurias. Llama a `fdr_correction` con todos los p-valores de la
|
||||||
|
familia y usa `reject` (no el umbral crudo) para decidir que es real. Usa `"bh"`
|
||||||
|
por defecto (mejor potencia); `"bonferroni"` cuando un falso positivo sea muy
|
||||||
|
costoso y prefieras maxima cautela.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Pura y sin dependencias externas (solo `math` de la stdlib).
|
||||||
|
- Corrige **dentro de una familia de pruebas**: pasa de una vez todos los
|
||||||
|
p-valores que compiten, no los corrijas por separado o pierdes el control del
|
||||||
|
sesgo.
|
||||||
|
- La salida esta **alineada 1:1** con la entrada. Las posiciones invalidas
|
||||||
|
(`None`, `NaN`, fuera de `[0, 1]`, no numericas) se devuelven como
|
||||||
|
`p_values_adjusted=None` y `reject=False`, y no cuentan en `n_tests` (m). Por
|
||||||
|
eso puedes pasar la lista completa de pares aunque algunos no tengan test.
|
||||||
|
- `n_tests` es el numero de p-valores **validos** (m), que puede ser menor que
|
||||||
|
`len(pvalues)` si hay `None`.
|
||||||
|
- BH y Bonferroni controlan cosas distintas: BH la tasa de falsos
|
||||||
|
descubrimientos (FDR), Bonferroni la probabilidad de *cualquier* falso
|
||||||
|
positivo (FWER). No son intercambiables; elige segun el coste de equivocarte.
|
||||||
|
- Metodo desconocido o lista vacia/sin p validos no lanzan: devuelven un dict
|
||||||
|
con `note`.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Correccion de comparaciones multiples (multiple-testing) para una lista de p-valores.
|
||||||
|
|
||||||
|
Funcion pura del grupo eda. Cuando se evaluan muchas hipotesis a la vez (p.ej.
|
||||||
|
todos los pares de una matriz de asociacion), la probabilidad de obtener al menos
|
||||||
|
un falso positivo por azar crece con el numero de pruebas: es el sesgo de mineria
|
||||||
|
de datos (data-mining bias) descrito por Aronson en *Evidence-Based Technical
|
||||||
|
Analysis* (cap. 6). Esta funcion ajusta los p-valores para controlar ese sesgo
|
||||||
|
mediante dos metodos clasicos:
|
||||||
|
|
||||||
|
- Benjamini-Hochberg (``"bh"``): controla la tasa de falsos descubrimientos
|
||||||
|
(False Discovery Rate, FDR). Menos conservador, mas potencia estadistica.
|
||||||
|
- Bonferroni (``"bonferroni"``): controla la tasa de error por familia
|
||||||
|
(Family-Wise Error Rate, FWER). Mas conservador.
|
||||||
|
|
||||||
|
No usa dependencias externas: aritmetica de la libreria estandar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_p(v) -> bool:
|
||||||
|
"""True si v es un p-valor numerico finito dentro de [0, 1]."""
|
||||||
|
if v is None or isinstance(v, bool):
|
||||||
|
return False
|
||||||
|
if not isinstance(v, (int, float)):
|
||||||
|
return False
|
||||||
|
x = float(v)
|
||||||
|
if math.isnan(x) or math.isinf(x):
|
||||||
|
return False
|
||||||
|
return 0.0 <= x <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def fdr_correction(pvalues: list, alpha: float = 0.05, method: str = "bh") -> dict:
|
||||||
|
"""Corrige una lista de p-valores por comparaciones multiples.
|
||||||
|
|
||||||
|
Aplica Benjamini-Hochberg (FDR) o Bonferroni (FWER) sobre ``pvalues`` y
|
||||||
|
devuelve, alineado posicion a posicion con la entrada, el p-valor ajustado y
|
||||||
|
si cada hipotesis se rechaza al nivel ``alpha`` tras la correccion. Las
|
||||||
|
posiciones cuyo valor no sea un p-valor valido (``None``, ``NaN``, fuera de
|
||||||
|
``[0, 1]`` o no numerico) se conservan en la salida como ``None`` /
|
||||||
|
``False`` y se excluyen del conteo de pruebas ``m``; asi el llamador puede
|
||||||
|
pasar la lista completa (incluidos pares sin test disponible) y recuperar un
|
||||||
|
mapeo 1:1.
|
||||||
|
|
||||||
|
Es una funcion pura y determinista: no hace I/O, no muta la entrada. No lanza
|
||||||
|
excepcion ante datos vacios o invalidos; en su lugar devuelve un dict con la
|
||||||
|
clave ``note`` explicando el caso degenerado.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pvalues: lista de p-valores (floats en [0, 1]). Se admiten ``None`` u
|
||||||
|
otros valores no validos en posiciones sin test disponible; se
|
||||||
|
propagan como ``None`` en la salida y no cuentan como prueba.
|
||||||
|
alpha: nivel de significancia objetivo tras la correccion (default 0.05).
|
||||||
|
Para BH es el umbral del FDR; para Bonferroni, del FWER.
|
||||||
|
method: ``"bh"`` (Benjamini-Hochberg, FDR) o ``"bonferroni"`` (FWER).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con las claves:
|
||||||
|
p_values_adjusted: lista alineada con ``pvalues``. Cada entrada es el
|
||||||
|
p-valor ajustado (float en [0, 1]) o ``None`` si la posicion no
|
||||||
|
era un p-valor valido.
|
||||||
|
reject: lista de booleanos alineada con ``pvalues``. ``True`` si la
|
||||||
|
hipotesis se rechaza al nivel ``alpha`` tras la correccion
|
||||||
|
(es significativa); ``False`` en caso contrario o si la posicion
|
||||||
|
no era valida.
|
||||||
|
n_tests: numero de p-valores validos usados en la correccion (m).
|
||||||
|
n_rejected: numero de hipotesis rechazadas (significativas).
|
||||||
|
alpha: nivel de significancia aplicado (float).
|
||||||
|
method: metodo aplicado (``"bh"`` o ``"bonferroni"``).
|
||||||
|
|
||||||
|
Casos degenerados (lista vacia, sin p-valores validos o metodo
|
||||||
|
desconocido) anaden ademas una clave ``note`` y devuelven listas
|
||||||
|
coherentes (``reject`` todo ``False``, ``p_values_adjusted`` con ``None``
|
||||||
|
en las posiciones invalidas).
|
||||||
|
"""
|
||||||
|
method_norm = (method or "").strip().lower()
|
||||||
|
if method_norm not in {"bh", "bonferroni"}:
|
||||||
|
n = len(pvalues)
|
||||||
|
return {
|
||||||
|
"p_values_adjusted": [None] * n,
|
||||||
|
"reject": [False] * n,
|
||||||
|
"n_tests": 0,
|
||||||
|
"n_rejected": 0,
|
||||||
|
"alpha": float(alpha),
|
||||||
|
"method": method,
|
||||||
|
"note": (
|
||||||
|
f"metodo desconocido '{method}'; usa 'bh' (Benjamini-Hochberg) "
|
||||||
|
"o 'bonferroni'"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
n = len(pvalues)
|
||||||
|
if n == 0:
|
||||||
|
return {
|
||||||
|
"p_values_adjusted": [],
|
||||||
|
"reject": [],
|
||||||
|
"n_tests": 0,
|
||||||
|
"n_rejected": 0,
|
||||||
|
"alpha": float(alpha),
|
||||||
|
"method": method_norm,
|
||||||
|
"note": "lista de p-valores vacia",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Posiciones validas: (indice_original, p). Las invalidas se propagan como None.
|
||||||
|
valid = [(i, float(p)) for i, p in enumerate(pvalues) if _is_valid_p(p)]
|
||||||
|
m = len(valid)
|
||||||
|
|
||||||
|
adjusted: list = [None] * n
|
||||||
|
reject: list = [False] * n
|
||||||
|
|
||||||
|
if m == 0:
|
||||||
|
return {
|
||||||
|
"p_values_adjusted": adjusted,
|
||||||
|
"reject": reject,
|
||||||
|
"n_tests": 0,
|
||||||
|
"n_rejected": 0,
|
||||||
|
"alpha": float(alpha),
|
||||||
|
"method": method_norm,
|
||||||
|
"note": "ningun p-valor valido en la entrada",
|
||||||
|
}
|
||||||
|
|
||||||
|
a = float(alpha)
|
||||||
|
|
||||||
|
if method_norm == "bonferroni":
|
||||||
|
# p ajustado = min(1, p * m); rechaza si p_ajustado <= alpha.
|
||||||
|
for orig_idx, p in valid:
|
||||||
|
padj = min(1.0, p * m)
|
||||||
|
adjusted[orig_idx] = padj
|
||||||
|
reject[orig_idx] = padj <= a
|
||||||
|
else:
|
||||||
|
# Benjamini-Hochberg (step-up). Ordena p ascendente y calcula q-valores
|
||||||
|
# con la monotonicidad acumulada de derecha a izquierda.
|
||||||
|
order = sorted(valid, key=lambda t: t[1]) # [(orig_idx, p), ...] por p asc
|
||||||
|
q_sorted = [0.0] * m
|
||||||
|
prev = 1.0
|
||||||
|
for rank in range(m, 0, -1):
|
||||||
|
orig_idx, p = order[rank - 1]
|
||||||
|
val = p * m / rank
|
||||||
|
prev = min(prev, val)
|
||||||
|
q_sorted[rank - 1] = min(prev, 1.0)
|
||||||
|
for k in range(m):
|
||||||
|
orig_idx, _p = order[k]
|
||||||
|
q = q_sorted[k]
|
||||||
|
adjusted[orig_idx] = q
|
||||||
|
reject[orig_idx] = q <= a
|
||||||
|
|
||||||
|
n_rejected = sum(1 for r in reject if r)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"p_values_adjusted": adjusted,
|
||||||
|
"reject": reject,
|
||||||
|
"n_tests": m,
|
||||||
|
"n_rejected": n_rejected,
|
||||||
|
"alpha": a,
|
||||||
|
"method": method_norm,
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Tests para fdr_correction (correccion de comparaciones multiples).
|
||||||
|
|
||||||
|
Importa el modulo hoja directamente (`datascience.fdr_correction`) para no
|
||||||
|
depender de que el paquete reexporte la funcion en su __init__ (lo integra el
|
||||||
|
orquestador al cerrar el grupo eda).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datascience.fdr_correction import fdr_correction
|
||||||
|
|
||||||
|
|
||||||
|
def test_bh_golden_rechaza_dos_de_tres():
|
||||||
|
# Dos p-valores fuertes y uno claramente no significativo.
|
||||||
|
# BH (step-up) sobre [0.01, 0.02, 0.5], m=3, alpha=0.05:
|
||||||
|
# q3 = 0.5*3/3 = 0.50
|
||||||
|
# q2 = min(0.50, 0.02*3/2=0.03) = 0.03
|
||||||
|
# q1 = min(0.03, 0.01*3/1=0.03) = 0.03
|
||||||
|
# reject = [q<=0.05] -> [True, True, False]
|
||||||
|
out = fdr_correction([0.01, 0.02, 0.5], alpha=0.05, method="bh")
|
||||||
|
assert out["reject"] == [True, True, False]
|
||||||
|
assert out["n_rejected"] == 2
|
||||||
|
assert out["n_tests"] == 3
|
||||||
|
assert out["method"] == "bh"
|
||||||
|
# q-valores esperados.
|
||||||
|
adj = out["p_values_adjusted"]
|
||||||
|
assert abs(adj[0] - 0.03) < 1e-9
|
||||||
|
assert abs(adj[1] - 0.03) < 1e-9
|
||||||
|
assert abs(adj[2] - 0.50) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_bonferroni_mas_conservador_que_bh():
|
||||||
|
pvalues = [0.01, 0.02, 0.5]
|
||||||
|
bh = fdr_correction(pvalues, alpha=0.05, method="bh")
|
||||||
|
bon = fdr_correction(pvalues, alpha=0.05, method="bonferroni")
|
||||||
|
# Bonferroni nunca rechaza mas que BH.
|
||||||
|
assert bon["n_rejected"] <= bh["n_rejected"]
|
||||||
|
# p ajustado = min(1, p*m): [0.03, 0.06, 1.0] -> solo el primero pasa.
|
||||||
|
assert bon["reject"] == [True, False, False]
|
||||||
|
assert abs(bon["p_values_adjusted"][0] - 0.03) < 1e-9
|
||||||
|
assert abs(bon["p_values_adjusted"][1] - 0.06) < 1e-9
|
||||||
|
assert bon["p_values_adjusted"][2] == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_p_values_adjusted_alineados_y_en_rango():
|
||||||
|
pvalues = [0.001, 0.2, 0.04, 0.6, 0.9]
|
||||||
|
out = fdr_correction(pvalues, method="bh")
|
||||||
|
assert len(out["p_values_adjusted"]) == len(pvalues)
|
||||||
|
assert len(out["reject"]) == len(pvalues)
|
||||||
|
for q in out["p_values_adjusted"]:
|
||||||
|
assert q is not None and 0.0 <= q <= 1.0
|
||||||
|
# El p-valor ajustado nunca es menor que el crudo (la correccion solo sube).
|
||||||
|
for p, q in zip(pvalues, out["p_values_adjusted"]):
|
||||||
|
assert q >= p - 1e-12
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_se_propaga_alineado():
|
||||||
|
# Posicion central sin test disponible: se propaga como None / False y no
|
||||||
|
# cuenta como prueba (m=2, no 3).
|
||||||
|
out = fdr_correction([0.001, None, 0.9], alpha=0.05, method="bh")
|
||||||
|
assert out["n_tests"] == 2
|
||||||
|
assert out["p_values_adjusted"][1] is None
|
||||||
|
assert out["reject"][1] is False
|
||||||
|
assert out["reject"][0] is True
|
||||||
|
assert len(out["reject"]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_lista_vacia_devuelve_note():
|
||||||
|
out = fdr_correction([])
|
||||||
|
assert out["p_values_adjusted"] == []
|
||||||
|
assert out["reject"] == []
|
||||||
|
assert out["n_tests"] == 0
|
||||||
|
assert out["n_rejected"] == 0
|
||||||
|
assert "note" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_solo_none_devuelve_note():
|
||||||
|
out = fdr_correction([None, None, float("nan")])
|
||||||
|
assert out["n_tests"] == 0
|
||||||
|
assert out["n_rejected"] == 0
|
||||||
|
assert out["reject"] == [False, False, False]
|
||||||
|
assert out["p_values_adjusted"] == [None, None, None]
|
||||||
|
assert "note" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_metodo_desconocido_devuelve_note():
|
||||||
|
out = fdr_correction([0.01, 0.02], method="holm")
|
||||||
|
assert "note" in out
|
||||||
|
assert out["n_rejected"] == 0
|
||||||
|
assert out["reject"] == [False, False]
|
||||||
|
|
||||||
|
|
||||||
|
def test_todos_significativos():
|
||||||
|
# Todos los p-valores diminutos -> todos rechazados con ambos metodos.
|
||||||
|
pvalues = [1e-6, 1e-5, 1e-4]
|
||||||
|
bh = fdr_correction(pvalues, alpha=0.05, method="bh")
|
||||||
|
bon = fdr_correction(pvalues, alpha=0.05, method="bonferroni")
|
||||||
|
assert bh["n_rejected"] == 3
|
||||||
|
assert bon["n_rejected"] == 3
|
||||||
|
assert all(bh["reject"])
|
||||||
|
assert all(bon["reject"])
|
||||||
@@ -201,7 +201,10 @@ def render_eda_markdown(profile: dict) -> str:
|
|||||||
if val is None:
|
if val is None:
|
||||||
continue
|
continue
|
||||||
if key == "outlier_pct":
|
if key == "outlier_pct":
|
||||||
stat_rows.append([label, _fmt_pct(val)])
|
# outlier_pct ya viene en escala 0-100 desde describe_numeric
|
||||||
|
# (100 * n_outliers / n). NO usar _fmt_pct (multiplica x100 otra
|
||||||
|
# vez y produce porcentajes imposibles, p.ej. 7% -> 700%).
|
||||||
|
stat_rows.append([label, _fmt_num(val, 2) + "%"])
|
||||||
elif key == "distribution_type":
|
elif key == "distribution_type":
|
||||||
stat_rows.append([label, str(val)])
|
stat_rows.append([label, str(val)])
|
||||||
else:
|
else:
|
||||||
@@ -264,24 +267,247 @@ def render_eda_markdown(profile: dict) -> str:
|
|||||||
parts.append("## Calidad")
|
parts.append("## Calidad")
|
||||||
parts.append(_md_table(["column", "quality_score", "issues"], rows))
|
parts.append(_md_table(["column", "quality_score", "issues"], rows))
|
||||||
|
|
||||||
# 7. Correlations (tolerate None for now).
|
# 7. Correlaciones / asociación. `association_matrix` ya corrige los p-valores
|
||||||
|
# por comparaciones múltiples (FDR Benjamini-Hochberg / Bonferroni); aquí solo
|
||||||
|
# se renderizan los campos que produjo (value, p_value_adjusted, significant),
|
||||||
|
# sin recalcular nada. Se prefieren los pares `strong` (magnitud alta Y
|
||||||
|
# significativos tras la corrección); si no hay, se muestran todos.
|
||||||
correlations = profile.get("correlations")
|
correlations = profile.get("correlations")
|
||||||
if correlations:
|
if correlations:
|
||||||
pairs = correlations
|
strong = []
|
||||||
|
all_pairs = []
|
||||||
|
multiple_testing = None
|
||||||
if isinstance(correlations, dict):
|
if isinstance(correlations, dict):
|
||||||
pairs = correlations.get("pairs") or correlations.get("strongest") or []
|
strong = correlations.get("strong") or correlations.get("strongest") or []
|
||||||
|
all_pairs = correlations.get("pairs") or []
|
||||||
|
multiple_testing = correlations.get("multiple_testing")
|
||||||
|
else:
|
||||||
|
all_pairs = correlations
|
||||||
|
shown = strong or all_pairs
|
||||||
corr_rows = []
|
corr_rows = []
|
||||||
for pair in pairs or []:
|
for pair in shown or []:
|
||||||
if isinstance(pair, dict):
|
if not isinstance(pair, dict):
|
||||||
|
continue
|
||||||
|
padj = pair.get("p_value_adjusted")
|
||||||
|
sig = pair.get("significant")
|
||||||
corr_rows.append([
|
corr_rows.append([
|
||||||
pair.get("a") or pair.get("col_a"),
|
pair.get("a") or pair.get("col_a"),
|
||||||
pair.get("b") or pair.get("col_b"),
|
pair.get("b") or pair.get("col_b"),
|
||||||
|
pair.get("method", ""),
|
||||||
_fmt_num(pair.get("value") if pair.get("value") is not None
|
_fmt_num(pair.get("value") if pair.get("value") is not None
|
||||||
else pair.get("corr")),
|
else pair.get("corr")),
|
||||||
|
_fmt_num(padj) if padj is not None else "",
|
||||||
|
"sí" if sig else ("no" if sig is not None else ""),
|
||||||
])
|
])
|
||||||
if corr_rows:
|
if corr_rows:
|
||||||
parts.append("## Correlaciones")
|
parts.append("## Correlaciones")
|
||||||
parts.append(_md_table(["a", "b", "corr"], corr_rows))
|
if isinstance(multiple_testing, dict):
|
||||||
|
parts.append(
|
||||||
|
"Corrección de comparaciones múltiples: "
|
||||||
|
f"{multiple_testing.get('method')} "
|
||||||
|
f"(α={multiple_testing.get('alpha')}); "
|
||||||
|
f"{multiple_testing.get('n_rejected')} de "
|
||||||
|
f"{multiple_testing.get('n_tests')} pares significativos tras la "
|
||||||
|
"corrección. Mostrando "
|
||||||
|
f"{'solo pares fuertes' if strong else 'todos los pares evaluados'}."
|
||||||
|
)
|
||||||
|
parts.append(_md_table(
|
||||||
|
["a", "b", "method", "value", "p_adj (FDR)", "sig"], corr_rows))
|
||||||
|
|
||||||
|
# 7b. Re-expresión sugerida (escalera de potencias de Tukey) por columna
|
||||||
|
# numérica. `suggest_reexpression` decide la transformación que más simetriza;
|
||||||
|
# aquí solo se rinde su recomendación y razón.
|
||||||
|
reexp_rows = []
|
||||||
|
for col in columns:
|
||||||
|
if not isinstance(col, dict):
|
||||||
|
continue
|
||||||
|
rx = col.get("reexpression")
|
||||||
|
if not isinstance(rx, dict) or rx.get("recommended") is None:
|
||||||
|
continue
|
||||||
|
ladder = rx.get("ladder_power")
|
||||||
|
reexp_rows.append([
|
||||||
|
col.get("name"),
|
||||||
|
_fmt_num(rx.get("skew")),
|
||||||
|
rx.get("recommended"),
|
||||||
|
_fmt_num(ladder) if ladder is not None else "",
|
||||||
|
rx.get("reason", ""),
|
||||||
|
])
|
||||||
|
if reexp_rows:
|
||||||
|
parts.append("## Re-expresión sugerida")
|
||||||
|
parts.append(_md_table(
|
||||||
|
["column", "skew", "transform", "ladder_power", "reason"], reexp_rows))
|
||||||
|
|
||||||
|
# 7c. Series temporales. Bloque por columna numérica cuando el pipeline corrió
|
||||||
|
# con run_series: estacionariedad (ADF+KPSS), autocorrelación (ACF/PACF +
|
||||||
|
# Ljung-Box), descomposición STL y, si es una serie de niveles, sugerencia de
|
||||||
|
# retornos.
|
||||||
|
series_blocks = []
|
||||||
|
for col in columns:
|
||||||
|
if not isinstance(col, dict):
|
||||||
|
continue
|
||||||
|
s = col.get("series")
|
||||||
|
if not isinstance(s, dict):
|
||||||
|
continue
|
||||||
|
name = col.get("name") or "(col)"
|
||||||
|
block = [f"### {name}"]
|
||||||
|
rows = []
|
||||||
|
stat = s.get("stationarity") or {}
|
||||||
|
if stat.get("verdict") is not None:
|
||||||
|
rows.append(["estacionariedad (ADF+KPSS)", stat.get("verdict")])
|
||||||
|
acf = s.get("acf_pacf") or {}
|
||||||
|
if acf.get("is_autocorrelated") is not None:
|
||||||
|
rows.append([
|
||||||
|
"autocorrelada (Ljung-Box)",
|
||||||
|
"sí" if acf.get("is_autocorrelated") else "no",
|
||||||
|
])
|
||||||
|
sig_lags = acf.get("significant_acf_lags")
|
||||||
|
if sig_lags:
|
||||||
|
rows.append([
|
||||||
|
"lags ACF significativos",
|
||||||
|
", ".join(str(lag) for lag in sig_lags[:12]),
|
||||||
|
])
|
||||||
|
stl = s.get("stl") or {}
|
||||||
|
if stl.get("trend_strength") is not None:
|
||||||
|
rows.append(["fuerza de tendencia (STL)", _fmt_num(stl.get("trend_strength"))])
|
||||||
|
if stl.get("seasonal_strength") is not None:
|
||||||
|
rows.append(["fuerza estacional (STL)", _fmt_num(stl.get("seasonal_strength"))])
|
||||||
|
if stl.get("period") is not None:
|
||||||
|
rows.append(["periodo estacional", stl.get("period")])
|
||||||
|
elif stl.get("note"):
|
||||||
|
rows.append(["STL", stl.get("note")])
|
||||||
|
if s.get("levels_suggested"):
|
||||||
|
# La transformación recomendada depende de la semántica: retornos para
|
||||||
|
# series financieras (precio/volumen), diferencias para magnitudes
|
||||||
|
# físicas (temperatura, caudal). Aplicar "retornos" a temperatura no
|
||||||
|
# tiene sentido físico; las diferencias sí.
|
||||||
|
kind = s.get("levels_kind")
|
||||||
|
if kind == "returns":
|
||||||
|
label = "convertir a retornos (serie de niveles financiera)"
|
||||||
|
elif kind == "differences":
|
||||||
|
label = "trabajar sobre diferencias (serie de niveles no financiera)"
|
||||||
|
else:
|
||||||
|
label = "convertir a retornos o diferencias (serie de niveles)"
|
||||||
|
rows.append(["sugerencia", label])
|
||||||
|
# Las métricas de retorno (media/volatilidad) solo se muestran cuando la
|
||||||
|
# transformación recomendada son retornos; para diferencias no aplican.
|
||||||
|
if kind != "differences":
|
||||||
|
tr = s.get("to_returns") or {}
|
||||||
|
if tr.get("mean") is not None:
|
||||||
|
rows.append(["retorno medio (log)", _fmt_num(tr.get("mean"))])
|
||||||
|
if tr.get("std") is not None:
|
||||||
|
rows.append(["volatilidad retornos (σ)", _fmt_num(tr.get("std"))])
|
||||||
|
if rows:
|
||||||
|
block.append(_md_table(["aspecto", "valor"], rows))
|
||||||
|
if stat.get("warning"):
|
||||||
|
block.append(f"> {stat.get('warning')}")
|
||||||
|
series_blocks.append("\n\n".join(block))
|
||||||
|
if series_blocks:
|
||||||
|
parts.append("## Series temporales")
|
||||||
|
parts.extend(series_blocks)
|
||||||
|
|
||||||
|
# 7d. Modelos baratos (PCA, KMeans, outliers multivariantes, normalidad). El
|
||||||
|
# pipeline corre `run_eda_models` cuando se pide con run_models; el bloque está
|
||||||
|
# completo en el JSON pero antes no tenía formatter en markdown y se omitía. Se
|
||||||
|
# lee todo defensivo con .get y cada submodelo se renderiza solo si está presente.
|
||||||
|
models = profile.get("models")
|
||||||
|
if isinstance(models, dict):
|
||||||
|
model_parts: list[str] = []
|
||||||
|
|
||||||
|
pca = models.get("pca")
|
||||||
|
if isinstance(pca, dict):
|
||||||
|
evr = pca.get("explained_variance_ratio") or []
|
||||||
|
cum = pca.get("cumulative") or []
|
||||||
|
pca_rows = []
|
||||||
|
for i, var in enumerate(evr):
|
||||||
|
acc = cum[i] if i < len(cum) else None
|
||||||
|
pca_rows.append([f"PC{i + 1}", _fmt_pct(var), _fmt_pct(acc)])
|
||||||
|
sub = ["### PCA"]
|
||||||
|
n_feat = pca.get("n_features")
|
||||||
|
n_used = pca.get("n_rows_used")
|
||||||
|
if n_feat is not None or n_used is not None:
|
||||||
|
sub.append(
|
||||||
|
f"{pca.get('n_components')} componentes sobre "
|
||||||
|
f"{n_used if n_used is not None else '?'} filas, "
|
||||||
|
f"{n_feat if n_feat is not None else '?'} features."
|
||||||
|
)
|
||||||
|
if pca_rows:
|
||||||
|
sub.append(_md_table(
|
||||||
|
["componente", "var. explicada", "acumulada"], pca_rows))
|
||||||
|
loadings = pca.get("top_loadings") or []
|
||||||
|
load_rows = []
|
||||||
|
for ld in loadings[:12]:
|
||||||
|
if not isinstance(ld, dict):
|
||||||
|
continue
|
||||||
|
comp = ld.get("component")
|
||||||
|
comp_label = f"PC{comp + 1}" if isinstance(comp, int) else str(comp)
|
||||||
|
load_rows.append([comp_label, ld.get("feature"),
|
||||||
|
_fmt_num(ld.get("loading"), 3)])
|
||||||
|
if load_rows:
|
||||||
|
sub.append("Cargas principales:")
|
||||||
|
sub.append(_md_table(["componente", "feature", "carga"], load_rows))
|
||||||
|
model_parts.append("\n\n".join(sub))
|
||||||
|
|
||||||
|
km = models.get("kmeans")
|
||||||
|
if isinstance(km, dict):
|
||||||
|
sub = ["### KMeans"]
|
||||||
|
best_k = km.get("best_k")
|
||||||
|
sil = km.get("silhouette")
|
||||||
|
sizes = km.get("cluster_sizes") or []
|
||||||
|
head = f"mejor k = {_fmt_num(best_k)}"
|
||||||
|
if sil is not None:
|
||||||
|
head += f" (silhouette {_fmt_num(sil, 3)})"
|
||||||
|
if sizes:
|
||||||
|
head += ". Tamaños de cluster: " + ", ".join(
|
||||||
|
_fmt_num(s) for s in sizes)
|
||||||
|
sub.append(head + ".")
|
||||||
|
score_rows = []
|
||||||
|
for sc in km.get("scores_by_k") or []:
|
||||||
|
if not isinstance(sc, dict):
|
||||||
|
continue
|
||||||
|
score_rows.append([sc.get("k"), _fmt_num(sc.get("silhouette"), 3),
|
||||||
|
_fmt_num(sc.get("inertia"), 2)])
|
||||||
|
if score_rows:
|
||||||
|
sub.append(_md_table(["k", "silhouette", "inertia"], score_rows))
|
||||||
|
model_parts.append("\n\n".join(sub))
|
||||||
|
|
||||||
|
out = models.get("outliers")
|
||||||
|
if isinstance(out, dict):
|
||||||
|
# outlier_pct del modelo multivariante ya viene en escala 0-100.
|
||||||
|
n_out = out.get("n_outliers")
|
||||||
|
pct = out.get("outlier_pct")
|
||||||
|
thr = out.get("threshold")
|
||||||
|
line = f"{_fmt_num(n_out)} filas marcadas como outlier"
|
||||||
|
if pct is not None:
|
||||||
|
line += f" ({_fmt_num(pct, 2)}%)"
|
||||||
|
if thr is not None:
|
||||||
|
line += f"; umbral de score {_fmt_num(thr, 3)}"
|
||||||
|
model_parts.append("### Outliers multivariante (Isolation Forest)\n\n"
|
||||||
|
+ line + ".")
|
||||||
|
|
||||||
|
normality = models.get("normality")
|
||||||
|
if isinstance(normality, dict):
|
||||||
|
norm_rows = []
|
||||||
|
for col_name, res in normality.items():
|
||||||
|
if not isinstance(res, dict):
|
||||||
|
continue
|
||||||
|
jb = res.get("jarque_bera") or {}
|
||||||
|
norm_rows.append([
|
||||||
|
col_name,
|
||||||
|
"sí" if res.get("is_normal") else "no",
|
||||||
|
_fmt_num(jb.get("p")) if jb.get("p") is not None else "",
|
||||||
|
])
|
||||||
|
if norm_rows:
|
||||||
|
model_parts.append(
|
||||||
|
"### Normalidad\n\n"
|
||||||
|
+ _md_table(["columna", "normal", "Jarque-Bera p"], norm_rows))
|
||||||
|
|
||||||
|
note = models.get("note")
|
||||||
|
if note:
|
||||||
|
model_parts.append(f"> {note}")
|
||||||
|
|
||||||
|
if model_parts:
|
||||||
|
parts.append("## Modelos")
|
||||||
|
parts.extend(model_parts)
|
||||||
|
|
||||||
# 8. LLM analysis (tolerate None for now).
|
# 8. LLM analysis (tolerate None for now).
|
||||||
llm = profile.get("llm")
|
llm = profile.get("llm")
|
||||||
@@ -299,4 +525,24 @@ def render_eda_markdown(profile: dict) -> str:
|
|||||||
else:
|
else:
|
||||||
parts.append(str(llm))
|
parts.append(str(llm))
|
||||||
|
|
||||||
|
# 9. Avisos exploratorios. `exploratory_caveats` recuerda que el EDA genera
|
||||||
|
# hipótesis, no conclusiones; se renderiza la lista de advertencias que aplican
|
||||||
|
# a lo que realmente se calculó.
|
||||||
|
caveats = profile.get("caveats")
|
||||||
|
cav_list = []
|
||||||
|
if isinstance(caveats, dict):
|
||||||
|
cav_list = caveats.get("caveats") or []
|
||||||
|
elif isinstance(caveats, list):
|
||||||
|
cav_list = caveats
|
||||||
|
cav_lines = []
|
||||||
|
for cav in cav_list:
|
||||||
|
if not isinstance(cav, dict):
|
||||||
|
continue
|
||||||
|
topic = cav.get("topic") or cav.get("id") or ""
|
||||||
|
msg = cav.get("message") or ""
|
||||||
|
cav_lines.append(f"- **{topic}**: {msg}")
|
||||||
|
if cav_lines:
|
||||||
|
parts.append("## Avisos exploratorios")
|
||||||
|
parts.append("\n".join(cav_lines))
|
||||||
|
|
||||||
return "\n\n".join(parts) + "\n"
|
return "\n\n".join(parts) + "\n"
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ def _sample_profile(correlations=None, llm=None):
|
|||||||
"p99": 95.0,
|
"p99": 95.0,
|
||||||
"skew": 0.4,
|
"skew": 0.4,
|
||||||
"kurtosis": 2.1,
|
"kurtosis": 2.1,
|
||||||
"outlier_pct": 0.012,
|
# outlier_pct ya viene en escala 0-100 desde describe_numeric
|
||||||
|
# (100 * n_outliers / n), NO en fracción 0-1.
|
||||||
|
"outlier_pct": 3.5,
|
||||||
"distribution_type": "right-skewed",
|
"distribution_type": "right-skewed",
|
||||||
"histogram": [
|
"histogram": [
|
||||||
{"lo": 0, "hi": 25, "count": 100},
|
{"lo": 0, "hi": 25, "count": 100},
|
||||||
@@ -126,8 +128,15 @@ def test_pct_fields_scaled_by_100():
|
|||||||
assert "0.86%" not in md
|
assert "0.86%" not in md
|
||||||
# categorical top pct=0.5 -> "50.0%".
|
# categorical top pct=0.5 -> "50.0%".
|
||||||
assert "50.0" in md
|
assert "50.0" in md
|
||||||
# outlier_pct=0.012 -> "1.20%".
|
|
||||||
assert "1.20%" in md
|
|
||||||
|
def test_outlier_pct_not_double_scaled():
|
||||||
|
# outlier_pct ya viene en escala 0-100 (describe_numeric): el render lo muestra
|
||||||
|
# tal cual + '%', SIN multiplicar otra vez por 100. outlier_pct=3.5 -> "3.5%",
|
||||||
|
# nunca "350%" (el bug del doble ×100).
|
||||||
|
md = render_eda_markdown(_sample_profile())
|
||||||
|
assert "3.5%" in md
|
||||||
|
assert "350" not in md
|
||||||
|
|
||||||
|
|
||||||
def test_pct_handles_none_as_blank():
|
def test_pct_handles_none_as_blank():
|
||||||
@@ -164,3 +173,62 @@ def test_tolerates_empty_profile():
|
|||||||
def test_tolerates_none_profile():
|
def test_tolerates_none_profile():
|
||||||
md = render_eda_markdown(None)
|
md = render_eda_markdown(None)
|
||||||
assert "# EDA — (unnamed)" in md
|
assert "# EDA — (unnamed)" in md
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_models():
|
||||||
|
"""Bloque `models` como el que produce run_eda_models (PCA/KMeans/...)."""
|
||||||
|
return {
|
||||||
|
"n_numeric_cols": 3,
|
||||||
|
"pca": {
|
||||||
|
"n_components": 2,
|
||||||
|
"n_rows_used": 1000,
|
||||||
|
"n_features": 3,
|
||||||
|
"explained_variance_ratio": [0.62, 0.21],
|
||||||
|
"cumulative": [0.62, 0.83],
|
||||||
|
"top_loadings": [
|
||||||
|
{"component": 0, "feature": "price", "loading": 0.71},
|
||||||
|
{"component": 1, "feature": "qty", "loading": -0.55},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"kmeans": {
|
||||||
|
"best_k": 3,
|
||||||
|
"silhouette": 0.48,
|
||||||
|
"cluster_sizes": [500, 300, 200],
|
||||||
|
"scores_by_k": [
|
||||||
|
{"k": 2, "silhouette": 0.41, "inertia": 1200.0},
|
||||||
|
{"k": 3, "silhouette": 0.48, "inertia": 900.0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"outliers": {
|
||||||
|
"n_outliers": 35,
|
||||||
|
"outlier_pct": 3.5,
|
||||||
|
"threshold": -0.51,
|
||||||
|
},
|
||||||
|
"normality": {
|
||||||
|
"price": {"jarque_bera": {"p": 0.0001}, "is_normal": False},
|
||||||
|
},
|
||||||
|
"note": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_section_rendered():
|
||||||
|
# H4: el bloque models antes se omitía en markdown; ahora tiene formatter.
|
||||||
|
profile = _sample_profile()
|
||||||
|
profile["models"] = _sample_models()
|
||||||
|
md = render_eda_markdown(profile)
|
||||||
|
assert "## Modelos" in md
|
||||||
|
assert "### PCA" in md
|
||||||
|
assert "### KMeans" in md
|
||||||
|
assert "### Outliers multivariante (Isolation Forest)" in md
|
||||||
|
assert "### Normalidad" in md
|
||||||
|
# Datos reales del PCA renderizados (varianza explicada ×100) y KMeans.
|
||||||
|
assert "62.0" in md # explained_variance_ratio 0.62 -> 62.00%
|
||||||
|
assert "mejor k = 3" in md
|
||||||
|
# outlier_pct del modelo ya viene en escala 0-100: 3.5 -> "3.5%", no "350".
|
||||||
|
assert "3.5%" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_absent_when_none():
|
||||||
|
# Edge: profile sin models (None) no produce sección Modelos ni rompe.
|
||||||
|
md = render_eda_markdown(_sample_profile()) # models=None en el sample
|
||||||
|
assert "## Modelos" not in md
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
name: render_eda_pdf
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def render_eda_pdf(profile: dict, out_path: str, title: str = None) -> dict"
|
||||||
|
description: "Renderiza un TableProfile del grupo eda en un PDF multipágina portátil pensado para LEER Y EXPLORAR EN EL MÓVIL. Páginas A5 retrato, una columna, tipografía grande; diseño Tufte (alto data-ink ratio, histogramas reales como small multiples, barras top-k, heatmap de asociación, integridad de ejes desde 0). Lee todo el profile defensivamente con .get y sólo renderiza las secciones presentes; bloques nuevos del profile (models, caveats, ...) se vuelcan genéricamente (forward-compatible). dict-no-throw: nunca lanza, devuelve {pdf_path, n_pages, note}. Motor matplotlib PdfPages, cero dependencias nuevas."
|
||||||
|
tags: [eda, pdf, render, report, mobile, tufte, visualization, matplotlib, profiling, datascience, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [os, textwrap, datetime, matplotlib, numpy]
|
||||||
|
params:
|
||||||
|
- name: profile
|
||||||
|
desc: "TableProfile dict del grupo de capacidad eda (el dict que profile_table devuelve bajo la clave 'profile'). Puede tener muchas claves ausentes o None; un profile None/vacío genera igualmente un PDF de 1 página. Claves consumidas: table, source, profiled_at, n_rows, n_cols, size_bytes, duplicate_rows/_pct, null_cell_pct, quality_score, type_breakdown, constant_cols, all_null_cols, key_candidates, columns[] (con numeric.histogram [{lo,hi,count}], categorical.top [{value,count,pct}], quality_score, flags/issues), correlations.pairs [{a,b,value}], llm. Cualquier otra clave de nivel superior se vuelca en una página forward-compat."
|
||||||
|
- name: out_path
|
||||||
|
desc: "ruta del archivo PDF de salida. Los directorios padre se crean si faltan."
|
||||||
|
- name: title
|
||||||
|
desc: "título opcional para la portada. Por defecto 'EDA — <table>'."
|
||||||
|
output: "dict (nunca lanza): {pdf_path: str, n_pages: int, note: str}. En éxito pdf_path es la ruta escrita, n_pages el número de páginas generadas y note un resumen ('N páginas', con detalle de las secciones omitidas si alguna falló). En error fatal de escritura pdf_path es None y note explica la causa."
|
||||||
|
tested: true
|
||||||
|
tests: ["test_golden_genera_pdf_multipagina", "test_edge_profile_vacio_no_revienta", "test_edge_profile_none_no_revienta", "test_edge_solo_numericas", "test_forward_compat_seccion_desconocida"]
|
||||||
|
test_file_path: "python/functions/datascience/render_eda_pdf_test.py"
|
||||||
|
file_path: "python/functions/datascience/render_eda_pdf.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import render_eda_pdf
|
||||||
|
|
||||||
|
# TableProfile mínimo (en la práctica viene de profile_table(...)["profile"]).
|
||||||
|
profile = {
|
||||||
|
"table": "ventas",
|
||||||
|
"source": "data/ventas.csv",
|
||||||
|
"n_rows": 1000,
|
||||||
|
"n_cols": 2,
|
||||||
|
"null_cell_pct": 0.02,
|
||||||
|
"quality_score": 92.5,
|
||||||
|
"type_breakdown": {"numeric": 1, "categorical": 1},
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "precio",
|
||||||
|
"inferred_type": "numeric",
|
||||||
|
"quality_score": 95.0,
|
||||||
|
"numeric": {
|
||||||
|
"min": 1.0, "max": 100.0, "median": 40.0, "mean": 42.5,
|
||||||
|
"std": 12.3, "outlier_pct": 1.2,
|
||||||
|
"histogram": [
|
||||||
|
{"lo": 0.0, "hi": 25.0, "count": 100},
|
||||||
|
{"lo": 25.0, "hi": 50.0, "count": 500},
|
||||||
|
{"lo": 50.0, "hi": 75.0, "count": 300},
|
||||||
|
{"lo": 75.0, "hi": 100.0, "count": 50},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "categoria",
|
||||||
|
"inferred_type": "categorical",
|
||||||
|
"quality_score": 99.0,
|
||||||
|
"categorical": {
|
||||||
|
"entropy": 1.05,
|
||||||
|
"top": [
|
||||||
|
{"value": "neumaticos", "count": 500, "pct": 0.5},
|
||||||
|
{"value": "aceite", "count": 300, "pct": 0.3},
|
||||||
|
{"value": "filtros", "count": 200, "pct": 0.2},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
res = render_eda_pdf(profile, "reports/eda_ventas.pdf", title="EDA — ventas")
|
||||||
|
print(res) # -> {'pdf_path': 'reports/eda_ventas.pdf', 'n_pages': 5, 'note': '5 páginas'}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando quieras una **4ª salida portátil del EDA para revisar en el teléfono**:
|
||||||
|
después de `profile_table(...)`, pásale el `profile` resultante para emitir un PDF
|
||||||
|
que el usuario recibe y explora desde el móvil, sin abrir notebooks ni markdown.
|
||||||
|
Úsala como capa de presentación del grupo `eda` (junto al report markdown, el JSON
|
||||||
|
sidecar y el notebook Jupyter): histogramas reales en small multiples, barras top-k
|
||||||
|
de las categóricas, heatmap de correlaciones y una portada con el score de calidad,
|
||||||
|
todo maquetado para pantalla pequeña con criterios de Tufte (alto data-ink ratio,
|
||||||
|
ejes honestos desde 0). No recalcula nada del perfil — sólo lo dibuja.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura**: escribe un archivo en `out_path` (crea los directorios padre). Usa el
|
||||||
|
backend headless `Agg` de matplotlib, así que corre en agentes/CI sin display.
|
||||||
|
- **Nunca lanza** (dict-no-throw): cada sección se construye aislada; si una falla,
|
||||||
|
se omite y se anota en `note`, pero el PDF se genera igual. Un profile `None`/`{}`
|
||||||
|
produce un PDF de 1 página válido.
|
||||||
|
- **Forward-compatible**: sólo conoce un conjunto fijo de claves de nivel superior;
|
||||||
|
cualquier bloque nuevo del profile (p.ej. `models`, `caveats`, series temporales
|
||||||
|
que añadan otras funciones del grupo) se vuelca en una página genérica "Otras
|
||||||
|
secciones" en vez de perderse o romper. No asume claves que quizá no existan.
|
||||||
|
- **Registro en el package**: el `## Ejemplo` usa `from datascience import render_eda_pdf`,
|
||||||
|
que requiere que la función esté añadida al `__init__.py` del paquete (lo hace `fn
|
||||||
|
index` + la integración del orquestador). El test importa el módulo directo
|
||||||
|
(`from render_eda_pdf import render_eda_pdf`) para no depender de ese registro.
|
||||||
|
- **Histograma real, no ASCII**: necesita `numeric.histogram` como lista de bins
|
||||||
|
`{lo, hi, count}` (el formato que emite `describe_numeric`). Si una columna numérica
|
||||||
|
no trae histograma, esa columna se salta en la página de distribuciones.
|
||||||
|
- **Heatmap de correlaciones**: reconstruye la matriz simétrica desde
|
||||||
|
`correlations.pairs` (`{a, b, value}`); anota los valores en celda sólo si hay ≤8
|
||||||
|
columnas para no saturar la pantalla del móvil.
|
||||||
|
- **PDF con texto seleccionable** (`pdf.fonttype=42`, TrueType embebido), legible y
|
||||||
|
buscable en visores móviles.
|
||||||
@@ -0,0 +1,942 @@
|
|||||||
|
"""render_eda_pdf — Portable, mobile-readable PDF report of a TableProfile (eda group).
|
||||||
|
|
||||||
|
Impure function (writes a file): takes a TableProfile dict from the `eda`
|
||||||
|
capability group and renders a MULTI-PAGE PDF designed to be read and explored
|
||||||
|
on a phone screen. It is the 4th output of the eda workflow, next to the
|
||||||
|
markdown report, the JSON sidecar and the executed Jupyter notebook.
|
||||||
|
|
||||||
|
Design follows Edward Tufte, "The Visual Display of Quantitative Information":
|
||||||
|
high data-ink ratio (no chartjunk, despined axes, light grids), small multiples
|
||||||
|
for per-column histograms, and graphical integrity (y-axes start at 0, no
|
||||||
|
misleading truncation). Pages are A5 portrait, single column, with a large,
|
||||||
|
legible typeface so the report stays readable on a small display.
|
||||||
|
|
||||||
|
Every key of the profile is read defensively with ``.get(...)`` and only the
|
||||||
|
sections actually present are rendered. The function is forward-compatible: if
|
||||||
|
the profile carries blocks this renderer does not know about (e.g. ``models``,
|
||||||
|
time series, ``caveats`` added by sibling functions), they are dumped generically
|
||||||
|
on a final page instead of being ignored or crashing the render.
|
||||||
|
|
||||||
|
dict-no-throw contract of the eda group: it NEVER raises. Any failure of a single
|
||||||
|
section is caught and noted; the function always returns a dict with the path,
|
||||||
|
the page count and a human note.
|
||||||
|
|
||||||
|
Engine: matplotlib ``PdfPages`` (already in ``python/.venv``) — zero new deps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import textwrap
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
# Headless backend: this runs in agents/CI without a display.
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt # noqa: E402
|
||||||
|
import numpy as np # noqa: E402
|
||||||
|
from matplotlib.backends.backend_pdf import PdfPages # noqa: E402
|
||||||
|
|
||||||
|
# A5 portrait in inches (148 x 210 mm). Single column, tall, phone-friendly.
|
||||||
|
_A5_PORTRAIT = (5.83, 8.27)
|
||||||
|
|
||||||
|
# Number of per-column small multiples stacked vertically on one page.
|
||||||
|
_NUMERIC_PER_PAGE = 3
|
||||||
|
_CATEGORICAL_PER_PAGE = 3
|
||||||
|
|
||||||
|
# Top-of-profile keys this renderer handles explicitly. Anything else found at
|
||||||
|
# the top level of the profile is dumped on the forward-compat "Otros" page so
|
||||||
|
# new sections added by sibling functions still reach the reader.
|
||||||
|
_KNOWN_TOP_KEYS = {
|
||||||
|
"table", "source", "profiled_at", "n_rows", "n_cols", "size_bytes",
|
||||||
|
"duplicate_rows", "duplicate_pct", "null_cell_pct", "constant_cols",
|
||||||
|
"all_null_cols", "quality_score", "type_breakdown", "key_candidates",
|
||||||
|
"columns", "correlations", "llm",
|
||||||
|
# Bloques con builder dedicado (no caen al volcado genérico str(dict)).
|
||||||
|
"models", "series", "caveats",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Restrained, high-contrast palette: a single accent reads cleanly on a phone.
|
||||||
|
_INK = "#1b1b1b"
|
||||||
|
_ACCENT = "#2a6f97"
|
||||||
|
_MUTED = "#8a8a8a"
|
||||||
|
|
||||||
|
# Tufte-ish render defaults shared by both public entry points.
|
||||||
|
_RC = {
|
||||||
|
"font.size": 10,
|
||||||
|
"font.family": "sans-serif",
|
||||||
|
"axes.titlesize": 11,
|
||||||
|
"axes.edgecolor": _MUTED,
|
||||||
|
"figure.facecolor": "white",
|
||||||
|
"savefig.facecolor": "white",
|
||||||
|
"pdf.fonttype": 42, # embed TrueType so text stays selectable on mobile.
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Small formatting + Tufte helpers
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _fmt_num(value, decimals: int = 3) -> str:
|
||||||
|
"""Format a number compactly; fall back to str for non-numerics/None."""
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return str(value)
|
||||||
|
if isinstance(value, int):
|
||||||
|
return f"{value:,}"
|
||||||
|
if isinstance(value, float):
|
||||||
|
if value != value: # NaN
|
||||||
|
return "NaN"
|
||||||
|
if value in (float("inf"), float("-inf")):
|
||||||
|
return str(value)
|
||||||
|
text = f"{value:.{decimals}f}".rstrip("0").rstrip(".")
|
||||||
|
return text if text else "0"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_pct(value, decimals: int = 1) -> str:
|
||||||
|
"""Format a fraction (0-1) as 'NN.N%'. Returns '—' for None."""
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
try:
|
||||||
|
num = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(value)
|
||||||
|
return f"{num * 100:.{decimals}f}%"
|
||||||
|
|
||||||
|
|
||||||
|
def _despine(ax) -> None:
|
||||||
|
"""Strip top/right spines and soften the rest — raise the data-ink ratio."""
|
||||||
|
for side in ("top", "right"):
|
||||||
|
ax.spines[side].set_visible(False)
|
||||||
|
for side in ("left", "bottom"):
|
||||||
|
ax.spines[side].set_color(_MUTED)
|
||||||
|
ax.spines[side].set_linewidth(0.6)
|
||||||
|
ax.tick_params(colors=_MUTED, labelsize=7, length=2)
|
||||||
|
ax.title.set_color(_INK)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text, width: int = 22) -> str:
|
||||||
|
"""Clip an arbitrary value to a short label for tight phone layouts."""
|
||||||
|
s = str(text) if text is not None else "—"
|
||||||
|
return s if len(s) <= width else s[: width - 1] + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def _text_page(pdf, title: str, lines: list, subtitle: str = None) -> int:
|
||||||
|
"""Render one text page (monospace body) and return 1 (pages written)."""
|
||||||
|
fig = plt.figure(figsize=_A5_PORTRAIT)
|
||||||
|
fig.text(0.08, 0.94, title, fontsize=16, fontweight="bold", color=_INK)
|
||||||
|
if subtitle:
|
||||||
|
fig.text(0.08, 0.905, subtitle, fontsize=9, color=_MUTED)
|
||||||
|
body = "\n".join(lines)
|
||||||
|
fig.text(
|
||||||
|
0.08, 0.88, body, fontsize=9.5, color=_INK, family="monospace",
|
||||||
|
va="top", ha="left", linespacing=1.5,
|
||||||
|
)
|
||||||
|
pdf.savefig(fig)
|
||||||
|
plt.close(fig)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _kv_lines(rows: list, key_width: int = 18) -> list:
|
||||||
|
"""Format [label, value] rows as aligned 'label : value' monospace lines."""
|
||||||
|
out = []
|
||||||
|
for label, value in rows:
|
||||||
|
out.append(f"{str(label):<{key_width}}: {value}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Page builders (each fully defensive, each returns the number of pages it made)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _cover_page(pdf, profile: dict, title: str) -> int:
|
||||||
|
"""Cover: table name, date, shape and an oversized quality score."""
|
||||||
|
fig = plt.figure(figsize=_A5_PORTRAIT)
|
||||||
|
|
||||||
|
table = profile.get("table") or "(tabla sin nombre)"
|
||||||
|
heading = title or f"EDA — {table}"
|
||||||
|
fig.text(0.08, 0.82, heading, fontsize=22, fontweight="bold", color=_INK,
|
||||||
|
wrap=True)
|
||||||
|
|
||||||
|
sub = []
|
||||||
|
src = profile.get("source")
|
||||||
|
if src:
|
||||||
|
sub.append(f"fuente: {_truncate(src, 40)}")
|
||||||
|
when = profile.get("profiled_at") or datetime.now(timezone.utc).strftime(
|
||||||
|
"%Y-%m-%d %H:%M UTC"
|
||||||
|
)
|
||||||
|
sub.append(f"generado: {when}")
|
||||||
|
fig.text(0.08, 0.76, "\n".join(sub), fontsize=10, color=_MUTED, va="top")
|
||||||
|
|
||||||
|
n_rows = profile.get("n_rows")
|
||||||
|
n_cols = profile.get("n_cols")
|
||||||
|
shape = (f"{_fmt_num(n_rows)} filas × {_fmt_num(n_cols)} columnas")
|
||||||
|
fig.text(0.08, 0.60, shape, fontsize=15, color=_ACCENT, fontweight="bold")
|
||||||
|
|
||||||
|
score = profile.get("quality_score")
|
||||||
|
if score is not None:
|
||||||
|
fig.text(0.08, 0.42, "calidad", fontsize=12, color=_MUTED)
|
||||||
|
fig.text(0.08, 0.31, _fmt_num(score), fontsize=60, fontweight="bold",
|
||||||
|
color=_INK)
|
||||||
|
fig.text(0.08, 0.25, "sobre 100", fontsize=12, color=_MUTED)
|
||||||
|
|
||||||
|
fig.text(0.08, 0.06, "Tufte · alta densidad de datos · lectura en móvil",
|
||||||
|
fontsize=8, color=_MUTED, style="italic")
|
||||||
|
pdf.savefig(fig)
|
||||||
|
plt.close(fig)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _overview_page(pdf, profile: dict) -> int:
|
||||||
|
"""Overview key/value page: types, duplicates, nulls, constants, keys."""
|
||||||
|
rows = []
|
||||||
|
if profile.get("n_rows") is not None:
|
||||||
|
rows.append(["Filas", _fmt_num(profile.get("n_rows"))])
|
||||||
|
if profile.get("n_cols") is not None:
|
||||||
|
rows.append(["Columnas", _fmt_num(profile.get("n_cols"))])
|
||||||
|
if profile.get("size_bytes") is not None:
|
||||||
|
rows.append(["Tamaño (bytes)", _fmt_num(profile.get("size_bytes"))])
|
||||||
|
if profile.get("duplicate_rows") is not None:
|
||||||
|
dup = _fmt_num(profile.get("duplicate_rows"))
|
||||||
|
if profile.get("duplicate_pct") is not None:
|
||||||
|
dup += f" ({_fmt_pct(profile.get('duplicate_pct'))})"
|
||||||
|
rows.append(["Filas duplicadas", dup])
|
||||||
|
if profile.get("null_cell_pct") is not None:
|
||||||
|
rows.append(["Celdas nulas", _fmt_pct(profile.get("null_cell_pct"))])
|
||||||
|
if profile.get("quality_score") is not None:
|
||||||
|
rows.append(["Calidad", _fmt_num(profile.get("quality_score"))])
|
||||||
|
|
||||||
|
type_breakdown = profile.get("type_breakdown") or {}
|
||||||
|
tb = ", ".join(
|
||||||
|
f"{k}: {v}" for k, v in type_breakdown.items() if v
|
||||||
|
)
|
||||||
|
if tb:
|
||||||
|
rows.append(["Tipos", tb])
|
||||||
|
|
||||||
|
constant_cols = profile.get("constant_cols") or []
|
||||||
|
if constant_cols:
|
||||||
|
rows.append(["Columnas constantes", _truncate(", ".join(constant_cols), 40)])
|
||||||
|
all_null_cols = profile.get("all_null_cols") or []
|
||||||
|
if all_null_cols:
|
||||||
|
rows.append(["Columnas all-null", _truncate(", ".join(all_null_cols), 40)])
|
||||||
|
key_candidates = profile.get("key_candidates") or []
|
||||||
|
if key_candidates:
|
||||||
|
rows.append(["Candidatos a clave", _truncate(", ".join(key_candidates), 40)])
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
rows.append(["(sin métricas de overview)", ""])
|
||||||
|
|
||||||
|
return _text_page(pdf, "Overview", _kv_lines(rows, key_width=20))
|
||||||
|
|
||||||
|
|
||||||
|
def _numeric_pages(pdf, columns: list) -> int:
|
||||||
|
"""Small multiples: a real histogram per numeric column, several per page."""
|
||||||
|
numeric_cols = [
|
||||||
|
c for c in columns
|
||||||
|
if isinstance(c, dict) and c.get("numeric") and c["numeric"].get("histogram")
|
||||||
|
]
|
||||||
|
if not numeric_cols:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
pages = 0
|
||||||
|
for start in range(0, len(numeric_cols), _NUMERIC_PER_PAGE):
|
||||||
|
chunk = numeric_cols[start:start + _NUMERIC_PER_PAGE]
|
||||||
|
fig, axes = plt.subplots(
|
||||||
|
len(chunk), 1, figsize=_A5_PORTRAIT, squeeze=False,
|
||||||
|
)
|
||||||
|
fig.suptitle("Distribuciones numéricas", fontsize=14, fontweight="bold",
|
||||||
|
color=_INK, x=0.08, ha="left", y=0.98)
|
||||||
|
for ax, col in zip(axes[:, 0], chunk):
|
||||||
|
_draw_histogram(ax, col)
|
||||||
|
# Hide unused axes if the chunk is short (keeps spacing even).
|
||||||
|
for ax in axes[len(chunk):, 0]:
|
||||||
|
ax.axis("off")
|
||||||
|
fig.tight_layout(rect=[0, 0, 1, 0.95])
|
||||||
|
pdf.savefig(fig)
|
||||||
|
plt.close(fig)
|
||||||
|
pages += 1
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_histogram(ax, col: dict) -> None:
|
||||||
|
"""Draw one column's real histogram from its {lo, hi, count} bins."""
|
||||||
|
num = col.get("numeric") or {}
|
||||||
|
hist = num.get("histogram") or []
|
||||||
|
lefts, widths, counts = [], [], []
|
||||||
|
for b in hist:
|
||||||
|
if not isinstance(b, dict):
|
||||||
|
continue
|
||||||
|
lo = b.get("lo")
|
||||||
|
hi = b.get("hi")
|
||||||
|
cnt = b.get("count") or 0
|
||||||
|
if lo is None or hi is None:
|
||||||
|
continue
|
||||||
|
w = hi - lo
|
||||||
|
if w <= 0:
|
||||||
|
w = max(abs(lo) * 1e-6, 1e-6)
|
||||||
|
lefts.append(lo)
|
||||||
|
widths.append(w)
|
||||||
|
counts.append(cnt)
|
||||||
|
|
||||||
|
name = col.get("name") or "(col)"
|
||||||
|
if not counts:
|
||||||
|
ax.axis("off")
|
||||||
|
ax.text(0.5, 0.5, f"{name}: sin datos numéricos", ha="center",
|
||||||
|
va="center", fontsize=8, color=_MUTED, transform=ax.transAxes)
|
||||||
|
return
|
||||||
|
|
||||||
|
ax.bar(lefts, counts, width=widths, align="edge", color=_ACCENT,
|
||||||
|
edgecolor="white", linewidth=0.3)
|
||||||
|
# Graphical integrity: count axis starts at 0, never truncated.
|
||||||
|
ax.set_ylim(bottom=0)
|
||||||
|
_despine(ax)
|
||||||
|
ax.set_title(_truncate(name, 28), fontsize=10, loc="left", pad=4)
|
||||||
|
ax.grid(axis="y", color=_MUTED, alpha=0.15, linewidth=0.5)
|
||||||
|
ax.set_axisbelow(True)
|
||||||
|
|
||||||
|
# Median reference line (a single light marker, no chartjunk).
|
||||||
|
median = num.get("median")
|
||||||
|
if isinstance(median, (int, float)) and not isinstance(median, bool):
|
||||||
|
ax.axvline(median, color=_INK, linewidth=0.8, alpha=0.5)
|
||||||
|
|
||||||
|
# One compact annotation line: mean / std / outliers.
|
||||||
|
bits = []
|
||||||
|
if num.get("mean") is not None:
|
||||||
|
bits.append(f"μ={_fmt_num(num.get('mean'))}")
|
||||||
|
if num.get("std") is not None:
|
||||||
|
bits.append(f"σ={_fmt_num(num.get('std'))}")
|
||||||
|
if num.get("outlier_pct") is not None:
|
||||||
|
bits.append(f"outliers={_fmt_num(num.get('outlier_pct'), 1)}%")
|
||||||
|
if bits:
|
||||||
|
ax.text(0.99, 0.92, " ".join(bits), transform=ax.transAxes,
|
||||||
|
ha="right", va="top", fontsize=7, color=_MUTED)
|
||||||
|
|
||||||
|
|
||||||
|
def _categorical_pages(pdf, columns: list) -> int:
|
||||||
|
"""Top-k horizontal bars per categorical column, several per page."""
|
||||||
|
cat_cols = [
|
||||||
|
c for c in columns
|
||||||
|
if isinstance(c, dict) and c.get("categorical")
|
||||||
|
and (c["categorical"].get("top"))
|
||||||
|
]
|
||||||
|
if not cat_cols:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
pages = 0
|
||||||
|
for start in range(0, len(cat_cols), _CATEGORICAL_PER_PAGE):
|
||||||
|
chunk = cat_cols[start:start + _CATEGORICAL_PER_PAGE]
|
||||||
|
fig, axes = plt.subplots(
|
||||||
|
len(chunk), 1, figsize=_A5_PORTRAIT, squeeze=False,
|
||||||
|
)
|
||||||
|
fig.suptitle("Categóricas (top-k)", fontsize=14, fontweight="bold",
|
||||||
|
color=_INK, x=0.08, ha="left", y=0.98)
|
||||||
|
for ax, col in zip(axes[:, 0], chunk):
|
||||||
|
_draw_topk_bars(ax, col)
|
||||||
|
for ax in axes[len(chunk):, 0]:
|
||||||
|
ax.axis("off")
|
||||||
|
fig.tight_layout(rect=[0, 0, 1, 0.95])
|
||||||
|
pdf.savefig(fig)
|
||||||
|
plt.close(fig)
|
||||||
|
pages += 1
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_topk_bars(ax, col: dict) -> None:
|
||||||
|
"""Draw top-k counts for one categorical column as horizontal bars."""
|
||||||
|
cat = col.get("categorical") or {}
|
||||||
|
top = cat.get("top") or []
|
||||||
|
labels, values = [], []
|
||||||
|
for item in top[:10]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
labels.append(_truncate(item.get("value"), 20))
|
||||||
|
values.append(item.get("count") or 0)
|
||||||
|
|
||||||
|
name = col.get("name") or "(col)"
|
||||||
|
if not values:
|
||||||
|
ax.axis("off")
|
||||||
|
ax.text(0.5, 0.5, f"{name}: sin categorías", ha="center", va="center",
|
||||||
|
fontsize=8, color=_MUTED, transform=ax.transAxes)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Largest on top: reverse so barh reads naturally top-to-bottom.
|
||||||
|
labels = labels[::-1]
|
||||||
|
values = values[::-1]
|
||||||
|
y = np.arange(len(values))
|
||||||
|
ax.barh(y, values, color=_ACCENT, edgecolor="white", linewidth=0.3)
|
||||||
|
ax.set_yticks(y)
|
||||||
|
ax.set_yticklabels(labels, fontsize=7)
|
||||||
|
ax.set_xlim(left=0) # bars start at 0 — honest length encoding.
|
||||||
|
_despine(ax)
|
||||||
|
ax.set_title(_truncate(name, 28), fontsize=10, loc="left", pad=4)
|
||||||
|
ax.grid(axis="x", color=_MUTED, alpha=0.15, linewidth=0.5)
|
||||||
|
ax.set_axisbelow(True)
|
||||||
|
if cat.get("entropy") is not None:
|
||||||
|
ax.text(0.99, 1.02, f"entropía={_fmt_num(cat.get('entropy'))}",
|
||||||
|
transform=ax.transAxes, ha="right", va="bottom", fontsize=7,
|
||||||
|
color=_MUTED)
|
||||||
|
|
||||||
|
|
||||||
|
def _quality_page(pdf, columns: list) -> int:
|
||||||
|
"""Worst-quality columns first, with their issues/flags."""
|
||||||
|
scored = [
|
||||||
|
c for c in columns
|
||||||
|
if isinstance(c, dict) and c.get("quality_score") is not None
|
||||||
|
]
|
||||||
|
if not scored:
|
||||||
|
return 0
|
||||||
|
scored = sorted(scored, key=lambda c: c.get("quality_score"))
|
||||||
|
|
||||||
|
lines = [f"{'columna':<20} {'score':>6} problemas", "-" * 52]
|
||||||
|
for col in scored:
|
||||||
|
issues = col.get("issues") or col.get("flags") or []
|
||||||
|
issues_s = ", ".join(issues) if isinstance(issues, list) else str(issues)
|
||||||
|
lines.append(
|
||||||
|
f"{_truncate(col.get('name'), 20):<20} "
|
||||||
|
f"{_fmt_num(col.get('quality_score'), 1):>6} {_truncate(issues_s, 24)}"
|
||||||
|
)
|
||||||
|
return _text_page(pdf, "Calidad", lines,
|
||||||
|
subtitle="ordenado de peor a mejor calidad")
|
||||||
|
|
||||||
|
|
||||||
|
def _correlations_page(pdf, correlations) -> int:
|
||||||
|
"""Heatmap of the association matrix reconstructed from the pairs list."""
|
||||||
|
if not correlations:
|
||||||
|
return 0
|
||||||
|
pairs = correlations
|
||||||
|
if isinstance(correlations, dict):
|
||||||
|
pairs = correlations.get("pairs") or correlations.get("strong") or []
|
||||||
|
if not pairs:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Build the symmetric label set and a value matrix from the pairs.
|
||||||
|
labels = []
|
||||||
|
for p in pairs:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
for key in ("a", "col_a", "b", "col_b"):
|
||||||
|
v = p.get(key)
|
||||||
|
if v is not None and v not in labels:
|
||||||
|
labels.append(v)
|
||||||
|
if len(labels) < 2:
|
||||||
|
return 0
|
||||||
|
idx = {lab: i for i, lab in enumerate(labels)}
|
||||||
|
n = len(labels)
|
||||||
|
mat = np.full((n, n), np.nan)
|
||||||
|
for i in range(n):
|
||||||
|
mat[i, i] = 1.0
|
||||||
|
for p in pairs:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
a = p.get("a") or p.get("col_a")
|
||||||
|
b = p.get("b") or p.get("col_b")
|
||||||
|
val = p.get("value")
|
||||||
|
if val is None:
|
||||||
|
val = p.get("corr")
|
||||||
|
if a in idx and b in idx and val is not None:
|
||||||
|
try:
|
||||||
|
fv = float(val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
mat[idx[a], idx[b]] = fv
|
||||||
|
mat[idx[b], idx[a]] = fv
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=_A5_PORTRAIT)
|
||||||
|
fig.suptitle("Correlaciones / asociación", fontsize=14, fontweight="bold",
|
||||||
|
color=_INK, x=0.08, ha="left", y=0.97)
|
||||||
|
im = ax.imshow(mat, cmap="RdBu_r", vmin=-1, vmax=1, aspect="auto")
|
||||||
|
ax.set_xticks(np.arange(n))
|
||||||
|
ax.set_yticks(np.arange(n))
|
||||||
|
ax.set_xticklabels([_truncate(lab, 12) for lab in labels], rotation=60,
|
||||||
|
ha="right", fontsize=7, color=_INK)
|
||||||
|
ax.set_yticklabels([_truncate(lab, 14) for lab in labels], fontsize=7,
|
||||||
|
color=_INK)
|
||||||
|
ax.tick_params(length=0)
|
||||||
|
for side in ("top", "right", "left", "bottom"):
|
||||||
|
ax.spines[side].set_visible(False)
|
||||||
|
# Annotate cells only when few columns (keeps it legible on a phone).
|
||||||
|
if n <= 8:
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(n):
|
||||||
|
if not np.isnan(mat[i, j]):
|
||||||
|
ax.text(j, i, _fmt_num(mat[i, j], 2), ha="center",
|
||||||
|
va="center", fontsize=6,
|
||||||
|
color=_INK if abs(mat[i, j]) < 0.6 else "white")
|
||||||
|
cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
|
||||||
|
cbar.ax.tick_params(labelsize=7)
|
||||||
|
fig.tight_layout(rect=[0, 0, 1, 0.94])
|
||||||
|
pdf.savefig(fig)
|
||||||
|
plt.close(fig)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_pages(pdf, llm) -> int:
|
||||||
|
"""Render the LLM block (data dictionary / summary) as wrapped text pages."""
|
||||||
|
if not llm:
|
||||||
|
return 0
|
||||||
|
lines = []
|
||||||
|
if isinstance(llm, dict):
|
||||||
|
for key, value in llm.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
lines.append(f"## {key}")
|
||||||
|
lines.extend(_wrap_value(value))
|
||||||
|
lines.append("")
|
||||||
|
else:
|
||||||
|
lines.extend(_wrap_value(llm))
|
||||||
|
if not lines:
|
||||||
|
return 0
|
||||||
|
return _paginate_text(pdf, "Análisis LLM", lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _generic_pages(pdf, profile: dict) -> int:
|
||||||
|
"""Forward-compat: dump unknown top-level sections so they still reach the reader."""
|
||||||
|
extras = {
|
||||||
|
k: v for k, v in profile.items()
|
||||||
|
if k not in _KNOWN_TOP_KEYS and v is not None
|
||||||
|
}
|
||||||
|
if not extras:
|
||||||
|
return 0
|
||||||
|
lines = []
|
||||||
|
for key, value in extras.items():
|
||||||
|
lines.append(f"## {key}")
|
||||||
|
lines.extend(_wrap_value(value))
|
||||||
|
lines.append("")
|
||||||
|
if not lines:
|
||||||
|
return 0
|
||||||
|
return _paginate_text(pdf, "Otras secciones", lines,
|
||||||
|
subtitle="bloques nuevos del profile (forward-compat)")
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_value(value, width: int = 78) -> list:
|
||||||
|
"""Flatten an arbitrary value into wrapped, readable text lines."""
|
||||||
|
out = []
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for k, v in value.items():
|
||||||
|
out.append(f"- {k}: {_truncate(_scalar(v), 64)}")
|
||||||
|
elif isinstance(value, (list, tuple)):
|
||||||
|
for item in value:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
out.append("- " + _truncate(
|
||||||
|
", ".join(f"{k}={_scalar(v)}" for k, v in item.items()), 70))
|
||||||
|
else:
|
||||||
|
out.append(f"- {_truncate(_scalar(item), 72)}")
|
||||||
|
else:
|
||||||
|
for line in textwrap.wrap(str(value), width=width) or [""]:
|
||||||
|
out.append(line)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _scalar(v) -> str:
|
||||||
|
"""Compact one-line representation of a scalar/nested value."""
|
||||||
|
if isinstance(v, float):
|
||||||
|
return _fmt_num(v)
|
||||||
|
if isinstance(v, (dict, list, tuple)):
|
||||||
|
return _truncate(str(v), 60)
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
|
def _paginate_text(pdf, title: str, lines: list, subtitle: str = None,
|
||||||
|
per_page: int = 34) -> int:
|
||||||
|
"""Split a long list of text lines across several text pages."""
|
||||||
|
pages = 0
|
||||||
|
for start in range(0, len(lines), per_page):
|
||||||
|
chunk = lines[start:start + per_page]
|
||||||
|
page_title = title if pages == 0 else f"{title} (cont.)"
|
||||||
|
pages += _text_page(pdf, page_title, chunk,
|
||||||
|
subtitle=subtitle if pages == 0 else None)
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Dedicated builders for forward-compat blocks (models / series / caveats).
|
||||||
|
# Before these existed, ``models``/``series``/``caveats`` fell to the generic
|
||||||
|
# dump and were rendered as truncated ``str(dict)``. Each builder is fully
|
||||||
|
# defensive, reads with ``.get`` and returns the number of pages it produced.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _models_pages(pdf, models) -> int:
|
||||||
|
"""Render the cheap-models block (PCA / KMeans / outliers / normality)."""
|
||||||
|
if not isinstance(models, dict):
|
||||||
|
return 0
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
pca = models.get("pca")
|
||||||
|
if isinstance(pca, dict):
|
||||||
|
lines.append("## PCA")
|
||||||
|
n_used = pca.get("n_rows_used")
|
||||||
|
n_feat = pca.get("n_features")
|
||||||
|
if n_used is not None or n_feat is not None:
|
||||||
|
lines.append(
|
||||||
|
f" {pca.get('n_components')} comp · "
|
||||||
|
f"{_fmt_num(n_used)} filas · {_fmt_num(n_feat)} features"
|
||||||
|
)
|
||||||
|
evr = pca.get("explained_variance_ratio") or []
|
||||||
|
cum = pca.get("cumulative") or []
|
||||||
|
for i, var in enumerate(evr):
|
||||||
|
acc = cum[i] if i < len(cum) else None
|
||||||
|
lines.append(f" PC{i + 1}: var {_fmt_pct(var)} acum {_fmt_pct(acc)}")
|
||||||
|
loadings = pca.get("top_loadings") or []
|
||||||
|
if loadings:
|
||||||
|
lines.append(" cargas principales:")
|
||||||
|
for ld in loadings[:8]:
|
||||||
|
if not isinstance(ld, dict):
|
||||||
|
continue
|
||||||
|
comp = ld.get("component")
|
||||||
|
comp_label = f"PC{comp + 1}" if isinstance(comp, int) else str(comp)
|
||||||
|
lines.append(
|
||||||
|
f" {comp_label} {_truncate(ld.get('feature'), 18)}: "
|
||||||
|
f"{_fmt_num(ld.get('loading'), 3)}"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
km = models.get("kmeans")
|
||||||
|
if isinstance(km, dict):
|
||||||
|
lines.append("## KMeans")
|
||||||
|
head = f" mejor k = {_fmt_num(km.get('best_k'))}"
|
||||||
|
if km.get("silhouette") is not None:
|
||||||
|
head += f" silhouette {_fmt_num(km.get('silhouette'), 3)}"
|
||||||
|
lines.append(head)
|
||||||
|
sizes = km.get("cluster_sizes") or []
|
||||||
|
if sizes:
|
||||||
|
lines.append(" tamaños cluster: " + ", ".join(
|
||||||
|
_fmt_num(s) for s in sizes))
|
||||||
|
for sc in km.get("scores_by_k") or []:
|
||||||
|
if not isinstance(sc, dict):
|
||||||
|
continue
|
||||||
|
lines.append(
|
||||||
|
f" k={sc.get('k')}: silhouette {_fmt_num(sc.get('silhouette'), 3)}"
|
||||||
|
f" inertia {_fmt_num(sc.get('inertia'), 1)}"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
out = models.get("outliers")
|
||||||
|
if isinstance(out, dict):
|
||||||
|
lines.append("## Outliers multivariante (Isolation Forest)")
|
||||||
|
# outlier_pct del modelo ya viene en escala 0-100.
|
||||||
|
line = f" {_fmt_num(out.get('n_outliers'))} outliers"
|
||||||
|
if out.get("outlier_pct") is not None:
|
||||||
|
line += f" ({_fmt_num(out.get('outlier_pct'), 2)}%)"
|
||||||
|
if out.get("threshold") is not None:
|
||||||
|
line += f" umbral {_fmt_num(out.get('threshold'), 3)}"
|
||||||
|
lines.append(line)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
normality = models.get("normality")
|
||||||
|
if isinstance(normality, dict):
|
||||||
|
lines.append("## Normalidad (Jarque-Bera)")
|
||||||
|
for col_name, res in normality.items():
|
||||||
|
if not isinstance(res, dict):
|
||||||
|
continue
|
||||||
|
jb = res.get("jarque_bera") or {}
|
||||||
|
lines.append(
|
||||||
|
f" {_truncate(col_name, 18):<18} normal={res.get('is_normal')}"
|
||||||
|
f" JB p={_fmt_num(jb.get('p'), 4)}"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
note = models.get("note")
|
||||||
|
if note:
|
||||||
|
lines.append(f"nota: {note}")
|
||||||
|
|
||||||
|
if not [ln for ln in lines if ln.strip()]:
|
||||||
|
return 0
|
||||||
|
return _paginate_text(pdf, "Modelos", lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _series_pages(pdf, series) -> int:
|
||||||
|
"""Render the time-series block: one compact summary per series column."""
|
||||||
|
if not isinstance(series, dict) or not series:
|
||||||
|
return 0
|
||||||
|
lines = []
|
||||||
|
for col, s in series.items():
|
||||||
|
if not isinstance(s, dict):
|
||||||
|
continue
|
||||||
|
lines.append(f"## {col}")
|
||||||
|
stat = s.get("stationarity") or {}
|
||||||
|
if stat.get("verdict") is not None:
|
||||||
|
lines.append(f" estacionariedad (ADF+KPSS): {stat.get('verdict')}")
|
||||||
|
acf = s.get("acf_pacf") or {}
|
||||||
|
if acf.get("is_autocorrelated") is not None:
|
||||||
|
lines.append(
|
||||||
|
" autocorrelada (Ljung-Box): "
|
||||||
|
+ ("sí" if acf.get("is_autocorrelated") else "no")
|
||||||
|
)
|
||||||
|
stl = s.get("stl") or {}
|
||||||
|
if stl.get("trend_strength") is not None:
|
||||||
|
lines.append(
|
||||||
|
f" fuerza tendencia (STL): {_fmt_num(stl.get('trend_strength'), 3)}")
|
||||||
|
if stl.get("seasonal_strength") is not None:
|
||||||
|
extra = (f" (periodo {stl.get('period')})"
|
||||||
|
if stl.get("period") is not None else "")
|
||||||
|
lines.append(
|
||||||
|
f" fuerza estacional (STL): "
|
||||||
|
f"{_fmt_num(stl.get('seasonal_strength'), 3)}{extra}")
|
||||||
|
elif stl.get("note"):
|
||||||
|
lines.append(f" STL: {_truncate(stl.get('note'), 60)}")
|
||||||
|
if s.get("levels_suggested"):
|
||||||
|
kind = s.get("levels_kind")
|
||||||
|
if kind == "returns":
|
||||||
|
lines.append(" sugerencia: convertir a retornos (serie financiera)")
|
||||||
|
elif kind == "differences":
|
||||||
|
lines.append(" sugerencia: trabajar sobre diferencias (serie física)")
|
||||||
|
else:
|
||||||
|
lines.append(" sugerencia: retornos o diferencias (serie de niveles)")
|
||||||
|
lines.append("")
|
||||||
|
if not [ln for ln in lines if ln.strip()]:
|
||||||
|
return 0
|
||||||
|
return _paginate_text(pdf, "Series temporales", lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _caveats_pages(pdf, caveats) -> int:
|
||||||
|
"""Render the exploratory caveats block as a wrapped, readable list."""
|
||||||
|
cav_list = []
|
||||||
|
if isinstance(caveats, dict):
|
||||||
|
cav_list = caveats.get("caveats") or []
|
||||||
|
elif isinstance(caveats, list):
|
||||||
|
cav_list = caveats
|
||||||
|
lines = []
|
||||||
|
for cav in cav_list:
|
||||||
|
if not isinstance(cav, dict):
|
||||||
|
continue
|
||||||
|
topic = cav.get("topic") or cav.get("id") or ""
|
||||||
|
msg = cav.get("message") or ""
|
||||||
|
lines.append(f"## {topic}")
|
||||||
|
lines.extend(textwrap.wrap(str(msg), width=78) or [""])
|
||||||
|
lines.append("")
|
||||||
|
if not [ln for ln in lines if ln.strip()]:
|
||||||
|
return 0
|
||||||
|
return _paginate_text(pdf, "Avisos exploratorios", lines,
|
||||||
|
subtitle="el EDA genera hipótesis, no conclusiones")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# DB-level (relational) page builders — used by render_eda_pdf_relational.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _db_cover_page(pdf, db_profile: dict, title: str) -> int:
|
||||||
|
"""Cover for a DatabaseProfile: name, date, table count, FK count."""
|
||||||
|
fig = plt.figure(figsize=_A5_PORTRAIT)
|
||||||
|
db_path = db_profile.get("db_path") or "(base sin nombre)"
|
||||||
|
heading = title or f"EDA base — {os.path.basename(str(db_path))}"
|
||||||
|
fig.text(0.08, 0.82, heading, fontsize=20, fontweight="bold", color=_INK,
|
||||||
|
wrap=True)
|
||||||
|
|
||||||
|
sub = [f"fuente: {_truncate(db_path, 44)}"]
|
||||||
|
when = db_profile.get("profiled_at") or datetime.now(timezone.utc).strftime(
|
||||||
|
"%Y-%m-%d %H:%M UTC")
|
||||||
|
sub.append(f"generado: {when}")
|
||||||
|
fig.text(0.08, 0.74, "\n".join(sub), fontsize=10, color=_MUTED, va="top")
|
||||||
|
|
||||||
|
n_tables = db_profile.get("n_tables")
|
||||||
|
fig.text(0.08, 0.58, f"{_fmt_num(n_tables)} tablas", fontsize=16,
|
||||||
|
color=_ACCENT, fontweight="bold")
|
||||||
|
n_fk = len(db_profile.get("fk_candidates") or [])
|
||||||
|
fig.text(0.08, 0.51, f"{_fmt_num(n_fk)} relaciones FK candidatas",
|
||||||
|
fontsize=12, color=_INK)
|
||||||
|
|
||||||
|
fig.text(0.08, 0.06, "Tufte · alta densidad de datos · lectura en móvil",
|
||||||
|
fontsize=8, color=_MUTED, style="italic")
|
||||||
|
pdf.savefig(fig)
|
||||||
|
plt.close(fig)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _db_tables_page(pdf, db_profile: dict) -> int:
|
||||||
|
"""One text page summarising every table (rows / cols / quality)."""
|
||||||
|
tables = db_profile.get("tables") or []
|
||||||
|
if not isinstance(tables, list) or not tables:
|
||||||
|
return 0
|
||||||
|
lines = [f"{'tabla':<24}{'filas':>9}{'cols':>6}{'cal':>6}", "-" * 45]
|
||||||
|
for t in tables:
|
||||||
|
if not isinstance(t, dict):
|
||||||
|
continue
|
||||||
|
lines.append(
|
||||||
|
f"{_truncate(t.get('table'), 24):<24}"
|
||||||
|
f"{_fmt_num(t.get('n_rows')):>9}"
|
||||||
|
f"{_fmt_num(t.get('n_cols')):>6}"
|
||||||
|
f"{_fmt_num(t.get('quality_score'), 1):>6}"
|
||||||
|
)
|
||||||
|
return _paginate_text(pdf, "Tablas", lines, subtitle="resumen por tabla")
|
||||||
|
|
||||||
|
|
||||||
|
def _db_fk_page(pdf, db_profile: dict) -> int:
|
||||||
|
"""FK candidates table + the join-graph mermaid text."""
|
||||||
|
fks = db_profile.get("fk_candidates") or []
|
||||||
|
lines = []
|
||||||
|
if isinstance(fks, list) and fks:
|
||||||
|
lines.append(f"{'from':<26}{'to':<26}{'incl':>7}")
|
||||||
|
lines.append("-" * 59)
|
||||||
|
for fk in fks:
|
||||||
|
if not isinstance(fk, dict):
|
||||||
|
continue
|
||||||
|
frm = f"{fk.get('from_table')}.{fk.get('from_col')}"
|
||||||
|
to = f"{fk.get('to_table')}.{fk.get('to_col')}"
|
||||||
|
inc = fk.get("inclusion")
|
||||||
|
inc_s = (_fmt_num(inc, 3) if isinstance(inc, (int, float))
|
||||||
|
and not isinstance(inc, bool) else str(inc))
|
||||||
|
lines.append(
|
||||||
|
f"{_truncate(frm, 25):<26}{_truncate(to, 25):<26}{inc_s:>7}")
|
||||||
|
else:
|
||||||
|
lines.append("(sin relaciones FK candidatas detectadas)")
|
||||||
|
|
||||||
|
mermaid = (db_profile.get("join_graph") or {}).get("mermaid")
|
||||||
|
if mermaid:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## join graph (mermaid)")
|
||||||
|
for raw in str(mermaid).splitlines():
|
||||||
|
lines.append(_truncate(raw, 72))
|
||||||
|
return _paginate_text(pdf, "Relaciones inter-tabla", lines,
|
||||||
|
subtitle="FK candidatas + join graph")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Public entry point
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def render_eda_pdf(profile: dict, out_path: str, title: str = None) -> dict:
|
||||||
|
"""Render a TableProfile dict into a portable, mobile-readable multi-page PDF.
|
||||||
|
|
||||||
|
The report is laid out for reading on a phone: A5 portrait pages, single
|
||||||
|
column, large type, Tufte-style high data-ink charts (real histograms as
|
||||||
|
small multiples, top-k bars, an association heatmap). Every profile key is
|
||||||
|
read defensively and only present sections are rendered; unknown top-level
|
||||||
|
blocks are dumped on a forward-compat page rather than dropped.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: TableProfile dict from the `eda` capability group (the dict
|
||||||
|
returned by ``profile_table`` under ``profile``). May have many keys
|
||||||
|
absent or None; a None/empty profile still yields a 1-page PDF.
|
||||||
|
out_path: filesystem path where the PDF is written. Parent directories
|
||||||
|
are created if missing.
|
||||||
|
title: optional report title for the cover. Defaults to
|
||||||
|
``"EDA — <table>"``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict (never raises): {"pdf_path": str, "n_pages": int, "note": str}.
|
||||||
|
On a fatal write error, ``pdf_path`` is None and ``note`` explains why.
|
||||||
|
"""
|
||||||
|
if profile is None:
|
||||||
|
profile = {}
|
||||||
|
if not isinstance(profile, dict):
|
||||||
|
return {"pdf_path": None, "n_pages": 0,
|
||||||
|
"note": f"profile no es dict: {type(profile).__name__}"}
|
||||||
|
|
||||||
|
columns = profile.get("columns") or []
|
||||||
|
if not isinstance(columns, list):
|
||||||
|
columns = []
|
||||||
|
|
||||||
|
notes = []
|
||||||
|
n_pages = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
parent = os.path.dirname(os.path.abspath(out_path))
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
except OSError as e:
|
||||||
|
return {"pdf_path": None, "n_pages": 0,
|
||||||
|
"note": f"no se pudo crear el directorio destino: {e}"}
|
||||||
|
|
||||||
|
# Tufte-ish defaults shared with the relational renderer (module-level _RC).
|
||||||
|
rc = _RC
|
||||||
|
|
||||||
|
# Each section is isolated: a failure in one never aborts the whole PDF.
|
||||||
|
builders = [
|
||||||
|
("cover", lambda p: _cover_page(p, profile, title)),
|
||||||
|
("overview", lambda p: _overview_page(p, profile)),
|
||||||
|
("numeric", lambda p: _numeric_pages(p, columns)),
|
||||||
|
("categorical", lambda p: _categorical_pages(p, columns)),
|
||||||
|
("quality", lambda p: _quality_page(p, columns)),
|
||||||
|
("correlations", lambda p: _correlations_page(p, profile.get("correlations"))),
|
||||||
|
("models", lambda p: _models_pages(p, profile.get("models"))),
|
||||||
|
("series", lambda p: _series_pages(p, profile.get("series"))),
|
||||||
|
("llm", lambda p: _llm_pages(p, profile.get("llm"))),
|
||||||
|
("caveats", lambda p: _caveats_pages(p, profile.get("caveats"))),
|
||||||
|
("generic", lambda p: _generic_pages(p, profile)),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
with plt.rc_context(rc):
|
||||||
|
with PdfPages(out_path) as pdf:
|
||||||
|
for name, build in builders:
|
||||||
|
try:
|
||||||
|
n_pages += build(pdf) or 0
|
||||||
|
except Exception as e: # noqa: BLE001 — one bad section never aborts.
|
||||||
|
notes.append(f"sección '{name}' omitida: {e}")
|
||||||
|
# Guarantee at least one page so the PDF is always valid.
|
||||||
|
if n_pages == 0:
|
||||||
|
n_pages += _text_page(
|
||||||
|
pdf, title or "EDA", ["(perfil vacío — sin secciones)"]
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"pdf_path": None, "n_pages": 0,
|
||||||
|
"note": f"fallo al escribir el PDF: {e}"}
|
||||||
|
|
||||||
|
note = f"{n_pages} páginas"
|
||||||
|
if notes:
|
||||||
|
note += " · " + "; ".join(notes)
|
||||||
|
return {"pdf_path": out_path, "n_pages": n_pages, "note": note}
|
||||||
|
|
||||||
|
|
||||||
|
def render_eda_pdf_relational(db_profile: dict, out_path: str,
|
||||||
|
title: str = None) -> dict:
|
||||||
|
"""Render a DatabaseProfile dict into a portable, mobile-readable PDF.
|
||||||
|
|
||||||
|
DB-level sibling of :func:`render_eda_pdf`: instead of a single table it
|
||||||
|
summarises a whole database (the dict ``profile_database`` returns under
|
||||||
|
``db_profile``). Pages are A5 portrait, single column, large type — built to
|
||||||
|
be read on a phone. Three pages: a cover (table + FK counts), a per-table
|
||||||
|
summary (rows / cols / quality) and the inter-table relations (FK candidates
|
||||||
|
plus the join-graph mermaid text). Every key is read defensively and any
|
||||||
|
section that fails is noted, never aborting the whole render.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_profile: DatabaseProfile dict from ``profile_database`` (the value
|
||||||
|
under ``db_profile``). May have keys absent or None; a None/empty
|
||||||
|
profile still yields a 1-page PDF.
|
||||||
|
out_path: filesystem path where the PDF is written. Parent directories
|
||||||
|
are created if missing.
|
||||||
|
title: optional cover title. Defaults to ``"EDA base — <db filename>"``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict (never raises): {"pdf_path": str, "n_pages": int, "note": str}.
|
||||||
|
On a fatal write error, ``pdf_path`` is None and ``note`` explains why.
|
||||||
|
"""
|
||||||
|
if db_profile is None:
|
||||||
|
db_profile = {}
|
||||||
|
if not isinstance(db_profile, dict):
|
||||||
|
return {"pdf_path": None, "n_pages": 0,
|
||||||
|
"note": f"db_profile no es dict: {type(db_profile).__name__}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
parent = os.path.dirname(os.path.abspath(out_path))
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
except OSError as e:
|
||||||
|
return {"pdf_path": None, "n_pages": 0,
|
||||||
|
"note": f"no se pudo crear el directorio destino: {e}"}
|
||||||
|
|
||||||
|
notes = []
|
||||||
|
n_pages = 0
|
||||||
|
|
||||||
|
builders = [
|
||||||
|
("cover", lambda p: _db_cover_page(p, db_profile, title)),
|
||||||
|
("tables", lambda p: _db_tables_page(p, db_profile)),
|
||||||
|
("relations", lambda p: _db_fk_page(p, db_profile)),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
with plt.rc_context(_RC):
|
||||||
|
with PdfPages(out_path) as pdf:
|
||||||
|
for name, build in builders:
|
||||||
|
try:
|
||||||
|
n_pages += build(pdf) or 0
|
||||||
|
except Exception as e: # noqa: BLE001 — one bad section never aborts.
|
||||||
|
notes.append(f"sección '{name}' omitida: {e}")
|
||||||
|
if n_pages == 0:
|
||||||
|
n_pages += _text_page(
|
||||||
|
pdf, title or "EDA base", ["(base vacía — sin secciones)"]
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"pdf_path": None, "n_pages": 0,
|
||||||
|
"note": f"fallo al escribir el PDF: {e}"}
|
||||||
|
|
||||||
|
note = f"{n_pages} páginas"
|
||||||
|
if notes:
|
||||||
|
note += " · " + "; ".join(notes)
|
||||||
|
return {"pdf_path": out_path, "n_pages": n_pages, "note": note}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"""Tests para render_eda_pdf.
|
||||||
|
|
||||||
|
Importa el módulo directo (sys.path), igual que el resto de tests del grupo eda,
|
||||||
|
para no depender del registro en __init__.py (lo añade el orquestador al integrar).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from render_eda_pdf import (
|
||||||
|
render_eda_pdf,
|
||||||
|
render_eda_pdf_relational,
|
||||||
|
_models_pages,
|
||||||
|
_series_pages,
|
||||||
|
_caveats_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _StubPdf:
|
||||||
|
"""Captura pdf.savefig sin escribir nada — para testear builders aislados."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.figs = 0
|
||||||
|
|
||||||
|
def savefig(self, fig):
|
||||||
|
self.figs += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _synthetic_profile() -> dict:
|
||||||
|
"""TableProfile sintético mínimo: 2 numéricas + 1 categórica + overview."""
|
||||||
|
return {
|
||||||
|
"table": "ventas",
|
||||||
|
"source": "data/ventas.csv",
|
||||||
|
"profiled_at": "2026-06-28 10:00 UTC",
|
||||||
|
"n_rows": 1000,
|
||||||
|
"n_cols": 3,
|
||||||
|
"null_cell_pct": 0.02,
|
||||||
|
"duplicate_rows": 5,
|
||||||
|
"duplicate_pct": 0.005,
|
||||||
|
"quality_score": 92.5,
|
||||||
|
"type_breakdown": {"numeric": 2, "categorical": 1},
|
||||||
|
"key_candidates": ["id"],
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "precio",
|
||||||
|
"inferred_type": "numeric",
|
||||||
|
"semantic_type": "currency",
|
||||||
|
"null_pct": 0.0,
|
||||||
|
"distinct_count": 850,
|
||||||
|
"unique_pct": 0.85,
|
||||||
|
"quality_score": 95.0,
|
||||||
|
"flags": [],
|
||||||
|
"numeric": {
|
||||||
|
"min": 1.0, "max": 100.0, "median": 40.0, "mean": 42.5,
|
||||||
|
"std": 12.3, "p25": 30.0, "p75": 55.0, "outlier_pct": 1.2,
|
||||||
|
"distribution_type": "right-skewed",
|
||||||
|
"histogram": [
|
||||||
|
{"lo": 0.0, "hi": 25.0, "count": 100},
|
||||||
|
{"lo": 25.0, "hi": 50.0, "count": 500},
|
||||||
|
{"lo": 50.0, "hi": 75.0, "count": 300},
|
||||||
|
{"lo": 75.0, "hi": 100.0, "count": 50},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "unidades",
|
||||||
|
"inferred_type": "numeric",
|
||||||
|
"semantic_type": "integer",
|
||||||
|
"null_pct": 0.01,
|
||||||
|
"distinct_count": 40,
|
||||||
|
"unique_pct": 0.04,
|
||||||
|
"quality_score": 88.0,
|
||||||
|
"flags": ["has_nulls"],
|
||||||
|
"numeric": {
|
||||||
|
"min": 1.0, "max": 12.0, "median": 4.0, "mean": 4.8,
|
||||||
|
"std": 2.1, "outlier_pct": 0.0,
|
||||||
|
"distribution_type": "normal",
|
||||||
|
"histogram": [
|
||||||
|
{"lo": 1.0, "hi": 4.0, "count": 400},
|
||||||
|
{"lo": 4.0, "hi": 8.0, "count": 450},
|
||||||
|
{"lo": 8.0, "hi": 12.0, "count": 150},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "categoria",
|
||||||
|
"inferred_type": "categorical",
|
||||||
|
"semantic_type": "",
|
||||||
|
"null_pct": 0.0,
|
||||||
|
"distinct_count": 3,
|
||||||
|
"unique_pct": 0.003,
|
||||||
|
"quality_score": 99.0,
|
||||||
|
"flags": [],
|
||||||
|
"categorical": {
|
||||||
|
"entropy": 1.05,
|
||||||
|
"top": [
|
||||||
|
{"value": "neumaticos", "count": 500, "pct": 0.5},
|
||||||
|
{"value": "aceite", "count": 300, "pct": 0.3},
|
||||||
|
{"value": "filtros", "count": 200, "pct": 0.2},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"correlations": {
|
||||||
|
"pairs": [
|
||||||
|
{"a": "precio", "b": "unidades", "value": -0.42, "method": "pearson"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_genera_pdf_multipagina(tmp_path):
|
||||||
|
"""Caso real: profile completo -> PDF existe, pesa >0 y tiene varias páginas."""
|
||||||
|
out = str(tmp_path / "eda_ventas.pdf")
|
||||||
|
res = render_eda_pdf(_synthetic_profile(), out, title="EDA — ventas")
|
||||||
|
|
||||||
|
assert isinstance(res, dict)
|
||||||
|
assert set(res.keys()) == {"pdf_path", "n_pages", "note"}
|
||||||
|
assert res["pdf_path"] == out
|
||||||
|
assert os.path.exists(out)
|
||||||
|
assert os.path.getsize(out) > 0
|
||||||
|
# Cover + overview + numéricas + categóricas + calidad + correlaciones >= 5.
|
||||||
|
assert res["n_pages"] >= 5
|
||||||
|
# Cabecera de archivo PDF.
|
||||||
|
with open(out, "rb") as fh:
|
||||||
|
assert fh.read(4) == b"%PDF"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_profile_vacio_no_revienta(tmp_path):
|
||||||
|
"""Edge: dict vacío -> 1 página garantizada, sin excepción."""
|
||||||
|
out = str(tmp_path / "vacio.pdf")
|
||||||
|
res = render_eda_pdf({}, out)
|
||||||
|
assert os.path.exists(out)
|
||||||
|
assert os.path.getsize(out) > 0
|
||||||
|
assert res["n_pages"] >= 1
|
||||||
|
assert res["pdf_path"] == out
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_profile_none_no_revienta(tmp_path):
|
||||||
|
"""Edge: None -> tratado como vacío, 1 página, sin excepción."""
|
||||||
|
out = str(tmp_path / "none.pdf")
|
||||||
|
res = render_eda_pdf(None, out)
|
||||||
|
assert os.path.exists(out)
|
||||||
|
assert res["n_pages"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_solo_numericas(tmp_path):
|
||||||
|
"""Edge: profile sólo con columnas numéricas (sin categóricas ni corr)."""
|
||||||
|
prof = {
|
||||||
|
"table": "t",
|
||||||
|
"n_rows": 10,
|
||||||
|
"n_cols": 1,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "x",
|
||||||
|
"inferred_type": "numeric",
|
||||||
|
"quality_score": 80.0,
|
||||||
|
"numeric": {
|
||||||
|
"median": 2.0, "mean": 2.0,
|
||||||
|
"histogram": [{"lo": 0.0, "hi": 4.0, "count": 10}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
out = str(tmp_path / "num.pdf")
|
||||||
|
res = render_eda_pdf(prof, out)
|
||||||
|
assert os.path.exists(out)
|
||||||
|
assert res["n_pages"] >= 2 # cover + numéricas al menos.
|
||||||
|
|
||||||
|
|
||||||
|
def test_forward_compat_seccion_desconocida(tmp_path):
|
||||||
|
"""Error/forward-compat: un bloque nuevo del profile se vuelca, no rompe."""
|
||||||
|
prof = {
|
||||||
|
"table": "t",
|
||||||
|
"n_rows": 5,
|
||||||
|
"columns": [],
|
||||||
|
# Bloques que este renderer no conoce (otros agentes los añaden):
|
||||||
|
"models": {"kmeans": {"k": 3, "silhouette": 0.55}},
|
||||||
|
"caveats": ["muestra pequeña", "fechas como texto"],
|
||||||
|
}
|
||||||
|
out = str(tmp_path / "fwd.pdf")
|
||||||
|
res = render_eda_pdf(prof, out)
|
||||||
|
assert os.path.exists(out)
|
||||||
|
assert res["n_pages"] >= 1
|
||||||
|
# No se perdió ninguna sección por error.
|
||||||
|
assert "omitida" not in res["note"]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# H4: builders dedicados para models / series / caveats (antes caían al volcado
|
||||||
|
# genérico como str(dict) truncado). Se testean aislados con un stub de pdf.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _sample_models() -> dict:
|
||||||
|
return {
|
||||||
|
"n_numeric_cols": 3,
|
||||||
|
"pca": {
|
||||||
|
"n_components": 2, "n_rows_used": 1000, "n_features": 3,
|
||||||
|
"explained_variance_ratio": [0.62, 0.21],
|
||||||
|
"cumulative": [0.62, 0.83],
|
||||||
|
"top_loadings": [
|
||||||
|
{"component": 0, "feature": "precio", "loading": 0.71},
|
||||||
|
{"component": 1, "feature": "unidades", "loading": -0.55},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"kmeans": {
|
||||||
|
"best_k": 3, "silhouette": 0.48, "cluster_sizes": [500, 300, 200],
|
||||||
|
"scores_by_k": [{"k": 3, "silhouette": 0.48, "inertia": 900.0}],
|
||||||
|
},
|
||||||
|
"outliers": {"n_outliers": 35, "outlier_pct": 3.5, "threshold": -0.51},
|
||||||
|
"normality": {"precio": {"jarque_bera": {"p": 0.0001}, "is_normal": False}},
|
||||||
|
"note": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_series() -> dict:
|
||||||
|
return {
|
||||||
|
"precio": {
|
||||||
|
"stationarity": {"verdict": "non_stationary"},
|
||||||
|
"acf_pacf": {"is_autocorrelated": True},
|
||||||
|
"stl": {"trend_strength": 0.95, "seasonal_strength": 0.10, "period": 7},
|
||||||
|
"levels_suggested": True, "levels_kind": "returns",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_caveats() -> dict:
|
||||||
|
return {
|
||||||
|
"n": 1,
|
||||||
|
"caveats": [
|
||||||
|
{"id": "exploratory_nature", "topic": "naturaleza exploratoria",
|
||||||
|
"message": "El EDA genera hipótesis, no conclusiones."},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_builder_produces_pages():
|
||||||
|
pdf = _StubPdf()
|
||||||
|
assert _models_pages(pdf, _sample_models()) >= 1
|
||||||
|
assert pdf.figs >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_builder_produces_pages():
|
||||||
|
pdf = _StubPdf()
|
||||||
|
assert _series_pages(pdf, _sample_series()) >= 1
|
||||||
|
assert pdf.figs >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_caveats_builder_produces_pages():
|
||||||
|
pdf = _StubPdf()
|
||||||
|
assert _caveats_pages(pdf, _sample_caveats()) >= 1
|
||||||
|
assert pdf.figs >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_builders_tolerate_none_and_empty():
|
||||||
|
pdf = _StubPdf()
|
||||||
|
# None / vacío -> 0 páginas, sin excepción.
|
||||||
|
assert _models_pages(pdf, None) == 0
|
||||||
|
assert _series_pages(pdf, {}) == 0
|
||||||
|
assert _caveats_pages(pdf, None) == 0
|
||||||
|
assert pdf.figs == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_series_caveats_no_caen_al_generico(tmp_path):
|
||||||
|
# Con builder dedicado, models/series/caveats NO se vuelcan en "Otras
|
||||||
|
# secciones" (genérico). El profile completo se renderiza sin error.
|
||||||
|
prof = _synthetic_profile()
|
||||||
|
prof["models"] = _sample_models()
|
||||||
|
prof["series"] = _sample_series()
|
||||||
|
prof["caveats"] = _sample_caveats()
|
||||||
|
out = str(tmp_path / "full.pdf")
|
||||||
|
res = render_eda_pdf(prof, out)
|
||||||
|
assert os.path.exists(out)
|
||||||
|
assert os.path.getsize(out) > 0
|
||||||
|
assert "omitida" not in res["note"]
|
||||||
|
# Cover+overview+num+cat+calidad+corr + models + series + caveats.
|
||||||
|
assert res["n_pages"] >= 8
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# H9: render_eda_pdf_relational — PDF DB-level (resumen de tablas + join graph).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _synthetic_db_profile() -> dict:
|
||||||
|
return {
|
||||||
|
"db_path": "data/shop.duckdb",
|
||||||
|
"profiled_at": "2026-06-29 01:00 UTC",
|
||||||
|
"n_tables": 2,
|
||||||
|
"tables": [
|
||||||
|
{"table": "customers", "n_rows": 4, "n_cols": 3, "quality_score": 98.0,
|
||||||
|
"key_candidates": ["id"]},
|
||||||
|
{"table": "orders", "n_rows": 6, "n_cols": 3, "quality_score": 95.0,
|
||||||
|
"key_candidates": ["order_id"]},
|
||||||
|
],
|
||||||
|
"fk_candidates": [
|
||||||
|
{"from_table": "orders", "from_col": "customer_id",
|
||||||
|
"to_table": "customers", "to_col": "id",
|
||||||
|
"inclusion": 1.0, "cardinality": "N:1"},
|
||||||
|
],
|
||||||
|
"join_graph": {"mermaid": "graph LR\n orders --> customers"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_relational_golden_genera_pdf(tmp_path):
|
||||||
|
out = str(tmp_path / "eda_db.pdf")
|
||||||
|
res = render_eda_pdf_relational(_synthetic_db_profile(), out, title="EDA base")
|
||||||
|
assert isinstance(res, dict)
|
||||||
|
assert set(res.keys()) == {"pdf_path", "n_pages", "note"}
|
||||||
|
assert res["pdf_path"] == out
|
||||||
|
assert os.path.exists(out)
|
||||||
|
assert os.path.getsize(out) > 0
|
||||||
|
# cover + tablas + relaciones >= 3.
|
||||||
|
assert res["n_pages"] >= 3
|
||||||
|
with open(out, "rb") as fh:
|
||||||
|
assert fh.read(4) == b"%PDF"
|
||||||
|
|
||||||
|
|
||||||
|
def test_relational_edge_vacio_no_revienta(tmp_path):
|
||||||
|
out = str(tmp_path / "db_vacio.pdf")
|
||||||
|
res = render_eda_pdf_relational({}, out)
|
||||||
|
assert os.path.exists(out)
|
||||||
|
assert res["n_pages"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_relational_edge_none_no_revienta(tmp_path):
|
||||||
|
out = str(tmp_path / "db_none.pdf")
|
||||||
|
res = render_eda_pdf_relational(None, out)
|
||||||
|
assert os.path.exists(out)
|
||||||
|
assert res["n_pages"] >= 1
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
name: stl_decompose
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def stl_decompose(values: list, period: int = None, robust: bool = True) -> dict"
|
||||||
|
description: "Descomposicion STL (Seasonal-Trend using Loess, statsmodels) de una serie temporal en tendencia, estacional y resto. Si period es None lo infiere por autocorrelacion. Devuelve las 3 componentes (o estadisticos si son largas), mas la fuerza de tendencia y de estacionalidad de Hyndman (1 - Var(resto)/Var(resto+componente)). Descarta None/NaN; serie corta (<2*period) -> nota."
|
||||||
|
tags: [statistics, timeseries, decomposition, stl, seasonality, trend, eda, forecasting, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [math, numpy, statsmodels]
|
||||||
|
params:
|
||||||
|
- name: values
|
||||||
|
desc: "serie temporal de valores numericos en orden cronologico. None/NaN/infinitos/no-numericos se descartan antes de descomponer."
|
||||||
|
- name: period
|
||||||
|
desc: "periodo estacional (observaciones por ciclo, p.ej. 12 para mensual con estacionalidad anual). Si None se infiere por autocorrelacion; si no hay periodo claro devuelve nota."
|
||||||
|
- name: robust
|
||||||
|
desc: "si True (default) usa el ajuste robusto de STL, que reduce el efecto de outliers sobre tendencia y estacionalidad."
|
||||||
|
output: "dict con 'period' usado, 'period_inferred' (bool), 'trend'/'seasonal'/'resid' (cada uno min/max/mean/std + values si la serie es corta, si no None), 'trend_strength' y 'seasonal_strength' (medidas de Hyndman en [0,1]). Serie insuficiente o sin periodo inferible: dict con 'note' y strengths en None. Nunca lanza excepcion."
|
||||||
|
tested: true
|
||||||
|
tests: ["test_serie_con_tendencia_y_estacionalidad", "test_fuerza_estacional_alta_con_estacionalidad_fuerte", "test_infiere_periodo_si_none", "test_serie_corta_devuelve_nota", "test_muestra_insuficiente_devuelve_nota", "test_descarta_none_y_nan", "test_serie_larga_resume_sin_values"]
|
||||||
|
test_file_path: "python/functions/datascience/stl_decompose_test.py"
|
||||||
|
file_path: "python/functions/datascience/stl_decompose.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import stl_decompose
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Serie mensual = tendencia lineal + ciclo estacional anual (periodo 12) + ruido
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
n = 120
|
||||||
|
serie = [0.3 * i + 10 * np.sin(2 * np.pi * i / 12) + rng.normal(0, 1) for i in range(n)]
|
||||||
|
|
||||||
|
res = stl_decompose(serie, period=12)
|
||||||
|
res["trend_strength"] # -> ~0.99 (tendencia clara)
|
||||||
|
res["seasonal_strength"] # -> ~0.98 (estacionalidad clara)
|
||||||
|
res["seasonal"]["values"][:3] # primeras 3 muestras de la componente estacional
|
||||||
|
|
||||||
|
# Sin pasar periodo: lo infiere por autocorrelacion
|
||||||
|
stl_decompose(serie)["period_inferred"] # -> True
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando quieres separar una serie temporal en sus partes para entenderla o
|
||||||
|
prepararla para modelar: cuanta de su variacion es tendencia de fondo, cuanta es
|
||||||
|
ciclo estacional repetitivo y cuanta es ruido. Util en EDA para decidir si merece
|
||||||
|
la pena desestacionalizar antes de comparar periodos, para detectar un cambio de
|
||||||
|
tendencia, o para extraer features (las fuerzas de tendencia/estacionalidad de
|
||||||
|
Hyndman resumen la serie en dos numeros comparables entre series).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Es pura pero importa `statsmodels.tsa.seasonal.STL` y `numpy` (en `python/.venv`).
|
||||||
|
- STL exige al menos **dos ciclos completos**: con `n < 2*period` devuelve una
|
||||||
|
nota en vez de descomponer. Para datos mensuales con estacionalidad anual
|
||||||
|
(period=12) necesitas >= 24 meses.
|
||||||
|
- La inferencia automatica de `period` busca el pico de autocorrelacion; es
|
||||||
|
heuristica. Si conoces el periodo real (12 mensual, 7 diario-semanal, 24
|
||||||
|
horario-diario), pasalo explicito: es mas fiable.
|
||||||
|
- Las componentes largas (> 200 puntos) se resumen en estadisticos y `values`
|
||||||
|
queda en `None` para no inflar el payload; las cortas vienen completas.
|
||||||
|
- Las fuerzas estan en `[0,1]` por construccion (se recortan a 0 si la varianza
|
||||||
|
del resto supera la de resto+componente, lo que indica componente inexistente).
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Descomposicion STL de una serie temporal en tendencia/estacional/resto (grupo eda).
|
||||||
|
|
||||||
|
Funcion pura y determinista que aplica STL (Seasonal-Trend decomposition using
|
||||||
|
Loess, Cleveland et al. 1990) via statsmodels y reporta las tres componentes mas
|
||||||
|
las medidas de fuerza de tendencia y de estacionalidad de Hyndman ("Forecasting:
|
||||||
|
Principles and Practice", seccion de feature extraction). Util en EDA para
|
||||||
|
entender que parte de la variacion de una serie es tendencia, ciclo estacional o
|
||||||
|
ruido antes de modelar o desestacionalizar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from statsmodels.tsa.seasonal import STL
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(values: list) -> list[float]:
|
||||||
|
"""Conserva solo valores numericos finitos, descartando None/NaN/no-numericos."""
|
||||||
|
out: list[float] = []
|
||||||
|
for v in values:
|
||||||
|
if v is None or isinstance(v, bool):
|
||||||
|
continue
|
||||||
|
if not isinstance(v, (int, float)):
|
||||||
|
continue
|
||||||
|
x = float(v)
|
||||||
|
if math.isnan(x) or math.isinf(x):
|
||||||
|
continue
|
||||||
|
out.append(x)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_period(arr: np.ndarray, max_period: int) -> int | None:
|
||||||
|
"""Infiere el periodo estacional dominante via autocorrelacion.
|
||||||
|
|
||||||
|
Busca el retardo (entre 2 y ``max_period``) que maximiza la autocorrelacion
|
||||||
|
de la serie. Devuelve None si no encuentra un pico claro (autocorrelacion
|
||||||
|
maxima por debajo de un umbral pequeno).
|
||||||
|
"""
|
||||||
|
n = len(arr)
|
||||||
|
if n < 6:
|
||||||
|
return None
|
||||||
|
x = arr - arr.mean()
|
||||||
|
denom = float(np.dot(x, x))
|
||||||
|
if denom == 0.0:
|
||||||
|
return None
|
||||||
|
best_lag = None
|
||||||
|
best_corr = 0.0
|
||||||
|
upper = min(max_period, n // 2)
|
||||||
|
for lag in range(2, upper + 1):
|
||||||
|
corr = float(np.dot(x[:-lag], x[lag:]) / denom)
|
||||||
|
if corr > best_corr:
|
||||||
|
best_corr = corr
|
||||||
|
best_lag = lag
|
||||||
|
if best_lag is None or best_corr < 0.2:
|
||||||
|
return None
|
||||||
|
return best_lag
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize(component: list[float], max_inline: int = 200) -> dict:
|
||||||
|
"""Resume una componente: la incluye entera si es corta, si no estadisticos."""
|
||||||
|
arr = np.asarray(component, dtype=float)
|
||||||
|
summary = {
|
||||||
|
"min": float(arr.min()),
|
||||||
|
"max": float(arr.max()),
|
||||||
|
"mean": float(arr.mean()),
|
||||||
|
"std": float(arr.std(ddof=0)),
|
||||||
|
}
|
||||||
|
if len(component) <= max_inline:
|
||||||
|
summary["values"] = [float(v) for v in component]
|
||||||
|
else:
|
||||||
|
summary["values"] = None
|
||||||
|
summary["note"] = f"serie larga ({len(component)} puntos): solo estadisticos"
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def stl_decompose(values: list, period: int = None, robust: bool = True) -> dict:
|
||||||
|
"""Descompone una serie temporal en tendencia, estacional y resto via STL.
|
||||||
|
|
||||||
|
Aplica STL (Seasonal-Trend decomposition using Loess) sobre ``values`` y
|
||||||
|
devuelve las tres componentes (resumidas si la serie es larga) junto a la
|
||||||
|
fuerza de tendencia y la fuerza estacional de Hyndman::
|
||||||
|
|
||||||
|
F_trend = max(0, 1 - Var(resto) / Var(resto + tendencia))
|
||||||
|
F_seasonal = max(0, 1 - Var(resto) / Var(resto + estacional))
|
||||||
|
|
||||||
|
Ambas en ``[0, 1]``: cercano a 1 indica una componente fuerte y bien
|
||||||
|
definida; cercano a 0 indica que esa componente apenas existe.
|
||||||
|
|
||||||
|
Funcion pura y determinista: no hace I/O, no muta los inputs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
values: serie temporal de valores numericos en orden cronologico.
|
||||||
|
None/NaN/infinitos/no-numericos se descartan antes de descomponer.
|
||||||
|
period: periodo estacional (numero de observaciones por ciclo, p.ej. 12
|
||||||
|
para datos mensuales con estacionalidad anual). Si es ``None`` se
|
||||||
|
intenta inferir por autocorrelacion; si no se halla un periodo
|
||||||
|
claro, se devuelve una nota.
|
||||||
|
robust: si ``True`` (default) usa el ajuste robusto de STL, que reduce el
|
||||||
|
efecto de outliers sobre tendencia y estacionalidad.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Con menos de ``2 * period`` puntos (o <8 si no hay periodo) devuelve un
|
||||||
|
dict con ``note`` explicando por que no se pudo descomponer y
|
||||||
|
``trend_strength``/``seasonal_strength`` en ``None``.
|
||||||
|
|
||||||
|
En otro caso un dict con::
|
||||||
|
|
||||||
|
{
|
||||||
|
"n": int,
|
||||||
|
"period": int, # periodo usado (inferido o dado)
|
||||||
|
"period_inferred": bool, # True si se infirio automaticamente
|
||||||
|
"robust": bool,
|
||||||
|
"trend": {min,max,mean,std, values|note},
|
||||||
|
"seasonal": {...},
|
||||||
|
"resid": {...},
|
||||||
|
"trend_strength": float, # F_trend de Hyndman en [0,1]
|
||||||
|
"seasonal_strength": float, # F_seasonal de Hyndman en [0,1]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
clean = _clean(values)
|
||||||
|
n = len(clean)
|
||||||
|
|
||||||
|
if n < 8:
|
||||||
|
return {
|
||||||
|
"n": n,
|
||||||
|
"note": "datos insuficientes",
|
||||||
|
"trend_strength": None,
|
||||||
|
"seasonal_strength": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
arr = np.asarray(clean, dtype=float)
|
||||||
|
|
||||||
|
inferred = False
|
||||||
|
if period is None:
|
||||||
|
period = _infer_period(arr, max_period=max(2, n // 2))
|
||||||
|
inferred = True
|
||||||
|
if period is None:
|
||||||
|
return {
|
||||||
|
"n": n,
|
||||||
|
"note": "no se pudo inferir un periodo estacional; pasa period explicito",
|
||||||
|
"trend_strength": None,
|
||||||
|
"seasonal_strength": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
period = int(period)
|
||||||
|
if period < 2:
|
||||||
|
return {
|
||||||
|
"n": n,
|
||||||
|
"note": "period debe ser >= 2",
|
||||||
|
"trend_strength": None,
|
||||||
|
"seasonal_strength": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# STL exige al menos dos ciclos completos.
|
||||||
|
if n < 2 * period:
|
||||||
|
return {
|
||||||
|
"n": n,
|
||||||
|
"period": period,
|
||||||
|
"note": f"serie corta: STL necesita >= 2*period ({2 * period}) puntos",
|
||||||
|
"trend_strength": None,
|
||||||
|
"seasonal_strength": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = STL(arr, period=period, robust=robust).fit()
|
||||||
|
trend = np.asarray(result.trend, dtype=float)
|
||||||
|
seasonal = np.asarray(result.seasonal, dtype=float)
|
||||||
|
resid = np.asarray(result.resid, dtype=float)
|
||||||
|
|
||||||
|
# Fuerza de tendencia y estacional (Hyndman). Var con ddof=0.
|
||||||
|
var_resid = float(np.var(resid, ddof=0))
|
||||||
|
var_resid_trend = float(np.var(resid + trend, ddof=0))
|
||||||
|
var_resid_seasonal = float(np.var(resid + seasonal, ddof=0))
|
||||||
|
|
||||||
|
trend_strength = (
|
||||||
|
max(0.0, 1.0 - var_resid / var_resid_trend) if var_resid_trend > 0 else 0.0
|
||||||
|
)
|
||||||
|
seasonal_strength = (
|
||||||
|
max(0.0, 1.0 - var_resid / var_resid_seasonal)
|
||||||
|
if var_resid_seasonal > 0
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"n": n,
|
||||||
|
"period": period,
|
||||||
|
"period_inferred": bool(inferred),
|
||||||
|
"robust": bool(robust),
|
||||||
|
"trend": _summarize(trend.tolist()),
|
||||||
|
"seasonal": _summarize(seasonal.tolist()),
|
||||||
|
"resid": _summarize(resid.tolist()),
|
||||||
|
"trend_strength": float(trend_strength),
|
||||||
|
"seasonal_strength": float(seasonal_strength),
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Tests para stl_decompose."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from stl_decompose import stl_decompose
|
||||||
|
|
||||||
|
|
||||||
|
def _serie_estacional(n: int, period: int, trend: float, amp: float, seed: int) -> list:
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
return [
|
||||||
|
trend * i + amp * np.sin(2 * np.pi * i / period) + rng.normal(0, 1)
|
||||||
|
for i in range(n)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_serie_con_tendencia_y_estacionalidad():
|
||||||
|
serie = _serie_estacional(n=120, period=12, trend=0.3, amp=10.0, seed=0)
|
||||||
|
res = stl_decompose(serie, period=12)
|
||||||
|
assert res["period"] == 12
|
||||||
|
assert res["trend_strength"] > 0.5
|
||||||
|
assert res["seasonal_strength"] > 0.5
|
||||||
|
assert len(res["trend"]["values"]) == 120
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuerza_estacional_alta_con_estacionalidad_fuerte():
|
||||||
|
# Amplitud estacional grande, ruido pequeno => seasonal_strength cercano a 1.
|
||||||
|
serie = _serie_estacional(n=120, period=12, trend=0.05, amp=20.0, seed=1)
|
||||||
|
res = stl_decompose(serie, period=12)
|
||||||
|
assert res["seasonal_strength"] > 0.9
|
||||||
|
|
||||||
|
|
||||||
|
def test_infiere_periodo_si_none():
|
||||||
|
serie = _serie_estacional(n=120, period=12, trend=0.1, amp=10.0, seed=2)
|
||||||
|
res = stl_decompose(serie) # period=None
|
||||||
|
assert res.get("period_inferred") is True
|
||||||
|
assert res["period"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_serie_corta_devuelve_nota():
|
||||||
|
# period=12 pero solo 20 puntos (< 2*period=24): nota, no descompone.
|
||||||
|
serie = _serie_estacional(n=20, period=12, trend=0.1, amp=5.0, seed=3)
|
||||||
|
res = stl_decompose(serie, period=12)
|
||||||
|
assert "note" in res
|
||||||
|
assert res["trend_strength"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_muestra_insuficiente_devuelve_nota():
|
||||||
|
res = stl_decompose([1, 2, 3, 4, 5])
|
||||||
|
assert res["n"] == 5
|
||||||
|
assert res["note"] == "datos insuficientes"
|
||||||
|
assert res["seasonal_strength"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_descarta_none_y_nan():
|
||||||
|
serie = _serie_estacional(n=120, period=12, trend=0.2, amp=8.0, seed=4)
|
||||||
|
sucio = []
|
||||||
|
for i, v in enumerate(serie):
|
||||||
|
sucio.append(v)
|
||||||
|
if i % 30 == 0:
|
||||||
|
sucio.append(None)
|
||||||
|
sucio.append(float("nan"))
|
||||||
|
res = stl_decompose(sucio, period=12)
|
||||||
|
assert res["n"] == 120
|
||||||
|
|
||||||
|
|
||||||
|
def test_serie_larga_resume_sin_values():
|
||||||
|
# >200 puntos: las componentes vienen resumidas sin 'values'.
|
||||||
|
serie = _serie_estacional(n=300, period=12, trend=0.1, amp=10.0, seed=5)
|
||||||
|
res = stl_decompose(serie, period=12)
|
||||||
|
assert res["trend"]["values"] is None
|
||||||
|
assert "mean" in res["trend"]
|
||||||
|
assert "note" in res["trend"]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: suggest_reexpression
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def suggest_reexpression(stats: dict) -> dict"
|
||||||
|
description: "Sugiere la re-expresion de la escalera de potencias de Tukey (none/log/log1p/sqrt/square/cube/box-cox/yeo-johnson) que mas simetriza una columna numerica, a partir de su skew y su dominio (ceros/negativos). Pura: razona por reglas, NO ejecuta la transformacion. Devuelve recomendacion + razon legible + alternativas ordenadas."
|
||||||
|
tags: [statistics, eda, reexpression, transform, skew, tukey, ladder-of-powers, box-cox, yeo-johnson, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: stats
|
||||||
|
desc: "dict con los estadisticos de una columna numerica (sub-bloque `numeric` de un ColumnProfile del grupo eda, o el ColumnProfile completo). Usa `skew` (obligatorio), y `min`/`zero_pct`/`negative_pct` cuando esten para determinar el dominio. Si recibe un ColumnProfile entero, baja a su clave `numeric`."
|
||||||
|
output: "dict con `recommended` (nombre de la transformacion o None si falta skew), `ladder_power` (exponente conceptual de la escalera de Tukey: 1.0 raw, 0.5 sqrt, 0.0 log, None para data-driven), `reason` (explicacion legible), `alternatives` (lista ordenada de {transform, ladder_power, reason}), `skew` (el usado) y `note` (vacio en caso normal; mensaje si la entrada es incompleta o el dominio es desconocido). Nunca lanza excepcion."
|
||||||
|
tested: true
|
||||||
|
tests: ["test_aproximadamente_simetrica_recomienda_none", "test_positiva_fuerte_todo_positivo_recomienda_log", "test_positiva_moderada_todo_positivo_recomienda_sqrt", "test_positiva_con_ceros_fuerte_recomienda_log1p", "test_positiva_con_negativos_recomienda_yeo_johnson", "test_negativa_fuerte_todo_positivo_recomienda_cube", "test_negativa_moderada_todo_positivo_recomienda_square", "test_dominio_desconocido_recomienda_yeo_johnson_con_nota", "test_acepta_columnprofile_completo_con_numeric_anidado", "test_skew_ausente_devuelve_nota", "test_stats_vacio_devuelve_nota", "test_no_dict_no_lanza", "test_skew_no_numerico_devuelve_nota"]
|
||||||
|
test_file_path: "python/functions/datascience/suggest_reexpression_test.py"
|
||||||
|
file_path: "python/functions/datascience/suggest_reexpression.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import suggest_reexpression
|
||||||
|
|
||||||
|
# Columna estrictamente positiva con cola derecha larga -> log.
|
||||||
|
stats = {"skew": 2.3, "min": 1.0, "zero_pct": 0.0, "negative_pct": 0.0}
|
||||||
|
out = suggest_reexpression(stats)
|
||||||
|
out["recommended"] # -> "log"
|
||||||
|
out["ladder_power"] # -> 0.0 (escalon p=0 de la escalera de Tukey)
|
||||||
|
out["reason"] # -> "skew = 2.3 (cola derecha..., fuerte) y todos los valores > 0: log comprime..."
|
||||||
|
[a["transform"] for a in out["alternatives"]] # -> ["box-cox", "sqrt"]
|
||||||
|
|
||||||
|
# Con valores negativos, log/Box-Cox no valen -> Yeo-Johnson.
|
||||||
|
suggest_reexpression({"skew": 1.8, "min": -4.0, "negative_pct": 20.0})["recommended"] # -> "yeo-johnson"
|
||||||
|
|
||||||
|
# Funciona directo sobre el sub-bloque `numeric` de describe_numeric:
|
||||||
|
# col["numeric"] = {"skew": ..., "min": ..., "zero_pct": ..., "negative_pct": ...}
|
||||||
|
suggest_reexpression(col["numeric"])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando un EDA ya detecto que una columna numerica esta sesgada (|skew| alto en el
|
||||||
|
bloque `numeric` de `describe_numeric` / `detect_distribution_type`) y quieres el
|
||||||
|
siguiente paso de Tukey: que transformacion la simetriza. Cierra el gap entre
|
||||||
|
"detecto skew" y "sugiere la re-expresion". Util antes de modelar (muchos modelos
|
||||||
|
asumen ~normalidad o varianza estable) y para enriquecer un reporte EDA con una
|
||||||
|
recomendacion accionable por columna. NO la uses si solo quieres el valor del skew
|
||||||
|
(eso ya lo da `describe_numeric`).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Es **pura**: NO ejecuta la transformacion, solo decide cual sugerir. Aplicarla es
|
||||||
|
trabajo del caller (numpy/scipy/sklearn) si decide seguir la recomendacion.
|
||||||
|
- Necesita `skew`. Sin el devuelve `recommended=None` + `note` (no lanza).
|
||||||
|
- El dominio (ceros/negativos) se infiere de `min`, `zero_pct` y `negative_pct`. Si
|
||||||
|
ninguno esta presente, el dominio es desconocido y sugiere `yeo-johnson` (opcion
|
||||||
|
segura para cualquier rango) con una nota; pasale al menos `min` para una decision
|
||||||
|
mas fina (log vs sqrt vs Box-Cox).
|
||||||
|
- `zero_pct`/`negative_pct` se interpretan como ">0 = hay ceros/negativos"; la escala
|
||||||
|
(fraccion 0-1 o porcentaje 0-100) es indiferente para la decision.
|
||||||
|
- Umbrales: |skew|<0.5 -> `none`; 0.5-1.0 -> moderada; >=1.0 -> fuerte. Son la
|
||||||
|
convencion habitual, no una verdad absoluta — un caller puede recomputar con el
|
||||||
|
`skew` que se devuelve.
|
||||||
|
- `log`/`Box-Cox` exigen datos estrictamente positivos; con ceros usa `log1p`; con
|
||||||
|
negativos o ceros, `Yeo-Johnson`. La funcion ya aplica estas reglas por ti.
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"""Sugiere la re-expresión (escalera de potencias de Tukey) que más simetriza una columna.
|
||||||
|
|
||||||
|
Funcion pura y determinista: no hace I/O, no ejecuta la transformación, no muta el
|
||||||
|
input. Solo razona por reglas sobre un bloque de estadísticos de una columna numérica
|
||||||
|
(el sub-bloque ``numeric`` de un ColumnProfile del grupo ``eda``: ``describe_numeric``)
|
||||||
|
y devuelve la transformación de la "escalera de potencias" de Tukey que se espera que
|
||||||
|
reduzca mejor la asimetría, junto a su razón legible y alternativas ordenadas.
|
||||||
|
|
||||||
|
Trasfondo (Tukey, *EDA* 1977, cap. 3-4 "re-expression"): la escalera de potencias
|
||||||
|
ordena las transformaciones por su exponente ``p``::
|
||||||
|
|
||||||
|
... x^3 x^2 x sqrt(x) log(x) -1/sqrt(x) -1/x ...
|
||||||
|
p=3 p=2 p=1 p=0.5 p=0 p=-0.5 p=-1
|
||||||
|
|
||||||
|
Bajar por la escalera (``p`` menor) comprime la cola derecha → corrige asimetría
|
||||||
|
POSITIVA. Subir por la escalera (``p`` mayor) corrige asimetría NEGATIVA. El log
|
||||||
|
(``p=0``) es el escalón más usado para colas derechas largas, pero exige datos
|
||||||
|
estrictamente positivos. Con ceros se usa ``log1p`` (= ``log(1+x)``); con negativos
|
||||||
|
o ceros, la generalización moderna es ``Yeo-Johnson`` (y ``Box-Cox`` para datos
|
||||||
|
estrictamente positivos), que estiman el exponente óptimo a partir de los datos.
|
||||||
|
|
||||||
|
Esta función NO ejecuta la transformación: decide cuál sugerir. Es el caller quien la
|
||||||
|
aplica (p.ej. con ``numpy``/``scipy``/``sklearn``) si decide seguir la recomendación.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Umbrales sobre |skew| (convención habitual en EDA):
|
||||||
|
# |skew| < 0.5 -> aproximadamente simétrica, no hace falta re-expresar.
|
||||||
|
# 0.5 <= |skew| < 1.0 -> asimetría moderada.
|
||||||
|
# |skew| >= 1.0 -> asimetría fuerte (cola larga).
|
||||||
|
_SYMMETRIC_THRESHOLD = 0.5
|
||||||
|
_STRONG_THRESHOLD = 1.0
|
||||||
|
|
||||||
|
# Exponente conceptual de la escalera de Tukey por transformación (didáctico).
|
||||||
|
_LADDER_POWER = {
|
||||||
|
"cube": 3.0,
|
||||||
|
"square": 2.0,
|
||||||
|
"none": 1.0,
|
||||||
|
"sqrt": 0.5,
|
||||||
|
"log": 0.0,
|
||||||
|
"log1p": 0.0,
|
||||||
|
"reciprocal": -1.0,
|
||||||
|
"box-cox": None, # data-driven (lambda estimado)
|
||||||
|
"yeo-johnson": None, # data-driven (lambda estimado)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(v):
|
||||||
|
"""Parsea a float; None si es None/bool/no parseable (NaN incluido)."""
|
||||||
|
if v is None or isinstance(v, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
f = float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if f != f: # NaN
|
||||||
|
return None
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def _alt(name: str, reason: str) -> dict:
|
||||||
|
"""Construye una entrada de alternativa con su exponente de la escalera."""
|
||||||
|
return {"transform": name, "ladder_power": _LADDER_POWER.get(name), "reason": reason}
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_reexpression(stats: dict) -> dict:
|
||||||
|
"""Sugiere la transformación de la escalera de potencias de Tukey que más simetriza.
|
||||||
|
|
||||||
|
Razona por reglas (no ejecuta la transformación) a partir de un bloque de
|
||||||
|
estadísticos de una columna numérica. Acepta tanto el sub-bloque ``numeric`` de
|
||||||
|
un ColumnProfile (claves ``skew``, ``min``, ``kurtosis``, ``zero_pct``,
|
||||||
|
``negative_pct``...) como el ColumnProfile completo (en cuyo caso usa su clave
|
||||||
|
``numeric``). La decisión combina la magnitud y el signo de ``skew`` con el
|
||||||
|
dominio de los datos (si hay ceros y/o negativos), porque ``log``/``Box-Cox``
|
||||||
|
solo admiten valores estrictamente positivos.
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
- ``|skew| < 0.5`` -> ``none`` (ya es ~simétrica).
|
||||||
|
- ``skew`` positivo (cola derecha):
|
||||||
|
- hay negativos -> ``yeo-johnson``.
|
||||||
|
- hay ceros (sin negativos) -> ``log1p`` (fuerte) / ``sqrt`` (moderado).
|
||||||
|
- estrictamente positivos -> ``log`` (fuerte) / ``sqrt`` (moderado).
|
||||||
|
- ``skew`` negativo (cola izquierda):
|
||||||
|
- hay negativos o ceros -> ``yeo-johnson``.
|
||||||
|
- estrictamente positivos -> ``cube`` (fuerte) / ``square`` (moderado).
|
||||||
|
- dominio desconocido (sin ``min``/``zero_pct``/``negative_pct``) y
|
||||||
|
``skew`` apreciable -> ``yeo-johnson`` (opción segura que admite cualquier
|
||||||
|
dominio) más una nota.
|
||||||
|
|
||||||
|
Es pura, determinista y no lanza excepciones: entradas vacías o sin ``skew``
|
||||||
|
devuelven ``recommended = None`` y una ``note`` explicativa.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stats: dict con los estadísticos de la columna. Espera al menos ``skew``.
|
||||||
|
Usa además ``min``, ``zero_pct`` y ``negative_pct`` (cuando estén) para
|
||||||
|
determinar el dominio. Si recibe un ColumnProfile completo, lee su
|
||||||
|
sub-bloque ``numeric``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con:
|
||||||
|
- ``recommended``: nombre de la transformación sugerida (``"none"``,
|
||||||
|
``"log"``, ``"log1p"``, ``"sqrt"``, ``"square"``, ``"cube"``,
|
||||||
|
``"reciprocal"``, ``"box-cox"``, ``"yeo-johnson"``) o ``None`` si no
|
||||||
|
se puede decidir (falta ``skew``).
|
||||||
|
- ``ladder_power``: exponente conceptual de la escalera de Tukey de la
|
||||||
|
transformación recomendada (``1.0`` raw, ``0.5`` sqrt, ``0.0`` log,
|
||||||
|
``None`` para las data-driven), o ``None`` si no hay recomendación.
|
||||||
|
- ``reason``: explicación legible de por qué se sugiere.
|
||||||
|
- ``alternatives``: lista ordenada de otras transformaciones razonables,
|
||||||
|
cada una ``{"transform", "ladder_power", "reason"}``.
|
||||||
|
- ``skew``: el skew usado en la decisión (float) o ``None``.
|
||||||
|
- ``note``: cadena vacía en el caso normal; mensaje cuando la entrada es
|
||||||
|
incompleta (sin ``skew``, dominio desconocido, etc.).
|
||||||
|
"""
|
||||||
|
if not isinstance(stats, dict) or not stats:
|
||||||
|
return {
|
||||||
|
"recommended": None,
|
||||||
|
"ladder_power": None,
|
||||||
|
"reason": "",
|
||||||
|
"alternatives": [],
|
||||||
|
"skew": None,
|
||||||
|
"note": "stats vacío o no es un dict: nada que sugerir",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Aceptar un ColumnProfile completo: bajar a su sub-bloque numeric.
|
||||||
|
if "skew" not in stats and isinstance(stats.get("numeric"), dict):
|
||||||
|
stats = stats["numeric"]
|
||||||
|
|
||||||
|
skew = _to_float(stats.get("skew"))
|
||||||
|
if skew is None:
|
||||||
|
return {
|
||||||
|
"recommended": None,
|
||||||
|
"ladder_power": None,
|
||||||
|
"reason": "",
|
||||||
|
"alternatives": [],
|
||||||
|
"skew": None,
|
||||||
|
"note": "skew ausente o no numérico: no se puede sugerir re-expresión",
|
||||||
|
}
|
||||||
|
|
||||||
|
minimum = _to_float(stats.get("min"))
|
||||||
|
zero_pct = _to_float(stats.get("zero_pct"))
|
||||||
|
negative_pct = _to_float(stats.get("negative_pct"))
|
||||||
|
|
||||||
|
# Determinar el dominio de los datos a partir de lo disponible.
|
||||||
|
domain_known = (
|
||||||
|
minimum is not None or zero_pct is not None or negative_pct is not None
|
||||||
|
)
|
||||||
|
has_negative = (negative_pct is not None and negative_pct > 0) or (
|
||||||
|
minimum is not None and minimum < 0
|
||||||
|
)
|
||||||
|
has_zero = (zero_pct is not None and zero_pct > 0) or (
|
||||||
|
minimum is not None and minimum == 0
|
||||||
|
)
|
||||||
|
strictly_positive = domain_known and not has_negative and not has_zero
|
||||||
|
|
||||||
|
abs_skew = abs(skew)
|
||||||
|
strong = abs_skew >= _STRONG_THRESHOLD
|
||||||
|
magnitude = "fuerte" if strong else "moderada"
|
||||||
|
side = "cola derecha (asimetría positiva)" if skew > 0 else "cola izquierda (asimetría negativa)"
|
||||||
|
note = ""
|
||||||
|
|
||||||
|
# 1. Aproximadamente simétrica -> no re-expresar.
|
||||||
|
if abs_skew < _SYMMETRIC_THRESHOLD:
|
||||||
|
return {
|
||||||
|
"recommended": "none",
|
||||||
|
"ladder_power": _LADDER_POWER["none"],
|
||||||
|
"reason": (
|
||||||
|
f"skew = {skew:.3g} (|skew| < {_SYMMETRIC_THRESHOLD}): la columna ya es "
|
||||||
|
"aproximadamente simétrica, no necesita re-expresión"
|
||||||
|
),
|
||||||
|
"alternatives": [],
|
||||||
|
"skew": skew,
|
||||||
|
"note": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
alternatives: list = []
|
||||||
|
|
||||||
|
# 2. Asimetría positiva (cola derecha): bajar por la escalera de Tukey.
|
||||||
|
if skew > 0:
|
||||||
|
if has_negative:
|
||||||
|
recommended = "yeo-johnson"
|
||||||
|
reason = (
|
||||||
|
f"skew = {skew:.3g} ({side}, {magnitude}) y hay valores negativos: "
|
||||||
|
"Yeo-Johnson estima el exponente óptimo y admite negativos y ceros "
|
||||||
|
"(log/Box-Cox no)"
|
||||||
|
)
|
||||||
|
elif has_zero:
|
||||||
|
recommended = "log1p" if strong else "sqrt"
|
||||||
|
reason = (
|
||||||
|
f"skew = {skew:.3g} ({side}, {magnitude}) con ceros presentes: "
|
||||||
|
+ ("log1p = log(1+x) comprime la cola sin romper en x=0"
|
||||||
|
if strong else
|
||||||
|
"sqrt simetriza una cola moderada y admite el cero")
|
||||||
|
)
|
||||||
|
alternatives.append(_alt(
|
||||||
|
"yeo-johnson",
|
||||||
|
"estima el exponente óptimo y admite ceros; alternativa data-driven",
|
||||||
|
))
|
||||||
|
alternatives.append(_alt(
|
||||||
|
"sqrt" if strong else "log1p",
|
||||||
|
"otro escalón cercano de la escalera para ceros",
|
||||||
|
))
|
||||||
|
elif strictly_positive:
|
||||||
|
recommended = "log" if strong else "sqrt"
|
||||||
|
reason = (
|
||||||
|
f"skew = {skew:.3g} ({side}, {magnitude}) y todos los valores > 0: "
|
||||||
|
+ ("log comprime con fuerza la cola derecha larga (escalón p=0)"
|
||||||
|
if strong else
|
||||||
|
"sqrt corrige una cola derecha moderada (escalón p=0.5)")
|
||||||
|
)
|
||||||
|
alternatives.append(_alt(
|
||||||
|
"box-cox",
|
||||||
|
"estima el exponente óptimo sobre datos estrictamente positivos",
|
||||||
|
))
|
||||||
|
alternatives.append(_alt(
|
||||||
|
"sqrt" if strong else "log",
|
||||||
|
"escalón vecino de la escalera de Tukey",
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
recommended = "yeo-johnson"
|
||||||
|
note = "dominio desconocido (sin min/zero_pct/negative_pct): se sugiere la opción segura"
|
||||||
|
reason = (
|
||||||
|
f"skew = {skew:.3g} ({side}, {magnitude}) pero no se conoce el dominio: "
|
||||||
|
"Yeo-Johnson funciona con cualquier rango (positivos, ceros, negativos)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Asimetría negativa (cola izquierda): subir por la escalera de Tukey.
|
||||||
|
else:
|
||||||
|
if has_negative or has_zero:
|
||||||
|
recommended = "yeo-johnson"
|
||||||
|
reason = (
|
||||||
|
f"skew = {skew:.3g} ({side}, {magnitude}) con ceros/negativos: "
|
||||||
|
"Yeo-Johnson sube por la escalera y admite cualquier dominio"
|
||||||
|
)
|
||||||
|
elif strictly_positive:
|
||||||
|
recommended = "cube" if strong else "square"
|
||||||
|
reason = (
|
||||||
|
f"skew = {skew:.3g} ({side}, {magnitude}) y todos los valores > 0: "
|
||||||
|
+ ("x^3 alarga la cola izquierda corta (escalón p=3)"
|
||||||
|
if strong else
|
||||||
|
"x^2 corrige una cola izquierda moderada (escalón p=2)")
|
||||||
|
)
|
||||||
|
alternatives.append(_alt(
|
||||||
|
"box-cox",
|
||||||
|
"estima un exponente > 1 óptimo sobre datos positivos",
|
||||||
|
))
|
||||||
|
alternatives.append(_alt(
|
||||||
|
"square" if strong else "cube",
|
||||||
|
"escalón vecino hacia arriba de la escalera de Tukey",
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
recommended = "yeo-johnson"
|
||||||
|
note = "dominio desconocido (sin min/zero_pct/negative_pct): se sugiere la opción segura"
|
||||||
|
reason = (
|
||||||
|
f"skew = {skew:.3g} ({side}, {magnitude}) pero no se conoce el dominio: "
|
||||||
|
"Yeo-Johnson funciona con cualquier rango"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"recommended": recommended,
|
||||||
|
"ladder_power": _LADDER_POWER.get(recommended),
|
||||||
|
"reason": reason,
|
||||||
|
"alternatives": alternatives,
|
||||||
|
"skew": skew,
|
||||||
|
"note": note,
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Tests para suggest_reexpression."""
|
||||||
|
|
||||||
|
from suggest_reexpression import suggest_reexpression
|
||||||
|
|
||||||
|
|
||||||
|
def test_aproximadamente_simetrica_recomienda_none():
|
||||||
|
# |skew| < 0.5 -> no hace falta re-expresar.
|
||||||
|
out = suggest_reexpression({"skew": 0.1, "min": 5.0, "zero_pct": 0.0, "negative_pct": 0.0})
|
||||||
|
assert out["recommended"] == "none"
|
||||||
|
assert out["ladder_power"] == 1.0
|
||||||
|
assert out["alternatives"] == []
|
||||||
|
assert out["note"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_positiva_fuerte_todo_positivo_recomienda_log():
|
||||||
|
# Cola derecha larga sobre datos estrictamente positivos -> log.
|
||||||
|
out = suggest_reexpression({"skew": 2.3, "min": 1.0, "zero_pct": 0.0, "negative_pct": 0.0})
|
||||||
|
assert out["recommended"] == "log"
|
||||||
|
assert out["ladder_power"] == 0.0
|
||||||
|
transforms = [a["transform"] for a in out["alternatives"]]
|
||||||
|
assert "box-cox" in transforms
|
||||||
|
|
||||||
|
|
||||||
|
def test_positiva_moderada_todo_positivo_recomienda_sqrt():
|
||||||
|
out = suggest_reexpression({"skew": 0.7, "min": 2.0, "zero_pct": 0.0, "negative_pct": 0.0})
|
||||||
|
assert out["recommended"] == "sqrt"
|
||||||
|
assert out["ladder_power"] == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_positiva_con_ceros_fuerte_recomienda_log1p():
|
||||||
|
# log(0) indefinido -> log1p en presencia de ceros.
|
||||||
|
out = suggest_reexpression({"skew": 1.5, "min": 0.0, "zero_pct": 12.0, "negative_pct": 0.0})
|
||||||
|
assert out["recommended"] == "log1p"
|
||||||
|
assert out["ladder_power"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_positiva_con_negativos_recomienda_yeo_johnson():
|
||||||
|
# log/Box-Cox no admiten negativos -> Yeo-Johnson.
|
||||||
|
out = suggest_reexpression({"skew": 1.8, "min": -4.0, "zero_pct": 0.0, "negative_pct": 20.0})
|
||||||
|
assert out["recommended"] == "yeo-johnson"
|
||||||
|
assert out["ladder_power"] is None # data-driven
|
||||||
|
|
||||||
|
|
||||||
|
def test_negativa_fuerte_todo_positivo_recomienda_cube():
|
||||||
|
# Cola izquierda -> subir por la escalera de Tukey.
|
||||||
|
out = suggest_reexpression({"skew": -1.6, "min": 3.0, "zero_pct": 0.0, "negative_pct": 0.0})
|
||||||
|
assert out["recommended"] == "cube"
|
||||||
|
assert out["ladder_power"] == 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_negativa_moderada_todo_positivo_recomienda_square():
|
||||||
|
out = suggest_reexpression({"skew": -0.8, "min": 3.0, "zero_pct": 0.0, "negative_pct": 0.0})
|
||||||
|
assert out["recommended"] == "square"
|
||||||
|
assert out["ladder_power"] == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dominio_desconocido_recomienda_yeo_johnson_con_nota():
|
||||||
|
# Solo skew, sin min/zero_pct/negative_pct -> opción segura + nota.
|
||||||
|
out = suggest_reexpression({"skew": 1.4})
|
||||||
|
assert out["recommended"] == "yeo-johnson"
|
||||||
|
assert "dominio desconocido" in out["note"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_acepta_columnprofile_completo_con_numeric_anidado():
|
||||||
|
# Si llega un ColumnProfile entero, baja a su sub-bloque numeric.
|
||||||
|
profile = {
|
||||||
|
"name": "precio",
|
||||||
|
"inferred_type": "numeric",
|
||||||
|
"numeric": {"skew": 2.0, "min": 1.0, "zero_pct": 0.0, "negative_pct": 0.0},
|
||||||
|
}
|
||||||
|
out = suggest_reexpression(profile)
|
||||||
|
assert out["recommended"] == "log"
|
||||||
|
|
||||||
|
|
||||||
|
def test_skew_ausente_devuelve_nota():
|
||||||
|
out = suggest_reexpression({"min": 1.0, "max": 9.0})
|
||||||
|
assert out["recommended"] is None
|
||||||
|
assert "skew ausente" in out["note"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_vacio_devuelve_nota():
|
||||||
|
out = suggest_reexpression({})
|
||||||
|
assert out["recommended"] is None
|
||||||
|
assert out["alternatives"] == []
|
||||||
|
assert out["note"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_dict_no_lanza():
|
||||||
|
out = suggest_reexpression(None)
|
||||||
|
assert out["recommended"] is None
|
||||||
|
assert out["note"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_skew_no_numerico_devuelve_nota():
|
||||||
|
out = suggest_reexpression({"skew": "mucho"})
|
||||||
|
assert out["recommended"] is None
|
||||||
|
assert out["skew"] is None
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
name: to_returns
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def to_returns(values: list, method: str = 'log') -> dict"
|
||||||
|
description: "Convierte una serie de niveles (precios) a retornos: 'log' (ln(p_t/p_{t-1})) o 'simple' (p_t/p_{t-1}-1). Para correlacionar/modelar series financieras sobre retornos (aprox.) estacionarios en vez de niveles no estacionarios, evitando la regresion espuria (Granger-Newbold, Lopez de Prado). Devuelve la serie de retornos mas stats basicas. Maneja ceros/negativos en log marcando el paso invalido. Descarta None/NaN; <2 puntos validos -> nota."
|
||||||
|
tags: [timeseries, returns, finance, stationarity, log-returns, eda, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [math]
|
||||||
|
params:
|
||||||
|
- name: values
|
||||||
|
desc: "serie de niveles (precios) en orden cronologico. None/NaN/infinitos/no-numericos se descartan antes de calcular."
|
||||||
|
- name: method
|
||||||
|
desc: "'log' (default) para retornos logaritmicos ln(p_t/p_{t-1}), o 'simple' para retornos aritmeticos p_t/p_{t-1}-1."
|
||||||
|
output: "dict con 'returns' (lista, un retorno por par consecutivo; None si el paso es invalido), 'method', 'n_levels', 'n_returns', 'n_skipped', y stats 'mean'/'std'/'min'/'max' de los retornos validos (None si todos invalidos). method invalido o <2 puntos: dict con 'note' y 'returns': []. Nunca lanza excepcion."
|
||||||
|
tested: true
|
||||||
|
tests: ["test_log_returns_valores_conocidos", "test_simple_returns_valores_conocidos", "test_log_marca_no_positivo_como_invalido", "test_simple_admite_negativos", "test_method_invalido_devuelve_nota", "test_un_solo_punto_devuelve_nota", "test_descarta_none_y_nan", "test_stats_de_retornos"]
|
||||||
|
test_file_path: "python/functions/datascience/to_returns_test.py"
|
||||||
|
file_path: "python/functions/datascience/to_returns.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datascience import to_returns
|
||||||
|
|
||||||
|
# Retornos logaritmicos de una serie de precios
|
||||||
|
precios = [100.0, 105.0, 103.0, 108.0]
|
||||||
|
res = to_returns(precios, method="log")
|
||||||
|
res["returns"] # -> [0.0488, -0.0192, 0.0474] (ln(105/100), ln(103/105), ...)
|
||||||
|
res["n_returns"] # -> 3
|
||||||
|
|
||||||
|
# Retornos simples (porcentuales)
|
||||||
|
to_returns(precios, method="simple")["returns"] # -> [0.05, -0.0190, 0.0485]
|
||||||
|
|
||||||
|
# Un precio <= 0 invalida ese paso en log (no peta)
|
||||||
|
to_returns([100.0, 0.0, 50.0], method="log")["n_skipped"] # -> 2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Antes de correlacionar, medir volatilidad o modelar una serie financiera de
|
||||||
|
precios. Los precios son no estacionarios (tienen raiz unitaria): correlacionar
|
||||||
|
dos series de precios da correlaciones altas pero espurias. Los retornos son
|
||||||
|
(aproximadamente) estacionarios, asi que son la unidad correcta. Encadena con
|
||||||
|
`adf_kpss_stationarity` para confirmar que los retornos ya son estacionarios, y
|
||||||
|
luego con `spearman_corr`/`pearson` o un modelo. Usa `log` para modelar (aditivo
|
||||||
|
en el tiempo) y `simple` cuando necesites interpretar el retorno como porcentaje.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Es pura (solo `math`, sin dependencias externas).
|
||||||
|
- `method="log"` exige precios estrictamente positivos: un valor <= 0 invalida
|
||||||
|
ese paso (queda `None` en `returns` y suma a `n_skipped`) en lugar de lanzar
|
||||||
|
`ValueError`. Revisa `n_skipped` si tu serie puede tener ceros/negativos.
|
||||||
|
- La serie de retornos tiene **un elemento menos** que la de niveles (no hay
|
||||||
|
retorno para el primer punto).
|
||||||
|
- Los huecos (None/NaN) se eliminan ANTES de emparejar, asi que el retorno se
|
||||||
|
calcula entre puntos validos consecutivos en el tiempo-indice original, no
|
||||||
|
rellenando el hueco. Si necesitas tratar huecos como saltos reales, limpia tu
|
||||||
|
la serie antes.
|
||||||
|
- `simple` solo invalida el paso cuando el precio previo es exactamente 0
|
||||||
|
(division por cero); admite precios y retornos negativos.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Convierte una serie de niveles (precios) a retornos (grupo eda).
|
||||||
|
|
||||||
|
Funcion pura y determinista que transforma una serie de niveles en una serie de
|
||||||
|
retornos, simples o logaritmicos. Motivada por Lopez de Prado ("Advances in
|
||||||
|
Financial ML") y Hamilton ("Time Series Analysis"): las series de precios son no
|
||||||
|
estacionarias (raiz unitaria), de modo que correlacionarlas o modelarlas sobre
|
||||||
|
sus niveles produce regresion espuria (Granger-Newbold). Los retornos son
|
||||||
|
(aproximadamente) estacionarios y son la unidad correcta para correlacionar,
|
||||||
|
medir volatilidad o ajustar modelos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(values: list) -> list[float]:
|
||||||
|
"""Conserva solo valores numericos finitos, descartando None/NaN/no-numericos.
|
||||||
|
|
||||||
|
A diferencia de otras funciones del grupo, aqui el ORDEN importa (es una
|
||||||
|
serie temporal), pero un hueco intermedio rompe el calculo de retorno
|
||||||
|
consecutivo; por eso se descartan los no-validos y el retorno se calcula
|
||||||
|
sobre los puntos validos restantes en su orden original.
|
||||||
|
"""
|
||||||
|
out: list[float] = []
|
||||||
|
for v in values:
|
||||||
|
if v is None or isinstance(v, bool):
|
||||||
|
continue
|
||||||
|
if not isinstance(v, (int, float)):
|
||||||
|
continue
|
||||||
|
x = float(v)
|
||||||
|
if math.isnan(x) or math.isinf(x):
|
||||||
|
continue
|
||||||
|
out.append(x)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def to_returns(values: list, method: str = "log") -> dict:
|
||||||
|
"""Convierte una serie de niveles (precios) a retornos.
|
||||||
|
|
||||||
|
Calcula el retorno entre observaciones consecutivas de la serie limpia:
|
||||||
|
|
||||||
|
- ``method="log"``: ``r_t = ln(p_t / p_{t-1})`` (retorno logaritmico).
|
||||||
|
Aditivo en el tiempo y simetrico; es el preferido para modelar. Requiere
|
||||||
|
precios estrictamente positivos: si aparece un valor <= 0 ese paso se
|
||||||
|
marca como invalido (``None`` en la serie) y se cuenta en ``n_skipped``.
|
||||||
|
- ``method="simple"``: ``r_t = p_t / p_{t-1} - 1`` (retorno aritmetico).
|
||||||
|
Admite valores negativos; solo se invalida el paso si ``p_{t-1} == 0``
|
||||||
|
(division por cero).
|
||||||
|
|
||||||
|
Funcion pura y determinista: no hace I/O, no muta los inputs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
values: serie de niveles (precios) en orden cronologico. None/NaN/
|
||||||
|
infinitos/no-numericos se descartan antes de calcular.
|
||||||
|
method: ``"log"`` (default) para retornos logaritmicos o ``"simple"``
|
||||||
|
para retornos aritmeticos.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Con menos de 2 puntos validos (no hay ningun par consecutivo) devuelve
|
||||||
|
``{"n": n, "note": "datos insuficientes", "returns": []}``.
|
||||||
|
|
||||||
|
Si ``method`` no es ``"log"`` ni ``"simple"`` devuelve
|
||||||
|
``{"note": "method debe ser 'log' o 'simple'", "returns": []}``.
|
||||||
|
|
||||||
|
En otro caso un dict con::
|
||||||
|
|
||||||
|
{
|
||||||
|
"method": str,
|
||||||
|
"n_levels": int, # niveles validos de entrada
|
||||||
|
"returns": [float|None],# un retorno por par consecutivo (None si invalido)
|
||||||
|
"n_returns": int, # retornos validos (no None)
|
||||||
|
"n_skipped": int, # pasos invalidados (log de no-positivo, div/0)
|
||||||
|
"mean": float, # media de los retornos validos
|
||||||
|
"std": float, # desviacion tipica (ddof=0) de los validos
|
||||||
|
"min": float,
|
||||||
|
"max": float,
|
||||||
|
}
|
||||||
|
|
||||||
|
Si todos los pasos resultan invalidos, ``mean/std/min/max`` son ``None``.
|
||||||
|
"""
|
||||||
|
if method not in ("log", "simple"):
|
||||||
|
return {"note": "method debe ser 'log' o 'simple'", "returns": []}
|
||||||
|
|
||||||
|
clean = _clean(values)
|
||||||
|
n = len(clean)
|
||||||
|
|
||||||
|
if n < 2:
|
||||||
|
return {"n": n, "note": "datos insuficientes", "returns": []}
|
||||||
|
|
||||||
|
returns: list[float | None] = []
|
||||||
|
n_skipped = 0
|
||||||
|
for prev, cur in zip(clean[:-1], clean[1:]):
|
||||||
|
if method == "log":
|
||||||
|
if prev <= 0.0 or cur <= 0.0:
|
||||||
|
returns.append(None)
|
||||||
|
n_skipped += 1
|
||||||
|
continue
|
||||||
|
returns.append(math.log(cur / prev))
|
||||||
|
else: # simple
|
||||||
|
if prev == 0.0:
|
||||||
|
returns.append(None)
|
||||||
|
n_skipped += 1
|
||||||
|
continue
|
||||||
|
returns.append(cur / prev - 1.0)
|
||||||
|
|
||||||
|
valid = [r for r in returns if r is not None]
|
||||||
|
if valid:
|
||||||
|
mean = sum(valid) / len(valid)
|
||||||
|
var = sum((r - mean) ** 2 for r in valid) / len(valid)
|
||||||
|
std = math.sqrt(var)
|
||||||
|
vmin = min(valid)
|
||||||
|
vmax = max(valid)
|
||||||
|
else:
|
||||||
|
mean = std = vmin = vmax = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"method": method,
|
||||||
|
"n_levels": n,
|
||||||
|
"returns": returns,
|
||||||
|
"n_returns": len(valid),
|
||||||
|
"n_skipped": n_skipped,
|
||||||
|
"mean": mean if mean is None else float(mean),
|
||||||
|
"std": std if std is None else float(std),
|
||||||
|
"min": vmin if vmin is None else float(vmin),
|
||||||
|
"max": vmax if vmax is None else float(vmax),
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Tests para to_returns."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from to_returns import to_returns
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_returns_valores_conocidos():
|
||||||
|
precios = [100.0, 105.0, 103.0, 108.0]
|
||||||
|
res = to_returns(precios, method="log")
|
||||||
|
esperado = [
|
||||||
|
math.log(105 / 100),
|
||||||
|
math.log(103 / 105),
|
||||||
|
math.log(108 / 103),
|
||||||
|
]
|
||||||
|
assert res["n_returns"] == 3
|
||||||
|
assert res["n_skipped"] == 0
|
||||||
|
for got, exp in zip(res["returns"], esperado):
|
||||||
|
assert math.isclose(got, exp, rel_tol=1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_simple_returns_valores_conocidos():
|
||||||
|
precios = [100.0, 105.0, 103.0]
|
||||||
|
res = to_returns(precios, method="simple")
|
||||||
|
esperado = [105 / 100 - 1, 103 / 105 - 1]
|
||||||
|
for got, exp in zip(res["returns"], esperado):
|
||||||
|
assert math.isclose(got, exp, rel_tol=1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_marca_no_positivo_como_invalido():
|
||||||
|
# Un 0 invalida los dos pasos que lo tocan (prev=0 y cur=0).
|
||||||
|
res = to_returns([100.0, 0.0, 50.0], method="log")
|
||||||
|
assert res["n_skipped"] == 2
|
||||||
|
assert res["returns"] == [None, None]
|
||||||
|
assert res["mean"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_simple_admite_negativos():
|
||||||
|
# Retornos negativos validos en simple; -10 no invalida (solo prev==0 lo hace).
|
||||||
|
res = to_returns([100.0, 90.0, 81.0], method="simple")
|
||||||
|
assert res["n_skipped"] == 0
|
||||||
|
assert all(r < 0 for r in res["returns"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_method_invalido_devuelve_nota():
|
||||||
|
res = to_returns([1.0, 2.0, 3.0], method="cuadratico")
|
||||||
|
assert res["returns"] == []
|
||||||
|
assert "method" in res["note"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_solo_punto_devuelve_nota():
|
||||||
|
res = to_returns([100.0])
|
||||||
|
assert res["n"] == 1
|
||||||
|
assert res["note"] == "datos insuficientes"
|
||||||
|
assert res["returns"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_descarta_none_y_nan():
|
||||||
|
precios = [100.0, None, 105.0, float("nan"), 110.0]
|
||||||
|
res = to_returns(precios, method="log")
|
||||||
|
# Quedan 3 niveles validos (100, 105, 110) => 2 retornos.
|
||||||
|
assert res["n_levels"] == 3
|
||||||
|
assert res["n_returns"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_de_retornos():
|
||||||
|
precios = [100.0, 110.0, 121.0] # +10% cada paso en simple
|
||||||
|
res = to_returns(precios, method="simple")
|
||||||
|
assert math.isclose(res["mean"], 0.10, rel_tol=1e-9)
|
||||||
|
assert math.isclose(res["std"], 0.0, abs_tol=1e-12)
|
||||||
|
assert math.isclose(res["min"], 0.10, rel_tol=1e-9)
|
||||||
|
assert math.isclose(res["max"], 0.10, rel_tol=1e-9)
|
||||||
@@ -5,8 +5,8 @@ lang: py
|
|||||||
domain: infra
|
domain: infra
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "def duckdb_list_tables(db_path: str) -> dict"
|
signature: "def duckdb_list_tables(db_path: str, base_tables_only: bool = False) -> dict"
|
||||||
description: "Lista las tablas de una base DuckDB abierta en modo solo lectura (duckdb.connect(db_path, read_only=True)), de modo que nunca crea ni modifica la base. La conexion se cierra siempre en try/finally. Consulta information_schema.tables del esquema main y devuelve los nombres ordenados alfabeticamente. Devuelve un dict sin lanzar (estilo del grupo duckdb): {status:'ok', tables} en exito y {status:'error', error} en fallo. Es la introspeccion 'que tablas hay' del grupo duckdb; complementa a duckdb_query_readonly_py_infra (lectura de filas) y a duckdb_table_schema_py_infra (schema de una tabla). Depende del paquete duckdb (1.5.2 en python/.venv)."
|
description: "Lista las tablas de una base DuckDB abierta en modo solo lectura (duckdb.connect(db_path, read_only=True)), de modo que nunca crea ni modifica la base. La conexion se cierra siempre en try/finally. Consulta information_schema.tables del esquema main y devuelve los nombres ordenados alfabeticamente. Con base_tables_only=True filtra table_type='BASE TABLE', excluyendo las VIEWs (util para perfilar/relacionar solo tablas reales). Devuelve un dict sin lanzar (estilo del grupo duckdb): {status:'ok', tables} en exito y {status:'error', error} en fallo. Es la introspeccion 'que tablas hay' del grupo duckdb; complementa a duckdb_query_readonly_py_infra (lectura de filas) y a duckdb_table_schema_py_infra (schema de una tabla). Depende del paquete duckdb (1.5.2 en python/.venv)."
|
||||||
tags: [duckdb, sql, introspection, readonly, tables]
|
tags: [duckdb, sql, introspection, readonly, tables]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
@@ -17,12 +17,16 @@ imports: [duckdb]
|
|||||||
params:
|
params:
|
||||||
- name: db_path
|
- name: db_path
|
||||||
desc: "ruta al archivo DuckDB. Debe existir: el modo read_only NO crea la base. Un path inexistente devuelve {status:'error'}."
|
desc: "ruta al archivo DuckDB. Debe existir: el modo read_only NO crea la base. Un path inexistente devuelve {status:'error'}."
|
||||||
|
- name: base_tables_only
|
||||||
|
desc: "si True (default False) filtra table_type='BASE TABLE', excluyendo las VIEWs del esquema main. Util para perfilar/relacionar solo tablas reales (perfilar una VIEW infla el conteo y multiplica relaciones FK falsas)."
|
||||||
output: "dict. En exito: {status:'ok', tables:[str,...]} con los nombres de tabla del esquema main ordenados alfabeticamente. En error (sin lanzar): {status:'error', error:str}."
|
output: "dict. En exito: {status:'ok', tables:[str,...]} con los nombres de tabla del esquema main ordenados alfabeticamente. En error (sin lanzar): {status:'error', error:str}."
|
||||||
tested: true
|
tested: true
|
||||||
tests:
|
tests:
|
||||||
- "test_lista_tablas_ordenadas"
|
- "test_lista_tablas_ordenadas"
|
||||||
- "test_base_vacia_devuelve_lista_vacia"
|
- "test_base_vacia_devuelve_lista_vacia"
|
||||||
- "test_db_inexistente_devuelve_status_error"
|
- "test_db_inexistente_devuelve_status_error"
|
||||||
|
- "test_base_tables_only_excluye_views"
|
||||||
|
- "test_attach_sqlite_materializado_lista_por_information_schema"
|
||||||
test_file_path: "python/functions/infra/duckdb_list_tables_test.py"
|
test_file_path: "python/functions/infra/duckdb_list_tables_test.py"
|
||||||
file_path: "python/functions/infra/duckdb_list_tables.py"
|
file_path: "python/functions/infra/duckdb_list_tables.py"
|
||||||
---
|
---
|
||||||
@@ -64,7 +68,8 @@ selector de tablas en una UI. Es el primer paso natural antes de
|
|||||||
- DuckDB es single-writer: si otro proceso tiene la base abierta en escritura con
|
- DuckDB es single-writer: si otro proceso tiene la base abierta en escritura con
|
||||||
una version distinta del motor, la apertura read-only puede fallar con error de
|
una version distinta del motor, la apertura read-only puede fallar con error de
|
||||||
lock. El error se devuelve como `{status:'error', ...}`, no se lanza.
|
lock. El error se devuelve como `{status:'error', ...}`, no se lanza.
|
||||||
- Solo lista tablas del esquema `main` (el por defecto). Vistas y tablas de otros
|
- Solo lista objetos del esquema `main` (el por defecto); tablas de otros esquemas
|
||||||
esquemas no aparecen.
|
no aparecen. Por defecto incluye **vistas** (table_type VIEW) además de las tablas
|
||||||
|
base; pasa `base_tables_only=True` para quedarte solo con las `BASE TABLE`.
|
||||||
- Una base recien creada sin tablas devuelve `{status:'ok', tables:[]}` (no es un
|
- Una base recien creada sin tablas devuelve `{status:'ok', tables:[]}` (no es un
|
||||||
error): lista vacia.
|
error): lista vacia.
|
||||||
|
|||||||
@@ -13,12 +13,19 @@ introspeccion de alto nivel "que tablas hay" del grupo duckdb.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def duckdb_list_tables(db_path: str) -> dict:
|
def duckdb_list_tables(db_path: str, base_tables_only: bool = False) -> dict:
|
||||||
"""Lista las tablas de una base DuckDB en modo solo lectura.
|
"""Lista las tablas de una base DuckDB en modo solo lectura.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db_path: ruta al archivo DuckDB. Debe existir: el modo read_only NO crea
|
db_path: ruta al archivo DuckDB. Debe existir: el modo read_only NO crea
|
||||||
la base. Un path inexistente devuelve {status:'error', ...}.
|
la base. Un path inexistente devuelve {status:'error', ...}.
|
||||||
|
base_tables_only: si True (default False) filtra por
|
||||||
|
`table_type = 'BASE TABLE'`, excluyendo las VIEWs (y demas objetos no
|
||||||
|
tabla-base) del esquema `main`. Util para perfilar/relacionar solo las
|
||||||
|
tablas reales: perfilar una VIEW infla el numero de tablas y multiplica
|
||||||
|
las relaciones FK falsas. El default mantiene el comportamiento previo
|
||||||
|
(lista todo lo que aparece en information_schema.tables del esquema
|
||||||
|
main) para no romper consumidores existentes.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict. En exito: {status:'ok', tables:[str,...]} con los nombres de tabla
|
dict. En exito: {status:'ok', tables:[str,...]} con los nombres de tabla
|
||||||
@@ -28,10 +35,14 @@ def duckdb_list_tables(db_path: str) -> dict:
|
|||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = __import__("duckdb").connect(db_path, read_only=True)
|
conn = __import__("duckdb").connect(db_path, read_only=True)
|
||||||
rows = conn.execute(
|
sql = (
|
||||||
"SELECT table_name FROM information_schema.tables "
|
"SELECT table_name FROM information_schema.tables "
|
||||||
"WHERE table_schema = 'main' ORDER BY table_name"
|
"WHERE table_schema = 'main'"
|
||||||
).fetchall()
|
)
|
||||||
|
if base_tables_only:
|
||||||
|
sql += " AND table_type = 'BASE TABLE'"
|
||||||
|
sql += " ORDER BY table_name"
|
||||||
|
rows = conn.execute(sql).fetchall()
|
||||||
tables = [row[0] for row in rows]
|
tables = [row[0] for row in rows]
|
||||||
return {"status": "ok", "tables": tables}
|
return {"status": "ok", "tables": tables}
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
|
|||||||
@@ -38,3 +38,59 @@ def test_db_inexistente_devuelve_status_error(tmp_path):
|
|||||||
res = duckdb_list_tables(str(tmp_path / "noexiste.duckdb"))
|
res = duckdb_list_tables(str(tmp_path / "noexiste.duckdb"))
|
||||||
assert res["status"] == "error"
|
assert res["status"] == "error"
|
||||||
assert "error" in res
|
assert "error" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_tables_only_excluye_views(tmp_path):
|
||||||
|
# Una BASE TABLE + una VIEW: por defecto se listan ambas; con
|
||||||
|
# base_tables_only=True la VIEW se excluye.
|
||||||
|
db = tmp_path / "withviews.duckdb"
|
||||||
|
con = duckdb.connect(str(db))
|
||||||
|
con.execute("CREATE TABLE ventas (id INTEGER, total DOUBLE)")
|
||||||
|
con.execute("CREATE VIEW ventas_resumen AS SELECT id FROM ventas")
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
# Default: incluye la view.
|
||||||
|
res_all = duckdb_list_tables(str(db))
|
||||||
|
assert res_all["status"] == "ok"
|
||||||
|
assert res_all["tables"] == ["ventas", "ventas_resumen"]
|
||||||
|
|
||||||
|
# base_tables_only: solo la tabla base.
|
||||||
|
res_base = duckdb_list_tables(str(db), base_tables_only=True)
|
||||||
|
assert res_base["status"] == "ok"
|
||||||
|
assert res_base["tables"] == ["ventas"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_sqlite_materializado_lista_por_information_schema(tmp_path):
|
||||||
|
# Regresión H14: tras ATTACH de una base SQLite en DuckDB se materializan sus
|
||||||
|
# tablas y se listan vía information_schema (NO sqlite_master, que no existe en
|
||||||
|
# DuckDB). duckdb_list_tables debe verlas como tablas del esquema main.
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
sqlite_path = str(tmp_path / "src.sqlite")
|
||||||
|
sconn = sqlite3.connect(sqlite_path)
|
||||||
|
sconn.execute("CREATE TABLE clientes (id INTEGER PRIMARY KEY, nombre TEXT)")
|
||||||
|
sconn.execute("INSERT INTO clientes VALUES (1,'Ana'),(2,'Luis')")
|
||||||
|
sconn.execute("CREATE VIEW clientes_v AS SELECT id FROM clientes")
|
||||||
|
sconn.commit()
|
||||||
|
sconn.close()
|
||||||
|
|
||||||
|
ddb_path = str(tmp_path / "materialized.duckdb")
|
||||||
|
con = duckdb.connect(ddb_path)
|
||||||
|
con.execute("INSTALL sqlite")
|
||||||
|
con.execute("LOAD sqlite")
|
||||||
|
con.execute(f"ATTACH '{sqlite_path}' AS src (TYPE sqlite)")
|
||||||
|
# Listar tablas base del catálogo attachado por information_schema (no
|
||||||
|
# sqlite_master) y materializarlas como tablas nativas DuckDB.
|
||||||
|
rows = con.execute(
|
||||||
|
"SELECT table_name FROM information_schema.tables "
|
||||||
|
"WHERE table_catalog='src' AND table_type='BASE TABLE' "
|
||||||
|
"AND table_name NOT LIKE 'sqlite_%'"
|
||||||
|
).fetchall()
|
||||||
|
for (name,) in rows:
|
||||||
|
con.execute(f'CREATE TABLE "{name}" AS SELECT * FROM src."{name}"')
|
||||||
|
con.execute("DETACH src")
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
res = duckdb_list_tables(ddb_path)
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
assert "clientes" in res["tables"]
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -49,7 +49,9 @@ params:
|
|||||||
- name: filename_prefix
|
- name: filename_prefix
|
||||||
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
||||||
output: "dict en API format listo para comfyui_submit_workflow: img2img base (parte de input_image) con prompt de variante + ImageScale opcional (normaliza a size) + LoRA opcional. Nodos: CheckpointLoaderSimple '4', LoadImage '10', VAEEncode '11', CLIPTextEncode '6'/'7', KSampler '3' (denoise medio), VAEDecode '8', SaveImage '9', + ImageScale y LoraLoader si aplican."
|
output: "dict en API format listo para comfyui_submit_workflow: img2img base (parte de input_image) con prompt de variante + ImageScale opcional (normaliza a size) + LoRA opcional. Nodos: CheckpointLoaderSimple '4', LoadImage '10', VAEEncode '11', CLIPTextEncode '6'/'7', KSampler '3' (denoise medio), VAEDecode '8', SaveImage '9', + ImageScale y LoraLoader si aplican."
|
||||||
tested: false
|
tested: true
|
||||||
|
tests: ["estructura img2img (LoadImage+VAEEncode, sin EmptyLatentImage)", "input_image/prompt reflejados en LoadImage y CLIPTextEncode positivo", "size por defecto inserta ImageScale a 512; size=None lo omite", "denoise se clampa a [0,1]", "filename_prefix/seed/lora opcional reflejados", "input_image o variant vacios -> ValueError", "determinismo: misma entrada -> mismo dict"]
|
||||||
|
test_file_path: "python/functions/ml/tests/test_comfyui_build_asset_variant_workflow.py"
|
||||||
file_path: python/functions/ml/comfyui_build_asset_variant_workflow.py
|
file_path: python/functions/ml/comfyui_build_asset_variant_workflow.py
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
name: comfyui_build_audio_workflow
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: ml
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def comfyui_build_audio_workflow(ckpt_name: str, prompt: str, *, lyrics: str = \"\", seconds: float = 10.0, seed: int = 0, steps: int = 50, cfg: float = 5.0, sampler_name: str = \"euler\", scheduler: str = \"simple\", shift: float = 5.0, lyrics_strength: float = 1.0, filename_prefix: str = \"audio/comfy_audio\") -> dict"
|
||||||
|
description: "Construye el dict de un workflow ComfyUI texto->audio (ACE-Step) en API format. Cadena con nodos de audio NATIVOS de ComfyUI 0.26.0: CheckpointLoaderSimple(AUDIO_ace_step_v1_3.5b.safetensors -> MODEL, CLIP, VAE) -> TextEncodeAceStepAudio(tags=prompt, lyrics) como positive + ConditioningZeroOut como negative + EmptyAceStepLatentAudio(seconds) -> ModelSamplingSD3(shift) -> KSampler -> VAEDecodeAudio -> SaveAudio(.flac). ACE-Step es abierto (Apache 2.0). Genera musica y SFX por texto; lyrics opcional para voz cantada. Pura, sin red ni I/O. Hermana de audio de comfyui_build_txt2img_workflow."
|
||||||
|
tags: [comfyui, audio, ace-step, sfx, music, ml, workflow]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: ckpt_name
|
||||||
|
desc: "Nombre del checkpoint ACE-Step tal como lo ve el servidor ComfyUI (ej. 'AUDIO_ace_step_v1_3.5b.safetensors', todo-en-uno: DiT + text encoder + VAE de audio). Debe estar entre los que devuelve comfyui_object_info en CheckpointLoaderSimple."
|
||||||
|
- name: prompt
|
||||||
|
desc: "Descripcion del sonido o estilo musical. Va al campo 'tags' de TextEncodeAceStepAudio. Ej. '8-bit coin pickup sound, retro game' o 'lofi hip hop, mellow piano, 90 bpm'."
|
||||||
|
- name: lyrics
|
||||||
|
desc: "Letra cantada para musica con voz. Vacio '' para SFX o musica instrumental. keyword-only."
|
||||||
|
- name: seconds
|
||||||
|
desc: "Duracion del audio en segundos (min 1.0). Controla el tamano del latente via EmptyAceStepLatentAudio. keyword-only."
|
||||||
|
- name: seed
|
||||||
|
desc: "Semilla del KSampler. 0 es determinista; cambiar para variar el resultado. keyword-only."
|
||||||
|
- name: steps
|
||||||
|
desc: "Pasos de sampling del KSampler. 50 recomendado para ACE-Step. keyword-only."
|
||||||
|
- name: cfg
|
||||||
|
desc: "Classifier-free guidance scale. 5.0 recomendado para ACE-Step. keyword-only."
|
||||||
|
- name: sampler_name
|
||||||
|
desc: "Algoritmo del KSampler. Por defecto 'euler'. keyword-only."
|
||||||
|
- name: scheduler
|
||||||
|
desc: "Scheduler del KSampler. Por defecto 'simple'. keyword-only."
|
||||||
|
- name: shift
|
||||||
|
desc: "Shift del ModelSamplingSD3 aplicado al MODEL antes del sampling. 5.0 recomendado para ACE-Step; mejora la coherencia temporal. keyword-only."
|
||||||
|
- name: lyrics_strength
|
||||||
|
desc: "Fuerza del condicionamiento de la letra (1.0 por defecto; sin efecto practico cuando lyrics esta vacio). keyword-only."
|
||||||
|
- name: filename_prefix
|
||||||
|
desc: "Prefijo del .flac generado por SaveAudio en output/ del servidor. keyword-only."
|
||||||
|
output: "dict en API format listo para comfyui_submit_workflow. node_ids string; cada valor con class_type + inputs. Devuelve 8 nodos: CheckpointLoaderSimple, TextEncodeAceStepAudio, ConditioningZeroOut, EmptyAceStepLatentAudio, ModelSamplingSD3, KSampler, VAEDecodeAudio y SaveAudio. El denoise del KSampler se fija a 1.0 (genera desde el latente vacio, no es audio2audio)."
|
||||||
|
tested: true
|
||||||
|
tests: ["estructura: 8 nodos ACE-Step presentes + ckpt en CheckpointLoaderSimple + prompt en TextEncodeAceStepAudio.tags", "cableado: clip [4,1], positive [6,0], negative via ConditioningZeroOut [10,0], model post ModelSamplingSD3 [11,0], vae [4,2], denoise 1.0", "params reflejados (lyrics/seconds/seed/steps/cfg/sampler_name/scheduler/shift/lyrics_strength/filename_prefix)", "edge: seconds y seed variables se reflejan en EmptyAceStepLatentAudio y KSampler", "determinismo: misma entrada -> mismo dict (builder puro)"]
|
||||||
|
test_file_path: "python/functions/ml/tests/test_comfyui_build_audio_workflow.py"
|
||||||
|
file_path: "python/functions/ml/comfyui_build_audio_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_audio_workflow import comfyui_build_audio_workflow
|
||||||
|
|
||||||
|
wf = comfyui_build_audio_workflow(
|
||||||
|
ckpt_name="AUDIO_ace_step_v1_3.5b.safetensors",
|
||||||
|
prompt="8-bit coin pickup sound, retro game, short",
|
||||||
|
seconds=4.0, seed=42,
|
||||||
|
)
|
||||||
|
# wf["6"]["class_type"] == "TextEncodeAceStepAudio"
|
||||||
|
# wf["9"]["class_type"] == "SaveAudio"
|
||||||
|
# -> comfyui_submit_workflow(wf, server="127.0.0.1:8188") para encolar (necesita GPU)
|
||||||
|
# -> comfyui_wait_result(prompt_id) -> comfyui_fetch_output_audio(prompt_id, dest=...)
|
||||||
|
```
|
||||||
|
|
||||||
|
O lanzable directo con: `./fn run comfyui_build_audio_workflow` (imprime el JSON del workflow ACE-Step de ejemplo).
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Antes de enviar una generacion de audio (musica o SFX por texto) a ComfyUI:
|
||||||
|
construye aqui el dict del workflow ACE-Step y pasalo a `comfyui_submit_workflow`.
|
||||||
|
Usala cuando quieres un sonido o pieza musical descrita en lenguaje natural
|
||||||
|
(`prompt`), opcionalmente con letra cantada (`lyrics`). Baja el resultado con
|
||||||
|
`comfyui_fetch_output_audio`. Verifica el workflow contra el servidor con
|
||||||
|
`comfyui_validate_workflow` antes de encolar.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Es API format (nodos numerados), NO el formato de la UI de ComfyUI. Es lo que
|
||||||
|
acepta POST /prompt.
|
||||||
|
- El checkpoint ACE-Step debe existir y ser visible para el servidor (carpeta de
|
||||||
|
checkpoints o extra_model_paths) o ComfyUI rechaza el workflow con HTTP 400 al
|
||||||
|
enviarlo. Esta funcion es pura y no valida contra el servidor.
|
||||||
|
- Stable Audio Open 1.0 (la otra via nativa, mas ligera) esta GATED en HuggingFace
|
||||||
|
(resolve da HTTP 403 sin aceptar la licencia): por eso el modelo por defecto es
|
||||||
|
ACE-Step, que es abierto (Apache 2.0) y no gated.
|
||||||
|
- VRAM 8GB: `ace_step_v1_3.5b.safetensors` pesa ~7.7GB. Arrancar ComfyUI con
|
||||||
|
`--lowvram` para que streamee bloques a CPU; aun asi va justo. Antes de generar
|
||||||
|
audio, liberar VRAM de SD/Flux con POST /free {"unload_models":true,
|
||||||
|
"free_memory":true}. Si da OOM, bajar `seconds`. El builder es puro: no toca la
|
||||||
|
GPU, solo arma el dict (un OOM ocurre en el submit posterior, no aqui).
|
||||||
|
- ACE-Step es modelo de MUSICA: para SFX cortos funciona pero el resultado tiende
|
||||||
|
a sonar "musical". `seconds` minimo 1.0. Para SFX muy cortos usar 2-4 s.
|
||||||
|
- SaveAudio guarda `.flac` por defecto (clave "audio" en outputs[node]). Para bajar
|
||||||
|
el archivo usa `comfyui_fetch_output_audio` (no `comfyui_fetch_output_video`, que
|
||||||
|
solo busca extensiones de video).
|
||||||
|
- `lyrics` vacio = instrumental/SFX. Con letra, ACE-Step canta; `lyrics_strength`
|
||||||
|
ajusta cuanto se ciñe a ella.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Construye un workflow ComfyUI de texto->audio (ACE-Step) en "API format".
|
||||||
|
|
||||||
|
API format: cada clave es un node_id (string); cada nodo tiene class_type +
|
||||||
|
inputs. Las conexiones entre nodos son listas [node_id, output_index]. Este es
|
||||||
|
el formato que acepta POST /prompt, distinto del formato de la UI (graph con
|
||||||
|
links explicitos).
|
||||||
|
|
||||||
|
El grafo usa los nodos de audio NATIVOS de ComfyUI 0.26.0 para el modelo
|
||||||
|
ACE-Step (abierto, Apache 2.0): CheckpointLoaderSimple ->
|
||||||
|
TextEncodeAceStepAudio (tags + lyrics) -> EmptyAceStepLatentAudio ->
|
||||||
|
ModelSamplingSD3 -> KSampler -> VAEDecodeAudio -> SaveAudio. El negative se
|
||||||
|
construye con ConditioningZeroOut sobre el positive (patron oficial de ACE-Step).
|
||||||
|
|
||||||
|
Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def comfyui_build_audio_workflow(
|
||||||
|
ckpt_name: str,
|
||||||
|
prompt: str,
|
||||||
|
*,
|
||||||
|
lyrics: str = "",
|
||||||
|
seconds: float = 10.0,
|
||||||
|
seed: int = 0,
|
||||||
|
steps: int = 50,
|
||||||
|
cfg: float = 5.0,
|
||||||
|
sampler_name: str = "euler",
|
||||||
|
scheduler: str = "simple",
|
||||||
|
shift: float = 5.0,
|
||||||
|
lyrics_strength: float = 1.0,
|
||||||
|
filename_prefix: str = "audio/comfy_audio",
|
||||||
|
) -> dict:
|
||||||
|
"""Construye el dict del workflow texto->audio para ACE-Step.
|
||||||
|
|
||||||
|
Cadena de nodos: CheckpointLoaderSimple -> TextEncodeAceStepAudio (positivo)
|
||||||
|
+ ConditioningZeroOut (negativo) + EmptyAceStepLatentAudio -> ModelSamplingSD3
|
||||||
|
-> KSampler -> VAEDecodeAudio -> SaveAudio. SaveAudio escribe un .flac en la
|
||||||
|
carpeta output/<filename_prefix> del servidor ComfyUI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ckpt_name: nombre del checkpoint ACE-Step tal como lo ve el servidor
|
||||||
|
(ej. "AUDIO_ace_step_v1_3.5b.safetensors"). Debe estar entre los que
|
||||||
|
devuelve comfyui_object_info en CheckpointLoaderSimple.
|
||||||
|
prompt: descripcion del sonido o estilo musical (va al campo "tags" de
|
||||||
|
TextEncodeAceStepAudio). Ej. "8-bit coin pickup sound, retro game".
|
||||||
|
lyrics: letra cantada para musica con voz. Vacio "" para SFX o musica
|
||||||
|
instrumental.
|
||||||
|
seconds: duracion del audio en segundos (min 1.0). Controla el tamano
|
||||||
|
del latente via EmptyAceStepLatentAudio.
|
||||||
|
seed: semilla del KSampler (cambia para variar el resultado).
|
||||||
|
steps: pasos de sampling del KSampler (50 recomendado para ACE-Step).
|
||||||
|
cfg: classifier-free guidance scale (5.0 recomendado para ACE-Step).
|
||||||
|
sampler_name: nombre del sampler (ej. "euler").
|
||||||
|
scheduler: scheduler del sampler (ej. "simple").
|
||||||
|
shift: shift del ModelSamplingSD3 aplicado al MODEL antes del sampling
|
||||||
|
(5.0 recomendado para ACE-Step). Mejora la coherencia temporal.
|
||||||
|
lyrics_strength: fuerza del condicionamiento de la letra (1.0 por
|
||||||
|
defecto; sin efecto practico cuando lyrics esta vacio).
|
||||||
|
filename_prefix: prefijo del .flac generado por SaveAudio en output/.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict en API format listo para comfyui_submit_workflow. Las claves son
|
||||||
|
node_ids ("3".."11") y cada valor tiene class_type + inputs.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"4": {
|
||||||
|
"class_type": "CheckpointLoaderSimple",
|
||||||
|
"inputs": {"ckpt_name": ckpt_name},
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"class_type": "TextEncodeAceStepAudio",
|
||||||
|
"inputs": {
|
||||||
|
"clip": ["4", 1],
|
||||||
|
"tags": prompt,
|
||||||
|
"lyrics": lyrics,
|
||||||
|
"lyrics_strength": lyrics_strength,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"10": {
|
||||||
|
"class_type": "ConditioningZeroOut",
|
||||||
|
"inputs": {"conditioning": ["6", 0]},
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"class_type": "EmptyAceStepLatentAudio",
|
||||||
|
"inputs": {"seconds": seconds, "batch_size": 1},
|
||||||
|
},
|
||||||
|
"11": {
|
||||||
|
"class_type": "ModelSamplingSD3",
|
||||||
|
"inputs": {"model": ["4", 0], "shift": shift},
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"inputs": {
|
||||||
|
"seed": seed,
|
||||||
|
"steps": steps,
|
||||||
|
"cfg": cfg,
|
||||||
|
"sampler_name": sampler_name,
|
||||||
|
"scheduler": scheduler,
|
||||||
|
"denoise": 1.0,
|
||||||
|
"model": ["11", 0],
|
||||||
|
"positive": ["6", 0],
|
||||||
|
"negative": ["10", 0],
|
||||||
|
"latent_image": ["5", 0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"class_type": "VAEDecodeAudio",
|
||||||
|
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"class_type": "SaveAudio",
|
||||||
|
"inputs": {"filename_prefix": filename_prefix, "audio": ["8", 0]},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
|
||||||
|
wf = comfyui_build_audio_workflow(
|
||||||
|
ckpt_name="AUDIO_ace_step_v1_3.5b.safetensors",
|
||||||
|
prompt="8-bit coin pickup sound, retro game, short",
|
||||||
|
seconds=4.0,
|
||||||
|
seed=42,
|
||||||
|
)
|
||||||
|
print(json.dumps(wf, indent=2))
|
||||||
@@ -44,7 +44,9 @@ params:
|
|||||||
- name: filename_prefix
|
- name: filename_prefix
|
||||||
desc: "Prefijo del archivo de salida del SaveImage. keyword-only."
|
desc: "Prefijo del archivo de salida del SaveImage. keyword-only."
|
||||||
output: "dict en API format listo para comfyui_submit_workflow (claves = node_ids string, valores = class_type + inputs). SV3D: ImageOnlyCheckpointLoader + LoadImage + SV3D_Conditioning + VideoLinearCFGGuidance + KSampler + VAEDecode + SaveImage (los N frames del orbit). Zero123: ImageOnlyCheckpointLoader + LoadImage + StableZero123_Conditioning_Batched + KSampler + VAEDecode + SaveImage (un batch de directions vistas). El frame i (i-esima imagen del SaveImage, azimuth creciente desde la frontal) = direccion i de directional_sprite_view_order(directions). El modulo expone ademas directional_sprite_view_order(directions) -> lista de nombres de direccion alineada por indice con los frames."
|
output: "dict en API format listo para comfyui_submit_workflow (claves = node_ids string, valores = class_type + inputs). SV3D: ImageOnlyCheckpointLoader + LoadImage + SV3D_Conditioning + VideoLinearCFGGuidance + KSampler + VAEDecode + SaveImage (los N frames del orbit). Zero123: ImageOnlyCheckpointLoader + LoadImage + StableZero123_Conditioning_Batched + KSampler + VAEDecode + SaveImage (un batch de directions vistas). El frame i (i-esima imagen del SaveImage, azimuth creciente desde la frontal) = direccion i de directional_sprite_view_order(directions). El modulo expone ademas directional_sprite_view_order(directions) -> lista de nombres de direccion alineada por indice con los frames."
|
||||||
tested: false
|
tested: true
|
||||||
|
tests: ["sv3d: estructura + orbit (video_frames=directions, size nativa 576)", "orbit_frames override", "zero123: StableZero123_Conditioning_Batched, azimuth equiespaciado, size 256", "cfg/ckpt por defecto segun modelo", "elevation/seed reflejados", "directional_sprite_view_order para 4/8/N", "errores: input vacio, model invalido, directions<1", "determinismo"]
|
||||||
|
test_file_path: "python/functions/ml/tests/test_comfyui_build_directional_sprite_workflow.py"
|
||||||
file_path: "python/functions/ml/comfyui_build_directional_sprite_workflow.py"
|
file_path: "python/functions/ml/comfyui_build_directional_sprite_workflow.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ params:
|
|||||||
- name: labels
|
- name: labels
|
||||||
desc: "rotulos opcionales, uno por imagen (mismo orden); reservan una franja bajo cada celda."
|
desc: "rotulos opcionales, uno por imagen (mismo orden); reservan una franja bajo cada celda."
|
||||||
output: "dict con ok (bool), out_path (str, ruta del PNG generado), rows (int, filas), cols (int, columnas), error (str, vacio si OK)."
|
output: "dict con ok (bool), out_path (str, ruta del PNG generado), rows (int, filas), cols (int, columnas), error (str, vacio si OK)."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["grid basico: ok + out_path + cols/rows (ceil(sqrt(N)))", "cols explicito define filas", "cell define dimension del canvas", "labels reservan franja bajo cada celda", "error: lista vacia", "error: ruta inexistente", "determinismo del dict de salida"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_build_grid.py"
|
||||||
file_path: "python/functions/ml/comfyui_build_grid.py"
|
file_path: "python/functions/ml/comfyui_build_grid.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ params:
|
|||||||
- name: filename_prefix
|
- name: filename_prefix
|
||||||
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
||||||
output: "dict en API format listo para comfyui_submit_workflow: inpaint que repinta SOLO la region marcada en blanco por la mascara, conservando el resto del asset, con grow_mask para difuminar la costura, escalado consistente opcional (img+mask) y LoRA de estilo opcional. Nodos modo vae_encode: CheckpointLoaderSimple '4', LoadImage '10', LoadImageMask '12', VAEEncodeForInpaint '11', CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9' (+ ImageScale/ImageToMask si size, + LoraLoader si lora). Modo noise_mask sustituye VAEEncodeForInpaint por VAEEncode + SetLatentNoiseMask (+ GrowMask)."
|
output: "dict en API format listo para comfyui_submit_workflow: inpaint que repinta SOLO la region marcada en blanco por la mascara, conservando el resto del asset, con grow_mask para difuminar la costura, escalado consistente opcional (img+mask) y LoRA de estilo opcional. Nodos modo vae_encode: CheckpointLoaderSimple '4', LoadImage '10', LoadImageMask '12', VAEEncodeForInpaint '11', CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9' (+ ImageScale/ImageToMask si size, + LoraLoader si lora). Modo noise_mask sustituye VAEEncodeForInpaint por VAEEncode + SetLatentNoiseMask (+ GrowMask)."
|
||||||
tested: false
|
tested: true
|
||||||
|
tests: ["estructura vae_encode (LoadImage+LoadImageMask+VAEEncodeForInpaint)", "prompt de region + grow_mask reflejados", "grow_mask se clampa a [0,64]", "mode noise_mask degrada a VAEEncode+SetLatentNoiseMask+GrowMask", "size inserta ImageScale a imagen y mascara + ImageToMask", "lora opcional + filename_prefix", "errores: input/mask/prompt vacios, mode invalido", "determinismo"]
|
||||||
|
test_file_path: "python/functions/ml/tests/test_comfyui_build_inpaint_asset_workflow.py"
|
||||||
file_path: python/functions/ml/comfyui_build_inpaint_asset_workflow.py
|
file_path: python/functions/ml/comfyui_build_inpaint_asset_workflow.py
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ params:
|
|||||||
- name: filename_prefix
|
- name: filename_prefix
|
||||||
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
||||||
output: "dict en API format listo para comfyui_submit_workflow: outpaint que extiende el lienzo por los lados pedidos y genera lo nuevo con '{prompt}, {style}, seamless extension...', conservando el asset original. Nodos: CheckpointLoaderSimple '4', LoadImage '10', ImagePadForOutpaint (id nuevo, reusa el '12' que libera el LoadImageMask eliminado), VAEEncodeForInpaint '11' (pixels <- pad IMAGE, mask <- pad MASK), CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9' (+ LoraLoader si lora). El LoadImageMask de la base inpaint se elimina: la mascara la GENERA el pad."
|
output: "dict en API format listo para comfyui_submit_workflow: outpaint que extiende el lienzo por los lados pedidos y genera lo nuevo con '{prompt}, {style}, seamless extension...', conservando el asset original. Nodos: CheckpointLoaderSimple '4', LoadImage '10', ImagePadForOutpaint (id nuevo, reusa el '12' que libera el LoadImageMask eliminado), VAEEncodeForInpaint '11' (pixels <- pad IMAGE, mask <- pad MASK), CLIPTextEncode '6'/'7', KSampler '3', VAEDecode '8', SaveImage '9' (+ LoraLoader si lora). El LoadImageMask de la base inpaint se elimina: la mascara la GENERA el pad."
|
||||||
tested: false
|
tested: true
|
||||||
|
tests: ["estructura outpaint (ImagePadForOutpaint, sin LoadImageMask)", "pad cableado a VAEEncodeForInpaint (pixels<-IMAGE, mask<-MASK)", "extensiones redondeadas a multiplo de 8", "sin extension (todo 0 tras redondear) -> ValueError", "feather y prompt reflejados", "lora opcional + filename_prefix", "errores: input/prompt vacios", "determinismo"]
|
||||||
|
test_file_path: "python/functions/ml/tests/test_comfyui_build_outpaint_asset_workflow.py"
|
||||||
file_path: python/functions/ml/comfyui_build_outpaint_asset_workflow.py
|
file_path: python/functions/ml/comfyui_build_outpaint_asset_workflow.py
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,21 @@ CheckpointLoaderSimple -> ... -> KSampler -> VAEDecode --IMAGE--+-> SaveImage (f
|
|||||||
`-> DepthAnythingV2Preprocessor -> SaveImage (depth)
|
`-> DepthAnythingV2Preprocessor -> SaveImage (depth)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from ml.comfyui_build_parallax_background_workflow import comfyui_build_parallax_background_workflow
|
||||||
|
|
||||||
|
# Fondo apaisado + su mapa de profundidad, 4 bandas de parallax (función pura, sin red).
|
||||||
|
wf = comfyui_build_parallax_background_workflow("forest at dusk, fantasy", layers=4, seed=7)
|
||||||
|
|
||||||
|
# El dict API format trae DOS SaveImage: el fondo y el depth map. Encólalo con submit_workflow.
|
||||||
|
saves = [n for n in wf.values() if n.get("class_type") == "SaveImage"]
|
||||||
|
print(len(saves), "SaveImage (fondo + depth)") # 2
|
||||||
|
```
|
||||||
|
|
||||||
## Cuando usarla
|
## Cuando usarla
|
||||||
|
|
||||||
Cuando necesites el fondo de un nivel 2D con scroll parallax y quieras las capas
|
Cuando necesites el fondo de un nivel 2D con scroll parallax y quieras las capas
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ name: comfyui_build_pixelart_workflow
|
|||||||
kind: function
|
kind: function
|
||||||
lang: py
|
lang: py
|
||||||
domain: ml
|
domain: ml
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: pure
|
purity: pure
|
||||||
signature: "def comfyui_build_pixelart_workflow(positive: str, negative: str = \"blurry, jpeg artifacts, gradient, smooth shading, anti-aliasing\", *, ckpt_name: str = \"IMG_juggernaut_xl_v11.safetensors\", pixel_lora: str = \"SDXL_pixel-art.safetensors\", lora_strength: float = 1.2, use_lcm: bool = True, lcm_lora: str = \"SDXL_lcm-lora.safetensors\", lcm_strength: float = 1.0, steps: int | None = None, cfg: float | None = None, width: int = 1024, height: int = 1024, seed: int = 0, sampler_name: str | None = None, scheduler: str | None = None, filename_prefix: str = \"pixelart\") -> dict"
|
signature: "def comfyui_build_pixelart_workflow(positive: str, negative: str = \"blurry, jpeg artifacts, gradient, smooth shading, anti-aliasing\", *, ckpt_name: str = \"IMG_juggernaut_xl_v11.safetensors\", pixel_lora: str = \"SDXL_pixel-art.safetensors\", lora_strength: float = 1.2, use_lcm: bool = True, lcm_lora: str = \"SDXL_lcm-lora.safetensors\", lcm_strength: float = 1.0, steps: int | None = None, cfg: float | None = None, width: int = 1024, height: int = 1024, seed: int = 0, sampler_name: str | None = None, scheduler: str | None = None, transparent: bool = True, rembg_model: str = \"u2net\", filename_prefix: str = \"pixelart\") -> dict"
|
||||||
description: "Construye el dict (API format) del workflow ComfyUI de pixel-art Fase 1: SDXL base + LoRA SDXL_pixel-art (nerijs), opcionalmente con LCM-LoRA para 8 steps. Compone comfyui_build_txt2img_workflow + comfyui_inject_multi_lora. El pixel-perfect (Fase 2) lo hace comfyui_pixelize_image, no este workflow. Pura, sin red ni I/O. class_types verificados contra /object_info (8GB lowvram)."
|
description: "Construye el dict (API format) del workflow ComfyUI de pixel-art Fase 1: SDXL base + LoRA SDXL_pixel-art (nerijs), opcionalmente con LCM-LoRA para 8 steps. Si transparent (default), inyecta un nodo 'Image Rembg' tras el VAEDecode para recortar el fondo -> sprite con alpha (mismo patron que comfyui_build_item_icon_workflow); transparent=False para tiles/fondos opacos. Compone comfyui_build_txt2img_workflow + comfyui_inject_multi_lora. El pixel-perfect (Fase 2) lo hace comfyui_pixelize_image, no este workflow. Pura, sin red ni I/O. class_types verificados contra /object_info (8GB lowvram)."
|
||||||
tags: [comfyui, ml, gamedev-2d, pixelart, workflow, stable-diffusion, sdxl]
|
tags: [comfyui, ml, gamedev-2d, pixelart, workflow, stable-diffusion, sdxl, rembg, transparent]
|
||||||
uses_functions: [comfyui_build_txt2img_workflow_py_ml, comfyui_inject_multi_lora_py_ml]
|
uses_functions: [comfyui_build_txt2img_workflow_py_ml, comfyui_inject_multi_lora_py_ml]
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -45,11 +45,15 @@ params:
|
|||||||
desc: "Sampler del KSampler. None = default del modo ('lcm' con LCM, 'euler' sin). keyword-only."
|
desc: "Sampler del KSampler. None = default del modo ('lcm' con LCM, 'euler' sin). keyword-only."
|
||||||
- name: scheduler
|
- name: scheduler
|
||||||
desc: "Scheduler del KSampler. None = default del modo ('sgm_uniform' con LCM, 'normal' sin). keyword-only."
|
desc: "Scheduler del KSampler. None = default del modo ('sgm_uniform' con LCM, 'normal' sin). keyword-only."
|
||||||
|
- name: transparent
|
||||||
|
desc: "si True (default) inyecta 'Image Rembg' tras VAEDecode y el PNG sale con alpha (fondo recortado) — para sprites de sujeto (personajes/objetos). False deja fondo opaco — para tiles/texturas/fondos. keyword-only."
|
||||||
|
- name: rembg_model
|
||||||
|
desc: "modelo Rembg ('u2net' general, 'isnet-anime' anime). Solo se usa si transparent=True. keyword-only."
|
||||||
- name: filename_prefix
|
- name: filename_prefix
|
||||||
desc: "Prefijo del PNG que SaveImage escribe en output/. keyword-only."
|
desc: "Prefijo del PNG que SaveImage escribe en output/. keyword-only."
|
||||||
output: "dict en API format listo para comfyui_submit_workflow: CheckpointLoaderSimple + 1 LoraLoader (SDXL_pixel-art) o 2 (+ SDXL_lcm-lora si use_lcm) + KSampler con params del modo + SaveImage."
|
output: "dict en API format listo para comfyui_submit_workflow: CheckpointLoaderSimple + 1 LoraLoader (SDXL_pixel-art) o 2 (+ SDXL_lcm-lora si use_lcm) + KSampler con params del modo + nodo 'Image Rembg' antes del SaveImage si transparent + SaveImage."
|
||||||
tested: true
|
tested: true
|
||||||
tests: ["golden use_lcm=True: 2 LoraLoader (SDXL_pixel-art@1.2, lcm@1.0) + KSampler steps 8/cfg 1.5/sampler lcm/sgm_uniform", "edge use_lcm=False: 1 LoraLoader + KSampler steps 25/cfg 7/euler/normal", "edge overrides steps/cfg + clamp lora_strength a 2.0", "error positive vacio -> ValueError", "determinismo"]
|
tests: ["golden use_lcm=True: 2 LoraLoader (SDXL_pixel-art@1.2, lcm@1.0) + KSampler steps 8/cfg 1.5/sampler lcm/sgm_uniform", "edge use_lcm=False: 1 LoraLoader + KSampler steps 25/cfg 7/euler/normal", "edge overrides steps/cfg + clamp lora_strength a 2.0", "error positive vacio -> ValueError", "determinismo", "transparent default inyecta Image Rembg + repunta SaveImage", "transparent=False sin Rembg (SaveImage lee del VAEDecode)", "rembg_model override"]
|
||||||
test_file_path: "python/functions/ml/comfyui_build_pixelart_workflow_test.py"
|
test_file_path: "python/functions/ml/comfyui_build_pixelart_workflow_test.py"
|
||||||
file_path: "python/functions/ml/comfyui_build_pixelart_workflow.py"
|
file_path: "python/functions/ml/comfyui_build_pixelart_workflow.py"
|
||||||
---
|
---
|
||||||
@@ -94,3 +98,15 @@ Para tilesets, genera cada tile por separado y ensambla con `comfyui_build_grid`
|
|||||||
`--lowvram`; la Fase 2 es CPU y no toca VRAM.
|
`--lowvram`; la Fase 2 es CPU y no toca VRAM.
|
||||||
- Función pura: no valida contra el server. Si una LoRA/checkpoint falta, el HTTP
|
- Función pura: no valida contra el server. Si una LoRA/checkpoint falta, el HTTP
|
||||||
400 salta al enviar con `comfyui_submit_workflow`.
|
400 salta al enviar con `comfyui_submit_workflow`.
|
||||||
|
- **transparent=True (default, v1.1.0)**: inyecta el nodo `Image Rembg (Remove
|
||||||
|
Background)`. Requiere el custom node `ComfyUI-Image-Background-Remove` (o equiv.)
|
||||||
|
instalado en el server; si falta, el `submit` devuelve error en el dict (no crashea).
|
||||||
|
El sprite sale RGBA con fondo recortado — ideal para personajes/objetos. Para
|
||||||
|
tiles/texturas/fondos sin contorno usar `transparent=False` (PNG opaco).
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-28) — `transparent`/`rembg_model`: inyecta `Image Rembg` tras el
|
||||||
|
VAEDecode (mismo patron que `comfyui_build_item_icon_workflow`) para producir
|
||||||
|
sprites con fondo transparente. Cierra el bug del pipeline pixelart que no podia
|
||||||
|
generar sprites sin fondo (issue sprite-fix).
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Funcion pura: sin red, sin I/O. Determinista para los mismos argumentos.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -29,6 +30,44 @@ _LCM_DEFAULTS = {"steps": 8, "cfg": 1.5, "sampler_name": "lcm", "scheduler": "sg
|
|||||||
_PLAIN_DEFAULTS = {"steps": 25, "cfg": 7.0, "sampler_name": "euler", "scheduler": "normal"}
|
_PLAIN_DEFAULTS = {"steps": 25, "cfg": 7.0, "sampler_name": "euler", "scheduler": "normal"}
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_rembg(workflow: dict, model: str) -> dict:
|
||||||
|
"""Inserta 'Image Rembg (Remove Background)' (transparency=True) entre VAEDecode y SaveImage.
|
||||||
|
|
||||||
|
Mismo helper que comfyui_build_item_icon_workflow / comfyui_build_sprite_sheet_workflow:
|
||||||
|
el nodo recorta la silueta del sujeto dejando alpha, y se repunta SaveImage.images a
|
||||||
|
la salida del Rembg para que el PNG salga con fondo transparente. No muta el dict de
|
||||||
|
entrada (copia profunda).
|
||||||
|
"""
|
||||||
|
wf = copy.deepcopy(workflow)
|
||||||
|
vaedecode_id = next(
|
||||||
|
(nid for nid, n in wf.items() if n.get("class_type") == "VAEDecode"), None
|
||||||
|
)
|
||||||
|
save_id = next((nid for nid, n in wf.items() if n.get("class_type") == "SaveImage"), None)
|
||||||
|
if vaedecode_id is None or save_id is None:
|
||||||
|
raise ValueError(
|
||||||
|
"comfyui_build_pixelart_workflow: no se encontro VAEDecode/SaveImage para Rembg"
|
||||||
|
)
|
||||||
|
numeric = [int(k) for k in wf.keys() if str(k).isdigit()]
|
||||||
|
rembg_id = str((max(numeric) + 1) if numeric else len(wf) + 1)
|
||||||
|
wf[rembg_id] = {
|
||||||
|
"class_type": "Image Rembg (Remove Background)",
|
||||||
|
"inputs": {
|
||||||
|
"images": [vaedecode_id, 0],
|
||||||
|
"transparency": True,
|
||||||
|
"model": 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
wf[save_id]["inputs"]["images"] = [rembg_id, 0]
|
||||||
|
return wf
|
||||||
|
|
||||||
|
|
||||||
def comfyui_build_pixelart_workflow(
|
def comfyui_build_pixelart_workflow(
|
||||||
positive: str,
|
positive: str,
|
||||||
negative: str = "blurry, jpeg artifacts, gradient, smooth shading, anti-aliasing",
|
negative: str = "blurry, jpeg artifacts, gradient, smooth shading, anti-aliasing",
|
||||||
@@ -46,6 +85,8 @@ def comfyui_build_pixelart_workflow(
|
|||||||
seed: int = 0,
|
seed: int = 0,
|
||||||
sampler_name: str | None = None,
|
sampler_name: str | None = None,
|
||||||
scheduler: str | None = None,
|
scheduler: str | None = None,
|
||||||
|
transparent: bool = True,
|
||||||
|
rembg_model: str = "u2net",
|
||||||
filename_prefix: str = "pixelart",
|
filename_prefix: str = "pixelart",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Construye el dict (API format) del workflow pixel-art SDXL + LoRA.
|
"""Construye el dict (API format) del workflow pixel-art SDXL + LoRA.
|
||||||
@@ -70,15 +111,24 @@ def comfyui_build_pixelart_workflow(
|
|||||||
width, height: resolucion base (1024x1024 SDXL; luego downscale x8 -> 128
|
width, height: resolucion base (1024x1024 SDXL; luego downscale x8 -> 128
|
||||||
en la Fase 2 con comfyui_pixelize_image).
|
en la Fase 2 con comfyui_pixelize_image).
|
||||||
seed: semilla del KSampler.
|
seed: semilla del KSampler.
|
||||||
|
transparent: si True (default) inyecta 'Image Rembg' tras el VAEDecode y el
|
||||||
|
PNG sale con alpha (fondo recortado) — lo habitual para sprites de sujeto
|
||||||
|
(personajes, criaturas, objetos). Si False deja la imagen opaca sobre
|
||||||
|
fondo plano, para tiles/texturas/fondos que no quieren transparencia.
|
||||||
|
keyword-only.
|
||||||
|
rembg_model: modelo Rembg ('u2net' general, 'isnet-anime' para anime). Solo
|
||||||
|
se usa si transparent=True. keyword-only.
|
||||||
filename_prefix: prefijo del PNG en output/.
|
filename_prefix: prefijo del PNG en output/.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict en API format listo para comfyui_submit_workflow, con el
|
dict en API format listo para comfyui_submit_workflow, con el
|
||||||
CheckpointLoaderSimple, 1 LoraLoader (SDXL_pixel-art) o 2 (SDXL_pixel-art +
|
CheckpointLoaderSimple, 1 LoraLoader (SDXL_pixel-art) o 2 (SDXL_pixel-art +
|
||||||
SDXL_lcm-lora si use_lcm), KSampler con los params del modo y SaveImage.
|
SDXL_lcm-lora si use_lcm), KSampler con los params del modo, un nodo
|
||||||
|
'Image Rembg' antes del SaveImage si transparent, y SaveImage.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: si positive esta vacio.
|
ValueError: si positive esta vacio, o si la base no tiene VAEDecode/SaveImage
|
||||||
|
donde inyectar el Rembg (propagado por el helper, solo si transparent).
|
||||||
"""
|
"""
|
||||||
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
from ml.comfyui_build_txt2img_workflow import comfyui_build_txt2img_workflow
|
||||||
from ml.comfyui_inject_multi_lora import comfyui_inject_multi_lora
|
from ml.comfyui_inject_multi_lora import comfyui_inject_multi_lora
|
||||||
@@ -117,7 +167,12 @@ def comfyui_build_pixelart_workflow(
|
|||||||
{"name": lcm_lora, "strength_model": lcm_strength, "strength_clip": lcm_strength}
|
{"name": lcm_lora, "strength_model": lcm_strength, "strength_clip": lcm_strength}
|
||||||
)
|
)
|
||||||
|
|
||||||
return comfyui_inject_multi_lora(base, loras)
|
wf = comfyui_inject_multi_lora(base, loras)
|
||||||
|
|
||||||
|
if transparent:
|
||||||
|
wf = _inject_rembg(wf, rembg_model)
|
||||||
|
|
||||||
|
return wf
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ params:
|
|||||||
- name: filename_prefix
|
- name: filename_prefix
|
||||||
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
desc: "Prefijo del archivo de salida en SaveImage. keyword-only."
|
||||||
output: "dict en API format listo para comfyui_submit_workflow: txt2img base (CheckpointLoaderSimple '4', EmptyLatentImage '5', CLIPTextEncode '6'/'7', KSampler '3' denoise 1.0, VAEDecode '8', SaveImage '9') + rama ControlNet (LoadImage del boceto -> [Preprocessor del control_type si preprocess] -> ControlNetApply -> KSampler.positive, con ControlNetLoader del modelo CN) + LoraLoader si lora. Es UN sprite; varios objetos del mismo set -> llamar por subject/sketch_image con el mismo style/checkpoint/(lora)."
|
output: "dict en API format listo para comfyui_submit_workflow: txt2img base (CheckpointLoaderSimple '4', EmptyLatentImage '5', CLIPTextEncode '6'/'7', KSampler '3' denoise 1.0, VAEDecode '8', SaveImage '9') + rama ControlNet (LoadImage del boceto -> [Preprocessor del control_type si preprocess] -> ControlNetApply -> KSampler.positive, con ControlNetLoader del modelo CN) + LoraLoader si lora. Es UN sprite; varios objetos del mismo set -> llamar por subject/sketch_image con el mismo style/checkpoint/(lora)."
|
||||||
tested: false
|
tested: true
|
||||||
|
tests: ["estructura txt2img + ControlNet (EmptyLatentImage, ControlNetLoader/Apply)", "lineart: preprocesador + modelo por defecto, ControlNetApply consume el mapa de lineas", "canny: preprocesador + modelo", "preprocess=False pasa el boceto directo al ControlNet", "controlnet_name override + strength reflejado", "strength se clampa a [0,2]", "lora opcional", "errores: sketch/subject vacios, control_type invalido", "determinismo"]
|
||||||
|
test_file_path: "python/functions/ml/tests/test_comfyui_build_sprite_from_sketch_workflow.py"
|
||||||
file_path: python/functions/ml/comfyui_build_sprite_from_sketch_workflow.py
|
file_path: python/functions/ml/comfyui_build_sprite_from_sketch_workflow.py
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -26,9 +26,9 @@ params:
|
|||||||
- name: token
|
- name: token
|
||||||
desc: "Token OAuth; si vacio lo carga ask_llm_vision automaticamente. keyword-only."
|
desc: "Token OAuth; si vacio lo carga ask_llm_vision automaticamente. keyword-only."
|
||||||
output: "dict {ok, verdict, score_0_10, reasons, error}. En exito ok=True, verdict 'good'|'bad', score_0_10 el score del modelo y reasons la lista de razones. En error (imagen invalida, API caida, 429, JSON no parseable) ok=False con error. Nunca lanza excepcion."
|
output: "dict {ok, verdict, score_0_10, reasons, error}. En exito ok=True, verdict 'good'|'bad', score_0_10 el score del modelo y reasons la lista de razones. En error (imagen invalida, API caida, 429, JSON no parseable) ok=False con error. Nunca lanza excepcion."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["_extract_json: fence json", "_extract_json: brace plano", "_extract_json: sin objeto -> ValueError", "flujo: veredicto estructurado good", "verdict ambiguo -> bad conservador", "API caida -> ok=False", "respuesta no parseable -> ok=False"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_critique_image_llm.py"
|
||||||
file_path: "python/functions/ml/comfyui_critique_image_llm.py"
|
file_path: "python/functions/ml/comfyui_critique_image_llm.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ params:
|
|||||||
- name: nsfw
|
- name: nsfw
|
||||||
desc: "Marca provenance.nsfw. keyword-only."
|
desc: "Marca provenance.nsfw. keyword-only."
|
||||||
output: "dict {ok, recipe, slug, has_workflow, error}. recipe sigue el schema minimo de comfyui_save_skill con provenance.source='civitai' y score_n=0. ok=False solo si no hay ni workflow embebido ni civitai_meta utilizable."
|
output: "dict {ok, recipe, slug, has_workflow, error}. recipe sigue el schema minimo de comfyui_save_skill con provenance.source='civitai' y score_n=0. ok=False solo si no hay ni workflow embebido ni civitai_meta utilizable."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["_slugify (normaliza y acota a 6 tokens)", "_loras_from_prompt", "_dims_from_prompt + _checkpoint_from_prompt", "_detect_base_workflow (flux/txt2img)", "_from_civitai_meta (mapea steps/cfg/size/modelo/prompts)", "flujo fallback a civitai_meta sin workflow embebido", "slug derivado del prompt", "error: sin workflow ni meta"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_extract_recipe_from_png.py"
|
||||||
file_path: "python/functions/ml/comfyui_extract_recipe_from_png.py"
|
file_path: "python/functions/ml/comfyui_extract_recipe_from_png.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
name: comfyui_extract_template
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: ml
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def comfyui_extract_template(name: str, comfyui_python: str | None = None, to_api: bool = False, server: str = \"127.0.0.1:8188\") -> dict"
|
||||||
|
description: "Extrae el grafo de nodos de un workflow template oficial de ComfyUI por su template_id. Devuelve el grafo completo (formato UI: nodes/links), la lista de class_types que usa (aplanando subgrafos y descartando UUID de instancia), el formato, el bundle y los assets en disco. Opcionalmente (to_api=True) convierte el grafo UI a API format reutilizando comfyui_import_workflow_json (requiere un servidor ComfyUI vivo). Nombre inexistente -> error legible con sugerencias, sin traceback. Localiza el interprete de ComfyUI y usa su API oficial via subprocess. Impura: lee disco (+ red opcional si to_api)."
|
||||||
|
tags: [comfyui, ml, templates, workflow, extract]
|
||||||
|
uses_functions: ["comfyui_import_workflow_json_py_ml"]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: name
|
||||||
|
desc: "template_id exacto del template (p.ej. 'sdxl_simple_example', 'image_sdxl'). Usa comfyui_list_templates para ver los nombres disponibles."
|
||||||
|
- name: comfyui_python
|
||||||
|
desc: "Ruta al interprete python de ComfyUI con el paquete comfyui-workflow-templates. None autodetecta (env COMFYUI_PYTHON, ~/ComfyUI/.venv/bin/python)."
|
||||||
|
- name: to_api
|
||||||
|
desc: "True intenta convertir el grafo UI a API format via comfyui_import_workflow_json (requiere servidor ComfyUI vivo en `server`). Si falla, el grafo UI se devuelve igualmente y el motivo va en api_error."
|
||||||
|
- name: server
|
||||||
|
desc: "host:port del servidor ComfyUI usado para la conversion to_api (default '127.0.0.1:8188')."
|
||||||
|
output: "dict {ok, name, format, class_types, has_subgraphs, n_nodes, graph, api_workflow, api_error, bundle, version, assets, error}. graph = dict del template (formato UI o API). class_types = lista ordenada de tipos de nodo reales. api_workflow = dict API si to_api tuvo exito, si no {}. Nunca lanza: nombre inexistente -> ok=False con error + sugerencias."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "sin el paquete instalado -> ok=False con error que menciona comfyui-workflow-templates"
|
||||||
|
- "el nombre pedido se preserva y el dict trae todas sus claves aun en fallo"
|
||||||
|
- "golden (skip si no hay ComfyUI con el paquete): extrae un template real con graph + class_types no vacios"
|
||||||
|
- "golden (skip si no hay ComfyUI con el paquete): nombre inexistente -> ok=False con error legible"
|
||||||
|
test_file_path: "python/functions/ml/tests/test_comfyui_extract_template.py"
|
||||||
|
file_path: "python/functions/ml/comfyui_extract_template.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lanzable directo (grafo slim + class_types de un template concreto):
|
||||||
|
python/.venv/bin/python3 python/functions/ml/comfyui_extract_template.py sdxl_simple_example
|
||||||
|
|
||||||
|
# Con conversion a API format (necesita ComfyUI corriendo en 127.0.0.1:8188):
|
||||||
|
python/.venv/bin/python3 python/functions/ml/comfyui_extract_template.py sdxl_simple_example --to-api
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from ml.comfyui_extract_template import comfyui_extract_template
|
||||||
|
|
||||||
|
res = comfyui_extract_template("sdxl_simple_example")
|
||||||
|
print(res["format"], res["n_nodes"], "nodos") # ui_graph 25 nodos
|
||||||
|
print(res["class_types"]) # ['CheckpointLoaderSimple', 'KSamplerAdvanced', ...]
|
||||||
|
graph = res["graph"] # dict cargable en la UI tal cual
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando quieras reutilizar la estructura de nodos de un template oficial: cargar su
|
||||||
|
grafo en tu UI, usarlo de base para un workflow propio, o saber exactamente que
|
||||||
|
class_types encadena. Segundo paso del flujo listar (`comfyui_list_templates`) ->
|
||||||
|
extraer. Para encolar el resultado en `/prompt` usa `to_api=True` (o pasa el grafo por
|
||||||
|
`comfyui_import_workflow_json`).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- El grafo viene en **formato UI** (nodes/links con posiciones), no en API format. La
|
||||||
|
UI de ComfyUI lo entiende tal cual (cargalo o copia el dict); para `/prompt` hay que
|
||||||
|
convertirlo a API format con `to_api=True`.
|
||||||
|
- `to_api=True` reutiliza `comfyui_import_workflow_json`, que necesita un **servidor
|
||||||
|
ComfyUI vivo** para mapear los widgets a sus claves de input. Sin servidor, la
|
||||||
|
extraccion del grafo UI sigue funcionando (ok=True) y el motivo del fallo de
|
||||||
|
conversion va en `api_error` (no rompe). KISS: no se fuerza la conversion.
|
||||||
|
- Templates **subgraphed** (con `definitions.subgraphs`, `has_subgraphs=True`): la
|
||||||
|
conversion a API NO expande el subgraph (limitacion de la normalizacion UI->API
|
||||||
|
estandar), asi que `api_workflow` puede quedar con solo los nodos top-level. Para
|
||||||
|
esos, cargar el grafo UI en la UI es lo fiable. `class_types` sí incluye los nodos
|
||||||
|
reales de dentro del subgraph.
|
||||||
|
- Nombre inexistente -> `ok=False` con `error` legible y sugerencias por substring (o
|
||||||
|
difflib). No lanza traceback.
|
||||||
|
- El paquete vive en el venv de ComfyUI; si no se encuentra el interprete o el paquete,
|
||||||
|
`ok=False` indicando `pip install comfyui-workflow-templates`.
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"""Extrae el grafo de nodos de un workflow template oficial de ComfyUI por su nombre.
|
||||||
|
|
||||||
|
Funcion impura: lee disco (el .json del template instalado) ejecutando la API oficial
|
||||||
|
del paquete comfyui-workflow-templates dentro del interprete de ComfyUI.
|
||||||
|
|
||||||
|
Dado el nombre de un template (su template_id, p.ej. "image_sdxl" o
|
||||||
|
"api_bfl_flux2_max_sofa_swap"), devuelve:
|
||||||
|
- graph: el dict completo del .json (formato UI: nodes/links con posiciones).
|
||||||
|
- class_types: la lista de tipos de nodo (class_type) que usa, aplanando los
|
||||||
|
subgrafos de `definitions` si los hay.
|
||||||
|
- format: "ui_graph" (lo normal en los templates) o "api".
|
||||||
|
- assets: rutas en disco de los ficheros del template (json + previews .webp).
|
||||||
|
|
||||||
|
Opcionalmente (to_api=True) intenta convertir el grafo UI a API format reutilizando
|
||||||
|
comfyui_import_workflow_json del registry. Esa conversion necesita un servidor ComfyUI
|
||||||
|
vivo para mapear los widgets a sus claves de input; si no lo hay, se devuelve el grafo
|
||||||
|
UI + class_types igualmente y se reporta el motivo en api_error (KISS: no se fuerza la
|
||||||
|
conversion de grafos complejos).
|
||||||
|
|
||||||
|
El paquete vive en el venv de ComfyUI (no en el del registry), por eso esta funcion no
|
||||||
|
lo importa: localiza el interprete de ComfyUI y le pasa un script que usa la API oficial.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
if _THIS_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, _THIS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
# Script que corre DENTRO del python de ComfyUI. Resuelve un template por id, vuelca su
|
||||||
|
# grafo + metadata como JSON. Si no existe, devuelve sugerencias cercanas.
|
||||||
|
_EXTRACT_SCRIPT = r"""
|
||||||
|
import json, sys, difflib, re
|
||||||
|
try:
|
||||||
|
import comfyui_workflow_templates_core as core
|
||||||
|
except Exception as exc:
|
||||||
|
print(json.dumps({"__err__": "import", "msg": str(exc)}))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
||||||
|
|
||||||
|
TID = json.loads({tid_json!r})
|
||||||
|
m = core.load_manifest()
|
||||||
|
if TID not in m.templates:
|
||||||
|
near = [k for k in m.templates if TID.lower() in k.lower()][:8]
|
||||||
|
if not near:
|
||||||
|
near = difflib.get_close_matches(TID, list(m.templates.keys()), n=8, cutoff=0.6)
|
||||||
|
print(json.dumps({"__err__": "not_found", "suggestions": near}))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
entry = m.templates[TID]
|
||||||
|
json_asset = next((a.filename for a in entry.assets if a.filename.endswith(".json")), None)
|
||||||
|
if not json_asset:
|
||||||
|
print(json.dumps({"__err__": "no_json"}))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
path = core.get_asset_path(TID, json_asset)
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
graph = json.load(fh)
|
||||||
|
|
||||||
|
# Detecta formato y extrae class_types.
|
||||||
|
fmt = "unknown"
|
||||||
|
class_types = set()
|
||||||
|
has_subgraphs = False
|
||||||
|
if isinstance(graph, dict) and isinstance(graph.get("nodes"), list):
|
||||||
|
fmt = "ui_graph"
|
||||||
|
for n in graph["nodes"]:
|
||||||
|
t = n.get("type") if isinstance(n, dict) else None
|
||||||
|
if t and not _UUID_RE.match(str(t)):
|
||||||
|
class_types.add(t)
|
||||||
|
defs = graph.get("definitions")
|
||||||
|
if isinstance(defs, dict) and isinstance(defs.get("subgraphs"), list):
|
||||||
|
for sg in defs["subgraphs"]:
|
||||||
|
for n in (sg.get("nodes") or []) if isinstance(sg, dict) else []:
|
||||||
|
if isinstance(n, dict) and n.get("type"):
|
||||||
|
has_subgraphs = True
|
||||||
|
if not _UUID_RE.match(str(n["type"])):
|
||||||
|
class_types.add(n["type"])
|
||||||
|
elif isinstance(graph, dict):
|
||||||
|
fmt = "api"
|
||||||
|
for v in graph.values():
|
||||||
|
if isinstance(v, dict) and v.get("class_type"):
|
||||||
|
class_types.add(v["class_type"])
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"graph": graph,
|
||||||
|
"class_types": sorted(class_types),
|
||||||
|
"format": fmt,
|
||||||
|
"has_subgraphs": has_subgraphs,
|
||||||
|
"bundle": entry.bundle,
|
||||||
|
"version": entry.version,
|
||||||
|
"assets": core.resolve_all_assets(TID),
|
||||||
|
"json_path": path,
|
||||||
|
}))
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _find_comfyui_python(explicit: str | None) -> str | None:
|
||||||
|
"""Localiza un interprete de ComfyUI con el paquete instalado (ver list_templates)."""
|
||||||
|
candidates = []
|
||||||
|
if explicit:
|
||||||
|
candidates.append(os.path.expanduser(explicit))
|
||||||
|
env = os.environ.get("COMFYUI_PYTHON")
|
||||||
|
if env:
|
||||||
|
candidates.append(os.path.expanduser(env))
|
||||||
|
candidates += [
|
||||||
|
os.path.expanduser("~/ComfyUI/.venv/bin/python"),
|
||||||
|
os.path.expanduser("~/ComfyUI/venv/bin/python"),
|
||||||
|
os.path.expanduser("~/comfyui/.venv/bin/python"),
|
||||||
|
sys.executable,
|
||||||
|
]
|
||||||
|
for c in candidates:
|
||||||
|
if c and os.path.isfile(c):
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def comfyui_extract_template(
|
||||||
|
name: str,
|
||||||
|
comfyui_python: str | None = None,
|
||||||
|
to_api: bool = False,
|
||||||
|
server: str = "127.0.0.1:8188",
|
||||||
|
) -> dict:
|
||||||
|
"""Extrae el grafo y los class_types de un template oficial de ComfyUI por nombre.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: template_id exacto del template (p.ej. "image_sdxl"). Usa
|
||||||
|
comfyui_list_templates para ver los nombres disponibles.
|
||||||
|
comfyui_python: ruta al interprete python de ComfyUI con el paquete
|
||||||
|
comfyui-workflow-templates. Si None, se autodetecta.
|
||||||
|
to_api: si True, intenta convertir el grafo UI a API format reutilizando
|
||||||
|
comfyui_import_workflow_json (requiere un servidor ComfyUI vivo en
|
||||||
|
`server`). Si la conversion falla, se devuelve el grafo UI igualmente y
|
||||||
|
el motivo va en api_error.
|
||||||
|
server: host:port del servidor ComfyUI para la conversion to_api.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict {ok, name, format, class_types, has_subgraphs, n_nodes, graph,
|
||||||
|
api_workflow, api_error, bundle, version, assets, error}:
|
||||||
|
- graph: el dict del template en formato UI (o API si ya lo estaba).
|
||||||
|
- class_types: lista ordenada de tipos de nodo del grafo (incluye los de
|
||||||
|
subgrafos de `definitions`).
|
||||||
|
- api_workflow: dict en API format si to_api tuvo exito, si no {}.
|
||||||
|
Nunca lanza. Nombre inexistente -> ok=False con error legible + sugerencias.
|
||||||
|
"""
|
||||||
|
py = _find_comfyui_python(comfyui_python)
|
||||||
|
base = {
|
||||||
|
"ok": False,
|
||||||
|
"name": name,
|
||||||
|
"format": "",
|
||||||
|
"class_types": [],
|
||||||
|
"has_subgraphs": False,
|
||||||
|
"n_nodes": 0,
|
||||||
|
"graph": {},
|
||||||
|
"api_workflow": {},
|
||||||
|
"api_error": "",
|
||||||
|
"bundle": "",
|
||||||
|
"version": "",
|
||||||
|
"assets": [],
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
if not py:
|
||||||
|
base["error"] = (
|
||||||
|
"no se encontro un interprete de ComfyUI. Pasa comfyui_python=... o "
|
||||||
|
"define COMFYUI_PYTHON. Instala el paquete con: "
|
||||||
|
"pip install comfyui-workflow-templates"
|
||||||
|
)
|
||||||
|
return base
|
||||||
|
|
||||||
|
script = _EXTRACT_SCRIPT.replace("{tid_json!r}", repr(json.dumps(name)))
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[py, "-c", script],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
base["error"] = f"fallo al ejecutar el interprete de ComfyUI ({py}): {exc}"
|
||||||
|
return base
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
base["error"] = f"el interprete de ComfyUI fallo: {proc.stderr.strip()[:500]}"
|
||||||
|
return base
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(proc.stdout.strip().splitlines()[-1])
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
base["error"] = f"salida no parseable del interprete de ComfyUI: {exc}"
|
||||||
|
return base
|
||||||
|
|
||||||
|
err = data.get("__err__")
|
||||||
|
if err == "import":
|
||||||
|
base["error"] = (
|
||||||
|
f"el paquete comfyui-workflow-templates no esta instalado en {py} "
|
||||||
|
f"({data.get('msg', '')}). Instalalo con: "
|
||||||
|
"pip install comfyui-workflow-templates"
|
||||||
|
)
|
||||||
|
return base
|
||||||
|
if err == "not_found":
|
||||||
|
sug = data.get("suggestions", [])
|
||||||
|
hint = f" ¿Quizas: {', '.join(sug)}?" if sug else ""
|
||||||
|
base["error"] = f"template '{name}' no existe en el paquete.{hint}"
|
||||||
|
return base
|
||||||
|
if err == "no_json":
|
||||||
|
base["error"] = f"el template '{name}' no tiene asset .json."
|
||||||
|
return base
|
||||||
|
|
||||||
|
graph = data.get("graph", {})
|
||||||
|
fmt = data.get("format", "")
|
||||||
|
nodes = graph.get("nodes") if isinstance(graph, dict) else None
|
||||||
|
n_nodes = len(nodes) if isinstance(nodes, list) else (
|
||||||
|
len(graph) if fmt == "api" and isinstance(graph, dict) else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
out = {
|
||||||
|
"ok": True,
|
||||||
|
"name": name,
|
||||||
|
"format": fmt,
|
||||||
|
"class_types": data.get("class_types", []),
|
||||||
|
"has_subgraphs": data.get("has_subgraphs", False),
|
||||||
|
"n_nodes": n_nodes,
|
||||||
|
"graph": graph,
|
||||||
|
"api_workflow": {},
|
||||||
|
"api_error": "",
|
||||||
|
"bundle": data.get("bundle", ""),
|
||||||
|
"version": data.get("version", ""),
|
||||||
|
"assets": data.get("assets", []),
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if to_api:
|
||||||
|
if fmt == "api":
|
||||||
|
out["api_workflow"] = graph
|
||||||
|
else:
|
||||||
|
out["api_workflow"], out["api_error"] = _convert_to_api(graph, server)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_to_api(graph: dict, server: str) -> tuple[dict, str]:
|
||||||
|
"""Convierte un grafo UI a API format via comfyui_import_workflow_json del registry.
|
||||||
|
|
||||||
|
Requiere un servidor ComfyUI vivo para mapear widgets. Devuelve (workflow, "")
|
||||||
|
si tuvo exito o ({}, motivo) si fallo. No lanza.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from comfyui_import_workflow_json import comfyui_import_workflow_json
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {}, f"no se pudo importar comfyui_import_workflow_json: {exc}"
|
||||||
|
|
||||||
|
tmp = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
"w", suffix=".json", delete=False, encoding="utf-8"
|
||||||
|
) as fh:
|
||||||
|
json.dump(graph, fh)
|
||||||
|
tmp = fh.name
|
||||||
|
res = comfyui_import_workflow_json(tmp, server=server)
|
||||||
|
if res.get("ok"):
|
||||||
|
return res.get("workflow", {}), ""
|
||||||
|
return {}, (
|
||||||
|
res.get("error", "conversion fallida")
|
||||||
|
+ f" (requiere un servidor ComfyUI vivo en {server})"
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {}, f"conversion to_api fallida: {exc}"
|
||||||
|
finally:
|
||||||
|
if tmp and os.path.exists(tmp):
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser(description="Extrae el grafo de un template ComfyUI")
|
||||||
|
ap.add_argument("name", help="template_id (ver comfyui_list_templates)")
|
||||||
|
ap.add_argument("--comfyui-python", default=None)
|
||||||
|
ap.add_argument("--to-api", action="store_true")
|
||||||
|
ap.add_argument("--server", default="127.0.0.1:8188")
|
||||||
|
ap.add_argument("--full", action="store_true", help="incluye el grafo entero")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
res = comfyui_extract_template(
|
||||||
|
args.name,
|
||||||
|
args.comfyui_python,
|
||||||
|
to_api=args.to_api,
|
||||||
|
server=args.server,
|
||||||
|
)
|
||||||
|
if args.full or not res["ok"]:
|
||||||
|
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
slim = {k: v for k, v in res.items() if k != "graph"}
|
||||||
|
slim["graph_keys"] = list(res["graph"].keys()) if isinstance(res["graph"], dict) else []
|
||||||
|
print(json.dumps(slim, indent=2, ensure_ascii=False))
|
||||||
@@ -5,7 +5,7 @@ lang: py
|
|||||||
domain: ml
|
domain: ml
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "def comfyui_fetch_civitai_image_meta(image_ref, *, token: str | None = None, timeout: float = 15.0) -> dict"
|
signature: "def comfyui_fetch_civitai_image_meta(image_ref, token: str | None = None, timeout: float = 15.0) -> dict"
|
||||||
description: "Recupera los detalles de generacion de una imagen de Civitai por su id o URL (civitai.com/images/<id>): prompt, prompt negativo, modelo, sampler, steps, cfg, seed, dimensiones, recursos (checkpoint + LoRAs) y nivel NSFW. Es el paso 'entrar al link y observar como lo hicieron'. Usa los endpoints tRPC image.getGenerationData + image.get que consume la propia web de civitai.com, porque la API v1 publica (GET /api/v1/images) hoy devuelve meta=null y un JPEG recomprimido sin workflow embebido. Si la meta trae un workflow ComfyUI embebido (campo comfy) lo devuelve en API format. NO descarga la imagen ni reconstruye workflow: solo lee. Impura: HTTP a civitai.com + subprocess (pass para el token)."
|
description: "Recupera los detalles de generacion de una imagen de Civitai por su id o URL (civitai.com/images/<id>): prompt, prompt negativo, modelo, sampler, steps, cfg, seed, dimensiones, recursos (checkpoint + LoRAs) y nivel NSFW. Es el paso 'entrar al link y observar como lo hicieron'. Usa los endpoints tRPC image.getGenerationData + image.get que consume la propia web de civitai.com, porque la API v1 publica (GET /api/v1/images) hoy devuelve meta=null y un JPEG recomprimido sin workflow embebido. Si la meta trae un workflow ComfyUI embebido (campo comfy) lo devuelve en API format. NO descarga la imagen ni reconstruye workflow: solo lee. Impura: HTTP a civitai.com + subprocess (pass para el token)."
|
||||||
tags: [comfyui, civitai, replicate, ml, metadata, http, stable-diffusion]
|
tags: [comfyui, civitai, replicate, ml, metadata, http, stable-diffusion]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
|
|||||||
@@ -128,15 +128,15 @@ def _extract_comfy_workflow(meta):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def comfyui_fetch_civitai_image_meta(image_ref, *, token=None, timeout=15.0):
|
def comfyui_fetch_civitai_image_meta(image_ref, token=None, timeout=15.0):
|
||||||
"""Recupera los detalles de generación de una imagen de Civitai por id/URL.
|
"""Recupera los detalles de generación de una imagen de Civitai por id/URL.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
image_ref: id numérico de la imagen (int o str), o su URL
|
image_ref: id numérico de la imagen (int o str), o su URL
|
||||||
`https://civitai.com/images/<id>` (con o sin query string).
|
`https://civitai.com/images/<id>` (con o sin query string).
|
||||||
token: API token de Civitai (header Authorization Bearer). Si None se
|
token: API token de Civitai (header Authorization Bearer). Si None se
|
||||||
resuelve de `pass civitai/api-token`. No hardcodear. keyword-only.
|
resuelve de `pass civitai/api-token`. No hardcodear.
|
||||||
timeout: timeout HTTP en segundos por petición. keyword-only.
|
timeout: timeout HTTP en segundos por petición.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict {ok, image_id, meta, resources, process, comfy_workflow, width,
|
dict {ok, image_id, meta, resources, process, comfy_workflow, width,
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
name: comfyui_fetch_output_audio
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: ml
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def comfyui_fetch_output_audio(prompt_id: str, *, server: str = \"127.0.0.1:8188\", dest: str | None = None, outputs: dict | None = None, timeout: float = 120.0) -> dict"
|
||||||
|
description: "Localiza y descarga el output de audio de un workflow ComfyUI a disco local. Hermana de comfyui_fetch_output_video / _image / _mesh pero para los nodos de audio (SaveAudio, SaveAudioMP3, SaveAudioOpus, SaveAudioAdvanced): esos exponen su salida en GET /history bajo la clave 'audio' con items {filename, subfolder, type}. Localiza el primer .flac/.wav/.mp3/.opus/.ogg/.m4a, lo baja via GET /view y opcionalmente lo escribe en dest. Acepta outputs= ya obtenido de comfyui_wait_result para evitar re-consultar /history. Impura: HTTP GET + escritura en disco, solo stdlib."
|
||||||
|
tags: [comfyui, audio, fetch, ace-step, ml, download, workflow]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: error_go_core
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: prompt_id
|
||||||
|
desc: "id devuelto por comfyui_submit_workflow, de un workflow cuyo nodo de audio (SaveAudio/SaveAudioMP3/...) ya termino (usa comfyui_wait_result antes si dudas). Se ignora si se pasa outputs."
|
||||||
|
- name: server
|
||||||
|
desc: "host:port del servidor ComfyUI sin esquema. keyword-only."
|
||||||
|
- name: dest
|
||||||
|
desc: "Ruta destino. Si None, escribe el basename del audio en el cwd. Si es un directorio existente (o termina en separador), escribe el basename dentro. Si es una ruta de archivo, escribe ahi. keyword-only."
|
||||||
|
- name: outputs
|
||||||
|
desc: "dict de outputs ya obtenido (el que devuelve comfyui_wait_result). Si se pasa, se busca el audio ahi y NO se consulta /history (evita una peticion de red extra). keyword-only."
|
||||||
|
- name: timeout
|
||||||
|
desc: "Timeout de cada peticion HTTP en segundos. keyword-only."
|
||||||
|
output: "dict {ok, path, format, bytes, error}. path = ruta local del archivo de audio guardado, format = extension sin punto (ej. 'flac' o 'mp3'), bytes = bytes descargados. Si falla, ok=False y error explica (sin audio en los outputs, HTTP, conexion o escritura)."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "test_is_audio_item_por_extension"
|
||||||
|
- "test_find_saveaudio_flac_bajo_audio"
|
||||||
|
- "test_find_saveaudiomp3_bajo_audio"
|
||||||
|
- "test_find_prioriza_clave_audio"
|
||||||
|
- "test_find_sin_audio_devuelve_none"
|
||||||
|
test_file_path: "python/functions/ml/tests/test_comfyui_fetch_output_audio.py"
|
||||||
|
file_path: "python/functions/ml/comfyui_fetch_output_audio.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from ml.comfyui_fetch_output_audio import comfyui_fetch_output_audio
|
||||||
|
|
||||||
|
# Tras comfyui_submit_workflow + comfyui_wait_result de un workflow de audio
|
||||||
|
# (ACE-Step, Stable Audio), baja el .flac/.mp3 al disco.
|
||||||
|
res = comfyui_fetch_output_audio("8a278988-8a94-4225-add3-88a406f7101c", dest="/tmp/audios")
|
||||||
|
# res == {"ok": True, "path": "/tmp/audios/comfy_audio_00001_.flac",
|
||||||
|
# "format": "flac", "bytes": 882000, "error": ""}
|
||||||
|
|
||||||
|
# Si ya tienes los outputs de comfyui_wait_result, pasalos y evita re-consultar /history:
|
||||||
|
outputs = {"9": {"audio": [{"filename": "comfy_audio_00001_.flac", "subfolder": "audio", "type": "output"}]}}
|
||||||
|
res2 = comfyui_fetch_output_audio("ignored", dest="/tmp/audios", outputs=outputs)
|
||||||
|
```
|
||||||
|
|
||||||
|
Lánzalo con el python del venv (import de arriba o heredoc). Nota: `./fn run` directo no aplica porque la firma usa `*` (keyword-only), no soportado por el generador de runner de `fn run`.
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Después de generar audio con ComfyUI (música o SFX por texto con ACE-Step, o Stable
|
||||||
|
Audio), cuando necesites el archivo `.flac`/`.wav`/`.mp3`/`.opus` real en disco (no
|
||||||
|
solo su nombre): para reproducirlo, subirlo a un vault, o usarlo como asset de un
|
||||||
|
juego. Es la hermana de `comfyui_fetch_output_video` (vídeo/animación),
|
||||||
|
`comfyui_fetch_output_image` (imágenes) y `comfyui_fetch_output_mesh` (mallas 3D).
|
||||||
|
El builder hermano es `comfyui_build_audio_workflow`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Impura: hace HTTP GET a /history y /view y escribe en disco. Requiere el server
|
||||||
|
vivo y que el prompt YA haya terminado (usa `comfyui_wait_result` antes, o pásale
|
||||||
|
`outputs=`).
|
||||||
|
- Los nodos SaveAudio* exponen el archivo bajo la clave `"audio"` de los outputs
|
||||||
|
(no `"images"` como los de imagen/vídeo). Por eso `comfyui_fetch_output_video` NO
|
||||||
|
sirve para audio: busca extensiones de vídeo y claves gifs/videos/images.
|
||||||
|
- SaveAudio guarda `.flac` por defecto; SaveAudioMP3 `.mp3`, SaveAudioOpus `.opus`.
|
||||||
|
La función cubre todas por extensión.
|
||||||
|
- Toma el PRIMER archivo de audio que encuentra. Si un workflow exporta varios,
|
||||||
|
baja solo uno; para los demás llama otra vez o usa GET /view con el filename concreto.
|
||||||
|
- El history se purga al reiniciar el server: si el prompt ya no está, devuelve
|
||||||
|
`ok=False`. Pasar `outputs=` evita esa consulta y el problema.
|
||||||
|
- `dest` se interpreta: None -> cwd; directorio EXISTENTE -> dentro; ruta de archivo
|
||||||
|
-> esa ruta. Un directorio que aún no existe se trata como ruta de archivo: créalo
|
||||||
|
antes (o termina la ruta en separador).
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Localiza y descarga el output de audio de un workflow ComfyUI a disco.
|
||||||
|
|
||||||
|
Hermana de comfyui_fetch_output_video / comfyui_fetch_output_image / _mesh, pero
|
||||||
|
para los nodos de audio (SaveAudio, SaveAudioMP3, SaveAudioOpus, SaveAudioAdvanced).
|
||||||
|
Esos nodos exponen su salida en GET /history/{prompt_id} bajo la clave "audio"
|
||||||
|
como lista de items {filename, subfolder, type}. Esta funcion localiza el primer
|
||||||
|
archivo con extension de audio (.flac/.wav/.mp3/.opus/.ogg/.m4a), lo baja via
|
||||||
|
GET /view a disco y, opcionalmente, lo escribe en `dest`.
|
||||||
|
|
||||||
|
Impura: red (HTTP GET a /history y /view) + escritura en disco. Solo stdlib.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
# Extensiones de audio que producen los nodos SaveAudio* de ComfyUI.
|
||||||
|
_AUDIO_EXTS = (".flac", ".wav", ".mp3", ".opus", ".ogg", ".m4a")
|
||||||
|
# Claves de output preferentes para audio (se inspeccionan primero).
|
||||||
|
_AUDIO_KEYS = ("audio", "audios")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_audio_item(item: dict) -> bool:
|
||||||
|
"""True si el item de output apunta a un archivo de audio (por extension)."""
|
||||||
|
fn = (item.get("filename") or "").lower()
|
||||||
|
return fn.endswith(_AUDIO_EXTS)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_audio_output(outputs: dict) -> dict | None:
|
||||||
|
"""Busca en los outputs de /history el primer archivo de audio.
|
||||||
|
|
||||||
|
Hace dos pasadas: primero en la clave preferente "audio" (la que usan los
|
||||||
|
nodos SaveAudio*), luego en cualquier clave por si un nodo lo expone bajo
|
||||||
|
otro nombre. Devuelve {filename, subfolder, type} o None.
|
||||||
|
"""
|
||||||
|
for prefer in (True, False):
|
||||||
|
for node_out in outputs.values():
|
||||||
|
if not isinstance(node_out, dict):
|
||||||
|
continue
|
||||||
|
for key, items in node_out.items():
|
||||||
|
if prefer and key not in _AUDIO_KEYS:
|
||||||
|
continue
|
||||||
|
if not isinstance(items, list):
|
||||||
|
continue
|
||||||
|
for item in items:
|
||||||
|
if isinstance(item, dict) and _is_audio_item(item):
|
||||||
|
return {
|
||||||
|
"filename": item.get("filename", ""),
|
||||||
|
"subfolder": item.get("subfolder", ""),
|
||||||
|
"type": item.get("type", "output"),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_dest(dest: str | None, filename: str) -> str:
|
||||||
|
"""Resuelve la ruta local destino a partir de `dest` y el basename remoto."""
|
||||||
|
base = os.path.basename(filename)
|
||||||
|
if dest is None:
|
||||||
|
return os.path.join(os.getcwd(), base)
|
||||||
|
expanded = os.path.expanduser(dest)
|
||||||
|
if os.path.isdir(expanded) or expanded.endswith(os.sep):
|
||||||
|
return os.path.join(expanded, base)
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
|
def comfyui_fetch_output_audio(
|
||||||
|
prompt_id: str,
|
||||||
|
*,
|
||||||
|
server: str = "127.0.0.1:8188",
|
||||||
|
dest: str | None = None,
|
||||||
|
outputs: dict | None = None,
|
||||||
|
timeout: float = 120.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Descarga el audio de un prompt ComfyUI ya ejecutado a disco local.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt_id: id devuelto por comfyui_submit_workflow, de un workflow cuyo
|
||||||
|
nodo de audio (SaveAudio/SaveAudioMP3/...) ya termino (usa
|
||||||
|
comfyui_wait_result antes si dudas). Se ignora si se pasa `outputs`.
|
||||||
|
server: host:port del servidor ComfyUI (sin esquema). keyword-only.
|
||||||
|
dest: ruta destino. Si None, escribe el basename del audio en el cwd.
|
||||||
|
Si es un directorio (o termina en separador), escribe el basename
|
||||||
|
dentro. Si es una ruta de archivo, escribe ahi. keyword-only.
|
||||||
|
outputs: dict de outputs ya obtenido (el que devuelve comfyui_wait_result).
|
||||||
|
Si se pasa, se busca el audio ahi y NO se consulta /history (evita una
|
||||||
|
peticion de red extra justo despues de esperar). keyword-only.
|
||||||
|
timeout: timeout de cada peticion HTTP en segundos. keyword-only.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict {ok, path, format, bytes, error}. path = ruta local del archivo de
|
||||||
|
audio guardado; format = extension sin punto (ej. "flac" o "mp3"); bytes =
|
||||||
|
tamano descargado. Si falla, ok=False y error explica (sin audio en los
|
||||||
|
outputs, HTTP, conexion o escritura).
|
||||||
|
"""
|
||||||
|
# 1. Obtener los outputs: del parametro (sin red) o consultando /history.
|
||||||
|
if outputs is None:
|
||||||
|
hist_url = f"http://{server}/history/{prompt_id}"
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(hist_url, timeout=timeout) as resp:
|
||||||
|
hist = json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
body = exc.read().decode(errors="replace")[:200]
|
||||||
|
return {"ok": False, "path": "", "format": "", "bytes": 0,
|
||||||
|
"error": f"HTTP {exc.code} en {hist_url}: {body}"}
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
return {"ok": False, "path": "", "format": "", "bytes": 0,
|
||||||
|
"error": f"no se pudo conectar a {hist_url}: {exc.reason}"}
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
return {"ok": False, "path": "", "format": "", "bytes": 0,
|
||||||
|
"error": f"respuesta no es JSON valido desde {hist_url}: {exc}"}
|
||||||
|
entry = hist.get(prompt_id)
|
||||||
|
if not entry:
|
||||||
|
return {"ok": False, "path": "", "format": "", "bytes": 0,
|
||||||
|
"error": f"prompt_id {prompt_id} no esta en /history (¿no termino o se purgo?)"}
|
||||||
|
outputs = entry.get("outputs", {})
|
||||||
|
|
||||||
|
audio = _find_audio_output(outputs or {})
|
||||||
|
if audio is None:
|
||||||
|
return {"ok": False, "path": "", "format": "", "bytes": 0,
|
||||||
|
"error": f"sin archivo de audio en los outputs de {prompt_id}"}
|
||||||
|
|
||||||
|
# 2. Descargar el archivo via GET /view.
|
||||||
|
qs = urllib.parse.urlencode({
|
||||||
|
"filename": audio["filename"],
|
||||||
|
"subfolder": audio["subfolder"],
|
||||||
|
"type": audio["type"],
|
||||||
|
})
|
||||||
|
view_url = f"http://{server}/view?{qs}"
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(view_url, timeout=timeout) as resp:
|
||||||
|
blob = resp.read()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
body = exc.read().decode(errors="replace")[:200]
|
||||||
|
return {"ok": False, "path": "", "format": "", "bytes": 0,
|
||||||
|
"error": f"HTTP {exc.code} en {view_url}: {body}"}
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
return {"ok": False, "path": "", "format": "", "bytes": 0,
|
||||||
|
"error": f"no se pudo conectar a {view_url}: {exc.reason}"}
|
||||||
|
|
||||||
|
# 3. Escribir a disco.
|
||||||
|
out_path = _resolve_dest(dest, audio["filename"])
|
||||||
|
try:
|
||||||
|
parent = os.path.dirname(out_path)
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
with open(out_path, "wb") as f:
|
||||||
|
f.write(blob)
|
||||||
|
except OSError as exc:
|
||||||
|
return {"ok": False, "path": "", "format": "", "bytes": 0,
|
||||||
|
"error": f"no se pudo escribir en {out_path!r}: {exc}"}
|
||||||
|
|
||||||
|
fmt = os.path.splitext(audio["filename"])[1].lstrip(".").lower()
|
||||||
|
return {"ok": True, "path": out_path, "format": fmt, "bytes": len(blob), "error": ""}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
pid = sys.argv[1] if len(sys.argv) > 1 else "00000000-0000-0000-0000-000000000000"
|
||||||
|
res = comfyui_fetch_output_audio(pid, dest="/tmp/comfy_audio")
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
@@ -26,9 +26,9 @@ params:
|
|||||||
- name: resample
|
- name: resample
|
||||||
desc: "filtro de reescalado: 'lanczos' (por defecto), 'nearest', 'bilinear', 'bicubic', 'area'. String desconocido -> LANCZOS. keyword-only."
|
desc: "filtro de reescalado: 'lanczos' (por defecto), 'nearest', 'bilinear', 'bicubic', 'area'. String desconocido -> LANCZOS. keyword-only."
|
||||||
output: "dict con ok (bool), out_path (str, ruta del PNG RGB; vacio si error), size ([w,h] final), error (str, vacio si OK)."
|
output: "dict con ok (bool), out_path (str, ruta del PNG RGB; vacio si error), size ([w,h] final), error (str, vacio si OK)."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["aplana transparente sobre blanco -> RGB sin alpha", "color de fondo personalizado", "size redimensiona a cuadrado", "out_path por defecto con sufijo _flat", "error: imagen inexistente", "determinismo (mismos bytes de salida)"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_flatten_alpha_on_color.py"
|
||||||
file_path: "python/functions/ml/comfyui_flatten_alpha_on_color.py"
|
file_path: "python/functions/ml/comfyui_flatten_alpha_on_color.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ params:
|
|||||||
- name: timeout
|
- name: timeout
|
||||||
desc: "Timeout HTTP en segundos. keyword-only."
|
desc: "Timeout HTTP en segundos. keyword-only."
|
||||||
output: "dict {ok, workflow, format_detected, error}. workflow = dict en API format; format_detected = 'api' (passthrough) o 'ui_graph' (convertido) o ''. Si falla la lectura/parse, ok=False y error explica."
|
output: "dict {ok, workflow, format_detected, error}. workflow = dict en API format; format_detected = 'api' (passthrough) o 'ui_graph' (convertido) o ''. Si falla la lectura/parse, ok=False y error explica."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["API format se devuelve tal cual (format=api)", "UI graph se normaliza a API (descarta Note, resuelve conexiones)", "JSON invalido -> error", "formato no reconocido -> error", "JSON no es objeto -> error", "archivo inexistente -> error", "determinismo"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_import_workflow_json.py"
|
||||||
file_path: "python/functions/ml/comfyui_import_workflow_json.py"
|
file_path: "python/functions/ml/comfyui_import_workflow_json.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ lang: py
|
|||||||
domain: ml
|
domain: ml
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "def comfyui_import_workflow_png(png_path_or_url: str, *, timeout: float = 15.0) -> dict"
|
signature: "def comfyui_import_workflow_png(png_path_or_url: str, timeout: float = 15.0) -> dict"
|
||||||
description: "Extrae el workflow embebido en los chunks de texto de un PNG de ComfyUI. Lee el chunk 'prompt' (API format) y/o 'workflow' (UI graph) de los chunks tEXt/zTXt/iTXt con stdlib (struct, zlib). Acepta path local o URL. Impura: red opcional + lectura de disco."
|
description: "Extrae el workflow embebido en los chunks de texto de un PNG de ComfyUI. Lee el chunk 'prompt' (API format) y/o 'workflow' (UI graph) de los chunks tEXt/zTXt/iTXt con stdlib (struct, zlib). Acepta path local o URL. Impura: red opcional + lectura de disco."
|
||||||
tags: [comfyui, ml, import, png, workflow, stable-diffusion]
|
tags: [comfyui, ml, import, png, workflow, stable-diffusion]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ import urllib.request
|
|||||||
import zlib
|
import zlib
|
||||||
|
|
||||||
|
|
||||||
def comfyui_import_workflow_png(png_path_or_url: str, *, timeout: float = 15.0) -> dict:
|
def comfyui_import_workflow_png(png_path_or_url: str, timeout: float = 15.0) -> dict:
|
||||||
"""Devuelve el/los workflow(s) embebido(s) en un PNG de ComfyUI.
|
"""Devuelve el/los workflow(s) embebido(s) en un PNG de ComfyUI.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
png_path_or_url: ruta local de un PNG, o URL http(s) de un PNG.
|
png_path_or_url: ruta local de un PNG, o URL http(s) de un PNG.
|
||||||
timeout: timeout HTTP en segundos (solo si es URL). keyword-only.
|
timeout: timeout HTTP en segundos (solo si es URL).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict {ok, prompt, workflow, format_detected, error}:
|
dict {ok, prompt, workflow, format_detected, error}:
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ name: comfyui_interrupt_queue
|
|||||||
kind: function
|
kind: function
|
||||||
lang: py
|
lang: py
|
||||||
domain: ml
|
domain: ml
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "def comfyui_interrupt_queue(server: str = \"127.0.0.1:8188\") -> dict"
|
signature: "def comfyui_interrupt_queue(clear_pending: bool = False, server: str = \"127.0.0.1:8188\", timeout: float = 10.0) -> dict"
|
||||||
description: "Corta la generacion en curso de ComfyUI (POST /interrupt) y devuelve el estado de la cola (GET /queue). Devuelve {ok, interrupted, queue_running, queue_pending, error}. NO lanza excepcion en fallo de red: degrada a {ok: False, error}. /interrupt corta solo el prompt en ejecucion, no vacia los pendientes. Impura: HTTP POST + GET, solo stdlib (urllib, json)."
|
description: "Corta la generacion en curso de ComfyUI (POST /interrupt) y, si clear_pending=True, vacia ademas la cola de pendientes (POST /queue {\"clear\":true}). Consulta GET /queue al final para reportar queue_remaining. Devuelve {ok, interrupted, cleared, queue_remaining, error}. NO lanza excepcion en fallo de red: degrada a {ok: False, error}. /interrupt corta solo el prompt en ejecucion, no vacia los pendientes salvo clear_pending. Impura: HTTP POST + GET, solo stdlib (urllib, json)."
|
||||||
tags: [comfyui, ml, queue, interrupt, control, http]
|
tags: [comfyui, ml, queue, interrupt, control, http]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
@@ -15,12 +15,16 @@ returns_optional: false
|
|||||||
error_type: "error_go_core"
|
error_type: "error_go_core"
|
||||||
imports: []
|
imports: []
|
||||||
params:
|
params:
|
||||||
|
- name: clear_pending
|
||||||
|
desc: "keyword-only. Si True, ademas de cortar el prompt en ejecucion vacia la cola de pendientes con POST /queue {\"clear\":true}. Default False."
|
||||||
- name: server
|
- name: server
|
||||||
desc: "host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188')."
|
desc: "keyword-only. host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188')."
|
||||||
output: "dict con ok (bool, True si interrupt + lectura de cola OK), interrupted (bool, True si POST /interrupt respondio), queue_running (int, prompts ejecutandose), queue_pending (int, prompts encolados), error (str, vacio si todo OK)."
|
- name: timeout
|
||||||
tested: false
|
desc: "keyword-only. Timeout de cada peticion HTTP en segundos (default 10.0)."
|
||||||
tests: []
|
output: "dict con ok (bool, True si interrupt + clear (si se pidio) + lectura de cola OK), interrupted (bool, True si POST /interrupt respondio), cleared (bool, True si clear_pending y POST /queue {clear:true} respondio; False si no se pidio o fallo), queue_remaining (int, queue_running + queue_pending tras la operacion), error (str, vacio si todo OK)."
|
||||||
test_file_path: ""
|
tested: true
|
||||||
|
tests: ["test_interrumpe_sin_vaciar", "test_clear_pending_vacia_cola", "test_clear_pending_cola_vacia_no_rompe", "test_servidor_caido_no_lanza"]
|
||||||
|
test_file_path: "python/functions/ml/tests/test_comfyui_interrupt_queue.py"
|
||||||
file_path: "python/functions/ml/comfyui_interrupt_queue.py"
|
file_path: "python/functions/ml/comfyui_interrupt_queue.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -31,30 +35,47 @@ import sys, os
|
|||||||
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
from ml.comfyui_interrupt_queue import comfyui_interrupt_queue
|
from ml.comfyui_interrupt_queue import comfyui_interrupt_queue
|
||||||
|
|
||||||
|
# Solo cortar el prompt en ejecucion (los pendientes siguen):
|
||||||
res = comfyui_interrupt_queue()
|
res = comfyui_interrupt_queue()
|
||||||
# {'ok': True, 'interrupted': True, 'queue_running': 0, 'queue_pending': 0, 'error': ''}
|
# {'ok': True, 'interrupted': True, 'cleared': False, 'queue_remaining': 3, 'error': ''}
|
||||||
if res["ok"] and res["interrupted"]:
|
|
||||||
print(f"cortado; pendientes en cola: {res['queue_pending']}")
|
# Cortar el actual Y vaciar los pendientes de golpe:
|
||||||
|
res = comfyui_interrupt_queue(clear_pending=True)
|
||||||
|
# {'ok': True, 'interrupted': True, 'cleared': True, 'queue_remaining': 0, 'error': ''}
|
||||||
|
if res["ok"]:
|
||||||
|
print(f"cortado; quedan {res['queue_remaining']} en cola")
|
||||||
```
|
```
|
||||||
|
|
||||||
O lanzable directo con: `./fn run comfyui_interrupt_queue`.
|
O lanzable directo: `./fn run comfyui_interrupt_queue` · `./fn run comfyui_interrupt_queue --clear`.
|
||||||
|
|
||||||
## Cuando usarla
|
## Cuando usarla
|
||||||
|
|
||||||
Para abortar una generacion que se esta tomando demasiado, que tira de mas VRAM de
|
Para abortar una generacion que se esta tomando demasiado, que tira de mas VRAM de
|
||||||
la prevista, o tras encolar por error un workflow pesado. Tambien para inspeccionar
|
la prevista, o tras encolar por error un workflow pesado. Con `clear_pending=True`
|
||||||
de un vistazo cuanto queda en cola (`queue_running` / `queue_pending`) sin parsear
|
es el freno de mano completo: corta el actual y borra todo lo encolado en una sola
|
||||||
el JSON de /queue a mano. Es el freno de mano del round-trip build -> submit -> wait.
|
llamada (sin tener que encadenar `comfyui_queue_manage("clear")` despues). Tras la
|
||||||
|
operacion `queue_remaining` dice de un vistazo cuanto queda en cola.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- `/interrupt` corta SOLO el prompt en ejecucion; los pendientes (`queue_pending`)
|
- `/interrupt` corta SOLO el prompt en ejecucion; sin `clear_pending` los pendientes
|
||||||
siguen y el siguiente arranca de inmediato. Para vaciar la cola entera hay que
|
(`queue_pending`) siguen y el siguiente arranca de inmediato. Pasa
|
||||||
llamar `POST /queue` con `{"clear": true}` (no lo hace esta funcion — solo corta
|
`clear_pending=True` para vaciar tambien la cola (POST /queue {"clear": true}).
|
||||||
+ lee).
|
|
||||||
- No es idempotente en el sentido de "sin efecto": si hay algo ejecutandose, lo
|
- No es idempotente en el sentido de "sin efecto": si hay algo ejecutandose, lo
|
||||||
mata. Si la cola esta vacia, el interrupt es inocuo (interrupted=True igual).
|
mata. Si la cola esta vacia, tanto el interrupt como el clear son inocuos
|
||||||
|
(`interrupted=True`/`cleared=True` igual, `queue_remaining=0`).
|
||||||
|
- `queue_remaining` se lee al FINAL (GET /queue tras interrupt+clear): es
|
||||||
|
`queue_running + queue_pending`. Justo tras un interrupt sin clear puede ser >0
|
||||||
|
porque el siguiente pendiente ya arranco.
|
||||||
- En fallo de red NO lanza: devuelve `ok=False` con el mensaje en `error`. Comprueba
|
- En fallo de red NO lanza: devuelve `ok=False` con el mensaje en `error`. Comprueba
|
||||||
`ok` antes de fiarte de los conteos.
|
`ok` antes de fiarte de `queue_remaining`.
|
||||||
- Tras el interrupt conviene liberar VRAM con `POST /free` si vas a encolar otro
|
- Tras el interrupt conviene liberar VRAM con `POST /free` si vas a encolar otro
|
||||||
trabajo pesado (esta funcion no lo hace).
|
trabajo pesado (esta funcion no lo hace; ver el round-trip build -> submit -> wait).
|
||||||
|
- Para operaciones de cola mas finas (borrar UN prompt por id, contar el historial)
|
||||||
|
usa `comfyui_queue_manage`; esta funcion se centra en el interrupt + clear masivo.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-28) — anade flag `clear_pending` (vacia la cola en la misma
|
||||||
|
llamada) + param `timeout`; el output pasa a {ok, interrupted, cleared,
|
||||||
|
queue_remaining, error} y se anaden tests (mock HTTP local).
|
||||||
|
|||||||
@@ -1,38 +1,52 @@
|
|||||||
"""Interrumpe la generacion en curso de ComfyUI y devuelve el estado de la cola.
|
"""Interrumpe la generacion en curso de ComfyUI y, opcionalmente, vacia la cola.
|
||||||
|
|
||||||
Funcion impura: hace red (HTTP POST /interrupt + GET /queue). Solo stdlib.
|
Funcion impura: hace red (HTTP POST /interrupt, POST /queue, GET /queue). Solo
|
||||||
|
stdlib (urllib, json).
|
||||||
|
|
||||||
POST /interrupt corta el prompt que ComfyUI esta ejecutando ahora mismo (no vacia
|
POST /interrupt corta el prompt que ComfyUI esta ejecutando ahora mismo: NO vacia
|
||||||
la cola: los prompts pendientes siguen). GET /queue devuelve queue_running (lo que
|
los pendientes, solo aborta el actual y el siguiente arranca de inmediato. Para
|
||||||
se ejecuta) y queue_pending (lo encolado). Esta funcion combina ambos en un dict
|
vaciar de golpe los pendientes hay que ademas hacer POST /queue con {"clear": true}
|
||||||
honesto que NO lanza excepcion en fallo de red: devuelve {ok: False, error}.
|
(lo que activa el flag clear_pending). GET /queue se consulta al final para reportar
|
||||||
|
cuantos trabajos quedan en cola tras la operacion (queue_remaining).
|
||||||
|
|
||||||
|
NO lanza excepcion en fallo de red: devuelve un dict de estado {ok: False, error}.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
def comfyui_interrupt_queue(server: str = "127.0.0.1:8188") -> dict:
|
def comfyui_interrupt_queue(
|
||||||
"""Interrumpe la generacion en curso y devuelve el estado de la cola.
|
clear_pending: bool = False,
|
||||||
|
server: str = "127.0.0.1:8188",
|
||||||
|
timeout: float = 10.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Corta la generacion en curso de ComfyUI y devuelve el estado de la cola.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
clear_pending: si True, ademas de cortar el prompt en ejecucion vacia la
|
||||||
|
cola de pendientes con POST /queue {"clear": true}. keyword-only.
|
||||||
server: host:port del servidor ComfyUI sin esquema (default
|
server: host:port del servidor ComfyUI sin esquema (default
|
||||||
"127.0.0.1:8188").
|
"127.0.0.1:8188"). keyword-only.
|
||||||
|
timeout: timeout de cada peticion HTTP en segundos (default 10.0).
|
||||||
|
keyword-only.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict con:
|
dict con:
|
||||||
- ok (bool): True si tanto el interrupt como la lectura de la cola
|
- ok (bool): True si el interrupt, la lectura de la cola y (si se pidio)
|
||||||
tuvieron exito.
|
el clear tuvieron exito.
|
||||||
- interrupted (bool): True si el POST /interrupt respondio sin error.
|
- interrupted (bool): True si el POST /interrupt respondio sin error.
|
||||||
- queue_running (int): numero de prompts ejecutandose ahora mismo.
|
- cleared (bool): True si clear_pending era True y el POST /queue
|
||||||
- queue_pending (int): numero de prompts encolados pendientes.
|
{"clear": true} respondio sin error; False si no se pidio o fallo.
|
||||||
|
- queue_remaining (int): trabajos que quedan en cola tras la operacion
|
||||||
|
(queue_running + queue_pending segun GET /queue al final).
|
||||||
- error (str): mensaje de error si algo fallo; cadena vacia si todo OK.
|
- error (str): mensaje de error si algo fallo; cadena vacia si todo OK.
|
||||||
"""
|
"""
|
||||||
out = {
|
out = {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"interrupted": False,
|
"interrupted": False,
|
||||||
"queue_running": 0,
|
"cleared": False,
|
||||||
"queue_pending": 0,
|
"queue_remaining": 0,
|
||||||
"error": "",
|
"error": "",
|
||||||
}
|
}
|
||||||
base = f"http://{server}"
|
base = f"http://{server}"
|
||||||
@@ -40,19 +54,37 @@ def comfyui_interrupt_queue(server: str = "127.0.0.1:8188") -> dict:
|
|||||||
# 1. POST /interrupt (cuerpo vacio): corta el prompt en ejecucion.
|
# 1. POST /interrupt (cuerpo vacio): corta el prompt en ejecucion.
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(f"{base}/interrupt", data=b"", method="POST")
|
req = urllib.request.Request(f"{base}/interrupt", data=b"", method="POST")
|
||||||
with urllib.request.urlopen(req, timeout=10.0):
|
with urllib.request.urlopen(req, timeout=timeout):
|
||||||
out["interrupted"] = True
|
out["interrupted"] = True
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
reason = getattr(exc, "reason", exc)
|
reason = getattr(exc, "reason", exc)
|
||||||
out["error"] = f"interrupt fallo: no se pudo conectar a {base}/interrupt: {reason}"
|
out["error"] = f"interrupt fallo: no se pudo conectar a {base}/interrupt: {reason}"
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# 2. GET /queue: estado actual de la cola tras el interrupt.
|
# 2. Opcional: POST /queue {"clear": true} para vaciar los pendientes.
|
||||||
|
if clear_pending:
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(f"{base}/queue", timeout=10.0) as resp:
|
payload = json.dumps({"clear": True}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{base}/queue",
|
||||||
|
data=payload,
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout):
|
||||||
|
out["cleared"] = True
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
reason = getattr(exc, "reason", exc)
|
||||||
|
out["error"] = f"clear fallo: no se pudo conectar a {base}/queue: {reason}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
# 3. GET /queue: cuantos trabajos quedan en cola tras la operacion.
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"{base}/queue", timeout=timeout) as resp:
|
||||||
data = json.loads(resp.read())
|
data = json.loads(resp.read())
|
||||||
out["queue_running"] = len(data.get("queue_running", []))
|
running = len(data.get("queue_running", []))
|
||||||
out["queue_pending"] = len(data.get("queue_pending", []))
|
pending = len(data.get("queue_pending", []))
|
||||||
|
out["queue_remaining"] = running + pending
|
||||||
out["ok"] = True
|
out["ok"] = True
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
reason = getattr(exc, "reason", exc)
|
reason = getattr(exc, "reason", exc)
|
||||||
@@ -63,9 +95,12 @@ def comfyui_interrupt_queue(server: str = "127.0.0.1:8188") -> dict:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
res = comfyui_interrupt_queue()
|
import sys
|
||||||
|
|
||||||
|
clear = "--clear" in sys.argv[1:]
|
||||||
|
res = comfyui_interrupt_queue(clear_pending=clear)
|
||||||
print(
|
print(
|
||||||
f"ok={res['ok']} interrupted={res['interrupted']} "
|
f"ok={res['ok']} interrupted={res['interrupted']} "
|
||||||
f"running={res['queue_running']} pending={res['queue_pending']} "
|
f"cleared={res['cleared']} queue_remaining={res['queue_remaining']} "
|
||||||
f"error={res['error']!r}"
|
f"error={res['error']!r}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ params:
|
|||||||
- name: venv_python
|
- name: venv_python
|
||||||
desc: "Python del venv ComfyUI para los jueces estetico/fidelidad. keyword-only."
|
desc: "Python del venv ComfyUI para los jueces estetico/fidelidad. keyword-only."
|
||||||
output: "dict {ok, verdict, score, votes, reasons, error, details}. verdict 'good'|'bad' por mayoria; score media ponderada 0-10 de los jueces vivos; votes = {clip, aesthetic, llm} cada uno 'good'|'bad'|'failed'; reasons agrega razones del critico + notas de jueces caidos; details lleva el dict crudo de cada juez. ok=False solo si los tres fallan."
|
output: "dict {ok, verdict, score, votes, reasons, error, details}. verdict 'good'|'bad' por mayoria; score media ponderada 0-10 de los jueces vivos; votes = {clip, aesthetic, llm} cada uno 'good'|'bad'|'failed'; reasons agrega razones del critico + notas de jueces caidos; details lleva el dict crudo de cada juez. ok=False solo si los tres fallan."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["tres votos good -> verdict good + score medio", "mayoria bad", "empate -> bad conservador", "juez caido se excluye sin crashear", "los tres jueces fallan -> ok=False", "weights afectan score pero no el voto"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_judge_image.py"
|
||||||
file_path: "python/functions/ml/comfyui_judge_image.py"
|
file_path: "python/functions/ml/comfyui_judge_image.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ lang: py
|
|||||||
domain: ml
|
domain: ml
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "def comfyui_list_skills(*, library_dir: str = None, include_nsfw: bool = False) -> dict"
|
signature: "def comfyui_list_skills(library_dir: str = None, include_nsfw: bool = False) -> dict"
|
||||||
description: "Lista las skills ComfyUI guardadas en la libreria de disco con su metadata de resumen: slug, title, base_workflow, version, score_mean/score_n y nsfw (de provenance.nsfw), mas n_versions. Respeta include_nsfw=False (oculta las NSFW por defecto). Libreria inexistente o vacia -> lista vacia sin error. library_dir default ~/ComfyUI/skills_library."
|
description: "Lista las skills ComfyUI guardadas en la libreria de disco con su metadata de resumen: slug, title, base_workflow, version, score_mean/score_n y nsfw (de provenance.nsfw), mas n_versions. Respeta include_nsfw=False (oculta las NSFW por defecto). Libreria inexistente o vacia -> lista vacia sin error. library_dir default ~/ComfyUI/skills_library."
|
||||||
error_type: error_go_core
|
error_type: error_go_core
|
||||||
tags: [comfyui, comfyui-skill, ml, skill, library]
|
tags: [comfyui, comfyui-skill, ml, skill, library]
|
||||||
|
|||||||
@@ -28,13 +28,12 @@ def _n_versions(skill_dir):
|
|||||||
if f.startswith("v") and f.endswith(".json")])
|
if f.startswith("v") and f.endswith(".json")])
|
||||||
|
|
||||||
|
|
||||||
def comfyui_list_skills(*, library_dir: str = None, include_nsfw: bool = False) -> dict:
|
def comfyui_list_skills(library_dir: str = None, include_nsfw: bool = False) -> dict:
|
||||||
"""Lista las skills de la libreria con su metadata de resumen.
|
"""Lista las skills de la libreria con su metadata de resumen.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`. keyword-only.
|
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`.
|
||||||
include_nsfw: si False (default), oculta las skills con `provenance.nsfw == True`.
|
include_nsfw: si False (default), oculta las skills con `provenance.nsfw == True`.
|
||||||
keyword-only.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict ``{ok, skills, count, error}`` donde `skills` es una lista de dicts
|
dict ``{ok, skills, count, error}`` donde `skills` es una lista de dicts
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
name: comfyui_list_templates
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: ml
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def comfyui_list_templates(comfyui_python: str | None = None, bundle: str | None = None, name_filter: str | None = None, with_nodes: bool = True, workflows_only: bool = True, limit: int = 0) -> dict"
|
||||||
|
description: "Lista los workflow templates oficiales del paquete pip comfyui-workflow-templates (los del menu 'Browse Templates' del frontend de ComfyUI). Devuelve nombre, bundle/categoria, path en disco, n_nodes y node_types (class_types reales, aplanando subgrafos y descartando los UUID de instancia). Localiza el interprete de ComfyUI y usa su API oficial via subprocess (el paquete vive en el venv de ComfyUI, no en el del registry). Impura: lee disco. Filtra entradas no-workflow (index*/localizacion) por defecto."
|
||||||
|
tags: [comfyui, ml, templates, workflow, discovery]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: comfyui_python
|
||||||
|
desc: "Ruta al interprete python de ComfyUI con el paquete comfyui-workflow-templates instalado. None autodetecta (env COMFYUI_PYTHON, ~/ComfyUI/.venv/bin/python, ~/ComfyUI/venv/bin/python)."
|
||||||
|
- name: bundle
|
||||||
|
desc: "Filtra por bundle exacto: 'media-api', 'media-image', 'media-video' o 'media-other'. None = todos."
|
||||||
|
- name: name_filter
|
||||||
|
desc: "Subcadena (case-insensitive) que debe contener el nombre del template. None = sin filtro."
|
||||||
|
- name: with_nodes
|
||||||
|
desc: "True (default) incluye node_types en cada registro; False los omite (registros mas ligeros)."
|
||||||
|
- name: workflows_only
|
||||||
|
desc: "True (default) excluye entradas que no son grafos de workflow (ficheros index*/localizacion del paquete)."
|
||||||
|
- name: limit
|
||||||
|
desc: "Si > 0, trunca a los primeros N templates tras filtrar y ordenar por nombre."
|
||||||
|
output: "dict {ok: bool, count: int, package_version: str, templates: list, error: str}. Cada template: {name, category, bundle, version, path, n_nodes, node_types, is_workflow}. Nunca lanza: paquete ausente o interprete no hallado -> ok=False con error legible que indica como instalar (pip install comfyui-workflow-templates)."
|
||||||
|
tested: true
|
||||||
|
tests:
|
||||||
|
- "_find_comfyui_python: interprete existente se devuelve tal cual"
|
||||||
|
- "_find_comfyui_python: ruta inexistente cae al fallback (sys.executable)"
|
||||||
|
- "sin el paquete instalado -> ok=False con error que menciona comfyui-workflow-templates"
|
||||||
|
- "el dict de retorno conserva todas sus claves aun en fallo"
|
||||||
|
- "golden (skip si no hay ComfyUI con el paquete): catalogo no vacio, cada template con name+bundle"
|
||||||
|
- "golden (skip si no hay ComfyUI con el paquete): bundle inexistente filtra a lista vacia con ok=True"
|
||||||
|
test_file_path: "python/functions/ml/tests/test_comfyui_list_templates.py"
|
||||||
|
file_path: "python/functions/ml/comfyui_list_templates.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lanzable directo (muestra version del paquete + 15 primeros con sus node_types):
|
||||||
|
./fn run comfyui_list_templates
|
||||||
|
|
||||||
|
# Filtrado por bundle de imagen, sin abrir node_types, primeros 20:
|
||||||
|
python/.venv/bin/python3 python/functions/ml/comfyui_list_templates.py \
|
||||||
|
--bundle media-image --no-nodes --limit 20
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from ml.comfyui_list_templates import comfyui_list_templates
|
||||||
|
|
||||||
|
res = comfyui_list_templates(name_filter="sdxl")
|
||||||
|
print(res["count"], "templates SDXL") # p.ej. 4
|
||||||
|
for t in res["templates"]:
|
||||||
|
print(t["name"], t["n_nodes"], t["node_types"][:3])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Para descubrir que workflow templates oficiales trae ComfyUI sin abrir la UI:
|
||||||
|
explorar el catalogo, filtrar por bundle/nombre, o saber que `node_types` usa cada
|
||||||
|
template antes de extraerlo con `comfyui_extract_template`. Primer paso del flujo
|
||||||
|
listar -> extraer -> (cargar en UI / convertir a API).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- El paquete `comfyui-workflow-templates` vive en el venv de ComfyUI, NO en el del
|
||||||
|
registry. La funcion no lo importa: localiza el python de ComfyUI y corre su API
|
||||||
|
oficial en un subprocess. Si no encuentra ese interprete (o el paquete no esta
|
||||||
|
instalado) devuelve `ok=False` con un error que dice como instalarlo. No lanza.
|
||||||
|
- Desde la 0.10.x el paquete es multi-bundle y ya NO expone una carpeta `templates/`
|
||||||
|
unica (la API antigua `get_templates_path()` lanza a proposito). Por eso se usa
|
||||||
|
`comfyui_workflow_templates_core` (`load_manifest`/`get_asset_path`).
|
||||||
|
- `node_types` aplana los subgrafos de `definitions` y descarta los `type` que son
|
||||||
|
UUID (instancias de subgraph), para mostrar class_types reales (KSampler, CLIPLoader,
|
||||||
|
…) en vez de identificadores opacos. `n_nodes` cuenta solo los nodos top-level.
|
||||||
|
- `workflows_only=True` (default) excluye ~16 entradas `index*` que son metadata de
|
||||||
|
localizacion del frontend, no grafos. Pasa `workflows_only=False` (o `--all` en CLI)
|
||||||
|
para verlas.
|
||||||
|
- Impura: abre cada `.json` en disco (≈451 ficheros pequeños, ~0.2s). No toca red ni
|
||||||
|
arranca GPU.
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""Lista los workflow templates oficiales que trae el paquete comfyui-workflow-templates.
|
||||||
|
|
||||||
|
Funcion impura: lee disco (los .json de los templates instalados) ejecutando la
|
||||||
|
API oficial del paquete dentro del interprete de ComfyUI.
|
||||||
|
|
||||||
|
ComfyUI 0.26+ distribuye los templates oficiales (los del menu "Browse Templates"
|
||||||
|
del frontend) en el paquete pip `comfyui-workflow-templates`, que desde la 0.10.x es
|
||||||
|
un meta-paquete multi-bundle: ya NO expone una carpeta `templates/` unica, sino una
|
||||||
|
API en `comfyui_workflow_templates_core` (`load_manifest`, `iter_templates`,
|
||||||
|
`get_asset_path`). Cada template es un grafo de nodos en formato UI (nodes/links con
|
||||||
|
posiciones), agrupado en uno de cuatro bundles: media-api, media-image, media-video,
|
||||||
|
media-other.
|
||||||
|
|
||||||
|
Como el paquete vive en el venv de ComfyUI (no en el del registry), esta funcion no
|
||||||
|
lo importa directamente: localiza el interprete de ComfyUI y le pasa un script que usa
|
||||||
|
la API oficial y vuelca el catalogo como JSON. Asi es robusta ante cambios de la
|
||||||
|
estructura interna del paquete.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
# Script que corre DENTRO del python de ComfyUI. Usa la API oficial del paquete y
|
||||||
|
# vuelca el catalogo (metadata + node_types por template) como una linea JSON.
|
||||||
|
_DUMP_SCRIPT = r"""
|
||||||
|
import json, sys, re
|
||||||
|
try:
|
||||||
|
import comfyui_workflow_templates_core as core
|
||||||
|
except Exception as exc:
|
||||||
|
print(json.dumps({"__err__": "import", "msg": str(exc)}))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
||||||
|
|
||||||
|
def _collect_types(graph):
|
||||||
|
# Recoge class_types reales: aplana los subgrafos de definitions y descarta los
|
||||||
|
# type que son UUID (instancias de subgraph, cuyo contenido real ya se incluye).
|
||||||
|
types = set()
|
||||||
|
if isinstance(graph, dict) and isinstance(graph.get("nodes"), list):
|
||||||
|
for n in graph["nodes"]:
|
||||||
|
if isinstance(n, dict) and n.get("type") and not _UUID_RE.match(str(n["type"])):
|
||||||
|
types.add(n["type"])
|
||||||
|
defs = graph.get("definitions")
|
||||||
|
if isinstance(defs, dict) and isinstance(defs.get("subgraphs"), list):
|
||||||
|
for sg in defs["subgraphs"]:
|
||||||
|
for n in (sg.get("nodes") or []) if isinstance(sg, dict) else []:
|
||||||
|
if isinstance(n, dict) and n.get("type") and not _UUID_RE.match(str(n["type"])):
|
||||||
|
types.add(n["type"])
|
||||||
|
return len(graph["nodes"]), sorted(types)
|
||||||
|
if isinstance(graph, dict): # API format
|
||||||
|
for v in graph.values():
|
||||||
|
if isinstance(v, dict) and v.get("class_type"):
|
||||||
|
types.add(v["class_type"])
|
||||||
|
if types:
|
||||||
|
return len(graph), sorted(types)
|
||||||
|
return 0, []
|
||||||
|
|
||||||
|
WITH_NODES = {with_nodes}
|
||||||
|
m = core.load_manifest()
|
||||||
|
try:
|
||||||
|
import importlib.metadata as _md
|
||||||
|
pkg_version = _md.version("comfyui-workflow-templates")
|
||||||
|
except Exception:
|
||||||
|
pkg_version = ""
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for tid, entry in m.templates.items():
|
||||||
|
json_asset = next(
|
||||||
|
(a.filename for a in entry.assets if a.filename.endswith(".json")), None
|
||||||
|
)
|
||||||
|
path = core.get_asset_path(tid, json_asset) if json_asset else ""
|
||||||
|
rec = {
|
||||||
|
"name": tid,
|
||||||
|
"bundle": entry.bundle,
|
||||||
|
"category": entry.bundle,
|
||||||
|
"version": entry.version,
|
||||||
|
"path": path,
|
||||||
|
"n_nodes": 0,
|
||||||
|
"node_types": [],
|
||||||
|
}
|
||||||
|
rec["is_workflow"] = False
|
||||||
|
if path:
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
graph = json.load(fh)
|
||||||
|
n_nodes, node_types = _collect_types(graph)
|
||||||
|
is_api = isinstance(graph, dict) and any(
|
||||||
|
isinstance(v, dict) and v.get("class_type") for v in graph.values()
|
||||||
|
)
|
||||||
|
rec["is_workflow"] = bool(
|
||||||
|
(isinstance(graph, dict) and isinstance(graph.get("nodes"), list) and graph["nodes"])
|
||||||
|
or is_api
|
||||||
|
)
|
||||||
|
rec["n_nodes"] = n_nodes
|
||||||
|
if WITH_NODES:
|
||||||
|
rec["node_types"] = node_types
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
out.append(rec)
|
||||||
|
|
||||||
|
print(json.dumps({"package_version": pkg_version, "templates": out}))
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _find_comfyui_python(explicit: str | None) -> str | None:
|
||||||
|
"""Devuelve la ruta a un interprete de ComfyUI que tenga el paquete instalado.
|
||||||
|
|
||||||
|
Orden de busqueda: argumento explicito -> env COMFYUI_PYTHON -> candidatos
|
||||||
|
habituales (~/ComfyUI/.venv, ~/ComfyUI/venv) -> el python actual. Devuelve None
|
||||||
|
si ninguno existe en disco.
|
||||||
|
"""
|
||||||
|
candidates = []
|
||||||
|
if explicit:
|
||||||
|
candidates.append(os.path.expanduser(explicit))
|
||||||
|
env = os.environ.get("COMFYUI_PYTHON")
|
||||||
|
if env:
|
||||||
|
candidates.append(os.path.expanduser(env))
|
||||||
|
candidates += [
|
||||||
|
os.path.expanduser("~/ComfyUI/.venv/bin/python"),
|
||||||
|
os.path.expanduser("~/ComfyUI/venv/bin/python"),
|
||||||
|
os.path.expanduser("~/comfyui/.venv/bin/python"),
|
||||||
|
sys.executable,
|
||||||
|
]
|
||||||
|
for c in candidates:
|
||||||
|
if c and os.path.isfile(c):
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def comfyui_list_templates(
|
||||||
|
comfyui_python: str | None = None,
|
||||||
|
bundle: str | None = None,
|
||||||
|
name_filter: str | None = None,
|
||||||
|
with_nodes: bool = True,
|
||||||
|
workflows_only: bool = True,
|
||||||
|
limit: int = 0,
|
||||||
|
) -> dict:
|
||||||
|
"""Lista los templates oficiales de ComfyUI con su grafo de nodos.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comfyui_python: ruta al interprete python de ComfyUI que tiene instalado
|
||||||
|
el paquete comfyui-workflow-templates. Si None, se autodetecta (env
|
||||||
|
COMFYUI_PYTHON o ~/ComfyUI/.venv/bin/python).
|
||||||
|
bundle: si se da, filtra por bundle exacto ("media-api", "media-image",
|
||||||
|
"media-video", "media-other").
|
||||||
|
name_filter: si se da, filtra a templates cuyo nombre contenga esta
|
||||||
|
subcadena (case-insensitive).
|
||||||
|
with_nodes: si True (default) incluye node_types en cada registro. Si
|
||||||
|
False los omite (registros mas ligeros).
|
||||||
|
workflows_only: si True (default) excluye entradas que no son grafos de
|
||||||
|
workflow (ficheros index*/localizacion del paquete).
|
||||||
|
limit: si > 0, trunca la lista a los primeros N tras filtrar.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict {ok, count, package_version, templates, error}:
|
||||||
|
- templates: lista de {name, category, bundle, version, path, n_nodes,
|
||||||
|
node_types} ordenada por name.
|
||||||
|
- count: numero de templates devueltos (tras filtros y limit).
|
||||||
|
Nunca lanza: cualquier fallo (paquete ausente, interprete no hallado)
|
||||||
|
devuelve ok=False con un error legible.
|
||||||
|
"""
|
||||||
|
py = _find_comfyui_python(comfyui_python)
|
||||||
|
if not py:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"count": 0,
|
||||||
|
"package_version": "",
|
||||||
|
"templates": [],
|
||||||
|
"error": (
|
||||||
|
"no se encontro un interprete de ComfyUI. Pasa comfyui_python=... "
|
||||||
|
"o define COMFYUI_PYTHON. El paquete se instala con: "
|
||||||
|
"pip install comfyui-workflow-templates"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
script = _DUMP_SCRIPT.replace("{with_nodes}", "True" if with_nodes else "False")
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[py, "-c", script],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"count": 0,
|
||||||
|
"package_version": "",
|
||||||
|
"templates": [],
|
||||||
|
"error": f"fallo al ejecutar el interprete de ComfyUI ({py}): {exc}",
|
||||||
|
}
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"count": 0,
|
||||||
|
"package_version": "",
|
||||||
|
"templates": [],
|
||||||
|
"error": f"el interprete de ComfyUI fallo: {proc.stderr.strip()[:500]}",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(proc.stdout.strip().splitlines()[-1])
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"count": 0,
|
||||||
|
"package_version": "",
|
||||||
|
"templates": [],
|
||||||
|
"error": f"salida no parseable del interprete de ComfyUI: {exc}",
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.get("__err__") == "import":
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"count": 0,
|
||||||
|
"package_version": "",
|
||||||
|
"templates": [],
|
||||||
|
"error": (
|
||||||
|
"el paquete comfyui-workflow-templates no esta instalado en "
|
||||||
|
f"{py} ({data.get('msg', '')}). Instalalo con: "
|
||||||
|
"pip install comfyui-workflow-templates"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
templates = data.get("templates", [])
|
||||||
|
if workflows_only:
|
||||||
|
templates = [t for t in templates if t.get("is_workflow")]
|
||||||
|
if bundle:
|
||||||
|
templates = [t for t in templates if t.get("bundle") == bundle]
|
||||||
|
if name_filter:
|
||||||
|
nf = name_filter.lower()
|
||||||
|
templates = [t for t in templates if nf in t.get("name", "").lower()]
|
||||||
|
templates.sort(key=lambda t: t.get("name", ""))
|
||||||
|
if limit and limit > 0:
|
||||||
|
templates = templates[:limit]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"count": len(templates),
|
||||||
|
"package_version": data.get("package_version", ""),
|
||||||
|
"templates": templates,
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser(description="Lista templates oficiales de ComfyUI")
|
||||||
|
ap.add_argument("--comfyui-python", default=None)
|
||||||
|
ap.add_argument("--bundle", default=None)
|
||||||
|
ap.add_argument("--name-filter", default=None)
|
||||||
|
ap.add_argument("--no-nodes", action="store_true", help="omite node_types")
|
||||||
|
ap.add_argument("--all", action="store_true", help="incluye entradas no-workflow (index*)")
|
||||||
|
ap.add_argument("--limit", type=int, default=0)
|
||||||
|
ap.add_argument("--full", action="store_true", help="dump completo (todos los node_types)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
res = comfyui_list_templates(
|
||||||
|
args.comfyui_python,
|
||||||
|
bundle=args.bundle,
|
||||||
|
name_filter=args.name_filter,
|
||||||
|
with_nodes=not args.no_nodes,
|
||||||
|
workflows_only=not args.all,
|
||||||
|
limit=args.limit,
|
||||||
|
)
|
||||||
|
if args.full or not res["ok"]:
|
||||||
|
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"ok": res["ok"],
|
||||||
|
"count": res["count"],
|
||||||
|
"package_version": res["package_version"],
|
||||||
|
"sample": res["templates"][:15],
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -5,7 +5,7 @@ lang: py
|
|||||||
domain: ml
|
domain: ml
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "def comfyui_load_skill(slug: str, *, version=None, library_dir: str = None) -> dict"
|
signature: "def comfyui_load_skill(slug: str, version=None, library_dir: str = None) -> dict"
|
||||||
description: "Lee una receta de skill ComfyUI de la libreria de disco: recipe.json (version actual) o un snapshot versions/vN.json. Hermana inversa de comfyui_save_skill; el round-trip save(recipe)->load(slug) devuelve un dict identico. library_dir default ~/ComfyUI/skills_library. Slug, version o archivo inexistente -> {ok:False} sin lanzar."
|
description: "Lee una receta de skill ComfyUI de la libreria de disco: recipe.json (version actual) o un snapshot versions/vN.json. Hermana inversa de comfyui_save_skill; el round-trip save(recipe)->load(slug) devuelve un dict identico. library_dir default ~/ComfyUI/skills_library. Slug, version o archivo inexistente -> {ok:False} sin lanzar."
|
||||||
error_type: error_go_core
|
error_type: error_go_core
|
||||||
tags: [comfyui, comfyui-skill, ml, skill, library]
|
tags: [comfyui, comfyui-skill, ml, skill, library]
|
||||||
|
|||||||
@@ -36,14 +36,14 @@ def _version_filename(version):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def comfyui_load_skill(slug: str, *, version=None, library_dir: str = None) -> dict:
|
def comfyui_load_skill(slug: str, version=None, library_dir: str = None) -> dict:
|
||||||
"""Lee la receta de una skill (version actual o un snapshot concreto).
|
"""Lee la receta de una skill (version actual o un snapshot concreto).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
slug: slug de la skill (nombre de su carpeta en la libreria).
|
slug: slug de la skill (nombre de su carpeta en la libreria).
|
||||||
version: si None, lee `recipe.json` (version actual). Si se pasa (int, "1" o
|
version: si None, lee `recipe.json` (version actual). Si se pasa (int, "1" o
|
||||||
"v1"), lee el snapshot `versions/vN.json`. keyword-only.
|
"v1"), lee el snapshot `versions/vN.json`.
|
||||||
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`. keyword-only.
|
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict ``{ok, recipe, slug, path, version, error}``. En exito ``ok=True`` y `recipe`
|
dict ``{ok, recipe, slug, path, version, error}``. En exito ``ok=True`` y `recipe`
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ name: comfyui_pixelize_image
|
|||||||
kind: function
|
kind: function
|
||||||
lang: py
|
lang: py
|
||||||
domain: ml
|
domain: ml
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "def comfyui_pixelize_image(src_path: str, dst_path: str, *, downscale: int = 8, colors: int = 16, palette=None, dither: bool = False, upscale_back: bool = True) -> dict"
|
signature: "def comfyui_pixelize_image(src_path: str, dst_path: str, *, downscale: int = 8, colors: int = 16, palette=None, dither: bool = False, upscale_back: bool = True, keep_alpha: bool = True, alpha_threshold: int = 128) -> dict"
|
||||||
description: "Post-proceso pixel-perfect (Fase 2 pixelart): imagen -> downscale nearest-neighbor por factor (colapsa cada bloque borroso a un pixel duro) -> cuantizacion a N colores (MEDIANCUT) o a una paleta fija embebida (game-boy / pico-8 / nes / lista de hex) -> opcional re-upscale nearest conservando los pixeles duros. Convierte el 'pixelart borroso de IA' en pixelart de verdad. Nucleo PIL puro, CPU-only: sin GPU, sin red. Devuelve {ok, out_path, size, n_colors_final, error}. Impura solo por la lectura/escritura de disco."
|
description: "Post-proceso pixel-perfect (Fase 2 pixelart): imagen -> downscale nearest-neighbor por factor (colapsa cada bloque borroso a un pixel duro) -> cuantizacion a N colores (MEDIANCUT) o a una paleta fija embebida (game-boy / pico-8 / nes / lista de hex) -> opcional re-upscale nearest conservando los pixeles duros. Alpha-aware: si la entrada es RGBA y keep_alpha, cuantiza SOLO el RGB (el fondo transparente no entra en la paleta) y preserva/binariza el alpha por separado -> PNG RGBA con transparencia real. Convierte el 'pixelart borroso de IA' en pixelart de verdad. Nucleo PIL puro, CPU-only: sin GPU, sin red. Devuelve {ok, out_path, size, n_colors_final, has_alpha, error}. Impura solo por la lectura/escritura de disco."
|
||||||
tags: [comfyui, gamedev-2d, pixelart, ml, pil, quantize, palette, image]
|
tags: [comfyui, gamedev-2d, pixelart, ml, pil, quantize, palette, image, alpha, transparent]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -29,9 +29,13 @@ params:
|
|||||||
desc: "aplica Floyd-Steinberg al cuantizar (off por defecto = pixelart limpio). keyword-only."
|
desc: "aplica Floyd-Steinberg al cuantizar (off por defecto = pixelart limpio). keyword-only."
|
||||||
- name: upscale_back
|
- name: upscale_back
|
||||||
desc: "re-escala nearest al tamano original (preview con pixeles duros). False deja la imagen pequena. keyword-only."
|
desc: "re-escala nearest al tamano original (preview con pixeles duros). False deja la imagen pequena. keyword-only."
|
||||||
output: "dict con ok (bool), out_path (str), size ([w,h] de la imagen final), n_colors_final (int, colores distintos del resultado), error (str, vacio si OK)."
|
- name: keep_alpha
|
||||||
|
desc: "si True (default) y la entrada tiene canal alpha, preserva la transparencia: cuantiza solo el RGB y downscalea/binariza el alpha aparte -> PNG RGBA. Sin efecto si la imagen no tiene alpha (sale RGB igual que antes). keyword-only."
|
||||||
|
- name: alpha_threshold
|
||||||
|
desc: "umbral (0..255) para binarizar el alpha en opaco (255) o transparente (0). Solo aplica cuando se preserva el alpha. keyword-only."
|
||||||
|
output: "dict con ok (bool), out_path (str), size ([w,h] de la imagen final), n_colors_final (int, colores RGB distintos; en la zona opaca si es RGBA), has_alpha (bool, True si la salida es RGBA), error (str, vacio si OK)."
|
||||||
tested: true
|
tested: true
|
||||||
tests: [test_golden_downscale_quantize, test_no_upscale_back_keeps_small, test_edge_fixed_palette_game_boy, test_edge_palette_list_hex, test_edge_downscale_1_only_quantizes, test_error_missing_src, test_error_downscale_zero, test_error_bad_palette]
|
tests: [test_golden_downscale_quantize, test_no_upscale_back_keeps_small, test_edge_fixed_palette_game_boy, test_edge_palette_list_hex, test_edge_downscale_1_only_quantizes, test_error_missing_src, test_error_downscale_zero, test_error_bad_palette, test_alpha_preserved_transparent_corners, test_alpha_off_flattens_to_rgb, test_rgb_input_unaffected_by_keep_alpha, test_error_all_transparent_no_crash]
|
||||||
test_file_path: "python/functions/ml/comfyui_pixelize_image_test.py"
|
test_file_path: "python/functions/ml/comfyui_pixelize_image_test.py"
|
||||||
file_path: "python/functions/ml/comfyui_pixelize_image.py"
|
file_path: "python/functions/ml/comfyui_pixelize_image.py"
|
||||||
---
|
---
|
||||||
@@ -54,14 +58,21 @@ res = comfyui_pixelize_image(
|
|||||||
# Forzar la paleta retro Game Boy (4 colores) y dejar la imagen pequena (sin upscale)
|
# Forzar la paleta retro Game Boy (4 colores) y dejar la imagen pequena (sin upscale)
|
||||||
comfyui_pixelize_image("/tmp/hero_pixel.png", "/tmp/hero_gb.png",
|
comfyui_pixelize_image("/tmp/hero_pixel.png", "/tmp/hero_gb.png",
|
||||||
palette="game-boy", upscale_back=False)
|
palette="game-boy", upscale_back=False)
|
||||||
|
|
||||||
|
# Sprite RGBA (tras rembg): preserva la transparencia, cuantiza solo el sujeto
|
||||||
|
res = comfyui_pixelize_image("/tmp/knight_rgba.png", "/tmp/knight_px.png",
|
||||||
|
downscale=1, colors=16, keep_alpha=True)
|
||||||
|
# {'ok': True, 'has_alpha': True, 'n_colors_final': 16, ...} -> fondo transparente intacto
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cuando usarla
|
## Cuando usarla
|
||||||
|
|
||||||
Fase 2 del pipeline pixelart: tras generar el crudo (SDXL + LoRA `SDXL_pixel-art`),
|
Fase 2 del pipeline pixelart: tras generar el crudo (SDXL + LoRA `SDXL_pixel-art`),
|
||||||
para colapsar el grid borroso a pixeles duros y limitar la paleta. Tambien sirve
|
para colapsar el grid borroso a pixeles duros y limitar la paleta. Si la imagen
|
||||||
para "pixelizar" cualquier imagen (sprite, render, foto) a estetica retro sin
|
viene de `rembg` con fondo recortado (RGBA), `keep_alpha=True` mantiene la
|
||||||
tocar la GPU. Para llevar el resultado a Godot con filtro Nearest:
|
transparencia y deja el fondo fuera de la paleta. Tambien sirve para "pixelizar"
|
||||||
|
cualquier imagen (sprite, render, foto) a estetica retro sin tocar la GPU. Para
|
||||||
|
llevar el resultado a Godot con filtro Nearest:
|
||||||
`comfyui_export_asset_to_godot(out, "pixelart", proj)`.
|
`comfyui_export_asset_to_godot(out, "pixelart", proj)`.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
@@ -76,7 +87,22 @@ tocar la GPU. Para llevar el resultado a Godot con filtro Nearest:
|
|||||||
duros (preview).
|
duros (preview).
|
||||||
- Todo error es **dict `ok=False`** (no excepcion): `src_path` inexistente,
|
- Todo error es **dict `ok=False`** (no excepcion): `src_path` inexistente,
|
||||||
`downscale<1`, paleta desconocida -> `error` explica. No crashea ni borra nada.
|
`downscale<1`, paleta desconocida -> `error` explica. No crashea ni borra nada.
|
||||||
- `n_colors_final` cuenta colores distintos reales del PNG escrito; con paleta fija
|
- `n_colors_final` cuenta colores RGB distintos reales del PNG escrito; con salida
|
||||||
puede ser **menor** que el tamano de la paleta si la imagen no usa todos.
|
RGBA cuenta **solo la zona opaca** (el transparente no es un color del pixel-art);
|
||||||
|
con paleta fija puede ser **menor** que el tamano de la paleta si la imagen no usa todos.
|
||||||
|
- **alpha-aware (v1.1.0)**: con entrada RGBA y `keep_alpha=True` (default), el fondo
|
||||||
|
transparente se rellena internamente con la moda del sujeto antes de cuantizar, asi
|
||||||
|
NO gasta una entrada de la paleta; el alpha se downscalea nearest aparte y se
|
||||||
|
binariza por `alpha_threshold` (0/255 = bordes duros pixel-art). Entrada sin alpha
|
||||||
|
-> comportamiento RGB identico al de antes (retrocompatible).
|
||||||
|
- Si la entrada RGBA esta **toda transparente** (rembg sin sujeto), no crashea:
|
||||||
|
devuelve `ok=True`, `has_alpha=True`, `n_colors_final=0` y el PNG sigue transparente.
|
||||||
- CPU-only: no toca la GPU ni el servidor ComfyUI; corre en cualquier interprete
|
- CPU-only: no toca la GPU ni el servidor ComfyUI; corre en cualquier interprete
|
||||||
con Pillow.
|
con Pillow (numpy acelera el relleno alpha; sin numpy degrada limpio).
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-28) — alpha-aware: `keep_alpha`/`alpha_threshold`. Si la entrada
|
||||||
|
es RGBA, cuantiza solo el RGB (fondo transparente fuera de la paleta) y preserva el
|
||||||
|
alpha binarizado -> PNG RGBA con transparencia real. Cierra el bug del pipeline
|
||||||
|
pixelart que perdia el fondo transparente por el `convert("RGB")` (issue sprite-fix).
|
||||||
|
|||||||
@@ -64,8 +64,60 @@ def _normalize_palette(palette):
|
|||||||
return [_hex_to_rgb(h) for h in hexes]
|
return [_hex_to_rgb(h) for h in hexes]
|
||||||
|
|
||||||
|
|
||||||
def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back):
|
def _img_has_alpha(img) -> bool:
|
||||||
"""Nucleo puro PIL: imagen RGB -> imagen RGB pixelizada.
|
"""True si la imagen lleva transparencia (RGBA, LA o P con transparency)."""
|
||||||
|
return img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_transparent_with_mode(small_rgb, small_alpha, threshold):
|
||||||
|
"""Rellena los pixeles transparentes con el color opaco mas frecuente (moda).
|
||||||
|
|
||||||
|
Asi el fondo transparente NO aporta colores nuevos a la cuantizacion: las zonas
|
||||||
|
con alpha <= threshold toman un color que ya esta en el sujeto (y por tanto en la
|
||||||
|
paleta resultante), sin gastar entradas de la paleta en el color de fondo. El
|
||||||
|
color real de esas zonas es irrelevante para la salida porque luego reciben
|
||||||
|
alpha 0. Si no hay numpy, cae a no rellenar (degradacion limpia).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
small_rgb: PIL.Image RGB ya reducida.
|
||||||
|
small_alpha: PIL.Image 'L' del alpha ya reducido (mismo tamano).
|
||||||
|
threshold: umbral de alpha (0..255); <= threshold = transparente.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PIL.Image RGB con el fondo transparente relleno con la moda del sujeto.
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
rgb = small_rgb.convert("RGB")
|
||||||
|
mask = small_alpha.point(lambda p: 255 if p > threshold else 0).convert("L")
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
except ImportError:
|
||||||
|
return rgb
|
||||||
|
|
||||||
|
arr = np.asarray(rgb).reshape(-1, 3)
|
||||||
|
opaque = np.asarray(mask).reshape(-1) > 0
|
||||||
|
if not opaque.any():
|
||||||
|
return rgb # nada opaco: caso degenerado, deja igual
|
||||||
|
op_pixels = arr[opaque]
|
||||||
|
colors, counts = np.unique(op_pixels, axis=0, return_counts=True)
|
||||||
|
fill = tuple(int(x) for x in colors[counts.argmax()])
|
||||||
|
bg = Image.new("RGB", rgb.size, fill)
|
||||||
|
bg.paste(rgb, (0, 0), mask) # rgb donde mask=255, fill (moda) donde mask=0
|
||||||
|
return bg
|
||||||
|
|
||||||
|
|
||||||
|
def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back,
|
||||||
|
keep_alpha, alpha_threshold):
|
||||||
|
"""Nucleo puro PIL: imagen -> imagen pixelizada (RGB, o RGBA si keep_alpha).
|
||||||
|
|
||||||
|
Si la imagen de entrada tiene canal alpha y keep_alpha es True, la cuantizacion
|
||||||
|
de color se hace SOLO sobre el RGB (con el fondo transparente relleno con la moda
|
||||||
|
del sujeto para que no entre en la paleta) y el alpha se downscalea nearest por
|
||||||
|
separado y se binariza por `alpha_threshold`, recombinando a RGBA. Asi se
|
||||||
|
preserva la transparencia sin que las zonas transparentes contaminen la paleta.
|
||||||
|
Para imagenes sin alpha (o keep_alpha False) el comportamiento RGB es identico al
|
||||||
|
de antes.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
img: PIL.Image de entrada.
|
img: PIL.Image de entrada.
|
||||||
@@ -74,22 +126,39 @@ def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back):
|
|||||||
palette_rgb: lista [(r,g,b), ...] o None (cuantizacion automatica).
|
palette_rgb: lista [(r,g,b), ...] o None (cuantizacion automatica).
|
||||||
dither: aplica Floyd-Steinberg al cuantizar si True.
|
dither: aplica Floyd-Steinberg al cuantizar si True.
|
||||||
upscale_back: re-escala nearest al tamano original si True.
|
upscale_back: re-escala nearest al tamano original si True.
|
||||||
|
keep_alpha: si True y la imagen tiene alpha, preserva la transparencia.
|
||||||
|
alpha_threshold: umbral (0..255) para binarizar el alpha (opaco/transparente).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PIL.Image RGB pixelizada.
|
PIL.Image pixelizada: RGB, o RGBA si se preservo la transparencia.
|
||||||
"""
|
"""
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
img = img.convert("RGB")
|
has_alpha = bool(keep_alpha) and _img_has_alpha(img)
|
||||||
w, h = img.size
|
if has_alpha:
|
||||||
|
rgba = img.convert("RGBA")
|
||||||
|
alpha_full = rgba.getchannel("A")
|
||||||
|
rgb = rgba.convert("RGB")
|
||||||
|
else:
|
||||||
|
rgb = img.convert("RGB")
|
||||||
|
alpha_full = None
|
||||||
|
|
||||||
|
w, h = rgb.size
|
||||||
# 1. downscale nearest -> grid real (colapsa bloques borrosos a 1 pixel).
|
# 1. downscale nearest -> grid real (colapsa bloques borrosos a 1 pixel).
|
||||||
sw, sh = max(1, w // downscale), max(1, h // downscale)
|
sw, sh = max(1, w // downscale), max(1, h // downscale)
|
||||||
small = img.resize((sw, sh), Image.NEAREST)
|
small = rgb.resize((sw, sh), Image.NEAREST)
|
||||||
|
small_alpha = (
|
||||||
|
alpha_full.resize((sw, sh), Image.NEAREST) if alpha_full is not None else None
|
||||||
|
)
|
||||||
|
# 1b. con alpha: el fondo transparente no debe entrar en la paleta.
|
||||||
|
if small_alpha is not None:
|
||||||
|
small = _fill_transparent_with_mode(small, small_alpha, int(alpha_threshold))
|
||||||
|
|
||||||
d = Image.Dither.FLOYDSTEINBERG if dither else Image.Dither.NONE
|
d = Image.Dither.FLOYDSTEINBERG if dither else Image.Dither.NONE
|
||||||
# 2. cuantizar la paleta.
|
# 2. cuantizar la paleta (siempre sobre RGB).
|
||||||
if palette_rgb:
|
if palette_rgb:
|
||||||
pal_img = Image.new("P", (1, 1))
|
pal_img = Image.new("P", (1, 1))
|
||||||
flat = [c for rgb in palette_rgb for c in rgb][:768]
|
flat = [c for rgb_c in palette_rgb for c in rgb_c][:768]
|
||||||
# Rellena las 256 entradas repitiendo el ultimo color real (no ceros): asi
|
# Rellena las 256 entradas repitiendo el ultimo color real (no ceros): asi
|
||||||
# quantize no puede introducir un color extra (negro) por las entradas vacias.
|
# quantize no puede introducir un color extra (negro) por las entradas vacias.
|
||||||
if flat:
|
if flat:
|
||||||
@@ -102,12 +171,42 @@ def _pixelize_pil(img, downscale, colors, palette_rgb, dither, upscale_back):
|
|||||||
n = max(2, min(256, int(colors)))
|
n = max(2, min(256, int(colors)))
|
||||||
small = small.quantize(colors=n, method=Image.Quantize.MEDIANCUT, dither=d)
|
small = small.quantize(colors=n, method=Image.Quantize.MEDIANCUT, dither=d)
|
||||||
out = small.convert("RGB")
|
out = small.convert("RGB")
|
||||||
|
|
||||||
|
# 2b. recombinar el alpha (binarizado) -> RGBA con transparencia dura.
|
||||||
|
if small_alpha is not None:
|
||||||
|
out = out.convert("RGBA")
|
||||||
|
hard_alpha = small_alpha.point(lambda p: 255 if p > int(alpha_threshold) else 0)
|
||||||
|
out.putalpha(hard_alpha)
|
||||||
|
|
||||||
# 3. opcional: re-upscale nearest para preview/entrega (pixeles duros).
|
# 3. opcional: re-upscale nearest para preview/entrega (pixeles duros).
|
||||||
if upscale_back:
|
if upscale_back:
|
||||||
out = out.resize((w, h), Image.NEAREST)
|
out = out.resize((w, h), Image.NEAREST)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _count_colors(result) -> int:
|
||||||
|
"""Numero de colores RGB distintos en el resultado.
|
||||||
|
|
||||||
|
Para salida RGBA cuenta solo los colores de la zona opaca (alpha > 0), que es lo
|
||||||
|
que define el sprite; el transparente no es un "color" del pixel-art. Para RGB
|
||||||
|
cuenta todos los colores. Devuelve -1 si no se pudo contar.
|
||||||
|
"""
|
||||||
|
if result.mode == "RGBA":
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
except ImportError:
|
||||||
|
colors_found = result.convert("RGB").getcolors(maxcolors=1 << 20)
|
||||||
|
return len(colors_found) if colors_found is not None else -1
|
||||||
|
arr = np.asarray(result)
|
||||||
|
opaque = arr[..., 3] > 0
|
||||||
|
rgb_op = arr[..., :3][opaque]
|
||||||
|
if rgb_op.size == 0:
|
||||||
|
return 0
|
||||||
|
return int(len(np.unique(rgb_op.reshape(-1, 3), axis=0)))
|
||||||
|
colors_found = result.getcolors(maxcolors=1 << 20)
|
||||||
|
return len(colors_found) if colors_found is not None else -1
|
||||||
|
|
||||||
|
|
||||||
def comfyui_pixelize_image(
|
def comfyui_pixelize_image(
|
||||||
src_path: str,
|
src_path: str,
|
||||||
dst_path: str,
|
dst_path: str,
|
||||||
@@ -117,6 +216,8 @@ def comfyui_pixelize_image(
|
|||||||
palette=None,
|
palette=None,
|
||||||
dither: bool = False,
|
dither: bool = False,
|
||||||
upscale_back: bool = True,
|
upscale_back: bool = True,
|
||||||
|
keep_alpha: bool = True,
|
||||||
|
alpha_threshold: int = 128,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Pixeliza una imagen y la guarda como PNG.
|
"""Pixeliza una imagen y la guarda como PNG.
|
||||||
|
|
||||||
@@ -135,16 +236,28 @@ def comfyui_pixelize_image(
|
|||||||
limpio). keyword-only.
|
limpio). keyword-only.
|
||||||
upscale_back: re-escala nearest al tamano original (preview con pixeles
|
upscale_back: re-escala nearest al tamano original (preview con pixeles
|
||||||
duros). False deja la imagen pequena (sw x sh). keyword-only.
|
duros). False deja la imagen pequena (sw x sh). keyword-only.
|
||||||
|
keep_alpha: si True (default) y la imagen de entrada tiene canal alpha,
|
||||||
|
preserva la transparencia: cuantiza solo el RGB y downscalea/binariza el
|
||||||
|
alpha por separado, devolviendo PNG RGBA. Las zonas transparentes no
|
||||||
|
entran en la paleta de color. Si la imagen no tiene alpha, no tiene
|
||||||
|
efecto (sale RGB igual que antes). keyword-only.
|
||||||
|
alpha_threshold: umbral (0..255) para binarizar el alpha en opaco (255) o
|
||||||
|
transparente (0). Solo aplica cuando se preserva el alpha. keyword-only.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict con:
|
dict con:
|
||||||
- ok (bool): True si se pixelizo y guardo.
|
- ok (bool): True si se pixelizo y guardo.
|
||||||
- out_path (str): ruta del PNG generado.
|
- out_path (str): ruta del PNG generado.
|
||||||
- size (list[int]): [w, h] de la imagen final.
|
- size (list[int]): [w, h] de la imagen final.
|
||||||
- n_colors_final (int): numero de colores distintos en el resultado.
|
- n_colors_final (int): numero de colores RGB distintos en el resultado
|
||||||
|
(en la zona opaca si la salida es RGBA).
|
||||||
|
- has_alpha (bool): True si la salida es RGBA con transparencia preservada.
|
||||||
- error (str): mensaje de error; cadena vacia si todo OK.
|
- error (str): mensaje de error; cadena vacia si todo OK.
|
||||||
"""
|
"""
|
||||||
out = {"ok": False, "out_path": "", "size": [0, 0], "n_colors_final": 0, "error": ""}
|
out = {
|
||||||
|
"ok": False, "out_path": "", "size": [0, 0], "n_colors_final": 0,
|
||||||
|
"has_alpha": False, "error": "",
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -168,7 +281,8 @@ def comfyui_pixelize_image(
|
|||||||
try:
|
try:
|
||||||
with Image.open(src_path) as src:
|
with Image.open(src_path) as src:
|
||||||
result = _pixelize_pil(
|
result = _pixelize_pil(
|
||||||
src, int(downscale), colors, palette_rgb, bool(dither), bool(upscale_back)
|
src, int(downscale), colors, palette_rgb, bool(dither),
|
||||||
|
bool(upscale_back), bool(keep_alpha), int(alpha_threshold),
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
out["error"] = f"no se pudo leer/decodificar {src_path!r}: {exc}"
|
out["error"] = f"no se pudo leer/decodificar {src_path!r}: {exc}"
|
||||||
@@ -182,10 +296,10 @@ def comfyui_pixelize_image(
|
|||||||
out["error"] = f"no se pudo escribir {dst_path!r}: {exc}"
|
out["error"] = f"no se pudo escribir {dst_path!r}: {exc}"
|
||||||
return out
|
return out
|
||||||
|
|
||||||
colors_found = result.getcolors(maxcolors=1 << 20)
|
n_final = _count_colors(result)
|
||||||
n_final = len(colors_found) if colors_found is not None else -1
|
|
||||||
out.update(
|
out.update(
|
||||||
ok=True, out_path=dst_path, size=list(result.size), n_colors_final=n_final
|
ok=True, out_path=dst_path, size=list(result.size), n_colors_final=n_final,
|
||||||
|
has_alpha=(result.mode == "RGBA"),
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -18,9 +18,9 @@ params:
|
|||||||
- name: png_path
|
- name: png_path
|
||||||
desc: "Ruta local del PNG generado por ComfyUI."
|
desc: "Ruta local del PNG generado por ComfyUI."
|
||||||
output: "dict {ok, prompt, parameters, error}. prompt = workflow API format embebido (dict); parameters = {model, seed, steps, cfg, sampler_name, scheduler, denoise, positive, negative} extraido del KSampler y nodos conectados; error = motivo si ok=False."
|
output: "dict {ok, prompt, parameters, error}. prompt = workflow API format embebido (dict); parameters = {model, seed, steps, cfg, sampler_name, scheduler, denoise, positive, negative} extraido del KSampler y nodos conectados; error = motivo si ok=False."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["extrae prompt embebido + parametros del KSampler (seed/steps/cfg/sampler/scheduler/denoise/positive/negative/model)", "error: archivo inexistente", "error: PNG sin chunk prompt", "error: chunk prompt no es JSON", "error: no es un PNG valido", "determinismo"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_read_png_metadata.py"
|
||||||
file_path: "python/functions/ml/comfyui_read_png_metadata.py"
|
file_path: "python/functions/ml/comfyui_read_png_metadata.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ params:
|
|||||||
- name: server
|
- name: server
|
||||||
desc: "host:port del servidor ComfyUI sin esquema. Debe estar vivo para consultar /object_info."
|
desc: "host:port del servidor ComfyUI sin esquema. Debe estar vivo para consultar /object_info."
|
||||||
output: "dict {ok, missing_nodes, missing_models, suggestions, error}. ok = se pudo consultar el servidor; missing_nodes = class_type ausentes (nodos custom); missing_models = lista de {node, input, value}; suggestions = lista de {kind, name, action, hint, ...} (una por nodo/modelo faltante) con la funcion a usar; error = motivo si ok=False."
|
output: "dict {ok, missing_nodes, missing_models, suggestions, error}. ok = se pudo consultar el servidor; missing_nodes = class_type ausentes (nodos custom); missing_models = lista de {node, input, value}; suggestions = lista de {kind, name, action, hint, ...} (una por nodo/modelo faltante) con la funcion a usar; error = motivo si ok=False."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["traduce nodos y modelos faltantes en suggestions (install_custom_node / search_and_download)", "sin faltantes -> suggestions vacio", "servidor caido -> ok=False con error propagado"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_resolve_workflow_deps.py"
|
||||||
file_path: "python/functions/ml/comfyui_resolve_workflow_deps.py"
|
file_path: "python/functions/ml/comfyui_resolve_workflow_deps.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ lang: py
|
|||||||
domain: ml
|
domain: ml
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "def comfyui_save_skill(recipe: dict, *, library_dir: str = None) -> dict"
|
signature: "def comfyui_save_skill(recipe: dict, library_dir: str = None) -> dict"
|
||||||
description: "Persiste una receta de skill ComfyUI (schema comfyui-skill) en la libreria de disco: valida el schema minimo y escribe <library_dir>/<slug>/recipe.json + un snapshot inmutable versions/vN.json (N incremental) + bitacora growth_log.jsonl + regenera INDEX.md. No muta la receta (round-trip identico con comfyui_load_skill). library_dir default ~/ComfyUI/skills_library. Devuelve dict {ok, slug, path, version_file, n_versions, error}; nunca lanza."
|
description: "Persiste una receta de skill ComfyUI (schema comfyui-skill) en la libreria de disco: valida el schema minimo y escribe <library_dir>/<slug>/recipe.json + un snapshot inmutable versions/vN.json (N incremental) + bitacora growth_log.jsonl + regenera INDEX.md. No muta la receta (round-trip identico con comfyui_load_skill). library_dir default ~/ComfyUI/skills_library. Devuelve dict {ok, slug, path, version_file, n_versions, error}; nunca lanza."
|
||||||
error_type: error_go_core
|
error_type: error_go_core
|
||||||
tags: [comfyui, comfyui-skill, ml, skill, library, persistence]
|
tags: [comfyui, comfyui-skill, ml, skill, library, persistence]
|
||||||
|
|||||||
@@ -91,13 +91,13 @@ def _rewrite_index(lib):
|
|||||||
fh.write("\n".join(lines))
|
fh.write("\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
def comfyui_save_skill(recipe: dict, *, library_dir: str = None) -> dict:
|
def comfyui_save_skill(recipe: dict, library_dir: str = None) -> dict:
|
||||||
"""Valida y persiste una receta de skill en la libreria de disco.
|
"""Valida y persiste una receta de skill en la libreria de disco.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
recipe: dict de la receta (schema `comfyui-skill`). Requiere al menos `slug`,
|
recipe: dict de la receta (schema `comfyui-skill`). Requiere al menos `slug`,
|
||||||
`base_workflow` y `version` (strings no vacios). No se muta.
|
`base_workflow` y `version` (strings no vacios). No se muta.
|
||||||
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`. keyword-only.
|
library_dir: raiz de la libreria. Default `~/ComfyUI/skills_library`.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict ``{ok, slug, path, recipe_path, version_file, n_versions, error}``. En error de
|
dict ``{ok, slug, path, recipe_path, version_file, n_versions, error}``. En error de
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ params:
|
|||||||
- name: timeout
|
- name: timeout
|
||||||
desc: "Timeout del subproceso en segundos (la primera vez puede descargar CLIP). keyword-only."
|
desc: "Timeout del subproceso en segundos (la primera vez puede descargar CLIP). keyword-only."
|
||||||
output: "dict {ok, score_0_10, error}. En exito ok=True y score_0_10 es el score continuo (~1-10, mayor = mejor). En error ok=False, score_0_10=0.0 y error describe la causa. Nunca lanza excepcion."
|
output: "dict {ok, score_0_10, error}. En exito ok=True y score_0_10 es el score continuo (~1-10, mayor = mejor). En error ok=False, score_0_10=0.0 y error describe la causa. Nunca lanza excepcion."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["error: imagen inexistente (guard previo al subproceso)", "error: python del venv ComfyUI ausente", "error: .pth del modelo ausente", "nunca lanza excepcion + determinismo del error"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_score_aesthetic.py"
|
||||||
file_path: "python/functions/ml/comfyui_score_aesthetic.py"
|
file_path: "python/functions/ml/comfyui_score_aesthetic.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ params:
|
|||||||
- name: timeout
|
- name: timeout
|
||||||
desc: "Timeout del subproceso en segundos. keyword-only."
|
desc: "Timeout del subproceso en segundos. keyword-only."
|
||||||
output: "dict {ok, score_0_1, error}. En exito ok=True y score_0_1 es la similitud coseno clamp a [0,1] (mayor = mas fiel al prompt; tipico 0.28-0.35 buen match, 0.10-0.18 distinto). En error ok=False, score_0_1=0.0 y error describe la causa. Nunca lanza excepcion."
|
output: "dict {ok, score_0_1, error}. En exito ok=True y score_0_1 es la similitud coseno clamp a [0,1] (mayor = mas fiel al prompt; tipico 0.28-0.35 buen match, 0.10-0.18 distinto). En error ok=False, score_0_1=0.0 y error describe la causa. Nunca lanza excepcion."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["error: imagen inexistente", "error: prompt vacio", "error: python del venv ComfyUI ausente", "nunca lanza excepcion + determinismo del error"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_score_clip_alignment.py"
|
||||||
file_path: "python/functions/ml/comfyui_score_clip_alignment.py"
|
file_path: "python/functions/ml/comfyui_score_clip_alignment.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
name: crop_to_content
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: ml
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "def crop_to_content(img, *, pad_ratio: float = 0.06, square: bool = True, alpha_threshold: int = 10, bg_tolerance: int = 16)"
|
||||||
|
description: "Recorta una imagen PIL al bounding box de su contenido y la cuadra, para que el sujeto llene el frame antes de un downscale a pixel-art. Detecta el contenido por alpha (region con alpha > alpha_threshold) si la imagen es RGBA/LA, o por diferencia contra el color de fondo de las esquinas (con bg_tolerance) si es RGB. Recorta al bbox, anade un margen pad_ratio y, si square, rellena a cuadrado centrando el sujeto sin deformar (fondo transparente si RGBA, color de fondo si RGB). Pura PIL (opera sobre el objeto PIL.Image, no toca disco ni red, no muta la entrada). Si no hay contenido (todo transparente o todo fondo) devuelve una copia intacta — no crashea."
|
||||||
|
tags: [pil, image, crop, bbox, pixelart, gamedev-2d, ml, alpha, sprite]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: img
|
||||||
|
desc: "PIL.Image de entrada (cualquier modo). No se muta. None lanza ValueError."
|
||||||
|
- name: pad_ratio
|
||||||
|
desc: "Margen anadido alrededor del sujeto como fraccion del lado mayor del bbox recortado (0.06 = 6%). 0 = sin margen. keyword-only."
|
||||||
|
- name: square
|
||||||
|
desc: "Si True rellena a un lienzo cuadrado de lado max(w,h)+2*pad con el sujeto centrado (fondo transparente si hay alpha, color de fondo si RGB); si False solo recorta al bbox + margen sin cuadrar. keyword-only."
|
||||||
|
- name: alpha_threshold
|
||||||
|
desc: "Umbral de alpha (0..255) para considerar un pixel 'contenido' cuando la imagen tiene canal alpha. keyword-only."
|
||||||
|
- name: bg_tolerance
|
||||||
|
desc: "Tolerancia (0..255) de diferencia contra el color de fondo de las esquinas para imagenes sin alpha (RGB). keyword-only."
|
||||||
|
output: "PIL.Image nueva recortada (y cuadrada si square) con el sujeto llenando el frame. Si la imagen no tiene contenido detectable, devuelve una copia intacta de la entrada (mismo tamano)."
|
||||||
|
tested: true
|
||||||
|
tests: [test_golden_corner_subject_fills_frame, test_edge_centered_subject_not_overcropped, test_edge_rgb_background_bbox, test_edge_no_square_only_crops, test_error_all_transparent_returns_copy, test_error_none_raises, test_does_not_mutate_input]
|
||||||
|
test_file_path: "python/functions/ml/crop_to_content_test.py"
|
||||||
|
file_path: "python/functions/ml/crop_to_content.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from PIL import Image
|
||||||
|
from ml.crop_to_content import crop_to_content
|
||||||
|
|
||||||
|
# Sprite RGBA tras rembg: el sujeto ocupa una esquina -> recortar al bbox y cuadrar.
|
||||||
|
with Image.open("/tmp/knight_rgba.png") as im:
|
||||||
|
out = crop_to_content(im, pad_ratio=0.06, square=True)
|
||||||
|
out.save("/tmp/knight_cropped.png") # RGBA cuadrada, sujeto centrado llenando el frame
|
||||||
|
|
||||||
|
# CLI directo:
|
||||||
|
# ./fn run crop_to_content (corre los tests)
|
||||||
|
# python3 crop_to_content.py /tmp/in.png /tmp/out.png 0.06
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Antes de bajar una imagen a pixel-art (32/64px): si el sujeto ocupa poca area del
|
||||||
|
lienzo, al downscalear queda diminuto y tosco. `crop_to_content` recorta el aire
|
||||||
|
alrededor y cuadra para que el sujeto aproveche todos los pixeles del grid. Es el
|
||||||
|
paso de encuadre del pipeline `comfyui_pixelart_real_oneshot` (autocrop). Funciona
|
||||||
|
con sprites recortados por rembg (detecta por alpha) o con imagenes de fondo plano
|
||||||
|
(detecta por diferencia contra el color de esquina).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Pura sobre PIL.Image**: recibe y devuelve un objeto `PIL.Image`, NO rutas. El
|
||||||
|
caller hace el `Image.open` / `.save`. No muta la imagen de entrada.
|
||||||
|
- Deteccion del contenido: con **alpha** usa `alpha > alpha_threshold`; sin alpha
|
||||||
|
usa la **moda de las 4 esquinas** como color de fondo y `bg_tolerance` de
|
||||||
|
diferencia. Si el fondo no es uniforme (gradiente) la deteccion RGB puede fallar;
|
||||||
|
para esos casos pasa la imagen ya recortada por rembg (RGBA).
|
||||||
|
- Si no hay contenido (todo transparente o todo del color de fondo) devuelve una
|
||||||
|
**copia intacta** del original (mismo tamano), nunca lanza por una imagen vacia.
|
||||||
|
Solo lanza `ValueError` si `img` es `None`.
|
||||||
|
- `square=True` (default) cuadra a `max(w,h)+2*pad`: si el sujeto es muy alargado el
|
||||||
|
lienzo crece al lado mayor y el sujeto queda centrado con barras transparentes (o
|
||||||
|
de color de fondo) a los lados — sin deformar.
|
||||||
|
- `pad_ratio` es relativo al lado **mayor del bbox**, no del lienzo original.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""crop_to_content — recorta una imagen PIL al bounding box de su contenido y la cuadra.
|
||||||
|
|
||||||
|
Quita el aire alrededor del sujeto para que llene el frame antes de un downscale a
|
||||||
|
pixel-art: si el sujeto ocupa el 25% del lienzo, al bajar a 64px queda diminuto y
|
||||||
|
tosco (pocos pixeles para el detalle). Esta funcion calcula el bounding box del
|
||||||
|
contenido, recorta a ese bbox, anade un margen relativo y, opcionalmente, rellena a
|
||||||
|
cuadrado sin deformar para que el sujeto llene el frame.
|
||||||
|
|
||||||
|
Como detecta el contenido:
|
||||||
|
- Si la imagen tiene canal alpha (RGBA / LA / P con transparencia): el bbox es la
|
||||||
|
region con `alpha > alpha_threshold` (lo opaco es el sujeto, lo transparente es
|
||||||
|
fondo). Es el caso tras pasar la imagen por rembg.
|
||||||
|
- Si no tiene alpha (RGB): el bbox es la region que difiere del color de fondo,
|
||||||
|
estimado como la moda de los cuatro pixeles de esquina. Sirve para imagenes con
|
||||||
|
fondo plano sin recortar todavia.
|
||||||
|
|
||||||
|
Relleno a cuadrado (`square=True`): el lado del lienzo final es `max(w, h) + 2*pad`
|
||||||
|
y el sujeto se centra. El fondo del lienzo es transparente si la imagen tiene alpha,
|
||||||
|
o el color de fondo estimado si es RGB. Asi no se deforma el sujeto.
|
||||||
|
|
||||||
|
Funcion pura: opera sobre el objeto PIL.Image y devuelve uno nuevo; no toca disco ni
|
||||||
|
red y no muta la imagen de entrada. Si no encuentra contenido (lienzo vacio o todo
|
||||||
|
transparente), devuelve una copia intacta de la entrada — nunca lanza por una imagen
|
||||||
|
sin sujeto (contrato no-throw salvo `img` None).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
|
||||||
|
def _as_rgb_tuple(c) -> tuple:
|
||||||
|
"""Normaliza un pixel (int de modo L, o tupla RGB/RGBA) a una 3-tupla RGB."""
|
||||||
|
if isinstance(c, (tuple, list)):
|
||||||
|
return tuple(int(x) for x in c[:3])
|
||||||
|
return (int(c), int(c), int(c))
|
||||||
|
|
||||||
|
|
||||||
|
def _corner_bg_color(img) -> tuple:
|
||||||
|
"""Color de fondo estimado: la moda de los cuatro pixeles de esquina (RGB)."""
|
||||||
|
rgb = img.convert("RGB")
|
||||||
|
w, h = rgb.size
|
||||||
|
corners = [
|
||||||
|
rgb.getpixel((0, 0)),
|
||||||
|
rgb.getpixel((w - 1, 0)),
|
||||||
|
rgb.getpixel((0, h - 1)),
|
||||||
|
rgb.getpixel((w - 1, h - 1)),
|
||||||
|
]
|
||||||
|
corners = [_as_rgb_tuple(c) for c in corners]
|
||||||
|
return Counter(corners).most_common(1)[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def _has_alpha(img) -> bool:
|
||||||
|
"""True si la imagen lleva transparencia (RGBA, LA o P con transparency)."""
|
||||||
|
return img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
|
||||||
|
|
||||||
|
|
||||||
|
def _content_bbox(img, alpha_threshold: int, bg_tolerance: int):
|
||||||
|
"""Devuelve (l, t, r, b) del contenido o None si no hay.
|
||||||
|
|
||||||
|
Por alpha si la imagen lo tiene; si no, por diferencia contra el color de fondo
|
||||||
|
de las esquinas con tolerancia `bg_tolerance`.
|
||||||
|
"""
|
||||||
|
from PIL import Image, ImageChops
|
||||||
|
|
||||||
|
if _has_alpha(img):
|
||||||
|
alpha = img.convert("RGBA").getchannel("A")
|
||||||
|
mask = alpha.point(lambda p: 255 if p > alpha_threshold else 0)
|
||||||
|
return mask.getbbox()
|
||||||
|
|
||||||
|
rgb = img.convert("RGB")
|
||||||
|
bg = Image.new("RGB", rgb.size, _corner_bg_color(rgb))
|
||||||
|
diff = ImageChops.difference(rgb, bg).convert("L")
|
||||||
|
mask = diff.point(lambda p: 255 if p > bg_tolerance else 0)
|
||||||
|
return mask.getbbox()
|
||||||
|
|
||||||
|
|
||||||
|
def crop_to_content(
|
||||||
|
img,
|
||||||
|
*,
|
||||||
|
pad_ratio: float = 0.02,
|
||||||
|
square: bool = True,
|
||||||
|
alpha_threshold: int = 10,
|
||||||
|
bg_tolerance: int = 16,
|
||||||
|
):
|
||||||
|
"""Recorta una imagen PIL al bbox de su contenido, con margen y cuadrado opcional.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
img: PIL.Image de entrada (cualquier modo). No se muta.
|
||||||
|
pad_ratio: margen anadido alrededor del sujeto como fraccion del lado mayor
|
||||||
|
del bbox recortado (0.06 = 6%). 0 = sin margen. keyword-only.
|
||||||
|
square: si True rellena a un lienzo cuadrado de lado `max(w,h)+2*pad` con el
|
||||||
|
sujeto centrado (fondo transparente si hay alpha, color de fondo si RGB);
|
||||||
|
si False solo recorta al bbox + margen sin cuadrar. keyword-only.
|
||||||
|
alpha_threshold: umbral de alpha (0..255) para considerar un pixel "contenido"
|
||||||
|
cuando la imagen tiene canal alpha. keyword-only.
|
||||||
|
bg_tolerance: tolerancia (0..255) de diferencia contra el color de fondo de
|
||||||
|
las esquinas para imagenes sin alpha (RGB). keyword-only.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PIL.Image nueva recortada (y cuadrada si square). Si la imagen no tiene
|
||||||
|
contenido detectable (todo transparente o todo del color de fondo), devuelve
|
||||||
|
una copia intacta de la entrada.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: si img es None.
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
if img is None:
|
||||||
|
raise ValueError("crop_to_content: img es None")
|
||||||
|
|
||||||
|
bbox = _content_bbox(img, int(alpha_threshold), int(bg_tolerance))
|
||||||
|
if bbox is None:
|
||||||
|
return img.copy()
|
||||||
|
|
||||||
|
left, top, right, bottom = bbox
|
||||||
|
cropped = img.crop((left, top, right, bottom))
|
||||||
|
cw, ch = cropped.size
|
||||||
|
pad = int(round(max(cw, ch) * float(pad_ratio)))
|
||||||
|
has_alpha = _has_alpha(img)
|
||||||
|
|
||||||
|
if has_alpha:
|
||||||
|
base = cropped.convert("RGBA")
|
||||||
|
bg_fill = (0, 0, 0, 0)
|
||||||
|
mode = "RGBA"
|
||||||
|
else:
|
||||||
|
base = cropped.convert("RGB")
|
||||||
|
bg_fill = _corner_bg_color(img)
|
||||||
|
mode = "RGB"
|
||||||
|
|
||||||
|
if square:
|
||||||
|
side = max(cw, ch) + 2 * pad
|
||||||
|
canvas = Image.new(mode, (side, side), bg_fill)
|
||||||
|
ox = (side - cw) // 2
|
||||||
|
oy = (side - ch) // 2
|
||||||
|
else:
|
||||||
|
if pad <= 0:
|
||||||
|
return base
|
||||||
|
canvas = Image.new(mode, (cw + 2 * pad, ch + 2 * pad), bg_fill)
|
||||||
|
ox = oy = pad
|
||||||
|
|
||||||
|
if has_alpha:
|
||||||
|
canvas.paste(base, (ox, oy), base) # usa el alpha del sujeto como mascara
|
||||||
|
else:
|
||||||
|
canvas.paste(base, (ox, oy))
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("uso: crop_to_content.py <src> <dst> [pad_ratio]", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
src, dst = sys.argv[1], sys.argv[2]
|
||||||
|
pr = float(sys.argv[3]) if len(sys.argv) > 3 else 0.06
|
||||||
|
with Image.open(src) as im:
|
||||||
|
out = crop_to_content(im, pad_ratio=pr)
|
||||||
|
out.save(dst)
|
||||||
|
print(f"ok: {src} -> {dst} {out.size} {out.mode}")
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
name: pixeloe_downscale
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: ml
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def pixeloe_downscale(src_path: str, dst_path: str, *, mode: str = 'contrast', target_size: int = 64, patch_size: int = 16, thickness: int = 2, color_matching: bool = True, no_upscale: bool = True, comfy_python: str | None = None) -> dict"
|
||||||
|
description: "Downscale contrast-aware (Contrast-Aware Outline Expansion de Kohaku, lib `pixeloe`) que colapsa una ilustracion a un grid de pixel-art pequeno (64 personajes, 32 iconos) conservando contornos/silueta. Es la etapa de downscale del metodo SOTA de pixel-art (report 0215). NO cuantiza la paleta (eso lo hace despues comfyui_pixelize_image). Resuelve el gotcha de que `pixeloe` solo vive en el venv de ComfyUI con un 'bridge' de interprete: si falta en el interprete actual, re-ejecuta su nucleo por subprocess con el python de ComfyUI. No-throw: todo error viaja en `error`. Determinista; impura por I/O de disco + subprocess. Devuelve {ok, out_path, size, mode, target_size, via, error}."
|
||||||
|
tags: [comfyui, gamedev-2d, pixelart, ml, pixeloe, downscale, contrast-aware, image, bridge]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_py_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: src_path
|
||||||
|
desc: "ruta de la imagen de entrada (PNG/JPG/...). Si no existe -> ok=False con error."
|
||||||
|
- name: dst_path
|
||||||
|
desc: "ruta del PNG de salida; se crea el directorio padre si falta."
|
||||||
|
- name: mode
|
||||||
|
desc: "algoritmo de downscale de pixeloe: 'contrast' (SOTA, conserva silueta), 'bicubic', 'nearest', 'center' o 'k-centroid'. keyword-only."
|
||||||
|
- name: target_size
|
||||||
|
desc: "lado del grid resultante en pixeles (64 para personajes, 32 para iconos). keyword-only."
|
||||||
|
- name: patch_size
|
||||||
|
desc: "tamano del patch que pixeloe colapsa por celda del grid. keyword-only."
|
||||||
|
- name: thickness
|
||||||
|
desc: "grosor de la expansion de contorno (outline expansion). keyword-only."
|
||||||
|
- name: color_matching
|
||||||
|
desc: "corrige el color de cada celda contra el original si True. keyword-only."
|
||||||
|
- name: no_upscale
|
||||||
|
desc: "True devuelve el grid real target_size x target_size (lo habitual, para luego cuantizar); False re-escala al tamano original con pixeles duros (preview). keyword-only."
|
||||||
|
- name: comfy_python
|
||||||
|
desc: "ruta a un interprete con `pixeloe` para el bridge cuando el actual no la tiene. Si None: COMFY_PYTHON y luego ~/ComfyUI/.venv/bin/python3. keyword-only."
|
||||||
|
output: "dict con ok (bool), out_path (str), size ([w,h] de la imagen escrita), mode (str usado), target_size (int pedido), via ('inproc' si pixeloe estaba en este interprete, 'bridge' si se delego por subprocess) y error (str, vacio si OK). No lanza excepciones."
|
||||||
|
tested: true
|
||||||
|
tests: [test_golden_downscale_64_or_clean_degrade, test_edge_target_size_32, test_edge_mode_nearest_no_color_matching, test_error_missing_src_no_throw, test_error_no_interpreter_with_pixeloe]
|
||||||
|
test_file_path: "python/functions/ml/pixeloe_downscale_test.py"
|
||||||
|
file_path: "python/functions/ml/pixeloe_downscale.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.environ["HOME"], "fn_registry", "python", "functions"))
|
||||||
|
from ml.pixeloe_downscale import pixeloe_downscale
|
||||||
|
|
||||||
|
# Colapsa el render del caballero (1024x1024) a un grid de pixel-art 64x64
|
||||||
|
# conservando la silueta. NO cuantiza paleta todavia.
|
||||||
|
res = pixeloe_downscale(
|
||||||
|
os.path.expanduser("~/ComfyUI/output/pixel_compare/knight_base_00001_.png"),
|
||||||
|
"/tmp/knight_grid64.png",
|
||||||
|
mode="contrast", target_size=64, no_upscale=True,
|
||||||
|
)
|
||||||
|
# {'ok': True, 'out_path': '/tmp/knight_grid64.png', 'size': [64, 64],
|
||||||
|
# 'mode': 'contrast', 'target_size': 64, 'via': 'bridge', 'error': ''}
|
||||||
|
|
||||||
|
# Despues: dureza de color (cuantizacion) con la funcion hermana.
|
||||||
|
from ml.comfyui_pixelize_image import comfyui_pixelize_image
|
||||||
|
comfyui_pixelize_image("/tmp/knight_grid64.png", "/tmp/knight_q16.png",
|
||||||
|
downscale=1, colors=16, upscale_back=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Primera etapa del metodo SOTA de pixel-art: cuando ya tienes una ilustracion (render
|
||||||
|
SDXL/Flux, sprite, foto) y quieres reducirla a un grid de pixel-art chico **sin perder
|
||||||
|
los contornos** (lo que arruina un resize NEAREST/lanczos normal). Usala **antes** de
|
||||||
|
la cuantizacion dura de paleta con `comfyui_pixelize_image` (paso de color). `target_size`
|
||||||
|
64 para personajes, 32 para iconos. Si solo necesitas el resize+cuantizado rapido sin
|
||||||
|
contornos finos, `comfyui_pixelize_image` sola basta; para el resultado ganador, encadena
|
||||||
|
`pixeloe_downscale` -> `comfyui_pixelize_image`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **`pixeloe` solo esta en el venv de ComfyUI** (`~/ComfyUI/.venv`), no en el del registry.
|
||||||
|
La funcion lo resuelve con un *bridge*: si `import pixeloe` falla, re-ejecuta su nucleo
|
||||||
|
por subprocess con el python de ComfyUI. El campo `via` dice si fue `inproc` o `bridge`.
|
||||||
|
- **El modulo es `pixeloe.legacy.pixelize`**, no `pixeloe.pixelize` (ruta vieja eliminada).
|
||||||
|
- **El nodo `PixelOEPixelize+` de ComfyUI_essentials estaba roto** por ese cambio de import;
|
||||||
|
por eso aqui se llama la lib directa (numpy + PIL, sin cv2).
|
||||||
|
- **NO cuantiza la paleta**: el resultado conserva muchos colores; la dureza retro la aplica
|
||||||
|
despues `comfyui_pixelize_image`. No esperes pocos colores en la salida.
|
||||||
|
- **No-throw**: src inexistente, pixeloe ausente en todos los interpretes, o subprocess
|
||||||
|
caido -> `ok=False` con `error` explicado, nunca excepcion. El pipeline llamante hace
|
||||||
|
fallback mirando `ok`.
|
||||||
|
- Resolucion del interprete del bridge: arg `comfy_python` -> env `COMFY_PYTHON` ->
|
||||||
|
`~/ComfyUI/.venv/bin/python3` (el primero que exista como archivo).
|
||||||
|
- `no_upscale=True` (default) devuelve el grid real `target_size x target_size`; con `False`
|
||||||
|
vuelve al tamano original con pixeles duros (preview), no el grid pequeno.
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"""pixeloe_downscale — downscale contrast-aware a un grid de pixel-art (etapa SOTA).
|
||||||
|
|
||||||
|
Colapsa una ilustracion a un grid de pixel-art pequeno (p.ej. 64x64) usando la
|
||||||
|
libreria `pixeloe` de Kohaku (Contrast-Aware Outline Expansion), el metodo SOTA
|
||||||
|
para preservar contornos/silueta al reducir. Es la etapa de *downscale* del
|
||||||
|
metodo ganador de pixel-art (ver report 0215): NO cuantiza la paleta — esa dureza
|
||||||
|
de color la aplica despues otra funcion (`comfyui_pixelize_image`).
|
||||||
|
|
||||||
|
Gotcha de entorno (resuelto con un "bridge" de interprete): la lib `pixeloe` solo
|
||||||
|
esta instalada en el venv de ComfyUI (`~/ComfyUI/.venv`), no en el venv del
|
||||||
|
registry, y su modulo vive en `pixeloe.legacy.pixelize` (la ruta vieja
|
||||||
|
`pixeloe.pixelize` ya no existe). Por eso la funcion:
|
||||||
|
|
||||||
|
1. Intenta `import pixeloe` en el interprete actual y ejecuta el nucleo directo.
|
||||||
|
2. Si falta (`ModuleNotFoundError`), re-ejecuta este mismo archivo como subprocess
|
||||||
|
(`python pixeloe_downscale.py --bridge <json>`) con un interprete que SI la
|
||||||
|
tenga, parseando la unica linea JSON que ese hijo imprime a stdout.
|
||||||
|
3. Si no hay ningun interprete con pixeloe, devuelve ok=False (sin excepcion);
|
||||||
|
el pipeline que la llama hara fallback.
|
||||||
|
|
||||||
|
La funcion es no-throw: cualquier error se captura y viaja en el campo `error`.
|
||||||
|
Determinista; impura solo por la lectura/escritura de disco y el subprocess.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_comfy_python(comfy_python):
|
||||||
|
"""Devuelve el primer interprete candidato que exista como archivo, o None.
|
||||||
|
|
||||||
|
Orden: arg comfy_python -> env COMFY_PYTHON -> ~/ComfyUI/.venv/bin/python3.
|
||||||
|
"""
|
||||||
|
candidates = []
|
||||||
|
if comfy_python:
|
||||||
|
candidates.append(comfy_python)
|
||||||
|
env = os.environ.get("COMFY_PYTHON")
|
||||||
|
if env:
|
||||||
|
candidates.append(env)
|
||||||
|
candidates.append(os.path.expanduser("~/ComfyUI/.venv/bin/python3"))
|
||||||
|
for c in candidates:
|
||||||
|
if c and os.path.isfile(c):
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _run_core(src_path, dst_path, mode, target_size, patch_size, thickness,
|
||||||
|
color_matching, no_upscale):
|
||||||
|
"""Nucleo no-throw: requiere `pixeloe` importable EN ESTE interprete.
|
||||||
|
|
||||||
|
Lee src como RGB uint8 (numpy + PIL, sin cv2), llama
|
||||||
|
`pixeloe.legacy.pixelize.pixelize` y guarda el resultado como PNG. Devuelve el
|
||||||
|
dict de resultado. NO lanza excepciones: las captura en `error`.
|
||||||
|
"""
|
||||||
|
out = {
|
||||||
|
"ok": False,
|
||||||
|
"out_path": "",
|
||||||
|
"size": [0, 0],
|
||||||
|
"mode": mode,
|
||||||
|
"target_size": int(target_size),
|
||||||
|
"via": "inproc",
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
except Exception as exc: # noqa: BLE001 - degradacion limpia, no relanzar
|
||||||
|
out["error"] = f"numpy/PIL no disponible en este interprete: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
if not os.path.isfile(src_path):
|
||||||
|
out["error"] = f"src_path no existe: {src_path!r}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pixeloe.legacy.pixelize import pixelize
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
out["error"] = f"no se pudo importar pixeloe.legacy.pixelize: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = np.array(Image.open(src_path).convert("RGB")) # HxWx3 uint8
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
out["error"] = f"no se pudo leer/decodificar {src_path!r}: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = pixelize(
|
||||||
|
img,
|
||||||
|
mode=mode,
|
||||||
|
target_size=int(target_size),
|
||||||
|
patch_size=int(patch_size),
|
||||||
|
thickness=int(thickness),
|
||||||
|
contrast=1.0,
|
||||||
|
saturation=1.0,
|
||||||
|
color_matching=bool(color_matching),
|
||||||
|
no_upscale=bool(no_upscale),
|
||||||
|
)
|
||||||
|
except TypeError as exc:
|
||||||
|
# Firma de pixelize distinta a la esperada: reseñar, no relanzar.
|
||||||
|
out["error"] = f"pixelize rechazo los kwargs (firma distinta?): {exc}"
|
||||||
|
return out
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
out["error"] = f"pixelize fallo: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
try:
|
||||||
|
arr = np.asarray(res)
|
||||||
|
result_img = Image.fromarray(arr)
|
||||||
|
dst_dir = os.path.dirname(os.path.abspath(dst_path))
|
||||||
|
os.makedirs(dst_dir, exist_ok=True)
|
||||||
|
result_img.save(dst_path)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
out["error"] = f"no se pudo escribir {dst_path!r}: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
out.update(ok=True, out_path=dst_path, size=list(result_img.size), error="")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _run_via_bridge(interp, src_path, dst_path, mode, target_size, patch_size,
|
||||||
|
thickness, color_matching, no_upscale):
|
||||||
|
"""Ejecuta el nucleo en otro interprete (que tiene pixeloe) via subprocess.
|
||||||
|
|
||||||
|
Corre `interp <este_archivo> --bridge <json_args>` y parsea la ultima linea de
|
||||||
|
stdout que sea JSON valido (pixeloe puede emitir ruido antes). No-throw.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
out = {
|
||||||
|
"ok": False,
|
||||||
|
"out_path": "",
|
||||||
|
"size": [0, 0],
|
||||||
|
"mode": mode,
|
||||||
|
"target_size": int(target_size),
|
||||||
|
"via": "bridge",
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
args = {
|
||||||
|
"src_path": src_path,
|
||||||
|
"dst_path": dst_path,
|
||||||
|
"mode": mode,
|
||||||
|
"target_size": int(target_size),
|
||||||
|
"patch_size": int(patch_size),
|
||||||
|
"thickness": int(thickness),
|
||||||
|
"color_matching": bool(color_matching),
|
||||||
|
"no_upscale": bool(no_upscale),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[interp, os.path.abspath(__file__), "--bridge", json.dumps(args)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=600,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
out["error"] = f"fallo el subprocess bridge ({interp}): {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
tail = (proc.stderr or "").strip()[-500:]
|
||||||
|
out["error"] = f"bridge salio con codigo {proc.returncode}: {tail}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
# Parsea de atras hacia delante la primera linea que sea JSON valido.
|
||||||
|
parsed = None
|
||||||
|
for ln in reversed((proc.stdout or "").splitlines()):
|
||||||
|
ln = ln.strip()
|
||||||
|
if not ln:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(ln)
|
||||||
|
break
|
||||||
|
except Exception: # noqa: BLE001 - linea de ruido, sigue probando
|
||||||
|
continue
|
||||||
|
|
||||||
|
if parsed is None:
|
||||||
|
tail = (proc.stderr or "").strip()[-300:]
|
||||||
|
out["error"] = f"bridge no produjo salida JSON. stderr: {tail}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
parsed["via"] = "bridge"
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def pixeloe_downscale(
|
||||||
|
src_path: str,
|
||||||
|
dst_path: str,
|
||||||
|
*,
|
||||||
|
mode: str = "contrast",
|
||||||
|
target_size: int = 64,
|
||||||
|
patch_size: int = 16,
|
||||||
|
thickness: int = 2,
|
||||||
|
color_matching: bool = True,
|
||||||
|
no_upscale: bool = True,
|
||||||
|
comfy_python: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Downscale contrast-aware de una imagen a un grid de pixel-art (no cuantiza).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
src_path: ruta de la imagen de entrada (PNG/JPG/...).
|
||||||
|
dst_path: ruta del PNG de salida (se crea el directorio si falta).
|
||||||
|
mode: algoritmo de downscale de pixeloe: "contrast" (SOTA, conserva
|
||||||
|
silueta), "bicubic", "nearest", "center" o "k-centroid". keyword-only.
|
||||||
|
target_size: lado del grid resultante en pixeles (64 personajes, 32
|
||||||
|
iconos). keyword-only.
|
||||||
|
patch_size: tamano del patch que pixeloe colapsa por celda. keyword-only.
|
||||||
|
thickness: grosor de la expansion de contorno (outline). keyword-only.
|
||||||
|
color_matching: corrige el color de cada celda contra el original si True.
|
||||||
|
keyword-only.
|
||||||
|
no_upscale: True devuelve el grid real target_size x target_size (lo
|
||||||
|
habitual para luego cuantizar); False re-escala al tamano original con
|
||||||
|
pixeles duros (preview). keyword-only.
|
||||||
|
comfy_python: ruta a un interprete con `pixeloe` para el bridge cuando el
|
||||||
|
actual no la tiene. Si None, se prueba COMFY_PYTHON y luego
|
||||||
|
~/ComfyUI/.venv/bin/python3. keyword-only.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con:
|
||||||
|
- ok (bool): True si se hizo el downscale y se guardo el PNG.
|
||||||
|
- out_path (str): ruta del PNG generado.
|
||||||
|
- size (list[int]): [w, h] de la imagen escrita.
|
||||||
|
- mode (str): modo de downscale usado.
|
||||||
|
- target_size (int): lado del grid pedido.
|
||||||
|
- via (str): "inproc" si pixeloe estaba en este interprete, "bridge" si se
|
||||||
|
delego a otro interprete por subprocess.
|
||||||
|
- error (str): mensaje de error; cadena vacia si todo OK.
|
||||||
|
"""
|
||||||
|
out = {
|
||||||
|
"ok": False,
|
||||||
|
"out_path": "",
|
||||||
|
"size": [0, 0],
|
||||||
|
"mode": mode,
|
||||||
|
"target_size": int(target_size),
|
||||||
|
"via": "",
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not os.path.isfile(src_path):
|
||||||
|
out["error"] = f"src_path no existe: {src_path!r}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
# 1. pixeloe disponible en el interprete actual -> nucleo directo.
|
||||||
|
has_local = True
|
||||||
|
try:
|
||||||
|
import pixeloe # noqa: F401
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
has_local = False
|
||||||
|
except Exception: # noqa: BLE001 - pixeloe presente pero roto -> bridge
|
||||||
|
has_local = False
|
||||||
|
|
||||||
|
if has_local:
|
||||||
|
res = _run_core(
|
||||||
|
src_path, dst_path, mode, int(target_size), int(patch_size),
|
||||||
|
int(thickness), bool(color_matching), bool(no_upscale),
|
||||||
|
)
|
||||||
|
res["via"] = "inproc"
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 2. Bridge a un interprete que tenga pixeloe.
|
||||||
|
interp = _resolve_comfy_python(comfy_python)
|
||||||
|
if interp is None:
|
||||||
|
out["error"] = (
|
||||||
|
"pixeloe no disponible: no se encontro ningun interprete con "
|
||||||
|
"pixeloe (pasa comfy_python, define COMFY_PYTHON, o instala "
|
||||||
|
"~/ComfyUI/.venv)"
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
return _run_via_bridge(
|
||||||
|
interp, src_path, dst_path, mode, int(target_size), int(patch_size),
|
||||||
|
int(thickness), bool(color_matching), bool(no_upscale),
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - contrato no-throw
|
||||||
|
out["error"] = f"error inesperado: {exc}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if "--bridge" in sys.argv:
|
||||||
|
# Modo bridge: ejecuta el nucleo y emite UNA linea JSON a stdout.
|
||||||
|
_idx = sys.argv.index("--bridge")
|
||||||
|
_payload = sys.argv[_idx + 1] if len(sys.argv) > _idx + 1 else "{}"
|
||||||
|
try:
|
||||||
|
_a = json.loads(_payload)
|
||||||
|
except Exception as _exc: # noqa: BLE001
|
||||||
|
print(json.dumps({
|
||||||
|
"ok": False, "out_path": "", "size": [0, 0], "mode": "",
|
||||||
|
"target_size": 0, "via": "inproc",
|
||||||
|
"error": f"payload --bridge invalido: {_exc}",
|
||||||
|
}))
|
||||||
|
sys.exit(0)
|
||||||
|
_res = _run_core(
|
||||||
|
_a.get("src_path", ""),
|
||||||
|
_a.get("dst_path", ""),
|
||||||
|
_a.get("mode", "contrast"),
|
||||||
|
_a.get("target_size", 64),
|
||||||
|
_a.get("patch_size", 16),
|
||||||
|
_a.get("thickness", 2),
|
||||||
|
_a.get("color_matching", True),
|
||||||
|
_a.get("no_upscale", True),
|
||||||
|
)
|
||||||
|
print(json.dumps(_res))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Modo CLI normal.
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("uso: pixeloe_downscale.py <src> <dst> [target_size] [mode]",
|
||||||
|
file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
_src, _dst = sys.argv[1], sys.argv[2]
|
||||||
|
_ts = int(sys.argv[3]) if len(sys.argv) > 3 else 64
|
||||||
|
_md = sys.argv[4] if len(sys.argv) > 4 else "contrast"
|
||||||
|
print(json.dumps(pixeloe_downscale(_src, _dst, target_size=_ts, mode=_md),
|
||||||
|
indent=2))
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user