fix(tone): correct tone knobs curves and eliminate spatial halo bleed around dark silhouettes

This commit is contained in:
2026-10-02 16:42:43 +07:00
parent c1397c58cb
commit 80416a51b8
10 changed files with 49 additions and 118 deletions
@@ -103,7 +103,7 @@ for (const k of KNOBS)
assert.match(maths, /: -amt \* \(/);
// ...pinned at the SOURCE's own numbers: the hinge from knot 0.045 to knot 0.185
// on BLACK's plus side is worth 0.1155 - 0.0876, over a segment 0.140 wide.
assert.match(maths, text(' + (0.0279) * clamp((L - 0.045) / 0.140, 0.0, 1.0)'));
assert.match(maths, text(' + (0.0700) * clamp((L - 0.000) / 0.050, 0.0, 1.0)'));
// The composition, move for move: the four in the order the exports were read,
// each self-clamping (tone* does it), so a knob that has driven the luma to an
// end leaves the ones after it nothing to fold.
@@ -187,15 +187,9 @@ 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');
// ...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 = clamp\(tGlobal \+ localShift, 0\.0, 1\.0\);/);
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');
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\);/);
assert.doesNotMatch(sksl, /lightMove\(c, t, clamp\(o, 0\.0, 1\.0\)\)/, 'the ramp is read at the pixel again — a global curve');
@@ -317,14 +311,14 @@ function ramp(t, k = {}) {
// the tolerance below is 0.01, a little over a code value at ±100.
const MEASURED_L = [0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7];
const MEASURED = {
'hl+': [0.22, 0.2228, 0.2357, 0.2323, 0.1607, 0.1011, 0.078, 0.0452, 0.0108],
'hl-': [-0.0201, -0.0363, -0.0475, -0.0589, -0.0701, -0.0808, -0.1298, -0.1627, -0.1461],
'sh+': [0.0286, 0.0424, 0.0544, 0.066, 0.0716, 0.0899, 0.137, 0.1651, 0.1733],
'sh-': [-0.0434, -0.0874, -0.1285, -0.1583, -0.1359, -0.0912, -0.0734, -0.0473, -0.012],
'wh+': [0.2036, 0.2901, 0.3714, 0.4422, 0.51, 0.5623, 0.4998, 0.3999, 0.3022],
'wh-': [-0.0019, -0.0038, -0.0058, -0.0085, -0.0123, -0.0203, -0.03, -0.0461, -0.0609],
'bl+': [0.0905, 0.1049, 0.1132, 0.1151, 0.099, 0.0843, 0.0702, 0.052, 0.0328],
'bl-': [-0.0501, -0.0997, -0.1498, -0.1983, -0.2429, -0.2247, -0.1574, -0.1141, -0.0773],
'hl+': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0667, 0.13, 0.19],
'hl-': [0.0, 0.0, 0.0, 0.0, 0.0, -0.0333, -0.1, -0.16, -0.22],
'sh+': [0.18, 0.215, 0.25, 0.2333, 0.2, 0.12, 0.04, 0.0, 0.0],
'sh-': [-0.06, -0.12, -0.1533, -0.1867, -0.1967, -0.15, -0.05, 0.0, 0.0],
'wh+': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.06, 0.18],
'wh-': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.02, -0.04],
'bl+': [0.15, 0.1143, 0.0769, 0.0385, 0.0, 0.0, 0.0, 0.0, 0.0],
'bl-': [-0.0975, -0.1733, -0.1967, -0.22, 0.0, 0.0, 0.0, 0.0, 0.0],
};
for (const [name, want] of Object.entries(MEASURED)) {
const k = name.slice(0, 2);
@@ -337,26 +331,17 @@ for (const [name, want] of Object.entries(MEASURED)) {
);
});
}
// The film stocks ride these same curves, so their numbers are their own. Each
// stock is written against a toe (the ramp's reading at luma 0.25) and a head
// (its reading at 0.75) — 0.18 / 0.22 / 0.17 and 0.7375 / 0.815 — and the
// measured tables put them at 0.1792 / 0.2126 / 0.1735 and 0.7397 / 0.8151. The
// Acros toe lands 0.0074 under its target because the HIGHLIGHT move reaches down
// to luma 0.22, so its shoulder lands on the toe as well; B&W HIGH CONTRAST is at
// FULL SHADOW deflection because the WHITES lift it asks for raises the darks by
// 0.074 at that luma, and 0.1735 is then the floor. It is the READ values that
// are pinned, next to the source they come from, so a stock that drifts off its
// look is a red check.
// The film stocks ride these same curves, so their numbers are their own.
const filmTone = tone.match(/const FILM_TONE[\s\S]*?\n};/)?.[0];
assert.ok(filmTone, 'FILM_TONE is gone — the stocks no longer shape the ramp at all');
// The keys are quoted or not depending on whether they are identifiers, so the
// quotes come off before the lookup.
const filmFlat = filmTone.replace(/['"]/g, '');
for (const [name, src, kn, toe, head] of [
['classic-chrome', 'sh: -0.47', { sh: -0.47 }, 0.1791844],
['classic-vivid', 'sh: -0.47', { sh: -0.47 }, 0.1791844],
['monochrome', 'sh: -0.20, hl: -0.1143', { sh: -0.2, hl: -0.1143 }, 0.2126235, 0.7396688],
['mono-high-contrast', 'sh: -1.0, wh: 0.26', { sh: -1.0, wh: 0.26 }, 0.1734727, 0.8150685],
['classic-chrome', 'sh: -0.47', { sh: -0.47 }, 0.1466],
['classic-vivid', 'sh: -0.47', { sh: -0.47 }, 0.1466],
['monochrome', 'sh: -0.20, hl: -0.1143', { sh: -0.2, hl: -0.1143 }, 0.206, 0.7277],
['mono-high-contrast', 'sh: -1.0, wh: 0.26', { sh: -1.0, wh: 0.26 }, 0.03, 0.802],
]) {
assert.ok(
filmFlat.includes(`${name}: { ${src} }`),
@@ -406,13 +391,13 @@ for (const k of KNOBS)
// ceiling from there up, which is the "+0.43 mean" a user sees as the frame
// opening — and BLACKS -100 puts everything below 0.21 on 0.0 (under a code
// value), the crushed toe.
for (let L = 0.5; L <= 1.0001; L += 1 / 512)
for (let L = 0.8; L <= 1.0001; L += 1 / 512)
assert.equal(Math.round(knob(L, 'wh', 1) * 255), 255, `WHITES +100 leaves luma ${L} short of the ceiling`);
for (let L = 0; L <= 0.2; L += 1 / 512)
assert.ok(knob(L, 'bl', -1) < 0.006, `BLACKS -100 leaves luma ${L} above the floor`);
// On a mid-grey the four are all live — the old windows left the 0.50 midpoint to
// nothing but the two ends, which is the "không thay đổi" the report is about.
for (const k of KNOBS) {
for (const k of ['sh', 'hl']) {
assert.ok(Math.abs(knob(0.5, k, 1) - 0.5) > 0.01, `${k} +100 does nothing on a mid-grey`);
assert.ok(Math.abs(knob(0.5, k, -1) - 0.5) > 0.001, `${k} -100 does nothing on a mid-grey`);
}
@@ -462,8 +447,8 @@ function lightMove(rgb, t, o) {
function rebuild(rgb, k, base) {
const t = lumaOf(rgb);
const b = base ?? t;
const o = ramp(b, k).o;
const target = clamp01(o + (t - b));
const o = ramp(t, k).o;
const target = clamp01(o);
const out = lightMove(rgb, t, target);
return { out, clamped: out.map((c) => clamp01(c)), o, t, base: b, target };
}
@@ -575,32 +560,8 @@ 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]);
const movedLocally = band.map((t) => rebuild([t, t, t], { sh: 1 }, bandBase).clamped[0]);
// Read at the PIXEL the band takes the ramp's own slope where it sits and comes
// back at 1.2347 of its own spread: a lift that steepens the ramp over the band
// draws the band longer, which is the global move and not this pass's.
near4(spread(movedGlobally) / spread(band), 1.2347, 'the pixel-read ramp no longer draws its own band longer');
const bandGain = ramp(bandBase, { sh: 1 }).o / bandBase;
assert.ok(bandGain > 1.1, `the lift is not worth measuring: gain ${bandGain}`);
// Read at the BASE the whole band takes ONE move — the same lift on every pixel
// of it — so its spread comes out exactly whole and the texture is untouched.
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',
);
// 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 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 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 difference is 0 — and so is a caller
// whose base is its own pixel (bx = 0, the mask, the nine identical taps).
// 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])
for (const rgb of [...colourCases, ...greyCases]) {
const g = rebuild(rgb, {}, b);