Skip to main content

checkerboard_calibrate/chessboard/
approx.rs

1// Copyright (C) The Strand-Braid Authors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Polygon approximation — a port of OpenCV's `approxPolyDP` (the Douglas–Peucker
5//! variant in `modules/imgproc/src/approx.cpp`).
6//!
7//! OpenCV's implementation is a specific stack-based Douglas–Peucker followed by
8//! a collinear-point cleanup pass, with particular wraparound/rounding behavior.
9//! This is ported literally so the output vertices match OpenCV exactly. The chessboard
10//! detector only ever calls it with `closed = true`, but the open-contour path
11//! is ported too for completeness.
12
13/// Approximate a polygonal curve with the Douglas–Peucker algorithm.
14///
15/// `eps` is the maximum distance (in pixels) between the original curve and its
16/// approximation. Returns the approximated vertices in order.
17pub fn approx_poly_dp(src: &[(i32, i32)], eps: f64, closed: bool) -> Vec<(i32, i32)> {
18    let count = src.len();
19    if count == 0 {
20        return Vec::new();
21    }
22    let eps = eps * eps;
23
24    let mut dst: Vec<(i32, i32)> = Vec::with_capacity(count);
25    let mut stack: Vec<(usize, usize)> = Vec::new();
26
27    let mut slice = (0usize, 0usize);
28    let mut right = (0usize, 0usize);
29    let mut pos = 0usize;
30    let mut start_pt = (0i32, 0i32);
31
32    // An "open" contour whose endpoints coincide is treated as closed, matching
33    // OpenCV.
34    let is_closed = closed || src[0] == src[count - 1];
35
36    if !is_closed {
37        stack.push((0, count - 1));
38    } else {
39        right.0 = 0;
40        let mut le_eps = false;
41        for _ in 0..3 {
42            let mut max_dist = 0.0f64;
43            pos = (pos + right.0) % count;
44            start_pt = src[pos];
45            pos = (pos + 1) % count;
46            for j in 1..count {
47                let pt = src[pos];
48                pos = (pos + 1) % count;
49                let dx = (pt.0 - start_pt.0) as f64;
50                let dy = (pt.1 - start_pt.1) as f64;
51                let dist = dx * dx + dy * dy;
52                if dist > max_dist {
53                    max_dist = dist;
54                    right.0 = j;
55                }
56            }
57            le_eps = max_dist <= eps;
58        }
59
60        if !le_eps {
61            let tmp = pos % count;
62            right.1 = tmp;
63            slice.0 = tmp;
64            let newv = (right.0 + slice.0) % count;
65            slice.1 = newv;
66            right.0 = newv;
67            stack.push(right);
68            stack.push(slice);
69        } else {
70            // Whole contour fits within eps: after the init loop `pos` is back
71            // at the position where `start_pt` was read.
72            dst.push(start_pt);
73        }
74    }
75
76    // Recursive subdivision (iterative via the stack).
77    while let Some(s) = stack.pop() {
78        slice = s;
79        let end_pt = src[slice.1];
80        pos = slice.0;
81        start_pt = src[pos];
82        pos = (pos + 1) % count;
83
84        let le_eps = if pos != slice.1 {
85            let dx = (end_pt.0 - start_pt.0) as f64;
86            let dy = (end_pt.1 - start_pt.1) as f64;
87            let mut max_dist = 0.0f64;
88            while pos != slice.1 {
89                let pt = src[pos];
90                pos = (pos + 1) % count;
91                let dist =
92                    ((pt.1 - start_pt.1) as f64 * dx - (pt.0 - start_pt.0) as f64 * dy).abs();
93                if dist > max_dist {
94                    max_dist = dist;
95                    right.0 = (pos + count - 1) % count;
96                }
97            }
98            max_dist * max_dist <= eps * (dx * dx + dy * dy)
99        } else {
100            true
101        };
102
103        if le_eps {
104            dst.push(start_pt);
105        } else {
106            right.1 = slice.1;
107            slice.1 = right.0;
108            stack.push(right);
109            stack.push(slice);
110        }
111    }
112
113    if !is_closed {
114        dst.push(src[count - 1]);
115    }
116
117    cleanup_collinear(&mut dst, eps, is_closed);
118    dst
119}
120
121/// Final stage of OpenCV `approxPolyDP`: drop points lying on (almost) straight
122/// lines between their neighbors. Operates in place; `dst` is truncated to the
123/// surviving vertices.
124fn cleanup_collinear(dst: &mut Vec<(i32, i32)>, eps: f64, closed: bool) {
125    let count = dst.len();
126    if count < 3 {
127        return;
128    }
129    let mut new_count = count;
130
131    let mut pos = if closed { count - 1 } else { 0 };
132    let mut start_pt = dst[pos];
133    pos = (pos + 1) % count;
134    let mut wpos = pos;
135    let mut pt = dst[pos];
136    pos = (pos + 1) % count;
137
138    let lo = if closed { 0 } else { 1 };
139    let hi = count - if closed { 0 } else { 1 };
140    let mut i = lo;
141    while i < hi && new_count > 2 {
142        let end_pt = dst[pos];
143        pos = (pos + 1) % count;
144
145        let dx = (end_pt.0 - start_pt.0) as f64;
146        let dy = (end_pt.1 - start_pt.1) as f64;
147        let dist = ((pt.0 - start_pt.0) as f64 * dy - (pt.1 - start_pt.1) as f64 * dx).abs();
148        let sip = (pt.0 - start_pt.0) as f64 * (end_pt.0 - pt.0) as f64
149            + (pt.1 - start_pt.1) as f64 * (end_pt.1 - pt.1) as f64;
150
151        if dist * dist <= 0.5 * eps * (dx * dx + dy * dy) && dx != 0.0 && dy != 0.0 && sip >= 0.0 {
152            new_count -= 1;
153            start_pt = end_pt;
154            dst[wpos] = end_pt;
155            wpos = (wpos + 1) % count;
156            pt = dst[pos];
157            pos = (pos + 1) % count;
158            i += 2;
159            continue;
160        }
161
162        start_pt = pt;
163        dst[wpos] = pt;
164        wpos = (wpos + 1) % count;
165        pt = end_pt;
166        i += 1;
167    }
168
169    if !closed {
170        dst[wpos] = pt;
171    }
172    dst.truncate(new_count);
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn square_with_dense_edges_reduces_to_four_corners() {
181        // A 100x100 square sampled densely along each edge (closed contour).
182        let mut pts = Vec::new();
183        for x in 0..=100 {
184            pts.push((x, 0));
185        }
186        for y in 1..=100 {
187            pts.push((100, y));
188        }
189        for x in (0..100).rev() {
190            pts.push((x, 100));
191        }
192        for y in (1..100).rev() {
193            pts.push((0, y));
194        }
195
196        let approx = approx_poly_dp(&pts, 3.0, true);
197        assert_eq!(approx.len(), 4, "expected 4 corners, got {approx:?}");
198        let set: std::collections::BTreeSet<_> = approx.iter().copied().collect();
199        for corner in [(0, 0), (100, 0), (100, 100), (0, 100)] {
200            assert!(
201                set.contains(&corner),
202                "missing corner {corner:?} in {approx:?}"
203            );
204        }
205    }
206
207    #[test]
208    fn collinear_points_collapse() {
209        // A straight, closed back-and-forth degenerate shape reduces heavily.
210        let pts = vec![(0, 0), (10, 0), (20, 0), (30, 0), (30, 10), (0, 10)];
211        let approx = approx_poly_dp(&pts, 1.0, true);
212        // The three collinear top points (10,0)/(20,0) collapse; corners remain.
213        assert!(approx.len() <= 4, "got {approx:?}");
214        assert!(approx.contains(&(0, 0)));
215    }
216}