Skip to main content

mtb_align/
align.rs

1use crate::{
2    Bitmaps, Error, Gray, Percentile, Shift, Shrink, compute_bitmaps, disagreement, shrink2,
3};
4
5/// Ward's nine, centre first: he and OpenCV keep the first to beat the running
6/// best, so `(-1, -1)` wins every tie. Centre first ties to zero instead.
7const CANDIDATES: [(i32, i32); 9] = [
8    (0, 0),
9    (-1, -1),
10    (-1, 0),
11    (-1, 1),
12    (0, -1),
13    (0, 1),
14    (1, -1),
15    (1, 0),
16    (1, 1),
17];
18
19/// How the search is run.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub struct Options {
22    /// Ward's `shift_bits`, reaching `±(2^bits - 1)` pixels. Six is his figure for
23    /// the three megapixel frames of 2003; the same shake spans four times the
24    /// pixels at fifty, which wants eight. Each bit is another halving, and the
25    /// coarsest level guesses with only ±1 to correct it after, so small images
26    /// want fewer, not more.
27    pub bits: u32,
28    /// How far from the threshold a sample must sit before it is trusted.
29    pub tolerance: u8,
30    /// A frame more than half clipped puts the median past the top of the range
31    /// and empties the bitmap; Ward's 17th or 83rd moves the cut back among the
32    /// samples that survived.
33    pub percentile: Percentile,
34    pub shrink: Shrink,
35}
36
37impl Default for Options {
38    fn default() -> Self {
39        Self {
40            bits: 6,
41            tolerance: 4,
42            percentile: Percentile::MEDIAN,
43            shrink: Shrink::Average,
44        }
45    }
46}
47
48impl Options {
49    /// The conventions OpenCV's `AlignMTB` uses, for comparing against it.
50    pub fn opencv() -> Self {
51        Self {
52            shrink: Shrink::Subsample,
53            ..Self::default()
54        }
55    }
56}
57
58/// How far `target` has to move to sit on top of `reference`.
59pub fn shift(reference: &Gray, target: &Gray, options: &Options) -> Result<Shift, Error> {
60    let size = |gray: &Gray| (gray.width(), gray.height());
61    if size(reference) != size(target) {
62        return Err(Error::SizeMismatch {
63            reference: size(reference),
64            target: size(target),
65        });
66    }
67
68    let levels = shrinks(reference.width(), reference.height(), options.bits);
69    let reference_pyramid = pyramid(reference, levels, options.shrink);
70    let target_pyramid = pyramid(target, levels, options.shrink);
71
72    // Ward recurses; coarsest-first says the same and holds one level at a time.
73    Ok((0..=levels).rev().fold(Shift::ZERO, |coarser, level| {
74        let bitmaps = |pyramid: &[Gray]| {
75            compute_bitmaps(&pyramid[level], options.percentile, options.tolerance)
76        };
77
78        // Twice the resolution, so twice the pixels for the same displacement.
79        refine(
80            &bitmaps(&reference_pyramid),
81            &bitmaps(&target_pyramid),
82            coarser.doubled(),
83        )
84    }))
85}
86
87/// How far each exposure has to move to sit on `images[reference]`, measured
88/// between *adjacent* exposures and accumulated: one percentile fits two
89/// neighbours well and the ends of a bracket badly.
90///
91/// So `images` has to be in exposure order, which is not always the order the
92/// camera wrote the files in.
93pub fn align_stack(
94    images: &[Gray],
95    reference: usize,
96    options: &Options,
97) -> Result<Vec<Shift>, Error> {
98    if reference >= images.len() {
99        return Err(Error::NoSuchReference {
100            reference,
101            exposures: images.len(),
102        });
103    }
104
105    let mut shifts = vec![Shift::ZERO; images.len()];
106
107    // Outwards from the reference, each frame placed against the last one placed.
108    for index in (0..reference).rev() {
109        let step = shift(&images[index + 1], &images[index], options)?;
110        shifts[index] = shifts[index + 1].offset(step.x, step.y);
111    }
112
113    for index in reference + 1..images.len() {
114        let step = shift(&images[index - 1], &images[index], options)?;
115        shifts[index] = shifts[index - 1].offset(step.x, step.y);
116    }
117
118    Ok(shifts)
119}
120
121/// How many times the image is halved. Ward descends `shift_bits` times whatever
122/// the size; this is OpenCV's cap on the longest side, plus one on the shortest.
123fn shrinks(width: usize, height: usize, bits: u32) -> usize {
124    if width == 0 || height == 0 {
125        return 0;
126    }
127
128    let longest = width.max(height).ilog2().saturating_sub(1);
129    let shortest = width.min(height).ilog2();
130
131    bits.saturating_sub(1).min(longest).min(shortest) as usize
132}
133
134fn pyramid(gray: &Gray, levels: usize, shrink: Shrink) -> Vec<Gray> {
135    let mut pyramid = Vec::with_capacity(levels + 1);
136    pyramid.push(gray.clone());
137
138    for _ in 0..levels {
139        let coarser = shrink2(pyramid.last().expect("just pushed"), shrink);
140        pyramid.push(coarser);
141    }
142
143    pyramid
144}
145
146/// The best of the nine offsets within a pixel of `around`.
147fn refine(reference: &Bitmaps, target: &Bitmaps, around: Shift) -> Shift {
148    let mut best = around;
149    let mut least = u64::MAX;
150
151    for (x, y) in CANDIDATES {
152        let candidate = around.offset(x, y);
153        let error = disagreement(reference, target, candidate);
154
155        if error < least {
156            least = error;
157            best = candidate;
158        }
159    }
160
161    best
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    // Recovering real offsets lives in `tests/recovery.rs`.
169    #[test]
170    fn the_defaults_are_wards_recommendations() {
171        let options = Options::default();
172
173        assert_eq!(options.bits, 6);
174        assert_eq!(options.tolerance, 4);
175        assert_eq!(options.percentile, Percentile::MEDIAN);
176        assert_eq!(options.shrink, Shrink::Average);
177        assert_eq!(Options::opencv().shrink, Shrink::Subsample);
178    }
179
180    #[test]
181    fn the_level_count_follows_the_longest_side_until_the_bits_run_out() {
182        assert_eq!(shrinks(8256, 6192, 6), 5);
183        assert_eq!(shrinks(4, 4, 6), 1);
184        assert_eq!(shrinks(3, 3, 6), 0);
185        assert_eq!(shrinks(2, 2, 6), 0);
186        assert_eq!(shrinks(1, 1, 6), 0);
187        assert_eq!(shrinks(1024, 1024, 3), 2);
188    }
189
190    #[test]
191    fn an_extreme_aspect_ratio_stops_before_a_level_vanishes() {
192        assert_eq!(shrinks(1000, 3, 6), 1);
193        assert_eq!(shrinks(1000, 1, 6), 0);
194    }
195
196    #[test]
197    fn a_pyramid_keeps_the_source_and_one_plane_per_halving() {
198        let levels = pyramid(
199            &Gray::from_vec(vec![0; 64 * 64], 64, 64),
200            3,
201            Shrink::Average,
202        );
203
204        assert_eq!(levels.len(), 4);
205        assert_eq!((levels[0].width(), levels[0].height()), (64, 64));
206        assert_eq!((levels[3].width(), levels[3].height()), (8, 8));
207    }
208
209    /// Every pixel is excluded, so all nine candidates tie at zero.
210    #[test]
211    fn an_exposure_with_no_usable_signal_reports_no_shift() {
212        let flat = Gray::from_vec(vec![128; 160 * 120], 160, 120);
213
214        assert_eq!(shift(&flat, &flat, &Options::default()), Ok(Shift::ZERO));
215    }
216
217    #[test]
218    fn a_single_pixel_image_reports_no_shift() {
219        let dot = Gray::from_vec(vec![200], 1, 1);
220
221        assert_eq!(shift(&dot, &dot, &Options::default()), Ok(Shift::ZERO));
222    }
223
224    #[test]
225    fn aligning_mismatched_sizes_is_an_error() {
226        let small = Gray::from_vec(vec![0; 16], 4, 4);
227        let large = Gray::from_vec(vec![0; 25], 5, 5);
228
229        assert_eq!(
230            shift(&small, &large, &Options::default()),
231            Err(Error::SizeMismatch {
232                reference: (4, 4),
233                target: (5, 5)
234            })
235        );
236    }
237
238    #[test]
239    fn a_reference_outside_the_stack_is_an_error() {
240        let one = [Gray::from_vec(vec![0; 4], 2, 2)];
241
242        assert_eq!(
243            align_stack(&one, 3, &Options::default()),
244            Err(Error::NoSuchReference {
245                reference: 3,
246                exposures: 1
247            })
248        );
249    }
250}