8 Commits

Author SHA1 Message Date
egutierrez c1f355ffa5 Merge orq/fleet-detect: detect_fleet_context ($TMUX) + spawn auto-detecta socket + hook CONTEXTO FLEET + doctrina (report 0041) 2026-06-21 21:55:11 +02:00
egutierrez 237f763c19 Merge orq/img3d-registry-funcs: promover remove_background al registry + mask en depth_to_relief_glb (grupo img-to-3d, report 0040) 2026-06-21 21:51:13 +02:00
agent bf67ff3180 docs(orquestador): deteccion de flota por $TMUX, kitty solo fuera de tmux
orquestador.md + orchestration.md: la deteccion de 'estoy en una flota' se hace
por $TMUX (via detect_fleet_context), NO por $FLEET_SOCKET (fragil). kitty es
fallback SOLO cuando in_tmux=false. spawn_fleet_agent auto-detecta el socket
(ya no hace falta pasar --socket/--session). Documenta la linea CONTEXTO FLEET
del hook y anade detect_fleet_context al catalogo del grupo orchestration.
2026-06-21 21:50:32 +02:00
agent 03fc0461fa feat(hook): inyectar CONTEXTO FLEET con socket/session al orquestador
hook_fleet_state_inject.sh ahora, ademas de MODO ORQUESTADOR, llama a
detect_fleet_context (por $TMUX) e inyecta una linea CONTEXTO FLEET con
socket/session + recordatorio de usar spawn_fleet_agent (nunca kitty) cuando
in_fleet=true. No depende del venv (solo bash+tmux) y se emite antes del bloque
FLEET-STATE. Degrada limpio: si el detector falta o $TMUX esta vacia, no emite
la linea y el turno sigue intacto.
2026-06-21 21:50:32 +02:00
agent a1105dc4c5 feat(infra): spawn_fleet_agent auto-detecta socket/session de $TMUX
--socket/--session ahora opcionales: si no se pasan, se auto-detectan del
contexto tmux ($TMUX) via detect_fleet_context. Los explicitos siguen
primando. Aborta (exit 2) solo si tras auto-detectar siguen vacios (no hay
tmux). Elimina el bug de caer a kitty cuando $FLEET_SOCKET viene vacia pese a
estar en la flota. Bump v1.2.0 + growth log.
2026-06-21 21:50:32 +02:00
agent 3c9e909eda feat(infra): detect_fleet_context — contexto de flota por $TMUX (no $FLEET_SOCKET)
Funcion nueva detect_fleet_context_bash_infra (tag orchestration). Deriva
socket/session de $TMUX (senal fiable que todo proceso dentro de tmux tiene
siempre), con fallback a $FLEET_SOCKET/$FLEET_SESSION. Devuelve JSON
{in_fleet,in_tmux,socket,session,source}. Causa raiz del bug: $FLEET_SOCKET
(exportada con tmux set-environment -g por launch_fleetclaude) a veces viene
vacia en un claude resumido/relanzado pese a vivir en la flota, y el modo
orquestador caia al fallback kitty. .md self-doc (Ejemplo + Cuando usarla +
Gotchas).
2026-06-21 21:50:32 +02:00
egutierrez 3cf8b21fea 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>
2026-06-21 21:43:08 +02:00
egutierrez cbefc82c02 Merge orq/pane-id-json: campo ClaudeFleet.PaneID + resolve_pane_ids + poblar en list_claude_fleet (report 0039) 2026-06-21 21:30:40 +02:00
13 changed files with 670 additions and 49 deletions
+30 -19
View File
@@ -75,36 +75,46 @@ siendo grande para un agente, pásala por el **splitter** (ver `.claude/rules/or
### 2. Lanzar cada secundario
**Regla dura: cada secundario se lanza SIEMPRE como terminal visible — window de la flota tmux si
hay perfil fleet (`$FLEET_SOCKET`, lo normal), o kitty fuera de él. NUNCA como sub-agente del Agent
tool (ver paso 8).** Empieza por el bloque de flota tmux cuando estás en un perfil fleet; kitty es
el fallback para secundarios que deban vivir fuera de la flota.
estás dentro de tmux/una flota, o kitty SOLO cuando de verdad no hay tmux. NUNCA como sub-agente del
Agent tool (ver paso 8).** La detección de "estoy en una flota" se hace por **`$TMUX`** (señal
fiable, vía `detect_fleet_context`), **NO por `$FLEET_SOCKET`** (a veces viene vacía en un claude
resumido/relanzado pese a vivir en la flota → te haría caer a kitty por error). El hook
`hook_fleet_state_inject.sh` te inyecta cada turno una línea `CONTEXTO FLEET: … socket=<X>` cuando
estás dentro de la flota; úsala. Empieza por el bloque de flota tmux; kitty es el fallback solo fuera
de tmux.
Siempre con `--dangerously-skip-permissions` (memoria `lanzar-agentes-skip-permissions`): los
secundarios trabajan autónomos y desatendidos; los prompts de permiso en cada Bash los atascarían.
#### En la flota tmux (PREFERIDO en perfil fleet)
#### En la flota tmux (PREFERIDO siempre que estés en tmux)
Si estás dentro de un perfil FleetView (`$FLEET_SOCKET` seteada), **NO lances kitties sueltas**:
lanza cada ejecutor como una **window de la flota tmux** con `spawn_fleet_agent`, para que viva en
la flota, se vea en la TUI `fleetview` y sea conmutable con `/fleet focus`:
Si estás dentro de tmux/una flota (`$TMUX` seteada — compruébalo con `detect_fleet_context`, **no**
con `$FLEET_SOCKET`), **NO lances kitties sueltas**: lanza cada ejecutor como una **window de la
flota tmux** con `spawn_fleet_agent`, para que viva en la flota, se vea en la TUI `fleetview` y sea
conmutable con `/fleet focus`:
```bash
./fn run spawn_fleet_agent --socket "$FLEET_SOCKET" --session "$FLEET_SESSION" \
# spawn_fleet_agent auto-detecta el socket/session de $TMUX — NO hace falta pasar --socket/--session:
./fn run spawn_fleet_agent \
--cwd <dir-aislado> --prompt-file /tmp/orq_<slug>.md --title "<subtarea>" \
--parent "$MI_SESSION_ID"
# devuelve el window_id; despues escribe el DoD-contrato del ejecutor:
./fn run set_dod_contract <sessionId-del-ejecutor> "<DoD golden+edge+error>" pending
```
- `spawn_fleet_agent_bash_infra` crea la window tmux + arranca claude con el prompt autocontenido
(o `--skill <name>`), y con `--role executor|orchestrator` marca su `goal.json`. El aislamiento
git (sub-repo / worktree / scope) sigue imponiéndose en el prompt.
- `spawn_fleet_agent_bash_infra` **auto-detecta** socket/session del contexto tmux (`$TMUX`) vía
`detect_fleet_context`; pásalos explícitos solo si quieres otra flota (los explícitos priman).
Crea la window tmux + arranca claude con el prompt autocontenido (o `--skill <name>`), y con
`--role executor|orchestrator` marca su `goal.json`. El aislamiento git (sub-repo / worktree /
scope) sigue imponiéndose en el prompt.
- **`--parent <mi-sessionId>` (recomendado):** escribe `parent_orchestrator` en el `goal.json` del
ejecutor atribuyéndotelo a ti. Es lo que habilita el **push activo** del watcher (te avisa en TU
pane cuando ese ejecutor termina). Sin `--parent` el aviso no se rutea. Opcional y
retro-compatible. Ver `.claude/rules/orchestration.md`.
#### Fuera de la flota (kitty fallback)
#### Fuera de tmux (kitty fallback)
Solo cuando `detect_fleet_context` reporta `in_tmux=false` (de verdad no hay tmux):
```bash
./fn run launch_claude_agent_kitty "<PROYECTO> · <subtarea>" <dir-aislado> /tmp/orq_<slug>.md
@@ -113,7 +123,8 @@ la flota, se vea en la TUI `fleetview` y sea conmutable con `/fleet focus`:
- `launch_claude_agent_kitty_bash_infra(title, directory, prompt_file)` lanza el secundario con el
comando canónico (`setsid nohup kitty … zsh -ic 'claude --dangerously-skip-permissions … ; exec
zsh'`) que sobrevive al cierre de la terminal padre y deja una shell viva al terminar el claude;
devuelve el log de arranque (`/tmp/orq_<slug>_kitty.log`). Usa kitty solo fuera de un perfil fleet.
devuelve el log de arranque (`/tmp/orq_<slug>_kitty.log`). Usa kitty solo cuando NO estás en tmux
(`$TMUX` vacía); estando en una flota, kitty fragmenta la flota — usa `spawn_fleet_agent`.
### 3. Aislamiento git obligatorio por secundario (regla de oro)
@@ -204,8 +215,8 @@ Cuando un secundario termina (rama pusheada + report verde):
**Todo agente de trabajo va como terminal visible del fleet, NUNCA como sub-agente headless del Agent tool.** Un sub-agente headless corre invisible: no sale en `fleetview`, no es conmutable con `/fleet focus` ni se puede retomar. Jerarquía al lanzar un agente:
1. **En perfil fleet** (`$FLEET_SOCKET`, lo normal) → `spawn_fleet_agent` (window de la flota tmux).
2. **Fuera de un perfil fleet** → kitty con `launch_claude_agent_kitty`.
1. **Dentro de tmux/flota** (`$TMUX` seteada — comprueba con `detect_fleet_context`, NO con `$FLEET_SOCKET`) → `spawn_fleet_agent` (auto-detecta el socket; window de la flota tmux).
2. **Fuera de tmux** (`in_tmux=false`) → kitty con `launch_claude_agent_kitty`.
3. **Agent tool (sub-agente headless)** → **PROHIBIDO para lanzar un agente de trabajo.** SOLO para
utilidades internas read-only tuyas que devuelven un resultado y mueren: el **verificador**
adversarial de un cierre, el **splitter** (`Plan`), o una búsqueda puntual (`Explore`).
@@ -268,10 +279,10 @@ git -C ~/fn_registry worktree add /tmp/orq_capdoc -b orq/cap-deploy master
# /tmp/orq_health.md → trabaja en apps/kanban (sub-repo propio), rama issue/health, push, report.
# /tmp/orq_capdoc.md → trabaja SOLO en /tmp/orq_capdoc (worktree), rama orq/cap-deploy, push, report.
# 4. Lanzar ambos (window de la flota si hay $FLEET_SOCKET; aquí kitty fallback). Tras conocer su
# sessionId, escribe su DoD-contrato con set_dod_contract.
./fn run launch_claude_agent_kitty "kanban · health endpoint" ~/fn_registry/apps/kanban /tmp/orq_health.md
./fn run launch_claude_agent_kitty "fn_registry · doc deploy" /tmp/orq_capdoc /tmp/orq_capdoc.md
# 4. Lanzar ambos como windows de la flota (estás en tmux → spawn_fleet_agent auto-detecta el socket
# de $TMUX; kitty SOLO si in_tmux=false). Tras conocer su sessionId, escribe su DoD-contrato.
./fn run spawn_fleet_agent --cwd ~/fn_registry/apps/kanban --prompt-file /tmp/orq_health.md --title "kanban · health endpoint" --parent "$MI_SESSION_ID"
./fn run spawn_fleet_agent --cwd /tmp/orq_capdoc --prompt-file /tmp/orq_capdoc.md --title "fn_registry · doc deploy" --parent "$MI_SESSION_ID"
# 5. Seguir cada turno: drena FLEET-STATE, verifica DICE_TERMINADO, nudge a ESTANCADO, lee reports/ (maquinaria en orchestration.md).
+18 -2
View File
@@ -123,6 +123,21 @@ existe, degrada limpio sin romper el turno (la línea de rol se sigue emitiendo)
clasificación sigues drenando (abajo). El resumen lo produce `summarize_fleet_transitions_py_infra`
sobre el feed del watcher.
Además, el mismo hook inyecta una línea **`CONTEXTO FLEET`** cuando detecta (vía
`detect_fleet_context_bash_infra`, leyendo **`$TMUX`**, no `$FLEET_SOCKET`) que el orquestador vive
dentro de una flota tmux:
```
CONTEXTO FLEET: estás dentro de la fleet tmux socket=<X> session=<Y>. Lanza ejecutores con spawn_fleet_agent (auto-detecta el socket) — NUNCA kitty/launch_claude_agent_kitty estando aquí.
```
Es el recordatorio que evita el bug de caer a kitty cuando `$FLEET_SOCKET` viene vacía pese a estar
en la flota: la detección de contexto se hace por `$TMUX` (señal fiable que todo proceso dentro de
tmux tiene siempre), no por `$FLEET_SOCKET` (a veces ausente en un claude resumido/relanzado). Esta
parte del hook no necesita venv ni python (solo bash + tmux) y se emite antes del bloque
`FLEET-STATE`; si el detector falta o `$TMUX` está vacía, simplemente no se emite la línea (turno
intacto).
Gotcha conocido: el bloque `FLEET-STATE` (peek pasivo) lista transiciones de TODA la flota, incluidas
las de otros orquestadores y sus ejecutores. Si hay más de un orquestador activo, filtra por tu propia
familia de agentes (los que tú lanzaste) — igual que en "No te vigiles a ti mismo" más abajo. El **push
@@ -302,7 +317,8 @@ en lote.
| `summarize_fleet_transitions_py_infra` | Resumir las transiciones del feed en una línea (`terminados/reclaman/estancados`); alimenta el bloque `FLEET-STATE` que el hook `UserPromptSubmit` inyecta cada turno |
| `classify_fleet_termination_go_infra` | Clasificar el estado de terminación de un agente (RECLAMA/MAL_LANZADO/DICE_TERMINADO/ESTANCADO/TRABAJANDO) — lo usa el watcher |
| `list_claude_fleet_go_infra` | Fleet tipado con goal/phase/`role` + `dod_contract`/`dod_status` + `tmux_window` (alimenta `/fleet`, el watcher y el tool `fleet_list`). **Invócala por el tool `mcp__orchestrator__fleet_list` (preferido) o el binario `apps/fleetview/fleetview list --json`**, NUNCA por `./fn run` (la despacha como `go test`). El JSON del CLI **ya expone** `role`/`dod_contract`/`dod_status` (`""` si el `goal.json` no los declara); el tool MCP además rellena los vacíos desde `~/.claude/goals/<session_id>.json` |
| `spawn_fleet_agent_bash_infra` | Lanzar un ejecutor (o el orquestador) como window de la flota tmux — preferido sobre kitty cuando hay perfil fleet. `--parent <tu-sessionId>` atribuye el ejecutor a ti y habilita el push activo del watcher |
| `detect_fleet_context_bash_infra` | Detectar si estás en una flota tmux derivando socket/session de `$TMUX` (señal fiable), con fallback a `$FLEET_SOCKET`. Devuelve JSON `{in_fleet,in_tmux,socket,session,source}`. Lo usan `spawn_fleet_agent` (auto-detección de socket) y el hook (línea `CONTEXTO FLEET`) para no caer a kitty estando en la flota |
| `spawn_fleet_agent_bash_infra` | Lanzar un ejecutor (o el orquestador) como window de la flota tmux — preferido sobre kitty siempre que estés en tmux. **Auto-detecta socket/session de `$TMUX`** (vía `detect_fleet_context`) si no se pasan `--socket`/`--session` (los explícitos priman). `--parent <tu-sessionId>` atribuye el ejecutor a ti y habilita el push activo del watcher |
| `mark_claude_role_py_infra` | Marcar `role` (orchestrator/executor) en el goal.json de un Claude resolviendo PID→sessionId |
| `mark_claude_parent_py_infra` | Marcar `parent_orchestrator` (sessionId del orquestador que lo lanzó) en el goal.json de un ejecutor resolviendo PID→sessionId. Lo invoca `spawn_fleet_agent --parent`; habilita el routing del watcher al pane del orquestador padre |
| `kill_fleet_agent_bash_infra` | Cierre dirigido de UN ejecutor: SIGTERM al claude + kill-window de su window tmux. Guards anti-orquestador y anti-self. Lo usa el orquestador para liberar el slot idle tras verificar `met` (auto-kill) |
@@ -311,7 +327,7 @@ en lote.
**Cómo invocarlas.** Las Bash y Python del grupo se lanzan con `./fn run <id> [args]` (verificado:
`list_claude_agents`, `drain_fleet_events`, `reboot_all_claudes`, `set_dod_contract`,
`mark_claude_role`, `mark_claude_parent`, `kill_fleet_agent`, `launch_claude_agent_kitty`,
`spawn_fleet_agent`). Las **Go con tests** NO: `./fn run` las despacha como `go test`. Por eso
`spawn_fleet_agent`, `detect_fleet_context`). Las **Go con tests** NO: `./fn run` las despacha como `go test`. Por eso
`list_claude_fleet_go_infra` se usa por el binario `apps/fleetview/fleetview list --json`, y
`classify_fleet_termination_go_infra` la consume el watcher embebido en fleetview (no se invoca a
mano).
@@ -46,6 +46,24 @@ ROLE=""
printf '%s\n' "MODO ORQUESTADOR activo (role=orchestrator)."
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/fn_registry}"
# Contexto de flota: recordarle al orquestador en que socket/sesion tmux vive,
# para que lance ejecutores con spawn_fleet_agent (auto-detecta el socket) y
# NUNCA caiga a kitty estando dentro de la flota. La deteccion va por $TMUX
# (senal fiable), no por $FLEET_SOCKET (a veces vacia en un claude resumido/
# relanzado). No necesita venv ni python: solo bash + tmux. Degrada limpio: si
# el detector falta o falla, simplemente no se emite la linea (turno intacto).
DETECTOR="$PROJECT_DIR/bash/functions/infra/detect_fleet_context.sh"
if [ -f "$DETECTOR" ]; then
CTX=$(bash "$DETECTOR" 2>/dev/null || true)
IN_FLEET=$(printf '%s' "$CTX" | sed -n 's/.*"in_fleet":\(true\|false\).*/\1/p')
F_SOCKET=$(printf '%s' "$CTX" | sed -n 's/.*"socket":"\([^"]*\)".*/\1/p')
F_SESSION=$(printf '%s' "$CTX" | sed -n 's/.*"session":"\([^"]*\)".*/\1/p')
if [ "$IN_FLEET" = "true" ]; then
printf 'CONTEXTO FLEET: estas dentro de la fleet tmux socket=%s session=%s. Lanza ejecutores con spawn_fleet_agent (auto-detecta el socket) — NUNCA kitty/launch_claude_agent_kitty estando aqui.\n' "$F_SOCKET" "$F_SESSION"
fi
fi
PY="$PROJECT_DIR/python/.venv/bin/python3"
{ [ -x "$PY" ] && [ -d "$PROJECT_DIR/python/functions" ]; } || exit 0
@@ -0,0 +1,98 @@
---
name: detect_fleet_context
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: "detect_fleet_context() -> JSON {in_fleet,in_tmux,socket,session,source}"
description: "Detecta de forma robusta si el proceso corre dentro de una flota tmux FleetView, derivando socket y sesion de $TMUX (senal fiable) en vez de $FLEET_SOCKET (fragil, a veces vacia en un claude resumido/relanzado). Salida JSON con in_fleet/in_tmux/socket/session/source."
tags: [orchestration, fleet, tmux, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
tested: false
file_path: "bash/functions/infra/detect_fleet_context.sh"
params:
- name: "(ninguno)"
desc: "No recibe argumentos. Lee el entorno ($TMUX, con fallback a $FLEET_SOCKET/$FLEET_SESSION) y consulta el servidor tmux."
output: "JSON en stdout: {\"in_fleet\":bool, \"in_tmux\":bool, \"socket\":str, \"session\":str, \"source\":\"tmux|fleet_socket|none\"}. in_tmux=true basta para lanzar una window; in_fleet es la senal semantica de 'estoy en una flota'."
---
# detect_fleet_context
Detecta el contexto de flota del proceso actual sin depender de `$FLEET_SOCKET`.
## Por que existe
La deteccion de "estoy en una flota FleetView" dependia de la variable de
entorno `$FLEET_SOCKET`, que `launch_fleetclaude` exporta con
`tmux set-environment -g`. Esa variable solo llega a los procesos que tmux
arranca **despues** de setearla: un `claude` relanzado o resumido a mano puede
no heredarla y `$FLEET_SOCKET` queda vacia, aunque ese claude SI viva en una
window de la flota. Cuando eso pasa, el modo orquestador cae al fallback kitty
(`launch_claude_agent_kitty`) y lanza ejecutores en terminales sueltas en vez de
como windows de la flota.
La senal **fiable** es `$TMUX`: todo proceso dentro de tmux la tiene SIEMPRE, con
el formato `/tmp/tmux-<uid>/<socket>,<server_pid>,<client_id>`. De ahi se extrae
el socket (basename del path antes de la primera coma) y, con
`tmux -L <socket> display-message -p '#{session_name}'`, la sesion actual.
## Salida
```json
{"in_fleet":true,"in_tmux":true,"socket":"fleet3","session":"fleet3","source":"tmux"}
```
| Campo | Significado |
|---|---|
| `in_fleet` | Heuristica de "estoy en una flota". `true` si en tmux Y (socket/sesion casan `fleet`, O hay window `fleetview`, O la sesion tiene >= 2 windows). |
| `in_tmux` | `true` si el proceso esta dentro de tmux. Basta para lanzar una window (mejor que caer a kitty). |
| `socket` | Socket tmux derivado de `$TMUX` (o de `$FLEET_SOCKET` en fallback). |
| `session` | Sesion tmux actual resuelta con `display-message` (fallback a `$FLEET_SESSION` o al socket). |
| `source` | `tmux` (derivado de `$TMUX`), `fleet_socket` (fallback), o `none`. |
## Ejemplo
```bash
# Dentro de una window de la flota fleet3:
bash bash/functions/infra/detect_fleet_context.sh
# {"in_fleet":true,"in_tmux":true,"socket":"fleet3","session":"fleet3","source":"tmux"}
# Fuera de tmux, sin FLEET_SOCKET:
env -u TMUX -u FLEET_SOCKET bash bash/functions/infra/detect_fleet_context.sh
# {"in_fleet":false,"in_tmux":false,"socket":"","session":"","source":"none"}
# Parsear el socket con jq para pasarlo a spawn_fleet_agent:
ctx=$(bash bash/functions/infra/detect_fleet_context.sh)
sock=$(printf '%s' "$ctx" | jq -r .socket)
```
## Cuando usarla
Antes de lanzar un ejecutor de la flota: llama a esta funcion para saber si
estas dentro de una flota tmux. Si `in_tmux=true`, lanza con `spawn_fleet_agent`
(que ya la usa para auto-detectar el socket); NUNCA caigas a kitty. Tambien la
usa el hook `hook_fleet_state_inject.sh` para recordarle al orquestador el socket
de su flota cada turno.
## Gotchas
- Es **impura**: consulta el servidor tmux (`display-message`, `list-windows`).
No modifica estado.
- `in_fleet` es **heuristico** a proposito. Para LANZAR basta `in_tmux=true`
(lanzar una window en cualquier tmux supera a una kitty suelta). `in_fleet` es
solo la senal semantica que consume el hook y la doctrina.
- Fallback `source=fleet_socket`: si `$TMUX` no esta pero `$FLEET_SOCKET` si,
devuelve `socket`/`session` de esas vars con `in_tmux=false`. Un
`tmux -L <socket> new-window` puede seguir funcionando si el servidor existe,
aunque el caller no este attached.
- No requiere `jq` ni python: emite el JSON con `printf`, para poder ser el
detector base que invocan hooks y otras funciones bash.
- Si `tmux` no esta instalado y `$TMUX` esta seteada (raro), `socket` se deriva
igual de `$TMUX` pero `session` cae al fallback y `in_fleet` no se puede afinar
por windows.
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# detect_fleet_context — detecta de forma robusta si el proceso actual corre
# dentro de una sesion tmux de una flota FleetView, derivando el socket y la
# sesion de la variable de entorno $TMUX (senal fiable) en vez de depender de
# $FLEET_SOCKET (que a veces viene vacia en el entorno de un claude resumido o
# relanzado, aunque ese claude SI viva en una window de la flota).
#
# Por que $TMUX y no $FLEET_SOCKET:
# launch_fleetclaude exporta FLEET_SOCKET/FLEET_SESSION con `tmux
# set-environment -g`. Esa variable solo llega a los procesos que tmux arranca
# DESPUES de setearla; un claude relanzado o resumido a mano puede no heredarla
# y entonces $FLEET_SOCKET queda vacia. En cambio, todo proceso que corre
# dentro de tmux tiene SIEMPRE $TMUX seteada, con el formato:
# /tmp/tmux-<uid>/<socket>,<server_pid>,<client_id>
# De ahi se extrae el socket (basename del path antes de la primera coma) y,
# con `tmux -L <socket> display-message -p '#{session_name}'`, la sesion
# actual. Eso identifica el contexto fleet sin depender de $FLEET_SOCKET.
#
# Salida: JSON en stdout con los campos:
# in_fleet : true|false — heuristica de "estoy en una flota" (ver criterio).
# in_tmux : true|false — estoy dentro de tmux (basta para lanzar una window).
# socket : nombre del socket tmux derivado ("" si no hay).
# session : nombre de la sesion tmux actual ("" si no se resuelve).
# source : "tmux" | "fleet_socket" | "none" — de donde se derivo el contexto.
#
# Criterio de "flota reconocible" (in_fleet): estar en tmux (in_tmux) Y que se
# cumpla al menos uno, de mas fiable a menos:
# 1. el socket o la sesion casan el patron de flota (contienen "fleet"), o
# 2. existe una window llamada "fleetview" (la TUI de la flota), o
# 3. la sesion tiene >= 2 windows (una flota agrupa varios agentes en windows).
# Es heuristico a proposito: para LANZAR un ejecutor basta con in_tmux (lanzar
# una window en cualquier tmux es mejor que caer a una kitty suelta); in_fleet es
# la senal semantica que consume el hook del orquestador y la doctrina.
#
# Funcion IMPURA: lee el entorno y consulta el servidor tmux (display-message,
# list-windows). No modifica estado. Degrada limpio: si tmux no esta o falla
# cualquier consulta, devuelve los campos que pueda y nunca aborta con error.
set -euo pipefail
IFS=$' \t\n'
detect_fleet_context() {
local socket="" session="" source="none"
local in_tmux="false" in_fleet="false"
if [[ -n "${TMUX:-}" ]]; then
in_tmux="true"
source="tmux"
# $TMUX = /tmp/tmux-<uid>/<socket>,<server_pid>,<client_id>
# Socket = basename del path antes de la primera coma.
local tmux_path="${TMUX%%,*}"
socket="$(basename "$tmux_path" 2>/dev/null || true)"
# Sesion actual: tmux resuelve el cliente via $TMUX. -L fija el socket.
if command -v tmux >/dev/null 2>&1 && [[ -n "$socket" ]]; then
session="$(tmux -L "$socket" display-message -p '#{session_name}' 2>/dev/null || true)"
fi
# Fallback de sesion si display-message no resolvio nada.
[[ -z "$session" ]] && session="${FLEET_SESSION:-$socket}"
elif [[ -n "${FLEET_SOCKET:-}" ]]; then
# No estamos en tmux pero hay FLEET_SOCKET exportada: usarla como ultimo
# recurso (un claude que perdio $TMUX pero conserva la env del perfil).
in_tmux="false"
source="fleet_socket"
socket="${FLEET_SOCKET}"
session="${FLEET_SESSION:-$socket}"
fi
# Heuristica in_fleet: solo tiene sentido si estamos en tmux.
if [[ "$in_tmux" == "true" && -n "$socket" ]]; then
local sl="${socket,,}" sesl="${session,,}"
if [[ "$sl" == *fleet* || "$sesl" == *fleet* ]]; then
in_fleet="true"
elif command -v tmux >/dev/null 2>&1; then
# Construir el target de sesion sin trucos de expansion fragiles.
local -a tgt=()
[[ -n "$session" ]] && tgt=(-t "$session")
# window "fleetview" presente => flota.
if tmux -L "$socket" list-windows "${tgt[@]}" \
-F '#{window_name}' 2>/dev/null | grep -qx 'fleetview'; then
in_fleet="true"
else
# >= 2 windows => agrupacion tipo flota.
local nwin
nwin="$(tmux -L "$socket" list-windows "${tgt[@]}" \
-F x 2>/dev/null | wc -l | tr -d ' ')"
[[ "${nwin:-0}" -ge 2 ]] && in_fleet="true"
fi
fi
fi
# JSON sin dependencias (jq/python no requeridos: este es el detector base).
printf '{"in_fleet":%s,"in_tmux":%s,"socket":"%s","session":"%s","source":"%s"}\n' \
"$in_fleet" "$in_tmux" "$socket" "$session" "$source"
return 0
}
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
detect_fleet_context "$@"
fi
+16 -5
View File
@@ -3,23 +3,24 @@ name: spawn_fleet_agent
kind: function
lang: bash
domain: infra
version: 1.1.0
version: 1.2.0
purity: impure
signature: "spawn_fleet_agent --socket <s> --session <s> --cwd <dir> [--prompt-file <f> | --skill <name>] [--role orchestrator|executor] [--parent <sid>] [--title <t>]"
description: "Lanza un Claude como window nueva dentro de la sesion tmux de un perfil FleetView (socket aislado), opcionalmente en modo orquestador (skill embebida como primer prompt), marcado con un role en su goal.json y atribuido a su orquestador padre. Es la forma de que un ejecutor o el propio orquestador VIVAN en la flota tmux (visibles en la TUI fleetview, conmutables con /fleet focus) en vez de en kitties sueltas. Reemplaza a launch_claude_agent_kitty cuando se opera dentro de un perfil fleet ya montado. Con --parent <sid> escribe parent_orchestrator en el goal.json del nuevo Claude (via mark_claude_parent) para que el watcher de fleetview rutee sus avisos al orquestador que lo lanzo. Imprime el window_id creado."
signature: "spawn_fleet_agent [--socket <s>] [--session <s>] [--cwd <dir>] [--prompt-file <f> | --skill <name>] [--role orchestrator|executor] [--parent <sid>] [--title <t>]"
description: "Lanza un Claude como window nueva dentro de la sesion tmux de un perfil FleetView (socket aislado), opcionalmente en modo orquestador (skill embebida como primer prompt), marcado con un role en su goal.json y atribuido a su orquestador padre. --socket/--session son opcionales: si no se pasan se auto-detectan del contexto tmux ($TMUX) via detect_fleet_context (los explicitos tienen prioridad), evitando caer a kitty cuando $FLEET_SOCKET viene vacia. Es la forma de que un ejecutor o el propio orquestador VIVAN en la flota tmux (visibles en la TUI fleetview, conmutables con /fleet focus) en vez de en kitties sueltas. Reemplaza a launch_claude_agent_kitty cuando se opera dentro de un perfil fleet ya montado. Con --parent <sid> escribe parent_orchestrator en el goal.json del nuevo Claude (via mark_claude_parent) para que el watcher de fleetview rutee sus avisos al orquestador que lo lanzo. Imprime el window_id creado."
tags: [fleet, claude-fleet, orchestration, tmux, infra]
uses_functions:
- mark_claude_role_py_infra
- mark_claude_parent_py_infra
- detect_fleet_context_bash_infra
uses_types: []
error_type: error_go_core
file_path: "bash/functions/infra/spawn_fleet_agent.sh"
tested: false
params:
- name: --socket
desc: "Socket tmux del perfil FleetView (ej. fleet, fleet2). El perfil debe estar ya montado (sesion viva)."
desc: "Socket tmux del perfil FleetView (ej. fleet, fleet2). Opcional: se auto-detecta de $TMUX via detect_fleet_context si no se pasa. El perfil debe estar ya montado (sesion viva)."
- name: --session
desc: "Nombre de la sesion tmux dentro del socket (normalmente igual al socket)."
desc: "Nombre de la sesion tmux dentro del socket (normalmente igual al socket). Opcional: se auto-detecta de $TMUX si no se pasa."
- name: --cwd
desc: "Directorio de trabajo del nuevo Claude. Default: PWD."
- name: --prompt-file
@@ -54,6 +55,11 @@ Lanza un Claude dentro de un perfil FleetView (sesion tmux de un socket aislado)
./fn run spawn_fleet_agent --socket fleet2 --session fleet2 --cwd "$HOME/fn_registry" \
--prompt-file /tmp/orq_health.md --title "kanban-health" \
--parent 32945650-a4e1-472b-90c9-5b38ef60a463
# Sin --socket/--session: auto-detecta el socket de la flota actual ($TMUX).
# Forma preferida desde dentro de la flota — no hace falta saber el socket:
./fn run spawn_fleet_agent --cwd "$HOME/fn_registry" \
--prompt-file /tmp/orq_health.md --title "kanban-health"
```
## Cuando usarla
@@ -62,9 +68,14 @@ Cuando el orquestador (o el launcher) necesita arrancar un Claude que debe vivir
## Gotchas
- **Auto-deteccion de socket/session**: si no pasas `--socket`/`--session`, se derivan de `$TMUX` via `detect_fleet_context`. Los explicitos tienen prioridad. Solo aborta (exit 2) si tras auto-detectar siguen vacios (de verdad no hay tmux). No dependas de `$FLEET_SOCKET`: a veces viene vacia en un claude resumido/relanzado aunque viva en la flota — `$TMUX` es la senal fiable.
- El perfil (socket+session) debe estar **ya montado** (`launch_fleetclaude` primero); si la sesion no existe, falla con exit 1.
- El `--role` se aplica en **background**: el `sessionId` del nuevo Claude no existe hasta que Claude escribe `~/.claude/sessions/<PID>.json` (unos segundos). `mark_claude_role` espera ese archivo. Si el arranque es muy lento, el role puede tardar en aparecer; es no-fatal (el agente simplemente no se pinea/identifica hasta entonces).
- El `--parent` se aplica igual en **background** via `mark_claude_parent` (misma espera del `sessions/<PID>.json`). Cuando se pasan `--role` y `--parent` juntos se encadenan **secuencialmente** en el mismo subshell (primero role, luego parent) para que la segunda escritura lea el goal ya con la primera clave puesta — sin carrera de lectura-modificacion-escritura. Es no-fatal: si el sessions JSON no aparece a tiempo, el `parent_orchestrator` simplemente no se escribe.
- `--skill` envia `/<name>` como primer prompt: depende de que Claude Code interprete el primer argumento como invocacion de slash command (verificado con `/orquestador`).
- El nuevo Claude hereda `FLEET_SOCKET`/`FLEET_SESSION` del entorno del server tmux (que `launch_fleetclaude` fija con `set-environment`), asi apunta al perfil correcto.
- `--dangerously-skip-permissions` siempre (los agentes de la flota trabajan desatendidos); riesgo asumido como en el resto del modo orquestador.
## Capability growth log
- v1.2.0 (2026-06-21) — `--socket`/`--session` ahora son opcionales: se auto-detectan del contexto tmux (`$TMUX`) via `detect_fleet_context` cuando no se pasan. Elimina el gotcha de caer a kitty cuando `$FLEET_SOCKET` viene vacia pese a estar en la flota. Los valores explicitos siguen primando.
+23 -2
View File
@@ -29,11 +29,15 @@ spawn_fleet_agent() {
--title) shift; title="${1:-claude}" ;;
-h|--help)
cat <<'USAGE'
Uso: spawn_fleet_agent --socket <s> --session <s> --cwd <dir> [opciones]
Uso: spawn_fleet_agent [--socket <s>] [--session <s>] [--cwd <dir>] [opciones]
Lanza un Claude como window nueva en la sesion tmux <session> del socket <socket>
(un perfil FleetView ya montado). Imprime el window_id creado.
--socket/--session son OPCIONALES: si no se pasan, se auto-detectan del contexto
tmux actual ($TMUX) via detect_fleet_context. Los valores explicitos tienen
prioridad. Aborta solo si tras auto-detectar siguen vacios (no hay tmux).
Opciones:
--prompt-file <f> Primer prompt del Claude = contenido del archivo (prompt
autocontenido del ejecutor). El cat lo hace el shell del
@@ -66,8 +70,25 @@ USAGE
shift
done
# Auto-detectar socket/session del contexto tmux ($TMUX) cuando no se pasan
# explicitos. Los --socket/--session explicitos SIEMPRE tienen prioridad.
# Esto evita el bug de caer a kitty cuando $FLEET_SOCKET viene vacia pese a
# estar dentro de una window de la flota (ver detect_fleet_context).
if [[ -z "$socket" || -z "$session" ]]; then
local _detector ctx det_socket="" det_session=""
_detector="$(dirname "${BASH_SOURCE[0]}")/detect_fleet_context.sh"
if [[ -f "$_detector" ]]; then
ctx="$(bash "$_detector" 2>/dev/null || true)"
# Parseo minimo sin depender de jq: extraer "socket":"..." / "session":"...".
det_socket="$(printf '%s' "$ctx" | sed -n 's/.*"socket":"\([^"]*\)".*/\1/p')"
det_session="$(printf '%s' "$ctx" | sed -n 's/.*"session":"\([^"]*\)".*/\1/p')"
[[ -z "$socket" ]] && socket="$det_socket"
[[ -z "$session" ]] && session="$det_session"
fi
fi
[[ -z "$socket" || -z "$session" ]] && {
echo "spawn_fleet_agent: --socket y --session son obligatorios" >&2
echo "spawn_fleet_agent: no se detecto contexto tmux (\$TMUX vacia) y no se pasaron --socket/--session. Lanza desde dentro de la flota o pasa el socket/session explicito." >&2
return 2
}
[[ -z "$cwd" ]] && cwd="$PWD"
+1 -1
View File
@@ -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 |
| [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 |
| [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 |
| [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 |
+30 -16
View File
@@ -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
`.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
`backend/depth.py`). El flujo canonico es de **dos pasos encadenados**:
Promovido desde la app `img_to_3d_webapp` (su backend incrustaba estas funciones; ver
`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
| 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. |
| `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. |
| `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`. |
| `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 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
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)
@@ -37,17 +40,24 @@ pipeline ademas marca `stage` (`estimate`/`relief`) en el error.
# ausentes en el venv de vision. Ver "Fronteras / gotchas".
import sys
sys.path.insert(0, "python/functions/datascience")
from remove_background import remove_background
from estimate_image_depth import estimate_image_depth
from depth_to_relief_glb import depth_to_relief_glb
IMG = "apps/img_to_3d_webapp/samples/cats.jpg"
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
assert est["status"] == "ok"
# 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"
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.
@@ -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
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**.
- **Deps pesadas y de dos mundos.** Requiere `torch`+`transformers` (vision) y `trimesh` (mesh),
que hoy viven en el venv de `img_to_3d_webapp`, NO en el venv del registry. Ademas el
`datascience.__init__` arrastra deps de scrapers (`bs4`...) que no estan en el venv de vision,
por eso el import es **plano** (al modulo) y no via el paquete. `fn run` de estas funciones
exige un venv que combine ambos mundos (torch + transformers + trimesh + las deps del dominio
datascience). Ver gotchas en cada `.md`.
- **Deps pesadas y de dos mundos.** Requiere `torch`+`transformers` (vision), `trimesh` (mesh) y,
para `remove_background`, `rembg`+`onnxruntime` (segmentacion) y `opencv-python` (GrabCut) —
todas opcionales: el umbral de `remove_background` es NumPy puro. Hoy viven en el venv de
`img_to_3d_webapp`, NO en el venv del registry. Ademas el `datascience.__init__` arrastra deps
de scrapers (`bs4`...) que no estan en el venv de vision, por eso el import es **plano** (al
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
- 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).
- Paquetes: `torch`, `transformers`, `trimesh`, `pillow`, `numpy`.
del modelo de profundidad a `~/.cache/huggingface/` y el de `rembg` (U2Net ~170 MB) a su cache.
- 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
lang: py
domain: datascience
version: "1.0.0"
version: "1.1.0"
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"
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."
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. 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]
uses_functions: []
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."
- 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."
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
tests: []
test_file_path: ""
@@ -81,3 +83,14 @@ suavizar el relieve.
- **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
`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,
z_scale: float = 0.35,
max_dim: int = 220,
mask: "np.ndarray | None" = None,
) -> dict:
"""
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.
max_dim: lado máximo del grid tras downsample (controla nº de vértices/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):
É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
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.
aspect = W / float(H)
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).
u = np.linspace(0.0, 1.0, W, 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)