diff --git a/dist/aftune.js b/dist/aftune.js index 6efffa1..d7cddbb 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,31 @@ 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. + * + * 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/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..d7cddbb 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,31 @@ 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. + * + * 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/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..4930723 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -1135,6 +1135,54 @@ 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); + + // 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 + ? 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;