diff --git a/docs/拍摄光线仅提示-2026-10-16.md b/docs/拍摄光线仅提示-2026-10-16.md new file mode 100644 index 0000000..3872884 --- /dev/null +++ b/docs/拍摄光线仅提示-2026-10-16.md @@ -0,0 +1,9 @@ +# 拍摄光线仅提示 + +亮度低于 110 时显示「建议到光线明亮处拍摄,保持不动即可拍摄」;高于 220 时显示「光线过强,建议避开强光,保持不动即可拍摄」。110–220 不提示光线问题。 + +亮度不再是实时捕捉或拍后 JPEG 校验的拦截条件,无法读出亮度也不单独拦截。其他人脸置信度、角度、位置、距离和稳定性要求不变;过暗导致无法识别人脸时仍需要调整,不保证检测接口接受所有极暗或过曝照片。 + +保留亮度观测值,供后台校准使用;不改变报告计分、结论或付费接口调用次数。策略版本 `2026-10-16-light-advisory-v10`。 + +验证包含三视图暗/亮边界测试、实时暗光提示、保持暗光完成倒计时和拍后校验、实际本地模型自动三连拍;不调用付费检测。 diff --git a/h5/js/app.js b/h5/js/app.js index f47cd9a..636afff 100644 --- a/h5/js/app.js +++ b/h5/js/app.js @@ -1036,7 +1036,7 @@ updateAngleHaptic(!!(verdict && verdict.reason === 'angle'), Date.now()); setMask(ok ? 'ok' : (noFace ? 'no-face' : '')); // 合格时「保持不动」只显示不播报:随即抓拍,「拍摄完成」会截断它 - setHint(ok ? '保持不动' : hint, ok, ok); + setHint(ok ? (verdict.warning ? verdict.warning + ',保持不动即可拍摄' : '保持不动') : hint, ok, ok); if (!ok) { okSince = 0; holdState = null; return; } var held = CaptureQuality.hold(holdState, verdict.observation, Date.now()); holdState = held.state; diff --git a/h5/js/capture-quality.js b/h5/js/capture-quality.js index a0d33b1..45ec23a 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-16-confidence-v9', score: 0.3, frontScore: 0.25, + var config = Object.freeze({ version: '2026-10-16-light-advisory-v10', score: 0.3, frontScore: 0.25, near: 0.30, far: 0.68, center: 0.16, edge: 0.01, dark: 110, bright: 220, holdMs: 450, minFrames: 2, maxGapMs: 2000, yawDrift: 8, pitchDrift: 8, centerDrift: 0.055, sizeDrift: 0.065 }); @@ -33,11 +33,11 @@ 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, finite(check.yaw) ? 'angle' : 'quality'); - 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, + // Lighting is advisory for both live frames and saved JPEGs, never a gate. + var warning = finite(options.luma) && options.luma < config.dark ? '建议到光线明亮处拍摄' : + finite(options.luma) && options.luma > config.bright ? '光线过强,建议避开强光' : ''; + return { ok: true, hint: '保持不动', warning: warning, observation: { yaw: pose.yaw, pitch: pose.pitch, roll: pose.roll, + score: d.detection.score, luma: finite(options.luma) ? options.luma : null, ratio: ratio, center_x: (box.x + box.width / 2) / w, center_y: (box.y + box.height / 2) / h } }; } function hold(state, observation, now) { diff --git a/h5/test/capture-quality-browser.mjs b/h5/test/capture-quality-browser.mjs index 001c32d..2144205 100644 --- a/h5/test/capture-quality-browser.mjs +++ b/h5/test/capture-quality-browser.mjs @@ -117,10 +117,8 @@ try { assert((await page.locator('#cam-step').textContent()).includes('1/3'), 'invalid frontal angle never advances'); assert.equal(await page.evaluate(() => window.__voice.some(row => /^hint-turn-|^step[23]-|^shot-done$/.test(row.id))), false, 'no turn/next-step/completion audio before a successful front photo'); await page.evaluate(() => { window.__cameraFixture.light(.6); return window.__cameraFixture.set('C3_180.jpg'); }); - await page.waitForFunction(()=>window.__evaluations.some(row=>row.target==='front'&&!row.ok&&row.hint==='请到光线充足处')); - assert((await page.locator('#cam-step').textContent()).includes('1/3'),'dim sample cannot advance'); - // Simulate improving room lighting, not bypassing the actual quality gate. - await page.evaluate(()=>window.__cameraFixture.light(1.3)); + await page.waitForFunction(()=>document.querySelector('#cam-hint').textContent.includes('建议到光线明亮处拍摄')); + // Keep the dim sample through countdown AND final JPEG validation. try { await page.waitForFunction(() => document.querySelector('#cam-step').textContent.includes('2/3')); } catch (error) { console.log(await page.evaluate(async () => { @@ -132,6 +130,7 @@ try { }).then(value => JSON.stringify(value, null, 2)), errors); throw error; } await page.waitForFunction(() => window.__voice.some(row => row.id === 'step2-rightface')); + await page.evaluate(()=>window.__cameraFixture.light(1.3)); const firstVoice = await page.evaluate(() => window.__voice); const completion = firstVoice.find(row => row.id === 'shot-done'), turn = firstVoice.find(row => row.id === 'step2-rightface'); assert(completion && turn && completion.step.includes('1/3') && turn.step.includes('2/3')); @@ -152,7 +151,8 @@ 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 >= (key==='front'?.3:.4) && row.luma >= 110 && row.luma <= 220); + assert(row.score >= (key==='front'?.25:.3)); + if(key==='front') assert(row.luma < 110, 'dim JPEG still passes final validation'); assert(Math.abs(row.roll) <= 22); } assert(Math.abs(body.capture_diagnostics.views.front.yaw) <= 18); diff --git a/h5/test/customer-feedback-fourth.js b/h5/test/customer-feedback-fourth.js index 9e78e7f..7437134 100644 --- a/h5/test/customer-feedback-fourth.js +++ b/h5/test/customer-feedback-fourth.js @@ -55,15 +55,17 @@ 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(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'); +for (const luma of [null, 0, 50, 240, 255, NaN]) assert(check(face(), { luma }).ok, 'lighting is advisory, not blocking'); +assert.equal(check(face(), { luma: 50 }).reason, undefined, 'lighting must not trigger angle vibration'); assert.equal(check(null).reason, 'quality'); assert.equal(check(face(), { count: 2 }).reason, 'quality'); const brokenLandmarks = face(); brokenLandmarks.landmarks.positions = []; assert.equal(check(brokenLandmarks).reason, 'quality', 'unreadable pose is not confirmed wrong angle'); for (const luma of [110,110.01,120,149.99,180,219.99,220]) assert(check(face(), { luma }).ok); -for (const luma of [65,100,109.99]) assert.equal(check(face(),{luma}).hint,'请到光线充足处'); -for (const luma of [220.01,235,255]) assert.equal(check(face(),{luma}).hint,'光线过强,请避开强光'); +for (const target of ['front','left','right']) for (const luma of [0,65,100,109.99,110,220,220.01,235,255,null,NaN]) { + const verdict=check(face(target==='front'?0:target==='left'?-45:45),{target,luma}); + assert(verdict.ok);assert.equal(verdict.warning,Number.isFinite(luma)&&luma<110?'建议到光线明亮处拍摄':Number.isFinite(luma)&&luma>220?'光线过强,建议避开强光':''); +} assert(check(face(), { offsetX: .15 }).ok); assert(!check(face(), { offsetX: .17 }).ok); for (const changes of [{ height: 190 }, { height: 400 }, { y: 8 }]) { @@ -121,7 +123,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-16-confidence-v9'); +assert.equal(normalized.version, '2026-10-16-light-advisory-v10'); assert.equal(normalized.views.front.luma, undefined); assert(!JSON.stringify(normalized).includes('discard')); assert.equal(Diagnostics.normalize({ version: '