DriftLedger/plugins/ppocr/android/OcrModule.kt
fengmengqi bf04400852 feat: OCR 模型按需下载 + 三层独立开关 + 日志持久化 + 原生浮层主题同步 + 文档重构
OCR 模型按需下载(P8 瘦身):
- 移除 plugins/ppocr/assets/ 内置模型(det 9.8MB + rec 21MB + dict),APK 减包 ~31MB
- 新增 modelDownloader.ts:优先从 HuggingFace/CDN 下载,兜底从 APK assets 拷贝
- OcrModule.kt 新增 setModelDir,支持从 filesystem 加载模型,回退 assets 兼容旧用户
- settingsStore 持久化 ocrModelVersion / ocrModelDir
- 自动化页集成模型状态检查与一键下载 UI

OCR 三层独立控制:
- OcrProcessorConfig 从单一 aiVisionEnabled 拆分为 layer1/2/3 三个独立开关
- 设置页可按层启停(L1 正则规则 / L2 本地 OCR / L3 AI Vision)
- OCR 处理增加耗时与字符数日志

日志系统升级:
- logger.ts 新增 LogFileBackend 抽象,支持磁盘持久化(按日期 app-YYYY-MM-DD.log)
- 新增 logBackend.ts(ExpoLogFileBackend)+ 日志中心页 settings/logs.tsx
- 日志中心:实时缓冲 + 历史文件、4 级过滤、Tag/关键词搜索、JSON 展开、分享导出、7 天过期清理
- _layout.tsx 启动时初始化文件后端 + 日志脱敏(验证码/卡号)

原生浮层 UI 主题同步(P6):
- 新增 floatingUiConfig.ts:JS 侧从 theme tokens + i18n 构建 FloatingUiConfig 推送原生
- 新增 FloatingUiConfigStore.kt:SharedPreferences 存储,三浮层组件读取
- FloatingBillView 重设计:颜色/文案走配置、新增币种 chip、金额校验改 BigDecimal
- FloatingHelper / FloatingTip 同步适配
- _layout.tsx 新增 FloatingUiConfigSyncer,主题/语言切换自动推送

UI 与组件增强:
- FormModal 新增 select/dropdown 控件、行内布局(row/flex)、联动回调 onValuesChange
- 新增 Touchable 通用触摸组件、AccountCreateModal 快速建账弹窗
- 信用卡页展示账单周期/到期还款日/本期应还/剩余可用额度,关联账户改下拉选择
- AI 设置页重做:OpenAI/Gemini/DeepSeek 预设 + 默认 URL/模型
- 引导页新增 Android 权限检查步骤(无障碍/通知/短信/存储/悬浮窗)

去重优化:
- 对手方匹配改为模糊包含(includes),双方均无对手方时判定低置信度重复
- DedupResult 新增 matchedItem 返回匹配对比项

文档重构:
- README.md 重写为入口索引(品牌更新 + 模块概览 + 文档导航表)
- 新增 AGENTS.md(AI 助手贡献指南)、docs/architecture.md(Mermaid 数据流/分层/OCR 级联图)
- 新增 docs/development.md(环境/命令/编码规范/测试/提交规范)、plugins/README.md
- UI 重设计文档(design spec + p1-p8)移入 docs/design/

其他:
- i18n 新增权限/信用卡详情/日志中心/AI 设置等翻译键
- ppocr Config Plugin 修复 import 注入去重;size-optimization 增强
- 新增测试:logger.test.ts、floating-ui-config.test.ts
2026-07-23 19:03:38 +08:00

706 lines
29 KiB
Kotlin
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.

package com.beancount.mobile.ppocr
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import android.util.Log
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.WritableNativeArray
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.module.annotations.ReactModule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.BufferedReader
import java.io.InputStreamReader
import java.nio.FloatBuffer
import java.util.concurrent.locks.ReentrantLock
import kotlin.math.max
import kotlin.math.min
/**
* PP-OCRv6 (ONNX Runtime) React Native Bridgeplan.md「3.4 Layer 2」+「决策 4 Config Plugin」
*
* 引擎ONNX Runtime跨平台、微软官方、Windows 友好),替代 NCNN 路线。
* 模型ppocrv6_det.onnx + ppocrv6_rec.onnxPP-OCRv6 small从 PaddlePaddle 官方 HuggingFace 下载)。
* 字典ppocrv6_dict.txtPP-OCRv6 多语言字典18708 字符rec 模型 18710 维输出 = 字典 + blank + 特殊位)。
*
* 流水线:
* 1. det文本检测bitmap → DB 后处理得到文本框
* 2. rec文本识别每个框 crop → resize 到 48px 高 → CTC 解码
*
* 性能优化(参考 AutoAccounting OcrProcessor.kt
* - 短边压缩到 720px像素量比 1440p 减少约 75%
* - CPU 执行(兼容性最稳,部分设备 GPU 会崩溃)
* - det 最大边限制 960PaddleOCR 默认 limit_max_side_len
*
* JS 层通过 NativeModules.PpOcr.recognizeText(base64) 调用。
*/
const val OCR_MODULE_NAME = "PpOcr"
@ReactModule(name = OCR_MODULE_NAME)
class OcrModule(private val context: ReactApplicationContext) :
ReactContextBaseJavaModule(context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val lock = ReentrantLock()
private var ortEnv: OrtEnvironment? = null
private var detSession: OrtSession? = null
private var recSession: OrtSession? = null
private var dictionary: List<String> = emptyList()
@Volatile private var initialized = false
@Volatile private var initFailed = false
/** 模型文件目录filesystem 绝对路径)。非空时从该目录加载模型,否则回退到 assets。 */
@Volatile private var modelDir: String? = null
override fun getName(): String = OCR_MODULE_NAME
override fun initialize() {
super.initialize()
// 异步加载模型,避免阻塞 RN 桥初始化
scope.launch { initEngine() }
}
/** 从 assets 或 filesystem 加载 det/rec ONNX 模型与字典。 */
private fun initEngine() {
lock.lock()
try {
if (initialized || initFailed) return
val env = OrtEnvironment.getEnvironment()
val opts = OrtSession.SessionOptions().apply {
// CPU 线程数2 是兼容性/性能的稳妥折中(高端机可调高)
setInterOpNumThreads(2)
setIntraOpNumThreads(2)
// 移动端关闭内存优化里的图优化级别过高(部分模型会崩)
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.BASIC_OPT)
}
val dir = modelDir
val (det, rec, dict) = if (dir != null) {
// 从 filesystem 加载P8模型按需下载到本地目录
val detPath = dir + java.io.File.separator + ASSET_DET_MODEL
val recPath = dir + java.io.File.separator + ASSET_REC_MODEL
val dictPath = dir + java.io.File.separator + ASSET_DICT
Log.i(OCR_MODULE_NAME, "从 filesystem 加载模型: det=$detPath, rec=$recPath")
Triple(
env.createSession(detPath, opts),
env.createSession(recPath, opts),
loadDictionaryFromFile(dictPath)
)
} else {
// 回退:从 assets 加载(兼容未迁移的老用户)
val detBytes = context.assets.open(ASSET_DET_MODEL).use { it.readBytes() }
val recBytes = context.assets.open(ASSET_REC_MODEL).use { it.readBytes() }
Log.i(OCR_MODULE_NAME, "从 assets 加载模型(兼容模式)")
Triple(
env.createSession(detBytes, opts),
env.createSession(recBytes, opts),
loadDictionaryFromAssets()
)
}
detSession = det
recSession = rec
ortEnv = env
dictionary = dict
initialized = true
Log.i(OCR_MODULE_NAME, "PP-OCRv6 ONNX 模型加载成功det+rec, dict=${dict.size}")
} catch (e: Exception) {
initFailed = true
Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e)
val dir = modelDir
if (dir != null) {
Log.e(OCR_MODULE_NAME, "请确认 $dir 下存在 $ASSET_DET_MODEL / $ASSET_REC_MODEL / $ASSET_DICT")
} else {
Log.e(OCR_MODULE_NAME, "请确认 assets 下存在 $ASSET_DET_MODEL / $ASSET_REC_MODEL / $ASSET_DICT")
}
} finally {
lock.unlock()
}
}
/** rec 推理用的 OrtEnvironment复用 ortEnv 单例)。 */
private val recEnv: OrtEnvironment? get() = ortEnv
/**
* 从 assets 加载 ppocrv6_dict.txt 字典。
*
* PaddleOCR CTC 约定:模型输出 logits 的 index 0 是 blank字符从 index 1 开始;
* 字典条目 dictionary[i] 对应模型输出 index i+1。解码时 dictIdx = argmaxIdx - 1。
* 字典本身不含 blank运行时固定用 index 0 作 blank见 ctcGreedyDecode
*/
private fun loadDictionaryFromAssets(): List<String> {
val words = mutableListOf<String>()
context.assets.open(ASSET_DICT).use { stream ->
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).useLines { lines ->
lines.forEach { line ->
// PaddleOCR 字典每行一个字符(去掉行尾换行)
words.add(line.trimEnd('\r', '\n'))
}
}
}
return words
}
/** 从 filesystem 路径加载字典文件。 */
private fun loadDictionaryFromFile(path: String): List<String> {
val words = mutableListOf<String>()
java.io.File(path).bufferedReader(Charsets.UTF_8).useLines { lines ->
lines.forEach { line ->
words.add(line.trimEnd('\r', '\n'))
}
}
return words
}
/**
* 识别图片文本。
* @param imageBase64 base64 编码的图片JPEG/PNG
* @return 识别出的纯文本(所有行用 \n 连接)
*/
@ReactMethod
fun recognizeText(imageBase64: String, promise: Promise) {
scope.launch {
var bitmap: Bitmap? = null
var scaled: Bitmap? = null
try {
ensureReady()
bitmap = decodeBase64(imageBase64)
if (bitmap == null) {
promise.reject("DECODE_FAILED", "base64 解码失败")
return@launch
}
scaled = scaleDownForOcr(bitmap, OCR_MAX_SHORT_EDGE)
val blocks = runInference(scaled)
val text = blocks.joinToString("\n") { it.text }
promise.resolve(text)
} catch (e: Exception) {
Log.e(OCR_MODULE_NAME, "recognizeText 异常: ${e.message}", e)
promise.reject("OCR_ERROR", e.message)
} finally {
bitmap?.recycle()
if (scaled !== bitmap) {
scaled?.recycle()
}
}
}
}
/**
* 识别并返回带坐标的文本块(用于复杂版面)。
* @return JSON 数组字符串:[{text, x, y, width, height, confidence}]
*/
@ReactMethod
fun recognizeTextBlocks(imageBase64: String, promise: Promise) {
scope.launch {
var bitmap: Bitmap? = null
var scaled: Bitmap? = null
try {
ensureReady()
bitmap = decodeBase64(imageBase64)
if (bitmap == null) {
promise.reject("DECODE_FAILED", "base64 解码失败")
return@launch
}
scaled = scaleDownForOcr(bitmap, OCR_MAX_SHORT_EDGE)
val blocks = runInference(scaled)
// 序列化为 RN WritableArray
val arr = WritableNativeArray()
for (b in blocks) {
val map: WritableMap = WritableNativeMap()
map.putString("text", b.text)
map.putDouble("x", b.x.toDouble())
map.putDouble("y", b.y.toDouble())
map.putDouble("width", b.width.toDouble())
map.putDouble("height", b.height.toDouble())
map.putDouble("confidence", b.confidence.toDouble())
arr.pushMap(map)
}
promise.resolve(arr)
} catch (e: Exception) {
Log.e(OCR_MODULE_NAME, "recognizeTextBlocks 异常: ${e.message}", e)
promise.reject("OCR_ERROR", e.message)
} finally {
bitmap?.recycle()
if (scaled !== bitmap) {
scaled?.recycle()
}
}
}
}
/** 引擎是否已就绪(模型加载完成)。 */
@ReactMethod
fun isReady(promise: Promise) {
promise.resolve(initialized)
}
/** 设置模型文件目录(绝对路径)。若引擎已初始化则释放并重新加载。 */
@ReactMethod
fun setModelDir(dir: String, promise: Promise) {
modelDir = dir
if (initialized) {
release()
initFailed = false
scope.launch { initEngine() }
}
promise.resolve(true)
}
// ============== 推理流水线 ==============
private fun ensureReady() {
if (!initialized && !initFailed) initEngine()
if (!initialized) throw IllegalStateException("OCR 引擎未就绪(模型未加载,${if (initFailed) "初始化失败" else "加载中"}")
}
/** 完整推理det 检测文本框 → 对每个框 rec 识别 → 返回带坐标的文本块。 */
private fun runInference(bitmap: Bitmap): List<OcrBlock> {
Log.i(OCR_MODULE_NAME, "runInference 开始: bitmap 尺寸 = ${bitmap.width}x${bitmap.height}")
val det = detSession ?: run {
Log.e(OCR_MODULE_NAME, "detSession 为空,放弃推理")
return emptyList()
}
val rec = recSession ?: run {
Log.e(OCR_MODULE_NAME, "recSession 为空,放弃推理")
return emptyList()
}
var resized: Bitmap? = null
var detInputTensor: OnnxTensor? = null
var detOutputs: OrtSession.Result? = null
val results = mutableListOf<OcrBlock>()
try {
// ---- 1. 文本检测DB----
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
val ratioY = bitmap.height.toFloat() / resized.height
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)
detOutputs = det.run(detInputs)
@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}")
// DB 后处理threshold → 轮廓 → 最小外接矩形
val boxes = dbPostprocess(detProb, detInput.h, detInput.w, ratioX, ratioY)
Log.i(OCR_MODULE_NAME, "dbPostprocess 后处理完成,检测到文本框数量 = ${boxes.size}")
if (boxes.isEmpty()) return emptyList()
// ---- 2. 文本识别CRNN+CTC----
for ((idx, box) in boxes.withIndex()) {
var crop: Bitmap? = null
var recInputTensor: OnnxTensor? = null
var recOutputs: OrtSession.Result? = null
try {
crop = cropBox(bitmap, box)
if (crop == null) {
Log.w(OCR_MODULE_NAME, "裁剪文本框失败 (index = $idx)")
continue
}
val recInput = preprocessRec(crop)
recInputTensor = OnnxTensor.createTensor(recEnv, FloatBuffer.wrap(recInput.data), longArrayOf(1L, 3L, REC_IMAGE_HEIGHT.toLong(), recInput.w.toLong()))
val recInputs = mapOf("x" to recInputTensor)
recOutputs = rec.run(recInputs)
// 输出 shape: [1, T, numClasses]
@Suppress("UNCHECKED_CAST")
val logits = (recOutputs[0].value as Array<Array<FloatArray>>)[0]
val (text, conf) = ctcGreedyDecode(logits)
val xs = box.map { it[0] }
val ys = box.map { it[1] }
val minX = (xs.minOrNull() ?: 0f).toInt()
val minY = (ys.minOrNull() ?: 0f).toInt()
val maxX = (xs.maxOrNull() ?: 0f).toInt()
val maxY = (ys.maxOrNull() ?: 0f).toInt()
val w = maxX - minX
val h = maxY - minY
Log.i(OCR_MODULE_NAME, "文本框 $idx 识别结果 = '$text', 坐标 = ($minX, $minY, $w, $h), 置信度 = $conf")
if (text.isNotEmpty()) {
results.add(OcrBlock(text, minX.toFloat(), minY.toFloat(), w.toFloat(), h.toFloat(), conf))
}
} finally {
crop?.recycle()
recInputTensor?.close()
recOutputs?.close()
}
}
} finally {
if (resized !== bitmap) {
resized?.recycle()
}
detInputTensor?.close()
detOutputs?.close()
}
return results
}
// ============== 前处理 ==============
/** det 前处理resize → BCHW → normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225])。 */
private fun preprocessDet(bmp: Bitmap): TensorData {
val w = bmp.width
val h = bmp.height
val pixels = IntArray(w * h)
bmp.getPixels(pixels, 0, w, 0, 0, w, h)
val data = FloatArray(3 * w * h)
// BCHW 顺序
val means = floatArrayOf(0.485f, 0.456f, 0.406f)
val stds = floatArrayOf(0.229f, 0.224f, 0.225f)
for (c in 0..2) {
val mean = means[c]
val std = stds[c]
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
data[c * w * h + y * w + x] = (channelVal / 255.0f - mean) / std
}
}
}
return TensorData(data, w, h)
}
/** rec 前处理crop → resize 到 48 高(保持宽高比)→ pad 到整除 4 → normalize。 */
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()
// 宽度上限,避免单行过长爆显存
resizedW = min(resizedW, REC_MAX_WIDTH)
resizedW = max(resizedW, 1)
val resized = if (resizedW == w && h == REC_IMAGE_HEIGHT) bmp
else Bitmap.createScaledBitmap(bmp, resizedW, REC_IMAGE_HEIGHT, true)
w = resized.width
val pixels = IntArray(w * REC_IMAGE_HEIGHT)
resized.getPixels(pixels, 0, w, 0, 0, w, REC_IMAGE_HEIGHT)
val data = FloatArray(3 * w * REC_IMAGE_HEIGHT)
val means = floatArrayOf(0.5f, 0.5f, 0.5f)
val stds = floatArrayOf(0.5f, 0.5f, 0.5f)
for (c in 0..2) {
val mean = means[c]
val std = stds[c]
for (y in 0 until REC_IMAGE_HEIGHT) {
for (x in 0 until w) {
val px = pixels[y * w + x]
val channelVal = (px shr (16 - 8 * c)) and 0xFF
data[c * w * REC_IMAGE_HEIGHT + y * w + x] = (channelVal / 255.0f - mean) / std
}
}
}
if (resized !== bmp) resized.recycle()
return TensorData(data, w, REC_IMAGE_HEIGHT)
}
// ============== DB 后处理(简化版) ==============
// 参考 PaddleOCR db_postprocesssigmoid → threshold → 连通域 → 最小外接矩形
// 这里用轻量实现:逐像素阈值化后用投影法估框,对常见单/多行账单足够。
/**
* DB 后处理sigmoid + 阈值 0.3 → 二值图 → 连通域外接矩形。
* 简化版:用水平投影切行 + 垂直投影切列,得到矩形框(对账单类版面够用)。
*/
private fun dbPostprocess(prob: Array<FloatArray>, h: Int, w: Int, ratioX: Float, ratioY: Float): List<List<FloatArray>> {
// 自动检测是否需要 Sigmoid
var minVal = Float.MAX_VALUE
var maxVal = Float.MIN_VALUE
for (y in 0 until h) {
for (x in 0 until w) {
val v = prob[y][x]
if (v < minVal) minVal = v
if (v > maxVal) maxVal = v
}
}
val needSigmoid = minVal < -0.05f || maxVal > 1.05f
Log.i(OCR_MODULE_NAME, "DB prob min=$minVal, max=$maxVal, needSigmoid=$needSigmoid")
// 过滤状态栏前8%和导航栏后8%),避免其上的干扰字符(如电池、时间、返回键)影响识别或导致行粘连
val startY = (h * 0.08).toInt()
val endY = (h * 0.92).toInt()
val binMask = Array(h) { IntArray(w) }
var activeCountTotal = 0
for (y in 0 until h) {
for (x in 0 until w) {
if (y < startY || y > endY) {
binMask[y][x] = 0
continue
}
val raw = prob[y][x]
val sig = if (needSigmoid) {
1.0f / (1.0f + Math.exp(-raw.toDouble()).toFloat())
} else {
raw
}
val isActive = if (sig > DET_THRESH) 1 else 0
binMask[y][x] = isActive
if (isActive == 1) activeCountTotal++
}
}
Log.i(OCR_MODULE_NAME, "二值化完成: 活跃像素 = $activeCountTotal / ${w * h}")
// 清理垂直干扰线(如滚动条、背景边框线):如果某列在文本有效区域内的活跃像素超过该区域高度的 30%,视为干扰列,整列清零
val maxColActive = ((endY - startY) * 0.3).toInt()
var clearedColsCount = 0
for (x in 0 until w) {
var colActive = 0
for (y in startY..endY) {
if (binMask[y][x] == 1) colActive++
}
if (colActive > maxColActive) {
clearedColsCount++
for (y in 0 until h) {
binMask[y][x] = 0
}
}
}
Log.i(OCR_MODULE_NAME, "垂直线噪清理完成: 清理了 $clearedColsCount / $w")
// 水平投影:按行找文本行
val rowHits = IntArray(h)
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
}
}
if (inLine) rowRanges.add(intArrayOf(lineStart, h - 1))
Log.i(OCR_MODULE_NAME, "dbPostprocess 水平分割完成找到行Ranges数 = ${rowRanges.size}")
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),
))
}
}
return boxes
}
// ============== CTC 解码 ==============
/**
* CTC greedy decode每个时间步取 argmax去 blank 去重复。返回 (text, avgConfidence)。
*
* PaddleOCR 约定logits 的 index 0 固定是 blank字符从 index 1 起,
* dictionary[i] 对应模型输出 index i+1。因此 dictIdx = argmaxIdx - 1。
*
* PP-OCRv6 模型输出已经是概率分布(值域 [0,1]),无需额外 softmax。
* 直接取 argmax 对应的值作为该时间步的置信度。
*/
private fun ctcGreedyDecode(logits: Array<FloatArray>): Pair<String, Float> {
if (logits.isEmpty()) return "" to 0f
val numClasses = logits[0].size
val blankIdx = 0 // PaddleOCR CTCblank 固定在 index 0
val sb = StringBuilder()
var lastIdx = -1
var confSum = 0.0f
var confCount = 0
for (t in logits.indices) {
var maxIdx = 0
var maxVal = logits[t][0]
for (i in 1 until numClasses) {
if (logits[t][i] > maxVal) { maxVal = logits[t][i]; maxIdx = i }
}
if (maxIdx != blankIdx && maxIdx != lastIdx) {
val dictIdx = maxIdx - 1 // index 1..N → dictionary[0..N-1]
if (dictIdx in 0 until dictionary.size) {
sb.append(dictionary[dictIdx])
confSum += maxVal // v6 输出已是概率,直接用
confCount++
}
}
lastIdx = maxIdx
}
val avgConf = if (confCount > 0) confSum / confCount else 0f
return sb.toString() to avgConf
}
// ============== Bitmap 工具 ==============
private fun cropBox(bmp: Bitmap, box: List<FloatArray>): Bitmap? {
val xs = box.map { it[0] }
val ys = box.map { it[1] }
val paddingX = 4
val paddingY = 2
val minX = max(0, (xs.minOrNull() ?: 0f).toInt() - paddingX)
val minY = max(0, (ys.minOrNull() ?: 0f).toInt() - paddingY)
val maxX = min(bmp.width, ((xs.maxOrNull() ?: 0f) + 1).toInt() + paddingX)
val maxY = min(bmp.height, ((ys.maxOrNull() ?: 0f) + 1).toInt() + paddingY)
val w = maxX - minX
val h = maxY - minY
if (w < 2 || h < 2) return null
return Bitmap.createBitmap(bmp, minX, minY, w, h)
}
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
return Bitmap.createScaledBitmap(bmp, alignedW, alignedH, true)
}
// ============== 公共工具(复用) ==============
/** 解码 base64 图片为 Bitmap。 */
private fun decodeBase64(base64: String): Bitmap? {
return try {
// 去除 data:image/...;base64, 前缀
val data = if (base64.contains(",")) base64.substringAfter(",") else base64
val bytes = Base64.decode(data, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} catch (e: Exception) {
Log.e(OCR_MODULE_NAME, "base64 解码失败: ${e.message}")
null
}
}
/**
* 短边压缩到 maxShortEdge参考 AutoAccounting scaleDownForOcr
* 像素量比 1440p 减少约 75%,识别速度大幅提升。
*/
private fun scaleDownForOcr(bitmap: Bitmap, maxShortEdge: 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()
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
}
override fun onCatalystInstanceDestroy() {
super.onCatalystInstanceDestroy()
release()
}
override fun invalidate() {
super.invalidate()
release()
}
private fun release() {
lock.lock()
try {
detSession?.close()
recSession?.close()
// OrtEnvironment 是单例,不主动 close进程级
detSession = null
recSession = null
ortEnv = null
initialized = false
} catch (_: Exception) {
} finally {
lock.unlock()
}
}
/** 识别结果块。 */
private data class OcrBlock(val text: String, val x: Float, val y: Float, val width: Float, val height: Float, val confidence: Float)
/** 预处理后的张量数据 + 宽高。 */
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
/** rec 固定图像高度。 */
private const val REC_IMAGE_HEIGHT = 48
/** rec 单行最大宽度。 */
private const val REC_MAX_WIDTH = 320
/** assets 中的模型/字典文件名。 */
private const val ASSET_DET_MODEL = "ppocrv6_det.onnx"
private const val ASSET_REC_MODEL = "ppocrv6_rec.onnx"
// PP-OCRv6 多语言识别模型的配套字典18708 字符 + 运行时 1 blank = 18710 维输出)。
// 注意:必须与 rec 模型配套,错用 v5 字典18383会导致 CTC 解码丢字符。
private const val ASSET_DICT = "ppocrv6_dict.txt"
}
}