fix(camera): constrain brightness and adjust side photo previews
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 皱纹三视图标注
|
||||
|
||||
- 报告皱纹页卡展示正面大图和两张侧脸缩略图,点击查看大图时沿用缩略图的缩放、位移与裁切范围,并保留标注。
|
||||
- 报告皱纹页卡展示正面大图和两张侧脸缩略图,并保留各自标注。正面放大沿用缩略图的裁切;侧脸放大在缩略图的填充基础上缩小50%,再向下移动缩放后图片高度的50%。大图外框不动,顶部留白按确认效果保留,报告小图不变。
|
||||
- 每张照片只使用对应照片的皱纹增强图或真实标注坐标,不将正面标注投射到侧脸。
|
||||
- 新检测优先复用三视图接口已提供的侧脸标注;缺失时分别以左右侧脸为主图补取,每侧最多调用一次。通常新增两次计费调用。
|
||||
- 补取串行执行,每次前间隔 4 秒,每侧最多等待 20 秒;整个检测按 120 秒预算限制补取,给现有 150 秒前端超时预留余量。因此生成报告会增加等待时间。
|
||||
|
||||
+16
-5
@@ -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' }, '');
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: '<script>' }).version, 'unknown');
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
const assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
|
||||
const source=fs.readFileSync(require('node:path').join(__dirname,'../js/app.js'),'utf8');
|
||||
const helper=source.slice(source.indexOf(' function reportZoomSource('),source.indexOf(" document.querySelectorAll('.card-detail').forEach(function (card, group)"));
|
||||
const drawn=[],canvases=[];
|
||||
const frame={getBoundingClientRect:()=>({left:0,top:0,width:100,height:90})};
|
||||
const img={naturalWidth:1000,naturalHeight:1000,src:'original',getBoundingClientRect:()=>({left:-90,top:-104,width:280,height:252})};
|
||||
const context={getComputedStyle:e=>e===frame?{backgroundColor:'#f6f6f6'}:{objectFit:'cover',objectPosition:'50% 50%'},document:{createElement:()=>{
|
||||
const canvas={getContext:()=>({fillRect(){},drawImage(...args){drawn.push(args);}}),toDataURL:()=> 'cropped'};canvases.push(canvas);return canvas;
|
||||
}}};
|
||||
vm.runInNewContext(helper,context);
|
||||
assert.equal(context.reportZoomSource(img,frame,1),'cropped');
|
||||
assert.equal(context.reportZoomSource(img,frame,.5),'cropped');
|
||||
assert.equal(drawn[1][3],drawn[0][3]*.5);assert.equal(drawn[1][4],drawn[0][4]*.5);
|
||||
assert.equal(canvases[0].width,canvases[1].width);assert.equal(canvases[0].height,canvases[1].height);
|
||||
assert(drawn[1][1]<=0&&drawn[1][1]+drawn[1][3]>=canvases[1].width);
|
||||
assert(drawn[1][2]<=0&&drawn[1][2]+drawn[1][4]>=canvases[1].height);
|
||||
assert.equal(context.reportZoomSource(img,frame,.5,.5),'cropped');
|
||||
assert.equal(drawn[2][2]-drawn[1][2],drawn[1][4]*.5,'move down by exactly half the scaled photo height');
|
||||
assert.equal(drawn[2][1],drawn[1][1]);assert.equal(drawn[2][3],drawn[1][3]);assert.equal(drawn[2][4],drawn[1][4]);
|
||||
assert.equal(canvases[2].height,canvases[1].height);
|
||||
assert(source.includes('sideButton ? .5 : 1'),'half-sized fill applies only to side-photo large previews');
|
||||
console.log('PASS side zoom fill: size halved, then moved down exactly 50% photo height; preview frame/front/small images unchanged');
|
||||
Reference in New Issue
Block a user