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

### Added

- **Computational photography and panorama (Epic 108)**: gray-world white
balance, aligned well-exposedness fusion, bounded homography panorama canvas,
feather blending, Python entry points, and OpenCV warp parity receipts behind
an additive vision feature.

- **Robust visual/RGB-D odometry integration (Epic 107)**: spatially balanced
keypoint selection, forward/backward LK validation, scale-explicit monocular
motion, metric depth-backed PnP, mapping `DeltaMotion` bridges, Python RGB-D
Expand Down
2 changes: 2 additions & 0 deletions bench/opencv_comparison/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
{ "id": "multi_object_tracking", "domain": "video", "modes": ["streaming"] },
{ "id": "visual_odometry", "domain": "localization", "modes": ["correctness"] },
{ "id": "rgbd_odometry", "domain": "localization", "modes": ["correctness"] },
{ "id": "exposure_fusion", "domain": "photography", "modes": ["allocate"] },
{ "id": "panorama_pair", "domain": "photography", "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", "reuse"] },
Expand Down
1 change: 1 addition & 0 deletions bench/opencv_comparison/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"rgbd": ROOT / "bench" / "opencv_rgbd_comparison" / "run.py",
"video": ROOT / "bench" / "opencv_video_comparison" / "run.py",
"odometry": ROOT / "bench" / "opencv_odometry_comparison" / "run.py",
"photography": ROOT / "bench" / "opencv_photography_comparison" / "run.py",
}


Expand Down
4 changes: 4 additions & 0 deletions bench/opencv_photography_comparison/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# OpenCV photography comparison

Checks bounded panorama canvas geometry and source-only warp pixels against
OpenCV `warpPerspective`, plus deterministic gray-world channel balance.
61 changes: 61 additions & 0 deletions bench/opencv_photography_comparison/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""OpenCV correctness receipt for Epic 108 panorama composition."""

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


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path)
args = parser.parse_args()
height, width = 32, 64
x = np.arange(width, dtype=np.uint8)[None, :]
source = np.zeros((height, width, 3), dtype=np.uint8)
source[..., 0] = x + 80
source[..., 1] = 20
target = np.zeros_like(source)
target[..., 1] = 40
target[..., 2] = 160
homography = np.array([[1.0, 0.0, 24.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])
panorama, origin_x, origin_y = sr.stitch_panorama_pair(source, target, homography)
cv_warp = cv2.warpPerspective(source, homography, (width + 24, height))
source_only_error = int(np.max(np.abs(
panorama[:, width:, :].astype(np.int16) - cv_warp[:, width:, :].astype(np.int16)
)))

cast = np.zeros((2, 2, 3), dtype=np.uint8)
cast[...] = [40, 80, 120]
balanced = sr.gray_world_white_balance_image(cast)
channel_means = balanced.mean(axis=(0, 1))
balance_spread = float(channel_means.max() - channel_means.min())
expected_shape = [height, width + 24, 3]
status = "pass" if (
list(panorama.shape) == expected_shape and origin_x == 0 and origin_y == 0
and source_only_error == 0 and balance_spread <= 1.0
) else "fail"
emit_report(make_report(
suite="opencv-photography", kind="correctness", status=status,
environment_receipt=environment(
opencv_version=cv2.__version__, spatialrust_version=sr.__version__),
results={
"panorama_shape": list(panorama.shape),
"origin": [origin_x, origin_y],
"opencv_source_only_max_error": source_only_error,
"balanced_channel_mean_spread": balance_spread,
"thresholds": {"warp_max_error": 0, "channel_mean_spread": 1.0},
},
), args.output)


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions crates/spatialrust-platform/src/stability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ impl StabilityRegistry {
"spatialrust-vision::optical-flow",
"spatialrust-vision::video",
"spatialrust-vision::odometry",
"spatialrust-vision::photography",
"spatialrust-vision::ai-adapters",
"spatialrust-gpu::GpuImage",
];
Expand Down
10 changes: 9 additions & 1 deletion crates/spatialrust-py/spatialrust.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ __all__: list[str] = [
"apply_transform", "recenter", "scale", "normalize_unit_sphere", "merge",
"centroid", "bounding_box", "oriented_bounding_box", "voxelize", "range_image",
"rgbd_to_point_cloud", "depth_to_xyz", "calibrate_pinhole_camera",
"calibrate_fisheye_angles", "dense_flow_image", "filter2d_image", "gaussian_blur_image",
"calibrate_fisheye_angles", "dense_flow_image", "gray_world_white_balance_image",
"stitch_panorama_pair", "filter2d_image", "gaussian_blur_image",
"median_blur_image", "bilateral_filter_image", "sobel_image", "scharr_image",
"laplacian_image", "pyr_down_image", "pyr_up_image", "morphology_image",
"threshold_image", "otsu_threshold_image", "adaptive_threshold_image",
Expand Down Expand Up @@ -705,6 +706,13 @@ def estimate_rgbd_odometry(
depth_scale: float = ...,
threshold: float = ...,
) -> tuple[_F64Array, _F64Array, _BoolArray, int]: ...
def gray_world_white_balance_image(image: _U8Array) -> _U8Array: ...
def stitch_panorama_pair(
source: _U8Array,
target: _U8Array,
homography: _F64Array,
max_output_pixels: int = ...,
) -> tuple[_U8Array, int, int]: ...
def stereo_block_match(
left: _U8Array,
right: _U8Array,
Expand Down
67 changes: 60 additions & 7 deletions crates/spatialrust-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ use spatialrust::vision::{
estimate_homography_ransac as estimate_homography_ransac_op,
estimate_rgbd_odometry as estimate_rgbd_odometry_op, filter2d as filter2d_op,
find_contours as trace_contours, gaussian_blur as gaussian_blur_op,
histogram_u8 as histogram_u8_op, integral_image as integral_image_op,
laplacian as laplacian_op, letterbox as letterbox_op,
gray_world_white_balance as gray_world_white_balance_op, histogram_u8 as histogram_u8_op,
integral_image as integral_image_op, laplacian as laplacian_op, letterbox as letterbox_op,
match_descriptors as match_descriptors_op, median_blur as median_blur_op,
morphology_ex as morphology_ex_op, 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,
Expand All @@ -89,11 +89,12 @@ use spatialrust::vision::{
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, soft_nms as soft_nms_op,
solve_pnp as solve_pnp_op, stereo_block_match as stereo_block_match_op,
threshold as threshold_op, AbsolutePose, AdaptiveThresholdMethod, BinaryMask, BorderMode,
BoundingBox2, CameraMatrix3, CannyOptions, ConfidenceMap, Connectivity, CornerSelectionOptions,
DescriptorBuffer, FastOptions, HarrisOptions, Interpolation, Kernel2D, Keypoint2, MaskRle,
MatchOptions, MorphologyOperation, MorphologyShape, ObjectImageCorrespondence, OrbOptions,
OrbScoreType, PointCorrespondence2, PointMap, RgbdOdometryOptions, RleOrder,
stitch_panorama_pair as stitch_panorama_pair_op, threshold as threshold_op, AbsolutePose,
AdaptiveThresholdMethod, BinaryMask, BorderMode, BoundingBox2, CameraMatrix3, CannyOptions,
ConfidenceMap, Connectivity, CornerSelectionOptions, DescriptorBuffer, FastOptions,
HarrisOptions, Interpolation, Kernel2D, Keypoint2, MaskRle, MatchOptions, MorphologyOperation,
MorphologyShape, ObjectImageCorrespondence, OrbOptions, OrbScoreType, PanoramaOptions,
PerspectiveTransform, PointCorrespondence2, PointMap, RgbdOdometryOptions, RleOrder,
RobustEstimationOptions, ShiTomasiOptions, SoftNmsMethod, StereoBmOptions, StructuringElement,
ThresholdType,
};
Expand Down Expand Up @@ -789,6 +790,56 @@ fn dense_flow_image<'py>(
Ok(array.into_pyarray_bound(py))
}

/// Applies gray-world white balance to an RGB image.
#[pyfunction]
fn gray_world_white_balance_image<'py>(
py: Python<'py>,
image: PyReadonlyArray3<'_, u8>,
) -> PyResult<Bound<'py, PyArray3<u8>>> {
let image = rgb_image_from_numpy(image)?;
let output = gray_world_white_balance_op(image.view()).map_err(to_py_err)?;
let array = Array3::from_shape_vec((output.height(), output.width(), 3), output.into_vec())
.map_err(to_py_err)?;
Ok(array.into_pyarray_bound(py))
}

/// Stitches a source RGB image into target coordinates with a 3x3 homography.
#[pyfunction]
#[pyo3(signature = (source, target, homography, max_output_pixels=67108864))]
fn stitch_panorama_pair<'py>(
py: Python<'py>,
source: PyReadonlyArray3<'_, u8>,
target: PyReadonlyArray3<'_, u8>,
homography: PyReadonlyArray2<'_, f64>,
max_output_pixels: usize,
) -> PyResult<(Bound<'py, PyArray3<u8>>, i32, i32)> {
let source = rgb_image_from_numpy(source)?;
let target = rgb_image_from_numpy(target)?;
let matrix = homography.as_array();
if matrix.shape() != [3, 3] {
return Err(PyValueError::new_err("homography must have shape (3, 3)"));
}
let panorama = stitch_panorama_pair_op(
source.view(),
target.view(),
PerspectiveTransform {
matrix: [
[matrix[[0, 0]], matrix[[0, 1]], matrix[[0, 2]]],
[matrix[[1, 0]], matrix[[1, 1]], matrix[[1, 2]]],
[matrix[[2, 0]], matrix[[2, 1]], matrix[[2, 2]]],
],
},
PanoramaOptions { max_output_pixels },
)
.map_err(to_py_err)?;
let (origin_x, origin_y) = (panorama.origin_x(), panorama.origin_y());
let image = panorama.image();
let array =
Array3::from_shape_vec((image.height(), image.width(), 3), image.as_slice().to_vec())
.map_err(to_py_err)?;
Ok((array.into_pyarray_bound(py), origin_x, origin_y))
}

fn cloud_from_xyz(arr: PyReadonlyArray2<'_, f32>) -> PyResult<PointCloud> {
let view = arr.as_array();
let shape = view.shape();
Expand Down Expand Up @@ -3188,6 +3239,8 @@ fn spatialrust_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(estimate_homography_ransac, m)?)?;
m.add_function(wrap_pyfunction!(solve_pnp, m)?)?;
m.add_function(wrap_pyfunction!(estimate_rgbd_odometry, m)?)?;
m.add_function(wrap_pyfunction!(gray_world_white_balance_image, m)?)?;
m.add_function(wrap_pyfunction!(stitch_panorama_pair, m)?)?;
m.add_function(wrap_pyfunction!(stereo_block_match, m)?)?;
m.add_function(wrap_pyfunction!(match_binary_descriptors, m)?)?;
m.add_function(wrap_pyfunction!(match_float_descriptors, m)?)?;
Expand Down
8 changes: 7 additions & 1 deletion crates/spatialrust-vision/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ imgproc-canny = ["imgproc-filter"]
feature2d = ["imgproc-filter", "resize"]
geometry = ["dep:spatialrust-camera"]
odometry = ["geometry", "feature2d"]
photography = ["warp", "geometry"]
detection = []
dense = ["detection"]
spatial = ["dense", "dep:spatialrust-core", "dep:spatialrust-camera"]
ai-adapters = ["preprocess", "dense", "detection", "dep:spatialrust-tensor", "dep:bytemuck", "spatialrust-tensor/image"]
video = ["dense", "detection"]
video-adapters = ["video"]
full = ["preprocess", "warp", "imgproc-filter", "imgproc-morphology", "imgproc-analysis", "imgproc-canny", "feature2d", "geometry", "odometry", "detection", "dense", "spatial", "ai-adapters", "video-adapters"]
full = ["preprocess", "warp", "imgproc-filter", "imgproc-morphology", "imgproc-analysis", "imgproc-canny", "feature2d", "geometry", "odometry", "photography", "detection", "dense", "spatial", "ai-adapters", "video-adapters"]

[dependencies]
spatialrust-image.workspace = true
Expand Down Expand Up @@ -90,3 +91,8 @@ required-features = ["video"]
name = "odometry"
harness = false
required-features = ["odometry"]

[[bench]]
name = "photography"
harness = false
required-features = ["photography"]
21 changes: 21 additions & 0 deletions crates/spatialrust-vision/benches/photography.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use spatialrust_image::Image;
use spatialrust_vision::{fuse_exposures, ExposureFusionOptions};

fn benchmark_photography(c: &mut Criterion) {
let images = [48_u8, 128, 220].map(|value| Image::from_pixel(640, 480, [value; 3]).unwrap());
c.bench_function("exposure_fusion_vga_3", |b| {
b.iter(|| {
black_box(
fuse_exposures(
&[images[0].view(), images[1].view(), images[2].view()],
ExposureFusionOptions::default(),
)
.unwrap(),
)
})
});
}

criterion_group!(benches, benchmark_photography);
criterion_main!(benches);
4 changes: 4 additions & 0 deletions crates/spatialrust-vision/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ mod multiview;
mod optical_flow;
#[cfg(feature = "odometry")]
mod odometry;
#[cfg(feature = "photography")]
mod photography;
#[cfg(feature = "geometry")]
mod pnp;
#[cfg(feature = "ai-adapters")]
Expand Down Expand Up @@ -91,6 +93,8 @@ pub use multiview::*;
pub use optical_flow::*;
#[cfg(feature = "odometry")]
pub use odometry::*;
#[cfg(feature = "photography")]
pub use photography::*;
#[cfg(feature = "geometry")]
pub use pnp::*;
#[cfg(feature = "feature2d")]
Expand Down
Loading
Loading