跟新需求文档

This commit is contained in:
xsl
2026-09-26 19:50:39 +08:00
parent ab9a39a8f3
commit 71a090fd75
26 changed files with 41746 additions and 712 deletions
+456 -154
View File
@@ -60,6 +60,16 @@
else if ($('#p-photoguide').classList.contains('show')) closeOverlay('p-photoguide');
});
// 分析中弹层点遮罩取消:作废未决分析回调(mock 路径同样经 seq 丢弃)、关弹层、停相机、回档案页
$('#p-analyzing').addEventListener('click', function (e) {
if (e.target !== this) return; // 只响应遮罩空白处,不误伤卡片
camSeq++;
closeOverlay('p-analyzing');
stopCamera();
shutterLock = false; // 流程终止解锁
showScreen('s-profile');
});
/* ─── 肤质轮播 ─── */
var SKINS = [
{ img: 'assets/img/skin-0-dry.png', name: '干性肌肤', desc: '全脸干燥,容易紧绷、缺乏光泽' },
@@ -142,32 +152,57 @@
});
/* ─── 实时相机(getUserMedia)+ 系统相机降级 ─── */
var IS_MOCK = /[?&]mock=1/.test(location.search); // mock:不加载 face-api、不跑检测,analyze 直返内置样本
var camVideo = $('#cam-video'), camFile = $('#cam-file');
var camStream = null, camLive = false, camFallback = false, camSeqCam = 0; // camSeqCam:相机启动令牌,防止迟到的授权回调泄漏摄像头
var photos = []; // 三连拍的照片(dataURL),photos[0] 为正面照,送检用
var photos = []; // 三连拍(dataURL),与 CAM_STEPS 同序:[0]正脸 [1]右脸(头左转) [2]左脸(头右转)
// 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级
function getCamStream() {
return navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'user' }, width: { ideal: 1280 }, height: { ideal: 1280 } },
audio: false
}).catch(function (err) {
if (err && err.name === 'OverconstrainedError') {
return navigator.mediaDevices.getUserMedia({ video: true, audio: false });
}
throw err;
});
}
function startCamera() {
photos = [];
if (camStream) { stopCamera(); }
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { enableFallback(); return; }
camSeqCam++;
var token = camSeqCam;
navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 1280 } },
audio: false
}).then(function (stream) {
getCamStream().then(function (stream) {
if (token !== camSeqCam) { stream.getTracks().forEach(function (t) { t.stop(); }); return; } // 期间已退出拍照页:立即释放
camStream = stream;
camVideo.srcObject = stream;
camLive = true;
// iOS 来电/切后台等系统打断会结束 track:挂 ended 监听走自动重连
stream.getTracks().forEach(function (t) {
t.onended = function () { onCamTrackEnded(token); };
});
$('#s-cam').classList.add('cam-live');
beginDetect(); // 确认相机可用后才加载检测模型(无相机权限设备不白加载)
maybeStartDetect(); // 模型已就绪时即刻开跑检测循环
}).catch(function () {
if (token !== camSeqCam) return;
enableFallback();
});
}
// track 被系统结束(iOS 打断)且页面仍在拍照前台:toast 后自动重连,重连失败走 startCamera 内的 enableFallback
function onCamTrackEnded(token) {
if (token !== camSeqCam || !camLive) return; // 旧流/已主动停止:忽略
if (document.hidden || currentScreen !== 's-cam') return; // 后台恢复由 visibilitychange 流程接管
camLive = false; // 多个 track 同时 ended 只触发一次重连
toast('摄像头连接中断,正在重连');
startCamera();
}
function stopCamera() {
camSeqCam++; // 使进行中的 getUserMedia 回调失效
stopDetect(); // 检测循环/倒计时/无进展提示一并收尾
$('#cam-status').classList.remove('show');
if (camStream) { camStream.getTracks().forEach(function (t) { t.stop(); }); camStream = null; }
camVideo.srcObject = null;
camLive = false;
@@ -175,6 +210,8 @@
}
function enableFallback() {
camFallback = true;
$('#cam-status').classList.remove('show'); // 与 stopCamera 对齐:去掉「检测引擎加载中」胶囊
resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框
toast('已切换为系统相机拍摄');
}
// 从视频帧截取照片(镜像绘制,与预览一致;压到最长边 1280 的 JPEG)
@@ -192,6 +229,7 @@
}
// 降级模式:快门调起系统相机,拍完/选完在 change 里推进流程
camFile.addEventListener('change', function () {
shutterLock = false; // 选完(含空选择)即解锁;推进窗口由 onPhotoCaptured 重新上锁
var f = camFile.files && camFile.files[0];
camFile.value = '';
if (!f) return;
@@ -210,130 +248,170 @@
};
reader.readAsDataURL(f);
});
// 部分系统取消相机/相册只发 cancel 不发 change:兜底解锁,防快门卡死
camFile.addEventListener('cancel', function () { shutterLock = false; });
/* ─── Face++ 结果 → 设计稿报告数据(映射层) ─── */
function num(node, key) {
if (node == null) return 0;
if (key != null) return Number(node[key] && (node[key].value != null ? node[key].value : node[key])) || 0;
return Number(node.value != null ? node.value : node) || 0;
}
function one(x) { return Math.round(x * 10) / 10; }
function clamp(x, lo, hi) { return Math.max(lo, Math.min(hi, x)); }
/* ─── 姿态检测(face-api + HeadPose,引导式自动三连拍) ─── */
var MODEL_URL = 'vendor/faceapi/models';
var DETECT_INTERVAL = 150; // 检测帧间隔(ms)
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% 太近
function mapSkinResult(r) {
var fw = num(r.forehead_wrinkle, 'value') === 1;
var cf = num(r.crows_feet, 'value') === 1;
var ef = num(r.eye_finelines, 'value') === 1;
var gw = num(r.glabella_wrinkle, 'value') === 1;
var nf = num(r.nasolabial_fold, 'value') === 1, nfSev = num(r.nasolabial_fold_severity, 'value');
var ep = num(r.eye_pouch, 'value') === 1, epSev = num(r.eye_pouch_severity, 'value');
var dc = num(r.dark_circle, 'value');
var bh = num(r.blackhead, 'value');
var poreCount = ['pores_forehead', 'pores_left_cheek', 'pores_right_cheek', 'pores_jaw']
.filter(function (k) { return num(r[k], 'value') === 1; }).length;
var acneN = (r.acne && r.acne.rectangle) ? r.acne.rectangle.length : 0;
var ccN = (r.closed_comedones && r.closed_comedones.rectangle) ? r.closed_comedones.rectangle.length : 0;
var spotN = (r.skin_spot && r.skin_spot.rectangle) ? r.skin_spot.rectangle.length : 0;
var sens = r.sensitivity || {};
var sensArea = Number(sens.sensitivity_area) || 0;
var sensInt = Number(sens.sensitivity_intensity) || 0;
var age = num(r.skin_age, 'value');
var modelsPromise = null, modelsOk = false, detectDisabled = false; // detectDisabled:face-api 缺失→全程手动;模型加载失败仅当次禁用,下次进拍照页重试
var detRunning = false, detTimer = null; // 检测循环
var okSince = 0; // 当前连续 ok 起点(0=未在累积)
var counting = false, cdTimer = null, cdN = 0; // 倒计时
var nudgeTimer = null; // 12s 无进展提示
var capturing = false; // 白屏补光抓帧中(暂停姿态判定)
var lastHint = ''; // 提示文案去重
// 紧塑力:法令纹 + 眼袋(松弛下垂类)
var firm = 10 - (nf ? [0.9, 1.5, 2.2][clamp(nfSev, 0, 2)] : 0) - (ep ? [0.7, 1.2, 1.8][clamp(epSev, 0, 2)] : 0);
// 活力感:黑眼圈 + 眼部细纹/鱼尾纹/抬头纹/眉间纹(倦怠神采类)
var vita = 10 - (dc > 0 ? (dc === 2 ? 1.3 : 1.0) : 0) - (ef ? 0.6 : 0) - (cf ? 0.5 : 0) - (fw ? 0.4 : 0) - (gw ? 0.3 : 0);
// 稳定力:敏感泛红 + 痘痘/闭口/毛孔/黑头(屏障稳定类)
var stab = 10 - (sensArea * 2.5 + sensInt / 100 * 2.0) - Math.min(acneN, 5) * 0.5 - Math.min(ccN, 5) * 0.35 - poreCount * 0.4 - bh * 0.3;
firm = one(clamp(firm, 4, 9.8)); vita = one(clamp(vita, 4, 9.8)); stab = one(clamp(stab, 3.5, 9.8));
var total = Math.round(clamp((firm * 0.35 + vita * 0.33 + stab * 0.32) * 10, 40, 98));
var dims = [
{ name: '紧塑力', score: firm, tip: '轮廓支撑与紧致度,护肤配合由下向上的提拉手法' },
{ name: '活力感', score: vita, tip: '神采与倦怠感,规律作息改善眼周循环' },
{ name: '稳定力', score: stab, tip: '强韧稳态力,锁水保湿抵御刺激' }
].sort(function (a, b) { return a.score - b.score; });
// 具体描述两行文案
var sevName = ['轻度', '中度', '重度'];
var para1;
if (!nf && !ep) {
para1 = '面部轮廓饱满舒展,下颌线清晰立体,中面部容积充足,肌肤回弹速度快;这种紧致状态,通常和内在胶原合成充足、整体作息节奏平稳直接相关。';
} else {
var parts = [];
if (nf) parts.push('观察到' + sevName[clamp(nfSev, 0, 2)] + '法令纹痕迹,面部支撑有松弛趋势');
if (ep) parts.push('眼部有' + sevName[clamp(epSev, 0, 2)] + '眼袋');
para1 = parts.join(',') + '。护肤时请避免用力向下拉扯皮肤,涂抹护肤品时配合由下向上的提拉手法,重点照顾下颌线与苹果肌位置。';
// 模型只加载一次(缓存 promise);失败清空缓存,下次进拍照页重试
function loadModels() {
if (!modelsPromise) {
modelsPromise = Promise.all([
faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL),
faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL)
]).then(function () { modelsOk = true; },
function (err) { modelsPromise = null; throw err; });
}
var para2;
var wrinkles = [];
if (fw) wrinkles.push('抬头纹'); if (cf) wrinkles.push('鱼尾纹');
if (ef) wrinkles.push('眼部细纹'); if (gw) wrinkles.push('眉间纹'); if (nf) wrinkles.push('法令纹');
if (wrinkles.length === 0) {
para2 = '面部各区域平整舒展,没有明显的纹路沟壑,只有做夸张表情时会出现极浅的动态纹路;纹路的多少,和内在胶原支撑力、日常面部肌肉活动习惯都有关系。';
} else {
para2 = '当前观察到' + wrinkles.join('、') + '的痕迹;纹路的多少,和内在胶原支撑力、日常面部肌肉活动习惯都有关系,日常注意表情管理与防晒,有助于减缓纹路加深。';
}
var found = [];
if (spotN > 0) found.push('斑点' + spotN + '处');
if (acneN > 0) found.push('痘痘' + acneN + '处');
if (ccN > 0) found.push('闭口' + ccN + '处');
if (found.length) para2 += '(本次检测到' + found.join(',') + ')';
return {
total: total, age: age,
firm: firm, vita: vita, stab: stab,
focusName: dims[0].name, focusTip: dims[0].tip,
para1: para1, para2: para2,
lowest: dims[0].score
};
return modelsPromise;
}
function renderReport(m, frontPhoto, sidePhoto) {
$('#stat-score').textContent = m.total + '分';
$('#stat-age').textContent = m.age + '岁';
var setDim = function (id, score) {
var el = $(id);
el.textContent = score.toFixed(1);
el.classList.toggle('dim-bad', score < 6);
};
setDim('#dim-firm', m.firm); setDim('#dim-vita', m.vita); setDim('#dim-stab', m.stab);
$('#dims-note').innerHTML = '近期优先关注:' + m.focusName + '<br>' + m.focusTip;
$('#ditem-para1').textContent = m.para1;
$('#ditem-para2').textContent = m.para2;
if (frontPhoto) $('#ditem-img1').src = frontPhoto;
if (sidePhoto) $('#ditem-img2').src = sidePhoto;
function setMask(cls) { // '' 静态白框 | 'no-face' 呼吸动画 | 'ok' 品牌绿定格
var m = $('#cam-mask');
m.classList.toggle('no-face', cls === 'no-face');
m.classList.toggle('ok', cls === 'ok');
}
function setHint(text, ok) {
var h = $('#cam-hint');
if (!text) { h.classList.remove('show', 'ok'); lastHint = ''; return; }
if (text !== lastHint) h.textContent = text; // 同文案不重复触发布局
h.classList.add('show');
h.classList.toggle('ok', !!ok);
lastHint = text;
}
function hideNudge() { $('#cam-nudge').classList.remove('show'); }
// 每步进入/回退时的检测状态机重置(蒙版/提示/倒计时/无进展计时全部归位)
function resetDetectState() {
okSince = 0;
lastHint = '';
cancelCountdown();
clearTimeout(nudgeTimer);
hideNudge();
setHint('');
setMask(camLive && !IS_MOCK && !detectDisabled && !camFallback ? 'no-face' : '');
armNudge();
}
// 单步 12s 未触发倒计时→底部浮出轻提示(不阻塞流程);进入下一步/倒计时/抓帧时重置
function armNudge() {
clearTimeout(nudgeTimer);
hideNudge();
if (IS_MOCK || detectDisabled || camFallback) return; // 手动模式无需提示
if (currentScreen !== 's-cam') return;
nudgeTimer = setTimeout(function () {
if (currentScreen === 's-cam' && !counting && !shutterLock) $('#cam-nudge').classList.add('show');
}, NO_PROGRESS_MS);
}
/* ─── 分析请求(mock=1 用内置样本,便于离线/自动化验证) ─── */
var MOCK_RESULT = {"skin_color":{"value":2,"confidence":0.65},"skin_age":{"value":42},"left_eyelids":{"value":0,"confidence":0.99},"right_eyelids":{"value":0,"confidence":0.99},"eye_pouch":{"value":0,"confidence":0.75},"dark_circle":{"value":1,"confidence":0.63},"forehead_wrinkle":{"value":0,"confidence":0.76},"crows_feet":{"value":1,"confidence":0.76},"eye_finelines":{"value":1,"confidence":0.86},"glabella_wrinkle":{"value":0,"confidence":0.76},"nasolabial_fold":{"value":1,"confidence":0.80},"nasolabial_fold_severity":{"value":1,"confidence":0.30},"skin_type":{"skin_type":3},"pores_forehead":{"value":0,"confidence":0.82},"pores_left_cheek":{"value":0,"confidence":0.76},"pores_right_cheek":{"value":0,"confidence":0.95},"pores_jaw":{"value":0,"confidence":0.98},"blackhead":{"value":0,"confidence":0.99},"acne":{"rectangle":[],"confidence":[]},"closed_comedones":{"rectangle":[],"confidence":[]},"mole":{"rectangle":[],"confidence":[]},"skin_spot":{"rectangle":[{"left":356,"top":494,"width":11,"height":13}],"confidence":[0.50]},"sensitivity":{"sensitivity_area":0.26,"sensitivity_intensity":65.36}};
function requestAnalyze(imageDataUrl, seq, done) {
if (/[?&]mock=1/.test(location.search)) {
setTimeout(function () { done(null, MOCK_RESULT); }, 900);
function startCountdown() {
counting = true;
clearTimeout(nudgeTimer);
hideNudge();
cdN = COUNTDOWN_S;
var c = $('#cam-count');
c.textContent = cdN;
c.classList.add('show');
setMask('ok'); // 轮廓保持绿色
setHint('保持不动', true);
cdTimer = setTimeout(cdTick, 1000);
}
function cdTick() {
cdN--;
if (cdN <= 0) {
counting = false;
$('#cam-count').classList.remove('show');
doCapture(); // 倒计时结束:白屏补光抓帧(与手动快门同路径)
return;
}
fetch('/api/skin-analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_base64: imageDataUrl })
}).then(function (res) { return res.json(); })
.then(function (data) {
if (seq !== camSeq) return; // 流程已被重置,丢弃过期响应
if (!data.ok) done(data.message || '检测失败,请重试');
else done(null, data.result);
})
.catch(function () {
if (seq !== camSeq) return;
done('网络异常,请检查网络后重试');
});
$('#cam-count').textContent = cdN;
cdTimer = setTimeout(cdTick, 1000);
}
function cancelCountdown() {
counting = false;
clearTimeout(cdTimer);
$('#cam-count').classList.remove('show');
}
/* ─── 拍照流程 ─── */
function maybeStartDetect() {
if (detRunning || IS_MOCK || detectDisabled || !modelsOk || !camLive || currentScreen !== 's-cam') return;
detRunning = true;
detTick();
}
function stopDetect() {
detRunning = false;
clearTimeout(detTimer);
cancelCountdown();
clearTimeout(nudgeTimer);
hideNudge();
}
// 递归 setTimeout 而非 setInterval:检测是异步的,避免帧与帧堆叠
function detTick() {
if (!detRunning) return;
detTimer = setTimeout(function () {
if (!detRunning) return;
detectFrame().then(detTick, detTick);
}, DETECT_INTERVAL);
}
function detectFrame() {
if (shutterLock || capturing || !camLive) return Promise.resolve(); // 抓帧/流程推进中不判姿态
var step = camStep, seq = camSeq; // 快照:检测是异步的,帧落地时步骤/流程可能已变
return faceapi
.detectSingleFace(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: 320, scoreThreshold: 0.4 }))
.withFaceLandmarks()
.then(function (d) {
if (step !== camStep || seq !== camSeq) return; // 跨步骤陈帧/流程已重置:丢弃
if (detRunning && !shutterLock && !capturing) onDetect(d);
})
.catch(function () { /* 单帧异常忽略,下一帧继续 */ });
}
function onDetect(d) {
var hint = '', ok = false, hasFace = !!d;
if (!hasFace) {
hint = '未检测到人脸';
} else {
// 提示优先级:无人脸 > 距离 > 角度
var ratio = d.detection.box.height / (camVideo.videoHeight || 1);
if (ratio < FACE_NEAR) hint = '请靠近一点';
else if (ratio > FACE_FAR) hint = '请远一点';
else {
// 前置预览为镜像画面 → mirrored=true;距离不合格时角度不判 ok
var pose = HeadPose.check(d.landmarks.positions, CAM_STEPS[camStep].target, true);
hint = pose.hint;
ok = pose.ok;
}
}
if (counting && !ok) cancelCountdown(); // 倒计时中任一帧不 ok→立即取消,回到对应提示
setMask(counting || ok ? 'ok' : (hasFace ? '' : 'no-face'));
setHint(counting ? '保持不动' : hint, counting || ok);
if (counting) return;
if (ok) {
if (!okSince) okSince = Date.now();
else if (Date.now() - okSince >= OK_HOLD_MS) startCountdown();
} else {
okSince = 0;
}
}
/* ─── 拍照流程(三步:正脸 → 右脸出镜 → 左脸出镜) ─── */
// target 即 HeadPose.check 的 target:2/3 右脸出镜=用户头向左转(target 'right',产物作 right_image);
// 3/3 左脸出镜=头向右转(target 'left',产物作 left_image)。
var CAM_STEPS = [
{ bg: 'assets/img/cam-front.jpg', step: '1/3<br>请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'] },
{ bg: 'assets/img/cam-left.jpg', step: '2/3<br>请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'] },
{ bg: 'assets/img/cam-right.jpg', step: '3/3<br>请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'] }
{ 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' }
];
var camStep = 0;
@@ -344,61 +422,106 @@
$('#cam-guide').innerHTML = cfg.guides
.map(function (g) { return '<span>' + g + '</span>'; })
.join('<i></i>');
// 步骤圆点:当前步高亮、完成步绿色打勾
var dots = $('#cam-dots').children;
for (var i = 0; i < dots.length; i++) {
dots[i].classList.toggle('done', i < camStep);
dots[i].classList.toggle('on', i === camStep);
}
$('#cam-mask').setAttribute('data-step', camStep); // 蒙版按步骤切换
resetDetectState();
}
var camSeq = 0; // 流程令牌:cam-back/开始拍照时递增,使未决的快门/分析回调失效
var shutterLock = false; // 快门防连点锁:流程进行中直接忽略
// 照片就绪后推进流程(实时截帧与降级选图共用)
// 照片就绪后推进流程(实时抓帧与降级选图共用):存图→步骤点变绿→300ms 过渡→下一步
function onPhotoCaptured(dataUrl) {
if (shutterLock && !camFallback) return;
shutterLock = true;
shutterLock = true; // 推进窗口上锁:降级模式 change 里已解锁,这里重新锁住直到下一步/送检
photos[camStep] = dataUrl;
var flash = $('#cam-flash');
flash.classList.add('on');
setTimeout(function () { flash.classList.remove('on'); }, 220);
$('#cam-dots').children[camStep].classList.add('done');
var seq = camSeq;
setTimeout(function () {
if (seq !== camSeq) return; // 期间已 cam-back/重新开始:放弃本次推进
if (camStep < CAM_STEPS.length - 1) {
camStep++;
renderCamStep();
shutterLock = false; // 进入下一步即解锁
renderCamStep(); // 内含检测状态机重置
shutterLock = false; // 进入下一步即解锁
} else {
// 三张拍完:送检正面照
renderCamStep();
camStep = 0;
openOverlay('p-analyzing');
var t0 = Date.now();
requestAnalyze(photos[0], seq, function (errMsg, result) {
if (seq !== camSeq) return; // 流程已被重置:丢弃
var wait = Math.max(0, 1200 - (Date.now() - t0)); // 分析中至少展示 1.2s,避免闪烁
setTimeout(function () {
if (seq !== camSeq) return;
closeOverlay('p-analyzing');
stopCamera();
if (errMsg) {
toast(errMsg);
showScreen('s-profile'); // 回档案页,允许重新提交再拍
shutterLock = false;
return;
}
renderReport(mapSkinResult(result), photos[0], photos[1]);
showScreen('s-report');
shutterLock = false; // 流程结束进入报告后解锁
}, wait);
});
submitPhotos(seq); // 三张拍完:送检
}
}, 260);
}, 300);
}
// 白屏补光抓拍:全屏纯白瞬间点亮→约300ms 后抓全分辨率帧→保持300ms 再淡出(总 600–900ms)
function doCapture() {
if (shutterLock) return;
if (!camLive) return;
shutterLock = true;
capturing = true;
cancelCountdown();
clearTimeout(nudgeTimer);
hideNudge();
var seq = camSeq;
var fill = $('#cam-fill');
fill.classList.add('on'); // .on 无过渡,瞬间点亮
if (navigator.vibrate) navigator.vibrate(30);
setTimeout(function () {
var shot = captureFromVideo();
setTimeout(function () {
fill.classList.remove('on'); // 移除 .on 恢复过渡,淡出
capturing = false;
if (seq !== camSeq) return; // 期间已退出/重置:不再推进(遮罩仍正常淡出)
if (!shot) { shutterLock = false; okSince = 0; toast('相机尚未就绪,请稍候'); return; } // 重置 okSince:避免陈旧累积立即重启倒计时
onPhotoCaptured(shot);
}, 300);
}, 300);
}
// 三张拍完:三视图送检(正脸=photos[0],左=photos[2],右=photos[1])
function submitPhotos(seq) {
camStep = 0;
openOverlay('p-analyzing');
var t0 = Date.now();
requestAnalyze(photos, seq, function (errMsg, payload) {
if (seq !== camSeq) return; // 流程已被重置:丢弃
var wait = Math.max(0, 1200 - (Date.now() - t0)); // 分析中至少展示 1.2s,避免闪烁
setTimeout(function () {
if (seq !== camSeq) return;
closeOverlay('p-analyzing');
stopCamera();
shutterLock = false; // 流程结束解锁
if (errMsg) {
toast(errMsg);
showScreen('s-profile'); // 回档案页,允许重新提交再拍
return;
}
var vm = null;
try {
vm = SkinScore.buildViewModel(payload.result, payload.side, {
realAge: +($('#f-age').value || 0) || null,
skinType: SKINS[skinIndex].name
});
} catch (e) { vm = null; }
if (!vm) {
toast('报告生成失败,请重新拍摄');
showScreen('s-profile');
return;
}
renderReport(vm, photos[0], photos[1]); // img1=正脸实拍,img2=右脸实拍
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
showScreen('s-report');
}, wait);
});
}
function shutter() {
if (shutterLock) return;
if (camLive) {
var shot = captureFromVideo();
if (!shot) { toast('相机尚未就绪,请稍候'); return; }
onPhotoCaptured(shot);
if (!camVideo.videoWidth) { toast('相机尚未就绪,请稍候'); return; }
doCapture(); // 手动兜底:任何时刻可拍当前步,同样走白屏补光,不依赖检测
} else if (camFallback) {
shutterLock = true; // 前置上锁防连点:change/cancel 回调里解锁
camFile.click(); // 降级:调起系统相机,回调里推进
} else {
// 相机还在启动中
@@ -406,6 +529,150 @@
}
}
/* ─── 分析请求(mock=1 直返 SkinScore 内置样本,便于离线/自动化验证) ─── */
var ANALYZE_TIMEOUT_MS = 45000; // 检测服务超时阈值:超时按「开小差」处理回档案页
function requestAnalyze(ph, seq, done) {
if (IS_MOCK && typeof SkinScore !== 'undefined') {
setTimeout(function () {
if (seq !== camSeq) return; // mock 同样尊重取消/重置
done(null, { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE });
}, 900);
return;
}
var ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
var timer = setTimeout(function () { if (ctrl) ctrl.abort(); }, ANALYZE_TIMEOUT_MS);
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] }),
signal: ctrl ? ctrl.signal : undefined // 老内核无 AbortController:仅靠令牌丢弃迟到响应
}).then(function (res) { return res.json(); })
.then(function (data) {
clearTimeout(timer);
if (seq !== camSeq) return; // 流程已被重置,丢弃过期响应
if (!data.ok) done(data.message || '检测失败,请重试');
else done(null, { result: data.result, side: data.side });
})
.catch(function (err) {
clearTimeout(timer);
if (seq !== camSeq) return;
if (err && err.name === 'AbortError') done('检测服务开小差了,请稍候重试'); // 45s 超时主动中断
else done('网络异常,请检查网络后重试');
});
}
/* ─── SkinScore viewModel → 报告渲染(评分公式见 js/skin-score.js,不直接向用户展示 P 值) ─── */
var LV_CLASS = { '良好': 'lv-good', '改善': 'lv-warn', '预警': 'lv-bad' };
var CAT_DOM = {
contour: { s: '#s-contour', lv: '#lv-contour', tx: '#ditem-para1' },
wrinkles: { s: '#s-wrinkles', lv: '#lv-wrinkles', tx: '#ditem-para2' },
smooth: { s: '#s-smooth', lv: '#lv-smooth', tx: '#tx-smooth' },
tone: { s: '#s-tone', lv: '#lv-tone', tx: '#tx-tone' },
sens: { s: '#s-sens', lv: '#lv-sens', tx: '#tx-sens' },
acne: { s: '#s-acne', lv: '#lv-acne', tx: '#tx-acne' }
};
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function setDim(scoreSel, lvSel, dim) {
var el = $(scoreSel), lv = $(lvSel);
if (dim.S == null) {
el.textContent = '-.-';
lv.textContent = '未检出';
lv.className = 'dim-lv';
return;
}
el.textContent = dim.S.toFixed(1);
lv.textContent = dim.level;
lv.className = 'dim-lv ' + (LV_CLASS[dim.level] || '');
}
function setCat(key, cat) {
var dom = CAT_DOM[key];
var s = $(dom.s), lv = $(dom.lv);
if (cat.P == null) { // 缺数据分类:显示说明,不显示分数
s.textContent = '本次未检出有效数据';
s.className = 'cat-s cat-na';
lv.style.display = 'none';
} else {
s.textContent = cat.S.toFixed(1);
s.className = 'cat-s';
lv.style.display = '';
lv.textContent = cat.level;
lv.className = 'cat-badge ' + (LV_CLASS[cat.level] || '');
}
$(dom.tx).textContent = cat.text;
}
function renderReport(vm, frontPhoto, sidePhoto) {
// 头部:综合评分(vm.total 缺失时回退三维度 S 均分×10)
var total = vm.total;
if (total == null) {
var ds = [vm.dims.firm.S, vm.dims.vita.S, vm.dims.stab.S], sum = 0, n = 0, i;
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 + '岁' : '--岁';
$('#stat-age-note').textContent = vm.ageClampNote || ''; // 肤龄钳制留痕(小字灰)
// 三维度:S 值(1 位小数) + 档位(良好绿/改善琥珀/预警红)
setDim('#dim-firm', '#dim-firm-lv', vm.dims.firm);
setDim('#dim-vita', '#dim-vita-lv', vm.dims.vita);
setDim('#dim-stab', '#dim-stab-lv', vm.dims.stab);
$('#dims-note').innerHTML = vm.focus.name
? '近期优先关注:' + esc(vm.focus.name) + '<br>' + esc(vm.focus.tip)
: '近期优先关注:<br>---';
// 三个 Tab 面板:各所辖两个分类(S + 档位徽章 + 话术正文)
var keys = ['contour', 'wrinkles', 'smooth', 'tone', 'sens', 'acne'];
for (var i = 0; i < keys.length; i++) setCat(keys[i], vm.cats[keys[i]]);
if (frontPhoto) $('#ditem-img1').src = frontPhoto;
if (sidePhoto) $('#ditem-img2').src = sidePhoto;
// 降级标注(下颌线未参与评分等,非空才展示)
var notesEl = $('#vm-notes');
if (vm.notes && vm.notes.length) {
notesEl.innerHTML = vm.notes.map(function (t) { return '*' + esc(t); }).join('<br>');
notesEl.style.display = '';
} else {
notesEl.style.display = 'none';
}
// 综合文案 / 生活方式 / 精华推荐
$('#vm-summary').textContent = vm.summary;
$('#vm-lifestyle').textContent = vm.lifestyle;
$('#essence-name').textContent = vm.essence.name;
$('#essence-text').textContent = vm.essence.text;
$('#essence-triggers').textContent = vm.essence.triggers.length ? '触发:' + vm.essence.triggers.join('、') : '';
}
/* ─── 相机可用后加载检测模型(加载完成前快门即手动模式) ─── */
function beginDetect() {
if (IS_MOCK || camFallback) { resetDetectState(); return; }
if (typeof faceapi === 'undefined') {
detectDisabled = true; // face-api 本身缺失:全程手动,不再重试
resetDetectState();
toast('自动识别不可用,已切换手动拍摄');
return;
}
detectDisabled = false; // 上次模型加载失败允许重试(modelsPromise 失败时已清空,loadModels 会重新拉取)
resetDetectState();
if (!modelsOk) $('#cam-status').classList.add('show'); // “检测引擎加载中…”
loadModels().then(function () {
$('#cam-status').classList.remove('show');
maybeStartDetect();
}).catch(function () {
$('#cam-status').classList.remove('show');
detectDisabled = true; // 仅禁用当次;下次进拍照页 beginDetect 复位后重试
resetDetectState();
if (currentScreen === 's-cam') toast('自动识别不可用,已切换手动拍摄');
});
}
/* ─── 隐私勾选 ─── */
var agreed = false;
$('#privacy-row').addEventListener('click', function (e) {
@@ -456,15 +723,16 @@
closeOverlay('p-photoguide');
camSeq++; // 使旧的快门/分析回调失效
camStep = 0;
photos = []; // 新一轮三连拍(从 startCamera 挪出:后台恢复续拍时保留已拍)
shutterLock = false; // 重新进入流程,保险解锁
renderCamStep();
showScreen('s-cam');
startCamera();
startCamera(); // beginDetect 在其成功回调里:确认相机可用才加载模型
},
'cam-back': function () {
camSeq++; // 使未决的快门/分析回调失效,避免回退被旧定时器顶回
shutterLock = false; // 中途退出也解锁
if (camStep > 0) { camStep--; renderCamStep(); }
if (camStep > 0) { camStep--; renderCamStep(); } // renderCamStep 内含检测状态机重置
else { stopCamera(); showScreen('s-profile'); }
},
'shutter': shutter,
@@ -481,6 +749,40 @@
if (fn) fn();
});
/* ─── 页面隐藏/来电生命周期:隐藏即停相机与全部定时器,回前台从当前步恢复(保留 photos[] 与 camStep) ─── */
function handleCamHidden() {
if (currentScreen !== 's-cam') return;
camSeq++; // 使未决的快门/推进/分析回调失效(白屏定时器醒来即自洁退出)
shutterLock = false;
capturing = false;
$('#cam-fill').classList.remove('on'); // 白屏若亮着立即熄灭
closeOverlay('p-analyzing'); // 分析中被打断:回调已作废,弹层不能残留
stopCamera(); // 停检测循环/倒计时/nudge 并释放摄像头(camSeqCam++ 令迟到授权回调失效)
}
function handleCamVisible() {
if (currentScreen !== 's-cam') return;
camSeq++; // 双保险:隐藏期间残留的迟到回调一并作废
renderCamStep(); // 重置当前步检测状态机,从当前步继续
if (!camFallback) startCamera(); // 降级模式无流可起,仅重置状态机;重走 camSeqCam 令牌防复活
}
document.addEventListener('visibilitychange', function () {
if (document.hidden) handleCamHidden(); else handleCamVisible();
});
window.addEventListener('pagehide', handleCamHidden);
window.addEventListener('pageshow', function (e) {
if (e.persisted) handleCamVisible(); // bfcache 恢复(不一定伴随 visibilitychange)
});
/* ─── 初始化 ─── */
renderCamStep();
// 仅调试用:?mock=1&screen=report 直渲 mock 报告页(供无头浏览器截图验证布局,非正式入口)
if (IS_MOCK && /[?&]screen=report/.test(location.search) && typeof SkinScore !== 'undefined') {
showScreen('s-report');
try {
renderReport(SkinScore.buildViewModel(SkinScore.MOCK_RESULT, SkinScore.MOCK_SIDE, {
realAge: +($('#f-age').value || 0) || 28, // 调试档案缺省 28 岁
skinType: SKINS[skinIndex].name
}), null, null);
} catch (e) { /* 调试入口异常不阻塞正式流程 */ }
}
})();
+349
View File
@@ -0,0 +1,349 @@
/**
* head-pose.js —— 纯姿态估算模块(yaw / pitch / roll)
*
* 输入为 face-api.js 的 68 特征点([{x,y}, ...],图片坐标系、未镜像),
* 输出角度制姿态角。纯数据进、纯数据出:不碰 DOM、不 require face-api,
* 浏览器挂 window.HeadPose,Node 下 module.exports,可直接单测。
*
* ------------------------- 坐标与符号约定 -------------------------
* 68 点索引(dlib/face-api 约定,以「看图视角」描述):
* 36 = 画面左侧眼外角(被摄者右眼外角)
* 45 = 画面右侧眼外角(被摄者左眼外角)
* 30 = 鼻尖,33 = 鼻底,19/24 = 左右眉峰,8 = 下巴尖
* 图片坐标系:x 向右,y 向下。
*
* yaw(左右转头):
* yaw > 0 表示鼻尖偏向画面右侧(45 点一侧),即被摄者向【自己的左侧】转头
* (此时画面中看到被摄者的右脸 → check 的 target 'right');
* yaw < 0 表示被摄者向【自己的右侧】转头(画面中看到左脸 → target 'left')。
* mirrored=true(前置摄像头预览做了水平镜像)时内部翻转 yaw 符号,
* 使输出相对用户所看到的画面方向一致;check() 内部会还原语义方向,
* 因此 check 结果与 mirrored 取值无关。
* ⚠ 方向符号真机需校准:不同机型/镜像策略下若左右相反,
* 只需调整调用方传入的 mirrored 标志或 YAW_COEFF 符号。
*
* pitch(俯仰):
* pitch > 0 表示抬头(鼻底相对眉眼连线中点的纵向下移增大),
* pitch < 0 表示低头。⚠ 经验映射,真机需校准 PITCH_BASELINE / PITCH_COEFF。
*
* roll(平面内歪头):
* 两眼外角连线倾角,roll > 0 表示画面右侧(45 点一侧)偏低。
*
* 所有映射均为基于 68 点几何的轻量经验估算(非 solvePnP 精确解),
* 系数仅保证量级与单调性合理,上线前需用真机样本回归校准。
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.HeadPose = factory();
}
}(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// ---- 68 点索引 ----
var IDX = {
EYE_LEFT_OUTER: 36, // 画面左侧眼外角
EYE_RIGHT_OUTER: 45, // 画面右侧眼外角
NOSE_TIP: 30,
NOSE_BASE: 33,
BROW_LEFT: 19,
BROW_RIGHT: 24,
CHIN: 8
};
// ---- 经验系数(⚠ 真机需校准) ----
// yaw = atan((dLeft-dRight)/(dLeft+dRight)) × YAW_COEFF。
// atan 输出 ∈ (-45°,45°),取 2.2 使真实 ~45° 侧脸读数落在 40° 上下。
var YAW_COEFF = 2.2;
// pitch:鼻底相对「眉眼连线中点」的纵向偏移 / 脸高(眉 19/24 — 下巴 8),
// 减去中性正脸基线后线性映射为角度。
var PITCH_BASELINE = 0.32; // 正脸时 offset/faceHeight 的典型值
var PITCH_COEFF = 180; // 归一化偏移 → 角度 的增益
// ---- check 阈值(角度制) ----
// 正脸口径:Face++ 正脸判定 |yaw|<10°,前端留 2° 余量取 ≤12°
var FRONT_YAW_MAX = 12;
var FRONT_PITCH_MAX = 15;
var SIDE_YAW_MIN = 28;
var SIDE_YAW_MAX = 75;
var SIDE_PITCH_MAX = 20;
var HINT_KEEP = '保持不动';
var HINT_TURN_LEFT = '请向左转头'; // 用户向自己的左侧转
var HINT_TURN_RIGHT = '请向右转头'; // 用户向自己的右侧转
var HINT_RAISE = '请抬头';
var HINT_LOWER = '请低头';
var HINT_FRONT = '请正对屏幕';
var RAD2DEG = 180 / Math.PI;
function dist(a, b) {
var dx = a.x - b.x;
var dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
}
function midpoint(a, b) {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
function clamp(v, lo, hi) {
return v < lo ? lo : (v > hi ? hi : v);
}
function assertPoints(points) {
if (!points || typeof points.length !== 'number' || points.length < 68) {
throw new TypeError('HeadPose.estimate: 需要 68 个特征点数组');
}
var names = Object.keys(IDX);
for (var i = 0; i < names.length; i++) {
var p = points[IDX[names[i]]];
if (!p || typeof p.x !== 'number' || typeof p.y !== 'number' ||
!isFinite(p.x) || !isFinite(p.y)) {
throw new TypeError('HeadPose.estimate: 特征点 ' + IDX[names[i]] + ' 非法');
}
}
}
/**
* estimate(points, mirrored) -> { yaw, pitch, roll }(角度制),非法/退化输入返回 null
* points : face-api 68 特征点 [{x,y}, ...](图片坐标系,未镜像)
* mirrored : 预览画面是否水平镜像(true 时翻转 yaw 符号)
* 返回 null 的情形:特征点重合(dLeft+dRight=0,yaw 退化为 NaN)、
* 脸高过小(pitch 不可信,不再静默置 0)、结果出现 NaN/Infinity。
*/
function estimate(points, mirrored) {
assertPoints(points);
var eyeL = points[IDX.EYE_LEFT_OUTER];
var eyeR = points[IDX.EYE_RIGHT_OUTER];
var noseTip = points[IDX.NOSE_TIP];
var noseBase = points[IDX.NOSE_BASE];
var browMid = midpoint(points[IDX.BROW_LEFT], points[IDX.BROW_RIGHT]);
var chin = points[IDX.CHIN];
// roll:两眼外角连线倾角(y 向下,45 点偏低为正)
var roll = Math.atan2(eyeR.y - eyeL.y, eyeR.x - eyeL.x) * RAD2DEG;
// yaw:鼻尖到左右眼外角距离的不对称度;特征点重合(dLeft+dRight=0)时为 NaN,走非法分支
var dLeft = dist(noseTip, eyeL);
var dRight = dist(noseTip, eyeR);
var dSum = dLeft + dRight;
var yaw = NaN;
if (dSum > 0) yaw = Math.atan((dLeft - dRight) / dSum) * RAD2DEG * YAW_COEFF;
// pitch:鼻底相对眉眼连线中点的纵向偏移,以脸高归一后线性映射;
// 脸高过小时 pitch 不可信,不再静默置 0,走非法分支
var eyeMid = midpoint(eyeL, eyeR);
var browEyeMid = midpoint(browMid, eyeMid);
var faceHeight = chin.y - browMid.y;
var pitch = NaN;
if (faceHeight > 1) {
var ratio = (noseBase.y - browEyeMid.y) / faceHeight;
pitch = (ratio - PITCH_BASELINE) * PITCH_COEFF;
}
if (!isFinite(yaw) || !isFinite(pitch) || !isFinite(roll)) return null; // 退化输入 → 非法三角
yaw = clamp(yaw, -90, 90);
pitch = clamp(pitch, -90, 90);
if (mirrored) { yaw = -yaw; }
return { yaw: yaw, pitch: pitch, roll: roll };
}
/**
* check(points, target, mirrored) -> { ok, hint }
* target: 'front' | 'left' | 'right'
* 'front' : |yaw|<=12 且 |pitch|<=15
* 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-75°,-28°]),|pitch|<=20
* 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [28°,75°]),|pitch|<=20
* ok=true 时 hint='保持不动';输入非法(含 estimate 返回 null 的退化特征点)时 ok=false、hint='请正对屏幕'。
*/
function check(points, target, mirrored) {
var e;
try {
e = estimate(points, mirrored);
} catch (err) {
return { ok: false, hint: HINT_FRONT };
}
if (!e) return { ok: false, hint: HINT_FRONT }; // 退化特征点(重合/脸高过小 → NaN/Infinity)
// 语义 yaw:>0 = 被摄者向自己的左侧转头,与预览是否镜像无关
var sYaw = mirrored ? -e.yaw : e.yaw;
var pitch = e.pitch;
if (target === 'front') {
if (Math.abs(sYaw) <= FRONT_YAW_MAX && Math.abs(pitch) <= FRONT_PITCH_MAX) {
return { ok: true, hint: HINT_KEEP };
}
if (Math.abs(sYaw) > FRONT_YAW_MAX) {
// 已向左转过头 → 提示向右回正,反之亦然
return { ok: false, hint: sYaw > 0 ? HINT_TURN_RIGHT : HINT_TURN_LEFT };
}
return { ok: false, hint: pitch > 0 ? HINT_LOWER : HINT_RAISE };
}
if (target === 'left' || target === 'right') {
// 'left' 需要语义 yaw 为负(头向右转),'right' 需要为正
var want = target === 'left' ? -1 : 1;
var sy = sYaw * want; // 统一折算到正区间判断
if (sy >= SIDE_YAW_MIN && sy <= SIDE_YAW_MAX && Math.abs(pitch) <= SIDE_PITCH_MAX) {
return { ok: true, hint: HINT_KEEP };
}
if (sy < SIDE_YAW_MIN) {
// 转得不够(含转错方向):提示朝目标方向转
return { ok: false, hint: target === 'left' ? HINT_TURN_RIGHT : HINT_TURN_LEFT };
}
if (sy > SIDE_YAW_MAX) {
// 转过了:提示往回
return { ok: false, hint: target === 'left' ? HINT_TURN_LEFT : HINT_TURN_RIGHT };
}
return { ok: false, hint: pitch > 0 ? HINT_LOWER : HINT_RAISE };
}
return { ok: false, hint: HINT_FRONT };
}
return {
estimate: estimate,
check: check,
// 导出阈值与系数便于调用方/真机校准阶段调整
config: {
YAW_COEFF: YAW_COEFF,
PITCH_BASELINE: PITCH_BASELINE,
PITCH_COEFF: PITCH_COEFF,
FRONT_YAW_MAX: FRONT_YAW_MAX,
FRONT_PITCH_MAX: FRONT_PITCH_MAX,
SIDE_YAW_MIN: SIDE_YAW_MIN,
SIDE_YAW_MAX: SIDE_YAW_MAX,
SIDE_PITCH_MAX: SIDE_PITCH_MAX
}
};
}));
// -------------------------- Node 自测 --------------------------
if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.main === module) {
var HeadPose = module.exports;
var failures = 0;
function ok(cond, msg) {
if (cond) {
console.log('PASS ' + msg);
} else {
failures++;
console.error('FAIL ' + msg);
}
}
// 合成对称正脸:眼距 100,脸高 170,pitch 偏移恰好落在基线上
function makeFrontal() {
var pts = [];
for (var i = 0; i < 68; i++) { pts.push({ x: 200, y: 150 }); }
pts[36] = { x: 150, y: 100 }; // 左眼外角(画面左)
pts[45] = { x: 250, y: 100 }; // 右眼外角(画面右)
pts[19] = { x: 170, y: 70 };
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
return pts;
}
// 把鼻点横向偏移,模拟 ~45° 侧脸(向画面右侧偏 = 被摄者向左转)
function makeSide() {
var pts = makeFrontal();
pts[30] = { x: 235, y: 140 };
pts[33] = { x: 235, y: 139.4 };
return pts;
}
// 1) 对称正脸 → yaw/pitch/roll ≈ 0
var front = HeadPose.estimate(makeFrontal(), false);
ok(Math.abs(front.yaw) < 0.5, '正脸 yaw≈0 (got ' + front.yaw.toFixed(2) + ')');
ok(Math.abs(front.pitch) < 1, '正脸 pitch≈0 (got ' + front.pitch.toFixed(2) + ')');
ok(Math.abs(front.roll) < 0.5, '正脸 roll≈0 (got ' + front.roll.toFixed(2) + ')');
// 2) 正脸 front check 通过
var fc = HeadPose.check(makeFrontal(), 'front', false);
ok(fc.ok === true && fc.hint === '保持不动', '正脸 front ok,hint=保持不动');
// 3) 45° 侧脸:读数落在合理区间
var side = HeadPose.estimate(makeSide(), false);
ok(side.yaw > 40 && side.yaw < 50, '侧脸 yaw≈45° (got ' + side.yaw.toFixed(2) + ')');
// 4) 方向语义:鼻尖偏画面右 = 被摄者向左转 = target 'right' 通过、'left' 拒绝
var rc = HeadPose.check(makeSide(), 'right', false);
var lc = HeadPose.check(makeSide(), 'left', false);
ok(rc.ok === true, '侧脸(被摄者左转)target=right ok');
ok(lc.ok === false && lc.hint === '请向右转头', '同侧脸 target=left 拒绝并引导向右');
// 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) 仍拒绝');
// 6) 反方向侧脸:鼻尖偏画面左 → target 'left' 通过
var back = makeSide();
back[30] = { x: 165, y: 140 };
back[33] = { x: 165, y: 139.4 };
var bc = HeadPose.check(back, 'left', false);
ok(bc.ok === true, '反向侧脸 target=left ok');
ok(HeadPose.check(back, 'right', false).ok === false, '反向侧脸 target=right 拒绝');
// 7) 侧脸做 front check → 拒绝并引导回正
var sc = HeadPose.check(makeSide(), 'front', false);
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 提示抬头
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=请抬头');
// 10) 非法输入:check 不抛异常,返回兜底引导
var bad = HeadPose.check(null, 'front', false);
ok(bad.ok === false && bad.hint === '请正对屏幕', '非法输入兜底 hint=请正对屏幕');
// 11) 退化特征点:全部重合 → dLeft+dRight=0 → yaw=NaN → estimate 返回 null,check 兜底「请正对屏幕」(不再误导「请向左转头」)
var flat = [];
for (var fi = 0; fi < 68; fi++) { flat.push({ x: 100, y: 100 }); }
ok(HeadPose.estimate(flat, false) === null, '特征点重合 → estimate 返回 null');
var fc2 = HeadPose.check(flat, 'front', false);
ok(fc2.ok === false && fc2.hint === '请正对屏幕', '退化输入 check 兜底 hint=请正对屏幕');
// 12) 脸高过小 → pitch 不可信,不再静默置 0,走非法分支
var tiny = makeFrontal();
tiny[8] = { x: 200, y: 70.5 }; // faceHeight = 0.5 ≤ 1
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');
var slight = makeFrontal();
slight[30] = { x: 209, y: 140 };
slight[33] = { x: 209, y: 139.4 };
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 };
ok(HeadPose.check(slight2, 'front', false).ok === true, 'yaw≈10.7°(≤12°)front 通过');
if (failures > 0) {
console.error('\n' + failures + ' 项自测失败');
process.exit(1);
}
console.log('\n全部自测通过');
}
+977
View File
@@ -0,0 +1,977 @@
/* ═════ 巨子测肤 H5 — 三维度×6分类评分引擎(纯逻辑,浏览器/Node 双端) ═════
* 最高契约:docs/三维度评分与话术生成需求文档_V2.0.md(公式/锁值/话术/产品推荐以其为准)
* 浏览器:window.SkinScore;Node:module.exports。ES5、零依赖、不碰 DOM。
*
* 字段口径(已对 reference/facepp-skinanalyze/examples/ 真实 pro JSON 复核):
* - 分数类字段优先取 result.score_info.<key>,缺失退化 result.<key>(V2.0 §137 归一)
* - pores_type_score 分区键存在无下划线变体(pores_leftcheek_score 等),且无 severity 键
* - wrinkle_count / fine_line 为分区计数对象(forehead_count 等叶子),非 {value:n}
* - 敏感面积优先 red_spot.red_spot_area(V2.0 第二章字段来源),缺失回退 sensitivity.sensitivity_area
* - eye_pouch_severity.value 为 0–3 枚举(缺失退化 eye_pouch.value,非整数取整、越界按缺失)
* - sensitivity_area / red_spot_area / water_area ≤1 视为小数口径×100,否则按百分比直读
* - 水分交叉校验「按 severity 换算」实现为 水分项 = 100 − water_severity(待客户确认)
* - oily_intensity_score → 出油概览句为自设计措辞(文档未给全文)
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.SkinScore = factory();
}(typeof self !== 'undefined' ? self : this, function () {
'use strict';
/* ─── 基础工具 ─── */
function isNum(x) { return typeof x === 'number' && isFinite(x); }
function val(x) { // 解包 {value:n} / 数字 / 数字字符串
if (x == null) return null;
if (isNum(x)) return x;
if (typeof x === 'object') { return isNum(x.value) ? x.value : null; }
var n = Number(x);
return isNaN(n) ? null : n;
}
function pick(r, key) { // score_info 优先,再顶层(§137 字段归一)
if (r == null) return null;
var si = r.score_info;
if (si && si[key] != null) { var v = val(si[key]); if (v != null) return v; }
return val(r[key]);
}
function cnt(node) { // 计数:.count 缺失退化 rectangle 数组长度
if (node == null) return null;
var c = val(node.count);
if (c != null) return c;
if (node.rectangle && typeof node.rectangle.length === 'number') return node.rectangle.length;
return null;
}
function sumLeaves(node) { // 分区计数对象(真实 wrinkle_count/fine_line 为 {forehead_count:22,...} 形状):对数值叶子求和
if (node == null || typeof node !== 'object') return val(node);
var sum = 0, any = false, k;
for (k in node) {
if (node[k] != null && typeof node[k] === 'object') continue; // 只看数值叶子,不递归嵌套对象
var v = val(node[k]);
if (v != null) { sum += v; any = true; }
}
return any ? sum : null;
}
function cntField(r, key) { // 计数字段归一:标量/{value} 直读(score_info 优先),分区对象对数值叶子求和
var v = pick(r, key);
if (v != null) return v;
var si = r.score_info;
var node = (si && si[key] != null) ? si[key] : r[key];
return sumLeaves(node);
}
function round2(x) { return Math.round((x + 1e-9) * 100) / 100; } // 1e-9 修正 9.445 类浮点
function sRaw(P) { return 7 + 3 * P / 100; }
function sShow(P) { // 展示层钳制:P 可为负(肤色扣分/大面积敏感),S 会 <7,与文档「S 7.0–10.0」口径矛盾;
if (P == null) return null; // 仅钳 viewModel 展示值,判档/最差项/精华三重判据仍用未舍入原值
var s = sRaw(P);
return round2(s < 7 ? 7 : (s > 10 ? 10 : s));
}
function levelOf(P) { // 判档一律用未四舍五入 P 原值
if (P == null) return null;
return P < 80 ? '预警' : (P < 90 ? '改善' : '良好');
}
function fmt(x) { // 展示用:两位内,去掉多余的 .0
if (x == null) return '';
var v = round2(x);
return Math.round(v) === v ? String(Math.round(v)) : String(v);
}
/* 模板填充:{var} 替换;变量缺失(null/undefined/'')时省略包含它的最小分句(,;。界),
* 严禁输出 None/undefined/— 占位(V2.0 第五章生成规则)。 */
function fill(tpl, vars) {
var parts = tpl.split(/([,;。])/);
var out = '';
for (var i = 0; i < parts.length; i += 2) {
var clause = parts[i], delim = parts[i + 1] || '';
var miss = false;
var c = clause.replace(/\{(\w+)\}/g, function (m, k) {
var v = vars[k];
if (v === null || v === undefined || v === '') { miss = true; return m; }
return v;
});
if (!miss) out += c + delim; // 缺失:整分句连同分隔符省略
}
return out;
}
function joinParts(parts, sep) {
var out = [];
for (var i = 0; i < parts.length; i++) if (parts[i]) out.push(parts[i]);
return out.join(sep);
}
/* ─── 字段归一:分区配置 ─── */
var WRINKLE_REGIONS = [ // 12 分区;left_nasolabial_wrinkle_info 与 _fold_info 并存(§137)
{ keys: ['forehead_wrinkle_info'], cn: '抬头纹' },
{ keys: ['glabella_wrinkle_info'], cn: '眉间纹' },
{ keys: ['left_eye_wrinkle_info'], cn: '左眼周' },
{ keys: ['right_eye_wrinkle_info'], cn: '右眼周' },
{ keys: ['left_crowsfeet_wrinkle_info'], cn: '左鱼尾纹' },
{ keys: ['right_crowsfeet_wrinkle_info'], cn: '右鱼尾纹' },
{ keys: ['left_nasolabial_wrinkle_info', 'left_nasolabial_fold_info'], cn: '左法令纹' },
{ keys: ['right_nasolabial_wrinkle_info', 'right_nasolabial_fold_info'], cn: '右法令纹' },
{ keys: ['left_mouth_wrinkle_info'], cn: '左嘴角纹' },
{ keys: ['right_mouth_wrinkle_info'], cn: '右嘴角纹' },
{ keys: ['left_cheek_wrinkle_info'], cn: '左脸颊' },
{ keys: ['right_cheek_wrinkle_info'], cn: '右脸颊' }
];
var PORE_REGION_CN = { // 真实返回同时存在下划线/无下划线变体(如 pores_left_cheek_score 与 pores_leftcheek_score),均需中文名兜底
forehead: '额头', left_cheek: '左脸颊', right_cheek: '右脸颊', jaw: '下巴', nose: '鼻部',
leftcheek: '左侧面颊', rightcheek: '右侧面颊', chin: '下巴'
};
/* ─── 分类计算(每项返回 { P, vars },P=null 表示缺项) ─── */
// 轮廓:P = min(眼袋severity映射, 下颌线角度分)
var EYE_POUCH_MAP = [100, 90, 79, 74]; // 0→100/1→90/2→79/3→74
function jawScore(angle) { // 100−|偏离116–120°|×2,下限60
var dev = angle < 116 ? 116 - angle : (angle > 120 ? angle - 120 : 0);
return Math.max(60, 100 - dev * 2);
}
function jawlineOf(side) { // 键名归一:*_jawline_info / *_jawline_angle / *_jawline_quality
if (!side) return null;
var info = null, k;
for (k in side) if (/_jawline_info$/.test(k)) { info = side[k]; break; }
if (!info) return null;
var angle = null, quality = null;
for (k in info) {
if (/_jawline_angle$/.test(k)) angle = val(info[k]);
else if (/_jawline_quality$/.test(k)) quality = val(info[k]);
}
return { angle: angle, quality: quality };
}
function calcContour(r, sides, notes) {
var pouchSev = pick(r, 'eye_pouch_severity');
if (pouchSev == null) pouchSev = pick(r, 'eye_pouch'); // 退化
var pouchP = null, pouchLvl = null;
if (pouchSev != null) {
var idx = Math.round(pouchSev); // severity 可能返回非整数:取整后再做映射数组索引
if (idx < 0 || idx > 3) {
notes.push('眼袋 severity 越界(' + pouchSev + '),按缺失处理,轮廓评分缺眼袋项');
} else {
pouchLvl = idx;
pouchP = EYE_POUCH_MAP[idx];
}
} else {
notes.push('眼袋 severity 字段缺失,轮廓评分缺眼袋项');
}
var la = null, ra = null, jawParts = [], jawScores = [];
var anySide = sides && (sides.left || sides.right);
var sidesMeta = [['left', '左', sides && sides.left], ['right', '右', sides && sides.right]];
for (var i = 0; i < 2; i++) {
var tag = sidesMeta[i][1], side = sidesMeta[i][2];
if (!side) continue;
var j = jawlineOf(side);
if (!j) { notes.push(tag + '侧脸未返回下颌线数据,下颌线未参与轮廓评分'); continue; }
if (j.quality !== 1) { // 仅 quality=1 角度有效
notes.push(tag + '侧脸角度无效(quality=' + (j.quality == null ? '缺失' : j.quality) + '),下颌线未参与轮廓评分,侧脸角度需20°–120°,建议重拍');
continue;
}
if (j.angle == null) { notes.push(tag + '侧脸下颌线角度缺失,未参与轮廓评分'); continue; }
if (tag === '左') la = j.angle; else ra = j.angle;
jawScores.push(jawScore(j.angle));
}
var jawP = jawScores.length ? Math.min.apply(null, jawScores) : null; // 左右取较差侧
if (!anySide) notes.push('未拍摄侧脸视图,轮廓评分仅由眼袋决定,建议补拍侧脸视图');
else if (jawP == null) notes.push('双侧下颌线均不可用,轮廓评分仅由眼袋决定');
var cand = [];
if (pouchP != null) cand.push(pouchP);
if (jawP != null) cand.push(jawP);
if (!cand.length) { notes.push('轮廓:眼袋与下颌线数据均缺失,轮廓分类不参与评分'); return { P: null, vars: {} }; }
return {
P: Math.min.apply(null, cand),
vars: {
la: la, ra: ra,
eye_pouch: pouchLvl, // 取整后的等级;越界/缺失时为 null(话术省略该分句)
dark_circle_sev: pick(r, 'dark_circle')
}
};
}
// 皱纹:P = 100 − max(12分区 wrinkle_score);分区 severity≥2 锁79、≥3 锁74
function calcWrinkles(r, notes) {
var regions = [], used = {}, i, k, node;
for (i = 0; i < WRINKLE_REGIONS.length; i++) {
var rg = WRINKLE_REGIONS[i]; node = null;
for (var j = 0; j < rg.keys.length; j++) {
if (r[rg.keys[j]] != null) { node = r[rg.keys[j]]; used[rg.keys[j]] = 1; break; }
}
if (!node) continue;
var sc = val(node.wrinkle_score);
if (sc == null) continue;
regions.push({ cn: rg.cn, score: sc, sev: val(node.wrinkle_severity_level) });
}
for (k in r) { // 兜底:未配置的 *_wrinkle_info / *_fold_info 变体也参与
if (used[k] || !/(_wrinkle_info|_fold_info)$/.test(k)) continue;
node = r[k];
if (!node || typeof node !== 'object') continue;
var s2 = val(node.wrinkle_score);
if (s2 == null) continue;
regions.push({ cn: k.replace(/(_wrinkle_info|_fold_info)$/, ''), score: s2, sev: val(node.wrinkle_severity_level) });
}
if (!regions.length) { notes.push('皱纹:12 分区 wrinkle_info 全部缺失,皱纹分类不参与评分'); return { P: null, vars: {} }; }
var worst = regions[0], maxSev = null;
for (i = 0; i < regions.length; i++) {
if (regions[i].score > worst.score) worst = regions[i];
if (regions[i].sev != null && (maxSev == null || regions[i].sev > maxSev)) maxSev = regions[i].sev;
}
var P = 100 - worst.score;
if (maxSev != null) {
if (maxSev >= 3) P = Math.min(P, 74);
else if (maxSev >= 2) P = Math.min(P, 79);
} else {
notes.push('皱纹:分区 severity 不可得,上限锁未执行');
}
var wrCnt = cntField(r, 'wrinkle_count');
var flCnt = cntField(r, 'fine_line'); // 真实键名为 fine_line(分区计数对象),兼容 fine_line_count
if (flCnt == null) flCnt = cntField(r, 'fine_line_count');
return {
P: P,
vars: {
wr_cnt: wrCnt,
fl_cnt: flCnt,
worst_wr_region: worst.cn,
wr_max: worst.score,
max_sev: maxSev
}
};
}
// 光滑:P = 0.4×min(4分区 pores_type_score) + 0.3×blackhead_score + 0.3×rough_score;分区 severity≥2 锁79
function calcSmooth(r, notes) {
var pts = (r.score_info && r.score_info.pores_type_score) || r.pores_type_score || null;
var poreMin = null, poreRegion = null, poreSevMax = null, k;
if (pts && typeof pts === 'object') {
for (k in pts) {
if (/_score$/.test(k)) {
var s = val(pts[k]);
if (s == null) continue;
var region = k.replace(/^pores_/, '').replace(/_score$/, '');
if (poreMin == null || s < poreMin) { poreMin = s; poreRegion = PORE_REGION_CN[region] || region; }
} else if (/_severity$/.test(k)) {
var sv = val(pts[k]);
if (sv != null && (poreSevMax == null || sv > poreSevMax)) poreSevMax = sv;
}
}
} else {
notes.push('pores_type_score 缺失(score_info 内亦无),光滑评分缺毛孔项');
}
// 真实 pores_type_score 内无 severity 键:锁所需数据缺失时留痕,避免锁静默失效
if (pts && typeof pts === 'object' && poreSevMax == null) {
notes.push('毛孔分区严重度未返回,光滑上限锁未执行');
}
var bh = pick(r, 'blackhead_score');
var rough = pick(r, 'rough_score');
if (bh == null) notes.push('blackhead_score 缺失,光滑评分缺黑头项');
if (rough == null) notes.push('rough_score 缺失,光滑评分缺纹理项');
var factors = [[0.4, poreMin], [0.3, bh], [0.3, rough]], wSum = 0, acc = 0, n = 0, i;
for (i = 0; i < factors.length; i++) {
if (factors[i][1] == null) continue; // 单项缺失:剔除并按剩余权重归一
wSum += factors[i][0]; acc += factors[i][0] * factors[i][1]; n++;
}
if (!n) { notes.push('光滑:毛孔/黑头/纹理三项全部缺失,光滑分类不参与评分'); return { P: null, vars: {} }; }
var P = acc / wSum;
if (poreSevMax != null && poreSevMax >= 2) P = Math.min(P, 79);
var poreCnt = null;
if (r.enlarged_pore_count != null) {
if (isNum(val(r.enlarged_pore_count))) poreCnt = val(r.enlarged_pore_count);
else if (typeof r.enlarged_pore_count === 'object') { // 4 分区求和
poreCnt = 0;
for (k in r.enlarged_pore_count) { var c = val(r.enlarged_pore_count[k]); if (c != null) poreCnt += c; }
}
}
var bhCnt = cnt(r.blackhead);
if (bhCnt == null) bhCnt = pick(r, 'blackhead_count');
return {
P: P,
vars: {
worst_pore_region: poreRegion,
pore4_min: poreMin,
pore_cnt: poreCnt,
blackhead_cnt: bhCnt,
tex: rough,
oily: pick(r, 'oily_intensity_score')
}
};
}
// 肤色水润:P = 0.5×(melanin_score−melasma×10−freckle×5) + 0.5×water_score
// 交叉校验:water_severity≥60 且 water_score≥80 → 水分项按 severity 换算(实现为 100−severity,待客户确认)
function calcTone(r, notes) {
var melanin = pick(r, 'melanin_score');
var melasma = pick(r, 'melasma') || 0; // 缺失按未检出(不扣分)
var freckle = pick(r, 'freckle') || 0;
var waterScore = pick(r, 'water_score');
var waterObj = r.water || {};
var waterSev = val(waterObj.water_severity);
if (waterSev == null) waterSev = pick(r, 'water_severity');
var waterArea = val(waterObj.water_area);
if (waterArea == null) waterArea = pick(r, 'water_area');
if (waterArea != null && waterArea <= 1) waterArea = waterArea * 100; // 小数口径×100 → 百分比
var melaninItem = null, waterItem = null;
if (melanin != null) melaninItem = melanin - melasma * 10 - freckle * 5;
else notes.push('melanin_score 缺失,肤色水润评分缺肤色项');
if (waterScore != null) {
waterItem = waterScore;
if (waterSev != null && waterSev >= 60 && waterScore >= 80) {
waterItem = 100 - waterSev;
notes.push('水分交叉校验触发(water_severity=' + waterSev + ' 且 water_score=' + waterScore + '),水润项按 severity 换算为 ' + waterItem);
}
} else {
notes.push('water_score 缺失,肤色水润评分缺水润项');
}
var items = [melaninItem, waterItem], acc = 0, n = 0, i;
for (i = 0; i < 2; i++) if (items[i] != null) { acc += 0.5 * items[i]; n++; }
if (!n) { notes.push('肤色水润:肤色项与水润项均缺失,肤色水润分类不参与评分'); return { P: null, vars: {} }; }
var P = n === 2 ? acc : acc * 2 / n; // 单项时按 0.5 权重归一
var itaObj = r.skintone_ita || {};
var haObj = r.skin_hue_ha || {};
var st = itaObj.skin_tone != null ? itaObj.skin_tone
: (itaObj.skintone != null ? itaObj.skintone // 真实键名为 skintone(另有 ITA 值),兼容 skin_tone/tone
: (itaObj.tone != null ? itaObj.tone : null));
return {
P: P,
vars: {
ita: val(itaObj.ITA != null ? itaObj.ITA : itaObj.value),
st: st,
ha: val(haObj.HA != null ? haObj.HA : haObj.value),
melanin: melanin,
spot_flag: (melasma || freckle) ? joinParts([melasma ? '黄褐斑' : '', freckle ? '雀斑' : ''], '、') : '未见明显色斑标记',
water_score: waterScore,
water_area: waterArea,
water_sev: waterSev
}
};
}
// 敏感:P = min(sensitivity_score, 100−面积%×1.5);面积≥15% 锁79、≥30% 锁74
function calcSens(r, notes) {
var sensObj = r.sensitivity || null;
var score = pick(r, 'sensitivity_score');
if (score == null && sensObj) score = val(sensObj.sensitivity_score);
// 面积口径来源(V2.0 第二章字段来源):优先 red_spot.red_spot_area(小数×100=%);
// 缺失再回退 sensitivity.sensitivity_area;两者均保留 ≤1→×100 启发式
var areaRaw = r.red_spot && typeof r.red_spot === 'object' ? val(r.red_spot.red_spot_area) : null;
if (areaRaw == null) {
areaRaw = sensObj ? val(sensObj.sensitivity_area) : null;
if (areaRaw == null) areaRaw = pick(r, 'sensitivity_area');
}
if (score == null && areaRaw == null) {
notes.push('sensitivity 对象缺失,敏感分类不参与评分与均分(禁止编造降级假分数)');
return { P: null, vars: { missing: true } };
}
// 面积口径:≤1 视为小数(如 0.001)×100 换算成百分比;否则按百分比直读(如 2.0 → 2%)
var areaPct = areaRaw == null ? null : (areaRaw <= 1 ? areaRaw * 100 : areaRaw);
var cand = [];
if (score != null) cand.push(score);
if (areaPct != null) cand.push(100 - areaPct * 1.5);
var P = Math.min.apply(null, cand);
if (areaPct != null) {
if (areaPct >= 30) P = Math.min(P, 74);
else if (areaPct >= 15) P = Math.min(P, 79);
}
return { P: P, vars: { sens_area: areaPct, sens_score: score } };
}
// 痘痘:P = min(acne_score 分级映射, 100−计数扣分);扣分封顶50
function acneGrade(score) { return score >= 95 ? 100 : (score >= 85 ? 85 : (score >= 75 ? 70 : 55)); }
function calcAcne(r, notes) {
var score = pick(r, 'acne_score');
var cc = cnt(r.closed_comedones); // 闭口 ×1
var pa = cnt(r.acne); // 丘疹 ×2
var pu = cnt(r.acne_pustule); // 脓包 ×4
var no = cnt(r.acne_nodule); // 结节 ×6
var mk = cnt(r.acne_mark); // 痘印 ×1.5
var counts = [cc, pa, pu, no, mk], allNull = true, i;
for (i = 0; i < 5; i++) if (counts[i] != null) { allNull = false; break; }
if (score == null && allNull) {
notes.push('acne_score 与皮损计数全部缺失,痘痘分类不参与评分');
return { P: null, vars: {} };
}
if (score == null) notes.push('acne_score 缺失,痘痘评分仅由计数扣分决定');
var deduct = Math.min(50, (cc || 0) * 1 + (pa || 0) * 2 + (pu || 0) * 4 + (no || 0) * 6 + (mk || 0) * 1.5);
var cand = [];
if (score != null) cand.push(acneGrade(score));
if (!allNull) cand.push(100 - deduct);
var detail = joinParts([
cc ? '闭口粉刺' + cc + '处' : '',
pa ? '丘疹' + pa + '处' : '',
pu ? '脓包' + pu + '处' : '',
no ? '结节' + no + '处' : '',
mk ? '痘印' + mk + '处' : ''
], '、');
var total = 0;
for (i = 0; i < 5; i++) total += counts[i] || 0;
return {
P: Math.min.apply(null, cand),
vars: {
acne_cnt: allNull ? null : total,
acne_detail: detail,
heavy: (pu || 0) > 0 || (no || 0) > 0 // 仅在真实检出脓包/结节时输出 heavy_note
}
};
}
/* ─── 话术库(V2.0 第五章逐字;变量缺失省略对应分句) ─── */
function oilDesc(oily) { // {oil_desc} 出油概览句(自设计措辞,文档未给全文)
if (oily == null) return '';
if (oily < 40) return '肌肤出油较少,整体清爽';
if (oily < 60) return 'T区存在轻度出油';
return '全脸出油偏多,需加强控油';
}
function jawSeg(v, goodTpl) { // 组合下颌线分句;返回 '' 表示省略
if (v.la == null && v.ra == null) return '';
var ang = v.la != null && v.ra != null ? '下颌线角度左' + fmt(v.la) + '°/右' + fmt(v.ra) + '°'
: (v.la != null ? '下颌线角度左' + fmt(v.la) + '°' : '下颌线角度右' + fmt(v.ra) + '°');
var outL = v.la != null && (v.la < 116 || v.la > 120);
var outR = v.ra != null && (v.ra < 116 || v.ra > 120);
var paren;
if (goodTpl && !outL && !outR) paren = '(处于标准区间)';
else if (outL && outR) paren = '(标准区间116–120°,双侧均偏离标准,提示下颌缘支撑力下降)';
else if (outL || outR) paren = '(标准区间116–120°,单侧偏离标准,提示下颌缘支撑力下降)';
else paren = '(标准区间116–120°)';
return ang + paren;
}
function eyeSeg(v) {
return joinParts([
v.eye_pouch != null ? '眼袋' + fmt(v.eye_pouch) + '级' : '',
v.dark_circle_sev != null ? '黑眼圈' + fmt(v.dark_circle_sev) + '级' : ''
], '、');
}
function textContour(P, v) {
var lv = levelOf(P), jaw = jawSeg(v, lv === '良好'), eye = eyeSeg(v);
if (lv === '预警') return fill('面部轮廓出现可见松弛{jaw};{eye};中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。',
{ jaw: jaw ? ',' + jaw : '', eye: eye });
// V2.0 §5.1 改善档原文只含眼袋,不含黑眼圈
if (lv === '改善') return fill('{jaw},{eye},眼下区域出现轻度松弛信号。建议增加紧致类精华步骤,配合由下向上的提拉按摩手法,重点关照眼周与中面部。',
{ jaw: jaw, eye: v.eye_pouch != null ? '眼袋' + fmt(v.eye_pouch) + '级' : '' });
return fill('面部轮廓紧致立体{jaw},{eye},中面部容积饱满,未见明显软组织下垂。轮廓支撑力良好,建议维持日常紧致护理与防晒,保持规律作息,守护现有轮廓。',
{ jaw: jaw ? ',' + jaw : '', eye: eye });
}
function wrSeg(v) {
return joinParts([
v.wr_cnt != null ? '全脸皱纹' + fmt(v.wr_cnt) + '条' : '',
v.fl_cnt != null ? '细纹' + fmt(v.fl_cnt) + '条' : ''
], '、');
}
function textWrinkles(P, v) {
var lv = levelOf(P), seg = wrSeg(v);
var paren = v.wr_max != null
? (lv === '预警'
? '(分区分' + fmt(v.wr_max) + (v.max_sev != null ? ',最高严重度' + fmt(v.max_sev) + '级' : '') + ')'
: '(分区分' + fmt(v.wr_max) + (v.max_sev != null ? ',严重度' + fmt(v.max_sev) + '级' : '') + ')')
: '';
if (lv === '预警') return fill('{seg},最差区域为{r}{p},部分区域已现静态纹趋势。建议针对眼周、法令纹等高发区使用视黄醇/胜肽类淡纹精华,遏制静态纹转化。',
{ seg: seg, r: v.worst_wr_region, p: paren });
if (lv === '改善') return fill('{seg},最差区域为{r}{p}。建议加强日常淡纹与弹润护理,保持肌肤滋润度。',
{ seg: seg, r: v.worst_wr_region, p: paren });
return fill('全脸12分区纹路平整(最差区域{r}{p2}){fl},未见明显静态纹。建议维持日常保湿与防晒,保持现有淡纹护理节奏。',
{ r: v.worst_wr_region, p2: v.wr_max != null ? ',分区分' + fmt(v.wr_max) : '', fl: v.fl_cnt != null ? ',细纹合计' + fmt(v.fl_cnt) + '条' : '' });
}
function cntSeg(v) {
return joinParts([
v.pore_cnt != null ? '粗大毛孔' + fmt(v.pore_cnt) + '颗' : '',
v.blackhead_cnt != null ? '黑头' + fmt(v.blackhead_cnt) + '颗' : ''
], '、');
}
function textSmooth(P, v) {
var lv = levelOf(P), seg = cntSeg(v), oil = oilDesc(v.oily);
if (lv === '预警') return fill('皮肤表面平整度较低,最差毛孔区位于{r}{p},{c}{o}纹理分仅{t},整体细腻度不足。建议避免过度清洁,重点关注{r},配合低浓度酸类周期护理与柔润精华。',
{ r: v.worst_pore_region, p: v.pore4_min != null ? '(分区毛孔分' + fmt(v.pore4_min) + ')' : '', c: seg ? seg + ',' : '', o: oil ? oil + ',' : '', t: fmt(v.tex) });
if (lv === '改善') return fill('皮肤表面整体较为平滑,毛孔最差区为{r}{p},{c}{o}纹理分{t},局部存在轻微角质粗糙感。建议加强基础清洁与低浓度酸类周期护理。',
{ r: v.worst_pore_region, p: v.pore4_min != null ? '(分区毛孔分' + fmt(v.pore4_min) + ')' : '', c: seg ? seg + ',' : '', o: oil ? oil + ',' : '', t: fmt(v.tex) });
return fill('皮肤表面细腻平滑,最差毛孔区{r}控制良好(分区毛孔分{p}{c}),纹理分{t},角质层排列整齐。建议维持温和清洁与保湿节奏。',
{ r: v.worst_pore_region, p: fmt(v.pore4_min), c: seg ? ',' + seg : '', t: fmt(v.tex) });
}
function itaHdr(v) { // 「肤色ITA {ita}({st},HA {ha})」组合,缺项省略
var stStr = (v.st === null || v.st === undefined || v.st === '') ? '' : String(v.st); // st=0 是合法肤色分类,不能被 falsy 吞掉
var inner = joinParts([stStr, v.ha != null ? 'HA ' + fmt(v.ha) : ''], ',');
if (v.ita == null && !inner) return '';
if (v.ita == null) return '肤色' + (inner ? '(' + inner + ')' : '');
return '肤色ITA ' + fmt(v.ita) + (inner ? '(' + inner + ')' : '');
}
function textTone(P, v) {
var lv = levelOf(P), hdr = itaHdr(v);
var wseg = joinParts([
v.water_area != null ? '缺水面积' + fmt(v.water_area) + '%' : '',
v.water_sev != null ? '严重度' + fmt(v.water_sev) : ''
], '、');
if (wseg) wseg = ',' + wseg;
var vars = { h: hdr, m: fmt(v.melanin), f: v.spot_flag, w: fmt(v.water_score), ws: wseg };
if (lv === '预警') return fill('{h},色素分{m},整体偏暗沉({f});水分分{w}{ws},水润度不足。建议加强透亮与补水护理,做好防晒,减少色素刺激。', vars);
if (lv === '改善') return fill('{h},色素分{m},整体稳定但存在局部暗沉倾向({f});水分分{w}{ws}。建议加强抗氧化与光泽管理,做好基础防晒与补水。', vars);
return fill('{h},色素分{m},肤色均匀透亮({f});水分分{w}{ws}。色素与水润状态均衡,建议持续做好防晒与基础保湿。', vars);
}
function textSens(P, v) {
if (v.missing) return '本次检测未获取到敏感度数据(sensitivity 字段缺失),敏感分类暂不参与评分,建议重新拍摄检测以获取完整结果。';
var lv = levelOf(P), vars = { a: fmt(v.sens_area), s: fmt(v.sens_score) };
if (lv === '预警') return fill('泛红面积达{a}%,红区分{s},屏障受损信号明显、敏感区域较大。建议立即转为舒缓修护路线,停用酸类与强清洁,必要时寻求专业建议。', vars);
if (lv === '改善') return fill('泛红面积{a}%,红区分{s},存在局部轻微泛红与屏障波动。建议使用舒缓修护类产品,减少刺激源,注意温差与清洁力度。', vars);
return fill('泛红面积{a}%,红区分{s},屏障状态稳定,未见明显敏感信号。建议维持现有温和护理,避免过度去角质。', vars);
}
function textAcne(P, v) {
var lv = levelOf(P);
var heavy = v.heavy ? ',含脓包、结节等中重度皮损,炎症反应明显' : '';
// 「检出N处皮损(明细)」与 heavy 拼接成句:heavy 为空时不得拖垮整分句(fill 的缺失省略以分句为单位)
var found = (v.acne_cnt != null && v.acne_detail) ? '检出' + fmt(v.acne_cnt) + '处皮损(' + v.acne_detail + ')' : '';
if (lv === '预警') return (found ? found + heavy + '。' : '')
+ '建议尽快进行针对性抗痘护理,避免挤压与刺激,必要时寻求皮肤科专业处理。';
if (lv === '改善') return (found ? found + ',' : '')
+ '以闭口粉刺、丘疹为主,局部存在炎症倾向。建议加强温和清洁与控油,避免挤压,配合抗炎舒缓护理。';
// 良好档:所有计数字段缺失(仅靠 acne_score 分级得高 P)时不能声称「未检出」,输出缺数据提示句
if (v.acne_cnt == null) return '本次检测未获取皮损计数数据,仅按痤疮分级评估。建议维持清洁与控油节奏,注意作息与饮食。';
return '未检出痤疮与皮损。建议维持清洁与控油节奏,注意作息与饮食。';
}
/* ─── 常量表 ─── */
var CAT_DEFS = [ // 顺序即最差分类并列取舍优先级:轮廓>皱纹>光滑>肤色水润>敏感>痘痘
{ key: 'contour', name: '轮廓', dim: 'firm', calc: calcContour, text: textContour },
{ key: 'wrinkles', name: '皱纹', dim: 'firm', calc: calcWrinkles, text: textWrinkles },
{ key: 'smooth', name: '光滑', dim: 'vita', calc: calcSmooth, text: textSmooth },
{ key: 'tone', name: '肤色水润', dim: 'vita', calc: calcTone, text: textTone },
{ key: 'sens', name: '敏感', dim: 'stab', calc: calcSens, text: textSens },
{ key: 'acne', name: '痘痘', dim: 'stab', calc: calcAcne, text: textAcne }
];
var DIM_DEFS = [
{ key: 'firm', name: '紧塑力', tip: '轮廓支撑与紧致度:重点针对下颌线与眼周使用紧致类精华,配合由下向上的提拉按摩手法' },
{ key: 'vita', name: '活力感', tip: '细腻度与水润光泽:加强补水与温和清洁,做好防晒与抗氧化管理' },
{ key: 'stab', name: '稳定力', tip: '屏障稳定与痘痘管理:温和清洁、舒缓修护,避免刺激源与挤压' }
];
var LIFESTYLE = { // V2.0 §6.2 全文
contour: '戒除揉眼、托腮等表情性挤压习惯,保证睡眠减少纹路加深',
wrinkles: '保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳',
smooth: '规律去角质但不过度(每1–2周一次),运动后及时清洁,减少油炸与高糖饮食',
tone: '每日防晒足量补涂,增加维C与抗氧化食物摄入,保证饮水节奏',
sens: '避免过热的水洗脸与桑拿,选择无香精配方,换季时精简护肤步骤',
acne: '不挤压痘痘,枕巾高频更换,减少乳制品与高升糖饮食摄入'
};
var ESSENCE = { // V2.0 §6.1 三档全文
1: { name: '25+束紧精华', text: '建议搭配使用25+束紧精华:针对年轻肌肤活力减弱问题,激活肌肤动力、改善暗沉,提升弹润紧致表现,守护年轻光泽并延缓初始衰老迹象。' },
2: { name: '35+支撑精华', text: '建议搭配使用35+支撑精华:专为轻熟肌打造,提升弹韧表现、维持面部轮廓清晰度,改善疲态并延缓衰老速度。' },
3: { name: '50+重塑精华', text: '建议搭配使用50+重塑精华:针对成熟肌肤恢复能力下降,提升紧致表现、改善松弛与轮廓模糊,修复强韧屏障,重塑年轻质感。' }
};
/* ─── 主入口 ─── */
function buildViewModel(apiResult, sideResults, profile) {
var r = apiResult || {};
profile = profile || {};
var notes = [];
var realAge = val(profile.realAge);
// 肌肤年龄:原值不改写,展示值钳制到 realAge±3 并留痕
var skinAge = val(r.skin_age);
var skinAgeDisplay = skinAge, ageClampNote = '';
if (realAge == null) {
notes.push('未提供实际年龄:产品组别、肌肤年龄钳制与精华「年龄组档」判据均不生效');
} else if (skinAge != null) {
skinAgeDisplay = Math.min(Math.max(skinAge, realAge - 3), realAge + 3);
if (skinAgeDisplay !== skinAge) ageClampNote = '触发(原' + skinAge + '→' + skinAgeDisplay + ')';
}
var group = null;
if (realAge != null) group = realAge < 32 ? '25+组' : (realAge <= 45 ? '35+组' : '50+组');
// 6 分类
var cats = {}, sList = [], i, def, res;
for (i = 0; i < CAT_DEFS.length; i++) {
def = CAT_DEFS[i];
res = def.key === 'contour' ? def.calc(r, sideResults, notes) : def.calc(r, notes);
cats[def.key] = {
name: def.name, dim: def.dim,
P: res.P, S: sShow(res.P), level: levelOf(res.P),
text: res.P == null
? (res.vars.missing ? def.text(null, res.vars) : '本次检测' + def.name + '分类数据缺失,暂不参与评分。')
: def.text(res.P, res.vars)
};
if (res.P != null) sList.push(sRaw(res.P));
}
// 三维度(木桶 min,缺项剔除)
var dims = {};
for (i = 0; i < DIM_DEFS.length; i++) {
var d = DIM_DEFS[i], ps = [], j;
for (j = 0; j < CAT_DEFS.length; j++) {
if (CAT_DEFS[j].dim !== d.key) continue;
var P = cats[CAT_DEFS[j].key].P;
if (P != null) ps.push(P);
}
var dP = ps.length ? Math.min.apply(null, ps) : null;
if (!ps.length) notes.push(d.name + ':旗下分类数据全部缺失,维度不参与评分');
dims[d.key] = { name: d.name, P: dP, S: sShow(dP), level: levelOf(dP) };
}
// 均分(判档用未四舍五入原值)与最差分类(并列按 CAT_DEFS 顺序取前者)
var avgRaw = null, avgS = null;
if (sList.length) {
var sum = 0;
for (i = 0; i < sList.length; i++) sum += sList[i];
avgRaw = sum / sList.length;
avgS = round2(avgRaw);
}
var worst = null;
for (i = 0; i < CAT_DEFS.length; i++) {
def = CAT_DEFS[i];
var cP = cats[def.key].P;
if (cP == null) continue;
if (!worst || cP < worst.P) worst = { catKey: def.key, name: def.name, P: cP, S: sShow(cP) };
}
// 精华推荐:三重判据取最强档,triggers 记录命中(档位=最终档)的判据
var crits = []; // {name, tier}
if (avgRaw != null) crits.push({ name: '均分档', tier: avgRaw >= 9 ? 1 : (avgRaw >= 8 ? 2 : 3) });
if (worst) {
var wS = sRaw(worst.P);
if (wS < 7) crits.push({ name: '最差项约束', tier: 3 });
else if (wS < 8) crits.push({ name: '最差项约束', tier: 2 });
}
if (group) crits.push({ name: '年龄组档', tier: group === '25+组' ? 1 : (group === '35+组' ? 2 : 3) });
var tier = 1;
if (crits.length) {
tier = crits[0].tier;
for (i = 0; i < crits.length; i++) tier = Math.max(tier, crits[i].tier); // 推荐强度只升不降
} else {
notes.push('三重判据均不可用,精华推荐缺省为 25+束紧精华');
}
var triggers = [];
for (i = 0; i < crits.length; i++) if (crits[i].tier === tier) triggers.push(crits[i].name);
var essence = { name: ESSENCE[tier].name, text: ESSENCE[tier].text, triggers: triggers };
// 近期优先关注:最差维度
var focus = { name: '', tip: '' }, fP = null;
for (i = 0; i < DIM_DEFS.length; i++) {
var dd = DIM_DEFS[i], dP2 = dims[dd.key].P;
if (dP2 == null) continue;
if (fP == null || dP2 < fP) { fP = dP2; focus = { name: dd.name, tip: dd.tip }; }
}
// 综合文案(§6.1)
var summary;
if (avgRaw == null) {
summary = '【综合】本次检测数据不足,暂无法生成完整评分,建议重新拍摄检测。';
} else {
var ageSeg = '';
if (realAge != null) {
ageSeg = '实际年龄' + realAge + '岁';
if (skinAge != null) ageSeg += '(接口测得肌肤年龄' + skinAge + '岁,差' + Math.abs(skinAge - realAge) + '岁)';
} else if (skinAge != null) {
ageSeg = '接口测得肌肤年龄' + skinAge + '岁';
}
var head = joinParts([ageSeg, group ? '产品组别' + group : ''], ',');
// 优先改善:P 最低两项
var sorted = [];
for (i = 0; i < CAT_DEFS.length; i++) {
def = CAT_DEFS[i];
if (cats[def.key].P != null) sorted.push({ name: def.name, P: cats[def.key].P });
}
sorted.sort(function (a, b) { return a.P - b.P; });
var improve = '';
if (sorted.length >= 2) improve = '优先改善' + sorted[0].name + '(P=' + fmt(sorted[0].P) + ')与' + sorted[1].name + '(P=' + fmt(sorted[1].P) + '),其余维度维持现有护理节奏。';
else if (sorted.length === 1) improve = '优先改善' + sorted[0].name + '(P=' + fmt(sorted[0].P) + '),其余维度维持现有护理节奏。';
var tail = joinParts([
profile.skinType ? '整体肤质' + profile.skinType : '',
oilDesc(pick(r, 'oily_intensity_score'))
], ',');
summary = '【综合】' + (head ? head + ';' : '')
+ '六分类S均分' + fmt(avgS) + (worst ? '、最差分类' + worst.name + ' S=' + fmt(worst.S) : '') + '。'
+ '按三重判据' + (triggers.length ? '(触发:' + triggers.join('、') + ')' : '') + '推荐' + essence.name
+ (improve ? ':' + improve : '。')
+ (tail ? tail + '。' : '');
}
return {
total: pick(r, 'total_score'),
skinAgeRaw: skinAge,
skinAgeDisplay: skinAgeDisplay,
ageClampNote: ageClampNote,
dims: dims,
cats: cats,
group: group,
essence: essence,
avgS: avgS,
worst: worst,
focus: focus,
summary: summary,
lifestyle: worst ? LIFESTYLE[worst.catKey] : '',
notes: notes
};
}
/* ─── Mock 数据(pro 形状;数值对齐测试文档「图片 13」案例) ─── */
var MOCK_RESULT = {
score_info: {
total_score: 79,
water_score: 83,
rough_score: 87,
sensitivity_score: 98,
melanin_score: 80,
blackhead_score: 88,
acne_score: 86,
oily_intensity_score: 42,
pores_type_score: { // §137:嵌套在 score_info 内
pores_forehead_score: 73, pores_left_cheek_score: 86,
pores_right_cheek_score: 85, pores_jaw_score: 90,
pores_forehead_severity: 1, pores_left_cheek_severity: 1,
pores_right_cheek_severity: 1, pores_jaw_severity: 1
}
},
skin_age: { value: 20 },
skin_type: { skin_type: 3 },
eye_pouch: { value: 0, confidence: 0.75 },
eye_pouch_severity: { value: 0 },
dark_circle: { value: 1, confidence: 0.63 },
wrinkle_count: { value: 11 },
forehead_wrinkle_info: { wrinkle_score: 20, wrinkle_severity_level: 1 },
glabella_wrinkle_info: { wrinkle_score: 15, wrinkle_severity_level: 1 },
left_eye_wrinkle_info: { wrinkle_score: 30, wrinkle_severity_level: 1 },
right_eye_wrinkle_info: { wrinkle_score: 25, wrinkle_severity_level: 1 },
left_crowsfeet_wrinkle_info: { wrinkle_score: 40, wrinkle_severity_level: 2 },
right_crowsfeet_wrinkle_info: { wrinkle_score: 35, wrinkle_severity_level: 1 },
left_nasolabial_wrinkle_info: { wrinkle_score: 60, wrinkle_severity_level: 2 },
right_nasolabial_fold_info: { wrinkle_score: 77, wrinkle_severity_level: 3 }, // _fold_info 变体
left_mouth_wrinkle_info: { wrinkle_score: 45, wrinkle_severity_level: 2 },
right_mouth_wrinkle_info: { wrinkle_score: 50, wrinkle_severity_level: 2 },
left_cheek_wrinkle_info: { wrinkle_score: 10, wrinkle_severity_level: 0 },
right_cheek_wrinkle_info: { wrinkle_score: 12, wrinkle_severity_level: 0 },
water: { water_score: 83, water_area: 15.0, water_severity: 12 },
sensitivity: { sensitivity_score: 98, sensitivity_area: 0.001, sensitivity_intensity: 30 }, // 0.001=小数口径→0.1%
melasma: { value: 0 },
freckle: { value: 0 },
skintone_ita: { ITA: 71.8 },
skin_hue_ha: { HA: 2 },
enlarged_pore_count: { forehead: 150, left_cheek: 66, right_cheek: 50, jaw: 30 }, // 合计 296
blackhead: { count: 39, rectangle: [] },
closed_comedones: { count: 3, rectangle: [{}, {}, {}] },
acne: { count: 3, rectangle: [{}, {}, {}] }, // 丘疹
acne_pustule: { count: 1, rectangle: [{}] },
acne_nodule: { count: 0, rectangle: [] },
acne_mark: { count: 1, rectangle: [{}] },
mole: { count: 2, rectangle: [{}, {}] }
};
var MOCK_SIDE = {
left: { left_jawline_info: { left_jawline_angle: 117.2, left_jawline_angle_level: 0, left_jawline_quality: 1 } },
right: { right_jawline_info: { right_jawline_angle: 113.4, right_jawline_angle_level: 1, right_jawline_quality: 1 } } // 偏离 2.6° → 94.8
};
return {
buildViewModel: buildViewModel,
MOCK_RESULT: MOCK_RESULT,
MOCK_SIDE: MOCK_SIDE
};
}));
/* ─── Node 自测:node h5/js/skin-score.js ─── */
if (typeof require === 'function' && typeof module !== 'undefined' && require.main === module) {
(function () {
var SS = module.exports;
var pass = 0, fail = 0;
function ok(cond, msg) {
if (cond) { pass++; console.log(' ✓ ' + msg); }
else { fail++; console.error(' ✗ FAIL: ' + msg); }
}
function approx(a, b) { return a != null && Math.abs(a - b) < 1e-9; }
function clone(o) { return JSON.parse(JSON.stringify(o)); }
function collectTexts(vm) { // 收集 viewModel 全部面向用户的字符串
var out = [], k;
out.push(vm.summary, vm.lifestyle, vm.ageClampNote, vm.focus.name, vm.focus.tip, vm.essence.name, vm.essence.text);
for (k in vm.cats) out.push(vm.cats[k].text);
for (k = 0; k < vm.notes.length; k++) out.push(vm.notes[k]);
return out;
}
var BAD = /None|undefined|NaN|null|—|\{[a-z_0-9]+\}/i;
function checkTexts(vm, tag) {
var ts = collectTexts(vm), bad = [];
for (var i = 0; i < ts.length; i++) if (BAD.test(ts[i])) bad.push(ts[i]);
ok(bad.length === 0, tag + ':话术无 None/undefined/NaN/null/—/未替换占位' + (bad.length ? ' → ' + bad[0] : ''));
}
console.log('== T0 MOCK 全量(数值对拍测试文档「图片 13」) ==');
var vm = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25, skinType: '混合偏干肌肤' });
ok(approx(vm.cats.smooth.P, 81.7), '光滑P = 0.4×73+0.3×88+0.3×87 = 81.7(AR列对拍)');
ok(approx(vm.cats.tone.P, 81.5), '肤色水润P = 0.5×80+0.5×83 = 81.5(AR列对拍)');
ok(vm.cats.wrinkles.P === 23 && vm.cats.wrinkles.level === '预警', '皱纹P = 100−77 = 23(severity=3 锁74 不再抬升)');
ok(approx(vm.cats.contour.P, 94.8), '轮廓P = min(眼袋100, 下颌线较差侧94.8) = 94.8');
ok(vm.cats.sens.P === 98, '敏感P = min(98, 100−0.1×1.5=99.85) = 98(面积 0.001 小数口径×100)');
ok(vm.cats.acne.P === 85, '痘痘P = min(分级映射85, 100−14.5=85.5) = 85');
ok(vm.cats.wrinkles.S === 7.69 && vm.cats.smooth.S === 9.45 && vm.cats.tone.S === 9.45, 'S=7+3×P/100 两位小数(含 9.445→9.45 浮点修正)');
ok(vm.dims.firm.P === 23 && vm.dims.vita.P === 81.5 && vm.dims.stab.P === 85, '维度木桶 min:紧塑力23 / 活力感81.5 / 稳定力85');
ok(vm.skinAgeRaw === 20 && vm.skinAgeDisplay === 22 && vm.ageClampNote === '触发(原20→22)', '肌肤年龄钳制 20→22 留痕,原值不改写');
ok(vm.group === '25+组', 'realAge=25 → 25+组');
ok(vm.essence.name === '35+支撑精华' && vm.essence.triggers.length === 1 && vm.essence.triggers[0] === '最差项约束',
'精华三重判据:均分9.32→25+、最差S7.69→至少35+、年龄→25+,取最强档35+,triggers=["最差项约束"]');
ok(vm.avgS === 9.32, '六分类S均分 9.32');
ok(vm.worst.catKey === 'wrinkles' && vm.worst.P === 23, '最差分类 = 皱纹');
ok(vm.focus.name === '紧塑力', '近期优先关注 = 最差维度紧塑力');
ok(vm.total === 79, '综合评分 = score_info.total_score');
ok(vm.summary.indexOf('【综合】') === 0 && vm.summary.indexOf('35+支撑精华') > 0, '综合文案生成');
ok(vm.cats.smooth.text.indexOf('粗大毛孔296颗、黑头39颗') >= 0 && vm.cats.smooth.text.indexOf('纹理分87') >= 0,
'光滑话术含粗大毛孔/黑头/纹理分变量(分区计数 4 区求和 296)');
checkTexts(vm, 'T0');
console.log('== T1 上限锁 ==');
var r1 = clone(SS.MOCK_RESULT);
var rk;
for (rk in r1) if (/_wrinkle_info$|_fold_info$/.test(rk)) r1[rk].wrinkle_score = 5; // 全部压低 → 自然 P=95
r1.right_nasolabial_fold_info.wrinkle_severity_level = 3;
ok(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }).cats.wrinkles.P === 74, '皱纹分区 severity=3 → 锁 74');
r1.right_nasolabial_fold_info.wrinkle_severity_level = 2;
ok(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }).cats.wrinkles.P === 79, '皱纹分区 severity=2 → 锁 79');
var r1b = clone(SS.MOCK_RESULT);
r1b.score_info.pores_type_score.pores_forehead_severity = 2; // 自然 81.7
ok(SS.buildViewModel(r1b, SS.MOCK_SIDE, { realAge: 25 }).cats.smooth.P === 79, '毛孔分区 severity=2 → 光滑锁 79');
checkTexts(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }), 'T1');
console.log('== T2 木桶 min(显式构造) ==');
var vm2 = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 40 });
ok(vm2.dims.firm.P === Math.min(vm2.cats.contour.P, vm2.cats.wrinkles.P)
&& vm2.dims.vita.P === Math.min(vm2.cats.smooth.P, vm2.cats.tone.P)
&& vm2.dims.stab.P === Math.min(vm2.cats.sens.P, vm2.cats.acne.P), '三维度 P = min(旗下两分类)');
ok(vm2.group === '35+组', 'realAge=40 → 35+组');
console.log('== T3 钳制边界 ==');
var r3 = clone(SS.MOCK_RESULT);
r3.skin_age = { value: 24 };
var vm3 = SS.buildViewModel(r3, SS.MOCK_SIDE, { realAge: 25 });
ok(vm3.skinAgeDisplay === 24 && vm3.ageClampNote === '', '±3 以内不钳制、不留痕');
r3.skin_age = { value: 40 };
var vm3b = SS.buildViewModel(r3, SS.MOCK_SIDE, { realAge: 25 });
ok(vm3b.skinAgeDisplay === 28 && vm3b.ageClampNote === '触发(原40→28)' && vm3b.skinAgeRaw === 40, '上边界钳制 40→28');
console.log('== T4 精华三重判据:最差项 S<7 → 50+重塑 ==');
var r4 = clone(SS.MOCK_RESULT);
r4.score_info.melanin_score = 0;
r4.score_info.water_score = 0;
r4.melasma = { value: 1 };
r4.freckle = { value: 1 };
r4.water = { water_score: 0, water_area: 40, water_severity: 70 }; // sev≥60 但 score<80,不触发换算
var vm4 = SS.buildViewModel(r4, SS.MOCK_SIDE, { realAge: 25 });
ok(approx(vm4.cats.tone.P, -7.5), '肤色水润P = 0.5×(0−10−5)+0.5×0 = −7.5(P 原值不动,不夹逼)');
ok(vm4.cats.tone.S === 7, '展示层 S 钳到 7(未钳原值 6.78 仍参与判档)');
ok(vm4.essence.name === '50+重塑精华' && vm4.essence.triggers.indexOf('最差项约束') >= 0, '最差项 S 原值<7 → 50+重塑精华,triggers 含最差项约束(三重判据用未舍入原值)');
checkTexts(vm4, 'T4');
console.log('== T5 缺数据降级(V2.0 第七章) ==');
var r5 = clone(SS.MOCK_RESULT);
delete r5.sensitivity;
delete r5.score_info.sensitivity_score;
var vm5 = SS.buildViewModel(r5, SS.MOCK_SIDE, { realAge: 25 });
ok(vm5.cats.sens.P === null && vm5.cats.sens.text.indexOf('缺失') >= 0, 'sensitivity 缺失 → 敏感 P=null + 数据缺失提示(不编假分数)');
ok(vm5.dims.stab.P === 85, '稳定力剔除敏感项 = 痘痘P 85');
var hasSensNote = false, k;
for (k = 0; k < vm5.notes.length; k++) if (vm5.notes[k].indexOf('sensitivity') >= 0) hasSensNote = true;
ok(hasSensNote, 'notes 记录 sensitivity 缺失降级');
ok(vm5.avgS === 9.2, '均分剔除敏感项按 5 分类计算(未舍入 9.196 → 展示 9.2)');
var vm5b = SS.buildViewModel(clone(SS.MOCK_RESULT), null, { realAge: 25 });
ok(vm5b.cats.contour.P === 100 && vm5b.notes.join('|').indexOf('侧脸') >= 0, '无侧脸 → 轮廓 P 由眼袋单独决定并记 note');
var vm5c = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, {});
ok(vm5c.group === null && vm5c.skinAgeDisplay === 20 && vm5c.ageClampNote === ''
&& vm5c.essence.triggers.indexOf('年龄组档') < 0 && vm5c.notes.join('|').indexOf('实际年龄') >= 0,
'realAge 缺失 → group=null、不钳制、判据③不生效并记 note');
checkTexts(vm5, 'T5');
console.log('== T6 下颌线 quality 降级 ==');
var vm6 = SS.buildViewModel(clone(SS.MOCK_RESULT), {
left: { left_jawline_info: { left_jawline_angle: 100, left_jawline_angle_level: 1, left_jawline_quality: 3 } },
right: { right_jawline_info: { right_jawline_angle: 118, right_jawline_angle_level: 0, right_jawline_quality: 1 } }
}, { realAge: 25 });
ok(vm6.cats.contour.P === 100, '左 quality=3 不参与,右 118° 标准 → 轮廓 P=min(100,100)=100');
ok(vm6.notes.join('|').indexOf('左侧脸角度无效(quality=3)') >= 0, 'notes 含「左侧脸角度无效(quality=3)」');
console.log('== T7 AR列数值对拍(第二条) ==');
var vm7 = SS.buildViewModel({
score_info: { sensitivity_score: 30 },
sensitivity: { sensitivity_score: 30, sensitivity_area: 0.103 } // 图片37:10.3%
}, null, { realAge: 29 });
ok(vm7.cats.sens.P === 30, '敏感P = min(30, 100−10.3×1.5=84.55) = 30(AR列对拍)');
console.log('== T8 count 缺失退化 rectangle 长度 ==');
var r8 = clone(SS.MOCK_RESULT);
r8.acne_pustule = { rectangle: [{}, {}] }; // 无 count → 2 个脓包
var vm8 = SS.buildViewModel(r8, SS.MOCK_SIDE, { realAge: 25 });
// 扣分 = 3×1+3×2+2×4+0+1×1.5 = 18.5 → 100−18.5=81.5 < 分级映射85
ok(approx(vm8.cats.acne.P, 81.5), '痘痘P = min(85, 100−18.5) = 81.5(rectangle 退化计数)');
ok(vm8.cats.acne.text.indexOf('脓包2处') >= 0, 'acne_detail 按计数列明细,为0的类型不列');
var vm8b = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 }); // 含脓包1处
ok(vm8b.cats.acne.text.indexOf('含脓包、结节等中重度皮损') < 0, '改善档无 heavy_note(heavy_note 仅预警档且真实检出时输出)');
var r8c = clone(SS.MOCK_RESULT);
r8c.score_info.acne_score = 70; // 分级映射 → 55,预警档
ok(SS.buildViewModel(r8c, SS.MOCK_SIDE, { realAge: 25 }).cats.acne.text.indexOf('(闭口粉刺3处、丘疹3处、脓包1处、痘印1处),含脓包、结节等中重度皮损,炎症反应明显。') >= 0,
'预警档真实检出脓包 → heavy_note 逐字输出');
console.log('== T9 真实接口字段形状(对齐 reference/facepp-skinanalyze/examples 样本) ==');
// (5)(9) 毛孔分区无下划线变体键 + 无 severity 键
var r9 = clone(SS.MOCK_RESULT);
r9.score_info.pores_type_score = { pores_forehead_score: 60, pores_leftcheek_score: 50, pores_rightcheek_score: 72, pores_jaw_score: 84 };
var vm9 = SS.buildViewModel(r9, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9.cats.smooth.text.indexOf('左侧面颊') >= 0 && vm9.cats.smooth.text.indexOf('leftcheek') < 0,
'毛孔分区无下划线变体 pores_leftcheek_score → 中文「左侧面颊」,话术无英文键名残留');
ok(vm9.notes.join('|').indexOf('毛孔分区严重度未返回,光滑上限锁未执行') >= 0, 'pores_type_score 无 severity 键 → notes 留痕「光滑上限锁未执行」');
checkTexts(vm9, 'T9-毛孔');
// (6) 敏感面积优先 red_spot.red_spot_area(V2.0 第二章字段来源)
var r9b = clone(SS.MOCK_RESULT);
r9b.red_spot = { red_spot_area: 0.006, red_spot_intensity: 3 }; // 小数口径 → 0.6%
r9b.sensitivity.sensitivity_area = 0.5; // 若干扰字段被误读会变 50% → 锁 74
var vm9b = SS.buildViewModel(r9b, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9b.cats.sens.P === 98 && vm9b.cats.sens.text.indexOf('泛红面积0.6%') >= 0,
'敏感面积优先 red_spot.red_spot_area:0.006×100=0.6%,P=min(98, 100−0.9)=98(不回退 sensitivity_area=0.5)');
ok(SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 }).cats.sens.P === 98,
'red_spot 缺失 → 回退 sensitivity.sensitivity_area(0.001 小数口径→0.1%,P=98)');
// (7) wrinkle_count/fine_line 为分区计数对象(真实样本形状,无 .value)
var r9d = clone(SS.MOCK_RESULT);
r9d.wrinkle_count = { forehead_count: 22, left_undereye_count: 58, right_undereye_count: 44, glabella_count: 14, left_crowsfeet_count: 10, right_crowsfeet_count: 7, left_mouth_count: 1, right_mouth_count: 1, left_nasolabial_count: 1, right_nasolabial_count: 1, left_cheek_count: 0, right_cheek_count: 0 };
r9d.fine_line = { forehead_count: 18, left_undereye_count: 57, right_undereye_count: 46, glabella_count: 9, left_crowsfeet_count: 11, right_crowsfeet_count: 9, left_cheek_count: 0, right_cheek_count: 0 };
var vm9d = SS.buildViewModel(r9d, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9d.cats.wrinkles.text.indexOf('全脸皱纹159条') >= 0 && vm9d.cats.wrinkles.text.indexOf('细纹150条') >= 0,
'wrinkle_count/fine_line 分区对象数值叶子求和 → 全脸皱纹159条、细纹150条');
checkTexts(vm9d, 'T9-皱纹计数');
// (8) 肤色分类真实键名 skintone_ita.skintone(值为 0 时不得被 falsy 吞掉)
var r9e = clone(SS.MOCK_RESULT);
r9e.skintone_ita = { ITA: 58.64615, skintone: 0 };
r9e.skin_hue_ha = { HA: 56.246513, skin_hue: 0 };
var vm9e = SS.buildViewModel(r9e, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9e.cats.tone.text.indexOf('肤色ITA 58.65(0,HA 56.25)') >= 0, 'skintone_ita.skintone=0 → 肤色分类 0 正常输出');
// (10)(12) 眼袋 severity 非整数取整 / 越界按缺失;轮廓改善档话术无「黑眼圈」(V2.0 §5.1 逐字)
var r9f = clone(SS.MOCK_RESULT);
r9f.eye_pouch_severity = { value: 1.6 };
var vm9f = SS.buildViewModel(r9f, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9f.cats.contour.P === 79 && vm9f.cats.contour.text.indexOf('眼袋2级') >= 0,
'眼袋 severity 非整数 1.6 → 取整 2 → 映射 79(min(79, 下颌线94.8),预警档)');
var vm9f2 = SS.buildViewModel(clone(SS.MOCK_RESULT), { // 下颌线 117°/110° → jawP=88 → P=88 改善档
left: { left_jawline_info: { left_jawline_angle: 117, left_jawline_quality: 1 } },
right: { right_jawline_info: { right_jawline_angle: 110, right_jawline_quality: 1 } }
}, { realAge: 25 });
ok(vm9f2.cats.contour.level === '改善' && vm9f2.cats.contour.text.indexOf('眼袋0级') >= 0 && vm9f2.cats.contour.text.indexOf('黑眼圈') < 0,
'轮廓改善档含「眼袋0级」且无「黑眼圈」(§5.1 改善档原文无黑眼圈)');
var r9g = clone(SS.MOCK_RESULT);
r9g.eye_pouch_severity = { value: 9 };
var vm9g = SS.buildViewModel(r9g, SS.MOCK_SIDE, { realAge: 25 });
ok(approx(vm9g.cats.contour.P, 94.8) && vm9g.notes.join('|').indexOf('眼袋 severity 越界(9)') >= 0,
'眼袋 severity 越界 9 → 按缺失处理记 note,轮廓 P 仅由下颌线 94.8 决定');
checkTexts(vm9f, 'T9-眼袋');
// (13) 痘痘良好档计数全缺失 → 缺数据提示句,不声称「未检出」
var r9h = clone(SS.MOCK_RESULT);
delete r9h.closed_comedones; delete r9h.acne; delete r9h.acne_pustule; delete r9h.acne_nodule; delete r9h.acne_mark;
r9h.score_info.acne_score = 96; // 分级映射 100 → 良好档
var vm9h = SS.buildViewModel(r9h, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9h.cats.acne.text.indexOf('未检出') < 0 && vm9h.cats.acne.text.indexOf('未获取皮损计数数据') >= 0,
'痘痘良好档且计数全缺失 → 缺数据提示句(不声称未检出)');
// 预警档无脓包/结节(heavy 为空)时「检出N处皮损(明细)」不得被 fill 分句省略拖垮(真实样本形状:丘疹3+闭口6)
var r9i = clone(SS.MOCK_RESULT);
r9i.acne_pustule = { count: 0, rectangle: [] };
r9i.acne_mark = { count: 0, rectangle: [] };
r9i.score_info.acne_score = 70; // 分级映射 55 → 预警档
var vm9i = SS.buildViewModel(r9i, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9i.cats.acne.text.indexOf('检出6处皮损(闭口粉刺3处、丘疹3处)。建议尽快') >= 0,
'痘痘预警档无中重度皮损 → 计数明细正常输出、无 heavy_note');
console.log('----------------------------------------');
console.log('自测结果:' + pass + ' 通过,' + fail + ' 失败');
if (fail > 0) process.exit(1);
}());
}