Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ removed no sooner than the next major (see `docs/API_STABILITY.md`).

### Added

- **AI-ready image and vision foundation (Epics 75–79)**: mutable ROI views,
planar/interleaved layouts and color metadata in `spatialrust-image`; new
feature-gated `spatialrust-vision` preprocessing, warp, detection, mask/RLE,
and dense spatial-map APIs; camera/point-cloud/pipeline bridges; Python/NumPy
bindings and stubs; property tests, OpenCV comparison, Criterion benchmark,
and an end-to-end vision-to-point-cloud demo.

- **OpenCV-oriented image/camera foundation**: new `spatialrust-image` typed
packed buffers and strided zero-copy views; new `spatialrust-camera` pinhole
projection/unprojection, Brown–Conrady distortion, aligned depth/RGB-D to
XYZ/XYZRGB conversion, Python binding, MVP integration test, Criterion bench,
synthetic demo, and OpenCV `rgbd.depthTo3d` comparison harness.

- **GPU Euclidean clustering** (`segment-euclidean-gpu`): WGSL uniform-grid label
propagation, `GpuEuclideanClusterExtractor`,
`EuclideanClusterExtractor::extract_with_policy`, MVP `cluster_policy`, and CLI
Expand Down
7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ members = [
"crates/spatialrust-metrics",
"crates/spatialrust-transform",
"crates/spatialrust-voxelize",
"crates/spatialrust-image",
"crates/spatialrust-camera",
"crates/spatialrust-vision",
]
# spatialrust-py is a PyO3 cdylib built with maturin; keep it out of the Rust
# workspace so `cargo test --workspace` does not require a Python toolchain.
Expand Down Expand Up @@ -43,6 +46,9 @@ spatialrust-pipeline = { path = "crates/spatialrust-pipeline", version = "1.0.0"
spatialrust-metrics = { path = "crates/spatialrust-metrics", version = "1.0.0", default-features = false }
spatialrust-transform = { path = "crates/spatialrust-transform", version = "1.0.0", default-features = false }
spatialrust-voxelize = { path = "crates/spatialrust-voxelize", version = "1.0.0", default-features = false }
spatialrust-image = { path = "crates/spatialrust-image", version = "1.0.0" }
spatialrust-camera = { path = "crates/spatialrust-camera", version = "1.0.0" }
spatialrust-vision = { path = "crates/spatialrust-vision", version = "1.0.0", default-features = false }

bytemuck = { version = "1", features = ["derive"] }
las = "0.9"
Expand All @@ -56,6 +62,7 @@ serde = { version = "1", features = ["derive"] }
thiserror = "2"
wgpu = "24"
criterion = { version = "0.5", features = ["html_reports"] }
proptest = "1"

[profile.release]
lto = "thin"
Expand Down
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ MVP pipeline is implemented end-to-end: PCD/PLY/LAS/COPC IO, voxel downsampling

## Workspace crates

One dataflow, eleven crates — each pipeline stage maps to the crate that implements it, all sitting on a small math/core/search foundation:
One dataflow, focused crates — each pipeline stage maps to the crate that implements it, all sitting on a small math/core/search foundation:

<p align="center">
<img src="docs/assets/architecture.svg" alt="SpatialRust architecture: Load → Voxel → Normals → Plane → Cluster → Register → Save dataflow with implementing crates, wgpu voxel acceleration, and the core/math/search foundation" width="960">
Expand All @@ -156,6 +156,9 @@ One dataflow, eleven crates — each pipeline stage maps to the crate that imple
| `spatialrust` | Meta crate / stable re-exports |
| `spatialrust-core` | Point schema, metadata, execution traits |
| `spatialrust-math` | Vec/Mat/Pose math primitives |
| `spatialrust-image` | Typed image buffers and zero-copy strided views |
| `spatialrust-camera` | Pinhole/Brown–Conrady camera models and RGB-D conversion |
| `spatialrust-vision` | Resize/preprocess, warps, detection postprocess, masks, and dense spatial maps |
| `spatialrust-io` | Point cloud readers/writers (PCD, PLY, LAS, COPC) |
| `spatialrust-search` | KD-tree search, k-NN / radius graphs |
| `spatialrust-filtering` | Voxel / FPS downsample, outlier removal, crop, MLS |
Expand Down Expand Up @@ -184,6 +187,39 @@ labels = result.labels() # (N,) int32 cluster ids
sr.write("labeled.las", result.output) # LAS/PCD/PLY/COPC by extension
```

Aligned RGB-D images feed the same point-cloud pipeline without an OpenCV
runtime dependency:

```python
depth = np.ones((480, 640), dtype=np.float32)
rgb = np.zeros((480, 640, 3), dtype=np.uint8)
cloud = sr.rgbd_to_point_cloud(
depth, rgb, fx=525.0, fy=525.0, cx=319.5, cy=239.5
)
result = sr.run_pipeline(cloud, leaf_size=0.03)
```

Rust users enable `camera-rgbd`; projection/unprojection supports optional
Brown–Conrady radial and tangential distortion. The reproducible numerical and
timing comparison against OpenCV is under `bench/opencv_rgbd_comparison/`.

The `vision-full` feature adds an AI-ready CPU image path with explicit data
ownership: nearest/bilinear/bicubic/area resize, letterbox and CHW normalization,
color conversion, remap/warps, IoU/NMS/Soft-NMS, connected components, contours,
RLE masks, and depth/confidence/flow/point maps. Dense maps bridge explicitly to
calibrated cameras and point clouds; no API performs a hidden device transfer.

```python
model_image, transform = sr.letterbox_image(rgb, 640, 640)
chw = sr.normalize_image_chw(model_image) # float32 (3,H,W)
keep = sr.nms(boxes_xyxy, scores, iou_threshold=0.5)
cloud = sr.point_map_to_point_cloud(points, confidence, 0.5)
```

The reproducible algorithm comparison is in
`bench/opencv_vision_comparison/`; the complete synthetic demo is
`crates/spatialrust-py/examples/vision_ai_pipeline.py`.

<p align="center">
<img src="docs/assets/python_segmentation.png" alt="Top-down view of clusters segmented from the public PCL table_scene_lms400 point cloud via a single Python run_pipeline() call" width="540">
</p>
Expand Down
17 changes: 17 additions & 0 deletions bench/opencv_rgbd_comparison/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# OpenCV RGB-D comparison

This harness compares SpatialRust `rgbd_to_point_cloud` against OpenCV's
`cv.rgbd.depthTo3d` on the same deterministic depth image and intrinsics.

The OpenCV wheel must include the contrib `rgbd` module:

```bash
pip install maturin numpy opencv-contrib-python
cd crates/spatialrust-py
maturin develop --release
cd ../..
python bench/opencv_rgbd_comparison/run.py
```

The command exits non-zero when valid-point masks differ or maximum XYZ error
exceeds `1e-5` meters. It also reports median runtime for both implementations.
58 changes: 58 additions & 0 deletions bench/opencv_rgbd_comparison/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Numerical and timing comparison with OpenCV rgbd.depthTo3d."""

from __future__ import annotations

import statistics
import time

import cv2
import numpy as np
import spatialrust as sr


def timed(call, repeats: int = 20):
values = []
result = None
for _ in range(repeats):
start = time.perf_counter()
result = call()
values.append(time.perf_counter() - start)
return result, statistics.median(values)


def main() -> None:
if not hasattr(cv2, "rgbd"):
raise RuntimeError("OpenCV rgbd module missing; install opencv-contrib-python")

height, width = 240, 320
yy, xx = np.mgrid[:height, :width]
depth = (1.0 + xx * 0.001 + yy * 0.0005).astype(np.float32)
depth[::31, ::29] = np.nan
color = np.empty((height, width, 3), dtype=np.uint8)
color[..., 0] = xx % 256
color[..., 1] = yy % 256
color[..., 2] = 127
fx, fy, cx, cy = 280.0, 282.0, 159.5, 119.5
intrinsics = np.array([[fx, 0.0, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]])

cv_points, cv_seconds = timed(lambda: cv2.rgbd.depthTo3d(depth, intrinsics))
sr_cloud, sr_seconds = timed(
lambda: sr.rgbd_to_point_cloud(depth, color, fx, fy, cx, cy)
)
mask = np.isfinite(depth) & (depth > 0)
expected = cv_points[mask].astype(np.float32)
actual = sr_cloud.xyz()
if expected.shape != actual.shape:
raise AssertionError(f"shape mismatch: OpenCV={expected.shape}, SpatialRust={actual.shape}")
max_error = float(np.max(np.abs(expected - actual)))
if max_error > 1e-5:
raise AssertionError(f"maximum XYZ error {max_error:.3e} exceeds 1e-5 m")

print(f"points: {len(actual)}")
print(f"max XYZ error: {max_error:.3e} m")
print(f"SpatialRust median: {sr_seconds * 1e3:.3f} ms")
print(f"OpenCV median: {cv_seconds * 1e3:.3f} ms")


if __name__ == "__main__":
main()
16 changes: 16 additions & 0 deletions bench/opencv_vision_comparison/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# OpenCV vision comparison

This deterministic harness compares SpatialRust's Python-visible CPU vision
primitives with OpenCV: four resize filters, RGB-to-gray/HSV conversion,
bilinear remap, NMS, and connected-component areas.

From the repository root, after installing the editable Python extension:

```powershell
$env:PYTHONPATH=(Resolve-Path '.\.venv\Lib\site-packages').Path
python bench\opencv_vision_comparison\run.py
```

The command prints a JSON report and exits non-zero when a documented numerical
tolerance is exceeded. OpenCV is comparison/test tooling only; it is not a Rust
runtime dependency.
109 changes: 109 additions & 0 deletions bench/opencv_vision_comparison/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Numerically compare SpatialRust vision primitives with OpenCV.

Run after `maturin develop` so the native `spatialrust` module is importable.
The script exits non-zero when a compatibility tolerance is exceeded.
"""

from __future__ import annotations

import json

import cv2
import numpy as np

import spatialrust as sr


def max_abs(a: np.ndarray, b: np.ndarray) -> int:
return int(np.max(np.abs(a.astype(np.int16) - b.astype(np.int16))))


def main() -> None:
rng = np.random.default_rng(75)
image = rng.integers(0, 256, size=(73, 97, 3), dtype=np.uint8)
size = (61, 43)
results: dict[str, object] = {"opencv_version": cv2.__version__}

resize_cases = {
"nearest": getattr(cv2, "INTER_NEAREST_EXACT", cv2.INTER_NEAREST),
"bilinear": cv2.INTER_LINEAR,
"bicubic": cv2.INTER_CUBIC,
"area": cv2.INTER_AREA,
}
limits = {"nearest": 0, "bilinear": 1, "bicubic": 1, "area": 1}
for name, flag in resize_cases.items():
actual = sr.resize_image(image, size[0], size[1], interpolation=name)
expected = cv2.resize(image, size, interpolation=flag)
error = max_abs(actual, expected)
results[f"resize_{name}_max_u8_error"] = error
if error > limits[name]:
raise AssertionError(f"{name} resize error {error} > {limits[name]}")

gray = sr.rgb_to_gray_image(image)
gray_cv = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
gray_error = max_abs(gray, gray_cv)
results["rgb_to_gray_max_u8_error"] = gray_error
if gray_error > 1:
raise AssertionError(f"gray error {gray_error} > 1")

hsv = sr.rgb_to_hsv_image(image)
hsv_cv = cv2.cvtColor(image, cv2.COLOR_RGB2HSV)
hue_delta = np.abs(hsv[..., 0].astype(np.int16) - hsv_cv[..., 0].astype(np.int16))
hue_error = int(np.max(np.minimum(hue_delta, 180 - hue_delta)))
sv_error = max_abs(hsv[..., 1:], hsv_cv[..., 1:])
results["rgb_to_hsv_max_hue_error"] = hue_error
results["rgb_to_hsv_max_sv_error"] = sv_error
if hue_error > 1 or sv_error > 1:
raise AssertionError(f"HSV error exceeds tolerance: H={hue_error}, SV={sv_error}")

grid_x, grid_y = np.meshgrid(
np.arange(image.shape[1], dtype=np.float32),
np.arange(image.shape[0], dtype=np.float32),
)
map_x = grid_x + np.float32(0.3125)
map_y = grid_y - np.float32(0.1875)
remapped = sr.remap_image(image, map_x, map_y, interpolation="bilinear")
remapped_cv = cv2.remap(
image,
map_x,
map_y,
cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT,
borderValue=(0, 0, 0),
)
remap_error = max_abs(remapped, remapped_cv)
results["remap_bilinear_max_u8_error"] = remap_error
if remap_error > 1:
raise AssertionError(f"remap error {remap_error} > 1")

boxes_xyxy = np.array(
[[0, 0, 20, 20], [2, 2, 18, 18], [30, 30, 45, 45], [32, 32, 46, 46]],
dtype=np.float32,
)
scores = np.array([0.95, 0.8, 0.9, 0.7], dtype=np.float32)
actual_indices = sr.nms(boxes_xyxy, scores, 0.1, 0.5).tolist()
boxes_xywh = boxes_xyxy.copy()
boxes_xywh[:, 2:] -= boxes_xywh[:, :2]
cv_indices = cv2.dnn.NMSBoxes(boxes_xywh.tolist(), scores.tolist(), 0.1, 0.5)
expected_indices = np.asarray(cv_indices).reshape(-1).astype(int).tolist()
results["nms_indices"] = actual_indices
if actual_indices != expected_indices:
raise AssertionError(f"NMS mismatch: {actual_indices} != {expected_indices}")

mask = np.zeros((32, 40), dtype=np.uint8)
mask[2:10, 3:12] = 1
mask[16:30, 20:37] = 1
_, stats = sr.connected_components_image(mask, connectivity=8)
count_cv, _, stats_cv, _ = cv2.connectedComponentsWithStats(mask, connectivity=8)
areas = sorted(stat[1] for stat in stats)
areas_cv = sorted(int(value) for value in stats_cv[1:count_cv, cv2.CC_STAT_AREA])
results["connected_component_areas"] = areas
if areas != areas_cv:
raise AssertionError(f"component areas mismatch: {areas} != {areas_cv}")

results["status"] = "pass"
print(json.dumps(results, indent=2, sort_keys=True))


if __name__ == "__main__":
main()
25 changes: 25 additions & 0 deletions crates/spatialrust-camera/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[package]
name = "spatialrust-camera"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Camera models and RGB-D point cloud conversion for SpatialRust"

[features]
default = []

[dependencies]
spatialrust-core.workspace = true
spatialrust-image.workspace = true
spatialrust-math.workspace = true
thiserror.workspace = true

[dev-dependencies]
criterion.workspace = true

[[bench]]
name = "rgbd"
harness = false
28 changes: 28 additions & 0 deletions crates/spatialrust-camera/benches/rgbd.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use spatialrust_camera::{rgbd_to_point_cloud, CameraIntrinsics, PinholeCamera};
use spatialrust_image::Image;

fn benchmark_rgbd(c: &mut Criterion) {
let width = 640;
let height = 480;
let depth = Image::<f32, 1>::try_new(width, height, vec![2.0; width * height]).unwrap();
let color = Image::<u8, 3>::try_new(width, height, vec![127; width * height * 3]).unwrap();
let camera = PinholeCamera::new(
CameraIntrinsics::try_new(525.0, 525.0, 319.5, 239.5, width, height).unwrap(),
);

c.bench_function("rgbd_to_point_cloud_640x480", |b| {
b.iter(|| {
rgbd_to_point_cloud(
black_box(depth.view()),
black_box(color.view()),
black_box(&camera),
Default::default(),
)
.unwrap()
});
});
}

criterion_group!(benches, benchmark_rgbd);
criterion_main!(benches);
Loading
Loading