本文是《React Native x HarmonyOS NEXT 创新能力接入方案》系列第 5 篇。上一篇我们接入了 OCR 商品包装识别,这一篇继续做视觉能力:在 React Native 页面中选择商品图片,通过 RNOH TurboModule 调用 HarmonyOS Core Vision Kit 的 subjectSegmentation.doSegmentation,完成商品主体分割、Mask 生成和背景替换。
源码: https://atomgit.com/huqi/RNHarmonySkuAssistant


1. 先康康效果

最终链路如下:

React Native 页面
    ↓ 选择商品图片
NativeImageSegmentationModule.ts
    ↓ Promise 调用
RNOH C++ ArkTSTurboModule
    ↓ 转发到 ArkTS
ImageSegmentationModule.ets
    ↓ fileIo + Image Kit
PixelMap
    ↓ Core Vision Kit
subjectSegmentation.doSegmentation
    ↓
foregroundImage + mattingList
    ↓
主体图 / Mask 图 / 背景替换图
    ↓
React Native Image 组件展示结果

真机截图:

商品图主体分割页面

选择商品图后的状态

主体分割结果

蓝底背景替换结果

本次真机运行数据:

主体分割耗时:6558 ms
蓝底背景替换耗时:6382 ms
原图 URI:file://media/Photo/1620/IMG_1781800935_1609/IMG_20260619_004035.jpg
主体图 URI:file:///data/storage/el2/base/haps/entry/temp/...-product-subject.png
Mask URI:file:///data/storage/el2/base/haps/entry/temp/...-product-mask.png
背景替换图 URI:file:///data/storage/el2/base/haps/entry/temp/...-product-replace.png

2. 文件结构

本篇新增和修改的主要文件如下:

App.tsx
src/pages/ImageSegmentationPage.tsx
src/native/NativeImageSegmentationModule.ts
src/types/imageSegmentation.ts

harmony/entry/src/main/ets/turbomodule/ImageSegmentationModule.ets
harmony/entry/src/main/ets/GeneratedPackage.ets

harmony/entry/src/main/cpp/ImageSegmentationPackage.h
harmony/entry/src/main/cpp/turbomodule/ImageSegmentationModule.h
harmony/entry/src/main/cpp/turbomodule/ImageSegmentationModule.cpp
harmony/entry/src/main/cpp/PackageProvider.cpp
harmony/entry/src/main/cpp/CMakeLists.txt

注意:这个工程不是只注册 ArkTS 模块就结束。React Native 侧通过 TurboModule 调用时,还需要 C++ 侧把 ImageSegmentationModule 注册成 ArkTSTurboModule,否则页面里 TurboModuleRegistry.get('ImageSegmentationModule') 会拿不到模块。


3. React Native 类型声明

src/types/imageSegmentation.ts

export interface SegmentationResult {
  success: boolean;
  originalUri: string;
  subjectUri?: string;
  maskUri?: string;
  message?: string;
  costMs?: number;
}

export interface ReplaceBackgroundResult {
  success: boolean;
  originalUri: string;
  outputUri?: string;
  backgroundColor: string;
  message?: string;
  costMs?: number;
}

src/native/NativeImageSegmentationModule.ts

import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
import type {
  ReplaceBackgroundResult,
  SegmentationResult,
} from '../types/imageSegmentation';

export interface Spec extends TurboModule {
  pickImage(): Promise<string>;
  segmentSubject(imageUri: string): Promise<SegmentationResult>;
  replaceBackground(
    imageUri: string,
    backgroundColor: string,
  ): Promise<ReplaceBackgroundResult>;
}

export default TurboModuleRegistry.get<Spec>(
  'ImageSegmentationModule',
) as Spec | null;

这里使用 get 而不是 getEnforcing,是为了页面可以给出更友好的“模块不可用”提示。真机调试时,如果 C++ 注册漏了,页面不会直接崩溃,而是显示 ImageSegmentationModule 不可用


4. React Native 页面

页面核心逻辑在 src/pages/ImageSegmentationPage.tsx

const handlePickImage = async () => {
  if (!NativeImageSegmentationModule) {
    setMessage('ImageSegmentationModule 不可用');
    return;
  }

  try {
    setLoading(true);
    setMessage('正在打开图片选择器...');
    const uri = await NativeImageSegmentationModule.pickImage();
    setImageUri(uri);
    setSegmentResult(null);
    setReplaceResult(null);
    setMessage('已选择商品图,可以开始主体分割');
  } catch (error) {
    const errStr = String(error);
    setMessage(errStr === 'CANCEL' ? '已取消选择图片' : `选择图片失败:${errStr}`);
  } finally {
    setLoading(false);
  }
};

const handleSegment = async () => {
  if (!NativeImageSegmentationModule) {
    setMessage('ImageSegmentationModule 不可用');
    return;
  }
  if (!imageUri) {
    setMessage('请先选择图片');
    return;
  }

  try {
    setLoading(true);
    setMessage('正在进行主体分割...');
    const result = await NativeImageSegmentationModule.segmentSubject(imageUri);
    setSegmentResult(result);
    setMessage(result.message ?? '主体分割完成');
  } catch (error) {
    setMessage(`主体分割失败:${String(error)}`);
  } finally {
    setLoading(false);
  }
};

结果图直接通过 RN Image 展示:

{segmentResult?.subjectUri ? (
  <Image
    source={{ uri: segmentResult.subjectUri }}
    style={styles.previewImage}
    resizeMode="contain"
  />
) : null}

{replaceResult?.outputUri ? (
  <Image
    source={{ uri: replaceResult.outputUri }}
    style={styles.previewImage}
    resizeMode="contain"
  />
) : null}

这里有一个细节:ArkTS 返回给 RN 的本地图片必须是 file:///data/...png 这样的 URI。只返回 /data/storage/...png 裸路径时,结果信息能显示,但 RN Image 组件不会稳定渲染。


5. ArkTS:选择图片

图片选择放在 ArkTS 模块中,避免 RN 侧处理 HarmonyOS 文件授权和 URI 差异。

import { photoAccessHelper } from '@kit.MediaLibraryKit';

async pickImage(): Promise<string> {
  try {
    const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
    photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
    photoSelectOptions.maxSelectNumber = 1;

    const photoViewPicker = new photoAccessHelper.PhotoViewPicker();
    const photoSelectResult = await photoViewPicker.select(photoSelectOptions);

    if (photoSelectResult.photoUris && photoSelectResult.photoUris.length > 0) {
      return photoSelectResult.photoUris[0];
    }

    return Promise.reject('CANCEL');
  } catch (err) {
    const error = err as BusinessError;
    const msg = error.message || String(err);
    return Promise.reject(`选择图片失败: ${msg}`);
  }
}

系统图库返回的图片 URI 示例:

file://media/Photo/1620/IMG_1781800935_1609/IMG_20260619_004035.jpg

这个 URI 可以通过 fileIo.open(imageUri, fileIo.OpenMode.READ_ONLY) 读取。


6. ArkTS:真实主体分割

核心实现位于 harmony/entry/src/main/ets/turbomodule/ImageSegmentationModule.ets

import { fileIo } from '@kit.CoreFileKit';
import { image } from '@kit.ImageKit';
import { subjectSegmentation } from '@kit.CoreVisionKit';

async segmentSubject(imageUri: string): Promise<SegmentationResult> {
  if (!imageUri) {
    return Promise.reject('IMAGE_URI_EMPTY');
  }

  const start = Date.now();
  let source: image.ImageSource | null = null;
  let pixelMap: image.PixelMap | null = null;
  let file: fileIo.File | null = null;
  let serviceInitialized = false;

  try {
    file = await fileIo.open(imageUri, fileIo.OpenMode.READ_ONLY);
    source = image.createImageSource(file.fd);
    pixelMap = await source.createPixelMap();

    serviceInitialized = await subjectSegmentation.init();
    if (!serviceInitialized) {
      return Promise.reject('SEGMENTATION_SERVICE_INIT_FAILED');
    }

    const nativeResult = await subjectSegmentation.doSegmentation(
      { pixelMap },
      {
        maxCount: 5,
        enableSubjectDetails: true,
        enableSubjectForegroundImage: true,
      },
    );

    const subjectUri = await this.savePixelMapToTemp(
      nativeResult.fullSubject.foregroundImage,
      'product-subject.png',
    );

    const maskUri = await this.saveMattingAsMask(
      nativeResult.fullSubject.mattingList,
      pixelMap,
      'product-mask.png',
    );

    return {
      success: true,
      originalUri: imageUri,
      subjectUri,
      maskUri,
      message: '主体分割完成',
      costMs: Date.now() - start,
    };
  } catch (err) {
    const error = err as BusinessError;
    const msg = error.message || String(err);
    return {
      success: false,
      originalUri: imageUri,
      message: `主体分割失败: ${msg}`,
      costMs: Date.now() - start,
    };
  } finally {
    if (serviceInitialized) {
      await subjectSegmentation.release();
    }
    if (pixelMap) {
      await pixelMap.release();
    }
    if (source) {
      await source.release();
    }
    if (file) {
      await fileIo.close(file);
    }
  }
}

subjectSegmentation.doSegmentation 返回的关键数据:

nativeResult.fullSubject.foregroundImage  // 已抠出主体的 PixelMap
nativeResult.fullSubject.mattingList      // 每个像素的前景/背景标记
nativeResult.subjectCount                 // 识别出的主体数量

本篇直接使用 foregroundImage 作为主体图,并用 mattingList 生成可视化 Mask 和背景替换图。


7. 保存 PixelMap 给 RN 展示

保存图片时使用 Image Kit 的 ImagePacker.packToFile

private async savePixelMapToTemp(
  pixelMapToSave: image.PixelMap,
  fileName: string,
): Promise<string> {
  const context = this.ctx.uiAbilityContext;
  const filePath = `${context.tempDir}/${Date.now()}-${fileName}`;

  const packOpts: image.PackingOption = { format: 'image/png', quality: 100 };
  const imagePackerApi = image.createImagePacker();
  let writeFile: fileIo.File | null = null;

  try {
    writeFile = await fileIo.open(
      filePath,
      fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC,
    );
    await imagePackerApi.packToFile(pixelMapToSave, writeFile.fd, packOpts);
  } finally {
    if (writeFile) {
      await fileIo.close(writeFile);
    }
    await imagePackerApi.release();
  }

  return `file://${filePath}`;
}

这里踩过两个坑:

  1. 不要只返回沙箱裸路径。RN Image 更适合接收 file:///data/...png
  2. 打开输出文件时加上 TRUNC,避免同名文件残留旧内容。本项目最终使用时间戳文件名,也能避免覆盖问题。

8. Mask 与背景替换

mattingListInt32Array,这里按 > 0 判断前景像素。生成 Mask 时,前景写白色,背景写黑色:

const maskBytes = new Uint8Array(pixelCount * 4);

for (let i = 0; i < pixelCount; i++) {
  const offset = i * 4;
  if (i < mattingList.length && mattingList[i] > 0) {
    maskBytes[offset] = 255;
    maskBytes[offset + 1] = 255;
    maskBytes[offset + 2] = 255;
    maskBytes[offset + 3] = 255;
  } else {
    maskBytes[offset] = 0;
    maskBytes[offset + 1] = 0;
    maskBytes[offset + 2] = 0;
    maskBytes[offset + 3] = 255;
  }
}

创建 PixelMap 时使用兼容 API 8 的 image.createPixelMap

const opts: image.InitializationOptions = {
  size: { width, height },
  srcPixelFormat: image.PixelMapFormat.RGBA_8888,
  pixelFormat: image.PixelMapFormat.RGBA_8888,
  editable: true,
};

const maskPixelMap = await image.createPixelMap(maskBytes.buffer, opts);

不要使用 createPixelMapFromPixels 作为 API 24 项目的默认方案。它在当前 SDK 声明中标注为 API 26 起支持,编译虽然可能通过,但会出现兼容性警告,也会增加真机风险。

背景替换的思路也很直接:

读取原图 RGBA 像素
遍历每个像素
mattingList[i] > 0:保留原图像素
否则:写入背景色 RGB
生成新的 PixelMap
保存为 product-replace.png

这个版本是为了验证端侧能力闭环,边缘羽化、阴影、统一画布尺寸等美化逻辑可以作为后续优化。


9. C++ TurboModule 注册

只在 GeneratedPackage.ets 中注册 ArkTS 模块还不够,RN 侧 TurboModule 还需要 C++ 侧注册。

harmony/entry/src/main/cpp/turbomodule/ImageSegmentationModule.cpp

#include "ImageSegmentationModule.h"

namespace rnoh {

ImageSegmentationModule::ImageSegmentationModule(
    const ArkTSTurboModule::Context ctx,
    const std::string name)
    : ArkTSTurboModule(ctx, name) {
  methodMap_ = {
      ARK_ASYNC_METHOD_METADATA(pickImage, 0),
      ARK_ASYNC_METHOD_METADATA(segmentSubject, 1),
      ARK_ASYNC_METHOD_METADATA(replaceBackground, 2),
  };
}

} // namespace rnoh

harmony/entry/src/main/cpp/ImageSegmentationPackage.h

class ImageSegmentationPackageTurboModuleFactoryDelegate
    : public TurboModuleFactoryDelegate {
 public:
  SharedTurboModule createTurboModule(Context ctx, const std::string &name) const override {
    if (name == "ImageSegmentationModule") {
      return std::make_shared<ImageSegmentationModule>(ctx, name);
    }
    return nullptr;
  }
};

然后在 PackageProvider.cpp 中加入:

#include "ImageSegmentationPackage.h"

packages.push_back(std::make_shared<ImageSegmentationPackage>(ctx));

并在 CMakeLists.txt 中编译:

"./turbomodule/ImageSegmentationModule.cpp"

如果漏掉这一步,RN 页面通常会表现为:

ImageSegmentationModule 不可用

10. ArkTS 包注册

harmony/entry/src/main/ets/GeneratedPackage.ets

import { ImageSegmentationModule } from './turbomodule/ImageSegmentationModule';

export default class GeneratedPackage extends RNOHPackage {
  getUITurboModuleFactoryByNameMap(): Map<string, (ctx: UITurboModuleContext) => UITurboModule> {
    return new Map<string, (ctx: UITurboModuleContext) => UITurboModule>([
      [ImageSegmentationModule.NAME, (ctx) => new ImageSegmentationModule(ctx)],
    ]);
  }
}

当前工程中还会保留其他模块,例如 SKU、TTS、OCR。这里只展示主体分割相关注册。


11. 真机构建与运行

本次使用 DevEco Studio 自带工具链构建:

cd harmony
env -u OHOS_SDK -u OHOS_SDK_PATH \
  DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk/default \
  PATH="/Applications/DevEco-Studio.app/Contents/tools/node/bin:/Applications/DevEco-Studio.app/Contents/tools/hvigor/bin:/Applications/DevEco-Studio.app/Contents/tools/ohpm/bin:/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains:$PATH" \
  /Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw assembleHap --mode module -p module=entry --no-daemon --stacktrace

安装并启动:

hdc install -r harmony/entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b host.huqi.sku_assistant

验证命令:

npm test -- --runInBand
hvigorw assembleHap
hdc install -r ...
hdc shell aa start ...

结果:

Jest:1 passed
Hvigor:BUILD SUCCESSFUL
HAP:install bundle successfully
Ability:start ability successfully

构建时 RNOH 依赖中会出现一些 ArkTS 兼容性 warning,例如 px2vp deprecated、RNOH 内部 definite assignment warning。这些不是本篇主体分割代码引入的构建失败项。


12. 常见问题

12.1 ImageSegmentationModule 为 null

优先检查:

1. `NativeImageSegmentationModule.ts` 中模块名是否是 `ImageSegmentationModule`
2. `GeneratedPackage.ets` 是否注册 ArkTS 模块
3. C++ `PackageProvider.cpp` 是否加入 `ImageSegmentationPackage`
4. `CMakeLists.txt` 是否编译 `ImageSegmentationModule.cpp`
5. 是否重新构建并安装 HAP

本项目就是在补齐 C++ 注册后,RN 页面才真正拿到模块。

12.2 结果路径有值,但图片不显示

检查 ArkTS 返回给 RN 的路径格式。

不推荐:

/data/storage/el2/base/haps/entry/temp/product-subject.png

推荐:

file:///data/storage/el2/base/haps/entry/temp/product-subject.png

RN Image 组件对本地文件 URI 更稳定。

12.3 首次分割较慢

主体分割耗时会受图片尺寸、设备性能、服务初始化状态影响。本次真机上主体分割和背景替换大约 6 秒左右。连续处理、图片压缩、后台线程和缓存策略都可以继续优化。

12.4 分割边缘不够自然

本篇只做能力闭环,没有做商业级修图。后续可以优化:

1. 对 mask 边缘做羽化
2. 给商品增加轻微投影
3. 输出固定画布比例,例如 1:1 或 4:5
4. 对图片先缩放再处理,减少耗时
5. 用前景矩形裁切并居中商品主体

13. 本篇小结

这篇完成的是一个真实可运行的 RNOH x HarmonyOS 端侧视觉能力闭环:

RN 交互
    ↓
TurboModule 声明
    ↓
C++ ArkTSTurboModule 注册
    ↓
ArkTS 图片选择、解码、AI 分割、图片保存
    ↓
RN 展示主体图和背景替换图

本篇我们实践的亮点是:页面上展示的主体图、Mask 和背景替换图都来自真机上的 Core Vision Kit 调用。

后续可以把这套能力抽成 rnoh-image-tools,继续复用到商品主图白底化、素材批处理、OCR + 主体分割组合识别等场景。


Logo

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

更多推荐