@sssxyd/face-liveness-detector 0.4.1-beta.6 → 0.4.1-beta.8
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 +595 -144
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +595 -144
- package/dist/index.js.map +1 -1
- package/dist/types/enums.d.ts +0 -1
- package/dist/types/enums.d.ts.map +1 -1
- package/dist/types/face-detection-engine.d.ts.map +1 -1
- package/dist/types/motion-liveness-detector.d.ts +158 -22
- package/dist/types/motion-liveness-detector.d.ts.map +1 -1
- package/dist/types/types.d.ts +2 -2
- package/dist/types/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.esm.js
CHANGED
|
@@ -41,7 +41,6 @@ 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_REAL"] = "FACE_NOT_REAL";
|
|
45
44
|
DetectionCode["FACE_NOT_LIVE"] = "FACE_NOT_LIVE";
|
|
46
45
|
DetectionCode["FACE_LOW_QUALITY"] = "FACE_LOW_QUALITY";
|
|
47
46
|
DetectionCode["FACE_CHECK_PASS"] = "FACE_CHECK_PASS";
|
|
@@ -109,7 +108,7 @@ const DEFAULT_OPTIONS$1 = {
|
|
|
109
108
|
action_liveness_action_list: [LivenessAction.BLINK, LivenessAction.MOUTH_OPEN, LivenessAction.NOD_DOWN, LivenessAction.NOD_UP],
|
|
110
109
|
action_liveness_action_count: 1,
|
|
111
110
|
action_liveness_action_randomize: true,
|
|
112
|
-
action_liveness_verify_timeout:
|
|
111
|
+
action_liveness_verify_timeout: 15000,
|
|
113
112
|
action_liveness_min_mouth_open_percent: 0.2,
|
|
114
113
|
};
|
|
115
114
|
/**
|
|
@@ -1634,8 +1633,11 @@ class MotionLivenessDetector {
|
|
|
1634
1633
|
getOptions() {
|
|
1635
1634
|
return this.config;
|
|
1636
1635
|
}
|
|
1637
|
-
|
|
1638
|
-
return this.normalizedLandmarksHistory.length >= 5;
|
|
1636
|
+
collectedMinFrames() {
|
|
1637
|
+
return this.normalizedLandmarksHistory.length >= 5;
|
|
1638
|
+
}
|
|
1639
|
+
collectedFullFrames() {
|
|
1640
|
+
return this.normalizedLandmarksHistory.length >= this.config.frameBufferSize;
|
|
1639
1641
|
}
|
|
1640
1642
|
reset() {
|
|
1641
1643
|
this.eyeAspectRatioHistory = [];
|
|
@@ -1683,7 +1685,7 @@ class MotionLivenessDetector {
|
|
|
1683
1685
|
});
|
|
1684
1686
|
}
|
|
1685
1687
|
// 数据不足时,继续收集
|
|
1686
|
-
if (!this.
|
|
1688
|
+
if (!this.collectedMinFrames()) {
|
|
1687
1689
|
return this.createEmptyResult({
|
|
1688
1690
|
reason: '数据收集中,帧数不足',
|
|
1689
1691
|
collectedFrames: this.normalizedLandmarksHistory.length
|
|
@@ -1700,7 +1702,7 @@ class MotionLivenessDetector {
|
|
|
1700
1702
|
// 综合判定(结合正向和逆向检测)
|
|
1701
1703
|
const isLively = this.makeLivenessDecision(eyeActivity, mouthActivity, muscleActivity, photoGeometryResult);
|
|
1702
1704
|
return new MotionDetectionResult(isLively, {
|
|
1703
|
-
frameCount: Math.max(this.eyeAspectRatioHistory.length, this.mouthAspectRatioHistory.length),
|
|
1705
|
+
frameCount: Math.max(this.eyeAspectRatioHistory.length, this.mouthAspectRatioHistory.length, this.faceLandmarksHistory.length, this.normalizedLandmarksHistory.length),
|
|
1704
1706
|
// 正向检测结果(生物特征)
|
|
1705
1707
|
eyeAspectRatioStdDev: eyeActivity.stdDev,
|
|
1706
1708
|
mouthAspectRatioStdDev: mouthActivity.stdDev,
|
|
@@ -1893,10 +1895,13 @@ class MotionLivenessDetector {
|
|
|
1893
1895
|
* 检测面部肌肉的微动(关键点位置微妙变化)
|
|
1894
1896
|
* 关键:允许刚性运动+生物特征(真人摇头),拒绝纯刚性运动(照片旋转)
|
|
1895
1897
|
*
|
|
1896
|
-
*
|
|
1898
|
+
* 【合理使用归一化坐标】这里使用归一化坐标是有意义的,因为:
|
|
1899
|
+
* - 目的是检测肌肉的【相对运动幅度】,与人脸尺寸无关
|
|
1900
|
+
* - 消除人脸在画面中位置变化的影响
|
|
1901
|
+
* - 用相对于人脸的比例运动来判断肌肉活动
|
|
1897
1902
|
*/
|
|
1898
1903
|
detectMuscleMovement() {
|
|
1899
|
-
//
|
|
1904
|
+
// 使用归一化坐标历史,消除人脸位置和尺寸影响
|
|
1900
1905
|
if (this.normalizedLandmarksHistory.length < 2) {
|
|
1901
1906
|
return { score: 0, variation: 0, hasMovement: false };
|
|
1902
1907
|
}
|
|
@@ -1916,7 +1921,7 @@ class MotionLivenessDetector {
|
|
|
1916
1921
|
127, 356 // 脸颊
|
|
1917
1922
|
];
|
|
1918
1923
|
const distances = [];
|
|
1919
|
-
//
|
|
1924
|
+
// 使用归一化坐标计算相对位移
|
|
1920
1925
|
for (let i = 1; i < this.normalizedLandmarksHistory.length; i++) {
|
|
1921
1926
|
const prevFrame = this.normalizedLandmarksHistory[i - 1];
|
|
1922
1927
|
const currFrame = this.normalizedLandmarksHistory[i];
|
|
@@ -2017,12 +2022,14 @@ class MotionLivenessDetector {
|
|
|
2017
2022
|
* - 照片所有关键点运动是【刚性的】→ 所有点以相同方向、相似幅度移动
|
|
2018
2023
|
* - 活体肌肉运动是【非刚性的】→ 不同部位独立运动(眼睛、嘴、脸颊等)
|
|
2019
2024
|
*
|
|
2020
|
-
*
|
|
2025
|
+
* 【合理使用归一化坐标】这里使用归一化坐标是有意义的,因为:
|
|
2026
|
+
* - 消除人脸在画面中的平移,只关注人脸内部的相对运动模式
|
|
2027
|
+
* - 检测的是运动向量的方向一致性,不依赖绝对坐标
|
|
2021
2028
|
*
|
|
2022
2029
|
* 返回值 0-1:值越接近1说明是刚性运动(照片运动)
|
|
2023
2030
|
*/
|
|
2024
2031
|
detectRigidMotion() {
|
|
2025
|
-
//
|
|
2032
|
+
// 使用归一化坐标历史,消除平移影响
|
|
2026
2033
|
if (this.normalizedLandmarksHistory.length < 2) {
|
|
2027
2034
|
return 0; // 数据不足,不判定为刚性运动
|
|
2028
2035
|
}
|
|
@@ -2035,7 +2042,7 @@ class MotionLivenessDetector {
|
|
|
2035
2042
|
61, 291 // 嘴角
|
|
2036
2043
|
];
|
|
2037
2044
|
const motionVectors = [];
|
|
2038
|
-
//
|
|
2045
|
+
// 使用最近两帧计算运动向量
|
|
2039
2046
|
const frame1 = this.normalizedLandmarksHistory[this.normalizedLandmarksHistory.length - 2];
|
|
2040
2047
|
const frame2 = this.normalizedLandmarksHistory[this.normalizedLandmarksHistory.length - 1];
|
|
2041
2048
|
for (const ptIdx of samplePoints) {
|
|
@@ -2361,7 +2368,7 @@ class MotionLivenessDetector {
|
|
|
2361
2368
|
* 2. 跨帧深度模式 - 辅助参考
|
|
2362
2369
|
*/
|
|
2363
2370
|
detectPhotoGeometry() {
|
|
2364
|
-
if (this.
|
|
2371
|
+
if (this.normalizedLandmarksHistory.length < 3) {
|
|
2365
2372
|
return { isPhoto: false, confidence: 0, details: {} };
|
|
2366
2373
|
}
|
|
2367
2374
|
// 【核心检测1】平面单应性约束检测(最可靠,纯2D几何)
|
|
@@ -2374,12 +2381,13 @@ class MotionLivenessDetector {
|
|
|
2374
2381
|
const depthResult = this.detectDepthConsistency();
|
|
2375
2382
|
const crossFrameDepth = this.detectCrossFrameDepthPattern();
|
|
2376
2383
|
// 综合判定:2D几何约束权重高,Z坐标权重低
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2384
|
+
// 【改进】提高perspectiveScore的权重,因为完美的平滑变换是照片的强特征
|
|
2385
|
+
const photoScore = homographyResult.planarScore * 0.30 + // 单应性约束(最可靠)
|
|
2386
|
+
perspectivePattern.perspectiveScore * 0.40 + // 透视变换模式(可靠且权重提高)
|
|
2387
|
+
crossRatioResult.invarianceScore * 0.15 + // 交叉比率不变性(可靠)
|
|
2388
|
+
(1 - Math.min(depthResult.depthVariation, 1)) * 0.10 + // 深度(辅助,低权重)
|
|
2381
2389
|
crossFrameDepth.planarPattern * 0.05; // 跨帧深度(辅助,低权重)
|
|
2382
|
-
const isPhoto = photoScore > 0.
|
|
2390
|
+
const isPhoto = photoScore > 0.50; // 【改进】降低阈值到0.50(从0.60)
|
|
2383
2391
|
const confidence = Math.min(photoScore, 1);
|
|
2384
2392
|
// 记录历史
|
|
2385
2393
|
this.planarityScores.push(photoScore);
|
|
@@ -2425,18 +2433,19 @@ class MotionLivenessDetector {
|
|
|
2425
2433
|
* - 真实3D人脸旋转时,面部各点不共面,交叉比率会变化
|
|
2426
2434
|
* - 照片无论怎么偏转,共线点的交叉比率保持不变
|
|
2427
2435
|
*
|
|
2428
|
-
*
|
|
2429
|
-
*
|
|
2436
|
+
* 虽然交叉比率是射影不变量(用任何坐标系都可以),
|
|
2437
|
+
* 但使用原始坐标以保持一致性和物理意义的清晰性
|
|
2430
2438
|
*/
|
|
2431
2439
|
detectCrossRatioInvariance() {
|
|
2432
|
-
//
|
|
2433
|
-
|
|
2440
|
+
// 【使用原始坐标历史】虽然交叉比率是射影不变量,
|
|
2441
|
+
// 但原始坐标保持物理清晰性
|
|
2442
|
+
if (this.faceLandmarksHistory.length < 3) {
|
|
2434
2443
|
return { invarianceScore: 0 };
|
|
2435
2444
|
}
|
|
2436
2445
|
// 选择面部中线上近似共线的点(额头-鼻梁-鼻尖-嘴-下巴)
|
|
2437
2446
|
const midlinePoints = [10, 168, 1, 0, 152]; // 从上到下
|
|
2438
2447
|
const crossRatios = [];
|
|
2439
|
-
for (const frame of this.
|
|
2448
|
+
for (const frame of this.faceLandmarksHistory) {
|
|
2440
2449
|
if (frame.length < 468)
|
|
2441
2450
|
continue;
|
|
2442
2451
|
// 提取中线点的Y坐标(它们大致在一条垂直线上)
|
|
@@ -2492,118 +2501,541 @@ class MotionLivenessDetector {
|
|
|
2492
2501
|
/**
|
|
2493
2502
|
* 【单应性约束检测】判断多帧特征点是否满足平面约束
|
|
2494
2503
|
*
|
|
2495
|
-
*
|
|
2504
|
+
* 【关键改进】:
|
|
2505
|
+
* 1. 使用DLT算法计算完整的3x3单应性矩阵(8参数)
|
|
2506
|
+
* 2. 使用相邻帧而不是首尾帧(减少变化幅度)
|
|
2507
|
+
* 3. 检查单应性矩阵的性质(秩、行列式等)
|
|
2508
|
+
* 4. 计算多帧的平均误差(更稳定)
|
|
2509
|
+
* 5. 对所有帧对的H矩阵一致性进行验证
|
|
2510
|
+
*
|
|
2496
2511
|
* 这是纯 2D 几何检测,最可靠!
|
|
2497
2512
|
*/
|
|
2498
2513
|
detectHomographyConstraint() {
|
|
2499
|
-
//
|
|
2500
|
-
|
|
2514
|
+
// 【关键】使用原始坐标历史,而不是归一化坐标
|
|
2515
|
+
// 原因:单应性矩阵在原始图像坐标中定义
|
|
2516
|
+
// 归一化坐标虽然消除平移影响,但破坏了H矩阵的定义
|
|
2517
|
+
if (this.faceLandmarksHistory.length < 2) {
|
|
2501
2518
|
return { planarScore: 0 };
|
|
2502
2519
|
}
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
//
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2520
|
+
// 【改进】使用所有面部关键点(468个点)而不是采样点
|
|
2521
|
+
// 更多的点对会给出更准确的单应性矩阵估计
|
|
2522
|
+
const errors = [];
|
|
2523
|
+
const homographyMatrices = [];
|
|
2524
|
+
let lastSrcPoints = []; // 保存最后一组点对,用于计算特征尺度
|
|
2525
|
+
// 【策略改进】优先使用最近的帧对(尽早检测)
|
|
2526
|
+
// 照片的几何约束是瞬间的,2帧就足够;多帧用来验证一致性
|
|
2527
|
+
const recentFrameCount = Math.min(5, this.faceLandmarksHistory.length);
|
|
2528
|
+
// 计算最近帧对的变换误差(重点在最近的帧)
|
|
2529
|
+
for (let i = Math.max(1, this.faceLandmarksHistory.length - recentFrameCount); i < this.faceLandmarksHistory.length; i++) {
|
|
2530
|
+
const frame1 = this.faceLandmarksHistory[i - 1];
|
|
2531
|
+
const frame2 = this.faceLandmarksHistory[i];
|
|
2532
|
+
if (frame1.length < 100 || frame2.length < 100)
|
|
2533
|
+
continue; // 至少100个有效点
|
|
2534
|
+
// 【改进】收集所有有效的点对(而不是只采样10个点)
|
|
2535
|
+
// 这给出更好的H矩阵估计
|
|
2536
|
+
const srcPoints = [];
|
|
2537
|
+
const dstPoints = [];
|
|
2538
|
+
for (let ptIdx = 0; ptIdx < Math.min(frame1.length, frame2.length); ptIdx++) {
|
|
2539
|
+
if (frame1[ptIdx] && frame2[ptIdx] &&
|
|
2540
|
+
frame1[ptIdx].length >= 2 && frame2[ptIdx].length >= 2) {
|
|
2541
|
+
const x1 = frame1[ptIdx][0];
|
|
2542
|
+
const y1 = frame1[ptIdx][1];
|
|
2543
|
+
const x2 = frame2[ptIdx][0];
|
|
2544
|
+
const y2 = frame2[ptIdx][1];
|
|
2545
|
+
// 【修复】只排除明显的异常值(移动过大),保留所有其他点
|
|
2546
|
+
// 包括静止的点和微妙移动的点,DLT需要全局点分布
|
|
2547
|
+
const displacement = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
|
2548
|
+
if (displacement < 200) { // 仅排除极端异常
|
|
2549
|
+
srcPoints.push([x1, y1]);
|
|
2550
|
+
dstPoints.push([x2, y2]);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
if (srcPoints.length < 10)
|
|
2555
|
+
continue; // 至少10个匹配点对(DLT最少需要4个)
|
|
2556
|
+
// 保存这一组点对(用于后面计算特征尺度)
|
|
2557
|
+
lastSrcPoints = srcPoints;
|
|
2558
|
+
// 【新增】使用DLT算法计算完整的3x3单应性矩阵
|
|
2559
|
+
const H = this.estimateHomographyDLT(srcPoints, dstPoints);
|
|
2560
|
+
if (!H)
|
|
2561
|
+
continue;
|
|
2562
|
+
homographyMatrices.push(H);
|
|
2563
|
+
// 【改进】使用单应性矩阵计算误差(而不是仿射变换)
|
|
2564
|
+
let frameError = 0;
|
|
2565
|
+
let validCount = 0;
|
|
2566
|
+
for (let j = 0; j < srcPoints.length; j++) {
|
|
2567
|
+
const transformed = this.applyHomography(H, srcPoints[j][0], srcPoints[j][1]);
|
|
2568
|
+
const actual = dstPoints[j];
|
|
2569
|
+
const error = Math.sqrt((transformed[0] - actual[0]) ** 2 + (transformed[1] - actual[1]) ** 2);
|
|
2570
|
+
frameError += error;
|
|
2571
|
+
validCount++;
|
|
2572
|
+
}
|
|
2573
|
+
if (validCount > 0) {
|
|
2574
|
+
errors.push(frameError / validCount);
|
|
2519
2575
|
}
|
|
2520
2576
|
}
|
|
2521
|
-
if (
|
|
2577
|
+
if (errors.length === 0) {
|
|
2522
2578
|
return { planarScore: 0, error: 0 };
|
|
2523
2579
|
}
|
|
2524
|
-
//
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2580
|
+
// 计算所有帧的平均误差
|
|
2581
|
+
const avgError = errors.reduce((a, b) => a + b, 0) / errors.length;
|
|
2582
|
+
// 【新增】检查H矩阵的一致性
|
|
2583
|
+
// 照片的H矩阵在不同帧对中应该保持相对稳定
|
|
2584
|
+
let matrixConsistency = 1.0;
|
|
2585
|
+
if (homographyMatrices.length > 1) {
|
|
2586
|
+
matrixConsistency = this.checkHomographyConsistency(homographyMatrices);
|
|
2587
|
+
}
|
|
2588
|
+
// 【改进】使用相对误差而不是绝对误差
|
|
2589
|
+
// 相对误差 = avgError / 点集特征尺度
|
|
2590
|
+
// 这样对不同分辨率和点集大小更鲁棒
|
|
2591
|
+
const characteristicScale = lastSrcPoints.length > 0 ? this.computeCharacteristicScale(lastSrcPoints) : 1;
|
|
2592
|
+
const relativeError = characteristicScale > 0.1 ? avgError / characteristicScale : avgError;
|
|
2593
|
+
// 平面性判决:
|
|
2594
|
+
// relativeError < 0.05 → 很可能是平面(照片)
|
|
2595
|
+
// relativeError > 0.1 → 很可能是立体(活体)
|
|
2596
|
+
const errorScore = Math.max(0, 1 - relativeError / 0.1);
|
|
2597
|
+
const planarScore = errorScore * matrixConsistency;
|
|
2598
|
+
console.debug('[HomographyConstraint]', {
|
|
2599
|
+
recentFrameCount,
|
|
2600
|
+
frameCount: errors.length,
|
|
2601
|
+
avgError: avgError.toFixed(4),
|
|
2602
|
+
errorScore: errorScore.toFixed(3),
|
|
2603
|
+
matrixConsistency: matrixConsistency.toFixed(3),
|
|
2604
|
+
planarScore: planarScore.toFixed(3),
|
|
2605
|
+
homographyMatrixCount: homographyMatrices.length
|
|
2606
|
+
});
|
|
2607
|
+
return { planarScore: Math.min(planarScore, 1), error: avgError };
|
|
2608
|
+
}
|
|
2609
|
+
/**
|
|
2610
|
+
* 使用DLT算法估计单应性矩阵(Homography Estimation using DLT)
|
|
2611
|
+
*
|
|
2612
|
+
* DLT (Direct Linear Transform) 是估计射影变换的标准算法
|
|
2613
|
+
*
|
|
2614
|
+
* 输入:
|
|
2615
|
+
* - src: 源点坐标数组 (至少4对)
|
|
2616
|
+
* - dst: 目标点坐标数组
|
|
2617
|
+
*
|
|
2618
|
+
* 输出:
|
|
2619
|
+
* - 3x3单应性矩阵H,使得 p' = H * p (齐次坐标表示)
|
|
2620
|
+
*
|
|
2621
|
+
* 关键特性:
|
|
2622
|
+
* - 与仿射变换的区别:
|
|
2623
|
+
* * 仿射:6参数,处理旋转+缩放+平移+剪切
|
|
2624
|
+
* * 单应性:8参数,处理完整的射影变换(包括透视失真)
|
|
2625
|
+
* - 适用场景:照片倾斜拍摄、相机透视变换
|
|
2626
|
+
* - 数值稳定性:使用点集归一化提高数值精度
|
|
2627
|
+
*
|
|
2628
|
+
* 算法步骤:
|
|
2629
|
+
* 1. 归一化源点和目标点(改进数值稳定性)
|
|
2630
|
+
* 2. 构建 2n×9 的 A 矩阵(n为点对数)
|
|
2631
|
+
* 3. 使用最小二乘法求解 Ah = 0
|
|
2632
|
+
* 4. 反演应化矩阵到原始坐标系
|
|
2633
|
+
*/
|
|
2634
|
+
estimateHomographyDLT(src, dst) {
|
|
2635
|
+
if (src.length < 4 || dst.length < 4 || src.length !== dst.length)
|
|
2636
|
+
return null;
|
|
2637
|
+
const n = src.length;
|
|
2638
|
+
// 【关键】对点进行归一化,提高数值稳定性
|
|
2639
|
+
const srcNorm = this.normalizePoints(src);
|
|
2640
|
+
const dstNorm = this.normalizePoints(dst);
|
|
2641
|
+
if (!srcNorm || !dstNorm)
|
|
2642
|
+
return null;
|
|
2643
|
+
// 构建DLT方程矩阵 A (2n × 9)
|
|
2644
|
+
//
|
|
2645
|
+
// 标准DLT形式推导:
|
|
2646
|
+
// 已知齐次坐标关系:p' = H * p
|
|
2647
|
+
// 即:[x', y', 1]^T = H * [x, y, 1]^T / w
|
|
2648
|
+
// 其中:w = h7*x + h8*y + h9 (齐次化分母)
|
|
2649
|
+
//
|
|
2650
|
+
// 展开得到:
|
|
2651
|
+
// x' = (h1*x + h2*y + h3) / (h7*x + h8*y + h9)
|
|
2652
|
+
// y' = (h4*x + h5*y + h6) / (h7*x + h8*y + h9)
|
|
2653
|
+
//
|
|
2654
|
+
// 交叉相乘消去分母:
|
|
2655
|
+
// x'*(h7*x + h8*y + h9) = h1*x + h2*y + h3
|
|
2656
|
+
// y'*(h7*x + h8*y + h9) = h4*x + h5*y + h6
|
|
2657
|
+
//
|
|
2658
|
+
// 整理为齐次线性方程 Ah = 0:
|
|
2659
|
+
// h1*x + h2*y + h3 - xp*h7*x - xp*h8*y - xp*h9 = 0
|
|
2660
|
+
// h4*x + h5*y + h6 - yp*h7*x - yp*h8*y - yp*h9 = 0
|
|
2661
|
+
//
|
|
2662
|
+
// 矩阵形式(每对点产生2行方程):
|
|
2663
|
+
// [x, y, 1, 0, 0, 0, -xp*x, -xp*y, -xp] [h1]
|
|
2664
|
+
// [0, 0, 0, x, y, 1, -yp*x, -yp*y, -yp] * [h2] = 0
|
|
2665
|
+
// [...]
|
|
2666
|
+
// [h9]
|
|
2667
|
+
const A = [];
|
|
2668
|
+
for (let i = 0; i < n; i++) {
|
|
2669
|
+
const x = srcNorm.points[i][0];
|
|
2670
|
+
const y = srcNorm.points[i][1];
|
|
2671
|
+
const xp = dstNorm.points[i][0];
|
|
2672
|
+
const yp = dstNorm.points[i][1];
|
|
2673
|
+
// 第一行:h1*x + h2*y + h3 - xp*h7*x - xp*h8*y - xp*h9 = 0
|
|
2674
|
+
A.push([x, y, 1, 0, 0, 0, -xp * x, -xp * y, -xp]);
|
|
2675
|
+
// 第二行:h4*x + h5*y + h6 - yp*h7*x - yp*h8*y - yp*h9 = 0
|
|
2676
|
+
A.push([0, 0, 0, x, y, 1, -yp * x, -yp * y, -yp]);
|
|
2677
|
+
}
|
|
2678
|
+
// 使用最小二乘法求解 Ah = 0
|
|
2679
|
+
// h 是 A^T*A 最小特征值对应的特征向量
|
|
2680
|
+
const h = this.solveHomographyLSQ(A);
|
|
2681
|
+
if (!h)
|
|
2682
|
+
return null;
|
|
2683
|
+
// 反演应化:从归一化坐标回到原始图像坐标
|
|
2684
|
+
// H_orig = T_dst^(-1) * H_norm * T_src
|
|
2685
|
+
const H = this.denormalizeHomography(h, srcNorm, dstNorm);
|
|
2686
|
+
return H;
|
|
2687
|
+
}
|
|
2688
|
+
/**
|
|
2689
|
+
* 点集归一化(Hartley标准化)- 提高DLT数值稳定性
|
|
2690
|
+
*
|
|
2691
|
+
* 目的:
|
|
2692
|
+
* - DLT算法对坐标尺度敏感,归一化可显著改善数值稳定性
|
|
2693
|
+
* - 避免矩阵条件数过大,减少数值误差
|
|
2694
|
+
*
|
|
2695
|
+
* 方法:
|
|
2696
|
+
* 1. 计算点集的重心 (cx, cy)
|
|
2697
|
+
* 2. 计算点到重心的平均距离
|
|
2698
|
+
* 3. 缩放使得平均距离为 √2
|
|
2699
|
+
*
|
|
2700
|
+
* 变换:T = [s, 0, -s*cx; 0, s, -s*cy; 0, 0, 1]
|
|
2701
|
+
* 其中 s = √2 / avgDistance
|
|
2702
|
+
*
|
|
2703
|
+
* 好处:
|
|
2704
|
+
* - 点集的重心在原点
|
|
2705
|
+
* - 点到原点的平均距离为 √2(标准化)
|
|
2706
|
+
* - A^T*A 矩阵条件数接近最优
|
|
2707
|
+
*
|
|
2708
|
+
* 逆操作:在得到矩阵后需要反归一化回原始坐标
|
|
2709
|
+
*/
|
|
2710
|
+
normalizePoints(points) {
|
|
2711
|
+
if (points.length === 0)
|
|
2712
|
+
return null;
|
|
2713
|
+
// 计算重心
|
|
2714
|
+
let cx = 0, cy = 0;
|
|
2715
|
+
for (const p of points) {
|
|
2716
|
+
cx += p[0];
|
|
2717
|
+
cy += p[1];
|
|
2718
|
+
}
|
|
2719
|
+
cx /= points.length;
|
|
2720
|
+
cy /= points.length;
|
|
2721
|
+
// 计算平均距离
|
|
2722
|
+
let avgDist = 0;
|
|
2723
|
+
for (const p of points) {
|
|
2724
|
+
const dx = p[0] - cx;
|
|
2725
|
+
const dy = p[1] - cy;
|
|
2726
|
+
avgDist += Math.sqrt(dx * dx + dy * dy);
|
|
2727
|
+
}
|
|
2728
|
+
avgDist /= points.length;
|
|
2729
|
+
// 缩放因子
|
|
2730
|
+
const scale = avgDist > 0.001 ? Math.sqrt(2) / avgDist : 1;
|
|
2731
|
+
// 应用归一化变换
|
|
2732
|
+
const normalized = [];
|
|
2733
|
+
for (const p of points) {
|
|
2734
|
+
normalized.push([
|
|
2735
|
+
(p[0] - cx) * scale,
|
|
2736
|
+
(p[1] - cy) * scale
|
|
2737
|
+
]);
|
|
2738
|
+
}
|
|
2739
|
+
// 归一化矩阵 T
|
|
2740
|
+
const T = [
|
|
2741
|
+
[scale, 0, -cx * scale],
|
|
2742
|
+
[0, scale, -cy * scale],
|
|
2743
|
+
[0, 0, 1]
|
|
2744
|
+
];
|
|
2745
|
+
return { points: normalized, T };
|
|
2746
|
+
}
|
|
2747
|
+
/**
|
|
2748
|
+
* 最小二乘法求解齐次线性方程 Ah = 0
|
|
2749
|
+
*
|
|
2750
|
+
* 问题:找到使 ||Ah|| 最小的单位向量 h
|
|
2751
|
+
*
|
|
2752
|
+
* 标准解法:
|
|
2753
|
+
* - 完整SVD:对 A 进行 SVD 分解,h 是最小奇异值的右奇异向量
|
|
2754
|
+
* - 特征向量法(此处使用):
|
|
2755
|
+
* 1. 计算 A^T * A(9×9对称矩阵)
|
|
2756
|
+
* 2. 求 A^T*A 的最小特征值对应的特征向量
|
|
2757
|
+
* 3. 该特征向量即为所求的 h
|
|
2758
|
+
*
|
|
2759
|
+
* 说明:
|
|
2760
|
+
* - h 是 3×3 单应性矩阵 H 的向量化形式
|
|
2761
|
+
* - 返回的特征向量已归一化
|
|
2762
|
+
*/
|
|
2763
|
+
solveHomographyLSQ(A) {
|
|
2764
|
+
if (A.length < 8)
|
|
2765
|
+
return null;
|
|
2766
|
+
// 构造 A^T * A 矩阵 (9×9)
|
|
2767
|
+
// 这是一个对称半正定矩阵
|
|
2768
|
+
const ATA = Array(9).fill(0).map(() => Array(9).fill(0));
|
|
2769
|
+
for (let i = 0; i < 9; i++) {
|
|
2770
|
+
for (let j = 0; j < 9; j++) {
|
|
2771
|
+
for (let k = 0; k < A.length; k++) {
|
|
2772
|
+
ATA[i][j] += A[k][i] * A[k][j];
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2529
2775
|
}
|
|
2530
|
-
//
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2776
|
+
// 求 A^T*A 的最小特征向量
|
|
2777
|
+
// 最小特征向量对应最小特征值,使 ||Ah|| 最小
|
|
2778
|
+
const eigenVec = this.getSmallestEigenvector(ATA);
|
|
2779
|
+
return eigenVec;
|
|
2780
|
+
}
|
|
2781
|
+
/**
|
|
2782
|
+
* 求9x9对称矩阵(A^T*A)的最小特征向量
|
|
2783
|
+
* 使用改进的迭代方法
|
|
2784
|
+
*
|
|
2785
|
+
* 原理:
|
|
2786
|
+
* - A^T*A 是对称半正定矩阵
|
|
2787
|
+
* - 最小特征值对应的特征向量是最小二乘解
|
|
2788
|
+
* - 使用迭代幂法(Power Iteration)的变种求最小特征向量
|
|
2789
|
+
*/
|
|
2790
|
+
getSmallestEigenvector(mat) {
|
|
2791
|
+
if (mat.length !== 9)
|
|
2792
|
+
return null;
|
|
2793
|
+
// 初始随机向量
|
|
2794
|
+
let v = [1, 0, 0, 0, 1, 0, 0, 0, 1]; // 初始值更稳定
|
|
2795
|
+
let prevEigenvalue = Infinity;
|
|
2796
|
+
// 迭代求解最小特征值对应的特征向量
|
|
2797
|
+
for (let iter = 0; iter < 50; iter++) {
|
|
2798
|
+
// 计算 A*v
|
|
2799
|
+
const Av = [0, 0, 0, 0, 0, 0, 0, 0, 0];
|
|
2800
|
+
for (let i = 0; i < 9; i++) {
|
|
2801
|
+
for (let j = 0; j < 9; j++) {
|
|
2802
|
+
Av[i] += mat[i][j] * v[j];
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
// 计算 Rayleigh 商(特征值估计)
|
|
2806
|
+
let vTAv = 0;
|
|
2807
|
+
let vTv = 0;
|
|
2808
|
+
for (let i = 0; i < 9; i++) {
|
|
2809
|
+
vTAv += v[i] * Av[i];
|
|
2810
|
+
vTv += v[i] * v[i];
|
|
2811
|
+
}
|
|
2812
|
+
const eigenvalue = vTv > 1e-10 ? vTAv / vTv : 0;
|
|
2813
|
+
// 标准化 Av
|
|
2814
|
+
const AvNorm = Math.sqrt(Av.reduce((a, b) => a + b * b, 0));
|
|
2815
|
+
if (AvNorm < 1e-10) {
|
|
2816
|
+
break;
|
|
2541
2817
|
}
|
|
2818
|
+
// 更新 v
|
|
2819
|
+
for (let i = 0; i < 9; i++) {
|
|
2820
|
+
v[i] = Av[i] / AvNorm;
|
|
2821
|
+
}
|
|
2822
|
+
// 收敛判断:特征值变化很小
|
|
2823
|
+
if (Math.abs(eigenvalue - prevEigenvalue) < 1e-8) {
|
|
2824
|
+
break;
|
|
2825
|
+
}
|
|
2826
|
+
prevEigenvalue = eigenvalue;
|
|
2542
2827
|
}
|
|
2543
|
-
|
|
2544
|
-
|
|
2828
|
+
// 最后做一次归一化
|
|
2829
|
+
const norm = Math.sqrt(v.reduce((a, b) => a + b * b, 0));
|
|
2830
|
+
if (norm > 1e-10) {
|
|
2831
|
+
for (let i = 0; i < 9; i++) {
|
|
2832
|
+
v[i] = v[i] / norm;
|
|
2833
|
+
}
|
|
2545
2834
|
}
|
|
2546
|
-
|
|
2547
|
-
// 归一化坐标下,误差已经是相对于人脸尺寸的比例
|
|
2548
|
-
// 不需要再除以脸宽
|
|
2549
|
-
const relativeError = avgError;
|
|
2550
|
-
// 平面得分:误差越小,越可能是平面(照片)
|
|
2551
|
-
// relativeError < 0.02 → 非常可能是平面
|
|
2552
|
-
// relativeError > 0.08 → 不太可能是平面
|
|
2553
|
-
const planarScore = Math.max(0, 1 - relativeError / 0.05);
|
|
2554
|
-
// 记录误差历史
|
|
2555
|
-
this.homographyErrors.push(relativeError);
|
|
2556
|
-
if (this.homographyErrors.length > this.config.frameBufferSize) {
|
|
2557
|
-
this.homographyErrors.shift();
|
|
2558
|
-
}
|
|
2559
|
-
return { planarScore: Math.min(planarScore, 1), error: relativeError };
|
|
2835
|
+
return v;
|
|
2560
2836
|
}
|
|
2561
2837
|
/**
|
|
2562
|
-
*
|
|
2563
|
-
*
|
|
2564
|
-
*
|
|
2838
|
+
* 反演应化矩阵 - 从归一化坐标回到原始图像坐标
|
|
2839
|
+
*
|
|
2840
|
+
* 原理:
|
|
2841
|
+
* - 在归一化坐标系中估算的H矩阵需要转换回原始坐标
|
|
2842
|
+
*
|
|
2843
|
+
* 公式:H_orig = T_dst^(-1) * H_norm * T_src
|
|
2844
|
+
*
|
|
2845
|
+
* 说明:
|
|
2846
|
+
* - T_src:源点的归一化变换矩阵
|
|
2847
|
+
* - T_dst:目标点的归一化变换矩阵
|
|
2848
|
+
* - H_norm:在归一化坐标中计算得到的3×3矩阵
|
|
2849
|
+
* - H_orig:最终的单应性矩阵(用于原始图像坐标)
|
|
2850
|
+
*
|
|
2851
|
+
* 验证:p'_orig = H_orig * p_src_orig
|
|
2565
2852
|
*/
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2853
|
+
denormalizeHomography(h, srcNorm, dstNorm) {
|
|
2854
|
+
// 将向量h转换为3x3矩阵
|
|
2855
|
+
const H_norm = [
|
|
2856
|
+
[h[0], h[1], h[2]],
|
|
2857
|
+
[h[3], h[4], h[5]],
|
|
2858
|
+
[h[6], h[7], h[8]]
|
|
2859
|
+
];
|
|
2860
|
+
// H = T_dst^-1 * H_norm * T_src
|
|
2861
|
+
const T_src = srcNorm.T;
|
|
2862
|
+
const T_dst = dstNorm.T;
|
|
2863
|
+
const T_dst_inv = this.invertMatrix3x3(T_dst);
|
|
2864
|
+
if (!T_dst_inv)
|
|
2865
|
+
return H_norm;
|
|
2866
|
+
// 矩阵乘法:(3x3) * (3x3) * (3x3)
|
|
2867
|
+
const temp = this.multiplyMatrix3x3(T_dst_inv, H_norm);
|
|
2868
|
+
const H = this.multiplyMatrix3x3(temp, T_src);
|
|
2869
|
+
return H;
|
|
2870
|
+
}
|
|
2871
|
+
/**
|
|
2872
|
+
* 检查点集的分布范围(防止点集集中在小区域)
|
|
2873
|
+
* 返回值:0-1,值越大说明分布越分散
|
|
2874
|
+
*/
|
|
2875
|
+
checkPointSpread(points) {
|
|
2876
|
+
if (points.length < 2)
|
|
2877
|
+
return 0;
|
|
2878
|
+
let minX = Infinity, maxX = -Infinity;
|
|
2879
|
+
let minY = Infinity, maxY = -Infinity;
|
|
2880
|
+
for (const p of points) {
|
|
2881
|
+
minX = Math.min(minX, p[0]);
|
|
2882
|
+
maxX = Math.max(maxX, p[0]);
|
|
2883
|
+
minY = Math.min(minY, p[1]);
|
|
2884
|
+
maxY = Math.max(maxY, p[1]);
|
|
2885
|
+
}
|
|
2886
|
+
const rangeX = maxX - minX;
|
|
2887
|
+
const rangeY = maxY - minY;
|
|
2888
|
+
const area = rangeX * rangeY;
|
|
2889
|
+
// 点集的相对面积(相对于整个图像,假设1920x1080)
|
|
2890
|
+
// 面积越大,点的分布越分散
|
|
2891
|
+
const imageArea = 1920 * 1080;
|
|
2892
|
+
const relativeArea = Math.min(area / imageArea, 1);
|
|
2893
|
+
return relativeArea;
|
|
2894
|
+
}
|
|
2895
|
+
/**
|
|
2896
|
+
* 3x3矩阵求逆
|
|
2897
|
+
*/
|
|
2898
|
+
invertMatrix3x3(m) {
|
|
2899
|
+
const [m00, m01, m02] = m[0];
|
|
2900
|
+
const [m10, m11, m12] = m[1];
|
|
2901
|
+
const [m20, m21, m22] = m[2];
|
|
2902
|
+
const det = m00 * (m11 * m22 - m12 * m21) -
|
|
2903
|
+
m01 * (m10 * m22 - m12 * m20) +
|
|
2904
|
+
m02 * (m10 * m21 - m11 * m20);
|
|
2589
2905
|
if (Math.abs(det) < 0.0001)
|
|
2590
2906
|
return null;
|
|
2591
|
-
const
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2907
|
+
const inv = [
|
|
2908
|
+
[
|
|
2909
|
+
(m11 * m22 - m12 * m21) / det,
|
|
2910
|
+
(m02 * m21 - m01 * m22) / det,
|
|
2911
|
+
(m01 * m12 - m02 * m11) / det
|
|
2912
|
+
],
|
|
2913
|
+
[
|
|
2914
|
+
(m12 * m20 - m10 * m22) / det,
|
|
2915
|
+
(m00 * m22 - m02 * m20) / det,
|
|
2916
|
+
(m02 * m10 - m00 * m12) / det
|
|
2917
|
+
],
|
|
2918
|
+
[
|
|
2919
|
+
(m10 * m21 - m11 * m20) / det,
|
|
2920
|
+
(m01 * m20 - m00 * m21) / det,
|
|
2921
|
+
(m00 * m11 - m01 * m10) / det
|
|
2922
|
+
]
|
|
2923
|
+
];
|
|
2924
|
+
return inv;
|
|
2925
|
+
}
|
|
2926
|
+
/**
|
|
2927
|
+
* 3x3矩阵乘法
|
|
2928
|
+
*/
|
|
2929
|
+
multiplyMatrix3x3(A, B) {
|
|
2930
|
+
const result = Array(3).fill(0).map(() => Array(3).fill(0));
|
|
2931
|
+
for (let i = 0; i < 3; i++) {
|
|
2932
|
+
for (let j = 0; j < 3; j++) {
|
|
2933
|
+
for (let k = 0; k < 3; k++) {
|
|
2934
|
+
result[i][j] += A[i][k] * B[k][j];
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
return result;
|
|
2598
2939
|
}
|
|
2599
2940
|
/**
|
|
2600
|
-
*
|
|
2941
|
+
* 应用单应性变换 - 齐次坐标变换与反齐次化
|
|
2942
|
+
*
|
|
2943
|
+
* 操作步骤:
|
|
2944
|
+
* 1. 输入:(x, y) → 齐次坐标 [x, y, 1]
|
|
2945
|
+
* 2. 矩阵乘法:H * p 得到齐次结果 [x'w, y'w, w]
|
|
2946
|
+
* 3. 反齐次化:[x'w/w, y'w/w] = [x', y']
|
|
2947
|
+
*
|
|
2948
|
+
* 公式:
|
|
2949
|
+
* [x'] [h11 h12 h13] [x]
|
|
2950
|
+
* [y' ] = [h21 h22 h23] [y]
|
|
2951
|
+
* [w'] [h31 h32 h33] [1]
|
|
2952
|
+
*
|
|
2953
|
+
* 最后:x' = x'w / w, y' = y'w / w
|
|
2954
|
+
*
|
|
2955
|
+
* 注意:如果 w' ≈ 0,点被投影到无穷远(退化情况)
|
|
2601
2956
|
*/
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2957
|
+
applyHomography(H, x, y) {
|
|
2958
|
+
// 齐次坐标表示
|
|
2959
|
+
const p = [x, y, 1];
|
|
2960
|
+
// 矩阵乘法:H * p
|
|
2961
|
+
const Hp = [
|
|
2962
|
+
H[0][0] * p[0] + H[0][1] * p[1] + H[0][2] * p[2],
|
|
2963
|
+
H[1][0] * p[0] + H[1][1] * p[1] + H[1][2] * p[2],
|
|
2964
|
+
H[2][0] * p[0] + H[2][1] * p[1] + H[2][2] * p[2]
|
|
2606
2965
|
];
|
|
2966
|
+
// 反齐次化:除以齐次坐标的 Z 分量
|
|
2967
|
+
// 如果 w ≈ 0,则点在无穷远,返回齐次结果
|
|
2968
|
+
if (Math.abs(Hp[2]) < 0.0001) {
|
|
2969
|
+
return [Hp[0], Hp[1]]; // 异常情况:接近无穷
|
|
2970
|
+
}
|
|
2971
|
+
return [Hp[0] / Hp[2], Hp[1] / Hp[2]]; // 正常情况:反齐次化
|
|
2972
|
+
}
|
|
2973
|
+
/**
|
|
2974
|
+
* 【新增】计算点集的特征尺度(点间的平均距离)
|
|
2975
|
+
*
|
|
2976
|
+
* 用于相对误差计算,使算法对不同分辨率和点集大小更鲁棒
|
|
2977
|
+
*/
|
|
2978
|
+
computeCharacteristicScale(points) {
|
|
2979
|
+
if (points.length < 2)
|
|
2980
|
+
return 1;
|
|
2981
|
+
let totalDist = 0;
|
|
2982
|
+
let count = 0;
|
|
2983
|
+
// 采样计算点间距离,避免 O(n²) 复杂度
|
|
2984
|
+
const sampleSize = Math.min(points.length, 30);
|
|
2985
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
2986
|
+
for (let j = i + 1; j < sampleSize; j++) {
|
|
2987
|
+
const dx = points[i][0] - points[j][0];
|
|
2988
|
+
const dy = points[i][1] - points[j][1];
|
|
2989
|
+
totalDist += Math.sqrt(dx * dx + dy * dy);
|
|
2990
|
+
count++;
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
return count > 0 ? totalDist / count : 1;
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* 【新增】检查单应性矩阵的一致性
|
|
2997
|
+
*
|
|
2998
|
+
* 原理:
|
|
2999
|
+
* - 照片旋转时,每对相邻帧的H矩阵应该相近(因为是持续旋转)
|
|
3000
|
+
* - 真实人脸做随机动作时,H矩阵会变化很大
|
|
3001
|
+
*/
|
|
3002
|
+
checkHomographyConsistency(matrices) {
|
|
3003
|
+
if (matrices.length < 2)
|
|
3004
|
+
return 1;
|
|
3005
|
+
// 计算矩阵间的相似度
|
|
3006
|
+
let totalSimilarity = 0;
|
|
3007
|
+
let pairCount = 0;
|
|
3008
|
+
for (let i = 1; i < matrices.length; i++) {
|
|
3009
|
+
const M1 = matrices[i - 1];
|
|
3010
|
+
const M2 = matrices[i];
|
|
3011
|
+
// Frobenius范数相似度
|
|
3012
|
+
let sumDiff = 0;
|
|
3013
|
+
for (let r = 0; r < 3; r++) {
|
|
3014
|
+
for (let c = 0; c < 3; c++) {
|
|
3015
|
+
const diff = M1[r][c] - M2[r][c];
|
|
3016
|
+
sumDiff += diff * diff;
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
const frobeniusDist = Math.sqrt(sumDiff);
|
|
3020
|
+
// 标准化距离(除以矩阵范数)
|
|
3021
|
+
let normM1 = 0, normM2 = 0;
|
|
3022
|
+
for (let r = 0; r < 3; r++) {
|
|
3023
|
+
for (let c = 0; c < 3; c++) {
|
|
3024
|
+
normM1 += M1[r][c] * M1[r][c];
|
|
3025
|
+
normM2 += M2[r][c] * M2[r][c];
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
normM1 = Math.sqrt(normM1);
|
|
3029
|
+
normM2 = Math.sqrt(normM2);
|
|
3030
|
+
const avgNorm = (normM1 + normM2) / 2;
|
|
3031
|
+
const normalizedDist = avgNorm > 0.1 ? Math.min(frobeniusDist / avgNorm, 2) : 2;
|
|
3032
|
+
// 将距离转换为相似度 (0-1)
|
|
3033
|
+
// 距离越小,相似度越高
|
|
3034
|
+
const similarity = Math.max(0, 1 - normalizedDist / 2);
|
|
3035
|
+
totalSimilarity += similarity;
|
|
3036
|
+
pairCount++;
|
|
3037
|
+
}
|
|
3038
|
+
return pairCount > 0 ? totalSimilarity / pairCount : 1;
|
|
2607
3039
|
}
|
|
2608
3040
|
/**
|
|
2609
3041
|
* 【关键】检测深度一致性
|
|
@@ -2705,32 +3137,28 @@ class MotionLivenessDetector {
|
|
|
2705
3137
|
const planarPattern = consistentFrames / depthChanges.length;
|
|
2706
3138
|
return { planarPattern };
|
|
2707
3139
|
}
|
|
2708
|
-
/**
|
|
2709
|
-
* 【关键】检测透视变换模式
|
|
2710
|
-
*
|
|
2711
|
-
* 原理:
|
|
2712
|
-
* - 照片偏转时,特征点位置变化遵循严格的透视变换规律
|
|
2713
|
-
* - 检测左右脸的相对变化是否符合透视投影
|
|
2714
|
-
*/
|
|
2715
3140
|
/**
|
|
2716
3141
|
* 【透视变换模式检测】
|
|
2717
3142
|
*
|
|
2718
|
-
*
|
|
3143
|
+
* 【改进】使用原始坐标而不是归一化坐标
|
|
2719
3144
|
*
|
|
2720
3145
|
* 原理:照片左右偏转时,左右脸宽度比例会平滑变化
|
|
3146
|
+
* 这种比例变化遵循严格的透视投影规律
|
|
2721
3147
|
*/
|
|
2722
3148
|
detectPerspectiveTransformPattern() {
|
|
2723
|
-
//
|
|
2724
|
-
|
|
3149
|
+
// 【关键】使用原始坐标历史,而不是归一化坐标
|
|
3150
|
+
// 透视变换的宽度比例变化在原始图像坐标中更准确
|
|
3151
|
+
if (this.faceLandmarksHistory.length < 3) {
|
|
2725
3152
|
return { perspectiveScore: 0 };
|
|
2726
3153
|
}
|
|
2727
3154
|
// 比较左右脸的宽度比例变化
|
|
2728
3155
|
// 照片左偏时:右脸变窄,左脸变宽(透视效果)
|
|
2729
3156
|
// 这种变化应该是平滑且可预测的
|
|
2730
3157
|
const widthRatios = [];
|
|
2731
|
-
for (const frame of this.
|
|
3158
|
+
for (const frame of this.faceLandmarksHistory) {
|
|
2732
3159
|
if (frame.length >= 468) {
|
|
2733
|
-
//
|
|
3160
|
+
// 使用原始坐标计算距离比例
|
|
3161
|
+
// 234: 左脸颊边缘,1: 鼻尖,454: 右脸颊边缘
|
|
2734
3162
|
const leftWidth = this.pointDist(frame[234], frame[1]); // 左脸到鼻子
|
|
2735
3163
|
const rightWidth = this.pointDist(frame[1], frame[454]); // 鼻子到右脸
|
|
2736
3164
|
if (leftWidth > 0 && rightWidth > 0) {
|
|
@@ -2767,17 +3195,35 @@ class MotionLivenessDetector {
|
|
|
2767
3195
|
* 逆向检测优先级更高,因为照片几何约束是物理定律,无法伪造
|
|
2768
3196
|
*/
|
|
2769
3197
|
makeLivenessDecision(eyeActivity, mouthActivity, muscleActivity, photoGeometry) {
|
|
2770
|
-
if (!this.
|
|
3198
|
+
if (!this.collectedMinFrames()) {
|
|
2771
3199
|
return true; // 数据不足,默认通过
|
|
2772
3200
|
}
|
|
2773
3201
|
// ============ 逆向检测(照片几何特征)============
|
|
2774
3202
|
// 这是最可靠的检测方式,优先级最高
|
|
2775
3203
|
const isPhotoByGeometry = photoGeometry.isPhoto;
|
|
2776
3204
|
const photoConfidence = photoGeometry.confidence || 0;
|
|
3205
|
+
const frameCount = Math.max(this.eyeAspectRatioHistory.length, this.mouthAspectRatioHistory.length, this.faceLandmarksHistory.length, this.normalizedLandmarksHistory.length);
|
|
3206
|
+
// 【改进】根据帧数调整照片检测的敏感度
|
|
3207
|
+
// 少帧情况下,照片特征更容易误判,但如果几何约束强,仍应拒绝
|
|
3208
|
+
let photoConfidenceThreshold = 0.55;
|
|
3209
|
+
if (frameCount < 8) {
|
|
3210
|
+
// 少于8帧时,提高拒绝阈值,但只对超强照片特征有效
|
|
3211
|
+
// perspectiveScore=1.0 是超强信号,不应该放过
|
|
3212
|
+
if ((photoGeometry.details?.perspectiveScore || 0) > 0.95) {
|
|
3213
|
+
photoConfidenceThreshold = 0.45; // 降低阈值
|
|
3214
|
+
}
|
|
3215
|
+
else {
|
|
3216
|
+
photoConfidenceThreshold = 0.65; // 提高阈值
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
2777
3219
|
// 如果照片几何检测高置信度判定为照片,直接拒绝
|
|
2778
|
-
|
|
3220
|
+
// 【改进】根据帧数和具体特征调整阈值
|
|
3221
|
+
if (isPhotoByGeometry && photoConfidence > photoConfidenceThreshold) {
|
|
2779
3222
|
console.debug('[Decision] REJECTED by photo geometry detection', {
|
|
2780
3223
|
photoConfidence: photoConfidence.toFixed(3),
|
|
3224
|
+
photoConfidenceThreshold: photoConfidenceThreshold.toFixed(3),
|
|
3225
|
+
perspectiveScore: (photoGeometry.details?.perspectiveScore || 0).toFixed(3),
|
|
3226
|
+
frameCount,
|
|
2781
3227
|
details: photoGeometry.details
|
|
2782
3228
|
});
|
|
2783
3229
|
return false;
|
|
@@ -2847,11 +3293,13 @@ class MotionLivenessDetector {
|
|
|
2847
3293
|
/**
|
|
2848
3294
|
* 检查脸部形状稳定性
|
|
2849
3295
|
*
|
|
2850
|
-
*
|
|
2851
|
-
*
|
|
3296
|
+
* 【合理使用归一化坐标】这里使用归一化坐标是有意义的,因为:
|
|
3297
|
+
* - 目的是检测【脸部形状的变化】(眼睛距离、嘴巴高度等)
|
|
3298
|
+
* - 与人脸在画面中的位置和尺寸无关
|
|
3299
|
+
* - 消除平移和缩放影响,专注于形状的变化
|
|
2852
3300
|
*/
|
|
2853
3301
|
checkFaceShapeStability() {
|
|
2854
|
-
//
|
|
3302
|
+
// 使用归一化坐标历史,消除平移和缩放影响
|
|
2855
3303
|
if (this.normalizedLandmarksHistory.length < 5) {
|
|
2856
3304
|
return 0.5; // 数据不足
|
|
2857
3305
|
}
|
|
@@ -2864,7 +3312,7 @@ class MotionLivenessDetector {
|
|
|
2864
3312
|
return 0.95; // 非常可能是照片
|
|
2865
3313
|
}
|
|
2866
3314
|
// 【第二层防护】检测脸部形状稳定性
|
|
2867
|
-
//
|
|
3315
|
+
// 使用归一化坐标计算相对距离,检测形状变化
|
|
2868
3316
|
const faceDistances = [];
|
|
2869
3317
|
// 计算以下距离:
|
|
2870
3318
|
// 1. 左眼-右眼(眼距)
|
|
@@ -3976,8 +4424,8 @@ class FaceDetectionEngine extends SimpleEventEmitter {
|
|
|
3976
4424
|
}
|
|
3977
4425
|
// 静默活体检测
|
|
3978
4426
|
const motionResult = this.detectionState.motionDetector.analyzeMotion(face, faceBox);
|
|
3979
|
-
|
|
3980
|
-
|
|
4427
|
+
if (this.detectionState.motionDetector.collectedMinFrames()) {
|
|
4428
|
+
// 采集到最小帧数后,否定性判定才可信
|
|
3981
4429
|
if (!motionResult.isLively) {
|
|
3982
4430
|
this.emitDebug('motion-detection', 'Motion liveness check failed - possible photo attack', {
|
|
3983
4431
|
details: motionResult.details,
|
|
@@ -3991,17 +4439,20 @@ class FaceDetectionEngine extends SimpleEventEmitter {
|
|
|
3991
4439
|
this.partialResetDetectionState();
|
|
3992
4440
|
return;
|
|
3993
4441
|
}
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4442
|
+
// 采集到足够帧数后,肯定性判定才可信
|
|
4443
|
+
if (this.detectionState.motionDetector.collectedFullFrames()) {
|
|
4444
|
+
this.emitDebug('motion-detection', 'Motion liveness check passed', {
|
|
4445
|
+
debug: motionResult.debug,
|
|
4446
|
+
details: motionResult.details,
|
|
4447
|
+
}, 'warn');
|
|
4448
|
+
this.detectionState.liveness = true;
|
|
4449
|
+
}
|
|
4450
|
+
else {
|
|
4451
|
+
this.emitDebug('motion-detection', 'Motion liveness check ongoing - collecting more frames', {
|
|
4452
|
+
debug: motionResult.debug,
|
|
4453
|
+
details: motionResult.details,
|
|
4454
|
+
}, 'warn');
|
|
4455
|
+
}
|
|
4005
4456
|
}
|
|
4006
4457
|
// 动作活体检测阶段处理
|
|
4007
4458
|
if (this.detectionState.period === DetectionPeriod.VERIFY) {
|