Files
Presa-Mano/docs/superpowers/specs/2026-05-11-find-model-design.md
Adriano Dal PastroandClaude Opus 4.7 ba5be511d2 docs: design spec for Cerca modello 3D pose-fit overlay
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>
2026-05-11 18:35:53 +02:00

728 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.