示例仓库: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)
系统 APIHarmonyOS 7.0.0 / API 26(compatibleSdkVersion: 26.0.0)
React Native0.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.CoreVisionKitAPI 26.0.0固定 4 倍不可配;输入原图最大 2048×2048,超了要先等比缩
文搜图 textSearchImage@kit.CoreVisionKitAPI 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.CoreVisionKitAPI 26.0.0(HarmonyOS 7)端侧模型重建放大,固定 4 倍不可配;输入原图最大 2048×2048,超限要先等比缩放
文搜图 textSearchImage@kit.CoreVisionKitAPI 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})文搜图,返回缩略路径 + 相似度 + sourceUritopK
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.jpg0.366#1
橙色的水母real_jellyfish.jpg0.361#1
一杯拉花咖啡real_coffee.jpg0.334#1
↑ 同一句的第 2 名real_honey.jpg0.327#2
绿色的山间公路real_scene.jpg0.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 会被直接拒掉,而且报错不指名是哪个参数不合法,只能回去翻文档看字符集要求。


十三、整页总结

① 图库 + 探测② 增强 / 超分 / 搜图③ 搜图命中
走查1走查2走查3
④ 批处理 / 自检⑤ 处理对比
走查4走查5

总结:

本文围绕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没有原生对比度、饱和度参数,需要手动实现色彩矩阵叠加,且矩阵乘法顺序会直接影响最终画面效果。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐