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
原因: 部分机型 boundingBox 是 0~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:拍后失败提示一闪就没
原因: onCapture 的 finally 里 capturing=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. 小结
- 预览只用 XComponent,实时人脸用 Metadata,别和 ImageReceiver 硬刚。
- 坐标先确认是像素还是 0~1,再调阈值。
- 竖屏只认旋转后的一套 vf。
- 两套检测器距离阈值不要硬对齐。
- 线程相机回调别直接改 UI,轮询 + sequence 更稳。
- 拍后 弹窗要带
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])
}
}