Skip to main content

mtb_align/
geometry.rs

1use crate::Gray;
2
3/// How far the target image has to move to sit on top of the reference.
4#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
5pub struct Shift {
6    pub x: i32,
7    pub y: i32,
8}
9
10impl Shift {
11    pub const ZERO: Self = Self { x: 0, y: 0 };
12
13    pub fn new(x: i32, y: i32) -> Self {
14        Self { x, y }
15    }
16
17    /// Carries a shift to the next finer level, where it spans twice the pixels.
18    pub fn doubled(self) -> Self {
19        Self::new(self.x * 2, self.y * 2)
20    }
21
22    pub fn offset(self, x: i32, y: i32) -> Self {
23        Self::new(self.x + x, self.y + y)
24    }
25}
26
27/// A region of an image, in pixels from the top left.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub struct Rect {
30    pub x: usize,
31    pub y: usize,
32    pub width: usize,
33    pub height: usize,
34}
35
36impl Rect {
37    pub fn is_empty(self) -> bool {
38        self.width == 0 || self.height == 0
39    }
40}
41
42impl Gray {
43    /// Moves the image by `shift`, leaving zeros behind it. OpenCV's `shiftMat`,
44    /// except that a shift larger than the image is allowed and empties it.
45    pub fn shifted(&self, shift: Shift) -> Gray {
46        let (width, height) = (self.width(), self.height());
47        let mut moved = vec![0; width * height];
48
49        for y in 0..height {
50            let Some(source_y) = source(y, shift.y, height) else {
51                continue;
52            };
53
54            for x in 0..width {
55                if let Some(source_x) = source(x, shift.x, width) {
56                    moved[y * width + x] = self.sample(source_x, source_y);
57                }
58            }
59        }
60
61        Gray::from_vec(moved, width, height)
62    }
63}
64
65/// The region every exposure still covers once moved. What OpenCV's `cut` trims
66/// to: outside it some frame contributes only padding.
67pub fn common_crop(shifts: &[Shift], width: usize, height: usize) -> Rect {
68    // The frame pushed furthest decides each edge, and one that did not move
69    // still holds its own, so both folds start at zero.
70    let span = |extent: usize, of: fn(Shift) -> i32| {
71        let furthest = |pick: fn(i32, i32) -> i32| {
72            shifts
73                .iter()
74                .fold(0, |carried, &shift| pick(carried, of(shift)))
75        };
76
77        let start = furthest(i32::max).max(0) as usize;
78        let lost_from_the_far_edge = furthest(i32::min).unsigned_abs() as usize;
79
80        (
81            start,
82            extent
83                .saturating_sub(lost_from_the_far_edge)
84                .saturating_sub(start),
85        )
86    };
87
88    let (x, width) = span(width, |shift| shift.x);
89    let (y, height) = span(height, |shift| shift.y);
90
91    Rect {
92        x,
93        y,
94        width,
95        height,
96    }
97}
98
99/// Where a destination pixel reads from, or `None` if that is off the image.
100fn source(destination: usize, shift: i32, extent: usize) -> Option<usize> {
101    let source = destination as i64 - shift as i64;
102
103    (0..extent as i64)
104        .contains(&source)
105        .then_some(source as usize)
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    fn counting(width: usize, height: usize) -> Gray {
113        Gray::from_vec((1..=(width * height) as u8).collect(), width, height)
114    }
115
116    #[test]
117    fn doubling_carries_a_shift_to_the_next_level_down() {
118        assert_eq!(Shift::new(-3, 5).doubled(), Shift::new(-6, 10));
119        assert_eq!(Shift::ZERO.doubled(), Shift::ZERO);
120    }
121
122    #[test]
123    fn offsetting_moves_by_the_candidate_step() {
124        assert_eq!(Shift::new(4, -2).offset(-1, 1), Shift::new(3, -1));
125    }
126
127    #[test]
128    fn shifting_by_nothing_leaves_the_image_alone() {
129        let image = counting(3, 2);
130
131        assert_eq!(image.shifted(Shift::ZERO).as_slice(), image.as_slice());
132    }
133
134    #[test]
135    fn shifting_pads_the_edge_it_moved_away_from() {
136        let shifted = counting(3, 2).shifted(Shift::new(1, 1));
137
138        assert_eq!(shifted.as_slice(), &[0, 0, 0, 0, 1, 2]);
139    }
140
141    #[test]
142    fn shifting_the_other_way_pads_the_far_edge() {
143        let shifted = counting(3, 2).shifted(Shift::new(-1, 0));
144
145        assert_eq!(shifted.as_slice(), &[2, 3, 0, 5, 6, 0]);
146    }
147
148    /// OpenCV rejects this, so there is no convention to match.
149    #[test]
150    fn shifting_further_than_the_image_leaves_nothing_behind() {
151        let shifted = counting(3, 2).shifted(Shift::new(9, -9));
152
153        assert_eq!(shifted.as_slice(), &[0; 6]);
154    }
155
156    #[test]
157    fn a_stack_that_never_moved_keeps_the_whole_frame() {
158        let crop = common_crop(&[Shift::ZERO, Shift::ZERO], 100, 80);
159
160        assert_eq!(
161            crop,
162            Rect {
163                x: 0,
164                y: 0,
165                width: 100,
166                height: 80
167            }
168        );
169        assert!(!crop.is_empty());
170    }
171
172    #[test]
173    fn a_positive_shift_trims_the_leading_edge() {
174        assert_eq!(
175            common_crop(&[Shift::ZERO, Shift::new(3, 2)], 100, 80),
176            Rect {
177                x: 3,
178                y: 2,
179                width: 97,
180                height: 78
181            }
182        );
183    }
184
185    #[test]
186    fn a_negative_shift_trims_the_trailing_edge() {
187        assert_eq!(
188            common_crop(&[Shift::ZERO, Shift::new(-4, -1)], 100, 80),
189            Rect {
190                x: 0,
191                y: 0,
192                width: 96,
193                height: 79
194            }
195        );
196    }
197
198    #[test]
199    fn shifts_in_both_directions_trim_both_edges() {
200        assert_eq!(
201            common_crop(
202                &[Shift::new(5, 0), Shift::new(-4, -1), Shift::new(2, 3)],
203                100,
204                80
205            ),
206            Rect {
207                x: 5,
208                y: 3,
209                width: 91,
210                height: 76
211            }
212        );
213    }
214
215    #[test]
216    fn a_stack_that_moved_further_than_it_is_wide_has_nothing_in_common() {
217        let crop = common_crop(&[Shift::new(-60, 0), Shift::new(60, 0)], 100, 80);
218
219        assert!(crop.is_empty(), "{crop:?}");
220    }
221}