diff --git a/src/presa_mano/clients/__init__.py b/src/presa_mano/clients/__init__.py index 4c4dd3a..5e8f0da 100644 --- a/src/presa_mano/clients/__init__.py +++ b/src/presa_mano/clients/__init__.py @@ -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", ] diff --git a/src/presa_mano/clients/open3dmodel.py b/src/presa_mano/clients/open3dmodel.py new file mode 100644 index 0000000..2beea4f --- /dev/null +++ b/src/presa_mano/clients/open3dmodel.py @@ -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}" diff --git a/tests/test_open3dmodel_client.py b/tests/test_open3dmodel_client.py new file mode 100644 index 0000000..868c84d --- /dev/null +++ b/tests/test_open3dmodel_client.py @@ -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