Files
RecipesCam/docker/frontend/scripts/auto-tone-check.mjs
T

120 lines
6.5 KiB
JavaScript

// 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 ±10.
assert.deepEqual(autoTone(spike(255)), { highlight: -10, shadow: 0 }, 'blown frame only pulls highlights');
assert.deepEqual(autoTone(spike(0)), { highlight: 0, shadow: 10 }, '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
// (9 units), p01 = 2/255 = 0.008 (6 units).
assert.deepEqual(autoTone(flat()), { highlight: -9, shadow: 6 }, '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: -10, shadow: 6 }, '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 -> 5.3 units -> 5. Proof the pull is graded.
const shoulder = spike(100, 989);
shoulder[243] = 11;
assert.deepEqual(autoTone(shoulder), { highlight: -5, shadow: 0 }, 'p99 just over 0.9 pulls half the way');
// 2% of pixels two bins off the floor: p01 = 0.008, so the shadow side moves —
// but only 6 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: 6 }, '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');