前言

主体分割封面

给商品图片制作贴纸时,用户经常希望留下杯子或者商品本身,再把它放到另一张背景图上。如果直接裁剪出一个矩形,原图中的桌面和墙面也会跟着保留下来。到了新画面中,这些背景就会挡住原有内容,贴纸看起来仍然像一张方形照片。

要解决这个问题,页面需要知道主体的轮廓,还要让轮廓以外的位置变成透明。HarmonyOS 的 CoreVisionKit 提供主体分割能力,可以从输入图片中识别显著主体,并返回包含前景的像素图。我们将把这个结果显示在页面上,再保存成带透明信息的 PNG 文件,让它能继续参与图片合成。

一、先准备可以交给分割服务的图片

制作贴纸之前,用户需要先提供一张包含清晰主体的图片。页面可以从工程的 resources/rawfile 读取一张默认图片,也可以打开系统图片选择器,让用户替换成自己的照片。两条入口最终都得到一张像素图,后面的分割过程就可以使用相同的数据类型。

图片文件保存的是经过编码的数据,而视觉接口处理的是已经解码的图像内容。ImageStore 创建图像源以后,调用 createPixelMap() 得到 RGBA_8888 格式的 PixelMap。像素图交给页面持有,图像源则在解码结束后释放。这样页面下次处理时可以直接使用当前图片,不需要重复打开原始文件。

选择图片时,新图片先完成解码,再替换页面持有的旧图片。如果用户关闭选择器而没有选择文件,页面继续保留原来的输入。这样用户即使改变主意,也能直接使用页面上已经显示的图片开始分割。

主体分割对输入尺寸有要求,图片宽、高都需要大于 20 像素且小于 9000 像素,主体面积至少占原图面积的千分之五。页面会显示当前输入的宽和高,处理失败时可以先核对尺寸。主体是否足够明显,还需要结合图片内容判断:画面里只有很小一件商品时,可以先调整拍摄或裁剪范围,让对象在图片中占据更合适的位置。

分割服务会按照图像内容判断显著主体。对于杯子和碟子这样的组合,返回的前景可能包含多个相邻部分,不能仅凭请求参数预先确定每件物品都会独立输出。页面先把合并结果作为一张前景图展示,用户就能结合自己的用途判断保留下来的范围是否合适。

二、请求前景图,取得主体区域

输入图片准备好以后,我们使用 subjectSegmentation 创建分割结果。调用前先执行 init(),初始化成功后再把像素图交给 doSegmentation()。分割结束后,服务通过 release() 释放内部资源,返回的前景像素图则交给页面继续显示和保存。

这次请求的目标是得到可以直接合成的图片,因此需要开启 enableSubjectForegroundImage。配置里的 maxCount 限制服务处理的主体数量,取值为 1 至 20,默认值为 6。我们显式填写 6,最终识别出了多少主体仍然读取结果中的 subjectCount。

        // 请求整体前景图,主体数量由服务返回。
        const data = await subjectSegmentation.doSegmentation({ pixelMap: input }, { maxCount: 6, enableSubjectDetails: false, enableSubjectForegroundImage: true });
        result.picture = data.fullSubject.foregroundImage;

返回值中的 fullSubject 代表合并后的主体信息。其中,subjectRectangle 描述主体所在的矩形区域,foregroundImage 提供前景像素图。页面把区域信息写在详情中,并把前景图放进图片组件,这样我们既能了解主体的大致位置,也能直接查看轮廓。

矩形区域和前景轮廓承担的任务不同。假设杯子位于矩形中央,矩形四个角仍可能包含原来的桌面。只有读取前景像素图,页面才能把杯子边缘以外的区域显示为透明,也才能在保存时保留细小的形状变化。

配置中的 enableSubjectDetails 控制是否返回逐个主体的详情。当前页面把所有前景作为一张贴纸处理,所以关闭详情,直接读取 fullSubject。如果产品需要让用户分别选择不同对象,就需要开启详情,并围绕单个主体的返回数据设计选择和导出流程。

当图片里的主体数量超过设置的上限时,结果会受到 maxCount 约束。这个值不是要求服务必须返回的数量,页面也不应该根据它生成固定数量的预览。以返回的 subjectCount 和前景图为准,才能让页面与实际处理结果保持一致。

主体分割与 PNG 输出流程

服务返回像素图以后,工作还没有结束。页面首先要让用户观察分割效果,然后把像素图编码成文件。显示和保存虽然使用同一张输出图,却分别依赖图片组件和编码器,因此需要给输出图保留足够长的使用时间。

三、在页面上判断分割效果

页面把输入图和前景图并排放置,两张图片都使用 ImageFit.Contain。这样原图不会为了填满显示区域而被裁掉,用户可以在相同的页面空间里对照主体位置。下方的详情区域显示主体数量和矩形范围,方便把服务返回的信息与画面联系起来。

原图与前景结果

前景图背后的浅色区域来自页面设置的背景色。当某个像素完全透明时,用户看到的是组件后面的颜色,而不是服务生成的新背景。我们给结果区域设置背景色,是为了让透明部分在页面上容易辨认,保存图片时不会把这层页面背景一起写入文件。

判断结果能否用于贴纸,需要观察主体边缘以及对象内部是否有不该透明的地方。例如细长的勺柄如果被截断,换到其他背景上以后缺口会更加明显。带孔的物体也值得检查内部空隙,因为这个位置既可能需要保留背景,也可能需要保留主体的一部分。

页面展示的是服务直接返回的前景图,没有额外执行描边或羽化。这样观察到的轮廓就对应本次分割结果。如果后续要增加边缘修整,可以在取得前景图之后新增编辑步骤,并保留原始结果,方便区分识别产生的变化和编辑产生的变化。

用户更换输入图片后,页面清除上一张图的前景结果,再等待新的处理。这一步让原图和输出之间的对应关系保持清楚,避免新图片已经出现,旁边仍显示旧图片的贴纸。用户取消选图时则保留原来的输入和输出,页面不会因为一次取消操作丢失已有结果。

四、将前景保存为透明 PNG

页面能够显示透明效果以后,还要确保透明信息跟随图片一起保存。这里选择 PNG 格式,因为 PNG 可以保存像素的透明度。单纯把文件扩展名写成 .png 并不会改变图片内容,编码器也必须使用 image/png 格式生成文件数据。

像素图中的每个位置都有颜色和透明度信息。完全透明的位置在合成时显示后面的背景,完全不透明的位置显示前景颜色,介于两者之间的透明度则可以表现柔和的边缘。编码时保留这些信息,才能让图片放到深色或彩色背景上以后继续呈现原来的轮廓。

检查文件时,可以把保存的 PNG 重新载入,再放到与页面结果区不同的背景上。如果透明区域随着背景颜色变化,说明合成时能够透出后面的内容。只在白色背景上查看图片容易忽略白边,因为白色像素和透明位置看起来可能很接近。

ImageStore.png() 创建 ImagePacker,再调用 packToData() 将前景像素图编码为 PNG 数据。编码成功后,应用把数据写入自己的 filesDir 目录,并在详情区域显示文件路径。这里保存的是应用内部文件,用户如果需要在相册中找到图片,还要另外接入保存到媒体库的流程。

写入文件需要等待编码结果,也需要在结束后关闭文件和释放编码器。页面在调用 png() 期间继续持有前景像素图,编码结束后仍然可以显示这张图。等页面退出且任务完成,页面再释放输入与输出,避免编码器读取图片时资源提前失效。

这里使用固定文件名 subject-result.png。再次完成分割时,新结果会覆盖同名文件,适合页面只保留最近一次处理结果的用途。如果业务需要保存多次操作,就要生成不同文件名,并为用户提供查找和管理这些文件的入口。

主体区域也能用于后续布局,但不能直接当作裁剪后的图片尺寸。矩形只给出主体的大致范围,前景图还可能包含周围透明区域。如果应用裁去透明边缘,再把图片放回原来的位置,就需要记录裁剪前的偏移量,避免主体在合成时发生位移。

保留完整画布虽然会让文件包含更多透明位置,却能让主体继续使用原图的坐标。对于需要先分割、再在原位置叠加效果的页面,这种表示方式便于计算。制作尺寸紧凑的贴纸时,可以把裁剪和位置记录放在分割之后处理,由业务决定最终需要保留多大的画布。

五、让处理中的图片保持有效

分割调用和 PNG 保存都需要时间。处理过程中,页面暂时禁用选择图片和再次处理的按钮,等当前操作结束再恢复。这样前一次调用读取的输入不会被新选图片替换,同一页面也不会把两次处理的结果交叉显示。

如果用户在处理完成前离开页面,任务仍可能持有输入图或正在生成的前景图。页面先标记自己已经退出,任务返回后就不再更新界面。退出清理会等待页面上的处理与保存结束,再释放页面持有的像素图,正在执行的操作就有机会完成自己的资源清理。

任务层使用标识判断返回结果是否仍然有效。超过设定的等待时间或者页面退出时,任务会让旧结果失效。如果底层调用随后返回前景图,任务层会释放这张不再展示的图。这个处理控制的是页面接收结果的时机,底层接口是否能够立即停止仍由接口本身决定。

调用失败时,页面展示错误信息并恢复按钮。可以先核对输入图片是否成功解码、尺寸是否满足要求,再根据返回的错误码定位分割或保存阶段。把这几个阶段分开观察,能更快判断问题发生在图片读取、服务调用还是文件写入。

保存失败和分割失败也会给用户带来不同影响。前者可能已经得到可显示的前景,只是在写入文件时遇到问题。后者则没有取得可用的输出。排查时可以先观察结果区是否出现前景,再结合错误信息检查应用目录中的目标文件。

总结

我们从准备输入图片开始,使用主体分割取得合并后的前景图,再把它放到页面中检查,并编码为 PNG 保存。经过这几个步骤,原图中的主体就可以作为带透明背景的图片用于后续合成。

分割配置决定是否返回前景图和逐个主体详情,subjectRectangle 提供主体的大致范围,foregroundImage 提供实际像素。ImagePacker 负责把像素图编码成文件,而页面需要管理输入、输出和异步任务各自的使用时间。

大家可以换一张主体清晰的照片,观察返回的轮廓和保存后的 PNG,再尝试把它放在不同颜色的背景上。对比合成效果时,也可以留意细长边缘和对象内部的透明区域。

核心关键代码

下面整理的是可独立放入工程的主体分割页面。上面的截图来自包含功能目录的页面,下面的版本启动后直接进入主体分割,处理步骤与截图对应的功能一致。请在 entry/src/main/resources/rawfile 中准备一张名为 coffee.png 的图片,并在页面配置中注册 pages/Index。

在 API 26 工程中,检查 entry/src/main/resources/base/profile/main_pages.json 的 src 是否包含 pages/Index,并让 entry/src/main/ets/entryability/EntryAbility.ets 中的 loadContent() 加载这个页面。缺少 services 目录时先创建,再添加以下文件。

Core Vision Kit 的视觉处理需要在支持该能力的真机上运行,模拟器暂不支持本篇调用。服务区域为中国境内,不含港澳台。

分割服务:VisionAdapter.ets

将以下代码保存为 entry/src/main/ets/services/VisionAdapter.ets。服务负责请求主体前景图,并把输出交给页面。

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

export class VisionResult {
  text: string = '';
  picture: image.PixelMap | undefined = undefined;
}

export class VisionAdapter {
  async process(kind: string, input: image.PixelMap): Promise<VisionResult> {
    const result = new VisionResult();
    if (kind === '分割') {
      try {
        if (!await subjectSegmentation.init()) { throw new Error('主体分割初始化失败'); }
        // 请求整体前景图,主体数量由服务返回。
        const data = await subjectSegmentation.doSegmentation({ pixelMap: input }, { maxCount: 6, enableSubjectDetails: false, enableSubjectForegroundImage: true });
        result.picture = data.fullSubject.foregroundImage;
        result.text = '主体数量:' + data.subjectCount + '\n区域:' + JSON.stringify(data.fullSubject.subjectRectangle);
        return result;
      } finally { await subjectSegmentation.release(); }
    }
    throw new Error('未支持的处理功能:' + kind);
  }
}

图片读取和编码:ImageStore.ets

将以下代码保存为 entry/src/main/ets/services/ImageStore.ets。图片解码和 PNG 写入由这个文件负责。

import { image } from '@kit.ImageKit';
import { common } from '@kit.AbilityKit';
import { fileIo } from '@kit.CoreFileKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';

export class ImageStore {
  private context: common.UIAbilityContext;
  constructor(context: common.UIAbilityContext) { this.context = context; }

  async raw(name: string): Promise<image.PixelMap> {
    const bytes = await this.context.resourceManager.getRawFileContent(name);
    const source = image.createImageSource(bytes.buffer as ArrayBuffer);
    try {
      // 图像解码后交给视觉接口,图像源不再由页面持有。
      return await source.createPixelMap({ desiredPixelFormat: image.PixelMapFormat.RGBA_8888 });
    } finally { source.release(); }
  }

  async choose(): Promise<image.PixelMap | undefined> {
    const picker = new photoAccessHelper.PhotoViewPicker();
    const result = await picker.select({ MIMEType: photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE, maxSelectNumber: 1 });
    if (result.photoUris.length === 0) { return undefined; }
    const file = fileIo.openSync(result.photoUris[0], fileIo.OpenMode.READ_ONLY);
    let source: image.ImageSource | undefined = undefined;
    try {
      source = image.createImageSource(file.fd);
      return await source.createPixelMap({ desiredPixelFormat: image.PixelMapFormat.RGBA_8888 });
    } finally { source?.release(); fileIo.closeSync(file); }
  }

  async png(pm: image.PixelMap, name: string): Promise<string> {
    const packer = image.createImagePacker();
    try {
      // 等待编码完成,再将 PNG 数据写入应用文件目录。
      const bytes = await packer.packToData(pm, { format: 'image/png', quality: 100 });
      const path = this.context.filesDir + '/' + name;
      const file = fileIo.openSync(path, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
      try { fileIo.writeSync(file.fd, bytes); } finally { fileIo.closeSync(file); }
      return path;
    } finally { packer.release(); }
  }
}

任务管理:VisionTask.ets

将以下代码保存为 entry/src/main/ets/services/VisionTask.ets。任务层管理处理中状态,并在结果失效时释放输出。

import { image } from '@kit.ImageKit';
import { VisionAdapter, VisionResult } from './VisionAdapter';

export class VisionTask {
  busy: boolean = false;
  state: string = '待处理';
  private generation: number = 0;
  private pending: Promise<VisionResult> | undefined = undefined;

  cancel(): void {
    if (!this.busy) { return; }
    // 更换标识,使还在执行的任务返回后不再提交旧结果。
    this.generation++;
    this.state = '正在等待底层调用结束,结果将被丢弃';
  }

  async run(kind: string, input: image.PixelMap): Promise<VisionResult | undefined> {
    if (this.busy) { throw new Error('上一次处理仍未结束'); }
    this.busy = true;
    this.state = '处理中';
    const token = ++this.generation;
    // 超时只使结果失效,仍要等待底层调用结束。
    const timer = setTimeout(() => { if (this.busy && token === this.generation) { this.cancel(); } }, 30000);
    try {
      this.pending = new VisionAdapter().process(kind, input);
      const result = await this.pending;
      if (token !== this.generation) {
        result.picture?.release();
        this.state = '已取消,可重新处理';
        return undefined;
      }
      this.state = '处理完成';
      return result;
    } catch (err) {
      this.state = token === this.generation ? '处理失败,可检查输入后重试' : '已取消,可重新处理';
      if (token === this.generation) { throw err as Error; }
      return undefined;
    } finally {
      clearTimeout(timer);
      this.pending = undefined;
      this.busy = false;
    }
  }
}

页面入口:Index.ets

将以下代码保存为 entry/src/main/ets/pages/Index.ets。页面负责输入、结果展示、保存和退出清理。

import { image } from '@kit.ImageKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { ImageStore } from '../services/ImageStore';
import { VisionTask } from '../services/VisionTask';

@Entry
@Component
struct Index {
  @State input: image.PixelMap | undefined = undefined;
  @State output: image.PixelMap | undefined = undefined;
  @State status: string = '准备输入图片';
  @State detail: string = '';
  @State errorMessage: string = '';
  @State busy: boolean = false;
  @State imageWidth: number = 0;
  @State imageHeight: number = 0;
  private alive: boolean = true;
  private activeJob: boolean = false;
  private pagePending: Promise<void> | undefined = undefined;
  private task: VisionTask = new VisionTask();
  private context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
  private io: ImageStore = new ImageStore(this.context);

  aboutToAppear(): void { this.alive = true; this.launch(() => this.prepare()); }
  aboutToDisappear(): void { this.alive = false; this.dispose(); }

  private launch(action: () => Promise<void>): void {
    if (this.activeJob || !this.alive) { return; }
    this.activeJob = true;
    // 输入、处理和保存都纳入页面任务,退出时等待它结束。
    this.pagePending = action().catch((err: BusinessError) => { if (this.alive) { this.fail(err); } })
      .finally(() => { this.activeJob = false; });
  }

  private async dispose(): Promise<void> {
    this.task.cancel();
    try { await this.pagePending; } catch (_) { /* 错误由任务入口处理。 */ }
    this.input?.release(); this.input = undefined;
    this.output?.release(); this.output = undefined;
  }

  private fail(err: BusinessError): void {
    if (!this.alive) { return; }
    this.status = '处理失败,可检查输入后重试';
    this.errorMessage = '错误码:' + (err.code ?? -1) + '\n' + err.message;
  }

  private async prepare(): Promise<void> {
    if (this.busy || this.task.busy) { return; }
    this.busy = true; this.detail = ''; this.errorMessage = '';
    try {
      const pm = await this.io.raw('coffee.png');
      if (!this.alive) { pm.release(); return; }
      this.input?.release(); this.input = pm;
      const info = await pm.getImageInfo(); this.imageWidth = info.size.width; this.imageHeight = info.size.height;
      this.status = '输入已就绪:' + this.imageWidth + ' × ' + this.imageHeight;
    } catch (err) { this.fail(err as BusinessError); }
    finally { if (this.alive) { this.busy = false; } }
  }

  private async choose(): Promise<void> {
    if (this.busy) { return; }
    this.busy = true; this.errorMessage = '';
    try {
      const pm = await this.io.choose();
      if (!this.alive) { pm?.release(); return; }
      if (!pm) { this.status = '未选择图片,保留原输入'; return; }
      // 新输入解码成功后,清除上一张图片的输出。
      this.input?.release(); this.input = pm; this.detail = '';
      this.output?.release(); this.output = undefined;
      const info = await pm.getImageInfo(); this.imageWidth = info.size.width; this.imageHeight = info.size.height;
      this.status = '已选择:' + this.imageWidth + ' × ' + this.imageHeight;
    } catch (err) { this.fail(err as BusinessError); }
    finally { if (this.alive) { this.busy = false; } }
  }

  private async run(): Promise<void> {
    if (this.busy || !this.input) { return; }
    this.busy = true; this.status = '处理中'; this.detail = ''; this.errorMessage = '';
    this.output?.release(); this.output = undefined;
    try {
      const result = await this.task.run('分割', this.input);
      if (!this.alive) { result?.picture?.release(); return; }
      if (result) {
        this.output = result.picture; this.detail = result.text;
        if (this.output) {
          const path = await this.io.png(this.output, 'subject-result.png');
          if (!this.alive) { return; }
          this.detail += '\n保存到:' + path;
        }
      }
      if (this.alive) { this.status = this.task.state; }
    } catch (err) { if (this.alive) { this.fail(err as BusinessError); } }
    finally { if (this.alive) { this.busy = false; } }
  }

  build() {
    Column({ space: 12 }) {
      Text('主体分割与透明 PNG').fontSize(23).fontWeight(FontWeight.Bold).fontColor('#142B52').width('100%')
      Row({ space: 8 }) {
        Image(this.input).width('48%').height(230).objectFit(ImageFit.Contain)
        if (this.output) {
          Image(this.output).width('48%').height(230).objectFit(ImageFit.Contain).backgroundColor('#DFE8F0')
        }
      }
      Row({ space: 8 }) {
        Button('选择图片').enabled(!this.busy).onClick(() => { this.launch(() => this.choose()); })
        Button('开始处理').enabled(!this.busy).onClick(() => { this.launch(() => this.run()); })
      }
      Text(this.status).fontSize(15).fontColor('#145897').width('100%')
      if (this.errorMessage) { Text(this.errorMessage).fontSize(14).width('100%') }
      Scroll() { Text(this.detail).fontSize(14).width('100%') }.layoutWeight(1)
    }.width('100%').height('100%').padding({ top: 16, left: 18, right: 18, bottom: 16 }).backgroundColor('#F1F4F9')
  }
}
Logo

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

更多推荐