#!/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())