feat(h5): update skin scoring, age display and capture countdown
This commit is contained in:
+82
-51
@@ -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))');
|
||||
|
||||
Reference in New Issue
Block a user