fix: 拍照引导检测链路校准——模拟摄像头闭环修复四个实测问题
用户真机症状:一直『未检测到人脸』;脸进框却提示太远;凑近又超框。用 C3 三视图生成 Y4M 模拟摄像头闭环复现并定位: - 检测可靠性:tinyFaceDetector 320/0.4 → 416/0.3(正脸置信度 0.67→0.99,框更准);连续漏检 ≥3 帧才报未检测到人脸(单帧抖动不再刷屏) - 距离阈值:0.35/0.8 → 0.26/0.58(以取景框≈42% 屏高、416 输入实测框 0.40–0.44 标定;原阈值下脸进框即误判太远) - HeadPose:check() 双重镜像符号 bug(estimate 已翻转 yaw,check 又翻一次→侧脸引导方向相反);YAW_COEFF 2.2→3.8(±45° 侧脸实测只读 ±25°);PITCH_BASELINE 0.32→0.45(正脸误读 29° 被判低头) - head-pose 自测夹具按新系数数值标定,全部通过 - e2e 新增 E7:模拟摄像头全自动三连拍(正脸→右脸→左脸,姿态引导+倒计时+抓帧,分析请求拦截不烧次数包),全量绿
This commit is contained in:
+2
-2
@@ -55,7 +55,7 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
|
||||
|
||||
- **步骤圆点**:顶部三点,当前步高亮、完成步绿色打勾。
|
||||
- **取景蒙版**:内联 SVG(`#cam-mask`)按步骤切换轮廓——正脸步为正脸线框(含耳部),侧脸步为对应方向的侧脸剪影(鼻朝向与转头方向一致);框外 58% 黑色半透明压暗(SVG mask 镂空),轮廓线 2.5px 半透明白。未检测到人脸时轮廓呼吸动画(1.8s);姿态 ok 时轮廓变绿并停止动效。
|
||||
- **实时检测**:face-api 从本地 `vendor/faceapi/` 加载(`tinyFaceDetector` + `faceLandmark68Net`)。进入拍照页即开始加载模型(状态提示"检测引擎加载中…"),加载完成前快门为手动模式。检测循环 ~150ms 一帧,`detectSingleFace(video, TinyFaceDetectorOptions({inputSize:320, scoreThreshold:0.4})).withFaceLandmarks()` 后拿 68 特征点调 `HeadPose.check(pos, target, /* mirrored= */true)`;人脸框高度占比 <35% 提示"请靠近一点"、>80% 提示"请远一点"。提示一次一条,优先级:无人脸 > 距离 > 角度。
|
||||
- **实时检测**:face-api 从本地 `vendor/faceapi/` 加载(`tinyFaceDetector` + `faceLandmark68Net`)。进入拍照页即开始加载模型(状态提示"检测引擎加载中…"),加载完成前快门为手动模式。检测循环 ~150ms 一帧,`detectSingleFace(video, TinyFaceDetectorOptions({inputSize:416, scoreThreshold:0.3})).withFaceLandmarks()` 后拿 68 特征点调 `HeadPose.check(pos, target, /* mirrored= */true)`;连续漏检 ≥3 帧才报"未检测到人脸"(容忍单帧抖动);人脸框高度占比 <26% 提示"请靠近一点"、>58% 提示"请远一点"(以取景框≈42% 屏高标定)。提示一次一条,优先级:无人脸 > 距离 > 角度。
|
||||
- **倒计时自动拍**:姿态 ok 连续维持 ≥600ms 进入 3s 倒计时(中央大数字,轮廓保持绿色);倒计时中任一帧不 ok 立即取消并回到对应提示。倒计时结束进入白屏补光。
|
||||
- **白屏补光**:全屏纯白覆盖层瞬间点亮,~300ms 后抓全分辨率帧(镜像 + 最长边 1280 JPEG),保持 ~300ms 再淡出(总 600–900ms),同步 `navigator.vibrate(30)`。
|
||||
- **手动兜底**:快门任何时刻可拍当前步(同样走白屏补光),不依赖检测;face-api/模型加载失败 → toast"自动识别不可用,已切换手动拍摄",全程手动。系统相机降级模式(getUserMedia 不可用/被拒)维持原样:无检测、无补光。
|
||||
@@ -129,7 +129,7 @@ server {
|
||||
- 产品图(prod-1.jpg)为设计稿素材;检测图已替换为用户实拍。
|
||||
- **语音文件待生成**:播报文案/文件名/格式已定(`../docs/语音播报内容设计文档_V1.0.md`),生成后放入 `assets/voice/` 并更新 `manifest.json` 即生效。
|
||||
- 切换镜头、「了解更多」为开发中占位(Toast 提示)。
|
||||
- **侧脸角度符号与 yaw 系数需真机校准**:`head-pose.js` 的 `YAW_COEFF`/pitch 映射与 `mirrored` 符号在不同机型/镜像策略下可能相反,上线前用真机样本回归(校准接口:调用方 `mirrored` 标志与 `HeadPose.config`)。
|
||||
- **姿态与距离参数已用模拟摄像头校准**(2026-09-27):以 C3 三视图(+45° 侧脸)生成 Y4M 模拟摄像头视频,闭环修正了 yaw 增益(2.2→3.8)、pitch 基线(0.32→0.45)、check() 双重镜像符号 bug、距离阈值(0.35/0.8→0.26/0.58)、检测参数(320/0.4→416/0.3)与漏检 3 帧容忍;自动三连拍在无头浏览器全程通过(e2e E7)。**真机仍建议复核一次**:不同机型前置镜像策略/取景比例可能微调体验(校准接口:调用方 `mirrored` 标志与 `HeadPose.config`)。
|
||||
|
||||
## 技术说明
|
||||
|
||||
|
||||
+15
-5
@@ -310,10 +310,13 @@
|
||||
/* ─── 姿态检测(face-api + HeadPose,引导式自动三连拍) ─── */
|
||||
var MODEL_URL = 'vendor/faceapi/models';
|
||||
var DETECT_INTERVAL = 150; // 检测帧间隔(ms)
|
||||
var DETECT_INPUT_SIZE = 416; // tinyFaceDetector 输入尺寸:320 下正脸置信度仅 0.67 且框偏松,416 置信度 ~0.99 框更准(CPU 端略慢,移动端 GPU 无感)
|
||||
var DETECT_SCORE = 0.3; // 置信度阈值:0.4 在噪声帧易漏检,0.3 配合距离/角度闸门足够抗误检
|
||||
var MISS_FRAMES_MAX = 3; // 连续漏检 ≥3 帧(~450ms)才报「未检测到人脸」,容忍单帧抖动
|
||||
var OK_HOLD_MS = 600; // 姿态 ok 需连续维持时长,达标才进倒计时
|
||||
var COUNTDOWN_S = 3; // 倒计时秒数
|
||||
var NO_PROGRESS_MS = 12000; // 单步超过该时长未触发倒计时→浮出手动提示
|
||||
var FACE_NEAR = 0.35, FACE_FAR = 0.8; // 人脸框高度占画面比:<35% 太远,>80% 太近
|
||||
var FACE_NEAR = 0.26, FACE_FAR = 0.58; // 人脸框高/视频高:<26% 太远,>58% 太近(以取景框≈42% 屏高、416 输入实测框 0.40–0.44 标定)
|
||||
|
||||
var modelsPromise = null, modelsOk = false, detectDisabled = false; // detectDisabled:face-api 缺失→全程手动;模型加载失败仅当次禁用,下次进拍照页重试
|
||||
var detRunning = false, detTimer = null; // 检测循环
|
||||
@@ -356,6 +359,7 @@
|
||||
// 每步进入/回退时的检测状态机重置(蒙版/提示/倒计时/无进展计时全部归位)
|
||||
function resetDetectState() {
|
||||
okSince = 0;
|
||||
missFrames = 0;
|
||||
lastHint = '';
|
||||
cancelCountdown();
|
||||
clearTimeout(nudgeTimer);
|
||||
@@ -429,7 +433,7 @@
|
||||
if (shutterLock || capturing || !camLive) return Promise.resolve(); // 抓帧/流程推进中不判姿态
|
||||
var step = camStep, seq = camSeq; // 快照:检测是异步的,帧落地时步骤/流程可能已变
|
||||
return faceapi
|
||||
.detectSingleFace(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: 320, scoreThreshold: 0.4 }))
|
||||
.detectSingleFace(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
|
||||
.withFaceLandmarks()
|
||||
.then(function (d) {
|
||||
if (step !== camStep || seq !== camSeq) return; // 跨步骤陈帧/流程已重置:丢弃
|
||||
@@ -437,10 +441,15 @@
|
||||
})
|
||||
.catch(function () { /* 单帧异常忽略,下一帧继续 */ });
|
||||
}
|
||||
var missFrames = 0; // 连续漏检帧计数:单帧抖动不报「未检测到人脸」
|
||||
function onDetect(d) {
|
||||
var hint = '', ok = false, hasFace = !!d;
|
||||
if (!hasFace) {
|
||||
var hint = '', ok = false;
|
||||
missFrames = d ? 0 : missFrames + 1;
|
||||
var hasFace = !!d, noFace = !hasFace && missFrames >= MISS_FRAMES_MAX;
|
||||
if (noFace) {
|
||||
hint = '未检测到人脸';
|
||||
} else if (!hasFace) {
|
||||
return; // 偶发漏检帧:保持上一条提示与状态,不扰动倒计时/ok 累积
|
||||
} else {
|
||||
// 提示优先级:无人脸 > 距离 > 角度
|
||||
var ratio = d.detection.box.height / (camVideo.videoHeight || 1);
|
||||
@@ -452,9 +461,10 @@
|
||||
hint = pose.hint;
|
||||
ok = pose.ok;
|
||||
}
|
||||
window.__dbgRatio = ratio; // 自动化校准/测试用(检测框高/视频高)
|
||||
}
|
||||
if (counting && !ok) cancelCountdown(); // 倒计时中任一帧不 ok→立即取消,回到对应提示
|
||||
setMask(counting || ok ? 'ok' : (hasFace ? '' : 'no-face'));
|
||||
setMask(counting || ok ? 'ok' : (noFace ? 'no-face' : ''));
|
||||
setHint(counting ? '保持不动' : hint, counting || ok);
|
||||
if (counting) return;
|
||||
if (ok) {
|
||||
|
||||
+22
-21
@@ -52,13 +52,13 @@
|
||||
CHIN: 8
|
||||
};
|
||||
|
||||
// ---- 经验系数(⚠ 真机需校准) ----
|
||||
// ---- 经验系数(2026-09-27 用 C3 三视图(+45°侧脸)模拟摄像头校准) ----
|
||||
// yaw = atan((dLeft-dRight)/(dLeft+dRight)) × YAW_COEFF。
|
||||
// atan 输出 ∈ (-45°,45°),取 2.2 使真实 ~45° 侧脸读数落在 40° 上下。
|
||||
var YAW_COEFF = 2.2;
|
||||
// pitch:鼻底相对「眉眼连线中点」的纵向偏移 / 脸高(眉 19/24 — 下巴 8),
|
||||
// 校准:±45° 实拍侧脸读数 ±25°(2.2 偏小)→ 3.8 使读数落在 44° 上下;正脸 ~4°,仍 <12° 阈值
|
||||
var YAW_COEFF = 3.8;
|
||||
// pitch:鼻底相对「眉眼连线中点」的纵向偏移 / 脸高(眉 19/24 — 下巴 8),
|
||||
// 减去中性正脸基线后线性映射为角度。
|
||||
var PITCH_BASELINE = 0.32; // 正脸时 offset/faceHeight 的典型值
|
||||
var PITCH_BASELINE = 0.45; // 校准:0.32 时正脸误读 ~29°(被误判低头),实测基线 0.48,留 0.03 余量
|
||||
var PITCH_COEFF = 180; // 归一化偏移 → 角度 的增益
|
||||
|
||||
// ---- check 阈值(角度制) ----
|
||||
@@ -170,8 +170,9 @@
|
||||
}
|
||||
if (!e) return { ok: false, hint: HINT_FRONT }; // 退化特征点(重合/脸高过小 → NaN/Infinity)
|
||||
|
||||
// 语义 yaw:>0 = 被摄者向自己的左侧转头,与预览是否镜像无关
|
||||
var sYaw = mirrored ? -e.yaw : e.yaw;
|
||||
// 语义 yaw:>0 = 被摄者向自己的左侧转头。estimate(mirrored) 内部已按镜像翻转过符号,
|
||||
// 这里直接用 e.yaw(此前再翻一次是双重镜像 bug:侧脸引导方向完全相反)
|
||||
var sYaw = e.yaw;
|
||||
var pitch = e.pitch;
|
||||
|
||||
if (target === 'front') {
|
||||
@@ -247,15 +248,15 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
|
||||
pts[24] = { x: 230, y: 70 };
|
||||
pts[8] = { x: 200, y: 240 }; // 下巴 → 脸高 240-70=170
|
||||
pts[30] = { x: 200, y: 140 }; // 鼻尖居中
|
||||
pts[33] = { x: 200, y: 139.4 }; // (139.4-85)/170 = 0.32 = PITCH_BASELINE
|
||||
pts[33] = { x: 200, y: 161.5 }; // (161.5-85)/170 = 0.45 = PITCH_BASELINE
|
||||
return pts;
|
||||
}
|
||||
|
||||
// 把鼻点横向偏移,模拟 ~45° 侧脸(向画面右侧偏 = 被摄者向左转)
|
||||
// 把鼻点横向偏移 17.7px,模拟 ~45° 侧脸(向画面右侧偏 = 被摄者向左转;偏移量按 YAW_COEFF=3.8 数值标定)
|
||||
function makeSide() {
|
||||
var pts = makeFrontal();
|
||||
pts[30] = { x: 235, y: 140 };
|
||||
pts[33] = { x: 235, y: 139.4 };
|
||||
pts[30] = { x: 217.7, y: 140 };
|
||||
pts[33] = { x: 217.7, y: 161.5 };
|
||||
return pts;
|
||||
}
|
||||
|
||||
@@ -279,16 +280,16 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
|
||||
ok(rc.ok === true, '侧脸(被摄者左转)target=right ok');
|
||||
ok(lc.ok === false && lc.hint === '请向右转头', '同侧脸 target=left 拒绝并引导向右');
|
||||
|
||||
// 5) mirrored=true 翻转 yaw 符号,但 check 语义不变
|
||||
// 5) mirrored=true 翻转 yaw 符号,check 语义随之翻转(同一点集在镜像预览下对应相反的物理转头)
|
||||
var sideM = HeadPose.estimate(makeSide(), true);
|
||||
ok(Math.abs(sideM.yaw + side.yaw) < 0.001, 'mirrored=true 时 yaw 符号翻转');
|
||||
ok(HeadPose.check(makeSide(), 'right', true).ok === true, 'mirrored 下 check(right) 仍 ok');
|
||||
ok(HeadPose.check(makeSide(), 'left', true).ok === false, 'mirrored 下 check(left) 仍拒绝');
|
||||
ok(HeadPose.check(makeSide(), 'right', true).ok === false, 'mirrored 语义翻转:check(right) 拒绝');
|
||||
ok(HeadPose.check(makeSide(), 'left', true).ok === true, 'mirrored 语义翻转:check(left) 通过');
|
||||
|
||||
// 6) 反方向侧脸:鼻尖偏画面左 → target 'left' 通过
|
||||
// 6) 反方向侧脸:鼻尖偏画面左 → target 'left' 通过
|
||||
var back = makeSide();
|
||||
back[30] = { x: 165, y: 140 };
|
||||
back[33] = { x: 165, y: 139.4 };
|
||||
back[30] = { x: 182.3, y: 140 };
|
||||
back[33] = { x: 182.3, y: 161.5 };
|
||||
var bc = HeadPose.check(back, 'left', false);
|
||||
ok(bc.ok === true, '反向侧脸 target=left ok');
|
||||
ok(HeadPose.check(back, 'right', false).ok === false, '反向侧脸 target=right 拒绝');
|
||||
@@ -330,15 +331,15 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
|
||||
// 13) 正脸阈值收紧到 |yaw|≤12°(Face++ 口径 <10° + 2° 余量):yaw≈13.7°(旧阈值 15 内)应拒绝
|
||||
ok(HeadPose.config.FRONT_YAW_MAX === 12, 'config.FRONT_YAW_MAX = 12');
|
||||
var slight = makeFrontal();
|
||||
slight[30] = { x: 209, y: 140 };
|
||||
slight[33] = { x: 209, y: 139.4 };
|
||||
slight[30] = { x: 205.18, y: 140 }; // 偏移 5.18 → yaw≈13.7°(YAW_COEFF=3.8 标定)
|
||||
slight[33] = { x: 205.18, y: 161.5 };
|
||||
var se = HeadPose.estimate(slight, false);
|
||||
ok(se.yaw > 12 && se.yaw < 15, '轻微偏头 yaw∈(12,15) (got ' + se.yaw.toFixed(2) + ')');
|
||||
var sc2 = HeadPose.check(slight, 'front', false);
|
||||
ok(sc2.ok === false, 'yaw≈13.7° front 拒绝(旧阈值 15 会放行)');
|
||||
var slight2 = makeFrontal();
|
||||
slight2[30] = { x: 207, y: 140 };
|
||||
slight2[33] = { x: 207, y: 139.4 };
|
||||
slight2[30] = { x: 204.04, y: 140 }; // 偏移 4.04 → yaw≈10.7°
|
||||
slight2[33] = { x: 204.04, y: 161.5 };
|
||||
ok(HeadPose.check(slight2, 'front', false).ok === true, 'yaw≈10.7°(≤12°)front 通过');
|
||||
|
||||
if (failures > 0) {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 272 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1003 KiB |
Reference in New Issue
Block a user