fix(tone): read the ramp's base from the pixel's own neighbourhood, so highlights and shadows move a region and keep the detail in it
This commit is contained in:
@@ -69,6 +69,10 @@ const {
|
||||
TONE_BLACK_EDGE,
|
||||
TONE_BASE_RADIUS,
|
||||
TONE_BASE_SIGMA,
|
||||
TONE_BASE_TAPS,
|
||||
TONE_BASE_RANGE_EV,
|
||||
TONE_BASE_SKSL,
|
||||
toneBaseStep,
|
||||
toneUniformArray,
|
||||
} = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href);
|
||||
|
||||
@@ -187,11 +191,24 @@ assert.doesNotMatch(sksl, /float blackA = |float whiteA = /, 'the amplitudes are
|
||||
assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — it folds the flat stretch at 0.238');
|
||||
assert.doesNotMatch(sksl, /float lin\(/, 'the linear-light knee ahead of the knobs came back');
|
||||
assert.doesNotMatch(sksl, /clamp\(0\.25 \+ 0\.25/, 'a knob is clamped against a neighbour knot again');
|
||||
assert.match(sksl, /float target = clamp\(o, 0\.0, 1\.0\);/);
|
||||
assert.doesNotMatch(sksl, /float target = base > 0\.0004 \? o \* t \/ base : t;/, 'the gain-scaled detail came back — a tone knob softens again');
|
||||
// The ramp is read at the BASE (Base' + Detail), and the pixel's own difference
|
||||
// from it rides through un-scaled. These pin the two lines the whole tone pass
|
||||
// hangs on: read at `t`, a knob is a global curve again (the HIGHLIGHT/SHADOW
|
||||
// local-contrast report), and scaled by the neighbourhood's gain, a knob softens
|
||||
// the frame's texture into its level (the report before that).
|
||||
assert.match(sksl, /float o = toneCurve\(base, blVal, shVal, hlVal, whVal\);/);
|
||||
assert.match(sksl, /float target = clamp\(o \+ \(t - base\), 0\.0, 1\.0\);/);
|
||||
assert.doesNotMatch(sksl, /float o = toneCurve\(t, /, 'the ramp is read at the pixel again — a global curve');
|
||||
assert.match(sksl, /vec3 res = lightMove\(c, t, target\);/);
|
||||
assert.doesNotMatch(sksl, /float target = base > 0\.0004 \? o \* t \/ base : t;/, 'the gain-scaled detail came back — a tone knob softens again');
|
||||
assert.match(sksl, /vec3 res = lightMove\(c, t, target\);/);
|
||||
// The base layer itself: an edge-aware pass, not the gaussian the ramp fell back
|
||||
// to (a gaussian average lands ACROSS an edge, and the halo follows the knob).
|
||||
assert.match(TONE_BASE_SKSL, /uniform float2 dir;/);
|
||||
assert.match(TONE_BASE_SKSL, /float ref = toneBaseMed3\(/);
|
||||
assert.match(TONE_BASE_SKSL, /float d1 = \(a1 - ref\) \/ \(ref \+ TONE_BASE_PED\) \/ TONE_BASE_RANGE;/);
|
||||
assert.doesNotMatch(TONE_BASE_SKSL, /toneBaseMed3\(l0, ref, /, 'the reference is the median of the wrong triple');
|
||||
assert.ok(toneBaseStep(1200, 600) === 4 && toneBaseStep(400, 300) === 1 && toneBaseStep(4000, 3000) === 13,
|
||||
`the base step drifted: ${toneBaseStep(1200, 600)}, ${toneBaseStep(400, 300)}, ${toneBaseStep(4000, 3000)}`);
|
||||
assert.doesNotMatch(sksl, /lightMove\(c, t, clamp\(o, 0\.0, 1\.0\)\)/, 'the ramp is read at the pixel again — a global curve');
|
||||
assert.doesNotMatch(sksl, /mix\(a0, a1, smoothstep/, 'the ramp is smoothstepped again');
|
||||
// The linear-light knee that used to run ahead of all this is GONE from the tone
|
||||
@@ -447,8 +464,8 @@ function lightMove(rgb, t, o) {
|
||||
function rebuild(rgb, k, base) {
|
||||
const t = lumaOf(rgb);
|
||||
const b = base ?? t;
|
||||
const o = ramp(t, k).o;
|
||||
const target = clamp01(o);
|
||||
const o = ramp(b, k).o;
|
||||
const target = clamp01(o + (t - b));
|
||||
const out = lightMove(rgb, t, target);
|
||||
return { out, clamped: out.map((c) => clamp01(c)), o, t, base: b, target };
|
||||
}
|
||||
@@ -543,8 +560,9 @@ for (const [rgb, knobs] of [
|
||||
//
|
||||
// read at the pixel every pixel lands on its own o, so the band's spread
|
||||
// comes out multiplied by whatever slope the curve has
|
||||
// over it — 1.2347 of its own spread at this deflection,
|
||||
// the SHADOW +100 table steepening through 0.26..0.50.
|
||||
// over it — 0.779 of its own spread at this deflection,
|
||||
// the SHADOW +100 table flattening through 0.26..0.50
|
||||
// (its +0.25 lift saturates and the knots bend over).
|
||||
// read at the base every pixel of ONE neighbourhood takes the same move,
|
||||
// o(base) - base, and its own difference from the base is
|
||||
// added to it, so the texture inside the region comes out
|
||||
@@ -560,6 +578,14 @@ const band = Array.from({ length: 32 }, (_, i) => 0.26 + 0.24 * (i / 31));
|
||||
const spread = (xs) => Math.max(...xs) - Math.min(...xs);
|
||||
const bandBase = band.reduce((a, b) => a + b, 0) / band.length;
|
||||
const movedGlobally = band.map((t) => rebuild([t, t, t], { sh: 1 }).clamped[0]);
|
||||
// ...and measured, so the two readings cannot drift apart in the source while
|
||||
// the numbers above stay prose: the global one scales the band by the curve's
|
||||
// slope (0.78 at SHADOW +100 — the lift saturates over this quarter and the
|
||||
// texture shrinks with it), the base one moves it and leaves its spread alone.
|
||||
const movedOnBase = band.map((t) => rebuild([t, t, t], { sh: 1 }, bandBase).clamped[0]);
|
||||
const slopeOverBand = spread(movedGlobally) / spread(band);
|
||||
assert.ok(slopeOverBand < 0.9, `the SHADOW +100 table stopped flattening through 0.26..0.50: ${slopeOverBand}`);
|
||||
near4(spread(movedOnBase), spread(band));
|
||||
|
||||
// Every knob on zero is the identity, whatever base is handed in
|
||||
for (const b of [0.01, 0.1, 0.35, 0.7, 0.99])
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// The tone ramp is drawn through a BASE layer, and that base is now a blurred
|
||||
// CHILD of the pass rather than a ring of point samples inside it — see
|
||||
// TONE_BASE_RADIUS in toneShader.ts for the mottle that ring caused. Two things
|
||||
// The tone ramp is drawn through a BASE layer, and that base is a CHILD of the
|
||||
// pass — the edge-aware pass of TONE_BASE_SKSL, run by the caller — rather than a
|
||||
// ring of point samples inside it or a blur of it: see TONE_BASE_RADIUS in
|
||||
// toneShader.ts for the mottle a ring caused and for the halo a gaussian one
|
||||
// does. Two things
|
||||
// can go wrong with that and neither is a crash: the shader stops compiling once
|
||||
// it takes a second child (SkSL is only checked at runtime, and no other check
|
||||
// here compiles TONE_SKSL as a whole), or the second child is wired to the wrong
|
||||
@@ -34,12 +36,16 @@ writeFileSync(
|
||||
),
|
||||
);
|
||||
const {
|
||||
TONE_SKSL, TONE_BASE_RADIUS, TONE_BASE_SIGMA, getToneUniforms, toneUniformArray, toneIsActive,
|
||||
TONE_KNOBS,
|
||||
TONE_SKSL, TONE_BASE_SKSL, TONE_BASE_RADIUS, TONE_BASE_SIGMA, TONE_BASE_TAPS, toneBaseStep,
|
||||
getToneUniforms, toneUniformArray, toneIsActive, TONE_KNOBS,
|
||||
} = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href);
|
||||
|
||||
// The shader has to NAME a base child — a ring of taps would not need one.
|
||||
// The shader has to NAME a base child — a ring of taps would not need one — and
|
||||
// the base pass has to take its own two, one per axis.
|
||||
assert.match(TONE_SKSL, /uniform shader base;/);
|
||||
assert.match(TONE_BASE_SKSL, /uniform shader src;/);
|
||||
assert.match(TONE_BASE_SKSL, /uniform float2 dir;/);
|
||||
assert.ok(TONE_BASE_TAPS > 0 && toneBaseStep(1200, 600) * TONE_BASE_TAPS >= 1200 * TONE_BASE_RADIUS - 0.5);
|
||||
|
||||
const CanvasKit = await CanvasKitInit({
|
||||
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
|
||||
@@ -118,17 +124,34 @@ const t = srcValue / 255;
|
||||
const b = baseValue / 255;
|
||||
// The neighbourhood's new luma, plus the pixel's own DIFFERENCE from the base —
|
||||
// Base' + Detail, the reconstruction the shader emits (see the note in
|
||||
// toneRamp). The ratio Base' * (Input / Base) is what this used to predict, and
|
||||
// it is what took the texture out of the frame at BLACK -100.
|
||||
const diff = Math.abs(t - b);
|
||||
const smooth = (x) => x * x * (3 - 2 * x);
|
||||
const expected = Math.round(255 * clamp01(ramp(t, { sh: 1 }).o));
|
||||
// toneRamp). Two other reconstructions were here before it: the ramp read at the
|
||||
// PIXEL (a global curve, and the local-contrast report), and Base' * (Input /
|
||||
// Base), the gain-weighted detail that took the texture out of the frame at
|
||||
// BLACK -100.
|
||||
const detail = (pixel, base) => clamp01(ramp(base, { sh: 1 }).o + (pixel - base));
|
||||
const expected = Math.round(255 * detail(t, b));
|
||||
|
||||
const got = render(srcValue, baseValue);
|
||||
assert.ok(
|
||||
Math.abs(got - expected) <= 2,
|
||||
`a base of ${b.toFixed(4)} under a pixel of ${t.toFixed(4)} gave ${got}, the ramp predicts ${expected}`,
|
||||
);
|
||||
// The same instruction on the other side of the pixel: a base BRIGHTER than it
|
||||
// takes the neighbourhood's move and hands the pixel its own difference back, so
|
||||
// the pixel lands BELOW the neighbourhood's new luma. One move per
|
||||
// neighbourhood, not one per pixel — which is exactly what a global curve
|
||||
// cannot say.
|
||||
const brighterValue = 180;
|
||||
const brighter = render(srcValue, brighterValue);
|
||||
const brighterWant = Math.round(255 * detail(t, brighterValue / 255));
|
||||
assert.ok(
|
||||
Math.abs(brighter - brighterWant) <= 2,
|
||||
`a base of ${(brighterValue / 255).toFixed(4)} over a pixel of ${t.toFixed(4)} gave ${brighter}, Base'+Detail predicts ${brighterWant}`,
|
||||
);
|
||||
assert.ok(
|
||||
brighter < got,
|
||||
`a brighter base did not pull the pixel down (${brighter} against ${got}) — the base is not in the move`,
|
||||
);
|
||||
// With the base handed in as the sharp image the difference is exactly zero and
|
||||
// the pass is the GLOBAL move — the ramp at the pixel — which is what a mask (no
|
||||
// neighbourhood of its own) needs it to be. It is NOT the identity any more:
|
||||
@@ -161,6 +184,8 @@ assert.equal(
|
||||
|
||||
console.log(
|
||||
`tone base ok: pixel ${srcValue} over a base of ${baseValue} -> ${got} ` +
|
||||
`(ramp predicts ${expected}, global move ${globalMove}, sharp-base ${selfBase}); ` +
|
||||
`radius ${TONE_BASE_RADIUS} of the frame, sigma ${TONE_BASE_SIGMA} of it`,
|
||||
`(Base'+Detail predicts ${expected}, global move ${globalMove}, sharp-base ${selfBase}); ` +
|
||||
`over a base of ${brighterValue} -> ${brighter} (predicts ${brighterWant}); ` +
|
||||
`radius ${TONE_BASE_RADIUS} of the frame, ${TONE_BASE_TAPS} taps per side, step ` +
|
||||
`${toneBaseStep(1200, 600)} px at 1200, sigma ${TONE_BASE_SIGMA} of the radius`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
// LOCAL CONTRAST AND HALO of the tone ramp, on the reference pipeline's own
|
||||
// test scene — and the twin below is checked against the real SkSL, compiled and
|
||||
// rendered through CanvasKit, not against a reading of the source.
|
||||
//
|
||||
// THE SCENE is crates/pipeline/src/tests_tone.rs's, in LINEAR light: a bright
|
||||
// half (sky 0.85) carrying 1 px dark twig lines (0.14) and a dark half (ground
|
||||
// 0.011) carrying 1 px bright stalk lines (0.06), each pixel modulated by a
|
||||
// ±3% 8-bit texture step. Everything is measured on the ENCODED 8-bit output,
|
||||
// like the reference does, because that is what a knob's damage looks like.
|
||||
//
|
||||
// THE BASE PASS RUNS ON A SURFACE TONE_BASE_SCALE OF THE FRAME (toneBaseSurface
|
||||
// in toneShader.ts), and both sides of this file mirror that: the real side
|
||||
// draws the frame's child through a canvas-scale and the twin box-averages the
|
||||
// frame to match, then the real side's tone pass reads the half through the
|
||||
// local matrix exportEngine.baseUpscale hands it — NEAREST, so the twin replicates
|
||||
// the half's texels back. A Node/CanvasKit calibration pinned both samplers
|
||||
// against drawImageRectOptions (the frame's own 2:1 downscale and its nearest
|
||||
// 1:2 upscale), and the asserts below re-pin them here: `downAt` and `upscale2`
|
||||
// disagree with Skia by less than one code value, and would disagree by several
|
||||
// at a half-texel off.
|
||||
//
|
||||
// WHAT THE FOUR KNOBS MUST NOT DO (the report this pass was rewritten for):
|
||||
//
|
||||
// LOCAL CONTRAST. A 1 px twig is detail, not tone: HIGHLIGHT -100 and
|
||||
// SHADOW +100 may move a region, they may not flattern the twig into the sky
|
||||
// it sits in. Read at the PIXEL, every pixel lands on the ramp read at its own
|
||||
// luma, so a knob's slope compresses the detail with the tone — the twig lost
|
||||
// 0.79 of its contrast against the sky at HIGHLIGHT -100 (scratchpad
|
||||
// proto3/proto.mjs). Read at the BASE with the pixel's own difference added
|
||||
// back (Base' + Detail), the region takes one move and the twig keeps its own
|
||||
// size. Measured here as the high-pass energy of a half (std of the pixel
|
||||
// against its 9x9 mean, in code values) and as the twig/stalk line contrast,
|
||||
// |log2(mean off line / mean on line)|, both against the GLOBAL reading of the
|
||||
// same knob and against EXPOSURE +/-1 EV.
|
||||
//
|
||||
// NO HALO. At a step edge the base must be the level of the pixel's OWN side,
|
||||
// or the pixels beside the sky take the shadow side's move and a bright rim
|
||||
// follows the knob (docker/halo_highlight_shadow_white_black.md). A gaussian
|
||||
// base fails this by construction and is measured here to prove the test
|
||||
// bites: base 1.0/255, global 4.0/255, gaussian 37.0/255 on the same frame.
|
||||
//
|
||||
// TWO CAVEATS, both measured rather than argued, and both printed below:
|
||||
//
|
||||
// The "opposite half unchanged" criterion cannot hold ABSOLUTELY with this
|
||||
// app's measured tables: HIGHLIGHT -100 moves a MID luma by -0.031 and SHADOW
|
||||
// +100 lifts a mid luma by +0.17 (TONE_KNOBS), so the half the knob is not
|
||||
// aimed at moves -7.8/255 and +43/255 — as does a global curve, and by
|
||||
// construction: the fix is that the base does not ADD a leak of its own on top
|
||||
// of the curve, which is what the flat-region check asserts (|base - global|
|
||||
// <= 1.5/255 there, i.e. the base stops moving where the curve does).
|
||||
//
|
||||
// SHADOW +100 cannot hold 0.9x EXPOSURE +1 EV on the dark half either, with
|
||||
// ANY implementation: this app's SHADOW is an absolute lift (slope 1 on the
|
||||
// dark half) while the reference's is a ratio in log space, so the exposure
|
||||
// reading is the stronger one. The check asserts what the design promises
|
||||
// (>= 0.9x the plain frame, and >= 1.1x the global reading) and PRINTS the
|
||||
// exposure ratio for the record.
|
||||
//
|
||||
// node scripts/tone-local-contrast-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 { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
|
||||
|
||||
const transpile = (path) =>
|
||||
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'tone-local-contrast-'));
|
||||
writeFileSync(join(tmp, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
|
||||
writeFileSync(
|
||||
join(tmp, 'toneShader.mjs'),
|
||||
transpile('../shared/utils/toneShader.ts').replace(
|
||||
/^import .*from ['"]\.\/colorUtils['"];$/m,
|
||||
'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";',
|
||||
),
|
||||
);
|
||||
const { TONE_SKSL, TONE_BASE_SKSL, TONE_KNOBS, toneBaseSurface, TONE_BASE_SCALE, toneBaseStep, getToneUniforms, toneUniformArray } = await import(
|
||||
pathToFileURL(join(tmp, 'toneShader.mjs')).href
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The scene, the size the reference measures at, as 8-bit ENCODED bytes: the
|
||||
// twin reads the same bytes the renderer is handed, so the two cannot diverge on
|
||||
// the input either.
|
||||
const W = 1200;
|
||||
const H = 600;
|
||||
const STEP = toneBaseStep(W, H);
|
||||
// The surface the base pass runs on, and its own tap step: the radius is the
|
||||
// FRAME's fraction either way, the step is what changes (see toneBaseSurface).
|
||||
const HALF = toneBaseSurface(W, H);
|
||||
const HSTEP = HALF.step;
|
||||
const HALF_N = HALF.width * HALF.height;
|
||||
const enc = (x) => (x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055);
|
||||
const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
||||
const onLine = (x, y) => (Math.floor(x / 48) + Math.floor(y / 48)) % 2 === 0 && (x % 4 === 1 || y % 5 === 2);
|
||||
const q8 = (v) => Math.round(Math.max(0, Math.min(1, v)) * 255);
|
||||
function sceneBytes(L) {
|
||||
const b = new Uint8Array(W * H * 4);
|
||||
for (let y = 0; y < H; y++)
|
||||
for (let x = 0; x < W; x++) {
|
||||
const tex = 1 + 0.06 * ((((x * 7 + y * 13) % 5) - 2) / 2);
|
||||
const base = x < W / 2 ? (onLine(x, y) ? L.twig : L.sky) : onLine(x, y) ? L.stalk : L.ground;
|
||||
const v = q8(enc(Math.min(1, base * tex)));
|
||||
const i = (y * W + x) * 4;
|
||||
b[i] = b[i + 1] = b[i + 2] = v;
|
||||
b[i + 3] = 255;
|
||||
}
|
||||
return b;
|
||||
}
|
||||
const toFloat = (bytes, w = W, h = H) => {
|
||||
const f = new Float32Array(w * h);
|
||||
for (let i = 0; i < w * h; i++) f[i] = bytes[i * 4] / 255;
|
||||
return f;
|
||||
};
|
||||
const SCENE = sceneBytes({ sky: 0.85, twig: 0.14, ground: 0.011, stalk: 0.06 });
|
||||
// The halo frame: the SAME chequer with the lines taken out of the values, so the
|
||||
// only edge left to test is the one at x = W/2.
|
||||
const FLAT = sceneBytes({ sky: 0.85, twig: 0.85, ground: 0.011, stalk: 0.011 });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// THE TWIN: TONE_BASE_SKSL's two axis passes and TONE_MATH_SKSL's ramp, in
|
||||
// arithmetic — the twin of tone-base-check.mjs, one frame wide. The base it
|
||||
// produces is quantized to 8 bits between the passes, because the renderer's
|
||||
// surfaces are: that is the one place the two could drift, so it is modelled.
|
||||
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
||||
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
|
||||
const TAPS = 8;
|
||||
const SIGMA = 0.35;
|
||||
const RANGE_EV = 0.25;
|
||||
const PED = 0.02;
|
||||
const RADIUS = 0.025;
|
||||
const LN2R = Math.LN2 * RANGE_EV;
|
||||
const med3 = (a, b, c) => Math.max(Math.min(a, b), Math.min(Math.max(a, b), c));
|
||||
|
||||
// The frame as the half-res surface sees it. The pass draws its full-size child
|
||||
// onto that surface through a 2:1 canvas scale, and Skia's linear sampler at 2:1
|
||||
// IS the 2x2 average (calibrated against drawImageRectOptions: same bytes), so
|
||||
// the twin averages the same four pixels and lets the pass quantize the result —
|
||||
// the average itself is a sampler reading, not a surface write, so it is NOT
|
||||
// rounded here. The four pixels are CLAMPED ONE BY ONE, not the average's own
|
||||
// half index: a tap that falls off the frame is a truncated box in the real
|
||||
// sampler, and averaging a clamped half index instead reads a box that does not
|
||||
// exist there (one code value at the frame's first and last rows/columns, which
|
||||
// is exactly what the asserts below caught).
|
||||
function downAt(src) {
|
||||
const g = (X, Y) => src[clamp(Y, 0, H - 1) * W + clamp(X, 0, W - 1)];
|
||||
return (x, y) =>
|
||||
(g(2 * x, 2 * y) + g(2 * x + 1, 2 * y) + g(2 * x, 2 * y + 1) + g(2 * x + 1, 2 * y + 1)) / 4;
|
||||
}
|
||||
|
||||
// ...and back, as the tone pass's own read of it: the half is handed to the tone
|
||||
// shader with the local matrix baseUpscale builds (2x) and a NEAREST sampler, so
|
||||
// frame pixel X reads half pixel floor(X / 2) — a 2x2 replication of the half's
|
||||
// texels, calibrated against Skia's own sampler (calib2.mjs: 0/256 mismatches on
|
||||
// a 16x16 field; linear's quarter-texel weights instead put the far side of a
|
||||
// step edge into the pixel beside it, which is the halo this pass must not have).
|
||||
function upscale2(half) {
|
||||
const out = new Float32Array(W * H);
|
||||
for (let y = 0; y < H; y++) {
|
||||
const y0 = Math.min(Math.floor(y * TONE_BASE_SCALE), HALF.height - 1) * HALF.width;
|
||||
for (let x = 0; x < W; x++)
|
||||
out[y * W + x] = half[y0 + Math.min(Math.floor(x * TONE_BASE_SCALE), HALF.width - 1)];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function axis(src, w, h, step, dx, dy, at = (x, y) => src[clamp(y, 0, h - 1) * w + clamp(x, 0, w - 1)]) {
|
||||
const out = new Float32Array(w * h);
|
||||
const gs = [];
|
||||
for (let i = 1; i <= TAPS; i++) gs.push(Math.exp(-0.5 * (i / (TAPS * SIGMA)) ** 2));
|
||||
for (let y = 0; y < h; y++)
|
||||
for (let x = 0; x < w; x++) {
|
||||
const c = at(x, y);
|
||||
const ref = med3(at(x - dx * step, y - dy * step), c, at(x + dx * step, y + dy * step));
|
||||
let sum = ref;
|
||||
let total = 1;
|
||||
for (let i = 1; i <= TAPS; i++) {
|
||||
const g = gs[i - 1];
|
||||
for (const sgn of [1, -1]) {
|
||||
const a = at(x + sgn * dx * step * i, y + sgn * dy * step * i);
|
||||
const d = (a - ref) / (ref + PED) / LN2R;
|
||||
const r = Math.exp(-d * d);
|
||||
sum += g * r * a;
|
||||
total += g * r;
|
||||
}
|
||||
}
|
||||
out[y * w + x] = sum / total;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const quant = (f) => {
|
||||
const o = new Float32Array(f.length);
|
||||
for (let i = 0; i < f.length; i++) o[i] = Math.round(f[i] * 255) / 255;
|
||||
return o;
|
||||
};
|
||||
// The base at HALF the frame, as the passes leave it (8-bit between them), and
|
||||
// put back at the frame's own size — which is what the ramp in the tone pass
|
||||
// reads.
|
||||
const halfBaseOf = (src) =>
|
||||
quant(axis(quant(axis(null, HALF.width, HALF.height, HSTEP, 1, 0, downAt(src))), HALF.width, HALF.height, HSTEP, 0, 1));
|
||||
// The half keeps the NEIGHBOURHOOD, which is the whole point of the scale: its
|
||||
// step read out at the frame's own size is the frame's own step, so the radius
|
||||
// toneShader names is the same fraction of the frame on either surface.
|
||||
assert.equal(HSTEP / TONE_BASE_SCALE, STEP, 'the half surface lost the base radius');
|
||||
// The hedge the ramp used to have: a gaussian of TONE_BASE_RADIUS, run through
|
||||
// the same ramp, to show what this test is FOR (it fails the halo bound).
|
||||
function gaussian(src, sigma) {
|
||||
const r = Math.ceil(sigma * 3);
|
||||
const k = [];
|
||||
for (let i = -r; i <= r; i++) k.push(Math.exp(-0.5 * (i / sigma) ** 2));
|
||||
const ksum = k.reduce((a, b) => a + b, 0);
|
||||
const tmpf = new Float32Array(src.length);
|
||||
const out = new Float32Array(src.length);
|
||||
for (let y = 0; y < H; y++)
|
||||
for (let x = 0; x < W; x++) {
|
||||
let s = 0;
|
||||
for (let i = -r; i <= r; i++) s += k[i + r] * src[y * W + clamp(x + i, 0, W - 1)];
|
||||
tmpf[y * W + x] = s / ksum;
|
||||
}
|
||||
for (let y = 0; y < H; y++)
|
||||
for (let x = 0; x < W; x++) {
|
||||
let s = 0;
|
||||
for (let i = -r; i <= r; i++) s += k[i + r] * tmpf[clamp(y + i, 0, H - 1) * W + x];
|
||||
out[y * W + x] = s / ksum;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const poly = (c, L) => {
|
||||
let v = c.v[0];
|
||||
for (let i = 1; i < c.p.length; i++)
|
||||
v += (c.v[i] - c.v[i - 1]) * clamp((L - c.p[i - 1]) / (c.p[i] - c.p[i - 1]), 0, 1);
|
||||
return v;
|
||||
};
|
||||
const knob = (L, k, amt) =>
|
||||
clamp01(L + (amt >= 0 ? amt * poly(TONE_KNOBS[k].plus, L) : -amt * poly(TONE_KNOBS[k].minus, L)));
|
||||
const ramp = (L, k = {}) =>
|
||||
knob(knob(knob(knob(L, 'bl', k.bl ?? 0), 'sh', k.sh ?? 0), 'hl', k.hl ?? 0), 'wh', k.wh ?? 0);
|
||||
// Base' + Detail: the neighbourhood's new luma plus the pixel's own difference
|
||||
// from the base, un-scaled (TONE_MATH_SKSL's toneRamp).
|
||||
const apply = (frame, baseArr, k) => {
|
||||
const out = new Float32Array(frame.length);
|
||||
for (let i = 0; i < frame.length; i++) {
|
||||
const t = frame[i];
|
||||
const b = baseArr ? baseArr[i] : t;
|
||||
out[i] = clamp01(ramp(b, k) + (t - b));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const applyGlobal = (frame, k) => apply(frame, null, k);
|
||||
// EXPOSURE as EXPOSURE_SKSL runs it: the stop is a RATIO on the pixel's own
|
||||
// encoded luma, so the frame is the knob's identity at 0 EV (see toneShader.ts).
|
||||
const exposure = (frame, ev) => {
|
||||
const out = new Float32Array(frame.length);
|
||||
for (let i = 0; i < frame.length; i++) {
|
||||
const y = Math.max(lin(frame[i]), 1e-6);
|
||||
out[i] = clamp01(frame[i] * (enc(Math.min(1, y * 2 ** ev)) / enc(y)));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// THE REAL PIPELINE, through CanvasKit: TONE_BASE_SKSL twice — the horizontal
|
||||
// pass on a surface TONE_BASE_SCALE of the frame, drawing the frame's own child
|
||||
// through a canvas-scale, the vertical one reading that half at 1:1, exactly as
|
||||
// exportEngine.toneBase builds them — then TONE_SKSL with the frame and the half
|
||||
// base as its two children, the base read through baseUpscale's local matrix.
|
||||
const CanvasKit = await CanvasKitInit({
|
||||
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
|
||||
});
|
||||
const infoAt = (w, h) => ({
|
||||
width: w, height: h, colorType: CanvasKit.ColorType.RGBA_8888,
|
||||
alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB,
|
||||
});
|
||||
const info = infoAt(W, H);
|
||||
const halfInfo = infoAt(HALF.width, HALF.height);
|
||||
const imageOf = (bytes) => CanvasKit.MakeImage(info, bytes, W * 4);
|
||||
const halfImageOf = (bytes) => CanvasKit.MakeImage(halfInfo, bytes, HALF.width * 4);
|
||||
const childOf = (image, localMatrix, fm = CanvasKit.FilterMode.Linear) =>
|
||||
localMatrix
|
||||
? image.makeShaderOptions(CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, fm, CanvasKit.MipmapMode.None, localMatrix)
|
||||
: image.makeShaderOptions(CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, fm, CanvasKit.MipmapMode.None);
|
||||
const readOut = (surface, w = W, h = H) =>
|
||||
surface.makeImageSnapshot().readPixels(0, 0, infoAt(w, h)).slice(0, w * h * 4);
|
||||
|
||||
function pass(effect, uniforms, children) {
|
||||
const shader = effect.makeShaderWithChildren(uniforms, children);
|
||||
assert.ok(shader, 'the pass did not take its children');
|
||||
const surf = CanvasKit.MakeSurface(W, H);
|
||||
const paint = new CanvasKit.Paint();
|
||||
paint.setShader(shader);
|
||||
surf.getCanvas().drawRect(CanvasKit.XYWHRect(0, 0, W, H), paint);
|
||||
const out = readOut(surf);
|
||||
paint.delete();
|
||||
shader.delete();
|
||||
surf.delete();
|
||||
return out;
|
||||
}
|
||||
// One axis of the base pass, drawn 1:1 on the half — the scale is the CHILD's
|
||||
// local matrix, not the canvas (see exportEngine.baseDownscale), which is what
|
||||
// keeps `dir` in the half surface's own pixels rather than in the frame's.
|
||||
function baseAxis(effect, child, dx, dy) {
|
||||
const shader = effect.makeShaderWithChildren([dx, dy], [child]);
|
||||
assert.ok(shader, 'the base pass did not take its child');
|
||||
const surf = CanvasKit.MakeSurface(HALF.width, HALF.height);
|
||||
const paint = new CanvasKit.Paint();
|
||||
paint.setShader(shader);
|
||||
surf.getCanvas().drawRect(CanvasKit.XYWHRect(0, 0, HALF.width, HALF.height), paint);
|
||||
const out = readOut(surf, HALF.width, HALF.height);
|
||||
paint.delete();
|
||||
shader.delete();
|
||||
surf.delete();
|
||||
return out;
|
||||
}
|
||||
function realBase(bytes) {
|
||||
const effect = CanvasKit.RuntimeEffect.Make(TONE_BASE_SKSL);
|
||||
assert.ok(effect, 'TONE_BASE_SKSL does not compile');
|
||||
const img = imageOf(bytes);
|
||||
const h = baseAxis(effect, childOf(img, CanvasKit.Matrix.scaled(TONE_BASE_SCALE, TONE_BASE_SCALE)), HSTEP, 0);
|
||||
const hImg = halfImageOf(h);
|
||||
const v = baseAxis(effect, childOf(hImg), 0, HSTEP);
|
||||
effect.delete();
|
||||
img.delete();
|
||||
hImg.delete();
|
||||
return v;
|
||||
}
|
||||
function realTone(bytes, baseHalfBytes, adjustments) {
|
||||
const effect = CanvasKit.RuntimeEffect.Make(TONE_SKSL);
|
||||
assert.ok(effect, 'TONE_SKSL does not compile');
|
||||
const img = imageOf(bytes);
|
||||
const uniforms = getToneUniforms(adjustments);
|
||||
const up = CanvasKit.Matrix.scaled(1 / TONE_BASE_SCALE, 1 / TONE_BASE_SCALE);
|
||||
const bImg = baseHalfBytes ? halfImageOf(baseHalfBytes) : null;
|
||||
const out = pass(effect, toneUniformArray(uniforms), [
|
||||
childOf(img),
|
||||
bImg ? childOf(bImg, up, CanvasKit.FilterMode.Nearest) : childOf(img),
|
||||
]);
|
||||
effect.delete();
|
||||
img.delete();
|
||||
if (bImg) bImg.delete();
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// THE MEASUREMENTS, the reference's own: a HALF is 40 px clear of the frame's
|
||||
// own edge, the line contrast is measured on the lines against the flat pixels
|
||||
// of the same half (the 9x9 statistic skips the lines, since a 9x9 window over a
|
||||
// 1 px line is mostly line).
|
||||
const lumaAt = (f, x, y) => f[y * W + x];
|
||||
function lineContrast(f, x0, x1) {
|
||||
let on = 0, onc = 0, off = 0, offc = 0;
|
||||
for (let y = 40; y < H - 40; y++)
|
||||
for (let x = x0; x < x1; x++) {
|
||||
const v = lumaAt(f, x, y);
|
||||
if (onLine(x, y)) { on += v; onc++; } else { off += v; offc++; }
|
||||
}
|
||||
return Math.abs(Math.log2(off / offc / (on / onc)));
|
||||
}
|
||||
function stats(f, x0, x1, skipLines = false) {
|
||||
let sum = 0, n = 0, hp2 = 0;
|
||||
for (let y = 40; y < H - 40; y++)
|
||||
for (let x = x0; x < x1; x++) {
|
||||
if (skipLines && onLine(x, y)) continue;
|
||||
let m = 0, mn = 0;
|
||||
for (let dy = 0; dy < 9; dy++)
|
||||
for (let dx = 0; dx < 9; dx++) {
|
||||
const sx = clamp(x + dx - 4, 0, W - 1);
|
||||
const sy = clamp(y + dy - 4, 0, H - 1);
|
||||
if (skipLines && onLine(sx, sy)) continue;
|
||||
m += lumaAt(f, sx, sy);
|
||||
mn++;
|
||||
}
|
||||
m /= mn;
|
||||
const hp = lumaAt(f, x, y) - m;
|
||||
sum += lumaAt(f, x, y);
|
||||
hp2 += hp * hp;
|
||||
n++;
|
||||
}
|
||||
return { mean: sum / n, hp: (Math.sqrt(hp2 / n) * 255) };
|
||||
}
|
||||
// Halo: the deviation of the moved frame from the SAME-side pixel 100 px clear of
|
||||
// the edge, over +-40 px around it, in code values.
|
||||
function halo(plain, moved) {
|
||||
const edge = W / 2, row = H / 2;
|
||||
const farB = lumaAt(moved, 100, row) - lumaAt(plain, 100, row);
|
||||
const farD = lumaAt(moved, W - 100, row) - lumaAt(plain, W - 100, row);
|
||||
let worst = 0;
|
||||
for (let x = edge - 40; x < edge + 40; x++) {
|
||||
const d = lumaAt(moved, x, row) - lumaAt(plain, x, row);
|
||||
worst = Math.max(worst, Math.abs(d - (x < edge ? farB : farD)));
|
||||
}
|
||||
return worst * 255;
|
||||
}
|
||||
const maxDiff = (a, b, n = W * H) => {
|
||||
let m = 0;
|
||||
for (let i = 0; i < n; i++) m = Math.max(m, Math.abs(a[i] - b[i]));
|
||||
return m * 255;
|
||||
};
|
||||
const meanDiff = (a, b, n = W * H) => {
|
||||
let s = 0;
|
||||
for (let i = 0; i < n; i++) s += Math.abs(a[i] - b[i]);
|
||||
return (s / n) * 255;
|
||||
};
|
||||
const halfOf = (f) => ({ bright: stats(f, 40, 540), dark: stats(f, 700, 1160) });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The twin and the real pipeline, once each per frame and knob. `sceneTwinBase`
|
||||
// is the twin's base AT THE FRAME'S OWN SIZE (the half bilineared back by the
|
||||
// local matrix the tone pass reads it through) — the ramp is fed the frame's
|
||||
// coordinates, exactly like the shader's own child.
|
||||
const sceneF = toFloat(SCENE);
|
||||
const sceneTwinHalf = halfBaseOf(sceneF);
|
||||
const sceneTwinBase = upscale2(sceneTwinHalf);
|
||||
const plain = sceneF;
|
||||
|
||||
// The half the real passes leave, and the tone passes that read it.
|
||||
const SCENE_BASE_BYTES = realBase(SCENE);
|
||||
const twin = {};
|
||||
const real = {};
|
||||
for (const [name, knobK, adj] of [
|
||||
['hl-100', { hl: -1 }, { highlights: -100 }],
|
||||
['sh+100', { sh: 1 }, { shadows: 100 }],
|
||||
]) {
|
||||
twin[name] = { base: apply(sceneF, sceneTwinBase, knobK), global: applyGlobal(sceneF, knobK) };
|
||||
real[name] = toFloat(realTone(SCENE, SCENE_BASE_BYTES, adj));
|
||||
}
|
||||
const twinExp = { 'exp-1': exposure(sceneF, -1), 'exp+1': exposure(sceneF, 1) };
|
||||
|
||||
// The twin against the real shader, per pixel: the numbers below are only worth
|
||||
// reading if the two agree, so this is asserted before anything is measured. The
|
||||
// tone reading carries the upscale with it (the twin bilinears the half back, the
|
||||
// real pass reads it through its local matrix), so a half-texel out in either
|
||||
// would show here as tens of code values, not as the fraction below.
|
||||
// The base itself, at the size the passes leave it: the bilateral (the twin)
|
||||
// against TONE_BASE_SKSL compiled and rendered (the real), on the same frame.
|
||||
const sceneRealBase = toFloat(SCENE_BASE_BYTES, HALF.width, HALF.height);
|
||||
const i2j = (i, stride = W) => (i / stride) | 0;
|
||||
const worst = (a, b, stride = W) => {
|
||||
let mi = 0, mv = -1, n = 0;
|
||||
const par = [0, 0, [0, 0]];
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const d = Math.abs(a[i] - b[i]);
|
||||
if (d > mv) { mv = d; mi = i; }
|
||||
par[i % 2] += d;
|
||||
par[2][i2j(i, stride) % 2] += d;
|
||||
n++;
|
||||
}
|
||||
return `worst ${(mv * 255).toFixed(3)}/255 at ${mi % stride},${i2j(mi, stride)}; mean-even-col ${((par[0] / (n / 2)) * 255).toFixed(3)} odd-col ${((par[1] / (n / 2)) * 255).toFixed(3)} even-row ${((par[2][0] / (n / 2)) * 255).toFixed(3)} odd-row ${((par[2][1] / (n / 2)) * 255).toFixed(3)}`;
|
||||
};
|
||||
assert.ok(
|
||||
maxDiff(sceneTwinHalf, sceneRealBase, HALF_N) <= 1.001,
|
||||
`the twin base is not TONE_BASE_SKSL: max ${maxDiff(sceneTwinHalf, sceneRealBase, HALF_N).toFixed(3)}/255 — ${worst(sceneTwinHalf, sceneRealBase, HALF.width)}`
|
||||
);
|
||||
for (const name of ['hl-100', 'sh+100']) {
|
||||
const d = meanDiff(twin[name].base, real[name]);
|
||||
const mx = maxDiff(twin[name].base, real[name]);
|
||||
assert.ok(
|
||||
d <= 1.0 && mx <= 4,
|
||||
`the twin is not the shader at ${name}: mean ${d.toFixed(3)}/255, max ${mx.toFixed(3)}/255 — ${worst(twin[name].base, real[name])}`
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Halo, on the step-edge frame.
|
||||
const flatF = toFloat(FLAT);
|
||||
const flatTwinHalf = halfBaseOf(flatF);
|
||||
const flatTwinBase = upscale2(flatTwinHalf);
|
||||
const flatGauss = gaussian(flatF, W * RADIUS * SIGMA);
|
||||
const flatRealBaseBytes = realBase(FLAT);
|
||||
const flatRealBase = toFloat(flatRealBaseBytes, HALF.width, HALF.height);
|
||||
const flatReal = toFloat(realTone(FLAT, flatRealBaseBytes, { highlights: -100 }));
|
||||
const haloBase = halo(flatF, apply(flatF, flatTwinBase, { hl: -1 }));
|
||||
const haloGlobal = halo(flatF, applyGlobal(flatF, { hl: -1 }));
|
||||
const haloGauss = halo(flatF, apply(flatF, flatGauss, { hl: -1 }));
|
||||
const haloReal = halo(flatF, flatReal);
|
||||
// The base stops where the curve stops: on a patch 60 px clear of the edge the
|
||||
// two readings agree to within the 8-bit quantization of one base, because the
|
||||
// base's own texture averages out there — the mean is what is compared, since
|
||||
// the per-pixel difference on such a patch IS the +-3% texture the base exists
|
||||
// to hand back to the pixel (that is contrast kept, not a leak).
|
||||
function patchMean(f) {
|
||||
let s = 0, n = 0;
|
||||
for (let y = 60; y < 160; y++)
|
||||
for (let x = 60; x < 160; x++) { s += lumaAt(f, x, y); n++; }
|
||||
return s / n;
|
||||
}
|
||||
const flatLeak = Math.abs(patchMean(apply(flatF, flatTwinBase, { hl: -1 })) - patchMean(applyGlobal(flatF, { hl: -1 }))) * 255;
|
||||
const flatTexture = (() => {
|
||||
const a = apply(flatF, flatTwinBase, { hl: -1 });
|
||||
const g = applyGlobal(flatF, { hl: -1 });
|
||||
let m = 0;
|
||||
for (let y = 60; y < 160; y++)
|
||||
for (let x = 60; x < 160; x++) m = Math.max(m, Math.abs(lumaAt(a, x, y) - lumaAt(g, x, y)));
|
||||
return m * 255;
|
||||
})();
|
||||
|
||||
assert.ok(haloBase <= 5, `the base haloed: ${haloBase.toFixed(3)}/255`);
|
||||
assert.ok(haloReal <= 5, `the REAL pipeline haloed: ${haloReal.toFixed(3)}/255`);
|
||||
assert.ok(haloGauss > 10, `the gaussian hedge did not halo (${haloGauss.toFixed(3)}/255) — the test no longer bites`);
|
||||
assert.ok(flatLeak <= 1.5, `the base leaks into the flat region: ${flatLeak.toFixed(3)}/255`);
|
||||
assert.ok(
|
||||
maxDiff(flatTwinHalf, flatRealBase, HALF_N) <= 1.001,
|
||||
`the real base is not TONE_BASE_SKSL on the step edge: max ${maxDiff(flatTwinHalf, flatRealBase, HALF_N).toFixed(3)}/255`
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local contrast, knob by knob and half by half, with each assertion read off
|
||||
// the measurement it is about rather than hard-coded. A half is CRUSHED when the
|
||||
// GLOBAL reading of the knob leaves less high-pass energy there than the plain
|
||||
// frame does, and that is the half where the two readings have to part: there the
|
||||
// base reading must beat the global one (the report this pass was rewritten for),
|
||||
// and elsewhere it must hold the frame's own detail. `hp` is the high-pass energy
|
||||
// of the half in code values, `lc` the 1 px line contrast, |log2(off/on)|.
|
||||
const pc = (x) => `${x.toFixed(3)}x`;
|
||||
// How far a half's level may move before its log-contrast reading is read as the
|
||||
// knob's own lift rather than as damage (see the note in the loop).
|
||||
const LC_SHIFT = 10;
|
||||
const HALVES = [['bright', 40, 540], ['dark', 700, 1160]];
|
||||
const KNOBS = [
|
||||
['HIGHLIGHT -100', 'hl-100', { hl: -1 }, twinExp['exp-1'], 'EXPOSURE -1 EV'],
|
||||
['SHADOW +100', 'sh+100', { sh: 1 }, twinExp['exp+1'], 'EXPOSURE +1 EV'],
|
||||
];
|
||||
const bTex = (f) => stats(f, 40, 540, true).hp;
|
||||
const rows = [];
|
||||
for (const [name, key, knobK, exp, expName] of KNOBS) {
|
||||
const moved = twin[key].base;
|
||||
const glob = twin[key].global;
|
||||
const realF = real[key];
|
||||
for (const [half, x0, x1] of HALVES) {
|
||||
const hp = {
|
||||
base: stats(moved, x0, x1).hp,
|
||||
real: stats(realF, x0, x1).hp,
|
||||
global: stats(glob, x0, x1).hp,
|
||||
plain: stats(plain, x0, x1).hp,
|
||||
exp: stats(exp, x0, x1).hp,
|
||||
};
|
||||
const lc = {
|
||||
base: lineContrast(moved, x0, x1),
|
||||
global: lineContrast(glob, x0, x1),
|
||||
plain: lineContrast(plain, x0, x1),
|
||||
exp: lineContrast(exp, x0, x1),
|
||||
};
|
||||
const crushed = hp.global < hp.plain;
|
||||
// ...and how far the knob moves the LEVEL of this half. A lift squeezes a
|
||||
// ratio: SHADOW +100 puts the dark half up 61 code values, so a stalk line
|
||||
// whose luma difference is untouched comes out at 0.58 where it was 1.34 —
|
||||
// the ratio is what moves, not the detail. So the log reading is asserted on
|
||||
// halves the knob leaves level, and the absolute one (hp) everywhere.
|
||||
const shift = (stats(moved, x0, x1).mean - stats(plain, x0, x1).mean) * 255;
|
||||
const level = Math.abs(shift) <= LC_SHIFT;
|
||||
if (crushed) {
|
||||
// The pixel reading crushes this half; the base reading must not follow it.
|
||||
assert.ok(
|
||||
hp.base >= 1.1 * hp.global,
|
||||
`${name}: the ${half} half is where the global reading crushes (${hp.global.toFixed(3)} against ${hp.plain.toFixed(3)} plain) and the base reading followed it: ${pc(hp.base / hp.global)}`,
|
||||
);
|
||||
assert.ok(
|
||||
hp.real >= 1.1 * hp.global,
|
||||
`${name}: the REAL pass followed the crush on the ${half} half: ${pc(hp.real / hp.global)} (${hp.real.toFixed(3)} against ${hp.global.toFixed(3)})`,
|
||||
);
|
||||
assert.ok(
|
||||
lc.base >= 1.0 * lc.global,
|
||||
`${name}: the base reading lost the ${half} half's 1 px lines to the global one: ${lc.base.toFixed(3)} against ${lc.global.toFixed(3)}`,
|
||||
);
|
||||
} else {
|
||||
// The pixel reading holds or expands this half; the base reading still has
|
||||
// to keep the detail the untouched frame has.
|
||||
assert.ok(
|
||||
hp.base >= 0.9 * hp.plain,
|
||||
`${name}: the ${half} half lost detail against the plain frame: ${hp.base.toFixed(3)} against ${hp.plain.toFixed(3)} code values`,
|
||||
);
|
||||
assert.ok(
|
||||
!level || lc.base >= 0.9 * lc.plain,
|
||||
`${name}: the ${half} half's 1 px lines lost contrast against the plain frame: ${lc.base.toFixed(3)} against ${lc.plain.toFixed(3)} (level moved ${shift.toFixed(2)}/255)`,
|
||||
);
|
||||
assert.ok(
|
||||
level || lc.base >= 0.8 * lc.global,
|
||||
`${name}: the ${half} half's 1 px lines lost contrast to the global reading: ${lc.base.toFixed(3)} against ${lc.global.toFixed(3)} (level moved ${shift.toFixed(2)}/255)`,
|
||||
);
|
||||
// The caller's exposure criterion, where EXPOSURE is the MILD reading of
|
||||
// the knob: a stop that leaves the frame less texture than the plain frame
|
||||
// does is a reading of the lift, not of the detail (caveat at the head).
|
||||
if (hp.exp < hp.plain)
|
||||
assert.ok(
|
||||
hp.base >= 0.9 * hp.exp,
|
||||
`${name}: the ${half} half fell under ${expName}: ${pc(hp.base / hp.exp)} (${hp.base.toFixed(3)} against ${hp.exp.toFixed(3)})`,
|
||||
);
|
||||
}
|
||||
rows.push({ name, half, hp, lc, crushed, expName, shift, level });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// THE NUMBERS, so a drift in any of them is visible in the log and not only in a
|
||||
// threshold. The opposite half's mean shift — the "the half the knob is not aimed
|
||||
// at moved too" observation — is printed and not asserted, for the reason at the
|
||||
// head of this file.
|
||||
console.log(
|
||||
`tone local contrast ok — scene ${W}x${H}, base ${HALF.width}x${HALF.height} ` +
|
||||
`(TONE_BASE_SCALE ${TONE_BASE_SCALE}), base step ${HSTEP} px there = ${RADIUS} of the FRAME's long edge, ` +
|
||||
`${TAPS} taps per side, range sigma ${RANGE_EV} EV, and 8-bit in and out`,
|
||||
);
|
||||
console.log(
|
||||
` twin vs real shader: max ${Math.max(maxDiff(twin['hl-100'].base, real['hl-100']), maxDiff(twin['sh+100'].base, real['sh+100'])).toFixed(3)}/255, ` +
|
||||
`mean ${Math.max(meanDiff(twin['hl-100'].base, real['hl-100']), meanDiff(twin['sh+100'].base, real['sh+100'])).toFixed(4)}/255; ` +
|
||||
`base image max ${Math.max(maxDiff(sceneTwinHalf, sceneRealBase, HALF_N), maxDiff(flatTwinHalf, flatRealBase, HALF_N)).toFixed(3)}/255 ` +
|
||||
`(half vs half, ${HALF.width}x${HALF.height})`,
|
||||
);
|
||||
console.log(
|
||||
` halo, step edge: base ${haloBase.toFixed(2)}/255, real ${haloReal.toFixed(2)}/255, global ${haloGlobal.toFixed(2)}/255, ` +
|
||||
`gaussian ${haloGauss.toFixed(2)}/255 (bound 5); flat-region leak ${flatLeak.toFixed(3)}/255 (bound 1.5) ` +
|
||||
`with ${flatTexture.toFixed(2)}/255 of texture kept per pixel there`,
|
||||
);
|
||||
for (const { name, half, hp, lc, crushed, expName, shift, level } of rows)
|
||||
console.log(
|
||||
` ${name}, ${half} half${crushed ? ' (CRUSHED by the global reading)' : ''}${level ? '' : ` (level moved ${shift.toFixed(2)}/255 — the log reading below is the lift's, not detail)`}: hp base ${hp.base.toFixed(3)} ` +
|
||||
`(real ${hp.real.toFixed(3)}), global ${hp.global.toFixed(3)}, plain ${hp.plain.toFixed(3)}, ${expName} ${hp.exp.toFixed(3)} ` +
|
||||
`— base ${pc(hp.base / hp.global)} global, ${pc(hp.base / hp.plain)} plain${hp.exp < hp.plain ? `, ${pc(hp.base / hp.exp)} ${expName}` : ` (${expName} is ${pc(hp.exp / hp.plain)} the plain frame — a lift, not detail)`}; ` +
|
||||
`line contrast base ${lc.base.toFixed(3)} vs global ${lc.global.toFixed(3)}, plain ${lc.plain.toFixed(3)}`,
|
||||
);
|
||||
console.log(
|
||||
` texture the base keeps (line-free patch of the bright half): ${bTex(plain).toFixed(3)} code values in, ` +
|
||||
`${bTex(twin['sh+100'].base).toFixed(3)} with SHADOW +100 (${pc(bTex(twin['sh+100'].base) / bTex(plain))})`,
|
||||
);
|
||||
for (const [name, key, half] of [['HIGHLIGHT -100', 'hl-100', 'dark'], ['SHADOW +100', 'sh+100', 'bright']]) {
|
||||
const x = half === 'dark' ? [700, 1160] : [40, 540];
|
||||
const shift = (f) => ((stats(f, x[0], x[1]).mean - stats(plain, x[0], x[1]).mean) * 255).toFixed(2);
|
||||
console.log(
|
||||
` ${name}, opposite half mean shift: base ${shift(twin[key].base)}/255, global ${shift(twin[key].global)}/255 ` +
|
||||
`(the curve's own move on a luma it is not aimed at; printed, not asserted)`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user