diff --git a/h5/js/app.js b/h5/js/app.js index 1f74006..8143e6c 100644 --- a/h5/js/app.js +++ b/h5/js/app.js @@ -607,7 +607,7 @@ var img = new Image(); img.onload = function () { faceapi - .detectAllFaces(img, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE })) + .detectAllFaces(img, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: CaptureQuality.scoreThreshold(target) })) .withFaceLandmarks() .then(function (faces) { var d = faces[0], box = d && d.detection.box; @@ -768,8 +768,8 @@ var MODEL_URL = 'vendor/faceapi/models'; var DETECT_INTERVAL = 150; // 检测帧间隔(ms) var DETECT_INPUT_SIZE = 416; // tinyFaceDetector 输入尺寸:320 下正脸置信度仅 0.67 且框偏松,416 置信度 ~0.99 框更准(CPU 端略慢,移动端 GPU 无感) - var DETECT_SCORE = CaptureQuality.config.score; var MISS_FRAMES_MAX = 3; // 连续漏检 ≥3 帧(~450ms)才报「未检测到人脸」,容忍单帧抖动 + var FRONT_MISS_FRAMES_MAX = 6; // 正面首次发现人脸前,多给相机对焦/曝光几次检测机会。 var NO_PROGRESS_MS = 12000; // 单步超过该时长仍未抓拍→浮出调整提示 var PREVIEW_SCALE = 1; // 与 CSS #cam-video 铺满预览一致,镜像只改变方向 @@ -860,6 +860,7 @@ holdState = null; resetAngleHaptic(); missFrames = 0; + frontFaceSeen = false; oppFrames = 0; lastHint = ''; clearTimeout(nudgeTimer); @@ -913,7 +914,7 @@ if (shutterLock || capturing || !camLive) return Promise.resolve(); // 抓帧/流程推进中不判姿态 var step = camStep, seq = camSeq, generation = detGeneration; // 旧镜头推理结果不能污染新镜头 return faceapi - .detectAllFaces(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: DETECT_SCORE })) + .detectAllFaces(camVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: DETECT_INPUT_SIZE, scoreThreshold: CaptureQuality.scoreThreshold(CAM_STEPS[step].target) })) .withFaceLandmarks() .then(function (d) { if (step !== camStep || seq !== camSeq || generation !== detGeneration) return; @@ -941,6 +942,7 @@ } catch (e) { return null; } } var missFrames = 0; // 连续漏检帧计数:单帧抖动不报「未检测到人脸」 + var frontFaceSeen = false; /* camera-haptic-pure-begin */ function captureHaptic(nav) { // Unsupported/blocked vibration must never interrupt automatic capture. @@ -976,7 +978,10 @@ if (Array.isArray(d)) d = d[0]; var hint = '', ok = false; missFrames = d ? 0 : missFrames + 1; - var hasFace = !!d, noFace = !hasFace && missFrames >= MISS_FRAMES_MAX; + var hasFace = !!d; + if (hasFace && CAM_STEPS[camStep].target === 'front') frontFaceSeen = true; + var missLimit = CAM_STEPS[camStep].target === 'front' && !frontFaceSeen ? FRONT_MISS_FRAMES_MAX : MISS_FRAMES_MAX; + var noFace = !hasFace && missFrames >= missLimit; if (noFace) { hint = '未检测到人脸'; } else if (!hasFace) { diff --git a/h5/js/capture-quality.js b/h5/js/capture-quality.js index 7bafc18..ecd6d9a 100644 --- a/h5/js/capture-quality.js +++ b/h5/js/capture-quality.js @@ -6,11 +6,12 @@ '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-15-pitch-roll-relaxed-v7', score: 0.4, + var config = Object.freeze({ version: '2026-10-16-front-detection-v8', score: 0.4, frontScore: 0.3, 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 }); function finite(v) { return typeof v === 'number' && isFinite(v); } + function scoreThreshold(target) { return target === 'front' ? config.frontScore : config.score; } function evaluate(d, options) { var failed = function(hint, reason) { return { ok: false, hint: hint, reason: reason || 'quality' }; }; if (!d) return failed('未检测到人脸'); @@ -18,7 +19,7 @@ var box = d.detection.box, w = options.width, h = options.height; if (!box || ![box.x, box.y, box.width, box.height, w, h].every(finite) || w <= 0 || h <= 0 || box.width <= 0 || box.height <= 0) return failed('请将脸部完整移入取景框'); var ratio = box.height / h; - if (!finite(d.detection.score) || d.detection.score < config.score) return failed('请保持面部清晰'); + if (!finite(d.detection.score) || d.detection.score < scoreThreshold(options.target)) return failed('请保持面部清晰'); if (ratio < config.near) return failed('请靠近一点'); if (ratio > config.far) return failed('请远一点'); if (box.x < w * config.edge || box.y < h * config.edge || box.x + box.width > w * (1 - config.edge) || box.y + box.height > h * (1 - config.edge)) return failed('请将脸部完整移入取景框'); @@ -51,5 +52,5 @@ { since: state.since, lastAt: now, frames: state.frames + 1, anchor: state.anchor }; return { state: next, ready: now - next.since >= config.holdMs && next.frames >= config.minFrames }; } - return { config: config, evaluate: evaluate, hold: hold }; + return { config: config, scoreThreshold: scoreThreshold, evaluate: evaluate, hold: hold }; }); diff --git a/h5/test/capture-quality-browser.mjs b/h5/test/capture-quality-browser.mjs index c01d793..001c32d 100644 --- a/h5/test/capture-quality-browser.mjs +++ b/h5/test/capture-quality-browser.mjs @@ -152,7 +152,7 @@ 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 >= .4 && row.luma >= 110 && row.luma <= 220); + assert(row.score >= (key==='front'?.3:.4) && row.luma >= 110 && row.luma <= 220); 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 f27c4b5..2ac19f5 100644 --- a/h5/test/customer-feedback-fourth.js +++ b/h5/test/customer-feedback-fourth.js @@ -73,7 +73,11 @@ 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 = .45; assert(check(weak).ok); -weak.detection.score = .35; assert(!check(weak).ok); +for(const score of [.3,.35,.3999]){const d=face();d.detection.score=score;assert(check(d).ok,'relaxed front confidence');} +const low=face();low.detection.score=.2999;assert.equal(check(low).hint,'请保持面部清晰'); +for(const target of ['left','right']){const d=face(target==='left'?-45:45);d.detection.score=.35;assert.equal(check(d,{target}).hint,'请保持面部清晰');d.detection.score=.4;assert(check(d,{target}).ok);} +assert.equal(Quality.scoreThreshold('front'),.3);assert.equal(Quality.scoreThreshold('left'),.4);assert.equal(Quality.scoreThreshold('right'),.4); +weak.detection.score = .29; 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); @@ -116,7 +120,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-15-pitch-roll-relaxed-v7'); +assert.equal(normalized.version, '2026-10-16-front-detection-v8'); assert.equal(normalized.views.front.luma, undefined); assert(!JSON.stringify(normalized).includes('discard')); assert.equal(Diagnostics.normalize({ version: '