给 React Native 装上鸿蒙端侧 AI 视觉能力|基于 CoreVisionKit 封装 RN 库,图像超分、文搜图、画质增强踩坑记录
示例仓库:https://atomgit.com/dijun_520/react-native-ohos-ai-vision
社区引导:框架源码与版本发布见 https://atomgit.com/CPF-RN/ohos_react_native/tree/0.84-main
CPF-RN 鸿蒙社区:https://atomgit.com/CPF-rn
| 项目 | 版本 |
|---|---|
| 开发工具 | DevEco Studio 26.0(内置 SDK:DevEco Studio/sdk) |
| 系统 API | HarmonyOS 7.0.0 / API 26(compatibleSdkVersion: 26.0.0) |
| React Native | 0.84.1 |
| RN 鸿蒙框架 | @react-native-oh/react-native-harmony 0.84.5(RNOH) |
| Node.js | ≥ 22.11.0 |
| 实测设备 | 华为(nova 14) |
一、为什么要给 RN 做端侧视觉
React Native for OpenHarmony(RNOH)把 RN 生态带到了鸿蒙上,但绝大多数 RN 三方库在鸿蒙端没有原生实现。UI 组件类库可以靠适配层兜,系统能力类库不行——桥的另一头必须是真实现。而 HarmonyOS 从 API 26 开始,系统里就装着一批现成的端侧视觉模型:图像超分、文搜图、OCR、人脸检测……这些能力躺在 @kit.CoreVisionKit 里,ArkTS 几行代码就能调,RN 应用却摸不到。
【业务痛点】:
1. 一个是清晰度,聊天记录里存下来的图、早年保存的素材,点开大图就是一片锯齿,用户会下意识觉得"这应用做得粗糙"。
2. 一个是找图,图库攒了几千张,名字全是时间戳,用户描述得出来(“那张橙色的水母”),但翻不到。
接下来,可以按这种方案:把这批能力包成 RN 应用可以直接 import 的库,让 JS 侧一行 await AIVision.superResolution({...}) 就能吃到端侧模型,为什么坚持走端侧而不是接云端 API?
| 维度 | 端侧(CoreVisionKit) | 云端 API |
|---|---|---|
| 隐私 | 图片不出设备,官方个人数据处理说明写明"存留期:不留存、云端不存储用户数据" | 图片要上传 |
| 成本 | 无网络请求、无调用费用 | 按次计费 |
| 可用性 | 无网可用,延迟稳定 | 依赖网络质量 |
代价也有:模型冷启动慢、能力随设备/系统版本浮动。
是在实际过程中,对着 @kit.VisionKit 翻了半天,怎么也找不到 imageSuperResolution。后来才发现Vision Kit 是给交互控件用的(活体检测、卡证识别、文档扫描),分析类的 API 全在 Core Vision Kit 下(OCR、人脸、主体分割、超分、文搜图)。
| 能力 | 归属 | 起始版本 | 几个硬约束 |
|---|---|---|---|
图像超分 imageSuperResolution | @kit.CoreVisionKit | API 26.0.0 | 固定 4 倍不可配;输入原图最大 2048×2048,超了要先等比缩 |
文搜图 textSearchImage | @kit.CoreVisionKit | API 26.0.0 | 管的是"特征库"不是像素;系统服务级单例,初始一次全程可用 |
| 画质增强 | effectKit(@kit.ArkGraphics2D) | API 12+ | 系统滤镜链,不走 AI 模型 |
两项 AI 能力不需要申请任何
ohos.permission.*,选系统图库走photoAccessHelper.PhotoViewPicker也免权限;系统能力标识是SystemCapability.AI.Vision.VisionBase,入口得用canIUse()兜一层。
二、CoreVisionKit 能给什么、不能给什么
Vision Kit 面向交互控件(活体检测、卡证识别、文档扫描),Core Vision Kit 面向分析类 API(OCR、人脸、主体分割、超分、文搜图),本文用到的三项能力,规格如下:
| 能力 | 归属 | 起始版本 | 说明 |
|---|---|---|---|
图像超分 imageSuperResolution | @kit.CoreVisionKit | API 26.0.0(HarmonyOS 7) | 端侧模型重建放大,固定 4 倍不可配;输入原图最大 2048×2048,超限要先等比缩放 |
文搜图 textSearchImage | @kit.CoreVisionKit | API 26.0.0 | 管理"特征库"而非像素:init / insert / search / delete / clear / release 一套增删查;系统服务级单例,初始化一次全程可用 |
| 画质增强 | effectKit(@kit.ArkGraphics2D) | API 12+ | 系统滤镜链,不算端侧 AI,但和前两者是天然的"入门搭子" |
权限:两项 AI 能力均无需申请任何
ohos.permission.*;图库选图走photoAccessHelper.PhotoViewPicker免权限。
三、封装成 RN 库:接口设计与架构
分层
三层结构,ArkTS 细节全部收敛在原生层,JS 侧只见到一套干净的 Promise 接口:
JS 接口层(TS) 类型定义、参数默认值、Promise 化
↓ TurboModule
ArkTS 实现层 kit 调用、沙箱文件管理、rawfile 落盘、
sourceUri 映射表、进度事件发送
↓
系统能力 CoreVisionKit / effectKit / 文件系统
API相关的接口
| API | 作用 | 关键参数 |
|---|---|---|
detectCapabilities(force?) | 对每项能力先声明再实测,返回逐项耗时 | force:跳过缓存重测 |
superResolution({imagePath, allowFallback}) | 4 倍超分,返回输出尺寸与是否降级 | allowFallback: false 时原生不可用直接报错 |
enhanceQuality({imagePath}) | effectKit 画质增强,尺寸不变 | — |
initImageSearch() | 初始化文搜图服务 | — |
insertImageToIndex({imagePath, scope}) | 把图片特征插进端侧索引 | scope:命名空间隔离 |
searchImagesBySentence({query, scope, topK}) | 文搜图,返回缩略路径 + 相似度 + sourceUri | topK |
removeImageFromIndex({imagePath, scope}) | 从索引删除 | — |
batchProcess({paths, ...}) | 批量处理,逐张触发进度事件 | 回调 done/i/total |
clearImageIndex({scope}) | 清空索引 | — |
四、先看页面长什么样
演示页是一屏到底的卡片流,从上到下依次是:演示图库 → 能力探测 → 画质增强 → 图像超分 → 文搜图 → 批处理 → 全量自检 → 处理对比。

图里 8 张照片不是系统图标,是特意挑的主体差异大、色调差异也大的真实照片:木地板上的黑狗、绿色山脊公路、裹毯子的巴哥、橙色水母、沙滩海象、拉花咖啡、紫洋葱、蜂蜜。只有素材本身有区分度,后面的语义检索和画质对比才有意义。
素材之外还有两张 256×256 的低清图(
lowres/jelly_low)专门作为超分输入——超分测试的关键就是输入要"真的小",用 512×512 的图去测 4 倍放大,输出 2048×2048,和低清重建是两回事。
五、能力探测:三盏绿灯
detectCapabilities(force) 会分别声明和实测每一项能力,避免"系统说有、其实跑不动"。

| 能力 | 声明 | 实测 | 说明 |
|---|---|---|---|
| 图像超分 | ✓ | ✓ | 首次加载模型 |
| 文搜图 | ✓ | ✓ | 服务已初始化,索引 8 张 |
| 画质增强 | ✓ | ✓ | 系统滤镜链,不依赖端侧 AI 服务 |
结论「端侧 AI 能力完整可用」。三项里只有画质增强是纯 CPU 滤镜,所以耗时 0ms;超分第一次要加载模型,1033ms 是冷启动代价,后续单张只要几百毫秒。
六、画质增强:暗调图和高饱和图各来一张
enhanceQuality 走 effectKit,API 12+ 即可用。挑了两张"难处理"的:一张整体欠曝的黑狗,一张饱和度已经很高的水母。
| 暗调小狗 · 512×512 · 24ms | 高饱和水母 · 512×512 · 18ms |
|---|---|
![]() | ![]() |
放大看细节更直观——木地板纹理被提亮出来,狗的黑毛没有糊成一团:

水母那张的提升在橙色本体和水体层次上,暗部噪点没有被一起拉爆:

实测:512×512 输入,24ms(dog)、18ms(jellyfish),输出尺寸不变。
它快是有原因的:不加载模型、不吃设备算力浮动,本质是一组固定滤镜参数。所以在能力探测里它敢报 0ms,在任何设备上表现都可预期——这也是为什么把它放进 batchProcess 做批量的默认操作:快、稳、无冷启动。
七、图像超分:256 → 1024,且确认没走降级
这是整个库里最容易"看起来成功、其实是假的"的接口。所以调用时显式传 allowFallback: false——原生模型不可用就应该直接报错,而不是偷偷用插值放大糊弄过去。
结果是原生模型跑通了:输出 1024×1024,结果不带 fallback 标记。
| 山景 256→1024 · 707ms | 水母 256→1024 · 394ms |
|---|---|
![]() | ![]() |
放大对比,左侧原图的锯齿边缘在右侧被"长"出了连续纹理(水母触须尤其明显):


实测:256×256 → 1024×1024,4 倍放大,山景 707ms / 水母 394ms,无 fallback 标记。
"重建"和"放大"的区别:插值是把周围像素取平均,像素变多了、信息没变多,放大后发虚;端侧超分模型在"清晰图—模糊图"配对数据上训练过,做的是基于上下文预测并补全合理细节——所以放大图里能看到原图没有的连续纹理,这就是 707ms 换来的东西。
八、文搜图:4 句话,各自命中自己那张
insertImageToIndex 把 8 张照片建进端侧索引,再用 4 句中文去搜。每句查询都只对应图库里的一张照片,区分度可验证。
| 搜索结果(一) | 搜索结果(二) |
|---|---|
![]() | ![]() |
| 查询 | 命中 | 相似度 | 排名 |
|---|---|---|---|
| 一只黑色的狗 | real_dog.jpg | 0.366 | #1 |
| 橙色的水母 | real_jellyfish.jpg | 0.361 | #1 |
| 一杯拉花咖啡 | real_coffee.jpg | 0.334 | #1 |
| ↑ 同一句的第 2 名 | real_honey.jpg | 0.327 | #2 |
| 绿色的山间公路 | real_scene.jpg | 0.385 | #1 |
命中结果直接出缩略图 + 原始文件名 + 排名 + 相似度,不是一串数字。
怎么读这组相似度分数
绝对值都不高(0.33~0.39),这很正常,不要拿绝对值当"命中率"。端侧模型把图片和查询文本编码进同一个语义空间再算相似度,分母覆盖了所有"不相关"的语义组合,任何一句具体的话和任何一张具体的图,满分对面都是无穷多的负样本。正确用法是看相对关系:
- top1 是否稳定排在 top2 前面(4 句查询全部如此);
- top1 与 top2 的分差(咖啡 0.334 vs 蜂蜜 0.327,gap 只有 0.007——咖啡和蜂蜜色调、构图确实接近,说明模型"知道"这两张难分)。
所以业务里不建议硬编码"相似度 > 0.3 才算命中",更稳的判定是:top1 存在,且 top1 − top2 的分差大于某个小量,或者干脆只取 top1 展示。绝对阈值只在"图库极度异质"的场景下才可靠。
索引的是特征,不是像素
文搜图的工作方式:insertImageToIndex 只把图片的语义特征提取进系统数据库,不做像素处理,图片文件本身的增删仍由业务负责。这也解释了后面自检里"删索引记录"和"删文件"是两件事、要分开断言。
九、批处理:8 张全部成功
batchProcess 串行处理整组图片,并对每张触发进度事件。

8 张 512×512 逐张完成,耗时 16~19ms,结果缩略图按顺序铺开,下方是逐条状态:
1. ✓ 512×512 · 19ms 5. ✓ 512×512 · 16ms
2. ✓ 512×512 · 19ms 6. ✓ 512×512 · 16ms
3. ✓ 512×512 · 17ms 7. ✓ 512×512 · 17ms
4. ✓ 512×512 · 17ms 8. ✓ 512×512 · 18ms
为什么选串行而不是并发:画质增强本身只要十几毫秒,并发的调度开销和内存峰值反而更贵;串行的另一个好处是进度事件天然有序,UI 侧不需要自己排序。如果批量操作里混入了超分(单张几百毫秒)。
十、全量自检:
页面上有个「逐个跑一遍全部 API」的按钮,按依赖顺序调用 9 个接口,每一步都做断言——不是"调通了就算过",而是校验返回值本身对不对。

这一轮完整日志(自检 scope=apitest,入索引 3 张):
| # | 接口 | 断言结果 |
| — | — | — | — |
| 1 | detectCapabilities(true) | API 26 · 端侧 AI 能力完整可用 |
| 2 | superResolution(原生,禁降级) | 1024×1024 · 154KB |
| 3 | enhanceQuality(显式参数) | 512×512 · 80KB |
| 4 | initImageSearch | true |
| 5 | insertImageToIndex ×3 | 3 张入索引(scope=apitest) |
| 6 | searchImagesBySentence | 1 命中 · top 相似度 0.366 |
| 7 | removeImageFromIndex | 删除后剩 0 条 |
| 8 | batchProcess(超分 + 自动建索引) | 3 张成功 · 索引 +3 |
| 9 | clearImageIndex | 索引已清空(indexedCount=0) |
三步走完
| 启动:3 张素材 + 九个按钮 | ① 能力探测:三项全绿 | ① 画质增强:小狗 |
|---|---|---|
![]() | ![]() | ![]() |
| ① 超分:256→1024 / 393ms | ② 搜「一只狗」0.325 | ② 搜「一杯咖啡」 |
|---|---|---|
![]() | ![]() | ![]() |
| ② 删除后重搜:1 → 0 条 | ③ 批处理 3/3 结果墙 | 一次跑完 |
|---|---|---|
![]() | ![]() | ![]() |
十一、QA开发过程中容易踩到的三个坑
1. rawfile:// 不是文件系统路径
insertImageToIndex 原本直接 fs.openSync(uri) 把来源拷进沙箱,而 rawfile://demo_lowres.png 在真机上直接报:
Invalid argument. Open failed, path is: rawfile://demo_lowres.png
更麻烦的是能力探测能通过(探测只查系统服务),真正插入时才炸。现在统一走 materialize():rawfile 用 resourceManager.getRawFileContent 读字节再落盘,file:// 和沙箱路径走 fs.copyFile,并且保留原扩展名(原来一律写成 .jpg,PNG 会被错误重命名)。
2. RN 的 Image 加载不了 rawfile://
原图必须用 JS 资产 require('./assets/real_*.jpg');结果图和命中图是沙箱文件,要给 Image 加 file:// 前缀。两套路径规则不一样,混用就是白框。
3. 搜索结果显示的是沙箱文件名
img_1794134012691.png 这种名字对用户毫无意义。修法是索引时记录「沙箱路径 → 原始来源」,搜索结果新增 sourceUri。
十二、代码结构分析:
1 三层结构
react-native-ohos-ai-vision/
├─ src/
│ ├─ NativeAIVision.ts # TurboModule 契约:Spec 接口 + 全部类型
│ ├─ index.ts # 对外的 AIVision 对象、isSupported、事件订阅
│ └─ hooks/useAIImage.ts # useAIImage / useBatchProcessor
├─ harmony/ai_vision/src/main/ets/
│ ├─ AIVisionPackage.ets # 往 RNOH 注册 TurboModule
│ └─ AIVisionModule.ets # 全部实现(842 行)
└─ example/src/App.tsx # 单文件示例
设计上只有一条主张:ArkTS 的细节全部收敛在原生层。JS 侧拿到的只有 Promise 和普通对象,没有 PixelMap、没有 ArrayBuffer、没有 resourceManager。桥上只过字符串和数字,跨语言边界的内存模型最简单。
| API | 作用 | 关键参数 |
|---|---|---|
detectCapabilities(refresh?) | 声明 + 实测每项能力 | refresh 跳过缓存重测 |
superResolution({uri, allowFallback}) | 4 倍重建 | allowFallback: false 时不可用即报错 |
enhanceQuality({uri, level?, brightness?, contrast?, saturation?}) | 画质增强,尺寸不变 | level 三档预设 |
initImageSearch() | 初始化文搜图服务 | — |
insertImageToIndex({uri, scope}) | 拷入沙箱并提特征入索引 | scope 命名空间 |
searchImagesBySentence({query, scope, topK}) | 一句话搜图 | topK 默认 20 |
removeImageFromIndex({imagePath, scope}) | 从索引删除 | 用 imagePath,不是 uri |
batchProcess({uris, operation, addToIndex?, scope?, allowFallback?}) | 批处理 | operation: enhance / superResolution |
clearImageIndex() | 清空索引 | 无参数,全局生效 |
subscribeBatchProgress(fn)(JS 侧) | 订阅进度,返回取消函数 | 对应原生 aiVisionBatchProgress 事件 |
两个参数细节容易踩:入索引传 uri(来源,可以是 rawfile://),删除传 imagePath(入库后返回的沙箱路径),两者不能互换;clearImageIndex 没有 scope,因为底层 textSearchImage.clearData() 本身就是全局的,这是系统接口的限制,库只能在清空后把进程内的计数和映射表一起归零。
2 注册:一个 Map,两个名字必须对上
// harmony/ai_vision/src/main/ets/AIVisionPackage.ets
export class AIVisionPackage extends RNPackage {
// 返回的是「模块名 → 工厂函数」的表,RNOH 按 JS 请求的名字来查
getUITurboModuleFactoryByNameMap(): Map<string, (ctx: UITurboModuleContext) => UITurboModule> {
const map = new Map<string, (ctx: UITurboModuleContext) => UITurboModule>();
// key 必须和 JS 侧 getEnforcing 的字符串一字不差;value 是工厂,JS 第一次调用时才 new
map.set('AIVision', (ctx: UITurboModuleContext) => new AIVisionModule(ctx));
return map;
}
}
工厂是懒的,构造函数里那两行值得看一眼:
constructor(ctx: UITurboModuleContext) {
super(ctx);
// 落盘目录、rawfile 读取、事件发送,全都要从这个 context 上取
this.context = ctx.uiAbilityContext as common.UIAbilityContext;
console.info(`${TAG} module constructed`);
}
filesDir、cacheDir、resourceManager 三个东西都挂在 UIAbilityContext 上,所以这里必须转成 common.UIAbilityContext;用 common.Context 会拿不到 filesDir。宿主工程在 RNPackagesFactory.ets 的 createRNPackages() 里 new AIVisionPackage(ctx) 一次,桥就通了。名字 'AIVision' 必须和 JS 侧那一行逐字符一致:
// src/NativeAIVision.ts
export default TurboModuleRegistry.getEnforcing<Spec>('AIVision');
对不上时的报错是 TurboModuleRegistry.getEnforcing(...): 'AIVision' could not be found。这个错抛在 JS 侧,第一反应容易去查依赖装没装、oh-package.json5 配没配,其实八成就是这里名字写岔了——大小写、多个空格都算。
Spec 那个接口不是装饰。TS 里声明的入参出参就是桥的契约,改了 ArkTS 却不同步改它,RNOH 不会在编译期报错,只会在运行时把 undefined 塞进方法参数里,然后在很远的地方炸。
3 超分:单例分析器,和两个必须 release 的对象
private async ensureSrAnalyzer(): Promise<void> {
// 已经有就直接走,惰性单例的"单例"就靠这一句
if (this.srAnalyzer !== null) {
return;
}
try {
// create 全局单例即可(秒级耗时,禁止并发创建)
this.srAnalyzer = await imageSuperResolution.ImageSRAnalyzer.create();
} catch (e) {
// 统一翻译成"设备未提供端侧AI服务",见 7.3
throw aiUnavailableError('图像超分', e as Object);
}
}
返回 void 而不是返回分析器,是为了让调用点写成 await ensure... 再 this.srAnalyzer!。那个感叹号是 ArkTS 的非空断言,因为编译器看不出"await 之后它一定不是 null"。
create() 是秒级的,而且不能并发创建——两个按钮同时点、或者批处理里第一张还没建完第二张就开始,都可能拿到两个分析器。惰性单例加一个提前 return 是最省事的解法。
真正处理的那段,重点是 finally 里两个 release():
const input = await this.loadPixelMap(options.uri); // 解码后的原图,占 native 内存
let out: image.PixelMap | null = null; // 先声明在 try 外,finally 才够得着
try {
await this.ensureSrAnalyzer();
const response = await this.srAnalyzer!.process(request);
out = response.pixelMap; // 新图,同样占 native 内存
const result = await this.savePixelMap(out, this.outputDirOf(options.outputDir), 'sr_', 95);
result.costMs = Date.now() - start; // 含解码 + 推理 + 编码写盘
return result;
} finally {
await input.release();
if (out !== null) {
await out.release();
}
}
let out 写在 try 外面是这段唯一有讲究的地方:response.pixelMap 赋值给 out 之后才可能抛错(savePixelMap 里编码失败、写盘失败),如果 out 声明在 try 内部,finally 里根本引用不到它。
PixelMap 占的是 native 内存,JS 那侧的 GC 管不到它。input 不释放,连续处理几十张就是几百 MB 常驻;out 不释放更隐蔽——图已经打包成 jpg 存盘了,界面上一切正常,只有内存曲线一路往上,失败时还要把分析器置空:
async superResolution(options: ProcessImageOptions): Promise<ImageResult> {
try {
return await this.superResolve(options);
} catch (e) {
this.srAnalyzer = null; // 下次调用重新创建
const err = aiUnavailableError('图像超分', e as Object);
if (options.allowFallback === true) {
return await this.fallbackUpscale(options, err.message);
}
throw err;
}
}
分析器实例一旦出过错就不会自己恢复,不清掉的话后面每次调用都在用一个坏实例,报错永远一样。
4 降级:先判断能不能用,再决定给不给图
allowFallback 不是"要不要兜底"这么简单,它决定了 catch 之后走哪条路。兜底的实现是这样:
const info = await input.getImageInfo();
const scaleX = Math.min(2, 4096 / info.size.width); // 想放 2 倍,但不许超过 4096
const scaleY = Math.min(2, 4096 / info.size.height);
if (scaleX > 1 || scaleY > 1) {
await input.scale(scaleX, scaleY); // 原地改这张 PixelMap,不产生新对象
}
const filter = effectKit.createEffect(input);
filter.brightness(0.03); // 补一点点亮度
filter.setColorMatrix(contrastMatrix(1.12)); // 只动对比度,饱和度保持原样
out = await filter.getEffectPixelMap(); // 到这里才产生第二张图
...
result.fallback = true;
result.note = `端侧超分不可用,已用 ImageKit 2× 放大 + 画质增强替代(${reason})`;
Math.min(2, 4096 / w) 的意思是"最多两倍,且长边不超过 4096":800 宽的图取 2,3000 宽的图取 1.365,5000 宽的图取 0.819。
第三条就是这段的问题所在:scaleX 已经被算成 0.819,而 scaleX > 1 || scaleY > 1 只要有一个成立就会调用 scale(0.819, 2),一张 5000×500 的长图会被压扁成 4096×1000,比例彻底坏掉。演示用的八张图最大 512,永远走不到这个分支,所以我当时没发现,也没改。要修的话两轴用同一个系数:
const s = Math.min(2, 4096 / info.size.width, 4096 / info.size.height);
if (s > 1) {
await input.scale(s, s);
}
写成这样还有个附带好处:scale 的入参相同,双线性插值的采样比例一致,出来的图不会有一边糊一边锐。
倍率封在 2 倍、单边上限 4096 像素,之后过一道 brightness + contrast。这道滤镜不是为了变清楚,是让放大后的发虚感弱一点、亮部不至于一亮到底——21ms 里大部分时间花在 getEffectPixelMap() 上。
结果必须带 fallback: true 和 note。 业务侧只有靠这两个字段才知道手里这张是替代品,模拟器上那条"端侧超分不可用,已兜底放大"的状态提示,就是页面读了 note 直接显示的。
if (/abnormal/i.test(msg) || /service/i.test(msg)) {
return new Error(`${capability}不可用:当前设备未提供端侧AI服务(常见于模拟器或未预置AI服务的设备),` +
`请改用预装端侧AI的 HarmonyOS 真机(API 26+)`);
}
这句 abnormal 是排查时最先撞上的东西。它不像"权限没开"或者"版本不够"那样自带解释,看到只能靠猜;同一个字符串出现在三个地方——能力探测、超分、文搜图 init,所以收进一个 aiUnavailableError() 统一翻译,并且把已经翻译过的文案放过一遍,避免套娃成"不可用:不可用:…"。
5 画质增强:effectKit 没有"对比度"这个参数
effectKit 只开放了 brightness()、blur()、grayscale()、setColorMatrix() 这些,想要对比度和饱和度得自己拼 4×5 色彩矩阵:
/** 对比度矩阵 */
function contrastMatrix(contrast: number): number[] {
// 以中灰 128 为支点:乘完 contrast 后补回偏差,否则整张图会跟着变亮/变暗
const off = (1 - contrast) * 128 / 255; // 矩阵里的数值都归一到 0~1,所以除 255
return [
contrast, 0, 0, 0, off, // R' = c·R + off
0, contrast, 0, 0, off, // G' = c·G + off
0, 0, contrast, 0, off, // B' = c·B + off
0, 0, 0, 1, 0 // A' = A,alpha 不参与
];
}
/** 饱和度矩阵(Rec.601 亮度) */
function saturationMatrix(s: number): number[] {
const lr = 0.3086, lg = 0.6094, lb = 0.0820;
// s=1 时退化成单位矩阵;s=0 时三个通道都等于同一个亮度值,也就是灰度
return [
(1 - s) * lr + s, (1 - s) * lg, (1 - s) * lb, 0, 0,
(1 - s) * lr, (1 - s) * lg + s, (1 - s) * lb, 0, 0,
(1 - s) * lr, (1 - s) * lg, (1 - s) * lb + s, 0, 0,
0, 0, 0, 1, 0
];
}
20 个元素按行主序排,前四行对应 R/G/B/A 四个输出通道,第五列是常数偏移(对比度那个 off 就是干这个的)。饱和度用的是 Rec.601 亮度权重 0.3086 / 0.6094 / 0.0820,不是简单取平均——人眼对绿色最敏感,按平均算会让绿色物体掉色,草地会变灰。
off 的推导值得单独说一句,因为它是最容易写错的一个数。对比度本质是绕中灰做缩放:out = (in - 0.5) * c + 0.5,展开成矩阵的形式就是 out = c * in + (1 - c) * 0.5,那个 (1 - c) * 0.5 就是 off,写成 128 / 255 只是把 0.5 换成了 8 位色阶的说法。漏掉它的话,整张图会随着对比度一起变亮,黑狗的暗部还没出细节,背景先灰了。
两个矩阵不分别 set,先乘再一次写进去:
// 先乘:contrast 在前、saturation 在后,顺序不能换(矩阵乘法不满足交换律)
const matrix = multiplyColorMatrix(contrastMatrix(params.contrast), saturationMatrix(params.saturation));
filter.setColorMatrix(matrix); // 一次 set 就是一遍全图遍历
乘法本身是个 4×5 的仿射组合,28 次乘加,循环里的 if (c === 4) 那三行专门处理偏移列:
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 5; c++) {
let sum = 0;
for (let k = 0; k < 4; k++) {
sum += a[r * 5 + k] * b[k * 5 + c];
}
if (c === 4) {
// 算第 5 列(平移项)时,a 自己的平移还要乘上 b 的 alpha 系数
sum += a[r * 5 + 4] * b[4 * 5 + 4];
}
out[r * 5 + c] = sum;
}
}
为什么单独补这一项:第 5 列的语义是"常数偏移",它不参与 4×4 的通道混合,但会被后一个矩阵的整体缩放影响。b[24] 就是饱和度的 alpha 分量,恒为 1,所以这一项实际等于 a 的偏移原样传下去——看着像多余,其实是保证公式在任意矩阵组合下都成立。真删了,两个矩阵各带偏移的时候结果就偏了。
顺序换过来会怎样?先饱和再对比,暗部彩噪会被对比度一起放大;先对比再饱和,溢出更早出现在高饱和区域。两版我都出过图,水母那张差别肉眼可见,所以固定成现在这个顺序。
这也是为什么要费劲合并:512×512 是 26 万像素,一次 setColorMatrix 就得把每个像素的 RGBA 重算一遍,分两次设等于白跑一遍全图。24ms 里有几毫秒是这么抠出来的。
三档预设也很朴素:
if (level === 'low') return {brightness: 0.02, contrast: 1.08, saturation: 1.08};
if (level === 'high') return {brightness: 0.07, contrast: 1.25, saturation: 1.30};
return {brightness: 0.04, contrast: 1.15, saturation: 1.18};
medium 这三个数是照着演示里那八张图调出来的:对比度上到 1.2,黑狗那张暗部开始出彩噪;饱和度上到 1.2,水母的橙边开始溢色。
6 文搜图:rawfile 得先变成沙箱文件
这是三个坑里代价最大的一个,修法全在 materialize() 里:
private async materialize(uri: string, dir: string): Promise<string> {
if (uri.length === 0) {
throw new Error('uri 不能为空');
}
if (uri.startsWith('rawfile://')) {
const name = uri.substring('rawfile://'.length); // 剥掉前缀,剩下的是 rawfile 目录内的相对名
const data: Uint8Array = await this.context.resourceManager.getRawFileContent(name);
// 只取这一段视图,别把整块底层 buffer 写出去(见下文)
const buf: ArrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
const dest = `${dir}/img_${Date.now()}.${this.extOf(name)}`; // 扩展名跟着原名走
const out = fs.openSync(dest, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
try {
fs.writeSync(out.fd, buf);
} finally {
fs.closeSync(out); // 无论写成功还是失败都关句柄
}
return dest;
}
// 非 rawfile:file:// 前缀去掉就是沙箱路径,fd 对 fd 拷一份
const source = uri.startsWith('file://') ? uri.substring('file://'.length) : uri;
const dest = `${dir}/img_${Date.now()}.${this.extOf(source)}`;
const srcFile = fs.openSync(source, fs.OpenMode.READ_ONLY);
try {
await fs.copyFile(srcFile.fd, dest);
} finally {
fs.closeSync(srcFile);
}
return dest;
}
三个要点。rawfile:// 必须走 resourceManager.getRawFileContent 读字节,它不是一个文件系统路径,fs.openSync 只会回你一句 Invalid argument。data.buffer.slice(byteOffset, ...) 这一刀不能省,Uint8Array 底下那块 buffer 可能是共享的,直接整块写出会把别人的数据也写进文件。扩展名靠 extOf() 从原名带过来——早期我偷懒一律写 .jpg,PNG 被改名后虽然还能显示,但后面所有按后缀分流的地方都错。
还有个容易写漏的地方:openSync 的 flag 里 TRUNC 不能省。文件名带 Date.now(),理论上不会撞,但同一毫秒内先后调两次(比如批处理里循环还没 await 完,用户又点了一次)就有极小概率拿到同一个名字;没有 TRUNC,新内容会从文件头开始覆盖,长度却沿用旧文件,写出来的 jpg 尾部挂着一截旧数据,解码器直接报错。
落盘目录按 scope 分,同时登记来源:
// 每个 scope 一个目录,插入和删除都在自己的目录里发生,互不影响
const destDir = `${this.context.filesDir}/ai_vision_index/${scope}`;
if (!fs.accessSync(destDir)) { // accessSync 返回 false 表示不存在,不是抛异常
fs.mkdirSync(destDir, true); // 第二个参数 true = 递归创建
}
const destPath = await this.materialize(uri, destDir);
const ok = await textSearchImage.insertImage(destPath, scope); // 只认沙箱路径
...
this.indexedCount++;
this.indexSource.set(destPath, uri); // 记下"这张沙箱图是从哪来的"
indexSource 就是那张「沙箱路径 → 原始来源」的表,搜索时反查:
for (const o of objects) {
const source = this.indexSource.get(o.imagePath);
const hit: SearchHit = {imagePath: o.imagePath, scope: o.scope, similarity: o.similarity};
if (source !== undefined) {
hit.sourceUri = source;
}
hits.push(hit);
}
用内存 Map 而不是把原名拼进文件名,是因为那些文件会被系统拿去做特征索引,改名不知道会牵动什么。表随进程走,丢了也不心疼——特征库本来就不保证跨版本有效。
代价也是有的:冷启动之后,早先入库的图再搜出来就只剩沙箱路径,sourceUri 丢了,得从文件名反推。这条我没修,理由写在 7.11。
scope 的校验是硬性的,1~32 位纯字母数字:
if (s.length < 1 || s.length > 32) {
throw new Error('scope must be 1-32 chars of letters/digits');
}
for (let i = 0; i < s.length; i++) {
const c = s.charAt(i);
const isAlnum = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');
if (!isAlnum) {
throw new Error('scope only allows letters and digits');
}
}
为什么不用正则?没什么讲究,写的时候就着手上这个循环顺手敲完了,逻辑跟 ^[a-zA-Z0-9]+$ 等价,最多跑 32 次。
真被拒的时候,这两条报错分不清是长度问题还是字符问题——第一条的文案里也写着 letters/digits。查的时候先数长度,长度没问题再找非法字符,比盯着日志猜快。
这条限制来自系统接口本身。带下划线的 apitest_v2 会被直接拒掉,而且报错不指名是哪个参数不合法,只能回去翻文档看字符集要求。
十三、整页总结
| ① 图库 + 探测 | ② 增强 / 超分 / 搜图 | ③ 搜图命中 |
|---|---|---|
![]() | ![]() | ![]() |
| ④ 批处理 / 自检 | ⑤ 处理对比 | |
|---|---|---|
![]() | ![]() |
总结:
本文围绕React Native for OpenHarmony(RNOH),基于鸿蒙
CoreVisionKit封装了一套RN可用的端侧AI视觉库,补齐RN生态缺失的系统AI能力,支持图像4倍超分、文搜图,搭配effectKit实现画质增强,全程在设备本地运算,图片不上传云端。
核心思路是搭建三层调用架构:JS/TS接口层向外暴露简洁Promise API,通过TurboModule桥接ArkTS原生层,原生层统一封装CoreVisionKit、文件处理、PixelMap内存管理、事件回调等逻辑,把鸿蒙原生能力细节全部收敛,上层RN业务只需一行调用即可使用。
对比云端方案,这套端侧方案隐私性更好、无调用费用、离线可用;短板是模型存在冷启动耗时,能力受设备与鸿蒙系统版本约束,仅API26及以上设备支持超分、文搜图两项AI能力,且这两项能力无需额外申请权限。
文章完整落地了能力探测、画质增强、固定4倍图像超分、文搜图、批量处理、全流程自检六大功能,同时配套降级兜底方案:当端侧AI模型不可用时,自动切换为插值放大+滤镜增强,并且返回标记告知业务侧结果是降级生成,避免假成功。
开发过程踩了多个关键坑:rawfile://不是真实文件路径,不能直接用fs打开,需要读取字节流落盘到沙箱;RN Image无法直接加载rawfile资源,要区分JS资产、沙箱文件两套路径;PixelMap属于原生内存,不受JS GC管控,必须手动release防止内存泄漏;文搜图底层是系统单例服务,索引存储语义特征而非原图像素,索引删除、文件删除是两个独立操作,同时它的全局清空接口不支持按scope隔离;effectKit没有原生对比度、饱和度参数,需要手动实现色彩矩阵叠加,且矩阵乘法顺序会直接影响最终画面效果。
更多推荐
























所有评论(0)