diff --git a/crates/spatialrust-py/spatialrust.pyi b/crates/spatialrust-py/spatialrust.pyi index 5a4fc5c..5a330be 100644 --- a/crates/spatialrust-py/spatialrust.pyi +++ b/crates/spatialrust-py/spatialrust.pyi @@ -13,6 +13,7 @@ from numpy.typing import NDArray __version__: str __all__: list[str] = [ "__version__", "ImageMetadata", "Tensor", "Keypoint2", "OnnxRuntimeSession", + "GaussianBlurWorkspace", "DLPackTensorView", "PointCloud", "PipelineResult", "RegionResult", "DbscanResult", "GroundResult", "MultiPlaneResult", "SphereResult", "CylinderResult", "RegistrationResult", "MultiObjectTracker", "read_image", @@ -182,6 +183,17 @@ def rgbd_to_point_cloud( def filter2d_image( image: _U8Array, kernel: NDArray[np.float64], delta: float = ... ) -> _U8Array: ... + +@final +class GaussianBlurWorkspace: + """Reusable host scratch storage for RGB uint8 Gaussian blur.""" + + def __init__(self) -> None: ... + @property + def capacity(self) -> int: ... + @property + def allocated_bytes(self) -> int: ... + def gaussian_blur_image( image: _U8Array, kernel_width: int, @@ -189,6 +201,7 @@ def gaussian_blur_image( sigma_x: float, sigma_y: Optional[float] = ..., out: Optional[_U8Array] = ..., + workspace: Optional[GaussianBlurWorkspace] = ..., ) -> _U8Array: ... def median_blur_image(image: _U8Array, kernel_size: int) -> _U8Array: ... def bilateral_filter_image( diff --git a/crates/spatialrust-py/src/lib.rs b/crates/spatialrust-py/src/lib.rs index 7789242..6295d87 100644 --- a/crates/spatialrust-py/src/lib.rs +++ b/crates/spatialrust-py/src/lib.rs @@ -2153,16 +2153,47 @@ fn filter2d_image<'py>( } thread_local! { - /// Reused across every `gaussian_blur_image` call, since the Python entry point has - /// no way to pass a persisted workspace; without pooling, the horizontal intermediate - /// buffer would be re-allocated from scratch on every call, allocate or reuse alike. + /// Reused by convenience calls that omit an explicit Gaussian workspace. static POOLED_GAUSSIAN_BLUR_WORKSPACE: std::cell::RefCell = std::cell::RefCell::new(GaussianBlurU8Workspace::new()); } +/// Explicit reusable host scratch storage for RGB uint8 Gaussian blur. +#[pyclass(name = "GaussianBlurWorkspace")] +struct PyGaussianBlurWorkspace { + inner: GaussianBlurU8Workspace, +} + +#[pymethods] +impl PyGaussianBlurWorkspace { + #[new] + #[pyo3(signature = ())] + fn new() -> Self { + Self { inner: GaussianBlurU8Workspace::new() } + } + + #[getter] + fn capacity(&self) -> usize { + self.inner.capacity() + } + + #[getter] + fn allocated_bytes(&self) -> usize { + self.inner.allocated_bytes() + } +} + /// Applies a normalized Gaussian blur to an RGB image using Reflect101 borders. #[pyfunction] -#[pyo3(signature = (image, kernel_width, kernel_height, sigma_x, sigma_y=None, out=None))] +#[pyo3(signature = ( + image, + kernel_width, + kernel_height, + sigma_x, + sigma_y=None, + out=None, + workspace=None +))] fn gaussian_blur_image<'py>( py: Python<'py>, image: PyReadonlyArray3<'_, u8>, @@ -2171,63 +2202,98 @@ fn gaussian_blur_image<'py>( sigma_x: f64, sigma_y: Option, out: Option>>, + mut workspace: Option>, ) -> PyResult>> { let mut packed = Vec::new(); let image = rgb_image_view_from_numpy(&image, &mut packed)?; let sigma_y = sigma_y.unwrap_or(sigma_x); + if let Some(workspace) = workspace.as_mut() { + return gaussian_blur_image_with_workspace( + py, + image, + kernel_width, + kernel_height, + sigma_x, + sigma_y, + out, + &mut workspace.inner, + ); + } POOLED_GAUSSIAN_BLUR_WORKSPACE.with(|cell| { - let mut workspace = cell.borrow_mut(); - if let Some(out) = out { - { - let mut out_rw = out.try_readwrite().map_err(|_| { - PyValueError::new_err("out must not overlap the Gaussian input") - })?; - let mut out_array = out_rw.as_array_mut(); - if out_array.shape() != [image.height(), image.width(), 3] { - return Err(PyValueError::new_err(format!( - "out shape must be ({}, {}, 3), 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, 3)", - )); - }; - gaussian_blur_u8_into_op( - image, - kernel_width, - kernel_height, - sigma_x, - sigma_y, - BorderMode::Reflect101, - out_slice, - &mut workspace, - ) - .map_err(to_py_err)?; - } - return Ok(out); - } - let mut output = vec![0_u8; image.width() * image.height() * 3]; - gaussian_blur_u8_into_op( + gaussian_blur_image_with_workspace( + py, image, kernel_width, kernel_height, sigma_x, sigma_y, - BorderMode::Reflect101, - &mut output, - &mut workspace, + out, + &mut cell.borrow_mut(), ) - .map_err(to_py_err)?; - let array = Array3::from_shape_vec((image.height(), image.width(), 3), output) - .map_err(to_py_err)?; - Ok(array.into_pyarray_bound(py)) }) } +#[allow(clippy::too_many_arguments)] +fn gaussian_blur_image_with_workspace<'py>( + py: Python<'py>, + image: ImageView<'_, u8, 3>, + kernel_width: usize, + kernel_height: usize, + sigma_x: f64, + sigma_y: f64, + out: Option>>, + workspace: &mut GaussianBlurU8Workspace, +) -> PyResult>> { + if let Some(out) = out { + { + let mut out_rw = out + .try_readwrite() + .map_err(|_| PyValueError::new_err("out must not overlap the Gaussian input"))?; + let mut out_array = out_rw.as_array_mut(); + if out_array.shape() != [image.height(), image.width(), 3] { + return Err(PyValueError::new_err(format!( + "out shape must be ({}, {}, 3), 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, 3)", + )); + }; + gaussian_blur_u8_into_op( + image, + kernel_width, + kernel_height, + sigma_x, + sigma_y, + BorderMode::Reflect101, + out_slice, + workspace, + ) + .map_err(to_py_err)?; + } + return Ok(out); + } + let mut output = vec![0_u8; image.width() * image.height() * 3]; + gaussian_blur_u8_into_op( + image, + kernel_width, + kernel_height, + sigma_x, + sigma_y, + BorderMode::Reflect101, + &mut output, + workspace, + ) + .map_err(to_py_err)?; + let array = Array3::from_shape_vec((image.height(), image.width(), 3), output) + .map_err(to_py_err)?; + Ok(array.into_pyarray_bound(py)) +} + /// Applies an odd-aperture median filter to an RGB image. #[pyfunction] fn median_blur_image<'py>( @@ -4242,6 +4308,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/stubtest_allowlist.txt b/crates/spatialrust-py/stubtest_allowlist.txt index 3b2ebba..d86698a 100644 --- a/crates/spatialrust-py/stubtest_allowlist.txt +++ b/crates/spatialrust-py/stubtest_allowlist.txt @@ -1,3 +1,4 @@ spatialrust\.CannyWorkspace\.__init__ spatialrust\.DistanceTransformWorkspace\.__init__ +spatialrust\.GaussianBlurWorkspace\.__init__ spatialrust\.MorphologyWorkspace\.__init__ diff --git a/crates/spatialrust-py/tests/test_bindings.py b/crates/spatialrust-py/tests/test_bindings.py index 06f004c..c24f288 100644 --- a/crates/spatialrust-py/tests/test_bindings.py +++ b/crates/spatialrust-py/tests/test_bindings.py @@ -604,6 +604,50 @@ def test_gaussian_output_validation(): sr.gaussian_blur_image(image, 5, 5, 1.2, out=image) +def test_gaussian_reuses_explicit_workspace_and_output(): + image = np.arange(96 * 128 * 3, dtype=np.uint8).reshape(96, 128, 3) + workspace = sr.GaussianBlurWorkspace() + output = np.empty_like(image) + assert workspace.capacity == 0 + assert workspace.allocated_bytes == 0 + + expected = sr.gaussian_blur_image(image[:, ::-1], 7, 5, 1.4, 0.9) + returned = sr.gaussian_blur_image( + image[:, ::-1], + 7, + 5, + 1.4, + 0.9, + out=output, + workspace=workspace, + ) + assert returned is output + np.testing.assert_array_equal(output, expected) + capacity = workspace.capacity + allocated_bytes = workspace.allocated_bytes + assert capacity >= image.size + assert allocated_bytes > 0 + + assert ( + sr.gaussian_blur_image( + image, 3, 3, 0.8, out=output, workspace=workspace + ) + is output + ) + assert workspace.capacity == capacity + assert workspace.allocated_bytes >= allocated_bytes + warmed_allocated_bytes = workspace.allocated_bytes + + assert ( + sr.gaussian_blur_image( + image, 7, 5, 1.4, 0.9, out=output, workspace=workspace + ) + is output + ) + assert workspace.capacity == capacity + assert workspace.allocated_bytes == warmed_allocated_bytes + + def test_advanced_filters_and_pyramid_shapes(): image = np.arange(9 * 11 * 3, dtype=np.uint8).reshape(9, 11, 3) assert sr.median_blur_image(image[:, ::-1], 3).shape == image.shape diff --git a/crates/spatialrust-vision/src/filter.rs b/crates/spatialrust-vision/src/filter.rs index 9c80eb2..1ad3c94 100644 --- a/crates/spatialrust-vision/src/filter.rs +++ b/crates/spatialrust-vision/src/filter.rs @@ -277,6 +277,14 @@ impl GaussianBlurU8Workspace { pub fn capacity(&self) -> usize { self.horizontal.capacity().max(self.high_precision_horizontal.capacity()) } + + /// Returns bytes reserved by the intermediate buffers and cached kernels. + #[must_use] + pub fn allocated_bytes(&self) -> usize { + self.horizontal.capacity() * std::mem::size_of::() + + self.high_precision_horizontal.capacity() * std::mem::size_of::() + + (self.kernel_x.capacity() + self.kernel_y.capacity()) * std::mem::size_of::() + } } /// Applies a specialized 3×3, 5×5, or 7×7 Gaussian blur to interleaved `u8` input. @@ -1142,6 +1150,8 @@ mod tests { ) .unwrap(); let capacity = workspace.capacity(); + let allocated_bytes = workspace.allocated_bytes(); + assert!(allocated_bytes > 0); gaussian_blur_u8_into( image.view(), 5, @@ -1154,6 +1164,7 @@ mod tests { ) .unwrap(); assert_eq!(workspace.capacity(), capacity); + assert_eq!(workspace.allocated_bytes(), allocated_bytes); assert!(gaussian_blur_u8_into( image.view(), 5, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 36acdc3..cfd99dd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -574,7 +574,7 @@ backend, allocation mode, and accuracy contract. | Epic | Status | Depends on | Outcome | | --- | --- | --- | --- | | 112 | Complete | 111 | Attribute native kernel, allocation, Python conversion, and transfer costs with reproducible throughput and memory receipts | -| 113 | Planned | 112 | Caller-owned outputs and reusable workspaces for multi-stage CPU vision without hidden copies | +| 113 | Complete | 112 | Caller-owned outputs and reusable workspaces for multi-stage CPU vision without hidden copies | | 114 | Planned | 112–113 | Safe size-aware CPU dispatch for packed fast paths, strided fallbacks, and bounded row/tile parallelism | | 115 | Complete | 113–114 | Accelerated resize and color conversion with precomputed sampling plans and fused preprocessing experiments | | 116 | Complete | 113–115 | Accelerated separable Gaussian and Sobel engine with cached kernels and shared gradient passes | @@ -602,10 +602,10 @@ to one implicitly, and GPU receipts must retain named upload/readback stages. | Slice | Status | Scope | Evidence | | --- | --- | --- | --- | -| 113A | Planned | `*_into` entry points for Gaussian, Sobel, morphology, and Canny | packed/strided identity and padding tests | -| 113B | Planned | Explicit reusable scratch storage for multi-pass algorithms | steady-state allocation receipt | -| 113C | Planned | Validate dimensions, metadata, overlap, and channel contracts | negative and property tests | -| 113D | Planned | Reuse outputs through Python `out=` where supported | object-identity and numerical tests | +| 113A | Complete | `*_into` entry points for Gaussian, Sobel, morphology, and Canny | packed/strided identity and padding tests | +| 113B | Complete | Explicit reusable scratch storage for multi-pass algorithms | Gaussian/morphology/Canny steady-state capacity and allocation receipts | +| 113C | Complete | Validate dimensions, metadata, overlap, and channel contracts | negative, strided, metadata, and property tests | +| 113D | Complete | Reuse outputs through Python `out=` where supported | Gaussian/Sobel/morphology/Canny object-identity and numerical tests | | 113E | Complete | Exact EDT caller-owned output and explicit reusable scratch | Rust/Python identity, capacity, and brute-force tests | ### Epic 114 delivery slices diff --git a/notes/2026-07-16_epic113_caller_owned_vision.md b/notes/2026-07-16_epic113_caller_owned_vision.md new file mode 100644 index 0000000..870758b --- /dev/null +++ b/notes/2026-07-16_epic113_caller_owned_vision.md @@ -0,0 +1,60 @@ +# Epic 113 caller-owned CPU vision receipt + +Date: 2026-07-16 + +Epic 113 closes the caller-owned output and reusable CPU scratch contract for +Gaussian blur, Sobel, rectangular morphology, Canny, and exact Euclidean +distance transform. + +## Delivered surface + +- `C:\Users\rsasa\Workspace\SpatialRust\crates\spatialrust-vision\src\filter.rs` + exposes packed Gaussian `*_into` execution with caller-owned output, + `GaussianBlurU8Workspace`, stable capacity reuse, cached kernels, and explicit + reserved-byte reporting. +- `C:\Users\rsasa\Workspace\SpatialRust\crates\spatialrust-vision\src\advanced_filter.rs` + exposes caller-owned direct, absolute, threshold, paired-gradient, and fused + L1 Sobel outputs. The direct signed path writes without a full-image + intermediate. +- `C:\Users\rsasa\Workspace\SpatialRust\crates\spatialrust-vision\src\morphology.rs` + owns full-image and per-worker line scratch in + `RectMorphologyWorkspace`. +- `C:\Users\rsasa\Workspace\SpatialRust\crates\spatialrust-vision\src\canny.rs` + accepts packed or strided output views and retains gradient, magnitude-ring, + state, and frontier storage in `CannyWorkspace`. +- `C:\Users\rsasa\Workspace\SpatialRust\crates\spatialrust-py\src\lib.rs` + supports Python `out=` identity for all four algorithm families and now + exposes `GaussianBlurWorkspace` alongside the existing morphology, Canny, + and distance-transform workspaces. Omitting it retains the convenience + thread-local pool; passing it makes scratch ownership explicit. + +No CPU entry point selects a GPU or performs a device transfer. NumPy overlap +checks reject input/output aliasing before mutable output access. + +## Correctness and ownership gates + +- Gaussian packed output length, NumPy shape, contiguity, overlap, strided + input, output identity, capacity reuse, and reserved-byte stability. +- Sobel packed output lengths, derivative/channel contracts, strided input, + metadata preservation, Python output identity, and overlap rejection. +- Morphology packed output length, rectangular-workspace eligibility, generic + fallback parity, strided input, output identity, overlap rejection, and + steady-state full-image/worker/line capacity. +- Canny dimension validation, packed/strided output padding preservation, + binary parity, Python output identity, and steady-state allocation bounds. +- Exact EDT caller-owned output and workspace coverage remains recorded by + Epic 113E. + +## Validation + +```powershell +cargo test -p spatialrust-vision +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace --no-run +cargo check --manifest-path crates\spatialrust-py\Cargo.toml +pytest crates\spatialrust-py\tests +python -m mypy.stubtest spatialrust --ignore-missing-stub --allowlist crates\spatialrust-py\stubtest_allowlist.txt --ignore-unused-allowlist +``` + +This receipt introduces no new performance comparison or portable speed claim. +It records ownership, allocation-reuse, and correctness contracts only.