diff --git a/docs/superpowers/specs/2026-05-11-find-model-design.md b/docs/superpowers/specs/2026-05-11-find-model-design.md new file mode 100644 index 0000000..cf4460d --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-find-model-design.md @@ -0,0 +1,727 @@ +# 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: . + # 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_", ...). +``` + +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 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": "", + "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 = ""` (es. `"low fitness 0.42"`, `"rmse 8.1mm > 5.0"`, +`"Open3DModel REGISTRATION_FAILED: "`). 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": "", + "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 `
` + +**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 +

Fit modello 3D

+
+ + +
+
+ +
+ +
—
+ +
+ +
+ +
+ + + +
+ +
—
+ + +
+ 5 + …
+
+

+
+``` + +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 = ''; + (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 = ``; + } +} + +$('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=": "` | +| `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":"","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.