diff --git a/README.md b/README.md index ade5a19..5938215 100644 --- a/README.md +++ b/README.md @@ -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× | @@ -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 @@ -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) | diff --git a/bench/opencv_fused_resize_gray_comparison/README.md b/bench/opencv_fused_resize_gray_comparison/README.md new file mode 100644 index 0000000..e01cc2c --- /dev/null +++ b/bench/opencv_fused_resize_gray_comparison/README.md @@ -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. diff --git a/bench/opencv_fused_resize_gray_comparison/performance.py b/bench/opencv_fused_resize_gray_comparison/performance.py new file mode 100644 index 0000000..3c6f1d7 --- /dev/null +++ b/bench/opencv_fused_resize_gray_comparison/performance.py @@ -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() diff --git a/crates/spatialrust-py/spatialrust.pyi b/crates/spatialrust-py/spatialrust.pyi index 2cbe37a..98d01f7 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", "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", @@ -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, diff --git a/crates/spatialrust-py/src/lib.rs b/crates/spatialrust-py/src/lib.rs index 279be80..994eea7 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, 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, @@ -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>>, +) -> PyResult>> { + 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::::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>( @@ -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)?)?; diff --git a/crates/spatialrust-py/tests/test_bindings.py b/crates/spatialrust-py/tests/test_bindings.py index a8260ee..821423b 100644 --- a/crates/spatialrust-py/tests/test_bindings.py +++ b/crates/spatialrust-py/tests/test_bindings.py @@ -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", @@ -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]) diff --git a/crates/spatialrust-vision/benches/preprocess.rs b/crates/spatialrust-vision/benches/preprocess.rs index f4d8386..f7b2d52 100644 --- a/crates/spatialrust-vision/benches/preprocess.rs +++ b/crates/spatialrust-vision/benches/preprocess.rs @@ -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) { @@ -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::::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; width * height * 3 / 4]) + .unwrap(); + let mut gray = + Image::::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); diff --git a/crates/spatialrust-vision/src/preprocess.rs b/crates/spatialrust-vision/src/preprocess.rs index 92173f2..d239e2b 100644 --- a/crates/spatialrust-vision/src/preprocess.rs +++ b/crates/spatialrust-vision/src/preprocess.rs @@ -4,7 +4,9 @@ use spatialrust_image::{ ColorSpace, Image, ImageMetadata, ImageRegion, ImageView, ImageViewMut, PlanarImage, }; -use crate::{resize, Interpolation, PixelComponent, VisionError, VisionResult}; +use crate::{ + resize, BilinearResizeU8Plan, Interpolation, PixelComponent, VisionError, VisionResult, +}; /// Padding applied around an image. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] @@ -361,6 +363,25 @@ pub fn rgb_to_gray_into( Ok(()) } +/// Fuses bilinear RGB resize and BT.601 grayscale conversion without an intermediate RGB image. +pub fn resize_rgb_to_gray( + input: ImageView<'_, u8, 3>, + output_width: usize, + output_height: usize, +) -> VisionResult> { + BilinearResizeU8Plan::new(input.width(), input.height(), output_width, output_height)? + .resize_rgb_to_gray(input) +} + +/// Fuses bilinear RGB resize and BT.601 grayscale conversion into caller-owned storage. +pub fn resize_rgb_to_gray_into( + input: ImageView<'_, u8, 3>, + output: ImageViewMut<'_, u8, 1>, +) -> VisionResult<()> { + BilinearResizeU8Plan::new(input.width(), input.height(), output.width(), output.height())? + .resize_rgb_to_gray_into(input, output) +} + fn rgb_to_gray_row(input: ImageView<'_, u8, 3>, y: usize, target: &mut [u8]) { let source = input.row(y).expect("input row in bounds"); for (pixel, target_value) in source.chunks_exact(3).zip(target.iter_mut()) { @@ -430,7 +451,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, - rgb_to_gray, rgb_to_gray_into, rgb_to_hsv, Padding, + 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::{ @@ -520,6 +542,51 @@ mod tests { assert_eq!(gray.metadata().color_space, ColorSpace::Gray); } + #[test] + fn fused_resize_rgb_to_gray_matches_unfused_half_scale_exactly() { + let metadata = ImageMetadata { + color_space: ColorSpace::Rgb, + color_range: spatialrust_image::ColorRange::Full, + ..ImageMetadata::default() + }; + let input = Image::::try_new_with_metadata( + 8, + 6, + (0..144).map(|value| (value * 53 % 256) as u8).collect(), + metadata, + ) + .unwrap(); + let resized = + crate::BilinearResizeU8Plan::new(8, 6, 4, 3).unwrap().resize(input.view()).unwrap(); + let expected = rgb_to_gray(resized.view()).unwrap(); + let actual = resize_rgb_to_gray(input.view(), 4, 3).unwrap(); + assert_eq!(actual, expected); + assert_eq!(actual.metadata().color_space, ColorSpace::Gray); + assert_eq!(actual.metadata().color_range, spatialrust_image::ColorRange::Full); + } + + #[test] + fn fused_resize_rgb_to_gray_matches_unfused_general_and_strided_output() { + let mut input_storage = vec![231_u8; 83]; + for y in 0..5 { + for x in 0..15 { + input_storage[y * 17 + x] = (y * 41 + x * 13) as u8; + } + } + let input = ImageView::::new(5, 5, 17, &input_storage).unwrap(); + let resized = crate::BilinearResizeU8Plan::new(5, 5, 7, 3).unwrap().resize(input).unwrap(); + let expected = rgb_to_gray(resized.view()).unwrap(); + let mut output_storage = vec![199_u8; 29]; + let output = ImageViewMut::::new(7, 3, 11, &mut output_storage).unwrap(); + resize_rgb_to_gray_into(input, output).unwrap(); + for y in 0..3 { + assert_eq!(&output_storage[y * 11..y * 11 + 7], expected.view().row(y).unwrap()); + if y < 2 { + assert_eq!(&output_storage[y * 11 + 7..(y + 1) * 11], &[199; 4]); + } + } + } + #[test] fn color_conversions_match_known_primaries() { let metadata = ImageMetadata { color_space: ColorSpace::Rgb, ..Default::default() }; diff --git a/crates/spatialrust-vision/src/resize.rs b/crates/spatialrust-vision/src/resize.rs index bb7fe34..7b4dc08 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::{Image, ImageView, ImageViewMut}; +use spatialrust_image::{ColorSpace, Image, ImageMetadata, ImageView, ImageViewMut}; use crate::{PixelComponent, VisionError, VisionResult}; @@ -165,6 +165,95 @@ impl BilinearResizeU8Plan { Ok(()) } + /// Fuses bilinear RGB resize and BT.601 grayscale conversion into one pass. + /// + /// The result is bit-exact with this plan's RGB [`Self::resize`] followed by + /// SpatialRust's Q14 RGB-to-gray conversion, while avoiding the intermediate + /// three-channel image. + pub fn resize_rgb_to_gray(&self, input: ImageView<'_, u8, 3>) -> VisionResult> { + let len = self.output_width.checked_mul(self.output_height).ok_or_else(|| { + VisionError::InvalidDimensions("fused resize-to-gray output is too large".to_owned()) + })?; + let metadata = ImageMetadata { color_space: ColorSpace::Gray, ..input.metadata() }; + let mut output = Image::try_new_with_metadata( + self.output_width, + self.output_height, + vec![0; len], + metadata, + )?; + self.resize_rgb_to_gray_into(input, output.view_mut())?; + Ok(output) + } + + /// Fuses bilinear RGB resize and BT.601 grayscale conversion into caller-owned storage. + pub fn resize_rgb_to_gray_into( + &self, + input: ImageView<'_, u8, 3>, + mut output: ImageViewMut<'_, u8, 1>, + ) -> VisionResult<()> { + 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() + ))); + } + if (output.width(), output.height()) != self.output_dimensions() { + return Err(VisionError::InvalidDimensions(format!( + "resize plan expects output {}x{}, found {}x{}", + self.output_width, + self.output_height, + output.width(), + output.height() + ))); + } + output.set_metadata(ImageMetadata { color_space: ColorSpace::Gray, ..input.metadata() })?; + if self.output_width == 0 || self.output_height == 0 { + return Ok(()); + } + + let row_stride = output.row_stride(); + let half_scale = self.input_width == self.output_width.saturating_mul(2) + && self.input_height == self.output_height.saturating_mul(2); + let run_row = |y: usize, row: &mut [u8]| { + if half_scale { + resize_half_rgb_to_gray_row(input, row, y, self.output_width); + } else { + self.resize_bilinear_rgb_to_gray_row(input, row, y); + } + }; + if self.output_width * self.output_height >= PARALLEL_RESIZE_COMPONENTS { + if self.output_height >= 2_000 { + const ROWS_PER_TASK: usize = 8; + let block_stride = row_stride.checked_mul(ROWS_PER_TASK).ok_or_else(|| { + VisionError::InvalidDimensions("fused resize row block is too large".to_owned()) + })?; + output.as_mut_slice().par_chunks_mut(block_stride).enumerate().for_each( + |(block, rows)| { + for (row, target) in rows.chunks_mut(row_stride).enumerate() { + run_row(block * ROWS_PER_TASK + row, target); + } + }, + ); + } else { + output + .as_mut_slice() + .par_chunks_mut(row_stride) + .enumerate() + .for_each(|(y, row)| run_row(y, row)); + } + } else { + output + .as_mut_slice() + .chunks_mut(row_stride) + .enumerate() + .for_each(|(y, row)| run_row(y, row)); + } + Ok(()) + } + fn resize_bilinear_row( &self, input: ImageView<'_, u8, CHANNELS>, @@ -194,6 +283,64 @@ impl BilinearResizeU8Plan { } } } + + fn resize_bilinear_rgb_to_gray_row( + &self, + input: ImageView<'_, u8, 3>, + output: &mut [u8], + y: usize, + ) { + 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; + let upper = x_sample.upper * 3; + let pixel = std::array::from_fn(|channel| { + bilinear_u8_component( + top[lower + channel], + top[upper + channel], + bottom[lower + channel], + bottom[upper + channel], + inv_wx, + wx, + inv_wy, + wy, + ) + }); + output[x] = rgb_luma_q14(pixel); + } + } +} + +#[inline(always)] +fn bilinear_u8_component( + top_left: u8, + top_right: u8, + bottom_left: u8, + bottom_right: u8, + inv_wx: u32, + wx: u32, + inv_wy: u32, + wy: u32, +) -> u8 { + let top = u32::from(top_left) * inv_wx + u32::from(top_right) * wx; + let bottom = u32::from(bottom_left) * inv_wx + u32::from(bottom_right) * wx; + let round = 1 << (BILINEAR_WEIGHT_BITS * 2 - 1); + ((top * inv_wy + bottom * wy + round) >> (BILINEAR_WEIGHT_BITS * 2)) as u8 +} + +#[inline(always)] +fn rgb_luma_q14(pixel: [u8; 3]) -> u8 { + ((4_899_u32 * u32::from(pixel[0]) + + 9_617_u32 * u32::from(pixel[1]) + + 1_868_u32 * u32::from(pixel[2]) + + 8_192) + >> 14) as u8 } fn bilinear_axis_samples(input_len: usize, output_len: usize) -> Vec { @@ -237,6 +384,27 @@ fn resize_half_row( } } +fn resize_half_rgb_to_gray_row( + input: ImageView<'_, u8, 3>, + output: &mut [u8], + y: usize, + output_width: usize, +) { + 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().take(output_width).enumerate() { + let source = x * 6; + let pixel = std::array::from_fn(|channel| { + let sum = u16::from(top[source + channel]) + + u16::from(top[source + 3 + channel]) + + u16::from(bottom[source + channel]) + + u16::from(bottom[source + 3 + channel]); + ((sum + 2) >> 2) as u8 + }); + *target = rgb_luma_q14(pixel); + } +} + /// 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 1c527a6..5d0ebb9 100644 --- a/crates/spatialrust-vision/tests/properties.rs +++ b/crates/spatialrust-vision/tests/properties.rs @@ -8,10 +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, solve_pnp, - AbsolutePose, BinaryMask, BorderMode, BoundingBox2, CameraMatrix3, CannyOptions, - DescriptorBuffer, Interpolation, Kernel2D, MatchOptions, MorphologyShape, - ObjectImageCorrespondence, PointCorrespondence2, RleOrder, StructuringElement, + 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, }; proptest! { @@ -37,6 +38,25 @@ proptest! { } } + #[test] + fn fused_resize_to_gray_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(37))) + .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 = rgb_to_gray(resized.view()).unwrap(); + let actual = resize_rgb_to_gray(image.view(), output_width, output_height).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 cb03bf8..9c9fc4c 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 | Planned | Evaluate resize+gray and resize+CHW fusion without changing standalone APIs | fused/unfused parity and timing | +| 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 | | 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 76cba15..ee018c4 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, size-aware row blocks, and CPU target dispatch; allocated calls measured 1.03×/1.05× faster than OpenCV at 1080p/4K. Harness.spatialrust-vision · preprocessCPU + 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 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 648ce7a..17ddb5d 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, and target-dispatched Q14 RGB-to-gray are delivered; nearest/area and fused resize+gray/CHW remain next.

+

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.

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.

@@ -57,8 +57,9 @@

Latest measured outcome

Gaussian engine

Symmetric fixed-point 3×3/5×5 passes, cached kernels, unrolled interiors, and explicit workspace reuse improve the old native 5×5 path by 20.7× at 1080p and 26.7× at 4K. Canonical output is exact; standalone OpenCV remains about 2.93–3.58× faster.

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.

-

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 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 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_gray_acceleration.md b/notes/2026-07-16_fused_resize_gray_acceleration.md new file mode 100644 index 0000000..801f3e4 --- /dev/null +++ b/notes/2026-07-16_fused_resize_gray_acceleration.md @@ -0,0 +1,40 @@ +# Fused bilinear resize-to-gray acceleration + +This Epic 115C slice adds safe allocating and caller-owned APIs that combine a +reusable Q11 `u8` bilinear resize plan with Q14 BT.601 RGB-to-gray conversion. +The implementation writes gray output directly and never materializes the +resized RGB image. Packed and strided inputs/outputs retain explicit ownership +and metadata behavior. + +## Correctness + +- Fused output is bit-exact with `BilinearResizeU8Plan::resize` followed by + `rgb_to_gray` for arbitrary dimensions and canonical half reductions. +- Three hundred seeded randomized dimensions include non-contiguous input. +- OpenCV 4.13 disagreement is at most 1/255; canonical exact fractions are + 99.8655%–99.8700%. + +## Native reuse medians + +| Input → output | Unfused | Fused | Improvement | +| --- | ---: | ---: | ---: | +| 1920×1080 → 960×540 | 0.760 ms | 0.695 ms | 1.09× | +| 3840×2160 → 1920×1080 | 2.775 ms | 2.410 ms | 1.15× | +| 7680×4320 → 3840×2160 | 11.087 ms | 9.453 ms | 1.17× | + +## OpenCV 4.13 Python medians + +OpenCL was disabled and both runtimes used their default 12-thread policies. +Allocated OpenCV timings include `resize` plus `cvtColor`; reuse supplies both +the intermediate RGB image and final gray output. + +| Input → output | OpenCV allocate | SpatialRust allocate | Result | OpenCV reuse | SpatialRust reuse | +| --- | ---: | ---: | ---: | ---: | ---: | +| 1920×1080 → 960×540 | 0.755 ms | 0.677 ms | **SpatialRust 1.12×** | 0.347 ms | 0.658 ms | +| 3840×2160 → 1920×1080 | 2.665 ms | 2.687 ms | OpenCV 1.01× | 1.517 ms | 2.392 ms | +| 7680×4320 → 3840×2160 | 8.863 ms | 10.062 ms | OpenCV 1.14× | 6.525 ms | 9.474 ms | + +The claim is intentionally limited to the allocated 1080p→540p camera-pyramid +pipeline. The focused harness at +`C:\Users\rsasa\Workspace\SpatialRust\bench\opencv_fused_resize_gray_comparison` +emits the complete environment, dispersion, and raw samples.