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

10 KiB
Raw Blame History

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

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

配套:构建/编译陷阱见 android-build-guide.mdModal/键盘见 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=1600half-up无条件对齐 32 旧值 960 会让小数点仅 12px 而糊掉;无条件对齐 32 防止非法尺寸喂入 det 触发广播报错
det 前处理 preprocessDet mean/std=ImageNet通道序 BGR (px shr (8*c)) and 0xFFc=0→B对齐官方训练约定
det 后处理 dbPostprocess DET_THRESH=0.2BOX_THRESH=0.6UNCLIP_RATIO=1.5MIN_SIZE=5 连通域法,见 §3
crop cropBox paddingX=4/paddingY=2h≥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 像素宽,它所在那一行的水平投影远低于阈值,被判成「非文本行」→ rowRangey1 停在数字主体底部,基线行被排除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 四向各扩 dhalf-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→22Kotlin/Java Math.roundhalf-up22.5→23。两者会让 det 输入差 32 像素列。若验证台用银行家、Kotlin 用 half-up则 Kotlin 实际跑的是验证台没验证过的尺寸,成为盲点。

做法:验证台显式用 half-upint(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.5area×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 仅多收噪声无额外召回。两者都是「在官方语义下向移动端性能倾斜」的有意识折中,非疏漏。