web: BLACK keeps the picture's texture and not the base it was read off, clarity stops the blur at an edge instead of at a colour, and a backup folder that refuses is handed back to the picker
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.
This commit is contained in:
@@ -172,7 +172,7 @@ assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again —
|
||||
// Nor a smoothstep through the knots: an S-curve bends the ramp by six code
|
||||
// values in the quarter-tones with every knob on zero, and this pass also runs
|
||||
// for the stock split tones and for DR alone.
|
||||
assert.match(sksl, /float o = base\n \+ blackA \* toneBump\(base, 0\.00, 0\.50\)\n \+ shadowA \* toneBump\(base, 0\.25, 0\.25\)\n \+ highA \* toneBump\(base, 0\.75, 0\.25\)\n \+ whiteA \* toneBump\(base, 1\.00, 0\.50\);/);
|
||||
assert.match(sksl, /float o = clamp\(base\n \+ blackA \* toneBump\(base, 0\.00, 0\.50\)\n \+ shadowA \* toneBump\(base, 0\.25, 0\.25\)\n \+ highA \* toneBump\(base, 0\.75, 0\.25\)\n \+ whiteA \* toneBump\(base, 1\.00, 0\.50\), 0\.0, 1\.0\);/);
|
||||
assert.doesNotMatch(sksl, /float lin\(/, 'the straight segments are back — a knot is an angle in a tone curve');
|
||||
// The guard. Two bumps share each half of the ramp and their steep sides can land
|
||||
// on the same stretch, so the two amplitudes of a half are held under a total
|
||||
@@ -184,11 +184,16 @@ assert.match(sksl, /float holdHi = 6\.1584 \* abs\(highA\) \+ 3\.0792 \* abs\(wh
|
||||
assert.match(sksl, /float kLo = holdLo > 1\.0 \? 1\.0 \/ holdLo : 1\.0;/);
|
||||
assert.match(sksl, /float kHi = holdHi > 1\.0 \? 1\.0 \/ holdHi : 1\.0;/);
|
||||
assert.doesNotMatch(sksl, /clamp\(0\.25 \+ 0\.25/, 'a knob is clamped against a neighbour knot again');
|
||||
// ...and the pixel rides the ratio of that: the DETAIL layer, kept whole. The
|
||||
// ramp's own luma is not what is handed to the rebuild any more — the pixel's is,
|
||||
// scaled by the neighbourhood's gain — or the move would be global again and the
|
||||
// ...and the pixel rides the neighbourhood's move with its own difference from
|
||||
// it: Base' + Detail, ADDED and not scaled. Multiplying by the gain o / base is
|
||||
// what this pass used to do and it takes the detail away exactly where a knob
|
||||
// takes the base down — at BLACK -100 the picture came back soft, and on a
|
||||
// monochrome frame (all three channels on the pixel's luma) it came back as the
|
||||
// blurred base itself. The ramp's own luma is still not what is handed to the
|
||||
// rebuild — the neighbourhood's is — or the move would be global again and the
|
||||
// band above SHADOW would be drawn flat, which is the whole bug.
|
||||
assert.match(sksl, /float target = base > 0\.0004 \? o \* t \/ base : t;/);
|
||||
assert.match(sksl, /float target = o \+ \(t - base\);/);
|
||||
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, /return lightMove\(c, t, clamp\(target, 0\.0, 1\.0\)\);/);
|
||||
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');
|
||||
@@ -472,12 +477,13 @@ function lightMove(rgb, t, o) {
|
||||
// The rebuild, with the base layer the shader now draws the ramp through. `base`
|
||||
// defaults to the pixel itself — the degenerate call, and the one a mask makes —
|
||||
// which lands `target` back on `o` and is the move this function had before there
|
||||
// was a base at all.
|
||||
// was a base at all. Away from that the pixel's own DIFFERENCE from the base is
|
||||
// ADDED to the neighbourhood's new luma (Base' + Detail), never scaled by it.
|
||||
function rebuild(rgb, k, base) {
|
||||
const t = lumaOf(rgb);
|
||||
const b = base ?? t;
|
||||
const o = ramp(b, k).o;
|
||||
const target = clamp01(b > 0.0004 ? (o * t) / b : t);
|
||||
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 };
|
||||
}
|
||||
@@ -576,12 +582,17 @@ for (const [rgb, knobs] of [
|
||||
// own spread at this deflection (0.50 when the ramp was
|
||||
// straight segments, the bump adds a little of the lift
|
||||
// back into the band).
|
||||
// read at the base every pixel of ONE neighbourhood takes the same gain,
|
||||
// o(base)/base, so the texture inside it rides out whole,
|
||||
// and the same lift lands on the pixels either way.
|
||||
// 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
|
||||
// at ITS OWN size and the same lift lands on the pixels
|
||||
// either way.
|
||||
//
|
||||
// Those are the two numbers the live probe reads off the deployed bundle (0.57
|
||||
// before, 0.78 after, over this frame); this is the same claim in arithmetic.
|
||||
// The spread here is kept WHOLE and not at 0.78 — this twin's band sits on one
|
||||
// base, where the reconstruction is exact by construction; on a real frame the
|
||||
// band spans many neighbourhoods and the live number is the one to read.
|
||||
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;
|
||||
@@ -590,23 +601,27 @@ const movedLocally = band.map((t) => rebuild([t, t, t], { sh: 1 }, bandBase).cla
|
||||
near(spread(movedGlobally) / spread(band), 0.480832, 'the pixel-read ramp no longer draws its own band flat');
|
||||
const bandGain = ramp(bandBase, { sh: 1 }).o / bandBase;
|
||||
assert.ok(bandGain > 1.1, `the lift is not worth measuring: gain ${bandGain}`);
|
||||
close(spread(movedLocally) / spread(band), bandGain, 'the band did not keep its texture under the lift');
|
||||
close(spread(movedLocally) / spread(band), 1, 'the band lost its texture under the lift — the detail is being scaled again');
|
||||
assert.ok(
|
||||
Math.abs(movedLocally[0] - band[0] - (ramp(bandBase, { sh: 1 }).o - bandBase)) < 1e-12,
|
||||
'the lift is no longer the neighbourhood’s',
|
||||
);
|
||||
assert.ok(
|
||||
spread(movedLocally) / spread(movedGlobally) > 1.5,
|
||||
`the base is not earning its keep: ${spread(movedLocally) / spread(movedGlobally)}x the global move's spread`
|
||||
);
|
||||
// The gain belongs to the NEIGHBOURHOOD, not to the pixel: two pixels of one base
|
||||
// The move belongs to the NEIGHBOURHOOD, not to the pixel: two pixels of one base
|
||||
// take the same one however far apart they sit, which is exactly what leaves the
|
||||
// difference between them standing. (Read at the pixel, the gain would be the
|
||||
// pixel's own o / t — the slope of the curve where the pixel happens to be.)
|
||||
// difference between them standing. (Read at the pixel, the move would be the
|
||||
// pixel's own o - t — the curve where the pixel happens to be.)
|
||||
for (const [lo, hi] of [[0.28, 0.44], [0.30, 0.48]]) {
|
||||
const a = rebuild([lo, lo, lo], { sh: 1 }, 0.38).clamped[0] / lo;
|
||||
const b = rebuild([hi, hi, hi], { sh: 1 }, 0.38).clamped[0] / hi;
|
||||
close(a, b, 'the gain is the pixel’s again, not the neighbourhood’s');
|
||||
const a = rebuild([lo, lo, lo], { sh: 1 }, 0.38).clamped[0] - lo;
|
||||
const b = rebuild([hi, hi, hi], { sh: 1 }, 0.38).clamped[0] - hi;
|
||||
close(a, b, 'the move is the pixel’s again, not the neighbourhood’s');
|
||||
}
|
||||
// Every knob on zero is the identity through the base path too, whatever base is
|
||||
// handed in — the ramp at b IS b, so the ratio is 1 — and so is a caller whose
|
||||
// base is its own pixel (bx = 0, the mask, the nine identical taps).
|
||||
// handed in — the ramp at b IS b, so the difference is 0 — and so is a caller
|
||||
// whose base is its own pixel (bx = 0, the mask, the nine identical taps).
|
||||
for (const b of [0.01, 0.1, 0.35, 0.7, 0.99])
|
||||
for (const rgb of [...colourCases, ...greyCases]) {
|
||||
const g = rebuild(rgb, {}, b);
|
||||
|
||||
@@ -97,12 +97,15 @@ const bump = (b, knot, halfWidth) => {
|
||||
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;
|
||||
const expected = Math.round(255 * ((ramp(b) * t) / b));
|
||||
// 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(
|
||||
|
||||
Reference in New Issue
Block a user