Files
juzi/h5/README.md
T
2026-09-28 15:27:31 +08:00

191 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 1'me 在线肌肤测试 H5
引导式自动三连拍(正脸/右脸/左脸),调用 **Face++ 皮肤分析(专业版 skinanalyze_pro)** 三视图真实检测,即时生成三维度×6分类肌肤状态分析与个性化护理建议的移动端 H5。
- 前端:原生 HTML/CSS/JS,零依赖、零构建(face-api.js 已本地化到 vendor/,运行时零外部请求)
- 后端:`server.js`(Node ≥ 18,零依赖)静态托管 + Face++ API 中转,**api_secret 只存在服务端**
## 目录结构
```
h5/
├── index.html 页面结构(启动 / 档案 / 拍照 / 报告 + 弹层 + 内联 SVG 取景蒙版)
├── admin.html 后台查看页(记录列表 / 失败与异常 / 统计概览,零依赖,见下文「后台查看」)
├── server.js 后端:静态托管 + POST /api/skin-analyze(Face++ skinanalyze_pro 三视图中转)+ /api/admin/*(记录查询/重试/统计/导出)
├── css/style.css C 端全部样式(375px 基准)
├── css/admin.css 后台查看页样式
├── js/app.js 全部交互(屏幕切换、肤质轮播、引导式三连拍、报告渲染)
├── js/admin.js 后台查看页脚本(纯原生 ES5)
├── js/skin-score.js 三维度×6分类评分引擎(纯逻辑 UMD,window.SkinScore,可 node 自测;服务端评分复用同一份)
├── js/head-pose.js 头部姿态估算(纯逻辑 UMD,window.HeadPose,可 node 自测)
├── data/records.jsonl 检测记录库(一行一条 JSON,零依赖嵌入式存储,已 .gitignore)
├── data/photos/ 检测照片 data/photos/<record_id>/{front,left,right}.jpg(本地磁盘代替对象存储)
├── data/seed.js 演示种子数据(一次性:node h5/data/seed.js,覆盖重写 records.jsonl)
├── data/results/ 每次检测的 Face++ 原始返回落盘(复核用,已 .gitignore)
├── 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/ 开发期整页截图(仅存档,部署时可整目录剔除)
../apikey.bash Face++ 密钥(export SKIN_API_KEY=... / SKIN_API_SECRET=...,已 .gitignore)
```
## 密钥配置
二选一:
1. 环境变量:`export SKIN_API_KEY=xxx SKIN_API_SECRET=xxx` 后启动 server.js
2. 密钥文件:项目根目录放 `apikey.bash`(内容为 `export SKIN_API_KEY=...` 等,server.js 会自动解析)
**密钥文件已被 .gitignore 排除,严禁提交到版本库。**
## 本地运行
```bash
cd h5
node server.js # 默认 8080,可用 PORT=xxx 修改
# 打开 http://127.0.0.1:8080
```
调试:
- `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 岁/干性肌肤);mock 数据源为 `assets/mock/report-fixture.js`(真实案例1三视图检测返回,含 face_maps 图层 data URL 与 marks 坐标),改该文件即可换样本。
## 拍照交互(引导式自动三连拍)
三步(顺序对齐 Figma,左右一律以用户本人为准):1/3 正脸(target `front`)→ 2/3 右脸出镜(用户头向自己的左侧转,target `right`,产物作 `right_image`)→ 3/3 左脸出镜(头向自己的右侧转,target `left`,产物作 `left_image`)。送检前 `photosByView` 按 target 重排为 [正, 左, 右]。
- **步骤圆点**:顶部三点,当前步高亮、完成步绿色打勾。
- **取景蒙版**:内联 SVG(`#cam-mask`)按步骤切换轮廓——正脸步为正脸剪影(含耳部),侧脸步为对应方向的侧脸剪影(鼻朝向与转头方向一致);**轮廓路径由 Figma 拍照示意图(`design/assets/images/*-拍照示意.png` 的 alpha 镂空区)逐像素描摹生成**,坐标为 Figma 375×812 整帧,viewBox 只取 y 86–812(帧顶 86px 是微信导航栏,H5 视口从其下方开始;步骤文案/引导等纵向百分比也以 726 高为基准);框外 85% 黑(SVG mask 镂空,对齐 Figma 图层不透明度),轮廓线 2.5px 半透明白。未检测到人脸时轮廓呼吸动画(1.8s);姿态 ok 时轮廓变绿并停止动效。
- **正脸步提示口径**:正脸步角度不合格一律只提示「请正对屏幕」(不再提示左右转头/抬头低头),阈值 |yaw|≤15°、|pitch|≤18°(2026-09-28 产品要求放宽);侧脸步仍按方向提示「请向左/右转头」。
- **实时检测**:face-api 从本地 `vendor/faceapi/` 加载(`tinyFaceDetector` + `faceLandmark68Net`)。进入拍照页即开始加载模型(状态提示"检测引擎加载中…"),加载完成前快门为手动模式。检测循环 ~150ms 一帧,`detectSingleFace(video, TinyFaceDetectorOptions({inputSize:416, scoreThreshold:0.3})).withFaceLandmarks()` 后拿 68 特征点调 `HeadPose.check(pos, target, mirrored)`——特征点取自原始帧(预览 CSS 镜像不影响),侧脸步 mirrored 恒 false(个别前置帧已镜像的机型由侧脸自校准翻转 `sideYawFlip`,只动判定不动画面),正脸步只看 |yaw|;连续漏检 ≥3 帧才报"未检测到人脸"(容忍单帧抖动);人脸框高度占比 <26% 提示"请靠近一点"、>58% 提示"请远一点"(以取景框≈42% 屏高标定)。提示一次一条,优先级:无人脸 > 距离 > 角度。
- **倒计时自动拍**:姿态 ok 连续维持 ≥600ms 后,先等「保持不动」等在播提示音自然收尾(画面保持绿框+保持不动),再进 3s 倒计时(中央大数字,轮廓保持绿色,数字与「三、二、一」语音同步起播,不再拦腰切断);倒计时中任一帧不 ok 立即取消并回到对应提示。倒计时结束进入白屏补光。
- **镜像策略**:前置镜头三步**预览**都镜像(照镜子习惯;Figma 侧脸轮廓即按镜像画面绘制:右脸出镜耳在画面右、左脸出镜耳在画面左);后置镜头预览不镜像,侧脸轮廓随之水平翻转(`.cam-mask.flip`)。**抓帧一律不镜像**:三张照片都是摄像头原始视角,Face++ 左右分区文案(左嘴角纹/左右脸颊)对应用户真实左右,与真值样本同口径。
- **白屏补光**:全屏纯白覆盖层瞬间点亮,~300ms 后抓全分辨率帧(原始视角 + 最长边 1280 JPEG),保持 ~300ms 再淡出(总 600–900ms),同步 `navigator.vibrate(30)`。
- **手动兜底**:快门任何时刻可拍当前步(同样走白屏补光),不依赖检测;face-api/模型加载失败 → toast"自动识别不可用,已切换手动拍摄",全程手动。系统相机降级模式(getUserMedia 不可用/被拒)维持原样:无检测、无补光。
- **相册选图**:相机页「相册」按钮可选当前步的图片(任何模式下可用),与实拍同管线(压 1280 → 推进流程)。
- **语音播报**:步骤引导/姿态提示/倒计时/拍摄完成/分析中/失败共 14 条,文案与文件名契约见 `../docs/语音播报内容设计文档_V1.0.md`。WebAudio 单通道、姿态提示同条 4s 去重、解锁后预加载全部条目;流程音(步骤/拍摄完成/倒计时/分析中)排队顺序播放互不截断,姿态提示不截断流程音(期间只挂起最新一条,收尾后补播),失败提示可打断「分析中」。`assets/voice/manifest.json` 清单制(14 条已生成),清单缺失的条目静默跳过。
- **无进展提示**:单步 12s 未触发倒计时,底部浮出"识别不到?可点下方快门手动拍摄"(不阻塞流程)。
- **资源管理**:离开拍照页(cam-back/完成)停止检测循环、清全部定时器、释放摄像头;`camSeq`(流程)/`camSeqCam`(相机)双令牌防竞态;face-api 模型只加载一次(缓存 promise,失败后下次进入重试)。
## 检测流程与评分渲染
1. 三视图送检:POST `/api/skin-analyze`,请求 `{ front_image, left_image, right_image, profile? }`(base64 可带 dataURL 前缀;正脸=photos[0],左=photos[2],右=photos[1];`profile` 为可选档案 `{real_age,gender,region,daily_skin_type}`,后台记录用)。
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.3.docx` 为最高契约(V2.3 口径,含 §8.5 护理建议五段):
- 6 分类:轮廓 = min(眼袋映射分, 下颌线角度分);皱纹(12 分区);光滑(毛孔/黑头/纹理);肤色水润(色素×水润);敏感(泛红面积取 `red_spot.red_spot_area`/红区分);痘痘(分级映射×丘疹 `acne.count` 计数扣分);分组按实际年龄;
- 三维度木桶 min:紧塑力(轮廓+皱纹)、活力感(光滑+肤色水润)、稳定力(敏感+痘痘);
- 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.careAdvice`)、精华推荐卡(`vm.essence.name` + 命中判据;推荐正文已在护理建议第 4 段,卡内不重复);
- 近期优先关注 = 最差维度(`vm.focus`);6 分类各配检测可视化图:轮廓=正脸实拍、皱纹=正脸压暗+`texture_enhanced_lines` 皱纹增强线(线图层几乎全透明,直挂白底不可读,故与压暗沉脸合成)、光滑=正脸+色斑/痣/痤疮标记叠加、肤色=`brown_area` 色沉图、敏感=`red_area` 红区图、痘痘=正脸+闭口粉刺/痤疮标记叠加(marks 按原像素坐标叠加到正脸实拍)。
5. 浏览器冒烟:`node h5/test/e2e-browser.mjs`(需全局 `playwright` + `npx playwright install chromium`,服务跑在 8080)——mock 三连拍到报告页,并核对 14 条语音可拉取、流程语音完整播完不被截断。
6. 真值回归:`node h5/test/regression.js` 以 `docs/皮肤检测测试结果_三视图_20260926.xlsx`(sheet「三视图检测汇总」,数据第 3/4 行=案例1/案例2)两组三视图真值对照评分引擎输出。
7. **报告历史**:检测成功后按「检测」为单位存入 localStorage(`juzi.reports.v1`,上限 5 条,超配额逐条淘汰)——含三张照片、档案、全部评分数据与 marks 坐标,**不含 face_maps 整幅图层**(体积原因;回看时皱纹/肤色/敏感图降级为占位,光滑/痘痘标记叠加仍可用)。当前无 UI 入口(第9轮移除),数据仅作本地追溯。
8. **原始返回落盘**:服务端每次检测成功后把 Face++ 完整原始返回写入 `h5/data/results/<时间戳>-<request_id>.json`(复核/真值用,已 .gitignore;写盘失败不影响响应)。
9. 检测失败按服务端 message 提示并退回档案页。
## 后台查看(admin.html)
本地查看版后台,浏览器打开 **`http://127.0.0.1:8080/admin.html`**(无登录鉴权,仅内网/本地使用;上线前需加网关或鉴权)。请求体除三张照片外还带可选档案 `profile:{real_age,gender,region,daily_skin_type}`(前端 `js/app.js` 自动附带,年龄非法按 null),服务端据此落库并在检测成功时用 `SkinScore.buildViewModel` 现场评分(与 C 端同一份公式)。
- **数据落库**:每次有效调用(正脸照片校验通过起)生成 `record_id`,照片写 `data/photos/<record_id>/`,记录(档案/照片地址/拍摄与上传状态/检测状态与失败原因/评分/文案/精华/耗时/raw 文件名/重试关系)追加到 `data/records.jsonl`。C 端响应结构不变。
- **记录列表**:多条件筛选(时间区间/检测状态/失败原因/年龄/产品组别/维度+档位/记录ID),行点击开详情抽屉(照片大图与下载、状态、六分类与三维度评分表、文案全文、护理建议、精华与触发判据、计算备注、接口原始 JSON 折叠查看、手动重试)。
- **失败与异常**:失败原因分布条 + 失败列表(每条可一键重试:用原照片重新检测,生成新记录并挂版本链)。
- **统计概览**:今日/累计/成功率/平均耗时卡片,三维度档位分布,产品组别分布(纯 CSS 饼图),近 14 天每日检测量柱状图,按日期区间导出 CSV(UTF-8 BOM,不含 raw_json)。
- **演示数据**:`node h5/data/seed.js` 生成约 10 条演示记录(覆盖重写 records.jsonl,照片复制自 `reference/facepp-skinanalyze/examples/images/` 真实三视图)。
### Admin API(统一返回 `{ok:false,message}` 错误)
| 接口 | 说明 |
| --- | --- |
| `GET /api/admin/records` | 分页查询。参数:`from/to`(ISO 日期)、`detect_status`、`fail_code`、`age_min/age_max`、`group`、`dim`(firm/vita/stab)+`dim_level`、`q`(record_id 前缀)、`page`、`page_size`(默认 20)、`only_fail=1`。返回 `{total,page,page_size,items}`(倒序,含照片 URL,不含 raw_json 与文案全文) |
| `GET /api/admin/records/:id` | 完整记录 + `raw_json` 全文(读失败为 null)+ `photos` URL + `versions`(版本链:父记录与重试子记录) |
| `POST /api/admin/records/:id/retry` | 手动重试:原照片重新检测,生成新记录(`retry_of`=原 id),原记录 `retry_count+1`,返回新记录 |
| `GET /api/admin/photos/:id/:which` | 照片回显(`which`=front/left/right),`image/jpeg` inline;文件不存在 404 |
| `GET /api/admin/stats` | 今日/累计检测量、成功率、平均耗时、失败原因分布、三维度档位分布、产品组别分布、近 14 天每日检测量/成功数 |
| `GET /api/admin/export?from=&to=` | CSV 导出(UTF-8 BOM,Content-Disposition attachment 带日期区间文件名);禁止 raw_json 进单元格(Excel 单格 32767 字符截断) |
## 部署
需要 Node ≥ 18 运行 `server.js`(负责静态与 API 中转),**不能用纯静态托管**(密钥不能进前端)。
```bash
PORT=8080 node server.js # 建议 pm2 / systemd 常驻
```
### 线上部署(juzi.xiangsilian.com)
- Node 只绑回环(`HOST` 默认 `127.0.0.1`,公网入口仅 nginx;需局域网调试才 `HOST=0.0.0.0`),nginx 反代 + certbot HTTPS,见 `/etc/nginx/sites-available/juzi`。
- **后台 Basic Auth**:nginx 对 `admin.html`、`css/admin.css`、`js/admin.js`、`/api/admin/` 做 `auth_basic`(账密 `/etc/nginx/.htpasswd-juzi`),`/data/`(照片/原始 JSON/记录库)由 nginx 404 + server.js 静态层 403 双重封锁,照片只能走鉴权后的 `/api/admin/photos`。**上线正式版前应替换为文档要求的后台账号体系。**
- 服务常驻建议 systemd:
```ini
[Unit]
Description=juzi skin h5
After=network.target
[Service]
WorkingDirectory=/home/ubuntu/juzi/h5
ExecStart=/usr/bin/node server.js
Restart=always
[Install]
WantedBy=multi-user.target
```
### Windows 本机部署(提测用)
```bat
h5\deploy-local.bat :: 默认 PORT=8088,日志写 h5\server.log
```
登记开机自启(用户级计划任务,需系统允许;若提示"拒绝访问",直接双击 `deploy-local.bat` 或手动运行即可):
```bat
schtasks /create /tn "JuziSkinH5" /tr "\"C:\Windows\System32\cmd.exe\" /c \"D:\juzi\h5\deploy-local.bat\"" /sc onlogon /f
:: 移除: schtasks /delete /tn "JuziSkinH5" /f
```
局域网提测访问 `http://<本机局域网IP>:8088/`。注意:① 首次监听若弹 Windows 防火墙提示需允许 node.exe;② **手机走局域网 HTTP 时 getUserMedia 不可用**,会自动降级为系统相机手动三连拍(分析链路完整),要测引导式自动拍照需在本机浏览器访问 `http://127.0.0.1:8088`,或上 HTTPS。
nginx 反代示例(静态与 API 都交给 server.js,nginx 只做 HTTPS 终结):
```nginx
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
client_max_body_size 20m; # 与 server.js 请求上限一致
}
}
```
> **HTTPS 必需**:getUserMedia 相机权限仅在安全上下文可用(localhost 除外)。正式域名必须上 HTTPS,否则前端自动降级为系统相机拍照。
## 已知占位清单
- 产品图(prod-1.jpg)为设计稿素材;检测图已替换为用户实拍。
- 语音 14 条已由 `tools/gen_voice.py` 生成;改文案后重跑脚本并同步 `manifest.json`。
- 「了解更多」为开发中占位(Toast 提示);拍照页右下按钮为切换前后镜头(系统相机降级模式下提示在系统相机内切换)。
- 产品推荐卡(COREⅡ 双仓精华液)为设计稿固定素材,不随精华推荐档位(25+/35+/50+)变化,待产品提供三档对应素材。
- **姿态与距离参数已用模拟摄像头校准**(2026-09-27):以 C3 三视图(+45° 侧脸)生成 Y4M 模拟摄像头视频,闭环修正了 yaw 增益(2.2→3.8)、pitch 基线(0.32→0.45)、check() 双重镜像符号 bug、距离阈值(0.35/0.8→0.26/0.58)、检测参数(320/0.4→416/0.3)与漏检 3 帧容忍;自动三连拍在无头浏览器全程通过(e2e E7)。**真机仍建议复核一次**:不同机型前置镜像策略/取景比例可能微调体验(校准接口:调用方 `mirrored` 标志与 `HeadPose.config`)。
## 技术说明
- 尺寸换算:设计稿 1080px 基准 ÷ 2.88 = 375px 落地基准。
- 图片性能:首屏背景 preload;首屏外 `loading="lazy"`。
- 快门防连点/双流程令牌(camSeq/camSeqCam)防竞态;拍照页取景框外纯黑(对齐 Figma),白字直接落在黑色上。
- face-api.js 0.22.2 与模型全部本地化(`vendor/faceapi/`,约 1.4MB),运行时零外部请求,微信 webview / iOS Safari 弱网与内网部署可用;许可证(MIT / Apache-2.0)见 `vendor/faceapi/README.md`。