Spec brainstorming validato: SAM21 si inserisce tra YOLO bbox e PCA con fallback automatico al path bbox legacy. Tre componenti nuovi (clients/sam21.py, perception/depth_align.py, helper _points_via_sam21) e due modifiche minori (config.yaml, api/_deps.py). Out-of-scope: scan/ object_points, mask UI, nuwa_to_cv_frame cleanup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 KiB
SAM21 segmentation in the grasp pipeline
Status: approved (brainstorming → ready for implementation plan)
Date: 2026-05-11
Scope: presa-mano orchestrator, grasp/TrovaUtensile flow
Goal
Replace the current bbox-only point extraction in the grasp pipeline with a SAM2.1-segmented point cloud, so that the PCA → GraspPlanner stage operates on points that belong to the object itself, not to the bbox background. The bbox remains the prompt; SAM21 turns it into a tight 2D mask, and the depth+RGB intrinsics produce the 3D points directly in the OpenCV RGB-camera frame.
Non-goals
- Modify
/api/perception/scanor/api/perception/object_points. They keep the existing bbox→ROI cloud path. SAM21 is grasp-only. - Fix the pre-existing
nuwa_to_cv_frameextra flip on the ROI cloud path (already flagged inCLAUDE.mdas suspicious — out of scope here). - Expose a SAM21 passthrough endpoint from the orchestrator. SAM21 stays internal.
- Surface the 2D mask in the GUI.
return_mask=false. - Add a SAM21 health prefetch. Fallback already handles "not ready".
High-level flow
YOLO bbox
│
▼
config.grasp.use_sam21 == true ? ── no ──┐
│ yes │
▼ │
[Nuwa: rgb_jpeg, depth_png, intrinsics] │
│ │
▼ │
[presa-mano: align_depth_to_rgb] │
│ │
▼ │
[SAM21 /api/segment_pointcloud] │
│ │
▼ │
points in RGB-camera/OpenCV frame ──┐ │
│ │
exception during SAM21 path ────────┤ │
▼ ▼
[legacy bbox path]
(pointcloud_roi + filter_outliers + nuwa_to_cv_frame)
│
▼
transform_points(T_base_camera, …)
│
▼
PCA → GraspPlanner → wrist pose
Architecture
Five touchpoints, all inside src/presa_mano/:
clients/sam21.py (new)
Thin httpx wrapper, peer of nuwa.py / marker.py / yolo.py.
class Sam21Unavailable(RuntimeError): ... # transport/connection
class Sam21Error(RuntimeError): ... # semantic 4xx/5xx from service
class Sam21Client:
def __init__(self, base_url: str, timeout_s: float = 10.0): ...
def segment_pointcloud(
self,
rgb_png: bytes,
depth_png: bytes,
intrinsics: dict, # {fx, fy, cx, cy, width, height}
bbox: tuple[int, int, int, int],
depth_scale_mm: float = 1.0,
) -> np.ndarray: # (N, 3) float64, OpenCV RGB-camera frame
...
Internally:
- base64-encode the two PNGs.
- POST to
{base_url}/api/segment_pointcloudwithreturn_format="json",return_mask=False. - Unwrap the
{success, data, error}envelope. Onsuccess=falseraiseSam21Error(code, message). Onhttpx.ConnectError/TimeoutExceptionraiseSam21Unavailable. On HTTP 503 with codeMODEL_NOT_READYraiseSam21Unavailable("model loading")(it's transient, same fallback behaviour). - Parse
data.points(list of[x,y,z,r,g,b]) into an(N,3)numpy array (colours discarded — grasp planner doesn't use them).
perception/depth_align.py (new)
Vectorised IR→RGB depth alignment, built on the math already in
perception/depth_to_rgb.py. back_project_rgb_pixel does the reverse direction
per-pixel and iteratively; here we go forward in one pass.
def align_depth_to_rgb(
depth_ir_mm: np.ndarray, # (H_ir, W_ir) uint16, mm, IR-frame
reg: CameraRegistration, # IR & RGB intrinsics + extrinsics
) -> np.ndarray: # (H_rgb, W_rgb) uint16, mm, RGB-pixel-aligned
Algorithm:
- Build IR pixel grid
(u_ir, v_ir)masked bydepth_ir_mm > 0. - Back-project to 3D in IR camera frame:
P_ir = depth * K_ir^-1 * [u, v, 1]. - Transform to RGB camera frame:
P_rgb = R_ir_to_rgb @ P_ir + t_ir_to_rgb. - Project to RGB pixel grid:
(u_rgb, v_rgb) = K_rgb @ P_rgb / P_rgb.z. - Z-buffer: for each RGB pixel that receives ≥1 projected point, keep the
minimum
P_rgb.z(closest = occluder wins). Initialise the buffer to a large sentinel (e.g.np.iinfo(np.uint32).max), usenp.minimum.atfor the in-place reduce, then convert sentinel→0 at the end. (Initialising to 0 and usingnp.minimum.atwould be wrong: every projected depth would compare against 0 and the buffer would stay 0.) - Output
uint16mm at RGB resolution; unset pixels stay 0.
Inputs come from CameraRegistration.from_nuwa(nuwa.intrinsics()) which already
parses both IR and RGB intrinsics and the IR→RGB rigid transform.
Unit test: synthetic IR depth of a plane → align → back-project from RGB pixels
with back_project_rgb_pixel → check that the round-trip recovers the plane
within sub-pixel/sub-mm tolerance on the bulk of pixels (edge artefacts
acceptable).
api/_deps.py (modify)
One factory, mirroring nuwa()/marker()/yolo():
def sam21() -> Sam21Client:
cfg = get_config()
s = cfg.get("services", {}).get("sam21", {})
return Sam21Client(
base_url=s.get("url", "http://localhost:8002"),
timeout_s=float(s.get("timeout_s", 10.0)),
)
Per-request construction, no module-level cache — PUT /api/config hot-reload
keeps working.
api/grasp_routes.py::_object_points_in_base (modify)
Insert the SAM21 branch before the legacy branch. Signature unchanged. Return
value unchanged shape; meta enriched with source ∈ {"sam21","bbox_roi"}
and (on fallback) sam21_fallback_reason.
def _object_points_in_base(conf, class_name, sw):
cfg = get_config()
nuwa = _deps.nuwa()
yolo = _deps.yolo()
side = cfg["robot"]["wrist_side"]
T_base_cam = CalibrationStore(_deps.calibration_file_path(side)).T_base_camera()
if T_base_cam is None:
raise HTTPException(412, f"calibration missing for side='{side}'")
detector = ObjectDetector(yolo, nuwa)
with sw.step("yolo"):
dets = detector.detect(conf=conf)
if class_name:
dets = [d for d in dets if (d.get("class") or "").lower() == class_name.lower()]
if not dets:
raise HTTPException(404, "no object detected")
dets.sort(key=lambda d: d["confidence"], reverse=True)
top = dets[0]
bbox = _bbox_to_roi(top["bbox"])
meta: dict[str, Any] = {"detection": top, "T_base_camera": T_base_cam.tolist()}
if cfg.get("grasp", {}).get("use_sam21", True):
try:
pts_cv = _points_via_sam21(nuwa, bbox, sw)
pts_base = transform_points(T_base_cam, pts_cv)
meta["source"] = "sam21"
meta["n_points"] = len(pts_base)
return pts_base, meta
except _Sam21Fallback as e:
log.warning("SAM21 path failed, falling back to bbox: %s", e)
meta["sam21_fallback_reason"] = str(e)
pts_base = _points_via_bbox(nuwa, T_base_cam, bbox, sw)
meta["source"] = "bbox_roi"
meta["n_points"] = len(pts_base)
return pts_base, meta
Two helpers, both private to grasp_routes.py. Sentinel exception
_Sam21Fallback is module-local and never escapes — it only signals "the
SAM21 branch couldn't produce a usable result, drop to bbox".
_points_via_sam21(nuwa, bbox, sw) -> np.ndarray:
sw.step("nuwa_grab"): fetchrgb_jpeg,depth_png,intrinsics.- Convert RGB JPEG → PNG bytes (
PIL.Image.open(...).save(buf, "PNG")). sw.step("depth_align"): decode depth PNG → numpy →align_depth_to_rgb→ encode back to PNG bytes.sw.step("sam21"):_deps.sam21().segment_pointcloud(...)→(N,3).- If
N < 10, raise_Sam21Fallback("too few points: N")— same minimum the legacy path enforces, applied uniformly so PCA never runs on degenerate clouds. - Raise
_Sam21Fallbackon:Sam21Unavailable(any transport error)Sam21Errorwith code in{SEGMENTATION_EMPTY, NO_VALID_DEPTH, MODEL_NOT_READY}- Any unexpected exception (catch-all with
repr(e)in the reason)
- Log
ERROR(notWARNING) onSam21Errorwith code in{SHAPE_MISMATCH, BBOX_OUT_OF_BOUNDS, IMAGE_DECODE_ERROR, INVALID_INPUT}— those signal a bug in the payload assembly that we want to notice, even though we still fall back instead of failing the request.
_points_via_bbox(nuwa, T_base_cam, bbox, sw) -> np.ndarray: extracted from
the current body of _object_points_in_base, identical behaviour
(pointcloud_roi → filter_outliers → nuwa_to_cv_frame → transform_points).
Still raises HTTPException(422) on <10 valid points — that is a true
failure of the grasp request.
config.yaml (additions)
services:
sam21:
url: http://localhost:8002
timeout_s: 10.0
grasp:
use_sam21: true
grasp.use_sam21=false skips the SAM21 branch entirely (no exception, no
fallback log) — useful in dev when SAM21 isn't running.
Frame convention summary
| Source | Native frame | Treated as |
|---|---|---|
Nuwa /api/rgb |
RGB-camera (OpenCV) | input to SAM21, RGB intrinsics |
Nuwa /api/depth |
IR-camera | input to align_depth_to_rgb only; never back-projected directly |
Nuwa /api/intrinsics |
RGB-camera (OpenCV) | passed to SAM21 as-is |
| SAM21 output | RGB-camera (OpenCV: X-right, Y-down, Z-forward) | directly T_base_camera-compatible — no nuwa_to_cv_frame flip |
T_base_camera |
base ← RGB-camera (OpenCV) | unchanged |
The SAM21 path skips nuwa_to_cv_frame. The legacy path still calls it, as
today. The two paths converge in base frame and feed compute_axes /
GraspPlanner identically.
Error handling matrix
| Origin | Cause | Behaviour |
|---|---|---|
httpx.ConnectError / TimeoutException |
SAM21 container down, GPU OOM, network | log WARNING, fall back to bbox |
HTTP 503 MODEL_NOT_READY |
SAM21 still loading checkpoint | log WARNING, fall back to bbox |
HTTP 422 SEGMENTATION_EMPTY |
SAM2 found no object in bbox | log WARNING, fall back to bbox |
HTTP 422 NO_VALID_DEPTH |
mask OK but 0 pixels with depth | log WARNING, fall back to bbox |
HTTP 400 SHAPE_MISMATCH / BBOX_OUT_OF_BOUNDS / IMAGE_DECODE_ERROR / INVALID_INPUT |
payload assembly bug | log ERROR + fall back to bbox |
| Any other exception | unexpected | log ERROR with repr(e) + fall back to bbox |
| Nuwa fails (intrinsics/depth/rgb) | camera down | propagate 502 (would fail bbox path too) |
| Calibration missing | T_base_camera is None |
propagate 412 (existing behaviour) |
<10 valid depth points after fallback |
bbox region too empty | propagate 422 (existing behaviour) |
Performance budget
GPU mode (per SAM21 README + measured costs):
nuwa_grab(RGB jpeg + depth png + intrinsics): ~30 msdepth_align(vectorised numpy): ~5–15 mssam21(segment + back-project, GPU): ~300–500 msto_base_frame: <1 ms
Total SAM21 path: ~350–550 ms (vs legacy ~50–100 ms). Acceptable cost for the precision gain; the user-perceived grasp loop is dominated by wrist move time anyway.
CPU mode (no NVIDIA): SAM21 alone ~2–5 s. Still well below the 10 s client timeout, but the user should know. README of SAM21 already documents this.
Testing
- Unit
tests/test_depth_align.py: synthetic plane in IR frame → align → per-pixel round-trip viaback_project_rgb_pixelreproduces the plane within tolerance on ≥90% of valid pixels. - Unit
tests/test_sam21_client.py: mockhttpxresponses for happy path, 503MODEL_NOT_READY, 422SEGMENTATION_EMPTY,ConnectError. Assert the exception type and message. - Integration (manual, against live stack):
- SAM21 up, object in frame →
POST /api/grasp/plan→ response hassource: "sam21",n_points > 0,timings_ms.sam21 < 1000. - SAM21 stopped (
docker compose stop segmentation-sam21) → same call →source: "bbox_roi",sam21_fallback_reason: "unreachable: …". - SAM21 up, bbox over empty desk →
source: "bbox_roi",sam21_fallback_reason: "empty mask".
- SAM21 up, object in frame →
Out-of-scope follow-ups (logged, not done)
/api/perception/scanand/object_pointscould later switch to SAM21 with a query param, once we've validated the grasp path in production.- Surfacing the SAM21 mask in the GUI (overlay on the live RGB) would help debug bad detections — a UI-only change, easy when wanted.
- The pre-existing
nuwa_to_cv_frameextra flip on ROI cloud (CLAUDE.md"Frame conventions (gotcha-prone)") deserves a clean-up but in its own PR.