13 bite-sized TDD tasks covering: numpy-stl dep + config, partition_by_plane, stl_sampler, projection, Open3DModel client + LRU mesh cache, sam21_bridge refactor, _deps factories, GET /api/models/recipes proxy, POST /api/grasp/find_model handler, GUI rebuild, manual integration smoke test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2177 lines
74 KiB
Markdown
2177 lines
74 KiB
Markdown
# Cerca modello: 3D pose-fit overlay — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Aggiungere a presa-mano un endpoint `POST /api/grasp/find_model` che integra `Open3DModel /api/pose` per il fit 6-DoF di un modello 3D sul cluster SAM21, e ridisegnare il tab "Cerca modello" del GUI con un picker ricetta + overlay risultato (punti modello blu + punti accettati verdi + punti esclusi rossi togglabili).
|
||
|
||
**Architecture:** Riusa la pipeline YOLO+SAM21 esistente (refactorizzata per esporre i punti pre-plane-filter e l'RGB snapshot). Aggiunge un client HTTP per Open3DModel con cache LRU del mesh STL, un sampler per il mesh, e un proiettore camera→pixel. Il fit vive interamente in frame camera OpenCV — nessuna trasformazione in base frame. Il GUI fa una sola chiamata, riceve RGB base64 + 3 liste di pixel, e disegna con toggle client-side.
|
||
|
||
**Tech Stack:** Python 3.10+ (FastAPI, httpx, numpy, opencv-python), `numpy-stl` (nuova dep, ~50KB), vanilla JS + Canvas 2D.
|
||
|
||
**Spec di riferimento:** `docs/superpowers/specs/2026-05-11-find-model-design.md`.
|
||
|
||
---
|
||
|
||
## File structure
|
||
|
||
**Nuovi (backend):**
|
||
- `src/presa_mano/clients/open3dmodel.py` — HTTP client per Open3DModel
|
||
- `src/presa_mano/clients/open3dmodel_cache.py` — LRU mesh cache
|
||
- `src/presa_mano/perception/stl_sampler.py` — parse STL + sample surface
|
||
- `src/presa_mano/perception/projection.py` — proiezione camera→pixel
|
||
- `src/presa_mano/api/models_routes.py` — proxy `GET /api/models/recipes`
|
||
|
||
**Modificati (backend):**
|
||
- `src/presa_mano/perception/work_plane.py` — aggiunta `partition_by_plane`
|
||
- `src/presa_mano/perception/sam21_bridge.py` — refactor: estrae `segment_with_sam21` + `Sam21Segmentation`
|
||
- `src/presa_mano/clients/__init__.py` — export Open3DModel*
|
||
- `src/presa_mano/api/_deps.py` — factory `open3dmodel()` + singleton `recipe_cache()`
|
||
- `src/presa_mano/api/grasp_routes.py` — nuovo handler `find_model`
|
||
- `src/presa_mano/main.py` — mount `models_routes`
|
||
- `config.yaml`, `.env.example`, `pyproject.toml`
|
||
|
||
**Modificati (frontend):**
|
||
- `web/index.html` — rimuove sezioni vecchie, aggiunge picker + canvas risultato
|
||
- `web/app.js` — rimuove handler `planBtn`/`executeBtn` + crop/scatters, aggiunge blocco "fit modello"
|
||
|
||
**Test nuovi:**
|
||
- `tests/test_partition_plane.py`
|
||
- `tests/test_stl_sampler.py`
|
||
- `tests/test_projection.py`
|
||
- `tests/test_open3dmodel_client.py`
|
||
- `tests/test_recipe_mesh_cache.py`
|
||
- `tests/test_sam21_bridge_segment.py`
|
||
|
||
---
|
||
|
||
## Task 1: Setup dipendenza + config
|
||
|
||
**Files:**
|
||
- Modify: `pyproject.toml:6-15`
|
||
- Modify: `config.yaml:1-22` (sezione `services`) e fine file (sezione `grasp`)
|
||
- Modify: `.env.example`
|
||
|
||
- [ ] **Step 1: Aggiungi numpy-stl a pyproject.toml**
|
||
|
||
```toml
|
||
dependencies = [
|
||
"fastapi>=0.110.0",
|
||
"uvicorn[standard]>=0.27.0",
|
||
"httpx>=0.27.0",
|
||
"numpy>=1.24.0",
|
||
"numpy-stl>=3.1.0",
|
||
"opencv-contrib-python-headless>=4.9.0.80",
|
||
"pyyaml>=6.0",
|
||
"pydantic>=2.5.0",
|
||
"python-multipart>=0.0.9",
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 2: Aggiorna lockfile**
|
||
|
||
Run: `uv sync`
|
||
Expected: `Resolved … Installed numpy-stl …` senza errori.
|
||
|
||
- [ ] **Step 3: Aggiungi `services.open3dmodel` in config.yaml**
|
||
|
||
Sotto `services.sam21:` aggiungi:
|
||
|
||
```yaml
|
||
open3dmodel:
|
||
base_url: http://localhost:8878
|
||
timeout_s: 30.0
|
||
api_key_env: OPEN3D_API_KEY
|
||
max_stl_bytes: 20000000
|
||
```
|
||
|
||
- [ ] **Step 4: Aggiungi `grasp.find_model` in config.yaml**
|
||
|
||
Dentro la sezione `grasp:` esistente, aggiungi:
|
||
|
||
```yaml
|
||
find_model:
|
||
sample_points: 2000
|
||
min_fitness: 0.5
|
||
max_rmse_mm: 5.0
|
||
```
|
||
|
||
- [ ] **Step 5: Aggiorna .env.example**
|
||
|
||
Aggiungi in fondo al file:
|
||
|
||
```
|
||
# Chiave API per Open3DModel (pose-fit 6DOF su STL).
|
||
# Generala con la procedura del README di Open3DModel; deve combaciare
|
||
# con OPEN3D_API_KEY in ./Open3DModel/.env.
|
||
OPEN3D_API_KEY=
|
||
```
|
||
|
||
- [ ] **Step 6: Verifica import-time**
|
||
|
||
Run: `uv run python -c "import stl; print(stl.__version__)"`
|
||
Expected: stampa una versione (es. `3.1.x`).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add pyproject.toml uv.lock config.yaml .env.example
|
||
git commit -m "build: add numpy-stl + Open3DModel config block for find_model
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: `partition_by_plane` in perception/work_plane.py
|
||
|
||
**Files:**
|
||
- Test: `tests/test_partition_plane.py`
|
||
- Modify: `src/presa_mano/perception/work_plane.py:125-146`
|
||
|
||
- [ ] **Step 1: Scrivi il test che fallisce**
|
||
|
||
Crea `tests/test_partition_plane.py`:
|
||
|
||
```python
|
||
"""Unit tests for partition_by_plane."""
|
||
from __future__ import annotations
|
||
|
||
import numpy as np
|
||
import pytest
|
||
|
||
from presa_mano.perception.work_plane import WorkPlane, partition_by_plane
|
||
|
||
|
||
def _wp(offset_mm: float = 0.0) -> WorkPlane:
|
||
"""Plane z=0 with normal pointing toward camera (-Z)."""
|
||
return WorkPlane(
|
||
normal=[0.0, 0.0, -1.0],
|
||
offset=0.0,
|
||
centroid=[0.0, 0.0, 0.0],
|
||
n_inliers=100,
|
||
fit_rms_mm=0.5,
|
||
roi=[0, 0, 100, 100],
|
||
offset_mm=offset_mm,
|
||
created_at=0.0,
|
||
)
|
||
|
||
|
||
def test_partition_no_plane_returns_all_accepted():
|
||
pts = np.array([[0.0, 0.0, 1.0], [1.0, 2.0, 3.0]])
|
||
accepted, excluded, info = partition_by_plane(pts, None)
|
||
assert np.array_equal(accepted, pts)
|
||
assert excluded.shape == (0, 3)
|
||
assert info == {"plane_applied": False, "n_points_before_plane": 2}
|
||
|
||
|
||
def test_partition_empty_input():
|
||
pts = np.empty((0, 3), dtype=np.float64)
|
||
accepted, excluded, info = partition_by_plane(pts, _wp())
|
||
assert accepted.shape == (0, 3)
|
||
assert excluded.shape == (0, 3)
|
||
assert info["plane_applied"] is False
|
||
assert info["n_points_before_plane"] == 0
|
||
|
||
|
||
def test_partition_splits_by_offset():
|
||
"""Plane at z=0, normal=-Z, offset_mm=5. With OpenCV sign convention
|
||
(camera looks +Z, surfaces in front have z>0), points with z<-0.005 m are
|
||
below the plane and get excluded."""
|
||
pts = np.array([
|
||
[0.0, 0.0, 0.10], # 100mm sopra → accepted
|
||
[0.0, 0.0, 0.00], # sul piano → accepted (boundary)
|
||
[0.0, 0.0, -0.004], # 4mm sotto → accepted (entro offset 5mm)
|
||
[0.0, 0.0, -0.010], # 10mm sotto → excluded
|
||
[0.0, 0.0, -0.500], # 500mm sotto → excluded
|
||
])
|
||
accepted, excluded, info = partition_by_plane(pts, _wp(offset_mm=5.0))
|
||
assert accepted.shape[0] == 3
|
||
assert excluded.shape[0] == 2
|
||
assert info["plane_applied"] is True
|
||
assert info["n_points_before_plane"] == 5
|
||
# partition: nessuna sovrapposizione
|
||
union = np.vstack([accepted, excluded])
|
||
assert sorted(union[:, 2].tolist()) == sorted(pts[:, 2].tolist())
|
||
|
||
|
||
def test_partition_consistent_with_apply_below_plane_filter():
|
||
"""`accepted` di partition_by_plane deve essere bit-identico a quello
|
||
ritornato da apply_below_plane_filter (l'API legacy non deve cambiare)."""
|
||
from presa_mano.perception.work_plane import apply_below_plane_filter
|
||
rng = np.random.default_rng(42)
|
||
pts = rng.normal(scale=0.1, size=(200, 3))
|
||
wp = _wp(offset_mm=10.0)
|
||
accepted_new, _excluded, _info = partition_by_plane(pts, wp)
|
||
accepted_legacy, info_legacy = apply_below_plane_filter(pts, wp)
|
||
np.testing.assert_array_equal(accepted_new, accepted_legacy)
|
||
assert info_legacy["n_points_before_plane"] == 200
|
||
```
|
||
|
||
- [ ] **Step 2: Esegui i test per vedere il fallimento**
|
||
|
||
Run: `uv run pytest tests/test_partition_plane.py -v`
|
||
Expected: `ImportError: cannot import name 'partition_by_plane'` o `AttributeError`.
|
||
|
||
- [ ] **Step 3: Implementa `partition_by_plane` e refactor `apply_below_plane_filter`**
|
||
|
||
Sostituisci il blocco `apply_below_plane_filter` (linee 125-146) in `src/presa_mano/perception/work_plane.py` con:
|
||
|
||
```python
|
||
def partition_by_plane(
|
||
pts_cv: np.ndarray,
|
||
wp: WorkPlane | None,
|
||
) -> tuple[np.ndarray, np.ndarray, dict]:
|
||
"""Split points into (accepted_above_plane, excluded_below_plane).
|
||
|
||
Same threshold as `apply_below_plane_filter`: keep iff
|
||
`signed >= -offset_mm/1000`. When `wp is None` or `pts_cv` is empty,
|
||
`excluded` is empty and `plane_applied=False`.
|
||
"""
|
||
info = {"plane_applied": False,
|
||
"n_points_before_plane": int(len(pts_cv))}
|
||
empty = np.empty((0, 3), dtype=pts_cv.dtype if pts_cv.size else np.float64)
|
||
if wp is None or len(pts_cv) == 0:
|
||
return pts_cv, empty, info
|
||
n_vec = np.asarray(wp.normal, dtype=np.float64)
|
||
d_off = float(wp.offset)
|
||
offset_m = float(wp.offset_mm) / 1000.0
|
||
signed = pts_cv @ n_vec + d_off
|
||
keep = signed >= -offset_m
|
||
info["plane_applied"] = True
|
||
return pts_cv[keep], pts_cv[~keep], info
|
||
|
||
|
||
def apply_below_plane_filter(
|
||
pts_cv: np.ndarray,
|
||
wp: WorkPlane | None,
|
||
) -> tuple[np.ndarray, dict]:
|
||
"""Thin wrapper around partition_by_plane that drops the excluded set.
|
||
Kept for backwards compatibility with existing callers."""
|
||
accepted, _excluded, info = partition_by_plane(pts_cv, wp)
|
||
return accepted, info
|
||
```
|
||
|
||
- [ ] **Step 4: Esegui i nuovi test**
|
||
|
||
Run: `uv run pytest tests/test_partition_plane.py -v`
|
||
Expected: 4 passed.
|
||
|
||
- [ ] **Step 5: Esegui i test esistenti per assicurarsi che non rompiamo nulla**
|
||
|
||
Run: `uv run pytest tests/test_work_plane.py -v`
|
||
Expected: tutti i test esistenti passano.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add tests/test_partition_plane.py src/presa_mano/perception/work_plane.py
|
||
git commit -m "feat(perception): partition_by_plane returns accepted + excluded sets
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: `perception/stl_sampler.py`
|
||
|
||
**Files:**
|
||
- Create: `src/presa_mano/perception/stl_sampler.py`
|
||
- Test: `tests/test_stl_sampler.py`
|
||
|
||
- [ ] **Step 1: Scrivi i test che falliscono**
|
||
|
||
Crea `tests/test_stl_sampler.py`:
|
||
|
||
```python
|
||
"""Unit tests for stl_sampler."""
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import struct
|
||
|
||
import numpy as np
|
||
import pytest
|
||
|
||
from presa_mano.perception.stl_sampler import parse_stl_bytes, sample_surface
|
||
|
||
|
||
def _make_cube_stl_bytes(side_mm: float = 10.0) -> bytes:
|
||
"""Synthesize a binary STL of an axis-aligned cube centered at origin,
|
||
from -side/2 to +side/2 on each axis. 12 triangles, no normals needed."""
|
||
s = side_mm / 2.0
|
||
v = [
|
||
(-s, -s, -s), ( s, -s, -s), ( s, s, -s), (-s, s, -s),
|
||
(-s, -s, s), ( s, -s, s), ( s, s, s), (-s, s, s),
|
||
]
|
||
# 6 faces × 2 tris (vertex order: any orientation works for our use)
|
||
tris = [
|
||
(0, 1, 2), (0, 2, 3), # z=-s
|
||
(4, 6, 5), (4, 7, 6), # z=+s
|
||
(0, 4, 5), (0, 5, 1), # y=-s
|
||
(3, 2, 6), (3, 6, 7), # y=+s
|
||
(0, 3, 7), (0, 7, 4), # x=-s
|
||
(1, 5, 6), (1, 6, 2), # x=+s
|
||
]
|
||
buf = io.BytesIO()
|
||
buf.write(b"\x00" * 80) # header
|
||
buf.write(struct.pack("<I", len(tris))) # n_triangles
|
||
for (i, j, k) in tris:
|
||
buf.write(struct.pack("<fff", 0.0, 0.0, 0.0)) # normal (unused)
|
||
for idx in (i, j, k):
|
||
buf.write(struct.pack("<fff", *v[idx]))
|
||
buf.write(struct.pack("<H", 0)) # attribute byte count
|
||
return buf.getvalue()
|
||
|
||
|
||
def test_parse_stl_cube_bbox():
|
||
stl_bytes = _make_cube_stl_bytes(side_mm=10.0)
|
||
verts, tris = parse_stl_bytes(stl_bytes)
|
||
assert verts.ndim == 2 and verts.shape[1] == 3
|
||
assert tris.ndim == 2 and tris.shape[1] == 3
|
||
assert tris.dtype.kind == "i"
|
||
# BB del cubo: [-5, +5] su ogni asse
|
||
np.testing.assert_allclose(verts.min(axis=0), [-5.0, -5.0, -5.0], atol=1e-6)
|
||
np.testing.assert_allclose(verts.max(axis=0), [ 5.0, 5.0, 5.0], atol=1e-6)
|
||
|
||
|
||
def test_sample_surface_in_bbox():
|
||
stl_bytes = _make_cube_stl_bytes(side_mm=10.0)
|
||
verts, tris = parse_stl_bytes(stl_bytes)
|
||
pts = sample_surface(verts, tris, n=1000, seed=0)
|
||
assert pts.shape == (1000, 3)
|
||
# tutti i punti devono cadere dentro la BB del cubo (più epsilon
|
||
# per il rumore di floating point bary)
|
||
np.testing.assert_array_less(pts.max(axis=0), 5.0 + 1e-4)
|
||
np.testing.assert_array_less(-pts.min(axis=0), 5.0 + 1e-4)
|
||
|
||
|
||
def test_sample_surface_distribution_uniform():
|
||
"""8 octanti del cubo, ognuno ≥ 8% dei punti (perfetto = 100/6 ≈ 16.7%
|
||
per faccia, distribuita su 4 octanti adiacenti)."""
|
||
stl_bytes = _make_cube_stl_bytes(side_mm=10.0)
|
||
verts, tris = parse_stl_bytes(stl_bytes)
|
||
pts = sample_surface(verts, tris, n=2000, seed=1)
|
||
counts = np.zeros(8, dtype=np.int64)
|
||
for p in pts:
|
||
# bin (x>0, y>0, z>0) → 0..7
|
||
idx = (int(p[0] > 0) << 2) | (int(p[1] > 0) << 1) | int(p[2] > 0)
|
||
counts[idx] += 1
|
||
frac = counts / pts.shape[0]
|
||
assert (frac > 0.08).all(), f"octant fractions too uneven: {frac}"
|
||
|
||
|
||
def test_sample_surface_seed_deterministic():
|
||
stl_bytes = _make_cube_stl_bytes(side_mm=10.0)
|
||
verts, tris = parse_stl_bytes(stl_bytes)
|
||
a = sample_surface(verts, tris, n=100, seed=42)
|
||
b = sample_surface(verts, tris, n=100, seed=42)
|
||
np.testing.assert_array_equal(a, b)
|
||
|
||
|
||
def test_parse_stl_bad_payload_raises():
|
||
with pytest.raises(Exception): # numpy-stl raises various; we just want it to not silently pass
|
||
parse_stl_bytes(b"not a real STL")
|
||
```
|
||
|
||
- [ ] **Step 2: Esegui i test per vedere il fallimento**
|
||
|
||
Run: `uv run pytest tests/test_stl_sampler.py -v`
|
||
Expected: `ImportError: No module named 'presa_mano.perception.stl_sampler'`.
|
||
|
||
- [ ] **Step 3: Implementa `stl_sampler.py`**
|
||
|
||
Crea `src/presa_mano/perception/stl_sampler.py`:
|
||
|
||
```python
|
||
"""Parse binary/ASCII STL bytes and uniformly sample the surface.
|
||
|
||
Output unit follows the STL convention (no unit conversion — STL files for
|
||
robotics are by convention in millimetres, but we don't enforce it here).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
|
||
import numpy as np
|
||
from stl import mesh as stl_mesh
|
||
|
||
|
||
def parse_stl_bytes(stl_bytes: bytes) -> tuple[np.ndarray, np.ndarray]:
|
||
"""Parse STL bytes (binary or ASCII) → (vertices (M,3) float64,
|
||
triangles (T,3) int32).
|
||
|
||
Vertices are deduplicated so the same point used by multiple triangles
|
||
appears once. Raises numpy_stl exceptions on malformed input.
|
||
"""
|
||
m = stl_mesh.Mesh.from_file(filename="dummy.stl", fh=io.BytesIO(stl_bytes))
|
||
# m.vectors shape: (T, 3, 3) — three vertices per triangle, raw.
|
||
tris_raw = np.asarray(m.vectors, dtype=np.float64).reshape(-1, 3)
|
||
unique, inverse = np.unique(tris_raw, axis=0, return_inverse=True)
|
||
triangles = inverse.reshape(-1, 3).astype(np.int32)
|
||
return unique, triangles
|
||
|
||
|
||
def sample_surface(
|
||
vertices: np.ndarray,
|
||
triangles: np.ndarray,
|
||
n: int,
|
||
seed: int = 0,
|
||
) -> np.ndarray:
|
||
"""Sample n points uniformly on the mesh surface, weighted by triangle area.
|
||
|
||
Algorithm:
|
||
1. Compute area for each triangle.
|
||
2. Cumulative area → pick triangle index via inverse CDF.
|
||
3. For each pick, sample a uniform barycentric coordinate:
|
||
`(1-√r1) A + √r1 (1-r2) B + √r1 r2 C` with r1, r2 ~ U(0,1).
|
||
"""
|
||
if triangles.size == 0:
|
||
return np.empty((0, 3), dtype=np.float64)
|
||
A = vertices[triangles[:, 0]]
|
||
B = vertices[triangles[:, 1]]
|
||
C = vertices[triangles[:, 2]]
|
||
areas = 0.5 * np.linalg.norm(np.cross(B - A, C - A), axis=1)
|
||
total = areas.sum()
|
||
if total <= 0.0:
|
||
# degenerate mesh: fall back to vertex sampling
|
||
rng = np.random.default_rng(seed)
|
||
idx = rng.integers(0, vertices.shape[0], size=n)
|
||
return vertices[idx]
|
||
cdf = np.cumsum(areas) / total
|
||
rng = np.random.default_rng(seed)
|
||
u = rng.random(n)
|
||
tri_idx = np.searchsorted(cdf, u, side="right")
|
||
tri_idx = np.clip(tri_idx, 0, triangles.shape[0] - 1)
|
||
r1 = rng.random(n)
|
||
r2 = rng.random(n)
|
||
sqrt_r1 = np.sqrt(r1)
|
||
w_a = (1.0 - sqrt_r1)[:, None]
|
||
w_b = (sqrt_r1 * (1.0 - r2))[:, None]
|
||
w_c = (sqrt_r1 * r2)[:, None]
|
||
return (w_a * A[tri_idx] + w_b * B[tri_idx] + w_c * C[tri_idx]).astype(np.float64)
|
||
```
|
||
|
||
- [ ] **Step 4: Esegui i test**
|
||
|
||
Run: `uv run pytest tests/test_stl_sampler.py -v`
|
||
Expected: 5 passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/test_stl_sampler.py src/presa_mano/perception/stl_sampler.py
|
||
git commit -m "feat(perception): parse STL + uniform surface sampling
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: `perception/projection.py`
|
||
|
||
**Files:**
|
||
- Create: `src/presa_mano/perception/projection.py`
|
||
- Test: `tests/test_projection.py`
|
||
|
||
- [ ] **Step 1: Scrivi i test che falliscono**
|
||
|
||
Crea `tests/test_projection.py`:
|
||
|
||
```python
|
||
"""Unit tests for camera-to-pixel projection."""
|
||
from __future__ import annotations
|
||
|
||
import numpy as np
|
||
|
||
from presa_mano.perception.projection import K_from_intrinsics, project_camera_to_pixels
|
||
|
||
|
||
def _K():
|
||
return np.array([[600.0, 0.0, 320.0],
|
||
[ 0.0, 600.0, 240.0],
|
||
[ 0.0, 0.0, 1.0]])
|
||
|
||
|
||
def test_K_from_intrinsics_matches_manual():
|
||
intr = {"fx": 600.0, "fy": 600.0, "cx": 320.0, "cy": 240.0,
|
||
"width": 640, "height": 480}
|
||
K = K_from_intrinsics(intr)
|
||
np.testing.assert_array_equal(K, _K())
|
||
|
||
|
||
def test_project_principal_point():
|
||
"""Punto sull'asse ottico a 1m → cx,cy."""
|
||
pts = np.array([[0.0, 0.0, 1000.0]]) # mm
|
||
out = project_camera_to_pixels(pts, _K(), (640, 480))
|
||
assert out.shape == (1, 2)
|
||
assert out[0, 0] == 320 and out[0, 1] == 240
|
||
|
||
|
||
def test_project_behind_camera_dropped():
|
||
"""Z<=0 deve essere scartato."""
|
||
pts = np.array([[0.0, 0.0, -1000.0], [10.0, 20.0, 0.0]])
|
||
out = project_camera_to_pixels(pts, _K(), (640, 480))
|
||
assert out.shape == (0, 2)
|
||
|
||
|
||
def test_project_outside_frame_dropped():
|
||
"""Punto che proietta fuori bbox immagine viene scartato."""
|
||
pts = np.array([
|
||
[0.0, 0.0, 1000.0], # → (320, 240): inside
|
||
[10000.0, 0.0, 1000.0], # → u molto fuori bbox: dropped
|
||
[0.0, -10000.0, 1000.0], # → v molto fuori bbox (sopra): dropped
|
||
])
|
||
out = project_camera_to_pixels(pts, _K(), (640, 480))
|
||
assert out.shape == (1, 2)
|
||
assert out[0, 0] == 320 and out[0, 1] == 240
|
||
|
||
|
||
def test_project_known_offset():
|
||
"""Punto in (0.1, 0.0, 1.0) m → u = cx + fx*(0.1/1.0) = 320 + 60 = 380."""
|
||
pts = np.array([[100.0, 0.0, 1000.0]]) # mm
|
||
out = project_camera_to_pixels(pts, _K(), (640, 480))
|
||
assert out[0, 0] == 380 and out[0, 1] == 240
|
||
|
||
|
||
def test_project_unit_invariance():
|
||
"""Stesso punto in m vs mm proietta sullo stesso pixel (K@P/P.z elide unità)."""
|
||
pts_mm = np.array([[100.0, 0.0, 1000.0]])
|
||
pts_m = pts_mm / 1000.0
|
||
out_mm = project_camera_to_pixels(pts_mm, _K(), (640, 480))
|
||
out_m = project_camera_to_pixels(pts_m, _K(), (640, 480))
|
||
np.testing.assert_array_equal(out_mm, out_m)
|
||
|
||
|
||
def test_project_empty_input():
|
||
out = project_camera_to_pixels(np.empty((0, 3)), _K(), (640, 480))
|
||
assert out.shape == (0, 2)
|
||
```
|
||
|
||
- [ ] **Step 2: Esegui i test per vedere il fallimento**
|
||
|
||
Run: `uv run pytest tests/test_projection.py -v`
|
||
Expected: `ImportError`.
|
||
|
||
- [ ] **Step 3: Implementa `projection.py`**
|
||
|
||
Crea `src/presa_mano/perception/projection.py`:
|
||
|
||
```python
|
||
"""Project 3D points (camera frame) to 2D pixels using OpenCV intrinsics."""
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
import numpy as np
|
||
|
||
|
||
def K_from_intrinsics(intr: dict[str, Any]) -> np.ndarray:
|
||
"""Build a 3x3 K from a {fx,fy,cx,cy,width,height} dict.
|
||
Width/height are not used here but must be present so callers can pass
|
||
the same dict to `project_camera_to_pixels`."""
|
||
return np.array([
|
||
[float(intr["fx"]), 0.0, float(intr["cx"])],
|
||
[0.0, float(intr["fy"]), float(intr["cy"])],
|
||
[0.0, 0.0, 1.0],
|
||
], dtype=np.float64)
|
||
|
||
|
||
def project_camera_to_pixels(
|
||
points_cam: np.ndarray,
|
||
K: np.ndarray,
|
||
image_size: tuple[int, int],
|
||
) -> np.ndarray:
|
||
"""Project (N,3) points in OpenCV camera frame to (M,2) int pixels.
|
||
|
||
Drops points with Z <= 0 (behind the camera) and points that project
|
||
outside `image_size` (W,H). The unit of `points_cam` is free: K@P/P.z
|
||
cancels it as long as K is in pixels.
|
||
"""
|
||
if points_cam.size == 0:
|
||
return np.empty((0, 2), dtype=np.int32)
|
||
W, H = image_size
|
||
pts = np.asarray(points_cam, dtype=np.float64)
|
||
z = pts[:, 2]
|
||
keep_z = z > 0.0
|
||
if not keep_z.any():
|
||
return np.empty((0, 2), dtype=np.int32)
|
||
pts = pts[keep_z]
|
||
proj = (K @ pts.T).T # (M, 3)
|
||
uv = proj[:, :2] / proj[:, 2:3]
|
||
u = np.round(uv[:, 0]).astype(np.int32)
|
||
v = np.round(uv[:, 1]).astype(np.int32)
|
||
in_frame = (u >= 0) & (u < W) & (v >= 0) & (v < H)
|
||
return np.stack([u[in_frame], v[in_frame]], axis=1)
|
||
```
|
||
|
||
- [ ] **Step 4: Esegui i test**
|
||
|
||
Run: `uv run pytest tests/test_projection.py -v`
|
||
Expected: 7 passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/test_projection.py src/presa_mano/perception/projection.py
|
||
git commit -m "feat(perception): project camera-frame 3D points to image pixels
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: `clients/open3dmodel.py` — HTTP client
|
||
|
||
**Files:**
|
||
- Create: `src/presa_mano/clients/open3dmodel.py`
|
||
- Modify: `src/presa_mano/clients/__init__.py`
|
||
- Test: `tests/test_open3dmodel_client.py`
|
||
|
||
- [ ] **Step 1: Scrivi i test che falliscono**
|
||
|
||
Crea `tests/test_open3dmodel_client.py`:
|
||
|
||
```python
|
||
"""Unit tests for Open3DModelClient."""
|
||
from __future__ import annotations
|
||
|
||
from unittest.mock import patch
|
||
|
||
import httpx
|
||
import numpy as np
|
||
import pytest
|
||
|
||
from presa_mano.clients.open3dmodel import (
|
||
Open3DModelClient,
|
||
Open3DModelError,
|
||
Open3DModelUnavailable,
|
||
)
|
||
|
||
|
||
def _intrinsics():
|
||
return {"fx": 600.0, "fy": 600.0, "cx": 320.0, "cy": 240.0,
|
||
"width": 640, "height": 480}
|
||
|
||
|
||
def _ok_pose_response():
|
||
body = {
|
||
"success": True,
|
||
"data": {
|
||
"symmetry_type": "cylindrical",
|
||
"translation_mm": {"x": 1.0, "y": 2.0, "z": 500.0},
|
||
"transform_4x4": [
|
||
[1.0, 0.0, 0.0, 1.0],
|
||
[0.0, 1.0, 0.0, 2.0],
|
||
[0.0, 0.0, 1.0, 500.0],
|
||
[0.0, 0.0, 0.0, 1.0],
|
||
],
|
||
"fitness": 0.87,
|
||
"inlier_rmse_mm": 1.8,
|
||
},
|
||
"error": None,
|
||
}
|
||
return httpx.Response(200, json=body)
|
||
|
||
|
||
def _envelope_err(status: int, code: str, message: str):
|
||
body = {"success": False, "data": None,
|
||
"error": {"code": code, "message": message}}
|
||
return httpx.Response(status, json=body)
|
||
|
||
|
||
def test_list_recipes_happy_path():
|
||
body = [{"id": "r1", "name": "pestello", "symmetry_type": "cylindrical"}]
|
||
response = httpx.Response(200, json=body)
|
||
client = Open3DModelClient("http://o3d:8878", api_key="K", timeout_s=2.0)
|
||
with patch("httpx.Client.get", return_value=response) as mock_get:
|
||
recs = client.list_recipes()
|
||
assert recs == body
|
||
posted_url = mock_get.call_args.args[0]
|
||
assert posted_url.endswith("/api/recipes")
|
||
# API key header
|
||
headers = mock_get.call_args.kwargs.get("headers", {})
|
||
assert headers.get("X-Api-Key") == "K"
|
||
|
||
|
||
def test_list_recipes_connect_error_raises_unavailable():
|
||
client = Open3DModelClient("http://o3d:8878", api_key="K", timeout_s=2.0)
|
||
with patch("httpx.Client.get", side_effect=httpx.ConnectError("nope")):
|
||
with pytest.raises(Open3DModelUnavailable):
|
||
client.list_recipes()
|
||
|
||
|
||
def test_estimate_pose_happy_path():
|
||
pts_m = np.array([[0.01, 0.02, 0.5], [0.015, 0.022, 0.49]])
|
||
response = _ok_pose_response()
|
||
client = Open3DModelClient("http://o3d:8878", api_key="K", timeout_s=2.0)
|
||
with patch("httpx.Client.post", return_value=response) as mock_post:
|
||
data = client.estimate_pose("r1", pts_m, _intrinsics())
|
||
assert data["fitness"] == 0.87
|
||
assert data["inlier_rmse_mm"] == 1.8
|
||
body = mock_post.call_args.kwargs["json"]
|
||
assert body["recipe_id"] == "r1"
|
||
assert body["unit"] == "m"
|
||
assert body["point_cloud"][0] == [0.01, 0.02, 0.5]
|
||
headers = mock_post.call_args.kwargs.get("headers", {})
|
||
assert headers.get("X-Api-Key") == "K"
|
||
|
||
|
||
def test_estimate_pose_envelope_failure_recipe_not_found():
|
||
pts_m = np.array([[0.0, 0.0, 0.5]])
|
||
response = _envelope_err(200, "RECIPE_NOT_FOUND", "Ricetta ... non esiste")
|
||
client = Open3DModelClient("http://o3d:8878", api_key="K", timeout_s=2.0)
|
||
with patch("httpx.Client.post", return_value=response):
|
||
with pytest.raises(Open3DModelError) as exc:
|
||
client.estimate_pose("r-bogus", pts_m, _intrinsics())
|
||
assert exc.value.code == "RECIPE_NOT_FOUND"
|
||
|
||
|
||
def test_estimate_pose_envelope_failure_registration_failed():
|
||
pts_m = np.array([[0.0, 0.0, 0.5]])
|
||
response = _envelope_err(200, "REGISTRATION_FAILED", "low fitness")
|
||
client = Open3DModelClient("http://o3d:8878", api_key="K", timeout_s=2.0)
|
||
with patch("httpx.Client.post", return_value=response):
|
||
with pytest.raises(Open3DModelError) as exc:
|
||
client.estimate_pose("r1", pts_m, _intrinsics())
|
||
assert exc.value.code == "REGISTRATION_FAILED"
|
||
|
||
|
||
def test_estimate_pose_http_403_raises_open3dmodel_error_auth():
|
||
"""403 con detail testuale (HTTPException di FastAPI, NON envelope)."""
|
||
pts_m = np.array([[0.0, 0.0, 0.5]])
|
||
response = httpx.Response(403, json={"detail": "API key non valida"})
|
||
client = Open3DModelClient("http://o3d:8878", api_key="WRONG", timeout_s=2.0)
|
||
with patch("httpx.Client.post", return_value=response):
|
||
with pytest.raises(Open3DModelError) as exc:
|
||
client.estimate_pose("r1", pts_m, _intrinsics())
|
||
assert exc.value.code == "HTTP_403"
|
||
|
||
|
||
def test_estimate_pose_connect_error_raises_unavailable():
|
||
pts_m = np.array([[0.0, 0.0, 0.5]])
|
||
client = Open3DModelClient("http://o3d:8878", api_key="K", timeout_s=2.0)
|
||
with patch("httpx.Client.post", side_effect=httpx.ConnectError("down")):
|
||
with pytest.raises(Open3DModelUnavailable):
|
||
client.estimate_pose("r1", pts_m, _intrinsics())
|
||
|
||
|
||
def test_get_stl_bytes_happy_path():
|
||
response = httpx.Response(200, content=b"BINARYSTL", headers={"ETag": "abc123"})
|
||
client = Open3DModelClient("http://o3d:8878", api_key="K", timeout_s=2.0)
|
||
with patch("httpx.Client.get", return_value=response):
|
||
body, etag = client.get_stl_bytes("r1")
|
||
assert body == b"BINARYSTL"
|
||
assert etag == "abc123"
|
||
|
||
|
||
def test_get_stl_bytes_oversize_raises():
|
||
big = b"x" * (25 * 1024 * 1024) # 25 MB
|
||
response = httpx.Response(200, content=big)
|
||
client = Open3DModelClient("http://o3d:8878", api_key="K",
|
||
timeout_s=2.0, max_stl_bytes=20_000_000)
|
||
with patch("httpx.Client.get", return_value=response):
|
||
with pytest.raises(Open3DModelError) as exc:
|
||
client.get_stl_bytes("r1")
|
||
assert exc.value.code == "STL_TOO_LARGE"
|
||
|
||
|
||
def test_get_stl_bytes_missing_etag_falls_back_to_hash():
|
||
response = httpx.Response(200, content=b"BINARYSTL") # no ETag header
|
||
client = Open3DModelClient("http://o3d:8878", api_key="K", timeout_s=2.0)
|
||
with patch("httpx.Client.get", return_value=response):
|
||
body, etag = client.get_stl_bytes("r1")
|
||
assert body == b"BINARYSTL"
|
||
assert len(etag) > 0 # qualunque hash deterministico
|
||
```
|
||
|
||
- [ ] **Step 2: Esegui i test per vedere il fallimento**
|
||
|
||
Run: `uv run pytest tests/test_open3dmodel_client.py -v`
|
||
Expected: ImportError.
|
||
|
||
- [ ] **Step 3: Implementa `open3dmodel.py`**
|
||
|
||
Crea `src/presa_mano/clients/open3dmodel.py`:
|
||
|
||
```python
|
||
"""Client for the Open3DModel pose-estimation service.
|
||
|
||
Open3DModel API (see /opt/docker/robotserver/Open3DModel/README.md):
|
||
- GET /api/recipes → list
|
||
- GET /api/recipes/{id}/stl → STL bytes (HTTPException 404 if missing)
|
||
- POST /api/pose → envelope {success, data, error}
|
||
data has transform_4x4, fitness,
|
||
inlier_rmse_mm, translation_mm, ...
|
||
- Header X-Api-Key richiesto (403 con detail testuale altrimenti).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
from typing import Any
|
||
|
||
import httpx
|
||
import numpy as np
|
||
|
||
|
||
class Open3DModelUnavailable(RuntimeError):
|
||
"""Transport-level failure (connect refused, timeout)."""
|
||
|
||
|
||
class Open3DModelError(RuntimeError):
|
||
"""Semantic failure: envelope error code, HTTP 4xx/5xx with non-envelope
|
||
body, or client-side validation (e.g. STL oversize)."""
|
||
|
||
def __init__(self, code: str, message: str):
|
||
super().__init__(f"{code}: {message}")
|
||
self.code = code
|
||
self.message = message
|
||
|
||
|
||
class Open3DModelClient:
|
||
def __init__(self, base_url: str, api_key: str | None,
|
||
timeout_s: float = 30.0,
|
||
max_stl_bytes: int = 20_000_000):
|
||
self.base_url = base_url.rstrip("/")
|
||
self.api_key = api_key
|
||
self.timeout_s = timeout_s
|
||
self.max_stl_bytes = max_stl_bytes
|
||
|
||
def _headers(self) -> dict[str, str]:
|
||
h = {}
|
||
if self.api_key:
|
||
h["X-Api-Key"] = self.api_key
|
||
return h
|
||
|
||
def list_recipes(self) -> list[dict[str, Any]]:
|
||
url = f"{self.base_url}/api/recipes"
|
||
try:
|
||
with httpx.Client(timeout=self.timeout_s) as c:
|
||
r = c.get(url, headers=self._headers())
|
||
except httpx.RequestError as e:
|
||
raise Open3DModelUnavailable(f"open3dmodel unreachable: {e}") from e
|
||
if r.status_code != 200:
|
||
raise Open3DModelError(f"HTTP_{r.status_code}", self._detail_msg(r))
|
||
try:
|
||
body = r.json()
|
||
except ValueError as e:
|
||
raise Open3DModelUnavailable(f"non-JSON response: {e}") from e
|
||
if not isinstance(body, list):
|
||
raise Open3DModelError("UNEXPECTED_PAYLOAD",
|
||
f"expected list, got {type(body).__name__}")
|
||
return body
|
||
|
||
def estimate_pose(self, recipe_id: str, points_cv_m: np.ndarray,
|
||
intrinsics: dict[str, Any]) -> dict[str, Any]:
|
||
body = {
|
||
"recipe_id": str(recipe_id),
|
||
"point_cloud": np.asarray(points_cv_m, dtype=float).tolist(),
|
||
"intrinsics": {
|
||
"fx": float(intrinsics["fx"]),
|
||
"fy": float(intrinsics["fy"]),
|
||
"cx": float(intrinsics["cx"]),
|
||
"cy": float(intrinsics["cy"]),
|
||
"width": int(intrinsics["width"]),
|
||
"height": int(intrinsics["height"]),
|
||
},
|
||
"unit": "m",
|
||
}
|
||
url = f"{self.base_url}/api/pose"
|
||
try:
|
||
with httpx.Client(timeout=self.timeout_s) as c:
|
||
r = c.post(url, json=body, headers=self._headers())
|
||
except httpx.RequestError as e:
|
||
raise Open3DModelUnavailable(f"open3dmodel unreachable: {e}") from e
|
||
try:
|
||
payload = r.json()
|
||
except ValueError as e:
|
||
raise Open3DModelUnavailable(
|
||
f"non-JSON response (status {r.status_code}): {e}") from e
|
||
if isinstance(payload, dict) and "success" in payload:
|
||
if payload.get("success") is True:
|
||
return payload.get("data") or {}
|
||
err = payload.get("error") or {}
|
||
raise Open3DModelError(
|
||
str(err.get("code") or "UNKNOWN"),
|
||
str(err.get("message") or "no message"),
|
||
)
|
||
# Non-envelope response (e.g. FastAPI HTTPException 403/422)
|
||
raise Open3DModelError(f"HTTP_{r.status_code}", self._detail_msg(r))
|
||
|
||
def get_stl_bytes(self, recipe_id: str) -> tuple[bytes, str]:
|
||
url = f"{self.base_url}/api/recipes/{recipe_id}/stl"
|
||
try:
|
||
with httpx.Client(timeout=self.timeout_s) as c:
|
||
r = c.get(url, headers=self._headers())
|
||
except httpx.RequestError as e:
|
||
raise Open3DModelUnavailable(f"open3dmodel unreachable: {e}") from e
|
||
if r.status_code == 404:
|
||
raise Open3DModelError("RECIPE_NOT_FOUND",
|
||
f"STL for {recipe_id} not found")
|
||
if r.status_code != 200:
|
||
raise Open3DModelError(f"HTTP_{r.status_code}", self._detail_msg(r))
|
||
body = r.content
|
||
if len(body) > self.max_stl_bytes:
|
||
raise Open3DModelError("STL_TOO_LARGE",
|
||
f"{len(body)} bytes > {self.max_stl_bytes}")
|
||
etag = r.headers.get("ETag") or r.headers.get("Last-Modified") \
|
||
or hashlib.sha1(body).hexdigest()
|
||
return body, etag
|
||
|
||
@staticmethod
|
||
def _detail_msg(r: httpx.Response) -> str:
|
||
try:
|
||
body = r.json()
|
||
if isinstance(body, dict) and "detail" in body:
|
||
return str(body["detail"])
|
||
except ValueError:
|
||
pass
|
||
return r.text[:200] or f"http {r.status_code}"
|
||
```
|
||
|
||
- [ ] **Step 4: Esporta da `clients/__init__.py`**
|
||
|
||
Modifica `src/presa_mano/clients/__init__.py`:
|
||
|
||
```python
|
||
"""HTTP clients for upstream microservices."""
|
||
|
||
from .g1 import G1Client, G1StatusError, G1Unavailable
|
||
from .marker import MarkerClient
|
||
from .nuwa import NuwaClient, NuwaError
|
||
from .open3dmodel import Open3DModelClient, Open3DModelError, Open3DModelUnavailable
|
||
from .sam21 import Sam21Client, Sam21Error, Sam21Unavailable
|
||
from .yolo import YoloClient
|
||
|
||
__all__ = [
|
||
"G1Client", "G1StatusError", "G1Unavailable",
|
||
"MarkerClient", "NuwaClient", "NuwaError",
|
||
"Open3DModelClient", "Open3DModelError", "Open3DModelUnavailable",
|
||
"Sam21Client", "Sam21Error", "Sam21Unavailable",
|
||
"YoloClient",
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 5: Esegui i test**
|
||
|
||
Run: `uv run pytest tests/test_open3dmodel_client.py -v`
|
||
Expected: 10 passed.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add tests/test_open3dmodel_client.py src/presa_mano/clients/open3dmodel.py src/presa_mano/clients/__init__.py
|
||
git commit -m "feat(clients): Open3DModel client (list_recipes, estimate_pose, get_stl_bytes)
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: `clients/open3dmodel_cache.py` — LRU mesh cache
|
||
|
||
**Files:**
|
||
- Create: `src/presa_mano/clients/open3dmodel_cache.py`
|
||
- Test: `tests/test_recipe_mesh_cache.py`
|
||
|
||
- [ ] **Step 1: Scrivi i test che falliscono**
|
||
|
||
Crea `tests/test_recipe_mesh_cache.py`:
|
||
|
||
```python
|
||
"""Unit tests for RecipeMeshCache."""
|
||
from __future__ import annotations
|
||
|
||
from unittest.mock import MagicMock
|
||
|
||
import numpy as np
|
||
import pytest
|
||
|
||
from presa_mano.clients.open3dmodel_cache import RecipeMesh, RecipeMeshCache
|
||
|
||
|
||
def _verts_tris():
|
||
verts = np.array([[0.,0.,0.],[1.,0.,0.],[0.,1.,0.]])
|
||
tris = np.array([[0,1,2]], dtype=np.int32)
|
||
return verts, tris
|
||
|
||
|
||
def _mock_client(stl_bytes_by_id: dict[str, tuple[bytes, str]]):
|
||
"""Mock client whose get_stl_bytes returns the registered (bytes, etag)
|
||
for each recipe_id, and counts calls."""
|
||
m = MagicMock()
|
||
m._call_count = {}
|
||
def _get(recipe_id: str):
|
||
m._call_count[recipe_id] = m._call_count.get(recipe_id, 0) + 1
|
||
return stl_bytes_by_id[recipe_id]
|
||
m.get_stl_bytes.side_effect = _get
|
||
return m
|
||
|
||
|
||
def test_cache_miss_then_hit(monkeypatch):
|
||
verts, tris = _verts_tris()
|
||
monkeypatch.setattr(
|
||
"presa_mano.clients.open3dmodel_cache.parse_stl_bytes",
|
||
lambda b: (verts, tris),
|
||
)
|
||
client = _mock_client({"r1": (b"STL1", "etag-A")})
|
||
cache = RecipeMeshCache(client=client, capacity=4)
|
||
|
||
m1 = cache.get("r1")
|
||
assert isinstance(m1, RecipeMesh)
|
||
np.testing.assert_array_equal(m1.vertices, verts)
|
||
np.testing.assert_array_equal(m1.triangles, tris)
|
||
assert m1.etag == "etag-A"
|
||
assert client._call_count["r1"] == 1
|
||
|
||
# secondo accesso → hit
|
||
m2 = cache.get("r1")
|
||
assert m2 is m1
|
||
assert client._call_count["r1"] == 1
|
||
|
||
|
||
def test_cache_etag_change_refetches(monkeypatch):
|
||
verts, tris = _verts_tris()
|
||
monkeypatch.setattr(
|
||
"presa_mano.clients.open3dmodel_cache.parse_stl_bytes",
|
||
lambda b: (verts, tris),
|
||
)
|
||
client = MagicMock()
|
||
seq = iter([(b"STL_OLD", "etag-A"), (b"STL_NEW", "etag-B")])
|
||
client.get_stl_bytes.side_effect = lambda rid: next(seq)
|
||
cache = RecipeMeshCache(client=client, capacity=4)
|
||
cache.get("r1")
|
||
m2 = cache.get("r1")
|
||
assert m2.etag == "etag-B"
|
||
assert client.get_stl_bytes.call_count == 2
|
||
|
||
|
||
def test_cache_lru_evicts_oldest(monkeypatch):
|
||
verts, tris = _verts_tris()
|
||
monkeypatch.setattr(
|
||
"presa_mano.clients.open3dmodel_cache.parse_stl_bytes",
|
||
lambda b: (verts, tris),
|
||
)
|
||
client = _mock_client({
|
||
f"r{i}": (f"S{i}".encode(), f"e{i}") for i in range(10)
|
||
})
|
||
cache = RecipeMeshCache(client=client, capacity=3)
|
||
cache.get("r1"); cache.get("r2"); cache.get("r3")
|
||
# r1 è il più vecchio
|
||
cache.get("r4") # evict r1
|
||
cache.get("r1") # miss → re-fetch (call_count va a 2)
|
||
assert client._call_count["r1"] == 2
|
||
|
||
|
||
def test_cache_get_uses_overrideable_client(monkeypatch):
|
||
"""`get(recipe_id, client=other)` permette di passare un client fresco
|
||
(utile quando PUT /api/config cambia base_url)."""
|
||
verts, tris = _verts_tris()
|
||
monkeypatch.setattr(
|
||
"presa_mano.clients.open3dmodel_cache.parse_stl_bytes",
|
||
lambda b: (verts, tris),
|
||
)
|
||
default_client = _mock_client({"r1": (b"S1", "e1")})
|
||
override_client = _mock_client({"r1": (b"S2", "e2")})
|
||
cache = RecipeMeshCache(client=default_client, capacity=2)
|
||
|
||
m = cache.get("r1", client=override_client)
|
||
assert m.etag == "e2"
|
||
assert default_client._call_count.get("r1", 0) == 0
|
||
assert override_client._call_count["r1"] == 1
|
||
```
|
||
|
||
- [ ] **Step 2: Esegui i test per vedere il fallimento**
|
||
|
||
Run: `uv run pytest tests/test_recipe_mesh_cache.py -v`
|
||
Expected: ImportError.
|
||
|
||
- [ ] **Step 3: Implementa la cache**
|
||
|
||
Crea `src/presa_mano/clients/open3dmodel_cache.py`:
|
||
|
||
```python
|
||
"""In-memory LRU cache of parsed STL meshes, keyed by recipe_id + etag."""
|
||
from __future__ import annotations
|
||
|
||
from collections import OrderedDict
|
||
from dataclasses import dataclass
|
||
|
||
import numpy as np
|
||
|
||
from ..perception.stl_sampler import parse_stl_bytes
|
||
from .open3dmodel import Open3DModelClient
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RecipeMesh:
|
||
vertices: np.ndarray # (M, 3) float64, mm in model frame
|
||
triangles: np.ndarray # (T, 3) int32
|
||
etag: str
|
||
|
||
|
||
class RecipeMeshCache:
|
||
"""LRU keyed by recipe_id. On hit, validates the etag against the client
|
||
and re-fetches if it changed. Singleton-friendly: thread-safe with a
|
||
simple lock is NOT implemented (presa-mano is single-process; if
|
||
concurrency becomes a need, wrap the OrderedDict ops in a lock)."""
|
||
|
||
def __init__(self, client: Open3DModelClient, capacity: int = 8):
|
||
self._client = client
|
||
self._capacity = max(1, int(capacity))
|
||
self._store: OrderedDict[str, RecipeMesh] = OrderedDict()
|
||
|
||
def get(self, recipe_id: str, client: Open3DModelClient | None = None
|
||
) -> RecipeMesh:
|
||
c = client or self._client
|
||
body, etag = c.get_stl_bytes(recipe_id)
|
||
cached = self._store.get(recipe_id)
|
||
if cached is not None and cached.etag == etag:
|
||
self._store.move_to_end(recipe_id)
|
||
return cached
|
||
verts, tris = parse_stl_bytes(body)
|
||
mesh = RecipeMesh(vertices=verts, triangles=tris, etag=etag)
|
||
self._store[recipe_id] = mesh
|
||
self._store.move_to_end(recipe_id)
|
||
while len(self._store) > self._capacity:
|
||
self._store.popitem(last=False)
|
||
return mesh
|
||
```
|
||
|
||
- [ ] **Step 4: Esegui i test**
|
||
|
||
Run: `uv run pytest tests/test_recipe_mesh_cache.py -v`
|
||
Expected: 4 passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/test_recipe_mesh_cache.py src/presa_mano/clients/open3dmodel_cache.py
|
||
git commit -m "feat(clients): LRU mesh cache for Open3DModel recipes (etag-validated)
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Refactor `perception/sam21_bridge.py` — estrai `segment_with_sam21`
|
||
|
||
**Files:**
|
||
- Modify: `src/presa_mano/perception/sam21_bridge.py:34-136`
|
||
- Test: `tests/test_sam21_bridge_segment.py`
|
||
|
||
- [ ] **Step 1: Scrivi il test di regressione per il refactor**
|
||
|
||
Crea `tests/test_sam21_bridge_segment.py`:
|
||
|
||
```python
|
||
"""Tests for the segment_with_sam21/points_via_sam21 decomposition.
|
||
|
||
We exercise the path that the find_model endpoint will take:
|
||
segment_with_sam21(nuwa, sam21, bbox, sw) → Sam21Segmentation
|
||
and the legacy wrapper still works.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from unittest.mock import MagicMock
|
||
|
||
import cv2
|
||
import numpy as np
|
||
import pytest
|
||
|
||
from presa_mano.perception.sam21_bridge import (
|
||
Sam21Segmentation,
|
||
segment_with_sam21,
|
||
points_via_sam21,
|
||
Sam21Fallback,
|
||
)
|
||
from presa_mano.timing import Stopwatch
|
||
|
||
|
||
def _fake_nuwa():
|
||
"""Returns a MagicMock Nuwa that yields a 4x4 black RGB jpeg and a 4x4
|
||
uint16 depth PNG (all 1000 mm) + a minimal Nuwa intrinsics dict."""
|
||
rgb = np.zeros((4, 4, 3), dtype=np.uint8)
|
||
ok, jpg = cv2.imencode(".jpg", rgb)
|
||
assert ok
|
||
depth = np.full((4, 4), 1000, dtype=np.uint16)
|
||
ok, dpng = cv2.imencode(".png", depth)
|
||
assert ok
|
||
intr = {
|
||
"rgb": {"fx": 600.0, "fy": 600.0, "cx": 2.0, "cy": 2.0},
|
||
"ir": {"fx": 600.0, "fy": 600.0, "cx": 2.0, "cy": 2.0,
|
||
"extrinsics": {"R": [[1,0,0],[0,1,0],[0,0,1]],
|
||
"t": [0.0, 0.0, 0.0]}},
|
||
}
|
||
n = MagicMock()
|
||
n.rgb_jpeg.return_value = bytes(jpg)
|
||
n.depth_png.return_value = bytes(dpng)
|
||
n.intrinsics.return_value = intr
|
||
return n, bytes(jpg)
|
||
|
||
|
||
def _fake_sam21(points_m: np.ndarray):
|
||
s = MagicMock()
|
||
s.segment_pointcloud.return_value = points_m
|
||
return s
|
||
|
||
|
||
def test_segment_with_sam21_returns_segmentation(monkeypatch):
|
||
nuwa, rgb_jpeg = _fake_nuwa()
|
||
pts_m = np.array([[0.01, 0.02, 0.5], [0.011, 0.022, 0.49]])
|
||
sam21 = _fake_sam21(pts_m)
|
||
# Niente plane filter qui — segment_with_sam21 NON deve applicarlo
|
||
monkeypatch.setattr(
|
||
"presa_mano.perception.sam21_bridge.get_work_plane", lambda: None
|
||
)
|
||
sw = Stopwatch()
|
||
seg = segment_with_sam21(nuwa, sam21, bbox=(0, 0, 4, 4), sw=sw)
|
||
assert isinstance(seg, Sam21Segmentation)
|
||
np.testing.assert_array_equal(seg.points_cv_m, pts_m)
|
||
assert seg.rgb_jpeg == rgb_jpeg
|
||
assert "width" in seg.intrinsics_rgb and "height" in seg.intrinsics_rgb
|
||
|
||
|
||
def test_points_via_sam21_still_applies_plane_filter(monkeypatch):
|
||
"""L'API legacy non deve cambiare: ritorna (pts, plane_info) e applica
|
||
apply_below_plane_filter come prima."""
|
||
nuwa, _rgb = _fake_nuwa()
|
||
pts_m = np.array([[0.0, 0.0, 0.5]] * 20)
|
||
sam21 = _fake_sam21(pts_m)
|
||
monkeypatch.setattr(
|
||
"presa_mano.perception.sam21_bridge.get_work_plane", lambda: None
|
||
)
|
||
sw = Stopwatch()
|
||
pts, info = points_via_sam21(nuwa, sam21, bbox=(0, 0, 4, 4), sw=sw)
|
||
assert pts.shape == (20, 3)
|
||
assert info["plane_applied"] is False
|
||
assert info["n_points_before_plane"] == 20
|
||
|
||
|
||
def test_points_via_sam21_too_few_points_raises_fallback(monkeypatch):
|
||
nuwa, _rgb = _fake_nuwa()
|
||
pts_m = np.array([[0.0, 0.0, 0.5]] * 5) # <10
|
||
sam21 = _fake_sam21(pts_m)
|
||
monkeypatch.setattr(
|
||
"presa_mano.perception.sam21_bridge.get_work_plane", lambda: None
|
||
)
|
||
sw = Stopwatch()
|
||
with pytest.raises(Sam21Fallback):
|
||
points_via_sam21(nuwa, sam21, bbox=(0, 0, 4, 4), sw=sw)
|
||
```
|
||
|
||
- [ ] **Step 2: Esegui i test per vedere il fallimento**
|
||
|
||
Run: `uv run pytest tests/test_sam21_bridge_segment.py -v`
|
||
Expected: ImportError per `segment_with_sam21` / `Sam21Segmentation`.
|
||
|
||
- [ ] **Step 3: Refactor `sam21_bridge.py`**
|
||
|
||
Sostituisci la funzione `points_via_sam21` in `src/presa_mano/perception/sam21_bridge.py` (linee 34-136) con questa decomposizione:
|
||
|
||
```python
|
||
@dataclass
|
||
class Sam21Segmentation:
|
||
"""Output di segment_with_sam21 — SAM21 puro senza plane filter."""
|
||
points_cv_m: np.ndarray # (N,3) OpenCV camera frame, metri
|
||
rgb_jpeg: bytes # snapshot Nuwa usato per la segmentazione
|
||
intrinsics_rgb: dict # con width/height iniettati dal decoded RGB
|
||
|
||
|
||
def segment_with_sam21(nuwa, sam21, bbox: tuple[int, int, int, int],
|
||
sw: Stopwatch) -> Sam21Segmentation:
|
||
"""SAM21 puro: nuwa_grab + depth_align + segment, SENZA plane filter.
|
||
|
||
Raises Sam21Fallback per failure semantiche (mask vuota, no depth, SAM21
|
||
unreachable, decode fail). Re-raises HTTPException(502) per failure Nuwa.
|
||
"""
|
||
try:
|
||
with sw.step("nuwa_grab"):
|
||
rgb_jpeg = nuwa.rgb_jpeg()
|
||
depth_png_in = nuwa.depth_png()
|
||
intr_full = nuwa.intrinsics()
|
||
except (NuwaError, httpx.RequestError) as e:
|
||
raise HTTPException(status_code=502, detail=f"Nuwa unreachable: {e}")
|
||
|
||
if not intr_full.get("rgb"):
|
||
log.warning("SAM21 path skipped: nuwa intrinsics missing 'rgb' block")
|
||
raise Sam21Fallback("nuwa intrinsics missing 'rgb' block")
|
||
|
||
try:
|
||
bgr = cv2.imdecode(np.frombuffer(rgb_jpeg, np.uint8), cv2.IMREAD_COLOR)
|
||
if bgr is None:
|
||
log.warning("SAM21 path skipped: could not decode Nuwa RGB JPEG")
|
||
raise Sam21Fallback("could not decode Nuwa RGB JPEG")
|
||
h_rgb, w_rgb = bgr.shape[:2]
|
||
ok, rgb_png_buf = cv2.imencode(".png", bgr)
|
||
if not ok:
|
||
log.warning("SAM21 path skipped: could not re-encode RGB as PNG")
|
||
raise Sam21Fallback("could not re-encode RGB as PNG")
|
||
rgb_png = bytes(rgb_png_buf)
|
||
|
||
with sw.step("depth_align"):
|
||
depth_ir = cv2.imdecode(np.frombuffer(depth_png_in, np.uint8),
|
||
cv2.IMREAD_UNCHANGED)
|
||
if depth_ir is None:
|
||
log.warning("SAM21 path skipped: could not decode Nuwa depth PNG")
|
||
raise Sam21Fallback("could not decode Nuwa depth PNG")
|
||
if depth_ir.dtype != np.uint16:
|
||
log.warning("SAM21 path skipped: depth PNG dtype %s (expected uint16)",
|
||
depth_ir.dtype)
|
||
raise Sam21Fallback(f"depth PNG dtype expected uint16, got {depth_ir.dtype}")
|
||
h_ir, w_ir = depth_ir.shape
|
||
intr_full = {
|
||
**intr_full,
|
||
"rgb": {**intr_full["rgb"], "width": w_rgb, "height": h_rgb},
|
||
"ir": {**intr_full.get("ir", {}), "width": w_ir, "height": h_ir},
|
||
}
|
||
reg = CameraRegistration.from_nuwa(intr_full)
|
||
depth_aligned = align_depth_to_rgb(depth_ir, reg)
|
||
ok, depth_png_buf = cv2.imencode(".png", depth_aligned)
|
||
if not ok:
|
||
log.warning("SAM21 path skipped: could not encode aligned depth as PNG")
|
||
raise Sam21Fallback("could not encode aligned depth as PNG")
|
||
depth_png_out = bytes(depth_png_buf)
|
||
except Sam21Fallback:
|
||
raise
|
||
except Exception as e:
|
||
log.error("Unexpected SAM21 prep error: %r", e)
|
||
raise Sam21Fallback(f"prep failed: {e!r}") from e
|
||
|
||
with sw.step("sam21"):
|
||
try:
|
||
pts_cv_m = sam21.segment_pointcloud(
|
||
rgb_png=rgb_png,
|
||
depth_png=depth_png_out,
|
||
intrinsics=intr_full["rgb"],
|
||
bbox=bbox,
|
||
depth_scale_mm=1.0,
|
||
)
|
||
except Sam21Unavailable as e:
|
||
log.warning("SAM21 unreachable, falling back to bbox: %s", e)
|
||
raise Sam21Fallback(f"unreachable: {e}") from e
|
||
except Sam21Error as e:
|
||
if e.code in SAM21_BUG_CODES:
|
||
log.error("SAM21 rejected payload (%s): %s", e.code, e.message)
|
||
else:
|
||
log.warning("SAM21 returned %s: %s — falling back to bbox", e.code, e.message)
|
||
raise Sam21Fallback(f"{e.code}: {e.message}") from e
|
||
except Exception as e:
|
||
log.error("Unexpected SAM21 error: %r", e)
|
||
raise Sam21Fallback(f"unexpected: {e!r}") from e
|
||
|
||
return Sam21Segmentation(
|
||
points_cv_m=pts_cv_m,
|
||
rgb_jpeg=rgb_jpeg,
|
||
intrinsics_rgb=intr_full["rgb"],
|
||
)
|
||
|
||
|
||
def points_via_sam21(nuwa, sam21, bbox: tuple[int, int, int, int],
|
||
sw: Stopwatch) -> tuple[np.ndarray, dict]:
|
||
"""Legacy wrapper: segment_with_sam21 + apply_below_plane_filter.
|
||
Returns (points_cv_m, plane_info). Raises Sam21Fallback if <10 points
|
||
after plane filter (unchanged semantics)."""
|
||
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:
|
||
log.warning("SAM21 cloud has %d points after plane filter (<10), falling back to bbox",
|
||
pts_cv.shape[0])
|
||
raise Sam21Fallback(
|
||
f"too few points after plane filter: {pts_cv.shape[0]}"
|
||
f" (before plane: {plane_info['n_points_before_plane']})"
|
||
)
|
||
return pts_cv, plane_info
|
||
```
|
||
|
||
Aggiungi l'import per `dataclass` in cima al file se manca (`from dataclasses import dataclass`).
|
||
|
||
- [ ] **Step 4: Esegui i nuovi test**
|
||
|
||
Run: `uv run pytest tests/test_sam21_bridge_segment.py -v`
|
||
Expected: 3 passed.
|
||
|
||
- [ ] **Step 5: Verifica che i test esistenti passano ancora**
|
||
|
||
Run: `uv run pytest tests/ -v -k "not models and not open3d and not stl"`
|
||
Expected: tutti passano.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add tests/test_sam21_bridge_segment.py src/presa_mano/perception/sam21_bridge.py
|
||
git commit -m "refactor(sam21_bridge): extract segment_with_sam21 (no plane filter)
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: `api/_deps.py` — factory open3dmodel + singleton cache
|
||
|
||
**Files:**
|
||
- Modify: `src/presa_mano/api/_deps.py:1-40`
|
||
|
||
- [ ] **Step 1: Aggiungi le factory in `_deps.py`**
|
||
|
||
Modifica `src/presa_mano/api/_deps.py`. Sostituisci la sezione import in cima (linee 1-11) con:
|
||
|
||
```python
|
||
"""Build clients & helpers from current config — called per-request so runtime
|
||
updates (PUT /api/config) take effect without restart."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from ..clients import (
|
||
G1Client, MarkerClient, NuwaClient,
|
||
Open3DModelClient, Sam21Client, YoloClient,
|
||
)
|
||
from ..clients.open3dmodel_cache import RecipeMeshCache
|
||
from ..config import data_dir, get_config
|
||
|
||
_recipe_cache_singleton: RecipeMeshCache | None = None
|
||
```
|
||
|
||
Poi, sotto la funzione `sam21()` (intorno a riga 37), aggiungi:
|
||
|
||
```python
|
||
def open3dmodel() -> Open3DModelClient:
|
||
cfg = get_config()
|
||
c = _svc(cfg, "open3dmodel")
|
||
api_key_env = c.get("api_key_env")
|
||
api_key = os.environ.get(api_key_env) if api_key_env else None
|
||
return Open3DModelClient(
|
||
base_url=c.get("base_url", "http://localhost:8878"),
|
||
api_key=api_key,
|
||
timeout_s=float(c.get("timeout_s", 30.0)),
|
||
max_stl_bytes=int(c.get("max_stl_bytes", 20_000_000)),
|
||
)
|
||
|
||
|
||
def recipe_cache() -> RecipeMeshCache:
|
||
"""Singleton mesh cache. Sopravvive a PUT /api/config; etag stale forza
|
||
refetch al prossimo accesso. Inizializzata lazy con il client corrente
|
||
al momento della prima get."""
|
||
global _recipe_cache_singleton
|
||
if _recipe_cache_singleton is None:
|
||
_recipe_cache_singleton = RecipeMeshCache(open3dmodel(), capacity=8)
|
||
return _recipe_cache_singleton
|
||
```
|
||
|
||
- [ ] **Step 2: Verifica che presa-mano si avvia ancora**
|
||
|
||
Run: `uv run python -c "from presa_mano.api import _deps; print(_deps.open3dmodel().base_url)"`
|
||
Expected: `http://localhost:8878` (o quello che è in config.yaml).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/presa_mano/api/_deps.py
|
||
git commit -m "feat(api): _deps factories for Open3DModel client + recipe cache
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: `api/models_routes.py` — proxy GET /api/models/recipes
|
||
|
||
**Files:**
|
||
- Create: `src/presa_mano/api/models_routes.py`
|
||
- Modify: `src/presa_mano/main.py:12-24`
|
||
|
||
- [ ] **Step 1: Implementa il router**
|
||
|
||
Crea `src/presa_mano/api/models_routes.py`:
|
||
|
||
```python
|
||
"""Proxy minimale verso Open3DModel — evita di esporre l'api_key al browser.
|
||
|
||
Per ora un solo endpoint: GET /api/models/recipes → lista ricette caricate
|
||
in Open3DModel.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
|
||
from ..clients import Open3DModelError, Open3DModelUnavailable
|
||
from . import _deps
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
@router.get("/recipes")
|
||
def list_recipes() -> dict[str, Any]:
|
||
try:
|
||
recs = _deps.open3dmodel().list_recipes()
|
||
except Open3DModelUnavailable as e:
|
||
raise HTTPException(status_code=502, detail=f"Open3DModel unreachable: {e}")
|
||
except Open3DModelError as e:
|
||
raise HTTPException(status_code=502, detail=f"Open3DModel error: {e.code}: {e.message}")
|
||
return {"recipes": recs}
|
||
```
|
||
|
||
- [ ] **Step 2: Monta il router in main.py**
|
||
|
||
Modifica `src/presa_mano/main.py`. Nell'import block (linee 12-23), aggiungi `models_routes`:
|
||
|
||
```python
|
||
from .api import (
|
||
agent_routes,
|
||
calibration_routes,
|
||
config_routes,
|
||
grasp_routes,
|
||
home_routes,
|
||
models_routes,
|
||
perception_routes,
|
||
posture_routes,
|
||
robot_routes,
|
||
wrist_routes,
|
||
work_plane_routes,
|
||
)
|
||
```
|
||
|
||
Poi, dopo la linea `app.include_router(grasp_routes.router, prefix="/api/grasp", tags=["grasp"])` (linea 42), aggiungi:
|
||
|
||
```python
|
||
app.include_router(models_routes.router, prefix="/api/models", tags=["models"])
|
||
```
|
||
|
||
- [ ] **Step 3: Verifica che il router è registrato**
|
||
|
||
Run: `uv run python -c "from presa_mano.main import app; print([r.path for r in app.routes if 'models' in r.path])"`
|
||
Expected: stampa `['/api/models/recipes']`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/presa_mano/api/models_routes.py src/presa_mano/main.py
|
||
git commit -m "feat(api): GET /api/models/recipes proxy to Open3DModel
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: `api/grasp_routes.py` — POST /api/grasp/find_model
|
||
|
||
**Files:**
|
||
- Modify: `src/presa_mano/api/grasp_routes.py` (aggiungi handler dopo `execute`)
|
||
|
||
- [ ] **Step 1: Aggiungi i nuovi import in cima al file**
|
||
|
||
Modifica `src/presa_mano/api/grasp_routes.py`. Sostituisci le linee 1-22 (sezione import) con:
|
||
|
||
```python
|
||
"""Grasp planning + execution endpoints."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
from typing import Any, Optional
|
||
|
||
import numpy as np
|
||
from fastapi import APIRouter, HTTPException
|
||
from pydantic import BaseModel, Field
|
||
|
||
from ..calibration import CalibrationStore
|
||
from ..clients import G1Unavailable, Open3DModelError, Open3DModelUnavailable
|
||
from ..config import get_config
|
||
from ..geometry import transform_points
|
||
from ..grasp import GraspPlanner, compute_axes
|
||
from ..perception import ObjectDetector, filter_outliers, nuwa_to_cv_frame
|
||
from ..perception.projection import K_from_intrinsics, project_camera_to_pixels
|
||
from ..perception.sam21_bridge import (
|
||
Sam21Fallback,
|
||
points_via_sam21,
|
||
segment_with_sam21,
|
||
)
|
||
from ..perception.stl_sampler import sample_surface
|
||
from ..perception.work_plane import (
|
||
apply_below_plane_filter,
|
||
get_work_plane,
|
||
partition_by_plane,
|
||
)
|
||
from .perception_routes import _bbox_to_roi
|
||
from ..timing import Stopwatch
|
||
from . import _deps
|
||
|
||
router = APIRouter()
|
||
```
|
||
|
||
- [ ] **Step 2: Aggiungi il Pydantic model + l'handler `find_model`**
|
||
|
||
In fondo al file `src/presa_mano/api/grasp_routes.py` (dopo `execute`), aggiungi:
|
||
|
||
```python
|
||
class FindModelReq(BaseModel):
|
||
recipe_id: str = Field(..., min_length=1)
|
||
conf: Optional[float] = None
|
||
class_name: Optional[str] = None
|
||
|
||
|
||
_FIT_DOMAIN_CODES = {
|
||
"REGISTRATION_FAILED", "EMPTY_CLOUD", "MODEL_MISSING",
|
||
"INVALID_MESH", "NO_CORRESPONDENCES",
|
||
}
|
||
|
||
|
||
@router.post("/find_model")
|
||
def find_model(req: FindModelReq) -> dict[str, Any]:
|
||
sw = Stopwatch()
|
||
cfg = get_config()
|
||
fm_cfg = cfg.get("grasp", {}).get("find_model", {})
|
||
min_fitness = float(fm_cfg.get("min_fitness", 0.5))
|
||
max_rmse_mm = float(fm_cfg.get("max_rmse_mm", 5.0))
|
||
sample_n = int(fm_cfg.get("sample_points", 2000))
|
||
|
||
side = cfg.get("robot", {}).get("wrist_side", "right")
|
||
T_base_cam = CalibrationStore(_deps.calibration_file_path(side)).T_base_camera()
|
||
if T_base_cam is None:
|
||
raise HTTPException(status_code=412, detail=f"calibration missing for side='{side}'")
|
||
|
||
nuwa = _deps.nuwa()
|
||
yolo = _deps.yolo()
|
||
sam21 = _deps.sam21()
|
||
o3d = _deps.open3dmodel()
|
||
|
||
# 1. YOLO
|
||
detector = ObjectDetector(yolo, nuwa)
|
||
try:
|
||
with sw.step("yolo"):
|
||
dets = detector.detect(conf=req.conf)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
raise HTTPException(status_code=502, detail=f"YOLO detect failed: {e}")
|
||
if req.class_name:
|
||
dets = [d for d in dets if (d.get("class") or "").lower() == req.class_name.lower()]
|
||
if not dets:
|
||
raise HTTPException(status_code=404, detail="no object detected")
|
||
dets.sort(key=lambda d: d["confidence"], reverse=True)
|
||
top = dets[0]
|
||
bbox = _bbox_to_roi(top["bbox"])
|
||
|
||
# 2. SAM21 (no plane filter)
|
||
try:
|
||
seg = segment_with_sam21(nuwa, sam21, bbox, sw)
|
||
except Sam21Fallback as e:
|
||
raise HTTPException(status_code=422, detail=f"SAM21 fail: {e}")
|
||
|
||
# 3. Partition by plane
|
||
with sw.step("partition_plane"):
|
||
accepted_m, excluded_m, plane_info = partition_by_plane(
|
||
seg.points_cv_m, get_work_plane()
|
||
)
|
||
if accepted_m.shape[0] < 10:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail=f"too few accepted points after plane filter: {accepted_m.shape[0]}"
|
||
f" (before plane: {plane_info['n_points_before_plane']})"
|
||
)
|
||
|
||
# 4. Open3DModel pose
|
||
fit_failed = False
|
||
fit_reason: str | None = None
|
||
pose_data: dict[str, Any] = {}
|
||
try:
|
||
with sw.step("open3d_pose"):
|
||
pose_data = o3d.estimate_pose(req.recipe_id, accepted_m, seg.intrinsics_rgb)
|
||
except Open3DModelUnavailable as e:
|
||
raise HTTPException(status_code=502, detail=f"Open3DModel unreachable: {e}")
|
||
except Open3DModelError as e:
|
||
if e.code == "RECIPE_NOT_FOUND":
|
||
raise HTTPException(status_code=404, detail=f"recipe_id not found: {req.recipe_id}")
|
||
if e.code in {"HTTP_401", "HTTP_403"}:
|
||
raise HTTPException(status_code=502, detail=f"Open3DModel auth: {e.message}")
|
||
if e.code in _FIT_DOMAIN_CODES:
|
||
fit_failed = True
|
||
fit_reason = f"{e.code}: {e.message}"
|
||
else:
|
||
raise HTTPException(status_code=502, detail=f"Open3DModel error: {e.code}: {e.message}")
|
||
|
||
fitness = float(pose_data.get("fitness", 0.0))
|
||
rmse_mm = float(pose_data.get("inlier_rmse_mm", 1e9))
|
||
transform_4x4 = pose_data.get("transform_4x4")
|
||
|
||
if not fit_failed:
|
||
if fitness < min_fitness:
|
||
fit_failed = True
|
||
fit_reason = f"low fitness {fitness:.2f} < {min_fitness}"
|
||
elif rmse_mm > max_rmse_mm:
|
||
fit_failed = True
|
||
fit_reason = f"rmse {rmse_mm:.2f}mm > {max_rmse_mm}"
|
||
|
||
# 5. Mesh + sample + project
|
||
W = int(seg.intrinsics_rgb["width"])
|
||
H = int(seg.intrinsics_rgb["height"])
|
||
K = K_from_intrinsics(seg.intrinsics_rgb)
|
||
|
||
model_px = np.empty((0, 2), dtype=np.int32)
|
||
if not fit_failed and transform_4x4 is not None:
|
||
try:
|
||
with sw.step("recipe_mesh"):
|
||
mesh = _deps.recipe_cache().get(req.recipe_id, client=o3d)
|
||
with sw.step("sample"):
|
||
model_pts_mm = sample_surface(mesh.vertices, mesh.triangles, sample_n)
|
||
with sw.step("transform"):
|
||
T = np.asarray(transform_4x4, dtype=np.float64)
|
||
# transform_points expects (4,4) acting on column vectors → use directly
|
||
model_pts_cam_mm = transform_points(T, model_pts_mm)
|
||
with sw.step("project_model"):
|
||
model_px = project_camera_to_pixels(model_pts_cam_mm, K, (W, H))
|
||
except (Open3DModelError, Open3DModelUnavailable) as e:
|
||
raise HTTPException(status_code=502, detail=f"STL fetch/parse failed: {e}")
|
||
|
||
with sw.step("project_points"):
|
||
accepted_px = project_camera_to_pixels(accepted_m, K, (W, H))
|
||
excluded_px = project_camera_to_pixels(excluded_m, K, (W, H))
|
||
|
||
with sw.step("encode_rgb"):
|
||
rgb_b64 = base64.b64encode(seg.rgb_jpeg).decode("ascii")
|
||
|
||
fit_ok = not fit_failed
|
||
|
||
return {
|
||
"ok": True,
|
||
"rgb_jpeg_b64": rgb_b64,
|
||
"rgb_size": [W, H],
|
||
"detection": top,
|
||
"recipe": {"id": req.recipe_id,
|
||
"name": pose_data.get("recipe_name"),
|
||
"symmetry_type": pose_data.get("symmetry_type")},
|
||
"model_points_px": model_px.tolist(),
|
||
"accepted_points_px": accepted_px.tolist(),
|
||
"excluded_points_px": excluded_px.tolist(),
|
||
"n_accepted": int(accepted_m.shape[0]),
|
||
"n_excluded": int(excluded_m.shape[0]),
|
||
"plane_applied": bool(plane_info["plane_applied"]),
|
||
"fit_ok": fit_ok,
|
||
"fit_failed": fit_failed,
|
||
"fit_reason": fit_reason,
|
||
"fitness": fitness if not fit_failed or pose_data else None,
|
||
"inlier_rmse_mm": rmse_mm if not fit_failed or pose_data else None,
|
||
"transform_camera_4x4": transform_4x4,
|
||
"timings_ms": sw.as_dict(),
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Smoke-test l'import**
|
||
|
||
Run: `uv run python -c "from presa_mano.api.grasp_routes import router; print([r.path for r in router.routes])"`
|
||
Expected: include `/find_model`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/presa_mano/api/grasp_routes.py
|
||
git commit -m "feat(api): POST /api/grasp/find_model with pose-fit + overlay projection
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Frontend — `web/index.html`
|
||
|
||
**Files:**
|
||
- Modify: `web/index.html:229-282`
|
||
|
||
- [ ] **Step 1: Sostituisci la sezione tab-grasp**
|
||
|
||
In `web/index.html`, sostituisci interamente il blocco da `<section class="panel" id="tab-grasp">` fino al `</section>` corrispondente (linee 228-282) con:
|
||
|
||
```html
|
||
<!-- ============================== CERCA MODELLO ============================== -->
|
||
<section class="panel" id="tab-grasp">
|
||
<div class="grid-2">
|
||
<div>
|
||
<h2>RGB + detection — click per misurare</h2>
|
||
<div class="canvas-wrap">
|
||
<canvas id="graspCanvas" width="640" height="480"></canvas>
|
||
</div>
|
||
<div class="measure-strip" id="measureInfo">click sull'immagine per la distanza</div>
|
||
<div class="kv">
|
||
<label>Nuvola di <select id="graspObjectPick"><option value="">(top overall)</option></select></label>
|
||
<label>Confidenza <input type="number" id="graspConf" value="0.5" step="0.05" min="0" max="1" /></label>
|
||
</div>
|
||
<div id="graspLegend" class="grasp-legend"></div>
|
||
<div class="actions">
|
||
<button id="detectBtn">Rileva oggetto</button>
|
||
<button id="loopBtn">▶ Loop riconoscimento</button>
|
||
<label class="inline" style="margin-left:8px"><input type="number" id="loopRateMs" value="500" min="100" step="50" style="width:80px"> ms</label>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<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 consumati da app.js residuo. Tenuti per evitare null deref. -->
|
||
<div style="display:none">
|
||
<span id="statsWindow">5</span>
|
||
<table id="statsTable"><tbody>
|
||
<tr><td>x</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td></tr>
|
||
<tr><td>y</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td></tr>
|
||
<tr><td>z</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td></tr>
|
||
</tbody></table>
|
||
<div id="statsMeta"></div>
|
||
<pre id="graspOut"></pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
```
|
||
|
||
- [ ] **Step 2: Verifica HTML valido (smoke)**
|
||
|
||
Run: `grep -c 'id="findResultCanvas"' web/index.html`
|
||
Expected: `1`.
|
||
|
||
Run: `grep -E 'id="(planBtn|executeBtn|cropCanvas|scatterXY|scatterXZ|graspDryRun)"' web/index.html`
|
||
Expected: nessun output (tutti rimossi).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add web/index.html
|
||
git commit -m "feat(web): rebuild Cerca modello tab — model picker + result canvas
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Frontend — `web/app.js`
|
||
|
||
**Files:**
|
||
- Modify: `web/app.js` (sezione TROVA UTENSILE — circa righe 112-580)
|
||
|
||
- [ ] **Step 1: Rimuovi i blocchi morti**
|
||
|
||
Apri `web/app.js` e:
|
||
|
||
1. **Rimuovi** queste righe (riferimenti agli elementi rimossi):
|
||
|
||
```js
|
||
const cropCanvas = $('cropCanvas');
|
||
const cropCtx = cropCanvas.getContext('2d');
|
||
const scatterXY = $('scatterXY');
|
||
const scatterXZ = $('scatterXZ');
|
||
```
|
||
|
||
Sostituiscile con:
|
||
|
||
```js
|
||
// (rimossi cropCanvas/scatter — ora c'è findResultCanvas)
|
||
```
|
||
|
||
2. **Rimuovi** le variabili dichiarate intorno alla riga 121-124:
|
||
```js
|
||
let lastCropBbox = null;
|
||
let lastPoints = null;
|
||
```
|
||
(restano `lastDetections`, `lastSelectedClass`, `knownClasses`).
|
||
|
||
3. **Rimuovi** interamente le funzioni `drawCrop`, `drawScatter`, `drawScatters` (intorno alle righe 164-216).
|
||
|
||
4. Nella funzione `applyScanResponse` (intorno alla riga 264-274), **rimuovi** le righe che fanno riferimento ai morti:
|
||
|
||
```js
|
||
lastCropBbox = sel ? sel.bbox : null;
|
||
lastPoints = r.points_base || r.points_camera || null;
|
||
```
|
||
|
||
E rimuovi le chiamate `drawCrop(); drawScatters();` da quella funzione.
|
||
|
||
5. **Rimuovi** completamente gli handler di `$('planBtn').addEventListener(...)` e `$('executeBtn').addEventListener(...)` (intorno alle righe 298-490). Sono ~200 righe.
|
||
|
||
6. **Rimuovi** ogni altra chiamata residua a `drawCrop()`, `drawScatters()`, `lastCropBbox`, `lastPoints` ovunque nel file. Usa una ricerca grep per trovarle:
|
||
|
||
```bash
|
||
grep -n "drawCrop\|drawScatter\|lastCropBbox\|lastPoints\|cropCanvas\|cropCtx" web/app.js
|
||
```
|
||
|
||
Deve restituire 0 match dopo la pulizia.
|
||
|
||
- [ ] **Step 2: Aggiungi il blocco FIT MODELLO**
|
||
|
||
Dopo l'ultimo handler residuo del tab "Trova utensile" (subito prima del commento successivo che separa un'altra sezione, es. `// ==================== VISTA 3D ====================`), aggiungi questo blocco:
|
||
|
||
```js
|
||
// ==================== 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…';
|
||
$('findStatus').className = 'status-line';
|
||
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;
|
||
const fit = r.fit_ok
|
||
? `OK · fitness=${(r.fitness ?? 0).toFixed(2)} · rmse=${(r.inlier_rmse_mm ?? 0).toFixed(2)}mm`
|
||
: `Fit fallito: ${r.fit_reason}`;
|
||
$('findStatus').textContent = `${fit} · ${fmtTimings(r.timings_ms)}`;
|
||
$('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);
|
||
};
|
||
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();
|
||
```
|
||
|
||
- [ ] **Step 3: Verifica nessun riferimento morto**
|
||
|
||
Run:
|
||
```bash
|
||
grep -nE "planBtn|executeBtn|drawCrop|drawScatter|cropCanvas|cropCtx|lastCropBbox|lastPoints" web/app.js
|
||
```
|
||
Expected: nessun output.
|
||
|
||
- [ ] **Step 4: Smoke-test il sito (server già up via docker compose)**
|
||
|
||
Apri `http://localhost:8890/` nel browser. Console JS deve essere pulita (nessun `Cannot read property of null`). Il tab "Cerca modello" deve mostrare il picker.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add web/app.js
|
||
git commit -m "feat(web): Cerca modello — model picker + find_model overlay handlers
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Integration test manuale
|
||
|
||
Questo non è un test automatico — segui i passi e annota cosa funziona.
|
||
|
||
**Files:** nessuna modifica al codice.
|
||
|
||
- [ ] **Step 1: Verifica che tutti i servizi sono up**
|
||
|
||
```bash
|
||
docker compose ps
|
||
```
|
||
|
||
Deve elencare in stato `Up`/`healthy`:
|
||
- `presa-mano`
|
||
- `nuwa-hp60c`
|
||
- `sam21` / `segmentation-sam21`
|
||
- `yolo`
|
||
- `open3dmodel`
|
||
|
||
Se manca `open3dmodel`: `docker compose up -d open3dmodel`.
|
||
|
||
- [ ] **Step 2: Verifica che il proxy ricette funziona**
|
||
|
||
```bash
|
||
curl -s http://localhost:8890/api/models/recipes | jq
|
||
```
|
||
|
||
Expected: `{"recipes": [{"id": "...", "name": "pestello", "symmetry_type": "cylindrical", ...}]}`.
|
||
|
||
Se 502 "auth": verifica che `OPEN3D_API_KEY` in `presa-mano/.env` combaci con `Open3DModel/.env`.
|
||
|
||
- [ ] **Step 3: Verifica calibrazione hand-eye + work_plane sono attivi**
|
||
|
||
```bash
|
||
curl -s http://localhost:8890/api/calibration/result?side=right | jq '.calibration | keys'
|
||
curl -s http://localhost:8890/api/work_plane | jq '.plane.offset_mm'
|
||
```
|
||
|
||
Devono restituire matrici/numeri, non null. Se mancano, calibrali tramite GUI prima di proseguire.
|
||
|
||
- [ ] **Step 4: Chiamata find_model felice**
|
||
|
||
Metti un pestello sul tavolo, poi:
|
||
|
||
```bash
|
||
RECIPE_ID=$(curl -s http://localhost:8890/api/models/recipes | jq -r '.recipes[0].id')
|
||
curl -s -X POST http://localhost:8890/api/grasp/find_model \
|
||
-H 'Content-Type: application/json' \
|
||
-d "{\"recipe_id\":\"$RECIPE_ID\",\"conf\":0.5}" \
|
||
| jq '{fit_ok, fit_failed, fit_reason, fitness, rmse:.inlier_rmse_mm, n_accepted, n_excluded, n_model:(.model_points_px|length), total_ms:.timings_ms.total}'
|
||
```
|
||
|
||
Expected (oggetto giusto in scena):
|
||
```json
|
||
{
|
||
"fit_ok": true,
|
||
"fit_failed": false,
|
||
"fit_reason": null,
|
||
"fitness": <≥0.5>,
|
||
"rmse": <≤5.0>,
|
||
"n_accepted": <>0>,
|
||
"n_excluded": <≥0>,
|
||
"n_model": <>0, intorno a 2000 entro il bbox>,
|
||
"total_ms": <<2000>
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Chiamata find_model con recipe_id sbagliato**
|
||
|
||
```bash
|
||
curl -s -X POST http://localhost:8890/api/grasp/find_model \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"recipe_id":"bogus-uuid","conf":0.5}' -w "\nHTTP: %{http_code}\n"
|
||
```
|
||
|
||
Expected: HTTP 404 con detail `recipe_id not found: bogus-uuid`.
|
||
|
||
- [ ] **Step 6: Open3DModel down → 502**
|
||
|
||
```bash
|
||
docker compose stop open3dmodel
|
||
curl -s -X POST http://localhost:8890/api/grasp/find_model \
|
||
-H 'Content-Type: application/json' \
|
||
-d "{\"recipe_id\":\"$RECIPE_ID\",\"conf\":0.5}" -w "\nHTTP: %{http_code}\n"
|
||
docker compose start open3dmodel
|
||
```
|
||
|
||
Expected: HTTP 502 con detail `Open3DModel unreachable: …`.
|
||
|
||
- [ ] **Step 7: Smoke-test GUI**
|
||
|
||
In browser su `http://localhost:8890/`:
|
||
1. Tab "Cerca modello" → dropdown popolato con almeno "pestello".
|
||
2. Click "Rileva oggetto" → bbox YOLO visibili a sinistra.
|
||
3. Seleziona "pestello" dal dropdown modello.
|
||
4. Click "Trova" → entro 2s appare RGB con punti blu sopra il pestello.
|
||
5. Toggle "Punti accettati" → comparsa di punti verdi.
|
||
6. Toggle "Punti esclusi" → comparsa di punti rossi (se work_plane è calibrato; altrimenti nessun rosso).
|
||
7. Nessun errore in console JS.
|
||
|
||
- [ ] **Step 8: Se tutto OK, annota il successo**
|
||
|
||
Nessun commit (nessuna modifica al codice). Annota nel file di sessione (o memoria) che l'integrazione end-to-end è verde.
|
||
|
||
---
|
||
|
||
## Self-review
|
||
|
||
Dopo aver completato tutti i task, controlla:
|
||
|
||
1. **Spec coverage:** Ogni sezione della spec ha un task corrispondente?
|
||
- `clients/open3dmodel.py` → Task 5 ✓
|
||
- `clients/open3dmodel_cache.py` → Task 6 ✓
|
||
- `perception/stl_sampler.py` → Task 3 ✓
|
||
- `perception/projection.py` → Task 4 ✓
|
||
- `perception/sam21_bridge.py` refactor → Task 7 ✓
|
||
- `perception/work_plane.py` (partition_by_plane) → Task 2 ✓
|
||
- `api/_deps.py` → Task 8 ✓
|
||
- `api/grasp_routes.py` (find_model) → Task 10 ✓
|
||
- `api/models_routes.py` + main.py mount → Task 9 ✓
|
||
- `config.yaml` + `.env.example` + `pyproject.toml` → Task 1 ✓
|
||
- `web/index.html` → Task 11 ✓
|
||
- `web/app.js` → Task 12 ✓
|
||
- Test unit (partition, sampler, projection, client, cache, bridge) → Task 2/3/4/5/6/7 ✓
|
||
- Integration manuale → Task 13 ✓
|
||
|
||
2. **Comandi e percorsi:** Tutti i path sono assoluti dalla repo root o relativi a essa. Tutti i comandi `uv run`/`curl`/`grep` sono completi.
|
||
|
||
3. **Tipi coerenti:** `RecipeMesh.vertices/triangles` usati identici tra Task 3, 6, 10. `Sam21Segmentation.points_cv_m/rgb_jpeg/intrinsics_rgb` identici tra Task 7 e 10.
|