From 05f242c44526c8bacb9dcd1ac2c939b498558a86 Mon Sep 17 00:00:00 2001 From: arata1972 Date: Tue, 6 Oct 2026 05:36:57 +0900 Subject: [PATCH] M11: estimate missing ARW colour from guarded camera-preview correspondences Co-authored-by: Codex --- ROADMAP.md | 10 +- crates/engine/src/camera_preview.rs | 207 ++++++++++++++++++++++++++++ crates/engine/src/files.rs | 7 +- crates/engine/src/lib.rs | 1 + crates/engine/src/media.rs | 2 +- docs/camera-preview-colour.md | 31 +++++ docs/parity.md | 6 +- 7 files changed, 251 insertions(+), 13 deletions(-) create mode 100644 crates/engine/src/camera_preview.rs create mode 100644 docs/camera-preview-colour.md diff --git a/ROADMAP.md b/ROADMAP.md index 54c86e812..e36c4f3fb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). |---|---:|---|---| | **Feature checklist** | 79% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim | | **RAW coverage** (formats people shoot) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. Per-model verification is thin (~40 corpus files vs >1,000 models) | -| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No camera colour calibration**: non-DNG raws use a neutral matrix, so colour is muted/inaccurate. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | +| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW has a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | | **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) | | **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering | | **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ | @@ -36,7 +36,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | User | Readiness | What blocks them | |---|---:|---| | JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks | -| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour (muted colour on every photo) | +| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW preview estimates are only a starting point) | | Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only | | Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering | | Relies on AI (masks, denoise) | ~25% | No segmentation or AI denoise models | @@ -46,7 +46,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*. 1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use - matrices the files carry themselves, then chart shots. Raises colour quality for every non-DNG raw at once. + matrices the files carry themselves, then chart shots. ARW preview-derived fitting is a guarded first step; expand coverage and validate fidelity. 2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, RW2 v4, NEF lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source, no LGPL dependency); compressed NEF (#86) is the template. @@ -81,7 +81,7 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help | M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) | | M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ | | M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) | -| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** ⬜ (top priority), CR3, compressed ORF/RAF ⬜) | +| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) | | M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ | | M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ | | M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ | @@ -175,7 +175,7 @@ Not decoded yet — preview only (no permissively licensed description; black-bo from the same-colour pixel two to the left), the reconstruction rule for scales 2/3 is not established. - **Olympus compressed ORF**, **Fujifilm compressed RAF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG. -**Camera colour matrices:** non-DNG raws use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged +**Camera colour matrices:** ARW files can use a guarded correction fitted from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate, not measured calibration. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged `matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax/Panasonic equivalents) and our own chart-based calibration (M11.4). Adobe matrices are never used. diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs new file mode 100644 index 000000000..97b33864d --- /dev/null +++ b/crates/engine/src/camera_preview.rs @@ -0,0 +1,207 @@ +//! Fit missing camera colour from a file's own colour JPEG, never from another vendor's profiles. +//! The JPEG supplies colour correspondences only: all output pixels still come from the RAW mosaic. +//! Use a fixed-size sensor proxy so thumbnails and full exports obtain the same transform. +use lightcraft_color::{Mat3, luminance_2020}; +use lightcraft_pipeline::tone::ToneMap; +use lightcraft_raster::{ + Rgb32f, + resample::{Filter, fit}, +}; +use lightcraft_raw::{RawImage, color::CameraTransform}; + +pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option { + if !transform.matrix_is_fallback || raw.format != lightcraft_raw::RawFormat::Arw { + return None; + } + let jpeg = lightcraft_raw::embedded_preview(bytes)?; + let decoded = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions { max_size: Some((384, 384)), max_pixels: 64_000_000 }).ok()?; + let reference = decoded.to_working(); + // Reject reference aspect mismatches; same-aspect crops/warps also face the holdout gate. + let crop = raw.crop.clipped(raw.active_area.width, raw.active_area.height); + if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 { + return None; + } + if lightcraft_pipeline::profiling() { + eprintln!("[profile] camera-preview reference {}x{} raw {}x{}", reference.width, reference.height, crop.width, crop.height); + } + let aspect = crop.width as f64 / crop.height as f64; + if (reference.width as f64 / reference.height as f64 / aspect - 1.0).abs() > 0.02 { + return None; + } + let k = (crop.width.max(crop.height).div_ceil(384).max(2)).div_ceil(2) * 2; + let sensor = raw.develop_binned(k, 0.99).ok()??; + let mut sensor = fit(&sensor, 96, 96, Filter::Box); + let reference = fit(&reference, sensor.width, sensor.height, Filter::Box); + if (sensor.width, sensor.height) != (reference.width, reference.height) { + return None; + } + let gain = 2f32.powf(transform.baseline_exposure as f32); + sensor.map_in_place(|p| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain))); + let matrix = fit_pairs(&sensor, &reference)?; + if lightcraft_pipeline::profiling() { + eprintln!("[profile] camera-preview colour fitted: {:?}", matrix.0); + } + Some(matrix) +} + +fn scene_reference(display: [f32; 3], tone: &ToneMap) -> Option<[f64; 3]> { + let y = luminance_2020(display); + if !display.iter().all(|v| v.is_finite() && *v > 0.004 && *v < 0.98) || !(0.015..0.85).contains(&y) { + return None; + } + // Undo the default RAW shoulder before fitting; it is applied exactly once by the normal pipeline. + let (mut lo, mut hi) = (0.0, 4.0); + for _ in 0..24 { + let mid = (lo + hi) * 0.5; + if tone.apply(mid) < y { + lo = mid; + } else { + hi = mid; + } + } + let scale = ((lo + hi) * 0.5 / y) as f64; + Some(display.map(|v| v as f64 * scale)) +} + +// Validate in linear display light, where a fixed error bound is meaningful regardless of exposure. +fn display_reference(scene: [f64; 3], tone: &ToneMap) -> Option<[f64; 3]> { + if !scene.iter().all(|v| v.is_finite()) { + return None; + } + let y = luminance_2020(scene.map(|v| v as f32)); + if y <= 0.0 || !y.is_finite() { + return None; + } + Some(scene.map(|v| v * (tone.apply(y) / y) as f64)) +} + +fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option { + if sensor.data.len() != reference.data.len() { + return None; + } + let tone = ToneMap::new(0.0, 0.0, 0.0); + let mut pairs = Vec::new(); + let mut colour = 0; + for (input, output) in sensor.data.iter().zip(&reference.data) { + if !input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) { + continue; + } + let Some(target) = scene_reference(*output, &tone) else { + continue; + }; + let min = output.iter().copied().fold(f32::INFINITY, f32::min); + let max = output.iter().copied().fold(0.0, f32::max); + colour += usize::from(max - min > 0.05); + pairs.push((input.map(f64::from), target)); + } + if lightcraft_pipeline::profiling() { + eprintln!("[profile] calibration pairs {} colour {}", pairs.len(), colour); + } + if pairs.len() < 256 || colour < pairs.len() / 20 { + return None; + } + let mut gram = [[0.0; 3]; 3]; + let mut cross = [[0.0; 3]; 3]; + for (i, (x, y)) in pairs.iter().enumerate() { + if i % 3 == 0 { + continue; + } + for row in 0..3 { + for col in 0..3 { + gram[row][col] += x[row] * x[col]; + cross[row][col] += y[row] * x[col]; + } + } + } + let trace: f64 = (0..3).map(|i| gram[i][i]).sum(); + // Reject insufficient colour diversity; regularization must not invent a calibration. + let g = Mat3(gram); + let inverse = g.inverse()?; + let condition = trace * (0..3).map(|i| inverse.0[i][i].abs()).sum::(); + if !inverse.0.iter().flatten().all(|v| v.is_finite()) || trace <= 0.0 || !condition.is_finite() || condition > 1e6 { + return None; + } + let regularization = trace * 1e-3; + let exposure = (0..3).map(|i| cross[i][i]).sum::() / trace; + for i in 0..3 { + gram[i][i] += regularization; + cross[i][i] += regularization * exposure; + } + let matrix = Mat3(cross).mul(&Mat3(gram).inverse()?); + if lightcraft_pipeline::profiling() { + eprintln!("[profile] calibration candidate {:?}", matrix.0); + } + if !matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0) { + return None; + } + let mut before = 0.0; + let mut after = 0.0; + let mut samples = 0; + for (i, (x, y)) in pairs.iter().enumerate() { + if i % 3 != 0 { + continue; + } + let corrected = display_reference(matrix.apply(*x), &tone)?; + let original = display_reference(*x, &tone)?; + let target = display_reference(*y, &tone)?; + for c in 0..3 { + before += (original[c] - target[c]).powi(2); + after += (corrected[c] - target[c]).powi(2); + samples += 1; + } + } + // Independent held-out pixels must materially improve, and match within a bounded error. + if lightcraft_pipeline::profiling() { + eprintln!("[profile] calibration holdout before {} after {} mean {}", before, after, after / samples as f64); + } + if !after.is_finite() || after >= before * 0.7 || after / samples as f64 > 0.075f64.powi(2) { + return None; + } + Some(matrix) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn recovers_missing_colour_and_exposure_without_replacing_sensor_pixels() { + let tone = ToneMap::new(0.0, 0.0, 0.0); + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let mut sensor = Rgb32f::new(32, 32); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + *src = [0.05 + (i % 11) as f32 * 0.018, 0.05 + (i % 17) as f32 * 0.011, 0.06 + (i % 23) as f32 * 0.008]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| v * tone.apply(y) / y); + } + let original = sensor.clone(); + let fitted = fit_pairs(&sensor, &reference).unwrap(); + assert_eq!(sensor.data, original.data); + for (a, b) in fitted.0.iter().flatten().zip(known.0.iter().flatten()) { + assert!((a - b).abs() < 0.15, "{fitted:?}"); + } + } + #[test] + fn rejects_monochrome_invalid_and_unrelated_previews() { + let mut sensor = Rgb32f::new(32, 32); + let mut reference = sensor.clone(); + for (i, p) in sensor.data.iter_mut().enumerate() { + *p = [0.1 + (i % 13) as f32 * 0.02, 0.15, 0.1]; + } + reference.data.fill([0.2; 3]); + assert!(fit_pairs(&sensor, &reference).is_none()); + reference.data.fill([f32::NAN; 3]); + assert!(fit_pairs(&sensor, &reference).is_none()); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + *src = [0.04 + (i % 11) as f32 * 0.02, 0.05 + (i % 17) as f32 * 0.01, 0.03 + (i % 23) as f32 * 0.01]; + *dst = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01]; + } + assert!(fit_pairs(&sensor, &reference).is_none()); + // A constant coloured sensor cannot determine a three-dimensional colour transform. + sensor.data.fill([0.1, 0.15, 0.12]); + assert!(fit_pairs(&sensor, &reference).is_none()); + reference.data.truncate(8); + assert!(fit_pairs(&sensor, &reference).is_none()); + } +} diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 32296c5af..3a9f42c8a 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -221,6 +221,9 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result< } Err(e) => return Err(e.to_string()), }; + let xy = lightcraft_raw::color::as_shot_white_xy(&raw); + let t = lightcraft_raw::color::camera_transform(&raw, xy); + let preview_matrix = crate::camera_preview::fit_preview(&raw, &bytes, &t); drop(bytes); // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in. let lens = embedded_lens(&raw.info()); @@ -242,12 +245,10 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result< // the samples aren't needed any more (the colour model below reads only the tags) raw.data = lightcraft_raw::RawData::U16(Vec::new()); let mut stages = vec![("develop", t0.elapsed())]; - let xy = lightcraft_raw::color::as_shot_white_xy(&raw); - let t = lightcraft_raw::color::camera_transform(&raw, xy); stages.push(("transform", t0.elapsed())); lightcraft_raw::highlight::reconstruct(&mut img, t.wb, HIGHLIGHT_CLIP); stages.push(("highlights", t0.elapsed())); - let m = t.matrix.to_f32(); + let m = preview_matrix.map(|p| p.mul(&t.matrix)).unwrap_or(t.matrix).to_f32(); let gain = 2f32.powf(t.baseline_exposure as f32); let wb = t.wb; img.map_in_place(|p| { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index dcbfd9edc..9d05b855e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,6 +10,7 @@ //! run them off the UI thread. #![forbid(unsafe_code)] +mod camera_preview; pub mod cmd; pub mod crs; pub mod crs_masks; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 4841533b6..96d25932c 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -26,7 +26,7 @@ use lightcraft_raster::{Histogram, Rgb32f, Rgba8}; use serde::{Deserialize, Serialize}; /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 4; +pub const RENDER_CACHE_VERSION: u64 = 5; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md new file mode 100644 index 000000000..f10a53820 --- /dev/null +++ b/docs/camera-preview-colour.md @@ -0,0 +1,31 @@ +# ARW preview-derived colour starting point + +Sony ARW files with no usable camera colour matrix previously used the generic camera-RGB≈sRGB fallback. This can produce visibly muted, dark photographs. The engine now estimates a bounded 3×3 correction from a file's own embedded camera JPEG. The JPEG provides colour correspondences, not output pixels: decoding, demosaicing, white balance, highlight reconstruction and RAW editing still operate on the sensor data. + +A fixed sensor proxy (at most 96×96) is used at every requested output size. The reference is decoded with DCT scaling and a 64-million-pixel input limit. Reference and sensor aspect ratios must agree within 2%; references with a different aspect ratio are rejected. Near-black, clipped and nonfinite samples are excluded. At least 256 usable pairs, enough coloured samples and a nonsingular, bounded-condition input covariance are required. Ridge regression is biased towards an exposure-only correction, rather than unconstrained colour amplification. Matrix coefficients are bounded. + +One third of the usable pixels are held out from fitting. After the normal RAW tone shoulder, the correction must reduce linear-display squared error by at least 30%, with per-channel RMS error at most 0.075 (a provisional camera-look tolerance, not a colour-accuracy certification). Unusable, monochrome, mismatched, ill-conditioned or poor-fitting references retain the existing fallback. DNG matrices and other RAW formats are unchanged. Render cache version 5 invalidates previously rendered thumbnails and views. + +This is a per-file camera-look estimate, not a measured per-camera spectral calibration. The embedded JPEG includes camera tone curves, picture styles, noise reduction and possibly local/lens adjustments; a global matrix cannot reproduce all of them. Similar-sized cropped or warped references are not guaranteed to be detected. Skin colour and highlights can still differ. A camera-matrix database and chart-based verification remain necessary. Do not advertise this as Lightroom colour parity or complete camera coverage. + +Validation uses synthetic coloured scenes with a known matrix, monochrome/nonfinite/singular inputs, unrelated references and mismatched lengths. Five private Sony ILCE-6700 ARWs are checked locally; photographs and embedded JPEGs are not committed or uploaded. See the PR for the observed acceptance count and remaining differences. + +## Local observations (Sony ILCE-6700) + +The guarded estimate was accepted on all five private samples. Brightness and yellow clothing improved; skin still differs from the camera JPEG. This is why the change is proposed as a draft for colour-science review and broader camera verification. + +A camera JPEG from the same capture, the previous RAW renderer and the fitted RAW renderer were exported at 1200 px with neutral settings and separately with Exposure +1 EV, Saturation +100 and Contrast +50. All three controls changed the previous RAW output. Mean absolute encoded-RGB differences from each source’s neutral rendering were: + +| Source | Exposure +1 | Saturation +100 | Contrast +50 | +|---|---:|---:|---:| +| Camera JPEG | 0.1930 | 0.0642 | 0.0373 | +| RAW, generic fallback | 0.1399 | 0.0318 | 0.0315 | +| RAW, preview-derived estimate | 0.1628 | 0.0509 | 0.0706 | + +These are response diagnostics on one image, not colour-accuracy or Lightroom-parity scores. The weaker saturation response starts from an already muted rendering; increasing saturation does not replace missing camera colour characterization. JPEG uses an identity tone map at neutral settings; RAW uses the scene-to-display shoulder, so identical slider values are not expected to produce identical changes. Edited RAW was also checked in the headless UI with the actual GPU renderer, after waiting for pending jobs: the loupe reported `source: render`, the histogram and displayed pixels changed, and Exposure +1 / Saturation +100 remained in the controls. No slider-no-op was reproduced in this sample. + +A second comparison used five separately supplied same-capture JPEGs, including four with embedded Display P3 profiles. RAW-only changes left all five JPEG exports pixel-identical. Two supplied JPEGs have a narrower crop than the RAW; no pixel-by-pixel colour-accuracy claim is made across those different frames. On the supplied matching JPEG for the slider-response sample, the corresponding response figures were exposure 0.1952, saturation 0.0517 and contrast 0.0260. RAW saturation response increased from 0.0318 to 0.0509 with the estimate. + +**Unresolved white-balance limitation:** the generic camera model also derives the RAW as-shot temperature/tint without a measured camera matrix. The sample reports roughly 6829 K / −127 tint. Changing temperature alone to 9000 K causes a strong pink cast, even after preview-derived colour fitting. The estimate does not establish a physically calibrated illuminant or repair absolute-Kelvin WB behaviour. It must not be presented as a complete RAW-development fix; measured camera characterization and white-balance validation are still required. + +For the colour-fit sample, independent CLI renders at 400 px and full resolution selected exactly the same fitted matrix. Full cold render/export took 3.96 s; 400 px took 0.48 s on this local machine (includes process/import/render/encode, not just the adjustment pipeline). diff --git a/docs/parity.md b/docs/parity.md index 2f24d5995..fda380c4c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -59,9 +59,7 @@ Ordered by user impact, then tier, then effort. The checklist above counts featu that decide whether a photographer can switch (see the honest assessment in [ROADMAP.md](../ROADMAP.md#where-we-stand)). Take the first one nobody is working on. -1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Every non-DNG raw is developed with a neutral - matrix today, so colours are muted. Start by fitting each camera to its own embedded JPEG and using matrices the - files carry themselves; never Adobe data. +1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. ARW now has guarded per-file embedded-JPEG fitting; measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data. 2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed RAF / ORF, RW2 v4, Nikon "lossy after split" NEF, Canon sRAW; HEIC/AVIF decode. Clean-room, from prose descriptions only (see `crates/raw/src/vendor/nefc.rs` for how compressed NEF was done). Until decoded, such photos are `preview_only`. @@ -276,7 +274,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs` | DNG files use the colour matrices they carry; every other raw uses a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices they carry; ARW has guarded per-file embedded-JPEG colour fitting (see docs/camera-preview-colour.md), not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF |