Compare commits
3
Commits
e53c809522
...
c9352fe4ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9352fe4ca | ||
|
|
3f165bf9e5 | ||
|
|
92a3965b7d |
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"source": "new_feedback/第三轮反馈20261012/UI外发版10.09.psd",
|
||||
"source_sha256": "c7698f5900adc7476286818450d928e8fa2ce2e58cda09375ff2b861629fd58f",
|
||||
"source_page": "9",
|
||||
"hidden_scan_layer": "9/2/2",
|
||||
"viewport": [
|
||||
6250,
|
||||
5500,
|
||||
7330,
|
||||
7591
|
||||
],
|
||||
"face_bbox": [
|
||||
155,
|
||||
426,
|
||||
926,
|
||||
1417
|
||||
],
|
||||
"cycle_ms": 1000,
|
||||
"yellow": "#F5F078",
|
||||
"assets": [
|
||||
{
|
||||
"file": "h5/assets/img/ui-20261012/scan-base.webp",
|
||||
"size": [
|
||||
1080,
|
||||
2091
|
||||
],
|
||||
"bytes": 6688,
|
||||
"sha256": "08f8e75634691d4329210188bd56472892d8152de0d194c9358f82fda6197ddf"
|
||||
},
|
||||
{
|
||||
"file": "h5/assets/img/ui-20261012/scan-face-mask.png",
|
||||
"size": [
|
||||
1080,
|
||||
2091
|
||||
],
|
||||
"bytes": 16417,
|
||||
"sha256": "d9c729600b6141a3977e38a121f680c20c6b543a6828b1d38cd5f9878d311008"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
# 第四轮反馈修改与验收
|
||||
|
||||
依据:`feedback/第四轮反馈` 的客户会话、6 张批注图及傅艳在本线程中的逐项确认,包含随后指定的皱纹三档原文。按用户要求提交及部署,不代替用户推送 origin。
|
||||
|
||||
## 已确认并实现
|
||||
|
||||
1. 登录按钮、登录成功弹窗、首页/我的报告状态及相关引导统一使用「游客登录/游客已登录」,内部仍为原 `browser` 身份,不另建账号、不丢失已有报告。明确说明游客报告仅能在当前浏览器查看、不能跨手机同步。
|
||||
2. 拍摄质量在前端收紧,不新增服务端质量拦截、重拍要求或额外质量检查接口。服务端原有业务错误处理不变;仍按现有算法剔除无效数据。成功报告不显示侧脸角度无效、建议重拍等备注,后台保存完整备注及结构化质量标记。
|
||||
3. 皱纹结论使用傅艳最后指定的「改善/良好/优秀」三档原文(下附)。客户正文不再附加 12 分区/9 指标长文;分区原始指标保存在结构化快照,并在受保护后台单独展示。历史报告仅显示原有结论、不再展示分区附文,不回写历史快照。评分、肤龄、推荐规则及照片视角归属不改。
|
||||
4. PSD 原始画面拆为白脸底图和透明人脸遮罩,黄色扫描区约 1 秒完成一次上下往返。成功返回至少等到实际 CSS 动画完成一轮;响应慢时不额外延长等待,取消/失败不为动画延迟。相册和翻转图标依旧仅装饰、不可操作,取消入口保留。
|
||||
5. 三维度评分下方恢复浅灰提示区及黄色竖线,固定两行结构:`近期优先关注:{维度名}` 与客户指定建议。根据已保存的原始维度 P 值选最低项,不使用前端一位小数排序;并列依次紧塑力、透亮感、稳韧力,缺失不作 0、全部缺失则隐藏。历史报告也按其保存分数展示,不重算、不回写历史快照。
|
||||
|
||||
| 最低维度 | 建议原文 |
|
||||
| --- | --- |
|
||||
| 紧塑力 | 强化紧塑力,提拉轮廓抚平细纹。 |
|
||||
| 透亮感 | 提升透亮感,补水提亮加强防晒。 |
|
||||
| 稳韧力 | 增强稳韧力,舒缓泛红强韧屏障。 |
|
||||
|
||||
### 皱纹(紧塑力)原文
|
||||
|
||||
- 【改善】全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。
|
||||
- 【良好】全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条,面部整体平整,仅眼周或口周有细微的干纹,无明显静态深纹,纹路面积占比相对较低。
|
||||
- 【优秀】面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。
|
||||
|
||||
缺失的计数仍标记为未获取,不伪造为 0;分档计算保持不变。
|
||||
|
||||
## 前端参数及限制
|
||||
|
||||
参数版本 `2026-10-12-fourth-v1`,集中在 `h5/js/capture-quality.js` 和 `h5/js/head-pose.js`。
|
||||
|
||||
| 条件 | 之前 | 本轮 |
|
||||
| --- | --- | --- |
|
||||
| 人脸检测置信度 | ≥0.3,取单一人脸 | ≥0.5,检查检测到的人脸数量为 1 |
|
||||
| 框高/原始帧高 | 30%~68% | 34%~64% |
|
||||
| 水平居中 | 偏离≤18% 屏宽 | 偏离≤12% 屏宽 |
|
||||
| 完整入镜 | 没有单独边缘门限 | 检测框距原始帧边缘≥2% |
|
||||
| 正脸 yaw/pitch | ≤15°/18° | ≤10°/12° |
|
||||
| 侧脸幅度/pitch | 28°~75°/≤20° | 35°~65°/≤15° |
|
||||
| 歪头 roll | 不判断 | ≤10° |
|
||||
| 脸部平均亮度 | 65~235 | 75~225 |
|
||||
| 稳定累计 | 450ms,短暂漏检不打断 | ≥650ms 且≥3 合格帧;任何漏检/异常打断,帧间隔>2000ms 打断 |
|
||||
| 稳定期间漂移 | 未单独判断 | 相对起始合格帧 yaw/pitch≤5°,中心 x/y≤原帧 3.5%,框高占比变化≤4.5 个百分点 |
|
||||
| 最终 JPEG | 实时抓拍后直接推进 | 抓拍后本地复验通过才推进 |
|
||||
|
||||
系统相机/相册文件使用同样的单人、完整入镜、距离、角度、光线门限;由于系统照片可能已镜像,沿用不判断左右方向的兼容方式,并记录 `direction_checked:false`。模型加载中最多等待 8 秒;模型确实不可用时保留原有手动/文件降级通路,并记录 `unchecked/model-unavailable`,不伪称已校验。
|
||||
|
||||
这些角度是 68 点几何经验估计,不是服务端 `jawline_quality` 的直接替代;只能尽量提高合格率,不能承诺手机实拍 100% 通过。仍需提测收集不同机型、光线和转头方式的样本后校准。未修改取景轮廓、自动拍摄三步顺序或语音资源。
|
||||
|
||||
真实本地识别模型的自动流程测试发现:无硬件加速时每帧推理约 1.05 秒,若将间隔上限设置为 1 秒会一直停留在「保持不动」。因此最终上限采用 2 秒;650ms/至少 3 帧及全部质量/漂移约束不变,慢设备按实际推理耗时累计,不为抢速度跳过合格帧。
|
||||
|
||||
## 后台校准留痕
|
||||
|
||||
- 现有测肤请求携带参数版本、三视角的本地观测(yaw/pitch/roll、置信度、亮度、框比例及中心位置),不新增网络往返、不向前端下发服务端私钥。
|
||||
- 后端仅接收限定字段和有界数值,明确标记 `client-untrusted`。当前参数版本对应的配置快照一同保留,避免以后调整参数后无法追溯。
|
||||
- `capture_quality` 单独保存接口左右侧脸的 quality、下颌线角度、valid/invalid/unavailable 状态;quality 不为 1 或角度缺失只做标记,不改变原成功响应、不要求用户重拍。
|
||||
- 后台列表在拍摄状态下标注异常;详情增加「拍摄质量与前端校准」;CSV 增加质量状态及参数版本。旧记录不批量补写,仍可查看原有计算备注/原始返回。
|
||||
- 校准时结合后台原图、前端参数/观测及接口质量结果分析,不将客户端自报当作可信质量结论;本轮不引入自动学习或自动修改线上参数。
|
||||
|
||||
## PSD 来源
|
||||
|
||||
`tools/export_fourth_feedback_assets.py` 从原 PSD 第 9 页隐藏原扫描效果图层 `9/2/2`,保留原人脸、圆角边框、文字及装饰图标;生成 `scan-base.webp` 和具有真正 alpha 通道的 `scan-face-mask.png`。不修改原 PSD,不使用 AI 重新绘制人脸。
|
||||
|
||||
`design/ui-20261012/scan-motion-manifest.json` 记录原 PSD SHA-256、裁切范围、图层路径、遮罩范围、动画周期、色号及资源 SHA-256。新底图/遮罩只在进入档案/相机后加载,登录首屏不请求扫描素材、完整字体或人脸识别引擎。
|
||||
|
||||
## 验证
|
||||
|
||||
- `node h5/test/customer-feedback-fourth.js`:本地角度/光线/距离/多脸、稳定时长/帧数/漏检/漂移、关注维度/并列/缺失、历史皱纹快照不回写、后台诊断字段清理。
|
||||
- `node h5/test/customer-feedback-fourth.mjs`:隔离服务器和模拟上游;quality=3 仍成功保存,仅后台标记;后台/CSV/历史报告不回写;游客登录及状态;五种屏宽;快速成功响应等待实际动画一轮。
|
||||
- 第三轮专项回归:PSD 原文/字体、弹窗、三款产品、装饰图标/键盘、取消/迟到响应/失败恢复及六种等待视口;新动效替代原静态图断言。
|
||||
- 第二轮、原客户反馈浏览器回归,以及报告持久化/账号隔离/模拟微信回调均通过;推荐规则 126 个用例、原算法 86 条断言、请求超时测试通过。
|
||||
- 登录性能:150ms 延迟、约 2Mbps 下载及 4 倍 CPU 降速下最后一次冷启动约 1.67 秒,资源 267784 字节;未将识别引擎或扫描图放到首屏。
|
||||
- 使用本地真实模型对 C3_180、C3_-135、C3_135 三张样例进行推理,当前收紧门限均通过(yaw 约 4.28°/-44.77°/42.59°)。这不是对真实手机拍摄合格率的保证。
|
||||
- `node h5/test/capture-quality-browser.mjs`:真实识别引擎和原始模型,使用保持长宽比、居中后的三视角样例模拟视频流,实际自动拍摄三张并复验 JPEG;正脸不能冒充侧脸、反方向侧脸不能直接推进,诊断对应正确视角,相机成功后释放。不是手动触发快门,也不是模拟识别结果。
|
||||
|
||||
全部写入测试仅发生在独立临时数据目录,上游测肤以本地样例替代;不调用收费 Face++,不修改生产账号、照片、报告或配置。真实手机微信/相机仍需用户最终验收。
|
||||
|
||||
## 生产部署及只读验收
|
||||
|
||||
- 功能提交:`92a3965b7d637f19ec3d31389f142447339ff2a6`(`feat(h5): apply fourth feedback and tighten local capture validation`)。
|
||||
- 服务器:`ubuntu@1me.xajuzi.com:/home/ubuntu/juzi`;入口:<https://1me.xajuzi.com/state/>。部署前服务器工作区干净、HEAD 与 origin/main 均为 `e53c809`;通过 Git bundle 快进更新,不代替用户 push、不覆盖服务器私有配置或已有用户数据。
|
||||
- 更新后重启 `juzi-h5.service`,让服务端评分展示模块、质量诊断模块及前端同版。服务 active/running,`NRestarts=0`,健康接口 `ok:true`;检查时最近 10 分钟错误级日志为 0。
|
||||
- 公网首页、app/capture-quality/head-pose/report-ui 四个脚本、CSS、两份扫描素材全部 200;逐文件 SHA-256 与本地一致,均为 `Cache-Control: no-cache`。
|
||||
- 公网未登录报告、后台页面及统计接口均 401;私有身份文件、server.js 均 404;微信配置仍启用,不修改微信凭据或流程。
|
||||
- 公网真实模型的自动三连拍测试通过:三个实际合格视角自动抓拍、最终 JPEG 本地复验、实际识别到的正脸/错误方向不能冒充目标侧脸、正确诊断归属、报告后释放摄像头;登录与测肤响应仅浏览器内拦截,不产生生产账号或报告、不调用收费测肤。
|
||||
- 公网第三轮专项串行复验通过:五种屏宽的弹窗断行/产品版式、三款产品、扫描动效与成功进入报告、六种等待视口、装饰图标、取消/迟到响应/失败恢复及字体/扫描图失败降级;无页面 JS 异常。已检查公网首页、等待画面及产品截图。
|
||||
- 公网第二轮回归串行复验通过:首页口号/Logo 比例、实际 PSD 字体、首屏不加载完整字体和识别引擎、全屏相机、三种圆角轮廓/五官辅助线、五种宽度无溢出;无页面 JS 异常。
|
||||
- 公网报告交互串行复验通过:三个维度独立滑动/键盘切换、垂直滚动不误切换、图片放大、七张检测图、三档产品排版;皱纹正文显示指定结论、不再包含分区技术附文。无页面 JS 异常。
|
||||
- 同时启动多套冷浏览器时曾出现字体/资源下载超时;串行重试自动拍摄流程通过。字体仍按原 PSD 使用原始 MiSans,并不将此网络压力测试描述为真实手机合格率保证。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 拍摄约束放宽与标题更新
|
||||
|
||||
依据用户真实手机反馈:上一轮拍摄过于费劲,适度放宽前端门限,页面名称改为 `1'me肌肤状态检测`。此次不修改设计素材、评分、肌龄、推荐规则、微信登录流程或历史报告。
|
||||
|
||||
## 参数对照
|
||||
|
||||
参数版本由 `2026-10-12-fourth-v1` 升为 `2026-10-12-fourth-v2`;新报告后台保存新版本与配置快照,已有记录不回写。
|
||||
|
||||
| 项目 | 上一轮 v1 | 本次 v2 |
|
||||
| --- | --- | --- |
|
||||
| 人脸置信度 | ≥0.5 | ≥0.4 |
|
||||
| 人脸框高占原帧 | 34%~64% | 30%~68% |
|
||||
| 水平偏离屏幕中心 | ≤12% 屏宽 | ≤16% 屏宽 |
|
||||
| 检测框边缘余量 | ≥2% 原帧 | ≥1% 原帧 |
|
||||
| 正脸 yaw/pitch | ≤10°/12° | ≤15°/18° |
|
||||
| 侧脸幅度/pitch | 35°~65°/≤15° | 30°~70°/≤20° |
|
||||
| 歪头 roll | ≤10° | ≤15° |
|
||||
| 脸部平均亮度 | 75~225 | 65~235 |
|
||||
| 稳定时长/检测帧数 | ≥650ms/≥3 帧 | ≥450ms/≥2 帧 |
|
||||
| yaw/pitch 漂移 | ≤5° | ≤8° |
|
||||
| 中心 x/y 漂移 | ≤原帧 3.5% | ≤原帧 5.5% |
|
||||
| 框高占比变化 | ≤4.5 个百分点 | ≤6.5 个百分点 |
|
||||
|
||||
仍要求单人出镜、完整入镜、正确视角、基本距离/光线;最终 JPEG 必须通过同一套本地复验。没有删除质量检查、绕过检测、增加服务端质量拦截或网络往返。漏检/识别异常及超过 2 秒的检测间隔仍打断累计。已有模型不可用降级路径保持不变,后台明确标记未校验。
|
||||
|
||||
最低稳定等待减少 200ms;慢设备从至少三次合格观察减为两次,减少持续保持姿势的难度。实际速度仍取决于手机模型推理耗时,不能保证所有机型和环境的检测合格率。
|
||||
|
||||
## 标题
|
||||
|
||||
`h5/index.html` 的 HTML title、Open Graph 分享标题及页面描述统一改为 `1'me肌肤状态检测`。首页 PSD 原组合图已经是「肌肤状态检测」,无需改图。
|
||||
|
||||
## 验证与发布
|
||||
|
||||
本地已通过:
|
||||
|
||||
- 参数边界:上一版偏头/俯仰/偏位/亮度/轻微漂移等拒绝样例现在通过,多人/错误视角/裁切/大幅移动仍拒绝;450ms 两帧准确触发,短于 450ms 不触发,漏检/超时间隔重置。
|
||||
- 真实模型自动三连拍及最终 JPEG 本地复验,正脸不能冒充侧脸、错误方向不能推进,诊断照片归属正确,成功后释放摄像头。
|
||||
- 隔离服务器后台诊断保存 v2 配置快照;页面与分享标题正确;quality=3 不阻断报告,游客状态、历史快照、五种屏宽及实际一轮扫描动画通过。
|
||||
- 报告持久化、后台/H5/CSV 一致、账号隔离、皱纹原文;账号/OAuth 单测;原算法 86 条及推荐规则 126 用例;请求超时与震动安全回归。
|
||||
|
||||
部署目标:`ubuntu@1me.xajuzi.com:/home/ubuntu/juzi`,部署基线 `3f165bf`,通过 Git bundle 快进同步、不代用户 push。重启 `juzi-h5.service` 以保存新版后端参数快照。生产验证仅 GET 请求及浏览器内存拦截,不创建线上账号/报告,不调用收费 Face++。
|
||||
@@ -5,23 +5,23 @@
|
||||
"3": "skin-score.js",
|
||||
"4": "customer-feedback-second.js",
|
||||
"5": "n",
|
||||
"6": ".runAndExtendWithFaceDetection",
|
||||
"7": ".toString",
|
||||
"6": "extractFaceTensors",
|
||||
"7": ".forwardInput",
|
||||
"8": "admin.js",
|
||||
"9": "__awaiter",
|
||||
"10": "FaceLandmarks",
|
||||
"10": "wechat-browser.mjs",
|
||||
"11": "lib/accounts.js",
|
||||
"12": "server.js",
|
||||
"13": "files",
|
||||
"14": ".draw",
|
||||
"15": "extractFaces",
|
||||
"16": "巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范",
|
||||
"17": "k",
|
||||
"18": "1'me 在线肌肤测试 H5",
|
||||
"19": "head-pose.js",
|
||||
"17": "report-persistence.mjs",
|
||||
"18": "1'me肌肤状态检测 H5",
|
||||
"19": "customer-feedback-fourth.js",
|
||||
"20": "build_fixtures.py",
|
||||
"21": "disposeUnusedWeightTensors",
|
||||
"22": ".predictAgeAndGender",
|
||||
"22": "AgeGenderNet",
|
||||
"23": "facepp-skinanalyze(旷视皮肤分析专业版调用技能)",
|
||||
"24": "巨子测肤 · 1'me 语音播报内容设计文档 V1.0",
|
||||
"25": "mobile-select.iife.min.js",
|
||||
@@ -39,6 +39,7 @@
|
||||
"37": "UI 外发版 10.09 图片资源",
|
||||
"38": "FaceLandmark68Net",
|
||||
"39": "UI交互界面调整",
|
||||
"40": "第四轮反馈修改与验收",
|
||||
"41": "NeuralNetwork",
|
||||
"42": "layer_at",
|
||||
"43": "normalize",
|
||||
@@ -53,48 +54,57 @@
|
||||
"52": "region-data.js",
|
||||
"53": "护理建议(全量文本,按需求摘抄)",
|
||||
"54": "UI 对齐 10.09 设计稿:调研与实施计划",
|
||||
"55": "i",
|
||||
"55": "pn",
|
||||
"56": "report-ui.js",
|
||||
"57": "regression.js",
|
||||
"58": ".dispose",
|
||||
"59": "call_skinanalyze.sh",
|
||||
"60": "report-fixture.js",
|
||||
"61": "FaceProcessor",
|
||||
"62": "提交前 Review(2026-10-11)",
|
||||
"63": "e2e-browser.mjs",
|
||||
"64": "account-client.js",
|
||||
"65": "微信登录接入与公网部署说明",
|
||||
"66": "report-persistence.mjs",
|
||||
"66": "FaceLandmarks68",
|
||||
"67": "ui-20261009.mjs",
|
||||
"68": "analyze-timeout.js",
|
||||
"69": "misans/README.md",
|
||||
"70": ".forwardInput",
|
||||
"70": "extractConvParamsFactory",
|
||||
"71": "extractAllFacesAndComputeResults",
|
||||
"72": "build_report_copy.py",
|
||||
"73": "camera-geometry.js",
|
||||
"74": "report-copy.js",
|
||||
"75": "prepare_h5_assets.py",
|
||||
"76": "TinyYolov2Base",
|
||||
"77": "drawFaceExpressions",
|
||||
"78": "t",
|
||||
"76": ".toString",
|
||||
"77": ".runAndExtendWithFaceDetections",
|
||||
"78": "i",
|
||||
"79": "登录反馈与加载优化(2026-10-12)",
|
||||
"80": "线上微信登录部署记录",
|
||||
"81": "wechat-production.mjs",
|
||||
"82": "wechat-browser.mjs",
|
||||
"82": "拍摄约束放宽与标题更新",
|
||||
"83": "10.09 新版 H5 实施与验收",
|
||||
"84": "微信登录联调:2026-10-12",
|
||||
"85": "test/accounts.js",
|
||||
"86": "FaceExpressionNet",
|
||||
"87": "产品推荐规则:肤龄基准与边界调整",
|
||||
"88": "ui-20261012/manifest.json",
|
||||
"89": "傅艳NEW与项思炼的会话 2026年10月12日.md",
|
||||
"90": "customer-feedback.mjs",
|
||||
"91": "wi",
|
||||
"92": "客户反馈修改(2026-10-12)",
|
||||
"93": ".runAndExtendWithFaceDetections",
|
||||
"94": "第三轮反馈:PSD 对齐及本地验收",
|
||||
"93": ".runAndExtendWithFaceDetection",
|
||||
"94": "第三轮反馈:PSD 对齐、验收及上线记录",
|
||||
"95": "login-performance.mjs",
|
||||
"96": "production-loop.mjs",
|
||||
"97": "PredictSingleAgeAndGenderWithFaceAlignmentTask",
|
||||
"98": "PredictSingleFaceExpressionsWithFaceAlignmentTask",
|
||||
"99": "DetectSingleFaceLandmarksTask",
|
||||
"100": "extractAllFacesAndComputeResults",
|
||||
"106": "第三轮反馈20261012/傅艳NEW与项思炼的会话 2026年10月12日.md"
|
||||
"100": "ComputeAllFaceDescriptorsTask",
|
||||
"101": "drawFaceExpressions",
|
||||
"102": "TinyYolov2",
|
||||
"103": "第四轮反馈/傅艳NEW与项思炼的会话 2026年10月12日.md",
|
||||
"104": "export_fourth_feedback_assets.py",
|
||||
"105": "capture-quality-browser.mjs",
|
||||
"106": "第三轮反馈20261012/傅艳NEW与项思炼的会话 2026年10月12日.md",
|
||||
"107": "ComputeSingleFaceDescriptorTask"
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"0": "ea3f8d8f67782fdc", "1": "cb6ea50bd4e4a5ca", "2": "485914b81d357062", "3": "1f737ca8edb978f2", "4": "56bf12952b0de42b", "5": "ecc33b1dfcb6c0a4", "6": "8bd61518dd267641", "7": "cd6f168abba94fea", "8": "1ecf47531e1d507b", "9": "8abf7998360e0ccc", "10": "7fb7f7cc828b286c", "11": "4a490abd5e503318", "12": "53594b653ad48743", "13": "7e3b558a587a4ebe", "14": "bf75bfc99d4f9e34", "15": "fb002c2979ef551d", "16": "95add6a975c0cb72", "17": "09246ceabd90e01c", "18": "38bb0b185ceed116", "19": "0a373ad2197fd1a6", "20": "aecf2fce44ba49c5", "21": "254ec4cab6c4ac57", "22": "a2b13160223b5a98", "23": "90fac00055070ce2", "24": "f85b5b1634224c86", "25": "46e8db3e6e3f37ea", "26": "487edffbbcedb8cb", "27": "eab427ce9a5219e9", "28": "121c45cc1e571288", "29": "c608f1c6492e1cb1", "30": "73c13793fca09f56", "31": "fbbcfc059751cc6a", "32": "f6bcf56c4589316b", "33": "75f750c5b7d9f63d", "34": "f024ff7942935690", "35": "5a17f8810dae4ff2", "36": "a7d375041779bad4", "37": "cb8f26249485324a", "38": "9a25b1230b5fe649", "39": "fdd95375b4a5ddb3", "41": "d96493a5f110bd4c", "42": "916170b9f5d7346b", "43": "e48804ba2a6c4e86", "44": "852c1bb2446edd9c", "45": "92ab3e5134ed8685", "46": "65718282b3c32109", "47": "e594ecaa8e7d1538", "48": "cf2f2424bc00436f", "49": "1f976711cda158dc", "50": "bbb2b57d72922b16", "51": "eb6a182204867275", "52": "2ec3c8c088568ba9", "53": "68c45056378341a7", "54": "c58d41e8e580fdc6", "55": "b9ba0a5c51b155b1", "56": "8c9a3fc5edff7e44", "57": "f2a40b5f1b29f669", "58": "3ba0641876109aff", "59": "891f59cda3708718", "60": "a505b7de6257a4e8", "62": "1366ae24c7e5897d", "63": "c256b43f75d7809e", "64": "ce0e68dab611f022", "65": "74ae017de0ffd36b", "66": "e042d7fe7351bcb9", "67": "3e126d8deaf6e200", "68": "6b91f3679541de79", "69": "e511846eeba03076", "70": "880a74c6d255e5cc", "72": "c84f196a7bd35861", "73": "2b887bf601ccfb9f", "74": "ae2a154311e1f53a", "75": "69fdd23278518884", "76": "04c475f726d2508f", "77": "979dcc63399620ad", "78": "bf1217db5748fa4e", "79": "c6cfdfe181232ecc", "80": "315325cde1352e98", "81": "37c559741825be74", "82": "2b037735e1f5b0f6", "83": "1284897282eb47a3", "84": "d8bb296741d3d05c", "85": "4bc370be8afd7db1", "87": "e5fa7717989eda80", "88": "8b5ab5232cef3457", "89": "9e3363b80eeceeea", "90": "dd1c246e19a2d032", "91": "83b0fd3b14363e4d", "92": "fced244d93677d90", "93": "435ca504c453b5ca", "94": "70616dc5d4c69039", "95": "59c31f858c7b6970", "96": "4195478d5c528371", "97": "01f68dfc34a29d32", "98": "8c471c7baeaaca54", "99": "da489b69f7f553c4", "100": "adaed54e9cb4adfc", "106": "8bc72c179793e1c2"}
|
||||
{"0": "65cda48e6d2f67a3", "1": "cb6ea50bd4e4a5ca", "2": "b159712c832c558e", "3": "bcbfbc0b66f44357", "4": "56bf12952b0de42b", "5": "8e50b0e3c19c0a11", "6": "e154e5d8b03e5206", "7": "880a74c6d255e5cc", "8": "a689fcb62e7754af", "9": "63bb420d48d7a255", "10": "2b037735e1f5b0f6", "11": "4a490abd5e503318", "12": "aeca274694e158d2", "13": "7e3b558a587a4ebe", "14": "bf75bfc99d4f9e34", "15": "b1a71a621511068d", "16": "95add6a975c0cb72", "17": "e042d7fe7351bcb9", "18": "f93d5d85e671fcee", "19": "39abb1eef19df707", "20": "aecf2fce44ba49c5", "21": "59485c317afc6c2b", "22": "512a2535c88c4284", "23": "90fac00055070ce2", "24": "f85b5b1634224c86", "25": "46e8db3e6e3f37ea", "26": "041694c55d6f8a30", "27": "eab427ce9a5219e9", "28": "d3fab0fa540bd925", "29": "c608f1c6492e1cb1", "30": "73c13793fca09f56", "31": "fbbcfc059751cc6a", "32": "f6bcf56c4589316b", "33": "75f750c5b7d9f63d", "34": "f024ff7942935690", "35": "241be5abeab1da6f", "36": "a7d375041779bad4", "37": "cb8f26249485324a", "38": "9a25b1230b5fe649", "39": "fdd95375b4a5ddb3", "40": "5e546676ab96de03", "41": "d96493a5f110bd4c", "42": "916170b9f5d7346b", "43": "7986b9d5a423a3fc", "44": "852c1bb2446edd9c", "45": "92ab3e5134ed8685", "46": "65718282b3c32109", "47": "e594ecaa8e7d1538", "48": "cf2f2424bc00436f", "49": "1f976711cda158dc", "50": "bbb2b57d72922b16", "51": "eb6a182204867275", "52": "2ec3c8c088568ba9", "53": "68c45056378341a7", "54": "c58d41e8e580fdc6", "55": "82703230bd5ed1e1", "56": "9ee2ad45dc27851d", "57": "f2a40b5f1b29f669", "58": "803dac08b323abc2", "59": "891f59cda3708718", "60": "a505b7de6257a4e8", "61": "0890ad2144e37304", "62": "1366ae24c7e5897d", "63": "c256b43f75d7809e", "64": "ce0e68dab611f022", "65": "74ae017de0ffd36b", "66": "b6d6154e2dd14637", "67": "3e126d8deaf6e200", "68": "6b91f3679541de79", "69": "e511846eeba03076", "70": "af5864c4ac65160d", "71": "06e9a65e05e71d35", "72": "c84f196a7bd35861", "73": "2b887bf601ccfb9f", "74": "ae2a154311e1f53a", "75": "69fdd23278518884", "76": "74310612e8e059f4", "77": "2ee521497bb8506a", "78": "5cb44ae4ec05213b", "79": "c6cfdfe181232ecc", "80": "315325cde1352e98", "81": "37c559741825be74", "82": "200f25fbad7af8fb", "83": "1284897282eb47a3", "84": "d8bb296741d3d05c", "85": "4bc370be8afd7db1", "86": "e858d75e995c92e5", "87": "e5fa7717989eda80", "88": "8b5ab5232cef3457", "89": "9e3363b80eeceeea", "90": "dd1c246e19a2d032", "91": "83b0fd3b14363e4d", "92": "fced244d93677d90", "93": "8bd61518dd267641", "94": "713d04996c4627df", "95": "59c31f858c7b6970", "96": "4195478d5c528371", "97": "01f68dfc34a29d32", "98": "8c471c7baeaaca54", "99": "23f1d406f6c779ae", "100": "3dd68cce72af5314", "101": "a0bc88eb14b70170", "102": "29790cf710874dd0", "103": "6ffbe07b521a061b", "104": "e114a7aebaaca2f1", "105": "0567be4dc8048b9c", "106": "8bc72c179793e1c2", "107": "7ac475ea9bc280f8"}
|
||||
+124
-108
@@ -1,16 +1,16 @@
|
||||
# Graph Report - juzi (2026-10-12)
|
||||
|
||||
## Corpus Check
|
||||
- 83 files · ~4,627,050 words
|
||||
- 93 files · ~4,889,148 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 1326 nodes · 2732 edges · 98 communities (78 shown, 20 thin omitted)
|
||||
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 183 edges (avg confidence: 0.54)
|
||||
- 1387 nodes · 2814 edges · 108 communities (82 shown, 26 thin omitted)
|
||||
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 189 edges (avg confidence: 0.54)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Graph Freshness
|
||||
- Built from commit: `f3e7eac9`
|
||||
- Built from commit: `3f165bf9`
|
||||
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||
- Run `graphify update .` after code changes (no API cost).
|
||||
|
||||
@@ -21,23 +21,23 @@
|
||||
- skin-score.js
|
||||
- customer-feedback-second.js
|
||||
- n
|
||||
- .runAndExtendWithFaceDetection
|
||||
- .toString
|
||||
- extractFaceTensors
|
||||
- .forwardInput
|
||||
- admin.js
|
||||
- __awaiter
|
||||
- FaceLandmarks
|
||||
- wechat-browser.mjs
|
||||
- lib/accounts.js
|
||||
- server.js
|
||||
- files
|
||||
- .draw
|
||||
- extractFaces
|
||||
- 巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范
|
||||
- k
|
||||
- 1'me 在线肌肤测试 H5
|
||||
- head-pose.js
|
||||
- report-persistence.mjs
|
||||
- 1'me肌肤状态检测 H5
|
||||
- customer-feedback-fourth.js
|
||||
- build_fixtures.py
|
||||
- disposeUnusedWeightTensors
|
||||
- .predictAgeAndGender
|
||||
- AgeGenderNet
|
||||
- facepp-skinanalyze(旷视皮肤分析专业版调用技能)
|
||||
- 巨子测肤 · 1'me 语音播报内容设计文档 V1.0
|
||||
- mobile-select.iife.min.js
|
||||
@@ -53,6 +53,7 @@
|
||||
- UI 外发版 10.09 图片资源
|
||||
- FaceLandmark68Net
|
||||
- UI交互界面调整
|
||||
- 第四轮反馈修改与验收
|
||||
- NeuralNetwork
|
||||
- layer_at
|
||||
- normalize
|
||||
@@ -65,57 +66,64 @@
|
||||
- call_skinanalyze_3view.sh
|
||||
- 护理建议(全量文本,按需求摘抄)
|
||||
- UI 对齐 10.09 设计稿:调研与实施计划
|
||||
- i
|
||||
- pn
|
||||
- report-ui.js
|
||||
- regression.js
|
||||
- .dispose
|
||||
- call_skinanalyze.sh
|
||||
- FaceProcessor
|
||||
- 提交前 Review(2026-10-11)
|
||||
- e2e-browser.mjs
|
||||
- account-client.js
|
||||
- 微信登录接入与公网部署说明
|
||||
- report-persistence.mjs
|
||||
- FaceLandmarks68
|
||||
- ui-20261009.mjs
|
||||
- analyze-timeout.js
|
||||
- misans/README.md
|
||||
- .forwardInput
|
||||
- extractConvParamsFactory
|
||||
- extractAllFacesAndComputeResults
|
||||
- build_report_copy.py
|
||||
- TinyYolov2Base
|
||||
- drawFaceExpressions
|
||||
- t
|
||||
- .toString
|
||||
- .runAndExtendWithFaceDetections
|
||||
- i
|
||||
- 登录反馈与加载优化(2026-10-12)
|
||||
- 线上微信登录部署记录
|
||||
- wechat-production.mjs
|
||||
- wechat-browser.mjs
|
||||
- 拍摄约束放宽与标题更新
|
||||
- 10.09 新版 H5 实施与验收
|
||||
- 微信登录联调:2026-10-12
|
||||
- test/accounts.js
|
||||
- FaceExpressionNet
|
||||
- 产品推荐规则:肤龄基准与边界调整
|
||||
- ui-20261012/manifest.json
|
||||
- 傅艳NEW与项思炼的会话 2026年10月12日.md
|
||||
- wi
|
||||
- 客户反馈修改(2026-10-12)
|
||||
- .runAndExtendWithFaceDetections
|
||||
- 第三轮反馈:PSD 对齐及本地验收
|
||||
- .runAndExtendWithFaceDetection
|
||||
- 第三轮反馈:PSD 对齐、验收及上线记录
|
||||
- login-performance.mjs
|
||||
- production-loop.mjs
|
||||
- PredictSingleAgeAndGenderWithFaceAlignmentTask
|
||||
- PredictSingleFaceExpressionsWithFaceAlignmentTask
|
||||
- DetectSingleFaceLandmarksTask
|
||||
- extractAllFacesAndComputeResults
|
||||
- ComputeAllFaceDescriptorsTask
|
||||
- drawFaceExpressions
|
||||
- TinyYolov2
|
||||
- 第四轮反馈/傅艳NEW与项思炼的会话 2026年10月12日.md
|
||||
- 第三轮反馈20261012/傅艳NEW与项思炼的会话 2026年10月12日.md
|
||||
- ComputeSingleFaceDescriptorTask
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `__awaiter()` - 59 edges
|
||||
2. `__generator()` - 59 edges
|
||||
3. `n()` - 37 edges
|
||||
4. `r()` - 35 edges
|
||||
5. `t()` - 33 edges
|
||||
6. `i()` - 33 edges
|
||||
5. `i()` - 33 edges
|
||||
6. `t()` - 32 edges
|
||||
7. `C()` - 32 edges
|
||||
8. `toNetInput()` - 27 edges
|
||||
9. `e()` - 22 edges
|
||||
10. `loadRecords()` - 20 edges
|
||||
10. `loadRecords()` - 21 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `actualLines()` --indirect_call--> `el()` [INFERRED]
|
||||
@@ -124,19 +132,19 @@
|
||||
h5/js/admin.js → h5/vendor/faceapi/face-api.js
|
||||
- `bind()` --indirect_call--> `a()` [INFERRED]
|
||||
h5/js/admin.js → h5/vendor/faceapi/face-api.js
|
||||
- `detectFrame()` --indirect_call--> `d()` [INFERRED]
|
||||
- `checkStill()` --indirect_call--> `d()` [INFERRED]
|
||||
h5/js/app.js → h5/vendor/faceapi/face-api.js
|
||||
- `vizCompose()` --indirect_call--> `rh()` [INFERRED]
|
||||
- `detectFrame()` --indirect_call--> `d()` [INFERRED]
|
||||
h5/js/app.js → h5/vendor/faceapi/face-api.js
|
||||
|
||||
## Import Cycles
|
||||
- None detected.
|
||||
|
||||
## Communities (98 total, 20 thin omitted)
|
||||
## Communities (108 total, 26 thin omitted)
|
||||
|
||||
### Community 0 - "face-api.js"
|
||||
Cohesion: 0.02
|
||||
Nodes (37): bgrToRgbTensor(), cn(), computeReshapedDimensions(), createBrowserEnv(), createFileSystem(), createNodejsEnv(), Dimensions(), dr() (+29 more)
|
||||
Nodes (51): bgrToRgbTensor(), cn(), computeReshapedDimensions(), createBrowserEnv(), createFaceDetectionNet(), createSsdMobilenetv1(), Dimensions(), dr() (+43 more)
|
||||
|
||||
### Community 1 - "线上闭环测试(不含真实微信授权)"
|
||||
Cohesion: 0.25
|
||||
@@ -144,10 +152,10 @@ Nodes (7): 发现并修复, 可复现命令, 已通过的检查, 线上闭环测
|
||||
|
||||
### Community 2 - "app.js"
|
||||
Cohesion: 0.05
|
||||
Nodes (82): applyMirror(), armNudge(), beginDetect(), bindBackdropClose(), busyWithFlow(), captureFromVideo(), captureHaptic(), checkStill() (+74 more)
|
||||
Nodes (85): applyMirror(), armNudge(), beginDetect(), bindBackdropClose(), busyWithFlow(), captureFromVideo(), captureHaptic(), checkStill() (+77 more)
|
||||
|
||||
### Community 3 - "skin-score.js"
|
||||
Cohesion: 0.12
|
||||
Cohesion: 0.11
|
||||
Nodes (45): acneGrade(), buildViewModel(), calcAcne(), calcContour(), calcSens(), calcSmooth(), calcTone(), calcWrinkles() (+37 more)
|
||||
|
||||
### Community 4 - "customer-feedback-second.js"
|
||||
@@ -155,28 +163,28 @@ Cohesion: 0.17
|
||||
Nodes (10): assert, context, face, fs, geometry, original, path, source (+2 more)
|
||||
|
||||
### Community 5 - "n"
|
||||
Cohesion: 0.15
|
||||
Nodes (36): a(), ae(), bf(), cf(), d(), drawContour(), dt(), ea() (+28 more)
|
||||
Cohesion: 0.12
|
||||
Nodes (43): a(), ae(), ba(), bf(), cf(), d(), da(), drawContour() (+35 more)
|
||||
|
||||
### Community 6 - ".runAndExtendWithFaceDetection"
|
||||
Cohesion: 0.23
|
||||
Nodes (6): allFacesMtcnn(), allFacesSsdMobilenetv1(), allFacesTinyYolov2(), DetectAllFaceLandmarksTask(), detectAllFaces(), DetectSingleFaceTask()
|
||||
### Community 6 - "extractFaceTensors"
|
||||
Cohesion: 0.16
|
||||
Nodes (5): Box(), extendWithFaceLandmarks(), extractFaceTensors(), FaceLandmarks(), minBbox()
|
||||
|
||||
### Community 7 - ".toString"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): FaceMatch(), la(), pr(), pt(), vt()
|
||||
### Community 7 - ".forwardInput"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): boxPredictionLayer(), decodeBoxesLayer(), depthwiseConvLayer(), getCenterCoordinatesAndSizesLayer(), getStridesForLayerIdx(), mobileNetV1(), outputLayer(), pointwiseConvLayer() (+1 more)
|
||||
|
||||
### Community 8 - "admin.js"
|
||||
Cohesion: 0.21
|
||||
Nodes (28): ageText(), api(), bind(), closeDetail(), collectFilters(), dimsHtml(), doRetry(), dSec() (+20 more)
|
||||
Cohesion: 0.20
|
||||
Nodes (29): ageText(), api(), bind(), captureQualityHtml(), closeDetail(), collectFilters(), dimsHtml(), doRetry() (+21 more)
|
||||
|
||||
### Community 9 - "__awaiter"
|
||||
Cohesion: 0.14
|
||||
Nodes (12): __awaiter(), ComposableTask(), fetchImage(), fetchJson(), fetchNetWeights(), fetchOrThrow(), __generator(), getModelUris() (+4 more)
|
||||
Cohesion: 0.12
|
||||
Nodes (14): __awaiter(), bufferToImage(), ComposableTask(), extendWithFaceExpressions(), fetchImage(), fetchJson(), fetchNetWeights(), fetchOrThrow() (+6 more)
|
||||
|
||||
### Community 10 - "FaceLandmarks"
|
||||
Cohesion: 0.09
|
||||
Nodes (8): FaceLandmark68NetBase(), FaceLandmarks(), FaceLandmarks5(), FaceLandmarks68(), getCenterPoint(), minBbox(), Point(), wl()
|
||||
### Community 10 - "wechat-browser.mjs"
|
||||
Cohesion: 0.22
|
||||
Nodes (6): createAccounts, h5, ReportUI, require, root, SkinScore
|
||||
|
||||
### Community 11 - "lib/accounts.js"
|
||||
Cohesion: 0.17
|
||||
@@ -184,44 +192,40 @@ Nodes (20): atomic(), body(), cookie(), cookies(), crypto, fs, json(), login() (
|
||||
|
||||
### Community 12 - "server.js"
|
||||
Cohesion: 0.08
|
||||
Nodes (51): accounts, appendRecord(), callFacepp(), CRED, crypto, csvCell(), DATA_DIR, displaySkinAge() (+43 more)
|
||||
Nodes (52): accounts, appendRecord(), callFacepp(), CaptureDiagnostics, CRED, crypto, csvCell(), DATA_DIR (+44 more)
|
||||
|
||||
### Community 13 - "files"
|
||||
Cohesion: 0.12
|
||||
Nodes (15): files, analyze-fail, analyzing, countdown-321, hint-far, hint-front, hint-hold, hint-near (+7 more)
|
||||
|
||||
### Community 15 - "extractFaces"
|
||||
Cohesion: 0.12
|
||||
Nodes (22): awaitMediaLoaded(), Box(), bufferToImage(), createCanvas(), createCanvasFromMedia(), extractFaces(), extractFaceTensors(), extractImagePatches() (+14 more)
|
||||
Cohesion: 0.24
|
||||
Nodes (15): awaitMediaLoaded(), createCanvas(), createCanvasFromMedia(), extractFaces(), extractImagePatches(), getContext2dOrThrow(), getEnv(), getMediaDimensions() (+7 more)
|
||||
|
||||
### Community 16 - "巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范"
|
||||
Cohesion: 0.15
|
||||
Nodes (12): 1. 基准与尺寸换算, 2. 色彩规范, 3. 字体规范, 4. 组件切图(assets/svg/), 5. 图片素材(assets/images/,共 15 张), 6. 页面清单(design/ 下有整屏 PNG 预览), 7. 目录结构, 8. 还原建议 (+4 more)
|
||||
|
||||
### Community 17 - "k"
|
||||
Cohesion: 0.11
|
||||
Nodes (21): b(), ba(), be(), ca(), da(), fa(), jt(), k() (+13 more)
|
||||
### Community 17 - "report-persistence.mjs"
|
||||
Cohesion: 0.29
|
||||
Nodes (6): child, dataRoot, fx, h5, ReportUI, require
|
||||
|
||||
### Community 18 - "1'me 在线肌肤测试 H5"
|
||||
### Community 18 - "1'me肌肤状态检测 H5"
|
||||
Cohesion: 0.13
|
||||
Nodes (15): 10.09 更新, 1'me 在线肌肤测试 H5, Admin API(统一返回 `{ok:false,message}` 错误), Windows 本机部署(提测用), 后台查看(admin.html), 密钥配置, 已知占位清单, 技术说明 (+7 more)
|
||||
Nodes (15): 10.09 更新, 1'me肌肤状态检测 H5, Admin API(统一返回 `{ok:false,message}` 错误), Windows 本机部署(提测用), 后台查看(admin.html), 密钥配置, 已知占位清单, 技术说明 (+7 more)
|
||||
|
||||
### Community 19 - "head-pose.js"
|
||||
Cohesion: 0.33
|
||||
Nodes (8): assertPoints(), check(), clamp(), dist(), estimate(), makeFrontal(), makeSide(), midpoint()
|
||||
### Community 19 - "customer-feedback-fourth.js"
|
||||
Cohesion: 0.06
|
||||
Nodes (37): evaluate(), finite(), assertPoints(), check(), clamp(), dist(), estimate(), makeFrontal() (+29 more)
|
||||
|
||||
### Community 20 - "build_fixtures.py"
|
||||
Cohesion: 0.40
|
||||
Nodes (9): build_case1(), build_case2(), build_truth(), col(), dump(), main(), parse_value(), set_path() (+1 more)
|
||||
|
||||
### Community 21 - "disposeUnusedWeightTensors"
|
||||
Cohesion: 0.08
|
||||
Cohesion: 0.10
|
||||
Nodes (24): disposeUnusedWeightTensors(), extractorsFactory$3(), extractorsFactory$5(), extractorsFactory$7(), extractorsFactory$9(), extractParamsFromWeigthMap(), extractParamsFromWeigthMap$1(), extractParamsFromWeigthMap$2() (+16 more)
|
||||
|
||||
### Community 22 - ".predictAgeAndGender"
|
||||
Cohesion: 0.28
|
||||
Nodes (4): extendWithAge(), extendWithGender(), PredictAllAgeAndGenderTask(), PredictSingleAgeAndGenderTask()
|
||||
|
||||
### Community 23 - "facepp-skinanalyze(旷视皮肤分析专业版调用技能)"
|
||||
Cohesion: 0.20
|
||||
Nodes (9): 1. 凭据(统一管理), 2. 标准调用模板(全可选参数,每次都带), 2b. 三视图调用模板(正脸 + 双侧脸 → 下颌线), 3.1 本轮踩过的坑(务必避复), 3. 三维度 × 6 分类 P/S 计算(2026-09-26 生效,取代旧六维), 4. 产品分组公式, 5. Excel 报告铁律(必须遵守), 6. 其他 Agent 调用约定 (+1 more)
|
||||
@@ -235,8 +239,8 @@ Cohesion: 0.20
|
||||
Nodes (16): _arrayLikeToArray(), _arrayWithHoles(), _arrayWithoutHoles(), _classCallCheck(), _createClass(), _defineProperties(), h(), _iterableToArray() (+8 more)
|
||||
|
||||
### Community 26 - ".extractWeights"
|
||||
Cohesion: 0.05
|
||||
Nodes (29): AgeGenderNet(), createFaceDetectionNet(), createFaceRecognitionNet(), createMtcnn(), createSsdMobilenetv1(), createTinyFaceDetector(), createTinyYolov2(), extractConvParamsFactory() (+21 more)
|
||||
Cohesion: 0.14
|
||||
Nodes (14): createFaceRecognitionNet(), createMtcnn(), createTinyFaceDetector(), createTinyYolov2(), extractFCParamsFactory(), extractorsFactory$2(), extractorsFactory$4(), extractorsFactory$8() (+6 more)
|
||||
|
||||
### Community 27 - "gen_voice.py"
|
||||
Cohesion: 0.30
|
||||
@@ -244,7 +248,7 @@ Nodes (11): ndarray, Path, gen_countdown(), main(), normalize_clip(), 倒计时
|
||||
|
||||
### Community 28 - "C"
|
||||
Cohesion: 0.09
|
||||
Nodes (31): ao(), C(), cd(), co(), Do(), eo(), fo(), Ft() (+23 more)
|
||||
Nodes (36): ao(), Bo(), C(), cd(), cl(), co(), Do(), e() (+28 more)
|
||||
|
||||
### Community 29 - "face-api.js 本地化依赖(h5/vendor/faceapi/)"
|
||||
Cohesion: 0.29
|
||||
@@ -260,7 +264,7 @@ Nodes (4): main(), 递归缩放 result 内全部坐标:{left,top,width,height}
|
||||
|
||||
### Community 35 - "customer-feedback.js"
|
||||
Cohesion: 0.06
|
||||
Nodes (26): alternate, assert, crop, details, excellent, fs, model, modelBefore (+18 more)
|
||||
Nodes (27): alternate, approved, assert, crop, details, excellent, fs, model (+19 more)
|
||||
|
||||
### Community 37 - "UI 外发版 10.09 图片资源"
|
||||
Cohesion: 0.40
|
||||
@@ -270,13 +274,17 @@ Nodes (4): UI 外发版 10.09 图片资源, 前端素材对应, 文件位置,
|
||||
Cohesion: 0.40
|
||||
Nodes (4): 1meAI测肤旷视UI问题反馈, UI交互界面调整, 交互, **设计**
|
||||
|
||||
### Community 40 - "第四轮反馈修改与验收"
|
||||
Cohesion: 0.22
|
||||
Nodes (8): PSD 来源, 前端参数及限制, 后台校准留痕, 已确认并实现, 生产部署及只读验收, 皱纹(紧塑力)原文, 第四轮反馈修改与验收, 验证
|
||||
|
||||
### Community 42 - "layer_at"
|
||||
Cohesion: 0.18
|
||||
Nodes (11): list, bounds(), layer_at(), main(), save_image(), composite(), main(), sha256() (+3 more)
|
||||
|
||||
### Community 43 - "normalize"
|
||||
Cohesion: 0.09
|
||||
Nodes (27): conv(), conv$1(), convDown(), convLayer(), convLayer$1(), convNoRelu(), convWithBatchNorm(), denseBlock3() (+19 more)
|
||||
Cohesion: 0.07
|
||||
Nodes (28): conv(), conv$1(), convDown(), convLayer(), convLayer$1(), convNoRelu(), convWithBatchNorm(), denseBlock3() (+20 more)
|
||||
|
||||
### Community 44 - "start-local.sh"
|
||||
Cohesion: 0.50
|
||||
@@ -298,21 +306,21 @@ Nodes (19): 5\.1 轮廓(紧塑力), 5\.2 皱纹(紧塑力), 5\.3 肤色
|
||||
Cohesion: 0.11
|
||||
Nodes (17): 1. 结论和依据, 2. 统一比较口径, 3. 逐页差异和调整内容, 4. 报告数据如何接到新 UI, 5. 登录与“我的报告”的真实能力缺口, 6. 原稿中需要整理的内容, 7. 实施顺序与交付物, 8. 验收定义 (+9 more)
|
||||
|
||||
### Community 55 - "i"
|
||||
Cohesion: 0.11
|
||||
Nodes (20): bn(), dn(), ee(), et(), fn(), i(), je(), ln() (+12 more)
|
||||
### Community 55 - "pn"
|
||||
Cohesion: 0.25
|
||||
Nodes (8): bn(), fn(), ln(), pd(), pn(), un(), wn(), xn()
|
||||
|
||||
### Community 56 - "report-ui.js"
|
||||
Cohesion: 0.50
|
||||
Nodes (8): build(), count(), fill(), metricText(), numeric(), scalar(), wrinkleDetails(), wrinkleText()
|
||||
Nodes (7): build(), count(), fill(), focusOf(), numeric(), scalar(), wrinkleDetails()
|
||||
|
||||
### Community 57 - "regression.js"
|
||||
Cohesion: 0.60
|
||||
Nodes (5): diffCtx(), loadJSON(), main(), okNum(), okStr()
|
||||
|
||||
### Community 58 - ".dispose"
|
||||
Cohesion: 0.14
|
||||
Nodes (13): extendWithFaceLandmarks(), getSizesForScale(), iou(), nonMaxSuppression(), nonMaxSuppression$1(), normalize$1(), ObjectDetection(), rescaleAndNormalize() (+5 more)
|
||||
Cohesion: 0.15
|
||||
Nodes (9): FaceRecognitionNet(), NetInput(), nonMaxSuppression(), scale(), SsdMobilenetv1(), stage1(), stage2(), stage3() (+1 more)
|
||||
|
||||
### Community 62 - "提交前 Review(2026-10-11)"
|
||||
Cohesion: 0.40
|
||||
@@ -330,25 +338,33 @@ Nodes (5): browser(), init(), read(), rememberKind(), request()
|
||||
Cohesion: 0.22
|
||||
Nodes (7): 公网服务器配置, 公网真机验收清单, 反向代理与后台保护, 已完成的链路, 微信登录接入与公网部署说明, 数据、迁移与备份, 本机开发环境限制(不代表线上状态)
|
||||
|
||||
### Community 66 - "report-persistence.mjs"
|
||||
Cohesion: 0.29
|
||||
Nodes (6): child, dataRoot, fx, h5, ReportUI, require
|
||||
### Community 66 - "FaceLandmarks68"
|
||||
Cohesion: 0.10
|
||||
Nodes (8): FaceLandmark68NetBase(), FaceLandmarks5(), FaceLandmarks68(), getCenterPoint(), Point(), pt(), vt(), wl()
|
||||
|
||||
### Community 68 - "analyze-timeout.js"
|
||||
Cohesion: 0.20
|
||||
Nodes (7): assert, end, fs, path, source, start, vm
|
||||
|
||||
### Community 70 - ".forwardInput"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): boxPredictionLayer(), decodeBoxesLayer(), depthwiseConvLayer(), getCenterCoordinatesAndSizesLayer(), getStridesForLayerIdx(), mobileNetV1(), outputLayer(), pointwiseConvLayer() (+1 more)
|
||||
### Community 70 - "extractConvParamsFactory"
|
||||
Cohesion: 0.20
|
||||
Nodes (9): extractConvParamsFactory(), extractorsFactory(), extractorsFactory$1(), extractorsFactory$6(), extractParams(), extractParams$2(), extractParams$6(), extractParamsTiny() (+1 more)
|
||||
|
||||
### Community 77 - "drawFaceExpressions"
|
||||
Cohesion: 0.22
|
||||
Nodes (6): DrawBox(), drawDetections(), drawFaceExpressions(), DrawFaceLandmarks(), FaceExpressions(), isWithFaceExpressions()
|
||||
### Community 71 - "extractAllFacesAndComputeResults"
|
||||
Cohesion: 0.29
|
||||
Nodes (5): extendWithAge(), extendWithFaceDescriptor(), extendWithGender(), extractAllFacesAndComputeResults(), extractSingleFaceAndComputeResult()
|
||||
|
||||
### Community 78 - "t"
|
||||
Cohesion: 0.10
|
||||
Nodes (26): bi(), Bo(), ce(), cl(), de(), e(), Ef(), er() (+18 more)
|
||||
### Community 76 - ".toString"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): createFileSystem(), createNodejsEnv(), FaceMatch(), la(), pr()
|
||||
|
||||
### Community 77 - ".runAndExtendWithFaceDetections"
|
||||
Cohesion: 0.18
|
||||
Nodes (8): DetectAllFacesTask(), DrawBox(), drawDetections(), DrawFaceLandmarks(), extendWithFaceDetection(), isWithFaceDetection(), isWithFaceLandmarks(), resizeResults()
|
||||
|
||||
### Community 78 - "i"
|
||||
Cohesion: 0.06
|
||||
Nodes (45): b(), be(), bi(), ca(), ce(), de(), dn(), ee() (+37 more)
|
||||
|
||||
### Community 79 - "登录反馈与加载优化(2026-10-12)"
|
||||
Cohesion: 0.25
|
||||
@@ -362,9 +378,9 @@ Nodes (6): 初次部署时的凭据查找记录(现已由用户提供), 初
|
||||
Cohesion: 0.60
|
||||
Nodes (4): api(), base, callback(), start()
|
||||
|
||||
### Community 82 - "wechat-browser.mjs"
|
||||
Cohesion: 0.22
|
||||
Nodes (6): createAccounts, h5, ReportUI, require, root, SkinScore
|
||||
### Community 82 - "拍摄约束放宽与标题更新"
|
||||
Cohesion: 0.40
|
||||
Nodes (4): 参数对照, 拍摄约束放宽与标题更新, 标题, 验证与发布
|
||||
|
||||
### Community 83 - "10.09 新版 H5 实施与验收"
|
||||
Cohesion: 0.29
|
||||
@@ -394,41 +410,41 @@ Nodes (9): Ci(), Ei(), Fi(), Ii(), ki(), pe(), ri(), wi() (+1 more)
|
||||
Cohesion: 0.33
|
||||
Nodes (5): 公网部署与复测, 客户反馈修改(2026-10-12), 皱纹数据说明, 逐项处理, 验证
|
||||
|
||||
### Community 93 - ".runAndExtendWithFaceDetections"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): DetectAllFacesTask(), extendWithFaceDetection(), isWithFaceDetection(), isWithFaceLandmarks(), resizeResults()
|
||||
### Community 93 - ".runAndExtendWithFaceDetection"
|
||||
Cohesion: 0.23
|
||||
Nodes (6): allFacesMtcnn(), allFacesSsdMobilenetv1(), allFacesTinyYolov2(), DetectAllFaceLandmarksTask(), detectAllFaces(), DetectSingleFaceTask()
|
||||
|
||||
### Community 94 - "第三轮反馈:PSD 对齐及本地验收"
|
||||
Cohesion: 0.40
|
||||
Nodes (4): 原稿与导出可追溯性, 已完成, 本地验证, 第三轮反馈:PSD 对齐及本地验收
|
||||
### Community 94 - "第三轮反馈:PSD 对齐、验收及上线记录"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): 原稿与导出可追溯性, 已完成, 本地验证, 生产部署及只读验收, 第三轮反馈:PSD 对齐、验收及上线记录
|
||||
|
||||
### Community 96 - "production-loop.mjs"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): artifacts, errors, samples
|
||||
|
||||
### Community 100 - "extractAllFacesAndComputeResults"
|
||||
Cohesion: 0.11
|
||||
Nodes (9): ComputeAllFaceDescriptorsTask(), ComputeSingleFaceDescriptorTask(), extendWithFaceDescriptor(), extendWithFaceExpressions(), extractAllFacesAndComputeResults(), extractSingleFaceAndComputeResult(), FaceRecognitionNet(), PredictAllFaceExpressionsTask() (+1 more)
|
||||
### Community 101 - "drawFaceExpressions"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): drawFaceExpressions(), FaceExpressions(), isWithFaceExpressions()
|
||||
|
||||
## Knowledge Gaps
|
||||
- **256 isolated node(s):** `source`, `document_size`, `pages`, `assets`, `layers` (+251 more)
|
||||
- **288 isolated node(s):** `source`, `document_size`, `pages`, `assets`, `layers` (+283 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **20 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
- **26 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `k()` connect `k` to `face-api.js`, `app.js`, `n`, `lib/accounts.js`, `t`, `C`?**
|
||||
_High betweenness centrality (0.061) - this node is a cross-community bridge._
|
||||
- **Why does `renderReport()` connect `app.js` to `k`, `t`?**
|
||||
_High betweenness centrality (0.048) - this node is a cross-community bridge._
|
||||
- **Why does `rate()` connect `lib/accounts.js` to `k`?**
|
||||
_High betweenness centrality (0.048) - this node is a cross-community bridge._
|
||||
- **Why does `v()` connect `n` to `report-ui.js`, `face-api.js`, `app.js`, `C`?**
|
||||
_High betweenness centrality (0.068) - this node is a cross-community bridge._
|
||||
- **Why does `k()` connect `n` to `face-api.js`, `app.js`, `lib/accounts.js`, `i`, `C`?**
|
||||
_High betweenness centrality (0.062) - this node is a cross-community bridge._
|
||||
- **Why does `fill()` connect `report-ui.js` to `n`?**
|
||||
_High betweenness centrality (0.054) - this node is a cross-community bridge._
|
||||
- **Are the 24 inferred relationships involving `n()` (e.g. with `newRecordId()` and `ae()`) actually correct?**
|
||||
_`n()` has 24 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 27 inferred relationships involving `r()` (e.g. with `handleExport()` and `handleRecordsQuery()`) actually correct?**
|
||||
_`r()` has 27 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 7 inferred relationships involving `t()` (e.g. with `renderReport()` and `e()`) actually correct?**
|
||||
_`t()` has 7 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 9 inferred relationships involving `i()` (e.g. with `da()` and `f()`) actually correct?**
|
||||
_`i()` has 9 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **What connects `source`, `document_size`, `pages` to the rest of the system?**
|
||||
_256 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
_288 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
File diff suppressed because one or more lines are too long
+2773
-1153
File diff suppressed because it is too large
Load Diff
+126
-36
@@ -40,13 +40,13 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/js/app.js": {
|
||||
"mtime": 1791793504.3911867,
|
||||
"ast_hash": "bedf52ab54b171d46f36b14e40c942a9",
|
||||
"mtime": 1791801437.4952922,
|
||||
"ast_hash": "1a79141b30421384ebb80a7c7c383ba9",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/js/head-pose.js": {
|
||||
"mtime": 1791559485.790093,
|
||||
"ast_hash": "cdfc5962793a4d6e567ffe00be7653e8",
|
||||
"mtime": 1791809137.1269257,
|
||||
"ast_hash": "6c71a4db97fa9b08ec4f6ec5ca54d31b",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/js/skin-score.js": {
|
||||
@@ -55,8 +55,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/server.js": {
|
||||
"mtime": 1791773587.8349628,
|
||||
"ast_hash": "4895534b06f0c2d29ada96d9b52b4693",
|
||||
"mtime": 1791800754.2112646,
|
||||
"ast_hash": "9983e6c978b3357e31bbfce59202dcde",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/build_fixtures.py": {
|
||||
@@ -135,13 +135,13 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/README.md": {
|
||||
"mtime": 1791646538.376033,
|
||||
"ast_hash": "1f8bb2cad20e467a93e1120dc7cf3924",
|
||||
"mtime": 1791809197.5909035,
|
||||
"ast_hash": "4e597d6be915f6cd0b7fbd4bf34e5a96",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/index.html": {
|
||||
"mtime": 1791792928.367181,
|
||||
"ast_hash": "f55cc02cf612e5d2526b3ef61ebb4d54",
|
||||
"mtime": 1791809137.1269257,
|
||||
"ast_hash": "505ec38f7f2120e7d4d1b61eb8328097",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/vendor/faceapi/README.md": {
|
||||
@@ -815,8 +815,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/js/admin.js": {
|
||||
"mtime": 1791735707.6717124,
|
||||
"ast_hash": "0de3075fb017762401fd2d27eb26abcb",
|
||||
"mtime": 1791806985.2188702,
|
||||
"ast_hash": "d9d5781dd607497f2803404be4e23e7a",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/js/region-data.js": {
|
||||
@@ -1245,13 +1245,13 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/js/report-ui.js": {
|
||||
"mtime": 1791792515.5711725,
|
||||
"ast_hash": "ba1192ff1f00a5e1ac4712e470124d0b",
|
||||
"mtime": 1791806985.2188702,
|
||||
"ast_hash": "5860e9a09f70fdb201b2f51bdcee88e4",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/lib/accounts.js": {
|
||||
"mtime": 1791770434.3399014,
|
||||
"ast_hash": "af809199fbaae6318fb71fe602ddc102",
|
||||
"mtime": 1791806985.2188702,
|
||||
"ast_hash": "a8116f973f1e01dcbfbe2e1e26c7c826",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/accounts.js": {
|
||||
@@ -1265,8 +1265,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/ui-20261009.mjs": {
|
||||
"mtime": 1791773972.891023,
|
||||
"ast_hash": "3a13f77513f40e5fc04aaeff06a906e8",
|
||||
"mtime": 1791800811.939266,
|
||||
"ast_hash": "778edb5314c2d35ae75c05344b96f532",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"tools/build_report_copy.py": {
|
||||
@@ -1530,8 +1530,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/wechat-browser.mjs": {
|
||||
"mtime": 1791773972.891023,
|
||||
"ast_hash": "c27ca11dea3026358188f80affee2d6b",
|
||||
"mtime": 1791800811.939266,
|
||||
"ast_hash": "616790aae9cf9b41d30ee8b8da979cb0",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/2026-10-11\u63d0\u4ea4\u524dReview.md": {
|
||||
@@ -1540,8 +1540,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/analyze-timeout.js": {
|
||||
"mtime": 1791730674.763797,
|
||||
"ast_hash": "c30bdb0ecb2de0b4c280335940e9d01f",
|
||||
"mtime": 1791800811.939266,
|
||||
"ast_hash": "07b7c2248ae3fd4112046828ce522318",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/production-loop.mjs": {
|
||||
@@ -1585,8 +1585,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/login-performance.mjs": {
|
||||
"mtime": 1791773812.003017,
|
||||
"ast_hash": "1ba88edfa7b259fc319de7f845925582",
|
||||
"mtime": 1791800811.939266,
|
||||
"ast_hash": "ffe9df947564cdef24e8f842cbc577f1",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/assets/img/ui-20261009/bg-history-h5.webp": {
|
||||
@@ -1620,13 +1620,13 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/customer-feedback.js": {
|
||||
"mtime": 1791779743.1310055,
|
||||
"ast_hash": "a01d5f9ec4424a2f21f8239451f576f9",
|
||||
"mtime": 1791806985.2188702,
|
||||
"ast_hash": "b0897ac30075bc2a350af40ef6660733",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/customer-feedback.mjs": {
|
||||
"mtime": 1791780010.8710139,
|
||||
"ast_hash": "a8332f17ef693582bc81bff83950fedb",
|
||||
"mtime": 1791807904.8348992,
|
||||
"ast_hash": "6bc22a957a60d361c8350bbaa60f63ca",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"feedback/\u5085\u8273NEW\u4e0e\u9879\u601d\u70bc\u7684\u4f1a\u8bdd 2026\u5e7410\u670812\u65e5/\u5085\u8273NEW\u4e0e\u9879\u601d\u70bc\u7684\u4f1a\u8bdd 2026\u5e7410\u670812\u65e5.md": {
|
||||
@@ -1675,13 +1675,13 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/customer-feedback-second.js": {
|
||||
"mtime": 1791786351.8390813,
|
||||
"ast_hash": "ca63c07bb8ee8eb77267b9d08e5cb3d8",
|
||||
"mtime": 1791809137.130926,
|
||||
"ast_hash": "d494abe8a486bb753d671f9c4c8b533f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/customer-feedback-second.mjs": {
|
||||
"mtime": 1791792650.5151644,
|
||||
"ast_hash": "2e340ce46b90c142341cb1f9636cb2e7",
|
||||
"mtime": 1791807904.8348992,
|
||||
"ast_hash": "169cedf52d8e32afc0c85cd68de2d0b1",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/\u5ba2\u6237\u53cd\u9988\u7b2c\u4e8c\u6279\u4fee\u6539-2026-10-12.md": {
|
||||
@@ -1695,8 +1695,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/customer-feedback-third.mjs": {
|
||||
"mtime": 1791793748.2471912,
|
||||
"ast_hash": "60694d94b13194da8aa13f796c8be196",
|
||||
"mtime": 1791801066.5032508,
|
||||
"ast_hash": "5fd25617bf8e4eb73ff168f2525e9811",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"tools/export_third_feedback_assets.py": {
|
||||
@@ -1795,8 +1795,98 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/\u5ba2\u6237\u53cd\u9988\u7b2c\u4e09\u8f6e\u4fee\u6539-2026-10-12.md": {
|
||||
"mtime": 1791793748.2471912,
|
||||
"ast_hash": "ae9f2b73434be30167f778fa974d950b",
|
||||
"mtime": 1791794316.9191844,
|
||||
"ast_hash": "077185d65247379a27c4b2cbeeb1c1f9",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"design/ui-20261012/scan-motion-manifest.json": {
|
||||
"mtime": 1791801070.1352544,
|
||||
"ast_hash": "8b2822306350853ee70ad0e21aa03424",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/js/capture-quality.js": {
|
||||
"mtime": 1791809137.1269257,
|
||||
"ast_hash": "ec66579ee0cde06a3fa045d612da69c1",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/lib/capture-diagnostics.js": {
|
||||
"mtime": 1791800706.5472584,
|
||||
"ast_hash": "7fec6b17bde84b1df8ef30d026c0af66",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/capture-quality-browser.mjs": {
|
||||
"mtime": 1791809137.130926,
|
||||
"ast_hash": "6daff225b0e02cec327882171408e4c5",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/customer-feedback-fourth.js": {
|
||||
"mtime": 1791809197.5909035,
|
||||
"ast_hash": "818c6b58ba8b4771275457bb13604f1f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/test/customer-feedback-fourth.mjs": {
|
||||
"mtime": 1791809137.130926,
|
||||
"ast_hash": "a07ebd722efaf2d8b6e382f566a5f649",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"tools/export_fourth_feedback_assets.py": {
|
||||
"mtime": 1791801066.5032508,
|
||||
"ast_hash": "60176be009baa038115e22afb9016b5d",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/\u5ba2\u6237\u53cd\u9988\u7b2c\u56db\u8f6e\u4fee\u6539-2026-10-12.md": {
|
||||
"mtime": 1791808065.414884,
|
||||
"ast_hash": "7117fd5a61f1c38159aa4ca4ac901a6c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"feedback/\u7b2c\u56db\u8f6e\u53cd\u9988/\u5085\u8273NEW\u4e0e\u9879\u601d\u70bc\u7684\u4f1a\u8bdd 2026\u5e7410\u670812\u65e5.md": {
|
||||
"mtime": 1791798692.0,
|
||||
"ast_hash": "560feba691e2db7329acc09e28f001c6",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"feedback/\u7b2c\u56db\u8f6e\u53cd\u9988/\u56fe\u7247\u548c\u9644\u4ef6/exported_img_v3_0216d_149f31b5-7891-42d9-8b90-d4a62ed533dg.jpeg": {
|
||||
"mtime": 1791798692.0,
|
||||
"ast_hash": "38c0605f25dba92bc7542255320cf553",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"feedback/\u7b2c\u56db\u8f6e\u53cd\u9988/\u56fe\u7247\u548c\u9644\u4ef6/exported_img_v3_0216d_48aff2c4-70c9-46fb-a54f-6d64a11f2bag.png": {
|
||||
"mtime": 1791798692.0,
|
||||
"ast_hash": "cd3906ba4d4f81b49c5ecb30717b0c07",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"feedback/\u7b2c\u56db\u8f6e\u53cd\u9988/\u56fe\u7247\u548c\u9644\u4ef6/exported_img_v3_0216d_aaa6c4cf-bb45-444e-a75b-1a142079f90g.png": {
|
||||
"mtime": 1791798692.0,
|
||||
"ast_hash": "0732cc2c712b408b16076b92daae3ef8",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"feedback/\u7b2c\u56db\u8f6e\u53cd\u9988/\u56fe\u7247\u548c\u9644\u4ef6/exported_img_v3_0216d_b4a8c873-2c99-47c7-9910-ebd1144100cg.png": {
|
||||
"mtime": 1791798692.0,
|
||||
"ast_hash": "ab5e31302c3e8576d723a842c91f56b5",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"feedback/\u7b2c\u56db\u8f6e\u53cd\u9988/\u56fe\u7247\u548c\u9644\u4ef6/exported_img_v3_0216d_dd91f5b9-0e52-4631-aa66-3ff9ef74c4bg.jpeg": {
|
||||
"mtime": 1791798692.0,
|
||||
"ast_hash": "5afe4ae417ebf5f11c47fc27900ed6d1",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"feedback/\u7b2c\u56db\u8f6e\u53cd\u9988/\u56fe\u7247\u548c\u9644\u4ef6/exported_img_v3_0216d_f853f4b9-3b5d-453e-91ce-8696787b374g.png": {
|
||||
"mtime": 1791798692.0,
|
||||
"ast_hash": "fe59ccf7a4580f406e2699efe3046007",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/assets/img/ui-20261012/scan-base.webp": {
|
||||
"mtime": 1791801069.8992546,
|
||||
"ast_hash": "3a125f75a057a9ce3883c48b505dd629",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"h5/assets/img/ui-20261012/scan-face-mask.png": {
|
||||
"mtime": 1791801069.9552546,
|
||||
"ast_hash": "d704649199e6785839683755f4e5b716",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/\u62cd\u6444\u7ea6\u675f\u653e\u5bbd\u4e0e\u6807\u9898\u66f4\u65b0-2026-10-12.md": {
|
||||
"mtime": 1791809197.5949035,
|
||||
"ast_hash": "62516ef04c3cba2f54e74c522d4e0b29",
|
||||
"semantic_hash": ""
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -1,4 +1,4 @@
|
||||
# 1'me 在线肌肤测试 H5
|
||||
# 1'me肌肤状态检测 H5
|
||||
|
||||
引导式自动三连拍(正脸/右脸/左脸),调用 **Face++ 皮肤分析(专业版 skinanalyze_pro)** 三视图真实检测,即时生成三维度×6分类肌肤状态分析与个性化护理建议的移动端 H5。
|
||||
|
||||
@@ -62,14 +62,16 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
|
||||
三步严格按顺序,本步拍到合格照片才进入下一步(左右一律以用户本人为准):1/3 正对屏幕(target `front`)→ 2/3 右脸出镜(用户头向自己的左侧转,target `right`,产物作 `right_image`)→ 3/3 左脸出镜(头向自己的右侧转,target `left`,产物作 `left_image`)。送检前 `photosByView` 按 target 重排为 [正, 左, 右]。页面内没有返回按钮,微信/浏览器返回键经 History API 回到档案页并释放摄像头。
|
||||
|
||||
- **取景蒙版**:内联 SVG(`#cam-mask`)按步骤切换轮廓——正脸步为正脸剪影(含耳部),侧脸步为对应方向的侧脸剪影;**轮廓路径由 Figma 拍照示意图(`design/assets/images/*-拍照示意.png` 的 alpha 镂空区)逐像素描摹生成**,坐标为 Figma 375×812 整帧,viewBox 只取 y 86–812;框外 85% 黑,轮廓线 2.5px 半透明白。检测运行时轮廓内有自上而下的扫描带(`#cam-clip` 由 app.js 复制轮廓生成);未检测到人脸时轮廓呼吸动画;条件合格时轮廓变绿。
|
||||
- **预览缩小 15%**:`#cam-video` 按 `scale(.85)` 显示,手机离脸近一些也能把脸装进轮廓;距离阈值随之按 1/0.85 换算(人脸框高/视频高 <0.30 提示「请靠近一点」、>0.68 提示「请远一点」)。
|
||||
- **动态提示**(一次一条,优先级:无人脸 > 距离 > 居中 > 角度 > 光线):「未检测到人脸」「请靠近一点 / 请远一点」「请将脸部移入取景框」(人脸中心偏离中线 >18% 屏宽)、角度类「请向左转头 / 请向右转头 / 请略微抬头 / 请略微低头」(正脸阈值 |yaw|≤15°、|pitch|≤18°,超出时按超出比例更大的一项给方向;侧脸 yaw 28°–75°、|pitch|≤20°)、光线类「请到光线充足处 / 光线过强,请避开强光」(脸部平均亮度 <65 或 >235)。
|
||||
- **合格即拍**:上述条件全部合格并连续维持 450ms(滤掉一闪而过的合格帧,防运动模糊)立即白屏补光抓拍,**无倒计时、无手动快门**。
|
||||
- **预览铺满**:`#cam-video` 保持现有 `object-fit:cover` 铺满与镜像策略,不改取景视觉。
|
||||
- **动态提示**:根据真机难拍反馈适度放宽(参数版本 `2026-10-12-fourth-v2`),单人、检测置信度≥0.4、人脸框高占原始画面 30%~68%、水平中心偏离≤16% 屏宽、完整入镜且距原始帧边缘≥1%。正脸 |yaw|≤15°/|pitch|≤18°,侧脸转头幅度 30°~70°/|pitch|≤20°,两者 |roll|≤15°;脸部平均亮度 65~235。参数集中在 `js/capture-quality.js` 与 `js/head-pose.js`,均为经验值,不等价于服务端质量分。
|
||||
- **合格即拍**:连续稳定至少 450ms 且至少 2 个合格检测帧;容许 yaw/pitch 漂移≤8°、中心移动≤原帧 5.5%、框高占比变化≤6.5 个百分点。漏检、超限漂移或帧间隔超过 2 秒均重置稳定累计;白屏抓拍后在本地复验实际 JPEG,合格才推进。**无倒计时、无手动快门**(已有降级模式除外)。
|
||||
- **两张之间停顿**:正脸、右脸拍完后中央弹出停顿卡约 2s(「正脸拍摄完成 / 下一张:右脸出镜,请向左转头」),停顿期间不检测,结束后再切到下一步并播下一步引导,让用户对下一张有心理预期。
|
||||
- **实时检测**:face-api 从本地 `vendor/faceapi/` 加载(`tinyFaceDetector` + `faceLandmark68Net`),进入拍照页开始加载(状态提示「检测引擎加载中…」)。检测循环 ~150ms 一帧,特征点取自原始帧(预览 CSS 镜像不影响),个别前置帧已镜像的机型由侧脸自校准翻转 `sideYawFlip`;连续漏检 ≥3 帧才报「未检测到人脸」。
|
||||
- **镜像策略**:前置镜头预览镜像(照镜子习惯);后置预览不镜像,侧脸轮廓随之翻转(`.cam-mask.flip`)。**抓帧一律不镜像**,三张照片都是摄像头原始视角。
|
||||
- **白屏补光**:全屏纯白覆盖层瞬间点亮,~300ms 后抓全分辨率帧(原始视角 + 最长边 1280 JPEG),保持 ~300ms 再淡出,同步 `navigator.vibrate(30)`。
|
||||
- **快门只在无法实时识别时出现**:系统相机降级(getUserMedia 不可用/被拒,如手机走局域网 HTTP)、face-api/模型加载失败、`?mock=1` 调试。降级与相册选图拿到的照片会先用 face-api 校验(有人脸、正脸/侧脸转头幅度对;系统相机是否镜像因机型而异,侧脸不校验左右),不合格提示重拍、停在本步;模型不可用时放行,由服务端兜底。
|
||||
- **快门只在无法实时识别时出现**:系统相机降级、face-api/模型加载失败、`?mock=1` 调试。文件照片共用同一套本地单人/距离/完整入镜/角度/光线校验,不合格停在当前步;系统照片可能已镜像,侧脸不判断左右,并标记 `direction_checked:false`。本地模型加载中最多等 8 秒;确实不可用时保留原有降级通路,但后台明确记录 `unchecked/model-unavailable`,不冒充检查通过。服务端质量结果仅记录,不新增重拍阻断。
|
||||
|
||||
- **报告等待与质量留痕**:原 PSD 画面增加约 1 秒上下往返扫描;成功跳转等待至少一次实际动画循环,错误/取消立即恢复。拍摄质量备注不在用户报告展示,后台列表、详情和 CSV 保留侧脸质量状态与前端参数版本。前端诊断为客户端自报,仅供校准,不作为服务端质量真值。
|
||||
- **相册选图**:相机页「相册」按钮可选当前步的图片,与降级拍照同管线(压 1280 → 校验 → 推进)。
|
||||
- **语音播报**:步骤引导/姿态提示/拍摄完成/分析中/失败,文案与文件名契约见 `../docs/语音播报内容设计文档_V1.0.md`。新增的提示(居中、抬头/低头、光线)暂无音频,清单缺失的条目静默跳过,只显示文字。
|
||||
- **无进展提示**:单步 12s 仍未拍到,底部浮出「识别不到?请正对镜头,并到光线充足处」(不阻塞流程)。
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
+22
-3
@@ -72,11 +72,23 @@
|
||||
.prod-scroll { flex: 0 0 51.219512%; }
|
||||
.prod-scroll img { width: 100%; height: 100%; aspect-ratio: auto; object-fit: fill; }
|
||||
|
||||
/* Exact static PSD waiting image. No fake progress or added scanning animation.
|
||||
Contain the original composition instead of stretching/cropping faces/icons. */
|
||||
/* Fourth feedback adds a fast one-second round trip to original PSD pixels.
|
||||
The stage/mask share one contain transform, including short/landscape screens. */
|
||||
#p-analyzing { background: #000; overflow: hidden; }
|
||||
.scan-stage { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: min(100vw, 480px, calc(100dvh * 1080 / 2091)); height: min(100dvh, calc(min(100vw, 480px) * 2091 / 1080)); }
|
||||
@supports not (height: 100dvh) { .scan-stage { width: min(100vw, 480px, calc(100vh * 1080 / 2091)); height: min(100vh, calc(min(100vw, 480px) * 2091 / 1080)); } }
|
||||
.scan-artwork { width: 100%; height: 100%; object-fit: contain; }
|
||||
#p-analyzing:not(.scan-artwork-ready) .scan-artwork { visibility: hidden; }
|
||||
.scan-face { position: absolute; inset: 0; -webkit-mask-size: 100% 100%; mask-size: 100% 100%; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; overflow: hidden; }
|
||||
.scan-beam { position: absolute; left: 0; right: 0; top: 20%; height: 18%; background: linear-gradient(to bottom, #F5F078 0%, #F5F078 35%, rgba(245,240,120,0) 100%); }
|
||||
#p-analyzing:not(.scan-artwork-ready) .scan-stage { visibility: hidden; }
|
||||
.scan-running .scan-beam { animation: report-scan var(--scan-cycle, 1000ms) ease-in-out infinite; }
|
||||
@keyframes report-scan { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(166%); } }
|
||||
.scan-running:not(.scan-artwork-ready) .scan-status { animation: report-scan-fallback var(--scan-cycle, 1000ms) ease-in-out infinite; }
|
||||
@keyframes report-scan-fallback { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scan-running .scan-beam { animation-name: report-scan-reduced; }
|
||||
@keyframes report-scan-reduced { 0%, 100% { opacity: .45; } 50% { opacity: 1; } }
|
||||
}
|
||||
/* A failed/stalled image must not leave an unexplained black screen. The live
|
||||
status is visually hidden only once the exact PSD artwork has loaded. */
|
||||
.scan-status { position: absolute; top: 10.5%; left: 0; right: 0; color: #fff; text-align: center; font: 700 23.148148px/1.4 "MiSans Demibold",var(--font); }
|
||||
@@ -87,3 +99,10 @@
|
||||
color: #bbb; font: 12px/1.5 "PingFang SC","Microsoft YaHei",sans-serif;
|
||||
}
|
||||
.scan-cancel:focus-visible { outline: 1px solid var(--yellow); border-radius: 4px; }
|
||||
|
||||
/* Restore only the requested focus area, not the older age-comparison pill. */
|
||||
#dims-note { display: block !important; margin-top: 12px; padding: 10px 12px 10px 24px; text-align: left; }
|
||||
#dims-note[hidden] { display: none !important; }
|
||||
#dims-note::before { left: 12px; top: 10px; bottom: 10px; }
|
||||
#dims-note .dims-note-l1 { font-size: 12px; line-height: 1.5; }
|
||||
#dims-note .dims-note-l2 { font-size: 11px; line-height: 1.5; margin-top: 3px; }
|
||||
|
||||
+9
-5
@@ -4,12 +4,12 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="description" content="1'me 在线肌肤测试:拍摄正面与左右侧面照片,即时获取六维肌肤状态分析与个性化护理建议。">
|
||||
<meta name="description" content="1'me肌肤状态检测:拍摄正面与左右侧面照片,即时获取六维肌肤状态分析与个性化护理建议。">
|
||||
<meta name="theme-color" content="#101823">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta property="og:title" content="1'me 在线肌肤测试">
|
||||
<meta property="og:title" content="1'me肌肤状态检测">
|
||||
<meta property="og:description" content="拍摄正面与左右侧面照片,即时获取六维肌肤状态分析与个性化护理建议。">
|
||||
<title>1'me 在线肌肤测试</title>
|
||||
<title>1'me肌肤状态检测</title>
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
|
||||
<link rel="preload" as="image" href="assets/img/ui-20261009/bg-login-h5.webp" fetchpriority="high">
|
||||
<link rel="stylesheet" href="vendor/mobile-select/mobile-select.css">
|
||||
@@ -24,7 +24,7 @@
|
||||
<img class="login-brand" src="assets/img/ui-20261009/brand-login.png" alt="1’me 以我">
|
||||
<div class="login-bottom">
|
||||
<button class="login-button login-wechat" data-action="login-wechat"><img src="assets/img/ui-20261009/icon-wechat.png" alt="">微信登录</button>
|
||||
<button class="login-button login-browser" data-action="login-browser"><img src="assets/img/ui-20261009/icon-guest.png" alt="">本机浏览器登录</button>
|
||||
<button class="login-button login-browser" data-action="login-browser"><img src="assets/img/ui-20261009/icon-guest.png" alt="">游客登录</button>
|
||||
<label class="login-consent"><input type="checkbox" id="login-consent"><span>同意<button type="button" data-action="open-policy">用户协议、隐私政策和儿童保护政策</button></span></label>
|
||||
<p class="login-message" id="login-message" role="status">正在检查登录状态…</p>
|
||||
</div>
|
||||
@@ -455,7 +455,10 @@
|
||||
<div class="overlay" id="p-analyzing" role="dialog" aria-modal="true" aria-label="正在生成肌肤报告">
|
||||
<!-- Original visible PSD layers, including decorative album/flip icons.
|
||||
Not camera controls: no action attributes, focus targets or file inputs. -->
|
||||
<img id="scan-artwork" class="scan-artwork" data-src="assets/img/ui-20261012/scan-waiting.webp" width="1080" height="2091" alt="" aria-hidden="true" draggable="false">
|
||||
<div class="scan-stage" aria-hidden="true">
|
||||
<img id="scan-artwork" class="scan-artwork" data-src="assets/img/ui-20261012/scan-base.webp" width="1080" height="2091" alt="" draggable="false">
|
||||
<div id="scan-face" class="scan-face" data-mask="assets/img/ui-20261012/scan-face-mask.png"><i class="scan-beam"></i></div>
|
||||
</div>
|
||||
<p class="scan-status" role="status">检测中…</p>
|
||||
<button class="scan-cancel" type="button" data-action="cancel-analysis">取消</button>
|
||||
</div>
|
||||
@@ -473,6 +476,7 @@
|
||||
</div>
|
||||
|
||||
<script src="js/head-pose.js" defer></script>
|
||||
<script src="js/capture-quality.js" defer></script>
|
||||
<script src="js/skin-score.js" defer></script>
|
||||
<script src="vendor/mobile-select/mobile-select.iife.min.js" defer></script>
|
||||
<script src="js/region-data.js" defer></script>
|
||||
|
||||
+41
-1
@@ -85,6 +85,12 @@ function ageText(age) { return age == null ? '-' : esc(age) + '岁'; }
|
||||
function skinAgeHtml(r) {
|
||||
return '<b>原始 ' + ageText(r.skin_age) + '</b><div class="sub">展示 ' + ageText(r.skin_age_display) + '</div>';
|
||||
}
|
||||
function captureQualityHtml(r) {
|
||||
var q = r.capture_quality;
|
||||
if (!q) return '';
|
||||
var label = q.status === 'invalid' ? '侧脸质量异常(未拦截)' : q.status === 'unavailable' ? '侧脸质量未获取' : '侧脸质量有效';
|
||||
return '<div class="sub">' + esc(label) + '</div>';
|
||||
}
|
||||
function productName(name) {
|
||||
for (var i = 0; i < ReportUI.products.length; i++) {
|
||||
var p = ReportUI.products[i];
|
||||
@@ -135,7 +141,7 @@ function loadRecords() {
|
||||
'<td>' + thumb + '</td>' +
|
||||
'<td class="mono">' + esc(it.record_id) + (it.retry_of ? '<div class="sub">重试自 ' + esc(it.retry_of) + '</div>' : '') + '</td>' +
|
||||
'<td>' + profileHtml(it) + '</td>' +
|
||||
'<td>' + esc(it.shoot_status) + '</td>' +
|
||||
'<td>' + esc(it.shoot_status) + captureQualityHtml(it) + '</td>' +
|
||||
'<td>' + esc(it.upload_status) + '</td>' +
|
||||
'<td><span class="st ' + (ST_CLASS[it.detect_status] || '') + '">' + esc(it.detect_status) + '</span>' +
|
||||
(it.fail_code ? '<div class="sub">' + esc(it.fail_code) + '</div>' : '') + '</td>' +
|
||||
@@ -355,6 +361,17 @@ function renderDetail(data) {
|
||||
'<span>被重试次数 <b>' + (r.retry_count || 0) + '</b></span>' +
|
||||
'</div>');
|
||||
|
||||
/* 档案 */
|
||||
if (r.capture_quality || r.capture_diagnostics) {
|
||||
var quality = captureQualityHtml(r);
|
||||
if (r.capture_quality) ['left', 'right'].forEach(function (key) {
|
||||
var view = r.capture_quality.views[key];
|
||||
quality += '<p>' + (key === 'left' ? '左脸' : '右脸') + ':quality=' + esc(view.quality == null ? '未获取' : view.quality) + ',角度=' + esc(view.angle == null ? '未获取' : view.angle) + ',状态=' + esc(view.status) + '</p>';
|
||||
});
|
||||
if (r.capture_diagnostics) quality += '<p>前端参数版本:' + esc(r.capture_diagnostics.version) + '(客户端自报,仅作校准参考)</p><pre class="d-copy">' + esc(JSON.stringify(r.capture_diagnostics, null, 2)) + '</pre>';
|
||||
h += dSec('拍摄质量与前端校准', quality);
|
||||
}
|
||||
|
||||
/* 档案 */
|
||||
h += dSec('档案信息', '<div class="d-kv">' +
|
||||
'<span>实际年龄 <b>' + ageText(r.real_age) + '</b></span>' +
|
||||
@@ -432,6 +449,29 @@ function renderDetail(data) {
|
||||
if (r.lifestyle) copyHtml += '<div class="d-copy"><b>生活方式:</b>' + esc(r.lifestyle) + '</div>';
|
||||
if (copyHtml) h += dSec(presentation ? '前端报告结论(保存快照)与计算摘要' : '旧版生成文案(无前端快照)', copyHtml);
|
||||
|
||||
// Technical region metrics stay in the protected admin, not customer prose.
|
||||
var wrinkleView = presentation && presentation.views && presentation.views.filter(function(view) { return view.key === 'wrinkles'; })[0];
|
||||
var wrinkleRows = wrinkleView && wrinkleView.details;
|
||||
if (!wrinkleRows && data.raw_json && data.raw_json.result) wrinkleRows = ReportUI.wrinkleDetails(data.raw_json.result);
|
||||
if (wrinkleRows) {
|
||||
var metricDefs = [['wrinkle_score', '皱纹分'], ['wrinkle_severity_level', '严重等级'],
|
||||
['wrinkle_deep_num', '深纹条数'], ['wrinkle_shallow_num', '浅纹条数'],
|
||||
['wrinkle_norm_length', '标准化长度'], ['wrinkle_norm_depth', '标准化深度'],
|
||||
['wrinkle_pixel_density', '像素密度'], ['wrinkle_area_ratio', '面积比例'], ['wrinkle_deep_ratio', '深纹比例']];
|
||||
var wrinkleHead = '<th>分区</th>', wrinkleBody = '';
|
||||
metricDefs.forEach(function(metric) { wrinkleHead += '<th>' + metric[1] + '</th>'; });
|
||||
wrinkleRows.forEach(function(row) {
|
||||
wrinkleBody += '<tr><td>' + esc(row.name) + '</td>';
|
||||
metricDefs.forEach(function(metric) {
|
||||
var value = row.metrics[metric[0]];
|
||||
wrinkleBody += '<td>' + (value == null ? '未获取' : esc(String(value))) + '</td>';
|
||||
});
|
||||
wrinkleBody += '</tr>';
|
||||
});
|
||||
h += dSec('皱纹分区原始指标(仅后台复核;左右以本人为准)', '<div style="overflow-x:auto"><table class="d-table"><thead><tr>' +
|
||||
wrinkleHead + '</tr></thead><tbody>' + wrinkleBody + '</tbody></table></div>');
|
||||
}
|
||||
|
||||
var care = presentation ? presentation.care : r.careAdvice;
|
||||
if (care && care.length) {
|
||||
var advice = '';
|
||||
|
||||
+158
-89
@@ -76,7 +76,7 @@
|
||||
function renderAccountStatus() {
|
||||
var user = AccountClient.user();
|
||||
document.querySelectorAll('[data-auth-status]').forEach(function (label) {
|
||||
label.textContent = user ? (user.kind === 'wechat' ? '微信已登录' : '本机浏览器已登录') : '尚未登录';
|
||||
label.textContent = user ? (user.kind === 'wechat' ? '微信已登录' : '游客已登录') : '尚未登录';
|
||||
label.parentElement.dataset.kind = user ? user.kind : '';
|
||||
});
|
||||
}
|
||||
@@ -84,7 +84,7 @@
|
||||
showScreen('s-splash', 'replace');
|
||||
$('#login-success-message').textContent = AccountClient.user().kind === 'wechat'
|
||||
? '当前使用:微信登录\n同一微信账号可跨手机查看报告。'
|
||||
: '当前使用:本机浏览器登录\n报告仅能通过当前浏览器身份查看。';
|
||||
: '当前使用:游客登录\n游客报告仅可在当前浏览器查看,不能跨手机同步。';
|
||||
openOverlay('p-login-success');
|
||||
document.querySelectorAll('.screen').forEach(function (screen) { screen.inert = true; });
|
||||
$('[data-action="login-confirm"]').focus();
|
||||
@@ -141,21 +141,69 @@
|
||||
}
|
||||
|
||||
/* ─── 弹窗管理 ─── */
|
||||
var SCAN_CYCLE_MS = 1000, scanCycle = null, scanMask = null;
|
||||
function restartScanCycle() {
|
||||
if (!scanCycle || scanCycle.done) return;
|
||||
var overlay = $('#p-analyzing'), cycle = scanCycle;
|
||||
overlay.classList.remove('scan-running');
|
||||
clearTimeout(cycle.timer);
|
||||
// Restart from the first painted frame, including a late-loaded PSD image.
|
||||
requestAnimationFrame(function () { requestAnimationFrame(function () {
|
||||
if (scanCycle !== cycle || cycle.done) return;
|
||||
overlay.style.setProperty('--scan-cycle', SCAN_CYCLE_MS + 'ms');
|
||||
overlay.classList.add('scan-running');
|
||||
// Legacy browsers without CSS animations still get a bounded visible wait.
|
||||
if (getComputedStyle($('.scan-beam')).animationName === 'none') cycle.timer = setTimeout(cycle.resolve, SCAN_CYCLE_MS);
|
||||
}); });
|
||||
}
|
||||
function startScanCycle() {
|
||||
stopScanCycle();
|
||||
var cycle = { done: false, timer: null };
|
||||
cycle.promise = new Promise(function (resolve) {
|
||||
cycle.resolve = function () { if (!cycle.done) { cycle.done = true; clearTimeout(cycle.timer); resolve(); } };
|
||||
});
|
||||
scanCycle = cycle;
|
||||
restartScanCycle();
|
||||
return cycle.promise;
|
||||
}
|
||||
function stopScanCycle() {
|
||||
if (scanCycle) scanCycle.resolve();
|
||||
scanCycle = null;
|
||||
$('#p-analyzing').classList.remove('scan-running');
|
||||
}
|
||||
$('#p-analyzing').addEventListener('animationiteration', function (event) {
|
||||
if (!scanCycle || scanCycle.done) return;
|
||||
var ready = this.classList.contains('scan-artwork-ready');
|
||||
if ((ready && event.target.classList.contains('scan-beam')) || (!ready && event.target.classList.contains('scan-status'))) scanCycle.resolve();
|
||||
});
|
||||
function prepareScanArtwork() {
|
||||
var image = $('#scan-artwork');
|
||||
function ready() {
|
||||
var wasReady = $('#p-analyzing').classList.contains('scan-artwork-ready');
|
||||
var loaded = image.complete && image.naturalWidth && scanMask && scanMask.complete && scanMask.naturalWidth;
|
||||
$('#p-analyzing').classList.toggle('scan-artwork-ready', !!loaded);
|
||||
if (loaded && !wasReady) restartScanCycle();
|
||||
}
|
||||
if (!scanMask || (scanMask.complete && !scanMask.naturalWidth)) {
|
||||
scanMask = new Image(); scanMask.onload = ready; scanMask.onerror = ready;
|
||||
scanMask.src = $('#scan-face').dataset.mask;
|
||||
$('#scan-face').style.webkitMaskImage = 'url("' + scanMask.src + '")';
|
||||
$('#scan-face').style.maskImage = 'url("' + scanMask.src + '")';
|
||||
}
|
||||
if (!image.getAttribute('src') || (image.complete && !image.naturalWidth)) {
|
||||
image.onload = function () { $('#p-analyzing').classList.add('scan-artwork-ready'); };
|
||||
image.onerror = function () { $('#p-analyzing').classList.remove('scan-artwork-ready'); };
|
||||
image.onload = ready;
|
||||
image.onerror = ready;
|
||||
image.src = image.dataset.src;
|
||||
}
|
||||
ready();
|
||||
}
|
||||
function openOverlay(id) {
|
||||
if (id === 'p-privacy' || id === 'p-photoguide') loadAppFont();
|
||||
if (id === 'p-analyzing') prepareScanArtwork();
|
||||
$('#' + id).classList.add('show');
|
||||
if (id === 'p-analyzing') $('.scan-cancel').focus({ preventScroll: true });
|
||||
if (id === 'p-analyzing') { startScanCycle(); $('.scan-cancel').focus({ preventScroll: true }); }
|
||||
}
|
||||
function closeOverlay(id) { $('#' + id).classList.remove('show'); }
|
||||
function closeOverlay(id) { $('#' + id).classList.remove('show'); if (id === 'p-analyzing') stopScanCycle(); }
|
||||
|
||||
// 点遮罩空白处关闭(与各自关闭按钮同行为:隐私→回启动页、拍照引导→留在档案页)
|
||||
// 只响应遮罩本身(拍照引导另含底图层),不误伤弹窗卡片内交互
|
||||
@@ -329,6 +377,7 @@
|
||||
var camVideo = $('#cam-video'), camFile = $('#cam-file'), albumFile = $('#album-file');
|
||||
var camStream = null, camLive = false, camFallback = false, camSeqCam = 0; // camSeqCam:相机启动令牌,防止迟到的授权回调泄漏摄像头
|
||||
var photos = []; // 三连拍(dataURL),拍摄中与 CAM_STEPS 同序;送检前经 photosByView 重排为 [正脸, 左脸, 右脸]
|
||||
var captureDiagnostics = [], holdState = null;
|
||||
|
||||
// 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级
|
||||
var camFacing = 'user'; // 当前镜头:'user' 前置 | 'environment' 后置(翻转按钮切换)
|
||||
@@ -440,6 +489,7 @@
|
||||
reader.onload = function () {
|
||||
var img = new Image();
|
||||
img.onload = function () {
|
||||
if (seq !== camSeq || step !== camStep) return;
|
||||
var scale = Math.min(1, 1280 / Math.max(img.width, img.height));
|
||||
var c = document.createElement('canvas');
|
||||
c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale);
|
||||
@@ -447,9 +497,9 @@
|
||||
var url = c.toDataURL('image/jpeg', 0.88);
|
||||
shutterLock = true; // 校验期间不接受新的拍摄/选图
|
||||
showCamStatus('照片检查中…');
|
||||
checkStill(url, CAM_STEPS[step].target).then(function (err) {
|
||||
hideCamStatus();
|
||||
checkStill(url, CAM_STEPS[step].target, { mode: 'file', ignoreDirection: true, step: step, seq: seq }).then(function (err) {
|
||||
if (seq !== camSeq || step !== camStep) return; // 期间已退出/重置
|
||||
hideCamStatus();
|
||||
shutterLock = false;
|
||||
if (err) { toast(err); voice.play(VOICE.fail, { cut: true }); return; } // 不合格:停在本步重拍
|
||||
onPhotoCaptured(url);
|
||||
@@ -460,27 +510,42 @@
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
}
|
||||
// 静态照片校验:有人脸、朝向与当前步一致(检测模型不可用时放行,由服务端兜底)
|
||||
// 系统相机的前置照片是否镜像因机型而异,侧脸只校验转头幅度、不校验左右
|
||||
var STILL_FRONT_YAW_MAX = 20, STILL_SIDE_YAW_MIN = 20;
|
||||
function checkStill(dataUrl, target) {
|
||||
if (IS_MOCK || !modelsOk || typeof faceapi === 'undefined') return Promise.resolve('');
|
||||
// Live final frames and file uploads share all local quality gates. Files may
|
||||
// be mirrored by the system camera; their side direction remains unverified.
|
||||
function checkStill(dataUrl, target, options) {
|
||||
options = options || { mode: 'file', ignoreDirection: true, step: camStep, seq: camSeq };
|
||||
function remember(sample) { if (options.seq === camSeq) captureDiagnostics[options.step] = sample; }
|
||||
if (IS_MOCK) { remember({ mode: 'mock' }); return Promise.resolve(''); }
|
||||
if (!modelsOk || typeof faceapi === 'undefined') {
|
||||
if (modelsPromise && !options.waited) {
|
||||
options.waited = true;
|
||||
return new Promise(function (resolve) {
|
||||
var timer = setTimeout(resolve, 8000);
|
||||
modelsPromise.then(function () { clearTimeout(timer); resolve(); }, function () { clearTimeout(timer); resolve(); });
|
||||
}).then(function () { return checkStill(dataUrl, target, options); });
|
||||
}
|
||||
// Keep existing unsupported-device access, explicitly marked for admin.
|
||||
// Never silently treat an unavailable local engine as a passed check.
|
||||
remember({ mode: 'unchecked', reason: 'model-unavailable' });
|
||||
return Promise.resolve('');
|
||||
}
|
||||
return new Promise(function (resolve) {
|
||||
var img = new Image();
|
||||
img.onload = function () {
|
||||
faceapi
|
||||
.detectSingleFace(img, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
|
||||
.detectAllFaces(img, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
|
||||
.withFaceLandmarks()
|
||||
.then(function (d) {
|
||||
if (!d) { resolve('照片中未识别到人脸,请重新拍摄'); return; }
|
||||
var e = null;
|
||||
try { e = HeadPose.estimate(d.landmarks.positions, false); } catch (err) { e = null; }
|
||||
if (!e) { resolve(''); return; }
|
||||
var yaw = Math.abs(e.yaw);
|
||||
if (target === 'front' && yaw > STILL_FRONT_YAW_MAX) { resolve('请拍摄正脸照片'); return; }
|
||||
if (target !== 'front' && yaw < STILL_SIDE_YAW_MIN) { resolve('请拍摄' + (target === 'right' ? '右' : '左') + '侧脸照片'); return; }
|
||||
.then(function (faces) {
|
||||
var d = faces[0], box = d && d.detection.box;
|
||||
var verdict = CaptureQuality.evaluate(d, { count: faces.length, target: target,
|
||||
width: img.naturalWidth, height: img.naturalHeight,
|
||||
offsetX: box ? (box.x + box.width / 2) / img.naturalWidth - .5 : 0,
|
||||
luma: box ? faceLuma(box, img) : null,
|
||||
mirrored: options.mode === 'live' && sideYawFlip, ignoreDirection: options.ignoreDirection });
|
||||
if (!verdict.ok) { resolve(verdict.hint + ',请重新拍摄'); return; }
|
||||
remember(Object.assign({ mode: options.mode, direction_checked: !options.ignoreDirection }, verdict.observation));
|
||||
resolve('');
|
||||
}, function () { resolve(''); });
|
||||
}, function () { resolve('照片检查失败,请重新拍摄'); });
|
||||
};
|
||||
img.onerror = function () { resolve('照片读取失败,请重新选择'); };
|
||||
img.src = dataUrl;
|
||||
@@ -628,15 +693,10 @@
|
||||
var MODEL_URL = 'vendor/faceapi/models';
|
||||
var DETECT_INTERVAL = 150; // 检测帧间隔(ms)
|
||||
var DETECT_INPUT_SIZE = 416; // tinyFaceDetector 输入尺寸:320 下正脸置信度仅 0.67 且框偏松,416 置信度 ~0.99 框更准(CPU 端略慢,移动端 GPU 无感)
|
||||
var DETECT_SCORE = 0.3; // 置信度阈值:0.4 在噪声帧易漏检,0.3 配合距离/角度闸门足够抗误检
|
||||
var DETECT_SCORE = CaptureQuality.config.score;
|
||||
var MISS_FRAMES_MAX = 3; // 连续漏检 ≥3 帧(~450ms)才报「未检测到人脸」,容忍单帧抖动
|
||||
var OK_HOLD_MS = 450; // 条件连续合格时长:滤掉一闪而过的合格帧(防运动模糊),达标立即抓拍
|
||||
var NO_PROGRESS_MS = 12000; // 单步超过该时长仍未抓拍→浮出调整提示
|
||||
var PREVIEW_SCALE = 1; // 与 CSS #cam-video 铺满预览一致,镜像只改变方向
|
||||
// 人脸框高/原始视频高;保留现有距离判定阈值。
|
||||
var FACE_NEAR = 0.30, FACE_FAR = 0.68;
|
||||
var CENTER_TOL = 0.18; // 人脸中心偏离取景中线超过屏宽 18% → 提示移入取景框
|
||||
var LUMA_DARK = 65, LUMA_BRIGHT = 235; // 脸部平均亮度(0–255):过暗/过曝提示调整光线
|
||||
|
||||
var modelsPromise = null, modelsOk = false, detectDisabled = false;
|
||||
var faceApiPromise = null;
|
||||
@@ -716,6 +776,7 @@
|
||||
// 每步进入/回退时的检测状态机重置(蒙版/提示/无进展计时全部归位)
|
||||
function resetDetectState() {
|
||||
okSince = 0;
|
||||
holdState = null;
|
||||
readyHapticAt = 0;
|
||||
missFrames = 0;
|
||||
oppFrames = 0;
|
||||
@@ -762,13 +823,13 @@
|
||||
if (shutterLock || capturing || !camLive) return Promise.resolve(); // 抓帧/流程推进中不判姿态
|
||||
var step = camStep, seq = camSeq; // 快照:检测是异步的,帧落地时步骤/流程可能已变
|
||||
return faceapi
|
||||
.detectSingleFace(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
|
||||
.detectAllFaces(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE }))
|
||||
.withFaceLandmarks()
|
||||
.then(function (d) {
|
||||
if (step !== camStep || seq !== camSeq) return; // 跨步骤陈帧/流程已重置:丢弃
|
||||
if (detRunning && !shutterLock && !capturing) onDetect(d);
|
||||
})
|
||||
.catch(function () { /* 单帧异常忽略,下一帧继续 */ });
|
||||
.catch(function () { if (step === camStep && seq === camSeq) { holdState = null; okSince = 0; } }); // Unchecked frames break continuous readiness.
|
||||
}
|
||||
// 人脸框中心相对取景中线的水平偏移(占屏宽比例);按 object-fit:cover + 预览缩放换算到屏幕,镜像只影响符号
|
||||
function faceOffsetX(box) {
|
||||
@@ -779,11 +840,11 @@
|
||||
}
|
||||
// 脸部平均亮度:人脸框缩到 16×16 取均值
|
||||
var lumaCanvas = null;
|
||||
function faceLuma(box) {
|
||||
function faceLuma(box, source) {
|
||||
try {
|
||||
if (!lumaCanvas) { lumaCanvas = document.createElement('canvas'); lumaCanvas.width = lumaCanvas.height = 16; }
|
||||
var c = lumaCanvas.getContext('2d', { willReadFrequently: true });
|
||||
c.drawImage(camVideo, box.x, box.y, box.width, box.height, 0, 0, 16, 16);
|
||||
c.drawImage(source || camVideo, box.x, box.y, box.width, box.height, 0, 0, 16, 16);
|
||||
var px = c.getImageData(0, 0, 16, 16).data, sum = 0;
|
||||
for (var i = 0; i < px.length; i += 4) sum += 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
|
||||
return sum / (px.length / 4);
|
||||
@@ -798,57 +859,51 @@
|
||||
}
|
||||
/* camera-haptic-pure-end */
|
||||
function onDetect(d) {
|
||||
var count = Array.isArray(d) ? d.length : d ? 1 : 0;
|
||||
if (Array.isArray(d)) d = d[0];
|
||||
var hint = '', ok = false;
|
||||
missFrames = d ? 0 : missFrames + 1;
|
||||
var hasFace = !!d, noFace = !hasFace && missFrames >= MISS_FRAMES_MAX;
|
||||
if (noFace) {
|
||||
hint = '未检测到人脸';
|
||||
} else if (!hasFace) {
|
||||
return; // 偶发漏检帧:保持上一条提示与状态,不扰动 ok 累积
|
||||
holdState = null; okSince = 0;
|
||||
return; // Keep the hint quiet, but never count a missing frame as stable.
|
||||
} else {
|
||||
// 提示优先级:无人脸 > 距离 > 居中 > 角度 > 光线
|
||||
var box = d.detection.box;
|
||||
var ratio = box.height / (camVideo.videoHeight || 1);
|
||||
if (ratio < FACE_NEAR) hint = '请靠近一点';
|
||||
else if (ratio > FACE_FAR) hint = '请远一点';
|
||||
else if (Math.abs(faceOffsetX(box)) > CENTER_TOL) hint = '请将脸部移入取景框';
|
||||
else {
|
||||
// 特征点取自原始帧(预览的 CSS 镜像不影响),三步的语义方向都按原始视角判定:
|
||||
// 标准机型 sideYawFlip 恒为 false,个别前置帧本身已镜像的机型由侧脸自校准翻转
|
||||
var tgt = CAM_STEPS[camStep].target;
|
||||
var pose = HeadPose.check(d.landmarks.positions, tgt, sideYawFlip);
|
||||
// 侧脸自校准:个别机型前置帧本身已镜像(语义 yaw 与实际转向持续相反)时自救——
|
||||
// 需连续 30 帧(~10s)强反向才翻转一次:阈值要高,否则用户转错方向 1~2s 就会被误判为镜像机,
|
||||
// 反而把错误的侧脸当合格抓走(左右图归属错乱)。翻转只动判定方向,不动预览画面
|
||||
if (tgt !== 'front' && pose.yaw != null && !mirrorCalibrated) {
|
||||
var wantSign = tgt === 'right' ? 1 : -1;
|
||||
if (pose.yaw * wantSign < -12) {
|
||||
if (++oppFrames >= 30) {
|
||||
sideYawFlip = !sideYawFlip;
|
||||
mirrorCalibrated = true;
|
||||
oppFrames = 0;
|
||||
}
|
||||
} else oppFrames = 0;
|
||||
}
|
||||
if (!pose.ok) hint = pose.hint;
|
||||
else {
|
||||
var luma = faceLuma(box);
|
||||
if (luma != null && luma < LUMA_DARK) hint = '请到光线充足处';
|
||||
else if (luma != null && luma > LUMA_BRIGHT) hint = '光线过强,请避开强光';
|
||||
else ok = true;
|
||||
}
|
||||
// 原始帧判断语义方向;只有已确认镜像的机型才翻转。
|
||||
var tgt = CAM_STEPS[camStep].target;
|
||||
var pose = HeadPose.check(d.landmarks.positions, tgt, sideYawFlip);
|
||||
// 侧脸自校准需连续 30 帧强反向才翻转一次,避免短暂转错方向误判为镜像。
|
||||
if (tgt !== 'front' && pose.yaw != null && !mirrorCalibrated) {
|
||||
var wantSign = tgt === 'right' ? 1 : -1;
|
||||
if (pose.yaw * wantSign < -12) {
|
||||
if (++oppFrames >= 30) {
|
||||
sideYawFlip = !sideYawFlip;
|
||||
mirrorCalibrated = true;
|
||||
oppFrames = 0;
|
||||
}
|
||||
} else oppFrames = 0;
|
||||
}
|
||||
var verdict = CaptureQuality.evaluate(d, { count: count, target: tgt, mirrored: sideYawFlip,
|
||||
width: camVideo.videoWidth, height: camVideo.videoHeight,
|
||||
offsetX: faceOffsetX(box), luma: faceLuma(box) });
|
||||
hint = verdict.hint; ok = verdict.ok;
|
||||
window.__dbgRatio = ratio; // 自动化校准/测试用(检测框高/视频高)
|
||||
}
|
||||
setMask(ok ? 'ok' : (noFace ? 'no-face' : ''));
|
||||
// 合格时「保持不动」只显示不播报:随即抓拍,「拍摄完成」会截断它
|
||||
setHint(ok ? '保持不动' : hint, ok, ok);
|
||||
if (!ok) { okSince = 0; return; }
|
||||
if (!okSince) {
|
||||
okSince = Date.now();
|
||||
if (!ok) { okSince = 0; holdState = null; return; }
|
||||
var held = CaptureQuality.hold(holdState, verdict.observation, Date.now());
|
||||
holdState = held.state;
|
||||
if (holdState.frames === 1) {
|
||||
okSince = holdState.since;
|
||||
if (!readyHapticAt || okSince - readyHapticAt >= 2000) { readyHapticAt = okSince; captureHaptic(navigator); }
|
||||
}
|
||||
else if (Date.now() - okSince >= OK_HOLD_MS) { okSince = 0; doCapture(); }
|
||||
if (held.ready) { okSince = 0; holdState = null; doCapture(); }
|
||||
}
|
||||
|
||||
/* ─── 拍照流程(三步,顺序对齐 Figma:正脸 → 右脸出镜 → 左脸出镜) ─── */
|
||||
@@ -934,7 +989,15 @@
|
||||
capturing = false;
|
||||
if (seq !== camSeq) return; // 期间已退出/重置:不再推进(遮罩仍正常淡出)
|
||||
if (!shot) { shutterLock = false; okSince = 0; toast('相机尚未就绪,请稍候'); return; } // 重置 okSince:避免陈旧累积立即再抓
|
||||
onPhotoCaptured(shot);
|
||||
var step = camStep;
|
||||
checkStill(shot, CAM_STEPS[step].target, { mode: 'live', ignoreDirection: false, step: step, seq: seq }).then(function (error) {
|
||||
if (seq !== camSeq || step !== camStep) return;
|
||||
if (error) {
|
||||
shutterLock = false; holdState = null; okSince = 0;
|
||||
setHint(error, false); armNudge(); return;
|
||||
}
|
||||
onPhotoCaptured(shot);
|
||||
});
|
||||
}, 300);
|
||||
}, 300);
|
||||
}
|
||||
@@ -952,11 +1015,12 @@
|
||||
region: regionSel.value || '',
|
||||
skinType: SKINS[skinIndex].name
|
||||
};
|
||||
var t0 = Date.now();
|
||||
var animationComplete = scanCycle.promise;
|
||||
requestAnalyze(photos, seq, profile, function (errMsg, payload) {
|
||||
if (seq !== camSeq) return; // 流程已被重置:丢弃
|
||||
var wait = Math.max(0, 1200 - (Date.now() - t0)); // 分析中至少展示 1.2s,避免闪烁
|
||||
setTimeout(function () {
|
||||
// A fast successful response waits for one actual visible animation cycle;
|
||||
// cancellation/errors never get held hostage by the presentation animation.
|
||||
(errMsg ? Promise.resolve() : animationComplete).then(function () {
|
||||
if (seq !== camSeq) return;
|
||||
closeOverlay('p-analyzing');
|
||||
stopCamera();
|
||||
@@ -987,7 +1051,7 @@
|
||||
else if (!payload.report_saved) toast('报告生成成功,但保存失败,请勿关闭页面并联系管理员');
|
||||
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
|
||||
showScreen('s-report', 'replace'); // 报告顶替拍照页:返回键回档案页,不再回到相机
|
||||
}, wait);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1031,6 +1095,9 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
front_image: ph[0], left_image: ph[1], right_image: ph[2],
|
||||
capture_diagnostics: { version: CaptureQuality.config.version, views: {
|
||||
front: captureDiagnostics[0] || null, right: captureDiagnostics[1] || null, left: captureDiagnostics[2] || null
|
||||
} },
|
||||
profile: { // 档案随检测上报,后台记录用(服务端缺省 null)
|
||||
real_age: profile && profile.age,
|
||||
gender: profile && profile.gender,
|
||||
@@ -1333,25 +1400,26 @@
|
||||
setDim(k, vm.dims[k], k === worstDimKey ? 'worst' : k === bestDimKey ? 'best' : '');
|
||||
});
|
||||
var presentation = payload && payload.presentation || ReportUI.build(vm, payload && payload.result);
|
||||
$('#dims-note').textContent = '';
|
||||
var focus = ReportUI.focusOf(vm), focusEl = $('#dims-note');
|
||||
focusEl.hidden = !focus;
|
||||
focusEl.replaceChildren();
|
||||
if (focus) {
|
||||
var first = document.createElement('p'), name = document.createElement('b'), second = document.createElement('p');
|
||||
first.className = 'dims-note-l1'; first.textContent = '近期优先关注:'; name.textContent = focus.name; first.appendChild(name);
|
||||
second.className = 'dims-note-l2'; second.textContent = focus.tip;
|
||||
focusEl.append(first, second);
|
||||
}
|
||||
var textIds = { contour: '#ditem-para1', wrinkles: '#ditem-para2', color: '#tx-tone', smooth: '#tx-smooth', water: '#tx-water', sens: '#tx-sens', acne: '#tx-acne' };
|
||||
presentation.views.forEach(function (v) { $(textIds[v.key]).textContent = v.text; });
|
||||
presentation.views.forEach(function (v) { $(textIds[v.key]).textContent = ReportUI.customerText(v); });
|
||||
|
||||
// 检测可视化:轮廓=正脸实拍,其余按 VIZ_CAT 取 face_maps 图层或叠加 marks(payload 缺失时各卡保留占位)
|
||||
renderViz(payload, frontPhoto);
|
||||
|
||||
// 降级标注:只展示用户可感知/可执行的(下颌线、侧脸、补拍类);
|
||||
// 引擎内部技术项(上限锁未执行/字段未返回等)不面向用户展示
|
||||
// Fourth feedback: capture quality belongs in the protected admin record,
|
||||
// not in the customer report. Preserve vm.notes/snapshots unchanged.
|
||||
var notesEl = $('#vm-notes');
|
||||
var userNotes = (vm.notes || [])
|
||||
.filter(function (t) { return /下颌线|侧脸|补拍|重拍/.test(t); })
|
||||
.map(function (t) { return t.replace(/\(quality=[^)]*\)/g, ''); }); // 接口质量码仅留在引擎留痕里
|
||||
if (userNotes.length) {
|
||||
notesEl.innerHTML = userNotes.map(function (t) { return '*' + esc(t); }).join('<br>');
|
||||
notesEl.style.display = '';
|
||||
} else {
|
||||
notesEl.style.display = 'none';
|
||||
}
|
||||
notesEl.textContent = '';
|
||||
notesEl.style.display = 'none';
|
||||
|
||||
$('#care-list').innerHTML = presentation.care.map(function (s, i) {
|
||||
return '<p class="care-item">' + (i + 1) + '、' + esc(s.text) + '</p>';
|
||||
@@ -1493,6 +1561,7 @@
|
||||
camSeq++; // 使旧的快门/分析回调失效
|
||||
camStep = 0;
|
||||
photos = []; // 新一轮三连拍(从 startCamera 挪出:后台恢复续拍时保留已拍)
|
||||
captureDiagnostics = [];
|
||||
shutterLock = false; // 重新进入流程,保险解锁
|
||||
showScreen('s-cam', 'push');
|
||||
startCamera(); // beginDetect 在其成功回调里:确认相机可用才加载模型
|
||||
@@ -1515,7 +1584,7 @@
|
||||
'switch-login': function () {
|
||||
historySeq++;
|
||||
$('#login-consent').checked = false;
|
||||
$('#login-message').textContent = '微信与本机浏览器为独立账号,报告不会自动合并。请选择登录方式;返回可保留当前登录。';
|
||||
$('#login-message').textContent = '微信与游客为独立账号,报告不会自动合并。请选择登录方式;返回可保留当前登录。';
|
||||
showScreen('s-login', 'push');
|
||||
}
|
||||
};
|
||||
@@ -1537,7 +1606,7 @@
|
||||
$('#history-count').textContent = '您最近已进行过' + data.total + '次面部问题检测';
|
||||
$('#identity-note').textContent = AccountClient.user().kind === 'wechat'
|
||||
? '保留最近 5 份报告,同一微信账号可跨手机查看。'
|
||||
: '保留最近 5 份报告。本机身份仅限当前浏览器;更换手机或清除浏览器数据后无法找回。';
|
||||
: '保留最近 5 份报告。游客报告仅可在当前浏览器查看,不能跨手机同步;清除浏览器数据后无法找回。';
|
||||
if (!data.items.length) { box.innerHTML = '<p class="history-empty">暂无报告<br>完成一次检测后,报告会自动保存在这里。</p>'; return; }
|
||||
box.innerHTML = data.items.map(function (item) {
|
||||
var date = new Date(item.created_at).toLocaleDateString('sv-SE');
|
||||
@@ -1584,7 +1653,7 @@
|
||||
function loginWechat() {
|
||||
if (!loginAllowed()) return;
|
||||
if (!/MicroMessenger/i.test(navigator.userAgent)) {
|
||||
$('#login-message').textContent = '请在微信内打开本页面,或使用本机浏览器登录。';
|
||||
$('#login-message').textContent = '请在微信内打开本页面,或使用游客登录。';
|
||||
return;
|
||||
}
|
||||
loginBusy = true;
|
||||
@@ -1645,14 +1714,14 @@
|
||||
$('#login-message').textContent = code === 'state'
|
||||
? '登录链接已过期或会话丢失,请在同一微信窗口重新点击微信登录。'
|
||||
: code === 'network' ? '暂时无法连接微信服务,请稍后重新登录。'
|
||||
: '微信授权未完成,请重试;如仍失败,请将错误页面截图发给管理员。也可选择本机浏览器登录。';
|
||||
: '微信授权未完成,请重试;如仍失败,请将错误页面截图发给管理员。也可选择游客登录。';
|
||||
} else if (loginSuccess && (!AccountClient.user() || AccountClient.user().kind !== 'wechat')) {
|
||||
$('#login-message').textContent = '微信返回后登录会话未能保存,请在同一微信窗口重新登录。';
|
||||
} else if (AccountClient.user()) {
|
||||
if (loginSuccess) loginSucceeded();
|
||||
else showScreen('s-splash', 'replace');
|
||||
}
|
||||
else $('#login-message').textContent = '微信登录可跨手机查看报告;本机登录仅限当前浏览器。';
|
||||
else $('#login-message').textContent = '微信登录可跨手机查看报告;游客登录仅限当前浏览器。';
|
||||
}).catch(function (e) { $('#login-message').textContent = e.message; })
|
||||
.finally(function () {
|
||||
loginBusy = false;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Local-only capture gates; no request, scoring, or report recommendation logic. */
|
||||
(function(root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory(require('./head-pose.js'));
|
||||
else root.CaptureQuality = factory(root.HeadPose);
|
||||
})(typeof window !== 'undefined' ? window : this, function(HeadPose) {
|
||||
'use strict';
|
||||
// Real-phone feedback: ease alignment and normal hand/head movement while
|
||||
// retaining two checked frames, one face, framing and final JPEG validation.
|
||||
var config = Object.freeze({ version: '2026-10-12-fourth-v2', score: 0.4,
|
||||
near: 0.30, far: 0.68, center: 0.16, edge: 0.01,
|
||||
dark: 65, bright: 235, holdMs: 450, minFrames: 2, maxGapMs: 2000,
|
||||
yawDrift: 8, pitchDrift: 8, centerDrift: 0.055, sizeDrift: 0.065 });
|
||||
function finite(v) { return typeof v === 'number' && isFinite(v); }
|
||||
function evaluate(d, options) {
|
||||
var failed = function(hint) { return { ok: false, hint: hint }; };
|
||||
if (!d) return failed('未检测到人脸');
|
||||
if (options.count !== 1) return failed('请保持单人出镜');
|
||||
var box = d.detection.box, w = options.width, h = options.height;
|
||||
if (!box || ![box.x, box.y, box.width, box.height, w, h].every(finite) || w <= 0 || h <= 0 || box.width <= 0 || box.height <= 0) return failed('请将脸部完整移入取景框');
|
||||
var ratio = box.height / h;
|
||||
if (!finite(d.detection.score) || d.detection.score < config.score) return failed('请保持面部清晰');
|
||||
if (ratio < config.near) return failed('请靠近一点');
|
||||
if (ratio > config.far) return failed('请远一点');
|
||||
if (box.x < w * config.edge || box.y < h * config.edge || box.x + box.width > w * (1 - config.edge) || box.y + box.height > h * (1 - config.edge)) return failed('请将脸部完整移入取景框');
|
||||
if (!finite(options.offsetX) || Math.abs(options.offsetX) > config.center) return failed('请将脸部移入取景框');
|
||||
var pose;
|
||||
try { pose = HeadPose.estimate(d.landmarks.positions, options.mirrored); } catch (_) { return failed('请正对屏幕'); }
|
||||
if (!pose) return failed('请正对屏幕');
|
||||
// System-camera files may already be mirrored. Retain the established
|
||||
// direction-agnostic fallback, but apply the same angle/pitch/roll limits.
|
||||
var target = options.target;
|
||||
if (options.ignoreDirection && target !== 'front') target = pose.yaw < 0 ? 'left' : 'right';
|
||||
var check = HeadPose.check(d.landmarks.positions, target, options.mirrored);
|
||||
if (!check.ok) return failed(check.hint);
|
||||
if (!finite(options.luma)) return failed('请保持面部清晰');
|
||||
if (options.luma < config.dark) return failed('请到光线充足处');
|
||||
if (options.luma > config.bright) return failed('光线过强,请避开强光');
|
||||
return { ok: true, hint: '保持不动', observation: { yaw: pose.yaw, pitch: pose.pitch, roll: pose.roll,
|
||||
score: d.detection.score, luma: options.luma, ratio: ratio,
|
||||
center_x: (box.x + box.width / 2) / w, center_y: (box.y + box.height / 2) / h } };
|
||||
}
|
||||
function hold(state, observation, now) {
|
||||
if (!observation) return { ready: false, state: null };
|
||||
var reset = !state || now - state.lastAt > config.maxGapMs || now < state.lastAt;
|
||||
if (!reset) {
|
||||
var a = state.anchor, b = observation;
|
||||
reset = Math.abs(a.yaw - b.yaw) > config.yawDrift || Math.abs(a.pitch - b.pitch) > config.pitchDrift ||
|
||||
Math.abs(a.center_x - b.center_x) > config.centerDrift || Math.abs(a.center_y - b.center_y) > config.centerDrift || Math.abs(a.ratio - b.ratio) > config.sizeDrift;
|
||||
}
|
||||
var next = reset ? { since: now, lastAt: now, frames: 1, anchor: observation } :
|
||||
{ since: state.since, lastAt: now, frames: state.frames + 1, anchor: state.anchor };
|
||||
return { state: next, ready: now - next.since >= config.holdMs && next.frames >= config.minFrames };
|
||||
}
|
||||
return { config: config, evaluate: evaluate, hold: hold };
|
||||
});
|
||||
+13
-10
@@ -62,13 +62,14 @@
|
||||
var PITCH_COEFF = 180; // 归一化偏移 → 角度 的增益
|
||||
|
||||
// ---- check 阈值(角度制) ----
|
||||
// 正脸口径:Face++ 正脸判定 |yaw|<10°;产品要求(2026-09-28)正脸引导放宽一点点,
|
||||
// 前端取 |yaw|≤15°、|pitch|≤18°(略超 Face++ 口径,正脸图仍可正常分析,仅 orientation 读数略偏)
|
||||
// Real-phone feedback: relaxed alignment, without accepting frontal photos
|
||||
// as side views. These empirical angles are not upstream quality scores.
|
||||
var FRONT_YAW_MAX = 15;
|
||||
var FRONT_PITCH_MAX = 18;
|
||||
var SIDE_YAW_MIN = 28;
|
||||
var SIDE_YAW_MAX = 75;
|
||||
var SIDE_YAW_MIN = 30;
|
||||
var SIDE_YAW_MAX = 70;
|
||||
var SIDE_PITCH_MAX = 20;
|
||||
var ROLL_MAX = 15;
|
||||
|
||||
var HINT_KEEP = '保持不动';
|
||||
var HINT_TURN_LEFT = '请向左转头'; // 用户向自己的左侧转
|
||||
@@ -157,10 +158,10 @@
|
||||
/**
|
||||
* check(points, target, mirrored) -> { ok, hint }
|
||||
* target: 'front' | 'left' | 'right'
|
||||
* 'front' : |yaw|<=15 且 |pitch|<=18;不 ok 时按超出更多的一项给方向提示
|
||||
* 'front' : |yaw|<=15 且 |pitch|<=18、|roll|<=15;不 ok 时给方向提示
|
||||
* (UI 反馈 2026-10:动态提示「请向左转头」「请略微抬头」等,取代此前只提示回正)
|
||||
* 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-75°,-28°]),|pitch|<=20
|
||||
* 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [28°,75°]),|pitch|<=20
|
||||
* 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-70°,-30°]),|pitch|<=20
|
||||
* 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [30°,70°]),|pitch|<=20
|
||||
* ok=true 时 hint='保持不动';输入非法(含 estimate 返回 null 的退化特征点)时 ok=false、hint='请正对屏幕'。
|
||||
*/
|
||||
function check(points, target, mirrored) {
|
||||
@@ -176,6 +177,7 @@
|
||||
// 这里直接用 e.yaw(此前再翻一次是双重镜像 bug:侧脸引导方向完全相反)
|
||||
var sYaw = e.yaw;
|
||||
var pitch = e.pitch;
|
||||
if (Math.abs(e.roll) > ROLL_MAX) return { ok: false, hint: '请勿歪头', yaw: sYaw };
|
||||
|
||||
if (target === 'front') {
|
||||
if (Math.abs(sYaw) <= FRONT_YAW_MAX && Math.abs(pitch) <= FRONT_PITCH_MAX) {
|
||||
@@ -222,7 +224,8 @@
|
||||
FRONT_PITCH_MAX: FRONT_PITCH_MAX,
|
||||
SIDE_YAW_MIN: SIDE_YAW_MIN,
|
||||
SIDE_YAW_MAX: SIDE_YAW_MAX,
|
||||
SIDE_PITCH_MAX: SIDE_PITCH_MAX
|
||||
SIDE_PITCH_MAX: SIDE_PITCH_MAX,
|
||||
ROLL_MAX: ROLL_MAX
|
||||
}
|
||||
};
|
||||
}));
|
||||
@@ -338,7 +341,7 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
|
||||
ok(HeadPose.estimate(tiny, false) === null, 'faceHeight 过小 → estimate 返回 null');
|
||||
ok(HeadPose.check(tiny, 'front', false).hint === '请正对屏幕', 'faceHeight 过小 check 兜底');
|
||||
|
||||
// 13) 正脸阈值放宽到 |yaw|≤15°(产品要求 2026-09-28):yaw≈14.9° 应通过,yaw≈16.2° 应拒绝并引导转回
|
||||
// 13) Phone feedback: restore the 15° frontal margin, still reject 16°+.
|
||||
ok(HeadPose.config.FRONT_YAW_MAX === 15, 'config.FRONT_YAW_MAX = 15');
|
||||
var slight = makeFrontal();
|
||||
slight[30] = { x: 205.6, y: 140 }; // 偏移 5.6 → yaw≈14.9°(YAW_COEFF=3.8 标定)
|
||||
@@ -346,7 +349,7 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m
|
||||
var se = HeadPose.estimate(slight, false);
|
||||
ok(se.yaw > 13.5 && se.yaw <= 15, '轻微偏头 yaw∈(13.5,15] (got ' + se.yaw.toFixed(2) + ')');
|
||||
var sc2 = HeadPose.check(slight, 'front', false);
|
||||
ok(sc2.ok === true, 'yaw≈14.9°(≤15°)front 通过(旧阈值 12 会拒绝)');
|
||||
ok(sc2.ok === true, 'yaw≈14.9° front 通过(真机反馈放宽)');
|
||||
var slight2 = makeFrontal();
|
||||
slight2[30] = { x: 206.2, y: 140 }; // 偏移 6.2 → yaw≈16.2°
|
||||
slight2[33] = { x: 206.2, y: 161.5 };
|
||||
|
||||
+23
-11
@@ -16,7 +16,25 @@
|
||||
];
|
||||
var levelMap = { '预警': '改善', '改善': '良好', '良好': '优秀' };
|
||||
var dimNames = { firm: '紧塑力', vita: '透亮感', stab: '稳韧力' };
|
||||
var focusTips = { firm: '强化紧塑力,提拉轮廓抚平细纹。', vita: '提升透亮感,补水提亮加强防晒。', stab: '增强稳韧力,舒缓泛红强韧屏障。' };
|
||||
function numeric(value) { return typeof value === 'number' && isFinite(value); }
|
||||
// Read saved raw dimension values, never re-score historical reports or use
|
||||
// the legacy focus.name (which contains the old dimension terminology).
|
||||
function focusOf(vm) {
|
||||
var selected = null;
|
||||
['firm', 'vita', 'stab'].forEach(function(key) {
|
||||
var dim = vm.dims && vm.dims[key];
|
||||
if (dim && numeric(dim.P) && (!selected || dim.P < selected.score)) selected = { key: key, score: dim.P, name: dimNames[key], tip: focusTips[key] };
|
||||
});
|
||||
return selected;
|
||||
}
|
||||
function customerText(view) {
|
||||
if (view.key === 'wrinkles') return view.text.split('\n分区明细')[0];
|
||||
if (view.key !== 'contour') return view.text;
|
||||
// Also cover immutable old presentations, without modifying stored copy.
|
||||
return view.text.replace(/侧脸角度数据未完整获取[;;]?/g, '')
|
||||
.replace('本次未检出有效数据,请重新拍摄后查看。', '本次未获取轮廓有效数据。');
|
||||
}
|
||||
function scalar(value) { return numeric(value) ? value : value && numeric(value.value) ? value.value : null; }
|
||||
var wrinkleDefs = [
|
||||
['forehead', '抬头纹(额头纹)', ['forehead_wrinkle_info'], 'forehead_count'],
|
||||
@@ -52,12 +70,6 @@
|
||||
return { key: def[0], name: def[1], metrics: values };
|
||||
});
|
||||
}
|
||||
function metricText(value) { return value == null ? '未获取' : String(Math.round(value * 10000) / 10000); }
|
||||
function wrinkleText(rows) {
|
||||
return '\n分区明细(左右以本人为准;抬头纹与额头纹为同一分区):\n' + rows.map(function (row) {
|
||||
return row.name + ':' + wrinkleMetrics.map(function (metric) { return metric[1] + ' ' + metricText(row.metrics[metric[0]]); }).join(',') + '。';
|
||||
}).join('\n');
|
||||
}
|
||||
function fill(template, vars) {
|
||||
return template.replace(/\{([a-z_]+)\}/g, function(_, key) {
|
||||
var v = vars[key];
|
||||
@@ -77,7 +89,7 @@
|
||||
var severity = vars.dark_circle_sev;
|
||||
var description = numeric(severity) ? ['无明显黑眼圈', '黑眼圈轻度', '黑眼圈中度', '黑眼圈重度'][severity] : null;
|
||||
template = template.replace(/黑眼圈轻\/中\/重度(根据分数给出判断)/g, description || '黑眼圈数据未获取');
|
||||
if (vars.la == null || vars.ra == null) template = template.replace(/左右下颌线角度(?:分别为左|左) \{la\}°\/ 右 \{ra\}°[;;]/g, '侧脸角度数据未完整获取;');
|
||||
if (vars.la == null || vars.ra == null) template = template.replace(/左右下颌线角度(?:分别为左|左) \{la\}°\/ 右 \{ra\}°[;;]/g, '');
|
||||
}
|
||||
if (d[0] === 'acne') {
|
||||
var pustules = count(result, 'acne_pustule'), nodules = count(result, 'acne_nodule');
|
||||
@@ -104,8 +116,8 @@
|
||||
var details = d[0] === 'wrinkles' ? wrinkleDetails(result) : null;
|
||||
var advice = level ? Copy.care[d[0]][level] : null;
|
||||
return { key: d[0], name: d[1], dim: d[2], source: d[3], P: cat.P, level: level || null,
|
||||
text: (valid ? fill(template, vars) : '本次未检出有效数据,请重新拍摄后查看。') + (details ? wrinkleText(details) : ''),
|
||||
valid: valid, advice: valid ? advice : null };
|
||||
text: valid ? fill(template, vars) : '本次未检出有效数据,请重新拍摄后查看。',
|
||||
details: details, valid: valid, advice: valid ? advice : null };
|
||||
});
|
||||
// Preserve selection of the two weakest original categories. Tone expands into
|
||||
// color+water advice, with thresholds inherited from the same original category.
|
||||
@@ -114,7 +126,7 @@
|
||||
var chosen = views.filter(function(v) { return ranked.indexOf(v.source) >= 0 && v.advice; });
|
||||
var care = chosen.map(function(v) { return { name: v.name, text: v.advice.care + v.advice.lifestyle }; });
|
||||
var product = products.filter(function(p) { return p.match === vm.essence.name; })[0] || null;
|
||||
return { version: Copy.version + '+feedback-2026-10-12', dims: dimNames, views: views, care: care, product: product };
|
||||
return { version: Copy.version + '+feedback-2026-10-12', dims: dimNames, views: views, care: care, product: product, focus: focusOf(vm) };
|
||||
}
|
||||
return { build: build, products: products, levelMap: levelMap, dimNames: dimNames, wrinkleDetails: wrinkleDetails };
|
||||
return { build: build, products: products, levelMap: levelMap, dimNames: dimNames, wrinkleDetails: wrinkleDetails, focusOf: focusOf, customerText: customerText };
|
||||
});
|
||||
|
||||
+3
-3
@@ -87,7 +87,7 @@ module.exports = function createAccounts(root, env = process.env) {
|
||||
try {
|
||||
if (req.method !== 'GET' && !sameOrigin(req)) { json(res, 403, { ok: false, message: '请求来源不匹配' }); return true; }
|
||||
if (pathname === '/api/auth/config' && req.method === 'GET') {
|
||||
json(res, 200, { ok: true, wechat: available, fallback: 'browser', reason: available ? null : '当前环境尚未开通微信登录,可使用本机浏览器登录' });
|
||||
json(res, 200, { ok: true, wechat: available, fallback: 'browser', reason: available ? null : '当前环境尚未开通微信登录,可使用游客登录' });
|
||||
} else if (pathname === '/api/auth/session' && req.method === 'GET') {
|
||||
json(res, 200, { ok: true, user: publicUser(session(req)) });
|
||||
} else if (pathname === '/api/auth/browser' && req.method === 'POST') {
|
||||
@@ -98,7 +98,7 @@ module.exports = function createAccounts(root, env = process.env) {
|
||||
if (token) {
|
||||
if (typeof token !== 'string' || !/^[a-f0-9]{64}$/.test(token)) token = null;
|
||||
user = token && db.users.find(u => u.kind === 'browser' && u.credential === hash(token));
|
||||
if (!user) { json(res, 401, { ok: false, message: '本机身份已失效,请重新登录' }); return true; }
|
||||
if (!user) { json(res, 401, { ok: false, message: '游客身份已失效,请重新登录' }); return true; }
|
||||
} else {
|
||||
token = random(); user = { id: crypto.randomUUID(), kind: 'browser', credential: hash(token), consent: new Date().toISOString(), reports: [] };
|
||||
db.users.push(user);
|
||||
@@ -110,7 +110,7 @@ module.exports = function createAccounts(root, env = process.env) {
|
||||
} else if (pathname === '/api/auth/wechat' && req.method === 'POST') {
|
||||
const data = await body(req);
|
||||
if (data.consent !== true) { json(res, 400, { ok: false, message: '请先阅读并同意协议' }); return true; }
|
||||
if (!available) { json(res, 503, { ok: false, message: '微信登录尚未配置,请使用本机浏览器登录' }); return true; }
|
||||
if (!available) { json(res, 503, { ok: false, message: '微信登录尚未配置,请使用游客登录' }); return true; }
|
||||
if (!rate(req)) { json(res, 429, { ok: false, message: '操作频繁,请稍后重试' }); return true; }
|
||||
const state = random(), binding = random();
|
||||
for (const k of Object.keys(db.states)) if (db.states[k].expires < Date.now()) delete db.states[k];
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
const CaptureQuality = require('../js/capture-quality.js');
|
||||
const HeadPose = require('../js/head-pose.js');
|
||||
const views = ['front', 'left', 'right'];
|
||||
function scalar(value) { return typeof value === 'number' && Number.isFinite(value) ? value : null; }
|
||||
// Client observations are untrusted calibration hints, never authorization or
|
||||
// a provider-quality verdict. Strip arbitrary strings/extra fields on ingestion.
|
||||
function normalize(input) {
|
||||
if (!input || typeof input !== 'object') return null;
|
||||
const version = typeof input.version === 'string' && /^[\w.-]{1,64}$/.test(input.version) ? input.version : 'unknown';
|
||||
const result = { version, source: 'client-untrusted', views: {} };
|
||||
for (const key of views) {
|
||||
const row = input.views && input.views[key];
|
||||
if (!row || typeof row !== 'object') continue;
|
||||
const out = { mode: ['live', 'file', 'unchecked', 'mock'].includes(row.mode) ? row.mode : 'unknown' };
|
||||
if (row.reason === 'model-unavailable') out.reason = row.reason;
|
||||
if (typeof row.direction_checked === 'boolean') out.direction_checked = row.direction_checked;
|
||||
for (const metric of ['yaw', 'pitch', 'roll', 'score', 'luma', 'ratio', 'center_x', 'center_y']) {
|
||||
const value = scalar(row[metric]);
|
||||
if (value != null && Math.abs(value) <= 1000) out[metric] = value;
|
||||
}
|
||||
result.views[key] = out;
|
||||
}
|
||||
if (version === CaptureQuality.config.version) result.policy = { capture: CaptureQuality.config, pose: HeadPose.config };
|
||||
return result;
|
||||
}
|
||||
function assess(raw) {
|
||||
const nested = raw.side_result || {};
|
||||
const result = { status: 'valid', views: {} };
|
||||
for (const key of ['left', 'right']) {
|
||||
const side = raw[key + '_side_result'] || nested[key + '_side_result'];
|
||||
const node = side && side[key + '_jawline_info'];
|
||||
const quality = scalar(node && node[key + '_jawline_quality']);
|
||||
const angle = scalar(node && node[key + '_jawline_angle']);
|
||||
const status = quality == null ? 'unavailable' : quality === 1 && angle != null ? 'valid' : 'invalid';
|
||||
result.views[key] = { quality, angle, status };
|
||||
if (status === 'invalid') result.status = 'invalid';
|
||||
else if (status === 'unavailable' && result.status === 'valid') result.status = 'unavailable';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
module.exports = { normalize, assess };
|
||||
+10
-2
@@ -26,6 +26,7 @@ const DATA_DIR = path.join(DATA_ROOT, 'results'); // 每次检测的原始返
|
||||
const PHOTOS_DIR = path.join(DATA_ROOT, 'photos'); // 检测照片:data/photos/<record_id>/{front,left,right}.jpg
|
||||
const RECORDS_FILE = path.join(DATA_ROOT, 'records.jsonl'); // 检测记录(一行一条 JSON)
|
||||
const ReportUI = require('./js/report-ui.js');
|
||||
const CaptureDiagnostics = require('./lib/capture-diagnostics.js');
|
||||
const accounts = require('./lib/accounts.js')(path.join(DATA_ROOT, 'accounts'));
|
||||
|
||||
/* 服务端评分引擎:与 C 端同一份公式(禁止复制公式,直接复用) */
|
||||
@@ -267,6 +268,8 @@ function scoreProfile(rec) {
|
||||
|
||||
/* 检测成功:用 SkinScore.buildViewModel 计算并补全记录字段(复用同一份公式) */
|
||||
function fillSuccess(rec, fpJson) {
|
||||
// Observation only: do not block, re-shoot, retry or alter scoring based on it.
|
||||
rec.capture_quality = CaptureDiagnostics.assess(fpJson);
|
||||
const vm = SkinScore.buildViewModel(fpJson.result, {
|
||||
left: fpJson.left_side_result || (fpJson.side_result && fpJson.side_result.left_side_result) || null,
|
||||
right: fpJson.right_side_result || (fpJson.side_result && fpJson.side_result.right_side_result) || null
|
||||
@@ -416,6 +419,7 @@ async function handleAnalyze(req, res) {
|
||||
raw_file: null, cost_ms: null,
|
||||
retry_of: null, retry_count: 0
|
||||
};
|
||||
rec.capture_diagnostics = CaptureDiagnostics.normalize(body.capture_diagnostics);
|
||||
appendRecord(rec);
|
||||
console.log(`[record] 新检测记录 ${recordId}(${rec.shoot_status},照片${Object.keys(photos).length}/3)`);
|
||||
|
||||
@@ -514,6 +518,8 @@ function listItem(rec) {
|
||||
created_at: rec.created_at,
|
||||
real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type,
|
||||
shoot_status: rec.shoot_status, upload_status: rec.upload_status, detect_status: rec.detect_status,
|
||||
capture_quality: rec.capture_quality || null,
|
||||
capture_policy_version: rec.capture_diagnostics ? rec.capture_diagnostics.version : null,
|
||||
fail_code: rec.fail_code, fail_msg: rec.fail_msg,
|
||||
total_score: rec.total_score, skin_age: rec.skin_age,
|
||||
skin_age_display: displaySkinAge(rec), recommendation: rec.recommendation || null,
|
||||
@@ -765,7 +771,7 @@ function handleExport(req, res, url) {
|
||||
const header = ['记录ID', '创建时间', '年龄', '性别', '地区', '日常肤质', '拍摄状态', '上传状态', '检测状态',
|
||||
'失败环节', '失败码', '失败信息', '总分', '原始肌龄(推荐依据)', '前端展示肌龄',
|
||||
'紧塑力P', '紧塑力S', '紧塑力档', '透亮感P', '透亮感S', '透亮感档', '稳韧力P', '稳韧力S', '稳韧力档',
|
||||
'均分S', '产品组别', '推荐精华', '触发判据', '耗时ms', '推荐规则版本', '推荐原因'];
|
||||
'均分S', '产品组别', '推荐精华', '触发判据', '耗时ms', '推荐规则版本', '推荐原因', '侧脸质量状态', '前端拍摄参数版本'];
|
||||
const lines = [header.join(',')];
|
||||
for (const r of filtered) {
|
||||
lines.push([
|
||||
@@ -778,7 +784,9 @@ function handleExport(req, res, url) {
|
||||
r.avg_s, r.group, r.essence ? ((ReportUI.products.find(p => p.match === r.essence.name) || {}).title || r.essence.name) : null,
|
||||
r.trigger, r.cost_ms,
|
||||
r.recommendation ? r.recommendation.version : '旧版规则(历史记录)',
|
||||
r.recommendation ? r.recommendation.reason : null
|
||||
r.recommendation ? r.recommendation.reason : null,
|
||||
r.capture_quality ? r.capture_quality.status : null,
|
||||
r.capture_diagnostics ? r.capture_diagnostics.version : null
|
||||
].map(csvCell).join(','));
|
||||
}
|
||||
const fromTag = (q.from || 'all').replace(/[^\d-]/g, '');
|
||||
|
||||
@@ -10,7 +10,7 @@ const end = source.indexOf(' /* ─── SkinScore viewModel', start);
|
||||
assert(start >= 0 && end > start);
|
||||
function setup() {
|
||||
let timer, complete, signal, result;
|
||||
const ctx = { IS_MOCK: false, camSeq: 1, AbortController,
|
||||
const ctx = { IS_MOCK: false, camSeq: 1, AbortController, CaptureQuality: require('../js/capture-quality'), captureDiagnostics: [],
|
||||
setTimeout(fn, delay) { timer = { fn, delay }; return 1; },
|
||||
clearTimeout() { timer = null; },
|
||||
fetch(url, options) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// Real local detector + synthetic video containing existing three-view photos.
|
||||
// Auth/analysis are in-memory fixtures: no account/report writes or paid calls.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
|
||||
const base = process.env.H5_BASE_URL || 'http://127.0.0.1:8080/';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 375, height: 726 } });
|
||||
page.setDefaultTimeout(60000);
|
||||
const errors = [], failedRequests = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
page.on('requestfailed', request => failedRequests.push({ path: new URL(request.url()).pathname, error: request.failure().errorText }));
|
||||
await page.route('**/api/auth/session', route => route.fulfill({ json: { ok: true, user: { id: 'local-camera-fixture', kind: 'browser' } } }));
|
||||
for (const name of ['C3_180.jpg', 'C3_135.jpg', 'C3_-135.jpg']) {
|
||||
const body = fs.readFileSync(fileURLToPath(new URL('../../reference/facepp-skinanalyze/examples/images/' + name, import.meta.url)));
|
||||
await page.route('**/sample-' + name, route => route.fulfill({ contentType: 'image/jpeg', body }));
|
||||
}
|
||||
await page.addInitScript(() => {
|
||||
const canvas = document.createElement('canvas'); canvas.width = 800; canvas.height = 600;
|
||||
let currentImage, offsetX = 0;
|
||||
function draw() {
|
||||
if (!currentImage) return;
|
||||
const c = canvas.getContext('2d'); c.fillStyle = '#111'; c.fillRect(0, 0, canvas.width, canvas.height);
|
||||
c.drawImage(currentImage, offsetX, 0, canvas.width, canvas.height);
|
||||
}
|
||||
setInterval(draw, 100);
|
||||
window.__cameraFixture = { canvas, set: async name => {
|
||||
const image = new Image(); image.src = 'sample-' + name; await image.decode();
|
||||
// Simulate a person following the centering prompt, not an off-center
|
||||
// landscape source stretched into a portrait camera frame.
|
||||
let nextOffset = 0;
|
||||
if (window.faceapi && faceapi.nets.faceLandmark68Net.isLoaded) {
|
||||
const faces = await faceapi.detectAllFaces(image, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: .5 }));
|
||||
if (faces.length === 1) nextOffset = canvas.width / 2 - (faces[0].box.x + faces[0].box.width / 2) * canvas.width / image.naturalWidth;
|
||||
}
|
||||
currentImage = image; offsetX = nextOffset; draw();
|
||||
} };
|
||||
navigator.mediaDevices.getUserMedia = async () => {
|
||||
await window.__cameraFixture.set('C3_180.jpg');
|
||||
return canvas.captureStream(10);
|
||||
};
|
||||
});
|
||||
await page.route('**/api/skin-analyze', async route => {
|
||||
const fixture = await page.evaluate(() => ({ ok: true, result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE, report_saved: true }));
|
||||
await route.fulfill({ json: fixture });
|
||||
});
|
||||
await page.goto(base); await page.click('[data-action="open-privacy"]');
|
||||
assert.equal(await page.title(), "1'me肌肤状态检测");
|
||||
await page.click('#privacy-check'); await page.click('[data-action="agree-continue"]');
|
||||
for (const selector of ['#pick-birth', '#pick-gender', '#pick-region']) {
|
||||
await page.click(selector); await page.click('.ms-mobile-select.ms-show .ms-ensure'); await page.waitForTimeout(400);
|
||||
}
|
||||
await page.click('[data-action="submit-profile"]'); await page.click('[data-action="start-camera"]');
|
||||
try {
|
||||
await page.waitForFunction(() => window.faceapi && faceapi.nets.faceLandmark68Net.isLoaded && document.querySelector('#s-cam').classList.contains('cam-live'));
|
||||
} catch (error) {
|
||||
console.log(await page.evaluate(() => ({ status: document.querySelector('#cam-status').textContent, hint: document.querySelector('#cam-hint').textContent, toast: document.querySelector('#toast').textContent, width: document.querySelector('#cam-video').videoWidth, classes: document.querySelector('#s-cam').className, engine: !!window.faceapi })), errors, failedRequests);
|
||||
throw error;
|
||||
}
|
||||
assert.equal(await page.locator('#s-cam.cam-manual').count(), 0, 'real automatic pipeline, not manual bypass');
|
||||
await page.evaluate(() => {
|
||||
window.__holdSamples = []; window.__hints = []; window.__evaluations = [];
|
||||
const evaluate = CaptureQuality.evaluate;
|
||||
CaptureQuality.evaluate = function(d, options) {
|
||||
const result = evaluate(d, options);
|
||||
window.__evaluations.push({ target: options.target, ok: result.ok, hint: result.hint });
|
||||
return result;
|
||||
};
|
||||
const hold = CaptureQuality.hold;
|
||||
CaptureQuality.hold = function(state, obs, now) {
|
||||
const result = hold(state, obs, now);
|
||||
window.__holdSamples.push({ gap: state ? now - state.lastAt : null, frames: result.state && result.state.frames, ready: result.ready, obs });
|
||||
if (window.__holdSamples.length > 8) window.__holdSamples.shift();
|
||||
return result;
|
||||
};
|
||||
new MutationObserver(() => { window.__hints.push(document.querySelector('#cam-hint').textContent); if (window.__hints.length > 20) window.__hints.shift(); }).observe(document.querySelector('#cam-hint'), { childList: true, subtree: true });
|
||||
});
|
||||
try { await page.waitForFunction(() => document.querySelector('#cam-step').textContent.includes('2/3')); }
|
||||
catch (error) {
|
||||
console.log(await page.evaluate(async () => {
|
||||
const v = document.querySelector('#cam-video');
|
||||
const faces = await faceapi.detectAllFaces(v, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: .5 })).withFaceLandmarks();
|
||||
return { status: document.querySelector('#cam-status').textContent, hint: document.querySelector('#cam-hint').textContent,
|
||||
step: document.querySelector('#cam-step').textContent, size: [v.videoWidth, v.videoHeight, v.clientWidth, v.clientHeight],
|
||||
ratio: window.__dbgRatio, faces: faces.map(d => ({ score: d.detection.score, pose: HeadPose.estimate(d.landmarks.positions, false), box: d.detection.box })), holds: window.__holdSamples, hints: window.__hints };
|
||||
}).then(value => JSON.stringify(value, null, 2)), errors); throw error;
|
||||
}
|
||||
await page.waitForFunction(() => window.__evaluations.some(row => row.target === 'right' && !row.ok));
|
||||
assert((await page.locator('#cam-step').textContent()).includes('2/3'), 'front photo cannot pass the side gate');
|
||||
await page.evaluate(() => window.__cameraFixture.set('C3_135.jpg'));
|
||||
await page.waitForFunction(() => document.querySelector('#cam-step').textContent.includes('3/3'));
|
||||
await page.waitForFunction(() => window.__evaluations.some(row => row.target === 'left' && !row.ok));
|
||||
assert((await page.locator('#cam-step').textContent()).includes('3/3'), 'wrong-direction side cannot pass');
|
||||
const sent = page.waitForRequest(r => r.url().endsWith('/api/skin-analyze'));
|
||||
await page.evaluate(() => window.__cameraFixture.set('C3_-135.jpg'));
|
||||
const body = (await sent).postDataJSON();
|
||||
await page.waitForSelector('#s-report.active');
|
||||
for (const key of ['front', 'left', 'right']) {
|
||||
const row = body.capture_diagnostics.views[key];
|
||||
assert.equal(row.mode, 'live'); assert.equal(row.direction_checked, true);
|
||||
assert(row.score >= .4 && row.luma >= 65 && row.luma <= 235);
|
||||
assert(Math.abs(row.roll) <= 15);
|
||||
}
|
||||
assert(Math.abs(body.capture_diagnostics.views.front.yaw) <= 15);
|
||||
assert(body.capture_diagnostics.views.left.yaw <= -30);
|
||||
assert(body.capture_diagnostics.views.right.yaw >= 30);
|
||||
assert(await page.evaluate(() => document.querySelector('#cam-video').srcObject === null), 'camera released after success');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: actual local models + automatic three-view capture + final JPEG revalidation; front/wrong-direction frames blocked locally; all three approved samples recorded; camera released; no paid call or backend writes');
|
||||
} finally { await browser.close(); }
|
||||
@@ -0,0 +1,94 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const Quality = require('../js/capture-quality');
|
||||
const HeadPose = require('../js/head-pose');
|
||||
const ReportUI = require('../js/report-ui');
|
||||
const Diagnostics = require('../lib/capture-diagnostics');
|
||||
function points(yaw = 0, pitch = 0, roll = 0) {
|
||||
const result = Array.from({ length: 68 }, () => ({ x: 200, y: 150 }));
|
||||
result[36] = { x: 150, y: 100 }; result[45] = { x: 250, y: 100 };
|
||||
result[19] = { x: 170, y: 70 }; result[24] = { x: 230, y: 70 };
|
||||
result[8] = { x: 200, y: 240 };
|
||||
// Solve the empirical yaw geometry rather than duplicating a second policy.
|
||||
let low = -80, high = 80;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const mid = (low + high) / 2; result[30] = { x: 200 + mid, y: 140 };
|
||||
result[33] = { x: 200, y: 161.5 };
|
||||
if (HeadPose.estimate(result, false).yaw < yaw) low = mid; else high = mid;
|
||||
}
|
||||
result[33] = { x: 200, y: 161.5 + pitch / 180 * 170 };
|
||||
if (roll) {
|
||||
const angle = roll * Math.PI / 180;
|
||||
for (const p of result) { const x = p.x - 200, y = p.y - 150; p.x = 200 + x * Math.cos(angle) - y * Math.sin(angle); p.y = 150 + x * Math.sin(angle) + y * Math.cos(angle); }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const face = (yaw = 0, pitch = 0, roll = 0) => ({ detection: { score: .95, box: { x: 170, y: 160, width: 260, height: 300 } }, landmarks: { positions: points(yaw, pitch, roll) } });
|
||||
const options = { count: 1, target: 'front', width: 600, height: 600, offsetX: 0, luma: 120, mirrored: false };
|
||||
const check = (d, changes) => Quality.evaluate(d, { ...options, ...changes });
|
||||
assert(check(face()).ok);
|
||||
assert(!check(null).ok); assert(!check(face(), { count: 2 }).ok);
|
||||
for (const yaw of [-14, 14]) assert(check(face(yaw)).ok, 'former strict front limit now passes');
|
||||
for (const yaw of [-16, 16]) assert(!check(face(yaw)).ok);
|
||||
for (const pitch of [-17, 17]) assert(check(face(0, pitch)).ok);
|
||||
for (const pitch of [-20, 20]) assert(!check(face(0, pitch)).ok);
|
||||
assert(check(face(0, 0, 12)).ok, 'normal tilt now passes');
|
||||
assert.equal(check(face(0, 0, 16)).hint, '请勿歪头');
|
||||
for (const yaw of [25, 72]) assert(!check(face(yaw), { target: 'right' }).ok);
|
||||
for (const yaw of [32, 68]) assert(check(face(yaw), { target: 'right' }).ok);
|
||||
assert(check(face(45), { target: 'right' }).ok);
|
||||
assert(!check(face(45), { target: 'left' }).ok);
|
||||
assert(check(face(-45), { target: 'left' }).ok);
|
||||
assert(check(face(-45), { target: 'right', mirrored: true }).ok);
|
||||
assert(check(face(-45), { target: 'right', ignoreDirection: true }).ok);
|
||||
assert(!check(face(25), { target: 'left', ignoreDirection: true }).ok);
|
||||
for (const luma of [null, 50, 240, NaN]) assert(!check(face(), { luma }).ok);
|
||||
for (const luma of [70, 230]) assert(check(face(), { luma }).ok);
|
||||
assert(check(face(), { offsetX: .15 }).ok);
|
||||
assert(!check(face(), { offsetX: .17 }).ok);
|
||||
for (const changes of [{ height: 190 }, { height: 400 }, { y: 8 }]) {
|
||||
const d = face(); Object.assign(d.detection.box, changes); assert(check(d).ok, 'former framing limits now pass');
|
||||
}
|
||||
for (const changes of [{ height: 179 }, { height: 410 }, { x: -10 }, { y: 5 }]) {
|
||||
const d = face(); Object.assign(d.detection.box, changes); assert(!check(d).ok);
|
||||
}
|
||||
const weak = face(); weak.detection.score = .45; assert(check(weak).ok);
|
||||
weak.detection.score = .35; assert(!check(weak).ok);
|
||||
const obs = check(face()).observation;
|
||||
let held = Quality.hold(null, obs, 1000); assert(!held.ready);
|
||||
held = Quality.hold(held.state, obs, 1400); assert(!held.ready);
|
||||
assert(!Quality.hold(held.state, obs, 1449).ready);
|
||||
assert(Quality.hold(held.state, obs, 1450).ready);
|
||||
assert(Quality.hold(Quality.hold(null, obs, 1000).state, obs, 1450).ready, 'two stable frames at 450ms now pass');
|
||||
assert.equal(Quality.hold(held.state, null, 1700).state, null);
|
||||
assert.equal(Quality.hold(held.state, obs, 3500).state.frames, 1, 'gap resets');
|
||||
const slow = Quality.hold(Quality.hold(null, obs, 1000).state, obs, 2100);
|
||||
assert(slow.ready, '1100ms inference now only needs two checked frames');
|
||||
for (const change of [{ yaw: 7 }, { pitch: 7 }, { ratio: .56 }, { center_x: .55 }, { center_y: .55 }]) assert(Quality.hold(held.state, { ...obs, ...change }, 1500).ready, 'small movement no longer resets');
|
||||
for (const change of [{ yaw: 9 }, { pitch: 9 }, { ratio: .6 }, { center_x: .56 }, { center_y: .58 }]) assert.equal(Quality.hold(held.state, { ...obs, ...change }, 1500).state.frames, 1, 'large motion still resets');
|
||||
for (const [dims, key] of [
|
||||
[[10, 50, 90], 'firm'], [[90, 10, 50], 'vita'], [[90, 50, 10], 'stab'],
|
||||
[[50, 50, 90], 'firm'], [[null, 50, 50], 'vita'], [[50.1, 50, 90], 'vita']
|
||||
]) {
|
||||
const vm = { dims: Object.fromEntries(['firm', 'vita', 'stab'].map((key, i) => [key, { P: dims[i] }])), focus: { name: '旧名字' } };
|
||||
const original = JSON.stringify(vm), focus = ReportUI.focusOf(vm);
|
||||
assert.equal(focus.key, key); assert.equal(focus.name, ReportUI.dimNames[key]); assert(focus.tip.endsWith('。'));
|
||||
assert.equal(JSON.stringify(vm), original);
|
||||
}
|
||||
assert.equal(ReportUI.focusOf({ dims: {} }), null);
|
||||
const wrinkle = { key: 'wrinkles', text: '原始皱纹结论。\n分区明细(左右以本人为准):\n抬头纹:皱纹分 13。' };
|
||||
assert.equal(ReportUI.customerText(wrinkle), '原始皱纹结论。');
|
||||
assert(wrinkle.text.includes('分区明细'), 'historical snapshot not overwritten');
|
||||
assert.equal(ReportUI.customerText({ key: 'contour', text: '轮廓清晰;侧脸角度数据未完整获取;其他结论。' }), '轮廓清晰;其他结论。');
|
||||
const raw = { left_side_result: { left_jawline_info: { left_jawline_quality: 3, left_jawline_angle: 110 } }, side_result: { right_side_result: { right_jawline_info: { right_jawline_quality: 1, right_jawline_angle: 115 } } } };
|
||||
assert.equal(Diagnostics.assess(raw).status, 'invalid');
|
||||
assert.equal(Diagnostics.assess({}).status, 'unavailable');
|
||||
assert.equal(Diagnostics.assess(raw).views.right.status, 'valid');
|
||||
const normalized = Diagnostics.normalize({ version: Quality.config.version, secret: 'discard', views: { front: { mode: 'live', yaw: 1, score: .9, luma: Infinity, secret: 'discard' }, left: { mode: 'unchecked', reason: 'model-unavailable' } } });
|
||||
assert.equal(normalized.policy.capture.holdMs, 450);
|
||||
assert.equal(normalized.policy.capture.minFrames, 2);
|
||||
assert.equal(normalized.version, '2026-10-12-fourth-v2');
|
||||
assert.equal(normalized.views.front.luma, undefined);
|
||||
assert(!JSON.stringify(normalized).includes('discard'));
|
||||
assert.equal(Diagnostics.normalize({ version: '<script>' }).version, 'unknown');
|
||||
console.log('PASS: former strict pose/framing/light/motion limits now pass; invalid views/multiple faces/cropping/large movement still rejected; 450ms/2-frame stability/miss/gap reset; focus/history/backend diagnostics');
|
||||
@@ -0,0 +1,138 @@
|
||||
// Isolated backend/browser/fixture upstream: no production data or paid API.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const CaptureQuality = require('../js/capture-quality');
|
||||
const ReportUI = require('../js/report-ui');
|
||||
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
|
||||
const h5 = fileURLToPath(new URL('../', import.meta.url));
|
||||
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-fourth-test-'));
|
||||
const artifacts = process.env.H5_ARTIFACT_DIR;
|
||||
const child = spawn(process.execPath, ['-e', `
|
||||
const fs=require('fs'), vm=require('vm'), w={};
|
||||
vm.runInNewContext(fs.readFileSync('assets/mock/report-fixture.js','utf8'),{window:w});
|
||||
const f=w.MOCK_FIXTURE;
|
||||
f.side.right.right_jawline_info.right_jawline_quality=3;
|
||||
global.fetch=async()=>({ok:true,status:200,json:async()=>({result:f.result,face_rectangle:f.face_rectangle,left_side_result:f.side.left,right_side_result:f.side.right})});
|
||||
require('./server.js');
|
||||
`], { cwd: h5, env: { ...process.env, PORT: '18104', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
|
||||
SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture', PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let browser;
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(Error('test startup timeout')), 10000);
|
||||
child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timeout); resolve(); } });
|
||||
child.once('exit', code => { clearTimeout(timeout); reject(Error('server exited ' + code)); });
|
||||
});
|
||||
const base = 'http://127.0.0.1:18104/';
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
|
||||
const errors = [], requests = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
page.on('request', r => requests.push(new URL(r.url()).pathname));
|
||||
await page.addInitScript(() => {
|
||||
navigator.mediaDevices.getUserMedia = () => Promise.reject(new DOMException('fixture denial', 'NotAllowedError'));
|
||||
window.__scanIterations = [];
|
||||
document.addEventListener('animationiteration', e => { if (e.target.classList.contains('scan-beam')) window.__scanIterations.push(e.elapsedTime); });
|
||||
});
|
||||
await page.route('**/vendor/faceapi/face-api.js', route => route.abort());
|
||||
await page.goto(base); await page.waitForLoadState('networkidle');
|
||||
assert.equal(await page.title(), "1'me肌肤状态检测");
|
||||
assert.equal(await page.locator('meta[property="og:title"]').getAttribute('content'), "1'me肌肤状态检测");
|
||||
assert.equal((await page.locator('[data-action="login-browser"]').textContent()).trim(), '游客登录');
|
||||
assert(!requests.some(p => /scan-(base|face)|\.woff2$|faceapi/.test(p)), 'no heavy/scanning entry requests');
|
||||
await page.check('#login-consent'); await page.click('[data-action="login-browser"]');
|
||||
await page.waitForSelector('#p-login-success.show');
|
||||
assert((await page.locator('#login-success-message').textContent()).includes('当前使用:游客登录'));
|
||||
await page.click('[data-action="login-confirm"]');
|
||||
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), '游客已登录');
|
||||
const api = page.context().request;
|
||||
await page.addScriptTag({ url: base + 'assets/mock/report-fixture.js' });
|
||||
const fixture = await page.evaluate(() => window.MOCK_FIXTURE);
|
||||
const result = await (await api.post(base + 'api/skin-analyze', { data: {
|
||||
front_image: fixture.photos[0], left_image: fixture.photos[1], right_image: fixture.photos[2],
|
||||
profile: { real_age: 30 }, capture_diagnostics: { version: CaptureQuality.config.version,
|
||||
views: { front: { mode: 'live', yaw: 2, luma: 120 }, left: { mode: 'file', direction_checked: false }, right: { mode: 'unchecked', reason: 'model-unavailable' } }, ignore: 'discard' }
|
||||
} })).json();
|
||||
assert(result.ok && result.report_saved, 'provider quality=3 must NOT block or request a re-shoot');
|
||||
assert(result.vm.notes.some(n => n.includes('quality=3')));
|
||||
const admin = (await (await api.get(base + 'api/admin/records/' + result.record_id)).json()).record;
|
||||
assert.equal(admin.capture_quality.status, 'invalid');
|
||||
assert.equal(admin.capture_quality.views.right.quality, 3);
|
||||
assert.equal(admin.capture_diagnostics.version, CaptureQuality.config.version);
|
||||
assert.equal(admin.capture_diagnostics.policy.pose.SIDE_YAW_MIN, 30);
|
||||
assert.equal(admin.capture_diagnostics.policy.capture.minFrames, 2);
|
||||
assert.equal(admin.capture_diagnostics.ignore, undefined);
|
||||
assert(admin.notes.some(n => n.includes('quality=3')));
|
||||
const wrinkle = admin.presentation.views.find(view => view.key === 'wrinkles');
|
||||
assert(!wrinkle.text.includes('分区明细') && !wrinkle.text.includes('标准化长度'));
|
||||
assert.equal(wrinkle.details.length, 12);
|
||||
const csv = await (await api.get(base + 'api/admin/export')).text();
|
||||
assert(csv.includes('侧脸质量状态,前端拍摄参数版本'));
|
||||
assert(csv.includes('invalid,' + CaptureQuality.config.version));
|
||||
const before = (await api.get(base + 'api/reports/' + result.record_id));
|
||||
const snapshotBefore = await before.text();
|
||||
await page.click('[data-action="history"]'); await page.click('[data-report-id]');
|
||||
await page.waitForSelector('#s-report.active');
|
||||
assert(await page.locator('#dims-note').isVisible());
|
||||
assert.equal(await page.locator('#vm-notes').isVisible(), false);
|
||||
assert(!(await page.locator('#s-report').textContent()).includes('建议重拍'));
|
||||
const expected = ReportUI.focusOf(result.vm);
|
||||
assert.equal(await page.locator('.dims-note-l1').textContent(), '近期优先关注:' + expected.name);
|
||||
assert.equal(await page.locator('.dims-note-l2').textContent(), expected.tip);
|
||||
for (const width of [320, 375, 390, 414, 480]) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
const geometry = await page.locator('#dims-note').evaluate(el => ({ width: el.clientWidth, scroll: el.scrollWidth, bottom: el.getBoundingClientRect().bottom, next: document.querySelector('.dimension-title').getBoundingClientRect().top }));
|
||||
assert(geometry.scroll <= geometry.width && geometry.bottom <= geometry.next, 'focus wraps without overlap at ' + width);
|
||||
}
|
||||
if (artifacts) { fs.mkdirSync(artifacts, { recursive: true }); await page.setViewportSize({ width: 375, height: 726 }); await page.screenshot({ path: path.join(artifacts, '01-focus-history.png') }); }
|
||||
assert.equal(await (await api.get(base + 'api/reports/' + result.record_id)).text(), snapshotBefore, 'display changes must not mutate historical report');
|
||||
const desktop = await browser.newPage({ viewport: { width: 1500, height: 1000 } });
|
||||
desktop.on('pageerror', e => errors.push(e.message));
|
||||
await desktop.goto(base + 'admin.html');
|
||||
await desktop.waitForFunction(() => document.querySelector('#tbl-records').textContent.includes('侧脸质量异常'));
|
||||
await desktop.locator('#tbl-records tbody tr').first().click();
|
||||
await desktop.waitForFunction(() => document.querySelector('#d-body').textContent.includes('前端参数版本'));
|
||||
assert((await desktop.locator('#d-body').textContent()).includes('quality=3'));
|
||||
const wrinkleTable = desktop.locator('.d-sec').filter({ hasText: '皱纹分区原始指标(仅后台复核' });
|
||||
assert.equal(await wrinkleTable.locator('tbody tr').count(), 12);
|
||||
assert.equal(await wrinkleTable.locator('thead th').count(), 10);
|
||||
assert((await wrinkleTable.textContent()).includes('标准化长度'));
|
||||
if (artifacts) await desktop.screenshot({ path: path.join(artifacts, '02-admin-quality.png') });
|
||||
// A fast intercepted analysis response still waits for one actual CSS cycle.
|
||||
await page.goto(base); await page.click('[data-action="open-privacy"]');
|
||||
await page.click('#privacy-check'); await page.click('[data-action="agree-continue"]');
|
||||
for (const selector of ['#pick-birth', '#pick-gender', '#pick-region']) {
|
||||
await page.click(selector); await page.click('.ms-mobile-select.ms-show .ms-ensure'); await page.waitForTimeout(400);
|
||||
}
|
||||
await page.click('[data-action="submit-profile"]'); await page.click('[data-action="start-camera"]');
|
||||
let sent;
|
||||
await page.route('**/api/skin-analyze', route => { sent = route.request().postDataJSON(); return route.fulfill({ json: result }); });
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await page.waitForFunction(n => document.querySelector('#cam-step').textContent.includes(n + '/3'), i);
|
||||
await page.waitForTimeout(1600);
|
||||
await page.setInputFiles('#cam-file', path.join(h5, '../reference/facepp-skinanalyze/examples/images/C3_180.jpg'));
|
||||
}
|
||||
await page.waitForSelector('#p-analyzing.show');
|
||||
await page.waitForTimeout(300);
|
||||
assert(await page.locator('#p-analyzing').isVisible(), 'fast response must not cut off cycle');
|
||||
if (artifacts) await page.screenshot({ path: path.join(artifacts, '03-scan-motion.png') });
|
||||
await page.waitForSelector('#s-report.active');
|
||||
assert(await page.evaluate(() => window.__scanIterations.some(seconds => seconds >= 1)), 'one actual complete 1000ms CSS cycle, independent of Playwright observation latency');
|
||||
assert(sent && sent.capture_diagnostics.version === CaptureQuality.config.version);
|
||||
assert.equal(sent.capture_diagnostics.views.front.mode, 'unchecked');
|
||||
assert.equal(await page.locator('#p-analyzing.scan-running').count(), 0);
|
||||
assert.equal((await (await api.get(base + 'api/reports')).json()).total, 1, 'intercepted flow creates no extra report');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: guest login/status; provider quality=3 remains successful/saved/backend-only; calibration/admin/CSV; focus historical immutability/five widths; real CSS cycle gates fast report; fallback trace; no JS errors/paid calls');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
const stopped = once(child, 'exit'); child.kill(); await stopped;
|
||||
fs.rmSync(dataRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -33,7 +33,7 @@ assert(context.captureHaptic({ vibrate: p => { pattern = Array.from(p); return t
|
||||
assert.deepEqual(pattern, [70, 40, 70]);
|
||||
|
||||
// Exercise the actual readiness state machine; only camera/pose inputs and clock
|
||||
// are stubs. No detector thresholds, hold time or capture sequence are changed.
|
||||
// are stubs; the relaxed shared hold policy now requires 450ms/2 frames.
|
||||
let now = 10000, buzzes = 0, captures = 0;
|
||||
const state = {
|
||||
missFrames: 0, MISS_FRAMES_MAX: 3, camVideo: { videoHeight: 600 }, FACE_NEAR: .30, FACE_FAR: .68,
|
||||
@@ -41,6 +41,10 @@ const state = {
|
||||
HeadPose: { check: () => ({ ok: true }) }, sideYawFlip: false, faceLuma: () => 100,
|
||||
LUMA_DARK: 65, LUMA_BRIGHT: 235, window: {}, setMask: () => {}, setHint: () => {},
|
||||
okSince: 0, readyHapticAt: 0, OK_HOLD_MS: 450, Date: { now: () => now },
|
||||
holdState: null, CaptureQuality: {
|
||||
evaluate: d => ({ ok: d.detection.box.height >= 100, hint: '保持不动', observation: { yaw: 0, pitch: 0, center_x: .5, center_y: .5, ratio: .5 } }),
|
||||
hold: require('../js/capture-quality').hold
|
||||
},
|
||||
navigator: { vibrate: () => { buzzes++; return true; } }, doCapture: () => { captures++; }
|
||||
};
|
||||
vm.runInNewContext(extract('camera-haptic-pure') + source.slice(source.indexOf(' function onDetect(d)'), source.indexOf(' /* ─── 拍照流程')), state);
|
||||
@@ -53,7 +57,8 @@ now += 100; state.onDetect(face); assert.equal(buzzes, 1, 'jitter must not repea
|
||||
now += 2100; state.onDetect({ ...face, detection: { box: { height: 20 } } });
|
||||
state.onDetect(face); assert.equal(buzzes, 2);
|
||||
state.navigator.vibrate = () => { throw Error('blocked'); };
|
||||
now += 450; state.onDetect(face); assert.equal(captures, 2, 'vibration failure must not prevent capture');
|
||||
now += 300; state.onDetect(face);
|
||||
now += 150; state.onDetect(face); assert.equal(captures, 2, 'vibration failure must not prevent capture');
|
||||
for (const manual of [true, false]) {
|
||||
let manualBuzzes = 0, scheduled = [];
|
||||
const shutter = { shutterLock: false, camLive: true, capturing: false, camSeq: 1,
|
||||
@@ -66,4 +71,4 @@ for (const manual of [true, false]) {
|
||||
assert(shutter.shutterLock && shutter.capturing);
|
||||
assert.deepEqual(scheduled, [300], 'blocked haptic must still schedule camera capture');
|
||||
}
|
||||
console.log('PASS: three bounded rounded contours, invalid outline fallback, no geometry mutation; haptic missing/false/exception safety, pre-capture cue, jitter throttling, unchanged 450ms auto-capture');
|
||||
console.log('PASS: three bounded rounded contours; haptic safety, pre-capture cue, jitter throttling; relaxed 450ms/2-frame hold');
|
||||
|
||||
@@ -10,6 +10,7 @@ const artifacts = process.env.H5_ARTIFACT_DIR;
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
|
||||
page.setDefaultTimeout(60000); // Match the production-facing third-round suite.
|
||||
const errors = [], requests = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
page.on('request', r => requests.push(new URL(r.url()).pathname));
|
||||
|
||||
@@ -61,7 +61,7 @@ try {
|
||||
await page.goto(base);
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await page.waitForLoadState('networkidle');
|
||||
assert(!requests.some(p => /\.woff2$|faceapi|scan-waiting|product-\d+\.webp/.test(p)), 'heavy/scanning assets must not compete with entry');
|
||||
assert(!requests.some(p => /\.woff2$|faceapi|scan-(waiting|base|face)|product-\d+\.webp/.test(p)), 'heavy/scanning assets must not compete with entry');
|
||||
await page.waitForFunction(() => document.querySelector('#s-splash .splash-slogan').naturalWidth === 718 && document.querySelector('.brand-lockup').naturalWidth === 517);
|
||||
await screenshot('01-home');
|
||||
await page.click('[data-action="open-privacy"]');
|
||||
@@ -82,7 +82,7 @@ try {
|
||||
await screenshot('02-consent');
|
||||
await page.click('#privacy-check'); await page.click('[data-action="agree-continue"]');
|
||||
await page.waitForSelector('#s-profile.active');
|
||||
assert(requests.some(p => p.endsWith('scan-waiting.webp')), 'preload waiting image only after profile entry');
|
||||
assert(requests.some(p => p.endsWith('scan-base.webp')), 'preload waiting image only after profile entry');
|
||||
for (const selector of ['#pick-birth', '#pick-gender', '#pick-region']) {
|
||||
await page.click(selector); await page.click('.ms-mobile-select.ms-show .ms-ensure'); await page.waitForTimeout(400);
|
||||
}
|
||||
@@ -121,12 +121,14 @@ try {
|
||||
return [[540, 720], [540, 1200]].map(([x, y]) => Array.from(context.getImageData(x, y, 1, 1).data));
|
||||
});
|
||||
assert(scanPixels[0].slice(0, 3).every(value => value > 245), 'visible PSD face must not be lost to clipping');
|
||||
assert(scanPixels[1][0] > 220 && scanPixels[1][1] > 220 && scanPixels[1][2] < 150, 'yellow lower face must be visible');
|
||||
assert(scanPixels[1].slice(0, 3).every(value => value > 245), 'base face stays white; yellow scan is now a separate animated layer');
|
||||
assert.equal(await page.locator('.scan-beam').evaluate(e => getComputedStyle(e).animationName), 'report-scan');
|
||||
assert.equal(await page.locator('#p-analyzing [data-action]').count(), 1, 'only cancel is actionable; PSD icons decorative');
|
||||
for (const [width, height] of [[320, 726], [375, 726], [390, 844], [414, 896], [480, 726], [375, 440]]) {
|
||||
await page.setViewportSize({ width, height });
|
||||
const art = await rect('#scan-artwork');
|
||||
assert.equal(art.width, width); assert.equal(art.height, height);
|
||||
assert(art.width <= width + .1 && art.height <= height + .1);
|
||||
assert(Math.abs(art.width / art.height - 1080 / 2091) < .001);
|
||||
assert.equal(await page.locator('#scan-artwork').evaluate(e => getComputedStyle(e).objectFit), 'contain');
|
||||
assert(await page.locator('.scan-cancel').isVisible());
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
||||
@@ -189,7 +191,7 @@ try {
|
||||
// reflow or escape the dialog, and short viewports must keep controls visible.
|
||||
const fallback = await browser.newPage({ viewport: { width: 375, height: 726 } });
|
||||
await fallback.route('**/*.woff2', route => route.abort());
|
||||
await fallback.route('**/scan-waiting.webp', route => route.abort());
|
||||
await fallback.route('**/scan-base.webp', route => route.abort());
|
||||
await fallback.route('**/api/auth/session', route => route.fulfill({ json: { ok: true, user: { id: 'font-fallback-fixture', kind: 'browser' } } }));
|
||||
await fallback.goto(base); await fallback.click('[data-action="open-privacy"]');
|
||||
await fallback.waitForLoadState('networkidle');
|
||||
|
||||
@@ -31,8 +31,20 @@ const model = SkinScore.buildViewModel(result, SkinScore.MOCK_SIDE, { realAge: 3
|
||||
const modelBefore = JSON.stringify(model), rawBefore = JSON.stringify(result);
|
||||
const presentation = ReportUI.build(model, result);
|
||||
const text = presentation.views.find(view => view.key === 'wrinkles').text;
|
||||
for (const row of details) assert(text.includes(row.name));
|
||||
assert(text.includes('标准化长度 1.2088'));
|
||||
assert.deepEqual(presentation.views.find(view => view.key === 'wrinkles').details, details);
|
||||
assert(!text.includes('分区明细') && !text.includes('标准化长度'));
|
||||
const approved = {
|
||||
'预警': '全脸共检测到皱纹 12 条、细纹 34 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。',
|
||||
'改善': '全脸共检测到皱纹 12 条、细纹 34 条,面部整体平整,仅眼周或口周有细微的干纹,无明显静态深纹,纹路面积占比相对较低。',
|
||||
'良好': '面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。'
|
||||
};
|
||||
for (const level of Object.keys(approved)) {
|
||||
const fixture = JSON.parse(modelBefore);
|
||||
fixture.cats.wrinkles.level = level;
|
||||
fixture.cats.wrinkles.vars.wr_cnt = 12;
|
||||
fixture.cats.wrinkles.vars.fl_cnt = 34;
|
||||
assert.equal(ReportUI.build(fixture, result).views.find(view => view.key === 'wrinkles').text, approved[level]);
|
||||
}
|
||||
assert.equal(JSON.stringify(model), modelBefore);
|
||||
assert.equal(JSON.stringify(result), rawBefore);
|
||||
const excellent = JSON.parse(JSON.stringify(model));
|
||||
|
||||
@@ -9,6 +9,7 @@ const base = process.env.H5_BASE_URL || 'http://127.0.0.1:8080/';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
|
||||
page.setDefaultTimeout(60000); // Cold public-site font downloads can exceed 30s.
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
await page.goto(base + '?mock=1&screen=report');
|
||||
@@ -53,8 +54,9 @@ try {
|
||||
await page.click('#p-zoom');
|
||||
await page.waitForSelector('#p-zoom.show', { state: 'hidden' });
|
||||
await page.waitForTimeout(300);
|
||||
assert((await page.locator('#ditem-para2').textContent()).includes('抬头纹(额头纹)'));
|
||||
assert((await page.locator('#ditem-para2').textContent()).includes('右法令纹'));
|
||||
const wrinkleSummary = await page.locator('#ditem-para2').textContent();
|
||||
assert(wrinkleSummary.includes('面中、口周、眼周') || wrinkleSummary.includes('面部整体平整'));
|
||||
assert(!wrinkleSummary.includes('分区明细') && !wrinkleSummary.includes('标准化长度'));
|
||||
assert((await page.locator('#smooth-map-note').textContent()).includes('毛孔'));
|
||||
const focused = await page.locator('.ditem-img').evaluateAll(images => images.map(i => ({ focused: i.dataset.crop, ratio: i.naturalWidth / i.naturalHeight })));
|
||||
assert(focused.every(i => i.focused === 'face' && Math.abs(i.ratio - 215 / 278) < .005));
|
||||
@@ -106,5 +108,5 @@ try {
|
||||
await page.unroute('**/assets/mock/report-fixture.js');
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: real mobile swipes in three independent groups, vertical scroll vs swipe, click/keyboard tabs, no zoom after swipe, zoom still works, seven face-focused maps, 12-region copy, fixed fonts/all three product lines in five widths, partial map caption, PSD yellow, no JS errors');
|
||||
console.log('PASS: real mobile swipes in three independent groups, vertical scroll vs swipe, click/keyboard tabs, no zoom after swipe, zoom still works, seven face-focused maps, approved wrinkle summary without technical appendix, fixed fonts/all three product lines in five widths, partial map caption, PSD yellow, no JS errors');
|
||||
} finally { await browser.close(); }
|
||||
|
||||
@@ -95,7 +95,7 @@ try {
|
||||
if (artifacts) await page.screenshot({ path: path.join(artifacts, 'browser-home-status.png') });
|
||||
await page.click('[data-action="history"]');
|
||||
await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('次'));
|
||||
assert.equal(await page.locator('#s-history [data-auth-status]').textContent(), '本机浏览器已登录');
|
||||
assert.equal(await page.locator('#s-history [data-auth-status]').textContent(), '游客已登录');
|
||||
if (artifacts) await page.screenshot({ path: path.join(artifacts, 'browser-history-status.png') });
|
||||
await page.click('#s-history [data-action="switch-login"]');
|
||||
assert.equal(await page.locator('#s-login.active').count(), 1);
|
||||
|
||||
@@ -21,7 +21,7 @@ await page.click('[data-action="login-browser"]');
|
||||
assert(await page.locator('#s-login').evaluate(e => e.classList.contains('active')));
|
||||
await page.check('#login-consent');
|
||||
await page.click('[data-action="login-wechat"]');
|
||||
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('本机浏览器'));
|
||||
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('游客'));
|
||||
await page.click('[data-action="login-browser"]');
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await page.waitForSelector('#p-login-success.show');
|
||||
|
||||
@@ -59,8 +59,8 @@ try {
|
||||
async function confirmLogin(page, kind) {
|
||||
await page.waitForSelector('#p-login-success.show');
|
||||
assert.equal(await page.locator('#login-success-title').textContent(), '登录成功');
|
||||
assert((await page.locator('#login-success-message').textContent()).includes(kind === 'wechat' ? '当前使用:微信登录' : '当前使用:本机浏览器登录'));
|
||||
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), kind === 'wechat' ? '微信已登录' : '本机浏览器已登录');
|
||||
assert((await page.locator('#login-success-message').textContent()).includes(kind === 'wechat' ? '当前使用:微信登录' : '当前使用:游客登录'));
|
||||
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), kind === 'wechat' ? '微信已登录' : '游客已登录');
|
||||
if (process.env.H5_ARTIFACT_DIR) {
|
||||
fs.mkdirSync(process.env.H5_ARTIFACT_DIR, { recursive: true });
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Split the original PSD scan artwork for deterministic, code-driven motion."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
from psd_tools import PSDImage
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("psd", type=Path)
|
||||
args = parser.parse_args()
|
||||
psd = PSDImage.open(args.psd)
|
||||
page = psd[9]
|
||||
if page.name != "扫描" or page[2][1].name != "矩形 661":
|
||||
raise ValueError("Unexpected scan layout; nothing exported")
|
||||
left, top, right, bottom = page.bbox
|
||||
viewport = (left, top + 248, right, bottom)
|
||||
source = page[2][2]
|
||||
source.visible = False
|
||||
base = page.composite(viewport=viewport).convert("RGB")
|
||||
# Recover the connected white face from the unmodified cached PSD pixels.
|
||||
# Disconnected frame/text/icon whites must never become part of the mask.
|
||||
candidate = Image.eval(base.convert("L"), lambda v: 255 if v > 245 else 0)
|
||||
ImageDraw.floodfill(candidate, (540, 720), 128)
|
||||
mask = Image.eval(candidate, lambda v: 255 if v == 128 else 0)
|
||||
if mask.getbbox() is None or mask.getpixel((540, 1200)) != 255:
|
||||
raise ValueError(f"PSD white face was lost to cross-layer clipping: bbox={mask.getbbox()}, seed={base.getpixel((540, 720))}, lower={base.getpixel((540, 1200))}")
|
||||
target = ROOT / "h5/assets/img/ui-20261012"
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
alpha_mask = Image.new("RGBA", mask.size, (255, 255, 255, 0))
|
||||
alpha_mask.putalpha(mask)
|
||||
assets = []
|
||||
for name, image in [("scan-base.webp", base), ("scan-face-mask.png", alpha_mask)]:
|
||||
output = target / name
|
||||
if output.suffix == ".webp":
|
||||
image.save(output, lossless=True, method=6)
|
||||
else:
|
||||
image.save(output, optimize=True)
|
||||
data = output.read_bytes()
|
||||
assets.append({"file": str(output.relative_to(ROOT)), "size": list(image.size),
|
||||
"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()})
|
||||
print(f"Exported {name}: {image.size}, {len(data)} bytes", flush=True)
|
||||
digest = hashlib.sha256()
|
||||
with args.psd.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(8 * 1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
manifest = {"source": str(args.psd), "source_sha256": digest.hexdigest(),
|
||||
"source_page": "9", "hidden_scan_layer": "9/2/2", "viewport": list(viewport),
|
||||
"face_bbox": list(mask.getbbox()), "cycle_ms": 1000, "yellow": "#F5F078",
|
||||
"assets": assets}
|
||||
design = ROOT / "design/ui-20261012"
|
||||
(design / "scan-motion-manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user