Skip to main content

scirs2_vision/stereo/
rectification.rs

1//! Stereo image rectification.
2//!
3//! Implements Bouguet's algorithm for computing rectification homographies and
4//! the associated pixel remapping tables used to transform raw stereo images
5//! into a canonical parallel configuration where epipolar lines are horizontal.
6
7use crate::stereo::calibration::StereoCalibration;
8
9// ─────────────────────────────────────────────────────────────────────────────
10// StereoRectifier
11// ─────────────────────────────────────────────────────────────────────────────
12
13/// Computes and applies stereo rectification maps.
14///
15/// After calling `compute_maps`, use `remap_left` / `remap_right` to
16/// obtain the rectified images.
17///
18/// ## Example
19/// ```
20/// use scirs2_vision::stereo::calibration::StereoCalibration;
21/// use scirs2_vision::stereo::rectification::StereoRectifier;
22///
23/// let cal = StereoCalibration::from_baseline(500.0, 500.0, 32.0, 24.0, 0.1);
24/// let mut rect = StereoRectifier::new(cal);
25/// rect.compute_maps(64, 48);
26/// assert!(rect.map_left.is_some());
27/// ```
28pub struct StereoRectifier {
29    /// Stereo calibration parameters.
30    pub calib: StereoCalibration,
31    /// Rectification map for the left image: `(map_x, map_y)` in pixels.
32    pub map_left: Option<(Vec<f32>, Vec<f32>)>,
33    /// Rectification map for the right image: `(map_x, map_y)` in pixels.
34    pub map_right: Option<(Vec<f32>, Vec<f32>)>,
35    /// The 3×3 left rectification homography (row-major).
36    pub r_left: [[f64; 3]; 3],
37    /// The 3×3 right rectification homography (row-major).
38    pub r_right: [[f64; 3]; 3],
39}
40
41impl StereoRectifier {
42    /// Create a rectifier from calibration data.
43    pub fn new(calib: StereoCalibration) -> Self {
44        Self {
45            calib,
46            map_left: None,
47            map_right: None,
48            r_left: identity_3x3(),
49            r_right: identity_3x3(),
50        }
51    }
52
53    /// Compute the rectification maps for an image of size `width × height`.
54    ///
55    /// Uses a simplified Bouguet decomposition:
56    /// - If the cameras are already parallel (no rotation), the maps reduce to
57    ///   identity (no warping needed).
58    /// - Otherwise a axis-angle half-rotation is applied to each camera so both
59    ///   look in a common direction with horizontal epipolar lines.
60    pub fn compute_maps(&mut self, width: usize, height: usize) {
61        let n = width * height;
62
63        // Decompose rotation into axis-angle, split half to each camera.
64        let (r_l, r_r) = self.compute_rectification_rotations();
65        self.r_left = r_l;
66        self.r_right = r_r;
67
68        // Build pixel maps: for each output pixel (u,v), find the source pixel
69        // in the original (unrectified) image.
70        let fl = self.calib.left.fx as f32;
71        let fr = self.calib.right.fx as f32;
72        let cxl = self.calib.left.cx as f32;
73        let cyl = self.calib.left.cy as f32;
74        let cxr = self.calib.right.cx as f32;
75        let cyr = self.calib.right.cy as f32;
76
77        let mut mx_l = vec![0.0_f32; n];
78        let mut my_l = vec![0.0_f32; n];
79        let mut mx_r = vec![0.0_f32; n];
80        let mut my_r = vec![0.0_f32; n];
81
82        // New (shared) focal length and principal point — use left as reference.
83        let f_new = fl;
84        let cx_new = cxl;
85        let cy_new = cyl;
86
87        for row in 0..height {
88            for col in 0..width {
89                let idx = row * width + col;
90
91                // Left map
92                {
93                    let xn = (col as f32 - cx_new) / f_new;
94                    let yn = (row as f32 - cy_new) / f_new;
95                    let p = mat3_apply_f32(&r_l, [xn, yn, 1.0]);
96                    let xp = p[0] / p[2];
97                    let yp = p[1] / p[2];
98                    mx_l[idx] = xp * fl + cxl;
99                    my_l[idx] = yp * fl + cyl;
100                }
101
102                // Right map
103                {
104                    let xn = (col as f32 - cx_new) / f_new;
105                    let yn = (row as f32 - cy_new) / f_new;
106                    let p = mat3_apply_f32(&r_r, [xn, yn, 1.0]);
107                    let xp = p[0] / p[2];
108                    let yp = p[1] / p[2];
109                    mx_r[idx] = xp * fr + cxr;
110                    my_r[idx] = yp * fr + cyr;
111                }
112            }
113        }
114
115        self.map_left = Some((mx_l, my_l));
116        self.map_right = Some((mx_r, my_r));
117    }
118
119    /// Apply the left rectification map to a grayscale image.
120    pub fn remap_left(&self, image: &[u8], width: usize, height: usize) -> Vec<u8> {
121        self.map_left
122            .as_ref()
123            .map(|m| remap_bilinear(image, width, height, m))
124            .unwrap_or_else(|| image.to_vec())
125    }
126
127    /// Apply the right rectification map to a grayscale image.
128    pub fn remap_right(&self, image: &[u8], width: usize, height: usize) -> Vec<u8> {
129        self.map_right
130            .as_ref()
131            .map(|m| remap_bilinear(image, width, height, m))
132            .unwrap_or_else(|| image.to_vec())
133    }
134
135    /// Generic remap using bilinear interpolation.
136    pub fn remap(
137        &self,
138        image: &[u8],
139        width: usize,
140        height: usize,
141        map: &(Vec<f32>, Vec<f32>),
142    ) -> Vec<u8> {
143        remap_bilinear(image, width, height, map)
144    }
145
146    // ── Rectification rotation computation ───────────────────────────────────
147
148    /// Compute (R_left, R_right) rectification rotations using Bouguet's
149    /// axis-angle splitting.
150    fn compute_rectification_rotations(&self) -> ([[f64; 3]; 3], [[f64; 3]; 3]) {
151        // R maps from right to left camera. We want to find:
152        //   R_l, R_r  s.t.  R_l · R_r^T = R  (the extrinsic rotation)
153        // and both cameras look in the same direction after rectification.
154
155        let r = self.calib.rotation;
156
157        // Compute the axis-angle of R.
158        let (axis, angle) = rotation_to_axis_angle(&r);
159
160        // Split the rotation: left gets +half, right gets -half around the axis.
161        let r_l = axis_angle_to_matrix(axis, angle * 0.5);
162        let r_r = axis_angle_to_matrix(axis, -angle * 0.5);
163
164        // Transpose (inverse) because the map warps backward.
165        (transpose_3x3(&r_l), transpose_3x3(&r_r))
166    }
167}
168
169// ─────────────────────────────────────────────────────────────────────────────
170// Bilinear remap
171// ─────────────────────────────────────────────────────────────────────────────
172
173/// Apply a pixel remap with bilinear interpolation.
174///
175/// For each output pixel `i`, the source coordinates are `(map_x[i], map_y[i])`.
176/// Out-of-bounds pixels are filled with 0.
177pub fn remap_bilinear(
178    image: &[u8],
179    width: usize,
180    height: usize,
181    map: &(Vec<f32>, Vec<f32>),
182) -> Vec<u8> {
183    let n = width * height;
184    let mut out = vec![0u8; n];
185    let (map_x, map_y) = map;
186
187    for i in 0..n {
188        let x = map_x[i];
189        let y = map_y[i];
190
191        if x < 0.0 || y < 0.0 || x >= (width - 1) as f32 || y >= (height - 1) as f32 {
192            // Clamp boundary pixels.
193            let xi = x.round().clamp(0.0, (width - 1) as f32) as usize;
194            let yi = y.round().clamp(0.0, (height - 1) as f32) as usize;
195            out[i] = image[yi * width + xi];
196            continue;
197        }
198
199        let xi = x as usize;
200        let yi = y as usize;
201        let fx = x - xi as f32;
202        let fy = y - yi as f32;
203
204        let v00 = image[yi * width + xi] as f32;
205        let v01 = image[yi * width + xi + 1] as f32;
206        let v10 = image[(yi + 1) * width + xi] as f32;
207        let v11 = image[(yi + 1) * width + xi + 1] as f32;
208
209        let val = (1.0 - fy) * ((1.0 - fx) * v00 + fx * v01) + fy * ((1.0 - fx) * v10 + fx * v11);
210        out[i] = val as u8;
211    }
212    out
213}
214
215// ─────────────────────────────────────────────────────────────────────────────
216// Rotation utilities
217// ─────────────────────────────────────────────────────────────────────────────
218
219fn identity_3x3() -> [[f64; 3]; 3] {
220    [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
221}
222
223fn transpose_3x3(m: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
224    [
225        [m[0][0], m[1][0], m[2][0]],
226        [m[0][1], m[1][1], m[2][1]],
227        [m[0][2], m[1][2], m[2][2]],
228    ]
229}
230
231/// Extract axis and angle (in radians) from a rotation matrix.
232fn rotation_to_axis_angle(r: &[[f64; 3]; 3]) -> ([f64; 3], f64) {
233    // trace = 1 + 2 cos θ
234    let trace = r[0][0] + r[1][1] + r[2][2];
235    let cos_theta = ((trace - 1.0) / 2.0).clamp(-1.0, 1.0);
236    let theta = cos_theta.acos();
237
238    if theta.abs() < 1e-10 {
239        // Identity rotation: arbitrary axis.
240        return ([0.0, 0.0, 1.0], 0.0);
241    }
242
243    let denom = 2.0 * theta.sin();
244    let axis = [
245        (r[2][1] - r[1][2]) / denom,
246        (r[0][2] - r[2][0]) / denom,
247        (r[1][0] - r[0][1]) / denom,
248    ];
249    (axis, theta)
250}
251
252/// Build a rotation matrix from axis (unit vector) and angle (Rodrigues).
253fn axis_angle_to_matrix(axis: [f64; 3], angle: f64) -> [[f64; 3]; 3] {
254    let (s, c) = angle.sin_cos();
255    let t = 1.0 - c;
256    let [ax, ay, az] = axis;
257
258    [
259        [t * ax * ax + c, t * ax * ay - s * az, t * ax * az + s * ay],
260        [t * ax * ay + s * az, t * ay * ay + c, t * ay * az - s * ax],
261        [t * ax * az - s * ay, t * ay * az + s * ax, t * az * az + c],
262    ]
263}
264
265/// Apply a 3×3 matrix to a 3-vector (homogeneous).
266fn mat3_apply_f32(m: &[[f64; 3]; 3], v: [f32; 3]) -> [f32; 3] {
267    let [x, y, z] = [v[0] as f64, v[1] as f64, v[2] as f64];
268    [
269        (m[0][0] * x + m[0][1] * y + m[0][2] * z) as f32,
270        (m[1][0] * x + m[1][1] * y + m[1][2] * z) as f32,
271        (m[2][0] * x + m[2][1] * y + m[2][2] * z) as f32,
272    ]
273}
274
275// ─────────────────────────────────────────────────────────────────────────────
276// Tests
277// ─────────────────────────────────────────────────────────────────────────────
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    fn make_parallel_cal() -> StereoCalibration {
284        StereoCalibration::from_baseline(500.0, 500.0, 32.0, 24.0, 0.1)
285    }
286
287    #[test]
288    fn test_compute_maps_produces_maps() {
289        let mut rect = StereoRectifier::new(make_parallel_cal());
290        rect.compute_maps(64, 48);
291        assert!(rect.map_left.is_some());
292        assert!(rect.map_right.is_some());
293
294        let (mx, my) = rect.map_left.as_ref().expect("map_left should exist");
295        assert_eq!(mx.len(), 64 * 48);
296        assert_eq!(my.len(), 64 * 48);
297    }
298
299    #[test]
300    fn test_remap_identity_like() {
301        // For a parallel stereo rig (R = I) the rectification maps should
302        // be approximately identity; the remapped image is close to the original.
303        let mut rect = StereoRectifier::new(make_parallel_cal());
304        rect.compute_maps(32, 24);
305
306        let img: Vec<u8> = (0..32 * 24).map(|i| ((i * 3) % 256) as u8).collect();
307
308        let out = rect.remap_left(&img, 32, 24);
309        assert_eq!(out.len(), img.len());
310    }
311
312    #[test]
313    fn test_remap_bilinear_boundary() {
314        let img = vec![100u8; 10 * 10];
315        let n = 10 * 10;
316        // Map all pixels to the top-left corner.
317        let map = (vec![0.0_f32; n], vec![0.0_f32; n]);
318        let out = remap_bilinear(&img, 10, 10, &map);
319        assert_eq!(out.len(), n);
320        assert!(out.iter().all(|&v| v == 100));
321    }
322
323    #[test]
324    fn test_rotation_utilities_roundtrip() {
325        // Build a small rotation, extract axis-angle, rebuild, check trace.
326        let angle_in = 0.3_f64;
327        let axis = [0.0, 0.0, 1.0];
328        let r = axis_angle_to_matrix(axis, angle_in);
329        let (_, angle_out) = rotation_to_axis_angle(&r);
330        assert!((angle_in - angle_out).abs() < 1e-8, "angle roundtrip");
331    }
332}