From 2868af01bc809f6a8d948ac71e41bf65169c6418 Mon Sep 17 00:00:00 2001 From: rsasaki0109 Date: Thu, 16 Jul 2026 08:08:19 +0900 Subject: [PATCH] perf(vision): fuse resize normalization and CHW packing --- README.md | 13 ++ .../README.md | 15 ++ .../performance.py | 182 ++++++++++++++++++ crates/spatialrust-py/spatialrust.pyi | 11 +- crates/spatialrust-py/src/lib.rs | 47 ++++- crates/spatialrust-py/tests/test_bindings.py | 8 +- .../spatialrust-vision/benches/preprocess.rs | 43 ++++- crates/spatialrust-vision/src/preprocess.rs | 77 +++++++- crates/spatialrust-vision/src/resize.rs | 173 ++++++++++++++++- crates/spatialrust-vision/tests/properties.rs | 41 +++- docs/ROADMAP.md | 2 +- docs/site/algorithms.html | 2 +- docs/site/vision2.html | 5 +- ...026-07-16_fused_resize_chw_acceleration.md | 40 ++++ 14 files changed, 643 insertions(+), 16 deletions(-) create mode 100644 bench/opencv_fused_resize_chw_comparison/README.md create mode 100644 bench/opencv_fused_resize_chw_comparison/performance.py create mode 100644 notes/2026-07-16_fused_resize_chw_acceleration.md diff --git a/README.md b/README.md index 5938215..768b767 100644 --- a/README.md +++ b/README.md @@ -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×** | @@ -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 @@ -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) | diff --git a/bench/opencv_fused_resize_chw_comparison/README.md b/bench/opencv_fused_resize_chw_comparison/README.md new file mode 100644 index 0000000..58466f1 --- /dev/null +++ b/bench/opencv_fused_resize_chw_comparison/README.md @@ -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. diff --git a/bench/opencv_fused_resize_chw_comparison/performance.py b/bench/opencv_fused_resize_chw_comparison/performance.py new file mode 100644 index 0000000..be88449 --- /dev/null +++ b/bench/opencv_fused_resize_chw_comparison/performance.py @@ -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() diff --git a/crates/spatialrust-py/spatialrust.pyi b/crates/spatialrust-py/spatialrust.pyi index 98d01f7..b7ad75f 100644 --- a/crates/spatialrust-py/spatialrust.pyi +++ b/crates/spatialrust-py/spatialrust.pyi @@ -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", @@ -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, diff --git a/crates/spatialrust-py/src/lib.rs b/crates/spatialrust-py/src/lib.rs index 994eea7..714fa8b 100644 --- a/crates/spatialrust-py/src/lib.rs +++ b/crates/spatialrust-py/src/lib.rs @@ -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, @@ -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>>, +) -> PyResult>> { + 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))] @@ -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)?)?; diff --git a/crates/spatialrust-py/tests/test_bindings.py b/crates/spatialrust-py/tests/test_bindings.py index 821423b..4d80c95 100644 --- a/crates/spatialrust-py/tests/test_bindings.py +++ b/crates/spatialrust-py/tests/test_bindings.py @@ -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", @@ -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(): diff --git a/crates/spatialrust-vision/benches/preprocess.rs b/crates/spatialrust-vision/benches/preprocess.rs index f7b2d52..28749dd 100644 --- a/crates/spatialrust-vision/benches/preprocess.rs +++ b/crates/spatialrust-vision/benches/preprocess.rs @@ -83,10 +83,51 @@ fn benchmark_fused_resize_gray(c: &mut Criterion) { } } +fn benchmark_fused_resize_chw(c: &mut Criterion) { + for &(name, width, height, output_width, output_height) in &[ + ("1080p_to_640", 1920, 1080, 640, 640), + ("4k_to_640", 3840, 2160, 640, 640), + ("4k_to_720p", 3840, 2160, 1280, 720), + ] { + let input = Image::::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::::try_new( + output_width, + output_height, + vec![0; output_width * output_height * 3], + ) + .unwrap(); + let mut chw = vec![0.0_f32; output_width * output_height * 3]; + let mut group = c.benchmark_group("resize_normalize_chw_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(); + pack_chw_into(resized.view(), 1.0 / 255.0, [0.0; 3], [1.0; 3], &mut chw).unwrap(); + }); + }); + group.bench_function(BenchmarkId::new("fused_reuse", name), |b| { + b.iter(|| { + plan.resize_rgb_to_chw_into( + black_box(input.view()), + 1.0 / 255.0, + [0.0; 3], + [1.0; 3], + &mut chw, + ) + .unwrap(); + }); + }); + group.finish(); + } +} + criterion_group!( benches, benchmark_preprocess, benchmark_reusable_preprocess, - benchmark_fused_resize_gray + benchmark_fused_resize_gray, + benchmark_fused_resize_chw ); criterion_main!(benches); diff --git a/crates/spatialrust-vision/src/preprocess.rs b/crates/spatialrust-vision/src/preprocess.rs index d239e2b..cf1af83 100644 --- a/crates/spatialrust-vision/src/preprocess.rs +++ b/crates/spatialrust-vision/src/preprocess.rs @@ -244,6 +244,34 @@ pub fn pack_chw_into( Ok(()) } +/// Fuses bilinear RGB resize, normalization, and CHW packing without an intermediate image. +pub fn resize_pack_chw( + input: ImageView<'_, u8, 3>, + output_width: usize, + output_height: usize, + scale: f32, + mean: [f32; 3], + std: [f32; 3], +) -> VisionResult> { + BilinearResizeU8Plan::new(input.width(), input.height(), output_width, output_height)? + .resize_rgb_to_chw(input, scale, mean, std) +} + +/// Fuses bilinear RGB resize, normalization, and CHW packing into caller-owned storage. +#[allow(clippy::too_many_arguments)] +pub fn resize_pack_chw_into( + input: ImageView<'_, u8, 3>, + output_width: usize, + output_height: usize, + scale: f32, + mean: [f32; 3], + std: [f32; 3], + output: &mut [f32], +) -> VisionResult<()> { + BilinearResizeU8Plan::new(input.width(), input.height(), output_width, output_height)? + .resize_rgb_to_chw_into(input, scale, mean, std, output) +} + fn fill_chw_plane( input: ImageView<'_, T, CHANNELS>, channel: usize, @@ -451,8 +479,8 @@ pub fn rgb_to_hsv(input: ImageView<'_, u8, 3>) -> VisionResult> { mod tests { use super::{ crop, gray_to_rgb, letterbox, normalize, normalize_into, pack_chw, pack_chw_into, pad, - resize_rgb_to_gray, resize_rgb_to_gray_into, rgb_to_gray, rgb_to_gray_into, rgb_to_hsv, - Padding, + resize_pack_chw, resize_pack_chw_into, resize_rgb_to_gray, resize_rgb_to_gray_into, + rgb_to_gray, rgb_to_gray_into, rgb_to_hsv, Padding, }; use crate::Interpolation; use spatialrust_image::{ @@ -522,6 +550,51 @@ mod tests { assert!(chw[2 * plane..].iter().all(|&value| value == 0.0)); } + #[test] + fn fused_resize_chw_matches_unfused_general_and_preserves_metadata() { + let metadata = ImageMetadata { + color_space: ColorSpace::Rgb, + color_range: spatialrust_image::ColorRange::Full, + ..ImageMetadata::default() + }; + let input = Image::::try_new_with_metadata( + 7, + 5, + (0..105).map(|value| (value * 37 % 256) as u8).collect(), + metadata, + ) + .unwrap(); + let plan = crate::BilinearResizeU8Plan::new(7, 5, 11, 8).unwrap(); + let resized = plan.resize(input.view()).unwrap(); + let expected = + pack_chw(resized.view(), 1.0 / 255.0, [0.1, 0.2, 0.3], [0.5, 1.0, 2.0]).unwrap(); + let actual = + resize_pack_chw(input.view(), 11, 8, 1.0 / 255.0, [0.1, 0.2, 0.3], [0.5, 1.0, 2.0]) + .unwrap(); + assert_eq!(actual, expected); + assert_eq!(actual.metadata(), metadata); + } + + #[test] + fn fused_resize_chw_matches_unfused_half_scale_for_strided_input_and_reuse() { + let mut storage = vec![231_u8; 169]; + for y in 0..6 { + for x in 0..24 { + storage[y * 29 + x] = (y * 41 + x * 13) as u8; + } + } + let input = ImageView::::new(8, 6, 29, &storage).unwrap(); + let plan = crate::BilinearResizeU8Plan::new(8, 6, 4, 3).unwrap(); + let resized = plan.resize(input).unwrap(); + let expected = pack_chw(resized.view(), 0.25, [1.0, 2.0, 3.0], [1.0; 3]).unwrap(); + let mut output = vec![-1.0_f32; 36]; + resize_pack_chw_into(input, 4, 3, 0.25, [1.0, 2.0, 3.0], [1.0; 3], &mut output).unwrap(); + assert_eq!(output, expected.as_slice()); + assert!( + resize_pack_chw_into(input, 4, 3, 0.25, [0.0; 3], [1.0; 3], &mut output[..35]).is_err() + ); + } + #[test] fn rgb_to_gray_into_accepts_strided_output() { let image = Image::::try_new(2, 1, vec![255, 0, 0, 0, 255, 0]).unwrap(); diff --git a/crates/spatialrust-vision/src/resize.rs b/crates/spatialrust-vision/src/resize.rs index 7b4dc08..003ab76 100644 --- a/crates/spatialrust-vision/src/resize.rs +++ b/crates/spatialrust-vision/src/resize.rs @@ -1,5 +1,5 @@ use rayon::prelude::*; -use spatialrust_image::{ColorSpace, Image, ImageMetadata, ImageView, ImageViewMut}; +use spatialrust_image::{ColorSpace, Image, ImageMetadata, ImageView, ImageViewMut, PlanarImage}; use crate::{PixelComponent, VisionError, VisionResult}; @@ -254,6 +254,110 @@ impl BilinearResizeU8Plan { Ok(()) } + /// Fuses bilinear RGB resize, per-channel normalization, and CHW packing. + /// + /// The result is bit-exact with this plan's RGB [`Self::resize`] followed by + /// SpatialRust's planar normalization for the same parameters, while avoiding + /// the intermediate resized RGB image. + pub fn resize_rgb_to_chw( + &self, + input: ImageView<'_, u8, 3>, + scale: f32, + mean: [f32; 3], + std: [f32; 3], + ) -> VisionResult> { + let plane_len = self.output_width.checked_mul(self.output_height).ok_or_else(|| { + VisionError::InvalidDimensions("fused resize-to-CHW plane is too large".to_owned()) + })?; + let len = plane_len.checked_mul(3).ok_or_else(|| { + VisionError::InvalidDimensions("fused resize-to-CHW output is too large".to_owned()) + })?; + let mut output = vec![0.0_f32; len]; + self.resize_rgb_to_chw_into(input, scale, mean, std, &mut output)?; + Ok(PlanarImage::try_new_with_metadata( + self.output_width, + self.output_height, + output, + input.metadata(), + )?) + } + + /// Fuses bilinear RGB resize, normalization, and CHW packing into a reusable slice. + pub fn resize_rgb_to_chw_into( + &self, + input: ImageView<'_, u8, 3>, + scale: f32, + mean: [f32; 3], + std: [f32; 3], + output: &mut [f32], + ) -> VisionResult<()> { + validate_rgb_chw_normalization(scale, std)?; + if (input.width(), input.height()) != self.input_dimensions() { + return Err(VisionError::InvalidDimensions(format!( + "resize plan expects input {}x{}, found {}x{}", + self.input_width, + self.input_height, + input.width(), + input.height() + ))); + } + let plane_len = self.output_width.checked_mul(self.output_height).ok_or_else(|| { + VisionError::InvalidDimensions("fused resize-to-CHW plane is too large".to_owned()) + })?; + let required = plane_len.checked_mul(3).ok_or_else(|| { + VisionError::InvalidDimensions("fused resize-to-CHW output is too large".to_owned()) + })?; + if output.len() != required { + return Err(VisionError::ShapeMismatch(format!( + "CHW output needs {required} elements, found {}", + output.len() + ))); + } + if plane_len == 0 { + return Ok(()); + } + + let half_scale = self.input_width == self.output_width.saturating_mul(2) + && self.input_height == self.output_height.saturating_mul(2); + let run_row = |plane_row: usize, target: &mut [f32]| { + let channel = plane_row / self.output_height; + let y = plane_row % self.output_height; + if half_scale { + resize_half_rgb_to_chw_row( + input, + target, + y, + channel, + scale, + mean[channel], + std[channel], + ); + } else { + self.resize_bilinear_rgb_to_chw_row( + input, + target, + y, + channel, + scale, + mean[channel], + std[channel], + ); + } + }; + if required >= PARALLEL_RESIZE_COMPONENTS { + output + .par_chunks_mut(self.output_width) + .enumerate() + .for_each(|(plane_row, target)| run_row(plane_row, target)); + } else { + output + .chunks_mut(self.output_width) + .enumerate() + .for_each(|(plane_row, target)| run_row(plane_row, target)); + } + Ok(()) + } + fn resize_bilinear_row( &self, input: ImageView<'_, u8, CHANNELS>, @@ -315,6 +419,50 @@ impl BilinearResizeU8Plan { output[x] = rgb_luma_q14(pixel); } } + + #[allow(clippy::too_many_arguments)] + fn resize_bilinear_rgb_to_chw_row( + &self, + input: ImageView<'_, u8, 3>, + output: &mut [f32], + y: usize, + channel: usize, + scale: f32, + mean: f32, + std: f32, + ) { + let y_sample = self.y_samples[y]; + let top = input.row(y_sample.lower).expect("planned source row"); + let bottom = input.row(y_sample.upper).expect("planned source row"); + let wy = u32::from(y_sample.upper_weight); + let inv_wy = BILINEAR_WEIGHT_SCALE - wy; + for (x, x_sample) in self.x_samples.iter().copied().enumerate() { + let wx = u32::from(x_sample.upper_weight); + let inv_wx = BILINEAR_WEIGHT_SCALE - wx; + let lower = x_sample.lower * 3 + channel; + let upper = x_sample.upper * 3 + channel; + let value = bilinear_u8_component( + top[lower], + top[upper], + bottom[lower], + bottom[upper], + inv_wx, + wx, + inv_wy, + wy, + ); + output[x] = (f32::from(value) * scale - mean) / std; + } + } +} + +fn validate_rgb_chw_normalization(scale: f32, std: [f32; 3]) -> VisionResult<()> { + if !scale.is_finite() || std.iter().any(|value| !value.is_finite() || *value == 0.0) { + return Err(VisionError::InvalidParameter( + "normalization scale/std must be finite and std non-zero".to_owned(), + )); + } + Ok(()) } #[inline(always)] @@ -405,6 +553,29 @@ fn resize_half_rgb_to_gray_row( } } +#[allow(clippy::too_many_arguments)] +fn resize_half_rgb_to_chw_row( + input: ImageView<'_, u8, 3>, + output: &mut [f32], + y: usize, + channel: usize, + scale: f32, + mean: f32, + std: f32, +) { + let top = input.row(y * 2).expect("half-scale source row"); + let bottom = input.row(y * 2 + 1).expect("half-scale source row"); + for (x, target) in output.iter_mut().enumerate() { + let source = x * 6 + channel; + let sum = u16::from(top[source]) + + u16::from(top[source + 3]) + + u16::from(bottom[source]) + + u16::from(bottom[source + 3]); + let value = ((sum + 2) >> 2) as u8; + *target = (f32::from(value) * scale - mean) / std; + } +} + /// Resizes an interleaved image while preserving semantic metadata. pub fn resize( input: ImageView<'_, T, CHANNELS>, diff --git a/crates/spatialrust-vision/tests/properties.rs b/crates/spatialrust-vision/tests/properties.rs index 5d0ebb9..8d4cbdd 100644 --- a/crates/spatialrust-vision/tests/properties.rs +++ b/crates/spatialrust-vision/tests/properties.rs @@ -8,11 +8,11 @@ use spatialrust_image::Image; use spatialrust_math::{Mat3, Vec2, Vec3}; use spatialrust_vision::{ canny, decode_rle, distance_transform_edt_with_spacing, encode_rle, erode, estimate_homography, - filter2d, integral_image, match_descriptors, project_object_point, resize, resize_rgb_to_gray, - rgb_to_gray, solve_pnp, AbsolutePose, BilinearResizeU8Plan, BinaryMask, BorderMode, - BoundingBox2, CameraMatrix3, CannyOptions, DescriptorBuffer, Interpolation, Kernel2D, - MatchOptions, MorphologyShape, ObjectImageCorrespondence, PointCorrespondence2, RleOrder, - StructuringElement, + filter2d, integral_image, match_descriptors, pack_chw, project_object_point, resize, + resize_pack_chw, resize_rgb_to_gray, rgb_to_gray, solve_pnp, AbsolutePose, + BilinearResizeU8Plan, BinaryMask, BorderMode, BoundingBox2, CameraMatrix3, CannyOptions, + DescriptorBuffer, Interpolation, Kernel2D, MatchOptions, MorphologyShape, + ObjectImageCorrespondence, PointCorrespondence2, RleOrder, StructuringElement, }; proptest! { @@ -57,6 +57,37 @@ proptest! { prop_assert_eq!(actual, expected); } + #[test] + fn fused_resize_to_chw_matches_unfused_plan( + width in 1usize..24, + height in 1usize..24, + output_width in 1usize..24, + output_height in 1usize..24, + seed in any::(), + ) { + let data = (0..width * height * 3) + .map(|index| seed.wrapping_add((index as u8).wrapping_mul(53))) + .collect::>(); + let image = Image::::try_new(width, height, data).unwrap(); + let plan = BilinearResizeU8Plan::new(width, height, output_width, output_height).unwrap(); + let resized = plan.resize(image.view()).unwrap(); + let expected = pack_chw( + resized.view(), + 1.0 / 255.0, + [0.1, 0.2, 0.3], + [0.5, 1.0, 2.0], + ).unwrap(); + let actual = resize_pack_chw( + image.view(), + output_width, + output_height, + 1.0 / 255.0, + [0.1, 0.2, 0.3], + [0.5, 1.0, 2.0], + ).unwrap(); + prop_assert_eq!(actual, expected); + } + #[test] fn identity_filter_preserves_arbitrary_u16_roi_storage( width in 1usize..24, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9c9fc4c..1bed606 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -629,7 +629,7 @@ to one implicitly, and GPU receipts must retain named upload/readback stages. | --- | --- | --- | --- | | 115A | Complete | Precompute resize source coordinates and interpolation coefficients | reusable Q11 bilinear plan, shape/stride/padding tests | | 115B | In progress | Packed bilinear/nearest/area and RGB-to-gray fast paths | packed RGB8 bilinear, exact half-scale, and Q14 RGB-to-gray complete; nearest/area remain | -| 115C | In progress | Evaluate resize+gray and resize+CHW fusion without changing standalone APIs | resize+gray complete: bit-exact unfused parity and 1.12× OpenCV allocated win at 1080p→540p; resize+CHW remains | +| 115C | Complete | Evaluate resize+gray and resize+CHW fusion without changing standalone APIs | bit-exact unfused parity; resize+gray wins 1.12× at 1080p→540p and resize+CHW wins 2.02×–2.33× against OpenCV allocation | | 115D | Complete | Improve current SpatialRust throughput by at least 5x on one canonical large profile | native reuse improved 47.8× at 1080p and 37.3× at 4K; VGA Python reuse is 1.10× faster than OpenCV | ### Epic 116 delivery slices diff --git a/docs/site/algorithms.html b/docs/site/algorithms.html index ee018c4..9509a65 100644 --- a/docs/site/algorithms.html +++ b/docs/site/algorithms.html @@ -29,7 +29,7 @@

Algorithm catalog

VoxelizationOccupancy grids, range images, voxel keys, segments, centroid/first-point reductionsspatialrust-voxelize, gpuCPU / GPU TransformsApply transform, centroid, AABB/OBB, recenter, scale, unit-sphere normalization, mergespatialrust-transformCPU - PreprocessingCrop, pad, letterbox, normalize, interleaved-to-CHW, RGB/BGR swap, RGB↔gray/HSV, reusable outputs. Packed RGB8-to-gray uses Q14 BT.601 and CPU target dispatch. Fused bilinear resize-to-gray avoids an intermediate RGB image and measured 0.677 ms versus OpenCV's 0.755 ms for allocated 1080p→540p, with exact SpatialRust unfused parity. Fused harness.spatialrust-vision · preprocessCPU + PreprocessingCrop, pad, letterbox, normalize, interleaved-to-CHW, RGB/BGR swap, RGB↔gray/HSV, reusable outputs. Fused bilinear resize-to-gray avoids an intermediate RGB image and wins 1.12× at allocated 1080p→540p. Fused resize-normalize-CHW avoids the HWC intermediate and measured 2.02×–2.33× faster than OpenCV blobFromImage. Both retain exact SpatialRust unfused parity. CHW harness.spatialrust-vision · preprocessCPU Resize and warpNearest, bilinear, bicubic and area resize; reusable fixed-point RGB8 bilinear plans; exact row-parallel half-scale path; remap; affine and perspective warp. VGA caller-output half-scale measured 1.10× faster than OpenCV with exact pixels. Harness.spatialrust-vision · resize, warpCPU / GPU Filtering2D correlation/convolution, separable/box/Gaussian, accelerated 3×3/5×5 u8 Gaussian with workspace reuse, median, bilateral, Sobel, exact paired gradients and fused L1 magnitude, Scharr, Laplacian, Gaussian pyramidsspatialrust-vision · imgproc-filtersafe CPU explicit GPU API MorphologyErode, dilate, open, close, gradient, top-hat, black-hat; separable sliding Rect fast path plus Cross/Ellipse/Diamond/custom fallbackspatialrust-vision · imgproc-morphologysafe CPU explicit wgpu API diff --git a/docs/site/vision2.html b/docs/site/vision2.html index 17ddb5d..02e2325 100644 --- a/docs/site/vision2.html +++ b/docs/site/vision2.html @@ -28,7 +28,7 @@

Nine reviewable Epics

Performance attribution

Separate native kernel, allocation, Python conversion, upload, execution, and readback costs. Publish MPix/s, ns/pixel, memory, and thread policy.

Reusable outputs and workspaces

Add caller-owned outputs and explicit scratch storage for Gaussian, Sobel, morphology, and Canny while preserving packed and strided behavior.

Safe CPU dispatch

Use scalar small-image paths, packed specializations, bounded row/tile parallelism, and fully compatible generic fallbacks.

-

Resize and color

Reusable Q11 bilinear plans, exact packed RGB8 half-scale, target-dispatched Q14 RGB-to-gray, and fused resize-to-gray are delivered; nearest/area and fused resize+CHW remain next.

+

Resize and color

Reusable Q11 bilinear plans, exact packed RGB8 half-scale, target-dispatched Q14 RGB-to-gray, fused resize-to-gray, and fused resize-normalize-CHW are delivered; nearest/area remain next.

Gaussian and Sobel

Reuse separable intermediates, cache kernels, isolate borders, and compute paired gradients with shared traversal.

Morphology

Introduce rectangular sliding min/max, small-kernel paths, ping-pong scratch, and exact generic-shape fallback.

Fused Canny

Avoid materializing public intermediates in the standard path while retaining an opt-in inspectable result.

@@ -58,8 +58,9 @@

Latest measured outcome

Packed RGB8 resize

Precomputed Q11 coefficients and an exact row-parallel half-scale path reduce the old 26×–146× gap. VGA caller-output measured 0.120 ms versus OpenCV's 0.133 ms (1.10× faster); 1080p/4K/8K remain scoped optimization targets.

RGB8 to gray

Q14 BT.601 coefficients, target-feature dispatch, and size-aware row blocks improve native reuse by 5.7×–10.6×. Allocated Python calls measured 1.03× faster than OpenCV at 1080p and 1.05× at 4K; 8K caller-output reuse measured 1.02× faster.

Fused resize to gray

A single-pass Q11 bilinear + Q14 BT.601 path removes the intermediate RGB image. The allocated 1920×1080→960×540 pipeline measured 0.677 ms versus OpenCV's 0.755 ms (1.12× faster), with bit-exact SpatialRust unfused parity.

+

Fused model input

Bilinear resize, float normalization, and planar CHW packing now write model input directly. Allocated calls measured 2.21× faster than OpenCV blobFromImage at 1080p→640×640, 2.02× at 4K→640×640, and 2.33× at 4K→1280×720.

-

These are workload- and host-specific results. The resize win covers packed RGB8 640×480→320×240 caller-owned output; OpenCV remains 1.49×–1.67× faster for allocated 1080p–8K and 1.85×–2.40× faster for reuse. RGB-to-gray wins cover allocated 1080p/4K and caller-owned 8K; OpenCV remains faster at VGA and for 1080p/4K reuse. The fused resize-to-gray win covers allocated 1080p→540p only; 4K allocation is effectively tied, and OpenCV leads 8K allocation plus all reuse profiles. The Sobel claim covers fused L1 magnitude allocation, not standalone paired gradients; reuse ties at 1080p and favors OpenCV at 4K/8K. Gaussian improvement compares against SpatialRust's prior generic engine and is not an OpenCV win. The connected-components claim covers structured segmentation/document masks; dense random noise is not claimed. Repository receipts contain the reproducible methodology.

+

These are workload- and host-specific results. The resize win covers packed RGB8 640×480→320×240 caller-owned output; OpenCV remains 1.49×–1.67× faster for allocated 1080p–8K and 1.85×–2.40× faster for reuse. RGB-to-gray wins cover allocated 1080p/4K and caller-owned 8K; OpenCV remains faster at VGA and for 1080p/4K reuse. The fused resize-to-gray win covers allocated 1080p→540p only; 4K allocation is effectively tied, and OpenCV leads 8K allocation plus all reuse profiles. The fused CHW claim compares scale=1/255, zero mean, unit std against OpenCV blobFromImage at the named model shapes; arbitrary mean/std retain exact SpatialRust unfused parity. The Sobel claim covers fused L1 magnitude allocation, not standalone paired gradients; reuse ties at 1080p and favors OpenCV at 4K/8K. Gaussian improvement compares against SpatialRust's prior generic engine and is not an OpenCV win. The connected-components claim covers structured segmentation/document masks; dense random noise is not claimed. Repository receipts contain the reproducible methodology.

diff --git a/notes/2026-07-16_fused_resize_chw_acceleration.md b/notes/2026-07-16_fused_resize_chw_acceleration.md new file mode 100644 index 0000000..3fa6684 --- /dev/null +++ b/notes/2026-07-16_fused_resize_chw_acceleration.md @@ -0,0 +1,40 @@ +# Fused resize-normalize-CHW acceleration + +This Epic 115C slice adds safe allocating and caller-owned APIs that fuse Q11 +bilinear RGB resize, `f32` scaling/normalization, and planar CHW packing. The +output is written directly in model-input layout without an intermediate HWC +image. Arbitrary scale, per-channel mean, and non-zero finite standard +deviation remain explicit parameters. + +## Correctness + +- Fused output is bit-exact with `BilinearResizeU8Plan::resize` followed by + `pack_chw` for arbitrary dimensions and normalization parameters. +- Packed and strided input plus caller-owned output paths are covered in Rust. +- Three hundred seeded arbitrary-size Python cases include non-contiguous + input and retain exact SpatialRust fused/unfused parity. +- Against OpenCV 4.13 `dnn.blobFromImage`, maximum float disagreement is + `0.003921628` (one `u8` level after scaling by `1/255`). + +## Native caller-owned medians + +| Input → model shape | Unfused | Fused | Improvement | +| --- | ---: | ---: | ---: | +| 1920×1080 → 640×640 | 1.270 ms | 1.106 ms | 1.15× | +| 3840×2160 → 640×640 | 1.385 ms | 1.239 ms | 1.12× | +| 3840×2160 → 1280×720 | 2.587 ms | 2.345 ms | 1.10× | + +## OpenCV 4.13 Python medians + +OpenCL was disabled and OpenCV used 12 threads. OpenCV's reference is the +integrated `cv2.dnn.blobFromImage` call, not a slower NumPy composition. + +| Input → model shape | OpenCV allocate | SpatialRust allocate | Allocate result | SpatialRust reuse | Reuse vs OpenCV allocate | +| --- | ---: | ---: | ---: | ---: | ---: | +| 1920×1080 → 640×640 | 3.570 ms | 1.617 ms | **SpatialRust 2.21×** | 1.114 ms | **SpatialRust 3.56×** | +| 3840×2160 → 640×640 | 4.272 ms | 2.117 ms | **SpatialRust 2.02×** | 1.420 ms | **SpatialRust 3.02×** | +| 3840×2160 → 1280×720 | 8.359 ms | 3.592 ms | **SpatialRust 2.33×** | 2.472 ms | **SpatialRust 3.48×** | + +The focused harness at +`C:\Users\rsasa\Workspace\SpatialRust\bench\opencv_fused_resize_chw_comparison` +emits the complete environment, dispersion, and raw paired samples.