Skip to content

Commit 1536222

Browse files
committed
feat(ocr): 屏卡体系——屏聚合纯函数+bbox落库+在线屏分配
1 parent 98a8895 commit 1536222

29 files changed

Lines changed: 1423 additions & 27 deletions

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ fn ocr_block(ts: u64, text: &str, score: f32, region_kind: Option<&str>) -> Sess
1313
score,
1414
region: "full".into(),
1515
region_kind: region_kind.map(String::from),
16+
bbox: None,
17+
screen_id: None,
1618
}
1719
}
1820

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,11 @@ fn detail(segments: Vec<(&str, u64, u64)>, ocr: Vec<(&str, u64)>) -> SessionDeta
4242
score: 0.9,
4343
region: "full".into(),
4444
region_kind: None,
45+
bbox: None,
46+
screen_id: None,
4547
})
4648
.collect();
47-
SessionDetail { session, segments, ocr_blocks, events: Vec::new() }
49+
SessionDetail { session, segments, ocr_blocks, events: Vec::new(), screens: Vec::new() }
4850
}
4951

5052
/// 构造带事件的会话详情(REQ-108:真实信号消费测试)。
@@ -166,8 +168,8 @@ fn build_chapter_signals_detects_frame_switch_approximation() {
166168
speaker: None, confidence: None },
167169
];
168170
let ocr = vec![
169-
SessionOcrBlock { id: 0, session_id: 1, timestamp_ms: 100, text: "PPT-第一章".into(), score: 0.9, region: "full".into(), region_kind: None },
170-
SessionOcrBlock { id: 1, session_id: 1, timestamp_ms: 40100, text: "PPT-第二章".into(), score: 0.9, region: "full".into(), region_kind: None },
171+
SessionOcrBlock { id: 0, session_id: 1, timestamp_ms: 100, text: "PPT-第一章".into(), score: 0.9, region: "full".into(), region_kind: None, bbox: None, screen_id: None },
172+
SessionOcrBlock { id: 1, session_id: 1, timestamp_ms: 40100, text: "PPT-第二章".into(), score: 0.9, region: "full".into(), region_kind: None, bbox: None, screen_id: None },
171173
];
172174
// Act
173175
let signals = build_chapter_signals(&segments, &ocr);

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,11 @@ fn detail(segments: Vec<(&str, u64, u64)>, ocr: Vec<(&str, u64)>) -> SessionDeta
4545
score: 0.9,
4646
region: "full".into(),
4747
region_kind: None,
48+
bbox: None,
49+
screen_id: None,
4850
})
4951
.collect();
50-
SessionDetail { session, segments, ocr_blocks, events: Vec::new() }
52+
SessionDetail { session, segments, ocr_blocks, events: Vec::new(), screens: Vec::new() }
5153
}
5254

5355
fn keyframes() -> Vec<KeyFrameCandidate> {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ pub async fn analyze_session_command(
3939
.map(|p| ProfileKind::parse(&p))
4040
.or_else(|| session.profile.as_deref().map(ProfileKind::parse))
4141
.unwrap_or(ProfileKind::Lecture);
42-
let detail = crate::types::SessionDetail { session, segments, ocr_blocks, events };
42+
let detail = crate::types::SessionDetail { session, segments, ocr_blocks, events, screens: Vec::new() };
4343
// REQ-060:口语符号映射表(AppState 加载;JSON 校准生效)
4444
Ok(analyze_session_opt(&detail, kind, &state.symbol_normalize))
4545
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ pub async fn build_session_artifact(
4141
.unwrap_or(ProfileKind::Lecture);
4242
// 关键图候选(M6 投票输出;当前从图片库取时间戳近似——产物不阻断)
4343
let keyframes = crate::commands_images::keyframes_from_store(&state, session_id).unwrap_or_default();
44-
let detail = crate::types::SessionDetail { session, segments, ocr_blocks, events };
44+
let detail = crate::types::SessionDetail { session, segments, ocr_blocks, events, screens: Vec::new() };
4545
let artifact = build_artifact(kind, &detail, &keyframes);
4646
state
4747
.db

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ pub async fn list_sessions(
6565
.map_err(|e| e.to_string())
6666
}
6767

68-
/// 会话详情:会话 + 转写段 + OCR 块 + 信号事件(时间轴对齐,一次取全)。
68+
/// 会话详情:会话 + 转写段 + OCR 块 + 信号事件 + 画面要点屏(时间轴对齐,一次取全)。
6969
#[tauri::command]
7070
pub async fn get_session_detail(state: State<'_, AppState>, id: i64) -> Result<SessionDetail, String> {
7171
if id <= 0 {
@@ -80,7 +80,11 @@ pub async fn get_session_detail(state: State<'_, AppState>, id: i64) -> Result<S
8080
let ocr_blocks = state.db.list_ocr_blocks(id).map_err(|e| e.to_string())?;
8181
// REQ-108(v0.7.0 M1.5):信号事件随详情取全(章节检测真实信号消费)
8282
let events = state.db.list_events(id).map_err(|e| e.to_string())?;
83-
Ok(SessionDetail { session, segments, ocr_blocks, events })
83+
// v0.7.3(REQ-160,ADR-015):画面要点屏卡(新数据按 screen_id 分组、
84+
// 旧数据聚类兜底;图匹配失败/目录缺失不阻断——前端无缩略图降级)
85+
let images_dir = state.data_dir.join("session-images").join(id.to_string());
86+
let screens = crate::screens::build_screens(id, &ocr_blocks, &images_dir);
87+
Ok(SessionDetail { session, segments, ocr_blocks, events, screens })
8488
}
8589

8690
/// 删除会话(级联清理转写段与 OCR 块)。
@@ -143,6 +147,9 @@ pub async fn add_session_ocr_block(
143147
region: if region == "subtitle" { "subtitle" } else { "full" }.to_string(),
144148
// 外部追加无版面上下文,区域标注留空
145149
region_kind: None,
150+
// v0.7.3:外部追加无位置/屏上下文(None=旧口径,聚类兜底)
151+
bbox: None,
152+
screen_id: None,
146153
};
147154
state.db.add_ocr_block(&new).map(|b| b.id).map_err(|e| e.to_string())
148155
}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,21 @@ impl Db {
105105
"region_kind",
106106
"ALTER TABLE session_ocr_blocks ADD COLUMN region_kind TEXT",
107107
)?;
108+
// v0.7.3(REQ-156,ADR-015):旧库迁移——ocr_blocks 表补屏卡体系两列:
109+
// bbox=检测框 JSON {x,y,w,h}(帧坐标系)、screen_id=采集时分配的屏号
110+
// (NULL=旧数据无屏,视图层聚类兜底——ADR-015 决策 1/2)
111+
ensure_column(
112+
&conn,
113+
"session_ocr_blocks",
114+
"bbox",
115+
"ALTER TABLE session_ocr_blocks ADD COLUMN bbox TEXT",
116+
)?;
117+
ensure_column(
118+
&conn,
119+
"session_ocr_blocks",
120+
"screen_id",
121+
"ALTER TABLE session_ocr_blocks ADD COLUMN screen_id INTEGER",
122+
)?;
108123
// v0.7.0 M1(REQ-103):旧库迁移——segments 表补 volume 列
109124
// (段内平均音量,重点标注音量骤变信号输入;旧数据 None=未知)
110125
ensure_column(

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,12 @@ mod tests {
9494
db.add_ocr_block(&NewSessionOcrBlock {
9595
session_id: s.id, timestamp_ms: 1000, text: "梯度下降算法详解".into(),
9696
score: 0.9, region: "full".into(), region_kind: None,
97+
bbox: None, screen_id: None,
9798
}).unwrap();
9899
db.add_ocr_block(&NewSessionOcrBlock {
99100
session_id: s.id, timestamp_ms: 2000, text: "梯度下降(字幕)".into(),
100101
score: 0.9, region: "subtitle".into(), region_kind: None,
102+
bbox: None, screen_id: None,
101103
}).unwrap();
102104
// Act
103105
let hits = search_ocr_blocks(&db, "梯度", 10).unwrap();
@@ -124,6 +126,7 @@ mod tests {
124126
db.add_ocr_block(&NewSessionOcrBlock {
125127
session_id: s.id, timestamp_ms: 500, text: "Python 教程".into(),
126128
score: 0.9, region: "full".into(), region_kind: None,
129+
bbox: None, screen_id: None,
127130
}).unwrap();
128131
// Act & Assert
129132
assert_eq!(search_ocr_blocks(&db, "python", 10).unwrap().len(), 1);
@@ -141,6 +144,7 @@ mod tests {
141144
db.add_ocr_block(&NewSessionOcrBlock {
142145
session_id: s.id, timestamp_ms: i * 100, text: format!("关键词{}", i),
143146
score: 0.9, region: "full".into(), region_kind: None,
147+
bbox: None, screen_id: None,
144148
}).unwrap();
145149
}
146150
// Act & Assert:limit=3

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -185,13 +185,17 @@ impl Db {
185185
.map_err(Into::into)
186186
}
187187

188-
/// 追加一条 OCR 块(实时落库)。
188+
/// 追加一条 OCR 块(实时落库;v0.7.3 起双写 bbox/screen_id——REQ-156)。
189189
pub fn add_ocr_block(&self, new: &NewSessionOcrBlock) -> Result<SessionOcrBlock> {
190190
let conn = self.conn.lock().expect("db lock poisoned");
191+
// bbox 序列化为 JSON {x,y,w,h}(帧坐标系;None → NULL=旧口径)
192+
let bbox_json = new.bbox.map(|b| {
193+
serde_json::json!({ "x": b.x, "y": b.y, "w": b.w, "h": b.h }).to_string()
194+
});
191195
conn.execute(
192-
"INSERT INTO session_ocr_blocks (session_id, timestamp_ms, text, score, region, region_kind)
193-
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
194-
params![new.session_id, new.timestamp_ms, new.text, new.score, new.region, new.region_kind],
196+
"INSERT INTO session_ocr_blocks (session_id, timestamp_ms, text, score, region, region_kind, bbox, screen_id)
197+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
198+
params![new.session_id, new.timestamp_ms, new.text, new.score, new.region, new.region_kind, bbox_json, new.screen_id],
195199
)?;
196200
Ok(SessionOcrBlock {
197201
id: conn.last_insert_rowid(),
@@ -201,6 +205,8 @@ impl Db {
201205
score: new.score,
202206
region: new.region.clone(),
203207
region_kind: new.region_kind.clone(),
208+
bbox: new.bbox,
209+
screen_id: new.screen_id,
204210
})
205211
}
206212

@@ -275,11 +281,11 @@ impl Db {
275281
Ok(affected > 0)
276282
}
277283

278-
/// 列出会话全部 OCR 块(按时间轴升序)。
284+
/// 列出会话全部 OCR 块(按时间轴升序;v0.7.3 起含 bbox/screen_id 列)。
279285
pub fn list_ocr_blocks(&self, session_id: i64) -> Result<Vec<SessionOcrBlock>> {
280286
let conn = self.conn.lock().expect("db lock poisoned");
281287
let mut stmt = conn.prepare(
282-
"SELECT id, session_id, timestamp_ms, text, score, region, region_kind
288+
"SELECT id, session_id, timestamp_ms, text, score, region, region_kind, bbox, screen_id
283289
FROM session_ocr_blocks WHERE session_id = ?1 ORDER BY timestamp_ms ASC",
284290
)?;
285291
let rows = stmt.query_map(params![session_id], row_to_ocr_block)?;

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,16 @@ pub fn row_to_segment(row: &Row<'_>) -> rusqlite::Result<SessionSegment> {
6666
})
6767
}
6868

69-
/// 把 rusqlite 行映射为 SessionOcrBlock。
69+
/// 把 rusqlite 行映射为 SessionOcrBlock(9 列:原 7 列 + bbox JSON + screen_id)。
70+
///
71+
/// @ai-context: v0.7.3(REQ-156):bbox 为 JSON {x,y,w,h}(帧坐标系,null=旧数据);
72+
/// screen_id 为屏号(null=旧数据无屏——视图层聚类兜底)。
7073
pub fn row_to_ocr_block(row: &Row<'_>) -> rusqlite::Result<SessionOcrBlock> {
74+
let bbox_json: Option<String> = row.get(7)?;
75+
let bbox = match bbox_json.as_deref() {
76+
Some(json) => serde_json::from_str(json).unwrap_or(None), // 解析失败诚实降级 None
77+
None => None,
78+
};
7179
Ok(SessionOcrBlock {
7280
id: row.get(0)?,
7381
session_id: row.get(1)?,
@@ -76,6 +84,8 @@ pub fn row_to_ocr_block(row: &Row<'_>) -> rusqlite::Result<SessionOcrBlock> {
7684
score: row.get(4)?,
7785
region: row.get(5)?,
7886
region_kind: row.get(6)?,
87+
bbox,
88+
screen_id: row.get(8)?,
7989
})
8090
}
8191

0 commit comments

Comments
 (0)