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
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# ═════ 真值 fixture 构建脚本(零第三方新依赖:openpyxl 环境已装,仅用于建 fixture)═════
# 用法:python3 h5/test/build_fixtures.py
# 产出:
# fixtures/case1.json ← reference/facepp-skinanalyze/examples/ref_3view_raw.json
# (案例1 完整原始返回,精简坐标数组与 face_maps base64)
# fixtures/case2.json ← docs/皮肤检测测试结果_三视图_20260926.xlsx AY4 扁平化文本解析回嵌套
# fixtures/truth.json ← 同表第 3/4 行真值(P/S/档位/文案等断言目标)
# 两个 case fixture 均为服务端 /api/skin-analyze 响应形状:
# { result, face_rectangle, side: { left: left_side_result, right: right_side_result } }
import json
import os
import re
import sys
import openpyxl
ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'))
REF_JSON = os.path.join(ROOT, 'reference', 'facepp-skinanalyze', 'examples', 'ref_3view_raw.json')
XLSX = os.path.join(ROOT, 'docs', '皮肤检测测试结果_三视图_20260926.xlsx')
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'fixtures')
# ─── 通用:递归精简(坐标数组→[],face_maps→{};评分只用标量与 count)───
def slim(node, key=''):
if isinstance(node, dict):
if key == 'face_maps': # base64 整图,评分不需要,整体置空对象
return {}
return {k: slim(v, k) for k, v in node.items()}
if isinstance(node, list):
# 元素为 dict 的坐标/标记数组(rectangle/polygon/*_mark/*_outline)→ 空数组;
# 纯数值数组(confidence 列表等)保留
if node and all(isinstance(x, dict) for x in node):
return []
return [slim(x, key) for x in node]
return node
# ─── 案例1:原始 pro 返回 → 服务端响应形状 ───
def build_case1():
with open(REF_JSON, encoding='utf-8') as f:
raw = json.load(f)
fixture = {
'request_id': raw.get('request_id'),
'time_used': raw.get('time_used'),
'result': slim(raw['result']),
'face_rectangle': raw.get('face_rectangle'),
'side': {
'left': slim(raw.get('left_side_result')),
'right': slim(raw.get('right_side_result'))
}
}
return fixture
# ─── 案例2:AY4 扁平化「路径 = 值」文本 → 嵌套 JSON ───
SEC_RE = re.compile(r'^——\s*(.+?)\s*——$')
KV_RE = re.compile(r'^([^=]+?)\s*=\s*(.*)$')
NUMLIST_RE = re.compile(r'^[\d\s,.\-eE+]+$')
INT_RE = re.compile(r'^-?\d+$')
FLOAT_RE = re.compile(r'^-?(?:\d+\.\d*|\.\d+)(?:[eE][-+]?\d+)?$|^-?\d+[eE][-+]?\d+$')
MS_RE = re.compile(r'^(-?\d+(?:\.\d+)?)\s*ms$')
skipped = [] # [摘要] 跳过的路径(坐标数组/base64/深层结构)
def parse_value(path, s):
s = s.strip()
if s == '':
return None, False
if s.startswith('{'): # 行内 JSON(face_rectangle)
try:
return json.loads(s), True
except ValueError:
skipped.append(path + ' [行内JSON解析失败]')
return None, False
if s.startswith('['):
if s == '[]':
return [], True
inner = s[1:-1].strip() if s.endswith(']') else ''
if inner and NUMLIST_RE.match(inner): # 纯数值数组(confidence 列表)
out = []
for tok in inner.split(','):
tok = tok.strip()
if tok == '':
continue
out.append(float(tok) if ('.' in tok or 'e' in tok or 'E' in tok) else int(tok))
return out, True
skipped.append(path + ' ' + s[:40]) # [N组坐标]/[N项]/[base64图,…]/[深层结构省略]
return None, False
m = MS_RE.match(s) # time_used 的「751 ms」
if m:
return float(m.group(1)) if '.' in m.group(1) else int(m.group(1)), True
if INT_RE.match(s):
return int(s), True
if FLOAT_RE.match(s):
return float(s), True
return s, True # 字符串(request_id 等)
def set_path(root, path, value):
segs = path.split('.')
cur = root
for i, seg in enumerate(segs[:-1]):
nxt = segs[i + 1]
if seg.isdigit(): # 数字段:上级应是 list(本数据仅 skin_type.details.N,均被摘要跳过)
idx = int(seg)
while len(cur) <= idx:
cur.append({} if not nxt.isdigit() else [])
if cur[idx] is None:
cur[idx] = [] if nxt.isdigit() else {}
cur = cur[idx]
else:
if seg not in cur or not isinstance(cur[seg], (dict, list)):
cur[seg] = [] if nxt.isdigit() else {}
cur = cur[seg]
last = segs[-1]
if last.isdigit():
idx = int(last)
while len(cur) <= idx:
cur.append(None)
cur[idx] = value
else:
cur[last] = value
def build_case2(flat_text):
doc = {}
section = None # 当前 —— section —— 名;None=顶层
for line in flat_text.split('\n'):
line = line.strip()
if not line:
continue
m = SEC_RE.match(line)
if m:
section = m.group(1)
continue
m = KV_RE.match(line)
if not m:
continue
path, raw_v = m.group(1).strip(), m.group(2)
full_path = (section + '.' + path) if section else path
value, ok = parse_value(full_path, raw_v)
if not ok:
continue
target = doc
if section:
if section not in doc or not isinstance(doc[section], dict):
doc[section] = {}
target = doc[section]
set_path(target, path, value)
fixture = {
'request_id': doc.get('request_id'),
'time_used': doc.get('time_used'),
'result': slim(doc.get('result', {})),
'face_rectangle': doc.get('face_rectangle'),
'side': {
'left': slim(doc.get('left_side_result')),
'right': slim(doc.get('right_side_result'))
}
}
return fixture
# ─── 真值表:Excel 第 3/4 行 → truth.json ───
def col(ws, row, letter):
return ws['%s%d' % (letter, row)].value
def build_truth(ws, row):
def cat(p_col, s_col, tier_col, copy_col):
return {
'P': col(ws, row, p_col), 'S': col(ws, row, s_col),
'tier': col(ws, row, tier_col), 'copy': col(ws, row, copy_col)
}
def dim(p_col, s_col, tier_col):
return {'P': col(ws, row, p_col), 'S': col(ws, row, s_col), 'tier': col(ws, row, tier_col)}
return {
'profile': {'realAge': col(ws, row, 'AS'), 'skinType': '混合性'}, # 综合文案实测输出「混合性」
'skinAge': col(ws, row, 'E'),
'totalScore': col(ws, row, 'F'),
'cats': {
'contour': cat('I', 'J', 'K', 'L'),
'wrinkles': cat('M', 'N', 'O', 'P'),
'smooth': cat('Q', 'R', 'S', 'T'),
'tone': cat('U', 'V', 'W', 'X'),
'sens': cat('Y', 'Z', 'AA', 'AB'),
'acne': cat('AC', 'AD', 'AE', 'AF')
},
'dims': {
'firm': dim('AG', 'AH', 'AI'),
'vita': dim('AJ', 'AK', 'AL'),
'stab': dim('AM', 'AN', 'AO')
},
'jawline': {'left': col(ws, row, 'AP'), 'right': col(ws, row, 'AQ')},
'group': col(ws, row, 'AT'),
'essence': col(ws, row, 'AU'),
'avgS': col(ws, row, 'AV'),
'summary': col(ws, row, 'AW'),
'lifestyle': col(ws, row, 'AX')
}
def dump(name, obj):
path = os.path.join(OUT_DIR, name)
with open(path, 'w', encoding='utf-8') as f:
json.dump(obj, f, ensure_ascii=False, indent=1)
print(' 写出 %s(%d 字节)' % (os.path.relpath(path, ROOT), os.path.getsize(path)))
def main():
os.makedirs(OUT_DIR, exist_ok=True)
print('== 案例1 fixture ← ref_3view_raw.json ==')
dump('case1.json', build_case1())
print('== 案例2 fixture ← Excel AY4 扁平化解析 ==')
wb = openpyxl.load_workbook(XLSX, data_only=True)
ws = wb['三视图检测汇总']
case2 = build_case2(ws['AY4'].value)
dump('case2.json', case2)
print(' [摘要] 跳过 %d 个路径(坐标数组/base64/深层结构,评分只用标量,不影响):' % len(skipped))
for s in skipped:
print(' - ' + s)
print('== 真值表 ← Excel 第 3/4 行 ==')
dump('truth.json', {'case1': build_truth(ws, 3), 'case2': build_truth(ws, 4)})
# 构建期自检:fixture 关键标量 vs 真值
c1 = json.load(open(os.path.join(OUT_DIR, 'case1.json'), encoding='utf-8'))
c2 = json.load(open(os.path.join(OUT_DIR, 'case2.json'), encoding='utf-8'))
t = json.load(open(os.path.join(OUT_DIR, 'truth.json'), encoding='utf-8'))
for tag, fx, tr in (('案例1', c1, t['case1']), ('案例2', c2, t['case2'])):
la = fx['side']['left']['left_jawline_info']['left_jawline_angle']
ra = fx['side']['right']['right_jawline_info']['right_jawline_angle']
sa = fx['result']['skin_age']['value']
assert abs(la - tr['jawline']['left']) < 1e-9, (tag, '左下颌角', la, tr['jawline']['left'])
assert abs(ra - tr['jawline']['right']) < 1e-9, (tag, '右下颌角', ra, tr['jawline']['right'])
assert sa == tr['skinAge'], (tag, '肤龄', sa, tr['skinAge'])
print(' %s 自检通过:下颌角 %.2f/%.2f、肤龄 %d 与真值一致' % (tag, la, ra, sa))
if __name__ == '__main__':
sys.exit(main())