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

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

环境:HarmonyOS 5.x / ArkTS / CameraKit + CoreVisionKit

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


1. 要做什么

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

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

小程序里一行配置:

javascript 复制代码
validFace: { scale: [0.7, 0.9] }

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


2. 最终架构

swift 复制代码
┌─────────────────────────────────────────┐
│  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)

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

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

typescript 复制代码
// 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
  };
}

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

typescript 复制代码
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 接入

typescript 复制代码
// 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 存快照 + 页面轮询

typescript 复制代码
// 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 实时判断顺序

typescript 复制代码
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

日志:

text 复制代码
widthRatio=0.000 heightRatio=0.001

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

解法: scaleMetadataRectToPixels

typescript 复制代码
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

text 复制代码
img=2560x1440 vf=632x778      widthRatio=2.861
img=1440x2560 vf=1123x1382    widthRatio=0.679

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

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

typescript 复制代码
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 为准:

typescript 复制代码
// 拍摄时
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:

typescript 复制代码
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 读可选字段(有则校验):

typescript 复制代码
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:必须绿框才能拍

typescript 复制代码
Button('拍摄')
  .enabled(!this.capturing && this.previewStarted && this.frameHighlight)
  .onClick(() => this.onCapture());

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

6. 拍后完整流程

typescript 复制代码
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 AICamera CameraKit 自研
实时 插件内置 MetadataOutput
拍后 插件/业务 faceDetector.detect
距离 scale 0.7~0.9 metadata 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

typescript 复制代码
/** 竖屏界面预览区宽/高(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

typescript 复制代码
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

typescript 复制代码
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

typescript 复制代码
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

typescript 复制代码
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

typescript 复制代码
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])
  }
}
相关推荐
画画的阿飞8 小时前
里程碑三:基于 Vue3 领域模型架构建设
前端·node.js
玉米Yvmi8 小时前
大文件上传的基石:切片上传原理与实现详解
前端·javascript·面试
用户4099322502129 小时前
Composable的命名规矩和参数约定,别再瞎写了
前端·javascript·后端
用户游民9 小时前
Flutter Provider原理以及用法
前端·flutter
Rust研习社9 小时前
告别环境混乱!使用 mise 管理你的开发环境
前端·后端·rust
小小荧9 小时前
Vue Native多分支迭代,Vue跨端原生生态迎来革新
前端·javascript·vue.js
EntyIU9 小时前
uv工程化项目指南
前端·python·uv
WebGirl9 小时前
如何在VS code中添加SKill
前端
marsh02069 小时前
49 openclaw故障排查:系统异常时的诊断方法
服务器·前端·青少年编程·ai·php·技术美术