web: give a RAW the colour its own camera would have given it

LibRaw is deliberately kept out of white balance and tone here, so a RAW opened
in the studio lands on the neutral demosaic — while the JPEG on the back of the
camera carried the body's own rendering. cameraMatch.ts holds that difference as
one 3x3 per body, fitted offline against the camera's own preview of the same
frame and applied in the develop shader right after the sRGB encode.

Measured on held-out blocks, mean CIEDE2000 against the camera preview:

  GR III  5.99 -> 4.28     X100V  8.19 -> 4.22
  GR II  11.68 -> 9.73     X100S  7.41 -> 7.27
  X-T3    9.03 -> 6.12

The matrix is fitted luma-preserving and the shader holds that exactly, so the
profile moves colour and never exposure: a preview that came out dark stays
dark, by design. What is left over is largely high-frequency (sharpening, noise
reduction, demosaic) — the error keeps falling as the blocks grow.

The fits are weak evidence on their own. Validation on colour charts came out
poor: the daylight chart is an Adobe DNG Converter export that aligns to the
body's own develop at only 0.785 correlation and gets worse with the profile
applied, and the tungsten chart is a different illuminant entirely. The honest
claims are the self-fit numbers above and that the X100S — whose cast was small
to begin with — barely moves.

Verified end to end through the real develop: an unfitted body (Sony ILME-FX30)
develops byte-for-byte identically to before, and reading the matrix back out of
each profiled develop recovers the fitted one.

ponytail: one matrix per body, no tone curve and no 3D LUT (a curve on top
measured 2% better and needs a spline plus array uniforms). The match is applied
to the 8-bit band the develop already produces — give develop 16-bit output if a
profile ever has to grade rather than match.
This commit is contained in:
2026-09-26 22:27:38 +07:00
parent 432acba9c1
commit 610a274103
2 changed files with 91 additions and 1 deletions
+24 -1
View File
@@ -17,6 +17,7 @@
// the main thread (Skia is not available in the RAW worker). Move it to a worker
// with an OffscreenCanvas if the develop ever blocks the UI visibly.
import LibRaw from 'libraw-wasm';
import { cameraMatch, type Mat3 } from './cameraMatch';
import { Skia } from './skiaShim';
// What `imageData()` returns for the settings below: 16-bit, 3 channels, with
@@ -39,6 +40,8 @@ const SAMPLE_MAX = 65535;
// Pixels per band, held under the point where the Float32 copy dominates the
// memory the page is allowed to use.
const BAND_PIXELS = 2_000_000;
// Leaves the develop alone when the body has no fitted profile.
const IDENTITY: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1];
const RAW_DEVELOP_SKSL = `
uniform shader raw;
@@ -48,12 +51,19 @@ uniform float4 m0; // camera -> sRGB, the first three columns of rgb_cam
uniform float4 m1;
uniform float4 m2;
uniform float4 crop; // (y offset of this band, 0, 0, 0)
uniform float4 w0; // the body's camera match, one row per float4
uniform float4 w1; // (identity when the body has no profile)
uniform float4 w2;
float3 encode(float3 x) {
x = clamp(x, 0.0, 1.0);
return mix(x * 12.92, 1.055 * pow(x, float3(1.0 / 2.4)) - 0.055, step(float3(0.0031308), x));
}
float luma(float3 x) {
return dot(float3(0.2126, 0.7152, 0.0722), x);
}
half4 main(float2 pos) {
float4 p = raw.eval(float2(pos.x, pos.y - crop.x));
// Only the floor here. A photo the sensor could not hold goes over the white
@@ -72,7 +82,16 @@ half4 main(float2 pos) {
// compressed, not kept; give develop a 16-bit output (or a knee of its own) when
// RAW highlights have to be recovered rather than merely look right.
rgb = mx > 1.0 ? mix(rgb / mx, float3(1.0), 1.0 - 1.0 / mx) : rgb;
return half4(half3(encode(rgb)), 1.0);
float3 e = encode(rgb);
// The body's colour, in the encoded space it was fitted in. The matrix was
// fitted luma-preserving, and the luma(e) / luma(c) factor holds that exact:
// the profile turns the chroma and never rescues an exposure, which stays the
// studio's job. Identity is a no-op.
float3 c = clamp(float3(dot(w0.xyz, e), dot(w1.xyz, e), dot(w2.xyz, e)), 0.0, 1.0);
// ponytail: the band leaves as 8-bit JPEG, so the match is applied to a value
// that is already quantised — plenty for a look, but give develop 16-bit output
// if a profile ever has to grade rather than merely match.
return half4(half3(clamp(c * (luma(e) / max(luma(c), 1e-4)), 0.0, 1.0)), 1.0);
}
`;
@@ -113,6 +132,7 @@ export async function developRaw(bytes: Uint8Array): Promise<Uint8Array> {
const mul = cd.cam_mul.map((v) => v / green);
const row = (i: number) => cd.rgb_cam[i].slice(0, 3);
const [r0, r1, r2] = [row(0), row(1), row(2)];
const cm: Mat3 = cameraMatch(meta?.normalized_model) ?? IDENTITY;
const bandH = Math.max(1, Math.min(h, Math.floor(BAND_PIXELS / w)));
const f32 = new Float32Array(w * bandH * 4);
@@ -145,6 +165,9 @@ export async function developRaw(bytes: Uint8Array): Promise<Uint8Array> {
r1[0], r1[1], r1[2], 0,
r2[0], r2[1], r2[2], 0,
y0, 0, 0, 0,
cm[0], cm[1], cm[2], 0,
cm[3], cm[4], cm[5], 0,
cm[6], cm[7], cm[8], 0,
]);
const shader = effect.makeShaderWithChildren(uniforms, [child]);
const paint = Skia.Paint();