# 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.