bc550569ad
BLACK at full deflection returned a soft picture, and on a monochrome frame it
returned the blurred base outright. The tone pass reads the ramp at the
neighbourhood's base and then rebuilds the pixel, and it rebuilt it by the RATIO
the base had moved by — `Base' * (Input / Base)`, the other half of
fix_shadow.md's decomposition. A ratio is a gain, and that gain is a function of
the neighbourhood: the knob that takes the base toward zero scales every pixel's
detail by the same coefficient, so the knob that darkens the frame takes its
texture with it. Measured on lightroom_shadow.jpg at 1024px through tone-sim.mjs,
the pass in plain JS with the shader's own constants: at BLACK -100 the finest
gradient came back at 0.75 of the input under the ratio and at 0.98 under the
sum, while the frame darkened the same either way (mean 0.416 to 0.359 both). A
monochrome stock is the whole frame of that error, because all three channels ARE
the pixel's luma there, which is why the picture came back as the base — soft,
and short of every edge it had.
The reconstruction is `Base' + Detail`, ADDED and not scaled, and it costs
nothing where there is no move to make: with every knob on zero the ramp at the
base IS the base, so the difference is exactly zero and the pass is the identity
however coarse the base is. A caller that hands in no neighbourhood at all — a
mask — hands in the pixel's own image as its base and gets the global move back,
which is what the ratio gave it too. `o` is held inside the cube before the
detail is added, so a neighbourhood the ramp has pushed under the floor keeps the
structure around it instead of carrying its pedestal down onto every pixel in the
region. The bright side of the same move is untouched: the lift is still the
neighbourhood's, and at SHADOW +100 the band above the lifted knot still keeps
0.81 of its spread where the global move kept 0.42.
CLARITY drew a light stroke down every contour, and the reason was in what the
blur called a neighbour. The range weight was the colour difference,
`exp(-dot(d, d) * 24)`, which is loose on any coloured edge — two sides of a hair,
a branch or a rail can share a red and differ in green — so the reference reached
across the edge, and the reference is exactly what the blend subtracts. The wider
it reaches, the more a contour reads as detail. It reads LUMINANCE now, one
decision per tap at the doc's own scale (`CLARITY_RANGE_SIGMA` = 0.04), so an edge
of any hue stops the blur dead.
The blend moved the three channels by their own differences, which is what
coloured fringing along every contour was, and the amount it moved them by was
the MASK's `CLARITY_GAIN` borrowed for a different child. It moves LUMINANCE now
— one value carries the whole pixel back with it, so hue is untouchable and skin
does not go sallow at the top of the knob — under the doc's midtone weight
M(L) = 4L(1-L), which deepens the greys a picture is made of and leaves the burnt
ends and the deepest shadows where they are. The positive side's gain lives
inside the shader (4.5, raised from the doc's 1.8 because the range weight above
reaches less far and carries less detail): clarity-halo.mjs, which measures the
pass pushing a pixel outside the range of its own neighbourhood, reads a bright
stroke of 55.3/255 on lightroom_shadow.jpg and 54.3/255 on DSCF1701.JPG at +10,
against the old pass's 145 and 127 at the same knob — and 8.0 already reads 98, so
the knob does not need to go further to keep the flat areas moving.
`exportEngine` passes the knob's own units now, `[clarityKnob / 10]`, since the
gain and the sign are the shader's business and the mask's gain is not the
frame's.
And a backup folder the browser had stopped letting the page write to wrote
nothing and said so, once, with no way back: a permission outlives the tab only
while the tab does, so the row kept reading a permission of a moment ago while
the write went to the disk without one. A refusal that names the permission, or
the folder that is not there, now goes through the picker ONCE — the picker is
the only thing that hands a folder back — and the run is repeated; anything else
is the folder itself saying no, and is not asked twice. The sentence on the
screen is one line over a strip of photographs and cannot carry a reason, so the
reason goes to the console and a word of it into the note (`{why}`, the name the
browser gave the refusal and never a stack), which is the whole of what tells a
folder that was moved from a permission that lapsed. Both dictionaries learn the
word.
Checked: `tsc --noEmit` clean; `tone-base-check.mjs` and `highlight-knee-check.mjs`
updated to the new reconstruction and passing; `tone-sim.mjs` on
lightroom_shadow.jpg at 1024px for the numbers above; `clarity-halo.mjs` for the
stroke.
150 lines
7.2 KiB
JavaScript
150 lines
7.2 KiB
JavaScript
// 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
|
|
// 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
|
|
// slot and the pass quietly reads the sharp image as its own base — which is
|
|
// exactly the identity the default (bx = 0) used to give, so a regression would
|
|
// look like nothing happening. So the pass is compiled and rendered for real:
|
|
// the frame and the base are held at two different flat values, and the pixel
|
|
// has to land on the somewhere-between value the ramp over THAT base predicts.
|
|
//
|
|
// node scripts/tone-base-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 dir = mkdtempSync(join(tmpdir(), 'tone-base-check-'));
|
|
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
|
|
writeFileSync(
|
|
join(dir, 'toneShader.mjs'),
|
|
transpile('../shared/utils/toneShader.ts').replace(
|
|
/^import .*from ['"]\.\/colorUtils['"];$/m,
|
|
'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";',
|
|
),
|
|
);
|
|
const {
|
|
TONE_SKSL, TONE_ANCHOR, TONE_BUMP_SLOPE_HALF, TONE_BUMP_SLOPE_QUARTER,
|
|
TONE_BASE_RADIUS, TONE_BASE_SIGMA, getToneUniforms, toneUniformArray, toneIsActive,
|
|
} = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href);
|
|
|
|
// The shader has to NAME a base child — a ring of taps would not need one.
|
|
assert.match(TONE_SKSL, /uniform shader base;/);
|
|
|
|
const CanvasKit = await CanvasKitInit({
|
|
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
|
|
});
|
|
const SIZE = 8;
|
|
|
|
function flat(value) {
|
|
const surf = CanvasKit.MakeSurface(SIZE, SIZE);
|
|
const paint = new CanvasKit.Paint();
|
|
paint.setColor(CanvasKit.Color(value, value, value));
|
|
surf.getCanvas().drawPaint(paint);
|
|
return surf.makeImageSnapshot();
|
|
}
|
|
|
|
const asChild = (image) =>
|
|
image.makeShaderOptions(
|
|
CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None,
|
|
);
|
|
|
|
// SHADOW full, everything else off — the knob the mottle was reported on.
|
|
const adjustments = { shadow: 10 };
|
|
const uniforms = getToneUniforms(adjustments);
|
|
assert.ok(toneIsActive(uniforms), 'SHADOW +100 no longer turns the pass on');
|
|
|
|
function render(srcValue, baseValue) {
|
|
const effect = CanvasKit.RuntimeEffect.Make(TONE_SKSL);
|
|
assert.ok(effect, 'TONE_SKSL does not compile');
|
|
const src = flat(srcValue);
|
|
const base = flat(baseValue);
|
|
const shader = effect.makeShaderWithChildren(uniforms ? toneUniformArray(uniforms) : [], [
|
|
asChild(src),
|
|
asChild(base),
|
|
]);
|
|
assert.ok(shader, 'the pass did not take two children — the base is not wired in');
|
|
const out = CanvasKit.MakeSurface(SIZE, SIZE);
|
|
const paint = new CanvasKit.Paint();
|
|
paint.setShader(shader);
|
|
out.getCanvas().drawPaint(paint);
|
|
const pixels = out.makeImageSnapshot().readPixels(0, 0, {
|
|
width: SIZE, height: SIZE, colorType: CanvasKit.ColorType.RGBA_8888,
|
|
alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB,
|
|
});
|
|
return pixels[0];
|
|
}
|
|
|
|
// The ramp, in the same arithmetic the shader runs: the identity plus one bump
|
|
// per knob, (1-u^2)^2 with u the distance to the knob's knot in half-widths.
|
|
// SHADOW carries half the anchor on 0.25 and its bump is the quarter-width one,
|
|
// so SHADOW +100 reads b + a1 * (1-u^2)^2 with a1 = 0.125 and u = (b-0.25)/0.25.
|
|
// (The ramp used to be straight between knots; see the note at the head of
|
|
// toneRamp for the Mach bands that retired it.)
|
|
const bump = (b, knot, halfWidth) => {
|
|
const u = (b - knot) / halfWidth;
|
|
const v = Math.max(0, 1 - u * u);
|
|
return v * v;
|
|
};
|
|
const ramp = (b) => b + TONE_ANCHOR * 0.5 * bump(b, 0.25, 0.25);
|
|
const srcValue = 128; // the pixel: 0.501961 encoded
|
|
const baseValue = 76; // its neighbourhood, darker: 0.298039
|
|
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 expected = Math.round(255 * (Math.min(1, Math.max(0, ramp(b))) + (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 over that base predicts ${expected}`,
|
|
);
|
|
// And the two are NOT the same value: if the pass had quietly read the sharp
|
|
// image as its base the answer would be the pixel itself, unchanged.
|
|
assert.ok(
|
|
Math.abs(got - srcValue) > 8,
|
|
`the pass returned the pixel (${got}) — it is reading its own sharp image as the base again`,
|
|
);
|
|
// With the base handed in as the sharp image the pass is the global move, which
|
|
// is what a mask (no neighbourhood of its own) needs it to be.
|
|
const selfBase = render(srcValue, srcValue);
|
|
assert.ok(
|
|
Math.abs(selfBase - srcValue) <= 1,
|
|
`a base equal to the pixel must be the identity, got ${selfBase} for ${srcValue}`,
|
|
);
|
|
|
|
// The guard, in the same arithmetic. BLACK and SHADOW at +100 share the lower
|
|
// half of the ramp and their bumps are read at their own steepest points, so the
|
|
// pair is the loosest two moves of a half can be: past a total slope of 1 the sum
|
|
// carries the curve BACKWARDS, which is a worse band than the seams it replaced.
|
|
// The guard holds the two under one and gives them up together past it.
|
|
const blackA = TONE_ANCHOR; // BLACK +100
|
|
const shadowA = TONE_ANCHOR * 0.5; // SHADOW +100
|
|
const lift = (b, k) => b + k * (blackA * bump(b, 0, 0.5) + shadowA * bump(b, 0.25, 0.25));
|
|
const hold = TONE_BUMP_SLOPE_HALF * blackA + TONE_BUMP_SLOPE_QUARTER * shadowA;
|
|
assert.ok(hold > 1, `BLACK and SHADOW +100 no longer reach the guard (hold ${hold})`);
|
|
const rises = (f) => Array.from({ length: 400 }, (_, i) => f((i + 1) / 400) > f(i / 400)).every(Boolean);
|
|
// Both controls: the pair really does fold without the guard, and the guard is
|
|
// what stops it — a guard on a sum that never folded would be dead weight.
|
|
assert.ok(!rises((b) => lift(b, 1)), `the unguarded pair no longer folds — the guard holds nothing`);
|
|
assert.ok(rises((b) => lift(b, 1 / hold)), 'BLACK and SHADOW both at +100 fold the ramp back on itself');
|
|
|
|
console.log(
|
|
`tone base ok: pixel ${srcValue} over a base of ${baseValue} -> ${got} ` +
|
|
`(ramp predicts ${expected}, sharp-base identity ${selfBase}); ` +
|
|
`radius ${TONE_BASE_RADIUS} of the frame, sigma ${TONE_BASE_SIGMA} of it`,
|
|
);
|