《HarmonyOS技术精讲-Core Vision Kit》第2篇:图像预处理基础
一个容易被忽略的预处理环节HarmonyOS NEXT 开发里很多人在接触图像识别或检测功能时会直接调用高级接口如文字识别OCR或人脸检测。但有一个基础能力经常被跳过就是图像预处理。假设你要做一个身份证 OCR 功能用户拍的照片可能角度不正、光线过暗、背景复杂。直接把这张图扔给识别 API结果大概率是乱码或者识别失败。这个问题在 HarmonyOS 开发里比较常见 —— 不是因为 API 不好用而是输入数据不满足接口的前置条件。Core Vision Kit 提供了一套图像预处理的能力包括格式转换、灰度化、二值化、旋转、裁剪等。这篇文章就聚焦这些基础操作帮你理解为什么需要它们、怎么用以及实际开发中容易踩的坑。Core Vision Kit 图像预处理解决了什么问题这套能力本质上是给后续的检测和识别任务“喂”合适的图像数据。大多数视觉识别接口对输入图像有隐性要求格式要求很多接口直接接受PixelMap对象但有些内部实现依赖特定格式如 BGRA_8888。色彩要求灰度图可以减少计算量、增强对比度二值化能突出文本轮廓便于文字识别。几何要求矫正旋转和裁剪可以去除无关背景、统一输入尺寸。如果你直接传原图可能因为格式不支持、色彩信息过剩、或者方向错误导致识别失败或性能低下。适用场景任何需要将摄像头拍摄或本地图片作为输入的视觉任务特别是 OCR、条码识别、人脸检测。不适用场景如果只是展示图片、不做后续分析不需要预处理如果图片已经是标准格式和方向也不需要重复处理。官方文档虽然提到了这些 API但没有解释实际使用中的几个关键限制比如 PixelMap 的格式转换细节、二值化的阈值选择策略。下面直接从代码入手。环境说明DevEco Studio 版本DevEco Studio 6.1.0 及以上 HarmonyOS SDK 版本HarmonyOS 6.1.0(23) 及以上 目标设备手机真机测试更稳定核心实现三个独立示例下面实现三个功能每个都是独立的入口可以直接运行。示例 1灰度化并保存这段代码用于将一张彩色图片转换为灰度图并保存到本地。// grayScaleDemo.etsimport{image}fromkit.ImageKit;import{fileIo}fromkit.CoreFileKit;import{common}fromkit.AbilityKit;exportasyncfunctiongrayScaleDemo(context:common.Context){// 1. 加载资源图片获取 PixelMapconstresourceMgrcontext.resourceManager;constrawFileawaitresourceMgr.getRawFileContent(input.jpg);constimageSourceimage.createImageSource(rawFile.buffer);// 关键点创建 PixelMap 时指定期望的格式constdecodingOptions:image.DecodingOptions{desiredPixelFormat:image.PixelMapFormat.BGRA_8888};constpixelMapawaitimageSource.createPixelMap(decodingOptions);// 2. 手动实现灰度化对每个像素进行处理// 通过 getPixel 和 setPixel 效率较低这里为了演示清晰constwidthpixelMap.getImageInfoSync().size.width;constheightpixelMap.getImageInfoSync().size.height;constpixelArrayBufferpixelMap.readPixelsToBufferSync();// 获取原始像素数据constpixelArraynewUint8Array(pixelArrayBuffer);for(leti0;ipixelArray.length;i4){// BGRA_8888 格式B, G, R, A 各占一字节constbpixelArray[i];constgpixelArray[i1];constrpixelArray[i2];// 灰度化公式Gray 0.299 * R 0.587 * G 0.114 * BconstgrayMath.round(0.299*r0.587*g0.114*b);pixelArray[i]gray;// BpixelArray[i1]gray;// GpixelArray[i2]gray;// R// A 通道保持原值}// 3. 将修改后的数据写回 PixelMappixelMap.writeBufferToPixels(pixelArray.bufferasArrayBuffer);// 4. 保存为 JPEG 文件constfilePathcontext.filesDir/gray_demo_output.jpg;constfilefileIo.openSync(filePath,fileIo.OpenMode.CREATE|fileIo.OpenMode.WRITE_ONLY);constpackerimage.createImagePacker();constpackOptions:image.PackingOption{format:image/jpeg,quality:95};constpackedDataawaitpacker.packing(pixelMap,packOptions);fileIo.writeSync(file.fd,packedData);fileIo.closeSync(file);// 释放资源pixelMap.release();imageSource.release();console.info(灰度图保存成功路径filePath);}输入输出文字对比输入一张彩色图片红绿蓝三通道彩色。输出一张黑白图片每个像素的 R、G、B 值相等画面呈灰度效果。注意事项这里用的readPixelsToBufferSync是同步方法如果图片很大超过 5000x5000 像素会阻塞主线程建议在子线程中执行。writeBufferToPixels也是同步操作实际开发中建议使用其异步版本writeBufferToPixels()虽然当前示例用了 Sync但正式代码推荐异步。灰度化公式通常选择加权平均值但如果你需要快速处理可以用简单的平均值(RGB)/3效果差距不大。示例 2二值化处理二值化是在灰度图基础上将像素值大于某个阈值的设为白色255小于的设为黑色0。这段代码演示如何对刚得到的灰度图进行二值化。// binarizationDemo.etsimport{image}fromkit.ImageKit;import{fileIo}fromkit.CoreFileKit;import{common}fromkit.AbilityKit;exportasyncfunctionbinarizationDemo(context:common.Context){// 1. 同样先获取灰度 PixelMap这里复用 grayScaleDemo 的灰度结果但为了演示完整直接再次加载constresourceMgrcontext.resourceManager;constrawFileawaitresourceMgr.getRawFileContent(input.jpg);constimageSourceimage.createImageSource(rawFile.buffer);constdecodingOptions:image.DecodingOptions{desiredPixelFormat:image.PixelMapFormat.BGRA_8888};constpixelMapawaitimageSource.createPixelMap(decodingOptions);// 2. 先执行灰度化同示例1的灰度逻辑constpixelArrayBufferpixelMap.readPixelsToBufferSync();constpixelArraynewUint8Array(pixelArrayBuffer);constwidthpixelMap.getImageInfoSync().size.width;constheightpixelMap.getImageInfoSync().size.height;for(leti0;ipixelArray.length;i4){constbpixelArray[i];constgpixelArray[i1];constrpixelArray[i2];constgrayMath.round(0.299*r0.587*g0.114*b);// 二值化阈值选 128也可根据实际情况动态计算constbinaryValgray128?255:0;pixelArray[i]binaryVal;pixelArray[i1]binaryVal;pixelArray[i2]binaryVal;}// 3. 得到二值化后的 PixelMappixelMap.writeBufferToPixels(pixelArray.bufferasArrayBuffer);// 4. 保存constfilePathcontext.filesDir/binary_demo_output.jpg;constfilefileIo.openSync(filePath,fileIo.OpenMode.CREATE|fileIo.OpenMode.WRITE_ONLY);constpackerimage.createImagePacker();constpackOptions:image.PackingOption{format:image/jpeg,quality:95};constpackedDataawaitpacker.packing(pixelMap,packOptions);fileIo.writeSync(file.fd,packedData);fileIo.closeSync(file);pixelMap.release();imageSource.release();console.info(二值化图保存成功路径filePath);}输入输出文字对比输入原始彩色图片灰度图的中间状态不可见。输出黑白二值图只有纯黑和纯白两种颜色字母/轮廓区域被突出。踩坑点这里用的阈值 128 是固定值。在实际 OCR 场景中如果图片整体偏暗或偏亮固定阈值会导致大量内容丢失。推荐使用大津法Otsu自动计算阈值但 Core Vision Kit 目前没有直接提供 Otsu 接口需要自己实现双峰直方图算法。如果不想增加复杂度可以先用ohos.file.previewer让用户手动调节阈值。示例 3旋转 90 度后裁剪中心区域这段代码实现读取图片 - 旋转 90 度 - 取旋转后图片的中心 200x200 区域并保存。// rotateAndCropDemo.etsimport{image}fromkit.ImageKit;import{fileIo}fromkit.CoreFileKit;import{common}fromkit.AbilityKit;exportasyncfunctionrotateAndCropDemo(context:common.Context){// 1. 加载原始图片constresourceMgrcontext.resourceManager;constrawFileawaitresourceMgr.getRawFileContent(input.jpg);constimageSourceimage.createImageSource(rawFile.buffer);constpixelMapawaitimageSource.createPixelMap();// 2. 旋转 90 度// 注意PixelMap 的 rotate 是异步方法awaitpixelMap.rotate(90);// 3. 获取旋转后图片尺寸constinfopixelMap.getImageInfoSync();constoldWidthinfo.size.width;constoldHeightinfo.size.height;// 旋转后宽高交换因为旋转90度constnewWidtholdHeight;constnewHeightoldWidth;// 4. 计算中心区域以新的宽高为准constcropWidth200;constcropHeight200;// 确保裁剪区域不越界constcropXMath.max(0,Math.floor((newWidth-cropWidth)/2));constcropYMath.max(0,Math.floor((newHeight-cropHeight)/2));constcropRect:image.Region{size:{width:cropWidth,height:cropHeight},x:cropX,y:cropY};// 5. 裁剪constcropPixelMapawaitpixelMap.crop(cropRect);// 6. 保存constfilePathcontext.filesDir/rotate_crop_demo_output.jpg;constfilefileIo.openSync(filePath,fileIo.OpenMode.CREATE|fileIo.OpenMode.WRITE_ONLY);constpackerimage.createImagePacker();constpackOptions:image.PackingOption{format:image/jpeg,quality:95};constpackedDataawaitpacker.packing(cropPixelMap,packOptions);fileIo.writeSync(file.fd,packedData);fileIo.closeSync(file);cropPixelMap.release();pixelMap.release();imageSource.release();console.info(旋转裁剪图保存成功路径filePath);}输入输出文字对比输入原始横向拍摄的图片比如 1920x1080 像素。输出图片被顺时针旋转 90 度变成纵向 1080x1920然后中心 200x200 区域被裁剪出来并保存只保留了一小部分内容。注意事项rotate(90)接受的角度是度数正值表示顺时针。crop方法返回一个新的 PixelMap不会修改原对象。原 PixelMap 仍然可用。裁剪区域不能超出图片边界否则会报错。上面代码做了边界保护。常见问题 1灰度化后文字反而模糊了效果不如直接彩色输入好现象灰度化后的图片看起来细节丢失文字边缘不清晰。原因很多情况下文字信息在彩色图中通过颜色对比已经很明显。灰度化会丢失颜色维度信息。例如红色文字和蓝色背景的对比在灰度化后可能变得模糊。灰度化不是万能的它只适用于颜色对比不强烈、或者本身是黑白文档的场景。解决方案在进行灰度化前可以先尝试色彩增强比如调整对比度、饱和度或者直接对原始彩色图进行二值化降采样到单通道。如果需要更好的效果可以考虑先用 Core Vision Kit 的imageFilter模块如果合入做一次直方图均衡化。常见问题 2旋转后图像边缘出现黑边现象旋转 90 度后图片边缘出现黑色填充特别是旋转非 90 度时异常明显。原因PixelMap.rotate接口内部使用Canvas实现旋转后画布大小默认按照旋转后的边界计算。但如果旋转角度不是 90 度的整数倍画布会填充黑色。90 度旋转通常不会出现但如果你尝试 45 度或其他角度黑边不可避免。解决方案如果确实需要任意角度旋转建议改用Canvas手动绘制同时设置fillStyle为白色或者透明。或者先放大画布再旋转然后再裁剪掉黑边。但一般来说在预处理阶段只使用 0/90/180/270 度旋转即可满足大多数需求尽量避免大角度旋转。最佳实践不要在 build() 中频繁创建 PixelMap如果你的 UI 需要显示预处理的预览图推荐先在后台线程如使用 TaskPool中完成所有操作再把最终的 PixelMap 传给 UI 渲染。如果在 build() 中直接创建 PixelMapArkUI 会频繁触发组件重建导致卡顿甚至 ANR。优先使用 writeBufferToPixels 的异步版本虽然示例中用了同步版本writeBufferToPixelsSync但实际项目中应该用writeBufferToPixels无后缀。同步方法会阻塞当前线程如果图片较大如 4096x2160会严重拖慢应用响应。合理管理 PixelMap 的内存释放每次createPixelMap、crop、createImageSource都会创建新对象。PixelMap对象持有大量 Native 内存不会自动被 ArkUI 的垃圾回收机制回收。一定要手动调用 release()。建议将release()写在finally块中确保异常时也能释放。Demo 入口结构// 入口文件entry/src/main/ets/pages/Index.etsimport{grayScaleDemo}from../utils/grayScaleDemo;import{binarizationDemo}from../utils/binarizationDemo;import{rotateAndCropDemo}from../utils/rotateAndCropDemo;import{common}fromkit.AbilityKit;EntryComponentstruct Index{privatecontextgetContext(this)ascommon.Context;build(){Column(){Button(示例1灰度化).onClick((){grayScaleDemo(this.context);})Button(示例2二值化).onClick((){binarizationDemo(this.context);})Button(示例3旋转裁剪).onClick((){rotateAndCropDemo(this.context);})}.width(100%).height(100%).justifyContent(FlexAlign.Center)}}FAQQ灰度化和二值化有无现成的 APIACore Vision Kit 目前没有直接针对灰度化或二值化的单独 API。只能通过读取像素数据手动处理或者使用imageFilter模块中的滤镜但滤镜效果有限。官方文档提到后续会增加预处理能力但当前版本建议用上面代码。Q为什么保存的 JPEG 文件比原图小很多A因为二值化后的图片只有黑和白两种颜色压缩算法效率极高文件体积会大幅下降。这是正常现象。Q旋转后如果图片尺寸不是 2 的倍数会不会出问题A不会。PixelMap 内部支持任意尺寸没有对齐限制。但部分硬件加速可能会对尺寸有限制如 GPU 要求宽高为 2 的幂但一般应用场景不需要考虑。Q可以在子线程中执行这些操作吗A可以。但需要注意 PixelMap 的操作不能在两个线程同时进行否则会出现数据竞争。建议用ohos.taskpool或者worker并在线程间传递 PixelMap 的fd或buffer而非对象本身。示例代码地址项目地址