Files
juzi/h5/test/build_fixtures.py
Ubuntu ba3b346fce 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
2026-09-26 22:35:02 +08:00

246 lines
9.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())