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();