feat(clients): Sam21Client wrapper for /api/segment_pointcloud

Posts (rgb_png, depth_png, intrinsics, bbox) and returns (N,3) points in
meters in OpenCV RGB-camera frame. Normalises transport vs semantic errors
into Sam21Unavailable / Sam21Error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal PastroandClaude Opus 4.7 committed 2026-05-11 14:30:16 +02:00
1 parent 810a6102fb
commit 75da9add84
2 files changed
+225

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
"""Client for the SAM21 segmentation service (port 8002)."""
from __future__ import annotations
import base64
from typing import Any
import httpx
import numpy as np
class Sam21Unavailable(RuntimeError):
"""Transport-level failure (connection refused, timeout, model loading)."""
class Sam21Error(RuntimeError):
"""Semantic failure surfaced by the service (e.g. empty segmentation)."""
def __init__(self, code: str, message: str):
super().__init__(f"{code}: {message}")
self.code = code
self.message = message
_TRANSIENT_CODES = {"MODEL_NOT_READY"}
class Sam21Client:
"""Thin wrapper around POST /api/segment_pointcloud.
Returns points in **meters**, OpenCV RGB-camera frame (X-right, Y-down,
Z-forward) — directly compatible with `T_base_camera`.
"""
def __init__(self, base_url: str, timeout_s: float = 10.0):
self.base_url = base_url.rstrip("/")
self.timeout_s = timeout_s
def segment_pointcloud(
self,
rgb_png: bytes,
depth_png: bytes,
intrinsics: dict[str, Any],
bbox: tuple[int, int, int, int],
depth_scale_mm: float = 1.0,
) -> np.ndarray:
body = {
"rgb_png_base64": base64.b64encode(rgb_png).decode("ascii"),
"depth_png_base64": base64.b64encode(depth_png).decode("ascii"),
"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"]),
},
"bbox": [int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3])],
"depth_scale_mm": float(depth_scale_mm),
"return_format": "json",
"return_mask": False,
}
url = f"{self.base_url}/api/segment_pointcloud"
try:
with httpx.Client(timeout=self.timeout_s) as c:
r = c.post(url, json=body)
except (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError) as e:
raise Sam21Unavailable(f"sam21 unreachable: {e}") from e
# Try to parse the envelope even on non-2xx, because SAM21 always
# returns its `{success, data, error}` shape.
try:
payload = r.json()
except ValueError as e:
raise Sam21Unavailable(f"sam21 returned non-JSON (status {r.status_code}): {e}") from e
if not isinstance(payload, dict):
raise Sam21Unavailable(f"sam21 returned unexpected payload type: {type(payload)}")
if payload.get("success") is True:
data = payload.get("data") or {}
points = data.get("points") or []
if not points:
return np.empty((0, 3), dtype=np.float64)
arr_mm = np.asarray(points, dtype=np.float64)
if arr_mm.ndim != 2 or arr_mm.shape[1] < 3:
raise Sam21Unavailable(f"unexpected points shape: {arr_mm.shape}")
return arr_mm[:, :3] / 1000.0
err = payload.get("error") or {}
code = str(err.get("code") or "UNKNOWN")
msg = str(err.get("message") or f"http {r.status_code}")
if code in _TRANSIENT_CODES:
raise Sam21Unavailable(f"{code}: {msg}")
raise Sam21Error(code, msg)