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:
+175
-113
@@ -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/B(c=0→R, 1→G, 2→B)
|
||||
val channelVal = (px shr (16 - 8 * c)) and 0xFF
|
||||
// 对齐官方训练通道序 BGR(c=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_postprocess:sigmoid → 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"
|
||||
|
||||
Reference in New Issue
Block a user