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 @@ -152,6 +152,8 @@ ratio; these are machine-specific measurements, not universal guarantees.
| --- | ---: | ---: | ---: |
| AI CHW preprocess, allocate | **SpatialRust 4.48×** | **SpatialRust 9.27×** | **SpatialRust 9.14×** |
| AI CHW preprocess, reuse vs OpenCV allocate | **SpatialRust 8.16×** | **SpatialRust 14.56×** | **SpatialRust 15.78×** |
| Fused resize → normalized CHW, allocate[^fused-chw-2026] | — | **SpatialRust 2.21×** | **SpatialRust 2.02×** |
| Fused resize → normalized CHW, reuse vs OpenCV allocate[^fused-chw-2026] | — | **SpatialRust 3.56×** | **SpatialRust 3.02×** |
| Bilinear resize, allocate[^resize-2026] | OpenCV 1.19× | OpenCV 1.49× | OpenCV 1.60× |
| 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×** |
Expand Down Expand Up @@ -209,6 +211,16 @@ records the exact environment and methodology.
and canonical profiles differ from OpenCV by at most 1/255. See the
[focused harness](bench/opencv_fused_resize_gray_comparison/).

[^fused-chw-2026]: `resize_pack_chw` combines Q11 bilinear resize, `f32`
scaling/normalization, and planar CHW packing without an intermediate HWC
image. Against OpenCV 4.13 `dnn.blobFromImage`, allocated calls measured
1.617 ms versus 3.570 ms for 1080p→640×640 and 2.117 ms versus 4.272 ms for
4K→640×640. The 4K→1280×720 profile measured 3.592 ms versus 8.359 ms
(SpatialRust 2.33×). Caller-owned SpatialRust output is 3.02×–3.56× faster
than OpenCV allocation. Three hundred randomized cases are bit-exact with
the SpatialRust unfused path and differ from OpenCV by at most 1/255. See
the [focused harness](bench/opencv_fused_resize_chw_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 @@ -311,6 +323,7 @@ The same deterministic RGB inputs passed all VGA, 1080p, and 4K gates:
| 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` |
| Fused resize → normalized CHW | Exact versus SpatialRust unfused; OpenCV max float error `0.003921628` across 300 randomized cases |
| 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) |
| Morphology open 5×5 | Exact pixels (max error 0) |
Expand Down
15 changes: 15 additions & 0 deletions bench/opencv_fused_resize_chw_comparison/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# OpenCV fused resize-normalize-CHW comparison

This harness compares OpenCV's integrated `cv2.dnn.blobFromImage` against
SpatialRust's fused bilinear RGB resize, float normalization, and CHW packing.
It covers common 640×640 and 1280×720 model-input shapes.

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

OpenCL is disabled and paired timings measure SpatialRust allocated and
caller-owned outputs separately against the OpenCV integrated call. Three
hundred arbitrary-size cases, including non-contiguous inputs, require exact
SpatialRust fused/unfused parity and bound OpenCV float disagreement.
182 changes: 182 additions & 0 deletions bench/opencv_fused_resize_chw_comparison/performance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""Reproducible OpenCV blob versus SpatialRust fused resize-to-CHW 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_640": (1920, 1080, 640, 640, 32),
"4k_to_640": (3840, 2160, 640, 640, 20),
"4k_to_720p": (3840, 2160, 1280, 720, 16),
}
SCALE = 1.0 / 255.0


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_blob(image: np.ndarray, width: int, height: int) -> np.ndarray:
return cv2.dnn.blobFromImage(
image,
scalefactor=SCALE,
size=(width, height),
mean=(0.0, 0.0, 0.0),
swapRB=False,
crop=False,
)[0]


def validate_randomized_cases() -> tuple[int, float]:
rng = np.random.default_rng(1154)
max_error = 0.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_normalize_image_chw(image, output_width, output_height)
unfused = sr.normalize_image_chw(
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_blob(
np.ascontiguousarray(image), output_width, output_height
)
error = float(np.max(np.abs(expected - actual), initial=0.0))
if error > SCALE + 1e-7:
raise AssertionError(f"random case {case} max error {error} exceeds 1/255")
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)
spatialrust_out = np.empty((3, output_height, output_width), dtype=np.float32)

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

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

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

expected = opencv_allocate()
actual = spatialrust_allocate()
unfused = sr.normalize_image_chw(
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 - actual)
max_error = float(np.max(error, initial=0.0))
if max_error > SCALE + 1e-7:
raise AssertionError(f"{profile} max error {max_error} exceeds 1/255")
if spatialrust_reuse() is not spatialrust_out:
raise AssertionError("caller-owned output identity was not preserved")
if not np.array_equal(spatialrust_out, actual):
raise AssertionError(f"{profile} reuse output differs from allocation")

_, _, opencv_timing, spatialrust_timing = timed_pair(
opencv_allocate,
spatialrust_allocate,
warmup=args.warmup,
repeats=repeats,
seed=1154,
min_sample_time_ms=20.0,
)
_, _, opencv_reuse_reference, spatialrust_reuse_timing = timed_pair(
opencv_allocate,
spatialrust_reuse,
warmup=args.warmup,
repeats=repeats,
seed=2154,
min_sample_time_ms=20.0,
)
opencv_ms = float(opencv_timing["median"])
spatialrust_ms = float(spatialrust_timing["median"])
opencv_reuse_ms = float(opencv_reuse_reference["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, float scale, CHW pack",
"max_absolute_error": max_error,
"exact_fraction": float((error == 0.0).mean()),
"spatialrust_unfused_exact": True,
"opencv_blob": opencv_timing,
"spatialrust": spatialrust_timing,
"spatialrust_speedup": opencv_ms / spatialrust_ms,
"opencv_blob_reuse_reference": opencv_reuse_reference,
"spatialrust_reuse": spatialrust_reuse_timing,
"spatialrust_reuse_speedup": opencv_reuse_ms / spatialrust_reuse_ms,
}

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-normalize-chw-performance",
kind="performance",
status="pass",
environment_receipt=receipt,
results={
"methodology": {
"opencv_reference": "cv2.dnn.blobFromImage",
"timing_scope": "allocated calls; SpatialRust caller-owned output also compared to OpenCV allocation",
"paired_interleaved": True,
"minimum_sample_time_ms": 20.0,
"scale": SCALE,
"mean": [0.0, 0.0, 0.0],
"std": [1.0, 1.0, 1.0],
"randomized_correctness_cases": randomized_cases,
"randomized_max_absolute_error": randomized_max_error,
"accuracy": "exact versus SpatialRust unfused; OpenCV max error <= 1/255",
},
"profiles": results,
},
)
emit_report(report, args.output)


if __name__ == "__main__":
main()
11 changes: 10 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", "resize_rgb_to_gray_image", "rgb_to_hsv_image", "remap_image",
"normalize_image_chw", "resize_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 @@ -285,6 +285,15 @@ def normalize_image_chw(
std: Optional[tuple[float, float, float]] = ...,
out: Optional[_F32Array] = ...,
) -> _F32Array: ...
def resize_normalize_image_chw(
image: _U8Array,
width: int,
height: int,
scale: float = ...,
mean: Optional[tuple[float, float, float]] = ...,
std: Optional[tuple[float, float, float]] = ...,
out: Optional[_F32Array] = ...,
) -> _F32Array: ...
def rgb_to_gray_image(image: _U8Array, out: Optional[_U8Array] = ...) -> _U8Array: ...
def resize_rgb_to_gray_image(
image: _U8Array,
Expand Down
47 changes: 46 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, resize_rgb_to_gray as resize_rgb_to_gray_op,
resize_into as resize_into_op, resize_pack_chw as resize_pack_chw_op,
resize_pack_chw_into as resize_pack_chw_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,
Expand Down Expand Up @@ -3328,6 +3329,49 @@ fn normalize_image_chw<'py>(
Ok(array.into_pyarray_bound(py))
}

/// Fuses bilinear RGB resize, normalization, and CHW packing.
#[pyfunction]
#[pyo3(signature = (image, width, height, scale=1.0/255.0, mean=None, std=None, out=None))]
#[allow(clippy::too_many_arguments)]
fn resize_normalize_image_chw<'py>(
py: Python<'py>,
image: PyReadonlyArray3<'_, u8>,
width: usize,
height: usize,
scale: f32,
mean: Option<(f32, f32, f32)>,
std: Option<(f32, f32, f32)>,
out: Option<Bound<'py, PyArray3<f32>>>,
) -> PyResult<Bound<'py, PyArray3<f32>>> {
let mut packed = Vec::new();
let image = rgb_image_view_from_numpy(&image, &mut packed)?;
let mean = mean.map_or([0.0; 3], |(r, g, b)| [r, g, b]);
let std = std.map_or([1.0; 3], |(r, g, b)| [r, g, b]);
if let Some(out) = out {
{
let mut out_rw = out.readwrite();
let mut out_array = out_rw.as_array_mut();
if out_array.shape() != [3, height, width] {
return Err(PyValueError::new_err(format!(
"out shape must be (3, {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 float32 array of shape (3, H, W)",
));
};
resize_pack_chw_into_op(image, width, height, scale, mean, std, out_slice)
.map_err(to_py_err)?;
}
return Ok(out);
}
let output = resize_pack_chw_op(image, width, height, scale, mean, std).map_err(to_py_err)?;
let array = Array3::from_shape_vec((3, height, width), output.into_vec()).map_err(to_py_err)?;
Ok(array.into_pyarray_bound(py))
}

/// Converts an RGB image to an `(H, W)` grayscale image.
#[pyfunction]
#[pyo3(signature = (image, out=None))]
Expand Down Expand Up @@ -3924,6 +3968,7 @@ fn spatialrust_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(resize_image, m)?)?;
m.add_function(wrap_pyfunction!(letterbox_image, m)?)?;
m.add_function(wrap_pyfunction!(normalize_image_chw, m)?)?;
m.add_function(wrap_pyfunction!(resize_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)?)?;
Expand Down
8 changes: 7 additions & 1 deletion crates/spatialrust-py/tests/test_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def test_exports_present():
"PointCloud", "voxel_downsample", "dbscan", "register_icp",
"voxelize", "knn_graph", "chamfer_distance", "oriented_bounding_box",
"rgbd_to_point_cloud", "depth_to_xyz",
"resize_image", "letterbox_image", "normalize_image_chw",
"resize_image", "letterbox_image", "normalize_image_chw", "resize_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",
Expand Down Expand Up @@ -161,6 +161,12 @@ def test_image_resize_letterbox_and_normalize():
chw_out = np.empty((3, 2, 2), dtype=np.float32)
assert sr.normalize_image_chw(image, out=chw_out) is chw_out
np.testing.assert_allclose(chw_out, chw, atol=1e-6)
fused_chw = sr.resize_normalize_image_chw(image, 4, 3)
expected_fused_chw = sr.normalize_image_chw(sr.resize_image(image, 4, 3))
np.testing.assert_array_equal(fused_chw, expected_fused_chw)
fused_chw_out = np.empty((3, 3, 4), dtype=np.float32)
assert sr.resize_normalize_image_chw(image, 4, 3, out=fused_chw_out) is fused_chw_out
np.testing.assert_array_equal(fused_chw_out, expected_fused_chw)


def test_image_color_and_remap():
Expand Down
Loading
Loading