Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions crates/spatialrust-py/spatialrust.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -182,13 +183,25 @@ 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,
kernel_height: int,
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(
Expand Down
161 changes: 114 additions & 47 deletions crates/spatialrust-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GaussianBlurU8Workspace> =
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>,
Expand All @@ -2171,63 +2202,98 @@ fn gaussian_blur_image<'py>(
sigma_x: f64,
sigma_y: Option<f64>,
out: Option<Bound<'py, PyArray3<u8>>>,
mut workspace: Option<PyRefMut<'_, PyGaussianBlurWorkspace>>,
) -> PyResult<Bound<'py, PyArray3<u8>>> {
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<Bound<'py, PyArray3<u8>>>,
workspace: &mut GaussianBlurU8Workspace,
) -> PyResult<Bound<'py, PyArray3<u8>>> {
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>(
Expand Down Expand Up @@ -4242,6 +4308,7 @@ fn spatialrust_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyTensor>()?;
m.add_class::<PyKeypoint2>()?;
m.add_class::<PyDistanceTransformWorkspace>()?;
m.add_class::<PyGaussianBlurWorkspace>()?;
m.add_class::<PyMorphologyWorkspace>()?;
m.add_class::<PyCannyWorkspace>()?;
m.add_class::<PyMultiObjectTracker>()?;
Expand Down
1 change: 1 addition & 0 deletions crates/spatialrust-py/stubtest_allowlist.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
spatialrust\.CannyWorkspace\.__init__
spatialrust\.DistanceTransformWorkspace\.__init__
spatialrust\.GaussianBlurWorkspace\.__init__
spatialrust\.MorphologyWorkspace\.__init__
44 changes: 44 additions & 0 deletions crates/spatialrust-py/tests/test_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions crates/spatialrust-vision/src/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<u16>()
+ self.high_precision_horizontal.capacity() * std::mem::size_of::<u32>()
+ (self.kernel_x.capacity() + self.kernel_y.capacity()) * std::mem::size_of::<u16>()
}
}

/// Applies a specialized 3×3, 5×5, or 7×7 Gaussian blur to interleaved `u8` input.
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
10 changes: 5 additions & 5 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading