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及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
This commit is contained in:
fengmengqi
2026-07-28 20:57:37 +08:00
parent bf04400852
commit 6767dd538a
222 changed files with 8970 additions and 2500 deletions
+175 -113
View File
@@ -24,6 +24,8 @@ import kotlinx.coroutines.launch
import java.io.BufferedReader
import java.io.InputStreamReader
import java.nio.FloatBuffer
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock
import kotlin.math.max
import kotlin.math.min
@@ -64,6 +66,8 @@ class OcrModule(private val context: ReactApplicationContext) :
/** 模型文件目录(filesystem 绝对路径)。非空时从该目录加载模型,否则回退到 assets。 */
@Volatile private var modelDir: String? = null
/** 初始化完成信号,ensureReady 可等待异步 initEngine 完成(最多等 15 秒)。 */
@Volatile private var initLatch = CountDownLatch(1)
override fun getName(): String = OCR_MODULE_NAME
@@ -115,9 +119,11 @@ class OcrModule(private val context: ReactApplicationContext) :
ortEnv = env
dictionary = dict
initialized = true
initLatch.countDown()
Log.i(OCR_MODULE_NAME, "PP-OCRv6 ONNX 模型加载成功(det+rec, dict=${dict.size}")
} catch (e: Exception) {
initFailed = true
initLatch.countDown()
Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e)
val dir = modelDir
if (dir != null) {
@@ -181,7 +187,7 @@ class OcrModule(private val context: ReactApplicationContext) :
promise.reject("DECODE_FAILED", "base64 解码失败")
return@launch
}
scaled = scaleDownForOcr(bitmap, OCR_MAX_SHORT_EDGE)
scaled = capLongEdge(bitmap, CAP_LONG_EDGE)
val blocks = runInference(scaled)
val text = blocks.joinToString("\n") { it.text }
promise.resolve(text)
@@ -213,7 +219,7 @@ class OcrModule(private val context: ReactApplicationContext) :
promise.reject("DECODE_FAILED", "base64 解码失败")
return@launch
}
scaled = scaleDownForOcr(bitmap, OCR_MAX_SHORT_EDGE)
scaled = capLongEdge(bitmap, CAP_LONG_EDGE)
val blocks = runInference(scaled)
// 序列化为 RN WritableArray
val arr = WritableNativeArray()
@@ -249,24 +255,38 @@ class OcrModule(private val context: ReactApplicationContext) :
/** 设置模型文件目录(绝对路径)。若引擎已初始化则释放并重新加载。 */
@ReactMethod
fun setModelDir(dir: String, promise: Promise) {
modelDir = dir
if (initialized) {
release()
initFailed = false
scope.launch { initEngine() }
}
// 修复:expo-file-system 返回 file:// URI,需转为文件系统绝对路径
val newDir = dir.removePrefix("file://")
// 修复:目录未变且引擎已就绪时跳过重新加载,避免冗余 release 导致竞态
if (newDir == modelDir && initialized) {
promise.resolve(true)
return
}
modelDir = newDir
// 修复:无论之前是成功还是失败,设置新目录后都应重新加载模型
if (initialized || initFailed) {
release()
initFailed = false
initLatch = CountDownLatch(1)
scope.launch { initEngine() }
}
promise.resolve(true)
}
// ============== 推理流水线 ==============
private fun ensureReady() {
if (!initialized && !initFailed) initEngine()
// 修复:异步 initEngine 进行中时等待完成(最多 15 秒),而非立即抛异常
if (!initialized && !initFailed) {
initLatch.await(15, TimeUnit.SECONDS)
}
if (!initialized) throw IllegalStateException("OCR 引擎未就绪(模型未加载,${if (initFailed) "初始化失败" else "加载中"}")
}
/** 完整推理:det 检测文本框 → 对每个框 rec 识别 → 返回带坐标的文本块。 */
private fun runInference(bitmap: Bitmap): List<OcrBlock> {
val totalStartTime = System.currentTimeMillis()
Log.i(OCR_MODULE_NAME, "runInference 开始: bitmap 尺寸 = ${bitmap.width}x${bitmap.height}")
val det = detSession ?: run {
Log.e(OCR_MODULE_NAME, "detSession 为空,放弃推理")
@@ -282,8 +302,14 @@ class OcrModule(private val context: ReactApplicationContext) :
var detOutputs: OrtSession.Result? = null
val results = mutableListOf<OcrBlock>()
var detTime = 0L
var postTime = 0L
var recTime = 0L
var boxCount = 0
try {
// ---- 1. 文本检测(DB----
val detStartTime = System.currentTimeMillis()
resized = resizeForDet(bitmap, DET_LIMIT_MAX_SIDE)
Log.i(OCR_MODULE_NAME, "det 图像缩放后尺寸 = ${resized.width}x${resized.height}")
val ratioX = bitmap.width.toFloat() / resized.width
@@ -292,18 +318,30 @@ class OcrModule(private val context: ReactApplicationContext) :
val detInput = preprocessDet(resized)
detInputTensor = OnnxTensor.createTensor(recEnv, FloatBuffer.wrap(detInput.data), longArrayOf(1L, 3L, detInput.h.toLong(), detInput.w.toLong()))
val detInputs = mapOf("x" to detInputTensor)
val detRunStart = System.currentTimeMillis()
detOutputs = det.run(detInputs)
detTime = System.currentTimeMillis() - detRunStart
@Suppress("UNCHECKED_CAST")
val detProb = (detOutputs[0].value as Array<Array<Array<FloatArray>>>)[0][0] // [H,W]
Log.i(OCR_MODULE_NAME, "det 推理完成,概率图尺寸 = ${detProb.size}x${detProb[0].size}")
Log.i(OCR_MODULE_NAME, "det 推理完成 (耗时: ${detTime}ms),概率图尺寸 = ${detProb.size}x${detProb[0].size}")
// DB 后处理:threshold → 轮廓 → 最小外接矩形
val postStart = System.currentTimeMillis()
val boxes = dbPostprocess(detProb, detInput.h, detInput.w, ratioX, ratioY)
Log.i(OCR_MODULE_NAME, "dbPostprocess 后处理完成,检测到文本框数量 = ${boxes.size}")
postTime = System.currentTimeMillis() - postStart
boxCount = boxes.size
Log.i(OCR_MODULE_NAME, "dbPostprocess 后处理完成 (耗时: ${postTime}ms),检测到文本框数量 = ${boxCount}")
if (boxes.isEmpty()) return emptyList()
if (boxes.isEmpty()) {
val totalTime = System.currentTimeMillis() - totalStartTime
Log.i(OCR_MODULE_NAME, "PP-OCRv6 推理完成(无文本): 总耗时 = ${totalTime}ms (det模型推理 = ${detTime}ms, det后处理 = ${postTime}ms)")
return emptyList()
}
// ---- 2. 文本识别(CRNN+CTC----
val recStart = System.currentTimeMillis()
for ((idx, box) in boxes.withIndex()) {
var crop: Bitmap? = null
var recInputTensor: OnnxTensor? = null
@@ -341,6 +379,7 @@ class OcrModule(private val context: ReactApplicationContext) :
recOutputs?.close()
}
}
recTime = System.currentTimeMillis() - recStart
} finally {
if (resized !== bitmap) {
resized?.recycle()
@@ -349,6 +388,12 @@ class OcrModule(private val context: ReactApplicationContext) :
detOutputs?.close()
}
val totalTime = System.currentTimeMillis() - totalStartTime
Log.i(
OCR_MODULE_NAME,
"PP-OCRv6 ONNX 推理整体完成: 总耗时 = ${totalTime}ms (det模型推理 = ${detTime}ms, det后处理 = ${postTime}ms, rec文本识别 = ${recTime}ms, 识别文本框数 = ${results.size}/${boxCount})"
)
return results
}
@@ -370,8 +415,8 @@ class OcrModule(private val context: ReactApplicationContext) :
for (y in 0 until h) {
for (x in 0 until w) {
val px = pixels[y * w + x]
// 提取 R/G/Bc=0→R, 1→G, 2→B
val channelVal = (px shr (16 - 8 * c)) and 0xFF
// 对齐官方训练通道序 BGRc=0→B, 1→G, 2→R);mean/std 数值按 BGR 顺序配套
val channelVal = (px shr (8 * c)) and 0xFF
data[c * w * h + y * w + x] = (channelVal / 255.0f - mean) / std
}
}
@@ -383,9 +428,10 @@ class OcrModule(private val context: ReactApplicationContext) :
private fun preprocessRec(bmp: Bitmap): TensorData {
var w = bmp.width
val h = bmp.height
// resize 到高度 48,宽度等比缩放
var resizedW = (w.toFloat() / h * REC_IMAGE_HEIGHT).toInt()
// 宽度上限,避免单行过长爆显存
// resize 到高度 48,宽度等比缩放(ceil 取整对齐官方,避免右缘字符因 floor 被裁)
// 注:java.lang.Math.ceil 仅有 double 重载,故用 toDouble()Math.round 有 float 重载所以无需)
var resizedW = Math.ceil(w.toDouble() / h * REC_IMAGE_HEIGHT).toInt()
// 宽度上限,避免单行过长爆显存(官方 cap=3200,移动端折中 REC_MAX_WIDTH=1280
resizedW = min(resizedW, REC_MAX_WIDTH)
resizedW = max(resizedW, 1)
val resized = if (resizedW == w && h == REC_IMAGE_HEIGHT) bmp
@@ -411,9 +457,9 @@ class OcrModule(private val context: ReactApplicationContext) :
return TensorData(data, w, REC_IMAGE_HEIGHT)
}
// ============== DB 后处理(简化版 ==============
// 参考 PaddleOCR db_postprocesssigmoid → threshold → 连通域 → 最小外接矩形
// 这里用轻量实现:逐像素阈值化后用投影法估框,对常见单/多行账单足够
// ============== DB 后处理(连通域法,对齐 PaddleOCR 官方 ==============
// sigmoid → 阈值二值化4-连通域标记每域 bbox 按官方 unclip 公式外扩 → box_score_fast 过滤。
// 取代旧的「水平/垂直投影法」:投影法会把基线孤立小数点切到行外导致金额丢点(¥143.97→¥14397
/**
* DB 后处理:sigmoid + 阈值 0.3 → 二值图 → 连通域外接矩形。
@@ -438,6 +484,7 @@ class OcrModule(private val context: ReactApplicationContext) :
val endY = (h * 0.92).toInt()
val binMask = Array(h) { IntArray(w) }
val sigMap = Array(h) { FloatArray(w) } // 概率图(sigmoid 后),供连通域 box_score_fast 取文本像素均值
var activeCountTotal = 0
for (y in 0 until h) {
for (x in 0 until w) {
@@ -451,6 +498,7 @@ class OcrModule(private val context: ReactApplicationContext) :
} else {
raw
}
sigMap[y][x] = sig
val isActive = if (sig > DET_THRESH) 1 else 0
binMask[y][x] = isActive
if (isActive == 1) activeCountTotal++
@@ -475,79 +523,76 @@ class OcrModule(private val context: ReactApplicationContext) :
}
Log.i(OCR_MODULE_NAME, "垂直线噪清理完成: 清理了 $clearedColsCount / $w")
// 水平投影:按行找文本行
val rowHits = IntArray(h)
// 连通域标记(4-连通,迭代 BFS 防栈溢出):取代旧的「水平投影切行 + 垂直投影切列」。
// 投影法会把基线/顶线上的孤立标点(小数点等,该行水平投影 < w/20)切到行外,
// 导致 crop 不含标点、rec 漏识(金额 ¥143.97 → ¥14397 的根因,已用官方同模型对照坐实)。
// 连通域天然把「数字 + 基线点」归为同一域,根治丢点;并正确处理断裂字符与多栏版面。
val labels = Array(h) { IntArray(w) }
var nComp = 0
val compX0 = mutableListOf<Int>()
val compY0 = mutableListOf<Int>()
val compX1 = mutableListOf<Int>()
val compY1 = mutableListOf<Int>()
val compArea = mutableListOf<Int>()
val compSum = mutableListOf<Float>() // 域内文本像素 sig 累加,供 box_score_fast 取均值
val stack = ArrayDeque<IntArray>()
for (y in 0 until h) {
var sum = 0
for (x in 0 until w) sum += binMask[y][x]
rowHits[y] = sum
}
val minRowWidth = max(1, w / 20) // 一行至少要有这么多像素才算文本
val rowRanges = mutableListOf<IntArray>()
var inLine = false
var lineStart = 0
for (y in 0 until h) {
val isText = rowHits[y] >= minRowWidth
if (isText && !inLine) { inLine = true; lineStart = y }
else if (!isText && inLine) {
rowRanges.add(intArrayOf(lineStart, y - 1))
inLine = false
for (x in 0 until w) {
if (binMask[y][x] == 1 && labels[y][x] == 0) {
nComp++
val id = nComp
var x0 = x; var y0 = y; var x1 = x; var y1 = y
var area = 0; var sum = 0f
stack.clear()
stack.addLast(intArrayOf(x, y))
labels[y][x] = id
while (stack.isNotEmpty()) {
val p = stack.removeLast()
val px = p[0]; val py = p[1]
area++
sum += sigMap[py][px]
if (px < x0) x0 = px; if (px > x1) x1 = px
if (py < y0) y0 = py; if (py > y1) y1 = py
if (px > 0 && binMask[py][px - 1] == 1 && labels[py][px - 1] == 0) { labels[py][px - 1] = id; stack.addLast(intArrayOf(px - 1, py)) }
if (px < w - 1 && binMask[py][px + 1] == 1 && labels[py][px + 1] == 0) { labels[py][px + 1] = id; stack.addLast(intArrayOf(px + 1, py)) }
if (py > 0 && binMask[py - 1][px] == 1 && labels[py - 1][px] == 0) { labels[py - 1][px] = id; stack.addLast(intArrayOf(px, py - 1)) }
if (py < h - 1 && binMask[py + 1][px] == 1 && labels[py + 1][px] == 0) { labels[py + 1][px] = id; stack.addLast(intArrayOf(px, py + 1)) }
}
compX0.add(x0); compY0.add(y0); compX1.add(x1); compY1.add(y1)
compArea.add(area); compSum.add(sum)
}
}
}
if (inLine) rowRanges.add(intArrayOf(lineStart, h - 1))
Log.i(OCR_MODULE_NAME, "dbPostprocess 水平分割完成,找到行Ranges数 = ${rowRanges.size}")
Log.i(OCR_MODULE_NAME, "dbPostprocess 连通域标记完成,连通域数 = $nComp")
val boxes = mutableListOf<List<FloatArray>>()
// 对每行做垂直投影切列(账单每行通常是连续一段或多段)
for ((y0, y1) in rowRanges.map { it[0] to it[1] }) {
val colHits = IntArray(w)
for (x in 0 until w) {
var sum = 0
for (y in y0..y1) sum += binMask[y][x]
colHits[x] = sum
}
val minColHeight = max(1, (y1 - y0 + 1) / 12)
var inSeg = false
var segStart = 0
var segs = mutableListOf<IntArray>()
for (x in 0 until w) {
val isText = colHits[x] >= minColHeight
if (isText && !inSeg) { inSeg = true; segStart = x }
else if (!isText && inSeg) {
// 合并间隔很近的段
if (segs.isNotEmpty() && segStart - segs.last()[1] < DET_MERGE_GAP) {
segs.last()[1] = x - 1
} else {
segs.add(intArrayOf(segStart, x - 1))
}
inSeg = false
}
}
if (inSeg) {
if (segs.isNotEmpty() && (w - 1) - segs.last()[1] < DET_MERGE_GAP) {
segs.last()[1] = w - 1
} else {
segs.add(intArrayOf(segStart, w - 1))
}
}
for ((x0, x1) in segs.map { it[0] to it[1] }) {
// 过滤过小的框
val boxW = x1 - x0 + 1
val boxH = y1 - y0 + 1
if (boxW < 4 || boxH < 2) continue
// 映射回原图坐标(4 个角点)
val fx0 = x0 * ratioX
val fx1 = x1 * ratioX
val fy0 = y0 * ratioY
val fy1 = y1 * ratioY
boxes.add(listOf(
floatArrayOf(fx0, fy0),
floatArrayOf(fx1, fy0),
floatArrayOf(fx1, fy1),
floatArrayOf(fx0, fy1),
))
}
var dropSmall = 0; var dropScore = 0
for (i in 0 until nComp) {
val bx0 = compX0[i]; val by0 = compY0[i]; val bx1 = compX1[i]; val by1 = compY1[i]
val bw = bx1 - bx0 + 1; val bh = by1 - by0 + 1
val area = compArea[i]
val perim = 2 * (bw + bh)
// 官方 unclip 公式:distance = 连通域面积 × 比例 / 周长(水平文本下≈各向同性外扩,把基线标点纳入框)
var d = if (perim > 0) area * UNCLIP_RATIO / perim else 0f
if (d < 1f) d = 1f
val nx0 = max(0, Math.round(bx0 - d))
val ny0 = max(0, Math.round(by0 - d))
val nx1 = min(w - 1, Math.round(bx1 + d))
val ny1 = min(h - 1, Math.round(by1 + d))
if ((nx1 - nx0 + 1) < MIN_SIZE || (ny1 - ny0 + 1) < MIN_SIZE) { dropSmall++; continue }
// box_score_fast:域内文本像素 prob 均值(非整 bbox 均值,否则被背景稀释而误杀)
val score = compSum[i] / area
if (score < BOX_THRESH) { dropScore++; continue }
val fx0 = nx0 * ratioX; val fy0 = ny0 * ratioY
val fx1 = nx1 * ratioX; val fy1 = ny1 * ratioY
boxes.add(listOf(
floatArrayOf(fx0, fy0),
floatArrayOf(fx1, fy0),
floatArrayOf(fx1, fy1),
floatArrayOf(fx0, fy1),
))
}
Log.i(OCR_MODULE_NAME, "dbPostprocess 取框完成: 扩后min_size丢=$dropSmall, box_score丢=$dropScore, 保留=${boxes.size}")
return boxes
}
@@ -606,18 +651,29 @@ class OcrModule(private val context: ReactApplicationContext) :
val w = maxX - minX
val h = maxY - minY
if (w < 2 || h < 2) return null
return Bitmap.createBitmap(bmp, minX, minY, w, h)
var crop = Bitmap.createBitmap(bmp, minX, minY, w, h)
// 竖排文本旋转 90°(对齐官方 get_rotate_crop_image:高/宽 ≥ 1.5 视为竖排,
// 否则 rec 模型按水平行识别会乱码;账单侧边竖排小字借此可识别)
if (crop.height >= crop.width * 1.5f) {
val m = android.graphics.Matrix()
m.postRotate(90f)
val rotated = Bitmap.createBitmap(crop, 0, 0, crop.width, crop.height, m, true)
if (rotated !== crop) crop.recycle()
crop = rotated
}
return crop
}
private fun resizeForDet(bmp: Bitmap, maxSide: Int): Bitmap {
val ratio = maxSide.toFloat() / max(bmp.width, bmp.height)
if (ratio >= 1f) return bmp
val newW = (bmp.width * ratio).toInt()
val newH = (bmp.height * ratio).toInt()
// 确保尺寸是 32 的倍数(det 模型下采样要求)
val alignedW = (newW / 32) * 32
val alignedH = (newH / 32) * 32
if (alignedW < 32 || alignedH < 32) return bmp
// 长边上限 + 无条件对齐 32(det 下采样要求),half-up 取整对齐官方 round。
// 关键修复:旧实现 ratio>=1 时直接 return 不对齐,若整图未预压且非 32 倍数会喂入非法尺寸,
// 导致 det 内部特征图广播报错;现无条件对齐,且 cap 后图通常 ≤32 倍数时仅轻微取整。
val ratioC = minOf(1f, maxSide.toFloat() / max(bmp.width, bmp.height))
val newW = Math.round(bmp.width * ratioC)
val newH = Math.round(bmp.height * ratioC)
val alignedW = max(32, Math.round(newW / 32f) * 32)
val alignedH = max(32, Math.round(newH / 32f) * 32)
if (alignedW == bmp.width && alignedH == bmp.height) return bmp
return Bitmap.createScaledBitmap(bmp, alignedW, alignedH, true)
}
@@ -637,17 +693,18 @@ class OcrModule(private val context: ReactApplicationContext) :
}
/**
* 短边压缩到 maxShortEdge(参考 AutoAccounting scaleDownForOcr
* 像素量比 1440p 减少约 75%,识别速度大幅提升
* 整图长边上限降采样:仅当长边超过 cap 才按比例缩小(half-up 取整),否则原样返回
* 使 rec 的 crop 源尽量高清(手机截图通常不触发),仅防超大图 OOM
* 取代旧的「短边压 720」——旧法把整图先砍掉 75% 像素,叠加 det resize 后小数点等细笔画被严重淡化。
*/
private fun scaleDownForOcr(bitmap: Bitmap, maxShortEdge: Int): Bitmap {
private fun capLongEdge(bitmap: Bitmap, capLong: Int): Bitmap {
val width = bitmap.width
val height = bitmap.height
val shortEdge = minOf(width, height)
if (shortEdge <= maxShortEdge) return bitmap
val scale = maxShortEdge.toFloat() / shortEdge
val newWidth = (width * scale).toInt()
val newHeight = (height * scale).toInt()
val longEdge = maxOf(width, height)
if (longEdge <= capLong) return bitmap
val scale = capLong.toFloat() / longEdge
val newWidth = Math.round(width * scale)
val newHeight = Math.round(height * scale)
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
}
@@ -683,18 +740,23 @@ class OcrModule(private val context: ReactApplicationContext) :
private data class TensorData(val data: FloatArray, val w: Int, val h: Int)
companion object {
/** OCR 最大短边(参考 AutoAccounting OCR_MAX_SHORT_EDGE)。 */
private const val OCR_MAX_SHORT_EDGE = 720
/** det resize 最大边(PaddleOCR limit_max_side_len 默认值)。 */
private const val DET_LIMIT_MAX_SIDE = 960
/** DB 二值化阈值。 */
private const val DET_THRESH = 0.3f
/** 投影法合并相邻文本段的间隔(像素)。 */
private const val DET_MERGE_GAP = 10
/** 整图长边上限:仅超大图降采样防 OOM;手机截图(≤2400)不预压,使 rec 的 crop 源为高清原图。
* 对齐官方「只放大不缩小」语义的移动端折中(官方 max_side_limit=4000)。 */
private const val CAP_LONG_EDGE = 3000
/** det 输入长边上限(官方 OCR 管线不降采样;移动端为性能折中取 1600,旧值 960 会让小数点仅 1-2px 而糊掉)。 */
private const val DET_LIMIT_MAX_SIDE = 1600
/** DB 二值化阈值(对齐官方模型 inference.yml=0.2,对细笔画/小数点更敏感;噪声框由 BOX_THRESH 兜底)。 */
private const val DET_THRESH = 0.2f
/** box_score_fast 过滤阈值:连通域文本像素 prob 均值低于此值视为噪声框(对齐官方 OCR 管线=0.6)。 */
private const val BOX_THRESH = 0.6f
/** 官方 unclip 外扩比例:distance = 连通域面积 × 比例 / 周长。 */
private const val UNCLIP_RATIO = 1.5f
/** 外扩后文本框短边下限(像素),小于此值丢弃(对齐官方 min_size=5)。 */
private const val MIN_SIZE = 5
/** rec 固定图像高度。 */
private const val REC_IMAGE_HEIGHT = 48
/** rec 单行最大宽度。 */
private const val REC_MAX_WIDTH = 320
/** rec 单行最大宽度(官方 cap=3200,移动端折中 1280;旧值 320 会把长商户名水平压扁 5×+)。 */
private const val REC_MAX_WIDTH = 1280
/** assets 中的模型/字典文件名。 */
private const val ASSET_DET_MODEL = "ppocrv6_det.onnx"
private const val ASSET_REC_MODEL = "ppocrv6_rec.onnx"