前言

换发型这个需求,和贴头饰看似相似,但有个本质区别:头饰是"贴在头顶就完事",发型需要"大面积覆盖头部、精确对齐发际线、跟随人脸平滑移动"。如果你直接把人脸框坐标丢给 .position(),发型图会跟着人脸框一起抖——每帧的 MetadataOutput 坐标都有几像素的跳动,结果就是发型图像贴在一张抖动的纸上。

这篇文章围绕一个完整的换发型案例,重点解决三个问题:怎么让人脸坐标不抖、怎么让发型图覆盖正确的位置、怎么给用户提供手动微调能力。每个环节的代码我都会说明"为什么这样写",文末有完整源码,可以直接取用。

效果预览

主要流程

整个流程可以压缩成 4 步:

  1. XComponent + Camera Kit 初始化相机预览,MetadataOutput 输出人脸框坐标。
  2. 对人脸框坐标做指数平滑(lerp),消除帧间抖动,让发型图稳稳跟住人脸。
  3. 根据平滑后的人脸框坐标,按 hairScaleW / hairScaleH / hairOffsetX / hairOffsetY 计算发型图位置和尺寸。
  4. 发型图用外层 Stack + .position() 叠在预览画面上方,.hitTestBehavior(Transparent) 保证不拦截触摸事件。

后面所有代码,都是围绕这条链路展开的。

开始之前:权限和发型资源

权限

和前几篇一样,相机权限在 module.json5 声明:

{
  "name": "ohos.permission.CAMERA",
  "reason": "$string:camera_reason",
  "usedScene": {
    "abilities": ["EntryAbility"],
    "when": "inuse"
  }
}

发型资源

案例用 4 张发型图放在 resources/rawfile/resources/base/media/ 目录下,通过 $r('app.media.xx') 引用:

A hand-drawn doodle illustration on pure white pap

class HairConfig {
  items: HairItem[] = [
    { name: '发型一', resource: $r('app.media.01') },
    { name: '发型二', resource: $r('app.media.02') },
    { name: '发型三', resource: $r('app.media.03') },
    { name: '发型四', resource: $r('app.media.04') },
  ];
}

发型图需要是透明背景的 PNG——不透明的部分会遮住预览画面,看起来像一块色块盖在头上。背景透明后,只有发丝部分可见,和预览画面自然融合。

指数平滑——为什么人脸框坐标会抖

MetadataOutput 每帧返回的人脸框坐标不是稳定的。在 30fps 下,连续两帧的 topLeftX 可能从 120 跳到 124 再跳回 118。肉眼看人脸框本身不明显(2~4 像素的偏移很难察觉),但发型图覆盖面积大,同样的偏移在发型图边缘就非常明显——头发像在微微晃动。

lerp 平滑函数

const SMOOTH_FACTOR: number = 0.35;

private lerp(oldVal: number, newVal: number): number {
  if (this.firstFrame) {
    return newVal;
  }
  return oldVal * (1 - SMOOTH_FACTOR) + newVal * SMOOTH_FACTOR;
}

这就是一个线性插值(low-pass filter):当前帧的新值占 35%,历史平滑值占 65%。高频抖动被滤掉,低频的人脸移动被保留。

firstFrame 的处理很关键——第一帧时没有历史值,必须直接用原始值,否则发型图会从 (0,0) 缓慢移到目标位置,出现"从左上角飘过来"的视觉效果。

在 metadataObjectsAvailable 回调里应用平滑

private onMetadataObjectsAvailable(metadataOutput: camera.MetadataOutput): void {
  metadataOutput.on('metadataObjectsAvailable', (_err: BusinessError,
    metadataObjectArr: Array<camera.MetadataObject>) => {
    if (metadataObjectArr.length > 0) {
      let box: camera.Rect = metadataObjectArr[0].boundingBox;
      let rawFaceX: number = box.topLeftX * this.previewAreaWidth;
      let rawFaceY: number = box.topLeftY * this.previewAreaHeight;
      let rawFaceW: number = box.width * this.previewAreaWidth;
      let rawFaceH: number = box.height * this.previewAreaHeight;

      this.smoothFaceX = this.lerp(this.smoothFaceX, rawFaceX);
      this.smoothFaceY = this.lerp(this.smoothFaceY, rawFaceY);
      this.smoothFaceW = this.lerp(this.smoothFaceW, rawFaceW);
      this.smoothFaceH = this.lerp(this.smoothFaceH, rawFaceH);

      this.faceX = this.smoothFaceX;
      this.faceY = this.smoothFaceY;
      this.faceW = this.smoothFaceW;
      this.faceH = this.smoothFaceH;

      if (this.firstFrame) {
        this.firstFrame = false;
      }
      this.hasFace = true;
    } else {
      this.hasFace = false;
      this.firstFrame = true;
    }
  });
}

四个坐标(X、Y、W、H)各自独立平滑。平滑后的值赋给 @State 变量 faceX / faceY / faceW / faceH,驱动 UI 刷新。

为什么不用 @State 直接做平滑? smoothFaceX 等是 private 成员,不触发 UI 刷新,只在回调内部累积。平滑完成后一次性赋值给 @State,每帧只触发一次刷新。如果用 @State 做中间计算,lerp 里的 oldValnewVal 赋值各触发一次刷新,每帧就变成两次刷新,浪费性能。

firstFrame 的重置时机

private async prepareCamera(): Promise<void> {
  this.firstFrame = true;
  // ...
}

private async releaseCamera(): Promise<void> {
  // ...
  this.firstFrame = true;
}

相机启动时和释放时都重置 firstFrame = true。启动时重置确保第一帧不会从旧平滑值过渡;丢失人脸时(metadataObjectArr.length === 0)也重置,因为人脸重新出现时的第一帧坐标可能和上次差异很大,用旧平滑值会导致发型图"缓动"到新位置。

发型图定位——覆盖面积远大于人脸框

发型图不是贴在人脸框上方的小头饰,而是需要大面积覆盖整个头部。这就带来一个定位问题:人脸框只包含面部区域(额头到下巴),发型需要从头顶一直延伸到耳朵两侧。

定位参数设计

@State hairOffsetX: number = 0;
@State hairOffsetY: number = -10;
@State hairScaleW: number = 1.0;
@State hairScaleH: number = 1.2;
参数默认值含义
hairScaleW1.0发型宽度 = faceW × hairScaleW
hairScaleH1.2发型高度 = faceW × hairScaleH(注意:用的是 faceW 不是 faceH)
hairOffsetX0发型水平偏移(相对人脸框中心,像素)
hairOffsetY-10发型垂直偏移(相对人脸框顶部,像素,负值上移)

为什么 hairScaleH 乘的是 faceW 而不是 faceH 这是关键设计。人脸框的高度包含了下巴和脖子区域,但发型的纵向范围应该和脸部宽度挂钩——头部的宽高比相对稳定,用宽度做基准更合理。如果用 faceH,当用户仰头或低头时 faceH 会剧烈变化,发型高度也跟着抖;用 faceW 则稳定得多,因为正面朝向摄像头时 faceW 基本不变。

位置计算

private getHairW(): number {
  return this.faceW * this.hairScaleW;
}

private getHairH(): number {
  return this.faceW * this.hairScaleH;
}

private getHairX(): number {
  return this.faceX + this.hairOffsetX + (this.faceW - this.getHairW()) / 2;
}

private getHairY(): number {
  return this.faceY + this.hairOffsetY;
}

X 坐标:先水平居中(faceX + (faceW - hairW) / 2),再加用户手动偏移 hairOffsetX。当 hairScaleW = 1.0 时发型宽度和人脸框一样;hairScaleW = 1.5 时发型比人脸宽 50%,居中后左右各溢出 25%。

Y 坐标:人脸框顶部 + hairOffsetY。默认 hairOffsetY = -10,发型图顶部比人脸框顶部上移 10 像素。发型需要覆盖额头以上的区域,所以 Y 坐标必须比人脸框顶部更高(更小)。

和人脸贴纸定位的对比

人脸贴纸(前一篇的 facePaster)用的是 overlapRatio 控制嵌入深度,定位逻辑是"从人脸框顶部往上移,再往下嵌入"。换发型的定位更直接——直接用 hairOffsetY 做像素级偏移,因为发型的覆盖范围不是简单的"贴在头顶",而是从头顶延伸到两侧,需要更精细的控制。

手动微调——不同脸型需要不同参数

自动定位再准,也不可能适配所有人的脸型。案例提供了手动调整面板,用户可以微调发型的位置和大小。

调整面板

@Builder
adjustPanelBuilder() {
  Column() {
    Row() {
      Text('↔ 位置:')
        .fontSize(13)
        .fontColor('#AAAAAA')
        .margin({ right: 8 })
      Button() { Text('⬅').fontSize(14).fontColor('#FFFFFF') }
        .width(36).height(30)
        .backgroundColor('#3A3A5A')
        .borderRadius(6)
        .onClick(() => { this.hairOffsetX -= 5; })
      Button() { Text('➡').fontSize(14).fontColor('#FFFFFF') }
        .width(36).height(30)
        .backgroundColor('#3A3A5A')
        .borderRadius(6)
        .margin({ left: 4 })
        .onClick(() => { this.hairOffsetX += 5; })
      Button() { Text('⬆').fontSize(14).fontColor('#FFFFFF') }
        .width(36).height(30)
        .backgroundColor('#3A3A5A')
        .borderRadius(6)
        .margin({ left: 8 })
        .onClick(() => { this.hairOffsetY -= 5; })
      Button() { Text('⬇').fontSize(14).fontColor('#FFFFFF') }
        .width(36).height(30)
        .backgroundColor('#3A3A5A')
        .borderRadius(6)
        .margin({ left: 4 })
        .onClick(() => { this.hairOffsetY += 5; })
    }

    Row() {
      Text('📐 大小:')
        .fontSize(13)
        .fontColor('#AAAAAA')
        .margin({ right: 8 })
      Text('宽')
        .fontSize(12)
        .fontColor('#CCCCCC')
      Button() { Text('➖').fontSize(12).fontColor('#FFFFFF') }
        .width(30).height(28)
        .backgroundColor('#3A3A5A')
        .borderRadius(6)
        .margin({ left: 4, right: 4 })
        .onClick(() => { this.hairScaleW = Math.max(0.5, this.hairScaleW - 0.1); })
      Button() { Text('➕').fontSize(12).fontColor('#FFFFFF') }
        .width(30).height(28)
        .backgroundColor('#3A3A5A')
        .borderRadius(6)
        .margin({ right: 8 })
        .onClick(() => { this.hairScaleW = Math.min(3.0, this.hairScaleW + 0.1); })
      Text('高')
        .fontSize(12)
        .fontColor('#CCCCCC')
      Button() { Text('➖').fontSize(12).fontColor('#FFFFFF') }
        .width(30).height(28)
        .backgroundColor('#3A3A5A')
        .borderRadius(6)
        .margin({ left: 4, right: 4 })
        .onClick(() => { this.hairScaleH = Math.max(0.5, this.hairScaleH - 0.1); })
      Button() { Text('➕').fontSize(12).fontColor('#FFFFFF') }
        .width(30).height(28)
        .backgroundColor('#3A3A5A')
        .borderRadius(6)
        .onClick(() => { this.hairScaleH = Math.min(3.0, this.hairScaleH + 0.1); })
    }

    Row() {
      Text(`${this.hairScaleW.toFixed(1)}x${this.hairScaleH.toFixed(1)}  偏移(${this.hairOffsetX},${this.hairOffsetY})`)
        .fontSize(11)
        .fontColor('#888888')
    }
  }
}

调整面板提供两组操作:

  1. 位置调整:四个方向按钮,每次 ±5 像素。因为平滑后人脸框坐标是稳定的,5 像素的调整步长肉眼可见但不至于跳太多。
  2. 大小调整:宽高各自加减,步长 0.1,范围限制在 0.5~3.0。Math.max(0.5, ...)Math.min(3.0, ...) 防止缩到太小或放到太大。

底部显示当前参数值,方便用户确认调整效果。

调整 → 锁定的流程

if (this.isAdjusting) {
  Button('锁定位置')
    .backgroundColor('#4CAF50')
    .onClick(() => {
      this.isAdjusting = false;
      this.statusText = '发型已锁定,跟随人脸移动';
    })
  Button('重置')
    .backgroundColor('#666666')
    .onClick(() => {
      this.hairOffsetX = 0;
      this.hairOffsetY = -10;
      this.hairScaleW = 1.0;
      this.hairScaleH = 1.2;
    })
}

点击"调整"按钮进入调整模式,面板出现。调整满意后点"锁定位置",面板消失,发型图按调整后的参数继续跟随人脸。点"重置"恢复默认参数。

这个流程的设计思路是:调整参数时,用户需要看到实时效果——每次点击方向按钮,发型图立即移动 5 像素,可以精确对齐。锁定后参数不变,发型图只跟随人脸移动,不会因为误触而偏移。

发型图的叠放——为什么用外层 Stack 而不是预览区 Stack

这是案例中一个不太明显但很关键的设计决策。

错误方案:发型图放在预览区 Stack 里

// ❌ 预览区 Stack 里叠放发型图
Stack() {
  XComponent({...})
  Image(hairResource)
    .position({ x: this.getHairX(), y: this.getHairY() })
    .width(this.getHairW())
    .height(this.getHairH())
}
.clip(true)  // 这里会把超出预览区的发型图裁掉

问题在于预览区下方有控制栏,预览区高度不是全屏。发型图的上半部分(头顶以上)可能超出预览区 Stack 的边界,被 .clip(true) 裁掉。结果就是:发型的顶部被切平了。

正确方案:发型图放在外层全屏 Stack 里

build() {
  Stack({ alignContent: Alignment.TopStart }) {
    Column() {
      Stack() {
        XComponent({...})
        // 人脸框在这里
      }
      .layoutWeight(1)
      // 控制栏
      Column() { ... }
    }
    .width('100%')
    .height('100%')

    // 发型图在外层 Stack,不受预览区 clip 限制
    if (this.hasFace && this.isDetecting) {
      Image(this.hairConfig.items[this.selectedIndex].resource)
        .objectFit(ImageFit.Cover)
        .position({ x: this.getHairX(), y: this.getHairY() })
        .width(this.getHairW())
        .height(this.getHairH())
        .hitTestBehavior(HitTestMode.Transparent)
    }
  }
  .width('100%')
  .height('100%')
}

外层 Stack 是全屏的,发型图的 .position() 坐标基于全屏坐标系,不受预览区高度限制。这样发型的顶部可以自然延伸到屏幕边缘,不会被裁剪。

hitTestBehavior(Transparent) 是必须的

.hitTestBehavior(HitTestMode.Transparent)

发型图叠在预览画面上方,如果不设 Transparent,发型图会拦截触摸事件——用户点击"拍照"或"调整"按钮时,如果按钮上方恰好有发型图,触摸事件会被发型图吃掉,按钮无法响应。

HitTestMode.Transparent 让发型图不参与触摸事件分发,触摸穿透到下层的按钮上。头饰案例不需要这个处理(因为头饰很小,不太可能挡住按钮),但发型图的覆盖面积大,必须做穿透处理。

发型选择栏

@Builder
hairBarBuilder() {
  Scroll() {
    Row() {
      ForEach(this.hairConfig.items, (item: HairItem, index: number) => {
        Column() {
          Image(item.resource)
            .width(44)
            .height(44)
            .objectFit(ImageFit.Cover)
            .borderRadius(6)
          Text(item.name)
            .fontSize(10)
            .fontColor('#CCCCCC')
            .margin({ top: 2 })
        }
        .width(56)
        .height(64)
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
        .borderRadius(10)
        .backgroundColor(this.selectedIndex === index ? '#4A90D9' : '#2A2A4A')
        .margin({ left: 4, right: 4 })
        .onClick(() => {
          this.selectedIndex = index;
        })
      }, (_item: HairItem, index: number) => `${index}`)
    }
  }
  .scrollable(ScrollDirection.Horizontal)
  .scrollBar(BarState.Off)
  .width('100%')
  .height(72)
}

和头饰选择栏类似,水平滚动,点击切换 selectedIndex。区别是这里用 Image 而不是 Text(emoji) 展示预览图——发型的视觉效果比 emoji 复杂得多,必须用实际图片才能让用户预判效果。

完整源码

import { camera } from '@kit.CameraKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { display } from '@kit.ArkUI';
import { abilityAccessCtrl, bundleManager } from '@kit.AbilityKit';

const DOMAIN = 0x0000;
const TAG = 'HairReplace';
const SMOOTH_FACTOR: number = 0.35;

interface HairItem {
  name: string;
  resource: Resource;
}

class HairConfig {
  items: HairItem[] = [
    { name: '发型一', resource: $r('app.media.01') },
    { name: '发型二', resource: $r('app.media.02') },
    { name: '发型三', resource: $r('app.media.03') },
    { name: '发型四', resource: $r('app.media.04') },
  ];
}

@Entry
@Component
struct HairReplace {
  @State faceX: number = 0;
  @State faceY: number = 0;
  @State faceW: number = 0;
  @State faceH: number = 0;
  @State hasFace: boolean = false;
  @State selectedIndex: number = 0;
  @State statusText: string = '点击开始检测人脸';
  @State isDetecting: boolean = false;
  @State surfaceReady: boolean = false;
  @State isAdjusting: boolean = false;
  @State hairOffsetX: number = 0;
  @State hairOffsetY: number = -10;
  @State hairScaleW: number = 1.0;
  @State hairScaleH: number = 1.2;

  private hairConfig: HairConfig = new HairConfig();
  private mXComponentController: XComponentController = new XComponentController();
  private cameraManager: camera.CameraManager | undefined = undefined;
  private cameraSession: camera.PhotoSession | undefined = undefined;
  private cameraInput: camera.CameraInput | undefined = undefined;
  private previewOutput: camera.PreviewOutput | undefined = undefined;
  private photoOutput: camera.PhotoOutput | undefined = undefined;
  private metadataOutput: camera.MetadataOutput | undefined = undefined;
  private xComponentSurfaceId: string = '';
  private previewAreaWidth: number = 0;
  private previewAreaHeight: number = 0;
  private smoothFaceX: number = 0;
  private smoothFaceY: number = 0;
  private smoothFaceW: number = 0;
  private smoothFaceH: number = 0;
  private firstFrame: boolean = true;
  private dragStartX: number = 0;
  private dragStartY: number = 0;
  private dragStartOffsetX: number = 0;
  private dragStartOffsetY: number = 0;

  aboutToDisappear(): void {
    this.releaseCamera();
  }

  private checkPermission(): boolean {
    let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
    let bundleInfo: bundleManager.BundleInfo =
      bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION);
    let tokenID: number = bundleInfo.appInfo.accessTokenId;
    let grantStatus: number = atManager.checkAccessTokenSync(tokenID, 'ohos.permission.CAMERA');
    return grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;
  }

  private async requestPermission(): Promise<boolean> {
    if (this.checkPermission()) {
      return true;
    }
    let context: Context = this.getUIContext().getHostContext() as Context;
    let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
    try {
      let result = await atManager.requestPermissionsFromUser(context, ['ohos.permission.CAMERA']);
      return result.authResults[0] === 0;
    } catch (error) {
      return false;
    }
  }

  private lerp(oldVal: number, newVal: number): number {
    if (this.firstFrame) {
      return newVal;
    }
    return oldVal * (1 - SMOOTH_FACTOR) + newVal * SMOOTH_FACTOR;
  }

  private async prepareCamera(): Promise<void> {
    this.statusText = '正在初始化相机...';
    this.firstFrame = true;

    let hasPermission: boolean = await this.requestPermission();
    if (!hasPermission) {
      this.statusText = '相机权限未授予';
      return;
    }

    if (!this.surfaceReady || this.xComponentSurfaceId === '') {
      this.statusText = '预览组件未就绪';
      return;
    }

    let context: Context = this.getUIContext().getHostContext() as Context;
    let mgr: camera.CameraManager | undefined = undefined;
    try {
      mgr = camera.getCameraManager(context);
    } catch (error) {
      this.statusText = '相机管理器创建失败';
      return;
    }
    if (!mgr) {
      this.statusText = '相机管理器创建失败';
      return;
    }
    this.cameraManager = mgr;

    let cameraDevices: Array<camera.CameraDevice> = [];
    try {
      cameraDevices = mgr.getSupportedCameras();
    } catch (error) {
      this.statusText = '获取相机设备失败';
      return;
    }
    if (cameraDevices.length === 0) {
      this.statusText = '未找到可用相机';
      return;
    }

    let frontDevice: camera.CameraDevice = cameraDevices[0];
    for (let i: number = 0; i < cameraDevices.length; i++) {
      if (cameraDevices[i].cameraPosition === camera.CameraPosition.CAMERA_POSITION_FRONT) {
        frontDevice = cameraDevices[i];
        break;
      }
    }

    let cInput: camera.CameraInput | undefined = undefined;
    try {
      cInput = mgr.createCameraInput(frontDevice);
    } catch (error) {
      this.statusText = '创建相机输入失败';
      return;
    }
    this.cameraInput = cInput;

    try {
      await cInput.open();
    } catch (error) {
      this.statusText = '打开相机失败';
      return;
    }

    let cameraOutputCapability: camera.CameraOutputCapability =
      mgr.getSupportedOutputCapability(frontDevice, camera.SceneMode.NORMAL_PHOTO);
    if (!cameraOutputCapability) {
      this.statusText = '获取相机能力失败';
      return;
    }

    let metadataObjectTypes: Array<camera.MetadataObjectType> = cameraOutputCapability.supportedMetadataObjectTypes;
    let mOutput: camera.MetadataOutput | undefined = undefined;
    try {
      mOutput = mgr.createMetadataOutput(metadataObjectTypes);
    } catch (error) {
      this.statusText = '创建元数据输出失败';
      return;
    }
    this.metadataOutput = mOutput;
    if (mOutput) {
      this.onMetadataObjectsAvailable(mOutput);
      this.onMetadataError(mOutput);
    }

    if (cameraOutputCapability.previewProfiles.length === 0) {
      this.statusText = '无可用预览配置';
      return;
    }
    let previewProfile: camera.Profile = cameraOutputCapability.previewProfiles[0];
    for (let i: number = 0; i < cameraOutputCapability.previewProfiles.length; i++) {
      let p: camera.Profile = cameraOutputCapability.previewProfiles[i];
      if (p.size.width <= 1920 && p.size.width >= 640) {
        previewProfile = p;
        break;
      }
    }

    let pOutput: camera.PreviewOutput | undefined = undefined;
    try {
      pOutput = mgr.createPreviewOutput(previewProfile, this.xComponentSurfaceId);
    } catch (error) {
      let err: BusinessError = error as BusinessError;
      hilog.error(DOMAIN, TAG, `createPreviewOutput error: ${err.code}`);
      this.statusText = '创建预览输出失败';
      return;
    }
    this.previewOutput = pOutput;

    if (cameraOutputCapability.photoProfiles.length > 0) {
      let photoProfile: camera.Profile = cameraOutputCapability.photoProfiles[0];
      try {
        this.photoOutput = mgr.createPhotoOutput(photoProfile);
      } catch (error) {
        hilog.error(DOMAIN, TAG, 'createPhotoOutput error');
      }
    }

    let session: camera.PhotoSession | undefined = undefined;
    try {
      session = mgr.createSession<camera.PhotoSession>(camera.SceneMode.NORMAL_PHOTO);
    } catch (error) {
      this.statusText = '创建相机会话失败';
      return;
    }
    this.cameraSession = session;

    try {
      session.beginConfig();
      session.addInput(cInput);
      if (pOutput) {
        session.addOutput(pOutput);
      }
      if (mOutput) {
        session.addOutput(mOutput);
      }
      if (this.photoOutput) {
        session.addOutput(this.photoOutput);
      }
      await session.commitConfig();
      await session.start();

      if (mOutput) {
        mOutput.start().then(() => {
          hilog.info(DOMAIN, TAG, 'metadataOutput started');
        }).catch((err: BusinessError) => {
          hilog.error(DOMAIN, TAG, `metadataOutput start error: ${err.code}`);
        });
      }

      this.isDetecting = true;
      this.statusText = '正在检测人脸...';
    } catch (error) {
      let err: BusinessError = error as BusinessError;
      hilog.error(DOMAIN, TAG, `session error: ${err.code} ${err.message}`);
      this.statusText = `相机启动失败: ${err.code}`;
    }
  }

  private async releaseCamera(): Promise<void> {
    try {
      if (this.metadataOutput) {
        this.metadataOutput.release();
        this.metadataOutput = undefined;
      }
      if (this.cameraSession) {
        this.cameraSession.stop();
        this.cameraSession.release();
        this.cameraSession = undefined;
      }
      if (this.previewOutput) {
        this.previewOutput.release();
        this.previewOutput = undefined;
      }
      if (this.photoOutput) {
        this.photoOutput.release();
        this.photoOutput = undefined;
      }
      if (this.cameraInput) {
        this.cameraInput.close();
        this.cameraInput = undefined;
      }
    } catch (error) {
      let err: BusinessError = error as BusinessError;
      hilog.error(DOMAIN, TAG, `releaseCamera error: ${err.code}`);
    }
    this.isDetecting = false;
    this.hasFace = false;
    this.firstFrame = true;
  }

  private onMetadataObjectsAvailable(metadataOutput: camera.MetadataOutput): void {
    metadataOutput.on('metadataObjectsAvailable', (_err: BusinessError,
      metadataObjectArr: Array<camera.MetadataObject>) => {
      if (metadataObjectArr.length > 0) {
        let box: camera.Rect = metadataObjectArr[0].boundingBox;
        let rawFaceX: number = box.topLeftX * this.previewAreaWidth;
        let rawFaceY: number = box.topLeftY * this.previewAreaHeight;
        let rawFaceW: number = box.width * this.previewAreaWidth;
        let rawFaceH: number = box.height * this.previewAreaHeight;

        this.smoothFaceX = this.lerp(this.smoothFaceX, rawFaceX);
        this.smoothFaceY = this.lerp(this.smoothFaceY, rawFaceY);
        this.smoothFaceW = this.lerp(this.smoothFaceW, rawFaceW);
        this.smoothFaceH = this.lerp(this.smoothFaceH, rawFaceH);

        this.faceX = this.smoothFaceX;
        this.faceY = this.smoothFaceY;
        this.faceW = this.smoothFaceW;
        this.faceH = this.smoothFaceH;

        if (this.firstFrame) {
          this.firstFrame = false;
        }
        this.hasFace = true;
        this.statusText = this.isAdjusting ? '调整发型位置和大小,完成后点击锁定' : '已检测到人脸,选择发型替换';
      } else {
        this.hasFace = false;
        this.firstFrame = true;
        if (this.isDetecting) {
          this.statusText = '未检测到人脸,请正对摄像头';
        }
      }
    });
  }

  private onMetadataError(metadataOutput: camera.MetadataOutput): void {
    metadataOutput.on('error', (err: BusinessError) => {
      hilog.error(DOMAIN, TAG, `Metadata error: ${err.code}`);
    });
  }

  private getHairW(): number {
    return this.faceW * this.hairScaleW;
  }

  private getHairH(): number {
    return this.faceW * this.hairScaleH;
  }

  private getHairX(): number {
    return this.faceX + this.hairOffsetX + (this.faceW - this.getHairW()) / 2;
  }

  private getHairY(): number {
    return this.faceY + this.hairOffsetY;
  }

  @Builder
  hairBarBuilder() {
    Scroll() {
      Row() {
        ForEach(this.hairConfig.items, (item: HairItem, index: number) => {
          Column() {
            Image(item.resource)
              .width(44)
              .height(44)
              .objectFit(ImageFit.Cover)
              .borderRadius(6)
            Text(item.name)
              .fontSize(10)
              .fontColor('#CCCCCC')
              .margin({ top: 2 })
          }
          .width(56)
          .height(64)
          .justifyContent(FlexAlign.Center)
          .alignItems(HorizontalAlign.Center)
          .borderRadius(10)
          .backgroundColor(this.selectedIndex === index ? '#4A90D9' : '#2A2A4A')
          .margin({ left: 4, right: 4 })
          .onClick(() => {
            this.selectedIndex = index;
          })
        }, (_item: HairItem, index: number) => `${index}`)
      }
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
    .height(72)
  }

  @Builder
  adjustPanelBuilder() {
    Column() {
      Row() {
        Text('↔ 位置:')
          .fontSize(13)
          .fontColor('#AAAAAA')
          .margin({ right: 8 })
        Button() { Text('⬅').fontSize(14).fontColor('#FFFFFF') }
          .width(36)
          .height(30)
          .backgroundColor('#3A3A5A')
          .borderRadius(6)
          .onClick(() => { this.hairOffsetX -= 5; })
        Button() { Text('➡').fontSize(14).fontColor('#FFFFFF') }
          .width(36)
          .height(30)
          .backgroundColor('#3A3A5A')
          .borderRadius(6)
          .margin({ left: 4 })
          .onClick(() => { this.hairOffsetX += 5; })
        Button() { Text('⬆').fontSize(14).fontColor('#FFFFFF') }
          .width(36)
          .height(30)
          .backgroundColor('#3A3A5A')
          .borderRadius(6)
          .margin({ left: 8 })
          .onClick(() => { this.hairOffsetY -= 5; })
        Button() { Text('⬇').fontSize(14).fontColor('#FFFFFF') }
          .width(36)
          .height(30)
          .backgroundColor('#3A3A5A')
          .borderRadius(6)
          .margin({ left: 4 })
          .onClick(() => { this.hairOffsetY += 5; })
      }
      .margin({ top: 6, bottom: 6 })

      Row() {
        Text('📐 大小:')
          .fontSize(13)
          .fontColor('#AAAAAA')
          .margin({ right: 8 })
        Text('宽')
          .fontSize(12)
          .fontColor('#CCCCCC')
        Button() { Text('➖').fontSize(12).fontColor('#FFFFFF') }
          .width(30)
          .height(28)
          .backgroundColor('#3A3A5A')
          .borderRadius(6)
          .margin({ left: 4, right: 4 })
          .onClick(() => { this.hairScaleW = Math.max(0.5, this.hairScaleW - 0.1); })
        Button() { Text('➕').fontSize(12).fontColor('#FFFFFF') }
          .width(30)
          .height(28)
          .backgroundColor('#3A3A5A')
          .borderRadius(6)
          .margin({ right: 8 })
          .onClick(() => { this.hairScaleW = Math.min(3.0, this.hairScaleW + 0.1); })
        Text('高')
          .fontSize(12)
          .fontColor('#CCCCCC')
        Button() { Text('➖').fontSize(12).fontColor('#FFFFFF') }
          .width(30)
          .height(28)
          .backgroundColor('#3A3A5A')
          .borderRadius(6)
          .margin({ left: 4, right: 4 })
          .onClick(() => { this.hairScaleH = Math.max(0.5, this.hairScaleH - 0.1); })
        Button() { Text('➕').fontSize(12).fontColor('#FFFFFF') }
          .width(30)
          .height(28)
          .backgroundColor('#3A3A5A')
          .borderRadius(6)
          .onClick(() => { this.hairScaleH = Math.min(3.0, this.hairScaleH + 0.1); })
      }
      .margin({ bottom: 6 })

      Row() {
        Text(`${this.hairScaleW.toFixed(1)}x${this.hairScaleH.toFixed(1)}  偏移(${this.hairOffsetX},${this.hairOffsetY})`)
          .fontSize(11)
          .fontColor('#888888')
      }
      .margin({ bottom: 4 })
    }
    .width('100%')
  }

  build() {
    Stack({ alignContent: Alignment.TopStart }) {
      Column() {
        Stack({ alignContent: Alignment.TopStart }) {
          XComponent({
            type: XComponentType.SURFACE,
            controller: this.mXComponentController
          })
            .backgroundColor('#1A1A2E')
            .onLoad(() => {
              let dw: number = display.getDefaultDisplaySync().width;
              let dh: number = Math.floor(dw * 4 / 3);
              this.mXComponentController.setXComponentSurfaceRect({
                surfaceWidth: dw,
                surfaceHeight: dh
              });
              this.xComponentSurfaceId = this.mXComponentController.getXComponentSurfaceId();
              this.surfaceReady = true;
              hilog.info(DOMAIN, TAG, `XComponent loaded`);
            })
            .width('100%')
            .height('100%')

          if (this.hasFace && this.isDetecting) {
            Text()
              .position({ x: this.faceX, y: this.faceY })
              .width(this.faceW)
              .height(this.faceH)
              .border({ width: 2, color: '#4A90D980' })
              .borderRadius(4)
          }
        }
        .width('100%')
        .layoutWeight(1)
        .onAreaChange((_old: Area, newArea: Area) => {
          this.previewAreaWidth = Number(newArea.width);
          this.previewAreaHeight = Number(newArea.height);
        })

        Column() {
          Text(this.statusText)
            .fontSize(14)
            .fontColor('#DDDDDD')
            .width('100%')
            .textAlign(TextAlign.Center)
            .padding({ top: 8, bottom: 4 })

          this.hairBarBuilder()

          if (this.isAdjusting) {
            this.adjustPanelBuilder()
          }

          Row() {
            if (this.isAdjusting) {
              Button('锁定位置')
                .width('40%')
                .height(36)
                .fontSize(14)
                .fontColor('#FFFFFF')
                .backgroundColor('#4CAF50')
                .borderRadius(18)
                .onClick(() => {
                  this.isAdjusting = false;
                  this.statusText = '发型已锁定,跟随人脸移动';
                })
              Button('重置')
                .width('25%')
                .height(36)
                .fontSize(14)
                .fontColor('#FFFFFF')
                .backgroundColor('#666666')
                .borderRadius(18)
                .onClick(() => {
                  this.hairOffsetX = 0;
                  this.hairOffsetY = -10;
                  this.hairScaleW = 1.0;
                  this.hairScaleH = 1.2;
                })
            }
            Button(this.isDetecting ? '停止检测' : '开始检测')
              .width(this.isAdjusting ? '30%' : '60%')
              .height(36)
              .fontSize(14)
              .fontColor('#FFFFFF')
              .backgroundColor(this.isDetecting ? '#FF6B35' : '#4A90D9')
              .borderRadius(18)
              .enabled(this.surfaceReady || this.isDetecting)
              .onClick(() => {
                if (this.isDetecting) {
                  this.releaseCamera();
                  this.statusText = '检测已停止';
                } else {
                  this.prepareCamera();
                }
              })
            if (this.isDetecting && !this.isAdjusting) {
              Button('调整')
                .width('30%')
                .height(36)
                .fontSize(14)
                .fontColor('#FFFFFF')
                .backgroundColor('#9C27B0')
                .borderRadius(18)
                .onClick(() => {
                  this.isAdjusting = true;
                  this.statusText = '调整发型位置和大小,完成后点击锁定';
                })
            }
          }
          .width('100%')
          .justifyContent(FlexAlign.Center)
          .margin({ top: 4, bottom: 12 })
        }
        .width('100%')
        .backgroundColor('#16162A')
        .borderRadius({ topLeft: 16, topRight: 16 })
        .padding({ left: 12, right: 12 })
      }
      .width('100%')
      .height('100%')

      if (this.hasFace && this.isDetecting) {
        Image(this.hairConfig.items[this.selectedIndex].resource)
          .objectFit(ImageFit.Cover)
          .position({ x: this.getHairX(), y: this.getHairY() })
          .width(this.getHairW())
          .height(this.getHairH())
          .hitTestBehavior(HitTestMode.Transparent)
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#0D0D1A')
  }
}

总结

换发型案例比人脸贴纸多了三个要解决的问题,每一个都有对应的解法:

  1. 人脸框抖动:用 lerp 指数平滑,SMOOTH_FACTOR = 0.35,配合 firstFrame 首帧直穿。平滑逻辑放在 private 成员上运算,最后一次性赋值 @State,每帧只触发一次 UI 刷新。
  2. 发型覆盖面积大:发型图放在外层全屏 Stack 里,不受预览区 clip 限制;高度用 faceW 而非 faceH 做基准,避免仰头低头时发型高度抖动;.hitTestBehavior(Transparent) 确保触摸穿透。
  3. 不同脸型适配:提供手动调整面板,位置 ±5 像素步进,缩放 ±0.1 步进,范围 0.5~3.0;调整后锁定参数,发型继续跟随人脸移动。

整条链路就是:MetadataOutput 拿人脸框 → lerp 平滑去抖 → 按 scaleW/scaleH/offsetX/offsetY 计算位置 → 外层 Stack 叠放 + hitTest 穿透。核心是"平滑"和"分层"两个设计决策——不平滑发型会晃,不分层发型会被裁。

Logo

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

更多推荐