@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 +754 -1975
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +755 -1974
- package/dist/index.js.map +1 -1
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/enums.d.ts +3 -1
- package/dist/types/enums.d.ts.map +1 -1
- package/dist/types/face-detection-engine.d.ts +1 -1
- package/dist/types/face-detection-engine.d.ts.map +1 -1
- package/dist/types/face-detection-state.d.ts +6 -4
- package/dist/types/face-detection-state.d.ts.map +1 -1
- package/dist/types/face-moving-detector.d.ts +160 -0
- package/dist/types/face-moving-detector.d.ts.map +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/motion-liveness-detector.d.ts +40 -180
- package/dist/types/motion-liveness-detector.d.ts.map +1 -1
- package/dist/types/photo-attack-detector.d.ts +164 -0
- package/dist/types/photo-attack-detector.d.ts.map +1 -0
- package/dist/types/photo-attack-detector.example.d.ts +34 -0
- package/dist/types/photo-attack-detector.example.d.ts.map +1 -0
- package/dist/types/types.d.ts +2 -0
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/uniapp-sdk.d.ts +1 -1
- package/dist/types/uniapp-sdk.d.ts.map +1 -1
- package/package.json +1 -1
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$
|
|
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$
|
|
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
|
-
*
|
|
1553
|
-
* - 对真实人脸:推断出正确的 3D 结构
|
|
1554
|
-
* - 对照片人脸:也可能推断出"假"的 3D 结构(因为照片上的人脸看起来也像 3D 的)
|
|
1550
|
+
* 核心原理:
|
|
1551
|
+
* 通过分析连续帧间人脸关键点的几何变化,检测由头部姿态调整、表情变化或外部移动引起的形变信号。
|
|
1555
1552
|
*
|
|
1556
|
-
*
|
|
1557
|
-
* 1.
|
|
1558
|
-
* 2.
|
|
1559
|
-
* 3.
|
|
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
|
|
1565
|
-
|
|
1566
|
-
isLively;
|
|
1562
|
+
class FaceMovingDetectionResult {
|
|
1563
|
+
isMoving;
|
|
1567
1564
|
details;
|
|
1568
1565
|
debug;
|
|
1569
|
-
constructor(
|
|
1570
|
-
this.
|
|
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 <
|
|
1576
|
-
return '
|
|
1572
|
+
if (this.details.frameCount < 2) {
|
|
1573
|
+
return '数据不足,无法进行运动检测';
|
|
1577
1574
|
}
|
|
1578
|
-
if (this.
|
|
1575
|
+
if (!this.isMoving) {
|
|
1579
1576
|
return '';
|
|
1580
|
-
|
|
1581
|
-
const
|
|
1582
|
-
const
|
|
1583
|
-
const
|
|
1584
|
-
|
|
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:
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
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
|
|
1595
|
+
class FaceMovingDetector {
|
|
1616
1596
|
config;
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
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
|
-
|
|
1815
|
-
|
|
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
|
-
|
|
1858
|
-
if (
|
|
1859
|
-
return
|
|
1860
|
-
}
|
|
1861
|
-
//
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
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
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
const
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
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
|
-
|
|
1949
|
-
|
|
1950
|
-
//
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
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
|
-
*
|
|
1963
|
-
*
|
|
1964
|
-
*
|
|
1690
|
+
* 算法步骤:
|
|
1691
|
+
* 1. 对两帧的关键点进行中心化(以鼻尖为原点)
|
|
1692
|
+
* 2. 计算对应关键点之间的欧氏距离
|
|
1693
|
+
* 3. 取所有距离的平均值作为运动强度
|
|
1965
1694
|
*
|
|
1966
|
-
*
|
|
1695
|
+
* @param prevFrame - 前一帧的人脸检测结果
|
|
1696
|
+
* @param currFrame - 当前帧的人脸检测结果
|
|
1697
|
+
* @returns 运动强度 (0-1)
|
|
1967
1698
|
*/
|
|
1968
|
-
|
|
1969
|
-
|
|
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
|
|
1974
|
-
|
|
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
|
-
//
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
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
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
//
|
|
2004
|
-
|
|
2005
|
-
|
|
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
|
-
*
|
|
1744
|
+
* @param mesh - 原始关键点坐标数组
|
|
1745
|
+
* @returns 中心化后的关键点坐标数组
|
|
2030
1746
|
*/
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
];
|
|
2044
|
-
const
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
2107
|
-
* - 照片透视变形:值会【单向变化】 如 0.4 → 0.3 → 0.25 → 0.2
|
|
2108
|
-
*
|
|
2109
|
-
* 返回值:true = 检测到往复波动(活体特征)
|
|
1775
|
+
* @param frame - 人脸检测结果
|
|
1776
|
+
* @returns 中心化坐标的偏移量
|
|
2110
1777
|
*/
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
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
|
-
|
|
2129
|
-
|
|
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
|
|
2147
|
-
let
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
const
|
|
2152
|
-
|
|
2153
|
-
|
|
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
|
|
1800
|
+
return totalDistance / validCount;
|
|
2180
1801
|
}
|
|
2181
1802
|
/**
|
|
2182
|
-
*
|
|
2183
|
-
*
|
|
2184
|
-
* 原理:
|
|
2185
|
-
* - 真实眨眼:左右眼几乎同时闭合和睁开,EAR变化高度同步
|
|
2186
|
-
* - 照片透视畸变:根据偏转方向,一只眼睛可能比另一只变化更大
|
|
2187
|
-
*
|
|
2188
|
-
* 返回值 0-1:越接近1说明左右眼越对称(越像真实眨眼)
|
|
1803
|
+
* 计算基于真实时间的运动持续时长(秒)
|
|
2189
1804
|
*/
|
|
2190
|
-
|
|
2191
|
-
if (this.
|
|
2192
|
-
return
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
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
|
-
|
|
2236
|
-
if (this.
|
|
2237
|
-
return
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
const
|
|
2241
|
-
const
|
|
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
|
-
|
|
2287
|
-
if (
|
|
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
|
-
|
|
2338
|
-
if (values.length
|
|
2339
|
-
return
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
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
|
-
*
|
|
2359
|
-
* - 对照片也可能推断出"假"的3D结构
|
|
2360
|
-
* - 因此【2D几何约束】比【Z坐标分析】更可靠
|
|
1846
|
+
* @description 通过检查帧缓冲区长度来判断实例是否处于可用状态
|
|
1847
|
+
* 当帧缓冲区长度大于等于2时,认为实例可用
|
|
2361
1848
|
*
|
|
2362
|
-
*
|
|
2363
|
-
* 1. 单应性变换约束 - 平面必须满足
|
|
2364
|
-
* 2. 特征点相对位置变化 - 照片偏转时遵循透视规律
|
|
2365
|
-
*
|
|
2366
|
-
* 参考性检测(基于推断的Z坐标,可能被欺骗):
|
|
2367
|
-
* 1. 深度一致性 - 辅助参考
|
|
2368
|
-
* 2. 跨帧深度模式 - 辅助参考
|
|
1849
|
+
* @returns {boolean} 如果实例可用返回true,否则返回false
|
|
2369
1850
|
*/
|
|
2370
|
-
|
|
2371
|
-
|
|
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
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
2679
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
2764
|
-
|
|
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
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
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
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
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
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
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
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
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
|
-
|
|
1935
|
+
const reasonStr = reasons.length > 0 ? `(${reasons.join('、')})` : '';
|
|
1936
|
+
return `检测到照片攻击${reasonStr},置信度 ${confidence}%`;
|
|
2943
1937
|
}
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
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
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
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
|
-
*
|
|
1968
|
+
* 添加一帧的人脸检测结果
|
|
1969
|
+
* @param faceResult - 单帧的人脸检测结果
|
|
3035
1970
|
*/
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
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
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
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
|
-
|
|
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
|
|
2036
|
+
return new PhotoAttackDetectionResult(details.isPhoto, details);
|
|
3101
2037
|
}
|
|
3102
2038
|
/**
|
|
3103
|
-
*
|
|
2039
|
+
* 方案一:3D 深度方差分析
|
|
3104
2040
|
*
|
|
3105
2041
|
* 原理:
|
|
3106
|
-
* -
|
|
3107
|
-
* -
|
|
2042
|
+
* - 真实人脸具有真实的 3D 结构,Z 坐标(深度)跨越较大范围
|
|
2043
|
+
* - 照片是 2D 的,所有点深度基本相同,Z 坐标方差极小
|
|
2044
|
+
* - MediaPipe 可以从 2D 图像推断出深度,但:
|
|
2045
|
+
* - 真实人脸:推断正确,Z 坐标有明显差异(鼻尖 > 脸颊 > 耳朵)
|
|
2046
|
+
* - 照片:推断平坦,Z 坐标基本相同
|
|
3108
2047
|
*/
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
//
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
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
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
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
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
}
|
|
3145
|
-
|
|
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
|
-
* -
|
|
3152
|
-
* -
|
|
2122
|
+
* - 真实人脸运动:由于透视效应,近处点移动幅度大,远处点移动幅度小
|
|
2123
|
+
* - 照片攻击:所有点按照同一仿射变换移动,各点位移比例完全相同
|
|
2124
|
+
* - 通过分析多帧中各关键点的位移向量,可以判断是否存在这种一致性模式
|
|
3153
2125
|
*/
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
if (
|
|
3157
|
-
return {
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
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
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
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
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
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
|
-
|
|
3256
|
-
|
|
3257
|
-
//
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
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
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
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
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
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
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
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
|
-
|
|
3521
|
-
|
|
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
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
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
|
-
|
|
3534
|
-
|
|
2343
|
+
/**
|
|
2344
|
+
* 计算方差(用于数组)
|
|
2345
|
+
*/
|
|
2346
|
+
calculateVariance(values) {
|
|
2347
|
+
if (values.length === 0)
|
|
3535
2348
|
return 0;
|
|
3536
|
-
const
|
|
3537
|
-
const
|
|
3538
|
-
return
|
|
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
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
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
|
-
|
|
3565
|
-
|
|
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
|
-
|
|
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
|
|
3644
|
-
|
|
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.
|
|
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
|
-
|
|
3688
|
-
function createDetectionState() {
|
|
2436
|
+
function createDetectionState(engine) {
|
|
3689
2437
|
const detectionState = new DetectionState({});
|
|
3690
|
-
detectionState.
|
|
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.
|
|
3265
|
+
if (!this.detectionState.faceMovingDetector) {
|
|
4514
3266
|
this.emit('detector-error', {
|
|
4515
3267
|
code: ErrorCode.INTERNAL_ERROR,
|
|
4516
|
-
message: '
|
|
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
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
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.
|
|
4544
|
-
message:
|
|
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
|
-
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
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
|