diff --git a/dist/aftune.js b/dist/aftune.js index d7cddbb..3735e5f 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,154 @@ 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; + 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 + * -- 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, 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, 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. */ + 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); + /* 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.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..d10559c 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 = [ @@ -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 @@ -4304,6 +4311,15 @@ 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. */ + /* `!== 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 @@ -4342,7 +4358,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 +4366,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 +4378,14 @@ 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, sat: s.satZone, + rows: s.rows, cols: s.cols }); } catch (e) { failed = e && e.message ? e.message : String(e); break; @@ -4375,7 +4401,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 +4417,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 +4436,17 @@ 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 + ? { 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 }; + lost: lost, back: back, pinned: pinned, steps: vals.length, + odd: odd && odd.suspect.length ? odd.suspect.length : 0, + heard: odd ? odd.heard : 0 }; } @@ -4634,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), @@ -4699,12 +4735,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 +4811,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'; @@ -4768,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(); }); @@ -4795,6 +4870,100 @@ 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; + + /* 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; + 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 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) oddRing(svg, NS, a0.x, a0.y, w, h); + + 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 +4973,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); @@ -4825,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 d7cddbb..3735e5f 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,154 @@ 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; + 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 + * -- 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, 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, 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. */ + 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); + /* 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.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..d10559c 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 = [ @@ -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 @@ -4304,6 +4311,15 @@ 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. */ + /* `!== 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 @@ -4342,7 +4358,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 +4366,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 +4378,14 @@ 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, sat: s.satZone, + rows: s.rows, cols: s.cols }); } catch (e) { failed = e && e.message ? e.message : String(e); break; @@ -4375,7 +4401,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 +4417,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 +4436,17 @@ 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 + ? { 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 }; + lost: lost, back: back, pinned: pinned, steps: vals.length, + odd: odd && odd.suspect.length ? odd.suspect.length : 0, + heard: odd ? odd.heard : 0 }; } @@ -4634,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), @@ -4699,12 +4735,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 +4811,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'; @@ -4768,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(); }); @@ -4795,6 +4870,100 @@ 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; + + /* 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; + 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 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) oddRing(svg, NS, a0.x, a0.y, w, h); + + 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 +4973,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); @@ -4825,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 ae2029d..23b743c 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 !== undefined ? 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,112 @@ 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'); + + // 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(); + + // 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..68c24e5 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -1243,6 +1243,115 @@ 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'), + sat: scene.map(() => false), + rows: 1, cols: 4, + })); + 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) => ({ ...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); + + // 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'), + 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, // every zone in the wrong place. Refused rather than rendered. let threw = false;