From 8115db4ec26f1fefe09e7129d9a9575fe71c5209 Mon Sep 17 00:00:00 2001 From: xsl Date: Thu, 15 Oct 2026 20:41:28 +0800 Subject: [PATCH] fix(camera): constrain brightness and adjust side photo previews --- docs/皱纹三视图标注说明-2026-10-15.md | 2 +- h5/js/app.js | 21 ++++++++++++++++----- h5/js/capture-quality.js | 4 ++-- h5/test/capture-quality-browser.mjs | 12 +++++++++--- h5/test/customer-feedback-fourth.js | 9 +++++---- h5/test/zoom-fill.js | 23 +++++++++++++++++++++++ 6 files changed, 56 insertions(+), 15 deletions(-) create mode 100644 h5/test/zoom-fill.js diff --git a/docs/皱纹三视图标注说明-2026-10-15.md b/docs/皱纹三视图标注说明-2026-10-15.md index b0fe2e7..c378a2e 100644 --- a/docs/皱纹三视图标注说明-2026-10-15.md +++ b/docs/皱纹三视图标注说明-2026-10-15.md @@ -1,6 +1,6 @@ # 皱纹三视图标注 -- 报告皱纹页卡展示正面大图和两张侧脸缩略图,点击查看大图时沿用缩略图的缩放、位移与裁切范围,并保留标注。 +- 报告皱纹页卡展示正面大图和两张侧脸缩略图,并保留各自标注。正面放大沿用缩略图的裁切;侧脸放大在缩略图的填充基础上缩小50%,再向下移动缩放后图片高度的50%。大图外框不动,顶部留白按确认效果保留,报告小图不变。 - 每张照片只使用对应照片的皱纹增强图或真实标注坐标,不将正面标注投射到侧脸。 - 新检测优先复用三视图接口已提供的侧脸标注;缺失时分别以左右侧脸为主图补取,每侧最多调用一次。通常新增两次计费调用。 - 补取串行执行,每次前间隔 4 秒,每侧最多等待 20 秒;整个检测按 120 秒预算限制补取,给现有 150 秒前端超时预留余量。因此生成报告会增加等待时间。 diff --git a/h5/js/app.js b/h5/js/app.js index 1c77729..33b4fb0 100644 --- a/h5/js/app.js +++ b/h5/js/app.js @@ -1733,7 +1733,7 @@ }); /* ─── 报告页:每张维度卡各自的子 Tab;点检测图放大,再点返回 ─── */ - function reportZoomSource(img, frame) { + function reportZoomSource(img, frame, fillScale, contentDown) { // Bake the thumbnail's CSS zoom/translation and clipping into the large // image. Annotations are already composited with their own photograph. try { @@ -1743,17 +1743,28 @@ if (style.objectFit==='cover' || style.objectFit==='contain') { scaleX=scaleY=style.objectFit==='cover' ? Math.max(scaleX,scaleY) : Math.min(scaleX,scaleY); } - var width=img.naturalWidth*scaleX, height=img.naturalHeight*scaleY; + fillScale=fillScale || 1; + var width=img.naturalWidth*scaleX*fillScale, height=img.naturalHeight*scaleY*fillScale; var position=style.objectPosition.split(' '); var px=position[0].endsWith('%') ? parseFloat(position[0])/100 : .5; var py=position[1] && position[1].endsWith('%') ? parseFloat(position[1])/100 : .5; + var left=box.left-windowBox.left+(box.width-width)*px; + var top=box.top-windowBox.top+(box.height-height)*py; + // Smaller side-photo fill keeps its center, but never exposes a blank + // edge when the earlier vertical offset would push the image outside. + if (fillScale < 1) { + if (width >= windowBox.width) left=Math.max(windowBox.width-width,Math.min(0,left)); + if (height >= windowBox.height) top=Math.max(windowBox.height-height,Math.min(0,top)); + } + // Explicit preview adjustment uses the scaled photo's own height, + // not the frame height. Do not clamp away the requested displacement. + top += height * (contentDown || 0); var ratio=Math.min(4,1600/Math.max(windowBox.width,windowBox.height)); var cv=document.createElement('canvas'); cv.width=Math.round(windowBox.width*ratio);cv.height=Math.round(windowBox.height*ratio); var ctx=cv.getContext('2d'); ctx.fillStyle=getComputedStyle(frame).backgroundColor;ctx.fillRect(0,0,cv.width,cv.height); - ctx.drawImage(img,(box.left-windowBox.left+(box.width-width)*px)*ratio, - (box.top-windowBox.top+(box.height-height)*py)*ratio,width*ratio,height*ratio); + ctx.drawImage(img,left*ratio,top*ratio,width*ratio,height*ratio); return cv.toDataURL('image/jpeg',.95); } catch (_) { return img.src; } } @@ -1805,7 +1816,7 @@ var sideButton = e.target.closest('.contour-side-photo, .wrinkle-side-photo'); var img = sideButton ? sideButton.querySelector('img') : e.target.closest('.ditem-img'); if (img && img.src && !img.hidden && img.closest('.dpanel').classList.contains('active')) { - $('#zoom-img').src = reportZoomSource(img, sideButton || img.closest('.viz-frame')); + $('#zoom-img').src = reportZoomSource(img, sideButton || img.closest('.viz-frame'), sideButton ? .5 : 1, sideButton ? .5 : 0); openOverlay('p-zoom'); history.pushState({ screen: currentScreen, overlay: 'p-zoom' }, ''); } diff --git a/h5/js/capture-quality.js b/h5/js/capture-quality.js index 067cfb4..347f7e8 100644 --- a/h5/js/capture-quality.js +++ b/h5/js/capture-quality.js @@ -6,9 +6,9 @@ '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-dark-100-v4', score: 0.4, + var config = Object.freeze({ version: '2026-10-15-light-150-220-v5', score: 0.4, near: 0.30, far: 0.68, center: 0.16, edge: 0.01, - dark: 100, bright: 235, holdMs: 450, minFrames: 2, maxGapMs: 2000, + dark: 150, 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 evaluate(d, options) { diff --git a/h5/test/capture-quality-browser.mjs b/h5/test/capture-quality-browser.mjs index 79ffb6d..8c8bdc0 100644 --- a/h5/test/capture-quality-browser.mjs +++ b/h5/test/capture-quality-browser.mjs @@ -51,14 +51,16 @@ try { AudioRecorder.prototype = OriginalAudioContext.prototype; window.AudioContext = window.webkitAudioContext = AudioRecorder; const canvas = document.createElement('canvas'); canvas.width = 800; canvas.height = 600; - let currentImage, offsetX = 0; + let currentImage, offsetX = 0, lightGain = 1; function draw() { if (!currentImage) return; const c = canvas.getContext('2d'); c.fillStyle = '#111'; c.fillRect(0, 0, canvas.width, canvas.height); + c.filter='brightness('+lightGain+')'; c.drawImage(currentImage, offsetX, 0, canvas.width, canvas.height); + c.filter='none'; } setInterval(draw, 100); - window.__cameraFixture = { canvas, set: async name => { + window.__cameraFixture = { canvas, light:gain=>{lightGain=gain;draw();}, set: async name => { const image = new Image(); image.src = 'sample-' + name; await image.decode(); // Simulate a person following the centering prompt, not an off-center // landscape source stretched into a portrait camera frame. @@ -115,6 +117,10 @@ 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.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)); try { await page.waitForFunction(() => document.querySelector('#cam-step').textContent.includes('2/3')); } catch (error) { console.log(await page.evaluate(async () => { @@ -146,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 >= 100 && row.luma <= 235); + assert(row.score >= .4 && row.luma >= 150 && row.luma <= 220); assert(Math.abs(row.roll) <= 18); } 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 bea3211..34b39f1 100644 --- a/h5/test/customer-feedback-fourth.js +++ b/h5/test/customer-feedback-fourth.js @@ -24,7 +24,7 @@ function points(yaw = 0, pitch = 0, roll = 0) { return result; } const face = (yaw = 0, pitch = 0, roll = 0) => ({ detection: { score: .95, box: { x: 170, y: 160, width: 260, height: 300 } }, landmarks: { positions: points(yaw, pitch, roll) } }); -const options = { count: 1, target: 'front', width: 600, height: 600, offsetX: 0, luma: 120, mirrored: false }; +const options = { count: 1, target: 'front', width: 600, height: 600, offsetX: 0, luma: 180, mirrored: false }; const check = (d, changes) => Quality.evaluate(d, { ...options, ...changes }); assert(check(face()).ok); assert(!check(null).ok); assert(!check(face(), { count: 2 }).ok); @@ -61,8 +61,9 @@ 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 [100, 100.01, 230, 235]) assert(check(face(), { luma }).ok); -for (const luma of [65,70,99.99]) assert.equal(check(face(),{luma}).hint,'请到光线充足处'); +for (const luma of [150, 150.01, 180, 219.99, 220]) assert(check(face(), { luma }).ok); +for (const luma of [65,100,120,149.99]) assert.equal(check(face(),{luma}).hint,'请到光线充足处'); +for (const luma of [220.01,235,255]) assert.equal(check(face(),{luma}).hint,'光线过强,请避开强光'); assert(check(face(), { offsetX: .15 }).ok); assert(!check(face(), { offsetX: .17 }).ok); for (const changes of [{ height: 190 }, { height: 400 }, { y: 8 }]) { @@ -115,7 +116,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-dark-100-v4'); +assert.equal(normalized.version, '2026-10-15-light-150-220-v5'); assert.equal(normalized.views.front.luma, undefined); assert(!JSON.stringify(normalized).includes('discard')); assert.equal(Diagnostics.normalize({ version: '