@sssxyd/face-liveness-detector 0.4.1-beta.5 → 0.4.1-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.esm.js CHANGED
@@ -10,8 +10,10 @@ var LivenessAction;
10
10
  LivenessAction["BLINK"] = "blink";
11
11
  // Mouth open
12
12
  LivenessAction["MOUTH_OPEN"] = "mouth_open";
13
- // Nod
14
- LivenessAction["NOD"] = "nod";
13
+ // Nod down (look down)
14
+ LivenessAction["NOD_DOWN"] = "nod_down";
15
+ // Nod up (look up)
16
+ LivenessAction["NOD_UP"] = "nod_up";
15
17
  })(LivenessAction || (LivenessAction = {}));
16
18
  /**
17
19
  * Liveness action status enumeration
@@ -20,6 +22,7 @@ var LivenessActionStatus;
20
22
  (function (LivenessActionStatus) {
21
23
  LivenessActionStatus["STARTED"] = "started";
22
24
  LivenessActionStatus["COMPLETED"] = "completed";
25
+ LivenessActionStatus["MISMATCH"] = "mismatch";
23
26
  LivenessActionStatus["TIMEOUT"] = "timeout";
24
27
  })(LivenessActionStatus || (LivenessActionStatus = {}));
25
28
  var DetectionPeriod;
@@ -38,7 +41,6 @@ var DetectionCode;
38
41
  DetectionCode["FACE_TOO_SMALL"] = "FACE_TOO_SMALL";
39
42
  DetectionCode["FACE_TOO_LARGE"] = "FACE_TOO_LARGE";
40
43
  DetectionCode["FACE_NOT_FRONTAL"] = "FACE_NOT_FRONTAL";
41
- DetectionCode["FACE_NOT_REAL"] = "FACE_NOT_REAL";
42
44
  DetectionCode["FACE_NOT_LIVE"] = "FACE_NOT_LIVE";
43
45
  DetectionCode["FACE_LOW_QUALITY"] = "FACE_LOW_QUALITY";
44
46
  DetectionCode["FACE_CHECK_PASS"] = "FACE_CHECK_PASS";
@@ -103,10 +105,10 @@ const DEFAULT_OPTIONS$1 = {
103
105
  min_blur_score: 0.6
104
106
  },
105
107
  // action Liveness Settings
106
- action_liveness_action_list: [LivenessAction.BLINK, LivenessAction.MOUTH_OPEN, LivenessAction.NOD],
108
+ action_liveness_action_list: [LivenessAction.BLINK, LivenessAction.MOUTH_OPEN, LivenessAction.NOD_DOWN, LivenessAction.NOD_UP],
107
109
  action_liveness_action_count: 1,
108
110
  action_liveness_action_randomize: true,
109
- action_liveness_verify_timeout: 60000,
111
+ action_liveness_verify_timeout: 15000,
110
112
  action_liveness_min_mouth_open_percent: 0.2,
111
113
  };
112
114
  /**
@@ -1697,7 +1699,7 @@ class MotionLivenessDetector {
1697
1699
  // 综合判定(结合正向和逆向检测)
1698
1700
  const isLively = this.makeLivenessDecision(eyeActivity, mouthActivity, muscleActivity, photoGeometryResult);
1699
1701
  return new MotionDetectionResult(isLively, {
1700
- frameCount: Math.max(this.eyeAspectRatioHistory.length, this.mouthAspectRatioHistory.length),
1702
+ frameCount: Math.max(this.eyeAspectRatioHistory.length, this.mouthAspectRatioHistory.length, this.faceLandmarksHistory.length, this.normalizedLandmarksHistory.length),
1701
1703
  // 正向检测结果(生物特征)
1702
1704
  eyeAspectRatioStdDev: eyeActivity.stdDev,
1703
1705
  mouthAspectRatioStdDev: mouthActivity.stdDev,
@@ -1890,10 +1892,13 @@ class MotionLivenessDetector {
1890
1892
  * 检测面部肌肉的微动(关键点位置微妙变化)
1891
1893
  * 关键:允许刚性运动+生物特征(真人摇头),拒绝纯刚性运动(照片旋转)
1892
1894
  *
1893
- * 【重要修复】使用归一化坐标进行比较,消除人脸在画面中移动的影响
1895
+ * 【合理使用归一化坐标】这里使用归一化坐标是有意义的,因为:
1896
+ * - 目的是检测肌肉的【相对运动幅度】,与人脸尺寸无关
1897
+ * - 消除人脸在画面中位置变化的影响
1898
+ * - 用相对于人脸的比例运动来判断肌肉活动
1894
1899
  */
1895
1900
  detectMuscleMovement() {
1896
- // 【关键】使用归一化坐标历史,而非绝对坐标
1901
+ // 使用归一化坐标历史,消除人脸位置和尺寸影响
1897
1902
  if (this.normalizedLandmarksHistory.length < 2) {
1898
1903
  return { score: 0, variation: 0, hasMovement: false };
1899
1904
  }
@@ -1913,7 +1918,7 @@ class MotionLivenessDetector {
1913
1918
  127, 356 // 脸颊
1914
1919
  ];
1915
1920
  const distances = [];
1916
- // 【关键】使用归一化坐标计算位移
1921
+ // 使用归一化坐标计算相对位移
1917
1922
  for (let i = 1; i < this.normalizedLandmarksHistory.length; i++) {
1918
1923
  const prevFrame = this.normalizedLandmarksHistory[i - 1];
1919
1924
  const currFrame = this.normalizedLandmarksHistory[i];
@@ -2014,12 +2019,14 @@ class MotionLivenessDetector {
2014
2019
  * - 照片所有关键点运动是【刚性的】→ 所有点以相同方向、相似幅度移动
2015
2020
  * - 活体肌肉运动是【非刚性的】→ 不同部位独立运动(眼睛、嘴、脸颊等)
2016
2021
  *
2017
- * 【重要修复】使用归一化坐标进行比较
2022
+ * 【合理使用归一化坐标】这里使用归一化坐标是有意义的,因为:
2023
+ * - 消除人脸在画面中的平移,只关注人脸内部的相对运动模式
2024
+ * - 检测的是运动向量的方向一致性,不依赖绝对坐标
2018
2025
  *
2019
2026
  * 返回值 0-1:值越接近1说明是刚性运动(照片运动)
2020
2027
  */
2021
2028
  detectRigidMotion() {
2022
- // 【关键】使用归一化坐标历史
2029
+ // 使用归一化坐标历史,消除平移影响
2023
2030
  if (this.normalizedLandmarksHistory.length < 2) {
2024
2031
  return 0; // 数据不足,不判定为刚性运动
2025
2032
  }
@@ -2032,7 +2039,7 @@ class MotionLivenessDetector {
2032
2039
  61, 291 // 嘴角
2033
2040
  ];
2034
2041
  const motionVectors = [];
2035
- // 【关键】使用归一化坐标计算运动向量
2042
+ // 使用最近两帧计算运动向量
2036
2043
  const frame1 = this.normalizedLandmarksHistory[this.normalizedLandmarksHistory.length - 2];
2037
2044
  const frame2 = this.normalizedLandmarksHistory[this.normalizedLandmarksHistory.length - 1];
2038
2045
  for (const ptIdx of samplePoints) {
@@ -2358,7 +2365,7 @@ class MotionLivenessDetector {
2358
2365
  * 2. 跨帧深度模式 - 辅助参考
2359
2366
  */
2360
2367
  detectPhotoGeometry() {
2361
- if (this.faceLandmarksHistory.length < 3) {
2368
+ if (this.normalizedLandmarksHistory.length < 3) {
2362
2369
  return { isPhoto: false, confidence: 0, details: {} };
2363
2370
  }
2364
2371
  // 【核心检测1】平面单应性约束检测(最可靠,纯2D几何)
@@ -2371,12 +2378,13 @@ class MotionLivenessDetector {
2371
2378
  const depthResult = this.detectDepthConsistency();
2372
2379
  const crossFrameDepth = this.detectCrossFrameDepthPattern();
2373
2380
  // 综合判定:2D几何约束权重高,Z坐标权重低
2374
- const photoScore = homographyResult.planarScore * 0.35 + // 单应性约束(最可靠)
2375
- perspectivePattern.perspectiveScore * 0.30 + // 透视变换模式(可靠)
2376
- crossRatioResult.invarianceScore * 0.20 + // 交叉比率不变性(可靠)
2377
- (1 - depthResult.depthVariation) * 0.10 + // 深度(辅助,低权重)
2381
+ // 【改进】提高perspectiveScore的权重,因为完美的平滑变换是照片的强特征
2382
+ const photoScore = homographyResult.planarScore * 0.30 + // 单应性约束(最可靠)
2383
+ perspectivePattern.perspectiveScore * 0.40 + // 透视变换模式(可靠且权重提高)
2384
+ crossRatioResult.invarianceScore * 0.15 + // 交叉比率不变性(可靠)
2385
+ (1 - Math.min(depthResult.depthVariation, 1)) * 0.10 + // 深度(辅助,低权重)
2378
2386
  crossFrameDepth.planarPattern * 0.05; // 跨帧深度(辅助,低权重)
2379
- const isPhoto = photoScore > 0.60; // 阈值
2387
+ const isPhoto = photoScore > 0.50; // 【改进】降低阈值到0.50(从0.60)
2380
2388
  const confidence = Math.min(photoScore, 1);
2381
2389
  // 记录历史
2382
2390
  this.planarityScores.push(photoScore);
@@ -2422,18 +2430,19 @@ class MotionLivenessDetector {
2422
2430
  * - 真实3D人脸旋转时,面部各点不共面,交叉比率会变化
2423
2431
  * - 照片无论怎么偏转,共线点的交叉比率保持不变
2424
2432
  *
2425
- * 【注意】交叉比率本身是比率,不依赖绝对坐标
2426
- * 使用归一化坐标只是为了一致性
2433
+ * 虽然交叉比率是射影不变量(用任何坐标系都可以),
2434
+ * 但使用原始坐标以保持一致性和物理意义的清晰性
2427
2435
  */
2428
2436
  detectCrossRatioInvariance() {
2429
- // 【使用归一化坐标历史,保持一致性】
2430
- if (this.normalizedLandmarksHistory.length < 3) {
2437
+ // 【使用原始坐标历史】虽然交叉比率是射影不变量,
2438
+ // 但原始坐标保持物理清晰性
2439
+ if (this.faceLandmarksHistory.length < 3) {
2431
2440
  return { invarianceScore: 0 };
2432
2441
  }
2433
2442
  // 选择面部中线上近似共线的点(额头-鼻梁-鼻尖-嘴-下巴)
2434
2443
  const midlinePoints = [10, 168, 1, 0, 152]; // 从上到下
2435
2444
  const crossRatios = [];
2436
- for (const frame of this.normalizedLandmarksHistory) {
2445
+ for (const frame of this.faceLandmarksHistory) {
2437
2446
  if (frame.length < 468)
2438
2447
  continue;
2439
2448
  // 提取中线点的Y坐标(它们大致在一条垂直线上)
@@ -2489,118 +2498,366 @@ class MotionLivenessDetector {
2489
2498
  /**
2490
2499
  * 【单应性约束检测】判断多帧特征点是否满足平面约束
2491
2500
  *
2492
- * 【重要修复】使用归一化坐标进行比较
2501
+ * 【关键改进】:
2502
+ * 1. 使用DLT算法计算完整的3x3单应性矩阵(8参数)
2503
+ * 2. 使用相邻帧而不是首尾帧(减少变化幅度)
2504
+ * 3. 检查单应性矩阵的性质(秩、行列式等)
2505
+ * 4. 计算多帧的平均误差(更稳定)
2506
+ * 5. 对所有帧对的H矩阵一致性进行验证
2507
+ *
2493
2508
  * 这是纯 2D 几何检测,最可靠!
2494
2509
  */
2495
2510
  detectHomographyConstraint() {
2496
- // 【关键】使用归一化坐标历史
2497
- if (this.normalizedLandmarksHistory.length < 2) {
2511
+ // 【关键】使用原始坐标历史,而不是归一化坐标
2512
+ // 原因:单应性矩阵在原始图像坐标中定义
2513
+ // 归一化坐标虽然消除平移影响,但破坏了H矩阵的定义
2514
+ if (this.faceLandmarksHistory.length < 2) {
2498
2515
  return { planarScore: 0 };
2499
2516
  }
2500
- const frame1 = this.normalizedLandmarksHistory[0];
2501
- const frame2 = this.normalizedLandmarksHistory[this.normalizedLandmarksHistory.length - 1];
2502
- if (frame1.length < 468 || frame2.length < 468) {
2503
- return { planarScore: 0, error: 0 };
2504
- }
2505
- // 选择用于计算单应性的4个基准点(面部四角)
2506
- const basePoints = [10, 152, 234, 454]; // 额头、下巴、左脸颊、右脸颊
2507
- // 选择用于验证的检验点
2508
- const testPoints = [33, 263, 61, 291, 1, 168]; // 眼角、嘴角、鼻尖、鼻梁
2509
- // 提取基准点坐标(归一化后的坐标)
2510
- const srcBase = [];
2511
- const dstBase = [];
2512
- for (const idx of basePoints) {
2513
- if (frame1[idx] && frame2[idx]) {
2514
- srcBase.push([frame1[idx][0], frame1[idx][1]]);
2515
- dstBase.push([frame2[idx][0], frame2[idx][1]]);
2517
+ // 【改进】使用所有面部关键点(468个点)而不是采样点
2518
+ // 更多的点对会给出更准确的单应性矩阵估计
2519
+ const errors = [];
2520
+ const homographyMatrices = [];
2521
+ // 计算相邻帧的变换误差
2522
+ for (let i = 1; i < this.faceLandmarksHistory.length; i++) {
2523
+ const frame1 = this.faceLandmarksHistory[i - 1];
2524
+ const frame2 = this.faceLandmarksHistory[i];
2525
+ if (frame1.length < 468 || frame2.length < 468)
2526
+ continue;
2527
+ // 【改进】收集所有有效的点对(而不是只采样10个点)
2528
+ // 这给出更好的H矩阵估计
2529
+ const srcPoints = [];
2530
+ const dstPoints = [];
2531
+ for (let ptIdx = 0; ptIdx < frame1.length; ptIdx++) {
2532
+ if (frame1[ptIdx] && frame2[ptIdx] &&
2533
+ frame1[ptIdx].length >= 2 && frame2[ptIdx].length >= 2) {
2534
+ // 使用x, y原始坐标
2535
+ srcPoints.push([frame1[ptIdx][0], frame1[ptIdx][1]]);
2536
+ dstPoints.push([frame2[ptIdx][0], frame2[ptIdx][1]]);
2537
+ }
2516
2538
  }
2517
- }
2518
- if (srcBase.length < 4) {
2519
- return { planarScore: 0, error: 0 };
2520
- }
2521
- // 计算简化的仿射变换(近似单应性)
2522
- // 使用最小二乘法拟合仿射变换 [a, b, c; d, e, f]
2523
- const transform = this.estimateAffineTransform(srcBase, dstBase);
2524
- if (!transform) {
2525
- return { planarScore: 0, error: 0 };
2526
- }
2527
- // 用仿射变换预测检验点位置,计算误差
2528
- let totalError = 0;
2529
- let validPoints = 0;
2530
- for (const idx of testPoints) {
2531
- if (frame1[idx] && frame2[idx]) {
2532
- const predicted = this.applyAffineTransform(transform, frame1[idx][0], frame1[idx][1]);
2533
- const actual = [frame2[idx][0], frame2[idx][1]];
2534
- // 归一化坐标下的误差(相对于人脸尺寸的比例)
2535
- const error = Math.sqrt((predicted[0] - actual[0]) ** 2 + (predicted[1] - actual[1]) ** 2);
2536
- totalError += error;
2537
- validPoints++;
2539
+ if (srcPoints.length < 4)
2540
+ continue;
2541
+ // 【新增】使用DLT算法计算完整的3x3单应性矩阵
2542
+ const H = this.estimateHomographyDLT(srcPoints, dstPoints);
2543
+ if (!H)
2544
+ continue;
2545
+ homographyMatrices.push(H);
2546
+ // 【改进】使用单应性矩阵计算误差(而不是仿射变换)
2547
+ let frameError = 0;
2548
+ let validCount = 0;
2549
+ for (let j = 0; j < srcPoints.length; j++) {
2550
+ const transformed = this.applyHomography(H, srcPoints[j][0], srcPoints[j][1]);
2551
+ const actual = dstPoints[j];
2552
+ const error = Math.sqrt((transformed[0] - actual[0]) ** 2 + (transformed[1] - actual[1]) ** 2);
2553
+ frameError += error;
2554
+ validCount++;
2555
+ }
2556
+ if (validCount > 0) {
2557
+ errors.push(frameError / validCount);
2538
2558
  }
2539
2559
  }
2540
- if (validPoints === 0) {
2560
+ if (errors.length === 0) {
2541
2561
  return { planarScore: 0, error: 0 };
2542
2562
  }
2543
- const avgError = totalError / validPoints;
2544
- // 归一化坐标下,误差已经是相对于人脸尺寸的比例
2545
- // 不需要再除以脸宽
2546
- const relativeError = avgError;
2547
- // 平面得分:误差越小,越可能是平面(照片)
2548
- // relativeError < 0.02 → 非常可能是平面
2549
- // relativeError > 0.08 → 不太可能是平面
2550
- const planarScore = Math.max(0, 1 - relativeError / 0.05);
2551
- // 记录误差历史
2552
- this.homographyErrors.push(relativeError);
2553
- if (this.homographyErrors.length > this.config.frameBufferSize) {
2554
- this.homographyErrors.shift();
2555
- }
2556
- return { planarScore: Math.min(planarScore, 1), error: relativeError };
2563
+ // 计算所有帧的平均误差
2564
+ const avgError = errors.reduce((a, b) => a + b, 0) / errors.length;
2565
+ // 【新增】检查H矩阵的一致性
2566
+ // 照片的H矩阵在不同帧对中应该保持相对稳定
2567
+ let matrixConsistency = 1.0;
2568
+ if (homographyMatrices.length > 1) {
2569
+ matrixConsistency = this.checkHomographyConsistency(homographyMatrices);
2570
+ }
2571
+ // 平面得分 = 误差低 且 H矩阵一致
2572
+ // avgError < 0.01 → 非常可能是平面
2573
+ // avgError > 0.05 → 可能是立体(活体)
2574
+ const errorScore = Math.max(0, 1 - avgError / 0.03);
2575
+ const planarScore = errorScore * matrixConsistency;
2576
+ console.debug('[HomographyConstraint]', {
2577
+ frameCount: errors.length,
2578
+ avgError: avgError.toFixed(4),
2579
+ errorScore: errorScore.toFixed(3),
2580
+ matrixConsistency: matrixConsistency.toFixed(3),
2581
+ planarScore: planarScore.toFixed(3)
2582
+ });
2583
+ return { planarScore: Math.min(planarScore, 1), error: avgError };
2557
2584
  }
2558
2585
  /**
2559
- * 估计仿射变换矩阵 (简化的单应性)
2560
- * 输入:源点和目标点对
2561
- * 输出:[a, b, c, d, e, f] 表示变换 x' = ax + by + c, y' = dx + ey + f
2586
+ * 估计仿射变换矩阵
2587
+ * 【改进】使用完整的6参数仿射变换(包含剪切分量)
2588
+ *
2589
+ * 变换形式: x' = ax + by + c, y' = dx + ey + f
2590
+ * 其中 [a, b] 和 [d, e] 可以包含旋转、缩放、剪切分量
2591
+ /**
2592
+ * 【新增】使用DLT算法估计单应性矩阵
2593
+ *
2594
+ * DLT (Direct Linear Transform) 是估计射影变换的标准算法
2595
+ * 输入:源点和目标点对 (至少4对)
2596
+ * 输出:3x3单应性矩阵H,使得 p' = H * p (齐次坐标)
2597
+ *
2598
+ * 相比仿射变换:
2599
+ * - 仿射:6参数,处理旋转+缩放+平移+剪切
2600
+ * - 单应性:8参数,处理完整的射影变换(包括透视)
2601
+ *
2602
+ * 照片倾斜拍摄时,需要完整的单应性矩阵!
2562
2603
  */
2563
- estimateAffineTransform(src, dst) {
2564
- if (src.length < 3 || dst.length < 3)
2604
+ estimateHomographyDLT(src, dst) {
2605
+ if (src.length < 4 || dst.length < 4 || src.length !== dst.length)
2606
+ return null;
2607
+ const n = src.length;
2608
+ // 【关键】对点进行归一化,提高数值稳定性
2609
+ const srcNorm = this.normalizePoints(src);
2610
+ const dstNorm = this.normalizePoints(dst);
2611
+ if (!srcNorm || !dstNorm)
2565
2612
  return null;
2566
- const n = Math.min(src.length, dst.length);
2567
- // 构建方程组 Ax = b (最小二乘)
2568
- // 对于 x': [x1, y1, 1, 0, 0, 0] * [a,b,c,d,e,f]^T = x1'
2569
- // 对于 y': [0, 0, 0, x1, y1, 1] * [a,b,c,d,e,f]^T = y1'
2570
- let sumX = 0, sumY = 0, sumX2 = 0, sumY2 = 0;
2571
- let sumXpX = 0, sumYpY = 0, sumXp = 0, sumYp = 0;
2613
+ // 构建DLT方程矩阵 A (2n x 9)
2614
+ // 对每对点,构建2行方程:
2615
+ // [-x, -y, -1, 0, 0, 0, x*x', y*x', x']
2616
+ // [0, 0, 0, -x, -y, -1, x*y', y*y', y']
2617
+ const A = [];
2572
2618
  for (let i = 0; i < n; i++) {
2573
- const x = src[i][0], y = src[i][1];
2574
- const xp = dst[i][0], yp = dst[i][1];
2575
- sumX += x;
2576
- sumY += y;
2577
- sumX2 += x * x;
2578
- sumY2 += y * y;
2579
- sumXpX += xp * x;
2580
- sumXp += xp;
2581
- sumYpY += yp * y;
2582
- sumYp += yp;
2583
- }
2584
- // 计算缩放和旋转(简化版本)
2585
- const det = sumX2 * n - sumX * sumX;
2619
+ const x = srcNorm.points[i][0];
2620
+ const y = srcNorm.points[i][1];
2621
+ const xp = dstNorm.points[i][0];
2622
+ const yp = dstNorm.points[i][1];
2623
+ // 第一行
2624
+ A.push([-x, -y, -1, 0, 0, 0, x * xp, y * xp, xp]);
2625
+ // 第二行
2626
+ A.push([0, 0, 0, -x, -y, -1, x * yp, y * yp, yp]);
2627
+ }
2628
+ // 使用SVD求解 Ah = 0
2629
+ // h 是最小奇异值对应的右奇异向量
2630
+ const h = this.solveHomographyLSQ(A);
2631
+ if (!h)
2632
+ return null;
2633
+ // 反演应化矩阵(从归一化坐标回到原始坐标)
2634
+ const H = this.denormalizeHomography(h, srcNorm, dstNorm);
2635
+ return H;
2636
+ }
2637
+ /**
2638
+ * 点集归一化(提高数值稳定性)
2639
+ * 变换点使得重心在原点,平均距离为sqrt(2)
2640
+ */
2641
+ normalizePoints(points) {
2642
+ if (points.length === 0)
2643
+ return null;
2644
+ // 计算重心
2645
+ let cx = 0, cy = 0;
2646
+ for (const p of points) {
2647
+ cx += p[0];
2648
+ cy += p[1];
2649
+ }
2650
+ cx /= points.length;
2651
+ cy /= points.length;
2652
+ // 计算平均距离
2653
+ let avgDist = 0;
2654
+ for (const p of points) {
2655
+ const dx = p[0] - cx;
2656
+ const dy = p[1] - cy;
2657
+ avgDist += Math.sqrt(dx * dx + dy * dy);
2658
+ }
2659
+ avgDist /= points.length;
2660
+ // 缩放因子
2661
+ const scale = avgDist > 0.001 ? Math.sqrt(2) / avgDist : 1;
2662
+ // 应用归一化变换
2663
+ const normalized = [];
2664
+ for (const p of points) {
2665
+ normalized.push([
2666
+ (p[0] - cx) * scale,
2667
+ (p[1] - cy) * scale
2668
+ ]);
2669
+ }
2670
+ // 归一化矩阵 T
2671
+ const T = [
2672
+ [scale, 0, -cx * scale],
2673
+ [0, scale, -cy * scale],
2674
+ [0, 0, 1]
2675
+ ];
2676
+ return { points: normalized, T };
2677
+ }
2678
+ /**
2679
+ * 最小二乘法求解 Ah = 0
2680
+ * 其中 h 是3x3矩阵的向量化形式
2681
+ */
2682
+ solveHomographyLSQ(A) {
2683
+ // 简化的SVD求解:找使 ||Ah|| 最小的 h
2684
+ // 为了简化,使用迭代最小二乘法或对称矩阵的特征向量
2685
+ if (A.length < 8)
2686
+ return null;
2687
+ // A^T * A 矩阵 (9x9)
2688
+ const ATA = Array(9).fill(0).map(() => Array(9).fill(0));
2689
+ for (let i = 0; i < 9; i++) {
2690
+ for (let j = 0; j < 9; j++) {
2691
+ for (let k = 0; k < A.length; k++) {
2692
+ ATA[i][j] += A[k][i] * A[k][j];
2693
+ }
2694
+ }
2695
+ }
2696
+ // 求ATA的最小特征向量(对应最小特征值)
2697
+ const eigenVec = this.getSmallestEigenvector(ATA);
2698
+ return eigenVec;
2699
+ }
2700
+ /**
2701
+ * 求3x3对称矩阵的最小特征向量(简化版本)
2702
+ * 使用幂迭代法或直接求解
2703
+ */
2704
+ getSmallestEigenvector(mat) {
2705
+ if (mat.length !== 9)
2706
+ return null;
2707
+ // 为了简化,使用初步估计:取行和最小的方向
2708
+ // 或者使用固定迭代次数的幂法
2709
+ // 简化:返回一个初步猜测的向量
2710
+ // 实际应用应该使用完整的SVD或特征值分解库
2711
+ // 这里使用Power Iteration的反向版本(找最小特征值)
2712
+ let v = [1, 1, 1, 1, 1, 1, 1, 1, 1];
2713
+ for (let iter = 0; iter < 10; iter++) {
2714
+ // v_new = A * v
2715
+ const v_new = [0, 0, 0, 0, 0, 0, 0, 0, 0];
2716
+ for (let i = 0; i < 9; i++) {
2717
+ for (let j = 0; j < 9; j++) {
2718
+ v_new[i] += mat[i][j] * v[j];
2719
+ }
2720
+ }
2721
+ // 归一化
2722
+ const norm = Math.sqrt(v_new.reduce((a, b) => a + b * b, 0));
2723
+ if (norm < 0.0001)
2724
+ break;
2725
+ for (let i = 0; i < 9; i++) {
2726
+ v[i] = v_new[i] / norm;
2727
+ }
2728
+ }
2729
+ return v;
2730
+ }
2731
+ /**
2732
+ * 反演应化矩阵
2733
+ * H_orig = T_dst^-1 * H_norm * T_src
2734
+ */
2735
+ denormalizeHomography(h, srcNorm, dstNorm) {
2736
+ // 将向量h转换为3x3矩阵
2737
+ const H_norm = [
2738
+ [h[0], h[1], h[2]],
2739
+ [h[3], h[4], h[5]],
2740
+ [h[6], h[7], h[8]]
2741
+ ];
2742
+ // H = T_dst^-1 * H_norm * T_src
2743
+ const T_src = srcNorm.T;
2744
+ const T_dst = dstNorm.T;
2745
+ const T_dst_inv = this.invertMatrix3x3(T_dst);
2746
+ if (!T_dst_inv)
2747
+ return H_norm;
2748
+ // 矩阵乘法:(3x3) * (3x3) * (3x3)
2749
+ const temp = this.multiplyMatrix3x3(T_dst_inv, H_norm);
2750
+ const H = this.multiplyMatrix3x3(temp, T_src);
2751
+ return H;
2752
+ }
2753
+ /**
2754
+ * 3x3矩阵求逆
2755
+ */
2756
+ invertMatrix3x3(m) {
2757
+ const [m00, m01, m02] = m[0];
2758
+ const [m10, m11, m12] = m[1];
2759
+ const [m20, m21, m22] = m[2];
2760
+ const det = m00 * (m11 * m22 - m12 * m21) -
2761
+ m01 * (m10 * m22 - m12 * m20) +
2762
+ m02 * (m10 * m21 - m11 * m20);
2586
2763
  if (Math.abs(det) < 0.0001)
2587
2764
  return null;
2588
- const a = (sumXpX * n - sumXp * sumX) / (sumX2 * n - sumX * sumX + 0.0001);
2589
- const b = 0; // 简化,忽略剪切
2590
- const d = 0;
2591
- const e = (sumYpY * n - sumYp * sumY) / (sumY2 * n - sumY * sumY + 0.0001);
2592
- const c = sumXp / n - a * sumX / n;
2593
- const f = sumYp / n - e * sumY / n;
2594
- return [a || 1, b, c || 0, d, e || 1, f || 0];
2765
+ const inv = [
2766
+ [
2767
+ (m11 * m22 - m12 * m21) / det,
2768
+ (m02 * m21 - m01 * m22) / det,
2769
+ (m01 * m12 - m02 * m11) / det
2770
+ ],
2771
+ [
2772
+ (m12 * m20 - m10 * m22) / det,
2773
+ (m00 * m22 - m02 * m20) / det,
2774
+ (m02 * m10 - m00 * m12) / det
2775
+ ],
2776
+ [
2777
+ (m10 * m21 - m11 * m20) / det,
2778
+ (m01 * m20 - m00 * m21) / det,
2779
+ (m00 * m11 - m01 * m10) / det
2780
+ ]
2781
+ ];
2782
+ return inv;
2595
2783
  }
2596
2784
  /**
2597
- * 应用仿射变换
2785
+ * 3x3矩阵乘法
2598
2786
  */
2599
- applyAffineTransform(t, x, y) {
2600
- return [
2601
- t[0] * x + t[1] * y + t[2],
2602
- t[3] * x + t[4] * y + t[5]
2787
+ multiplyMatrix3x3(A, B) {
2788
+ const result = Array(3).fill(0).map(() => Array(3).fill(0));
2789
+ for (let i = 0; i < 3; i++) {
2790
+ for (let j = 0; j < 3; j++) {
2791
+ for (let k = 0; k < 3; k++) {
2792
+ result[i][j] += A[i][k] * B[k][j];
2793
+ }
2794
+ }
2795
+ }
2796
+ return result;
2797
+ }
2798
+ /**
2799
+ * 应用单应性变换(齐次坐标)
2800
+ * p' = H * p / (H * p 的 Z 分量)
2801
+ */
2802
+ applyHomography(H, x, y) {
2803
+ // 齐次坐标
2804
+ const p = [x, y, 1];
2805
+ // H * p
2806
+ const Hp = [
2807
+ H[0][0] * p[0] + H[0][1] * p[1] + H[0][2] * p[2],
2808
+ H[1][0] * p[0] + H[1][1] * p[1] + H[1][2] * p[2],
2809
+ H[2][0] * p[0] + H[2][1] * p[1] + H[2][2] * p[2]
2603
2810
  ];
2811
+ // 反齐次化
2812
+ if (Math.abs(Hp[2]) < 0.0001) {
2813
+ return [Hp[0], Hp[1]];
2814
+ }
2815
+ return [Hp[0] / Hp[2], Hp[1] / Hp[2]];
2816
+ }
2817
+ /**
2818
+ * 【新增】检查单应性矩阵的一致性
2819
+ *
2820
+ * 原理:
2821
+ * - 照片旋转时,每对相邻帧的H矩阵应该相近(因为是持续旋转)
2822
+ * - 真实人脸做随机动作时,H矩阵会变化很大
2823
+ */
2824
+ checkHomographyConsistency(matrices) {
2825
+ if (matrices.length < 2)
2826
+ return 1;
2827
+ // 计算矩阵间的相似度
2828
+ let totalSimilarity = 0;
2829
+ let pairCount = 0;
2830
+ for (let i = 1; i < matrices.length; i++) {
2831
+ const M1 = matrices[i - 1];
2832
+ const M2 = matrices[i];
2833
+ // Frobenius范数相似度
2834
+ let sumDiff = 0;
2835
+ for (let r = 0; r < 3; r++) {
2836
+ for (let c = 0; c < 3; c++) {
2837
+ const diff = M1[r][c] - M2[r][c];
2838
+ sumDiff += diff * diff;
2839
+ }
2840
+ }
2841
+ const frobeniusDist = Math.sqrt(sumDiff);
2842
+ // 标准化距离(除以矩阵范数)
2843
+ let normM1 = 0, normM2 = 0;
2844
+ for (let r = 0; r < 3; r++) {
2845
+ for (let c = 0; c < 3; c++) {
2846
+ normM1 += M1[r][c] * M1[r][c];
2847
+ normM2 += M2[r][c] * M2[r][c];
2848
+ }
2849
+ }
2850
+ normM1 = Math.sqrt(normM1);
2851
+ normM2 = Math.sqrt(normM2);
2852
+ const avgNorm = (normM1 + normM2) / 2;
2853
+ const normalizedDist = avgNorm > 0.1 ? Math.min(frobeniusDist / avgNorm, 2) : 2;
2854
+ // 将距离转换为相似度 (0-1)
2855
+ // 距离越小,相似度越高
2856
+ const similarity = Math.max(0, 1 - normalizedDist / 2);
2857
+ totalSimilarity += similarity;
2858
+ pairCount++;
2859
+ }
2860
+ return pairCount > 0 ? totalSimilarity / pairCount : 1;
2604
2861
  }
2605
2862
  /**
2606
2863
  * 【关键】检测深度一致性
@@ -2702,32 +2959,28 @@ class MotionLivenessDetector {
2702
2959
  const planarPattern = consistentFrames / depthChanges.length;
2703
2960
  return { planarPattern };
2704
2961
  }
2705
- /**
2706
- * 【关键】检测透视变换模式
2707
- *
2708
- * 原理:
2709
- * - 照片偏转时,特征点位置变化遵循严格的透视变换规律
2710
- * - 检测左右脸的相对变化是否符合透视投影
2711
- */
2712
2962
  /**
2713
2963
  * 【透视变换模式检测】
2714
2964
  *
2715
- * 【重要修复】使用归一化坐标进行比较
2965
+ * 【改进】使用原始坐标而不是归一化坐标
2716
2966
  *
2717
2967
  * 原理:照片左右偏转时,左右脸宽度比例会平滑变化
2968
+ * 这种比例变化遵循严格的透视投影规律
2718
2969
  */
2719
2970
  detectPerspectiveTransformPattern() {
2720
- // 【关键】使用归一化坐标历史
2721
- if (this.normalizedLandmarksHistory.length < 3) {
2971
+ // 【关键】使用原始坐标历史,而不是归一化坐标
2972
+ // 透视变换的宽度比例变化在原始图像坐标中更准确
2973
+ if (this.faceLandmarksHistory.length < 3) {
2722
2974
  return { perspectiveScore: 0 };
2723
2975
  }
2724
2976
  // 比较左右脸的宽度比例变化
2725
2977
  // 照片左偏时:右脸变窄,左脸变宽(透视效果)
2726
2978
  // 这种变化应该是平滑且可预测的
2727
2979
  const widthRatios = [];
2728
- for (const frame of this.normalizedLandmarksHistory) {
2980
+ for (const frame of this.faceLandmarksHistory) {
2729
2981
  if (frame.length >= 468) {
2730
- // 使用归一化坐标计算距离比例
2982
+ // 使用原始坐标计算距离比例
2983
+ // 234: 左脸颊边缘,1: 鼻尖,454: 右脸颊边缘
2731
2984
  const leftWidth = this.pointDist(frame[234], frame[1]); // 左脸到鼻子
2732
2985
  const rightWidth = this.pointDist(frame[1], frame[454]); // 鼻子到右脸
2733
2986
  if (leftWidth > 0 && rightWidth > 0) {
@@ -2771,10 +3024,28 @@ class MotionLivenessDetector {
2771
3024
  // 这是最可靠的检测方式,优先级最高
2772
3025
  const isPhotoByGeometry = photoGeometry.isPhoto;
2773
3026
  const photoConfidence = photoGeometry.confidence || 0;
3027
+ const frameCount = Math.max(this.eyeAspectRatioHistory.length, this.mouthAspectRatioHistory.length, this.faceLandmarksHistory.length, this.normalizedLandmarksHistory.length);
3028
+ // 【改进】根据帧数调整照片检测的敏感度
3029
+ // 少帧情况下,照片特征更容易误判,但如果几何约束强,仍应拒绝
3030
+ let photoConfidenceThreshold = 0.55;
3031
+ if (frameCount < 8) {
3032
+ // 少于8帧时,提高拒绝阈值,但只对超强照片特征有效
3033
+ // perspectiveScore=1.0 是超强信号,不应该放过
3034
+ if ((photoGeometry.details?.perspectiveScore || 0) > 0.95) {
3035
+ photoConfidenceThreshold = 0.45; // 降低阈值
3036
+ }
3037
+ else {
3038
+ photoConfidenceThreshold = 0.65; // 提高阈值
3039
+ }
3040
+ }
2774
3041
  // 如果照片几何检测高置信度判定为照片,直接拒绝
2775
- if (isPhotoByGeometry && photoConfidence > 0.75) {
3042
+ // 【改进】根据帧数和具体特征调整阈值
3043
+ if (isPhotoByGeometry && photoConfidence > photoConfidenceThreshold) {
2776
3044
  console.debug('[Decision] REJECTED by photo geometry detection', {
2777
3045
  photoConfidence: photoConfidence.toFixed(3),
3046
+ photoConfidenceThreshold: photoConfidenceThreshold.toFixed(3),
3047
+ perspectiveScore: (photoGeometry.details?.perspectiveScore || 0).toFixed(3),
3048
+ frameCount,
2778
3049
  details: photoGeometry.details
2779
3050
  });
2780
3051
  return false;
@@ -2844,11 +3115,13 @@ class MotionLivenessDetector {
2844
3115
  /**
2845
3116
  * 检查脸部形状稳定性
2846
3117
  *
2847
- * 【重要修复】使用归一化坐标进行比较
2848
- * 这样即使人脸在画面中移动或缩放,比较仍然有效
3118
+ * 【合理使用归一化坐标】这里使用归一化坐标是有意义的,因为:
3119
+ * - 目的是检测【脸部形状的变化】(眼睛距离、嘴巴高度等)
3120
+ * - 与人脸在画面中的位置和尺寸无关
3121
+ * - 消除平移和缩放影响,专注于形状的变化
2849
3122
  */
2850
3123
  checkFaceShapeStability() {
2851
- // 【关键】使用归一化坐标历史
3124
+ // 使用归一化坐标历史,消除平移和缩放影响
2852
3125
  if (this.normalizedLandmarksHistory.length < 5) {
2853
3126
  return 0.5; // 数据不足
2854
3127
  }
@@ -2861,7 +3134,7 @@ class MotionLivenessDetector {
2861
3134
  return 0.95; // 非常可能是照片
2862
3135
  }
2863
3136
  // 【第二层防护】检测脸部形状稳定性
2864
- // 使用归一化坐标计算距离
3137
+ // 使用归一化坐标计算相对距离,检测形状变化
2865
3138
  const faceDistances = [];
2866
3139
  // 计算以下距离:
2867
3140
  // 1. 左眼-右眼(眼距)
@@ -3963,6 +4236,15 @@ class FaceDetectionEngine extends SimpleEventEmitter {
3963
4236
  return;
3964
4237
  }
3965
4238
  try {
4239
+ // 面部区域占比计算
4240
+ const faceRatio = (faceBox[2] * faceBox[3]) / (this.actualVideoWidth * this.actualVideoHeight);
4241
+ // 面部区域过小则跳过当前帧
4242
+ if (faceRatio <= this.options.collect_min_face_ratio) {
4243
+ this.emitDetectorInfo({ code: DetectionCode.FACE_TOO_SMALL, faceRatio: faceRatio });
4244
+ this.emitDebug('detection', 'Face is too small', { ratio: faceRatio.toFixed(4), minRatio: this.options.collect_min_face_ratio, maxRatio: this.options.collect_max_face_ratio }, 'info');
4245
+ return;
4246
+ }
4247
+ // 静默活体检测
3966
4248
  const motionResult = this.detectionState.motionDetector.analyzeMotion(face, faceBox);
3967
4249
  // 只有ready状态的检测器的结果才可信
3968
4250
  if (this.detectionState.motionDetector.isReady()) {
@@ -3991,18 +4273,18 @@ class FaceDetectionEngine extends SimpleEventEmitter {
3991
4273
  details: motionResult.details,
3992
4274
  }, 'warn');
3993
4275
  }
3994
- // 计算面部大小比例,不达标则跳过当前帧
3995
- const faceRatio = (faceBox[2] * faceBox[3]) / (this.actualVideoWidth * this.actualVideoHeight);
3996
- if (faceRatio <= this.options.collect_min_face_ratio) {
3997
- this.emitDetectorInfo({ code: DetectionCode.FACE_TOO_SMALL, faceRatio: faceRatio });
3998
- this.emitDebug('detection', 'Face is too small', { ratio: faceRatio.toFixed(4), minRatio: this.options.collect_min_face_ratio, maxRatio: this.options.collect_max_face_ratio }, 'info');
4276
+ // 动作活体检测阶段处理
4277
+ if (this.detectionState.period === DetectionPeriod.VERIFY) {
4278
+ this.handleVerifyPhase(gestures);
3999
4279
  return;
4000
4280
  }
4281
+ // 面部区域过大则跳过当前帧
4001
4282
  if (faceRatio >= this.options.collect_max_face_ratio) {
4002
4283
  this.emitDetectorInfo({ code: DetectionCode.FACE_TOO_LARGE, faceRatio: faceRatio });
4003
4284
  this.emitDebug('detection', 'Face is too large', { ratio: faceRatio.toFixed(4), minRatio: this.options.collect_min_face_ratio, maxRatio: this.options.collect_max_face_ratio }, 'info');
4004
4285
  return;
4005
4286
  }
4287
+ // 捕获并准备帧数据
4006
4288
  const frameData = this.captureAndPrepareFrames();
4007
4289
  if (!frameData) {
4008
4290
  this.emitDebug('detection', '帧采集失败,无法继续检测', {
@@ -4023,6 +4305,7 @@ class FaceDetectionEngine extends SimpleEventEmitter {
4023
4305
  return;
4024
4306
  }
4025
4307
  }
4308
+ // 计算图像质量分数,不达标则跳过当前帧
4026
4309
  const qualityResult = calcImageQuality(this.cv, grayFrame, this.options.collect_image_quality_features, this.options.collect_min_image_quality);
4027
4310
  if (!qualityResult.passed || qualityResult.score < this.options.collect_min_image_quality) {
4028
4311
  this.emitDetectorInfo({ code: DetectionCode.FACE_LOW_QUALITY, faceRatio: faceRatio, faceFrontal: frontal, imageQuality: qualityResult.score });
@@ -4037,15 +4320,15 @@ class FaceDetectionEngine extends SimpleEventEmitter {
4037
4320
  }
4038
4321
  // 当前帧通过常规检查
4039
4322
  this.emitDetectorInfo({ passed: true, code: DetectionCode.FACE_CHECK_PASS, faceRatio: faceRatio, faceFrontal: frontal, imageQuality: qualityResult.score });
4040
- // 处理不同检测阶段的逻辑
4041
4323
  // 检测阶段,图像各方面合规,进入采集阶段
4042
4324
  if (this.detectionState.period === DetectionPeriod.DETECT) {
4043
4325
  this.handleDetectPhase();
4044
4326
  }
4045
- // 采集阶段,采集当前帧图像,记录采集次数, 达到指定次数后进入验证阶段
4327
+ // 采集阶段,采集当前帧图像
4046
4328
  if (this.detectionState.period === DetectionPeriod.COLLECT) {
4047
4329
  this.handleCollectPhase(bgrFrame, qualityResult.score, faceBox);
4048
4330
  }
4331
+ // 采集到足够的图像,并且静默活体通过,进入动作验证阶段
4049
4332
  if (this.detectionState.isReadyToVerify(this.options.collect_min_collect_count)) {
4050
4333
  this.emitDebug('detection', 'Ready to enter action verification phase', {
4051
4334
  collectCount: this.detectionState.collectCount,
@@ -4060,9 +4343,6 @@ class FaceDetectionEngine extends SimpleEventEmitter {
4060
4343
  return;
4061
4344
  }
4062
4345
  }
4063
- if (this.detectionState.period === DetectionPeriod.VERIFY) {
4064
- this.handleVerifyPhase(gestures);
4065
- }
4066
4346
  }
4067
4347
  catch (error) {
4068
4348
  const errorInfo = this.extractErrorInfo(error);
@@ -4089,12 +4369,14 @@ class FaceDetectionEngine extends SimpleEventEmitter {
4089
4369
  this.collectHighQualityImage(bgrFrame, qualityScore, faceBox);
4090
4370
  }
4091
4371
  /**
4092
- * Handle verify phase
4372
+ * 验证动作阶段处理
4373
+ * @param gestures - Detected gestures from Human.js
4093
4374
  */
4094
4375
  handleVerifyPhase(gestures) {
4095
- // No action set yet, will continue after setting
4096
4376
  if (!this.detectionState.currentAction) {
4377
+ // 当前无动作,选择下一个动作
4097
4378
  if (!this.selectNextAction()) {
4379
+ // 下一个动作不可用,内部错误
4098
4380
  this.emit('detector-error', {
4099
4381
  code: ErrorCode.INTERNAL_ERROR,
4100
4382
  message: 'No available actions to perform for liveness verification'
@@ -4104,32 +4386,47 @@ class FaceDetectionEngine extends SimpleEventEmitter {
4104
4386
  }
4105
4387
  return;
4106
4388
  }
4107
- // Check if action detected
4108
- const detected = this.detectAction(this.detectionState.currentAction, gestures);
4109
- if (!detected) {
4389
+ // 检测实际动作
4390
+ const detectedActions = this.detectAction(gestures);
4391
+ if (detectedActions.length === 0) {
4392
+ // 没有任何动作,继续检测
4110
4393
  return;
4111
4394
  }
4112
- const actionComplete = {
4395
+ // 验证检测到的动作:只有检测到期望的动作才算成功
4396
+ // 如果同时检测到多个动作(如NOD_DOWN和NOD_UP),只要包含期望的动作即可
4397
+ if (!detectedActions.includes(this.detectionState.currentAction)) {
4398
+ this.emitDebug('liveness', 'Action mismatch', {
4399
+ expected: this.detectionState.currentAction,
4400
+ detected: detectedActions
4401
+ }, 'warn');
4402
+ this.emit('detector-action', {
4403
+ action: this.detectionState.currentAction,
4404
+ detected: detectedActions,
4405
+ status: LivenessActionStatus.MISMATCH
4406
+ });
4407
+ this.stopDetection(false);
4408
+ return;
4409
+ }
4410
+ // 动作验证成功
4411
+ this.emit('detector-action', {
4113
4412
  action: this.detectionState.currentAction,
4413
+ detected: detectedActions,
4114
4414
  status: LivenessActionStatus.COMPLETED
4115
- };
4116
- // Action completed
4117
- this.emit('detector-action', actionComplete);
4415
+ });
4118
4416
  this.emitDebug('liveness', 'Action detected', { action: this.detectionState.currentAction });
4119
4417
  this.detectionState.onActionCompleted();
4120
- // Check if all required actions completed
4418
+ // 检查是否完成所有动作
4121
4419
  if (this.detectionState.completedActions.size >= this.getPerformActionCount()) {
4122
4420
  this.stopDetection(true);
4123
4421
  return;
4124
4422
  }
4125
- // Select next action
4423
+ // 选择下一个动作
4126
4424
  if (!this.selectNextAction()) {
4127
4425
  this.emit('detector-error', {
4128
4426
  code: ErrorCode.INTERNAL_ERROR,
4129
4427
  message: 'No available actions to perform for liveness verification'
4130
4428
  });
4131
4429
  this.stopDetection(false);
4132
- return;
4133
4430
  }
4134
4431
  }
4135
4432
  /**
@@ -4213,6 +4510,7 @@ class FaceDetectionEngine extends SimpleEventEmitter {
4213
4510
  }
4214
4511
  const actionStart = {
4215
4512
  action: nextAction,
4513
+ detected: [],
4216
4514
  status: LivenessActionStatus.STARTED
4217
4515
  };
4218
4516
  this.emit('detector-action', actionStart);
@@ -4224,6 +4522,7 @@ class FaceDetectionEngine extends SimpleEventEmitter {
4224
4522
  }, 'warn');
4225
4523
  this.emit('detector-action', {
4226
4524
  action: nextAction,
4525
+ detected: [],
4227
4526
  status: LivenessActionStatus.TIMEOUT
4228
4527
  });
4229
4528
  this.partialResetDetectionState();
@@ -4231,49 +4530,63 @@ class FaceDetectionEngine extends SimpleEventEmitter {
4231
4530
  return true;
4232
4531
  }
4233
4532
  /**
4234
- * Detect specific action
4533
+ * Detect all actions from gestures
4534
+ * @returns Array of detected actions, empty array if none detected
4235
4535
  */
4236
- detectAction(action, gestures) {
4536
+ detectAction(gestures) {
4537
+ const detectedActions = [];
4237
4538
  if (!gestures || gestures.length === 0) {
4238
- this.emitDebug('liveness', 'No gestures detected for action verification', { action, gestureCount: gestures?.length ?? 0 }, 'info');
4239
- return false;
4539
+ this.emitDebug('liveness', 'No gestures detected for action verification', { gestureCount: gestures?.length ?? 0 }, 'info');
4540
+ return detectedActions;
4240
4541
  }
4241
4542
  try {
4242
- switch (action) {
4243
- case LivenessAction.BLINK:
4244
- return gestures.some(g => {
4245
- if (!g.gesture)
4246
- return false;
4247
- return g.gesture.includes('blink');
4248
- });
4249
- case LivenessAction.MOUTH_OPEN:
4250
- return gestures.some(g => {
4251
- const gestureStr = g.gesture;
4252
- if (!gestureStr || !gestureStr.includes('mouth'))
4253
- return false;
4254
- const percentMatch = gestureStr.match(/mouth\s+(\d+)%\s+open/);
4255
- if (!percentMatch || !percentMatch[1])
4256
- return false;
4257
- const percent = parseInt(percentMatch[1]) / 100; // Convert to 0-1 range
4258
- return percent > (this.options.action_liveness_min_mouth_open_percent);
4259
- });
4260
- case LivenessAction.NOD:
4261
- return gestures.some(g => {
4262
- if (!g.gesture)
4263
- return false;
4264
- // Check for continuous head movement (up -> down or down -> up)
4265
- const headPattern = g.gesture.match(/head\s+(up|down)/i);
4266
- return !!headPattern && !!headPattern[1];
4267
- });
4268
- default:
4269
- this.emitDebug('liveness', 'Unknown action type in detection', { action }, 'warn');
4543
+ // Check for BLINK
4544
+ if (gestures.some(g => {
4545
+ if (!g.gesture)
4270
4546
  return false;
4547
+ return g.gesture.includes('blink');
4548
+ })) {
4549
+ detectedActions.push(LivenessAction.BLINK);
4550
+ }
4551
+ // Check for MOUTH_OPEN
4552
+ if (gestures.some(g => {
4553
+ const gestureStr = g.gesture;
4554
+ if (!gestureStr || !gestureStr.includes('mouth'))
4555
+ return false;
4556
+ const percentMatch = gestureStr.match(/mouth\s+(\d+)%\s+open/);
4557
+ if (!percentMatch || !percentMatch[1])
4558
+ return false;
4559
+ const percent = parseInt(percentMatch[1]) / 100; // Convert to 0-1 range
4560
+ return percent > (this.options.action_liveness_min_mouth_open_percent);
4561
+ })) {
4562
+ detectedActions.push(LivenessAction.MOUTH_OPEN);
4563
+ }
4564
+ // Check for NOD_DOWN (head down)
4565
+ if (gestures.some(g => {
4566
+ if (!g.gesture)
4567
+ return false;
4568
+ const headPattern = g.gesture.match(/head\s+down/i);
4569
+ return !!headPattern;
4570
+ })) {
4571
+ detectedActions.push(LivenessAction.NOD_DOWN);
4572
+ }
4573
+ // Check for NOD_UP (head up)
4574
+ if (gestures.some(g => {
4575
+ if (!g.gesture)
4576
+ return false;
4577
+ const headPattern = g.gesture.match(/head\s+up/i);
4578
+ return !!headPattern;
4579
+ })) {
4580
+ detectedActions.push(LivenessAction.NOD_UP);
4581
+ }
4582
+ if (detectedActions.length > 0) {
4583
+ this.emitDebug('liveness', 'Actions detected', { detectedActions }, 'info');
4271
4584
  }
4272
4585
  }
4273
4586
  catch (error) {
4274
- this.emitDebug('liveness', 'Error during action detection', { action, error: error.message }, 'error');
4275
- return false;
4587
+ this.emitDebug('liveness', 'Error during action detection', { error: error.message }, 'error');
4276
4588
  }
4589
+ return detectedActions;
4277
4590
  }
4278
4591
  /**
4279
4592
  * Emit debug event