From 12f987eac81980b64e0347aa34b281aa8da7adfe Mon Sep 17 00:00:00 2001 From: rsasaki0109 Date: Thu, 16 Jul 2026 08:32:00 +0900 Subject: [PATCH] Add allocation-light Canny path --- CHANGELOG.md | 5 + README.md | 10 +- bench/opencv_canny_comparison/README.md | 15 + bench/opencv_canny_comparison/performance.py | 163 +++++++++ crates/spatialrust-py/spatialrust.pyi | 13 +- crates/spatialrust-py/src/lib.rs | 86 ++++- crates/spatialrust-py/tests/test_bindings.py | 20 ++ crates/spatialrust-vision/benches/canny.rs | 27 +- crates/spatialrust-vision/src/canny.rs | 355 ++++++++++++++++++- docs/ROADMAP.md | 6 +- docs/site/algorithms.html | 2 +- notes/2026-07-16_canny_fast_path.md | 56 +++ 12 files changed, 728 insertions(+), 30 deletions(-) create mode 100644 bench/opencv_canny_comparison/README.md create mode 100644 bench/opencv_canny_comparison/performance.py create mode 100644 notes/2026-07-16_canny_fast_path.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f8316..4b131a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 768b767..5b44429 100644 --- a/README.md +++ b/README.md @@ -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×** | @@ -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 diff --git a/bench/opencv_canny_comparison/README.md b/bench/opencv_canny_comparison/README.md new file mode 100644 index 0000000..05749f4 --- /dev/null +++ b/bench/opencv_canny_comparison/README.md @@ -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. diff --git a/bench/opencv_canny_comparison/performance.py b/bench/opencv_canny_comparison/performance.py new file mode 100644 index 0000000..eeb6e15 --- /dev/null +++ b/bench/opencv_canny_comparison/performance.py @@ -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() diff --git a/crates/spatialrust-py/spatialrust.pyi b/crates/spatialrust-py/spatialrust.pyi index b7ad75f..38e094e 100644 --- a/crates/spatialrust-py/spatialrust.pyi +++ b/crates/spatialrust-py/spatialrust.pyi @@ -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", @@ -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, diff --git a/crates/spatialrust-py/src/lib.rs b/crates/spatialrust-py/src/lib.rs index 714fa8b..a577413 100644 --- a/crates/spatialrust-py/src/lib.rs +++ b/crates/spatialrust-py/src/lib.rs @@ -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, @@ -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::{ @@ -2781,9 +2782,28 @@ 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>, @@ -2791,15 +2811,46 @@ fn canny_image<'py>( high_threshold: f64, aperture_size: usize, l2_gradient: bool, + out: Option>>, + mut workspace: Option>, ) -> PyResult>> { - 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)) } @@ -3882,6 +3933,7 @@ fn spatialrust_module(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/crates/spatialrust-py/tests/test_bindings.py b/crates/spatialrust-py/tests/test_bindings.py index 4d80c95..a7a5656 100644 --- a/crates/spatialrust-py/tests/test_bindings.py +++ b/crates/spatialrust-py/tests/test_bindings.py @@ -762,6 +762,26 @@ def test_canny_image_binary_output_and_noncontiguous_input(): assert np.count_nonzero(edges) > 0 +def test_canny_image_reuses_output_and_workspace(): + image = np.zeros((31, 37), dtype=np.uint8) + image[5:26, 9:29] = 255 + expected = sr.canny_image(image, 50.0, 100.0, 3, True) + output = np.empty_like(image) + workspace = sr.CannyWorkspace() + actual = sr.canny_image( + image, + 50.0, + 100.0, + 3, + True, + out=output, + workspace=workspace, + ) + assert actual is output + np.testing.assert_array_equal(actual, expected) + assert workspace.capacity >= image.size + + def test_feature2d_corner_detectors_and_keypoint_metadata(): image = np.zeros((25, 29), dtype=np.uint8) image[5:19, 7:22] = 255 diff --git a/crates/spatialrust-vision/benches/canny.rs b/crates/spatialrust-vision/benches/canny.rs index ec32c6d..725a4c3 100644 --- a/crates/spatialrust-vision/benches/canny.rs +++ b/crates/spatialrust-vision/benches/canny.rs @@ -1,6 +1,8 @@ use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use spatialrust_image::Image; -use spatialrust_vision::{canny, CannyOptions}; +use spatialrust_vision::{ + canny, canny_into, canny_with_intermediates, CannyOptions, CannyWorkspace, +}; fn benchmark_canny(c: &mut Criterion) { let mut group = c.benchmark_group("canny"); @@ -11,11 +13,32 @@ fn benchmark_canny(c: &mut Criterion) { .collect(); let image = Image::::try_new(width, height, data).unwrap(); group.throughput(Throughput::Elements((width * height) as u64)); - group.bench_function(BenchmarkId::from_parameter(name), |b| { + group.bench_function(BenchmarkId::new("allocate", name), |b| { b.iter(|| { black_box(canny(image.view(), CannyOptions::default()).unwrap()); }); }); + group.bench_function(BenchmarkId::new("inspectable", name), |b| { + b.iter(|| { + black_box(canny_with_intermediates(image.view(), CannyOptions::default()).unwrap()); + }); + }); + let mut output = Image::::from_pixel(width, height, [0]).unwrap(); + let mut workspace = CannyWorkspace::new(); + canny_into(image.view(), CannyOptions::default(), output.view_mut(), &mut workspace) + .unwrap(); + group.bench_function(BenchmarkId::new("reuse", name), |b| { + b.iter(|| { + canny_into( + image.view(), + CannyOptions::default(), + output.view_mut(), + &mut workspace, + ) + .unwrap(); + black_box(output.as_slice()); + }); + }); } group.finish(); } diff --git a/crates/spatialrust-vision/src/canny.rs b/crates/spatialrust-vision/src/canny.rs index e63da95..80d40bd 100644 --- a/crates/spatialrust-vision/src/canny.rs +++ b/crates/spatialrust-vision/src/canny.rs @@ -2,9 +2,12 @@ use std::collections::VecDeque; -use spatialrust_image::{Image, ImageView}; +use rayon::prelude::*; +use spatialrust_image::{Image, ImageView, ImageViewMut}; -use crate::{sobel, spatial_gradient_u8, BorderMode, VisionError, VisionResult}; +use crate::{ + sobel, spatial_gradient_u8, spatial_gradient_u8_into, BorderMode, VisionError, VisionResult, +}; /// Validated Canny thresholds and gradient settings. #[derive(Clone, Copy, Debug, PartialEq)] @@ -60,9 +63,278 @@ pub struct CannyResult { pub suppressed: Image, } +/// Reusable caller-owned storage for the allocation-light Canny path. +/// +/// The workspace grows to the largest image seen and retains that capacity. +/// It contains CPU buffers only and never performs an implicit device copy. +#[derive(Clone, Debug, Default)] +pub struct CannyWorkspace { + gradient_x: Vec, + gradient_y: Vec, + comparison_magnitude: Vec, + states: Vec, + strong: Vec, +} + +impl CannyWorkspace { + /// Creates an empty workspace that allocates on its first call. + #[must_use] + pub const fn new() -> Self { + Self { + gradient_x: Vec::new(), + gradient_y: Vec::new(), + comparison_magnitude: Vec::new(), + states: Vec::new(), + strong: Vec::new(), + } + } + + /// Returns the reusable pixel capacity without counting the edge stack. + #[must_use] + pub fn capacity(&self) -> usize { + self.gradient_x + .capacity() + .min(self.gradient_y.capacity()) + .min(self.comparison_magnitude.capacity()) + .min(self.states.capacity()) + } + + fn resize(&mut self, len: usize) { + self.gradient_x.resize(len, 0); + self.gradient_y.resize(len, 0); + self.comparison_magnitude.resize(len, 0); + self.states.resize(len, 1); + self.strong.clear(); + } +} + /// Finds edges in a single-channel u8 image. pub fn canny(input: ImageView<'_, u8, 1>, options: CannyOptions) -> VisionResult> { - Ok(canny_with_intermediates(input, options)?.edges) + let len = checked_len(input.width(), input.height())?; + let mut output = Image::try_new_with_metadata( + input.width(), + input.height(), + vec![0; len], + input.metadata(), + )?; + let mut workspace = CannyWorkspace::new(); + canny_into(input, options, output.view_mut(), &mut workspace)?; + Ok(output) +} + +/// Finds edges into caller-owned output using reusable CPU workspace. +/// +/// The output may be packed or strided, must match the input dimensions, and +/// receives only binary values (`0` or `255`). The specialized allocation-light +/// path applies to the common 3x3 aperture; larger apertures retain the exact +/// inspectable implementation. +pub fn canny_into( + input: ImageView<'_, u8, 1>, + options: CannyOptions, + mut output: ImageViewMut<'_, u8, 1>, + workspace: &mut CannyWorkspace, +) -> VisionResult<()> { + let options = options.validate()?; + if output.width() != input.width() || output.height() != input.height() { + return Err(VisionError::ShapeMismatch(format!( + "Canny output must be {}x{}, found {}x{}", + input.width(), + input.height(), + output.width(), + output.height() + ))); + } + if options.aperture_size != 3 { + let result = canny_with_intermediates(input, options)?; + for y in 0..input.height() { + let start = y * input.width(); + output + .row_mut(y) + .expect("validated Canny output row") + .copy_from_slice(&result.edges.as_slice()[start..start + input.width()]); + } + return Ok(()); + } + + canny_3x3_into(input, options, &mut output, workspace) +} + +fn checked_len(width: usize, height: usize) -> VisionResult { + width + .checked_mul(height) + .ok_or_else(|| VisionError::InvalidDimensions("Canny image dimensions overflow".into())) +} + +fn canny_3x3_into( + input: ImageView<'_, u8, 1>, + options: CannyOptions, + output: &mut ImageViewMut<'_, u8, 1>, + workspace: &mut CannyWorkspace, +) -> VisionResult<()> { + let width = input.width(); + let height = input.height(); + let len = checked_len(width, height)?; + workspace.resize(len); + spatial_gradient_u8_into( + input, + BorderMode::Replicate, + &mut workspace.gradient_x, + &mut workspace.gradient_y, + )?; + + if options.l2_gradient { + if len >= 1_000_000 { + workspace.comparison_magnitude.par_iter_mut().enumerate().for_each( + |(index, magnitude)| { + let x = i32::from(workspace.gradient_x[index]); + let y = i32::from(workspace.gradient_y[index]); + *magnitude = x * x + y * y; + }, + ); + } else { + for ((magnitude, &x), &y) in workspace + .comparison_magnitude + .iter_mut() + .zip(&workspace.gradient_x) + .zip(&workspace.gradient_y) + { + let x = i32::from(x); + let y = i32::from(y); + *magnitude = x * x + y * y; + } + } + } else { + for ((magnitude, &x), &y) in workspace + .comparison_magnitude + .iter_mut() + .zip(&workspace.gradient_x) + .zip(&workspace.gradient_y) + { + *magnitude = i32::from(x).abs() + i32::from(y).abs(); + } + } + + let (low, high) = canny_thresholds(options); + workspace.states.fill(1); + if len >= 1_000_000 { + let gradient_x = &workspace.gradient_x; + let gradient_y = &workspace.gradient_y; + let magnitudes = &workspace.comparison_magnitude; + let strong = workspace + .states + .par_iter_mut() + .enumerate() + .fold(Vec::new, |mut strong, (index, state)| { + let magnitude = magnitudes[index]; + if i64::from(magnitude) > low + && is_directional_maximum_i32( + index % width, + index / width, + width, + height, + i32::from(gradient_x[index]), + i32::from(gradient_y[index]), + magnitude, + magnitudes, + ) + { + if i64::from(magnitude) > high { + *state = 2; + strong.push(index); + } else { + *state = 0; + } + } + strong + }) + .reduce(Vec::new, |mut left, mut right| { + left.append(&mut right); + left + }); + workspace.strong = strong; + } else { + for y in 0..height { + let row = y * width; + for x in 0..width { + let index = row + x; + let magnitude = workspace.comparison_magnitude[index]; + if i64::from(magnitude) <= low + || !is_directional_maximum_i32( + x, + y, + width, + height, + i32::from(workspace.gradient_x[index]), + i32::from(workspace.gradient_y[index]), + magnitude, + &workspace.comparison_magnitude, + ) + { + continue; + } + if i64::from(magnitude) > high { + workspace.states[index] = 2; + workspace.strong.push(index); + } else { + workspace.states[index] = 0; + } + } + } + } + + while let Some(index) = workspace.strong.pop() { + let x = index % width; + let y = index / width; + let x0 = x.saturating_sub(1); + let x1 = (x + 1).min(width.saturating_sub(1)); + let y0 = y.saturating_sub(1); + let y1 = (y + 1).min(height.saturating_sub(1)); + for ny in y0..=y1 { + for nx in x0..=x1 { + let neighbor = ny * width + nx; + if workspace.states[neighbor] == 0 { + workspace.states[neighbor] = 2; + workspace.strong.push(neighbor); + } + } + } + } + + if len >= 1_000_000 && output.row_stride() == width { + output + .as_mut_slice() + .par_iter_mut() + .zip(&workspace.states) + .for_each(|(pixel, &state)| *pixel = if state == 2 { 255 } else { 0 }); + } else { + for y in 0..height { + let start = y * width; + let target = output.row_mut(y).expect("validated Canny output row"); + for (pixel, &state) in target.iter_mut().zip(&workspace.states[start..start + width]) { + *pixel = if state == 2 { 255 } else { 0 }; + } + } + } + Ok(()) +} + +fn canny_thresholds(options: CannyOptions) -> (i64, i64) { + let (mut low, mut high) = (options.low_threshold, options.high_threshold); + if options.aperture_size == 7 { + low /= 16.0; + high /= 16.0; + } + if low > high { + std::mem::swap(&mut low, &mut high); + } + if options.l2_gradient { + ( + low.min(32767.0).mul_add(low.min(32767.0), 0.0).floor() as i64, + high.min(32767.0).mul_add(high.min(32767.0), 0.0).floor() as i64, + ) + } else { + (low.floor() as i64, high.floor() as i64) + } } /// Runs Canny and retains gradient, magnitude, and suppression stages. @@ -255,10 +527,44 @@ fn is_directional_maximum( } } +#[inline] +fn is_directional_maximum_i32( + x: usize, + y: usize, + width: usize, + height: usize, + gradient_x: i32, + gradient_y: i32, + magnitude: i32, + magnitudes: &[i32], +) -> bool { + const TG22: i64 = 13_573; + let abs_x = i64::from(gradient_x.abs()); + let abs_y_scaled = i64::from(gradient_y.abs()) << 15; + let tg22_x = abs_x * TG22; + let get = |offset_x: isize, offset_y: isize| { + let nx = x as isize + offset_x; + let ny = y as isize + offset_y; + if nx < 0 || ny < 0 || nx >= width as isize || ny >= height as isize { + 0 + } else { + magnitudes[ny as usize * width + nx as usize] + } + }; + if abs_y_scaled < tg22_x { + magnitude > get(-1, 0) && magnitude >= get(1, 0) + } else if abs_y_scaled > tg22_x + (abs_x << 16) { + magnitude > get(0, -1) && magnitude >= get(0, 1) + } else { + let sign = if (gradient_x ^ gradient_y) < 0 { -1 } else { 1 }; + magnitude > get(-sign, -1) && magnitude > get(sign, 1) + } +} + #[cfg(test)] mod tests { - use super::{canny, canny_with_intermediates, CannyOptions}; - use spatialrust_image::{Image, ImageRegion}; + use super::{canny, canny_into, canny_with_intermediates, CannyOptions, CannyWorkspace}; + use spatialrust_image::{Image, ImageRegion, ImageViewMut}; #[test] fn finds_both_sides_of_bright_bar_on_strided_roi() { @@ -313,4 +619,43 @@ mod tests { assert!(canny(image.view(), CannyOptions { low_threshold: -1.0, ..Default::default() }) .is_err()); } + + #[test] + fn fast_path_matches_inspectable_path_for_l1_and_l2() { + let data = (0..31 * 19).map(|index| ((index * 37 + index / 31 * 17) & 255) as u8).collect(); + let image = Image::::try_new(31, 19, data).unwrap(); + for l2_gradient in [false, true] { + let options = CannyOptions { + low_threshold: 80.0, + high_threshold: 160.0, + l2_gradient, + ..Default::default() + }; + assert_eq!( + canny(image.view(), options).unwrap(), + canny_with_intermediates(image.view(), options).unwrap().edges + ); + } + } + + #[test] + fn reusable_path_supports_strided_output_without_touching_padding() { + let image = Image::::try_new( + 7, + 5, + (0..35).map(|index| ((index * 53) & 255) as u8).collect(), + ) + .unwrap(); + let expected = canny(image.view(), CannyOptions::default()).unwrap(); + let mut storage = vec![17_u8; 5 * 11]; + let output = ImageViewMut::::new(7, 5, 11, &mut storage).unwrap(); + let mut workspace = CannyWorkspace::new(); + canny_into(image.view(), CannyOptions::default(), output, &mut workspace).unwrap(); + for y in 0..5 { + assert_eq!(&storage[y * 11..y * 11 + 7], &expected.as_slice()[y * 7..y * 7 + 7]); + if y < 4 { + assert_eq!(&storage[y * 11 + 7..(y + 1) * 11], &[17; 4]); + } + } + } } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1bed606..57cd123 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -654,9 +654,9 @@ to one implicitly, and GPU receipts must retain named upload/readback stages. | Slice | Status | Scope | Evidence | | --- | --- | --- | --- | -| 118A | Planned | Compute paired gradients, magnitude, and direction with shared traversal | intermediate parity tests | -| 118B | Planned | Ring-buffer suppression and reusable hysteresis queue | peak-memory receipt | -| 118C | Planned | Keep inspectable intermediates opt-in while making `canny()` allocation-light | API behavior tests | +| 118A | Complete | Compute paired gradients, magnitude, and direction with shared traversal | fast-path/intermediate bit-exact parity tests | +| 118B | In progress | Ring-buffer suppression and reusable hysteresis queue | reusable gradients, magnitude, states, and stack complete; ring-buffer suppression remains | +| 118C | Complete | Keep inspectable intermediates opt-in while making `canny()` allocation-light | `canny_into`, strided output padding, Python output identity, and workspace capacity tests | | 118D | Planned | Improve Canny by at least 5x on one canonical large profile | F1/IoU and timing receipt | ### Epic 119 delivery slices diff --git a/docs/site/algorithms.html b/docs/site/algorithms.html index 9509a65..7cf219b 100644 --- a/docs/site/algorithms.html +++ b/docs/site/algorithms.html @@ -35,7 +35,7 @@

Algorithm catalog

MorphologyErode, dilate, open, close, gradient, top-hat, black-hat; separable sliding Rect fast path plus Cross/Ellipse/Diamond/custom fallbackspatialrust-vision · imgproc-morphologysafe CPU explicit wgpu API Image analysisFixed/Otsu/adaptive threshold, histogram, equalization, CLAHE, integral image, Cannyspatialrust-vision · imgproc-analysis, imgproc-cannyCPU Local featuresFAST, Harris, Shi–Tomasi, ORB, descriptor matching, grid selection, pyramidal Lucas–Kanade trackingspatialrust-vision · feature2dCPU - Dense visionExact Euclidean distance transform with reusable workspace/output; row-major run-length/union-find connected components; contours, polygon approximation, mask RLE, and dense spatial maps. Structured VGA/1080p/4K masks measured 2.17×–3.61× faster than OpenCV SAUF with exact labels, areas, and boxes across canonical plus 320 randomized cases. Harness.spatialrust-vision · denseCPU + Dense visionExact Euclidean distance transform with reusable workspace/output; allocation-light Canny with opt-in inspectable intermediates; row-major run-length/union-find connected components; contours, polygon approximation, mask RLE, and dense spatial maps. Canny adds safe strided output and reusable workspace with bit-exact OpenCV parity across 300 randomized images; OpenCV remains 1.65×–1.77× faster on the document-line reuse profiles. Canny harness. Structured connected-component masks measured 2.17×–3.61× faster than OpenCV SAUF. Components harness.spatialrust-vision · denseCPU Detection post-processingIoU/GIoU, greedy NMS, class-aware batched NMS, and hard/linear/Gaussian Soft-NMS. Seeded Python NMS is 3.22×–8.95× faster than OpenCV; batched NMS is 26.38×–97.25× faster; linear/Gaussian Soft-NMS is 3.42×–7.40× faster. NMS indices are exact and Soft-NMS scores stay within 1.79e-7. NMS · batched · Soft-NMS.spatialrust-vision · detectionCPU Multiview geometryHomography, fundamental/essential matrices, RANSAC, triangulation, relative pose, PnP/PnP-RANSACspatialrust-vision · geometryCPU Stereo and odometryStereo rectification, block matching, disparity-to-depth/XYZ, monocular and RGB-D visual odometryspatialrust-vision · geometry, odometryCPU diff --git a/notes/2026-07-16_canny_fast_path.md b/notes/2026-07-16_canny_fast_path.md new file mode 100644 index 0000000..607484d --- /dev/null +++ b/notes/2026-07-16_canny_fast_path.md @@ -0,0 +1,56 @@ +# Epic 118A/118C: allocation-light Canny + +Date: 2026-07-16 (Asia/Tokyo) + +## Outcome + +The ordinary 3x3 Canny path no longer builds four public intermediate images +before discarding them. `canny_with_intermediates` remains available for +inspection. `canny_into` accepts packed or strided caller-owned output and a +`CannyWorkspace` that retains paired `i16` gradients, comparison magnitude, +classification state, and the hysteresis stack. Large magnitude, directional +suppression, and packed-output stages use the existing Rayon CPU policy. + +The Python binding exposes the same contract through `out=` and +`CannyWorkspace`. There are no hidden device transfers. + +## Correctness + +- Rust fast-path output is bit-exact with the inspectable path for L1 and L2. +- Strided output tests verify row padding remains untouched. +- The focused Python harness passed 300 seeded randomized images bit-exact + against OpenCV 4.13.0. +- VGA, 1080p, and 4K document-line and sensor-noise timing inputs are also + bit-exact. + +## Focused OpenCV timing + +Windows 11, Intel64 Family 6 Model 158, 12 logical CPUs, CPython 3.12.10, +OpenCV 4.13.0 with 12 threads and OpenCL disabled. Calls were warmed up, seeded, +batched to at least 20 ms, and measured in randomized interleaved order. + +| Profile | Pattern | OpenCV allocate | SpatialRust allocate | OpenCV reuse | SpatialRust reuse | Reuse result | +| --- | --- | ---: | ---: | ---: | ---: | ---: | +| VGA | document lines | 0.491 ms | 1.153 ms | 0.430 ms | 0.762 ms | OpenCV 1.77x | +| 1080p | document lines | 3.134 ms | 8.417 ms | 2.631 ms | 4.454 ms | OpenCV 1.69x | +| 4K | document lines | 10.605 ms | 30.408 ms | 10.300 ms | 16.994 ms | OpenCV 1.65x | +| VGA | sensor noise | 2.123 ms | 8.163 ms | 2.039 ms | 7.306 ms | OpenCV 3.66x | +| 1080p | sensor noise | 15.068 ms | 32.668 ms | 15.286 ms | 29.361 ms | OpenCV 1.92x | +| 4K | sensor noise | 64.216 ms | 122.607 ms | 63.170 ms | 113.000 ms | OpenCV 1.79x | + +OpenCV still wins these standalone workloads, so Epic 118D remains open. The +focused row replaces the old README measurement where `canny()` always created +inspectable intermediates and OpenCV led by 10.66x–12.65x. + +## Reproduction + +```powershell +.venv\Scripts\python.exe bench\opencv_canny_comparison\performance.py ` + --output target\opencv-canny-performance.json +``` + +Relevant absolute paths on the receipt host: + +- `C:\Users\rsasa\Workspace\SpatialRust\crates\spatialrust-vision\src\canny.rs` +- `C:\Users\rsasa\Workspace\SpatialRust\bench\opencv_canny_comparison\performance.py` +- `C:\Users\rsasa\Workspace\SpatialRust\target\opencv-canny-performance.json`