web: open a RAW at the resolution of its sensor, not at the quarter of it

LibRaw's half-size demosaic was on. The Ricoh GR's own DNG (D0004128.DNG)
developed to 3010x2012 while the JPEG written beside it in the same second is
6000x4000, and the Fuji's RAF to 3008x2007 against its own 6000x4000 -- the
quarter was the flag, not the file. With `halfSize: false` the same develop
returns 6020x4024 and it is the sensor's frame on every body tried:

  D0004128.DNG  6020x4024   IMGP6916.DNG        6028x4024
  DSCF1701.RAF  6016x4014   _DSC0009.ARW        6024x4024
  AFXT2721.RAF  6246x4170   Nikon-D850 NEF      6216x4136
  _GDN0447.NEF  4284x2844   P1010607.RW2        3472x3472
  5G4A9396.CR2  2880x1920

Nine files, 27s to 155s a develop on one core. Checked through the app
itself, not only through LibRaw: photo-dims 6020x4024 on the DNG against
6000x4000 on the JPEG, both err none.

The colour it opens with is now fitted per file to the preview the camera wrote
into it (previewMatch.ts): a 3x3 over a block grid of the develop against the
same grid of that preview, then one cubic a channel for what the 3x3 leaves.
The offline per-body table this replaces (cameraMatch.ts) stopped matching the
moment the path under it changed -- its rows no longer summed to 1 once the
highlight knee landed ahead of it -- and a body with a row opened with a cast
one without did not. The file's own preview does not age.

The white level the gain carries is the frame's own plateau rather than
`maximum` (sensorWhite.ts), a factor of 1.89 to 2.00 out; without it every
frame opened a stop bright and a body that sat lower (X-Trans, 1.892) never
reached the highlight desaturation at all.

The desaturation gate reads the gain-lifted levels as well as the sensor's,
which is the whole of the magenta: on a body whose cam_mul lifts red and blue
(the GR's [2.64, 1, 1.73]) a blown sky crosses the white level at 0.38 of the
raw range in red while green crosses at 1.0, so a gate read on the sensor's
levels alone stayed shut across it. Measured in the app against the camera's
own JPEG, mean dRGB over a 16x16 block grid: +1.20, -5.95, -6.11 with the
sensor's clip alone, +0.21, +0.24, +0.47 with both, mean |dL| 21.5 against
10.3. The same grid on the Fuji comes back balanced (+4.7, +5.0, +3.6) and best
aligned at offset 0,0.

-HL is recovery and +HL is a lift, so they are different moves now: recovery is
the doc's soft knee in linear light over the top half, which is the only term
in the tone shader that is not a shift and the only one that can put detail
back into a blown sky rather than merely darken it.

The four checks pin the develop down where it can only run in a browser:
raw-develop-check, preview-match-check, white-level-check, highlight-knee-check.
This commit is contained in:
2026-09-28 15:24:37 +07:00
parent b824308182
commit 224ff0b935
9 changed files with 940 additions and 163 deletions
@@ -0,0 +1,86 @@
// Highlight roll-off, both ends of the pipeline, as one soft knee:
//
// L' = L , L < T
// L' = T + (L - T) / (1 + 2 S (L - T)) , L >= T
//
// The develop draws it on the sensor's own levels (T = 0.7, S = 1 / (2 (1 - T)),
// which puts the asymptote on 1.0) so the two stops the sensor holds above its
// white level are COMPRESSED into the frame instead of being thrown away by the
// old fade-to-white; the tone pass draws the same curve in linear light on the
// value the develop and the camera match left, where -HL is the knob (T = 0.5,
// S = |hl|). Before this, a blown sky left the develop on exactly 1.0 in all
// three channels and HL had a flat white to pull on: measured on DSC03453.ARW,
// where the camera's own preview is clipped the develop's luma was 253.4 with a
// standard deviation of 2.4, against 251.2 / 10.0 through the knee.
//
// Both are SkSL, so the shape is pinned on the source; the curve itself is
// checked as arithmetic, with the constants the source is asserted to carry.
//
// node scripts/highlight-knee-check.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const develop = readFileSync(new URL('../src/engine/rawDevelop.ts', import.meta.url), 'utf8');
const tone = readFileSync(new URL('../shared/utils/toneShader.ts', import.meta.url), 'utf8');
// The develop: knee on the sensor's max channel, the channel ratios kept, so the
// hue and the saturation of a blown area survive the pull-down.
const dev = develop.match(/if \(mx > 0\.7\) \{[\s\S]*?\n \}/)?.[0];
assert.ok(dev, 'the develop knee is gone — a blown sky is flat 1.0 again');
assert.match(dev, /float over = mx - 0\.7;/);
assert.match(dev, /rgb \*= \(0\.7 \+ over \/ \(1\.0 \+ over \* 3\.3333\)\) \/ mx;/);
assert.doesNotMatch(develop, /mix\(rgb \/ mx, float3\(1\.0\)/, 'the fade-to-white is back');
// The tone pass: the knee runs in LINEAR light and before the luma is read, the
// lift keeps its headroom weight — and the recovery must not also ride the
// additive term, which would darken the white the knee protects.
assert.match(tone, /if \(hl < 0\.0\) \{/);
assert.match(tone, /vec3 lin = toLinear\(rgb\);/);
assert.match(tone, /float l0 = dot\(lin, vec3\(0\.2126, 0\.7152, 0\.0722\)\);/);
assert.match(tone, /lin \*= \(0\.5 \+ over \/ \(1\.0 \+ S \* over \* 2\.0\)\) \/ l0;/);
assert.match(tone, /rgb = clamp\(toEncoded\(lin\), 0\.0, 1\.0\);/);
assert.match(tone, /float o = t \+ max\(hl, 0\.0\) \* hlMask \* \(1\.0 - t\) \+ sh \* 0\.34 \* shMask;/);
// The transfer pair has to be the accurate one, or the knee is drawn in a space
// that is not linear at all.
assert.match(tone, /return mix\(c \/ 12\.92, pow\(\(c \+ 0\.055\) \/ 1\.055, vec3\(2\.4\)\), step\(vec3\(0\.04045\), c\)\);/);
// The arithmetic. T = 0.7 / S = 1 / (2 (1 - T)) is the develop's pair (S is what
// puts the asymptote on 1.0: T + 1/(2S) = 1); T = 0.5 with S = 1 is the top of
// the tone pass's knob.
const knee = (l, T, S) => (l < T ? l : T + (l - T) / (1 + 2 * S * (l - T)));
for (const [T, S] of [
[0.7, 1 / (2 * (1 - 0.7))],
[0.5, 0.25],
[0.5, 1],
]) {
// Below the knee the frame is untouched, and the curve is continuous and C1 at
// T — slope 1 on both sides — so there is no seam for a later pass to mask.
assert.equal(knee(T - 0.2, T, S), T - 0.2);
assert.equal(knee(T, T, S), T);
const slope = (x) => (knee(x + 1e-6, T, S) - knee(x, T, S)) / 1e-6;
assert.ok(Math.abs(slope(T) - 1) < 1e-3, `seam at T=${T}: slope ${slope(T)}`);
// Monotone, and never a brightening: a recovery slider that lifted a highlight
// would be a lift in disguise, and an inverted pair of pixels is a visible edge.
let prev = -Infinity;
for (let l = 0; l <= 2; l += 1 / 512) {
assert.ok(slope(l) > 0, `inverted at ${l} (T=${T}, S=${S})`);
assert.ok(knee(l, T, S) <= l + 1e-9, `brightened ${l} -> ${knee(l, T, S)}`);
assert.ok(knee(l, T, S) >= prev);
prev = knee(l, T, S);
}
// The asymptote: everything the sensor held above the knee lands under it.
// The develop's pair puts it exactly on 1.0, the tone pass's S = 1 on 0.75.
assert.ok(Math.abs(knee(1e6, T, S) - (T + 1 / (2 * S))) < 1e-4);
}
assert.ok(Math.abs(0.7 + 1 / (2 * (1 / (2 * (1 - 0.7)))) - 1) < 1e-9, 'the develop plateau left 1.0');
// ...and the same pair in the encoded domain, which is the domain the develop
// hands over: mx = 1.0 (the white level) lands on 237, the sensor's own plateau
// (1.93, see the gain above) on 248 — a ramp of a dozen code values where the
// old fade-to-white left nothing above 250 at all.
const enc = (x) => (x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055);
const DEV_S = 1 / (2 * (1 - 0.7));
assert.equal(Math.round(enc(knee(1.0, 0.7, DEV_S)) * 255), 237);
assert.equal(Math.round(enc(knee(1.93, 0.7, DEV_S)) * 255), 248);
console.log('highlight-knee-check ok');
@@ -0,0 +1,102 @@
// The per-file colour fit is pure arithmetic, so it can be checked here rather
// than in a browser: hand fitMatch a grid and the same grid through a known 3x3,
// and it has to hand that 3x3 back. Done that way because the function only ever
// runs inside the RAW develop, which needs LibRaw and CanvasKit — neither of
// which a node check has. The module is TypeScript, so it is transpiled on the
// fly out of the installed compiler (the repo's convention for checks: see
// raw-develop-check.mjs, which reads the shader source instead).
//
// node scripts/preview-match-check.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import ts from 'typescript';
const file = new URL('../src/engine/previewMatch.ts', import.meta.url);
const js = ts.transpileModule(readFileSync(file, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const { fitMatch, MATCH_GRID } = await import(
`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`
);
// A grid with colour in it: three independent ramps, none of them clipped and
// none at the floor, so every block carries something to fit. Independent on
// purpose — three correlated channels leave the 3x3 ill-conditioned, and a fit
// that comes back a percent off on such a scene says nothing about the code.
function scene() {
let s = 1;
const rnd = () => ((s = (s * 1103515245 + 12345) & 0x7fffffff) % 4096) / 4096;
const g = new Uint8Array(MATCH_GRID * MATCH_GRID * 4);
for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
for (let c = 0; c < 3; c++) g[i * 4 + c] = 25 + Math.round(rnd() * 190);
g[i * 4 + 3] = 255;
}
return g;
}
const M = [1.06, -0.05, 0.01, 0.02, 0.98, 0.03, -0.04, 0.06, 1.02];
const dev = scene();
const ref = new Uint8Array(dev.length);
for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
const e = [dev[i * 4], dev[i * 4 + 1], dev[i * 4 + 2]];
for (let r = 0; r < 3; r++) {
const v = M[r * 3] * e[0] + M[r * 3 + 1] * e[1] + M[r * 3 + 2] * e[2];
ref[i * 4 + r] = Math.max(0, Math.min(255, Math.round(v)));
}
ref[i * 4 + 3] = 255;
}
const fit = fitMatch(dev, ref);
assert.ok(fit, 'a fitted grid has to fit');
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
assert.ok(Math.abs(fit.m[r * 3 + c] - M[r * 3 + c]) < 0.01, `recovered ${fit.m[r * 3 + c]} for ${M[r * 3 + c]}`);
}
}
// This scene was made by a 3x3 and nothing else, so the curve fitted on what the
// 3x3 leaves has to come back flat — the identity, up to the rounding the scene
// carries.
assert.equal(fit.tone.length, 12);
for (let i = 0; i < 12; i++) {
const flat = i % 4 === 1 ? 1 : 0;
assert.ok(Math.abs(fit.tone[i] - flat) < 0.15, `tone ${i} came back ${fit.tone[i]}, not ${flat}`);
}
// Nothing to fit against: the camera clipped the whole frame, so no block says
// anything about its rendering, and the caller develops as the sensor left it.
assert.equal(fitMatch(dev, new Uint8Array(dev.length).fill(255)), null);
// A near-neutral frame through a matrix whose rows carry a negative element — what
// the bodies the fit used to refuse looked like (X-T3 -0.04, X100V -1.67 on rows
// that also carry +1.05 and +1.63). Three channels that share nearly all their
// content leave the normal equations near-singular, and the plain solve came back
// with a diagonal element at or below zero, which the caller read as a degeneracy
// and dropped the whole fit for. It is a colour: the ridge lifts the system back
// to definite and the fit has to come back a matrix.
function neutral() {
let s = 7;
const rnd = () => ((s = (s * 1103515245 + 12345) & 0x7fffffff) % 4096) / 4096;
const g = new Uint8Array(MATCH_GRID * MATCH_GRID * 4);
for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
const x = 40 + Math.round(rnd() * 180);
for (let c = 0; c < 3; c++) g[i * 4 + c] = Math.max(0, Math.min(255, x + Math.round((rnd() - 0.5) * 8)));
g[i * 4 + 3] = 255;
}
return g;
}
const N = [1.05, -1.67, 1.63, 0.02, 0.95, 0.03, -0.04, 0.06, 1.02];
const nDev = neutral();
const nRef = new Uint8Array(nDev.length);
for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
const e = [nDev[i * 4], nDev[i * 4 + 1], nDev[i * 4 + 2]];
for (let r = 0; r < 3; r++) {
const v = N[r * 3] * e[0] + N[r * 3 + 1] * e[1] + N[r * 3 + 2] * e[2];
nRef[i * 4 + r] = Math.max(0, Math.min(255, Math.round(v)));
}
nRef[i * 4 + 3] = 255;
}
const nFit = fitMatch(nDev, nRef);
assert.ok(nFit, 'a near-neutral frame has to fit, not come back null');
for (const v of nFit.m) assert.ok(Number.isFinite(v) && Math.abs(v) <= 4, `degenerate fit ${v}`);
console.log('preview-match-check ok');
@@ -0,0 +1,122 @@
// 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\.95, 1\.0, 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');
console.log('raw-develop-check ok');
@@ -0,0 +1,67 @@
// Where the develop's white level comes from (src/engine/sensorWhite.ts). The
// probe reads the frame instead of trusting a constant: the plane's ceiling is
// 1.89-2.00x `maximum - black` and which one depends on the body, and a frame
// that sat below the desaturation's 0.95 start came out tinted (Fuji X-Trans,
// measured 1.892). These pin the two states it has to tell apart — a blown
// plateau (spike plus gap) and a bright smooth sky (a top bin no bigger than the
// ones under it) — and the fall-backs on frames with nothing to measure.
//
// node scripts/white-level-check.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import ts from 'typescript';
const src = readFileSync(new URL('../src/engine/sensorWhite.ts', import.meta.url), 'utf8');
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const { sensorWhite } = await import(`data:text/javascript,${encodeURIComponent(js)}`);
const N = 1_000_000;
const MAXIMUM = 16383;
const BLACK = 512;
const LEGACY = 2 * (MAXIMUM - BLACK);
const FLOOR = 8192; // an unremarkable mid-tone the frame is mostly made of
// A frame of `N` mid-tone samples plus `counts` of them sitting in the nine bins
// from top-8 up to top: counts[8] is the top bin, counts[0] the one eight below.
function plane(top, counts) {
const data = new Uint16Array(N);
data.fill(FLOOR);
let i = 0;
for (let d = 0; d < 9; d++) for (let k = 0; k < counts[d]; k++) data[i++] = top - 8 + d;
return data;
}
// The sensor's own plateau: a pile at the top with nine near-empty bins under it.
assert.equal(sensorWhite(plane(30995, [1, 1, 1, 1, 1, 1, 1, 1, 3000]), MAXIMUM, BLACK), 30995, 'X-Trans plateau missed');
assert.equal(sensorWhite(plane(31779, [1, 1, 1, 1, 1, 1, 1, 1, 3000]), MAXIMUM, BLACK), 31779, 'Sony plateau missed');
// ...and the body that really does sit at twice the white level is read as such,
// not as the constant it used to be. Same answer either way, which is the point:
// the probe does not need to know which body it has.
assert.equal(sensorWhite(plane(LEGACY, [1, 1, 1, 1, 1, 1, 1, 1, 3000]), MAXIMUM, BLACK), LEGACY);
// A smooth sky fills the bins around the top far more than a clipped one does,
// and its top bin is a tail, not a step: no gap, no clip, no move of the white.
assert.equal(
sensorWhite(plane(30995, [400, 900, 1500, 2000, 2400, 2600, 2600, 2400, 2000]), MAXIMUM, BLACK),
LEGACY,
'a smooth top read as a clip'
);
// A bright flat wall sits exactly on one value with a big pile — but the bins
// under it are empty too, so that one IS a clip and is meant to move the white.
assert.equal(sensorWhite(plane(30995, [0, 0, 0, 0, 0, 0, 0, 0, 5000]), MAXIMUM, BLACK), 30995);
// Nothing to measure: a frame that never reached the plane's ceiling, and one
// whose only pile is a handful of hot pixels, both stay on the fixed factor two.
assert.equal(sensorWhite(plane(9000, [1, 1, 1, 1, 1, 1, 1, 1, 3000]), MAXIMUM, BLACK), LEGACY, 'an unblown frame moved the white');
assert.equal(sensorWhite(plane(30995, [0, 0, 0, 0, 0, 0, 0, 0, 5]), MAXIMUM, BLACK), LEGACY, 'hot pixels moved the white');
// The pile is a fraction of the plane, not a fixed count, so a small sensor with
// the same clipping is read the same way as a large one.
const small = new Uint16Array(100_000);
small.fill(FLOOR);
for (let i = 0; i < 40; i++) small[i] = 30995;
assert.equal(sensorWhite(small, MAXIMUM, BLACK), 30995, 'a small plane moved the threshold');
console.log('white-level-check ok');