From f5665f326c5c194121482c594ed9c74573c57650 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:19:27 +0300 Subject: [PATCH 1/2] focus: a grid you can read, and the zones that focus somewhere else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First feedback from someone calibrating autofocus with this: "пока не понятно, нужны цифры -- меньше зон, зато в зоне рисовать цифру". Fair. 255 coloured cells show that there is a bright patch somewhere and say nothing about what any of it reads. The readout is now 3x3 by default with the value drawn in each block, digits grouped so five figures can be read at a glance, the sharpest block outlined and labelled. 3x3 / 4x4 / All zones switches it. The fine grid is still what gets measured -- this only changes what is drawn over the picture, so the readout cannot move where the peak actually is. Block value is the MEAN over measured zones, not the sum: 17 columns do not divide by three, and a sum ranks the wider block higher for being wider. Not the maximum either, which is the reduction clamping already defeats. A block with nothing believable in it is null, not zero -- zero is a value, and it sorts below every real block as though it had been measured. The same conversation named the real failure of cheap autofocus: after a year it focuses anywhere but the subject, and dirt on the glass the owner never notices is a large part of why. That turns out to be measurable with what the sweep already collects. Dirt sits millimetres from the lens, so across a defocus sweep it peaks at a lens position nowhere near the rest of the frame. The sweep reads every zone at every position and was throwing all but the maximum away. Each zone's own peak position is now kept, the consensus taken as the median -- one smeared corner peaking at the far end drags a mean toward itself and then judges everything else against a consensus it invented -- and zones far from it are ringed on the picture and named in the verdict. Two restraints. A zone that does not move across the sweep gets no opinion: a blank wall has an argmax and it is noise. And it is reported as focusing at a different distance, not as dirt -- a genuinely near object gives the same signature and is not a fault. Naming the cause is the operator's job; knowing where to look is what they could not get from a live image, where a smear reads as nothing but a soft patch. Also makes the sweep settle a host seam (sweepSettleMs) beside intervalMs and moveRepeatMs. A fixture has nothing to settle, and at 700ms the sweep checks alone outran the page's whole time budget. Every guard mutation-tested: breaking it turns exactly the checks written for it red, and no others. --- dist/aftune.js | 134 +++++++++++++++++++++++++++++++++++++ dist/editor.css | 11 +++ dist/editor.js | 160 ++++++++++++++++++++++++++++++++++++++++++-- src/aftune.js | 134 +++++++++++++++++++++++++++++++++++++ src/editor.css | 11 +++ src/editor.js | 160 ++++++++++++++++++++++++++++++++++++++++++-- tests/ui-check.html | 140 ++++++++++++++++++++++++++++++++++++-- tools/smoke.mjs | 74 ++++++++++++++++++++ 8 files changed, 805 insertions(+), 19 deletions(-) diff --git a/dist/aftune.js b/dist/aftune.js index d7cddbb..a6e8c47 100644 --- a/dist/aftune.js +++ b/dist/aftune.js @@ -141,6 +141,8 @@ export function summarise(zones, rows, cols, opts = {}) { unlit: state.filter((s) => s === 'unlit').length, clipped: state.filter((s) => s === 'clipped').length, saturated: sat.filter(Boolean).length, + /* Per zone, for anything drawing the grid or reducing it further. */ + satZone: sat, /* The one that decides whether a comparison is worth anything: the * grid's own peak sitting at the ceiling is what makes a sweep flat. * @@ -199,3 +201,135 @@ export function normalise(sum, ceiling) { const top = ceiling || sum.peak || 1; return sum.fv.map((v, i) => (sum.state[i] === 'measured' ? Math.min(1, v / top) : null)); } + +/* + * A readable grid. + * + * 255 coloured cells show that there is a bright patch somewhere and say + * nothing about what anything reads. Someone calibrating autofocus wants a + * number they can compare between parts of the frame, which means far fewer + * cells and the value printed in each. + * + * The fine grid stays the measurement -- this only reduces it for display, so + * nothing here can change where the peak actually is. + * + * Blocks are sized by boundary rather than by a constant width, because + * neither 15 rows nor 17 columns divides evenly by anything an operator would + * want to look at: 3 across leaves columns of 5, 6 and 6. The value is the + * MEAN over the measured zones in the block, not the sum, so blocks of + * different sizes stay comparable -- and not the maximum, which is the + * reduction clamping already defeats. + */ +function bounds(len, n) { + const out = []; + for (let b = 0; b < n; b++) + out.push([Math.floor(b * len / n), Math.floor((b + 1) * len / n)]); + return out; +} + +export function coarsen(s, n) { + if (!Number.isSafeInteger(n) || n <= 0) + throw new Error(`not a block count: ${n}`); + /* Asking for more blocks than there are zones would hand back empty cells + * reported as "nothing measurable", which is a different claim entirely. */ + const rowsN = Math.min(n, s.rows), colsN = Math.min(n, s.cols); + const rb = bounds(s.rows, rowsN), cb = bounds(s.cols, colsN); + const blocks = []; + for (let br = 0; br < rowsN; br++) { + for (let bc = 0; bc < colsN; bc++) { + const [r0, r1] = rb[br], [c0, c1] = cb[bc]; + let sum = 0, measured = 0, saturated = 0, total = 0; + for (let r = r0; r < r1; r++) { + for (let c = c0; c < c1; c++) { + const i = r * s.cols + c; + total++; + if (s.state[i] !== 'measured') continue; + measured++; + sum += s.fv[i]; + } + } + for (let r = r0; r < r1; r++) + for (let c = c0; c < c1; c++) + if (s.satZone && s.satZone[r * s.cols + c]) saturated++; + blocks.push({ + row: br, col: bc, rowSpan: [r0, r1], colSpan: [c0, c1], + /* null, not 0. A block with nothing worth believing in it has + * no value, and zero is a value -- one that sorts below every + * real block and reads as "measured, and very soft". */ + value: measured ? Math.round(sum / measured) : null, + measured, total, saturated, + }); + } + } + let best = null; + for (const b of blocks) + if (b.value !== null && (best === null || b.value > blocks[best].value)) + best = blocks.indexOf(b); + return { rows: rowsN, cols: colsN, blocks, best }; +} + +/* + * What a defocus sweep says about each zone SEPARATELY. + * + * A sweep walks the lens and reads the whole grid at every stop, so it already + * holds one focus curve per zone -- and the position where a zone peaks is the + * distance that part of the frame is at. The scene mostly agrees; anything + * that disagrees sharply is at a different distance from everything else. + * + * The common cause is nothing in the scene at all: dirt on the dome, a spider + * web, a leaf against the glass. Those sit a few millimetres from the lens, so + * they come into focus nowhere near where the picture does, and they are + * exactly what drags a cheap autofocus onto the glass and keeps it there. An + * operator cannot see this on a live image -- a smear reads as a soft patch -- + * but across a sweep it is unmistakable. + * + * It is reported as "focuses at a different distance", which is what was + * measured. A near object that is genuinely part of the scene produces the + * same signature and is not a fault; naming the cause is the operator's job, + * and the two need looking at with the same eye anyway. + */ +export function sweepZones(frames, opts = {}) { + if (!Array.isArray(frames) || frames.length < 3) + throw new Error(`a sweep needs at least three readings, got ${frames && frames.length}`); + const n = frames[0].fv.length; + for (const f of frames) + if (f.fv.length !== n) + throw new Error('the grid changed shape during the sweep'); + + /* How much a zone has to move before its peak position means anything. A + * zone reading the same at every position -- a blank wall, a patch of sky + * -- has an argmax, and it is noise. Asking it where it focuses gets an + * answer indistinguishable from a confident one. */ + const swing = opts.swing !== undefined ? opts.swing : 0.25; + /* How far from the rest of the frame counts as a different distance, as a + * fraction of the sweep. Below this it is the same subject and the usual + * disagreement between parts of it. */ + const apart = opts.apart !== undefined ? opts.apart : 0.3; + + const peakAt = new Array(n).fill(null); + for (let i = 0; i < n; i++) { + let hi = -1, lo = Infinity, at = -1, ever = false; + for (let f = 0; f < frames.length; f++) { + if (frames[f].state[i] !== 'measured') continue; + ever = true; + const v = frames[f].fv[i]; + if (v > hi) { hi = v; at = f; } + if (v < lo) lo = v; + } + /* Never measured, or never moved. No opinion either way. */ + if (!ever || hi <= 0 || (hi - lo) / hi < swing) continue; + peakAt[i] = at; + } + + const heard = peakAt.filter((v) => v !== null).sort((a, b) => a - b); + if (!heard.length) return { consensus: null, peakAt, suspect: [], heard: 0 }; + /* Median, not mean. One smeared corner peaking at the far end of the sweep + * drags a mean toward itself and then measures everything else against a + * consensus it invented. */ + const consensus = heard[heard.length >> 1]; + const far = Math.max(1, Math.round((frames.length - 1) * apart)); + const suspect = []; + for (let i = 0; i < n; i++) + if (peakAt[i] !== null && Math.abs(peakAt[i] - consensus) > far) suspect.push(i); + return { consensus, peakAt, suspect, heard: heard.length, far }; +} diff --git a/dist/editor.css b/dist/editor.css index bf63402..d6b398f 100644 --- a/dist/editor.css +++ b/dist/editor.css @@ -86,6 +86,17 @@ .re-fz-none { fill: none; stroke: rgba(255,255,255,.28); stroke-width: 1; stroke-dasharray: 3 3; } .re-fz-peak { fill: none; stroke: #fff; stroke-width: 2.5; paint-order: stroke; } +/* The number in the block. Painted over a picture of unknown brightness, so it + * carries its own dark stroke rather than trusting the fill underneath. */ +.re-fz-num { fill: #fff; stroke: rgba(0,0,0,.72); stroke-width: 3; paint-order: stroke; + font: 600 13px ui-monospace, SFMono-Regular, Menlo, monospace; + text-anchor: middle; dominant-baseline: middle; pointer-events: none; } +.re-fz-tag { fill: #fff; stroke: rgba(0,0,0,.72); stroke-width: 2.5; paint-order: stroke; + font: 500 10px ui-sans-serif, system-ui, sans-serif; + text-anchor: middle; dominant-baseline: middle; pointer-events: none; } +/* A block focusing at a different distance from the rest of the frame. Dashed + * rather than solid so it cannot be mistaken for the peak outline. */ +.re-fz-odd { fill: none; stroke: #7fd4ff; stroke-width: 2; stroke-dasharray: 5 3; } .re-chart-grip { position: absolute; width: 18px; height: 18px; margin: -9px 0 0 -9px; border: 2px solid #fff; border-radius: 50%; background: #5c70e8; box-shadow: 0 1px 4px rgba(0,0,0,.5); cursor: grab; pointer-events: auto; diff --git a/dist/editor.js b/dist/editor.js index 7dfa654..e08139a 100644 --- a/dist/editor.js +++ b/dist/editor.js @@ -15,7 +15,7 @@ import { solveFromPatches, patchCentres, scoreCcm, CHART_COLS, CHART_ROWS } from './calibrate.js'; import { parseIni, readColour, fitAwbCurve, gainsForCt, mergeCcmTables, colourFragment, readDefectCorrection, enableDefectCorrection } from './iqprofile.js'; -import { summarise, peakHold, normalise } from './aftune.js'; +import { summarise, peakHold, normalise, coarsen, sweepZones } from './aftune.js'; const CFA_NAMES = ['RGGB', 'GRBG', 'GBRG', 'BGGR']; const DEMOSAIC = [ @@ -4304,6 +4304,11 @@ export function mountEditor(root, { */ const SWEEP_STEPS = 8; const SWEEP_SETTLE_MS = 700; + /* How long the lens is left alone after a step before the grid is read. A + * host seam like intervalMs and moveRepeatMs: a motor that settles faster + * than this is being waited on for nothing, and a test driving a fixture + * has nothing to settle at all. */ + const settleMs = () => (focus && focus.sweepSettleMs) || SWEEP_SETTLE_MS; let sweepGen = 0; /* A sweep in flight when the editor is torn down still has a return walk to * finish -- the lens is real and must go back -- but what it must NOT do is @@ -4342,7 +4347,7 @@ export function mountEditor(root, { async function sweepRun(say, onStep) { const gen = ++sweepGen; const mine = () => gen === sweepGen; - const vals = []; + const vals = [], frames = []; let out = 0, failed = null, lost = 0, pinned = 0; /* The poll is stopped for the duration: it and the sweep would be * asking the same camera for the same grid at once, and its answers @@ -4350,6 +4355,8 @@ export function mountEditor(root, { * The manual controls go with it -- see ownLens. */ stopFocusPoll(); ownLens(true); + /* Last sweep's findings belong to last sweep's lens and filter. */ + focusOdd = null; try { for (let i = 0; i <= SWEEP_STEPS; i++) { if (!mine()) break; @@ -4360,6 +4367,13 @@ export function mountEditor(root, { * pinned reading anywhere along it is enough to make the * spread an understatement. */ if (s.peakSaturated) pinned++; + /* Kept whole, not reduced to its peak. The sweep already + * reads every zone at every position, so it holds one + * focus curve per zone -- and where each zone peaks is the + * distance that part of the frame is at. Throwing all but + * the maximum away discards the only measurement that can + * see dirt on the glass. */ + frames.push({ fv: s.fv, state: s.state }); } catch (e) { failed = e && e.message ? e.message : String(e); break; @@ -4375,7 +4389,7 @@ export function mountEditor(root, { break; } out++; - await pause(SWEEP_SETTLE_MS); + await pause(settleMs()); } } finally { /* Every step taken is a step given back, and NOT conditional on the @@ -4391,7 +4405,7 @@ export function mountEditor(root, { * end -- claiming the lens is back when a step was refused is * the one outcome worse than saying nothing. */ if (!(await moveOnce('near'))) lost++; - await pause(SWEEP_SETTLE_MS); + await pause(settleMs()); onStep(SWEEP_STEPS + i + 1, SWEEP_STEPS * 2); } await moveOnce('stop'); @@ -4410,8 +4424,16 @@ export function mountEditor(root, { if (!mine()) return { stopped: true, lost: lost, back: back }; if (!vals.length) return { failed: 'nothing measurable along the sweep.' + back, lost: lost }; const hi = Math.max.apply(null, vals), lo = Math.min.apply(null, vals); + let odd = null; + /* Needs a sweep with a shape to it. A run cut short by a stop or a + * refused move has too few positions for an argmax to mean anything. */ + try { odd = sweepZones(frames); } catch (e) { odd = null; } + focusOdd = odd && odd.suspect.length ? odd.suspect : null; + drawFocusMarks(); return { hi: hi, lo: lo, ratio: lo > 0 ? hi / lo : null, n: vals.length, - lost: lost, back: back, pinned: pinned, steps: vals.length }; + lost: lost, back: back, pinned: pinned, steps: vals.length, + odd: odd && odd.suspect.length ? odd.suspect.length : 0, + heard: odd ? odd.heard : 0 }; } @@ -4699,12 +4721,28 @@ export function mountEditor(root, { * everywhere is worse than a quiet one that falls away, * and the peak alone cannot tell them apart -- which is * exactly the mistake this button exists to prevent. */ + /* The finding that matters most for calibration, and the + * one an operator cannot get from a live image: a smear on + * the dome reads as a soft patch and nothing more. Across + * a sweep it peaks at a lens position nowhere near the + * rest of the frame, because it is a few millimetres away. + * That is what drags a cheap autofocus onto the glass and + * keeps it there. */ + const aside = r.odd + ? ' ' + r.odd + ' of the ' + r.heard + ' zones that ' + + 'responded focus at a different distance from the rest ' + + 'of the frame, ringed on the picture. Dirt on the dome, ' + + 'a web or something up against the glass will do that, ' + + 'and autofocus will chase it \u2014 check them before ' + + 'trusting a calibration.' + : ''; say((r.ratio === null ? 'Highest ' + r.hi + ', lowest ' + r.lo + ' across the sweep.' : 'Falls to 1/' + r.ratio.toFixed(1) + ' of its peak across the ' + 'sweep (' + r.hi + ' down to ' + r.lo + '). A filter worth ' + - 'keeping falls away steeply; a loud one barely moves.') + r.back, - !!r.lost); + 'keeping falls away steeply; a loud one barely moves.') + + r.back + aside, + !!r.lost || !!r.odd); }).catch(function (e) { busy(false); say('Could not measure it: ' + (e && e.message ? e.message : e), true); @@ -4759,6 +4797,28 @@ export function mountEditor(root, { }); } + /* How coarse the readout is. The fine grid is still what gets measured + * -- this only changes what is drawn over the picture. */ + const grain = el('div', 're-seg'); + grain.dataset.act = 'focus-grain'; + [[3, '3\u00d73'], [4, '4\u00d74'], [0, 'All zones']].forEach(function (pair) { + const b = el('button', '', ''); + b.type = 'button'; + b.dataset.act = 'grain-' + pair[0]; + b.textContent = pair[1]; + b.setAttribute('aria-pressed', focusBlocks === pair[0] ? 'true' : 'false'); + b.addEventListener('click', function () { + focusBlocks = pair[0]; + [...grain.children].forEach(function (o) { + o.setAttribute('aria-pressed', + o === b ? 'true' : 'false'); + }); + drawFocusMarks(); + }); + grain.append(b); + }); + row.append(grain); + const reset = el('button', 're-btn', ''); reset.dataset.act = 'focus-reset'; reset.textContent = 'Reset the best'; @@ -4795,6 +4855,87 @@ export function mountEditor(root, { * the mapping is arithmetic. A camera that cropped its AF window would need * the grid's own boundaries, and this would be wrong -- but it would be * wrong visibly, the grid sitting over part of the picture. */ + /* How many blocks across the readable grid is reduced to; 0 is the full + * measured grid. 255 coloured cells show that there is a bright patch + * somewhere and say nothing about what any of it reads, and the first + * person to calibrate autofocus with this said so. */ + let focusBlocks = 3; + /* Zones the last sweep found focusing at a different distance from the rest + * of the frame. Cleared whenever the lens or the filter moves under it. */ + let focusOdd = null; + + function drawCoarse(svg, s, W, H, NS) { + const c = coarsen(s, focusBlocks); + const top = c.best === null ? null : c.blocks[c.best].value; + for (const b of c.blocks) { + const a0 = stageCoords((b.colSpan[0] * W) / s.cols, (b.rowSpan[0] * H) / s.rows); + const b0 = stageCoords((b.colSpan[1] * W) / s.cols, (b.rowSpan[1] * H) / s.rows); + if (!a0 || !b0) continue; + const w = Math.max(0, b0.x - a0.x), h = Math.max(0, b0.y - a0.y); + const cell = document.createElementNS(NS, 'rect'); + cell.setAttribute('x', a0.x); + cell.setAttribute('y', a0.y); + cell.setAttribute('width', w); + cell.setAttribute('height', h); + if (b.value === null) { + cell.setAttribute('class', 're-fz re-fz-none'); + } else { + cell.setAttribute('class', 're-fz'); + /* Against the best block, so the brightest cell is the one to + * focus on rather than whatever the scale happens to reach. */ + cell.setAttribute('fill-opacity', + (0.08 + 0.52 * (top ? b.value / top : 0)).toFixed(3)); + } + svg.append(cell); + + const odd = focusOdd && focusOdd.some((i) => { + const r = (i / s.cols) | 0, cc = i % s.cols; + return r >= b.rowSpan[0] && r < b.rowSpan[1] && + cc >= b.colSpan[0] && cc < b.colSpan[1]; + }); + if (odd) { + const ring = document.createElementNS(NS, 'rect'); + ring.setAttribute('x', a0.x + 2); + ring.setAttribute('y', a0.y + 2); + ring.setAttribute('width', Math.max(0, w - 4)); + ring.setAttribute('height', Math.max(0, h - 4)); + ring.setAttribute('class', 're-fz-odd'); + svg.append(ring); + } + + const label = document.createElementNS(NS, 'text'); + label.setAttribute('x', a0.x + w / 2); + label.setAttribute('y', a0.y + h / 2); + label.setAttribute('class', 're-fz-num'); + /* Scaled to the cell and clamped: a 5-block grid on a phone gets + * cells too small for 13px, and a 2-block grid on a desktop would + * otherwise print a number the size of a caption. */ + label.setAttribute('font-size', + Math.max(9, Math.min(17, Math.round(Math.min(w / 4.2, h / 2.6))))); + /* Thin spaces every three digits. A six-figure number with no + * grouping is exactly the thing this grid was asked to fix. */ + label.textContent = b.value === null + ? '\u2014' : String(b.value).replace(/\B(?=(\d{3})+(?!\d))/g, '\u2009'); + svg.append(label); + + if (c.best !== null && b === c.blocks[c.best] && b.value !== null) { + const pk = document.createElementNS(NS, 'rect'); + pk.setAttribute('x', a0.x); + pk.setAttribute('y', a0.y); + pk.setAttribute('width', w); + pk.setAttribute('height', h); + pk.setAttribute('class', 're-fz-peak'); + svg.append(pk); + const tag = document.createElementNS(NS, 'text'); + tag.setAttribute('x', a0.x + w / 2); + tag.setAttribute('y', a0.y + h / 2 + Math.min(20, h / 3.4)); + tag.setAttribute('class', 're-fz-tag'); + tag.textContent = 'sharpest'; + svg.append(tag); + } + } + } + function drawFocusMarks() { focusMarks.replaceChildren(); if (mode !== 'focus' || !focusSum || !state.info) return; @@ -4804,6 +4945,11 @@ export function mountEditor(root, { const NS = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(NS, 'svg'); svg.setAttribute('class', 're-chart-svg'); + if (focusBlocks) { + drawCoarse(svg, s, W, H, NS); + focusMarks.append(svg); + return; + } for (let i = 0; i < s.fv.length; i++) { const r = (i / s.cols) | 0, c = i % s.cols; const a = stageCoords((c * W) / s.cols, (r * H) / s.rows); diff --git a/src/aftune.js b/src/aftune.js index d7cddbb..a6e8c47 100644 --- a/src/aftune.js +++ b/src/aftune.js @@ -141,6 +141,8 @@ export function summarise(zones, rows, cols, opts = {}) { unlit: state.filter((s) => s === 'unlit').length, clipped: state.filter((s) => s === 'clipped').length, saturated: sat.filter(Boolean).length, + /* Per zone, for anything drawing the grid or reducing it further. */ + satZone: sat, /* The one that decides whether a comparison is worth anything: the * grid's own peak sitting at the ceiling is what makes a sweep flat. * @@ -199,3 +201,135 @@ export function normalise(sum, ceiling) { const top = ceiling || sum.peak || 1; return sum.fv.map((v, i) => (sum.state[i] === 'measured' ? Math.min(1, v / top) : null)); } + +/* + * A readable grid. + * + * 255 coloured cells show that there is a bright patch somewhere and say + * nothing about what anything reads. Someone calibrating autofocus wants a + * number they can compare between parts of the frame, which means far fewer + * cells and the value printed in each. + * + * The fine grid stays the measurement -- this only reduces it for display, so + * nothing here can change where the peak actually is. + * + * Blocks are sized by boundary rather than by a constant width, because + * neither 15 rows nor 17 columns divides evenly by anything an operator would + * want to look at: 3 across leaves columns of 5, 6 and 6. The value is the + * MEAN over the measured zones in the block, not the sum, so blocks of + * different sizes stay comparable -- and not the maximum, which is the + * reduction clamping already defeats. + */ +function bounds(len, n) { + const out = []; + for (let b = 0; b < n; b++) + out.push([Math.floor(b * len / n), Math.floor((b + 1) * len / n)]); + return out; +} + +export function coarsen(s, n) { + if (!Number.isSafeInteger(n) || n <= 0) + throw new Error(`not a block count: ${n}`); + /* Asking for more blocks than there are zones would hand back empty cells + * reported as "nothing measurable", which is a different claim entirely. */ + const rowsN = Math.min(n, s.rows), colsN = Math.min(n, s.cols); + const rb = bounds(s.rows, rowsN), cb = bounds(s.cols, colsN); + const blocks = []; + for (let br = 0; br < rowsN; br++) { + for (let bc = 0; bc < colsN; bc++) { + const [r0, r1] = rb[br], [c0, c1] = cb[bc]; + let sum = 0, measured = 0, saturated = 0, total = 0; + for (let r = r0; r < r1; r++) { + for (let c = c0; c < c1; c++) { + const i = r * s.cols + c; + total++; + if (s.state[i] !== 'measured') continue; + measured++; + sum += s.fv[i]; + } + } + for (let r = r0; r < r1; r++) + for (let c = c0; c < c1; c++) + if (s.satZone && s.satZone[r * s.cols + c]) saturated++; + blocks.push({ + row: br, col: bc, rowSpan: [r0, r1], colSpan: [c0, c1], + /* null, not 0. A block with nothing worth believing in it has + * no value, and zero is a value -- one that sorts below every + * real block and reads as "measured, and very soft". */ + value: measured ? Math.round(sum / measured) : null, + measured, total, saturated, + }); + } + } + let best = null; + for (const b of blocks) + if (b.value !== null && (best === null || b.value > blocks[best].value)) + best = blocks.indexOf(b); + return { rows: rowsN, cols: colsN, blocks, best }; +} + +/* + * What a defocus sweep says about each zone SEPARATELY. + * + * A sweep walks the lens and reads the whole grid at every stop, so it already + * holds one focus curve per zone -- and the position where a zone peaks is the + * distance that part of the frame is at. The scene mostly agrees; anything + * that disagrees sharply is at a different distance from everything else. + * + * The common cause is nothing in the scene at all: dirt on the dome, a spider + * web, a leaf against the glass. Those sit a few millimetres from the lens, so + * they come into focus nowhere near where the picture does, and they are + * exactly what drags a cheap autofocus onto the glass and keeps it there. An + * operator cannot see this on a live image -- a smear reads as a soft patch -- + * but across a sweep it is unmistakable. + * + * It is reported as "focuses at a different distance", which is what was + * measured. A near object that is genuinely part of the scene produces the + * same signature and is not a fault; naming the cause is the operator's job, + * and the two need looking at with the same eye anyway. + */ +export function sweepZones(frames, opts = {}) { + if (!Array.isArray(frames) || frames.length < 3) + throw new Error(`a sweep needs at least three readings, got ${frames && frames.length}`); + const n = frames[0].fv.length; + for (const f of frames) + if (f.fv.length !== n) + throw new Error('the grid changed shape during the sweep'); + + /* How much a zone has to move before its peak position means anything. A + * zone reading the same at every position -- a blank wall, a patch of sky + * -- has an argmax, and it is noise. Asking it where it focuses gets an + * answer indistinguishable from a confident one. */ + const swing = opts.swing !== undefined ? opts.swing : 0.25; + /* How far from the rest of the frame counts as a different distance, as a + * fraction of the sweep. Below this it is the same subject and the usual + * disagreement between parts of it. */ + const apart = opts.apart !== undefined ? opts.apart : 0.3; + + const peakAt = new Array(n).fill(null); + for (let i = 0; i < n; i++) { + let hi = -1, lo = Infinity, at = -1, ever = false; + for (let f = 0; f < frames.length; f++) { + if (frames[f].state[i] !== 'measured') continue; + ever = true; + const v = frames[f].fv[i]; + if (v > hi) { hi = v; at = f; } + if (v < lo) lo = v; + } + /* Never measured, or never moved. No opinion either way. */ + if (!ever || hi <= 0 || (hi - lo) / hi < swing) continue; + peakAt[i] = at; + } + + const heard = peakAt.filter((v) => v !== null).sort((a, b) => a - b); + if (!heard.length) return { consensus: null, peakAt, suspect: [], heard: 0 }; + /* Median, not mean. One smeared corner peaking at the far end of the sweep + * drags a mean toward itself and then measures everything else against a + * consensus it invented. */ + const consensus = heard[heard.length >> 1]; + const far = Math.max(1, Math.round((frames.length - 1) * apart)); + const suspect = []; + for (let i = 0; i < n; i++) + if (peakAt[i] !== null && Math.abs(peakAt[i] - consensus) > far) suspect.push(i); + return { consensus, peakAt, suspect, heard: heard.length, far }; +} diff --git a/src/editor.css b/src/editor.css index bf63402..d6b398f 100644 --- a/src/editor.css +++ b/src/editor.css @@ -86,6 +86,17 @@ .re-fz-none { fill: none; stroke: rgba(255,255,255,.28); stroke-width: 1; stroke-dasharray: 3 3; } .re-fz-peak { fill: none; stroke: #fff; stroke-width: 2.5; paint-order: stroke; } +/* The number in the block. Painted over a picture of unknown brightness, so it + * carries its own dark stroke rather than trusting the fill underneath. */ +.re-fz-num { fill: #fff; stroke: rgba(0,0,0,.72); stroke-width: 3; paint-order: stroke; + font: 600 13px ui-monospace, SFMono-Regular, Menlo, monospace; + text-anchor: middle; dominant-baseline: middle; pointer-events: none; } +.re-fz-tag { fill: #fff; stroke: rgba(0,0,0,.72); stroke-width: 2.5; paint-order: stroke; + font: 500 10px ui-sans-serif, system-ui, sans-serif; + text-anchor: middle; dominant-baseline: middle; pointer-events: none; } +/* A block focusing at a different distance from the rest of the frame. Dashed + * rather than solid so it cannot be mistaken for the peak outline. */ +.re-fz-odd { fill: none; stroke: #7fd4ff; stroke-width: 2; stroke-dasharray: 5 3; } .re-chart-grip { position: absolute; width: 18px; height: 18px; margin: -9px 0 0 -9px; border: 2px solid #fff; border-radius: 50%; background: #5c70e8; box-shadow: 0 1px 4px rgba(0,0,0,.5); cursor: grab; pointer-events: auto; diff --git a/src/editor.js b/src/editor.js index 7dfa654..e08139a 100644 --- a/src/editor.js +++ b/src/editor.js @@ -15,7 +15,7 @@ import { solveFromPatches, patchCentres, scoreCcm, CHART_COLS, CHART_ROWS } from './calibrate.js'; import { parseIni, readColour, fitAwbCurve, gainsForCt, mergeCcmTables, colourFragment, readDefectCorrection, enableDefectCorrection } from './iqprofile.js'; -import { summarise, peakHold, normalise } from './aftune.js'; +import { summarise, peakHold, normalise, coarsen, sweepZones } from './aftune.js'; const CFA_NAMES = ['RGGB', 'GRBG', 'GBRG', 'BGGR']; const DEMOSAIC = [ @@ -4304,6 +4304,11 @@ export function mountEditor(root, { */ const SWEEP_STEPS = 8; const SWEEP_SETTLE_MS = 700; + /* How long the lens is left alone after a step before the grid is read. A + * host seam like intervalMs and moveRepeatMs: a motor that settles faster + * than this is being waited on for nothing, and a test driving a fixture + * has nothing to settle at all. */ + const settleMs = () => (focus && focus.sweepSettleMs) || SWEEP_SETTLE_MS; let sweepGen = 0; /* A sweep in flight when the editor is torn down still has a return walk to * finish -- the lens is real and must go back -- but what it must NOT do is @@ -4342,7 +4347,7 @@ export function mountEditor(root, { async function sweepRun(say, onStep) { const gen = ++sweepGen; const mine = () => gen === sweepGen; - const vals = []; + const vals = [], frames = []; let out = 0, failed = null, lost = 0, pinned = 0; /* The poll is stopped for the duration: it and the sweep would be * asking the same camera for the same grid at once, and its answers @@ -4350,6 +4355,8 @@ export function mountEditor(root, { * The manual controls go with it -- see ownLens. */ stopFocusPoll(); ownLens(true); + /* Last sweep's findings belong to last sweep's lens and filter. */ + focusOdd = null; try { for (let i = 0; i <= SWEEP_STEPS; i++) { if (!mine()) break; @@ -4360,6 +4367,13 @@ export function mountEditor(root, { * pinned reading anywhere along it is enough to make the * spread an understatement. */ if (s.peakSaturated) pinned++; + /* Kept whole, not reduced to its peak. The sweep already + * reads every zone at every position, so it holds one + * focus curve per zone -- and where each zone peaks is the + * distance that part of the frame is at. Throwing all but + * the maximum away discards the only measurement that can + * see dirt on the glass. */ + frames.push({ fv: s.fv, state: s.state }); } catch (e) { failed = e && e.message ? e.message : String(e); break; @@ -4375,7 +4389,7 @@ export function mountEditor(root, { break; } out++; - await pause(SWEEP_SETTLE_MS); + await pause(settleMs()); } } finally { /* Every step taken is a step given back, and NOT conditional on the @@ -4391,7 +4405,7 @@ export function mountEditor(root, { * end -- claiming the lens is back when a step was refused is * the one outcome worse than saying nothing. */ if (!(await moveOnce('near'))) lost++; - await pause(SWEEP_SETTLE_MS); + await pause(settleMs()); onStep(SWEEP_STEPS + i + 1, SWEEP_STEPS * 2); } await moveOnce('stop'); @@ -4410,8 +4424,16 @@ export function mountEditor(root, { if (!mine()) return { stopped: true, lost: lost, back: back }; if (!vals.length) return { failed: 'nothing measurable along the sweep.' + back, lost: lost }; const hi = Math.max.apply(null, vals), lo = Math.min.apply(null, vals); + let odd = null; + /* Needs a sweep with a shape to it. A run cut short by a stop or a + * refused move has too few positions for an argmax to mean anything. */ + try { odd = sweepZones(frames); } catch (e) { odd = null; } + focusOdd = odd && odd.suspect.length ? odd.suspect : null; + drawFocusMarks(); return { hi: hi, lo: lo, ratio: lo > 0 ? hi / lo : null, n: vals.length, - lost: lost, back: back, pinned: pinned, steps: vals.length }; + lost: lost, back: back, pinned: pinned, steps: vals.length, + odd: odd && odd.suspect.length ? odd.suspect.length : 0, + heard: odd ? odd.heard : 0 }; } @@ -4699,12 +4721,28 @@ export function mountEditor(root, { * everywhere is worse than a quiet one that falls away, * and the peak alone cannot tell them apart -- which is * exactly the mistake this button exists to prevent. */ + /* The finding that matters most for calibration, and the + * one an operator cannot get from a live image: a smear on + * the dome reads as a soft patch and nothing more. Across + * a sweep it peaks at a lens position nowhere near the + * rest of the frame, because it is a few millimetres away. + * That is what drags a cheap autofocus onto the glass and + * keeps it there. */ + const aside = r.odd + ? ' ' + r.odd + ' of the ' + r.heard + ' zones that ' + + 'responded focus at a different distance from the rest ' + + 'of the frame, ringed on the picture. Dirt on the dome, ' + + 'a web or something up against the glass will do that, ' + + 'and autofocus will chase it \u2014 check them before ' + + 'trusting a calibration.' + : ''; say((r.ratio === null ? 'Highest ' + r.hi + ', lowest ' + r.lo + ' across the sweep.' : 'Falls to 1/' + r.ratio.toFixed(1) + ' of its peak across the ' + 'sweep (' + r.hi + ' down to ' + r.lo + '). A filter worth ' + - 'keeping falls away steeply; a loud one barely moves.') + r.back, - !!r.lost); + 'keeping falls away steeply; a loud one barely moves.') + + r.back + aside, + !!r.lost || !!r.odd); }).catch(function (e) { busy(false); say('Could not measure it: ' + (e && e.message ? e.message : e), true); @@ -4759,6 +4797,28 @@ export function mountEditor(root, { }); } + /* How coarse the readout is. The fine grid is still what gets measured + * -- this only changes what is drawn over the picture. */ + const grain = el('div', 're-seg'); + grain.dataset.act = 'focus-grain'; + [[3, '3\u00d73'], [4, '4\u00d74'], [0, 'All zones']].forEach(function (pair) { + const b = el('button', '', ''); + b.type = 'button'; + b.dataset.act = 'grain-' + pair[0]; + b.textContent = pair[1]; + b.setAttribute('aria-pressed', focusBlocks === pair[0] ? 'true' : 'false'); + b.addEventListener('click', function () { + focusBlocks = pair[0]; + [...grain.children].forEach(function (o) { + o.setAttribute('aria-pressed', + o === b ? 'true' : 'false'); + }); + drawFocusMarks(); + }); + grain.append(b); + }); + row.append(grain); + const reset = el('button', 're-btn', ''); reset.dataset.act = 'focus-reset'; reset.textContent = 'Reset the best'; @@ -4795,6 +4855,87 @@ export function mountEditor(root, { * the mapping is arithmetic. A camera that cropped its AF window would need * the grid's own boundaries, and this would be wrong -- but it would be * wrong visibly, the grid sitting over part of the picture. */ + /* How many blocks across the readable grid is reduced to; 0 is the full + * measured grid. 255 coloured cells show that there is a bright patch + * somewhere and say nothing about what any of it reads, and the first + * person to calibrate autofocus with this said so. */ + let focusBlocks = 3; + /* Zones the last sweep found focusing at a different distance from the rest + * of the frame. Cleared whenever the lens or the filter moves under it. */ + let focusOdd = null; + + function drawCoarse(svg, s, W, H, NS) { + const c = coarsen(s, focusBlocks); + const top = c.best === null ? null : c.blocks[c.best].value; + for (const b of c.blocks) { + const a0 = stageCoords((b.colSpan[0] * W) / s.cols, (b.rowSpan[0] * H) / s.rows); + const b0 = stageCoords((b.colSpan[1] * W) / s.cols, (b.rowSpan[1] * H) / s.rows); + if (!a0 || !b0) continue; + const w = Math.max(0, b0.x - a0.x), h = Math.max(0, b0.y - a0.y); + const cell = document.createElementNS(NS, 'rect'); + cell.setAttribute('x', a0.x); + cell.setAttribute('y', a0.y); + cell.setAttribute('width', w); + cell.setAttribute('height', h); + if (b.value === null) { + cell.setAttribute('class', 're-fz re-fz-none'); + } else { + cell.setAttribute('class', 're-fz'); + /* Against the best block, so the brightest cell is the one to + * focus on rather than whatever the scale happens to reach. */ + cell.setAttribute('fill-opacity', + (0.08 + 0.52 * (top ? b.value / top : 0)).toFixed(3)); + } + svg.append(cell); + + const odd = focusOdd && focusOdd.some((i) => { + const r = (i / s.cols) | 0, cc = i % s.cols; + return r >= b.rowSpan[0] && r < b.rowSpan[1] && + cc >= b.colSpan[0] && cc < b.colSpan[1]; + }); + if (odd) { + const ring = document.createElementNS(NS, 'rect'); + ring.setAttribute('x', a0.x + 2); + ring.setAttribute('y', a0.y + 2); + ring.setAttribute('width', Math.max(0, w - 4)); + ring.setAttribute('height', Math.max(0, h - 4)); + ring.setAttribute('class', 're-fz-odd'); + svg.append(ring); + } + + const label = document.createElementNS(NS, 'text'); + label.setAttribute('x', a0.x + w / 2); + label.setAttribute('y', a0.y + h / 2); + label.setAttribute('class', 're-fz-num'); + /* Scaled to the cell and clamped: a 5-block grid on a phone gets + * cells too small for 13px, and a 2-block grid on a desktop would + * otherwise print a number the size of a caption. */ + label.setAttribute('font-size', + Math.max(9, Math.min(17, Math.round(Math.min(w / 4.2, h / 2.6))))); + /* Thin spaces every three digits. A six-figure number with no + * grouping is exactly the thing this grid was asked to fix. */ + label.textContent = b.value === null + ? '\u2014' : String(b.value).replace(/\B(?=(\d{3})+(?!\d))/g, '\u2009'); + svg.append(label); + + if (c.best !== null && b === c.blocks[c.best] && b.value !== null) { + const pk = document.createElementNS(NS, 'rect'); + pk.setAttribute('x', a0.x); + pk.setAttribute('y', a0.y); + pk.setAttribute('width', w); + pk.setAttribute('height', h); + pk.setAttribute('class', 're-fz-peak'); + svg.append(pk); + const tag = document.createElementNS(NS, 'text'); + tag.setAttribute('x', a0.x + w / 2); + tag.setAttribute('y', a0.y + h / 2 + Math.min(20, h / 3.4)); + tag.setAttribute('class', 're-fz-tag'); + tag.textContent = 'sharpest'; + svg.append(tag); + } + } + } + function drawFocusMarks() { focusMarks.replaceChildren(); if (mode !== 'focus' || !focusSum || !state.info) return; @@ -4804,6 +4945,11 @@ export function mountEditor(root, { const NS = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(NS, 'svg'); svg.setAttribute('class', 're-chart-svg'); + if (focusBlocks) { + drawCoarse(svg, s, W, H, NS); + focusMarks.append(svg); + return; + } for (let i = 0; i < s.fv.length; i++) { const r = (i / s.cols) | 0, c = i % s.cols; const a = stageCoords((c * W) / s.cols, (r * H) / s.rows); diff --git a/tests/ui-check.html b/tests/ui-check.html index ae2029d..33c3009 100644 --- a/tests/ui-check.html +++ b/tests/ui-check.html @@ -1944,6 +1944,73 @@ t('the focus heatmap is handled', false, e.message); } +/* A camera's real grid is 15x17. 255 coloured cells show that there is a + * bright patch somewhere and say nothing about what any of it reads -- which + * is what the first person to calibrate autofocus with this said. */ +try { + const R = 15, C = 17; + // Sharp down the right-hand third, soft elsewhere. + const big = { rows: R, cols: C, zones: Array.from({ length: R * C }, (_, i) => + // Real magnitudes: a camera's h2 runs to five figures, which is the + // whole reason the number needs grouping to be readable at a glance. + [0, (i % C) >= 12 ? 60000 : 9000, 0, 10, 1000, 0]) }; + const host = document.createElement('div'); + document.body.append(host); + const ed = mountEditor(host, { base: '../dist/', styles: false, autoCapture: false, + focus: { intervalMs: 20, zones: () => Promise.resolve(big) } }); + // The overlay is drawn over a frame, so there has to be one. + const bytes = new Uint8Array(await (await fetch('./fixture.dng')).arrayBuffer()); + await ed.open(bytes, 'fixture.dng'); + await new Promise((r) => setTimeout(r, 300)); + [...host.querySelectorAll('.re-seg button')].find((b) => b.textContent.trim() === 'Focus').click(); + await new Promise((r) => setTimeout(r, 200)); + + const nums = () => [...host.querySelectorAll('.re-fz-num')]; + t('the readable grid is nine blocks, not 255 cells', + host.querySelectorAll('.re-fz, .re-fz-none').length === 9, + `${host.querySelectorAll('.re-fz, .re-fz-none').length} cells`); + t('and every block carries its number', nums().length === 9, `${nums().length} numbers`); + t('the numbers are digits, not colours', + nums().every((n) => /\d/.test(n.textContent)), + nums().map((n) => n.textContent).join(' | ')); + t('and are grouped so six figures can be read', + nums().some((n) => /\u2009/.test(n.textContent)), + nums().map((n) => n.textContent).join(' | ')); + t('the sharpest block is marked once', + host.querySelectorAll('.re-fz-peak').length === 1); + { + // The right-hand third is the sharp one, so the marked block must sit + // there -- a readout that names the wrong part of the frame is worse + // than none. + const box = host.querySelector('.re-chart-svg').getBoundingClientRect(); + const pk = host.querySelector('.re-fz-peak').getBoundingClientRect(); + t('and it is the sharp side of the frame', + pk.left > box.left + box.width * 0.5, + `peak at ${(pk.left - box.left).toFixed(0)}px of ${box.width.toFixed(0)}px`); + } + + // The fine grid is still the measurement; this only changes what is drawn. + const grain = (label) => [...host.querySelectorAll('[data-act="focus-grain"] button')] + .find((b) => b.textContent.trim() === label); + grain('All zones').click(); + await new Promise((r) => setTimeout(r, 80)); + t('all zones brings back every cell', + host.querySelectorAll('.re-fz, .re-fz-none').length === R * C, + `${host.querySelectorAll('.re-fz, .re-fz-none').length} cells`); + t('and drops the numbers, which would not fit', nums().length === 0); + grain('4\u00d74').click(); + await new Promise((r) => setTimeout(r, 80)); + t('four across is sixteen blocks', + host.querySelectorAll('.re-fz, .re-fz-none').length === 16, + `${host.querySelectorAll('.re-fz, .re-fz-none').length} cells`); + grain('3\u00d73').click(); + await new Promise((r) => setTimeout(r, 80)); + t('and it goes back', nums().length === 9, `${nums().length} numbers`); + ed.destroy(); +} catch (e) { + t('the readable grid is handled', false, e.message); +} + /* The poll reads a camera over a network, so every one of these is a question * about time: two reads in flight at once, an answer that outlives the thing * that asked for it, and a frame replaced under a grid measured of another. */ @@ -2454,6 +2521,11 @@ return Promise.resolve(); }; focus.moveRepeatMs = 20; + // Nothing to settle in a fixture, and at 700ms a real settle the + // sweep checks alone would outrun the page's whole time budget. + // Nothing to settle in a fixture; a test that has to ACT during a + // sweep asks for a slower one so there is a sweep to act on. + focus.sweepSettleMs = opts.settle || 30; } if (!opts.readOnly) { focus.holdSeconds = opts.hold || 30; @@ -2768,7 +2840,7 @@ g.ed.destroy(); // Stopping half way is the case that most needs the lens returned. - g = await mk({ lens: true }); + g = await mk({ lens: true, settle: 220 }); q(g.host, 'af-measure').click(); await pause(1600); q(g.host, 'af-measure-stop').click(); @@ -2783,7 +2855,7 @@ g.ed.destroy(); // Leaving the tab mid-sweep, which is the same problem without a button. - g = await mk({ lens: true }); + g = await mk({ lens: true, settle: 220 }); q(g.host, 'af-measure').click(); await pause(1600); [...g.host.querySelectorAll('.re-seg button')].find((b) => b.textContent.trim() === 'Preview').click(); @@ -2826,7 +2898,7 @@ // Destroyed mid-sweep. The walk back still has to happen -- the lens is // real -- but nothing may poll a panel that has been removed. - g = await mk({ lens: true }); + g = await mk({ lens: true, settle: 220 }); q(g.host, 'af-measure').click(); await pause(1600); g.ed.destroy(); @@ -2839,7 +2911,7 @@ // Leave Focus and come straight back. The old sweep is still walking the // lens home; a new one starting now sends `far` against its `near`. - g = await mk({ lens: true }); + g = await mk({ lens: true, settle: 220 }); q(g.host, 'af-measure').click(); await pause(1600); [...g.host.querySelectorAll('.re-seg button')].find((b) => b.textContent.trim() === 'Preview').click(); @@ -2904,8 +2976,66 @@ t('and that it is the sharpest zone', /the sharpest among them/.test(g.host.textContent), ''); g.ed.destroy(); + // Dirt on the dome sits a few millimetres from the lens, so across a sweep + // it peaks nowhere near where the picture does. An operator cannot see it + // on a live image -- a smear reads as a soft patch -- and it is exactly + // what drags a cheap autofocus onto the glass and keeps it there. + g = await mk({ lens: true, sweepGrid: (pos) => ({ rows: 1, cols: 4, zones: [ + // three zones agree focus is back at the start; one peaks at the far end + [0, Math.max(40, 4000 - pos * 460), 0, 4, 1000, 0], + [0, Math.max(40, 400 + pos * 460), 0, 4, 1000, 0], + [0, Math.max(40, 4000 - pos * 460), 0, 4, 1000, 0], + [0, Math.max(40, 4000 - pos * 460), 0, 4, 1000, 0]] }) }); + q(g.host, 'af-measure').click(); + for (let i = 0; i < 300 && q(g.host, 'af-measure').hidden; i++) await pause(100); + { + const s = q(g.host, 'af-status').textContent; + t('a zone focusing at another distance is called out', + /different distance/.test(s), s.slice(-140)); + t('and the operator is told what it usually is', + /dirt|web|glass/i.test(s), s.slice(-140)); + } + g.ed.destroy(); + + // ...and ringed on the picture, which needs a picture to ring it on. + g = await mk({ lens: true, sweepGrid: (pos) => ({ rows: 1, cols: 4, zones: [ + [0, Math.max(40, 4000 - pos * 460), 0, 4, 1000, 0], + [0, Math.max(40, 400 + pos * 460), 0, 4, 1000, 0], + [0, Math.max(40, 4000 - pos * 460), 0, 4, 1000, 0], + [0, Math.max(40, 4000 - pos * 460), 0, 4, 1000, 0]] }) }); + { + const bytes = new Uint8Array(await (await fetch('./fixture.dng')).arrayBuffer()); + await g.ed.open(bytes, 'fixture.dng'); + await pause(300); + [...g.host.querySelectorAll('.re-seg button')].find((b) => b.textContent.trim() === 'Focus').click(); + await pause(120); + q(g.host, 'af-measure').click(); + for (let i = 0; i < 300 && q(g.host, 'af-measure').hidden; i++) await pause(100); + t('and it is ringed on the picture', + g.host.querySelectorAll('.re-fz-odd').length > 0, + g.host.querySelectorAll('.re-fz-odd').length + ' ringed'); + } + g.ed.destroy(); + + // A scene that all sits at one distance must not be accused of anything -- + // a warning that fires on every sweep is a warning nobody reads. + g = await mk({ lens: true, sweepGrid: (pos) => ({ rows: 1, cols: 4, zones: [ + [0, Math.max(40, 4000 - pos * 460), 0, 4, 1000, 0], + [0, Math.max(40, 3000 - pos * 340), 0, 4, 1000, 0], + [0, Math.max(40, 3600 - pos * 400), 0, 4, 1000, 0], + [0, Math.max(40, 4000 - pos * 460), 0, 4, 1000, 0]] }) }); + q(g.host, 'af-measure').click(); + for (let i = 0; i < 300 && q(g.host, 'af-measure').hidden; i++) await pause(100); + { + const s = q(g.host, 'af-status').textContent; + t('a scene at one distance is not accused', + !/different distance/.test(s), s.slice(-100)); + t('and nothing is ringed', g.host.querySelectorAll('.re-fz-odd').length === 0); + } + g.ed.destroy(); + // Near and Far drive the same lens the sweep is driving. - g = await mk({ lens: true }); + g = await mk({ lens: true, settle: 220 }); q(g.host, 'af-measure').click(); await pause(900); t('the manual controls are held down for the sweep', diff --git a/tools/smoke.mjs b/tools/smoke.mjs index 5b9b4b9..4caadc6 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -1243,6 +1243,80 @@ console.log('\nfocus statistics: the grid a person focuses a lens by'); !dark.peakSaturated); } + // 255 coloured cells show that there is a bright patch somewhere and say + // nothing about what anything reads. Reduced for display only -- this must + // not be able to move where the peak actually is. + { + // A 2x4 grid, sharp down the right-hand side. + const s = A.summarise(grid(2, 4, (i) => zone({ h2: (i % 4) >= 2 ? 800 : 100 })), 2, 4); + const c = A.coarsen(s, 2); + check('two blocks across', c.cols, 2); + check('and two down', c.rows, 2); + check('the sharp side reads higher', + c.blocks[1].value > c.blocks[0].value, true); + check('the best block is on that side', c.blocks[c.best].col, 1); + + // The mean, not the sum: 15 rows across 3 blocks divides evenly but 17 + // columns does not, and a sum would rank the wider block higher for + // being wider. + const even = A.summarise(grid(1, 5, () => zone({ h2: 640 })), 1, 5); + const cc = A.coarsen(even, 2); + check('blocks of different widths still compare', + cc.blocks[0].value, cc.blocks[1].value); + + // A block with nothing believable in it has no value. Zero is a value, + // and it sorts below every real block as though it had been measured. + const dark = A.summarise(grid(1, 2, (i) => (i + ? zone({ h2: 800 }) : zone({ y: 0, h2: 800 }))), 1, 2); + check('an unmeasurable block is null, not zero', A.coarsen(dark, 2).blocks[0].value, null); + + // More blocks than zones would hand back empty cells reported as + // "nothing measurable", which is a different claim entirely. + check('more blocks than zones is capped', A.coarsen(even, 99).cols, 5); + let refused = false; + try { A.coarsen(even, 0); } catch { refused = true; } + assert('a zero block count is refused', refused); + } + + // Dirt on the dome focuses a few millimetres away, so across a sweep it + // peaks nowhere near where the picture does -- which is exactly what drags + // a cheap autofocus onto the glass. Invisible on a live image; unmistakable + // across a sweep. + { + // Five positions, four zones. Three agree that focus is at position 3; + // zone 1 peaks at position 0 and is at some quite different distance. + const curve = (peak) => [0, 1, 2, 3, 4].map((p) => 1000 - Math.abs(p - peak) * 240); + const scene = [curve(3), curve(0), curve(3), curve(3)]; + const frames = [0, 1, 2, 3, 4].map((p) => ({ + fv: scene.map((c) => c[p]), + state: scene.map(() => 'measured'), + })); + const r = A.sweepZones(frames); + check('the scene agrees where focus is', r.consensus, 3); + check('and the odd one out is named', r.suspect, [1]); + + // A zone that never moves has an argmax and it is noise. Asking it + // where it focuses gets an answer indistinguishable from a real one. + const flat = frames.map((f, p) => ({ + fv: [...f.fv.slice(0, 3), 500], + state: f.state, + })); + const rf = A.sweepZones(flat); + check('a zone that never moved is not accused', rf.suspect.includes(3), false); + check('and is not counted as having an opinion', rf.peakAt[3], null); + + // Median, not mean: one smeared corner at the far end would drag a mean + // toward itself and then judge everything else against it. + const smear = [curve(4), curve(4), curve(0), curve(4), curve(4)]; + const rs = A.sweepZones([0, 1, 2, 3, 4].map((p) => ({ + fv: smear.map((c) => c[p]), state: smear.map(() => 'measured') }))); + check('the consensus is the median', rs.consensus, 4); + + let short = false; + try { A.sweepZones([{ fv: [1], state: ['measured'] }]); } catch { short = true; } + assert('a sweep too short to have a shape is refused', short); + } + // A grid whose length disagrees with its shape would still draw -- shifted, // every zone in the wrong place. Refused rather than rendered. let threw = false; From eacbcb96ee3b3166fc4cbdebf358bbf47728c4a4 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:51:00 +0300 Subject: [PATCH 2/2] focus: findings that survive nothing they were not measured under Five from review, all real. "All zones" dropped every ring -- the one view someone switches to precisely to see WHICH zones were flagged, while the verdict went on saying they were on the picture. Both renderers now share the ring drawing. A clamped zone was still being judged. Saturation plateaus at the ceiling, so the FIRST reading to reach it wins the argmax, and where the counter filled up is not where the lens was sharpest: the zone got accused of focusing somewhere else on the strength of an artefact. `state` cannot carry this -- summarise keeps saturation separate on purpose, because a pinned zone is still perfectly well exposed -- so the frames now carry satZone and a zone clamped anywhere along the sweep gets no opinion at all. Findings outlived their conditions. They were cleared when a sweep started and nowhere else, so opening a frame, driving the lens by hand, or applying or reverting a filter each left rings describing a camera that no longer existed. Shape, not just zone count. Every index is read back as a row and a column through the CURRENT width, so two grids of equal size and different shape put the same index somewhere else in the picture. Frames carry rows and cols, a change mid-sweep is refused, and the finding carries its shape so drawing will not place it on a grid it does not match. sweepSettleMs: 0 waited the full 700ms. `0 || 700` -- zero is a real answer, a host with nothing to settle, and the fallback turned it into the production wait twice per step in exactly the case that asked for none. Seven more checks, including that the rest of the frame still reaches consensus when a zone is dropped for clamping: without it the fix could quietly disable the whole feature and every test would still pass. The fixture had the same truthiness bug as the code under test, which is why the settle guard first came back vacuous. --- dist/aftune.js | 29 ++++++++++++++---- dist/editor.js | 72 +++++++++++++++++++++++++++++++++++---------- src/aftune.js | 29 ++++++++++++++---- src/editor.js | 72 +++++++++++++++++++++++++++++++++++---------- tests/ui-check.html | 48 +++++++++++++++++++++++++++++- tools/smoke.mjs | 45 ++++++++++++++++++++++++---- 6 files changed, 249 insertions(+), 46 deletions(-) diff --git a/dist/aftune.js b/dist/aftune.js index a6e8c47..3735e5f 100644 --- a/dist/aftune.js +++ b/dist/aftune.js @@ -292,9 +292,15 @@ export function sweepZones(frames, opts = {}) { if (!Array.isArray(frames) || frames.length < 3) throw new Error(`a sweep needs at least three readings, got ${frames && frames.length}`); const n = frames[0].fv.length; - for (const f of frames) - if (f.fv.length !== n) + const rows = frames[0].rows, cols = frames[0].cols; + /* Shape, not just count. Every index here is read back as a row and a + * column through the CURRENT grid's width, so two grids of the same size + * and different shape put the same index in a different part of the + * picture -- and a ring drawn over the wrong zone is worse than none. */ + for (const f of frames) { + if (f.fv.length !== n || f.rows !== rows || f.cols !== cols) throw new Error('the grid changed shape during the sweep'); + } /* How much a zone has to move before its peak position means anything. A * zone reading the same at every position -- a blank wall, a patch of sky @@ -308,21 +314,32 @@ export function sweepZones(frames, opts = {}) { const peakAt = new Array(n).fill(null); for (let i = 0; i < n; i++) { - let hi = -1, lo = Infinity, at = -1, ever = false; + let hi = -1, lo = Infinity, at = -1, ever = false, pinned = false; for (let f = 0; f < frames.length; f++) { + if (frames[f].sat && frames[f].sat[i]) pinned = true; if (frames[f].state[i] !== 'measured') continue; ever = true; const v = frames[f].fv[i]; if (v > hi) { hi = v; at = f; } if (v < lo) lo = v; } + /* A zone that hit the counter's ceiling anywhere along the sweep has + * no usable peak POSITION, which is a separate loss from the one the + * ratio suffers. Clamped readings plateau, the first of them wins the + * argmax, and where the counter happened to fill up is not where the + * lens was sharpest -- so the zone would be accused of focusing + * somewhere else on the strength of an artefact. `state` cannot carry + * this: summarise keeps saturation separate on purpose, because a + * pinned zone is still perfectly well exposed. */ + if (pinned) continue; /* Never measured, or never moved. No opinion either way. */ if (!ever || hi <= 0 || (hi - lo) / hi < swing) continue; peakAt[i] = at; } const heard = peakAt.filter((v) => v !== null).sort((a, b) => a - b); - if (!heard.length) return { consensus: null, peakAt, suspect: [], heard: 0 }; + if (!heard.length) + return { consensus: null, peakAt, suspect: [], heard: 0, rows, cols }; /* Median, not mean. One smeared corner peaking at the far end of the sweep * drags a mean toward itself and then measures everything else against a * consensus it invented. */ @@ -331,5 +348,7 @@ export function sweepZones(frames, opts = {}) { const suspect = []; for (let i = 0; i < n; i++) if (peakAt[i] !== null && Math.abs(peakAt[i] - consensus) > far) suspect.push(i); - return { consensus, peakAt, suspect, heard: heard.length, far }; + /* The shape travels with the finding. A consumer holding these indices + * across a grid change would otherwise place them by the new width. */ + return { consensus, peakAt, suspect, heard: heard.length, far, rows, cols }; } diff --git a/dist/editor.js b/dist/editor.js index e08139a..d10559c 100644 --- a/dist/editor.js +++ b/dist/editor.js @@ -3982,6 +3982,10 @@ export function mountEditor(root, { * lens can never be beaten, so it reads as "you are getting worse". */ function resetFocusState() { focusSum = null; focusBest = null; focusErr = null; focusHold = null; + /* Measured against one scene, one lens position and one filter. A new + * frame is none of those, and a ring left over from the last one + * points at a zone that no longer means anything. */ + focusOdd = null; } /* One grid, summarised. Shared by the poll and by the sweep so the two @@ -4221,6 +4225,9 @@ export function mountEditor(root, { * moving. */ if (moveVerb) return; moveVerb = verb; + /* Driving the lens by hand moves everything the findings were + * measured against. */ + focusOdd = null; movePointer = (ev && ev.pointerId !== undefined) ? ev.pointerId : null; moveGen++; /* Armed BEFORE the first ask. Arming them after meant a move that @@ -4308,7 +4315,11 @@ export function mountEditor(root, { * host seam like intervalMs and moveRepeatMs: a motor that settles faster * than this is being waited on for nothing, and a test driving a fixture * has nothing to settle at all. */ - const settleMs = () => (focus && focus.sweepSettleMs) || SWEEP_SETTLE_MS; + /* `!== undefined`, not truthiness: 0 is a real answer here -- a host with + * nothing to settle -- and `0 || 700` turns it into the full production + * wait, twice per step, in exactly the case that asked for none. */ + const settleMs = () => (focus && focus.sweepSettleMs !== undefined + ? focus.sweepSettleMs : SWEEP_SETTLE_MS); let sweepGen = 0; /* A sweep in flight when the editor is torn down still has a return walk to * finish -- the lens is real and must go back -- but what it must NOT do is @@ -4373,7 +4384,8 @@ export function mountEditor(root, { * distance that part of the frame is at. Throwing all but * the maximum away discards the only measurement that can * see dirt on the glass. */ - frames.push({ fv: s.fv, state: s.state }); + frames.push({ fv: s.fv, state: s.state, sat: s.satZone, + rows: s.rows, cols: s.cols }); } catch (e) { failed = e && e.message ? e.message : String(e); break; @@ -4428,7 +4440,8 @@ export function mountEditor(root, { /* Needs a sweep with a shape to it. A run cut short by a stop or a * refused move has too few positions for an argmax to mean anything. */ try { odd = sweepZones(frames); } catch (e) { odd = null; } - focusOdd = odd && odd.suspect.length ? odd.suspect : null; + focusOdd = odd && odd.suspect.length + ? { idx: odd.suspect, rows: odd.rows, cols: odd.cols } : null; drawFocusMarks(); return { hi: hi, lo: lo, ratio: lo > 0 ? hi / lo : null, n: vals.length, lost: lost, back: back, pinned: pinned, steps: vals.length, @@ -4656,7 +4669,8 @@ export function mountEditor(root, { /* The held best is from the old filter and cannot be compared * with what this one reads -- different filters count detail * differently, so the number to beat has to start again. */ - focusHold = null; focusBest = null; + /* Findings belong to the filter they were measured under. */ + focusHold = null; focusBest = null; focusOdd = null; renderFocus(); startFocusPoll(); armHold(status, send, Math.max(5, focus.holdSeconds || 30), @@ -4828,7 +4842,8 @@ export function mountEditor(root, { /* Restarted, not merely cleared and re-read: a read still in flight * would otherwise land afterwards and push the very peak that was * just discarded back into a fresh hold. */ - focusHold = null; focusBest = null; + /* Findings belong to the filter they were measured under. */ + focusHold = null; focusBest = null; focusOdd = null; renderFocus(); startFocusPoll(); }); @@ -4864,6 +4879,26 @@ export function mountEditor(root, { * of the frame. Cleared whenever the lens or the filter moves under it. */ let focusOdd = null; + /* A finding is only about the grid it was measured on. Indices are read + * back as a row and a column through the CURRENT width, so a camera that + * changed shape between the sweep and now would have them pointing at + * whatever happens to sit at that offset. */ + function oddHere(s) { + if (!focusOdd) return null; + if (focusOdd.rows !== s.rows || focusOdd.cols !== s.cols) return null; + return focusOdd.idx; + } + + function oddRing(svg, NS, x, y, w, h) { + const ring = document.createElementNS(NS, 'rect'); + ring.setAttribute('x', x + 1.5); + ring.setAttribute('y', y + 1.5); + ring.setAttribute('width', Math.max(0, w - 3)); + ring.setAttribute('height', Math.max(0, h - 3)); + ring.setAttribute('class', 're-fz-odd'); + svg.append(ring); + } + function drawCoarse(svg, s, W, H, NS) { const c = coarsen(s, focusBlocks); const top = c.best === null ? null : c.blocks[c.best].value; @@ -4888,20 +4923,13 @@ export function mountEditor(root, { } svg.append(cell); - const odd = focusOdd && focusOdd.some((i) => { + const marked = oddHere(s); + const odd = marked && marked.some((i) => { const r = (i / s.cols) | 0, cc = i % s.cols; return r >= b.rowSpan[0] && r < b.rowSpan[1] && cc >= b.colSpan[0] && cc < b.colSpan[1]; }); - if (odd) { - const ring = document.createElementNS(NS, 'rect'); - ring.setAttribute('x', a0.x + 2); - ring.setAttribute('y', a0.y + 2); - ring.setAttribute('width', Math.max(0, w - 4)); - ring.setAttribute('height', Math.max(0, h - 4)); - ring.setAttribute('class', 're-fz-odd'); - svg.append(ring); - } + if (odd) oddRing(svg, NS, a0.x, a0.y, w, h); const label = document.createElementNS(NS, 'text'); label.setAttribute('x', a0.x + w / 2); @@ -4971,6 +4999,20 @@ export function mountEditor(root, { } svg.append(cell); } + /* The reason to switch to every zone is to see exactly which ones were + * flagged, so this is the last view that should drop the rings -- and + * it did, while the verdict went on saying they were on the picture. */ + const marked = oddHere(s); + if (marked) { + for (const i of marked) { + const r = (i / s.cols) | 0, c = i % s.cols; + const a2 = stageCoords((c * W) / s.cols, (r * H) / s.rows); + const b2 = stageCoords(((c + 1) * W) / s.cols, ((r + 1) * H) / s.rows); + if (!a2 || !b2) continue; + oddRing(svg, NS, a2.x, a2.y, + Math.max(0, b2.x - a2.x), Math.max(0, b2.y - a2.y)); + } + } if (s.peakAt) { const r = s.peakAt.row, c = s.peakAt.col; const a = stageCoords((c * W) / s.cols, (r * H) / s.rows); diff --git a/src/aftune.js b/src/aftune.js index a6e8c47..3735e5f 100644 --- a/src/aftune.js +++ b/src/aftune.js @@ -292,9 +292,15 @@ export function sweepZones(frames, opts = {}) { if (!Array.isArray(frames) || frames.length < 3) throw new Error(`a sweep needs at least three readings, got ${frames && frames.length}`); const n = frames[0].fv.length; - for (const f of frames) - if (f.fv.length !== n) + const rows = frames[0].rows, cols = frames[0].cols; + /* Shape, not just count. Every index here is read back as a row and a + * column through the CURRENT grid's width, so two grids of the same size + * and different shape put the same index in a different part of the + * picture -- and a ring drawn over the wrong zone is worse than none. */ + for (const f of frames) { + if (f.fv.length !== n || f.rows !== rows || f.cols !== cols) throw new Error('the grid changed shape during the sweep'); + } /* How much a zone has to move before its peak position means anything. A * zone reading the same at every position -- a blank wall, a patch of sky @@ -308,21 +314,32 @@ export function sweepZones(frames, opts = {}) { const peakAt = new Array(n).fill(null); for (let i = 0; i < n; i++) { - let hi = -1, lo = Infinity, at = -1, ever = false; + let hi = -1, lo = Infinity, at = -1, ever = false, pinned = false; for (let f = 0; f < frames.length; f++) { + if (frames[f].sat && frames[f].sat[i]) pinned = true; if (frames[f].state[i] !== 'measured') continue; ever = true; const v = frames[f].fv[i]; if (v > hi) { hi = v; at = f; } if (v < lo) lo = v; } + /* A zone that hit the counter's ceiling anywhere along the sweep has + * no usable peak POSITION, which is a separate loss from the one the + * ratio suffers. Clamped readings plateau, the first of them wins the + * argmax, and where the counter happened to fill up is not where the + * lens was sharpest -- so the zone would be accused of focusing + * somewhere else on the strength of an artefact. `state` cannot carry + * this: summarise keeps saturation separate on purpose, because a + * pinned zone is still perfectly well exposed. */ + if (pinned) continue; /* Never measured, or never moved. No opinion either way. */ if (!ever || hi <= 0 || (hi - lo) / hi < swing) continue; peakAt[i] = at; } const heard = peakAt.filter((v) => v !== null).sort((a, b) => a - b); - if (!heard.length) return { consensus: null, peakAt, suspect: [], heard: 0 }; + if (!heard.length) + return { consensus: null, peakAt, suspect: [], heard: 0, rows, cols }; /* Median, not mean. One smeared corner peaking at the far end of the sweep * drags a mean toward itself and then measures everything else against a * consensus it invented. */ @@ -331,5 +348,7 @@ export function sweepZones(frames, opts = {}) { const suspect = []; for (let i = 0; i < n; i++) if (peakAt[i] !== null && Math.abs(peakAt[i] - consensus) > far) suspect.push(i); - return { consensus, peakAt, suspect, heard: heard.length, far }; + /* The shape travels with the finding. A consumer holding these indices + * across a grid change would otherwise place them by the new width. */ + return { consensus, peakAt, suspect, heard: heard.length, far, rows, cols }; } diff --git a/src/editor.js b/src/editor.js index e08139a..d10559c 100644 --- a/src/editor.js +++ b/src/editor.js @@ -3982,6 +3982,10 @@ export function mountEditor(root, { * lens can never be beaten, so it reads as "you are getting worse". */ function resetFocusState() { focusSum = null; focusBest = null; focusErr = null; focusHold = null; + /* Measured against one scene, one lens position and one filter. A new + * frame is none of those, and a ring left over from the last one + * points at a zone that no longer means anything. */ + focusOdd = null; } /* One grid, summarised. Shared by the poll and by the sweep so the two @@ -4221,6 +4225,9 @@ export function mountEditor(root, { * moving. */ if (moveVerb) return; moveVerb = verb; + /* Driving the lens by hand moves everything the findings were + * measured against. */ + focusOdd = null; movePointer = (ev && ev.pointerId !== undefined) ? ev.pointerId : null; moveGen++; /* Armed BEFORE the first ask. Arming them after meant a move that @@ -4308,7 +4315,11 @@ export function mountEditor(root, { * host seam like intervalMs and moveRepeatMs: a motor that settles faster * than this is being waited on for nothing, and a test driving a fixture * has nothing to settle at all. */ - const settleMs = () => (focus && focus.sweepSettleMs) || SWEEP_SETTLE_MS; + /* `!== undefined`, not truthiness: 0 is a real answer here -- a host with + * nothing to settle -- and `0 || 700` turns it into the full production + * wait, twice per step, in exactly the case that asked for none. */ + const settleMs = () => (focus && focus.sweepSettleMs !== undefined + ? focus.sweepSettleMs : SWEEP_SETTLE_MS); let sweepGen = 0; /* A sweep in flight when the editor is torn down still has a return walk to * finish -- the lens is real and must go back -- but what it must NOT do is @@ -4373,7 +4384,8 @@ export function mountEditor(root, { * distance that part of the frame is at. Throwing all but * the maximum away discards the only measurement that can * see dirt on the glass. */ - frames.push({ fv: s.fv, state: s.state }); + frames.push({ fv: s.fv, state: s.state, sat: s.satZone, + rows: s.rows, cols: s.cols }); } catch (e) { failed = e && e.message ? e.message : String(e); break; @@ -4428,7 +4440,8 @@ export function mountEditor(root, { /* Needs a sweep with a shape to it. A run cut short by a stop or a * refused move has too few positions for an argmax to mean anything. */ try { odd = sweepZones(frames); } catch (e) { odd = null; } - focusOdd = odd && odd.suspect.length ? odd.suspect : null; + focusOdd = odd && odd.suspect.length + ? { idx: odd.suspect, rows: odd.rows, cols: odd.cols } : null; drawFocusMarks(); return { hi: hi, lo: lo, ratio: lo > 0 ? hi / lo : null, n: vals.length, lost: lost, back: back, pinned: pinned, steps: vals.length, @@ -4656,7 +4669,8 @@ export function mountEditor(root, { /* The held best is from the old filter and cannot be compared * with what this one reads -- different filters count detail * differently, so the number to beat has to start again. */ - focusHold = null; focusBest = null; + /* Findings belong to the filter they were measured under. */ + focusHold = null; focusBest = null; focusOdd = null; renderFocus(); startFocusPoll(); armHold(status, send, Math.max(5, focus.holdSeconds || 30), @@ -4828,7 +4842,8 @@ export function mountEditor(root, { /* Restarted, not merely cleared and re-read: a read still in flight * would otherwise land afterwards and push the very peak that was * just discarded back into a fresh hold. */ - focusHold = null; focusBest = null; + /* Findings belong to the filter they were measured under. */ + focusHold = null; focusBest = null; focusOdd = null; renderFocus(); startFocusPoll(); }); @@ -4864,6 +4879,26 @@ export function mountEditor(root, { * of the frame. Cleared whenever the lens or the filter moves under it. */ let focusOdd = null; + /* A finding is only about the grid it was measured on. Indices are read + * back as a row and a column through the CURRENT width, so a camera that + * changed shape between the sweep and now would have them pointing at + * whatever happens to sit at that offset. */ + function oddHere(s) { + if (!focusOdd) return null; + if (focusOdd.rows !== s.rows || focusOdd.cols !== s.cols) return null; + return focusOdd.idx; + } + + function oddRing(svg, NS, x, y, w, h) { + const ring = document.createElementNS(NS, 'rect'); + ring.setAttribute('x', x + 1.5); + ring.setAttribute('y', y + 1.5); + ring.setAttribute('width', Math.max(0, w - 3)); + ring.setAttribute('height', Math.max(0, h - 3)); + ring.setAttribute('class', 're-fz-odd'); + svg.append(ring); + } + function drawCoarse(svg, s, W, H, NS) { const c = coarsen(s, focusBlocks); const top = c.best === null ? null : c.blocks[c.best].value; @@ -4888,20 +4923,13 @@ export function mountEditor(root, { } svg.append(cell); - const odd = focusOdd && focusOdd.some((i) => { + const marked = oddHere(s); + const odd = marked && marked.some((i) => { const r = (i / s.cols) | 0, cc = i % s.cols; return r >= b.rowSpan[0] && r < b.rowSpan[1] && cc >= b.colSpan[0] && cc < b.colSpan[1]; }); - if (odd) { - const ring = document.createElementNS(NS, 'rect'); - ring.setAttribute('x', a0.x + 2); - ring.setAttribute('y', a0.y + 2); - ring.setAttribute('width', Math.max(0, w - 4)); - ring.setAttribute('height', Math.max(0, h - 4)); - ring.setAttribute('class', 're-fz-odd'); - svg.append(ring); - } + if (odd) oddRing(svg, NS, a0.x, a0.y, w, h); const label = document.createElementNS(NS, 'text'); label.setAttribute('x', a0.x + w / 2); @@ -4971,6 +4999,20 @@ export function mountEditor(root, { } svg.append(cell); } + /* The reason to switch to every zone is to see exactly which ones were + * flagged, so this is the last view that should drop the rings -- and + * it did, while the verdict went on saying they were on the picture. */ + const marked = oddHere(s); + if (marked) { + for (const i of marked) { + const r = (i / s.cols) | 0, c = i % s.cols; + const a2 = stageCoords((c * W) / s.cols, (r * H) / s.rows); + const b2 = stageCoords(((c + 1) * W) / s.cols, ((r + 1) * H) / s.rows); + if (!a2 || !b2) continue; + oddRing(svg, NS, a2.x, a2.y, + Math.max(0, b2.x - a2.x), Math.max(0, b2.y - a2.y)); + } + } if (s.peakAt) { const r = s.peakAt.row, c = s.peakAt.col; const a = stageCoords((c * W) / s.cols, (r * H) / s.rows); diff --git a/tests/ui-check.html b/tests/ui-check.html index 33c3009..23b743c 100644 --- a/tests/ui-check.html +++ b/tests/ui-check.html @@ -2525,7 +2525,7 @@ // sweep checks alone would outrun the page's whole time budget. // Nothing to settle in a fixture; a test that has to ACT during a // sweep asks for a slower one so there is a sweep to act on. - focus.sweepSettleMs = opts.settle || 30; + focus.sweepSettleMs = opts.settle !== undefined ? opts.settle : 30; } if (!opts.readOnly) { focus.holdSeconds = opts.hold || 30; @@ -3014,6 +3014,52 @@ t('and it is ringed on the picture', g.host.querySelectorAll('.re-fz-odd').length > 0, g.host.querySelectorAll('.re-fz-odd').length + ' ringed'); + + // Switching to every zone is how you find out WHICH zone was flagged, + // so it is the last view that may drop the rings -- and it did, while + // the verdict went on saying they were on the picture. + const gr = (label) => [...g.host.querySelectorAll('[data-act="focus-grain"] button')] + .find((b) => b.textContent.trim() === label); + gr('All zones').click(); + await pause(120); + t('and still ringed with every zone shown', + g.host.querySelectorAll('.re-fz-odd').length > 0, + g.host.querySelectorAll('.re-fz-odd').length + ' ringed'); + + // A finding belongs to the lens position it was measured at. + gr('3\u00d73').click(); + await pause(80); + q(g.host, 'focus-far').dispatchEvent(new PointerEvent('pointerdown', { button: 0, bubbles: true })); + q(g.host, 'focus-far').dispatchEvent(new PointerEvent('pointerup', { button: 0, bubbles: true })); + await pause(150); + t('driving the lens by hand drops the old findings', + g.host.querySelectorAll('.re-fz-odd').length === 0, + g.host.querySelectorAll('.re-fz-odd').length + ' left'); + // A new frame is a different scene; a ring left over from the last one + // points at a zone that no longer means anything. + q(g.host, 'af-measure').click(); + for (let i = 0; i < 300 && q(g.host, 'af-measure').hidden; i++) await pause(100); + t('the findings come back', g.host.querySelectorAll('.re-fz-odd').length > 0); + await g.ed.open(bytes, 'fixture.dng'); + await pause(300); + [...g.host.querySelectorAll('.re-seg button')].find((b) => b.textContent.trim() === 'Focus').click(); + await pause(150); + t('and a new frame drops them', + g.host.querySelectorAll('.re-fz-odd').length === 0, + g.host.querySelectorAll('.re-fz-odd').length + ' left'); + } + g.ed.destroy(); + + // A host with nothing to settle said so, and `0 || 700` turned that into + // the full production wait twice per step -- 11s of doing nothing, in + // exactly the case that asked for none. + g = await mk({ lens: true, settle: 0 }); + { + const began = Date.now(); + q(g.host, 'af-measure').click(); + for (let i = 0; i < 300 && q(g.host, 'af-measure').hidden; i++) await pause(50); + const took = Date.now() - began; + t('a settle of zero is a value, not an absence', took < 3000, took + 'ms'); } g.ed.destroy(); diff --git a/tools/smoke.mjs b/tools/smoke.mjs index 4caadc6..68c24e5 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -1290,6 +1290,8 @@ console.log('\nfocus statistics: the grid a person focuses a lens by'); const frames = [0, 1, 2, 3, 4].map((p) => ({ fv: scene.map((c) => c[p]), state: scene.map(() => 'measured'), + sat: scene.map(() => false), + rows: 1, cols: 4, })); const r = A.sweepZones(frames); check('the scene agrees where focus is', r.consensus, 3); @@ -1297,10 +1299,7 @@ console.log('\nfocus statistics: the grid a person focuses a lens by'); // A zone that never moves has an argmax and it is noise. Asking it // where it focuses gets an answer indistinguishable from a real one. - const flat = frames.map((f, p) => ({ - fv: [...f.fv.slice(0, 3), 500], - state: f.state, - })); + const flat = frames.map((f) => ({ ...f, fv: [...f.fv.slice(0, 3), 500] })); const rf = A.sweepZones(flat); check('a zone that never moved is not accused', rf.suspect.includes(3), false); check('and is not counted as having an opinion', rf.peakAt[3], null); @@ -1309,12 +1308,48 @@ console.log('\nfocus statistics: the grid a person focuses a lens by'); // toward itself and then judge everything else against it. const smear = [curve(4), curve(4), curve(0), curve(4), curve(4)]; const rs = A.sweepZones([0, 1, 2, 3, 4].map((p) => ({ - fv: smear.map((c) => c[p]), state: smear.map(() => 'measured') }))); + fv: smear.map((c) => c[p]), state: smear.map(() => 'measured'), + sat: smear.map(() => false), rows: 1, cols: 5 }))); check('the consensus is the median', rs.consensus, 4); let short = false; try { A.sweepZones([{ fv: [1], state: ['measured'] }]); } catch { short = true; } assert('a sweep too short to have a shape is refused', short); + + // A clamped zone plateaus, so the FIRST ceiling reading wins its + // argmax -- and where the counter filled up is not where the lens was + // sharpest. Judged on that, the zone gets accused of focusing + // somewhere else on the strength of an artefact. `state` cannot carry + // this: a pinned zone is still perfectly well exposed. + { + const pin = [0, 1, 2, 3, 4].map((p) => ({ + fv: scene.map((c) => c[p]), + state: scene.map(() => 'measured'), + // zone 1 is the outlier; say its counter was full throughout + sat: [false, true, false, false], + rows: 1, cols: 4, + })); + const rp = A.sweepZones(pin); + check('a clamped zone is given no peak position', rp.peakAt[1], null); + check('so it is not accused of focusing elsewhere', rp.suspect, []); + // ...and the rest of the frame still has its say. + check('while the others still agree', rp.consensus, 3); + } + + // Two grids of the same SIZE and different shape put the same index in + // a different part of the picture, and a ring over the wrong zone is + // worse than no ring. + { + const one = (r, c) => ({ fv: [1, 2, 3, 4], state: Array(4).fill('measured'), + sat: Array(4).fill(false), rows: r, cols: c }); + let reshaped = false; + try { A.sweepZones([one(1, 4), one(2, 2), one(1, 4)]); } catch { reshaped = true; } + assert('a grid that changed shape mid-sweep is refused', reshaped); + } + + // The finding travels with the shape it was measured on, so a consumer + // cannot place it by some later grid's width. + check('a finding carries its own shape', [r.rows, r.cols], [1, 4]); } // A grid whose length disagrees with its shape would still draw -- shifted,