Files
RecipesCam/docker/frontend/scripts/raw-develop-check.mjs
T
3dtours ce691aa8cb 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.
2026-10-06 10:45:15 +07:00

180 lines
10 KiB
JavaScript

// LibRaw subtracts the black level itself, whatever `noAutoScale` says, so the
// develop must not do it again: a second subtraction drained red and blue — the
// channels `cam_mul` lifts most — and turned every Sony ARW green. This pins that
// down on the source, since the develop itself only runs in a browser (LibRaw
// worker + CanvasKit).
//
// node scripts/raw-develop-check.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const src = readFileSync(new URL('../src/engine/rawDevelop.ts', import.meta.url), 'utf8');
const sksl = src.match(/const RAW_DEVELOP_SKSL = `([\s\S]*?)`;/)?.[1];
assert.ok(sksl, 'RAW_DEVELOP_SKSL not found');
// The prose above the code talks about the black level, so drop the comments.
const code = sksl.replace(/\/\/[^\n]*/g, '');
// The plane comes in already black-subtracted, so its only scale is the white
// level less the black level, handed over as one inverse.
assert.match(code, /uniform float gain;/);
assert.doesNotMatch(code, /uniform float4 black;/);
assert.doesNotMatch(code, /-\s*black/, 'the shader subtracts the black level again');
assert.match(code, /float3 n = max\(p\.rgb \* gain, 0\.0\);/);
assert.match(code, /float3 lin = n \* mul\.rgb;/);
// The WB gains, applied where the sensor has already clipped, are what leaves the
// blown areas magenta, so the shader has to read the sensor's own levels — before
// the gains — and desaturate the pixel towards its own value as the clip is
// approached. Measured on the FX30 ARW: the camera's own JPEG has an all-white top
// percentile (R/G 0.995, B/G 0.999), the develop without this line came back at
// R/G 1.017, B/G 0.862 — a warm tint on every blown area.
//
// The clip to read is the one the gains make as well as the sensor's own: on a body
// whose gains lift red and blue (the Ricoh GR: `cam_mul` [2.64, 1, 1.73]) a blown
// sky crosses the white level at 0.38 of the raw range in red and 0.58 in blue while
// green only crosses at 1.0, so a gate read on the sensor's levels alone stayed shut
// across the whole sky. Measured on that frame in the app against the camera's own
// JPEG: mean dRGB +1.2, -5.9, -6.1 with the sensor's clip alone, +0.8, +1.0, +1.1
// 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*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
// buffer the shader reads as `gain`, and its divisor has to be the white level
// the frame itself ran out at — `maximum` alone left every frame a stop bright,
// and a fixed factor two only fitted the two bodies it was measured on. The
// probe and its own check live in src/engine/sensorWhite.ts.
assert.match(src, /^ *uniforms\[0\] = SAMPLE_MAX \/ sensorWhite\(data, cd\.maximum, cd\.black\);$/m);
assert.match(src, /^import \{ sensorWhite \} from '\.\/sensorWhite';$/m);
// On the FX30 that inverse is 2.065 (65535 / (2 (16380 - 512))), against the
// 4.13 the white level alone gives. The camera's own JPEG lands on the 2.065:
// 0.05% of its pixels at pure white against the develop's 0.76% at 4.13 — which
// is also what the probe has to fall back to on a frame with nothing blown.
assert.ok(65535 / (2 * (16380 - 512)) - 2.065 < 0.001, 'the white level factor dropped out');
const probe = readFileSync(new URL('../src/engine/sensorWhite.ts', import.meta.url), 'utf8');
assert.match(
probe,
/if \(top < maximum - black\) return legacy;/,
'a frame with nothing blown no longer falls back to the fixed factor'
);
// What a RAW opens as is the develop of its own sensor data, fitted to the
// preview the camera wrote into the file — so the file opens at the colour the
// body chose and at the resolution its sensor has. The preview is the reference
// and the fallback, never the frame: a file with no preview of its own is not
// fitted, and there is no body table behind the fit any more (a table fitted on
// 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 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 = 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;/);
// The fit has to land on the encoded value in float, not as an 8-bit colour filter
// painted over the frame afterwards: the fit carries an exposure, and a channel it
// lifted past the white level was cut where it stood — blue first, the channel the
// WB gains lift most — which is what left the bright end of the frame short of
// 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.
//
// 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, /float tone\(float4 w, float x\) \{/);
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\(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
// camera's own JPEG beside it. With the flag off the same develop returns
// 6020x4024, and every one of the eight bodies checked doubled its frame.
assert.match(src, /^ *halfSize: false,$/m, 'the develop is half-size again');
// The matrix the develop applies is the camera->sRGB one LibRaw already hands
// back, not one derived from `cam_xyz` beside it. They disagree on the Olympus
// ORF this was reported on: rgb_cam x cam_mul lands on R/G 1.021 B/G 0.920
// against the file's own preview's 1.013 / 0.841, the cam_xyz chain on
// 0.921 / 0.886 — green, and blue for a scene the camera left neutral, which is
// the cast the frame opened with. And the matrix must not be divided by `pre_mul`
// again: that undoes the row-sum-to-one normalisation dcraw built into rgb_cam.
{
const body = src.match(/const XYZ_TO_SRGB = \[[\s\S]*?\n\}\n/)?.[0];
assert.ok(body, 'getCamToSrgbMatrix not found');
// The file is TypeScript; the two annotations in this block are all that stop
// `new Function` from reading it.
const js = body.replace(/: any/g, '').replace(/: number\[\]\[\]/g, '');
const getCamToSrgbMatrix = new Function(`${js}; return getCamToSrgbMatrix;`)();
const rgb_cam = [
[1.631760597229004, -0.39896339178085327, -0.23279713094234467],
[-0.298429936170578, 1.7056032419204712, -0.4071732759475708],
[0.02502557262778282, -0.45518237352371216, 1.4301568269729614],
];
const cam_xyz = [
[0.9422, -0.3258, -0.0711],
[-0.2655, 1.0898, 0.2015],
[-0.0512, 0.1354, 0.5512],
];
const pre_mul = [2.0312116146087646, 0.9462084174156189, 1.4558180570602417];
assert.deepEqual(
getCamToSrgbMatrix({ rgb_cam, cam_xyz, pre_mul }),
rgb_cam.map((row) => row.slice(0, 3)),
'rgb_cam is no longer applied as LibRaw handed it back'
);
// A neutral camera triple stays neutral through what is left: every row of the
// matrix has to sum to one, or the frame opens with a cast of its own.
for (const [name, cd] of [
['rgb_cam', { rgb_cam }],
['cam_xyz', { cam_xyz }],
['nothing', {}],
]) {
const rows = getCamToSrgbMatrix(cd);
for (const row of rows) {
assert.ok(Math.abs(row[0] + row[1] + row[2] - 1) < 1e-6, `${name}: row sums to what white is not`);
}
}
}
console.log('raw-develop-check ok');