diff --git a/dist/aftune.js b/dist/aftune.js index 7f38e77..39754e5 100644 --- a/dist/aftune.js +++ b/dist/aftune.js @@ -361,10 +361,9 @@ export function sweepZones(frames, opts = {}) { * -- 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; + /* How many times the scene's own disagreement a zone has to exceed before + * it counts as being at a different distance. */ + const apart = opts.apart !== undefined ? opts.apart : 3; const peakAt = new Array(n).fill(null); const why = new Array(n).fill('unmeasured'); @@ -410,7 +409,28 @@ export function sweepZones(frames, opts = {}) { * 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)); + /* How far out is far, measured against how tightly the SCENE agrees -- + * not against the number of readings. + * + * Every zone is sampled at the same instants, so their peak ORDER means + * something however fast the lens was moving. What does not survive uneven + * travel is a threshold set as a fixed fraction of the reading count: an + * operator who slows down through focus piles most of the readings there, + * which spreads the normal zones out in index and makes a fixed fraction + * either blind or trigger-happy. The median absolute deviation of the + * zones that responded absorbs exactly that, because it is measured in the + * same distorted units as the thing being judged. + * + * The fraction stays as a FLOOR, so a scene where every zone agrees to the + * frame does not start flagging its own noise. */ + const dev = heard.map((v) => Math.abs(v - consensus)).sort((a, b) => a - b); + const mad = dev[dev.length >> 1]; + /* The floor is two readings, NOT a fraction of how many were taken. A + * fraction looks prudent and is the bug: a long sweep gets a large floor, + * so the more carefully someone measures the blinder this gets. Two + * readings is only there to stop a scene that agrees to the frame from + * flagging its own noise. */ + const far = Math.max(2, Math.round(apart * mad)); const suspect = []; for (let i = 0; i < n; i++) if (peakAt[i] !== null && Math.abs(peakAt[i] - consensus) > far) suspect.push(i); @@ -461,3 +481,36 @@ export function zoneDetail(hold, opts = {}) { } return out; } + +/* + * Did the lens go one way? + * + * Everything sweepZones concludes about DISTANCE rests on one assumption: that + * reading order is lens order. A motor guarantees it. A hand does not -- an + * operator who turns forward, back, and forward again visits the same position + * at three different indices, and two zones peaking at different indices may + * be at the same distance after all. Uneven speed is survivable, and the + * median absolute deviation handles it; going BACK is not, because it breaks + * the mapping rather than stretching it. + * + * Detected from the scene's own curve rather than from any position the + * readings do not carry: swept once through focus, the overall reading rises + * and falls once. Crossing the halfway mark upwards more than once means the + * lens came back. The ratio is unharmed either way -- highest over lowest does + * not care what order they arrived in -- so only the distance findings are + * withheld. + */ +export function sweptOneWay(peaks) { + const v = peaks.filter((p) => typeof p === 'number' && isFinite(p)); + if (v.length < 4) return false; + const hi = Math.max.apply(null, v), lo = Math.min.apply(null, v); + if (hi <= lo) return false; + /* Half way up the range: high enough that noise around the trough does not + * register as the lens turning round, low enough to catch a real second + * excursion. */ + const mid = lo + (hi - lo) / 2; + let ups = 0; + for (let i = 1; i < v.length; i++) + if (v[i - 1] < mid && v[i] >= mid) ups++; + return ups <= 1; +} diff --git a/dist/editor.js b/dist/editor.js index fdd55a1..0122189 100644 --- a/dist/editor.js +++ b/dist/editor.js @@ -15,7 +15,8 @@ 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, coarsen, sweepZones, zoneDetail } from './aftune.js'; +import { summarise, peakHold, normalise, coarsen, sweepZones, zoneDetail, + sweptOneWay } from './aftune.js'; const CFA_NAMES = ['RGGB', 'GRBG', 'GBRG', 'BGGR']; const DEMOSAIC = [ @@ -4018,6 +4019,11 @@ export function mountEditor(root, { focusErr = null; if (!focusHold) focusHold = peakHold(); focusBest = focusHold.push(sum); + if (handFrames && handFrames.length < HAND_MAX) { + handFrames.push({ fv: sum.fv, state: sum.state, sat: sum.satZone, + rows: sum.rows, cols: sum.cols, peak: sum.peak, + pinned: sum.peakSaturated }); + } } renderFocus(); drawFocusMarks(); @@ -4355,6 +4361,95 @@ export function mountEditor(root, { return p; } + /* One verdict, whichever drove the lens. A hand sweep and a motor sweep + * measure the same thing and must not be able to word it differently. */ + function sayVerdict(say, r) { + if (r.pinned) { + say('Cannot say. The sharpest zone was at the top of the ' + + 'camera\u2019s counter for ' + r.pinned + ' of the ' + + r.steps + ' readings, so it had no room to fall and the ' + + 'spread would be an understatement of nothing. Lower the ' + + 'first gain until the grid stops reading at the ceiling, ' + + 'then measure again.' + r.back, true); + return; + } + /* 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.' + : ''; + /* The ratio, not the peak. A filter that reads loudly everywhere is + * worse than a quiet one that falls away, and the peak alone cannot + * tell them apart -- which is the mistake this exists to prevent. */ + 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 + aside, + !!r.lost || !!r.odd); + } + + /* Least a hand sweep can be judged on. Three is enough for sweepZones to + * have a shape, but a ratio taken from three readings of a barrel someone + * nudged is not a measurement of anything. */ + const HAND_MIN = 8; + /* How far the reading must travel before the sweep counts as a sweep. The + * operator may have turned nothing, turned it the wrong way, or turned it + * within the depth of field -- and a ratio computed from that would read + * as "this filter cannot see focus" when the lens simply did not move. */ + const HAND_MOVED = 0.2; + + function handVerdict(frames) { + if (!frames || frames.length < HAND_MIN) + return { failed: 'only ' + (frames ? frames.length : 0) + ' readings; ' + + 'turn the lens right through focus, slowly, so there is a curve to measure' }; + const vals = frames.map((f) => f.peak).filter((v) => v !== null); + if (!vals.length) return { failed: 'nothing measurable along the sweep' }; + const hi = Math.max.apply(null, vals), lo = Math.min.apply(null, vals); + if (hi <= 0 || (hi - lo) / hi < HAND_MOVED) + return { failed: 'the reading barely changed, so the lens does not look ' + + 'like it moved through focus. Turn it from one end of its travel to ' + + 'the other' }; + /* Shape first. sweepZones refuses a grid that changed mid-sweep, and + * the ratio is no better off: peaks taken over different zone sizes + * are not comparable numbers, so reporting one while quietly dropping + * the zone findings would hand back the half that is just as wrong. */ + const shape = frames[0].rows + 'x' + frames[0].cols; + if (frames.some((f) => f.rows + 'x' + f.cols !== shape)) + return { failed: 'the camera changed its zone grid part way through, so ' + + 'the readings are not measurements of the same thing' }; + const pinned = frames.filter((f) => f.pinned).length; + /* Which way the lens went is the whole basis for calling a zone + * distant, and a hand is free to turn back. The ratio does not care + * -- highest over lowest is the same whatever order they arrived in + * -- so a wandering sweep keeps its ratio and loses only the claim it + * can no longer support. */ + const oneWay = sweptOneWay(frames.map((f) => f.peak)); + let odd = null; + if (oneWay) { try { odd = sweepZones(frames); } catch (e) { odd = null; } } + return { + hi: hi, lo: lo, ratio: lo > 0 ? hi / lo : null, steps: vals.length, + pinned: pinned, back: oneWay ? '' : + ' Zones at a different distance are not reported: the reading rose ' + + 'and fell more than once, so the lens looks like it was turned back ' + + 'and forth rather than swept one way.', + odd: odd && odd.suspect.length ? odd.suspect.length : 0, + heard: odd ? odd.heard : 0, + suspect: odd && odd.suspect.length + ? { idx: odd.suspect, rows: odd.rows, cols: odd.cols } : null, + }; + } + async function sweepRun(say, onStep) { const gen = ++sweepGen; const mine = () => gen === sweepGen; @@ -4368,6 +4463,7 @@ export function mountEditor(root, { ownLens(true); /* Last sweep's findings belong to last sweep's lens and filter. */ focusOdd = null; + handStop(); try { for (let i = 0; i <= SWEEP_STEPS; i++) { if (!mine()) break; @@ -4571,6 +4667,24 @@ export function mountEditor(root, { stop.hidden = true; acts.append(measure, stop); } + /* The operator can always be the motor, and on some lenses should be. + * Measure it takes eight fixed steps, which is a guess at how far this + * lens has to travel to leave focus -- on an 85H50AI those eight steps + * moved the reading 3% while the full travel moved it fourfold, so the + * automatic sweep had nothing to measure. A hand covers the whole + * range. The only thing it loses is evenly spaced travel, and a median + * consensus never depended on that. */ + let hand = null, handDone = null; + { + hand = el('button', 're-btn', ''); + hand.dataset.act = 'af-hand'; + hand.textContent = 'Measure by hand'; + handDone = el('button', 're-btn', ''); + handDone.dataset.act = 'af-hand-stop'; + handDone.textContent = 'Done'; + handDone.hidden = true; + acts.append(hand, handDone); + } box.append(acts); const say = (msg, warn) => { @@ -4687,16 +4801,61 @@ export function mountEditor(root, { }); }); + if (hand) { + let ticker = null; + const busy = (on) => { + hand.hidden = on; + handDone.hidden = !on; + send.disabled = on; + back.disabled = on; + if (measure) measure.disabled = on; + }; + const finish = () => { + if (ticker) { clearInterval(ticker); ticker = null; } + busy(false); + }; + hand.addEventListener('click', function () { + if (busyHolding(status)) return; + if (sweepping()) return; + busy(true); + handCancel = finish; + /* Findings from the last sweep describe the lens where it was. */ + focusOdd = null; + handFrames = []; + const tick = () => { + const n = handFrames ? handFrames.length : 0; + say('Turn the lens slowly from one end of its travel to the ' + + 'other, right through focus. Keep going \u2014 ' + n + + ' reading' + (n === 1 ? '' : 's') + ' so far.'); + }; + tick(); + ticker = setInterval(tick, 400); + }); + handDone.addEventListener('click', function () { + const frames = handFrames; + handFrames = null; + handCancel = null; + finish(); + const r = handVerdict(frames); + if (r.failed) { say('Could not measure it: ' + r.failed + '.', true); return; } + focusOdd = r.suspect; + drawFocusMarks(); + sayVerdict(say, r); + }); + } + if (measure) { const busy = (on) => { measure.hidden = on; stop.hidden = !on; send.disabled = on; back.disabled = on; + if (hand) hand.disabled = on; }; stop.addEventListener('click', function () { sweepStop(); }); measure.addEventListener('click', function () { if (busyHolding(status)) return; + if (sweepping()) return; busy(true); runSweep(say, function (i, n) { say('Walking the lens and reading as it goes — ' + i + ' of ' + n + @@ -4722,41 +4881,7 @@ export function mountEditor(root, { * Measured on an 85H50AI: a bank whose own sum moved 2.6x * across the sweep reported 1/1.0, because its peak zone * never left 65535. */ - if (r.pinned) { - say('Cannot say. The sharpest zone was at the top of the ' + - 'camera\u2019s counter for ' + r.pinned + ' of the ' + - r.steps + ' readings, so it had no room to fall and the ' + - 'spread would be an understatement of nothing. Lower the ' + - 'first gain until the grid stops reading at the ceiling, ' + - 'then measure again.' + r.back, true); - return; - } - /* The ratio, not the peak. A filter that reads loudly - * 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 + aside, - !!r.lost || !!r.odd); + sayVerdict(say, r); }).catch(function (e) { busy(false); say('Could not measure it: ' + (e && e.message ? e.message : e), true); @@ -4878,6 +5003,29 @@ export function mountEditor(root, { /* 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; + /* While the operator sweeps the lens by hand, every live reading is kept. + * sweepZones never needed lens POSITIONS -- it works from the order the + * readings arrived in and takes a median consensus -- so a hand on the + * barrel is as good a sweep as a motor, only less evenly spaced, which is + * exactly what a median is robust to. Capped because a sweep the operator + * walked away from would otherwise grow for as long as the page is open. */ + const HAND_MAX = 400; + let handFrames = null; + /* Set by the panel that owns the running hand sweep, so leaving Focus or + * tearing the editor down can end it. Without this the ticker kept firing + * and the live poll kept filling a collection whose Done button had been + * removed from the document. */ + let handCancel = null; + + function handStop() { + handFrames = null; + if (handCancel) { const c = handCancel; handCancel = null; c(); } + } + + /* One lens, one measurement. The two sweeps drive the same camera and the + * hand one depends on the live poll that the motor one stops, so either + * running means neither may start. */ + function sweepping() { return handFrames !== null || sweepBusy !== 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 @@ -5061,7 +5209,7 @@ export function mountEditor(root, { abandonHold(); /* A poll that outlived its tab would keep a camera answering for a * panel nobody is looking at. */ - if (m !== 'focus') { stopFocusPoll(); moveRelease(); sweepStop(); focusStatus = null; } + if (m !== 'focus') { stopFocusPoll(); moveRelease(); sweepStop(); handStop(); focusStatus = null; } /* Anything a filter write has outstanding belonged to the panel that is * going. Its answer must not come back and arm a trial here. */ filterGen++; @@ -5358,6 +5506,7 @@ export function mountEditor(root, { stopFocusPoll(); moveRelease(); sweepStop(); + handStop(); // The walk back still has to happen -- the lens is real -- but // nothing after it may touch a panel that is being removed. sweepClosed = true; diff --git a/src/aftune.js b/src/aftune.js index 7f38e77..39754e5 100644 --- a/src/aftune.js +++ b/src/aftune.js @@ -361,10 +361,9 @@ export function sweepZones(frames, opts = {}) { * -- 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; + /* How many times the scene's own disagreement a zone has to exceed before + * it counts as being at a different distance. */ + const apart = opts.apart !== undefined ? opts.apart : 3; const peakAt = new Array(n).fill(null); const why = new Array(n).fill('unmeasured'); @@ -410,7 +409,28 @@ export function sweepZones(frames, opts = {}) { * 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)); + /* How far out is far, measured against how tightly the SCENE agrees -- + * not against the number of readings. + * + * Every zone is sampled at the same instants, so their peak ORDER means + * something however fast the lens was moving. What does not survive uneven + * travel is a threshold set as a fixed fraction of the reading count: an + * operator who slows down through focus piles most of the readings there, + * which spreads the normal zones out in index and makes a fixed fraction + * either blind or trigger-happy. The median absolute deviation of the + * zones that responded absorbs exactly that, because it is measured in the + * same distorted units as the thing being judged. + * + * The fraction stays as a FLOOR, so a scene where every zone agrees to the + * frame does not start flagging its own noise. */ + const dev = heard.map((v) => Math.abs(v - consensus)).sort((a, b) => a - b); + const mad = dev[dev.length >> 1]; + /* The floor is two readings, NOT a fraction of how many were taken. A + * fraction looks prudent and is the bug: a long sweep gets a large floor, + * so the more carefully someone measures the blinder this gets. Two + * readings is only there to stop a scene that agrees to the frame from + * flagging its own noise. */ + const far = Math.max(2, Math.round(apart * mad)); const suspect = []; for (let i = 0; i < n; i++) if (peakAt[i] !== null && Math.abs(peakAt[i] - consensus) > far) suspect.push(i); @@ -461,3 +481,36 @@ export function zoneDetail(hold, opts = {}) { } return out; } + +/* + * Did the lens go one way? + * + * Everything sweepZones concludes about DISTANCE rests on one assumption: that + * reading order is lens order. A motor guarantees it. A hand does not -- an + * operator who turns forward, back, and forward again visits the same position + * at three different indices, and two zones peaking at different indices may + * be at the same distance after all. Uneven speed is survivable, and the + * median absolute deviation handles it; going BACK is not, because it breaks + * the mapping rather than stretching it. + * + * Detected from the scene's own curve rather than from any position the + * readings do not carry: swept once through focus, the overall reading rises + * and falls once. Crossing the halfway mark upwards more than once means the + * lens came back. The ratio is unharmed either way -- highest over lowest does + * not care what order they arrived in -- so only the distance findings are + * withheld. + */ +export function sweptOneWay(peaks) { + const v = peaks.filter((p) => typeof p === 'number' && isFinite(p)); + if (v.length < 4) return false; + const hi = Math.max.apply(null, v), lo = Math.min.apply(null, v); + if (hi <= lo) return false; + /* Half way up the range: high enough that noise around the trough does not + * register as the lens turning round, low enough to catch a real second + * excursion. */ + const mid = lo + (hi - lo) / 2; + let ups = 0; + for (let i = 1; i < v.length; i++) + if (v[i - 1] < mid && v[i] >= mid) ups++; + return ups <= 1; +} diff --git a/src/editor.js b/src/editor.js index fdd55a1..0122189 100644 --- a/src/editor.js +++ b/src/editor.js @@ -15,7 +15,8 @@ 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, coarsen, sweepZones, zoneDetail } from './aftune.js'; +import { summarise, peakHold, normalise, coarsen, sweepZones, zoneDetail, + sweptOneWay } from './aftune.js'; const CFA_NAMES = ['RGGB', 'GRBG', 'GBRG', 'BGGR']; const DEMOSAIC = [ @@ -4018,6 +4019,11 @@ export function mountEditor(root, { focusErr = null; if (!focusHold) focusHold = peakHold(); focusBest = focusHold.push(sum); + if (handFrames && handFrames.length < HAND_MAX) { + handFrames.push({ fv: sum.fv, state: sum.state, sat: sum.satZone, + rows: sum.rows, cols: sum.cols, peak: sum.peak, + pinned: sum.peakSaturated }); + } } renderFocus(); drawFocusMarks(); @@ -4355,6 +4361,95 @@ export function mountEditor(root, { return p; } + /* One verdict, whichever drove the lens. A hand sweep and a motor sweep + * measure the same thing and must not be able to word it differently. */ + function sayVerdict(say, r) { + if (r.pinned) { + say('Cannot say. The sharpest zone was at the top of the ' + + 'camera\u2019s counter for ' + r.pinned + ' of the ' + + r.steps + ' readings, so it had no room to fall and the ' + + 'spread would be an understatement of nothing. Lower the ' + + 'first gain until the grid stops reading at the ceiling, ' + + 'then measure again.' + r.back, true); + return; + } + /* 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.' + : ''; + /* The ratio, not the peak. A filter that reads loudly everywhere is + * worse than a quiet one that falls away, and the peak alone cannot + * tell them apart -- which is the mistake this exists to prevent. */ + 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 + aside, + !!r.lost || !!r.odd); + } + + /* Least a hand sweep can be judged on. Three is enough for sweepZones to + * have a shape, but a ratio taken from three readings of a barrel someone + * nudged is not a measurement of anything. */ + const HAND_MIN = 8; + /* How far the reading must travel before the sweep counts as a sweep. The + * operator may have turned nothing, turned it the wrong way, or turned it + * within the depth of field -- and a ratio computed from that would read + * as "this filter cannot see focus" when the lens simply did not move. */ + const HAND_MOVED = 0.2; + + function handVerdict(frames) { + if (!frames || frames.length < HAND_MIN) + return { failed: 'only ' + (frames ? frames.length : 0) + ' readings; ' + + 'turn the lens right through focus, slowly, so there is a curve to measure' }; + const vals = frames.map((f) => f.peak).filter((v) => v !== null); + if (!vals.length) return { failed: 'nothing measurable along the sweep' }; + const hi = Math.max.apply(null, vals), lo = Math.min.apply(null, vals); + if (hi <= 0 || (hi - lo) / hi < HAND_MOVED) + return { failed: 'the reading barely changed, so the lens does not look ' + + 'like it moved through focus. Turn it from one end of its travel to ' + + 'the other' }; + /* Shape first. sweepZones refuses a grid that changed mid-sweep, and + * the ratio is no better off: peaks taken over different zone sizes + * are not comparable numbers, so reporting one while quietly dropping + * the zone findings would hand back the half that is just as wrong. */ + const shape = frames[0].rows + 'x' + frames[0].cols; + if (frames.some((f) => f.rows + 'x' + f.cols !== shape)) + return { failed: 'the camera changed its zone grid part way through, so ' + + 'the readings are not measurements of the same thing' }; + const pinned = frames.filter((f) => f.pinned).length; + /* Which way the lens went is the whole basis for calling a zone + * distant, and a hand is free to turn back. The ratio does not care + * -- highest over lowest is the same whatever order they arrived in + * -- so a wandering sweep keeps its ratio and loses only the claim it + * can no longer support. */ + const oneWay = sweptOneWay(frames.map((f) => f.peak)); + let odd = null; + if (oneWay) { try { odd = sweepZones(frames); } catch (e) { odd = null; } } + return { + hi: hi, lo: lo, ratio: lo > 0 ? hi / lo : null, steps: vals.length, + pinned: pinned, back: oneWay ? '' : + ' Zones at a different distance are not reported: the reading rose ' + + 'and fell more than once, so the lens looks like it was turned back ' + + 'and forth rather than swept one way.', + odd: odd && odd.suspect.length ? odd.suspect.length : 0, + heard: odd ? odd.heard : 0, + suspect: odd && odd.suspect.length + ? { idx: odd.suspect, rows: odd.rows, cols: odd.cols } : null, + }; + } + async function sweepRun(say, onStep) { const gen = ++sweepGen; const mine = () => gen === sweepGen; @@ -4368,6 +4463,7 @@ export function mountEditor(root, { ownLens(true); /* Last sweep's findings belong to last sweep's lens and filter. */ focusOdd = null; + handStop(); try { for (let i = 0; i <= SWEEP_STEPS; i++) { if (!mine()) break; @@ -4571,6 +4667,24 @@ export function mountEditor(root, { stop.hidden = true; acts.append(measure, stop); } + /* The operator can always be the motor, and on some lenses should be. + * Measure it takes eight fixed steps, which is a guess at how far this + * lens has to travel to leave focus -- on an 85H50AI those eight steps + * moved the reading 3% while the full travel moved it fourfold, so the + * automatic sweep had nothing to measure. A hand covers the whole + * range. The only thing it loses is evenly spaced travel, and a median + * consensus never depended on that. */ + let hand = null, handDone = null; + { + hand = el('button', 're-btn', ''); + hand.dataset.act = 'af-hand'; + hand.textContent = 'Measure by hand'; + handDone = el('button', 're-btn', ''); + handDone.dataset.act = 'af-hand-stop'; + handDone.textContent = 'Done'; + handDone.hidden = true; + acts.append(hand, handDone); + } box.append(acts); const say = (msg, warn) => { @@ -4687,16 +4801,61 @@ export function mountEditor(root, { }); }); + if (hand) { + let ticker = null; + const busy = (on) => { + hand.hidden = on; + handDone.hidden = !on; + send.disabled = on; + back.disabled = on; + if (measure) measure.disabled = on; + }; + const finish = () => { + if (ticker) { clearInterval(ticker); ticker = null; } + busy(false); + }; + hand.addEventListener('click', function () { + if (busyHolding(status)) return; + if (sweepping()) return; + busy(true); + handCancel = finish; + /* Findings from the last sweep describe the lens where it was. */ + focusOdd = null; + handFrames = []; + const tick = () => { + const n = handFrames ? handFrames.length : 0; + say('Turn the lens slowly from one end of its travel to the ' + + 'other, right through focus. Keep going \u2014 ' + n + + ' reading' + (n === 1 ? '' : 's') + ' so far.'); + }; + tick(); + ticker = setInterval(tick, 400); + }); + handDone.addEventListener('click', function () { + const frames = handFrames; + handFrames = null; + handCancel = null; + finish(); + const r = handVerdict(frames); + if (r.failed) { say('Could not measure it: ' + r.failed + '.', true); return; } + focusOdd = r.suspect; + drawFocusMarks(); + sayVerdict(say, r); + }); + } + if (measure) { const busy = (on) => { measure.hidden = on; stop.hidden = !on; send.disabled = on; back.disabled = on; + if (hand) hand.disabled = on; }; stop.addEventListener('click', function () { sweepStop(); }); measure.addEventListener('click', function () { if (busyHolding(status)) return; + if (sweepping()) return; busy(true); runSweep(say, function (i, n) { say('Walking the lens and reading as it goes — ' + i + ' of ' + n + @@ -4722,41 +4881,7 @@ export function mountEditor(root, { * Measured on an 85H50AI: a bank whose own sum moved 2.6x * across the sweep reported 1/1.0, because its peak zone * never left 65535. */ - if (r.pinned) { - say('Cannot say. The sharpest zone was at the top of the ' + - 'camera\u2019s counter for ' + r.pinned + ' of the ' + - r.steps + ' readings, so it had no room to fall and the ' + - 'spread would be an understatement of nothing. Lower the ' + - 'first gain until the grid stops reading at the ceiling, ' + - 'then measure again.' + r.back, true); - return; - } - /* The ratio, not the peak. A filter that reads loudly - * 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 + aside, - !!r.lost || !!r.odd); + sayVerdict(say, r); }).catch(function (e) { busy(false); say('Could not measure it: ' + (e && e.message ? e.message : e), true); @@ -4878,6 +5003,29 @@ export function mountEditor(root, { /* 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; + /* While the operator sweeps the lens by hand, every live reading is kept. + * sweepZones never needed lens POSITIONS -- it works from the order the + * readings arrived in and takes a median consensus -- so a hand on the + * barrel is as good a sweep as a motor, only less evenly spaced, which is + * exactly what a median is robust to. Capped because a sweep the operator + * walked away from would otherwise grow for as long as the page is open. */ + const HAND_MAX = 400; + let handFrames = null; + /* Set by the panel that owns the running hand sweep, so leaving Focus or + * tearing the editor down can end it. Without this the ticker kept firing + * and the live poll kept filling a collection whose Done button had been + * removed from the document. */ + let handCancel = null; + + function handStop() { + handFrames = null; + if (handCancel) { const c = handCancel; handCancel = null; c(); } + } + + /* One lens, one measurement. The two sweeps drive the same camera and the + * hand one depends on the live poll that the motor one stops, so either + * running means neither may start. */ + function sweepping() { return handFrames !== null || sweepBusy !== 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 @@ -5061,7 +5209,7 @@ export function mountEditor(root, { abandonHold(); /* A poll that outlived its tab would keep a camera answering for a * panel nobody is looking at. */ - if (m !== 'focus') { stopFocusPoll(); moveRelease(); sweepStop(); focusStatus = null; } + if (m !== 'focus') { stopFocusPoll(); moveRelease(); sweepStop(); handStop(); focusStatus = null; } /* Anything a filter write has outstanding belonged to the panel that is * going. Its answer must not come back and arm a trial here. */ filterGen++; @@ -5358,6 +5506,7 @@ export function mountEditor(root, { stopFocusPoll(); moveRelease(); sweepStop(); + handStop(); // The walk back still has to happen -- the lens is real -- but // nothing after it may touch a panel that is being removed. sweepClosed = true; diff --git a/tests/ui-check.html b/tests/ui-check.html index 649dc5e..57d3879 100644 --- a/tests/ui-check.html +++ b/tests/ui-check.html @@ -3130,6 +3130,212 @@ } g.ed.destroy(); + // No motor: the operator is the motor. Same measurement, same verdict -- + // the only thing a hand loses is evenly spaced travel, which a median + // consensus does not care about. + { + let pos = 0; + const g2 = await mk({ sweepGrid: () => ({ rows: 1, cols: 4, zones: [ + // three zones peak early, one peaks late: a smear on the glass + [0, Math.max(40, 4000 - pos * 300), 0, 4, 1000, 0], + [0, Math.max(40, 300 + pos * 300), 0, 4, 1000, 0], + [0, Math.max(40, 4000 - pos * 300), 0, 4, 1000, 0], + [0, Math.max(40, 4000 - pos * 300), 0, 4, 1000, 0]] }) }); + t('a camera with no motor is offered a hand sweep', !!q(g2.host, 'af-hand')); + t('and not the motor one', !q(g2.host, 'af-measure')); + + q(g2.host, 'af-hand').click(); + t('the button gives way to Done', q(g2.host, 'af-hand').hidden && + !q(g2.host, 'af-hand-stop').hidden); + t('and it says what to do with the lens', + /Turn the lens slowly/.test(q(g2.host, 'af-status').textContent), + q(g2.host, 'af-status').textContent.slice(0, 60)); + + // The operator turns the barrel right through focus. + for (pos = 0; pos <= 12; pos++) await pause(60); + q(g2.host, 'af-hand-stop').click(); + await pause(120); + { + const s = q(g2.host, 'af-status').textContent; + t('a hand sweep reports the same falls-to ratio', + /Falls to 1\/\d/.test(s), s.slice(0, 70)); + t('and finds the zone at another distance', + /different distance/.test(s), s.slice(-90)); + t('the button comes back', !q(g2.host, 'af-hand').hidden); + } + g2.ed.destroy(); + } + + // A motor does not take the choice away. Measure it guesses how far this + // lens must travel; a hand knows. + { + const g5 = await mk({ lens: true, settle: 30 }); + t('a motorised camera is offered both', !!q(g5.host, 'af-measure') && !!q(g5.host, 'af-hand'), + `measure ${!!q(g5.host, 'af-measure')}, hand ${!!q(g5.host, 'af-hand')}`); + g5.ed.destroy(); + } + + // ...and what it finds is drawn on the picture, not just described. + { + let pos = 0; + const g6 = await mk({ sweepGrid: () => ({ rows: 1, cols: 4, zones: [ + [0, Math.max(40, 4000 - pos * 300), 0, 4, 1000, 0], + [0, Math.max(40, 300 + pos * 300), 0, 4, 1000, 0], + [0, Math.max(40, 4000 - pos * 300), 0, 4, 1000, 0], + [0, Math.max(40, 4000 - pos * 300), 0, 4, 1000, 0]] }) }); + const bytes = new Uint8Array(await (await fetch('./fixture.dng')).arrayBuffer()); + await g6.ed.open(bytes, 'fixture.dng'); + await pause(300); + [...g6.host.querySelectorAll('.re-seg button')].find((b) => b.textContent.trim() === 'Focus').click(); + await pause(120); + q(g6.host, 'af-hand').click(); + for (pos = 0; pos <= 12; pos++) await pause(60); + q(g6.host, 'af-hand-stop').click(); + await pause(150); + t('a hand sweep rings what it found, on the picture', + g6.host.querySelectorAll('.re-fz-odd').length > 0, + g6.host.querySelectorAll('.re-fz-odd').length + ' ringed'); + g6.ed.destroy(); + } + + // Two sweeps, one lens. The hand sweep lives off the live poll that the + // motor sweep stops, so either running means neither may start. + { + const g7 = await mk({ lens: true, settle: 220 }); + q(g7.host, 'af-hand').click(); + await pause(80); + t('a running hand sweep greys out the motor one', + q(g7.host, 'af-measure').disabled === true); + q(g7.host, 'af-measure').click(); + await pause(200); + t('and clicking it anyway starts nothing', + !q(g7.host, 'af-hand-stop').hidden && g7.seen.moves.length === 0, + `${g7.seen.moves.length} moves`); + q(g7.host, 'af-hand-stop').click(); + await pause(120); + + // The other way round, and past the disabled attribute: a rebuild can + // clear `disabled`, so the handler has to refuse on its own. + q(g7.host, 'af-measure').click(); + await pause(150); + const handBtn = q(g7.host, 'af-hand'); + handBtn.disabled = false; + handBtn.click(); + await pause(150); + t('and a hand sweep cannot start inside a motor one', + q(g7.host, 'af-hand-stop').hidden === true); + q(g7.host, 'af-measure-stop').click(); + for (let i = 0; i < 100 && q(g7.host, 'af-measure').hidden; i++) await pause(100); + await pause(100); + g7.ed.destroy(); + } + + // A hand sweep is a timer and a growing array with no owner once the panel + // is gone. Leaving Focus has to end it, not merely hide its Done button. + { + const g8 = await mk({}); + q(g8.host, 'af-hand').click(); + await pause(200); + // Held directly: leaving Focus detaches the panel, so querying the live + // DOM afterwards finds nothing either way and proves nothing. + const oldDone = q(g8.host, 'af-hand-stop'); + const oldHand = q(g8.host, 'af-hand'); + t('the sweep is running', oldDone.hidden === false && oldHand.hidden === true); + [...g8.host.querySelectorAll('.re-seg button')] + .find((b) => b.textContent.trim() !== 'Focus').click(); + await pause(300); + t('leaving Focus ends the hand sweep, not just its panel', + oldDone.hidden === true && oldHand.hidden === false, + `done hidden ${oldDone.hidden}, hand hidden ${oldHand.hidden}`); + g8.ed.destroy(); + } + + // A grid that changed shape part way makes the peaks incomparable, and a + // ratio taken across it is the same wrong answer as the zone findings. + { + let wide = false; + const g9 = await mk({}); + g9.seen.setGrid({ rows: 1, cols: 4, zones: [ + [0, 900, 0, 9, 1000, 0], [0, 300, 0, 9, 1000, 0], + [0, 600, 0, 9, 1000, 0], [0, 120, 0, 9, 1000, 0]] }); + q(g9.host, 'af-hand').click(); + for (let i = 0; i < 6; i++) await pause(60); + g9.seen.setGrid({ rows: 2, cols: 2, zones: [ + [0, 90, 0, 9, 1000, 0], [0, 4000, 0, 9, 1000, 0], + [0, 60, 0, 9, 1000, 0], [0, 120, 0, 9, 1000, 0]] }); + for (let i = 0; i < 8; i++) await pause(60); + q(g9.host, 'af-hand-stop').click(); + await pause(120); + const s = q(g9.host, 'af-status').textContent; + t('a grid that changed shape is refused, not rated', + /changed its zone grid/.test(s) && !/Falls to/.test(s), s.slice(0, 90)); + g9.ed.destroy(); + } + + // A hand is free to turn back, and then reading order is no longer lens + // order -- the same position is visited at several indices, so two zones + // peaking at different indices may be at the same distance after all. The + // ratio survives that; the distance claim does not. + { + let step = 0; + const g10 = await mk({ sweepGrid: () => { + // forward through focus, back out, forward again + const path = [0,1,2,3,4,5,6,5,4,3,2,1,0,1,2,3,4,5,6]; + const at = path[Math.min(step, path.length - 1)]; + return { rows: 1, cols: 4, zones: [ + [0, Math.max(40, 300 + at * 600), 0, 4, 1000, 0], + [0, Math.max(40, 3600 - at * 560), 0, 4, 1000, 0], + [0, Math.max(40, 300 + at * 600), 0, 4, 1000, 0], + [0, Math.max(40, 300 + at * 600), 0, 4, 1000, 0]] }; + } }); + q(g10.host, 'af-hand').click(); + for (step = 0; step < 19; step++) await pause(60); + q(g10.host, 'af-hand-stop').click(); + await pause(150); + const s = q(g10.host, 'af-status').textContent; + t('a lens turned back and forth still gets its ratio', + /Falls to 1\/\d/.test(s), s.slice(0, 60)); + // The explanation SAYS "at a different distance" while declining to + // report any, so the negative has to test the claim, not the phrase. + t('but not a claim about distance it cannot support', + /turned back and forth/.test(s) && !/ringed on the picture/.test(s), + s.slice(-110)); + t('and nothing is ringed on the picture', + g10.host.querySelectorAll('.re-fz-odd').length === 0); + g10.ed.destroy(); + } + + // A lens nobody turned must not be reported as a measurement: the ratio + // would read "this filter cannot see focus" when nothing moved at all. + { + const g3 = await mk({ sweepGrid: () => ({ rows: 1, cols: 4, zones: [ + [0, 900, 0, 9, 1000, 0], [0, 880, 0, 9, 1000, 0], + [0, 910, 0, 9, 1000, 0], [0, 895, 0, 9, 1000, 0]] }) }); + q(g3.host, 'af-hand').click(); + for (let i = 0; i < 14; i++) await pause(60); + q(g3.host, 'af-hand-stop').click(); + await pause(120); + const s = q(g3.host, 'af-status').textContent; + t('a lens that never moved is said so, not scored', + /does not look like it moved|barely changed/.test(s) && !/Falls to/.test(s), + s.slice(0, 90)); + g3.ed.destroy(); + } + + // Stopped too early there is no curve to measure, and three readings of a + // nudged barrel is not a measurement of anything. + { + const g4 = await mk({}); + q(g4.host, 'af-hand').click(); + await pause(120); + q(g4.host, 'af-hand-stop').click(); + await pause(100); + const s = q(g4.host, 'af-status').textContent; + t('too few readings is refused, not averaged', + /readings/.test(s) && !/Falls to/.test(s), s.slice(0, 90)); + g4.ed.destroy(); + } + // Near and Far drive the same lens the sweep is driving. g = await mk({ lens: true, settle: 220 }); q(g.host, 'af-measure').click(); diff --git a/tools/smoke.mjs b/tools/smoke.mjs index eb157e9..c47e026 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -1385,6 +1385,41 @@ console.log('\nfocus statistics: the grid a person focuses a lens by'); 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); + // How far out is "far" is measured against how tightly the scene agrees, + // not against the number of readings. A hand that slows down through + // focus piles most readings there and spreads the normal zones out in + // index; a threshold set as a fixed fraction of the count then either + // goes blind or starts flagging the scene itself. + { + // Seven zones that all peak within a frame or two of each other, + // plus one far away -- across MANY readings, so a fixed 30%-of-count + // threshold (here ~11 frames) would miss an outlier 6 frames out. + const n = 38; + const tight = (pk) => Array.from({ length: n }, (_, p) => 1000 - Math.abs(p - pk) * 60); + const zs = [18, 19, 18, 20, 18, 19, 18, 25].map(tight); + const fr = Array.from({ length: n }, (_, p) => ({ + fv: zs.map((c) => Math.max(10, c[p])), + state: zs.map(() => 'measured'), + sat: zs.map(() => false), rows: 1, cols: 8, + })); + const rr = A.sweepZones(fr); + check('a tight scene makes a modest outlier visible', rr.suspect, [7]); + } + + // ...but a scene that genuinely disagrees is not turned into outliers. + { + const n = 38; + const spread = [6, 12, 18, 24, 30, 14, 22, 16].map( + (pk) => Array.from({ length: n }, (_, p) => 1000 - Math.abs(p - pk) * 60)); + const fr = Array.from({ length: n }, (_, p) => ({ + fv: spread.map((c) => Math.max(10, c[p])), + state: spread.map(() => 'measured'), + sat: spread.map(() => false), rows: 1, cols: 8, + })); + assert('a scene spread over many distances is not all outliers', + A.sweepZones(fr).suspect.length <= 2); + } + // 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)]; @@ -1446,6 +1481,23 @@ console.log('\nfocus statistics: the grid a person focuses a lens by'); check('a finding carries its own shape', [r.rows, r.cols], [1, 4]); } + // Everything sweepZones says about DISTANCE rests on reading order being + // lens order. A motor guarantees that; a hand can turn back, and then the + // same position is visited at several indices. + { + const rise = (n, pk) => Array.from({ length: n }, (_, i) => 1000 - Math.abs(i - pk) * 90); + assert('one pass through focus is a sweep', A.sweptOneWay(rise(20, 10))); + // Forward, back, forward: the reading climbs, falls, and climbs again. + const there = rise(14, 7), andBack = rise(14, 7).slice().reverse(); + assert('there and back again is not', !A.sweptOneWay(there.concat(andBack, there))); + // Noise around the trough must not read as the lens turning round. + const noisy = rise(20, 10).map((v, i) => v + (i % 2 ? 12 : -12)); + assert('a wobble on the way is still one sweep', A.sweptOneWay(noisy)); + // Too short to have a shape, and a flat line, say nothing rather than yes. + assert('three readings are not a sweep', !A.sweptOneWay([100, 200, 150])); + assert('a flat reading is not a sweep', !A.sweptOneWay([500, 500, 500, 500, 500])); + } + // 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;