diff --git a/README.md b/README.md index 3676f06..89986cc 100644 --- a/README.md +++ b/README.md @@ -152,8 +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×** | -| Bilinear resize, allocate | OpenCV 26.46× | OpenCV 64.02× | OpenCV 93.61× | -| Bilinear resize, reuse | OpenCV 27.36× | OpenCV 145.81× | OpenCV 113.87× | +| 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 | OpenCV 11.97× | OpenCV 5.98× | OpenCV 12.98× | | RGB to gray, reuse | OpenCV 6.01× | OpenCV 13.81× | OpenCV 2.09× | | Gaussian blur 5×5[^gaussian-2026] | OpenCV 139.02× | OpenCV 3.10× | OpenCV 2.93× | @@ -181,6 +181,14 @@ records the exact environment and methodology. 5×5 latency by 20.7× at 1080p and 26.7× at 4K; OpenCV still leads the standalone operation. +[^resize-2026]: The packed RGB8 half-scale path precomputes arbitrary-scale + Q11 sampling coefficients and specializes exact 2× downsampling as a + row-parallel 2×2 average. On the OpenCV 4.13 focused receipt, caller-owned + VGA output measured 0.120 ms versus 0.133 ms (SpatialRust 1.10×); 1080p, + 4K, and 8K reuse remain OpenCV wins by 2.40×, 2.01×, and 1.85×. Canonical + half-scale pixels are exact, and 300 arbitrary-size cases have maximum + absolute error 1. See the [focused harness](bench/opencv_resize_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 @@ -279,7 +287,7 @@ The same deterministic RGB inputs passed all VGA, 1080p, and 4K gates: | Workload | OpenCV comparison result at VGA / 1080p / 4K | | --- | --- | -| Bilinear resize | Exact pixels (max error 0) | +| Bilinear resize | Canonical half-scale exact; 300 arbitrary-size cases max error 1/255 | | RGB to gray | Max error 1/255; MAE 0.1333 / 0.1333 / 0.1329 | | 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 | diff --git a/bench/opencv_resize_comparison/README.md b/bench/opencv_resize_comparison/README.md new file mode 100644 index 0000000..e8b4e6a --- /dev/null +++ b/bench/opencv_resize_comparison/README.md @@ -0,0 +1,15 @@ +# OpenCV packed RGB8 resize comparison + +This focused harness compares public Python APIs for packed RGB `uint8` +bilinear resize at exactly half width and half height. It measures both +allocated and caller-owned output calls at VGA, 1080p, 4K, and 8K. + +```powershell +.\.venv\Scripts\python.exe bench/opencv_resize_comparison/performance.py ` + --output target/opencv-resize-performance.json +``` + +OpenCL is disabled, OpenCV receives the logical CPU count, and paired timings +use seeded random input. The canonical half-scale output must be bit-exact. +Three hundred arbitrary-dimension cases, including non-contiguous inputs, gate +the planned fixed-point path at a maximum absolute error of one `uint8` level. diff --git a/bench/opencv_resize_comparison/performance.py b/bench/opencv_resize_comparison/performance.py new file mode 100644 index 0000000..e75ff17 --- /dev/null +++ b/bench/opencv_resize_comparison/performance.py @@ -0,0 +1,197 @@ +"""Reproducible packed RGB8 bilinear-resize comparison with OpenCV.""" + +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 = { + "vga": (640, 480, 48), + "1080p": (1920, 1080, 32), + "4k": (3840, 2160, 20), + "8k": (7680, 4320, 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 validate_randomized_cases() -> tuple[int, int]: + rng = np.random.default_rng(115) + checked = 0 + max_error = 0 + for case in range(300): + height = int(rng.integers(1, 97)) + width = int(rng.integers(1, 129)) + output_height = int(rng.integers(1, 101)) + output_width = int(rng.integers(1, 133)) + image = rng.integers(0, 256, (height, width, 3), dtype=np.uint8) + if case % 3 == 0: + image = image[:, ::-1] + packed = np.ascontiguousarray(image) + expected = cv2.resize( + packed, (output_width, output_height), interpolation=cv2.INTER_LINEAR + ) + actual = sr.resize_image( + image, output_width, output_height, interpolation="bilinear" + ) + error = int(np.abs(expected.astype(np.int16) - actual.astype(np.int16)).max()) + if error > 1: + raise AssertionError(f"random case {case} max error {error} exceeds 1") + max_error = max(max_error, error) + checked += 1 + return checked, 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, repeats = PROFILES[profile] + output_width = width // 2 + output_height = height // 2 + image = rng.integers(0, 256, (height, width, 3), dtype=np.uint8) + opencv_out = np.empty((output_height, output_width, 3), dtype=np.uint8) + spatialrust_out = np.empty_like(opencv_out) + + def opencv_allocate() -> np.ndarray: + return cv2.resize( + image, (output_width, output_height), interpolation=cv2.INTER_LINEAR + ) + + def spatialrust_allocate() -> np.ndarray: + return sr.resize_image( + image, output_width, output_height, interpolation="bilinear" + ) + + def opencv_reuse() -> np.ndarray: + return cv2.resize( + image, + (output_width, output_height), + dst=opencv_out, + interpolation=cv2.INTER_LINEAR, + ) + + def spatialrust_reuse() -> np.ndarray: + return sr.resize_image( + image, + output_width, + output_height, + interpolation="bilinear", + out=spatialrust_out, + ) + + expected = opencv_allocate() + actual = spatialrust_allocate() + error = np.abs(expected.astype(np.int16) - actual.astype(np.int16)) + max_error = int(error.max()) + if max_error != 0: + raise AssertionError(f"{profile} canonical half-scale max error is {max_error}") + if opencv_reuse() is not opencv_out or spatialrust_reuse() is not spatialrust_out: + raise AssertionError("caller-owned output identity was not preserved") + if not np.array_equal(opencv_out, expected) or not np.array_equal( + spatialrust_out, actual + ): + raise AssertionError(f"{profile} reuse output differs from allocated output") + + _, _, opencv_timing, spatialrust_timing = timed_pair( + opencv_allocate, + spatialrust_allocate, + warmup=args.warmup, + repeats=repeats, + seed=115, + min_sample_time_ms=20.0, + ) + _, _, opencv_reuse_timing, spatialrust_reuse_timing = timed_pair( + opencv_reuse, + spatialrust_reuse, + warmup=args.warmup, + repeats=repeats, + seed=2115, + 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_width": width, + "input_height": height, + "output_width": output_width, + "output_height": output_height, + "operation": "packed RGB uint8 INTER_LINEAR half-scale resize", + "max_absolute_error": max_error, + "exact_fraction": float((error == 0).mean()), + "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-packed-rgb8-bilinear-resize-performance", + kind="performance", + status="pass", + environment_receipt=receipt, + results={ + "methodology": { + "timing_scope": "allocated and caller-owned-output Python API calls", + "paired_interleaved": True, + "minimum_sample_time_ms": 20.0, + "input": "seeded packed random uint8 RGB", + "scale": "exactly one half in both axes", + "randomized_correctness_cases": randomized_cases, + "randomized_max_absolute_error": randomized_max_error, + "thread_policy": "logical CPU count for OpenCV; Rayon default for SpatialRust", + "accuracy": "canonical exact; arbitrary dimensions maximum uint8 error <= 1", + }, + "profiles": results, + }, + ) + emit_report(report, args.output) + + +if __name__ == "__main__": + main() diff --git a/crates/spatialrust-image/src/lib.rs b/crates/spatialrust-image/src/lib.rs index 6e7b046..9729cf5 100644 --- a/crates/spatialrust-image/src/lib.rs +++ b/crates/spatialrust-image/src/lib.rs @@ -635,6 +635,15 @@ impl<'a, T, const CHANNELS: usize> ImageViewMut<'a, T, CHANNELS> { self.data.get_mut(start..start + self.width * CHANNELS) } + /// Returns the complete mutable backing span, including inter-row padding. + /// + /// The final row has no required trailing padding, so the returned length is + /// the minimum checked span accepted by [`ImageViewMut::new`]. + #[must_use] + pub fn as_mut_slice(&mut self) -> &mut [T] { + self.data + } + /// Creates a checked mutable zero-copy subview. pub fn subview( &mut self, diff --git a/crates/spatialrust-py/src/lib.rs b/crates/spatialrust-py/src/lib.rs index a10708d..279be80 100644 --- a/crates/spatialrust-py/src/lib.rs +++ b/crates/spatialrust-py/src/lib.rs @@ -98,14 +98,14 @@ use spatialrust::vision::{ solve_pnp as solve_pnp_op, spatial_gradient_u8 as spatial_gradient_u8_op, spatial_gradient_u8_into as spatial_gradient_u8_into_op, stereo_block_match as stereo_block_match_op, stitch_panorama_pair as stitch_panorama_pair_op, - threshold as threshold_op, AbsolutePose, AdaptiveThresholdMethod, BinaryMask, BorderMode, - BoundingBox2, CameraMatrix3, CannyOptions, ConfidenceMap, Connectivity, CornerSelectionOptions, - DescriptorBuffer, Detection, DistanceTransformWorkspace, FastOptions, GaussianBlurU8Workspace, - HarrisOptions, Interpolation, Kernel2D, Keypoint2, MaskRle, MatchOptions, MorphologyOperation, - MorphologyShape, ObjectImageCorrespondence, OrbOptions, OrbScoreType, PanoramaOptions, - PerspectiveTransform, PointCorrespondence2, PointMap, RectMorphologyWorkspace, - RgbdOdometryOptions, RleOrder, RobustEstimationOptions, ShiTomasiOptions, SoftNmsMethod, - StereoBmOptions, StructuringElement, ThresholdType, + threshold as threshold_op, AbsolutePose, AdaptiveThresholdMethod, BilinearResizeU8Plan, + BinaryMask, BorderMode, BoundingBox2, CameraMatrix3, CannyOptions, ConfidenceMap, Connectivity, + CornerSelectionOptions, DescriptorBuffer, Detection, DistanceTransformWorkspace, FastOptions, + GaussianBlurU8Workspace, HarrisOptions, Interpolation, Kernel2D, Keypoint2, MaskRle, + MatchOptions, MorphologyOperation, MorphologyShape, ObjectImageCorrespondence, OrbOptions, + OrbScoreType, PanoramaOptions, PerspectiveTransform, PointCorrespondence2, PointMap, + RectMorphologyWorkspace, RgbdOdometryOptions, RleOrder, RobustEstimationOptions, + ShiTomasiOptions, SoftNmsMethod, StereoBmOptions, StructuringElement, ThresholdType, }; use spatialrust::vision::{dense_flow_block_match as dense_flow_native, DenseFlowOptions}; use spatialrust::voxelize::{ @@ -3213,6 +3213,10 @@ fn resize_image<'py>( let mut packed = Vec::new(); let image = rgb_image_view_from_numpy(&image, &mut packed)?; let interpolation = parse_interpolation(interpolation)?; + let bilinear_plan = (interpolation == Interpolation::Bilinear) + .then(|| BilinearResizeU8Plan::new(image.width(), image.height(), width, height)) + .transpose() + .map_err(to_py_err)?; if let Some(out) = out { { let mut out_rw = out.readwrite(); @@ -3230,11 +3234,19 @@ fn resize_image<'py>( }; let output = ImageViewMut::::new(width, height, width * 3, out_slice) .map_err(to_py_err)?; - resize_into_op(image, output, interpolation).map_err(to_py_err)?; + if let Some(plan) = &bilinear_plan { + plan.resize_into(image, output).map_err(to_py_err)?; + } else { + resize_into_op(image, output, interpolation).map_err(to_py_err)?; + } } return Ok(out); } - let output = resize_op(image, width, height, interpolation).map_err(to_py_err)?; + let output = if let Some(plan) = &bilinear_plan { + plan.resize(image).map_err(to_py_err)? + } else { + resize_op(image, width, height, interpolation).map_err(to_py_err)? + }; let array = Array3::from_shape_vec((height, width, 3), output.into_vec()).map_err(to_py_err)?; Ok(array.into_pyarray_bound(py)) } diff --git a/crates/spatialrust-py/tests/test_bindings.py b/crates/spatialrust-py/tests/test_bindings.py index 5c246b6..a8260ee 100644 --- a/crates/spatialrust-py/tests/test_bindings.py +++ b/crates/spatialrust-py/tests/test_bindings.py @@ -138,6 +138,17 @@ def test_image_resize_letterbox_and_normalize(): assert sr.resize_image(image, 4, 4, interpolation="nearest", out=resized_out) is resized_out np.testing.assert_array_equal(resized_out, resized) + bilinear_input = np.arange(8 * 8 * 3, dtype=np.uint8).reshape(8, 8, 3)[:, ::-1] + bilinear = sr.resize_image(bilinear_input, 4, 4, interpolation="bilinear") + bilinear_out = np.empty_like(bilinear) + assert ( + sr.resize_image( + bilinear_input, 4, 4, interpolation="bilinear", out=bilinear_out + ) + is bilinear_out + ) + np.testing.assert_array_equal(bilinear_out, bilinear) + letterboxed, transform = sr.letterbox_image(image, 4, 6, fill=(7, 8, 9)) assert letterboxed.shape == (6, 4, 3) assert transform == (2.0, 0, 1, 4, 4) diff --git a/crates/spatialrust-vision/Cargo.toml b/crates/spatialrust-vision/Cargo.toml index 40aecde..1e0f056 100644 --- a/crates/spatialrust-vision/Cargo.toml +++ b/crates/spatialrust-vision/Cargo.toml @@ -10,7 +10,7 @@ description = "AI-ready image processing and vision algorithms for SpatialRust" [features] default = [] -resize = [] +resize = ["dep:rayon"] preprocess = ["resize"] warp = ["resize"] imgproc-filter = ["dep:pulp", "dep:rayon"] diff --git a/crates/spatialrust-vision/benches/resize.rs b/crates/spatialrust-vision/benches/resize.rs index 8b7f42d..43252bf 100644 --- a/crates/spatialrust-vision/benches/resize.rs +++ b/crates/spatialrust-vision/benches/resize.rs @@ -1,6 +1,6 @@ use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use spatialrust_image::Image; -use spatialrust_vision::{resize, resize_into, Interpolation}; +use spatialrust_vision::{resize, resize_into, BilinearResizeU8Plan, Interpolation}; fn benchmark_resize(c: &mut Criterion) { let mut group = c.benchmark_group("resize_bilinear_rgb8"); @@ -15,6 +15,7 @@ fn benchmark_resize(c: &mut Criterion) { vec![0; output_width * output_height * 3], ) .unwrap(); + let plan = BilinearResizeU8Plan::new(width, height, output_width, output_height).unwrap(); group.throughput(Throughput::Elements((output_width * output_height) as u64)); group.bench_function(BenchmarkId::new("allocate", name), |b| { b.iter(|| { @@ -33,6 +34,12 @@ fn benchmark_resize(c: &mut Criterion) { .unwrap() }); }); + group.bench_function(BenchmarkId::new("planned_allocate", name), |b| { + b.iter(|| plan.resize(black_box(input.view())).unwrap()); + }); + group.bench_function(BenchmarkId::new("planned_reuse", name), |b| { + b.iter(|| plan.resize_into(black_box(input.view()), output.view_mut()).unwrap()); + }); } group.finish(); } diff --git a/crates/spatialrust-vision/src/resize.rs b/crates/spatialrust-vision/src/resize.rs index db8146e..bb7fe34 100644 --- a/crates/spatialrust-vision/src/resize.rs +++ b/crates/spatialrust-vision/src/resize.rs @@ -1,3 +1,4 @@ +use rayon::prelude::*; use spatialrust_image::{Image, ImageView, ImageViewMut}; use crate::{PixelComponent, VisionError, VisionResult}; @@ -16,6 +17,226 @@ pub enum Interpolation { Area, } +const BILINEAR_WEIGHT_BITS: u32 = 11; +const BILINEAR_WEIGHT_SCALE: u32 = 1 << BILINEAR_WEIGHT_BITS; +const PARALLEL_RESIZE_COMPONENTS: usize = 100_000; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct BilinearAxisSample { + lower: usize, + upper: usize, + upper_weight: u16, +} + +/// Reusable source coordinates and fixed-point coefficients for `u8` bilinear resize. +/// +/// Constructing a plan performs all half-pixel coordinate mapping once. Execution +/// never allocates and accepts packed or explicitly strided input/output views. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BilinearResizeU8Plan { + input_width: usize, + input_height: usize, + output_width: usize, + output_height: usize, + x_samples: Vec, + y_samples: Vec, +} + +impl BilinearResizeU8Plan { + /// Builds a plan for the named input and output dimensions. + pub fn new( + input_width: usize, + input_height: usize, + output_width: usize, + output_height: usize, + ) -> VisionResult { + if output_width != 0 && output_height != 0 && (input_width == 0 || input_height == 0) { + return Err(VisionError::InvalidDimensions( + "cannot resize an empty input to a non-empty output".to_owned(), + )); + } + let half_scale = input_width == output_width.saturating_mul(2) + && input_height == output_height.saturating_mul(2); + Ok(Self { + input_width, + input_height, + output_width, + output_height, + x_samples: if half_scale { + Vec::new() + } else { + bilinear_axis_samples(input_width, output_width) + }, + y_samples: if half_scale { + Vec::new() + } else { + bilinear_axis_samples(input_height, output_height) + }, + }) + } + + /// Returns the planned input dimensions as `(width, height)`. + #[must_use] + pub const fn input_dimensions(&self) -> (usize, usize) { + (self.input_width, self.input_height) + } + + /// Returns the planned output dimensions as `(width, height)`. + #[must_use] + pub const fn output_dimensions(&self) -> (usize, usize) { + (self.output_width, self.output_height) + } + + /// Resizes into a newly allocated image while preserving input metadata. + pub fn resize( + &self, + input: ImageView<'_, u8, CHANNELS>, + ) -> VisionResult> { + let len = self + .output_width + .checked_mul(self.output_height) + .and_then(|pixels| pixels.checked_mul(CHANNELS)) + .ok_or_else(|| { + VisionError::InvalidDimensions("resize output is too large".to_owned()) + })?; + let mut output = Image::try_new_with_metadata( + self.output_width, + self.output_height, + vec![0; len], + input.metadata(), + )?; + self.resize_into(input, output.view_mut())?; + Ok(output) + } + + /// Resizes into caller-owned storage without allocating. + pub fn resize_into( + &self, + input: ImageView<'_, u8, CHANNELS>, + mut output: ImageViewMut<'_, u8, CHANNELS>, + ) -> 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(input.metadata())?; + if self.output_width == 0 || self.output_height == 0 { + return Ok(()); + } + + let row_stride = output.row_stride(); + let output_components = self.output_width * self.output_height * CHANNELS; + let rows = output.as_mut_slice().par_chunks_mut(row_stride).enumerate(); + if self.input_width == self.output_width.saturating_mul(2) + && self.input_height == self.output_height.saturating_mul(2) + { + if output_components >= PARALLEL_RESIZE_COMPONENTS { + rows.for_each(|(y, row)| resize_half_row(input, row, y, self.output_width)); + } else { + output + .as_mut_slice() + .chunks_mut(row_stride) + .enumerate() + .for_each(|(y, row)| resize_half_row(input, row, y, self.output_width)); + } + } else if output_components >= PARALLEL_RESIZE_COMPONENTS { + rows.for_each(|(y, row)| self.resize_bilinear_row(input, row, y)); + } else { + output + .as_mut_slice() + .chunks_mut(row_stride) + .enumerate() + .for_each(|(y, row)| self.resize_bilinear_row(input, row, y)); + } + Ok(()) + } + + fn resize_bilinear_row( + &self, + input: ImageView<'_, u8, CHANNELS>, + 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; + let round = 1 << (BILINEAR_WEIGHT_BITS * 2 - 1); + 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 * CHANNELS; + let upper = x_sample.upper * CHANNELS; + let destination = x * CHANNELS; + for channel in 0..CHANNELS { + let horizontal_top = + u32::from(top[lower + channel]) * inv_wx + u32::from(top[upper + channel]) * wx; + let horizontal_bottom = u32::from(bottom[lower + channel]) * inv_wx + + u32::from(bottom[upper + channel]) * wx; + output[destination + channel] = + ((horizontal_top * inv_wy + horizontal_bottom * wy + round) + >> (BILINEAR_WEIGHT_BITS * 2)) as u8; + } + } + } +} + +fn bilinear_axis_samples(input_len: usize, output_len: usize) -> Vec { + if input_len == 0 { + return Vec::new(); + } + (0..output_len) + .map(|output| { + let coordinate = half_pixel_coordinate(output, input_len, output_len); + let base = coordinate.floor() as isize; + BilinearAxisSample { + lower: clamped_index(base, input_len), + upper: clamped_index(base + 1, input_len), + upper_weight: ((coordinate - coordinate.floor()) * f64::from(BILINEAR_WEIGHT_SCALE)) + .round() + .clamp(0.0, f64::from(BILINEAR_WEIGHT_SCALE)) + as u16, + } + }) + .collect() +} + +fn resize_half_row( + input: ImageView<'_, u8, CHANNELS>, + 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 in 0..output_width { + let source = x * 2 * CHANNELS; + let destination = x * CHANNELS; + for channel in 0..CHANNELS { + let sum = u16::from(top[source + channel]) + + u16::from(top[source + CHANNELS + channel]) + + u16::from(bottom[source + channel]) + + u16::from(bottom[source + CHANNELS + channel]); + output[destination + channel] = ((sum + 2) >> 2) as u8; + } + } +} + /// Resizes an interleaved image while preserving semantic metadata. pub fn resize( input: ImageView<'_, T, CHANNELS>, @@ -234,8 +455,8 @@ fn sample_area( #[cfg(test)] mod tests { - use super::{resize, resize_into, Interpolation}; - use spatialrust_image::{Image, ImageViewMut}; + use super::{resize, resize_into, BilinearResizeU8Plan, Interpolation}; + use spatialrust_image::{Image, ImageView, ImageViewMut}; #[test] fn nearest_repeats_pixels() { @@ -285,4 +506,59 @@ mod tests { } } } + + #[test] + fn bilinear_u8_plan_matches_generic_with_bounded_rounding() { + let input = + Image::::try_new(7, 5, (0..105).map(|value| (value * 37 % 256) as u8).collect()) + .unwrap(); + let plan = BilinearResizeU8Plan::new(7, 5, 11, 8).unwrap(); + let actual = plan.resize(input.view()).unwrap(); + let expected = resize(input.view(), 11, 8, Interpolation::Bilinear).unwrap(); + assert!(actual + .as_slice() + .iter() + .zip(expected.as_slice()) + .all(|(&left, &right)| left.abs_diff(right) <= 1)); + } + + #[test] + fn bilinear_u8_plan_half_scale_matches_generic_exactly() { + let input = + Image::::try_new(8, 6, (0..144).map(|value| (value * 53 % 256) as u8).collect()) + .unwrap(); + let plan = BilinearResizeU8Plan::new(8, 6, 4, 3).unwrap(); + let actual = plan.resize(input.view()).unwrap(); + let expected = resize(input.view(), 4, 3, Interpolation::Bilinear).unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn bilinear_u8_plan_preserves_strided_padding_and_validates_shape() { + let input = Image::::try_new(4, 4, (0..16).collect()).unwrap(); + let plan = BilinearResizeU8Plan::new(4, 4, 2, 2).unwrap(); + let mut storage = vec![99_u8; 7]; + let output = ImageViewMut::::new(2, 2, 5, &mut storage).unwrap(); + plan.resize_into(input.view(), output).unwrap(); + assert_eq!(&storage, &[3, 5, 99, 99, 99, 11, 13]); + + let wrong = BilinearResizeU8Plan::new(5, 4, 2, 2).unwrap(); + let mut output = Image::::try_new(2, 2, vec![0; 4]).unwrap(); + assert!(wrong.resize_into(input.view(), output.view_mut()).is_err()); + } + + #[test] + fn bilinear_u8_plan_accepts_strided_rgb_input() { + let mut storage = vec![231_u8; 54]; + for y in 0..4 { + for x in 0..12 { + storage[y * 14 + x] = (y * 41 + x * 13) as u8; + } + } + let input = ImageView::::new(4, 4, 14, &storage).unwrap(); + let plan = BilinearResizeU8Plan::new(4, 4, 2, 2).unwrap(); + let actual = plan.resize(input).unwrap(); + let expected = resize(input, 2, 2, Interpolation::Bilinear).unwrap(); + assert_eq!(actual, expected); + } } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c22acf2..25f9513 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -576,7 +576,7 @@ backend, allocation mode, and accuracy contract. | 112 | Planned | 111 | Attribute native kernel, allocation, Python conversion, and transfer costs with reproducible throughput and memory receipts | | 113 | Planned | 112 | Caller-owned outputs and reusable workspaces for multi-stage CPU vision without hidden copies | | 114 | Planned | 112–113 | Safe size-aware CPU dispatch for packed fast paths, strided fallbacks, and bounded row/tile parallelism | -| 115 | Planned | 113–114 | Accelerated resize and color conversion with precomputed sampling plans and fused preprocessing experiments | +| 115 | In progress | 113–114 | Accelerated resize and color conversion with precomputed sampling plans and fused preprocessing experiments | | 116 | In progress | 113–115 | Accelerated separable Gaussian and Sobel engine with cached kernels and shared gradient passes | | 117 | Complete | 113–116 | Sliding-window morphology engine with exact OpenCV comparison and generic-mask fallback | | 118 | Planned | 113–117 | Fused Canny fast path that avoids public intermediates unless explicitly requested | @@ -627,10 +627,10 @@ to one implicitly, and GPU receipts must retain named upload/readback stages. | Slice | Status | Scope | Evidence | | --- | --- | --- | --- | -| 115A | Planned | Precompute resize source coordinates and interpolation coefficients | plan reuse tests | -| 115B | Planned | Packed bilinear/nearest/area and RGB-to-gray fast paths | OpenCV max-error contract | +| 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 and exact half-scale path complete; nearest/area/gray remain | | 115C | Planned | Evaluate resize+gray and resize+CHW fusion without changing standalone APIs | fused/unfused parity and timing | -| 115D | Planned | Improve current SpatialRust throughput by at least 5x on one canonical large profile | native and Python receipts | +| 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 ea50cb6..57bbaca 100644 --- a/docs/site/algorithms.html +++ b/docs/site/algorithms.html @@ -30,7 +30,7 @@

Algorithm catalog

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 outputsspatialrust-vision · preprocessCPU - Resize and warpNearest, bilinear, bicubic and area resize; remap; affine and perspective warpspatialrust-vision · resize, warpCPU / GPU + 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 Image analysisFixed/Otsu/adaptive threshold, histogram, equalization, CLAHE, integral image, Cannyspatialrust-vision · imgproc-analysis, imgproc-cannyCPU diff --git a/docs/site/vision2.html b/docs/site/vision2.html index 6c88c34..5677e4e 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

Precompute sampling plans, accelerate common packed formats, and evaluate fused resize+gray and resize+CHW preprocessing.

+

Resize and color

Reusable Q11 bilinear plans and the exact packed RGB8 half-scale path are delivered; nearest/area, RGB-to-gray, and fused resize+gray/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.

@@ -55,8 +55,9 @@

Latest measured outcome

Structured-mask labeling

Run-length union-find connected components measured 2.17×–3.61× faster than OpenCV SAUF at VGA, 1080p, and 4K. Labels, areas, and boxes are exact across canonical and 320 randomized cases.

Fused Sobel L1

Exact 3×3 abs(Gx) + abs(Gy) avoids four OpenCV materialization stages. Allocated Python calls measured 1.86× faster at 1080p, 2.19× at 4K, and 2.42× at 8K across 300 randomized parity cases.

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.

-

These are workload- and host-specific results. 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. 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_resize_acceleration.md b/notes/2026-07-16_resize_acceleration.md new file mode 100644 index 0000000..7feca49 --- /dev/null +++ b/notes/2026-07-16_resize_acceleration.md @@ -0,0 +1,46 @@ +# Packed RGB8 bilinear resize acceleration — 2026-07-16 + +## Scope + +This slice accelerates public CPU and Python bilinear resize without changing +the existing generic `resize` / `resize_into` contract. It adds +`BilinearResizeU8Plan`, which caches half-pixel source coordinates and Q11 +weights for arbitrary dimensions. Exact half-width/half-height resize skips +the coefficient tables and executes a bounded row-parallel 2×2 average. + +Input and output may be packed or strided. Caller-owned output preserves row +padding and metadata, and Python `out=` preserves object identity. No device +transfer or hidden GPU selection is introduced. + +## Native before / after + +Criterion used packed RGB8 inputs and half-scale outputs on the Windows host +recorded below. The old generic reuse medians were 4.764 ms at VGA, 31.739 ms +at 1080p, and 104.69 ms at 4K. Planned reuse measured 0.134 ms, 0.664 ms, and +2.803 ms respectively: improvements of about 35.6×, 47.8×, and 37.3×. + +## OpenCV comparison + +Focused paired/interleaved Python timings used OpenCV 4.13.0, CPython 3.12.10, +12 logical threads, OpenCL disabled, seeded random packed RGB8 input, and exact +half-scale output. + +| Profile | OpenCV allocate | SpatialRust allocate | OpenCV reuse | SpatialRust reuse | Outcome | +| --- | ---: | ---: | ---: | ---: | --- | +| VGA | 0.133 ms | 0.158 ms | 0.133 ms | 0.120 ms | SpatialRust reuse 1.10× faster | +| 1080p | 0.599 ms | 0.894 ms | 0.283 ms | 0.680 ms | OpenCV leads | +| 4K | 2.027 ms | 3.253 ms | 1.217 ms | 2.450 ms | OpenCV leads | +| 8K | 6.563 ms | 10.985 ms | 5.157 ms | 9.562 ms | OpenCV leads | + +Canonical half-scale output is bit-exact. Three hundred arbitrary input/output +dimension cases, including non-contiguous NumPy input, had maximum absolute +error 1. The receipt is generated by +`bench/opencv_resize_comparison/performance.py`; the authoritative local JSON +for this run was `target/opencv-resize-performance-final.json`. + +## Boundary + +This is an OpenCV win only for the named VGA caller-output workload. OpenCV +still leads allocated calls at every measured size and caller-output calls at +1080p, 4K, and 8K. Epic 115 remains in progress for nearest/area packed paths, +RGB-to-gray, and fused resize preprocessing.