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>
This commit is contained in:
1 parent
8f7b52cc20
commit
ba5be511d2
1 file changed
+727
@@ -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: <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.
|
||||||
Reference in new issue
Block a user