Skip to content

Commit 7251abc

Browse files
committed
feat(structures): v0.10.2 结构图重构:参考图集分析+四层过滤
1 parent cf50ab6 commit 7251abc

10 files changed

Lines changed: 528 additions & 309 deletions

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

Lines changed: 2 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ use std::sync::Arc;
1212
use std::time::Instant;
1313

1414
use serde::Serialize;
15-
use tauri::Emitter;
1615
use tauri::State;
1716

1817
use crate::commands::AppState;
@@ -127,44 +126,12 @@ pub async fn stop_live_session(state: State<'_, AppState>) -> Result<Option<i64>
127126
// 无结构区域 → 内部降级跳过——run_refine 已有完整降级链)
128127
if let Some(sid) = stopped {
129128
trigger_auto_refine(&state, sid);
130-
// v0.7.7(REQ-182):停止后自动结构图捕获(非线性结构"图像即产物";
131-
// 无屏/无图/版面空 → 内部降级跳过;去重幂等可重跑)
132-
trigger_auto_capture_structures(&state, sid);
129+
// v0.10.2:停止后不再自动捕获结构图(会话 33 实测 50%+ 误收字幕条)
130+
// ——改为图库「分析参考图集」手动触发(structure_capture 新管线)
133131
}
134132
Ok(stopped)
135133
}
136134

137-
/// 停止后自动结构图捕获触发(v0.7.7 REQ-182)。
138-
///
139-
/// @ai-context: 与 trigger_auto_refine 并列的停止后批处理——纯本地规则管线
140-
/// (版面分析 + 启发式过滤),无模型依赖;失败仅留日志不阻断
141-
/// (捕获是增强非主链路)。
142-
fn trigger_auto_capture_structures(state: &AppState, session_id: i64) {
143-
let state: AppState = (*state).clone();
144-
tauri::async_runtime::spawn_blocking(move || {
145-
let now = std::time::SystemTime::now()
146-
.duration_since(std::time::UNIX_EPOCH)
147-
.map(|d| d.as_millis() as u64)
148-
.unwrap_or(0);
149-
match crate::structure_capture::capture_session_structures(
150-
&state.db,
151-
&state.data_dir,
152-
session_id,
153-
now,
154-
) {
155-
Ok(summary) if summary.captured > 0 => {
156-
eprintln!(
157-
"[Structures] 会话 {} 停止:自动捕获 {} 张结构图(扫描 {} 屏)",
158-
session_id, summary.captured, summary.screens_scanned
159-
);
160-
let _ = state.app.emit("session:structures-updated", &summary);
161-
}
162-
Ok(_) => {} // 无结构区域/无图:静默跳过(常态)
163-
Err(e) => eprintln!("[Structures] 会话 {} 自动捕获失败: {}", session_id, e),
164-
}
165-
});
166-
}
167-
168135
/// 停止后自动结构精修触发(v0.7.3 REQ-159)。
169136
///
170137
/// @ai-context: 前置检查:会话存在 region_kind=table/formula 的 OCR 块才触发

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
//! 结构图命令层(REQ-182/183/184 / v0.7.7):批量捕获 / 手动框选 / 列表 / 删除。
1+
//! 结构图命令层(REQ-182/183/184 / v0.7.7;v0.10.2 重构):参考图集分析 / 手动框选 / 列表 / 删除。
22
//!
33
//! @ai-context: IPC 安全边界:全部入参校验(session_id>0、归一化坐标 0-1、
44
//! 最小尺寸);文件访问限定会话图片目录(data_dir/session-images/<id>,
55
//! AGENTS.md 安全红线);事件 session:structures-updated 驱动前端图库刷新。
6-
//! @ai-context: 停止后自动触发与手动重跑共用同一管线(capture_session_structures)——
7-
//! 去重幂等,重跑不重复入库。
6+
//! @ai-context: v0.10.2 起取消停止后自动触发——改为前端「分析参考图集」手动
7+
//! 调用(capture_session_structures 直扫 full/ 参考帧 + 四层过滤);
8+
//! 去重幂等,重复分析不重复入库。
89
910
use tauri::Emitter;
1011
use tauri::State;
@@ -18,7 +19,7 @@ fn session_images_dir(data_dir: &std::path::Path, session_id: i64) -> std::path:
1819
data_dir.join("session-images").join(session_id.to_string())
1920
}
2021

21-
/// 批量捕获结构图(停止后自动触发 / 图库「重新捕获」重跑;幂等)。
22+
/// 批量捕获结构图(图库「分析参考图集」触发;v0.10.2 起不再自动触发;幂等)。
2223
#[tauri::command]
2324
pub async fn capture_session_structures(
2425
state: State<'_, AppState>,

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
//! 结构图记录存储(REQ-183 / v0.7.7):session_structure_images 表 CRUD。
22
//!
33
//! @ai-context: 非线性结构图的持久化元数据(文件在会话目录 struct/,本表记录
4-
//! 归属/类型/坐标/时间/来源)——图库 UI 与将来笔记消费(按屏
5-
//! screen_id 锚点)的数据源;文件删除由记录驱动。
4+
//! 归属/类型/坐标/时间/来源)——图库 UI 与将来笔记消费的数据源;
5+
//! 文件删除由记录驱动。
66
//! @ai-context: kind/source 为字符串枚举(不动 RegionKind——版面分析模块零
7-
//! 改动);bbox 为 JSON 帧坐标;旧会话(无 screen_id)NULL 降级。
7+
//! 改动);bbox 为 JSON 帧坐标;screen_id 仅手动框选/旧数据有值
8+
//! (v0.10.2 起自动捕获直扫参考图集,无屏概念 → NULL)。
89
910
use rusqlite::{params, Connection, OptionalExtension};
1011

@@ -17,7 +18,7 @@ use crate::error::Result;
1718
pub struct StructureImageRecord {
1819
pub id: i64,
1920
pub session_id: i64,
20-
/// 所属屏(NULL=旧数据无屏/手动无屏上下文)
21+
/// 所属屏(NULL=旧数据无屏/手动无屏上下文/v0.10.2 起自动捕获无屏关联)
2122
#[serde(default)]
2223
pub screen_id: Option<i64>,
2324
/// table/formula/code/image/manual

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

Lines changed: 93 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,33 @@
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
1413
use std::path::Path;
1514

1615
use crate::db::Db;
1716
use 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")]
2728
pub 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+
/// (解码失败帧跳过——诚实降级)。
4041
pub 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

Comments
 (0)