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>
30 KiB
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_cameradalla 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
/plane/execute. Restano invariate. - Esporre
/api/posedi 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 eplane_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_sam21la 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.
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.
@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)
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)
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:
@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:
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)
_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:
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)
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
{
"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
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)
{
"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:
<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,scatterXZe le funzionidrawCrop,drawScatter,drawScatterse 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 ====================):
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 conz≥-5mm. wp=None→accepted=input,excluded=[],plane_applied=false.pts=empty→ entrambi vuoti, nessun errore.
- Piano XY a Z=0 con offset 5mm. Mix di punti sopra/sotto/al limite →
-
tests/test_stl_sampler.py- Cubo binary STL 10×10×10 mm →
parse_stl_bytesritorna 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.
- Cubo binary STL 10×10×10 mm →
-
tests/test_projection.py- Punto a
(0, 0, 1000)mm conK=[[600,0,320],[0,600,240],[0,0,1]]→(320, 240)esatto. - Punti con
Z<=0esclusi. - Punti che proiettano fuori bbox immagine esclusi.
- Punto a
-
tests/test_open3dmodel_client.py- Mock
httpx:- Happy
list_recipes→ lista. - 403 con detail "API key non valida" →
Open3DModelError(code="HTTP_403", …). success=falsecon code=REGISTRATION_FAILED→Open3DModelError("REGISTRATION_FAILED", …).success=falsecon 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).
- Happy
- Mock
-
tests/test_recipe_mesh_cache.py- LRU evict dopo 9 chiamate distinte con capacity=8.
- Stesso
recipe_iddue volte → 1 sola GET sul mock client.
Integration (manuali, stack reale)
-
docker compose upcon 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_pxnon vuoto,timings_ms.total < 2000. -
docker compose stop open3dmodel→ stessa chiamata → HTTP 502 con messaggio chiaro"Open3DModel unreachable: …". -
Oggetto giusto in scena ma
recipe_iddi sfera (mismatch) →fit_ok=false,fit_reasoncita fitness/rmse, RGB + accepted_px + excluded_px restituiti. -
DELETE /api/work_planeprima della chiamata → risposta haexcluded_points_px=[],plane_applied=false.fit_okdipende solo dal pose-fit. -
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_cameradalla 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_4x4in frame base per consumo da agent.