Skip to content

Commit cac87f2

Browse files
committed
linting, checks and fixes
1 parent 6a88ca7 commit cac87f2

13 files changed

Lines changed: 189 additions & 35 deletions

docs/annotation/object_mask_annotation_3d.md

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,15 @@ for ann in result.annotations:
9898

9999
## 2. Direct Python import — point cloud only (no image)
100100

101-
Omit `image_input` and the task synthesises a front-view image from the point cloud itself.
101+
When `image_input` is omitted, the task synthesises a front-view RGB image directly from the point cloud's own XYZ+RGB data and runs segmentation on that synthetic view. This covers two common situations:
102+
103+
**No image available at all** — the point cloud came from a file, a scan, or a pipeline that did not preserve the original photo. The synthesised view is the only option.
104+
105+
**Stereo source with two images** — a stereo cloud is generated from a left and right image pair, but those are two separate images taken from slightly different viewpoints. There is no single image that naturally represents the combined stereo view. In this case, let the system synthesise the view from the point cloud — the synthesised view is computed from the cloud's 3D positions and stored colours, so it does not require choosing between the two frames. See [section 5](#5-stereo-point-cloud-integration) for the full stereo workflow.
106+
107+
The synthesised image is a point-splatting projection: each point's XYZ is projected into pixel coordinates using the camera intrinsics, and its RGB colour is painted onto a canvas. For depth-estimation clouds (one point per pixel) the result is nearly identical to the original photo. For stereo clouds or scans with variable density, sparse or occluded regions produce a patchy image that may reduce detection quality compared to a real photo.
108+
109+
> **Camera intrinsics required for non-PrimeSense clouds.** The default intrinsics (`fx=525, cx=319.5, cy=239.5`) match a 640×480 PrimeSense sensor. If your point cloud was generated by a different camera (stereo rig, RealSense, etc.), pass `advanced_config` with the correct values — otherwise back-projection will not align masks with the 3D points. See [Advanced config](#advanced-config).
102110
103111
```python
104112
import open3d as o3d
@@ -167,7 +175,91 @@ for i, ann in enumerate(result.annotations):
167175

168176
---
169177

170-
## 5. REST API
178+
## 5. Stereo point cloud integration
179+
180+
Point clouds produced by [Stereo Depth](../features/stereo_depth.md) are in camera space (X right, Y down, Z forward, origin at the left camera), which is exactly what this task expects. To annotate a stereo cloud correctly:
181+
182+
- **Always pass the stereo camera intrinsics** via `advanced_config`. The default values are for a PrimeSense sensor and will not produce back-projection that matches any other stereo rig.
183+
- **Do not pass `image_input`** — a stereo cloud comes from two images taken at slightly different viewpoints and there is no single image that represents the combined view. Leave `image_input` unset and the system will synthesise the segmentation image directly from the point cloud's stored colours.
184+
- **Do not centroid-shift the point cloud** before passing it in. The PLY viewer handles visual centering in JavaScript; shifting the cloud in Python breaks the Z > 0 requirement that back-projection depends on.
185+
186+
```python
187+
import open3d as o3d
188+
from vizion3d.annotation import ObjectMaskAnnotation3D, ObjectMaskAnnotation3DCommand
189+
from vizion3d.annotation.models import ObjectMaskAnnotation3DConfig
190+
191+
pcd = o3d.io.read_point_cloud("stereo_result.ply")
192+
193+
# Intrinsics must match the stereo rig used to generate the cloud.
194+
# Read these from your calib.txt: cam0=[fx 0 cx; 0 fy cy; 0 0 1]
195+
stereo_cfg = ObjectMaskAnnotation3DConfig(
196+
fx=1733.74,
197+
fy=1733.74,
198+
cx=792.27,
199+
cy=541.89,
200+
)
201+
202+
result = ObjectMaskAnnotation3D().run(
203+
ObjectMaskAnnotation3DCommand(
204+
point_cloud=pcd,
205+
return_annotated_cloud=True,
206+
advanced_config=stereo_cfg,
207+
)
208+
)
209+
210+
for ann in result.annotations:
211+
print(f"{ann.label:20s} conf={ann.confidence:.2f} 3D points={len(ann.point_indices)}")
212+
213+
o3d.io.write_point_cloud("annotated_stereo.ply", result.annotated_cloud)
214+
```
215+
216+
The stereo pipeline can also generate the point cloud and annotate it in a single script:
217+
218+
```python
219+
import open3d as o3d
220+
from vizion3d.stereo import StereoDepth, StereoDepthCommand, StereoDepthAdvancedConfig
221+
from vizion3d.annotation import ObjectMaskAnnotation3D, ObjectMaskAnnotation3DCommand
222+
from vizion3d.annotation.models import ObjectMaskAnnotation3DConfig
223+
224+
# Step 1 — stereo depth → point cloud
225+
stereo_result = StereoDepth().run(
226+
StereoDepthCommand(
227+
left_image="left.png",
228+
right_image="right.png",
229+
return_point_cloud=True,
230+
advanced_config=StereoDepthAdvancedConfig(
231+
focal_length=1733.74,
232+
cx=792.27,
233+
cy=541.89,
234+
baseline=536.62,
235+
),
236+
)
237+
)
238+
239+
# Step 2 — annotate the stereo cloud (reuse the same intrinsics)
240+
# image_input is omitted — the system synthesises the segmentation view from the cloud.
241+
annotation_result = ObjectMaskAnnotation3D().run(
242+
ObjectMaskAnnotation3DCommand(
243+
point_cloud=stereo_result.point_cloud,
244+
return_annotated_cloud=True,
245+
advanced_config=ObjectMaskAnnotation3DConfig(
246+
fx=1733.74,
247+
fy=1733.74,
248+
cx=792.27,
249+
cy=541.89,
250+
),
251+
)
252+
)
253+
254+
for ann in annotation_result.annotations:
255+
print(f"{ann.label:20s} conf={ann.confidence:.2f} 3D points={len(ann.point_indices)}")
256+
257+
o3d.io.write_point_cloud("annotated_stereo.ply", annotation_result.annotated_cloud)
258+
```
259+
260+
---
261+
262+
## 6. REST API
171263

172264
Start the server:
173265

@@ -185,7 +277,7 @@ To preload the annotation checkpoint at startup:
185277

186278
```bash
187279
uv run vizion3d-serve-rest --object_mask_annotation_3d \
188-
--annotation_model /models/yolo11n-seg.pt
280+
--annotation_model /models/yolo26l-seg.pt
189281
```
190282

191283
Send a request with `multipart/form-data`. The `image` field is optional — omit it to let the server synthesise the front view.
@@ -208,7 +300,7 @@ curl -X POST "http://localhost:8000/annotation/object-mask-annotation-3d" \
208300

209301
```json
210302
{
211-
"backend_used": "/path/to/yolo11n-seg.pt",
303+
"backend_used": "/path/to/yolo26l-seg.pt",
212304
"annotations": [
213305
{
214306
"label": "chair",
@@ -226,7 +318,7 @@ curl -X POST "http://localhost:8000/annotation/object-mask-annotation-3d" \
226318

227319
---
228320

229-
## 6. gRPC API
321+
## 7. gRPC API
230322

231323
Start the server:
232324

docs/concepts/camera_intrinsics.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ K = | 0.0 525.0 239.5 |
159159
| 0.0 0.0 1.0 |
160160
```
161161

162-
For a different camera or resolution, always supply calibrated values — wrong intrinsics produce correct topology but geometrically distorted metric scale.
162+
For a different camera or resolution, always supply calibrated values — intrinsics that do not match your camera produce correct topology but geometrically distorted metric scale.
163163

164164
See the full field reference and per-entry-point usage examples in the Advanced Config pages:
165165

docs/features/depth_estimation.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -381,8 +381,6 @@ The same config is available in the REST and gRPC entry points. See [Advanced Co
381381

382382
---
383383

384-
---
385-
386384
## Known limitations
387385

388386
- **Relative depth only** — the default monocular backend produces relative (inverse) depth, not metric depth. Point cloud distances are internally consistent but not calibrated to real-world scale without a known reference distance.

docs/features/depth_estimation_advanced_config.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ X = (u - cx) * d / fx
1616
Y = (v - cy) * d / fy
1717
```
1818

19-
All four intrinsic parameters — `fx`, `fy`, `cx`, `cy` — appear in this formula. Getting them wrong produces a point cloud that is geometrically distorted: correct topology but wrong angles, skewed shapes, or objects that appear compressed or stretched.
19+
All four intrinsic parameters — `fx`, `fy`, `cx`, `cy` — appear in this formula. Values that do not match your camera produce a point cloud that is geometrically distorted: correct topology but skewed angles, compressed shapes, or stretched geometry.
2020

2121
---
2222

@@ -44,7 +44,7 @@ fx = (image_width / 2) / tan(FoV_h / 2)
4444

4545
The vertical focal length in pixels. For cameras with square pixels, `fy ≈ fx`. Cameras with non-square sensors may have `fy ≠ fx`.
4646

47-
**Effect on the point cloud:** Controls vertical spread analogously to `fx`. Incorrect `fy` produces vertically compressed or stretched geometry.
47+
**Effect on the point cloud:** Controls vertical spread analogously to `fx`. A `fy` that does not match your sensor produces vertically compressed or stretched geometry.
4848

4949
**How to find it:** `K[1][1]` from the calibration matrix, or:
5050

@@ -60,7 +60,7 @@ fy = (image_height / 2) / tan(FoV_v / 2)
6060

6161
The horizontal image coordinate of the optical axis — ideally the exact centre of the sensor. For a 640-wide image the ideal value is `319.5`; for a 1920-wide image it is typically near `959.5`.
6262

63-
**Effect on the point cloud:** Shifts the entire point cloud left or right. A wrong `cx` makes the scene appear to be viewed from an off-centre vantage point, introducing a lateral tilt.
63+
**Effect on the point cloud:** Shifts the entire point cloud left or right. A `cx` that does not match your sensor makes the scene appear viewed from an off-centre vantage point, introducing a lateral tilt.
6464

6565
---
6666

@@ -70,7 +70,7 @@ The horizontal image coordinate of the optical axis — ideally the exact centre
7070

7171
The vertical image coordinate of the optical axis. For a 480-tall image the ideal value is `239.5`.
7272

73-
**Effect on the point cloud:** Shifts the entire point cloud up or down. Like `cx`, an incorrect value introduces a tilt — vertical in this case.
73+
**Effect on the point cloud:** Shifts the entire point cloud up or down. Like `cx`, a value that does not match your sensor introduces a tilt — vertical in this case.
7474

7575
---
7676

docs/features/depth_estimation_vs_stereo_depth.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,19 @@ Provided the camera calibration is accurate, the output is **real metric depth i
2828
|---|---|---|
2929
| **Input** | Rectified left + right image pair | Single RGB image |
3030
| **Depth type** | Metric (real metres) | Relative (inverse depth, arbitrary scale) |
31-
| **Coordinate system** | Camera space | Camera space |
32-
| **Units** | Metres (real) | Metres (fictitious — mapped to `[0, depth_trunc]`) |
31+
| **Coordinate system** | Camera space (X right, Y down, Z forward) | Camera space (X right, Y down, Z forward) |
32+
| **Z ordering** | Near objects have smaller Z | Near objects have larger Z — relative depth only, not physical ordering |
33+
| **Units** | Metres (real) | Metres (relative, mapped to `[0, depth_trunc]`) |
3334
| **Object at 2.4 m reads as 2.4 m** | Yes — if calibration is correct | No — depends on scene content |
34-
| **Scale factor to world** | 1.0 (accurate) | Unknown, scene-dependent |
35-
| **`point_cloud_scale` field** | 1.0 (accurate) | 1.0 (misleading — not real metres) |
35+
| **Scale factor to world** | 1.0 (real) | Unknown, scene-dependent |
36+
| **`point_cloud_scale` field** | 1.0 (real metres) | 1.0 (relative, not real metres) |
3637
| **Shape / topology correct** | Yes | Yes, if correct intrinsics supplied via `DepthEstimationAdvanceConfig` |
3738
| **Camera calibration needed** | Yes — `focal_length`, `baseline`, `cx`, `cy` | Optional — only affects point cloud geometry |
39+
| **Compatible with annotation task** || ✅ back-projection is self-consistent |
40+
| **Compatible with other 3D tools** | ✅ registration, reconstruction, metric tools | ⚠️ Z ordering is relative — not directly interoperable with metric clouds |
41+
| **Output format** | Open3D `PointCloud` | Open3D `PointCloud` |
42+
| **PLY export** |||
43+
| **MPS inference** | float32 | float32 |
3844
| **Input requirements** | Stereo rig, rectified images | Any single photo |
3945
| **Depth completeness** | Gaps in occluded / textureless regions | Dense — every pixel has a prediction |
4046
| **Runtime** | Moderate (transformer-based matching) | Moderate (ViT-based encoder-decoder) |
@@ -78,7 +84,7 @@ result = DepthEstimation().run(
7884
points = np.asarray(result.point_cloud.points) # shape (N, 3)
7985
# point_cloud_scale == 1.0, but distances are NOT real metres —
8086
# the depth model output is relative and mapped to depth_trunc.
81-
print(f"point_cloud_scale: {result.point_cloud_scale}") # 1.0 (misleading)
87+
print(f"point_cloud_scale: {result.point_cloud_scale}") # 1.0 (relative, not real metres)
8288
```
8389

8490
### Stereo point cloud (Stereo Depth)
@@ -113,10 +119,10 @@ print(f"point_cloud_scale: {result.point_cloud_scale}") # 1.0 (accurate)
113119

114120
| Output field | Depth Estimation | Stereo Depth |
115121
|---|---|---|
116-
| `depth_map` | Relative depth (fictitious metres) | Metric depth (real metres) |
122+
| `depth_map` | Relative depth (not real metres) | Metric depth (real metres) |
117123
| `disparity_map` | Not present | Pixel disparity (always returned) |
118124
| `min_depth` / `max_depth` | Relative range | Real range in metres |
119-
| `point_cloud_scale` | 1.0 (misleading) | 1.0 (accurate) |
125+
| `point_cloud_scale` | 1.0 (relative, not real metres) | 1.0 (real metres) |
120126
| `backend_used` | Local path to Depth Anything V2 `.pth` | Local path to S2M2 `.pth` |
121127

122128
---

docs/features/stereo_depth.md

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -468,8 +468,55 @@ cfg = StereoDepthAdvancedConfig(
468468

469469
---
470470

471+
## 3D annotation from a stereo cloud
472+
473+
A stereo point cloud is in camera space (Z = metric depth, origin at the left camera), making it directly compatible with [Object Mask Annotation 3D](../annotation/object_mask_annotation_3d.md). Pass the same intrinsics you used for stereo depth. Do not pass `image_input` — the annotation task synthesises the segmentation image from the point cloud's stored colours, which avoids having to pick between the left and right frames.
474+
475+
```python
476+
import open3d as o3d
477+
from vizion3d.stereo import StereoDepth, StereoDepthCommand, StereoDepthAdvancedConfig
478+
from vizion3d.annotation import ObjectMaskAnnotation3D, ObjectMaskAnnotation3DCommand
479+
from vizion3d.annotation.models import ObjectMaskAnnotation3DConfig
480+
481+
stereo_result = StereoDepth().run(
482+
StereoDepthCommand(
483+
left_image="left.png",
484+
right_image="right.png",
485+
return_point_cloud=True,
486+
advanced_config=StereoDepthAdvancedConfig(
487+
focal_length=1733.74,
488+
cx=792.27,
489+
cy=541.89,
490+
baseline=536.62,
491+
),
492+
)
493+
)
494+
495+
annotation_result = ObjectMaskAnnotation3D().run(
496+
ObjectMaskAnnotation3DCommand(
497+
point_cloud=stereo_result.point_cloud,
498+
return_annotated_cloud=True,
499+
advanced_config=ObjectMaskAnnotation3DConfig(
500+
fx=1733.74,
501+
fy=1733.74,
502+
cx=792.27,
503+
cy=541.89,
504+
),
505+
)
506+
)
507+
508+
for ann in annotation_result.annotations:
509+
print(f"{ann.label:20s} conf={ann.confidence:.2f} 3D points={len(ann.point_indices)}")
510+
511+
o3d.io.write_point_cloud("annotated.ply", annotation_result.annotated_cloud)
512+
```
513+
514+
See [Object Mask Annotation 3D — Stereo integration](../annotation/object_mask_annotation_3d.md#5-stereo-point-cloud-integration) for the full walkthrough.
515+
516+
---
517+
471518
## Known limitations
472519

473-
- **Rectified pairs required** — images must be stereo-rectified so corresponding points lie on the same horizontal scanline. Un-rectified pairs will produce incorrect results.
474-
- **Metric scale depends on calibration** — an incorrect `baseline` or `focal_length` scales all depth values uniformly. Always use calibrated values for real applications.
520+
- **Rectified pairs required** — images must be stereo-rectified so corresponding points lie on the same horizontal scanline. Un-rectified pairs will not produce reliable results.
521+
- **Metric scale depends on calibration** — an inaccurate `baseline` or `focal_length` scales all depth values uniformly. Always use calibrated values for real applications.
475522
- **Python 3.12 required for Open3D**`return_depth_image` and `return_point_cloud` require Open3D, which currently only supports Python 3.12 in this project.

tests/integration/test_object_mask_annotation_3d_rest.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ def _run_group(model_backend, indoor_image_bytes, indoor_point_cloud_ply,
7070
ENDPOINT,
7171
files={
7272
"image": ("scene.jpg", indoor_image_bytes, "image/jpeg"),
73-
"point_cloud_ply": ("cloud.ply", indoor_point_cloud_ply, "application/octet-stream"),
73+
"point_cloud_ply": (
74+
"cloud.ply", indoor_point_cloud_ply, "application/octet-stream"
75+
),
7476
},
7577
data={"model_backend": model_backend, "return_annotated_cloud": "true"},
7678
)

tests/unit/test_object_mask_annotation_3d_config.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@
1313
from PIL import Image
1414

1515
from vizion3d.annotation.commands import ObjectMaskAnnotation3DCommand
16-
from vizion3d.annotation.handlers import ObjectMaskAnnotation3DHandler
17-
from vizion3d.annotation.models import ObjectMaskAnnotation3DConfig, ObjectMaskAnnotation3DResult
1816
from vizion3d.annotation.defaults import (
1917
DEFAULT_ANNOTATION_MODEL_FILENAME,
2018
DEFAULT_ANNOTATION_MODEL_URL,
2119
)
20+
from vizion3d.annotation.handlers import ObjectMaskAnnotation3DHandler
21+
from vizion3d.annotation.models import ObjectMaskAnnotation3DConfig, ObjectMaskAnnotation3DResult
2222

2323
o3d = pytest.importorskip("open3d", reason="open3d required")
2424

@@ -116,9 +116,10 @@ def _fake(self_inner, model_path, image, cfg):
116116

117117
class TestRESTConfigParsing:
118118
def test_rest_default_config_accepted(self, dummy_image_bytes, small_point_cloud):
119+
from fastapi.testclient import TestClient
120+
119121
from vizion3d.lifting.utils import create_ply_binary
120122
from vizion3d.server.rest.app import app
121-
from fastapi.testclient import TestClient
122123

123124
pts = np.asarray(small_point_cloud.points).astype(np.float32)
124125
cols = (np.asarray(small_point_cloud.colors) * 255).astype(np.uint8)
@@ -145,9 +146,9 @@ def _fake_run(*args, **kwargs):
145146

146147
class TestGRPCConfigUnmarshalling:
147148
def test_grpc_default_config(self):
149+
from vizion3d.lifting.utils import create_ply_binary
148150
from vizion3d.proto import lifting_pb2
149151
from vizion3d.server.grpc.server import LiftingServiceServicer
150-
from vizion3d.lifting.utils import create_ply_binary
151152

152153
pts = np.array([[0.0, 0.0, 1.0]], dtype=np.float32)
153154
cols = np.array([[128, 128, 128]], dtype=np.uint8)
@@ -171,9 +172,9 @@ def _fake(self_inner, cmd):
171172
assert received[0].conf_threshold == pytest.approx(0.25)
172173

173174
def test_grpc_custom_config_applied(self):
175+
from vizion3d.lifting.utils import create_ply_binary
174176
from vizion3d.proto import lifting_pb2
175177
from vizion3d.server.grpc.server import LiftingServiceServicer
176-
from vizion3d.lifting.utils import create_ply_binary
177178

178179
pts = np.array([[0.0, 0.0, 1.0]], dtype=np.float32)
179180
ply_bytes = create_ply_binary(pts, np.array([[128, 128, 128]], dtype=np.uint8))

tests/unit/test_object_mask_annotation_3d_handler.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,16 @@ def test_out_of_image_filtered_out(self):
122122

123123

124124
class TestFrontViewSynthesis:
125-
def test_canvas_size_derived_from_intrinsics(self, small_point_cloud):
125+
def test_canvas_size_derived_from_projected_extent(self, small_point_cloud):
126126
pts, cols = _extract_cloud_arrays(small_point_cloud)
127127
cfg = ObjectMaskAnnotation3DConfig(fx=100.0, fy=100.0, cx=32.0, cy=24.0)
128128
img = _render_front_view(pts, cols, cfg)
129-
assert img.width == int(cfg.cx * 2) + 1
130-
assert img.height == int(cfg.cy * 2) + 1
129+
X, Y, Z = pts[:, 0], pts[:, 1], pts[:, 2]
130+
valid = Z > 0
131+
u = np.round(cfg.fx * X[valid] / Z[valid] + cfg.cx).astype(np.int32)
132+
v = np.round(cfg.fy * Y[valid] / Z[valid] + cfg.cy).astype(np.int32)
133+
assert img.width == max(int(u.max()) + 1, 1)
134+
assert img.height == max(int(v.max()) + 1, 1)
131135

132136
def test_returns_pil_image(self, small_point_cloud):
133137
pts, cols = _extract_cloud_arrays(small_point_cloud)

vizion3d/lifting/handlers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,9 @@ def _run_depth_anything_checkpoint(self, model_path: str, image: Image.Image) ->
185185
device_type = device if isinstance(device, str) else device.type
186186
if device_type == "cuda":
187187
autocast_ctx = torch.amp.autocast(device_type="cuda", dtype=torch.float16, enabled=True)
188-
elif device_type == "mps":
189-
autocast_ctx = torch.amp.autocast(device_type="mps", dtype=torch.float16, enabled=True)
190188
else:
189+
# MPS float16 can produce degraded depth maps due to limited precision
190+
# in transformer attention operations. CPU has no autocast benefit.
191191
autocast_ctx = contextlib.nullcontext()
192192

193193
with torch.inference_mode(), autocast_ctx:

0 commit comments

Comments
 (0)