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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ removed no sooner than the next major (see `docs/API_STABILITY.md`).

### Added

- **Allocation-light Canny (Epic 118A/118C)**: `canny()` no longer materializes
public gradient, magnitude, and suppression images only to discard them.
Added safe strided `canny_into`, reusable `CannyWorkspace`, large-image
parallel stages, Python `out=`/workspace support, and a focused bit-exact
OpenCV comparison harness.
- **Exact Euclidean distance transform**: `spatialrust-vision` now computes
foreground-to-nearest-background L2 distances in linear time, supports
anisotropic pixel spacing, exposes a NumPy binding, and includes native
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ ratio; these are machine-specific measurements, not universal guarantees.
| Morphology open 5×5, reuse[^morphology-2026] | OpenCV 60.32× | OpenCV 16.25× | OpenCV 17.78× |
| Morphology open 511×511, allocate[^morphology-2026] | OpenCV 2.10× | **SpatialRust 2.61×** | **SpatialRust 2.40×** |
| Morphology open 511×511, reuse[^morphology-2026] | OpenCV 2.46× | **SpatialRust 3.25×** | **SpatialRust 2.77×** |
| Canny | OpenCV 10.66× | OpenCV 12.54× | OpenCV 12.65× |
| Canny 3×3, reuse, document lines[^canny-2026] | OpenCV 1.77× | OpenCV 1.69× | OpenCV 1.65× |
| Canny 3×3, reuse, sensor noise[^canny-2026] | OpenCV 3.66× | OpenCV 1.92× | OpenCV 1.79× |
| Exact Euclidean distance transform, allocate | OpenCV 1.99× | OpenCV 1.85× | OpenCV 1.45× |
| Exact Euclidean distance transform, reuse | OpenCV 1.02× | OpenCV 1.06× | **SpatialRust 1.07×** |

Expand All @@ -185,6 +186,13 @@ records the exact environment and methodology.
5×5 latency by 20.7× at 1080p and 26.7× at 4K; OpenCV still leads the
standalone operation.

[^canny-2026]: The allocation-light 3×3 path keeps inspectable intermediates
opt-in, adds caller-owned output plus reusable `CannyWorkspace`, and
parallelizes magnitude, directional suppression, and packed output on large
images. The focused OpenCV 4.13 receipt is bit-exact across 300 randomized
images. OpenCV still leads both named workloads; the old 10.66×–12.65× row
described the superseded always-materialize-all-intermediates path.

[^resize-2026]: The packed RGB8 half-scale path precomputes arbitrary-scale
Q11 sampling coefficients and specializes exact 2× downsampling as a
row-parallel 2×2 average. On the OpenCV 4.13 focused receipt, caller-owned
Expand Down
15 changes: 15 additions & 0 deletions bench/opencv_canny_comparison/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Canny comparison

This focused harness compares OpenCV Canny with SpatialRust's ordinary allocated
API and its caller-owned output plus reusable `CannyWorkspace` API. Both use a
3x3 aperture, thresholds 80/160, and L2 gradient magnitude. It checks 300 seeded
random images for bit-exact parity before timing document-line and sensor-noise
profiles at VGA, 1080p, and 4K.

```powershell
.venv\Scripts\python.exe bench\opencv_canny_comparison\performance.py `
--output target\opencv-canny-performance.json
```

Results are workload- and machine-specific. The report records raw interleaved
samples, versions, thread count, OpenCL state, and caller-owned output timings.
163 changes: 163 additions & 0 deletions bench/opencv_canny_comparison/performance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""Reproducible OpenCV versus allocation-light SpatialRust Canny comparison."""

from __future__ import annotations

import argparse
import os
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, 30),
"1080p": (1920, 1080, 20),
"4k": (3840, 2160, 12),
}
PATTERNS = ("document-lines", "sensor-noise")


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=6)
return parser.parse_args()


def make_image(width: int, height: int, pattern: str, seed: int) -> np.ndarray:
if pattern == "sensor-noise":
return np.random.default_rng(seed).integers(0, 256, (height, width), dtype=np.uint8)
image = np.zeros((height, width), dtype=np.uint8)
for y in range(20, height, 80):
cv2.line(image, (10, y), (width - 11, y), 255, 2)
for x in range(60, width, 320):
cv2.rectangle(image, (x, 35), (min(width - 1, x + 90), min(height - 1, 105)), 160, 2)
return image


def validate_randomized_cases() -> int:
rng = np.random.default_rng(118)
for case in range(300):
height = int(rng.integers(1, 97))
width = int(rng.integers(1, 129))
image = rng.integers(0, 256, (height, width), dtype=np.uint8)
expected = cv2.Canny(image, 80.0, 160.0, apertureSize=3, L2gradient=True)
actual = sr.canny_image(image, 80.0, 160.0, aperture_size=3, l2_gradient=True)
if not np.array_equal(actual, expected):
raise AssertionError(f"random Canny case {case} differs from OpenCV")
return 300


def main() -> None:
args = parse_args()
profiles = [value.strip() for value in args.profiles.split(",") if value.strip()]
patterns = [value.strip() for value in args.patterns.split(",") if value.strip()]
if unknown := sorted(set(profiles) - PROFILES.keys()):
raise ValueError(f"unknown profiles: {', '.join(unknown)}")
if unknown := sorted(set(patterns) - set(PATTERNS)):
raise ValueError(f"unknown patterns: {', '.join(unknown)}")
if hasattr(cv2, "ocl"):
cv2.ocl.setUseOpenCL(False)
cv2.setNumThreads(os.cpu_count() or 1)

randomized_cases = validate_randomized_cases()
results: dict[str, object] = {}
for profile in profiles:
width, height, repeats = PROFILES[profile]
for pattern in patterns:
image = make_image(width, height, pattern, 20_260_716)
opencv_out = np.empty_like(image)
spatialrust_out = np.empty_like(image)
workspace = sr.CannyWorkspace()

def opencv_allocate() -> np.ndarray:
return cv2.Canny(image, 80.0, 160.0, apertureSize=3, L2gradient=True)

def spatialrust_allocate() -> np.ndarray:
return sr.canny_image(
image, 80.0, 160.0, aperture_size=3, l2_gradient=True
)

def opencv_reuse() -> np.ndarray:
return cv2.Canny(
image, 80.0, 160.0, opencv_out, apertureSize=3, L2gradient=True
)

def spatialrust_reuse() -> np.ndarray:
return sr.canny_image(
image,
80.0,
160.0,
aperture_size=3,
l2_gradient=True,
out=spatialrust_out,
workspace=workspace,
)

if not np.array_equal(opencv_allocate(), spatialrust_allocate()):
raise AssertionError(f"{profile}/{pattern} differs from OpenCV")
if opencv_reuse() is not opencv_out or spatialrust_reuse() is not spatialrust_out:
raise AssertionError("caller-owned output identity was not preserved")
_, _, cv_alloc, sr_alloc = timed_pair(
opencv_allocate,
spatialrust_allocate,
warmup=args.warmup,
repeats=repeats,
seed=118,
min_sample_time_ms=20.0,
)
_, _, cv_reuse, sr_reuse = timed_pair(
opencv_reuse,
spatialrust_reuse,
warmup=args.warmup,
repeats=repeats,
seed=1118,
min_sample_time_ms=20.0,
)
cv_alloc_ms = float(cv_alloc["median"])
sr_alloc_ms = float(sr_alloc["median"])
cv_reuse_ms = float(cv_reuse["median"])
sr_reuse_ms = float(sr_reuse["median"])
results[f"{profile}/{pattern}"] = {
"dimensions": [width, height],
"pattern": pattern,
"accuracy": "bit exact",
"opencv_allocate": cv_alloc,
"spatialrust_allocate": sr_alloc,
"spatialrust_allocate_speedup": cv_alloc_ms / sr_alloc_ms,
"opencv_reuse": cv_reuse,
"spatialrust_reuse": sr_reuse,
"spatialrust_reuse_speedup": cv_reuse_ms / sr_reuse_ms,
}

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-canny-performance",
kind="performance",
status="pass",
environment_receipt=receipt,
results={
"methodology": {
"operation": "Canny 3x3, thresholds 80/160, L2 gradient",
"paired_interleaved": True,
"minimum_sample_time_ms": 20.0,
"randomized_bit_exact_cases": randomized_cases,
},
"profiles": results,
},
)
emit_report(report, args.output)


if __name__ == "__main__":
main()
13 changes: 12 additions & 1 deletion crates/spatialrust-py/spatialrust.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ __all__: list[str] = [
"laplacian_image", "pyr_down_image", "pyr_up_image", "MorphologyWorkspace", "morphology_image",
"threshold_image", "otsu_threshold_image", "adaptive_threshold_image",
"histogram_image", "equalize_histogram_image", "clahe_image",
"integral_image_u8", "canny_image", "resize_image", "letterbox_image",
"integral_image_u8", "CannyWorkspace", "canny_image", "resize_image", "letterbox_image",
"normalize_image_chw", "resize_normalize_image_chw", "rgb_to_gray_image", "resize_rgb_to_gray_image", "rgb_to_hsv_image", "remap_image",
"nms", "batched_nms", "soft_nms", "connected_components_image", "distance_transform_edt",
"find_mask_contours",
Expand Down Expand Up @@ -257,12 +257,23 @@ def clahe_image(
tiles_y: int = ...,
) -> _U8Array: ...
def integral_image_u8(image: _U8Array) -> NDArray[np.float64]: ...

@final
class CannyWorkspace:
"""Reusable host scratch storage for allocation-light Canny detection."""

def __init__(self) -> None: ...
@property
def capacity(self) -> int: ...

def canny_image(
image: _U8Array,
low_threshold: float,
high_threshold: float,
aperture_size: int = ...,
l2_gradient: bool = ...,
out: Optional[_U8Array] = ...,
workspace: Optional[CannyWorkspace] = ...,
) -> _U8Array: ...
def resize_image(
image: _U8Array,
Expand Down
86 changes: 69 additions & 17 deletions crates/spatialrust-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ 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_u8 as label_components_u8,
batched_nms as batched_nms_op, bilateral_filter as bilateral_filter_op,
canny_into as canny_into_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, dilate_rect_u8_into as dilate_rect_u8_into_op,
Expand Down Expand Up @@ -101,13 +101,14 @@ use spatialrust::vision::{
spatial_gradient_u8_into as spatial_gradient_u8_into_op,
stereo_block_match as stereo_block_match_op, stitch_panorama_pair as stitch_panorama_pair_op,
threshold as threshold_op, AbsolutePose, AdaptiveThresholdMethod, BilinearResizeU8Plan,
BinaryMask, BorderMode, BoundingBox2, CameraMatrix3, CannyOptions, ConfidenceMap, Connectivity,
CornerSelectionOptions, DescriptorBuffer, Detection, DistanceTransformWorkspace, FastOptions,
GaussianBlurU8Workspace, HarrisOptions, Interpolation, Kernel2D, Keypoint2, MaskRle,
MatchOptions, MorphologyOperation, MorphologyShape, ObjectImageCorrespondence, OrbOptions,
OrbScoreType, PanoramaOptions, PerspectiveTransform, PointCorrespondence2, PointMap,
RectMorphologyWorkspace, RgbdOdometryOptions, RleOrder, RobustEstimationOptions,
ShiTomasiOptions, SoftNmsMethod, StereoBmOptions, StructuringElement, ThresholdType,
BinaryMask, BorderMode, BoundingBox2, CameraMatrix3, CannyOptions, CannyWorkspace,
ConfidenceMap, Connectivity, CornerSelectionOptions, DescriptorBuffer, Detection,
DistanceTransformWorkspace, FastOptions, GaussianBlurU8Workspace, HarrisOptions, Interpolation,
Kernel2D, Keypoint2, MaskRle, MatchOptions, MorphologyOperation, MorphologyShape,
ObjectImageCorrespondence, OrbOptions, OrbScoreType, PanoramaOptions, PerspectiveTransform,
PointCorrespondence2, PointMap, RectMorphologyWorkspace, RgbdOdometryOptions, RleOrder,
RobustEstimationOptions, ShiTomasiOptions, SoftNmsMethod, StereoBmOptions, StructuringElement,
ThresholdType,
};
use spatialrust::vision::{dense_flow_block_match as dense_flow_native, DenseFlowOptions};
use spatialrust::voxelize::{
Expand Down Expand Up @@ -2781,25 +2782,75 @@ fn integral_image_u8<'py>(
Ok(array.into_pyarray_bound(py))
}

/// Reusable host scratch storage for allocation-light Canny edge detection.
#[pyclass(name = "CannyWorkspace")]
struct PyCannyWorkspace {
inner: CannyWorkspace,
}

#[pymethods]
impl PyCannyWorkspace {
#[new]
fn new() -> Self {
Self { inner: CannyWorkspace::new() }
}

#[getter]
fn capacity(&self) -> usize {
self.inner.capacity()
}
}

/// Detects edges in a grayscale uint8 image with Canny hysteresis.
#[pyfunction]
#[pyo3(signature = (image, low_threshold, high_threshold, aperture_size=3, l2_gradient=false))]
#[pyo3(signature = (image, low_threshold, high_threshold, aperture_size=3, l2_gradient=false, out=None, workspace=None))]
fn canny_image<'py>(
py: Python<'py>,
image: PyReadonlyArray2<'_, u8>,
low_threshold: f64,
high_threshold: f64,
aperture_size: usize,
l2_gradient: bool,
out: Option<Bound<'py, PyArray2<u8>>>,
mut workspace: Option<PyRefMut<'_, PyCannyWorkspace>>,
) -> PyResult<Bound<'py, PyArray2<u8>>> {
let image = gray_u8_image_from_numpy(image)?;
let output = canny_op(
image.view(),
CannyOptions { low_threshold, high_threshold, aperture_size, l2_gradient },
)
.map_err(to_py_err)?;
let array = Array2::from_shape_vec((image.height(), image.width()), output.into_vec())
let mut packed = Vec::new();
let image = gray_u8_image_view_from_numpy(&image, &mut packed)?;
let options = CannyOptions { low_threshold, high_threshold, aperture_size, l2_gradient };
let mut local_workspace = CannyWorkspace::new();
let workspace =
workspace.as_deref_mut().map_or(&mut local_workspace, |workspace| &mut workspace.inner);
if let Some(out) = out {
{
let mut out_rw = out
.try_readwrite()
.map_err(|_| PyValueError::new_err("out must not overlap the Canny input"))?;
let mut out_array = out_rw.as_array_mut();
if out_array.shape() != [image.height(), image.width()] {
return Err(PyValueError::new_err(format!(
"out shape must be ({}, {}), found {:?}",
image.height(),
image.width(),
out_array.shape()
)));
}
let Some(out_slice) = out_array.as_slice_mut() else {
return Err(PyValueError::new_err(
"out must be a contiguous uint8 array of shape (H, W)",
));
};
let output = ImageViewMut::new(image.width(), image.height(), image.width(), out_slice)
.map_err(to_py_err)?;
canny_into_op(image, options, output, workspace).map_err(to_py_err)?;
}
return Ok(out);
}
let mut output = vec![0_u8; image.width() * image.height()];
let output_view = ImageViewMut::new(image.width(), image.height(), image.width(), &mut output)
.map_err(to_py_err)?;
canny_into_op(image, options, output_view, workspace).map_err(to_py_err)?;
let array =
Array2::from_shape_vec((image.height(), image.width()), output).map_err(to_py_err)?;
Ok(array.into_pyarray_bound(py))
}

Expand Down Expand Up @@ -3882,6 +3933,7 @@ fn spatialrust_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyKeypoint2>()?;
m.add_class::<PyDistanceTransformWorkspace>()?;
m.add_class::<PyMorphologyWorkspace>()?;
m.add_class::<PyCannyWorkspace>()?;
m.add_class::<PyOnnxRuntimeSession>()?;
m.add_class::<PyDlpackTensorView>()?;
m.add_class::<PyPointCloud>()?;
Expand Down
Loading
Loading