更新记录

1.0.0(2026-09-16)

  • 支持本地图片识别和实时扫描。
  • 支持普通文本及常用证件、车牌、银行卡、发票等结构化字段提取。

平台兼容性

uni-app(5.23)

Vue2 Vue3 Chrome Safari app-vue app-nvue Android iOS 鸿蒙
× × 8.0 × ×
微信小程序 支付宝小程序 抖音小程序 百度小程序 快手小程序 京东小程序 鸿蒙元服务 QQ小程序 飞书小程序 小红书小程序 快应用-华为 快应用-联盟
× × × × × × × × × × × ×

uni-app x(5.23)

Chrome Safari Android iOS 鸿蒙 微信小程序
× × 8.0 × × ×

yt-offlineocr

一款离线OCR插件,可识别本地图片,相机拍照识别,也可以打开实时扫描页面完成识别。运行期间无需请求网络接口,待识别图片和识别结果均保留在设备本地。支持身份证、银行卡、社保卡、车牌号、车架号、发票等

特别提醒

  • 购买本插件前,请先试用,请先试用,请先试用,确认满足需求之后再行购买。虚拟物品一旦购买之后无法退款。
  • 如有使用上的疑问、bug,可以进交流群联系作者;
  • 请在合法范围内使用,若使用本插件做非法开发,本方概不负责;
  • uniapp-x项目调用api需要加上 as 类型转换

平台要求

  • 仅支持 Android。
  • 最低支持 Android 8.0(API 26)。
  • 支持 armeabi-v7aarm64-v8a 设备。
  • 适配16kb
  • 实时扫描需要相机权限。
  • 使用原生能力,调试前需要重新制作包含本插件的自定义调试基座;修改插件后也需要重新制作基座。

支持的识别类型

mode 说明 实时扫描行为
TEXT 普通文本 显示识别内容,由用户点击确定返回
ID_CARD_FRONT 身份证正面 匹配成功后自动返回
ID_CARD_BACK 身份证反面 匹配成功后自动返回
LICENSE_PLATE 机动车车牌 匹配成功后自动返回
BANK_CARD 银行卡 匹配成功后自动返回
BUSINESS_LICENSE 营业执照 匹配成功后自动返回
SOCIAL_SECURITY_CARD 社会保障卡 匹配成功后自动返回
INVOICE 发票 匹配成功后自动返回
VIN VIN/车架号 匹配成功后自动返回
VEHICLE_LICENSE 行驶证 匹配成功后自动返回
PHONE_NUMBER 手机号码 匹配成功后自动返回
BICYCLE_PLATE 自行车牌照 匹配成功后自动返回

结构化类型只有在识别内容符合所选类型时才会自动返回。若选择了身份证正面,但取景框中不是身份证正面,扫描页面会继续识别,不会触发成功回调。

集成步骤

1. 放置插件

确认项目中存在以下目录:

uni_modules/yt-offlineocr

导入插件 API 时直接使用 uni_modules 路径,无需在 main.js 中注册。

2. 配置 Android 权限

在项目 manifest.jsonapp-plus.distribute.android.permissions 中加入:

"permissions": [
  "<uses-permission android:name=\"android.permission.CAMERA\"/>",
  "<uses-permission android:name=\"android.permission.READ_MEDIA_IMAGES\"/>",
  "<uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\" android:maxSdkVersion=\"32\"/>"
]

权限用途:

  • CAMERA:打开实时扫描页面。插件默认会在调用时检查并申请运行时权限。
  • READ_MEDIA_IMAGES:Android 13 及以上访问用户选择的相册图片。
  • READ_EXTERNAL_STORAGE:Android 12 及以下访问用户选择的相册图片。

如果项目原有 permissions 数组中已经存在同名权限,不要重复添加。

3. 导入 API

import {
  initializeOfflineOcr,
  recognizeOfflineOcr,
  startOfflineOcrScan,
  releaseOfflineOcr
} from '@/uni_modules/yt-offlineocr'

插件提供以下四个方法:

  • initializeOfflineOcr(options):提前初始化识别资源,可选调用。
  • recognizeOfflineOcr(options):识别一张本地图片。
  • startOfflineOcrScan(options):打开实时扫描页面。
  • releaseOfflineOcr(options):释放图片识别占用的资源。

提前初始化

初始化不是必需步骤。如果没有主动调用,第一次执行图片识别时会自动初始化。对首次识别响应速度有要求时,可以在进入业务页面后提前调用。

initializeOfflineOcr({
  cpuThreadCount: 4,
  detectionThreshold: 0.3,
  detectionBoxThreshold: 0.6,
  recognitionBatchSize: 1,
  maxImageDimension: 2560,
  success: result => {
    console.log('初始化成功', result)
  },
  fail: error => {
    console.log('初始化失败', error.errCode, error.errMsg)
  },
  complete: result => {
    console.log('初始化调用结束', result)
  }
})

初始化参数

参数 类型 必填 默认值 说明
cpuThreadCount number 4 识别使用的 CPU 线程数,允许范围 1~8
detectionThreshold number 0.3 文本检测阈值,允许范围 0~1
detectionBoxThreshold number 0.6 文本区域过滤阈值,允许范围 0~1
recognitionBatchSize number 1 单次识别批量大小,最小值为 1
maxImageDimension number 2560 输入图片最大边长,最小值为 640
success Function - 初始化成功回调
fail Function - 初始化失败回调
complete Function - 成功或失败后都会执行

一般业务建议先使用默认值。只有经过不同设备和图片场景测试后,再调整阈值或尺寸。

初始化成功结果

字段 类型 说明
success boolean 是否初始化成功
modelLoadMs number 初始化耗时,单位毫秒
message string 结果说明

识别相册图片

可以直接使用 uni.chooseImage 返回的临时图片路径:

uni.chooseImage({
  count: 1,
  sourceType: ['album'],
  success: chooseResult => {
    const imagePath = chooseResult.tempFilePaths[0]

    recognizeOfflineOcr({
      imagePath,
      mode: 'ID_CARD_FRONT',
      minConfidence: 0.35,
      success: result => {
        console.log('完整文字', result.text)
        console.log('逐行结果', result.lines)
        console.log('结构化字段', result.fields)
      },
      fail: error => {
        console.log('识别失败', error.errCode, error.errMsg)
      },
      complete: result => {
        console.log('图片识别结束', result)
      }
    })
  },
  fail: error => {
    console.log('没有选择图片', error)
  }
})

图片识别参数

参数 类型 必填 默认值 说明
imagePath string - 本地图片路径,可使用 uni.chooseImage 返回的路径、绝对路径、file://content://
mode OfflineOcrMode TEXT 识别类型
minConfidence number 0.35 最低置信度,允许范围 0~1
success Function - 识别成功回调
fail Function - 识别失败回调
complete Function - 成功或失败后都会执行

实时扫描

startOfflineOcrScan({
  mode: 'ID_CARD_FRONT',
  minConfidence: 0.35,
  hint: '',
  stableFrameCount: 2,
  requestCameraPermission: true,
  success: result => {
    console.log('扫描成功', result)
  },
  fail: error => {
    if (error.errCode === -31003) {
      console.log('用户取消了扫描')
      return
    }
    console.log('扫描失败', error.errCode, error.errMsg)
  },
  complete: result => {
    console.log('实时扫描结束', result)
  }
})

实时扫描参数

参数 类型 必填 默认值 说明
mode OfflineOcrMode TEXT 识别类型
minConfidence number 0.35 最低置信度,允许范围 0~1
hint string '' 取景框下方提示;留空时根据识别类型显示默认提示
stableFrameCount number 2 结构化结果连续稳定多少帧后自动返回,最小值为 1
requestCameraPermission boolean true 是否由插件检查并申请相机权限
success Function - 扫描成功回调
fail Function - 取消或扫描失败回调
complete Function - 成功、取消或失败后都会执行

建议保持 requestCameraPermission: true。只有业务页面已经完成相机权限申请并确认获得授权时,才设置为 false

扫描页面行为

  • 点击取景框区域可以重新聚焦。
  • 支持在扫描页打开或关闭补光灯。
  • 身份证正面、身份证反面会显示对应的取景提示。
  • TEXT 模式显示识别内容和确定按钮,由用户确认后返回。
  • TEXT 外的结构化类型不显示底部识别内容和确定按钮,匹配成功并达到稳定帧数后自动返回。
  • 点击关闭按钮或系统返回键时进入 fail 回调,错误码为 -31003

识别成功结果

图片识别和实时扫描使用相同的成功结果结构:

字段 类型 说明
status string 结果状态
mode string 本次使用的识别类型
text string 按行合并后的完整文字
lines Array<OfflineOcrLine> 逐行文字、置信度和位置
fields Array<OfflineOcrField> 当前识别类型提取出的结构化字段
warnings Array<string> 字段缺失、格式校验等提示;没有提示时为空数组
timing OfflineOcrTiming 各阶段耗时,单位毫秒
rawJson string 完整结果 JSON,可用于日志、存储或透传

lines 每项字段

字段 类型 说明
text string 当前行文字
confidence number 当前行置信度
points Array<{x, y}> 当前文字区域的顶点坐标

fields 每项字段

字段 类型 说明
name string 字段名称
value string 字段值

不同识别类型返回的字段名称和数量不同。业务代码应根据 name 查找字段,不要依赖数组固定下标。

释放资源

建议在识别业务页面销毁时调用:

export default {
  onUnload() {
    releaseOfflineOcr({
      success: result => {
        console.log(result.message)
      }
    })
  }
}

释放后仍可以继续调用初始化或图片识别,插件会重新准备所需资源。

错误结果

fail 回调收到的对象结构:

字段 类型 说明
errCode number 插件统一错误码,建议业务主要判断此字段
errMsg string 可读错误说明
code string 扩展错误标识
cause string \| null 更详细的错误原因,可能为空

错误码

errCode 说明 建议处理
-31001 当前页面不可用于启动原生功能 确认应用位于前台后重试
-31002 相机权限被拒绝 提示用户授权;永久拒绝时引导到系统设置
-31003 用户取消实时扫描 通常无需提示错误
-31004 已有实时扫描正在进行 等待本次扫描结束后再调用
-31005 图片路径为空 检查 imagePath
-31006 识别结果解析失败 记录错误信息并重试
-31101 初始化失败 释放后重试,并检查设备可用内存
-31102 图片识别失败 检查图片路径、格式和文件可读性

使用注意事项

  1. 插件方法均为异步回调形式,不要使用同步返回值接收识别结果。
  2. 第一次识别准备时间通常比后续识别长,可在业务空闲时提前初始化。
  3. 同一时间只允许打开一个实时扫描页面;请防止按钮连续点击。
  4. 结构化类型必须与实际证件或号码类型一致,否则实时扫描不会自动成功返回。
  5. 拍摄时应保证文字完整、光线均匀、画面清晰,尽量避免反光、阴影和大角度倾斜。
  6. minConfidence 越高,结果过滤越严格;不确定时建议保留默认值。
  7. 大图片会增加识别耗时和内存占用,通常无需主动提高 maxImageDimension
  8. 页面销毁后如不再继续识别,应调用 releaseOfflineOcr
  9. 修改插件文件或升级插件后,需要重新制作自定义调试基座,旧基座不会包含最新原生能力。
  10. 完整可运行示例位于项目 pages/index/index.vue

更多好用实惠插件

uniapp完整示例

<template>
    <scroll-view class="page" scroll-y>
        <view class="header">
            <text class="title">离线 OCR</text>
            <text class="subtitle">yt-offlineocr ·  UTS插件 调用示例</text>
        </view>

        <view class="card tip-card">
            <text class="card-title">功能说明</text>
            <text class="tip">模型、图片和识别结果全部保留在设备本地,不依赖网络接口。</text>
            <text class="tip">普通文本实时扫描需要手动确认;身份证、银行卡、车牌等结构化类型匹配成功后自动返回。</text>
        </view>

        <view class="card">
            <text class="card-title">1. 选择识别类型</text>
            <picker :range="modeLabels" :value="selectedModeIndex" @change="onModeChange">
                <view class="picker-value">{{ selectedModeLabel }} ›</view>
            </picker>
            <text class="mode-code">原生模式:{{ selectedMode }}</text>
        </view>

        <view class="card">
            <text class="card-title">2. 初始化与图片识别</text>
            <text class="tip">初始化不是必需步骤;不调用时,第一次图片识别会使用默认参数自动加载模型。</text>
            <button type="primary" :disabled="busy" @click="onInitialize">提前初始化离线模型</button>
            <button class="secondary-button" :disabled="busy" @click="onChooseImage">选择图片并识别</button>
            <image v-if="imagePath" class="preview-image" :src="imagePath" mode="aspectFit" />
            <text v-if="imagePath" class="path-text">图片:{{ imagePath }}</text>
        </view>

        <view class="card">
            <text class="card-title">3. 实时扫描</text>
            <text class="tip">插件会按需申请 CAMERA 权限,然后打开 AAR 内置的 CameraX 扫描页面。</text>
            <button type="primary" :disabled="busy" @click="onStartScan">打开实时扫描</button>
        </view>

        <view class="card result-card">
            <view class="result-header">
                <text class="card-title">调用结果</text>
                <button class="mini-button" size="mini" :disabled="busy" @click="onRelease">释放模型</button>
            </view>
            <text class="status">{{ statusText }}</text>

            <template v-if="lastResult">
                <text class="section-title">完整文字</text>
                <text class="result-text">{{ lastResult.text || '未识别到文字' }}</text>

                <template v-if="lastResult.fields && lastResult.fields.length">
                    <text class="section-title">结构化字段</text>
                    <view v-for="(field, index) in lastResult.fields" :key="index" class="field-row">
                        <text class="field-name">{{ field.name }}</text>
                        <text class="field-value">{{ field.value }}</text>
                    </view>
                </template>

                <text class="section-title">耗时</text>
                <text class="timing-text">检测 {{ lastResult.timing.detectionMs }}ms;识别 {{ lastResult.timing.recognitionMs }}ms;总计 {{ lastResult.timing.totalMs }}ms</text>
            </template>

            <text class="section-title">完整回调对象</text>
            <text class="json-text" selectable>{{ resultJson }}</text>
        </view>
        <view class="bottom-space"></view>
    </scroll-view>
</template>

<script>
    import {
        initializeOfflineOcr,
        recognizeOfflineOcr,
        startOfflineOcrScan,
        releaseOfflineOcr
    } from '@/uni_modules/yt-offlineocr'

    export default {
        data() {
            return {
                /** 页面是否正在初始化、识别或等待扫描结果,用于防止重复点击。 */
                busy: false,
                /** uni.chooseImage 返回的本地临时路径,直接传给 UTS 插件。 */
                imagePath: '',
                /** 最近一次成功的 OfflineOcrResult,模板用它展示文字和字段。 */
                lastResult: null,
                /** success/fail/complete 的调试输出。 */
                resultJson: '尚未调用',
                statusText: '请选择识别类型,然后初始化、选择图片或打开实时扫描。',
                selectedModeIndex: 0,
                /**
                 * value 必须与原生 OcrMode 枚举一致;label 仅用于页面展示。
                 * 若业务只需要少数场景,可以删除不使用的选项。
                 */
                modeOptions: [
                    { label: '普通文本', value: 'TEXT' },
                    { label: '身份证正面', value: 'ID_CARD_FRONT' },
                    { label: '身份证反面', value: 'ID_CARD_BACK' },
                    { label: '车牌', value: 'LICENSE_PLATE' },
                    { label: '银行卡', value: 'BANK_CARD' },
                    { label: '营业执照', value: 'BUSINESS_LICENSE' },
                    { label: '社会保障卡', value: 'SOCIAL_SECURITY_CARD' },
                    { label: '发票', value: 'INVOICE' },
                    { label: 'VIN 车架号', value: 'VIN' },
                    { label: '行驶证', value: 'VEHICLE_LICENSE' },
                    { label: '手机号码', value: 'PHONE_NUMBER' },
                    { label: '自行车牌照', value: 'BICYCLE_PLATE' }
                ]
            }
        },

        computed: {
            modeLabels() {
                return this.modeOptions.map(item => item.label)
            },
            selectedMode() {
                return this.modeOptions[this.selectedModeIndex].value
            },
            selectedModeLabel() {
                return this.modeOptions[this.selectedModeIndex].label
            }
        },

        onUnload() {
            // 页面销毁时释放图片识别复用的 ONNX Session,避免长期占用内存。
            // 实时扫描 Activity 内部使用自己的 Client,退出扫描页时已自动释放。
            releaseOfflineOcr({})
        },

        methods: {
            /** picker 返回的是字符串索引,需要转成 Number 后保存。 */
            onModeChange(event) {
                this.selectedModeIndex = Number(event.detail.value)
                this.lastResult = null
                this.statusText = `已选择:${this.selectedModeLabel}`
            },

            /** 将任意回调对象格式化后同时写入状态区和控制台。 */
            showCallback(callbackName, value) {
                this.resultJson = JSON.stringify(value, null, 2)
                console.log(`[yt-offlineocr] ${callbackName}: ${this.resultJson}`)
            },

            /**
             * 提前加载 PP-OCRv6 检测、识别模型。
             * cpuThreadCount 可按设备性能调整,常规 Android 手机推荐 2~4。
             */
            onInitialize() {
                this.busy = true
                this.statusText = '正在初始化离线模型…'
                initializeOfflineOcr({
                    cpuThreadCount: 4,
                    detectionThreshold: 0.3,
                    detectionBoxThreshold: 0.6,
                    recognitionBatchSize: 1,
                    maxImageDimension: 2560,
                    success: result => {
                        this.statusText = `模型初始化成功,加载耗时 ${result.modelLoadMs}ms`
                        this.showCallback('initialize success', result)
                    },
                    fail: error => {
                        this.statusText = `初始化失败:${error.errMsg}`
                        this.showCallback('initialize fail', error)
                    },
                    complete: () => {
                        this.busy = false
                    }
                })
            },

            /** 从系统相册选择一张图片,再调用 AAR 的异步图片识别接口。 */
            onChooseImage() {
                uni.chooseImage({
                    count: 1,
                    sourceType: ['album'],
                    success: chooseResult => {
                        const path = chooseResult.tempFilePaths[0]
                        this.imagePath = path
                        this.recognizeImage(path)
                    }
                })
            },

            /**
             * 识别指定本地图片。
             * 结构化类型仍会返回 text/lines,同时尝试在 fields 中提取对应字段。
             */
            recognizeImage(path) {
                this.busy = true
                this.lastResult = null
                this.statusText = `正在识别${this.selectedModeLabel}…`
                recognizeOfflineOcr({
                    imagePath: path,
                    mode: this.selectedMode,
                    minConfidence: 0.35,
                    success: result => {
                        this.lastResult = result
                        this.statusText = `识别完成,共 ${result.lines.length} 行,${result.fields.length} 个结构化字段。`
                        this.showCallback('recognize success', result)
                    },
                    fail: error => {
                        this.statusText = `图片识别失败:${error.errMsg}`
                        this.showCallback('recognize fail', error)
                    },
                    complete: () => {
                        this.busy = false
                    }
                })
            },

            /**
             * 打开 CameraX 实时扫描页。
             * hint 传空字符串,让原生页面按照 mode 自动生成取景框提示。
             */
            onStartScan() {
                this.busy = true
                this.lastResult = null
                this.statusText = `正在打开${this.selectedModeLabel}实时扫描…`
                startOfflineOcrScan({
                    mode: this.selectedMode,
                    minConfidence: 0.35,
                    hint: '',
                    stableFrameCount: 2,
                    requestCameraPermission: true,
                    success: result => {
                        this.lastResult = result
                        this.statusText = `实时扫描成功,返回 ${result.fields.length} 个结构化字段。`
                        this.showCallback('scan success', result)
                    },
                    fail: error => {
                        this.statusText = `实时扫描未完成:${error.errMsg}`
                        this.showCallback('scan fail', error)
                    },
                    complete: () => {
                        this.busy = false
                    }
                })
            },

            /** 手动释放图片识别模型;释放后仍可再次初始化或识别。 */
            onRelease() {
                releaseOfflineOcr({
                    success: result => {
                        this.lastResult = null
                        this.statusText = result.message
                        this.showCallback('release success', result)
                    }
                })
            }
        }
    }
</script>

<style>
    page {
        background: #f3f5f8;
    }

    .page {
        height: 100vh;
        box-sizing: border-box;
        padding: 28rpx;
    }

    .header {
        display: flex;
        flex-direction: column;
        padding: 22rpx 8rpx 30rpx;
    }

    .title {
        font-size: 46rpx;
        font-weight: 700;
        color: #172033;
    }

    .subtitle {
        margin-top: 10rpx;
        font-size: 26rpx;
        color: #697386;
    }

    .card {
        margin-bottom: 24rpx;
        padding: 28rpx;
        border-radius: 22rpx;
        background: #ffffff;
        box-shadow: 0 8rpx 28rpx rgba(30, 54, 86, 0.07);
    }

    .tip-card {
        background: #eef7ff;
    }

    .card-title,
    .section-title {
        display: block;
        font-size: 31rpx;
        font-weight: 600;
        color: #172033;
    }

    .section-title {
        margin-top: 26rpx;
        font-size: 28rpx;
    }

    .tip,
    .mode-code,
    .path-text,
    .timing-text {
        display: block;
        margin-top: 14rpx;
        font-size: 25rpx;
        line-height: 1.55;
        color: #667085;
    }

    .picker-value {
        margin-top: 20rpx;
        padding: 22rpx;
        border: 1px solid #d8dee8;
        border-radius: 14rpx;
        font-size: 30rpx;
        color: #1d2939;
    }

    button {
        margin-top: 22rpx;
    }

    .secondary-button {
        color: #2457d6;
        background: #edf3ff;
    }

    .preview-image {
        width: 100%;
        height: 360rpx;
        margin-top: 22rpx;
        border-radius: 14rpx;
        background: #101828;
    }

    .result-header {
        display: flex;
        align-items: center;
        justify-content: space-between;
    }

    .mini-button {
        margin: 0;
    }

    .status {
        display: block;
        margin-top: 18rpx;
        padding: 18rpx;
        border-radius: 12rpx;
        font-size: 26rpx;
        line-height: 1.5;
        color: #1d4ed8;
        background: #eff6ff;
    }

    .result-text,
    .json-text {
        display: block;
        margin-top: 14rpx;
        padding: 18rpx;
        border-radius: 12rpx;
        font-size: 25rpx;
        line-height: 1.6;
        color: #344054;
        background: #f8fafc;
        word-break: break-all;
        white-space: pre-wrap;
    }

    .json-text {
        font-family: monospace;
        font-size: 22rpx;
    }

    .field-row {
        display: flex;
        padding: 16rpx 0;
        border-bottom: 1px solid #edf0f4;
    }

    .field-name {
        width: 220rpx;
        font-size: 25rpx;
        color: #667085;
    }

    .field-value {
        flex: 1;
        font-size: 25rpx;
        color: #101828;
        word-break: break-all;
    }

    .bottom-space {
        height: 60rpx;
    }
</style>

uniappx完整示例

<template>
    <scroll-view class="page" scroll-y>
        <view class="header">
            <text class="title">离线 OCR</text>
            <text class="subtitle">yt-offlineocr · uni-app x 调用示例</text>
        </view>

        <view class="card tip-card">
            <text class="card-title">功能说明</text>
            <text class="tip">图片和识别结果均在设备本地处理,不依赖网络接口。</text>
            <text class="tip">普通文本需要手动确认;身份证、银行卡、车牌等结构化类型匹配成功后自动返回。</text>
        </view>

        <view class="card">
            <text class="card-title">1. 选择识别类型</text>
            <picker :range="modeLabels" :value="selectedModeIndex" @change="onModeChange">
                <view class="picker-value">{{ selectedModeLabel }} ›</view>
            </picker>
            <text class="mode-code">识别类型:{{ selectedMode }}</text>
        </view>

        <view class="card">
            <text class="card-title">2. 初始化与图片识别</text>
            <text class="tip">初始化可选;提前初始化可减少首次图片识别的等待时间。</text>
            <button class="action-button" type="primary" :disabled="busy" @click="onInitialize">提前初始化</button>
            <button class="action-button secondary-button" :disabled="busy" @click="onChooseImage">选择相册图片并识别</button>
            <image v-if="imagePath.length > 0" class="preview-image" :src="imagePath" mode="aspectFit" />
            <text v-if="imagePath.length > 0" class="path-text">图片:{{ imagePath }}</text>
        </view>

        <view class="card">
            <text class="card-title">3. 实时扫描</text>
            <text class="tip">调用后会先检查相机权限,授权成功后打开实时扫描页面。</text>
            <button class="action-button" type="primary" :disabled="busy" @click="onStartScan">打开实时扫描</button>
        </view>

        <view class="card result-card">
            <view class="result-header">
                <text class="card-title">调用结果</text>
                <button class="mini-button" size="mini" :disabled="busy" @click="onRelease">释放资源</button>
            </view>
            <text class="status">{{ statusText }}</text>

            <template v-if="lastResult != null">
                <text class="section-title">完整文字</text>
                <text class="result-text">{{ displayResult.text.length > 0 ? displayResult.text : '未识别到文字' }}</text>

                <template v-if="displayResult.fields.length > 0">
                    <text class="section-title">结构化字段</text>
                    <view v-for="(field, index) in displayResult.fields" :key="index" class="field-row">
                        <text class="field-name">{{ field.name }}</text>
                        <text class="field-value">{{ field.value }}</text>
                    </view>
                </template>

                <text class="section-title">耗时</text>
                <text class="timing-text">检测 {{ displayResult.timing.detectionMs }}ms;识别
                    {{ displayResult.timing.recognitionMs }}ms;总计 {{ displayResult.timing.totalMs }}ms</text>
            </template>

            <text class="section-title">完整回调对象</text>
            <text class="json-text" selectable>{{ resultJson }}</text>
        </view>
        <view class="bottom-space" />
    </scroll-view>
</template>

<script setup lang="uts">
    import {
        initializeOfflineOcr,
        recognizeOfflineOcr,
        startOfflineOcrScan,
        releaseOfflineOcr
    } from '@/uni_modules/yt-offlineocr'
    import {
        OfflineOcrMode,
        OfflineOcrResult,
        OfflineOcrInitResult,
        OfflineOcrReleaseResult,
        OfflineOcrFail
    } from '@/uni_modules/yt-offlineocr/utssdk/interface.uts'

    /** picker 展示项;value 必须与插件公开的 OfflineOcrMode 一致。 */
    type ModeOption = {
        label : string
        value : OfflineOcrMode
    }

    /** 页面所有可选类型。数组在初始化后不会修改,选择状态单独保存在 selectedModeIndex。 */
    const modeOptions : Array<ModeOption> = [
        { label: '普通文本', value: 'TEXT' },
        { label: '身份证正面', value: 'ID_CARD_FRONT' },
        { label: '身份证反面', value: 'ID_CARD_BACK' },
        { label: '车牌', value: 'LICENSE_PLATE' },
        { label: '银行卡', value: 'BANK_CARD' },
        { label: '营业执照', value: 'BUSINESS_LICENSE' },
        { label: '社会保障卡', value: 'SOCIAL_SECURITY_CARD' },
        { label: '发票', value: 'INVOICE' },
        { label: 'VIN 车架号', value: 'VIN' },
        { label: '行驶证', value: 'VEHICLE_LICENSE' },
        { label: '手机号码', value: 'PHONE_NUMBER' },
        { label: '自行车牌照', value: 'BICYCLE_PLATE' }
    ]

    /** 防止初始化、图片识别或实时扫描进行期间重复点击操作按钮。 */
    const busy = ref(false)
    /** 最近一次通过 uni.chooseImage 选择的本地图片路径。 */
    const imagePath = ref('')
    /** 最近一次成功结果;null 表示尚未成功识别。 */
    const lastResult = ref<OfflineOcrResult | null>(null)
    /**
     * 模板展示的非空结果占位值。
     *
     * uvue 模板不会因 v-if 自动把 OfflineOcrResult | null 收窄为非空类型,
     * 因此通过 displayResult 统一提供非空对象;实际内容仍只在 lastResult 非空时显示。
     */
    const emptyResult : OfflineOcrResult = {
        status: '',
        mode: 'TEXT',
        text: '',
        lines: [],
        fields: [],
        warnings: [],
        timing: {
            modelLoadMs: 0,
            detectionMs: 0,
            recognitionMs: 0,
            totalMs: 0
        },
        rawJson: ''
    }
    /** 将 success/fail 回调对象序列化后展示,便于调试与核对字段。 */
    const resultJson = ref('尚未调用')
    /** 当前操作的用户可读状态提示。 */
    const statusText = ref('请选择识别类型,然后初始化、选择图片或打开实时扫描。')
    /** picker 的选中下标。 */
    const selectedModeIndex = ref(0)

    /** 为模板提供永不为 null 的识别结果,规避 uvue 模板的可空类型限制。 */
    const displayResult = computed(() : OfflineOcrResult => {
        const result = lastResult.value
        return result != null ? result : emptyResult
    })

    /** picker 的文字数组。UTS 中显式循环创建数组,避免依赖 JS 动态数组推断。 */
    const modeLabels = computed(() : Array<string> => {
        const labels : Array<string> = []
        for (let index = 0; index < modeOptions.length; index++) {
            labels.push(modeOptions[index].label)
        }
        return labels
    })

    /** 当前选择项。下标始终由 picker 返回值限制在 modeOptions 的有效范围内。 */
    const selectedOption = computed(() : ModeOption => {
        return modeOptions[selectedModeIndex.value]
    })

    /** 传给插件的识别类型。 */
    const selectedMode = computed(() : OfflineOcrMode => {
        return selectedOption.value.value
    })

    /** 页面展示用的识别类型名称。 */
    const selectedModeLabel = computed(() : string => {
        return selectedOption.value.label
    })

    /**
     * 将回调数据保存到页面并打印日志。
     * JSON.stringify 只使用一个参数,兼容 uni-app x 的 UTS 运行环境。
     */
    function showCallback(callbackName : string, value : any) : void {
        resultJson.value = JSON.stringify(value)
        console.log('[yt-offlineocr] ' + callbackName + ': ' + resultJson.value)
    }

    /**
     * uni-app x Android 的 picker value 运行时为 number,不能强转成 string。
     * 直接读取 number 后更新当前类型并清空旧结果。
     */
    function onModeChange(event : UniPickerChangeEvent) : void {
        const index = event.detail.value as number
        if (index < 0 || index >= modeOptions.length) {
            return
        }
        selectedModeIndex.value = index
        lastResult.value = null
        statusText.value = '已选择:' + selectedModeLabel.value
    }

    /** 提前初始化识别资源。初始化为异步操作,结束状态由 complete 统一恢复按钮。 */
    function onInitialize() : void {
        busy.value = true
        statusText.value = '正在初始化…'
        initializeOfflineOcr({
            cpuThreadCount: 4,
            detectionThreshold: 0.3,
            detectionBoxThreshold: 0.6,
            recognitionBatchSize: 1,
            maxImageDimension: 2560,
            success: (result : OfflineOcrInitResult) => {
                statusText.value = '初始化成功,耗时 ' + result.modelLoadMs + 'ms'
                showCallback('initialize success', result)
            },
            fail: (error : OfflineOcrFail) => {
                statusText.value = '初始化失败:' + error.errMsg
                showCallback('initialize fail', error)
            },
            complete: () => {
                busy.value = false
            }
        })
    }

    /**
     * 调用插件识别指定图片;结构化类型的字段会在 result.fields 中返回。
     *
     * 此函数必须声明在 onChooseImage 之前。UTS 不支持此处的函数前置引用,
     * 否则在 chooseImage success 回调中无法解析 recognizeImage 名称。
     */
    function recognizeImage(path : string) : void {
        busy.value = true
        lastResult.value = null
        statusText.value = '正在识别' + selectedModeLabel.value + '…'
        recognizeOfflineOcr({
            imagePath: path,
            mode: selectedMode.value,
            minConfidence: 0.35,
            success: (result : OfflineOcrResult) => {
                lastResult.value = result
                statusText.value = '识别完成,共 ' + result.lines.length + ' 行,' + result.fields.length + ' 个结构化字段。'
                showCallback('recognize success', result)
            },
            fail: (error : OfflineOcrFail) => {
                statusText.value = '图片识别失败:' + error.errMsg
                showCallback('recognize fail', error)
            },
            complete: () => {
                busy.value = false
            }
        })
    }

    /** 从相册选择一张图片,成功后把临时路径直接传给插件。 */
    function onChooseImage() : void {
        uni.chooseImage({
            count: 1,
            sourceType: ['album'],
            success: (chooseResult) => {
                const path = chooseResult.tempFilePaths[0]
                imagePath.value = path
                recognizeImage(path)
            },
            fail: (error) => {
                statusText.value = '未选择图片:' + error.errMsg
                showCallback('choose image fail', error)
            }
        })
    }

    /** 打开实时扫描页;插件会按 requestCameraPermission 申请相机运行时权限。 */
    function onStartScan() : void {
        busy.value = true
        lastResult.value = null
        statusText.value = '正在打开' + selectedModeLabel.value + '实时扫描…'
        startOfflineOcrScan({
            mode: selectedMode.value,
            minConfidence: 0.35,
            hint: '',
            stableFrameCount: 2,
            requestCameraPermission: true,
            success: (result : OfflineOcrResult) => {
                lastResult.value = result
                statusText.value = '实时扫描成功,返回 ' + result.fields.length + ' 个结构化字段。'
                showCallback('scan success', result)
            },
            fail: (error : OfflineOcrFail) => {
                statusText.value = '实时扫描未完成:' + error.errMsg
                showCallback('scan fail', error)
            },
            complete: () => {
                busy.value = false
            }
        })
    }

    /** 手动释放图片识别资源;释放后仍可再次初始化或继续识别。 */
    function onRelease() : void {
        releaseOfflineOcr({
            success: (result : OfflineOcrReleaseResult) => {
                lastResult.value = null
                statusText.value = result.message
                showCallback('release success', result)
            }
        })
    }

    /** 页面销毁时释放资源,避免离开示例页后继续占用内存。 */
    onUnload(() => {
        releaseOfflineOcr({})
    })
</script>

<style>
    .page {
        flex: 1;
        box-sizing: border-box;
        padding: 28rpx;
        background: #f3f5f8;
    }

    .header {
        display: flex;
        flex-direction: column;
        padding: 22rpx 8rpx 30rpx;
    }

    .title {
        font-size: 46rpx;
        font-weight: 700;
        color: #172033;
    }

    .subtitle {
        margin-top: 10rpx;
        font-size: 26rpx;
        color: #697386;
    }

    .card {
        margin-bottom: 24rpx;
        padding: 28rpx;
        border-radius: 22rpx;
        background: #ffffff;
        box-shadow: 0 8rpx 28rpx rgba(30, 54, 86, 0.07);
    }

    .tip-card {
        background: #eef7ff;
    }

    .card-title,
    .section-title {
        font-size: 31rpx;
        font-weight: 600;
        color: #172033;
    }

    .section-title {
        margin-top: 26rpx;
        font-size: 28rpx;
    }

    .tip,
    .mode-code,
    .path-text,
    .timing-text {
        margin-top: 14rpx;
        font-size: 25rpx;
        line-height: 1.55;
        color: #667085;
    }

    .picker-value {
        margin-top: 20rpx;
        padding: 22rpx;
        border: 1px solid #d8dee8;
        border-radius: 14rpx;
        font-size: 30rpx;
        color: #1d2939;
    }

    .action-button {
        margin-top: 22rpx;
    }

    .secondary-button {
        color: #2457d6;
        background: #edf3ff;
    }

    .preview-image {
        width: 100%;
        height: 360rpx;
        margin-top: 22rpx;
        border-radius: 14rpx;
        background: #101828;
    }

    .result-header {
        display: flex;
        align-items: center;
        justify-content: space-between;
    }

    .mini-button {
        margin: 0;
    }

    .status {
        margin-top: 18rpx;
        padding: 18rpx;
        border-radius: 12rpx;
        font-size: 26rpx;
        line-height: 1.5;
        color: #1d4ed8;
        background: #eff6ff;
    }

    .result-text,
    .json-text {
        margin-top: 14rpx;
        padding: 18rpx;
        border-radius: 12rpx;
        font-size: 25rpx;
        line-height: 1.6;
        color: #344054;
        background: #f8fafc;
        white-space: pre-wrap;
    }

    .json-text {
        font-family: monospace;
        font-size: 22rpx;
    }

    .field-row {
        display: flex;
        padding: 16rpx 0;
        border-bottom: 1px solid #edf0f4;
    }

    .field-name {
        width: 220rpx;
        font-size: 25rpx;
        color: #667085;
    }

    .field-value {
        flex: 1;
        font-size: 25rpx;
        color: #101828;
    }

    .bottom-space {
        height: 60rpx;
    }
</style>

隐私、权限声明

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

实时扫描需要 CAMERA 相机权限。

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

插件不采集任何数据

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

暂无用户评论。