feat(h5): apply fourth feedback and tighten local capture validation

This commit is contained in:
xsl
2026-10-12 20:16:41 +08:00
parent e53c809522
commit 92a3965b7d
33 changed files with 3758 additions and 1385 deletions
+41 -1
View File
@@ -85,6 +85,12 @@ function ageText(age) { return age == null ? '-' : esc(age) + '岁'; }
function skinAgeHtml(r) {
return '<b>原始 ' + ageText(r.skin_age) + '</b><div class="sub">展示 ' + ageText(r.skin_age_display) + '</div>';
}
function captureQualityHtml(r) {
var q = r.capture_quality;
if (!q) return '';
var label = q.status === 'invalid' ? '侧脸质量异常(未拦截)' : q.status === 'unavailable' ? '侧脸质量未获取' : '侧脸质量有效';
return '<div class="sub">' + esc(label) + '</div>';
}
function productName(name) {
for (var i = 0; i < ReportUI.products.length; i++) {
var p = ReportUI.products[i];
@@ -135,7 +141,7 @@ function loadRecords() {
'<td>' + thumb + '</td>' +
'<td class="mono">' + esc(it.record_id) + (it.retry_of ? '<div class="sub">重试自 ' + esc(it.retry_of) + '</div>' : '') + '</td>' +
'<td>' + profileHtml(it) + '</td>' +
'<td>' + esc(it.shoot_status) + '</td>' +
'<td>' + esc(it.shoot_status) + captureQualityHtml(it) + '</td>' +
'<td>' + esc(it.upload_status) + '</td>' +
'<td><span class="st ' + (ST_CLASS[it.detect_status] || '') + '">' + esc(it.detect_status) + '</span>' +
(it.fail_code ? '<div class="sub">' + esc(it.fail_code) + '</div>' : '') + '</td>' +
@@ -355,6 +361,17 @@ function renderDetail(data) {
'<span>被重试次数 <b>' + (r.retry_count || 0) + '</b></span>' +
'</div>');
/* 档案 */
if (r.capture_quality || r.capture_diagnostics) {
var quality = captureQualityHtml(r);
if (r.capture_quality) ['left', 'right'].forEach(function (key) {
var view = r.capture_quality.views[key];
quality += '<p>' + (key === 'left' ? '左脸' : '右脸') + ':quality=' + esc(view.quality == null ? '未获取' : view.quality) + ',角度=' + esc(view.angle == null ? '未获取' : view.angle) + ',状态=' + esc(view.status) + '</p>';
});
if (r.capture_diagnostics) quality += '<p>前端参数版本:' + esc(r.capture_diagnostics.version) + '(客户端自报,仅作校准参考)</p><pre class="d-copy">' + esc(JSON.stringify(r.capture_diagnostics, null, 2)) + '</pre>';
h += dSec('拍摄质量与前端校准', quality);
}
/* 档案 */
h += dSec('档案信息', '<div class="d-kv">' +
'<span>实际年龄 <b>' + ageText(r.real_age) + '</b></span>' +
@@ -432,6 +449,29 @@ function renderDetail(data) {
if (r.lifestyle) copyHtml += '<div class="d-copy"><b>生活方式:</b>' + esc(r.lifestyle) + '</div>';
if (copyHtml) h += dSec(presentation ? '前端报告结论(保存快照)与计算摘要' : '旧版生成文案(无前端快照)', copyHtml);
// Technical region metrics stay in the protected admin, not customer prose.
var wrinkleView = presentation && presentation.views && presentation.views.filter(function(view) { return view.key === 'wrinkles'; })[0];
var wrinkleRows = wrinkleView && wrinkleView.details;
if (!wrinkleRows && data.raw_json && data.raw_json.result) wrinkleRows = ReportUI.wrinkleDetails(data.raw_json.result);
if (wrinkleRows) {
var metricDefs = [['wrinkle_score', '皱纹分'], ['wrinkle_severity_level', '严重等级'],
['wrinkle_deep_num', '深纹条数'], ['wrinkle_shallow_num', '浅纹条数'],
['wrinkle_norm_length', '标准化长度'], ['wrinkle_norm_depth', '标准化深度'],
['wrinkle_pixel_density', '像素密度'], ['wrinkle_area_ratio', '面积比例'], ['wrinkle_deep_ratio', '深纹比例']];
var wrinkleHead = '<th>分区</th>', wrinkleBody = '';
metricDefs.forEach(function(metric) { wrinkleHead += '<th>' + metric[1] + '</th>'; });
wrinkleRows.forEach(function(row) {
wrinkleBody += '<tr><td>' + esc(row.name) + '</td>';
metricDefs.forEach(function(metric) {
var value = row.metrics[metric[0]];
wrinkleBody += '<td>' + (value == null ? '未获取' : esc(String(value))) + '</td>';
});
wrinkleBody += '</tr>';
});
h += dSec('皱纹分区原始指标(仅后台复核;左右以本人为准)', '<div style="overflow-x:auto"><table class="d-table"><thead><tr>' +
wrinkleHead + '</tr></thead><tbody>' + wrinkleBody + '</tbody></table></div>');
}
var care = presentation ? presentation.care : r.careAdvice;
if (care && care.length) {
var advice = '';
+158 -89
View File
@@ -76,7 +76,7 @@
function renderAccountStatus() {
var user = AccountClient.user();
document.querySelectorAll('[data-auth-status]').forEach(function (label) {
label.textContent = user ? (user.kind === 'wechat' ? '微信已登录' : '本机浏览器已登录') : '尚未登录';
label.textContent = user ? (user.kind === 'wechat' ? '微信已登录' : '游客已登录') : '尚未登录';
label.parentElement.dataset.kind = user ? user.kind : '';
});
}
@@ -84,7 +84,7 @@
showScreen('s-splash', 'replace');
$('#login-success-message').textContent = AccountClient.user().kind === 'wechat'
? '当前使用:微信登录\n同一微信账号可跨手机查看报告。'
: '当前使用:本机浏览器登录\n报告仅能通过当前浏览器身份查看。';
: '当前使用:游客登录\n游客报告仅可在当前浏览器查看,不能跨手机同步。';
openOverlay('p-login-success');
document.querySelectorAll('.screen').forEach(function (screen) { screen.inert = true; });
$('[data-action="login-confirm"]').focus();
@@ -141,21 +141,69 @@
}
/* ─── 弹窗管理 ─── */
var SCAN_CYCLE_MS = 1000, scanCycle = null, scanMask = null;
function restartScanCycle() {
if (!scanCycle || scanCycle.done) return;
var overlay = $('#p-analyzing'), cycle = scanCycle;
overlay.classList.remove('scan-running');
clearTimeout(cycle.timer);
// Restart from the first painted frame, including a late-loaded PSD image.
requestAnimationFrame(function () { requestAnimationFrame(function () {
if (scanCycle !== cycle || cycle.done) return;
overlay.style.setProperty('--scan-cycle', SCAN_CYCLE_MS + 'ms');
overlay.classList.add('scan-running');
// Legacy browsers without CSS animations still get a bounded visible wait.
if (getComputedStyle($('.scan-beam')).animationName === 'none') cycle.timer = setTimeout(cycle.resolve, SCAN_CYCLE_MS);
}); });
}
function startScanCycle() {
stopScanCycle();
var cycle = { done: false, timer: null };
cycle.promise = new Promise(function (resolve) {
cycle.resolve = function () { if (!cycle.done) { cycle.done = true; clearTimeout(cycle.timer); resolve(); } };
});
scanCycle = cycle;
restartScanCycle();
return cycle.promise;
}
function stopScanCycle() {
if (scanCycle) scanCycle.resolve();
scanCycle = null;
$('#p-analyzing').classList.remove('scan-running');
}
$('#p-analyzing').addEventListener('animationiteration', function (event) {
if (!scanCycle || scanCycle.done) return;
var ready = this.classList.contains('scan-artwork-ready');
if ((ready && event.target.classList.contains('scan-beam')) || (!ready && event.target.classList.contains('scan-status'))) scanCycle.resolve();
});
function prepareScanArtwork() {
var image = $('#scan-artwork');
function ready() {
var wasReady = $('#p-analyzing').classList.contains('scan-artwork-ready');
var loaded = image.complete && image.naturalWidth && scanMask && scanMask.complete && scanMask.naturalWidth;
$('#p-analyzing').classList.toggle('scan-artwork-ready', !!loaded);
if (loaded && !wasReady) restartScanCycle();
}
if (!scanMask || (scanMask.complete && !scanMask.naturalWidth)) {
scanMask = new Image(); scanMask.onload = ready; scanMask.onerror = ready;
scanMask.src = $('#scan-face').dataset.mask;
$('#scan-face').style.webkitMaskImage = 'url("' + scanMask.src + '")';
$('#scan-face').style.maskImage = 'url("' + scanMask.src + '")';
}
if (!image.getAttribute('src') || (image.complete && !image.naturalWidth)) {
image.onload = function () { $('#p-analyzing').classList.add('scan-artwork-ready'); };
image.onerror = function () { $('#p-analyzing').classList.remove('scan-artwork-ready'); };
image.onload = ready;
image.onerror = ready;
image.src = image.dataset.src;
}
ready();
}
function openOverlay(id) {
if (id === 'p-privacy' || id === 'p-photoguide') loadAppFont();
if (id === 'p-analyzing') prepareScanArtwork();
$('#' + id).classList.add('show');
if (id === 'p-analyzing') $('.scan-cancel').focus({ preventScroll: true });
if (id === 'p-analyzing') { startScanCycle(); $('.scan-cancel').focus({ preventScroll: true }); }
}
function closeOverlay(id) { $('#' + id).classList.remove('show'); }
function closeOverlay(id) { $('#' + id).classList.remove('show'); if (id === 'p-analyzing') stopScanCycle(); }
// 点遮罩空白处关闭(与各自关闭按钮同行为:隐私→回启动页、拍照引导→留在档案页)
// 只响应遮罩本身(拍照引导另含底图层),不误伤弹窗卡片内交互
@@ -329,6 +377,7 @@
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 同序;送检前经 photosByView 重排为 [正脸, 左脸, 右脸]
var captureDiagnostics = [], holdState = null;
// 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级
var camFacing = 'user'; // 当前镜头:'user' 前置 | 'environment' 后置(翻转按钮切换)
@@ -440,6 +489,7 @@
reader.onload = function () {
var img = new Image();
img.onload = function () {
if (seq !== camSeq || step !== camStep) return;
var scale = Math.min(1, 1280 / Math.max(img.width, img.height));
var c = document.createElement('canvas');
c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale);
@@ -447,9 +497,9 @@
var url = c.toDataURL('image/jpeg', 0.88);
shutterLock = true; // 校验期间不接受新的拍摄/选图
showCamStatus('照片检查中…');
checkStill(url, CAM_STEPS[step].target).then(function (err) {
hideCamStatus();
checkStill(url, CAM_STEPS[step].target, { mode: 'file', ignoreDirection: true, step: step, seq: seq }).then(function (err) {
if (seq !== camSeq || step !== camStep) return; // 期间已退出/重置
hideCamStatus();
shutterLock = false;
if (err) { toast(err); voice.play(VOICE.fail, { cut: true }); return; } // 不合格:停在本步重拍
onPhotoCaptured(url);
@@ -460,27 +510,42 @@
};
reader.readAsDataURL(f);
}
// 静态照片校验:有人脸、朝向与当前步一致(检测模型不可用时放行,由服务端兜底)
// 系统相机的前置照片是否镜像因机型而异,侧脸只校验转头幅度、不校验左右
var STILL_FRONT_YAW_MAX = 20, STILL_SIDE_YAW_MIN = 20;
function checkStill(dataUrl, target) {
if (IS_MOCK || !modelsOk || typeof faceapi === 'undefined') return Promise.resolve('');
// Live final frames and file uploads share all local quality gates. Files may
// be mirrored by the system camera; their side direction remains unverified.
function checkStill(dataUrl, target, options) {
options = options || { mode: 'file', ignoreDirection: true, step: camStep, seq: camSeq };
function remember(sample) { if (options.seq === camSeq) captureDiagnostics[options.step] = sample; }
if (IS_MOCK) { remember({ mode: 'mock' }); return Promise.resolve(''); }
if (!modelsOk || typeof faceapi === 'undefined') {
if (modelsPromise && !options.waited) {
options.waited = true;
return new Promise(function (resolve) {
var timer = setTimeout(resolve, 8000);
modelsPromise.then(function () { clearTimeout(timer); resolve(); }, function () { clearTimeout(timer); resolve(); });
}).then(function () { return checkStill(dataUrl, target, options); });
}
// Keep existing unsupported-device access, explicitly marked for admin.
// Never silently treat an unavailable local engine as a passed check.
remember({ mode: 'unchecked', reason: 'model-unavailable' });
return Promise.resolve('');
}
return new Promise(function (resolve) {
var img = new Image();
img.onload = function () {
faceapi
.detectSingleFace(img, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
.detectAllFaces(img, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
.withFaceLandmarks()
.then(function (d) {
if (!d) { resolve('照片中未识别到人脸,请重新拍摄'); return; }
var e = null;
try { e = HeadPose.estimate(d.landmarks.positions, false); } catch (err) { e = null; }
if (!e) { resolve(''); return; }
var yaw = Math.abs(e.yaw);
if (target === 'front' && yaw > STILL_FRONT_YAW_MAX) { resolve('请拍摄正脸照片'); return; }
if (target !== 'front' && yaw < STILL_SIDE_YAW_MIN) { resolve('请拍摄' + (target === 'right' ? '右' : '左') + '侧脸照片'); return; }
.then(function (faces) {
var d = faces[0], box = d && d.detection.box;
var verdict = CaptureQuality.evaluate(d, { count: faces.length, target: target,
width: img.naturalWidth, height: img.naturalHeight,
offsetX: box ? (box.x + box.width / 2) / img.naturalWidth - .5 : 0,
luma: box ? faceLuma(box, img) : null,
mirrored: options.mode === 'live' && sideYawFlip, ignoreDirection: options.ignoreDirection });
if (!verdict.ok) { resolve(verdict.hint + ',请重新拍摄'); return; }
remember(Object.assign({ mode: options.mode, direction_checked: !options.ignoreDirection }, verdict.observation));
resolve('');
}, function () { resolve(''); });
}, function () { resolve('照片检查失败,请重新拍摄'); });
};
img.onerror = function () { resolve('照片读取失败,请重新选择'); };
img.src = dataUrl;
@@ -628,15 +693,10 @@
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 DETECT_SCORE = CaptureQuality.config.score;
var MISS_FRAMES_MAX = 3; // 连续漏检 ≥3 帧(~450ms)才报「未检测到人脸」,容忍单帧抖动
var OK_HOLD_MS = 450; // 条件连续合格时长:滤掉一闪而过的合格帧(防运动模糊),达标立即抓拍
var NO_PROGRESS_MS = 12000; // 单步超过该时长仍未抓拍→浮出调整提示
var PREVIEW_SCALE = 1; // 与 CSS #cam-video 铺满预览一致,镜像只改变方向
// 人脸框高/原始视频高;保留现有距离判定阈值。
var FACE_NEAR = 0.30, FACE_FAR = 0.68;
var CENTER_TOL = 0.18; // 人脸中心偏离取景中线超过屏宽 18% → 提示移入取景框
var LUMA_DARK = 65, LUMA_BRIGHT = 235; // 脸部平均亮度(0–255):过暗/过曝提示调整光线
var modelsPromise = null, modelsOk = false, detectDisabled = false;
var faceApiPromise = null;
@@ -716,6 +776,7 @@
// 每步进入/回退时的检测状态机重置(蒙版/提示/无进展计时全部归位)
function resetDetectState() {
okSince = 0;
holdState = null;
readyHapticAt = 0;
missFrames = 0;
oppFrames = 0;
@@ -762,13 +823,13 @@
if (shutterLock || capturing || !camLive) return Promise.resolve(); // 抓帧/流程推进中不判姿态
var step = camStep, seq = camSeq; // 快照:检测是异步的,帧落地时步骤/流程可能已变
return faceapi
.detectSingleFace(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
.detectAllFaces(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
.withFaceLandmarks()
.then(function (d) {
if (step !== camStep || seq !== camSeq) return; // 跨步骤陈帧/流程已重置:丢弃
if (detRunning && !shutterLock && !capturing) onDetect(d);
})
.catch(function () { /* 单帧异常忽略,下一帧继续 */ });
.catch(function () { if (step === camStep && seq === camSeq) { holdState = null; okSince = 0; } }); // Unchecked frames break continuous readiness.
}
// 人脸框中心相对取景中线的水平偏移(占屏宽比例);按 object-fit:cover + 预览缩放换算到屏幕,镜像只影响符号
function faceOffsetX(box) {
@@ -779,11 +840,11 @@
}
// 脸部平均亮度:人脸框缩到 16×16 取均值
var lumaCanvas = null;
function faceLuma(box) {
function faceLuma(box, source) {
try {
if (!lumaCanvas) { lumaCanvas = document.createElement('canvas'); lumaCanvas.width = lumaCanvas.height = 16; }
var c = lumaCanvas.getContext('2d', { willReadFrequently: true });
c.drawImage(camVideo, box.x, box.y, box.width, box.height, 0, 0, 16, 16);
c.drawImage(source || camVideo, box.x, box.y, box.width, box.height, 0, 0, 16, 16);
var px = c.getImageData(0, 0, 16, 16).data, sum = 0;
for (var i = 0; i < px.length; i += 4) sum += 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
return sum / (px.length / 4);
@@ -798,57 +859,51 @@
}
/* camera-haptic-pure-end */
function onDetect(d) {
var count = Array.isArray(d) ? d.length : d ? 1 : 0;
if (Array.isArray(d)) d = d[0];
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 累积
holdState = null; okSince = 0;
return; // Keep the hint quiet, but never count a missing frame as stable.
} else {
// 提示优先级:无人脸 > 距离 > 居中 > 角度 > 光线
var box = d.detection.box;
var ratio = box.height / (camVideo.videoHeight || 1);
if (ratio < FACE_NEAR) hint = '请靠近一点';
else if (ratio > FACE_FAR) hint = '请远一点';
else if (Math.abs(faceOffsetX(box)) > CENTER_TOL) hint = '请将脸部移入取景框';
else {
// 特征点取自原始帧(预览的 CSS 镜像不影响),三步的语义方向都按原始视角判定:
// 标准机型 sideYawFlip 恒为 false,个别前置帧本身已镜像的机型由侧脸自校准翻转
var tgt = CAM_STEPS[camStep].target;
var pose = HeadPose.check(d.landmarks.positions, tgt, 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 >= 30) {
sideYawFlip = !sideYawFlip;
mirrorCalibrated = true;
oppFrames = 0;
}
} else oppFrames = 0;
}
if (!pose.ok) hint = pose.hint;
else {
var luma = faceLuma(box);
if (luma != null && luma < LUMA_DARK) hint = '请到光线充足处';
else if (luma != null && luma > LUMA_BRIGHT) hint = '光线过强,请避开强光';
else ok = true;
}
// 原始帧判断语义方向;只有已确认镜像的机型才翻转。
var tgt = CAM_STEPS[camStep].target;
var pose = HeadPose.check(d.landmarks.positions, tgt, sideYawFlip);
// 侧脸自校准需连续 30 帧强反向才翻转一次,避免短暂转错方向误判为镜像。
if (tgt !== 'front' && pose.yaw != null && !mirrorCalibrated) {
var wantSign = tgt === 'right' ? 1 : -1;
if (pose.yaw * wantSign < -12) {
if (++oppFrames >= 30) {
sideYawFlip = !sideYawFlip;
mirrorCalibrated = true;
oppFrames = 0;
}
} else oppFrames = 0;
}
var verdict = CaptureQuality.evaluate(d, { count: count, target: tgt, mirrored: sideYawFlip,
width: camVideo.videoWidth, height: camVideo.videoHeight,
offsetX: faceOffsetX(box), luma: faceLuma(box) });
hint = verdict.hint; ok = verdict.ok;
window.__dbgRatio = ratio; // 自动化校准/测试用(检测框高/视频高)
}
setMask(ok ? 'ok' : (noFace ? 'no-face' : ''));
// 合格时「保持不动」只显示不播报:随即抓拍,「拍摄完成」会截断它
setHint(ok ? '保持不动' : hint, ok, ok);
if (!ok) { okSince = 0; return; }
if (!okSince) {
okSince = Date.now();
if (!ok) { okSince = 0; holdState = null; return; }
var held = CaptureQuality.hold(holdState, verdict.observation, Date.now());
holdState = held.state;
if (holdState.frames === 1) {
okSince = holdState.since;
if (!readyHapticAt || okSince - readyHapticAt >= 2000) { readyHapticAt = okSince; captureHaptic(navigator); }
}
else if (Date.now() - okSince >= OK_HOLD_MS) { okSince = 0; doCapture(); }
if (held.ready) { okSince = 0; holdState = null; doCapture(); }
}
/* ─── 拍照流程(三步,顺序对齐 Figma:正脸 → 右脸出镜 → 左脸出镜) ─── */
@@ -934,7 +989,15 @@
capturing = false;
if (seq !== camSeq) return; // 期间已退出/重置:不再推进(遮罩仍正常淡出)
if (!shot) { shutterLock = false; okSince = 0; toast('相机尚未就绪,请稍候'); return; } // 重置 okSince:避免陈旧累积立即再抓
onPhotoCaptured(shot);
var step = camStep;
checkStill(shot, CAM_STEPS[step].target, { mode: 'live', ignoreDirection: false, step: step, seq: seq }).then(function (error) {
if (seq !== camSeq || step !== camStep) return;
if (error) {
shutterLock = false; holdState = null; okSince = 0;
setHint(error, false); armNudge(); return;
}
onPhotoCaptured(shot);
});
}, 300);
}, 300);
}
@@ -952,11 +1015,12 @@
region: regionSel.value || '',
skinType: SKINS[skinIndex].name
};
var t0 = Date.now();
var animationComplete = scanCycle.promise;
requestAnalyze(photos, seq, profile, function (errMsg, payload) {
if (seq !== camSeq) return; // 流程已被重置:丢弃
var wait = Math.max(0, 1200 - (Date.now() - t0)); // 分析中至少展示 1.2s,避免闪烁
setTimeout(function () {
// A fast successful response waits for one actual visible animation cycle;
// cancellation/errors never get held hostage by the presentation animation.
(errMsg ? Promise.resolve() : animationComplete).then(function () {
if (seq !== camSeq) return;
closeOverlay('p-analyzing');
stopCamera();
@@ -987,7 +1051,7 @@
else if (!payload.report_saved) toast('报告生成成功,但保存失败,请勿关闭页面并联系管理员');
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
showScreen('s-report', 'replace'); // 报告顶替拍照页:返回键回档案页,不再回到相机
}, wait);
});
});
}
@@ -1031,6 +1095,9 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
front_image: ph[0], left_image: ph[1], right_image: ph[2],
capture_diagnostics: { version: CaptureQuality.config.version, views: {
front: captureDiagnostics[0] || null, right: captureDiagnostics[1] || null, left: captureDiagnostics[2] || null
} },
profile: { // 档案随检测上报,后台记录用(服务端缺省 null)
real_age: profile && profile.age,
gender: profile && profile.gender,
@@ -1333,25 +1400,26 @@
setDim(k, vm.dims[k], k === worstDimKey ? 'worst' : k === bestDimKey ? 'best' : '');
});
var presentation = payload && payload.presentation || ReportUI.build(vm, payload && payload.result);
$('#dims-note').textContent = '';
var focus = ReportUI.focusOf(vm), focusEl = $('#dims-note');
focusEl.hidden = !focus;
focusEl.replaceChildren();
if (focus) {
var first = document.createElement('p'), name = document.createElement('b'), second = document.createElement('p');
first.className = 'dims-note-l1'; first.textContent = '近期优先关注:'; name.textContent = focus.name; first.appendChild(name);
second.className = 'dims-note-l2'; second.textContent = focus.tip;
focusEl.append(first, second);
}
var textIds = { contour: '#ditem-para1', wrinkles: '#ditem-para2', color: '#tx-tone', smooth: '#tx-smooth', water: '#tx-water', sens: '#tx-sens', acne: '#tx-acne' };
presentation.views.forEach(function (v) { $(textIds[v.key]).textContent = v.text; });
presentation.views.forEach(function (v) { $(textIds[v.key]).textContent = ReportUI.customerText(v); });
// 检测可视化:轮廓=正脸实拍,其余按 VIZ_CAT 取 face_maps 图层或叠加 marks(payload 缺失时各卡保留占位)
renderViz(payload, frontPhoto);
// 降级标注:只展示用户可感知/可执行的(下颌线、侧脸、补拍类);
// 引擎内部技术项(上限锁未执行/字段未返回等)不面向用户展示
// Fourth feedback: capture quality belongs in the protected admin record,
// not in the customer report. Preserve vm.notes/snapshots unchanged.
var notesEl = $('#vm-notes');
var userNotes = (vm.notes || [])
.filter(function (t) { return /下颌线|侧脸|补拍|重拍/.test(t); })
.map(function (t) { return t.replace(/\(quality=[^)]*\)/g, ''); }); // 接口质量码仅留在引擎留痕里
if (userNotes.length) {
notesEl.innerHTML = userNotes.map(function (t) { return '*' + esc(t); }).join('<br>');
notesEl.style.display = '';
} else {
notesEl.style.display = 'none';
}
notesEl.textContent = '';
notesEl.style.display = 'none';
$('#care-list').innerHTML = presentation.care.map(function (s, i) {
return '<p class="care-item">' + (i + 1) + '、' + esc(s.text) + '</p>';
@@ -1493,6 +1561,7 @@
camSeq++; // 使旧的快门/分析回调失效
camStep = 0;
photos = []; // 新一轮三连拍(从 startCamera 挪出:后台恢复续拍时保留已拍)
captureDiagnostics = [];
shutterLock = false; // 重新进入流程,保险解锁
showScreen('s-cam', 'push');
startCamera(); // beginDetect 在其成功回调里:确认相机可用才加载模型
@@ -1515,7 +1584,7 @@
'switch-login': function () {
historySeq++;
$('#login-consent').checked = false;
$('#login-message').textContent = '微信与本机浏览器为独立账号,报告不会自动合并。请选择登录方式;返回可保留当前登录。';
$('#login-message').textContent = '微信与游客为独立账号,报告不会自动合并。请选择登录方式;返回可保留当前登录。';
showScreen('s-login', 'push');
}
};
@@ -1537,7 +1606,7 @@
$('#history-count').textContent = '您最近已进行过' + data.total + '次面部问题检测';
$('#identity-note').textContent = AccountClient.user().kind === 'wechat'
? '保留最近 5 份报告,同一微信账号可跨手机查看。'
: '保留最近 5 份报告。本机身份仅限当前浏览器;更换手机或清除浏览器数据后无法找回。';
: '保留最近 5 份报告。游客报告仅可在当前浏览器查看,不能跨手机同步;清除浏览器数据后无法找回。';
if (!data.items.length) { box.innerHTML = '<p class="history-empty">暂无报告<br>完成一次检测后,报告会自动保存在这里。</p>'; return; }
box.innerHTML = data.items.map(function (item) {
var date = new Date(item.created_at).toLocaleDateString('sv-SE');
@@ -1584,7 +1653,7 @@
function loginWechat() {
if (!loginAllowed()) return;
if (!/MicroMessenger/i.test(navigator.userAgent)) {
$('#login-message').textContent = '请在微信内打开本页面,或使用本机浏览器登录。';
$('#login-message').textContent = '请在微信内打开本页面,或使用游客登录。';
return;
}
loginBusy = true;
@@ -1645,14 +1714,14 @@
$('#login-message').textContent = code === 'state'
? '登录链接已过期或会话丢失,请在同一微信窗口重新点击微信登录。'
: code === 'network' ? '暂时无法连接微信服务,请稍后重新登录。'
: '微信授权未完成,请重试;如仍失败,请将错误页面截图发给管理员。也可选择本机浏览器登录。';
: '微信授权未完成,请重试;如仍失败,请将错误页面截图发给管理员。也可选择游客登录。';
} else if (loginSuccess && (!AccountClient.user() || AccountClient.user().kind !== 'wechat')) {
$('#login-message').textContent = '微信返回后登录会话未能保存,请在同一微信窗口重新登录。';
} else if (AccountClient.user()) {
if (loginSuccess) loginSucceeded();
else showScreen('s-splash', 'replace');
}
else $('#login-message').textContent = '微信登录可跨手机查看报告;本机登录仅限当前浏览器。';
else $('#login-message').textContent = '微信登录可跨手机查看报告;游客登录仅限当前浏览器。';
}).catch(function (e) { $('#login-message').textContent = e.message; })
.finally(function () {
loginBusy = false;
+53
View File
@@ -0,0 +1,53 @@
/* Local-only capture gates; no request, scoring, or report recommendation logic. */
(function(root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory(require('./head-pose.js'));
else root.CaptureQuality = factory(root.HeadPose);
})(typeof window !== 'undefined' ? window : this, function(HeadPose) {
'use strict';
var config = Object.freeze({ version: '2026-10-12-fourth-v1', score: 0.5,
near: 0.34, far: 0.64, center: 0.12, edge: 0.02,
dark: 75, bright: 225, holdMs: 650, minFrames: 3, maxGapMs: 2000,
yawDrift: 5, pitchDrift: 5, centerDrift: 0.035, sizeDrift: 0.045 });
function finite(v) { return typeof v === 'number' && isFinite(v); }
function evaluate(d, options) {
var failed = function(hint) { return { ok: false, hint: hint }; };
if (!d) return failed('未检测到人脸');
if (options.count !== 1) return failed('请保持单人出镜');
var box = d.detection.box, w = options.width, h = options.height;
if (!box || ![box.x, box.y, box.width, box.height, w, h].every(finite) || w <= 0 || h <= 0 || box.width <= 0 || box.height <= 0) return failed('请将脸部完整移入取景框');
var ratio = box.height / h;
if (!finite(d.detection.score) || d.detection.score < config.score) return failed('请保持面部清晰');
if (ratio < config.near) return failed('请靠近一点');
if (ratio > config.far) return failed('请远一点');
if (box.x < w * config.edge || box.y < h * config.edge || box.x + box.width > w * (1 - config.edge) || box.y + box.height > h * (1 - config.edge)) return failed('请将脸部完整移入取景框');
if (!finite(options.offsetX) || Math.abs(options.offsetX) > config.center) return failed('请将脸部移入取景框');
var pose;
try { pose = HeadPose.estimate(d.landmarks.positions, options.mirrored); } catch (_) { return failed('请正对屏幕'); }
if (!pose) return failed('请正对屏幕');
// System-camera files may already be mirrored. Retain the established
// direction-agnostic fallback, but apply the same angle/pitch/roll limits.
var target = options.target;
if (options.ignoreDirection && target !== 'front') target = pose.yaw < 0 ? 'left' : 'right';
var check = HeadPose.check(d.landmarks.positions, target, options.mirrored);
if (!check.ok) return failed(check.hint);
if (!finite(options.luma)) return failed('请保持面部清晰');
if (options.luma < config.dark) return failed('请到光线充足处');
if (options.luma > config.bright) return failed('光线过强,请避开强光');
return { ok: true, hint: '保持不动', observation: { yaw: pose.yaw, pitch: pose.pitch, roll: pose.roll,
score: d.detection.score, luma: options.luma, ratio: ratio,
center_x: (box.x + box.width / 2) / w, center_y: (box.y + box.height / 2) / h } };
}
function hold(state, observation, now) {
if (!observation) return { ready: false, state: null };
var reset = !state || now - state.lastAt > config.maxGapMs || now < state.lastAt;
if (!reset) {
var a = state.anchor, b = observation;
reset = Math.abs(a.yaw - b.yaw) > config.yawDrift || Math.abs(a.pitch - b.pitch) > config.pitchDrift ||
Math.abs(a.center_x - b.center_x) > config.centerDrift || Math.abs(a.center_y - b.center_y) > config.centerDrift || Math.abs(a.ratio - b.ratio) > config.sizeDrift;
}
var next = reset ? { since: now, lastAt: now, frames: 1, anchor: observation } :
{ since: state.since, lastAt: now, frames: state.frames + 1, anchor: state.anchor };
return { state: next, ready: now - next.since >= config.holdMs && next.frames >= config.minFrames };
}
return { config: config, evaluate: evaluate, hold: hold };
});
+17 -14
View File
@@ -62,13 +62,14 @@
var PITCH_COEFF = 180; // 归一化偏移 → 角度 的增益
// ---- check 阈值(角度制) ----
// 正脸口径: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;
// Fourth feedback: conservative initial local gates. Empirical pose estimates
// are not upstream quality scores; use recorded samples to calibrate further.
var FRONT_YAW_MAX = 10;
var FRONT_PITCH_MAX = 12;
var SIDE_YAW_MIN = 35;
var SIDE_YAW_MAX = 65;
var SIDE_PITCH_MAX = 15;
var ROLL_MAX = 10;
var HINT_KEEP = '保持不动';
var HINT_TURN_LEFT = '请向左转头'; // 用户向自己的左侧转
@@ -157,10 +158,10 @@
/**
* check(points, target, mirrored) -> { ok, hint }
* target: 'front' | 'left' | 'right'
* 'front' : |yaw|<=15 且 |pitch|<=18;不 ok 时按超出更多的一项给方向提示
* 'front' : |yaw|<=10 且 |pitch|<=12、|roll|<=10;不 ok 时给方向提示
* (UI 反馈 2026-10:动态提示「请向左转头」「请略微抬头」等,取代此前只提示回正)
* 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-75°,-28°]),|pitch|<=20
* 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [28°,75°]),|pitch|<=20
* 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-65°,-35°]),|pitch|<=15
* 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [35°,65°]),|pitch|<=15
* ok=true 时 hint='保持不动';输入非法(含 estimate 返回 null 的退化特征点)时 ok=false、hint='请正对屏幕'。
*/
function check(points, target, mirrored) {
@@ -176,6 +177,7 @@
// 这里直接用 e.yaw(此前再翻一次是双重镜像 bug:侧脸引导方向完全相反)
var sYaw = e.yaw;
var pitch = e.pitch;
if (Math.abs(e.roll) > ROLL_MAX) return { ok: false, hint: '请勿歪头', yaw: sYaw };
if (target === 'front') {
if (Math.abs(sYaw) <= FRONT_YAW_MAX && Math.abs(pitch) <= FRONT_PITCH_MAX) {
@@ -222,7 +224,8 @@
FRONT_PITCH_MAX: FRONT_PITCH_MAX,
SIDE_YAW_MIN: SIDE_YAW_MIN,
SIDE_YAW_MAX: SIDE_YAW_MAX,
SIDE_PITCH_MAX: SIDE_PITCH_MAX
SIDE_PITCH_MAX: SIDE_PITCH_MAX,
ROLL_MAX: ROLL_MAX
}
};
}));
@@ -338,15 +341,15 @@ 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|≤15°(产品要求 2026-09-28):yaw≈14.9° 应通过,yaw≈16.2° 应拒绝并引导转回
ok(HeadPose.config.FRONT_YAW_MAX === 15, 'config.FRONT_YAW_MAX = 15');
// 13) Fourth feedback: 10° local frontal gate rejects the former 15° edge.
ok(HeadPose.config.FRONT_YAW_MAX === 10, 'config.FRONT_YAW_MAX = 10');
var slight = makeFrontal();
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 > 13.5 && se.yaw <= 15, '轻微偏头 yaw∈(13.5,15] (got ' + se.yaw.toFixed(2) + ')');
var sc2 = HeadPose.check(slight, 'front', false);
ok(sc2.ok === true, 'yaw≈14.9°(≤15°)front 通过(旧阈值 12 会拒绝)');
ok(sc2.ok === false, 'yaw≈14.9° front 拒绝(第四轮收紧)');
var slight2 = makeFrontal();
slight2[30] = { x: 206.2, y: 140 }; // 偏移 6.2 → yaw≈16.2°
slight2[33] = { x: 206.2, y: 161.5 };
+23 -11
View File
@@ -16,7 +16,25 @@
];
var levelMap = { '预警': '改善', '改善': '良好', '良好': '优秀' };
var dimNames = { firm: '紧塑力', vita: '透亮感', stab: '稳韧力' };
var focusTips = { firm: '强化紧塑力,提拉轮廓抚平细纹。', vita: '提升透亮感,补水提亮加强防晒。', stab: '增强稳韧力,舒缓泛红强韧屏障。' };
function numeric(value) { return typeof value === 'number' && isFinite(value); }
// Read saved raw dimension values, never re-score historical reports or use
// the legacy focus.name (which contains the old dimension terminology).
function focusOf(vm) {
var selected = null;
['firm', 'vita', 'stab'].forEach(function(key) {
var dim = vm.dims && vm.dims[key];
if (dim && numeric(dim.P) && (!selected || dim.P < selected.score)) selected = { key: key, score: dim.P, name: dimNames[key], tip: focusTips[key] };
});
return selected;
}
function customerText(view) {
if (view.key === 'wrinkles') return view.text.split('\n分区明细')[0];
if (view.key !== 'contour') return view.text;
// Also cover immutable old presentations, without modifying stored copy.
return view.text.replace(/侧脸角度数据未完整获取[;;]?/g, '')
.replace('本次未检出有效数据,请重新拍摄后查看。', '本次未获取轮廓有效数据。');
}
function scalar(value) { return numeric(value) ? value : value && numeric(value.value) ? value.value : null; }
var wrinkleDefs = [
['forehead', '抬头纹(额头纹)', ['forehead_wrinkle_info'], 'forehead_count'],
@@ -52,12 +70,6 @@
return { key: def[0], name: def[1], metrics: values };
});
}
function metricText(value) { return value == null ? '未获取' : String(Math.round(value * 10000) / 10000); }
function wrinkleText(rows) {
return '\n分区明细(左右以本人为准;抬头纹与额头纹为同一分区):\n' + rows.map(function (row) {
return row.name + ':' + wrinkleMetrics.map(function (metric) { return metric[1] + ' ' + metricText(row.metrics[metric[0]]); }).join(',') + '。';
}).join('\n');
}
function fill(template, vars) {
return template.replace(/\{([a-z_]+)\}/g, function(_, key) {
var v = vars[key];
@@ -77,7 +89,7 @@
var severity = vars.dark_circle_sev;
var description = numeric(severity) ? ['无明显黑眼圈', '黑眼圈轻度', '黑眼圈中度', '黑眼圈重度'][severity] : null;
template = template.replace(/黑眼圈轻\/中\/重度(根据分数给出判断)/g, description || '黑眼圈数据未获取');
if (vars.la == null || vars.ra == null) template = template.replace(/左右下颌线角度(?:分别为左|左) \{la\}°\/ 右 \{ra\}°[;;]/g, '侧脸角度数据未完整获取;');
if (vars.la == null || vars.ra == null) template = template.replace(/左右下颌线角度(?:分别为左|左) \{la\}°\/ 右 \{ra\}°[;;]/g, '');
}
if (d[0] === 'acne') {
var pustules = count(result, 'acne_pustule'), nodules = count(result, 'acne_nodule');
@@ -104,8 +116,8 @@
var details = d[0] === 'wrinkles' ? wrinkleDetails(result) : null;
var advice = level ? Copy.care[d[0]][level] : null;
return { key: d[0], name: d[1], dim: d[2], source: d[3], P: cat.P, level: level || null,
text: (valid ? fill(template, vars) : '本次未检出有效数据,请重新拍摄后查看。') + (details ? wrinkleText(details) : ''),
valid: valid, advice: valid ? advice : null };
text: valid ? fill(template, vars) : '本次未检出有效数据,请重新拍摄后查看。',
details: details, valid: valid, advice: valid ? advice : null };
});
// Preserve selection of the two weakest original categories. Tone expands into
// color+water advice, with thresholds inherited from the same original category.
@@ -114,7 +126,7 @@
var chosen = views.filter(function(v) { return ranked.indexOf(v.source) >= 0 && v.advice; });
var care = chosen.map(function(v) { return { name: v.name, text: v.advice.care + v.advice.lifestyle }; });
var product = products.filter(function(p) { return p.match === vm.essence.name; })[0] || null;
return { version: Copy.version + '+feedback-2026-10-12', dims: dimNames, views: views, care: care, product: product };
return { version: Copy.version + '+feedback-2026-10-12', dims: dimNames, views: views, care: care, product: product, focus: focusOf(vm) };
}
return { build: build, products: products, levelMap: levelMap, dimNames: dimNames, wrinkleDetails: wrinkleDetails };
return { build: build, products: products, levelMap: levelMap, dimNames: dimNames, wrinkleDetails: wrinkleDetails, focusOf: focusOf, customerText: customerText };
});