diff --git a/docs/语音播报内容设计文档_V1.0.md b/docs/语音播报内容设计文档_V1.0.md index db59aca..734322c 100644 --- a/docs/语音播报内容设计文档_V1.0.md +++ b/docs/语音播报内容设计文档_V1.0.md @@ -20,10 +20,10 @@ | 文件名 | 文案 | 触发时机 | 时长 | |---|---|---|---| | `step1-front.mp3` | 请单人正脸出镜,面部对准取景框 | 进入拍照页 1/3 正脸步 | ≈3s | -| `step2-rightface.mp3` | 请保持单人出镜,慢慢向左转头,露出右脸 | 进入 2/3 右脸出镜步 | ≈4s | -| `step3-leftface.mp3` | 请慢慢向右转头,露出左脸 | 进入 3/3 左脸出镜步 | ≈3s | +| `step2-leftface.mp3` | 请慢慢向右转头,露出左脸 | 进入 2/3 左脸出镜步 | ≈3s | +| `step3-rightface.mp3` | 请保持单人出镜,慢慢向左转头,露出右脸 | 进入 3/3 右脸出镜步 | ≈4s | -> 语义说明:2/3 步需要画面中看到「右脸」= 用户头向**左**转;3/3 步看到「左脸」= 头向**右**转。语音引导的是用户动作,与取景蒙版方向一致,不要改写成"露出左侧/右侧脸"这类歧义表述。 +> 语义说明:拍摄顺序为正脸 → 左脸 → 右脸。2/3 步需要画面中看到「左脸」= 用户头向**右**转;3/3 步看到「右脸」= 头向**左**转。语音引导的是用户动作,与取景蒙版方向一致,不要改写成"露出左侧/右侧脸"这类歧义表述。 ### B. 姿态提示(实时检测不通过时播,同一提示 4 秒内不重复) @@ -32,9 +32,9 @@ | `hint-noface.mp3` | 未检测到人脸,请正对镜头 | 画面无人脸 | ≈3s | | `hint-near.mp3` | 请靠近一点 | 人脸框过高占比 <35%(太远) | ≈1.5s | | `hint-far.mp3` | 请远一点 | 占比 >80%(太近) | ≈1.5s | -| `hint-front.mp3` | 请正对屏幕 | 正脸步角度不合格 | ≈1.5s | -| `hint-turn-left.mp3` | 请向左转头 | 需要头向左转修正角度时 | ≈1.5s | -| `hint-turn-right.mp3` | 请向右转头 | 需要头向右转修正角度时 | ≈1.5s | +| `hint-front.mp3` | 请正对屏幕 | 正脸步角度不合格(正脸步一律只提示回正,不播转头/抬头低头) | ≈1.5s | +| `hint-turn-left.mp3` | 请向左转头 | 仅侧脸步:需要头向左转修正角度时 | ≈1.5s | +| `hint-turn-right.mp3` | 请向右转头 | 仅侧脸步:需要头向右转修正角度时 | ≈1.5s | | `hint-hold.mp3` | 保持不动 | 姿态已合格、进入稳定确认 | ≈1.5s | ### C. 拍摄与流程 @@ -51,7 +51,7 @@ 1. **清单制**:语音文件配 `assets/voice/manifest.json`(格式 `{"files":["step1-front","hint-near",...]}`,内容为已生成的文件名去 .mp3)。前端只播清单里声明的条目;清单或条目缺失时该条静默跳过——语音文件可分批补齐,每次补齐后更新清单。 2. **浏览器自动播放限制**:首次播放必须发生在用户手势链路里。前端在任意按钮点击时初始化并 resume WebAudio(点击「开始检测/继续/开始」即完成解锁),后续步骤提示均为程序触发。 3. **同条去重**:同一文件名 4 秒内不重复播(姿态提示会随检测帧反复触发)。 -4. **不叠加**:新提示开播前停掉上一条未播完的(单通道),倒计时与拍摄完成音除外(允许叠在提示音上)。 +4. **不叠加**:新提示开播前停掉上一条未播完的(单通道);例外:倒计时「三、二、一」不在播报中途拦腰切入——姿态达标后先等「保持不动」等在播提示音自然收尾,再同步起播倒计时语音与画面数字(`voice.afterCurrent` 接续,期间姿态被破坏则取消倒计时)。 5. **不打断原则**:分析中/失败提示开播时,拍照页已离开,步骤与姿态提示不再触发。 6. **静音开关**:V1.0 暂不提供 UI 开关,跟随系统音量与静音键;后续如加开关,在拍照页右上角放小喇叭图标,状态存 localStorage。 diff --git a/h5/README.md b/h5/README.md index 2bc2538..9e5b532 100644 --- a/h5/README.md +++ b/h5/README.md @@ -51,13 +51,15 @@ node server.js # 默认 8080,可用 PORT=xxx 修改 ## 拍照交互(引导式自动三连拍) -三步:1/3 正脸(target `front`)→ 2/3 右脸出镜(用户头向左转,target `right`,产物作 `right_image`)→ 3/3 左脸出镜(头向右转,target `left`,产物作 `left_image`)。 +三步:1/3 正脸(target `front`)→ 2/3 左脸出镜(用户头向右转,target `left`,产物作 `left_image`)→ 3/3 右脸出镜(头向左转,target `right`,产物作 `right_image`)。 - **步骤圆点**:顶部三点,当前步高亮、完成步绿色打勾。 -- **取景蒙版**:内联 SVG(`#cam-mask`)按步骤切换轮廓——正脸步为正脸线框(含耳部),侧脸步为对应方向的侧脸剪影(鼻朝向与转头方向一致);框外 58% 黑色半透明压暗(SVG mask 镂空),轮廓线 2.5px 半透明白。未检测到人脸时轮廓呼吸动画(1.8s);姿态 ok 时轮廓变绿并停止动效。 -- **实时检测**:face-api 从本地 `vendor/faceapi/` 加载(`tinyFaceDetector` + `faceLandmark68Net`)。进入拍照页即开始加载模型(状态提示"检测引擎加载中…"),加载完成前快门为手动模式。检测循环 ~150ms 一帧,`detectSingleFace(video, TinyFaceDetectorOptions({inputSize:416, scoreThreshold:0.3})).withFaceLandmarks()` 后拿 68 特征点调 `HeadPose.check(pos, target, /* mirrored= */true)`;连续漏检 ≥3 帧才报"未检测到人脸"(容忍单帧抖动);人脸框高度占比 <26% 提示"请靠近一点"、>58% 提示"请远一点"(以取景框≈42% 屏高标定)。提示一次一条,优先级:无人脸 > 距离 > 角度。 -- **倒计时自动拍**:姿态 ok 连续维持 ≥600ms 进入 3s 倒计时(中央大数字,轮廓保持绿色);倒计时中任一帧不 ok 立即取消并回到对应提示。倒计时结束进入白屏补光。 -- **白屏补光**:全屏纯白覆盖层瞬间点亮,~300ms 后抓全分辨率帧(镜像 + 最长边 1280 JPEG),保持 ~300ms 再淡出(总 600–900ms),同步 `navigator.vibrate(30)`。 +- **取景蒙版**:内联 SVG(`#cam-mask`)按步骤切换轮廓——正脸步为正脸剪影(含耳部),侧脸步为对应方向的侧脸剪影(鼻朝向与转头方向一致);**轮廓路径由 Figma 拍照示意图(`design/assets/images/*-拍照示意.png` 的 alpha 镂空区)逐像素描摹生成**,等比缩放到 375×812 viewBox;框外纯黑不透明(SVG mask 镂空,对齐 Figma:只有轮廓内显示实时画面),轮廓线 2.5px 半透明白。未检测到人脸时轮廓呼吸动画(1.8s);姿态 ok 时轮廓变绿并停止动效。 +- **正脸步提示口径**:正脸步角度不合格一律只提示「请正对屏幕」(不再提示左右转头/抬头低头),阈值 |yaw|≤15°、|pitch|≤18°(2026-09-28 产品要求放宽);侧脸步仍按方向提示「请向左/右转头」。 +- **实时检测**:face-api 从本地 `vendor/faceapi/` 加载(`tinyFaceDetector` + `faceLandmark68Net`)。进入拍照页即开始加载模型(状态提示"检测引擎加载中…"),加载完成前快门为手动模式。检测循环 ~150ms 一帧,`detectSingleFace(video, TinyFaceDetectorOptions({inputSize:416, scoreThreshold:0.3})).withFaceLandmarks()` 后拿 68 特征点调 `HeadPose.check(pos, target, mirrored)`——mirrored 正脸步跟随镜头(前置 true/后置 false),侧脸步恒 false(预览不镜像,特征点即原始视角;个别前置帧已镜像的机型由侧脸自校准翻转 `sideYawFlip`,只动判定不动画面);连续漏检 ≥3 帧才报"未检测到人脸"(容忍单帧抖动);人脸框高度占比 <26% 提示"请靠近一点"、>58% 提示"请远一点"(以取景框≈42% 屏高标定)。提示一次一条,优先级:无人脸 > 距离 > 角度。 +- **倒计时自动拍**:姿态 ok 连续维持 ≥600ms 后,先等「保持不动」等在播提示音自然收尾(画面保持绿框+保持不动),再进 3s 倒计时(中央大数字,轮廓保持绿色,数字与「三、二、一」语音同步起播,不再拦腰切断);倒计时中任一帧不 ok 立即取消并回到对应提示。倒计时结束进入白屏补光。 +- **镜像策略**:正脸步前置镜像(自拍习惯),侧脸步预览与抓帧均不镜像(所见即摄像头原始视角,轮廓鼻朝向与转头方向一致,左右不再迷惑);后置镜头全程不镜像。 +- **白屏补光**:全屏纯白覆盖层瞬间点亮,~300ms 后抓全分辨率帧(与预览同镜像策略 + 最长边 1280 JPEG),保持 ~300ms 再淡出(总 600–900ms),同步 `navigator.vibrate(30)`。 - **手动兜底**:快门任何时刻可拍当前步(同样走白屏补光),不依赖检测;face-api/模型加载失败 → toast"自动识别不可用,已切换手动拍摄",全程手动。系统相机降级模式(getUserMedia 不可用/被拒)维持原样:无检测、无补光。 - **相册选图**:相机页「相册」按钮可选当前步的图片(任何模式下可用),与实拍同管线(压 1280 → 推进流程)。 - **语音播报**:步骤引导/姿态提示/倒计时/拍摄完成/分析中/失败共 14 条,文案与文件名契约见 `../docs/语音播报内容设计文档_V1.0.md`。WebAudio 单通道播放、同条 4s 去重;`assets/voice/manifest.json` 清单制,**语音文件待生成**——清单未提供时全部静默跳过。 @@ -79,7 +81,7 @@ node server.js # 默认 8080,可用 PORT=xxx 修改 - 报告底部:降级标注(`vm.notes` 非空才展示,如下颌线未参与评分)、综合分析(`vm.summary`)、生活方式建议(`vm.lifestyle`)、精华推荐卡(`vm.essence` + 触发判据); - 近期优先关注 = 最差维度(`vm.focus`);6 分类各配检测可视化图:轮廓=正脸实拍、皱纹=正脸压暗+`texture_enhanced_lines` 皱纹增强线(线图层几乎全透明,直挂白底不可读,故与压暗沉脸合成)、光滑=正脸+色斑/痣/痤疮标记叠加、肤色=`brown_area` 色沉图、敏感=`red_area` 红区图、痘痘=正脸+闭口粉刺/痤疮标记叠加(marks 按原像素坐标叠加到正脸实拍)。 5. 真值回归:`node h5/test/regression.js` 以 `docs/皮肤检测测试结果_三视图_20260926.xlsx`(sheet「三视图检测汇总」,数据第 3/4 行=案例1/案例2)两组三视图真值对照评分引擎输出。 -6. **报告历史**:检测成功后按「检测」为单位存入 localStorage(`juzi.reports.v1`,上限 5 条,超配额逐条淘汰)——含三张照片、档案、全部评分数据与 marks 坐标,**不含 face_maps 整幅图层**(体积原因;回看时皱纹/肤色/敏感图降级为占位,光滑/痘痘标记叠加仍可用)。报告页导航栏「历史」进入列表,可回看/删除单条/清空。 +6. **报告历史**:检测成功后按「检测」为单位存入 localStorage(`juzi.reports.v1`,上限 5 条,超配额逐条淘汰)——含三张照片、档案、全部评分数据与 marks 坐标,**不含 face_maps 整幅图层**(体积原因;回看时皱纹/肤色/敏感图降级为占位,光滑/痘痘标记叠加仍可用)。当前无 UI 入口(第9轮移除),数据仅作本地追溯。 7. **原始返回落盘**:服务端每次检测成功后把 Face++ 完整原始返回写入 `h5/data/results/<时间戳>-.json`(复核/真值用,已 .gitignore;写盘失败不影响响应)。 8. 检测失败按服务端 message 提示并退回档案页。 @@ -135,5 +137,5 @@ server { - 尺寸换算:设计稿 1080px 基准 ÷ 2.88 = 375px 落地基准。 - 图片性能:首屏背景 preload;首屏外 `loading="lazy"`。 -- 快门防连点/双流程令牌(camSeq/camSeqCam)防竞态;拍照页 15% 压暗遮罩 + 蒙版 58% 镂空压暗保证白字可读。 +- 快门防连点/双流程令牌(camSeq/camSeqCam)防竞态;拍照页取景框外纯黑(对齐 Figma),白字直接落在黑色上。 - face-api.js 0.22.2 与模型全部本地化(`vendor/faceapi/`,约 1.4MB),运行时零外部请求,微信 webview / iOS Safari 弱网与内网部署可用;许可证(MIT / Apache-2.0)见 `vendor/faceapi/README.md`。 diff --git a/h5/assets/img/cam-left.jpg b/h5/assets/img/cam-left.jpg index 3f613bd..92257d2 100644 Binary files a/h5/assets/img/cam-left.jpg and b/h5/assets/img/cam-left.jpg differ diff --git a/h5/assets/img/cam-right.jpg b/h5/assets/img/cam-right.jpg index 92257d2..3f613bd 100644 Binary files a/h5/assets/img/cam-right.jpg and b/h5/assets/img/cam-right.jpg differ diff --git a/h5/assets/svg/prod-badge.svg b/h5/assets/svg/prod-badge.svg new file mode 100644 index 0000000..532006b --- /dev/null +++ b/h5/assets/svg/prod-badge.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/h5/assets/voice/manifest.json b/h5/assets/voice/manifest.json index db39a8f..14a976e 100644 --- a/h5/assets/voice/manifest.json +++ b/h5/assets/voice/manifest.json @@ -1,8 +1,8 @@ { "files": [ "step1-front", - "step2-rightface", - "step3-leftface", + "step2-leftface", + "step3-rightface", "hint-noface", "hint-near", "hint-far", diff --git a/h5/assets/voice/step3-leftface.mp3 b/h5/assets/voice/step2-leftface.mp3 similarity index 100% rename from h5/assets/voice/step3-leftface.mp3 rename to h5/assets/voice/step2-leftface.mp3 diff --git a/h5/assets/voice/step2-rightface.mp3 b/h5/assets/voice/step3-rightface.mp3 similarity index 100% rename from h5/assets/voice/step2-rightface.mp3 rename to h5/assets/voice/step3-rightface.mp3 diff --git a/h5/css/style.css b/h5/css/style.css index 7dbde51..d4421c7 100644 --- a/h5/css/style.css +++ b/h5/css/style.css @@ -212,8 +212,8 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; } } #s-cam.cam-live #cam-video { display: block; } #s-cam.cam-live .cam-bg { display: none; } -/* 15% 黑遮罩,压住画面保证白色文案可读(静态示意图与实时画面共用) */ -#s-cam::before { content: ""; position: absolute; inset: 0; background: rgba(0,0,0,.15); z-index: 1; } +/* 设计稿(Figma 05-07):取景框外全黑,仅轮廓镂空区显示实时画面,不再整屏压暗 */ +#s-cam::before { content: none; } #s-cam .nav { position: fixed; left: 0; right: 0; } .cam-step { position: absolute; left: 0; right: 0; top: 16.5%; @@ -266,7 +266,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; } text-align: center; font-size: 8px; line-height: 10px; color: #fff; } -/* 取景蒙版:SVG mask 镂空压暗(58%) + 轮廓线框,按 data-step 切换轮廓 */ +/* 取景蒙版:SVG mask 镂空 + 轮廓线框,按 data-step 切换轮廓;框外纯黑不透明(对齐 Figma:只有轮廓内显示画面) */ .cam-mask { position: absolute; inset: 0; width: 100%; height: 100%; @@ -276,7 +276,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; } .cam-mask[data-step="0"] [data-shape="0"], .cam-mask[data-step="1"] [data-shape="1"], .cam-mask[data-step="2"] [data-shape="2"] { display: block; } -.cam-dim-rect { fill: rgba(0,0,0,.58); } +.cam-dim-rect { fill: #000; } /* 框外全黑(Figma 设计):摄像头画面只在轮廓镂空区可见 */ .cam-line path { fill: none; stroke: rgba(255,255,255,.85); stroke-width: 2.5; } /* 未检测到人脸:轮廓呼吸动画(~1.8s);姿态 ok:品牌绿定格 */ @keyframes maskBreath { 0%,100% { stroke-opacity: .3; } 50% { stroke-opacity: .95; } } @@ -331,88 +331,114 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; } } .cam-nudge.show { opacity: 1; transform: translateX(-50%); } -/* ═══════════ 08 报告页 ═══════════ */ -.report-body { padding: 12px 38px 34px; } +/* ═══════════ 08 报告页(Figma 2:9 严格还原:设计稿 1080 基准 ÷2.88) ═══════════ */ +.report-body { padding: 0 38px 20px; } +/* 顶栏:白底通栏横带,左返回圆钮 + 居中黑标题(设计 255px 高通栏含手机状态栏,H5 只保留标题栏) */ +.report-nav { + position: relative; + margin: 0 -38px; /* 通栏:抵消 body 侧 padding */ + height: calc(44px + env(safe-area-inset-top)); + padding-top: env(safe-area-inset-top); + background: #fff; + display: flex; align-items: center; justify-content: center; +} +.report-nav .nav-back { + position: absolute; left: 8px; + top: calc(50% + env(safe-area-inset-top) / 2); transform: translateY(-50%); +} +.report-nav-title { font-size: 14px; font-weight: 500; color: #000; letter-spacing: .5px; } .report-title { - font-size: 20px; font-weight: 700; color: var(--ink); - text-align: center; margin: 10px 0 16px; + font-size: 20.6px; font-weight: 700; color: var(--ink); + text-align: center; margin: 20px 0 13px; letter-spacing: .5px; } .card { - background: #fff; border-radius: 6px; - padding: 14px 15px; - margin-bottom: 9px; + background: #fff; border-radius: 10px; + padding: 14px 18px; + margin-bottom: 10px; } -/* 总分 */ -.card-stats { display: flex; padding: 11px 15px; } -.stat { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; } -.stat-label { font-size: 9px; font-weight: 500; color: var(--grey); } -/* label 下短横线(34×1 居中) */ +/* 总分:两张独立白卡(设计 420+420,间距 17px→6px) */ +.stats-row { display: flex; gap: 6px; margin-bottom: 10px; } +.stats-row .card-stat { flex: 1; margin-bottom: 0; } +.card-stat { + display: flex; flex-direction: column; align-items: center; + padding: 12px 10px 13px; +} +.stat-label { font-size: 8.7px; font-weight: 500; color: var(--grey); } +/* label 下短横线(设计 99px→34px 居中) */ .stat-label::after { content: ""; display: block; width: 34px; height: 1px; background: #EFEFEF; margin: 4px auto 0; } -.stat-value { font-size: 29px; font-weight: 400; color: var(--ink); letter-spacing: 1px; line-height: 34px; } -/* 维度(灰底独立文字,置于总分卡与维度卡之间) */ -.dims-intro { font-size: 10px; font-weight: 500; color: #464646; line-height: 1.6; margin: 0 2px 9px; } +/* 大数字 30px/400 + 单位 15px/500(设计 87px 与 43.7px) */ +.stat-value { margin-top: 2px; line-height: 40px; color: var(--ink); } +.stat-value b { font-size: 30px; font-weight: 400; letter-spacing: 1px; } +.stat-value i { font-style: normal; font-size: 15px; font-weight: 500; } +/* 三维度卡(设计:标签 8.7px 灰 / 分数 28.9px lh50 / 竖向短分隔线 21px / 最低分标红) */ +.card-dims { padding: 17px 19px 15px; } .dims-row { display: flex; } -.dim { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; } -.dim + .dim { border-left: 1px solid #EFEFEF; } -.dim-name { font-size: 9px; font-weight: 500; color: var(--grey); } -.dim-score { font-size: 29px; font-weight: 500; color: var(--ink); line-height: 1.1; } -.dim-lv { font-size: 9px; font-weight: 500; min-height: 11px; } -.dims-note { font-size: 9px; font-weight: 500; color: var(--grey); line-height: 1.55; margin-top: 9px; text-align: center; } -.dims-disclaimer { font-size: 7px; font-weight: 400; color: var(--grey-3); margin: 0 2px 9px; line-height: 1.5; } -/* 具体描述 */ -.detail-tabs { - display: flex; - border-bottom: 1px solid #F0F0F0; - margin-bottom: 12px; -} -.dtab { - flex: 1; padding: 8px 0 9px; - font-size: 10px; font-weight: 500; color: var(--ink); - position: relative; +.dim { flex: 1; position: relative; display: flex; flex-direction: column; align-items: center; } +.dim + .dim::before { + content: ""; position: absolute; left: 0; top: 37px; + width: 1px; height: 21px; margin-top: -10.5px; background: #EFEFEF; } +.dim-name { font-size: 8.7px; font-weight: 500; color: var(--grey); } +.dim-score { font-size: 28.9px; font-weight: 500; color: var(--ink); line-height: 50px; } +.dim-score.dim-bad { color: var(--bad); } /* 最低分标红 */ +/* 关注行(设计 fs25→8.7px:前缀灰 + 维度名深色大一号;下行深色口号) */ +.dims-note { margin-top: 2px; text-align: center; line-height: 1.5; } +.dims-note-l1 { font-size: 8.7px; font-weight: 500; color: #858585; } +.dims-note-l1 b { font-size: 10.1px; font-weight: 500; color: var(--ink); } +.dims-note-l2 { font-size: 8.7px; font-weight: 500; color: var(--ink); } +/* 具体描述(Tab 10.1px,选中 700 + 78×7 黄条;竖排条目标题在图左;图文垂直居中) */ +.card-detail { padding: 0 19px 19px; } +.detail-tabs { display: flex; border-bottom: 1px solid #F0F0F0; } +.dtab { flex: 1; padding: 13px 0 0; font-weight: 500; color: var(--ink); } +.dtab-tx { position: relative; display: inline-block; font-size: 10.1px; padding: 0 5px 7px; } .dtab.active { font-weight: 700; } -.dtab.active::after { - content: ""; position: absolute; - left: 50%; transform: translateX(-50%); bottom: -1px; - width: 78px; height: 7px; border-radius: 3.5px; - background: var(--yellow); +.dtab.active .dtab-tx::after { + content: ""; position: absolute; left: 50%; transform: translateX(-50%); bottom: -1px; + width: 78px; height: 7px; border-radius: 3.5px; background: var(--yellow); } .dpanel { display: none; } -.dpanel.active { display: block; animation: fade .25s ease; } +.dpanel.active { display: block; animation: fade .25s ease; padding-top: 19px; } @keyframes fade { from { opacity: 0; } to { opacity: 1; } } -.ditem { display: flex; gap: 11px; align-items: flex-start; } -.ditem + .ditem { margin-top: 13px; padding-top: 13px; border-top: 1px solid #F0F0F0; } -.ditem-img { width: 92px; height: 96px; object-fit: cover; border-radius: 4px; flex-shrink: 0; } -.ditem-text { flex: 1; } +.ditem { display: flex; gap: 9px; align-items: center; } +.ditem + .ditem { margin-top: 18px; padding-top: 18px; border-top: 1px solid #F0F0F0; } +.ditem-img { width: 92px; height: 96px; object-fit: cover; border-radius: 6px; flex-shrink: 0; } +.ditem-text { flex: 1; min-width: 0; } /* 检测条目标题:竖排,位于图左(设计稿如此) */ .ditem-title { writing-mode: vertical-rl; letter-spacing: 4px; font-size: 11.5px; font-weight: 500; color: var(--ink); - margin-right: 12px; flex-shrink: 0; + margin-right: 3.5px; flex-shrink: 0; +} +.ditem-para { font-size: 10.1px; font-weight: 400; color: var(--para); line-height: 1.41; overflow-wrap: anywhere; } +/* 护理建议(标题 13px/700/#000;条目 10.1px #4A4A4A lh1.43;收尾句 11.1px/500/#000) */ +.card-care, .card-essence { padding: 18px 20px 16px 19px; } +.card-heading { font-size: 13px; font-weight: 700; color: #000; margin-bottom: 4px; } +.care-item { font-size: 10.1px; color: var(--para); line-height: 1.43; margin-bottom: 7px; overflow-wrap: anywhere; } +.care-summary { font-size: 11.1px; font-weight: 500; color: #000; margin-top: 5px; } +/* 精华推荐(Figma 无此卡,按业务文档 V2.3 §6.4 保留,样式沿用护理建议卡) */ +.essence-name { font-size: 12px; font-weight: 700; color: var(--ink); margin-bottom: 5px; } +.essence-triggers { font-size: 7px; color: var(--grey-3); margin-top: 6px; } +/* 产品推荐:大标题独立落在灰底;白卡左文案右图(图 151×174 撑满卡高,COREⅡ 为 Figma 导出字标 SVG) */ +.prod-title { + font-size: 21.6px; font-weight: 700; color: var(--ink); + line-height: 1.5; text-align: center; margin: 15px 0 16px; } -.ditem-para { font-size: 10px; font-weight: 400; color: var(--para); line-height: 1.45; overflow-wrap: anywhere; } -/* 护理建议 */ -.card-heading { font-size: 13px; font-weight: 700; color: #000; margin-bottom: 8px; } -.care-item { font-size: 10px; color: var(--para); line-height: 1.6; margin-bottom: 8px; overflow-wrap: anywhere; } -.care-summary { font-size: 11px; font-weight: 500; color: #000; margin-top: 10px; } -/* 产品推荐:白底圆角卡,左侧文案 + 右侧单图(图高撑卡) */ -.prod-title { font-size: 21px; font-weight: 700; color: var(--ink); line-height: 1.45; text-align: center; margin: 4px 0 12px; } .card-product { padding: 0; overflow: hidden; } .prod-card { display: flex; align-items: stretch; } .prod-info { flex: 1; min-width: 0; - padding: 18px 12px 18px 16px; - display: flex; flex-direction: column; justify-content: center; gap: 7px; + padding: 44px 10px 16px 16px; + display: flex; flex-direction: column; align-items: flex-start; } -.prod-badge { font-size: 8px; font-weight: 500; letter-spacing: 3px; color: #B9B9B9; } -.prod-name { font-size: 13px; font-weight: 700; color: var(--ink); line-height: 1.4; overflow-wrap: anywhere; } -.prod-sub { font-size: 7.5px; font-weight: 500; color: var(--grey-3); } +.prod-badge { width: 39px; height: auto; } +.prod-name { font-size: 13px; font-weight: 700; color: var(--ink); line-height: 1.34; margin-top: 9px; overflow-wrap: anywhere; } +.prod-sub { font-size: 7.3px; font-weight: 500; color: var(--grey-3); margin-top: auto; padding-top: 10px; } .prod-scroll { flex-shrink: 0; margin: 0; padding: 0; display: flex; overflow: hidden; @@ -420,15 +446,15 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; } } .prod-scroll::-webkit-scrollbar { display: none; } .prod-scroll img { - width: 168px; height: 225px; + width: 151px; height: 174px; object-fit: cover; flex-shrink: 0; - border-radius: 0 6px 6px 0; } -.btn-more { margin: 16px auto 0; } +.btn-more { margin: 20px auto 0; } +/* 免责小字:灰底独立文字,左对齐到卡片文案位(设计 x=166→57.6px;fw300 #000) */ .report-disclaimer { - font-size: 7px; font-weight: 300; color: var(--grey-3); - line-height: 1.7; text-align: center; - margin: 5px 2px 9px; /* 灰底独立文字,位于描述卡与护理建议卡之间 */ + font-size: 7.3px; font-weight: 300; color: #000; + line-height: 1.44; text-align: left; + margin: 13px 19px 11px; } /* ═══════════ 弹窗 ═══════════ */ @@ -636,23 +662,6 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; } } .overlay-policy .policy-page { position: relative; } -/* #8 维度卡关注行:灰小字 + 深色口号(对齐设计稿) */ -.dims-note { margin-top: 12px; } -.dims-note-l1 { font-size: 10px; font-weight: 400; color: var(--grey); } -.dims-note-l2 { font-size: 11.5px; font-weight: 500; color: var(--ink); line-height: 1.8; } - -/* #9 Tab 黄色指示条:宽度跟随文字(原固定 78px 过宽) */ -.dtab { padding: 8px 0 0; } -.dtab.active::after { content: none; } -.dtab-tx { position: relative; display: inline-block; padding: 0 5px 9px; } -.dtab.active .dtab-tx::after { - content: ""; position: absolute; left: 0; right: 0; bottom: -1px; - height: 7px; border-radius: 3.5px; background: var(--yellow); -} - -/* #13 分类卡图片对齐设计稿(约 100px 见方、圆角 10) */ -.ditem-img { width: 100px; height: 100px; border-radius: 10px; } - /* ═══════════ 第7轮:用户测试 agent 反馈 ═══════════ */ /* P1-1 隐私页标题被悬浮返回键遮挡:正文上留返回键位 */ #p-policy .policy-body { padding-top: 52px; } @@ -665,27 +674,7 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; } .rs-list { z-index: 50; } /* #4 无进展提示再上移(取景/拍摄区下方,引导 chips 上方) */ .cam-nudge { bottom: 24%; } -/* #6 报告页顶栏移除,返回/历史改悬浮 */ -#s-report { position: relative; } -.report-float-back { - position: absolute; top: 12px; left: 12px; z-index: 21; - background: #fff; box-shadow: 0 1px 6px rgba(0,0,0,.10); -} -.report-float-history { - position: absolute; top: 12px; right: 12px; z-index: 21; - font-size: 12px; color: var(--grey-2); padding: 7px 10px; - background: #fff; border-radius: 15px; box-shadow: 0 1px 6px rgba(0,0,0,.10); -} -#s-report .report-body { padding-top: 44px; } -/* #8 关注行:前缀灰 + 维度名深色 500 */ -.dims-note-l1 b { font-weight: 500; color: var(--ink); } -/* #9 维度分:最低分标红(替代原档位徽章) */ -.dim-score.dim-bad { color: var(--bad); } -/* #11 护理建议(设计稿样式:标题 + 编号条目) */ -#care-list .care-item { margin-bottom: 10px; } -#care-list .care-item b { font-weight: 600; color: var(--ink); } -/* 护理建议/精华推荐卡标题对齐设计稿(≈16px) */ -.card-care .card-heading, .card-essence .card-heading { font-size: 16px; } +/* #6 报告页头部:按 Figma 08 还原为内联白底顶栏(见报告页主区块),不再悬浮 */ /* ═══════════ 第9轮:真机反馈 ═══════════ */ /* #1 城市下拉浮层再提级,并加实体阴影边界(部分端 stacking 表现不同) */ @@ -697,12 +686,6 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; } font-size: 15px; font-weight: 500; text-shadow: 0 1px 8px rgba(0,0,0,.65); } .cam-hint.ok { background: none; color: #34C759; } -/* #6 Tab 条对齐设计稿:文字 11px、选中 700,黄色指示条恢复设计宽度 78px */ -.dtab-tx { font-size: 11px; padding: 0 5px 10px; } -.dtab.active .dtab-tx::after { left: 50%; right: auto; transform: translateX(-50%); width: 78px; } -/* #7 关注行:维度名加粗 700,两行收紧 */ -.dims-note-l1 b { font-weight: 700; } -.dims-note-l2 { line-height: 1.6; } /* #9 档案页顶栏移除,返回键悬浮(白圆带影,压暗背景上可读) */ .profile-float-back { position: fixed; top: 12px; left: 12px; z-index: 21; diff --git a/h5/index.html b/h5/index.html index 86ba445..3d19907 100644 --- a/h5/index.html +++ b/h5/index.html @@ -90,24 +90,25 @@

1/3
请单人正脸出镜

- +

检测引擎加载中…

@@ -134,18 +135,23 @@
-
+ +
+ + 1'me在线肌肤测试 +

您的肌肤状态表现

-
-
+ +
+
综合评分 - --分 + --分
-
+
肌肤年龄 - --岁 + --岁
@@ -226,6 +232,7 @@

检测完成后显示分析结果。

+

好状态,是内外同调的长期节奏。

@@ -235,11 +242,12 @@

+ +

感受高光时刻
从日常的好状态开始

-

感受高光时刻
从日常的好状态开始

- CORE Ⅱ + CORE Ⅱ

以我自在有势挺括双仓精华液

参透皮下根源 28天校准皮肤状态

diff --git a/h5/js/app.js b/h5/js/app.js index cb0a97c..5590a87 100644 --- a/h5/js/app.js +++ b/h5/js/app.js @@ -168,13 +168,16 @@ var IS_MOCK = /[?&]mock=1/.test(location.search); // mock:不加载 face-api、不跑检测,analyze 直返内置样本 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 同序:[0]正脸 [1]右脸(头左转) [2]左脸(头右转) + var photos = []; // 三连拍(dataURL),与 CAM_STEPS 同序:[0]正脸 [1]左脸(头右转) [2]右脸(头左转) // 约束逐级放宽:facingMode 裸值在部分 Android 抛 OverconstrainedError,先 ideal 再退 video:true,最后才降级 var camFacing = 'user'; // 当前镜头:'user' 前置 | 'environment' 后置(翻转按钮切换) - var camMirrored = true; // 预览是否镜像(前置镜像,后置不镜像) - var mirrorCalibrated = false; // 镜像自校准:本轮是否已翻转过一次(防振荡,一次校准管整个会话) + var camMirrored = true; // 前置镜像开关:仅正脸步生效(自拍习惯);侧脸步始终不镜像(左右不再迷惑,与取景轮廓方向一致) + var sideYawFlip = false; // 侧脸步语义 yaw 反向标志:个别机型前置帧本身已镜像时由自校准翻转 + var mirrorCalibrated = false; // 侧脸自校准:本轮是否已翻转过一次(防振荡,一次校准管整个会话) var oppFrames = 0; // 侧脸步语义 yaw 连续反向帧计数 + // 预览/抓帧是否镜像:正脸步跟随 camMirrored,侧脸步恒不镜像 + function effMirror() { return camMirrored && CAM_STEPS[camStep].target === 'front'; } function getCamStream() { return navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: camFacing }, width: { ideal: 1280 }, height: { ideal: 1280 } }, @@ -207,7 +210,7 @@ if (token !== camSeqCam) { stream.getTracks().forEach(function (t) { t.stop(); }); return; } // 期间已退出拍照页:立即释放 camStream = stream; camVideo.srcObject = stream; - camVideo.classList.toggle('mirror', camMirrored); + camVideo.classList.toggle('mirror', effMirror()); // iOS 来电/切后台等系统打断会结束 track:挂 ended 监听走自动重连 stream.getTracks().forEach(function (t) { t.onended = function () { onCamTrackEnded(token); }; @@ -247,22 +250,18 @@ resetDetectState(); // 降级无检测、无补光:蒙版回到静态线框 toast('已切换为系统相机拍摄'); } - // 从视频帧截取照片(镜像绘制,与预览一致;压到最长边 1280 的 JPEG) + // 从视频帧截取照片(完整帧,不镜像侧脸步;压到最长边 1280 的 JPEG) + // 抓完整帧而非取景框可见区:新 UI 轮廓外全黑,用户只关注轮廓内人脸;按可见区裁切在侧脸/偏站位时会切掉人脸(Face++ INVALID_IMAGE_FACE) function captureFromVideo() { var vw = camVideo.videoWidth, vh = camVideo.videoHeight; if (!vw || !vh) return null; - // 只取取景框可见区域(object-fit:cover 的中心裁切):照片 = 用户当时看到的画面 - var box = camVideo.getBoundingClientRect(); - var scaleFit = Math.max(box.width / vw, box.height / vh); - var visW = Math.min(vw, box.width / scaleFit), visH = Math.min(vh, box.height / scaleFit); - var sx = (vw - visW) / 2, sy = (vh - visH) / 2; - var scale = Math.min(1, 1280 / Math.max(visW, visH)); - var cw = Math.round(visW * scale), ch = Math.round(visH * scale); + var scale = Math.min(1, 1280 / Math.max(vw, vh)); + var cw = Math.round(vw * scale), ch = Math.round(vh * scale); var canvas = document.createElement('canvas'); canvas.width = cw; canvas.height = ch; var ctx = canvas.getContext('2d'); - if (camMirrored) { ctx.translate(cw, 0); ctx.scale(-1, 1); } // 前置镜像,与预览一致 - ctx.drawImage(camVideo, sx, sy, visW, visH, 0, 0, cw, ch); + if (effMirror()) { ctx.translate(cw, 0); ctx.scale(-1, 1); } // 与预览一致:正脸步前置镜像,侧脸步不镜像(照片=摄像头原始视角) + ctx.drawImage(camVideo, 0, 0, vw, vh, 0, 0, cw, ch); return canvas.toDataURL('image/jpeg', 0.88); } // 文件 → 统一压成最长边 1280 的 JPEG → 推进流程(降级系统相机与相册选图共用) @@ -303,7 +302,7 @@ /* ─── 语音播报(WebAudio 单通道;assets/voice/.mp3,缺失静默跳过;同条 4s 去重) 文案/文件名契约:docs/语音播报内容设计文档_V1.0.md ─── */ var VOICE = { - step: ['step1-front', 'step2-rightface', 'step3-leftface'], + step: ['step1-front', 'step2-leftface', 'step3-rightface'], hint: { '未检测到人脸': 'hint-noface', '请靠近一点': 'hint-near', '请远一点': 'hint-far', '请正对屏幕': 'hint-front', '请向左转头': 'hint-turn-left', '请向右转头': 'hint-turn-right', @@ -312,7 +311,7 @@ countdown: 'countdown-321', shot: 'shot-done', analyzing: 'analyzing', fail: 'analyze-fail' }; var voice = (function () { - var ctx = null, lastAt = {}, cur = null, files = null; // files:null=清单未加载;{}=无可用语音 + var ctx = null, lastAt = {}, cur = null, curEnd = 0, files = null; // files:null=清单未加载;{}=无可用语音 // 清单制:只播 assets/voice/manifest.json 里声明的文件,不逐个探文件(避免控制台 404 噪音) fetch('assets/voice/manifest.json') .then(function (r) { return r.ok ? r.json() : { files: [] }; }) @@ -333,15 +332,29 @@ if (!ab || !ctx) return; ctx.decodeAudioData(ab, function (buf) { if (cur) { try { cur.stop(); } catch (e) {} } - cur = ctx.createBufferSource(); - cur.buffer = buf; - cur.connect(ctx.destination); - cur.start(0); + var src = ctx.createBufferSource(); + src.buffer = buf; + src.connect(ctx.destination); + cur = src; + curEnd = Date.now() + buf.duration * 1000; + src.onended = function () { if (cur === src) cur = null; }; + src.start(0); }, function () { /* 解码失败忽略 */ }); }) .catch(function () { /* 网络异常忽略 */ }); } - return { unlock: unlock, play: play }; + // 等当前播报自然收尾后执行 cb(倒计时接续「保持不动」,不拦腰切断);无播报在执行则立即执行 + // 返回取消函数;注意:cur 被新播报 stop 也会触发 onended,调用方需配合 cancelCountdown 等路径取消 + function afterCurrent(cb) { + if (!cur || Date.now() >= curEnd - 60) { cb(); return null; } + var src = cur, cancelled = false; + src.onended = function () { + if (cur === src) cur = null; + if (!cancelled) cb(); + }; + return function () { cancelled = true; }; + } + return { unlock: unlock, play: play, afterCurrent: afterCurrent }; })(); document.addEventListener('click', voice.unlock); // 任意点击即完成解锁 @@ -360,6 +373,7 @@ var detRunning = false, detTimer = null; // 检测循环 var okSince = 0; // 当前连续 ok 起点(0=未在累积) var counting = false, cdTimer = null, cdN = 0; // 倒计时 + var cdDeferCancel = null; // 倒计时等播报收尾的取消句柄(afterCurrent) var nudgeTimer = null; // 12s 无进展提示 var capturing = false; // 白屏补光抓帧中(暂停姿态判定) var lastHint = ''; // 提示文案去重 @@ -419,15 +433,21 @@ } function startCountdown() { - counting = true; + counting = true; // 立即置位:等待播报收尾期间姿态被破坏,走 cancelCountdown 统一取消 clearTimeout(nudgeTimer); hideNudge(); + setMask('ok'); // 轮廓保持绿色 + setHint('保持不动', true, /* silent= */true); // 倒计时前不再抢播「保持不动」(会被 321 立刻切断,听着像吞字) + // 「保持不动」等仍在播放的提示音自然播完再进倒计时,画面数字与「三、二、一」同步起播 + cdDeferCancel = voice.afterCurrent(beginCountdown); + } + function beginCountdown() { + cdDeferCancel = null; + if (!counting) return; // 等待期间已被取消(姿态破坏/退出拍照页) cdN = COUNTDOWN_S; var c = $('#cam-count'); c.textContent = cdN; c.classList.add('show'); - setMask('ok'); // 轮廓保持绿色 - setHint('保持不动', true, /* silent= */true); // 倒计时前不再抢播「保持不动」(会被 321 立刻切断,听着像吞字) voice.play(VOICE.countdown); // 「三、二、一」与数字同步起播 cdTimer = setTimeout(cdTick, 1000); } @@ -444,6 +464,7 @@ } function cancelCountdown() { counting = false; + if (cdDeferCancel) { cdDeferCancel(); cdDeferCancel = null; } // 取消尚未起播的倒计时接续 clearTimeout(cdTimer); $('#cam-count').classList.remove('show'); } @@ -495,19 +516,20 @@ if (ratio < FACE_NEAR) hint = '请靠近一点'; else if (ratio > FACE_FAR) hint = '请远一点'; else { - // 镜像标志跟随镜头(前置 true/后置 false);距离不合格时角度不判 ok - var pose = HeadPose.check(d.landmarks.positions, CAM_STEPS[camStep].target, camMirrored); - // 镜像自校准:侧脸步用户实际转向与语义 yaw 持续相反(本机镜像策略与 camMirrored 假设不符), - // 连续 5 帧强反向即翻转镜像约定一次(翻转后语义回正,通常立即姿态合格) + // 距离不合格时角度不判 ok。镜像参数:正脸步跟随镜头(前置 true/后置 false); + // 侧脸步预览不镜像,特征点即原始视角,标准机型恒为 false,个别前置帧已镜像的机型由自校准翻转 sideYawFlip var tgt = CAM_STEPS[camStep].target; + var pose = HeadPose.check(d.landmarks.positions, tgt, tgt === 'front' ? camMirrored : 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 >= 5) { - camMirrored = !camMirrored; + if (++oppFrames >= 30) { + sideYawFlip = !sideYawFlip; mirrorCalibrated = true; oppFrames = 0; - camVideo.classList.toggle('mirror', camMirrored); } } else oppFrames = 0; } @@ -528,13 +550,13 @@ } } - /* ─── 拍照流程(三步:正脸 → 右脸出镜 → 左脸出镜) ─── */ - // target 即 HeadPose.check 的 target:2/3 右脸出镜=用户头向左转(target 'right',产物作 right_image); - // 3/3 左脸出镜=头向右转(target 'left',产物作 left_image)。 + /* ─── 拍照流程(三步:正脸 → 左脸出镜 → 右脸出镜) ─── */ + // target 即 HeadPose.check 的 target:2/3 左脸出镜=用户头向右转(target 'left',产物作 left_image); + // 3/3 右脸出镜=头向左转(target 'right',产物作 right_image)。 var CAM_STEPS = [ { bg: 'assets/img/cam-front.jpg', step: '1/3
请单人正脸出镜', guides: ['正脸居中', '取下眼镜', '光线充足'], target: 'front' }, - { bg: 'assets/img/cam-left.jpg', step: '2/3
请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right' }, - { bg: 'assets/img/cam-right.jpg', step: '3/3
请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left' } + { bg: 'assets/img/cam-left.jpg', step: '2/3
请单人左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left' }, + { bg: 'assets/img/cam-right.jpg', step: '3/3
请单人右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right' } ]; var camStep = 0; @@ -552,6 +574,7 @@ dots[i].classList.toggle('on', i === camStep); } $('#cam-mask').setAttribute('data-step', camStep); // 蒙版按步骤切换 + camVideo.classList.toggle('mirror', effMirror()); // 侧脸步预览切回不镜像(正脸步恢复前置镜像) resetDetectState(); voice.play(VOICE.step[camStep]); // 步骤引导语音 } @@ -603,7 +626,7 @@ }, 300); } - // 三张拍完:三视图送检(正脸=photos[0],左=photos[2],右=photos[1]) + // 三张拍完:三视图送检(正脸=photos[0],左=photos[1],右=photos[2]) function submitPhotos(seq) { camStep = 0; openOverlay('p-analyzing'); @@ -685,7 +708,7 @@ fetch('/api/skin-analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ front_image: ph[0], left_image: ph[2], right_image: ph[1] }), + body: JSON.stringify({ front_image: ph[0], left_image: ph[1], right_image: ph[2] }), signal: ctrl ? ctrl.signal : undefined // 老内核无 AbortController:仅靠令牌丢弃迟到响应 }).then(function (res) { return res.json(); }) .then(function (data) { @@ -911,8 +934,9 @@ for (i = 0; i < 3; i++) if (typeof ds[i] === 'number') { sum += ds[i]; n++; } if (n) total = Math.round(sum / n * 10); } - $('#stat-score').textContent = total != null ? total + '分' : '--分'; - $('#stat-age').textContent = vm.skinAgeDisplay != null ? vm.skinAgeDisplay + '岁' : '--岁'; + // 设计稿:大数字 30px/400 + 单位 15px/500(分/岁为小字后缀) + $('#stat-score').innerHTML = total != null ? '' + total + '分' : '--分'; + $('#stat-age').innerHTML = vm.skinAgeDisplay != null ? '' + vm.skinAgeDisplay + '岁' : '--岁'; $('#stat-age-note').textContent = ''; // 肌龄小字按产品要求不展示(钳制留痕 vm.ageClampNote 仍随数据返回) // 三维度:S 值(1 位小数),最低分标红(设计稿无档位徽章) @@ -954,17 +978,22 @@ } // 护理建议(V2.3 §8 五段:肤质定制/最差2分类/精华/生活方式);综合分析与生活方式通栏已按需求移除 + // 展示对齐 Figma:纯「序号、正文」,不显示段名【tag】、不加粗 var careBox = $('#care-list'); if (careBox) { careBox.innerHTML = (vm.careAdvice || []).map(function (s, i) { - return '

' + (i + 1) + '、【' + esc(s.tag) + '】' + esc(s.text) + '

'; + return '

' + (i + 1) + '、' + esc(s.text) + '

'; }).join(''); } // 精华推荐卡 $('#essence-name').textContent = vm.essence.name; $('#essence-text').textContent = vm.essence.text; - $('#essence-triggers').textContent = vm.essence.triggers.length ? '触发:' + vm.essence.triggers.join('、') : ''; + // 命中判据:引擎内部名 → 用户可读措辞(引擎值被真值回归逐字锁定,仅展示层映射) + var TRIGGER_LABEL = { '均分档': '综合均分', '最差项约束': '最低分维度', '年龄组档': '年龄段' }; + $('#essence-triggers').textContent = vm.essence.triggers.length + ? '推荐依据:' + vm.essence.triggers.map(function (t) { return TRIGGER_LABEL[t] || t; }).join('、') + : ''; } /* ─── 相机可用后加载检测模型(加载完成前快门即手动模式) ─── */ diff --git a/h5/js/head-pose.js b/h5/js/head-pose.js index 18da3cb..e7ec626 100644 --- a/h5/js/head-pose.js +++ b/h5/js/head-pose.js @@ -62,9 +62,10 @@ var PITCH_COEFF = 180; // 归一化偏移 → 角度 的增益 // ---- check 阈值(角度制) ---- - // 正脸口径:Face++ 正脸判定 |yaw|<10°,前端留 2° 余量取 ≤12° - var FRONT_YAW_MAX = 12; - var FRONT_PITCH_MAX = 15; + // 正脸口径:Face++ 正脸判定 |yaw|<10°;产品要求(2026-09-28)正脸引导放宽一点点, + // 前端取 |yaw|≤15°、|pitch|≤18°(略超 Face++ 口径,正脸图仍可正常分析,仅 orientation 读数略偏) + var FRONT_YAW_MAX = 15; + var FRONT_PITCH_MAX = 18; var SIDE_YAW_MIN = 28; var SIDE_YAW_MAX = 75; var SIDE_PITCH_MAX = 20; @@ -156,7 +157,8 @@ /** * check(points, target, mirrored) -> { ok, hint } * target: 'front' | 'left' | 'right' - * 'front' : |yaw|<=12 且 |pitch|<=15 + * 'front' : |yaw|<=15 且 |pitch|<=18;不 ok 时一律 hint='请正对屏幕' + * (产品要求 2026-09-28:正脸步不提示左右转头/抬头低头,只引导回正) * 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-75°,-28°]),|pitch|<=20 * 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [28°,75°]),|pitch|<=20 * ok=true 时 hint='保持不动';输入非法(含 estimate 返回 null 的退化特征点)时 ok=false、hint='请正对屏幕'。 @@ -179,11 +181,8 @@ if (Math.abs(sYaw) <= FRONT_YAW_MAX && Math.abs(pitch) <= FRONT_PITCH_MAX) { return { ok: true, hint: HINT_KEEP, yaw: sYaw }; } - if (Math.abs(sYaw) > FRONT_YAW_MAX) { - // 已向左转过头 → 提示向右回正,反之亦然 - return { ok: false, hint: sYaw > 0 ? HINT_TURN_RIGHT : HINT_TURN_LEFT, yaw: sYaw }; - } - return { ok: false, hint: pitch > 0 ? HINT_LOWER : HINT_RAISE, yaw: sYaw }; + // 正脸步只提示回正(产品要求:不区分左右转头/抬头低头,避免正脸阶段引导混乱) + return { ok: false, hint: HINT_FRONT, yaw: sYaw }; } if (target === 'left' || target === 'right') { @@ -294,22 +293,22 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m ok(bc.ok === true, '反向侧脸 target=left ok'); ok(HeadPose.check(back, 'right', false).ok === false, '反向侧脸 target=right 拒绝'); - // 7) 侧脸做 front check → 拒绝并引导回正 + // 7) 侧脸做 front check → 拒绝并只引导回正(产品要求:正脸步不再提示左右转头) var sc = HeadPose.check(makeSide(), 'front', false); - ok(sc.ok === false && sc.hint === '请向右转头', '侧脸 front 拒绝,hint=请向右转头'); + ok(sc.ok === false && sc.hint === '请正对屏幕', '侧脸 front 拒绝,hint=请正对屏幕'); // 8) roll:右眼(45 点)下移 10px → roll≈5.7° var tilted = makeFrontal(); tilted[45] = { x: 250, y: 110 }; ok(Math.abs(HeadPose.estimate(tilted, false).roll - 5.71) < 0.2, 'roll≈5.71°'); - // 9) 低头:鼻底上移 → pitch<0,front 提示抬头 + // 9) 低头:鼻底上移 → pitch<0,front 拒绝并只提示回正(正脸步不再提示抬头/低头) var down = makeFrontal(); down[33] = { x: 200, y: 100 }; var de = HeadPose.estimate(down, false); var dc = HeadPose.check(down, 'front', false); ok(de.pitch < -30, '低头 pitch<0 (got ' + de.pitch.toFixed(2) + ')'); - ok(dc.ok === false && dc.hint === '请抬头', '低头 front 拒绝,hint=请抬头'); + ok(dc.ok === false && dc.hint === '请正对屏幕', '低头 front 拒绝,hint=请正对屏幕'); // 10) 非法输入:check 不抛异常,返回兜底引导 var bad = HeadPose.check(null, 'front', false); @@ -328,19 +327,22 @@ 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|≤12°(Face++ 口径 <10° + 2° 余量):yaw≈13.7°(旧阈值 15 内)应拒绝 - ok(HeadPose.config.FRONT_YAW_MAX === 12, 'config.FRONT_YAW_MAX = 12'); + // 13) 正脸阈值放宽到 |yaw|≤15°(产品要求 2026-09-28):yaw≈14.9° 应通过,yaw≈16.2° 应拒绝且只提示回正 + ok(HeadPose.config.FRONT_YAW_MAX === 15, 'config.FRONT_YAW_MAX = 15'); var slight = makeFrontal(); - slight[30] = { x: 205.18, y: 140 }; // 偏移 5.18 → yaw≈13.7°(YAW_COEFF=3.8 标定) - slight[33] = { x: 205.18, y: 161.5 }; + slight[30] = { x: 205.6, y: 140 }; // 偏移 5.6 → yaw≈14.9°(YAW_COEFF=3.8 标定) + slight[33] = { x: 205.6, y: 161.5 }; var se = HeadPose.estimate(slight, false); - ok(se.yaw > 12 && se.yaw < 15, '轻微偏头 yaw∈(12,15) (got ' + se.yaw.toFixed(2) + ')'); + 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 === false, 'yaw≈13.7° front 拒绝(旧阈值 15 会放行)'); + ok(sc2.ok === true, 'yaw≈14.9°(≤15°)front 通过(旧阈值 12 会拒绝)'); var slight2 = makeFrontal(); - slight2[30] = { x: 204.04, y: 140 }; // 偏移 4.04 → yaw≈10.7° - slight2[33] = { x: 204.04, y: 161.5 }; - ok(HeadPose.check(slight2, 'front', false).ok === true, 'yaw≈10.7°(≤12°)front 通过'); + slight2[30] = { x: 206.2, y: 140 }; // 偏移 6.2 → yaw≈16.2° + slight2[33] = { x: 206.2, y: 161.5 }; + var se2 = HeadPose.estimate(slight2, false); + ok(se2.yaw > 15 && se2.yaw < 17.5, '偏头 yaw∈(15,17.5) (got ' + se2.yaw.toFixed(2) + ')'); + var sc3 = HeadPose.check(slight2, 'front', false); + ok(sc3.ok === false && sc3.hint === '请正对屏幕', 'yaw≈16.2° front 拒绝,hint=请正对屏幕'); if (failures > 0) { console.error('\n' + failures + ' 项自测失败'); diff --git a/h5/preview/08-cam-front.png b/h5/preview/08-cam-front.png index 7d18c09..8f16074 100644 Binary files a/h5/preview/08-cam-front.png and b/h5/preview/08-cam-front.png differ diff --git a/h5/preview/09-cam-left.png b/h5/preview/09-cam-left.png index a490d1b..4df7714 100644 Binary files a/h5/preview/09-cam-left.png and b/h5/preview/09-cam-left.png differ diff --git a/h5/preview/10-cam-right.png b/h5/preview/10-cam-right.png index 8bddd76..adcbfd7 100644 Binary files a/h5/preview/10-cam-right.png and b/h5/preview/10-cam-right.png differ diff --git a/tools/gen_voice.py b/tools/gen_voice.py index ea8e026..2fb7872 100644 --- a/tools/gen_voice.py +++ b/tools/gen_voice.py @@ -36,8 +36,8 @@ INSTRUCT = ( # (文件名不含扩展名, 文案) — 与设计文档第 2 节逐字一致 LINES: list[tuple[str, str]] = [ ("step1-front", "请单人正脸出镜,面部对准取景框"), - ("step2-rightface", "请保持单人出镜,慢慢向左转头,露出右脸"), - ("step3-leftface", "请慢慢向右转头,露出左脸"), + ("step2-leftface", "请慢慢向右转头,露出左脸"), + ("step3-rightface", "请保持单人出镜,慢慢向左转头,露出右脸"), ("hint-noface", "未检测到人脸,请正对镜头"), ("hint-near", "请靠近一点"), ("hint-far", "请远一点"),