grid-cortex-client 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/CLAUDE.md +1 -1
  2. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/PKG-INFO +1 -1
  3. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/model_type.py +38 -0
  4. grid_cortex_client-0.5.0/src/grid_cortex_client/models/contact_graspnet.py +155 -0
  5. grid_cortex_client-0.5.0/src/grid_cortex_client/models/metric3d.py +129 -0
  6. grid_cortex_client-0.5.0/tests/test_contact_graspnet.py +282 -0
  7. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_metric3d.py +20 -0
  8. grid_cortex_client-0.4.0/src/grid_cortex_client/models/metric3d.py +0 -95
  9. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/.gitignore +0 -0
  10. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/README.md +0 -0
  11. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/pyproject.toml +0 -0
  12. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/__init__.py +0 -0
  13. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/client.py +0 -0
  14. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/cortex_client.py +0 -0
  15. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/cortex_hub_client.py +0 -0
  16. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/__init__.py +0 -0
  17. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/base_model.py +0 -0
  18. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/da3metric.py +0 -0
  19. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/fast_foundation_stereo.py +0 -0
  20. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/finetune_pi05.py +0 -0
  21. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/foundation_stereo.py +0 -0
  22. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/graspgen.py +0 -0
  23. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/graspgenx.py +0 -0
  24. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/gsam2.py +0 -0
  25. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/locate_anything.py +0 -0
  26. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/moondream.py +0 -0
  27. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/oneformer.py +0 -0
  28. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/owlv2.py +0 -0
  29. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/pi05.py +0 -0
  30. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/qwen_vl.py +0 -0
  31. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/sam2.py +0 -0
  32. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/sam3.py +0 -0
  33. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/ur5e_pyroki_collision_batch.py +0 -0
  34. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/ur5e_pyroki_fk.py +0 -0
  35. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/models/zoedepth.py +0 -0
  36. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/preprocessing.py +0 -0
  37. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/tools/__init__.py +0 -0
  38. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/tools/generate_enum.py +0 -0
  39. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/tools/registry.py +0 -0
  40. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/utils.py +0 -0
  41. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/src/grid_cortex_client/ws.py +0 -0
  42. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/README.md +0 -0
  43. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/__init__.py +0 -0
  44. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/conftest.py +0 -0
  45. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_cortex_hub_client.py +0 -0
  46. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_da3metric.py +0 -0
  47. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_fast_foundation_stereo.py +0 -0
  48. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_finetune_pi05.py +0 -0
  49. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_foundation_stereo.py +0 -0
  50. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_graspgen.py +0 -0
  51. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_graspgenx.py +0 -0
  52. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_gsam2.py +0 -0
  53. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_locate_anything.py +0 -0
  54. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_moondream.py +0 -0
  55. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_oneformer.py +0 -0
  56. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_owlv2.py +0 -0
  57. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_pi05.py +0 -0
  58. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_qwen_vl.py +0 -0
  59. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_sam2.py +0 -0
  60. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_sam3.py +0 -0
  61. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_ur5e_pyroki_collision_batch.py +0 -0
  62. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_ur5e_pyroki_fk.py +0 -0
  63. {grid_cortex_client-0.4.0 → grid_cortex_client-0.5.0}/tests/test_zoedepth.py +0 -0
@@ -10,7 +10,7 @@ uv run --package grid-cortex-client pytest
10
10
 
11
11
  - Build tool: hatchling + hatch-vcs
12
12
  - Version: set via `BUILD_VERSION` env (auto-bumped on main from squash-commit type — see `knowledge/golden-rules/cortex/client-versioning.md`)
13
- - PyPI history: pre-PR-#44 versions up to `0.2.118` were published by the old pipeline; the new auto-pipeline starts at `0.3.0` and uses `cortex-client/v*` git tags as the version source of truth (first auto-publish lands via PR #45's `feat(cortex):` merge once its review feedback is addressed)
13
+ - PyPI history: pre-PR-#44 versions up to `0.2.118` were published by the old pipeline; the auto-pipeline (live since PR #45) starts at `0.3.0` and uses `cortex-client/v*` git tags as the version source of truth
14
14
 
15
15
  ## Key Details
16
16
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: grid-cortex-client
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Python client for Grid Cortex
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.8
@@ -13,6 +13,44 @@ from enum import Enum
13
13
 
14
14
 
15
15
  class ModelType(Enum):
16
+ CONTACT_GRASPNET = "contact-graspnet"
17
+ """
18
+ Generate 6-DoF grasp poses from a depth image + camera intrinsics
19
+ (+ optional segmentation mask) using Contact-GraspNet (PyTorch port).
20
+
21
+ Args:
22
+ depth_image (Union[str, Image.Image, np.ndarray]): Depth image (path/URL/PIL/ndarray; float32 H×W).
23
+ camera_intrinsics (Union[str, np.ndarray]): 3×3 intrinsics matrix.
24
+ seg_image (Union[str, Image.Image, np.ndarray, None]): Optional segmentation mask (uint8 H×W).
25
+ When supplied + aux_args["filter_grasps"] = True, grasps are restricted to the masked region.
26
+ aux_args (Dict[str, Any]): Optional dict. Keys:
27
+ - "top_k" (int, default 64): cap on returned grasps after score-sort.
28
+ - "z_range" ([float, float], default [0.2, 1.8]): z-axis crop in metres.
29
+ - "local_regions" (bool, default False): crop 3D regions around segments.
30
+ - "filter_grasps" (bool, default False): restrict grasps to segmap; requires seg_image.
31
+ - "forward_passes" (int, default 1): how many forward passes to ensemble.
32
+ timeout (float | None): Optional HTTP timeout.
33
+
34
+ Returns:
35
+ Dict with:
36
+ - grasps: Array of 4×4 grasp poses in camera frame, shape (N, 4, 4), N <= top_k.
37
+ - confidence: Array of confidence scores in [0, 1], shape (N,).
38
+ - latency_ms: Optional server-reported latency in milliseconds.
39
+
40
+ Raises:
41
+ ValueError: If depth or intrinsics cannot be loaded.
42
+ RuntimeError: If no HTTP transport is configured.
43
+ Exception: If the HTTP request fails.
44
+
45
+ Example:
46
+ >>> from grid_cortex_client import CortexClient, ModelType
47
+ >>> import numpy as np
48
+ >>> client = CortexClient()
49
+ >>> depth = np.load("depth.npy")
50
+ >>> K = np.array([[615,0,320],[0,615,240],[0,0,1]], dtype=np.float64)
51
+ >>> out = client.run(ModelType.CONTACT_GRASPNET, depth_image=depth, camera_intrinsics=K, aux_args={"top_k": 32})
52
+ >>> print(out["grasps"].shape) # (<=32, 4, 4)
53
+ """
16
54
  DA3METRIC = "da3metric"
17
55
  """
18
56
  Estimate metric depth from single RGB image.
@@ -0,0 +1,155 @@
1
+ # grid_cortex_client/src/grid_cortex_client/models/contact_graspnet.py
2
+ """ContactGraspNet wrapper.
3
+
4
+ 6-DoF grasp pose generation from a depth image + camera intrinsics, with an
5
+ optional segmentation mask to restrict the working region.
6
+
7
+ Successor to the legacy `graspnet` (TF) model; runs on the PyTorch port via
8
+ the cortex `contact_graspnet` Ray Serve deployment. Wire format mirrors
9
+ `graspgen` so swapping models in client code is just changing the ModelType.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Dict, Union
15
+
16
+ import numpy as np
17
+ from PIL import Image
18
+
19
+ from ..preprocessing import array_to_npy_bytes, load_image
20
+ from .base_model import BaseModel
21
+
22
+
23
+ class ContactGraspNet(BaseModel):
24
+ """6-DoF grasp generation from depth + intrinsics (+ optional seg).
25
+
26
+ Preferred usage
27
+ ---------------
28
+ ```pycon
29
+ >>> grasps, conf = CortexClient().run(
30
+ ... "contact_graspnet",
31
+ ... depth_image=depth, camera_intrinsics=K,
32
+ ... seg_image=seg, aux_args={"top_k": 64}
33
+ ... )
34
+ ```
35
+ """
36
+
37
+ name: str = "contact-graspnet"
38
+ model_id: str = "contact-graspnet"
39
+
40
+ def preprocess(
41
+ self,
42
+ *,
43
+ depth_image: Union[str, Image.Image, np.ndarray, None] = None,
44
+ camera_intrinsics: Union[str, np.ndarray, None] = None,
45
+ seg_image: Union[str, Image.Image, np.ndarray, None] = None,
46
+ aux_args: Dict[str, Any] | None = None,
47
+ ) -> Dict[str, Any]:
48
+ """Encode depth + intrinsics (+ optional seg) for the server.
49
+
50
+ Args:
51
+ depth_image: Depth image (path/URL/PIL/ndarray; float32 H×W).
52
+ camera_intrinsics: 3×3 intrinsics matrix (path/ndarray).
53
+ seg_image: Optional segmentation mask (path/PIL/ndarray; uint8 H×W).
54
+ When supplied + ``aux_args["filter_grasps"] = True``, grasps are
55
+ restricted to the masked region (see model README for details).
56
+ aux_args: Optional dict with ``top_k`` (default 64), ``z_range``
57
+ (default [0.2, 1.8]), ``local_regions``, ``filter_grasps``,
58
+ ``forward_passes``.
59
+ """
60
+ if depth_image is None or camera_intrinsics is None:
61
+ raise ValueError("depth_image and camera_intrinsics are required.")
62
+
63
+ # Accept .npy paths in addition to image formats. PIL.Image.open chokes
64
+ # on raw NumPy files; np.load is the right call for them. .png/.exr
65
+ # depth maps still flow through load_image as before.
66
+ if isinstance(depth_image, str):
67
+ if depth_image.endswith(".npy"):
68
+ depth_array = np.load(depth_image)
69
+ else:
70
+ depth_array = np.array(load_image(depth_image))
71
+ elif isinstance(depth_image, Image.Image):
72
+ depth_array = np.array(load_image(depth_image))
73
+ else:
74
+ depth_array = np.asarray(depth_image)
75
+
76
+ if isinstance(camera_intrinsics, str):
77
+ intrinsics = np.load(camera_intrinsics)
78
+ else:
79
+ intrinsics = np.asarray(camera_intrinsics)
80
+
81
+ payload: Dict[str, Any] = {
82
+ "depth_image": array_to_npy_bytes(depth_array.astype(np.float32)),
83
+ "camera_intrinsics": array_to_npy_bytes(intrinsics.astype(np.float64)),
84
+ "aux_args": aux_args or {},
85
+ }
86
+
87
+ if seg_image is not None:
88
+ if isinstance(seg_image, (str, Image.Image)):
89
+ seg_array = np.array(load_image(seg_image))
90
+ else:
91
+ seg_array = np.asarray(seg_image)
92
+ payload["seg_image"] = array_to_npy_bytes(seg_array.astype(np.uint8))
93
+
94
+ return payload
95
+
96
+ def postprocess(self, response_data: Dict[str, Any], **_: Any) -> Dict[str, Any]:
97
+ """Decode grasps and confidence from response."""
98
+ raw_grasps = response_data["output"]
99
+ # Keep the (N, 4, 4) shape invariant when no grasps were found —
100
+ # np.array([]) → (0,) which breaks any caller indexing grasps[:, :3, 3].
101
+ if not raw_grasps:
102
+ grasps = np.zeros((0, 4, 4), dtype=np.float32)
103
+ else:
104
+ grasps = np.array(raw_grasps)
105
+ conf = np.array(response_data["confidence"])
106
+ return {
107
+ "grasps": grasps,
108
+ "confidence": conf,
109
+ "latency_ms": response_data.get("latency_ms"),
110
+ }
111
+
112
+ def run(
113
+ self,
114
+ *,
115
+ depth_image: Union[str, Image.Image, np.ndarray, None] = None,
116
+ camera_intrinsics: Union[str, np.ndarray, None] = None,
117
+ seg_image: Union[str, Image.Image, np.ndarray, None] = None,
118
+ aux_args: Dict[str, Any] | None = None,
119
+ timeout: float | None = None,
120
+ ) -> Dict[str, Any]:
121
+ """Generate grasps using ContactGraspNet.
122
+
123
+ Args:
124
+ depth_image: Depth image (required).
125
+ camera_intrinsics: 3×3 intrinsics matrix (required).
126
+ seg_image: Optional segmentation mask (uint8 H×W).
127
+ aux_args: Optional dict. Keys: ``top_k`` (int, default 64),
128
+ ``z_range`` ([float, float], default [0.2, 1.8]),
129
+ ``local_regions`` (bool, default False),
130
+ ``filter_grasps`` (bool, default False; requires ``seg_image``),
131
+ ``forward_passes`` (int, default 1).
132
+ timeout: Optional HTTP timeout.
133
+
134
+ Returns:
135
+ Dict with:
136
+ - ``grasps``: (N, 4, 4) array of grasp poses in camera frame
137
+ - ``confidence``: (N,) array of confidence scores in [0, 1]
138
+ - ``latency_ms``: server-reported latency
139
+
140
+ Examples:
141
+ >>> from grid_cortex_client import CortexClient, ModelType
142
+ >>> import numpy as np
143
+ >>> client = CortexClient()
144
+ >>> depth = np.load("depth.npy")
145
+ >>> K = np.array([[615,0,320],[0,615,240],[0,0,1]], dtype=np.float64)
146
+ >>> out = client.run(ModelType.CONTACT_GRASPNET, depth_image=depth, camera_intrinsics=K, aux_args={"top_k": 32})
147
+ >>> print(out["grasps"].shape) # (<=32, 4, 4)
148
+ """
149
+ return super().run(
150
+ depth_image=depth_image,
151
+ camera_intrinsics=camera_intrinsics,
152
+ seg_image=seg_image,
153
+ aux_args=aux_args,
154
+ timeout=timeout,
155
+ )
@@ -0,0 +1,129 @@
1
+ """Metric3D wrapper.
2
+
3
+ Client-side helper for monocular metric depth estimation and surface normal
4
+ prediction powered by Metric3D ViT-Large.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from io import BytesIO
10
+ from typing import Any, Dict, Optional, Sequence, Union
11
+
12
+ import numpy as np
13
+ from PIL import Image
14
+
15
+ from ..preprocessing import load_image, image_to_bytes
16
+ from .base_model import BaseModel
17
+
18
+ VALID_OUTPUTS = ("depth", "normals")
19
+
20
+
21
+ class Metric3D(BaseModel):
22
+ """Monocular metric depth + surface normals (Metric3D ViT-Large).
23
+
24
+ Preferred usage (through :class:`CortexClient`)
25
+ ------------------------------------------------
26
+ ```pycon
27
+ >>> result = CortexClient().run("metric3d", image_input=img)
28
+ >>> result["depth"].shape # (H, W) float32 metres
29
+ >>> result["normals"].shape # (H, W, 3) float32 unit normals
30
+
31
+ >>> # Request a single map to cut response size (~75% smaller for depth-only)
32
+ >>> result = CortexClient().run("metric3d", image_input=img, outputs=["depth"])
33
+ >>> "normals" in result
34
+ False
35
+ ```
36
+ """
37
+
38
+ name: str = "metric3d"
39
+ model_id: str = "metric3d"
40
+
41
+ def __init__(self, *, transport=None) -> None:
42
+ super().__init__(transport=transport)
43
+
44
+ # ------------------------------------------------------------------
45
+ # BaseModel interface
46
+ # ------------------------------------------------------------------
47
+
48
+ def preprocess(
49
+ self,
50
+ *,
51
+ image_input: Union[str, Image.Image, np.ndarray],
52
+ outputs: Optional[Union[str, Sequence[str]]] = None,
53
+ ) -> Dict[str, Any]:
54
+ """Return msgpack-ready payload with raw image bytes under key ``image_input``.
55
+
56
+ Args:
57
+ image_input: RGB image as file path, URL, PIL Image, or numpy array.
58
+ outputs: Optional selection of maps to return — ``"depth"``,
59
+ ``"normals"``, or a non-empty subset of both. ``None`` (default)
60
+ returns both.
61
+
62
+ Raises:
63
+ ValueError: If ``outputs`` is empty or contains an unknown name.
64
+ """
65
+ pil_img = load_image(image_input)
66
+ payload: Dict[str, Any] = {"image_input": image_to_bytes(pil_img, encoding_format="JPEG")}
67
+
68
+ if outputs is not None:
69
+ selected = [outputs] if isinstance(outputs, str) else [str(o) for o in outputs]
70
+ if not selected or any(o not in VALID_OUTPUTS for o in selected):
71
+ raise ValueError(f"'outputs' must be a non-empty subset of {VALID_OUTPUTS}, got {outputs!r}")
72
+ payload["outputs"] = selected
73
+
74
+ return payload
75
+
76
+ def postprocess(
77
+ self,
78
+ response_data: Dict[str, Any],
79
+ **_: Any,
80
+ ) -> Dict[str, np.ndarray]:
81
+ """Decode the maps present in the server response.
82
+
83
+ Returns:
84
+ Dict with the returned subset of ``"depth"`` (H, W) float32 and
85
+ ``"normals"`` (H, W, 3) float32.
86
+ """
87
+ if isinstance(response_data, list):
88
+ response_data = response_data[0]
89
+
90
+ result: Dict[str, np.ndarray] = {}
91
+ for key in VALID_OUTPUTS:
92
+ buf = response_data.get(key)
93
+ if buf is None:
94
+ continue
95
+ if not isinstance(buf, (bytes, bytearray, memoryview)):
96
+ raise ValueError(f"'{key}' field is not bytes")
97
+ result[key] = np.load(BytesIO(buf), allow_pickle=False).astype(np.float32)
98
+
99
+ if not result:
100
+ raise ValueError(f"response contains none of {VALID_OUTPUTS}")
101
+ return result
102
+
103
+ def run(
104
+ self,
105
+ image_input: Union[str, Image.Image, np.ndarray],
106
+ timeout: float | None = None,
107
+ *,
108
+ outputs: Optional[Union[str, Sequence[str]]] = None,
109
+ ) -> Dict[str, np.ndarray]:
110
+ """Estimate metric depth and/or surface normals from a single RGB image.
111
+
112
+ Args:
113
+ image_input: RGB image as file path, URL, PIL Image, or numpy array.
114
+ timeout: Optional timeout in seconds for the HTTP request.
115
+ outputs: Keyword-only. Optional selection of maps to return —
116
+ ``"depth"``, ``"normals"``, or a non-empty subset of both.
117
+ ``None`` (default) returns both. Requesting only what you need
118
+ shrinks the response payload (normals are 3x the size of depth).
119
+
120
+ Returns:
121
+ Dict with the requested subset of:
122
+ - ``"depth"``: (H, W) float32 array, metric depth in metres.
123
+ - ``"normals"``: (H, W, 3) float32 array, unit surface normals.
124
+ """
125
+ return super().run(
126
+ image_input=image_input,
127
+ outputs=outputs,
128
+ timeout=timeout,
129
+ )
@@ -0,0 +1,282 @@
1
+ """Test ContactGraspNet model (6-DoF grasp generation, PyTorch port)."""
2
+
3
+ import os
4
+ import time
5
+
6
+ import numpy as np
7
+ import pytest
8
+
9
+ from grid_cortex_client import AsyncCortexClient, CortexClient, CortexHubClient, ModelType
10
+ from grid_cortex_client.client import CortexAPIError
11
+
12
+ from .conftest import BaseModelTestHTTP, is_gpu_mem_close, run_concurrent_requests
13
+
14
+
15
+ class TestContactGraspNetHTTP(BaseModelTestHTTP):
16
+ """HTTP-based tests for ContactGraspNet.
17
+
18
+ Reuses the depth/seg/intrinsics fixtures from the graspgen suite — both
19
+ grasping models take the same input shape, so a single eval set covers
20
+ both. ContactGraspNet has no point_cloud direct-input path (the model
21
+ extracts the cloud itself from depth + K with z-range filtering).
22
+ """
23
+
24
+ @property
25
+ def batch_size(self) -> int:
26
+ return 1
27
+
28
+ @property
29
+ def gpu_mem(self) -> float:
30
+ return 3200.0
31
+
32
+ @property
33
+ def max_input_size(self) -> tuple:
34
+ return (720, 1280)
35
+
36
+ @pytest.fixture(scope="class")
37
+ def depth_image(self, depth_image_np: np.ndarray) -> np.ndarray:
38
+ return depth_image_np
39
+
40
+ @pytest.fixture(scope="class")
41
+ def seg_image(self, seg_image_np: np.ndarray) -> np.ndarray:
42
+ return seg_image_np
43
+
44
+ @pytest.fixture(scope="class")
45
+ def camera_intrinsics(self, camera_intrinsics_np: np.ndarray) -> np.ndarray:
46
+ return camera_intrinsics_np
47
+
48
+ @pytest.fixture(scope="class")
49
+ def aux_args(self) -> dict:
50
+ return {
51
+ "top_k": 32,
52
+ "z_range": [0.2, 1.8],
53
+ "local_regions": False,
54
+ "filter_grasps": False,
55
+ "forward_passes": 1,
56
+ }
57
+
58
+ def test_basic_inference(
59
+ self,
60
+ client: CortexClient,
61
+ depth_image: np.ndarray,
62
+ camera_intrinsics: np.ndarray,
63
+ aux_args: dict,
64
+ ):
65
+ result = client.run(
66
+ ModelType.CONTACT_GRASPNET,
67
+ depth_image=depth_image,
68
+ camera_intrinsics=camera_intrinsics,
69
+ aux_args=aux_args,
70
+ )
71
+ assert result is not None
72
+ assert "grasps" in result
73
+ assert "confidence" in result
74
+ assert isinstance(result["grasps"], np.ndarray)
75
+ assert isinstance(result["confidence"], np.ndarray)
76
+
77
+ def test_with_segmentation(
78
+ self,
79
+ client: CortexClient,
80
+ depth_image: np.ndarray,
81
+ seg_image: np.ndarray,
82
+ camera_intrinsics: np.ndarray,
83
+ ):
84
+ aux = {"top_k": 16, "filter_grasps": True, "local_regions": True}
85
+ result = client.run(
86
+ ModelType.CONTACT_GRASPNET,
87
+ depth_image=depth_image,
88
+ seg_image=seg_image,
89
+ camera_intrinsics=camera_intrinsics,
90
+ aux_args=aux,
91
+ )
92
+ assert result is not None
93
+ assert "grasps" in result
94
+
95
+ def test_raw_response_with_gpu_stats(
96
+ self,
97
+ client: CortexClient,
98
+ depth_image: np.ndarray,
99
+ camera_intrinsics: np.ndarray,
100
+ aux_args: dict,
101
+ ):
102
+ raw = client.run(
103
+ ModelType.CONTACT_GRASPNET,
104
+ depth_image=depth_image,
105
+ camera_intrinsics=camera_intrinsics,
106
+ aux_args=aux_args,
107
+ raw_response=True,
108
+ )
109
+ assert "output" in raw
110
+ assert "confidence" in raw
111
+ assert "latency_ms" in raw
112
+ assert "gpu_stats" in raw
113
+ assert "mem_usage_mb" in raw["gpu_stats"]
114
+
115
+ def test_grasp_output_structure(
116
+ self,
117
+ client: CortexClient,
118
+ depth_image: np.ndarray,
119
+ camera_intrinsics: np.ndarray,
120
+ aux_args: dict,
121
+ ):
122
+ result = client.run(
123
+ ModelType.CONTACT_GRASPNET,
124
+ depth_image=depth_image,
125
+ camera_intrinsics=camera_intrinsics,
126
+ aux_args=aux_args,
127
+ )
128
+ grasps = result["grasps"]
129
+ confidence = result["confidence"]
130
+ if len(grasps) > 0:
131
+ assert grasps.ndim == 3
132
+ assert grasps.shape[1:] == (4, 4)
133
+ assert confidence.ndim == 1
134
+ assert len(grasps) == len(confidence)
135
+ assert len(grasps) <= aux_args["top_k"]
136
+
137
+ def test_maximum_payload_size(
138
+ self,
139
+ client: CortexClient,
140
+ camera_intrinsics: np.ndarray,
141
+ aux_args: dict,
142
+ ):
143
+ large_depth = np.random.rand(*self.max_input_size).astype(np.float32) * 2.0
144
+ result = client.run(
145
+ ModelType.CONTACT_GRASPNET,
146
+ depth_image=large_depth,
147
+ camera_intrinsics=camera_intrinsics,
148
+ aux_args=aux_args,
149
+ )
150
+ assert result is not None
151
+
152
+ def test_exceeds_maximum_payload_size(
153
+ self,
154
+ client: CortexClient,
155
+ camera_intrinsics: np.ndarray,
156
+ aux_args: dict,
157
+ ):
158
+ # Handler guard at depth.size > 4_000_000 -> 413.
159
+ huge_depth = np.random.rand(2001, 2001).astype(np.float32) * 2.0
160
+ with pytest.raises(CortexAPIError) as exc_info:
161
+ client.run(
162
+ ModelType.CONTACT_GRASPNET,
163
+ depth_image=huge_depth,
164
+ camera_intrinsics=camera_intrinsics,
165
+ aux_args=aux_args,
166
+ )
167
+ assert exc_info.value.status_code == 413
168
+
169
+ def test_inference_latency(
170
+ self,
171
+ client: CortexClient,
172
+ depth_image: np.ndarray,
173
+ camera_intrinsics: np.ndarray,
174
+ aux_args: dict,
175
+ ):
176
+ start = time.time()
177
+ result = client.run(
178
+ ModelType.CONTACT_GRASPNET,
179
+ depth_image=depth_image,
180
+ camera_intrinsics=camera_intrinsics,
181
+ aux_args=aux_args,
182
+ )
183
+ elapsed = time.time() - start
184
+ assert result is not None
185
+ assert elapsed < 50.0, f"Inference took {elapsed:.2f}s, expected < 50s"
186
+
187
+ def test_missing_camera_intrinsics(
188
+ self,
189
+ client: CortexClient,
190
+ depth_image: np.ndarray,
191
+ aux_args: dict,
192
+ ):
193
+ with pytest.raises(ValueError):
194
+ client.run(
195
+ ModelType.CONTACT_GRASPNET,
196
+ depth_image=depth_image,
197
+ aux_args=aux_args,
198
+ )
199
+
200
+ def test_missing_depth_image(
201
+ self,
202
+ client: CortexClient,
203
+ camera_intrinsics: np.ndarray,
204
+ aux_args: dict,
205
+ ):
206
+ with pytest.raises(ValueError):
207
+ client.run(
208
+ ModelType.CONTACT_GRASPNET,
209
+ camera_intrinsics=camera_intrinsics,
210
+ aux_args=aux_args,
211
+ )
212
+
213
+ @pytest.mark.asyncio
214
+ async def test_concurrent_requests(
215
+ self,
216
+ async_client: AsyncCortexClient,
217
+ depth_image: np.ndarray,
218
+ camera_intrinsics: np.ndarray,
219
+ aux_args: dict,
220
+ ):
221
+ async def make_request():
222
+ return await async_client.run(
223
+ ModelType.CONTACT_GRASPNET,
224
+ depth_image=depth_image,
225
+ camera_intrinsics=camera_intrinsics,
226
+ aux_args=aux_args,
227
+ )
228
+
229
+ # Match graspgen's pragmatic cap-of-1 — keeps the test from timing out
230
+ # at full input size while still exercising the async path.
231
+ num_requests = min(self.batch_size, 1)
232
+ results = await run_concurrent_requests(make_request, num_requests=num_requests)
233
+ assert len(results) == num_requests
234
+ assert all(not isinstance(r, Exception) for r in results)
235
+ assert all("grasps" in r for r in results)
236
+
237
+ @pytest.mark.asyncio
238
+ async def test_gpu_memory_usage(
239
+ self,
240
+ async_client: AsyncCortexClient,
241
+ depth_image: np.ndarray,
242
+ camera_intrinsics: np.ndarray,
243
+ aux_args: dict,
244
+ ):
245
+ async def make_request():
246
+ return await async_client.run(
247
+ ModelType.CONTACT_GRASPNET,
248
+ depth_image=depth_image,
249
+ camera_intrinsics=camera_intrinsics,
250
+ aux_args=aux_args,
251
+ raw_response=True,
252
+ )
253
+
254
+ num_requests = min(self.batch_size, 2)
255
+ results = await run_concurrent_requests(make_request, num_requests=num_requests)
256
+ assert len(results) == num_requests
257
+ assert all(not isinstance(r, Exception) for r in results)
258
+ for result in results:
259
+ print(f"Specified GPU mem: {self.gpu_mem} MB, Current GPU mem: {result['gpu_stats']['mem_usage_mb']} MB")
260
+ assert all(is_gpu_mem_close(self.gpu_mem, result["gpu_stats"]["mem_usage_mb"]) for result in results), (
261
+ f"GPU memory usage too high: {[r['gpu_stats']['mem_usage_mb'] for r in results]} MB"
262
+ )
263
+
264
+ @pytest.mark.asyncio
265
+ async def test_cortex_hub(
266
+ self,
267
+ depth_image: np.ndarray,
268
+ camera_intrinsics: np.ndarray,
269
+ aux_args: dict,
270
+ ):
271
+ api_key = os.getenv("GRID_CORTEX_API_KEY") or os.getenv("CORTEX_API_KEY")
272
+ if not api_key:
273
+ pytest.skip("GRID_CORTEX_API_KEY or CORTEX_API_KEY not set")
274
+
275
+ async with CortexHubClient(api_key=api_key) as hub:
276
+ result = await hub.run(
277
+ ModelType.CONTACT_GRASPNET,
278
+ depth_image=depth_image,
279
+ camera_intrinsics=camera_intrinsics,
280
+ aux_args=aux_args,
281
+ )
282
+ assert result is not None
@@ -55,6 +55,24 @@ class TestMetric3DHTTP(BaseModelTestHTTP):
55
55
  with pytest.raises((TypeError, CortexAPIError)):
56
56
  client.run(ModelType.METRIC3D, image_input=[rgb_image_np, rgb_image_np])
57
57
 
58
+ def test_output_selection(self, client: CortexClient, rgb_image_np: np.ndarray):
59
+ """outputs= returns only the requested maps; invalid names are rejected client-side."""
60
+ depth_only = client.run(ModelType.METRIC3D, image_input=rgb_image_np, outputs=["depth"])
61
+ assert set(depth_only) == {"depth"}
62
+ assert depth_only["depth"].shape == rgb_image_np.shape[:2]
63
+
64
+ normals_only = client.run(ModelType.METRIC3D, image_input=rgb_image_np, outputs="normals")
65
+ assert set(normals_only) == {"normals"}
66
+ assert normals_only["normals"].shape == (*rgb_image_np.shape[:2], 3)
67
+
68
+ both = client.run(ModelType.METRIC3D, image_input=rgb_image_np, outputs=["depth", "normals"])
69
+ assert set(both) == {"depth", "normals"}
70
+
71
+ with pytest.raises(ValueError):
72
+ client.run(ModelType.METRIC3D, image_input=rgb_image_np, outputs=["depth", "rgb"])
73
+ with pytest.raises(ValueError):
74
+ client.run(ModelType.METRIC3D, image_input=rgb_image_np, outputs=[])
75
+
58
76
  def test_maximum_payload_size(self, client: CortexClient):
59
77
  """Test that inference works with maximum allowed payload size."""
60
78
  large_image = np.random.randint(0, 255, (*self.max_input_size, 3), dtype=np.uint8)
@@ -122,7 +140,9 @@ class TestMetric3DHTTP(BaseModelTestHTTP):
122
140
 
123
141
  async with CortexHubClient(api_key=api_key) as hub:
124
142
  result = await hub.run(ModelType.METRIC3D, image_input=rgb_image_np)
143
+ depth_only = await hub.run(ModelType.METRIC3D, image_input=rgb_image_np, outputs=["depth"])
125
144
  assert result is not None
126
145
  assert isinstance(result, dict)
127
146
  assert "depth" in result
128
147
  assert "normals" in result
148
+ assert set(depth_only) == {"depth"}
@@ -1,95 +0,0 @@
1
- """Metric3D wrapper.
2
-
3
- Client-side helper for monocular metric depth estimation and surface normal
4
- prediction powered by Metric3D ViT-Large.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- from io import BytesIO
10
- from typing import Any, Dict, Union
11
-
12
- import numpy as np
13
- from PIL import Image
14
-
15
- from ..preprocessing import load_image, image_to_bytes
16
- from .base_model import BaseModel
17
-
18
-
19
- class Metric3D(BaseModel):
20
- """Monocular metric depth + surface normals (Metric3D ViT-Large).
21
-
22
- Preferred usage (through :class:`CortexClient`)
23
- ------------------------------------------------
24
- ```pycon
25
- >>> result = CortexClient().run("metric3d", image_input=img)
26
- >>> result["depth"].shape # (H, W) float32 metres
27
- >>> result["normals"].shape # (H, W, 3) float32 unit normals
28
- ```
29
- """
30
-
31
- name: str = "metric3d"
32
- model_id: str = "metric3d"
33
-
34
- def __init__(self, *, transport=None) -> None:
35
- super().__init__(transport=transport)
36
-
37
- # ------------------------------------------------------------------
38
- # BaseModel interface
39
- # ------------------------------------------------------------------
40
-
41
- def preprocess(
42
- self,
43
- *,
44
- image_input: Union[str, Image.Image, np.ndarray],
45
- ) -> Dict[str, Any]:
46
- """Return msgpack-ready payload with raw image bytes under key ``image_input``."""
47
- pil_img = load_image(image_input)
48
- return {"image_input": image_to_bytes(pil_img, encoding_format="JPEG")}
49
-
50
- def postprocess(
51
- self,
52
- response_data: Dict[str, Any],
53
- **_: Any,
54
- ) -> Dict[str, np.ndarray]:
55
- """Decode server response into depth and normals arrays.
56
-
57
- Returns:
58
- Dict with ``"depth"`` (H, W) float32 and ``"normals"`` (H, W, 3) float32.
59
- """
60
- if isinstance(response_data, list):
61
- response_data = response_data[0]
62
-
63
- depth_bytes = response_data.get("depth")
64
- normals_bytes = response_data.get("normals")
65
-
66
- if not isinstance(depth_bytes, (bytes, bytearray, memoryview)):
67
- raise ValueError("'depth' field missing or not bytes")
68
- if not isinstance(normals_bytes, (bytes, bytearray, memoryview)):
69
- raise ValueError("'normals' field missing or not bytes")
70
-
71
- depth = np.load(BytesIO(depth_bytes), allow_pickle=False).astype(np.float32)
72
- normals = np.load(BytesIO(normals_bytes), allow_pickle=False).astype(np.float32)
73
-
74
- return {"depth": depth, "normals": normals}
75
-
76
- def run(
77
- self,
78
- image_input: Union[str, Image.Image, np.ndarray],
79
- timeout: float | None = None,
80
- ) -> Dict[str, np.ndarray]:
81
- """Estimate metric depth and surface normals from a single RGB image.
82
-
83
- Args:
84
- image_input: RGB image as file path, URL, PIL Image, or numpy array.
85
- timeout: Optional timeout in seconds for the HTTP request.
86
-
87
- Returns:
88
- Dict with keys:
89
- - ``"depth"``: (H, W) float32 array, metric depth in metres.
90
- - ``"normals"``: (H, W, 3) float32 array, unit surface normals.
91
- """
92
- return super().run(
93
- image_input=image_input,
94
- timeout=timeout,
95
- )