diff --git a/docs/皱纹三视图标注说明-2026-10-15.md b/docs/皱纹三视图标注说明-2026-10-15.md index 05629a8..b0fe2e7 100644 --- a/docs/皱纹三视图标注说明-2026-10-15.md +++ b/docs/皱纹三视图标注说明-2026-10-15.md @@ -1,6 +1,6 @@ # 皱纹三视图标注 -- 报告皱纹页卡展示正面大图和两张侧脸缩略图,点击侧脸查看完整标注图。 +- 报告皱纹页卡展示正面大图和两张侧脸缩略图,点击查看大图时沿用缩略图的缩放、位移与裁切范围,并保留标注。 - 每张照片只使用对应照片的皱纹增强图或真实标注坐标,不将正面标注投射到侧脸。 - 新检测优先复用三视图接口已提供的侧脸标注;缺失时分别以左右侧脸为主图补取,每侧最多调用一次。通常新增两次计费调用。 - 补取串行执行,每次前间隔 4 秒,每侧最多等待 20 秒;整个检测按 120 秒预算限制补取,给现有 150 秒前端超时预留余量。因此生成报告会增加等待时间。 diff --git a/h5/js/app.js b/h5/js/app.js index 5f0f3d8..1c77729 100644 --- a/h5/js/app.js +++ b/h5/js/app.js @@ -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' }, ''); } diff --git a/h5/js/report-ui.js b/h5/js/report-ui.js index bee6873..66a3f38 100644 --- a/h5/js/report-ui.js +++ b/h5/js/report-ui.js @@ -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') { diff --git a/h5/js/skin-score.js b/h5/js/skin-score.js index c7f0526..afda9c2 100644 --- a/h5/js/skin-score.js +++ b/h5/js/skin-score.js @@ -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, diff --git a/h5/server.js b/h5/server.js index de9bac8..253cc4c 100644 --- a/h5/server.js +++ b/h5/server.js @@ -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)', '原始肌龄(推荐依据)', '前端展示肌龄', diff --git a/h5/test/contour-eye-copy.js b/h5/test/contour-eye-copy.js new file mode 100644 index 0000000..26ea8f5 --- /dev/null +++ b/h5/test/contour-eye-copy.js @@ -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'); diff --git a/h5/test/contour-sides.mjs b/h5/test/contour-sides.mjs index fb237ba..7af5d7d 100644 --- a/h5/test/contour-sides.mjs +++ b/h5/test/contour-sides.mjs @@ -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(); diff --git a/h5/test/dimension-cap.js b/h5/test/dimension-cap.js new file mode 100644 index 0000000..d7a7c1a --- /dev/null +++ b/h5/test/dimension-cap.js @@ -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'); diff --git a/h5/test/display-grade.js b/h5/test/display-grade.js index a5d4670..37aad48 100644 --- a/h5/test/display-grade.js +++ b/h5/test/display-grade.js @@ -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='旧轮廓文案'; diff --git a/h5/test/regression.js b/h5/test/regression.js index 23c1bc6..f15840c 100644 --- a/h5/test/regression.js +++ b/h5/test/regression.js @@ -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 列逐字断言兜底) diff --git a/h5/test/weighted-total.js b/h5/test/weighted-total.js index 3803966..e35c9b1 100644 --- a/h5/test/weighted-total.js +++ b/h5/test/weighted-total.js @@ -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('/')); diff --git a/h5/test/wrinkle-overall.js b/h5/test/wrinkle-overall.js index 0da2f80..fed220d 100644 --- a/h5/test/wrinkle-overall.js +++ b/h5/test/wrinkle-overall.js @@ -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'); diff --git a/h5/test/wrinkle-three-views.mjs b/h5/test/wrinkle-three-views.mjs index fa0fcd2..10fd61d 100644 --- a/h5/test/wrinkle-three-views.mjs +++ b/h5/test/wrinkle-three-views.mjs @@ -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);