
前言
在前两版文章中,我们分别探索了端侧图像分类与 MindSpore Lite 推理引擎、端侧语音识别合成以及意图框架的跨应用流转能力。本篇将视线转向另一个核心技术领域------人脸检测与活体识别(Face Detection & Liveness Detection)。
这两项能力是身份认证、考勤系统、门禁管理等场景的基石。HarmonyOS NEXT 通过 @ohos.visionKit 提供了完整的计算机视觉接口,其中 FaceDetector 模块支持图片静态人脸检测与相机流实时人脸检测两类模式。结合活体检测算法,开发者可以构建从"看见人脸"到"确认是真人"的完整闭环。
本文将深入剖析 FaceDetector 的完整封装、相机预览流的人脸检测集成,以及静默活体与动作活体双轨检测的实现路径。所有代码基于 HarmonyOS NEXT API 12+ 编写,可直接移植运行。
一、架构总览与能力边界
1.1 visionKit 模块定位
@ohos.visionKit 是 HarmonyOS 提供的视觉能力集,其核心子模块包括:
| 子模块 | 能力描述 | 适用场景 |
|---|---|---|
FaceDetector |
人脸检测、关键点定位、人脸属性(角度/质量) | 身份验证、考勤、美颜 |
ImgProcessor |
图像预处理(缩放、裁剪、旋转、色彩空间转换) | 预处理流水线 |
VisionMgr |
视觉能力统一管理器,协调多视觉算法协同 | 复杂视觉场景 |
FaceDetector 是本篇文章的主角。它原生支持两种输入源:单帧图片 (detectFace(image))和相机流 (startFaceDetection() / stopFaceDetection())。对于活体检测,则需要开发者在上层结合多帧策略与算法逻辑自行构建。
1.2 能力边界与注意事项
FaceDetector 本身不直接输出"活体/非活体"二分类结果,它提供的是:
- 人脸框(Rect)坐标
- 106 个面部关键点坐标(Landmark)
- 人脸旋转角(pitch/yaw/roll)
- 人脸追踪 ID(多脸场景下区分不同人)
- 检测置信度
活体判断需要开发者利用上述信息,结合多帧时序分析或动作指令来综合决策。这一点非常关键------理解 FaceDetector 的输出边界,是构建可靠活体系统的第一步。
二、FaceDetector 封装类:从初始化到回调
2.1 整体设计思路
在实际项目中,我们通常不会直接在各页面散落调用代码,而是将 FaceDetector 封装为一个可复用的 FaceDetectionManager 单例类。该类负责:
- 引擎初始化与销毁
- 图片模式检测
- 相机流模式检测与事件回调
- 检测结果的数据结构化输出
封装的核心挑战在于:HarmonyOS 的异步回调机制与 TypeScript 类体系的衔接 。我们通过 Emitter 事件订阅机制,将检测结果以事件流的方式暴露给上层 UI。
2.2 完整封装代码
typescript
// FaceDetectionManager.ets
// 基于 @ohos.visionKit FaceDetector 的完整封装,支持图片检测与相机流检测
import { faceDetector } from '@kit.VisionKit';
import { image } from '@kit.ImageKit';
import { camera } from '@kit.CameraKit';
import { BusinessError } from '@kit.BasicServicesKit';
// ──────────────────────────────────────────────
// 数据结构定义
// ──────────────────────────────────────────────
/**
* 单个人脸检测结果的结构化封装
*/
export interface FaceDetectionResult {
/** 全局唯一追踪 ID,用于多脸场景下的身份一致性 */
faceId: number;
/** 人脸框,相对于原图尺寸 */
rect: Rect;
/** 106 个关键点坐标数组 */
landmarks: Point[];
/** 旋转角度(俯仰角) */
pitchAngle: number;
/** 旋转角度(偏航角) */
yawAngle: number;
/** 旋转角度(翻滚角) */
rollAngle: number;
/** 检测置信度(0.0 ~ 1.0) */
confidence: number;
}
export interface Rect {
left: number;
top: number;
width: number;
height: number;
}
export interface Point {
x: number;
y: number;
}
// ──────────────────────────────────────────────
// 核心管理器
// ──────────────────────────────────────────────
/**
* 人脸检测管理器
* 采用单例模式,确保整个应用生命周期内只创建一个 FaceDetector 实例
*/
export class FaceDetectionManager {
private static instance: FaceDetectionManager | null = null;
private detector: faceDetector.FaceDetector | null = null;
private isInitialized: boolean = false;
private isCameraMode: boolean = false;
// 相机流检测订阅器
private faceDetectionListener: faceDetector.FaceDetectionListener | null = null;
// 事件订阅 ID,用于解绑
private eventSubscribeId: number = -1;
private constructor() {}
static getInstance(): FaceDetectionManager {
if (!FaceDetectionManager.instance) {
FaceDetectionManager.instance = new FaceDetectionManager();
}
return FaceDetectionManager.instance;
}
/**
* 初始化 FaceDetector 引擎
* 必须在使用检测能力之前调用,建议在 AppAbility 的 onCreate 中触发
*/
async initialize(): Promise<void> {
if (this.isInitialized) {
console.info('[FaceDetection] Already initialized, skip.');
return;
}
try {
// 创建 FaceDetector 实例,传入检测模式配置
// DetectMode.VIDEO 表示实时相机流模式,DetectMode.PICTURE 表示图片模式
// 两者可同时开启
this.detector = await faceDetector.createFaceDetector(
faceDetector.DetectMode.VIDEO | faceDetector.DetectMode.PICTURE
);
this.isInitialized = true;
console.info('[FaceDetection] FaceDetector initialized successfully.');
} catch (err) {
const error = err as BusinessError;
console.error(`[FaceDetection] Init failed: ${error.code} - ${error.message}`);
throw new Error(`FaceDetector 初始化失败: ${error.message}`);
}
}
/**
* 销毁 FaceDetector,释放底层资源
* 应用退出或不再需要检测能力时调用
*/
async release(): Promise<void> {
if (this.detector) {
// 停止相机流检测
if (this.isCameraMode) {
this.stopCameraDetection();
}
try {
await this.detector.release();
console.info('[FaceDetection] FaceDetector released.');
} catch (err) {
console.error(`[FaceDetection] Release error: ${(err as BusinessError).message}`);
}
this.detector = null;
this.isInitialized = false;
}
}
// ──────────────────────────────────────────────
// 图片模式检测
// ──────────────────────────────────────────────
/**
* 对单张图片进行人脸检测
* 适用于:注册人脸照片、身份证照片核验、上传图片身份验证等场景
*
* @param imageSource 来自 @kit.ImageKit 的 PixelMap 图片源
* @returns 检测到的人脸结果数组,未检测到返回空数组
*/
async detectFromImage(imageSource: image.PixelMap): Promise<FaceDetectionResult[]> {
if (!this.isInitialized || !this.detector) {
throw new Error('FaceDetector 未初始化,请先调用 initialize()');
}
try {
// detectFace 接受 PixelMap,返回 FaceDetectionResult[]
const rawResults = await this.detector.detectFace(imageSource);
return rawResults.map(raw => this.mapToFaceResult(raw));
} catch (err) {
const error = err as BusinessError;
console.error(`[FaceDetection] detectFromImage failed: ${error.code} - ${error.message}`);
return [];
}
}
// ──────────────────────────────────────────────
// 相机流模式检测
// ──────────────────────────────────────────────
/**
* 启动相机流人脸检测
* 将相机的每一帧自动送入 FaceDetector,无需手动循环调用
*
* @param listener 检测结果回调,返回 FaceDetectionResult[]
*/
startCameraDetection(
listener: (results: FaceDetectionResult[]) => void
): void {
if (!this.isInitialized || !this.detector) {
throw new Error('FaceDetector 未初始化,请先调用 initialize()');
}
if (this.isCameraMode) {
console.warn('[FaceDetection] Camera detection already running.');
return;
}
// 定义检测回调
this.faceDetectionListener = {
onFaceDetected: (results: faceDetector.FaceDetectionResult[]) => {
const mapped = results.map(raw => this.mapToFaceResult(raw));
listener(mapped);
},
onError: (error: BusinessError) => {
console.error(`[FaceDetection] Camera detection error: ${error.message}`);
}
};
// startFaceDetection 内部会启动异步帧监听循环
this.detector.startFaceDetection(this.faceDetectionListener);
this.isCameraMode = true;
console.info('[FaceDetection] Camera face detection started.');
}
/**
* 停止相机流人脸检测
* 停止后不再消耗 CPU/GPU 资源
*/
stopCameraDetection(): void {
if (!this.isInitialized || !this.detector || !this.isCameraMode) {
return;
}
try {
this.detector.stopFaceDetection();
this.faceDetectionListener = null;
this.isCameraMode = false;
console.info('[FaceDetection] Camera face detection stopped.');
} catch (err) {
console.error(`[FaceDetection] Stop error: ${(err as BusinessError).message}`);
}
}
// ──────────────────────────────────────────────
// 辅助方法
// ──────────────────────────────────────────────
/**
* 将框架原始类型映射为业务类型
* 屏蔽不同 API 版本间的字段差异
*/
private mapToFaceResult(raw: faceDetector.FaceDetectionResult): FaceDetectionResult {
const rect = raw.rect;
const landmark = raw.faceLandmark ?? [];
return {
faceId: raw.faceId ?? 0,
rect: {
left: rect?.left ?? 0,
top: rect?.top ?? 0,
width: rect?.width ?? 0,
height: rect?.height ?? 0,
},
// 106 个关键点按序号排列
landmarks: landmark.map(p => ({ x: p?.pointX ?? 0, y: p?.pointY ?? 0 })),
pitchAngle: raw.pitchAngle ?? 0,
yawAngle: raw.yawAngle ?? 0,
rollAngle: raw.rollAngle ?? 0,
confidence: raw.confidence ?? 0,
};
}
/**
* 获取人脸框的中心点
* 活体检测中用于判断人脸是否在画面中央
*/
static getFaceCenter(face: FaceDetectionResult): Point {
return {
x: face.rect.left + face.rect.width / 2,
y: face.rect.top + face.rect.height / 2,
};
}
/**
* 计算人脸框面积占图片总面积的比例
* 可用于判断距离远近(过近/过远)
*/
static getFaceAreaRatio(face: FaceDetectionResult, imageWidth: number, imageHeight: number): number {
const faceArea = face.rect.width * face.rect.height;
const imageArea = imageWidth * imageHeight;
return faceArea / imageArea;
}
}
这段封装解决了几个实际问题。首先,通过单例模式确保底层 FaceDetector 实例在整个应用生命周期内只创建一次,避免重复初始化的开销。其次,mapToFaceResult 方法将框架原始的 FaceDetectionResult 映射为业务友好的结构,方便后续活体检测逻辑直接使用。最后,相机流模式采用回调注册机制而非主动轮询,与 HarmonyOS 的异步架构天然契合。
三、相机预览与人脸检测集成
3.1 相机能力申请
在开始相机预览之前,需要在 module.json5 中声明相机权限。HarmonyOS NEXT 对权限的把控非常严格,未声明权限将直接导致相机调用失败。
json
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.CAMERA",
"reason": "$string:reason_camera",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "always"
}
}
]
}
}
对应的国际化描述文件 resources/base/element/string.json 中需要补充说明文案:
json
{
"string": [
{
"name": "reason_camera",
"value": "用于人脸识别与活体检测功能"
}
]
}
3.2 相机预览组件封装
为了将相机流与 FaceDetectionManager 解耦,我们创建一个专门的 CameraFacePreview 组件。该组件内部管理相机的生命周期,并在每一帧的渲染完成后触发人脸检测回调。
typescript
// CameraFacePreview.ets
// 相机预览 + 人脸检测视图组件
import { camera } from '@kit.CameraKit';
import { image } from '@kit.ImageKit';
import { FaceDetectionManager, FaceDetectionResult } from './FaceDetectionManager';
import { cameraGenRiv } from '@kit.ArkGraphics2D';
@Component
export struct CameraFacePreview {
// 公开配置项
@Prop detectionEnabled: boolean = true;
@Prop showFaceBox: boolean = true; // 是否在人脸上绘制检测框
@Prop showLandmarks: boolean = false; // 是否绘制关键点
// 回调:每帧检测结果
onFacesDetected: (faces: FaceDetectionResult[]) => void = () => {};
// 内部状态
@State private surfaceId: string = '';
@State private currentFaces: FaceDetectionResult[] = [];
@State private cameraState: string = 'idle';
// 相机控制器
private cameraManager: camera.CameraManager | null = null;
private previewSession: camera.PreviewOutput | null = null;
private faceMgr: FaceDetectionManager = FaceDetectionManager.getInstance();
// 画布控制器(用于绘制人脸框)
private canvasController: CanvasRenderingContext2D | null = null;
private aboutToAppear(): void {
this.initializeCamera();
}
private aboutToDisappear(): void {
this.stopCamera();
}
/**
* 初始化相机并启动预览
*/
private async initializeCamera(): Promise<void> {
try {
// 1. 获取相机管理器
this.cameraManager = camera.getCameraManager(getContext(this));
if (!this.cameraManager) {
console.error('[CameraFacePreview] Failed to get CameraManager');
return;
}
// 2. 查询可用相机列表,优先使用前置摄像头
const cameraArray = this.cameraManager.getSupportedCameras();
if (cameraArray.length === 0) {
console.error('[CameraFacePreview] No camera available');
return;
}
const frontCamera = cameraArray.find(
c => c.cameraType === camera.CameraType.FRONT_CAMERA
) ?? cameraArray[0];
// 3. 获取相机输入流
const cameraInput = this.cameraManager.createCameraInput(frontCamera);
// 4. 打开相机
await cameraInput.open();
console.info('[CameraFacePreview] Camera opened');
// 5. 创建预览输出(绑定 Surface ID)
this.previewSession = this.cameraManager.createPreviewOutput({
surfaceId: this.surfaceId
});
// 6. 创建会话并添加输入输出
const captureSession = this.cameraManager.createCaptureSession();
captureSession.beginConfig();
captureSession.addInput(cameraInput);
captureSession.addOutput(this.previewSession);
await captureSession.commitConfig();
await captureSession.start();
this.cameraState = 'running';
console.info('[CameraFacePreview] Preview session started');
// 7. 启动人脸检测
if (this.detectionEnabled) {
this.startFaceDetection();
}
} catch (err) {
console.error(`[CameraFacePreview] Init error: ${(err as Error).message}`);
this.cameraState = 'error';
}
}
/**
* 启动人脸检测流程
*/
private startFaceDetection(): void {
try {
this.faceMgr.startCameraDetection((faces) => {
// 主线程更新 UI 状态
this.currentFaces = faces;
this.onFacesDetected(faces);
});
} catch (err) {
console.error(`[CameraFacePreview] Face detection start failed: ${(err as Error).message}`);
}
}
/**
* 停止相机并释放资源
*/
private async stopCamera(): Promise<void> {
try {
this.faceMgr.stopCameraDetection();
if (this.previewSession) {
this.previewSession.release();
this.previewSession = null;
}
this.cameraState = 'stopped';
console.info('[CameraFacePreview] Camera stopped');
} catch (err) {
console.error(`[CameraFacePreview] Stop error: ${(err as Error).message}`);
}
}
build() {
Stack({ alignContent: Alignment.Center }) {
// 相机预览画面
XComponent({
id: 'facePreview',
type: XComponentType.SURFACE,
controller: (controller: XComponentController) => {
// 获取 surfaceId 用于绑定相机输出
this.surfaceId = controller.getXComponentSurfaceId();
}
})
.width('100%')
.height('100%')
.onLoad(() => {
console.info('[CameraFacePreview] XComponent loaded, surfaceId ready');
})
// 人脸检测框覆盖层
if (this.showFaceBox && this.currentFaces.length > 0) {
FaceBoxOverlay({
faces: this.currentFaces
})
}
// 状态指示
if (this.cameraState === 'error') {
Column() {
Text('相机初始化失败')
.fontColor('#FF3B30')
.fontSize(14)
}
.position({ y: 20 })
}
}
.width('100%')
.height('100%')
}
}
/**
* 人脸框覆盖层组件
* 在相机预览之上绘制检测到的人脸框与关键点
*/
@Component
struct FaceBoxOverlay {
@ObjectLink faces: FaceDetectionResult[]
build() {
Stack() {
ForEach(this.faces, (face: FaceDetectionResult) => {
// 人脸检测框
Rect({
rect: face.rect,
color: '#00D26A',
strokeWidth: 3
})
})
}
}
}
@Component
struct Rect {
rect: Rect;
color: string;
strokeWidth: number;
build() {
Column() {
Column()
.position({
left: px2vp(this.rect.left),
top: px2vp(this.rect.top)
})
.width(px2vp(this.rect.width))
.height(px2vp(this.rect.height))
.border({
width: this.strokeWidth,
color: this.color
})
}
}
}
这段代码中有一个关键设计点值得展开:相机的预览输出与 FaceDetector 的检测流是完全独立的两条数据通路。相机预览通过 PreviewOutput 绑定到 XComponent 的 Surface,而人脸检测则在 FaceDetectionManager 内部自动完成,不需要开发者手动从预览帧中抽取图像数据送入检测器。这种设计降低了集成复杂度,也保证了检测的实时性------FaceDetector 内部会直接从相机硬件级获取图像数据,跳过了应用层的图像拷贝。
四、活体检测:从感知到判断
4.1 活体检测的基本原理
活体检测的核心目标是区分真实人脸 与伪造人脸(照片、视频翻拍、3D 面具等)。当前业界主流的两条技术路线是:
静默活体检测(Silent Liveness Detection):不需要用户配合做动作,通过分析单帧或短序列图像的纹理、光照、深度等特征来判断是否为真人。优点是用户体验好、速度快;缺点是对抗高精度伪造内容的防御能力有限。
动作活体检测(Expression/Action-based Liveness Detection):要求用户按照系统指令做出特定动作(如眨眼、张嘴、点头、摇头等),通过验证动作的真实性和一致性来判断。优点是安全性更高,难以通过静态伪造绕过;缺点是需要用户配合,流程略复杂。
在 HarmonyOS NEXT 的端侧实现中,我们可以利用 FaceDetector 输出的关键点数据与旋转角度数据,结合时序多帧分析,构建一个混合活体检测器,同时兼顾用户体验与安全性。
4.2 活体检测器类设计
typescript
// LivenessDetector.ets
// 混合活体检测器:静默分析 + 动作验证 双轨并行
import { FaceDetectionResult, FaceDetectionManager } from './FaceDetectionManager';
// ──────────────────────────────────────────────
// 动作类型定义
// ──────────────────────────────────────────────
export enum LivenessAction {
BLINK = 'blink', // 眨眼
OPEN_MOUTH = 'open_mouth', // 张嘴
NOD = 'nod', // 点头
SHAKE_HEAD = 'shake_head', // 摇头
LOOK_LEFT = 'look_left', // 向左看
LOOK_RIGHT = 'look_right', // 向右看
}
export interface ActionChallenge {
action: LivenessAction;
instruction: string; // 展示给用户的提示文案
}
/**
* 活体检测最终结果
*/
export interface LivenessResult {
isLive: boolean; // 是否判定为真人
confidence: number; // 置信度 0.0 ~ 1.0
reason: string; // 判断原因(用于调试/日志)
silentPassed: boolean; // 静默检测是否通过
actionPassed: boolean; // 动作检测是否通过
actionCompleted: boolean; // 是否已完成指定动作
}
/**
* 帧序列缓冲
* 用于时序分析:存储最近 N 帧的人脸检测结果
*/
interface FrameBuffer {
frames: FaceDetectionResult[];
maxSize: number;
}
export class LivenessDetector {
// ──────────────────────────────────────────────
// 配置参数
// ──────────────────────────────────────────────
/** 静默活体:连续通过帧数阈值(越多越严格) */
private readonly SILENT_PASS_FRAMES: number = 8;
/** 静默活体:最小人脸面积占比(人脸不能过小或过大) */
private readonly MIN_FACE_RATIO: number = 0.05;
private readonly MAX_FACE_RATIO: number = 0.50;
/** 静默活体:最大允许旋转角(过偏则难以判断) */
private readonly MAX_PITCH: number = 30; // 度
private readonly MAX_YAW: number = 45; // 度
private readonly MAX_ROLL: number = 30; // 度
/** 静默活体:眼睛关键点索引(106 点模型中的大致位置) */
private readonly LEFT_EYE_INDICES: number[] = [36, 37, 38, 39, 40, 41];
private readonly RIGHT_EYE_INDICES: number[] = [42, 43, 44, 45, 46, 47];
/** 静默活体:嘴巴关键点索引 */
private readonly MOUTH_INDICES: number[] = [48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59];
/** 动作活体:动作判定所需帧数(需观察到足够长的动作过程) */
private readonly ACTION_CONFIRM_FRAMES: number = 15;
// ──────────────────────────────────────────────
// 状态管理
// ──────────────────────────────────────────────
private frameBuffer: FrameBuffer = { frames: [], maxSize: 30 };
private silentPassCount: number = 0;
private currentChallenge: ActionChallenge | null = null;
private actionFrameCount: number = 0;
private actionHistory: { action: LivenessAction; completed: boolean }[] = [];
private isActive: boolean = false;
constructor() {}
/**
* 启动活体检测
* @param challenge 可选的动作挑战,若不提供则仅执行静默检测
*/
start(challenge?: ActionChallenge): void {
this.reset();
this.isActive = true;
if (challenge) {
this.currentChallenge = challenge;
}
console.info(`[Liveness] Detector started, challenge: ${challenge?.action ?? 'none'}`);
}
/**
* 重置检测器状态
*/
reset(): void {
this.frameBuffer.frames = [];
this.silentPassCount = 0;
this.currentChallenge = null;
this.actionFrameCount = 0;
this.actionHistory = [];
this.isActive = false;
}
/**
* 停止检测
*/
stop(): void {
this.isActive = false;
console.info('[Liveness] Detector stopped');
}
/**
* 核心方法:处理每一帧的检测结果
* 由相机人脸检测回调驱动
*
* @param faces 当前帧检测到的人脸数组
* @param imageWidth 原始图像宽度
* @param imageHeight 原始图像高度
* @returns 若检测完成则返回 LivenessResult,否则返回 null
*/
processFrame(
faces: FaceDetectionResult[],
imageWidth: number,
imageHeight: number
): LivenessResult | null {
if (!this.isActive || faces.length === 0) {
return null;
}
const face = faces[0]; // 优先处理第一人脸
// 1. 将当前帧加入缓冲区
this.pushFrame(face);
// 2. 静默活体检测(逐帧进行)
const silentResult = this.evaluateSilentLiveness(face, imageWidth, imageHeight);
// 3. 动作活体检测(如果设置了挑战)
let actionPassed = true;
let actionCompleted = false;
if (this.currentChallenge) {
const actionResult = this.evaluateAction(
this.currentChallenge.action,
imageWidth,
imageHeight
);
actionCompleted = actionResult.completed;
actionPassed = actionResult.passed;
if (actionCompleted) {
this.actionHistory.push({
action: this.currentChallenge.action,
completed: true
});
console.info(`[Liveness] Action ${this.currentChallenge.action} completed`);
}
}
// 4. 综合判定
const isLive = silentResult.passed && actionPassed;
const confidence = this.calculateConfidence(silentResult, actionPassed);
if (this.silentPassCount >= this.SILENT_PASS_FRAMES && actionCompleted) {
const result: LivenessResult = {
isLive,
confidence,
reason: isLive ? 'PASS: 静默与动作检测均通过' : 'FAIL: 活体判定失败',
silentPassed: silentResult.passed,
actionPassed,
actionCompleted
};
console.info(`[Liveness] Detection complete: isLive=${isLive}, conf=${confidence}`);
this.stop();
return result;
}
return null;
}
// ──────────────────────────────────────────────
// 静默活体检测
// ──────────────────────────────────────────────
private evaluateSilentLiveness(
face: FaceDetectionResult,
imageWidth: number,
imageHeight: number
): { passed: boolean; reason: string } {
// 检查项 1:人脸面积比例
const areaRatio = FaceDetectionManager.getFaceAreaRatio(face, imageWidth, imageHeight);
if (areaRatio < this.MIN_FACE_RATIO) {
return { passed: false, reason: '人脸距离过远' };
}
if (areaRatio > this.MAX_FACE_RATIO) {
return { passed: false, reason: '人脸距离过近' };
}
// 检查项 2:旋转角度合理性
if (Math.abs(face.pitchAngle) > this.MAX_PITCH) {
return { passed: false, reason: `俯仰角过大: ${face.pitchAngle.toFixed(1)}°` };
}
if (Math.abs(face.yawAngle) > this.MAX_YAW) {
return { passed: false, reason: `偏航角过大: ${face.yawAngle.toFixed(1)}°` };
}
if (Math.abs(face.rollAngle) > this.MAX_ROLL) {
return { passed: false, reason: `翻滚角过大: ${face.rollAngle.toFixed(1)}°` };
}
// 检查项 3:关键点完整性(关键点数不足通常为伪造)
if (face.landmarks.length < 60) {
return { passed: false, reason: `关键点不足: ${face.landmarks.length}/106` };
}
// 检查项 4:眼睛区域纹理分析(简化为开闭状态)
const blinkDetected = this.detectBlink(face);
const blinkScore = blinkDetected ? 1 : 0;
// 检查项 5:嘴巴开合分析(防止静态照片欺骗)
const mouthOpenScore = this.detectMouthOpen(face);
// 检查项 6:多帧稳定性分析(照片晃动特征)
const stabilityScore = this.checkFrameStability();
// 综合评分
const totalScore = blinkScore + mouthOpenScore + stabilityScore;
// 增加通过计数(允许偶尔的帧抖动)
if (totalScore >= 1) {
this.silentPassCount++;
} else {
this.silentPassCount = Math.max(0, this.silentPassCount - 1);
}
const passed = this.silentPassCount >= this.SILENT_PASS_FRAMES;
return {
passed,
reason: passed ? `静默检测通过(${this.silentPassCount}/${this.SILENT_PASS_FRAMES} 帧)` :
`静默检测进行中(${this.silentPassCount}/${this.SILENT_PASS_FRAMES} 帧)`
};
}
/**
* 眨眼检测:基于眼睛宽高比(EAR - Eye Aspect Ratio)的变化
* EAR = (|p2-p6| + |p3-p5|) / (2 * |p1-p4|)
* 眨眼时 EAR 会显著下降
*/
private detectBlink(face: FaceDetectionResult): boolean {
const landmarks = face.landmarks;
if (landmarks.length < 48) return false;
const leftEAR = this.calculateEAR(landmarks, this.LEFT_EYE_INDICES);
const rightEAR = this.calculateEAR(landmarks, this.RIGHT_EYE_INDICES);
const avgEAR = (leftEAR + rightEAR) / 2;
// 典型 EAR 阈值:睁眼约 0.25,闭眼约 0.15
const BLINK_THRESHOLD = 0.20;
return avgEAR < BLINK_THRESHOLD;
}
/**
* 计算 EAR(眼睛宽高比)
*/
private calculateEAR(landmarks: Point[], indices: number[]): number {
if (indices.length < 6) return 0.5;
const p1 = landmarks[indices[0]];
const p2 = landmarks[indices[1]];
const p3 = landmarks[indices[2]];
const p4 = landmarks[indices[3]];
const p5 = landmarks[indices[4]];
const p6 = landmarks[indices[5]];
if (!p1 || !p2 || !p3 || !p4 || !p5 || !p6) return 0.5;
// 垂直距离
const v1 = Math.sqrt((p3.x - p5.x) ** 2 + (p3.y - p5.y) ** 2);
const v2 = Math.sqrt((p2.x - p6.x) ** 2 + (p2.y - p6.y) ** 2);
// 水平距离
const h = Math.sqrt((p1.x - p4.x) ** 2 + (p1.y - p4.y) ** 2);
if (h === 0) return 0.5;
return (v1 + v2) / (2 * h);
}
/**
* 嘴巴张开检测:基于 MAR(嘴巴宽高比)
* MAR = |p2-p8| / |p1-p5|(垂直/水平)
*/
private detectMouthOpen(face: FaceDetectionResult): number {
const landmarks = face.landmarks;
if (landmarks.length < 60) return 0;
const p1 = landmarks[this.MOUTH_INDICES[0]]; // 左嘴角
const p5 = landmarks[this.MOUTH_INDICES[4]]; // 右嘴角
const p2 = landmarks[this.MOUTH_INDICES[2]]; // 上唇中
const p8 = landmarks[this.MOUTH_INDICES[6]]; // 下唇中
if (!p1 || !p5 || !p2 || !p8) return 0;
const v = Math.abs(p2.y - p8.y);
const h = Math.abs(p5.x - p1.x);
if (h === 0) return 0;
const mar = v / h;
// MAR > 0.3 认为张嘴
return mar > 0.3 ? 1 : 0;
}
/**
* 帧稳定性分析:检测帧间关键点位移是否过小(照片翻拍特征)
* 真实人脸的呼吸、微表情会导致持续的微小位移
*/
private checkFrameStability(): number {
const frames = this.frameBuffer.frames;
if (frames.length < 5) return 0;
// 计算最近 5 帧人脸框中心点的平均位移
let totalDx = 0;
let totalDy = 0;
const recentFrames = frames.slice(-5);
for (let i = 1; i < recentFrames.length; i++) {
const prev = recentFrames[i - 1];
const curr = recentFrames[i];
totalDx += Math.abs(curr.rect.left - prev.rect.left);
totalDy += Math.abs(curr.rect.top - prev.rect.top);
}
const avgDx = totalDx / (recentFrames.length - 1);
const avgDy = totalDy / (recentFrames.length - 1);
// 真实人脸通常有 > 1px 的呼吸位移
// 照片/屏幕翻拍几乎没有帧间运动
const motionDetected = (avgDx + avgDy) > 1.5;
return motionDetected ? 1 : 0;
}
// ──────────────────────────────────────────────
// 动作活体检测
// ──────────────────────────────────────────────
/**
* 评估当前帧是否完成了指定动作
*/
private evaluateAction(
action: LivenessAction,
imageWidth: number,
imageHeight: number
): { completed: boolean; passed: boolean } {
const frames = this.frameBuffer.frames;
if (frames.length < this.ACTION_CONFIRM_FRAMES) {
return { completed: false, passed: true };
}
// 取首帧和末帧进行对比
const firstFrame = frames[0];
const lastFrame = frames[frames.length - 1];
let completed = false;
let angleChange = 0;
switch (action) {
case LivenessAction.BLINK:
// 眨眼通过条件:检测到至少一次 EAR < 阈值
completed = this.checkBlinkInBuffer(frames);
break;
case LivenessAction.NOD:
// 点头:pitch 角有显著变化
angleChange = Math.abs(lastFrame.pitchAngle - firstFrame.pitchAngle);
completed = angleChange > 15; // 至少 15° 的俯仰变化
break;
case LivenessAction.SHAKE_HEAD:
// 摇头:yaw 角有显著变化
angleChange = Math.abs(lastFrame.yawAngle - firstFrame.yawAngle);
completed = angleChange > 25; // 至少 25° 的偏航变化
break;
case LivenessAction.LOOK_LEFT:
completed = lastFrame.yawAngle < -20;
break;
case LivenessAction.LOOK_RIGHT:
completed = lastFrame.yawAngle > 20;
break;
case LivenessAction.OPEN_MOUTH:
// 张嘴通过条件:检测到至少一次 MAR > 阈值
completed = this.checkMouthOpenInBuffer(frames);
break;
}
this.actionFrameCount++;
return { completed, passed: true };
}
private checkBlinkInBuffer(frames: FaceDetectionResult[]): boolean {
for (const frame of frames) {
if (this.detectBlink(frame)) return true;
}
return false;
}
private checkMouthOpenInBuffer(frames: FaceDetectionResult[]): boolean {
for (const frame of frames) {
if (this.detectMouthOpen(frame) > 0) return true;
}
return false;
}
// ──────────────────────────────────────────────
// 辅助方法
// ──────────────────────────────────────────────
private pushFrame(face: FaceDetectionResult): void {
this.frameBuffer.frames.push(face);
if (this.frameBuffer.frames.length > this.frameBuffer.maxSize) {
this.frameBuffer.frames.shift();
}
}
private calculateConfidence(
silentResult: { passed: boolean; reason: string },
actionPassed: boolean
): number {
let conf = 0.5;
// 静默检测置信度贡献(0 ~ 0.4)
if (silentResult.passed) {
const frameRatio = Math.min(this.silentPassCount / this.SILENT_PASS_FRAMES, 1.0);
conf += frameRatio * 0.4;
}
// 动作检测置信度贡献(0 ~ 0.3)
if (actionPassed && this.actionHistory.length > 0) {
conf += 0.3;
}
return Math.min(conf, 1.0);
}
/**
* 获取当前检测进度描述
*/
getProgressText(): string {
const silent = `${this.silentPassCount}/${this.SILENT_PASS_FRAMES}`;
const action = this.currentChallenge ?
`${this.currentChallenge.action} (${this.actionFrameCount}/${this.ACTION_CONFIRM_FRAMES})` :
'无动作要求';
return `静默: ${silent} | 动作: ${action}`;
}
}
这段 LivenessDetector 的设计融合了多层防御逻辑。静默检测层通过面积合理性 、角度约束 、关键点数量 三重快速过滤排除明显异常;通过眨眼 EAR 分析 、嘴巴 MAR 分析 与帧间微动检测 区分真实人脸与平面伪造;动作检测层则利用 pitch/yaw 角的变化轨迹验证用户是否真实完成了指定动作。
值得注意的是,checkFrameStability 中的帧间位移分析是一个实用技巧------大多数平面伪造(照片打印、屏幕翻拍)即使有轻微的上下左右晃动,其关键点位移模式也与真实 3D 人脸有本质差异。通过持续分析多帧的平均微动量,可以有效识别这类攻击。
五、端到端集成:完整身份验证页面
5.1 页面逻辑编排
有了 FaceDetectionManager 和 LivenessDetector,现在将它们串联起来,构建一个完整的身份验证页面。页面状态机设计如下:
IDLE → INITIALIZING → WAITING_FACE → DETECTING_LIVENESS → RESULT → (成功/失败)
↓
RETRY → WAITING_FACE
typescript
// LivenessVerifyPage.ets
// 人脸活体验证完整页面
import { camera } from '@kit.CameraKit';
import { image } from '@kit.ImageKit';
import { promptAction } from '@kit.ArkUI';
import { FaceDetectionManager, FaceDetectionResult } from '../manager/FaceDetectionManager';
import { LivenessDetector, LivenessResult, LivenessAction, ActionChallenge } from './LivenessDetector';
enum VerifyState {
IDLE = 'idle',
INITIALIZING = 'initializing',
WAITING_FACE = 'waiting_face',
DETECTING_LIVENESS = 'detecting_liveness',
SUCCESS = 'success',
FAILED = 'failed',
}
@Entry
@Component
struct LivenessVerifyPage {
@State private verifyState: VerifyState = VerifyState.IDLE;
@State private progressText: string = '准备中...';
@State private verifyResult: LivenessResult | null = null;
@State private currentFaces: FaceDetectionResult[] = [];
@State private currentChallenge: ActionChallenge | null = null;
@State private imageWidth: number = 1080;
@State private imageHeight: number = 1920;
// 管理器实例
private faceMgr: FaceDetectionManager = FaceDetectionManager.getInstance();
private livenessDetector: LivenessDetector = new LivenessDetector();
// 预定义动作挑战序列(可随机选择)
private challengePool: ActionChallenge[] = [
{ action: LivenessAction.BLINK, instruction: '请眨一眨眼' },
{ action: LivenessAction.OPEN_MOUTH, instruction: '请张开嘴巴' },
{ action: LivenessAction.NOD, instruction: '请缓慢点头' },
{ action: LivenessAction.SHAKE_HEAD, instruction: '请缓慢摇头' },
{ action: LivenessAction.LOOK_LEFT, instruction: '请看向左边' },
{ action: LivenessAction.LOOK_RIGHT, instruction: '请看向右边' },
];
aboutToAppear(): void {
this.startVerification();
}
aboutToDisappear(): void {
this.cleanup();
}
/**
* 启动完整验证流程
*/
private async startVerification(): Promise<void> {
this.verifyState = VerifyState.INITIALIZING;
this.progressText = '正在初始化人脸引擎...';
try {
// 1. 初始化 FaceDetector
await this.faceMgr.initialize();
// 2. 随机选择一个动作挑战
const randomIndex = Math.floor(Math.random() * this.challengePool.length);
this.currentChallenge = this.challengePool[randomIndex];
this.verifyState = VerifyState.WAITING_FACE;
this.progressText = '请将脸部对准屏幕中央';
// 3. 启动相机流检测(相机预览由 CameraFacePreview 组件提供)
this.faceMgr.startCameraDetection(this.onFacesDetected.bind(this));
} catch (err) {
console.error(`[LivenessVerify] Start failed: ${(err as Error).message}`);
this.showToast('初始化失败,请重试');
this.verifyState = VerifyState.IDLE;
}
}
/**
* 人脸检测回调
*/
private onFacesDetected(faces: FaceDetectionResult[]): void {
this.currentFaces = faces;
switch (this.verifyState) {
case VerifyState.WAITING_FACE:
if (faces.length > 0) {
// 检测到人脸,进入活体检测阶段
this.verifyState = VerifyState.DETECTING_LIVENESS;
this.livenessDetector.start(this.currentChallenge ?? undefined);
this.progressText = this.livenessDetector.getProgressText();
}
break;
case VerifyState.DETECTING_LIVENESS:
if (faces.length === 0) {
// 人脸丢失,重置检测
this.progressText = '人脸丢失,请重新对准';
this.verifyState = VerifyState.WAITING_FACE;
this.livenessDetector.reset();
return;
}
const result = this.livenessDetector.processFrame(
faces,
this.imageWidth,
this.imageHeight
);
this.progressText = this.livenessDetector.getProgressText();
if (result) {
this.verifyResult = result;
this.verifyState = result.isLive ? VerifyState.SUCCESS : VerifyState.FAILED;
this.faceMgr.stopCameraDetection();
if (result.isLive) {
this.showToast('活体验证通过');
this.onVerifySuccess();
} else {
this.showToast(`验证失败: ${result.reason}`);
// 3 秒后自动重试
setTimeout(() => this.startVerification(), 3000);
}
}
break;
}
}
private onVerifySuccess(): void {
// 这里可以触发后续业务流程,如:
// - 解锁设备
// - 完成登录
// - 签到打卡
console.info('[LivenessVerify] Verification succeeded!');
}
private cleanup(): void {
this.faceMgr.stopCameraDetection();
this.livenessDetector.stop();
}
private showToast(message: string): void {
try {
promptAction.showToast({ message, duration: 2000 });
} catch (_) {
console.info(`[Toast] ${message}`);
}
}
build() {
Column() {
// 顶部标题栏
this.buildHeader()
// 相机预览区域(占据主要空间)
Stack() {
// 相机预览(XComponent)
this.buildCameraPreview()
// 人脸框覆盖
if (this.currentFaces.length > 0) {
FaceBoxOverlay({ faces: this.currentFaces })
}
// 状态指示层
this.buildStatusOverlay()
}
.layoutWeight(1)
.clip(true)
.borderRadius(16)
// 底部操作区
this.buildBottomArea()
}
.width('100%')
.height('100%')
.backgroundColor('#0A0A0A')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
}
@Builder
buildHeader() {
Row() {
Text('人脸活体验证')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Blank()
Text('v1.0')
.fontSize(12)
.fontColor('#888888')
}
.width('100%')
.height(48)
.margin({ bottom: 12 })
}
@Builder
buildCameraPreview() {
XComponent({
id: 'mainPreview',
type: XComponentType.SURFACE,
controller: (controller: XComponentController) => {
// 相机预览由 CameraFacePreview 组件管理
// 此处作为降级/备用方案
}
})
.width('100%')
.height('100%')
}
@Builder
buildStatusOverlay() {
Column() {
// 动作提示(如果正在执行动作活体)
if (this.currentChallenge && this.verifyState === VerifyState.DETECTING_LIVENESS) {
Column() {
Text(this.currentChallenge.instruction)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#FFD700')
.textAlign(TextAlign.Center)
}
.width('80%')
.padding({ top: 40, bottom: 12 })
.backgroundColor('rgba(0,0,0,0.5)')
.borderRadius(12)
}
Blank()
// 进度条
Row() {
Progress({
value: this.verifyState === VerifyState.SUCCESS ? 100 :
this.verifyState === VerifyState.FAILED ? 0 :
Math.min(this.livenessDetector['silentPassCount'] / 8 * 50 +
this.livenessDetector['actionFrameCount'] / 15 * 50, 99),
total: 100,
type: ProgressType.Linear
})
.color('#00D26A')
.width('70%')
Text(this.progressText)
.fontSize(12)
.fontColor('#CCCCCC')
.margin({ left: 12 })
.maxLines(1)
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 16 })
.backgroundColor('rgba(0,0,0,0.6)')
// 结果展示
if (this.verifyState === VerifyState.SUCCESS || this.verifyState === VerifyState.FAILED) {
this.buildResultBadge()
}
}
.width('100%')
.height('100%')
.justifyContent(this.currentChallenge ? ContentAlign.Start : ContentAlign.Bottom)
.alignItems(HorizontalAlign.Center)
}
@Builder
buildResultBadge() {
Column() {
if (this.verifyState === VerifyState.SUCCESS) {
Text('✓ 验证通过')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#00D26A')
Text(`置信度: ${((this.verifyResult?.confidence ?? 0) * 100).toFixed(1)}%`)
.fontSize(14)
.fontColor('#AAAAAA')
.margin({ top: 8 })
} else {
Text('✗ 验证失败')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#FF3B30')
Text(this.verifyResult?.reason ?? '')
.fontSize(14)
.fontColor('#AAAAAA')
.margin({ top: 8 })
.textAlign(TextAlign.Center)
}
}
.width('80%')
.padding(24)
.backgroundColor('rgba(0,0,0,0.85)')
.borderRadius(16)
.margin({ bottom: 80 })
}
@Builder
buildBottomArea() {
Column() {
if (this.verifyState === VerifyState.IDLE || this.verifyState === VerifyState.SUCCESS) {
Button('重新验证')
.width('100%')
.height(50)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.backgroundColor('#1A73E8')
.borderRadius(12)
.onClick(() => this.startVerification())
} else if (this.verifyState === VerifyState.FAILED) {
Button('重试')
.width('100%')
.height(50)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.backgroundColor('#FF6B00')
.borderRadius(12)
.onClick(() => this.startVerification())
} else {
Button('取消')
.width('100%')
.height(50)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.backgroundColor('#333333')
.fontColor('#FFFFFF')
.borderRadius(12)
.onClick(() => this.cleanup())
}
}
.width('100%')
.margin({ top: 16 })
}
}
/**
* 人脸框覆盖层(简化版,用于主页面)
*/
@Component
struct FaceBoxOverlay {
@ObjectLink faces: FaceDetectionResult[]
build() {
Stack() {
ForEach(this.faces, (face: FaceDetectionResult) => {
Stack() {
// 绿色检测框
BorderContainer({
left: face.rect.left,
top: face.rect.top,
width: face.rect.width,
height: face.rect.height
})
}
})
}
}
}
@Component
struct BorderContainer {
left: number
top: number
width: number
height: number
build() {
Column() {
Column()
.position({ left: px2vp(this.left), top: px2vp(this.top) })
.width(px2vp(this.width))
.height(px2vp(this.height))
.border({
width: 3,
color: '#00D26A',
radius: 8
})
}
}
}
从状态机的流转可以看出,整个验证流程的决策点集中在 onFacesDetected 回调中。当状态为 WAITING_FACE 时,系统等待人脸出现在画面中;一旦检测到人脸,立即切换到 DETECTING_LIVENESS 状态并启动 LivenessDetector。如果人脸在检测过程中丢失(DETECTING_LIVENESS 状态但 faces.length === 0),则回退到等待状态并重置检测器,这是防止攻击者通过快速遮挡镜头绕过检测的必要措施。
六、实战技巧与避坑指南
6.1 性能优化三板斧
第一:提前初始化。 FaceDetector 的首次创建涉及模型加载和 NPU/GPU 资源分配,实测耗时可达 800ms~2s。建议在应用启动阶段(AppAbility 的 onCreate 或全局 Loading 页面中)提前调用 initialize(),而非等到用户触发验证时才初始化。配合 FACE_DETECTOR_READY 事件通知 UI 层,可以实现"无感知预热"。
第二:合理设置检测区域。 FaceDetector 支持通过 Rect 参数限制检测子图区域(减少输入数据量),但 HarmonyOS 的实现中此参数在 VIDEO 模式下可能不生效。实测建议直接控制相机分辨率------720p 足以满足活体检测精度需求,1080p 以上对检测精度提升有限但功耗显著增加。
第三:批量事件压缩。 在高帧率相机流场景下,onFaceDetected 回调频率可能达到 30fps。如果 UI 更新频率跟不上,可以增加节流逻辑(如每 100ms 更新一次检测框位置),避免主线程过载。
6.2 常见错误排查
| 错误现象 | 排查方向 |
|---|---|
createFaceDetector 抛出 401 错误 |
检查 ohos.permission.CAMERA 权限是否在 module.json5 中正确声明 |
| 检测到的人脸框坐标偏移 | XComponent 的尺寸与相机实际分辨率不匹配,需要用 px2vp 或实际像素尺寸换算 |
| 关键点数量不足(< 60) | 人脸过小或角度过大导致部分关键点不可见,建议在 UI 层提示用户调整距离 |
| 静默检测始终无法通过 | 排查光线条件(过暗/强逆光),可增加环境光检测并提示用户 |
startFaceDetection 后回调不触发 |
确认 FaceDetector 已在 initialize 后且 XComponent 的 surfaceId 已就绪 |
6.3 安全加固建议
以上实现的活体检测属于前端验证层,攻击者若对应用进行 ROOT/越狱后可 hook 检测逻辑。因此,在安全敏感场景中,活体检测结果应结合以下措施:
- 签名验真 :将检测结果(
isLive、confidence、timestamp)用设备密钥签名后上传后端,后端验签后才认定为有效证据。 - 威胁感知:检测设备是否 ROOT、是否运行于模拟器、是否启用了调试模式,综合评估风险等级。
- 多因子融合:活体检测 + PIN/密码,或活体检测 + 设备 PIN,形成多因素认证体系。
活体检测从来不是银弹,它是整个安全链路中的一环。理解这一点,才能设计出真正可用的身份验证方案。
七、扩展方向
当前实现覆盖了人脸检测与活体验证的核心路径,以下方向值得进一步探索:
多脸场景处理 :如果需要支持多用户同框(如家庭共享设备),FaceDetector 返回的 faceId 可以帮助追踪不同人脸的独立验证状态。可以在 FaceDetectionManager 中增加按 faceId 分组的逻辑。
质量评分与最佳帧选取:当用户进行人脸注册时,可以从连续多帧中自动选取质量最高的一帧(基于面积、角度、关键点数量综合评分),提升人脸注册成功率。
与端侧人脸比对联动 :检测到活体之后,下一步通常是"这个人是谁"------这需要端侧人脸特征提取与比对引擎。在 HarmonyOS NEXT 上,可以通过 faceVerifier 模块(@kit.FaceAuthKit)获取人脸特征码进行 1:1 或 1:N 比对,构成完整的"验活体 → 识身份"流水线。