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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,26 @@ All profiles exactly matched OpenCV's kept-index order; updated float32 scores
stayed within `1.79e-7`. See the [Soft-NMS harness](bench/opencv_soft_nms_comparison/)
and dated [receipt](notes/2026-07-15_soft_nms_opencv_acceleration.md).

Connected-component labeling uses horizontal runs plus union-find instead of
per-pixel flood fill. Packed NumPy masks are borrowed without an input copy,
and all non-zero `uint8` values are foreground, matching OpenCV. Against
OpenCV 4.13's explicit row-major SAUF algorithm on structured masks:

| Profile | Pattern | OpenCV SAUF | SpatialRust | Result |
| --- | --- | ---: | ---: | ---: |
| VGA | Segmentation blobs | 1.284 ms | 0.413 ms | **SpatialRust 3.11×** |
| VGA | Document lines | 1.271 ms | 0.352 ms | **SpatialRust 3.61×** |
| 1080p | Segmentation blobs | 6.763 ms | 2.815 ms | **SpatialRust 2.40×** |
| 1080p | Document lines | 6.649 ms | 2.407 ms | **SpatialRust 2.76×** |
| 4K | Segmentation blobs | 21.356 ms | 9.838 ms | **SpatialRust 2.17×** |
| 4K | Document lines | 21.075 ms | 8.606 ms | **SpatialRust 2.45×** |

Labels, areas, and bounding boxes matched exactly on every canonical profile
and 320 additional seeded randomized 4/8-connectivity cases. The speed claim is
limited to the named structured masks; dense random noise still favors OpenCV.
See the [connected-components harness](bench/opencv_connected_components_comparison/)
and dated [receipt](notes/2026-07-15_connected_components_opencv_acceleration.md).

#### Vision accuracy

The same deterministic RGB inputs passed all VGA, 1080p, and 4K gates:
Expand All @@ -236,6 +256,7 @@ The same deterministic RGB inputs passed all VGA, 1080p, and 4K gates:
| Morphology open 5×5 | Exact pixels (max error 0) |
| Canny | Precision, recall, F1, and IoU all 1.0 |
| Exact Euclidean distance transform | Exact values on canonical profiles; separate irregular-mask max float error `9.54e-7` |
| Connected components (SAUF ordering) | Exact labels, areas, and bounding boxes on structured profiles and 320 randomized cases |

The broader correctness harness also checks filters, analysis, keypoints,
matching, and geometry with documented tolerances (exact pixels where we claim
Expand Down
2 changes: 2 additions & 0 deletions bench/opencv_comparison/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ VGA, 1080p, and 4K profiles and the initial competitive workload set:
11. AI preprocessing
12. RGB-D to voxel end-to-end
13. detection NMS, class-aware batched NMS, and Soft-NMS post-processing
14. connected components on structured segmentation and document masks

Exact matches use a JSON `null` PSNR (mathematically infinite) so reports remain
strict RFC-compatible JSON. Numerical comparisons retain max/mean/RMS and
Expand All @@ -53,6 +54,7 @@ python bench\opencv_comparison\run.py
python bench\opencv_nms_comparison\performance.py
python bench\opencv_batched_nms_comparison\performance.py
python bench\opencv_soft_nms_comparison\performance.py
python bench\opencv_connected_components_comparison\performance.py
```

Reports are written under `target/opencv-comparison/`. Run one suite with
Expand Down
1 change: 1 addition & 0 deletions bench/opencv_comparison/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
{ "id": "canny", "domain": "imgproc", "modes": ["allocate"] },
{ "id": "morphology_open", "domain": "imgproc", "modes": ["allocate"] },
{ "id": "distance_transform_edt", "domain": "imgproc", "modes": ["allocate"] },
{ "id": "connected_components", "domain": "imgproc", "modes": ["structured-mask"] },
{ "id": "orb", "domain": "feature2d", "modes": ["allocate"] },
{ "id": "stereo_bm", "domain": "calib3d", "modes": ["allocate"] },
{ "id": "pinhole_calibration", "domain": "calib3d", "modes": ["allocate"] },
Expand Down
1 change: 1 addition & 0 deletions bench/opencv_comparison/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ def test_manifest_reserves_representative_profiles_and_workloads(self) -> None:
self.assertIn("nms", workloads)
self.assertIn("batched_nms", workloads)
self.assertIn("soft_nms", workloads)
self.assertIn("connected_components", workloads)
self.assertIn("coefficient_of_variation", statistics)
self.assertIn("median_absolute_deviation", statistics)
self.assertIn("batch_size", statistics)
Expand Down
17 changes: 17 additions & 0 deletions bench/opencv_connected_components_comparison/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# OpenCV connected-components comparison

This harness compares SpatialRust connected-component labeling with OpenCV's
explicit SAUF implementation on structured segmentation-blob and document-line
masks. SAUF is selected because OpenCV documents it as the algorithm that
guarantees row-major label ordering, matching SpatialRust's public contract.

```powershell
python bench/opencv_connected_components_comparison/performance.py `
--output target/opencv-connected-components-performance.json
```

Labels, component areas, and half-open bounding boxes must match exactly before
timings are published. The report follows `spatialrust.opencv-comparison.v1`
and records raw samples, dispersion, library versions, OpenCV thread policy,
and the host environment. Calls are batched to at least 20 ms to stabilize
allocator-heavy short profiles. Results are scoped to the named structured masks.
194 changes: 194 additions & 0 deletions bench/opencv_connected_components_comparison/performance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"""Reproducible structured-mask connected-components comparison with OpenCV."""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

import cv2
import numpy as np
import spatialrust as sr

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from opencv_comparison.report import emit_report, environment, make_report, timed_pair


PROFILES = {
"vga": (640, 480, 40),
"1080p": (1920, 1080, 30),
"4k": (3840, 2160, 20),
}
PATTERNS = ("segmentation_blobs", "document_lines")


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path)
parser.add_argument("--profiles", default=",".join(PROFILES))
parser.add_argument("--patterns", default=",".join(PATTERNS))
parser.add_argument("--warmup", type=int, default=8)
return parser.parse_args()


def make_mask(width: int, height: int, pattern: str) -> np.ndarray:
x = np.arange(width, dtype=np.int32)[None, :]
y = np.arange(height, dtype=np.int32)[:, None]
if pattern == "segmentation_blobs":
foreground = (x % 97 < 23) & (y % 83 < 19)
elif pattern == "document_lines":
foreground = (y % 32 < 3) & (x % 211 > 8) & (x % 211 < 190)
else:
raise ValueError(f"unknown pattern: {pattern}")
return foreground.astype(np.uint8) * np.uint8(255)


def assert_sauf_compatible(
mask: np.ndarray, connectivity: int, context: str
) -> tuple[int, np.ndarray, np.ndarray]:
count, expected_labels, expected_stats, _ = (
cv2.connectedComponentsWithStatsWithAlgorithm(
mask, connectivity, cv2.CV_32S, cv2.CCL_SAUF
)
)
actual_labels, actual_stats = sr.connected_components_image(
mask, connectivity=connectivity
)
labels_exact = bool(np.array_equal(actual_labels, expected_labels))
actual_areas = np.asarray([value[1] for value in actual_stats], dtype=np.int64)
expected_areas = expected_stats[1:count, cv2.CC_STAT_AREA].astype(np.int64)
areas_exact = bool(np.array_equal(actual_areas, expected_areas))
actual_boxes = np.asarray([value[2] for value in actual_stats], dtype=np.float64)
expected_boxes = expected_stats[1:count, :4].astype(np.float64)
expected_boxes[:, 2] += expected_boxes[:, 0]
expected_boxes[:, 3] += expected_boxes[:, 1]
boxes_exact = bool(np.array_equal(actual_boxes, expected_boxes))
if not labels_exact or not areas_exact or not boxes_exact:
raise AssertionError(
f"{context} mismatch: labels={labels_exact}, "
f"areas={areas_exact}, boxes={boxes_exact}"
)
return count, expected_labels, expected_stats


def validate_randomized_cases(cases_per_connectivity: int = 160) -> int:
rng = np.random.default_rng(20_260_715)
checked = 0
for connectivity in (4, 8):
for case in range(cases_per_connectivity):
height = int(rng.integers(1, 90))
width = int(rng.integers(1, 120))
if case % 4 == 0:
density = float(rng.uniform(0.01, 0.8))
mask = (rng.random((height, width)) < density).astype(np.uint8) * 255
else:
mask = np.zeros((height, width), dtype=np.uint8)
for _ in range(int(rng.integers(1, 25))):
y0 = int(rng.integers(height))
y1 = int(rng.integers(y0 + 1, height + 1))
x0 = int(rng.integers(width))
x1 = int(rng.integers(x0 + 1, width + 1))
mask[y0:y1, x0:x1] = int(rng.integers(1, 256))
assert_sauf_compatible(mask, connectivity, f"random/{connectivity}/{case}")
checked += 1
return checked


def main() -> None:
args = parse_args()
profiles = [name.strip() for name in args.profiles.split(",") if name.strip()]
patterns = [name.strip() for name in args.patterns.split(",") if name.strip()]
unknown_profiles = sorted(set(profiles) - PROFILES.keys())
unknown_patterns = sorted(set(patterns) - set(PATTERNS))
if unknown_profiles:
raise ValueError(f"unknown profiles: {', '.join(unknown_profiles)}")
if unknown_patterns:
raise ValueError(f"unknown patterns: {', '.join(unknown_patterns)}")
if args.warmup < 0:
raise ValueError("warmup must be non-negative")
if not hasattr(cv2, "connectedComponentsWithStatsWithAlgorithm"):
raise RuntimeError("OpenCV build does not expose explicit CCL algorithms")
if hasattr(cv2, "ocl"):
cv2.ocl.setUseOpenCL(False)

randomized_cases = validate_randomized_cases()

results: dict[str, object] = {}
for profile in profiles:
width, height, repeats = PROFILES[profile]
profile_results: dict[str, object] = {}
for pattern in patterns:
mask = make_mask(width, height, pattern)

def opencv_components() -> tuple[object, ...]:
return cv2.connectedComponentsWithStatsWithAlgorithm(
mask, 8, cv2.CV_32S, cv2.CCL_SAUF
)

def spatialrust_components() -> tuple[object, ...]:
return sr.connected_components_image(mask, connectivity=8)

count, _, _ = assert_sauf_compatible(mask, 8, f"{profile}/{pattern}")
labels_exact = True
areas_exact = True
boxes_exact = True

_, _, opencv_timing, spatialrust_timing = timed_pair(
opencv_components,
spatialrust_components,
warmup=args.warmup,
repeats=repeats,
seed=173,
min_sample_time_ms=20.0,
)
opencv_ms = float(opencv_timing["median"])
spatialrust_ms = float(spatialrust_timing["median"])
profile_results[pattern] = {
"width": width,
"height": height,
"connectivity": 8,
"component_count": int(count - 1),
"labels_exact": labels_exact,
"areas_exact": areas_exact,
"bounding_boxes_exact": boxes_exact,
"opencv": opencv_timing,
"spatialrust": spatialrust_timing,
"spatialrust_speedup": opencv_ms / spatialrust_ms,
"faster_implementation": (
"spatialrust" if spatialrust_ms < opencv_ms else "opencv"
),
}
results[profile] = profile_results

receipt = environment(
opencv_version=cv2.__version__, spatialrust_version=sr.__version__
)
receipt["opencv_threads"] = cv2.getNumThreads()
receipt["opencv_opencl_enabled"] = bool(
hasattr(cv2, "ocl") and cv2.ocl.useOpenCL()
)
report = make_report(
suite="opencv-connected-components-performance",
kind="performance",
status="pass",
environment_receipt=receipt,
results={
"methodology": {
"timing_scope": "Python API call returning labels and foreground statistics",
"paired_interleaved": True,
"random_order_seed": 173,
"minimum_sample_time_ms": 20.0,
"opencv_algorithm": "CCL_SAUF",
"mask_values": "packed uint8; 0 background, 255 foreground",
"randomized_correctness_seed": 20_260_715,
"randomized_correctness_cases": randomized_cases,
"thread_policy": "library defaults; OpenCV thread count recorded",
},
"profiles": results,
},
)
emit_report(report, args.output)


if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion crates/spatialrust-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ reloaded = sr.read("labeled.las")
| `resize_image` / `letterbox_image` / `normalize_image_chw` | Model-ready RGB resize, padding, and float32 CHW packing |
| `rgb_to_gray_image` / `rgb_to_hsv_image` / `remap_image` | CPU color conversion and coordinate-map resampling |
| `nms` / `batched_nms` / `soft_nms` | Detection post-processing for `(N,4)` xyxy boxes, including class-aware suppression |
| `connected_components_image` / `find_mask_contours` | Binary-mask labeling and contour extraction |
| `connected_components_image` / `find_mask_contours` | Row-major 4/8-connected labeling (borrowed packed input; any non-zero byte is foreground) and contour extraction |
| `encode_mask_rle` / `decode_mask_rle` | Row-major or COCO column-major binary-mask RLE |
| `point_map_to_point_cloud` | Filter a dense `(H,W,3)` point map into a native point cloud |
| `knn_graph(cloud, k)` / `radius_graph(cloud, radius)` | PyG-style `(2, E)` `edge_index` for GNNs |
Expand Down
30 changes: 18 additions & 12 deletions crates/spatialrust-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,10 @@ use spatialrust::transform::{
use spatialrust::vision::{
adaptive_threshold as adaptive_threshold_op, approximate_polygon as approximate_contour,
batched_nms as batched_nms_op, bilateral_filter as bilateral_filter_op, canny as canny_op,
clahe as clahe_op, connected_components as label_components, decode_rle as decode_mask_runs,
detect_and_describe_orb as detect_and_describe_orb_op, detect_fast as detect_fast_op,
detect_harris as detect_harris_op, detect_shi_tomasi as detect_shi_tomasi_op,
clahe as clahe_op, connected_components_u8 as label_components_u8,
decode_rle as decode_mask_runs, detect_and_describe_orb as detect_and_describe_orb_op,
detect_fast as detect_fast_op, detect_harris as detect_harris_op,
detect_shi_tomasi as detect_shi_tomasi_op,
distance_transform_edt_u8_into as distance_transform_edt_u8_into_op,
distance_transform_edt_with_spacing as distance_transform_edt_op,
encode_rle as encode_mask_runs, equalize_histogram as equalize_histogram_op,
Expand Down Expand Up @@ -3159,15 +3160,23 @@ fn connected_components_image<'py>(
mask: PyReadonlyArray2<'_, u8>,
connectivity: u8,
) -> PyResult<(Bound<'py, PyArray2<u32>>, ComponentStats)> {
let image = gray_u8_image_from_numpy(mask)?;
let mask =
BinaryMask::try_new(image.width(), image.height(), image.into_vec()).map_err(to_py_err)?;
let view = mask.as_array();
let shape = view.shape();
let (height, width) = (shape[0], shape[1]);
let packed;
let pixels = match mask.as_slice() {
Ok(slice) => slice,
Err(_) => {
packed = view.iter().copied().collect::<Vec<_>>();
packed.as_slice()
}
};
let connectivity = match connectivity {
4 => Connectivity::Four,
8 => Connectivity::Eight,
_ => return Err(PyValueError::new_err("connectivity must be 4 or 8")),
};
let result = label_components(&mask, connectivity).map_err(to_py_err)?;
let result = label_components_u8(width, height, pixels, connectivity).map_err(to_py_err)?;
let stats = result
.components
.iter()
Expand All @@ -3184,11 +3193,8 @@ fn connected_components_image<'py>(
)
})
.collect();
let labels = Array2::from_shape_vec(
(result.labels.height(), result.labels.width()),
result.labels.as_slice().to_vec(),
)
.map_err(to_py_err)?;
let labels = Array2::from_shape_vec((height, width), result.labels.into_image().into_vec())
.map_err(to_py_err)?;
Ok((labels.into_pyarray_bound(py), stats))
}

Expand Down
6 changes: 6 additions & 0 deletions crates/spatialrust-py/tests/test_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,12 @@ def test_mask_components_contours_and_rle():
assert labels.shape == mask.shape
assert labels.dtype == np.uint32
assert sorted(stat[1] for stat in stats) == [4, 4]
mask_255 = mask * np.uint8(255)
storage = np.zeros((5, 14), dtype=np.uint8)
storage[:, ::2] = mask_255
view_labels, view_stats = sr.connected_components_image(storage[:, ::2], connectivity=4)
np.testing.assert_array_equal(view_labels, labels)
assert view_stats == stats
contours = sr.find_mask_contours(mask)
assert len(contours) == 2

Expand Down
Loading
Loading