diff --git a/dist/aftune.js b/dist/aftune.js index 3735e5f..7f38e77 100644 --- a/dist/aftune.js +++ b/dist/aftune.js @@ -168,10 +168,24 @@ 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, shape = null; return { push(sum) { - if (!best || best.length !== sum.fv.length) best = sum.fv.map(() => null); + /* 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 * zone reports a huge response -- that is the whole reason the @@ -182,11 +196,20 @@ 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; low = null; bestOverall = 0; lowOverall = null; shape = null; }, - reset() { best = null; bestOverall = 0; }, }; } @@ -227,9 +250,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 +266,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 +296,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 +367,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 +386,26 @@ 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; } + /* `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 + * -- 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 +416,48 @@ 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]; + /* 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/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..7f38e77 100644 --- a/src/aftune.js +++ b/src/aftune.js @@ -168,10 +168,24 @@ 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, shape = null; return { push(sum) { - if (!best || best.length !== sum.fv.length) best = sum.fv.map(() => null); + /* 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 * zone reports a huge response -- that is the whole reason the @@ -182,11 +196,20 @@ 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; low = null; bestOverall = 0; lowOverall = null; shape = null; }, - reset() { best = null; bestOverall = 0; }, }; } @@ -227,9 +250,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 +266,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 +296,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 +367,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 +386,26 @@ 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; } + /* `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 + * -- 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 +416,48 @@ 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]; + /* 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/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..eb157e9 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -1278,6 +1278,87 @@ 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'); + + // 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. + 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 @@ -1336,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.