第二版修改

This commit is contained in:
root
2026-10-10 09:46:17 +08:00
parent 4b26288098
commit 652c977cec
5 changed files with 130 additions and 78 deletions
+2 -1
View File
@@ -65,6 +65,7 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
- **预览缩小 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(滤掉一闪而过的合格帧,防运动模糊)立即白屏补光抓拍,**无倒计时、无手动快门**。
- **两张之间停顿**:正脸、右脸拍完后中央弹出停顿卡约 2s(「正脸拍摄完成 / 下一张:右脸出镜,请向左转头」),停顿期间不检测,结束后再切到下一步并播下一步引导,让用户对下一张有心理预期。
- **实时检测**:face-api 从本地 `vendor/faceapi/` 加载(`tinyFaceDetector` + `faceLandmark68Net`),进入拍照页开始加载(状态提示「检测引擎加载中…」)。检测循环 ~150ms 一帧,特征点取自原始帧(预览 CSS 镜像不影响),个别前置帧已镜像的机型由侧脸自校准翻转 `sideYawFlip`;连续漏检 ≥3 帧才报「未检测到人脸」。
- **镜像策略**:前置镜头预览镜像(照镜子习惯);后置预览不镜像,侧脸轮廓随之翻转(`.cam-mask.flip`)。**抓帧一律不镜像**,三张照片都是摄像头原始视角。
- **白屏补光**:全屏纯白覆盖层瞬间点亮,~300ms 后抓全分辨率帧(原始视角 + 最长边 1280 JPEG),保持 ~300ms 再淡出,同步 `navigator.vibrate(30)`。
@@ -77,7 +78,7 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
## 档案页与报告页
- **档案页**:出生年月 / 性别 / 所在地区三个滚轮选择器同一风格(`vendor/mobile-select`,MIT),出生年月自动换算年龄;地区为省–市两级联动,数据为 `js/region-data.js`(由 china-division 2.7.0 `pc.json` 生成,另含港澳台与「海外」)。
- **报告页**:无返回按钮与页头标题;总分环形图 + 肌肤年龄与「比实际年龄年轻/大 N 岁」;三维度分数 + 进度条(最低分标红并挂「优先关注」);紧塑力/活力感/稳定力各一张卡,卡内子 Tab 切换;点检测图全屏放大,再点或按返回键回到报告。
- **报告页**:无返回按钮与页头标题;总分环形图 + 肌肤年龄与「比实际年龄年轻/大 N 岁」;三维度分数 + 进度条(最低分标红并挂「优先关注」);维度详情为原布局——一张卡内紧塑力/活力感/稳定力三个 Tab,每个 Tab 两条「竖排标题 + 左图右文」;点检测图全屏放大,再点或按返回键回到报告。
## 检测流程与评分渲染
+45 -25
View File
@@ -314,6 +314,31 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
}
.cam-nudge.show { opacity: 1; transform: translateX(-50%); }
/* 两张之间的停顿卡:本张完成 + 下一张预告,停顿期间不检测 */
.cam-next {
position: absolute; left: 50%; top: 50%;
transform: translate(-50%, -50%) scale(.94);
width: calc(230 * var(--u)); padding: calc(22 * var(--u)) calc(16 * var(--u)) calc(20 * var(--u));
display: flex; flex-direction: column; align-items: center;
background: rgba(0,0,0,.72); border-radius: calc(12 * var(--u));
color: #fff; text-align: center;
z-index: 3; opacity: 0; pointer-events: none;
transition: opacity .2s ease, transform .2s ease;
}
.cam-next.show { opacity: 1; transform: translate(-50%, -50%); }
.cam-next-ok {
width: calc(40 * var(--u)); height: calc(40 * var(--u)); border-radius: 50%;
background: #34C759; position: relative; margin-bottom: calc(12 * var(--u));
}
.cam-next-ok::after {
content: ""; position: absolute; left: 50%; top: 45%;
width: calc(16 * var(--u)); height: calc(8 * var(--u));
border: solid #fff; border-width: 0 0 calc(3 * var(--u)) calc(3 * var(--u));
transform: translate(-50%, -50%) rotate(-45deg);
}
.cam-next b { font-size: calc(17 * var(--u)); font-weight: 500; }
.cam-next span { margin-top: calc(8 * var(--u)); font-size: calc(14 * var(--u)); color: rgba(255,255,255,.85); line-height: 1.5; }
/* ═══════════ 08 报告页 ═══════════ */
#s-report { background: var(--bg); }
.report-body { padding: 0 calc(18 * var(--u)) calc(28 * var(--u)); }
@@ -408,40 +433,35 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
.dims-note-l1 b { font-weight: 500; color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
.dims-note-l2 { margin-top: calc(4 * var(--u)); font-size: calc(12 * var(--u)); color: var(--grey); }
/* 维度区块:大标题 + 卡内子 Tab + 大图 + 说明 */
.sec-title {
font-size: calc(22 * var(--u)); font-weight: 700; color: var(--ink);
text-align: center; letter-spacing: calc(1 * var(--u));
margin: calc(26 * var(--u)) 0 calc(16 * var(--u));
}
.card-detail { padding: calc(14 * var(--u)) calc(30 * var(--u)) calc(22 * var(--u)); }
.detail-tabs { display: flex; justify-content: space-between; border-bottom: 1px solid #EFEFEF; }
.dtab {
position: relative;
min-width: calc(80 * var(--u));
padding: calc(6 * var(--u)) calc(4 * var(--u)) calc(11 * var(--u));
font-size: calc(14.5 * var(--u)); font-weight: 500; color: var(--ink);
}
/* 维度详情卡(原布局):紧塑力/活力感/稳定力三个 Tab,每个 Tab 两条「竖排标题 + 左图右文」;检测图点击放大 */
.card-detail { padding: 0 calc(19 * var(--u)) calc(19 * var(--u)); }
.detail-tabs { display: flex; border-bottom: 1px solid #F0F0F0; }
.dtab { flex: 1; padding: calc(13 * var(--u)) 0 0; font-weight: 500; color: var(--ink); }
.dtab-tx { position: relative; display: inline-block; font-size: calc(10.1 * var(--u)); padding: 0 calc(5 * var(--u)) calc(7 * var(--u)); }
.dtab.active { font-weight: 700; }
.dtab.active::after {
content: ""; position: absolute; left: 0; right: 0; bottom: calc(-3 * var(--u));
height: calc(6 * var(--u)); border-radius: calc(3 * var(--u)); background: var(--yellow);
.dtab.active .dtab-tx::after {
content: ""; position: absolute; left: 50%; transform: translateX(-50%); bottom: -1px;
width: calc(78 * var(--u)); height: calc(7 * var(--u)); border-radius: calc(3.5 * var(--u)); background: var(--yellow);
}
.dpanel { display: none; }
.dpanel.active { display: block; animation: fade .25s ease; padding-top: calc(14 * var(--u)); }
.dpanel.active { display: block; animation: fade .25s ease; padding-top: calc(19 * var(--u)); }
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.ditem { display: flex; gap: calc(9 * var(--u)); align-items: center; }
.ditem + .ditem { margin-top: calc(18 * var(--u)); padding-top: calc(18 * var(--u)); border-top: 1px solid #F0F0F0; }
.ditem-img {
width: calc(213 * var(--u)); height: calc(277 * var(--u));
margin: 0 auto calc(14 * var(--u));
object-fit: cover; border-radius: calc(6 * var(--u));
width: calc(92 * var(--u)); height: calc(96 * var(--u));
object-fit: cover; border-radius: calc(6 * var(--u)); flex-shrink: 0;
background: #F5F5F5; cursor: zoom-in;
}
.ditem-img[hidden] { display: none; }
.ditem-para {
padding-top: calc(12 * var(--u)); border-top: 1px solid #EFEFEF;
font-size: calc(13 * var(--u)); color: var(--para); line-height: 1.72;
text-align: justify; overflow-wrap: anywhere;
.ditem-text { flex: 1; min-width: 0; }
.ditem-title {
writing-mode: vertical-rl;
letter-spacing: calc(4 * var(--u));
font-size: calc(11.5 * var(--u)); font-weight: 500; color: var(--ink);
margin-right: calc(3.5 * var(--u)); flex-shrink: 0;
}
.ditem-para { font-size: calc(10.1 * var(--u)); font-weight: 400; color: var(--para); line-height: 1.41; overflow-wrap: anywhere; }
.vm-notes { font-size: calc(10 * var(--u)); color: var(--grey-3); line-height: 1.7; margin: calc(14 * var(--u)) calc(6 * var(--u)) 0; }
.report-disclaimer {
+58 -44
View File
@@ -118,6 +118,11 @@
<p class="cam-status" id="cam-status">检测引擎加载中…</p>
<p class="cam-hint" id="cam-hint"></p>
<p class="cam-nudge" id="cam-nudge">识别不到?请正对镜头,并到光线充足处</p>
<div class="cam-next" id="cam-next" aria-live="polite">
<i class="cam-next-ok"></i>
<b id="cam-next-done">正脸拍摄完成</b>
<span id="cam-next-tip">准备拍摄下一张</span>
</div>
<div class="cam-guide" id="cam-guide"></div>
<video id="cam-video" autoplay playsinline muted></video>
<input type="file" id="cam-file" accept="image/*" capture="user" hidden>
@@ -170,52 +175,61 @@
<div class="dims-note" id="dims-note"><p class="dims-note-l1">近期优先关注:<b>---</b></p></div>
</div>
<!-- 三个维度各一张卡:卡内子 Tab 切换所辖两个分类;检测图点击放大 -->
<h2 class="sec-title">紧塑力</h2>
<div class="card card-detail">
<div class="detail-tabs">
<button class="dtab active" data-tab="0">轮廓</button>
<button class="dtab" data-tab="1">皱纹</button>
<div class="detail-tabs" id="detail-tabs">
<button class="dtab active" data-tab="0"><span class="dtab-tx">紧塑力</span></button>
<button class="dtab" data-tab="1"><span class="dtab-tx">活力感</span></button>
<button class="dtab" data-tab="2"><span class="dtab-tx">稳定力</span></button>
</div>
<div class="dpanel active" data-panel="0">
<img id="ditem-img1" src="assets/img/det-1.jpg" loading="lazy" class="ditem-img" alt="轮廓检测图">
<p class="ditem-para" id="ditem-para1">检测完成后显示分析结果。</p>
</div>
<div class="dpanel" data-panel="1">
<img id="ditem-img2" src="assets/img/det-2.jpg" loading="lazy" class="ditem-img" alt="皱纹检测图">
<p class="ditem-para" id="ditem-para2">检测完成后显示分析结果。</p>
</div>
</div>
<h2 class="sec-title">活力感</h2>
<div class="card card-detail">
<div class="detail-tabs">
<button class="dtab active" data-tab="0">光滑</button>
<button class="dtab" data-tab="1">肤色水润</button>
</div>
<div class="dpanel active" data-panel="0">
<img id="viz-smooth" loading="lazy" class="ditem-img" alt="光滑检测标记图" hidden>
<p class="ditem-para" id="tx-smooth">检测完成后显示分析结果。</p>
</div>
<div class="dpanel" data-panel="1">
<img id="viz-tone" loading="lazy" class="ditem-img" alt="肤色色沉图" hidden>
<p class="ditem-para" id="tx-tone">检测完成后显示分析结果。</p>
</div>
</div>
<h2 class="sec-title">稳定力</h2>
<div class="card card-detail">
<div class="detail-tabs">
<button class="dtab active" data-tab="0">敏感</button>
<button class="dtab" data-tab="1">痘痘</button>
</div>
<div class="dpanel active" data-panel="0">
<img id="viz-sens" loading="lazy" class="ditem-img" alt="敏感红区图" hidden>
<p class="ditem-para" id="tx-sens">检测完成后显示分析结果。</p>
</div>
<div class="dpanel" data-panel="1">
<img id="viz-acne" loading="lazy" class="ditem-img" alt="痘痘检测标记图" hidden>
<p class="ditem-para" id="tx-acne">检测完成后显示分析结果。</p>
<div class="detail-panels">
<div class="dpanel active" data-panel="0">
<div class="ditem">
<h3 class="ditem-title">轮廓</h3>
<img id="ditem-img1" src="assets/img/det-1.jpg" loading="lazy" class="ditem-img" alt="轮廓检测图">
<div class="ditem-text">
<p class="ditem-para" id="ditem-para1">检测完成后显示分析结果。</p>
</div>
</div>
<div class="ditem">
<h3 class="ditem-title">皱纹</h3>
<img id="ditem-img2" src="assets/img/det-2.jpg" loading="lazy" class="ditem-img" alt="皱纹检测图">
<div class="ditem-text">
<p class="ditem-para" id="ditem-para2">检测完成后显示分析结果。</p>
</div>
</div>
</div>
<div class="dpanel" data-panel="1">
<div class="ditem">
<h3 class="ditem-title">光滑</h3>
<img id="viz-smooth" loading="lazy" class="ditem-img" alt="光滑检测标记图" hidden>
<div class="ditem-text">
<p class="ditem-para" id="tx-smooth">检测完成后显示分析结果。</p>
</div>
</div>
<div class="ditem">
<h3 class="ditem-title">肤色水润</h3>
<img id="viz-tone" loading="lazy" class="ditem-img" alt="肤色色沉图" hidden>
<div class="ditem-text">
<p class="ditem-para" id="tx-tone">检测完成后显示分析结果。</p>
</div>
</div>
</div>
<div class="dpanel" data-panel="2">
<div class="ditem">
<h3 class="ditem-title">敏感</h3>
<img id="viz-sens" loading="lazy" class="ditem-img" alt="敏感红区图" hidden>
<div class="ditem-text">
<p class="ditem-para" id="tx-sens">检测完成后显示分析结果。</p>
</div>
</div>
<div class="ditem">
<h3 class="ditem-title">痘痘</h3>
<img id="viz-acne" loading="lazy" class="ditem-img" alt="痘痘检测标记图" hidden>
<div class="ditem-text">
<p class="ditem-para" id="tx-acne">检测完成后显示分析结果。</p>
</div>
</div>
</div>
</div>
</div>
+23 -8
View File
@@ -711,10 +711,14 @@
// 左脸出镜=头向自己的右侧转(target 'left',产物作 left_image)。
// mask 为 index.html 取景轮廓编号(1=左脸出镜 耳在画面左,2=右脸出镜 耳在画面右,均按镜像预览绘制)
var CAM_STEPS = [
{ step: '1/3<br>请正对屏幕', guides: ['正脸居中', '取下眼镜', '光线充足'], target: 'front', mask: 0 },
{ step: '2/3<br>请右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right', mask: 2 },
{ step: '3/3<br>请左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left', mask: 1 }
{ step: '1/3<br>请正对屏幕', guides: ['正脸居中', '取下眼镜', '光线充足'], target: 'front', mask: 0,
done: '正脸拍摄完成' },
{ step: '2/3<br>请右脸出镜', guides: ['右脸居中', '取下眼镜', '光线充足'], target: 'right', mask: 2,
done: '右脸拍摄完成', preview: '下一张:右脸出镜<br>请向左转头' },
{ step: '3/3<br>请左脸出镜', guides: ['左脸居中', '取下眼镜', '光线充足'], target: 'left', mask: 1,
done: '左脸拍摄完成', preview: '下一张:左脸出镜<br>请向右转头' }
];
var NEXT_PAUSE_MS = 2000; // 两张之间的停顿:给用户「下一张要拍什么」的心理预期
function photosByView(ph) { // 拍摄顺序 → 送检/报告/历史统一口径 [正脸, 左脸, 右脸]
var at = {};
CAM_STEPS.forEach(function (s, i) { at[s.target] = ph[i]; });
@@ -729,6 +733,7 @@
.map(function (g) { return '<span>' + g + '</span>'; })
.join('<i></i>');
$('#cam-mask').setAttribute('data-step', cfg.mask); // 蒙版按步骤切换轮廓
$('#cam-next').classList.remove('show');
applyMirror();
resetDetectState();
voice.play(VOICE.step[camStep]); // 步骤引导语音
@@ -739,20 +744,30 @@
// 照片就绪(已通过校验)后推进流程:存图→「拍摄完成」→300ms 过渡→下一步;三步严格按顺序,本步合格才进下一步
function onPhotoCaptured(dataUrl) {
shutterLock = true; // 推进窗口上锁,直到下一步/送检
shutterLock = true; // 推进窗口上锁(停顿期间不检测、不接受快门),直到下一步/送检
photos[camStep] = dataUrl;
voice.play(VOICE.shot, { cut: true }); // 「拍摄完成」:已拍下,本步未播完的引导作废
var seq = camSeq;
var seq = camSeq, hasNext = camStep < CAM_STEPS.length - 1;
if (hasNext) {
// 停顿卡:本张完成 + 下一张拍什么、往哪转;停顿结束再切到下一步(播下一步引导语音)
setHint('');
hideNudge();
clearTimeout(nudgeTimer);
setMask('');
$('#cam-next-done').textContent = CAM_STEPS[camStep].done;
$('#cam-next-tip').innerHTML = CAM_STEPS[camStep + 1].preview;
$('#cam-next').classList.add('show');
}
setTimeout(function () {
if (seq !== camSeq) return; // 期间已退出/重新开始:放弃本次推进
if (camStep < CAM_STEPS.length - 1) {
if (hasNext) {
camStep++;
renderCamStep(); // 内含检测状态机重置
renderCamStep(); // 内含停顿卡收起与检测状态机重置
shutterLock = false; // 进入下一步即解锁
} else {
submitPhotos(seq); // 三张拍完:送检
}
}, 300);
}, hasNext ? NEXT_PAUSE_MS : 300);
}
// 白屏补光抓拍:全屏纯白瞬间点亮→约300ms 后抓全分辨率帧→保持300ms 再淡出(总 600–900ms)