Files
Presa-Mano/docs/superpowers/specs/2026-05-11-find-model-design.md
T
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

30 KiB
Raw Blame History

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.

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, 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 ====================):

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.