From 8cd75e9fdc80aeaeb5a8cc6db4709ba50baee3c5 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:40:52 +0300 Subject: [PATCH 1/2] focus: a low number can mean nothing to focus on, and says which MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second round of feedback from the same calibration: "теперь классическая проблема - на 9 квадрате не нашлось резких объектов и ему маленькую цифру дали". He is right, and it is the classic one. A blank wall, a patch of sky, a smooth painted door all report a small focus value wherever the lens is, because these statistics measure detail and there is none there to measure. Shown as a bare small number it reads as "this part of the picture is soft", and the operator goes chasing focus that was never the problem. A zone with something in it RESPONDS when the lens moves; a zone without one does not. That is the whole test, and peakHold already had half of it -- it tracked each zone's best, so it now tracks the low as well and swing becomes a live statistic, accumulated as the barrel turns rather than needing a sweep. Nothing is claimed until the frame AS A WHOLE has moved. "We have not looked yet" and "there is nothing there" are different answers and only one of them is the operator's problem, so before that every block is unknown. An empty block keeps its number -- it is what the camera reported -- but dimmed and captioned, so it stops carrying the weight of a verdict. It can never be named the sharpest either: that points the operator at the one part of the frame which can never answer. Unless nothing has detail at all, when the plain maximum stands rather than naming no block on a frame that may simply not have been swept yet. sweepZones now reports WHY a zone had no opinion -- responded, flat, pinned, unmeasured -- rather than dropping all four into the same null. `flat` is this same finding taken from the one measurement where the lens definitely moved. Two of my own mistakes, both caught by the tests: zoneDetail was handed the holder rather than the push result, so it saw no record at all; and coarsen read a missing entry as KNOWN, which between them captioned all nine blocks "nothing to focus on" on a frame nobody had swept. Only the two real answers count as knowing now. --- dist/aftune.js | 116 ++++++++++++++++++++++++++++++++++++++------ dist/editor.css | 4 ++ dist/editor.js | 26 ++++++++-- src/aftune.js | 116 ++++++++++++++++++++++++++++++++++++++------ src/editor.css | 4 ++ src/editor.js | 26 ++++++++-- tests/ui-check.html | 50 +++++++++++++++++++ tools/smoke.mjs | 50 +++++++++++++++++++ 8 files changed, 354 insertions(+), 38 deletions(-) diff --git a/dist/aftune.js b/dist/aftune.js index 3735e5f..af01358 100644 --- a/dist/aftune.js +++ b/dist/aftune.js @@ -168,10 +168,13 @@ export function summarise(zones, rows, cols, opts = {}) { * maximum from a different picture is worse than no history. */ export function peakHold() { - let best = null, bestOverall = 0; + let best = null, low = null, bestOverall = 0, lowOverall = null; return { push(sum) { - if (!best || best.length !== sum.fv.length) best = sum.fv.map(() => null); + if (!best || best.length !== sum.fv.length) { + best = sum.fv.map(() => null); + low = sum.fv.map(() => null); + } for (let i = 0; i < sum.fv.length; i++) { /* Only a zone that measured may set its own record. A blown * zone reports a huge response -- that is the whole reason the @@ -182,11 +185,18 @@ export function peakHold() { * because 0 is a reading and "no reading" is not. */ if (sum.state[i] !== 'measured') continue; if (best[i] === null || sum.fv[i] > best[i]) best[i] = sum.fv[i]; + /* The other end of the same record. A zone's best alone cannot + * say whether the zone ever RESPONDED to the lens moving, and + * that is the difference between a part of the frame that is + * out of focus and one with nothing in it to focus on. */ + if (low[i] === null || sum.fv[i] < low[i]) low[i] = sum.fv[i]; } if (sum.peak !== null && sum.peak > bestOverall) bestOverall = sum.peak; - return { best: best.slice(), bestOverall }; + if (sum.peak !== null && (lowOverall === null || sum.peak < lowOverall)) + lowOverall = sum.peak; + return { best: best.slice(), bestOverall, low: low.slice(), lowOverall }; }, - reset() { best = null; bestOverall = 0; }, + reset() { best = null; low = null; bestOverall = 0; lowOverall = null; }, }; } @@ -227,9 +237,13 @@ function bounds(len, n) { return out; } -export function coarsen(s, n) { +export function coarsen(s, n, opts = {}) { if (!Number.isSafeInteger(n) || n <= 0) throw new Error(`not a block count: ${n}`); + /* Per-zone 'some' / 'none' / 'unknown', from zoneDetail or a sweep. A + * block is only called empty when every zone in it that could be measured + * agrees -- one textured corner is enough to focus on. */ + const detail = opts.detail || null; /* Asking for more blocks than there are zones would hand back empty cells * reported as "nothing measurable", which is a different claim entirely. */ const rowsN = Math.min(n, s.rows), colsN = Math.min(n, s.cols); @@ -239,10 +253,21 @@ export function coarsen(s, n) { for (let bc = 0; bc < colsN; bc++) { const [r0, r1] = rb[br], [c0, c1] = cb[bc]; let sum = 0, measured = 0, saturated = 0, total = 0; + let withDetail = 0, knownDetail = 0; for (let r = r0; r < r1; r++) { for (let c = c0; c < c1; c++) { const i = r * s.cols + c; total++; + /* Only the two real answers count as knowing. Anything + * else -- absent, short array, undefined -- is "not + * looked at yet", not "nothing there". Read the other way + * round, a detail array that did not line up captioned + * every block on a frame nobody had swept. */ + const d = detail ? detail[i] : undefined; + if (d === 'some' || d === 'none') { + knownDetail++; + if (d === 'some') withDetail++; + } if (s.state[i] !== 'measured') continue; measured++; sum += s.fv[i]; @@ -258,14 +283,30 @@ export function coarsen(s, n) { * real block and reads as "measured, and very soft". */ value: measured ? Math.round(sum / measured) : null, measured, total, saturated, + /* 'none' is a statement about the SCENE -- there is nothing in + * this part of the frame to focus on -- and it is the reason a + * block reads low far more often than bad focus is. 'unknown' + * until the lens has moved enough to tell the two apart. */ + detail: !knownDetail ? 'unknown' : (withDetail ? 'some' : 'none'), }); } } - let best = null; - for (const b of blocks) - if (b.value !== null && (best === null || b.value > blocks[best].value)) - best = blocks.indexOf(b); - return { rows: rowsN, cols: colsN, blocks, best }; + /* An empty block is never the sharpest. It cannot be: it has nothing in it + * that focus could sharpen, and naming it would point the operator at the + * one part of the frame that can never answer. Only if NOTHING has detail + * does the plain maximum stand, because then the alternative is naming no + * block at all on a frame that may simply not have been swept yet. */ + const pick = (ok) => { + let at = null; + for (let i = 0; i < blocks.length; i++) { + const b = blocks[i]; + if (b.value === null || !ok(b)) continue; + if (at === null || b.value > blocks[at].value) at = i; + } + return at; + }; + const best = pick((b) => b.detail !== 'none'); + return { rows: rowsN, cols: colsN, blocks, best: best !== null ? best : pick(() => true) }; } /* @@ -313,6 +354,7 @@ export function sweepZones(frames, opts = {}) { const apart = opts.apart !== undefined ? opts.apart : 0.3; const peakAt = new Array(n).fill(null); + const why = new Array(n).fill('unmeasured'); for (let i = 0; i < n; i++) { let hi = -1, lo = Infinity, at = -1, ever = false, pinned = false; for (let f = 0; f < frames.length; f++) { @@ -331,15 +373,21 @@ export function sweepZones(frames, opts = {}) { * somewhere else on the strength of an artefact. `state` cannot carry * this: summarise keeps saturation separate on purpose, because a * pinned zone is still perfectly well exposed. */ - if (pinned) continue; - /* Never measured, or never moved. No opinion either way. */ - if (!ever || hi <= 0 || (hi - lo) / hi < swing) continue; + if (pinned) { why[i] = 'pinned'; continue; } + if (!ever || hi <= 0) { why[i] = 'unmeasured'; continue; } + /* Never moved: there is nothing in this zone to focus on. Reported as + * its own answer, because a zone with no detail and a zone that is out + * of focus produce the same small number and need opposite responses + * -- point the camera somewhere with edges in it, or turn the lens. */ + if ((hi - lo) / hi < swing) { why[i] = 'flat'; continue; } + why[i] = 'responded'; peakAt[i] = at; } const heard = peakAt.filter((v) => v !== null).sort((a, b) => a - b); if (!heard.length) - return { consensus: null, peakAt, suspect: [], heard: 0, rows, cols }; + return { consensus: null, peakAt, why, suspect: [], heard: 0, rows, cols, + flat: why.filter((w) => w === 'flat').length }; /* Median, not mean. One smeared corner peaking at the far end of the sweep * drags a mean toward itself and then measures everything else against a * consensus it invented. */ @@ -350,5 +398,43 @@ export function sweepZones(frames, opts = {}) { if (peakAt[i] !== null && Math.abs(peakAt[i] - consensus) > far) suspect.push(i); /* The shape travels with the finding. A consumer holding these indices * across a grid change would otherwise place them by the new width. */ - return { consensus, peakAt, suspect, heard: heard.length, far, rows, cols }; + return { consensus, peakAt, why, suspect, heard: heard.length, far, rows, cols, + flat: why.filter((w) => w === 'flat').length }; +} + +/* + * Which parts of the frame have anything in them to focus on. + * + * A blank wall, a patch of sky, a smooth painted door: all of them report a + * small focus value wherever the lens is, because focus statistics measure + * detail and there is none there to measure. Shown as a bare number that + * reads as "this part of the picture is soft", which sends an operator + * chasing focus that was never the problem. The first person to calibrate + * with this hit it immediately: "на 9 квадрате не нашлось резких объектов и + * ему маленькую цифру дали". + * + * A zone with detail in it RESPONDS when the lens moves; a zone without one + * does not. That is the whole test, and it needs the lens to have moved -- + * which is why nothing is claimed until the frame as a whole has shown it + * has. Before then every zone is 'unknown', because "we have not looked yet" + * and "there is nothing there" are different answers and only one of them is + * the operator's problem. + */ +export function zoneDetail(hold, opts = {}) { + /* How much the frame overall has to have moved before this says anything. + * Below it the lens has not been turned far enough to tell an empty zone + * from one that simply has not been swept past focus yet. */ + const moved = opts.moved !== undefined ? opts.moved : 0.2; + /* How much a zone has to move to count as having something in it. */ + const detail = opts.detail !== undefined ? opts.detail : 0.15; + const n = hold && hold.best ? hold.best.length : 0; + const out = new Array(n).fill('unknown'); + if (!n || !hold.bestOverall || hold.lowOverall === null) return out; + if ((hold.bestOverall - hold.lowOverall) / hold.bestOverall < moved) return out; + for (let i = 0; i < n; i++) { + const hi = hold.best[i], lo = hold.low[i]; + if (hi === null || lo === null || hi <= 0) continue; + out[i] = (hi - lo) / hi < detail ? 'none' : 'some'; + } + return out; } diff --git a/dist/editor.css b/dist/editor.css index d6b398f..58db6f4 100644 --- a/dist/editor.css +++ b/dist/editor.css @@ -96,6 +96,10 @@ text-anchor: middle; dominant-baseline: middle; pointer-events: none; } /* A block focusing at a different distance from the rest of the frame. Dashed * rather than solid so it cannot be mistaken for the peak outline. */ +/* A block the lens could not change: the number is what the camera reported + * and stays readable, but it is not a verdict on focus and must not carry the + * weight of one. */ +.re-fz-dim { opacity: .55; } .re-fz-odd { fill: none; stroke: #7fd4ff; stroke-width: 2; stroke-dasharray: 5 3; } .re-chart-grip { position: absolute; width: 18px; height: 18px; margin: -9px 0 0 -9px; border: 2px solid #fff; border-radius: 50%; background: #5c70e8; diff --git a/dist/editor.js b/dist/editor.js index d10559c..fdd55a1 100644 --- a/dist/editor.js +++ b/dist/editor.js @@ -15,7 +15,7 @@ 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 } from './aftune.js'; +import { summarise, peakHold, normalise, coarsen, sweepZones, zoneDetail } from './aftune.js'; const CFA_NAMES = ['RGGB', 'GRBG', 'GBRG', 'BGGR']; const DEMOSAIC = [ @@ -4900,7 +4900,11 @@ export function mountEditor(root, { } function drawCoarse(svg, s, W, H, NS) { - const c = coarsen(s, focusBlocks); + /* focusBest, not focusHold: the holder accumulates, the push result is + * what carries the record out. Handing it the holder got every block + * captioned "nothing to focus on" on a frame nobody had swept. */ + const c = coarsen(s, focusBlocks, + { detail: focusBest ? zoneDetail(focusBest) : null }); const top = c.best === null ? null : c.blocks[c.best].value; for (const b of c.blocks) { const a0 = stageCoords((b.colSpan[0] * W) / s.cols, (b.rowSpan[0] * H) / s.rows); @@ -4931,10 +4935,15 @@ export function mountEditor(root, { }); if (odd) oddRing(svg, NS, a0.x, a0.y, w, h); + /* A block with nothing in it to focus on is not a soft block, and + * a bare small number says the wrong one of those. The number is + * still shown -- it is what the camera reported -- but muted and + * captioned, so it stops reading as a verdict on focus. */ + const empty = b.detail === 'none'; const label = document.createElementNS(NS, 'text'); label.setAttribute('x', a0.x + w / 2); - label.setAttribute('y', a0.y + h / 2); - label.setAttribute('class', 're-fz-num'); + label.setAttribute('y', a0.y + (empty ? h / 2 - Math.min(9, h / 7) : h / 2)); + label.setAttribute('class', empty ? 're-fz-num re-fz-dim' : 're-fz-num'); /* Scaled to the cell and clamped: a 5-block grid on a phone gets * cells too small for 13px, and a 2-block grid on a desktop would * otherwise print a number the size of a caption. */ @@ -4946,6 +4955,15 @@ export function mountEditor(root, { ? '\u2014' : String(b.value).replace(/\B(?=(\d{3})+(?!\d))/g, '\u2009'); svg.append(label); + if (empty) { + const cap = document.createElementNS(NS, 'text'); + cap.setAttribute('x', a0.x + w / 2); + cap.setAttribute('y', a0.y + h / 2 + Math.min(12, h / 5)); + cap.setAttribute('class', 're-fz-tag re-fz-dim'); + cap.textContent = 'nothing to focus on'; + svg.append(cap); + } + if (c.best !== null && b === c.blocks[c.best] && b.value !== null) { const pk = document.createElementNS(NS, 'rect'); pk.setAttribute('x', a0.x); diff --git a/src/aftune.js b/src/aftune.js index 3735e5f..af01358 100644 --- a/src/aftune.js +++ b/src/aftune.js @@ -168,10 +168,13 @@ export function summarise(zones, rows, cols, opts = {}) { * maximum from a different picture is worse than no history. */ export function peakHold() { - let best = null, bestOverall = 0; + let best = null, low = null, bestOverall = 0, lowOverall = null; return { push(sum) { - if (!best || best.length !== sum.fv.length) best = sum.fv.map(() => null); + if (!best || best.length !== sum.fv.length) { + best = sum.fv.map(() => null); + low = sum.fv.map(() => null); + } for (let i = 0; i < sum.fv.length; i++) { /* Only a zone that measured may set its own record. A blown * zone reports a huge response -- that is the whole reason the @@ -182,11 +185,18 @@ export function peakHold() { * because 0 is a reading and "no reading" is not. */ if (sum.state[i] !== 'measured') continue; if (best[i] === null || sum.fv[i] > best[i]) best[i] = sum.fv[i]; + /* The other end of the same record. A zone's best alone cannot + * say whether the zone ever RESPONDED to the lens moving, and + * that is the difference between a part of the frame that is + * out of focus and one with nothing in it to focus on. */ + if (low[i] === null || sum.fv[i] < low[i]) low[i] = sum.fv[i]; } if (sum.peak !== null && sum.peak > bestOverall) bestOverall = sum.peak; - return { best: best.slice(), bestOverall }; + if (sum.peak !== null && (lowOverall === null || sum.peak < lowOverall)) + lowOverall = sum.peak; + return { best: best.slice(), bestOverall, low: low.slice(), lowOverall }; }, - reset() { best = null; bestOverall = 0; }, + reset() { best = null; low = null; bestOverall = 0; lowOverall = null; }, }; } @@ -227,9 +237,13 @@ function bounds(len, n) { return out; } -export function coarsen(s, n) { +export function coarsen(s, n, opts = {}) { if (!Number.isSafeInteger(n) || n <= 0) throw new Error(`not a block count: ${n}`); + /* Per-zone 'some' / 'none' / 'unknown', from zoneDetail or a sweep. A + * block is only called empty when every zone in it that could be measured + * agrees -- one textured corner is enough to focus on. */ + const detail = opts.detail || null; /* Asking for more blocks than there are zones would hand back empty cells * reported as "nothing measurable", which is a different claim entirely. */ const rowsN = Math.min(n, s.rows), colsN = Math.min(n, s.cols); @@ -239,10 +253,21 @@ export function coarsen(s, n) { for (let bc = 0; bc < colsN; bc++) { const [r0, r1] = rb[br], [c0, c1] = cb[bc]; let sum = 0, measured = 0, saturated = 0, total = 0; + let withDetail = 0, knownDetail = 0; for (let r = r0; r < r1; r++) { for (let c = c0; c < c1; c++) { const i = r * s.cols + c; total++; + /* Only the two real answers count as knowing. Anything + * else -- absent, short array, undefined -- is "not + * looked at yet", not "nothing there". Read the other way + * round, a detail array that did not line up captioned + * every block on a frame nobody had swept. */ + const d = detail ? detail[i] : undefined; + if (d === 'some' || d === 'none') { + knownDetail++; + if (d === 'some') withDetail++; + } if (s.state[i] !== 'measured') continue; measured++; sum += s.fv[i]; @@ -258,14 +283,30 @@ export function coarsen(s, n) { * real block and reads as "measured, and very soft". */ value: measured ? Math.round(sum / measured) : null, measured, total, saturated, + /* 'none' is a statement about the SCENE -- there is nothing in + * this part of the frame to focus on -- and it is the reason a + * block reads low far more often than bad focus is. 'unknown' + * until the lens has moved enough to tell the two apart. */ + detail: !knownDetail ? 'unknown' : (withDetail ? 'some' : 'none'), }); } } - let best = null; - for (const b of blocks) - if (b.value !== null && (best === null || b.value > blocks[best].value)) - best = blocks.indexOf(b); - return { rows: rowsN, cols: colsN, blocks, best }; + /* An empty block is never the sharpest. It cannot be: it has nothing in it + * that focus could sharpen, and naming it would point the operator at the + * one part of the frame that can never answer. Only if NOTHING has detail + * does the plain maximum stand, because then the alternative is naming no + * block at all on a frame that may simply not have been swept yet. */ + const pick = (ok) => { + let at = null; + for (let i = 0; i < blocks.length; i++) { + const b = blocks[i]; + if (b.value === null || !ok(b)) continue; + if (at === null || b.value > blocks[at].value) at = i; + } + return at; + }; + const best = pick((b) => b.detail !== 'none'); + return { rows: rowsN, cols: colsN, blocks, best: best !== null ? best : pick(() => true) }; } /* @@ -313,6 +354,7 @@ export function sweepZones(frames, opts = {}) { const apart = opts.apart !== undefined ? opts.apart : 0.3; const peakAt = new Array(n).fill(null); + const why = new Array(n).fill('unmeasured'); for (let i = 0; i < n; i++) { let hi = -1, lo = Infinity, at = -1, ever = false, pinned = false; for (let f = 0; f < frames.length; f++) { @@ -331,15 +373,21 @@ export function sweepZones(frames, opts = {}) { * somewhere else on the strength of an artefact. `state` cannot carry * this: summarise keeps saturation separate on purpose, because a * pinned zone is still perfectly well exposed. */ - if (pinned) continue; - /* Never measured, or never moved. No opinion either way. */ - if (!ever || hi <= 0 || (hi - lo) / hi < swing) continue; + if (pinned) { why[i] = 'pinned'; continue; } + if (!ever || hi <= 0) { why[i] = 'unmeasured'; continue; } + /* Never moved: there is nothing in this zone to focus on. Reported as + * its own answer, because a zone with no detail and a zone that is out + * of focus produce the same small number and need opposite responses + * -- point the camera somewhere with edges in it, or turn the lens. */ + if ((hi - lo) / hi < swing) { why[i] = 'flat'; continue; } + why[i] = 'responded'; peakAt[i] = at; } const heard = peakAt.filter((v) => v !== null).sort((a, b) => a - b); if (!heard.length) - return { consensus: null, peakAt, suspect: [], heard: 0, rows, cols }; + return { consensus: null, peakAt, why, suspect: [], heard: 0, rows, cols, + flat: why.filter((w) => w === 'flat').length }; /* Median, not mean. One smeared corner peaking at the far end of the sweep * drags a mean toward itself and then measures everything else against a * consensus it invented. */ @@ -350,5 +398,43 @@ export function sweepZones(frames, opts = {}) { if (peakAt[i] !== null && Math.abs(peakAt[i] - consensus) > far) suspect.push(i); /* The shape travels with the finding. A consumer holding these indices * across a grid change would otherwise place them by the new width. */ - return { consensus, peakAt, suspect, heard: heard.length, far, rows, cols }; + return { consensus, peakAt, why, suspect, heard: heard.length, far, rows, cols, + flat: why.filter((w) => w === 'flat').length }; +} + +/* + * Which parts of the frame have anything in them to focus on. + * + * A blank wall, a patch of sky, a smooth painted door: all of them report a + * small focus value wherever the lens is, because focus statistics measure + * detail and there is none there to measure. Shown as a bare number that + * reads as "this part of the picture is soft", which sends an operator + * chasing focus that was never the problem. The first person to calibrate + * with this hit it immediately: "на 9 квадрате не нашлось резких объектов и + * ему маленькую цифру дали". + * + * A zone with detail in it RESPONDS when the lens moves; a zone without one + * does not. That is the whole test, and it needs the lens to have moved -- + * which is why nothing is claimed until the frame as a whole has shown it + * has. Before then every zone is 'unknown', because "we have not looked yet" + * and "there is nothing there" are different answers and only one of them is + * the operator's problem. + */ +export function zoneDetail(hold, opts = {}) { + /* How much the frame overall has to have moved before this says anything. + * Below it the lens has not been turned far enough to tell an empty zone + * from one that simply has not been swept past focus yet. */ + const moved = opts.moved !== undefined ? opts.moved : 0.2; + /* How much a zone has to move to count as having something in it. */ + const detail = opts.detail !== undefined ? opts.detail : 0.15; + const n = hold && hold.best ? hold.best.length : 0; + const out = new Array(n).fill('unknown'); + if (!n || !hold.bestOverall || hold.lowOverall === null) return out; + if ((hold.bestOverall - hold.lowOverall) / hold.bestOverall < moved) return out; + for (let i = 0; i < n; i++) { + const hi = hold.best[i], lo = hold.low[i]; + if (hi === null || lo === null || hi <= 0) continue; + out[i] = (hi - lo) / hi < detail ? 'none' : 'some'; + } + return out; } diff --git a/src/editor.css b/src/editor.css index d6b398f..58db6f4 100644 --- a/src/editor.css +++ b/src/editor.css @@ -96,6 +96,10 @@ text-anchor: middle; dominant-baseline: middle; pointer-events: none; } /* A block focusing at a different distance from the rest of the frame. Dashed * rather than solid so it cannot be mistaken for the peak outline. */ +/* A block the lens could not change: the number is what the camera reported + * and stays readable, but it is not a verdict on focus and must not carry the + * weight of one. */ +.re-fz-dim { opacity: .55; } .re-fz-odd { fill: none; stroke: #7fd4ff; stroke-width: 2; stroke-dasharray: 5 3; } .re-chart-grip { position: absolute; width: 18px; height: 18px; margin: -9px 0 0 -9px; border: 2px solid #fff; border-radius: 50%; background: #5c70e8; diff --git a/src/editor.js b/src/editor.js index d10559c..fdd55a1 100644 --- a/src/editor.js +++ b/src/editor.js @@ -15,7 +15,7 @@ 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 } from './aftune.js'; +import { summarise, peakHold, normalise, coarsen, sweepZones, zoneDetail } from './aftune.js'; const CFA_NAMES = ['RGGB', 'GRBG', 'GBRG', 'BGGR']; const DEMOSAIC = [ @@ -4900,7 +4900,11 @@ export function mountEditor(root, { } function drawCoarse(svg, s, W, H, NS) { - const c = coarsen(s, focusBlocks); + /* focusBest, not focusHold: the holder accumulates, the push result is + * what carries the record out. Handing it the holder got every block + * captioned "nothing to focus on" on a frame nobody had swept. */ + const c = coarsen(s, focusBlocks, + { detail: focusBest ? zoneDetail(focusBest) : null }); const top = c.best === null ? null : c.blocks[c.best].value; for (const b of c.blocks) { const a0 = stageCoords((b.colSpan[0] * W) / s.cols, (b.rowSpan[0] * H) / s.rows); @@ -4931,10 +4935,15 @@ export function mountEditor(root, { }); if (odd) oddRing(svg, NS, a0.x, a0.y, w, h); + /* A block with nothing in it to focus on is not a soft block, and + * a bare small number says the wrong one of those. The number is + * still shown -- it is what the camera reported -- but muted and + * captioned, so it stops reading as a verdict on focus. */ + const empty = b.detail === 'none'; const label = document.createElementNS(NS, 'text'); label.setAttribute('x', a0.x + w / 2); - label.setAttribute('y', a0.y + h / 2); - label.setAttribute('class', 're-fz-num'); + label.setAttribute('y', a0.y + (empty ? h / 2 - Math.min(9, h / 7) : h / 2)); + label.setAttribute('class', empty ? 're-fz-num re-fz-dim' : 're-fz-num'); /* Scaled to the cell and clamped: a 5-block grid on a phone gets * cells too small for 13px, and a 2-block grid on a desktop would * otherwise print a number the size of a caption. */ @@ -4946,6 +4955,15 @@ export function mountEditor(root, { ? '\u2014' : String(b.value).replace(/\B(?=(\d{3})+(?!\d))/g, '\u2009'); svg.append(label); + if (empty) { + const cap = document.createElementNS(NS, 'text'); + cap.setAttribute('x', a0.x + w / 2); + cap.setAttribute('y', a0.y + h / 2 + Math.min(12, h / 5)); + cap.setAttribute('class', 're-fz-tag re-fz-dim'); + cap.textContent = 'nothing to focus on'; + svg.append(cap); + } + if (c.best !== null && b === c.blocks[c.best] && b.value !== null) { const pk = document.createElementNS(NS, 'rect'); pk.setAttribute('x', a0.x); diff --git a/tests/ui-check.html b/tests/ui-check.html index 23b743c..649dc5e 100644 --- a/tests/ui-check.html +++ b/tests/ui-check.html @@ -2011,6 +2011,56 @@ t('the readable grid is handled', false, e.message); } +/* A blank wall scores low wherever the lens is, and shown as a bare number it + * reads as "this part is out of focus" -- sending the operator after focus + * that was never the problem. Reported on the first real calibration. */ +try { + const R = 15, C = 17; + // The right-hand third responds to the lens; the left never changes. + const at = (step) => ({ rows: R, cols: C, zones: Array.from({ length: R * C }, (_, i) => + [0, (i % C) >= 12 ? 20000 + step * 9000 : 5200, 0, 10, 1000, 0]) }); + let step = 0; + const host = document.createElement('div'); + document.body.append(host); + const ed = mountEditor(host, { base: '../dist/', styles: false, autoCapture: false, + focus: { intervalMs: 20, zones: () => Promise.resolve(at(step)) } }); + const bytes = new Uint8Array(await (await fetch('./fixture.dng')).arrayBuffer()); + await ed.open(bytes, 'fixture.dng'); + await new Promise((r) => setTimeout(r, 300)); + [...host.querySelectorAll('.re-seg button')].find((b) => b.textContent.trim() === 'Focus').click(); + await new Promise((r) => setTimeout(r, 150)); + + const caps = () => [...host.querySelectorAll('.re-fz-tag')] + .filter((n) => /nothing to focus on/.test(n.textContent)); + t('nothing is claimed before the lens has moved', caps().length === 0, + caps().length + ' captioned'); + + // Sweep the lens past focus. Only the right-hand third responds. + for (step = 1; step <= 4; step++) await new Promise((r) => setTimeout(r, 90)); + step = 4; + await new Promise((r) => setTimeout(r, 250)); + t('a block with nothing in it says so rather than scoring low', + caps().length > 0, caps().length + ' captioned'); + t('and the block that responded is not captioned', + caps().length < 9, caps().length + ' of 9 captioned'); + { + // The sharpest must be a block that could actually be sharpened. + const box = host.querySelector('.re-chart-svg').getBoundingClientRect(); + const pk = host.querySelector('.re-fz-peak'); + t('the sharpest block is one with something in it', + !!pk && pk.getBoundingClientRect().left > box.left + box.width * 0.5, + pk ? `${(pk.getBoundingClientRect().left - box.left).toFixed(0)}px` : 'none'); + } + // The number is still shown -- it is what the camera reported -- but muted + // so it stops reading as a verdict on focus. + t('the number stays, dimmed rather than hidden', + [...host.querySelectorAll('.re-fz-num.re-fz-dim')].some((n) => /\d/.test(n.textContent)), + [...host.querySelectorAll('.re-fz-num.re-fz-dim')].map((n) => n.textContent).join('|')); + ed.destroy(); +} catch (e) { + t('the empty-block notice is handled', false, e.message); +} + /* The poll reads a camera over a network, so every one of these is a question * about time: two reads in flight at once, an answer that outlives the thing * that asked for it, and a frame replaced under a grid measured of another. */ diff --git a/tools/smoke.mjs b/tools/smoke.mjs index 68c24e5..a7771ea 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -1278,6 +1278,56 @@ console.log('\nfocus statistics: the grid a person focuses a lens by'); assert('a zero block count is refused', refused); } + // A blank wall, a patch of sky, a smooth door: all report a small focus + // value wherever the lens is, because there is no detail there to measure. + // Shown as a bare small number it reads as "this part is soft", and the + // operator chases focus that was never the problem. Reported on the very + // first calibration: "на 9 квадрате не нашлось резких объектов и ему + // маленькую цифру дали". + { + const hold = A.peakHold(); + const frame = (a, b) => A.summarise([zone({ h2: a }), zone({ h2: b })], 1, 2); + // Nothing has moved yet. "We have not looked" and "there is nothing + // there" are different answers and only one is the operator's problem. + let h = hold.push(frame(900, 500)); + check('before the lens moves, nothing is claimed', A.zoneDetail(h), ['unknown', 'unknown']); + + // Zone 0 responds as the lens sweeps; zone 1 never budges. + h = hold.push(frame(200, 500)); + h = hold.push(frame(1400, 502)); + const d = A.zoneDetail(h); + check('a zone that responded has something in it', d[0], 'some'); + check('a zone that never moved has not', d[1], 'none'); + + // And it travels into the readable grid. + const s = A.summarise([zone({ h2: 1400 }), zone({ h2: 502 })], 1, 2); + const c = A.coarsen(s, 2, { detail: d }); + check('the block carries it', [c.blocks[0].detail, c.blocks[1].detail], ['some', 'none']); + + // One textured corner is enough to focus on, so a block is only called + // empty when every zone in it that could be measured agrees. + const wide = A.summarise([zone({ h2: 1400 }), zone({ h2: 502 })], 1, 2); + check('a block with one textured zone is not empty', + A.coarsen(wide, 1, { detail: d }).blocks[0].detail, 'some'); + + // Anything that is not one of the two real answers is "not looked at + // yet", not "nothing there". Read the other way round, a detail array + // that did not line up captioned every block on an unswept frame. + check('a short detail array leaves blocks unknown', + A.coarsen(s, 2, { detail: [] }).blocks[0].detail, 'unknown'); + check('and so does no array at all', A.coarsen(s, 2).blocks[0].detail, 'unknown'); + + // An empty block can never be the sharpest: it has nothing in it that + // focus could sharpen, and naming it points the operator at the one + // part of the frame that can never answer. + const s2 = A.summarise([zone({ h2: 300 }), zone({ h2: 900 })], 1, 2); + const c2 = A.coarsen(s2, 2, { detail: ['some', 'none'] }); + check('an empty block is never the sharpest', c2.best, 0); + // ...unless nothing has detail, when the alternative is naming none. + check('but with nothing to go on the plain maximum stands', + A.coarsen(s2, 2, { detail: ['none', 'none'] }).best, 1); + } + // Dirt on the dome focuses a few millimetres away, so across a sweep it // peaks nowhere near where the picture does -- which is exactly what drags // a cheap autofocus onto the glass. Invisible on a live image; unmistakable From 6ec342386c8b1ed4b36488d425c69e4c85959408 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:59:12 +0300 Subject: [PATCH 2/2] focus: a zero reading is an answer, and a record belongs to a shape Three from review, all real, and two of them one mistake: I conflated a zero reading with no reading, in code whose own comment already says they are different. A lit zone reading zero at every lens position is the emptiest zone there is. zoneDetail skipped it for hi <= 0 and left it unknown, so the block most in need of "nothing to focus on" was the one block that never got it; and sweepZones called it unmeasured, telling the operator the zone could not be read when it had been read and found empty. null stays unknown -- that one really is never measured. Zero is 'none' and 'flat'. The accumulators cannot go negative, so hi === 0 means lo === 0 and there is no swing to divide for. peakHold kept its record across a reshape. Same class as the sweep bug fixed last round, in the live path this time: two grids of the same size and different shape put the same index somewhere else in the picture, so the held per-zone record describes the wrong zones. Worse, the mismatch branch cleared only those arrays and never bestOverall/lowOverall -- which are the gate deciding whether the lens has moved at all, so the gate went on answering from a range measured on a camera that no longer existed. The record is now keyed on the shape and all four are cleared together. --- dist/aftune.js | 35 +++++++++++++++++++++++++++++------ src/aftune.js | 35 +++++++++++++++++++++++++++++------ tools/smoke.mjs | 44 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 12 deletions(-) diff --git a/dist/aftune.js b/dist/aftune.js index af01358..7f38e77 100644 --- a/dist/aftune.js +++ b/dist/aftune.js @@ -168,12 +168,23 @@ export function summarise(zones, rows, cols, opts = {}) { * maximum from a different picture is worse than no history. */ export function peakHold() { - let best = null, low = null, bestOverall = 0, lowOverall = null; + let best = null, low = null, bestOverall = 0, lowOverall = null, shape = null; return { push(sum) { - if (!best || best.length !== sum.fv.length) { + /* Shape, not length. Two grids of the same size and different + * shape put the same index in a different part of the picture, so + * a record kept across the change describes the wrong zones -- and + * the overall extrema have to go with it, because they are the + * gate that decides whether the lens has moved at all. Clearing + * only the per-zone arrays left that gate holding a range measured + * on a camera that no longer exists. */ + const sig = sum.rows + 'x' + sum.cols; + if (!best || best.length !== sum.fv.length || shape !== sig) { best = sum.fv.map(() => null); low = sum.fv.map(() => null); + bestOverall = 0; + lowOverall = null; + shape = sig; } for (let i = 0; i < sum.fv.length; i++) { /* Only a zone that measured may set its own record. A blown @@ -196,7 +207,9 @@ export function peakHold() { lowOverall = sum.peak; return { best: best.slice(), bestOverall, low: low.slice(), lowOverall }; }, - reset() { best = null; low = null; bestOverall = 0; lowOverall = null; }, + reset() { + best = null; low = null; bestOverall = 0; lowOverall = null; shape = null; + }, }; } @@ -374,7 +387,12 @@ export function sweepZones(frames, opts = {}) { * this: summarise keeps saturation separate on purpose, because a * pinned zone is still perfectly well exposed. */ if (pinned) { why[i] = 'pinned'; continue; } - if (!ever || hi <= 0) { why[i] = 'unmeasured'; continue; } + /* `ever` is the whole question of whether anything was measured here. + * A zero maximum is an ANSWER to that, not an absence of one, and + * calling it unmeasured told the operator the zone could not be read + * when in fact it was read and had nothing in it. */ + if (!ever) { why[i] = 'unmeasured'; continue; } + if (hi === 0) { why[i] = 'flat'; continue; } /* Never moved: there is nothing in this zone to focus on. Reported as * its own answer, because a zone with no detail and a zone that is out * of focus produce the same small number and need opposite responses @@ -433,8 +451,13 @@ export function zoneDetail(hold, opts = {}) { if ((hold.bestOverall - hold.lowOverall) / hold.bestOverall < moved) return out; for (let i = 0; i < n; i++) { const hi = hold.best[i], lo = hold.low[i]; - if (hi === null || lo === null || hi <= 0) continue; - out[i] = (hi - lo) / hi < detail ? 'none' : 'some'; + /* null is "never measured" and stays unknown. Zero is a READING, and + * a lit zone that reads zero at every lens position is the emptiest + * zone there is -- skipping it left the one block most in need of the + * caption without it. The accumulators cannot go negative, so hi === 0 + * means lo === 0 too and there is no swing to divide for. */ + if (hi === null || lo === null) continue; + out[i] = (hi === 0 || (hi - lo) / hi < detail) ? 'none' : 'some'; } return out; } diff --git a/src/aftune.js b/src/aftune.js index af01358..7f38e77 100644 --- a/src/aftune.js +++ b/src/aftune.js @@ -168,12 +168,23 @@ export function summarise(zones, rows, cols, opts = {}) { * maximum from a different picture is worse than no history. */ export function peakHold() { - let best = null, low = null, bestOverall = 0, lowOverall = null; + let best = null, low = null, bestOverall = 0, lowOverall = null, shape = null; return { push(sum) { - if (!best || best.length !== sum.fv.length) { + /* Shape, not length. Two grids of the same size and different + * shape put the same index in a different part of the picture, so + * a record kept across the change describes the wrong zones -- and + * the overall extrema have to go with it, because they are the + * gate that decides whether the lens has moved at all. Clearing + * only the per-zone arrays left that gate holding a range measured + * on a camera that no longer exists. */ + const sig = sum.rows + 'x' + sum.cols; + if (!best || best.length !== sum.fv.length || shape !== sig) { best = sum.fv.map(() => null); low = sum.fv.map(() => null); + bestOverall = 0; + lowOverall = null; + shape = sig; } for (let i = 0; i < sum.fv.length; i++) { /* Only a zone that measured may set its own record. A blown @@ -196,7 +207,9 @@ export function peakHold() { lowOverall = sum.peak; return { best: best.slice(), bestOverall, low: low.slice(), lowOverall }; }, - reset() { best = null; low = null; bestOverall = 0; lowOverall = null; }, + reset() { + best = null; low = null; bestOverall = 0; lowOverall = null; shape = null; + }, }; } @@ -374,7 +387,12 @@ export function sweepZones(frames, opts = {}) { * this: summarise keeps saturation separate on purpose, because a * pinned zone is still perfectly well exposed. */ if (pinned) { why[i] = 'pinned'; continue; } - if (!ever || hi <= 0) { why[i] = 'unmeasured'; continue; } + /* `ever` is the whole question of whether anything was measured here. + * A zero maximum is an ANSWER to that, not an absence of one, and + * calling it unmeasured told the operator the zone could not be read + * when in fact it was read and had nothing in it. */ + if (!ever) { why[i] = 'unmeasured'; continue; } + if (hi === 0) { why[i] = 'flat'; continue; } /* Never moved: there is nothing in this zone to focus on. Reported as * its own answer, because a zone with no detail and a zone that is out * of focus produce the same small number and need opposite responses @@ -433,8 +451,13 @@ export function zoneDetail(hold, opts = {}) { if ((hold.bestOverall - hold.lowOverall) / hold.bestOverall < moved) return out; for (let i = 0; i < n; i++) { const hi = hold.best[i], lo = hold.low[i]; - if (hi === null || lo === null || hi <= 0) continue; - out[i] = (hi - lo) / hi < detail ? 'none' : 'some'; + /* null is "never measured" and stays unknown. Zero is a READING, and + * a lit zone that reads zero at every lens position is the emptiest + * zone there is -- skipping it left the one block most in need of the + * caption without it. The accumulators cannot go negative, so hi === 0 + * means lo === 0 too and there is no swing to divide for. */ + if (hi === null || lo === null) continue; + out[i] = (hi === 0 || (hi - lo) / hi < detail) ? 'none' : 'some'; } return out; } diff --git a/tools/smoke.mjs b/tools/smoke.mjs index a7771ea..eb157e9 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -1317,6 +1317,37 @@ console.log('\nfocus statistics: the grid a person focuses a lens by'); A.coarsen(s, 2, { detail: [] }).blocks[0].detail, 'unknown'); check('and so does no array at all', A.coarsen(s, 2).blocks[0].detail, 'unknown'); + // A lit zone reading zero at every position is the emptiest zone there + // is. null is "never measured" and stays unknown; zero is a READING, + // and skipping it left the block most in need of the caption without + // one. + { + const h2 = A.peakHold(); + const z = (a, b) => A.summarise([zone({ h2: a }), zone({ h2: b })], 1, 2); + h2.push(z(0, 900)); + h2.push(z(0, 200)); + const dz = A.zoneDetail(h2.push(z(0, 1500))); + check('a zone that reads zero throughout is empty, not unknown', dz[0], 'none'); + check('while the one that moved is not', dz[1], 'some'); + } + + // The held record belongs to a SHAPE. Two grids of the same size and + // different shape put the same index somewhere else in the picture, + // and the overall range has to go with it -- it is the gate deciding + // whether the lens moved at all. + { + const h3 = A.peakHold(); + const wide = A.summarise(grid(1, 4, (i) => zone({ h2: i ? 100 : 900 })), 1, 4); + const tall = A.summarise(grid(2, 2, (i) => zone({ h2: i ? 100 : 900 })), 2, 2); + h3.push(wide); + h3.push(A.summarise(grid(1, 4, () => zone({ h2: 100 })), 1, 4)); + assert('the lens has visibly moved on the old shape', + A.zoneDetail(h3.push(wide)).some((d) => d !== 'unknown')); + // Same zone count, different shape: the record cannot carry over. + check('a reshaped grid starts the record again', + A.zoneDetail(h3.push(tall)), ['unknown', 'unknown', 'unknown', 'unknown']); + } + // An empty block can never be the sharpest: it has nothing in it that // focus could sharpen, and naming it points the operator at the one // part of the frame that can never answer. @@ -1386,6 +1417,19 @@ console.log('\nfocus statistics: the grid a person focuses a lens by'); check('while the others still agree', rp.consensus, 3); } + // A lit zone whose response is zero at every position was measured and + // found empty, which is not the same as not being readable. + { + const zero = [0, 1, 2, 3, 4].map((p) => ({ + fv: [0, curve(3)[p], curve(3)[p], curve(3)[p]], + state: Array(4).fill('measured'), + sat: Array(4).fill(false), rows: 1, cols: 4, + })); + const rz = A.sweepZones(zero); + check('a measured zero zone is flat, not unmeasured', rz.why[0], 'flat'); + check('and it is counted as such', rz.flat, 1); + } + // Two grids of the same SIZE and different shape put the same index in // a different part of the picture, and a ring over the wrong zone is // worse than no ring.