Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions dist/aftune.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]),
};
}

Expand Down
41 changes: 39 additions & 2 deletions dist/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'), {
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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 };
}


Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions src/aftune.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]),
};
}

Expand Down
41 changes: 39 additions & 2 deletions src/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'), {
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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 };
}


Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions tests/ui-check.html
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
48 changes: 48 additions & 0 deletions tools/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading