From 610a274103994775d8520e5a890720bf33060241 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sat, 26 Sep 2026 22:27:38 +0700 Subject: [PATCH] web: give a RAW the colour its own camera would have given it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docker/frontend/src/engine/cameraMatch.ts | 67 +++++++++++++++++++++++ docker/frontend/src/engine/rawDevelop.ts | 25 ++++++++- 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 docker/frontend/src/engine/cameraMatch.ts diff --git a/docker/frontend/src/engine/cameraMatch.ts b/docker/frontend/src/engine/cameraMatch.ts new file mode 100644 index 0000000..56d7ba6 --- /dev/null +++ b/docker/frontend/src/engine/cameraMatch.ts @@ -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 = { + '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; +} diff --git a/docker/frontend/src/engine/rawDevelop.ts b/docker/frontend/src/engine/rawDevelop.ts index d7a1288..32fbaaf 100644 --- a/docker/frontend/src/engine/rawDevelop.ts +++ b/docker/frontend/src/engine/rawDevelop.ts @@ -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 { 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 { 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();