Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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× |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
15 changes: 15 additions & 0 deletions bench/opencv_resize_comparison/README.md
Original file line number Diff line number Diff line change
@@ -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.
197 changes: 197 additions & 0 deletions bench/opencv_resize_comparison/performance.py
Original file line number Diff line number Diff line change
@@ -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()
9 changes: 9 additions & 0 deletions crates/spatialrust-image/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 22 additions & 10 deletions crates/spatialrust-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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();
Expand All @@ -3230,11 +3234,19 @@ fn resize_image<'py>(
};
let output = ImageViewMut::<u8, 3>::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))
}
Expand Down
11 changes: 11 additions & 0 deletions crates/spatialrust-py/tests/test_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion crates/spatialrust-vision/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
9 changes: 8 additions & 1 deletion crates/spatialrust-vision/benches/resize.rs
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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(|| {
Expand All @@ -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();
}
Expand Down
Loading
Loading