一、目标与能力盘点

目标闭环很朴素:选图(或拍照)→ 抠出主体 → 生成带白边的贴纸 → 预览对照。

能力侧先做交叉验证(原则:官方文档提出假设,本机 SDK .d.ts 证明可编译):

  • 主体分割@kit.CoreVisionKitsubjectSegmentationinit() / doSegmentation() / release() 三段式,声明 API 12 起可用(本项目 API 23,无障碍)。doSegmentationenableSubjectForegroundImage 后直接返回带 alpha 的前景 foregroundImage

  • 白边:官方分割不提供任何描边能力。白边是像素算法,得自己写。

  • 选图/拍照PhotoViewPickerCameraPicker(系统相机,无需自申请相机权限)。

所以工作量分布天然是三块: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();
  }
}

两条纪律:

  1. 服务不承担贴纸风格。它只换出前景 PixelMap,白边、羽化、配色都是下游 Core 的事。

  2. 页面持有生命周期,连续处理不重复 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 回传「没反应」

真机首轮现象:点了「从相册选择」,选完图回到应用——什么都没发生。没有报错,没有日志。

排雷结果,四条修正:

  1. 打开 Picker 前就进入 processing 状态,UI 显示「等待系统相册返回」。系统相册是一个独立 Ability,用户在里面停留多久你控制不了,空窗期必须有反馈。

  2. 媒体 URI 不能直接交给 ImageSource。相册返回的 URI 带临时授权,正确姿势是先用它打开只读 fd,再从 fd 解码:

const file: fileIo.File = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
const source: image.ImageSource = image.createImageSource(file.fd);
  1. 系统 Picker 活跃期间不 release 分割服务。外部选择页切换会触发本页面生命周期回调,如果随手把服务释放了,回来就撞上服务生命周期竞争。

  2. E018 stage=* 分阶段日志(picker / decode / segmentation / outline)。异步管线没有分段日志,排障等于盲猜。

这四条里第 2 条是硬知识:URI → fd → ImageSource,不是 URI → ImageSource。

五、第二轮雷:Image Kit 62980115

第二轮走到分割完成,然后 addWhiteOutline62980115(SDK 定义:Invalid image parameter)。

根因不在「参数不合法」,而在对分割结果 PixelMap 的内存布局做了三个想当然的假设

想当然

事实

像素格式是 RGBA_8888

分割结果可能是 RGBA,也可能是 BGRA

缓冲大小 = width × height × 4

应该用 getPixelBytesNumber()

每行字节 = width × 4

行可能对齐,应该用 getBytesNumberPerRow()(stride)

修正后的读取姿势(这段代码值得抄走):

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 与 alphaTypesrcPixelFormat 显式声明);

  • 格式只做日志记录(console.info 打出 size/format/stride/alpha),不做假设。

62980115 这一轮的教训可以推广:native 图像缓冲是「带元数据的字节流」,stride 和格式就是元数据。任何绕过元数据直接算字节的做法,都是在不同设备上埋定时炸弹。

六、第三轮雷:处理成功,预览不刷新

第三轮真机处理成功(3.3 秒后日志齐全),但 Image 预览还是旧的

根因很 ArkUI:Lab 页原来的预览是一个通用 @Builder Preview(title, pixelMap)@Builder 的值参数在构建时拍了快照——异步任务完成后往 @State 里塞的新 PixelMap,不会重新绑定到 Builder 内部的 Image 上。

修正两板斧:

  1. 前景与贴纸改用两个直接读取宿主 @State 的 Builder(不经过参数传递);

  2. 每次生成结果递增 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.messageJSON.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):

  1. 像素运算搬 TaskPool:白边 Core 已经是纯函数 + ArrayBuffer,天然可迁移;分割 API 本身是异步的,瓶颈在像素后处理。

  2. 720px 上限动态化:按设备实测决定提升、动态设置或维持;未经测量就放开上限,等于把千万级 alpha 运算直接怼在主线程上。

  3. 边缘质量:检查细发/透明物边缘与白 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;错误读 Error.message

5

只读对象崩溃

系统配置对象一次性初始化,禁止二次赋值

系统组件的对接行为,文档描述与真机之间永远有缝隙。这个实验沉淀的最大资产不是白边算法,而是**「分段日志 + 分轮排雷 + 假设显式化」**的排障纪律——它让每一轮失败都变成了可复述的知识,而不是一次玄学经历。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐