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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ removed no sooner than the next major (see `docs/API_STABILITY.md`).

### Added

- **Reusable CPU vision kernels (Epic 103)**: stable caller-owned
`resize_into`, `rgb_to_gray_into`, `normalize_into`, and `pack_chw_into`
paths, NumPy `out=` bindings, size-aware safe parallel CHW packing, and a
VGA/1080p/4K allocation/reuse comparison receipt. The reference run records
both OpenCV's resize/color-conversion lead and SpatialRust's 8.54x–16.11x
reusable RGB-to-CHW advantage.

- **Vision API conformance (Epic 102)**: a machine-readable stable/provisional
image-camera-vision registry, a compile-and-behavior API contract, and a
dedicated Linux/Windows/macOS CI matrix for the image, camera, and full vision
Expand Down
6 changes: 3 additions & 3 deletions bench/opencv_comparison/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
"spatialrust_version"
],
"workloads": [
{ "id": "resize_bilinear", "domain": "imgproc", "modes": ["allocate"] },
{ "id": "rgb_to_gray", "domain": "imgproc", "modes": ["allocate"] },
{ "id": "resize_bilinear", "domain": "imgproc", "modes": ["allocate", "reuse"] },
{ "id": "rgb_to_gray", "domain": "imgproc", "modes": ["allocate", "reuse"] },
{ "id": "gaussian_blur", "domain": "imgproc", "modes": ["allocate"] },
{ "id": "sobel", "domain": "imgproc", "modes": ["allocate"] },
{ "id": "canny", "domain": "imgproc", "modes": ["allocate"] },
Expand All @@ -25,7 +25,7 @@
{ "id": "stereo_bm", "domain": "calib3d", "modes": ["allocate"] },
{ "id": "depth_to_xyz", "domain": "rgbd", "modes": ["allocate", "reuse"] },
{ "id": "rgbd_to_point_cloud", "domain": "spatial-e2e", "modes": ["allocate"] },
{ "id": "ai_preprocess", "domain": "dnn-adapter", "modes": ["allocate"] },
{ "id": "ai_preprocess", "domain": "dnn-adapter", "modes": ["allocate", "reuse"] },
{ "id": "rgbd_to_voxel", "domain": "spatial-e2e", "modes": ["allocate"] }
]
}
4 changes: 4 additions & 0 deletions bench/opencv_comparison/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
ROOT = Path(__file__).resolve().parents[2]
SUITES = {
"vision": ROOT / "bench" / "opencv_vision_comparison" / "run.py",
"vision-performance": ROOT
/ "bench"
/ "opencv_vision_comparison"
/ "performance.py",
"rgbd": ROOT / "bench" / "opencv_rgbd_comparison" / "run.py",
}

Expand Down
9 changes: 9 additions & 0 deletions bench/opencv_vision_comparison/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,12 @@ when a documented numerical tolerance is exceeded. Pass `--output PATH` to
retain the report. OpenCV is comparison/test tooling only; it is not a Rust
runtime dependency. The shared report contract and workload registry are in
[`../opencv_comparison`](../opencv_comparison/README.md).

Epic 103 adds allocate/reuse timing for bilinear resize, RGB-to-gray, and AI
CHW preprocessing at VGA, 1080p, and 4K. It preserves raw samples and p95 in
the same report contract:

```powershell
python bench\opencv_vision_comparison\performance.py `
--output target\opencv-comparison\vision-performance.json
```
219 changes: 219 additions & 0 deletions bench/opencv_vision_comparison/performance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
"""Performance comparison for Epic 103 reusable CPU vision paths."""

from __future__ import annotations

import argparse
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


PROFILES = {
"vga": (640, 480, 20),
"1080p": (1920, 1080, 8),
"4k": (3840, 2160, 3),
}


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path)
parser.add_argument("--profiles", default="vga,1080p,4k")
parser.add_argument("--warmup", type=int, default=3)
return parser.parse_args()


def measurement(
workload: str,
implementation: str,
mode: str,
width: int,
height: int,
timing: dict[str, object],
) -> dict[str, object]:
return {
"workload": workload,
"implementation": implementation,
"mode": mode,
"width": width,
"height": height,
"timing": timing,
}


def main() -> None:
args = parse_args()
selected = [name.strip() for name in args.profiles.split(",") if name.strip()]
unknown = sorted(set(selected) - PROFILES.keys())
if unknown:
raise ValueError(f"unknown profiles: {', '.join(unknown)}")
if args.warmup < 0:
raise ValueError("warmup must be non-negative")

if hasattr(cv2, "ocl"):
cv2.ocl.setUseOpenCL(False)

rng = np.random.default_rng(103)
measurements: list[dict[str, object]] = []
correctness: dict[str, object] = {}
speedups: dict[str, object] = {}

for profile in selected:
width, height, repeats = PROFILES[profile]
image = rng.integers(0, 256, size=(height, width, 3), dtype=np.uint8)
output_width, output_height = width // 2, height // 2

resize_cv_out = np.empty((output_height, output_width, 3), dtype=np.uint8)
resize_sr_out = np.empty_like(resize_cv_out)
resize_cv = cv2.resize(image, (output_width, output_height), interpolation=cv2.INTER_LINEAR)
resize_sr = sr.resize_image(image, output_width, output_height, interpolation="bilinear")
resize_error = int(
np.max(np.abs(resize_cv.astype(np.int16) - resize_sr.astype(np.int16)))
)
correctness[f"{profile}_resize_max_u8_error"] = resize_error
if resize_error > 1:
raise AssertionError(f"{profile} resize error {resize_error} > 1")

_, cv_resize_alloc = timed(
lambda: cv2.resize(image, (output_width, output_height), interpolation=cv2.INTER_LINEAR),
warmup=args.warmup,
repeats=repeats,
)
_, sr_resize_alloc = timed(
lambda: sr.resize_image(image, output_width, output_height, interpolation="bilinear"),
warmup=args.warmup,
repeats=repeats,
)
_, cv_resize_reuse = timed(
lambda: cv2.resize(
image,
(output_width, output_height),
dst=resize_cv_out,
interpolation=cv2.INTER_LINEAR,
),
warmup=args.warmup,
repeats=repeats,
)
_, sr_resize_reuse = timed(
lambda: sr.resize_image(
image,
output_width,
output_height,
interpolation="bilinear",
out=resize_sr_out,
),
warmup=args.warmup,
repeats=repeats,
)
np.testing.assert_array_equal(resize_sr_out, resize_sr)

gray_cv_out = np.empty((height, width), dtype=np.uint8)
gray_sr_out = np.empty_like(gray_cv_out)
gray_cv = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
gray_sr = sr.rgb_to_gray_image(image)
gray_error = int(np.max(np.abs(gray_cv.astype(np.int16) - gray_sr.astype(np.int16))))
correctness[f"{profile}_rgb_to_gray_max_u8_error"] = gray_error
if gray_error > 1:
raise AssertionError(f"{profile} gray error {gray_error} > 1")
_, cv_gray_alloc = timed(
lambda: cv2.cvtColor(image, cv2.COLOR_RGB2GRAY),
warmup=args.warmup,
repeats=repeats,
)
_, sr_gray_alloc = timed(
lambda: sr.rgb_to_gray_image(image), warmup=args.warmup, repeats=repeats
)
_, cv_gray_reuse = timed(
lambda: cv2.cvtColor(image, cv2.COLOR_RGB2GRAY, dst=gray_cv_out),
warmup=args.warmup,
repeats=repeats,
)
_, sr_gray_reuse = timed(
lambda: sr.rgb_to_gray_image(image, out=gray_sr_out),
warmup=args.warmup,
repeats=repeats,
)
np.testing.assert_array_equal(gray_sr_out, gray_sr)

chw_sr_out = np.empty((3, height, width), dtype=np.float32)
blob_cv = cv2.dnn.blobFromImage(
image, scalefactor=1.0 / 255.0, size=(width, height), swapRB=False, crop=False
)[0]
chw_sr = sr.normalize_image_chw(image)
chw_error = float(np.max(np.abs(blob_cv - chw_sr)))
correctness[f"{profile}_ai_preprocess_max_f32_error"] = chw_error
if chw_error > 1e-6:
raise AssertionError(f"{profile} AI preprocess error {chw_error} > 1e-6")
_, cv_chw_alloc = timed(
lambda: cv2.dnn.blobFromImage(
image, scalefactor=1.0 / 255.0, size=(width, height), swapRB=False, crop=False
),
warmup=args.warmup,
repeats=repeats,
)
_, sr_chw_alloc = timed(
lambda: sr.normalize_image_chw(image), warmup=args.warmup, repeats=repeats
)
_, sr_chw_reuse = timed(
lambda: sr.normalize_image_chw(image, out=chw_sr_out),
warmup=args.warmup,
repeats=repeats,
)
np.testing.assert_allclose(chw_sr_out, chw_sr, atol=0.0, rtol=0.0)

rows = (
("resize_bilinear", "opencv", "allocate", cv_resize_alloc),
("resize_bilinear", "spatialrust", "allocate", sr_resize_alloc),
("resize_bilinear", "opencv", "reuse", cv_resize_reuse),
("resize_bilinear", "spatialrust", "reuse", sr_resize_reuse),
("rgb_to_gray", "opencv", "allocate", cv_gray_alloc),
("rgb_to_gray", "spatialrust", "allocate", sr_gray_alloc),
("rgb_to_gray", "opencv", "reuse", cv_gray_reuse),
("rgb_to_gray", "spatialrust", "reuse", sr_gray_reuse),
("ai_preprocess", "opencv", "allocate", cv_chw_alloc),
("ai_preprocess", "spatialrust", "allocate", sr_chw_alloc),
("ai_preprocess", "spatialrust", "reuse", sr_chw_reuse),
)
measurements.extend(
measurement(workload, implementation, mode, width, height, timing)
for workload, implementation, mode, timing in rows
)
speedups[profile] = {
"resize_allocate": cv_resize_alloc["median"] / sr_resize_alloc["median"],
"resize_reuse": cv_resize_reuse["median"] / sr_resize_reuse["median"],
"rgb_to_gray_allocate": cv_gray_alloc["median"] / sr_gray_alloc["median"],
"rgb_to_gray_reuse": cv_gray_reuse["median"] / sr_gray_reuse["median"],
"ai_preprocess_allocate": cv_chw_alloc["median"] / sr_chw_alloc["median"],
"ai_preprocess_reuse_vs_opencv_allocate": cv_chw_alloc["median"]
/ sr_chw_reuse["median"],
}

environment_receipt = environment(
opencv_version=cv2.__version__, spatialrust_version=sr.__version__
)
environment_receipt["opencv_threads"] = cv2.getNumThreads()
environment_receipt["opencv_opencl_enabled"] = bool(
hasattr(cv2, "ocl") and cv2.ocl.useOpenCL()
)
report = make_report(
suite="opencv-vision-performance",
kind="performance",
status="pass",
environment_receipt=environment_receipt,
results={
"correctness": correctness,
"speedup_vs_opencv": speedups,
"measurements": measurements,
},
)
emit_report(report, args.output)


if __name__ == "__main__":
main()
7 changes: 7 additions & 0 deletions crates/spatialrust-image/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,13 @@ impl<'a, T, const CHANNELS: usize> ImageViewMut<'a, T, CHANNELS> {
self.metadata
}

/// Replaces semantic metadata after validating the channel count.
pub fn set_metadata(&mut self, metadata: ImageMetadata) -> Result<(), ImageError> {
metadata.validate::<CHANNELS>()?;
self.metadata = metadata;
Ok(())
}

/// Reborrows this mutable view as read-only.
#[must_use]
pub fn as_view(&self) -> ImageView<'_, T, CHANNELS> {
Expand Down
6 changes: 5 additions & 1 deletion crates/spatialrust-platform/src/stability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ impl StabilityRegistry {
"spatialrust-vision::BorderMode",
"spatialrust-vision::Interpolation",
"spatialrust-vision::resize",
"spatialrust-vision::resize_into",
"spatialrust-vision::normalize_into",
"spatialrust-vision::pack_chw_into",
"spatialrust-vision::rgb_to_gray_into",
"spatialrust-vision::Kernel1D",
"spatialrust-vision::Kernel2D",
"spatialrust-vision::filter2d",
Expand Down Expand Up @@ -172,7 +176,7 @@ mod tests {
registry.lookup("spatialrust-gpu::GpuImage").unwrap().class,
ApiStabilityClass::Provisional
);
assert!(registry.items().len() >= 35);
assert!(registry.items().len() >= 39);
assert_eq!(registry.experimental_count(), 0);
}
}
4 changes: 3 additions & 1 deletion crates/spatialrust-py/spatialrust.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ def resize_image(
width: int,
height: int,
interpolation: str = ...,
out: Optional[_U8Array] = ...,
) -> _U8Array: ...
def letterbox_image(
image: _U8Array,
Expand All @@ -233,8 +234,9 @@ def normalize_image_chw(
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) -> _U8Array: ...
def rgb_to_gray_image(image: _U8Array, out: Optional[_U8Array] = ...) -> _U8Array: ...
def rgb_to_hsv_image(image: _U8Array) -> _U8Array: ...
def remap_image(
image: _U8Array,
Expand Down
Loading
Loading