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
139 changes: 124 additions & 15 deletions dist/aftune.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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; },
};
}

Expand Down Expand Up @@ -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);
Expand All @@ -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];
Expand All @@ -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) };
}

/*
Expand Down Expand Up @@ -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++) {
Expand All @@ -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. */
Expand All @@ -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;
}
4 changes: 4 additions & 0 deletions dist/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 22 additions & 4 deletions dist/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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. */
Expand All @@ -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);
Expand Down
Loading
Loading