1- //! 结构图批量捕获管线(REQ-182 / v0.7.7):屏 → 选优帧 → 版面分析 → 过滤 → 裁剪 → 入库。
1+ //! 结构图批量捕获管线(REQ-182 / v0.7.7;v0.10.2 重构):参考图集 → 版面分析 → 过滤 → 裁剪 → 入库。
22//!
33//! @ai-context: 非线性结构(表格/公式/代码/流程图等)"图像即产物"兜底(ADR-010):
4- //! 会话停止后批量执行(live 停止链路触发)+ 图库「重新捕获」可重跑
5- //! (same_image 去重保证幂等——同图不重复入库)。
6- //! @ai-context: 方案 A+(2026-08-20 裁决):不动实时链路——对每屏时间窗内的
7- //! 归档 full 帧采样(≤8 帧 bound 解码成本)→ pick_sharpest 选优
8- //! (边缘能量,动效结束后的稳定清晰帧)→ analyze_layout 复用 →
9- //! table/formula/code 直收 + Image 过 diagram_likeness → 白边裁剪
10- //! (image_crop 机制)→ struct/ + 记录入库。
11- //! @ai-context: 预算耗尽(auto 桶 80/会话)→ 停止本会话后续捕获(summary 标记,
12- //! 命令层提示);旧会话无屏/无图 → 自然跳过(降级链)。
4+ //! v0.10.2 起取消逐屏自动捕获(会话 33 实测 50%+ 误收字幕条)——
5+ //! 改为手动一键分析参考图集(full/ 全部归档帧,与图集画廊同源):
6+ //! 逐帧 decode → analyze_layout → regions_to_frame → decide_keep
7+ //! (L0 字幕重叠 / L1 版面类型 / L2 OCR 置信度反向 / L3 位置约束)
8+ //! → 白边裁剪(image_crop 机制)→ struct/ + 记录入库。
9+ //! @ai-context: 去重(same_image)保证幂等——同图不重复入库,可重复分析;
10+ //! 预算耗尽(auto 桶 80/会话)→ 停止本会话后续捕获(summary 标记,
11+ //! 命令层提示);旧会话无图 → 自然跳过(降级链)。
1312
1413use std:: path:: Path ;
1514
1615use crate :: db:: Db ;
1716use crate :: error:: Result ;
18- use crate :: layout_analyzer:: { analyze_layout, FrameGrid , LayoutRegion } ;
19- use crate :: structure_detect:: { filter_structure_regions, pick_sharpest} ;
17+ use crate :: layout_analyzer:: { analyze_layout, FrameGrid , LayoutRegion , RegionKind } ;
18+ use crate :: structure_detect:: { decide_keep, diagram_likeness, StructureFilterContext } ;
19+ use crate :: types:: SessionOcrBlock ;
2020
21- /// 每屏采样上限(帧解码成本 bound——屏窗口内归档帧通常 1-2 张,长屏最多 8)。
22- const MAX_FRAMES_PER_SCREEN : usize = 8 ;
21+ /// OCR 块过滤时间窗(±3s):字幕/full 块按帧时间戳对齐(同时间基:相对会话
22+ /// 起点 ms)。字幕持续出现,相邻帧块可跨帧关联;窗太宽会引入他帧噪声。
23+ const STRUCTURE_OCR_WINDOW_MS : u64 = 3_000 ;
2324
2425/// 捕获结果摘要(命令层事件/前端提示数据源)。
2526#[ derive( Debug , Clone , Copy , Default , serde:: Serialize ) ]
2627#[ serde( rename_all = "camelCase" ) ]
2728pub struct CaptureSummary {
28- /// 有图屏数(参与扫描的屏 )
29- pub screens_scanned : usize ,
29+ /// 扫描参考图数(full/ 归档帧 )
30+ pub images_scanned : usize ,
3031 /// 实际入库结构图数
3132 pub captured : usize ,
3233 /// 自动桶预算是否耗尽(提前终止)
@@ -35,67 +36,73 @@ pub struct CaptureSummary {
3536
3637/// 批量捕获主入口(编排 + IO;纯逻辑均在 structure_detect/layout_analyzer)。
3738///
38- /// @ai-context: now_ms 注入(文件名时间基;测试可控);每屏独立失败不阻断
39- /// (解码失败帧跳过、无候选屏跳过 ——诚实降级)。
39+ /// @ai-context: now_ms 注入(文件名时间基;测试可控);逐帧独立失败不阻断
40+ /// (解码失败帧跳过——诚实降级)。
4041pub fn capture_session_structures (
4142 db : & Db ,
4243 data_dir : & Path ,
4344 session_id : i64 ,
4445 now_ms : u64 ,
4546) -> Result < CaptureSummary > {
4647 let images_dir = data_dir. join ( "session-images" ) . join ( session_id. to_string ( ) ) ;
48+ // ① 参考图集时间戳(全量;与图集画廊 list_session_images 同源)
49+ let timestamps = crate :: screens:: list_full_image_timestamps ( & images_dir) ;
50+ // ② OCR 块(过滤上下文数据源:字幕 bbox + full 块置信度)
4751 let blocks = db. list_ocr_blocks ( session_id) ?;
48- // ① 屏构建(复用屏卡体系;只保留有归档图的屏——无图无法裁剪)
49- let mut screens = crate :: screens:: build_screens ( & blocks, Some ( & images_dir) ) ;
50- screens. retain ( |s| s. image_ref . is_some ( ) ) ;
5152 let mut store = crate :: structure_store:: StructureImageStore :: new ( images_dir. clone ( ) ) ?;
52- let mut summary = CaptureSummary { screens_scanned : screens . len ( ) , ..Default :: default ( ) } ;
53+ let mut summary = CaptureSummary { images_scanned : timestamps . len ( ) , ..Default :: default ( ) } ;
5354 let mut seq = 0u64 ;
54- for screen in & screens {
55- // ② 屏时间窗内归档帧采样(≤8 均匀)
56- let candidates = frame_candidates (
57- & images_dir,
58- screen. first_seen_ms ,
59- screen. last_seen_ms ,
60- MAX_FRAMES_PER_SCREEN ,
61- ) ;
62- // ③ 解码(失败帧跳过)+ 网格
63- let decoded = decode_candidates ( & images_dir, & candidates) ;
64- if decoded. is_empty ( ) {
65- continue ;
66- }
67- let grids: Vec < ( u64 , FrameGrid ) > =
68- decoded. iter ( ) . map ( |( ts, img) | ( * ts, grid_from_rgb ( img) ) ) . collect ( ) ;
69- // ④ 选优帧(边缘能量最高;全零能量 → 该屏跳过)
70- let Some ( idx) = pick_sharpest ( & grids) else { continue } ;
71- let grid = & grids[ idx] . 1 ;
72- // ⑤ 版面分析 + 区域过滤(结构三类直收 + Text/Image 门控)
73- let kept = filter_structure_regions ( & analyze_layout ( grid) , grid) ;
74- if kept. is_empty ( ) {
75- continue ;
76- }
77- let frame = & decoded[ idx] . 1 ;
55+ for ts in timestamps {
56+ // ③ 解码(失败帧跳过,不阻断后续帧)
57+ let Some ( frame) = decode_frame ( & images_dir, ts) else { continue } ;
58+ let grid = grid_from_rgb ( & frame) ;
59+ // ④ 版面分析(网格坐标)→ 帧坐标区域(顺序保持,索引对应)
60+ let analyzed = analyze_layout ( & grid) ;
7861 let frame_regions = crate :: frame_features:: regions_to_frame (
79- & kept ,
62+ & analyzed ,
8063 grid. cols ,
8164 grid. rows ,
8265 frame. width ( ) ,
8366 frame. height ( ) ,
8467 ) ;
85- // ⑥ 逐区域裁剪 → 存储 → 记录
86- for r in frame_regions {
68+ if frame_regions. is_empty ( ) {
69+ continue ;
70+ }
71+ // ⑤ 过滤上下文(时间窗 ±3s)——逐帧组装(按帧 ts 对齐)
72+ let ctx = build_filter_context ( & blocks, ts) ;
73+ // ⑥ 逐区域判定 → 裁剪 → 存储 → 记录
74+ for ( i, r) in frame_regions. iter ( ) . enumerate ( ) {
8775 // 预算前置检查(save_auto 内部同检查——前置拦截避免单区域编码/IO
8876 // 错误被误判为预算耗尽而终止整个会话:审查修复)
8977 if store. remaining_budget ( ) == 0 {
9078 summary. budget_exhausted = true ;
9179 return Ok ( summary) ;
9280 }
93- let Some ( crop) = crop_region ( frame, & r) else { continue } ;
81+ // 图结构似然(仅 Image/Text 需要;结构三类 0.0 占位)
82+ let diagram_score = match r. kind {
83+ RegionKind :: Image | RegionKind :: Text => {
84+ let gr = & analyzed[ i] ;
85+ diagram_likeness ( & grid, gr. x , gr. y , gr. x + gr. w - 1 , gr. y + gr. h - 1 )
86+ }
87+ _ => 0.0 ,
88+ } ;
89+ // 四层判定(L3 位置 → L0 字幕重叠 → L1 类型 → L2 OCR 置信度)
90+ if !decide_keep (
91+ r. kind ,
92+ r,
93+ diagram_score,
94+ & ctx,
95+ frame. width ( ) ,
96+ frame. height ( ) ,
97+ ) {
98+ continue ;
99+ }
100+ let Some ( crop) = crop_region ( & frame, r) else { continue } ;
94101 let bgra = rgb_to_bgra ( & crop) ;
95102 seq += 1 ;
96103 match store. save_auto ( now_ms + seq, & bgra, crop. width ( ) , crop. height ( ) ) {
97104 Ok ( outcome) => {
98- // 去重命中(跨屏同图 /重跑):不重复插记录——同图只留一份
105+ // 去重命中(跨帧同图 /重跑):不重复插记录——同图只留一份
99106 if !outcome. is_new {
100107 continue ;
101108 }
@@ -104,10 +111,10 @@ pub fn capture_session_structures(
104111 & crate :: db_structures:: StructureImageRecord {
105112 id : 0 ,
106113 session_id,
107- screen_id : screen . screen_id ,
114+ screen_id : None ,
108115 kind : kind_name ( r. kind ) ,
109- bbox : bbox_json ( & r) ,
110- source_ts_ms : grids [ idx ] . 0 ,
116+ bbox : bbox_json ( r) ,
117+ source_ts_ms : ts ,
111118 crop_path : outcome. rel ,
112119 source : "auto" . to_string ( ) ,
113120 created_at : now_ms + seq,
@@ -125,39 +132,41 @@ pub fn capture_session_structures(
125132 Ok ( summary)
126133}
127134
128- /// 屏时间窗内归档帧候选(纯函数):时间戳 ∈ [first, last];超出采样上限
129- /// 均匀抽样(bound 解码成本);目录缺失/空 → 空列表。
130- fn frame_candidates (
131- images_dir : & Path ,
132- first_ms : u64 ,
133- last_ms : u64 ,
134- max : usize ,
135- ) -> Vec < u64 > {
136- let in_window: Vec < u64 > = crate :: screens:: list_full_image_timestamps ( images_dir)
137- . into_iter ( )
138- . filter ( |t| * t >= first_ms && * t <= last_ms)
139- . collect ( ) ;
140- if in_window. len ( ) <= max {
141- return in_window;
135+ /// 过滤上下文组装(纯函数):OCR 块 → 帧时间窗 ±STRUCTURE_OCR_WINDOW_MS 内信号。
136+ ///
137+ /// @ai-context: 字幕块(region="subtitle")进 subtitle_boxes(L0 重叠拦截);
138+ /// 其余(region="full")进 full_blocks 带置信度(L2 反向信号)。
139+ /// 无 bbox 的旧数据块跳过(无法参与重叠判定——L3 位置兜底)。
140+ fn build_filter_context ( blocks : & [ SessionOcrBlock ] , ts : u64 ) -> StructureFilterContext {
141+ let ( lo, hi) = (
142+ ts. saturating_sub ( STRUCTURE_OCR_WINDOW_MS ) ,
143+ ts + STRUCTURE_OCR_WINDOW_MS ,
144+ ) ;
145+ let mut ctx = StructureFilterContext {
146+ subtitle_boxes : Vec :: new ( ) ,
147+ full_blocks : Vec :: new ( ) ,
148+ } ;
149+ for b in blocks {
150+ if b. timestamp_ms < lo || b. timestamp_ms > hi {
151+ continue ;
152+ }
153+ if let Some ( bb) = b. bbox {
154+ if b. region == "subtitle" {
155+ ctx. subtitle_boxes . push ( bb) ;
156+ } else {
157+ ctx. full_blocks . push ( ( bb, b. score ) ) ;
158+ }
159+ }
142160 }
143- // 均匀抽样(首尾必含——首帧=屏开始画面、尾帧=最近画面,都值得参与选优)
144- let last = in_window. len ( ) - 1 ;
145- ( 0 ..max)
146- . map ( |i| in_window[ ( i as f32 * last as f32 / ( max - 1 ) as f32 ) . round ( ) as usize ] )
147- . collect ( )
161+ ctx
148162}
149163
150- /// 解码候选帧(纯 IO):webp → RGB;解码失败帧跳过(不阻断)。
151- fn decode_candidates ( images_dir : & Path , timestamps : & [ u64 ] ) -> Vec < ( u64 , image:: RgbImage ) > {
152- timestamps
153- . iter ( )
154- . filter_map ( |t| {
155- let img = image:: open ( images_dir. join ( "full" ) . join ( format ! ( "{t}.webp" ) ) )
156- . ok ( ) ?
157- . to_rgb8 ( ) ;
158- Some ( ( * t, img) )
159- } )
160- . collect ( )
164+ /// 解码单帧(纯 IO):webp → RGB;失败 → None(调用方跳过该帧)。
165+ fn decode_frame ( images_dir : & Path , ts : u64 ) -> Option < image:: RgbImage > {
166+ image:: open ( images_dir. join ( "full" ) . join ( format ! ( "{ts}.webp" ) ) )
167+ . ok ( ) ?
168+ . to_rgb8 ( )
169+ . into ( )
161170}
162171
163172/// RGB 帧 → 版面网格(纯函数;与 frame_features::grid_from_bgra 同口径:
0 commit comments