diff --git a/docker/frontend/scripts/white-level-check.mjs b/docker/frontend/scripts/white-level-check.mjs index 7c81b50..db36af3 100644 --- a/docker/frontend/scripts/white-level-check.mjs +++ b/docker/frontend/scripts/white-level-check.mjs @@ -57,6 +57,19 @@ assert.equal(sensorWhite(plane(30995, [0, 0, 0, 0, 0, 0, 0, 0, 5000]), MAXIMUM, 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'); +// A hot pixel above the plateau is not the ceiling: the window is hung off the +// pile, not off the frame's largest sample. Measured on a Panasonic DMC-LX10 RW2 +// — a pile of 3.1M at 13855 with one sample at 15993 over it — reading the stray +// as the top left the nine bins under it empty, the frame fell back on the factor +// two (7908 against its own 13855) and opened 0.8 of a stop bright with its +// highlights clipped flat. +const strayed = new Uint16Array(N); +strayed.fill(FLOOR); +for (let i = 0; i < 400_000; i++) strayed[i] = 30995; +strayed[600_000] = 34000; +strayed[700_000] = 32400; +assert.equal(sensorWhite(strayed, MAXIMUM, BLACK), 30995, 'a stray sample above the plateau 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); diff --git a/docker/frontend/src/engine/sensorWhite.ts b/docker/frontend/src/engine/sensorWhite.ts index af52574..3fd1da4 100644 --- a/docker/frontend/src/engine/sensorWhite.ts +++ b/docker/frontend/src/engine/sensorWhite.ts @@ -8,6 +8,15 @@ // 0.95 of the *sensor* — never fires on a body that sits at 0.946. Reading it // off the frame is what makes one develop right for every body. // +// A plateau, not a max, and a pile rather than the frame's largest sample: a hot +// pixel sits hundreds of counts above the level the sensor stops at — measured on +// a Panasonic DMC-LX10 RW2, one sample at 15993 and one at 14665 over a pile of +// 3.1M at 13855 — and a window hung off that stray holds the stray alone, finds +// no pile in it, and hands back the factor two. There the frame's own level is +// 1.75x that fall-back, so the develop opened 0.8 of a stop bright, clipped to +// flat the sky the sensor had held, and the fit to the camera's preview could +// only pull the exposure back down: the highlight detail was already gone. +// // A plateau, not a max: the frame is read over the nine counts at its top, and // the white level is the highest of those that is a **cliff** — a count the one // below it does not come near. A clipped region piles at the level the sensor @@ -37,16 +46,19 @@ export function sensorWhite(data: ArrayLike, maximum: number, black: number): number { const legacy = 2 * (maximum - black); const n = data.length; + const floor = Math.max(32, Math.ceil(n * 1e-6)); + // Whole-plane counts, so the window can be hung off the highest count the frame + // piled at rather than off its largest sample — the one a hot pixel owns. + const counts = new Uint32Array(65536); + for (let i = 0; i < n; i++) { const v = data[i]; counts[v > 65535 ? 65535 : v]++; } let top = 0; - for (let i = 0; i < n; i++) if (data[i] > top) top = data[i]; + for (let v = 65535; v >= 0; v--) if (counts[v] >= floor) { top = v; break; } if (top < maximum - black) return legacy; const lo = top - 8; const near = new Uint32Array(9); - for (let i = 0; i < n; i++) { const v = data[i]; if (v >= lo) near[v - lo]++; } - const floor = Math.max(32, Math.ceil(n * 1e-6)); - // The top count can be a stray sample above the sensor's level; the pile under - // it is the level itself, so the scan starts at the top and takes the first - // count that is both a pile and a cliff over the count below it. + for (let k = 0; k < 9; k++) near[k] = counts[lo + k]; + // The scan takes the first count in the window that is both a pile and a cliff + // over the count below it. for (let k = 8; k >= 1; k--) { if (near[k] >= floor && near[k] >= near[k - 1] * 8) return lo + k; }