Brainstorming → approved spec for the GUI/backend redesign of tab "Cerca modello": YOLO+SAM21 cluster feeds Open3DModel /api/pose, presa-mano samples the recipe STL, projects onto a snapshot RGB with togglable overlays for accepted/excluded points and the fitted model points. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
728 lines
30 KiB
Markdown
728 lines
30 KiB
Markdown
# Cerca modello: 3D pose-fitting overlay
|
||
|
||
**Status:** approved (brainstorming → ready for implementation plan)
|
||
**Date:** 2026-05-11
|
||
**Scope:** `presa-mano` orchestrator, tab "Cerca modello" (`tab-grasp`) GUI + new
|
||
backend route `POST /api/grasp/find_model`.
|
||
|
||
## Goal
|
||
|
||
Da YOLO + SAM21 abbiamo già il cluster di punti 3D dell'oggetto in frame
|
||
camera. Vogliamo poter scegliere una **ricetta** 3D (STL + simmetria) dal
|
||
sibling service `Open3DModel`, premere **Trova**, e visualizzare sopra una RGB
|
||
"fresca":
|
||
- i punti del modello CAD proiettati nella posa stimata (overlay blu),
|
||
- opzionalmente i punti SAM21 accettati dal filtro piano (verde),
|
||
- opzionalmente i punti SAM21 esclusi dal filtro piano (rosso).
|
||
|
||
In parallelo ripuliamo il tab rimuovendo le sezioni di debug "Crop utensile" e
|
||
"Nuvola punti oggetto (base frame)", e rimuoviamo i bottoni "Pianifica grasp" e
|
||
"Esegui presa" — gli endpoint sottostanti `/api/grasp/plan` e
|
||
`/api/grasp/execute` restano in vita (usati dagli agent tools), ma il GUI non
|
||
li espone più in questo tab.
|
||
|
||
## Non-goals
|
||
|
||
- Cablare `grasp_pose_camera` + `approach_waypoints_camera` dalla ricetta
|
||
dentro `/api/grasp/execute`. È una spec separata già concordata.
|
||
- Loop "trova" continuo analogo al `Loop riconoscimento`.
|
||
- Editor di ricette dentro presa-mano (Open3DModel ha il suo frontend React).
|
||
- Visual servo / reallineamento dopo find.
|
||
- Modificare la pipeline PCA-based di `/plan` e `/execute`. Restano invariate.
|
||
- Esporre `/api/pose` di Open3DModel come passthrough generico. Solo
|
||
`/api/models/recipes` (lista) è proxato per evitare di mettere api_key nel
|
||
frontend.
|
||
|
||
## Pre-requisiti runtime
|
||
|
||
- SAM21 attivo e raggiungibile (no fallback bbox per il fit modello).
|
||
- Work-plane può essere assente: in quel caso `excluded_points_px` è vuoto e
|
||
`plane_applied=false`. Il fit gira lo stesso (FPFH+RANSAC è robusto al
|
||
rumore ma più lento; l'operatore vede comunque il risultato).
|
||
- Hand-eye calibration deve esistere perché `points_via_sam21` la verifica
|
||
upstream (anche se il fit modello in sé vive in frame camera).
|
||
|
||
## High-level flow
|
||
|
||
```
|
||
┌──────────── GUI tab "Cerca modello" ────────────┐
|
||
│ [Rileva] → bboxes su RGB │
|
||
│ Dropdown classe + conf input │
|
||
│ Dropdown ricetta ⬅ GET /api/models/recipes │
|
||
│ [Trova] ──┐ │
|
||
│ │ ┌── canvas risultato (sotto) ──┐ │
|
||
│ │ │ RGB snapshot + overlay: │ │
|
||
│ │ │ • punti modello (blu) │ │
|
||
│ │ │ • flag accettati (verdi) │ │
|
||
│ │ │ • flag esclusi (rossi) │ │
|
||
│ │ │ fitness / rmse / timings │ │
|
||
│ ▼ └──────────────────────────────┘ │
|
||
│ POST /api/grasp/find_model ◀──┘ │
|
||
└─────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────── presa-mano backend ──────────────┐
|
||
│ 1. YOLO detect → bbox top-1 │
|
||
│ 2. SAM21 → punti in frame OpenCV camera (mm) │
|
||
│ + cattura rgb_jpeg_bytes nello stesso step │
|
||
│ 3. partition_by_plane(pts, work_plane) │
|
||
│ → (accepted, excluded) │
|
||
│ 4. POST :8878/api/pose (accepted, intrinsics, │
|
||
│ recipe_id, unit=mm) → transform_4x4 + fitness│
|
||
│ 5. RecipeMeshCache.get(recipe_id): │
|
||
│ GET /api/recipes/{id}/stl (LRU 8 entries) │
|
||
│ parse via numpy-stl → (verts, tris) np │
|
||
│ 6. sample_surface(verts, tris, n=2000) │
|
||
│ 7. transform_points(transform_4x4, model_pts) │
|
||
│ 8. project_camera_to_pixels(..., K_rgb_nuwa) │
|
||
│ per model + accepted + excluded │
|
||
│ 9. base64(rgb_jpeg_bytes) │
|
||
└─────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
Open3DModel :8878
|
||
(FPFH + RANSAC + ICP)
|
||
```
|
||
|
||
**Tutto in OpenCV camera frame (Z-forward, mm)** per il `/api/pose`. Il
|
||
`transform_4x4` ritornato resta in quel frame. La proiezione 2D usa K
|
||
dall'intrinsics RGB di Nuwa.
|
||
|
||
## Architecture
|
||
|
||
Cinque file modificati, quattro nuovi.
|
||
|
||
### `clients/open3dmodel.py` (nuovo)
|
||
|
||
Peer di `nuwa.py` / `sam21.py`. Solo `httpx`.
|
||
|
||
```python
|
||
class Open3DModelUnavailable(RuntimeError): ... # connect/timeout
|
||
class Open3DModelError(RuntimeError): # 4xx/5xx con success=false
|
||
def __init__(self, code: str, message: str): ...
|
||
code: str
|
||
message: str
|
||
|
||
class Open3DModelClient:
|
||
def __init__(self, base_url: str, api_key: str | None, timeout_s: float = 30.0): ...
|
||
|
||
def list_recipes(self) -> list[dict]:
|
||
# GET /api/recipes → [{id, name, symmetry_type, ...}, ...]
|
||
|
||
def get_stl_bytes(self, recipe_id: str) -> tuple[bytes, str]:
|
||
# GET /api/recipes/{id}/stl → (stl_bytes, etag_or_lastmod)
|
||
# Soglia hard max_stl_bytes=20MB: oltre → Open3DModelError("STL_TOO_LARGE", ...)
|
||
|
||
def estimate_pose(
|
||
self, recipe_id: str,
|
||
points_cv_m: np.ndarray, # (N,3) METRI, OpenCV frame
|
||
intrinsics: dict, # fx, fy, cx, cy, width, height
|
||
) -> dict:
|
||
# POST /api/pose con unit="m", payload point_cloud=[[x,y,z], ...]
|
||
# Header X-Api-Key: <key>.
|
||
# Open3DModel internamente moltiplica per 1000 quando unit=="m" e la
|
||
# response è sempre in mm (translation_mm, transform_4x4 con t in mm,
|
||
# inlier_rmse_mm). Niente conversione client-side.
|
||
# Unwrap envelope {success, data, error}. success=false → Open3DModelError.
|
||
# HTTP 401/403 (api key) → Open3DModelError(code="HTTP_<status>", ...).
|
||
```
|
||
|
||
Timeout default 30s perché su CPU mode il fit ICP può prendere alcuni secondi.
|
||
|
||
### `clients/open3dmodel_cache.py` (nuovo, ~50 LOC)
|
||
|
||
LRU in-memory di vertici STL già parsati, singleton di processo.
|
||
|
||
```python
|
||
@dataclass(frozen=True)
|
||
class RecipeMesh:
|
||
vertices: np.ndarray # (M, 3) float64, mm, model frame
|
||
triangles: np.ndarray # (T, 3) int32 indices into vertices
|
||
etag: str
|
||
|
||
class RecipeMeshCache:
|
||
def __init__(self, client: Open3DModelClient, capacity: int = 8): ...
|
||
def get(self, recipe_id: str) -> RecipeMesh:
|
||
# cache hit (stesso etag) → return.
|
||
# miss → GET STL → parse_stl_bytes → store. Evict LRU se oltre capacity.
|
||
```
|
||
|
||
ETag: usa header `ETag` o `Last-Modified` se Open3DModel li espone; altrimenti
|
||
ricalcola hash sha1 del payload alla GET (semplice, costo trascurabile).
|
||
|
||
I valori in cache restano in RAM finché il processo vive; persi al restart
|
||
(accettabile — la prima Trova dopo restart paga 50-300ms extra).
|
||
|
||
### `perception/stl_sampler.py` (nuovo, ~60 LOC)
|
||
|
||
```python
|
||
def parse_stl_bytes(stl_bytes: bytes) -> tuple[np.ndarray, np.ndarray]:
|
||
"""STL binary or ASCII → (vertices (M,3) float64, triangles (T,3) int32).
|
||
Usa numpy-stl (`stl.mesh.Mesh.from_file` su BytesIO). Deduplicazione
|
||
vertici tramite np.unique(axis=0, return_inverse=True).
|
||
"""
|
||
|
||
def sample_surface(verts: np.ndarray, tris: np.ndarray, n: int, seed: int = 0
|
||
) -> np.ndarray:
|
||
"""Sample n punti uniformi sulla superficie, pesati per area triangolo.
|
||
Algoritmo: area per tri → cumulato → np.searchsorted(rand) per scegliere
|
||
tri → barycentric uniforme `(1-√r1)A + √r1(1-r2)B + √r1·r2·C`. Ritorna (n,3).
|
||
"""
|
||
```
|
||
|
||
Niente normali, niente colori — solo XYZ in model frame, in mm.
|
||
|
||
### `perception/projection.py` (nuovo, ~30 LOC)
|
||
|
||
```python
|
||
def project_camera_to_pixels(
|
||
points_cam_mm: np.ndarray, # (N,3) OpenCV frame, mm
|
||
K: np.ndarray, # (3,3) intrinsics
|
||
image_size: tuple[int, int], # (W, H) per il clipping
|
||
) -> np.ndarray: # (M, 2) int32 — solo punti dentro frame e Z>0
|
||
"""Z>0 (davanti camera) AND 0<=u<W AND 0<=v<H.
|
||
L'unità del K (pixel) e di P (mm) si elidono nel rapporto (K@P)/P.z.
|
||
"""
|
||
```
|
||
|
||
### `perception/sam21_bridge.py` (refactor)
|
||
|
||
Oggi `points_via_sam21` fa tutto in un blocco — segmentazione + filtro piano +
|
||
`Sam21Fallback` per i casi degeneri. Per `find_model` servono i punti SAM21
|
||
**prima** del filtro piano (così `partition_by_plane` può estrarre accepted +
|
||
excluded entrambi) e l'**rgb_jpeg** già grabbato a step `nuwa_grab`.
|
||
|
||
Decompongo in due funzioni, senza cambiare comportamento osservabile di
|
||
`points_via_sam21`:
|
||
|
||
```python
|
||
@dataclass
|
||
class Sam21Segmentation:
|
||
points_cv_m: np.ndarray # (N,3) METRI, OpenCV frame, no plane filter
|
||
rgb_jpeg: bytes # snapshot Nuwa usato per la segmentazione
|
||
intrinsics_rgb: dict # con width/height iniettati
|
||
|
||
def segment_with_sam21(nuwa, sam21, bbox, sw: Stopwatch) -> Sam21Segmentation:
|
||
"""SAM21 puro: nuwa_grab + depth_align + segment. NESSUN plane filter.
|
||
Stesse eccezioni di prima (Sam21Fallback, HTTPException 502 per Nuwa).
|
||
"""
|
||
|
||
def points_via_sam21(nuwa, sam21, bbox, sw) -> tuple[np.ndarray, dict]:
|
||
"""Wrapper esistente: segment_with_sam21 + apply_below_plane_filter.
|
||
Firma e semantica invariate per i chiamanti attuali."""
|
||
seg = segment_with_sam21(nuwa, sam21, bbox, sw)
|
||
pts_cv, plane_info = apply_below_plane_filter(seg.points_cv_m, get_work_plane())
|
||
if pts_cv.shape[0] < 10:
|
||
raise Sam21Fallback(f"too few points after plane filter: ...")
|
||
return pts_cv, plane_info
|
||
```
|
||
|
||
`find_model` chiama `segment_with_sam21` direttamente, applica
|
||
`partition_by_plane`, e usa `seg.rgb_jpeg` per la response.
|
||
|
||
### `perception/work_plane.py` (modifica)
|
||
|
||
Aggiungo `partition_by_plane` come sibling di `apply_below_plane_filter`:
|
||
|
||
```python
|
||
def partition_by_plane(
|
||
pts_cv: np.ndarray,
|
||
wp: WorkPlane | None,
|
||
) -> tuple[np.ndarray, np.ndarray, dict]:
|
||
"""Restituisce (accepted, excluded, info).
|
||
info: {plane_applied: bool, n_points_before_plane: int}.
|
||
Stessa regola di apply_below_plane_filter: keep se signed >= -offset_m.
|
||
Quando wp is None: accepted=pts_cv, excluded=empty, plane_applied=False.
|
||
"""
|
||
```
|
||
|
||
`apply_below_plane_filter` resta come thin wrapper (delega a
|
||
`partition_by_plane` e scarta `excluded`). I chiamanti esistenti
|
||
(`grasp_routes._points_via_bbox`, `sam21_bridge.points_via_sam21`) non
|
||
cambiano.
|
||
|
||
### `api/_deps.py` (modifica)
|
||
|
||
```python
|
||
_recipe_cache_singleton: RecipeMeshCache | None = None
|
||
_recipe_cache_client_id: int | None = None # id() del client per cui è valido
|
||
|
||
def open3dmodel() -> Open3DModelClient:
|
||
cfg = get_config()
|
||
s = cfg.get("services", {}).get("open3dmodel", {})
|
||
api_key_env = s.get("api_key_env")
|
||
api_key = os.environ.get(api_key_env) if api_key_env else None
|
||
return Open3DModelClient(
|
||
base_url=s.get("base_url", "http://localhost:8878"),
|
||
api_key=api_key,
|
||
timeout_s=float(s.get("timeout_s", 30.0)),
|
||
)
|
||
|
||
def recipe_cache() -> RecipeMeshCache:
|
||
"""Singleton — la cache sopravvive a config-hot-reload. Se l'utente
|
||
cambia base_url di Open3DModel via PUT /api/config, lo svuotamento avviene
|
||
via stale etag al prossimo GET (l'entry vecchia non corrisponde
|
||
all'oggetto nuovo). Accettabile."""
|
||
global _recipe_cache_singleton
|
||
if _recipe_cache_singleton is None:
|
||
_recipe_cache_singleton = RecipeMeshCache(open3dmodel(), capacity=8)
|
||
return _recipe_cache_singleton
|
||
```
|
||
|
||
Il client per-request resta hot-reloadable. La cache è singleton ma usa il
|
||
client passato a `get()` solo come "default fetcher" — nelle chiamate
|
||
successive può ricevere un client fresco se serve (dettaglio: la firma di
|
||
`get(recipe_id, client=None)` accetta override; di default usa quello passato
|
||
al costruttore).
|
||
|
||
### `api/grasp_routes.py` (modifica — solo aggiunta)
|
||
|
||
Nuovo handler `POST /api/grasp/find_model` accanto a `/plan` e `/execute`.
|
||
Vedi sezione "Endpoint behavior" sotto.
|
||
|
||
### `api/models_routes.py` (nuovo, ~25 LOC)
|
||
|
||
Mini-proxy per evitare di mettere `OPEN3D_API_KEY` nel browser:
|
||
|
||
```python
|
||
router = APIRouter()
|
||
|
||
@router.get("/recipes")
|
||
def list_recipes() -> dict[str, Any]:
|
||
try:
|
||
recs = _deps.open3dmodel().list_recipes()
|
||
except Open3DModelUnavailable as e:
|
||
raise HTTPException(502, f"Open3DModel unreachable: {e}")
|
||
except Open3DModelError as e:
|
||
raise HTTPException(502, f"Open3DModel error: {e.code}: {e.message}")
|
||
return {"recipes": recs}
|
||
```
|
||
|
||
Montato in `main.py`: `app.include_router(models_routes.router, prefix="/api/models")`.
|
||
|
||
### `config.yaml` (aggiunta)
|
||
|
||
```yaml
|
||
services:
|
||
open3dmodel:
|
||
base_url: http://localhost:8878
|
||
timeout_s: 30.0
|
||
api_key_env: OPEN3D_API_KEY
|
||
grasp:
|
||
find_model:
|
||
sample_points: 2000 # punti sampleati dal mesh per overlay
|
||
min_fitness: 0.5 # sotto → fit_failed=true
|
||
max_rmse_mm: 5.0 # sopra → fit_failed=true
|
||
max_stl_bytes: 20000000 # 20 MB hard cap sul download STL
|
||
```
|
||
|
||
### `.env.example` (aggiunta)
|
||
|
||
```
|
||
OPEN3D_API_KEY=
|
||
```
|
||
|
||
### `pyproject.toml` (aggiunta)
|
||
|
||
`numpy-stl >= 3.0` come dipendenza runtime (dipende solo da numpy/python-utils,
|
||
~50KB installato).
|
||
|
||
## Endpoint behavior: `POST /api/grasp/find_model`
|
||
|
||
### Request
|
||
|
||
```json
|
||
{
|
||
"recipe_id": "<uuid>",
|
||
"conf": 0.5,
|
||
"class_name": "pestello"
|
||
}
|
||
```
|
||
|
||
`recipe_id` obbligatorio. `conf` e `class_name` opzionali, semantica identica a
|
||
`/plan` (riusa `ObjectDetector` esistente, top-1 dopo filtro).
|
||
|
||
### Pipeline (con `Stopwatch.step`)
|
||
|
||
```
|
||
1. "yolo" ObjectDetector.detect(conf) → filtro class_name → top-1
|
||
→ bbox. 0 detection → HTTP 404.
|
||
2. "sam21" seg = segment_with_sam21(nuwa, _deps.sam21(), bbox, sw)
|
||
→ seg.points_cv_m (METRI, OpenCV camera frame, no plane filter)
|
||
→ seg.rgb_jpeg (snapshot Nuwa)
|
||
→ seg.intrinsics_rgb (fx, fy, cx, cy, W, H)
|
||
→ Sam21Fallback → HTTP 422 (NIENTE fallback bbox: fit
|
||
su nuvola sporca produrrebbe posa garbage)
|
||
3. "partition_plane" accepted_m, excluded_m, info = partition_by_plane(seg.points_cv_m, wp)
|
||
→ len(accepted_m) < 10 → HTTP 422
|
||
4. "open3d_pose" open3dmodel().estimate_pose(recipe_id, accepted_m,
|
||
seg.intrinsics_rgb) # unit="m"
|
||
→ Open3DModelUnavailable → HTTP 502
|
||
→ Open3DModelError code=RECIPE_NOT_FOUND → HTTP 404
|
||
→ Open3DModelError code=HTTP_403/HTTP_401 → HTTP 502 (api key)
|
||
→ altre Open3DModelError (REGISTRATION_FAILED, EMPTY_CLOUD,
|
||
MODEL_MISSING, INVALID_MESH, NO_CORRESPONDENCES) →
|
||
catturato come fit_failed=true
|
||
5. "recipe_mesh" mesh = recipe_cache().get(recipe_id)
|
||
→ STL fetch/parse fail → HTTP 502
|
||
6. "sample" model_pts_mm = sample_surface(mesh.verts, mesh.tris,
|
||
cfg.grasp.find_model.sample_points)
|
||
# model frame, mm (STL convention)
|
||
7. "transform" model_pts_cam_mm = transform_points(transform_4x4, model_pts_mm)
|
||
# camera frame, mm (transform_4x4 ha translation in mm)
|
||
8. "project" K = K_from_intrinsics(seg.intrinsics_rgb)
|
||
model_px = project_camera_to_pixels(model_pts_cam_mm, K, (W,H))
|
||
# SAM21 points sono in metri ma K@P/P.z elide le unità:
|
||
accepted_px = project_camera_to_pixels(accepted_m, K, (W,H))
|
||
excluded_px = project_camera_to_pixels(excluded_m, K, (W,H))
|
||
9. "encode_rgb" rgb_b64 = base64(seg.rgb_jpeg).decode("ascii")
|
||
```
|
||
|
||
### Soglie di "successo" del fit
|
||
|
||
```python
|
||
fit_ok = (
|
||
pose_data.get("success") is True
|
||
and pose_data["fitness"] >= cfg.grasp.find_model.min_fitness
|
||
and pose_data["inlier_rmse_mm"] <= cfg.grasp.find_model.max_rmse_mm
|
||
)
|
||
```
|
||
|
||
Se `fit_ok == False`: `model_points_px = []`, `fit_failed = true`,
|
||
`fit_reason = "<causa>"` (es. `"low fitness 0.42"`, `"rmse 8.1mm > 5.0"`,
|
||
`"Open3DModel REGISTRATION_FAILED: <msg>"`). I punti accettati/esclusi vengono
|
||
comunque proiettati e ritornati — l'operatore deve poter debuggare il piano.
|
||
|
||
**Note sulla soglia min_fitness:** Open3DModel ha già la sua `POSE_MIN_FITNESS`
|
||
(default 0.5) e rifiuta server-side i fit sotto quella. La nostra
|
||
`cfg.grasp.find_model.min_fitness` è una soglia presa-mano-side, può essere
|
||
più stretta (es. 0.7) per "fit_ok pieno". Quando Open3DModel rigetta lui
|
||
stesso, ci arriva `success=false` con code=`REGISTRATION_FAILED` →
|
||
trattato come `fit_failed`.
|
||
|
||
### Response (status 200)
|
||
|
||
```json
|
||
{
|
||
"ok": true,
|
||
"rgb_jpeg_b64": "<base64>",
|
||
"rgb_size": [640, 480],
|
||
|
||
"detection": {"class": "pestello", "confidence": 0.83,
|
||
"bbox": {"x1":..., "y1":..., "x2":..., "y2":...}},
|
||
"recipe": {"id": "...", "name": "pestello", "symmetry_type": "cylindrical"},
|
||
|
||
"model_points_px": [[u,v], ...],
|
||
"accepted_points_px": [[u,v], ...],
|
||
"excluded_points_px": [[u,v], ...],
|
||
|
||
"n_accepted": 1234,
|
||
"n_excluded": 56,
|
||
"plane_applied": true,
|
||
|
||
"fit_ok": true,
|
||
"fit_failed": false,
|
||
"fit_reason": null,
|
||
"fitness": 0.87,
|
||
"inlier_rmse_mm": 1.8,
|
||
"transform_camera_4x4": [[...], [...], [...], [...]],
|
||
|
||
"timings_ms": {"yolo": 45, "sam21": 380, "partition_plane": 2,
|
||
"open3d_pose": 412, "recipe_mesh": 0,
|
||
"sample": 3, "transform": 1, "project": 2,
|
||
"encode_rgb": 8, "total": 853}
|
||
}
|
||
```
|
||
|
||
Niente `points_camera` o `points_base` raw: il GUI non ha bisogno del 3D.
|
||
|
||
### Errori HTTP
|
||
|
||
| Status | Quando |
|
||
|---|---|
|
||
| 400 | `recipe_id` mancante/malformato |
|
||
| 404 | Nessuna detection YOLO; oppure `recipe_id` non esiste in Open3DModel |
|
||
| 412 | Calibrazione hand-eye assente per il side attivo (riusa controllo upstream di `points_via_sam21`) |
|
||
| 422 | `Sam21Fallback`; oppure <10 punti accettati dopo plane filter |
|
||
| 502 | Nuwa down; SAM21 unreachable; Open3DModel unreachable; STL fetch/parse error |
|
||
|
||
`Open3DModelError` con `success=false` (fitness bassa, ICP non converge) NON
|
||
è 5xx: è uno stato del dominio (oggetto non riconoscibile in quella posa) e va
|
||
in body 200 con `fit_failed=true`.
|
||
|
||
## Frontend (HTML + JS)
|
||
|
||
### `web/index.html` — sezione `<section id="tab-grasp">`
|
||
|
||
**Colonna sinistra**: rimuovo `Pianifica grasp`, `Esegui presa`, `graspDryRun`.
|
||
Bottoni restanti: `Rileva oggetto`, `▶ Loop riconoscimento`.
|
||
|
||
**Colonna destra**: rimpiazzo completamente "Crop utensile" + "Nuvola punti
|
||
oggetto" con:
|
||
|
||
```html
|
||
<h2>Fit modello 3D</h2>
|
||
<div class="kv">
|
||
<label>Modello
|
||
<select id="findModelPick"><option value="">— carico ricette… —</option></select>
|
||
</label>
|
||
<button id="findRecipesRefreshBtn" class="ghost" title="Ricarica ricette">↻</button>
|
||
</div>
|
||
<div class="actions">
|
||
<button id="findBtn" class="primary" disabled>Trova</button>
|
||
</div>
|
||
|
||
<div id="findStatus" class="status-line">—</div>
|
||
|
||
<div class="canvas-wrap">
|
||
<canvas id="findResultCanvas" width="640" height="480"></canvas>
|
||
</div>
|
||
|
||
<div class="kv">
|
||
<label class="inline"><input type="checkbox" id="findShowModel" checked> Punti modello (blu)</label>
|
||
<label class="inline"><input type="checkbox" id="findShowAccepted"> Punti accettati (verdi)</label>
|
||
<label class="inline"><input type="checkbox" id="findShowExcluded"> Punti esclusi (rossi)</label>
|
||
</div>
|
||
|
||
<pre id="findDetails" class="log small">—</pre>
|
||
|
||
<!-- DOM hooks dormienti già esistenti (lascio per non rompere app.js) -->
|
||
<div style="display:none">
|
||
<span id="statsWindow">5</span>
|
||
<table id="statsTable"><tbody>…</tbody></table>
|
||
<div id="statsMeta"></div>
|
||
<pre id="graspOut"></pre>
|
||
</div>
|
||
```
|
||
|
||
Toggle "Punti modello" acceso di default (risultato principale). I due flag
|
||
SAM21 spenti di default (sono debug del piano).
|
||
|
||
### `web/app.js` — modifiche
|
||
|
||
**Rimozioni**:
|
||
- `cropCanvas` / `cropCtx`, `scatterXY`, `scatterXZ` e le funzioni `drawCrop`,
|
||
`drawScatter`, `drawScatters` e ogni chiamata.
|
||
- Handler di `planBtn`, `executeBtn` (gli elementi non esistono più).
|
||
- Variabili `lastCropBbox`, `lastPoints`.
|
||
- Pulizia di `applyScanResponse` (non scrive più crop/scatters).
|
||
|
||
**Aggiunte** (~120 LOC, blocco `// ==================== FIT MODELLO ====================`):
|
||
|
||
```js
|
||
const findCanvas = $('findResultCanvas');
|
||
const findCtx = findCanvas.getContext('2d');
|
||
let findResult = null;
|
||
let findRgbImg = null;
|
||
|
||
async function loadRecipes() {
|
||
const sel = $('findModelPick');
|
||
try {
|
||
const r = await api('GET', '/api/models/recipes');
|
||
sel.innerHTML = '<option value="">— seleziona modello —</option>';
|
||
(r.recipes || []).forEach(rec => {
|
||
const opt = document.createElement('option');
|
||
opt.value = rec.id;
|
||
opt.textContent = `${rec.name} (${rec.symmetry_type})`;
|
||
sel.appendChild(opt);
|
||
});
|
||
sel.disabled = (r.recipes || []).length === 0;
|
||
$('findBtn').disabled = !sel.value;
|
||
} catch (e) {
|
||
sel.innerHTML = `<option value="">errore: ${e.message}</option>`;
|
||
}
|
||
}
|
||
|
||
$('findModelPick').addEventListener('change', () => {
|
||
$('findBtn').disabled = !$('findModelPick').value;
|
||
});
|
||
$('findRecipesRefreshBtn').addEventListener('click', loadRecipes);
|
||
|
||
$('findBtn').addEventListener('click', async () => {
|
||
const recipeId = $('findModelPick').value;
|
||
if (!recipeId) return;
|
||
$('findBtn').disabled = true;
|
||
$('findStatus').textContent = 'Cerco posa…';
|
||
try {
|
||
const body = {
|
||
recipe_id: recipeId,
|
||
conf: +$('graspConf').value || null,
|
||
class_name: pickedClass(),
|
||
};
|
||
const r = await api('POST', '/api/grasp/find_model', body);
|
||
findResult = r;
|
||
findRgbImg = new Image();
|
||
findRgbImg.onload = () => { drawFindResult(); };
|
||
findRgbImg.src = 'data:image/jpeg;base64,' + r.rgb_jpeg_b64;
|
||
$('findStatus').textContent = r.fit_ok
|
||
? `OK · fitness=${r.fitness.toFixed(2)} · rmse=${r.inlier_rmse_mm.toFixed(2)}mm · ${fmtTimings(r.timings_ms)}`
|
||
: `Fit fallito: ${r.fit_reason}`;
|
||
$('findStatus').className = 'status-line ' + (r.fit_ok ? 'ok' : 'warn');
|
||
$('findDetails').textContent = JSON.stringify({
|
||
detection: r.detection, recipe: r.recipe,
|
||
n_accepted: r.n_accepted, n_excluded: r.n_excluded,
|
||
fitness: r.fitness, rmse_mm: r.inlier_rmse_mm,
|
||
timings: r.timings_ms,
|
||
}, null, 2);
|
||
} catch (e) {
|
||
$('findStatus').textContent = `errore: ${e.message}`;
|
||
$('findStatus').className = 'status-line err';
|
||
} finally {
|
||
$('findBtn').disabled = !$('findModelPick').value;
|
||
}
|
||
});
|
||
|
||
['findShowModel','findShowAccepted','findShowExcluded'].forEach(id =>
|
||
$(id).addEventListener('change', drawFindResult));
|
||
|
||
function drawFindResult() {
|
||
if (!findResult || !findRgbImg || !findRgbImg.complete) return;
|
||
const [W, H] = findResult.rgb_size;
|
||
if (findCanvas.width !== W) { findCanvas.width = W; findCanvas.height = H; }
|
||
findCtx.drawImage(findRgbImg, 0, 0);
|
||
const dot = (pts, color, r) => {
|
||
findCtx.fillStyle = color;
|
||
for (const [u, v] of pts) findCtx.fillRect(u - r, v - r, r*2+1, r*2+1);
|
||
};
|
||
// Ordine z-buffer: rosso < verde < blu (il modello copre i punti SAM21).
|
||
if ($('findShowExcluded').checked) dot(findResult.excluded_points_px, '#e74c3c', 1);
|
||
if ($('findShowAccepted').checked) dot(findResult.accepted_points_px, '#3ecf8e', 1);
|
||
if ($('findShowModel').checked) dot(findResult.model_points_px, '#4da3ff', 1);
|
||
}
|
||
|
||
function loadRecipesIfNeeded() {
|
||
if ($('tab-grasp').classList.contains('active') && !$('findModelPick').dataset.loaded) {
|
||
loadRecipes();
|
||
$('findModelPick').dataset.loaded = '1';
|
||
}
|
||
}
|
||
document.querySelectorAll('.tab').forEach(t =>
|
||
t.addEventListener('click', () => setTimeout(loadRecipesIfNeeded, 50)));
|
||
loadRecipesIfNeeded();
|
||
```
|
||
|
||
## Frame convention summary
|
||
|
||
| Sorgente | Frame nativo | Unità | Trattato come |
|
||
|---|---|---|---|
|
||
| SAM21 output (via `segment_with_sam21`) | OpenCV RGB-camera | METRI | inviato a `/api/pose` con `unit="m"` |
|
||
| `partition_by_plane` | OpenCV RGB-camera | METRI | invariato |
|
||
| Open3DModel `transform_4x4` | OpenCV RGB-camera | MM (translation) | applicato a `model_pts_mm` → camera-mm |
|
||
| Open3DModel mesh STL | model frame | MM (STL convention) | `sample_surface` mantiene mm |
|
||
| `model_pts_cam` dopo transform | OpenCV RGB-camera | MM | input a `project_camera_to_pixels` |
|
||
| Accepted/excluded per proiezione | OpenCV RGB-camera | METRI | K@P/P.z elide unità → pixel |
|
||
| K dall'intrinsics Nuwa RGB | pixel-space | (cancella) | matrice 3x3 |
|
||
|
||
Nessun passaggio per `T_base_camera`: il fit modello e l'overlay vivono tutti
|
||
in frame camera. La calibrazione hand-eye serve solo a monte (perché
|
||
`points_via_sam21` la richiede oggi per il filtro piano).
|
||
|
||
## Error handling matrix
|
||
|
||
| Origine | Causa | Comportamento |
|
||
|---|---|---|
|
||
| `httpx.ConnectError` / `Timeout` su Nuwa | Camera down | HTTP 502 |
|
||
| YOLO 0 detection o filtro classe via | Nessun oggetto | HTTP 404 |
|
||
| `Sam21Fallback` | Mask vuota, no depth, SAM21 down | HTTP 422 — niente fallback bbox |
|
||
| Calibrazione hand-eye assente | `T_base_camera is None` | HTTP 412 (propagato da `points_via_sam21`) |
|
||
| `<10` punti accettati dopo `partition_by_plane` | Piano mal calibrato o oggetto fuori | HTTP 422 |
|
||
| `Open3DModelUnavailable` | Container down, timeout | HTTP 502 |
|
||
| `Open3DModelError` code=`RECIPE_NOT_FOUND` | recipe_id sbagliato | HTTP 404 |
|
||
| `Open3DModelError` code=`HTTP_401` o `HTTP_403` | API key invalida/mancante | HTTP 502 con `"Open3DModel auth: ..."` |
|
||
| `Open3DModelError` code ∈ `{REGISTRATION_FAILED, EMPTY_CLOUD, MODEL_MISSING, INVALID_MESH, NO_CORRESPONDENCES}` | Pose-fit non converge / scena povera | 200 con `fit_failed=true`, `fit_reason="<code>: <msg>"` |
|
||
| `fitness < min_fitness` o `rmse > max_rmse` | Fit converge ma poco affidabile | 200 con `fit_failed=true`, `model_points_px=[]`, RGB+verdi+rossi ritornati |
|
||
| STL parse error (`numpy-stl` exception) | File malformato | HTTP 502, cache NON memorizza |
|
||
| STL > `max_stl_bytes` | Modello assurdo | HTTP 502, `Open3DModelError("STL_TOO_LARGE")` |
|
||
| `OPEN3D_API_KEY` non in env | `.env` non popolato | All'avvio: log WARNING. Al primo Trova: 502 se Open3DModel risponde 401 |
|
||
|
||
## Performance budget
|
||
|
||
| Step | Budget |
|
||
|---|---|
|
||
| `yolo` | 30-60 ms |
|
||
| `sam21` (GPU) | 300-500 ms |
|
||
| `partition_plane` | <1 ms |
|
||
| `open3d_pose` (GPU, recipe già caricata server-side) | 200-500 ms |
|
||
| `recipe_mesh` (cache hit) | <1 ms |
|
||
| `recipe_mesh` (cache miss + STL parse) | 50-300 ms (one-time per recipe) |
|
||
| `sample` (2000 punti) | 2-5 ms |
|
||
| `project` | <1 ms |
|
||
| `encode_rgb` (jpeg già encoded da Nuwa) | 5-10 ms |
|
||
| **Totale (cache hit)** | **~600-1100 ms** |
|
||
| **Totale (cache miss)** | **~800-1400 ms** |
|
||
|
||
CPU mode di Open3DModel può raddoppiare/triplicare il pose step ma resta
|
||
sotto il timeout 30s.
|
||
|
||
## Testing
|
||
|
||
### Unit tests
|
||
|
||
- `tests/test_partition_plane.py`
|
||
- Piano XY a Z=0 con offset 5mm. Mix di punti sopra/sotto/al limite →
|
||
`accepted` ∪ `excluded` = input, intersezione vuota, `accepted` ⊇ punti
|
||
con `z≥-5mm`.
|
||
- `wp=None` → `accepted=input`, `excluded=[]`, `plane_applied=false`.
|
||
- `pts=empty` → entrambi vuoti, nessun errore.
|
||
|
||
- `tests/test_stl_sampler.py`
|
||
- Cubo binary STL 10×10×10 mm → `parse_stl_bytes` ritorna BB corretto entro
|
||
1e-6.
|
||
- `sample_surface(n=1000)` → tutti i punti dentro BB del cubo.
|
||
- Distribuzione approssimativamente uniforme: 8 octanti, ogni octante
|
||
contiene ≥ 8% dei punti.
|
||
|
||
- `tests/test_projection.py`
|
||
- Punto a `(0, 0, 1000)` mm con `K=[[600,0,320],[0,600,240],[0,0,1]]` →
|
||
`(320, 240)` esatto.
|
||
- Punti con `Z<=0` esclusi.
|
||
- Punti che proiettano fuori bbox immagine esclusi.
|
||
|
||
- `tests/test_open3dmodel_client.py`
|
||
- Mock `httpx`:
|
||
- Happy `list_recipes` → lista.
|
||
- 403 con detail "API key non valida" → `Open3DModelError(code="HTTP_403", …)`.
|
||
- `success=false` con code=`REGISTRATION_FAILED` →
|
||
`Open3DModelError("REGISTRATION_FAILED", …)`.
|
||
- `success=false` con code=`RECIPE_NOT_FOUND` →
|
||
`Open3DModelError("RECIPE_NOT_FOUND", …)`.
|
||
- `ConnectError` → `Open3DModelUnavailable`.
|
||
- Response STL con `Content-Length > max_stl_bytes` →
|
||
`Open3DModelError("STL_TOO_LARGE", …)` (raised client-side, mai dal server).
|
||
|
||
- `tests/test_recipe_mesh_cache.py`
|
||
- LRU evict dopo 9 chiamate distinte con capacity=8.
|
||
- Stesso `recipe_id` due volte → 1 sola GET sul mock client.
|
||
|
||
### Integration (manuali, stack reale)
|
||
|
||
1. `docker compose up` con Nuwa + YOLO + SAM21 + Open3DModel attivi + Pestello
|
||
caricato come ricetta.
|
||
`curl -s -X POST :8890/api/grasp/find_model -d '{"recipe_id":"<pestello>","conf":0.5,"class_name":"pestello"}' -H 'Content-Type: application/json' | jq`
|
||
→ `fit_ok=true`, `fitness≥0.5`, `model_points_px` non vuoto,
|
||
`timings_ms.total < 2000`.
|
||
|
||
2. `docker compose stop open3dmodel` → stessa chiamata → HTTP 502 con messaggio
|
||
chiaro `"Open3DModel unreachable: …"`.
|
||
|
||
3. Oggetto giusto in scena ma `recipe_id` di sfera (mismatch) →
|
||
`fit_ok=false`, `fit_reason` cita fitness/rmse, RGB + accepted_px +
|
||
excluded_px restituiti.
|
||
|
||
4. `DELETE /api/work_plane` prima della chiamata → risposta ha
|
||
`excluded_points_px=[]`, `plane_applied=false`. `fit_ok` dipende solo dal
|
||
pose-fit.
|
||
|
||
5. GUI: tab "Cerca modello" → `Rileva` → dropdown popolato → seleziona pestello
|
||
→ `Trova` → canvas mostra RGB con punti blu (modello), toggle verde/rosso
|
||
funzionano con redraw immediato senza rifetch.
|
||
|
||
## Out-of-scope follow-ups (loggati, non fatti)
|
||
|
||
- Integrare `grasp_pose_camera` + `approach_waypoints_camera` dalla ricetta in
|
||
`/api/grasp/execute` (spec separata già concordata).
|
||
- Loop continuo "Trova" analogo al `Loop riconoscimento`.
|
||
- Editor di ricette dentro presa-mano (Open3DModel ha già il suo frontend).
|
||
- Visual servo per riallineare camera-pose dopo find.
|
||
- Esporre `transform_camera_4x4` in frame base per consumo da agent.
|