feat: 报告页 V2.2 检测可视化 + 评分引擎审计 + 真值回归/e2e 闭环

- server.js: face_maps 改为白名单 5 图层完整 dataURL 透传(REPORT_MAPS,按魔数判 mime),marks 坐标随 result 原样透传;实测 return_marks 不支持毛孔/黑头等扩展标记
- skin-score.js: 按需求文档 V2.2 逐字审计修正 9 处(黑眼圈 dark_circle_severity、oil_desc/spot_flag §7.2 拼接、skintone 中文映射、轮廓预警档前缀、痘痘 heavy_note、50+精华全文、综合文案数值格式),契约引用 V2.0→V2.2
- app.js/index.html/css: 报告页 6 分类检测可视化(轮廓=正脸实拍、皱纹=正脸压暗+皱纹增强线、光滑=正脸+色斑/痣/痤疮标记、肤色=色沉图、敏感=红区图、痘痘=正脸+闭口/痤疮标记),数据驱动 VIZ_CAT 配置,优雅降级
- h5/test/: Excel 两组三视图真值回归 84/84(含案例2 AY 列扁平化数据重建 fixture)
- h5/assets/mock/: 真实案例1 报告页 fixture(坐标与照片同比例缩放),?mock=1&screen=report 直渲
- e2e: Chrome 无头闭环 55/55(mock 全流程/直渲/真实 API 降级链路),截图存档 preview/e2e-20260926
- 自测: skin-score 83/83,fixture 冒烟 35/35
This commit is contained in:
Ubuntu
2026-09-26 22:35:02 +08:00
parent 71a090fd75
commit ba3b346fce
28 changed files with 2801 additions and 71 deletions
+170 -52
View File
@@ -1,16 +1,19 @@
/* ═════ 巨子测肤 H5 — 三维度×6分类评分引擎(纯逻辑,浏览器/Node 双端) ═════
* 最高契约:docs/三维度评分与话术生成需求文档_V2.0.md(公式/锁值/话术/产品推荐以其为准)
* 最高契约:docs/三维度评分与话术生成需求文档_V2.2.docx(公式/锁值/话术/产品推荐以其为准)
* 浏览器:window.SkinScore;Node:module.exports。ES5、零依赖、不碰 DOM。
*
* 字段口径(已对 reference/facepp-skinanalyze/examples/ 真实 pro JSON 复核):
* - 分数类字段优先取 result.score_info.<key>,缺失退化 result.<key>(V2.0 §137 归一)
* - 分数类字段优先取 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.0 第二章字段来源),缺失回退 sensitivity.sensitivity_area
* - 敏感面积优先 red_spot.red_spot_area(V2.2 §3.1:小数×100=%),缺失回退 sensitivity.sensitivity_area
* - eye_pouch_severity.value 为 0–3 枚举(缺失退化 eye_pouch.value,非整数取整、越界按缺失)
* - sensitivity_area / red_spot_area / water_area ≤1 视为小数口径×100,否则按百分比直读
* - 水分交叉校验「按 severity 换算」实现为 水分项 = 100 − water_severity(待客户确认)
* - oily_intensity_score → 出油概览句为自设计措辞(文档未给全文)
* - 黑眼圈级取 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();
@@ -27,7 +30,7 @@
var n = Number(x);
return isNaN(n) ? null : n;
}
function pick(r, key) { // score_info 优先,再顶层(§137 字段归一)
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; }
@@ -73,9 +76,12 @@
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.0 第五章生成规则)。 */
* 严禁输出 None/undefined/— 占位(V2.2 第七章生成规则与 7.3 缺值规则)。 */
function fill(tpl, vars) {
var parts = tpl.split(/([,;。])/);
var out = '';
@@ -98,7 +104,7 @@
}
/* ─── 字段归一:分区配置 ─── */
var WRINKLE_REGIONS = [ // 12 分区;left_nasolabial_wrinkle_info 与 _fold_info 并存(§137)
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: '左眼周' },
@@ -116,6 +122,14 @@
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 表示缺项) ─── */
@@ -177,12 +191,14 @@
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: pick(r, 'dark_circle')
dark_circle_sev: dcSev
}
};
}
@@ -291,7 +307,7 @@
pore_cnt: poreCnt,
blackhead_cnt: bhCnt,
tex: rough,
oily: pick(r, 'oily_intensity_score')
oily: r.oily_intensity // {oil_desc} 数据源(V2.2 §7.2 分区面积)
}
};
}
@@ -328,17 +344,26 @@
var P = n === 2 ? acc : acc * 2 / n; // 单项时按 0.5 权重归一
var itaObj = r.skintone_ita || {};
var haObj = r.skin_hue_ha || {};
var st = itaObj.skin_tone != null ? itaObj.skin_tone
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: val(itaObj.ITA != null ? itaObj.ITA : itaObj.value),
ita: itaV == null ? null : round1(itaV), // 真值表为 1 位小数(58.64615→58.6、56.246513→56.2)
st: st,
ha: val(haObj.HA != null ? haObj.HA : haObj.value),
ha: haV == null ? null : round1(haV),
melanin: melanin,
spot_flag: (melasma || freckle) ? joinParts([melasma ? '黄褐斑' : '', freckle ? '雀斑' : ''], '、') : '未见明显色斑标记',
spot_flag: spotFlag,
water_score: waterScore,
water_area: waterArea,
water_sev: waterSev
@@ -351,7 +376,7 @@
var sensObj = r.sensitivity || null;
var score = pick(r, 'sensitivity_score');
if (score == null && sensObj) score = val(sensObj.sensitivity_score);
// 面积口径来源(V2.0 第二章字段来源):优先 red_spot.red_spot_area(小数×100=%);
// 面积口径来源(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) {
@@ -408,18 +433,29 @@
P: Math.min.apply(null, cand),
vars: {
acne_cnt: allNull ? null : total,
acne_detail: detail,
heavy: (pu || 0) > 0 || (no || 0) > 0 // 仅在真实检出脓包/结节时输出 heavy_note
acne_detail: detail
}
};
}
/* ─── 话术库(V2.0 第五章逐字;变量缺失省略对应分句) ─── */
function oilDesc(oily) { // {oil_desc} 出油概览句(自设计措辞,文档未给全文)
if (oily == null) return '';
if (oily < 40) return '肌肤出油较少,整体清爽';
if (oily < 60) return 'T区存在轻度出油';
return '全脸出油偏多,需加强控油';
/* ─── 话术库(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 '';
@@ -442,9 +478,10 @@
}
function textContour(P, v) {
var lv = levelOf(P), jaw = jawSeg(v, lv === '良好'), eye = eyeSeg(v);
if (lv === '预警') return fill('面部轮廓出现可见松弛{jaw};{eye};中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。',
{ jaw: jaw ? ',' + jaw : '', eye: eye });
// V2.0 §5.1 改善档原文只含眼袋,不含黑眼圈
// 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},中面部容积饱满,未见明显软组织下垂。轮廓支撑力良好,建议维持日常紧致护理与防晒,保持规律作息,守护现有轮廓。',
@@ -513,10 +550,9 @@
}
function textAcne(P, v) {
var lv = levelOf(P);
var heavy = v.heavy ? ',含脓包、结节等中重度皮损,炎症反应明显' : '';
// 「检出N处皮损(明细)」与 heavy 拼接成句:heavy 为空时不得拖垮整分句(fill 的缺失省略以分句为单位)
// 「检出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 + heavy + '。' : '')
if (lv === '预警') return (found ? found + '。' : '')
+ '建议尽快进行针对性抗痘护理,避免挤压与刺激,必要时寻求皮肤科专业处理。';
if (lv === '改善') return (found ? found + ',' : '')
+ '以闭口粉刺、丘疹为主,局部存在炎症倾向。建议加强温和清洁与控油,避免挤压,配合抗炎舒缓护理。';
@@ -539,7 +575,7 @@
{ key: 'vita', name: '活力感', tip: '细腻度与水润光泽:加强补水与温和清洁,做好防晒与抗氧化管理' },
{ key: 'stab', name: '稳定力', tip: '屏障稳定与痘痘管理:温和清洁、舒缓修护,避免刺激源与挤压' }
];
var LIFESTYLE = { // V2.0 §6.2 全文
var LIFESTYLE = { // V2.2 §8.4 全文
contour: '戒除揉眼、托腮等表情性挤压习惯,保证睡眠减少纹路加深',
wrinkles: '保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳',
smooth: '规律去角质但不过度(每1–2周一次),运动后及时清洁,减少油炸与高糖饮食',
@@ -547,10 +583,10 @@
sens: '避免过热的水洗脸与桑拿,选择无香精配方,换季时精简护肤步骤',
acne: '不挤压痘痘,枕巾高频更换,减少乳制品与高升糖饮食摄入'
};
var ESSENCE = { // V2.0 §6.1 三档全文
var ESSENCE = { // V2.2 §8.3 三档全文(前缀「建议搭配使用X:」为报告页拼接胶)
1: { name: '25+束紧精华', text: '建议搭配使用25+束紧精华:针对年轻肌肤活力减弱问题,激活肌肤动力、改善暗沉,提升弹润紧致表现,守护年轻光泽并延缓初始衰老迹象。' },
2: { name: '35+支撑精华', text: '建议搭配使用35+支撑精华:专为轻熟肌打造,提升弹韧表现、维持面部轮廓清晰度,改善疲态并延缓衰老速度。' },
3: { name: '50+重塑精华', text: '建议搭配使用50+重塑精华:针对成熟肌肤恢复能力下降,提升紧致表现、改善松弛与轮廓模糊,修复强韧屏障,重塑年轻质感。' }
3: { name: '50+重塑精华', text: '建议搭配使用50+重塑精华:针对熟龄肌松弛与轮廓流失问题,深层重塑支撑结构,改善明显松弛与纹路,重建面部紧实与弹性。' }
};
/* ─── 主入口 ─── */
@@ -572,6 +608,7 @@
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;
@@ -646,7 +683,7 @@
if (fP == null || dP2 < fP) { fP = dP2; focus = { name: dd.name, tip: dd.tip }; }
}
// 综合文案(§6.1)
// 综合文案(V2.2 §8.2 模板)
var summary;
if (avgRaw == null) {
summary = '【综合】本次检测数据不足,暂无法生成完整评分,建议重新拍摄检测。';
@@ -667,14 +704,14 @@
}
sorted.sort(function (a, b) { return a.P - b.P; });
var improve = '';
if (sorted.length >= 2) improve = '优先改善' + sorted[0].name + '(P=' + fmt(sorted[0].P) + ')与' + sorted[1].name + '(P=' + fmt(sorted[1].P) + '),其余维度维持现有护理节奏。';
else if (sorted.length === 1) improve = '优先改善' + sorted[0].name + '(P=' + fmt(sorted[0].P) + '),其余维度维持现有护理节奏。';
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([
profile.skinType ? '整体肤质' + profile.skinType : '',
oilDesc(pick(r, 'oily_intensity_score'))
skinTypeCn ? '整体肤质' + skinTypeCn : '',
oilDesc(r.oily_intensity)
], ',');
summary = '【综合】' + (head ? head + ';' : '')
+ '六分类S均分' + fmt(avgS) + (worst ? '、最差分类' + worst.name + ' S=' + fmt(worst.S) : '') + '。'
+ '六分类S均分' + fmtS(avgRaw) + (worst ? '、最差分类' + worst.name + ' S=' + fmtS(sRaw(worst.P)) : '') + '。'
+ '按三重判据' + (triggers.length ? '(触发:' + triggers.join('、') + ')' : '') + '推荐' + essence.name
+ (improve ? ':' + improve : '。')
+ (tail ? tail + '。' : '');
@@ -709,7 +746,7 @@
blackhead_score: 88,
acne_score: 86,
oily_intensity_score: 42,
pores_type_score: { // §137:嵌套在 score_info 内
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,
@@ -718,6 +755,11 @@
},
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 },
@@ -851,7 +893,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
ok(vm4.essence.name === '50+重塑精华' && vm4.essence.triggers.indexOf('最差项约束') >= 0, '最差项 S 原值<7 → 50+重塑精华,triggers 含最差项约束(三重判据用未舍入原值)');
checkTexts(vm4, 'T4');
console.log('== T5 缺数据降级(V2.0 第七章) ==');
console.log('== T5 缺数据降级(V2.2 第十章异常与边界) ==');
var r5 = clone(SS.MOCK_RESULT);
delete r5.sensitivity;
delete r5.score_info.sensitivity_score;
@@ -892,12 +934,13 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
// 扣分 = 3×1+3×2+2×4+0+1×1.5 = 18.5 → 100−18.5=81.5 < 分级映射85
ok(approx(vm8.cats.acne.P, 81.5), '痘痘P = min(85, 100−18.5) = 81.5(rectangle 退化计数)');
ok(vm8.cats.acne.text.indexOf('脓包2处') >= 0, 'acne_detail 按计数列明细,为0的类型不列');
var vm8b = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 }); // 含脓包1处
ok(vm8b.cats.acne.text.indexOf('含脓包、结节等中重度皮损') < 0, '改善档无 heavy_note(heavy_note 仅预警档且真实检出时输出)');
var 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('(闭口粉刺3处、丘疹3处、脓包1处、痘印1处),含脓包、结节等中重度皮损,炎症反应明显。') >= 0,
'预警档真实检出脓包 → heavy_note 逐字输出');
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 键
@@ -909,7 +952,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
ok(vm9.notes.join('|').indexOf('毛孔分区严重度未返回,光滑上限锁未执行') >= 0, 'pores_type_score 无 severity 键 → notes 留痕「光滑上限锁未执行」');
checkTexts(vm9, 'T9-毛孔');
// (6) 敏感面积优先 red_spot.red_spot_area(V2.0 第二章字段来源)
// (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
@@ -928,14 +971,24 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
'wrinkle_count/fine_line 分区对象数值叶子求和 → 全脸皱纹159条、细纹150条');
checkTexts(vm9d, 'T9-皱纹计数');
// (8) 肤色分类真实键名 skintone_ita.skintone(值为 0 时不得被 falsy 吞掉)
// (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.65(0,HA 56.25)') >= 0, 'skintone_ita.skintone=0 → 肤色分类 0 正常输出');
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)');
// (10)(12) 眼袋 severity 非整数取整 / 越界按缺失;轮廓改善档话术无「黑眼圈」(V2.0 §5.1 逐字)
// (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 });
@@ -946,7 +999,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
right: { right_jawline_info: { right_jawline_angle: 110, right_jawline_quality: 1 } }
}, { realAge: 25 });
ok(vm9f2.cats.contour.level === '改善' && vm9f2.cats.contour.text.indexOf('眼袋0级') >= 0 && vm9f2.cats.contour.text.indexOf('黑眼圈') < 0,
'轮廓改善档含「眼袋0级」且无「黑眼圈」(§5.1 改善档原文无黑眼圈)');
'轮廓改善档含「眼袋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 });
@@ -961,14 +1014,79 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
var vm9h = SS.buildViewModel(r9h, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9h.cats.acne.text.indexOf('未检出') < 0 && vm9h.cats.acne.text.indexOf('未获取皮损计数数据') >= 0,
'痘痘良好档且计数全缺失 → 缺数据提示句(不声称未检出)');
// 预警档无脓包/结节(heavy 为空)时「检出N处皮损(明细)」不得被 fill 分句省略拖垮(真实样本形状:丘疹3+闭口6)
// 预警档「检出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,
'痘痘预警档无中重度皮损 → 计数明细正常输出、无 heavy_note');
'痘痘预警档 → 计数明细正常输出(为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 + ' 失败');