feat(h5): apply fourth feedback and tighten local capture validation
This commit is contained in:
+41
-1
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user