From 004cb127e05c869fb9f0d1fcc03fb6a766e7b979 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:31:33 +0300 Subject: [PATCH 1/2] focus: a reading that cannot rise is not a measurement Found by running the sweep against a real lens. On an 85H50AI the bank being measured moved 2.6x across a defocus sweep on its own sum -- and the sweep reported "falls to 1/1.0", because its peak zone sat on 65535 at both ends. 34 of 255 zones were pinned there. The per-zone accumulators are u16, and a zone at the top of that range has stopped measuring: the filter's real response went higher and the counter could not say so. A pinned value cannot fall, so a sweep across one reports that nothing changed -- indistinguishable from a filter that genuinely cannot see focus, and needing the opposite fix. Cut the gain, or change the filter. They must not produce the same number. summarise() now returns `saturated` and `peakSaturated`. Reported, not subtracted: dropping a pinned zone would hand back some lower zone's value as though it were the peak. Only h2 and v2 count, the two the blend is made of -- h1 and v1 belong to the other bank and never reach the reported value. This is NOT `clipped`, which is about the picture: pixels over the high-luma threshold, a specular highlight in an otherwise fine zone. A saturated zone can be perfectly exposed. What is full is the counter, and the cause is the filter's gain, not the scene. The live grid names the count and whether the sharpest zone is among them. The sweep counts pinned readings at every position and, given any, refuses to state a ratio at all -- it says which knob fixes it instead. A filter that is merely flat still gets its honest 1/1.0; being pinned is the only thing that buys an excuse. Ten checks, every guard mutation-tested: breaking it turns exactly the checks written for it red. --- dist/aftune.js | 35 +++++++++++++++++++++++++++++++++++ dist/editor.js | 41 +++++++++++++++++++++++++++++++++++++++-- src/aftune.js | 35 +++++++++++++++++++++++++++++++++++ src/editor.js | 41 +++++++++++++++++++++++++++++++++++++++-- tests/ui-check.html | 38 ++++++++++++++++++++++++++++++++++++++ tools/smoke.mjs | 34 ++++++++++++++++++++++++++++++++++ 6 files changed, 220 insertions(+), 4 deletions(-) diff --git a/dist/aftune.js b/dist/aftune.js index 6efffa1..2c798cc 100644 --- a/dist/aftune.js +++ b/dist/aftune.js @@ -46,6 +46,31 @@ export function blend(z) { * interesting case of the two, because a specular highlight in an otherwise * fine zone will pin the filters without dimming the luma. */ +/* + * The per-zone accumulators are u16, and a zone that reaches the top of that + * range has stopped measuring: the filter's real response went higher and the + * counter could not say so. + * + * This is NOT the same as `clipped`, which is about the picture -- pixels over + * the high-luma threshold, a specular highlight in an otherwise fine zone. A + * saturated zone can be perfectly exposed. What is full is the counter, and + * the cause is the filter's gain, not the scene. + * + * It matters most to anything comparing one reading with another, because a + * pinned value cannot fall: a defocus sweep across a saturated peak reports + * that nothing changed, which is indistinguishable from a filter that cannot + * see focus at all. The two need different answers -- cut the gain, or change + * the filter -- so they must not produce the same number. + */ +export const ZONE_CEILING = 65535; + +export function zoneSaturated(z, ceiling = ZONE_CEILING) { + /* The two the blend is made of, and only those. h1 and v1 belong to the + * other bank and can sit at the ceiling all day without touching the value + * this page reports. */ + return z.h2 >= ceiling || z.v2 >= ceiling; +} + export function zoneState(z, { yFloor = 0, hlCeil = 0 } = {}) { if (z.hlcnt > hlCeil) return 'clipped'; if (z.y <= yFloor) return 'unlit'; @@ -103,12 +128,22 @@ export function summarise(zones, rows, cols, opts = {}) { if (fv[i] > peak) { peak = fv[i]; peakAt = i; } } const measured = state.filter((s) => s === 'measured').length; + const ceiling = opts.ceiling !== undefined ? opts.ceiling : ZONE_CEILING; + const sat = zones.map((z) => zoneSaturated(z, ceiling)); + /* Saturation is reported, not subtracted. Dropping a pinned zone from the + * peak would handpoint the reading at some lower zone and carry on as if + * the number meant something; the honest answer is that the peak is real + * but cannot rise, and the caller is told so. */ return { rows, cols, fv, state, measured, peak: peakAt < 0 ? null : peak, peakAt: peakAt < 0 ? null : { row: (peakAt / cols) | 0, col: peakAt % cols, index: peakAt }, unlit: state.filter((s) => s === 'unlit').length, clipped: state.filter((s) => s === 'clipped').length, + saturated: sat.filter(Boolean).length, + /* The one that decides whether a comparison is worth anything: the + * grid's own peak sitting at the ceiling is what makes a sweep flat. */ + peakSaturated: peakAt >= 0 && sat[peakAt], }; } diff --git a/dist/editor.js b/dist/editor.js index 8ab71eb..4a6b410 100644 --- a/dist/editor.js +++ b/dist/editor.js @@ -3111,6 +3111,22 @@ export function mountEditor(root, { (s.unlit ? `, ${s.unlit} too dark` : '') + (s.clipped ? `, ${s.clipped} blown out` : '') + '.'; focusStatus.append(line); + /* A pinned counter is the one fault here that reads as a good result: + * the number is large and steady, which looks like a sharp, stable + * picture. It is neither -- it is a value that has stopped being able + * to move, and every comparison made against it is worthless. */ + if (s.saturated) { + const box = el('div', 're-notice re-warn', ICON.warn); + box.append(Object.assign(el('div'), { + textContent: `${s.saturated} zone${s.saturated === 1 ? ' is' : 's are'} ` + + 'reading at the top of the camera\u2019s counter' + + (s.peakSaturated ? ', the sharpest among them' : '') + + '. The filter\u2019s gain is higher than the counter can hold, so ' + + 'those readings cannot rise and a comparison between two of them ' + + 'means nothing. Lower the first gain until this clears.', + })); + focusStatus.append(box); + } if (!s.measured) { const box = el('div', 're-notice re-warn', ICON.warn); box.append(Object.assign(el('div'), { @@ -3373,7 +3389,7 @@ export function mountEditor(root, { const gen = ++sweepGen; const mine = () => gen === sweepGen; const vals = []; - let out = 0, failed = null, lost = 0; + let out = 0, failed = null, lost = 0, pinned = 0; /* The poll is stopped for the duration: it and the sweep would be * asking the same camera for the same grid at once, and its answers * would land in the panel out of step with where the lens actually is. @@ -3386,6 +3402,10 @@ export function mountEditor(root, { try { const s = await readGrid(); if (s.peak !== null) vals.push(s.peak); + /* Counted across the whole sweep, not just at the ends: one + * pinned reading anywhere along it is enough to make the + * spread an understatement. */ + if (s.peakSaturated) pinned++; } catch (e) { failed = e && e.message ? e.message : String(e); break; @@ -3437,7 +3457,7 @@ export function mountEditor(root, { if (!vals.length) return { failed: 'nothing measurable along the sweep.' + back, lost: lost }; const hi = Math.max.apply(null, vals), lo = Math.min.apply(null, vals); return { hi: hi, lo: lo, ratio: lo > 0 ? hi / lo : null, n: vals.length, - lost: lost, back: back }; + lost: lost, back: back, pinned: pinned, steps: vals.length }; } @@ -3704,6 +3724,23 @@ export function mountEditor(root, { return; } if (r.failed) { say('Could not measure it: ' + r.failed, true); return; } + /* A sweep taken across a pinned peak gets no ratio at all. + * Reporting one would be the same mistake in a new place: + * a saturated reading cannot fall, so the spread comes out + * near 1 and reads as "this filter cannot see focus" when + * the filter may be fine and the counter simply full. + * 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 diff --git a/src/aftune.js b/src/aftune.js index 6efffa1..2c798cc 100644 --- a/src/aftune.js +++ b/src/aftune.js @@ -46,6 +46,31 @@ export function blend(z) { * interesting case of the two, because a specular highlight in an otherwise * fine zone will pin the filters without dimming the luma. */ +/* + * The per-zone accumulators are u16, and a zone that reaches the top of that + * range has stopped measuring: the filter's real response went higher and the + * counter could not say so. + * + * This is NOT the same as `clipped`, which is about the picture -- pixels over + * the high-luma threshold, a specular highlight in an otherwise fine zone. A + * saturated zone can be perfectly exposed. What is full is the counter, and + * the cause is the filter's gain, not the scene. + * + * It matters most to anything comparing one reading with another, because a + * pinned value cannot fall: a defocus sweep across a saturated peak reports + * that nothing changed, which is indistinguishable from a filter that cannot + * see focus at all. The two need different answers -- cut the gain, or change + * the filter -- so they must not produce the same number. + */ +export const ZONE_CEILING = 65535; + +export function zoneSaturated(z, ceiling = ZONE_CEILING) { + /* The two the blend is made of, and only those. h1 and v1 belong to the + * other bank and can sit at the ceiling all day without touching the value + * this page reports. */ + return z.h2 >= ceiling || z.v2 >= ceiling; +} + export function zoneState(z, { yFloor = 0, hlCeil = 0 } = {}) { if (z.hlcnt > hlCeil) return 'clipped'; if (z.y <= yFloor) return 'unlit'; @@ -103,12 +128,22 @@ export function summarise(zones, rows, cols, opts = {}) { if (fv[i] > peak) { peak = fv[i]; peakAt = i; } } const measured = state.filter((s) => s === 'measured').length; + const ceiling = opts.ceiling !== undefined ? opts.ceiling : ZONE_CEILING; + const sat = zones.map((z) => zoneSaturated(z, ceiling)); + /* Saturation is reported, not subtracted. Dropping a pinned zone from the + * peak would handpoint the reading at some lower zone and carry on as if + * the number meant something; the honest answer is that the peak is real + * but cannot rise, and the caller is told so. */ return { rows, cols, fv, state, measured, peak: peakAt < 0 ? null : peak, peakAt: peakAt < 0 ? null : { row: (peakAt / cols) | 0, col: peakAt % cols, index: peakAt }, unlit: state.filter((s) => s === 'unlit').length, clipped: state.filter((s) => s === 'clipped').length, + saturated: sat.filter(Boolean).length, + /* The one that decides whether a comparison is worth anything: the + * grid's own peak sitting at the ceiling is what makes a sweep flat. */ + peakSaturated: peakAt >= 0 && sat[peakAt], }; } diff --git a/src/editor.js b/src/editor.js index 8ab71eb..4a6b410 100644 --- a/src/editor.js +++ b/src/editor.js @@ -3111,6 +3111,22 @@ export function mountEditor(root, { (s.unlit ? `, ${s.unlit} too dark` : '') + (s.clipped ? `, ${s.clipped} blown out` : '') + '.'; focusStatus.append(line); + /* A pinned counter is the one fault here that reads as a good result: + * the number is large and steady, which looks like a sharp, stable + * picture. It is neither -- it is a value that has stopped being able + * to move, and every comparison made against it is worthless. */ + if (s.saturated) { + const box = el('div', 're-notice re-warn', ICON.warn); + box.append(Object.assign(el('div'), { + textContent: `${s.saturated} zone${s.saturated === 1 ? ' is' : 's are'} ` + + 'reading at the top of the camera\u2019s counter' + + (s.peakSaturated ? ', the sharpest among them' : '') + + '. The filter\u2019s gain is higher than the counter can hold, so ' + + 'those readings cannot rise and a comparison between two of them ' + + 'means nothing. Lower the first gain until this clears.', + })); + focusStatus.append(box); + } if (!s.measured) { const box = el('div', 're-notice re-warn', ICON.warn); box.append(Object.assign(el('div'), { @@ -3373,7 +3389,7 @@ export function mountEditor(root, { const gen = ++sweepGen; const mine = () => gen === sweepGen; const vals = []; - let out = 0, failed = null, lost = 0; + let out = 0, failed = null, lost = 0, pinned = 0; /* The poll is stopped for the duration: it and the sweep would be * asking the same camera for the same grid at once, and its answers * would land in the panel out of step with where the lens actually is. @@ -3386,6 +3402,10 @@ export function mountEditor(root, { try { const s = await readGrid(); if (s.peak !== null) vals.push(s.peak); + /* Counted across the whole sweep, not just at the ends: one + * pinned reading anywhere along it is enough to make the + * spread an understatement. */ + if (s.peakSaturated) pinned++; } catch (e) { failed = e && e.message ? e.message : String(e); break; @@ -3437,7 +3457,7 @@ export function mountEditor(root, { if (!vals.length) return { failed: 'nothing measurable along the sweep.' + back, lost: lost }; const hi = Math.max.apply(null, vals), lo = Math.min.apply(null, vals); return { hi: hi, lo: lo, ratio: lo > 0 ? hi / lo : null, n: vals.length, - lost: lost, back: back }; + lost: lost, back: back, pinned: pinned, steps: vals.length }; } @@ -3704,6 +3724,23 @@ export function mountEditor(root, { return; } if (r.failed) { say('Could not measure it: ' + r.failed, true); return; } + /* A sweep taken across a pinned peak gets no ratio at all. + * Reporting one would be the same mistake in a new place: + * a saturated reading cannot fall, so the spread comes out + * near 1 and reads as "this filter cannot see focus" when + * the filter may be fine and the counter simply full. + * 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 diff --git a/tests/ui-check.html b/tests/ui-check.html index d8c90d9..c122178 100644 --- a/tests/ui-check.html +++ b/tests/ui-check.html @@ -2452,6 +2452,44 @@ t('and the lens ends where it began', g.seen.pos === 0, String(g.seen.pos)); g.ed.destroy(); + // A pinned counter, which is the failure that looks like a result. The + // zone values here are the shape measured on an 85H50AI: a peak stuck on + // 65535 whatever the lens does, while the rest of the grid moves. + g = await mk({ lens: true, sweepGrid: (pos) => ({ rows: 1, cols: 2, zones: [ + [0, 65535, 0, 9, 1000, 0], [0, Math.max(10, 900 - pos * 110), 0, 1, 1000, 0]] }) }); + q(g.host, 'af-measure').click(); + for (let i = 0; i < 200 && q(g.host, 'af-measure').hidden; i++) await pause(100); + { + const s = q(g.host, 'af-status').textContent; + t('a sweep over a pinned peak refuses to give a ratio', + /Cannot say/.test(s) && !/Falls to/.test(s), s.slice(0, 80)); + t('and says which knob fixes it', /first gain/.test(s), s.slice(0, 120)); + } + g.ed.destroy(); + + // ... but only then. A filter that genuinely cannot see focus must still + // get its honest 1/1.0 rather than being excused as saturation. + g = await mk({ lens: true, sweepGrid: () => ({ rows: 1, cols: 2, zones: [ + [0, 900, 0, 9, 1000, 0], [0, 100, 0, 1, 1000, 0]] }) }); + q(g.host, 'af-measure').click(); + for (let i = 0; i < 200 && q(g.host, 'af-measure').hidden; i++) await pause(100); + { + const s = q(g.host, 'af-status').textContent; + t('a flat sweep that is not pinned still reports its spread', + /Falls to/.test(s) && !/Cannot say/.test(s), s.slice(0, 80)); + } + g.ed.destroy(); + + // The live grid, not just the sweep: a pinned reading is large and steady, + // which is exactly what a good one looks like. + g = await mk({ grid: { rows: 1, cols: 2, + zones: [[0, 65535, 0, 90, 1000, 0], [0, 100, 0, 10, 1000, 0]] } }); + await pause(150); + t('the live grid says when the counter is full', + /top of the camera/.test(g.host.textContent), ''); + t('and that it is the sharpest zone', /the sharpest among them/.test(g.host.textContent), ''); + g.ed.destroy(); + // Near and Far drive the same lens the sweep is driving. g = await mk({ lens: true }); q(g.host, 'af-measure').click(); diff --git a/tools/smoke.mjs b/tools/smoke.mjs index 86f3249..dce2e52 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -1135,6 +1135,40 @@ console.log('\nfocus statistics: the grid a person focuses a lens by'); assert(`a zone carrying ${JSON.stringify(bad)} is refused`, refused); } + // A pinned counter is the fault here that looks like a good result: large, + // steady, and unable to move. Measured on an 85H50AI, a bank whose own sum + // moved 2.6x across a defocus sweep reported a spread of 1/1.0, because its + // peak zone sat on 65535 at both ends. + { + const at = A.summarise(grid(1, 2, (i) => zone({ h2: i ? 100 : A.ZONE_CEILING })), 1, 2); + check('a zone at the top of the counter is counted', at.saturated, 1); + assert('and the peak sitting there is called out', at.peakSaturated); + + // Reported, not subtracted. Dropping the pinned zone would hand back + // some lower zone's value as though it were the peak. + check('the pinned zone is still the peak', at.peak, A.blend(zone({ h2: A.ZONE_CEILING }))); + + const below = A.summarise(grid(1, 2, (i) => zone({ h2: i ? 100 : A.ZONE_CEILING - 1 })), 1, 2); + check('one short of the ceiling is not saturated', below.saturated, 0); + assert('nor is its peak', !below.peakSaturated); + + // v2 shares the blend with h2, so it pins the value just as hard. + const vert = A.summarise(grid(1, 1, () => zone({ v2: A.ZONE_CEILING })), 1, 1); + check('v2 at the ceiling counts too', vert.saturated, 1); + + // h1 and v1 belong to the other bank and never reach the reported value. + const other = A.summarise(grid(1, 1, () => zone({ h1: A.ZONE_CEILING, v1: A.ZONE_CEILING })), 1, 1); + check('the other bank at its ceiling does not', other.saturated, 0); + + // A saturated zone that is too dark to believe is not the peak, so the + // sweep has nothing to distrust. + const dark = A.summarise(grid(1, 2, (i) => (i + ? zone({ h2: 100 }) + : zone({ y: 0, h2: A.ZONE_CEILING }))), 1, 2); + assert('a pinned zone that was never measured does not condemn the peak', + !dark.peakSaturated); + } + // A grid whose length disagrees with its shape would still draw -- shifted, // every zone in the wrong place. Refused rather than rendered. let threw = false; From 867fa3f92be96a715c88d267d65326db26a4ab0b Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:37:01 +0300 Subject: [PATCH 2/2] focus: a pinned zone tied at the peak condemns it too Review caught a real hole. Peak selection keeps the first strict maximum, so a saturated zone can tie with an unsaturated one and lose: {h2: 65534, v2: 5} and {h2: 65535, v2: 0} both blend to 55295. Reading the flag off the winning index alone let grid ORDER decide whether the reading was trustworthy -- the pinned zone was equally the peak, and the sweep went on to print a ratio over it. If anything at the top of the grid cannot rise, the peak cannot rise. --- dist/aftune.js | 13 +++++++++++-- src/aftune.js | 13 +++++++++++-- tools/smoke.mjs | 14 ++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/dist/aftune.js b/dist/aftune.js index 2c798cc..d7cddbb 100644 --- a/dist/aftune.js +++ b/dist/aftune.js @@ -142,8 +142,17 @@ export function summarise(zones, rows, cols, opts = {}) { clipped: state.filter((s) => s === 'clipped').length, saturated: sat.filter(Boolean).length, /* The one that decides whether a comparison is worth anything: the - * grid's own peak sitting at the ceiling is what makes a sweep flat. */ - peakSaturated: peakAt >= 0 && sat[peakAt], + * grid's own peak sitting at the ceiling is what makes a sweep flat. + * + * Any zone TIED at the peak, not just the one that happened to win it. + * Peak selection keeps the first strict maximum, and a pinned zone can + * tie with an unpinned one at the same blended value -- {h2: 65534, + * v2: 5} and {h2: 65535, v2: 0} both come to 55295 -- so reading the + * flag off the winning index alone lets grid order decide whether the + * reading is trustworthy. If anything at the top of the grid cannot + * rise, the peak cannot rise. */ + peakSaturated: peakAt >= 0 && + fv.some((v, i) => v === peak && state[i] === 'measured' && sat[i]), }; } diff --git a/src/aftune.js b/src/aftune.js index 2c798cc..d7cddbb 100644 --- a/src/aftune.js +++ b/src/aftune.js @@ -142,8 +142,17 @@ export function summarise(zones, rows, cols, opts = {}) { clipped: state.filter((s) => s === 'clipped').length, saturated: sat.filter(Boolean).length, /* The one that decides whether a comparison is worth anything: the - * grid's own peak sitting at the ceiling is what makes a sweep flat. */ - peakSaturated: peakAt >= 0 && sat[peakAt], + * grid's own peak sitting at the ceiling is what makes a sweep flat. + * + * Any zone TIED at the peak, not just the one that happened to win it. + * Peak selection keeps the first strict maximum, and a pinned zone can + * tie with an unpinned one at the same blended value -- {h2: 65534, + * v2: 5} and {h2: 65535, v2: 0} both come to 55295 -- so reading the + * flag off the winning index alone lets grid order decide whether the + * reading is trustworthy. If anything at the top of the grid cannot + * rise, the peak cannot rise. */ + peakSaturated: peakAt >= 0 && + fv.some((v, i) => v === peak && state[i] === 'measured' && sat[i]), }; } diff --git a/tools/smoke.mjs b/tools/smoke.mjs index dce2e52..4930723 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -1160,6 +1160,20 @@ console.log('\nfocus statistics: the grid a person focuses a lens by'); const other = A.summarise(grid(1, 1, () => zone({ h1: A.ZONE_CEILING, v1: A.ZONE_CEILING })), 1, 1); check('the other bank at its ceiling does not', other.saturated, 0); + // Peak selection keeps the first strict maximum, so a pinned zone can + // tie with an unpinned one and lose -- these two both blend to 55295. + // Reading the flag off the winning index alone would let grid order + // decide whether the sweep is trustworthy. + { + const lo = zone({ h2: A.ZONE_CEILING - 1, v2: 5 }); + const hi = zone({ h2: A.ZONE_CEILING, v2: 0 }); + check('the tie is a real one', A.blend(lo), A.blend(hi)); + const tied = A.summarise([lo, hi], 1, 2); + assert('a pinned zone tied at the peak still condemns it', tied.peakSaturated); + // ...and in the order where it wins outright, which is the easy case. + assert('whichever way round they sit', A.summarise([hi, lo], 1, 2).peakSaturated); + } + // A saturated zone that is too dark to believe is not the peak, so the // sweep has nothing to distrust. const dark = A.summarise(grid(1, 2, (i) => (i