diff --git a/README.md b/README.md index a9ef571..1434862 100644 --- a/README.md +++ b/README.md @@ -158,8 +158,10 @@ ratio; these are machine-specific measurements, not universal guarantees. | RGB to gray, reuse | OpenCV 6.01× | OpenCV 13.81× | OpenCV 2.09× | | Gaussian blur 5×5 | OpenCV 139.02× | OpenCV 107.91× | OpenCV 167.28× | | Sobel X 3×3 | OpenCV 14.38× | OpenCV 20.31× | OpenCV 23.30× | -| Morphology open 5×5[^morphology-2026] | OpenCV 65.6× | OpenCV 13.1× | OpenCV 18.3× | -| Morphology open 511×511[^morphology-2026] | OpenCV 2.40× | **SpatialRust 2.46×** | **SpatialRust 2.06×** | +| Morphology open 5×5, allocate[^morphology-2026] | OpenCV 60.96× | OpenCV 13.34× | OpenCV 15.27× | +| 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× | | 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×** | @@ -172,12 +174,14 @@ the dated [Epic 111 receipt](notes/2026-07-15_epic111_opencv_comparison_v2.md) records the exact environment and methodology. [^morphology-2026]: Rectangular morphology was remeasured separately with - OpenCV 4.13, OpenCL off, and the same allocated Python-API timing scope. + OpenCV 4.13, OpenCL off, with both allocated and caller-owned-output Python + API timing scopes. `MorphologyWorkspace` retains all full-image and + per-worker line scratch; `out=` retains object identity. The separable sliding min/max path is bit-exact across 980 randomized operation cases. It removes the old 500–900× gap for 5×5 kernels and wins on the named 511×511 large-background workload, but OpenCV remains much faster for small rectangles. See the [focused harness](bench/opencv_morphology_comparison/) - and [receipt](notes/2026-07-15_rectangular_morphology_acceleration.md). + and [workspace receipt](notes/2026-07-16_morphology_workspace_reuse.md). The EDT fast path is exact on the canonical masks and reduced the native 4K allocation benchmark from 451.63 ms to about 75 ms. With caller-owned output diff --git a/bench/opencv_morphology_comparison/README.md b/bench/opencv_morphology_comparison/README.md index d638c80..ed47a74 100644 --- a/bench/opencv_morphology_comparison/README.md +++ b/bench/opencv_morphology_comparison/README.md @@ -1,7 +1,8 @@ # OpenCV rectangular morphology comparison This focused harness compares bit-exact grayscale rectangular opening through -the public Python APIs. It covers the common 5×5 case and a 511×511 +the public Python APIs in both allocated and caller-owned-output/workspace +modes. It covers the common 5×5 case and a 511×511 background-estimation/document workload where the window-area-independent SpatialRust engine can overtake OpenCV on large images. diff --git a/bench/opencv_morphology_comparison/performance.py b/bench/opencv_morphology_comparison/performance.py index e6e955a..67bc3c2 100644 --- a/bench/opencv_morphology_comparison/performance.py +++ b/bench/opencv_morphology_comparison/performance.py @@ -89,6 +89,9 @@ def main() -> None: profile_results: dict[str, object] = {} for kernel_size in kernels: kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8) + opencv_out = np.empty_like(image) + spatialrust_out = np.empty_like(image) + spatialrust_workspace = sr.MorphologyWorkspace() def opencv_open() -> np.ndarray: return cv2.morphologyEx( @@ -103,10 +106,39 @@ def spatialrust_open() -> np.ndarray: image, "open", kernel_size, kernel_size, "rect", 1 ) + def opencv_open_reuse() -> np.ndarray: + return cv2.morphologyEx( + image, + cv2.MORPH_OPEN, + kernel, + dst=opencv_out, + borderType=cv2.BORDER_REPLICATE, + ) + + def spatialrust_open_reuse() -> np.ndarray: + return sr.morphology_image( + image, + "open", + kernel_size, + kernel_size, + "rect", + 1, + out=spatialrust_out, + workspace=spatialrust_workspace, + ) + expected = opencv_open() actual = spatialrust_open() if not np.array_equal(actual, expected): raise AssertionError(f"{profile}/{kernel_size} is not bit-exact") + if opencv_open_reuse() is not opencv_out: + raise AssertionError("OpenCV did not return its caller-owned output") + if spatialrust_open_reuse() is not spatialrust_out: + raise AssertionError("SpatialRust did not return its caller-owned output") + if not np.array_equal(opencv_out, expected) or not np.array_equal( + spatialrust_out, expected + ): + raise AssertionError(f"{profile}/{kernel_size} reuse is not bit-exact") _, _, opencv_timing, spatialrust_timing = timed_pair( opencv_open, spatialrust_open, @@ -115,8 +147,18 @@ def spatialrust_open() -> np.ndarray: seed=117 + kernel_size, min_sample_time_ms=20.0, ) + _, _, opencv_reuse_timing, spatialrust_reuse_timing = timed_pair( + opencv_open_reuse, + spatialrust_open_reuse, + warmup=args.warmup, + repeats=repeats, + seed=2117 + kernel_size, + min_sample_time_ms=20.0, + ) opencv_ms = float(opencv_timing["median"]) spatialrust_ms = float(spatialrust_timing["median"]) + opencv_reuse_ms = float(opencv_reuse_timing["median"]) + spatialrust_reuse_ms = float(spatialrust_reuse_timing["median"]) profile_results[str(kernel_size)] = { "width": width, "height": height, @@ -132,6 +174,18 @@ def spatialrust_open() -> np.ndarray: "faster_implementation": ( "spatialrust" if spatialrust_ms < opencv_ms else "opencv" ), + "opencv_reuse": opencv_reuse_timing, + "spatialrust_reuse": spatialrust_reuse_timing, + "spatialrust_reuse_speedup": opencv_reuse_ms + / spatialrust_reuse_ms, + "faster_reuse_implementation": ( + "spatialrust" + if spatialrust_reuse_ms < opencv_reuse_ms + else "opencv" + ), + "spatialrust_workspace_capacity": spatialrust_workspace.capacity, + "spatialrust_workspace_workers": spatialrust_workspace.worker_capacity, + "spatialrust_workspace_line_capacity": spatialrust_workspace.line_capacity, } results[profile] = profile_results @@ -149,7 +203,7 @@ def spatialrust_open() -> np.ndarray: environment_receipt=receipt, results={ "methodology": { - "timing_scope": "Python API call returning an allocated uint8 image", + "timing_scope": "allocated and caller-owned-output Python API calls", "paired_interleaved": True, "minimum_sample_time_ms": 20.0, "input": "seeded packed random uint8 grayscale", diff --git a/crates/spatialrust-py/spatialrust.pyi b/crates/spatialrust-py/spatialrust.pyi index 766c10e..4672b12 100644 --- a/crates/spatialrust-py/spatialrust.pyi +++ b/crates/spatialrust-py/spatialrust.pyi @@ -31,7 +31,7 @@ __all__: list[str] = [ "calibrate_fisheye_angles", "dense_flow_image", "gray_world_white_balance_image", "stitch_panorama_pair", "filter2d_image", "gaussian_blur_image", "median_blur_image", "bilateral_filter_image", "sobel_image", "scharr_image", - "laplacian_image", "pyr_down_image", "pyr_up_image", "morphology_image", + "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", @@ -196,6 +196,19 @@ def laplacian_image( ) -> _F32Array: ... def pyr_down_image(image: _U8Array) -> _U8Array: ... def pyr_up_image(image: _U8Array) -> _U8Array: ... + +@final +class MorphologyWorkspace: + """Reusable host scratch storage for rectangular uint8 morphology.""" + + def __init__(self) -> None: ... + @property + def capacity(self) -> int: ... + @property + def worker_capacity(self) -> int: ... + @property + def line_capacity(self) -> int: ... + def morphology_image( image: _U8Array, operation: str, @@ -203,6 +216,8 @@ def morphology_image( kernel_height: int, shape: str = ..., iterations: int = ..., + out: Optional[_U8Array] = ..., + workspace: Optional[MorphologyWorkspace] = ..., ) -> _U8Array: ... def threshold_image( image: _U8Array, diff --git a/crates/spatialrust-py/src/lib.rs b/crates/spatialrust-py/src/lib.rs index 6879f02..62eb695 100644 --- a/crates/spatialrust-py/src/lib.rs +++ b/crates/spatialrust-py/src/lib.rs @@ -75,18 +75,19 @@ use spatialrust::vision::{ 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 as dilate_rect_u8_op, + detect_shi_tomasi as detect_shi_tomasi_op, dilate_rect_u8_into as dilate_rect_u8_into_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, - erode_rect_u8 as erode_rect_u8_op, estimate_homography_ransac as estimate_homography_ransac_op, + erode_rect_u8_into as erode_rect_u8_into_op, + estimate_homography_ransac as estimate_homography_ransac_op, estimate_rgbd_odometry as estimate_rgbd_odometry_op, filter2d as filter2d_op, find_contours as trace_contours, gaussian_blur as gaussian_blur_op, gray_world_white_balance as gray_world_white_balance_op, histogram_u8 as histogram_u8_op, integral_image as integral_image_op, laplacian as laplacian_op, letterbox as letterbox_op, match_descriptors as match_descriptors_op, median_blur as median_blur_op, - morphology_ex as morphology_ex_op, morphology_rect_u8 as morphology_rect_u8_op, nms as nms_op, - otsu_threshold_u8 as otsu_threshold_u8_op, pack_chw as pack_chw_op, + morphology_ex as morphology_ex_op, morphology_rect_u8_into as morphology_rect_u8_into_op, + nms as nms_op, otsu_threshold_u8 as otsu_threshold_u8_op, pack_chw as pack_chw_op, pack_chw_into as pack_chw_into_op, point_map_to_point_cloud as point_map_to_cloud, pyr_down as pyr_down_op, pyr_up as pyr_up_op, remap as remap_op, resize as resize_op, resize_into as resize_into_op, rgb_to_gray as rgb_to_gray_op, @@ -98,9 +99,9 @@ use spatialrust::vision::{ DescriptorBuffer, Detection, DistanceTransformWorkspace, FastOptions, HarrisOptions, Interpolation, Kernel2D, Keypoint2, MaskRle, MatchOptions, MorphologyOperation, MorphologyShape, ObjectImageCorrespondence, OrbOptions, OrbScoreType, PanoramaOptions, - PerspectiveTransform, PointCorrespondence2, PointMap, RgbdOdometryOptions, RleOrder, - RobustEstimationOptions, ShiTomasiOptions, SoftNmsMethod, StereoBmOptions, StructuringElement, - ThresholdType, + 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::{ @@ -2209,8 +2210,157 @@ fn pyr_up_image<'py>( } /// Applies grayscale erosion, dilation, or a composite morphology operation. +#[pyclass(name = "MorphologyWorkspace")] +struct PyMorphologyWorkspace { + inner: RectMorphologyWorkspace, + element: Option<(usize, usize, StructuringElement)>, +} + +#[pymethods] +impl PyMorphologyWorkspace { + #[new] + fn new() -> Self { + Self { inner: RectMorphologyWorkspace::new(), element: None } + } + + #[getter] + fn capacity(&self) -> usize { + self.inner.capacity() + } + + #[getter] + fn worker_capacity(&self) -> usize { + self.inner.worker_capacity() + } + + #[getter] + fn line_capacity(&self) -> usize { + self.inner.line_capacity() + } +} + +fn morphology_rect_dispatch_into( + image: ImageView<'_, u8, 1>, + operation: &str, + element: &StructuringElement, + iterations: usize, + output: &mut [u8], + workspace: &mut RectMorphologyWorkspace, +) -> spatialrust::vision::VisionResult<()> { + match operation { + "erode" => erode_rect_u8_into_op( + image, + element, + iterations, + BorderMode::Replicate, + output, + workspace, + ), + "dilate" => dilate_rect_u8_into_op( + image, + element, + iterations, + BorderMode::Replicate, + output, + workspace, + ), + "open" => morphology_rect_u8_into_op( + image, + MorphologyOperation::Open, + element, + iterations, + BorderMode::Replicate, + output, + workspace, + ), + "close" => morphology_rect_u8_into_op( + image, + MorphologyOperation::Close, + element, + iterations, + BorderMode::Replicate, + output, + workspace, + ), + "gradient" => morphology_rect_u8_into_op( + image, + MorphologyOperation::Gradient, + element, + iterations, + BorderMode::Replicate, + output, + workspace, + ), + "tophat" | "top-hat" => morphology_rect_u8_into_op( + image, + MorphologyOperation::TopHat, + element, + iterations, + BorderMode::Replicate, + output, + workspace, + ), + "blackhat" | "black-hat" => morphology_rect_u8_into_op( + image, + MorphologyOperation::BlackHat, + element, + iterations, + BorderMode::Replicate, + output, + workspace, + ), + other => Err(spatialrust::vision::VisionError::InvalidParameter(format!( + "unknown morphology operation `{other}`" + ))), + } +} + +#[allow(clippy::too_many_arguments)] +fn morphology_rect_python<'py>( + py: Python<'py>, + image: ImageView<'_, u8, 1>, + operation: &str, + element: &StructuringElement, + iterations: usize, + out: Option>>, + workspace: &mut RectMorphologyWorkspace, +) -> PyResult>> { + if let Some(out) = out { + { + let mut out_rw = out + .try_readwrite() + .map_err(|_| PyValueError::new_err("out must not overlap the morphology 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)", + )); + }; + morphology_rect_dispatch_into( + image, operation, element, iterations, out_slice, workspace, + ) + .map_err(to_py_err)?; + } + return Ok(out); + } + let mut output = vec![0; image.width() * image.height()]; + morphology_rect_dispatch_into(image, operation, element, iterations, &mut output, 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)) +} + #[pyfunction] -#[pyo3(signature = (image, operation, kernel_width, kernel_height, shape="rect", iterations=1))] +#[pyo3(signature = (image, operation, kernel_width, kernel_height, shape="rect", iterations=1, out=None, workspace=None))] fn morphology_image<'py>( py: Python<'py>, image: PyReadonlyArray2<'_, u8>, @@ -2219,6 +2369,8 @@ fn morphology_image<'py>( kernel_height: usize, shape: &str, iterations: usize, + out: Option>>, + mut workspace: Option>, ) -> PyResult>> { let mut packed = Vec::new(); let image = gray_u8_image_view_from_numpy(&image, &mut packed)?; @@ -2233,50 +2385,49 @@ fn morphology_image<'py>( ))) } }; - let element = - StructuringElement::try_new(shape, kernel_width, kernel_height).map_err(to_py_err)?; let operation = operation.to_ascii_lowercase(); let rect = shape == MorphologyShape::Rect; - let output = match operation.as_str() { - "erode" if rect => erode_rect_u8_op(image, &element, iterations, BorderMode::Replicate), - "dilate" if rect => dilate_rect_u8_op(image, &element, iterations, BorderMode::Replicate), - "erode" => spatialrust::vision::erode(image, &element, iterations, BorderMode::Replicate), - "dilate" => spatialrust::vision::dilate(image, &element, iterations, BorderMode::Replicate), - "open" if rect => morphology_rect_u8_op( - image, - MorphologyOperation::Open, - &element, - iterations, - BorderMode::Replicate, - ), - "close" if rect => morphology_rect_u8_op( - image, - MorphologyOperation::Close, - &element, - iterations, - BorderMode::Replicate, - ), - "gradient" if rect => morphology_rect_u8_op( - image, - MorphologyOperation::Gradient, - &element, - iterations, - BorderMode::Replicate, - ), - "tophat" | "top-hat" if rect => morphology_rect_u8_op( - image, - MorphologyOperation::TopHat, - &element, - iterations, - BorderMode::Replicate, - ), - "blackhat" | "black-hat" if rect => morphology_rect_u8_op( + if workspace.is_some() && !rect { + return Err(PyValueError::new_err( + "workspace reuse is available only for rectangular morphology", + )); + } + if rect { + if let Some(workspace) = workspace.as_deref_mut() { + let stale = match workspace.element.as_ref() { + Some((width, height, _)) => *width != kernel_width || *height != kernel_height, + None => true, + }; + if stale { + workspace.element = Some(( + kernel_width, + kernel_height, + StructuringElement::try_new(MorphologyShape::Rect, kernel_width, kernel_height) + .map_err(to_py_err)?, + )); + } + let PyMorphologyWorkspace { inner, element } = workspace; + let element = &element.as_ref().expect("cached rectangular element").2; + return morphology_rect_python(py, image, &operation, element, iterations, out, inner); + } + let element = + StructuringElement::try_new(shape, kernel_width, kernel_height).map_err(to_py_err)?; + let mut local_workspace = RectMorphologyWorkspace::new(); + return morphology_rect_python( + py, image, - MorphologyOperation::BlackHat, + &operation, &element, iterations, - BorderMode::Replicate, - ), + out, + &mut local_workspace, + ); + } + let element = + StructuringElement::try_new(shape, kernel_width, kernel_height).map_err(to_py_err)?; + let output = match operation.as_str() { + "erode" => spatialrust::vision::erode(image, &element, iterations, BorderMode::Replicate), + "dilate" => spatialrust::vision::dilate(image, &element, iterations, BorderMode::Replicate), "open" => morphology_ex_op( image, MorphologyOperation::Open, @@ -2317,6 +2468,29 @@ fn morphology_image<'py>( } } .map_err(to_py_err)?; + if let Some(out) = out { + { + let mut out_rw = out + .try_readwrite() + .map_err(|_| PyValueError::new_err("out must not overlap the morphology 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)", + )); + }; + out_slice.copy_from_slice(output.as_slice()); + } + return Ok(out); + } let array = Array2::from_shape_vec((image.height(), image.width()), output.into_vec()) .map_err(to_py_err)?; Ok(array.into_pyarray_bound(py)) @@ -3457,6 +3631,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 108e568..3d44a10 100644 --- a/crates/spatialrust-py/tests/test_bindings.py +++ b/crates/spatialrust-py/tests/test_bindings.py @@ -599,6 +599,51 @@ def test_rectangular_morphology_fast_path_accepts_noncontiguous_input(): np.testing.assert_array_equal(actual, expected) +def test_rectangular_morphology_reuses_output_and_workspace(): + image = np.arange(96 * 128, dtype=np.uint8).reshape(96, 128) + workspace = sr.MorphologyWorkspace() + output = np.empty_like(image) + assert workspace.capacity == 0 + + expected = sr.morphology_image(image, "open", 31, 17, "rect", 2) + returned = sr.morphology_image( + image, "open", 31, 17, "rect", 2, out=output, workspace=workspace + ) + assert returned is output + np.testing.assert_array_equal(output, expected) + capacity = workspace.capacity + line_capacity = workspace.line_capacity + assert capacity >= image.size + assert line_capacity >= image.shape[1] + 30 + + expected_close = sr.morphology_image(image, "close", 7, 5, "rect", 1) + returned = sr.morphology_image( + image, "close", 7, 5, "rect", 1, out=output, workspace=workspace + ) + assert returned is output + np.testing.assert_array_equal(output, expected_close) + assert workspace.capacity == capacity + assert workspace.line_capacity == line_capacity + + +def test_morphology_output_and_workspace_contract_errors(): + image = np.arange(8 * 10, dtype=np.uint8).reshape(8, 10) + workspace = sr.MorphologyWorkspace() + with pytest.raises(ValueError, match="out shape"): + sr.morphology_image( + image, "open", 3, 3, out=np.empty((8, 9), dtype=np.uint8), workspace=workspace + ) + backing = np.empty((8, 20), dtype=np.uint8) + with pytest.raises(ValueError, match="contiguous"): + sr.morphology_image( + image, "open", 3, 3, out=backing[:, ::2], workspace=workspace + ) + with pytest.raises(ValueError, match="only for rectangular"): + sr.morphology_image(image, "open", 3, 3, "ellipse", 1, workspace=workspace) + with pytest.raises(ValueError, match="must not overlap"): + sr.morphology_image(image, "open", 3, 3, out=image, workspace=workspace) + + def test_threshold_histogram_clahe_and_integral_contracts(): image = np.arange(9 * 11, dtype=np.uint8).reshape(9, 11)[:, ::-1] assert sr.threshold_image(image, 40).shape == image.shape diff --git a/crates/spatialrust-vision/benches/morphology.rs b/crates/spatialrust-vision/benches/morphology.rs index 88543f2..b4fe36b 100644 --- a/crates/spatialrust-vision/benches/morphology.rs +++ b/crates/spatialrust-vision/benches/morphology.rs @@ -1,8 +1,8 @@ use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use spatialrust_image::Image; use spatialrust_vision::{ - morphology_ex, morphology_rect_u8, BorderMode, MorphologyOperation, MorphologyShape, - StructuringElement, + morphology_ex, morphology_rect_u8, morphology_rect_u8_into, BorderMode, MorphologyOperation, + MorphologyShape, RectMorphologyWorkspace, StructuringElement, }; fn benchmark_morphology(c: &mut Criterion) { @@ -41,7 +41,7 @@ fn benchmark_morphology(c: &mut Criterion) { .collect(); let image = Image::::try_new(width, height, pixels).unwrap(); group.throughput(Throughput::Elements((width * height) as u64)); - group.bench_with_input(BenchmarkId::from_parameter(name), &image, |b, image| { + group.bench_with_input(BenchmarkId::new("allocate", name), &image, |b, image| { b.iter(|| { morphology_rect_u8( black_box(image.view()), @@ -53,6 +53,33 @@ fn benchmark_morphology(c: &mut Criterion) { .unwrap() }); }); + let mut output = vec![0; width * height]; + let mut workspace = RectMorphologyWorkspace::new(); + morphology_rect_u8_into( + image.view(), + MorphologyOperation::Open, + &element, + 1, + BorderMode::Replicate, + &mut output, + &mut workspace, + ) + .unwrap(); + group.bench_with_input(BenchmarkId::new("reuse", name), &image, |b, image| { + b.iter(|| { + morphology_rect_u8_into( + black_box(image.view()), + MorphologyOperation::Open, + &element, + 1, + BorderMode::Replicate, + &mut output, + &mut workspace, + ) + .unwrap(); + black_box(&output); + }); + }); } group.finish(); } diff --git a/crates/spatialrust-vision/src/morphology.rs b/crates/spatialrust-vision/src/morphology.rs index d20fd96..6b78941 100644 --- a/crates/spatialrust-vision/src/morphology.rs +++ b/crates/spatialrust-vision/src/morphology.rs @@ -203,7 +203,10 @@ pub fn erode_rect_u8( iterations: usize, border: BorderMode, ) -> VisionResult> { - rect_sequence(input, element, border, &[(Extreme::Minimum, iterations)]) + let mut output = vec![0; input.width() * input.height()]; + let mut workspace = RectMorphologyWorkspace::new(); + erode_rect_u8_into(input, element, iterations, border, &mut output, &mut workspace)?; + Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?) } /// Dilates a grayscale `u8` image with a rectangular element in linear time. @@ -213,7 +216,10 @@ pub fn dilate_rect_u8( iterations: usize, border: BorderMode, ) -> VisionResult> { - rect_sequence(input, element, border, &[(Extreme::Maximum, iterations)]) + let mut output = vec![0; input.width() * input.height()]; + let mut workspace = RectMorphologyWorkspace::new(); + dilate_rect_u8_into(input, element, iterations, border, &mut output, &mut workspace)?; + Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?) } /// Applies composite grayscale `u8` morphology using the rectangular fast path. @@ -224,38 +230,202 @@ pub fn morphology_rect_u8( iterations: usize, border: BorderMode, ) -> VisionResult> { - validate_rect(element)?; + let mut output = vec![0; input.width() * input.height()]; + let mut workspace = RectMorphologyWorkspace::new(); + morphology_rect_u8_into( + input, + operation, + element, + iterations, + border, + &mut output, + &mut workspace, + )?; + Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?) +} + +/// Reusable scratch storage for packed grayscale rectangular morphology. +/// +/// The workspace owns every full-image intermediate and one set of line +/// buffers per Rayon worker. Grow it once for the largest expected image and +/// element, then reuse it on the same worker thread. It never performs a +/// hidden device transfer and does not share mutable state between calls. +#[derive(Debug, Default)] +pub struct RectMorphologyWorkspace { + current: Vec, + horizontal: Vec, + transposed: Vec, + filtered: Vec, + output: Vec, + padded: Vec, + prefix: Vec, + suffix: Vec, + line_buffers: Vec, +} + +impl RectMorphologyWorkspace { + /// Creates an empty workspace that grows on first use. + #[must_use] + pub const fn new() -> Self { + Self { + current: Vec::new(), + horizontal: Vec::new(), + transposed: Vec::new(), + filtered: Vec::new(), + output: Vec::new(), + padded: Vec::new(), + prefix: Vec::new(), + suffix: Vec::new(), + line_buffers: Vec::new(), + } + } + + /// Returns the largest pixel count reserved by every full-image plane. + #[must_use] + pub fn capacity(&self) -> usize { + self.current + .capacity() + .min(self.horizontal.capacity()) + .min(self.transposed.capacity()) + .min(self.filtered.capacity()) + .min(self.output.capacity()) + } + + /// Returns the number of reusable parallel line-buffer sets. + #[must_use] + pub fn worker_capacity(&self) -> usize { + self.line_buffers.len() + } + + /// Returns the reusable element count reserved by every active line buffer. + #[must_use] + pub fn line_capacity(&self) -> usize { + if self.line_buffers.is_empty() { + return self.padded.capacity().min(self.prefix.capacity()).min(self.suffix.capacity()); + } + self.line_buffers + .iter() + .map(|buffers| { + buffers + .padded + .capacity() + .min(buffers.prefix.capacity()) + .min(buffers.suffix.capacity()) + }) + .min() + .unwrap_or(0) + } +} + +/// Erodes into caller-owned packed output using reusable scratch storage. +pub fn erode_rect_u8_into( + input: ImageView<'_, u8, 1>, + element: &StructuringElement, + iterations: usize, + border: BorderMode, + output: &mut [u8], + workspace: &mut RectMorphologyWorkspace, +) -> VisionResult<()> { + validate_output_len(output, input.width(), input.height())?; + run_rect_sequence(input, element, border, &[(Extreme::Minimum, iterations)], workspace)?; + output.copy_from_slice(&workspace.current); + Ok(()) +} + +/// Dilates into caller-owned packed output using reusable scratch storage. +pub fn dilate_rect_u8_into( + input: ImageView<'_, u8, 1>, + element: &StructuringElement, + iterations: usize, + border: BorderMode, + output: &mut [u8], + workspace: &mut RectMorphologyWorkspace, +) -> VisionResult<()> { + validate_output_len(output, input.width(), input.height())?; + run_rect_sequence(input, element, border, &[(Extreme::Maximum, iterations)], workspace)?; + output.copy_from_slice(&workspace.current); + Ok(()) +} + +/// Applies composite rectangular morphology into caller-owned packed output. +/// +/// `output` must contain exactly `input.width() * input.height()` elements. +/// Safe Rust borrowing requires input and output storage not to overlap. +#[allow(clippy::too_many_arguments)] +pub fn morphology_rect_u8_into( + input: ImageView<'_, u8, 1>, + operation: MorphologyOperation, + element: &StructuringElement, + iterations: usize, + border: BorderMode, + output: &mut [u8], + workspace: &mut RectMorphologyWorkspace, +) -> VisionResult<()> { + validate_output_len(output, input.width(), input.height())?; match operation { - MorphologyOperation::Open => rect_sequence( + MorphologyOperation::Open => run_rect_sequence( input, element, border, &[(Extreme::Minimum, iterations), (Extreme::Maximum, iterations)], - ), - MorphologyOperation::Close => rect_sequence( + workspace, + )?, + MorphologyOperation::Close => run_rect_sequence( input, element, border, &[(Extreme::Maximum, iterations), (Extreme::Minimum, iterations)], - ), + workspace, + )?, MorphologyOperation::Gradient => { - let high = dilate_rect_u8(input, element, iterations, border)?; - let low = erode_rect_u8(input, element, iterations, border)?; - subtract_u8(high, low) + run_rect_sequence( + input, + element, + border, + &[(Extreme::Maximum, iterations)], + workspace, + )?; + output.copy_from_slice(&workspace.current); + run_rect_sequence( + input, + element, + border, + &[(Extreme::Minimum, iterations)], + workspace, + )?; + for (high, &low) in output.iter_mut().zip(&workspace.current) { + *high = high.saturating_sub(low); + } + return Ok(()); } MorphologyOperation::TopHat => { - let original = pack(input)?; - let opened = - morphology_rect_u8(input, MorphologyOperation::Open, element, iterations, border)?; - subtract_u8(original, opened) + pack_u8_into(input, output); + run_rect_sequence( + input, + element, + border, + &[(Extreme::Minimum, iterations), (Extreme::Maximum, iterations)], + workspace, + )?; + for (original, &opened) in output.iter_mut().zip(&workspace.current) { + *original = original.saturating_sub(opened); + } + return Ok(()); } MorphologyOperation::BlackHat => { - let original = pack(input)?; - let closed = - morphology_rect_u8(input, MorphologyOperation::Close, element, iterations, border)?; - subtract_u8(closed, original) + run_rect_sequence( + input, + element, + border, + &[(Extreme::Maximum, iterations), (Extreme::Minimum, iterations)], + workspace, + )?; + copy_subtract_input(input, &workspace.current, output); + return Ok(()); } } + output.copy_from_slice(&workspace.current); + Ok(()) } #[derive(Clone, Copy)] @@ -274,19 +444,20 @@ fn validate_rect(element: &StructuringElement) -> VisionResult<()> { } } -fn rect_sequence( +fn run_rect_sequence( input: ImageView<'_, u8, 1>, element: &StructuringElement, border: BorderMode, stages: &[(Extreme, usize)], -) -> VisionResult> { + workspace: &mut RectMorphologyWorkspace, +) -> VisionResult<()> { validate_rect(element)?; - let metadata = input.metadata(); let (width, height) = (input.width(), input.height()); - let mut current = pack(input)?.into_vec(); - let mut workspace = RectWorkspace::default(); + workspace.current.resize(width * height, 0); + pack_u8_into(input, &mut workspace.current); for &(extreme, iterations) in stages { for _ in 0..iterations { + let current = std::mem::take(&mut workspace.current); workspace.apply( ¤t, width, @@ -298,24 +469,48 @@ fn rect_sequence( border, extreme, ); - std::mem::swap(&mut current, &mut workspace.output); + workspace.current = current; + std::mem::swap(&mut workspace.current, &mut workspace.output); } } - Ok(Image::try_new_with_metadata(width, height, current, metadata)?) + Ok(()) } -#[derive(Default)] -struct RectWorkspace { - horizontal: Vec, - transposed: Vec, - filtered: Vec, - output: Vec, - padded: Vec, - prefix: Vec, - suffix: Vec, +fn validate_output_len(output: &[u8], width: usize, height: usize) -> VisionResult<()> { + let len = width + .checked_mul(height) + .ok_or_else(|| VisionError::InvalidDimensions("morphology output size overflows".into()))?; + if output.len() != len { + return Err(VisionError::ShapeMismatch(format!( + "morphology output needs {len} elements, found {}", + output.len() + ))); + } + Ok(()) +} + +fn pack_u8_into(input: ImageView<'_, u8, 1>, output: &mut [u8]) { + for y in 0..input.height() { + let start = y * input.width(); + output[start..start + input.width()] + .copy_from_slice(input.row(y).expect("input row in bounds")); + } } -impl RectWorkspace { +fn copy_subtract_input(input: ImageView<'_, u8, 1>, high: &[u8], output: &mut [u8]) { + for y in 0..input.height() { + let start = y * input.width(); + for ((value, &closed), &original) in output[start..start + input.width()] + .iter_mut() + .zip(&high[start..start + input.width()]) + .zip(input.row(y).expect("input row in bounds")) + { + *value = closed.saturating_sub(original); + } + } +} + +impl RectMorphologyWorkspace { #[allow(clippy::too_many_arguments)] fn apply( &mut self, @@ -395,24 +590,31 @@ impl RectWorkspace { extreme: Extreme, ) { let len = width * height; + let workers = rayon::current_num_threads().min(width.max(height)).max(1); + self.line_buffers.resize_with(workers, LineBuffers::default); self.horizontal.resize(len, 0); - self.horizontal.par_chunks_mut(width).enumerate().for_each_init( - LineBuffers::default, - |buffers, (y, output)| { - let start = y * width; - filter_line( - &input[start..start + width], - output, - kernel_width, - anchor_x, - border, - extreme, - &mut buffers.padded, - &mut buffers.prefix, - &mut buffers.suffix, - ); - }, - ); + let horizontal_rows = height.div_ceil(workers); + self.horizontal + .par_chunks_mut(horizontal_rows * width) + .zip(self.line_buffers.par_iter_mut()) + .enumerate() + .for_each(|(chunk, (outputs, buffers))| { + let first_y = chunk * horizontal_rows; + for (local_y, output) in outputs.chunks_mut(width).enumerate() { + let start = (first_y + local_y) * width; + filter_line( + &input[start..start + width], + output, + kernel_width, + anchor_x, + border, + extreme, + &mut buffers.padded, + &mut buffers.prefix, + &mut buffers.suffix, + ); + } + }); self.transposed.resize(len, 0); self.transposed.par_chunks_mut(height).enumerate().for_each(|(x, output)| { @@ -421,23 +623,28 @@ impl RectWorkspace { } }); self.filtered.resize(len, 0); - self.filtered.par_chunks_mut(height).enumerate().for_each_init( - LineBuffers::default, - |buffers, (x, output)| { - let start = x * height; - filter_line( - &self.transposed[start..start + height], - output, - kernel_height, - anchor_y, - border, - extreme, - &mut buffers.padded, - &mut buffers.prefix, - &mut buffers.suffix, - ); - }, - ); + let vertical_rows = width.div_ceil(workers); + self.filtered + .par_chunks_mut(vertical_rows * height) + .zip(self.line_buffers.par_iter_mut()) + .enumerate() + .for_each(|(chunk, (outputs, buffers))| { + let first_x = chunk * vertical_rows; + for (local_x, output) in outputs.chunks_mut(height).enumerate() { + let start = (first_x + local_x) * height; + filter_line( + &self.transposed[start..start + height], + output, + kernel_height, + anchor_y, + border, + extreme, + &mut buffers.padded, + &mut buffers.prefix, + &mut buffers.suffix, + ); + } + }); self.output.resize(len, 0); self.output.par_chunks_mut(width).enumerate().for_each(|(y, output)| { for (x, value) in output.iter_mut().enumerate() { @@ -447,7 +654,7 @@ impl RectWorkspace { } } -#[derive(Default)] +#[derive(Debug, Default)] struct LineBuffers { padded: Vec, prefix: Vec, @@ -521,22 +728,6 @@ fn transpose_blocked(input: &[u8], output: &mut [u8], width: usize, height: usiz } } -fn subtract_u8(left: Image, right: Image) -> VisionResult> { - if left.width() != right.width() || left.height() != right.height() { - return Err(VisionError::ShapeMismatch( - "morphology subtraction dimensions must match".into(), - )); - } - let (width, height, metadata) = (left.width(), left.height(), left.metadata()); - let output = left - .into_vec() - .into_iter() - .zip(right.into_vec()) - .map(|(a, b)| a.saturating_sub(b)) - .collect(); - Ok(Image::try_new_with_metadata(width, height, output, metadata)?) -} - fn repeat_extreme( input: ImageView<'_, T, CHANNELS>, element: &StructuringElement, @@ -674,7 +865,8 @@ fn fill_ellipse(mask: &mut [bool], width: usize, height: usize) { mod tests { use super::{ dilate, dilate_rect_u8, erode, erode_rect_u8, morphology_ex, morphology_rect_u8, - MorphologyOperation, MorphologyShape, StructuringElement, + morphology_rect_u8_into, MorphologyOperation, MorphologyShape, RectMorphologyWorkspace, + StructuringElement, }; use crate::BorderMode; use spatialrust_image::{Image, ImageRegion}; @@ -831,4 +1023,104 @@ mod tests { let cross = StructuringElement::try_new(MorphologyShape::Cross, 3, 3).unwrap(); assert!(erode_rect_u8(image.view(), &cross, 1, BorderMode::Replicate).is_err()); } + + #[test] + fn rectangular_into_matches_allocating_path_for_strided_input() { + let parent = Image::::try_new( + 13, + 9, + (0..117).map(|index| ((index * 61 + 17) & 255) as u8).collect(), + ) + .unwrap(); + let input = parent.view().subview(ImageRegion::new(2, 1, 9, 7)).unwrap(); + let element = + StructuringElement::try_new_with_anchor(MorphologyShape::Rect, 4, 6, 1, 4).unwrap(); + let mut workspace = RectMorphologyWorkspace::new(); + let mut output = vec![0; input.width() * input.height()]; + + for operation in [ + MorphologyOperation::Open, + MorphologyOperation::Close, + MorphologyOperation::Gradient, + MorphologyOperation::TopHat, + MorphologyOperation::BlackHat, + ] { + morphology_rect_u8_into( + input, + operation, + &element, + 2, + BorderMode::Reflect101, + &mut output, + &mut workspace, + ) + .unwrap(); + assert_eq!( + output, + morphology_rect_u8(input, operation, &element, 2, BorderMode::Reflect101,) + .unwrap() + .into_vec() + ); + } + } + + #[test] + fn rectangular_workspace_reuses_full_image_and_worker_capacity() { + let image = Image::::try_new( + 1000, + 1000, + (0..1_000_000).map(|index| ((index * 29 + index / 1000) & 255) as u8).collect(), + ) + .unwrap(); + let element = StructuringElement::try_new(MorphologyShape::Rect, 5, 5).unwrap(); + let mut workspace = RectMorphologyWorkspace::new(); + let mut output = vec![0; 1_000_000]; + morphology_rect_u8_into( + image.view(), + MorphologyOperation::Open, + &element, + 1, + BorderMode::Replicate, + &mut output, + &mut workspace, + ) + .unwrap(); + let capacity = workspace.capacity(); + let workers = workspace.worker_capacity(); + let line_capacity = workspace.line_capacity(); + assert!(capacity >= output.len()); + assert!(workers >= 1); + assert!(line_capacity >= 1004); + + morphology_rect_u8_into( + image.view(), + MorphologyOperation::Close, + &element, + 1, + BorderMode::Replicate, + &mut output, + &mut workspace, + ) + .unwrap(); + assert_eq!(workspace.capacity(), capacity); + assert_eq!(workspace.worker_capacity(), workers); + assert_eq!(workspace.line_capacity(), line_capacity); + } + + #[test] + fn rectangular_into_validates_output_length() { + let image = Image::::from_pixel(4, 3, [9]).unwrap(); + let element = StructuringElement::try_new(MorphologyShape::Rect, 3, 3).unwrap(); + let mut workspace = RectMorphologyWorkspace::new(); + assert!(morphology_rect_u8_into( + image.view(), + MorphologyOperation::Open, + &element, + 1, + BorderMode::Replicate, + &mut [0; 11], + &mut workspace, + ) + .is_err()); + } } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 02d269c..c0de2aa 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -578,7 +578,7 @@ backend, allocation mode, and accuracy contract. | 114 | Planned | 112–113 | Safe size-aware CPU dispatch for packed fast paths, strided fallbacks, and bounded row/tile parallelism | | 115 | Planned | 113–114 | Accelerated resize and color conversion with precomputed sampling plans and fused preprocessing experiments | | 116 | Planned | 113–115 | Accelerated separable Gaussian and Sobel engine with cached kernels and shared gradient passes | -| 117 | In progress | 113–116 | Sliding-window morphology engine with exact OpenCV comparison and generic-mask fallback | +| 117 | Complete | 113–116 | Sliding-window morphology engine with exact OpenCV comparison and generic-mask fallback | | 118 | Planned | 113–117 | Fused Canny fast path that avoids public intermediates unless explicitly requested | | 119 | Planned | 104, 115–118 | Explicit upload-once GPU-resident vision chain with no intermediate readback | | 120 | Planned | 112–119 | Vision 2 cross-platform correctness, speed, memory, allocation, and transfer release gate | @@ -647,7 +647,7 @@ to one implicitly, and GPU receipts must retain named upload/readback stages. | --- | --- | --- | --- | | 117A | Complete | Separable sliding min/max for rectangular elements | generic-reference property tests and OpenCV receipt | | 117B | Complete | Packed rectangular `u8` dispatch and generic Cross/Ellipse/Diamond/custom-mask fallback | shape, border, stride, iteration, and anchor parity | -| 117C | Planned | Ping-pong workspace for iterations and composite operations | allocation and alias tests | +| 117C | Complete | Caller-owned output and reusable ping-pong/worker scratch for iterations and composite operations | capacity, alias, stride, object-identity, and OpenCV reuse receipt | | 117D | Complete | Improve morphology by at least 20x on one canonical large profile | 43.8× 4K 5×5 baseline improvement; bit-exact 511×511 OpenCV wins | ### Epic 118 delivery slices diff --git a/docs/site/morphology.html b/docs/site/morphology.html index 417d495..9577cd5 100644 --- a/docs/site/morphology.html +++ b/docs/site/morphology.html @@ -28,18 +28,19 @@

Choose the operation

CPU dispatch

Packed grayscale u8 rectangles use two separable one-dimensional min/max passes. Each line is split into prefix/suffix blocks, so work is linear in image size plus border padding rather than image size times rectangle area. Large images use bounded Rayon row/column stages; transpose buffers keep the column pass cache-friendly.

+

RectMorphologyWorkspace retains every full-image plane and one line-buffer set per worker. The *_into APIs write to caller-owned output. Python exposes the same contract as MorphologyWorkspace plus out=; overlapping input/output is rejected instead of silently copying.

Cross, ellipse, diamond, custom masks, other component types, channels, and strided views retain the safe generic implementation. Public CPU calls never upload to a GPU. Device morphology is a separate explicit wgpu API.

Accuracy and speed boundary

-

The OpenCV 4.13 comparison uses random packed uint8, replicated borders, allocated Python outputs, OpenCL off, and paired interleaved calls. All published timings first require bit-exact output; 980 additional randomized operation cases also pass.

+

The OpenCV 4.13 comparison uses random packed uint8, replicated borders, allocated and caller-owned Python outputs, OpenCL off, and paired interleaved calls. All published timings first require bit-exact output; 980 additional randomized operation cases also pass.

-
2.46×

SpatialRust lead for 1080p 511×511 opening on the recorded 12-thread Windows host.

-
2.06×

SpatialRust lead for 4K 511×511 opening on the same host.

+
3.25×

SpatialRust lead for 1080p 511×511 opening with caller-owned outputs on the recorded 12-thread Windows host.

+
2.77×

SpatialRust lead for 4K 511×511 opening with caller-owned outputs on the same host.

small kernels

OpenCV still leads 5×5. The result is a large-window crossover, not a blanket morphology claim.

-

Reproduce with the focused harness and inspect the dated receipt.

+

Reproduce with the focused harness and inspect the workspace receipt.

diff --git a/notes/2026-07-16_morphology_workspace_reuse.md b/notes/2026-07-16_morphology_workspace_reuse.md new file mode 100644 index 0000000..379e416 --- /dev/null +++ b/notes/2026-07-16_morphology_workspace_reuse.md @@ -0,0 +1,79 @@ +# Morphology workspace and OpenCV reuse receipt — 2026-07-16 + +## Outcome + +Epic 117C adds explicit caller-owned output and reusable host scratch to the +bit-exact rectangular morphology engine. On the recorded host, 511×511 opening +with both libraries writing caller-owned arrays is **3.25× faster than OpenCV +at 1080p** and **2.77× faster at 4K**. + +The public Rust surface is additive: + +- `RectMorphologyWorkspace` +- `erode_rect_u8_into` +- `dilate_rect_u8_into` +- `morphology_rect_u8_into` + +Python exposes `MorphologyWorkspace` and optional `out=` / `workspace=` +arguments on `morphology_image`. The binding caches the validated rectangular +element in the Python workspace, borrows packed NumPy input, returns the exact +caller-owned output object, and rejects overlapping input/output storage. + +## Reproduction environment + +- Windows 11 `10.0.26300`, AMD64 +- Intel Family 6 Model 158, 6 cores / 12 logical CPUs +- CPython 3.12.10 +- OpenCV 4.13.0, 12 reported threads, OpenCL disabled +- SpatialRust 1.0.0 release wheel +- seeded packed random grayscale `uint8`; `BORDER_REPLICATE` +- six warmups; paired/interleaved order; calls batched to at least 20 ms +- 30 VGA, 20 1080p, and 12 4K samples per kernel and allocation mode + +Run: + +```powershell +python bench/opencv_morphology_comparison/performance.py ` + --output target/opencv-morphology-workspace-performance.json +``` + +## Python API medians + +| Profile | Kernel | Mode | OpenCV | SpatialRust | Result | +| --- | ---: | --- | ---: | ---: | ---: | +| VGA | 5×5 | allocate | 0.131 ms | 8.010 ms | OpenCV 60.96× | +| VGA | 5×5 | reuse | 0.173 ms | 10.435 ms | OpenCV 60.32× | +| VGA | 511×511 | allocate | 8.582 ms | 18.034 ms | OpenCV 2.10× | +| VGA | 511×511 | reuse | 8.105 ms | 19.939 ms | OpenCV 2.46× | +| 1080p | 5×5 | allocate | 1.497 ms | 19.968 ms | OpenCV 13.34× | +| 1080p | 5×5 | reuse | 0.933 ms | 15.160 ms | OpenCV 16.25× | +| 1080p | 511×511 | allocate | 60.004 ms | 22.959 ms | **SpatialRust 2.61×** | +| 1080p | 511×511 | reuse | 60.287 ms | 18.562 ms | **SpatialRust 3.25×** | +| 4K | 5×5 | allocate | 5.473 ms | 83.590 ms | OpenCV 15.27× | +| 4K | 5×5 | reuse | 4.054 ms | 72.066 ms | OpenCV 17.78× | +| 4K | 511×511 | allocate | 211.419 ms | 88.032 ms | **SpatialRust 2.40×** | +| 4K | 511×511 | reuse | 221.212 ms | 80.001 ms | **SpatialRust 2.77×** | + +The comparison includes the Python API call. In reuse mode, OpenCV receives +`dst=` and SpatialRust receives both `out=` and `workspace=`. Workspace image +capacity stabilizes at exactly 2,073,600 pixels for 1080p and 8,294,400 pixels +for 4K, with 12 persistent worker line-buffer sets. + +## Correctness and ownership gates + +- bit-exact allocated and reused output for every timed profile; +- 980 seeded randomized OpenCV comparisons across all seven operations; +- Rust `*_into` parity for odd/even kernels, asymmetric anchors, oversized + rectangles, all border modes, multiple iterations, and strided input; +- full-image, worker-count, and line-buffer capacities remain unchanged on a + second same-size call; +- Python returns `out` by object identity; +- wrong shape, non-contiguous output, sparse-shape workspace use, and aliased + input/output are rejected. + +## Scope boundary + +Scratch reuse improves SpatialRust's steady-state large-window path but does +not erase OpenCV's SIMD advantage for small rectangles. The OpenCV-beating +claim remains limited to large rectangular windows on sufficiently large +images. Generic Cross/Ellipse/Diamond/custom masks do not use this workspace.