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:
1 parent
810a6102fb
commit
75da9add84
2 files changed
+225
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user