id-scanner-lib
Version:
Browser-based ID card, QR code, and face recognition scanner with liveness detection
910 lines (760 loc) • 27.4 kB
text/typescript
/* eslint-disable */
/**
* @file 活体检测模块
* @description 提供人脸活体检测功能
* @module modules/face/liveness-detector
*/
import { BaseScannerModule, ModuleCapabilities, ModuleEvent, ModuleInitOptions, ModuleStatus, ModuleType } from '../../interfaces/scanner-module';
import { FaceDetectionOptions, FaceDetectionResult, LivenessAction, LivenessDetectionResult, LivenessDetectionType, LivenessSession, Point } from '../../interfaces/face-detection';
import { ConfigManager } from '../../core/config';
import { Logger } from '../../core/logger';
import { ResourceManager } from '../../core/resource-manager';
import { CameraManager, CameraEvent } from '../../core/camera-manager';
import { Result } from '../../core/result';
import { FaceDetector } from './face-detector';
import { InitializationError, LivenessDetectionError } from '../../core/errors';
import { debounce, generateUUID } from '../../utils';
/**
* 眨眼检测阈值配置
*/
interface BlinkThresholds {
/** 眼睛闭合阈值 */
eyeClosedThreshold: number;
/** 眼睛张开阈值 */
eyeOpenThreshold: number;
/** 检测阈值 */
detectionThreshold: number;
}
/**
* 活体检测器配置
*/
export interface LivenessDetectorConfig {
/** 是否启用 */
enabled: boolean;
/** 检测类型 */
detectionType: LivenessDetectionType;
/** 检测置信度阈值 */
confidenceThreshold: number;
/** 眨眼检测阈值 */
blinkThresholds: BlinkThresholds;
/** 活体挑战超时(毫秒) */
challengeTimeout: number;
/** 活体挑战动作 */
challengeActions: LivenessAction[];
}
/**
* 活体检测模块
* 提供人脸活体检测功能,支持被动式和主动式活体检测
*/
export class LivenessDetector extends BaseScannerModule {
/** 模块类型 */
readonly type: ModuleType = ModuleType.FACE;
/** 模块配置 */
protected config: LivenessDetectorConfig;
/** 默认配置 */
private static readonly DEFAULT_CONFIG: LivenessDetectorConfig = {
enabled: true,
detectionType: LivenessDetectionType.PASSIVE,
confidenceThreshold: 0.7,
blinkThresholds: {
eyeClosedThreshold: 0.23,
eyeOpenThreshold: 0.30,
detectionThreshold: 0.85
},
challengeTimeout: 30000, // 30秒
challengeActions: [
LivenessAction.BLINK,
LivenessAction.NOD,
LivenessAction.SMILE
]
};
/** 配置管理器 */
private configManager: ConfigManager;
/** 日志记录器 */
private logger: Logger;
/** 资源管理器 */
private resourceManager: ResourceManager;
/** 摄像头管理器 */
private cameraManager: CameraManager;
/** 人脸检测器 */
private faceDetector: FaceDetector;
/** 当前活体会话 */
private currentSession: LivenessSession | null = null;
/** 检测历史记录,用于分析眨眼等动作 */
private detectionHistory: Array<{
timestamp: number;
eyeState: 'open' | 'closed' | 'unknown';
faceResult: FaceDetectionResult;
}> = [];
/** 最大历史记录长度 */
private readonly MAX_HISTORY_LENGTH = 30;
/** 防抖处理函数 */
private debouncedProcessFrame: (frameData: ImageData) => void;
/**
* 构造函数
* @param config 初始配置
* @param faceDetector 可选的人脸检测器实例
*/
constructor(config: Partial<LivenessDetectorConfig> = {}, faceDetector?: FaceDetector) {
super({ enabled: true, ...config });
this.configManager = ConfigManager.getInstance();
this.logger = Logger.getInstance();
this.resourceManager = ResourceManager.getInstance();
this.cameraManager = CameraManager.getInstance();
// 合并配置
this.config = {
...LivenessDetector.DEFAULT_CONFIG,
...config
};
// 使用传入或创建新的人脸检测器
this.faceDetector = faceDetector || new FaceDetector({
detectLandmarks: true,
detectExpressions: true
});
// 创建防抖处理函数
this.debouncedProcessFrame = debounce(this.processFrame.bind(this), 100);
}
/**
* 获取模块能力
*/
get capabilities(): ModuleCapabilities {
return {
supportsVideo: true,
supportsImage: true,
supportsBatch: false,
supportsRealtime: true,
supportsWebWorker: false,
supportedMediaTypes: ['image/jpeg', 'image/png', 'image/webp']
};
}
/**
* 初始化模块
* @param options 初始化选项
*/
async initialize(options?: ModuleInitOptions): Promise<void> {
if (this._status === ModuleStatus.INITIALIZING) {
throw new Error('活体检测模块正在初始化中');
}
if (this._status === ModuleStatus.READY) {
this.logger.debug('LivenessDetector', '活体检测模块已初始化');
return;
}
this.setStatus(ModuleStatus.INITIALIZING);
this.emit(ModuleEvent.INIT_START);
try {
// 应用配置选项
if (options?.config) {
this.updateConfig(options.config);
}
// 设置调试模式
if (options?.debug !== undefined) {
this.debug = options.debug;
}
// 确保人脸检测器已初始化
if (this.faceDetector.getStatus() !== ModuleStatus.READY) {
await this.faceDetector.initialize(options);
}
// 监听人脸检测器结果
this.faceDetector.on(ModuleEvent.REALTIME_RESULT, this.handleFaceDetectionResult.bind(this));
// 绑定摄像头事件
if (options?.bindCamera) {
this.cameraManager.on(CameraEvent.FRAME, this.handleCameraFrame.bind(this));
}
this.setStatus(ModuleStatus.READY);
this.emit(ModuleEvent.INIT_COMPLETE);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error('LivenessDetector', `初始化失败: ${errorMessage}`, error instanceof Error ? error : new Error(errorMessage));
this.setStatus(ModuleStatus.ERROR);
this.emit(ModuleEvent.INIT_ERROR, { error: error instanceof Error ? error : new Error(errorMessage) });
throw new Error(`活体检测模块初始化失败: ${errorMessage}`);
}
}
/**
* 处理图片
* @param image 图片源
* @param options 处理选项
*/
async processImage(
image: string | HTMLImageElement | HTMLCanvasElement | ImageData,
options: Record<string, any> = {}
): Promise<Result<LivenessDetectionResult>> {
this.checkInitialized();
if (this._status === ModuleStatus.PROCESSING) {
return Result.failure(new LivenessDetectionError('另一个处理操作正在进行中'));
}
this.setStatus(ModuleStatus.PROCESSING);
this.emit(ModuleEvent.PROCESS_START);
try {
// 首先使用人脸检测器处理图片
const faceResult = await this.faceDetector.processImage(image, {
withLandmarks: true,
withAttributes: true
});
const faceData = faceResult.getData();
if (!faceResult.isSuccess() || !faceData || faceData.length === 0) {
throw new LivenessDetectionError('未在图像中检测到人脸');
}
// 获取检测到的第一个人脸
const face = faceData[0];
// 执行被动式活体检测
const livenessResult = this.performPassiveLivenessDetection(face);
this.setStatus(ModuleStatus.READY);
this.emit(ModuleEvent.PROCESS_COMPLETE, { result: livenessResult });
return Result.success(livenessResult);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error('LivenessDetector', `图片处理失败: ${errorMessage}`, error instanceof Error ? error : new Error(errorMessage));
this.setStatus(ModuleStatus.ERROR);
this.emit(ModuleEvent.PROCESS_ERROR, { error: error instanceof Error ? error : new Error(errorMessage) });
return Result.failure(new LivenessDetectionError(`活体检测失败: ${errorMessage}`));
}
}
/**
* 开始活体检测会话
* @param type 活体检测类型
*/
startSession(type: LivenessDetectionType = this.config.detectionType): Result<LivenessSession> {
this.checkInitialized();
// 如果已有会话,先停止
if (this.currentSession) {
this.stopSession();
}
// 生成会话
let requiredActions: LivenessAction[] = [];
if (type === LivenessDetectionType.ACTIVE || type === LivenessDetectionType.HYBRID) {
// 从配置的动作中随机选择2-3个
const availableActions = [...this.config.challengeActions];
const actionCount = Math.min(availableActions.length, Math.floor(Math.random() * 2) + 2); // 2-3个动作
// 打乱动作顺序
for (let i = availableActions.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[availableActions[i], availableActions[j]] = [availableActions[j], availableActions[i]];
}
requiredActions = availableActions.slice(0, actionCount);
// 确保至少包含眨眼动作
if (!requiredActions.includes(LivenessAction.BLINK)) {
requiredActions[0] = LivenessAction.BLINK;
}
}
// 创建会话
this.currentSession = {
id: generateUUID(),
type,
requiredActions,
currentActionIndex: 0,
startTime: Date.now(),
timeout: this.config.challengeTimeout,
status: 'active',
completedActions: []
};
// 清空历史记录
this.detectionHistory = [];
this.logger.debug('LivenessDetector', `开始活体检测会话,类型: ${type}`, {
session: {
id: this.currentSession.id,
requiredActions
}
});
return Result.success(this.currentSession);
}
/**
* 停止当前会话
*/
stopSession(): void {
if (this.currentSession) {
if (this.currentSession.status === 'active') {
this.currentSession.status = 'failed';
}
this.logger.debug('LivenessDetector', '停止活体检测会话', {
session: {
id: this.currentSession.id,
status: this.currentSession.status
}
});
this.currentSession = null;
}
// 清空历史记录
this.detectionHistory = [];
}
/**
* 获取当前活体检测会话
*/
getCurrentSession(): LivenessSession | null {
return this.currentSession ? { ...this.currentSession } : null;
}
/**
* 开始实时处理
* @param videoElement 视频元素
* @param options 处理选项
*/
async startRealtime(
videoElement?: HTMLVideoElement,
options: Record<string, any> = {}
): Promise<Result<boolean>> {
this.checkInitialized();
if (this._status === ModuleStatus.PROCESSING) {
return Result.failure(new LivenessDetectionError('实时处理已在进行中'));
}
try {
// 停止现有会话
this.stopSession();
// 启动新会话
const sessionType = options.livenessType || this.config.detectionType;
this.startSession(sessionType as LivenessDetectionType);
// 启动人脸检测
const faceDetectorResult = await this.faceDetector.startRealtime(videoElement, {
withLandmarks: true,
withAttributes: true,
processingInterval: options.processingInterval || 100
});
if (!faceDetectorResult.isSuccess()) {
throw new Error('无法启动人脸检测');
}
this.setStatus(ModuleStatus.PROCESSING);
this.logger.debug('LivenessDetector', '开始实时活体检测');
return Result.success(true);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error('LivenessDetector', `启动实时活体检测失败: ${errorMessage}`, error instanceof Error ? error : new Error(errorMessage));
return Result.failure(new LivenessDetectionError(`启动实时活体检测失败: ${errorMessage}`));
}
}
/**
* 停止实时处理
*/
stopRealtime(): void {
// 停止人脸检测
this.faceDetector.stopRealtime();
// 停止活体会话
this.stopSession();
if (this._status === ModuleStatus.PROCESSING) {
this.setStatus(ModuleStatus.READY);
}
}
/**
* 释放资源
*/
async dispose(): Promise<void> {
// 停止实时处理
this.stopRealtime();
// 移除事件监听
this.faceDetector.off(ModuleEvent.REALTIME_RESULT, this.handleFaceDetectionResult.bind(this));
this.cameraManager.off(CameraEvent.FRAME, this.handleCameraFrame.bind(this));
// 释放人脸检测器资源
await this.faceDetector.dispose();
this._status = ModuleStatus.NOT_INITIALIZED;
}
/**
* 处理摄像头帧
*/
private handleCameraFrame(event: any): void {
if (this._status !== ModuleStatus.PROCESSING || !event.frameData) {
return;
}
// 使用防抖函数处理帧,减少计算负担
this.debouncedProcessFrame(event.frameData);
}
/**
* 处理视频帧
*/
private processFrame(frameData: ImageData): void {
// 实际处理逻辑委托给人脸检测器
// 我们将通过handleFaceDetectionResult接收结果
}
/**
* 处理人脸检测结果
*/
private handleFaceDetectionResult(event: any): void {
if (!event.results || !Array.isArray(event.results) || event.results.length === 0) {
return;
}
// 仅处理最大的人脸
let bestFace = event.results[0];
let maxArea = bestFace.boundingBox.width * bestFace.boundingBox.height;
for (let i = 1; i < event.results.length; i++) {
const face = event.results[i];
const area = face.boundingBox.width * face.boundingBox.height;
if (area > maxArea) {
bestFace = face;
maxArea = area;
}
}
// 如果没有活跃的会话,不进行处理
if (!this.currentSession || this.currentSession.status !== 'active') {
return;
}
// 检查会话是否超时
const now = Date.now();
if (now - this.currentSession.startTime > this.currentSession.timeout) {
this.currentSession.status = 'timeout';
this.emit('liveness:timeout', { session: this.currentSession });
return;
}
// 根据活体检测类型进行处理
switch (this.currentSession.type) {
case LivenessDetectionType.PASSIVE:
this.handlePassiveLivenessDetection(bestFace);
break;
case LivenessDetectionType.ACTIVE:
this.handleActiveLivenessDetection(bestFace);
break;
case LivenessDetectionType.HYBRID:
this.handleHybridLivenessDetection(bestFace);
break;
}
}
/**
* 处理被动式活体检测
*/
private handlePassiveLivenessDetection(face: FaceDetectionResult): void {
// 添加到历史记录
if (face.landmarks) {
// 添加眼睛状态到历史记录
this.addToHistory(face, 'unknown');
// 执行被动式活体检测
const result = this.performPassiveLivenessDetection(face);
// 如果检测到活体,完成会话
if (result.isLive && this.currentSession) {
this.currentSession.result = result;
this.currentSession.status = 'completed';
this.emit('liveness:detected', {
result,
session: this.currentSession
});
}
}
}
/**
* 处理主动式活体检测
*/
private handleActiveLivenessDetection(face: FaceDetectionResult): void {
if (!this.currentSession || !this.currentSession.requiredActions ||
this.currentSession.currentActionIndex === undefined) {
return;
}
// 添加到历史记录
this.addToHistory(face, 'unknown');
// 获取当前要执行的动作
const currentAction = this.currentSession.requiredActions[this.currentSession.currentActionIndex];
// 检测动作是否完成
let actionCompleted = false;
switch (currentAction) {
case LivenessAction.BLINK:
actionCompleted = this.detectBlink(face);
break;
case LivenessAction.NOD:
actionCompleted = this.detectNod(face);
break;
case LivenessAction.SHAKE:
actionCompleted = this.detectHeadShake(face);
break;
case LivenessAction.SMILE:
actionCompleted = this.detectSmile(face);
break;
case LivenessAction.MOUTH_OPEN:
actionCompleted = this.detectMouthOpen(face);
break;
}
if (actionCompleted) {
// 记录完成的动作
if (!this.currentSession.completedActions) {
this.currentSession.completedActions = [];
}
this.currentSession.completedActions.push(currentAction);
// 进入下一个动作
this.currentSession.currentActionIndex++;
// 发出动作完成事件
this.emit('liveness:action:completed', {
action: currentAction,
session: this.currentSession
});
// 检查是否所有动作都已完成
if (this.currentSession.currentActionIndex >= this.currentSession.requiredActions.length) {
// 所有动作完成,执行最终的活体检测
const result: LivenessDetectionResult = {
isLive: true,
score: 1.0,
type: LivenessDetectionType.ACTIVE,
actions: this.currentSession.completedActions,
processingTime: Date.now() - this.currentSession.startTime
};
// 更新会话状态
this.currentSession.result = result;
this.currentSession.status = 'completed';
// 发出活体检测完成事件
this.emit('liveness:detected', {
result,
session: this.currentSession
});
}
}
}
/**
* 处理混合式活体检测
*/
private handleHybridLivenessDetection(face: FaceDetectionResult): void {
// 同时执行被动和主动检测
this.handlePassiveLivenessDetection(face);
this.handleActiveLivenessDetection(face);
}
/**
* 执行被动式活体检测
*/
private performPassiveLivenessDetection(face: FaceDetectionResult): LivenessDetectionResult {
// 初始分数
let livenessScore = 0.5;
// 检查人脸关键点和属性
if (face.landmarks) {
// 眨眼检测提高活体可能性
const hasBlinkHistory = this.detectionHistory.length > 5;
if (hasBlinkHistory) {
const blinkDetected = this.detectBlink(face);
if (blinkDetected) {
livenessScore += 0.3;
}
}
// 检查面部表情变化
const hasExpressionChange = this.detectionHistory.length > 5 && face.attributes?.emotion;
if (hasExpressionChange && face.attributes && face.attributes.emotion) {
const emotions = face.attributes.emotion;
const emotionValues = Object.values(emotions || {});
const emotionVariance = emotionValues.reduce((sum, val) => sum + Math.pow(val! - 0.5, 2), 0) / emotionValues.length;
// 表情变化提高活体可能性
if (emotionVariance > 0.1) {
livenessScore += 0.15;
}
}
// 检查微小的头部移动
if (this.detectionHistory.length > 3) {
const recentFaces = this.detectionHistory.slice(-3).map(h => h.faceResult);
const hasMovement = recentFaces.some((f, i) => {
if (i === 0) return false;
const prev = recentFaces[i - 1];
// 计算人脸中心点的移动
const prevCenter = {
x: prev.boundingBox.x + prev.boundingBox.width / 2,
y: prev.boundingBox.y + prev.boundingBox.height / 2
};
const currCenter = {
x: f.boundingBox.x + f.boundingBox.width / 2,
y: f.boundingBox.y + f.boundingBox.height / 2
};
// 小幅度移动更像真人
const dist = this.distance(prevCenter, currCenter);
return dist > 1 && dist < 20; // 小幅度移动
});
if (hasMovement) {
livenessScore += 0.1;
}
}
}
// 限制分数范围在0-1之间
livenessScore = Math.max(0, Math.min(1, livenessScore));
// 根据阈值确定是否为活体
const isLive = livenessScore >= this.config.confidenceThreshold;
return {
isLive,
score: livenessScore,
type: LivenessDetectionType.PASSIVE,
processingTime: 0
};
}
/**
* 添加检测结果到历史记录
*/
private addToHistory(face: FaceDetectionResult, eyeState: 'open' | 'closed' | 'unknown'): void {
// 添加到历史记录
this.detectionHistory.push({
timestamp: Date.now(),
eyeState,
faceResult: face
});
// 限制历史长度
if (this.detectionHistory.length > this.MAX_HISTORY_LENGTH) {
this.detectionHistory.shift();
}
}
/**
* 计算眼睛纵横比(EAR)
* EAR是一种度量眼睛开合程度的指标
*/
private calculateEyeAspectRatio(face: FaceDetectionResult): number | null {
if (!face.landmarks || !face.landmarks.points || face.landmarks.points.length < 68) {
return null;
}
const points = face.landmarks.points;
// 68点人脸模型中的眼睛索引
// 左眼:36-41, 右眼:42-47
const leftEye = [36, 37, 38, 39, 40, 41].map(i => points[i]);
const rightEye = [42, 43, 44, 45, 46, 47].map(i => points[i]);
// 计算左眼EAR
const leftEAR = (
this.distance(leftEye[1], leftEye[5]) + this.distance(leftEye[2], leftEye[4])
) / (2 * this.distance(leftEye[0], leftEye[3]));
// 计算右眼EAR
const rightEAR = (
this.distance(rightEye[1], rightEye[5]) + this.distance(rightEye[2], rightEye[4])
) / (2 * this.distance(rightEye[0], rightEye[3]));
// 返回平均EAR
return (leftEAR + rightEAR) / 2;
}
/**
* 计算两点之间的距离
*/
private distance(p1: { x: number, y: number }, p2: { x: number, y: number }): number {
return Math.sqrt(
Math.pow(p2.x - p1.x, 2) +
Math.pow(p2.y - p1.y, 2)
);
}
/**
* 检测眨眼动作
*/
private detectBlink(face: FaceDetectionResult): boolean {
// 如果历史记录不足,无法检测
if (this.detectionHistory.length < 5) {
return false;
}
// 计算当前帧的EAR
const currentEAR = this.calculateEyeAspectRatio(face);
if (currentEAR === null) {
return false;
}
// 获取短时间窗口内的帧
const recentHistory = this.detectionHistory.slice(-10);
const historyEARs = recentHistory
.map(h => this.calculateEyeAspectRatio(h.faceResult))
.filter(ear => ear !== null) as number[];
if (historyEARs.length < 3) {
return false;
}
// 计算眼睛状态序列
const eyeStates = historyEARs.map(ear => {
if (ear < this.config.blinkThresholds.eyeClosedThreshold) {
return 'closed';
} else if (ear > this.config.blinkThresholds.eyeOpenThreshold) {
return 'open';
} else {
return 'transition';
}
});
// 检查是否存在从"open"到"closed"再到"open"的序列
let hasOpenState = false;
let hasClosedState = false;
let hasOpenStateAfterClosed = false;
for (const state of eyeStates) {
if (state === 'open') {
if (!hasClosedState) {
hasOpenState = true;
} else {
hasOpenStateAfterClosed = true;
break;
}
} else if (state === 'closed' && hasOpenState) {
hasClosedState = true;
}
}
return hasOpenState && hasClosedState && hasOpenStateAfterClosed;
}
/**
* 检测点头动作
*/
private detectNod(face: FaceDetectionResult): boolean {
// 如果历史记录不足,无法检测
if (this.detectionHistory.length < 8) {
return false;
}
// 跟踪鼻尖位置的垂直变化
if (!face.landmarks || !face.landmarks.nose) {
return false;
}
const nosePositions = this.detectionHistory
.slice(-8)
.map(h => h.faceResult.landmarks?.nose?.y);
if (nosePositions.some(y => y === undefined)) {
return false;
}
// 计算垂直位移的差异
const deltas = [];
for (let i = 1; i < nosePositions.length; i++) {
deltas.push(nosePositions[i]! - nosePositions[i - 1]!);
}
// 检查上下移动的模式
// 我们寻找垂直方向的位移符号变化,表示向上/向下移动
let directionChanges = 0;
for (let i = 1; i < deltas.length; i++) {
if ((deltas[i] > 0 && deltas[i - 1] < 0) || (deltas[i] < 0 && deltas[i - 1] > 0)) {
directionChanges++;
}
}
// 至少需要2次方向变化,表示下点头动作
return directionChanges >= 2;
}
/**
* 检测摇头动作
*/
private detectHeadShake(face: FaceDetectionResult): boolean {
// 如果历史记录不足,无法检测
if (this.detectionHistory.length < 8) {
return false;
}
// 跟踪鼻尖位置的水平变化
if (!face.landmarks || !face.landmarks.nose) {
return false;
}
const nosePositions = this.detectionHistory
.slice(-8)
.map(h => h.faceResult.landmarks?.nose?.x);
if (nosePositions.some(x => x === undefined)) {
return false;
}
// 计算水平位移的差异
const deltas = [];
for (let i = 1; i < nosePositions.length; i++) {
deltas.push(nosePositions[i]! - nosePositions[i - 1]!);
}
// 检查左右移动的模式
// 我们寻找水平方向的位移符号变化,表示左/右移动
let directionChanges = 0;
for (let i = 1; i < deltas.length; i++) {
if ((deltas[i] > 0 && deltas[i - 1] < 0) || (deltas[i] < 0 && deltas[i - 1] > 0)) {
directionChanges++;
}
}
// 至少需要2次方向变化,表示摇头动作
return directionChanges >= 2;
}
/**
* 检测微笑动作
*/
private detectSmile(face: FaceDetectionResult): boolean {
if (!face.attributes || !face.attributes.emotion) {
return false;
}
// 检查高兴情绪值
const happyScore = face.attributes.emotion.happy;
// 阈值设为0.7,高于此值认为是微笑
return happyScore !== undefined && happyScore > 0.7;
}
/**
* 检测张嘴动作
*/
private detectMouthOpen(face: FaceDetectionResult): boolean {
if (!face.landmarks || !face.landmarks.points || face.landmarks.points.length < 68) {
return false;
}
const points = face.landmarks.points;
// 68点人脸模型中的嘴巴索引
// 上唇:50-53, 下唇:56-59
const topLip = points[51]; // 上唇中心
const bottomLip = points[57]; // 下唇中心
// 嘴巴高度
const mouthHeight = this.distance(topLip, bottomLip);
// 计算嘴巴相对于人脸高度的比例
const faceHeight = face.boundingBox.height;
const mouthRatio = mouthHeight / faceHeight;
// 嘴巴开度阈值(约占人脸高度的10%)
return mouthRatio > 0.1;
}
}