Skip to main content

doc_quad/edge/
threshold.rs

1// src/edge/threshold.rs
2use ndarray::ArrayView2;
3use std::time::Instant;
4
5/// 自适应阈值计算器
6pub struct AdaptiveThreshold;
7
8impl AdaptiveThreshold {
9    /// 基于图像直方图统计计算 Canny 高低阈值。
10    ///
11    /// # 分支策略
12    ///
13    /// ## 极暗场景(中位数 <= 10)
14    /// 大部分像素为背景黑色,中位数法失效,改用高百分位数法(p70/p85)。
15    ///
16    /// ## 高亮背景场景(中位数 > 150)
17    /// 典型真实照片:背景为白纸/浅色桌面,中位数反映背景亮度而非边缘强度。
18    /// 进一步细分为极度高亮(动态范围 < 30 或高亮区集中度 < 10)和正常高亮两个子分支。
19    ///
20    /// ## 正常场景(10 < 中位数 <= 150)
21    /// 标准中位数法:low = (1-sigma)*median,high = (1+sigma)*median,
22    /// 同时对 high 施加 200.0 的硬上限,防止边界情况产生过高阈值。
23    ///
24    /// sigma 推荐值为 0.33。
25    pub fn calculate(view: &ArrayView2<'_, u8>, sigma: f32) -> (f32, f32) {
26        let start = Instant::now();
27
28        // 统计 256 级灰度直方图
29        let mut hist = [0u32; 256];
30        for &pixel in view.iter() {
31            hist[pixel as usize] += 1;
32        }
33
34        let total = view.len() as u32;
35
36        // 累积分布求中位数(第 50 百分位)
37        let mut count = 0u32;
38        let mut median = 127u8;
39        for (i, &c) in hist.iter().enumerate() {
40            count += c;
41            if count >= total / 2 {
42                median = i as u8;
43                break;
44            }
45        }
46
47        let (low, high) = if median <= 10 {
48            // ── 极暗/稀疏场景:使用高百分位数法 ──────────────────────────────
49            // 中位数 <= 10 说明大部分像素为背景黑色(如白纸上的黑色文字扫描件),
50            // 此时中位数法失效,改用高百分位数法保证边缘能被检测到。
51            // 取第 70 百分位作为 low 阈值基准,第 85 百分位作为 high 阈值基准。
52            let p70 = Self::percentile(&hist, total, 70);
53            let p85 = Self::percentile(&hist, total, 85);
54
55            // 若高百分位仍为 0(全黑图),使用固定保底阈值
56            let low = if p70 > 0 {
57                (p70 as f32 * (1.0 - sigma)).max(1.0)
58            } else {
59                10.0
60            };
61            let high = if p85 > 0 {
62                (p85 as f32).max(low + 1.0)
63            } else {
64                30.0
65            };
66
67            log::debug!(
68                "[Edge::Threshold] - Sparse scene (median={}), percentile method: \
69                 p70={}, p85={}, low={:.2}, high={:.2}",
70                median, p70, p85, low, high
71            );
72
73            (low, high)
74        } else if median > 150 {
75            // ── 高亮背景场景:使用直方图差分近似梯度分布 ────────────────────────
76            //
77            // 核心问题:p75=242, p90=245 相差仅 3,说明直方图极度集中在高亮区。
78            // 此时像素亮度百分位与 Sobel 梯度幅值完全脱钩,不能用来估算 Canny 阈值。
79            //
80            // 修复策略:
81            // 1. 计算直方图的"集中度"指标:p90 - p10(亮度动态范围)
82            // 2. 若动态范围 < 30(极度高亮/低对比度场景),切换到固定低阈值模式
83            // 3. 否则使用改进的 p25/p75 百分位(聚焦暗区,捕捉文档边缘弱梯度)
84            let p10 = Self::percentile(&hist, total, 10);
85            let p25 = Self::percentile(&hist, total, 25);
86            let p75 = Self::percentile(&hist, total, 75);
87            let p90 = Self::percentile(&hist, total, 90);
88
89            let dynamic_range = p90 as i32 - p10 as i32;
90            // 高亮区集中度:p90 与 p75 之差越小说明高亮区越集中
91            let highlight_spread = p90 as i32 - p75 as i32;
92
93            let (low, high) = if dynamic_range < 30 || highlight_spread < 10 {
94                // 极度高亮场景(白纸拍白桌):整个图像对比度极低,
95                // 文档边缘的 Sobel 梯度幅值极小,必须使用固定低阈值才能检测到。
96                // 经验值:文档边缘在此类场景下梯度幅值通常在 10~40 之间。
97                let low = 5.0f32;
98                let high = 20.0f32;
99
100                log::debug!(
101                    "[Edge::Threshold] - Extreme highlight scene (median={}, dynamic_range={}, \
102                     highlight_spread={}), using fixed low thresholds: low={:.2}, high={:.2}",
103                    median, dynamic_range, highlight_spread, low, high
104                );
105
106                (low, high)
107            } else {
108                // 正常高亮背景(有足够对比度):使用 p25/p75 聚焦暗区
109                // p25 通常落在文档内容区(文字、印章),p75 对应边缘过渡区
110                let low = (p25 as f32 * (1.0 - sigma)).max(1.0).min(60.0);
111                let high = (p75 as f32 * (1.0 - sigma * 0.5))
112                    .max(low + 5.0)
113                    .min(150.0);
114
115                log::debug!(
116                    "[Edge::Threshold] - Bright background scene (median={}, dynamic_range={}), \
117                     p25/p75 method: p25={}, p75={}, low={:.2}, high={:.2}",
118                    median, dynamic_range, p25, p75, low, high
119                );
120
121                (low, high)
122            };
123
124            (low, high)
125        } else {
126            // ── 正常场景:标准中位数法 + 硬上限 ──────────────────────────────
127            // 中位数在 10~150 之间,图像亮度分布适中,标准中位数法有效。
128            // 对 high 施加 200.0 的硬上限(P0 修复),防止中位数偏高时
129            // high 被截断到 255,导致与高亮背景场景相同的边缘丢失问题。
130            let low = (0.0f32.max((1.0 - sigma) * median as f32)).min(255.0);
131            let high = (255.0f32.min((1.0 + sigma) * median as f32))
132                .max(low + 1.0)
133                .min(200.0); // P0 修复:hard cap,防止 high 过高
134
135            (low, high)
136        };
137
138        log::debug!(
139            "[Edge::Threshold] - Thresholds: low={:.2}, high={:.2}, median={}, \
140             sigma={:.2}. Elapsed: {}µs",
141            low,
142            high,
143            median,
144            sigma,
145            start.elapsed().as_micros()
146        );
147
148        (low, high)
149    }
150
151    /// 从直方图中计算指定百分位数对应的像素值。
152    ///
153    /// # 参数
154    /// - `hist`:256 级灰度直方图
155    /// - `total`:总像素数
156    /// - `percent`:百分位(0~100)
157    fn percentile(hist: &[u32; 256], total: u32, percent: u32) -> u8 {
158        // 目标累积像素数
159        let target = (total as u64 * percent as u64 / 100) as u32;
160        let mut cumulative = 0u32;
161        for (i, &c) in hist.iter().enumerate() {
162            cumulative += c;
163            if cumulative >= target {
164                return i as u8;
165            }
166        }
167        255
168    }
169
170    /// 辅助方法:快速计算近似中值(用于低功耗场景)。
171    ///
172    /// 以步长 4 采样,牺牲精度换取速度,适用于实时预览帧。
173    pub fn fast_median(view: &ndarray::ArrayView2<'_, u8>) -> u8 {
174        let sample_step = 4;
175        let mut hist = [0u32; 256];
176        let mut count = 0u32;
177
178        for row in view.rows().into_iter().step_by(sample_step) {
179            for &pixel in row.iter().step_by(sample_step) {
180                hist[pixel as usize] += 1;
181                count += 1;
182            }
183        }
184
185        if count == 0 {
186            return 127;
187        }
188
189        let mut cumulative = 0u32;
190        for (i, &c) in hist.iter().enumerate() {
191            cumulative += c;
192            if cumulative >= count / 2 {
193                return i as u8;
194            }
195        }
196        127
197    }
198}