// Highlight roll-off, both ends of the pipeline, as one soft knee: // // L' = L , L < T // L' = T + (L - T) / (1 + 2 S (L - T)) , L >= T // // The develop draws it on the sensor's own levels (T = 0.7, S = 1 / (2 (1 - T)), // which puts the asymptote on 1.0) so the two stops the sensor holds above its // white level are COMPRESSED into the frame instead of being thrown away by the // old fade-to-white; the tone pass draws the same curve in linear light on the // value the develop and the camera match left, where -HL is the knob (T = 0.5, // S = |hl|). Before this, a blown sky left the develop on exactly 1.0 in all // three channels and HL had a flat white to pull on: measured on DSC03453.ARW, // where the camera's own preview is clipped the develop's luma was 253.4 with a // standard deviation of 2.4, against 251.2 / 10.0 through the knee. // // Both are SkSL, so the shape is pinned on the source; the curve itself is // checked as arithmetic, with the constants the source is asserted to carry. // // node scripts/highlight-knee-check.mjs import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; const develop = readFileSync(new URL('../src/engine/rawDevelop.ts', import.meta.url), 'utf8'); const tone = readFileSync(new URL('../shared/utils/toneShader.ts', import.meta.url), 'utf8'); // The develop: knee on the sensor's max channel, the channel ratios kept, so the // hue and the saturation of a blown area survive the pull-down. const dev = develop.match(/if \(mx > 0\.7\) \{[\s\S]*?\n \}/)?.[0]; assert.ok(dev, 'the develop knee is gone — a blown sky is flat 1.0 again'); assert.match(dev, /float over = mx - 0\.7;/); assert.match(dev, /rgb \*= \(0\.7 \+ over \/ \(1\.0 \+ over \* 3\.3333\)\) \/ mx;/); assert.doesNotMatch(develop, /mix\(rgb \/ mx, float3\(1\.0\)/, 'the fade-to-white is back'); // The tone pass: the knee runs in LINEAR light and before the luma is read, the // lift keeps its headroom weight — and the recovery must not also ride the // additive term, which would darken the white the knee protects. assert.match(tone, /if \(hl < 0\.0\) \{/); assert.match(tone, /vec3 lin = toLinear\(rgb\);/); assert.match(tone, /float l0 = dot\(lin, vec3\(0\.2126, 0\.7152, 0\.0722\)\);/); assert.match(tone, /lin \*= \(0\.5 \+ over \/ \(1\.0 \+ S \* over \* 2\.0\)\) \/ l0;/); assert.match(tone, /rgb = clamp\(toEncoded\(lin\), 0\.0, 1\.0\);/); assert.match(tone, /float o = t \+ max\(hl, 0\.0\) \* hlMask \* \(1\.0 - t\) \+ sh \* 0\.34 \* shMask;/); // The transfer pair has to be the accurate one, or the knee is drawn in a space // that is not linear at all. assert.match(tone, /return mix\(c \/ 12\.92, pow\(\(c \+ 0\.055\) \/ 1\.055, vec3\(2\.4\)\), step\(vec3\(0\.04045\), c\)\);/); // The arithmetic. T = 0.7 / S = 1 / (2 (1 - T)) is the develop's pair (S is what // puts the asymptote on 1.0: T + 1/(2S) = 1); T = 0.5 with S = 1 is the top of // the tone pass's knob. const knee = (l, T, S) => (l < T ? l : T + (l - T) / (1 + 2 * S * (l - T))); for (const [T, S] of [ [0.7, 1 / (2 * (1 - 0.7))], [0.5, 0.25], [0.5, 1], ]) { // Below the knee the frame is untouched, and the curve is continuous and C1 at // T — slope 1 on both sides — so there is no seam for a later pass to mask. assert.equal(knee(T - 0.2, T, S), T - 0.2); assert.equal(knee(T, T, S), T); const slope = (x) => (knee(x + 1e-6, T, S) - knee(x, T, S)) / 1e-6; assert.ok(Math.abs(slope(T) - 1) < 1e-3, `seam at T=${T}: slope ${slope(T)}`); // Monotone, and never a brightening: a recovery slider that lifted a highlight // would be a lift in disguise, and an inverted pair of pixels is a visible edge. let prev = -Infinity; for (let l = 0; l <= 2; l += 1 / 512) { assert.ok(slope(l) > 0, `inverted at ${l} (T=${T}, S=${S})`); assert.ok(knee(l, T, S) <= l + 1e-9, `brightened ${l} -> ${knee(l, T, S)}`); assert.ok(knee(l, T, S) >= prev); prev = knee(l, T, S); } // The asymptote: everything the sensor held above the knee lands under it. // The develop's pair puts it exactly on 1.0, the tone pass's S = 1 on 0.75. assert.ok(Math.abs(knee(1e6, T, S) - (T + 1 / (2 * S))) < 1e-4); } assert.ok(Math.abs(0.7 + 1 / (2 * (1 / (2 * (1 - 0.7)))) - 1) < 1e-9, 'the develop plateau left 1.0'); // ...and the same pair in the encoded domain, which is the domain the develop // hands over: mx = 1.0 (the white level) lands on 237, the sensor's own plateau // (1.93, see the gain above) on 248 — a ramp of a dozen code values where the // old fade-to-white left nothing above 250 at all. const enc = (x) => (x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055); const DEV_S = 1 / (2 * (1 - 0.7)); assert.equal(Math.round(enc(knee(1.0, 0.7, DEV_S)) * 255), 237); assert.equal(Math.round(enc(knee(1.93, 0.7, DEV_S)) * 255), 248); console.log('highlight-knee-check ok');