fix(report): cap dimension scores and match zoom crops to thumbnails
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 皱纹三视图标注
|
||||
|
||||
- 报告皱纹页卡展示正面大图和两张侧脸缩略图,点击侧脸查看完整标注图。
|
||||
- 报告皱纹页卡展示正面大图和两张侧脸缩略图,点击查看大图时沿用缩略图的缩放、位移与裁切范围,并保留标注。
|
||||
- 每张照片只使用对应照片的皱纹增强图或真实标注坐标,不将正面标注投射到侧脸。
|
||||
- 新检测优先复用三视图接口已提供的侧脸标注;缺失时分别以左右侧脸为主图补取,每侧最多调用一次。通常新增两次计费调用。
|
||||
- 补取串行执行,每次前间隔 4 秒,每侧最多等待 20 秒;整个检测按 120 秒预算限制补取,给现有 150 秒前端超时预留余量。因此生成报告会增加等待时间。
|
||||
|
||||
+26
-2
@@ -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
@@ -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
@@ -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
@@ -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)', '原始肌龄(推荐依据)', '前端展示肌龄',
|
||||
|
||||
@@ -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');
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
@@ -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='旧轮廓文案';
|
||||
|
||||
@@ -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 列逐字断言兜底)
|
||||
|
||||
@@ -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('/'));
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user