Skip to main content

videre_core/
face_match.rs

1//! Bounding-box geometry for matching XMP face regions to detected faces.
2//! Pure: no I/O, no database, so the matching rule is tested in isolation.
3
4/// A top-left-origin pixel box, the common space both a stored face bbox and a
5/// denormalized MWG region are converted into before comparison.
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub struct PixelBox {
8    pub x: f64,
9    pub y: f64,
10    pub w: f64,
11    pub h: f64,
12}
13
14/// Intersection-over-union of two boxes. 0.0 when they do not overlap or when
15/// either has zero (or negative) area.
16pub fn iou(a: &PixelBox, b: &PixelBox) -> f64 {
17    let ix = a.x.max(b.x);
18    let iy = a.y.max(b.y);
19    let ix2 = (a.x + a.w).min(b.x + b.w);
20    let iy2 = (a.y + a.h).min(b.y + b.h);
21    let iw = (ix2 - ix).max(0.0);
22    let ih = (iy2 - iy).max(0.0);
23    let inter = iw * ih;
24    let union = a.w * a.h + b.w * b.h - inter;
25    if union <= 0.0 {
26        0.0
27    } else {
28        inter / union
29    }
30}
31
32/// For each region (by index), the id of the detected face it matches, or None.
33/// Greedy: consider all (region, face) pairs with IoU >= `threshold`, take them
34/// highest IoU first, and never reuse a region or a face. Deterministic: ties
35/// break by region index then face id.
36pub fn greedy_match(
37    regions: &[PixelBox],
38    faces: &[(i64, PixelBox)],
39    threshold: f64,
40) -> Vec<Option<i64>> {
41    // (iou, region_idx, face_id, face_idx)
42    let mut pairs: Vec<(f64, usize, i64, usize)> = Vec::new();
43    for (ri, r) in regions.iter().enumerate() {
44        for (fi, (fid, fb)) in faces.iter().enumerate() {
45            let s = iou(r, fb);
46            if s >= threshold {
47                pairs.push((s, ri, *fid, fi));
48            }
49        }
50    }
51    // Highest IoU first; deterministic tie-break by region index then face id.
52    pairs.sort_by(|a, b| {
53        b.0.partial_cmp(&a.0)
54            .unwrap_or(std::cmp::Ordering::Equal)
55            .then(a.1.cmp(&b.1))
56            .then(a.2.cmp(&b.2))
57    });
58    let mut out = vec![None; regions.len()];
59    let mut used_face = vec![false; faces.len()];
60    for (_s, ri, fid, fi) in pairs {
61        if out[ri].is_none() && !used_face[fi] {
62            out[ri] = Some(fid);
63            used_face[fi] = true;
64        }
65    }
66    out
67}
68
69/// The default IoU acceptance threshold. Tuneable; 0.5 is the usual face-match
70/// floor and can be revisited against real digiKam/Lightroom exports.
71pub const DEFAULT_IOU_THRESHOLD: f64 = 0.5;
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn iou_of_identical_boxes_is_one() {
79        let a = PixelBox {
80            x: 10.0,
81            y: 10.0,
82            w: 100.0,
83            h: 100.0,
84        };
85        assert!((iou(&a, &a) - 1.0).abs() < 1e-6);
86    }
87
88    #[test]
89    fn iou_of_disjoint_boxes_is_zero() {
90        let a = PixelBox {
91            x: 0.0,
92            y: 0.0,
93            w: 10.0,
94            h: 10.0,
95        };
96        let b = PixelBox {
97            x: 100.0,
98            y: 100.0,
99            w: 10.0,
100            h: 10.0,
101        };
102        assert_eq!(iou(&a, &b), 0.0);
103    }
104
105    #[test]
106    fn iou_of_half_overlap() {
107        // Two 10x10 boxes overlapping in a 10x5 strip: inter=50, union=150.
108        let a = PixelBox {
109            x: 0.0,
110            y: 0.0,
111            w: 10.0,
112            h: 10.0,
113        };
114        let b = PixelBox {
115            x: 0.0,
116            y: 5.0,
117            w: 10.0,
118            h: 10.0,
119        };
120        assert!((iou(&a, &b) - (50.0 / 150.0)).abs() < 1e-6);
121    }
122
123    #[test]
124    fn greedy_matches_best_first_one_to_one() {
125        let regions = vec![
126            PixelBox {
127                x: 0.0,
128                y: 0.0,
129                w: 10.0,
130                h: 10.0,
131            },
132            PixelBox {
133                x: 100.0,
134                y: 100.0,
135                w: 10.0,
136                h: 10.0,
137            },
138        ];
139        let faces = vec![
140            (
141                7i64,
142                PixelBox {
143                    x: 1.0,
144                    y: 1.0,
145                    w: 10.0,
146                    h: 10.0,
147                },
148            ),
149            (
150                9i64,
151                PixelBox {
152                    x: 101.0,
153                    y: 101.0,
154                    w: 10.0,
155                    h: 10.0,
156                },
157            ),
158        ];
159        let m = greedy_match(&regions, &faces, 0.3);
160        assert_eq!(m, vec![Some(7), Some(9)]);
161    }
162
163    #[test]
164    fn below_threshold_is_unmatched() {
165        let regions = vec![PixelBox {
166            x: 0.0,
167            y: 0.0,
168            w: 10.0,
169            h: 10.0,
170        }];
171        let faces = vec![(
172            7i64,
173            PixelBox {
174                x: 50.0,
175                y: 50.0,
176                w: 10.0,
177                h: 10.0,
178            },
179        )];
180        assert_eq!(greedy_match(&regions, &faces, 0.5), vec![None]);
181    }
182}