1. 城市改自定义下拉(原生 select 各端样式不一) 2. 姿态提示浮层下移,不再压住脸部轮廓 3. 倒计时 321 前不再抢播「保持不动」(被切断听着像吞字) 4. 相机预热:分辨率稳定两拍后才出实时画面+放开检测,开头 1-2s 大脸闪变消除 5. 隐私协议页去掉头部文字条,返回键改悬浮 6. 肌龄小字常显「与实际年龄相差X岁」,钳制时追加「触发(原X→Y)」(vm 增 realAge 字段) 7. 删除设计稿没有的「6个维度观察/状态动态流动」两段文字 8. 维度卡关注行改设计稿两行式:灰小字+深色口号(稳定力口号用设计稿原文,另两条待确认) 9. Tab 黄色指示条宽度跟随文字(原固定 78px) 10. 抓拍只取取景框可见区域(object-fit cover 中心裁切),照片=所见画幅;拍照页顶栏黑条删除;无进展提示上移不再压快门 11. 相册选择器「拍照」项为系统行为,代码层已是图库意图(无 capture),详见回复 12. 切换镜头实现:前后置切换+镜像/姿态参数跟随,替换 Toast 占位 13. 报告分类卡去掉得分与档位徽章(设计稿只有话术),图片改 100px 见方圆角 10 e2e 全量绿(E0-E7)
1097 lines
67 KiB
JavaScript
1097 lines
67 KiB
JavaScript
/* ═════ 巨子测肤 H5 — 三维度×6分类评分引擎(纯逻辑,浏览器/Node 双端) ═════
|
||
* 最高契约:docs/三维度评分与话术生成需求文档_V2.2.docx(公式/锁值/话术/产品推荐以其为准)
|
||
* 浏览器:window.SkinScore;Node:module.exports。ES5、零依赖、不碰 DOM。
|
||
*
|
||
* 字段口径(已对 reference/facepp-skinanalyze/examples/ 真实 pro JSON 复核):
|
||
* - 分数类字段优先取 result.score_info.<key>,缺失退化 result.<key>(V2.2 §3.1 映射归一)
|
||
* - pores_type_score 分区键存在无下划线变体(pores_leftcheek_score 等),且无 severity 键
|
||
* - wrinkle_count / fine_line 为分区计数对象(forehead_count 等叶子),非 {value:n}
|
||
* - 敏感面积优先 red_spot.red_spot_area(V2.2 §3.1:小数×100=%),缺失回退 sensitivity.sensitivity_area
|
||
* - eye_pouch_severity.value 为 0–3 枚举(缺失退化 eye_pouch.value,非整数取整、越界按缺失)
|
||
* - 黑眼圈级取 dark_circle_severity.value,缺失退化 dark_circle.value(真值表口径:案例1=1 级,非 dark_circle 的 3)
|
||
* - sensitivity_area / red_spot_area / water_area / oily 分区 area ≤1 视为小数口径×100,否则按百分比直读
|
||
* - 水分交叉校验「按 severity 换算」实现为 水分项 = 100 − water_severity(文档未给公式,待客户确认)
|
||
* - {oil_desc} 按 V2.2 §7.2 由 oily_intensity 分区面积生成:T区出油面积X%、左右脸颊Y%/Z%,全脸 intensity=0 省略
|
||
* - {st} 肤色分类、{skin_type_cn} 肤质类型按接口枚举映射中文(skintone 0–4 / skin_type 0–3,真值已校)
|
||
* - {spot_flag} 按 V2.2 §7.2:色斑基句 +「,检出褐色斑N处」「、痣N处」续接,为 0 整体省略
|
||
*/
|
||
(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 优先,再顶层(§3.1 字段归一)
|
||
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);
|
||
}
|
||
function round1(x) { return Math.round((x + 1e-9) * 10) / 10; }
|
||
function fmtS(x) { return x == null ? '' : round2(x).toFixed(2); } // 综合文案 S:固定两位小数(真值「S=8.50」)
|
||
function fmtP(x) { return x == null ? '' : round1(x).toFixed(1); } // 综合文案 P:固定一位小数(真值「P=23.0」)
|
||
|
||
/* 模板填充:{var} 替换;变量缺失(null/undefined/'')时省略包含它的最小分句(,;。界),
|
||
* 严禁输出 None/undefined/— 占位(V2.2 第七章生成规则与 7.3 缺值规则)。 */
|
||
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 并存(V2.2 §3.3④ 变体归一)
|
||
{ 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: '下巴'
|
||
};
|
||
var SKINTONE_CN = { 0: '极白', 1: '白皙', 2: '自然', 3: '小麦色', 4: '黝黑' }; // skintone_ita.skintone 枚举(真值:0→极白、3→小麦色)
|
||
var SKIN_TYPE_CN = { 0: '油性', 1: '干性', 2: '中性', 3: '混合性' }; // skin_type.skin_type 枚举(真值:3→混合性)
|
||
function skinTypeCnOf(r, profile) { // {skin_type_cn}:接口 skin_type 优先,缺失回退用户自选肤质(V2.2 §8.5 肤质不参与分数)
|
||
var st = r ? r.skin_type : null;
|
||
var v = st != null && typeof st === 'object' ? val(st.skin_type != null ? st.skin_type : st.value) : val(st);
|
||
if (v != null && SKIN_TYPE_CN[v] != null) return SKIN_TYPE_CN[v];
|
||
return (profile && profile.skinType) || null;
|
||
}
|
||
|
||
/* ─── 分类计算(每项返回 { 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: {} }; }
|
||
var dcSev = pick(r, 'dark_circle_severity'); // 黑眼圈级:dark_circle_severity 优先(真值口径),缺失退化 dark_circle
|
||
if (dcSev == null) dcSev = pick(r, 'dark_circle');
|
||
return {
|
||
P: Math.min.apply(null, cand),
|
||
vars: {
|
||
la: la, ra: ra,
|
||
eye_pouch: pouchLvl, // 取整后的等级;越界/缺失时为 null(话术省略该分句)
|
||
dark_circle_sev: dcSev
|
||
}
|
||
};
|
||
}
|
||
|
||
// 皱纹: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: r.oily_intensity // {oil_desc} 数据源(V2.2 §7.2 分区面积)
|
||
}
|
||
};
|
||
}
|
||
|
||
// 肤色水润: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 stRaw = itaObj.skin_tone != null ? itaObj.skin_tone
|
||
: (itaObj.skintone != null ? itaObj.skintone // 真实键名为 skintone(另有 ITA 值),兼容 skin_tone/tone
|
||
: (itaObj.tone != null ? itaObj.tone : null));
|
||
// 肤色分类枚举→中文(真值:0→极白、3→小麦色);未收录枚举值原样透出
|
||
var st = stRaw != null && SKINTONE_CN[stRaw] != null ? SKINTONE_CN[stRaw] : stRaw;
|
||
// {spot_flag}(V2.2 §7.2):检出黄褐斑和/或雀斑写该种,均未检出写「未见明显色斑标记」;褐色斑/痣数>0 续接,为 0 整体省略
|
||
var spotFlag = (melasma || freckle) ? joinParts([melasma ? '黄褐斑' : '', freckle ? '雀斑' : ''], '、') : '未见明显色斑标记';
|
||
var bsCnt = cnt(r.brown_spot), moleCnt = cnt(r.mole);
|
||
if (bsCnt != null && bsCnt > 0) spotFlag += ',检出褐色斑' + fmt(bsCnt) + '处';
|
||
if (moleCnt != null && moleCnt > 0) spotFlag += '、痣' + fmt(moleCnt) + '处';
|
||
var itaV = val(itaObj.ITA != null ? itaObj.ITA : itaObj.value);
|
||
var haV = val(haObj.HA != null ? haObj.HA : haObj.value);
|
||
return {
|
||
P: P,
|
||
vars: {
|
||
ita: itaV == null ? null : round1(itaV), // 真值表为 1 位小数(58.64615→58.6、56.246513→56.2)
|
||
st: st,
|
||
ha: haV == null ? null : round1(haV),
|
||
melanin: melanin,
|
||
spot_flag: spotFlag,
|
||
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.2 §3.1):优先 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
|
||
}
|
||
};
|
||
}
|
||
|
||
/* ─── 话术库(V2.2 第七章逐字;变量缺失省略对应分句) ─── */
|
||
function oilPct(node) { // oily_intensity 分区面积:≤1 小数×100,否则直读
|
||
if (!node) return null;
|
||
var a = val(node.area);
|
||
return a == null ? null : (a <= 1 ? a * 100 : a);
|
||
}
|
||
function oilDesc(oily) { // {oil_desc}(V2.2 §7.2):T区出油面积X%、左右脸颊Y%/Z%;全脸 intensity=0 或数据缺失时整体省略
|
||
if (!oily || typeof oily !== 'object') return '';
|
||
var ff = oily.full_face;
|
||
if (ff && val(ff.intensity) === 0) return '';
|
||
var t = oilPct(oily.t_zone), l = oilPct(oily.left_cheek), rr = oilPct(oily.right_cheek);
|
||
if (t == null && l == null && rr == null) return '';
|
||
var parts = [];
|
||
if (t != null) parts.push('T区出油面积' + fmt(t) + '%');
|
||
if (l != null && rr != null) parts.push('左右脸颊' + fmt(l) + '%/' + fmt(rr) + '%');
|
||
else if (l != null) parts.push('左脸颊' + fmt(l) + '%');
|
||
else if (rr != null) parts.push('右脸颊' + fmt(rr) + '%');
|
||
return parts.join('、');
|
||
}
|
||
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);
|
||
// V2.2 第七章预警档原文自「下颌线角度…」起,无额外前缀句
|
||
if (lv === '预警') return fill('{jaw};{eye};中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。',
|
||
{ jaw: jaw, eye: eye });
|
||
// V2.2 第七章改善档原文只含眼袋,不含黑眼圈
|
||
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);
|
||
// 「检出N处皮损(明细)」拼接成句(V2.2 第七章预警档逐字:检出{acne_cnt}处皮损({acne_detail})。建议…)
|
||
var found = (v.acne_cnt != null && v.acne_detail) ? '检出' + fmt(v.acne_cnt) + '处皮损(' + v.acne_detail + ')' : '';
|
||
if (lv === '预警') return (found ? found + '。' : '')
|
||
+ '建议尽快进行针对性抗痘护理,避免挤压与刺激,必要时寻求皮肤科专业处理。';
|
||
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.2 §8.4 全文
|
||
contour: '戒除揉眼、托腮等表情性挤压习惯,保证睡眠减少纹路加深',
|
||
wrinkles: '保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳',
|
||
smooth: '规律去角质但不过度(每1–2周一次),运动后及时清洁,减少油炸与高糖饮食',
|
||
tone: '每日防晒足量补涂,增加维C与抗氧化食物摄入,保证饮水节奏',
|
||
sens: '避免过热的水洗脸与桑拿,选择无香精配方,换季时精简护肤步骤',
|
||
acne: '不挤压痘痘,枕巾高频更换,减少乳制品与高升糖饮食摄入'
|
||
};
|
||
var ESSENCE = { // V2.2 §8.3 三档全文(前缀「建议搭配使用X:」为报告页拼接胶)
|
||
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+组');
|
||
var skinTypeCn = skinTypeCnOf(r, profile); // {skin_type_cn}:接口枚举优先,回退用户自选肤质
|
||
|
||
// 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 }; }
|
||
}
|
||
|
||
// 综合文案(V2.2 §8.2 模板)
|
||
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=' + fmtP(sorted[0].P) + ')与' + sorted[1].name + '(P=' + fmtP(sorted[1].P) + '),其余维度维持现有护理节奏。';
|
||
else if (sorted.length === 1) improve = '优先改善' + sorted[0].name + '(P=' + fmtP(sorted[0].P) + '),其余维度维持现有护理节奏。';
|
||
var tail = joinParts([
|
||
skinTypeCn ? '整体肤质' + skinTypeCn : '',
|
||
oilDesc(r.oily_intensity)
|
||
], ',');
|
||
summary = '【综合】' + (head ? head + ';' : '')
|
||
+ '六分类S均分' + fmtS(avgRaw) + (worst ? '、最差分类' + worst.name + ' S=' + fmtS(sRaw(worst.P)) : '') + '。'
|
||
+ '按三重判据' + (triggers.length ? '(触发:' + triggers.join('、') + ')' : '') + '推荐' + essence.name
|
||
+ (improve ? ':' + improve : '。')
|
||
+ (tail ? tail + '。' : '');
|
||
}
|
||
|
||
return {
|
||
total: pick(r, 'total_score'),
|
||
skinAgeRaw: skinAge,
|
||
skinAgeDisplay: skinAgeDisplay,
|
||
ageClampNote: ageClampNote,
|
||
realAge: realAge,
|
||
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: { // §3.1:嵌套在 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 },
|
||
oily_intensity: { // 真实形状:分区 {area(小数),intensity},{oil_desc} 数据源(数值对齐案例1)
|
||
t_zone: { area: 0.57, intensity: 2 }, left_cheek: { area: 0.2, intensity: 1 },
|
||
right_cheek: { area: 0.02, intensity: 0 }, chin_area: { area: 0, intensity: 0 },
|
||
full_face: { area: 0.4, intensity: 2 }
|
||
},
|
||
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.2 第十章异常与边界) ==');
|
||
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('(闭口粉刺3处、丘疹3处、脓包1处、痘印1处),以闭口粉刺、丘疹为主') >= 0
|
||
&& vm8b.cats.acne.text.indexOf('含脓包、结节') < 0, '改善档逐字(V2.2 第七章无 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('检出8处皮损(闭口粉刺3处、丘疹3处、脓包1处、痘印1处)。建议尽快进行针对性抗痘护理') >= 0,
|
||
'预警档逐字:检出{acne_cnt}处皮损({acne_detail})。建议尽快…(V2.2 第七章)');
|
||
|
||
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.2 §3.1 字段映射)
|
||
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→极白),ITA/HA 真值表口径 1 位小数
|
||
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.6(极白,HA 56.2)') >= 0, 'skintone_ita.skintone=0 → 中文「极白」,ITA/HA 各 1 位小数(58.64615→58.6、56.246513→56.2)');
|
||
|
||
// (14) {spot_flag} 褐色斑/痣续接(V2.2 §7.2):基句 + 「,检出褐色斑N处」「、痣N处」,为 0 省略
|
||
var vm9e2 = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 }); // MOCK:色斑均未检出、mole=2
|
||
ok(vm9e2.cats.tone.text.indexOf('(未见明显色斑标记、痣2处)') >= 0, '{spot_flag} 均未检出 + 痣2处 → 「未见明显色斑标记、痣2处」');
|
||
var r9e3 = clone(SS.MOCK_RESULT);
|
||
r9e3.melasma = { value: 1 };
|
||
r9e3.brown_spot = { count: 23 };
|
||
r9e3.mole = { count: 1 };
|
||
ok(SS.buildViewModel(r9e3, SS.MOCK_SIDE, { realAge: 25 }).cats.tone.text.indexOf('(黄褐斑,检出褐色斑23处、痣1处)') >= 0,
|
||
'{spot_flag} 黄褐斑 + 褐色斑23处 + 痣1处 → 案例2 真值句式');
|
||
|
||
// (10)(12) 眼袋 severity 非整数取整 / 越界按缺失;轮廓改善档话术无「黑眼圈」(V2.2 第七章逐字)
|
||
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级」且无「黑眼圈」(V2.2 第七章改善档原文无黑眼圈)');
|
||
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,
|
||
'痘痘良好档且计数全缺失 → 缺数据提示句(不声称未检出)');
|
||
// 预警档「检出N处皮损(明细)」为固定首句,明细为 0 的类型不列(真实样本形状:丘疹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,
|
||
'痘痘预警档 → 计数明细正常输出(为0类型不列)');
|
||
|
||
console.log('== T11 水润 severity 换算触发(V2.2 §3 交叉校验) ==');
|
||
var r11 = clone(SS.MOCK_RESULT);
|
||
r11.score_info.water_score = 85;
|
||
r11.water = { water_area: 10, water_severity: 70 }; // sev≥60 且 score≥80 → 水润项 = 100−70 = 30
|
||
var vm11 = SS.buildViewModel(r11, SS.MOCK_SIDE, { realAge: 25 });
|
||
ok(approx(vm11.cats.tone.P, 55), '肤色水润P = 0.5×80+0.5×30 = 55(water_severity=70 且 water_score=85 → 水润项按 severity 换算)');
|
||
ok(vm11.notes.join('|').indexOf('水润项按 severity 换算为 30') >= 0, 'notes 记录水分交叉校验触发');
|
||
ok(vm4.cats.tone.P === -7.5, '对照:T4 sev=70 但 score=0<80 → 不触发换算(P=−7.5 不变)');
|
||
|
||
console.log('== T12 {oil_desc} 与 {skin_type_cn}(V2.2 §7.2/§8.2) ==');
|
||
var vm12 = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 });
|
||
ok(vm12.cats.smooth.text.indexOf('T区出油面积57%、左右脸颊20%/2%,纹理分87') >= 0,
|
||
'{oil_desc} = T区出油面积57%、左右脸颊20%/2%(oily_intensity 0.57/0.2/0.02 小数×100)');
|
||
ok(vm12.summary.indexOf('整体肤质混合性,T区出油面积57%、左右脸颊20%/2%。') >= 0,
|
||
'综合文案尾句 = 案例1 真值句式(skin_type=3→混合性 + oil_desc 无尾逗号)');
|
||
var r12b = clone(SS.MOCK_RESULT);
|
||
r12b.oily_intensity.full_face.intensity = 0;
|
||
var vm12b = SS.buildViewModel(r12b, SS.MOCK_SIDE, { realAge: 25 });
|
||
ok(vm12b.cats.smooth.text.indexOf('出油') < 0 && vm12b.summary.indexOf('出油') < 0, '全脸 intensity=0 → {oil_desc} 整体省略');
|
||
var r12c = clone(SS.MOCK_RESULT);
|
||
delete r12c.skin_type;
|
||
ok(SS.buildViewModel(r12c, SS.MOCK_SIDE, { realAge: 25, skinType: '油性肌肤' }).summary.indexOf('整体肤质油性肌肤') >= 0,
|
||
'skin_type 缺失 → {skin_type_cn} 回退用户自选肤质');
|
||
checkTexts(vm12, 'T12');
|
||
|
||
console.log('== T10 真值对拍:案例1 完整原始返回 vs 真值表(xlsx 第3行/V2.2 实测) ==');
|
||
(function () {
|
||
var raw = null, fs;
|
||
try {
|
||
fs = require('fs');
|
||
var paths = [__dirname + '/../../reference/facepp-skinanalyze/examples/ref_3view_raw.json',
|
||
'reference/facepp-skinanalyze/examples/ref_3view_raw.json'];
|
||
for (var i = 0; i < paths.length; i++) { try { raw = fs.readFileSync(paths[i], 'utf8'); break; } catch (e) {} }
|
||
} catch (e) {}
|
||
if (!raw) { console.log(' - 跳过(未找到 reference ref_3view_raw.json)'); return; }
|
||
var d = JSON.parse(raw);
|
||
var vm = SS.buildViewModel(d.result, { left: d.left_side_result, right: d.right_side_result }, { realAge: 30 });
|
||
ok(approx(vm.cats.contour.P, 75.5) && vm.cats.contour.S === 9.27 && vm.cats.contour.level === '预警',
|
||
'轮廓 P=75.5 / S=9.27 / 预警(min(眼袋1级→90, 下颌线较差侧103.75°→75.5))');
|
||
ok(vm.cats.wrinkles.P === 23 && vm.dims.firm.P === 23 && vm.dims.firm.S === 7.69, '皱纹 P=23,紧塑力木桶 23 / S=7.69');
|
||
ok(approx(vm.cats.smooth.P, 57.2) && vm.dims.vita.P === 57.2 && vm.dims.vita.S === 8.72, '光滑 P=57.2,活力感 57.2 / S=8.72');
|
||
ok(vm.cats.tone.P === 85, '肤色水润 P = 0.5×92+0.5×78 = 85');
|
||
ok(vm.cats.sens.P === 99 && vm.cats.acne.P === 70 && vm.dims.stab.P === 70 && vm.dims.stab.S === 9.1,
|
||
'敏感 99 / 痘痘 70(min(分级70, 100−12)),稳定力 70 / S=9.1');
|
||
ok(vm.avgS === 9.05 && vm.worst.catKey === 'wrinkles' && vm.group === '25+组'
|
||
&& vm.essence.name === '35+支撑精华' && vm.essence.triggers.join('、') === '最差项约束',
|
||
'均分 9.05 / 最差皱纹 / 25+组 / 精华 35+支撑(触发:最差项约束,三重判据取最强档)');
|
||
ok(vm.skinAgeRaw === 27 && vm.skinAgeDisplay === 27 && vm.ageClampNote === '', '肤龄 27(实际30)未触发钳制、原值不改写');
|
||
ok(vm.cats.contour.text === '下颌线角度左104.54°/右103.75°(标准区间116–120°,双侧均偏离标准,提示下颌缘支撑力下降);眼袋1级、黑眼圈1级;中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。',
|
||
'轮廓文案逐字 = 真值表(黑眼圈取 dark_circle_severity=1,非 dark_circle=3)');
|
||
ok(vm.cats.wrinkles.text === '全脸皱纹159条、细纹150条,最差区域为左嘴角纹(分区分77,最高严重度3级),部分区域已现静态纹趋势。建议针对眼周、法令纹等高发区使用视黄醇/胜肽类淡纹精华,遏制静态纹转化。',
|
||
'皱纹文案逐字 = 真值表');
|
||
ok(vm.cats.smooth.text === '皮肤表面平整度较低,最差毛孔区位于额头(分区毛孔分32),粗大毛孔1041颗、黑头113颗,T区出油面积57%、左右脸颊20%/2%,纹理分仅82,整体细腻度不足。建议避免过度清洁,重点关注额头,配合低浓度酸类周期护理与柔润精华。',
|
||
'光滑文案逐字 = 真值表');
|
||
ok(vm.cats.tone.text === '肤色ITA 58.6(极白,HA 56.2),色素分92,整体稳定但存在局部暗沉倾向(未见明显色斑标记,检出褐色斑37处);水分分78,缺水面积19.8%、严重度22。建议加强抗氧化与光泽管理,做好基础防晒与补水。',
|
||
'肤色水润文案逐字 = 真值表');
|
||
ok(vm.cats.sens.text === '泛红面积0.6%,红区分99,屏障状态稳定,未见明显敏感信号。建议维持现有温和护理,避免过度去角质。',
|
||
'敏感文案逐字 = 真值表');
|
||
ok(vm.cats.acne.text === '检出9处皮损(闭口粉刺6处、丘疹3处)。建议尽快进行针对性抗痘护理,避免挤压与刺激,必要时寻求皮肤科专业处理。',
|
||
'痘痘文案逐字 = 真值表(无 heavy_note)');
|
||
ok(vm.summary === '【综合】实际年龄30岁(接口测得肌肤年龄27岁,差3岁),产品组别25+组;六分类S均分9.05、最差分类皱纹 S=7.69。按三重判据(触发:最差项约束)推荐35+支撑精华:优先改善皱纹(P=23.0)与光滑(P=57.2),其余维度维持现有护理节奏。整体肤质混合性,T区出油面积57%、左右脸颊20%/2%。',
|
||
'综合文案逐字 = 真值表 AW 列');
|
||
ok(vm.lifestyle === '保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳', '生活方式 = 最差分类皱纹条');
|
||
}());
|
||
|
||
console.log('----------------------------------------');
|
||
console.log('自测结果:' + pass + ' 通过,' + fail + ' 失败');
|
||
if (fail > 0) process.exit(1);
|
||
}());
|
||
}
|