From ce691aa8cb6c50f34f065581f8b39644fa2152aa Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 6 Oct 2026 10:45:15 +0700 Subject: [PATCH] fix(raw): keep the blown highlights neutral after the tone curve The blown gate draws a clipped pixel at its own maximum -- one value across the three channels, so the frame's highlights carry no cast -- but the per-channel tone curve runs after it and re-tints what the gate had just made neutral. One cubic a channel, fitted on a grid that has no block left to fit where the frame ran out (previewMatch drops the fully blown ones), so at the plateau the three curves agree only at 1.0 and part company either side of it. On the ORF this was reported on, the moonlit sky came back 254,255,255 and 253,255,254 -- red under green across a quarter of the frame. The gate is the develop's own statement that the pixel had no colour of its own, so it is re-read on the value that leaves the shader. The blown pixels now measure 254.95,254.94,254.97 (R/G 1.0000) against 253.44,254.94,254.51 (0.9941) before; their two most common triples, 254,255,255 at 149315 and 253,255,254 at 122127, collapse to 255,255,255 at 278099. The bright bands land at -0.0,-0.0,-0.0 against the embedded preview where they were -2.1,-0.0,-1.0, and midtones and the lower half are untouched. --- docker/frontend/scripts/raw-develop-check.mjs | 57 ++++++++++++------- docker/frontend/src/engine/rawDevelop.ts | 13 ++++- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/docker/frontend/scripts/raw-develop-check.mjs b/docker/frontend/scripts/raw-develop-check.mjs index 8cc11e8..b446d96 100644 --- a/docker/frontend/scripts/raw-develop-check.mjs +++ b/docker/frontend/scripts/raw-develop-check.mjs @@ -38,7 +38,7 @@ assert.match(code, /float3 lin = n \* mul\.rgb;/); // with both, mean |dL| 21.5 against 11.1. assert.match( code, - /float hi = max\(max\(n\.r, n\.g\), n\.b\);\s*hi = max\(hi, max\(max\(lin\.r, lin\.g\), lin\.b\)\);\s*rgb = mix\(rgb, float3\(mx\), smoothstep\(0\.99, 1\.02, hi\)\);/ + /float hi = max\(max\(n\.r, n\.g\), n\.b\);\s*hi = max\(hi, max\(max\(lin\.r, lin\.g\), lin\.b\)\);\s*float blown = smoothstep\(0\.99, 1\.02, hi\);\s*rgb = mix\(rgb, float3\(mx\), blown\);/ ); // The inverse is built on the CPU side and has to be the first uniform of the @@ -69,14 +69,23 @@ assert.match( // one develop stops matching when the develop changes under it, and it did: the // highlight knee left every body that had one with a cast). assert.match(src, /preview = await cameraPreview\(raw\);/); -assert.match(src, /const match = blocks && ref \? fitMatch\(blocks, ref\) : null;/); +assert.match(src, /const tone = blocks && ref \? toneMatch\(blocks, ref\) : null;/); assert.doesNotMatch(src, /cameraMatch/, 'the body table is back'); assert.doesNotMatch(code, /uniform float4 w0;/, 'the body table is back in the shader'); +// Not a 3x3 any more. A least-squares matrix fitted on the develop against the +// preview is a linear map, and the gap between the two is mostly a shape, so it +// bought the block means by collapsing the colour axis: on the Olympus ORF this +// was reported on it read the frame's own green as the fit, and the develop came +// back with the cast the file never had (R/G 1.021 B/G 0.920 against the file's +// own preview's 1.013 / 0.841). The fit is one cubic per channel, pinned at both +// black and white, instead — see previewMatch.ts. +assert.doesNotMatch(src, /fitMatch/, 'the 3x3 preview match is back'); +assert.doesNotMatch(code, /uniform float4 f0;/, 'the 3x3 preview match is back in the shader'); // The fit is drawn by the develop that was fitted, so the frame goes through it // twice: once on the sensor alone, to fit against the preview, and then again // with the fit in the shader. assert.match(src, /const first = develop\(null\);/); -assert.match(src, /const matched = match \? develop\(match\) : null;/); +assert.match(src, /const matched = tone \? develop\(tone\) : null;/); assert.match(src, /const jpeg = \(matched \?\? first\)\.encodeToBytes/); assert.match(src, /if \(preview\) return preview;/); assert.match(src, /if \(thumb\?\.format !== 'jpeg' \|\| !thumb\.data\?\.length\) return null;/); @@ -88,30 +97,34 @@ assert.match(src, /if \(thumb\?\.format !== 'jpeg' \|\| !thumb\.data\?\.length\) // blue. Measured on the A5100 frame, pixels at 255: 5.25% blue, 2.10% of them blue // alone through the filter, against 2.46% / 2.44% through this path. // -// No rolloff after the fit. The fit is luma-preserving, not white-preserving — -// its rows sum to ~0.79 / 1.07 / 0.92, so white leaves it past 1.0 in green only — -// and dividing by that max pulled red and blue down with it: highlights came out -// cyan and not one cell of the frame reached white on all three channels (0.0%) -// against 2.3% now and 3.3% in the camera's preview, dE00 7.1 against 6.6. -assert.match(code, /uniform float4 f0;/); +// The curve is pinned at both ends, so white is one of the values it is fitted on +// and a blown pixel still lands on white — there is no rolloff after it to pull a +// channel back down (the 3x3 this replaced needed one, and it turned every +// highlight cyan: 0.0% of the frame reached white on all three channels against +// 2.3% now and 3.3% in the camera's preview, dE00 7.1 against 6.6). assert.doesNotMatch(src, /drawMatched|colorMatrix/, 'the fit is painted through a colour filter again'); -assert.match(code, /float3 q = clamp\(float3\(dot\(f0\.xyz, e\), dot\(f1\.xyz, e\), dot\(f2\.xyz, e\)\), 0\.0, 1\.0\);/); -assert.doesNotMatch(code, /float mq = /, 'the rolloff after the fit is back'); - -// A 3x3 can only scale a channel and the gap to the camera is mostly a shape, so -// the frame leaves through the per-channel curve the fit carries as well. Without -// it the shadows stayed bright and green: measured on the A5100 frame, dE00 6.6 -// against 4.4, and at the bottom of the lightness range dL +13.8 with green -// +0.128 against +4.5 and +0.017 through the curve (both against the camera's own -// JPEG). assert.match(code, /float tone\(float4 w, float x\) \{/); -assert.match(code, /return half4\(half3\(tone\(t0, q\.r\), tone\(t1, q\.g\), tone\(t2, q\.b\)\), 1\.0\);/); +assert.match(code, /return clamp\(w\.x \+ x \* \(w\.y \+ x \* \(w\.z \+ x \* w\.w\)\), 0\.0, 1\.0\);/); +// Pinned at both ends is not pinned at the plateau. The three curves agree at 1.0 +// and part company either side of it, and where the frame ran out there is no block +// left to fit them together — every blown block is dropped, and every block the +// develop blows is dropped too — so the neutral the gate above drew comes apart on +// the way out: measured on the Olympus ORF, a 255,255,255 sky came back 254,255,255 +// (149k px) and 253,255,254 (122k px), red under green across a quarter of the +// frame. The gate is read again on the value that leaves, so the pixel the develop +// itself called blown leaves at the neutral it was drawn as. +assert.match(code, /float3 o = float3\(tone\(t0, e\.r\), tone\(t1, e\.g\), tone\(t2, e\.b\)\);/); +assert.match( + code, + /return half4\(half3\(mix\(o, float3\(max\(max\(o\.r, o\.g\), o\.b\)\), blown\)\), 1\.0\);/ +); assert.match(code, /uniform float4 t0;/); +assert.doesNotMatch(code, /float mq = /, 'the rolloff after the fit is back'); // ...and it is the tail of the uniform buffer, one float4 per channel, which the // second develop overwrites on the buffer it already built. -assert.match(src, /^ *uniforms\.set\(fit\?\.tone \?\? FLAT_TONE, 33\);$/m); -assert.match(src, /const uniforms = new Float32Array\(45\);/); -assert.match(src, /^ *uniforms\[21\] = f\[0\]; uniforms\[22\] = f\[1\]; uniforms\[23\] = f\[2\]; uniforms\[24\] = 0;$/m); +assert.match(src, /^ *uniforms\.set\(tone \?\? FLAT_TONE, 21\);$/m); +assert.match(src, /const uniforms = new Float32Array\(33\);/); +assert.match(src, /^import \{ toneMatch, FLAT_TONE, MATCH_GRID \} from '\.\/previewMatch';$/m); // A RAW opens at the sensor's own resolution, not at the quarter the half-size // demosaic reports: the GR's DNG came back 3010x2012 against the 6000x4000 of the diff --git a/docker/frontend/src/engine/rawDevelop.ts b/docker/frontend/src/engine/rawDevelop.ts index d00311e..77e605b 100644 --- a/docker/frontend/src/engine/rawDevelop.ts +++ b/docker/frontend/src/engine/rawDevelop.ts @@ -140,7 +140,8 @@ half4 main(float2 pos) { // mx is the value to hold: it is the pixel's own lightness. float hi = max(max(n.r, n.g), n.b); hi = max(hi, max(max(lin.r, lin.g), lin.b)); - rgb = mix(rgb, float3(mx), smoothstep(0.99, 1.02, hi)); + float blown = smoothstep(0.99, 1.02, hi); + rgb = mix(rgb, float3(mx), blown); // The overflow used to fade towards white — mix(rgb / mx, 1, 1 - 1 / mx) — // which put every pixel of a blown sky on exactly 1.0 and threw the two stops // the sensor held above white away with it: LIGHT's HIGHLIGHT row then had a @@ -173,7 +174,15 @@ half4 main(float2 pos) { // fitted against (see previewMatch.ts). No rolloff: white is one of the values // the curve is fitted on, and it is pinned there, so a blown pixel still lands on // white while the frame's own highlights stay where the knee above left them. - return half4(half3(tone(t0, e.r), tone(t1, e.g), tone(t2, e.b)), 1.0); + float3 o = float3(tone(t0, e.r), tone(t1, e.g), tone(t2, e.b)); + // ...and the neutral the gate above drew is what the curve undoes: one cubic a + // channel, fitted on a grid that has no block left to fit where the frame ran out + // (the blown blocks are excluded, see previewMatch), so at the plateau the three + // curves agree only at 1.0 and part company either side of it — measured on the + // ORF, a 255,255,255 sky came back 254,255,255 and 253,255,254, red under green + // across a quarter of the frame. The gate is the develop's own statement that the + // pixel had no colour of its own, so it is re-read here, on the value that leaves. + return half4(half3(mix(o, float3(max(max(o.r, o.g), o.b)), blown)), 1.0); } `;