feat: 报告页 V2.2 检测可视化 + 评分引擎审计 + 真值回归/e2e 闭环
- server.js: face_maps 改为白名单 5 图层完整 dataURL 透传(REPORT_MAPS,按魔数判 mime),marks 坐标随 result 原样透传;实测 return_marks 不支持毛孔/黑头等扩展标记 - skin-score.js: 按需求文档 V2.2 逐字审计修正 9 处(黑眼圈 dark_circle_severity、oil_desc/spot_flag §7.2 拼接、skintone 中文映射、轮廓预警档前缀、痘痘 heavy_note、50+精华全文、综合文案数值格式),契约引用 V2.0→V2.2 - app.js/index.html/css: 报告页 6 分类检测可视化(轮廓=正脸实拍、皱纹=正脸压暗+皱纹增强线、光滑=正脸+色斑/痣/痤疮标记、肤色=色沉图、敏感=红区图、痘痘=正脸+闭口/痤疮标记),数据驱动 VIZ_CAT 配置,优雅降级 - h5/test/: Excel 两组三视图真值回归 84/84(含案例2 AY 列扁平化数据重建 fixture) - h5/assets/mock/: 真实案例1 报告页 fixture(坐标与照片同比例缩放),?mock=1&screen=report 直渲 - e2e: Chrome 无头闭环 55/55(mock 全流程/直渲/真实 API 降级链路),截图存档 preview/e2e-20260926 - 自测: skin-score 83/83,fixture 冒烟 35/35
This commit is contained in:
+181
-8
@@ -508,7 +508,7 @@
|
||||
showScreen('s-profile');
|
||||
return;
|
||||
}
|
||||
renderReport(vm, photos[0], photos[1]); // img1=正脸实拍,img2=右脸实拍
|
||||
renderReport(vm, photos[0], photos[1], payload); // img1=正脸实拍,payload 供检测可视化取 face_maps/marks
|
||||
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
|
||||
showScreen('s-report');
|
||||
}, wait);
|
||||
@@ -529,13 +529,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 分析请求(mock=1 直返 SkinScore 内置样本,便于离线/自动化验证) ─── */
|
||||
/* ─── 分析请求(mock=1 直返内置样本;有 MOCK_FIXTURE 时用报告页可视化 fixture,便于离线/自动化验证) ─── */
|
||||
var ANALYZE_TIMEOUT_MS = 45000; // 检测服务超时阈值:超时按「开小差」处理回档案页
|
||||
function requestAnalyze(ph, seq, done) {
|
||||
if (IS_MOCK && typeof SkinScore !== 'undefined') {
|
||||
setTimeout(function () {
|
||||
if (seq !== camSeq) return; // mock 同样尊重取消/重置
|
||||
done(null, { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE });
|
||||
var fx = window.MOCK_FIXTURE; // assets/mock/report-fixture.js(仅 mock 模式注入)
|
||||
if (fx) {
|
||||
// marks 坐标空间=fixture 照片空间:三连拍替换为 fixture 照片,保证可视化叠加对齐
|
||||
ph[0] = fx.photos[0]; ph[1] = fx.photos[1]; ph[2] = fx.photos[2];
|
||||
done(null, { result: fx.result, side: fx.side, face_rectangle: fx.face_rectangle });
|
||||
} else {
|
||||
done(null, { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE });
|
||||
}
|
||||
}, 900);
|
||||
return;
|
||||
}
|
||||
@@ -607,7 +614,168 @@
|
||||
$(dom.tx).textContent = cat.text;
|
||||
}
|
||||
|
||||
function renderReport(vm, frontPhoto, sidePhoto) {
|
||||
/* ─── 报告页检测可视化(face_maps 图层 + marks 坐标叠加,数据驱动) ─── */
|
||||
// 6 个分类各声明图片来源:map=整幅图层直接出图;overlay=透明增强图层盖在正脸上;marks=坐标标记画在正脸上
|
||||
// 增删标记类型/图层:改对应分类的一行即可
|
||||
var VIZ_CAT = {
|
||||
contour: { img: '#ditem-img1', photo: true }, // 轮廓:用户正脸实拍
|
||||
wrinkles: { img: '#ditem-img2', overlay: 'texture_enhanced_lines', dim: 0.55 }, // 皱纹:正脸压暗+皱纹增强线(图层几乎全透明,直挂在白卡上不可读,故合成)
|
||||
smooth: { img: '#viz-smooth', photo: true, marks: [ // 光滑:正脸+色斑/痣/痤疮标记
|
||||
{ key: 'brown_spot', color: '#B26A00' }, // 色斑
|
||||
{ key: 'mole', color: '#282828' }, // 痣
|
||||
{ key: 'acne', color: '#D30C0C' } // 痤疮
|
||||
] },
|
||||
tone: { img: '#viz-tone', map: 'brown_area' }, // 肤色:色沉图
|
||||
sens: { img: '#viz-sens', map: 'red_area' }, // 敏感:红区图
|
||||
acne: { img: '#viz-acne', photo: true, marks: [ // 痘痘:正脸+闭口粉刺/痤疮标记
|
||||
{ key: 'closed_comedones', color: '#E8A13D' }, // 闭口粉刺
|
||||
{ key: 'acne', color: '#D30C0C' }, // 痤疮
|
||||
{ key: 'acne_pustule', color: '#D30C0C' }, // 脓包
|
||||
{ key: 'acne_nodule', color: '#8E0000' } // 结节
|
||||
] }
|
||||
};
|
||||
|
||||
/* viz-pure-begin(纯函数段:与 DOM 无关,node 冒烟按锚点抽离断言,勿改锚点注释) */
|
||||
// 从 result 提取某分类的标记集合:polygon 与 rectangle 都保留,缺该标记类型则跳过
|
||||
function vizCollectMarks(markDefs, result) {
|
||||
var out = [];
|
||||
if (!markDefs || !result) return out;
|
||||
for (var i = 0; i < markDefs.length; i++) {
|
||||
var src = result[markDefs[i].key];
|
||||
if (!src || typeof src !== 'object') continue;
|
||||
var m = { color: markDefs[i].color, polygons: [], rects: [] };
|
||||
var polys = src.polygon || [];
|
||||
for (var p = 0; p < polys.length; p++) {
|
||||
var pts = [];
|
||||
for (var q = 0; q < (polys[p] || []).length; q++) {
|
||||
var pt = polys[p][q];
|
||||
if (pt && typeof pt.x === 'number' && typeof pt.y === 'number') pts.push([pt.x, pt.y]);
|
||||
}
|
||||
if (pts.length > 2) m.polygons.push(pts);
|
||||
}
|
||||
var rects = src.rectangle || [];
|
||||
for (var r = 0; r < rects.length; r++) {
|
||||
var rc = rects[r];
|
||||
if (rc && typeof rc.left === 'number' && typeof rc.top === 'number') {
|
||||
m.rects.push({ left: rc.left, top: rc.top, width: +rc.width || 0, height: +rc.height || 0 });
|
||||
}
|
||||
}
|
||||
if (m.polygons.length || m.rects.length) out.push(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// marks 坐标空间 = 上传图片像素空间:按「画布自然宽 / 坐标空间宽」换算(画布=照片自然尺寸时比值为 1)
|
||||
function vizScaleMarks(marks, scale) {
|
||||
var out = [];
|
||||
for (var i = 0; i < marks.length; i++) {
|
||||
var m = marks[i], nm = { color: m.color, polygons: [], rects: [] };
|
||||
for (var p = 0; p < m.polygons.length; p++) {
|
||||
var pts = [];
|
||||
for (var q = 0; q < m.polygons[p].length; q++) pts.push([m.polygons[p][q][0] * scale, m.polygons[p][q][1] * scale]);
|
||||
nm.polygons.push(pts);
|
||||
}
|
||||
for (var r = 0; r < m.rects.length; r++) {
|
||||
var rc = m.rects[r];
|
||||
nm.rects.push({ left: rc.left * scale, top: rc.top * scale, width: rc.width * scale, height: rc.height * scale });
|
||||
}
|
||||
out.push(nm);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
/* viz-pure-end */
|
||||
|
||||
// face_maps 值兼容:已是 dataURL 直接用;裸 base64 按魔数补前缀;其它一律视为缺失(优雅降级)
|
||||
function vizMapURL(v) {
|
||||
if (typeof v !== 'string' || !v) return null;
|
||||
if (v.indexOf('data:image/') === 0) return v;
|
||||
if (v.indexOf('/9j/') === 0) return 'data:image/jpeg;base64,' + v;
|
||||
if (v.indexOf('iVBOR') === 0) return 'data:image/png;base64,' + v;
|
||||
return null;
|
||||
}
|
||||
|
||||
function vizLoadImage(url, done) {
|
||||
var img = new Image();
|
||||
img.onload = function () { done(img.naturalWidth ? img : null); };
|
||||
img.onerror = function () { done(null); };
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
// canvas 合成:照片绘制到自然尺寸 → (可选压暗)→ (可选盖透明增强图层)→ 画坐标标记 → dataURL 挂到卡片 <img>
|
||||
function vizCompose(photoURL, overlayURL, marks, imgEl, opts) {
|
||||
vizLoadImage(photoURL, function (photo) {
|
||||
if (!photo) return; // 照片不可用:保留占位
|
||||
var finish = function (overlay) {
|
||||
var w = photo.naturalWidth, h = photo.naturalHeight;
|
||||
var cv = document.createElement('canvas');
|
||||
cv.width = w; cv.height = h;
|
||||
var ctx = cv.getContext('2d');
|
||||
ctx.drawImage(photo, 0, 0, w, h);
|
||||
if (opts && opts.dim) { ctx.fillStyle = 'rgba(0,0,0,' + opts.dim + ')'; ctx.fillRect(0, 0, w, h); } // 压暗底色让增强线可读
|
||||
if (overlay) ctx.drawImage(overlay, 0, 0, w, h);
|
||||
var scaled = vizScaleMarks(marks, w / photo.naturalWidth);
|
||||
var lw = Math.max(3, Math.round(w / 200)); // 线宽随图走,卡片缩略后仍可见
|
||||
var minBox = Math.round(w / 36); // 过小标记放大到最小框,保证缩略可见
|
||||
for (var i = 0; i < scaled.length; i++) {
|
||||
var m = scaled[i];
|
||||
ctx.strokeStyle = m.color;
|
||||
ctx.fillStyle = m.color;
|
||||
ctx.lineWidth = lw;
|
||||
var p, q, pts, rc;
|
||||
for (p = 0; p < m.polygons.length; p++) {
|
||||
pts = m.polygons[p];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0][0], pts[0][1]);
|
||||
for (q = 1; q < pts.length; q++) ctx.lineTo(pts[q][0], pts[q][1]);
|
||||
ctx.closePath();
|
||||
ctx.globalAlpha = 0.22;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
for (p = 0; p < m.rects.length; p++) {
|
||||
rc = m.rects[p];
|
||||
var rw = Math.max(rc.width, minBox), rh = Math.max(rc.height, minBox);
|
||||
var cx = rc.left + rc.width / 2, cy = rc.top + rc.height / 2;
|
||||
ctx.strokeRect(cx - rw / 2, cy - rh / 2, rw, rh);
|
||||
}
|
||||
}
|
||||
try { imgEl.src = cv.toDataURL('image/jpeg', 0.9); imgEl.hidden = false; } catch (e) { /* 合成失败保留占位 */ }
|
||||
};
|
||||
if (overlayURL) vizLoadImage(overlayURL, finish);
|
||||
else finish(null);
|
||||
});
|
||||
}
|
||||
|
||||
// 报告页 6 张检测图统一入口:任一输入缺失 → 该卡保留既有占位(不报错)
|
||||
function renderViz(payload, frontPhoto) {
|
||||
var result = payload && payload.result;
|
||||
var maps = (result && result.face_maps) || {};
|
||||
for (var key in VIZ_CAT) {
|
||||
var cfg = VIZ_CAT[key];
|
||||
var imgEl = $(cfg.img);
|
||||
if (!imgEl) continue;
|
||||
if (cfg.map) { // 整幅图层(色沉图/红区图):图层缺失 → 保留占位
|
||||
var u = vizMapURL(maps[cfg.map]);
|
||||
if (u) { imgEl.src = u; imgEl.hidden = false; }
|
||||
continue;
|
||||
}
|
||||
if (cfg.overlay) { // 透明增强图层(皱纹):有照片则合成(可按 cfg.dim 压暗),无照片单挂图层;图层缺失 → 保留占位
|
||||
var ov = vizMapURL(maps[cfg.overlay]);
|
||||
if (!ov) continue;
|
||||
if (!frontPhoto) { imgEl.src = ov; imgEl.hidden = false; continue; }
|
||||
vizCompose(frontPhoto, ov, [], imgEl, { dim: cfg.dim });
|
||||
continue;
|
||||
}
|
||||
// 照片类(轮廓/光滑/痘痘):无照片 → 保留占位;有坐标则 canvas 叠加,无坐标纯照片
|
||||
if (!frontPhoto) continue;
|
||||
var marks = cfg.marks ? vizCollectMarks(cfg.marks, result) : [];
|
||||
if (!marks.length) { imgEl.src = frontPhoto; imgEl.hidden = false; continue; }
|
||||
vizCompose(frontPhoto, null, marks, imgEl);
|
||||
}
|
||||
}
|
||||
|
||||
function renderReport(vm, frontPhoto, sidePhoto, payload) {
|
||||
// 头部:综合评分(vm.total 缺失时回退三维度 S 均分×10)
|
||||
var total = vm.total;
|
||||
if (total == null) {
|
||||
@@ -630,8 +798,9 @@
|
||||
// 三个 Tab 面板:各所辖两个分类(S + 档位徽章 + 话术正文)
|
||||
var keys = ['contour', 'wrinkles', 'smooth', 'tone', 'sens', 'acne'];
|
||||
for (var i = 0; i < keys.length; i++) setCat(keys[i], vm.cats[keys[i]]);
|
||||
if (frontPhoto) $('#ditem-img1').src = frontPhoto;
|
||||
if (sidePhoto) $('#ditem-img2').src = sidePhoto;
|
||||
|
||||
// 检测可视化:轮廓=正脸实拍,其余按 VIZ_CAT 取 face_maps 图层或叠加 marks(payload 缺失时各卡保留占位)
|
||||
renderViz(payload, frontPhoto);
|
||||
|
||||
// 降级标注(下颌线未参与评分等,非空才展示)
|
||||
var notesEl = $('#vm-notes');
|
||||
@@ -776,13 +945,17 @@
|
||||
/* ─── 初始化 ─── */
|
||||
renderCamStep();
|
||||
// 仅调试用:?mock=1&screen=report 直渲 mock 报告页(供无头浏览器截图验证布局,非正式入口)
|
||||
// 有 MOCK_FIXTURE 时走 fixture(检测可视化同步出图),否则回退 SkinScore 内置样本
|
||||
if (IS_MOCK && /[?&]screen=report/.test(location.search) && typeof SkinScore !== 'undefined') {
|
||||
showScreen('s-report');
|
||||
try {
|
||||
renderReport(SkinScore.buildViewModel(SkinScore.MOCK_RESULT, SkinScore.MOCK_SIDE, {
|
||||
var fx = window.MOCK_FIXTURE;
|
||||
var mockRes = fx ? fx.result : SkinScore.MOCK_RESULT;
|
||||
var mockSide = fx ? fx.side : SkinScore.MOCK_SIDE;
|
||||
renderReport(SkinScore.buildViewModel(mockRes, mockSide, {
|
||||
realAge: +($('#f-age').value || 0) || 28, // 调试档案缺省 28 岁
|
||||
skinType: SKINS[skinIndex].name
|
||||
}), null, null);
|
||||
}), fx ? fx.photos[0] : null, null, fx ? { result: mockRes, side: mockSide, face_rectangle: fx.face_rectangle } : null);
|
||||
} catch (e) { /* 调试入口异常不阻塞正式流程 */ }
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user