diff --git a/h5/README.md b/h5/README.md index 2cea180..2bc2538 100644 --- a/h5/README.md +++ b/h5/README.md @@ -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`)。 ## 技术说明 diff --git a/h5/js/app.js b/h5/js/app.js index df39e6a..459a16e 100644 --- a/h5/js/app.js +++ b/h5/js/app.js @@ -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) { diff --git a/h5/js/head-pose.js b/h5/js/head-pose.js index dcfab11..6c798ab 100644 --- a/h5/js/head-pose.js +++ b/h5/js/head-pose.js @@ -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) { diff --git a/h5/preview/e2e-20260926/11-auto3shot-report.png b/h5/preview/e2e-20260926/11-auto3shot-report.png new file mode 100644 index 0000000..67c94e9 Binary files /dev/null and b/h5/preview/e2e-20260926/11-auto3shot-report.png differ diff --git a/h5/preview/e2e-20260926/debug-cam-live.png b/h5/preview/e2e-20260926/debug-cam-live.png new file mode 100644 index 0000000..7d288de Binary files /dev/null and b/h5/preview/e2e-20260926/debug-cam-live.png differ