RNOH x HarmonyOS 端侧 AI:商品图主体分割与背景替换真机实践
本文是《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}`;
}
这里踩过两个坑:
- 不要只返回沙箱裸路径。RN
Image更适合接收file:///data/...png。 - 打开输出文件时加上
TRUNC,避免同名文件残留旧内容。本项目最终使用时间戳文件名,也能避免覆盖问题。
8. Mask 与背景替换
mattingList 是 Int32Array,这里按 > 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 + 主体分割组合识别等场景。
更多推荐



所有评论(0)