Files
juzi/h5/js/skin-score.js
T

1251 lines
79 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ═════ 巨子测肤 H5 — 三维度×6分类评分引擎(纯逻辑,浏览器/Node 双端) ═════
* 评分契约:docs/三维度评分与话术生成需求文档_V2.2.docx(公式/锁值/原始话术)
* 推荐覆盖契约:docs/产品推荐规则-肤龄版.md(2026-10-12 用户确认)
* 浏览器: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 capDimensionScore(S) { return isNum(S) ? Math.min(9.9, S) : null; }
// One shared 100-point total for H5, saved reports and admin. Never use the
// upstream total or reweight an incomplete set of dimensions.
function weightedTotal(dims) {
if (!dims) return null;
var keys = ['firm', 'vita', 'stab'], weights = [4, 3, 3], total = 0;
for (var i = 0; i < keys.length; i++) {
var score = dims[keys[i]] && dims[keys[i]].S;
if (!isNum(score) || score < 0 || score > 10) return null;
total += capDimensionScore(score) * weights[i];
}
return Math.round(total + 1e-9);
}
function sShow(P) { // 展示层钳制:P 可为负(肤色扣分/大面积敏感),S 会 <7,与文档「S 7.0–10.0」口径矛盾;
if (P == null) return null; // 仅钳展示值,推荐边界比较仍用未舍入维度 S 原值
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
}
};
}
// 皱纹:直接使用接口整体评分,不反向、不按最差分区或严重度限分。
function calcWrinkles(r, notes) {
var P = pick(r, 'wrinkle_score');
if (!isNum(P) || P < 0 || P > 100) {
P = null;
notes.push('皱纹:整体皱纹评分缺失或无效,不回退最差分区计算');
}
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) });
}
var worst = regions.length ? regions[0] : null, 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 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,
overall_wrinkle_score: P,
worst_wr_region: worst ? worst.cn : null,
wr_max: worst ? worst.score : null,
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 分区面积)
}
};
}
// 肤色项 = 80%×(色素分−黄褐斑×10−雀斑×5) + 20%×黑眼圈分;肤色/水分仍各50%。
// 交叉校验:water_severity≥60 且 water_score≥80 → 水分项按 severity 换算(实现为 100−severity,待客户确认)
function calcTone(r, notes) {
var melanin = pick(r, 'melanin_score');
var darkCircle = pick(r, 'dark_circle_score');
if (!isNum(darkCircle) || darkCircle < 0 || darkCircle > 100) darkCircle = null;
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 缺失,肤色水润评分缺肤色项');
var colorItem = null, colorWeight = 0, colorSum = 0;
if (melaninItem != null) { colorSum += melaninItem * 0.8; colorWeight += 0.8; }
if (darkCircle != null) { colorSum += darkCircle * 0.2; colorWeight += 0.2; }
else notes.push('dark_circle_score 缺失或无效,肤色项剔除黑眼圈并按有效权重归一');
if (colorWeight) colorItem = colorSum / colorWeight;
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 = [colorItem, 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,
dark_circle_score: darkCircle,
color_score: colorItem,
tone_scoring: { version: 'color-dark-circle-20-v1', melanin_score: melanin,
pigment_adjusted_score: melaninItem, dark_circle_score: darkCircle, color_score: colorItem,
pigment_weight: 0.8, dark_circle_weight: 0.2, color_effective_weight: colorWeight,
water_score: waterScore, water_adjusted_score: waterItem },
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)); }
var ACNE_TYPES = [
['closed_comedones', '闭口粉刺', 1], ['acne', '丘疹', 2],
['acne_pustule', '脓包', 4], ['acne_nodule', '结节', 6], ['acne_mark', '痘印', 1.5]
];
function calcAcne(r, sides, notes) {
var score = pick(r, 'acne_score');
var left = sides && sides.left, right = sides && sides.right;
var sources = { front: r, left: left && (left.left_acne_info || left.acne_info), right: right && (right.right_acne_info || right.acne_info) };
var merged = {}, rows = [], total = 0, rawDeduct = 0, allNull = true, complete = true;
for (var i = 0; i < ACNE_TYPES.length; i++) {
var def = ACNE_TYPES[i], views = {}, max = null;
['front', 'left', 'right'].forEach(function(view) {
var n = cnt(sources[view] && sources[view][def[0]]);
if (!isNum(n) || n < 0 || Math.floor(n) !== n) n = null;
views[view] = n;
if (n != null && (max == null || n > max)) max = n;
});
merged[def[0]] = max;
if (max == null) { complete = false; notes.push('痘痘:' + def[1] + '三视图计数均缺失,不为该项编造扣分'); }
else { allNull = false; total += max; rawDeduct += max * def[2]; }
rows.push({ key: def[0], name: def[1], weight: def[2], views: views, count: max,
sources: Object.keys(views).filter(function(view) { return max != null && views[view] === max; }),
deduction: max == null ? null : max * def[2] });
}
var deduct = Math.min(50, rawDeduct);
var audit = { version: 'three-view-type-max-v1', front_score: score,
mapped_score: score == null ? null : acneGrade(score), types: rows,
raw_deduction: allNull ? null : rawDeduct, deduction: allNull ? null : deduct, complete: complete };
var vars = { acne_cnt: allNull ? null : total, acne_counts: merged, acne_counts_complete: complete,
acne_detail: rows.filter(function(row) { return row.count > 0; }).map(function(row) { return row.name + row.count + '处'; }).join('、'), acne_scoring: audit };
if (score == null && allNull) {
notes.push('acne_score 与皮损计数全部缺失,痘痘分类不参与评分');
return { P: null, vars: vars };
}
if (score == null) notes.push('acne_score 缺失,痘痘评分仅由计数扣分决定');
var cand = [];
if (score != null) cand.push(acneGrade(score));
if (!allNull) cand.push(100 - deduct);
return { P: Math.min.apply(null, cand), vars: vars };
}
/* ─── 话术库(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 '本次检测未获取皮损计数数据,仅按痤疮分级评估。建议维持清洁与控油节奏,注意作息与饮食。';
if (v.acne_counts_complete === false) return (found ? found + '。' : '') + '部分类型皮损计数未获取,请结合检测图片查看。';
if (found) return found + '。建议维持清洁与控油节奏,注意作息与饮食。';
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+重塑精华:针对熟龄肌松弛与轮廓流失问题,深层重塑支撑结构,改善明显松弛与纹路,重建面部紧实与弹性。' }
};
// 2026-10-12 已确认的业务规则。只接收原始肤龄和三维度未舍入 S,
// 不使用实际年龄、展示肤龄、六分类均分或最差分类;历史快照不重算。
function recommendProduct(skinAge, scores) {
scores = scores || {};
var firm = isNum(scores.firm) ? scores.firm : null;
var vita = isNum(scores.vita) ? scores.vita : null;
var stab = isNum(scores.stab) ? scores.stab : null;
var rec = { version: '2026-10-12-skin-age-v1', skinAge: skinAge,
baseTier: null, tier: null, boundary: null, adjustment: 'none',
scores: { firm: firm, vita: vita, stab: stab }, reason: '', triggers: [] };
if (!isNum(skinAge) || skinAge <= 0) {
rec.adjustment = 'unavailable';
rec.reason = '未获取有效原始肌龄,暂不推荐具体产品;不以实际年龄代替。';
return rec;
}
rec.baseTier = skinAge < 35 ? 1 : (skinAge < 50 ? 2 : 3);
rec.tier = rec.baseTier;
rec.triggers.push('肤龄基础分组');
if (skinAge >= 32 && skinAge <= 38) rec.boundary = 35;
else if (skinAge >= 47 && skinAge <= 53) rec.boundary = 50;
if (rec.boundary != null && skinAge < rec.boundary && firm != null && firm < 8) {
rec.tier++;
rec.adjustment = 'up';
rec.reason = '处于' + rec.boundary + '岁下侧边界,紧塑力低于8分,升一档。';
rec.triggers.push(rec.boundary + '岁边界低分升档');
} else if (rec.boundary != null && skinAge >= rec.boundary && firm != null && firm >= 9
&& ((vita != null && vita >= 9) || (stab != null && stab >= 9))) {
rec.tier--;
rec.adjustment = 'down';
rec.reason = '处于' + rec.boundary + '岁上侧边界,紧塑力≥9且透亮感或稳韧力≥9,降一档。';
rec.triggers.push(rec.boundary + '岁边界高分降档');
} else if (skinAge < 32) {
rec.reason = '原始肌龄小于32岁,固定推荐25+,轮廓或皱纹低分不升档。';
} else if (rec.boundary != null) {
rec.reason = '处于' + rec.boundary + '岁边界,未满足升降档条件,保持肤龄基础档。';
} else {
rec.reason = '不在35±3或50±3岁边界内,仅按原始肌龄推荐。';
}
return rec;
}
/* ─── V2.3 §8 护理建议 ─── */
var SKIN_TYPE_TEXT = { // §8.6 五类映射【肤质定制】段全文 + 联动句
'干性肌肤': { base: '你的日常肤质为干性肌肤:全脸皮脂分泌偏少,角质层保水能力弱,易紧绷起屑。护理以高效保湿与封闭锁水为核心,洁面选用温和低泡产品,避免频繁去角质,面霜可选择含神经酰胺、角鲨烷等封闭性成分的配方。',
link: function (c) { return c.tone && c.tone.level === '预警' ? '当前已处于明显缺水状态,建议暂缓一切控油类产品' : ''; } },
'油性肌肤': { base: '你的日常肤质为油性肌肤:皮脂分泌旺盛,毛孔易被氧化油脂堵塞。护理以温和控油与毛孔疏通为核心,可选择含烟酰胺、水杨酸的清爽配方,但清洁仍需适度,避免过度去脂反而刺激代偿性出油。',
link: function (c) { return c.tone && c.tone.level === '预警' ? '需警惕外油内干:控油同时必须叠加保湿,避免屏障受损' : ''; } },
'中性肌肤': { base: '你的日常肤质为中性肌肤:水油分泌平衡,角质状态稳定,是理想的肌肤基础态。护理以维持现状为核心,精简护肤流程,做好基础清洁、保湿与防晒即可,无需叠加过多功效产品。',
link: function () { return ''; } },
'混合偏干肌肤': { base: '你的日常肤质为混合偏干肌肤:T区状态尚可,但两颊及面部外侧偏干,易出现紧绷与细纹。建议分区护理:两颊侧重保湿滋养,选用滋润型乳液/面霜,T区轻量保湿即可,避免全脸统一用厚重面霜。',
link: function (c) { return c.smooth && c.smooth.level === '预警' ? '干燥会放大纹理粗糙感,建议先将两颊水合度提上来再做角质调理' : ''; } },
'混合偏油肌肤': { base: '你的日常肤质为混合偏油肌肤:T区出油旺盛而两颊偏干,呈典型外油内干倾向。建议分区护理:T区使用控油清爽产品,两颊加强保湿;全脸避免高滋润面霜,可选用质地轻薄的保湿乳分区涂抹。',
link: function (c) { return c.tone && c.tone.level === '预警' ? '外油内干状态下切勿加大清洁力度,补水优先于控油' : ''; } }
};
function skinTypeKey(name) { // 归一:用户自选五类名;未知/缺失按中性
var keys = ['混合偏干肌肤', '混合偏油肌肤', '干性肌肤', '油性肌肤', '中性肌肤'];
for (var i = 0; i < keys.length; i++) if (name && name.indexOf(keys[i].replace('肌肤', '')) >= 0) return keys[i];
return '中性肌肤';
}
var CARE_POINTS = { // §8.7 护理要点库(6分类×3档,逐字;肤色水润=色沉+水分并列,先色沉后水分)
contour: {
'预警': '强化轮廓紧致管理,重点针对面中、苹果肌与下颌线支持带区域,配合向上提升按摩手法,促进真皮层胶原再生与微循环。',
'改善': '加强弹韧护理补充胶原支架营养,关注易显疲态区域的支撑力,日常可增加紧致精华步骤并配合向上按摩。',
'良好': '维持现有抗老护理节奏,做好光损伤防护与状态管理,巩固真皮胶原网络密度。'
},
wrinkles: {
'预警': '加强纹路分级管理,重点针对眼周、法令纹等动态纹高发区域,使用视黄醇/胜肽类淡纹精华并配合点按手法,遏制静态纹转化。',
'改善': '加强日常淡纹与弹润护理,维持真皮基质含水量,保持肌肤饱满平滑。',
'良好': '以抗初老预防为核心,提前建立抗氧化/紧致护理习惯,延缓第一道静态纹出现。'
},
smooth: {
'预警': '加强细腻度管理,避免过度清洁破坏角质屏障,重点关注毛孔显著区域,配合低浓度酸类(如水杨酸)周期护理与柔润精华。',
'改善': '加强基础清洁与角质调理,关注鼻翼、下颌等易粗糙部位,提升毛孔通畅度与柔滑度。',
'良好': '维持当前温和护理节奏,保护角质层排列与微循环稳态。'
},
tone: { // 每档 = [色沉段, 水分段](并列输出,先色沉后水分)
'预警': ['强化透亮与色素代谢管理,严格广谱防晒,配合温和抗氧化成分(如维C衍生物)抑制黑色素转运。', '强化屏障锁水修复,停用控油类产品,改用含神经酰胺的封闭性面霜,每周2-3次补水面膜快速纠正缺水状态。'],
'改善': ['加强日常光泽管理与局部色素防控,做好基础防晒与抗氧化打底。', '注重水油平衡调理,选用清爽保湿乳液,避免过度清洁带走皮脂膜。'],
'良好': ['维持现有防护与护理习惯,巩固肤色均匀度与透亮感。', '维持现有补水习惯,巩固水合状态与水油平衡。']
},
sens: {
'预警': '优先屏障修复与舒缓管理,精简护肤流程,避免频繁更换方案与叠加功效成分,修复受损角质层。',
'改善': '保持温和护理节奏,注意防晒与环境防护,逐步提升皮肤耐受度。',
'良好': '维持规律护理并按季节阶段性调整,巩固皮肤耐受稳态。'
},
acne: { // 痘痘:V1.2 无原文,V2.3 本版新撰
'预警': '优先抗炎与痘肌修护,避免挤压与厚重型封闭产品,选用含水杨酸或壬二酸的清爽配方,局部点涂抗痘精华,必要时寻求皮肤科处理。',
'改善': '维持温和清洁与控油节奏,注意枕巾与手部卫生,避免叠加过多功效产品刺激皮损。',
'良好': '维持现有清洁控油习惯,保持作息与饮食规律,防止毛孔堵塞复发。'
}
};
/* ─── 主入口 ─── */
function buildViewModel(apiResult, sideResults, profile) {
var r = apiResult || {};
profile = profile || {};
var notes = [];
var realAge = val(profile.realAge);
// 仅展示年龄限制在实际年龄±5岁,原始肌龄继续用于产品推荐。
var skinAge = val(r.skin_age);
var skinAgeDisplay = reportSkinAge({ skinAgeRaw: skinAge, realAge: realAge });
var ageClampNote = skinAgeDisplay != null && skinAgeDisplay !== skinAge
? '生物可视年龄已按实际年龄±5岁限制展示;接口原始肌龄保留用于推荐。' : '';
if (realAge == null) {
notes.push('未提供实际年龄:肌龄展示与产品推荐均使用接口原始肌龄');
}
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.key === 'acne') ? def.calc(r, sideResults, notes) : def.calc(r, notes);
cats[def.key] = {
name: def.name, dim: def.dim,
vars: res.vars, // 原始模板变量供新版展示层使用,不参与评分规则变更
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: capDimensionScore(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) };
}
var recommendation = recommendProduct(skinAge, {
firm: dims.firm.P == null ? null : sRaw(dims.firm.P),
vita: dims.vita.P == null ? null : sRaw(dims.vita.P),
stab: dims.stab.P == null ? null : sRaw(dims.stab.P)
});
var tier = recommendation.tier, triggers = recommendation.triggers;
var group = tier == null ? null : ['25+组', '35+组', '50+组'][tier - 1];
var essence = { name: tier == null ? '暂无法推荐' : ESSENCE[tier].name,
text: tier == null ? recommendation.reason : ESSENCE[tier].text, tier: tier, triggers: triggers };
if (tier == null) notes.push(recommendation.reason);
// 近期优先关注:最差维度
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)) : '') + '。'
+ (tier == null ? recommendation.reason : '按肤龄及边界规则(触发:' + triggers.join('、') + ')推荐' + essence.name)
+ (improve ? ':' + improve : '。')
+ (tail ? tail + '。' : '');
}
/* 护理建议(V2.3 §8.5 五段固定顺序:【肤质】【最差分类1】【最差分类2】【精华】【生活方式】;
§8.5.1 输出纪律:段内不得出现 P/S 数值、档位名、判据、字段名等内部标注) */
var careAdvice = [];
var stDef = SKIN_TYPE_TEXT[skinTypeKey(profile.skinType)];
var linkSeg = stDef.link(cats);
careAdvice.push({ tag: '肤质', text: stDef.base + (linkSeg ? linkSeg + '。' : '') });
if (sorted && sorted.length >= 2) {
for (i = 0; i < 2; i++) {
var ck = CAT_DEFS.filter(function (d) { return d.name === sorted[i].name; })[0].key;
var lv2 = cats[ck].level || '良好', pts = CARE_POINTS[ck][lv2];
careAdvice.push({ tag: sorted[i].name, text: Array.isArray(pts) ? pts.join('') : pts });
}
}
careAdvice.push({ tag: '精华', text: essence.text });
careAdvice.push({ tag: '生活方式', text: worst ? LIFESTYLE[worst.catKey] : '' });
return {
total: weightedTotal(dims),
totalRule: 'dimensions-433-v1',
vitaScoreRule: 'combined-tone-smooth-min-v2',
wrinkleScoreRule: 'api-overall-v1',
acneScoreRule: 'three-view-type-max-v1',
skinAgeRaw: skinAge,
skinAgeDisplay: skinAgeDisplay,
ageClampNote: ageClampNote,
realAge: realAge,
dims: dims,
cats: cats,
group: group,
essence: essence,
recommendation: recommendation,
avgS: avgS,
worst: worst,
focus: focus,
summary: summary,
lifestyle: worst ? LIFESTYLE[worst.catKey] : '',
careAdvice: careAdvice,
notes: notes
};
}
/* ─── Mock 数据(pro 形状;数值对齐测试文档「图片 13」案例) ─── */
var MOCK_RESULT = {
score_info: {
total_score: 79,
wrinkle_score: 23, // Keep this synthetic fixture's other baseline results stable.
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
};
// Apply the current display rule to new and historical reports without
// modifying their raw age, stored snapshot, copy or recommendation.
function reportSkinAge(vm, result) {
var raw = vm && Object.prototype.hasOwnProperty.call(vm, 'skinAgeRaw') ? val(vm.skinAgeRaw) : val(result && result.skin_age);
if (!isNum(raw) || raw < 0) return null;
var actual = val(vm && vm.realAge);
if (!isNum(actual) || actual < 0) return raw;
return Math.max(actual - 5, Math.min(actual + 5, raw));
}
return {
capDimensionScore: capDimensionScore,
weightedTotal: weightedTotal,
reportSkinAge: reportSkinAge,
buildViewModel: buildViewModel,
recommendProduct: recommendProduct,
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直接使用整体评分23,与最差分区77无关');
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 === Math.min(vm.cats.smooth.P,vm.cats.tone.P) && vm.dims.stab.P === 85, '维度木桶 min:紧塑力23 / 平滑度与肤色水润最低分 / 稳定力85');
ok(vm.skinAgeRaw === 20 && vm.skinAgeDisplay === 20 && vm.ageClampNote === '', '肌肤年龄直接展示原值20,不按实际年龄钳制');
ok(vm.group === '25+组', '原始肌龄20 → 25+组');
ok(vm.essence.name === '25+束紧精华' && vm.recommendation.tier === 1,
'肌龄小于32,即使皱纹低分也固定25+,不再使用最差项升档');
ok(vm.avgS === 9.32, '六分类S均分 9.32');
ok(vm.worst.catKey === 'wrinkles' && vm.worst.P === 23, '最差分类 = 皱纹');
ok(vm.focus.name === '紧塑力', '近期优先关注 = 最差维度紧塑力');
ok(vm.total === 88, '综合得分 = round(7.69×4+9.45×3+9.55×3) = 88,不使用接口总分79');
ok(vm.summary.indexOf('【综合】') === 0 && vm.summary.indexOf('25+束紧精华') > 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;
r1.score_info.wrinkle_score = 95;
ok(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }).cats.wrinkles.P === 95, '整体评分95不受分区 severity=3 限分');
r1.right_nasolabial_fold_info.wrinkle_severity_level = 2;
ok(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }).cats.wrinkles.P === 95, '整体评分95不受分区 severity=2 限分');
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 === '25+组', '实际年龄40不影响推荐,原始肌龄20仍为25+组');
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 === 30 && vm3b.ageClampNote !== '' && vm3b.skinAgeRaw === 40, '实际年龄25、原始肌龄40:展示30,原值不改变');
console.log('== T4 最差项不再覆盖肤龄推荐 ==');
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,评分公式不变');
ok(vm4.essence.name === '25+束紧精华', '原始肌龄20,即使最差项S<7也不升档');
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 === '25+组' && vm5c.skinAgeDisplay === 20 && vm5c.ageClampNote === ''
&& vm5c.essence.triggers.indexOf('年龄组档') < 0 && vm5c.notes.join('|').indexOf('实际年龄') >= 0,
'实际年龄缺失不钳制,但原始肌龄仍可推荐25+');
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);
// Legacy reference assertions isolate unrelated calculations/copy. The
// new scalar source/rule is covered by test/wrinkle-overall.js.
d.result.score_info.wrinkle_score = 23;
d.result.score_info.dark_circle_score = d.result.score_info.melanin_score - ((d.result.melasma || {}).value || 0) * 10 - ((d.result.freckle || {}).value || 0) * 5;
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 === '25+束紧精华' && vm.essence.triggers.join('、') === '肤龄基础分组',
'评分不变,原始肌龄27推荐25+,不再因最差皱纹升档');
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。按肤龄及边界规则(触发:肤龄基础分组)推荐25+束紧精华:优先改善皱纹(P=23.0)与光滑(P=57.2),其余维度维持现有护理节奏。整体肤质混合性,T区出油面积57%、左右脸颊20%/2%。',
'综合文案仅产品判据按2026-10-12规则替换,其余逐字不变');
ok(vm.lifestyle === '保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳', '生活方式 = 最差分类皱纹条');
}());
console.log('----------------------------------------');
console.log('自测结果:' + pass + ' 通过,' + fail + ' 失败');
if (fail > 0) process.exit(1);
}());
}