From 9c0c71a053ed1b07bec9d66ae2a114ced2d0ea8e Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sat, 10 Oct 2026 22:22:36 +0700 Subject: [PATCH] fix(raw): fix FUJIFILM RAF & RAW color cast by prioritizing pre_mul WB multipliers --- .../scripts/test-all-raws-accuracy.mjs | 173 ++++++++++++++++++ docker/frontend/src/engine/rawDevelop.ts | 19 +- docker/recipescam-images.tar.gz.sha256 | 2 +- 3 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 docker/frontend/scripts/test-all-raws-accuracy.mjs diff --git a/docker/frontend/scripts/test-all-raws-accuracy.mjs b/docker/frontend/scripts/test-all-raws-accuracy.mjs new file mode 100644 index 0000000..3fcb36d --- /dev/null +++ b/docker/frontend/scripts/test-all-raws-accuracy.mjs @@ -0,0 +1,173 @@ +import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import ts from 'typescript'; + +const transpile = (path) => + ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, + }).outputText; + +const LIBRAW_NODE_SRC = ` +import { Worker } from 'node:worker_threads'; + +const SHIM = \` +import { parentPort, workerData } from 'node:worker_threads'; +import { readFile } from 'node:fs/promises'; +globalThis.self = globalThis; +globalThis.postMessage = (m, t) => parentPort.postMessage(m, t); +Object.defineProperty(globalThis, 'onmessage', { + configurable: true, + set(fn) { parentPort.on('message', (data) => fn({ data })); }, +}); +const base = new URL('.', workerData.url); +globalThis.fetch = async (u) => + new Response(await readFile(new URL(u, base)), { headers: { 'content-type': 'application/wasm' } }); +await import(workerData.url); +\`; + +export default class LibRaw { + constructor() { + const url = new URL('worker.js', ${JSON.stringify(import.meta.resolve('libraw-wasm/dist/index.js'))}).href; + this.worker = new Worker(SHIM, { eval: true, workerData: { url } }); + this.pending = new Map(); + this.next = 0; + this.worker.on('message', ({ id, out, error }) => { + const p = this.pending.get(id); + if (!p) return; + this.pending.delete(id); + if (error) p.reject(new Error(error)); + else p.resolve(out); + }); + } + run(fn, ...args) { + return new Promise((resolve, reject) => { + const id = this.next++; + this.pending.set(id, { resolve, reject }); + const transfer = args.filter((a) => ArrayBuffer.isView(a)).map((a) => a.buffer); + this.worker.postMessage({ id, fn, args }, transfer); + }); + } + open(bytes, settings) { + const copy = Uint8Array.from(bytes); + return this.run('open', copy, settings); + } + metadata(full) { return this.run('metadata', !!full); } + imageData() { return this.run('imageData'); } + thumbnailData() { return this.run('thumbnailData'); } + dispose() { return this.worker.terminate(); } +} +`; + +const dir = join(tmpdir(), 'test-all-raws-acc'); +import { mkdtempSync } from 'node:fs'; +const testDir = mkdtempSync(dir); + +const ckEntry = import.meta.resolve('canvaskit-wasm/full'); +writeFileSync( + join(testDir, 'skiaShim.mjs'), + transpile('../src/engine/skiaShim.ts').replace( + /^import CanvasKitInit from ['"]canvaskit-wasm\/full['"];$/m, + `import CanvasKitInit from ${JSON.stringify(ckEntry)};`, + ), +); +writeFileSync(join(testDir, 'sensorWhite.mjs'), transpile('../src/engine/sensorWhite.ts')); +writeFileSync(join(testDir, 'halfFloat.mjs'), transpile('../src/engine/halfFloat.ts')); +writeFileSync(join(testDir, 'previewMatch.mjs'), transpile('../src/engine/previewMatch.ts')); +writeFileSync( + join(testDir, 'tiffDecode.mjs'), + transpile('../src/engine/tiffDecode.ts').replace(/['"]\.\/skiaShim['"]/, '"./skiaShim.mjs"') +); + +// Transpile rawDevelop.ts with fix +let rawDevTs = readFileSync(new URL('../src/engine/rawDevelop.ts', import.meta.url), 'utf8'); + +// Fix WB multiplier order in rawDevelop.ts code before transpiling +rawDevTs = rawDevTs.replace( + ` if (cd?.cam_mul && Array.isArray(cd.cam_mul) && cd.cam_mul.some((v: number) => v > 0 && v !== 1)) { + return cd.cam_mul; + } + + // 2. Try pre-calculated WB multipliers from LibRaw (pre_mul) + if (cd?.pre_mul && Array.isArray(cd.pre_mul) && cd.pre_mul.some((v: number) => v > 0 && v !== 1)) { + return cd.pre_mul; + }`, + ` if (cd?.pre_mul && Array.isArray(cd.pre_mul) && cd.pre_mul.some((v: number) => v > 0 && v !== 1)) { + return cd.pre_mul; + } + + // 2. Try camera WB multipliers from LibRaw (cam_mul) + if (cd?.cam_mul && Array.isArray(cd.cam_mul) && cd.cam_mul.some((v: number) => v > 0 && v !== 1)) { + return cd.cam_mul; + }` +); + +const rawDevelopCode = ts.transpileModule(rawDevTs, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, +}).outputText + .replace(/^import LibRaw from ['"]libraw-wasm['"];$/m, 'import LibRaw from "./librawNode.mjs";') + .replace(/['"]\.\/sensorWhite['"]/, '"./sensorWhite.mjs"') + .replace(/['"]\.\/halfFloat['"]/, '"./halfFloat.mjs"') + .replace(/['"]\.\/previewMatch['"]/, '"./previewMatch.mjs"') + .replace(/['"]\.\/skiaShim['"]/, '"./skiaShim.mjs"') + .replace(/['"]\.\/tiffDecode['"]/, '"./tiffDecode.mjs"'); + +writeFileSync(join(testDir, 'rawDevelop.mjs'), rawDevelopCode); +writeFileSync(join(testDir, 'librawNode.mjs'), LIBRAW_NODE_SRC); + +const { developRaw, extractEmbeddedJpeg } = await import(pathToFileURL(join(testDir, 'rawDevelop.mjs')).href); +const Skia = await (await import(pathToFileURL(join(testDir, 'skiaShim.mjs')).href)).initSkia(); + +const getAvgRgb = (img) => { + const w = 64, h = 64; + const surface = Skia.Surface.MakeOffscreen(w, h); + const canvas = surface.getCanvas(); + canvas.drawImageRectCubic(img, Skia.XYWHRect(0, 0, img.width(), img.height()), Skia.XYWHRect(0, 0, w, h), 1/3, 1/3); + surface.flush(); + const px = canvas.readPixels(0, 0, { width: w, height: h, colorType: Skia.ColorType.RGBA_8888, alphaType: Skia.AlphaType.Unpremul, colorSpace: Skia.ColorSpace.SRGB }); + surface.dispose(); + let r = 0, g = 0, b = 0; + for (let i = 0; i < px.length; i += 4) { + r += px[i]; g += px[i+1]; b += px[i+2]; + } + const count = px.length / 4; + return [ (r/count).toFixed(2), (g/count).toFixed(2), (b/count).toFixed(2) ]; +}; + +const sampleDir = '../sample'; +const files = readdirSync(sampleDir).filter(f => /\.(raf|nef|dng|rw2|orf|arw|cr2|cr3)$/i.test(f)); + +for (const f of files) { + const filePath = join(sampleDir, f); + const bytes = new Uint8Array(readFileSync(filePath)); + console.log(`\n=== Testing ${f} ===`); + try { + const devJpeg = await developRaw(bytes, f); + const embJpeg = extractEmbeddedJpeg(bytes); + + const devImg = Skia.Image.MakeImageFromEncoded(devJpeg); + const embImg = embJpeg ? Skia.Image.MakeImageFromEncoded(embJpeg) : null; + + const devRgb = getAvgRgb(devImg); + console.log(`Dev dim: ${devImg.width()}x${devImg.height()} RGB: [ ${devRgb.join(', ')} ]`); + + if (embImg) { + const embRgb = getAvgRgb(embImg); + console.log(`Emb dim: ${embImg.width()}x${embImg.height()} RGB: [ ${embRgb.join(', ')} ]`); + + const rDiff = Math.abs(parseFloat(devRgb[0]) - parseFloat(embRgb[0])); + const gDiff = Math.abs(parseFloat(devRgb[1]) - parseFloat(embRgb[1])); + const bDiff = Math.abs(parseFloat(devRgb[2]) - parseFloat(embRgb[2])); + console.log(`RGB Abs Diffs: R=${rDiff.toFixed(2)}, G=${gDiff.toFixed(2)}, B=${bDiff.toFixed(2)}`); + + const pass = rDiff < 15 && gDiff < 15 && bDiff < 15; + console.log(`COLOR ACCURACY PASS: ${pass ? 'YES ✅' : 'NO ❌'}`); + } + + devImg.delete(); + if (embImg) embImg.delete(); + } catch (err) { + console.error(`FAILED ${f}:`, err.message); + } +} diff --git a/docker/frontend/src/engine/rawDevelop.ts b/docker/frontend/src/engine/rawDevelop.ts index f2c6504..37bf81e 100644 --- a/docker/frontend/src/engine/rawDevelop.ts +++ b/docker/frontend/src/engine/rawDevelop.ts @@ -558,16 +558,17 @@ export function detectCameraBrand(fileName?: string, meta?: any): CameraBrand { } export function getBrandWbMultipliers(brand: CameraBrand, cd: any): number[] { - // 1. Try camera WB multipliers from LibRaw (cam_mul) - if (cd?.cam_mul && Array.isArray(cd.cam_mul) && cd.cam_mul.some((v: number) => v > 0 && v !== 1)) { - return cd.cam_mul; - } - - // 2. Try pre-calculated WB multipliers from LibRaw (pre_mul) + // 1. Try pre-calculated WB multipliers from LibRaw (pre_mul) + // LibRaw pre_mul contains green-normalized, daylight-calibrated WB multipliers. if (cd?.pre_mul && Array.isArray(cd.pre_mul) && cd.pre_mul.some((v: number) => v > 0 && v !== 1)) { return cd.pre_mul; } + // 2. Try camera WB multipliers from LibRaw (cam_mul) + if (cd?.cam_mul && Array.isArray(cd.cam_mul) && cd.cam_mul.some((v: number) => v > 0 && v !== 1)) { + return cd.cam_mul; + } + // 3. Brand-calibrated WB fallbacks (prevents generic single fallback from producing color casts) switch (brand) { case 'fujifilm': @@ -658,9 +659,11 @@ function previewIsFrame(preview: Uint8Array, meta: any): boolean { Number(meta?.iwidth) || 0, Number(meta?.iheight) || 0, Number(meta?.width) || 0, - Number(meta?.height) || 0 + Number(meta?.height) || 0, + Number(meta?.raw_width) || 0, + Number(meta?.raw_height) || 0 ); - if (!size || !frame) return true; + if (!size || !frame) return false; return Math.max(size.width, size.height) >= frame * 0.9; } diff --git a/docker/recipescam-images.tar.gz.sha256 b/docker/recipescam-images.tar.gz.sha256 index d028ea2..995446f 100644 --- a/docker/recipescam-images.tar.gz.sha256 +++ b/docker/recipescam-images.tar.gz.sha256 @@ -1 +1 @@ -5005ea6264b3fa5eeebb70658c347507e55bf9016541cfe74e29e4190fa3a95d recipescam-images.tar.gz +f3a607ed7e32fba30b946282e1fea750cd7a895ad718717081bf82841535504f recipescam-images.tar.gz