更新记录

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.jsonApp 模块配置 里勾上 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-v8aarmeabi-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.nbmodels/PP-OCRv5_mobile_rec.nbmodels/ch_ppocr_mobile_v2.0_cls_slim_opt.nblabels/ppocr_keys_ocrv5.txtconfig.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 完成回调

imagePathimageBase64 同时传入时以 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.chooseImagetempFilePaths[0] 即可。

若只要处理结果不落盘,传 imageBase64 也支持(会先解码到临时文件)。

通用返回(除模板匹配外):

字段 类型 说明
path string 结果图片的绝对路径
width number 结果宽度
height number 结果高度

crop(options) —— 裁剪

参数 类型 必填 说明
x / y number 左上角坐标
width / height number 裁剪尺寸,超出原图会自动截断(不报错)

resize(options) —— 缩放

三种用法,优先级从高到低:

  1. width + height 都给 → 精确拉伸到该尺寸(可能变形)
  2. 只给一个 → 另一边按原图比例算
  3. 都不给 → 用 scale 按比例缩放

interpolationlinear(默认)/ nearest(最快)/ cubic(放大时更平滑)。

rotate(options) —— 旋转

angle顺时针角度。90 的整数倍走无损快速路径(不插值、像素不糊);其余角度用 warpAffine

modeexpand(默认,扩展画布保证不裁切)/ crop(保持原尺寸)。

空白处填白色 —— 黑边会被 OCR 检测成文字块,填白能避免。

flip(options) —— 翻转

modeh 左右镜像 / 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) —— 模糊

typemean 均值 / gaussian 高斯(默认)/ median 中值(专治椒盐噪点)。 ksize 为正奇数,自动收敛;median 上限 5(OpenCV 限制)。

sharpen(options) —— 锐化

Unsharp Mask(原图 + (原图 − 高斯模糊) × amount),amount 默认 1.0。 比固定卷积核更自然,且不会把噪点一起放大。

canny(options) —— 边缘检测

threshold1 默认 50,threshold2 默认 150。

morphology(options) —— 形态学

operode 腐蚀(去小白点)/ 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 —— 识别前预处理

recognizecropRecognize 都支持同一个可选参数:

参数 默认 说明
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 处理返回空)

90100039010006 的区别:前者是「路径解析不出来」或「解出来了但解码失败」, 后者是「路径没问题但文件不存在」。排查时能省一轮猜测。

性能参考

真机实测样例(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.jsonApp模块配置 勾上 Camera; 源码视图对应 app-plusmodules"Camera": {}(HBuilderX 3.6.11+ 起「相机」「相册」已合并为一个 Camera 模块)。 ⚠️ 模块配置不会随热更新生效,改完必须重新「打自定义调试基座」。

Q:传 uni.chooseImagetempFilePaths[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 起已修复。原因是某个文字框的 confidenceNaN,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.utsutssdk/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.solibpaddle_light_api_shared.solibc++_shared.so)与模型资源(assets/
  • ⚠️ 不要再在 libs/ 下新建 arm64-v8aarmeabi-v7a 目录重复放 so。云打包会把 libs/<abi>/*.so 并入 jniLibs,与 AAR 内同名文件冲突,报 2 files found with path 'lib/arm64-v8a/libpaddle_light_api_shared.so' 导致打包失败

3. 导入 AAR 内的类,from 要写「包名」

UTS 会把 fromimport {} 里的符号拼接后再生成 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.0Double,而从 options 取出来的 options.angleNumber —— 两者写在同一行实参里,一个能过一个报错,很容易误判成「偶发」。

判据:只要这个值来自变量,就加 .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.jarUTSCallback.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_INFINITYsyntax error, pos 42
  • fastjson 解析 n 开头且后面不是 u/e 的 token → error parse new
  • success 回调跑在 postToMain 的主线程上,异常无人捕获 → 闪退

做法(分两层)

源头根治 —— 非有限数本来就不该从 native 出来。 本项目实测的元凶是 rec_process.ccRecPredictor::Postprocess():CTC 解码后直接 score /= count,而整条序列都是 blank 的框(该区域没有文字,例如图标、色块) count == 00.0f / 0NaN。已改为 count == 0score = 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装箱类型(生成 Kotlin Number),> < >= <= == != 一律走 io.dcloud.utscompareTo / numberEquals,而 Kotlin/Java 的 Double.compare 规定 NaN 大于一切、且 NaN 等于自身。用 HBuilderX 自带的 uts-runtime-release.jar 实测:

写法 NaN +∞ −∞ 0.9985
!(v >= 0) false ✗ 抓不到 NaN false true false
v != v false ✗ 自反性失效 false false false
v * 0 != 0 true 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

NativetoMat() 是「原样 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 里;不可把插件本身(或其修改版)当作插件/模板再发布或转售。

第三方开源组件的许可见上方「第三方组件与许可」。

更新日志

changelog.md

隐私、权限声明

1. 本插件需要申请的系统权限列表:

无(插件本身不申请任何系统权限)

2. 本插件采集的数据、发送的服务器地址、以及数据用途说明:

插件不采集任何数据

3. 本插件是否包含广告,如包含需详细说明广告表达方式、展示频率:

暂无用户评论。