HarmonyOS 鸿蒙 Subject Sticker 主体抠图贴纸实战 —— AI 分割、像素白边与五轮真机排雷
一、目标与能力盘点
目标闭环很朴素:选图(或拍照)→ 抠出主体 → 生成带白边的贴纸 → 预览对照。
能力侧先做交叉验证(原则:官方文档提出假设,本机 SDK .d.ts 证明可编译):
-
主体分割:
@kit.CoreVisionKit的subjectSegmentation,init() / doSegmentation() / release()三段式,声明 API 12 起可用(本项目 API 23,无障碍)。doSegmentation开enableSubjectForegroundImage后直接返回带 alpha 的前景foregroundImage。 -
白边:官方分割不提供任何描边能力。白边是像素算法,得自己写。
-
选图/拍照:
PhotoViewPicker与CameraPicker(系统相机,无需自申请相机权限)。
所以工作量分布天然是三块:AI 服务接入(薄)、像素算法(厚)、系统组件对接(坑最深)。
二、AI 分割接入:薄薄一层,别让它干活
SubjectStickerService.ets 全部职责只有三个静态方法:
export class SubjectStickerService {
static async initialize(): Promise<boolean> {
return subjectSegmentation.init();
}
static async cutout(source: image.PixelMap): Promise<image.PixelMap> {
const result: subjectSegmentation.SegmentationResult = await subjectSegmentation.doSegmentation(
{ pixelMap: source },
{
maxCount: 1,
enableSubjectDetails: false,
enableSubjectForegroundImage: true
}
);
return result.fullSubject.foregroundImage;
}
static async release(): Promise<void> {
await subjectSegmentation.release();
}
}
两条纪律:
-
服务不承担贴纸风格。它只换出前景 PixelMap,白边、羽化、配色都是下游 Core 的事。
-
页面持有生命周期,连续处理不重复 init/release。分割服务初始化有成本,选一张图 init 一次、处理 N 张、离开页面 release,才是正确节拍。
一个真机量级数据供参考:最长边 720px 的单样本,从解码到白边生成全链路约 3.3 秒(主线程)。这个数字后面还会用到。
三、白边算法:圆盘 Alpha 膨胀
白边贴纸的像素语义:把主体的 alpha 掩码向外膨胀 r 像素,膨胀出来的环涂成白色,主体原像素盖在上面。实现在 StickerOutlineCore.ets,零 ArkUI 依赖,可单测。
3.1 输出必须先扩边距
第一个容易忽略的点:输出画布要比输入大 2 × radius。否则贴着画面边缘的主体,外描边会被直接裁掉:
const targetWidth: number = sourceWidth + radius * 2;
const targetHeight: number = sourceHeight + radius * 2;
3.2 为什么不能用方形膨胀
经典加速技巧是「可分离 max filter」:先做水平半径 r 的滑动最大值,再做垂直半径 r 的滑动最大值,两次一维操作近似二维方形(正方形结构元)膨胀。
问题:方形结构元会把凸角变方。主体的圆钝轮廓(头顶、肩膀、手指)会被扩出一个个直角台阶,白边看起来像廉价的描边字。
正确结构元是圆盘:以主体边缘为圆心、r 为半径的圆的并集。精确做圆盘膨胀是 O(radius² × pixels),720px 图上就是数千万到上亿次的 alpha 比较,主线程直接爆炸。
3.3 弦长分解:O(radius × pixels)
本实验的解法:按行偏移量分解圆盘。对每个纵向偏移 dy,圆盘在该行的截面是一个水平弦,弦长 radiusX = ⌊√(r² − dy²)⌋。对每个 dy 做一次水平滑动最大值(半径为该行的弦长),再垂直方向取 max 合并:
export function dilateAlphaDisk(alpha: Uint8Array, width: number, height: number,
requestedRadius: number): Uint8Array {
const radius: number = clampRadius(requestedRadius);
if (radius === 0) {
return new Uint8Array(alpha);
}
const output: Uint8Array = new Uint8Array(alpha.length);
for (let dy: number = 0; dy <= radius; dy++) {
const radiusX: number = Math.floor(Math.sqrt(radius * radius - dy * dy));
const rowMax: Uint8Array = horizontalMax(alpha, width, height, radiusX);
for (let y: number = 0; y < height; y++) {
// y+dy 与 y−dy 两行取 max 写回(对称利用上下弦)
...
}
}
return output;
}
其中一维滑动最大值用单调队列,均摊 O(1) 每像素:
for (let x: number = -radius; x < width; x++) {
const entering: number = x + radius;
if (entering < width) {
const enteringValue: number = alpha[rowOffset + entering];
while (tail > head && alpha[rowOffset + deque[tail - 1]] <= enteringValue) {
tail--; // 弹掉永远成不了最大值的尾巴
}
deque[tail++] = entering;
}
const leaving: number = x - radius;
while (tail > head && deque[head] < leaving) {
head++; // 滑出窗口的队首出队
}
if (x >= 0 && tail > head) {
output[rowOffset + x] = alpha[rowOffset + deque[head]];
}
}
总复杂度 O(radius × pixels):半径 16 的圆盘膨胀从理论上亿次比较降到约 16 次线性扫描的量级。凸角保持圆润——单测里专门锁了「圆盘角点」的形态断言。
3.4 合成与预乘 alpha
合成顺序:先把膨胀 alpha 中「原本透明、膨胀后不透明」的像素涂白,再把主体原像素整体盖上(alpha > 0 的覆盖)。白像素的 RGB 要按 alphaType 区分:预乘(PREMUL)缓冲里白要乘以 alpha,直乘(UNPREMUL)缓冲直接 255——搞反了白边会发灰或过曝:
if (alpha === 0 && dilatedAlpha[pixel] > 0) {
const white: number = premultiplied ? dilatedAlpha[pixel] : 255;
...
}
四、第一轮雷:Picker 回传「没反应」
真机首轮现象:点了「从相册选择」,选完图回到应用——什么都没发生。没有报错,没有日志。
排雷结果,四条修正:
-
打开 Picker 前就进入 processing 状态,UI 显示「等待系统相册返回」。系统相册是一个独立 Ability,用户在里面停留多久你控制不了,空窗期必须有反馈。
-
媒体 URI 不能直接交给
ImageSource。相册返回的 URI 带临时授权,正确姿势是先用它打开只读 fd,再从 fd 解码:
const file: fileIo.File = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
const source: image.ImageSource = image.createImageSource(file.fd);
-
系统 Picker 活跃期间不 release 分割服务。外部选择页切换会触发本页面生命周期回调,如果随手把服务释放了,回来就撞上服务生命周期竞争。
-
补
E018 stage=*分阶段日志(picker / decode / segmentation / outline)。异步管线没有分段日志,排障等于盲猜。
这四条里第 2 条是硬知识:URI → fd → ImageSource,不是 URI → ImageSource。
五、第二轮雷:Image Kit 62980115
第二轮走到分割完成,然后 addWhiteOutline 抛 62980115(SDK 定义:Invalid image parameter)。
根因不在「参数不合法」,而在对分割结果 PixelMap 的内存布局做了三个想当然的假设:
|
想当然 |
事实 |
|---|---|
|
像素格式是 RGBA_8888 |
分割结果可能是 RGBA,也可能是 BGRA |
|
缓冲大小 = |
应该用 |
|
每行字节 = |
行可能对齐,应该用 |
修正后的读取姿势(这段代码值得抄走):
const rowBytes: number = foreground.getBytesNumberPerRow(); // stride,可能 > width*4
const pixelBytes: number = foreground.getPixelBytesNumber(); // 真实缓冲大小
const alignedBuffer: ArrayBuffer = new ArrayBuffer(pixelBytes);
await foreground.readPixelsToBuffer(alignedBuffer);
// stride 压紧:逐行拷出 width*4 的有效字节,喂给纯算法 Core
const packedRowBytes: number = info.size.width * 4;
for (let y: number = 0; y < info.size.height; y++) {
const sourceOffset: number = y * rowBytes;
const targetOffset: number = y * packedRowBytes;
for (let byteIndex: number = 0; byteIndex < packedRowBytes; byteIndex++) {
packed[targetOffset + byteIndex] = aligned[sourceOffset + byteIndex];
}
}
配套三条原则:
-
不无条件
convertPixelFormat(RGBA_8888)——RGBA/BGRA 都直接读,把格式信息透传给算法层; -
输出 PixelMap 保留输入的 pixelFormat 与 alphaType(
srcPixelFormat显式声明); -
格式只做日志记录(
console.info打出 size/format/stride/alpha),不做假设。
62980115 这一轮的教训可以推广:native 图像缓冲是「带元数据的字节流」,stride 和格式就是元数据。任何绕过元数据直接算字节的做法,都是在不同设备上埋定时炸弹。
六、第三轮雷:处理成功,预览不刷新
第三轮真机处理成功(3.3 秒后日志齐全),但 Image 预览还是旧的。
根因很 ArkUI:Lab 页原来的预览是一个通用 @Builder Preview(title, pixelMap)。@Builder 的值参数在构建时拍了快照——异步任务完成后往 @State 里塞的新 PixelMap,不会重新绑定到 Builder 内部的 Image 上。
修正两板斧:
-
前景与贴纸改用两个直接读取宿主
@State的 Builder(不经过参数传递); -
每次生成结果递增
previewEpoch,拼进Image的.id(),强制 native 渲染节点重建:
@State private previewEpoch: number = 0;
...
Image(this.stickerResult)
.id(`e018_sticker_${this.previewEpoch}`) // epoch 变 → 节点重建 → native 句柄重绑
沉淀成规则:异步就绪的 native 资源(PixelMap/Surface),不要通过普通 @Builder 值参数做长期渲染绑定。 要么让 Builder 直接读 @State,要么用 epoch 换 id 强制重建。这个模式在 E019 翻书实验里也被复用(pagesEpoch),已经是本项目的标准件。
七、第四、五轮雷:CameraPicker 的连环坑
「拍照」入口用系统 cameraPicker(后置相机,不自己申请相机权限)。两轮翻车:
第四轮:进入系统相机前立即失败,日志只有 camera open → failed。两个修正:
-
移除应用 cache 目录的
saveUri——设备不接受这个保存路径,改用 CameraPicker 默认媒体库保存策略,直接消费返回的媒体 URI(正好复用第四节的 URI 管线); -
错误显示改读
Error.message。JSON.stringify(error)会把 Error 序列化成{},排障时等于没有信息。
第五轮:错误 cannot assign to read only property。来自这个写法:
const profile: PickerProfile = new PickerProfile();
profile.cameraPosition = ...; // ❌ 系统对象是冻结的,赋值即崩
官方示例的做法是一次性配置对象初始化,构造时把属性写全,不做二次修改:
const profile: PickerProfile = { cameraPosition: CameraPosition.CAMERA_POSITION_BACK };
拍完的 URI 与相册 URI 汇入同一条 ProcessImageUri 管线——两个入口,一条处理链,别复制粘贴出两份。
八、性能账与下一步
真机单样本全链路约 3258ms(720px 上限)。这笔账的构成:解码 + 分割(AI 推理)+ 白边膨胀 + PixelMap 重建,全部在主线程。三个后续方向(实验的 Next Decision Gates):
-
像素运算搬 TaskPool:白边 Core 已经是纯函数 + ArrayBuffer,天然可迁移;分割 API 本身是异步的,瓶颈在像素后处理。
-
720px 上限动态化:按设备实测决定提升、动态设置或维持;未经测量就放开上限,等于把千万级 alpha 运算直接怼在主线程上。
-
边缘质量:检查细发/透明物边缘与白 halo,必要时上 mask 羽化/腐蚀与手动修边,再谈 PNG 保存。
九、分层 Pattern 与总结
Platform AI owns semantic mask —— AI 只回答「哪里是主体」
Pure Core owns deterministic pixels —— 白边形态由纯函数决定,可单测
Lab owns lifecycle, sampling and evidence —— 页面管生命周期、采样与证据
回头看这五轮雷,没有一轮是「算法写错了」:
|
轮次 |
雷 |
一句话教训 |
|---|---|---|
|
1 |
Picker 回传没反应 |
URI 要走 fd 解码;空窗期要有 processing 反馈 |
|
2 |
62980115 |
stride / pixelBytes / format 是元数据,别假设 |
|
3 |
预览不刷新 |
@Builder 值参数是快照;epoch 换 id 重建 native 节点 |
|
4 |
CameraPicker 秒败 |
别传 cache saveUri;错误读 |
|
5 |
只读对象崩溃 |
系统配置对象一次性初始化,禁止二次赋值 |
系统组件的对接行为,文档描述与真机之间永远有缝隙。这个实验沉淀的最大资产不是白边算法,而是**「分段日志 + 分轮排雷 + 假设显式化」**的排障纪律——它让每一轮失败都变成了可复述的知识,而不是一次玄学经历。
更多推荐


所有评论(0)