|
| 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