Skip to main content

oximedia_align/
confidence_map.rs

1#![allow(dead_code)]
2//! Alignment confidence and quality mapping.
3//!
4//! This module provides tools for computing, storing, and querying per-pixel or
5//! per-region confidence values that quantify how reliable an alignment result is.
6//! Confidence maps are useful for:
7//!
8//! - **Blending** -- weight contributions from different views during stitching
9//! - **Quality filtering** -- reject poorly-aligned regions
10//! - **Diagnostics** -- visualise alignment quality as a heat map
11//!
12//! # Overview
13//!
14//! A [`ConfidenceMap`] is a 2-D grid of `f64` values in `[0.0, 1.0]` where
15//! `1.0` means perfect confidence and `0.0` means no confidence.
16//!
17//! The module also provides aggregation helpers ([`ConfidenceStats`]) and a
18//! [`ConfidenceThresholder`] for producing binary accept/reject masks.
19
20/// A 2-D confidence map stored as a flat row-major vector.
21#[derive(Debug, Clone, PartialEq)]
22pub struct ConfidenceMap {
23    /// Width (number of columns)
24    pub width: usize,
25    /// Height (number of rows)
26    pub height: usize,
27    /// Row-major confidence values clamped to `[0, 1]`
28    pub data: Vec<f64>,
29}
30
31impl ConfidenceMap {
32    /// Create a new confidence map initialised to zero.
33    pub fn new(width: usize, height: usize) -> Self {
34        Self {
35            width,
36            height,
37            data: vec![0.0; width * height],
38        }
39    }
40
41    /// Create a confidence map filled with a constant value.
42    pub fn filled(width: usize, height: usize, value: f64) -> Self {
43        let v = value.clamp(0.0, 1.0);
44        Self {
45            width,
46            height,
47            data: vec![v; width * height],
48        }
49    }
50
51    /// Total number of pixels.
52    pub fn len(&self) -> usize {
53        self.data.len()
54    }
55
56    /// Returns `true` when the map has zero pixels.
57    pub fn is_empty(&self) -> bool {
58        self.data.is_empty()
59    }
60
61    /// Get the confidence value at `(x, y)`. Returns `None` if out of bounds.
62    pub fn get(&self, x: usize, y: usize) -> Option<f64> {
63        if x < self.width && y < self.height {
64            Some(self.data[y * self.width + x])
65        } else {
66            None
67        }
68    }
69
70    /// Set the confidence value at `(x, y)`. The value is clamped to `[0, 1]`.
71    /// Returns `false` if out of bounds.
72    pub fn set(&mut self, x: usize, y: usize, value: f64) -> bool {
73        if x < self.width && y < self.height {
74            self.data[y * self.width + x] = value.clamp(0.0, 1.0);
75            true
76        } else {
77            false
78        }
79    }
80
81    /// Apply a Gaussian blur to smooth the confidence map.
82    /// `radius` is the kernel half-size (the full kernel is `2*radius+1`).
83    #[allow(clippy::cast_precision_loss)]
84    pub fn gaussian_blur(&self, radius: usize) -> Self {
85        if radius == 0 || self.is_empty() {
86            return self.clone();
87        }
88        let kernel_size = 2 * radius + 1;
89        let sigma = radius as f64 / 2.0;
90        let mut kernel = vec![0.0f64; kernel_size];
91        let mut sum = 0.0;
92        for i in 0..kernel_size {
93            let x = i as f64 - radius as f64;
94            let val = (-x * x / (2.0 * sigma * sigma)).exp();
95            kernel[i] = val;
96            sum += val;
97        }
98        for v in &mut kernel {
99            *v /= sum;
100        }
101
102        // Horizontal pass
103        let mut temp = vec![0.0f64; self.data.len()];
104        for y in 0..self.height {
105            for x in 0..self.width {
106                let mut acc = 0.0;
107                for k in 0..kernel_size {
108                    let sx = (x as isize + k as isize - radius as isize)
109                        .max(0)
110                        .min(self.width as isize - 1) as usize;
111                    acc += self.data[y * self.width + sx] * kernel[k];
112                }
113                temp[y * self.width + x] = acc;
114            }
115        }
116
117        // Vertical pass
118        let mut result = vec![0.0f64; self.data.len()];
119        for y in 0..self.height {
120            for x in 0..self.width {
121                let mut acc = 0.0;
122                for k in 0..kernel_size {
123                    let sy = (y as isize + k as isize - radius as isize)
124                        .max(0)
125                        .min(self.height as isize - 1) as usize;
126                    acc += temp[sy * self.width + x] * kernel[k];
127                }
128                result[y * self.width + x] = acc.clamp(0.0, 1.0);
129            }
130        }
131
132        Self {
133            width: self.width,
134            height: self.height,
135            data: result,
136        }
137    }
138
139    /// Compute aggregate statistics for this map.
140    #[allow(clippy::cast_precision_loss)]
141    pub fn statistics(&self) -> ConfidenceStats {
142        if self.data.is_empty() {
143            return ConfidenceStats {
144                min: 0.0,
145                max: 0.0,
146                mean: 0.0,
147                std_dev: 0.0,
148                median: 0.0,
149            };
150        }
151        let n = self.data.len() as f64;
152        let min = self.data.iter().cloned().fold(f64::INFINITY, f64::min);
153        let max = self.data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
154        let mean = self.data.iter().sum::<f64>() / n;
155        let variance = self.data.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
156        let std_dev = variance.sqrt();
157        let mut sorted = self.data.clone();
158        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
159        let median = if sorted.len() % 2 == 0 {
160            let mid = sorted.len() / 2;
161            (sorted[mid - 1] + sorted[mid]) / 2.0
162        } else {
163            sorted[sorted.len() / 2]
164        };
165        ConfidenceStats {
166            min,
167            max,
168            mean,
169            std_dev,
170            median,
171        }
172    }
173}
174
175/// Aggregate statistics for a confidence map.
176#[derive(Debug, Clone, PartialEq)]
177pub struct ConfidenceStats {
178    /// Minimum confidence value
179    pub min: f64,
180    /// Maximum confidence value
181    pub max: f64,
182    /// Mean confidence value
183    pub mean: f64,
184    /// Standard deviation of confidence values
185    pub std_dev: f64,
186    /// Median confidence value
187    pub median: f64,
188}
189
190/// Threshold a confidence map to produce a binary mask.
191#[derive(Debug, Clone)]
192pub struct ConfidenceThresholder {
193    /// Threshold value in `[0, 1]`
194    pub threshold: f64,
195}
196
197impl ConfidenceThresholder {
198    /// Create a thresholder with the given cutoff.
199    pub fn new(threshold: f64) -> Self {
200        Self {
201            threshold: threshold.clamp(0.0, 1.0),
202        }
203    }
204
205    /// Apply the threshold and return a binary mask (`true` = accepted).
206    pub fn apply(&self, map: &ConfidenceMap) -> Vec<bool> {
207        map.data.iter().map(|&v| v >= self.threshold).collect()
208    }
209
210    /// Count the number of accepted pixels.
211    pub fn count_accepted(&self, map: &ConfidenceMap) -> usize {
212        map.data.iter().filter(|&&v| v >= self.threshold).count()
213    }
214
215    /// Return the acceptance ratio `[0, 1]`.
216    #[allow(clippy::cast_precision_loss)]
217    pub fn acceptance_ratio(&self, map: &ConfidenceMap) -> f64 {
218        if map.is_empty() {
219            return 0.0;
220        }
221        self.count_accepted(map) as f64 / map.data.len() as f64
222    }
223}
224
225/// Merge two confidence maps by taking the element-wise maximum.
226pub fn merge_max(a: &ConfidenceMap, b: &ConfidenceMap) -> Option<ConfidenceMap> {
227    if a.width != b.width || a.height != b.height {
228        return None;
229    }
230    let data: Vec<f64> = a
231        .data
232        .iter()
233        .zip(b.data.iter())
234        .map(|(&va, &vb)| va.max(vb))
235        .collect();
236    Some(ConfidenceMap {
237        width: a.width,
238        height: a.height,
239        data,
240    })
241}
242
243/// Merge two confidence maps by taking the element-wise minimum.
244pub fn merge_min(a: &ConfidenceMap, b: &ConfidenceMap) -> Option<ConfidenceMap> {
245    if a.width != b.width || a.height != b.height {
246        return None;
247    }
248    let data: Vec<f64> = a
249        .data
250        .iter()
251        .zip(b.data.iter())
252        .map(|(&va, &vb)| va.min(vb))
253        .collect();
254    Some(ConfidenceMap {
255        width: a.width,
256        height: a.height,
257        data,
258    })
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn test_new_map_zeroed() {
267        let m = ConfidenceMap::new(4, 3);
268        assert_eq!(m.width, 4);
269        assert_eq!(m.height, 3);
270        assert_eq!(m.len(), 12);
271        assert!(m.data.iter().all(|&v| v == 0.0));
272    }
273
274    #[test]
275    fn test_filled_clamped() {
276        let m = ConfidenceMap::filled(2, 2, 1.5);
277        assert!(m.data.iter().all(|&v| (v - 1.0).abs() < 1e-12));
278    }
279
280    #[test]
281    fn test_get_set() {
282        let mut m = ConfidenceMap::new(3, 3);
283        assert!(m.set(1, 2, 0.75));
284        assert!((m.get(1, 2).expect("get should succeed") - 0.75).abs() < 1e-12);
285    }
286
287    #[test]
288    fn test_get_out_of_bounds() {
289        let m = ConfidenceMap::new(2, 2);
290        assert!(m.get(5, 0).is_none());
291    }
292
293    #[test]
294    fn test_set_out_of_bounds() {
295        let mut m = ConfidenceMap::new(2, 2);
296        assert!(!m.set(5, 0, 0.5));
297    }
298
299    #[test]
300    fn test_set_clamps_value() {
301        let mut m = ConfidenceMap::new(2, 2);
302        m.set(0, 0, 2.0);
303        assert!((m.get(0, 0).expect("get should succeed") - 1.0).abs() < 1e-12);
304        m.set(0, 0, -1.0);
305        assert!(m.get(0, 0).expect("get should succeed").abs() < 1e-12);
306    }
307
308    #[test]
309    fn test_is_empty() {
310        let m = ConfidenceMap::new(0, 0);
311        assert!(m.is_empty());
312        let m2 = ConfidenceMap::new(1, 1);
313        assert!(!m2.is_empty());
314    }
315
316    #[test]
317    fn test_statistics_uniform() {
318        let m = ConfidenceMap::filled(3, 3, 0.5);
319        let s = m.statistics();
320        assert!((s.min - 0.5).abs() < 1e-12);
321        assert!((s.max - 0.5).abs() < 1e-12);
322        assert!((s.mean - 0.5).abs() < 1e-12);
323        assert!(s.std_dev < 1e-12);
324        assert!((s.median - 0.5).abs() < 1e-12);
325    }
326
327    #[test]
328    fn test_statistics_varied() {
329        let mut m = ConfidenceMap::new(3, 1);
330        m.set(0, 0, 0.0);
331        m.set(1, 0, 0.5);
332        m.set(2, 0, 1.0);
333        let s = m.statistics();
334        assert!(s.min.abs() < 1e-12);
335        assert!((s.max - 1.0).abs() < 1e-12);
336        assert!((s.mean - 0.5).abs() < 1e-12);
337        assert!((s.median - 0.5).abs() < 1e-12);
338    }
339
340    #[test]
341    fn test_thresholder() {
342        let mut m = ConfidenceMap::new(2, 2);
343        m.set(0, 0, 0.3);
344        m.set(1, 0, 0.7);
345        m.set(0, 1, 0.5);
346        m.set(1, 1, 0.9);
347        let t = ConfidenceThresholder::new(0.5);
348        let mask = t.apply(&m);
349        assert!(!mask[0]); // 0.3 < 0.5
350        assert!(mask[1]); // 0.7 >= 0.5
351        assert!(mask[2]); // 0.5 >= 0.5
352        assert!(mask[3]); // 0.9 >= 0.5
353        assert_eq!(t.count_accepted(&m), 3);
354        assert!((t.acceptance_ratio(&m) - 0.75).abs() < 1e-12);
355    }
356
357    #[test]
358    fn test_merge_max() {
359        let a = ConfidenceMap {
360            width: 2,
361            height: 1,
362            data: vec![0.3, 0.8],
363        };
364        let b = ConfidenceMap {
365            width: 2,
366            height: 1,
367            data: vec![0.5, 0.6],
368        };
369        let merged = merge_max(&a, &b).expect("merged should be valid");
370        assert!((merged.data[0] - 0.5).abs() < 1e-12);
371        assert!((merged.data[1] - 0.8).abs() < 1e-12);
372    }
373
374    #[test]
375    fn test_merge_min() {
376        let a = ConfidenceMap {
377            width: 2,
378            height: 1,
379            data: vec![0.3, 0.8],
380        };
381        let b = ConfidenceMap {
382            width: 2,
383            height: 1,
384            data: vec![0.5, 0.6],
385        };
386        let merged = merge_min(&a, &b).expect("merged should be valid");
387        assert!((merged.data[0] - 0.3).abs() < 1e-12);
388        assert!((merged.data[1] - 0.6).abs() < 1e-12);
389    }
390
391    #[test]
392    fn test_merge_mismatched_dimensions() {
393        let a = ConfidenceMap::new(2, 2);
394        let b = ConfidenceMap::new(3, 2);
395        assert!(merge_max(&a, &b).is_none());
396        assert!(merge_min(&a, &b).is_none());
397    }
398
399    #[test]
400    fn test_gaussian_blur_preserves_uniform() {
401        let m = ConfidenceMap::filled(5, 5, 0.7);
402        let blurred = m.gaussian_blur(1);
403        for v in &blurred.data {
404            assert!((v - 0.7).abs() < 1e-6);
405        }
406    }
407}