Skip to content

Commit 1629ea0

Browse files
committed
feat(ocr): 结构图检测纯函数——diagram_likeness/pick_sharpest/区域过滤
1 parent 0986100 commit 1629ea0

3 files changed

Lines changed: 419 additions & 0 deletions

File tree

‎app/src-tauri/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ mod structure_engine;
152152
mod structure_models;
153153
// v0.7.6(REQ-177/178):笔记结构渲染层——章节标题 + 词汇表块(纯函数)
154154
mod structure_note;
155+
// v0.7.7(REQ-182):结构图检测纯函数——diagram_likeness/pick_sharpest/区域过滤
156+
mod structure_detect;
155157
mod structure_tier;
156158
mod stutter_fold;
157159
mod subtitle;
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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

Comments
 (0)