HarmonyOS 测肤拍照页实战:Metadata 实时取景 + Core Vision 拍后校验,从 0.001 的 widthRatio 踩坑到可上线

395 阅读6分钟

HarmonyOS 测肤拍照实战:自定义相机、Metadata 实时取景、Core Vision 拍后校验

环境:HarmonyOS 5.x / ArkTS / CameraKit + CoreVisionKit
场景:AI 测肤需要「应用内拍照 + 椭圆取景框 + 实时引导 + 拍后校验」,对标微信小程序 megvii-skin-plugin 的 AICamera。鸿蒙没有同款插件,只能自己拼。


1. 要做什么

产品要求不是系统相册选图,而是:

能力说明
自定义相机前置、9:16 预览、椭圆取景框
实时引导靠近 / 远离 / 对准 / 绿框可拍
拍摄门槛实时检测通过才能按快门
拍后兜底Core Vision 再验一遍,失败弹窗说明原因

小程序里一行配置:

validFace: { scale: [0.7, 0.9] }

鸿蒙侧要把「脸宽占取景框比例」落到像素坐标上,还要处理传感器横竖屏、镜像、归一化坐标等一堆坑。


2. 最终架构

┌─────────────────────────────────────────┐
  AiSkinCameraPageView.ets                 UI:椭圆提示按钮弹窗
  - 120ms 轮询 latestFaceCheck           
  - frameHighlight 控制绿框/按钮         
└─────────────────┬───────────────────────┘
                  
┌─────────────────▼───────────────────────┐
  SkinCameraController.ets                 相机
  - XComponent + PreviewOutput           
  - MetadataOutput(实时人脸框)          
  - PhotoOutput(拍照存 cache)           
└─────────────────┬───────────────────────┘
                  
┌─────────────────▼───────────────────────┐
  FaceDetectHelper.ets                     规则
  - computeViewfinderRect                
  - validateSkinFaceRectInPreview           metadata
  - validateSkinFacePhoto                   Core Vision
└─────────────────────────────────────────┘

放弃过的方案:ImageReceiver 抽预览帧 + Core Vision 每帧检测。真机上预览卡死、no dirty buffer、双路 buffer 不稳定。预览只留 XComponent,实时人脸改 MetadataOutput


3. 取景框:让算法和 UI 是同一个框

页面上椭圆宽 78%,aspectRatio(0.8125),外层 aspectRatio(9/16)

// AiSkinCameraPageView.ets
Ellipse()
  .width('78%')
  .aspectRatio(0.8125)
  .stroke(this.frameHighlight ? '#4CAF50' : '#E8FFFFFF')

算法在 computeViewfinderRect 里:先按 9:16 居中裁切(等同 cover 可见区域),再取宽 78% 的矩形作为椭圆外接框:

// FaceDetectHelper.ets
function computeViewfinderRect(imageWidth: number, imageHeight: number): ViewfinderRect {
  const imageAspect = imageWidth / imageHeight;
  // ... 根据 imageAspect 算 cropLeft/cropTop/cropWidth/cropHeight
  const vfWidth = cropWidth * 0.78;
  const vfHeight = vfWidth / 0.8125;
  return {
    left: cropLeft + (cropWidth - vfWidth) / 2,
    top: cropTop + (cropHeight - vfHeight) / 2,
    width: vfWidth,
    height: vfHeight
  };
}

拍后成片也要和预览朝向一致,否则框对不上:

export async function normalizePixelMapForFaceCheck(
  pixelMap: image.PixelMap,
  isFrontCamera: boolean
): Promise<void> {
  const info = await pixelMap.getImageInfo();
  if (info.size.width > info.size.height) {
    await pixelMap.rotate(90);
  }
  if (isFrontCamera) {
    await pixelMap.flip(true, false);
  }
}

4. 实时检测:MetadataOutput

4.1 接入

// SkinCameraController.ets
this.metadataOutput = this.cameraManager.createMetadataOutput(metadataTypes);
this.metadataOutput.on('metadataObjectsAvailable', (err, arr) => {
  this.onMetadataObjectsAvailable(err, arr);
});

private readMetadataFaceRect(obj: camera.MetadataObject): SkinFaceRect | undefined {
  const box = obj.boundingBox;
  if (box === undefined) return undefined;
  return {
    left: Number(box.topLeftX ?? 0),
    top: Number(box.topLeftY ?? 0),
    width: Number(box.width ?? 0),
    height: Number(box.height ?? 0)
  };
}

回调里节流 200ms,调用 validateSkinFaceRectInPreview,结果写入 latestFaceCheck + sequence

4.2 UI 为什么不直接在回调里改 @State

相机回调线程里改 AppStorage / runOnUIThread 在部分机型上 UI 不刷新。改成 Controller 存快照 + 页面轮询

// AiSkinCameraPageView.ets
private startLivePolling(): void {
  this.livePollTimerId = setInterval(() => {
    this.pollLiveFaceState();
  }, 120);
}

private pollLiveFaceState(): void {
  const now = Date.now();
  if (this.capturing || now < this.liveTipMutedUntilMs) {
    return; // 拍摄中 / 弹窗期间不覆盖文案
  }
  const snapshot = this.cameraController.pollLiveFaceCheck();
  if (now - snapshot.updatedAt > 1200) {
    this.frameHighlight = false;
    this.tipMessage = this.defaultTip;
    return;
  }
  if (snapshot.sequence === this.lastAppliedLiveSeq) {
    return;
  }
  this.lastAppliedLiveSeq = snapshot.sequence;
  this.applyLiveFaceResult(snapshot.result!);
}

4.3 实时判断顺序

function validateMetadataCandidate(candidate: NormalizedFaceRect): FaceCheckResult {
  const viewfinder = computeViewfinderRect(candidate.imageWidth, candidate.imageHeight);
  const widthRatio = candidate.rect.width / viewfinder.width;

  // 1. 脸框宽高比(metadata 没有 yaw,只能近似正脸)
  if (!isMetadataFaceLikelyFrontal(candidate.rect)) {
    return { ok: false, message: '请保持正脸,露出完整面部' };
  }
  // 2. 居中
  if (!isMetadataFaceCenteredInViewfinder(candidate.rect, viewfinder)) {
    return { ok: false, message: '请将正脸对准取景框' };
  }
  // 3. 距离(真机标定 0.65 ~ 0.72)
  if (widthRatio < 0.65) {
    return { ok: false, message: '请靠近一些,让面部填满取景框' };
  }
  if (widthRatio > 0.72) {
    return { ok: false, message: '请远离一些,保持合适距离' };
  }
  return { ok: true, message: '' };
}

5. 踩坑与解法(带代码)

坑 1:widthRatio 一直是 0.001

日志:

widthRatio=0.000 heightRatio=0.001

原因: 部分机型 boundingBox0~1 归一化,代码当像素去除以取景框宽度(几百 px),比例永远接近 0,远近提示全乱。

解法: scaleMetadataRectToPixels

function scaleMetadataRectToPixels(
  imageWidth: number,
  imageHeight: number,
  rect: SkinFaceRect
): SkinFaceRect {
  const inUnitSquare =
    rect.left >= 0 && rect.top >= 0 &&
    rect.left + rect.width <= 1.05 &&
    rect.top + rect.height <= 1.05;
  if (inUnitSquare) {
    return {
      left: rect.left * imageWidth,
      top: rect.top * imageHeight,
      width: rect.width * imageWidth,
      height: rect.height * imageHeight
    };
  }
  // 兼容 0~1000 千分比 ...
  return rect;
}

修好后正常日志:img=1440x2560 vf=1123x1382 widthRatio=0.679


坑 2:日志里两套 vf

img=2560x1440 vf=632x778      widthRatio=2.861
img=1440x2560 vf=1123x1382    widthRatio=0.679

原因: 同时校验横屏原始坐标 + 旋转后竖屏坐标。

解法: 页面竖屏 9:16,只保留 normalizeMetadataFaceRect 转竖屏后再算:

function normalizeMetadataFaceRect(
  imageWidth: number,
  imageHeight: number,
  rect: SkinFaceRect
): NormalizedFaceRect {
  if (imageWidth <= imageHeight) {
    return { imageWidth, imageHeight, rect };
  }
  const rotated: SkinFaceRect = {
    left: imageHeight - rect.top - rect.height,
    top: rect.left,
    width: rect.height,
    height: rect.width
  };
  return { imageWidth: imageHeight, imageHeight: imageWidth, rect: rotated };
}

坑 3:metadata 绿了,拍后却说「请远离」

原因: 实时阈值 0.65~0.72(metadata 脸框),拍后 0.7~0.9(小程序 scale + Core Vision 脸框),两套框语义不同。

解法: 绿框拍摄时跳过拍后距离,距离以 metadata 为准:

// 拍摄时
const skipPhotoDistanceCheck = this.frameHighlight;
const check = await validateSkinFacePhoto(pixelMap, skipPhotoDistanceCheck);

// FaceDetectHelper.ets
if (!skipDistanceCheck) {
  const widthRatio = rect.width / viewfinder.width;
  if (widthRatio < 0.7) { /* 靠近 */ }
  if (widthRatio > 0.9) { /* 远离 */ }
}

坑 4:拍后失败提示一闪就没

原因: onCapturefinallycapturing=false 后,120ms 轮询立刻用 metadata 覆盖 Core Vision 文案。

解法: liveTipMutedUntilMs,失败弹窗期间轮询直接 return:

private pollLiveFaceState(): void {
  const now = Date.now();
  if (this.capturing || now < this.liveTipMutedUntilMs) {
    return;
  }
  // ...
}

private showPhotoCheckFailedDialog(reason: string): void {
  const dialogMessage = `图片不符合要求:${reason}\n请重新拍摄`;
  this.liveTipMutedUntilMs = Date.now() + 600000;
  this.getUIContext().showAlertDialog({
    title: '提示',
    message: dialogMessage,
    primaryButton: {
      value: '确定',
      action: () => {
        this.liveTipMutedUntilMs = 0;
        this.tipMessage = this.defaultTip;
      }
    }
  });
}

坑 5:遮住脸拍后仍通过

原因: 最初 Core Vision 只看 rect 在不在取景框里。

解法: validateVisionFaceQuality 读可选字段(有则校验):

function validateVisionFaceQuality(face: faceDetector.Face): FaceCheckResult {
  const extra = face as VisionFaceExtra;
  const score = extra.probability ?? extra.confidence;
  if (score !== undefined && score < 0.75) {
    return { ok: false, message: '图片不符合要求,请重新拍摄' };
  }
  if (extra.landmarks?.length > 0) {
    const n = extra.landmarks.filter(p => validPoint(p)).length;
    if (n < 5) {
      return { ok: false, message: '请保持正脸,露出完整面部' };
    }
  }
  const pose = extra.pose ?? extra.rotationAngle;
  if (pose?.yaw !== undefined && Math.abs(pose.yaw) > 18) {
    return { ok: false, message: '请保持正脸,露出完整面部' };
  }
  // pitch / roll 同理
  return { ok: true, message: '' };
}

注意:若机型 detect 只返回 rect,遮挡仍难拦,需真机打日志确认返回字段。


坑 6:必须绿框才能拍

Button('拍摄')
  .enabled(!this.capturing && this.previewStarted && this.frameHighlight)
  .onClick(() => this.onCapture());

private async onCapture(): Promise<void> {
  if (!this.frameHighlight) return;
  // ...
}

6. 拍后完整流程

private async onCapture(): Promise<void> {
  if (!this.frameHighlight) return;

  this.capturing = true;
  const skipPhotoDistanceCheck = this.frameHighlight;

  const uri = await this.cameraController.captureToUri();
  const pixelMap = await pixelMapFromUri(context, uri);
  const check = await validateSkinFacePhoto(pixelMap, skipPhotoDistanceCheck);
  await pixelMap.release();

  if (!check.ok) {
    this.frameHighlight = false;
    this.showPhotoCheckFailedDialog(check.message);
    return;
  }
  this.onCaptureSuccess(uri);
}

拍照存盘在 Controller 里:JPEG → PixelMap → 旋转/镜像 → 再 pack 成 jpg 到 cache。


7. 和小程序对比

项目微信小程序HarmonyOS 本实现
相机Megvii AICameraCameraKit 自研
实时插件内置MetadataOutput
拍后插件/业务faceDetector.detect
距离scale 0.7~0.9metadata 0.65~0.72,拍后跟 metadata
正脸插件metadata 宽高比 + Vision pose/landmarks

8. 小结

  1. 预览只用 XComponent,实时人脸用 Metadata,别和 ImageReceiver 硬刚。
  2. 坐标先确认是像素还是 0~1,再调阈值。
  3. 竖屏只认旋转后的一套 vf。
  4. 两套检测器距离阈值不要硬对齐。
  5. 线程相机回调别直接改 UI,轮询 + sequence 更稳。
  6. 拍后弹窗要带 check.message,并静音 metadata 轮询。

附录:测肤拍照完整源码(6 个文件)

SkinCameraAspect.ets

/** 竖屏界面预览区宽/高(9:16,即竖屏下的 16:9) */
export const SKIN_PREVIEW_UI_ASPECT = 9 / 16;

/** 相机 Profile 目标宽/高(横屏像素 16:9,如 1920×1080) */
export const SKIN_CAMERA_STREAM_ASPECT = 16 / 9;

SkinCameraLiveState.ets

import { FaceCheckResult } from './FaceDetectHelper';

export const SKIN_CAMERA_TIP_KEY = 'skin_camera_tip';
export const SKIN_CAMERA_FRAME_OK_KEY = 'skin_camera_frame_ok';
export const SKIN_CAMERA_LAST_CHECK_KEY = 'skin_camera_last_check_at';

let okStreak: number = 0;

export function initSkinCameraLiveStorage(defaultTip: string): void {
  okStreak = 0;
  AppStorage.setOrCreate(SKIN_CAMERA_TIP_KEY, defaultTip);
  AppStorage.setOrCreate(SKIN_CAMERA_FRAME_OK_KEY, false);
  AppStorage.setOrCreate(SKIN_CAMERA_LAST_CHECK_KEY, 0);
}

export function resetSkinCameraLiveStorage(): void {
  okStreak = 0;
}

/** 在预览分析线程调用,通过 AppStorage 驱动 @StorageLink 刷新 UI */
export function applySkinCameraLiveCheck(result: FaceCheckResult, defaultTip: string): void {
  AppStorage.set<number>(SKIN_CAMERA_LAST_CHECK_KEY, Date.now());
  if (result.ok) {
    okStreak++;
    AppStorage.set<boolean>(SKIN_CAMERA_FRAME_OK_KEY, true);
    AppStorage.set<string>(SKIN_CAMERA_TIP_KEY, '检测通过,请拍摄');
    return;
  }
  okStreak = 0;
  AppStorage.set<boolean>(SKIN_CAMERA_FRAME_OK_KEY, false);
  const msg = result.message.length > 0 ? result.message : defaultTip;
  AppStorage.set<string>(SKIN_CAMERA_TIP_KEY, msg);
}

FaceDetectHelper.ets

import { common } from '@kit.AbilityKit';
import { fileUri } from '@kit.CoreFileKit';
import { faceDetector } from '@kit.CoreVisionKit';
import { image } from '@kit.ImageKit';
import { SKIN_PREVIEW_UI_ASPECT } from './SkinCameraAspect';

export interface FaceCheckResult {
  ok: boolean;
  message: string;
}

export interface SkinFaceRect {
  left: number;
  top: number;
  width: number;
  height: number;
}

interface NormalizedFaceRect {
  imageWidth: number;
  imageHeight: number;
  rect: SkinFaceRect;
}

interface VisionPoint {
  x?: number;
  y?: number;
}

interface VisionPose {
  pitch?: number;
  yaw?: number;
  roll?: number;
}

interface VisionFaceExtra {
  landmarks?: VisionPoint[];
  pose?: VisionPose;
  rotationAngle?: VisionPose;
  probability?: number;
  confidence?: number;
}

/** 与 AiSkinCameraPageView 预览区一致(9:16),椭圆宽 78%、aspectRatio(0.8125) */
const PREVIEW_WIDTH_HEIGHT_RATIO = SKIN_PREVIEW_UI_ASPECT;
const VIEWFINDER_WIDTH_RATIO = 0.78;
const VIEWFINDER_WIDTH_HEIGHT_RATIO = 0.8125;
/** 与小程序 validFace.scale [0.7, 0.9]:脸宽相对取景框宽度 */
const MIN_FACE_WIDTH_IN_VIEWFINDER = 0.7;
const MAX_FACE_WIDTH_IN_VIEWFINDER = 0.9;
/** MetadataOutput 的框按页面竖屏坐标校准:正常取景时 widthRatio 约 0.68 */
const MIN_METADATA_FACE_WIDTH_IN_VIEWFINDER = 0.65;
const MAX_METADATA_FACE_WIDTH_IN_VIEWFINDER = 0.72;
/** MetadataOutput 无角度字段,先用脸框宽高形态过滤明显侧脸、仰头、俯拍或遮挡 */
const MIN_METADATA_FRONTAL_FACE_ASPECT = 0.30;
const MAX_METADATA_FRONTAL_FACE_ASPECT = 0.68;
const MIN_VISION_FACE_CONFIDENCE = 0.75;
const MAX_VISION_FACE_YAW = 18;
const MAX_VISION_FACE_PITCH = 18;
const MAX_VISION_FACE_ROLL = 18;
/** 人脸框允许超出取景框边缘的比例(相对取景框宽) */
const VIEWFINDER_EDGE_TOLERANCE = 0.06;
const METADATA_CENTER_TOLERANCE_X = 0.68;
const METADATA_CENTER_TOLERANCE_Y = 0.68;

let detectorReady: boolean = false;

async function ensureDetector(): Promise<void> {
  if (detectorReady) {
    return;
  }
  await faceDetector.init();
  detectorReady = true;
}

/** 相机页打开时预热,避免首帧检测过慢 */
export async function warmupFaceDetector(): Promise<void> {
  try {
    await ensureDetector();
  } catch (_e) {
  }
}

interface ViewfinderRect {
  left: number;
  top: number;
  width: number;
  height: number;
}

/**
 * 预览为居中裁切填满 9:16 区域;在成片上还原该可见区域,再取其中椭圆外接矩形作为取景框。
 */
function computeViewfinderRect(imageWidth: number, imageHeight: number): ViewfinderRect {
  const imageAspect = imageWidth / imageHeight;
  let cropLeft = 0;
  let cropTop = 0;
  let cropWidth = imageWidth;
  let cropHeight = imageHeight;

  if (imageAspect > PREVIEW_WIDTH_HEIGHT_RATIO) {
    cropHeight = imageHeight;
    cropWidth = imageHeight * PREVIEW_WIDTH_HEIGHT_RATIO;
    cropLeft = (imageWidth - cropWidth) / 2;
  } else if (imageAspect < PREVIEW_WIDTH_HEIGHT_RATIO) {
    cropWidth = imageWidth;
    cropHeight = imageWidth / PREVIEW_WIDTH_HEIGHT_RATIO;
    cropTop = (imageHeight - cropHeight) / 2;
  }

  const vfWidth = cropWidth * VIEWFINDER_WIDTH_RATIO;
  const vfHeight = vfWidth / VIEWFINDER_WIDTH_HEIGHT_RATIO;
  return {
    left: cropLeft + (cropWidth - vfWidth) / 2,
    top: cropTop + (cropHeight - vfHeight) / 2,
    width: vfWidth,
    height: vfHeight
  };
}

function isFaceInsideViewfinder(rect: SkinFaceRect, vf: ViewfinderRect): boolean {
  const tol = vf.width * VIEWFINDER_EDGE_TOLERANCE;
  const vfRight = vf.left + vf.width;
  const vfBottom = vf.top + vf.height;
  const faceRight = rect.left + rect.width;
  const faceBottom = rect.top + rect.height;
  const centerX = rect.left + rect.width / 2;
  const centerY = rect.top + rect.height / 2;

  if (centerX < vf.left + tol || centerX > vfRight - tol ||
    centerY < vf.top + tol || centerY > vfBottom - tol) {
    return false;
  }
  if (rect.left < vf.left - tol || faceRight > vfRight + tol ||
    rect.top < vf.top - tol || faceBottom > vfBottom + tol) {
    return false;
  }
  return true;
}

function isMetadataFaceCenteredInViewfinder(rect: SkinFaceRect, vf: ViewfinderRect): boolean {
  const centerX = rect.left + rect.width / 2;
  const centerY = rect.top + rect.height / 2;
  const vfCenterX = vf.left + vf.width / 2;
  const vfCenterY = vf.top + vf.height / 2;

  if (Math.abs(centerX - vfCenterX) > vf.width * METADATA_CENTER_TOLERANCE_X) {
    return false;
  }
  if (Math.abs(centerY - vfCenterY) > vf.height * METADATA_CENTER_TOLERANCE_Y) {
    return false;
  }
  return true;
}

function isMetadataFaceLikelyFrontal(rect: SkinFaceRect): boolean {
  if (rect.width <= 0 || rect.height <= 0) {
    return false;
  }
  const aspect = rect.width / rect.height;
  return aspect >= MIN_METADATA_FRONTAL_FACE_ASPECT && aspect <= MAX_METADATA_FRONTAL_FACE_ASPECT;
}

/** MetadataOutput 的 boundingBox 在部分机型上为 0~1 或 0~1000,需先换算为像素再与取景框比较 */
function scaleMetadataRectToPixels(
  imageWidth: number,
  imageHeight: number,
  rect: SkinFaceRect
): SkinFaceRect {
  if (imageWidth <= 0 || imageHeight <= 0) {
    return rect;
  }

  const inUnitSquare =
    rect.left >= 0 && rect.top >= 0 &&
    rect.left <= 1.05 && rect.top <= 1.05 &&
    rect.width > 0 && rect.height > 0 &&
    rect.left + rect.width <= 1.05 &&
    rect.top + rect.height <= 1.05;
  if (inUnitSquare) {
    return {
      left: rect.left * imageWidth,
      top: rect.top * imageHeight,
      width: rect.width * imageWidth,
      height: rect.height * imageHeight
    };
  }

  const inPermille =
    rect.left >= 0 && rect.top >= 0 &&
    rect.left + rect.width <= 1001 &&
    rect.top + rect.height <= 1001 &&
    (rect.width > 1 || rect.height > 1);
  if (inPermille && rect.width <= imageWidth && rect.height <= imageHeight) {
    return {
      left: rect.left * imageWidth / 1000,
      top: rect.top * imageHeight / 1000,
      width: rect.width * imageWidth / 1000,
      height: rect.height * imageHeight / 1000
    };
  }

  return rect;
}

function readFaceRect(face: faceDetector.Face): SkinFaceRect | undefined {
  const rect = face.rect;
  if (rect === undefined) {
    return undefined;
  }
  const left = Number(rect.left ?? 0);
  const top = Number(rect.top ?? 0);
  const width = Number(rect.width ?? 0);
  const height = Number(rect.height ?? 0);
  if (width <= 0 || height <= 0) {
    return undefined;
  }
  return { left, top, width, height };
}

function readVisionFaceExtra(face: faceDetector.Face): VisionFaceExtra {
  return face as VisionFaceExtra;
}

function isFiniteNumber(value: number | undefined): boolean {
  return value !== undefined && Number.isFinite(value);
}

function isValidVisionPoint(point: VisionPoint | undefined): boolean {
  return point !== undefined && isFiniteNumber(point.x) && isFiniteNumber(point.y);
}

function validateVisionFaceQuality(face: faceDetector.Face): FaceCheckResult {
  const extra = readVisionFaceExtra(face);
  const score = extra.probability ?? extra.confidence;
  if (score !== undefined && score < MIN_VISION_FACE_CONFIDENCE) {
    return { ok: false, message: '图片不符合要求,请重新拍摄' };
  }

  if (extra.landmarks !== undefined && extra.landmarks.length > 0) {
    const validLandmarkCount = extra.landmarks.filter((point: VisionPoint) => isValidVisionPoint(point)).length;
    if (validLandmarkCount < 5) {
      return { ok: false, message: '请保持正脸,露出完整面部' };
    }
  }

  const pose = extra.pose ?? extra.rotationAngle;
  if (pose !== undefined) {
    if (isFiniteNumber(pose.yaw) && Math.abs(pose.yaw!) > MAX_VISION_FACE_YAW) {
      return { ok: false, message: '请保持正脸,露出完整面部' };
    }
    if (isFiniteNumber(pose.pitch) && Math.abs(pose.pitch!) > MAX_VISION_FACE_PITCH) {
      return { ok: false, message: '请保持正脸,露出完整面部' };
    }
    if (isFiniteNumber(pose.roll) && Math.abs(pose.roll!) > MAX_VISION_FACE_ROLL) {
      return { ok: false, message: '请保持正脸,露出完整面部' };
    }
  }

  return { ok: true, message: '' };
}

function normalizeMetadataFaceRect(
  imageWidth: number,
  imageHeight: number,
  rect: SkinFaceRect
): NormalizedFaceRect {
  if (imageWidth <= imageHeight) {
    return { imageWidth, imageHeight, rect };
  }

  // MetadataOutput 通常返回横屏传感器坐标;转成页面竖屏坐标后再套用取景框规则。
  const rotated: SkinFaceRect = {
    left: imageHeight - rect.top - rect.height,
    top: rect.left,
    width: rect.height,
    height: rect.width
  };
  return { imageWidth: imageHeight, imageHeight: imageWidth, rect: rotated };
}

function validateMetadataCandidate(candidate: NormalizedFaceRect): FaceCheckResult {
  const viewfinder = computeViewfinderRect(candidate.imageWidth, candidate.imageHeight);
  const rect = candidate.rect;
  const widthRatio = rect.width / viewfinder.width;
  const heightRatio = rect.height / viewfinder.height;
  const tooFarByWidth = widthRatio > MAX_METADATA_FACE_WIDTH_IN_VIEWFINDER;

  console.info(
    `[SkinFace] rect=(${rect.left.toFixed(1)},${rect.top.toFixed(1)},` +
      `${rect.width.toFixed(1)},${rect.height.toFixed(1)}) ` +
      `vf=(${viewfinder.width.toFixed(0)}x${viewfinder.height.toFixed(0)}) ` +
      `img=${candidate.imageWidth}x${candidate.imageHeight} ` +
      `widthRatio=${widthRatio.toFixed(3)} heightRatio=${heightRatio.toFixed(3)}`
  );

  if (!isMetadataFaceLikelyFrontal(rect)) {
    return { ok: false, message: '请保持正脸,露出完整面部' };
  }

  if (!isMetadataFaceCenteredInViewfinder(rect, viewfinder)) {
    return { ok: false, message: '请将正脸对准取景框' };
  }

  if (widthRatio < MIN_METADATA_FACE_WIDTH_IN_VIEWFINDER) {
    return { ok: false, message: '请靠近一些,让面部填满取景框' };
  }
  if (tooFarByWidth) {
    return { ok: false, message: '请远离一些,保持合适距离' };
  }
  return { ok: true, message: '' };
}

export function validateSkinFaceRectInPreview(
  imageWidth: number,
  imageHeight: number,
  rects: SkinFaceRect[]
): FaceCheckResult {
  if (imageWidth <= 0 || imageHeight <= 0) {
    return { ok: false, message: '图片无效,请重新拍摄' };
  }
  if (rects.length === 0) {
    return { ok: false, message: '未检测到人脸,请将正脸对准取景框' };
  }
  if (rects.length > 1) {
    return { ok: false, message: '检测到多张人脸,请确保画面中只有本人' };
  }

  const pixelRect = scaleMetadataRectToPixels(imageWidth, imageHeight, rects[0]);
  const candidate = normalizeMetadataFaceRect(imageWidth, imageHeight, pixelRect);
  return validateMetadataCandidate(candidate);
}

/** 与成片处理一致:竖屏 + 前置去镜像,便于预览帧与拍照规则对齐 */
export async function normalizePixelMapForFaceCheck(
  pixelMap: image.PixelMap,
  isFrontCamera: boolean
): Promise<void> {
  const info = await pixelMap.getImageInfo();
  if (info.size.width > info.size.height) {
    await pixelMap.rotate(90);
  }
  if (isFrontCamera) {
    await pixelMap.flip(true, false);
  }
}

/**
 * 对拍照结果做人脸检测(Core Vision),判断是否适合测肤正脸照。
 */
export async function validateSkinFacePhoto(
  pixelMap: image.PixelMap,
  skipDistanceCheck: boolean = false
): Promise<FaceCheckResult> {
  const info = await pixelMap.getImageInfo();
  const imageWidth = info.size.width;
  const imageHeight = info.size.height;
  if (imageWidth <= 0 || imageHeight <= 0) {
    return { ok: false, message: '图片无效,请重新拍摄' };
  }

  try {
    await ensureDetector();
    const visionInfo: faceDetector.VisionInfo = { pixelMap: pixelMap };
    const faces: faceDetector.Face[] = await faceDetector.detect(visionInfo);

    if (faces.length === 0) {
      return { ok: false, message: '未检测到人脸,请将正脸对准取景框' };
    }
    if (faces.length > 1) {
      return { ok: false, message: '检测到多张人脸,请确保画面中只有本人' };
    }

    const rect = readFaceRect(faces[0]);
    if (rect === undefined) {
      return { ok: false, message: '人脸位置识别失败,请重新拍摄' };
    }
    const qualityResult = validateVisionFaceQuality(faces[0]);
    if (!qualityResult.ok) {
      return qualityResult;
    }

    const viewfinder = computeViewfinderRect(imageWidth, imageHeight);
    if (!isFaceInsideViewfinder(rect, viewfinder)) {
      return { ok: false, message: '请将正脸对准取景框' };
    }

    if (!skipDistanceCheck) {
      const widthRatio = rect.width / viewfinder.width;
      if (widthRatio < MIN_FACE_WIDTH_IN_VIEWFINDER) {
        return { ok: false, message: '请靠近一些,让面部填满取景框' };
      }
      if (widthRatio > MAX_FACE_WIDTH_IN_VIEWFINDER) {
        return { ok: false, message: '请远离一些,保持合适距离' };
      }
    }

    return { ok: true, message: '' };
  } catch (_e) {
    return { ok: false, message: '人脸检测失败,请重新拍摄' };
  }
}

/** 预览帧:先归一化朝向再检测 */
export async function validateSkinFacePreviewFrame(
  pixelMap: image.PixelMap,
  isFrontCamera: boolean
): Promise<FaceCheckResult> {
  await normalizePixelMapForFaceCheck(pixelMap, isFrontCamera);
  return validateSkinFacePhoto(pixelMap);
}

/** 转为 ImageKit 可用的本地绝对路径(internal://cache 或 file://) */
export function resolveSkinImagePath(context: common.UIAbilityContext, uri: string): string {
  if (uri.startsWith('internal://cache/')) {
    const fileName = uri.substring('internal://cache/'.length);
    return `${context.cacheDir}/${fileName}`;
  }
  if (uri.startsWith('file://')) {
    try {
      return new fileUri.FileUri(uri).path;
    } catch (_e) {
      return uri.replace('file://', '');
    }
  }
  return uri;
}

export async function pixelMapFromUri(context: common.UIAbilityContext, uri: string): Promise<image.PixelMap> {
  const path = resolveSkinImagePath(context, uri);
  const source = image.createImageSource(path);
  const pixelMap = await source.createPixelMap();
  await source.release();
  return pixelMap;
}

export async function releaseFaceDetector(): Promise<void> {
  if (!detectorReady) {
    return;
  }
  try {
    await faceDetector.release();
  } catch (_e) {
  }
  detectorReady = false;
}

SkinCameraController.ets

import { common } from '@kit.AbilityKit';
import { camera } from '@kit.CameraKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo, fileUri } from '@kit.CoreFileKit';
import { image } from '@kit.ImageKit';
import { SKIN_CAMERA_STREAM_ASPECT } from './SkinCameraAspect';
import { applySkinCameraLiveCheck } from './SkinCameraLiveState';
import {
  FaceCheckResult,
  normalizePixelMapForFaceCheck,
  SkinFaceRect,
  validateSkinFaceRectInPreview
} from './FaceDetectHelper';

const METADATA_ANALYZE_INTERVAL_MS = 200;
const DEFAULT_LIVE_TIP = '请将正脸对准取景框,摘掉眼镜露出额头';

export interface SkinLiveFaceSnapshot {
  result?: FaceCheckResult;
  updatedAt: number;
  sequence: number;
}

/**
 * 前置相机:XComponent 负责预览,MetadataOutput 返回实时人脸框。
 */
export class SkinCameraController {
  private context?: common.UIAbilityContext;
  private cameraManager?: camera.CameraManager;
  private session?: camera.PhotoSession;
  private input?: camera.CameraInput;
  private displayPreviewOutput?: camera.PreviewOutput;
  private metadataOutput?: camera.MetadataOutput;
  private photoOutput?: camera.PhotoOutput;
  private isFrontCamera: boolean = false;
  private liveAnalyzerReady: boolean = false;
  private metadataFrameSize: image.Size = { width: 1280, height: 720 };
  private lastMetadataAnalyzeMs: number = 0;
  private latestFaceCheck?: FaceCheckResult;
  private latestFaceCheckAt: number = 0;
  private latestFaceCheckSeq: number = 0;
  private captureResolve?: (uri: string) => void;
  private captureReject?: (reason: Error) => void;

  isLiveAnalyzerReady(): boolean {
    return this.liveAnalyzerReady;
  }

  pollLiveFaceCheck(): SkinLiveFaceSnapshot {
    return {
      result: this.latestFaceCheck,
      updatedAt: this.latestFaceCheckAt,
      sequence: this.latestFaceCheckSeq
    };
  }

  private static delay(ms: number): Promise<void> {
    return new Promise<void>((resolve: () => void) => {
      setTimeout(resolve, ms);
    });
  }

  private static pick16x9Profile(profiles: Array<camera.Profile>, preferSmallest: boolean): camera.Profile {
    if (profiles.length === 0) {
      throw new Error('无可用分辨率');
    }
    let best = profiles[0];
    let bestScore = Number.MAX_VALUE;
    let bestPixels = preferSmallest ? Number.MAX_VALUE : 0;
    for (const profile of profiles) {
      const w = profile.size.width;
      const h = profile.size.height;
      if (w <= 0 || h <= 0) {
        continue;
      }
      const ratio = w / h;
      const diff = Math.min(
        Math.abs(ratio - SKIN_CAMERA_STREAM_ASPECT),
        Math.abs(ratio - 1 / SKIN_CAMERA_STREAM_ASPECT)
      );
      const pixels = w * h;
      if (diff < bestScore - 0.01) {
        bestScore = diff;
        bestPixels = pixels;
        best = profile;
        continue;
      }
      if (Math.abs(diff - bestScore) < 0.01) {
        if (preferSmallest && pixels < bestPixels) {
          bestPixels = pixels;
          best = profile;
        } else if (!preferSmallest && pixels > bestPixels) {
          bestPixels = pixels;
          best = profile;
        }
      }
    }
    return best;
  }

  async start(
    context: common.UIAbilityContext,
    displaySurfaceId: string,
    surfaceController: XComponentController
  ): Promise<void> {
    await this.release();
    this.context = context;
    this.cameraManager = camera.getCameraManager(context);
    const devices = this.cameraManager.getSupportedCameras();
    const front = devices.find((d: camera.CameraDevice) =>
      d.cameraPosition === camera.CameraPosition.CAMERA_POSITION_FRONT);
    const device = front ?? devices[0];
    if (device === undefined) {
      throw new Error('未找到可用相机');
    }
    this.isFrontCamera = device.cameraPosition === camera.CameraPosition.CAMERA_POSITION_FRONT;

    const capability = this.cameraManager.getSupportedOutputCapability(device);
    if (capability.previewProfiles.length === 0 || capability.photoProfiles.length === 0) {
      throw new Error('相机能力不支持预览或拍照');
    }
    const displayProfile = SkinCameraController.pick16x9Profile(capability.previewProfiles, false);
    const photoProfile = SkinCameraController.pick16x9Profile(capability.photoProfiles, false);

    surfaceController.setXComponentSurfaceSize({
      surfaceWidth: displayProfile.size.width,
      surfaceHeight: displayProfile.size.height
    });

    this.input = this.cameraManager.createCameraInput(device);
    await this.input.open();

    this.displayPreviewOutput = this.cameraManager.createPreviewOutput(displayProfile, displaySurfaceId);
    this.setupMetadataOutput(capability, displayProfile);

    this.photoOutput = this.cameraManager.createPhotoOutput(photoProfile);
    this.photoOutput.on('photoAvailable', (errCode: BusinessError, photo: camera.Photo) => {
      if (this.captureResolve === undefined) {
        return;
      }
      if (errCode !== undefined && errCode.code !== 0) {
        this.captureReject?.(new Error(errCode.message || '拍照失败'));
        this.clearCaptureHandlers();
        return;
      }
      if (photo === undefined || this.context === undefined) {
        this.captureReject?.(new Error('未获取到照片'));
        this.clearCaptureHandlers();
        return;
      }
      SkinCameraController.photoToUri(this.context, photo, this.isFrontCamera).then((uri: string) => {
        this.captureResolve?.(uri);
        this.clearCaptureHandlers();
      }).catch((e: object) => {
        this.captureReject?.(new Error(String(e)));
        this.clearCaptureHandlers();
      });
    });

    this.session = this.cameraManager.createSession(camera.SceneMode.NORMAL_PHOTO) as camera.PhotoSession;
    this.session.beginConfig();
    this.session.addInput(this.input);
    this.session.addOutput(this.displayPreviewOutput);
    if (this.metadataOutput !== undefined) {
      try {
        this.session.addOutput(this.metadataOutput);
      } catch (e) {
        console.info(`[SkinCamera] metadata output skipped: ${String(e)}`);
        this.liveAnalyzerReady = false;
        try {
          await this.metadataOutput.release();
        } catch (_e) {
        }
        this.metadataOutput = undefined;
      }
    }
    this.session.addOutput(this.photoOutput);
    await this.session.commitConfig();
    await SkinCameraController.delay(120);
    await this.session.start();
    console.info(`[SkinCamera] started display=1 metadata=${this.liveAnalyzerReady ? 1 : 0}`);
  }

  private clearCaptureHandlers(): void {
    this.captureResolve = undefined;
    this.captureReject = undefined;
  }

  private setupMetadataOutput(
    capability: camera.CameraOutputCapability,
    previewProfile: camera.Profile
  ): void {
    if (this.cameraManager === undefined) {
      return;
    }
    try {
      const metadataTypes = capability.supportedMetadataObjectTypes;
      if (metadataTypes.length === 0) {
        this.liveAnalyzerReady = false;
        console.info('[SkinCamera] metadata FACE_DETECTION unsupported');
        return;
      }
      this.metadataFrameSize = {
        width: previewProfile.size.width,
        height: previewProfile.size.height
      };
      this.metadataOutput = this.cameraManager.createMetadataOutput(metadataTypes);
      this.metadataOutput.on('metadataObjectsAvailable',
        (err: BusinessError, metadataObjectArr: Array<camera.MetadataObject>) => {
          this.onMetadataObjectsAvailable(err, metadataObjectArr);
        });
      this.liveAnalyzerReady = true;
      console.info(`[SkinCamera] metadata ready ${this.metadataFrameSize.width}x${this.metadataFrameSize.height}`);
    } catch (e) {
      this.metadataOutput = undefined;
      this.liveAnalyzerReady = false;
      console.info(`[SkinCamera] metadata init failed: ${String(e)}`);
    }
  }

  private onMetadataObjectsAvailable(
    err: BusinessError,
    metadataObjectArr: Array<camera.MetadataObject>
  ): void {
    if (err !== undefined && err.code !== 0) {
      return;
    }
    const now = Date.now();
    if (now - this.lastMetadataAnalyzeMs < METADATA_ANALYZE_INTERVAL_MS) {
      return;
    }
    this.lastMetadataAnalyzeMs = now;
    const rects: SkinFaceRect[] = [];
    metadataObjectArr.forEach((obj: camera.MetadataObject) => {
      const rect = this.readMetadataFaceRect(obj);
      if (rect !== undefined) {
        rects.push(rect);
      }
    });
    const result = validateSkinFaceRectInPreview(
      this.metadataFrameSize.width,
      this.metadataFrameSize.height,
      rects
    );
    this.latestFaceCheck = result;
    this.latestFaceCheckAt = now;
    this.latestFaceCheckSeq++;
    applySkinCameraLiveCheck(result, DEFAULT_LIVE_TIP);
  }

  private readMetadataFaceRect(obj: camera.MetadataObject): SkinFaceRect | undefined {
    const box = obj.boundingBox;
    if (box === undefined) {
      return undefined;
    }
    const left = Number(box.topLeftX ?? 0);
    const top = Number(box.topLeftY ?? 0);
    const width = Number(box.width ?? 0);
    const height = Number(box.height ?? 0);
    if (width <= 0 || height <= 0) {
      return undefined;
    }
    return { left, top, width, height };
  }

  async captureToUri(): Promise<string> {
    if (this.photoOutput === undefined || this.context === undefined) {
      throw new Error('相机未就绪');
    }
    return new Promise<string>((resolve: (uri: string) => void, reject: (reason: Error) => void) => {
      this.captureResolve = resolve;
      this.captureReject = reject;
      const setting: camera.PhotoCaptureSetting = {
        quality: camera.QualityLevel.QUALITY_LEVEL_HIGH,
        rotation: camera.ImageRotation.ROTATION_0
      };
      this.photoOutput!.capture(setting, (err: BusinessError) => {
        if (err !== undefined && err.code !== 0) {
          reject(new Error(err.message || '拍照失败'));
          this.clearCaptureHandlers();
        }
      });
    });
  }

  private static async photoToUri(
    context: common.UIAbilityContext,
    photo: camera.Photo,
    isFrontCamera: boolean
  ): Promise<string> {
    const mainImage = photo.main;
    const component = await mainImage.getComponent(image.ComponentType.JPEG);
    if (component === undefined || component.byteBuffer === undefined) {
      await mainImage.release();
      throw new Error('无法读取照片数据');
    }
    const buffer = component.byteBuffer;
    await mainImage.release();

    const decodeOpts: image.DecodingOptions = { editable: true };
    const source = image.createImageSource(buffer);
    const pixelMap = await source.createPixelMap(decodeOpts);
    await source.release();

    await normalizePixelMapForFaceCheck(pixelMap, isFrontCamera);

    const packer = image.createImagePacker();
    const packOpts: image.PackingOption = { format: 'image/jpeg', quality: 92 };
    const packed = await packer.packing(pixelMap, packOpts);
    await pixelMap.release();

    const fileName = `skin_capture_${Date.now()}.jpg`;
    const path = `${context.cacheDir}/${fileName}`;
    const file = fileIo.openSync(path, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
    fileIo.writeSync(file.fd, packed);
    fileIo.closeSync(file.fd);
    return fileUri.getUriFromPath(path);
  }

  async release(): Promise<void> {
    try {
      if (this.session !== undefined) {
        await this.session.stop();
      }
    } catch (_e) {
    }
    try {
      await this.displayPreviewOutput?.release();
    } catch (_e) {
    }
    try {
      await this.metadataOutput?.release();
    } catch (_e) {
    }
    try {
      await this.photoOutput?.release();
    } catch (_e) {
    }
    try {
      await this.input?.close();
    } catch (_e) {
    }
    this.clearCaptureHandlers();
    this.liveAnalyzerReady = false;
    this.latestFaceCheck = undefined;
    this.latestFaceCheckAt = 0;
    this.latestFaceCheckSeq = 0;
    this.session = undefined;
    this.displayPreviewOutput = undefined;
    this.metadataOutput = undefined;
    this.photoOutput = undefined;
    this.input = undefined;
    this.cameraManager = undefined;
    this.context = undefined;
    this.isFrontCamera = false;
  }
}

AiSkinCameraPageView.ets

import { common } from '@kit.AbilityKit';
import { SkinBackHeader } from './SkinSharedComponents';
import { SkinCameraController, SkinLiveFaceSnapshot } from '../camera/SkinCameraController';
import {
  FaceCheckResult,
  pixelMapFromUri,
  releaseFaceDetector,
  validateSkinFacePhoto,
  warmupFaceDetector
} from '../camera/FaceDetectHelper';
import { SKIN_PREVIEW_UI_ASPECT } from '../camera/SkinCameraAspect';

@Component
export struct AiSkinCameraPageView {
  onBack: () => void = () => {};
  onCaptureSuccess: (uri: string) => void = (_uri: string) => {};

  @State private tipMessage: string = '请将正脸对准取景框,摘掉眼镜露出额头';
  @State private frameHighlight: boolean = false;
  @State private capturing: boolean = false;
  @State private cameraError: string = '';
  @State private previewStarted: boolean = false;

  private readonly defaultTip: string = '请将正脸对准取景框,摘掉眼镜露出额头';
  private xComponentController: XComponentController = new XComponentController();
  private cameraController: SkinCameraController = new SkinCameraController();
  private livePollTimerId: number = -1;
  private lastAppliedLiveSeq: number = -1;
  private liveTipMutedUntilMs: number = 0;
  private photoCheckDialogVisible: boolean = false;

  aboutToAppear(): void {
    this.resetLiveState();
  }

  aboutToDisappear(): void {
    this.stopLivePolling();
    this.cameraController.release().catch(() => {});
    releaseFaceDetector().catch(() => {});
  }

  private resetLiveState(): void {
    this.lastAppliedLiveSeq = -1;
    this.liveTipMutedUntilMs = 0;
    this.photoCheckDialogVisible = false;
    this.frameHighlight = false;
    this.tipMessage = this.defaultTip;
  }

  private startLivePolling(): void {
    this.stopLivePolling();
    this.livePollTimerId = setInterval(() => {
      this.pollLiveFaceState();
    }, 120);
  }

  private stopLivePolling(): void {
    if (this.livePollTimerId >= 0) {
      clearInterval(this.livePollTimerId);
      this.livePollTimerId = -1;
    }
  }

  private pollLiveFaceState(): void {
    const now = Date.now();
    if (this.capturing || now < this.liveTipMutedUntilMs) {
      return;
    }
    const snapshot: SkinLiveFaceSnapshot = this.cameraController.pollLiveFaceCheck();
    if (snapshot.updatedAt <= 0) {
      return;
    }
    if (now - snapshot.updatedAt > 1200) {
      this.frameHighlight = false;
      this.tipMessage = this.defaultTip;
      return;
    }
    if (snapshot.sequence === this.lastAppliedLiveSeq || snapshot.result === undefined) {
      return;
    }
    this.lastAppliedLiveSeq = snapshot.sequence;
    this.applyLiveFaceResult(snapshot.result);
  }

  private applyLiveFaceResult(result: FaceCheckResult): void {
    if (result.ok) {
      this.frameHighlight = true;
      this.tipMessage = '请拍摄';
      return;
    }
    this.frameHighlight = false;
    this.tipMessage = result.message.length > 0 ? result.message : this.defaultTip;
  }

  private hostContext(): common.UIAbilityContext | undefined {
    try {
      return this.getUIContext().getHostContext() as common.UIAbilityContext;
    } catch (_e) {
      return undefined;
    }
  }

  private showPhotoCheckFailedDialog(reason: string): void {
    if (this.photoCheckDialogVisible) {
      return;
    }
    const failureReason = reason.length > 0 ? reason : '请保持正脸、露出完整面部并对准取景框';
    const dialogMessage = `图片不符合要求:${failureReason}\n请重新拍摄`;
    this.photoCheckDialogVisible = true;
    this.liveTipMutedUntilMs = Date.now() + 600000;
    try {
      this.getUIContext().showAlertDialog({
        title: '提示',
        message: dialogMessage,
        primaryButton: {
          value: '确定',
          action: () => {
            this.photoCheckDialogVisible = false;
            this.liveTipMutedUntilMs = 0;
            this.tipMessage = this.defaultTip;
          }
        }
      });
    } catch (_e) {
      this.photoCheckDialogVisible = false;
      this.liveTipMutedUntilMs = Date.now() + 3000;
      this.tipMessage = dialogMessage;
    }
  }

  private async startPreview(): Promise<void> {
    const context = this.hostContext();
    const surfaceId = this.xComponentController.getXComponentSurfaceId();
    if (context === undefined || surfaceId.length === 0) {
      this.cameraError = '相机初始化失败';
      return;
    }
    try {
      this.resetLiveState();
      await warmupFaceDetector();
      await this.cameraController.start(context, surfaceId, this.xComponentController);
      this.previewStarted = true;
      this.startLivePolling();
      if (!this.cameraController.isLiveAnalyzerReady()) {
        this.tipMessage = '实时检测暂不可用,可直接拍摄';
      }
      this.cameraError = '';
    } catch (e) {
      this.previewStarted = false;
      this.stopLivePolling();
      this.cameraError = `相机打开失败:${String(e)}`;
    }
  }

  private async onCapture(): Promise<void> {
    if (this.capturing || this.cameraError.length > 0 || !this.previewStarted || !this.frameHighlight) {
      return;
    }
    this.capturing = true;
    this.liveTipMutedUntilMs = 0;
    const skipPhotoDistanceCheck = this.frameHighlight;
    this.tipMessage = '正在拍摄…';
    try {
      const context = this.hostContext();
      if (context === undefined) {
        this.tipMessage = '无法获取应用上下文';
        return;
      }
      const uri = await this.cameraController.captureToUri();
      this.tipMessage = '正在检测人脸…';
      const pixelMap = await pixelMapFromUri(context, uri);
      const check = await validateSkinFacePhoto(pixelMap, skipPhotoDistanceCheck);
      await pixelMap.release();
      if (!check.ok) {
        this.tipMessage = check.message.length > 0 ? check.message : '图片不符合要求,请重新拍摄';
        this.frameHighlight = false;
        this.showPhotoCheckFailedDialog(check.message);
        return;
      }
      this.frameHighlight = true;
      this.onCaptureSuccess(uri);
    } catch (e) {
      this.tipMessage = `拍摄失败:${String(e)}`;
      this.frameHighlight = false;
      this.liveTipMutedUntilMs = Date.now() + 3000;
    } finally {
      this.capturing = false;
    }
  }

  build() {
    Stack() {
      Column() {
        SkinBackHeader({ title: '', transparent: true, onBack: this.onBack });
        Stack() {
          if (this.cameraError.length > 0) {
            Column() {
              Text(this.cameraError)
                .fontSize(14)
                .fontColor('#FFFFFF')
                .textAlign(TextAlign.Center)
                .padding(24)
            }
            .width('100%')
            .height('100%')
            .backgroundColor('#1A1A1A')
            .justifyContent(FlexAlign.Center)
          } else {
            Stack({ alignContent: Alignment.Center }) {
              Stack() {
                XComponent({
                  id: 'skin_camera_preview',
                  type: XComponentType.SURFACE,
                  controller: this.xComponentController
                })
                  .width('100%')
                  .height('100%')
                  .onLoad(() => {
                    this.startPreview().catch(() => {});
                  })

                Stack({ alignContent: Alignment.Center }) {
                  Ellipse()
                    .width('78%')
                    .aspectRatio(0.8125)
                    .fill(Color.Transparent)
                    .stroke(this.frameHighlight ? '#4CAF50' : '#E8FFFFFF')
                    .strokeWidth(3)
                }
                .width('100%')
                .height('100%')
                .hitTestBehavior(HitTestMode.Transparent)

                Column() {
                  Text(this.tipMessage)
                    .fontSize(13)
                    .fontColor('#FFFFFF')
                    .textAlign(TextAlign.Center)
                    .padding({ left: 16, right: 16, top: 8, bottom: 8 })
                    .backgroundColor('#66000000')
                    .borderRadius(8)
                }
                .width('100%')
                .alignItems(HorizontalAlign.Center)
                .position({ x: 0, y: 12 })
                .hitTestBehavior(HitTestMode.Transparent)
              }
              .width('100%')
              .aspectRatio(SKIN_PREVIEW_UI_ASPECT)
              .constraintSize({ maxWidth: '100%', maxHeight: '100%' })
              .clip(true)
            }
            .width('100%')
            .height('100%')
          }
        }
        .layoutWeight(1)
        .width('100%')

        Button(this.capturing ? '拍摄中…' : '拍摄')
          .width(72)
          .height(72)
          .borderRadius(36)
          .backgroundColor(this.capturing || !this.frameHighlight ? '#99FFFFFF' : '#FFFFFF')
          .fontColor('#1E1F41')
          .margin({ bottom: 40, top: 12 })
          .enabled(!this.capturing && this.cameraError.length === 0 && this.previewStarted && this.frameHighlight)
          .onClick(() => {
            this.onCapture().catch(() => {});
          })
      }
      .width('100%')
      .height('100%')
      .backgroundColor('#000000')
    }
    .width('100%')
    .height('100%')
  }
}

AiSkinCamera.ets

import { router } from '@kit.ArkUI';
import { AiSkinCameraPageView } from '../features/skin/pages/AiSkinCameraPageView';
import { Routes } from '../core/app/Routes';
import { sessionStore } from '../core/app/SessionStoreModel';
import { onRoutePageAppear, onRoutePageDisappear, onRoutePageShow } from './PageRouteHelpers';

@Entry
@Component
struct AiSkinCamera {
  aboutToAppear(): void {
    onRoutePageAppear('AiSkinCamera', this.getUIContext());
  }

  aboutToDisappear(): void {
    onRoutePageDisappear();
  }

  onPageShow(): void {
    onRoutePageShow();
  }

  onBackPress(): boolean {
    router.back();
    return true;
  }

  build() {
    Stack() {
      AiSkinCameraPageView({
        onBack: () => {
          router.back();
        },
        onCaptureSuccess: (uri: string) => {
          sessionStore.selectedSkinImageUri = uri;
          sessionStore.skinQuestionTip = '';
          router.replaceUrl({ url: Routes.AI_PHOTO_CONFIRM });
        }
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#000000')
    .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
  }
}