web: make AUTO write the tone and the colour it reads off the frame
The AUTO chip measured the photo and wrote one knob, EV. It now writes the four
the measurement actually names, off the same binned ramp (ui/Histogram.tsx),
which is why it is one chip and not four: the means it needs are all in the
histogram the exposure answer already reads.
EV the mean luma, unchanged
HIGHLIGHT the top 1% (p99 > 0.9 pulls back), to -5 of the ruler at most
SHADOW the bottom 1% (p01 < 0.02 opens up), to +5
TEMPERATURE/TINT the gain that puts the three channel means on each other,
green as the anchor: gray-world on linearised means, then the
closest of 76 temperatures x 21 tints under the renderer's own
kelvinToRGB, so the pair cannot drift from what the ruler applies.
The ends rather than the average is what keeps a small blown window from
dragging the whole frame: a specular in the corner wants HIGHLIGHT, not a
flatter picture everywhere. Both ends stop at half the ruler, so the frame is
corrected and a hand can still finish the move; the knobs then report the
numbers AUTO chose, the way the EV knob does.
Each reading is a pure function of the ramp, so pressing AUTO twice lands on the
same recipe by construction, and a frame with a dead channel leaves the WB ruler
where it is rather than inventing a cast. ponytail: one linear ramp per end and
no scene analysis; add a curve, or weight by how much of the frame is clipped,
when AUTO starts overshooting a scene with a genuine specular in it.
scripts/auto-tone-check.mjs holds the three readings: the percentile walk, the
thresholds that leave a knob alone, and the scan landing back on the gain it was
asked for.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
// AUTO's three readings are pure arithmetic on the binned ramp, so they can be
|
||||
// checked here rather than in a browser: hand the functions a histogram whose
|
||||
// answer is known by construction and read it back. The module is TypeScript
|
||||
// (and a .tsx), so it is transpiled on the fly out of the installed compiler and
|
||||
// the React/i18n imports — which only the overlay component needs — are dropped
|
||||
// before it is evaluated (same convention as preview-match-check.mjs).
|
||||
//
|
||||
// node scripts/auto-tone-check.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
|
||||
const transpile = (path) =>
|
||||
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.ESNext,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
},
|
||||
}).outputText;
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'auto-check-'));
|
||||
// colorUtils only imports types, so the compiler drops that line on its own.
|
||||
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
|
||||
writeFileSync(
|
||||
join(dir, 'histogram.mjs'),
|
||||
transpile('../src/ui/Histogram.tsx')
|
||||
.replace(
|
||||
/^import .*from ['"]\.\.\/\.\.\/shared\/utils\/colorUtils['"];$/m,
|
||||
'import { kelvinToRGB } from "./colorUtils.mjs";',
|
||||
)
|
||||
// The overlay's own imports: the component is never rendered here, so React
|
||||
// and the i18n provider are dead weight node cannot resolve.
|
||||
.replace(/^import .*from ['"]react[^'"]*['"];$/gm, '')
|
||||
.replace(/^import .*from ['"]\.\.\/i18n\/I18nProvider['"];$/m, ''),
|
||||
);
|
||||
const { autoExposureStops, autoTone, autoWhiteBalance, AUTO_EV_MAX } = await import(
|
||||
pathToFileURL(join(dir, 'histogram.mjs')).href
|
||||
);
|
||||
|
||||
const BINS = 256;
|
||||
// One pixel per bin: the whole 0..255 ramp, evenly, so every quantile of it is
|
||||
// known in closed form (quantile q of a flat ramp is q).
|
||||
const flat = () => new Array(BINS).fill(1);
|
||||
// Every pixel at one level: a frame with a single tone in it, and nothing else.
|
||||
const spike = (v, n = 1000) => {
|
||||
const bins = new Array(BINS).fill(0);
|
||||
bins[v] = n;
|
||||
return bins;
|
||||
};
|
||||
|
||||
// --- Exposure: the mean luma against the 0.48 a grey card lands on, in stops.
|
||||
const stops = (lum) => autoExposureStops(lum);
|
||||
assert.equal(stops(spike(255)), Math.log2(0.48 / 1), 'white is pulled back by log2(0.48)');
|
||||
assert.equal(stops(spike(0)), AUTO_EV_MAX, "black is pushed to the knob own ceiling");
|
||||
// Half a stop down from the target is 0.48 / 2^0.5 = 0.339 -> bin 87.
|
||||
assert.equal(Math.round(stops(spike(87)) * 10) / 10, 0.5, 'a dark frame is asked for +0.5 stop');
|
||||
// The flat ramp averages exactly mid-grey, which is 0.0589 stop under target.
|
||||
assert.ok(Math.abs(stops(flat()) + 0.0589) < 5e-4, 'a flat ramp sits just under mid-grey');
|
||||
assert.equal(stops(spike(20, 0)), AUTO_EV_MAX, 'an empty histogram takes the ceiling, not infinity');
|
||||
|
||||
// --- Highlight / Shadow: the 1% tails, thresholds 0.9 / 0.02, ramp to ±5.
|
||||
assert.deepEqual(autoTone(spike(255)), { highlight: -5, shadow: 0 }, 'blown frame only pulls highlights');
|
||||
assert.deepEqual(autoTone(spike(0)), { highlight: 0, shadow: 5 }, 'black frame only opens shadows');
|
||||
// The flat ramp is the interesting one: its own top 1% really is clipped and its
|
||||
// own bottom 1% really is on the floor, so both ends move. p99 = 253/255 = 0.992
|
||||
// (5 units), p01 = 2/255 = 0.008 (3 units).
|
||||
assert.deepEqual(autoTone(flat()), { highlight: -5, shadow: 3 }, 'an even ramp has both ends in play');
|
||||
// 1% of the frame at white over an otherwise mid ramp: the spec's small blown
|
||||
// window, and the shadow side stays put because the ramp's floor is not black.
|
||||
const blown = flat();
|
||||
blown[255] += 3;
|
||||
assert.deepEqual(autoTone(blown), { highlight: -5, shadow: 3 }, 'a blown 1% is enough to pull the top');
|
||||
// p99 landed just over the threshold: 989 pixels mid-ramp and 11 at bin 243 puts
|
||||
// p99 at 0.953, 0.053 over 0.9 -> 2.65 units -> 3. Proof the pull is graded.
|
||||
const shoulder = spike(100, 989);
|
||||
shoulder[243] = 11;
|
||||
assert.deepEqual(autoTone(shoulder), { highlight: -3, shadow: 0 }, 'p99 just over 0.9 pulls a third of the way');
|
||||
// 2% of pixels two bins off the floor: p01 = 0.008, so the shadow side moves —
|
||||
// but only 3 units, because the tail is barely under the 0.02 threshold.
|
||||
const crushed = spike(128, 980);
|
||||
crushed[2] = 20;
|
||||
assert.deepEqual(autoTone(crushed), { highlight: 0, shadow: 3 }, 'a crushed 2% opens the shadows part way');
|
||||
// 0.4% on the floor is NOT the bottom 1%: the tenth-darkest pixel is the mid
|
||||
// ramp, so the threshold is not crossed and the knob stays put.
|
||||
const speck = spike(128, 996);
|
||||
speck[0] = 4;
|
||||
assert.deepEqual(autoTone(speck), { highlight: 0, shadow: 0 }, 'a speck of black is not a crushed frame');
|
||||
assert.deepEqual(autoTone(spike(0, 0)), { highlight: 0, shadow: 0 }, 'an empty histogram is left alone');
|
||||
|
||||
// --- White Balance: gray-world with G as the anchor, matched against the
|
||||
// engine's own kelvinToRGB, so a neutral frame must come back on 5500K / 0.
|
||||
assert.deepEqual(autoWhiteBalance(flat(), flat(), flat()), { temperature: 5500, tint: 0 }, 'neutral stays put');
|
||||
assert.deepEqual(
|
||||
autoWhiteBalance(spike(0), spike(0), spike(0)),
|
||||
{ temperature: 5500, tint: 0 },
|
||||
'a frame with no light in it has no cast to read',
|
||||
);
|
||||
// A mildly warm frame (R above G above B) is a frame lit too warmly, so the
|
||||
// ruler is asked to cool it — below 5500K. The cast sits on the planckian
|
||||
// locus, so no green/magenta trim is needed to reach it.
|
||||
const warm = autoWhiteBalance(spike(160), spike(150), spike(140));
|
||||
assert.ok(warm.temperature < 5500, `a warm frame cools: got ${warm.temperature}K`);
|
||||
assert.equal(warm.tint, 0, `and stays on the locus: got tint ${warm.tint}`);
|
||||
// A cast far warmer than the Kelvin ruler's travel cannot be cancelled, and the
|
||||
// two axes overlap once the temperature pins at its end — so this only asks for
|
||||
// the direction and for the trim staying small.
|
||||
const veryWarm = autoWhiteBalance(spike(180), spike(150), spike(120));
|
||||
assert.ok(veryWarm.temperature < 3000, `a harder warm cast cools further: got ${veryWarm.temperature}K`);
|
||||
assert.ok(Math.abs(veryWarm.tint) <= 5, `without a wild trim: got ${veryWarm.tint}`);
|
||||
// A frame that is only green is the one the TINT axis exists for: green is
|
||||
// trimmed toward magenta (positive), which no Kelvin move can do.
|
||||
const green = autoWhiteBalance(spike(120), spike(160), spike(120));
|
||||
assert.ok(green.tint > 5, `a green frame is trimmed toward magenta: got ${green.tint}`);
|
||||
|
||||
console.log('auto-tone-check ok');
|
||||
Reference in New Issue
Block a user