Files
RecipesCam/docker/frontend/scripts/raw-develop-check.mjs
T
3dtours 2e36acd18b fix(raw): open Olympus ORF at the colour of its own preview via rgb_cam
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.
2026-10-05 22:51:10 +07:00

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');