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
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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×** |
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion bench/opencv_morphology_comparison/README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
56 changes: 55 additions & 1 deletion bench/opencv_morphology_comparison/performance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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

Expand All @@ -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",
Expand Down
17 changes: 16 additions & 1 deletion crates/spatialrust-py/spatialrust.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -196,13 +196,28 @@ 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,
kernel_width: int,
kernel_height: int,
shape: str = ...,
iterations: int = ...,
out: Optional[_U8Array] = ...,
workspace: Optional[MorphologyWorkspace] = ...,
) -> _U8Array: ...
def threshold_image(
image: _U8Array,
Expand Down
Loading
Loading