Skip to main content

doc_quad/geom/
simplify.rs

1// src/geom/simplify.rs
2use geo::ConvexHull;
3use geo::Simplify;
4use geo_types::{Coord, LineString, Polygon};
5use std::time::Instant;
6
7/// 几何简化器
8pub struct GeometrySimplifier;
9
10impl GeometrySimplifier {
11    /// 使用凸包 + Douglas-Peucker 算法简化轮廓,筛选出四边形候选。
12    ///
13    /// # 算法说明
14    /// 1. 将输入坐标点集构造为 LineString 并主动闭合。
15    /// 2. **新增**:对轮廓求凸包,消除锯齿噪声,使 D-P 更易收敛到 4 顶点。
16    /// 3. 以多档容差对凸包执行 Douglas-Peucker 简化,寻找恰好为 5 点的结果。
17    /// 4. 若多档容差均未能得到 5 点,则使用极值点法强制提取 4 个角点作为后备。
18    ///
19    /// # 凸包预处理的必要性
20    /// 真实照片中文档边缘因透视变形、光照不均、纸张弯曲,Canny 产生带噪声的曲线段。
21    /// 直接对锯齿轮廓执行 D-P,容差小时点数过多,容差大时直接跳过 5 点跌至 3 点,
22    /// 无法在任何容差档位精确停在 5 点。凸包将轮廓预处理为凸多边形后,
23    /// D-P 的简化路径更平滑,能稳定收敛到 4 顶点(5 点闭合 LineString)。
24    ///
25    /// # 闭合保证
26    /// 输入轮廓若首尾不同,此处主动闭合,确保 geo::Simplify 对闭合轮廓的正确处理。
27    pub fn simplify_to_quad(contour: Vec<Coord<f32>>) -> Option<LineString<f32>> {
28        let start = Instant::now();
29
30        if contour.len() < 4 {
31            log::debug!(
32                "[Geom::Simplify] - Contour too short (len={}), skipping.",
33                contour.len()
34            );
35            return None;
36        }
37
38        let original_len = contour.len();
39
40        // P1 修复:主动闭合轮廓,防止 ContourExtractor 输出未闭合时判断失效
41        let mut coords = contour;
42        if coords.first() != coords.last() {
43            if let Some(&first) = coords.first() {
44                coords.push(first);
45            }
46        }
47
48        let line_string = LineString::new(coords);
49
50        // 周长为零(退化轮廓)直接跳过
51        let perimeter = geo::EuclideanLength::euclidean_length(&line_string);
52        if perimeter < f32::EPSILON {
53            log::debug!(
54                "[Geom::Simplify] - Contour has zero perimeter (len={}), skipping.",
55                original_len
56            );
57            return None;
58        }
59
60        // ★ P0 修复:凸包预处理
61        // 对原始轮廓求凸包,将锯齿状噪声曲线转换为平滑凸多边形,
62        // 大幅提升后续 Douglas-Peucker 简化到恰好 4 顶点的成功率。
63        let polygon = Polygon::new(line_string.clone(), vec![]);
64        let hull: LineString<f32> = polygon.convex_hull().exterior().clone();
65        let hull_point_count = hull.0.len();
66
67        log::debug!(
68            "[Geom::Simplify] - Convex hull: original_len={}, hull_points={}, perimeter={:.1}",
69            original_len,
70            hull_point_count,
71            perimeter
72        );
73
74        // 凸包退化检查(少于 4 点无法构成四边形)
75        if hull_point_count < 5 {
76            log::debug!(
77                "[Geom::Simplify] - Hull degenerate (hull_points={} < 5), skipping.",
78                hull_point_count
79            );
80            return None;
81        }
82
83        let hull_perimeter = geo::EuclideanLength::euclidean_length(&hull);
84
85        // 多档容差依次尝试,从细粒度到粗粒度
86        let epsilon_ratios: &[f32] = &[0.01, 0.02, 0.03, 0.05, 0.08, 0.12, 0.20, 0.30, 0.40];
87
88        for &ratio in epsilon_ratios {
89            let epsilon = hull_perimeter * ratio;
90            let simplified = hull.simplify(&epsilon);
91            let simplified_len = simplified.0.len();
92
93            log::trace!(
94                "[Geom::Simplify] - DP trial: ratio={:.0}%, epsilon={:.1}, simplified_points={}",
95                ratio * 100.0,
96                epsilon,
97                simplified_len
98            );
99
100            if simplified_len == 5 {
101                log::debug!(
102                    "[Geom::Simplify] - Found quad via convex-hull+DP at epsilon_ratio={:.0}%. \
103                     original_len={}, hull_points={}, Elapsed: {}µs",
104                    ratio * 100.0,
105                    original_len,
106                    hull_point_count,
107                    start.elapsed().as_micros()
108                );
109                return Some(simplified);
110            }
111        }
112
113        log::debug!(
114            "[Geom::Simplify] - DP failed to find 5-point result for hull (hull_points={}). \
115             Falling back to extreme-point method. Elapsed: {}µs",
116            hull_point_count,
117            start.elapsed().as_micros()
118        );
119
120        // 后备策略:对凸包执行极值点法(比对原始轮廓更稳定,凸包已去除噪声)
121        let result = Self::extract_quad_by_extremes(&hull);
122
123        if result.is_some() {
124            log::debug!(
125                "[Geom::Simplify] - Extreme-point fallback SUCCEEDED (hull_points={}). Elapsed: {}µs",
126                hull_point_count,
127                start.elapsed().as_micros()
128            );
129        } else {
130            log::debug!(
131                "[Geom::Simplify] - Extreme-point fallback FAILED (hull_points={}). Elapsed: {}µs",
132                hull_point_count,
133                start.elapsed().as_micros()
134            );
135        }
136
137        result
138    }
139
140    /// 极值点法:从轮廓中提取 4 个角点,构造近似四边形。
141    ///
142    /// # 算法说明
143    /// 分别找到使 (x+y)、(x-y)、(-x+y)、(-x-y) 最大的点,
144    /// 对应图像坐标系中的左上、右上、左下、右下四个极值方向。
145    /// 这 4 个点对于矩形轮廓能精确定位四个角,对透视变形矩形也有良好近似。
146    ///
147    /// 返回的 LineString 包含 5 个点(4 顶点 + 重复起点),与 Douglas-Peucker 输出格式一致。
148    ///
149    /// # P1 修复
150    /// 原退化检测使用 `f32::EPSILON` 精度判断 + 内层 `break` 导致计数不准确。
151    /// 改为基于欧氏距离的阈值判断(5px),更符合像素坐标的实际精度需求。
152    fn extract_quad_by_extremes(line_string: &LineString<f32>) -> Option<LineString<f32>> {
153        let pts = &line_string.0;
154        if pts.len() < 4 {
155            return None;
156        }
157
158        // 分别寻找 4 个极值方向的代表点:
159        // - min(x+y)  → 左上角(图像坐标系 y 向下)
160        // - max(x-y)  → 右上角
161        // - max(x+y)  → 右下角
162        // - min(x-y)  → 左下角
163        let tl = pts.iter().min_by(|a, b| {
164            (a.x + a.y)
165                .partial_cmp(&(b.x + b.y))
166                .unwrap_or(std::cmp::Ordering::Equal)
167        })?;
168        let tr = pts.iter().max_by(|a, b| {
169            (a.x - a.y)
170                .partial_cmp(&(b.x - b.y))
171                .unwrap_or(std::cmp::Ordering::Equal)
172        })?;
173        let br = pts.iter().max_by(|a, b| {
174            (a.x + a.y)
175                .partial_cmp(&(b.x + b.y))
176                .unwrap_or(std::cmp::Ordering::Equal)
177        })?;
178        let bl = pts.iter().min_by(|a, b| {
179            (a.x - a.y)
180                .partial_cmp(&(b.x - b.y))
181                .unwrap_or(std::cmp::Ordering::Equal)
182        })?;
183
184        log::debug!(
185            "[Geom::Simplify] - Extreme points: TL=({:.1},{:.1}), TR=({:.1},{:.1}), BR=({:.1},{:.1}), BL=({:.1},{:.1})",
186            tl.x, tl.y, tr.x, tr.y, br.x, br.y, bl.x, bl.y
187        );
188
189        // P1 修复:改用欧氏距离阈值判断重合,替代原来的 f32::EPSILON + break 逻辑。
190        // 原逻辑:内层 break 只跳出 j 循环,当 i=0 与 i=1 各有一个重合时,
191        // count 只被减 1 而非 2,导致退化四边形漏检。
192        // 新逻辑:任意两点距离 < 5px 即视为重合,直接拒绝。
193        let corners = [tl, tr, br, bl];
194        for i in 0..4 {
195            for j in (i + 1)..4 {
196                let dx = corners[i].x - corners[j].x;
197                let dy = corners[i].y - corners[j].y;
198                let dist = (dx * dx + dy * dy).sqrt();
199                if dist < 5.0 {
200                    log::warn!(
201                        "[Geom::Simplify] - Extreme-point fallback produced degenerate quad: \
202                         corners[{}]=({:.1},{:.1}) and corners[{}]=({:.1},{:.1}) are too close (dist={:.1}px < 5px).",
203                        i, corners[i].x, corners[i].y,
204                        j, corners[j].x, corners[j].y,
205                        dist
206                    );
207                    return None;
208                }
209            }
210        }
211
212        // 构造闭合 LineString(5 点,首尾相同)
213        let quad_coords = vec![*tl, *tr, *br, *bl, *tl];
214        Some(LineString::new(quad_coords))
215    }
216}