diff --git a/h5/css/ui-20261012.css b/h5/css/ui-20261012.css index ae0bdfa..d474923 100644 --- a/h5/css/ui-20261012.css +++ b/h5/css/ui-20261012.css @@ -89,7 +89,7 @@ /* Figma 1:65, 1080px canvas. One algorithm-selected product, not a catalogue. */ /* User-confirmed: both lines centered; use the same font size/line height as the stability heading rather than scaling text on wide previews. */ -.prod-title { width:calc(206.597222 * var(--u)); max-width:100%; text-align:center; margin:calc(16.9 * var(--u)) auto calc(11.158333 * var(--u)); padding:0; transform:translateX(calc(.347222 * var(--u))); font-size:22.106597px; line-height:28.738542px; font-weight:700; white-space:nowrap; } +.prod-title { width:calc(206.597222 * var(--u)); max-width:100%; text-align:center; margin:calc(18 * var(--u)) auto; padding:0; transform:translateX(calc(.347222 * var(--u))); font-size:22.106597px; line-height:28.738542px; font-weight:700; white-space:nowrap; } .report-care-products { width:calc(340.625 * var(--u)); margin-left:auto; margin-right:auto; padding:calc(21.180556 * var(--u)) calc(24.652778 * var(--u)) calc(7.986111 * var(--u)); } .report-care-products .card-heading, .product-section-heading { font-size:13.888889px; line-height:19.444444px; font-weight:700; text-align:left; } diff --git a/h5/js/app.js b/h5/js/app.js index fbc2985..5f0f3d8 100644 --- a/h5/js/app.js +++ b/h5/js/app.js @@ -1326,7 +1326,8 @@ { key: 'closed_comedones', color: '#E8A13D' }, // 闭口粉刺 { key: 'acne', color: '#D30C0C' }, // 痤疮 { key: 'acne_pustule', color: '#D30C0C' }, // 脓包 - { key: 'acne_nodule', color: '#8E0000' } // 结节 + { key: 'acne_nodule', color: '#8E0000' }, // 结节 + { key: 'acne_mark', color: '#8750C8' } // 痘印:紫色,与红色炎性痘痘区分 ] } }; diff --git a/h5/js/capture-quality.js b/h5/js/capture-quality.js index 62f479a..653d997 100644 --- a/h5/js/capture-quality.js +++ b/h5/js/capture-quality.js @@ -6,7 +6,7 @@ '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, + var config = Object.freeze({ version: '2026-10-15-angle-relaxed-20-v3', 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 }); diff --git a/h5/js/head-pose.js b/h5/js/head-pose.js index 69f784e..a0cd666 100644 --- a/h5/js/head-pose.js +++ b/h5/js/head-pose.js @@ -64,12 +64,13 @@ // ---- check 阈值(角度制) ---- // 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; + // 2026-10-15: expand accepted angles by 20%; lower side minimum by 20%. + var FRONT_YAW_MAX = 18; + var FRONT_PITCH_MAX = 21.6; + var SIDE_YAW_MIN = 24; + var SIDE_YAW_MAX = 84; + var SIDE_PITCH_MAX = 24; + var ROLL_MAX = 18; var HINT_KEEP = '保持不动'; var HINT_TURN_LEFT = '请向左转头'; // 用户向自己的左侧转 @@ -158,10 +159,10 @@ /** * check(points, target, mirrored) -> { ok, hint } * target: 'front' | 'left' | 'right' - * 'front' : |yaw|<=15 且 |pitch|<=18、|roll|<=15;角度不合格只提示回正, + * 'front' : |yaw|<=18 且 |pitch|<=21.6、|roll|<=18;角度不合格只提示回正, * 不在正脸拍摄完成前播侧脸转头指令。 - * 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-70°,-30°]),|pitch|<=20 - * 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [30°,70°]),|pitch|<=20 + * 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-84°,-24°]),|pitch|<=24 + * 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [24°,84°]),|pitch|<=24 * ok=true 时 hint='保持不动';输入非法(含 estimate 返回 null 的退化特征点)时 ok=false、hint='请正对屏幕'。 */ function check(points, target, mirrored) { @@ -295,13 +296,13 @@ 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) 正面步骤只提示回正,不能提前引导拍下一张侧脸。 var sc = HeadPose.check(makeSide(), 'front', false); - ok(sc.ok === false && sc.hint === '请向右转头', '左转侧脸 front 拒绝,hint=请向右转头'); + ok(sc.ok === false && sc.hint === '请正对屏幕', '左转侧脸 front 拒绝,hint=请正对屏幕'); var scb = makeSide(); scb[30] = { x: 182.3, y: 140 }; scb[33] = { x: 182.3, y: 161.5 }; - ok(HeadPose.check(scb, 'front', false).hint === '请向左转头', '右转侧脸 front 拒绝,hint=请向左转头'); + ok(HeadPose.check(scb, 'front', false).hint === '请正对屏幕', '右转侧脸 front 拒绝,hint=请正对屏幕'); // 8) roll:右眼(45 点)下移 10px → roll≈5.7° var tilted = makeFrontal(); @@ -314,10 +315,10 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m 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=请正对屏幕'); var up = makeFrontal(); up[33] = { x: 200, y: 200 }; - ok(HeadPose.check(up, 'front', false).hint === '请略微低头', '抬头过多 front 拒绝,hint=请略微低头'); + ok(HeadPose.check(up, 'front', false).hint === '请正对屏幕', '抬头过多 front 拒绝,hint=请正对屏幕'); // 10) 非法输入:check 不抛异常,返回兜底引导 var bad = HeadPose.check(null, 'front', false); @@ -336,8 +337,8 @@ 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) Phone feedback: restore the 15° frontal margin, still reject 16°+. - ok(HeadPose.config.FRONT_YAW_MAX === 15, 'config.FRONT_YAW_MAX = 15'); + // 13) Phone feedback: expand the frontal margin to 18 degrees. + ok(HeadPose.config.FRONT_YAW_MAX === 18, 'config.FRONT_YAW_MAX = 18'); 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 }; @@ -351,7 +352,7 @@ if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.m 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=请向右转头'); + ok(sc3.ok === true, 'yaw≈16.2° front 通过(放宽20%)'); if (failures > 0) { console.error('\n' + failures + ' 项自测失败'); diff --git a/h5/test/capture-quality-browser.mjs b/h5/test/capture-quality-browser.mjs index 6f4a743..c3a8534 100644 --- a/h5/test/capture-quality-browser.mjs +++ b/h5/test/capture-quality-browser.mjs @@ -147,11 +147,11 @@ try { 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(row.roll) <= 18); } - 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(Math.abs(body.capture_diagnostics.views.front.yaw) <= 18); + assert(body.capture_diagnostics.views.left.yaw <= -24 && body.capture_diagnostics.views.left.yaw >= -84); + assert(body.capture_diagnostics.views.right.yaw >= 24 && body.capture_diagnostics.views.right.yaw <= 84); 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; invalid front stays on step 1 with neutral audio, no early turn; actual decoded shot-done finishes before side guidance; wrong-direction frames blocked; approved samples/camera cleanup; no paid call/backend writes'); diff --git a/h5/test/customer-feedback-fourth.js b/h5/test/customer-feedback-fourth.js index 5e696e2..2b1ca44 100644 --- a/h5/test/customer-feedback-fourth.js +++ b/h5/test/customer-feedback-fourth.js @@ -28,27 +28,33 @@ 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, 'former strict front limit now passes'); -for (const yaw of [-16, 16, -45, 45]) { +for (const yaw of [-17.99, 17.99]) assert(check(face(yaw)).ok, '20% relaxed front limit now passes'); +for (const yaw of [-18.01, 18.01, -45, 45]) { assert(!check(face(yaw)).ok); assert.equal(check(face(yaw)).reason, 'angle'); assert.equal(check(face(yaw)).hint, '请正对屏幕', 'front correction must not ask for a side view before capture'); assert.equal(check(face(yaw), { mirrored: true }).hint, '请正对屏幕'); } -for (const pitch of [-17, 17]) assert(check(face(0, pitch)).ok); -for (const pitch of [-20, 20]) assert.equal(check(face(0, pitch)).hint, '请正对屏幕'); +for (const pitch of [-21.59, 21.59]) assert(check(face(0, pitch)).ok); +for (const pitch of [-21.61, 21.61]) assert.equal(check(face(0, pitch)).hint, '请正对屏幕'); assert.equal(check(face(0), { target: 'right' }).hint, '请向左转头', 'side step retains its proper turn instruction'); assert.equal(check(face(0), { target: 'left' }).hint, '请向右转头'); 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(0,0,17.99)).ok); +assert.equal(check(face(0, 0, 18.01)).hint, '请勿歪头'); +for (const target of ['left','right']) { + const sign=target==='left'?-1:1; + for (const yaw of [23.99,84.01]) assert(!check(face(sign*yaw),{target}).ok); + for (const yaw of [24.01,83.99]) assert(check(face(sign*yaw),{target}).ok); + for (const pitch of [-23.99,23.99]) assert(check(face(sign*45,pitch),{target}).ok); + for (const pitch of [-24.01,24.01]) assert(!check(face(sign*45,pitch),{target}).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); +assert(!check(face(23), { target: 'left', ignoreDirection: true }).ok); for (const luma of [null, 50, 240, NaN]) assert(!check(face(), { luma }).ok); assert.equal(check(face(), { luma: 50 }).reason, 'quality', 'lighting must not trigger angle vibration'); assert.equal(check(null).reason, 'quality'); @@ -108,7 +114,7 @@ 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.version, '2026-10-15-angle-relaxed-20-v3'); assert.equal(normalized.views.front.luma, undefined); assert(!JSON.stringify(normalized).includes('discard')); assert.equal(Diagnostics.normalize({ version: '