2e36acd18b
The develop preferred cam_xyz, whose rows are the XYZ of each camera channel and which nothing normalised: on the ORF this was reported on it left the frame green and blue (R/G 0.921, B/G 0.886) where the file's own preview sits at 1.013 / 0.841, and saturated reds came back as the dark purple the frame was reported for — a red pixel's green ran negative through a row that carries -2.64, so it clipped to 0 while the knee pulled red down with it. LibRaw hands back dcraw's own rgb_cam, already the camera -> sRGB transform with rows summing to one, and it is now applied as handed back: R/G 1.020 B/G 0.920, and the fit against the embedded preview follows to mean 167.5,164.8,138.5 against the preview's 167.0,164.8,138.6 and the camera's own JPEG's 165,162,133. Dividing rgb_cam by pre_mul — which carries that row normalisation — is what the frame before this one did instead, and it undoes it. The cam_xyz chain stays as the fallback for a file with no rgb_cam, with its rows normalised so a neutral frame opens neutral. invert3x3, unused since the frame stopped going through the chain, is dropped.
167 lines
9.6 KiB
JavaScript
167 lines
9.6 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*rgb = mix\(rgb, float3\(mx\), smoothstep\(0\.99, 1\.02, hi\)\);/
|
|
);
|
|
|
|
// 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 match = blocks && ref \? fitMatch\(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');
|
|
// 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 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.
|
|
//
|
|
// 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;/);
|
|
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, /uniform float4 t0;/);
|
|
// ...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);
|
|
|
|
// 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');
|