web: hand the RAW develop's plane to Skia as half, so the GPU keeps its shadows
An RGBA_F32 image with an sRGB tag comes back off the GPU backend sampled on a 1/255 grid; the same shader on a raster surface returns the floats untouched. The plane is raw/65535, so the shadows the black level is there to keep sit at 1e-3 and quantise to zero -- a 3010x2012 develop landed 41189 pixels under luma 2 with the dark end speckled blue/yellow, against none on the raster surface. A half is uploaded as float, so the plane stays exact either way. Rejects the earlier guess that the render target's colour space was to blame: gpu+rt-srgb and gpu+img-untagged came back byte-identical to gpu. scripts/half-check.mjs checks the conversion: the named encodings, and no plane value in a 14-bit sensor's range moving more than 4.8e-4 relative.
This commit is contained in:
@@ -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');
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<Uint8Array> {
|
||||
|
||||
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<Uint8Array> {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user