视觉 AI 能力在移动端的落地,这两年明显从"能跑通"转向了"跑得稳、接得快"。HarmonyOS 7 上的 Core Vision Kit 把一批高频能力做成了开箱即用的系统级接口,其中人脸检测和**通用文字识别(OCR)**是绝大多数应用最先要用到的两块。我最近在一个 HarmonyOS 7 的项目里把这两个能力接进了实际业务,从权限申请、图片输入、结果解析到性能调优走了一整遍,中间踩的坑不算少。这篇就把整套流程拆开讲清楚,包括为什么这么设计、每一步的意图是什么、以及那些文档里不会写的实测经验。不管你是刚接触 HarmonyOS 视觉 AI 的新手,还是已经接过其他平台 OCR 想迁移过来的老手,应该都能直接抄作业。
1. 先搞清楚 Core Vision Kit 到底给了什么
1.1 视觉 AI 在端侧和云侧的取舍逻辑
在动手写代码之前,有个问题必须先想明白:人脸检测和 OCR 到底该放端侧还是云侧。这个选择直接决定了后面所有的架构设计。
云侧方案的优势是模型大、识别率高、支持语种多,但代价是网络依赖、延迟不可控、按调用量计费,而且图片要上传出去,涉及用户隐私的合规成本。端侧方案的优势是离线可用、响应快、数据不出设备,缺点是模型体积和算力受限,复杂场景(比如手写体、极端光照)的准确率会打折扣。
Core Vision Kit 走的是端侧路线。它把模型和推理引擎打包进系统能力里,应用层只需要调用接口、传图片、拿结果。这意味着几件事:第一,你不需要自己集成推理框架,也不用管模型文件怎么加载;第二,首次调用会有一定的初始化开销,这个后面会讲怎么处理;第三,识别能力受系统版本约束,HarmonyOS 7 上的能力比早期版本有明显提升。
我个人的判断是:表单录入、证件识别、名片扫描、实时人脸框选这类场景,端侧完全够用,而且体验更好。真正需要云侧的,是那种对准确率要求极高、且能接受异步等待的批量文档处理。这个项目里我选的是端侧,理由就是响应速度和隐私。
1.2 人脸检测和 OCR 各自解决什么问题
这两个能力虽然都属于视觉 AI,但解决的问题完全不同,接口设计思路也不一样。
人脸检测回答的是"这张图里有没有人脸、在哪、多大、什么姿态"。它输出的是人脸的边界框坐标、关键点位置(眼睛、鼻子、嘴角等)、以及一些属性(比如是否遮挡、朝向角度)。注意,人脸检测不等于人脸识别——检测只负责"找到脸",不负责"这是谁"。如果你要做身份核验,检测只是第一步,后面还得接特征提取和比对。
通用文字识别回答的是"这张图里有什么字、在哪、是什么"。它输出的是文本内容加文本框坐标,通常还会按行或按块组织。通用 OCR 的特点是"什么字都认",不限定领域,所以对排版规整的印刷体效果好,对艺术字、手写体、严重倾斜的文本就要看具体实现。
这两个能力在实际业务里经常配合使用。比如一个证件录入流程:先用人脸检测确认证件照区域有没有合格的人脸,再用 OCR 把证件上的姓名、编号、有效期读出来。这种组合拳是端侧视觉 AI 最典型的用法。
1.3 接入前的环境与版本确认
动手之前先确认三件事,能省掉后面一大堆莫名其妙的报错。
第一,SDK 版本。Core Vision Kit 的能力在不同 API 版本上有差异,HarmonyOS 7 对应的 API 版本要确认清楚。在build-profile.json5里检查compileSdkVersion和compatibleSdkVersion,确保目标设备支持你要用的接口。
第二,设备能力。虽然 Core Vision Kit 是系统能力,但不同设备的算力差异会影响推理速度。低端设备上做实时视频流的人脸检测可能会掉帧,这个要有心理预期。
第三,权限。如果图片来自相机或相册,需要申请对应的权限。相机是ohos.permission.CAMERA,读取媒体库是受限权限,需要在配置里声明并做运行时申请。纯做图片文件识别的话,如果图片是应用自己生成的,可以不涉及这些权限。
提示:Core Vision Kit 的接口调用本身通常不需要额外权限,权限问题基本都出在"图片来源"这一环。把图片来源和识别能力分开考虑,排查问题会清晰很多。
2. 人脸检测接入:从图片输入到结果解析
2.1 图片输入的三种形态与选择
人脸检测的输入是图片,而图片在 HarmonyOS 里有几种不同的承载形式,选错了会在接口调用时直接报类型不匹配。
最常见的是PixelMap,这是 HarmonyOS 里表示解码后位图的对象,几乎所有视觉接口都接受它。第二种是图片文件的ArrayBuffer或文件描述符,需要先解码成PixelMap。第三种是相机预览流里的帧数据,通常也是转成PixelMap或直接给ImageSource。
我的建议是:统一在应用层把各种来源的图片都转成PixelMap再喂给视觉接口。这样做的好处是接口调用逻辑单一,不用为每种来源写一套分支。转换的代价是一次解码,对于单张图片识别完全可以接受。
import { image } from '@kit.ImageKit'; // 从文件路径解码出 PixelMap async function decodeToPixelMap(filePath: string): Promise<image.PixelMap> { const file = await fs.open(filePath, fs.OpenMode.READ_ONLY); const imageSource = image.createImageSource(file.fd); // 按需设置解码选项,控制内存占用 const pixelMap = await imageSource.createPixelMap({ desiredPixelFormat: image.PixelMapFormat.RGBA_8888, desiredSize: { width: 1080, height: 1440 } }); await imageSource.release(); await fs.close(file); return pixelMap; }这里有个细节值得说:desiredSize不是随便设的。人脸检测对分辨率有下限要求,太小的图检测不到脸;但太大的图会拖慢推理。实测下来,长边控制在 1080 到 1600 之间是个比较稳的区间,既能保证小脸可检测,又不会让单帧推理时间失控。
2.2 检测接口的调用与参数含义
Core Vision Kit 的人脸检测接口核心就是"传图、拿结果"。但结果里的字段需要理解清楚,否则后面用坐标画框会画歪。
检测结果通常包含这几类信息:
| 字段类别 | 含义 | 典型用途 |
|---|---|---|
| 边界框 | 人脸在图片中的矩形区域 | 画框、裁剪人脸区域 |
| 关键点 | 眼睛、鼻尖、嘴角等坐标 | 姿态判断、活体检测辅助 |
| 置信度 | 该结果是脸的可信程度 | 过滤误检 |
| 角度信息 | 人脸的旋转、俯仰、偏转 | 判断是否正脸 |
调用时要注意坐标系。视觉接口返回的坐标通常以图片左上角为原点,单位是像素。但如果你把结果显示在屏幕上,屏幕坐标和图片坐标之间还差一个缩放和偏移的换算。这个换算没做对,框就会偏。我的做法是封装一个坐标映射函数,把图片坐标按显示区域的缩放比例换算过去,统一处理。
// 图片坐标 -> 屏幕坐标的映射 function mapToScreen( box: { left: number; top: number; width: number; height: number }, imageSize: { width: number; height: number }, displaySize: { width: number; height: number } ) { const scale = Math.min( displaySize.width / imageSize.width, displaySize.height / imageSize.height ); const offsetX = (displaySize.width - imageSize.width * scale) / 2; const offsetY = (displaySize.height - imageSize.height * scale) / 2; return { left: box.left * scale + offsetX, top: box.top * scale + offsetY, width: box.width * scale, height: box.height * scale }; }2.3 置信度阈值与误检处理
人脸检测最常见的坑是误检——把圆形图案、照片里的海报人脸、甚至某些纹理误判成人脸。处理办法就是设置信度阈值。
阈值设多少要看场景。如果只是画个框做提示,阈值可以低一点,比如 0.5,宁可多框也别漏。如果检测结果要驱动后续业务(比如触发拍照、触发识别),阈值就得高,0.8 甚至 0.9,避免误触发。
但阈值不是万能的。有些误检的置信度也很高,这时候要靠后处理规则:比如限制人脸框的最小尺寸(太小的框大概率是噪声)、限制宽高比(正常人脸接近 1:1 到 1:1.5)、限制人脸数量(某些场景只关心最大的一张脸)。
我在项目里用的组合是:置信度 0.7 起步,再叠加最小尺寸过滤,最后按面积排序取最大的人脸作为主目标。这套规则下来,误检率降得很明显。
2.4 实时检测的性能控制
如果做人脸检测是为了实时预览框选,性能就是命门。几个关键控制点:
降采样。预览流的分辨率不用太高,720p 甚至 480p 就够检测用了,检测完再把框映射回高分辨率预览上。这样推理量能降一大截。
跳帧。没必要每帧都检测。人脸的移动是连续的,隔一两帧检测一次,中间用插值平滑,肉眼几乎看不出差别,但算力省了一半。
异步与背压。检测是耗时操作,必须放异步。而且要防止上一帧还没检测完、下一帧又进来,导致任务堆积。用一个标志位或者任务队列控制,保证同一时刻只有一个检测在跑。
let isDetecting = false; async function onFrame(pixelMap: image.PixelMap) { if (isDetecting) { return; // 上一帧还没处理完,直接丢弃当前帧 } isDetecting = true; try { const result = await faceDetector.detect(pixelMap); renderResult(result); } finally { isDetecting = false; } }这个"丢弃而非排队"的策略很关键。排队会让延迟越积越大,最后画面和结果完全对不上;丢弃虽然丢了几帧,但保证了实时性。
3. 通用文字识别:让 OCR 真正好用起来
3.1 OCR 的输入预处理为什么不能省
很多人接 OCR 的第一反应是"把图丢进去就完事",然后发现识别率不理想,就怪模型不行。实际上,输入图片的质量对 OCR 结果的影响,比模型本身还大。
几个必须做的预处理:
方向校正。手机拍的照片经常带旋转信息,或者用户拍歪了。OCR 对倾斜文本的容忍度有限,倾斜超过一定角度识别率会断崖式下跌。如果图片有 EXIF 方向信息,解码时要应用;如果没有,可以考虑先做一次方向检测。
对比度增强。光照不均、阴影、反光都会让文字和背景的对比度下降。适度的对比度拉伸能让文字边缘更清晰,识别率提升明显。
尺寸控制。和检测一样,OCR 也有最佳分辨率区间。太小文字糊成一团,太大推理慢。一般长边 1600 到 2000 是比较好的平衡点。如果原图很大,先缩放;如果原图很小,适度放大反而有助于识别小字。
区域裁剪。如果业务只关心图片的某个区域(比如证件上的编号区),先把这块裁出来单独识别,比整图识别再筛选要准得多,也快得多。
注意:预处理不是越多越好。每一步处理都会引入计算开销,也可能引入新的失真。我的原则是"按需处理"——先看原始识别结果哪里不行,再针对性地加预处理,而不是一股脑全上。
3.2 识别结果的层级结构
OCR 返回的结果不是一坨纯文本,而是有层级的结构。理解这个结构,才能把结果用对。
典型的结构是三层:文本块(block)→ 文本行(line)→ 文本元素(element)。一个 block 可能是一段话或一个表格单元格,里面包含若干 line,每个 line 里是具体的文字和坐标。
为什么要分层?因为不同业务关心的粒度不同。做全文提取,把所有 element 的文字拼起来就行;做版面还原,需要按 block 和 line 的位置重新排版;做字段抽取,需要根据坐标定位到特定区域再取文字。
// 把识别结果按行组织成带坐标的结构 interface OcrLine { text: string; box: { left: number; top: number; width: number; height: number }; } function flattenToLines(result: OcrResult): OcrLine[] { const lines: OcrLine[] = []; for (const block of result.blocks) { for (const line of block.lines) { lines.push({ text: line.text, box: line.boundingBox }); } } // 按从上到下、从左到右排序,符合阅读顺序 return lines.sort((a, b) => { if (Math.abs(a.box.top - b.box.top) > 10) { return a.box.top - b.box.top; } return a.box.left - b.box.left; }); }这个排序逻辑看着简单,但很实用。OCR 返回的顺序不一定符合人的阅读顺序,尤其是多栏排版或者有浮动元素的时候。按坐标重排一遍,后续做字段匹配会顺很多。
3.3 从"识别出文字"到"提取出字段"
通用 OCR 只给你文字,但业务要的是结构化字段。这中间的鸿沟,得靠后处理填。
以识别一张票据为例,OCR 可能返回"金额 128.00 元"这样一行。你要提取的是金额数值 128.00。做法有几种:
关键词定位。找到包含"金额"的行,然后从这行里用正则抽出数字。简单直接,适合格式固定的场景。
坐标邻近。有些票据是"标签在左、值在右"的布局,可以找"金额"标签的坐标,然后在它右侧找最近的文本作为值。这种方式对排版变化更鲁棒。
模板匹配。如果票据版式固定,可以预先定义每个字段的相对位置,识别后按位置取。准确率最高,但换一种版式就失效。
我在项目里用的是关键词定位加坐标邻近的组合:先用关键词找到锚点行,再结合坐标找值,最后用正则做格式校验。三层下来,字段抽取的准确率比单纯正则高不少。
// 关键词 + 正则提取金额 function extractAmount(lines: OcrLine[]): number | null { const amountPattern = /(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)/; for (const line of lines) { if (line.text.includes('金额') || line.text.includes('合计')) { const match = line.text.match(amountPattern); if (match) { return parseFloat(match[1].replace(/,/g, '')); } } } return null; }3.4 多语言与特殊字符的处理
通用 OCR 通常支持中英文混排,但有些细节要注意。
中英文之间的空格。OCR 有时会在中英文交界处插入空格,有时又不插。如果后续要做文本比对或搜索,得统一处理,否则"订单号ABC123"和"订单号 ABC123"会被当成两个不同的串。
数字和字母的混淆。这是 OCR 的老问题:0 和 O、1 和 l、5 和 S。在纯数字场景(比如编号、金额),可以在后处理里做规则纠正——如果上下文确定是数字,就把 O 纠正成 0、l 纠正成 1。但这个纠正要谨慎,别把本该是字母的也改了。
标点符号。中文标点和英文标点在 OCR 里经常混。如果业务对格式敏感,需要做归一化。
这些处理都不难,但很琐碎。我的建议是封装一个文本清洗函数,把所有归一化逻辑集中在一处,别散落在业务代码里。
4. 两个能力协同:一个完整的证件录入流程
4.1 流程设计与职责划分
把前面两块拼起来,做一个证件录入的完整流程,能看清很多单独看时注意不到的问题。
流程大致是:用户拍照或选图 → 人脸检测确认证件照区域有合格人脸 → OCR 识别证件上的文字字段 → 字段校验与纠错 → 填充表单。
这里的关键设计是职责划分。人脸检测负责"这张图合不合格",OCR 负责"图里写了什么"。两者不互相依赖,可以并行也可以串行。我选的是串行,因为人脸检测很快,先跑它能快速筛掉不合格的图,避免在废图上浪费 OCR 的算力。
4.2 结果校验与用户纠错
端侧 OCR 再准也会有错,尤其是证件这种对准确率要求高的场景。所以必须给用户纠错的机会。
我的做法是:OCR 结果不直接提交,而是填进一个可编辑的表单,用户确认或修改后再提交。同时,对关键字段做格式校验——身份证号位数对不对、日期格式合不合法、金额是不是数字。校验不过的字段高亮提示,引导用户重点核对。
这个设计看着是"多了一步",但实际体验反而更好。用户知道系统识别了什么、哪里可能有问题,比默默提交一个错误结果再被打回要强得多。
4.3 异常情况的兜底
实际用起来,异常情况比正常情况还多。几个必须处理的:
检测不到人脸。可能是图里真没脸,也可能是脸太小、太模糊、角度太偏。提示用户重新拍摄,并给出具体原因("请确保人脸清晰、正对镜头")。
OCR 结果为空。可能是图太糊、光照太差、或者根本不是证件。同样要给出可操作的提示,而不是干巴巴一句"识别失败"。
识别超时。端侧推理一般很快,但低端设备或超大图可能超时。设一个超时阈值,超了就提示重试或换图。
内存不足。大图解码和推理都吃内存,低端设备上可能 OOM。控制图片尺寸、及时释放 PixelMap,是必须做的。
// 及时释放,避免内存堆积 async function processImage(pixelMap: image.PixelMap) { try { const faces = await faceDetector.detect(pixelMap); if (faces.length === 0) { throw new Error('NO_FACE_DETECTED'); } const ocrResult = await textRecognizer.recognize(pixelMap); return { faces, ocrResult }; } finally { // 无论成功失败都释放 await pixelMap.release(); } }finally里释放资源这个习惯,在视觉 AI 场景里特别重要。图片对象占的内存不小,忘了释放,跑几次就 OOM 了。
5. 实测中那些文档不会告诉你的坑
5.1 首次调用慢:初始化开销的处理
Core Vision Kit 的接口首次调用时,系统需要加载模型、初始化推理引擎,这个开销可能达到几百毫秒甚至更长。如果用户点了按钮才触发首次调用,就会感觉"卡了一下"。
处理办法是预热。在页面加载后、用户操作前,用一个小的占位图先调一次接口,把初始化开销提前消化掉。用户真正操作时,接口已经是热状态,响应就快了。
// 页面加载后预热,用一张极小的图触发初始化 async function warmUp() { const tinyMap = await createTinyPixelMap(); try { await faceDetector.detect(tinyMap); await textRecognizer.recognize(tinyMap); } catch (e) { // 预热失败不影响主流程,忽略 } finally { await tinyMap.release(); } }预热失败要静默处理,不能因为预热出问题就阻断用户操作。
5.2 图片方向导致的识别错乱
这个坑我踩得最深。手机拍的照片,很多时候像素数据是正的,但 EXIF 里标了旋转角度。如果解码时没应用这个角度,OCR 拿到的就是一张躺倒的图,识别结果要么为空,要么乱七八糟。
排查这个问题的过程挺折腾:一开始以为是模型问题,换了几张图发现有的好有的坏,后来才意识到是方向。解决办法是在解码阶段就读取并应用 EXIF 方向信息,保证喂给 OCR 的 PixelMap 是视觉上正向的。
提示:判断是不是方向问题,有个简单办法——把识别失败的图在相册里转正再试。如果转正后能识别,那基本就是方向没处理。
5.3 坐标缩放带来的框选偏移
前面提过坐标映射,这里展开说下为什么容易错。
图片坐标和屏幕坐标之间差着缩放和偏移。缩放比例取决于图片尺寸和显示区域尺寸的比值,而且要保持宽高比,所以会有留白,留白就是偏移。如果只做缩放不做偏移,框会整体偏;如果缩放比例算错,框的大小会不对。
更隐蔽的是图片本身的裁剪。如果解码时设了desiredSize,实际 PixelMap 的尺寸可能和原图不一样,那坐标基准就变了。所以要么记录实际尺寸,要么干脆不缩放,让坐标基准保持一致。
我的做法是:解码时不缩放(或者缩放后记录实际尺寸),坐标映射统一用实际 PixelMap 尺寸作为基准。这样逻辑最清晰,不容易错。
5.4 低端设备上的性能取舍
在低端设备上跑视觉 AI,必须做取舍。我的经验是:
| 场景 | 高端设备策略 | 低端设备策略 |
|---|---|---|
| 实时人脸检测 | 每帧检测,高分辨率 | 跳帧检测,低分辨率 |
| 单张 OCR | 原图识别 | 降采样后识别 |
| 多能力协同 | 并行处理 | 串行处理,优先关键能力 |
| 结果渲染 | 实时平滑 | 降低刷新频率 |
核心思路是保功能、降精度。功能不能少,但精度和实时性可以按设备能力动态调整。检测一下设备等级,或者干脆用运行时性能反馈来动态调参,都是可行的。
5.5 识别结果的稳定性问题
同一个图,多次识别结果可能不完全一致,尤其是置信度接近阈值的那些。这在业务上会造成困扰——用户重试一次,结果变了。
处理办法是结果缓存加稳定化。同一张图(用哈希标识)的识别结果缓存起来,重试时直接返回缓存,保证一致性。如果确实需要重新识别,也要对结果做稳定化处理,比如对置信度接近的候选做平滑。
这个坑不常遇到,但一旦遇到就很迷惑。提前知道有这么回事,排查时能少走弯路。
6. 把能力封装成可复用的模块
6.1 接口封装的设计原则
两个能力接完之后,如果不封装,业务代码里会散落一堆重复的调用逻辑。封装的目标是让业务层只关心"要什么",不关心"怎么调"。
封装的原则有三条:统一输入输出、隔离平台细节、暴露可配置项。统一输入输出是指不管底层接口怎么变,对外都是"传图、拿结构化结果";隔离平台细节是指业务层不直接碰 Core Vision Kit 的类型;暴露可配置项是指阈值、分辨率这些参数要能调,不能写死。
// 对外统一的视觉服务接口 interface VisionService { detectFaces(pixelMap: image.PixelMap, options?: FaceOptions): Promise<FaceResult[]>; recognizeText(pixelMap: image.PixelMap, options?: OcrOptions): Promise<OcrLine[]>; } interface FaceOptions { minConfidence?: number; // 默认 0.7 minSize?: number; // 默认 80 maxFaces?: number; // 默认 10 } interface OcrOptions { maxDimension?: number; // 默认 1600 language?: string; // 默认中英混排 }这样业务层调用就是一行,参数也能按场景调。
6.2 错误处理与降级策略
封装层还要统一处理错误。视觉 AI 的错误大致分几类:输入错误(图不合法)、能力错误(检测/识别失败)、资源错误(内存不足、超时)。每类错误的处理方式不同。
输入错误直接提示用户换图;能力错误可以重试一次;资源错误要降级——比如降低分辨率重试,或者提示用户稍后再试。
async function recognizeWithFallback( pixelMap: image.PixelMap ): Promise<OcrLine[]> { try { return await visionService.recognizeText(pixelMap); } catch (e) { // 降级:缩小尺寸重试一次 const smaller = await downscale(pixelMap, 0.6); try { return await visionService.recognizeText(smaller); } finally { await smaller.release(); } } }降级策略要有限度,不能无限重试。一般降一级就够了,再不行就报错让用户处理。
6.3 测试与验证方法
视觉 AI 的测试比普通逻辑难,因为结果不是确定的。我的做法是准备一组基准测试图:正常图、模糊图、倾斜图、多脸图、无脸图、纯文字图、中英混排图。每次改动后跑一遍,看结果有没有退化。
同时记录关键指标:检测耗时、识别耗时、内存占用、准确率(人工核对)。这些指标能帮你判断改动是优化还是劣化。
提示:基准图要覆盖真实场景,别只用干净的测试图。真实用户的图往往又糊又歪,用干净图测出来的好结果没有参考价值。
7. 一些延伸思考
Core Vision Kit 的这两个能力只是视觉 AI 的入口。往深了走,还有几个方向值得关注。
一是能力的组合。人脸检测加 OCR 只是最基础的组合,再往上可以接人脸特征提取做身份核验,接文字方向检测做版面分析,接表格识别做结构化提取。每加一层,能解决的问题就多一类。
二是端云协同。端侧负责快速筛选和初步识别,云侧负责高精度复核。这种架构在保证体验的同时,把准确率也拉上去了。关键是设计好什么时候走端、什么时候走云,以及两者结果怎么融合。
三是性能的持续优化。视觉 AI 的性能优化是个持续过程,模型在更新、设备在变化、业务场景在扩展。建立一套性能监控和基准测试机制,比一次性优化更重要。
我在这个项目里最大的体会是:端侧视觉 AI 的难点不在接口调用,而在工程化。接口文档看一遍就会调,但怎么让它在各种设备上稳定跑、怎么处理各种异常输入、怎么把结果变成业务能用的字段,这些才是真正花时间的地方。把封装做好、把异常处理做全、把性能控制住,这套能力才算真正落地。
最后分享一个实用的小技巧:如果你不确定某个识别结果为什么不对,先把中间过程的图片存下来看一眼。很多时候问题不在识别,而在输入——图是歪的、是糊的、是裁错的。看一眼中间图,比盯着代码猜半天快得多。