ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

【共创稿事节】HarmonyOS `textRecognition` 文字识别:3 个坑翻车后,我终于跑通了

【共创稿事节】HarmonyOS `textRecognition` 文字识别:3 个坑翻车后,我终于跑通了 前言看到社区有个有趣的提问是关于文字识别的, 原话如下:在做发票文字提取功能,按照官方示例的十来行代码,结果一跑翻车啦:第一次调 recognizeText 直接抛错,报运行失败;处理完图片再识别,接口调用成功,但返回的文本是空的;偶尔结果只有一半,或明明有文字却显示 未识别。想搞清楚 textRecognition 从初始化到拿结果,到底哪一步容易漏或踩坑问题地址: textRecognition 识别图片:直接调用抛错、换张图返回空文本、结果还不完整,哪里没写对?。其实关于文字识别还是比较简单的(相对而言),这篇文章我会详细的把每一步为什么这样写讲清楚。你也可以直接跳到最后的完整示例对照自己的项目排查。相关效果图手里没有合适的发票我从网上下载了一张发票接下来主要是对该发票的识别UI 界面如下文字识别结果如下语言识别结果如下先记住这条链路完整流程可以压缩成下面 6 步用PhotoViewPicker选择图片拿到file://URI。通过fileIo.openSync()打开 URI拿到文件描述符fd。用fd创建ImageSource再生成RGBA_8888格式的PixelMap。API 12 及以上先调用textRecognition.init()。保证PixelMap一直存活到recognizeText()完成。识别结束后按顺序释放PixelMap、文件和 OCR 引擎资源。后面所有代码都是围绕这条链路展开的。开始之前导入和权限用textRecognition需要从kit.CoreVisionKit导入。图片处理用kit.ImageKit选图用kit.CoreFileKit的 picker读文件也要kit.CoreFileKit的fileIoimport{textRecognition}fromkit.CoreVisionKitimport{image}fromkit.ImageKitimport{picker}fromkit.CoreFileKitimport{fileIoasfs}fromkit.CoreFileKitimport{BusinessError}fromkit.BasicServicesKit权限方面用PhotoViewPicker选图不需要额外权限picker 自己处理了。module.json5里已有的READ_IMAGEVIDEO权限在 API 12 上其实也不需要了。如果你直接用photoAccessHelper访问相册而不是用 picker那才需要。如下图所示直接访问我的本地相册先把图片正确交给 OCR选图picker 返回的 URI 不能直接用选图这部分本身不难用PhotoViewPicker就行constoptionsnewpicker.PhotoSelectOptions()options.MIMETypepicker.PhotoViewMIMETypes.IMAGE_TYPEoptions.maxSelectNumber1constphotoPickernewpicker.PhotoViewPicker()constresult:picker.PhotoSelectResultawaitphotoPicker.select(options)constimageUri:stringresult.photoUris[0]拿到imageUri之后关键问题来了。坑 1createImageSource(imageUri)报路径错误picker 返回的 URI 长这样file://media/Photo/1925/IMG_xxx.jpg。很多人直接拿这个字符串传给image.createImageSource(imageUri)然后报错path (media/Photo/1925/...) to realpath error: No such file or directory [FileSourceStream] input the file path exception, errno:2. CreateImageSourceExec error原因是createImageSource(path)接受的是沙箱文件路径不是file://URI。picker 返回的 URI 需要通过fileIo打开拿到 fd再用 fd 创建 ImageSourceconstfile:fs.Filefs.openSync(imageUri,fs.OpenMode.READ_ONLY)constsource:image.ImageSourceimage.createImageSource(file.fd)用完 fd 记得关fs.closeSync(file)但是closeSync的时机有讲究——必须在createPixelMap完成之后再关。因为createPixelMap是异步的fd 提前关了PixelMap 数据就丢了。这个生命周期问题也是华为开发者论坛相关问题中反复出现的排查重点建议结合论坛问题与解决记录一起看。OCR 引擎API 12 之后必须初始化这是第二个坑也是最容易踩的。坑 2跳过init()直接调recognizeText报错 1001400001在 API 12 之前textRecognition没有init()方法直接调recognizeText就行。但从 API 12HarmonyOS 5.0开始必须先调init()初始化引擎否则报错BusinessError 1001400001: Failed to run OCR, please try again.正确做法constinitResult:booleanawaittextRecognition.init()if(!initResult){// 初始化失败设备可能不支持 OCRreturn}init()返回Promisebooleantrue表示成功。如果返回false或抛异常说明当前设备不支持 OCR 能力。页面销毁时记得释放aboutToDisappear():void{textRecognition.release()}这一步很关键不 release 的话 OCR 引擎资源不会被回收多次进出页面可能内存泄漏。PixelMap格式不对结果可能直接为空图片加载到 PixelMap 这步是第三个坑的高发区。坑 3PixelMap 格式不是 RGBA_8888识别结果为空textRecognition的VisionInfo接口只支持RGBA_8888格式的 PixelMap。如果你不指定格式createPixelMap默认可能返回BGRA_8888或其他格式调用recognizeText不会报错但返回的value是空字符串。必须在DecodingOptions里显式指定constimageInfo:image.ImageInfoawaitsource.getImageInfo()constdecodingOptions:image.DecodingOptions{editable:true,desiredPixelFormat:image.PixelMapFormat.RGBA_8888,desiredSize:{width:imageInfo.size.width,height:imageInfo.size.height}}constpm:image.PixelMapawaitsource.createPixelMap(decodingOptions)这里有两点要注意editable: true——PixelMap 需要可编辑某些后续操作比如方向校正可能会需要desiredSize保留原始尺寸——不要在这里压缩图片OCR 对分辨率敏感压缩后再识别准确率会下降如果你想预览缩略图可以单独创建一个小尺寸的 PixelMap 给 UI 展示用OCR 用原始尺寸的。fd的生命周期异步操作没结束文件不能关前面说过fs.openSync拿到的 fd 必须在createPixelMap完成后才能关。把完整流程放在一起看letfile:fs.File|undefinedundefinedletsource:image.ImageSource|undefinedundefinedtry{// 1. 通过 URI 打开文件拿到 fdfilefs.openSync(imageUri,fs.OpenMode.READ_ONLY)// 2. 用 fd 创建 ImageSourcesourceimage.createImageSource(file.fd)// 3. 创建 RGBA_8888 格式的 PixelMap异步操作constpm:image.PixelMapawaitsource.createPixelMap(decodingOptions)this.pixelMapForOCRpm// 4. 再创建一个缩略图给 UI 预览this.previewPixelMapawaitsource.createPixelMap(previewOpts)}catch(e){// 异常时也要关 fdif(file!undefined){fs.closeSync(file)}return}// 5. createPixelMap 都完成了安全关闭 fdif(file!undefined){fs.closeSync(file)}划重点closeSync一定要在所有createPixelMap的await之后。如果提前关了 fdPixelMap 里就是空数据OCR 识别出来自然是空的。调用recognizeText结果怎么拿、怎么定位前面四步都做对了这一步就简单了constvisionInfo:textRecognition.VisionInfo{pixelMap:this.pixelMapForOCR}constconfig:textRecognition.TextRecognitionConfiguration{isDirectionDetectionSupported:true}constocrResult:textRecognition.TextRecognitionResultawaittextRecognition.recognizeText(visionInfo,config)isDirectionDetectionSupported建议设为true。手机拍发票经常是歪的或倒着的开启方向检测能自动纠正。如果你能确定图片方向是正的设false可以提升性能。识别结果的数据结构TextRecognitionResult是个嵌套结构TextRecognitionResult ├── value: string // 全部识别文本拼成一个字符串 └── blocks: TextBlock[] // 文本块数组 ├── value: string // 该块的文本 └── lines: TextLine[] // 行数组 ├── value: string // 该行的文本 └── words: TextWord[] // 词数组 ├── value: string // 该词的文本 └── cornerPoints: PixelPoint[] // 四角坐标最常用的就是result.value直接拿到全部文字。如果你需要按块或按行定位比如发票上提取某个字段就遍历blocks和linesconstblocks:ArraytextRecognition.TextBlockocrResult.blocksfor(leti0;iblocks.length;i){constblock:textRecognition.TextBlockblocks[i]console.info(块${i1}:${block.value})constlines:ArraytextRecognition.TextLineblock.linesfor(letj0;jlines.length;j){console.info(行${j1}:${lines[j].value})}}每个TextWord还带有cornerPoints四个角的像素坐标可以用来在图上画框高亮。识别结果只有一半先查PixelMap是否被提前释放隐藏原因结果只有一半还有一种情况识别结果只出来一部分或者明明有文字却显示未识别。排查后发现是 PixelMap 在识别完成前被release()了。比如在aboutToAppear里创建了 PixelMap识别还没跑完页面切换触发aboutToDisappear把 PixelMap 释放了recognizeText还在用这块内存结果就是数据不完整。解决办法PixelMap 的生命周期必须覆盖整个识别过程。在我的代码里pixelMapForOCR作为组件的私有成员只在下一次选图时才释放上一张// 释放上一张的 PixelMapif(this.pixelMapForOCR!undefined){this.pixelMapForOCR.release()this.pixelMapForOCRundefined}页面销毁时才释放当前这张aboutToDisappear():void{if(this.pixelMapForOCR!undefined){this.pixelMapForOCR.release()}textRecognition.release()}完整代码把整条链路串起来把上面所有步骤串起来下面是通用文字识别的核心逻辑。我把完整代码贴出来方便你直接对照import { textRecognition } from kit.CoreVisionKit import { image } from kit.ImageKit import { picker } from kit.CoreFileKit import { fileIo as fs } from kit.CoreFileKit import { BusinessError } from kit.BasicServicesKit Entry Component struct Qa4 { State recognizedText: string State statusMsg: string 点击按钮选择图片进行文字识别 State isRecognizing: boolean false State previewPixelMap: image.PixelMap | undefined undefined State blockDetails: string State supportedLangs: string private pixelMapForOCR: image.PixelMap | undefined undefined aboutToDisappear(): void { if (this.pixelMapForOCR ! undefined) { this.pixelMapForOCR.release() } if (this.previewPixelMap ! undefined) { this.previewPixelMap.release() } textRecognition.release() } private async pickAndRecognize(): Promisevoid { if (this.isRecognizing) { return } this.isRecognizing true this.recognizedText this.blockDetails this.statusMsg 选择图片中... let imageUri: string try { const options new picker.PhotoSelectOptions() options.MIMEType picker.PhotoViewMIMETypes.IMAGE_TYPE options.maxSelectNumber 1 const photoPicker new picker.PhotoViewPicker() const result: picker.PhotoSelectResult await photoPicker.select(options) if (result.photoUris.length 0) { this.statusMsg 未选择图片 this.isRecognizing false return } imageUri result.photoUris[0] } catch (e) { const err e as BusinessError this.statusMsg 选择图片失败: ${err.code} this.isRecognizing false return } this.statusMsg 正在初始化 OCR 引擎... try { const initResult: boolean await textRecognition.init() if (!initResult) { this.statusMsg OCR 引擎初始化失败 this.isRecognizing false return } } catch (e) { const err e as BusinessError this.statusMsg OCR init 失败: ${err.code} this.isRecognizing false return } this.statusMsg 正在加载图片... if (this.pixelMapForOCR ! undefined) { this.pixelMapForOCR.release() this.pixelMapForOCR undefined } if (this.previewPixelMap ! undefined) { this.previewPixelMap.release() this.previewPixelMap undefined } let file: fs.File | undefined undefined let source: image.ImageSource | undefined undefined try { file fs.openSync(imageUri, fs.OpenMode.READ_ONLY) source image.createImageSource(file.fd) const imageInfo: image.ImageInfo await source.getImageInfo() const decodingOptions: image.DecodingOptions { editable: true, desiredPixelFormat: image.PixelMapFormat.RGBA_8888, desiredSize: { width: imageInfo.size.width, height: imageInfo.size.height } } const pm: image.PixelMap await source.createPixelMap(decodingOptions) this.pixelMapForOCR pm const previewOpts: image.DecodingOptions { editable: false, desiredSize: { width: 360, height: 360 } } try { this.previewPixelMap await source.createPixelMap(previewOpts) } catch (_) { this.previewPixelMap pm } } catch (e) { const err e as BusinessError this.statusMsg 图片加载失败: ${err.code} - ${err.message} if (file ! undefined) { fs.closeSync(file) } this.isRecognizing false return } if (file ! undefined) { fs.closeSync(file) } this.statusMsg 正在识别文字... try { const visionInfo: textRecognition.VisionInfo { pixelMap: this.pixelMapForOCR as image.PixelMap } const config: textRecognition.TextRecognitionConfiguration { isDirectionDetectionSupported: true } const ocrResult: textRecognition.TextRecognitionResult await textRecognition.recognizeText(visionInfo, config) const fullText: string ocrResult.value if (fullText.length 0) { this.recognizedText 未识别到文字 this.statusMsg 识别完成但结果为空 this.isRecognizing false return } this.recognizedText fullText this.statusMsg 识别完成共 ${fullText.length} 字符${ocrResult.blocks.length} 个文本块 let detail: string const blocks: ArraytextRecognition.TextBlock ocrResult.blocks for (let i 0; i blocks.length; i) { const block: textRecognition.TextBlock blocks[i] detail 【块${i 1}】${block.value}\n const lines: ArraytextRecognition.TextLine block.lines for (let j 0; j lines.length; j) { const line: textRecognition.TextLine lines[j] detail 行${j 1}: ${line.value}\n } } this.blockDetails detail } catch (e) { const err e as BusinessError this.statusMsg 识别失败: ${err.code} - ${err.message} this.recognizedText } this.isRecognizing false } private async querySupportedLanguages(): Promisevoid { try { const langs: Arraystring await textRecognition.getSupportedLanguages() this.supportedLangs langs.join(, ) } catch (e) { const err e as BusinessError this.supportedLangs 查询失败: ${err.code} } } build() { Scroll() { Column() { Text(textRecognition 文字识别) .fontSize(22) .fontWeight(FontWeight.Bold) .fontColor(#1A1A1A) .width(100%) .padding({ left: 20, right: 20, top: 20, bottom: 8 }) Row() { Button(选择图片识别) .type(ButtonType.Capsule) .backgroundColor(#3274F6) .fontColor(Color.White) .enabled(!this.isRecognizing) .onClick(() { this.pickAndRecognize() }) Button(查询支持语言) .type(ButtonType.Capsule) .backgroundColor(#FF9800) .fontColor(Color.White) .onClick(() { this.querySupportedLanguages() }) } .width(92%) .justifyContent(FlexAlign.SpaceEvenly) .margin({ top: 16 }) Text(this.statusMsg) .fontSize(14) .fontColor(#666666) .width(92%) .margin({ top: 12 }) .maxLines(6) .textOverflow({ overflow: TextOverflow.Ellipsis }) if (this.previewPixelMap ! undefined) { Image(this.previewPixelMap) .width(92%) .height(240) .objectFit(ImageFit.Contain) .borderRadius(12) .margin({ top: 12 }) .backgroundColor(#F5F5F5) } if (this.recognizedText.length 0) { Column() { Text(识别结果) .fontSize(16) .fontWeight(FontWeight.Medium) .margin({ bottom: 8 }) Text(this.recognizedText) .fontSize(14) .fontColor(#333333) .lineHeight(22) .width(100%) .maxLines(20) .textOverflow({ overflow: TextOverflow.Ellipsis }) .copyOption(CopyOptions.LocalDevice) } .width(92%) .padding(16) .backgroundColor(Color.White) .borderRadius(12) .margin({ top: 12 }) .alignItems(HorizontalAlign.Start) } if (this.blockDetails.length 0) { Column() { Text(分块详情) .fontSize(16) .fontWeight(FontWeight.Medium) .margin({ bottom: 8 }) Text(this.blockDetails) .fontSize(12) .fontColor(#555555) .lineHeight(20) .width(100%) .maxLines(30) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .width(92%) .padding(16) .backgroundColor(Color.White) .borderRadius(12) .margin({ top: 8 }) .alignItems(HorizontalAlign.Start) } if (this.supportedLangs.length 0) { Column() { Text(支持的语言) .fontSize(16) .fontWeight(FontWeight.Medium) .margin({ bottom: 8 }) Text(this.supportedLangs) .fontSize(14) .fontColor(#333333) .width(100%) } .width(92%) .padding(16) .backgroundColor(Color.White) .borderRadius(12) .margin({ top: 8, bottom: 24 }) .alignItems(HorizontalAlign.Start) } } .width(100%) } .scrollBar(BarState.Auto) .edgeEffect(EdgeEffect.Spring) .width(100%) .height(100%) .backgroundColor(#F2F3F5) } }出错时怎么查错误码速查识别过程中可能遇到的 BusinessError错误码含义常见原因200运行超时图片太大缩小后重试401参数检查失败VisionInfo 里的 PixelMap 为空或格式不对1001400001OCR 运行失败没调init()、PixelMap 已释放、格式非 RGBA_88881001400002OCR 服务异常引擎内部错误重启应用重试支持哪些语言textRecognition目前支持简体中文、英文、日文、韩文、繁体中文。可以通过getSupportedLanguages()查询当前设备实际支持的语言列表constlangs:ArraystringawaittextRecognition.getSupportedLanguages()返回[zh-CN, en, ja, ko, zh-TW]之类的数组。不同设备可能不同最好运行时查一下。写在最后回头看textRecognition的 API 本身并不复杂真正让人卡住的是数据和资源的交接URI 不能直接当路径要通过fileIo.openSync转 fd必须先init()API 12 新增的要求官方文档更新了但很多人没注意到PixelMap 格式必须是RGBA_8888不指定就可能是别的格式识别结果为空fd 和 PixelMap 的生命周期提前关 fd 或释放 PixelMap 都会导致识别失败或结果不完整把这四点搞对了textRecognition基本就能稳定跑通。发票、名片、截图提取文字都不在话下。
返回列表