HarmonyOS趣味相机实战第4篇:CoreVision人体识别、目标融合与智能装饰对齐底座

摘要

第 1 篇把相机权限、CameraKit 预览和真实拍照主链路跑通;第 2 篇把水印快照和本地相册讲清楚;第 3 篇把照片转文档和归档缓存补上。本文继续基于 D:/APP/1quweixiangji,复盘项目里已经搭好的“智能装饰对齐底座”:CameraKit 预览可以可选开启目标回调,CameraPreviewService 负责采集预览帧和拍照帧,CoreVisionHumanService 负责人体骨骼识别与主体分割,HumanFittingService 负责把头顶、眼睛、脸颊、脖子、肩膀、身体、手部等锚点换算成装饰层坐标。

本文有一个明确边界:当前页面主拍照流程没有直接把贴纸编辑 UI 接到主界面,Index.ets 启动预览时没有传入 targetCallback。所以本文讨论的是“智能装饰识别与对齐服务底座”,不是宣称完整贴纸编辑器已经上线。这个边界写清楚,文章才不会变成过度包装。

本文重点回答五个工程问题:

  1. CameraPreviewService.startPreview() 为什么把目标检测设计成可选回调。
  2. 实时预览采样和拍照后分析为什么要分开。
  3. CoreVision 的骨骼点、主体分割和 CameraKit 人脸 metadata 如何融合。
  4. 装饰库为什么要给每个素材配置 anchorplacementdefaultScalefollowTarget
  5. 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 定义 DetectedTargetBodyKeypointDecorationItemDecorationLayer 等模型
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

HarmonyOS 趣味相机智能装饰识别链路

本地构建命令仍沿用项目 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);
}

这段设计避免了两个常见坑:

  1. 采样任务并发堆积,导致预览卡顿。
  2. 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);
}

这里有两个设计细节:

  1. aroundfullFrame 不跟随人体,直接使用默认坐标。
  2. 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);

这一步要注意三个产品细节:

  1. 没有目标时,贴纸要落在默认位置,不能消失得太突然。
  2. 低精度时允许用户手动拖动微调。
  3. 拍照保存时,要保存最终装饰层数据,而不是只保存检测目标。

十七、建议补充的单元测试

当前 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 和骨骼目标能按新鲜度融合。
  • 可靠骨骼判断有最低关键点数量和置信度门槛。
  • headTopeyesneckshoulderhand 等锚点都有回退策略。
  • scaleFor() 对极大、极小目标都有上下限。
  • precision() 能区分 poseestimatedPosesegmentationbox
  • 用户关闭智能装饰后,不再继续采样。
  • 不把人体关键点、轮廓等识别结果默认持久化。
  • 如果未来保存装饰层,要在隐私政策和用户授权里说明用途。
  • 真机验证前置、后置、弱光、多人、半身、遮挡、切后台再回来等场景。

总结

1quweixiangji 的智能装饰能力目前更像一个“已经铺好的底座”:素材库知道每个贴纸应该跟随哪个锚点,CameraPreviewService 预留了可选目标回调,CoreVisionHumanService 能把骨骼点和主体分割转换成统一目标,CameraPreviewService 还能融合人脸 metadata 和骨骼目标,HumanFittingService 则负责把目标转换成贴纸坐标、缩放和拟合精度。

这个设计的好处是主拍照链路保持轻量,智能装饰能力可以按需开启。后续真正接 UI 时,只需要把目标回调、素材选择、图层渲染和拍照保存串起来,就能从“水印相机”推进到“智能贴纸相机”。关键是边界要讲清楚:当前文章复盘的是识别与对齐服务底座,完整贴纸编辑体验还需要页面状态、交互和保存链路继续补齐。

Logo

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

更多推荐