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
+67
View File
@@ -0,0 +1,67 @@
// The look a camera's own JPEG would have had.
//
// LibRaw here is deliberately kept out of white balance and tone (see
// rawDevelop), so a RAW that opens in the studio lands on the neutral demosaic —
// while the JPEG the photographer saw on the back of the camera carries the
// body's own colour rendering. These 3x3 matrices are that difference, fitted
// offline: develop a frame through this same path, compare it block by block
// against the camera's own preview of the same frame, and least-squares the
// matrix that takes the first to the second. Fitted per body on real scenes
// (4–12 MP each, held-out blocks scored with CIEDE2000):
//
// Ricoh GR III 4.28 (unfitted 5.99) Fujifilm X100V 4.22 (8.19)
// Ricoh GR II 9.73 (11.68) Fujifilm X100S 7.27 (7.41)
// Fujifilm X-T3 6.12 (9.03)
//
// What is left over is largely high-frequency (sharpening, noise reduction,
// demosaic) rather than colour: the error keeps falling as the blocks grow
// (GR III: 2.86 at 6px, 2.06 at 24px, 1.59 at 60px).
//
// The transform preserves luma exactly — out = luma * normalize(M * in / luma) —
// so it moves colour and never exposure. A preview that came out dark stays
// dark, on purpose: exposure is the studio's job, not the profile's. ponytail:
// one matrix, no tone curve and no 3D LUT; a curve on top was measured at 2%
// better and needs a spline plus an array uniform, so add one only when a body
// turns out to need it.
//
// Keyed on LibRaw's normalized_model — the bare model ("GR III", "X100V"), not
// the make-qualified camera_model: a body that was never fitted, or that reports
// a name we do not know, develops exactly as it did before.
export type Mat3 = readonly [
number, number, number,
number, number, number,
number, number, number,
];
// Row-major, applied to the sRGB-encoded develop (the space it was fitted in).
const MATCH: Record<string, Mat3> = {
'GR III': [
0.577754, 0.856314, -0.440638,
0.143396, 0.716041, 0.145206,
-0.177111, 0.291345, 0.859118,
],
'GR II': [
0.440692, 0.976166, -0.377622,
0.187633, 0.680171, 0.121235,
-0.211717, 0.293754, 0.911013,
],
'X-T3': [
1.626754, -0.533332, -0.130089,
-0.184760, 1.160799, 0.039331,
-0.015344, -0.022394, 0.993452,
],
'X100V': [
1.103373, 0.362157, -0.471229,
0.014568, 0.833165, 0.154949,
-0.448700, 0.586230, 0.852687,
],
'X100S': [
0.527629, 0.469773, -0.014534,
0.144123, 0.823023, 0.033459,
-0.036716, 0.369805, 0.711359,
],
};
export function cameraMatch(model?: string | null): Mat3 | null {
return (model && MATCH[model]) || null;
}
+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();