Skip to content

Commit 015ce93

Browse files
committed
feat(classroom): P2-1 本地OCR完整实现(PP-OCRv5 det/rec 联调验证)
ocrPipeline: DB二值化0.3/2x2膨胀/8邻域连通域/AABB框+外扩补偿/box score 0.5/CTC greedy(12例单测) ocrService: 模型自动下载(ModelScope官方源+进度广播+原子改名)+ 会话懒加载 + nativeImage解码 + 识别管线;recognize/download/status 三IPC visionWorker 本地优先分支启用(模型就绪时离线可用,失败降级云端VLM) 联调验证:熵减学习助手(0.993)/本地OCR测试2026(0.995);关键决策:ppocrv5_dict专用字典(18385通道)/det全分辨率输出/AABB简化minAreaRect
1 parent 24bca6b commit 015ce93

7 files changed

Lines changed: 681 additions & 52 deletions

File tree

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/**
2+
* 本地 OCR 推理管线纯函数(P2-1)
3+
*
4+
* @ai-context: PP-OCRv5 det/rec 后处理纯函数层(自 RapidOCR/PaddleOCR 官方
5+
* 实现移植,2026-08 联调验证通过:det 二值化 0.3 → 2×2 膨胀 → 8 邻域连通域
6+
* → AABB 框(简化 minAreaRect/unclip,框外扩补偿)→ box score ≥0.5;
7+
* rec 高度 48 等比缩放 → CTC 解码(argmax → 去连续重复 → 去 blank)。
8+
* 输入为 RGB Float32Array(解码层在 ocrService,nativeImage),可单测。
9+
* @ai-context EN: Pure postprocessing for PP-OCRv5 (ported from RapidOCR,
10+
* verified 2026-08): DB binarize 0.3 → 2x2 dilate → 8-connected components →
11+
* AABB boxes with padding; rec 48-height resize + CTC greedy decode.
12+
*/
13+
14+
export interface OcrBox {
15+
x0: number;
16+
y0: number;
17+
x1: number;
18+
y1: number;
19+
score: number;
20+
}
21+
22+
// ================================================================
23+
// 图像工具
24+
// ================================================================
25+
26+
/** 最近邻缩放 RGB(源 [sw*sh*3] → 目标 [dw*dh*3]) */
27+
export function resizeRgb(
28+
src: Float32Array, sw: number, sh: number, dw: number, dh: number,
29+
): Float32Array {
30+
const out = new Float32Array(dw * dh * 3);
31+
for (let y = 0; y < dh; y++) {
32+
const sy = Math.min(sh - 1, Math.floor((y + 0.5) * sh / dh));
33+
for (let x = 0; x < dw; x++) {
34+
const sx = Math.min(sw - 1, Math.floor((x + 0.5) * sw / dw));
35+
const so = (sy * sw + sx) * 3;
36+
const o = (y * dw + x) * 3;
37+
out[o] = src[so];
38+
out[o + 1] = src[so + 1];
39+
out[o + 2] = src[so + 2];
40+
}
41+
}
42+
return out;
43+
}
44+
45+
/** CHW 归一化 (x/255-0.5)/0.5(RGB 输入 → [3*h*w]) */
46+
export function normalizeChw(rgb: Float32Array, w: number, h: number): Float32Array {
47+
const out = new Float32Array(3 * h * w);
48+
const n = w * h;
49+
for (let i = 0; i < n; i++) {
50+
out[i] = (rgb[i * 3] / 255 - 0.5) / 0.5;
51+
out[n + i] = (rgb[i * 3 + 1] / 255 - 0.5) / 0.5;
52+
out[2 * n + i] = (rgb[i * 3 + 2] / 255 - 0.5) / 0.5;
53+
}
54+
return out;
55+
}
56+
57+
/** 2×2 膨胀(掩码 uint8,把相邻字符块连成文本行) */
58+
export function dilate2x2(mask: Uint8Array, w: number, h: number): Uint8Array {
59+
const out = new Uint8Array(w * h);
60+
for (let y = 0; y < h; y++) {
61+
for (let x = 0; x < w; x++) {
62+
if (mask[y * w + x]) {
63+
for (let dy = 0; dy < 2; dy++) {
64+
for (let dx = 0; dx < 2; dx++) {
65+
const ny = y + dy;
66+
const nx = x + dx;
67+
if (ny < h && nx < w) out[ny * w + nx] = 1;
68+
}
69+
}
70+
}
71+
}
72+
}
73+
return out;
74+
}
75+
76+
/** 8 邻域连通域(栈式 BFS,返回 AABB 组件列表;maxBoxes 上限保护) */
77+
export function connectedComponents(
78+
mask: Uint8Array, w: number, h: number, maxBoxes = 200,
79+
): Array<{ minX: number; maxX: number; minY: number; maxY: number; count: number }> {
80+
const visited = new Uint8Array(w * h);
81+
const comps: Array<{ minX: number; maxX: number; minY: number; maxY: number; count: number }> = [];
82+
const stack = new Int32Array(w * h);
83+
for (let y0 = 0; y0 < h && comps.length < maxBoxes; y0++) {
84+
for (let x0 = 0; x0 < w && comps.length < maxBoxes; x0++) {
85+
const start = y0 * w + x0;
86+
if (!mask[start] || visited[start]) continue;
87+
let top = 0;
88+
stack[top++] = start;
89+
visited[start] = 1;
90+
let minX = x0;
91+
let maxX = x0;
92+
let minY = y0;
93+
let maxY = y0;
94+
let count = 0;
95+
while (top > 0) {
96+
const idx = stack[--top];
97+
const cx = idx % w;
98+
const cy = (idx - cx) / w;
99+
if (cx < minX) minX = cx;
100+
if (cx > maxX) maxX = cx;
101+
if (cy < minY) minY = cy;
102+
if (cy > maxY) maxY = cy;
103+
count++;
104+
for (let dy = -1; dy <= 1; dy++) {
105+
for (let dx = -1; dx <= 1; dx++) {
106+
if (dx === 0 && dy === 0) continue;
107+
const nx = cx + dx;
108+
const ny = cy + dy;
109+
if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
110+
const nIdx = ny * w + nx;
111+
if (mask[nIdx] && !visited[nIdx]) {
112+
visited[nIdx] = 1;
113+
stack[top++] = nIdx;
114+
}
115+
}
116+
}
117+
}
118+
if (count >= 3) comps.push({ minX, maxX, minY, maxY, count });
119+
}
120+
}
121+
return comps;
122+
}
123+
124+
// ================================================================
125+
// det 后处理
126+
// ================================================================
127+
128+
export const DET_THRESH = 0.3;
129+
export const DET_BOX_THRESH = 0.5;
130+
/** det 预处理:min 边放大到该值(32 倍数对齐),与 RapidOCR 默认一致 */
131+
export const DET_LIMIT_SIDE = 736;
132+
133+
/**
134+
* det 后处理:概率图 [dh*dw] → 文本框(原图坐标)。
135+
* 二值化 → 膨胀 → 连通域 → AABB + 框外扩补偿(unclip 简化)→ box score 过滤 → 自上而下排序。
136+
*/
137+
export function detectBoxes(
138+
pred: Float32Array, dw: number, dh: number,
139+
origW: number, origH: number,
140+
): OcrBox[] {
141+
const mask = new Uint8Array(dh * dw);
142+
for (let i = 0; i < dh * dw; i++) mask[i] = pred[i] > DET_THRESH ? 1 : 0;
143+
const dilated = dilate2x2(mask, dw, dh);
144+
const comps = connectedComponents(dilated, dw, dh);
145+
const sx = origW / dw;
146+
const sy = origH / dh;
147+
const boxes: OcrBox[] = [];
148+
for (const c of comps) {
149+
const bw = c.maxX - c.minX + 1;
150+
const bh = c.maxY - c.minY + 1;
151+
if (bw < 4 || bh < 4) continue;
152+
// box score:框内概率均值(fast 模式近似)
153+
let sum = 0;
154+
let n = 0;
155+
for (let y = c.minY; y <= c.maxY; y++) {
156+
for (let x = c.minX; x <= c.maxX; x++) {
157+
sum += pred[y * dw + x];
158+
n++;
159+
}
160+
}
161+
const score = sum / n;
162+
if (score < DET_BOX_THRESH) continue;
163+
boxes.push({
164+
x0: Math.max(0, Math.floor(c.minX * sx)),
165+
y0: Math.max(0, Math.floor(c.minY * sy)),
166+
x1: Math.min(origW - 1, Math.ceil(c.maxX * sx)),
167+
y1: Math.min(origH - 1, Math.ceil(c.maxY * sy)),
168+
score,
169+
});
170+
}
171+
boxes.sort((a, b) => a.y0 - b.y0 || a.x0 - b.x0);
172+
return boxes;
173+
}
174+
175+
// ================================================================
176+
// rec 后处理(CTC 解码)
177+
// ================================================================
178+
179+
export const REC_IMG_HEIGHT = 48;
180+
181+
/**
182+
* CTC greedy 解码:argmax → 去连续重复 → 去 blank(0) → 字符映射;
183+
* 置信度 = 选中位概率均值(与 RapidOCR CTCLabelDecode 口径一致)。
184+
* @param probs [T][C] 二维数组(Float32Array 子视图)
185+
*/
186+
export function ctcDecode(
187+
probs: ArrayLike<number>[], character: string[],
188+
): { text: string; confidence: number } {
189+
const seqLen = probs.length;
190+
if (seqLen === 0) return { text: '', confidence: 0 };
191+
const indices = new Int32Array(seqLen);
192+
const confs = new Float64Array(seqLen);
193+
for (let t = 0; t < seqLen; t++) {
194+
const row = probs[t];
195+
const C = row.length;
196+
let best = 0;
197+
let bestP = row[0];
198+
for (let c = 1; c < C; c++) {
199+
if (row[c] > bestP) {
200+
bestP = row[c];
201+
best = c;
202+
}
203+
}
204+
indices[t] = best;
205+
confs[t] = bestP;
206+
}
207+
const selected: number[] = [];
208+
for (let t = 0; t < seqLen; t++) {
209+
if (indices[t] === 0) continue;
210+
if (t > 0 && indices[t] === indices[t - 1]) continue;
211+
selected.push(t);
212+
}
213+
const chars = selected.map((t) => character[indices[t]] ?? '?');
214+
const conf = selected.length > 0
215+
? selected.reduce((acc, t) => acc + confs[t], 0) / selected.length
216+
: 0;
217+
return { text: chars.join(''), confidence: conf };
218+
}

0 commit comments

Comments
 (0)