【共创稿事节】 HarmonyOS7 换发型:让发型图稳稳跟住人脸
文章目录
前言
换发型这个需求,和贴头饰看似相似,但有个本质区别:头饰是"贴在头顶就完事",发型需要"大面积覆盖头部、精确对齐发际线、跟随人脸平滑移动"。如果你直接把人脸框坐标丢给 .position(),发型图会跟着人脸框一起抖——每帧的 MetadataOutput 坐标都有几像素的跳动,结果就是发型图像贴在一张抖动的纸上。
这篇文章围绕一个完整的换发型案例,重点解决三个问题:怎么让人脸坐标不抖、怎么让发型图覆盖正确的位置、怎么给用户提供手动微调能力。每个环节的代码我都会说明"为什么这样写",文末有完整源码,可以直接取用。
效果预览

主要流程
整个流程可以压缩成 4 步:
- 用
XComponent+ Camera Kit 初始化相机预览,MetadataOutput输出人脸框坐标。 - 对人脸框坐标做指数平滑(
lerp),消除帧间抖动,让发型图稳稳跟住人脸。 - 根据平滑后的人脸框坐标,按
hairScaleW/hairScaleH/hairOffsetX/hairOffsetY计算发型图位置和尺寸。 - 发型图用外层
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') 引用:

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 里的 oldVal 和 newVal 赋值各触发一次刷新,每帧就变成两次刷新,浪费性能。
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;
| 参数 | 默认值 | 含义 |
|---|---|---|
hairScaleW | 1.0 | 发型宽度 = faceW × hairScaleW |
hairScaleH | 1.2 | 发型高度 = faceW × hairScaleH(注意:用的是 faceW 不是 faceH) |
hairOffsetX | 0 | 发型水平偏移(相对人脸框中心,像素) |
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')
}
}
}
调整面板提供两组操作:
- 位置调整:四个方向按钮,每次 ±5 像素。因为平滑后人脸框坐标是稳定的,5 像素的调整步长肉眼可见但不至于跳太多。
- 大小调整:宽高各自加减,步长 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')
}
}
总结
换发型案例比人脸贴纸多了三个要解决的问题,每一个都有对应的解法:
- 人脸框抖动:用
lerp指数平滑,SMOOTH_FACTOR = 0.35,配合firstFrame首帧直穿。平滑逻辑放在private成员上运算,最后一次性赋值@State,每帧只触发一次 UI 刷新。 - 发型覆盖面积大:发型图放在外层全屏
Stack里,不受预览区clip限制;高度用faceW而非faceH做基准,避免仰头低头时发型高度抖动;.hitTestBehavior(Transparent)确保触摸穿透。 - 不同脸型适配:提供手动调整面板,位置 ±5 像素步进,缩放 ±0.1 步进,范围 0.5~3.0;调整后锁定参数,发型继续跟随人脸移动。
整条链路就是:MetadataOutput 拿人脸框 → lerp 平滑去抖 → 按 scaleW/scaleH/offsetX/offsetY 计算位置 → 外层 Stack 叠放 + hitTest 穿透。核心是"平滑"和"分层"两个设计决策——不平滑发型会晃,不分层发型会被裁。
更多推荐



所有评论(0)