@sssxyd/face-liveness-detector 0.4.1-beta.9 → 0.4.2-alpha.2

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
@@ -44,6 +44,8 @@ var DetectionCode;
44
44
  DetectionCode["FACE_NOT_LIVE"] = "FACE_NOT_LIVE";
45
45
  DetectionCode["FACE_LOW_QUALITY"] = "FACE_LOW_QUALITY";
46
46
  DetectionCode["FACE_CHECK_PASS"] = "FACE_CHECK_PASS";
47
+ DetectionCode["PLEASE_MOVING_FACE"] = "PLEASE_MOVING_FACE";
48
+ DetectionCode["PHOTO_ATTACK_DETECTED"] = "PHOTO_ATTACK_DETECTED";
47
49
  })(DetectionCode || (DetectionCode = {}));
48
50
  /**
49
51
  * Error code enumeration
@@ -73,7 +75,7 @@ var EngineState;
73
75
  /**
74
76
  * Default configuration for FaceDetectionEngine
75
77
  */
76
- const DEFAULT_OPTIONS$1 = {
78
+ const DEFAULT_OPTIONS$2 = {
77
79
  // Resource paths
78
80
  human_model_path: undefined,
79
81
  tensorflow_wasm_path: undefined,
@@ -110,6 +112,7 @@ const DEFAULT_OPTIONS$1 = {
110
112
  action_liveness_action_randomize: true,
111
113
  action_liveness_verify_timeout: 15000,
112
114
  action_liveness_min_mouth_open_percent: 0.2,
115
+ photo_attack_detected_max_count: 5,
113
116
  };
114
117
  /**
115
118
  * Merge user configuration with defaults
@@ -119,7 +122,7 @@ const DEFAULT_OPTIONS$1 = {
119
122
  */
120
123
  function mergeOptions(userConfig) {
121
124
  // Start with deep clone of defaults
122
- const merged = structuredClone(DEFAULT_OPTIONS$1);
125
+ const merged = structuredClone(DEFAULT_OPTIONS$2);
123
126
  if (!userConfig) {
124
127
  return merged;
125
128
  }
@@ -1542,2079 +1545,822 @@ function matToBase64Jpeg(cv, mat, quality = 0.9) {
1542
1545
  }
1543
1546
 
1544
1547
  /**
1545
- * 活体检测器 - 微妙运动检测版本 + 照片几何特征检测
1546
- *
1547
- * 双重检测策略:
1548
- * 1. 正向检测:检测生物特征(微妙眨眼、细微张嘴、面部肌肉微动)
1549
- * 2. 逆向检测:检测照片几何特征(平面约束、透视变换规律、交叉比率)
1548
+ * 人脸运动检测器 - 基于 MediaPipe Face Mesh 的轻量级运动检测机制
1550
1549
  *
1551
- * ⚠️ 关键理解 ⚠️
1552
- * MediaPipe 返回的 Z 坐标(深度)是从2D图像【推断】出来的,不是真实的物理深度!
1553
- * - 对真实人脸:推断出正确的 3D 结构
1554
- * - 对照片人脸:也可能推断出"假"的 3D 结构(因为照片上的人脸看起来也像 3D 的)
1550
+ * 核心原理:
1551
+ * 通过分析连续帧间人脸关键点的几何变化,检测由头部姿态调整、表情变化或外部移动引起的形变信号。
1555
1552
  *
1556
- * 因此,检测策略优先级:
1557
- * 1. 【最可靠】2D 几何约束检测(单应性、交叉比率、透视变换规律)——物理定律,无法欺骗
1558
- * 2. 【次可靠】生物特征时序检测(眨眼时间、对称性)——行为模式
1559
- * 3. 【辅助参考】Z 坐标分析——可能被欺骗,仅作辅助
1553
+ * 具体流程:
1554
+ * 1. 关键点获取:提取归一化的人脸关键点坐标(x, y ∈ [0,1]),共468个点
1555
+ * 2. 平移不变性处理:以鼻尖关键点(索引为1)为原点进行中心化
1556
+ * 3. 帧间位移计算:计算当前帧与前一帧的欧氏距离平均值
1557
+ * 4. 运动判定:若位移超过阈值,判定为运动状态
1560
1558
  */
1561
1559
  /**
1562
- * 活体检测结果
1560
+ * 人脸运动检测结果
1563
1561
  */
1564
- class MotionDetectionResult {
1565
- // 是否为活体
1566
- isLively;
1562
+ class FaceMovingDetectionResult {
1563
+ isMoving;
1567
1564
  details;
1568
1565
  debug;
1569
- constructor(isLively, details, debug = {}) {
1570
- this.isLively = isLively;
1571
- this.debug = debug;
1566
+ constructor(isMoving, details, debug = {}) {
1567
+ this.isMoving = isMoving;
1572
1568
  this.details = details;
1569
+ this.debug = debug;
1573
1570
  }
1574
1571
  getMessage() {
1575
- if (this.details.frameCount < 5) {
1576
- return '数据不足,无法进行活体检测';
1572
+ if (this.details.frameCount < 2) {
1573
+ return '数据不足,无法进行运动检测';
1577
1574
  }
1578
- if (this.isLively)
1575
+ if (!this.isMoving) {
1579
1576
  return '';
1580
- // 正向检测信息
1581
- const eyePercent = (this.details.eyeFluctuation * 100).toFixed(0);
1582
- const mouthPercent = (this.details.mouthFluctuation * 100).toFixed(0);
1583
- const musclePercent = (this.details.muscleVariation * 100).toFixed(0);
1584
- const bioFeatures = `未检测到面部微动(眼睛: ${eyePercent}%, 嘴巴: ${mouthPercent}%, 肌肉: ${musclePercent}%)`;
1585
- // 逆向检测信息
1586
- if (this.details.isPhoto) {
1587
- const confidence = ((this.details.photoConfidence || 0) * 100).toFixed(0);
1588
- const reasons = [];
1589
- if ((this.details.homographyScore || 0) > 0.5)
1590
- reasons.push('单应性约束');
1591
- if ((this.details.perspectiveScore || 0) > 0.5)
1592
- reasons.push('透视规律');
1593
- if ((this.details.crossRatioScore || 0) > 0.5)
1594
- reasons.push('交叉比率');
1595
- const reasonStr = reasons.length > 0 ? `(${reasons.join('、')})` : '';
1596
- return `检测到照片特征${reasonStr},置信度${confidence}%`;
1597
- }
1598
- return bioFeatures;
1577
+ }
1578
+ const confidence = (this.details.movementConfidence * 100).toFixed(0);
1579
+ const movement = (this.details.currentMovement * 1000).toFixed(1);
1580
+ const frames = this.details.continuousMovingFrames;
1581
+ return `检测到人脸运动(强度: ${movement}, 置信度: ${confidence}%, 连续帧数: ${frames})`;
1599
1582
  }
1600
1583
  }
1601
- const DEFAULT_OPTIONS = {
1602
- frameBufferSize: 15, // 15 (0.5秒@30fps)
1603
- eyeMinFluctuation: 0.008, // 非常低的眨眼阈值(检测微妙变化)
1604
- mouthMinFluctuation: 0.005, // 非常低的张嘴阈值
1605
- muscleMinVariation: 0.002, // 非常低的肌肉变化阈值
1606
- activityThreshold: 0.2 // 只需要有 20% 的活动迹象就判定为活体
1584
+ const DEFAULT_OPTIONS$1 = {
1585
+ frameBufferSize: 30, // 30
1586
+ movementThreshold: 0.015, // 运动阈值:超过此值判定为运动
1587
+ minContinuousFrames: 1, // 至少连续1帧运动才认为是有效运动
1588
+ nosePointIndex: 1, // MediaPipe Face Mesh 中鼻尖的索引
1607
1589
  };
1608
1590
  /**
1609
- * 活体检测器 - 超敏感微动作版本 + 照片几何特征检测
1591
+ * 人脸运动检测器
1610
1592
  *
1611
- * 双重策略:
1612
- * 1. 检测生物微动(正向)
1613
- * 2. 检测照片几何约束(逆向)- 更可靠
1593
+ * 基于 MediaPipe Face Mesh 的468个关键点,通过分析帧间几何变化检测运动
1614
1594
  */
1615
- class MotionLivenessDetector {
1595
+ class FaceMovingDetector {
1616
1596
  config;
1617
- eyeAspectRatioHistory = [];
1618
- mouthAspectRatioHistory = [];
1619
- faceLandmarksHistory = []; // 原始坐标(用于Z坐标分析)
1620
- normalizedLandmarksHistory = []; // 【关键】归一化坐标(用于几何约束检测)
1621
- // 用于检测透视畸变攻击
1622
- leftEyeEARHistory = [];
1623
- rightEyeEARHistory = [];
1624
- frameTimestamps = [];
1625
- rigidMotionHistory = [];
1626
- // 【新增】用于照片几何特征检测
1627
- homographyErrors = []; // 单应性变换误差历史
1628
- depthConsistencyScores = []; // 深度一致性得分历史
1629
- planarityScores = []; // 平面性得分历史
1630
- constructor() {
1631
- this.config = { ...DEFAULT_OPTIONS };
1632
- }
1633
- getOptions() {
1634
- return this.config;
1635
- }
1636
- collectedMinFrames() {
1637
- return this.normalizedLandmarksHistory.length >= 5;
1638
- }
1639
- collectedFullFrames() {
1640
- return this.normalizedLandmarksHistory.length >= this.config.frameBufferSize;
1641
- }
1642
- reset() {
1643
- this.eyeAspectRatioHistory = [];
1644
- this.mouthAspectRatioHistory = [];
1645
- this.faceLandmarksHistory = [];
1646
- this.normalizedLandmarksHistory = []; // 【关键】归一化坐标
1647
- this.leftEyeEARHistory = [];
1648
- this.rightEyeEARHistory = [];
1649
- this.frameTimestamps = [];
1650
- this.rigidMotionHistory = [];
1651
- this.homographyErrors = [];
1652
- this.depthConsistencyScores = [];
1653
- this.planarityScores = [];
1654
- }
1655
- analyzeMotion(faceResult, faceBox) {
1656
- try {
1657
- const currentKeypoints = this.extractKeypoints(faceResult);
1658
- // 保存完整网格(原始坐标用于Z坐标分析)
1659
- if (currentKeypoints.landmarks) {
1660
- this.faceLandmarksHistory.push(currentKeypoints.landmarks);
1661
- if (this.faceLandmarksHistory.length > this.config.frameBufferSize) {
1662
- this.faceLandmarksHistory.shift();
1663
- }
1664
- // 【关键】保存归一化坐标用于几何约束检测
1665
- // 归一化到人脸局部坐标系,消除人脸移动的影响
1666
- const normalizedLandmarks = this.normalizeLandmarks(currentKeypoints.landmarks, faceBox);
1667
- this.normalizedLandmarksHistory.push(normalizedLandmarks);
1668
- if (this.normalizedLandmarksHistory.length > this.config.frameBufferSize) {
1669
- this.normalizedLandmarksHistory.shift();
1670
- }
1671
- }
1672
- else {
1673
- const lm = currentKeypoints.landmarks || [];
1674
- const debug = {
1675
- 'faceResult.mesh存在': !!faceResult.mesh,
1676
- 'mesh长度': faceResult.mesh?.length || 0,
1677
- 'faceResult.annotations存在': !!faceResult.annotations,
1678
- 'annotations键数': Object.keys(faceResult.annotations || {}).length,
1679
- 'currentKeypoints.landmarks长度': lm.length
1680
- };
1681
- return this.createEmptyResult({
1682
- reason: '缺少面部关键点,无法进行活体检测',
1683
- landmarks: currentKeypoints.landmarks,
1684
- debug
1685
- });
1686
- }
1687
- // 数据不足时,继续收集
1688
- if (!this.collectedMinFrames()) {
1689
- return this.createEmptyResult({
1690
- reason: '数据收集中,帧数不足',
1691
- collectedFrames: this.normalizedLandmarksHistory.length
1692
- });
1693
- }
1694
- // 【检测1】眼睛微妙波动 - 任何EAR变化都是活体
1695
- const eyeActivity = this.detectEyeFluctuation(currentKeypoints);
1696
- // 【检测2】嘴巴微妙波动 - 任何MAR变化都是活体
1697
- const mouthActivity = this.detectMouthFluctuation(currentKeypoints);
1698
- // 【检测3】面部肌肉微动 - 任何细微位置变化都是活体
1699
- const muscleActivity = this.detectMuscleMovement();
1700
- // 【新增检测4】照片几何特征检测(逆向检测)
1701
- const photoGeometryResult = this.detectPhotoGeometry();
1702
- // 综合判定(结合正向和逆向检测)
1703
- const isLively = this.makeLivenessDecision(eyeActivity, mouthActivity, muscleActivity, photoGeometryResult);
1704
- return new MotionDetectionResult(isLively, {
1705
- frameCount: Math.max(this.eyeAspectRatioHistory.length, this.mouthAspectRatioHistory.length, this.faceLandmarksHistory.length, this.normalizedLandmarksHistory.length),
1706
- // 正向检测结果(生物特征)
1707
- eyeAspectRatioStdDev: eyeActivity.stdDev,
1708
- mouthAspectRatioStdDev: mouthActivity.stdDev,
1709
- eyeFluctuation: eyeActivity.fluctuation,
1710
- mouthFluctuation: mouthActivity.fluctuation,
1711
- muscleVariation: muscleActivity.variation,
1712
- hasEyeMovement: eyeActivity.hasMovement,
1713
- hasMouthMovement: mouthActivity.hasMovement,
1714
- hasMuscleMovement: muscleActivity.hasMovement,
1715
- // 逆向检测结果(照片几何特征)
1716
- isPhoto: photoGeometryResult.isPhoto,
1717
- photoConfidence: photoGeometryResult.confidence,
1718
- homographyScore: photoGeometryResult.details?.homographyScore,
1719
- perspectiveScore: photoGeometryResult.details?.perspectiveScore,
1720
- crossRatioScore: photoGeometryResult.details?.crossRatioScore,
1721
- depthVariation: photoGeometryResult.details?.depthVariation,
1722
- crossFramePattern: photoGeometryResult.details?.crossFramePattern
1723
- });
1724
- }
1725
- catch (error) {
1726
- console.warn('[MotionLivenessDetector]', error);
1727
- return this.createEmptyResult({
1728
- reason: '活体检测异常',
1729
- error: error.message
1730
- });
1731
- }
1732
- }
1733
- /**
1734
- * 检测眼睛的微妙波动(任何变化)
1735
- * 防护:排除透视畸变、噪声,确保是真实的连续或周期性波动
1736
- */
1737
- detectEyeFluctuation(keypoints) {
1738
- if (!keypoints.leftEye || !keypoints.rightEye) {
1739
- return { score: 0, stdDev: 0, fluctuation: 0, hasMovement: false };
1740
- }
1741
- // 计算眼睛宽高比
1742
- const leftEAR = this.calculateEyeAspectRatio(keypoints.leftEye);
1743
- const rightEAR = this.calculateEyeAspectRatio(keypoints.rightEye);
1744
- const avgEAR = (leftEAR + rightEAR) / 2;
1745
- // 记录时间戳
1746
- this.frameTimestamps.push(Date.now());
1747
- if (this.frameTimestamps.length > this.config.frameBufferSize) {
1748
- this.frameTimestamps.shift();
1749
- }
1750
- // 分别记录左右眼EAR(用于一致性检测)
1751
- this.leftEyeEARHistory.push(leftEAR);
1752
- this.rightEyeEARHistory.push(rightEAR);
1753
- if (this.leftEyeEARHistory.length > this.config.frameBufferSize) {
1754
- this.leftEyeEARHistory.shift();
1755
- this.rightEyeEARHistory.shift();
1756
- }
1757
- this.eyeAspectRatioHistory.push(avgEAR);
1758
- if (this.eyeAspectRatioHistory.length > this.config.frameBufferSize) {
1759
- this.eyeAspectRatioHistory.shift();
1760
- }
1761
- if (this.eyeAspectRatioHistory.length < 2) {
1762
- return { score: 0, stdDev: 0, fluctuation: 0, hasMovement: false };
1763
- }
1764
- // 计算EAR的标准差(波动幅度)
1765
- const stdDev = this.calculateStdDev(this.eyeAspectRatioHistory);
1766
- // 计算EAR的最大最小差值(波动范围)
1767
- const maxEAR = Math.max(...this.eyeAspectRatioHistory);
1768
- const minEAR = Math.min(...this.eyeAspectRatioHistory);
1769
- const fluctuation = maxEAR - minEAR;
1770
- // 【防护1】检测是否是透视畸变(往复波动)
1771
- const isOscillating = this.detectOscillation(this.eyeAspectRatioHistory);
1772
- // 【防护2】检测是否是连续变化(真实眨眼)还是噪声
1773
- const hasRealBlink = this.detectRealBlink(this.eyeAspectRatioHistory);
1774
- // 【防护3】检测最近帧的变化(实时动作)
1775
- const hasRecentMovement = this.detectRecentMovement(this.eyeAspectRatioHistory);
1776
- // 【新增防护4】检测左右眼一致性(真实眨眼双眼同步)
1777
- const eyeSymmetry = this.detectEyeSymmetry();
1778
- // 【新增防护5】检测眨眼时间模式(真实眨眼非常快,100-400ms)
1779
- const hasValidBlinkTiming = this.detectBlinkTiming();
1780
- // 【新增防护6】检测运动-形变相关性(透视畸变特征)
1781
- const motionDeformCorrelation = this.detectMotionDeformCorrelation();
1782
- // 【关键】组合多个防护条件
1783
- // 必须满足:有波动 + (往复或大幅波动) + (真实眨眼或最近有动作)
1784
- // 并且:左右眼对称 + 时间模式正确 + 非透视畸变
1785
- const basicMovement = (fluctuation > this.config.eyeMinFluctuation || stdDev > 0.005) &&
1786
- (isOscillating || fluctuation > 0.02) &&
1787
- (hasRealBlink || hasRecentMovement);
1788
- // 透视畸变攻击防护:如果运动和形变高度相关,很可能是照片偏转
1789
- const isPerspectiveAttack = motionDeformCorrelation > 0.7 && !hasValidBlinkTiming;
1790
- // 最终判定:基础动作检测通过 + 不是透视攻击 + 左右眼对称
1791
- const hasMovement = basicMovement && !isPerspectiveAttack && eyeSymmetry > 0.5;
1792
- // 评分:波动越大评分越高,但透视攻击会降分
1793
- const baseScore = hasMovement ? Math.min((fluctuation + stdDev) / 0.05, 1) : 0;
1794
- const score = baseScore * (1 - motionDeformCorrelation * 0.5);
1795
- console.debug('[Eye]', {
1796
- EAR: avgEAR.toFixed(4),
1797
- fluctuation: fluctuation.toFixed(5),
1798
- stdDev: stdDev.toFixed(5),
1799
- oscillating: isOscillating,
1800
- realBlink: hasRealBlink,
1801
- recentMovement: hasRecentMovement,
1802
- eyeSymmetry: eyeSymmetry.toFixed(3),
1803
- blinkTiming: hasValidBlinkTiming,
1804
- motionDeformCorr: motionDeformCorrelation.toFixed(3),
1805
- isPerspectiveAttack,
1806
- score: score.toFixed(3)
1807
- });
1808
- return { score, stdDev, fluctuation, hasMovement, isPerspectiveAttack };
1597
+ frameBuffer = [];
1598
+ movementHistory = [];
1599
+ continuousMovingCount = 0;
1600
+ emitDebug = () => { }; // 默认空实现(不emit)
1601
+ constructor(options) {
1602
+ this.config = { ...DEFAULT_OPTIONS$1, ...options };
1809
1603
  }
1810
1604
  /**
1811
- * 检测嘴巴的微妙波动(任何变化)
1812
- * 防护:排除噪声,确保是真实的张嘴/闭嘴动作
1813
- */
1814
- detectMouthFluctuation(keypoints) {
1815
- if (!keypoints.mouth) {
1816
- return { score: 0, stdDev: 0, fluctuation: 0, hasMovement: false };
1817
- }
1818
- // 计算嘴巴宽高比
1819
- const MAR = this.calculateMouthAspectRatio(keypoints.mouth);
1820
- this.mouthAspectRatioHistory.push(MAR);
1821
- if (this.mouthAspectRatioHistory.length > this.config.frameBufferSize) {
1822
- this.mouthAspectRatioHistory.shift();
1823
- }
1824
- if (this.mouthAspectRatioHistory.length < 2) {
1825
- return { score: 0, stdDev: 0, fluctuation: 0, hasMovement: false };
1826
- }
1827
- // 计算MAR的标准差
1828
- const stdDev = this.calculateStdDev(this.mouthAspectRatioHistory);
1829
- // 计算波动范围
1830
- const maxMAR = Math.max(...this.mouthAspectRatioHistory);
1831
- const minMAR = Math.min(...this.mouthAspectRatioHistory);
1832
- const fluctuation = maxMAR - minMAR;
1833
- // 【防护1】检测真实的张嘴/闭嘴周期
1834
- const hasRealMouthMovement = this.detectRealMouthMovement(this.mouthAspectRatioHistory);
1835
- // 【防护2】检测最近是否有嘴巴活动
1836
- const hasRecentMouthMovement = this.detectRecentMovement(this.mouthAspectRatioHistory);
1837
- // 【关键】需要真实的嘴巴动作或最近有活动
1838
- const hasMovement = (fluctuation > this.config.mouthMinFluctuation || stdDev > 0.003) &&
1839
- (hasRealMouthMovement || hasRecentMouthMovement);
1840
- // 评分
1841
- const score = hasMovement ? Math.min((fluctuation + stdDev) / 0.05, 1) : 0;
1842
- console.debug('[Mouth]', {
1843
- MAR: MAR.toFixed(4),
1844
- fluctuation: fluctuation.toFixed(5),
1845
- stdDev: stdDev.toFixed(5),
1846
- realMovement: hasRealMouthMovement,
1847
- recentMovement: hasRecentMouthMovement,
1848
- score: score.toFixed(3)
1849
- });
1850
- return { score, stdDev, fluctuation, hasMovement };
1605
+ * 设置 emitDebug 方法(依赖注入)
1606
+ * @param emitDebugFn - 来自 FaceDetectionEngine 的 emitDebug 方法
1607
+ */
1608
+ setEmitDebug(emitDebugFn) {
1609
+ this.emitDebug = emitDebugFn;
1851
1610
  }
1852
1611
  /**
1853
- * 【关键】检测真实的嘴巴张嘴→闭嘴动作
1854
- *
1855
- * 原理类似眨眼,需要检测下降和上升的连续段
1612
+ * 添加一帧的人脸检测结果
1613
+ * @param faceResult - 单帧的人脸检测结果
1614
+ * @param timestamp - 高精度时间戳(建议使用 performance.now(),单位毫秒)
1856
1615
  */
1857
- detectRealMouthMovement(values) {
1858
- if (values.length < 3) {
1859
- return false;
1860
- }
1861
- // 统计连续段
1862
- let descendingSegments = 0;
1863
- let ascendingSegments = 0;
1864
- let inDescending = false;
1865
- let inAscending = false;
1866
- for (let i = 1; i < values.length; i++) {
1867
- const change = values[i] - values[i - 1];
1868
- const threshold = 0.008;
1869
- if (change < -threshold) {
1870
- if (!inDescending) {
1871
- descendingSegments++;
1872
- inDescending = true;
1873
- inAscending = false;
1874
- }
1875
- }
1876
- else if (change > threshold) {
1877
- if (!inAscending) {
1878
- ascendingSegments++;
1879
- inAscending = true;
1880
- inDescending = false;
1881
- }
1616
+ addFrame(faceResult, timestamp = Date.now()) {
1617
+ if (!faceResult.meshRaw || faceResult.meshRaw.length === 0)
1618
+ return;
1619
+ this.frameBuffer.push({ result: faceResult, timestamp });
1620
+ // 保持缓冲区大小
1621
+ if (this.frameBuffer.length > this.config.frameBufferSize) {
1622
+ this.frameBuffer.shift();
1623
+ if (this.movementHistory.length > this.config.frameBufferSize) {
1624
+ this.movementHistory.shift();
1882
1625
  }
1883
1626
  }
1884
- const hasCompletePattern = descendingSegments > 0 && ascendingSegments > 0;
1885
- // 或检查最近5帧
1886
- if (values.length >= 5) {
1887
- const recent5 = values.slice(-5);
1888
- const recentRange = Math.max(...recent5) - Math.min(...recent5);
1889
- const hasRecentOpening = recentRange > 0.015;
1890
- return hasCompletePattern || hasRecentOpening;
1891
- }
1892
- return hasCompletePattern;
1893
1627
  }
1894
1628
  /**
1895
- * 检测面部肌肉的微动(关键点位置微妙变化)
1896
- * 关键:允许刚性运动+生物特征(真人摇头),拒绝纯刚性运动(照片旋转)
1897
- *
1898
- * 【合理使用归一化坐标】这里使用归一化坐标是有意义的,因为:
1899
- * - 目的是检测肌肉的【相对运动幅度】,与人脸尺寸无关
1900
- * - 消除人脸在画面中位置变化的影响
1901
- * - 用相对于人脸的比例运动来判断肌肉活动
1629
+ * 执行人脸运动检测
1630
+ * @returns 检测结果
1902
1631
  */
1903
- detectMuscleMovement() {
1904
- // 使用归一化坐标历史,消除人脸位置和尺寸影响
1905
- if (this.normalizedLandmarksHistory.length < 2) {
1906
- return { score: 0, variation: 0, hasMovement: false };
1907
- }
1908
- // 【改进】检测刚性运动,但不直接拒绝
1909
- // 在综合判定中会结合其他生物特征来判断
1910
- const rigidityScore = this.detectRigidMotion();
1911
- // 记录刚性运动历史(用于运动-形变相关性分析)
1912
- this.rigidMotionHistory.push(rigidityScore);
1913
- if (this.rigidMotionHistory.length > this.config.frameBufferSize) {
1914
- this.rigidMotionHistory.shift();
1915
- }
1916
- // 选择敏感的肌肉关键点
1917
- const musclePoints = [
1918
- 61, 291, // 嘴角
1919
- 46, 53, // 左眉
1920
- 276, 283, // 右眉
1921
- 127, 356 // 脸颊
1922
- ];
1923
- const distances = [];
1924
- // 使用归一化坐标计算相对位移
1925
- for (let i = 1; i < this.normalizedLandmarksHistory.length; i++) {
1926
- const prevFrame = this.normalizedLandmarksHistory[i - 1];
1927
- const currFrame = this.normalizedLandmarksHistory[i];
1928
- let totalDist = 0;
1929
- let validPoints = 0;
1930
- for (const ptIdx of musclePoints) {
1931
- const prev = prevFrame[ptIdx];
1932
- const curr = currFrame[ptIdx];
1933
- if (prev && curr && prev.length >= 2 && curr.length >= 2) {
1934
- // 归一化坐标的距离(相对于人脸尺寸的比例)
1935
- const dist = Math.sqrt((curr[0] - prev[0]) ** 2 + (curr[1] - prev[1]) ** 2);
1936
- totalDist += dist;
1937
- validPoints++;
1938
- }
1939
- }
1940
- if (validPoints > 0) {
1941
- distances.push(totalDist / validPoints);
1942
- }
1943
- }
1944
- if (distances.length === 0) {
1945
- return { score: 0, variation: 0, hasMovement: false };
1632
+ detect() {
1633
+ const details = {
1634
+ frameCount: this.frameBuffer.length,
1635
+ currentMovement: 0,
1636
+ averageMovement: 0,
1637
+ movementStdDev: 0,
1638
+ maxMovement: 0,
1639
+ minMovement: 0,
1640
+ isMoving: false,
1641
+ movementConfidence: 0,
1642
+ continuousMovingFrames: 0,
1643
+ movementDuration: 0,
1644
+ lastCentroidShift: 0,
1645
+ centroidShiftRate: 0
1646
+ };
1647
+ // 帧数不足,无法检测
1648
+ if (this.frameBuffer.length < 2) {
1649
+ return new FaceMovingDetectionResult(false, details);
1650
+ }
1651
+ // ============ 计算当前帧的运动强度 ============
1652
+ const currentMovement = this.frameBuffer.length >= 2
1653
+ ? this.calculateMovement(this.frameBuffer[this.frameBuffer.length - 2].result, this.frameBuffer[this.frameBuffer.length - 1].result)
1654
+ : 0;
1655
+ details.currentMovement = currentMovement;
1656
+ this.movementHistory.push(currentMovement);
1657
+ // ============ 计算运动历史统计 ============
1658
+ if (this.movementHistory.length > 0) {
1659
+ details.averageMovement = this.calculateMean(this.movementHistory);
1660
+ details.movementStdDev = this.calculateStdDev(this.movementHistory);
1661
+ details.maxMovement = Math.max(...this.movementHistory);
1662
+ details.minMovement = Math.min(...this.movementHistory);
1663
+ }
1664
+ // ============ 运动状态判定 ============
1665
+ const isCurrentlyMoving = currentMovement > this.config.movementThreshold;
1666
+ if (isCurrentlyMoving) {
1667
+ this.continuousMovingCount++;
1946
1668
  }
1947
- // 计算肌肉运动的变异性
1948
- const avgDist = distances.reduce((a, b) => a + b, 0) / distances.length;
1949
- const variation = this.calculateStdDev(distances);
1950
- // 【关键】只要有任何细微变化就判定为活动
1951
- // 注意:阈值需要调整,因为归一化坐标的数值范围是 [0, 1]
1952
- const hasMovement = variation > 0.001 || avgDist > 0.005;
1953
- // 评分
1954
- const score = Math.min((variation + avgDist) / 0.05, 1);
1955
- console.debug('[Muscle] avgDist:', avgDist.toFixed(4), 'variation:', variation.toFixed(5), 'rigidity:', rigidityScore.toFixed(3), 'score:', score.toFixed(3));
1956
- return { score: Math.max(score, 0), variation, hasMovement, rigidityScore };
1669
+ else {
1670
+ this.continuousMovingCount = 0;
1671
+ }
1672
+ // 需要连续运动至少 minContinuousFrames 帧才认为是有效运动
1673
+ const hasValidMotion = this.continuousMovingCount >= this.config.minContinuousFrames;
1674
+ details.isMoving = hasValidMotion;
1675
+ details.continuousMovingFrames = this.continuousMovingCount;
1676
+ details.movementDuration = this.calculateMovementDuration();
1677
+ // ============ 运动置信度计算 ============
1678
+ // 基于当前运动强度与阈值的比例
1679
+ details.movementConfidence = Math.min(1, currentMovement / this.config.movementThreshold);
1680
+ // ============ 中心化坐标偏移 ============
1681
+ const lastFrame = this.frameBuffer[this.frameBuffer.length - 1];
1682
+ details.lastCentroidShift = this.calculateCentroidShift(lastFrame.result);
1683
+ // 计算中心化坐标的变化速率(基于实际时间)
1684
+ details.centroidShiftRate = this.calculateCentroidShiftRate();
1685
+ return new FaceMovingDetectionResult(details.isMoving, details);
1957
1686
  }
1958
1687
  /**
1959
- * 【防护机制】检测照片透视畸变(倾角拍摄)
1688
+ * 计算两帧之间的运动强度
1960
1689
  *
1961
- * 原理:
1962
- * - 照片是平面:所有关键点Z坐标(深度)应该相同且恒定
1963
- * - 当从倾角看平面照片时,虽然会产生2D投影变形,但深度仍然固定在一个平面
1964
- * - 真实活体:脸部有Z坐标深度,不同区域有深度差异(鼻子、下巴等突出)
1690
+ * 算法步骤:
1691
+ * 1. 对两帧的关键点进行中心化(以鼻尖为原点)
1692
+ * 2. 计算对应关键点之间的欧氏距离
1693
+ * 3. 取所有距离的平均值作为运动强度
1965
1694
  *
1966
- * 返回值:照片平面性得分(0-1,越接近1越可能是平面照片)
1695
+ * @param prevFrame - 前一帧的人脸检测结果
1696
+ * @param currFrame - 当前帧的人脸检测结果
1697
+ * @returns 运动强度 (0-1)
1967
1698
  */
1968
- detectPhotoPlanarity() {
1969
- if (this.faceLandmarksHistory.length < 3) {
1699
+ calculateMovement(prevFrame, currFrame) {
1700
+ const prevMesh = prevFrame.meshRaw;
1701
+ const currMesh = currFrame.meshRaw;
1702
+ if (!prevMesh || !currMesh || prevMesh.length === 0 || currMesh.length === 0) {
1970
1703
  return 0;
1971
1704
  }
1972
- // 获取最近帧的关键点
1973
- const latestFrame = this.faceLandmarksHistory[this.faceLandmarksHistory.length - 1];
1974
- if (!latestFrame || latestFrame.length < 468) {
1705
+ // ============ 第一步:中心化处理 ============
1706
+ const prevCentralized = this.centralizeMesh(prevMesh);
1707
+ const currCentralized = this.centralizeMesh(currMesh);
1708
+ if (prevCentralized.length === 0 || currCentralized.length === 0) {
1975
1709
  return 0;
1976
1710
  }
1977
- // 采样关键点的Z坐标(深度值)
1978
- // MediaPipe返回的Z坐标是相对值,表示距离摄像头的深度
1979
- const samplePoints = [
1980
- 10, // 额头上方
1981
- 152, // 下巴
1982
- 33, // 右眼外角
1983
- 263, // 左眼外角
1984
- 61, // 左嘴角
1985
- 291, // 右嘴角
1986
- 1, // 鼻尖
1987
- 234, // 右脸颊边缘
1988
- 454 // 左脸颊边缘
1989
- ];
1990
- const zValues = [];
1991
- for (const ptIdx of samplePoints) {
1992
- const point = latestFrame[ptIdx];
1993
- if (point && point.length >= 3 && typeof point[2] === 'number') {
1994
- zValues.push(point[2]);
1995
- }
1996
- }
1997
- if (zValues.length < 5) {
1711
+ // ============ 第二步:计算帧间位移 ============
1712
+ let totalDisplacement = 0;
1713
+ let validPointCount = 0;
1714
+ for (let i = 0; i < Math.min(prevCentralized.length, currCentralized.length); i++) {
1715
+ const prev = prevCentralized[i];
1716
+ const curr = currCentralized[i];
1717
+ if (!prev || !curr)
1718
+ continue;
1719
+ // 计算欧氏距离
1720
+ const dx = curr[0] - prev[0];
1721
+ const dy = curr[1] - prev[1];
1722
+ const distance = Math.sqrt(dx * dx + dy * dy);
1723
+ totalDisplacement += distance;
1724
+ validPointCount++;
1725
+ }
1726
+ // ============ 第三步:归一化运动强度 ============
1727
+ if (validPointCount === 0) {
1998
1728
  return 0;
1999
1729
  }
2000
- // 计算Z坐标的变异系数
2001
- const zMean = zValues.reduce((a, b) => a + b, 0) / zValues.length;
2002
- const zStdDev = this.calculateStdDev(zValues);
2003
- // 照片的Z坐标变异非常小(都在一个平面上)
2004
- // 活体的Z坐标有较大变异(鼻子比眼睛凸出,下巴和额头深度不同)
2005
- const zVarianceRatio = zMean > 0 ? zStdDev / zMean : 0;
2006
- // 平面性评分:如果Z坐标变异很小,说明是平面(照片)
2007
- // 如果zVarianceRatio < 0.15,认为是平面
2008
- // 如果zVarianceRatio > 0.3,认为是立体(活体)
2009
- const planarity = Math.max(0, (0.15 - zVarianceRatio) / 0.15);
2010
- console.debug('[Planarity]', {
2011
- zMean: zMean.toFixed(4),
2012
- zStdDev: zStdDev.toFixed(4),
2013
- zVarianceRatio: zVarianceRatio.toFixed(4),
2014
- planarity: planarity.toFixed(3)
2015
- });
2016
- return Math.min(planarity, 1);
1730
+ const averageDisplacement = totalDisplacement / validPointCount;
1731
+ // 将位移归一化到 [0, 1] 范围
1732
+ // 假设最大合理位移为 0.1(相对于图像尺寸)
1733
+ // 超过此值仍记为 1.0
1734
+ const normalizedMovement = Math.min(1, averageDisplacement / 0.1);
1735
+ return normalizedMovement;
2017
1736
  }
2018
1737
  /**
2019
- * 【防护机制】检测刚性运动(照片被拿着旋转/平移)
1738
+ * 对关键点网格进行中心化处理
2020
1739
  *
2021
- * 原理:
2022
- * - 照片所有关键点运动是【刚性的】→ 所有点以相同方向、相似幅度移动
2023
- * - 活体肌肉运动是【非刚性的】→ 不同部位独立运动(眼睛、嘴、脸颊等)
2024
- *
2025
- * 【合理使用归一化坐标】这里使用归一化坐标是有意义的,因为:
2026
- * - 消除人脸在画面中的平移,只关注人脸内部的相对运动模式
2027
- * - 检测的是运动向量的方向一致性,不依赖绝对坐标
1740
+ * 为消除人脸整体平移的干扰,以鼻尖(索引为 nosePointIndex)为原点
1741
+ * 将所有关键点的坐标转换为相对坐标:
1742
+ * p' = p - p_nose
2028
1743
  *
2029
- * 返回值 0-1:值越接近1说明是刚性运动(照片运动)
1744
+ * @param mesh - 原始关键点坐标数组
1745
+ * @returns 中心化后的关键点坐标数组
2030
1746
  */
2031
- detectRigidMotion() {
2032
- // 使用归一化坐标历史,消除平移影响
2033
- if (this.normalizedLandmarksHistory.length < 2) {
2034
- return 0; // 数据不足,不判定为刚性运动
2035
- }
2036
- // 采样关键点(覆盖全脸,去重)
2037
- const samplePoints = [
2038
- 33, 263, // 左右眼外角
2039
- 362, 133, // 左右眼内角
2040
- 234, 454, // 左右脸颊边缘
2041
- 10, 152, // 额头、下巴
2042
- 61, 291 // 嘴角
2043
- ];
2044
- const motionVectors = [];
2045
- // 使用最近两帧计算运动向量
2046
- const frame1 = this.normalizedLandmarksHistory[this.normalizedLandmarksHistory.length - 2];
2047
- const frame2 = this.normalizedLandmarksHistory[this.normalizedLandmarksHistory.length - 1];
2048
- for (const ptIdx of samplePoints) {
2049
- if (ptIdx < frame1.length && ptIdx < frame2.length) {
2050
- const p1 = frame1[ptIdx];
2051
- const p2 = frame2[ptIdx];
2052
- if (p1 && p2 && p1.length >= 2 && p2.length >= 2) {
2053
- motionVectors.push({
2054
- dx: p2[0] - p1[0],
2055
- dy: p2[1] - p1[1]
2056
- });
2057
- }
1747
+ centralizeMesh(mesh) {
1748
+ if (mesh.length <= this.config.nosePointIndex) {
1749
+ return [];
1750
+ }
1751
+ // 获取鼻尖坐标
1752
+ const nosePt = mesh[this.config.nosePointIndex];
1753
+ if (!nosePt) {
1754
+ return [];
1755
+ }
1756
+ const noseX = nosePt[0];
1757
+ const noseY = nosePt[1];
1758
+ // 对每个点进行中心化
1759
+ const centralized = [];
1760
+ for (const point of mesh) {
1761
+ if (!point) {
1762
+ centralized.push([0, 0]);
1763
+ continue;
2058
1764
  }
1765
+ const x = point[0] - noseX;
1766
+ const y = point[1] - noseY;
1767
+ centralized.push([x, y]);
2059
1768
  }
2060
- if (motionVectors.length < 3) {
2061
- return 0;
2062
- }
2063
- // 计算所有运动向量的【一致性】
2064
- // 如果所有向量都指向相同方向(方向角相似),则为刚性运动
2065
- const angles = motionVectors.map(v => Math.atan2(v.dy, v.dx));
2066
- const magnitudes = motionVectors.map(v => Math.sqrt(v.dx * v.dx + v.dy * v.dy));
2067
- // 方向一致性:计算方向的标准差
2068
- const meanAngle = this.calculateMeanAngle(angles);
2069
- const angleVariance = angles.reduce((sum, angle) => {
2070
- const diff = angle - meanAngle;
2071
- // 处理角度环绕问题
2072
- const wrappedDiff = Math.abs(diff) > Math.PI ? 2 * Math.PI - Math.abs(diff) : Math.abs(diff);
2073
- return sum + wrappedDiff * wrappedDiff;
2074
- }, 0) / angles.length;
2075
- const angleStdDev = Math.sqrt(angleVariance);
2076
- // 幅度一致性:计算幅度的变异系数
2077
- const meanMagnitude = magnitudes.reduce((a, b) => a + b, 0) / magnitudes.length;
2078
- const magnitudeVariance = magnitudes.reduce((sum, mag) => sum + (mag - meanMagnitude) ** 2, 0) / magnitudes.length;
2079
- const magnitudeStdDev = Math.sqrt(magnitudeVariance);
2080
- // 使用更低的阈值避免小运动时误判,当运动幅度很小时使用1避免除零
2081
- const magnitudeCV = meanMagnitude > 0.001 ? magnitudeStdDev / meanMagnitude : 1;
2082
- // 综合评分:方向和幅度都一致 → 刚性运动
2083
- // angleStdDev 越小(接近0)说明方向越一致
2084
- // magnitudeCV 越小(接近0)说明幅度越一致
2085
- const rigidityScore = Math.max(0, 1 - angleStdDev / 0.5) * Math.max(0, 1 - magnitudeCV);
2086
- console.debug('[RigidityCheck]', {
2087
- samplePointCount: motionVectors.length,
2088
- angleStdDev: angleStdDev.toFixed(4),
2089
- magnitudeCV: magnitudeCV.toFixed(4),
2090
- rigidityScore: rigidityScore.toFixed(3)
2091
- });
2092
- return Math.min(rigidityScore, 1);
1769
+ return centralized;
2093
1770
  }
2094
1771
  /**
2095
- * 计算角度的平均值(考虑循环性)
2096
- */
2097
- calculateMeanAngle(angles) {
2098
- const sinSum = angles.reduce((sum, a) => sum + Math.sin(a), 0);
2099
- const cosSum = angles.reduce((sum, a) => sum + Math.cos(a), 0);
2100
- return Math.atan2(sinSum / angles.length, cosSum / angles.length);
2101
- }
2102
- /**
2103
- * 检测序列是否呈现【往复波动】而不是【单向变化】
1772
+ * 计算中心化坐标相对于鼻尖的偏移量
1773
+ * 用于衡量关键点分布的整体位置变化
2104
1774
  *
2105
- * 原理:
2106
- * - 真实眨眼/表情:值会【往复波动】 如 0.4 → 0.3 → 0.4 → 0.5
2107
- * - 照片透视变形:值会【单向变化】 如 0.4 → 0.3 → 0.25 → 0.2
2108
- *
2109
- * 返回值:true = 检测到往复波动(活体特征)
1775
+ * @param frame - 人脸检测结果
1776
+ * @returns 中心化坐标的偏移量
2110
1777
  */
2111
- detectOscillation(values) {
2112
- if (values.length < 4) {
2113
- return false;
2114
- }
2115
- // 计算相邻值的差分
2116
- const diffs = [];
2117
- for (let i = 1; i < values.length; i++) {
2118
- diffs.push(values[i] - values[i - 1]);
2119
- }
2120
- // 统计方向改变次数(从正变负或从负变正)
2121
- let directionChanges = 0;
2122
- for (let i = 1; i < diffs.length; i++) {
2123
- if (diffs[i] * diffs[i - 1] < 0) { // 符号相反
2124
- directionChanges++;
2125
- }
1778
+ calculateCentroidShift(frame) {
1779
+ const mesh = frame.meshRaw;
1780
+ if (!mesh || mesh.length === 0) {
1781
+ return 0;
2126
1782
  }
2127
- // 往复波动通常有多次方向改变
2128
- // 单向变化只有0-1次方向改变
2129
- const isOscillating = directionChanges >= 1;
2130
- return isOscillating;
2131
- }
2132
- /**
2133
- * 【关键】检测真实眨眼(连续的闭眼→睁眼周期)
2134
- *
2135
- * 原理:
2136
- * - 真实眨眼:快速下降(EAR↓ 1-2帧)→ 保持低值(EAR低 2-3帧)→ 快速上升(EAR↑ 1-2帧)
2137
- * - 噪声或光线变化:孤立的异常值,前后没有连续的变化模式
2138
- *
2139
- * 返回值:true = 检测到完整或部分眨眼周期
2140
- */
2141
- detectRealBlink(values) {
2142
- if (values.length < 3) {
2143
- return false;
1783
+ const centralized = this.centralizeMesh(mesh);
1784
+ if (centralized.length === 0) {
1785
+ return 0;
2144
1786
  }
2145
- // 统计连续下降和上升的段数
2146
- let descendingSegments = 0;
2147
- let ascendingSegments = 0;
2148
- let inDescending = false;
2149
- let inAscending = false;
2150
- for (let i = 1; i < values.length; i++) {
2151
- const change = values[i] - values[i - 1];
2152
- const threshold = 0.01; // 判定为"变化"的阈值
2153
- if (change < -threshold) {
2154
- if (!inDescending) {
2155
- descendingSegments++;
2156
- inDescending = true;
2157
- inAscending = false;
2158
- }
2159
- }
2160
- else if (change > threshold) {
2161
- if (!inAscending) {
2162
- ascendingSegments++;
2163
- inAscending = true;
2164
- inDescending = false;
2165
- }
2166
- }
2167
- else ;
1787
+ // 计算所有中心化坐标到原点的距离平均值
1788
+ let totalDistance = 0;
1789
+ let validCount = 0;
1790
+ for (const point of centralized) {
1791
+ if (!point)
1792
+ continue;
1793
+ const distance = Math.sqrt(point[0] * point[0] + point[1] * point[1]);
1794
+ totalDistance += distance;
1795
+ validCount++;
2168
1796
  }
2169
- // 完整眨眼周期:下降→平台→上升,至少要有下降和上升
2170
- // 或者:最近几帧有明显的下升趋势
2171
- const hasCompletePattern = descendingSegments > 0 && ascendingSegments > 0;
2172
- // 或者检查最近5帧是否有明显变化
2173
- if (values.length >= 5) {
2174
- const recent5 = values.slice(-5);
2175
- const recentRange = Math.max(...recent5) - Math.min(...recent5);
2176
- const hasRecentBlink = recentRange > 0.02;
2177
- return hasCompletePattern || hasRecentBlink;
1797
+ if (validCount === 0) {
1798
+ return 0;
2178
1799
  }
2179
- return hasCompletePattern;
1800
+ return totalDistance / validCount;
2180
1801
  }
2181
1802
  /**
2182
- * 【新增防护】检测左右眼对称性
2183
- *
2184
- * 原理:
2185
- * - 真实眨眼:左右眼几乎同时闭合和睁开,EAR变化高度同步
2186
- * - 照片透视畸变:根据偏转方向,一只眼睛可能比另一只变化更大
2187
- *
2188
- * 返回值 0-1:越接近1说明左右眼越对称(越像真实眨眼)
1803
+ * 计算基于真实时间的运动持续时长(秒)
2189
1804
  */
2190
- detectEyeSymmetry() {
2191
- if (this.leftEyeEARHistory.length < 3 || this.rightEyeEARHistory.length < 3) {
2192
- return 1; // 数据不足,默认通过
2193
- }
2194
- // 计算左右眼EAR变化的差分
2195
- const leftDiffs = [];
2196
- const rightDiffs = [];
2197
- for (let i = 1; i < this.leftEyeEARHistory.length; i++) {
2198
- leftDiffs.push(this.leftEyeEARHistory[i] - this.leftEyeEARHistory[i - 1]);
2199
- rightDiffs.push(this.rightEyeEARHistory[i] - this.rightEyeEARHistory[i - 1]);
2200
- }
2201
- // 计算左右眼变化的相关性
2202
- // 真实眨眼:leftDiffs ≈ rightDiffs(同向同幅度)
2203
- // 透视畸变:可能一个大一个小,或方向不一致
2204
- let sumProduct = 0;
2205
- let sumLeftSq = 0;
2206
- let sumRightSq = 0;
2207
- for (let i = 0; i < leftDiffs.length; i++) {
2208
- sumProduct += leftDiffs[i] * rightDiffs[i];
2209
- sumLeftSq += leftDiffs[i] * leftDiffs[i];
2210
- sumRightSq += rightDiffs[i] * rightDiffs[i];
2211
- }
2212
- const denominator = Math.sqrt(sumLeftSq * sumRightSq);
2213
- if (denominator < 0.0001) {
2214
- return 1; // 几乎没有变化,视为对称
2215
- }
2216
- // 皮尔逊相关系数,范围 [-1, 1]
2217
- const correlation = sumProduct / denominator;
2218
- // 转换为对称性得分 [0, 1],相关性越高越对称
2219
- const symmetry = (correlation + 1) / 2;
2220
- console.debug('[EyeSymmetry]', {
2221
- correlation: correlation.toFixed(3),
2222
- symmetry: symmetry.toFixed(3)
2223
- });
2224
- return symmetry;
1805
+ calculateMovementDuration() {
1806
+ if (this.frameBuffer.length < 2)
1807
+ return 0;
1808
+ const firstTimestamp = this.frameBuffer[0].timestamp;
1809
+ const lastTimestamp = this.frameBuffer[this.frameBuffer.length - 1].timestamp;
1810
+ return (lastTimestamp - firstTimestamp) / 1000; // 转为秒
2225
1811
  }
2226
1812
  /**
2227
- * 【新增防护】检测眨眼时间模式
2228
- *
2229
- * 原理:
2230
- * - 真实眨眼非常快:完整周期 100-400ms(3-12帧@30fps)
2231
- * - 手动摆动照片:周期通常 500ms-2000ms(15-60帧@30fps)
2232
- *
2233
- * 返回值:true = 检测到符合真实眨眼的快速时间模式
1813
+ * 计算中心化坐标的变化速率(单位:每秒)
2234
1814
  */
2235
- detectBlinkTiming() {
2236
- if (this.eyeAspectRatioHistory.length < 5 || this.frameTimestamps.length < 5) {
2237
- return true; // 数据不足,默认通过
2238
- }
2239
- // 找到EAR的局部最小值(眨眼闭合点)
2240
- const values = this.eyeAspectRatioHistory;
2241
- const timestamps = this.frameTimestamps;
2242
- // 检测下降-上升周期的时间
2243
- let inDescent = false;
2244
- let descentStartIdx = -1;
2245
- let fastBlinkCount = 0;
2246
- let slowBlinkCount = 0;
2247
- for (let i = 1; i < values.length; i++) {
2248
- const change = values[i] - values[i - 1];
2249
- if (change < -0.01 && !inDescent) {
2250
- // 开始下降
2251
- inDescent = true;
2252
- descentStartIdx = i - 1;
2253
- }
2254
- else if (change > 0.01 && inDescent) {
2255
- // 开始上升(完成一个眨眼周期)
2256
- inDescent = false;
2257
- if (descentStartIdx >= 0 && i < timestamps.length) {
2258
- const duration = timestamps[i] - timestamps[descentStartIdx];
2259
- if (duration > 0 && duration < 500) {
2260
- fastBlinkCount++; // 快速眨眼(< 500ms)
2261
- }
2262
- else if (duration >= 500) {
2263
- slowBlinkCount++; // 慢速"眨眼"(可能是照片摆动)
2264
- }
2265
- }
2266
- }
2267
- }
2268
- // 如果快速眨眼比慢速眨眼多,认为是真实的
2269
- const hasValidTiming = fastBlinkCount > 0 || slowBlinkCount === 0;
2270
- console.debug('[BlinkTiming]', {
2271
- fastBlinks: fastBlinkCount,
2272
- slowBlinks: slowBlinkCount,
2273
- hasValidTiming
2274
- });
2275
- return hasValidTiming;
1815
+ calculateCentroidShiftRate() {
1816
+ if (this.frameBuffer.length < 2)
1817
+ return 0;
1818
+ const firstShift = this.calculateCentroidShift(this.frameBuffer[0].result);
1819
+ const lastShift = this.calculateCentroidShift(this.frameBuffer[this.frameBuffer.length - 1].result);
1820
+ const shiftDistance = Math.abs(lastShift - firstShift);
1821
+ const timeSec = (this.frameBuffer[this.frameBuffer.length - 1].timestamp - this.frameBuffer[0].timestamp) / 1000;
1822
+ return timeSec > 0 ? shiftDistance / timeSec : 0;
2276
1823
  }
2277
1824
  /**
2278
- * 【新增防护】检测运动-形变相关性
2279
- *
2280
- * 原理:
2281
- * - 照片偏转攻击:刚性运动越大 → EAR/MAR形变越大(高度相关)
2282
- * - 真实活体:眨眼/张嘴与头部运动无关(低相关或无相关)
2283
- *
2284
- * 返回值 0-1:越接近1说明运动和形变越相关(越像照片攻击)
1825
+ * 计算数组的平均值
2285
1826
  */
2286
- detectMotionDeformCorrelation() {
2287
- if (this.rigidMotionHistory.length < 3 || this.eyeAspectRatioHistory.length < 3) {
2288
- return 0; // 数据不足,默认不是攻击
2289
- }
2290
- // 计算EAR变化幅度
2291
- const earChanges = [];
2292
- for (let i = 1; i < this.eyeAspectRatioHistory.length; i++) {
2293
- earChanges.push(Math.abs(this.eyeAspectRatioHistory[i] - this.eyeAspectRatioHistory[i - 1]));
2294
- }
2295
- // 取最近的刚性运动历史(对齐长度)
2296
- const motionValues = this.rigidMotionHistory.slice(-(earChanges.length));
2297
- if (motionValues.length !== earChanges.length || motionValues.length < 3) {
1827
+ calculateMean(values) {
1828
+ if (values.length === 0)
2298
1829
  return 0;
2299
- }
2300
- // 计算皮尔逊相关系数
2301
- const n = motionValues.length;
2302
- const meanMotion = motionValues.reduce((a, b) => a + b, 0) / n;
2303
- const meanEAR = earChanges.reduce((a, b) => a + b, 0) / n;
2304
- let numerator = 0;
2305
- let denomMotion = 0;
2306
- let denomEAR = 0;
2307
- for (let i = 0; i < n; i++) {
2308
- const diffMotion = motionValues[i] - meanMotion;
2309
- const diffEAR = earChanges[i] - meanEAR;
2310
- numerator += diffMotion * diffEAR;
2311
- denomMotion += diffMotion * diffMotion;
2312
- denomEAR += diffEAR * diffEAR;
2313
- }
2314
- const denominator = Math.sqrt(denomMotion * denomEAR);
2315
- if (denominator < 0.0001) {
2316
- return 0; // 几乎没有变化
2317
- }
2318
- // 相关系数 [-1, 1],我们关心正相关(运动大→形变大)
2319
- const correlation = numerator / denominator;
2320
- // 只有正相关才可疑,负相关或无相关都正常
2321
- const suspiciousCorrelation = Math.max(0, correlation);
2322
- console.debug('[MotionDeformCorr]', {
2323
- correlation: correlation.toFixed(3),
2324
- suspicious: suspiciousCorrelation.toFixed(3)
2325
- });
2326
- return suspiciousCorrelation;
1830
+ return values.reduce((a, b) => a + b) / values.length;
2327
1831
  }
2328
1832
  /**
2329
- * 【关键】检测最近几帧是否有运动
2330
- *
2331
- * 防护:某人在检测开始时眨眼,之后就完全静止
2332
- * 这种情况应该判定为照片,因为照片可以有偶然的反光
2333
- * 活体应该有【持续的或周期性的】动作
2334
- *
2335
- * 返回值:true = 最近3-5帧内有明显变化
1833
+ * 计算数组的标准差
2336
1834
  */
2337
- detectRecentMovement(values) {
2338
- if (values.length < 4) {
2339
- return false; // 数据不足,保守判定
2340
- }
2341
- // 检查最近帧的变化幅度
2342
- // 如果最近帧都相同,说明动作已经停止
2343
- const recentFrames = values.slice(-5); // 最近5帧
2344
- const recentRange = Math.max(...recentFrames) - Math.min(...recentFrames);
2345
- const recentStdDev = this.calculateStdDev(recentFrames);
2346
- // 最近帧还有变化,说明活体在动
2347
- const hasRecentChange = recentRange > 0.008 || recentStdDev > 0.003;
2348
- // 额外检查:不能只是偶然的反光
2349
- // 如果最后2帧都完全相同或非常接近,说明已经停止
2350
- const lastTwoChanges = Math.abs(values[values.length - 1] - values[values.length - 2]);
2351
- const isStabiliziing = lastTwoChanges < 0.002;
2352
- return hasRecentChange && !isStabiliziing;
1835
+ calculateStdDev(values) {
1836
+ if (values.length === 0)
1837
+ return 0;
1838
+ const mean = this.calculateMean(values);
1839
+ const squaredDiffs = values.map(v => (v - mean) ** 2);
1840
+ const variance = squaredDiffs.reduce((a, b) => a + b) / values.length;
1841
+ return Math.sqrt(variance);
2353
1842
  }
2354
1843
  /**
2355
- * 【核心】照片几何特征检测(逆向检测)
1844
+ * 检查当前实例是否可用
2356
1845
  *
2357
- * 重要说明:
2358
- * - MediaPipe的Z坐标是从2D图像【推断】的,不是真实深度
2359
- * - 对照片也可能推断出"假"的3D结构
2360
- * - 因此【2D几何约束】比【Z坐标分析】更可靠
1846
+ * @description 通过检查帧缓冲区长度来判断实例是否处于可用状态
1847
+ * 当帧缓冲区长度大于等于2时,认为实例可用
2361
1848
  *
2362
- * 可靠的检测(基于2D几何,物理定律):
2363
- * 1. 单应性变换约束 - 平面必须满足
2364
- * 2. 特征点相对位置变化 - 照片偏转时遵循透视规律
2365
- *
2366
- * 参考性检测(基于推断的Z坐标,可能被欺骗):
2367
- * 1. 深度一致性 - 辅助参考
2368
- * 2. 跨帧深度模式 - 辅助参考
1849
+ * @returns {boolean} 如果实例可用返回true,否则返回false
2369
1850
  */
2370
- detectPhotoGeometry() {
2371
- if (this.normalizedLandmarksHistory.length < 3) {
2372
- return { isPhoto: false, confidence: 0, details: {} };
2373
- }
2374
- // 【核心检测1】平面单应性约束检测(最可靠,纯2D几何)
2375
- const homographyResult = this.detectHomographyConstraint();
2376
- // 【核心检测2】特征点相对位置变化模式(照片遵循透视变换规律)
2377
- const perspectivePattern = this.detectPerspectiveTransformPattern();
2378
- // 【核心检测3】交叉比率不变性检测(射影几何的核心不变量)
2379
- const crossRatioResult = this.detectCrossRatioInvariance();
2380
- // 【辅助检测】深度相关(Z坐标是推断的,权重降低)
2381
- const depthResult = this.detectDepthConsistency();
2382
- const crossFrameDepth = this.detectCrossFrameDepthPattern();
2383
- // 综合判定:2D几何约束权重高,Z坐标权重低
2384
- // 【改进】提高perspectiveScore的权重,因为完美的平滑变换是照片的强特征
2385
- const photoScore = homographyResult.planarScore * 0.30 + // 单应性约束(最可靠)
2386
- perspectivePattern.perspectiveScore * 0.40 + // 透视变换模式(可靠且权重提高)
2387
- crossRatioResult.invarianceScore * 0.15 + // 交叉比率不变性(可靠)
2388
- (1 - Math.min(depthResult.depthVariation, 1)) * 0.10 + // 深度(辅助,低权重)
2389
- crossFrameDepth.planarPattern * 0.05; // 跨帧深度(辅助,低权重)
2390
- const isPhoto = photoScore > 0.50; // 【改进】降低阈值到0.50(从0.60)
2391
- const confidence = Math.min(photoScore, 1);
2392
- // 记录历史
2393
- this.planarityScores.push(photoScore);
2394
- if (this.planarityScores.length > this.config.frameBufferSize) {
2395
- this.planarityScores.shift();
2396
- }
2397
- console.debug('[PhotoGeometry]', {
2398
- homography: homographyResult.planarScore.toFixed(3),
2399
- perspective: perspectivePattern.perspectiveScore.toFixed(3),
2400
- crossRatio: crossRatioResult.invarianceScore.toFixed(3),
2401
- depthVariation: depthResult.depthVariation.toFixed(3),
2402
- crossFrame: crossFrameDepth.planarPattern.toFixed(3),
2403
- photoScore: photoScore.toFixed(3),
2404
- isPhoto
2405
- });
2406
- return {
2407
- isPhoto,
2408
- confidence,
2409
- details: {
2410
- homographyScore: homographyResult.planarScore,
2411
- perspectiveScore: perspectivePattern.perspectiveScore,
2412
- crossRatioScore: crossRatioResult.invarianceScore,
2413
- depthVariation: depthResult.depthVariation,
2414
- crossFramePattern: crossFrameDepth.planarPattern
2415
- }
2416
- };
1851
+ isAvailable() {
1852
+ return this.frameBuffer.length >= 2;
2417
1853
  }
2418
1854
  /**
2419
- * 【新增核心检测】交叉比率不变性检测
2420
- *
2421
- * 原理(射影几何的基本定理):
2422
- * - 平面上共线4点的【交叉比率】在透视变换下保持不变
2423
- * - 真实3D人脸旋转时,面部各点不共面,交叉比率会变化
2424
- * - 照片无论怎么偏转,共线点的交叉比率保持不变
2425
- *
2426
- * 这是纯2D几何检测,非常可靠!
2427
- */
2428
- /**
2429
- * 【交叉比率不变性检测】
2430
- *
2431
- * 原理(射影几何的基本定理):
2432
- * - 平面上共线4点的【交叉比率】在透视变换下保持不变
2433
- * - 真实3D人脸旋转时,面部各点不共面,交叉比率会变化
2434
- * - 照片无论怎么偏转,共线点的交叉比率保持不变
2435
- *
2436
- * 虽然交叉比率是射影不变量(用任何坐标系都可以),
2437
- * 但使用原始坐标以保持一致性和物理意义的清晰性
1855
+ * 重置检测器
2438
1856
  */
2439
- detectCrossRatioInvariance() {
2440
- // 【使用原始坐标历史】虽然交叉比率是射影不变量,
2441
- // 但原始坐标保持物理清晰性
2442
- if (this.faceLandmarksHistory.length < 3) {
2443
- return { invarianceScore: 0 };
2444
- }
2445
- // 选择面部中线上近似共线的点(额头-鼻梁-鼻尖-嘴-下巴)
2446
- const midlinePoints = [10, 168, 1, 0, 152]; // 从上到下
2447
- const crossRatios = [];
2448
- for (const frame of this.faceLandmarksHistory) {
2449
- if (frame.length < 468)
2450
- continue;
2451
- // 提取中线点的Y坐标(它们大致在一条垂直线上)
2452
- const yCoords = [];
2453
- for (const idx of midlinePoints) {
2454
- if (frame[idx]) {
2455
- yCoords.push(frame[idx][1]);
2456
- }
2457
- }
2458
- if (yCoords.length >= 4) {
2459
- // 计算交叉比率 CR(A,B,C,D) = (AC * BD) / (BC * AD)
2460
- const a = yCoords[0], b = yCoords[1], c = yCoords[2], d = yCoords[3];
2461
- const ac = Math.abs(c - a);
2462
- const bd = Math.abs(d - b);
2463
- const bc = Math.abs(c - b);
2464
- const ad = Math.abs(d - a);
2465
- if (bc > 0.001 && ad > 0.001) {
2466
- const cr = (ac * bd) / (bc * ad);
2467
- crossRatios.push(cr);
2468
- }
2469
- }
2470
- }
2471
- if (crossRatios.length < 2) {
2472
- return { invarianceScore: 0 };
2473
- }
2474
- // 计算交叉比率的变异系数
2475
- // 照片:交叉比率应该几乎不变(变异系数小)
2476
- // 真人:交叉比率会变化(变异系数大)
2477
- const mean = crossRatios.reduce((a, b) => a + b, 0) / crossRatios.length;
2478
- const stdDev = this.calculateStdDev(crossRatios);
2479
- const cv = mean > 0.001 ? stdDev / mean : 0;
2480
- // 变异系数越小,越可能是平面(照片)
2481
- // cv < 0.05 → 非常稳定(照片)
2482
- // cv > 0.15 → 变化明显(真人)
2483
- const invarianceScore = Math.max(0, 1 - cv / 0.1);
2484
- console.debug('[CrossRatio]', {
2485
- mean: mean.toFixed(4),
2486
- stdDev: stdDev.toFixed(4),
2487
- cv: cv.toFixed(4),
2488
- invarianceScore: invarianceScore.toFixed(3)
2489
- });
2490
- return { invarianceScore: Math.min(invarianceScore, 1), cv };
1857
+ reset() {
1858
+ this.frameBuffer = [];
1859
+ this.movementHistory = [];
1860
+ this.continuousMovingCount = 0;
2491
1861
  }
2492
1862
  /**
2493
- * 【关键】检测单应性变换约束
2494
- *
2495
- * 原理:
2496
- * - 平面物体(照片)在不同视角下的投影满足 H * p1 = p2(H是3x3单应性矩阵)
2497
- * - 3D物体不满足这个约束,会有残差误差
2498
- *
2499
- * 方法:用4对点计算H,然后检验其他点是否符合H变换
2500
- */
2501
- /**
2502
- * 【单应性约束检测】判断多帧特征点是否满足平面约束
2503
- *
2504
- * 【关键改进】:
2505
- * 1. 使用DLT算法计算完整的3x3单应性矩阵(8参数)
2506
- * 2. 使用相邻帧而不是首尾帧(减少变化幅度)
2507
- * 3. 检查单应性矩阵的性质(秩、行列式等)
2508
- * 4. 计算多帧的平均误差(更稳定)
2509
- * 5. 对所有帧对的H矩阵一致性进行验证
2510
- *
2511
- * 这是纯 2D 几何检测,最可靠!
1863
+ * 获取当前缓冲区中的帧数
2512
1864
  */
2513
- detectHomographyConstraint() {
2514
- // 【关键】使用原始坐标历史,而不是归一化坐标
2515
- // 原因:单应性矩阵在原始图像坐标中定义
2516
- // 归一化坐标虽然消除平移影响,但破坏了H矩阵的定义
2517
- if (this.faceLandmarksHistory.length < 2) {
2518
- return { planarScore: 0 };
2519
- }
2520
- // 【改进】使用所有面部关键点(468个点)而不是采样点
2521
- // 更多的点对会给出更准确的单应性矩阵估计
2522
- const errors = [];
2523
- const homographyMatrices = [];
2524
- let lastSrcPoints = []; // 保存最后一组点对,用于计算特征尺度
2525
- // 【策略改进】优先使用最近的帧对(尽早检测)
2526
- // 照片的几何约束是瞬间的,2帧就足够;多帧用来验证一致性
2527
- const recentFrameCount = Math.min(5, this.faceLandmarksHistory.length);
2528
- console.debug('[HomographyConstraint] Starting analysis', {
2529
- totalFrames: this.faceLandmarksHistory.length,
2530
- recentFrameCount,
2531
- startIndex: Math.max(1, this.faceLandmarksHistory.length - recentFrameCount)
2532
- });
2533
- // 计算最近帧对的变换误差(重点在最近的帧)
2534
- for (let i = Math.max(1, this.faceLandmarksHistory.length - recentFrameCount); i < this.faceLandmarksHistory.length; i++) {
2535
- const frame1 = this.faceLandmarksHistory[i - 1];
2536
- const frame2 = this.faceLandmarksHistory[i];
2537
- console.debug(`[HomographyConstraint] Processing frame pair [${i - 1}, ${i}]`, {
2538
- frame1Length: frame1.length,
2539
- frame2Length: frame2.length
2540
- });
2541
- if (frame1.length < 100 || frame2.length < 100) {
2542
- console.debug(`[HomographyConstraint] Frame pair skipped (insufficient points)`);
2543
- continue; // 至少100个有效点
2544
- }
2545
- // 【改进】收集所有有效的点对(而不是只采样10个点)
2546
- // 这给出更好的H矩阵估计
2547
- const srcPoints = [];
2548
- const dstPoints = [];
2549
- for (let ptIdx = 0; ptIdx < Math.min(frame1.length, frame2.length); ptIdx++) {
2550
- if (frame1[ptIdx] && frame2[ptIdx] &&
2551
- frame1[ptIdx].length >= 2 && frame2[ptIdx].length >= 2) {
2552
- const x1 = frame1[ptIdx][0];
2553
- const y1 = frame1[ptIdx][1];
2554
- const x2 = frame2[ptIdx][0];
2555
- const y2 = frame2[ptIdx][1];
2556
- // 【修复】只排除明显的异常值(移动过大),保留所有其他点
2557
- // 包括静止的点和微妙移动的点,DLT需要全局点分布
2558
- const displacement = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
2559
- if (displacement < 200) { // 仅排除极端异常
2560
- srcPoints.push([x1, y1]);
2561
- dstPoints.push([x2, y2]);
2562
- }
2563
- }
2564
- }
2565
- console.debug(`[HomographyConstraint] Collected points`, {
2566
- srcPointsCount: srcPoints.length,
2567
- dstPointsCount: dstPoints.length
2568
- });
2569
- if (srcPoints.length < 10) {
2570
- console.debug(`[HomographyConstraint] Point pair skipped (only ${srcPoints.length} < 10 points)`);
2571
- continue; // 至少10个匹配点对(DLT最少需要4个)
2572
- }
2573
- // 保存这一组点对(用于后面计算特征尺度)
2574
- lastSrcPoints = srcPoints;
2575
- // 【新增】使用DLT算法计算完整的3x3单应性矩阵
2576
- const H = this.estimateHomographyDLT(srcPoints, dstPoints);
2577
- if (!H) {
2578
- console.debug(`[HomographyConstraint] DLT failed to estimate homography`);
2579
- continue;
2580
- }
2581
- console.debug(`[HomographyConstraint] H matrix estimated:`, {
2582
- h00: H[0][0].toFixed(4),
2583
- h11: H[1][1].toFixed(4),
2584
- h22: H[2][2].toFixed(4),
2585
- det: (H[0][0] * H[1][1] * H[2][2] + H[0][1] * H[1][2] * H[2][0] + H[0][2] * H[1][0] * H[2][1]
2586
- - H[0][2] * H[1][1] * H[2][0] - H[0][0] * H[1][2] * H[2][1] - H[0][1] * H[1][0] * H[2][2]).toFixed(4)
2587
- });
2588
- homographyMatrices.push(H);
2589
- // 【改进】使用单应性矩阵计算误差(而不是仿射变换)
2590
- let frameError = 0;
2591
- let validCount = 0;
2592
- const sampleErrors = [];
2593
- for (let j = 0; j < srcPoints.length; j++) {
2594
- const transformed = this.applyHomography(H, srcPoints[j][0], srcPoints[j][1]);
2595
- const actual = dstPoints[j];
2596
- const error = Math.sqrt((transformed[0] - actual[0]) ** 2 + (transformed[1] - actual[1]) ** 2);
2597
- frameError += error;
2598
- validCount++;
2599
- // 记录前5个误差用于调试
2600
- if (j < 5) {
2601
- sampleErrors.push(error);
2602
- }
2603
- }
2604
- if (validCount > 0) {
2605
- const avgFrameError = frameError / validCount;
2606
- errors.push(avgFrameError);
2607
- console.debug(`[HomographyConstraint] Frame error computed`, {
2608
- pointCount: validCount,
2609
- avgFrameError: avgFrameError.toFixed(4),
2610
- sampleErrors: sampleErrors.map(e => e.toFixed(4)).join(', ')
2611
- });
2612
- }
2613
- }
2614
- console.debug(`[HomographyConstraint] Error collection complete`, {
2615
- totalErrors: errors.length,
2616
- matrixCount: homographyMatrices.length
2617
- });
2618
- if (errors.length === 0) {
2619
- console.debug(`[HomographyConstraint] No errors computed, returning 0`);
2620
- return { planarScore: 0, error: 0 };
2621
- }
2622
- // 计算所有帧的平均误差
2623
- const avgError = errors.reduce((a, b) => a + b, 0) / errors.length;
2624
- // 【新增】检查H矩阵的一致性
2625
- // 照片的H矩阵在不同帧对中应该保持相对稳定
2626
- let matrixConsistency = 1.0;
2627
- if (homographyMatrices.length > 1) {
2628
- matrixConsistency = this.checkHomographyConsistency(homographyMatrices);
2629
- }
2630
- // 【改进】使用相对误差而不是绝对误差
2631
- // 相对误差 = avgError / 点集特征尺度
2632
- // 这样对不同分辨率和点集大小更鲁棒
2633
- const characteristicScale = lastSrcPoints.length > 0 ? this.computeCharacteristicScale(lastSrcPoints) : 1;
2634
- const relativeError = characteristicScale > 0.1 ? avgError / characteristicScale : avgError;
2635
- // 平面性判决:
2636
- // relativeError < 0.05 → 很可能是平面(照片)
2637
- // relativeError > 0.1 → 很可能是立体(活体)
2638
- const errorScore = Math.max(0, 1 - relativeError / 0.1);
2639
- const planarScore = errorScore * matrixConsistency;
2640
- console.debug('[HomographyConstraint] FINAL RESULT', {
2641
- recentFrameCount,
2642
- frameCount: errors.length,
2643
- avgError: avgError.toFixed(4),
2644
- characteristicScale: characteristicScale.toFixed(4),
2645
- relativeError: relativeError.toFixed(4),
2646
- errorScore: errorScore.toFixed(3),
2647
- matrixConsistency: matrixConsistency.toFixed(3),
2648
- planarScore: planarScore.toFixed(3),
2649
- homographyMatrixCount: homographyMatrices.length
2650
- });
2651
- return { planarScore: Math.min(planarScore, 1), error: avgError };
1865
+ getFrameCount() {
1866
+ return this.frameBuffer.length;
2652
1867
  }
2653
1868
  /**
2654
- * 使用DLT算法估计单应性矩阵(Homography Estimation using DLT)
2655
- *
2656
- * DLT (Direct Linear Transform) 是估计射影变换的标准算法
2657
- *
2658
- * 输入:
2659
- * - src: 源点坐标数组 (至少4对)
2660
- * - dst: 目标点坐标数组
2661
- *
2662
- * 输出:
2663
- * - 3x3单应性矩阵H,使得 p' = H * p (齐次坐标表示)
2664
- *
2665
- * 关键特性:
2666
- * - 与仿射变换的区别:
2667
- * * 仿射:6参数,处理旋转+缩放+平移+剪切
2668
- * * 单应性:8参数,处理完整的射影变换(包括透视失真)
2669
- * - 适用场景:照片倾斜拍摄、相机透视变换
2670
- * - 数值稳定性:使用点集归一化提高数值精度
2671
- *
2672
- * 算法步骤:
2673
- * 1. 归一化源点和目标点(改进数值稳定性)
2674
- * 2. 构建 2n×9 的 A 矩阵(n为点对数)
2675
- * 3. 使用最小二乘法求解 Ah = 0
2676
- * 4. 反演应化矩阵到原始坐标系
1869
+ * 获取运动历史数据
2677
1870
  */
2678
- estimateHomographyDLT(src, dst) {
2679
- if (src.length < 4 || dst.length < 4 || src.length !== dst.length) {
2680
- console.debug('[DLT] Invalid input', { srcLen: src.length, dstLen: dst.length });
2681
- return null;
2682
- }
2683
- const n = src.length;
2684
- // 【关键】对点进行归一化,提高数值稳定性
2685
- const srcNorm = this.normalizePoints(src);
2686
- const dstNorm = this.normalizePoints(dst);
2687
- if (!srcNorm || !dstNorm) {
2688
- console.debug('[DLT] Point normalization failed');
2689
- return null;
2690
- }
2691
- console.debug('[DLT] Normalization success', {
2692
- pointCount: n,
2693
- srcScale: srcNorm.T[0][0].toFixed(4),
2694
- dstScale: dstNorm.T[0][0].toFixed(4)
2695
- });
2696
- // 构建DLT方程矩阵 A (2n × 9)
2697
- //
2698
- // 标准DLT形式推导:
2699
- // 已知齐次坐标关系:p' = H * p
2700
- // 即:[x', y', 1]^T = H * [x, y, 1]^T / w
2701
- // 其中:w = h7*x + h8*y + h9 (齐次化分母)
2702
- //
2703
- // 展开得到:
2704
- // x' = (h1*x + h2*y + h3) / (h7*x + h8*y + h9)
2705
- // y' = (h4*x + h5*y + h6) / (h7*x + h8*y + h9)
2706
- //
2707
- // 交叉相乘消去分母:
2708
- // x'*(h7*x + h8*y + h9) = h1*x + h2*y + h3
2709
- // y'*(h7*x + h8*y + h9) = h4*x + h5*y + h6
2710
- //
2711
- // 整理为齐次线性方程 Ah = 0:
2712
- // h1*x + h2*y + h3 - xp*h7*x - xp*h8*y - xp*h9 = 0
2713
- // h4*x + h5*y + h6 - yp*h7*x - yp*h8*y - yp*h9 = 0
2714
- //
2715
- // 矩阵形式(每对点产生2行方程):
2716
- // [x, y, 1, 0, 0, 0, -xp*x, -xp*y, -xp] [h1]
2717
- // [0, 0, 0, x, y, 1, -yp*x, -yp*y, -yp] * [h2] = 0
2718
- // [...]
2719
- // [h9]
2720
- const A = [];
2721
- for (let i = 0; i < n; i++) {
2722
- const x = srcNorm.points[i][0];
2723
- const y = srcNorm.points[i][1];
2724
- const xp = dstNorm.points[i][0];
2725
- const yp = dstNorm.points[i][1];
2726
- // 第一行:h1*x + h2*y + h3 - xp*h7*x - xp*h8*y - xp*h9 = 0
2727
- A.push([x, y, 1, 0, 0, 0, -xp * x, -xp * y, -xp]);
2728
- // 第二行:h4*x + h5*y + h6 - yp*h7*x - yp*h8*y - yp*h9 = 0
2729
- A.push([0, 0, 0, x, y, 1, -yp * x, -yp * y, -yp]);
2730
- }
2731
- // 使用最小二乘法求解 Ah = 0
2732
- // h 是 A^T*A 最小特征值对应的特征向量
2733
- const h = this.solveHomographyLSQ(A);
2734
- if (!h)
2735
- return null;
2736
- // 反演应化:从归一化坐标回到原始图像坐标
2737
- // H_orig = T_dst^(-1) * H_norm * T_src
2738
- const H = this.denormalizeHomography(h, srcNorm, dstNorm);
2739
- return H;
1871
+ getMovementHistory() {
1872
+ return [...this.movementHistory];
2740
1873
  }
2741
1874
  /**
2742
- * 点集归一化(Hartley标准化)- 提高DLT数值稳定性
2743
- *
2744
- * 目的:
2745
- * - DLT算法对坐标尺度敏感,归一化可显著改善数值稳定性
2746
- * - 避免矩阵条件数过大,减少数值误差
2747
- *
2748
- * 方法:
2749
- * 1. 计算点集的重心 (cx, cy)
2750
- * 2. 计算点到重心的平均距离
2751
- * 3. 缩放使得平均距离为 √2
2752
- *
2753
- * 变换:T = [s, 0, -s*cx; 0, s, -s*cy; 0, 0, 1]
2754
- * 其中 s = √2 / avgDistance
2755
- *
2756
- * 好处:
2757
- * - 点集的重心在原点
2758
- * - 点到原点的平均距离为 √2(标准化)
2759
- * - A^T*A 矩阵条件数接近最优
2760
- *
2761
- * 逆操作:在得到矩阵后需要反归一化回原始坐标
1875
+ * 获取连续运动帧数
2762
1876
  */
2763
- normalizePoints(points) {
2764
- if (points.length === 0)
2765
- return null;
2766
- // 计算重心
2767
- let cx = 0, cy = 0;
2768
- for (const p of points) {
2769
- cx += p[0];
2770
- cy += p[1];
2771
- }
2772
- cx /= points.length;
2773
- cy /= points.length;
2774
- // 计算平均距离
2775
- let avgDist = 0;
2776
- for (const p of points) {
2777
- const dx = p[0] - cx;
2778
- const dy = p[1] - cy;
2779
- avgDist += Math.sqrt(dx * dx + dy * dy);
2780
- }
2781
- avgDist /= points.length;
2782
- // 缩放因子
2783
- const scale = avgDist > 0.001 ? Math.sqrt(2) / avgDist : 1;
2784
- // 应用归一化变换
2785
- const normalized = [];
2786
- for (const p of points) {
2787
- normalized.push([
2788
- (p[0] - cx) * scale,
2789
- (p[1] - cy) * scale
2790
- ]);
2791
- }
2792
- // 归一化矩阵 T
2793
- const T = [
2794
- [scale, 0, -cx * scale],
2795
- [0, scale, -cy * scale],
2796
- [0, 0, 1]
2797
- ];
2798
- return { points: normalized, T };
1877
+ getContinuousMovingFrames() {
1878
+ return this.continuousMovingCount;
2799
1879
  }
2800
- /**
2801
- * 最小二乘法求解齐次线性方程 Ah = 0
2802
- *
2803
- * 问题:找到使 ||Ah|| 最小的单位向量 h
2804
- *
2805
- * 标准解法:
2806
- * - 完整SVD:对 A 进行 SVD 分解,h 是最小奇异值的右奇异向量
2807
- * - 特征向量法(此处使用):
2808
- * 1. 计算 A^T * A(9×9对称矩阵)
2809
- * 2. 求 A^T*A 的最小特征值对应的特征向量
2810
- * 3. 该特征向量即为所求的 h
2811
- *
2812
- * 说明:
2813
- * - h 3×3 单应性矩阵 H 的向量化形式
2814
- * - 返回的特征向量已归一化
2815
- */
2816
- solveHomographyLSQ(A) {
2817
- if (A.length < 8)
2818
- return null;
2819
- // 构造 A^T * A 矩阵 (9×9)
2820
- // 这是一个对称半正定矩阵
2821
- const ATA = Array(9).fill(0).map(() => Array(9).fill(0));
2822
- for (let i = 0; i < 9; i++) {
2823
- for (let j = 0; j < 9; j++) {
2824
- for (let k = 0; k < A.length; k++) {
2825
- ATA[i][j] += A[k][i] * A[k][j];
2826
- }
2827
- }
2828
- }
2829
- // A^T*A 的最小特征向量
2830
- // 最小特征向量对应最小特征值,使 ||Ah|| 最小
2831
- const eigenVec = this.getSmallestEigenvector(ATA);
2832
- return eigenVec;
1880
+ }
1881
+
1882
+ /**
1883
+ * 照片攻击检测器 - 双重方案实现
1884
+ *
1885
+ * 方案一:MediaPipe 3D 关键点深度方差分析
1886
+ * - 完全不依赖背景
1887
+ * - 对白墙、黑墙、任意纯色背景均有效
1888
+ * - 只需人脸本身具有 3D 结构(真实人脸有,照片没有)
1889
+ *
1890
+ * 方案二:关键点运动透视一致性检验
1891
+ * - 比较鼻尖、脸颊、耳朵等在多帧中的 2D 位移比例
1892
+ * - 真实人脸因透视效应,近处点移动幅度 > 远处点
1893
+ * - 照片上所有点按同一仿射变换移动 运动向量高度一致
1894
+ *
1895
+ * ⚠️ 关键理解 ⚠️
1896
+ * MediaPipe 返回的 Z 坐标(深度)是从 2D 图像【推断】出来的,不是真实的物理深度!
1897
+ * - 对真实人脸:推断出正确的 3D 结构 → Z 坐标有方差
1898
+ * - 对照片人脸:推断深度值可能平坦 → Z 坐标方差极小
1899
+ */
1900
+ /**
1901
+ * 照片攻击检测结果
1902
+ */
1903
+ class PhotoAttackDetectionResult {
1904
+ isPhoto;
1905
+ details;
1906
+ debug;
1907
+ constructor(isPhoto, details, debug = {}) {
1908
+ this.isPhoto = isPhoto;
1909
+ this.details = details;
1910
+ this.debug = debug;
2833
1911
  }
2834
- /**
2835
- * 求9x9对称矩阵(A^T*A)的最小特征向量
2836
- *
2837
- * 【关键】使用 Jacobi 特征值分解而不是幂法
2838
- *
2839
- * 问题分析:
2840
- * - 迭代幂法(Power Iteration)求的是**最大**特征值的特征向量
2841
- * - 我们需要**最小**特征值的特征向量
2842
- * - 之前的幂法实现虽然标记为最小,但实际求的是最大 → 算法失败!
2843
- *
2844
- * 解决方案:
2845
- * 1. 使用 QR 算法或 Jacobi 方法求完整的特征值分解
2846
- * 2. 选择最小特征值对应的特征向量
2847
- *
2848
- * 此处实现简化的 Jacobi 迭代:
2849
- * - 对称矩阵对角化
2850
- * - 提取最小特征值的特征向量
2851
- */
2852
- getSmallestEigenvector(mat) {
2853
- if (mat.length !== 9)
2854
- return null;
2855
- // 复制矩阵,Jacobi方法会修改原矩阵
2856
- const A = mat.map(row => [...row]);
2857
- const V = Array(9).fill(0).map((_, i) => {
2858
- const row = Array(9).fill(0);
2859
- row[i] = 1;
2860
- return row;
2861
- }); // 9×9 单位矩阵,用于存储特征向量
2862
- // Jacobi迭代(简化版)
2863
- // 目标:将 A 对角化为 D = V^T * A * V
2864
- for (let iteration = 0; iteration < 20; iteration++) {
2865
- let maxOffDiag = 0;
2866
- let p = 0, q = 1;
2867
- // 找非对角元素中绝对值最大的
2868
- for (let i = 0; i < 9; i++) {
2869
- for (let j = i + 1; j < 9; j++) {
2870
- if (Math.abs(A[i][j]) > maxOffDiag) {
2871
- maxOffDiag = Math.abs(A[i][j]);
2872
- p = i;
2873
- q = j;
2874
- }
2875
- }
2876
- }
2877
- // 收敛判断
2878
- if (maxOffDiag < 1e-10) {
2879
- break;
2880
- }
2881
- // 计算Givens旋转角
2882
- const Aqq = A[q][q];
2883
- const App = A[p][p];
2884
- const Apq = A[p][q];
2885
- let theta = 0;
2886
- if (Math.abs(Aqq - App) < 1e-10) {
2887
- theta = Math.PI / 4;
2888
- }
2889
- else {
2890
- theta = 0.5 * Math.atan2(2 * Apq, Aqq - App);
2891
- }
2892
- const c = Math.cos(theta);
2893
- const s = Math.sin(theta);
2894
- // 更新矩阵 A(2×2 子块旋转)
2895
- const App_new = c * c * App - 2 * s * c * Apq + s * s * Aqq;
2896
- const Aqq_new = s * s * App + 2 * s * c * Apq + c * c * Aqq;
2897
- const Apq_new = 0; // 旋转后的非对角元素为0
2898
- // 更新第p行第q列的其他元素
2899
- for (let i = 0; i < 9; i++) {
2900
- if (i !== p && i !== q) {
2901
- const Aip = A[i][p];
2902
- const Aiq = A[i][q];
2903
- A[i][p] = c * Aip - s * Aiq;
2904
- A[p][i] = A[i][p];
2905
- A[i][q] = s * Aip + c * Aiq;
2906
- A[q][i] = A[i][q];
2907
- }
2908
- }
2909
- A[p][p] = App_new;
2910
- A[q][q] = Aqq_new;
2911
- A[p][q] = Apq_new;
2912
- A[q][p] = Apq_new;
2913
- // 更新特征向量矩阵 V
2914
- for (let i = 0; i < 9; i++) {
2915
- const Vip = V[i][p];
2916
- const Viq = V[i][q];
2917
- V[i][p] = c * Vip - s * Viq;
2918
- V[i][q] = s * Vip + c * Viq;
2919
- }
2920
- }
2921
- // 找到最小特征值的位置
2922
- let minIdx = 0;
2923
- let minEigenvalue = A[0][0];
2924
- for (let i = 1; i < 9; i++) {
2925
- if (A[i][i] < minEigenvalue) {
2926
- minEigenvalue = A[i][i];
2927
- minIdx = i;
2928
- }
1912
+ isAvailable() {
1913
+ return this.details.frameCount >= 3;
1914
+ }
1915
+ isTrusted() {
1916
+ return this.details.frameCount >= 15;
1917
+ }
1918
+ getMessage() {
1919
+ if (this.details.frameCount < 3) {
1920
+ return '数据不足,无法进行照片检测';
2929
1921
  }
2930
- // 提取对应的特征向量(V的第 minIdx 列)
2931
- const eigenvector = Array(9);
2932
- for (let i = 0; i < 9; i++) {
2933
- eigenvector[i] = V[i][minIdx];
1922
+ if (!this.isPhoto)
1923
+ return '';
1924
+ const confidence = (this.details.photoConfidence * 100).toFixed(0);
1925
+ const reasons = [];
1926
+ if (this.details.depthVarianceScore > 0.5) {
1927
+ const depthVar = (this.details.depthVariance * 1000).toFixed(1);
1928
+ reasons.push(`深度方差极小(${depthVar})`);
2934
1929
  }
2935
- // 归一化
2936
- const norm = Math.sqrt(eigenvector.reduce((a, b) => a + b * b, 0));
2937
- if (norm > 1e-10) {
2938
- for (let i = 0; i < 9; i++) {
2939
- eigenvector[i] = eigenvector[i] / norm;
2940
- }
1930
+ if (this.details.perspectiveScore > 0.5) {
1931
+ this.details.motionDisplacementVariance.toFixed(3);
1932
+ const consistency = (this.details.motionDirectionConsistency * 100).toFixed(0);
1933
+ reasons.push(`运动一致性过高(${consistency}%)`);
2941
1934
  }
2942
- return eigenvector;
1935
+ const reasonStr = reasons.length > 0 ? `(${reasons.join('、')})` : '';
1936
+ return `检测到照片攻击${reasonStr},置信度 ${confidence}%`;
2943
1937
  }
2944
- /**
2945
- * 反演应化矩阵 - 从归一化坐标回到原始图像坐标
2946
- *
2947
- * 原理:
2948
- * - 在归一化坐标系中估算的H矩阵需要转换回原始坐标
2949
- *
2950
- * 公式:H_orig = T_dst^(-1) * H_norm * T_src
2951
- *
2952
- * 说明:
2953
- * - T_src:源点的归一化变换矩阵
2954
- * - T_dst:目标点的归一化变换矩阵
2955
- * - H_norm:在归一化坐标中计算得到的3×3矩阵
2956
- * - H_orig:最终的单应性矩阵(用于原始图像坐标)
2957
- *
2958
- * 验证:p'_orig = H_orig * p_src_orig
2959
- */
2960
- denormalizeHomography(h, srcNorm, dstNorm) {
2961
- // 将向量h转换为3x3矩阵
2962
- const H_norm = [
2963
- [h[0], h[1], h[2]],
2964
- [h[3], h[4], h[5]],
2965
- [h[6], h[7], h[8]]
2966
- ];
2967
- // H = T_dst^-1 * H_norm * T_src
2968
- const T_src = srcNorm.T;
2969
- const T_dst = dstNorm.T;
2970
- const T_dst_inv = this.invertMatrix3x3(T_dst);
2971
- if (!T_dst_inv)
2972
- return H_norm;
2973
- // 矩阵乘法:(3x3) * (3x3) * (3x3)
2974
- const temp = this.multiplyMatrix3x3(T_dst_inv, H_norm);
2975
- const H = this.multiplyMatrix3x3(temp, T_src);
2976
- return H;
2977
- }
2978
- /**
2979
- * 检查点集的分布范围(防止点集集中在小区域)
2980
- * 返回值:0-1,值越大说明分布越分散
2981
- */
2982
- checkPointSpread(points) {
2983
- if (points.length < 2)
2984
- return 0;
2985
- let minX = Infinity, maxX = -Infinity;
2986
- let minY = Infinity, maxY = -Infinity;
2987
- for (const p of points) {
2988
- minX = Math.min(minX, p[0]);
2989
- maxX = Math.max(maxX, p[0]);
2990
- minY = Math.min(minY, p[1]);
2991
- maxY = Math.max(maxY, p[1]);
2992
- }
2993
- const rangeX = maxX - minX;
2994
- const rangeY = maxY - minY;
2995
- const area = rangeX * rangeY;
2996
- // 点集的相对面积(相对于整个图像,假设1920x1080)
2997
- // 面积越大,点的分布越分散
2998
- const imageArea = 1920 * 1080;
2999
- const relativeArea = Math.min(area / imageArea, 1);
3000
- return relativeArea;
1938
+ }
1939
+ const DEFAULT_OPTIONS = {
1940
+ frameBufferSize: 15, // 15帧 (0.5秒@30fps)
1941
+ depthVarianceThreshold: 0.001, // 深度方差阈值:真实人脸 > 0.005,照片 < 0.001
1942
+ motionVarianceThreshold: 0.01, // 运动方差阈值:真实人脸 > 0.02,照片 < 0.01
1943
+ perspectiveRatioThreshold: 0.85, // 透视比率阈值:真实人脸 > 0.95,照片 < 0.85
1944
+ motionConsistencyThreshold: 0.8, // 运动一致性阈值:真实人脸 < 0.5,照片 > 0.8
1945
+ };
1946
+ /**
1947
+ * 照片攻击检测器
1948
+ *
1949
+ * 两种检测方案:
1950
+ * 1. 3D 深度方差分析(依赖 MediaPipe Z 坐标)
1951
+ * 2. 运动透视一致性检验(纯 2D 几何分析)
1952
+ */
1953
+ class PhotoAttackDetector {
1954
+ config;
1955
+ frameBuffer = [];
1956
+ emitDebug = () => { }; // 默认空实现(不emit)
1957
+ constructor(options) {
1958
+ this.config = { ...DEFAULT_OPTIONS, ...options };
3001
1959
  }
3002
1960
  /**
3003
- * 3x3矩阵求逆
3004
- */
3005
- invertMatrix3x3(m) {
3006
- const [m00, m01, m02] = m[0];
3007
- const [m10, m11, m12] = m[1];
3008
- const [m20, m21, m22] = m[2];
3009
- const det = m00 * (m11 * m22 - m12 * m21) -
3010
- m01 * (m10 * m22 - m12 * m20) +
3011
- m02 * (m10 * m21 - m11 * m20);
3012
- if (Math.abs(det) < 0.0001)
3013
- return null;
3014
- const inv = [
3015
- [
3016
- (m11 * m22 - m12 * m21) / det,
3017
- (m02 * m21 - m01 * m22) / det,
3018
- (m01 * m12 - m02 * m11) / det
3019
- ],
3020
- [
3021
- (m12 * m20 - m10 * m22) / det,
3022
- (m00 * m22 - m02 * m20) / det,
3023
- (m02 * m10 - m00 * m12) / det
3024
- ],
3025
- [
3026
- (m10 * m21 - m11 * m20) / det,
3027
- (m01 * m20 - m00 * m21) / det,
3028
- (m00 * m11 - m01 * m10) / det
3029
- ]
3030
- ];
3031
- return inv;
1961
+ * 设置 emitDebug 方法(依赖注入)
1962
+ * @param emitDebugFn - 来自 FaceDetectionEngine 的 emitDebug 方法
1963
+ */
1964
+ setEmitDebug(emitDebugFn) {
1965
+ this.emitDebug = emitDebugFn;
3032
1966
  }
3033
1967
  /**
3034
- * 3x3矩阵乘法
1968
+ * 添加一帧的人脸检测结果
1969
+ * @param faceResult - 单帧的人脸检测结果
3035
1970
  */
3036
- multiplyMatrix3x3(A, B) {
3037
- const result = Array(3).fill(0).map(() => Array(3).fill(0));
3038
- for (let i = 0; i < 3; i++) {
3039
- for (let j = 0; j < 3; j++) {
3040
- for (let k = 0; k < 3; k++) {
3041
- result[i][j] += A[i][k] * B[k][j];
3042
- }
3043
- }
1971
+ addFrame(faceResult) {
1972
+ if (!faceResult.meshRaw || faceResult.meshRaw.length === 0)
1973
+ return;
1974
+ this.frameBuffer.push(faceResult);
1975
+ // 保持缓冲区大小
1976
+ if (this.frameBuffer.length > this.config.frameBufferSize) {
1977
+ this.frameBuffer.shift();
3044
1978
  }
3045
- return result;
3046
1979
  }
3047
1980
  /**
3048
- * 应用单应性变换 - 齐次坐标变换与反齐次化
3049
- *
3050
- * 操作步骤:
3051
- * 1. 输入:(x, y) → 齐次坐标 [x, y, 1]
3052
- * 2. 矩阵乘法:H * p 得到齐次结果 [x'w, y'w, w]
3053
- * 3. 反齐次化:[x'w/w, y'w/w] = [x', y']
3054
- *
3055
- * 公式:
3056
- * [x'] [h11 h12 h13] [x]
3057
- * [y' ] = [h21 h22 h23] [y]
3058
- * [w'] [h31 h32 h33] [1]
3059
- *
3060
- * 最后:x' = x'w / w, y' = y'w / w
3061
- *
3062
- * 注意:如果 w' ≈ 0,点被投影到无穷远(退化情况)
1981
+ * 执行照片攻击检测
1982
+ * @returns 检测结果
3063
1983
  */
3064
- applyHomography(H, x, y) {
3065
- // 齐次坐标表示
3066
- const p = [x, y, 1];
3067
- // 矩阵乘法:H * p
3068
- const Hp = [
3069
- H[0][0] * p[0] + H[0][1] * p[1] + H[0][2] * p[2],
3070
- H[1][0] * p[0] + H[1][1] * p[1] + H[1][2] * p[2],
3071
- H[2][0] * p[0] + H[2][1] * p[1] + H[2][2] * p[2]
3072
- ];
3073
- // 反齐次化:除以齐次坐标的 Z 分量
3074
- // 如果 w ≈ 0,则点在无穷远,返回齐次结果
3075
- if (Math.abs(Hp[2]) < 0.0001) {
3076
- return [Hp[0], Hp[1]]; // 异常情况:接近无穷
1984
+ detect() {
1985
+ const details = {
1986
+ frameCount: this.frameBuffer.length,
1987
+ depthVariance: 0,
1988
+ keyPointDepthVariance: 0,
1989
+ depthRange: 0,
1990
+ isFlatDepth: false,
1991
+ depthVarianceScore: 0,
1992
+ motionDisplacementVariance: 0,
1993
+ perspectiveRatio: 0,
1994
+ motionDirectionConsistency: 0,
1995
+ affineTransformPatternMatch: 0,
1996
+ perspectiveScore: 0,
1997
+ isPhoto: false,
1998
+ photoConfidence: 0,
1999
+ dominantFeature: 'combined'
2000
+ };
2001
+ // 帧数不足,无法检测
2002
+ if (this.frameBuffer.length < 3) {
2003
+ return new PhotoAttackDetectionResult(false, details);
2004
+ }
2005
+ // ============ 方案一:3D 深度方差分析 ============
2006
+ const depthAnalysis = this.analyzeDepthVariance();
2007
+ details.depthVariance = depthAnalysis.depthVariance;
2008
+ details.keyPointDepthVariance = depthAnalysis.keyPointDepthVariance;
2009
+ details.depthRange = depthAnalysis.depthRange;
2010
+ details.isFlatDepth = depthAnalysis.isFlatDepth;
2011
+ details.depthVarianceScore = depthAnalysis.score;
2012
+ // ============ 方案二:运动透视一致性检验 ============
2013
+ const perspectiveAnalysis = this.analyzePerspectiveConsistency();
2014
+ details.motionDisplacementVariance = perspectiveAnalysis.motionDisplacementVariance;
2015
+ details.perspectiveRatio = perspectiveAnalysis.perspectiveRatio;
2016
+ details.motionDirectionConsistency = perspectiveAnalysis.motionDirectionConsistency;
2017
+ details.affineTransformPatternMatch = perspectiveAnalysis.affineTransformPatternMatch;
2018
+ details.perspectiveScore = perspectiveAnalysis.score;
2019
+ // ============ 综合判定 ============
2020
+ const isPhotoByDepth = depthAnalysis.score > 0.6;
2021
+ const isPhotoByPerspective = perspectiveAnalysis.score > 0.6;
2022
+ // 只要有一个方案高置信度检测到照片,就判定为照片
2023
+ details.isPhoto = isPhotoByDepth || isPhotoByPerspective;
2024
+ // 置信度:两个方案的最大值(最强的证据)
2025
+ details.photoConfidence = Math.max(depthAnalysis.score, perspectiveAnalysis.score);
2026
+ // 确定最强特征
2027
+ if (Math.abs(depthAnalysis.score - perspectiveAnalysis.score) < 0.1) {
2028
+ details.dominantFeature = 'combined';
2029
+ }
2030
+ else if (depthAnalysis.score > perspectiveAnalysis.score) {
2031
+ details.dominantFeature = 'depth';
3077
2032
  }
3078
- return [Hp[0] / Hp[2], Hp[1] / Hp[2]]; // 正常情况:反齐次化
3079
- }
3080
- /**
3081
- * 【新增】计算点集的特征尺度(点间的平均距离)
3082
- *
3083
- * 用于相对误差计算,使算法对不同分辨率和点集大小更鲁棒
3084
- */
3085
- computeCharacteristicScale(points) {
3086
- if (points.length < 2)
3087
- return 1;
3088
- let totalDist = 0;
3089
- let count = 0;
3090
- // 采样计算点间距离,避免 O(n²) 复杂度
3091
- const sampleSize = Math.min(points.length, 30);
3092
- for (let i = 0; i < sampleSize; i++) {
3093
- for (let j = i + 1; j < sampleSize; j++) {
3094
- const dx = points[i][0] - points[j][0];
3095
- const dy = points[i][1] - points[j][1];
3096
- totalDist += Math.sqrt(dx * dx + dy * dy);
3097
- count++;
3098
- }
2033
+ else {
2034
+ details.dominantFeature = 'perspective';
3099
2035
  }
3100
- return count > 0 ? totalDist / count : 1;
2036
+ return new PhotoAttackDetectionResult(details.isPhoto, details);
3101
2037
  }
3102
2038
  /**
3103
- * 【新增】检查单应性矩阵的一致性
2039
+ * 方案一:3D 深度方差分析
3104
2040
  *
3105
2041
  * 原理:
3106
- * - 照片旋转时,每对相邻帧的H矩阵应该相近(因为是持续旋转)
3107
- * - 真实人脸做随机动作时,H矩阵会变化很大
2042
+ * - 真实人脸具有真实的 3D 结构,Z 坐标(深度)跨越较大范围
2043
+ * - 照片是 2D 的,所有点深度基本相同,Z 坐标方差极小
2044
+ * - MediaPipe 可以从 2D 图像推断出深度,但:
2045
+ * - 真实人脸:推断正确,Z 坐标有明显差异(鼻尖 > 脸颊 > 耳朵)
2046
+ * - 照片:推断平坦,Z 坐标基本相同
3108
2047
  */
3109
- checkHomographyConsistency(matrices) {
3110
- if (matrices.length < 2)
3111
- return 1;
3112
- // 计算矩阵间的相似度
3113
- let totalSimilarity = 0;
3114
- let pairCount = 0;
3115
- for (let i = 1; i < matrices.length; i++) {
3116
- const M1 = matrices[i - 1];
3117
- const M2 = matrices[i];
3118
- // Frobenius范数相似度
3119
- let sumDiff = 0;
3120
- for (let r = 0; r < 3; r++) {
3121
- for (let c = 0; c < 3; c++) {
3122
- const diff = M1[r][c] - M2[r][c];
3123
- sumDiff += diff * diff;
2048
+ analyzeDepthVariance() {
2049
+ let allDepths = [];
2050
+ let keyPointDepths = [];
2051
+ // 提取所有帧的深度值
2052
+ for (const result of this.frameBuffer) {
2053
+ // 方案一:使用 rotation 字段中的深度信息
2054
+ if (!result.meshRaw)
2055
+ continue;
2056
+ // 提取所有关键点的 Z 坐标
2057
+ for (const point of result.meshRaw) {
2058
+ if (point.length >= 3 && typeof point[2] === 'number') {
2059
+ allDepths.push(point[2]);
3124
2060
  }
3125
2061
  }
3126
- const frobeniusDist = Math.sqrt(sumDiff);
3127
- // 标准化距离(除以矩阵范数)
3128
- let normM1 = 0, normM2 = 0;
3129
- for (let r = 0; r < 3; r++) {
3130
- for (let c = 0; c < 3; c++) {
3131
- normM1 += M1[r][c] * M1[r][c];
3132
- normM2 += M2[r][c] * M2[r][c];
2062
+ // 提取关键特征点的深度(鼻子、脸颊、耳朵)
2063
+ const annotations = result.annotations;
2064
+ if (annotations) {
2065
+ const nose = annotations.nose || [];
2066
+ const leftCheek = annotations.leftCheek || [];
2067
+ const rightCheek = annotations.rightCheek || [];
2068
+ const leftEar = annotations.leftEar || [];
2069
+ const rightEar = annotations.rightEar || [];
2070
+ const allKeypoints = [
2071
+ ...nose,
2072
+ ...leftCheek,
2073
+ ...rightCheek,
2074
+ ...leftEar,
2075
+ ...rightEar
2076
+ ];
2077
+ for (const point of allKeypoints) {
2078
+ if (point.length >= 3 && typeof point[2] === 'number') {
2079
+ keyPointDepths.push(point[2]);
2080
+ }
3133
2081
  }
3134
2082
  }
3135
- normM1 = Math.sqrt(normM1);
3136
- normM2 = Math.sqrt(normM2);
3137
- const avgNorm = (normM1 + normM2) / 2;
3138
- const normalizedDist = avgNorm > 0.1 ? Math.min(frobeniusDist / avgNorm, 2) : 2;
3139
- // 将距离转换为相似度 (0-1)
3140
- // 距离越小,相似度越高
3141
- const similarity = Math.max(0, 1 - normalizedDist / 2);
3142
- totalSimilarity += similarity;
3143
- pairCount++;
3144
- }
3145
- return pairCount > 0 ? totalSimilarity / pairCount : 1;
2083
+ }
2084
+ if (allDepths.length === 0) {
2085
+ return {
2086
+ depthVariance: 0,
2087
+ keyPointDepthVariance: 0,
2088
+ depthRange: 0,
2089
+ isFlatDepth: true,
2090
+ score: 0
2091
+ };
2092
+ }
2093
+ // 计算深度方差
2094
+ const depthVariance = this.calculateVariance(allDepths);
2095
+ const depthRange = Math.max(...allDepths) - Math.min(...allDepths);
2096
+ // 关键点深度方差
2097
+ const keyPointDepthVariance = keyPointDepths.length > 0
2098
+ ? this.calculateVariance(keyPointDepths)
2099
+ : 0;
2100
+ // 判定是否为平坦深度
2101
+ const isFlatDepth = depthVariance < this.config.depthVarianceThreshold;
2102
+ // 计算置信度分数
2103
+ // 深度方差越小,越可能是照片;深度方差越大,越可能是真实人脸
2104
+ // 使用反向逻辑:照片得分高,真实人脸得分低
2105
+ const variance_score = Math.max(0, (this.config.depthVarianceThreshold - depthVariance) / this.config.depthVarianceThreshold);
2106
+ // 关键点深度方差也应该很小
2107
+ const keypoint_score = Math.max(0, (this.config.depthVarianceThreshold - keyPointDepthVariance) / this.config.depthVarianceThreshold);
2108
+ // 综合分数
2109
+ const score = Math.min(1, (variance_score + keypoint_score) / 2);
2110
+ return {
2111
+ depthVariance,
2112
+ keyPointDepthVariance,
2113
+ depthRange,
2114
+ isFlatDepth,
2115
+ score
2116
+ };
3146
2117
  }
3147
2118
  /**
3148
- * 【关键】检测深度一致性
2119
+ * 方案二:运动透视一致性检验
3149
2120
  *
3150
2121
  * 原理:
3151
- * - 真实人脸:鼻子Z坐标明显大于眼睛和脸颊(凸出)
3152
- * - 照片:所有点Z坐标接近相同(平面)
2122
+ * - 真实人脸运动:由于透视效应,近处点移动幅度大,远处点移动幅度小
2123
+ * - 照片攻击:所有点按照同一仿射变换移动,各点位移比例完全相同
2124
+ * - 通过分析多帧中各关键点的位移向量,可以判断是否存在这种一致性模式
3153
2125
  */
3154
- detectDepthConsistency() {
3155
- const latestFrame = this.faceLandmarksHistory[this.faceLandmarksHistory.length - 1];
3156
- if (!latestFrame || latestFrame.length < 468) {
3157
- return { depthVariation: 0.5 };
3158
- }
3159
- // 采样不同深度区域的点
3160
- const nosePoints = [1, 4, 5, 6]; // 鼻子(应该凸出)
3161
- const eyePoints = [33, 133, 263, 362]; // 眼睛(应该凹陷)
3162
- const cheekPoints = [234, 454, 50, 280]; // 脸颊(中间深度)
3163
- const foreheadPoints = [10, 67, 297]; // 额头
3164
- const getAvgZ = (points) => {
3165
- let sum = 0, count = 0;
3166
- for (const idx of points) {
3167
- const point = latestFrame[idx];
3168
- if (point && point.length >= 3 && typeof point[2] === 'number') {
3169
- sum += point[2];
3170
- count++;
3171
- }
3172
- }
3173
- return count > 0 ? sum / count : 0;
3174
- };
3175
- const noseZ = getAvgZ(nosePoints);
3176
- const eyeZ = getAvgZ(eyePoints);
3177
- const cheekZ = getAvgZ(cheekPoints);
3178
- const foreheadZ = getAvgZ(foreheadPoints);
3179
- // 计算深度差异
3180
- const allZ = [noseZ, eyeZ, cheekZ, foreheadZ].filter(z => z !== 0);
3181
- if (allZ.length < 3) {
3182
- return { depthVariation: 0.5, isFlat: false };
3183
- }
3184
- const zMean = allZ.reduce((a, b) => a + b, 0) / allZ.length;
3185
- const zStdDev = Math.sqrt(allZ.reduce((sum, z) => sum + (z - zMean) ** 2, 0) / allZ.length);
3186
- // 深度变异系数
3187
- const depthVariation = zMean !== 0 ? Math.abs(zStdDev / zMean) : 0;
3188
- // 检查深度关系是否符合真实人脸
3189
- // 真实人脸:鼻子应该比眼睛更接近摄像头(Z更小,因为Z表示深度/距离)
3190
- // 注意:MediaPipe的Z坐标是负值,越接近0表示越近
3191
- const noseCloser = noseZ > eyeZ; // 鼻子更近
3192
- // 记录历史
3193
- this.depthConsistencyScores.push(depthVariation);
3194
- if (this.depthConsistencyScores.length > this.config.frameBufferSize) {
3195
- this.depthConsistencyScores.shift();
2126
+ analyzePerspectiveConsistency() {
2127
+ // 需要至少 3 帧来计算运动
2128
+ if (this.frameBuffer.length < 3) {
2129
+ return {
2130
+ motionDisplacementVariance: 0,
2131
+ perspectiveRatio: 0,
2132
+ motionDirectionConsistency: 0,
2133
+ affineTransformPatternMatch: 0,
2134
+ score: 0
2135
+ };
3196
2136
  }
2137
+ // 选择关键特征点进行分析
2138
+ // 鼻子:近处点
2139
+ // 脸颊:中距离点
2140
+ // 耳朵:远处点
2141
+ const keyPointIndices = this.selectKeyPointIndices();
2142
+ // 计算各关键点在多帧中的位移向量
2143
+ const displacements = this.computeDisplacements(keyPointIndices);
2144
+ // 1. 计算各关键点位移的标准差
2145
+ // 真实人脸:各点位移差异大 -> 高方差
2146
+ // 照片:各点位移一致 -> 低方差
2147
+ const motionDisplacementVariance = this.calculateMotionVariance(displacements);
2148
+ // 2. 计算透视比率(近处点位移 / 远处点位移)
2149
+ // 真实人脸:比率 > 1(近处点移动幅度大)
2150
+ // 照片:比率 ≈ 1(所有点移动幅度相同)
2151
+ const perspectiveRatio = this.calculatePerspectiveRatio(displacements, keyPointIndices);
2152
+ // 3. 计算运动向量的方向一致性
2153
+ // 照片:所有点的运动向量方向高度一致
2154
+ // 真实人脸:各点运动方向差异大
2155
+ const motionDirectionConsistency = this.calculateDirectionConsistency(displacements);
2156
+ // 4. 计算仿射变换模式匹配度
2157
+ // 尝试用单一仿射变换拟合所有点的位移
2158
+ // 照片特征:拟合度高(高度一致的仿射变换)
2159
+ // 真实人脸:拟合度低(各点运动不符合单一变换)
2160
+ const affineTransformPatternMatch = this.calculateAffineTransformMatch(displacements);
2161
+ // 综合计算置信度
2162
+ // 各个指标合并:
2163
+ // - 位移方差越小(接近0)-> 照片特征越明显 -> score高
2164
+ // - 透视比率越接近1 -> 照片特征 -> score高
2165
+ // - 方向一致性越高 -> 照片特征 -> score高
2166
+ // - 仿射变换匹配度越高 -> 照片特征 -> score高
2167
+ const variance_indicator = Math.max(0, 1 - (motionDisplacementVariance / this.config.motionVarianceThreshold));
2168
+ const ratio_indicator = Math.max(0, 1 - Math.abs(perspectiveRatio - 1) / (1 - this.config.perspectiveRatioThreshold));
2169
+ const consistency_indicator = Math.min(1, motionDirectionConsistency / this.config.motionConsistencyThreshold);
2170
+ const affine_indicator = affineTransformPatternMatch;
2171
+ // 综合分数:四个指标的平均值
2172
+ const score = Math.min(1, (variance_indicator + ratio_indicator + consistency_indicator + affine_indicator) / 4);
3197
2173
  return {
3198
- depthVariation,
3199
- isFlat: depthVariation < 0.1, // 深度变异很小 → 平面(照片)
3200
- noseCloser,
3201
- details: { noseZ, eyeZ, cheekZ, foreheadZ }
2174
+ motionDisplacementVariance,
2175
+ perspectiveRatio,
2176
+ motionDirectionConsistency,
2177
+ affineTransformPatternMatch,
2178
+ score
3202
2179
  };
3203
2180
  }
3204
2181
  /**
3205
- * 【关键】检测跨帧深度模式
3206
- *
3207
- * 原理:
3208
- * - 照片旋转时:所有点的深度变化遵循平面投影规律(线性关系)
3209
- * - 真实人脸旋转时:不同部位的深度变化不成线性关系
2182
+ * 选择用于分析的关键点索引
2183
+ * 选择鼻子(近处)、脸颊(中距离)、耳朵(远处)作为代表
3210
2184
  */
3211
- detectCrossFrameDepthPattern() {
3212
- if (this.faceLandmarksHistory.length < 3) {
3213
- return { planarPattern: 0 };
3214
- }
3215
- // 比较多帧的深度变化模式
3216
- const samplePoints = [1, 33, 263, 61, 291]; // 鼻尖、眼角、嘴角
3217
- const depthChanges = [];
3218
- for (let i = 1; i < this.faceLandmarksHistory.length; i++) {
3219
- const prev = this.faceLandmarksHistory[i - 1];
3220
- const curr = this.faceLandmarksHistory[i];
3221
- const changes = [];
3222
- for (const idx of samplePoints) {
3223
- const prevPoint = prev[idx];
3224
- const currPoint = curr[idx];
3225
- if (prevPoint && currPoint && prevPoint.length >= 3 && currPoint.length >= 3 && typeof prevPoint[2] === 'number' && typeof currPoint[2] === 'number') {
3226
- changes.push(currPoint[2] - prevPoint[2]);
3227
- }
3228
- }
3229
- if (changes.length >= 3) {
3230
- depthChanges.push(changes);
3231
- }
3232
- }
3233
- if (depthChanges.length < 2) {
3234
- return { planarPattern: 0 };
3235
- }
3236
- // 检测深度变化的一致性(平面特征:所有点同方向变化)
3237
- let consistentFrames = 0;
3238
- for (const changes of depthChanges) {
3239
- const signs = changes.map(c => Math.sign(c));
3240
- const allSame = signs.every(s => s === signs[0]) || signs.every(s => Math.abs(changes[signs.indexOf(s)]) < 0.001);
3241
- if (allSame)
3242
- consistentFrames++;
3243
- }
3244
- const planarPattern = consistentFrames / depthChanges.length;
3245
- return { planarPattern };
2185
+ selectKeyPointIndices() {
2186
+ // MediaPipe 468个关键点的已知索引
2187
+ // 这些是常用的特征点索引
2188
+ return {
2189
+ near: [1, 4, 6, 195], // 鼻尖及周围(近处)
2190
+ mid: [127, 356], // 脸颊(中距离)
2191
+ far: [162, 389] // 耳朵(远处)
2192
+ };
3246
2193
  }
3247
2194
  /**
3248
- * 【透视变换模式检测】
3249
- *
3250
- * 【改进】使用原始坐标而不是归一化坐标
3251
- *
3252
- * 原理:照片左右偏转时,左右脸宽度比例会平滑变化
3253
- * 这种比例变化遵循严格的透视投影规律
2195
+ * 计算各关键点在多帧中的位移向量
3254
2196
  */
3255
- detectPerspectiveTransformPattern() {
3256
- // 【关键】使用原始坐标历史,而不是归一化坐标
3257
- // 透视变换的宽度比例变化在原始图像坐标中更准确
3258
- if (this.faceLandmarksHistory.length < 3) {
3259
- return { perspectiveScore: 0 };
3260
- }
3261
- // 比较左右脸的宽度比例变化
3262
- // 照片左偏时:右脸变窄,左脸变宽(透视效果)
3263
- // 这种变化应该是平滑且可预测的
3264
- const widthRatios = [];
3265
- for (const frame of this.faceLandmarksHistory) {
3266
- if (frame.length >= 468) {
3267
- // 使用原始坐标计算距离比例
3268
- // 234: 左脸颊边缘,1: 鼻尖,454: 右脸颊边缘
3269
- const leftWidth = this.pointDist(frame[234], frame[1]); // 左脸到鼻子
3270
- const rightWidth = this.pointDist(frame[1], frame[454]); // 鼻子到右脸
3271
- if (leftWidth > 0 && rightWidth > 0) {
3272
- widthRatios.push(leftWidth / rightWidth);
2197
+ computeDisplacements(keyPointIndices) {
2198
+ const result = { near: [], mid: [], far: [] };
2199
+ // 遍历帧对,计算位移
2200
+ for (let i = 1; i < this.frameBuffer.length; i++) {
2201
+ const prevMesh = this.frameBuffer[i - 1].meshRaw;
2202
+ const currMesh = this.frameBuffer[i].meshRaw;
2203
+ if (!prevMesh || !currMesh)
2204
+ continue;
2205
+ // 计算近处点的位移
2206
+ for (const idx of keyPointIndices.near) {
2207
+ if (idx < prevMesh.length && idx < currMesh.length) {
2208
+ const displacement = {
2209
+ x: currMesh[idx][0] - prevMesh[idx][0],
2210
+ y: currMesh[idx][1] - prevMesh[idx][1],
2211
+ magnitude: 0
2212
+ };
2213
+ displacement.magnitude = Math.sqrt(displacement.x ** 2 + displacement.y ** 2);
2214
+ result.near.push(displacement);
3273
2215
  }
3274
2216
  }
3275
- }
3276
- if (widthRatios.length < 3) {
3277
- return { perspectiveScore: 0 };
3278
- }
3279
- // 照片偏转时,宽度比例变化应该是单调的或周期性的
3280
- // 计算变化的平滑度
3281
- let smoothChanges = 0;
3282
- for (let i = 2; i < widthRatios.length; i++) {
3283
- const change1 = widthRatios[i - 1] - widthRatios[i - 2];
3284
- const change2 = widthRatios[i] - widthRatios[i - 1];
3285
- // 如果变化方向一致或变化很小,则认为是平滑的
3286
- if (change1 * change2 >= 0 || Math.abs(change1) < 0.02 || Math.abs(change2) < 0.02) {
3287
- smoothChanges++;
2217
+ // 计算中距离点的位移
2218
+ for (const idx of keyPointIndices.mid) {
2219
+ if (idx < prevMesh.length && idx < currMesh.length) {
2220
+ const displacement = {
2221
+ x: currMesh[idx][0] - prevMesh[idx][0],
2222
+ y: currMesh[idx][1] - prevMesh[idx][1],
2223
+ magnitude: 0
2224
+ };
2225
+ displacement.magnitude = Math.sqrt(displacement.x ** 2 + displacement.y ** 2);
2226
+ result.mid.push(displacement);
2227
+ }
2228
+ }
2229
+ // 计算远处点的位移
2230
+ for (const idx of keyPointIndices.far) {
2231
+ if (idx < prevMesh.length && idx < currMesh.length) {
2232
+ const displacement = {
2233
+ x: currMesh[idx][0] - prevMesh[idx][0],
2234
+ y: currMesh[idx][1] - prevMesh[idx][1],
2235
+ magnitude: 0
2236
+ };
2237
+ displacement.magnitude = Math.sqrt(displacement.x ** 2 + displacement.y ** 2);
2238
+ result.far.push(displacement);
2239
+ }
3288
2240
  }
3289
2241
  }
3290
- const smoothness = smoothChanges / (widthRatios.length - 2);
3291
- // 平滑的透视变化模式更可能是照片
3292
- const perspectiveScore = smoothness;
3293
- return { perspectiveScore };
2242
+ return result;
3294
2243
  }
3295
2244
  /**
3296
- * 综合判定 - 结合正向检测(生物特征)和逆向检测(照片几何)
3297
- *
3298
- * 双重策略:
3299
- * 1. 正向:检测生物微动特征(有 → 活体)
3300
- * 2. 逆向:检测照片几何约束(满足 → 照片)
3301
- *
3302
- * 逆向检测优先级更高,因为照片几何约束是物理定律,无法伪造
2245
+ * 计算运动位移的方差
2246
+ * 低方差 = 照片(所有点一致运动)
2247
+ * 高方差 = 真实人脸(各点运动差异大)
3303
2248
  */
3304
- makeLivenessDecision(eyeActivity, mouthActivity, muscleActivity, photoGeometry) {
3305
- if (!this.collectedMinFrames()) {
3306
- return true; // 数据不足,默认通过
3307
- }
3308
- // ============ 逆向检测(照片几何特征)============
3309
- // 这是最可靠的检测方式,优先级最高
3310
- const isPhotoByGeometry = photoGeometry.isPhoto;
3311
- const photoConfidence = photoGeometry.confidence || 0;
3312
- const frameCount = Math.max(this.eyeAspectRatioHistory.length, this.mouthAspectRatioHistory.length, this.faceLandmarksHistory.length, this.normalizedLandmarksHistory.length);
3313
- // 【改进】根据帧数调整照片检测的敏感度
3314
- // 少帧情况下,照片特征更容易误判,但如果几何约束强,仍应拒绝
3315
- let photoConfidenceThreshold = 0.55;
3316
- if (frameCount < 8) {
3317
- // 少于8帧时,提高拒绝阈值,但只对超强照片特征有效
3318
- // perspectiveScore=1.0 是超强信号,不应该放过
3319
- if ((photoGeometry.details?.perspectiveScore || 0) > 0.95) {
3320
- photoConfidenceThreshold = 0.45; // 降低阈值
3321
- }
3322
- else {
3323
- photoConfidenceThreshold = 0.65; // 提高阈值
3324
- }
3325
- }
3326
- // 如果照片几何检测高置信度判定为照片,直接拒绝
3327
- // 【改进】根据帧数和具体特征调整阈值
3328
- if (isPhotoByGeometry && photoConfidence > photoConfidenceThreshold) {
3329
- console.debug('[Decision] REJECTED by photo geometry detection', {
3330
- photoConfidence: photoConfidence.toFixed(3),
3331
- photoConfidenceThreshold: photoConfidenceThreshold.toFixed(3),
3332
- perspectiveScore: (photoGeometry.details?.perspectiveScore || 0).toFixed(3),
3333
- frameCount,
3334
- details: photoGeometry.details
3335
- });
3336
- return false;
3337
- }
3338
- // ============ 正向检测(生物特征)============
3339
- const hasEyeMovement = eyeActivity.hasMovement;
3340
- const hasMouthMovement = mouthActivity.hasMovement;
3341
- const hasMuscleMovement = muscleActivity.hasMovement;
3342
- const hasBioFeatures = hasEyeMovement || hasMouthMovement || hasMuscleMovement;
3343
- // 获取其他检测结果
3344
- const rigidityScore = muscleActivity.rigidityScore || 0;
3345
- const isPerspectiveAttack = eyeActivity.isPerspectiveAttack || false;
3346
- const faceShapeStability = this.checkFaceShapeStability();
3347
- // ============ 综合判定 ============
3348
- //
3349
- // 【决策矩阵】
3350
- //
3351
- // | 照片几何检测 | 生物特征 | 透视攻击 | 判定 |
3352
- // |-------------|---------|---------|------|
3353
- // | 是照片(>0.75) | - | - | ❌ 拒绝 |
3354
- // | 可疑(0.5-0.75) | 有 | 否 | ✅ 通过(生物特征覆盖) |
3355
- // | 可疑(0.5-0.75) | 无 | - | ❌ 拒绝 |
3356
- // | 不像照片(<0.5) | 有 | 否 | ✅ 通过 |
3357
- // | 不像照片(<0.5) | 无 | 是 | ❌ 拒绝 |
3358
- // | 不像照片(<0.5) | 无 | 否 | ⚠️ 待定(看刚性运动) |
3359
- let isLively;
3360
- if (photoConfidence > 0.5) {
3361
- // 照片可疑度中等以上:需要有明确的生物特征才能通过
3362
- isLively = hasBioFeatures && !isPerspectiveAttack;
3363
- }
3364
- else {
3365
- // 照片可疑度较低:正常的生物特征检测逻辑
3366
- const hasRigidMotion = rigidityScore > 0.7;
3367
- const isPhotoLikely = faceShapeStability > 0.9;
3368
- isLively =
3369
- (hasBioFeatures && !isPerspectiveAttack) ||
3370
- (hasRigidMotion && !isPhotoLikely && !isPerspectiveAttack);
3371
- }
3372
- console.debug('[Decision]', {
3373
- // 逆向检测结果
3374
- photoGeometry: isPhotoByGeometry,
3375
- photoConfidence: photoConfidence.toFixed(3),
3376
- // 正向检测结果
3377
- eye: eyeActivity.score.toFixed(3),
3378
- mouth: mouthActivity.score.toFixed(3),
3379
- muscle: muscleActivity.score.toFixed(3),
3380
- hasBioFeatures,
3381
- // 其他指标
3382
- rigidity: rigidityScore.toFixed(3),
3383
- faceShapeStability: faceShapeStability.toFixed(3),
3384
- isPerspectiveAttack,
3385
- // 最终结果
3386
- isLively
3387
- });
3388
- return isLively;
2249
+ calculateMotionVariance(displacements) {
2250
+ const allMagnitudes = [];
2251
+ allMagnitudes.push(...displacements.near.map(d => d.magnitude));
2252
+ allMagnitudes.push(...displacements.mid.map(d => d.magnitude));
2253
+ allMagnitudes.push(...displacements.far.map(d => d.magnitude));
2254
+ if (allMagnitudes.length === 0)
2255
+ return 0;
2256
+ return this.calculateVariance(allMagnitudes);
3389
2257
  }
3390
2258
  /**
3391
- * 【防护机制】检查脸部形状稳定性
3392
- *
3393
- * 原理:
3394
- * - 真实脸部:眨眼、张嘴等会改变脸部几何形状(EAR/MAR 变化)
3395
- * - 照片:脸部形状完全固定,不会有任何变化
3396
- * - 倾角照片:虽然会产生透视变形,但仍然是平面的,Z坐标无深度
3397
- *
3398
- * 返回值 0-1:值越接近1说明脸部形状越稳定(越可能是照片)
2259
+ * 计算透视比率(近处点位移 / 远处点位移)
2260
+ * 真实人脸:比率 > 1
2261
+ * 照片:比率 ≈ 1
3399
2262
  */
2263
+ calculatePerspectiveRatio(displacements, keyPointIndices) {
2264
+ const nearMagnitudes = displacements.near.map(d => d.magnitude);
2265
+ const farMagnitudes = displacements.far.map(d => d.magnitude);
2266
+ if (nearMagnitudes.length === 0 || farMagnitudes.length === 0)
2267
+ return 1;
2268
+ const nearAvg = nearMagnitudes.reduce((a, b) => a + b) / nearMagnitudes.length;
2269
+ const farAvg = farMagnitudes.reduce((a, b) => a + b) / farMagnitudes.length;
2270
+ if (farAvg === 0)
2271
+ return 1;
2272
+ return nearAvg / farAvg;
2273
+ }
3400
2274
  /**
3401
- * 检查脸部形状稳定性
3402
- *
3403
- * 【合理使用归一化坐标】这里使用归一化坐标是有意义的,因为:
3404
- * - 目的是检测【脸部形状的变化】(眼睛距离、嘴巴高度等)
3405
- * - 与人脸在画面中的位置和尺寸无关
3406
- * - 消除平移和缩放影响,专注于形状的变化
2275
+ * 计算运动向量的方向一致性
2276
+ * 照片:所有点的运动方向高度一致 -> 高分
2277
+ * 真实人脸:各点运动方向差异大 -> 低分
3407
2278
  */
3408
- checkFaceShapeStability() {
3409
- // 使用归一化坐标历史,消除平移和缩放影响
3410
- if (this.normalizedLandmarksHistory.length < 5) {
3411
- return 0.5; // 数据不足
3412
- }
3413
- // 【第一层防护】检测照片平面性(Z坐标深度)
3414
- // 注意:这个方法使用原始坐标的Z值,因为Z是相对深度
3415
- const planarity = this.detectPhotoPlanarity();
3416
- if (planarity > 0.7) {
3417
- // 检测到照片平面特征(Z坐标变异很小)
3418
- console.debug('[FaceShapeStability] Detected planar face (photo), planarity:', planarity.toFixed(3));
3419
- return 0.95; // 非常可能是照片
3420
- }
3421
- // 【第二层防护】检测脸部形状稳定性
3422
- // 使用归一化坐标计算相对距离,检测形状变化
3423
- const faceDistances = [];
3424
- // 计算以下距离:
3425
- // 1. 左眼-右眼(眼距)
3426
- // 2. 上嘴唇-下嘴唇(嘴高)
3427
- // 3. 左脸颊-右脸颊(脸宽)
3428
- for (const frame of this.normalizedLandmarksHistory) {
3429
- if (frame.length >= 468) {
3430
- const eyeDist = this.pointDist(frame[33], frame[263]); // 左右眼外角距离
3431
- const mouthHeight = Math.abs(frame[13][1] - frame[14][1]); // 上下嘴唇距离
3432
- const faceWidth = this.pointDist(frame[234], frame[454]); // 左右脸颊边缘距离
3433
- faceDistances.push([eyeDist, mouthHeight, faceWidth]);
3434
- }
3435
- }
3436
- if (faceDistances.length < 3) {
3437
- return 0.5;
3438
- }
3439
- // 计算每个距离的变异系数(越小说明越固定)
3440
- let totalCV = 0;
3441
- for (let i = 0; i < 3; i++) {
3442
- const values = faceDistances.map(d => d[i]);
3443
- const mean = values.reduce((a, b) => a + b, 0) / values.length;
3444
- const stdDev = this.calculateStdDev(values);
3445
- // 归一化坐标下,调整阈值
3446
- const cv = mean > 0.01 ? stdDev / mean : 0;
3447
- totalCV += cv;
3448
- }
3449
- const avgCV = totalCV / 3;
3450
- // CV越小,形状越稳定
3451
- // 如果avgCV < 0.02,说明形状完全不变(可能是照片)
3452
- // 如果avgCV > 0.1,说明形状在变化(活体)
3453
- const shapeStability = Math.min(Math.max(0.02 - avgCV, 0) / 0.02, 1);
3454
- // 综合得分:结合平面性和形状稳定性
3455
- const combinedStability = Math.max(shapeStability, planarity * 0.5);
3456
- console.debug('[FaceShapeStability]', {
3457
- avgCV: avgCV.toFixed(4),
3458
- planarity: planarity.toFixed(3),
3459
- shapeStability: shapeStability.toFixed(3),
3460
- combinedStability: combinedStability.toFixed(3)
3461
- });
3462
- return Math.min(combinedStability, 1);
3463
- }
3464
- extractKeypoints(face) {
3465
- const keypoints = {};
3466
- if (face.mesh && Array.isArray(face.mesh)) {
3467
- keypoints.landmarks = face.mesh;
3468
- }
3469
- // 类型守卫:确保landmarks存在且长度足够
3470
- const landmarks = keypoints.landmarks;
3471
- if (landmarks && Array.isArray(landmarks) && landmarks.length >= 468) {
3472
- // 左眼关键点 (MediaPipe Face Mesh 标准索引)
3473
- // 按顺序:外眼角、上眼睑上、上眼睑、内眼角、下眼睑、下眼睑下
3474
- keypoints.leftEye = [
3475
- landmarks[362], // 外眼角
3476
- landmarks[385], // 上眼睑上
3477
- landmarks[387], // 上眼睑
3478
- landmarks[263], // 内眼角
3479
- landmarks[373], // 下眼睑
3480
- landmarks[380] // 下眼睑下
3481
- ].filter(p => p !== undefined);
3482
- // 右眼关键点 (MediaPipe Face Mesh 标准索引)
3483
- keypoints.rightEye = [
3484
- landmarks[33], // 外眼角
3485
- landmarks[160], // 上眼睑上
3486
- landmarks[158], // 上眼睑
3487
- landmarks[133], // 内眼角
3488
- landmarks[153], // 下眼睑
3489
- landmarks[144] // 下眼睑下
3490
- ].filter(p => p !== undefined);
3491
- // 嘴巴关键点
3492
- keypoints.mouth = [
3493
- landmarks[61], // 左嘴角
3494
- landmarks[185], // 上嘴唇左
3495
- landmarks[40], // 上嘴唇中左
3496
- landmarks[39], // 上嘴唇中
3497
- landmarks[37], // 上嘴唇中右
3498
- landmarks[0], // 上嘴唇右
3499
- landmarks[267], // 下嘴唇右
3500
- landmarks[269], // 下嘴唇中右
3501
- landmarks[270], // 下嘴唇中
3502
- landmarks[409] // 下嘴唇左
3503
- ].filter(p => p !== undefined);
3504
- }
3505
- return keypoints;
3506
- }
3507
- calculateEyeAspectRatio(eye) {
3508
- if (!eye || eye.length < 6)
2279
+ calculateDirectionConsistency(displacements) {
2280
+ const allDisplacements = [];
2281
+ allDisplacements.push(...displacements.near);
2282
+ allDisplacements.push(...displacements.mid);
2283
+ allDisplacements.push(...displacements.far);
2284
+ if (allDisplacements.length < 2)
3509
2285
  return 0;
3510
- try {
3511
- const v1 = this.pointDist(eye[1], eye[5]);
3512
- const v2 = this.pointDist(eye[2], eye[4]);
3513
- const h = this.pointDist(eye[0], eye[3]);
3514
- return h === 0 ? 0 : (v1 + v2) / (2 * h);
3515
- }
3516
- catch {
2286
+ // 计算平均方向
2287
+ const avgX = allDisplacements.reduce((sum, d) => sum + d.x, 0) / allDisplacements.length;
2288
+ const avgY = allDisplacements.reduce((sum, d) => sum + d.y, 0) / allDisplacements.length;
2289
+ const avgMagnitude = Math.sqrt(avgX ** 2 + avgY ** 2);
2290
+ if (avgMagnitude === 0)
3517
2291
  return 0;
2292
+ // 规范化平均方向
2293
+ const avgDirX = avgX / avgMagnitude;
2294
+ const avgDirY = avgY / avgMagnitude;
2295
+ // 计算每个位移向量与平均方向的夹角余弦值
2296
+ let totalConsistency = 0;
2297
+ for (const d of allDisplacements) {
2298
+ const magnitude = Math.sqrt(d.x ** 2 + d.y ** 2);
2299
+ if (magnitude === 0)
2300
+ continue;
2301
+ const dirX = d.x / magnitude;
2302
+ const dirY = d.y / magnitude;
2303
+ // 夹角的余弦值(-1到1,1表示完全一致)
2304
+ const cosAngle = dirX * avgDirX + dirY * avgDirY;
2305
+ // 转换为 0-1 范围(0表示垂直,1表示平行)
2306
+ totalConsistency += (cosAngle + 1) / 2;
3518
2307
  }
2308
+ return totalConsistency / allDisplacements.length;
3519
2309
  }
3520
- calculateMouthAspectRatio(mouth) {
3521
- if (!mouth || mouth.length < 6)
2310
+ /**
2311
+ * 计算仿射变换模式匹配度
2312
+ * 使用最小二乘法拟合所有点的位移到单一仿射变换
2313
+ * 拟合度高 = 照片特征
2314
+ */
2315
+ calculateAffineTransformMatch(displacements) {
2316
+ const allDisplacements = [];
2317
+ allDisplacements.push(...displacements.near);
2318
+ allDisplacements.push(...displacements.mid);
2319
+ allDisplacements.push(...displacements.far);
2320
+ if (allDisplacements.length < 3)
3522
2321
  return 0;
3523
- try {
3524
- const upperY = mouth.slice(0, 5).reduce((s, p) => s + (p?.[1] || 0), 0) / 5;
3525
- const lowerY = mouth.slice(5).reduce((s, p) => s + (p?.[1] || 0), 0) / 5;
3526
- const w = this.pointDist(mouth[0], mouth[5]);
3527
- return w === 0 ? 0 : Math.abs(upperY - lowerY) / w;
3528
- }
3529
- catch {
2322
+ // 计算平均位移(简化模型:假设仿射变换为简单的平移)
2323
+ const avgX = allDisplacements.reduce((sum, d) => sum + d.x, 0) / allDisplacements.length;
2324
+ const avgY = allDisplacements.reduce((sum, d) => sum + d.y, 0) / allDisplacements.length;
2325
+ // 计算每个位移与平均值的偏差
2326
+ let totalDeviation = 0;
2327
+ for (const d of allDisplacements) {
2328
+ const devX = d.x - avgX;
2329
+ const devY = d.y - avgY;
2330
+ totalDeviation += Math.sqrt(devX ** 2 + devY ** 2);
2331
+ }
2332
+ const avgDeviation = totalDeviation / allDisplacements.length;
2333
+ const avgDisplacement = Math.sqrt(avgX ** 2 + avgY ** 2);
2334
+ if (avgDisplacement === 0)
3530
2335
  return 0;
3531
- }
2336
+ // 偏差与平均位移的比值越小,说明越符合单一仿射变换
2337
+ // 比值 = 0 表示完全一致(照片特征)
2338
+ // 比值 = 1 表示完全不一致
2339
+ const deviation_ratio = avgDeviation / avgDisplacement;
2340
+ // 转换为 0-1 的匹配度分数
2341
+ return Math.max(0, 1 - deviation_ratio);
3532
2342
  }
3533
- pointDist(p1, p2) {
3534
- if (!p1 || !p2 || p1.length < 2 || p2.length < 2)
2343
+ /**
2344
+ * 计算方差(用于数组)
2345
+ */
2346
+ calculateVariance(values) {
2347
+ if (values.length === 0)
3535
2348
  return 0;
3536
- const dx = p1[0] - p2[0];
3537
- const dy = p1[1] - p2[1];
3538
- return Math.sqrt(dx * dx + dy * dy);
2349
+ const mean = values.reduce((a, b) => a + b) / values.length;
2350
+ const squaredDiffs = values.map(v => (v - mean) ** 2);
2351
+ return squaredDiffs.reduce((a, b) => a + b) / values.length;
3539
2352
  }
3540
- calculateStdDev(values) {
3541
- if (values.length < 2)
3542
- return 0;
3543
- const mean = values.reduce((a, b) => a + b, 0) / values.length;
3544
- const variance = values.reduce((a, v) => a + (v - mean) ** 2, 0) / values.length;
3545
- return Math.sqrt(variance);
2353
+ /**
2354
+ * 重置检测器
2355
+ */
2356
+ reset() {
2357
+ this.frameBuffer = [];
3546
2358
  }
3547
2359
  /**
3548
- * 【关键方法】将关键点坐标归一化到人脸局部坐标系
3549
- *
3550
- * 问题:
3551
- * - MediaPipe 返回的 x,y 坐标是相对于【图像左上角】的像素坐标
3552
- * - 如果人脸在画面中移动,同一个关键点的绝对坐标会完全不同
3553
- * - 多帧之间直接比较绝对坐标是错误的!
3554
- *
3555
- * 解决:
3556
- * - 将坐标转换为相对于人脸边界框的归一化坐标
3557
- * - 归一化坐标 = (点坐标 - 人脸左上角) / 人脸尺寸
3558
- * - 这样无论人脸在画面中的位置,归一化坐标都一致
3559
- *
3560
- * @param landmarks 原始关键点数组
3561
- * @param faceBox 人脸边界框 [x, y, width, height]
3562
- * @returns 归一化后的关键点数组
2360
+ * 获取当前缓冲区中的帧数
3563
2361
  */
3564
- normalizeLandmarks(landmarks, faceBox) {
3565
- // faceBox: [x, y, width, height] 或 {x, y, width, height}
3566
- let boxX, boxY, boxW, boxH;
3567
- if (Array.isArray(faceBox)) {
3568
- [boxX, boxY, boxW, boxH] = faceBox;
3569
- }
3570
- else {
3571
- // 兼容对象格式
3572
- boxX = faceBox.x || 0;
3573
- boxY = faceBox.y || 0;
3574
- boxW = faceBox.width || 1;
3575
- boxH = faceBox.height || 1;
3576
- }
3577
- // 防止除零
3578
- if (boxW <= 0)
3579
- boxW = 1;
3580
- if (boxH <= 0)
3581
- boxH = 1;
3582
- const normalized = [];
3583
- for (const pt of landmarks) {
3584
- if (pt && pt.length >= 2) {
3585
- // 归一化 x, y 到 [0, 1] 相对于人脸框
3586
- const nx = (pt[0] - boxX) / boxW;
3587
- const ny = (pt[1] - boxY) / boxH;
3588
- // Z 坐标保持不变(MediaPipe 的 Z 是相对于人脸中心的)
3589
- const nz = pt.length >= 3 ? pt[2] : 0;
3590
- normalized.push([nx, ny, nz]);
3591
- }
3592
- else {
3593
- normalized.push([0, 0, 0]);
3594
- }
3595
- }
3596
- return normalized;
3597
- }
3598
- createEmptyResult(debug = {}) {
3599
- return new MotionDetectionResult(true, {
3600
- frameCount: 0,
3601
- eyeAspectRatioStdDev: 0,
3602
- mouthAspectRatioStdDev: 0,
3603
- eyeFluctuation: 0,
3604
- mouthFluctuation: 0,
3605
- muscleVariation: 0,
3606
- hasEyeMovement: false,
3607
- hasMouthMovement: false,
3608
- hasMuscleMovement: false
3609
- }, debug);
3610
- }
3611
- getStatistics() {
3612
- return {
3613
- eyeHistorySize: this.eyeAspectRatioHistory.length,
3614
- mouthHistorySize: this.mouthAspectRatioHistory.length,
3615
- eyeValues: this.eyeAspectRatioHistory.map(v => v.toFixed(4)),
3616
- mouthValues: this.mouthAspectRatioHistory.map(v => v.toFixed(4))
3617
- };
2362
+ getFrameCount() {
2363
+ return this.frameBuffer.length;
3618
2364
  }
3619
2365
  }
3620
2366
 
@@ -3625,7 +2371,6 @@ class DetectionState {
3625
2371
  period = DetectionPeriod.DETECT;
3626
2372
  startTime = performance.now();
3627
2373
  collectCount = 0;
3628
- suspectedFraudsCount = 0;
3629
2374
  bestQualityScore = 0;
3630
2375
  bestFrameImage = null;
3631
2376
  bestFaceImage = null;
@@ -3633,17 +2378,21 @@ class DetectionState {
3633
2378
  currentAction = null;
3634
2379
  actionVerifyTimeout = null;
3635
2380
  lastFrontalScore = 1;
3636
- motionDetector = null;
2381
+ faceMovingDetector = null;
2382
+ photoAttackDetector = null;
3637
2383
  liveness = false;
3638
2384
  constructor(options) {
3639
2385
  Object.assign(this, options);
3640
2386
  }
3641
2387
  reset() {
3642
2388
  this.clearActionVerifyTimeout();
3643
- const savedMotionDetector = this.motionDetector;
3644
- savedMotionDetector?.reset();
2389
+ const savedFaceMovingDetector = this.faceMovingDetector;
2390
+ const savedPhotoAttackDetector = this.photoAttackDetector;
2391
+ savedFaceMovingDetector?.reset();
2392
+ savedPhotoAttackDetector?.reset();
3645
2393
  Object.assign(this, new DetectionState({}));
3646
- this.motionDetector = savedMotionDetector;
2394
+ this.faceMovingDetector = savedFaceMovingDetector;
2395
+ this.photoAttackDetector = savedPhotoAttackDetector;
3647
2396
  }
3648
2397
  // 默认方法
3649
2398
  needFrontalFace() {
@@ -3684,10 +2433,12 @@ class DetectionState {
3684
2433
  }
3685
2434
  }
3686
2435
  }
3687
- // <-- Add this import at the top if ResolvedEngineOptions is defined in types.ts
3688
- function createDetectionState() {
2436
+ function createDetectionState(engine) {
3689
2437
  const detectionState = new DetectionState({});
3690
- detectionState.motionDetector = new MotionLivenessDetector();
2438
+ detectionState.faceMovingDetector = new FaceMovingDetector();
2439
+ detectionState.faceMovingDetector.setEmitDebug(engine.emitDebug.bind(engine));
2440
+ detectionState.photoAttackDetector = new PhotoAttackDetector();
2441
+ detectionState.photoAttackDetector.setEmitDebug(engine.emitDebug.bind(engine));
3691
2442
  return detectionState;
3692
2443
  }
3693
2444
 
@@ -3734,7 +2485,7 @@ class FaceDetectionEngine extends SimpleEventEmitter {
3734
2485
  constructor(options) {
3735
2486
  super();
3736
2487
  this.options = mergeOptions(options);
3737
- this.detectionState = createDetectionState();
2488
+ this.detectionState = createDetectionState(this);
3738
2489
  }
3739
2490
  /**
3740
2491
  * 提取错误信息的辅助方法 - 处理各种错误类型
@@ -3800,7 +2551,7 @@ class FaceDetectionEngine extends SimpleEventEmitter {
3800
2551
  this.stopDetection(false);
3801
2552
  }
3802
2553
  this.options = mergeOptions(options);
3803
- this.detectionState = createDetectionState();
2554
+ this.detectionState = createDetectionState(this);
3804
2555
  this.emitDebug('config', 'Engine options updated', { wasDetecting }, 'info');
3805
2556
  }
3806
2557
  getEngineState() {
@@ -4458,6 +3209,7 @@ class FaceDetectionEngine extends SimpleEventEmitter {
4458
3209
  */
4459
3210
  async performFaceDetection() {
4460
3211
  // Perform face detection
3212
+ const timestamp = performance.now();
4461
3213
  let result;
4462
3214
  try {
4463
3215
  result = await this.human?.detect(this.videoElement);
@@ -4483,7 +3235,7 @@ class FaceDetectionEngine extends SimpleEventEmitter {
4483
3235
  const faces = result.face || [];
4484
3236
  const gestures = result.gesture || [];
4485
3237
  if (faces.length === 1) {
4486
- this.handleSingleFace(faces[0], gestures);
3238
+ this.handleSingleFace(faces[0], gestures, timestamp);
4487
3239
  }
4488
3240
  else {
4489
3241
  this.handleMultipleFaces(faces.length);
@@ -4503,17 +3255,27 @@ class FaceDetectionEngine extends SimpleEventEmitter {
4503
3255
  /**
4504
3256
  * Handle single face detection
4505
3257
  */
4506
- handleSingleFace(face, gestures) {
3258
+ handleSingleFace(face, gestures, timestamp) {
4507
3259
  const faceBox = face.box || face.boxRaw;
4508
3260
  if (!faceBox) {
4509
3261
  console.warn('[FaceDetector] Face detected but no box/boxRaw property');
4510
3262
  this.emitDebug('detection', 'Face box is missing - face detected but no box/boxRaw property', {}, 'warn');
4511
3263
  return;
4512
3264
  }
4513
- if (!this.detectionState.motionDetector) {
3265
+ if (!this.detectionState.faceMovingDetector) {
4514
3266
  this.emit('detector-error', {
4515
3267
  code: ErrorCode.INTERNAL_ERROR,
4516
- message: 'Motion liveness detector is not initialized'
3268
+ message: 'Face moving detector is not initialized'
3269
+ });
3270
+ // Clear the detecting flag before stopping to avoid deadlock
3271
+ this.isDetectingFrameActive = false;
3272
+ this.stopDetection(false);
3273
+ return;
3274
+ }
3275
+ if (!this.detectionState.photoAttackDetector) {
3276
+ this.emit('detector-error', {
3277
+ code: ErrorCode.INTERNAL_ERROR,
3278
+ message: 'Photo attack detector is not initialized'
4517
3279
  });
4518
3280
  // Clear the detecting flag before stopping to avoid deadlock
4519
3281
  this.isDetectingFrameActive = false;
@@ -4521,6 +3283,11 @@ class FaceDetectionEngine extends SimpleEventEmitter {
4521
3283
  return;
4522
3284
  }
4523
3285
  try {
3286
+ // 动作活体检测阶段处理
3287
+ if (this.detectionState.period === DetectionPeriod.VERIFY) {
3288
+ this.handleVerifyPhase(gestures);
3289
+ return;
3290
+ }
4524
3291
  // 面部区域占比计算
4525
3292
  const faceRatio = (faceBox[2] * faceBox[3]) / (this.actualVideoWidth * this.actualVideoHeight);
4526
3293
  // 面部区域过小则跳过当前帧
@@ -4529,49 +3296,61 @@ class FaceDetectionEngine extends SimpleEventEmitter {
4529
3296
  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');
4530
3297
  return;
4531
3298
  }
4532
- // 静默活体检测
4533
- const motionResult = this.detectionState.motionDetector.analyzeMotion(face, faceBox);
4534
- if (this.detectionState.motionDetector.collectedMinFrames()) {
4535
- // 采集到最小帧数后,否定性判定才可信
4536
- if (!motionResult.isLively) {
4537
- this.emitDebug('motion-detection', 'Motion liveness check failed - possible photo attack', {
4538
- details: motionResult.details,
4539
- debug: motionResult.debug,
4540
- message: motionResult.getMessage(),
4541
- }, 'warn');
3299
+ // 面部区域过大则跳过当前帧
3300
+ if (faceRatio >= this.options.collect_max_face_ratio) {
3301
+ this.emitDetectorInfo({ code: DetectionCode.FACE_TOO_LARGE, faceRatio: faceRatio });
3302
+ 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');
3303
+ return;
3304
+ }
3305
+ this.detectionState.faceMovingDetector.addFrame(face, timestamp);
3306
+ if (!this.detectionState.faceMovingDetector.isAvailable()) {
3307
+ // 面部移动数据尚不可用,等待更多帧
3308
+ this.emitDebug('motion-detection', 'Face moving data not yet available - collecting more frames', {
3309
+ collectedFrames: this.detectionState.faceMovingDetector.getFrameCount()
3310
+ }, 'info');
3311
+ return;
3312
+ }
3313
+ const faceMovingResult = this.detectionState.faceMovingDetector.detect();
3314
+ if (!faceMovingResult.isMoving) {
3315
+ // 面部移动检测失败,可能为照片攻击
3316
+ this.emitDebug('motion-detection', 'Face moving detection failed - possible photo attack', {
3317
+ details: faceMovingResult.details,
3318
+ debug: faceMovingResult.debug
3319
+ }, 'warn');
3320
+ this.emitDetectorInfo({
3321
+ code: DetectionCode.PLEASE_MOVING_FACE,
3322
+ message: '请动一动您的脸部',
3323
+ });
3324
+ this.partialResetDetectionState();
3325
+ return;
3326
+ }
3327
+ this.detectionState.photoAttackDetector.addFrame(face);
3328
+ const photoAttackResult = this.detectionState.photoAttackDetector.detect();
3329
+ if (photoAttackResult.isAvailable()) {
3330
+ // 照片攻击检测可用(仅当判定为照片攻击时)
3331
+ if (photoAttackResult.isPhoto) {
4542
3332
  this.emitDetectorInfo({
4543
- code: DetectionCode.FACE_NOT_LIVE,
4544
- message: motionResult.getMessage(),
3333
+ code: DetectionCode.PHOTO_ATTACK_DETECTED,
3334
+ message: photoAttackResult.getMessage(),
4545
3335
  });
3336
+ this.emitDebug('motion-detection', 'Photo attack detected', {
3337
+ details: photoAttackResult.details,
3338
+ debug: photoAttackResult.debug,
3339
+ }, 'warn');
4546
3340
  this.partialResetDetectionState();
4547
3341
  return;
4548
3342
  }
4549
- // 采集到足够帧数后,肯定性判定才可信
4550
- if (this.detectionState.motionDetector.collectedFullFrames()) {
4551
- this.emitDebug('motion-detection', 'Motion liveness check passed', {
4552
- debug: motionResult.debug,
4553
- details: motionResult.details,
4554
- }, 'warn');
4555
- this.detectionState.liveness = true;
4556
- }
4557
3343
  else {
4558
- this.emitDebug('motion-detection', 'Motion liveness check ongoing - collecting more frames', {
4559
- debug: motionResult.debug,
4560
- details: motionResult.details,
4561
- }, 'warn');
3344
+ if (photoAttackResult.isTrusted()) {
3345
+ // 仅当采集到足够帧,且判定为非照片攻击时,才采信
3346
+ this.detectionState.liveness = true;
3347
+ this.emitDebug('motion-detection', 'Photo attack detection passed - face is live', {
3348
+ debug: photoAttackResult.debug,
3349
+ details: photoAttackResult.details,
3350
+ }, 'warn');
3351
+ }
4562
3352
  }
4563
3353
  }
4564
- // 动作活体检测阶段处理
4565
- if (this.detectionState.period === DetectionPeriod.VERIFY) {
4566
- this.handleVerifyPhase(gestures);
4567
- return;
4568
- }
4569
- // 面部区域过大则跳过当前帧
4570
- if (faceRatio >= this.options.collect_max_face_ratio) {
4571
- this.emitDetectorInfo({ code: DetectionCode.FACE_TOO_LARGE, faceRatio: faceRatio });
4572
- 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');
4573
- return;
4574
- }
4575
3354
  // 捕获并准备帧数据
4576
3355
  const frameData = this.captureAndPrepareFrames();
4577
3356
  if (!frameData) {
@@ -5466,5 +4245,5 @@ function checkEnvironmentSupport() {
5466
4245
  * Framework-agnostic face liveness detection engine
5467
4246
  */
5468
4247
 
5469
- export { DetectionCode, DetectionPeriod, EngineState, ErrorCode, FaceDetectionEngine, LivenessAction, LivenessActionStatus, SimpleEventEmitter, UniAppFaceDetectionEngine, checkEnvironmentSupport, createSDK, FaceDetectionEngine as default, detectBrowserEngine, getCvSync, getOpenCVVersion, preloadOpenCV };
4248
+ export { DetectionCode, DetectionPeriod, EngineState, ErrorCode, FaceDetectionEngine, LivenessAction, LivenessActionStatus, PhotoAttackDetectionResult, PhotoAttackDetector, SimpleEventEmitter, UniAppFaceDetectionEngine, checkEnvironmentSupport, createSDK, FaceDetectionEngine as default, detectBrowserEngine, getCvSync, getOpenCVVersion, preloadOpenCV };
5470
4249
  //# sourceMappingURL=index.esm.js.map