diff --git a/docker/frontend/scripts/half-check.mjs b/docker/frontend/scripts/half-check.mjs new file mode 100644 index 0000000..0f39cd2 --- /dev/null +++ b/docker/frontend/scripts/half-check.mjs @@ -0,0 +1,58 @@ +// The RAW develop hands its band plane to Skia as half (src/engine/halfFloat.ts) +// because the GPU backend quantises an F32 image to the 1/255 grid — which is +// exactly the grid the shadows live under. This checks the conversion itself: +// the named encodings, and that no plane value in the sensor's range moves more +// than the 16-bit plane step it came from. +// +// node scripts/half-check.mjs +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import ts from 'typescript'; + +const src = readFileSync(new URL('../src/engine/halfFloat.ts', import.meta.url), 'utf8'); +const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, +}).outputText; +const { f32ToF16 } = await import(`data:text/javascript,${encodeURIComponent(js)}`); + +function encode(...values) { + const src32 = Float32Array.from(values); + const out = new Uint16Array(values.length); + f32ToF16(src32, out, values.length); + return out; +} +// Independent decoder, from the IEEE 754 binary16 layout. +function decode(h) { + const sign = h & 0x8000 ? -1 : 1; + const exp = (h >> 10) & 0x1f; + const man = h & 0x3ff; + if (exp === 0x1f) return sign * Infinity; + if (exp === 0) return sign * man * 2 ** -24; + return sign * (1 + man / 1024) * 2 ** (exp - 15); +} + +const named = [ + [0, 0x0000], [1, 0x3c00], [0.5, 0x3800], [2, 0x4000], [-1, 0xbc00], + [2 ** -24, 0x0001], [1e-9, 0x0000], [70000, 0x7c00], [65504, 0x7bff], +]; +for (const [v, bits] of named) { + assert.equal(encode(v)[0], bits, `${v} -> expected ${bits.toString(16)}`); +} + +// Every value the plane can carry — a 14-bit sensor from its black floor to its +// white level — has to come back within half precision's 2^-11 relative error, +// and the shadows within a far finer absolute one, since that is where the +// develop's black level lives. 1e-3 relative is a tenth of an output level at +// white; the absolute bound covers the values whose relative error says nothing. +let worstRel = 0, worstAbs = 0; +for (let n = 0; n <= 16312; n++) { + const v = n / 65535; + const back = decode(encode(v)[0]); + worstAbs = Math.max(worstAbs, Math.abs(back - v)); + if (v > 0) worstRel = Math.max(worstRel, Math.abs(back - v) / v); +} +assert.ok(worstRel < 1e-3, `worst relative error ${worstRel}`); +assert.ok(worstAbs < 1e-4, `worst absolute error ${worstAbs} at raw ${Math.round(worstAbs * 65535)}`); +console.log(`plane 0..16312: worst ${worstRel.toExponential(2)} relative, ${worstAbs.toExponential(2)} absolute`); + +console.log('half-check ok'); diff --git a/docker/frontend/src/engine/halfFloat.ts b/docker/frontend/src/engine/halfFloat.ts new file mode 100644 index 0000000..74b8ee3 --- /dev/null +++ b/docker/frontend/src/engine/halfFloat.ts @@ -0,0 +1,42 @@ +// Float32 → half, for the one place in the pipeline that hands raw numbers to +// Skia: the RAW develop's band plane. +// +// Skia's GPU backend does not keep a float32 texture. A band uploaded as +// RGBA_F32 + an sRGB tag comes back sampled off a 1/255 grid — the shader asked +// for fract(v * 100) and got the wrap of round(v * 255) / 255 on every value +// (probed in Chromium/ANGLE against the same shader on a raster surface, which +// returned the floats untouched). The plane is raw/65535, so the shadows — the +// whole point of the develop's black level — sit at 1e-3 and quantise to zero: +// the browser's own develop came back with 41189 pixels at luma < 2 and the dark +// end speckled blue/yellow, while the same shader on a raster surface produced +// none. A half IS uploaded as float, so this keeps the plane exact on both. +// +// ponytail: 10-bit mantissa is 0.05% relative — a tenth of an output level at +// the white point and finer than the plane's own 1/65535 step in the shadows. +// Take RGBA_16161616 unorm instead if a develop ever has to be bit-exact. +const scratch = new Float32Array(1); +const scratchBits = new Uint32Array(scratch.buffer); + +export function f32ToF16(src: Float32Array, dst: Uint16Array, count: number): void { + for (let i = 0; i < count; i++) { + scratch[0] = src[i]; + const x = scratchBits[0]; + const sign = (x >> 16) & 0x8000; + let exp = (x >> 23) & 0xff; + const man = x & 0x7fffff; + if (exp === 0xff) { dst[i] = sign | 0x7c00; continue; } // inf / nan + if (exp < 103) { dst[i] = sign; continue; } // underflows the subnormal range + if (exp < 113) { // subnormal half + const shift = 113 - exp; + const full = man | 0x800000; + dst[i] = sign | ((full >> (shift + 13)) + ((full >> (shift + 12)) & 1)); + continue; + } + if (exp > 142) { dst[i] = sign | 0x7c00; continue; } // overflows to inf + exp -= 112; + const rest = man & 0x1fff; // round to nearest even + // The mantissa's rounding carry has to reach the exponent, so add — `|` + // would drop it, since bit 10 is set in most exponents. + dst[i] = sign | ((exp << 10) + (man >> 13) + (rest > 0x1000 || (rest === 0x1000 && ((man >> 13) & 1)) ? 1 : 0)); + } +} diff --git a/docker/frontend/src/engine/rawDevelop.ts b/docker/frontend/src/engine/rawDevelop.ts index 32fbaaf..4388568 100644 --- a/docker/frontend/src/engine/rawDevelop.ts +++ b/docker/frontend/src/engine/rawDevelop.ts @@ -18,6 +18,7 @@ // with an OffscreenCanvas if the develop ever blocks the UI visibly. import LibRaw from 'libraw-wasm'; import { cameraMatch, type Mat3 } from './cameraMatch'; +import { f32ToF16 } from './halfFloat'; import { Skia } from './skiaShim'; // What `imageData()` returns for the settings below: 16-bit, 3 channels, with @@ -136,6 +137,9 @@ export async function developRaw(bytes: Uint8Array): Promise { const bandH = Math.max(1, Math.min(h, Math.floor(BAND_PIXELS / w))); const f32 = new Float32Array(w * bandH * 4); + // The band goes up as half, not float32: the GPU backend puts an F32 image + // on the 1/255 grid and the shadows quantise to black (see halfFloat.ts). + const half = new Uint16Array(w * bandH * 4); for (let y0 = 0; y0 < h; y0 += bandH) { const rows = Math.min(bandH, h - y0); let o = 0; @@ -145,10 +149,11 @@ export async function developRaw(bytes: Uint8Array): Promise { f32[o++] = data[i + 2] / SAMPLE_MAX; f32[o++] = 1; } + f32ToF16(f32, half, w * rows * 4); const band = Skia.Image.MakeImage( - { width: w, height: rows, colorType: Skia.ColorType.RGBA_F32, alphaType: Skia.AlphaType.Unpremul }, - new Uint8Array(f32.buffer, 0, w * rows * 16), - w * 16 + { width: w, height: rows, colorType: Skia.ColorType.RGBA_F16, alphaType: Skia.AlphaType.Unpremul }, + new Uint8Array(half.buffer, 0, w * rows * 8), + w * 8 ); if (!band) throw new Error('band image failed'); const child = band.makeShaderOptions(