@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 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["PLEASE_MOVING_FACE"] = "PLEASE_MOVING_FACE";
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 originalOnRuntimeInitialized = cvModule.onRuntimeInitialized(cvModule).onRuntimeInitialized = () => {
1116
+ const originalCallback = cvModule.onRuntimeInitialized;
1117
+ const newCallback = () => {
1114
1118
  console.log('[FaceDetectionEngine] onRuntimeInitialized callback triggered');
1115
1119
  // 调用原始回调(如果存在)
1116
- if (originalOnRuntimeInitialized && typeof originalOnRuntimeInitialized === 'function') {
1120
+ if (originalCallback && typeof originalCallback === 'function') {
1117
1121
  try {
1118
- originalOnRuntimeInitialized();
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
- debug;
1566
- constructor(isMoving, details, debug = {}) {
1570
+ available = false;
1571
+ trusted = false;
1572
+ constructor(isMoving, details, available = false) {
1567
1573
  this.isMoving = isMoving;
1568
1574
  this.details = details;
1569
- this.debug = debug;
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
- debug;
1907
- constructor(isPhoto, details, debug = {}) {
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.debug = debug;
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
- depthVarianceThreshold: 0.001, // 深度方差阈值:真实人脸 > 0.005,照片 < 0.001
1942
- motionVarianceThreshold: 0.01, // 运动方差阈值:真实人脸 > 0.02,照片 < 0.01
1943
- perspectiveRatioThreshold: 0.85, // 透视比率阈值:真实人脸 > 0.95,照片 < 0.85
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
- // ============ 方案一: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
- // ============ 方案二:运动透视一致性检验 ============
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
- 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';
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
- const ratio_indicator = Math.max(0, 1 - Math.abs(perspectiveRatio - 1) / (1 - this.config.perspectiveRatioThreshold));
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
- getFrameCount() {
2363
- return this.frameBuffer.length;
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
- 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) {
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
- else {
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');
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()) {