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:
+181
-8
@@ -508,7 +508,7 @@
|
||||
showScreen('s-profile');
|
||||
return;
|
||||
}
|
||||
renderReport(vm, photos[0], photos[1]); // img1=正脸实拍,img2=右脸实拍
|
||||
renderReport(vm, photos[0], photos[1], payload); // img1=正脸实拍,payload 供检测可视化取 face_maps/marks
|
||||
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
|
||||
showScreen('s-report');
|
||||
}, wait);
|
||||
@@ -529,13 +529,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 分析请求(mock=1 直返 SkinScore 内置样本,便于离线/自动化验证) ─── */
|
||||
/* ─── 分析请求(mock=1 直返内置样本;有 MOCK_FIXTURE 时用报告页可视化 fixture,便于离线/自动化验证) ─── */
|
||||
var ANALYZE_TIMEOUT_MS = 45000; // 检测服务超时阈值:超时按「开小差」处理回档案页
|
||||
function requestAnalyze(ph, seq, done) {
|
||||
if (IS_MOCK && typeof SkinScore !== 'undefined') {
|
||||
setTimeout(function () {
|
||||
if (seq !== camSeq) return; // mock 同样尊重取消/重置
|
||||
done(null, { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE });
|
||||
var fx = window.MOCK_FIXTURE; // assets/mock/report-fixture.js(仅 mock 模式注入)
|
||||
if (fx) {
|
||||
// marks 坐标空间=fixture 照片空间:三连拍替换为 fixture 照片,保证可视化叠加对齐
|
||||
ph[0] = fx.photos[0]; ph[1] = fx.photos[1]; ph[2] = fx.photos[2];
|
||||
done(null, { result: fx.result, side: fx.side, face_rectangle: fx.face_rectangle });
|
||||
} else {
|
||||
done(null, { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE });
|
||||
}
|
||||
}, 900);
|
||||
return;
|
||||
}
|
||||
@@ -607,7 +614,168 @@
|
||||
$(dom.tx).textContent = cat.text;
|
||||
}
|
||||
|
||||
function renderReport(vm, frontPhoto, sidePhoto) {
|
||||
/* ─── 报告页检测可视化(face_maps 图层 + marks 坐标叠加,数据驱动) ─── */
|
||||
// 6 个分类各声明图片来源:map=整幅图层直接出图;overlay=透明增强图层盖在正脸上;marks=坐标标记画在正脸上
|
||||
// 增删标记类型/图层:改对应分类的一行即可
|
||||
var VIZ_CAT = {
|
||||
contour: { img: '#ditem-img1', photo: true }, // 轮廓:用户正脸实拍
|
||||
wrinkles: { img: '#ditem-img2', overlay: 'texture_enhanced_lines', dim: 0.55 }, // 皱纹:正脸压暗+皱纹增强线(图层几乎全透明,直挂在白卡上不可读,故合成)
|
||||
smooth: { img: '#viz-smooth', photo: true, marks: [ // 光滑:正脸+色斑/痣/痤疮标记
|
||||
{ key: 'brown_spot', color: '#B26A00' }, // 色斑
|
||||
{ key: 'mole', color: '#282828' }, // 痣
|
||||
{ key: 'acne', color: '#D30C0C' } // 痤疮
|
||||
] },
|
||||
tone: { img: '#viz-tone', map: 'brown_area' }, // 肤色:色沉图
|
||||
sens: { img: '#viz-sens', map: 'red_area' }, // 敏感:红区图
|
||||
acne: { img: '#viz-acne', photo: true, marks: [ // 痘痘:正脸+闭口粉刺/痤疮标记
|
||||
{ key: 'closed_comedones', color: '#E8A13D' }, // 闭口粉刺
|
||||
{ key: 'acne', color: '#D30C0C' }, // 痤疮
|
||||
{ key: 'acne_pustule', color: '#D30C0C' }, // 脓包
|
||||
{ key: 'acne_nodule', color: '#8E0000' } // 结节
|
||||
] }
|
||||
};
|
||||
|
||||
/* viz-pure-begin(纯函数段:与 DOM 无关,node 冒烟按锚点抽离断言,勿改锚点注释) */
|
||||
// 从 result 提取某分类的标记集合:polygon 与 rectangle 都保留,缺该标记类型则跳过
|
||||
function vizCollectMarks(markDefs, result) {
|
||||
var out = [];
|
||||
if (!markDefs || !result) return out;
|
||||
for (var i = 0; i < markDefs.length; i++) {
|
||||
var src = result[markDefs[i].key];
|
||||
if (!src || typeof src !== 'object') continue;
|
||||
var m = { color: markDefs[i].color, polygons: [], rects: [] };
|
||||
var polys = src.polygon || [];
|
||||
for (var p = 0; p < polys.length; p++) {
|
||||
var pts = [];
|
||||
for (var q = 0; q < (polys[p] || []).length; q++) {
|
||||
var pt = polys[p][q];
|
||||
if (pt && typeof pt.x === 'number' && typeof pt.y === 'number') pts.push([pt.x, pt.y]);
|
||||
}
|
||||
if (pts.length > 2) m.polygons.push(pts);
|
||||
}
|
||||
var rects = src.rectangle || [];
|
||||
for (var r = 0; r < rects.length; r++) {
|
||||
var rc = rects[r];
|
||||
if (rc && typeof rc.left === 'number' && typeof rc.top === 'number') {
|
||||
m.rects.push({ left: rc.left, top: rc.top, width: +rc.width || 0, height: +rc.height || 0 });
|
||||
}
|
||||
}
|
||||
if (m.polygons.length || m.rects.length) out.push(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// marks 坐标空间 = 上传图片像素空间:按「画布自然宽 / 坐标空间宽」换算(画布=照片自然尺寸时比值为 1)
|
||||
function vizScaleMarks(marks, scale) {
|
||||
var out = [];
|
||||
for (var i = 0; i < marks.length; i++) {
|
||||
var m = marks[i], nm = { color: m.color, polygons: [], rects: [] };
|
||||
for (var p = 0; p < m.polygons.length; p++) {
|
||||
var pts = [];
|
||||
for (var q = 0; q < m.polygons[p].length; q++) pts.push([m.polygons[p][q][0] * scale, m.polygons[p][q][1] * scale]);
|
||||
nm.polygons.push(pts);
|
||||
}
|
||||
for (var r = 0; r < m.rects.length; r++) {
|
||||
var rc = m.rects[r];
|
||||
nm.rects.push({ left: rc.left * scale, top: rc.top * scale, width: rc.width * scale, height: rc.height * scale });
|
||||
}
|
||||
out.push(nm);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
/* viz-pure-end */
|
||||
|
||||
// face_maps 值兼容:已是 dataURL 直接用;裸 base64 按魔数补前缀;其它一律视为缺失(优雅降级)
|
||||
function vizMapURL(v) {
|
||||
if (typeof v !== 'string' || !v) return null;
|
||||
if (v.indexOf('data:image/') === 0) return v;
|
||||
if (v.indexOf('/9j/') === 0) return 'data:image/jpeg;base64,' + v;
|
||||
if (v.indexOf('iVBOR') === 0) return 'data:image/png;base64,' + v;
|
||||
return null;
|
||||
}
|
||||
|
||||
function vizLoadImage(url, done) {
|
||||
var img = new Image();
|
||||
img.onload = function () { done(img.naturalWidth ? img : null); };
|
||||
img.onerror = function () { done(null); };
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
// canvas 合成:照片绘制到自然尺寸 → (可选压暗)→ (可选盖透明增强图层)→ 画坐标标记 → dataURL 挂到卡片 <img>
|
||||
function vizCompose(photoURL, overlayURL, marks, imgEl, opts) {
|
||||
vizLoadImage(photoURL, function (photo) {
|
||||
if (!photo) return; // 照片不可用:保留占位
|
||||
var finish = function (overlay) {
|
||||
var w = photo.naturalWidth, h = photo.naturalHeight;
|
||||
var cv = document.createElement('canvas');
|
||||
cv.width = w; cv.height = h;
|
||||
var ctx = cv.getContext('2d');
|
||||
ctx.drawImage(photo, 0, 0, w, h);
|
||||
if (opts && opts.dim) { ctx.fillStyle = 'rgba(0,0,0,' + opts.dim + ')'; ctx.fillRect(0, 0, w, h); } // 压暗底色让增强线可读
|
||||
if (overlay) ctx.drawImage(overlay, 0, 0, w, h);
|
||||
var scaled = vizScaleMarks(marks, w / photo.naturalWidth);
|
||||
var lw = Math.max(3, Math.round(w / 200)); // 线宽随图走,卡片缩略后仍可见
|
||||
var minBox = Math.round(w / 36); // 过小标记放大到最小框,保证缩略可见
|
||||
for (var i = 0; i < scaled.length; i++) {
|
||||
var m = scaled[i];
|
||||
ctx.strokeStyle = m.color;
|
||||
ctx.fillStyle = m.color;
|
||||
ctx.lineWidth = lw;
|
||||
var p, q, pts, rc;
|
||||
for (p = 0; p < m.polygons.length; p++) {
|
||||
pts = m.polygons[p];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0][0], pts[0][1]);
|
||||
for (q = 1; q < pts.length; q++) ctx.lineTo(pts[q][0], pts[q][1]);
|
||||
ctx.closePath();
|
||||
ctx.globalAlpha = 0.22;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
for (p = 0; p < m.rects.length; p++) {
|
||||
rc = m.rects[p];
|
||||
var rw = Math.max(rc.width, minBox), rh = Math.max(rc.height, minBox);
|
||||
var cx = rc.left + rc.width / 2, cy = rc.top + rc.height / 2;
|
||||
ctx.strokeRect(cx - rw / 2, cy - rh / 2, rw, rh);
|
||||
}
|
||||
}
|
||||
try { imgEl.src = cv.toDataURL('image/jpeg', 0.9); imgEl.hidden = false; } catch (e) { /* 合成失败保留占位 */ }
|
||||
};
|
||||
if (overlayURL) vizLoadImage(overlayURL, finish);
|
||||
else finish(null);
|
||||
});
|
||||
}
|
||||
|
||||
// 报告页 6 张检测图统一入口:任一输入缺失 → 该卡保留既有占位(不报错)
|
||||
function renderViz(payload, frontPhoto) {
|
||||
var result = payload && payload.result;
|
||||
var maps = (result && result.face_maps) || {};
|
||||
for (var key in VIZ_CAT) {
|
||||
var cfg = VIZ_CAT[key];
|
||||
var imgEl = $(cfg.img);
|
||||
if (!imgEl) continue;
|
||||
if (cfg.map) { // 整幅图层(色沉图/红区图):图层缺失 → 保留占位
|
||||
var u = vizMapURL(maps[cfg.map]);
|
||||
if (u) { imgEl.src = u; imgEl.hidden = false; }
|
||||
continue;
|
||||
}
|
||||
if (cfg.overlay) { // 透明增强图层(皱纹):有照片则合成(可按 cfg.dim 压暗),无照片单挂图层;图层缺失 → 保留占位
|
||||
var ov = vizMapURL(maps[cfg.overlay]);
|
||||
if (!ov) continue;
|
||||
if (!frontPhoto) { imgEl.src = ov; imgEl.hidden = false; continue; }
|
||||
vizCompose(frontPhoto, ov, [], imgEl, { dim: cfg.dim });
|
||||
continue;
|
||||
}
|
||||
// 照片类(轮廓/光滑/痘痘):无照片 → 保留占位;有坐标则 canvas 叠加,无坐标纯照片
|
||||
if (!frontPhoto) continue;
|
||||
var marks = cfg.marks ? vizCollectMarks(cfg.marks, result) : [];
|
||||
if (!marks.length) { imgEl.src = frontPhoto; imgEl.hidden = false; continue; }
|
||||
vizCompose(frontPhoto, null, marks, imgEl);
|
||||
}
|
||||
}
|
||||
|
||||
function renderReport(vm, frontPhoto, sidePhoto, payload) {
|
||||
// 头部:综合评分(vm.total 缺失时回退三维度 S 均分×10)
|
||||
var total = vm.total;
|
||||
if (total == null) {
|
||||
@@ -630,8 +798,9 @@
|
||||
// 三个 Tab 面板:各所辖两个分类(S + 档位徽章 + 话术正文)
|
||||
var keys = ['contour', 'wrinkles', 'smooth', 'tone', 'sens', 'acne'];
|
||||
for (var i = 0; i < keys.length; i++) setCat(keys[i], vm.cats[keys[i]]);
|
||||
if (frontPhoto) $('#ditem-img1').src = frontPhoto;
|
||||
if (sidePhoto) $('#ditem-img2').src = sidePhoto;
|
||||
|
||||
// 检测可视化:轮廓=正脸实拍,其余按 VIZ_CAT 取 face_maps 图层或叠加 marks(payload 缺失时各卡保留占位)
|
||||
renderViz(payload, frontPhoto);
|
||||
|
||||
// 降级标注(下颌线未参与评分等,非空才展示)
|
||||
var notesEl = $('#vm-notes');
|
||||
@@ -776,13 +945,17 @@
|
||||
/* ─── 初始化 ─── */
|
||||
renderCamStep();
|
||||
// 仅调试用:?mock=1&screen=report 直渲 mock 报告页(供无头浏览器截图验证布局,非正式入口)
|
||||
// 有 MOCK_FIXTURE 时走 fixture(检测可视化同步出图),否则回退 SkinScore 内置样本
|
||||
if (IS_MOCK && /[?&]screen=report/.test(location.search) && typeof SkinScore !== 'undefined') {
|
||||
showScreen('s-report');
|
||||
try {
|
||||
renderReport(SkinScore.buildViewModel(SkinScore.MOCK_RESULT, SkinScore.MOCK_SIDE, {
|
||||
var fx = window.MOCK_FIXTURE;
|
||||
var mockRes = fx ? fx.result : SkinScore.MOCK_RESULT;
|
||||
var mockSide = fx ? fx.side : SkinScore.MOCK_SIDE;
|
||||
renderReport(SkinScore.buildViewModel(mockRes, mockSide, {
|
||||
realAge: +($('#f-age').value || 0) || 28, // 调试档案缺省 28 岁
|
||||
skinType: SKINS[skinIndex].name
|
||||
}), null, null);
|
||||
}), fx ? fx.photos[0] : null, null, fx ? { result: mockRes, side: mockSide, face_rectangle: fx.face_rectangle } : null);
|
||||
} catch (e) { /* 调试入口异常不阻塞正式流程 */ }
|
||||
}
|
||||
})();
|
||||
|
||||
+170
-52
@@ -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 + ' 失败');
|
||||
|
||||
Reference in New Issue
Block a user