Skip to main content

iris/stereo/
mod.rs

1use crate::error::{IrisError, Result};
2use crate::image::Image;
3use burn::tensor::{Tensor, TensorData, backend::Backend};
4
5/// Stereo block matcher using Sum of Absolute Differences (SAD).
6pub struct StereoBlockMatcher {
7    /// Size of the matching block (must be odd).
8    block_size: i32,
9    /// Maximum number of disparities to search.
10    num_disparities: i32,
11    /// Minimum disparity value.
12    min_disparity: i32,
13}
14
15impl StereoBlockMatcher {
16    /// Creates a new `StereoBlockMatcher`.
17    ///
18    /// * `block_size` - Side length of the square matching block (must be odd, >= 3).
19    /// * `num_disparities` - Number of disparities to search (must be > 0, divisible by 16).
20    pub fn new(block_size: i32, num_disparities: i32) -> Result<Self> {
21        if block_size < 3 || block_size % 2 == 0 {
22            return Err(IrisError::InvalidParameter(format!(
23                "block_size must be odd and >= 3, got {block_size}"
24            )));
25        }
26        if num_disparities <= 0 || num_disparities % 16 != 0 {
27            return Err(IrisError::InvalidParameter(format!(
28                "num_disparities must be > 0 and divisible by 16, got {num_disparities}"
29            )));
30        }
31        Ok(Self {
32            block_size,
33            num_disparities,
34            min_disparity: 0,
35        })
36    }
37
38    /// Sets the minimum disparity value (default 0).
39    pub fn with_min_disparity(mut self, min_disparity: i32) -> Self {
40        self.min_disparity = min_disparity;
41        self
42    }
43
44    /// Computes a disparity map from a stereo pair of grayscale images.
45    ///
46    /// Both images must be single-channel (grayscale) `Image`s of identical dimensions.
47    /// Returns a 2D tensor of shape `[H, W]` containing disparity values scaled by 16
48    /// (fixed-point Q4 format, matching OpenCV convention).
49    pub fn compute<B: Backend>(&self, left: &Image<B>, right: &Image<B>) -> Result<Tensor<B, 2>> {
50        if left.channels() != 1 || right.channels() != 1 {
51            return Err(IrisError::InvalidParameter(
52                "Stereo input images must be single-channel (grayscale)".into(),
53            ));
54        }
55        if left.shape() != right.shape() {
56            return Err(IrisError::DimensionMismatch {
57                expected: left.shape().to_vec(),
58                actual: right.shape().to_vec(),
59            });
60        }
61
62        let dims = left.tensor.dims();
63        let h = dims[1];
64        let w = dims[2];
65
66        let half_block = self.block_size / 2;
67        let max_disp = self.num_disparities;
68        let min_d = self.min_disparity;
69
70        let left_data: Vec<f32> = left.tensor.clone().into_data().iter::<f32>().collect();
71        let right_data: Vec<f32> = right.tensor.clone().into_data().iter::<f32>().collect();
72
73        let mut disparity = vec![0.0f32; h * w];
74
75        for y in 0..h {
76            for x in 0..w {
77                let mut best_disp = 0i32;
78                let mut best_cost = f32::MAX;
79
80                for d in min_d..(min_d + max_disp) {
81                    // SAD over the block
82                    let mut cost = 0.0f32;
83                    let mut valid = true;
84
85                    for by in -half_block..=half_block {
86                        for bx in -half_block..=half_block {
87                            let ly = y as i32 + by;
88                            let lx = x as i32 + bx;
89                            let rx = lx - d;
90
91                            if ly < 0 || ly >= h as i32 || lx < 0 || lx >= w as i32 {
92                                valid = false;
93                                break;
94                            }
95                            if rx < 0 || rx >= w as i32 {
96                                valid = false;
97                                break;
98                            }
99
100                            let l_val = left_data[ly as usize * w + lx as usize];
101                            let r_val = right_data[ly as usize * w + rx as usize];
102                            cost += (l_val - r_val).abs();
103                        }
104                        if !valid {
105                            break;
106                        }
107                    }
108
109                    if valid && cost < best_cost {
110                        best_cost = cost;
111                        best_disp = d;
112                    }
113                }
114
115                // Scale by 16 for fixed-point Q4 output (OpenCV convention)
116                disparity[y * w + x] = (best_disp as f32) * 16.0;
117            }
118        }
119
120        let device = left.tensor.device();
121        let data = TensorData::new(disparity, [h, w]);
122        let tensor = Tensor::<B, 2>::from_data(data, &device);
123        Ok(tensor)
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::test_helpers::{TestBackend, test_device};
131    use burn::tensor::TensorData;
132
133    #[test]
134    fn test_stereo_new_valid() {
135        let matcher = StereoBlockMatcher::new(7, 64).unwrap();
136        assert_eq!(matcher.block_size, 7);
137        assert_eq!(matcher.num_disparities, 64);
138        assert_eq!(matcher.min_disparity, 0);
139    }
140
141    #[test]
142    fn test_stereo_new_invalid_block_size() {
143        assert!(StereoBlockMatcher::new(4, 64).is_err());
144        assert!(StereoBlockMatcher::new(2, 64).is_err());
145    }
146
147    #[test]
148    fn test_stereo_new_invalid_disparities() {
149        assert!(StereoBlockMatcher::new(7, 0).is_err());
150        assert!(StereoBlockMatcher::new(7, 7).is_err());
151    }
152
153    #[test]
154    fn test_stereo_with_min_disparity() {
155        let matcher = StereoBlockMatcher::new(3, 32)
156            .unwrap()
157            .with_min_disparity(5);
158        assert_eq!(matcher.min_disparity, 5);
159    }
160
161    #[test]
162    fn test_stereo_compute_uniform() {
163        let device = test_device();
164        let w = 32;
165        let h = 16;
166        // Both images are identical uniform => disparity should be 0 everywhere
167        let flat = vec![0.5f32; h * w];
168        let left = Image::new(Tensor::<TestBackend, 3>::from_data(
169            TensorData::new(flat.clone(), [1, h, w]),
170            &device,
171        ));
172        let right = Image::new(Tensor::<TestBackend, 3>::from_data(
173            TensorData::new(flat, [1, h, w]),
174            &device,
175        ));
176
177        let matcher = StereoBlockMatcher::new(3, 16).unwrap();
178        let disp = matcher.compute(&left, &right).unwrap();
179
180        assert_eq!(disp.dims(), [h, w]);
181        let vals: Vec<f32> = disp.into_data().iter::<f32>().collect();
182        assert!(vals.iter().all(|&v| v.abs() < 1e-5));
183    }
184
185    #[test]
186    fn test_stereo_compute_shifted() {
187        let device = test_device();
188        let w = 48;
189        let h = 16;
190        let mut left_vals = vec![0.0f32; h * w];
191        let mut right_vals = vec![0.0f32; h * w];
192
193        // Place a vertical bar in left image at x=24
194        for y in 0..h {
195            left_vals[y * w + 24] = 1.0;
196        }
197        // Place same bar in right image at x=20 (shift of 4 pixels)
198        for y in 0..h {
199            right_vals[y * w + 20] = 1.0;
200        }
201
202        let left = Image::new(Tensor::<TestBackend, 3>::from_data(
203            TensorData::new(left_vals, [1, h, w]),
204            &device,
205        ));
206        let right = Image::new(Tensor::<TestBackend, 3>::from_data(
207            TensorData::new(right_vals, [1, h, w]),
208            &device,
209        ));
210
211        let matcher = StereoBlockMatcher::new(3, 32).unwrap();
212        let disp = matcher.compute(&left, &right).unwrap();
213        let vals: Vec<f32> = disp.into_data().iter::<f32>().collect();
214
215        // At the bar location, disparity should be 4*16 = 64.0
216        let center_disp = vals[(h / 2) * w + 24];
217        assert!(
218            (center_disp - 64.0).abs() < 1.0,
219            "Expected ~64.0, got {center_disp}"
220        );
221    }
222
223    #[test]
224    fn test_stereo_shape_mismatch() {
225        let device = test_device();
226        let left = Image::new(Tensor::<TestBackend, 3>::from_data(
227            TensorData::new(vec![0.5f32; 8 * 8], [1, 8, 8]),
228            &device,
229        ));
230        let right = Image::new(Tensor::<TestBackend, 3>::from_data(
231            TensorData::new(vec![0.5f32; 10 * 10], [1, 10, 10]),
232            &device,
233        ));
234
235        let matcher = StereoBlockMatcher::new(3, 16).unwrap();
236        assert!(matcher.compute(&left, &right).is_err());
237    }
238}