# OCR 推理管线指南 (OCR Pipeline Guide)

本文记录原生 OCR 引擎（`plugins/ppocr/android/OcrModule.kt`，PP-OCRv6 / ONNX Runtime）的推理管线设计、一次「金额丢小数点」问题的根因与修复，以及**与 PaddleOCR 官方管线的差异和裁剪理由**。同时沉淀一套可复用的「OCR 识别错误诊断方法论」。

> 配套：构建/编译陷阱见 `android-build-guide.md`；Modal/键盘见 `modal-keyboard-guide.md`。

---

## 0. TL;DR

- 管线：`整图 cap 长边 → det 检测文本框 → 逐框 crop → rec 识别 → CTC 解码`，全部在 Kotlin 原生侧，JS 层只透传 base64。
- **det 后处理必须用「连通域法」**（4-连通 BFS + 官方 unclip 外扩 + box_score_fast 过滤）。**禁止回退到旧的「水平/垂直投影切行」**——它会把基线上的孤立小数点切到文本框外，导致 `¥143.97` 被识别成 `¥14397`。
- 怀疑「模型识别能力不足」之前，**先用官方 PaddleOCR 跑同一对模型做对照**：官方能认出来 → 是我们的预处理/后处理代码问题；官方也认不出 → 才是模型边界。这条纪律避免误判。
- 我们用 Python + onnxruntime **逐行镜像** Kotlin 管线做离线验证（同一对 onnx 模型 + 真实截图），算法正确性在移植到 Kotlin 前就锁死，原生改动只是「翻译」。

---

## 1. 管线总览与参数

实现位置：`plugins/ppocr/android/OcrModule.kt`。常量在 `companion object`。

| 环节 | 函数 | 关键参数 | 说明 |
|---|---|---|---|
| 整图缩放 | `capLongEdge` | `CAP_LONG_EDGE=3000` | 仅超大图降采样防 OOM；手机截图(≤2400)不预压，使 rec 的 crop 源为高清原图 |
| det resize | `resizeForDet` | `DET_LIMIT_MAX_SIDE=1600`，half-up，**无条件对齐 32** | 旧值 960 会让小数点仅 1–2px 而糊掉；无条件对齐 32 防止非法尺寸喂入 det 触发广播报错 |
| det 前处理 | `preprocessDet` | mean/std=ImageNet，**通道序 BGR** | `(px shr (8*c)) and 0xFF`（c=0→B），对齐官方训练约定 |
| det 后处理 | `dbPostprocess` | `DET_THRESH=0.2`、`BOX_THRESH=0.6`、`UNCLIP_RATIO=1.5`、`MIN_SIZE=5` | 连通域法，见 §3 |
| crop | `cropBox` | paddingX=4/paddingY=2，**h≥1.5w 时 rot90** | 竖排文本旋转，对齐官方 `get_rotate_crop_image` |
| rec 前处理 | `preprocessRec` | 高 `REC_IMAGE_HEIGHT=48`，宽 **ceil** 上限 `REC_MAX_WIDTH=1280`，**不 pad** | 旧宽上限 320 把长商户名压扁 5×+；不 pad 见 §6 |
| 解码 | `ctcGreedyDecode` | blank=0，去重，置信度=非 blank 步均值 | 与官方 `CTCLabelDecode` 逻辑一致 |

---

## 2. 丢小数点根因：投影切行把基线点切到框外

### 现象
招行一笔 `GOOGLE*ChatGPT` 交易，金额 `¥143.97` 被 OCR 识别成 `¥14397`；而同一页的 `交易地金额 21.19` 小数点却正常。

### 根因（已用官方同模型对照坐实）
旧的 `dbPostprocess` 用**水平投影切行**：一行活跃像素 ≥ `w/20` 才算文本行。小数点是基线上孤立的 1–2 像素宽，它所在那一行的水平投影**远低于阈值**，被判成「非文本行」→ `rowRange` 的 `y1` 停在数字主体底部，**基线行被排除** → `cropBox` 裁出的图**不含小数点** → rec 看不到点 → `14397`。

官方 PaddleOCR 的 det 后处理是**轮廓/连通域法**：连通域把「数字 + 基线点」归为同一个域，外接框天然含基线，所以点保住了。

> 关键证据：把旧管线切出的金额框在原图上**纵向下扩 10px** 再送 rec，小数点立刻以 0.99 置信度回来——证明点一直在二值图里，只是被切行排除在 crop 之外。

### 为什么前两轮误判（教训）
- 第一轮怀疑 **JPEG q60 / scaleDown 降采样**抹掉了点 → 用**原图**裁剪送 rec 仍 `14397`，证伪。
- 第二轮怀疑**模型能力边界**（大字号下点太小，rec 不认）→ 用**官方 PaddleOCR 跑同一对模型**，官方正确输出 `¥143.97`，**反转结论**：模型完全能认，问题 100% 在我们的后处理代码。

**纪律**：在归咎「模型不行」或「图像预处理丢信息」之前，先做官方同模型对照。它能一锤定音区分「模型边界」与「我们的代码 bug」。

---

## 3. 修复：连通域法 + 官方 unclip + box_score

`dbPostprocess` 重写为（与官方轮廓法等价、但纯 Kotlin 零新依赖）：

1. **二值化**：`sig > DET_THRESH(0.2)`，同时保留 `sigMap`（概率图）供后续 box_score 用。状态栏/导航栏（顶/底 8%）清零 + 垂直干扰线（列活跃 >30%）清零保留。
2. **连通域标记**：4-连通、**迭代 BFS**（用 `ArrayDeque`，防递归栈溢出），每域记录 bbox `(x0,y0,x1,y1)`、真实像素面积 `area`、域内 `sig` 累加 `sum`。
3. **官方 unclip 外扩**：`d = area × UNCLIP_RATIO(1.5) / 周长`，`d` 下限 1，bbox 四向各扩 `d`（half-up 取整）。水平文本下这与官方多边形外扩在结果上几乎等价，把基线标点纳入框。
4. **过滤**：扩后短边 `< MIN_SIZE(5)` 丢弃；**box_score_fast** = `sum / area`（**域内文本像素 prob 均值**，见 §5 口径）`< BOX_THRESH(0.6)` 丢弃。
5. 映射回原图坐标（×ratioX/ratioY）。

> 旧的「水平投影切行 + 垂直投影切列 + 临时纵向膨胀」已**整体删除**，不要复活。

---

## 4. 诊断方法论（可复用范式）

当某张图 OCR 结果错误时，按以下顺序定位，**不要直接改 Kotlin 猜**：

1. **Python 复现管线**：用 onnxruntime 加载同一对 `ppocrv6_det.onnx` / `ppocrv6_rec.onnx` + `ppocrv6_dict.txt`，**逐行镜像** Kotlin 的缩放/前处理/后处理/crop/解码（脚本见仓库根 `.ocr-test/`，未跟踪，验证完可删）。先确认能复现错误。
2. **逐环节隔离**：对出错文本框做对照实验——分别用「降采样图 / 原图」裁剪、放开 rec 宽上限、纵向放大 crop 等，看哪一步改变结果，缩小嫌疑环。
3. **官方同模型对照**：`pip install paddleocr` 后用官方 `PaddleOCR(... engine="onnxruntime")` 跑同一张图。**官方能认 → 我们代码 bug；官方也认不出 → 模型边界。** 这一步是判读的分水岭。
4. **dump 逐时间步 logits**：对 rec 输出看「出错字符位置」那个时间步，目标字符类的概率是多少、argmax 是什么。能区分「crop 没含该字符（概率≈0）」还是「含了但模型判错」。
5. **修复先在 Python 验证台改对、端到端跑通**，再 1:1 翻译到 Kotlin——原生端跑不了 onnx 离线验证，靠这层把算法正确性前置锁死。

---

## 5. 跨语言复现的两个对齐坑

### 5.1 舍入语义必须一致
det 尺寸对齐 32 时，Python 内置 `round` 是**银行家舍入**（22.5→22），Kotlin/Java `Math.round` 是 **half-up**（22.5→23）。两者会让 det 输入差 32 像素列。若验证台用银行家、Kotlin 用 half-up，则 Kotlin 实际跑的是**验证台没验证过的尺寸**，成为盲点。

**做法**：验证台显式用 half-up（`int(math.floor(x+0.5))`）镜像 `Math.round`，使两边输入逐像素一致；Kotlin 侧 `Math.ceil` 注意只有 `double` 重载（传 Float 编译失败，需 `toDouble()`，详见 `android-build-guide.md §5.3`）。

### 5.2 box_score_fast 的口径
`box_score_fast` 是**文本像素（连通域 mask 内）的 prob 均值**，**不是整个 bbox 矩形的均值**。后者含大量 0 值背景，会把分数稀释到阈值以下、把所有框误杀（实测曾出现「连通域 24 个、保留 0 个」）。用 `scipy.ndimage.mean(sig, labels)` / Kotlin 的 `sum/area` 取域内均值才对。

---

## 6. 与官方差异的裁剪清单（为什么不做某些官方能力）

两条标尺贯穿全部判断：① 我们是 **Android ONNX CPU**，无 GPU、无 OpenCV、包体/内存敏感；② 输入几乎全是 **App 截图**——水平文本、无旋转、无镜头畸变、无弯曲。官方很多重型能力是为「拍照/扫描/任意文档」的宽分布设计的，对我们的窄分布是 over-engineering。

| 项 | 类别 | 不做/保留现状的主因 | 重新评估的触发条件 |
|---|---|---|---|
| 透视矫正 `warpPerspective` | P2 | 截图无透视；需引 OpenCV(~30MB so) 或自写 warp | 上「拍照记账」 |
| det 通道序 BGR | **已做** | 零成本对齐训练约定，顺手做了 | — |
| 顶/底 8% + 列 30% 硬清零 | 保留 | 截图利>弊；无贴边/JS 预裁需求 | 出现贴边误删 或 JS 预裁状态栏 |
| `textline_orientation` 行方向模型 | 不做 | 截图恒 0°，每框白跑一次 ONNX 推理 | 上「拍照记账」 |
| `doc_orientation` + `UVDoc` 去弯 | 不做 | 截图无旋转/弯曲；移动端最贵的两个模型 | 产品转向拍纸质账本（基本不会） |
| rec 右侧 pad 到 320 | 不做 | 逐行 + ONNX 动态宽，pad 只增算力无收益（**少数「与官方不同但我们更对」的点**） | 改做 rec 批推理（大概率不做） |
| pyclipper 精确多边形膨胀 | 不做 | 水平文本 bbox 近似等价；需引 Clipper2 新依赖 | 支持倾斜/拍照文本 |

> 一句话：官方「完整管线」为宽分布 + 强算力调；我们为窄分布 + 弱算力，正确做法是**按输入分布裁掉用不上的重型环节**，只移植真正提升截图质量的部分（连通域取框、提分辨率、rec 放宽、参数对齐）。

---

## 7. 参数对照表（旧 → 新 → 官方）

| 参数 | 旧 | 新 | 官方（OCR 管线生效值） |
|---|---|---|---|
| 整图缩放 | 短边压 720 | 长边 cap 3000 | 不降采样（max_side_limit=4000） |
| det 长边 | 960 / floor | **1600** / half-up / 无条件对齐 32 | 不降采样 / round 对齐 32 |
| det thresh | 0.3 | **0.2** | 0.3（模型 yml 0.2） |
| det box_thresh | 无 | **0.6** | 0.6（模型 yml 0.45） |
| det unclip_ratio | 无（临时 0.4 行高） | **1.5**（area×ratio/周长） | 1.5 |
| det 取框法 | 投影切行 | **连通域** | 轮廓 findContours+unclip |
| rec 宽 cap | 320 / floor | **1280** / ceil | 3200 / ceil |
| det 通道序 | RGB | **BGR** | BGR |
| crop 竖排 | 无 | **rot90** | rot90 + 透视 |

> 移动端折中说明：det 长边取 1600（非官方全分辨率）是为 CPU 性能；box_thresh 取 0.6（非模型 yml 的 0.45）是实测 0.45 仅多收噪声无额外召回。两者都是「在官方语义下向移动端性能倾斜」的有意识折中，非疏漏。
