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:
Ubuntu
2026-09-26 22:35:02 +08:00
parent 71a090fd75
commit ba3b346fce
28 changed files with 2801 additions and 71 deletions
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
# ─── 报告页可视化调试 fixture 生成器(仅本目录工具,需 Pillow) ───
# 数据来源:reference/facepp-skinanalyze/examples/ref_3view_raw.json(案例1 完整原始返回)
# + reference/facepp-skinanalyze/examples/images/C3_180.jpg(案例1 正脸 4000×3000)
# 产物:report-fixture.js → window.MOCK_FIXTURE = { result, face_rectangle, side, photos:[正脸dataURL,null,null] }
# 生产不变量:照片与 face_maps 统一缩到宽 1280,marks 坐标同比例缩放(坐标空间 = 照片空间)
# 用法:python3 build-fixture.py
import base64
import io
import json
import os
from PIL import Image
ROOT = os.path.dirname(os.path.abspath(__file__))
REF = os.path.join(ROOT, '..', '..', '..', 'reference', 'facepp-skinanalyze', 'examples')
TARGET_W = 1280
# 与服务端 REPORT_MAPS 白名单一致(h5/server.js)
MAP_KEYS = ['red_area', 'brown_area', 'texture_enhanced_lines', 'texture_enhanced_pores', 'texture_enhanced_blackheads']
def to_data_url(img, fmt):
buf = io.BytesIO()
if fmt == 'JPEG':
img.convert('RGB').save(buf, 'JPEG', quality=85)
mime = 'image/jpeg'
else:
img.save(buf, 'PNG', optimize=True)
mime = 'image/png'
return 'data:%s;base64,%s' % (mime, base64.b64encode(buf.getvalue()).decode('ascii'))
def scale_coords(node, s):
"""递归缩放 result 内全部坐标:{left,top,width,height} 矩形与 {x,y} 点,与原数据同为整数。"""
if isinstance(node, dict):
ks = node.keys()
if {'left', 'top', 'width', 'height'} <= ks and all(isinstance(node[k], (int, float)) for k in ('left', 'top', 'width', 'height')):
node['left'] = round(node['left'] * s)
node['top'] = round(node['top'] * s)
node['width'] = max(1, round(node['width'] * s))
node['height'] = max(1, round(node['height'] * s))
return
if {'x', 'y'} <= ks and isinstance(node['x'], (int, float)) and isinstance(node['y'], (int, float)):
node['x'] = round(node['x'] * s)
node['y'] = round(node['y'] * s)
return
for v in node.values():
scale_coords(v, s)
elif isinstance(node, list):
for v in node:
scale_coords(v, s)
def main():
raw = json.load(open(os.path.join(REF, 'ref_3view_raw.json'), encoding='utf-8'))
result = raw['result']
src_w, src_h = Image.open(os.path.join(REF, 'images', 'C3_180.jpg')).size
s = TARGET_W / src_w # 4000 → 1280:0.32
target_h = round(src_h * s)
# 正脸照片
photo = Image.open(os.path.join(REF, 'images', 'C3_180.jpg')).convert('RGB')
photo = photo.resize((TARGET_W, target_h), Image.LANCZOS)
photo_url = to_data_url(photo, 'JPEG')
# face_maps:白名单图层,同比例缩放;格式保持原样(jpeg 图层出 jpeg,透明叠加层出 png,与服务端魔数判定一致)
maps = {}
for k in MAP_KEYS:
b64 = result.get('face_maps', {}).get(k)
if not b64:
continue
im = Image.open(io.BytesIO(base64.b64decode(b64)))
fmt = im.format # resize 后 format 丢失,先取
im = im.resize((TARGET_W, target_h), Image.LANCZOS)
maps[k] = to_data_url(im, 'JPEG' if fmt == 'JPEG' else 'PNG')
# marks 坐标随照片同比例缩放(face_maps 已替换为缩放后 dataURL,不参与坐标缩放)
result['face_maps'] = maps
scale_coords(result, s)
face_rect = raw.get('face_rectangle')
if face_rect:
scale_coords(face_rect, s)
fixture = {
'result': result,
'face_rectangle': face_rect,
'side': {'left': raw.get('left_side_result'), 'right': raw.get('right_side_result')},
'photos': [photo_url, None, None] # 与 CAM_STEPS 同序:[0]正脸;侧脸图未纳入 fixture
}
out = os.path.join(ROOT, 'report-fixture.js')
with open(out, 'w', encoding='utf-8') as f:
f.write('/* 报告页可视化 mock fixture — 由 build-fixture.py 生成,勿手改\n')
f.write(' 来源:ref_3view_raw.json(案例1)+ C3_180.jpg;照片与 face_maps 统一宽 %d,marks 坐标同比例缩放(×%.2f)\n' % (TARGET_W, s))
f.write(' 仅在 ?mock=1 时由 index.html 按需注入,生产零额外请求 */\n')
f.write('window.MOCK_FIXTURE = ')
json.dump(fixture, f, ensure_ascii=False, separators=(',', ':'))
f.write(';\n')
print('[build-fixture] %s %.1f KB(照片 %d×%d,图层 %d 张)' % (
out, os.path.getsize(out) / 1024, TARGET_W, target_h, len(maps)))
if __name__ == '__main__':
main()
File diff suppressed because one or more lines are too long
+129
View File
@@ -0,0 +1,129 @@
/* ─── 报告页可视化 mock 冒烟(node,零依赖):node smoke-test.js ───
断言:① fixture 的 dataURL 可解析(mime 与魔数一致、宽 1280)
② marks 坐标缩放后全部落在照片范围内(坐标空间=照片空间)
③ app.js 纯函数段(vizCollectMarks/vizScaleMarks)对 fixture 提取/换算正确
④ fixture.result 能驱动 SkinScore.buildViewModel 出完整报告 */
'use strict';
var fs = require('fs');
var path = require('path');
var pass = 0, fail = 0;
function ok(cond, msg) {
if (cond) { pass++; console.log(' ✓ ' + msg); }
else { fail++; console.error(' ✗ FAIL: ' + msg); }
}
/* ─── 无依赖图片尺寸解析:PNG 读 IHDR,JPEG 扫 SOF ─── */
function imgSize(buf) {
if (buf[0] === 0x89 && buf[1] === 0x50) { // PNG:89 50 4E 47
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20), fmt: 'png' };
}
if (buf[0] === 0xFF && buf[1] === 0xD8) { // JPEG:FF D8,SOF0-15(除 C4/C8/CC)
var off = 2;
while (off + 9 < buf.length) {
if (buf[off] !== 0xFF) { off++; continue; }
var marker = buf[off + 1];
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
return { w: buf.readUInt16BE(off + 7), h: buf.readUInt16BE(off + 5), fmt: 'jpeg' };
}
off += 2 + buf.readUInt16BE(off + 2);
}
}
return null;
}
function parseDataURL(u) {
var m = /^data:(image\/(?:jpeg|png));base64,([A-Za-z0-9+/=]+)$/.exec(u || '');
if (!m) return null;
return { mime: m[1], buf: Buffer.from(m[2], 'base64') };
}
/* ─── ① fixture 加载与 dataURL 解析 ─── */
console.log('== ① fixture dataURL 解析 ==');
var window = {};
new Function('window', fs.readFileSync(path.join(__dirname, 'report-fixture.js'), 'utf8'))(window);
var fx = window.MOCK_FIXTURE;
ok(fx && fx.result && Array.isArray(fx.photos), 'window.MOCK_FIXTURE 结构完整(result/photos)');
var photo = parseDataURL(fx.photos[0]);
var photoSize = photo && imgSize(photo.buf);
ok(!!photo, 'photos[0] 是合法 dataURL');
ok(photoSize && photoSize.fmt === 'jpeg' && photo.mime === 'image/jpeg', '照片 jpeg 魔数与 mime 一致');
ok(photoSize && photoSize.w === 1280 && photoSize.h === 960, '照片尺寸 1280×960(实测 ' + (photoSize ? photoSize.w + '×' + photoSize.h : '?') + ')');
ok(fx.photos[1] === null && fx.photos[2] === null, '侧脸位为 null');
var maps = fx.result.face_maps || {};
['red_area', 'brown_area', 'texture_enhanced_lines', 'texture_enhanced_pores', 'texture_enhanced_blackheads'].forEach(function (k) {
var p = parseDataURL(maps[k]);
var sz = p && imgSize(p.buf);
ok(!!p && !!sz, 'face_maps.' + k + ' dataURL 可解析(' + (sz ? sz.fmt : '失败') + ')');
ok(sz && sz.w === 1280 && sz.h === 960, 'face_maps.' + k + ' 已缩放到 1280×960');
ok(p && sz && p.mime === 'image/' + sz.fmt, 'face_maps.' + k + ' mime 与魔数一致(' + (p && p.mime) + ')');
});
/* ─── ② marks 坐标全部落在照片范围内 ─── */
console.log('== ② marks 坐标范围 ==');
var coordCount = 0, outCount = 0;
(function walk(node) {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) return node.forEach(walk);
var ks = node;
if (typeof ks.left === 'number' && typeof ks.top === 'number' && typeof ks.width === 'number' && typeof ks.height === 'number') {
coordCount++;
if (ks.left < 0 || ks.top < 0 || ks.left + ks.width > 1280 || ks.top + ks.height > 960) outCount++;
return;
}
if (typeof ks.x === 'number' && typeof ks.y === 'number') {
coordCount++;
if (ks.x < 0 || ks.x > 1280 || ks.y < 0 || ks.y > 960) outCount++;
return;
}
Object.keys(ks).forEach(function (k) { if (k !== 'face_maps') walk(ks[k]); });
})(fx.result);
ok(coordCount > 100, 'result 内含坐标点/矩形 ' + coordCount + ' 个(原图 4000×3000 → ×0.32)');
ok(outCount === 0, '全部坐标在 1280×960 照片范围内(越界 ' + outCount + ' 个)');
var fr = fx.face_rectangle;
ok(fr && fr.left >= 0 && fr.top >= 0 && fr.left + fr.width <= 1280 && fr.top + fr.height <= 960,
'face_rectangle 已缩放且在范围内(' + JSON.stringify(fr) + ')');
/* ─── ③ app.js 纯函数段抽离断言 ─── */
console.log('== ③ app.js 纯函数(viz-pure 锚点抽离) ==');
var appSrc = fs.readFileSync(path.join(__dirname, '..', '..', 'js', 'app.js'), 'utf8');
var b = appSrc.indexOf('/* viz-pure-begin'), e = appSrc.indexOf('/* viz-pure-end');
ok(b >= 0 && e > b, 'app.js 含 viz-pure-begin/end 锚点');
var pure = new Function(appSrc.slice(b, e) + '; return { collect: vizCollectMarks, scale: vizScaleMarks };')();
var SMOOTH_MARKS = [{ key: 'brown_spot', color: '#B26A00' }, { key: 'mole', color: '#282828' }, { key: 'acne', color: '#D30C0C' }];
var ACNE_MARKS = [{ key: 'closed_comedones', color: '#E8A13D' }, { key: 'acne', color: '#D30C0C' }, { key: 'acne_pustule', color: '#D30C0C' }, { key: 'acne_nodule', color: '#8E0000' }];
var sm = pure.collect(SMOOTH_MARKS, fx.result);
var am = pure.collect(ACNE_MARKS, fx.result);
ok(sm.length === 2, '光滑:提取到 brown_spot+acne 两组(mole count=0 被跳过),实得 ' + sm.length + ' 组');
ok(sm[0].polygons.length === 37 && sm[0].rects.length === 37, 'brown_spot 37 处 polygon/rectangle 全保留');
ok(am.length === 2 && am[0].rects.length === 6, '痘痘:closed_comedones 6 处 + acne 3 处(pustule/nodule 为空跳过)');
ok(sm[0].color === '#B26A00', '颜色样式随配置透传');
var half = pure.scale(sm, 0.5);
ok(half[0].rects[0].left === sm[0].rects[0].left / 2 && half[0].polygons[0][0][0] === sm[0].polygons[0][0][0] / 2,
'vizScaleMarks 坐标按比例换算(0.5×)');
ok(half[0].rects[0].left + half[0].rects[0].width <= 1280 && half[0].polygons[0].every(function (pt) { return pt[0] <= 1280 && pt[1] <= 960; }),
'缩放后坐标仍在照片范围内');
ok(pure.collect(SMOOTH_MARKS, null).length === 0 && pure.collect(null, fx.result).length === 0, '缺 result/缺配置 → 空集合(优雅降级)');
var noKey = pure.collect([{ key: 'not_exist', color: '#000' }], fx.result);
ok(noKey.length === 0, '标记类型缺失 → 跳过不报错');
/* ─── ④ fixture 驱动真实评分管线 ─── */
console.log('== ④ SkinScore.buildViewModel(fixture) ==');
var SkinScore = require(path.join(__dirname, '..', '..', 'js', 'skin-score.js'));
var vm = SkinScore.buildViewModel(fx.result, fx.side, { realAge: 30, skinType: '中性肌肤' });
var catKeys = ['contour', 'wrinkles', 'smooth', 'tone', 'sens', 'acne'];
var textsOK = true, scoreOK = true;
catKeys.forEach(function (k) {
var c = vm.cats[k];
if (!c || typeof c.text !== 'string' || /undefined|NaN/.test(c.text)) textsOK = false;
if (c.P == null || typeof c.S !== 'number') scoreOK = false;
});
ok(scoreOK, '6 分类全部出分(案例1 数据完整)');
ok(textsOK, '话术无 undefined/NaN');
ok(typeof vm.total === 'number' || vm.dims.firm.S != null, '综合评分/维度分可用');
console.log('\n' + (fail ? '✗ 失败 ' + fail + ' 项,' : '✓ 全部通过,') + '共 ' + (pass + fail) + ' 项断言');
process.exit(fail ? 1 : 0);