0ac9f2ddfa
The Fujifilm RAF this was reported on opened with magenta through every white in the frame. The develop itself was the cast, not the tone curve fitted over it: run flat — white balance, matrix, knee, encode, no curve at all — the frame's bright neutral blocks came back 232,196,201 against the 223,232,236 the camera's own preview has there. The matrix is not it either; every row of `rgb_cam` sums to one, so a neutral triple opens neutral. What was wrong is the balance it was given. Neither balance LibRaw hands over is the one the frame was rendered with. `pre_mul` on that frame is R/G 2.296, B/G 1.307; `cam_mul` is 1.795, 1.626; the balance the preview's own bright neutral blocks imply is 1.588, 1.266 — and that one is readable: average the sensor triple behind each block the camera left bright and near neutral, and the ratio that equalises those blocks is the frame's white. So the develop reads it there, before the first draw, from the same preview map the tone curve is fitted on, and keeps LibRaw's tags only as the fallback a frame with nothing neutral and bright to read (a clipped or dark frame) falls back to. `cam_mul` is tried before `pre_mul` on that path: as-shot before daylight, measured 6.73 -> 5.27 on this frame where the reverse gave 6.66. Measured end to end through the real develop (LibRaw worker + CanvasKit, 64x64 block grid against the file's own preview), mean dE00 / mean dRGB: HAGIANG-20170202-FUJIFILM-418.RAF 6.73 -> 4.89 +5.8,+0.9,+0.5 -> 0.7,-0.5,-0.7 _DSF3087.RAF 7.52 -> 6.78 +1.6,+0.1, 0.0 -> 0.5,-0.2,-0.7 DSCF1701.RAF 6.86 -> 6.54 ~0 -> -1.5,-1.7,-0.9 _DSF7273.RAF 5.13 -> 4.66 +2.4,+1.0,+1.5 -> 1.8,+0.9,+1.5 _3279791.ORF 5.81 -> 5.93 ~0 -> -0.4,-0.2,-0.5 P1010256.RW2 9.60 -> 9.56 +2.5,+0.6,+0.7 -> 1.6,+0.4,-0.1 The four Fujifilm frames lose the cast; the Olympus and Lumix frames hold. The two that open as their preview (Nikon NEF, DNG) still do. The bright neutral fifth of both Fujifilm frames now differs from the preview by the same figure on all three channels — a difference in brightness, not in colour, which is the tone curve's business and not this one's. The read is on the frame, not on the brand or its tags, so the bodies with no sample here (Sony, Canon, Ricoh, Leica) take the same path. scripts/raw-develop-check.mjs had gone stale against the module and is back in step with it — the highlight gate's threshold, the gain's divisor, the preview-WB read and the tag order are pinned, and previewWhiteBalance now carries a case that hands it a plane and a map and reads the balance back.
262 lines
14 KiB
JavaScript
262 lines
14 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.
|
|
// The gate opens below the white level, not at it: 0.82 against 0.98 was measured
|
|
// after the pairs above, so the tint reads on the way up rather than only once the
|
|
// frame has already run out of range.
|
|
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\.82, 0\.98, 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, /^ *const white = sensorWhite\(data, cd\.maximum, cd\.black\);$/m);
|
|
assert.match(src, /^ *uniforms\[0\] = SAMPLE_MAX \/ white;$/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, /extractEmbeddedJpeg\(bytes\);/);
|
|
assert.doesNotMatch(src, /cameraMatch/, 'the body table is back');
|
|
assert.doesNotMatch(code, /uniform float4 w0;/, 'the body table is back in the shader');
|
|
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');
|
|
assert.match(src, /const first = develop\(null\);/);
|
|
assert.match(src, /const jpeg = finalShot\.encodeToBytes\(Skia\.ImageFormat\.JPEG, 92\);/);
|
|
assert.match(src, /preview && preview\.length > 10000/);
|
|
|
|
// The balance the frame opens with is read off the file's own preview — the same
|
|
// reference the tone curve is fitted to — and only falls back to LibRaw's tags when
|
|
// the preview has nothing neutral and bright left to read. The tags alone were not
|
|
// the balance: on the Fujifilm RAF this was reported on, `pre_mul` was short of blue
|
|
// twice over (R/G 2.296 against the frame's 1.795, B/G 1.307 against 1.626) and left
|
|
// every white pink, `cam_mul` came closer, and the pair read off the preview matched
|
|
// it: block a* -1.9 against the preview's -2.1 where the tags gave 0.0 and -1.0,
|
|
// dE00 4.93 where they gave 6.73 and 5.27. And it holds across bodies for the same
|
|
// reason — it is read on the frame, not on the tags.
|
|
assert.ok(
|
|
src.indexOf('return cd.cam_mul;') < src.indexOf('return cd.pre_mul;'),
|
|
'pre_mul is preferred over the frame\'s own balance again'
|
|
);
|
|
assert.match(src, /^ *const refGrid = preview \? previewGrid\(preview, w, h, MATCH_GRID\) : null;$/m);
|
|
assert.match(
|
|
src,
|
|
/const rawMul = \(refGrid && previewWhiteBalance\(data, w, h, refGrid, MATCH_GRID, white\)\)\s*\?\? getBrandWbMultipliers\(brand, cd\);/
|
|
);
|
|
assert.match(src, /^export function previewWhiteBalance\($/m);
|
|
// ...and a balance read off too little of the frame is no balance: a frame that
|
|
// offers fewer than this many sensor pixels across its neutral bright blocks keeps
|
|
// the tags rather than a ratio read off noise.
|
|
assert.match(src, /if \(count < 4096 \|\| sums\[0\] <= 0 \|\| sums\[1\] <= 0 \|\| sums\[2\] <= 0\) 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 xyz = src.match(/^const XYZ_TO_SRGB = \[[\s\S]*?\n\];$/m)?.[0];
|
|
const fn = src.match(/^export function getCamToSrgbMatrix\([\s\S]*?\n\}\n/m)?.[0];
|
|
assert.ok(xyz && fn, 'getCamToSrgbMatrix not found');
|
|
// The file is TypeScript; the annotations in this block are all that stop
|
|
// `new Function` from reading it.
|
|
const js = (xyz + '\n' + fn)
|
|
.replace(/^export /m, '')
|
|
.replace(/: any/g, '')
|
|
.replace(/: number\[\]\[\]/g, '')
|
|
.replace(/\?: CameraBrand/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`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The balance read off the preview is arithmetic on the plane and the preview
|
|
// map, so both can be handed to it here. A plane whose red stands 1.5x its green
|
|
// and whose blue 1.2x, under a map the camera left bright and neutral, has one
|
|
// balance that would equalise those blocks, and it is the one that comes back.
|
|
{
|
|
const fn = src.match(/^export function previewWhiteBalance\([\s\S]*?\n\}\n/m)?.[0];
|
|
assert.ok(fn, 'previewWhiteBalance not found');
|
|
const previewWhiteBalance = new Function(
|
|
`${fn.replace(/^export /m, '').replace(/:\s*(ArrayLike<number>|Uint8Array|number\[\] \| null|number)/g, '')}; return previewWhiteBalance;`
|
|
)();
|
|
|
|
const N = 4;
|
|
const W = 128;
|
|
const H = 128;
|
|
const WHITE = 10000;
|
|
const plane = (r, g, b) => {
|
|
const d = new Uint16Array(W * H * 3);
|
|
for (let i = 0; i < W * H; i++) {
|
|
d[i * 3] = r;
|
|
d[i * 3 + 1] = g;
|
|
d[i * 3 + 2] = b;
|
|
}
|
|
return d;
|
|
};
|
|
const block = (g, bx, by, r, gg, b) => {
|
|
const i = (by * N + bx) * 4;
|
|
g[i] = r;
|
|
g[i + 1] = gg;
|
|
g[i + 2] = b;
|
|
g[i + 3] = 255;
|
|
};
|
|
const map = (f) => {
|
|
const g = new Uint8Array(N * N * 4);
|
|
for (let by = 0; by < N; by++) for (let bx = 0; bx < N; bx++) f(g, bx, by);
|
|
return g;
|
|
};
|
|
const allWhite = map((g, bx, by) => block(g, bx, by, 200, 200, 200));
|
|
|
|
const mul = previewWhiteBalance(plane(3000, 2000, 2400), W, H, allWhite, N, WHITE);
|
|
assert.ok(mul, 'a frame of bright neutral blocks has to read a balance');
|
|
assert.ok(
|
|
Math.abs(mul[0] - 2 / 3) < 1e-6 && mul[1] === 1 && Math.abs(mul[2] - 5 / 6) < 1e-6,
|
|
`read ${mul} off a 1.5x red / 1.2x blue plane`
|
|
);
|
|
|
|
// A block the camera did not leave neutral is not read, however bright it is:
|
|
// it carries the scene's colour, not the frame's balance.
|
|
const tinted = previewWhiteBalance(
|
|
plane(3000, 2000, 2400),
|
|
W,
|
|
H,
|
|
map((g, bx, by) => (bx ? block(g, bx, by, 200, 200, 200) : block(g, bx, by, 200, 120, 200))),
|
|
N,
|
|
WHITE
|
|
);
|
|
assert.deepEqual(tinted, mul, 'a tinted block moved the balance');
|
|
|
|
// Too little of the frame left to read is no balance at all — the caller keeps
|
|
// the tags rather than a ratio read off a handful of pixels.
|
|
assert.equal(previewWhiteBalance(plane(3000, 2000, 2400), 30, 30, allWhite, N, WHITE), null);
|
|
}
|
|
|
|
console.log('raw-develop-check ok');
|