feat(perception): extend SAM21 to /scan and /object_points
Extract the SAM21 wiring out of grasp_routes.py into a shared module perception/sam21_bridge.py (Sam21Fallback + points_via_sam21). Both /api/perception/scan and /api/perception/object_points now try SAM21 first and fall back to the bbox ROI path on any Sam21Fallback. Response now carries 'source' (sam21 | bbox_roi) and optional 'sam21_fallback_reason'. Side-effect: 'points_camera' on both endpoints is now in OpenCV RGB-camera frame (same as 'points_base' minus the T_base_camera transform), regardless of which path produced the cloud. Previously the bbox path stored points in Nuwa-native frame here. The web GUI plots points_base when available, so the scatter views automatically pick up the SAM21-segmented cloud. Verified live: /scan → source=sam21 n=2414 sam21=111ms; /object_points → source=sam21 n=2347 sam21=118ms; /grasp/plan regression → source=sam21 n=2390 sam21=119ms; /scan with SAM21 down → source=bbox_roi fallback='unreachable: ...' Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ba0259aa90
commit
3b96589bf8
3 files changed
+186
-163
No files matched your search
@@ -0,0 +1,126 @@
|
||||
"""Bridge: bbox → SAM21-segmented point cloud in OpenCV RGB-camera frame.
|
||||
|
||||
Shared by /api/grasp/* and /api/perception/* endpoints. The helper is
|
||||
stateless and pure HTTP-call wiring; callers pass in the Nuwa and SAM21
|
||||
clients built per-request via api/_deps.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import cv2
|
||||
import httpx
|
||||
import numpy as np
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..clients import NuwaError, Sam21Error, Sam21Unavailable
|
||||
from ..timing import Stopwatch
|
||||
from .depth_align import align_depth_to_rgb
|
||||
from .depth_to_rgb import CameraRegistration
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Sam21Fallback(Exception):
|
||||
"""The SAM21 path could not produce a usable cloud. Callers should
|
||||
fall back to the non-SAM21 path (bbox ROI cloud)."""
|
||||
|
||||
|
||||
SAM21_BUG_CODES = frozenset({"SHAPE_MISMATCH", "BBOX_OUT_OF_BOUNDS",
|
||||
"IMAGE_DECODE_ERROR", "INVALID_INPUT"})
|
||||
|
||||
|
||||
def points_via_sam21(nuwa, sam21, bbox: tuple[int, int, int, int],
|
||||
sw: Stopwatch) -> np.ndarray:
|
||||
"""SAM21 path: bbox → segmented point cloud in RGB-camera/OpenCV frame.
|
||||
|
||||
Returns points in **meters**. Raises Sam21Fallback on failures that
|
||||
should trigger the bbox path. Re-raises HTTPException(502) for Nuwa
|
||||
failures (the bbox path would fail too — better to fail fast). All
|
||||
SAM21-specific logging happens here; callers do not log on fallback.
|
||||
"""
|
||||
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:
|
||||
# JPEG → PNG. cv2.imdecode gives BGR; cv2.imencode(".png", bgr) writes
|
||||
# PNG bytes with standard R,G,B color order (libpng handles the swap),
|
||||
# which SAM21's PIL-based decoder reads as RGB.
|
||||
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
|
||||
# Nuwa /api/intrinsics omits width/height — inject the real ones
|
||||
# from the decoded images so CameraRegistration and SAM21 agree on
|
||||
# frame sizes.
|
||||
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 = 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
|
||||
|
||||
if pts_cv.shape[0] < 10:
|
||||
log.warning("SAM21 returned %d points (<10), falling back to bbox", pts_cv.shape[0])
|
||||
raise Sam21Fallback(f"too few points: {pts_cv.shape[0]}")
|
||||
return pts_cv
|
||||
Reference in new issue
Block a user