scirs2_vision/camera/intrinsics.rs
1//! Camera intrinsics, extrinsics and stereo camera system.
2//!
3//! This module provides the canonical `CameraIntrinsics` / `CameraExtrinsics` /
4//! `StereoCameraSystem` trio used across the 3-D vision pipeline. They share
5//! the same Brown-Conrady distortion model as [`super::PinholeCamera`] but are
6//! expressed with plain Rust arrays (`[f64; 3]`, `[[f64; 3]; 3]`) so that no
7//! ndarray dependency is needed at the call site.
8
9use crate::error::{Result, VisionError};
10
11// ─────────────────────────────────────────────────────────────────────────────
12// CameraIntrinsics
13// ─────────────────────────────────────────────────────────────────────────────
14
15/// Pinhole camera intrinsic parameters with Brown-Conrady distortion.
16///
17/// Projection chain (ideal then distorted):
18/// ```text
19/// xn = X / Z, yn = Y / Z (normalised coords)
20/// r² = xn² + yn²
21/// xd = xn*(1 + k1*r² + k2*r⁴ + k3*r⁶) + 2*p1*xn*yn + p2*(r²+2*xn²)
22/// yd = yn*(1 + k1*r² + k2*r⁴ + k3*r⁶) + p1*(r²+2*yn²) + 2*p2*xn*yn
23/// u = fx*xd + cx, v = fy*yd + cy
24/// ```
25#[derive(Debug, Clone, PartialEq)]
26pub struct CameraIntrinsics {
27 /// Focal length in pixels along the X axis.
28 pub fx: f64,
29 /// Focal length in pixels along the Y axis.
30 pub fy: f64,
31 /// Principal-point X coordinate (pixels).
32 pub cx: f64,
33 /// Principal-point Y coordinate (pixels).
34 pub cy: f64,
35 /// Radial distortion coefficient k1.
36 pub k1: f64,
37 /// Radial distortion coefficient k2.
38 pub k2: f64,
39 /// Radial distortion coefficient k3.
40 pub k3: f64,
41 /// Tangential distortion coefficient p1.
42 pub p1: f64,
43 /// Tangential distortion coefficient p2.
44 pub p2: f64,
45}
46
47impl CameraIntrinsics {
48 /// Create new intrinsics with all parameters.
49 pub fn new(
50 fx: f64,
51 fy: f64,
52 cx: f64,
53 cy: f64,
54 k1: f64,
55 k2: f64,
56 k3: f64,
57 p1: f64,
58 p2: f64,
59 ) -> Self {
60 Self {
61 fx,
62 fy,
63 cx,
64 cy,
65 k1,
66 k2,
67 k3,
68 p1,
69 p2,
70 }
71 }
72
73 /// Create distortion-free intrinsics.
74 pub fn ideal(fx: f64, fy: f64, cx: f64, cy: f64) -> Self {
75 Self::new(fx, fy, cx, cy, 0.0, 0.0, 0.0, 0.0, 0.0)
76 }
77
78 /// Return the 3×3 calibration matrix K.
79 ///
80 /// ```
81 /// # use scirs2_vision::camera::CameraIntrinsics;
82 /// let k = CameraIntrinsics::ideal(800.0, 600.0, 320.0, 240.0);
83 /// let mat = k.calibration_matrix();
84 /// assert!((mat[0][0] - 800.0).abs() < 1e-12);
85 /// assert!((mat[1][1] - 600.0).abs() < 1e-12);
86 /// assert!((mat[2][2] - 1.0).abs() < 1e-12);
87 /// ```
88 pub fn calibration_matrix(&self) -> [[f64; 3]; 3] {
89 [
90 [self.fx, 0.0, self.cx],
91 [0.0, self.fy, self.cy],
92 [0.0, 0.0, 1.0],
93 ]
94 }
95
96 /// Apply radial + tangential distortion to normalised image coordinates.
97 ///
98 /// ```
99 /// # use scirs2_vision::camera::CameraIntrinsics;
100 /// let cam = CameraIntrinsics::ideal(800.0, 800.0, 320.0, 240.0);
101 /// let d = cam.distort([0.0, 0.0]);
102 /// assert!((d[0]).abs() < 1e-12);
103 /// assert!((d[1]).abs() < 1e-12);
104 /// ```
105 pub fn distort(&self, normalized: [f64; 2]) -> [f64; 2] {
106 let xn = normalized[0];
107 let yn = normalized[1];
108 let r2 = xn * xn + yn * yn;
109 let r4 = r2 * r2;
110 let r6 = r4 * r2;
111 let radial = 1.0 + self.k1 * r2 + self.k2 * r4 + self.k3 * r6;
112 let xd = xn * radial + 2.0 * self.p1 * xn * yn + self.p2 * (r2 + 2.0 * xn * xn);
113 let yd = yn * radial + self.p1 * (r2 + 2.0 * yn * yn) + 2.0 * self.p2 * xn * yn;
114 [xd, yd]
115 }
116
117 /// Project a 3-D point `[X, Y, Z]` (in camera frame) to a pixel `[u, v]`
118 /// using the full distortion model.
119 ///
120 /// Returns `Err` when `Z ≤ 0`.
121 ///
122 /// ```
123 /// # use scirs2_vision::camera::CameraIntrinsics;
124 /// let cam = CameraIntrinsics::ideal(800.0, 800.0, 320.0, 240.0);
125 /// let px = cam.project([0.0, 0.0, 1.0]).unwrap();
126 /// assert!((px[0] - 320.0).abs() < 1e-9);
127 /// assert!((px[1] - 240.0).abs() < 1e-9);
128 /// ```
129 pub fn project(&self, point3d: [f64; 3]) -> Result<[f64; 2]> {
130 let z = point3d[2];
131 if z <= 0.0 {
132 return Err(VisionError::InvalidParameter(
133 "Z must be positive for projection".to_string(),
134 ));
135 }
136 let xn = point3d[0] / z;
137 let yn = point3d[1] / z;
138 let [xd, yd] = self.distort([xn, yn]);
139 Ok([self.fx * xd + self.cx, self.fy * yd + self.cy])
140 }
141
142 /// Back-project pixel `[u, v]` to a unit ray in the camera frame.
143 ///
144 /// The returned vector has unit L2 norm. Distortion is NOT undone here;
145 /// use [`Self::undistort`] first for accurate results.
146 ///
147 /// ```
148 /// # use scirs2_vision::camera::CameraIntrinsics;
149 /// let cam = CameraIntrinsics::ideal(800.0, 800.0, 320.0, 240.0);
150 /// let ray = cam.unproject([320.0, 240.0]);
151 /// assert!((ray[0]).abs() < 1e-12);
152 /// assert!((ray[1]).abs() < 1e-12);
153 /// assert!((ray[2] - 1.0).abs() < 1e-12);
154 /// ```
155 pub fn unproject(&self, pixel: [f64; 2]) -> [f64; 3] {
156 let xn = (pixel[0] - self.cx) / self.fx;
157 let yn = (pixel[1] - self.cy) / self.fy;
158 let len = (xn * xn + yn * yn + 1.0).sqrt();
159 [xn / len, yn / len, 1.0 / len]
160 }
161
162 /// Undistort a distorted pixel using Newton iterations.
163 ///
164 /// Inverts the Brown-Conrady model (max 20 iterations, stops when the
165 /// residual falls below 1e-10 pixels).
166 ///
167 /// ```
168 /// # use scirs2_vision::camera::CameraIntrinsics;
169 /// let cam = CameraIntrinsics::ideal(800.0, 800.0, 320.0, 240.0);
170 /// let u = cam.undistort([320.0, 240.0]);
171 /// assert!((u[0] - 320.0).abs() < 1e-9);
172 /// assert!((u[1] - 240.0).abs() < 1e-9);
173 /// ```
174 pub fn undistort(&self, pixel: [f64; 2]) -> [f64; 2] {
175 // Initial guess: normalised undistorted = normalised distorted
176 let mut xn = (pixel[0] - self.cx) / self.fx;
177 let mut yn = (pixel[1] - self.cy) / self.fy;
178
179 for _ in 0..20 {
180 let [xd, yd] = self.distort([xn, yn]);
181 // Residual in pixel space
182 let ex = pixel[0] - (self.fx * xd + self.cx);
183 let ey = pixel[1] - (self.fy * yd + self.cy);
184 if ex * ex + ey * ey < 1e-20 {
185 break;
186 }
187 // Newton step (Jacobian ≈ identity for small distortions)
188 xn += ex / self.fx;
189 yn += ey / self.fy;
190 }
191
192 [self.fx * xn + self.cx, self.fy * yn + self.cy]
193 }
194}
195
196// ─────────────────────────────────────────────────────────────────────────────
197// CameraExtrinsics
198// ─────────────────────────────────────────────────────────────────────────────
199
200/// Camera extrinsic parameters: a 3×3 rotation and a 3-element translation.
201///
202/// Together with [`CameraIntrinsics`] they define the complete camera model:
203/// `p_cam = R * p_world + t`.
204#[derive(Debug, Clone, PartialEq)]
205pub struct CameraExtrinsics {
206 /// 3×3 rotation matrix (world → camera).
207 pub rotation: [[f64; 3]; 3],
208 /// 3-element translation vector (world → camera).
209 pub translation: [f64; 3],
210}
211
212impl CameraExtrinsics {
213 /// Create new extrinsics.
214 pub fn new(rotation: [[f64; 3]; 3], translation: [f64; 3]) -> Self {
215 Self {
216 rotation,
217 translation,
218 }
219 }
220
221 /// Identity extrinsics (R = I₃, t = 0).
222 pub fn identity() -> Self {
223 Self {
224 rotation: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
225 translation: [0.0; 3],
226 }
227 }
228
229 /// Transform a world-frame point into the camera frame.
230 pub fn transform(&self, world_point: [f64; 3]) -> [f64; 3] {
231 let r = &self.rotation;
232 let t = &self.translation;
233 let x =
234 r[0][0] * world_point[0] + r[0][1] * world_point[1] + r[0][2] * world_point[2] + t[0];
235 let y =
236 r[1][0] * world_point[0] + r[1][1] * world_point[1] + r[1][2] * world_point[2] + t[1];
237 let z =
238 r[2][0] * world_point[0] + r[2][1] * world_point[1] + r[2][2] * world_point[2] + t[2];
239 [x, y, z]
240 }
241
242 /// 4×4 homogeneous transformation matrix `[R | t; 0 0 0 1]`.
243 pub fn as_matrix4(&self) -> [[f64; 4]; 4] {
244 let r = &self.rotation;
245 let t = &self.translation;
246 [
247 [r[0][0], r[0][1], r[0][2], t[0]],
248 [r[1][0], r[1][1], r[1][2], t[1]],
249 [r[2][0], r[2][1], r[2][2], t[2]],
250 [0.0, 0.0, 0.0, 1.0],
251 ]
252 }
253}
254
255// ─────────────────────────────────────────────────────────────────────────────
256// StereoCameraSystem
257// ─────────────────────────────────────────────────────────────────────────────
258
259/// A stereo camera rig consisting of left and right pinhole cameras.
260///
261/// The stereo geometry is described by a relative rotation `R` and translation
262/// `T` such that a point `P_l` in the left-camera frame maps to the right
263/// frame as `P_r = R * P_l + T`.
264///
265/// For a typical horizontal stereo setup `T ≈ [-baseline, 0, 0]`.
266#[derive(Debug, Clone)]
267pub struct StereoCameraSystem {
268 /// Left camera intrinsics.
269 pub left: CameraIntrinsics,
270 /// Right camera intrinsics.
271 pub right: CameraIntrinsics,
272 /// Baseline distance between optical centres (metres).
273 pub baseline: f64,
274 /// Rotation from left to right camera frame.
275 pub r: [[f64; 3]; 3],
276 /// Translation from left to right camera frame (metres).
277 pub t: [f64; 3],
278}
279
280impl StereoCameraSystem {
281 /// Create a new stereo system. The baseline is derived from `‖T‖`.
282 pub fn new(
283 left: CameraIntrinsics,
284 right: CameraIntrinsics,
285 r: [[f64; 3]; 3],
286 t: [f64; 3],
287 ) -> Self {
288 let baseline = (t[0] * t[0] + t[1] * t[1] + t[2] * t[2]).sqrt();
289 Self {
290 left,
291 right,
292 baseline,
293 r,
294 t,
295 }
296 }
297
298 /// Convert disparity to metric depth using `depth = baseline * fx / disparity`.
299 ///
300 /// Returns `None` when `disparity ≤ 0`.
301 ///
302 /// ```
303 /// # use scirs2_vision::camera::{CameraIntrinsics, StereoCameraSystem};
304 /// let cam = CameraIntrinsics::ideal(800.0, 800.0, 320.0, 240.0);
305 /// let stereo = StereoCameraSystem::new(
306 /// cam.clone(), cam,
307 /// [[1.0,0.0,0.0],[0.0,1.0,0.0],[0.0,0.0,1.0]],
308 /// [-0.1, 0.0, 0.0],
309 /// );
310 /// let depth = stereo.disparity_to_depth(80.0).unwrap();
311 /// assert!((depth - 1.0).abs() < 1e-9);
312 /// ```
313 pub fn disparity_to_depth(&self, disparity: f64) -> Option<f64> {
314 if disparity <= 0.0 {
315 return None;
316 }
317 Some(self.baseline * self.left.fx / disparity)
318 }
319
320 /// Triangulate a 3-D point from stereo pixel correspondences using the
321 /// **Direct Linear Transform** (mid-point method on the two rays).
322 ///
323 /// Both pixels are assumed to be in a **rectified** coordinate frame so
324 /// that epipolar lines are horizontal.
325 ///
326 /// Returns `None` when the rays are nearly parallel.
327 ///
328 /// ```
329 /// # use scirs2_vision::camera::{CameraIntrinsics, StereoCameraSystem};
330 /// let cam = CameraIntrinsics::ideal(800.0, 800.0, 320.0, 240.0);
331 /// let stereo = StereoCameraSystem::new(
332 /// cam.clone(), cam,
333 /// [[1.0,0.0,0.0],[0.0,1.0,0.0],[0.0,0.0,1.0]],
334 /// [-0.1, 0.0, 0.0],
335 /// );
336 /// // A point at (0, 0, 1 m): left pixel = (320, 240), right pixel = (240, 240)
337 /// let pt = stereo.triangulate([320.0, 240.0], [240.0, 240.0]).unwrap();
338 /// assert!((pt[2] - 1.0).abs() < 0.05);
339 /// ```
340 pub fn triangulate(&self, left_px: [f64; 2], right_px: [f64; 2]) -> Option<[f64; 3]> {
341 // Left ray direction in left-camera frame
342 let d1 = self.left.unproject(left_px);
343 // Right ray in left-camera frame: d2 = R^T * right_unproject
344 let d_r = self.right.unproject(right_px);
345 let rt = mat3_transpose(&self.r);
346 let d2 = mat3_vec3_mul(&rt, d_r);
347
348 // Origin of right camera in left-camera frame: O2 = -R^T * t
349 let neg_t = [-self.t[0], -self.t[1], -self.t[2]];
350 let o2 = mat3_vec3_mul(&rt, neg_t);
351
352 // Solve: o2 = s1*d1 - s2*d2 (mid-point method via least squares)
353 // s1*(d1·d1) - s2*(d1·d2) = o2·d1
354 // s1*(d1·d2) - s2*(d2·d2) = o2·d2
355 let a = dot3(d1, d1);
356 let b = dot3(d1, d2);
357 let c = dot3(d2, d2);
358 let det = a * c - b * b;
359 if det.abs() < 1e-12 {
360 return None;
361 }
362 let e = dot3(o2, d1);
363 let f = dot3(o2, d2);
364 let s1 = (e * c - f * b) / det;
365 let s2 = (e * b - f * a) / det;
366
367 // Mid-point between the two closest points on the rays
368 let p1 = [d1[0] * s1, d1[1] * s1, d1[2] * s1];
369 let p2_world = [o2[0] + d2[0] * s2, o2[1] + d2[1] * s2, o2[2] + d2[2] * s2];
370
371 Some([
372 (p1[0] + p2_world[0]) * 0.5,
373 (p1[1] + p2_world[1]) * 0.5,
374 (p1[2] + p2_world[2]) * 0.5,
375 ])
376 }
377
378 /// Compute a disparity map from a rectified stereo pair using block matching.
379 ///
380 /// Both images must have the same dimensions. Returns a disparity map of
381 /// identical dimensions; pixels where no match was found carry value `0.0`.
382 ///
383 /// # Arguments
384 /// * `left_img` – Row-major grayscale image `[row][col]`.
385 /// * `right_img` – Row-major grayscale image `[row][col]`.
386 /// * `max_disparity` – Maximum disparity to search (pixels).
387 /// * `block_size` – Half-window radius (full window = 2*r+1 × 2*r+1).
388 pub fn compute_disparity_map(
389 &self,
390 left_img: &[Vec<f64>],
391 right_img: &[Vec<f64>],
392 max_disparity: usize,
393 block_size: usize,
394 ) -> Vec<Vec<f64>> {
395 let rows = left_img.len();
396 if rows == 0 {
397 return Vec::new();
398 }
399 let cols = left_img[0].len();
400 let r = block_size;
401 let mut disp = vec![vec![0.0f64; cols]; rows];
402
403 #[allow(clippy::needless_range_loop)]
404 for row in r..rows.saturating_sub(r) {
405 for col in r..cols.saturating_sub(r) {
406 let mut best_disp = 0usize;
407 let mut best_sad = f64::INFINITY;
408
409 let max_d = max_disparity.min(col.saturating_sub(r) + 1);
410 for d in 0..max_d {
411 let right_col = col - d;
412 if right_col < r {
413 break;
414 }
415 let mut sad = 0.0f64;
416 'win: for dr in 0..=(2 * r) {
417 let lr = row + dr - r;
418 if lr >= rows {
419 break 'win;
420 }
421 for dc in 0..=(2 * r) {
422 let lc = col + dc - r;
423 let rc = right_col + dc - r;
424 if lc >= cols || rc >= cols {
425 continue;
426 }
427 sad += (left_img[lr][lc] - right_img[lr][rc]).abs();
428 }
429 }
430 if sad < best_sad {
431 best_sad = sad;
432 best_disp = d;
433 }
434 }
435 disp[row][col] = best_disp as f64;
436 }
437 }
438 disp
439 }
440}
441
442// ─────────────────────────────────────────────────────────────────────────────
443// Small matrix helpers (private)
444// ─────────────────────────────────────────────────────────────────────────────
445
446#[inline]
447fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
448 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
449}
450
451#[inline]
452fn mat3_transpose(m: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
453 [
454 [m[0][0], m[1][0], m[2][0]],
455 [m[0][1], m[1][1], m[2][1]],
456 [m[0][2], m[1][2], m[2][2]],
457 ]
458}
459
460#[inline]
461fn mat3_vec3_mul(m: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
462 [
463 m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
464 m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
465 m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
466 ]
467}
468
469// ─────────────────────────────────────────────────────────────────────────────
470// Tests
471// ─────────────────────────────────────────────────────────────────────────────
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn test_calibration_matrix() {
479 let cam = CameraIntrinsics::ideal(500.0, 600.0, 320.0, 240.0);
480 let k = cam.calibration_matrix();
481 assert!((k[0][0] - 500.0).abs() < 1e-12);
482 assert!((k[1][1] - 600.0).abs() < 1e-12);
483 assert!((k[0][2] - 320.0).abs() < 1e-12);
484 assert!((k[1][2] - 240.0).abs() < 1e-12);
485 assert!((k[2][2] - 1.0).abs() < 1e-12);
486 }
487
488 #[test]
489 fn test_project_undistorted() {
490 let cam = CameraIntrinsics::ideal(800.0, 800.0, 320.0, 240.0);
491 let px = cam
492 .project([1.0, 0.0, 2.0])
493 .expect("project should succeed for point in front of camera");
494 assert!((px[0] - 720.0).abs() < 1e-9, "u={}", px[0]);
495 assert!((px[1] - 240.0).abs() < 1e-9, "v={}", px[1]);
496 }
497
498 #[test]
499 fn test_project_negative_z_err() {
500 let cam = CameraIntrinsics::ideal(800.0, 800.0, 320.0, 240.0);
501 assert!(cam.project([0.0, 0.0, -1.0]).is_err());
502 assert!(cam.project([0.0, 0.0, 0.0]).is_err());
503 }
504
505 #[test]
506 fn test_unproject_principal_ray() {
507 let cam = CameraIntrinsics::ideal(800.0, 800.0, 320.0, 240.0);
508 let ray = cam.unproject([320.0, 240.0]);
509 assert!((ray[0]).abs() < 1e-12);
510 assert!((ray[1]).abs() < 1e-12);
511 // ray[2] should be 1/sqrt(1) = 1
512 assert!((ray[2] - 1.0).abs() < 1e-12);
513 }
514
515 #[test]
516 fn test_undistort_principal_point() {
517 let cam = CameraIntrinsics::new(800.0, 800.0, 320.0, 240.0, 0.1, 0.05, 0.0, 0.001, 0.001);
518 let u = cam.undistort([320.0, 240.0]);
519 assert!((u[0] - 320.0).abs() < 1e-9, "u={}", u[0]);
520 assert!((u[1] - 240.0).abs() < 1e-9, "v={}", u[1]);
521 }
522
523 #[test]
524 fn test_distort_zero() {
525 let cam = CameraIntrinsics::new(800.0, 800.0, 320.0, 240.0, 0.2, 0.05, 0.01, 0.001, 0.001);
526 let d = cam.distort([0.0, 0.0]);
527 assert!((d[0]).abs() < 1e-12);
528 assert!((d[1]).abs() < 1e-12);
529 }
530
531 #[test]
532 fn test_extrinsics_identity() {
533 let ext = CameraExtrinsics::identity();
534 let pt = [1.0, 2.0, 3.0];
535 let out = ext.transform(pt);
536 assert!((out[0] - pt[0]).abs() < 1e-12);
537 assert!((out[1] - pt[1]).abs() < 1e-12);
538 assert!((out[2] - pt[2]).abs() < 1e-12);
539 }
540
541 #[test]
542 fn test_disparity_to_depth() {
543 let cam = CameraIntrinsics::ideal(800.0, 800.0, 320.0, 240.0);
544 let stereo = StereoCameraSystem::new(
545 cam.clone(),
546 cam,
547 [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
548 [-0.1, 0.0, 0.0],
549 );
550 // depth = baseline * fx / disparity = 0.1 * 800 / 80 = 1.0
551 let depth = stereo
552 .disparity_to_depth(80.0)
553 .expect("disparity_to_depth should return Some for positive disparity");
554 assert!((depth - 1.0).abs() < 1e-9, "depth={}", depth);
555 }
556
557 #[test]
558 fn test_disparity_zero_returns_none() {
559 let cam = CameraIntrinsics::ideal(800.0, 800.0, 320.0, 240.0);
560 let stereo = StereoCameraSystem::new(
561 cam.clone(),
562 cam,
563 [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
564 [-0.1, 0.0, 0.0],
565 );
566 assert!(stereo.disparity_to_depth(0.0).is_none());
567 assert!(stereo.disparity_to_depth(-5.0).is_none());
568 }
569
570 #[test]
571 fn test_triangulate_known_point() {
572 // Horizontal stereo: left at origin, right shifted -0.1 m on X.
573 // A point at (0, 0, 1 m):
574 // left pixel = (320, 240) (principal point, f=800)
575 // right pixel = (320 - 80, 240) = (240, 240) (disparity = 80 px)
576 let cam = CameraIntrinsics::ideal(800.0, 800.0, 320.0, 240.0);
577 let stereo = StereoCameraSystem::new(
578 cam.clone(),
579 cam,
580 [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
581 [-0.1, 0.0, 0.0],
582 );
583 let pt = stereo
584 .triangulate([320.0, 240.0], [240.0, 240.0])
585 .expect("triangulate should succeed for valid stereo observations");
586 assert!((pt[2] - 1.0).abs() < 0.01, "Z={}", pt[2]);
587 }
588}