Skip to main content

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}