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:
1 parent
b85f4b9a48
commit
16ffe6ac20
3 files changed
+284
No files matched your search
@@ -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",
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
Reference in new issue
Block a user