DriftLedger/docs/ocr-pipeline-guide.md
fengmengqi 6767dd538a feat: 领域/组件/服务三层目录重构 + 微信无障碍绕过方案 + 新 UI 组件体系 + 账本增删改增强
### 架构重构:三层分层目录化
  - domain 拆分为 8 个子目录(core/pipeline/rules/finance/stats/taxonomy/transaction/platform)
  - components 拆分为 6 个子目录(form/ui/layout/account/category/stats/transaction)
  - services 拆分为 5 个子目录(automation/data/ocr/security),accessibilityParser 从 automationPipeline 提取
  - 新增 ruleConfig.ts — 规则配置唯一数据源(关键词/方向/OCR 模式),与业务逻辑解耦

  ### 微信账单抓取:节点混淆绕过(核心突破)
  - BillingAccessibilityService 重命名为 SelectToSpeakService,完整伪装为系统服务
  - 同时伪装包名+类名为 com.google.android.accessibility.selecttospeak,规避微信 8.0.52+ 白名单校验
  - Config Plugin 重写:8 个 kt 文件整体复制+package 正则替换+Manifest/import 联动
  - 支付宝/微信无障碍文本解析器全面增强(方向推断/账单分类提取/付款方式提取/容错)
  - 补充文档 accessibility-wechat-guide.md(伪装原理、踩坑全记录)

  ### 新 UI 组件体系
  - Toast:全局轻量 toast(Context Provider + 入场动画 + 操作按钮 + 自动消失)
  - ErrorBoundary:React class 错误边界(降级 UI + 重试)
  - EmptyState / ConfirmDialog / SegmentedControl / Skeleton / TimePicker
  - BottomSheet 重写:SafeAreaProvider 修复、手势下滑关闭、键盘响应式避让
  - FormModal 重构:拆出 FormFields 子组件(TextField/SelectField/DropdownField)
  - 新增 PeriodSwitcher、RangeStatsCard 独立组件

  ### 新 Hooks & 工具
  - useBottomInset — 统一底部安全区留白
  - useKeyboardAvoiding — 键盘高度响应式 hook(替代 translateY 方案)
  - sanitize.ts — 日志脱敏工具提取

  ### 账本增删改增强
  - 写锁增加代际计数器(lockGeneration),reset 后旧链 pending 任务自动跳过 set
  - 新增 restoreTransaction — 撤销删除(重新追加 raw 文本到 mobile.bean)
  - 删除交易时清除去重缓存(buildTxKeyFromRaw 重建去重键),支持「删了重记」
  - editTransaction/deleteTransaction 改用 dr-id 精确定位交易块(避免同名交易定位错误)
  - appendTransactionsBatch 改从存储直接读取,避免 zustand state 不一致

  ### OCR 原生模块增强
  - 异步 initEngine 增加 CountDownLatch 等待(最多 15s),解决竞态导致的「引擎未就绪」
  - setModelDir 增加去重判断 + file:// 前缀剥离,避免冗余 reload
  - 推理链路增加分阶段耗时日志(det 推理/det 后处理/rec 识别)
  - 图片缩放策略重命名(scaleDownForOcr → capLongEdge)

  ### OCR 模型按需下载
  - 移除了启动时自动下载 ~30MB OCR 模型的逻辑
  - 改为首次使用 OCR 时才触发下载

  ### 设置页重设计
  - ScrollView → SectionList 分组卡片布局(iOS 风格分组圆角行+右侧箭头)
  - 移除 Card 组件包装,直接使用独立分组头+底部关于卡片

  ### 首页优化
  - ScrollView → FlatList(ListHeaderComponent 承载净资产卡片+待办条)
  - 日期/金额格式化增加 locale 感知(zh/en)

  ### 通知管道增强
  - NotificationChannel MD5 去重改为批量淘汰(80% 阈值),替代逐个删除
  - 增加 debug 日志输出(过滤原因/包名)

  ### ESLint
  - 新增 eslint.config.mjs(typescript-eslint + react-hooks + react-native 规则集)
  - package.json 新增 lint/lint:fix 脚本,引入 5 个 devDependencies

  ### 文档
  - accessibility-wechat-guide.md — 微信无障碍伪装完整方案
  - modal-keyboard-guide.md — 弹窗键盘避让方案
  - ocr-pipeline-guide.md — OCR 三层层级管线
  - OCR及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
2026-07-28 20:57:37 +08:00

125 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 会让小数点仅 12px 而糊掉;无条件对齐 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` 才算文本行。小数点是基线上孤立的 12 像素宽,它所在那一行的水平投影**远低于阈值**,被判成「非文本行」→ `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` 下限 1bbox 四向各扩 `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.522Kotlin/Java `Math.round` **half-up**22.523)。两者会让 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 仅多收噪声无额外召回。两者都是「在官方语义下向移动端性能倾斜」的有意识折中,非疏漏。