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())
+1064
View File
File diff suppressed because it is too large Load Diff
+589
View File
@@ -0,0 +1,589 @@
{
"request_id": "1790403132,0f99111b-571a-46f5-8540-8209c21b3755",
"time_used": 751,
"result": {
"skin_age": {
"value": 50
},
"eye_pouch": {
"value": 0,
"confidence": 0.9219674
},
"dark_circle": {
"value": 3,
"confidence": 1
},
"dark_circle_severity": {
"value": 0,
"confidence": 1
},
"forehead_wrinkle": {
"value": 1,
"confidence": 0.57314694
},
"crows_feet": {
"value": 0,
"confidence": 0.6948663
},
"eye_finelines": {
"value": 0,
"confidence": 0.5346259
},
"glabella_wrinkle": {
"value": 0,
"confidence": 0.65657294
},
"nasolabial_fold": {
"value": 1,
"confidence": 0.80668426
},
"nasolabial_fold_severity": {
"value": 0,
"confidence": 0.3970088
},
"skin_type": {
"skin_type": 3
},
"pores_forehead": {
"value": 0,
"confidence": 1
},
"pores_left_cheek": {
"value": 0,
"confidence": 1
},
"pores_right_cheek": {
"value": 0,
"confidence": 1
},
"pores_jaw": {
"value": 0,
"confidence": 1
},
"blackhead": {
"value": 0,
"confidence": 1
},
"skintone_ita": {
"ITA": 29.471874,
"skintone": 3
},
"skin_hue_ha": {
"HA": 57.833736,
"skin_hue": 0
},
"sensitivity": {
"sensitivity_area": 0,
"sensitivity_intensity": 0
},
"acne": {
"rectangle": [],
"confidence": [],
"polygon": [],
"count": 0
},
"mole": {
"confidence": [
0.3529357
],
"count": 1
},
"brown_spot": {
"count": 23
},
"closed_comedones": {
"rectangle": [],
"confidence": [],
"polygon": [],
"count": 0
},
"acne_mark": {
"rectangle": [],
"confidence": [],
"polygon": [],
"count": 0
},
"acne_nodule": {
"rectangle": [],
"confidence": [],
"polygon": [],
"count": 0
},
"acne_pustule": {
"rectangle": [],
"confidence": [],
"polygon": [],
"count": 0
},
"blackhead_count": 3,
"skintone": {
"value": 2,
"confidence": 0.7146322
},
"fine_line": {
"forehead_count": 1,
"left_undereye_count": 3,
"right_undereye_count": 3,
"left_cheek_count": 0,
"right_cheek_count": 0,
"left_crowsfeet_count": 1,
"right_crowsfeet_count": 0,
"glabella_count": 0
},
"wrinkle_count": {
"forehead_count": 1,
"left_undereye_count": 2,
"right_undereye_count": 3,
"left_mouth_count": 0,
"right_mouth_count": 0,
"left_nasolabial_count": 1,
"right_nasolabial_count": 1,
"glabella_count": 0,
"left_cheek_count": 0,
"right_cheek_count": 0,
"left_crowsfeet_count": 0,
"right_crowsfeet_count": 0
},
"oily_intensity": {
"t_zone": {
"area": 0.62,
"intensity": 2
},
"left_cheek": {
"area": 0.16,
"intensity": 1
},
"right_cheek": {
"area": 0.16,
"intensity": 1
},
"chin_area": {
"area": 0,
"intensity": 0
},
"full_face": {
"area": 0.49,
"intensity": 2
}
},
"enlarged_pore_count": {
"forehead_count": 81,
"left_cheek_count": 10,
"right_cheek_count": 9,
"chin_count": 0
},
"red_spot": {
"red_spot_area": 0.009,
"red_spot_intensity": 3
},
"right_dark_circle_rete": {
"value": 0
},
"left_dark_circle_rete": {
"value": 0
},
"right_dark_circle_pigment": {
"value": 0
},
"left_dark_circle_pigment": {
"value": 0
},
"right_dark_circle_structural": {
"value": 0
},
"left_dark_circle_structural": {
"value": 1
},
"dark_circle_mark": {
"left_eye_rect": {
"left": 102,
"top": 137,
"width": 68,
"height": 59
},
"right_eye_rect": {
"left": 214,
"top": 136,
"width": 67,
"height": 59
}
},
"water": {
"water_severity": 7,
"water_area": 0.068,
"water_forehead": {
"area": 0.111
},
"water_leftcheek": {
"area": 0.007
},
"water_rightcheek": {
"area": 0.007
}
},
"rough": {
"rough_severity": 16,
"rough_area": 0.28,
"rough_forehead": {
"area": 0.352
},
"rough_leftcheek": {
"area": 0.297
},
"rough_rightcheek": {
"area": 0.194
},
"rough_jaw": {
"area": 0.008
}
},
"left_mouth_wrinkle_severity": {
"value": 0
},
"right_mouth_wrinkle_severity": {
"value": 0
},
"forehead_wrinkle_severity": {
"value": 0
},
"left_crows_feet_severity": {
"value": 1
},
"right_crows_feet_severity": {
"value": 0
},
"left_eye_finelines_severity": {
"value": 0
},
"right_eye_finelines_severity": {
"value": 0
},
"glabella_wrinkle_severity": {
"value": 0
},
"left_nasolabial_fold_severity": {
"value": 2
},
"right_nasolabial_fold_severity": {
"value": 2
},
"left_cheek_wrinkle_severity": {
"value": 0
},
"right_cheek_wrinkle_severity": {
"value": 0
},
"forehead_wrinkle_info": {
"wrinkle_score": 5,
"wrinkle_severity_level": 0,
"wrinkle_norm_length": 0.5042114066911291,
"wrinkle_norm_depth": 0.2771696504688832,
"wrinkle_pixel_density": 0.026174111741415557,
"wrinkle_area_ratio": 0.04610318331503842,
"wrinkle_deep_ratio": 0.6260869565217392,
"wrinkle_deep_num": 1,
"wrinkle_shallow_num": 1
},
"left_eye_wrinkle_info": {
"wrinkle_score": 6,
"wrinkle_severity_level": 0,
"wrinkle_norm_length": 1.3718454398776636,
"wrinkle_norm_depth": 0.13984917043740575,
"wrinkle_pixel_density": 0.4036391390409279,
"wrinkle_area_ratio": 0.09728506787330317,
"wrinkle_deep_ratio": 0.35384615384615387,
"wrinkle_deep_num": 2,
"wrinkle_shallow_num": 3
},
"right_eye_wrinkle_info": {
"wrinkle_score": 8,
"wrinkle_severity_level": 0,
"wrinkle_norm_length": 1.7560485062524749,
"wrinkle_norm_depth": 0.16168202220647293,
"wrinkle_pixel_density": 0.4840998584804119,
"wrinkle_area_ratio": 0.08267090620031796,
"wrinkle_deep_ratio": 0.5783132530120482,
"wrinkle_deep_num": 3,
"wrinkle_shallow_num": 3
},
"left_crowsfeet_wrinkle_info": {
"wrinkle_score": 10,
"wrinkle_severity_level": 1,
"wrinkle_norm_length": 0.46431691811243997,
"wrinkle_norm_depth": 0.11122994652406416,
"wrinkle_pixel_density": 0.42449501019699687,
"wrinkle_area_ratio": 0.050966608084358524,
"wrinkle_deep_ratio": 0,
"wrinkle_deep_num": 0,
"wrinkle_shallow_num": 1
},
"right_crowsfeet_wrinkle_info": {
"wrinkle_score": 0,
"wrinkle_severity_level": 0,
"wrinkle_norm_length": 0,
"wrinkle_norm_depth": 0,
"wrinkle_pixel_density": 0,
"wrinkle_area_ratio": 0,
"wrinkle_deep_ratio": 0,
"wrinkle_deep_num": 0,
"wrinkle_shallow_num": 0
},
"glabella_wrinkle_info": {
"wrinkle_score": 0,
"wrinkle_severity_level": 0,
"wrinkle_norm_length": 0,
"wrinkle_norm_depth": 0,
"wrinkle_pixel_density": 0,
"wrinkle_area_ratio": 0,
"wrinkle_deep_ratio": 0,
"wrinkle_deep_num": 0,
"wrinkle_shallow_num": 0
},
"left_mouth_wrinkle_info": {
"wrinkle_score": 0,
"wrinkle_severity_level": 0,
"wrinkle_norm_length": 0,
"wrinkle_norm_depth": 0,
"wrinkle_pixel_density": 0,
"wrinkle_area_ratio": 0,
"wrinkle_deep_ratio": 0,
"wrinkle_deep_num": 0,
"wrinkle_shallow_num": 0
},
"right_mouth_wrinkle_info": {
"wrinkle_score": 0,
"wrinkle_severity_level": 0,
"wrinkle_norm_length": 0,
"wrinkle_norm_depth": 0,
"wrinkle_pixel_density": 0,
"wrinkle_area_ratio": 0,
"wrinkle_deep_ratio": 0,
"wrinkle_deep_num": 0,
"wrinkle_shallow_num": 0
},
"left_nasolabial_wrinkle_info": {
"wrinkle_score": 36,
"wrinkle_severity_level": 2,
"wrinkle_norm_length": 0.23676013879409538,
"wrinkle_norm_depth": 0.3566448801742919,
"wrinkle_pixel_density": 0.06861427532071777,
"wrinkle_area_ratio": 0.09025069637883008,
"wrinkle_deep_ratio": 1,
"wrinkle_deep_num": 1,
"wrinkle_shallow_num": 0
},
"right_nasolabial_wrinkle_info": {
"wrinkle_score": 50,
"wrinkle_severity_level": 2,
"wrinkle_norm_length": 0.22799124476468444,
"wrinkle_norm_depth": 0.49550173010380627,
"wrinkle_pixel_density": 0.07321052192999311,
"wrinkle_area_ratio": 0.056790123456790124,
"wrinkle_deep_ratio": 1,
"wrinkle_deep_num": 1,
"wrinkle_shallow_num": 0
},
"left_cheek_wrinkle_info": {
"wrinkle_score": 0,
"wrinkle_severity_level": 0,
"wrinkle_norm_length": 0,
"wrinkle_norm_depth": 0,
"wrinkle_pixel_density": 0,
"wrinkle_area_ratio": 0,
"wrinkle_deep_ratio": 0,
"wrinkle_deep_num": 0,
"wrinkle_shallow_num": 0
},
"right_cheek_wrinkle_info": {
"wrinkle_score": 0,
"wrinkle_severity_level": 0,
"wrinkle_norm_length": 0,
"wrinkle_norm_depth": 0,
"wrinkle_pixel_density": 0,
"wrinkle_area_ratio": 0,
"wrinkle_deep_ratio": 0,
"wrinkle_deep_num": 0,
"wrinkle_shallow_num": 0
},
"score_info": {
"dark_circle_score": 89,
"skin_type_score": 35,
"wrinkle_score": 90,
"oily_intensity_score": 38,
"pores_score": 97,
"blackhead_score": 99,
"acne_score": 100,
"sensitivity_score": 100,
"melanin_score": 100,
"water_score": 93,
"rough_score": 84,
"total_score": 88,
"pores_type_score": {
"pores_forehead_score": 92,
"pores_leftcheek_score": 98,
"pores_rightcheek_score": 98,
"pores_jaw_score": 100
},
"dark_circle_type_score": {
"left_dark_circle_score": 89,
"right_dark_circle_score": 100
},
"red_spot_score": 97
},
"left_eye_pouch_rect": {
"left": 102,
"top": 137,
"width": 68,
"height": 59
},
"right_eye_pouch_rect": {
"left": 214,
"top": 136,
"width": 67,
"height": 59
},
"melasma": {
"value": 1,
"confidence": 0.50907385
},
"freckle": {
"value": 0,
"confidence": 0.45538568
},
"image_quality": {
"face_rect": {
"left": 81,
"top": 29,
"width": 229,
"height": 320
},
"face_ratio": 0.42792428,
"hair_occlusion": 0.11386067,
"face_orientation": {
"yaw": -5.9413967,
"pitch": 6.6220365,
"roll": 2.846047
},
"glasses": 0
},
"sensitivity_type_v1": 1
},
"face_rectangle": {
"top": 108,
"left": 77,
"width": 238,
"height": 238
},
"side": {
"left": {
"left_jawline_info": {
"left_jawline_quality": 1,
"left_jawline_angle_level": 1,
"left_jawline_angle": 114.81
},
"left_acne_info": {
"score": 98,
"acne": {
"rectangle": [],
"confidence": [],
"polygon": [],
"count": 0
},
"acne_pustule": {
"rectangle": [],
"confidence": [],
"polygon": [],
"count": 0
},
"acne_nodule": {
"rectangle": [],
"confidence": [],
"polygon": [],
"count": 0
},
"acne_mark": {
"rectangle": [],
"confidence": [],
"polygon": [],
"count": 0
},
"closed_comedones": {
"confidence": [
0.33919
],
"count": 1
}
},
"left_melanin_info": {
"mole": {
"confidence": [
0.35832882
],
"count": 1
},
"brown_spot": {
"confidence": [
0.4598653,
0.40733993,
0.3679863,
0.35319555,
0.3008949
],
"count": 5
}
}
},
"right": {
"right_jawline_info": {
"right_jawline_quality": 1,
"right_jawline_angle_level": 1,
"right_jawline_angle": 103.95
},
"right_acne_info": {
"score": 100,
"acne": {
"rectangle": [],
"confidence": [],
"polygon": [],
"count": 0
},
"acne_pustule": {
"rectangle": [],
"confidence": [],
"polygon": [],
"count": 0
},
"acne_nodule": {
"rectangle": [],
"confidence": [],
"polygon": [],
"count": 0
},
"acne_mark": {
"rectangle": [],
"confidence": [],
"polygon": [],
"count": 0
},
"closed_comedones": {
"rectangle": [],
"confidence": [],
"polygon": [],
"count": 0
}
},
"right_melanin_info": {
"mole": {
"confidence": [
0.43544632,
0.34137803
],
"count": 2
},
"brown_spot": {
"count": 8
}
}
}
}
}
+146
View File
@@ -0,0 +1,146 @@
{
"case1": {
"profile": {
"realAge": 30,
"skinType": "混合性"
},
"skinAge": 27,
"totalScore": 70,
"cats": {
"contour": {
"P": 75.5,
"S": 9.27,
"tier": "预警档",
"copy": "下颌线角度左104.54°/右103.75°(标准区间116–120°,双侧均偏离标准,提示下颌缘支撑力下降);眼袋1级、黑眼圈1级;中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。"
},
"wrinkles": {
"P": 23,
"S": 7.69,
"tier": "预警档",
"copy": "全脸皱纹159条、细纹150条,最差区域为左嘴角纹(分区分77,最高严重度3级),部分区域已现静态纹趋势。建议针对眼周、法令纹等高发区使用视黄醇/胜肽类淡纹精华,遏制静态纹转化。"
},
"smooth": {
"P": 57.2,
"S": 8.72,
"tier": "预警档",
"copy": "皮肤表面平整度较低,最差毛孔区位于额头(分区毛孔分32),粗大毛孔1041颗、黑头113颗,T区出油面积57%、左右脸颊20%/2%,纹理分仅82,整体细腻度不足。建议避免过度清洁,重点关注额头,配合低浓度酸类周期护理与柔润精华。"
},
"tone": {
"P": 85,
"S": 9.55,
"tier": "改善档",
"copy": "肤色ITA 58.6(极白,HA 56.2),色素分92,整体稳定但存在局部暗沉倾向(未见明显色斑标记,检出褐色斑37处);水分分78,缺水面积19.8%、严重度22。建议加强抗氧化与光泽管理,做好基础防晒与补水。"
},
"sens": {
"P": 99,
"S": 9.97,
"tier": "良好档",
"copy": "泛红面积0.6%,红区分99,屏障状态稳定,未见明显敏感信号。建议维持现有温和护理,避免过度去角质。"
},
"acne": {
"P": 70,
"S": 9.1,
"tier": "预警档",
"copy": "检出9处皮损(闭口粉刺6处、丘疹3处)。建议尽快进行针对性抗痘护理,避免挤压与刺激,必要时寻求皮肤科专业处理。"
}
},
"dims": {
"firm": {
"P": 23,
"S": 7.69,
"tier": "预警档"
},
"vita": {
"P": 57.2,
"S": 8.72,
"tier": "预警档"
},
"stab": {
"P": 70,
"S": 9.1,
"tier": "预警档"
}
},
"jawline": {
"left": 104.54,
"right": 103.75
},
"group": "25+组",
"essence": "35+支撑精华",
"avgS": 9.05,
"summary": "【综合】实际年龄30岁(接口测得肌肤年龄27岁,差3岁),产品组别25+组;六分类S均分9.05、最差分类皱纹 S=7.69。按三重判据(触发:最差项约束)推荐35+支撑精华:优先改善皱纹(P=23.0)与光滑(P=57.2),其余维度维持现有护理节奏。整体肤质混合性,T区出油面积57%、左右脸颊20%/2%。",
"lifestyle": "保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳"
},
"case2": {
"profile": {
"realAge": 47,
"skinType": "混合性"
},
"skinAge": 50,
"totalScore": 88,
"cats": {
"contour": {
"P": 75.9,
"S": 9.28,
"tier": "预警档",
"copy": "下颌线角度左114.81°/右103.95°(标准区间116–120°,双侧均偏离标准,提示下颌缘支撑力下降);眼袋0级、黑眼圈0级;中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。"
},
"wrinkles": {
"P": 50,
"S": 8.5,
"tier": "预警档",
"copy": "全脸皱纹8条、细纹8条,最差区域为右法令纹(分区分50,最高严重度2级),部分区域已现静态纹趋势。建议针对眼周、法令纹等高发区使用视黄醇/胜肽类淡纹精华,遏制静态纹转化。"
},
"smooth": {
"P": 91.7,
"S": 9.75,
"tier": "良好档",
"copy": "皮肤表面细腻平滑,最差毛孔区额头控制良好(分区毛孔分92,粗大毛孔100颗、黑头3颗),纹理分84,角质层排列整齐。建议维持温和清洁与保湿节奏。"
},
"tone": {
"P": 91.5,
"S": 9.75,
"tier": "良好档",
"copy": "肤色ITA 29.5(小麦色,HA 57.8),色素分100,肤色均匀透亮(黄褐斑,检出褐色斑23处、痣1处);水分分93,缺水面积6.8%、严重度7。色素与水润状态均衡,建议持续做好防晒与基础保湿。"
},
"sens": {
"P": 98.7,
"S": 9.96,
"tier": "良好档",
"copy": "泛红面积0.9%,红区分100,屏障状态稳定,未见明显敏感信号。建议维持现有温和护理,避免过度去角质。"
},
"acne": {
"P": 100,
"S": 10,
"tier": "良好档",
"copy": "未检出痤疮与皮损。建议维持清洁与控油节奏,注意作息与饮食。"
}
},
"dims": {
"firm": {
"P": 50,
"S": 8.5,
"tier": "预警档"
},
"vita": {
"P": 91.5,
"S": 9.75,
"tier": "良好档"
},
"stab": {
"P": 98.7,
"S": 9.96,
"tier": "良好档"
}
},
"jawline": {
"left": 114.81,
"right": 103.95
},
"group": "50+组",
"essence": "50+重塑精华",
"avgS": 9.54,
"summary": "【综合】实际年龄47岁(接口测得肌肤年龄50岁,差3岁),产品组别50+组;六分类S均分9.54、最差分类皱纹 S=8.50。按三重判据(触发:年龄组档)推荐50+重塑精华:优先改善皱纹(P=50.0)与轮廓(P=75.9),其余维度维持现有护理节奏。整体肤质混合性,T区出油面积62%、左右脸颊16%/16%。",
"lifestyle": "保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳"
}
}
+128
View File
@@ -0,0 +1,128 @@
/* ═════ 巨子测肤 H5 — 评分引擎真值回归(Excel 三视图表 × SkinScore)═════
* 用法:node h5/test/regression.js
* fixture 重建:python3 h5/test/build_fixtures.py(Excel 或参考 JSON 变更后需重建)
*
* 断言目标:docs/皮肤检测测试结果_三视图_20260926.xlsx「三视图检测汇总」第 3/4 行
* - 六分类 P(I/M/Q/U/Y/AC)、S(J/N/R/V/Z/AD)、档位(K/O/S/W/AA/AE)
* - 三维度 P/S/档位(AG..AO)、下颌线角度(AP/AQ)、产品组别(AT)、精华线(AU)、均分(AV)
* - 6 分类文案(L/P/T/X/AB/AF)、综合文案(AW)、生活方式(AX) 逐字一致
* - 浮点容差 0.05;文案逐字无容差
*
* 引擎与真值不一致时:不改 h5/js/skin-score.js(审计代理负责),差异登记在 KNOWN_DIFFS
* 并输出明细;未登记的新差异 → 退出码 1。登记项若转为通过(引擎已修复)会提示清理。
*/
'use strict';
var fs = require('fs');
var path = require('path');
var SkinScore = require(path.join(__dirname, '..', 'js', 'skin-score.js'));
/* ─── 已知差异登记:键 = 案例.列,值 = 差异原因(逐条说明)。当前为空:V2.2 审计后
引擎与两组真值全部一致;今后出现引擎/真值不一致时在此登记,未登记的新差异 → 退出码 1 ─── */
var KNOWN_DIFFS = {};
/* ─── 断言计数与输出 ─── */
var TOL = 0.05 + 1e-6; // 浮点容差 0.05(1e-6 仅抵二进制表示误差,如 98.65 vs 98.7)
var pass = 0, known = 0, fail = 0;
var failLines = [], knownLines = [];
function diffCtx(exp, act) { // 首个分歧点 ±30 字上下文
var i = 0, n = Math.min(exp.length, act.length);
while (i < n && exp[i] === act[i]) i++;
var lo = Math.max(0, i - 30);
return ' 分歧@' + i + ':\n 真值 …' + exp.slice(lo, i + 30) + '…\n 引擎 …' + act.slice(lo, i + 30) + '…';
}
function okNum(label, actual, expected) {
if (typeof actual === 'number' && typeof expected === 'number' && Math.abs(actual - expected) <= TOL) {
pass++; console.log(' ✓ ' + label + ' = ' + actual + (actual !== expected ? '(真值 ' + expected + ',容差内)' : ''));
} else {
fail++; failLines.push(label + ':引擎 ' + actual + ' ≠ 真值 ' + expected);
console.error(' ✗ ' + label + ':引擎 ' + actual + ' ≠ 真值 ' + expected);
}
}
function okStr(label, actual, expected, knownKey) {
if (actual === expected) { pass++; console.log(' ✓ ' + label); return; }
var reasons = knownKey ? KNOWN_DIFFS[knownKey] : null;
if (reasons) { // 已登记:已知差异,输出明细,不计失败
known++;
knownLines.push(knownKey);
console.log(' Δ ' + label + ' 已知差异(' + reasons.length + ' 条原因):');
for (var i = 0; i < reasons.length; i++) console.log(' 原因' + (i + 1) + ':' + reasons[i]);
console.log(diffCtx(String(expected), String(actual)));
} else {
fail++;
failLines.push(label + ':文案不一致\n' + diffCtx(String(expected), String(actual)));
console.error(' ✗ ' + label + ':文案不一致(未登记差异)\n' + diffCtx(String(expected), String(actual)));
}
}
/* ─── 列号映射:分类(文案列)与维度 ─── */
var CAT_COLS = { contour: 'I/J/K/L', wrinkles: 'M/N/O/P', smooth: 'Q/R/S/T', tone: 'U/V/W/X', sens: 'Y/Z/AA/AB', acne: 'AC/AD/AE/AF' };
var CAT_COPY_COL = { contour: 'L', wrinkles: 'P', smooth: 'T', tone: 'X', sens: 'AB', acne: 'AF' };
var DIM_COLS = { firm: 'AG/AH/AI', vita: 'AJ/AK/AL', stab: 'AM/AN/AO' };
/* ─── 主流程 ─── */
function loadJSON(name) {
return JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures', name), 'utf8'));
}
function main() {
var truth = loadJSON('truth.json');
var cases = [
{ tag: 'case1', fixture: loadJSON('case1.json'), truth: truth.case1 },
{ tag: 'case2', fixture: loadJSON('case2.json'), truth: truth.case2 }
];
for (var ci = 0; ci < cases.length; ci++) {
var tag = cases[ci].tag, fixture = cases[ci].fixture, tr = cases[ci].truth;
console.log('== ' + tag + '(realAge=' + tr.profile.realAge + ',skinType=' + tr.profile.skinType + ') ==');
var vm = SkinScore.buildViewModel(fixture.result, fixture.side, tr.profile);
// 下颌线角度 AP/AQ:fixture 保真校验(引擎消费角度后由 L 列逐字断言兜底)
okNum(tag + '.AP 左下颌线角度', fixture.side.left.left_jawline_info.left_jawline_angle, tr.jawline.left);
okNum(tag + '.AQ 右下颌线角度', fixture.side.right.right_jawline_info.right_jawline_angle, tr.jawline.right);
okNum(tag + '.E 肌肤年龄', vm.skinAgeRaw, tr.skinAge);
okNum(tag + '.F 总分', vm.total, tr.totalScore);
// 六分类 P/S/档位 + 文案逐字
for (var k in CAT_COLS) {
var cols = CAT_COLS[k].split('/'), c = vm.cats[k], t = tr.cats[k];
okNum(tag + '.' + cols[0] + ' ' + c.name + '·P', c.P, t.P);
okNum(tag + '.' + cols[1] + ' ' + c.name + '·S', c.S, t.S);
okStr(tag + '.' + cols[2] + ' ' + c.name + '·档位', c.level ? c.level + '档' : String(c.level), t.tier);
okStr(tag + '.' + CAT_COPY_COL[k] + ' ' + c.name + '·文案', c.text, t.copy, tag + '.' + CAT_COPY_COL[k]);
}
// 三维度 P/S/档位
for (var d in DIM_COLS) {
var dcols = DIM_COLS[d].split('/'), dd = vm.dims[d], dt = tr.dims[d];
okNum(tag + '.' + dcols[0] + ' ' + dd.name + '·P', dd.P, dt.P);
okNum(tag + '.' + dcols[1] + ' ' + dd.name + '·S', dd.S, dt.S);
okStr(tag + '.' + dcols[2] + ' ' + dd.name + '·档位', dd.level ? dd.level + '档' : String(dd.level), dt.tier);
}
// 组别/精华/均分/综合文案/生活方式
okStr(tag + '.AT 产品组别', String(vm.group), tr.group);
okStr(tag + '.AU 精华产品线', vm.essence.name, tr.essence);
okNum(tag + '.AV 六分类S均分', vm.avgS, tr.avgS);
okStr(tag + '.AW 综合文案', vm.summary, tr.summary, tag + '.AW');
okStr(tag + '.AX 生活方式', vm.lifestyle, tr.lifestyle);
}
// 登记项清理提示:已知差异键本轮全部命中为差异;若某键实际通过则提示移除登记
var knownHit = {}, i;
for (i = 0; i < knownLines.length; i++) knownHit[knownLines[i]] = 1;
for (var key in KNOWN_DIFFS) {
if (!knownHit[key]) console.log(' ◆ 提示:登记项 ' + key + ' 本轮通过(引擎或真值已变化),可从 KNOWN_DIFFS 移除');
}
console.log('----------------------------------------');
console.log('断言 ' + (pass + known + fail) + ' 条:通过 ' + pass + ',已知差异 ' + known + ',失败 ' + fail);
if (knownLines.length) console.log('已知差异明细:' + knownLines.join('、') + '(原因见上,待 skin-score.js 审计代理处理)');
if (failLines.length) {
console.error('失败明细:');
for (i = 0; i < failLines.length; i++) console.error(' ✗ ' + failLines[i]);
}
if (fail > 0) process.exit(1);
console.log(known > 0 ? '结论:全绿(仅剩已登记、已说明原因的已知差异)' : '结论:全绿');
}
main();