fix(h5): ease camera capture requirements and rename page title

This commit is contained in:
xsl
2026-10-12 20:47:58 +08:00
parent 3f165bf9e5
commit c9352fe4ca
15 changed files with 960 additions and 804 deletions
+4 -4
View File
@@ -1,4 +1,4 @@
# 1'me 在线肌肤测试 H5
# 1'me肌肤状态检测 H5
引导式自动三连拍(正脸/右脸/左脸),调用 **Face++ 皮肤分析(专业版 skinanalyze_pro)** 三视图真实检测,即时生成三维度×6分类肌肤状态分析与个性化护理建议的移动端 H5。
@@ -62,9 +62,9 @@ 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 复制轮廓生成);未检测到人脸时轮廓呼吸动画;条件合格时轮廓变绿。
- **预览铺满**:`#cam-video` 保持现有 `object-fit:cover` 铺满与镜像策略,第四轮不改取景视觉,仅收紧下述本地门限。
- **动态提示**:第四轮收紧本地判定,单人、检测置信度≥0.5、人脸框高占原始画面 34%~64%、水平中心偏离≤12% 屏宽、完整入镜且距原始帧边缘≥2%。正脸 |yaw|≤10°/|pitch|≤12°,侧脸转头幅度 35°~65°/|pitch|≤15°,两者 |roll|≤10°;脸部平均亮度 75~225。参数集中在 `js/capture-quality.js` 与 `js/head-pose.js`,均为经验初值,不等价于服务端质量分。
- **合格即拍**:连续稳定至少 650ms 且至少 3 个合格检测帧,漏检、姿态/位置漂移或帧间隔超过 2 秒均重置稳定累计;白屏抓拍后在本地复验实际 JPEG,合格才推进。**无倒计时、无手动快门**(已有降级模式除外)。
- **预览铺满**:`#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`)。**抓帧一律不镜像**,三张照片都是摄像头原始视角。
+3 -3
View File
@@ -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">
+6 -4
View File
@@ -4,10 +4,12 @@
else root.CaptureQuality = factory(root.HeadPose);
})(typeof window !== 'undefined' ? window : this, function(HeadPose) {
'use strict';
var config = Object.freeze({ version: '2026-10-12-fourth-v1', score: 0.5,
near: 0.34, far: 0.64, center: 0.12, edge: 0.02,
dark: 75, bright: 225, holdMs: 650, minFrames: 3, maxGapMs: 2000,
yawDrift: 5, pitchDrift: 5, centerDrift: 0.035, sizeDrift: 0.045 });
// 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 }; };
+14 -14
View File
@@ -62,14 +62,14 @@
var PITCH_COEFF = 180; // 归一化偏移 → 角度 的增益
// ---- check 阈值(角度制) ----
// Fourth feedback: conservative initial local gates. Empirical pose estimates
// are not upstream quality scores; use recorded samples to calibrate further.
var FRONT_YAW_MAX = 10;
var FRONT_PITCH_MAX = 12;
var SIDE_YAW_MIN = 35;
var SIDE_YAW_MAX = 65;
var SIDE_PITCH_MAX = 15;
var ROLL_MAX = 10;
// 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 = 30;
var SIDE_YAW_MAX = 70;
var SIDE_PITCH_MAX = 20;
var ROLL_MAX = 15;
var HINT_KEEP = '保持不动';
var HINT_TURN_LEFT = '请向左转头'; // 用户向自己的左侧转
@@ -158,10 +158,10 @@
/**
* check(points, target, mirrored) -> { ok, hint }
* target: 'front' | 'left' | 'right'
* 'front' : |yaw|<=10 且 |pitch|<=12、|roll|<=10;不 ok 时给方向提示
* 'front' : |yaw|<=15 且 |pitch|<=18、|roll|<=15;不 ok 时给方向提示
* (UI 反馈 2026-10:动态提示「请向左转头」「请略微抬头」等,取代此前只提示回正)
* 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-65°,-35°]),|pitch|<=15
* 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [35°,65°]),|pitch|<=15
* '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) {
@@ -341,15 +341,15 @@ 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) Fourth feedback: 10° local frontal gate rejects the former 15° edge.
ok(HeadPose.config.FRONT_YAW_MAX === 10, 'config.FRONT_YAW_MAX = 10');
// 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 标定)
slight[33] = { x: 205.6, y: 161.5 };
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 === false, 'yaw≈14.9° front 拒绝(第四轮收紧)');
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 };
+6 -5
View File
@@ -48,6 +48,7 @@ try {
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);
@@ -100,12 +101,12 @@ try {
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 >= .5 && row.luma >= 75 && row.luma <= 225);
assert(Math.abs(row.roll) <= 10);
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) <= 10);
assert(body.capture_diagnostics.views.left.yaw <= -35);
assert(body.capture_diagnostics.views.right.yaw >= 35);
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');
+26 -13
View File
@@ -28,10 +28,14 @@ const options = { count: 1, target: 'front', width: 600, height: 600, offsetX: 0
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);
for (const pitch of [-18, 18]) assert(!check(face(0, pitch)).ok);
assert.equal(check(face(0, 0, 15)).hint, '请勿歪头');
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);
@@ -39,22 +43,29 @@ 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);
assert(!check(face(), { offsetX: .15 }).ok);
for (const changes of [{ height: 180 }, { height: 410 }, { x: -10 }, { y: 5 }]) {
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 = .4; assert(!check(weak).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, 1650).ready);
assert(!Quality.hold(Quality.hold(null, obs, 1000).state, obs, 1700).ready, 'two frames never pass');
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, 'slow device still needs three frames');
assert(Quality.hold(slow.state, obs, 3200).ready, '1100ms inference must not permanently prevent a stable capture');
for (const change of [{ yaw: 6 }, { pitch: 6 }, { ratio: .6 }, { center_x: .55 }, { center_y: .56 }]) assert.equal(Quality.hold(held.state, { ...obs, ...change }, 1500).state.frames, 1, 'motion resets');
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']
@@ -74,8 +85,10 @@ 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, 650);
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: stricter local pose/framing/light/single-face gates; 650ms/3-frame stability/motion/miss/gap reset; all focus cases/ties/missing/saved scores; no wrinkle mutations; backend quality marking and bounded client diagnostics');
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');
+4 -1
View File
@@ -43,6 +43,8 @@ try {
});
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"]');
@@ -64,7 +66,8 @@ try {
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, 35);
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');
+4 -4
View File
@@ -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; the fourth-round shared hold policy now requires 650ms/3 frames.
// 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,
@@ -51,14 +51,14 @@ vm.runInNewContext(extract('camera-haptic-pure') + source.slice(source.indexOf('
const face = { detection: { box: { height: 250 } }, landmarks: { positions: [] } };
state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 0);
now += 200; state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 0);
now += 450; state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 1);
now += 250; state.onDetect(face); assert.equal(buzzes, 1); assert.equal(captures, 1);
now += 100; state.onDetect({ ...face, detection: { box: { height: 20 } } });
now += 100; state.onDetect(face); assert.equal(buzzes, 1, 'jitter must not repeatedly buzz');
now += 2100; state.onDetect({ ...face, detection: { box: { height: 20 } } });
state.onDetect(face); assert.equal(buzzes, 2);
state.navigator.vibrate = () => { throw Error('blocked'); };
now += 300; state.onDetect(face);
now += 350; state.onDetect(face); assert.equal(captures, 2, 'vibration failure must not prevent capture');
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,
@@ -71,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; haptic safety, pre-capture cue, jitter throttling; fourth-round 650ms/3-frame hold');
console.log('PASS: three bounded rounded contours; haptic safety, pre-capture cue, jitter throttling; relaxed 450ms/2-frame hold');