|
| 1 | +//! 结构图检测纯函数(REQ-182 / v0.7.7):diagram_likeness + pick_sharpest + 区域过滤。 |
| 2 | +//! |
| 3 | +//! @ai-context: 非线性文本结构(表格/公式/代码/流程图/思维导图/架构图)的 |
| 4 | +//! "图像即产物"兜底(ADR-010:图语义纯本地无法还原)——版面分析 |
| 5 | +//! 已分类区域,本模块决定**哪些区域值得持久化为结构图**: |
| 6 | +//! ① table/formula/code 直接候选(is_structural); |
| 7 | +//! ② Image 区域过 diagram_likeness 启发式(防照片/装饰误收); |
| 8 | +//! ③ text/unknown 跳过(text 走 OCR 线性文本;unknown 归 V1.0 AI 补缝)。 |
| 9 | +//! @ai-context: pick_sharpest 解决"屏卡代表帧可能不是最清晰帧"——屏时间窗内 |
| 10 | +//! 多张归档帧选边缘能量最高者(动效结束后的静止清晰帧)。 |
| 11 | +//! @ai-context: 纯函数无 IO(网格输入);合成网格可单测(layout_analyzer 先例)。 |
| 12 | +
|
| 13 | +use crate::layout_analyzer::{FrameGrid, LayoutRegion, RegionKind}; |
| 14 | + |
| 15 | +/// Image 区域判定为"疑似图结构"的阈值(0-1;合成网格标定,真机调参)。 |
| 16 | +pub const DIAGRAM_LIKENESS_THRESHOLD: f32 = 0.5; |
| 17 | +/// 墨迹判定阈值(与 layout_analyzer INK_THRESHOLD 同口径 160——两模块独立 |
| 18 | +/// 常量防耦合,改阈值须双处同步)。 |
| 19 | +const INK_THRESHOLD: u8 = 160; |
| 20 | +/// 长直线判定:连续墨迹段 ≥ 区域宽/高该比例视为"结构线"(框线/箭头线)。 |
| 21 | +const LINE_FRACTION: f32 = 0.25; |
| 22 | +/// 低信息区域拒绝:灰度方差低于该值视为纯色/装饰(与 layout VARIANCE_MIN 同思路)。 |
| 23 | +const VARIANCE_MIN_STRUCT: f32 = 500.0; |
| 24 | +/// 墨迹密度目标上限(结构图线条+文字密度通常 ≤0.6;照片纹理更高)。 |
| 25 | +const INK_TARGET_MAX: f32 = 0.6; |
| 26 | +/// 墨迹密度下限(低于该值过于稀疏,不像结构图)。 |
| 27 | +const INK_TARGET_MIN: f32 = 0.04; |
| 28 | +/// 合成评分权重:长直线是主信号(照片/文字均无框线),墨迹密度与面积占比辅助。 |
| 29 | +const W_LINE: f32 = 0.75; |
| 30 | +const W_INK: f32 = 0.15; |
| 31 | +const W_AREA: f32 = 0.10; |
| 32 | + |
| 33 | +/// 图结构似然评分(纯函数):区域网格 → 0-1。 |
| 34 | +/// |
| 35 | +/// @ai-context: 特征三件套——① 长直线(框线/箭头线,照片与文字均无); |
| 36 | +/// max_run=最长连续墨迹段占比 + line_rows=含长段的行数(框数信号); |
| 37 | +/// ② 墨迹密度(结构图线条+文字密度中等;超上限衰减防照片纹理拉分); |
| 38 | +/// ③ 面积占比(大区域才值得持久化,小图标/水印不收)。 |
| 39 | +/// @ai-context: 硬门槛:灰度方差过低(纯色块/渐变装饰)直接 0 分。 |
| 40 | +/// 区域越界/过小 → 0 分(防御)。 |
| 41 | +pub fn diagram_likeness(grid: &FrameGrid, x0: u32, y0: u32, x1: u32, y1: u32) -> f32 { |
| 42 | + if x1 < x0 || y1 < y0 || x1 >= grid.cols || y1 >= grid.rows { |
| 43 | + return 0.0; |
| 44 | + } |
| 45 | + let w = x1 - x0 + 1; |
| 46 | + let h = y1 - y0 + 1; |
| 47 | + if w < 2 || h < 2 { |
| 48 | + return 0.0; |
| 49 | + } |
| 50 | + // ① 长直线特征:行/列最长连续墨迹段 + 含长段行数 |
| 51 | + let mut max_h_run = 0u32; |
| 52 | + let mut line_rows = 0u32; |
| 53 | + for y in y0..=y1 { |
| 54 | + let (mut run, mut best, mut row_has_line) = (0u32, 0u32, false); |
| 55 | + for x in x0..=x1 { |
| 56 | + if is_ink(grid, y, x) { |
| 57 | + run += 1; |
| 58 | + best = best.max(run); |
| 59 | + } else { |
| 60 | + run = 0; |
| 61 | + } |
| 62 | + } |
| 63 | + max_h_run = max_h_run.max(best); |
| 64 | + if best as f32 >= w as f32 * LINE_FRACTION { |
| 65 | + row_has_line = true; |
| 66 | + } |
| 67 | + if row_has_line { |
| 68 | + line_rows += 1; |
| 69 | + } |
| 70 | + } |
| 71 | + let mut max_v_run = 0u32; |
| 72 | + let mut line_cols = 0u32; |
| 73 | + for x in x0..=x1 { |
| 74 | + let (mut run, mut best) = (0u32, 0u32); |
| 75 | + for y in y0..=y1 { |
| 76 | + if is_ink(grid, y, x) { |
| 77 | + run += 1; |
| 78 | + best = best.max(run); |
| 79 | + } else { |
| 80 | + run = 0; |
| 81 | + } |
| 82 | + } |
| 83 | + max_v_run = max_v_run.max(best); |
| 84 | + if best as f32 >= h as f32 * LINE_FRACTION { |
| 85 | + line_cols += 1; |
| 86 | + } |
| 87 | + } |
| 88 | + let max_run = (max_h_run as f32 / w as f32).max(max_v_run as f32 / h as f32); |
| 89 | + let line_rows_norm = ((line_rows + line_cols) as f32 / 2.0).min(1.0); |
| 90 | + let line_score = 0.7 * max_run + 0.3 * line_rows_norm; |
| 91 | + // ② 墨迹密度(区域内) |
| 92 | + let (mut ink, mut total) = (0u32, 0u32); |
| 93 | + let mut sum = 0u64; |
| 94 | + let mut sum_sq = 0u64; |
| 95 | + for y in y0..=y1 { |
| 96 | + for x in x0..=x1 { |
| 97 | + total += 1; |
| 98 | + let v = grid.cells[(y * grid.cols + x) as usize]; |
| 99 | + if v < INK_THRESHOLD { |
| 100 | + ink += 1; |
| 101 | + } |
| 102 | + sum += v as u64; |
| 103 | + sum_sq += (v as u64) * (v as u64); |
| 104 | + } |
| 105 | + } |
| 106 | + let density = ink as f32 / total as f32; |
| 107 | + let ink_score = if density < INK_TARGET_MIN { |
| 108 | + 0.0 |
| 109 | + } else if density <= INK_TARGET_MAX { |
| 110 | + density / INK_TARGET_MAX |
| 111 | + } else { |
| 112 | + // 超上限衰减(照片纹理):0.6→1.0 线性降到 1.0→0.5,防高密度纹理拉分 |
| 113 | + 1.0 - (density - INK_TARGET_MAX) / (1.0 - INK_TARGET_MAX) * 0.5 |
| 114 | + }; |
| 115 | + // ③ 面积占比 |
| 116 | + let area_ratio = (w * h) as f32 / (grid.cols * grid.rows) as f32; |
| 117 | + // ④ 灰度方差(硬门槛:纯色/渐变装饰直接拒绝) |
| 118 | + let mean = sum as f64 / total as f64; |
| 119 | + let variance = (sum_sq as f64 / total as f64 - mean * mean).max(0.0) as f32; |
| 120 | + if variance < VARIANCE_MIN_STRUCT { |
| 121 | + return 0.0; |
| 122 | + } |
| 123 | + (W_LINE * line_score + W_INK * ink_score + W_AREA * area_ratio.min(1.0)).min(1.0) |
| 124 | +} |
| 125 | + |
| 126 | +/// 网格坐标单元是否墨迹(区域评分用,阈值同口径)。 |
| 127 | +fn is_ink(grid: &FrameGrid, y: u32, x: u32) -> bool { |
| 128 | + grid.cells[(y * grid.cols + x) as usize] < INK_THRESHOLD |
| 129 | +} |
| 130 | + |
| 131 | +/// 帧边缘能量(纯函数):水平+垂直相邻格亮度差绝对值之和——清晰度代理指标 |
| 132 | +/// (文字/线条边缘锐利 → 能量高;模糊/纯色帧 → 能量低或零)。 |
| 133 | +pub fn edge_energy(grid: &FrameGrid) -> u64 { |
| 134 | + if grid.cols == 0 || grid.rows == 0 { |
| 135 | + return 0; |
| 136 | + } |
| 137 | + let mut energy = 0u64; |
| 138 | + for y in 0..grid.rows { |
| 139 | + for x in 0..grid.cols { |
| 140 | + let v = grid.cells[(y * grid.cols + x) as usize] as i64; |
| 141 | + if x + 1 < grid.cols { |
| 142 | + energy += (v - grid.cells[(y * grid.cols + x + 1) as usize] as i64).unsigned_abs(); |
| 143 | + } |
| 144 | + if y + 1 < grid.rows { |
| 145 | + energy += (v - grid.cells[((y + 1) * grid.cols + x) as usize] as i64).unsigned_abs(); |
| 146 | + } |
| 147 | + } |
| 148 | + } |
| 149 | + energy |
| 150 | +} |
| 151 | + |
| 152 | +/// 最清晰帧选择(纯函数):候选 (时间戳, 网格) 列表 → 边缘能量最高者索引。 |
| 153 | +/// |
| 154 | +/// @ai-context: 零能量帧(纯色/黑屏,无内容)跳过;全部零能量 → None |
| 155 | +/// (该屏无可捕获帧,调用方跳过——诚实降级)。 |
| 156 | +pub fn pick_sharpest(candidates: &[(u64, FrameGrid)]) -> Option<usize> { |
| 157 | + let mut best: Option<(usize, u64)> = None; |
| 158 | + for (i, (_, g)) in candidates.iter().enumerate() { |
| 159 | + let e = edge_energy(g); |
| 160 | + if e == 0 { |
| 161 | + continue; |
| 162 | + } |
| 163 | + if best.as_ref().is_none_or(|(_, be)| e > *be) { |
| 164 | + best = Some((i, e)); |
| 165 | + } |
| 166 | + } |
| 167 | + best.map(|(i, _)| i) |
| 168 | +} |
| 169 | + |
| 170 | +/// 结构图候选过滤(纯函数):版面区域 → 值得持久化的子集。 |
| 171 | +/// |
| 172 | +/// @ai-context: table/formula/code 直收(is_structural);Image 过 |
| 173 | +/// diagram_likeness 阈值;text/unknown 跳过。输入输出均为 |
| 174 | +/// 网格坐标(编排层随后 regions_to_frame 换算帧坐标裁剪)。 |
| 175 | +pub fn filter_structure_regions( |
| 176 | + regions: &[LayoutRegion], |
| 177 | + grid: &FrameGrid, |
| 178 | +) -> Vec<LayoutRegion> { |
| 179 | + regions |
| 180 | + .iter() |
| 181 | + .filter(|r| match r.kind { |
| 182 | + RegionKind::Table | RegionKind::Formula | RegionKind::Code => true, |
| 183 | + RegionKind::Image => { |
| 184 | + diagram_likeness(grid, r.x, r.y, r.x + r.w - 1, r.y + r.h - 1) |
| 185 | + >= DIAGRAM_LIKENESS_THRESHOLD |
| 186 | + } |
| 187 | + RegionKind::Text | RegionKind::Unknown => false, |
| 188 | + }) |
| 189 | + .cloned() |
| 190 | + .collect() |
| 191 | +} |
| 192 | + |
| 193 | +#[cfg(test)] |
| 194 | +#[path = "structure_detect_tests.rs"] |
| 195 | +mod tests; |
0 commit comments