feat(clients): Open3DModel client (list_recipes, estimate_pose, get_stl_bytes)

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-13 10:03:41 +02:00
1 parent b85f4b9a48
commit 16ffe6ac20
3 files changed
+284

No files matched your search

+2
View File
@@ -3,12 +3,14 @@
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",
]
+132
View File
@@ -0,0 +1,132 @@
"""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}"
+150
View File
@@ -0,0 +1,150 @@
"""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