feat(h5): update skin scoring, age display and capture countdown

This commit is contained in:
xsl
2026-10-15 00:20:48 +08:00
parent d8c67762da
commit 60ae6c97ef
37 changed files with 5110 additions and 1632 deletions
+82 -51
View File
@@ -216,8 +216,13 @@
};
}
// 皱纹:P = 100 − max(12分区 wrinkle_score);分区 severity≥2 锁79、≥3 锁74
// 皱纹:直接使用接口整体评分,不反向、不按最差分区或严重度限分。
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;
@@ -237,19 +242,11 @@
if (s2 == null) continue;
regions.push({ cn: k.replace(/(_wrinkle_info|_fold_info)$/, ''), score: s2, sev: val(node.wrinkle_severity_level) });
}
if (!regions.length) { notes.push('皱纹:12 分区 wrinkle_info 全部缺失,皱纹分类不参与评分'); return { P: null, vars: {} }; }
var worst = regions[0], maxSev = null;
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 P = 100 - worst.score;
if (maxSev != null) {
if (maxSev >= 3) P = Math.min(P, 74);
else if (maxSev >= 2) P = Math.min(P, 79);
} else {
notes.push('皱纹:分区 severity 不可得,上限锁未执行');
}
var wrCnt = cntField(r, 'wrinkle_count');
var flCnt = cntField(r, 'fine_line'); // 真实键名为 fine_line(分区计数对象),兼容 fine_line_count
if (flCnt == null) flCnt = cntField(r, 'fine_line_count');
@@ -258,8 +255,9 @@
vars: {
wr_cnt: wrCnt,
fl_cnt: flCnt,
worst_wr_region: worst.cn,
wr_max: worst.score,
overall_wrinkle_score: P,
worst_wr_region: worst ? worst.cn : null,
wr_max: worst ? worst.score : null,
max_sev: maxSev
}
};
@@ -325,10 +323,12 @@
};
}
// 肤色水润:P = 0.5×(melanin_score−melasma×10−freckle×5) + 0.5×water_score
// 肤色项 = 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');
@@ -342,6 +342,11 @@
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) {
@@ -351,7 +356,7 @@
} else {
notes.push('water_score 缺失,肤色水润评分缺水润项');
}
var items = [melaninItem, waterItem], acc = 0, n = 0, i;
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 权重归一
@@ -376,6 +381,12 @@
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,
@@ -415,40 +426,45 @@
// 痘痘:P = min(acne_score 分级映射, 100−计数扣分);扣分封顶50
function acneGrade(score) { return score >= 95 ? 100 : (score >= 85 ? 85 : (score >= 75 ? 70 : 55)); }
function calcAcne(r, notes) {
var 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 cc = cnt(r.closed_comedones); // 闭口 ×1
var pa = cnt(r.acne); // 丘疹 ×2
var pu = cnt(r.acne_pustule); // 脓包 ×4
var no = cnt(r.acne_nodule); // 结节 ×6
var mk = cnt(r.acne_mark); // 痘印 ×1.5
var counts = [cc, pa, pu, no, mk], allNull = true, i;
for (i = 0; i < 5; i++) if (counts[i] != null) { allNull = false; break; }
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: {} };
return { P: null, vars: vars };
}
if (score == null) notes.push('acne_score 缺失,痘痘评分仅由计数扣分决定');
var deduct = Math.min(50, (cc || 0) * 1 + (pa || 0) * 2 + (pu || 0) * 4 + (no || 0) * 6 + (mk || 0) * 1.5);
var cand = [];
if (score != null) cand.push(acneGrade(score));
if (!allNull) cand.push(100 - deduct);
var detail = joinParts([
cc ? '闭口粉刺' + cc + '处' : '',
pa ? '丘疹' + pa + '处' : '',
pu ? '脓包' + pu + '处' : '',
no ? '结节' + no + '处' : '',
mk ? '痘印' + mk + '处' : ''
], '、');
var total = 0;
for (i = 0; i < 5; i++) total += counts[i] || 0;
return {
P: Math.min.apply(null, cand),
vars: {
acne_cnt: allNull ? null : total,
acne_detail: detail
}
};
return { P: Math.min.apply(null, cand), vars: vars };
}
/* ─── 话术库(V2.2 第七章逐字;变量缺失省略对应分句) ─── */
@@ -571,6 +587,8 @@
+ '以闭口粉刺、丘疹为主,局部存在炎症倾向。建议加强温和清洁与控油,避免挤压,配合抗炎舒缓护理。';
// 良好档:所有计数字段缺失(仅靠 acne_score 分级得高 P)时不能声称「未检出」,输出缺数据提示句
if (v.acne_cnt == null) return '本次检测未获取皮损计数数据,仅按痤疮分级评估。建议维持清洁与控油节奏,注意作息与饮食。';
if (v.acne_counts_complete === false) return (found ? found + '。' : '') + '部分类型皮损计数未获取,请结合检测图片查看。';
if (found) return found + '。建议维持清洁与控油节奏,注意作息与饮食。';
return '未检出痤疮与皮损。建议维持清洁与控油节奏,注意作息与饮食。';
}
@@ -701,9 +719,11 @@
var notes = [];
var realAge = val(profile.realAge);
// 生物可视年龄直接使用算法原始肌龄,不再按实际年龄钳制。
// 仅展示年龄限制在实际年龄±5岁,原始肌龄继续用于产品推荐。
var skinAge = val(r.skin_age);
var skinAgeDisplay = skinAge, ageClampNote = '';
var skinAgeDisplay = reportSkinAge({ skinAgeRaw: skinAge, realAge: realAge });
var ageClampNote = skinAgeDisplay != null && skinAgeDisplay !== skinAge
? '生物可视年龄已按实际年龄±5岁限制展示;接口原始肌龄保留用于推荐。' : '';
if (realAge == null) {
notes.push('未提供实际年龄:肌龄展示与产品推荐均使用接口原始肌龄');
}
@@ -714,7 +734,7 @@
var cats = {}, sList = [], i, def, res;
for (i = 0; i < CAT_DEFS.length; i++) {
def = CAT_DEFS[i];
res = def.key === 'contour' ? def.calc(r, sideResults, notes) : def.calc(r, notes);
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, // 原始模板变量供新版展示层使用,不参与评分规则变更
@@ -828,6 +848,8 @@
return {
total: weightedTotal(dims),
totalRule: 'dimensions-433-v1',
wrinkleScoreRule: 'api-overall-v1',
acneScoreRule: 'three-view-type-max-v1',
skinAgeRaw: skinAge,
skinAgeDisplay: skinAgeDisplay,
ageClampNote: ageClampNote,
@@ -851,6 +873,7 @@
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,
@@ -908,11 +931,14 @@
right: { right_jawline_info: { right_jawline_angle: 113.4, right_jawline_angle_level: 1, right_jawline_quality: 1 } } // 偏离 2.6° → 94.8
};
// Historical snapshots may retain an old clamped display value. Read their
// original age without modifying the stored snapshot or recalculating copy.
// Apply the current display rule to new and historical reports without
// modifying their raw age, stored snapshot, copy or recommendation.
function reportSkinAge(vm, result) {
if (vm && Object.prototype.hasOwnProperty.call(vm, 'skinAgeRaw')) return val(vm.skinAgeRaw);
return val(result && result.skin_age);
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 {
weightedTotal: weightedTotal,
@@ -953,7 +979,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
var vm = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25, skinType: '混合偏干肌肤' });
ok(approx(vm.cats.smooth.P, 81.7), '光滑P = 0.4×73+0.3×88+0.3×87 = 81.7(AR列对拍)');
ok(approx(vm.cats.tone.P, 81.5), '肤色水润P = 0.5×80+0.5×83 = 81.5(AR列对拍)');
ok(vm.cats.wrinkles.P === 23 && vm.cats.wrinkles.level === '预警', '皱纹P = 100−77 = 23(severity=3 锁74 不再抬升)');
ok(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');
@@ -977,9 +1003,10 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
var rk;
for (rk in r1) if (/_wrinkle_info$|_fold_info$/.test(rk)) r1[rk].wrinkle_score = 5; // 全部压低 → 自然 P=95
r1.right_nasolabial_fold_info.wrinkle_severity_level = 3;
ok(SS.buildViewModel(r1, SS.MOCK_SIDE, { realAge: 25 }).cats.wrinkles.P === 74, '皱纹分区 severity=3 → 锁 74');
r1.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 === 79, '皱纹分区 severity=2 → 锁 79');
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');
@@ -999,7 +1026,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
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 === 40 && vm3b.ageClampNote === '' && vm3b.skinAgeRaw === 40, '原始肌龄40不因实际年龄25而改变');
ok(vm3b.skinAgeDisplay === 30 && vm3b.ageClampNote !== '' && vm3b.skinAgeRaw === 40, '实际年龄25、原始肌龄40:展示30,原值不改变');
console.log('== T4 最差项不再覆盖肤龄推荐 ==');
var r4 = clone(SS.MOCK_RESULT);
@@ -1180,6 +1207,10 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
} 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))');