@sssxyd/face-liveness-detector 0.4.2-alpha.2 → 0.4.3-alpha.1
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 +486 -80
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +486 -80
- 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/photo-attack-detector.d.ts +4 -8
- 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,
|
|
@@ -1562,11 +1565,13 @@ function matToBase64Jpeg(cv, mat, quality = 0.9) {
|
|
|
1562
1565
|
class FaceMovingDetectionResult {
|
|
1563
1566
|
isMoving;
|
|
1564
1567
|
details;
|
|
1565
|
-
|
|
1566
|
-
|
|
1568
|
+
available = false;
|
|
1569
|
+
trusted = false;
|
|
1570
|
+
constructor(isMoving, details, available = true, trusted = false) {
|
|
1567
1571
|
this.isMoving = isMoving;
|
|
1568
1572
|
this.details = details;
|
|
1569
|
-
this.
|
|
1573
|
+
this.available = available;
|
|
1574
|
+
this.trusted = trusted;
|
|
1570
1575
|
}
|
|
1571
1576
|
getMessage() {
|
|
1572
1577
|
if (this.details.frameCount < 2) {
|
|
@@ -1682,7 +1687,7 @@ class FaceMovingDetector {
|
|
|
1682
1687
|
details.lastCentroidShift = this.calculateCentroidShift(lastFrame.result);
|
|
1683
1688
|
// 计算中心化坐标的变化速率(基于实际时间)
|
|
1684
1689
|
details.centroidShiftRate = this.calculateCentroidShiftRate();
|
|
1685
|
-
return new FaceMovingDetectionResult(details.isMoving, details);
|
|
1690
|
+
return new FaceMovingDetectionResult(details.isMoving, details, true, true);
|
|
1686
1691
|
}
|
|
1687
1692
|
/**
|
|
1688
1693
|
* 计算两帧之间的运动强度
|
|
@@ -1840,17 +1845,6 @@ class FaceMovingDetector {
|
|
|
1840
1845
|
const variance = squaredDiffs.reduce((a, b) => a + b) / values.length;
|
|
1841
1846
|
return Math.sqrt(variance);
|
|
1842
1847
|
}
|
|
1843
|
-
/**
|
|
1844
|
-
* 检查当前实例是否可用
|
|
1845
|
-
*
|
|
1846
|
-
* @description 通过检查帧缓冲区长度来判断实例是否处于可用状态
|
|
1847
|
-
* 当帧缓冲区长度大于等于2时,认为实例可用
|
|
1848
|
-
*
|
|
1849
|
-
* @returns {boolean} 如果实例可用返回true,否则返回false
|
|
1850
|
-
*/
|
|
1851
|
-
isAvailable() {
|
|
1852
|
-
return this.frameBuffer.length >= 2;
|
|
1853
|
-
}
|
|
1854
1848
|
/**
|
|
1855
1849
|
* 重置检测器
|
|
1856
1850
|
*/
|
|
@@ -1903,17 +1897,13 @@ class FaceMovingDetector {
|
|
|
1903
1897
|
class PhotoAttackDetectionResult {
|
|
1904
1898
|
isPhoto;
|
|
1905
1899
|
details;
|
|
1906
|
-
|
|
1907
|
-
|
|
1900
|
+
available = false;
|
|
1901
|
+
trusted = false;
|
|
1902
|
+
constructor(isPhoto, details, available = false, trusted = false) {
|
|
1908
1903
|
this.isPhoto = isPhoto;
|
|
1909
1904
|
this.details = details;
|
|
1910
|
-
this.
|
|
1911
|
-
|
|
1912
|
-
isAvailable() {
|
|
1913
|
-
return this.details.frameCount >= 3;
|
|
1914
|
-
}
|
|
1915
|
-
isTrusted() {
|
|
1916
|
-
return this.details.frameCount >= 15;
|
|
1905
|
+
this.available = available;
|
|
1906
|
+
this.trusted = trusted;
|
|
1917
1907
|
}
|
|
1918
1908
|
getMessage() {
|
|
1919
1909
|
if (this.details.frameCount < 3) {
|
|
@@ -1928,9 +1918,9 @@ class PhotoAttackDetectionResult {
|
|
|
1928
1918
|
reasons.push(`深度方差极小(${depthVar})`);
|
|
1929
1919
|
}
|
|
1930
1920
|
if (this.details.perspectiveScore > 0.5) {
|
|
1931
|
-
this.details.motionDisplacementVariance.toFixed(3);
|
|
1921
|
+
const motionVar = this.details.motionDisplacementVariance.toFixed(3);
|
|
1932
1922
|
const consistency = (this.details.motionDirectionConsistency * 100).toFixed(0);
|
|
1933
|
-
reasons.push(`运动一致性过高(${consistency}%)`);
|
|
1923
|
+
reasons.push(`运动一致性过高(${consistency}%),位移方差(${motionVar})`);
|
|
1934
1924
|
}
|
|
1935
1925
|
const reasonStr = reasons.length > 0 ? `(${reasons.join('、')})` : '';
|
|
1936
1926
|
return `检测到照片攻击${reasonStr},置信度 ${confidence}%`;
|
|
@@ -1938,8 +1928,9 @@ class PhotoAttackDetectionResult {
|
|
|
1938
1928
|
}
|
|
1939
1929
|
const DEFAULT_OPTIONS = {
|
|
1940
1930
|
frameBufferSize: 15, // 15帧 (0.5秒@30fps)
|
|
1941
|
-
|
|
1942
|
-
|
|
1931
|
+
requiredFrameCount: 15, // 可信赖所需的最小帧数
|
|
1932
|
+
depthVarianceThreshold: 0.003, // 深度方差阈值:真实人脸 > 0.005,照片 < 0.001
|
|
1933
|
+
motionVarianceThreshold: 0.015, // 运动方差阈值:真实人脸 > 0.02,照片 < 0.01
|
|
1943
1934
|
perspectiveRatioThreshold: 0.85, // 透视比率阈值:真实人脸 > 0.95,照片 < 0.85
|
|
1944
1935
|
motionConsistencyThreshold: 0.8, // 运动一致性阈值:真实人脸 < 0.5,照片 > 0.8
|
|
1945
1936
|
};
|
|
@@ -2033,7 +2024,7 @@ class PhotoAttackDetector {
|
|
|
2033
2024
|
else {
|
|
2034
2025
|
details.dominantFeature = 'perspective';
|
|
2035
2026
|
}
|
|
2036
|
-
return new PhotoAttackDetectionResult(details.isPhoto, details);
|
|
2027
|
+
return new PhotoAttackDetectionResult(details.isPhoto, details, true, this.frameBuffer.length >= this.config.requiredFrameCount);
|
|
2037
2028
|
}
|
|
2038
2029
|
/**
|
|
2039
2030
|
* 方案一:3D 深度方差分析
|
|
@@ -2356,11 +2347,382 @@ class PhotoAttackDetector {
|
|
|
2356
2347
|
reset() {
|
|
2357
2348
|
this.frameBuffer = [];
|
|
2358
2349
|
}
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
/**
|
|
2353
|
+
* 屏幕攻击检测结果
|
|
2354
|
+
*/
|
|
2355
|
+
class ScreenAttackDetectionResult {
|
|
2356
|
+
isScreenAttack;
|
|
2357
|
+
details;
|
|
2358
|
+
available = false;
|
|
2359
|
+
trusted = false;
|
|
2360
|
+
constructor(isScreenAttack, details, available = false, trusted = false) {
|
|
2361
|
+
this.isScreenAttack = isScreenAttack;
|
|
2362
|
+
this.details = details;
|
|
2363
|
+
this.available = available;
|
|
2364
|
+
this.trusted = trusted;
|
|
2365
|
+
}
|
|
2366
|
+
getMessage() {
|
|
2367
|
+
if (this.details.frameCount < 1) {
|
|
2368
|
+
return '未获得足够数据,无法进行屏幕攻击检测';
|
|
2369
|
+
}
|
|
2370
|
+
if (!this.isScreenAttack)
|
|
2371
|
+
return '';
|
|
2372
|
+
const confidence = (this.details.screenAttackConfidence * 100).toFixed(2);
|
|
2373
|
+
const reasons = [];
|
|
2374
|
+
if (this.details.moireConfidence > 0.6) {
|
|
2375
|
+
const moireScore = (this.details.moireScore * 100).toFixed(2);
|
|
2376
|
+
reasons.push(`摩尔纹特征明显(${moireScore})`);
|
|
2377
|
+
}
|
|
2378
|
+
const reasonStr = reasons.length > 0 ? `(${reasons.join('、')})` : '';
|
|
2379
|
+
return `检测到屏幕攻击${reasonStr},置信度 ${confidence}%`;
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
const DEFAULT_SCREEN_OPTIONS = {
|
|
2383
|
+
moireThreshold: 0.65,
|
|
2384
|
+
pixelGridSensitivity: 0.75,
|
|
2385
|
+
requiredFrameCount: 12,
|
|
2386
|
+
earlyDetectionThreshold: 0.8
|
|
2387
|
+
};
|
|
2388
|
+
/**
|
|
2389
|
+
* 屏幕攻击检测器
|
|
2390
|
+
*
|
|
2391
|
+
* 检测方案:
|
|
2392
|
+
* 摩尔纹/像素网格分析(检测屏幕特有的周期性图案)
|
|
2393
|
+
*/
|
|
2394
|
+
class ScreenAttackDetector {
|
|
2395
|
+
config;
|
|
2396
|
+
opencv = null;
|
|
2397
|
+
frameCount = 0;
|
|
2398
|
+
emitDebug = () => { }; // 默认空实现
|
|
2399
|
+
constructor(options) {
|
|
2400
|
+
this.config = { ...DEFAULT_SCREEN_OPTIONS, ...options };
|
|
2401
|
+
}
|
|
2359
2402
|
/**
|
|
2360
|
-
*
|
|
2403
|
+
* 设置 OpenCV 实例
|
|
2404
|
+
* @param opencv - TechStark opencv.js 实例
|
|
2361
2405
|
*/
|
|
2362
|
-
|
|
2363
|
-
|
|
2406
|
+
setOpencv(opencv) {
|
|
2407
|
+
this.opencv = opencv;
|
|
2408
|
+
}
|
|
2409
|
+
/**
|
|
2410
|
+
* 设置 emitDebug 方法(依赖注入)
|
|
2411
|
+
* @param emitDebugFn - 来自 FaceDetectionEngine 的 emitDebug 方法
|
|
2412
|
+
*/
|
|
2413
|
+
setEmitDebug(emitDebugFn) {
|
|
2414
|
+
this.emitDebug = emitDebugFn;
|
|
2415
|
+
}
|
|
2416
|
+
/**
|
|
2417
|
+
* 检测屏幕攻击
|
|
2418
|
+
* @param faceBox - 人脸区域框
|
|
2419
|
+
* @param colorMat - 彩色图像矩阵
|
|
2420
|
+
* @param grayMat - 灰度图像矩阵
|
|
2421
|
+
* @returns 检测结果
|
|
2422
|
+
*/
|
|
2423
|
+
detect(colorMat, grayMat) {
|
|
2424
|
+
this.frameCount += 1;
|
|
2425
|
+
const details = {
|
|
2426
|
+
frameCount: this.frameCount,
|
|
2427
|
+
moireScore: 0,
|
|
2428
|
+
pixelGridStrength: 0,
|
|
2429
|
+
moireConfidence: 0,
|
|
2430
|
+
isScreenAttack: false,
|
|
2431
|
+
screenAttackConfidence: 0,
|
|
2432
|
+
feature: 'moire',
|
|
2433
|
+
debugInfo: {}
|
|
2434
|
+
};
|
|
2435
|
+
// 检查OpenCV是否已设置
|
|
2436
|
+
if (!this.opencv) {
|
|
2437
|
+
this.emitDebug('screen-attack', 'OpenCV未初始化', {}, 'warn');
|
|
2438
|
+
return new ScreenAttackDetectionResult(false, details);
|
|
2439
|
+
}
|
|
2440
|
+
try {
|
|
2441
|
+
// ============ 摩尔纹/像素网格分析 ============
|
|
2442
|
+
const moireAnalysis = this.analyzeMoirePattern(grayMat);
|
|
2443
|
+
details.moireScore = moireAnalysis.score;
|
|
2444
|
+
details.pixelGridStrength = moireAnalysis.pixelGridStrength;
|
|
2445
|
+
details.moireConfidence = moireAnalysis.confidence;
|
|
2446
|
+
details.debugInfo.moireAnalysis = moireAnalysis.debug;
|
|
2447
|
+
// 早期检测:如果摩尔纹分数过高,直接判定为屏幕攻击
|
|
2448
|
+
if (details.moireScore > this.config.earlyDetectionThreshold) {
|
|
2449
|
+
details.isScreenAttack = true;
|
|
2450
|
+
details.screenAttackConfidence = details.moireScore;
|
|
2451
|
+
details.feature = 'moire';
|
|
2452
|
+
this.emitDebug('screen-attack', '摩尔纹分析早期检测触发', {
|
|
2453
|
+
moireScore: details.moireScore,
|
|
2454
|
+
earlyThreshold: this.config.earlyDetectionThreshold
|
|
2455
|
+
});
|
|
2456
|
+
return new ScreenAttackDetectionResult(true, details, true, true);
|
|
2457
|
+
}
|
|
2458
|
+
// 基于摩尔纹置信度的判定
|
|
2459
|
+
details.isScreenAttack = details.moireConfidence > this.config.moireThreshold;
|
|
2460
|
+
details.screenAttackConfidence = details.moireScore;
|
|
2461
|
+
this.emitDebug('screen-attack', '摩尔纹分析完成', {
|
|
2462
|
+
moireScore: details.moireScore,
|
|
2463
|
+
isScreenAttack: details.isScreenAttack
|
|
2464
|
+
});
|
|
2465
|
+
return new ScreenAttackDetectionResult(details.isScreenAttack, details, true, this.frameCount >= this.config.requiredFrameCount);
|
|
2466
|
+
}
|
|
2467
|
+
catch (error) {
|
|
2468
|
+
this.emitDebug('screen-attack', `检测过程中发生错误: ${error}`, {}, 'error');
|
|
2469
|
+
return new ScreenAttackDetectionResult(false, details, false, false);
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
/**
|
|
2473
|
+
* 摩尔纹/像素网格分析
|
|
2474
|
+
*
|
|
2475
|
+
* 原理:
|
|
2476
|
+
* - 真实人脸:纹理在频域中分布较为随机
|
|
2477
|
+
* - 屏幕显示:像素阵列在频域中产生特有的摩尔纹和周期性图案
|
|
2478
|
+
* - 摄像头采样与屏幕像素网格的频率混叠效应
|
|
2479
|
+
*/
|
|
2480
|
+
analyzeMoirePattern(grayMat) {
|
|
2481
|
+
const cv = this.opencv;
|
|
2482
|
+
let debugInfo = {};
|
|
2483
|
+
try {
|
|
2484
|
+
// 分析整个图像,而不是特定区域
|
|
2485
|
+
const analysisMat = grayMat;
|
|
2486
|
+
// 确保图像是浮点类型用于FFT
|
|
2487
|
+
const floatMat = new cv.Mat();
|
|
2488
|
+
analysisMat.convertTo(floatMat, cv.CV_32F);
|
|
2489
|
+
// 创建复数矩阵用于FFT
|
|
2490
|
+
const planes = new cv.MatVector();
|
|
2491
|
+
const zeros = cv.Mat.zeros(floatMat.rows, floatMat.cols, cv.CV_32F);
|
|
2492
|
+
planes.push_back(floatMat);
|
|
2493
|
+
planes.push_back(zeros);
|
|
2494
|
+
// 执行2D FFT
|
|
2495
|
+
const complexI = new cv.Mat();
|
|
2496
|
+
cv.merge(planes, complexI);
|
|
2497
|
+
cv.dft(complexI, complexI, cv.DFT_COMPLEX_OUTPUT);
|
|
2498
|
+
// 计算幅度谱
|
|
2499
|
+
const mag = new cv.Mat();
|
|
2500
|
+
const planesVec = new cv.MatVector();
|
|
2501
|
+
cv.split(complexI, planesVec);
|
|
2502
|
+
const real = planesVec.get(0);
|
|
2503
|
+
const imag = planesVec.get(1);
|
|
2504
|
+
// 计算幅度: sqrt(real^2 + imag^2)
|
|
2505
|
+
cv.magnitude(real, imag, mag);
|
|
2506
|
+
// 转换为对数尺度以便可视化
|
|
2507
|
+
const matShift = new cv.Mat();
|
|
2508
|
+
mag.convertTo(matShift, cv.CV_32F);
|
|
2509
|
+
// 对数变换
|
|
2510
|
+
cv.add(cv.ones(mag.rows, mag.cols, cv.CV_32F), matShift, matShift);
|
|
2511
|
+
cv.log(matShift, matShift);
|
|
2512
|
+
// 重新排列四象限,使零频率分量位于中心
|
|
2513
|
+
const cx = Math.floor(matShift.cols / 2);
|
|
2514
|
+
const cy = Math.floor(matShift.rows / 2);
|
|
2515
|
+
const q0 = matShift.roi(new cv.Rect(0, 0, cx, cy));
|
|
2516
|
+
const q1 = matShift.roi(new cv.Rect(cx, 0, cx, cy));
|
|
2517
|
+
const q2 = matShift.roi(new cv.Rect(0, cy, cx, cy));
|
|
2518
|
+
const q3 = matShift.roi(new cv.Rect(cx, cy, cx, cy));
|
|
2519
|
+
const tmp = new cv.Mat();
|
|
2520
|
+
q0.copyTo(tmp);
|
|
2521
|
+
q3.copyTo(q0);
|
|
2522
|
+
tmp.copyTo(q3);
|
|
2523
|
+
q1.copyTo(tmp);
|
|
2524
|
+
q2.copyTo(q1);
|
|
2525
|
+
tmp.copyTo(q2);
|
|
2526
|
+
// 专门检测摩尔纹特征
|
|
2527
|
+
// 屏幕像素网格在频域中产生同心圆状的规律性模式
|
|
2528
|
+
const moireScore = this.analyzeMoireCharacteristics(matShift);
|
|
2529
|
+
const pixelGridStrength = this.calculatePixelGridStrength(matShift);
|
|
2530
|
+
// 综合分析摩尔纹特征
|
|
2531
|
+
let finalScore = 0;
|
|
2532
|
+
let patternStrength = 0;
|
|
2533
|
+
// 基于同心圆模式的分析
|
|
2534
|
+
if (moireScore > 0.3) {
|
|
2535
|
+
finalScore += moireScore * 0.6;
|
|
2536
|
+
}
|
|
2537
|
+
// 基于像素网格强度的分析
|
|
2538
|
+
if (pixelGridStrength > 0.4) {
|
|
2539
|
+
finalScore += pixelGridStrength * 0.4;
|
|
2540
|
+
}
|
|
2541
|
+
// 检查是否有清晰的周期性峰值
|
|
2542
|
+
const peaks = this.findMoirePeaks(matShift);
|
|
2543
|
+
const peakCount = peaks.length;
|
|
2544
|
+
if (peakCount > 15) { // 高峰值数量表明存在明显的摩尔纹
|
|
2545
|
+
finalScore += Math.min(0.3, peakCount / 100);
|
|
2546
|
+
}
|
|
2547
|
+
patternStrength = Math.max(moireScore, pixelGridStrength);
|
|
2548
|
+
// 限制分数范围
|
|
2549
|
+
finalScore = Math.min(1, Math.max(0, finalScore));
|
|
2550
|
+
// 计算置信度
|
|
2551
|
+
const confidence = finalScore * this.config.pixelGridSensitivity;
|
|
2552
|
+
debugInfo = {
|
|
2553
|
+
moireScore,
|
|
2554
|
+
pixelGridStrength,
|
|
2555
|
+
peakCount,
|
|
2556
|
+
patternStrength,
|
|
2557
|
+
rawScore: finalScore
|
|
2558
|
+
};
|
|
2559
|
+
// 释放内存
|
|
2560
|
+
floatMat.delete();
|
|
2561
|
+
complexI.delete();
|
|
2562
|
+
mag.delete();
|
|
2563
|
+
matShift.delete();
|
|
2564
|
+
planes.delete();
|
|
2565
|
+
zeros.delete();
|
|
2566
|
+
planesVec.delete();
|
|
2567
|
+
real.delete();
|
|
2568
|
+
imag.delete();
|
|
2569
|
+
tmp.delete();
|
|
2570
|
+
return {
|
|
2571
|
+
score: finalScore,
|
|
2572
|
+
pixelGridStrength,
|
|
2573
|
+
confidence,
|
|
2574
|
+
debug: debugInfo
|
|
2575
|
+
};
|
|
2576
|
+
}
|
|
2577
|
+
catch (error) {
|
|
2578
|
+
this.emitDebug('screen-attack-moire', `摩尔纹分析出错: ${error}`, {}, 'error');
|
|
2579
|
+
return { score: 0, pixelGridStrength: 0, confidence: 0, debug: {} };
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
/**
|
|
2583
|
+
* 分析频域中的摩尔纹特征
|
|
2584
|
+
*/
|
|
2585
|
+
analyzeMoireCharacteristics(spectrum) {
|
|
2586
|
+
this.opencv;
|
|
2587
|
+
const centerX = Math.floor(spectrum.cols / 2);
|
|
2588
|
+
const centerY = Math.floor(spectrum.rows / 2);
|
|
2589
|
+
// 计算径向剖面图以检测同心圆模式
|
|
2590
|
+
const radialProfile = this.computeRadialProfile(spectrum, centerX, centerY);
|
|
2591
|
+
// 检查径向剖面的规律性(同心圆特征)
|
|
2592
|
+
let regularityScore = 0;
|
|
2593
|
+
Math.max(1, Math.floor(radialProfile.length / 10));
|
|
2594
|
+
// 计算径向剖面的自相关性来检测规律性
|
|
2595
|
+
const autoCorrelation = this.calculateAutoCorrelation(radialProfile);
|
|
2596
|
+
// 寻找主要的周期性模式
|
|
2597
|
+
let maxPeak = 0;
|
|
2598
|
+
for (let i = 1; i < autoCorrelation.length / 4; i++) {
|
|
2599
|
+
if (autoCorrelation[i] > maxPeak) {
|
|
2600
|
+
maxPeak = autoCorrelation[i];
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
// 根据最大峰值确定摩尔纹特征强度
|
|
2604
|
+
regularityScore = Math.min(1, maxPeak / 10.0); // 归一化
|
|
2605
|
+
return regularityScore;
|
|
2606
|
+
}
|
|
2607
|
+
/**
|
|
2608
|
+
* 计算像素网格强度
|
|
2609
|
+
*/
|
|
2610
|
+
calculatePixelGridStrength(spectrum) {
|
|
2611
|
+
const cv = this.opencv;
|
|
2612
|
+
// 计算频谱的标准差 - 高标准差可能表示周期性结构
|
|
2613
|
+
const meanStddev = { mean: new cv.Scalar(), stddev: new cv.Scalar() };
|
|
2614
|
+
cv.meanStdDev(spectrum, meanStddev.mean, meanStddev.stddev);
|
|
2615
|
+
const spectrumStdDev = meanStddev.stddev.data64F[0];
|
|
2616
|
+
// 归一化标准差到0-1范围
|
|
2617
|
+
const normalizedStdDev = Math.min(1, spectrumStdDev / 5.0);
|
|
2618
|
+
return normalizedStdDev;
|
|
2619
|
+
}
|
|
2620
|
+
/**
|
|
2621
|
+
* 查找频域中的摩尔纹峰值
|
|
2622
|
+
*/
|
|
2623
|
+
findMoirePeaks(spectrum) {
|
|
2624
|
+
const cv = this.opencv;
|
|
2625
|
+
const peaks = [];
|
|
2626
|
+
// 使用局部最大值检测
|
|
2627
|
+
const kernel = cv.Mat.ones(3, 3, cv.CV_8UC1);
|
|
2628
|
+
// 膨胀操作以找到局部最大值
|
|
2629
|
+
const dilated = new cv.Mat();
|
|
2630
|
+
cv.dilate(spectrum, dilated, kernel);
|
|
2631
|
+
// 比较原图和膨胀后的图,相等的位置即为局部最大值
|
|
2632
|
+
const localMaxMask = new cv.Mat();
|
|
2633
|
+
cv.compare(spectrum, dilated, localMaxMask, cv.CMP_EQ);
|
|
2634
|
+
// 查找非零点(即峰值位置)
|
|
2635
|
+
for (let y = 1; y < spectrum.rows - 1; y++) {
|
|
2636
|
+
for (let x = 1; x < spectrum.cols - 1; x++) {
|
|
2637
|
+
if (localMaxMask.ucharPtr(y, x)[0] !== 0) {
|
|
2638
|
+
try {
|
|
2639
|
+
// 使用floatPtr访问浮点值
|
|
2640
|
+
const ptrValue = spectrum.floatPtr(y, x);
|
|
2641
|
+
if (ptrValue && ptrValue.length > 0) {
|
|
2642
|
+
const val = ptrValue[0];
|
|
2643
|
+
// 只保留显著的峰值
|
|
2644
|
+
if (val > 3.0) { // 阈值可根据实际调试调整
|
|
2645
|
+
peaks.push({ x, y, value: val });
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
catch (e) {
|
|
2650
|
+
// fallback to other method
|
|
2651
|
+
try {
|
|
2652
|
+
const val = spectrum.data32F[y * spectrum.cols + x];
|
|
2653
|
+
if (val > 3.0) {
|
|
2654
|
+
peaks.push({ x, y, value: val });
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
catch (e2) {
|
|
2658
|
+
console.warn("Could not access pixel value at", x, y);
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
// 释放内存
|
|
2665
|
+
kernel.delete();
|
|
2666
|
+
dilated.delete();
|
|
2667
|
+
localMaxMask.delete();
|
|
2668
|
+
return peaks;
|
|
2669
|
+
}
|
|
2670
|
+
/**
|
|
2671
|
+
* 计算径向剖面图
|
|
2672
|
+
*/
|
|
2673
|
+
computeRadialProfile(spectrum, centerX, centerY) {
|
|
2674
|
+
this.opencv;
|
|
2675
|
+
const profile = [];
|
|
2676
|
+
const maxRadius = Math.min(centerX, centerY);
|
|
2677
|
+
for (let r = 0; r < maxRadius; r++) {
|
|
2678
|
+
let sum = 0;
|
|
2679
|
+
let count = 0;
|
|
2680
|
+
for (let angle = 0; angle < 360; angle += 5) { // 每5度采样一次
|
|
2681
|
+
const rad = angle * Math.PI / 180;
|
|
2682
|
+
const x = Math.round(centerX + r * Math.cos(rad));
|
|
2683
|
+
const y = Math.round(centerY + r * Math.sin(rad));
|
|
2684
|
+
if (x >= 0 && x < spectrum.cols && y >= 0 && y < spectrum.rows) {
|
|
2685
|
+
try {
|
|
2686
|
+
const value = spectrum.floatPtr(y, x)[0];
|
|
2687
|
+
sum += value;
|
|
2688
|
+
count++;
|
|
2689
|
+
}
|
|
2690
|
+
catch (e) {
|
|
2691
|
+
try {
|
|
2692
|
+
const value = spectrum.data32F[y * spectrum.cols + x];
|
|
2693
|
+
sum += value;
|
|
2694
|
+
count++;
|
|
2695
|
+
}
|
|
2696
|
+
catch (e2) {
|
|
2697
|
+
// 忽略错误
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
profile.push(count > 0 ? sum / count : 0);
|
|
2703
|
+
}
|
|
2704
|
+
return profile;
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* 计算数组的自相关
|
|
2708
|
+
*/
|
|
2709
|
+
calculateAutoCorrelation(data) {
|
|
2710
|
+
const n = data.length;
|
|
2711
|
+
const result = new Array(n).fill(0);
|
|
2712
|
+
for (let lag = 0; lag < n; lag++) {
|
|
2713
|
+
let sum = 0;
|
|
2714
|
+
for (let i = 0; i < n - lag; i++) {
|
|
2715
|
+
sum += data[i] * data[i + lag];
|
|
2716
|
+
}
|
|
2717
|
+
result[lag] = sum / (n - lag);
|
|
2718
|
+
}
|
|
2719
|
+
return result;
|
|
2720
|
+
}
|
|
2721
|
+
/**
|
|
2722
|
+
* 重置检测器
|
|
2723
|
+
*/
|
|
2724
|
+
reset() {
|
|
2725
|
+
this.frameCount = 0;
|
|
2364
2726
|
}
|
|
2365
2727
|
}
|
|
2366
2728
|
|
|
@@ -2380,7 +2742,9 @@ class DetectionState {
|
|
|
2380
2742
|
lastFrontalScore = 1;
|
|
2381
2743
|
faceMovingDetector = null;
|
|
2382
2744
|
photoAttackDetector = null;
|
|
2745
|
+
screenAttachDetector = null;
|
|
2383
2746
|
liveness = false;
|
|
2747
|
+
realness = false;
|
|
2384
2748
|
constructor(options) {
|
|
2385
2749
|
Object.assign(this, options);
|
|
2386
2750
|
}
|
|
@@ -2388,11 +2752,17 @@ class DetectionState {
|
|
|
2388
2752
|
this.clearActionVerifyTimeout();
|
|
2389
2753
|
const savedFaceMovingDetector = this.faceMovingDetector;
|
|
2390
2754
|
const savedPhotoAttackDetector = this.photoAttackDetector;
|
|
2755
|
+
const savedScreenAttackDetector = this.screenAttachDetector;
|
|
2391
2756
|
savedFaceMovingDetector?.reset();
|
|
2392
2757
|
savedPhotoAttackDetector?.reset();
|
|
2758
|
+
savedScreenAttackDetector?.reset();
|
|
2393
2759
|
Object.assign(this, new DetectionState({}));
|
|
2394
2760
|
this.faceMovingDetector = savedFaceMovingDetector;
|
|
2395
2761
|
this.photoAttackDetector = savedPhotoAttackDetector;
|
|
2762
|
+
this.screenAttachDetector = savedScreenAttackDetector;
|
|
2763
|
+
}
|
|
2764
|
+
setOpenCv(opencv) {
|
|
2765
|
+
this.screenAttachDetector?.setOpencv(opencv);
|
|
2396
2766
|
}
|
|
2397
2767
|
// 默认方法
|
|
2398
2768
|
needFrontalFace() {
|
|
@@ -2401,7 +2771,7 @@ class DetectionState {
|
|
|
2401
2771
|
// 是否准备好进行动作验证
|
|
2402
2772
|
isReadyToVerify(minCollectCount) {
|
|
2403
2773
|
if (this.period === DetectionPeriod.COLLECT
|
|
2404
|
-
&& this.liveness
|
|
2774
|
+
&& this.liveness && this.realness
|
|
2405
2775
|
&& this.collectCount >= minCollectCount) {
|
|
2406
2776
|
return true;
|
|
2407
2777
|
}
|
|
@@ -2439,6 +2809,9 @@ function createDetectionState(engine) {
|
|
|
2439
2809
|
detectionState.faceMovingDetector.setEmitDebug(engine.emitDebug.bind(engine));
|
|
2440
2810
|
detectionState.photoAttackDetector = new PhotoAttackDetector();
|
|
2441
2811
|
detectionState.photoAttackDetector.setEmitDebug(engine.emitDebug.bind(engine));
|
|
2812
|
+
detectionState.screenAttachDetector = new ScreenAttackDetector();
|
|
2813
|
+
detectionState.screenAttachDetector.setOpencv(cv);
|
|
2814
|
+
detectionState.screenAttachDetector.setEmitDebug(engine.emitDebug.bind(engine));
|
|
2442
2815
|
return detectionState;
|
|
2443
2816
|
}
|
|
2444
2817
|
|
|
@@ -2552,6 +2925,7 @@ class FaceDetectionEngine extends SimpleEventEmitter {
|
|
|
2552
2925
|
}
|
|
2553
2926
|
this.options = mergeOptions(options);
|
|
2554
2927
|
this.detectionState = createDetectionState(this);
|
|
2928
|
+
this.detectionState.setOpenCv(this.cv);
|
|
2555
2929
|
this.emitDebug('config', 'Engine options updated', { wasDetecting }, 'info');
|
|
2556
2930
|
}
|
|
2557
2931
|
getEngineState() {
|
|
@@ -2790,6 +3164,7 @@ class FaceDetectionEngine extends SimpleEventEmitter {
|
|
|
2790
3164
|
if (!this.transitionEngineState(EngineState.READY, 'initialize() success')) {
|
|
2791
3165
|
throw new Error('Failed to transition to READY state');
|
|
2792
3166
|
}
|
|
3167
|
+
this.detectionState.setOpenCv(this.cv);
|
|
2793
3168
|
const loadedData = {
|
|
2794
3169
|
success: true,
|
|
2795
3170
|
opencv_version: getOpenCVVersion(),
|
|
@@ -3282,6 +3657,16 @@ class FaceDetectionEngine extends SimpleEventEmitter {
|
|
|
3282
3657
|
this.stopDetection(false);
|
|
3283
3658
|
return;
|
|
3284
3659
|
}
|
|
3660
|
+
if (!this.detectionState.screenAttachDetector) {
|
|
3661
|
+
this.emit('detector-error', {
|
|
3662
|
+
code: ErrorCode.INTERNAL_ERROR,
|
|
3663
|
+
message: 'Screen attack detector is not initialized'
|
|
3664
|
+
});
|
|
3665
|
+
// Clear the detecting flag before stopping to avoid deadlock
|
|
3666
|
+
this.isDetectingFrameActive = false;
|
|
3667
|
+
this.stopDetection(false);
|
|
3668
|
+
return;
|
|
3669
|
+
}
|
|
3285
3670
|
try {
|
|
3286
3671
|
// 动作活体检测阶段处理
|
|
3287
3672
|
if (this.detectionState.period === DetectionPeriod.VERIFY) {
|
|
@@ -3302,55 +3687,51 @@ class FaceDetectionEngine extends SimpleEventEmitter {
|
|
|
3302
3687
|
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
3688
|
return;
|
|
3304
3689
|
}
|
|
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;
|
|
3690
|
+
// 开启面部移动检测
|
|
3691
|
+
if (this.options.enable_face_moving_detection) {
|
|
3692
|
+
this.detectionState.faceMovingDetector.addFrame(face, timestamp);
|
|
3693
|
+
const faceMovingResult = this.detectionState.faceMovingDetector.detect();
|
|
3694
|
+
if (faceMovingResult.available) {
|
|
3695
|
+
if (!faceMovingResult.isMoving) {
|
|
3696
|
+
// 面部移动检测失败,可能为照片攻击
|
|
3697
|
+
this.emitDebug('motion-detection', 'Face moving detection failed - possible photo attack', faceMovingResult.details, 'warn');
|
|
3698
|
+
this.emitDetectorInfo({
|
|
3699
|
+
code: DetectionCode.FACE_NOT_MOVING,
|
|
3700
|
+
message: faceMovingResult.getMessage(),
|
|
3701
|
+
});
|
|
3702
|
+
this.partialResetDetectionState();
|
|
3703
|
+
return;
|
|
3704
|
+
}
|
|
3342
3705
|
}
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3706
|
+
}
|
|
3707
|
+
// 开启照片攻击检测
|
|
3708
|
+
if (this.options.enable_photo_attack_detection) {
|
|
3709
|
+
this.detectionState.photoAttackDetector.addFrame(face);
|
|
3710
|
+
const photoAttackResult = this.detectionState.photoAttackDetector.detect();
|
|
3711
|
+
if (photoAttackResult.available) {
|
|
3712
|
+
// 照片攻击检测可用(仅当判定为照片攻击时)
|
|
3713
|
+
if (photoAttackResult.isPhoto) {
|
|
3714
|
+
this.emitDetectorInfo({
|
|
3715
|
+
code: DetectionCode.PHOTO_ATTACK_DETECTED,
|
|
3716
|
+
message: photoAttackResult.getMessage(),
|
|
3717
|
+
});
|
|
3718
|
+
this.emitDebug('motion-detection', 'Photo attack detected', photoAttackResult.details, 'warn');
|
|
3719
|
+
this.partialResetDetectionState();
|
|
3720
|
+
return;
|
|
3721
|
+
}
|
|
3722
|
+
else {
|
|
3723
|
+
if (photoAttackResult.trusted) {
|
|
3724
|
+
// 仅当采集到足够帧,且判定为非照片攻击时,才采信
|
|
3725
|
+
this.detectionState.liveness = true;
|
|
3726
|
+
this.emitDebug('motion-detection', 'Photo attack detection passed - face is live', photoAttackResult.details, 'warn');
|
|
3727
|
+
}
|
|
3351
3728
|
}
|
|
3352
3729
|
}
|
|
3353
3730
|
}
|
|
3731
|
+
else {
|
|
3732
|
+
// 未启用照片攻击检测,默认活体为活体
|
|
3733
|
+
this.detectionState.liveness = true;
|
|
3734
|
+
}
|
|
3354
3735
|
// 捕获并准备帧数据
|
|
3355
3736
|
const frameData = this.captureAndPrepareFrames();
|
|
3356
3737
|
if (!frameData) {
|
|
@@ -3361,6 +3742,31 @@ class FaceDetectionEngine extends SimpleEventEmitter {
|
|
|
3361
3742
|
}
|
|
3362
3743
|
const bgrFrame = frameData.bgrFrame;
|
|
3363
3744
|
const grayFrame = frameData.grayFrame;
|
|
3745
|
+
if (this.options.enable_screen_attack_detection) {
|
|
3746
|
+
const screenAttackResult = this.detectionState.screenAttachDetector.detect(bgrFrame, grayFrame);
|
|
3747
|
+
if (screenAttackResult.available) {
|
|
3748
|
+
if (screenAttackResult.isScreenAttack) {
|
|
3749
|
+
this.emitDetectorInfo({
|
|
3750
|
+
code: DetectionCode.SCREEN_ATTACK_DETECTED,
|
|
3751
|
+
message: screenAttackResult.getMessage(),
|
|
3752
|
+
});
|
|
3753
|
+
this.emitDebug('motion-detection', 'Screen attack detected', screenAttackResult.details, 'warn');
|
|
3754
|
+
this.partialResetDetectionState();
|
|
3755
|
+
return;
|
|
3756
|
+
}
|
|
3757
|
+
else {
|
|
3758
|
+
if (screenAttackResult.trusted) {
|
|
3759
|
+
// 仅当采集到足够帧,且判定为非屏幕攻击时,才采信
|
|
3760
|
+
this.detectionState.realness = true;
|
|
3761
|
+
this.emitDebug('motion-detection', 'Screen attack detection passed - face is real', screenAttackResult.details, 'warn');
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
}
|
|
3765
|
+
}
|
|
3766
|
+
else {
|
|
3767
|
+
// 未启用屏幕攻击检测,默认真实性为真实
|
|
3768
|
+
this.detectionState.realness = true;
|
|
3769
|
+
}
|
|
3364
3770
|
let frontal = 1;
|
|
3365
3771
|
// 计算面部正对度,不达标则跳过当前帧
|
|
3366
3772
|
if (this.detectionState.needFrontalFace()) {
|