diff --git a/docs/取消拍后校验-2026-10-16.md b/docs/取消拍后校验-2026-10-16.md new file mode 100644 index 0000000..9b0dea6 --- /dev/null +++ b/docs/取消拍后校验-2026-10-16.md @@ -0,0 +1,9 @@ +# 取消每张照片拍后的二次检测 + +删除 `checkStill` 以及实时抓拍、系统相机/相册选图路径对它的调用。保存/读取照片成功后直接进入该张拍摄完成与下一步流程,不再检测已保存照片的人脸、角度、亮度或等待检测模型。 + +保留实时预览阶段的人脸识别、角度、位置与稳定性检测;光线仍仅提醒。保留 321 动画、补光、空帧/图片读取失败提示和取消/后台中断的过期回调保护。三张完成后的测肤报告接口调用不变,该接口仍可能因无法检测照片等原因返回失败。 + +拍摄观测诊断现在记录最后一次通过的预览帧,明确标记 `check_stage: preview`;系统相机/文件及手动降级无预览数据时标记 `check_stage: none`,不伪造拍后检测通过。策略版本 `2026-10-16-preview-only-v11`。 + +验证:真实本地模型自动三连拍时禁止调用静态照片检测;文件路径直接推进及取消保护;321 倒计时与流程漏斗回归。不调用付费接口。 diff --git a/h5/js/app.js b/h5/js/app.js index 636afff..a8c0a52 100644 --- a/h5/js/app.js +++ b/h5/js/app.js @@ -590,62 +590,14 @@ c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale); c.getContext('2d').drawImage(img, 0, 0, c.width, c.height); var url = c.toDataURL('image/jpeg', 0.88); - shutterLock = true; // 校验期间不接受新的拍摄/选图 - showCamStatus('照片检查中…'); - checkStill(url, CAM_STEPS[step].target, { mode: 'file', ignoreDirection: true, step: step, seq: seq }).then(function (err) { - if (seq !== camSeq || step !== camStep) return; // 期间已退出/重置 - hideCamStatus(); - shutterLock = false; - if (err) { toast(err); voice.play(VOICE.fail, { cut: true }); return; } // 不合格:停在本步重拍 - onPhotoCaptured(url); - }); + captureDiagnostics[step] = { mode: IS_MOCK ? 'mock' : 'file', check_stage: 'none', direction_checked: false }; + onPhotoCaptured(url); }; img.onerror = function () { toast('照片读取失败,请重新选择'); }; img.src = reader.result; }; reader.readAsDataURL(f); } - // Live final frames and file uploads share all local quality gates. Files may - // be mirrored by the system camera; their side direction remains unverified. - function checkStill(dataUrl, target, options) { - options = options || { mode: 'file', ignoreDirection: true, step: camStep, seq: camSeq }; - function remember(sample) { if (options.seq === camSeq) captureDiagnostics[options.step] = sample; } - if (IS_MOCK) { remember({ mode: 'mock' }); return Promise.resolve(''); } - if (!modelsOk || typeof faceapi === 'undefined') { - if (modelsPromise && !options.waited) { - options.waited = true; - return new Promise(function (resolve) { - var timer = setTimeout(resolve, 8000); - modelsPromise.then(function () { clearTimeout(timer); resolve(); }, function () { clearTimeout(timer); resolve(); }); - }).then(function () { return checkStill(dataUrl, target, options); }); - } - // Keep existing unsupported-device access, explicitly marked for admin. - // Never silently treat an unavailable local engine as a passed check. - remember({ mode: 'unchecked', reason: 'model-unavailable' }); - return Promise.resolve(''); - } - return new Promise(function (resolve) { - var img = new Image(); - img.onload = function () { - faceapi - .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; - var verdict = CaptureQuality.evaluate(d, { count: faces.length, target: target, - width: img.naturalWidth, height: img.naturalHeight, - offsetX: box ? (box.x + box.width / 2) / img.naturalWidth - .5 : 0, - luma: box ? faceLuma(box, img) : null, - mirrored: options.mode === 'live' && sideYawFlip, ignoreDirection: options.ignoreDirection }); - if (!verdict.ok) { resolve(verdict.hint + ',请重新拍摄'); return; } - remember(Object.assign({ mode: options.mode, direction_checked: !options.ignoreDirection }, verdict.observation)); - resolve(''); - }, function () { resolve('照片检查失败,请重新拍摄'); }); - }; - img.onerror = function () { resolve('照片读取失败,请重新选择'); }; - img.src = dataUrl; - }); - } function showCamStatus(text) { var s = $('#cam-status'); s.textContent = text; s.classList.add('show'); } function hideCamStatus() { $('#cam-status').classList.remove('show'); } // 降级模式:快门调起系统相机,拍完/选完在 change 里推进流程 @@ -1043,7 +995,11 @@ if (holdState.frames === 1) { okSince = holdState.since; } - if (held.ready) { okSince = 0; holdState = null; doCapture(); } + if (held.ready) { + // Observations describe the last accepted PREVIEW, not the saved JPEG. + captureDiagnostics[camStep] = Object.assign({ mode: 'live', check_stage: 'preview', direction_checked: true }, verdict.observation); + okSince = 0; holdState = null; doCapture(); + } } /* ─── 拍照流程(三步,顺序对齐 Figma:正脸 → 右脸出镜 → 左脸出镜) ─── */ @@ -1084,6 +1040,7 @@ // 照片就绪(已通过校验)后推进流程:存图→「拍摄完成」→300ms 过渡→下一步;三步严格按顺序,本步合格才进下一步 function onPhotoCaptured(dataUrl) { + if (!captureDiagnostics[camStep]) captureDiagnostics[camStep] = { mode: IS_MOCK ? 'mock' : 'unchecked', check_stage: 'none', direction_checked: false }; trackCapture(['front', 'right', 'left'][camStep]); shutterLock = true; // 推进窗口上锁(停顿期间不检测、不接受快门),直到下一步/送检 photos[camStep] = dataUrl; @@ -1168,15 +1125,7 @@ capturing = false; if (seq !== camSeq) return; // 期间已退出/重置:不再推进(遮罩仍正常淡出) if (!shot) { shutterLock = false; okSince = 0; toast('相机尚未就绪,请稍候'); return; } // 重置 okSince:避免陈旧累积立即再抓 - var step = camStep; - checkStill(shot, CAM_STEPS[step].target, { mode: 'live', ignoreDirection: false, step: step, seq: seq }).then(function (error) { - if (seq !== camSeq || step !== camStep) return; - if (error) { - shutterLock = false; holdState = null; okSince = 0; - setHint(error, false); armNudge(); return; - } - onPhotoCaptured(shot); - }); + onPhotoCaptured(shot); // No second local detection after capture. }, 300); }, 300); } diff --git a/h5/js/capture-quality.js b/h5/js/capture-quality.js index 45ec23a..959ed15 100644 --- a/h5/js/capture-quality.js +++ b/h5/js/capture-quality.js @@ -5,8 +5,8 @@ })(typeof window !== 'undefined' ? window : this, function(HeadPose) { '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-light-advisory-v10', score: 0.3, frontScore: 0.25, + // retaining two checked preview frames, one face and framing. No JPEG recheck. + var config = Object.freeze({ version: '2026-10-16-preview-only-v11', 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 }); diff --git a/h5/lib/capture-diagnostics.js b/h5/lib/capture-diagnostics.js index c957776..569179f 100644 --- a/h5/lib/capture-diagnostics.js +++ b/h5/lib/capture-diagnostics.js @@ -13,6 +13,7 @@ function normalize(input) { const row = input.views && input.views[key]; if (!row || typeof row !== 'object') continue; const out = { mode: ['live', 'file', 'unchecked', 'mock'].includes(row.mode) ? row.mode : 'unknown' }; + if (['preview', 'none'].includes(row.check_stage)) out.check_stage = row.check_stage; if (row.reason === 'model-unavailable') out.reason = row.reason; if (typeof row.direction_checked === 'boolean') out.direction_checked = row.direction_checked; for (const metric of ['yaw', 'pitch', 'roll', 'score', 'luma', 'ratio', 'center_x', 'center_y']) { diff --git a/h5/test/capture-no-recheck.js b/h5/test/capture-no-recheck.js new file mode 100644 index 0000000..d24e110 --- /dev/null +++ b/h5/test/capture-no-recheck.js @@ -0,0 +1,18 @@ +'use strict'; +const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'); +const source=fs.readFileSync(path.join(__dirname,'../js/app.js'),'utf8'); +assert(!source.includes('checkStill'), 'no still-photo detection helper or call remains'); +const fileFunction=source.slice(source.indexOf(' function readPhotoFile('),source.indexOf(' function showCamStatus(')); +let read,load;const completed=[]; +const ctx={camStep:0,camSeq:1,IS_MOCK:false,captureDiagnostics:[],Math, + FileReader:class{readAsDataURL(){this.result='data:image/jpeg;base64,fixture';read=()=>this.onload();}}, + Image:class{constructor(){this.width=640;this.height=640;load=()=>this.onload();}}, + document:{createElement(){return {getContext(){return {drawImage(){}};},toDataURL(){return 'saved-jpeg';}};}}, + onPhotoCaptured(url){completed.push(url);},toast(){throw Error('unexpected failure');}}; +vm.createContext(ctx);vm.runInContext(fileFunction,ctx); +for(let step=0;step<3;step++){ctx.camStep=step;ctx.readPhotoFile({});read();load();assert.equal(ctx.captureDiagnostics[step].check_stage,'none');assert.equal(completed.length,step+1);} +ctx.readPhotoFile({});read();ctx.camSeq++;load();assert.equal(completed.length,3,'canceled file callback cannot advance'); +const Diagnostics=require('../lib/capture-diagnostics'); +const normalized=Diagnostics.normalize({version:'test',views:{front:{mode:'live',check_stage:'preview'},left:{mode:'file',check_stage:'none'},right:{check_stage:'invented'}}}); +assert.equal(normalized.views.front.check_stage,'preview');assert.equal(normalized.views.left.check_stage,'none');assert.equal(normalized.views.right.check_stage,undefined); +console.log('PASS: no saved-photo recheck, file captures advance directly, stale callbacks ignored, preview-only diagnostics explicit'); diff --git a/h5/test/capture-quality-browser.mjs b/h5/test/capture-quality-browser.mjs index 2144205..292277f 100644 --- a/h5/test/capture-quality-browser.mjs +++ b/h5/test/capture-quality-browser.mjs @@ -96,6 +96,14 @@ try { assert.equal(await page.locator('#s-cam.cam-manual').count(), 0, 'real automatic pipeline, not manual bypass'); await page.evaluate(() => { window.__holdSamples = []; window.__hints = []; window.__evaluations = []; + window.__stillDetections = 0; + const detectFaces = faceapi.detectAllFaces; + faceapi.detectAllFaces = function(input, options) { + // Fixture setup uses sample-file images to align the synthetic video; + // captured/uploaded JPEGs are data URLs and must never be detected again. + if (input instanceof HTMLImageElement && input.src.startsWith('data:')) { window.__stillDetections++; throw Error('post-capture detection must not run'); } + return detectFaces.call(this, input, options); + }; const evaluate = CaptureQuality.evaluate; CaptureQuality.evaluate = function(d, options) { const result = evaluate(d, options); @@ -118,7 +126,7 @@ try { 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(()=>document.querySelector('#cam-hint').textContent.includes('建议到光线明亮处拍摄')); - // Keep the dim sample through countdown AND final JPEG validation. + // Keep the dim sample through countdown and capture, without a JPEG recheck. try { await page.waitForFunction(() => document.querySelector('#cam-step').textContent.includes('2/3')); } catch (error) { console.log(await page.evaluate(async () => { @@ -151,14 +159,16 @@ 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.equal(row.check_stage, 'preview'); assert(row.score >= (key==='front'?.25:.3)); - if(key==='front') assert(row.luma < 110, 'dim JPEG still passes final validation'); + if(key==='front') assert(row.luma < 110, 'dim preview still permits capture'); assert(Math.abs(row.roll) <= 22); } 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.equal(await page.evaluate(() => window.__stillDetections),0,'all three photos proceed without still-image detection'); 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'); + console.log('PASS: actual local models + automatic three-view capture without JPEG revalidation; invalid preview stays on its step, neutral audio; shot-done finishes before side guidance; camera cleanup; no paid call/backend writes'); } finally { await browser.close(); } diff --git a/h5/test/countdown-capture-browser.mjs b/h5/test/countdown-capture-browser.mjs index 015808d..fd8c563 100644 --- a/h5/test/countdown-capture-browser.mjs +++ b/h5/test/countdown-capture-browser.mjs @@ -15,7 +15,6 @@ try { window.__countdown = { start: function(step) { camStep=step; currentScreen='s-cam'; camLive=true; shutterLock=false; captureFromVideo=function(){window.__shots.push(step);return 'fixture';}; - checkStill=function(){return Promise.resolve(null);}; onPhotoCaptured=function(){window.__completed.push(step);}; doCapture(); }, again:doCapture, cancel:leaveCamera, busy:function(){return shutterLock;} diff --git a/h5/test/customer-feedback-fourth.js b/h5/test/customer-feedback-fourth.js index 7437134..ba37e95 100644 --- a/h5/test/customer-feedback-fourth.js +++ b/h5/test/customer-feedback-fourth.js @@ -123,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-light-advisory-v10'); +assert.equal(normalized.version, '2026-10-16-preview-only-v11'); assert.equal(normalized.views.front.luma, undefined); assert(!JSON.stringify(normalized).includes('discard')); assert.equal(Diagnostics.normalize({ version: '