@sssxyd/face-liveness-detector 0.4.2-alpha.3 → 0.4.3-alpha.10
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 +534 -216
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +534 -216
- package/dist/index.js.map +1 -1
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/enums.d.ts +3 -3
- package/dist/types/enums.d.ts.map +1 -1
- package/dist/types/face-detection-engine.d.ts.map +1 -1
- package/dist/types/face-detection-state.d.ts +4 -0
- package/dist/types/face-detection-state.d.ts.map +1 -1
- package/dist/types/face-moving-detector.d.ts +3 -11
- package/dist/types/face-moving-detector.d.ts.map +1 -1
- package/dist/types/library-loader.d.ts.map +1 -1
- package/dist/types/photo-attack-detector.d.ts +8 -48
- package/dist/types/photo-attack-detector.d.ts.map +1 -1
- package/dist/types/screen-attach-detector.d.ts +103 -0
- package/dist/types/screen-attach-detector.d.ts.map +1 -0
- package/dist/types/types.d.ts +8 -2
- package/dist/types/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.esm.js
CHANGED
|
@@ -41,11 +41,11 @@ var DetectionCode;
|
|
|
41
41
|
DetectionCode["FACE_TOO_SMALL"] = "FACE_TOO_SMALL";
|
|
42
42
|
DetectionCode["FACE_TOO_LARGE"] = "FACE_TOO_LARGE";
|
|
43
43
|
DetectionCode["FACE_NOT_FRONTAL"] = "FACE_NOT_FRONTAL";
|
|
44
|
-
DetectionCode["FACE_NOT_LIVE"] = "FACE_NOT_LIVE";
|
|
45
44
|
DetectionCode["FACE_LOW_QUALITY"] = "FACE_LOW_QUALITY";
|
|
46
45
|
DetectionCode["FACE_CHECK_PASS"] = "FACE_CHECK_PASS";
|
|
47
|
-
DetectionCode["
|
|
46
|
+
DetectionCode["FACE_NOT_MOVING"] = "FACE_NOT_MOVING";
|
|
48
47
|
DetectionCode["PHOTO_ATTACK_DETECTED"] = "PHOTO_ATTACK_DETECTED";
|
|
48
|
+
DetectionCode["SCREEN_ATTACK_DETECTED"] = "SCREEN_ATTACK_DETECTED";
|
|
49
49
|
})(DetectionCode || (DetectionCode = {}));
|
|
50
50
|
/**
|
|
51
51
|
* Error code enumeration
|
|
@@ -84,6 +84,9 @@ const DEFAULT_OPTIONS$2 = {
|
|
|
84
84
|
debug_log_level: 'info',
|
|
85
85
|
debug_log_stages: undefined, // undefined 表示所有阶段
|
|
86
86
|
debug_log_throttle: 100, // 默认 100ms 节流,防止过于频繁
|
|
87
|
+
enable_face_moving_detection: true,
|
|
88
|
+
enable_photo_attack_detection: true,
|
|
89
|
+
enable_screen_attack_detection: true,
|
|
87
90
|
// Detection Settings
|
|
88
91
|
detect_video_ideal_width: 1280,
|
|
89
92
|
detect_video_ideal_height: 720,
|
|
@@ -1110,12 +1113,13 @@ async function _initializeOpenCV(timeout) {
|
|
|
1110
1113
|
const canSetCallback = cvModule && Object.isExtensible(cvModule);
|
|
1111
1114
|
if (canSetCallback) {
|
|
1112
1115
|
try {
|
|
1113
|
-
const
|
|
1116
|
+
const originalCallback = cvModule.onRuntimeInitialized;
|
|
1117
|
+
const newCallback = () => {
|
|
1114
1118
|
console.log('[FaceDetectionEngine] onRuntimeInitialized callback triggered');
|
|
1115
1119
|
// 调用原始回调(如果存在)
|
|
1116
|
-
if (
|
|
1120
|
+
if (originalCallback && typeof originalCallback === 'function') {
|
|
1117
1121
|
try {
|
|
1118
|
-
|
|
1122
|
+
originalCallback();
|
|
1119
1123
|
}
|
|
1120
1124
|
catch (e) {
|
|
1121
1125
|
console.warn('[FaceDetectionEngine] Original onRuntimeInitialized callback failed:', e);
|
|
@@ -1123,6 +1127,7 @@ async function _initializeOpenCV(timeout) {
|
|
|
1123
1127
|
}
|
|
1124
1128
|
resolveOnce('callback');
|
|
1125
1129
|
};
|
|
1130
|
+
cvModule.onRuntimeInitialized = newCallback;
|
|
1126
1131
|
console.log('[FaceDetectionEngine] onRuntimeInitialized callback set successfully');
|
|
1127
1132
|
}
|
|
1128
1133
|
catch (e) {
|
|
@@ -1562,11 +1567,13 @@ function matToBase64Jpeg(cv, mat, quality = 0.9) {
|
|
|
1562
1567
|
class FaceMovingDetectionResult {
|
|
1563
1568
|
isMoving;
|
|
1564
1569
|
details;
|
|
1565
|
-
|
|
1566
|
-
|
|
1570
|
+
available = false;
|
|
1571
|
+
trusted = false;
|
|
1572
|
+
constructor(isMoving, details, available = false) {
|
|
1567
1573
|
this.isMoving = isMoving;
|
|
1568
1574
|
this.details = details;
|
|
1569
|
-
this.
|
|
1575
|
+
this.available = available;
|
|
1576
|
+
this.trusted = available;
|
|
1570
1577
|
}
|
|
1571
1578
|
getMessage() {
|
|
1572
1579
|
if (this.details.frameCount < 2) {
|
|
@@ -1682,7 +1689,7 @@ class FaceMovingDetector {
|
|
|
1682
1689
|
details.lastCentroidShift = this.calculateCentroidShift(lastFrame.result);
|
|
1683
1690
|
// 计算中心化坐标的变化速率(基于实际时间)
|
|
1684
1691
|
details.centroidShiftRate = this.calculateCentroidShiftRate();
|
|
1685
|
-
return new FaceMovingDetectionResult(details.isMoving, details);
|
|
1692
|
+
return new FaceMovingDetectionResult(details.isMoving, details, true);
|
|
1686
1693
|
}
|
|
1687
1694
|
/**
|
|
1688
1695
|
* 计算两帧之间的运动强度
|
|
@@ -1840,17 +1847,6 @@ class FaceMovingDetector {
|
|
|
1840
1847
|
const variance = squaredDiffs.reduce((a, b) => a + b) / values.length;
|
|
1841
1848
|
return Math.sqrt(variance);
|
|
1842
1849
|
}
|
|
1843
|
-
/**
|
|
1844
|
-
* 检查当前实例是否可用
|
|
1845
|
-
*
|
|
1846
|
-
* @description 通过检查帧缓冲区长度来判断实例是否处于可用状态
|
|
1847
|
-
* 当帧缓冲区长度大于等于2时,认为实例可用
|
|
1848
|
-
*
|
|
1849
|
-
* @returns {boolean} 如果实例可用返回true,否则返回false
|
|
1850
|
-
*/
|
|
1851
|
-
isAvailable() {
|
|
1852
|
-
return this.frameBuffer.length >= 2;
|
|
1853
|
-
}
|
|
1854
1850
|
/**
|
|
1855
1851
|
* 重置检测器
|
|
1856
1852
|
*/
|
|
@@ -1880,22 +1876,12 @@ class FaceMovingDetector {
|
|
|
1880
1876
|
}
|
|
1881
1877
|
|
|
1882
1878
|
/**
|
|
1883
|
-
* 照片攻击检测器
|
|
1884
|
-
*
|
|
1885
|
-
* 方案一:MediaPipe 3D 关键点深度方差分析
|
|
1886
|
-
* - 完全不依赖背景
|
|
1887
|
-
* - 对白墙、黑墙、任意纯色背景均有效
|
|
1888
|
-
* - 只需人脸本身具有 3D 结构(真实人脸有,照片没有)
|
|
1879
|
+
* 照片攻击检测器
|
|
1889
1880
|
*
|
|
1890
|
-
*
|
|
1881
|
+
* 关键点运动透视一致性检验
|
|
1891
1882
|
* - 比较鼻尖、脸颊、耳朵等在多帧中的 2D 位移比例
|
|
1892
1883
|
* - 真实人脸因透视效应,近处点移动幅度 > 远处点
|
|
1893
1884
|
* - 照片上所有点按同一仿射变换移动 → 运动向量高度一致
|
|
1894
|
-
*
|
|
1895
|
-
* ⚠️ 关键理解 ⚠️
|
|
1896
|
-
* MediaPipe 返回的 Z 坐标(深度)是从 2D 图像【推断】出来的,不是真实的物理深度!
|
|
1897
|
-
* - 对真实人脸:推断出正确的 3D 结构 → Z 坐标有方差
|
|
1898
|
-
* - 对照片人脸:推断深度值可能平坦 → Z 坐标方差极小
|
|
1899
1885
|
*/
|
|
1900
1886
|
/**
|
|
1901
1887
|
* 照片攻击检测结果
|
|
@@ -1903,17 +1889,13 @@ class FaceMovingDetector {
|
|
|
1903
1889
|
class PhotoAttackDetectionResult {
|
|
1904
1890
|
isPhoto;
|
|
1905
1891
|
details;
|
|
1906
|
-
|
|
1907
|
-
|
|
1892
|
+
available = false;
|
|
1893
|
+
trusted = false;
|
|
1894
|
+
constructor(isPhoto, details, available = false, trusted = false) {
|
|
1908
1895
|
this.isPhoto = isPhoto;
|
|
1909
1896
|
this.details = details;
|
|
1910
|
-
this.
|
|
1911
|
-
|
|
1912
|
-
isAvailable() {
|
|
1913
|
-
return this.details.frameCount >= 3;
|
|
1914
|
-
}
|
|
1915
|
-
isTrusted() {
|
|
1916
|
-
return this.details.frameCount >= 15;
|
|
1897
|
+
this.available = available;
|
|
1898
|
+
this.trusted = trusted;
|
|
1917
1899
|
}
|
|
1918
1900
|
getMessage() {
|
|
1919
1901
|
if (this.details.frameCount < 3) {
|
|
@@ -1923,14 +1905,10 @@ class PhotoAttackDetectionResult {
|
|
|
1923
1905
|
return '';
|
|
1924
1906
|
const confidence = (this.details.photoConfidence * 100).toFixed(0);
|
|
1925
1907
|
const reasons = [];
|
|
1926
|
-
if (this.details.depthVarianceScore > 0.5) {
|
|
1927
|
-
const depthVar = (this.details.depthVariance * 1000).toFixed(1);
|
|
1928
|
-
reasons.push(`深度方差极小(${depthVar})`);
|
|
1929
|
-
}
|
|
1930
1908
|
if (this.details.perspectiveScore > 0.5) {
|
|
1931
|
-
this.details.motionDisplacementVariance.toFixed(3);
|
|
1909
|
+
const motionVar = this.details.motionDisplacementVariance.toFixed(3);
|
|
1932
1910
|
const consistency = (this.details.motionDirectionConsistency * 100).toFixed(0);
|
|
1933
|
-
reasons.push(`运动一致性过高(${consistency}%)`);
|
|
1911
|
+
reasons.push(`运动一致性过高(${consistency}%),位移方差(${motionVar})`);
|
|
1934
1912
|
}
|
|
1935
1913
|
const reasonStr = reasons.length > 0 ? `(${reasons.join('、')})` : '';
|
|
1936
1914
|
return `检测到照片攻击${reasonStr},置信度 ${confidence}%`;
|
|
@@ -1938,17 +1916,15 @@ class PhotoAttackDetectionResult {
|
|
|
1938
1916
|
}
|
|
1939
1917
|
const DEFAULT_OPTIONS = {
|
|
1940
1918
|
frameBufferSize: 15, // 15帧 (0.5秒@30fps)
|
|
1941
|
-
|
|
1942
|
-
motionVarianceThreshold: 0.
|
|
1943
|
-
perspectiveRatioThreshold:
|
|
1919
|
+
requiredFrameCount: 15, // 可信赖所需的最小帧数
|
|
1920
|
+
motionVarianceThreshold: 0.005, // 运动方差阈值:真实人脸 > 0.02,照片 < 0.01
|
|
1921
|
+
perspectiveRatioThreshold: 1, // 透视比率阈值:真实人脸 > 1,照片 < 1
|
|
1944
1922
|
motionConsistencyThreshold: 0.8, // 运动一致性阈值:真实人脸 < 0.5,照片 > 0.8
|
|
1945
1923
|
};
|
|
1946
1924
|
/**
|
|
1947
1925
|
* 照片攻击检测器
|
|
1948
1926
|
*
|
|
1949
|
-
*
|
|
1950
|
-
* 1. 3D 深度方差分析(依赖 MediaPipe Z 坐标)
|
|
1951
|
-
* 2. 运动透视一致性检验(纯 2D 几何分析)
|
|
1927
|
+
* 运动透视一致性检验(纯 2D 几何分析)
|
|
1952
1928
|
*/
|
|
1953
1929
|
class PhotoAttackDetector {
|
|
1954
1930
|
config;
|
|
@@ -1984,11 +1960,6 @@ class PhotoAttackDetector {
|
|
|
1984
1960
|
detect() {
|
|
1985
1961
|
const details = {
|
|
1986
1962
|
frameCount: this.frameBuffer.length,
|
|
1987
|
-
depthVariance: 0,
|
|
1988
|
-
keyPointDepthVariance: 0,
|
|
1989
|
-
depthRange: 0,
|
|
1990
|
-
isFlatDepth: false,
|
|
1991
|
-
depthVarianceScore: 0,
|
|
1992
1963
|
motionDisplacementVariance: 0,
|
|
1993
1964
|
perspectiveRatio: 0,
|
|
1994
1965
|
motionDirectionConsistency: 0,
|
|
@@ -1996,127 +1967,24 @@ class PhotoAttackDetector {
|
|
|
1996
1967
|
perspectiveScore: 0,
|
|
1997
1968
|
isPhoto: false,
|
|
1998
1969
|
photoConfidence: 0,
|
|
1999
|
-
dominantFeature: 'combined'
|
|
2000
1970
|
};
|
|
2001
1971
|
// 帧数不足,无法检测
|
|
2002
1972
|
if (this.frameBuffer.length < 3) {
|
|
2003
1973
|
return new PhotoAttackDetectionResult(false, details);
|
|
2004
1974
|
}
|
|
2005
|
-
// ============
|
|
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
|
-
// ============ 方案二:运动透视一致性检验 ============
|
|
1975
|
+
// ============ 运动透视一致性检验 ============
|
|
2013
1976
|
const perspectiveAnalysis = this.analyzePerspectiveConsistency();
|
|
2014
1977
|
details.motionDisplacementVariance = perspectiveAnalysis.motionDisplacementVariance;
|
|
2015
1978
|
details.perspectiveRatio = perspectiveAnalysis.perspectiveRatio;
|
|
2016
1979
|
details.motionDirectionConsistency = perspectiveAnalysis.motionDirectionConsistency;
|
|
2017
1980
|
details.affineTransformPatternMatch = perspectiveAnalysis.affineTransformPatternMatch;
|
|
2018
1981
|
details.perspectiveScore = perspectiveAnalysis.score;
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
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';
|
|
2032
|
-
}
|
|
2033
|
-
else {
|
|
2034
|
-
details.dominantFeature = 'perspective';
|
|
2035
|
-
}
|
|
2036
|
-
return new PhotoAttackDetectionResult(details.isPhoto, details);
|
|
2037
|
-
}
|
|
2038
|
-
/**
|
|
2039
|
-
* 方案一:3D 深度方差分析
|
|
2040
|
-
*
|
|
2041
|
-
* 原理:
|
|
2042
|
-
* - 真实人脸具有真实的 3D 结构,Z 坐标(深度)跨越较大范围
|
|
2043
|
-
* - 照片是 2D 的,所有点深度基本相同,Z 坐标方差极小
|
|
2044
|
-
* - MediaPipe 可以从 2D 图像推断出深度,但:
|
|
2045
|
-
* - 真实人脸:推断正确,Z 坐标有明显差异(鼻尖 > 脸颊 > 耳朵)
|
|
2046
|
-
* - 照片:推断平坦,Z 坐标基本相同
|
|
2047
|
-
*/
|
|
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]);
|
|
2060
|
-
}
|
|
2061
|
-
}
|
|
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
|
-
}
|
|
2081
|
-
}
|
|
2082
|
-
}
|
|
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
|
-
};
|
|
1982
|
+
details.isPhoto = perspectiveAnalysis.score > 0.5;
|
|
1983
|
+
details.photoConfidence = perspectiveAnalysis.score;
|
|
1984
|
+
return new PhotoAttackDetectionResult(details.isPhoto, details, true, this.frameBuffer.length >= this.config.requiredFrameCount);
|
|
2117
1985
|
}
|
|
2118
1986
|
/**
|
|
2119
|
-
*
|
|
1987
|
+
* 运动透视一致性检验
|
|
2120
1988
|
*
|
|
2121
1989
|
* 原理:
|
|
2122
1990
|
* - 真实人脸运动:由于透视效应,近处点移动幅度大,远处点移动幅度小
|
|
@@ -2165,7 +2033,40 @@ class PhotoAttackDetector {
|
|
|
2165
2033
|
// - 方向一致性越高 -> 照片特征 -> score高
|
|
2166
2034
|
// - 仿射变换匹配度越高 -> 照片特征 -> score高
|
|
2167
2035
|
const variance_indicator = Math.max(0, 1 - (motionDisplacementVariance / this.config.motionVarianceThreshold));
|
|
2168
|
-
|
|
2036
|
+
// 修改透视比率计算逻辑以支持阈值大于等于1的情况
|
|
2037
|
+
let ratio_indicator = 0;
|
|
2038
|
+
if (this.config.perspectiveRatioThreshold === 1) {
|
|
2039
|
+
// 特殊情况:阈值等于1时
|
|
2040
|
+
// perspectiveRatio < 1: 近处点移动 < 远处点,明显照片特征 → 高分
|
|
2041
|
+
// perspectiveRatio >= 1: 近处点移动 >= 远处点,符合透视效应 → 低分
|
|
2042
|
+
if (perspectiveRatio < 1) {
|
|
2043
|
+
// 使用非线性放大,让 0.90 ~ 1 的 范围得到更高分数
|
|
2044
|
+
const deviation = 1 - perspectiveRatio;
|
|
2045
|
+
ratio_indicator = Math.min(1, deviation * 10);
|
|
2046
|
+
}
|
|
2047
|
+
else {
|
|
2048
|
+
// 大于等于1,符合透视效应,降低照片分数
|
|
2049
|
+
ratio_indicator = 0;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
else if (this.config.perspectiveRatioThreshold < 1) {
|
|
2053
|
+
// 阈值小于1时的逻辑
|
|
2054
|
+
const denominator = 1 - this.config.perspectiveRatioThreshold;
|
|
2055
|
+
ratio_indicator = Math.max(0, 1 - Math.abs(perspectiveRatio - 1) / denominator);
|
|
2056
|
+
}
|
|
2057
|
+
else {
|
|
2058
|
+
// 阈值大于1时,真实人脸应有更高比率
|
|
2059
|
+
// 如果透视比率大于阈值,则更可能是真实人脸,返回低分(非照片)
|
|
2060
|
+
// 如果透视比率小于等于阈值,则可能是照片,返回高分
|
|
2061
|
+
if (perspectiveRatio < this.config.perspectiveRatioThreshold) {
|
|
2062
|
+
// 透视比率小于阈值,更像照片
|
|
2063
|
+
ratio_indicator = Math.max(0, 1 - (this.config.perspectiveRatioThreshold - perspectiveRatio) / this.config.perspectiveRatioThreshold);
|
|
2064
|
+
}
|
|
2065
|
+
else {
|
|
2066
|
+
// 透视比率大于阈值,更像真实人脸
|
|
2067
|
+
ratio_indicator = 0; // 真实人脸,返回低分
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2169
2070
|
const consistency_indicator = Math.min(1, motionDirectionConsistency / this.config.motionConsistencyThreshold);
|
|
2170
2071
|
const affine_indicator = affineTransformPatternMatch;
|
|
2171
2072
|
// 综合分数:四个指标的平均值
|
|
@@ -2356,11 +2257,385 @@ class PhotoAttackDetector {
|
|
|
2356
2257
|
reset() {
|
|
2357
2258
|
this.frameBuffer = [];
|
|
2358
2259
|
}
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
/**
|
|
2263
|
+
* 屏幕攻击检测结果
|
|
2264
|
+
*/
|
|
2265
|
+
class ScreenAttackDetectionResult {
|
|
2266
|
+
isScreenAttack;
|
|
2267
|
+
details;
|
|
2268
|
+
available = false;
|
|
2269
|
+
trusted = false;
|
|
2270
|
+
constructor(isScreenAttack, details, available = false, trusted = false) {
|
|
2271
|
+
this.isScreenAttack = isScreenAttack;
|
|
2272
|
+
this.details = details;
|
|
2273
|
+
this.available = available;
|
|
2274
|
+
this.trusted = trusted;
|
|
2275
|
+
}
|
|
2276
|
+
getMessage() {
|
|
2277
|
+
if (this.details.frameCount < 1) {
|
|
2278
|
+
return '未获得足够数据,无法进行屏幕攻击检测';
|
|
2279
|
+
}
|
|
2280
|
+
if (!this.isScreenAttack)
|
|
2281
|
+
return '';
|
|
2282
|
+
const confidence = (this.details.screenAttackConfidence * 100).toFixed(2);
|
|
2283
|
+
const reasons = [];
|
|
2284
|
+
if (this.details.moireConfidence > 0.6) {
|
|
2285
|
+
const moireScore = (this.details.moireScore * 100).toFixed(2);
|
|
2286
|
+
reasons.push(`摩尔纹特征明显(${moireScore})`);
|
|
2287
|
+
}
|
|
2288
|
+
const reasonStr = reasons.length > 0 ? `(${reasons.join('、')})` : '';
|
|
2289
|
+
return `检测到屏幕攻击${reasonStr},置信度 ${confidence}%`;
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
const DEFAULT_SCREEN_OPTIONS = {
|
|
2293
|
+
moireThreshold: 0.65,
|
|
2294
|
+
pixelGridSensitivity: 0.75,
|
|
2295
|
+
requiredFrameCount: 12,
|
|
2296
|
+
earlyDetectionThreshold: 0.8
|
|
2297
|
+
};
|
|
2298
|
+
/**
|
|
2299
|
+
* 屏幕攻击检测器
|
|
2300
|
+
*
|
|
2301
|
+
* 检测方案:
|
|
2302
|
+
* 摩尔纹/像素网格分析(检测屏幕特有的周期性图案)
|
|
2303
|
+
*/
|
|
2304
|
+
class ScreenAttackDetector {
|
|
2305
|
+
config;
|
|
2306
|
+
opencv = null;
|
|
2307
|
+
frameCount = 0;
|
|
2308
|
+
emitDebug = () => { }; // 默认空实现
|
|
2309
|
+
constructor(options) {
|
|
2310
|
+
this.config = { ...DEFAULT_SCREEN_OPTIONS, ...options };
|
|
2311
|
+
}
|
|
2359
2312
|
/**
|
|
2360
|
-
*
|
|
2313
|
+
* 设置 OpenCV 实例
|
|
2314
|
+
* @param opencv - TechStark opencv.js 实例
|
|
2361
2315
|
*/
|
|
2362
|
-
|
|
2363
|
-
|
|
2316
|
+
setOpencv(opencv) {
|
|
2317
|
+
this.opencv = opencv;
|
|
2318
|
+
}
|
|
2319
|
+
/**
|
|
2320
|
+
* 设置 emitDebug 方法(依赖注入)
|
|
2321
|
+
* @param emitDebugFn - 来自 FaceDetectionEngine 的 emitDebug 方法
|
|
2322
|
+
*/
|
|
2323
|
+
setEmitDebug(emitDebugFn) {
|
|
2324
|
+
this.emitDebug = emitDebugFn;
|
|
2325
|
+
}
|
|
2326
|
+
/**
|
|
2327
|
+
* 检测屏幕攻击
|
|
2328
|
+
* @param faceBox - 人脸区域框
|
|
2329
|
+
* @param colorMat - 彩色图像矩阵
|
|
2330
|
+
* @param grayMat - 灰度图像矩阵
|
|
2331
|
+
* @returns 检测结果
|
|
2332
|
+
*/
|
|
2333
|
+
detect(colorMat, grayMat) {
|
|
2334
|
+
this.frameCount += 1;
|
|
2335
|
+
const details = {
|
|
2336
|
+
frameCount: this.frameCount,
|
|
2337
|
+
moireScore: 0,
|
|
2338
|
+
pixelGridStrength: 0,
|
|
2339
|
+
moireConfidence: 0,
|
|
2340
|
+
isScreenAttack: false,
|
|
2341
|
+
screenAttackConfidence: 0,
|
|
2342
|
+
feature: 'moire',
|
|
2343
|
+
debugInfo: {}
|
|
2344
|
+
};
|
|
2345
|
+
// 检查OpenCV是否已设置
|
|
2346
|
+
if (!this.opencv) {
|
|
2347
|
+
this.emitDebug('screen-attack', 'OpenCV未初始化', {}, 'warn');
|
|
2348
|
+
return new ScreenAttackDetectionResult(false, details);
|
|
2349
|
+
}
|
|
2350
|
+
try {
|
|
2351
|
+
// ============ 摩尔纹/像素网格分析 ============
|
|
2352
|
+
const moireAnalysis = this.analyzeMoirePattern(grayMat);
|
|
2353
|
+
details.moireScore = moireAnalysis.score;
|
|
2354
|
+
details.pixelGridStrength = moireAnalysis.pixelGridStrength;
|
|
2355
|
+
details.moireConfidence = moireAnalysis.confidence;
|
|
2356
|
+
details.debugInfo.moireAnalysis = moireAnalysis.debug;
|
|
2357
|
+
// 早期检测:如果摩尔纹分数过高,直接判定为屏幕攻击
|
|
2358
|
+
if (details.moireScore > this.config.earlyDetectionThreshold) {
|
|
2359
|
+
details.isScreenAttack = true;
|
|
2360
|
+
details.screenAttackConfidence = details.moireScore;
|
|
2361
|
+
details.feature = 'moire';
|
|
2362
|
+
this.emitDebug('screen-attack', '摩尔纹分析早期检测触发', {
|
|
2363
|
+
moireScore: details.moireScore,
|
|
2364
|
+
earlyThreshold: this.config.earlyDetectionThreshold
|
|
2365
|
+
});
|
|
2366
|
+
return new ScreenAttackDetectionResult(true, details, true, true);
|
|
2367
|
+
}
|
|
2368
|
+
// 基于摩尔纹置信度的判定
|
|
2369
|
+
details.isScreenAttack = details.moireConfidence > this.config.moireThreshold;
|
|
2370
|
+
details.screenAttackConfidence = details.moireScore;
|
|
2371
|
+
this.emitDebug('screen-attack', '摩尔纹分析完成', {
|
|
2372
|
+
moireScore: details.moireScore,
|
|
2373
|
+
isScreenAttack: details.isScreenAttack
|
|
2374
|
+
});
|
|
2375
|
+
return new ScreenAttackDetectionResult(details.isScreenAttack, details, true, this.frameCount >= this.config.requiredFrameCount);
|
|
2376
|
+
}
|
|
2377
|
+
catch (error) {
|
|
2378
|
+
this.emitDebug('screen-attack', `检测过程中发生错误: ${error}`, {}, 'error');
|
|
2379
|
+
return new ScreenAttackDetectionResult(false, details, false, false);
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
/**
|
|
2383
|
+
* 摩尔纹/像素网格分析
|
|
2384
|
+
*
|
|
2385
|
+
* 原理:
|
|
2386
|
+
* - 真实人脸:纹理在频域中分布较为随机
|
|
2387
|
+
* - 屏幕显示:像素阵列在频域中产生特有的摩尔纹和周期性图案
|
|
2388
|
+
* - 摄像头采样与屏幕像素网格的频率混叠效应
|
|
2389
|
+
*/
|
|
2390
|
+
analyzeMoirePattern(grayMat) {
|
|
2391
|
+
const cv = this.opencv;
|
|
2392
|
+
let debugInfo = {};
|
|
2393
|
+
try {
|
|
2394
|
+
// 分析整个图像,而不是特定区域
|
|
2395
|
+
const analysisMat = grayMat;
|
|
2396
|
+
// 确保图像是浮点类型用于FFT
|
|
2397
|
+
const floatMat = new cv.Mat();
|
|
2398
|
+
analysisMat.convertTo(floatMat, cv.CV_32F);
|
|
2399
|
+
// 创建复数矩阵用于FFT
|
|
2400
|
+
const planes = new cv.MatVector();
|
|
2401
|
+
const zeros = cv.Mat.zeros(floatMat.rows, floatMat.cols, cv.CV_32F);
|
|
2402
|
+
planes.push_back(floatMat);
|
|
2403
|
+
planes.push_back(zeros);
|
|
2404
|
+
// 执行2D FFT
|
|
2405
|
+
const complexI = new cv.Mat();
|
|
2406
|
+
cv.merge(planes, complexI);
|
|
2407
|
+
cv.dft(complexI, complexI, cv.DFT_COMPLEX_OUTPUT);
|
|
2408
|
+
// 计算幅度谱
|
|
2409
|
+
const mag = new cv.Mat();
|
|
2410
|
+
const planesVec = new cv.MatVector();
|
|
2411
|
+
cv.split(complexI, planesVec);
|
|
2412
|
+
const real = planesVec.get(0);
|
|
2413
|
+
const imag = planesVec.get(1);
|
|
2414
|
+
// 计算幅度: sqrt(real^2 + imag^2)
|
|
2415
|
+
cv.magnitude(real, imag, mag);
|
|
2416
|
+
// 转换为对数尺度以便可视化
|
|
2417
|
+
const matShift = new cv.Mat();
|
|
2418
|
+
mag.convertTo(matShift, cv.CV_32F);
|
|
2419
|
+
// 对数变换
|
|
2420
|
+
cv.add(cv.Mat.ones(mag.rows, mag.cols, cv.CV_32F), matShift, matShift);
|
|
2421
|
+
cv.log(matShift, matShift);
|
|
2422
|
+
// 重新排列四象限,使零频率分量位于中心
|
|
2423
|
+
const cx = Math.floor(matShift.cols / 2);
|
|
2424
|
+
const cy = Math.floor(matShift.rows / 2);
|
|
2425
|
+
const q0 = matShift.roi(new cv.Rect(0, 0, cx, cy));
|
|
2426
|
+
const q1 = matShift.roi(new cv.Rect(cx, 0, cx, cy));
|
|
2427
|
+
const q2 = matShift.roi(new cv.Rect(0, cy, cx, cy));
|
|
2428
|
+
const q3 = matShift.roi(new cv.Rect(cx, cy, cx, cy));
|
|
2429
|
+
const tmp = new cv.Mat();
|
|
2430
|
+
q0.copyTo(tmp);
|
|
2431
|
+
q3.copyTo(q0);
|
|
2432
|
+
tmp.copyTo(q3);
|
|
2433
|
+
q1.copyTo(tmp);
|
|
2434
|
+
q2.copyTo(q1);
|
|
2435
|
+
tmp.copyTo(q2);
|
|
2436
|
+
// 专门检测摩尔纹特征
|
|
2437
|
+
// 屏幕像素网格在频域中产生同心圆状的规律性模式
|
|
2438
|
+
const moireScore = this.analyzeMoireCharacteristics(matShift);
|
|
2439
|
+
const pixelGridStrength = this.calculatePixelGridStrength(matShift);
|
|
2440
|
+
// 综合分析摩尔纹特征
|
|
2441
|
+
let finalScore = 0;
|
|
2442
|
+
let patternStrength = 0;
|
|
2443
|
+
// 基于同心圆模式的分析
|
|
2444
|
+
if (moireScore > 0.3) {
|
|
2445
|
+
finalScore += moireScore * 0.6;
|
|
2446
|
+
}
|
|
2447
|
+
// 基于像素网格强度的分析
|
|
2448
|
+
if (pixelGridStrength > 0.4) {
|
|
2449
|
+
finalScore += pixelGridStrength * 0.4;
|
|
2450
|
+
}
|
|
2451
|
+
// 检查是否有清晰的周期性峰值
|
|
2452
|
+
const peaks = this.findMoirePeaks(matShift);
|
|
2453
|
+
const peakCount = peaks.length;
|
|
2454
|
+
if (peakCount > 15) { // 高峰值数量表明存在明显的摩尔纹
|
|
2455
|
+
finalScore += Math.min(0.3, peakCount / 100);
|
|
2456
|
+
}
|
|
2457
|
+
patternStrength = Math.max(moireScore, pixelGridStrength);
|
|
2458
|
+
// 限制分数范围
|
|
2459
|
+
finalScore = Math.min(1, Math.max(0, finalScore));
|
|
2460
|
+
// 计算置信度
|
|
2461
|
+
const confidence = finalScore * this.config.pixelGridSensitivity;
|
|
2462
|
+
debugInfo = {
|
|
2463
|
+
moireScore,
|
|
2464
|
+
pixelGridStrength,
|
|
2465
|
+
peakCount,
|
|
2466
|
+
patternStrength,
|
|
2467
|
+
rawScore: finalScore
|
|
2468
|
+
};
|
|
2469
|
+
// 释放内存
|
|
2470
|
+
floatMat.delete();
|
|
2471
|
+
complexI.delete();
|
|
2472
|
+
mag.delete();
|
|
2473
|
+
matShift.delete();
|
|
2474
|
+
planes.delete();
|
|
2475
|
+
zeros.delete();
|
|
2476
|
+
planesVec.delete();
|
|
2477
|
+
real.delete();
|
|
2478
|
+
imag.delete();
|
|
2479
|
+
tmp.delete();
|
|
2480
|
+
return {
|
|
2481
|
+
score: finalScore,
|
|
2482
|
+
pixelGridStrength,
|
|
2483
|
+
confidence,
|
|
2484
|
+
debug: debugInfo
|
|
2485
|
+
};
|
|
2486
|
+
}
|
|
2487
|
+
catch (error) {
|
|
2488
|
+
this.emitDebug('screen-attack-moire', `摩尔纹分析出错: ${error}`, {}, 'error');
|
|
2489
|
+
return { score: 0, pixelGridStrength: 0, confidence: 0, debug: {} };
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
/**
|
|
2493
|
+
* 分析频域中的摩尔纹特征
|
|
2494
|
+
*/
|
|
2495
|
+
analyzeMoireCharacteristics(spectrum) {
|
|
2496
|
+
this.opencv;
|
|
2497
|
+
const centerX = Math.floor(spectrum.cols / 2);
|
|
2498
|
+
const centerY = Math.floor(spectrum.rows / 2);
|
|
2499
|
+
// 计算径向剖面图以检测同心圆模式
|
|
2500
|
+
const radialProfile = this.computeRadialProfile(spectrum, centerX, centerY);
|
|
2501
|
+
// 检查径向剖面的规律性(同心圆特征)
|
|
2502
|
+
let regularityScore = 0;
|
|
2503
|
+
Math.max(1, Math.floor(radialProfile.length / 10));
|
|
2504
|
+
// 计算径向剖面的自相关性来检测规律性
|
|
2505
|
+
const autoCorrelation = this.calculateAutoCorrelation(radialProfile);
|
|
2506
|
+
// 寻找主要的周期性模式
|
|
2507
|
+
let maxPeak = 0;
|
|
2508
|
+
for (let i = 1; i < autoCorrelation.length / 4; i++) {
|
|
2509
|
+
if (autoCorrelation[i] > maxPeak) {
|
|
2510
|
+
maxPeak = autoCorrelation[i];
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
// 根据最大峰值确定摩尔纹特征强度
|
|
2514
|
+
regularityScore = Math.min(1, maxPeak / 10.0); // 归一化
|
|
2515
|
+
return regularityScore;
|
|
2516
|
+
}
|
|
2517
|
+
/**
|
|
2518
|
+
* 计算像素网格强度
|
|
2519
|
+
*/
|
|
2520
|
+
calculatePixelGridStrength(spectrum) {
|
|
2521
|
+
const cv = this.opencv;
|
|
2522
|
+
// 创建用于存储均值和标准差的标量
|
|
2523
|
+
const mean = new cv.Mat();
|
|
2524
|
+
const stddev = new cv.Mat();
|
|
2525
|
+
// 计算频谱的均值和标准差
|
|
2526
|
+
cv.meanStdDev(spectrum, mean, stddev);
|
|
2527
|
+
// 获取标准差的值
|
|
2528
|
+
let spectrumStdDev = 0;
|
|
2529
|
+
if (stddev.data64F && stddev.data64F.length > 0) {
|
|
2530
|
+
spectrumStdDev = stddev.data64F[0];
|
|
2531
|
+
}
|
|
2532
|
+
else if (stddev.data32F && stddev.data32F.length > 0) {
|
|
2533
|
+
spectrumStdDev = stddev.data32F[0];
|
|
2534
|
+
}
|
|
2535
|
+
else {
|
|
2536
|
+
// 如果无法获取数据,则手动计算
|
|
2537
|
+
let sum = 0;
|
|
2538
|
+
let sumSq = 0;
|
|
2539
|
+
const data = spectrum.data32F || spectrum.data64F;
|
|
2540
|
+
if (data) {
|
|
2541
|
+
for (let i = 0; i < data.length; i++) {
|
|
2542
|
+
sum += data[i];
|
|
2543
|
+
sumSq += data[i] * data[i];
|
|
2544
|
+
}
|
|
2545
|
+
const meanVal = sum / data.length;
|
|
2546
|
+
const variance = (sumSq / data.length) - (meanVal * meanVal);
|
|
2547
|
+
spectrumStdDev = Math.sqrt(variance);
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
// 释放内存
|
|
2551
|
+
mean.delete();
|
|
2552
|
+
stddev.delete();
|
|
2553
|
+
// 归一化标准差到0-1范围
|
|
2554
|
+
const normalizedStdDev = Math.min(1, spectrumStdDev / 5.0);
|
|
2555
|
+
return normalizedStdDev;
|
|
2556
|
+
}
|
|
2557
|
+
/**
|
|
2558
|
+
* 查找频域中的摩尔纹峰值
|
|
2559
|
+
*/
|
|
2560
|
+
findMoirePeaks(spectrum) {
|
|
2561
|
+
const cv = this.opencv;
|
|
2562
|
+
const peaks = [];
|
|
2563
|
+
// 使用局部最大值检测
|
|
2564
|
+
const kernel = cv.Mat.ones(3, 3, cv.CV_8UC1);
|
|
2565
|
+
// 膨胀操作以找到局部最大值
|
|
2566
|
+
const dilated = new cv.Mat();
|
|
2567
|
+
cv.dilate(spectrum, dilated, kernel);
|
|
2568
|
+
// 比较原图和膨胀后的图,相等的位置即为局部最大值
|
|
2569
|
+
const localMaxMask = new cv.Mat();
|
|
2570
|
+
cv.compare(spectrum, dilated, localMaxMask, cv.CMP_EQ);
|
|
2571
|
+
// 查找非零点(即峰值位置)
|
|
2572
|
+
for (let y = 1; y < spectrum.rows - 1; y++) {
|
|
2573
|
+
for (let x = 1; x < spectrum.cols - 1; x++) {
|
|
2574
|
+
const maskIndex = y * localMaxMask.cols + x;
|
|
2575
|
+
// 使用正确的数组访问方式
|
|
2576
|
+
if (localMaxMask.data[maskIndex] !== 0) {
|
|
2577
|
+
const spectrumIndex = y * spectrum.cols + x;
|
|
2578
|
+
const val = spectrum.data32F ? spectrum.data32F[spectrumIndex] : 0;
|
|
2579
|
+
// 只保留显著的峰值
|
|
2580
|
+
if (val > 3.0) { // 阈值可根据实际调试调整
|
|
2581
|
+
peaks.push({ x, y, value: val });
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
// 释放内存
|
|
2587
|
+
kernel.delete();
|
|
2588
|
+
dilated.delete();
|
|
2589
|
+
localMaxMask.delete();
|
|
2590
|
+
return peaks;
|
|
2591
|
+
}
|
|
2592
|
+
/**
|
|
2593
|
+
* 计算径向剖面图
|
|
2594
|
+
*/
|
|
2595
|
+
computeRadialProfile(spectrum, centerX, centerY) {
|
|
2596
|
+
this.opencv;
|
|
2597
|
+
const profile = [];
|
|
2598
|
+
const maxRadius = Math.min(centerX, centerY);
|
|
2599
|
+
for (let r = 0; r < maxRadius; r++) {
|
|
2600
|
+
let sum = 0;
|
|
2601
|
+
let count = 0;
|
|
2602
|
+
for (let angle = 0; angle < 360; angle += 5) { // 每5度采样一次
|
|
2603
|
+
const rad = angle * Math.PI / 180;
|
|
2604
|
+
const x = Math.round(centerX + r * Math.cos(rad));
|
|
2605
|
+
const y = Math.round(centerY + r * Math.sin(rad));
|
|
2606
|
+
if (x >= 0 && x < spectrum.cols && y >= 0 && y < spectrum.rows) {
|
|
2607
|
+
const index = y * spectrum.cols + x;
|
|
2608
|
+
if (spectrum.data32F && index < spectrum.data32F.length) {
|
|
2609
|
+
const value = spectrum.data32F[index];
|
|
2610
|
+
sum += value;
|
|
2611
|
+
count++;
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
profile.push(count > 0 ? sum / count : 0);
|
|
2616
|
+
}
|
|
2617
|
+
return profile;
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* 计算数组的自相关
|
|
2621
|
+
*/
|
|
2622
|
+
calculateAutoCorrelation(data) {
|
|
2623
|
+
const n = data.length;
|
|
2624
|
+
const result = new Array(n).fill(0);
|
|
2625
|
+
for (let lag = 0; lag < n; lag++) {
|
|
2626
|
+
let sum = 0;
|
|
2627
|
+
for (let i = 0; i < n - lag; i++) {
|
|
2628
|
+
sum += data[i] * data[i + lag];
|
|
2629
|
+
}
|
|
2630
|
+
result[lag] = sum / (n - lag);
|
|
2631
|
+
}
|
|
2632
|
+
return result;
|
|
2633
|
+
}
|
|
2634
|
+
/**
|
|
2635
|
+
* 重置检测器
|
|
2636
|
+
*/
|
|
2637
|
+
reset() {
|
|
2638
|
+
this.frameCount = 0;
|
|
2364
2639
|
}
|
|
2365
2640
|
}
|
|
2366
2641
|
|
|
@@ -2380,7 +2655,9 @@ class DetectionState {
|
|
|
2380
2655
|
lastFrontalScore = 1;
|
|
2381
2656
|
faceMovingDetector = null;
|
|
2382
2657
|
photoAttackDetector = null;
|
|
2658
|
+
screenAttachDetector = null;
|
|
2383
2659
|
liveness = false;
|
|
2660
|
+
realness = false;
|
|
2384
2661
|
constructor(options) {
|
|
2385
2662
|
Object.assign(this, options);
|
|
2386
2663
|
}
|
|
@@ -2388,11 +2665,17 @@ class DetectionState {
|
|
|
2388
2665
|
this.clearActionVerifyTimeout();
|
|
2389
2666
|
const savedFaceMovingDetector = this.faceMovingDetector;
|
|
2390
2667
|
const savedPhotoAttackDetector = this.photoAttackDetector;
|
|
2668
|
+
const savedScreenAttackDetector = this.screenAttachDetector;
|
|
2391
2669
|
savedFaceMovingDetector?.reset();
|
|
2392
2670
|
savedPhotoAttackDetector?.reset();
|
|
2671
|
+
savedScreenAttackDetector?.reset();
|
|
2393
2672
|
Object.assign(this, new DetectionState({}));
|
|
2394
2673
|
this.faceMovingDetector = savedFaceMovingDetector;
|
|
2395
2674
|
this.photoAttackDetector = savedPhotoAttackDetector;
|
|
2675
|
+
this.screenAttachDetector = savedScreenAttackDetector;
|
|
2676
|
+
}
|
|
2677
|
+
setOpenCv(opencv) {
|
|
2678
|
+
this.screenAttachDetector?.setOpencv(opencv);
|
|
2396
2679
|
}
|
|
2397
2680
|
// 默认方法
|
|
2398
2681
|
needFrontalFace() {
|
|
@@ -2401,7 +2684,7 @@ class DetectionState {
|
|
|
2401
2684
|
// 是否准备好进行动作验证
|
|
2402
2685
|
isReadyToVerify(minCollectCount) {
|
|
2403
2686
|
if (this.period === DetectionPeriod.COLLECT
|
|
2404
|
-
&& this.liveness
|
|
2687
|
+
&& this.liveness && this.realness
|
|
2405
2688
|
&& this.collectCount >= minCollectCount) {
|
|
2406
2689
|
return true;
|
|
2407
2690
|
}
|
|
@@ -2437,8 +2720,11 @@ function createDetectionState(engine) {
|
|
|
2437
2720
|
const detectionState = new DetectionState({});
|
|
2438
2721
|
detectionState.faceMovingDetector = new FaceMovingDetector();
|
|
2439
2722
|
detectionState.faceMovingDetector.setEmitDebug(engine.emitDebug.bind(engine));
|
|
2723
|
+
// 禁用深度分析,实测深度分析不准确
|
|
2440
2724
|
detectionState.photoAttackDetector = new PhotoAttackDetector();
|
|
2441
2725
|
detectionState.photoAttackDetector.setEmitDebug(engine.emitDebug.bind(engine));
|
|
2726
|
+
detectionState.screenAttachDetector = new ScreenAttackDetector();
|
|
2727
|
+
detectionState.screenAttachDetector.setEmitDebug(engine.emitDebug.bind(engine));
|
|
2442
2728
|
return detectionState;
|
|
2443
2729
|
}
|
|
2444
2730
|
|
|
@@ -2950,6 +3236,7 @@ class FaceDetectionEngine extends SimpleEventEmitter {
|
|
|
2950
3236
|
if (!this.transitionEngineState(EngineState.DETECTING, 'startDetection() video ready')) {
|
|
2951
3237
|
throw new Error('Failed to transition to DETECTING state');
|
|
2952
3238
|
}
|
|
3239
|
+
this.detectionState.setOpenCv(this.cv);
|
|
2953
3240
|
this.cancelPendingDetection();
|
|
2954
3241
|
this.animationFrameId = requestAnimationFrame(() => {
|
|
2955
3242
|
this.detect();
|
|
@@ -3282,6 +3569,16 @@ class FaceDetectionEngine extends SimpleEventEmitter {
|
|
|
3282
3569
|
this.stopDetection(false);
|
|
3283
3570
|
return;
|
|
3284
3571
|
}
|
|
3572
|
+
if (!this.detectionState.screenAttachDetector) {
|
|
3573
|
+
this.emit('detector-error', {
|
|
3574
|
+
code: ErrorCode.INTERNAL_ERROR,
|
|
3575
|
+
message: 'Screen attack detector is not initialized'
|
|
3576
|
+
});
|
|
3577
|
+
// Clear the detecting flag before stopping to avoid deadlock
|
|
3578
|
+
this.isDetectingFrameActive = false;
|
|
3579
|
+
this.stopDetection(false);
|
|
3580
|
+
return;
|
|
3581
|
+
}
|
|
3285
3582
|
try {
|
|
3286
3583
|
// 动作活体检测阶段处理
|
|
3287
3584
|
if (this.detectionState.period === DetectionPeriod.VERIFY) {
|
|
@@ -3302,55 +3599,51 @@ class FaceDetectionEngine extends SimpleEventEmitter {
|
|
|
3302
3599
|
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
3600
|
return;
|
|
3304
3601
|
}
|
|
3305
|
-
|
|
3306
|
-
if (
|
|
3307
|
-
|
|
3308
|
-
this.
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
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) {
|
|
3332
|
-
this.emitDetectorInfo({
|
|
3333
|
-
code: DetectionCode.PHOTO_ATTACK_DETECTED,
|
|
3334
|
-
message: photoAttackResult.getMessage(),
|
|
3335
|
-
});
|
|
3336
|
-
this.emitDebug('motion-detection', 'Photo attack detected', {
|
|
3337
|
-
details: photoAttackResult.details,
|
|
3338
|
-
debug: photoAttackResult.debug,
|
|
3339
|
-
}, 'warn');
|
|
3340
|
-
this.partialResetDetectionState();
|
|
3341
|
-
return;
|
|
3602
|
+
// 开启面部移动检测
|
|
3603
|
+
if (this.options.enable_face_moving_detection) {
|
|
3604
|
+
this.detectionState.faceMovingDetector.addFrame(face, timestamp);
|
|
3605
|
+
const faceMovingResult = this.detectionState.faceMovingDetector.detect();
|
|
3606
|
+
if (faceMovingResult.available) {
|
|
3607
|
+
if (!faceMovingResult.isMoving) {
|
|
3608
|
+
// 面部移动检测失败,可能为照片攻击
|
|
3609
|
+
this.emitDebug('motion-detection', 'Face moving detection failed - possible photo attack', faceMovingResult.details, 'warn');
|
|
3610
|
+
this.emitDetectorInfo({
|
|
3611
|
+
code: DetectionCode.FACE_NOT_MOVING,
|
|
3612
|
+
message: faceMovingResult.getMessage(),
|
|
3613
|
+
});
|
|
3614
|
+
this.partialResetDetectionState();
|
|
3615
|
+
return;
|
|
3616
|
+
}
|
|
3342
3617
|
}
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3618
|
+
}
|
|
3619
|
+
// 开启照片攻击检测
|
|
3620
|
+
if (this.options.enable_photo_attack_detection) {
|
|
3621
|
+
this.detectionState.photoAttackDetector.addFrame(face);
|
|
3622
|
+
const photoAttackResult = this.detectionState.photoAttackDetector.detect();
|
|
3623
|
+
if (photoAttackResult.available) {
|
|
3624
|
+
// 照片攻击检测可用(仅当判定为照片攻击时)
|
|
3625
|
+
if (photoAttackResult.isPhoto) {
|
|
3626
|
+
this.emitDetectorInfo({
|
|
3627
|
+
code: DetectionCode.PHOTO_ATTACK_DETECTED,
|
|
3628
|
+
message: photoAttackResult.getMessage(),
|
|
3629
|
+
});
|
|
3630
|
+
this.emitDebug('motion-detection', 'Photo attack detected', photoAttackResult.details, 'warn');
|
|
3631
|
+
this.partialResetDetectionState();
|
|
3632
|
+
return;
|
|
3633
|
+
}
|
|
3634
|
+
else {
|
|
3635
|
+
if (photoAttackResult.trusted) {
|
|
3636
|
+
// 仅当采集到足够帧,且判定为非照片攻击时,才采信
|
|
3637
|
+
this.detectionState.liveness = true;
|
|
3638
|
+
this.emitDebug('motion-detection', 'Photo attack detection passed - face is live', photoAttackResult.details, 'warn');
|
|
3639
|
+
}
|
|
3351
3640
|
}
|
|
3352
3641
|
}
|
|
3353
3642
|
}
|
|
3643
|
+
else {
|
|
3644
|
+
// 未启用照片攻击检测,默认活体为活体
|
|
3645
|
+
this.detectionState.liveness = true;
|
|
3646
|
+
}
|
|
3354
3647
|
// 捕获并准备帧数据
|
|
3355
3648
|
const frameData = this.captureAndPrepareFrames();
|
|
3356
3649
|
if (!frameData) {
|
|
@@ -3361,6 +3654,31 @@ class FaceDetectionEngine extends SimpleEventEmitter {
|
|
|
3361
3654
|
}
|
|
3362
3655
|
const bgrFrame = frameData.bgrFrame;
|
|
3363
3656
|
const grayFrame = frameData.grayFrame;
|
|
3657
|
+
if (this.options.enable_screen_attack_detection) {
|
|
3658
|
+
const screenAttackResult = this.detectionState.screenAttachDetector.detect(bgrFrame, grayFrame);
|
|
3659
|
+
if (screenAttackResult.available) {
|
|
3660
|
+
if (screenAttackResult.isScreenAttack) {
|
|
3661
|
+
this.emitDetectorInfo({
|
|
3662
|
+
code: DetectionCode.SCREEN_ATTACK_DETECTED,
|
|
3663
|
+
message: screenAttackResult.getMessage(),
|
|
3664
|
+
});
|
|
3665
|
+
this.emitDebug('motion-detection', 'Screen attack detected', screenAttackResult.details, 'warn');
|
|
3666
|
+
this.partialResetDetectionState();
|
|
3667
|
+
return;
|
|
3668
|
+
}
|
|
3669
|
+
else {
|
|
3670
|
+
if (screenAttackResult.trusted) {
|
|
3671
|
+
// 仅当采集到足够帧,且判定为非屏幕攻击时,才采信
|
|
3672
|
+
this.detectionState.realness = true;
|
|
3673
|
+
this.emitDebug('motion-detection', 'Screen attack detection passed - face is real', screenAttackResult.details, 'warn');
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
else {
|
|
3679
|
+
// 未启用屏幕攻击检测,默认真实性为真实
|
|
3680
|
+
this.detectionState.realness = true;
|
|
3681
|
+
}
|
|
3364
3682
|
let frontal = 1;
|
|
3365
3683
|
// 计算面部正对度,不达标则跳过当前帧
|
|
3366
3684
|
if (this.detectionState.needFrontalFace()) {
|