- 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
246 lines
9.4 KiB
Python
246 lines
9.4 KiB
Python
#!/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())
|