Skip to main content

mimick/library/lightbox/ocr/
geometry.rs

1//! Geometry for OCR boxes: where the photo sits in the viewer, box hit-testing, and reading order.
2
3use crate::api_client::OcrBox;
4
5/// Boxes whose tops are within 2% of the image height share a row, as in Immich web.
6const ROW_THRESHOLD: f64 = 0.02;
7
8/// Where the photo is drawn inside its widget.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub(super) struct ImageRect {
11    pub x: f64,
12    pub y: f64,
13    pub w: f64,
14    pub h: f64,
15}
16
17/// The rect `gtk::ContentFit::Contain` draws `image` into; `None` for empty sizes.
18pub(super) fn contain_rect(area: (f64, f64), image: (f64, f64)) -> Option<ImageRect> {
19    let (area_w, area_h) = area;
20    let (image_w, image_h) = image;
21    if ![area_w, area_h, image_w, image_h].iter().all(|v| *v > 0.0) {
22        return None;
23    }
24    let scale = (area_w / image_w).min(area_h / image_h);
25    let (w, h) = (image_w * scale, image_h * scale);
26    Some(ImageRect {
27        x: (area_w - w) / 2.0,
28        y: (area_h - h) / 2.0,
29        w,
30        h,
31    })
32}
33
34/// Maps normalized box corners into widget coordinates.
35pub(super) fn quad_in_area(rect: ImageRect, corners: [(f64, f64); 4]) -> [(f64, f64); 4] {
36    corners.map(|(nx, ny)| (rect.x + nx * rect.w, rect.y + ny * rect.h))
37}
38
39/// Whether `p` lies inside a convex quad; points on an edge count as inside.
40pub(super) fn quad_contains(quad: &[(f64, f64); 4], p: (f64, f64)) -> bool {
41    let (mut positive, mut negative) = (false, false);
42    for i in 0..4 {
43        let (ax, ay) = quad[i];
44        let (bx, by) = quad[(i + 1) % 4];
45        let cross = (bx - ax) * (p.1 - ay) - (by - ay) * (p.0 - ax);
46        positive |= cross > 0.0;
47        negative |= cross < 0.0;
48    }
49    !(positive && negative)
50}
51
52/// Rows top to bottom, then left to right; rows are grouped first so the sort stays a total order.
53pub(super) fn reading_order(mut boxes: Vec<OcrBox>) -> Vec<OcrBox> {
54    boxes.sort_by(|a, b| a.y1.total_cmp(&b.y1));
55    let mut rows: Vec<Vec<OcrBox>> = Vec::new();
56    for ocr_box in boxes {
57        match rows.last_mut() {
58            Some(row) if ocr_box.y1 - row[0].y1 < ROW_THRESHOLD => row.push(ocr_box),
59            _ => rows.push(vec![ocr_box]),
60        }
61    }
62    rows.into_iter()
63        .flat_map(|mut row| {
64            row.sort_by(|a, b| a.x1.total_cmp(&b.x1));
65            row
66        })
67        .collect()
68}
69
70/// Test box 0.25 wide and 0.0625 tall with its top-left at `(x, y)`.
71#[cfg(test)]
72pub(super) fn ob(text: &str, x: f64, y: f64) -> OcrBox {
73    OcrBox {
74        text: text.to_string(),
75        x1: x,
76        y1: y,
77        x2: x + 0.25,
78        y2: y,
79        x3: x + 0.25,
80        y3: y + 0.0625,
81        x4: x,
82        y4: y + 0.0625,
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn contain_rect_letterboxes_wide_image() {
92        assert_eq!(
93            contain_rect((1000.0, 1000.0), (2000.0, 1000.0)),
94            Some(ImageRect {
95                x: 0.0,
96                y: 250.0,
97                w: 1000.0,
98                h: 500.0
99            })
100        );
101    }
102
103    #[test]
104    fn contain_rect_pillarboxes_tall_image() {
105        assert_eq!(
106            contain_rect((1000.0, 500.0), (500.0, 1000.0)),
107            Some(ImageRect {
108                x: 375.0,
109                y: 0.0,
110                w: 250.0,
111                h: 500.0
112            })
113        );
114    }
115
116    #[test]
117    fn contain_rect_rejects_empty_sizes() {
118        assert_eq!(contain_rect((0.0, 500.0), (100.0, 100.0)), None);
119        assert_eq!(contain_rect((500.0, 500.0), (100.0, 0.0)), None);
120    }
121
122    #[test]
123    fn quad_maps_into_image_rect() {
124        let rect = ImageRect {
125            x: 0.0,
126            y: 250.0,
127            w: 1000.0,
128            h: 500.0,
129        };
130        assert_eq!(
131            quad_in_area(rect, [(0.125, 0.25), (0.5, 0.25), (0.5, 0.5), (0.125, 0.5)]),
132            [
133                (125.0, 375.0),
134                (500.0, 375.0),
135                (500.0, 500.0),
136                (125.0, 500.0)
137            ]
138        );
139    }
140
141    #[test]
142    fn quad_contains_handles_rotated_box() {
143        let diamond = [(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)];
144        assert!(quad_contains(&diamond, (50.0, 50.0)));
145        assert!(quad_contains(&diamond, (50.0, 0.0)));
146        assert!(!quad_contains(&diamond, (10.0, 10.0)));
147    }
148
149    #[test]
150    fn reading_order_groups_rows_then_columns() {
151        let order = reading_order(vec![
152            ob("right", 0.625, 0.5),
153            ob("left", 0.125, 0.51),
154            ob("top", 0.5, 0.25),
155        ]);
156        let texts: Vec<_> = order.iter().map(|b| b.text.as_str()).collect();
157        assert_eq!(texts, ["top", "left", "right"]);
158    }
159
160    #[test]
161    fn reading_order_survives_staircase_rows() {
162        let boxes: Vec<_> = (0..30)
163            .map(|i| ob(&i.to_string(), 1.0 - i as f64 * 0.03, i as f64 * 0.015))
164            .collect();
165        let mut texts: Vec<_> = reading_order(boxes).into_iter().map(|b| b.text).collect();
166        texts.sort();
167        let mut expected: Vec<_> = (0..30).map(|i| i.to_string()).collect();
168        expected.sort();
169        assert_eq!(texts, expected);
170    }
171}