From ad312cffc01c6a000f6c202372874c7cdb6a40cf Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:04:19 +0300 Subject: [PATCH 1/3] focus: the operator can be the motor A camera focused by hand could read its zones and tune its filter, but not measure how sharply that filter peaks -- the sweep needed something to drive the lens. It never did. sweepZones works from the ORDER the readings arrived in and takes a median consensus; it has no idea a motor exists. A hand on the barrel is as good a sweep, only less evenly spaced, which is exactly what a median is robust to. So "Measure by hand" starts collecting, the operator turns the lens right through focus, and Done gives the same verdict the motorised sweep gives: the falls-to ratio, and the zones focusing at a different distance from the rest of the frame, ringed on the picture. That second one is the finding that matters in the field -- dirt on the dome drags autofocus onto the glass and is exactly as invisible on a hand-focused camera as a motorised one. Nothing is fetched twice. focusTick was already reading the grid for the live display and throwing all but the latest away; the sweep just keeps them, so the operator watches the numbers move while they turn. Offered even where there IS a motor, which is not the house rule but is what the measurements say. Measure it takes eight fixed steps -- a guess at how far this lens must travel to leave focus -- and on the 85H50AI those eight steps moved the reading 3% while the full travel moved it fourfold. The automatic sweep had nothing to measure. A hand covers the whole range. One verdict for both paths: sayVerdict() is shared, so a hand sweep and a motor sweep cannot word the same measurement differently, and the pinned counter refusal applies to both. Three guards, because a hand is less trustworthy than a motor: under eight readings is refused rather than averaged; a reading that barely changed is reported as a lens that did not move rather than as a filter that cannot see focus; and the collection is capped so a sweep someone walked away from does not grow for as long as the page is open. --- dist/editor.js | 175 +++++++++++++++++++++++++++++++++++--------- src/editor.js | 175 +++++++++++++++++++++++++++++++++++--------- tests/ui-check.html | 99 +++++++++++++++++++++++++ 3 files changed, 379 insertions(+), 70 deletions(-) diff --git a/dist/editor.js b/dist/editor.js index fdd55a1..d8fc038 100644 --- a/dist/editor.js +++ b/dist/editor.js @@ -4018,6 +4018,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 +4360,78 @@ 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' }; + const pinned = frames.filter((f) => f.pinned).length; + let odd = null; + 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: '', + 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; @@ -4571,6 +4648,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,6 +4782,42 @@ export function mountEditor(root, { }); }); + if (hand) { + let ticker = null; + const busy = (on) => { + hand.hidden = on; + handDone.hidden = !on; + send.disabled = on; + back.disabled = on; + }; + hand.addEventListener('click', function () { + if (busyHolding(status)) return; + busy(true); + /* 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 () { + if (ticker) { clearInterval(ticker); ticker = null; } + const frames = handFrames; + handFrames = null; + busy(false); + 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; @@ -4722,41 +4853,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 +4975,14 @@ 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; /* 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 diff --git a/src/editor.js b/src/editor.js index fdd55a1..d8fc038 100644 --- a/src/editor.js +++ b/src/editor.js @@ -4018,6 +4018,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 +4360,78 @@ 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' }; + const pinned = frames.filter((f) => f.pinned).length; + let odd = null; + 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: '', + 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; @@ -4571,6 +4648,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,6 +4782,42 @@ export function mountEditor(root, { }); }); + if (hand) { + let ticker = null; + const busy = (on) => { + hand.hidden = on; + handDone.hidden = !on; + send.disabled = on; + back.disabled = on; + }; + hand.addEventListener('click', function () { + if (busyHolding(status)) return; + busy(true); + /* 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 () { + if (ticker) { clearInterval(ticker); ticker = null; } + const frames = handFrames; + handFrames = null; + busy(false); + 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; @@ -4722,41 +4853,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 +4975,14 @@ 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; /* 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 diff --git a/tests/ui-check.html b/tests/ui-check.html index 649dc5e..6e9a23c 100644 --- a/tests/ui-check.html +++ b/tests/ui-check.html @@ -3130,6 +3130,105 @@ } 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(); + } + + // 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(); From c8db186b1f40dba5d77a877cb468c358aa39cecd Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:09:13 +0300 Subject: [PATCH 2/3] focus: how far out is far, measured against the scene and not the clock Four from review, all real, and the first corrects something I wrote in the commit that introduced this. I said a median consensus is robust to uneven spacing. That is true of the CONSENSUS and false of the THRESHOLD, which was a fixed fraction of the reading count -- and an operator who slows down through focus piles most of the readings there, spreading the normal zones out in index until that fraction is either blind or trigger-happy. It now measures against how tightly the scene itself agrees: three times the median absolute deviation of the zones that responded. Every zone is sampled at the same instants, so their peak ORDER means something however fast the barrel turned, and the MAD is in the same distorted units as the thing being judged, so the distortion cancels. My first attempt kept the old fraction as a floor and the new test caught it immediately: on a 38-reading sweep that floor is 11 frames, which swallows a genuine 6-frame outlier. The more carefully someone measured, the blinder it got. The floor is two readings flat, there only to stop a scene that agrees to the frame from flagging its own noise. The rest: one shared sweepping() so the two sweeps cannot run at once -- the hand one lives off the live poll the motor one stops; handStop() wired into both leaving Focus and destroy, so an abandoned sweep does not keep a ticker and a growing array alive behind a Done button that is no longer in the document; and a grid-shape check that refuses the RATIO too, because peaks taken over different zone sizes are not comparable numbers and reporting one while dropping the zone findings hands back the half that is equally wrong. Two of the five guards came back vacuous on the first mutation pass. The mutual-exclusion one sits behind the disabled attribute, and a disabled button never fires its handler, so the test could not reach the code it was testing; it now re-enables the button first, which is also the real case, a panel rebuild clearing disabled. The teardown one asserted that readings stop, which stopFocusPoll already does on its own; it now holds the old Done button and checks it flipped back, because leaving Focus must END the sweep rather than merely detach its panel. --- dist/aftune.js | 30 +++++++++++++++--- dist/editor.js | 40 ++++++++++++++++++++++-- src/aftune.js | 30 +++++++++++++++--- src/editor.js | 40 ++++++++++++++++++++++-- tests/ui-check.html | 74 +++++++++++++++++++++++++++++++++++++++++++++ tools/smoke.mjs | 35 +++++++++++++++++++++ 6 files changed, 233 insertions(+), 16 deletions(-) diff --git a/dist/aftune.js b/dist/aftune.js index 7f38e77..930133b 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); diff --git a/dist/editor.js b/dist/editor.js index d8fc038..f9df916 100644 --- a/dist/editor.js +++ b/dist/editor.js @@ -4419,6 +4419,14 @@ export function mountEditor(root, { 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; let odd = null; try { odd = sweepZones(frames); } catch (e) { odd = null; } @@ -4445,6 +4453,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; @@ -4789,10 +4798,17 @@ export function mountEditor(root, { 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 = []; @@ -4806,10 +4822,10 @@ export function mountEditor(root, { ticker = setInterval(tick, 400); }); handDone.addEventListener('click', function () { - if (ticker) { clearInterval(ticker); ticker = null; } const frames = handFrames; handFrames = null; - busy(false); + handCancel = null; + finish(); const r = handVerdict(frames); if (r.failed) { say('Could not measure it: ' + r.failed + '.', true); return; } focusOdd = r.suspect; @@ -4824,10 +4840,12 @@ export function mountEditor(root, { 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 + @@ -4983,6 +5001,21 @@ export function mountEditor(root, { * 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 @@ -5166,7 +5199,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++; @@ -5463,6 +5496,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..930133b 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); diff --git a/src/editor.js b/src/editor.js index d8fc038..f9df916 100644 --- a/src/editor.js +++ b/src/editor.js @@ -4419,6 +4419,14 @@ export function mountEditor(root, { 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; let odd = null; try { odd = sweepZones(frames); } catch (e) { odd = null; } @@ -4445,6 +4453,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; @@ -4789,10 +4798,17 @@ export function mountEditor(root, { 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 = []; @@ -4806,10 +4822,10 @@ export function mountEditor(root, { ticker = setInterval(tick, 400); }); handDone.addEventListener('click', function () { - if (ticker) { clearInterval(ticker); ticker = null; } const frames = handFrames; handFrames = null; - busy(false); + handCancel = null; + finish(); const r = handVerdict(frames); if (r.failed) { say('Could not measure it: ' + r.failed + '.', true); return; } focusOdd = r.suspect; @@ -4824,10 +4840,12 @@ export function mountEditor(root, { 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 + @@ -4983,6 +5001,21 @@ export function mountEditor(root, { * 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 @@ -5166,7 +5199,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++; @@ -5463,6 +5496,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 6e9a23c..01773a3 100644 --- a/tests/ui-check.html +++ b/tests/ui-check.html @@ -3198,6 +3198,80 @@ 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 lens nobody turned must not be reported as a measurement: the ratio // would read "this filter cannot see focus" when nothing moved at all. { diff --git a/tools/smoke.mjs b/tools/smoke.mjs index eb157e9..ae931b3 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)]; From e567198fd494eef93a9755e0633542736bfe2ea5 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:25:24 +0300 Subject: [PATCH 3/3] focus: a hand can turn back, and then order is not distance The review kept the ring finding open after the median-absolute-deviation fix, and it was right to. That fix makes the threshold survive UNEVEN spacing. It does nothing about spacing that is not monotonic. Everything sweepZones concludes about distance rests on one assumption: reading order is lens order. A motor guarantees it -- every step goes the same way. 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 stretches the mapping and a median absorbs that; turning back breaks the mapping, and no statistic recovers it. So the sweep is checked for going one way, from the scene's own curve rather than from a position the readings do not carry: swept once through focus the overall reading rises and falls once, and crossing the halfway mark upwards more than once means the lens came back. Half way up is high enough that noise around the trough does not register as a turn and low enough to catch a real second excursion. A wandering sweep keeps its ratio -- highest over lowest is the same whatever order they arrived in -- and loses only the claim it cannot support, with the reason said out loud rather than the rings quietly not appearing. Motor sweeps are unaffected: they are monotonic by construction. --- dist/aftune.js | 33 +++++++++++++++++++++++++++++++++ dist/editor.js | 16 +++++++++++++--- src/aftune.js | 33 +++++++++++++++++++++++++++++++++ src/editor.js | 16 +++++++++++++--- tests/ui-check.html | 33 +++++++++++++++++++++++++++++++++ tools/smoke.mjs | 17 +++++++++++++++++ 6 files changed, 142 insertions(+), 6 deletions(-) diff --git a/dist/aftune.js b/dist/aftune.js index 930133b..39754e5 100644 --- a/dist/aftune.js +++ b/dist/aftune.js @@ -481,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 f9df916..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 = [ @@ -4428,11 +4429,20 @@ export function mountEditor(root, { 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; - try { odd = sweepZones(frames); } catch (e) { 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: '', + 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 diff --git a/src/aftune.js b/src/aftune.js index 930133b..39754e5 100644 --- a/src/aftune.js +++ b/src/aftune.js @@ -481,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 f9df916..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 = [ @@ -4428,11 +4429,20 @@ export function mountEditor(root, { 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; - try { odd = sweepZones(frames); } catch (e) { 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: '', + 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 diff --git a/tests/ui-check.html b/tests/ui-check.html index 01773a3..57d3879 100644 --- a/tests/ui-check.html +++ b/tests/ui-check.html @@ -3272,6 +3272,39 @@ 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. { diff --git a/tools/smoke.mjs b/tools/smoke.mjs index ae931b3..c47e026 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -1481,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;