更新记录
1.1.1(2026-09-20)
新增 13 个 OpenCV 图像处理 API:裁剪 / 缩放 / 旋转 / 翻转 / 灰度化 / 二值化(含 Otsu 与自适应阈值)/ 亮度对比度 / 模糊 / 锐化 / 边缘检测 / 形态学 / 模板匹配 / 裁剪后直接识别,全部「图片进 → 图片出」。
recognize新增可选参数preprocess(灰度 / 二值化 / 放大 / 锐化),对拍屏幕、拍照文档提升明显;不传时行为与旧版完全一致- 修复
templateMatch标注框颜色错误(此前画出来是蓝框,原因是cv::Scalar的通道序写反) - 修复拍照识别必失败:
uni.chooseImage的相册与相机分支返回的地址格式并不一致,相机分支给的是无前导/的沙盒相对地址 - 修复个别文字框置信度为
NaN时导致的 App 闪退;识别为空的框不再返回 - 本版本起转为付费插件:普通授权版 19 元、源码授权版 199 元
平台兼容性
uni-app(4.0)
| Vue2 | Vue3 | Chrome | Safari | app-vue | app-nvue | Android | iOS | 鸿蒙 |
|---|---|---|---|---|---|---|---|---|
| √ | √ | × | × | √ | √ | 8.0 | × | × |
| 微信小程序 | 支付宝小程序 | 抖音小程序 | 百度小程序 | 快手小程序 | 京东小程序 | 鸿蒙元服务 | QQ小程序 | 飞书小程序 | 小红书小程序 | 快应用-华为 | 快应用-联盟 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| × | × | × | × | × | × | × | × | × | × | × | × |
uni-app x(4.0)
| Chrome | Safari | Android | iOS | 鸿蒙 | 微信小程序 |
|---|---|---|---|---|---|
| × | × | 8.0 | × | × | × |
离线 OCR 文字识别(PaddleOCR + OpenCV)
基于百度 PaddleOCR(PP-OCRv5 mobile) + PaddleLite 推理引擎的 uni-app 离线文字识别插件。
全程本机离线运算 —— 不联网、不上传图片、不采集任何数据。
顺带附赠 13 个 OpenCV 图像处理 API(裁剪/缩放/旋转/二值化/模板匹配……), 以及「裁剪后直接识别」的一体 API —— 不用再单独装一个图像处理插件。
⚠️ 先看这条:必须打自定义调试基座
本插件内含原生库(.aar,含 .so + 21MB 模型),HBuilderX 的标准基座里没有它。
用标准基座运行会报「未找到插件 wwl-paddleocr」或调用直接失败。
第一次必须做这一步(约 3~5 分钟,只需做一次):
HBuilderX 菜单 → 运行 → 运行到手机或模拟器 → 制作自定义调试基座
↓
打完后:运行 → 运行到手机或模拟器 → 运行到 Android App 基座 → 选「自定义调试基座」
↓
之后改 js/vue 代码照常热刷新;只有「改了插件的 .uts / 换了 .aar / 改了 manifest 模块配置」才要重打
| 运行 / 打包方式 | 是否支持 | 说明 |
|---|---|---|
| 标准基座 | ❌ | 基座里没有本插件 |
| 自定义调试基座 | ✅ | 开发调试必须用这个 |
| 云打包(传统打包) | ✅ | 发行正式版用这个 |
| 离线打包 | ❌ | uts 插件不支持离线打包 |
| wgt 热更新 | ❌ | 含原生代码,无法热更新 |
| 安心打包 | ❌ | 付费插件不支持,只能用云端传统打包 |
要用「拍照」功能的话,还要在
manifest.json→ App 模块配置 里勾上Camera(源码视图对应app-plus.modules.Camera)。这个改动不随热更新生效,改完必须重打基座。
购买与授权
本插件在 DCloud 插件市场是付费插件,两档可选:
| 授权版 | 价格 | 你能拿到什么 |
|---|---|---|
| 普通授权版 | 19 元 | 加密后的插件,可直接用于商业 / 闭源项目;看不到源码 |
| 源码授权版 | 199 元 | 上面全部 + 完整 UTS 源码,可自行修改、二次开发 |
两档都绑定你购买时使用的 DCloud appid + App 包名,换掉其中任一个都要重新购买授权。 源码授权版自 2025-03-01 起不再需要 e签宝认证与合同签署,购买后即可下载源码。
| 事项 | 说明 |
|---|---|
| 试用 | 收费插件均支持免费试用。试用拿到的是加密版,且只能打自定义基座运行,不能用于正式发布 |
| 购买后 | 云端打包时自动验证并解密,正常打包发行 |
| 打包方式 | 付费 uts 插件只支持云端传统打包 —— 不支持离线打包,也不支持安心打包 |
| 加密范围 | 平台自动加密除 utssdk/interface.uts 外的所有 uts 文件;interface.uts 保持明文,用于给 HBuilderX 生成语法提示 |
| 售后 / 咨询 | 在本插件市场页面的留言 / 咨询区提问(建议装 uni-im App 接收回复通知),也可在评价区反馈 |
平台支持
| 平台 | 支持 |
|---|---|
| App-Android(uni-app / uni-app x) | ✅ |
| iOS | ❌ 暂不支持 |
| 鸿蒙 | ❌ 暂不支持 |
| Web / 小程序 | ❌ 暂不支持 |
- Android 最低版本:8.0(API 26)
- 支持 ABI:
arm64-v8a、armeabi-v7a
环境要求
| 项 | 要求 |
|---|---|
| HBuilderX | 4.0.0 及以上 |
| uni-app | vue2 / vue3 均可;uni-app x 亦可 |
| Android | 8.0(API 26)及以上,真机或模拟器 |
| 网络 | 不需要(模型内置,全程离线) |
| 权限 | 不需要任何权限(不用相机权限也能识别本地图片;仅当你要自己调 uni.chooseImage 拍照时才需 Camera 模块) |
| 包体积 | 插件 26.6MB;打进 APK 后单 ABI 实际增量约 13~15MB |
特性
- 中英文混合识别(PP-OCRv5 mobile 模型)
- 返回识别文字 + 置信度 + 文字框坐标
- 支持本地图片路径 / base64 两种输入
- 支持保存带识别框的可视化结果图
- 模型已内置于插件,开箱即用,无需额外下载
- 附带 13 个 OpenCV 图像处理 API:裁剪 / 缩放 / 旋转 / 翻转 / 灰度 / 二值化(含 Otsu、自适应)/ 亮度对比度 / 模糊 / 锐化 / 边缘检测 / 形态学 / 模板匹配,以及「裁剪后直接识别」的 OCR 一体 API
- 全部接口为异步回调式,不阻塞 JS 线程,结果回主线程
安装
方式一:从插件市场导入(推荐)
在插件市场本插件主页点「下载并导入」,HBuilderX 会自动把它装到项目的
uni_modules/wwl-paddleocr/ 目录下。工程里没有 uni_modules 目录也没关系,导入时会自动创建。
方式二:手动拷贝
把 wwl-paddleocr 整个目录拷到项目的 uni_modules/ 下即可(目录名不能改)。
引入
// 只能从插件根目录引入,不要深入子目录
import { initOCR, isReady, recognize, release } from '@/uni_modules/wwl-paddleocr'
⚠️
import路径只能到插件根目录。写'@/uni_modules/wwl-paddleocr/utssdk/...'是错的。
想直接跑起来看效果?
本插件在插件市场上上传了配套示例工程,点「一键导入示例工程」即可获得两个现成页面:
| 页面 | 作用 |
|---|---|
pages/index/index |
基础页:初始化 → 选图(相册/拍照)→ 识别 → 显示文字 |
pages/cv/cv |
OpenCV 效果台:13 个图像 API 全部做成按钮,点一下出结果图,模板匹配会在原图上把命中位置框出来 |
示例工程里还有一个 OCR 压测台(
pages/bench/bench):批量跑 1~100 张,出 P50/P90 耗时与识别条数。 它需要先把测试图推到设备私有目录(_doc/ocr_bench/)才能跑,属于开发自测工具, 入口按钮已标注「需自备图片」—— 你用不到可以完全不管它。
目录结构
uni_modules/wwl-paddleocr/
├── package.json 插件配置(市场信息、平台兼容性)
├── readme.md 本文档
├── changelog.md 更新日志
└── utssdk/
├── interface.uts 对外 API 与类型声明(HBuilderX 据此生成语法提示)
├── unierror.uts 错误码定义
└── app-android/
├── index.uts Android 平台实现
└── libs/
└── ocrlib-release.aar ← 原生库 + PP-OCRv5 模型 + OpenCV(26.6MB)
与
uni.chooseImage配合使用时,插件内部会自动规整路径, 直接传tempFilePaths[0]即可,不用自己处理file://和_doc/...的差异(见下方 FAQ)。
完整可跑示例
把下面整段存成一个页面(例如 pages/ocr/ocr.vue),并在 pages.json 里注册它,
打完自定义基座就能跑。这是一个能直接用的最小完整实现:
<template>
<view class="page">
<button type="primary" :disabled="ready || loading" @click="doInit">
{{ ready ? '✓ 引擎已就绪' : '① 初始化引擎' }}
</button>
<view class="row">
<button class="btn" :disabled="!ready || loading" @click="pick('album')">② 从相册选图</button>
<button class="btn" :disabled="!ready || loading" @click="pick('camera')">② 拍照</button>
</view>
<image v-if="showPath" class="pic" :src="showPath" mode="widthFix" />
<view class="status">状态:{{ status }}</view>
<view class="log" v-for="(l, i) in lines" :key="i">{{ l }}</view>
</view>
</template>
<script>
// ⚠️ import 只能到插件根目录
import { initOCR, recognize, release } from '@/uni_modules/wwl-paddleocr'
export default {
data() {
return {
ready: false,
loading: false,
status: '未初始化',
// showPath 给 <image> 用(必须带 file:// 前缀);
// 插件那一侧直接透传 chooseImage 的原始路径即可,不用管
showPath: '',
lines: []
}
},
onUnload() {
if (this.ready) release({})
},
methods: {
doInit() {
this.status = '初始化中(首次约 1~3 秒,要解压 21MB 模型)...'
initOCR({
success: (res) => {
this.ready = true
this.status = '引擎就绪,耗时 ' + res.cost + 'ms'
},
fail: (err) => {
this.status = '初始化失败 ' + err.errCode + ':' + err.errMsg
}
})
},
pick(sourceType) {
uni.chooseImage({
count: 1,
sourceType: [sourceType],
success: (r) => {
const path = r.tempFilePaths[0]
// 页面自己显示图片时,要「先规整成绝对路径 → 再补 file://」
this.showPath = this.toDisplay(this.toAbsPath(path))
this.doRecognize(path)
}
})
},
doRecognize(path) {
this.loading = true
this.lines = []
this.status = '识别中...'
// path 直接透传即可,插件内部会自己规整
recognize({
imagePath: path,
success: (res) => {
this.loading = false
this.status = '识别完成:' + res.items.length + ' 条,耗时 ' + res.cost + 'ms'
this.lines = res.items.map((it, i) =>
'[' + i + '] ' + it.text + ' 置信度 ' + it.confidence.toFixed(4))
},
fail: (err) => {
this.loading = false
this.status = '识别失败 ' + err.errCode + ':' + err.errMsg
}
})
},
/**
* 把三种形态统一成「裸绝对路径」:裸路径 / file:// URI / _doc 沙盒相对地址
* 插件内部会做这件事,但页面自己的 <image> / uni.getImageInfo 不会,必须自己来。
* ⚠️ 三步顺序不能颠倒:先剥 file:// 再判断形态(详见 FAQ)
*/
toAbsPath(p) {
if (!p) return ''
let s = String(p)
if (s.indexOf('file://') === 0) s = s.substring(7)
if (!s) return ''
if (s.charAt(0) === '/') return s
if (/^_(doc|downloads|documents|www|app|tmp)\//.test(s)) {
// #ifdef APP-PLUS
try {
const abs = plus.io.convertLocalFileSystemURL(s)
if (abs && abs.length > 0) return abs
} catch (e) {}
// #endif
return s
}
return '/' + s
},
/**
* 交给 <image> 显示时再补回 file:// 前缀。
* 裸绝对路径(/storage/...)在 Android 上显示不出来,这个前缀不能省。
*/
toDisplay(p) {
if (!p) return ''
return p.indexOf('file://') === 0 ? p : 'file://' + p
}
}
}
</script>
<style>
.page { padding: 24rpx; }
.row { display: flex; flex-direction: row; margin-top: 16rpx; }
.btn { margin-right: 16rpx; }
.pic { width: 100%; margin-top: 20rpx; }
.status { margin-top: 20rpx; font-size: 26rpx; color: #666; }
.log { font-size: 24rpx; line-height: 1.7; font-family: monospace; }
</style>
快速开始(片段版)
import { initOCR, recognize, release } from '@/uni_modules/wwl-paddleocr'
// 1. 初始化(建议应用启动时调用一次,耗时约 1~3 秒)
initOCR({
success: (res) => {
console.log('引擎就绪,耗时', res.cost, 'ms')
},
fail: (err) => {
console.error('初始化失败', err.errCode, err.errMsg)
}
})
// 2. 识别(异步)
recognize({
imagePath: '/storage/emulated/0/DCIM/test.jpg',
success: (res) => {
res.items.forEach((item, i) => {
console.log(`[${i}] ${item.text} 置信度 ${item.confidence}`)
})
},
fail: (err) => console.error('识别失败', err)
})
// 3. 释放(退出页面/应用时)
release({})
API 一览
OCR(4 个)
| API | 作用 |
|---|---|
initOCR |
初始化引擎 |
isReady |
查询引擎是否就绪 |
recognize |
识别图片中的文字 |
release |
释放引擎资源 |
图像处理(13 个,全部「图片进 → 图片出」)
| API | 作用 | API | 作用 |
|---|---|---|---|
crop |
裁剪 | blur |
模糊 |
resize |
缩放 | sharpen |
锐化 |
rotate |
旋转 | canny |
边缘检测 |
flip |
翻转 | morphology |
形态学 |
toGray |
灰度化 | templateMatch |
模板匹配 |
threshold |
二值化 | cropRecognize |
裁剪后直接识别 |
adjust |
亮度/对比度 |
API
initOCR(options)
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| modelDir | string | 否 | 外部模型目录。不传则用内置模型 |
| threadNum | number | 否 | CPU 线程数,默认 4 |
| powerMode | string | 否 | 电源模式,默认 LITE_POWER_HIGH |
| success | function | 否 | 成功回调,参数 { ready, cost } |
| fail | function | 否 | 失败回调,参数为错误对象 |
| complete | function | 否 | 完成回调(成功/失败都会调用) |
modelDir传入时,该目录下需包含:models/PP-OCRv5_mobile_det.nb、models/PP-OCRv5_mobile_rec.nb、models/ch_ppocr_mobile_v2.0_cls_slim_opt.nb、labels/ppocr_keys_ocrv5.txt、config.txt。
recognize(options)
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| imagePath | string | 二选一 | 图片路径。支持裸绝对路径、file:// URI、项目相对地址(_doc/...,uni.chooseImage 拍照分支返回的就是这种)、带 %xx 转义的 URI —— 插件会统一规整成绝对路径。实际取值直接传 tempFilePaths[0] 即可 |
| imageBase64 | string | 二选一 | 图片 base64(可含 dataURL 前缀) |
| preprocess | object | 否 | 识别前预处理,见下方 preprocess —— 识别前预处理 |
| saveImagePath | string | 否 | 可视化结果图保存路径 |
| success | function | 否 | 返回 { items, cost } |
| fail | function | 否 | 失败回调 |
| complete | function | 否 | 完成回调 |
imagePath与imageBase64同时传入时以imagePath优先。
items 结构:
| 字段 | 类型 | 说明 |
|---|---|---|
| text | string | 识别出的文字 |
| confidence | number | 置信度 0~1 |
| coordinates | number[] | 文字框坐标,长度 8:[x0,y0, x1,y1, x2,y2, x3,y3],顺序为左上、右上、右下、左下。无坐标信息时为空数组 |
坐标基于传入图片的像素坐标系,原点在图片左上角。
⚠️
items只包含识别出文字的框。检测到但没有任何文字的区域(图标、色块、纯背景等) 不会出现在结果里,因此items.length可能小于检测到的框数 —— 这是刻意为之: 这类框的 CTC 解码整条序列都是 blank,历史上会算出NaN置信度并导致真机闪退。
isReady()
返回布尔值,查询引擎是否已就绪。
release(options)
释放引擎占用的内存与 native 资源。
图像处理 API(OpenCV)
全部是「图片进 → 图片出」:输入
imagePath(或imageBase64), 结果写到saveImagePath。不传saveImagePath时会自动落到应用私有目录<外部私有目录>/cv_out/,路径从返回值的path取(页面显示用'file://' + path)。输入路径支持三种形态:裸绝对路径、
file://URI、_doc/...相对路径 —— 与recognize共用同一套规整逻辑,直接传uni.chooseImage的tempFilePaths[0]即可。若只要处理结果不落盘,传
imageBase64也支持(会先解码到临时文件)。
通用返回(除模板匹配外):
| 字段 | 类型 | 说明 |
|---|---|---|
path |
string | 结果图片的绝对路径 |
width |
number | 结果宽度 |
height |
number | 结果高度 |
crop(options) —— 裁剪
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
x / y |
number | 是 | 左上角坐标 |
width / height |
number | 是 | 裁剪尺寸,超出原图会自动截断(不报错) |
resize(options) —— 缩放
三种用法,优先级从高到低:
width+height都给 → 精确拉伸到该尺寸(可能变形)- 只给一个 → 另一边按原图比例算
- 都不给 → 用
scale按比例缩放
interpolation:linear(默认)/ nearest(最快)/ cubic(放大时更平滑)。
rotate(options) —— 旋转
angle 为顺时针角度。90 的整数倍走无损快速路径(不插值、像素不糊);其余角度用 warpAffine。
mode:expand(默认,扩展画布保证不裁切)/ crop(保持原尺寸)。
空白处填白色 —— 黑边会被 OCR 检测成文字块,填白能避免。
flip(options) —— 翻转
mode:h 左右镜像 / v 上下镜像 / both 双向。
toGray(options) —— 灰度化
threshold(options) —— 二值化
| 参数 | 默认 | 说明 |
|---|---|---|
type |
binary |
binary 固定阈值 / otsu 自动阈值 / adaptive 自适应阈值 |
threshold |
127 | type=binary 时使用,0~255 |
maxValue |
255 | 二值化后的最大值 |
blockSize |
15 | type=adaptive:邻域大小,自动收敛为奇数 |
constant |
5 | type=adaptive:从邻域均值中减去的常数 |
返回值比通用结果多一个 usedThreshold —— otsu 的阈值是算法算出来的,
不回传就没法调参;adaptive 没有全局阈值,返回 0。
文档扫描、光照不均的照片用
adaptive;整体偏暗/偏亮用otsu。
adjust(options) —— 亮度 / 对比度
brightness -100~100(默认 0),contrast 0.5~3.0(默认 1.0)。
围绕中灰(128)校正,所以调对比度不会连带整体变亮;中间用浮点计算,不会把高光/暗部切掉。
blur(options) —— 模糊
type:mean 均值 / gaussian 高斯(默认)/ median 中值(专治椒盐噪点)。
ksize 为正奇数,自动收敛;median 上限 5(OpenCV 限制)。
sharpen(options) —— 锐化
Unsharp Mask(原图 + (原图 − 高斯模糊) × amount),amount 默认 1.0。
比固定卷积核更自然,且不会把噪点一起放大。
canny(options) —— 边缘检测
threshold1 默认 50,threshold2 默认 150。
morphology(options) —— 形态学
op:erode 腐蚀(去小白点)/ dilate 膨胀 / open 开运算(先腐后膨,去噪)/
close 闭运算(先膨后腐,连断笔)。kernelSize 默认 3,iterations 默认 1。
templateMatch(options) —— 模板匹配
在被搜索的大图里找模板小图。
| 参数 | 说明 |
|---|---|
imagePath / imageBase64 |
被搜索的大图 |
templatePath / templateBase64 |
模板小图,必须严格小于大图,否则报 9010007 |
threshold |
匹配阈值 0~1,默认 0.8。调低更宽松、易误报 |
saveImagePath |
命中时把红框画在大图上存到这里;不传则只返回坐标 |
返回:
| 字段 | 类型 | 说明 |
|---|---|---|
found |
boolean | 是否命中 |
x / y / width / height |
number | 命中框位置;未命中时全为 0 |
score |
number | 全图最高匹配分(0~1) |
// 例:在大图里找按钮模板,命中就把红框画出来
templateMatch({
imagePath: bigPath,
templatePath: tplPath,
threshold: 0.8,
saveImagePath: outPath,
success: (r) => {
if (r.found) {
console.log(`命中 (${r.x}, ${r.y}) ${r.width}x${r.height} 分数 ${r.score}`)
// outPath 上已经画好红框了
} else {
console.log('未命中,全图最高分只有 ' + r.score + ',可把 threshold 降到略低于它')
}
}
})
未命中也走
success,不是fail—— 没找到不是错误。 同时把真实最高分一并返回,方便判断「差多少才够阈值」,不用反复试参数。 只有读图失败才走fail。
cropRecognize(options) —— 裁剪后直接识别
裁剪指定区域 →(可选预处理)→ 识别,全程内存操作、零文件 IO。
比「先 crop 存文件 → 再 recognize 读文件」少两次编解码 + 一次落盘。
参数在 crop 的基础上多一个 preprocess(见下),返回与 recognize 完全一致(items + cost)。
preprocess —— 识别前预处理
recognize 与 cropRecognize 都支持同一个可选参数:
| 参数 | 默认 | 说明 |
|---|---|---|
gray |
true |
先转灰度 |
threshold |
不做 | { type, threshold, blockSize, constant },type 默认 otsu |
scale |
不缩放 | 先放大,小字(< 20px)建议 2 |
sharpen |
false |
先锐化 |
执行顺序固定为 放大 → 灰度 → 二值化 → 锐化(放大放在最前,避免二值化先把细笔画吃掉)。
// 拍屏幕:先轻度模糊去摩尔纹,再自适应二值化
recognize({
imagePath: path,
preprocess: { gray: true, threshold: { type: 'adaptive', blockSize: 15 } },
success: (res) => console.log(res.items.length, res.cost)
})
不传
preprocess时行为与 1.0.x 完全一致。
完整示例:拍照 → 只识别画面中间一块
uni.chooseImage({
count: 1,
sourceType: ['camera'],
success: (r) => {
cropRecognize({
imagePath: r.tempFilePaths[0], // 相机给的是 _doc/... 相对地址,插件会自动规整
x: 60, y: 400, width: 960, height: 300,
preprocess: { gray: true, threshold: { type: 'otsu' } },
success: (res) => {
console.log('识别到', res.items.length, '行,耗时', res.cost, 'ms')
res.items.forEach(it => console.log(it.text, it.confidence))
},
fail: (err) => console.error(err.errCode, err.errMsg)
})
}
})
典型预处理组合
| 场景 | 推荐组合 |
|---|---|
| 拍屏幕(有摩尔纹) | blur(type:'gaussian', ksize:3) → threshold(type:'adaptive') |
| 拍摄纸质文档 | preprocess: { gray:true, threshold:{type:'otsu'}, sharpen:true } |
| 小字截图 | preprocess: { scale:2, gray:true, sharpen:true } |
| 手写 / 低对比 | adjust(brightness:20, contrast:1.6) → threshold(type:'adaptive') |
| 表格去线 | morphology(op:'open', kernelSize:3) |
| 从固定版面抓字段 | templateMatch 定位区域 → cropRecognize 识别 |
错误码
| 错误码 | 说明 |
|---|---|
| 9010001 | 当前平台不支持 |
| 9010002 | 引擎初始化失败(模型缺失/加载失败) |
| 9010003 | 参数非法(路径无法解析、图片解码失败、base64 非法) |
| 9010004 | 引擎未初始化,请先调用 initOCR |
| 9010005 | 识别过程内部异常 |
| 9010006 | 图片文件不存在或无法读取 |
| 9010007 | 图像处理失败(参数越界、模板比原图大、OpenCV 处理返回空) |
9010003与9010006的区别:前者是「路径解析不出来」或「解出来了但解码失败」, 后者是「路径没问题但文件不存在」。排查时能省一轮猜测。
性能参考
真机实测样例(OPPO PBEM00 / arm64-v8a / release 包,数值随机型与画面文字量波动,仅供估算):
| 场景 | 结果 | 耗时 |
|---|---|---|
initOCR 冷启动(含解压 21MB 模型) |
— | 1~3 s |
| 识别截图(16 行文字) | 16 条 | 2646 ms |
| 识别相册压缩图(27 行文字) | 27 条 | 1726 ms |
| 识别拍照图(7 行文字) | 7 条 | 1402 ms |
templateMatch(500×500 图内找子图) |
偏差 0px、score 0.9997 | 毫秒级 |
想在自己机器上测:导入示例工程后用 OCR 压测台(
pages/bench/bench), 它能批量跑 1~100 张并输出 P50 / P90 / max 耗时与内存变化。 压测图片用bench/push.ps1推送到设备私有目录,不进 App 包。
常见问题
Q:运行报「未找到插件 wwl-paddleocr」/调用直接失败?
A:你用的是标准基座。本插件含原生 .aar,必须打「自定义调试基座」才能用 ——
见本文档开头的「先看这条」。打一次即可,之后改 js/vue 热刷新就行。
Q:初始化很慢? A:首次调用需把模型从 AAR 解压到应用外部存储目录(约 21MB),耗时 1~3 秒属正常。之后调用会命中缓存。
Q:识别精度不够?
A:当前使用 PP-OCRv5 mobile 轻量模型,兼顾速度与包体积。如有更高精度需求可替换为 server 版模型,通过 modelDir 传入。
Q:包体积增加多少?
A:插件约 26.6MB(单个 AAR,内含 PP-OCRv5 模型 + PaddleLite so + C++ JNI)。打入 APK 后,受 ABI 切分与压缩影响,单 ABI 实际增量约 13~15MB。可只保留 arm64-v8a 进一步减小。
Q:可以同时多次调用 recognize 吗?
A:可以调用,但底层 pipeline 为串行执行(@Synchronized),多次调用会排队。建议上一张识别完成后再发下一张。
Q:必须用真机吗?模拟器行不行?
A:仅支持 arm64-v8a / armeabi-v7a,所以需要 ARM 架构的模拟器(或 x86 模拟器开 ARM 转译,性能很差)。建议用真机。
Q:真机点「拍照」弹出「HTML5+ Runtime 打包时未添加 camera 模块」?
A:不是插件问题,是 manifest.json 里没勾相机模块。打开 manifest.json → App模块配置 勾上 Camera;
源码视图对应 app-plus → modules → "Camera": {}(HBuilderX 3.6.11+ 起「相机」「相册」已合并为一个 Camera 模块)。
⚠️ 模块配置不会随热更新生效,改完必须重新「打自定义调试基座」。
Q:传 uni.chooseImage 的 tempFilePaths[0] 报 9010003 / 9010006?
A:1.0.7 起插件会同时规整「相册的 file:// URI」和「拍照的 _doc/... 相对地址」,正常不会再见。
uni.chooseImage 这两个分支返回的格式并不一致:相册给 file:///storage/...,
拍照给 _doc/uniapp_temp_xxx/camera/xxx.jpg(没有前导 /)——
所以只处理 file:// 的写法会表现为「相册能识别、拍照 100% 失败」。
若仍报错:9010003 说明路径解析不出来(例如传了 content://,需自行转换);
9010006 说明解析出来了但文件不存在(图被删了或没读权限)——
日志里会打出 imagePath=... → localPath=... 对照,一眼就能看出是路径规整错了还是文件真没了。
Q:插件调用都正常,但页面自己的 uni.getImageInfo / <image> 显示失败(errCode 14 路径不存在)?
A:插件内部会规整路径,页面侧不会 —— 所以拿 tempFilePaths[0] 去喂 uni.getImageInfo、
或者拼 'file://' + path 显示时,必须自己先规整一次。
拍照分支返回的 _doc/... 没有前导 /,直接拼成 file://_doc/... 就会被判「路径不存在」。
推荐在页面里加两个统一入口(本插件示例工程 pages/cv/cv.vue、以及本文档「完整可跑示例」里都是这两个函数):
// ① 把三种形态统一成「裸绝对路径」:裸路径 / file:// URI / _doc 沙盒相对地址
toAbsPath(p) {
if (!p) return ''
let s = String(p)
if (s.indexOf('file://') === 0) s = s.substring(7) // 剥 scheme(别急着补前导 /)
if (!s) return ''
if (s.charAt(0) === '/') return s // 已是绝对路径
if (/^_(doc|downloads|documents|www|app|tmp)\//.test(s)) { // 沙盒相对地址
// #ifdef APP-PLUS
try {
const abs = plus.io.convertLocalFileSystemURL(s)
if (abs && abs.length > 0) return abs
} catch (e) {}
// #endif
return s
}
return '/' + s // 其余按绝对路径补 /
}
// ② 交给 <image> 显示时再补回 file:// 前缀 —— 裸绝对路径在 Android 上显示不出来
toDisplay(p) {
if (!p) return ''
return p.indexOf('file://') === 0 ? p : 'file://' + p
}
顺序不能颠倒:先
toAbsPath()再toDisplay()。若在剥掉前缀后「顺手补前导/」,file://_doc/xxx会变成/_doc/xxx,反而把沙盒换算那一支跳过(实测踩过)。
Q:识别完成的一瞬间 App 闪退,日志是 fastjson ... error parse new?
A:1.0.5 起已修复。原因是某个文字框的 confidence 是 NaN,UTS→JS 的 gson 序列化把它写成裸 nan,
fastjson 解析不了。详见「二次开发注意事项」第 7 条。
Q:为什么只支持 Android? A:PaddleLite 在 iOS 侧的封装方式与 Android 差异较大,当前版本优先保证 Android 的稳定性与识别效果。
Q:图像处理 API 会额外增加包体积吗? A:不会。OpenCV 本来就在 AAR 里(OCR 的检测后处理依赖它),这批接口只是把已有能力接出来,包体积没有变化。
Q:图像 API 能并发调用吗?
A:能调用,但 native 侧串行执行(与 recognize 共用同一把锁),并发不会更快。建议一次处理完再发下一张。
Q:图像 API 的输出写到哪了?
A:不传 saveImagePath 时落在应用私有目录 <外部私有目录>/cv_out/。这类目录会随应用卸载 / 清数据一起消失,需要长期保留请显式传 saveImagePath。
Q:templateMatch 明明有目标却返回 found: false?
A:看返回的 score —— 它是真实最高分。如果 score 在 0.6~0.8 之间,说明目标存在但阈值定高了,把 threshold 调到略低于 score 即可。
隐私与合规
| 项 | 说明 |
|---|---|
| 联网 | 不联网。模型随插件分发,运行期无任何网络请求 |
| 采集数据 | 不采集任何数据。图片、识别结果只存在于设备本机与本应用进程内 |
| 图片上传 | 不上传。全程离线运算,图片不会离开设备 |
| 广告 | 无 |
| 系统权限 | 不需要任何权限。插件本身不申请权限(播放器/相册/相机均不涉) |
| 图片落盘位置 | 仅在你显式传 saveImagePath,或图像 API 未传 saveImagePath 时,写入应用私有目录(随卸载清除) |
面向 C 端 App 的隐私政策里,可以写明「本应用使用离线 OCR 能力,识别全程在设备本地完成, 不采集、不上传任何图片与文本」。这在很多行业(金融、医疗、政务)是硬性要求。
第三方组件与许可
本插件打包了以下开源组件,均为宽松许可,可商用:
| 组件 | 版本 | 许可 |
|---|---|---|
| PaddleOCR / PP-OCRv5 mobile 模型 | PP-OCRv5 | Apache-2.0 |
Paddle-Lite(libpaddle_light_api_shared.so) |
— | Apache-2.0 |
| OpenCV | 4.2.0 | BSD-3-Clause |
二次开发注意事项
本节记录的是「如果你要改插件源码」才会遇到的坑。单纯使用本插件不需要看任何一条。 文中提到的
uts插件集成发布文档/…是作者本机的开发记录,不在插件包内,此处仅作为来源标注。
1. export function 是雷(会导致编译期崩溃)
utssdk/interface.uts 与 utssdk/app-android/index.uts 里每个文件最多只能写 1 个 export function。
写第 2 个时,UTS 编译器会把它当成「同名函数重载组」处理,名字不同就直接 panic 并 abort 整个编译进程:
thread '<unnamed>' panicked at crates/uts_transforms/src/overload.rs:257:17:
internal error: entered unreachable code: overload error initOCR != isReady
正确写法是官方推荐的「函数类型别名 + export const」:
// interface.uts
export type InitOCR = (options: InitOCROptions) => void
export type IsReady = () => boolean
// app-android/index.uts
export const initOCR : InitOCR = function (options: InitOCROptions) : void { ... }
export const isReady : IsReady = function () : boolean { ... }
官方参照实现:HBuilderX 自带示例
samples/hello-uni-app-x/uni_modules/uni-getbatteryinfo/utssdk/。
2. libs/ 下只放 AAR,不要再放 so
utssdk/app-android/libs/ 下只应存在 ocrlib-release.aar 一个文件:
- AAR 内部已包含全部原生库(
jni/arm64-v8a|armeabi-v7a/下的libNative.so、libpaddle_light_api_shared.so、libc++_shared.so)与模型资源(assets/) - ⚠️ 不要再在
libs/下新建arm64-v8a、armeabi-v7a目录重复放 so。云打包会把libs/<abi>/*.so并入 jniLibs,与 AAR 内同名文件冲突,报2 files found with path 'lib/arm64-v8a/libpaddle_light_api_shared.so'导致打包失败
3. 导入 AAR 内的类,from 要写「包名」
UTS 会把 from 与 import {} 里的符号拼接后再生成 Kotlin import:
// ✅ 正确 —— from 写包名
import { OCRManager } from "com.wwl.ocrlib";
// ❌ 错误 —— 生成 import com.wwl.ocrlib.OCRManager.OCRManager,多了一层
import { OCRManager } from "com.wwl.ocrlib.OCRManager";
后者在云打包时会报 Unresolved reference 'OCRManager'(错误位置在生成 Kotlin 的 import 行上,很容易误判成「AAR 没打进去」)。
注意区分:默认导入(
import X from "a.b.C")是按原样使用、不拼接的, 上面的三份 Android/Java 平台类导入都是这个形态,所以它们不受影响。两种写法别混用。
4. 别在 UTS 里碰原生字节数组
android.util.Base64.decode() 这类返回 byte[] 的方法,在 UTS 里类型映射不可靠 ——
对返回值取 .length 会生成非法 Kotlin(Kotlin 的 ByteArray 只有 .size),
云打包报 Unresolved reference 'length'。
做法:把字节处理下沉到 AAR 内部,UTS 只传 string。
本插件的 base64 输入就是走 OCRManager.recognizeBase64(base64, savePath) 处理的。
5. 传给 AAR 的 Int / Double 形参要显式转换
UTS 的 number 会生成 Kotlin 的 Number(装箱类型),包括 for (let i = 0; ...) 的循环变量:
var i: Number = 0
而 AAR 里 fun getText(index: Int) 这类形参是 Int。把 Number 直接传进去,
Kotlin 编译器不认识这种隐式收窄,云打包报:
error: Argument type mismatch: actual type is 'Number', but 'Int' was expected.
做法:在实参位置显式调用 .toInt()(UTS 的 number 对象带 toInt(): Int):
// ✅ 正确 —— 直接写在实参里
coords.push(result.getCoordinateAt(i.toInt(), k.toInt()));
text: result.getText(i.toInt())
// ❌ 错误 —— 先落到局部变量会被 UTS 还原成 Number,白写
const idx: number = i.toInt();
result.getText(idx)
同理,
.toFloat()/.toDouble()/.toLong()也存在;凡是要传给原生Int/Float/Double/Long形参的number,都得就地转换。
Double是最容易漏的一个(1.1.0 加图像 API 时一口气踩了 12 处),因为字面量没这个问题:生成的 Kotlin 里255.0是Double,而从 options 取出来的options.angle是Number—— 两者写在同一行实参里,一个能过一个报错,很容易误判成「偶发」。判据:只要这个值来自变量,就加
.toDouble(),哪怕它明显是个小数。
6. 别把 file:// URI 或 _doc/... 相对地址直接丢给 BitmapFactory.decodeFile()
BitmapFactory.decodeFile(String) 只认裸绝对路径。而 uni.chooseImage 在 Android 上
给回的 tempFilePaths[0] 有两种形态,且同一份代码里两个分支给的还不一样:
a) 相册选图 → URI,带 scheme
file:///storage/emulated/0/Android/data/<包名>/apps/<appid>/doc/uniapp_temp/compressed/xxx.jpg
b) 拍照 → 项目相对地址,没有前导 `/`
_doc/uniapp_temp_1789886056537/camera/1789886082398.jpg
两种都会被 decodeFile 打开失败并返回 null。最坑的是外层看起来像「图片有问题」或像
相机权限没给,很容易往模型、权限方向排查,实际图片完全正常。
只处理 a) 是最危险的半成品:相册识别一路绿灯,拍照 100% 失败。
做法:按形态分流 ——
import Uri from "android.net.Uri"
import { UTSAndroid } from "io.dcloud.uts"
function toLocalFilePath(src: string): string {
const s = src.trim()
if (s.length == 0) return ""
if (s.startsWith("content://")) return "" // content:// 要走 ContentResolver,本插件不支持
// a) 项目相对地址(_doc/ _downloads/ _documents/ _www/)→ 官方 API 转绝对路径
if (!s.startsWith("/") && !s.startsWith("file:")) {
const abs = UTSAndroid.convert2AbsFullPath(s)
if (abs != null && abs.length > 0) return abs!
}
// b) file:// URI → 剥 scheme + 还原 %xx 转义
const p = Uri.parse(s).getPath()
return (p != null && p.length > 0) ? p! : s
}
为什么用 UTSAndroid.convert2AbsFullPath() 而不是自己拼 _doc:官方文档明确它支持
「相对路径、沙盒路径、沙盒外路径(含系统 API 返回的文件地址)」三类。
而 _doc 的宿主目录里含 appid(.../Android/data/<包名>/apps/<appid>/doc),
appid 无法从 Context 反推 —— 包名不等于 appid,且云打包允许自定义包名,自己拼在别人的工程里必失效。
只做
replace("file://", "")也能过大部分场景,但文件名带空格/中文时路径里的%20、%E4%B8%AD不会被还原,仍会打不开 —— 所以用Uri.getPath()一步到位。 另外文件不存在与解码失败要拆成两个错误码,否则排查时分不清是路径错还是文件没了。
7. 浮点字段返回前必须消毒:NaN 会让 UTS→JS 的序列化把 App 搞崩
这是本插件开发过程中最隐蔽的一个坑,真机表现为识别成功那一刻直接闪退:
FATAL EXCEPTION: main
com.alibaba.fastjson.JSONException: error parse new
at com.alibaba.fastjson.parser.JSONLexerBase.scanNullOrNew
at com.alibaba.fastjson.JSON.parse(JSON.java:147)
at io.dcloud.uts.UTSCallback.invoke(UTSCallback.kt:72)
at ...IndexKt.recognizeByJs$lambda$20(index.kt:404)
链路(可在 utsplugin-release.jar 的 UTSCallback.invoke 里逐行对照):
val gson = GsonBuilder()
.serializeSpecialFloatingPointValues() // ← 允许把 NaN / Infinity 原样写进 JSON
.setExclusionStrategies(DynamicMapStrategy())
.registerTypeAdapter(UTSJSONObject::class.java, UTSJSONJsonSerializer())
.create()
val jsonStr = gson.toJson(arg) // {"confidence":nan,...}
val parsed = fastjson.JSON.parse(jsonStr) // ✗ error parse new
- 只有
UTSJSONObject参数走toJSONObject()快路径;普通 UTSObject(export type定义的结构体)一律走 gson serializeSpecialFloatingPointValues()使 gson 不再拒绝非有限数,写出裸nan/infinity(实测Float.POSITIVE_INFINITY报syntax error, pos 42)- fastjson 解析
n开头且后面不是u/e的 token →error parse new - success 回调跑在
postToMain的主线程上,异常无人捕获 → 闪退
做法(分两层):
① 源头根治 —— 非有限数本来就不该从 native 出来。
本项目实测的元凶是 rec_process.cc 的 RecPredictor::Postprocess():CTC 解码后直接
score /= count,而整条序列都是 blank 的框(该区域没有文字,例如图标、色块)
count == 0 → 0.0f / 0 → NaN。已改为 count == 0 时 score = 0,
并在 pipeline.cc 里直接过滤掉 text == "" 的框
(注意 texts / scores / coordinates 是三个并行 vector,必须一起跳过,否则下标错位)。
② UTS 侧兜底消毒(防御性,正常运行时不会命中):
function sanitizeConfidence(v: number): number {
if (Number.isNaN(v)) return 0; // NaN 必须单独判
if (!(v >= 0)) return 0; // 负数
if (!(v <= 1)) return 1; // > 1 或 +Infinity
return v;
}
⚠️ 不要把
Number.isNaN换成"纯算术"写法 —— 我们实测过,在 UTS 里全都失效。 UTS 的number是装箱类型(生成 KotlinNumber),><>=<===!=一律走io.dcloud.uts的compareTo/numberEquals,而 Kotlin/Java 的Double.compare规定 NaN 大于一切、且 NaN 等于自身。用 HBuilderX 自带的uts-runtime-release.jar实测:
写法 NaN +∞ −∞ 0.9985 !(v >= 0)false ✗ 抓不到 NaN false true false v != vfalse ✗ 自反性失效 false false false v * 0 != 0true true true ✗ 正常值也误报 true ✗ 正常值也误报 后果很隐蔽:
!(v >= 0)放过的 NaN 会落到!(v <= 1)分支被归成 1 —— 空文本框反倒拿到满分置信度。Number.isNaN()是唯一可靠的判据 (UTS 会把它编译成UTSNumber.isNaN)。
再把回调本身也包一层 try-catch 兜底(宁可吞异常打日志,也不要让用户看见闪退)。
附带诊断:消毒命中时打一条
警告:N/M 个框的置信度不是有限数…日志, 就能判断是 native 在产异常得分,还是自己算错了。
8. 往 CV_8UC4 里填颜色:通道序是 R,G,B,A,不是 B,G,R
Native 的 toMat() 是「原样 memcpy Android RGBA_8888 的像素」到 CV_8UC4,
中间没有任何通道交换 —— 所以通道 0/1/2 分别就是 R / G / B。
给 cv::rectangle / cv::line 之类填色时,cv::Scalar 是按通道序号依次对应的,
所以必须写:
// color 是 0xRRGGBB
const int r = (color >> 16) & 0xFF;
const int g = (color >> 8) & 0xFF;
const int b = color & 0xFF;
cv::rectangle(out, rect, cv::Scalar(r, g, b, 255), thickness); // ✅
// cv::Scalar(b, g, r, 255) // ❌ 红框会画成蓝框
⚠️ 这个坑只在「颜色有语义」的接口上暴露(画框、画点、按通道调色), 灰度 / 二值化 / 模糊 / 锐化 / 形态学 / Canny 这些逐通道对称的操作完全看不出来, 所以极容易漏。改完务必真机看一眼颜色,或从截图里取像素值核对。
同类判据:
cvtColor用的都是COLOR_RGBA2GRAY/COLOR_RGBA2BGR/COLOR_GRAY2RGBA, 与 RGBA 字节序自洽 —— 一旦哪天把 Mat 换成 BGRA,这些转换码要一起改,别只改一处。
9. 改完 .uts 先本地自检,别直接提交云打包
HBuilderX 把 UTS 编译器和 kotlinc 都装在自己目录里,可以脱离 HBuilderX 在命令行里 「uts → kt → kotlinc 编译」跑完整一轮,几秒出结果,不用排队等云打包。思路:
① uts → kt :用 HBuilderX 自带的 uts 编译器把 index.uts 转成 index.kt
② kt → class:用 HBuilderX 自带 kotlinc(java + K2JVMCompiler 直调)+ uts-runtime-release.jar
⚠️ 别直接调
kotlinc.bat—— PowerShell 传参给.bat会经过cmd.exe, 而 cmd 会把未加引号的参数按;再切一刀,导致-classpath只剩第一个 jar。 用java.exe + K2JVMCompiler直调方式可绕开。
授权协议
本插件是付费插件,授权协议采用插件市场的标准许可协议(购买时展示、购买后生效)。
按市场规则,付费插件包内不放置 license.md,协议内容以插件市场页面为准。
一句话:本插件是付费插件(普通授权版 19 元 / 源码授权版 199 元, 授权绑定你的 DCloud appid + App 包名);购买后可用于商业 / 非商业项目, 可自由集成到你的 App 里;不可把插件本身(或其修改版)当作插件/模板再发布或转售。
第三方开源组件的许可见上方「第三方组件与许可」。

收藏人数:
购买源码授权版(
试用
使用 HBuilderX 导入示例项目
赞赏(0)
下载 3314
赞赏 0
下载 12622443
赞赏 1950
赞赏
京公网安备:11010802035340号