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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ ratio; these are machine-specific measurements, not universal guarantees.
| Bilinear resize, reuse[^resize-2026] | **SpatialRust 1.10×** | OpenCV 2.40× | OpenCV 2.01× |
| RGB to gray, allocate[^gray-2026] | OpenCV 1.73× | **SpatialRust 1.03×** | **SpatialRust 1.05×** |
| RGB to gray, reuse[^gray-2026] | OpenCV 1.22× | OpenCV 1.08× | OpenCV 1.03× |
| Fused 2× resize → gray, allocate[^fused-gray-2026] | — | **SpatialRust 1.12×** | OpenCV 1.01× |
| Fused 2× resize → gray, reuse[^fused-gray-2026] | — | OpenCV 1.90× | OpenCV 1.58× |
| Gaussian blur 5×5[^gaussian-2026] | OpenCV 139.02× | OpenCV 3.10× | OpenCV 2.93× |
| Sobel X 3×3 | OpenCV 14.38× | OpenCV 20.31× | OpenCV 23.30× |
| Morphology open 5×5, allocate[^morphology-2026] | OpenCV 60.96× | OpenCV 13.34× | OpenCV 15.27× |
Expand Down Expand Up @@ -197,6 +199,16 @@ records the exact environment and methodology.
wins. Three hundred randomized cases retain maximum absolute error 1. See
the [focused harness](bench/opencv_rgb_gray_comparison/).

[^fused-gray-2026]: `resize_rgb_to_gray` combines the reusable Q11 bilinear
plan and Q14 BT.601 conversion without materializing an intermediate RGB
image. For the canonical 1920×1080→960×540 allocated pipeline, SpatialRust
measured 0.677 ms versus OpenCV's two-call 0.755 ms (1.12×). The allocated
4K→1080p result was effectively tied (2.687 ms versus 2.665 ms), while
OpenCV leads 8K allocation and every caller-owned-output profile. The fused
result is bit-exact with SpatialRust's unfused path; 300 randomized cases
and canonical profiles differ from OpenCV by at most 1/255. See the
[focused harness](bench/opencv_fused_resize_gray_comparison/).

The additive paired-gradient path keeps standalone Sobel compatibility while
also exposing exact fused 3×3 L1 magnitude (`abs(Gx) + abs(Gy)`). On a newer
OpenCV 4.13 receipt, the fused allocated Python call is **1.86× faster at
Expand Down Expand Up @@ -297,6 +309,7 @@ The same deterministic RGB inputs passed all VGA, 1080p, and 4K gates:
| --- | --- |
| Bilinear resize | Canonical half-scale exact; 300 arbitrary-size cases max error 1/255 |
| RGB to gray | Max error 1/255; 99.72%–99.74% exact pixels across VGA–8K |
| Fused bilinear resize → gray | Exact versus SpatialRust unfused; OpenCV max error 1/255 across 300 randomized cases and 1080p–8K half reductions |
| AI CHW preprocess | Max float error `5.96e-8` |
| Gaussian blur | Canonical 5×5 profiles exact; 300 randomized 3×3/5×5/7×7 cases max error 2/255 |
| Sobel X 3×3 | Exact values (max error 0) |
Expand Down
14 changes: 14 additions & 0 deletions bench/opencv_fused_resize_gray_comparison/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# OpenCV fused resize-to-gray comparison

This harness compares a canonical two-stage OpenCV pipeline (`resize` followed
by `cvtColor`) with SpatialRust's single-pass bilinear RGB resize-to-gray API.
The profiles are camera-pyramid half reductions from 1080p, 4K, and 8K.

```powershell
.\.venv\Scripts\python.exe bench/opencv_fused_resize_gray_comparison/performance.py `
--output target/opencv-fused-resize-gray-performance.json
```

Allocated and caller-owned outputs are measured separately with paired,
interleaved samples. Randomized dimensions and non-contiguous inputs verify
exact parity with SpatialRust's unfused plan and bound OpenCV disagreement.
185 changes: 185 additions & 0 deletions bench/opencv_fused_resize_gray_comparison/performance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""Reproducible OpenCV resize+gray versus SpatialRust fused comparison."""

from __future__ import annotations

import argparse
import os
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 = {
"1080p_to_540p": (1920, 1080, 960, 540, 32),
"4k_to_1080p": (3840, 2160, 1920, 1080, 20),
"8k_to_4k": (7680, 4320, 3840, 2160, 10),
}


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


def opencv_pipeline(image: np.ndarray, width: int, height: int) -> np.ndarray:
resized = cv2.resize(image, (width, height), interpolation=cv2.INTER_LINEAR)
return cv2.cvtColor(resized, cv2.COLOR_RGB2GRAY)


def validate_randomized_cases() -> tuple[int, int]:
rng = np.random.default_rng(1153)
max_error = 0
for case in range(300):
height = int(rng.integers(2, 101))
width = int(rng.integers(2, 141))
output_height = int(rng.integers(1, 81))
output_width = int(rng.integers(1, 101))
image = rng.integers(0, 256, (height, width, 3), dtype=np.uint8)
if case % 3 == 0:
image = image[:, ::-1]
actual = sr.resize_rgb_to_gray_image(image, output_width, output_height)
unfused = sr.rgb_to_gray_image(
sr.resize_image(image, output_width, output_height)
)
if not np.array_equal(actual, unfused):
raise AssertionError(f"random case {case} differs from unfused SpatialRust")
expected = opencv_pipeline(
np.ascontiguousarray(image), output_width, output_height
)
error = int(np.abs(expected.astype(np.int16) - actual.astype(np.int16)).max())
if error > 2:
raise AssertionError(f"random case {case} max error {error} exceeds 2")
max_error = max(max_error, error)
return 300, max_error


def main() -> None:
args = parse_args()
profiles = [value.strip() for value in args.profiles.split(",") if value.strip()]
unknown = sorted(set(profiles) - PROFILES.keys())
if unknown:
raise ValueError(f"unknown profiles: {', '.join(unknown)}")
if hasattr(cv2, "ocl"):
cv2.ocl.setUseOpenCL(False)
cv2.setNumThreads(os.cpu_count() or 1)

randomized_cases, randomized_max_error = validate_randomized_cases()
rng = np.random.default_rng(20_260_716)
results: dict[str, object] = {}
for profile in profiles:
width, height, output_width, output_height, repeats = PROFILES[profile]
image = rng.integers(0, 256, (height, width, 3), dtype=np.uint8)
opencv_rgb = np.empty((output_height, output_width, 3), dtype=np.uint8)
opencv_out = np.empty((output_height, output_width), dtype=np.uint8)
spatialrust_out = np.empty_like(opencv_out)

def opencv_allocate() -> np.ndarray:
return opencv_pipeline(image, output_width, output_height)

def spatialrust_allocate() -> np.ndarray:
return sr.resize_rgb_to_gray_image(image, output_width, output_height)

def opencv_reuse() -> np.ndarray:
cv2.resize(
image,
(output_width, output_height),
dst=opencv_rgb,
interpolation=cv2.INTER_LINEAR,
)
return cv2.cvtColor(opencv_rgb, cv2.COLOR_RGB2GRAY, dst=opencv_out)

def spatialrust_reuse() -> np.ndarray:
return sr.resize_rgb_to_gray_image(
image, output_width, output_height, out=spatialrust_out
)

expected = opencv_allocate()
actual = spatialrust_allocate()
unfused = sr.rgb_to_gray_image(
sr.resize_image(image, output_width, output_height)
)
if not np.array_equal(actual, unfused):
raise AssertionError(f"{profile} differs from unfused SpatialRust")
error = np.abs(expected.astype(np.int16) - actual.astype(np.int16))
max_error = int(error.max())
if max_error > 2:
raise AssertionError(f"{profile} max error {max_error} exceeds 2")
if opencv_reuse() is not opencv_out or spatialrust_reuse() is not spatialrust_out:
raise AssertionError("caller-owned output identity was not preserved")

_, _, opencv_timing, spatialrust_timing = timed_pair(
opencv_allocate,
spatialrust_allocate,
warmup=args.warmup,
repeats=repeats,
seed=1153,
min_sample_time_ms=20.0,
)
_, _, opencv_reuse_timing, spatialrust_reuse_timing = timed_pair(
opencv_reuse,
spatialrust_reuse,
warmup=args.warmup,
repeats=repeats,
seed=2153,
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"])
results[profile] = {
"input_dimensions": [width, height],
"output_dimensions": [output_width, output_height],
"operation": "bilinear RGB8 resize followed by BT.601 gray",
"max_absolute_error": max_error,
"exact_fraction": float((error == 0).mean()),
"spatialrust_unfused_exact": True,
"opencv": opencv_timing,
"spatialrust": spatialrust_timing,
"spatialrust_speedup": opencv_ms / spatialrust_ms,
"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"
),
}

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-fused-resize-gray-performance",
kind="performance",
status="pass",
environment_receipt=receipt,
results={
"methodology": {
"timing_scope": "allocated and caller-owned-output Python API pipelines",
"paired_interleaved": True,
"minimum_sample_time_ms": 20.0,
"input": "seeded packed random uint8 RGB",
"randomized_correctness_cases": randomized_cases,
"randomized_max_absolute_error": randomized_max_error,
"accuracy": "exact versus SpatialRust unfused; OpenCV max error <= 2",
},
"profiles": results,
},
)
emit_report(report, args.output)


if __name__ == "__main__":
main()
8 changes: 7 additions & 1 deletion crates/spatialrust-py/spatialrust.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ __all__: list[str] = [
"threshold_image", "otsu_threshold_image", "adaptive_threshold_image",
"histogram_image", "equalize_histogram_image", "clahe_image",
"integral_image_u8", "canny_image", "resize_image", "letterbox_image",
"normalize_image_chw", "rgb_to_gray_image", "rgb_to_hsv_image", "remap_image",
"normalize_image_chw", "rgb_to_gray_image", "resize_rgb_to_gray_image", "rgb_to_hsv_image", "remap_image",
"nms", "batched_nms", "soft_nms", "connected_components_image", "distance_transform_edt",
"find_mask_contours",
"encode_mask_rle", "decode_mask_rle", "point_map_to_point_cloud", "knn_graph",
Expand Down Expand Up @@ -286,6 +286,12 @@ def normalize_image_chw(
out: Optional[_F32Array] = ...,
) -> _F32Array: ...
def rgb_to_gray_image(image: _U8Array, out: Optional[_U8Array] = ...) -> _U8Array: ...
def resize_rgb_to_gray_image(
image: _U8Array,
width: int,
height: int,
out: Optional[_U8Array] = ...,
) -> _U8Array: ...
def rgb_to_hsv_image(image: _U8Array) -> _U8Array: ...
def remap_image(
image: _U8Array,
Expand Down
42 changes: 41 additions & 1 deletion crates/spatialrust-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ use spatialrust::vision::{
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,
resize_into as resize_into_op, resize_rgb_to_gray as resize_rgb_to_gray_op,
resize_rgb_to_gray_into as resize_rgb_to_gray_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, sobel_l1_magnitude_u8 as sobel_l1_magnitude_u8_op,
sobel_l1_magnitude_u8_into as sobel_l1_magnitude_u8_into_op, soft_nms as soft_nms_op,
Expand Down Expand Up @@ -3367,6 +3368,44 @@ fn rgb_to_gray_image<'py>(
Ok(array.into_pyarray_bound(py))
}

/// Fuses bilinear resize and RGB-to-gray conversion into an `(H, W)` image.
#[pyfunction]
#[pyo3(signature = (image, width, height, out=None))]
fn resize_rgb_to_gray_image<'py>(
py: Python<'py>,
image: PyReadonlyArray3<'_, u8>,
width: usize,
height: usize,
out: Option<Bound<'py, PyArray2<u8>>>,
) -> PyResult<Bound<'py, PyArray2<u8>>> {
let mut packed = Vec::new();
let image = rgb_image_view_from_numpy(&image, &mut packed)?;
if let Some(out) = out {
{
let mut out_rw = out.readwrite();
let mut out_array = out_rw.as_array_mut();
if out_array.shape() != [height, width] {
return Err(PyValueError::new_err(format!(
"out shape must be ({height}, {width}), found {:?}",
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)",
));
};
let output =
ImageViewMut::<u8, 1>::new(width, height, width, out_slice).map_err(to_py_err)?;
resize_rgb_to_gray_into_op(image, output).map_err(to_py_err)?;
}
return Ok(out);
}
let output = resize_rgb_to_gray_op(image, width, height).map_err(to_py_err)?;
let array = Array2::from_shape_vec((height, width), output.into_vec()).map_err(to_py_err)?;
Ok(array.into_pyarray_bound(py))
}

/// Converts RGB to OpenCV-style uint8 HSV.
#[pyfunction]
fn rgb_to_hsv_image<'py>(
Expand Down Expand Up @@ -3886,6 +3925,7 @@ fn spatialrust_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(letterbox_image, m)?)?;
m.add_function(wrap_pyfunction!(normalize_image_chw, m)?)?;
m.add_function(wrap_pyfunction!(rgb_to_gray_image, m)?)?;
m.add_function(wrap_pyfunction!(resize_rgb_to_gray_image, m)?)?;
m.add_function(wrap_pyfunction!(rgb_to_hsv_image, m)?)?;
m.add_function(wrap_pyfunction!(remap_image, m)?)?;
m.add_function(wrap_pyfunction!(nms, m)?)?;
Expand Down
10 changes: 9 additions & 1 deletion crates/spatialrust-py/tests/test_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def test_exports_present():
"voxelize", "knn_graph", "chamfer_distance", "oriented_bounding_box",
"rgbd_to_point_cloud", "depth_to_xyz",
"resize_image", "letterbox_image", "normalize_image_chw",
"rgb_to_gray_image", "rgb_to_hsv_image", "remap_image",
"rgb_to_gray_image", "resize_rgb_to_gray_image", "rgb_to_hsv_image", "remap_image",
"nms", "batched_nms", "soft_nms", "connected_components_image", "distance_transform_edt",
"find_mask_contours", "encode_mask_rle", "decode_mask_rle",
"point_map_to_point_cloud",
Expand Down Expand Up @@ -171,6 +171,14 @@ def test_image_color_and_remap():
gray_out = np.empty((1, 2), dtype=np.uint8)
assert sr.rgb_to_gray_image(image, out=gray_out) is gray_out
np.testing.assert_array_equal(gray_out, gray)
source = np.tile(image, (2, 2, 1))
resized_rgb = sr.resize_image(source, 2, 1)
expected_resized_gray = sr.rgb_to_gray_image(resized_rgb)
resized_gray = sr.resize_rgb_to_gray_image(source, 2, 1)
np.testing.assert_array_equal(resized_gray, expected_resized_gray)
resized_gray_out = np.empty((1, 2), dtype=np.uint8)
assert sr.resize_rgb_to_gray_image(source, 2, 1, out=resized_gray_out) is resized_gray_out
np.testing.assert_array_equal(resized_gray_out, expected_resized_gray)
hsv = sr.rgb_to_hsv_image(image)
np.testing.assert_array_equal(hsv[0, 0], [0, 255, 255])
np.testing.assert_array_equal(hsv[0, 1], [60, 255, 255])
Expand Down
42 changes: 40 additions & 2 deletions crates/spatialrust-vision/benches/preprocess.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use spatialrust_image::Image;
use spatialrust_vision::{
letterbox, pack_chw, pack_chw_into, rgb_to_gray, rgb_to_gray_into, Interpolation,
letterbox, pack_chw, pack_chw_into, rgb_to_gray, rgb_to_gray_into, BilinearResizeU8Plan,
Interpolation,
};

fn benchmark_preprocess(c: &mut Criterion) {
Expand Down Expand Up @@ -50,5 +51,42 @@ fn benchmark_reusable_preprocess(c: &mut Criterion) {
}
}

criterion_group!(benches, benchmark_preprocess, benchmark_reusable_preprocess);
fn benchmark_fused_resize_gray(c: &mut Criterion) {
for &(name, width, height) in
&[("1080p_to_540p", 1920, 1080), ("4k_to_1080p", 3840, 2160), ("8k_to_4k", 7680, 4320)]
{
let output_width = width / 2;
let output_height = height / 2;
let input = Image::<u8, 3>::try_new(width, height, vec![127; width * height * 3]).unwrap();
let plan = BilinearResizeU8Plan::new(width, height, output_width, output_height).unwrap();
let mut resized =
Image::<u8, 3>::try_new(output_width, output_height, vec![0; width * height * 3 / 4])
.unwrap();
let mut gray =
Image::<u8, 1>::try_new(output_width, output_height, vec![0; width * height / 4])
.unwrap();
let mut group = c.benchmark_group("resize_rgb_to_gray_half_rgb8");
group.sample_size(10);
group.throughput(Throughput::Elements((output_width * output_height) as u64));
group.bench_function(BenchmarkId::new("unfused_reuse", name), |b| {
b.iter(|| {
plan.resize_into(black_box(input.view()), resized.view_mut()).unwrap();
rgb_to_gray_into(resized.view(), gray.view_mut()).unwrap();
});
});
group.bench_function(BenchmarkId::new("fused_reuse", name), |b| {
b.iter(|| {
plan.resize_rgb_to_gray_into(black_box(input.view()), gray.view_mut()).unwrap();
});
});
group.finish();
}
}

criterion_group!(
benches,
benchmark_preprocess,
benchmark_reusable_preprocess,
benchmark_fused_resize_gray
);
criterion_main!(benches);
Loading
Loading