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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ 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 | OpenCV 805.78× | OpenCV 578.52× | OpenCV 774.40× |
| 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×** |
| 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 @@ -170,6 +171,14 @@ samples are produced by the [performance harness](bench/opencv_vision_comparison
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.
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).

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
and [`DistanceTransformWorkspace`](https://rsasaki0109.github.io/SpatialRust/spatialrust_vision/struct.DistanceTransformWorkspace.html),
Expand Down
15 changes: 15 additions & 0 deletions bench/opencv_morphology_comparison/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# 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
background-estimation/document workload where the window-area-independent
SpatialRust engine can overtake OpenCV on large images.

```powershell
python bench/opencv_morphology_comparison/performance.py `
--output target/opencv-morphology-performance.json
```

OpenCL is disabled, input is seeded packed random `uint8`, calls are paired and
interleaved, and every timing is gated by exact output equality. The report is
a machine-specific receipt, not a blanket speed guarantee.
166 changes: 166 additions & 0 deletions bench/opencv_morphology_comparison/performance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""Reproducible rectangular morphology comparison with OpenCV."""

from __future__ import annotations

import argparse
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),
}
KERNELS = (5, 511)


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path)
parser.add_argument("--profiles", default=",".join(PROFILES))
parser.add_argument("--kernels", default=",".join(map(str, KERNELS)))
parser.add_argument("--warmup", type=int, default=6)
return parser.parse_args()


def validate_randomized_cases() -> int:
rng = np.random.default_rng(117)
operations = {
"erode": cv2.MORPH_ERODE,
"dilate": cv2.MORPH_DILATE,
"open": cv2.MORPH_OPEN,
"close": cv2.MORPH_CLOSE,
"gradient": cv2.MORPH_GRADIENT,
"tophat": cv2.MORPH_TOPHAT,
"blackhat": cv2.MORPH_BLACKHAT,
}
checked = 0
for case in range(140):
height = int(rng.integers(1, 80))
width = int(rng.integers(1, 100))
kernel_width = int(rng.integers(1, 18))
kernel_height = int(rng.integers(1, 18))
iterations = int(rng.integers(0, 3))
image = rng.integers(0, 256, (height, width), dtype=np.uint8)
kernel = np.ones((kernel_height, kernel_width), dtype=np.uint8)
for name, code in operations.items():
expected = cv2.morphologyEx(
image,
code,
kernel,
iterations=iterations,
borderType=cv2.BORDER_REPLICATE,
)
actual = sr.morphology_image(
image, name, kernel_width, kernel_height, "rect", iterations
)
if not np.array_equal(actual, expected):
raise AssertionError(f"random case {case} failed for {name}")
checked += 1
return checked


def main() -> None:
args = parse_args()
profiles = [value.strip() for value in args.profiles.split(",") if value.strip()]
kernels = [int(value) for value in args.kernels.split(",") if value.strip()]
unknown = sorted(set(profiles) - PROFILES.keys())
if unknown:
raise ValueError(f"unknown profiles: {', '.join(unknown)}")
if any(value < 1 for value in kernels):
raise ValueError("kernel sizes must be positive")
if hasattr(cv2, "ocl"):
cv2.ocl.setUseOpenCL(False)

randomized_cases = validate_randomized_cases()
rng = np.random.default_rng(20_260_715)
results: dict[str, object] = {}
for profile in profiles:
width, height, repeats = PROFILES[profile]
image = rng.integers(0, 256, (height, width), dtype=np.uint8)
profile_results: dict[str, object] = {}
for kernel_size in kernels:
kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8)

def opencv_open() -> np.ndarray:
return cv2.morphologyEx(
image,
cv2.MORPH_OPEN,
kernel,
borderType=cv2.BORDER_REPLICATE,
)

def spatialrust_open() -> np.ndarray:
return sr.morphology_image(
image, "open", kernel_size, kernel_size, "rect", 1
)

expected = opencv_open()
actual = spatialrust_open()
if not np.array_equal(actual, expected):
raise AssertionError(f"{profile}/{kernel_size} is not bit-exact")
_, _, opencv_timing, spatialrust_timing = timed_pair(
opencv_open,
spatialrust_open,
warmup=args.warmup,
repeats=repeats,
seed=117 + kernel_size,
min_sample_time_ms=20.0,
)
opencv_ms = float(opencv_timing["median"])
spatialrust_ms = float(spatialrust_timing["median"])
profile_results[str(kernel_size)] = {
"width": width,
"height": height,
"kernel_width": kernel_size,
"kernel_height": kernel_size,
"operation": "open",
"iterations": 1,
"border": "replicate",
"exact": True,
"opencv": opencv_timing,
"spatialrust": spatialrust_timing,
"spatialrust_speedup": opencv_ms / spatialrust_ms,
"faster_implementation": (
"spatialrust" if spatialrust_ms < opencv_ms else "opencv"
),
}
results[profile] = profile_results

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-rectangular-morphology-performance",
kind="performance",
status="pass",
environment_receipt=receipt,
results={
"methodology": {
"timing_scope": "Python API call returning an allocated uint8 image",
"paired_interleaved": True,
"minimum_sample_time_ms": 20.0,
"input": "seeded packed random uint8 grayscale",
"randomized_correctness_cases": randomized_cases,
"thread_policy": "library defaults; OpenCV thread count recorded",
},
"profiles": results,
},
)
emit_report(report, args.output)


if __name__ == "__main__":
main()
112 changes: 83 additions & 29 deletions crates/spatialrust-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,31 +75,32 @@ 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,
detect_shi_tomasi as detect_shi_tomasi_op, dilate_rect_u8 as dilate_rect_u8_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,
estimate_homography_ransac as estimate_homography_ransac_op,
erode_rect_u8 as erode_rect_u8_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, 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, rgb_to_gray_into as rgb_to_gray_into_op,
rgb_to_hsv as rgb_to_hsv_op, scharr as scharr_op, sobel as sobel_op, soft_nms as soft_nms_op,
solve_pnp as solve_pnp_op, stereo_block_match as stereo_block_match_op,
stitch_panorama_pair as stitch_panorama_pair_op, threshold as threshold_op, AbsolutePose,
AdaptiveThresholdMethod, BinaryMask, BorderMode, BoundingBox2, CameraMatrix3, CannyOptions,
ConfidenceMap, Connectivity, CornerSelectionOptions, 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,
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,
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,
rgb_to_gray_into as rgb_to_gray_into_op, rgb_to_hsv as rgb_to_hsv_op, scharr as scharr_op,
sobel as sobel_op, soft_nms as soft_nms_op, solve_pnp as solve_pnp_op,
stereo_block_match as stereo_block_match_op, stitch_panorama_pair as stitch_panorama_pair_op,
threshold as threshold_op, AbsolutePose, AdaptiveThresholdMethod, BinaryMask, BorderMode,
BoundingBox2, CameraMatrix3, CannyOptions, ConfidenceMap, Connectivity, CornerSelectionOptions,
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,
};
use spatialrust::vision::{dense_flow_block_match as dense_flow_native, DenseFlowOptions};
use spatialrust::voxelize::{
Expand Down Expand Up @@ -704,6 +705,24 @@ fn gray_u8_image_from_numpy(array: PyReadonlyArray2<'_, u8>) -> PyResult<Image<u
Image::try_new(shape[1], shape[0], view.iter().copied().collect()).map_err(to_py_err)
}

fn gray_u8_image_view_from_numpy<'a, 'py>(
array: &'a PyReadonlyArray2<'py, u8>,
packed: &'a mut Vec<u8>,
) -> PyResult<ImageView<'a, u8, 1>> {
let shape = array.shape();
if shape.len() != 2 {
return Err(PyValueError::new_err("expected an (H, W) uint8 grayscale array"));
}
let (height, width) = (shape[0], shape[1]);
let data = if let Ok(slice) = array.as_slice() {
slice
} else {
packed.extend(array.as_array().iter().copied());
packed.as_slice()
};
ImageView::new(width, height, width, data).map_err(to_py_err)
}

/// Fits pinhole intrinsics from known camera-space points and image pixels.
#[pyfunction]
#[pyo3(signature = (camera_points, pixels, width, height, huber_delta=2.0, max_iterations=12))]
Expand Down Expand Up @@ -2201,7 +2220,8 @@ fn morphology_image<'py>(
shape: &str,
iterations: usize,
) -> PyResult<Bound<'py, PyArray2<u8>>> {
let image = gray_u8_image_from_numpy(image)?;
let mut packed = Vec::new();
let image = gray_u8_image_view_from_numpy(&image, &mut packed)?;
let shape = match shape.to_ascii_lowercase().as_str() {
"rect" | "rectangle" => MorphologyShape::Rect,
"cross" => MorphologyShape::Cross,
Expand All @@ -2216,43 +2236,77 @@ 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" => {
spatialrust::vision::erode(image.view(), &element, iterations, BorderMode::Replicate)
}
"dilate" => {
spatialrust::vision::dilate(image.view(), &element, iterations, BorderMode::Replicate)
}
"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(
image,
MorphologyOperation::BlackHat,
&element,
iterations,
BorderMode::Replicate,
),
"open" => morphology_ex_op(
image.view(),
image,
MorphologyOperation::Open,
&element,
iterations,
BorderMode::Replicate,
),
"close" => morphology_ex_op(
image.view(),
image,
MorphologyOperation::Close,
&element,
iterations,
BorderMode::Replicate,
),
"gradient" => morphology_ex_op(
image.view(),
image,
MorphologyOperation::Gradient,
&element,
iterations,
BorderMode::Replicate,
),
"tophat" | "top-hat" => morphology_ex_op(
image.view(),
image,
MorphologyOperation::TopHat,
&element,
iterations,
BorderMode::Replicate,
),
"blackhat" | "black-hat" => morphology_ex_op(
image.view(),
image,
MorphologyOperation::BlackHat,
&element,
iterations,
Expand Down
10 changes: 10 additions & 0 deletions crates/spatialrust-py/tests/test_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,16 @@ def test_morphology_operations_and_noncontiguous_input():
assert output.dtype == np.uint8


def test_rectangular_morphology_fast_path_accepts_noncontiguous_input():
image = np.arange(13 * 17, dtype=np.uint8).reshape(13, 17)
contiguous = image[:, ::-1].copy()
noncontiguous = image[:, ::-1]
for operation in ("erode", "dilate", "open", "close", "gradient", "tophat", "blackhat"):
expected = sr.morphology_image(contiguous, operation, 4, 6, "rect", 2)
actual = sr.morphology_image(noncontiguous, operation, 4, 6, "rect", 2)
np.testing.assert_array_equal(actual, expected)


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
Expand Down
Loading
Loading