HarmonyOS趣味相机实战第4篇:CoreVision人体识别、目标融合与智能装饰对齐底座
HarmonyOS趣味相机实战第4篇:CoreVision人体识别、目标融合与智能装饰对齐底座
摘要
第 1 篇把相机权限、CameraKit 预览和真实拍照主链路跑通;第 2 篇把水印快照和本地相册讲清楚;第 3 篇把照片转文档和归档缓存补上。本文继续基于 D:/APP/1quweixiangji,复盘项目里已经搭好的“智能装饰对齐底座”:CameraKit 预览可以可选开启目标回调,CameraPreviewService 负责采集预览帧和拍照帧,CoreVisionHumanService 负责人体骨骼识别与主体分割,HumanFittingService 负责把头顶、眼睛、脸颊、脖子、肩膀、身体、手部等锚点换算成装饰层坐标。
本文有一个明确边界:当前页面主拍照流程没有直接把贴纸编辑 UI 接到主界面,Index.ets 启动预览时没有传入 targetCallback。所以本文讨论的是“智能装饰识别与对齐服务底座”,不是宣称完整贴纸编辑器已经上线。这个边界写清楚,文章才不会变成过度包装。
本文重点回答五个工程问题:
CameraPreviewService.startPreview()为什么把目标检测设计成可选回调。- 实时预览采样和拍照后分析为什么要分开。
- CoreVision 的骨骼点、主体分割和 CameraKit 人脸 metadata 如何融合。
- 装饰库为什么要给每个素材配置
anchor、placement、defaultScale和followTarget。 HumanFittingService如何把人体目标转换成贴纸坐标、缩放和精度等级。
工程背景与源码定位
| 文件 | 作用 |
|---|---|
entry/src/main/ets/service/CameraPreviewService.ets |
CameraKit 预览、拍照、metadata 输出、实时视觉采样和目标优先级融合 |
entry/src/main/ets/service/CoreVisionHumanService.ets |
CoreVisionKit 骨骼识别、主体分割、坐标归一化和目标输出 |
entry/src/main/ets/service/HumanFittingService.ets |
根据人体目标和装饰锚点计算图层位置、缩放和拟合精度 |
entry/src/main/ets/service/DecorationLibrary.ets |
定义装饰分类、素材、套装、锚点和层级 |
entry/src/main/ets/model/DecorationModels.ets |
定义 DetectedTarget、BodyKeypoint、DecorationItem、DecorationLayer 等模型 |
entry/src/main/ets/pages/Index.ets |
当前主拍照页面,已接 CameraKit 预览,但未传入目标检测回调 |
entry/src/main/module.json5 |
相机权限声明,智能分析仍以相机前台使用为基础 |
entry/src/test/LocalUnit.test.ets |
当前已有照片快照测试,后续可扩展拟合纯函数测试 |
环境与版本信息
| 项目 | 当前值 | 说明 |
|---|---|---|
| 工程路径 | D:/APP/1quweixiangji |
本文只引用该项目已有源码 |
| 工程类型 | HarmonyOS Stage 模型 | EntryAbility 加载 pages/Index |
| target SDK | 6.0.2(22) |
以当前工程配置为准 |
| bundleName | com.fun.quweixiangji |
来自 AppScope/app.json5 |
| versionName | 1.0.1 |
当前复盘对应版本 |
| 相机能力 | @kit.CameraKit |
预览、拍照、metadata 输出 |
| 视觉能力 | @kit.CoreVisionKit |
骨骼点检测、主体分割 |
| 图像对象 | @ohos.multimedia.image |
PixelMap 与预览 Surface 采样 |
| 页面模型 | ArkTS 声明式 UI | 当前主界面未直接启用智能贴纸编辑 UI |

本地构建命令仍沿用项目 README:
cd D:\APP\1quweixiangji
$env:JAVA_HOME='D:\Program Files\Huawei\DevEco Studio\jbr'
$env:Path="$env:JAVA_HOME\bin;$env:Path"
& 'D:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin\hvigorw.bat' --mode module -p module=entry@default -p product=default assembleHap --no-daemon
版本兼容性、隐私边界与性能边界
人体识别和智能贴纸比普通水印更敏感,也更吃性能。当前实现的边界如下:
| 能力 | 当前实现 | 边界说明 |
|---|---|---|
| 预览采样 | image.createPixelMapFromSurface(surfaceId) |
只在启用目标回调时采样,不是默认主流程 |
| 拍照后分析 | photoAvailable 后分析 PixelMap |
用于拍照结果的人体目标补充 |
| 骨骼识别 | skeletonDetection.SkeletonDetector |
失败时返回空目标,不阻断拍照 |
| 主体分割 | subjectSegmentation.doSegmentation |
只取主体轮廓,用于目标边界补充 |
| 人脸 metadata | CameraKit metadata 输出 | 优先用于脸部定位,和骨骼结果融合 |
| 数据留存 | 当前不持久化人体目标 | 不把关键点、轮廓作为历史隐私数据保存 |
| 页面接入 | 主页面未传入 targetCallback |
本文讨论服务底座,不夸大为完整 UI |
如果后续把人体关键点、贴纸位置或识别结果保存到相册、云端或分享文件里,需要在隐私政策里明确说明:处理目的、保存范围、删除方式、是否上传以及用户如何关闭。
一、装饰模型先定义“贴哪里”
智能贴纸不能只保存一个图片名。每个装饰都要知道自己应该跟随哪个身体部位、默认多大、在哪一层、是否跟随目标移动。DecorationModels.ets 里定义了锚点类型:
export type AnchorType =
'headTop' | 'eyes' | 'cheeks' | 'noseMouth' | 'neck' | 'shoulder' | 'bodyCenter' | 'hand' |
'around' | 'fullFrame';
装饰素材模型:
export interface DecorationItem {
id: string;
name: string;
category: DecorationCategory;
symbol: string;
color: string;
anchor: AnchorType;
defaultScale: number;
defaultRotation: number;
defaultLayer: number;
placement: DecorationPlacement;
followTarget: boolean;
freeMove: boolean;
multiInstance: boolean;
targetTypes: TargetType[];
}
这里最关键的是四个字段:
| 字段 | 作用 |
|---|---|
anchor |
决定贴纸跟随头顶、眼睛、脖子、肩膀、手部还是全画幅 |
placement |
决定贴纸在身体前、身体后还是场景层 |
followTarget |
决定是否随人体目标移动 |
defaultLayer |
决定多个装饰叠放时的视觉层级 |
这比“用户拖一个贴纸到画面上”更接近工程化:素材一开始就知道自己的默认语义位置。
二、装饰库把素材和锚点绑定
DecorationLibrary.ets 里把素材分成十类:
static categories(): CategoryTab[] {
return [
{ key: 'pack', label: '套装' },
{ key: 'head', label: '头部' },
{ key: 'face', label: '脸部' },
{ key: 'neck', label: '颈肩' },
{ key: 'body', label: '身体' },
{ key: 'hand', label: '手持' },
{ key: 'mood', label: '氛围' },
{ key: 'text', label: '文字' },
{ key: 'frame', label: '边框' },
{ key: 'festival', label: '节日' }
];
}
部分素材定义如下:
DecorationLibrary.item('cat_ears', '猫耳', 'head', '^ ^', '#FF9AC8', 'headTop', 1.0, 18, true),
DecorationLibrary.item('round_glasses', '圆框眼镜', 'face', 'oo', '#5A4A7A', 'eyes', 0.86, 30, true),
DecorationLibrary.item('scarf', '围巾', 'neck', 'scarf', '#B58CFF', 'neck', 1.04, 24, true),
DecorationLibrary.item('angel_wings', '天使翅膀', 'body', 'wings', '#F7FAFF', 'shoulder', 1.18, 10, true, 'behindBody'),
DecorationLibrary.item('bouquet', '花束', 'hand', 'flowers', '#FF8CB3', 'hand', 0.76, 38, true),
DecorationLibrary.item('polaroid_frame', '拍立得框', 'frame', 'frame', '#FFFFFF', 'fullFrame', 1.0, 80, false, 'scene')
这段代码把产品语义翻译成可计算数据:
| 素材 | 分类 | 锚点 | 层级含义 |
|---|---|---|---|
| 猫耳 | 头部 | headTop |
应在头顶附近 |
| 圆框眼镜 | 脸部 | eyes |
应跟随眼睛中心 |
| 围巾 | 颈肩 | neck |
应落在脖子附近 |
| 天使翅膀 | 身体 | shoulder |
放在身体后方 |
| 花束 | 手持 | hand |
跟随手腕或手部 |
| 拍立得框 | 边框 | fullFrame |
不跟随人体,覆盖全画面 |
套装则是多素材组合:
{
id: 'cat_selfie',
name: '猫咪自拍',
summary: '猫耳、胡须、腮红、铃铛项圈',
itemIds: ['cat_ears', 'cat_whiskers', 'blush', 'bell_collar']
}
这为后续“一键套装”打好了基础。
三、CameraPreviewService 把目标检测设计成可选能力
主页面当前启动预览时只传入上下文、Surface 和镜头方向:
const result: CameraPreviewState = await CameraPreviewService.startPreview(
context,
this.previewSurfaceId,
this.activeCameraPosition
);
而服务层方法签名其实预留了目标回调:
static async startPreview(
context: common.UIAbilityContext,
surfaceId: string,
position: CameraPositionKey,
targetCallback?: CameraDetectedTargetCallback
): Promise<CameraPreviewState> {
CameraPreviewService.targetCallback = targetCallback ? targetCallback : null;
const shouldDetectTargets: boolean = targetCallback !== undefined;
CameraPreviewService.shouldAnalyzeTargetsOnCapture = shouldDetectTargets;
}
这个设计很重要:普通拍照不需要承受人体识别开销;只有未来接入智能装饰 UI 时,才传入 targetCallback 开启目标检测。
可以理解成两种模式:
| 模式 | targetCallback |
行为 |
|---|---|---|
| 普通相机 | 未传入 | 只启动预览和拍照 |
| 智能装饰 | 传入 | 启动 metadata、人像采样和目标回调 |
这样主流程轻,扩展能力也不会丢。
四、预览会话里同时准备 metadata 和实时采样
当 shouldDetectTargets 为 true 时,预览服务会尝试创建 metadata 输出:
const metadataOutput: camera.MetadataOutput | null = shouldDetectTargets ?
CameraPreviewService.createFaceMetadataOutput(manager, capability) :
null;
会话配置时加入 metadata:
session.beginConfig();
session.addInput(input);
session.addOutput(output);
session.addOutput(photoOutput);
if (metadataOutput !== null) {
session.addOutput(metadataOutput);
}
await session.commitConfig();
await session.start();
启动后再开启 metadata 和实时采样:
if (shouldDetectTargets && metadataOutput !== null) {
try {
await metadataOutput.start();
} catch (metadataError) {
hilog.warn(DOMAIN, TAG, 'start metadata output failed: %{public}s', JSON.stringify(metadataError));
CameraPreviewService.notifyTargets([]);
}
} else if (shouldDetectTargets) {
CameraPreviewService.notifyTargets([]);
}
if (shouldDetectTargets) {
CameraPreviewService.startLiveVisionSampling(surfaceId);
}
这里的容错思路很清楚:metadata 启动失败,不代表相机预览失败;目标检测失败,不应该阻断拍照。
五、实时预览采样:有节制地从 Surface 取 PixelMap
实时采样入口:
private static startLiveVisionSampling(surfaceId: string): void {
CameraPreviewService.cancelLiveVisionSampling();
CameraPreviewService.liveVisionActive = true;
CameraPreviewService.liveVisionSurfaceId = surfaceId;
CameraPreviewService.liveVisionGeneration += 1;
CameraPreviewService.scheduleLiveVisionSample(500, CameraPreviewService.liveVisionGeneration);
}
采样时先检查代际和忙碌状态:
if (!CameraPreviewService.liveVisionActive || generation !== CameraPreviewService.liveVisionGeneration) {
return;
}
if (CameraPreviewService.liveVisionBusy || CameraPreviewService.liveVisionSurfaceId.length === 0) {
CameraPreviewService.scheduleLiveVisionSample(LIVE_VISION_INTERVAL_MS, generation);
return;
}
真正分析:
pixelMap = await image.createPixelMapFromSurface(CameraPreviewService.liveVisionSurfaceId);
const targets: DetectedTarget[] =
await CoreVisionHumanService.analyzePixelMap(
pixelMap,
CameraPreviewService.previewRotation,
CameraPreviewService.activeCameraPosition === 'front'
);
const cameraTargets: CameraDetectedTarget[] = CameraPreviewService.toCameraTargets(targets);
CameraPreviewService.lastLiveVisionTargets = cameraTargets;
CameraPreviewService.lastLiveVisionAt = Date.now();
if (cameraTargets.length > 0) {
CameraPreviewService.notifyTargets(cameraTargets);
}
最后释放 PixelMap,并继续调度下一次采样:
finally {
if (pixelMap !== null) {
await pixelMap.release();
}
CameraPreviewService.liveVisionBusy = false;
CameraPreviewService.scheduleLiveVisionSample(LIVE_VISION_INTERVAL_MS, generation);
}
这段设计避免了两个常见坑:
- 采样任务并发堆积,导致预览卡顿。
- Surface 抽帧后的 PixelMap 不释放,导致内存压力。
六、拍照后分析:用真实照片补充目标结果
拍照回调里会分析真实照片:
private static bindPhotoAvailable(photoOutput: camera.PhotoOutput): void {
const callback: AsyncCallback<camera.Photo> = (error: BusinessError, photo: camera.Photo): void => {
if (error) {
hilog.warn(DOMAIN, TAG, 'photo available failed: %{public}s', JSON.stringify(error));
return;
}
CameraPreviewService.lastPhotoAvailableAt = Date.now();
CameraPreviewService.photoEventCount += 1;
const promise: Promise<CameraPhotoVisionResult> =
CameraPreviewService.analyzeCapturedPhoto(photo, CameraPreviewService.shouldAnalyzeTargetsOnCapture);
CameraPreviewService.lastPhotoVisionPromise = promise;
};
CameraPreviewService.photoAvailableCallback = callback;
photoOutput.on('photoAvailable', callback);
}
实时预览采样解决“取景时贴纸跟随”,拍照后分析解决“最终照片结果更准”。两者分开是合理的:预览要轻,拍照结果可以稍微等一等,换更稳定的目标数据。
七、CoreVisionHumanService:骨骼识别加主体分割
人体分析入口:
static async analyzePixelMap(
pixelMap: image.PixelMap,
rotation: camera.ImageRotation = camera.ImageRotation.ROTATION_0,
mirrorX: boolean = false
): Promise<DetectedTarget[]> {
const imageInfo: image.ImageInfo = await pixelMap.getImageInfo();
const sourceSize: SourceSize = {
width: Math.max(1, imageInfo.size.width),
height: Math.max(1, imageInfo.size.height),
rotation,
mirrorX
};
const skeletons: CoreVisionSkeleton[] = await CoreVisionHumanService.detectSkeletons(pixelMap);
const segmentationData: SegmentationData | null =
await CoreVisionHumanService.detectSegmentation(pixelMap, sourceSize);
const targets: DetectedTarget[] = skeletons.map((skeleton: CoreVisionSkeleton, index: number) => {
return CoreVisionHumanService.skeletonToTarget(skeleton, sourceSize, index);
});
if (segmentationData !== null) {
CoreVisionHumanService.attachSegmentation(targets, segmentationData);
}
return targets;
}
这条链路输出的是统一的 DetectedTarget,而不是直接把 CoreVision 原始结果丢给页面。这样后续无论来自骨骼识别、分割轮廓还是 CameraKit metadata,都能进入同一个拟合服务。
八、骨骼点映射到项目自己的关键点模型
CoreVision 骨骼点要映射为项目自己的 BodyKeypointName:
private static mapSkeletonPointName(type: skeletonDetection.SkeletonPointType): BodyKeypointName | null {
switch (type) {
case skeletonDetection.SkeletonPointType.NOSE:
return 'nose';
case skeletonDetection.SkeletonPointType.LEFT_EYE:
return 'leftEye';
case skeletonDetection.SkeletonPointType.RIGHT_EYE:
return 'rightEye';
case skeletonDetection.SkeletonPointType.LEFT_SHOULDER:
return 'leftShoulder';
case skeletonDetection.SkeletonPointType.RIGHT_SHOULDER:
return 'rightShoulder';
case skeletonDetection.SkeletonPointType.LEFT_WRIST:
return 'leftWrist';
case skeletonDetection.SkeletonPointType.RIGHT_WRIST:
return 'rightWrist';
default:
return null;
}
}
为什么不直接用 SDK 枚举?因为页面装饰逻辑不应该依赖底层识别 SDK。项目自己的模型更稳定:
export type BodyKeypointName =
'head' | 'leftEye' | 'rightEye' | 'nose' | 'mouth' | 'neck' | 'leftShoulder' | 'rightShoulder' |
'leftElbow' | 'rightElbow' | 'leftWrist' | 'rightWrist' | 'leftHand' | 'rightHand' |
'leftHip' | 'rightHip' | 'leftKnee' | 'rightKnee' | 'leftAnkle' | 'rightAnkle';
这样将来更换识别源时,只要继续输出这组业务关键点,HumanFittingService 就不用大改。
九、主体分割用于补全人体轮廓
骨骼点适合定位眼睛、肩膀和手腕,主体分割适合补充整体轮廓。项目使用:
const result: subjectSegmentation.SegmentationResult =
await subjectSegmentation.doSegmentation(
{ pixelMap },
{
maxCount: 1,
enableSubjectDetails: true,
enableSubjectForegroundImage: false
}
);
分割结果会变成 HumanSegmentationMask:
segmentation: {
width: PREVIEW_FRAME_WIDTH,
height: PREVIEW_FRAME_HEIGHT,
outline,
confidence: result.subjectCount > 0 ? 0.88 : 0.62
}
这里没有保留前景图,只保留轮廓点。对智能装饰来说,轮廓已经能辅助计算身体宽度、头顶位置和区域边界,没必要把更重的图片数据传来传去。
十、目标融合:优先选更可靠的目标
CameraPreviewService.preferredTargets() 负责决定最终给页面/装饰层用哪批目标:
private static preferredTargets(targets: CameraDetectedTarget[]): CameraDetectedTarget[] {
if (CameraPreviewService.hasFaceMetadataTargets(targets)) {
CameraPreviewService.lastFaceMetadataTargets = targets;
CameraPreviewService.lastFaceMetadataAt = Date.now();
return targets;
}
const reliablePoseTargets: CameraDetectedTarget[] =
targets.filter((target: CameraDetectedTarget) => CameraPreviewService.isReliablePoseTarget(target));
const faceMetadataFresh: boolean =
CameraPreviewService.lastFaceMetadataTargets.length > 0 &&
Date.now() - CameraPreviewService.lastFaceMetadataAt <= FACE_METADATA_FRESH_MS;
if (reliablePoseTargets.length > 0) {
if (faceMetadataFresh) {
return CameraPreviewService.mergePoseTargetsWithFaces(
reliablePoseTargets,
CameraPreviewService.lastFaceMetadataTargets
);
}
return reliablePoseTargets;
}
}
这里有一个很实用的策略:人脸 metadata 通常脸部定位准,骨骼识别通常身体和手腕信息更丰富。新鲜的人脸结果和可靠骨骼结果可以合并,让眼镜、猫耳、围巾、手持道具都更容易对齐。
可靠骨骼目标的判断:
private static isReliablePoseTarget(target: CameraDetectedTarget): boolean {
if (target.fitSource !== 'poseKeypoints' || !target.keypoints || target.keypoints.length < 4) {
return false;
}
let reliableCount: number = 0;
let upperBodyCount: number = 0;
target.keypoints.forEach((point: BodyKeypoint) => {
if (point.confidence < 0.28) {
return;
}
reliableCount += 1;
if (point.name === 'leftEye' || point.name === 'rightEye' || point.name === 'nose' ||
point.name === 'leftShoulder' || point.name === 'rightShoulder' ||
point.name === 'leftWrist' || point.name === 'rightWrist') {
upperBodyCount += 1;
}
});
return reliableCount >= 4 && upperBodyCount >= 2;
}
它不是“只要识别到人就用”,而是要求关键点数量和上半身关键点达到最低门槛。
十一、HumanFittingService:把人体目标变成装饰坐标
贴纸最终要落在二维画面上。HumanFittingService.layerX() 和 layerY() 就是把目标转换为图层坐标:
static layerX(anchor: AnchorType, target: DetectedTarget, visualSize: number, defaultX: number): number {
if (anchor === 'around' || anchor === 'fullFrame') {
return defaultX;
}
const center: BodyKeypoint | null = HumanFittingService.anchorCenter(anchor, target);
if (center !== null) {
return HumanFittingService.clampLayerX(center.x - visualSize / 2, visualSize);
}
return HumanFittingService.fallbackLayerX(anchor, target, visualSize);
}
static layerY(anchor: AnchorType, target: DetectedTarget, visualSize: number, defaultY: number): number {
if (anchor === 'around' || anchor === 'fullFrame') {
return defaultY;
}
const center: BodyKeypoint | null = HumanFittingService.anchorCenter(anchor, target);
if (center !== null) {
const y: number = anchor === 'headTop' ? center.y - visualSize * 0.58 : center.y - visualSize / 2;
return HumanFittingService.clampLayerY(y, visualSize);
}
return HumanFittingService.fallbackLayerY(anchor, target, visualSize);
}
这里有两个设计细节:
around和fullFrame不跟随人体,直接使用默认坐标。headTop的 Y 轴有额外上移,否则猫耳、皇冠会压到脸上。
十二、不同锚点对应不同人体位置
锚点中心由 anchorCenter() 分发:
private static anchorCenter(anchor: AnchorType, target: DetectedTarget): BodyKeypoint | null {
switch (anchor) {
case 'headTop':
return HumanFittingService.headTopPoint(target);
case 'eyes':
return HumanFittingService.eyesCenter(target);
case 'cheeks':
return HumanFittingService.cheekCenter(target);
case 'noseMouth':
return HumanFittingService.noseMouthCenter(target);
case 'neck':
return HumanFittingService.neckPoint(target);
case 'shoulder':
return HumanFittingService.averageKeypointPair(target, 'leftShoulder', 'rightShoulder', 'leftShoulder') ??
HumanFittingService.outlineCenterAtRatio(target, 0.4, 'leftShoulder');
case 'bodyCenter':
return HumanFittingService.bodyCenter(target);
case 'hand':
return HumanFittingService.handPoint(target) ??
HumanFittingService.outlineSidePointAtRatio(target, 0.64, 'rightHand');
default:
return null;
}
}
这就是装饰拟合的核心表:
| 锚点 | 优先数据 | 回退数据 |
|---|---|---|
headTop |
头部关键点 | 人脸框顶部、轮廓顶部 |
eyes |
左右眼中心 | 人脸框估算、轮廓比例 |
cheeks |
人脸框和眼睛 | 轮廓比例 |
noseMouth |
鼻子和嘴 | 人脸框下部 |
neck |
脖子点或肩膀中心 | 人脸框下沿、轮廓比例 |
shoulder |
左右肩中心 | 轮廓 40% 高度 |
bodyCenter |
肩膀和髋部 | 轮廓中心 |
hand |
手腕或手部点 | 轮廓侧边点 |
有了这层分发,素材库只需要声明 anchor,不需要知道骨骼识别细节。
十三、缩放不是固定值,而是跟随脸宽和身体宽度
scaleFor() 根据目标尺寸计算贴纸缩放:
static scaleFor(item: DecorationItem, target: DetectedTarget, textLike: boolean): number {
const faceWidth: number = HumanFittingService.faceWidth(target);
const bodyWidth: number = HumanFittingService.bodyWidth(target);
const shoulderWidth: number = HumanFittingService.shoulderWidth(target, bodyWidth);
let desiredSize: number = Math.max(34, faceWidth * 1.35);
switch (item.anchor) {
case 'headTop':
desiredSize = faceWidth * 1.46;
break;
case 'eyes':
case 'noseMouth':
case 'cheeks':
desiredSize = faceWidth * 1.2;
break;
case 'neck':
desiredSize = Math.max(faceWidth * 0.72, shoulderWidth * 0.5);
break;
case 'shoulder':
desiredSize = Math.max(target.width * 0.58, shoulderWidth * 1.16);
break;
case 'bodyCenter':
desiredSize = bodyWidth * 0.38;
break;
case 'hand':
desiredSize = Math.max(faceWidth * 0.64, bodyWidth * 0.28);
break;
}
const baseSize: number = textLike ? 82 : 64;
return HumanFittingService.clamp(desiredSize / baseSize, 0.42, 1.65);
}
这比固定 scale=1 更稳。远处的人脸小,贴纸也变小;近处的人脸大,贴纸跟着放大;文字素材和图形素材使用不同 baseSize,避免文字贴纸过大。
十四、拟合精度可以反馈给 UI
precision() 把识别质量抽象成四档:
export type HumanFitPrecision = 'segmentation' | 'pose' | 'estimatedPose' | 'box';
static precision(target: DetectedTarget | null): HumanFitPrecision {
if (target === null) {
return 'box';
}
if (target.fitSource === 'poseKeypoints' && HumanFittingService.hasReliableKeypoints(target)) {
return 'pose';
}
if (HumanFittingService.hasReliableKeypoints(target)) {
return 'estimatedPose';
}
if (target.fitSource === 'segmentationMask' && target.segmentation && target.segmentation.outline.length > 0) {
return 'segmentation';
}
return 'box';
}
这类状态很适合未来展示在调试面板或贴纸编辑器里:
| 精度 | 含义 | UI 策略 |
|---|---|---|
pose |
可靠骨骼关键点 | 允许自动贴合 |
estimatedPose |
有关键点但来源较弱 | 允许贴合,同时提示可微调 |
segmentation |
有主体轮廓 | 适合身体/边框,不适合眼镜 |
box |
只有矩形框或无目标 | 使用默认位置,避免强行贴脸 |
用户体验上,不要把低精度结果硬说成“已精准识别”,否则贴纸偏了会很明显。
十五、资源释放必须覆盖视觉服务
相机停止时,CameraPreviewService.stopPreview() 会释放视觉采样和 CoreVision 资源:
await CameraPreviewService.stopLiveVisionSampling();
await CoreVisionHumanService.release();
CoreVisionHumanService.release():
static async release(): Promise<void> {
if (CoreVisionHumanService.skeletonDetector !== null) {
await CoreVisionHumanService.skeletonDetector.destroy();
CoreVisionHumanService.skeletonDetector = null;
}
if (CoreVisionHumanService.segmentationReady) {
await subjectSegmentation.release();
CoreVisionHumanService.segmentationReady = false;
}
}
这一步不能省。骨骼检测器、分割能力、CameraKit 会话和 PixelMap 都属于重资源,如果切后台或退出页面时不释放,很容易引起下次预览黑屏、发热或内存压力。
十六、后续页面接入建议
当前 Index.ets 没有传入 targetCallback。如果后续要接智能贴纸 UI,可以增加类似状态:
@State detectedTargets: DetectedTarget[] = [];
@State selectedDecorations: DecorationLayer[] = [];
启动预览时传回调:
const result: CameraPreviewState = await CameraPreviewService.startPreview(
context,
this.previewSurfaceId,
this.activeCameraPosition,
(targets: CameraDetectedTarget[]) => {
this.detectedTargets = targets;
}
);
渲染装饰层时根据当前选中素材和首个目标计算:
const x = HumanFittingService.layerX(item.anchor, target, visualSize, defaultX);
const y = HumanFittingService.layerY(item.anchor, target, visualSize, defaultY);
const scale = HumanFittingService.scaleFor(item, target, textLike);
这一步要注意三个产品细节:
- 没有目标时,贴纸要落在默认位置,不能消失得太突然。
- 低精度时允许用户手动拖动微调。
- 拍照保存时,要保存最终装饰层数据,而不是只保存检测目标。
十七、建议补充的单元测试
当前 LocalUnit.test.ets 已经覆盖照片水印快照。智能装饰底座可以优先补纯函数测试,因为 HumanFittingService 不依赖真实相机。
| 测试点 | 输入 | 断言 |
|---|---|---|
| 眼镜坐标 | 有左右眼关键点的目标 | eyes 锚点落在双眼中心附近 |
| 猫耳坐标 | 有人脸框的目标 | headTop 的 Y 值高于脸部中心 |
| 手持道具 | 有左右手腕关键点 | hand 锚点选择手部位置 |
| 缩放限制 | 极大或极小目标 | scaleFor() 被限制在 0.42~1.65 |
| 精度判断 | 可靠骨骼点 | precision() 返回 pose |
| 分割回退 | 无关键点但有轮廓 | precision() 返回 segmentation |
示例测试思路:
it('glasses anchor follows eyes center', 0, () => {
const target: DetectedTarget = {
id: 't1',
label: '人物',
confidence: 0.9,
centerX: 158,
centerY: 195,
width: 120,
height: 220,
rotation: 0,
keypoints: [
{ name: 'leftEye', x: 130, y: 120, confidence: 0.9 },
{ name: 'rightEye', x: 186, y: 120, confidence: 0.9 },
{ name: 'nose', x: 158, y: 144, confidence: 0.8 },
{ name: 'leftShoulder', x: 112, y: 220, confidence: 0.8 },
{ name: 'rightShoulder', x: 204, y: 220, confidence: 0.8 }
],
fitSource: 'poseKeypoints'
};
const x = HumanFittingService.layerX('eyes', target, 64, 0);
expect(x).assertLarger(90);
});
这类测试不需要真机、不需要相机权限,能快速守住拟合算法。
十八、常见问题排查
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 预览正常但没有目标 | 没传 targetCallback |
检查 startPreview() 第四个参数 |
| 贴纸抖动 | 目标来源在 metadata 和预览采样之间频繁切换 | 检查 preferredTargets() 的新鲜度阈值 |
| 眼镜贴到鼻子上 | 脸部关键点不足或眼睛置信度低 | 检查 isReliablePoseTarget() 统计 |
| 手持道具位置不准 | 没有手腕关键点 | 让 handPoint() 回退到轮廓侧边点 |
| 猫耳压住脸 | headTop 没做 Y 轴上移 |
检查 layerY() 中 visualSize * 0.58 |
| 退出页面后发热 | 采样定时器或 CoreVision 未释放 | 检查 stopLiveVisionSampling() 和 CoreVisionHumanService.release() |
| 分割结果无法用 | 只有轮廓,没有关键点 | 身体装饰可用,脸部装饰应降级 |
| 前置镜头左右错位 | 镜像参数没传 | 检查 mirrorX: activeCameraPosition === 'front' |
| 远距离贴纸过大 | 固定缩放 | 使用 scaleFor() 按脸宽/身体宽计算 |
| 用户以为功能已完整上线 | 文章或 UI 没说明边界 | 明确当前是服务底座,UI 接入另做 |
十九、上线前验收清单
- 普通拍照模式不传
targetCallback,不会额外开启视觉采样。 - 智能装饰模式传入
targetCallback后,metadata 和实时采样能按需工作。 - Surface 销毁或预览停止时,实时采样定时器会取消。
- PixelMap 每次采样后都释放。
- CoreVision 骨骼检测失败时返回空目标,不阻断预览。
- 主体分割失败时返回空轮廓,不阻断骨骼识别。
- 人脸 metadata 和骨骼目标能按新鲜度融合。
- 可靠骨骼判断有最低关键点数量和置信度门槛。
headTop、eyes、neck、shoulder、hand等锚点都有回退策略。scaleFor()对极大、极小目标都有上下限。precision()能区分pose、estimatedPose、segmentation和box。- 用户关闭智能装饰后,不再继续采样。
- 不把人体关键点、轮廓等识别结果默认持久化。
- 如果未来保存装饰层,要在隐私政策和用户授权里说明用途。
- 真机验证前置、后置、弱光、多人、半身、遮挡、切后台再回来等场景。
总结
1quweixiangji 的智能装饰能力目前更像一个“已经铺好的底座”:素材库知道每个贴纸应该跟随哪个锚点,CameraPreviewService 预留了可选目标回调,CoreVisionHumanService 能把骨骼点和主体分割转换成统一目标,CameraPreviewService 还能融合人脸 metadata 和骨骼目标,HumanFittingService 则负责把目标转换成贴纸坐标、缩放和拟合精度。
这个设计的好处是主拍照链路保持轻量,智能装饰能力可以按需开启。后续真正接 UI 时,只需要把目标回调、素材选择、图层渲染和拍照保存串起来,就能从“水印相机”推进到“智能贴纸相机”。关键是边界要讲清楚:当前文章复盘的是识别与对齐服务底座,完整贴纸编辑体验还需要页面状态、交互和保存链路继续补齐。
更多推荐
所有评论(0)