feat(datascience): promover remove_background al registry + mask en depth_to_relief_glb (grupo img-to-3d)
Completa la promoción del flujo imagen->3D al registry (grupo de capacidad img-to-3d), extraído de la app img_to_3d_webapp. - remove_background_py_datascience (nueva): elimina el fondo con cascada rembg/U2Net -> OpenCV GrabCut -> umbral NumPy, compone el objeto sobre gris neutro y devuelve image + mask + engine. Impura, nunca lanza. Adaptada de backend/bg_removal.py con firma de ruta (image_path) y salida dict, demo CLI JSON-serializable. - depth_to_relief_glb_py_datascience (v1.1.0): añade el parámetro opcional mask para recortar la malla de relieve al objeto (descarta las caras del fondo), cerrando la cadena con remove_background. Aditivo (mask=None = comportamiento previo), fiel al original de backend/depth.py. - docs/capabilities/img-to-3d.md: incorpora remove_background como paso 0 (pre-proceso), actualiza el flujo a 3 pasos encadenados, la tabla de funciones (4), el ejemplo end-to-end con mask y las deps (rembg/opencv). - docs/capabilities/INDEX.md: conteo del grupo 3 -> 4. Las dos funciones ya presentes (estimate_image_depth, depth_to_relief_glb) y el pipeline build_relief_glb_from_image fueron promovidas en una ronda previa. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -42,7 +42,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
|||||||
| [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output |
|
| [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output |
|
||||||
| [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs |
|
| [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs |
|
||||||
| [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI |
|
| [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI |
|
||||||
| [img-to-3d](img-to-3d.md) | 3 | Imagen 2D -> modelo 3D: profundidad monocular (Depth-Anything-V2) + malla de relieve texturizada exportada a .glb, con pipeline one-shot. Produce el glb que mesh-3d consume/renderiza |
|
| [img-to-3d](img-to-3d.md) | 4 | Imagen 2D -> modelo 3D: recorte de fondo (rembg/GrabCut/umbral) + profundidad monocular (Depth-Anything-V2) + malla de relieve texturizada exportada a .glb, con pipeline one-shot. Produce el glb que mesh-3d consume/renderiza |
|
||||||
| [whatsapp](whatsapp.md) | 3 | Operar WhatsApp Web por CDP sobre la pestaña existente (sin ventana ni foco): buscar/abrir chat, leer conversacion, enviar texto. Compone 4 primitivas CDP-Python (cdp_eval/type_chars/press_key/click_xy). No HTTP: WhatsApp usa WebSocket + cifrado E2E |
|
| [whatsapp](whatsapp.md) | 3 | Operar WhatsApp Web por CDP sobre la pestaña existente (sin ventana ni foco): buscar/abrir chat, leer conversacion, enviar texto. Compone 4 primitivas CDP-Python (cdp_eval/type_chars/press_key/click_xy). No HTTP: WhatsApp usa WebSocket + cifrado E2E |
|
||||||
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
|
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
|
||||||
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
|
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
|
||||||
|
|||||||
@@ -10,24 +10,27 @@ partir de una sola foto se estima un mapa de profundidad monocular con un modelo
|
|||||||
reconstruye una malla de relieve (heightmap) texturizada con la imagen original, exportada como
|
reconstruye una malla de relieve (heightmap) texturizada con la imagen original, exportada como
|
||||||
`.glb` cargable por cualquier visor glTF (three.js `useGLTF`/`GLTFLoader`, Babylon, model-viewer).
|
`.glb` cargable por cualquier visor glTF (three.js `useGLTF`/`GLTFLoader`, Babylon, model-viewer).
|
||||||
|
|
||||||
Promovido desde la app `img_to_3d_webapp` (su backend incrustaba estas dos funciones; ver su
|
Promovido desde la app `img_to_3d_webapp` (su backend incrustaba estas funciones; ver
|
||||||
`backend/depth.py`). El flujo canonico es de **dos pasos encadenados**:
|
`backend/depth.py` y `backend/bg_removal.py`). El flujo canonico encadena un pre-proceso opcional
|
||||||
|
de fondo con los dos pasos de reconstruccion:
|
||||||
|
|
||||||
```
|
```
|
||||||
estimate_image_depth (imagen -> depth+image) -> depth_to_relief_glb (depth+image -> .glb)
|
[remove_background (imagen -> rgb+mask)] -> estimate_image_depth (imagen -> depth+image) -> depth_to_relief_glb (depth+image[+mask] -> .glb)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Funciones
|
## Funciones
|
||||||
|
|
||||||
| ID | Firma corta | Que hace |
|
| ID | Firma corta | Que hace |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| `remove_background_py_datascience` | `remove_background(image_path, engine?) -> dict` | **Pre-proceso (paso 0).** Elimina el fondo en cascada rembg -> GrabCut -> umbral y compone el objeto sobre gris neutro. Devuelve `image` PIL + `mask` ndarray. La `mask` se pasa a `depth_to_relief_glb` para recortar la malla al objeto. |
|
||||||
| `estimate_image_depth_py_datascience` | `estimate_image_depth(image_path, model_name?, device?, use_cache?) -> dict` | Estima profundidad monocular con Depth-Anything-V2 (GPU/CPU). Devuelve `depth` ndarray [0,1] + `image` PIL. Paso 1. |
|
| `estimate_image_depth_py_datascience` | `estimate_image_depth(image_path, model_name?, device?, use_cache?) -> dict` | Estima profundidad monocular con Depth-Anything-V2 (GPU/CPU). Devuelve `depth` ndarray [0,1] + `image` PIL. Paso 1. |
|
||||||
| `depth_to_relief_glb_py_datascience` | `depth_to_relief_glb(image, depth, out_glb_path, z_scale?, max_dim?) -> dict` | Convierte `depth`+`image` en una malla de relieve texturizada y la exporta a `.glb`. Paso 2. |
|
| `depth_to_relief_glb_py_datascience` | `depth_to_relief_glb(image, depth, out_glb_path, z_scale?, max_dim?, mask?) -> dict` | Convierte `depth`+`image` en una malla de relieve texturizada y la exporta a `.glb`. Con `mask` opcional recorta las caras del fondo. Paso 2. |
|
||||||
| `build_relief_glb_from_image_py_pipelines` | `build_relief_glb_from_image(image_path, out_glb_path, model_name?, device?, z_scale?, max_dim?) -> dict` | **Pipeline one-shot**: compone los dos pasos en una sola llamada (imagen -> .glb). Salida JSON-serializable, apta para `fn run`. |
|
| `build_relief_glb_from_image_py_pipelines` | `build_relief_glb_from_image(image_path, out_glb_path, model_name?, device?, z_scale?, max_dim?) -> dict` | **Pipeline one-shot**: compone estimacion + relieve en una sola llamada (imagen -> .glb). Salida JSON-serializable, apta para `fn run`. |
|
||||||
|
|
||||||
Las tres son **impuras** (cargan modelo / GPU / escriben archivo), devuelven `dict` con `status`
|
Las cuatro son **impuras** (cargan modelo / GPU / escriben archivo), devuelven `dict` con `status`
|
||||||
(`ok`/`error`) y **nunca lanzan**: los fallos vuelven como `{status:'error', error:str}`. El
|
(`ok`/`error`) y **nunca lanzan**: los fallos vuelven como `{status:'error', error:str}`. El
|
||||||
pipeline ademas marca `stage` (`estimate`/`relief`) en el error.
|
pipeline ademas marca `stage` (`estimate`/`relief`) en el error. `remove_background` en
|
||||||
|
`engine="auto"` nunca falla (cae al umbral NumPy puro sin deps externas).
|
||||||
|
|
||||||
## Ejemplo canonico (end-to-end imagen → glb)
|
## Ejemplo canonico (end-to-end imagen → glb)
|
||||||
|
|
||||||
@@ -37,17 +40,24 @@ pipeline ademas marca `stage` (`estimate`/`relief`) en el error.
|
|||||||
# ausentes en el venv de vision. Ver "Fronteras / gotchas".
|
# ausentes en el venv de vision. Ver "Fronteras / gotchas".
|
||||||
import sys
|
import sys
|
||||||
sys.path.insert(0, "python/functions/datascience")
|
sys.path.insert(0, "python/functions/datascience")
|
||||||
|
from remove_background import remove_background
|
||||||
from estimate_image_depth import estimate_image_depth
|
from estimate_image_depth import estimate_image_depth
|
||||||
from depth_to_relief_glb import depth_to_relief_glb
|
from depth_to_relief_glb import depth_to_relief_glb
|
||||||
|
|
||||||
IMG = "apps/img_to_3d_webapp/samples/cats.jpg"
|
IMG = "apps/img_to_3d_webapp/samples/cats.jpg"
|
||||||
OUT = "/tmp/cats_relief.glb"
|
OUT = "/tmp/cats_relief.glb"
|
||||||
|
|
||||||
|
# Paso 0 (opcional pero recomendado): aislar el objeto del fondo. La mask recorta la malla.
|
||||||
|
cut = remove_background(IMG) # engine='auto' -> rembg -> grabcut -> umbral
|
||||||
|
assert cut["status"] == "ok"
|
||||||
|
print(cut["engine"], cut["fg_fraction"]) # p.ej. rembg:u2net 0.42
|
||||||
|
|
||||||
est = estimate_image_depth(IMG) # device='auto' -> GPU si hay
|
est = estimate_image_depth(IMG) # device='auto' -> GPU si hay
|
||||||
assert est["status"] == "ok"
|
assert est["status"] == "ok"
|
||||||
# est["depth"]: ndarray HxW float32 [0,1] (1=mas cerca) | est["image"]: PIL.Image RGB
|
# est["depth"]: ndarray HxW float32 [0,1] (1=mas cerca) | est["image"]: PIL.Image RGB
|
||||||
|
|
||||||
res = depth_to_relief_glb(est["image"], est["depth"], OUT, z_scale=0.35, max_dim=220)
|
# Pasando la mask del paso 0, las caras del fondo se descartan: malla solo del objeto.
|
||||||
|
res = depth_to_relief_glb(est["image"], est["depth"], OUT, z_scale=0.35, max_dim=220, mask=cut["mask"])
|
||||||
assert res["status"] == "ok"
|
assert res["status"] == "ok"
|
||||||
print(res["glb_path"], res["vertices"], res["faces"]) # /tmp/cats_relief.glb 36300 71832
|
print(res["glb_path"], res["vertices"], res["faces"]) # /tmp/cats_relief.glb 36300 71832
|
||||||
# OUT es un glTF binario valido: trimesh.load(OUT) devuelve una Scene texturizada.
|
# OUT es un glTF binario valido: trimesh.load(OUT) devuelve una Scene texturizada.
|
||||||
@@ -70,15 +80,19 @@ O en una sola llamada con el pipeline (recomendado para fn run / Launcher TUI):
|
|||||||
- **No cubre el render/visualizacion.** Producir el `.glb` es el limite del grupo. Cargarlo y
|
- **No cubre el render/visualizacion.** Producir el `.glb` es el limite del grupo. Cargarlo y
|
||||||
subirlo a GPU (OpenGL) en una app C++/ImGui es el grupo **`mesh-3d`** (`gltf_load_mesh_cpp_gfx`
|
subirlo a GPU (OpenGL) en una app C++/ImGui es el grupo **`mesh-3d`** (`gltf_load_mesh_cpp_gfx`
|
||||||
carga justamente este tipo de `.glb`). img-to-3d **produce**; mesh-3d **consume/renderiza**.
|
carga justamente este tipo de `.glb`). img-to-3d **produce**; mesh-3d **consume/renderiza**.
|
||||||
- **Deps pesadas y de dos mundos.** Requiere `torch`+`transformers` (vision) y `trimesh` (mesh),
|
- **Deps pesadas y de dos mundos.** Requiere `torch`+`transformers` (vision), `trimesh` (mesh) y,
|
||||||
que hoy viven en el venv de `img_to_3d_webapp`, NO en el venv del registry. Ademas el
|
para `remove_background`, `rembg`+`onnxruntime` (segmentacion) y `opencv-python` (GrabCut) —
|
||||||
`datascience.__init__` arrastra deps de scrapers (`bs4`...) que no estan en el venv de vision,
|
todas opcionales: el umbral de `remove_background` es NumPy puro. Hoy viven en el venv de
|
||||||
por eso el import es **plano** (al modulo) y no via el paquete. `fn run` de estas funciones
|
`img_to_3d_webapp`, NO en el venv del registry. Ademas el `datascience.__init__` arrastra deps
|
||||||
exige un venv que combine ambos mundos (torch + transformers + trimesh + las deps del dominio
|
de scrapers (`bs4`...) que no estan en el venv de vision, por eso el import es **plano** (al
|
||||||
datascience). Ver gotchas en cada `.md`.
|
modulo) y no via el paquete. `fn run` de estas funciones exige un venv que combine ambos mundos
|
||||||
|
(torch + transformers + trimesh + rembg/opencv + las deps del dominio datascience). Ver gotchas
|
||||||
|
en cada `.md`.
|
||||||
|
|
||||||
## Prerequisitos
|
## Prerequisitos
|
||||||
|
|
||||||
- GPU NVIDIA + CUDA recomendada (corre en CPU pero lento). Primera ejecucion descarga los pesos
|
- GPU NVIDIA + CUDA recomendada (corre en CPU pero lento). Primera ejecucion descarga los pesos
|
||||||
del modelo a `~/.cache/huggingface/` (cientos de MB segun la variante).
|
del modelo de profundidad a `~/.cache/huggingface/` y el de `rembg` (U2Net ~170 MB) a su cache.
|
||||||
- Paquetes: `torch`, `transformers`, `trimesh`, `pillow`, `numpy`.
|
- Paquetes: `torch`, `transformers`, `trimesh`, `pillow`, `numpy`. Para el recorte de fondo de
|
||||||
|
mayor calidad: `rembg` (+`onnxruntime`) y `opencv-python` (ambos opcionales; sin ellos
|
||||||
|
`remove_background` cae al umbral NumPy).
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ name: depth_to_relief_glb
|
|||||||
kind: function
|
kind: function
|
||||||
lang: py
|
lang: py
|
||||||
domain: datascience
|
domain: datascience
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "def depth_to_relief_glb(image: Image.Image, depth: np.ndarray, out_glb_path: str, z_scale: float = 0.35, max_dim: int = 220) -> dict"
|
signature: "def depth_to_relief_glb(image: Image.Image, depth: np.ndarray, out_glb_path: str, z_scale: float = 0.35, max_dim: int = 220, mask: np.ndarray | None = None) -> dict"
|
||||||
description: "Construye una malla de relieve (heightmap) texturizada a partir de un mapa de profundidad + la imagen original y la exporta como glTF binario (.glb). El depth se vuelve el eje Z de un grid regular de vertices y la imagen se mapea como textura UV. Paso 2 del flujo img->3D (grupo img-to-3d): consume la salida de estimate_image_depth."
|
description: "Construye una malla de relieve (heightmap) texturizada a partir de un mapa de profundidad + la imagen original y la exporta como glTF binario (.glb). El depth se vuelve el eje Z de un grid regular de vertices y la imagen se mapea como textura UV. Con mask opcional recorta la malla al objeto (descarta las caras del fondo). Paso 2 del flujo img->3D (grupo img-to-3d): consume la salida de estimate_image_depth y, opcionalmente, la mask de remove_background."
|
||||||
tags: [img-to-3d, datascience, mesh, glb, gltf, relief, heightmap, trimesh, 3d, texture]
|
tags: [img-to-3d, datascience, mesh, glb, gltf, relief, heightmap, trimesh, 3d, texture]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
@@ -25,7 +25,9 @@ params:
|
|||||||
desc: "Amplitud del relieve como fraccion del lado de la malla (default 0.35). Mayor = relieve mas pronunciado/exagerado."
|
desc: "Amplitud del relieve como fraccion del lado de la malla (default 0.35). Mayor = relieve mas pronunciado/exagerado."
|
||||||
- name: max_dim
|
- name: max_dim
|
||||||
desc: "Lado maximo del grid tras downsample bilineal (default 220, ~48k vertices / ~96k caras). Controla resolucion de la malla vs tamano del .glb. Imagenes mayores se reducen; menores se dejan igual."
|
desc: "Lado maximo del grid tras downsample bilineal (default 220, ~48k vertices / ~96k caras). Controla resolucion de la malla vs tamano del .glb. Imagenes mayores se reducen; menores se dejan igual."
|
||||||
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int}. Error: {status:'error', error:str} (depth con forma invalida, directorio de salida inexistente, fallo de trimesh.export). No lanza."
|
- name: mask
|
||||||
|
desc: "Mascara opcional HxW (0..255, 255=objeto), tipicamente la 'mask' de remove_background. Si se pasa, se reescala al grid (NEAREST), el fondo se aplana a Z=0 y las caras cuyos tres vertices caen en el fondo se descartan: la malla queda recortada al objeto. None (default) = malla del frame completo (relieve incluido el fondo)."
|
||||||
|
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int}. Con mask, 'faces' es menor (solo caras del objeto); 'vertices' no cambia (el grid completo se conserva). Error: {status:'error', error:str} (depth con forma invalida, directorio de salida inexistente, fallo de trimesh.export). No lanza."
|
||||||
tested: false
|
tested: false
|
||||||
tests: []
|
tests: []
|
||||||
test_file_path: ""
|
test_file_path: ""
|
||||||
@@ -81,3 +83,14 @@ suavizar el relieve.
|
|||||||
- **Import plano**: importa el modulo directo, NO `from datascience import ...` (el `__init__` del
|
- **Import plano**: importa el modulo directo, NO `from datascience import ...` (el `__init__` del
|
||||||
paquete arrastra deps de otros dominios ausentes en el venv de vision). Ver misma gotcha en
|
paquete arrastra deps de otros dominios ausentes en el venv de vision). Ver misma gotcha en
|
||||||
`estimate_image_depth`.
|
`estimate_image_depth`.
|
||||||
|
- **mask opcional (v1.1.0)**: pasa la `mask` de `remove_background` para recortar la malla al
|
||||||
|
objeto. Se reescala con NEAREST (sin interpolar, preserva el borde binario), el fondo se aplana
|
||||||
|
a Z=0 y sus caras se eliminan. El nº de `vertices` no baja (el grid completo se conserva para no
|
||||||
|
romper el mapeo UV 1:1); solo baja `faces`. Una mask degenerada (todo objeto) deja la malla
|
||||||
|
intacta; una mask vacia (todo fondo) deja la malla sin caras (glb valido pero vacio).
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-21) — anade parametro opcional `mask` para recortar la malla al objeto
|
||||||
|
(descarta las caras del fondo), cerrando la cadena con `remove_background` del grupo img-to-3d.
|
||||||
|
Aditivo: `mask=None` mantiene el comportamiento previo. Fiel al original de `backend/depth.py`.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ def depth_to_relief_glb(
|
|||||||
out_glb_path: str,
|
out_glb_path: str,
|
||||||
z_scale: float = 0.35,
|
z_scale: float = 0.35,
|
||||||
max_dim: int = 220,
|
max_dim: int = 220,
|
||||||
|
mask: "np.ndarray | None" = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Construye una malla de relieve texturizada y la exporta como .glb.
|
Construye una malla de relieve texturizada y la exporta como .glb.
|
||||||
@@ -33,6 +34,9 @@ def depth_to_relief_glb(
|
|||||||
z_scale: amplitud del relieve (fracción del lado de la malla). Default 0.35.
|
z_scale: amplitud del relieve (fracción del lado de la malla). Default 0.35.
|
||||||
max_dim: lado máximo del grid tras downsample (controla nº de vértices/caras).
|
max_dim: lado máximo del grid tras downsample (controla nº de vértices/caras).
|
||||||
Default 220 (~48k vértices, ~96k caras).
|
Default 220 (~48k vértices, ~96k caras).
|
||||||
|
mask: máscara opcional HxW (0..255, 255 = objeto), típicamente la "mask" devuelta por
|
||||||
|
remove_background. Si se pasa, el fondo se aplana y las caras cuyos vértices caigan
|
||||||
|
en el fondo se descartan: la malla contiene solo el objeto, sin el plano de fondo.
|
||||||
|
|
||||||
Devuelve (dict, nunca lanza):
|
Devuelve (dict, nunca lanza):
|
||||||
Éxito: {"status": "ok", "glb_path": out_glb_path, "vertices": int, "faces": int,
|
Éxito: {"status": "ok", "glb_path": out_glb_path, "vertices": int, "faces": int,
|
||||||
@@ -58,6 +62,14 @@ def depth_to_relief_glb(
|
|||||||
depth = np.asarray(depth_img, dtype=np.float32) / 255.0
|
depth = np.asarray(depth_img, dtype=np.float32) / 255.0
|
||||||
H, W = depth.shape
|
H, W = depth.shape
|
||||||
|
|
||||||
|
# Si se pasó máscara (objeto vs fondo), reescalarla al grid ya downsampleado: el fondo
|
||||||
|
# no aporta relieve (se aplana a 0) y luego sus caras se descartan, dejando solo el objeto.
|
||||||
|
fg = None
|
||||||
|
if mask is not None:
|
||||||
|
mask_img = Image.fromarray(np.asarray(mask).astype(np.uint8)).resize((W, H), Image.NEAREST)
|
||||||
|
fg = np.asarray(mask_img) >= 128
|
||||||
|
depth = np.where(fg, depth, 0.0).astype(np.float32)
|
||||||
|
|
||||||
# Coordenadas del grid: X corrige aspect ratio, Y hacia abajo, Z = profundidad.
|
# Coordenadas del grid: X corrige aspect ratio, Y hacia abajo, Z = profundidad.
|
||||||
aspect = W / float(H)
|
aspect = W / float(H)
|
||||||
xs = np.linspace(-aspect / 2.0, aspect / 2.0, W, dtype=np.float32)
|
xs = np.linspace(-aspect / 2.0, aspect / 2.0, W, dtype=np.float32)
|
||||||
@@ -79,6 +91,12 @@ def depth_to_relief_glb(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Con máscara: conservar solo las caras cuyos tres vértices son objeto. La malla queda
|
||||||
|
# recortada al objeto, sin el plano de fondo que deformaría el relieve.
|
||||||
|
if fg is not None:
|
||||||
|
keep = fg.ravel()[faces].all(axis=1)
|
||||||
|
faces = faces[keep]
|
||||||
|
|
||||||
# UV mapeando cada vértice al pixel de la imagen (V invertido para convención glTF).
|
# UV mapeando cada vértice al pixel de la imagen (V invertido para convención glTF).
|
||||||
u = np.linspace(0.0, 1.0, W, dtype=np.float32)
|
u = np.linspace(0.0, 1.0, W, dtype=np.float32)
|
||||||
v = np.linspace(0.0, 1.0, H, dtype=np.float32)
|
v = np.linspace(0.0, 1.0, H, dtype=np.float32)
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
name: remove_background
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: datascience
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def remove_background(image_path: str, engine: str = 'auto') -> dict"
|
||||||
|
description: "Elimina el fondo de una imagen con cascada de motores (rembg/U2Net -> OpenCV GrabCut -> umbral NumPy), compone el objeto sobre fondo gris neutro y devuelve image+mask+engine. Paso de pre-proceso del flujo img->3D (grupo img-to-3d): su mask alimenta depth_to_relief_glb para recortar la malla de relieve al objeto."
|
||||||
|
tags: [img-to-3d, datascience, background-removal, segmentation, rembg, grabcut, opencv, computer-vision, mask]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: image_path
|
||||||
|
desc: "Ruta a la imagen de entrada. Cualquier formato que PIL.Image.open abra (jpg, png, webp, RGBA...). Si no existe o no es imagen valida, se devuelve status error. Un PNG RGBA ya recortado se reaprovecha en modo auto (passthrough:alpha)."
|
||||||
|
- name: engine
|
||||||
|
desc: "Motor de segmentacion. 'auto' (default) prueba en cascada rembg:u2net -> opencv:grabcut -> threshold:border y NUNCA falla (cae al umbral NumPy puro sin deps externas). Forzar uno: 'rembg' (red neuronal U2Net, mejor calidad, deps pesadas), 'grabcut' (OpenCV, rectangulo central), 'threshold' (distancia al color medio de los bordes, NumPy puro, objeto centrado). Si se fuerza un motor y no esta disponible/falla o produce mascara degenerada -> status error."
|
||||||
|
output: "dict. Exito: {status:'ok', image: PIL.Image RGB del objeto compuesto sobre fondo gris neutro (127,127,127), mask: ndarray HxW uint8 (0..255, 255=objeto), engine: str del motor usado ('rembg:u2net' | 'opencv:grabcut' | 'threshold:border' | 'passthrough:alpha'), height:int, width:int, fg_fraction: float (fraccion de pixeles objeto, redondeada a 4 decimales)}. Error: {status:'error', error:str} (ruta invalida, motor desconocido, motor forzado no disponible/fallido, o ningun motor produjo una mascara valida). No lanza nunca. El demo CLI (__main__) imprime un resumen JSON sin el ndarray ni la imagen y, si se pasa out_dir, guarda rgb.png + mask.png."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "python/functions/datascience/remove_background.py"
|
||||||
|
source_file: "apps/img_to_3d_webapp/backend/bg_removal.py"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Requiere un venv con pillow + numpy (rembg/opencv solo si fuerzas esos motores; el umbral es NumPy puro).
|
||||||
|
# Import PLANO al modulo: el paquete datascience.__init__ arrastra deps de otros dominios
|
||||||
|
# (bs4, duckdb...) que no estan en ese venv. Ver Gotchas.
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "python/functions/datascience")
|
||||||
|
from remove_background import remove_background
|
||||||
|
|
||||||
|
res = remove_background("apps/img_to_3d_webapp/samples/cats.jpg", engine="auto")
|
||||||
|
assert res["status"] == "ok"
|
||||||
|
print(res["engine"]) # p.ej. "rembg:u2net" (o "opencv:grabcut" / "threshold:border")
|
||||||
|
print(res["height"], res["width"]) # p.ej. 1024 768
|
||||||
|
print(res["mask"].shape, res["mask"].dtype) # (1024, 768) uint8 (255=objeto)
|
||||||
|
assert 0.0 < res["fg_fraction"] < 1.0
|
||||||
|
# res["mask"] (ndarray HxW uint8) alimenta depth_to_relief_glb para recortar la malla al objeto.
|
||||||
|
# res["image"] es el objeto compuesto sobre gris neutro, listo para estimar profundidad.
|
||||||
|
```
|
||||||
|
|
||||||
|
Lanzable como demo (imprime resumen JSON, sin serializar el ndarray; guarda PNGs si das out_dir):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fn run remove_background_py_datascience apps/img_to_3d_webapp/samples/cats.jpg auto /tmp/cut
|
||||||
|
# {"status": "ok", "engine": "rembg:u2net", "height": 1024, "width": 768,
|
||||||
|
# "fg_fraction": 0.4123, "rgb_path": "/tmp/cut/rgb.png", "mask_path": "/tmp/cut/mask.png"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Como pre-proceso ANTES de estimar profundidad en el flujo img->3D: aislar el objeto evita que el
|
||||||
|
modelo de profundidad estire el fondo plano, y la `mask` permite recortar la malla de relieve al
|
||||||
|
objeto (se pasa a `depth_to_relief_glb`). Tambien para segmentacion de primer plano generica
|
||||||
|
cuando necesitas separar un objeto de su fondo y componerlo sobre un color neutro (recortes para
|
||||||
|
catalogos, datasets, miniaturas).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Impura**: segun el motor carga modelos neuronales y lee disco. `rembg`/`onnxruntime` (~170MB)
|
||||||
|
DESCARGA el modelo U2Net la primera vez a su cache (`~/.u2net/`), requiere red en esa primera
|
||||||
|
carga; `opencv-python` para GrabCut; el umbral (`threshold:border`) es NumPy puro sin deps externas.
|
||||||
|
- **Estado de proceso**: `_REMBG_SESSION` cachea la sesion rembg a nivel de modulo para no recargar
|
||||||
|
los pesos en cada llamada. Es estado mutable compartido del proceso y ocupa RAM hasta que el
|
||||||
|
interprete muere.
|
||||||
|
- **engine='auto' nunca lanza**: prueba rembg -> grabcut -> threshold y siempre cae al umbral NumPy
|
||||||
|
puro si los anteriores no estan disponibles o fallan. Forzar un motor concreto SI puede devolver
|
||||||
|
status error (motor no instalado, fallo, o mascara degenerada).
|
||||||
|
- **Mascara degenerada**: si la fraccion de objeto resulta `< 0.01` o `> 0.995` la mascara se
|
||||||
|
descarta (casi todo fondo o casi todo objeto) y en modo auto se prueba el siguiente motor.
|
||||||
|
- **threshold:border es de baja calidad**: asume objeto centrado con los bordes de la imagen siendo
|
||||||
|
fondo (calcula la distancia al color medio de los bordes). Es el fallback de ultimo recurso.
|
||||||
|
- **passthrough:alpha**: si la imagen ya viene recortada (PNG RGBA con alfa por debajo de 128) se
|
||||||
|
reutiliza su canal alfa como mascara, SOLO en modo auto. Si fuerzas un motor concreto se respeta
|
||||||
|
esa eleccion e ignora el alfa existente.
|
||||||
|
- **Import plano**: importa el modulo directo (`sys.path` a `python/functions/datascience` +
|
||||||
|
`from remove_background import remove_background`), NO `from datascience import ...`. El
|
||||||
|
`datascience.__init__` carga todo el dominio (scrapers con bs4, duckdb...) con deps ajenas a esta
|
||||||
|
funcion que romperian el import del paquete en el venv de vision.
|
||||||
|
- Nunca lanza: errores (ruta invalida, motor forzado no disponible, OOM) vuelven como
|
||||||
|
`{status:'error', error:str}`.
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
Eliminación de fondo de una imagen con cascada de motores (rembg -> GrabCut -> umbral).
|
||||||
|
|
||||||
|
Función del registry (grupo de capacidad `img-to-3d`, dominio `datascience`). Promovida desde
|
||||||
|
la app `img_to_3d_webapp` (backend/bg_removal.py) para que cualquier artefacto pueda aislar el
|
||||||
|
objeto de primer plano sin reimplementar la cascada de segmentación ni la composición sobre fondo
|
||||||
|
neutro.
|
||||||
|
|
||||||
|
Impura: carga modelos neuronales (rembg/U2Net), usa GPU/CPU vía onnxruntime, lee disco y mantiene
|
||||||
|
una caché de sesión rembg a nivel de proceso para no recargar los pesos en cada llamada. Las deps
|
||||||
|
pesadas (rembg, opencv) se importan dentro de los helpers (lazy) para que el módulo se pueda
|
||||||
|
importar sin ellas; el motor de umbral es NumPy puro sin deps externas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# Fondo gris neutro sobre el que se compone el objeto recortado.
|
||||||
|
NEUTRAL_BG = (127, 127, 127)
|
||||||
|
# Umbral de alfa para considerar un PNG RGBA "ya recortado" (passthrough).
|
||||||
|
_ALPHA_THRESH = 128
|
||||||
|
# Sesión rembg cacheada a nivel de proceso (estado mutable: ver .md "Gotchas").
|
||||||
|
_REMBG_SESSION = None
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_alpha_mask(image):
|
||||||
|
"""Devuelve el canal alfa como máscara HxW uint8 si la imagen ya viene recortada, si no None."""
|
||||||
|
if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
|
||||||
|
alpha = np.asarray(image.convert("RGBA"))[:, :, 3]
|
||||||
|
if alpha.min() < _ALPHA_THRESH:
|
||||||
|
return alpha
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _composite_over_neutral(image_rgb, mask):
|
||||||
|
"""Compone la imagen RGB sobre el fondo gris neutro usando la máscara como alfa."""
|
||||||
|
rgb = np.asarray(image_rgb.convert("RGB"), dtype=np.float32)
|
||||||
|
alpha = (mask.astype(np.float32) / 255.0)[:, :, None]
|
||||||
|
bg = np.empty_like(rgb)
|
||||||
|
bg[:] = NEUTRAL_BG
|
||||||
|
out = rgb * alpha + bg * (1.0 - alpha)
|
||||||
|
return Image.fromarray(out.clip(0, 255).astype(np.uint8), mode="RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_with_rembg(image):
|
||||||
|
"""Segmenta con rembg (modelo U2Net). Devuelve (mask HxW uint8, engine_str)."""
|
||||||
|
global _REMBG_SESSION
|
||||||
|
from rembg import new_session, remove
|
||||||
|
|
||||||
|
if _REMBG_SESSION is None:
|
||||||
|
_REMBG_SESSION = new_session("u2net")
|
||||||
|
cut = remove(image.convert("RGB"), session=_REMBG_SESSION)
|
||||||
|
mask = np.asarray(cut.convert("RGBA"))[:, :, 3]
|
||||||
|
return mask, "rembg:u2net"
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_with_grabcut(image):
|
||||||
|
"""Segmenta con OpenCV GrabCut (rectángulo central). Devuelve (mask HxW uint8, engine_str)."""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
rgb = np.asarray(image.convert("RGB"))
|
||||||
|
h, w = rgb.shape[:2]
|
||||||
|
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
|
||||||
|
gc_mask = np.zeros((h, w), np.uint8)
|
||||||
|
bgd_model = np.zeros((1, 65), np.float64)
|
||||||
|
fgd_model = np.zeros((1, 65), np.float64)
|
||||||
|
margin_x, margin_y = int(0.08 * w), int(0.08 * h)
|
||||||
|
rect = (margin_x, margin_y, max(1, w - 2 * margin_x), max(1, h - 2 * margin_y))
|
||||||
|
cv2.grabCut(bgr, gc_mask, rect, bgd_model, fgd_model, 5, cv2.GC_INIT_WITH_RECT)
|
||||||
|
fg = np.where((gc_mask == cv2.GC_FGD) | (gc_mask == cv2.GC_PR_FGD), 255, 0).astype(np.uint8)
|
||||||
|
return fg, "opencv:grabcut"
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_with_threshold(image):
|
||||||
|
"""Segmenta por distancia al color medio de los bordes (NumPy puro). Devuelve (mask, engine_str)."""
|
||||||
|
rgb = np.asarray(image.convert("RGB"), dtype=np.float32)
|
||||||
|
h, w = rgb.shape[:2]
|
||||||
|
border = np.concatenate([rgb[0, :, :], rgb[-1, :, :], rgb[:, 0, :], rgb[:, -1, :]], axis=0)
|
||||||
|
bg_color = border.mean(axis=0)
|
||||||
|
dist = np.linalg.norm(rgb - bg_color, axis=2)
|
||||||
|
thresh = max(30.0, float(dist.mean()))
|
||||||
|
fg = (dist > thresh).astype(np.uint8) * 255
|
||||||
|
return fg, "threshold:border"
|
||||||
|
|
||||||
|
|
||||||
|
def remove_background(image_path: str, engine: str = "auto") -> dict:
|
||||||
|
"""
|
||||||
|
Elimina el fondo de una imagen y compone el objeto sobre un fondo gris neutro.
|
||||||
|
|
||||||
|
Parámetros:
|
||||||
|
image_path: ruta a la imagen de entrada (cualquier formato que PIL abra).
|
||||||
|
engine: "auto" (default) prueba rembg -> GrabCut -> umbral en cascada y NUNCA falla
|
||||||
|
(cae al umbral NumPy puro sin deps externas); también admite forzar un motor concreto:
|
||||||
|
"rembg", "grabcut" o "threshold". Si se fuerza un motor y no está disponible/falla,
|
||||||
|
o la máscara resulta degenerada, se devuelve status error.
|
||||||
|
|
||||||
|
Devuelve (dict, nunca lanza):
|
||||||
|
Éxito: {"status": "ok", "image": PIL.Image RGB del objeto compuesto sobre gris neutro,
|
||||||
|
"mask": ndarray HxW uint8 (0..255, 255=objeto), "engine": str del motor usado
|
||||||
|
("rembg:u2net" | "opencv:grabcut" | "threshold:border" | "passthrough:alpha"),
|
||||||
|
"height": int, "width": int, "fg_fraction": float (fracción de píxeles objeto,
|
||||||
|
redondeada a 4 decimales)}.
|
||||||
|
Error: {"status": "error", "error": str} (ruta inválida, motor desconocido, motor forzado
|
||||||
|
no disponible/fallido, o ningún motor produjo una máscara válida).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
image = Image.open(image_path)
|
||||||
|
|
||||||
|
# Passthrough: si la imagen ya viene recortada (PNG RGBA con alfa), reutiliza su alfa.
|
||||||
|
# Solo en modo auto; si se fuerza un motor concreto se respeta esa elección.
|
||||||
|
if engine == "auto":
|
||||||
|
existing = _existing_alpha_mask(image)
|
||||||
|
if existing is not None:
|
||||||
|
composed = _composite_over_neutral(image, existing)
|
||||||
|
frac = float((existing >= 128).mean())
|
||||||
|
h, w = existing.shape[:2]
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"image": composed,
|
||||||
|
"mask": existing,
|
||||||
|
"engine": "passthrough:alpha",
|
||||||
|
"height": int(h),
|
||||||
|
"width": int(w),
|
||||||
|
"fg_fraction": round(frac, 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Construir la lista de motores a probar según el engine pedido.
|
||||||
|
if engine == "auto":
|
||||||
|
attempts = [_remove_with_rembg, _remove_with_grabcut, _remove_with_threshold]
|
||||||
|
elif engine == "rembg":
|
||||||
|
attempts = [_remove_with_rembg]
|
||||||
|
elif engine == "grabcut":
|
||||||
|
attempts = [_remove_with_grabcut]
|
||||||
|
elif engine == "threshold":
|
||||||
|
attempts = [_remove_with_threshold]
|
||||||
|
else:
|
||||||
|
attempts = []
|
||||||
|
|
||||||
|
if not attempts:
|
||||||
|
return {"status": "error", "error": f"Motor desconocido: {engine!r}"}
|
||||||
|
|
||||||
|
last_exc = None
|
||||||
|
for attempt in attempts:
|
||||||
|
try:
|
||||||
|
mask, used = attempt(image)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
last_exc = e
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Rechazar máscaras degeneradas (casi todo fondo o casi todo objeto).
|
||||||
|
frac = float((mask >= 128).mean())
|
||||||
|
if frac < 0.01 or frac > 0.995:
|
||||||
|
last_exc = f"mascara degenerada (fg_fraction={round(frac, 4)}) con {used}"
|
||||||
|
continue
|
||||||
|
|
||||||
|
composed = _composite_over_neutral(image, mask)
|
||||||
|
h, w = mask.shape[:2]
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"image": composed,
|
||||||
|
"mask": mask,
|
||||||
|
"engine": used,
|
||||||
|
"height": int(h),
|
||||||
|
"width": int(w),
|
||||||
|
"fg_fraction": round(frac, 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"No se pudo eliminar el fondo con engine={engine!r}: {last_exc}",
|
||||||
|
}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Demo runner para `fn run remove_background_py_datascience <image_path> [engine] [out_dir]`.
|
||||||
|
# Imprime un resumen JSON-serializable (el ndarray y la PIL.Image no se serializan).
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(json.dumps({"status": "error", "error": "uso: <image_path> [engine] [out_dir]"}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
path = sys.argv[1]
|
||||||
|
eng = sys.argv[2] if len(sys.argv) > 2 else "auto"
|
||||||
|
out_dir = sys.argv[3] if len(sys.argv) > 3 else None
|
||||||
|
|
||||||
|
res = remove_background(path, engine=eng)
|
||||||
|
if res["status"] == "ok":
|
||||||
|
summary = {
|
||||||
|
"status": "ok",
|
||||||
|
"engine": res["engine"],
|
||||||
|
"height": res["height"],
|
||||||
|
"width": res["width"],
|
||||||
|
"fg_fraction": res["fg_fraction"],
|
||||||
|
}
|
||||||
|
if out_dir:
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
rgb_path = os.path.join(out_dir, "rgb.png")
|
||||||
|
mask_path = os.path.join(out_dir, "mask.png")
|
||||||
|
res["image"].save(rgb_path)
|
||||||
|
Image.fromarray(res["mask"]).save(mask_path)
|
||||||
|
summary["rgb_path"] = rgb_path
|
||||||
|
summary["mask_path"] = mask_path
|
||||||
|
print(json.dumps(summary))
|
||||||
|
else:
|
||||||
|
print(json.dumps(res))
|
||||||
|
sys.exit(1)
|
||||||
Reference in New Issue
Block a user