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
+1
View File
@@ -10,3 +10,4 @@ credentials.env
node_modules/
*.log
__pycache__/
aipkey.bash
+10 -8
View File
@@ -15,10 +15,12 @@ h5/
├── js/app.js 全部交互(屏幕切换、肤质轮播、引导式三连拍、报告渲染)
├── js/skin-score.js 三维度×6分类评分引擎(纯逻辑 UMD,window.SkinScore,可 node 自测)
├── js/head-pose.js 头部姿态估算(纯逻辑 UMD,window.HeadPose,可 node 自测)
├── test/regression.js 真值回归(node h5/test/regression.js,对照 docs xlsx 两组三视图真值)
├── vendor/faceapi/ face-api.js 0.22.2 + 模型(本地化,见该目录 README)
├── assets/
│ ├── favicon.svg 站点图标
│ ├── img/ 背景图、肤质插画、拍照底图、报告检测/产品图
│ ├── mock/ mock 报告页数据源(report-fixture.js,真实案例1数据)
│ └── svg/ 品牌 Logo 与控件图标
└── preview/ 开发期整页截图(仅存档,部署时可整目录剔除)
@@ -45,7 +47,7 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
调试:
- `http://127.0.0.1:8080/?mock=1` 使用内置样本(`SkinScore.MOCK_RESULT`/`MOCK_SIDE`)跳过真实检测;不加载 face-api、不跑姿态检测,快门手动逐步推进(离线/自动化验证用)。
- `http://127.0.0.1:8080/?mock=1&screen=report` 直渲 mock 报告页(**仅调试用**,供无头浏览器截图验证布局;档案缺省按 28 岁/干性肌肤)。
- `http://127.0.0.1:8080/?mock=1&screen=report` 直渲 mock 报告页(**仅调试用**,供无头浏览器截图验证布局;档案缺省按 28 岁/干性肌肤);mock 数据源为 `assets/mock/report-fixture.js`(真实案例1三视图检测返回,含 face_maps 图层 data URL 与 marks 坐标),改该文件即可换样本。
## 拍照交互(引导式自动三连拍)
@@ -63,18 +65,19 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
## 检测流程与评分渲染
1. 三视图送检:POST `/api/skin-analyze`,请求 `{ front_image, left_image, right_image }`(base64 可带 dataURL 前缀;正脸=photos[0],左=photos[2],右=photos[1])。
2. 服务端转发 Face++ **专业版 `skinanalyze_pro`**(三视图必须走 `image_file` 文件形式),请求 `return_side_results=jawline_info` 取左右下颌线角度,`return_maps`/`return_marks` 取叠加图与标注;响应 `{ ok:true, result, face_rectangle, side:{ left, right } }`,失败 `{ ok:false, code, message }`(message 已是用户可读文案)。
3. 评分由 `js/skin-score.js` 引擎完成,公式/锁值/话术/产品推荐以 `docs/三维度评分与话术生成需求文档_V2.0.md` 为最高契约:
- 6 分类:轮廓(眼袋 severity × 下颌线角度)、皱纹(12 分区)、光滑(毛孔/黑头/纹理)、肤色水润(色素×水润)、敏感(泛红面积/红区分)、痘痘(分级映射×计数扣分);
2. 服务端转发 Face++ **专业版 `skinanalyze_pro`**(三视图必须走 `image_file` 文件形式),请求 `return_side_results=jawline_info` 取左右下颌线角度,`return_maps`/`return_marks` 取叠加图与标注;响应 `{ ok:true, result, face_rectangle, side:{ left, right } }`,失败 `{ ok:false, code, message }`(message 已是用户可读文案)。`result.face_maps` 为对象,含报告页所需图层的完整 data URL(`red_area`/`brown_area`/`texture_enhanced_lines`/`texture_enhanced_pores`/`texture_enhanced_blackheads`,由 server.js 的 `REPORT_MAPS` 常量控制);`marks` 坐标(`acne`/`brown_spot`/`mole`/`closed_comedones`/`acne_pustule`/`acne_nodule`/`wrinkle_mark`/`melanin_mark`/`dark_circle_mark`)随 `result` 原样透传,与上传图片同一像素空间。
3. 评分由 `js/skin-score.js` 引擎完成,公式/锁值/话术/产品推荐以 `docs/三维度评分与话术生成需求文档_V2.2.docx` 为最高契约(V2.2 口径):
- 6 分类:轮廓 = min(眼袋映射分, 下颌线角度分);皱纹(12 分区);光滑(毛孔/黑头/纹理);肤色水润(色素×水润);敏感(泛红面积取 `red_spot.red_spot_area`/红区分);痘痘(分级映射×丘疹 `acne.count` 计数扣分);分组按实际年龄;
- 三维度木桶 min:紧塑力(轮廓+皱纹)、活力感(光滑+肤色水润)、稳定力(敏感+痘痘);
- S = 7 + 3×P/100;档位:良好(P≥90)/改善(80–90)/预警(<80);肤龄钳制到实际年龄 ±3 并留痕;
- S = 7 + 3×P/100;档位:良好(P≥90)/改善(80–90)/预警(<80);肤龄钳制到实际年龄 ±3 并留痕;三视图版 18 条话术;
- 精华推荐三重判据(均分档/最差项约束/年龄组档)取最强档,triggers 记录命中判据。
4. 前端 `SkinScore.buildViewModel(data.result, data.side, { realAge, skinType })` → viewModel 直渲报告页:
- 头部:综合评分(`vm.total`,缺失回退三维度 S 均分×10)、肤龄 `vm.skinAgeDisplay` + 钳制标记 `vm.ageClampNote`;三维度 S(1 位小数)+ 档位配色(良好绿/改善琥珀/预警红);
- 三个 Tab 面板各展示所辖两分类:分类名、S + 档位徽章、话术正文(`vm.cats.*.text`);P 值不直接展示,`P=null` 的分类显示"本次未检出有效数据";
- 报告底部:降级标注(`vm.notes` 非空才展示,如下颌线未参与评分)、综合分析(`vm.summary`)、生活方式建议(`vm.lifestyle`)、精华推荐卡(`vm.essence` + 触发判据);
- 近期优先关注 = 最差维度(`vm.focus`);检测图 img1=正脸实拍、img2=右脸实拍。
5. 检测失败按服务端 message 提示并退回档案页。
- 近期优先关注 = 最差维度(`vm.focus`);6 分类各配检测可视化图:轮廓=正脸实拍、皱纹=正脸压暗+`texture_enhanced_lines` 皱纹增强线(线图层几乎全透明,直挂白底不可读,故与压暗沉脸合成)、光滑=正脸+色斑/痣/痤疮标记叠加、肤色=`brown_area` 色沉图、敏感=`red_area` 红区图、痘痘=正脸+闭口粉刺/痤疮标记叠加(marks 按原像素坐标叠加到正脸实拍)。
5. 真值回归:`node h5/test/regression.js` 以 `docs/皮肤检测测试结果_三视图_20260926.xlsx`(sheet「三视图检测汇总」,数据第 3/4 行=案例1/案例2)两组三视图真值对照评分引擎输出。
6. 检测失败按服务端 message 提示并退回档案页。
## 部署
@@ -123,7 +126,6 @@ server {
- 产品图(prod-1.jpg)为设计稿素材;检测图已替换为用户实拍。
- 相册、切换镜头、「了解更多」为开发中占位(Toast 提示)。
- **侧脸角度符号与 yaw 系数需真机校准**:`head-pose.js` 的 `YAW_COEFF`/pitch 映射与 `mirrored` 符号在不同机型/镜像策略下可能相反,上线前用真机样本回归(校准接口:调用方 `mirrored` 标志与 `HeadPose.config`)。
- **face_maps 叠加图未回传前端**:服务端请求了 `return_maps` 但响应只保留键名列表(整幅 base64 体积过大),报告页暂不使用叠加图,需要时再加服务端裁剪/压缩通道。
## 技术说明
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
# ─── 报告页可视化调试 fixture 生成器(仅本目录工具,需 Pillow) ───
# 数据来源:reference/facepp-skinanalyze/examples/ref_3view_raw.json(案例1 完整原始返回)
# + reference/facepp-skinanalyze/examples/images/C3_180.jpg(案例1 正脸 4000×3000)
# 产物:report-fixture.js → window.MOCK_FIXTURE = { result, face_rectangle, side, photos:[正脸dataURL,null,null] }
# 生产不变量:照片与 face_maps 统一缩到宽 1280,marks 坐标同比例缩放(坐标空间 = 照片空间)
# 用法:python3 build-fixture.py
import base64
import io
import json
import os
from PIL import Image
ROOT = os.path.dirname(os.path.abspath(__file__))
REF = os.path.join(ROOT, '..', '..', '..', 'reference', 'facepp-skinanalyze', 'examples')
TARGET_W = 1280
# 与服务端 REPORT_MAPS 白名单一致(h5/server.js)
MAP_KEYS = ['red_area', 'brown_area', 'texture_enhanced_lines', 'texture_enhanced_pores', 'texture_enhanced_blackheads']
def to_data_url(img, fmt):
buf = io.BytesIO()
if fmt == 'JPEG':
img.convert('RGB').save(buf, 'JPEG', quality=85)
mime = 'image/jpeg'
else:
img.save(buf, 'PNG', optimize=True)
mime = 'image/png'
return 'data:%s;base64,%s' % (mime, base64.b64encode(buf.getvalue()).decode('ascii'))
def scale_coords(node, s):
"""递归缩放 result 内全部坐标:{left,top,width,height} 矩形与 {x,y} 点,与原数据同为整数。"""
if isinstance(node, dict):
ks = node.keys()
if {'left', 'top', 'width', 'height'} <= ks and all(isinstance(node[k], (int, float)) for k in ('left', 'top', 'width', 'height')):
node['left'] = round(node['left'] * s)
node['top'] = round(node['top'] * s)
node['width'] = max(1, round(node['width'] * s))
node['height'] = max(1, round(node['height'] * s))
return
if {'x', 'y'} <= ks and isinstance(node['x'], (int, float)) and isinstance(node['y'], (int, float)):
node['x'] = round(node['x'] * s)
node['y'] = round(node['y'] * s)
return
for v in node.values():
scale_coords(v, s)
elif isinstance(node, list):
for v in node:
scale_coords(v, s)
def main():
raw = json.load(open(os.path.join(REF, 'ref_3view_raw.json'), encoding='utf-8'))
result = raw['result']
src_w, src_h = Image.open(os.path.join(REF, 'images', 'C3_180.jpg')).size
s = TARGET_W / src_w # 4000 → 1280:0.32
target_h = round(src_h * s)
# 正脸照片
photo = Image.open(os.path.join(REF, 'images', 'C3_180.jpg')).convert('RGB')
photo = photo.resize((TARGET_W, target_h), Image.LANCZOS)
photo_url = to_data_url(photo, 'JPEG')
# face_maps:白名单图层,同比例缩放;格式保持原样(jpeg 图层出 jpeg,透明叠加层出 png,与服务端魔数判定一致)
maps = {}
for k in MAP_KEYS:
b64 = result.get('face_maps', {}).get(k)
if not b64:
continue
im = Image.open(io.BytesIO(base64.b64decode(b64)))
fmt = im.format # resize 后 format 丢失,先取
im = im.resize((TARGET_W, target_h), Image.LANCZOS)
maps[k] = to_data_url(im, 'JPEG' if fmt == 'JPEG' else 'PNG')
# marks 坐标随照片同比例缩放(face_maps 已替换为缩放后 dataURL,不参与坐标缩放)
result['face_maps'] = maps
scale_coords(result, s)
face_rect = raw.get('face_rectangle')
if face_rect:
scale_coords(face_rect, s)
fixture = {
'result': result,
'face_rectangle': face_rect,
'side': {'left': raw.get('left_side_result'), 'right': raw.get('right_side_result')},
'photos': [photo_url, None, None] # 与 CAM_STEPS 同序:[0]正脸;侧脸图未纳入 fixture
}
out = os.path.join(ROOT, 'report-fixture.js')
with open(out, 'w', encoding='utf-8') as f:
f.write('/* 报告页可视化 mock fixture — 由 build-fixture.py 生成,勿手改\n')
f.write(' 来源:ref_3view_raw.json(案例1)+ C3_180.jpg;照片与 face_maps 统一宽 %d,marks 坐标同比例缩放(×%.2f)\n' % (TARGET_W, s))
f.write(' 仅在 ?mock=1 时由 index.html 按需注入,生产零额外请求 */\n')
f.write('window.MOCK_FIXTURE = ')
json.dump(fixture, f, ensure_ascii=False, separators=(',', ':'))
f.write(';\n')
print('[build-fixture] %s %.1f KB(照片 %d×%d,图层 %d 张)' % (
out, os.path.getsize(out) / 1024, TARGET_W, target_h, len(maps)))
if __name__ == '__main__':
main()
File diff suppressed because one or more lines are too long
+129
View File
@@ -0,0 +1,129 @@
/* ─── 报告页可视化 mock 冒烟(node,零依赖):node smoke-test.js ───
断言:① fixture 的 dataURL 可解析(mime 与魔数一致、宽 1280)
② marks 坐标缩放后全部落在照片范围内(坐标空间=照片空间)
③ app.js 纯函数段(vizCollectMarks/vizScaleMarks)对 fixture 提取/换算正确
④ fixture.result 能驱动 SkinScore.buildViewModel 出完整报告 */
'use strict';
var fs = require('fs');
var path = require('path');
var pass = 0, fail = 0;
function ok(cond, msg) {
if (cond) { pass++; console.log(' ✓ ' + msg); }
else { fail++; console.error(' ✗ FAIL: ' + msg); }
}
/* ─── 无依赖图片尺寸解析:PNG 读 IHDR,JPEG 扫 SOF ─── */
function imgSize(buf) {
if (buf[0] === 0x89 && buf[1] === 0x50) { // PNG:89 50 4E 47
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20), fmt: 'png' };
}
if (buf[0] === 0xFF && buf[1] === 0xD8) { // JPEG:FF D8,SOF0-15(除 C4/C8/CC)
var off = 2;
while (off + 9 < buf.length) {
if (buf[off] !== 0xFF) { off++; continue; }
var marker = buf[off + 1];
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
return { w: buf.readUInt16BE(off + 7), h: buf.readUInt16BE(off + 5), fmt: 'jpeg' };
}
off += 2 + buf.readUInt16BE(off + 2);
}
}
return null;
}
function parseDataURL(u) {
var m = /^data:(image\/(?:jpeg|png));base64,([A-Za-z0-9+/=]+)$/.exec(u || '');
if (!m) return null;
return { mime: m[1], buf: Buffer.from(m[2], 'base64') };
}
/* ─── ① fixture 加载与 dataURL 解析 ─── */
console.log('== ① fixture dataURL 解析 ==');
var window = {};
new Function('window', fs.readFileSync(path.join(__dirname, 'report-fixture.js'), 'utf8'))(window);
var fx = window.MOCK_FIXTURE;
ok(fx && fx.result && Array.isArray(fx.photos), 'window.MOCK_FIXTURE 结构完整(result/photos)');
var photo = parseDataURL(fx.photos[0]);
var photoSize = photo && imgSize(photo.buf);
ok(!!photo, 'photos[0] 是合法 dataURL');
ok(photoSize && photoSize.fmt === 'jpeg' && photo.mime === 'image/jpeg', '照片 jpeg 魔数与 mime 一致');
ok(photoSize && photoSize.w === 1280 && photoSize.h === 960, '照片尺寸 1280×960(实测 ' + (photoSize ? photoSize.w + '×' + photoSize.h : '?') + ')');
ok(fx.photos[1] === null && fx.photos[2] === null, '侧脸位为 null');
var maps = fx.result.face_maps || {};
['red_area', 'brown_area', 'texture_enhanced_lines', 'texture_enhanced_pores', 'texture_enhanced_blackheads'].forEach(function (k) {
var p = parseDataURL(maps[k]);
var sz = p && imgSize(p.buf);
ok(!!p && !!sz, 'face_maps.' + k + ' dataURL 可解析(' + (sz ? sz.fmt : '失败') + ')');
ok(sz && sz.w === 1280 && sz.h === 960, 'face_maps.' + k + ' 已缩放到 1280×960');
ok(p && sz && p.mime === 'image/' + sz.fmt, 'face_maps.' + k + ' mime 与魔数一致(' + (p && p.mime) + ')');
});
/* ─── ② marks 坐标全部落在照片范围内 ─── */
console.log('== ② marks 坐标范围 ==');
var coordCount = 0, outCount = 0;
(function walk(node) {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) return node.forEach(walk);
var ks = node;
if (typeof ks.left === 'number' && typeof ks.top === 'number' && typeof ks.width === 'number' && typeof ks.height === 'number') {
coordCount++;
if (ks.left < 0 || ks.top < 0 || ks.left + ks.width > 1280 || ks.top + ks.height > 960) outCount++;
return;
}
if (typeof ks.x === 'number' && typeof ks.y === 'number') {
coordCount++;
if (ks.x < 0 || ks.x > 1280 || ks.y < 0 || ks.y > 960) outCount++;
return;
}
Object.keys(ks).forEach(function (k) { if (k !== 'face_maps') walk(ks[k]); });
})(fx.result);
ok(coordCount > 100, 'result 内含坐标点/矩形 ' + coordCount + ' 个(原图 4000×3000 → ×0.32)');
ok(outCount === 0, '全部坐标在 1280×960 照片范围内(越界 ' + outCount + ' 个)');
var fr = fx.face_rectangle;
ok(fr && fr.left >= 0 && fr.top >= 0 && fr.left + fr.width <= 1280 && fr.top + fr.height <= 960,
'face_rectangle 已缩放且在范围内(' + JSON.stringify(fr) + ')');
/* ─── ③ app.js 纯函数段抽离断言 ─── */
console.log('== ③ app.js 纯函数(viz-pure 锚点抽离) ==');
var appSrc = fs.readFileSync(path.join(__dirname, '..', '..', 'js', 'app.js'), 'utf8');
var b = appSrc.indexOf('/* viz-pure-begin'), e = appSrc.indexOf('/* viz-pure-end');
ok(b >= 0 && e > b, 'app.js 含 viz-pure-begin/end 锚点');
var pure = new Function(appSrc.slice(b, e) + '; return { collect: vizCollectMarks, scale: vizScaleMarks };')();
var SMOOTH_MARKS = [{ key: 'brown_spot', color: '#B26A00' }, { key: 'mole', color: '#282828' }, { key: 'acne', color: '#D30C0C' }];
var ACNE_MARKS = [{ key: 'closed_comedones', color: '#E8A13D' }, { key: 'acne', color: '#D30C0C' }, { key: 'acne_pustule', color: '#D30C0C' }, { key: 'acne_nodule', color: '#8E0000' }];
var sm = pure.collect(SMOOTH_MARKS, fx.result);
var am = pure.collect(ACNE_MARKS, fx.result);
ok(sm.length === 2, '光滑:提取到 brown_spot+acne 两组(mole count=0 被跳过),实得 ' + sm.length + ' 组');
ok(sm[0].polygons.length === 37 && sm[0].rects.length === 37, 'brown_spot 37 处 polygon/rectangle 全保留');
ok(am.length === 2 && am[0].rects.length === 6, '痘痘:closed_comedones 6 处 + acne 3 处(pustule/nodule 为空跳过)');
ok(sm[0].color === '#B26A00', '颜色样式随配置透传');
var half = pure.scale(sm, 0.5);
ok(half[0].rects[0].left === sm[0].rects[0].left / 2 && half[0].polygons[0][0][0] === sm[0].polygons[0][0][0] / 2,
'vizScaleMarks 坐标按比例换算(0.5×)');
ok(half[0].rects[0].left + half[0].rects[0].width <= 1280 && half[0].polygons[0].every(function (pt) { return pt[0] <= 1280 && pt[1] <= 960; }),
'缩放后坐标仍在照片范围内');
ok(pure.collect(SMOOTH_MARKS, null).length === 0 && pure.collect(null, fx.result).length === 0, '缺 result/缺配置 → 空集合(优雅降级)');
var noKey = pure.collect([{ key: 'not_exist', color: '#000' }], fx.result);
ok(noKey.length === 0, '标记类型缺失 → 跳过不报错');
/* ─── ④ fixture 驱动真实评分管线 ─── */
console.log('== ④ SkinScore.buildViewModel(fixture) ==');
var SkinScore = require(path.join(__dirname, '..', '..', 'js', 'skin-score.js'));
var vm = SkinScore.buildViewModel(fx.result, fx.side, { realAge: 30, skinType: '中性肌肤' });
var catKeys = ['contour', 'wrinkles', 'smooth', 'tone', 'sens', 'acne'];
var textsOK = true, scoreOK = true;
catKeys.forEach(function (k) {
var c = vm.cats[k];
if (!c || typeof c.text !== 'string' || /undefined|NaN/.test(c.text)) textsOK = false;
if (c.P == null || typeof c.S !== 'number') scoreOK = false;
});
ok(scoreOK, '6 分类全部出分(案例1 数据完整)');
ok(textsOK, '话术无 undefined/NaN');
ok(typeof vm.total === 'number' || vm.dims.firm.S != null, '综合评分/维度分可用');
console.log('\n' + (fail ? '✗ 失败 ' + fail + ' 项,' : '✓ 全部通过,') + '共 ' + (pass + fail) + ' 项断言');
process.exit(fail ? 1 : 0);
+4
View File
@@ -540,3 +540,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; }
/* 精华推荐卡 */
.essence-name { font-size: 15px; font-weight: 700; color: var(--ink); margin-bottom: 6px; }
.essence-triggers { font-size: 7px; color: var(--grey-3); margin-top: 6px; }
/* ═══════════ 第5轮:报告页检测可视化(face_maps 图层 / marks 叠加,新增,不动以上已验收样式) ═══════════ */
/* 分类卡检测图复用 .ditem-img(左图右文、92×96 裁切);无检测图时(hidden)卡片退回文字占位 */
.ditem-img[hidden] { display: none; }
+8
View File
@@ -196,6 +196,7 @@
<div class="dpanel" data-panel="1">
<div class="ditem">
<h3 class="ditem-title">光滑</h3>
<img id="viz-smooth" loading="lazy" class="ditem-img" alt="光滑检测标记图" hidden>
<div class="ditem-text">
<p class="cat-head"><span class="cat-s" id="s-smooth">-.-</span><i class="cat-badge" id="lv-smooth"></i></p>
<p class="ditem-para" id="tx-smooth">检测完成后显示分析结果。</p>
@@ -203,6 +204,7 @@
</div>
<div class="ditem">
<h3 class="ditem-title">肤色水润</h3>
<img id="viz-tone" loading="lazy" class="ditem-img" alt="肤色色沉图" hidden>
<div class="ditem-text">
<p class="cat-head"><span class="cat-s" id="s-tone">-.-</span><i class="cat-badge" id="lv-tone"></i></p>
<p class="ditem-para" id="tx-tone">检测完成后显示分析结果。</p>
@@ -212,6 +214,7 @@
<div class="dpanel" data-panel="2">
<div class="ditem">
<h3 class="ditem-title">敏感</h3>
<img id="viz-sens" loading="lazy" class="ditem-img" alt="敏感红区图" hidden>
<div class="ditem-text">
<p class="cat-head"><span class="cat-s" id="s-sens">-.-</span><i class="cat-badge" id="lv-sens"></i></p>
<p class="ditem-para" id="tx-sens">检测完成后显示分析结果。</p>
@@ -219,6 +222,7 @@
</div>
<div class="ditem">
<h3 class="ditem-title">痘痘</h3>
<img id="viz-acne" loading="lazy" class="ditem-img" alt="痘痘检测标记图" hidden>
<div class="ditem-text">
<p class="cat-head"><span class="cat-s" id="s-acne">-.-</span><i class="cat-badge" id="lv-acne"></i></p>
<p class="ditem-para" id="tx-acne">检测完成后显示分析结果。</p>
@@ -321,6 +325,10 @@
<script src="vendor/faceapi/face-api.js" defer></script>
<script src="js/head-pose.js" defer></script>
<script src="js/skin-score.js" defer></script>
<script>
// 仅 ?mock=1 调试:同步注入报告页可视化 fixture(生产模式不发起该请求)
if (/[?&]mock=1/.test(location.search)) document.write('<script src="assets/mock/report-fixture.js"><\/script>');
</script>
<script src="js/app.js" defer></script>
</body>
</html>
+181 -8
View File
@@ -508,7 +508,7 @@
showScreen('s-profile');
return;
}
renderReport(vm, photos[0], photos[1]); // img1=正脸实拍,img2=右脸实拍
renderReport(vm, photos[0], photos[1], payload); // img1=正脸实拍,payload 供检测可视化取 face_maps/marks
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
showScreen('s-report');
}, wait);
@@ -529,13 +529,20 @@
}
}
/* ─── 分析请求(mock=1 直返 SkinScore 内置样本,便于离线/自动化验证) ─── */
/* ─── 分析请求(mock=1 直返内置样本;有 MOCK_FIXTURE 时用报告页可视化 fixture,便于离线/自动化验证) ─── */
var ANALYZE_TIMEOUT_MS = 45000; // 检测服务超时阈值:超时按「开小差」处理回档案页
function requestAnalyze(ph, seq, done) {
if (IS_MOCK && typeof SkinScore !== 'undefined') {
setTimeout(function () {
if (seq !== camSeq) return; // mock 同样尊重取消/重置
done(null, { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE });
var fx = window.MOCK_FIXTURE; // assets/mock/report-fixture.js(仅 mock 模式注入)
if (fx) {
// marks 坐标空间=fixture 照片空间:三连拍替换为 fixture 照片,保证可视化叠加对齐
ph[0] = fx.photos[0]; ph[1] = fx.photos[1]; ph[2] = fx.photos[2];
done(null, { result: fx.result, side: fx.side, face_rectangle: fx.face_rectangle });
} else {
done(null, { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE });
}
}, 900);
return;
}
@@ -607,7 +614,168 @@
$(dom.tx).textContent = cat.text;
}
function renderReport(vm, frontPhoto, sidePhoto) {
/* ─── 报告页检测可视化(face_maps 图层 + marks 坐标叠加,数据驱动) ─── */
// 6 个分类各声明图片来源:map=整幅图层直接出图;overlay=透明增强图层盖在正脸上;marks=坐标标记画在正脸上
// 增删标记类型/图层:改对应分类的一行即可
var VIZ_CAT = {
contour: { img: '#ditem-img1', photo: true }, // 轮廓:用户正脸实拍
wrinkles: { img: '#ditem-img2', overlay: 'texture_enhanced_lines', dim: 0.55 }, // 皱纹:正脸压暗+皱纹增强线(图层几乎全透明,直挂在白卡上不可读,故合成)
smooth: { img: '#viz-smooth', photo: true, marks: [ // 光滑:正脸+色斑/痣/痤疮标记
{ key: 'brown_spot', color: '#B26A00' }, // 色斑
{ key: 'mole', color: '#282828' }, // 痣
{ key: 'acne', color: '#D30C0C' } // 痤疮
] },
tone: { img: '#viz-tone', map: 'brown_area' }, // 肤色:色沉图
sens: { img: '#viz-sens', map: 'red_area' }, // 敏感:红区图
acne: { img: '#viz-acne', photo: true, marks: [ // 痘痘:正脸+闭口粉刺/痤疮标记
{ key: 'closed_comedones', color: '#E8A13D' }, // 闭口粉刺
{ key: 'acne', color: '#D30C0C' }, // 痤疮
{ key: 'acne_pustule', color: '#D30C0C' }, // 脓包
{ key: 'acne_nodule', color: '#8E0000' } // 结节
] }
};
/* viz-pure-begin(纯函数段:与 DOM 无关,node 冒烟按锚点抽离断言,勿改锚点注释) */
// 从 result 提取某分类的标记集合:polygon 与 rectangle 都保留,缺该标记类型则跳过
function vizCollectMarks(markDefs, result) {
var out = [];
if (!markDefs || !result) return out;
for (var i = 0; i < markDefs.length; i++) {
var src = result[markDefs[i].key];
if (!src || typeof src !== 'object') continue;
var m = { color: markDefs[i].color, polygons: [], rects: [] };
var polys = src.polygon || [];
for (var p = 0; p < polys.length; p++) {
var pts = [];
for (var q = 0; q < (polys[p] || []).length; q++) {
var pt = polys[p][q];
if (pt && typeof pt.x === 'number' && typeof pt.y === 'number') pts.push([pt.x, pt.y]);
}
if (pts.length > 2) m.polygons.push(pts);
}
var rects = src.rectangle || [];
for (var r = 0; r < rects.length; r++) {
var rc = rects[r];
if (rc && typeof rc.left === 'number' && typeof rc.top === 'number') {
m.rects.push({ left: rc.left, top: rc.top, width: +rc.width || 0, height: +rc.height || 0 });
}
}
if (m.polygons.length || m.rects.length) out.push(m);
}
return out;
}
// marks 坐标空间 = 上传图片像素空间:按「画布自然宽 / 坐标空间宽」换算(画布=照片自然尺寸时比值为 1)
function vizScaleMarks(marks, scale) {
var out = [];
for (var i = 0; i < marks.length; i++) {
var m = marks[i], nm = { color: m.color, polygons: [], rects: [] };
for (var p = 0; p < m.polygons.length; p++) {
var pts = [];
for (var q = 0; q < m.polygons[p].length; q++) pts.push([m.polygons[p][q][0] * scale, m.polygons[p][q][1] * scale]);
nm.polygons.push(pts);
}
for (var r = 0; r < m.rects.length; r++) {
var rc = m.rects[r];
nm.rects.push({ left: rc.left * scale, top: rc.top * scale, width: rc.width * scale, height: rc.height * scale });
}
out.push(nm);
}
return out;
}
/* viz-pure-end */
// face_maps 值兼容:已是 dataURL 直接用;裸 base64 按魔数补前缀;其它一律视为缺失(优雅降级)
function vizMapURL(v) {
if (typeof v !== 'string' || !v) return null;
if (v.indexOf('data:image/') === 0) return v;
if (v.indexOf('/9j/') === 0) return 'data:image/jpeg;base64,' + v;
if (v.indexOf('iVBOR') === 0) return 'data:image/png;base64,' + v;
return null;
}
function vizLoadImage(url, done) {
var img = new Image();
img.onload = function () { done(img.naturalWidth ? img : null); };
img.onerror = function () { done(null); };
img.src = url;
}
// canvas 合成:照片绘制到自然尺寸 → (可选压暗)→ (可选盖透明增强图层)→ 画坐标标记 → dataURL 挂到卡片 <img>
function vizCompose(photoURL, overlayURL, marks, imgEl, opts) {
vizLoadImage(photoURL, function (photo) {
if (!photo) return; // 照片不可用:保留占位
var finish = function (overlay) {
var w = photo.naturalWidth, h = photo.naturalHeight;
var cv = document.createElement('canvas');
cv.width = w; cv.height = h;
var ctx = cv.getContext('2d');
ctx.drawImage(photo, 0, 0, w, h);
if (opts && opts.dim) { ctx.fillStyle = 'rgba(0,0,0,' + opts.dim + ')'; ctx.fillRect(0, 0, w, h); } // 压暗底色让增强线可读
if (overlay) ctx.drawImage(overlay, 0, 0, w, h);
var scaled = vizScaleMarks(marks, w / photo.naturalWidth);
var lw = Math.max(3, Math.round(w / 200)); // 线宽随图走,卡片缩略后仍可见
var minBox = Math.round(w / 36); // 过小标记放大到最小框,保证缩略可见
for (var i = 0; i < scaled.length; i++) {
var m = scaled[i];
ctx.strokeStyle = m.color;
ctx.fillStyle = m.color;
ctx.lineWidth = lw;
var p, q, pts, rc;
for (p = 0; p < m.polygons.length; p++) {
pts = m.polygons[p];
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (q = 1; q < pts.length; q++) ctx.lineTo(pts[q][0], pts[q][1]);
ctx.closePath();
ctx.globalAlpha = 0.22;
ctx.fill();
ctx.globalAlpha = 1;
ctx.stroke();
}
for (p = 0; p < m.rects.length; p++) {
rc = m.rects[p];
var rw = Math.max(rc.width, minBox), rh = Math.max(rc.height, minBox);
var cx = rc.left + rc.width / 2, cy = rc.top + rc.height / 2;
ctx.strokeRect(cx - rw / 2, cy - rh / 2, rw, rh);
}
}
try { imgEl.src = cv.toDataURL('image/jpeg', 0.9); imgEl.hidden = false; } catch (e) { /* 合成失败保留占位 */ }
};
if (overlayURL) vizLoadImage(overlayURL, finish);
else finish(null);
});
}
// 报告页 6 张检测图统一入口:任一输入缺失 → 该卡保留既有占位(不报错)
function renderViz(payload, frontPhoto) {
var result = payload && payload.result;
var maps = (result && result.face_maps) || {};
for (var key in VIZ_CAT) {
var cfg = VIZ_CAT[key];
var imgEl = $(cfg.img);
if (!imgEl) continue;
if (cfg.map) { // 整幅图层(色沉图/红区图):图层缺失 → 保留占位
var u = vizMapURL(maps[cfg.map]);
if (u) { imgEl.src = u; imgEl.hidden = false; }
continue;
}
if (cfg.overlay) { // 透明增强图层(皱纹):有照片则合成(可按 cfg.dim 压暗),无照片单挂图层;图层缺失 → 保留占位
var ov = vizMapURL(maps[cfg.overlay]);
if (!ov) continue;
if (!frontPhoto) { imgEl.src = ov; imgEl.hidden = false; continue; }
vizCompose(frontPhoto, ov, [], imgEl, { dim: cfg.dim });
continue;
}
// 照片类(轮廓/光滑/痘痘):无照片 → 保留占位;有坐标则 canvas 叠加,无坐标纯照片
if (!frontPhoto) continue;
var marks = cfg.marks ? vizCollectMarks(cfg.marks, result) : [];
if (!marks.length) { imgEl.src = frontPhoto; imgEl.hidden = false; continue; }
vizCompose(frontPhoto, null, marks, imgEl);
}
}
function renderReport(vm, frontPhoto, sidePhoto, payload) {
// 头部:综合评分(vm.total 缺失时回退三维度 S 均分×10)
var total = vm.total;
if (total == null) {
@@ -630,8 +798,9 @@
// 三个 Tab 面板:各所辖两个分类(S + 档位徽章 + 话术正文)
var keys = ['contour', 'wrinkles', 'smooth', 'tone', 'sens', 'acne'];
for (var i = 0; i < keys.length; i++) setCat(keys[i], vm.cats[keys[i]]);
if (frontPhoto) $('#ditem-img1').src = frontPhoto;
if (sidePhoto) $('#ditem-img2').src = sidePhoto;
// 检测可视化:轮廓=正脸实拍,其余按 VIZ_CAT 取 face_maps 图层或叠加 marks(payload 缺失时各卡保留占位)
renderViz(payload, frontPhoto);
// 降级标注(下颌线未参与评分等,非空才展示)
var notesEl = $('#vm-notes');
@@ -776,13 +945,17 @@
/* ─── 初始化 ─── */
renderCamStep();
// 仅调试用:?mock=1&screen=report 直渲 mock 报告页(供无头浏览器截图验证布局,非正式入口)
// 有 MOCK_FIXTURE 时走 fixture(检测可视化同步出图),否则回退 SkinScore 内置样本
if (IS_MOCK && /[?&]screen=report/.test(location.search) && typeof SkinScore !== 'undefined') {
showScreen('s-report');
try {
renderReport(SkinScore.buildViewModel(SkinScore.MOCK_RESULT, SkinScore.MOCK_SIDE, {
var fx = window.MOCK_FIXTURE;
var mockRes = fx ? fx.result : SkinScore.MOCK_RESULT;
var mockSide = fx ? fx.side : SkinScore.MOCK_SIDE;
renderReport(SkinScore.buildViewModel(mockRes, mockSide, {
realAge: +($('#f-age').value || 0) || 28, // 调试档案缺省 28 岁
skinType: SKINS[skinIndex].name
}), null, null);
}), fx ? fx.photos[0] : null, null, fx ? { result: mockRes, side: mockSide, face_rectangle: fx.face_rectangle } : null);
} catch (e) { /* 调试入口异常不阻塞正式流程 */ }
}
})();
+170 -52
View File
@@ -1,16 +1,19 @@
/* ═════ 巨子测肤 H5 — 三维度×6分类评分引擎(纯逻辑,浏览器/Node 双端) ═════
* 最高契约:docs/三维度评分与话术生成需求文档_V2.0.md(公式/锁值/话术/产品推荐以其为准)
* 最高契约:docs/三维度评分与话术生成需求文档_V2.2.docx(公式/锁值/话术/产品推荐以其为准)
* 浏览器:window.SkinScore;Node:module.exports。ES5、零依赖、不碰 DOM。
*
* 字段口径(已对 reference/facepp-skinanalyze/examples/ 真实 pro JSON 复核):
* - 分数类字段优先取 result.score_info.<key>,缺失退化 result.<key>(V2.0 §137 归一)
* - 分数类字段优先取 result.score_info.<key>,缺失退化 result.<key>(V2.2 §3.1 映射归一)
* - pores_type_score 分区键存在无下划线变体(pores_leftcheek_score 等),且无 severity 键
* - wrinkle_count / fine_line 为分区计数对象(forehead_count 等叶子),非 {value:n}
* - 敏感面积优先 red_spot.red_spot_area(V2.0 第二章字段来源),缺失回退 sensitivity.sensitivity_area
* - 敏感面积优先 red_spot.red_spot_area(V2.2 §3.1:小数×100=%),缺失回退 sensitivity.sensitivity_area
* - eye_pouch_severity.value 为 0–3 枚举(缺失退化 eye_pouch.value,非整数取整、越界按缺失)
* - sensitivity_area / red_spot_area / water_area ≤1 视为小数口径×100,否则按百分比直读
* - 水分交叉校验「按 severity 换算」实现为 水分项 = 100 − water_severity(待客户确认)
* - oily_intensity_score → 出油概览句为自设计措辞(文档未给全文)
* - 黑眼圈级取 dark_circle_severity.value,缺失退化 dark_circle.value(真值表口径:案例1=1 级,非 dark_circle 的 3)
* - sensitivity_area / red_spot_area / water_area / oily 分区 area ≤1 视为小数口径×100,否则按百分比直读
* - 水分交叉校验「按 severity 换算」实现为 水分项 = 100 − water_severity(文档未给公式,待客户确认)
* - {oil_desc} 按 V2.2 §7.2 由 oily_intensity 分区面积生成:T区出油面积X%、左右脸颊Y%/Z%,全脸 intensity=0 省略
* - {st} 肤色分类、{skin_type_cn} 肤质类型按接口枚举映射中文(skintone 0–4 / skin_type 0–3,真值已校)
* - {spot_flag} 按 V2.2 §7.2:色斑基句 +「,检出褐色斑N处」「、痣N处」续接,为 0 整体省略
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
@@ -27,7 +30,7 @@
var n = Number(x);
return isNaN(n) ? null : n;
}
function pick(r, key) { // score_info 优先,再顶层(§137 字段归一)
function pick(r, key) { // score_info 优先,再顶层(§3.1 字段归一)
if (r == null) return null;
var si = r.score_info;
if (si && si[key] != null) { var v = val(si[key]); if (v != null) return v; }
@@ -73,9 +76,12 @@
var v = round2(x);
return Math.round(v) === v ? String(Math.round(v)) : String(v);
}
function round1(x) { return Math.round((x + 1e-9) * 10) / 10; }
function fmtS(x) { return x == null ? '' : round2(x).toFixed(2); } // 综合文案 S:固定两位小数(真值「S=8.50」)
function fmtP(x) { return x == null ? '' : round1(x).toFixed(1); } // 综合文案 P:固定一位小数(真值「P=23.0」)
/* 模板填充:{var} 替换;变量缺失(null/undefined/'')时省略包含它的最小分句(,;。界),
* 严禁输出 None/undefined/— 占位(V2.0 第五章生成规则)。 */
* 严禁输出 None/undefined/— 占位(V2.2 第七章生成规则与 7.3 缺值规则)。 */
function fill(tpl, vars) {
var parts = tpl.split(/([,;。])/);
var out = '';
@@ -98,7 +104,7 @@
}
/* ─── 字段归一:分区配置 ─── */
var WRINKLE_REGIONS = [ // 12 分区;left_nasolabial_wrinkle_info 与 _fold_info 并存(§137)
var WRINKLE_REGIONS = [ // 12 分区;left_nasolabial_wrinkle_info 与 _fold_info 并存(V2.2 §3.3④ 变体归一)
{ keys: ['forehead_wrinkle_info'], cn: '抬头纹' },
{ keys: ['glabella_wrinkle_info'], cn: '眉间纹' },
{ keys: ['left_eye_wrinkle_info'], cn: '左眼周' },
@@ -116,6 +122,14 @@
forehead: '额头', left_cheek: '左脸颊', right_cheek: '右脸颊', jaw: '下巴', nose: '鼻部',
leftcheek: '左侧面颊', rightcheek: '右侧面颊', chin: '下巴'
};
var SKINTONE_CN = { 0: '极白', 1: '白皙', 2: '自然', 3: '小麦色', 4: '黝黑' }; // skintone_ita.skintone 枚举(真值:0→极白、3→小麦色)
var SKIN_TYPE_CN = { 0: '油性', 1: '干性', 2: '中性', 3: '混合性' }; // skin_type.skin_type 枚举(真值:3→混合性)
function skinTypeCnOf(r, profile) { // {skin_type_cn}:接口 skin_type 优先,缺失回退用户自选肤质(V2.2 §8.5 肤质不参与分数)
var st = r ? r.skin_type : null;
var v = st != null && typeof st === 'object' ? val(st.skin_type != null ? st.skin_type : st.value) : val(st);
if (v != null && SKIN_TYPE_CN[v] != null) return SKIN_TYPE_CN[v];
return (profile && profile.skinType) || null;
}
/* ─── 分类计算(每项返回 { P, vars },P=null 表示缺项) ─── */
@@ -177,12 +191,14 @@
if (pouchP != null) cand.push(pouchP);
if (jawP != null) cand.push(jawP);
if (!cand.length) { notes.push('轮廓:眼袋与下颌线数据均缺失,轮廓分类不参与评分'); return { P: null, vars: {} }; }
var dcSev = pick(r, 'dark_circle_severity'); // 黑眼圈级:dark_circle_severity 优先(真值口径),缺失退化 dark_circle
if (dcSev == null) dcSev = pick(r, 'dark_circle');
return {
P: Math.min.apply(null, cand),
vars: {
la: la, ra: ra,
eye_pouch: pouchLvl, // 取整后的等级;越界/缺失时为 null(话术省略该分句)
dark_circle_sev: pick(r, 'dark_circle')
dark_circle_sev: dcSev
}
};
}
@@ -291,7 +307,7 @@
pore_cnt: poreCnt,
blackhead_cnt: bhCnt,
tex: rough,
oily: pick(r, 'oily_intensity_score')
oily: r.oily_intensity // {oil_desc} 数据源(V2.2 §7.2 分区面积)
}
};
}
@@ -328,17 +344,26 @@
var P = n === 2 ? acc : acc * 2 / n; // 单项时按 0.5 权重归一
var itaObj = r.skintone_ita || {};
var haObj = r.skin_hue_ha || {};
var st = itaObj.skin_tone != null ? itaObj.skin_tone
var stRaw = itaObj.skin_tone != null ? itaObj.skin_tone
: (itaObj.skintone != null ? itaObj.skintone // 真实键名为 skintone(另有 ITA 值),兼容 skin_tone/tone
: (itaObj.tone != null ? itaObj.tone : null));
// 肤色分类枚举→中文(真值:0→极白、3→小麦色);未收录枚举值原样透出
var st = stRaw != null && SKINTONE_CN[stRaw] != null ? SKINTONE_CN[stRaw] : stRaw;
// {spot_flag}(V2.2 §7.2):检出黄褐斑和/或雀斑写该种,均未检出写「未见明显色斑标记」;褐色斑/痣数>0 续接,为 0 整体省略
var spotFlag = (melasma || freckle) ? joinParts([melasma ? '黄褐斑' : '', freckle ? '雀斑' : ''], '、') : '未见明显色斑标记';
var bsCnt = cnt(r.brown_spot), moleCnt = cnt(r.mole);
if (bsCnt != null && bsCnt > 0) spotFlag += ',检出褐色斑' + fmt(bsCnt) + '处';
if (moleCnt != null && moleCnt > 0) spotFlag += '、痣' + fmt(moleCnt) + '处';
var itaV = val(itaObj.ITA != null ? itaObj.ITA : itaObj.value);
var haV = val(haObj.HA != null ? haObj.HA : haObj.value);
return {
P: P,
vars: {
ita: val(itaObj.ITA != null ? itaObj.ITA : itaObj.value),
ita: itaV == null ? null : round1(itaV), // 真值表为 1 位小数(58.64615→58.6、56.246513→56.2)
st: st,
ha: val(haObj.HA != null ? haObj.HA : haObj.value),
ha: haV == null ? null : round1(haV),
melanin: melanin,
spot_flag: (melasma || freckle) ? joinParts([melasma ? '黄褐斑' : '', freckle ? '雀斑' : ''], '、') : '未见明显色斑标记',
spot_flag: spotFlag,
water_score: waterScore,
water_area: waterArea,
water_sev: waterSev
@@ -351,7 +376,7 @@
var sensObj = r.sensitivity || null;
var score = pick(r, 'sensitivity_score');
if (score == null && sensObj) score = val(sensObj.sensitivity_score);
// 面积口径来源(V2.0 第二章字段来源):优先 red_spot.red_spot_area(小数×100=%);
// 面积口径来源(V2.2 §3.1):优先 red_spot.red_spot_area(小数×100=%);
// 缺失再回退 sensitivity.sensitivity_area;两者均保留 ≤1→×100 启发式
var areaRaw = r.red_spot && typeof r.red_spot === 'object' ? val(r.red_spot.red_spot_area) : null;
if (areaRaw == null) {
@@ -408,18 +433,29 @@
P: Math.min.apply(null, cand),
vars: {
acne_cnt: allNull ? null : total,
acne_detail: detail,
heavy: (pu || 0) > 0 || (no || 0) > 0 // 仅在真实检出脓包/结节时输出 heavy_note
acne_detail: detail
}
};
}
/* ─── 话术库(V2.0 第五章逐字;变量缺失省略对应分句) ─── */
function oilDesc(oily) { // {oil_desc} 出油概览句(自设计措辞,文档未给全文)
if (oily == null) return '';
if (oily < 40) return '肌肤出油较少,整体清爽';
if (oily < 60) return 'T区存在轻度出油';
return '全脸出油偏多,需加强控油';
/* ─── 话术库(V2.2 第七章逐字;变量缺失省略对应分句) ─── */
function oilPct(node) { // oily_intensity 分区面积:≤1 小数×100,否则直读
if (!node) return null;
var a = val(node.area);
return a == null ? null : (a <= 1 ? a * 100 : a);
}
function oilDesc(oily) { // {oil_desc}(V2.2 §7.2):T区出油面积X%、左右脸颊Y%/Z%;全脸 intensity=0 或数据缺失时整体省略
if (!oily || typeof oily !== 'object') return '';
var ff = oily.full_face;
if (ff && val(ff.intensity) === 0) return '';
var t = oilPct(oily.t_zone), l = oilPct(oily.left_cheek), rr = oilPct(oily.right_cheek);
if (t == null && l == null && rr == null) return '';
var parts = [];
if (t != null) parts.push('T区出油面积' + fmt(t) + '%');
if (l != null && rr != null) parts.push('左右脸颊' + fmt(l) + '%/' + fmt(rr) + '%');
else if (l != null) parts.push('左脸颊' + fmt(l) + '%');
else if (rr != null) parts.push('右脸颊' + fmt(rr) + '%');
return parts.join('、');
}
function jawSeg(v, goodTpl) { // 组合下颌线分句;返回 '' 表示省略
if (v.la == null && v.ra == null) return '';
@@ -442,9 +478,10 @@
}
function textContour(P, v) {
var lv = levelOf(P), jaw = jawSeg(v, lv === '良好'), eye = eyeSeg(v);
if (lv === '预警') return fill('面部轮廓出现可见松弛{jaw};{eye};中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。',
{ jaw: jaw ? ',' + jaw : '', eye: eye });
// V2.0 §5.1 改善档原文只含眼袋,不含黑眼圈
// V2.2 第七章预警档原文自「下颌线角度…」起,无额外前缀句
if (lv === '预警') return fill('{jaw};{eye};中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。',
{ jaw: jaw, eye: eye });
// V2.2 第七章改善档原文只含眼袋,不含黑眼圈
if (lv === '改善') return fill('{jaw},{eye},眼下区域出现轻度松弛信号。建议增加紧致类精华步骤,配合由下向上的提拉按摩手法,重点关照眼周与中面部。',
{ jaw: jaw, eye: v.eye_pouch != null ? '眼袋' + fmt(v.eye_pouch) + '级' : '' });
return fill('面部轮廓紧致立体{jaw},{eye},中面部容积饱满,未见明显软组织下垂。轮廓支撑力良好,建议维持日常紧致护理与防晒,保持规律作息,守护现有轮廓。',
@@ -513,10 +550,9 @@
}
function textAcne(P, v) {
var lv = levelOf(P);
var heavy = v.heavy ? ',含脓包、结节等中重度皮损,炎症反应明显' : '';
// 「检出N处皮损(明细)」与 heavy 拼接成句:heavy 为空时不得拖垮整分句(fill 的缺失省略以分句为单位)
// 「检出N处皮损(明细)」拼接成句(V2.2 第七章预警档逐字:检出{acne_cnt}处皮损({acne_detail})。建议…)
var found = (v.acne_cnt != null && v.acne_detail) ? '检出' + fmt(v.acne_cnt) + '处皮损(' + v.acne_detail + ')' : '';
if (lv === '预警') return (found ? found + heavy + '。' : '')
if (lv === '预警') return (found ? found + '。' : '')
+ '建议尽快进行针对性抗痘护理,避免挤压与刺激,必要时寻求皮肤科专业处理。';
if (lv === '改善') return (found ? found + ',' : '')
+ '以闭口粉刺、丘疹为主,局部存在炎症倾向。建议加强温和清洁与控油,避免挤压,配合抗炎舒缓护理。';
@@ -539,7 +575,7 @@
{ key: 'vita', name: '活力感', tip: '细腻度与水润光泽:加强补水与温和清洁,做好防晒与抗氧化管理' },
{ key: 'stab', name: '稳定力', tip: '屏障稳定与痘痘管理:温和清洁、舒缓修护,避免刺激源与挤压' }
];
var LIFESTYLE = { // V2.0 §6.2 全文
var LIFESTYLE = { // V2.2 §8.4 全文
contour: '戒除揉眼、托腮等表情性挤压习惯,保证睡眠减少纹路加深',
wrinkles: '保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳',
smooth: '规律去角质但不过度(每1–2周一次),运动后及时清洁,减少油炸与高糖饮食',
@@ -547,10 +583,10 @@
sens: '避免过热的水洗脸与桑拿,选择无香精配方,换季时精简护肤步骤',
acne: '不挤压痘痘,枕巾高频更换,减少乳制品与高升糖饮食摄入'
};
var ESSENCE = { // V2.0 §6.1 三档全文
var ESSENCE = { // V2.2 §8.3 三档全文(前缀「建议搭配使用X:」为报告页拼接胶)
1: { name: '25+束紧精华', text: '建议搭配使用25+束紧精华:针对年轻肌肤活力减弱问题,激活肌肤动力、改善暗沉,提升弹润紧致表现,守护年轻光泽并延缓初始衰老迹象。' },
2: { name: '35+支撑精华', text: '建议搭配使用35+支撑精华:专为轻熟肌打造,提升弹韧表现、维持面部轮廓清晰度,改善疲态并延缓衰老速度。' },
3: { name: '50+重塑精华', text: '建议搭配使用50+重塑精华:针对成熟肌肤恢复能力下降,提升紧致表现、改善松弛与轮廓模糊,修复强韧屏障,重塑年轻质感。' }
3: { name: '50+重塑精华', text: '建议搭配使用50+重塑精华:针对熟龄肌松弛与轮廓流失问题,深层重塑支撑结构,改善明显松弛与纹路,重建面部紧实与弹性。' }
};
/* ─── 主入口 ─── */
@@ -572,6 +608,7 @@
var group = null;
if (realAge != null) group = realAge < 32 ? '25+组' : (realAge <= 45 ? '35+组' : '50+组');
var skinTypeCn = skinTypeCnOf(r, profile); // {skin_type_cn}:接口枚举优先,回退用户自选肤质
// 6 分类
var cats = {}, sList = [], i, def, res;
@@ -646,7 +683,7 @@
if (fP == null || dP2 < fP) { fP = dP2; focus = { name: dd.name, tip: dd.tip }; }
}
// 综合文案(§6.1)
// 综合文案(V2.2 §8.2 模板)
var summary;
if (avgRaw == null) {
summary = '【综合】本次检测数据不足,暂无法生成完整评分,建议重新拍摄检测。';
@@ -667,14 +704,14 @@
}
sorted.sort(function (a, b) { return a.P - b.P; });
var improve = '';
if (sorted.length >= 2) improve = '优先改善' + sorted[0].name + '(P=' + fmt(sorted[0].P) + ')与' + sorted[1].name + '(P=' + fmt(sorted[1].P) + '),其余维度维持现有护理节奏。';
else if (sorted.length === 1) improve = '优先改善' + sorted[0].name + '(P=' + fmt(sorted[0].P) + '),其余维度维持现有护理节奏。';
if (sorted.length >= 2) improve = '优先改善' + sorted[0].name + '(P=' + fmtP(sorted[0].P) + ')与' + sorted[1].name + '(P=' + fmtP(sorted[1].P) + '),其余维度维持现有护理节奏。';
else if (sorted.length === 1) improve = '优先改善' + sorted[0].name + '(P=' + fmtP(sorted[0].P) + '),其余维度维持现有护理节奏。';
var tail = joinParts([
profile.skinType ? '整体肤质' + profile.skinType : '',
oilDesc(pick(r, 'oily_intensity_score'))
skinTypeCn ? '整体肤质' + skinTypeCn : '',
oilDesc(r.oily_intensity)
], ',');
summary = '【综合】' + (head ? head + ';' : '')
+ '六分类S均分' + fmt(avgS) + (worst ? '、最差分类' + worst.name + ' S=' + fmt(worst.S) : '') + '。'
+ '六分类S均分' + fmtS(avgRaw) + (worst ? '、最差分类' + worst.name + ' S=' + fmtS(sRaw(worst.P)) : '') + '。'
+ '按三重判据' + (triggers.length ? '(触发:' + triggers.join('、') + ')' : '') + '推荐' + essence.name
+ (improve ? ':' + improve : '。')
+ (tail ? tail + '。' : '');
@@ -709,7 +746,7 @@
blackhead_score: 88,
acne_score: 86,
oily_intensity_score: 42,
pores_type_score: { // §137:嵌套在 score_info 内
pores_type_score: { // §3.1:嵌套在 score_info 内
pores_forehead_score: 73, pores_left_cheek_score: 86,
pores_right_cheek_score: 85, pores_jaw_score: 90,
pores_forehead_severity: 1, pores_left_cheek_severity: 1,
@@ -718,6 +755,11 @@
},
skin_age: { value: 20 },
skin_type: { skin_type: 3 },
oily_intensity: { // 真实形状:分区 {area(小数),intensity},{oil_desc} 数据源(数值对齐案例1)
t_zone: { area: 0.57, intensity: 2 }, left_cheek: { area: 0.2, intensity: 1 },
right_cheek: { area: 0.02, intensity: 0 }, chin_area: { area: 0, intensity: 0 },
full_face: { area: 0.4, intensity: 2 }
},
eye_pouch: { value: 0, confidence: 0.75 },
eye_pouch_severity: { value: 0 },
dark_circle: { value: 1, confidence: 0.63 },
@@ -851,7 +893,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
ok(vm4.essence.name === '50+重塑精华' && vm4.essence.triggers.indexOf('最差项约束') >= 0, '最差项 S 原值<7 → 50+重塑精华,triggers 含最差项约束(三重判据用未舍入原值)');
checkTexts(vm4, 'T4');
console.log('== T5 缺数据降级(V2.0 第七章) ==');
console.log('== T5 缺数据降级(V2.2 第十章异常与边界) ==');
var r5 = clone(SS.MOCK_RESULT);
delete r5.sensitivity;
delete r5.score_info.sensitivity_score;
@@ -892,12 +934,13 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
// 扣分 = 3×1+3×2+2×4+0+1×1.5 = 18.5 → 100−18.5=81.5 < 分级映射85
ok(approx(vm8.cats.acne.P, 81.5), '痘痘P = min(85, 100−18.5) = 81.5(rectangle 退化计数)');
ok(vm8.cats.acne.text.indexOf('脓包2处') >= 0, 'acne_detail 按计数列明细,为0的类型不列');
var vm8b = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 }); // 含脓包1处
ok(vm8b.cats.acne.text.indexOf('含脓包、结节等中重度皮损') < 0, '改善档无 heavy_note(heavy_note 仅预警档且真实检出时输出)');
var vm8b = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 }); // 含脓包1处,改善档
ok(vm8b.cats.acne.text.indexOf('(闭口粉刺3处、丘疹3处、脓包1处、痘印1处),以闭口粉刺、丘疹为主') >= 0
&& vm8b.cats.acne.text.indexOf('含脓包、结节') < 0, '改善档逐字(V2.2 第七章无 heavy_note 分句)');
var r8c = clone(SS.MOCK_RESULT);
r8c.score_info.acne_score = 70; // 分级映射 → 55,预警档
ok(SS.buildViewModel(r8c, SS.MOCK_SIDE, { realAge: 25 }).cats.acne.text.indexOf('(闭口粉刺3处、丘疹3处、脓包1处、痘印1处),含脓包、结节等中重度皮损,炎症反应明显。') >= 0,
'预警档真实检出脓包 → heavy_note 逐字输出');
ok(SS.buildViewModel(r8c, SS.MOCK_SIDE, { realAge: 25 }).cats.acne.text.indexOf('检出8处皮损(闭口粉刺3处、丘疹3处、脓包1处、痘印1处)。建议尽快进行针对性抗痘护理') >= 0,
'预警档逐字:检出{acne_cnt}处皮损({acne_detail})。建议尽快…(V2.2 第七章)');
console.log('== T9 真实接口字段形状(对齐 reference/facepp-skinanalyze/examples 样本) ==');
// (5)(9) 毛孔分区无下划线变体键 + 无 severity 键
@@ -909,7 +952,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
ok(vm9.notes.join('|').indexOf('毛孔分区严重度未返回,光滑上限锁未执行') >= 0, 'pores_type_score 无 severity 键 → notes 留痕「光滑上限锁未执行」');
checkTexts(vm9, 'T9-毛孔');
// (6) 敏感面积优先 red_spot.red_spot_area(V2.0 第二章字段来源)
// (6) 敏感面积优先 red_spot.red_spot_area(V2.2 §3.1 字段映射)
var r9b = clone(SS.MOCK_RESULT);
r9b.red_spot = { red_spot_area: 0.006, red_spot_intensity: 3 }; // 小数口径 → 0.6%
r9b.sensitivity.sensitivity_area = 0.5; // 若干扰字段被误读会变 50% → 锁 74
@@ -928,14 +971,24 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
'wrinkle_count/fine_line 分区对象数值叶子求和 → 全脸皱纹159条、细纹150条');
checkTexts(vm9d, 'T9-皱纹计数');
// (8) 肤色分类真实键名 skintone_ita.skintone(值为 0 时不得被 falsy 吞掉)
// (8) 肤色分类真实键名 skintone_ita.skintone:枚举→中文(0→极白),ITA/HA 真值表口径 1 位小数
var r9e = clone(SS.MOCK_RESULT);
r9e.skintone_ita = { ITA: 58.64615, skintone: 0 };
r9e.skin_hue_ha = { HA: 56.246513, skin_hue: 0 };
var vm9e = SS.buildViewModel(r9e, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9e.cats.tone.text.indexOf('肤色ITA 58.65(0,HA 56.25)') >= 0, 'skintone_ita.skintone=0 → 肤色分类 0 正常输出');
ok(vm9e.cats.tone.text.indexOf('肤色ITA 58.6(极白,HA 56.2)') >= 0, 'skintone_ita.skintone=0 → 中文「极白」,ITA/HA 各 1 位小数(58.64615→58.6、56.246513→56.2)');
// (10)(12) 眼袋 severity 非整数取整 / 越界按缺失;轮廓改善档话术无「黑眼圈」(V2.0 §5.1 逐字)
// (14) {spot_flag} 褐色斑/痣续接(V2.2 §7.2):基句 + 「,检出褐色斑N处」「、痣N处」,为 0 省略
var vm9e2 = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 }); // MOCK:色斑均未检出、mole=2
ok(vm9e2.cats.tone.text.indexOf('(未见明显色斑标记、痣2处)') >= 0, '{spot_flag} 均未检出 + 痣2处 → 「未见明显色斑标记、痣2处」');
var r9e3 = clone(SS.MOCK_RESULT);
r9e3.melasma = { value: 1 };
r9e3.brown_spot = { count: 23 };
r9e3.mole = { count: 1 };
ok(SS.buildViewModel(r9e3, SS.MOCK_SIDE, { realAge: 25 }).cats.tone.text.indexOf('(黄褐斑,检出褐色斑23处、痣1处)') >= 0,
'{spot_flag} 黄褐斑 + 褐色斑23处 + 痣1处 → 案例2 真值句式');
// (10)(12) 眼袋 severity 非整数取整 / 越界按缺失;轮廓改善档话术无「黑眼圈」(V2.2 第七章逐字)
var r9f = clone(SS.MOCK_RESULT);
r9f.eye_pouch_severity = { value: 1.6 };
var vm9f = SS.buildViewModel(r9f, SS.MOCK_SIDE, { realAge: 25 });
@@ -946,7 +999,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
right: { right_jawline_info: { right_jawline_angle: 110, right_jawline_quality: 1 } }
}, { realAge: 25 });
ok(vm9f2.cats.contour.level === '改善' && vm9f2.cats.contour.text.indexOf('眼袋0级') >= 0 && vm9f2.cats.contour.text.indexOf('黑眼圈') < 0,
'轮廓改善档含「眼袋0级」且无「黑眼圈」(§5.1 改善档原文无黑眼圈)');
'轮廓改善档含「眼袋0级」且无「黑眼圈」(V2.2 第七章改善档原文无黑眼圈)');
var r9g = clone(SS.MOCK_RESULT);
r9g.eye_pouch_severity = { value: 9 };
var vm9g = SS.buildViewModel(r9g, SS.MOCK_SIDE, { realAge: 25 });
@@ -961,14 +1014,79 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma
var vm9h = SS.buildViewModel(r9h, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9h.cats.acne.text.indexOf('未检出') < 0 && vm9h.cats.acne.text.indexOf('未获取皮损计数数据') >= 0,
'痘痘良好档且计数全缺失 → 缺数据提示句(不声称未检出)');
// 预警档无脓包/结节(heavy 为空)时「检出N处皮损(明细)」不得被 fill 分句省略拖垮(真实样本形状:丘疹3+闭口6)
// 预警档「检出N处皮损(明细)」为固定首句,明细为 0 的类型不列(真实样本形状:丘疹3+闭口6)
var r9i = clone(SS.MOCK_RESULT);
r9i.acne_pustule = { count: 0, rectangle: [] };
r9i.acne_mark = { count: 0, rectangle: [] };
r9i.score_info.acne_score = 70; // 分级映射 55 → 预警档
var vm9i = SS.buildViewModel(r9i, SS.MOCK_SIDE, { realAge: 25 });
ok(vm9i.cats.acne.text.indexOf('检出6处皮损(闭口粉刺3处、丘疹3处)。建议尽快') >= 0,
'痘痘预警档无中重度皮损 → 计数明细正常输出、无 heavy_note');
'痘痘预警档 → 计数明细正常输出(为0类型不列)');
console.log('== T11 水润 severity 换算触发(V2.2 §3 交叉校验) ==');
var r11 = clone(SS.MOCK_RESULT);
r11.score_info.water_score = 85;
r11.water = { water_area: 10, water_severity: 70 }; // sev≥60 且 score≥80 → 水润项 = 100−70 = 30
var vm11 = SS.buildViewModel(r11, SS.MOCK_SIDE, { realAge: 25 });
ok(approx(vm11.cats.tone.P, 55), '肤色水润P = 0.5×80+0.5×30 = 55(water_severity=70 且 water_score=85 → 水润项按 severity 换算)');
ok(vm11.notes.join('|').indexOf('水润项按 severity 换算为 30') >= 0, 'notes 记录水分交叉校验触发');
ok(vm4.cats.tone.P === -7.5, '对照:T4 sev=70 但 score=0<80 → 不触发换算(P=−7.5 不变)');
console.log('== T12 {oil_desc} 与 {skin_type_cn}(V2.2 §7.2/§8.2) ==');
var vm12 = SS.buildViewModel(clone(SS.MOCK_RESULT), SS.MOCK_SIDE, { realAge: 25 });
ok(vm12.cats.smooth.text.indexOf('T区出油面积57%、左右脸颊20%/2%,纹理分87') >= 0,
'{oil_desc} = T区出油面积57%、左右脸颊20%/2%(oily_intensity 0.57/0.2/0.02 小数×100)');
ok(vm12.summary.indexOf('整体肤质混合性,T区出油面积57%、左右脸颊20%/2%。') >= 0,
'综合文案尾句 = 案例1 真值句式(skin_type=3→混合性 + oil_desc 无尾逗号)');
var r12b = clone(SS.MOCK_RESULT);
r12b.oily_intensity.full_face.intensity = 0;
var vm12b = SS.buildViewModel(r12b, SS.MOCK_SIDE, { realAge: 25 });
ok(vm12b.cats.smooth.text.indexOf('出油') < 0 && vm12b.summary.indexOf('出油') < 0, '全脸 intensity=0 → {oil_desc} 整体省略');
var r12c = clone(SS.MOCK_RESULT);
delete r12c.skin_type;
ok(SS.buildViewModel(r12c, SS.MOCK_SIDE, { realAge: 25, skinType: '油性肌肤' }).summary.indexOf('整体肤质油性肌肤') >= 0,
'skin_type 缺失 → {skin_type_cn} 回退用户自选肤质');
checkTexts(vm12, 'T12');
console.log('== T10 真值对拍:案例1 完整原始返回 vs 真值表(xlsx 第3行/V2.2 实测) ==');
(function () {
var raw = null, fs;
try {
fs = require('fs');
var paths = [__dirname + '/../../reference/facepp-skinanalyze/examples/ref_3view_raw.json',
'reference/facepp-skinanalyze/examples/ref_3view_raw.json'];
for (var i = 0; i < paths.length; i++) { try { raw = fs.readFileSync(paths[i], 'utf8'); break; } catch (e) {} }
} catch (e) {}
if (!raw) { console.log(' - 跳过(未找到 reference ref_3view_raw.json)'); return; }
var d = JSON.parse(raw);
var vm = SS.buildViewModel(d.result, { left: d.left_side_result, right: d.right_side_result }, { realAge: 30 });
ok(approx(vm.cats.contour.P, 75.5) && vm.cats.contour.S === 9.27 && vm.cats.contour.level === '预警',
'轮廓 P=75.5 / S=9.27 / 预警(min(眼袋1级→90, 下颌线较差侧103.75°→75.5))');
ok(vm.cats.wrinkles.P === 23 && vm.dims.firm.P === 23 && vm.dims.firm.S === 7.69, '皱纹 P=23,紧塑力木桶 23 / S=7.69');
ok(approx(vm.cats.smooth.P, 57.2) && vm.dims.vita.P === 57.2 && vm.dims.vita.S === 8.72, '光滑 P=57.2,活力感 57.2 / S=8.72');
ok(vm.cats.tone.P === 85, '肤色水润 P = 0.5×92+0.5×78 = 85');
ok(vm.cats.sens.P === 99 && vm.cats.acne.P === 70 && vm.dims.stab.P === 70 && vm.dims.stab.S === 9.1,
'敏感 99 / 痘痘 70(min(分级70, 100−12)),稳定力 70 / S=9.1');
ok(vm.avgS === 9.05 && vm.worst.catKey === 'wrinkles' && vm.group === '25+组'
&& vm.essence.name === '35+支撑精华' && vm.essence.triggers.join('、') === '最差项约束',
'均分 9.05 / 最差皱纹 / 25+组 / 精华 35+支撑(触发:最差项约束,三重判据取最强档)');
ok(vm.skinAgeRaw === 27 && vm.skinAgeDisplay === 27 && vm.ageClampNote === '', '肤龄 27(实际30)未触发钳制、原值不改写');
ok(vm.cats.contour.text === '下颌线角度左104.54°/右103.75°(标准区间116–120°,双侧均偏离标准,提示下颌缘支撑力下降);眼袋1级、黑眼圈1级;中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。',
'轮廓文案逐字 = 真值表(黑眼圈取 dark_circle_severity=1,非 dark_circle=3)');
ok(vm.cats.wrinkles.text === '全脸皱纹159条、细纹150条,最差区域为左嘴角纹(分区分77,最高严重度3级),部分区域已现静态纹趋势。建议针对眼周、法令纹等高发区使用视黄醇/胜肽类淡纹精华,遏制静态纹转化。',
'皱纹文案逐字 = 真值表');
ok(vm.cats.smooth.text === '皮肤表面平整度较低,最差毛孔区位于额头(分区毛孔分32),粗大毛孔1041颗、黑头113颗,T区出油面积57%、左右脸颊20%/2%,纹理分仅82,整体细腻度不足。建议避免过度清洁,重点关注额头,配合低浓度酸类周期护理与柔润精华。',
'光滑文案逐字 = 真值表');
ok(vm.cats.tone.text === '肤色ITA 58.6(极白,HA 56.2),色素分92,整体稳定但存在局部暗沉倾向(未见明显色斑标记,检出褐色斑37处);水分分78,缺水面积19.8%、严重度22。建议加强抗氧化与光泽管理,做好基础防晒与补水。',
'肤色水润文案逐字 = 真值表');
ok(vm.cats.sens.text === '泛红面积0.6%,红区分99,屏障状态稳定,未见明显敏感信号。建议维持现有温和护理,避免过度去角质。',
'敏感文案逐字 = 真值表');
ok(vm.cats.acne.text === '检出9处皮损(闭口粉刺6处、丘疹3处)。建议尽快进行针对性抗痘护理,避免挤压与刺激,必要时寻求皮肤科专业处理。',
'痘痘文案逐字 = 真值表(无 heavy_note)');
ok(vm.summary === '【综合】实际年龄30岁(接口测得肌肤年龄27岁,差3岁),产品组别25+组;六分类S均分9.05、最差分类皱纹 S=7.69。按三重判据(触发:最差项约束)推荐35+支撑精华:优先改善皱纹(P=23.0)与光滑(P=57.2),其余维度维持现有护理节奏。整体肤质混合性,T区出油面积57%、左右脸颊20%/2%。',
'综合文案逐字 = 真值表 AW 列');
ok(vm.lifestyle === '保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳', '生活方式 = 最差分类皱纹条');
}());
console.log('----------------------------------------');
console.log('自测结果:' + pass + ' 通过,' + fail + ' 失败');
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

+18 -3
View File
@@ -99,6 +99,16 @@ function readBody(req, limitBytes) {
const sleep = ms => new Promise(r => setTimeout(r, ms));
/* ─── 报告页图层白名单:face_maps 只透传这几层,一行增删 ─── */
const REPORT_MAPS = ['red_area', 'brown_area', 'texture_enhanced_lines', 'texture_enhanced_pores', 'texture_enhanced_blackheads'];
/* 按 magic bytes 判断图片格式并补 data: URL 前缀(实测 red_area/brown_area 为 jpeg 头、其余 png,不硬编码) */
function toDataUrl(b64) {
const head = Buffer.from(b64.slice(0, 16), 'base64');
const mime = (head[0] === 0xFF && head[1] === 0xD8) ? 'image/jpeg' : 'image/png';
return 'data:' + mime + ';base64,' + b64;
}
/* 剥离 data:image/xxx;base64, 前缀,空值返回 '' */
function stripDataPrefix(s) {
return (s || '').replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, '');
@@ -175,10 +185,15 @@ async function handleAnalyze(req, res) {
return res.end(JSON.stringify({ ok: false, code: 'INTERNAL_ERROR', message: ERR_MSG.INTERNAL_ERROR }));
}
const result = fpJson.result;
// face_maps 是整幅 base64 叠加图,体积过大且报告页暂不使用;
// 这里只保留其键名列表,便于前端/调用方知晓可用图层
// face_maps 是整幅 base64 叠加图,体积很大:只透传报告页所需图层(REPORT_MAPS),
// 值补 data: URL 前缀供前端 <img> 直接用;未选中的图层直接删除键;
// marks 坐标字段(acne/brown_spot/mole/closed_comedones/wrinkle_mark 等)本就在 result 里,原样透传
if (result.face_maps && typeof result.face_maps === 'object') {
result.face_maps = Object.keys(result.face_maps);
const maps = {};
for (const k of REPORT_MAPS) {
if (typeof result.face_maps[k] === 'string' && result.face_maps[k]) maps[k] = toDataUrl(result.face_maps[k]);
}
result.face_maps = maps;
}
// 下颌线结果在响应顶层 left_side_result/right_side_result(不在 result 内),
// 个别批次可能嵌套在 side_result 下,两种层级都兼容
+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();