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); } `;