feat: 拍摄流程对齐 Figma(正→左→右顺序/描摹轮廓蒙版/正脸只提示回正) + 报告页按 Figma 08 重排
- 拍照顺序 正脸→左脸→右脸:步骤文案/语音文件(step2-leftface/step3-rightface)/payload 归属对齐 - 蒙版轮廓由 Figma 拍照示意位图 alpha 镂空区逐像素描摹生成(0 正脸含耳/1 左脸鼻朝左/2 右脸鼻朝右),框外纯黑 - 侧脸步预览与抓帧不镜像(左右不再迷惑),HeadPose 侧脸自校准阈值 30 帧防误翻转 - 语音:「保持不动」播完再进倒计时(afterCurrent 接续,不再拦腰切断) - 正脸步角度不合格只提示「请正对屏幕」,阈值放宽 |yaw|≤15°/|pitch|≤18°;转头提示仅侧脸步 - 抓帧改完整帧(修 Face++ INVALID_IMAGE_FACE) - 报告页按 Figma 08:内联白底顶栏(返回+标题)、总分双独立白卡(30px 大数字+15px 单位后缀)、 维度短竖分隔线+最低分标红、Tab 黄条、免责左对齐、护理建议去 tag 加粗+收尾句、 产品大标题移出白卡、COREⅡ 换 Figma 导出 SVG 字标、产品图 151×174、卡圆角 10px - 精华卡判据措辞用户化(均分档→综合均分/最差项约束→最低分维度/年龄组档→年龄段,引擎值不动) - 修报告页滚动 P0(去 position:relative 覆写),文档同步(语音文档/README)
This commit is contained in:
+71
-42
@@ -168,13 +168,16 @@
|
||||
var IS_MOCK = /[?&]mock=1/.test(location.search); // mock:不加载 face-api、不跑检测,analyze 直返内置样本
|
||||
var camVideo = $('#cam-video'), camFile = $('#cam-file'), albumFile = $('#album-file');
|
||||
var camStream = null, camLive = false, camFallback = false, camSeqCam = 0; // camSeqCam:相机启动令牌,防止迟到的授权回调泄漏摄像头
|
||||
var photos = []; // 三连拍(dataURL),与 CAM_STEPS 同序:[0]正脸 [1]右脸(头左转) [2]左脸(头右转)
|
||||
var photos = []; // 三连拍(dataURL),与 CAM_STEPS 同序:[0]正脸 [1]左脸(头右转) [2]右脸(头左转)
|
||||
|
||||
// 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级
|
||||
var camFacing = 'user'; // 当前镜头:'user' 前置 | 'environment' 后置(翻转按钮切换)
|
||||
var camMirrored = true; // 预览是否镜像(前置镜像,后置不镜像)
|
||||
var mirrorCalibrated = false; // 镜像自校准:本轮是否已翻转过一次(防振荡,一次校准管整个会话)
|
||||
var camMirrored = true; // 前置镜像开关:仅正脸步生效(自拍习惯);侧脸步始终不镜像(左右不再迷惑,与取景轮廓方向一致)
|
||||
var sideYawFlip = false; // 侧脸步语义 yaw 反向标志:个别机型前置帧本身已镜像时由自校准翻转
|
||||
var mirrorCalibrated = false; // 侧脸自校准:本轮是否已翻转过一次(防振荡,一次校准管整个会话)
|
||||
var oppFrames = 0; // 侧脸步语义 yaw 连续反向帧计数
|
||||
// 预览/抓帧是否镜像:正脸步跟随 camMirrored,侧脸步恒不镜像
|
||||
function effMirror() { return camMirrored && CAM_STEPS[camStep].target === 'front'; }
|
||||
function getCamStream() {
|
||||
return navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: camFacing }, width: { ideal: 1280 }, height: { ideal: 1280 } },
|
||||
@@ -207,7 +210,7 @@
|
||||
if (token !== camSeqCam) { stream.getTracks().forEach(function (t) { t.stop(); }); return; } // 期间已退出拍照页:立即释放
|
||||
camStream = stream;
|
||||
camVideo.srcObject = stream;
|
||||
camVideo.classList.toggle('mirror', camMirrored);
|
||||
camVideo.classList.toggle('mirror', effMirror());
|
||||
// iOS 来电/切后台等系统打断会结束 track:挂 ended 监听走自动重连
|
||||
stream.getTracks().forEach(function (t) {
|
||||
t.onended = function () { onCamTrackEnded(token); };
|
||||
@@ -247,22 +250,18 @@
|
||||
resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框
|
||||
toast('已切换为系统相机拍摄');
|
||||
}
|
||||
// 从视频帧截取照片(镜像绘制,与预览一致;压到最长边 1280 的 JPEG)
|
||||
// 从视频帧截取照片(完整帧,不镜像侧脸步;压到最长边 1280 的 JPEG)
|
||||
// 抓完整帧而非取景框可见区:新 UI 轮廓外全黑,用户只关注轮廓内人脸;按可见区裁切在侧脸/偏站位时会切掉人脸(Face++ INVALID_IMAGE_FACE)
|
||||
function captureFromVideo() {
|
||||
var vw = camVideo.videoWidth, vh = camVideo.videoHeight;
|
||||
if (!vw || !vh) return null;
|
||||
// 只取取景框可见区域(object-fit:cover 的中心裁切):照片 = 用户当时看到的画面
|
||||
var box = camVideo.getBoundingClientRect();
|
||||
var scaleFit = Math.max(box.width / vw, box.height / vh);
|
||||
var visW = Math.min(vw, box.width / scaleFit), visH = Math.min(vh, box.height / scaleFit);
|
||||
var sx = (vw - visW) / 2, sy = (vh - visH) / 2;
|
||||
var scale = Math.min(1, 1280 / Math.max(visW, visH));
|
||||
var cw = Math.round(visW * scale), ch = Math.round(visH * scale);
|
||||
var scale = Math.min(1, 1280 / Math.max(vw, vh));
|
||||
var cw = Math.round(vw * scale), ch = Math.round(vh * scale);
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = cw; canvas.height = ch;
|
||||
var ctx = canvas.getContext('2d');
|
||||
if (camMirrored) { ctx.translate(cw, 0); ctx.scale(-1, 1); } // 前置镜像,与预览一致
|
||||
ctx.drawImage(camVideo, sx, sy, visW, visH, 0, 0, cw, ch);
|
||||
if (effMirror()) { ctx.translate(cw, 0); ctx.scale(-1, 1); } // 与预览一致:正脸步前置镜像,侧脸步不镜像(照片=摄像头原始视角)
|
||||
ctx.drawImage(camVideo, 0, 0, vw, vh, 0, 0, cw, ch);
|
||||
return canvas.toDataURL('image/jpeg', 0.88);
|
||||
}
|
||||
// 文件 → 统一压成最长边 1280 的 JPEG → 推进流程(降级系统相机与相册选图共用)
|
||||
@@ -303,7 +302,7 @@
|
||||
/* ─── 语音播报(WebAudio 单通道;assets/voice/<id>.mp3,缺失静默跳过;同条 4s 去重)
|
||||
文案/文件名契约:docs/语音播报内容设计文档_V1.0.md ─── */
|
||||
var VOICE = {
|
||||
step: ['step1-front', 'step2-rightface', 'step3-leftface'],
|
||||
step: ['step1-front', 'step2-leftface', 'step3-rightface'],
|
||||
hint: {
|
||||
'未检测到人脸': 'hint-noface', '请靠近一点': 'hint-near', '请远一点': 'hint-far',
|
||||
'请正对屏幕': 'hint-front', '请向左转头': 'hint-turn-left', '请向右转头': 'hint-turn-right',
|
||||
@@ -312,7 +311,7 @@
|
||||
countdown: 'countdown-321', shot: 'shot-done', analyzing: 'analyzing', fail: 'analyze-fail'
|
||||
};
|
||||
var voice = (function () {
|
||||
var ctx = null, lastAt = {}, cur = null, files = null; // files:null=清单未加载;{}=无可用语音
|
||||
var ctx = null, lastAt = {}, cur = null, curEnd = 0, files = null; // files:null=清单未加载;{}=无可用语音
|
||||
// 清单制:只播 assets/voice/manifest.json 里声明的文件,不逐个探文件(避免控制台 404 噪音)
|
||||
fetch('assets/voice/manifest.json')
|
||||
.then(function (r) { return r.ok ? r.json() : { files: [] }; })
|
||||
@@ -333,15 +332,29 @@
|
||||
if (!ab || !ctx) return;
|
||||
ctx.decodeAudioData(ab, function (buf) {
|
||||
if (cur) { try { cur.stop(); } catch (e) {} }
|
||||
cur = ctx.createBufferSource();
|
||||
cur.buffer = buf;
|
||||
cur.connect(ctx.destination);
|
||||
cur.start(0);
|
||||
var src = ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
src.connect(ctx.destination);
|
||||
cur = src;
|
||||
curEnd = Date.now() + buf.duration * 1000;
|
||||
src.onended = function () { if (cur === src) cur = null; };
|
||||
src.start(0);
|
||||
}, function () { /* 解码失败忽略 */ });
|
||||
})
|
||||
.catch(function () { /* 网络异常忽略 */ });
|
||||
}
|
||||
return { unlock: unlock, play: play };
|
||||
// 等当前播报自然收尾后执行 cb(倒计时接续「保持不动」,不拦腰切断);无播报在执行则立即执行
|
||||
// 返回取消函数;注意:cur 被新播报 stop 也会触发 onended,调用方需配合 cancelCountdown 等路径取消
|
||||
function afterCurrent(cb) {
|
||||
if (!cur || Date.now() >= curEnd - 60) { cb(); return null; }
|
||||
var src = cur, cancelled = false;
|
||||
src.onended = function () {
|
||||
if (cur === src) cur = null;
|
||||
if (!cancelled) cb();
|
||||
};
|
||||
return function () { cancelled = true; };
|
||||
}
|
||||
return { unlock: unlock, play: play, afterCurrent: afterCurrent };
|
||||
})();
|
||||
document.addEventListener('click', voice.unlock); // 任意点击即完成解锁
|
||||
|
||||
@@ -360,6 +373,7 @@
|
||||
var detRunning = false, detTimer = null; // 检测循环
|
||||
var okSince = 0; // 当前连续 ok 起点(0=未在累积)
|
||||
var counting = false, cdTimer = null, cdN = 0; // 倒计时
|
||||
var cdDeferCancel = null; // 倒计时等播报收尾的取消句柄(afterCurrent)
|
||||
var nudgeTimer = null; // 12s 无进展提示
|
||||
var capturing = false; // 白屏补光抓帧中(暂停姿态判定)
|
||||
var lastHint = ''; // 提示文案去重
|
||||
@@ -419,15 +433,21 @@
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
counting = true;
|
||||
counting = true; // 立即置位:等待播报收尾期间姿态被破坏,走 cancelCountdown 统一取消
|
||||
clearTimeout(nudgeTimer);
|
||||
hideNudge();
|
||||
setMask('ok'); // 轮廓保持绿色
|
||||
setHint('保持不动', true, /* silent= */true); // 倒计时前不再抢播「保持不动」(会被 321 立刻切断,听着像吞字)
|
||||
// 「保持不动」等仍在播放的提示音自然播完再进倒计时,画面数字与「三、二、一」同步起播
|
||||
cdDeferCancel = voice.afterCurrent(beginCountdown);
|
||||
}
|
||||
function beginCountdown() {
|
||||
cdDeferCancel = null;
|
||||
if (!counting) return; // 等待期间已被取消(姿态破坏/退出拍照页)
|
||||
cdN = COUNTDOWN_S;
|
||||
var c = $('#cam-count');
|
||||
c.textContent = cdN;
|
||||
c.classList.add('show');
|
||||
setMask('ok'); // 轮廓保持绿色
|
||||
setHint('保持不动', true, /* silent= */true); // 倒计时前不再抢播「保持不动」(会被 321 立刻切断,听着像吞字)
|
||||
voice.play(VOICE.countdown); // 「三、二、一」与数字同步起播
|
||||
cdTimer = setTimeout(cdTick, 1000);
|
||||
}
|
||||
@@ -444,6 +464,7 @@
|
||||
}
|
||||
function cancelCountdown() {
|
||||
counting = false;
|
||||
if (cdDeferCancel) { cdDeferCancel(); cdDeferCancel = null; } // 取消尚未起播的倒计时接续
|
||||
clearTimeout(cdTimer);
|
||||
$('#cam-count').classList.remove('show');
|
||||
}
|
||||
@@ -495,19 +516,20 @@
|
||||
if (ratio < FACE_NEAR) hint = '请靠近一点';
|
||||
else if (ratio > FACE_FAR) hint = '请远一点';
|
||||
else {
|
||||
// 镜像标志跟随镜头(前置 true/后置 false);距离不合格时角度不判 ok
|
||||
var pose = HeadPose.check(d.landmarks.positions, CAM_STEPS[camStep].target, camMirrored);
|
||||
// 镜像自校准:侧脸步用户实际转向与语义 yaw 持续相反(本机镜像策略与 camMirrored 假设不符),
|
||||
// 连续 5 帧强反向即翻转镜像约定一次(翻转后语义回正,通常立即姿态合格)
|
||||
// 距离不合格时角度不判 ok。镜像参数:正脸步跟随镜头(前置 true/后置 false);
|
||||
// 侧脸步预览不镜像,特征点即原始视角,标准机型恒为 false,个别前置帧已镜像的机型由自校准翻转 sideYawFlip
|
||||
var tgt = CAM_STEPS[camStep].target;
|
||||
var pose = HeadPose.check(d.landmarks.positions, tgt, tgt === 'front' ? camMirrored : sideYawFlip);
|
||||
// 侧脸自校准:个别机型前置帧本身已镜像(语义 yaw 与实际转向持续相反)时自救——
|
||||
// 需连续 30 帧(~10s)强反向才翻转一次:阈值要高,否则用户转错方向 1~2s 就会被误判为镜像机,
|
||||
// 反而把错误的侧脸当合格抓走(左右图归属错乱)。翻转只动判定方向,不动预览画面
|
||||
if (tgt !== 'front' && pose.yaw != null && !mirrorCalibrated) {
|
||||
var wantSign = tgt === 'right' ? 1 : -1;
|
||||
if (pose.yaw * wantSign < -12) {
|
||||
if (++oppFrames >= 5) {
|
||||
camMirrored = !camMirrored;
|
||||
if (++oppFrames >= 30) {
|
||||
sideYawFlip = !sideYawFlip;
|
||||
mirrorCalibrated = true;
|
||||
oppFrames = 0;
|
||||
camVideo.classList.toggle('mirror', camMirrored);
|
||||
}
|
||||
} else oppFrames = 0;
|
||||
}
|
||||
@@ -528,13 +550,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 拍照流程(三步:正脸 → 右脸出镜 → 左脸出镜) ─── */
|
||||
// target 即 HeadPose.check 的 target:2/3 右脸出镜=用户头向左转(target 'right',产物作 right_image);
|
||||
// 3/3 左脸出镜=头向右转(target 'left',产物作 left_image)。
|
||||
/* ─── 拍照流程(三步:正脸 → 左脸出镜 → 右脸出镜) ─── */
|
||||
// target 即 HeadPose.check 的 target:2/3 左脸出镜=用户头向右转(target 'left',产物作 left_image);
|
||||
// 3/3 右脸出镜=头向左转(target 'right',产物作 right_image)。
|
||||
var CAM_STEPS = [
|
||||
{ bg: 'assets/img/cam-front.jpg', step: '1/3<br>请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'], target: 'front' },
|
||||
{ bg: 'assets/img/cam-left.jpg', step: '2/3<br>请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right' },
|
||||
{ bg: 'assets/img/cam-right.jpg', step: '3/3<br>请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left' }
|
||||
{ bg: 'assets/img/cam-left.jpg', step: '2/3<br>请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left' },
|
||||
{ bg: 'assets/img/cam-right.jpg', step: '3/3<br>请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right' }
|
||||
];
|
||||
var camStep = 0;
|
||||
|
||||
@@ -552,6 +574,7 @@
|
||||
dots[i].classList.toggle('on', i === camStep);
|
||||
}
|
||||
$('#cam-mask').setAttribute('data-step', camStep); // 蒙版按步骤切换
|
||||
camVideo.classList.toggle('mirror', effMirror()); // 侧脸步预览切回不镜像(正脸步恢复前置镜像)
|
||||
resetDetectState();
|
||||
voice.play(VOICE.step[camStep]); // 步骤引导语音
|
||||
}
|
||||
@@ -603,7 +626,7 @@
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// 三张拍完:三视图送检(正脸=photos[0],左=photos[2],右=photos[1])
|
||||
// 三张拍完:三视图送检(正脸=photos[0],左=photos[1],右=photos[2])
|
||||
function submitPhotos(seq) {
|
||||
camStep = 0;
|
||||
openOverlay('p-analyzing');
|
||||
@@ -685,7 +708,7 @@
|
||||
fetch('/api/skin-analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ front_image: ph[0], left_image: ph[2], right_image: ph[1] }),
|
||||
body: JSON.stringify({ front_image: ph[0], left_image: ph[1], right_image: ph[2] }),
|
||||
signal: ctrl ? ctrl.signal : undefined // 老内核无 AbortController:仅靠令牌丢弃迟到响应
|
||||
}).then(function (res) { return res.json(); })
|
||||
.then(function (data) {
|
||||
@@ -911,8 +934,9 @@
|
||||
for (i = 0; i < 3; i++) if (typeof ds[i] === 'number') { sum += ds[i]; n++; }
|
||||
if (n) total = Math.round(sum / n * 10);
|
||||
}
|
||||
$('#stat-score').textContent = total != null ? total + '分' : '--分';
|
||||
$('#stat-age').textContent = vm.skinAgeDisplay != null ? vm.skinAgeDisplay + '岁' : '--岁';
|
||||
// 设计稿:大数字 30px/400 + 单位 15px/500(分/岁为小字后缀)
|
||||
$('#stat-score').innerHTML = total != null ? '<b>' + total + '</b><i>分</i>' : '<b>--</b><i>分</i>';
|
||||
$('#stat-age').innerHTML = vm.skinAgeDisplay != null ? '<b>' + vm.skinAgeDisplay + '</b><i>岁</i>' : '<b>--</b><i>岁</i>';
|
||||
$('#stat-age-note').textContent = ''; // 肌龄小字按产品要求不展示(钳制留痕 vm.ageClampNote 仍随数据返回)
|
||||
|
||||
// 三维度:S 值(1 位小数),最低分标红(设计稿无档位徽章)
|
||||
@@ -954,17 +978,22 @@
|
||||
}
|
||||
|
||||
// 护理建议(V2.3 §8 五段:肤质定制/最差2分类/精华/生活方式);综合分析与生活方式通栏已按需求移除
|
||||
// 展示对齐 Figma:纯「序号、正文」,不显示段名【tag】、不加粗
|
||||
var careBox = $('#care-list');
|
||||
if (careBox) {
|
||||
careBox.innerHTML = (vm.careAdvice || []).map(function (s, i) {
|
||||
return '<p class="care-item"><b>' + (i + 1) + '、【' + esc(s.tag) + '】</b>' + esc(s.text) + '</p>';
|
||||
return '<p class="care-item">' + (i + 1) + '、' + esc(s.text) + '</p>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 精华推荐卡
|
||||
$('#essence-name').textContent = vm.essence.name;
|
||||
$('#essence-text').textContent = vm.essence.text;
|
||||
$('#essence-triggers').textContent = vm.essence.triggers.length ? '触发:' + vm.essence.triggers.join('、') : '';
|
||||
// 命中判据:引擎内部名 → 用户可读措辞(引擎值被真值回归逐字锁定,仅展示层映射)
|
||||
var TRIGGER_LABEL = { '均分档': '综合均分', '最差项约束': '最低分维度', '年龄组档': '年龄段' };
|
||||
$('#essence-triggers').textContent = vm.essence.triggers.length
|
||||
? '推荐依据:' + vm.essence.triggers.map(function (t) { return TRIGGER_LABEL[t] || t; }).join('、')
|
||||
: '';
|
||||
}
|
||||
|
||||
/* ─── 相机可用后加载检测模型(加载完成前快门即手动模式) ─── */
|
||||
|
||||
+24
-22
@@ -62,9 +62,10 @@
|
||||
var PITCH_COEFF = 180; // 归一化偏移 → 角度 的增益
|
||||
|
||||
// ---- check 阈值(角度制) ----
|
||||
// 正脸口径:Face++ 正脸判定 |yaw|<10°,前端留 2° 余量取 ≤12°
|
||||
var FRONT_YAW_MAX = 12;
|
||||
var FRONT_PITCH_MAX = 15;
|
||||
// 正脸口径:Face++ 正脸判定 |yaw|<10°;产品要求(2026-09-28)正脸引导放宽一点点,
|
||||
// 前端取 |yaw|≤15°、|pitch|≤18°(略超 Face++ 口径,正脸图仍可正常分析,仅 orientation 读数略偏)
|
||||
var FRONT_YAW_MAX = 15;
|
||||
var FRONT_PITCH_MAX = 18;
|
||||
var SIDE_YAW_MIN = 28;
|
||||
var SIDE_YAW_MAX = 75;
|
||||
var SIDE_PITCH_MAX = 20;
|
||||
@@ -156,7 +157,8 @@
|
||||
/**
|
||||
* check(points, target, mirrored) -> { ok, hint }
|
||||
* target: 'front' | 'left' | 'right'
|
||||
* 'front' : |yaw|<=12 且 |pitch|<=15
|
||||
* 'front' : |yaw|<=15 且 |pitch|<=18;不 ok 时一律 hint='请正对屏幕'
|
||||
* (产品要求 2026-09-28:正脸步不提示左右转头/抬头低头,只引导回正)
|
||||
* 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-75°,-28°]),|pitch|<=20
|
||||
* 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [28°,75°]),|pitch|<=20
|
||||
* ok=true 时 hint='保持不动';输入非法(含 estimate 返回 null 的退化特征点)时 ok=false、hint='请正对屏幕'。
|
||||
@@ -179,11 +181,8 @@
|
||||
if (Math.abs(sYaw) <= FRONT_YAW_MAX && Math.abs(pitch) <= FRONT_PITCH_MAX) {
|
||||
return { ok: true, hint: HINT_KEEP, yaw: sYaw };
|
||||
}
|
||||
if (Math.abs(sYaw) > FRONT_YAW_MAX) {
|
||||
// 已向左转过头 → 提示向右回正,反之亦然
|
||||
return { ok: false, hint: sYaw > 0 ? HINT_TURN_RIGHT : HINT_TURN_LEFT, yaw: sYaw };
|
||||
}
|
||||
return { ok: false, hint: pitch > 0 ? HINT_LOWER : HINT_RAISE, yaw: sYaw };
|
||||
// 正脸步只提示回正(产品要求:不区分左右转头/抬头低头,避免正脸阶段引导混乱)
|
||||
return { ok: false, hint: HINT_FRONT, yaw: sYaw };
|
||||
}
|
||||
|
||||
if (target === 'left' || target === 'right') {
|
||||
@@ -294,22 +293,22 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
|
||||
ok(bc.ok === true, '反向侧脸 target=left ok');
|
||||
ok(HeadPose.check(back, 'right', false).ok === false, '反向侧脸 target=right 拒绝');
|
||||
|
||||
// 7) 侧脸做 front check → 拒绝并引导回正
|
||||
// 7) 侧脸做 front check → 拒绝并只引导回正(产品要求:正脸步不再提示左右转头)
|
||||
var sc = HeadPose.check(makeSide(), 'front', false);
|
||||
ok(sc.ok === false && sc.hint === '请向右转头', '侧脸 front 拒绝,hint=请向右转头');
|
||||
ok(sc.ok === false && sc.hint === '请正对屏幕', '侧脸 front 拒绝,hint=请正对屏幕');
|
||||
|
||||
// 8) roll:右眼(45 点)下移 10px → roll≈5.7°
|
||||
var tilted = makeFrontal();
|
||||
tilted[45] = { x: 250, y: 110 };
|
||||
ok(Math.abs(HeadPose.estimate(tilted, false).roll - 5.71) < 0.2, 'roll≈5.71°');
|
||||
|
||||
// 9) 低头:鼻底上移 → pitch<0,front 提示抬头
|
||||
// 9) 低头:鼻底上移 → pitch<0,front 拒绝并只提示回正(正脸步不再提示抬头/低头)
|
||||
var down = makeFrontal();
|
||||
down[33] = { x: 200, y: 100 };
|
||||
var de = HeadPose.estimate(down, false);
|
||||
var dc = HeadPose.check(down, 'front', false);
|
||||
ok(de.pitch < -30, '低头 pitch<0 (got ' + de.pitch.toFixed(2) + ')');
|
||||
ok(dc.ok === false && dc.hint === '请抬头', '低头 front 拒绝,hint=请抬头');
|
||||
ok(dc.ok === false && dc.hint === '请正对屏幕', '低头 front 拒绝,hint=请正对屏幕');
|
||||
|
||||
// 10) 非法输入:check 不抛异常,返回兜底引导
|
||||
var bad = HeadPose.check(null, 'front', false);
|
||||
@@ -328,19 +327,22 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
|
||||
ok(HeadPose.estimate(tiny, false) === null, 'faceHeight 过小 → estimate 返回 null');
|
||||
ok(HeadPose.check(tiny, 'front', false).hint === '请正对屏幕', 'faceHeight 过小 check 兜底');
|
||||
|
||||
// 13) 正脸阈值收紧到 |yaw|≤12°(Face++ 口径 <10° + 2° 余量):yaw≈13.7°(旧阈值 15 内)应拒绝
|
||||
ok(HeadPose.config.FRONT_YAW_MAX === 12, 'config.FRONT_YAW_MAX = 12');
|
||||
// 13) 正脸阈值放宽到 |yaw|≤15°(产品要求 2026-09-28):yaw≈14.9° 应通过,yaw≈16.2° 应拒绝且只提示回正
|
||||
ok(HeadPose.config.FRONT_YAW_MAX === 15, 'config.FRONT_YAW_MAX = 15');
|
||||
var slight = makeFrontal();
|
||||
slight[30] = { x: 205.18, y: 140 }; // 偏移 5.18 → yaw≈13.7°(YAW_COEFF=3.8 标定)
|
||||
slight[33] = { x: 205.18, y: 161.5 };
|
||||
slight[30] = { x: 205.6, y: 140 }; // 偏移 5.6 → yaw≈14.9°(YAW_COEFF=3.8 标定)
|
||||
slight[33] = { x: 205.6, y: 161.5 };
|
||||
var se = HeadPose.estimate(slight, false);
|
||||
ok(se.yaw > 12 && se.yaw < 15, '轻微偏头 yaw∈(12,15) (got ' + se.yaw.toFixed(2) + ')');
|
||||
ok(se.yaw > 13.5 && se.yaw <= 15, '轻微偏头 yaw∈(13.5,15] (got ' + se.yaw.toFixed(2) + ')');
|
||||
var sc2 = HeadPose.check(slight, 'front', false);
|
||||
ok(sc2.ok === false, 'yaw≈13.7° front 拒绝(旧阈值 15 会放行)');
|
||||
ok(sc2.ok === true, 'yaw≈14.9°(≤15°)front 通过(旧阈值 12 会拒绝)');
|
||||
var slight2 = makeFrontal();
|
||||
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 通过');
|
||||
slight2[30] = { x: 206.2, y: 140 }; // 偏移 6.2 → yaw≈16.2°
|
||||
slight2[33] = { x: 206.2, y: 161.5 };
|
||||
var se2 = HeadPose.estimate(slight2, false);
|
||||
ok(se2.yaw > 15 && se2.yaw < 17.5, '偏头 yaw∈(15,17.5) (got ' + se2.yaw.toFixed(2) + ')');
|
||||
var sc3 = HeadPose.check(slight2, 'front', false);
|
||||
ok(sc3.ok === false && sc3.hint === '请正对屏幕', 'yaw≈16.2° front 拒绝,hint=请正对屏幕');
|
||||
|
||||
if (failures > 0) {
|
||||
console.error('\n' + failures + ' 项自测失败');
|
||||
|
||||
Reference in New Issue
Block a user