fix(report): cap dimension scores and match zoom crops to thumbnails

This commit is contained in:
xsl
2026-10-15 20:17:07 +08:00
parent 482fb4280d
commit 5632ac43d7
13 changed files with 91 additions and 24 deletions
@@ -1,6 +1,6 @@
# 皱纹三视图标注
- 报告皱纹页卡展示正面大图和两张侧脸缩略图,点击侧脸查看完整标注图。
- 报告皱纹页卡展示正面大图和两张侧脸缩略图,点击查看大图时沿用缩略图的缩放、位移与裁切范围,并保留标注。
- 每张照片只使用对应照片的皱纹增强图或真实标注坐标,不将正面标注投射到侧脸。
- 新检测优先复用三视图接口已提供的侧脸标注;缺失时分别以左右侧脸为主图补取,每侧最多调用一次。通常新增两次计费调用。
- 补取串行执行,每次前间隔 4 秒,每侧最多等待 20 秒;整个检测按 120 秒预算限制补取,给现有 150 秒前端超时预留余量。因此生成报告会增加等待时间。
+26 -2
View File
@@ -1300,7 +1300,7 @@
el.classList.toggle('dim-bad', has && rank === 'worst');
box.classList.toggle('worst', has && rank === 'worst');
box.classList.toggle('best', has && rank === 'best');
box.querySelector('.dim-bar i').style.width = has ? Math.max(0, Math.min(100, dim.S * 10)) + '%' : '0';
box.querySelector('.dim-bar i').style.width = has ? Math.max(0, SkinScore.capDimensionScore(dim.S) * 10) + '%' : '0';
}
function setCat(key, cat) {
@@ -1733,6 +1733,30 @@
});
/* ─── 报告页:每张维度卡各自的子 Tab;点检测图放大,再点返回 ─── */
function reportZoomSource(img, frame) {
// Bake the thumbnail's CSS zoom/translation and clipping into the large
// image. Annotations are already composited with their own photograph.
try {
var box=img.getBoundingClientRect(), windowBox=frame.getBoundingClientRect();
if (!img.naturalWidth || !box.width || !windowBox.width || !windowBox.height) return img.src;
var style=getComputedStyle(img), scaleX=box.width/img.naturalWidth, scaleY=box.height/img.naturalHeight;
if (style.objectFit==='cover' || style.objectFit==='contain') {
scaleX=scaleY=style.objectFit==='cover' ? Math.max(scaleX,scaleY) : Math.min(scaleX,scaleY);
}
var width=img.naturalWidth*scaleX, height=img.naturalHeight*scaleY;
var position=style.objectPosition.split(' ');
var px=position[0].endsWith('%') ? parseFloat(position[0])/100 : .5;
var py=position[1] && position[1].endsWith('%') ? parseFloat(position[1])/100 : .5;
var ratio=Math.min(4,1600/Math.max(windowBox.width,windowBox.height));
var cv=document.createElement('canvas');
cv.width=Math.round(windowBox.width*ratio);cv.height=Math.round(windowBox.height*ratio);
var ctx=cv.getContext('2d');
ctx.fillStyle=getComputedStyle(frame).backgroundColor;ctx.fillRect(0,0,cv.width,cv.height);
ctx.drawImage(img,(box.left-windowBox.left+(box.width-width)*px)*ratio,
(box.top-windowBox.top+(box.height-height)*py)*ratio,width*ratio,height*ratio);
return cv.toDataURL('image/jpeg',.95);
} catch (_) { return img.src; }
}
document.querySelectorAll('.card-detail').forEach(function (card, group) {
card.querySelector('.detail-tabs').setAttribute('role', 'tablist');
var tabs = Array.from(card.querySelectorAll('.dtab')), panels = Array.from(card.querySelectorAll('.dpanel'));
@@ -1781,7 +1805,7 @@
var sideButton = e.target.closest('.contour-side-photo, .wrinkle-side-photo');
var img = sideButton ? sideButton.querySelector('img') : e.target.closest('.ditem-img');
if (img && img.src && !img.hidden && img.closest('.dpanel').classList.contains('active')) {
$('#zoom-img').src = img.src;
$('#zoom-img').src = reportZoomSource(img, sideButton || img.closest('.viz-frame'));
openOverlay('p-zoom');
history.pushState({ screen: currentScreen, overlay: 'p-zoom' }, '');
}
+8 -5
View File
@@ -19,10 +19,10 @@
var focusTips = { firm: '强化紧塑力,提拉轮廓抚平细纹。', vita: '提升透亮感,补水提亮加强防晒。', stab: '增强稳韧力,舒缓泛红强韧屏障。' };
function numeric(value) { return typeof value === 'number' && isFinite(value); }
function dimensionScore(value) {
return numeric(value) ? (Math.round((value + 1e-9) * 10) / 10).toFixed(1) : '-';
return numeric(value) ? Math.min(9.9, Math.round((value + 1e-9) * 10) / 10).toFixed(1) : '-';
}
var gradeRule = 'display-score-8-9-v1';
var copyRule = 'wrinkles-excellent-acne-detected-water-v2';
var copyRule = 'wrinkles-excellent-acne-detected-water-contour-v3';
function scoreOfP(P) {
return numeric(P) ? Math.round((Math.max(7, Math.min(10, 7 + 3 * P / 100)) + 1e-9) * 100) / 100 : null;
}
@@ -118,9 +118,12 @@
level = levelForScore(viewS);
var template = valid && level ? Copy.conclusion[d[0]][level] : '';
if (d[0] === 'contour') {
var severity = vars.dark_circle_sev;
var description = numeric(severity) ? ['无明显黑眼圈', '黑眼圈轻度', '黑眼圈中度', '黑眼圈重度'][severity] : null;
template = template.replace(/黑眼圈轻\/中\/重度(根据分数给出判断)/g, description || '黑眼圈数据未获取');
// Mention only positively detected eye problems, independent of the
// contour grade. Missing data must not imply absence or presence.
var eyeNotes = [], pouch = vars.eye_pouch, severity = vars.dark_circle_sev;
if (numeric(pouch) && pouch % 1 === 0 && pouch >= 1 && pouch <= 3) eyeNotes.push(pouch === 1 ? '眼周眼袋初显' : '眼袋明显');
if (numeric(severity) && severity % 1 === 0 && severity >= 1 && severity <= 3) eyeNotes.push(['','黑眼圈轻度','黑眼圈中度','黑眼圈重度'][severity]);
template = template.replace(/(?:眼袋明显|眼周眼袋初显|眼周无明显眼袋问题)、黑眼圈轻\/中\/重度(根据分数给出判断);/g, eyeNotes.length ? eyeNotes.join('、') + ';' : '');
if (vars.la == null || vars.ra == null) template = template.replace(/左右下颌线角度(?:分别为左|左) \{la\}°\/ 右 \{ra\}°[;;]/g, '');
}
if (d[0] === 'color') {
+4 -2
View File
@@ -63,6 +63,7 @@
}
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) {
@@ -71,7 +72,7 @@
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 += score * weights[i];
total += capDimensionScore(score) * weights[i];
}
return Math.round(total + 1e-9);
}
@@ -757,7 +758,7 @@
}
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: sShow(dP), level: levelOf(dP) };
dims[d.key] = { name: d.name, P: dP, S: capDimensionScore(sShow(dP)), level: levelOf(dP) };
}
// 均分(判档用未四舍五入原值)与最差分类(并列按 CAT_DEFS 顺序取前者)
@@ -942,6 +943,7 @@
return Math.max(actual - 5, Math.min(actual + 5, raw));
}
return {
capDimensionScore: capDimensionScore,
weightedTotal: weightedTotal,
reportSkinAge: reportSkinAge,
buildViewModel: buildViewModel,
+5 -5
View File
@@ -545,9 +545,9 @@ function displaySkinAge(rec) {
function displayTotalScore(rec) {
return SkinScore.weightedTotal(rec.dims);
}
function displayScoreLevels(scores) {
function displayScoreLevels(scores, capDimensions = false) {
return scores ? Object.fromEntries(Object.entries(scores).map(([key, value]) => [key,
Object.assign({}, value, { level: ReportUI.internalLevel(value && value.S) })])) : scores;
Object.assign({}, value, { S:capDimensions ? SkinScore.capDimensionScore(value && value.S) : value && value.S, level: ReportUI.internalLevel(value && value.S) })])) : scores;
}
function listItem(rec) {
return {
@@ -560,7 +560,7 @@ function listItem(rec) {
fail_code: rec.fail_code, fail_msg: rec.fail_msg,
total_score: displayTotalScore(rec), total_score_rule: 'dimensions-433-v1', skin_age: rec.skin_age,
skin_age_display: displaySkinAge(rec), recommendation: rec.recommendation || null,
dims: displayScoreLevels(rec.dims), grade_rule: ReportUI.gradeRule, group: rec.group,
dims: displayScoreLevels(rec.dims,true), grade_rule: ReportUI.gradeRule, group: rec.group,
essence: rec.essence ? rec.essence.name : null,
cost_ms: rec.cost_ms,
retry_of: rec.retry_of, retry_count: rec.retry_count || 0,
@@ -661,7 +661,7 @@ function handleRecordDetail(req, res, id) {
sendJson(res, 200, {
ok: true,
record: Object.assign({}, rec, { total_score: displayTotalScore(rec), total_score_rule: 'dimensions-433-v1', skin_age_display: displaySkinAge(rec),
dims: displayScoreLevels(rec.dims), sub_scores: displayScoreLevels(rec.sub_scores), grade_rule: ReportUI.gradeRule, presentation }),
dims: displayScoreLevels(rec.dims,true), sub_scores: displayScoreLevels(rec.sub_scores), grade_rule: ReportUI.gradeRule, presentation }),
raw_json: rawJson,
photos: photoUrls(rec),
versions: {
@@ -818,7 +818,7 @@ function handleExport(req, res, url) {
const filtered = readAllRecords().filter(r => matchQuery(r, q))
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
const dimP = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].P : null;
const dimS = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].S : null;
const dimS = (r, d) => (r.dims && r.dims[d]) ? SkinScore.capDimensionScore(r.dims[d].S) : null;
const dimLv = (r, d) => (r.dims && r.dims[d]) ? ReportUI.levelForScore(r.dims[d].S) : null;
const header = ['记录ID', '创建时间(北京时间)', '年龄', '性别', '地区', '日常肤质', '拍摄状态', '上传状态', '检测状态',
'失败环节', '失败码', '失败信息', '综合得分(4:3:3)', '原始肌龄(推荐依据)', '前端展示肌龄',
+17
View File
@@ -0,0 +1,17 @@
'use strict';
const assert=require('node:assert/strict'),S=require('../js/skin-score'),UI=require('../js/report-ui');
const raw=structuredClone(S.MOCK_RESULT),base=S.buildViewModel(raw,S.MOCK_SIDE,{});
for(const P of [20,50,90])for(const pouch of [0,1,2,3,null,9])for(const circle of [0,1,2,3,null,9,1.5]) {
const vm=structuredClone(base);vm.cats.contour.P=P;Object.assign(vm.cats.contour.vars,{eye_pouch:pouch,dark_circle_sev:circle});
const before=JSON.stringify(vm),text=UI.build(vm,raw).views.find(v=>v.key==='contour').text;
assert.equal(text.includes('眼袋'),[1,2,3].includes(pouch));
assert.equal(text.includes('黑眼圈'),[1,2,3].includes(circle));
assert(!/无明显眼袋|无明显黑眼圈|数据未获取|[;、]{2}|[{}]/.test(text));
assert.equal(JSON.stringify(vm),before);
}
const saved=UI.build(base,raw);saved.copyRule='wrinkles-excellent-acne-detected-water-v2';saved.views[0].text='眼周无明显眼袋问题、无明显黑眼圈;';
base.cats.contour.vars.eye_pouch=0;base.cats.contour.vars.dark_circle_sev=2;
const before=JSON.stringify(saved),display=UI.forDisplay(base,raw,saved);
assert(!display.views[0].text.includes('眼袋'));assert(display.views[0].text.includes('黑眼圈中度'));
assert.equal(JSON.stringify(saved),before);assert.deepEqual(display.product,saved.product);
console.log('PASS contour eye copy: 126 grade/detection combinations, only positive problems, missing/invalid omitted, old report refresh and immutable scores/snapshots');
+7 -4
View File
@@ -40,7 +40,9 @@ try {
assert(offset!=null&&Number(offset)>=0&&Number(offset)<=.090001);
const fullFront=await page.locator('#ditem-img1').getAttribute('src');
await page.locator('#ditem-img1').click();
assert.equal(await page.locator('#zoom-img').getAttribute('src'),fullFront,'front zoom still uses uncropped source');
assert.notEqual(await page.locator('#zoom-img').getAttribute('src'),fullFront,'front zoom follows clipped thumbnail');
await page.waitForFunction(()=>document.querySelector('#zoom-img').complete);
assert(Math.abs(await page.locator('#zoom-img').evaluate(e=>e.naturalWidth/e.naturalHeight)-215/250.2)<.003);
await page.locator('#p-zoom').click();
const geo=await page.locator('.contour-side-photo').first().evaluate(e=>{const r=e.getBoundingClientRect();return{w:r.width,h:r.height,fit:getComputedStyle(e.querySelector('img')).objectFit};});
assert(Math.abs(geo.h/geo.w-92.25/104.35)<.002);assert.equal(geo.fit,'cover');
@@ -60,11 +62,12 @@ try {
assert(Math.abs(spacing.vertical/spacing.u-7.56)<.1);
await page.getByRole('button',{name:'查看左脸完整大图'}).click();
assert(await page.locator('#p-zoom').evaluate(e=>e.classList.contains('show')));
assert.equal(await page.locator('#zoom-img').getAttribute('src'),left);
assert.equal(await page.locator('#zoom-img').evaluate(e=>e.naturalWidth/e.naturalHeight),4/3);
assert.notEqual(await page.locator('#zoom-img').getAttribute('src'),left);
await page.waitForFunction(()=>document.querySelector('#zoom-img').complete);
assert(Math.abs(await page.locator('#zoom-img').evaluate(e=>e.naturalWidth/e.naturalHeight)-geo.w/geo.h)<.003);
await page.locator('#p-zoom').click();
await page.getByRole('button',{name:'查看右脸完整大图'}).click();
assert.equal(await page.locator('#zoom-img').getAttribute('src'),right);
assert.notEqual(await page.locator('#zoom-img').getAttribute('src'),right);
await page.keyboard.press('Escape');
assert(!await page.locator('#p-zoom').evaluate(e=>e.classList.contains('show')));
await page.locator('.card-detail').first().locator('[data-tab="1"]').click();
+16
View File
@@ -0,0 +1,16 @@
'use strict';
const assert=require('node:assert/strict'),S=require('../js/skin-score'),UI=require('../js/report-ui');
for(const score of [9.9,9.94,9.95,9.99,10]) {
assert.equal(S.capDimensionScore(score),9.9);
assert.equal(UI.dimensionScore(score),'9.9','rounding must never display 10.0');
}
assert.equal(UI.dimensionScore(9.34),'9.3');assert.equal(S.capDimensionScore(null),null);
const r={eye_pouch_severity:0,score_info:{wrinkle_score:100,blackhead_score:100,rough_score:100,melanin_score:100,dark_circle_score:100,water_score:100,sensitivity_score:100,acne_score:100},red_spot:{red_spot_area:0}};
for(const key of ['closed_comedones','acne','acne_pustule','acne_nodule','acne_mark'])r[key]={count:0};
const before=JSON.stringify(r),vm=S.buildViewModel(r,null,{});
for(const dim of Object.values(vm.dims)){assert.equal(dim.P,100);assert.equal(dim.S,9.9);}
for(const cat of Object.values(vm.cats)){assert.equal(cat.P,100);assert.equal(cat.S,10,'small categories unchanged');}
assert.equal(vm.total,99);assert.equal(JSON.stringify(r),before);
const legacy={firm:{S:10},vita:{S:9.99},stab:{S:9.97}},saved=JSON.stringify(legacy);
assert.equal(S.weightedTotal(legacy),99);assert.equal(JSON.stringify(legacy),saved,'old stored scores not rewritten');
console.log('PASS dimension cap: 9.9 for all dimensions, no rounding to 10, subcategories/raw data unchanged, total99 and immutable historical scores');
+1 -1
View File
@@ -27,7 +27,7 @@ r.score_info.wrinkle_score = 76;
const vm = SS.buildViewModel(r,SS.MOCK_SIDE,{}), ui = UI.build(vm,r);
assert.equal(UI.dimensionScore(vm.dims.firm.S),'9.3');
assert.equal(ui.views.find(v=>v.key==='contour').level,'优秀');
assert(ui.views.find(v=>v.key==='contour').text.includes('眼周无明显眼袋问题'),'contour copy remains original by user request');
assert(ui.views.find(v=>v.key==='contour').text.includes('眼袋明显'),'contour eye problem follows actual detection, not aggregate grade');
const old = structuredClone(ui);
delete old.gradeRule;
old.views[0].level='改善';old.views[0].text='旧轮廓文案';
+2
View File
@@ -116,6 +116,8 @@ function main() {
tr.summary = tr.summary.replace('六分类S均分9.54', '六分类S均分9.53');
// Restored original minimum: compare combined tone with smoothness.
}
// Approved dimension cap; keep original category scores and fixture files.
for (const dim of Object.values(tr.dims)) dim.S = Math.min(9.9,dim.S);
var vm = SkinScore.buildViewModel(fixture.result, fixture.side, tr.profile);
// 下颌线角度 AP/AQ:fixture 保真校验(引擎消费角度后由 L 列逐字断言兜底)
+1 -1
View File
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
const Score = require('../js/skin-score');
const dims = (firm, vita, stab) => ({ firm: { S: firm }, vita: { S: vita }, stab: { S: stab } });
for (const [scores, total] of [
[[8, 9, 8.5], 85], [[10, 10, 10], 100], [[7, 7, 7], 70],
[[8, 9, 8.5], 85], [[10, 10, 10], 99], [[7, 7, 7], 70],
[[10, 7, 7], 82], [[7, 10, 7], 79], [[7, 7, 10], 79],
[[8.01, 8.82, 8], 83], [[7.69, 8.72, 9.1], 84]
]) assert.equal(Score.weightedTotal(dims(...scores)), total, scores.join('/'));
+1 -1
View File
@@ -27,5 +27,5 @@ const base=SS.buildViewModel(sample(65),SS.MOCK_SIDE,{});
const changed=sample(65);changed.left_mouth_wrinkle_info={wrinkle_score:0,wrinkle_severity_level:0};
assert.equal(SS.buildViewModel(changed,SS.MOCK_SIDE,{}).cats.wrinkles.P,base.cats.wrinkles.P,'region severity has no scoring effect');
assert.equal(UI.dimensionScore(8.95),'9.0');assert.equal(UI.dimensionScore(9.45),'9.5');
assert.equal(UI.dimensionScore(9.97),'10.0');assert.equal(UI.dimensionScore(null),'-');
assert.equal(UI.dimensionScore(9.97),'9.9');assert.equal(UI.dimensionScore(null),'-');
console.log('PASS overall wrinkles: direct 0..100, no inversion/region caps, missing/invalid data, firm min, source immutability, half-up dimension display');
+2 -2
View File
@@ -35,8 +35,8 @@ assert.deepEqual(layout.sideA,layout.sideB,'same side image scaling and position
assert.equal(layout.offsetA,layout.offsetB,'same face crop offset; overlay moves with photo');
const leftSrc=await p.locator('#wrinkle-left').getAttribute('src'),rightSrc=await p.locator('#wrinkle-right').getAttribute('src');
assert.notEqual(leftSrc,left);assert.notEqual(rightSrc,right);assert.notEqual(leftSrc,rightSrc);
await p.getByRole('button',{name:'查看左脸皱纹检测大图'}).click();assert.equal(await p.locator('#zoom-img').getAttribute('src'),leftSrc);await p.locator('#p-zoom').click();
await p.getByRole('button',{name:'查看右脸皱纹检测大图'}).click();assert.equal(await p.locator('#zoom-img').getAttribute('src'),rightSrc);await p.locator('#p-zoom').click();
await p.getByRole('button',{name:'查看左脸皱纹检测大图'}).click();assert.notEqual(await p.locator('#zoom-img').getAttribute('src'),leftSrc);await p.locator('#p-zoom').click();
await p.getByRole('button',{name:'查看右脸皱纹检测大图'}).click();assert.notEqual(await p.locator('#zoom-img').getAttribute('src'),rightSrc);await p.locator('#p-zoom').click();
}
await p.evaluate(x=>window.__wrinkleTest(x),{left,right,views:{}});
await p.waitForFunction(()=>!document.querySelector('#wrinkle-left').closest('figure').hidden&&!document.querySelector('#wrinkle-right').closest('figure').hidden);