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

### Added

- **Camera calibration contracts (Epic 105)**: robust pinhole intrinsics,
Kannala–Brandt4 fisheye fitting, supplied-rotation stereo and hand-eye
translation solves, and fixed-camera sparse point bundle adjustment. All
solvers return common RMS/max/iteration receipts, validate rotations and
indices, and use deterministic small dense math without a native optimizer.

- **Texture-backed GPU image chains (Epic 104)**: `GpuImage` now uses pooled
`rgba8uint` textures instead of component-expanded storage buffers. Explicit
upload/readback receipts compose through copy, RGB-to-gray, box blur, nearest
Expand Down
11 changes: 11 additions & 0 deletions bench/opencv_calibration_comparison/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# OpenCV calibration comparison

Build/install the Python extension and run:

```bash
python bench/opencv_calibration_comparison/run.py \
--output target/opencv-comparison/calibration.json
```

The suite uses OpenCV projection/fisheye conventions to synthesize observations,
then gates SpatialRust pinhole and Kannala–Brandt4 parameter recovery.
88 changes: 88 additions & 0 deletions bench/opencv_calibration_comparison/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""OpenCV correctness comparison for Epic 105 camera calibration contracts."""

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 parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path)
return parser.parse_args()


def main() -> None:
args = parse_args()
camera_points = np.array(
[
[x * 0.07, y * 0.06, 1.5 + 0.025 * abs(x + y)]
for y in range(-4, 5)
for x in range(-5, 6)
],
dtype=np.float64,
)
expected_intrinsics = np.array([720.0, 715.0, 640.0, 360.0])
camera_matrix = np.array(
[[720.0, 0.0, 640.0], [0.0, 715.0, 360.0], [0.0, 0.0, 1.0]],
dtype=np.float64,
)
pixels, _ = cv2.projectPoints(
camera_points, np.zeros(3), np.zeros(3), camera_matrix, np.zeros(5)
)
pinhole = sr.calibrate_pinhole_camera(
camera_points, pixels[:, 0, :], 1280, 720
)
intrinsics_error = float(
np.max(np.abs(np.asarray(pinhole[:4]) - expected_intrinsics))
)

expected_fisheye = np.array([0.025, -0.003, 0.0004, -0.00002])
theta = np.linspace(0.06, 1.2, 20, dtype=np.float64)
rays = np.column_stack((np.tan(theta), np.zeros_like(theta), np.ones_like(theta)))
fisheye_pixels, _ = cv2.fisheye.projectPoints(
rays[:, None, :],
np.zeros(3),
np.zeros(3),
np.eye(3, dtype=np.float64),
expected_fisheye,
)
distorted_radius = fisheye_pixels[:, 0, 0]
fisheye = sr.calibrate_fisheye_angles(theta, distorted_radius)
fisheye_error = float(
np.max(np.abs(np.asarray(fisheye[:4]) - expected_fisheye))
)
status = "pass" if intrinsics_error <= 1e-8 and fisheye_error <= 1e-8 else "fail"
report = make_report(
suite="opencv-calibration",
kind="correctness",
status=status,
environment_receipt=environment(
opencv_version=cv2.__version__, spatialrust_version=sr.__version__
),
results={
"pinhole_max_parameter_error": intrinsics_error,
"pinhole_rms_pixels": pinhole[4],
"fisheye_max_coefficient_error": fisheye_error,
"fisheye_rms_normalized_radius": fisheye[4],
"thresholds": {
"pinhole_max_parameter_error": 1e-8,
"fisheye_max_coefficient_error": 1e-8,
},
},
)
emit_report(report, args.output)
if status != "pass":
raise SystemExit(1)


if __name__ == "__main__":
main()
4 changes: 3 additions & 1 deletion bench/opencv_comparison/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
{ "id": "canny", "domain": "imgproc", "modes": ["allocate"] },
{ "id": "morphology_open", "domain": "imgproc", "modes": ["allocate"] },
{ "id": "orb", "domain": "feature2d", "modes": ["allocate"] },
{ "id": "stereo_bm", "domain": "calib3d", "modes": ["allocate"] },
{ "id": "stereo_bm", "domain": "calib3d", "modes": ["allocate"] },
{ "id": "pinhole_calibration", "domain": "calib3d", "modes": ["allocate"] },
{ "id": "fisheye_calibration", "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", "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 @@ -12,6 +12,7 @@

ROOT = Path(__file__).resolve().parents[2]
SUITES = {
"calibration": ROOT / "bench" / "opencv_calibration_comparison" / "run.py",
"vision": ROOT / "bench" / "opencv_vision_comparison" / "run.py",
"vision-performance": ROOT
/ "bench"
Expand Down
4 changes: 4 additions & 0 deletions crates/spatialrust-camera/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,7 @@ criterion.workspace = true
[[bench]]
name = "rgbd"
harness = false

[[bench]]
name = "calibration"
harness = false
80 changes: 80 additions & 0 deletions crates/spatialrust-camera/benches/calibration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use spatialrust_camera::{
bundle_adjust_points, calibrate_pinhole, BundleObservation, BundleProblem, BundleView,
CalibrationOptions, CameraIntrinsics, PinholeCamera, PinholeObservation, RigidTransform3,
};
use spatialrust_math::{Mat3, Vec3};

fn benchmark_calibration(c: &mut Criterion) {
let camera = PinholeCamera::new(
CameraIntrinsics::try_new(800.0, 805.0, 640.0, 360.0, 1280, 720).unwrap(),
);
for &count in &[100_usize, 1_000] {
let observations = (0..count)
.map(|index| {
let x = index % 31;
let y = (index / 31) % 23;
let point = Vec3::new(
x as f64 * 0.02 - 0.3,
y as f64 * 0.02 - 0.2,
2.0 + index as f64 * 1e-4,
);
PinholeObservation { camera_point: point, pixel: camera.project(point).unwrap() }
})
.collect::<Vec<_>>();
let mut group = c.benchmark_group("calibrate_pinhole");
group.throughput(Throughput::Elements(count as u64));
group.bench_with_input(BenchmarkId::from_parameter(count), &observations, |b, input| {
b.iter(|| {
calibrate_pinhole(black_box(input), 1280, 720, CalibrationOptions::default())
.unwrap()
});
});
group.finish();
}

let views = [0.0, -0.3, 0.3]
.into_iter()
.map(|x| BundleView {
camera,
camera_from_world: RigidTransform3 {
rotation: Mat3::<f64>::identity(),
translation: Vec3::new(x, 0.0, 0.0),
},
})
.collect::<Vec<_>>();
let truth = (0..100)
.map(|index| Vec3::new(index as f64 * 0.005 - 0.25, (index % 13) as f64 * 0.01 - 0.06, 2.5))
.collect::<Vec<_>>();
let observations = truth
.iter()
.enumerate()
.flat_map(|(point_index, &point)| {
views.iter().enumerate().map(move |(view_index, view)| {
let camera_point = view.camera_from_world.transform_point(point);
BundleObservation {
view_index,
point_index,
pixel: view.camera.project(camera_point).unwrap(),
}
})
})
.collect::<Vec<_>>();
c.bench_function("bundle_adjust_fixed_cameras/100_points_3_views", |b| {
b.iter_batched(
|| BundleProblem {
views: views.clone(),
points: truth.iter().map(|point| *point + Vec3::new(0.02, -0.01, 0.05)).collect(),
observations: observations.clone(),
},
|mut problem| {
bundle_adjust_points(black_box(&mut problem), CalibrationOptions::default())
.unwrap()
},
criterion::BatchSize::SmallInput,
);
});
}

criterion_group!(benches, benchmark_calibration);
criterion_main!(benches);
Loading
Loading