diff --git a/NOTICE b/NOTICE index 254e26280..5fd60227f 100644 --- a/NOTICE +++ b/NOTICE @@ -15,6 +15,13 @@ list (author, source, license) is in assets/ATTRIBUTION.md. Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt. Showcase photographs by Ansel Adams, Dorothea Lange and NASA (docs/images/) — public domain. See assets/ATTRIBUTION.md. +crates/raw/src/spectral_table.rs (lightcraft-raw) holds camera colour matrices derived from the +camera spectral sensitivities, training reflectances and model alias list of rawtoaces-data +(https://github.com/AcademySoftwareFoundation/rawtoaces-data, v1.1.0), an Academy Software +Foundation project licensed under the Apache License 2.0, and from the CIE 1931 colour-matching +functions it ships (CIE, CC-BY-4.0). The matrices were fitted by the LightCraft contributors +(2026) with tools/spectral_camera_table.py. See assets/ATTRIBUTION.md. + crates/segment (lightcraft-segment) ports the SAM 3 model code of Hugging Face Transformers (https://github.com/huggingface/transformers, src/transformers/models/sam3 and sam3_tracker), Copyright The HuggingFace Team and Meta Platforms, Inc., licensed under the Apache License 2.0. diff --git a/ROADMAP.md b/ROADMAP.md index ff1aa0ed2..9bb4744b9 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.7% | 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 | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim | | **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, uncompressed Samsung SRW (NX5 to NX1100, EX1, WB2000), RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini), Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Nikon High Efficiency NEF (HE / HE★: Z 8, Z 9, Z 6III, Z f; JPEG XS-based, preview only, #193). Per-model verification is thin (~55 corpus files vs >1,000 models) | -| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles (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) | +| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); 52 models whose JPEG fit is rejected start from matrices fitted to measured spectral sensitivities; 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, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **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), paginated contact-sheet PDF export (A4/Letter, layout/captions, current edits, 150 dpi sRGB) | **Map view, Book, full Print module, Slideshow module, Web, publish services**: contact sheets work, custom print packages and printer colour management remain missing | @@ -49,7 +49,8 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par 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. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, Pentax PEF, Samsung SRW and Olympus ORF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF - since issue #310, not yet checked on the corpus); then validate fidelity. + since issue #310, not yet checked on the corpus); 52 models whose fit is rejected fall back to matrices fitted to + measured spectral sensitivities (rawtoaces-data); then validate fidelity. 2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, 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. @@ -191,7 +192,7 @@ packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recov Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete): - **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**. -**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF, Samsung SRW and Olympus ORF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged +**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF, Samsung SRW and Olympus ORF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. 52 models whose fit is rejected (or that have no usable JPEG) start from matrices fitted to their measured spectral sensitivities instead (rawtoaces-data, Apache-2.0; `crates/raw/src/spectral.rs`). 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 equivalents; Panasonic RW2 files carry none) and our own chart-based calibration (M11.4). Adobe matrices are never used. diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 0fc883bdd..7ade9dac2 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -14,6 +14,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` | | `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none | | `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none | +| `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix | | `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none | | `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a | diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index fd7c58f8a..8f7789591 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -4,7 +4,7 @@ //! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a //! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals //! (applied like a DNG `ProfileHueSatMap`) corrects that when it also improves the held-out pixels. -use lightcraft_color::{D50, D65, Mat3, PROPHOTO, REC2020, bradford, luminance_2020}; +use lightcraft_color::{D50, D65, Mat3, PROPHOTO, REC2020, Xy, bradford, luminance_2020}; use lightcraft_pipeline::tone::{CameraTone, ToneMap}; use lightcraft_raster::{ Rgb32f, @@ -46,6 +46,43 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { ) } +/// The colour a raw starts from, first available first: the file's own colour matrices (DNG); a look fitted to the +/// file's JPEG ([`fit_preview`]: with the camera profile for its model when there is one, else with colour fitted to +/// this photo alone); the model's colour matrices from measured spectral sensitivities (`lightcraft_raw::spectral`); +/// the neutral fallback. The spectral matrices only stand in for the neutral fallback, so a photo whose JPEG fit is +/// accepted renders as without them; when used they are written into `raw.color`. Returns the as-shot white, the +/// camera transform at it and the fitted look. +pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8]) -> (Xy, CameraTransform, Option) { + starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t)) +} + +/// [`starting_colour`] with the JPEG fit passed in (tests pin the order with it). +fn starting_colour_with( + raw: &mut RawImage, + fit: impl FnOnce(&RawImage, &CameraTransform) -> Option, +) -> (Xy, CameraTransform, Option) { + let start = |raw: &RawImage| { + let xy = lightcraft_raw::color::as_shot_white_xy(raw); + (xy, lightcraft_raw::color::camera_transform(raw, xy)) + }; + let (xy, t) = start(raw); + if !t.matrix_is_fallback { + return (xy, t, None); + } + if let Some(look) = fit(raw, &t) { + return (xy, t, Some(look)); + } + let Some(camera) = raw.metadata.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), model)) else { + return (xy, t, None); + }; + camera.fill(&mut raw.color); + if lightcraft_pipeline::profiling() { + eprintln!("[profile] {:?} camera colour from spectral sensitivities: {} {}", raw.format, camera.make, camera.model); + } + let (xy, t) = start(raw); + (xy, t, None) +} + pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option { if !transform.matrix_is_fallback || !file_local_look(raw.format) { return None; @@ -1389,6 +1426,130 @@ mod tests { assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples"); } + fn raw_of(format: RawFormat, make: &str, model: &str, color: lightcraft_raw::ColorData) -> RawImage { + let area = lightcraft_raw::Rect::new(0, 0, 2, 2); + RawImage { + format, + width: 2, + height: 2, + cpp: 1, + data: lightcraft_raw::RawData::U16(vec![0; 4]), + cfa: None, + bits: 14, + black: Default::default(), + white: vec![16383.0], + active_area: area, + crop: area, + orientation: Default::default(), + color, + wb_multipliers: Some([2.0, 1.0, 1.5]), + linearized: false, + opcodes: Default::default(), + metadata: lightcraft_raw::Metadata { make: Some(make.to_owned()), model: Some(model.to_owned()), ..Default::default() }, + } + } + + /// Step 1: a file's own colour matrices win over everything, including a spectral row for its model; no JPEG fit + /// is tried. + #[test] + fn a_files_own_matrices_come_first() { + let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); + let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", color.clone()); + let (_, t, look) = starting_colour_with(&mut raw, |_, _| panic!("no JPEG fit for a raw with its own matrices")); + assert_eq!(raw.color, color); + assert!(!t.matrix_is_fallback && look.is_none()); + } + + /// Steps 2 and 3 before step 4: an accepted JPEG fit (with or without a camera profile) is kept for a model that + /// has spectral matrices, and the spectral matrices are not written. Photos that had a fitted look before the + /// spectral table render exactly as they did. + #[test] + fn an_accepted_jpeg_fit_comes_before_the_spectral_matrices() { + for (make, model) in [("SONY", "ILCE-7M4"), ("Canon", "Canon EOS 600D")] { + assert!(lightcraft_raw::spectral::find(Some(make), model).is_some(), "{model}"); + let mut raw = raw_of(RawFormat::Cr2, make, model, Default::default()); + let fitted = dro_test_look(&std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)), &|x| x.sqrt()); + let (xy, t, look) = starting_colour_with(&mut raw, |raw, t| { + assert!(t.matrix_is_fallback && !lightcraft_raw::color::has_matrix(&raw.color), "the fit sees the raw without spectral matrices"); + Some(fitted.clone()) + }); + assert_eq!(look.map(|l| l.matrix), Some(fitted.matrix), "{model}"); + assert_eq!(raw.color, lightcraft_raw::ColorData::default(), "{model}: spectral matrices left out"); + assert!(t.matrix_is_fallback, "{model}"); + assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw)); + } + } + + /// Step 4: when the JPEG fit fails (here: no camera JPEG at all), a model with a spectral row takes its matrices, + /// whether or not it has a camera profile (the ILCE-7M4 has a bundled one). + #[test] + fn a_failed_jpeg_fit_falls_back_to_the_spectral_matrices() { + assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); + let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap(); + let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default()); + let (_, t, look) = starting_colour(&mut raw, &[]); + assert!(look.is_none()); + assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some)); + assert!(!t.matrix_is_fallback); + + let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap(); + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); + let (xy, t, look) = starting_colour_with(&mut raw, |_, _| None); + assert!(look.is_none()); + assert_eq!(raw.color.illuminant, [17, 21]); + assert_eq!(raw.color.forward_matrix, camera.forward_matrix.map(Some)); + assert!(!t.matrix_is_fallback); + assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw)); + // white-balanced white stays white + let w = t.matrix.apply([1.0; 3]); + assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-4), "{w:?}"); + } + + /// Step 5: a camera outside the table whose fit fails keeps the neutral fallback. + #[test] + fn a_camera_outside_the_table_keeps_the_fallback() { + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default()); + let (_, t, look) = starting_colour(&mut raw, &[]); + assert!(look.is_none() && t.matrix_is_fallback); + assert_eq!(raw.color, lightcraft_raw::ColorData::default()); + // without a model there is no table row + let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default()); + raw.metadata.model = None; + assert!(starting_colour(&mut raw, &[]).1.matrix_is_fallback); + } + + /// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is + /// accepted keeps it and its colour data; one whose fit fails takes its spectral matrices when its model has + /// them; a DNG keeps its own matrices. White balance stays relative to the as-shot look for every raw without + /// matrices of its own. + #[test] + fn corpus_colour_precedence() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + for name in ["arw-sony-a7m4-14bit.arw", "cr2-canon-5d3.cr2", "nef-nikon-d5100-lossless.nef", "cr2-canon-7d.cr2", "dng-canon-5d3-16bit.dng"] { + let Ok(bytes) = std::fs::read(dir.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let own = lightcraft_raw::color::has_matrix(&raw.color); + let covered = raw.metadata.model.as_deref().and_then(|m| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), m)).is_some(); + let before = raw.color.clone(); + let (_, t, fitted) = starting_colour(&mut raw, &bytes); + let spectral = !own && fitted.is_none() && covered; + eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some()); + assert_eq!(raw.color != before, spectral, "{name}"); + assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}"); + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}"); + assert_eq!(info.relative_wb, !own, "{name}"); + } + } + #[test] fn supported_raws_get_a_file_local_look() { // every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 93c214f8e..4315a3098 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -364,12 +364,13 @@ fn load_bytes_now( }; // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in // (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`). - let lens = embedded_lens(&raw.info()); + let info = raw.info(); + let lens = embedded_lens(&info); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - let xy = lightcraft_raw::color::as_shot_white_xy(&raw); - let t = lightcraft_raw::color::camera_transform(&raw, xy); - // the source's segmentation mattes (DNG semantic masks), read while the preview is fitted - let (camera_look, mattes) = rayon::join(|| crate::camera_preview::fit_preview(&raw, &bytes, &t), || dng_mattes(&bytes, &raw)); + // Without colour matrices of its own, white balance is relative to the as-shot look (`Photo::relative_wb`). + let own_matrix = lightcraft_raw::color::has_matrix(&raw.color); + // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted + let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes), || dng_mattes(&bytes, &info)); drop(bytes); // Previews and thumbnails bin the mosaic straight to (about) the size they need; only // larger levels (exports, 1:1) demosaic the whole sensor. @@ -435,7 +436,7 @@ fn load_bytes_now( } let twin = twin.map(|picture| finish(picture).0); let (temp, tint) = xy_to_temp_tint(xy); - let relative = crate::camera_preview::file_local_look(raw.format) && t.matrix_is_fallback; + let relative = crate::camera_preview::file_local_look(raw.format) && !own_matrix; // White balance re-evaluates the file's own colour model (when it has one and no // file-local look matrix sits on top of it) let camera_color = (!t.matrix_is_fallback && camera_look.is_none()).then(|| { @@ -483,7 +484,7 @@ fn local_tone(raw: &lightcraft_raw::RawImage) -> Option Option> { +fn dng_mattes(bytes: &[u8], raw: &lightcraft_raw::RawInfo) -> Option> { use lightcraft_pipeline::masks::{MatteKind, Mattes}; if raw.format != lightcraft_raw::RawFormat::Dng { return None; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 129b0f2c8..be7c51a45 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 40; +pub const RENDER_CACHE_VERSION: u64 = 41; /// 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/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 49ae2e09f..a6b264e00 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -20,7 +20,8 @@ //! packed 2/5/7, the 16-bit words of the oldest bodies), Pentax PEF (uncompressed, Huffman), Olympus ORF (uncompressed: 16-bit words, 12-bit XZ-2 words, 12-bit 16-byte blocks of the E-300/E-330/E-500). //! [`embedded_preview`] covers these containers' JPEG previews. Variants we can't decode yet (compressed ORF, CR3 unverified marker families / C-RAW configurations) return [`RawError::Unsupported`]; each vendor module documents its sources //! (public specifications, tag-name documentation, black-box analysis of CC0 samples) and gaps. Non-DNG files carry no -//! colour matrix: [`color`] falls back to a documented neutral model. The decoders never panic on malformed input. +//! colour matrix: [`spectral`] has matrices fitted to measured spectral sensitivities for 52 models, and +//! otherwise [`color`] falls back to a documented neutral model. The decoders never panic on malformed input. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -39,6 +40,7 @@ mod preview; pub mod profile; mod saturation; pub mod semantic; +pub mod spectral; mod tiffraw; mod unpack; mod vendor; diff --git a/crates/raw/src/spectral.rs b/crates/raw/src/spectral.rs new file mode 100644 index 000000000..00f41b554 --- /dev/null +++ b/crates/raw/src/spectral.rs @@ -0,0 +1,171 @@ +//! Camera colour matrices from measured spectral sensitivities, for raws that carry no colour matrix. +//! +//! [`find`] looks a camera up by its Exif make and model in a table generated by +//! `tools/spectral_camera_table.py` from rawtoaces-data (Academy Software Foundation, Apache-2.0): per model, DNG +//! `ColorMatrix1/2` and `ForwardMatrix1/2` for Standard Illuminant A and D65, fitted to the camera's measured spectral +//! sensitivities. [`Camera::fill`] writes them into a file's [`ColorData`], and [`crate::color`]'s DNG model does the +//! rest. Other names of a covered camera (Rebel T3i for EOS 600D) come from rawtoaces-data's alias list. No number +//! comes from Adobe or from a GPL raw decoder. Which colour a raw starts from is the engine's choice +//! (`docs/camera-preview-colour.md`). +use crate::ColorData; +use lightcraft_color::Mat3; + +#[path = "spectral_table.rs"] +mod table; + +/// DNG `CalibrationIlluminant` codes of the two matrices: Standard Illuminant A, D65. +const ILLUMINANTS: [u16; 2] = [17, 21]; + +/// One camera's colour matrices. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Camera { + pub make: &'static str, + pub model: &'static str, + /// XYZ → camera under Standard Illuminant A and D65 (`ColorMatrix1/2`). + pub color_matrix: [Mat3; 2], + /// White-balanced camera → XYZ D50 (`ForwardMatrix1/2`). + pub forward_matrix: [Mat3; 2], +} + +impl Camera { + /// Give `color` this camera's matrices, when it has no colour matrix of its own. Returns whether it did. + pub fn fill(&self, color: &mut ColorData) -> bool { + if crate::color::has_matrix(color) { + return false; + } + color.illuminant = ILLUMINANTS; + color.color_matrix = self.color_matrix.map(Some); + color.forward_matrix = self.forward_matrix.map(Some); + true + } +} + +/// Model names compared without case, the brand, a leading `EOS` or a trailing `DIGITAL` +/// (Exif "Canon EOS 400D DIGITAL" is the table's "EOS 400D"). +fn key(brand: &str, model: &str) -> String { + let mut words: Vec = model.split_whitespace().map(str::to_uppercase).collect(); + if words.first().is_some_and(|w| *w == brand) { + words.remove(0); + } + if words.first().is_some_and(|w| w == "EOS") { + words.remove(0); + } + if words.len() > 1 && words.last().is_some_and(|w| w == "DIGITAL") { + words.pop(); + } + words.join(" ") +} + +/// First word of a make, upper case ("NIKON CORPORATION" → "NIKON"). +fn brand(make: &str) -> String { + make.split_whitespace().next().unwrap_or_default().to_uppercase() +} + +/// The table's camera for a file's Exif make and model, directly or through an alias. Without a make, the model +/// alone must match. +pub fn find(make: Option<&str>, model: &str) -> Option<&'static Camera> { + let make = make.map(brand).filter(|b| !b.is_empty()); + let same = |row_make: &str, row_model: &str| { + let row_brand = brand(row_make); + let wanted = key(&row_brand, model); + make.as_ref().is_none_or(|m| *m == row_brand) && !wanted.is_empty() && key(&row_brand, row_model) == wanted + }; + match table::ALIASES.iter().find(|(m, alias, _)| same(m, alias)) { + Some((m, _, target)) => table::CAMERAS.iter().find(|c| brand(c.make) == brand(m) && c.model == *target), + None => table::CAMERAS.iter().find(|c| same(c.make, c.model)), + } +} + +/// Every camera in the table. +pub fn cameras() -> &'static [Camera] { + table::CAMERAS +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exif_names_find_their_rows() { + for (make, model, row) in [ + ("Canon", "Canon EOS 5D Mark III", "EOS 5D Mark III"), + ("Canon", "Canon EOS 600D", "EOS 600D"), + ("Canon", "Canon EOS-1Ds Mark III", "EOS-1Ds Mark III"), + ("Canon", "Canon EOS DIGITAL REBEL XTi", "Digital Rebel XTi"), + ("Canon", "Canon EOS R5m2", "EOS R5m2"), + ("Canon", "Canon PowerShot S90", "PowerShot S90"), + ("NIKON CORPORATION", "NIKON D850", "D850"), + ("NIKON CORPORATION", "NIKON Z f", "Z f"), + ("SONY", "ILCE-7M3", "ILCE-7M3"), + ("FUJIFILM", "X-T4", "X-T4"), + ("Panasonic", "DC-GX9", "DC-GX9"), + ] { + assert_eq!(find(Some(make), model).map(|c| c.model), Some(row), "{make} {model}"); + } + assert_eq!(find(None, "NIKON D850").map(|c| c.model), Some("D850")); + assert_eq!(find(None, "ILCE-7M3").map(|c| c.model), Some("ILCE-7M3")); + } + + #[test] + fn aliases_reach_the_camera_they_name() { + for (make, model, row) in [ + ("Canon", "Canon EOS 400D DIGITAL", "Digital Rebel XTi"), + ("Canon", "Canon EOS Kiss Digital X", "Digital Rebel XTi"), + ("Canon", "Canon EOS REBEL T3i", "EOS 600D"), + ("Canon", "Canon EOS Kiss X5", "EOS 600D"), + ("Canon", "Canon EOS R5 Mark II", "EOS R5m2"), + ("NIKON CORPORATION", "NIKON D800", "D800E"), + ("NIKON CORPORATION", "NIKON Z 6_2", "Z f"), + ("SONY", "ILCE-7RM4A", "ILCE-7RM4"), + ("FUJIFILM", "GFX100S", "GFX 100"), + ] { + assert_eq!(find(Some(make), model).map(|c| c.model), Some(row), "{make} {model}"); + } + } + + #[test] + fn other_cameras_and_other_makes_find_nothing() { + assert_eq!(find(Some("Canon"), "Canon EOS 7D"), None); + assert_eq!(find(Some("SONY"), "ILCE-7M2"), None); + assert_eq!(find(Some("OLYMPUS IMAGING CORP."), "E-M5"), None); + // the model of another make + assert_eq!(find(Some("Canon"), "D850"), None); + assert_eq!(find(Some("SONY"), "X-T4"), None); + assert_eq!(find(None, ""), None); + assert_eq!(find(Some(""), ""), None); + } + + #[test] + fn every_row_is_a_usable_dng_profile() { + assert_eq!(cameras().len(), 52); + for c in cameras() { + assert_eq!(find(Some(c.make), c.model), Some(c), "{} {}", c.make, c.model); + for m in c.color_matrix.iter().chain(&c.forward_matrix) { + assert!(m.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 4.0), "{}", c.model); + assert!(m.inverse().is_some(), "{}", c.model); + } + // a forward matrix maps white-balanced white to D50 (DNG spec, chapter 6) + for fm in &c.forward_matrix { + let w = fm.apply([1.0; 3]); + let d50 = lightcraft_color::D50.to_xyz(); + assert!((0..3).all(|i| (w[i] - d50[i]).abs() < 1e-4), "{} {w:?}", c.model); + } + } + } + + #[test] + fn fill_keeps_a_files_own_matrices() { + let c = find(Some("Canon"), "Canon EOS 600D").unwrap(); + let mut color = ColorData::default(); + assert!(c.fill(&mut color)); + assert_eq!(color.illuminant, [17, 21]); + assert_eq!(color.color_matrix, [Some(c.color_matrix[0]), Some(c.color_matrix[1])]); + assert_eq!(color.forward_matrix, [Some(c.forward_matrix[0]), Some(c.forward_matrix[1])]); + + let own = Mat3([[0.5, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.7]]); + let mut color = ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; + let before = color.clone(); + assert!(!c.fill(&mut color)); + assert_eq!(color, before); + } +} diff --git a/crates/raw/src/spectral_table.rs b/crates/raw/src/spectral_table.rs new file mode 100644 index 000000000..f116e2335 --- /dev/null +++ b/crates/raw/src/spectral_table.rs @@ -0,0 +1,349 @@ +// Generated by tools/spectral_camera_table.py from rawtoaces-data (https://github.com/AcademySoftwareFoundation/rawtoaces-data, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c). +// Derived from the camera spectral sensitivities of rawtoaces-data (Academy Software Foundation), licensed under +// the Apache License 2.0 (LICENSE-APACHE); see NOTICE and assets/ATTRIBUTION.md. Do not edit by hand. +use super::Camera; +use lightcraft_color::Mat3; + +#[rustfmt::skip] +pub(super) const CAMERAS: &[Camera] = &[ + Camera { + make: "ARRI", + model: "D21", + color_matrix: [Mat3([[0.933065, -0.03997, 0.042177], [-0.321253, 0.916394, 0.250116], [-0.047929, 0.258088, 0.770888]]), Mat3([[0.841118, 0.11685, -0.05716], [-0.416513, 1.06087, 0.169491], [-0.121062, 0.361236, 0.692345]])], + forward_matrix: [Mat3([[0.913988, -0.102353, 0.152661], [0.315506, 0.849769, -0.165276], [-0.134707, -0.698389, 1.6582]]), Mat3([[1.01361, -0.108757, 0.059444], [0.405694, 0.811177, -0.216871], [-0.027612, -0.326433, 1.17915]])], + }, + Camera { + make: "Arashi Vision", + model: "Insta360 X5", + color_matrix: [Mat3([[1.0899, -0.436896, 0.023525], [-0.482767, 1.39879, 0.369626], [-0.056659, 0.169421, 0.72407]]), Mat3([[0.700283, -0.158934, -0.077847], [-0.543955, 1.30291, 0.196622], [-0.148964, 0.25165, 0.516509]])], + forward_matrix: [Mat3([[0.710808, 0.160362, 0.093126], [0.231838, 0.891164, -0.123002], [0.020454, -0.498248, 1.3029]]), Mat3([[0.706099, 0.209581, 0.048616], [0.287186, 0.8971, -0.184286], [0.044158, -0.279999, 1.06095]])], + }, + Camera { + make: "Canon", + model: "Digital Rebel XTi", + color_matrix: [Mat3([[0.984721, -0.317618, -0.03823], [-0.536205, 1.41139, 0.499196], [-0.019493, 0.0898, 0.993219]]), Mat3([[0.654378, -0.152128, -0.077165], [-0.504141, 1.17532, 0.279049], [-0.071499, 0.132862, 0.672873]])], + forward_matrix: [Mat3([[0.735947, 0.130604, 0.097745], [0.271991, 0.870186, -0.142177], [-0.013289, -0.229681, 1.06808]]), Mat3([[0.693623, 0.247958, 0.022715], [0.295094, 0.978904, -0.273998], [0.00951, -0.117049, 0.932644]])], + }, + Camera { + make: "Canon", + model: "EOS-1D X Mark II", + color_matrix: [Mat3([[0.841821, -0.109388, -0.029826], [-0.469467, 1.34341, 0.484215], [-0.074495, 0.204633, 0.843881]]), Mat3([[0.559829, 0.012974, -0.076841], [-0.474068, 1.17573, 0.252424], [-0.137973, 0.263217, 0.557436]])], + forward_matrix: [Mat3([[0.867342, -0.069209, 0.166163], [0.282942, 0.879178, -0.162119], [0.042308, -0.560464, 1.34326]]), Mat3([[0.866644, -0.0379, 0.135551], [0.340507, 0.913959, -0.254466], [0.035971, -0.324047, 1.11318]])], + }, + Camera { + make: "Canon", + model: "EOS-1Ds Mark III", + color_matrix: [Mat3([[0.933464, -0.245919, -0.026807], [-0.571496, 1.49286, 0.379177], [-0.048229, 0.100118, 0.927528]]), Mat3([[0.623037, -0.073582, -0.085177], [-0.54689, 1.28786, 0.213006], [-0.105037, 0.156191, 0.659429]])], + forward_matrix: [Mat3([[0.785005, 0.07525, 0.10404], [0.285685, 0.799015, -0.084701], [0.044611, -0.231741, 1.01223]]), Mat3([[0.764142, 0.106772, 0.093381], [0.318478, 0.835999, -0.154477], [0.03106, -0.128764, 0.922809]])], + }, + Camera { + make: "Canon", + model: "EOS-1Ds Mark II", + color_matrix: [Mat3([[0.945891, -0.245875, -0.023592], [-0.58518, 1.49586, 0.412994], [-0.049647, 0.124624, 1.04996]]), Mat3([[0.633076, -0.080316, -0.073882], [-0.552992, 1.27833, 0.227086], [-0.10768, 0.180208, 0.730732]])], + forward_matrix: [Mat3([[0.78531, 0.073316, 0.10567], [0.29637, 0.804671, -0.101041], [0.021036, -0.254656, 1.05872]]), Mat3([[0.780605, 0.119757, 0.063934], [0.332784, 0.851961, -0.184745], [0.021321, -0.137163, 0.940946]])], + }, + Camera { + make: "Canon", + model: "EOS 100D", + color_matrix: [Mat3([[0.823379, -0.153284, -0.011827], [-0.451822, 1.34611, 0.422153], [-0.063154, 0.17914, 0.866791]]), Mat3([[0.537713, -0.001778, -0.071702], [-0.479729, 1.20511, 0.230384], [-0.135862, 0.245077, 0.608091]])], + forward_matrix: [Mat3([[0.839709, -0.013024, 0.137611], [0.260264, 0.869968, -0.130232], [0.040567, -0.473274, 1.25781]]), Mat3([[0.852444, -0.005311, 0.117164], [0.33135, 0.880249, -0.211599], [0.038232, -0.260159, 1.04703]])], + }, + Camera { + make: "Canon", + model: "EOS 200D", + color_matrix: [Mat3([[0.882798, -0.11491, -0.016841], [-0.443662, 1.31235, 0.491834], [-0.062151, 0.206192, 0.798346]]), Mat3([[0.570466, 0.029473, -0.079371], [-0.461712, 1.15876, 0.257222], [-0.118587, 0.24972, 0.538517]])], + forward_matrix: [Mat3([[0.867592, -0.074026, 0.17073], [0.27684, 0.904905, -0.181745], [0.007997, -0.615366, 1.43247]]), Mat3([[0.884048, -0.067045, 0.147293], [0.347584, 0.917256, -0.26484], [0.021454, -0.322825, 1.12647]])], + }, + Camera { + make: "Canon", + model: "EOS 200D II", + color_matrix: [Mat3([[0.820814, -0.08112, -0.025232], [-0.449049, 1.31524, 0.500348], [-0.063522, 0.20839, 0.807016]]), Mat3([[0.534782, 0.039491, -0.075304], [-0.461016, 1.15287, 0.26203], [-0.117011, 0.248633, 0.539767]])], + forward_matrix: [Mat3([[0.879874, -0.096427, 0.180849], [0.283378, 0.900453, -0.183831], [0.006798, -0.61219, 1.4305]]), Mat3([[0.897644, -0.089573, 0.156225], [0.354938, 0.914467, -0.269405], [0.020307, -0.323555, 1.12835]])], + }, + Camera { + make: "Canon", + model: "EOS 5DS", + color_matrix: [Mat3([[0.784071, -0.14278, -0.018109], [-0.493528, 1.36672, 0.49299], [-0.067845, 0.178082, 0.792129]]), Mat3([[0.501948, -0.005161, -0.069927], [-0.500698, 1.19504, 0.25793], [-0.125608, 0.228657, 0.525639]])], + forward_matrix: [Mat3([[0.838414, -0.019978, 0.145859], [0.285256, 0.874752, -0.160008], [0.038705, -0.507361, 1.29376]]), Mat3([[0.840997, -0.003166, 0.126464], [0.342942, 0.904091, -0.247033], [0.034419, -0.289165, 1.07985]])], + }, + Camera { + make: "Canon", + model: "EOS 5D", + color_matrix: [Mat3([[0.961758, -0.26807, -0.006664], [-0.565407, 1.47679, 0.405541], [-0.067833, 0.12912, 0.951998]]), Mat3([[0.616895, -0.064661, -0.085183], [-0.554321, 1.28625, 0.22097], [-0.137616, 0.197613, 0.656267]])], + forward_matrix: [Mat3([[0.79062, 0.078103, 0.095573], [0.287453, 0.812941, -0.100394], [0.068474, -0.279714, 1.03635]]), Mat3([[0.77434, 0.091394, 0.098562], [0.324069, 0.841224, -0.165293], [0.044245, -0.169979, 0.950838]])], + }, + Camera { + make: "Canon", + model: "EOS 5D Mark III", + color_matrix: [Mat3([[0.798227, -0.156522, -0.024001], [-0.479014, 1.37953, 0.412187], [-0.067815, 0.178094, 0.795854]]), Mat3([[0.536462, -0.020759, -0.074508], [-0.493492, 1.22724, 0.222071], [-0.136505, 0.242956, 0.547031]])], + forward_matrix: [Mat3([[0.829015, -0.007278, 0.142558], [0.266702, 0.857593, -0.124295], [0.048011, -0.488746, 1.26584]]), Mat3([[0.818929, 0.023372, 0.121994], [0.319742, 0.879191, -0.198933], [0.040465, -0.281513, 1.06615]])], + }, + Camera { + make: "Canon", + model: "EOS 5D Mark II", + color_matrix: [Mat3([[0.765281, -0.126067, -0.028016], [-0.539891, 1.44802, 0.407637], [-0.058008, 0.128461, 0.861097]]), Mat3([[0.508976, -0.002163, -0.072663], [-0.524812, 1.25489, 0.224018], [-0.11344, 0.183978, 0.599963]])], + forward_matrix: [Mat3([[0.84396, -0.009524, 0.12986], [0.296711, 0.799464, -0.096175], [0.052873, -0.321603, 1.09383]]), Mat3([[0.8407, 0.001511, 0.122085], [0.343565, 0.82753, -0.171095], [0.035331, -0.182192, 0.971966]])], + }, + Camera { + make: "Canon", + model: "EOS 5D Mark IV", + color_matrix: [Mat3([[0.763394, -0.085035, -0.016567], [-0.442907, 1.31347, 0.486351], [-0.080725, 0.247335, 0.884784]]), Mat3([[0.49672, 0.034424, -0.068025], [-0.461551, 1.16055, 0.255439], [-0.154521, 0.307914, 0.579555]])], + forward_matrix: [Mat3([[0.880649, -0.094084, 0.177732], [0.27666, 0.906137, -0.182797], [0.024429, -0.659213, 1.45989]]), Mat3([[0.898336, -0.087734, 0.153694], [0.347611, 0.924078, -0.271689], [0.035769, -0.378779, 1.16812]])], + }, + Camera { + make: "Canon", + model: "EOS 600D", + color_matrix: [Mat3([[0.800214, -0.150575, -0.010842], [-0.462694, 1.36091, 0.41413], [-0.054076, 0.17327, 0.853835]]), Mat3([[0.528239, -0.007001, -0.067784], [-0.485979, 1.21691, 0.225002], [-0.13144, 0.244539, 0.592949]])], + forward_matrix: [Mat3([[0.836952, -0.011114, 0.138458], [0.267123, 0.859868, -0.12699], [0.017395, -0.463764, 1.27147]]), Mat3([[0.849461, 0.005947, 0.108888], [0.332065, 0.875561, -0.207627], [0.033552, -0.262503, 1.05405]])], + }, + Camera { + make: "Canon", + model: "EOS M", + color_matrix: [Mat3([[0.858133, -0.188655, -0.015316], [-0.477476, 1.37657, 0.415737], [-0.066593, 0.173487, 0.870331]]), Mat3([[0.559337, -0.024286, -0.076489], [-0.498439, 1.22646, 0.227107], [-0.13663, 0.237788, 0.612213]])], + forward_matrix: [Mat3([[0.818185, 0.015661, 0.130449], [0.263684, 0.85776, -0.121444], [0.046738, -0.442459, 1.22083]]), Mat3([[0.820106, 0.031315, 0.112875], [0.32549, 0.874009, -0.199499], [0.03847, -0.243276, 1.02991]])], + }, + Camera { + make: "Canon", + model: "EOS R10", + color_matrix: [Mat3([[0.900395, -0.120204, -0.023257], [-0.450784, 1.32079, 0.490112], [-0.060508, 0.207987, 0.757971]]), Mat3([[0.586274, 0.022629, -0.081629], [-0.465757, 1.16444, 0.255537], [-0.114546, 0.247787, 0.505639]])], + forward_matrix: [Mat3([[0.860416, -0.07424, 0.178119], [0.279245, 0.90614, -0.185385], [-0.005252, -0.64772, 1.47808]]), Mat3([[0.874599, -0.057155, 0.146852], [0.346428, 0.920831, -0.267259], [0.018292, -0.341711, 1.14852]])], + }, + Camera { + make: "Canon", + model: "EOS R5", + color_matrix: [Mat3([[0.906792, -0.181028, -0.039982], [-0.497007, 1.37995, 0.46653], [-0.056558, 0.135904, 0.790883]]), Mat3([[0.599617, -0.03146, -0.086837], [-0.500836, 1.20715, 0.246929], [-0.122485, 0.204473, 0.529195]])], + forward_matrix: [Mat3([[0.825544, 0.000874, 0.137878], [0.27921, 0.851443, -0.130653], [0.053855, -0.393303, 1.16455]]), Mat3([[0.80269, 0.040879, 0.120727], [0.321664, 0.899552, -0.221215], [0.039913, -0.245426, 1.03062]])], + }, + Camera { + make: "Canon", + model: "EOS R5m2", + color_matrix: [Mat3([[0.925997, -0.201878, -0.03247], [-0.514263, 1.39694, 0.472057], [-0.067213, 0.159605, 0.932813]]), Mat3([[0.603973, -0.037874, -0.08705], [-0.520173, 1.22523, 0.247204], [-0.147012, 0.240988, 0.619754]])], + forward_matrix: [Mat3([[0.816853, 0.016859, 0.130583], [0.284699, 0.847793, -0.132492], [0.049678, -0.384186, 1.15961]]), Mat3([[0.796684, 0.049994, 0.117618], [0.326624, 0.8894, -0.216024], [0.040655, -0.243309, 1.02776]])], + }, + Camera { + make: "Canon", + model: "EOS R6", + color_matrix: [Mat3([[0.887235, -0.176902, -0.018642], [-0.462429, 1.33965, 0.473061], [-0.070097, 0.19962, 0.862666]]), Mat3([[0.571385, -0.014759, -0.079163], [-0.485367, 1.18708, 0.251862], [-0.145333, 0.265796, 0.580543]])], + forward_matrix: [Mat3([[0.830203, -0.011614, 0.145706], [0.268315, 0.891706, -0.16002], [0.03391, -0.533608, 1.3248]]), Mat3([[0.829826, 0.011021, 0.123449], [0.329107, 0.920067, -0.249174], [0.037653, -0.307101, 1.09455]])], + }, + Camera { + make: "Canon", + model: "EOS R6m2", + color_matrix: [Mat3([[0.904651, -0.185945, -0.032292], [-0.490303, 1.37329, 0.46456], [-0.076482, 0.19622, 0.939437]]), Mat3([[0.595351, -0.032062, -0.086354], [-0.499687, 1.20659, 0.246445], [-0.151602, 0.266204, 0.633694]])], + forward_matrix: [Mat3([[0.822515, -0.0028, 0.14458], [0.275534, 0.86495, -0.140483], [0.04532, -0.468236, 1.24802]]), Mat3([[0.80207, 0.038555, 0.123671], [0.322259, 0.905003, -0.227262], [0.03728, -0.271382, 1.05921]])], + }, + Camera { + make: "Canon", + model: "EOS RP", + color_matrix: [Mat3([[0.843269, -0.158656, -0.026198], [-0.497933, 1.37758, 0.476056], [-0.070882, 0.179269, 0.839769]]), Mat3([[0.548578, -0.014251, -0.077893], [-0.504763, 1.20691, 0.250579], [-0.139008, 0.243991, 0.560854]])], + forward_matrix: [Mat3([[0.836529, -0.017241, 0.145008], [0.285362, 0.860694, -0.146057], [0.049163, -0.481077, 1.25702]]), Mat3([[0.825922, 0.011858, 0.126516], [0.334357, 0.898585, -0.232942], [0.038621, -0.283664, 1.07015]])], + }, + Camera { + make: "Canon", + model: "EOS R", + color_matrix: [Mat3([[0.732886, -0.080769, -0.010303], [-0.454757, 1.32829, 0.481287], [-0.083062, 0.251827, 0.895746]]), Mat3([[0.473635, 0.038459, -0.063761], [-0.474141, 1.17757, 0.250797], [-0.157456, 0.313293, 0.589901]])], + forward_matrix: [Mat3([[0.884185, -0.093978, 0.174089], [0.284166, 0.893986, -0.178152], [0.023242, -0.654769, 1.45663]]), Mat3([[0.908841, -0.095987, 0.151442], [0.356864, 0.902145, -0.259009], [0.034581, -0.371735, 1.16226]])], + }, + Camera { + make: "Canon", + model: "PowerShot S90", + color_matrix: [Mat3([[1.37542, -0.592063, 0.037432], [-0.219062, 1.12456, 0.326212], [-0.021707, 0.09788, 0.58587]]), Mat3([[0.90732, -0.244506, -0.087152], [-0.314903, 1.06475, 0.215416], [-0.11223, 0.179775, 0.439924]])], + forward_matrix: [Mat3([[0.67794, 0.203129, 0.083227], [0.105177, 1.01192, -0.1171], [0.055684, -0.464622, 1.23404]]), Mat3([[0.655349, 0.321862, -0.012915], [0.180096, 1.10102, -0.281111], [0.064028, -0.26917, 1.03025]])], + }, + Camera { + make: "Fujifilm", + model: "GFX 100", + color_matrix: [Mat3([[1.37208, -0.633889, 0.081365], [-0.430548, 1.32101, 0.427017], [-0.039908, 0.114944, 0.782345]]), Mat3([[0.834968, -0.233255, -0.07589], [-0.475844, 1.18948, 0.241344], [-0.126239, 0.194812, 0.541708]])], + forward_matrix: [Mat3([[0.673446, 0.25104, 0.039809], [0.200798, 0.950696, -0.151494], [0.031013, -0.340478, 1.13457]]), Mat3([[0.679917, 0.304542, -0.020163], [0.261808, 1.00662, -0.268426], [0.044444, -0.214012, 0.994673]])], + }, + Camera { + make: "Fujifilm", + model: "X-T3", + color_matrix: [Mat3([[1.29933, -0.598524, 0.075033], [-0.403354, 1.28265, 0.450876], [-0.037817, 0.112326, 0.810909]]), Mat3([[0.785701, -0.221043, -0.071522], [-0.456611, 1.15627, 0.255052], [-0.124341, 0.191243, 0.562458]])], + forward_matrix: [Mat3([[0.671025, 0.255703, 0.037568], [0.190434, 0.97306, -0.163493], [0.03182, -0.334086, 1.12737]]), Mat3([[0.676828, 0.316449, -0.028982], [0.256753, 1.03712, -0.293871], [0.04319, -0.207191, 0.989106]])], + }, + Camera { + make: "Fujifilm", + model: "X-T4", + color_matrix: [Mat3([[1.27406, -0.587718, 0.074639], [-0.371617, 1.24808, 0.450058], [-0.038746, 0.113215, 0.780014]]), Mat3([[0.771728, -0.215706, -0.070731], [-0.439625, 1.14183, 0.253491], [-0.125229, 0.192512, 0.542394]])], + forward_matrix: [Mat3([[0.668116, 0.258486, 0.037694], [0.175163, 0.992049, -0.167213], [0.040145, -0.355105, 1.14006]]), Mat3([[0.675162, 0.317733, -0.028599], [0.248581, 1.05006, -0.298641], [0.04702, -0.219918, 0.998002]])], + }, + Camera { + make: "Hasselblad", + model: "L1D-20c", + color_matrix: [Mat3([[0.697908, -0.225515, 0.018865], [-0.403519, 1.29608, 0.413637], [-0.048508, 0.167167, 0.74727]]), Mat3([[0.466971, -0.076456, -0.040872], [-0.471572, 1.21777, 0.21163], [-0.133006, 0.246905, 0.516655]])], + forward_matrix: [Mat3([[0.754419, 0.110524, 0.099352], [0.21061, 0.943846, -0.154456], [0.016488, -0.523808, 1.33242]]), Mat3([[0.780313, 0.168783, 0.0152], [0.295267, 0.942833, -0.2381], [0.041, -0.301041, 1.08515]])], + }, + Camera { + make: "Hasselblad", + model: "L2D-20c", + color_matrix: [Mat3([[0.787283, -0.249155, 0.044752], [-0.415734, 1.29586, 0.451949], [-0.060535, 0.144901, 0.709626]]), Mat3([[0.506449, -0.071884, -0.038243], [-0.435817, 1.14056, 0.251334], [-0.129637, 0.209508, 0.474062]])], + forward_matrix: [Mat3([[0.776836, 0.120396, 0.067064], [0.218196, 0.951802, -0.169998], [0.071832, -0.4598, 1.21307]]), Mat3([[0.807201, 0.167509, -0.010414], [0.291699, 1.01528, -0.306978], [0.062694, -0.296911, 1.05932]])], + }, + Camera { + make: "Nikon", + model: "D200", + color_matrix: [Mat3([[1.37569, -0.560621, 0.01677], [-0.606594, 1.52996, 0.38327], [-0.024329, 0.069398, 0.99135]]), Mat3([[0.898945, -0.249527, -0.086342], [-0.568281, 1.29839, 0.222009], [-0.098369, 0.139456, 0.687094]])], + forward_matrix: [Mat3([[0.708545, 0.18748, 0.068271], [0.274935, 0.818921, -0.093855], [0.012224, -0.169279, 0.98216]]), Mat3([[0.685079, 0.274473, 0.004744], [0.294469, 0.898619, -0.193087], [0.026107, -0.10149, 0.900488]])], + }, + Camera { + make: "Nikon", + model: "D3300", + color_matrix: [Mat3([[1.01047, -0.365965, 0.032301], [-0.473579, 1.3513, 0.474736], [-0.075188, 0.164522, 1.02012]]), Mat3([[0.644589, -0.136221, -0.056505], [-0.47509, 1.16499, 0.26318], [-0.15352, 0.237111, 0.681108]])], + forward_matrix: [Mat3([[0.738138, 0.162349, 0.063809], [0.234836, 0.925124, -0.15996], [0.059989, -0.351996, 1.11711]]), Mat3([[0.742449, 0.23561, -0.013763], [0.290358, 1.00587, -0.29623], [0.04507, -0.217376, 0.99741]])], + }, + Camera { + make: "Nikon", + model: "D5100", + color_matrix: [Mat3([[1.34772, -0.48474, 0.012024], [-0.466856, 1.29899, 0.44042], [-0.076248, 0.161652, 0.981853]]), Mat3([[0.929942, -0.213704, -0.081538], [-0.481178, 1.17186, 0.262186], [-0.14942, 0.233631, 0.699016]])], + forward_matrix: [Mat3([[0.732398, 0.159354, 0.072543], [0.240376, 0.912366, -0.152741], [0.06111, -0.349037, 1.11303]]), Mat3([[0.727572, 0.254229, -0.017505], [0.287762, 1.00467, -0.292432], [0.040205, -0.206009, 0.990908]])], + }, + Camera { + make: "Nikon", + model: "D5300", + color_matrix: [Mat3([[0.943297, -0.302977, 0.012055], [-0.489398, 1.36679, 0.480036], [-0.079912, 0.172098, 1.02217]]), Mat3([[0.613661, -0.114638, -0.052652], [-0.475392, 1.16153, 0.266621], [-0.147109, 0.232899, 0.680179]])], + forward_matrix: [Mat3([[0.75961, 0.127618, 0.077067], [0.24786, 0.911605, -0.159465], [0.062139, -0.362458, 1.12542]]), Mat3([[0.765013, 0.208344, -0.009061], [0.301196, 0.998195, -0.299391], [0.042143, -0.217084, 1.00005]])], + }, + Camera { + make: "Nikon", + model: "D7000", + color_matrix: [Mat3([[1.0579, -0.370378, 0.024968], [-0.50908, 1.38998, 0.475636], [-0.075079, 0.158561, 1.00866]]), Mat3([[0.675661, -0.143226, -0.053077], [-0.488456, 1.16985, 0.270384], [-0.137868, 0.21334, 0.681191]])], + forward_matrix: [Mat3([[0.744976, 0.150244, 0.069076], [0.251735, 0.900835, -0.15257], [0.058406, -0.336086, 1.10278]]), Mat3([[0.755556, 0.237153, -0.028413], [0.304411, 0.998446, -0.302857], [0.039148, -0.192875, 0.978832]])], + }, + Camera { + make: "Nikon", + model: "D700", + color_matrix: [Mat3([[1.25876, -0.491229, 0.017162], [-0.619342, 1.54791, 0.372191], [-0.050936, 0.098145, 1.01122]]), Mat3([[0.835285, -0.217528, -0.074624], [-0.583051, 1.32613, 0.209425], [-0.121368, 0.168895, 0.708639]])], + forward_matrix: [Mat3([[0.722062, 0.17127, 0.070964], [0.279306, 0.809472, -0.088779], [0.042476, -0.200528, 0.983157]]), Mat3([[0.707886, 0.252255, 0.004155], [0.304416, 0.875438, -0.179854], [0.032977, -0.118624, 0.910752]])], + }, + Camera { + make: "Nikon", + model: "D70", + color_matrix: [Mat3([[0.95752, -0.326533, -0.000865], [-0.654123, 1.5628, 0.437699], [-0.01723, 0.05324, 0.899754]]), Mat3([[0.621434, -0.137207, -0.067353], [-0.59081, 1.28888, 0.250406], [-0.066923, 0.098053, 0.633702]])], + forward_matrix: [Mat3([[0.74958, 0.134109, 0.080606], [0.313007, 0.787492, -0.100498], [0.005453, -0.15183, 0.971482]]), Mat3([[0.722318, 0.215673, 0.026305], [0.326263, 0.879351, -0.205614], [0.017282, -0.077471, 0.885294]])], + }, + Camera { + make: "Nikon", + model: "D800E", + color_matrix: [Mat3([[0.981723, -0.320813, 0.025417], [-0.500428, 1.37669, 0.486261], [-0.07946, 0.169419, 1.04548]]), Mat3([[0.629114, -0.117451, -0.046422], [-0.483155, 1.16337, 0.27171], [-0.147278, 0.230496, 0.696276]])], + forward_matrix: [Mat3([[0.760723, 0.133423, 0.07015], [0.25382, 0.906695, -0.160515], [0.059962, -0.34837, 1.11351]]), Mat3([[0.780492, 0.212338, -0.028534], [0.311949, 0.998057, -0.310006], [0.041771, -0.208304, 0.991637]])], + }, + Camera { + make: "Nikon", + model: "D810", + color_matrix: [Mat3([[0.983274, -0.318832, 0.030267], [-0.51358, 1.39278, 0.481653], [-0.076666, 0.176366, 1.07197]]), Mat3([[0.625688, -0.110113, -0.045912], [-0.497828, 1.18171, 0.267671], [-0.149244, 0.239807, 0.71615]])], + forward_matrix: [Mat3([[0.763991, 0.129679, 0.070625], [0.262526, 0.897486, -0.160012], [0.045849, -0.35404, 1.13329]]), Mat3([[0.791151, 0.196615, -0.023471], [0.322368, 0.977225, -0.299593], [0.038389, -0.209045, 0.995761]])], + }, + Camera { + make: "Nikon", + model: "D850", + color_matrix: [Mat3([[1.17359, -0.461087, -0.011934], [-0.607581, 1.52285, 0.4063], [-0.096127, 0.196063, 1.00005]]), Mat3([[0.773836, -0.196466, -0.097677], [-0.609077, 1.35526, 0.205388], [-0.190117, 0.290252, 0.676129]])], + forward_matrix: [Mat3([[0.713588, 0.158895, 0.091813], [0.273041, 0.838318, -0.111359], [0.055293, -0.367196, 1.13701]]), Mat3([[0.669707, 0.227388, 0.0672], [0.291703, 0.871502, -0.163205], [0.042827, -0.229884, 1.01216]])], + }, + Camera { + make: "Nikon", + model: "Z f", + color_matrix: [Mat3([[1.16951, -0.415422, 0.033906], [-0.516721, 1.41065, 0.441119], [-0.080466, 0.180954, 1.07672]]), Mat3([[0.749962, -0.151755, -0.063228], [-0.504317, 1.20913, 0.248149], [-0.162888, 0.256933, 0.729432]])], + forward_matrix: [Mat3([[0.744024, 0.151119, 0.069153], [0.253649, 0.888649, -0.142297], [0.049891, -0.351314, 1.12653]]), Mat3([[0.756117, 0.216534, -0.008355], [0.304737, 0.961046, -0.265784], [0.041615, -0.212408, 0.995897]])], + }, + Camera { + make: "Panasonic", + model: "DC-GX9", + color_matrix: [Mat3([[0.820911, -0.254983, 0.035979], [-0.372553, 1.25333, 0.438192], [-0.054294, 0.146795, 0.747319]]), Mat3([[0.534195, -0.07728, -0.038219], [-0.400358, 1.10647, 0.251688], [-0.12982, 0.216241, 0.50214]])], + forward_matrix: [Mat3([[0.772645, 0.116685, 0.074966], [0.196365, 0.972505, -0.16887], [0.06062, -0.463162, 1.22765]]), Mat3([[0.805529, 0.178696, -0.01993], [0.275218, 1.04725, -0.322465], [0.060973, -0.29745, 1.06158]])], + }, + Camera { + make: "Sony", + model: "DSC-RX100M4", + color_matrix: [Mat3([[0.783848, -0.284096, 0.031033], [-0.411895, 1.30629, 0.410807], [-0.047532, 0.165091, 0.725564]]), Mat3([[0.507376, -0.094837, -0.042497], [-0.488786, 1.23246, 0.213166], [-0.137082, 0.247475, 0.503834]])], + forward_matrix: [Mat3([[0.733625, 0.143965, 0.086705], [0.210364, 0.945779, -0.156144], [0.012886, -0.528963, 1.34118]]), Mat3([[0.767183, 0.193058, 0.004054], [0.296288, 0.94455, -0.240838], [0.043397, -0.304922, 1.08663]])], + }, + Camera { + make: "Sony", + model: "ILCE-6400", + color_matrix: [Mat3([[0.686447, -0.204525, 0.026311], [-0.43013, 1.31387, 0.445779], [-0.061279, 0.132669, 0.791917]]), Mat3([[0.44557, -0.064452, -0.027734], [-0.437513, 1.13079, 0.261784], [-0.122323, 0.190304, 0.552427]])], + forward_matrix: [Mat3([[0.782555, 0.106676, 0.075064], [0.224677, 0.924591, -0.149268], [0.078407, -0.377222, 1.12392]]), Mat3([[0.821757, 0.17479, -0.032251], [0.30346, 1.00862, -0.31208], [0.052261, -0.224433, 0.997277]])], + }, + Camera { + make: "Sony", + model: "ILCE-7CM2", + color_matrix: [Mat3([[0.758033, -0.221503, 0.036455], [-0.492549, 1.36195, 0.503357], [-0.065665, 0.148645, 0.906293]]), Mat3([[0.482991, -0.070139, -0.027429], [-0.478804, 1.14702, 0.282926], [-0.119226, 0.194381, 0.616628]])], + forward_matrix: [Mat3([[0.788283, 0.10759, 0.068422], [0.261923, 0.909809, -0.171732], [0.053026, -0.362705, 1.13478]]), Mat3([[0.83437, 0.173346, -0.043421], [0.336938, 0.996333, -0.333271], [0.037073, -0.203664, 0.991696]])], + }, + Camera { + make: "Sony", + model: "ILCE-7M3", + color_matrix: [Mat3([[0.759047, -0.22787, 0.042531], [-0.502563, 1.37732, 0.491076], [-0.066877, 0.150947, 0.900094]]), Mat3([[0.482867, -0.070899, -0.024574], [-0.489531, 1.16667, 0.274244], [-0.123988, 0.201218, 0.610148]])], + forward_matrix: [Mat3([[0.787201, 0.113043, 0.064052], [0.265374, 0.902211, -0.167585], [0.052744, -0.364541, 1.1369]]), Mat3([[0.840001, 0.174345, -0.05005], [0.340881, 0.981246, -0.322127], [0.039232, -0.209817, 0.995689]])], + }, + Camera { + make: "Sony", + model: "ILCE-7M4", + color_matrix: [Mat3([[0.763892, -0.227658, 0.033049], [-0.512687, 1.38412, 0.503225], [-0.063774, 0.141899, 0.91715]]), Mat3([[0.485445, -0.074907, -0.026973], [-0.491937, 1.15934, 0.283071], [-0.115422, 0.185993, 0.622862]])], + forward_matrix: [Mat3([[0.784064, 0.112194, 0.068037], [0.2698, 0.896428, -0.166229], [0.050008, -0.338826, 1.11392]]), Mat3([[0.830062, 0.181159, -0.046925], [0.341401, 0.985843, -0.327244], [0.034749, -0.188826, 0.979182]])], + }, + Camera { + make: "Sony", + model: "ILCE-7RM2", + color_matrix: [Mat3([[0.775308, -0.25358, 0.026979], [-0.524545, 1.40154, 0.490876], [-0.060608, 0.132591, 0.920838]]), Mat3([[0.492753, -0.089554, -0.036484], [-0.505928, 1.18284, 0.273705], [-0.116121, 0.182519, 0.626106]])], + forward_matrix: [Mat3([[0.76369, 0.133034, 0.067571], [0.267238, 0.888036, -0.155274], [0.048003, -0.313698, 1.0908]]), Mat3([[0.787687, 0.201628, -0.025019], [0.326839, 0.971594, -0.298433], [0.034173, -0.177854, 0.968786]])], + }, + Camera { + make: "Sony", + model: "ILCE-7RM3", + color_matrix: [Mat3([[0.742519, -0.22996, 0.032883], [-0.500197, 1.37662, 0.485744], [-0.063378, 0.142467, 0.898759]]), Mat3([[0.473388, -0.075142, -0.031025], [-0.487453, 1.16732, 0.271829], [-0.119917, 0.192619, 0.610537]])], + forward_matrix: [Mat3([[0.776802, 0.119664, 0.06783], [0.260471, 0.900308, -0.160779], [0.051869, -0.347156, 1.12039]]), Mat3([[0.813753, 0.181916, -0.031373], [0.32902, 0.979656, -0.308676], [0.037764, -0.198487, 0.985828]])], + }, + Camera { + make: "Sony", + model: "ILCE-7RM4", + color_matrix: [Mat3([[0.732028, -0.220808, 0.020118], [-0.501296, 1.37502, 0.49365], [-0.061615, 0.13838, 0.885461]]), Mat3([[0.472151, -0.07908, -0.031322], [-0.483014, 1.15528, 0.279019], [-0.111192, 0.18072, 0.604588]])], + forward_matrix: [Mat3([[0.775623, 0.114594, 0.074079], [0.260863, 0.901608, -0.162471], [0.050735, -0.342927, 1.1173]]), Mat3([[0.806038, 0.193021, -0.034763], [0.32686, 0.992356, -0.319216], [0.033831, -0.189102, 0.980375]])], + }, + Camera { + make: "Sony", + model: "ILCE-7SM2", + color_matrix: [Mat3([[0.615775, -0.137144, 0.06099], [-0.408209, 1.28402, 0.462018], [-0.084205, 0.196387, 0.85663]]), Mat3([[0.395982, -0.016098, -0.005517], [-0.420931, 1.11058, 0.265879], [-0.162165, 0.267589, 0.576945]])], + forward_matrix: [Mat3([[0.854583, 0.041834, 0.067878], [0.234125, 0.948414, -0.182539], [0.097857, -0.530195, 1.25744]]), Mat3([[0.948357, 0.092307, -0.076369], [0.33668, 1.02973, -0.366408], [0.073834, -0.330955, 1.08223]])], + }, + Camera { + make: "Sony", + model: "ILCE-7SM3", + color_matrix: [Mat3([[0.749715, -0.228299, 0.030188], [-0.512062, 1.38442, 0.500457], [-0.072776, 0.169469, 0.933545]]), Mat3([[0.476058, -0.07383, -0.031403], [-0.495874, 1.16904, 0.277606], [-0.134473, 0.222664, 0.625349]])], + forward_matrix: [Mat3([[0.779691, 0.113899, 0.070706], [0.268632, 0.905927, -0.174559], [0.04639, -0.390929, 1.16964]]), Mat3([[0.816745, 0.179015, -0.031464], [0.334824, 0.988848, -0.323672], [0.037938, -0.229363, 1.01653]])], + }, + Camera { + make: "Sony", + model: "ILCE-9", + color_matrix: [Mat3([[0.747492, -0.216385, 0.049086], [-0.499768, 1.37142, 0.499037], [-0.071324, 0.164705, 0.951496]]), Mat3([[0.473639, -0.063703, -0.016557], [-0.488372, 1.1591, 0.280181], [-0.129154, 0.213406, 0.651]])], + forward_matrix: [Mat3([[0.796409, 0.106147, 0.061739], [0.26822, 0.906732, -0.174952], [0.050574, -0.378333, 1.15286]]), Mat3([[0.868551, 0.167059, -0.071314], [0.354491, 0.985971, -0.340462], [0.037779, -0.211149, 0.998474]])], + }, +]; + +/// `(make, model as sold, table model)`: other names of a camera in [`CAMERAS`], or of a camera with the same sensor. +#[rustfmt::skip] +pub(super) const ALIASES: &[(&str, &str, &str)] = &[ + ("Canon", "EOS 400D", "Digital Rebel XTi"), // same model, alternative name + ("Canon", "Kiss Digital X", "Digital Rebel XTi"), // same model, alternative name + ("Canon", "EOS Rebel SL1", "EOS 100D"), // same model, alternative name + ("Canon", "Kiss X7", "EOS 100D"), // same model, alternative name + ("Canon", "EOS Rebel SL2", "EOS 200D"), // same model, alternative name + ("Canon", "Kiss X9", "EOS 200D"), // same model, alternative name + ("Canon", "EOS 250D", "EOS 200D II"), // same model, alternative name + ("Canon", "EOS Rebel SL3", "EOS 200D II"), // same model, alternative name + ("Canon", "Kiss X10", "EOS 200D II"), // same model, alternative name + ("Canon", "EOS Rebel T3i", "EOS 600D"), // same model, alternative name + ("Canon", "Kiss X5", "EOS 600D"), // same model, alternative name + ("Canon", "EOS R5 Mark II", "EOS R5m2"), // same model, alternative name + ("Canon", "EOS 5DS R", "EOS 5DS"), // different model, same sensor + ("Canon", "EOS 6D Mark II", "EOS RP"), // different model, same sensor + ("Canon", "EOS R5 C", "EOS R5"), // different model, same sensor + ("Canon", "EOS R50", "EOS R10"), // different model, same sensor + ("Canon", "EOS R8", "EOS R6m2"), // different model, same sensor + ("Nikon", "D800", "D800E"), // different model, same sensor + ("Nikon", "Z 6_2", "Z f"), // different model, same sensor + ("Sony", "ILCE-7RM5", "ILCE-7RM4"), // different model, same sensor + ("Sony", "ILCE-7RM4A", "ILCE-7RM4"), // updated model, same sensor + ("Sony", "ILCE-7RM3A", "ILCE-7RM3"), // updated model, same sensor + ("Fujifilm", "GFX100S", "GFX 100"), // different model, same sensor +]; diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 813b1e162..afa48dfa6 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -99,6 +99,35 @@ Measured before → after (mean L* and CIE76 ΔE at 200 px): on the 24 ILCE-7CR Picture styles other than Standard are lowered to the Standard curve too where it is darker; Lightroom ignores them as well. Nikon Active D-Lighting, Canon Auto Lighting Optimizer and Fujifilm D-Range settings are likely to have the same effect and are not handled yet. Render cache version 23 redraws thumbnails; smart previews built before keep the brightened tone curve in their header until rebuilt. +## Spectral camera matrices and the order of precedence + +For 52 camera models LightCraft has colour matrices fitted to the camera's measured spectral sensitivities +(`crates/raw/src/spectral.rs`, generated into `spectral_table.rs` by `tools/spectral_camera_table.py` from +[rawtoaces-data](https://github.com/AcademySoftwareFoundation/rawtoaces-data) v1.1.0, Apache-2.0): 22 Canon, 11 Nikon, +11 Sony, 3 Fujifilm and 5 others, plus the other names rawtoaces-data lists for them (EOS Rebel T3i and Kiss X5 for +the EOS 600D; bodies it lists as sharing a sensor, such as the GFX100S with the GFX 100). Per model the generator fits, +under CIE Standard Illuminant A and under D65, a white-preserving 3×3 from white-balanced camera RGB to XYZ by the +least mean CIE76 error over rawtoaces-data's 190 training reflectances, and writes DNG `ColorMatrix1/2` and +`ForwardMatrix1/2`. The existing DNG colour model then interpolates them for the file's as-shot white, which the +vendor white-balance multipliers give through the same matrices. No number comes from Adobe or from a raw decoder. + +A raw starts from the first of these that is available (`camera_preview::starting_colour`): + +1. the file's own colour matrices (DNG); +2. a camera profile for its model (bundled or local), with tone and chroma fitted to the file's JPEG; +3. the file-local fit to its own JPEG described above; +4. the model's spectral matrices, with LightCraft's default tone curve and no JPEG fit; +5. the neutral fallback. + +The spectral matrices only replace the neutral fallback: a photo whose JPEG fit is accepted (with or without a +profile) renders exactly as before, and a covered model takes its spectral matrices only when that fit fails the +gates or the file has no usable JPEG. White balance stays relative to the as-shot look for every raw without matrices +of its own, as before, so the catalog's `Photo::relative_wb` and the controls are unchanged. `lightcraft-cli +calibrate` still pools the uncorrected camera colour of covered models, so a profile can be fitted for them. + +No colour-difference measurement of the spectral matrices against the camera JPEG, or against the file-local fit, +is recorded here yet. Whether they should come before the file-local fit for these models is open until one is. + ## Nikon crop and preview colour metadata Nikon maker-note `CropArea` (0x0045) supplies the default `[left, top, width, height]` crop. The decoder validates the rectangle against the active sensor area and falls back to that area for missing or invalid values. The CFA origin is unchanged; cropping follows demosaicing. diff --git a/docs/parity.md b/docs/parity.md index fbc62f205..81eb2c034 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -299,7 +299,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`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it), kept with smart previews (see docs/camera-preview-colour.md); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); this is 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-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it), kept with smart previews (see docs/camera-preview-colour.md); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); this is 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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 11f01b07e..fb1fd3165 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -166,6 +166,11 @@ - Samsung SRW files without compression now open as raws: NX5, NX10, NX11, NX20, NX200, NX210, NX1000, NX1100, EX1 and WB2000. The compressed ones (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini) still open from their camera JPEG. Photos already imported pick the change up on Reload. +- Raws from 52 camera models (22 Canon, 11 Nikon, 11 Sony, 3 Fujifilm and 5 others, plus their other names) whose + camera JPEG can't be fitted no longer open with the neutral fallback: they start from colour matrices fitted to the + camera's measured spectral sensitivities, from the Academy Software Foundation's rawtoaces-data. A camera profile + and the fit to the photo's own JPEG still come first, so photos that had a fitted look keep it. See + [camera preview colour](camera-preview-colour.md#spectral-camera-matrices-and-the-order-of-precedence). - Canon CR2 raws no longer open too dark with blocked-up shadows: the black level was measured over border columns that light already reaches (left of the image area Canon records), 4 % of the range too high on the EOS 6D. It is now measured on the masked columns alone (8 corpus bodies, EOS 40D to 5DS R). On a 6D photo the starting render diff --git a/tools/requirements-spectral-camera-table.txt b/tools/requirements-spectral-camera-table.txt new file mode 100644 index 000000000..c758ce1d9 --- /dev/null +++ b/tools/requirements-spectral-camera-table.txt @@ -0,0 +1,5 @@ +# Python packages for tools/spectral_camera_table.py (crates/raw/src/spectral_table.rs). +# python3 -m venv .venv-spectral && .venv-spectral/bin/pip install -r tools/requirements-spectral-camera-table.txt +numpy==2.5.3 +scipy==1.18.1 +colour-science==0.4.7 diff --git a/tools/spectral_camera_table.py b/tools/spectral_camera_table.py new file mode 100644 index 000000000..b05ac2802 --- /dev/null +++ b/tools/spectral_camera_table.py @@ -0,0 +1,122 @@ +# Writes crates/raw/src/spectral_table.rs: DNG-shaped colour matrices per camera model, fitted to the +# measured spectral sensitivities in rawtoaces-data (Academy Software Foundation, Apache-2.0). +# +# git clone https://github.com/AcademySoftwareFoundation/rawtoaces-data ../rawtoaces-data +# git -C ../rawtoaces-data checkout v1.1.0 +# python3 -m venv .venv-spectral && .venv-spectral/bin/pip install -r tools/requirements-spectral-camera-table.txt +# .venv-spectral/bin/python tools/spectral_camera_table.py ../rawtoaces-data crates/raw/src/spectral_table.rs +# +# Method (Academy P-2013-001, written from its description; no rawtoaces code is used): the camera and the CIE 1931 +# observer both look at the 190 training reflectances of rawtoaces-data under CIE Standard Illuminant A and under D65. +# The camera values are white balanced to that illuminant, and a white-preserving 3x3 to XYZ is fitted by least +# mean CIE76 error in Lab. Per illuminant this gives ColorMatrix (XYZ -> camera, DNG CalibrationIlluminant 17 / 21) +# and ForwardMatrix (white-balanced camera -> XYZ D50). The aliases are rawtoaces-data's own `data/aliases.json`. +import json +import os +import subprocess +import sys + +import colour +import numpy as np +from scipy.optimize import minimize + +WL = np.arange(380, 781, 5) +D50 = colour.xy_to_XYZ(colour.CCS_ILLUMINANTS["CIE 1931 2 Degree Standard Observer"]["D50"]) + + +def spectral(path, columns): + data = json.load(open(path))["spectral_data"]["data"]["main"] + return np.array([[data[str(w)][i] for i in range(columns)] for w in WL], dtype=float) + + +def illuminant(name): + sd = colour.SDS_ILLUMINANTS[name] + return np.array([sd[w] for w in WL], dtype=float) + + +def fit(cmf, refl, ssf, light, target_white): + """White-balanced camera RGB -> XYZ adapted to `target_white`; maps (1, 1, 1) to `target_white`.""" + w = ssf.T @ light + cam = (ssf.T @ (light[:, None] * refl)) / w[:, None] + k = 1.0 / (cmf[:, 1] @ light) + xyz = k * (cmf.T @ (light[:, None] * refl)) + white = k * (cmf.T @ light) + cat = colour.adaptation.matrix_chromatic_adaptation_VonKries(white, target_white, transform="Bradford") + xyz = cat @ xyz + target_xy = colour.XYZ_to_xy(target_white) + lab = colour.XYZ_to_Lab(xyz.T, target_xy) + + def build(p): + return np.stack([p[:3], p[3:], target_white - p[:3] - p[3:]], axis=1) + + def loss(p): + return float(np.mean(np.linalg.norm(colour.XYZ_to_Lab((build(p) @ cam).T, target_xy) - lab, axis=1))) + + m0 = xyz @ np.linalg.pinv(cam) + r = minimize(loss, np.concatenate([m0[:, 0], m0[:, 1]]), method="Nelder-Mead", options={"maxiter": 20000, "xatol": 1e-9, "fatol": 1e-9}) + return build(r.x), loss(r.x) + + +def dng_tags(cmf, refl, ssf): + """[(ColorMatrix, ForwardMatrix, mean training dE76)] for Standard Illuminant A, then D65.""" + out = [] + for name in ("A", "D65"): + light = illuminant(name) + forward, error = fit(cmf, refl, ssf, light, D50) + k = 1.0 / (cmf[:, 1] @ light) + white = k * (cmf.T @ light) + to_xyz, _ = fit(cmf, refl, ssf, light, white) + cam_from_xyz = np.diag(ssf.T @ light) @ np.linalg.inv(to_xyz) + cam_from_xyz = cam_from_xyz / (cam_from_xyz @ white).max() + out.append((cam_from_xyz.round(6), forward.round(6), error)) + return out + + +def mat(m): + return "Mat3([" + ", ".join("[" + ", ".join(f"{v:.6}" for v in row) + "]" for row in m) + "])" + + +def main(root, out): + commit = subprocess.run(["git", "-C", root, "rev-parse", "HEAD"], capture_output=True, text=True, check=True).stdout.strip() + cmf = spectral(os.path.join(root, "data/cmf/cmf_1931.json"), 3) + training = json.load(open(os.path.join(root, "data/training/training_spectral.json")))["spectral_data"]["data"]["main"] + refl = np.array([training[str(w)] for w in WL], dtype=float) + rows = [] + for f in sorted(os.listdir(os.path.join(root, "data/camera"))): + if not f.endswith("_380_780_5.json"): + continue + path = os.path.join(root, "data/camera", f) + header = json.load(open(path))["header"] + if header.get("license") != "Apache-2.0": + sys.exit(f"{f}: licence {header.get('license')!r}, expected Apache-2.0") + (cm1, fm1, e1), (cm2, fm2, e2) = dng_tags(cmf, refl, spectral(path, 3)) + print(f"{f}: training dE76 A {e1:.3f}, D65 {e2:.3f}", file=sys.stderr) + rows.append( + f' Camera {{\n make: "{header["manufacturer"]}",\n model: "{header["model"]}",\n' + f" color_matrix: [{mat(cm1)}, {mat(cm2)}],\n forward_matrix: [{mat(fm1)}, {mat(fm2)}],\n }}," + ) + aliases = json.load(open(os.path.join(root, "data/aliases.json")))["data"]["camera"]["model"] + alias_rows = [ + f' ("{make}", "{alias}", "{target["model"]}"), // {target.get("comments", "")}' + for make, models in aliases.items() + for alias, target in models.items() + ] + rust = ( + "// Generated by tools/spectral_camera_table.py from rawtoaces-data " + f"(https://github.com/AcademySoftwareFoundation/rawtoaces-data, commit {commit}).\n" + "// Derived from the camera spectral sensitivities of rawtoaces-data (Academy Software Foundation), licensed under\n" + "// the Apache License 2.0 (LICENSE-APACHE); see NOTICE and assets/ATTRIBUTION.md. Do not edit by hand.\n" + "use super::Camera;\n" + "use lightcraft_color::Mat3;\n\n" + "#[rustfmt::skip]\npub(super) const CAMERAS: &[Camera] = &[\n" + "\n".join(rows) + "\n];\n\n" + "/// `(make, model as sold, table model)`: other names of a camera in [`CAMERAS`], or of a camera with the same sensor.\n" + "#[rustfmt::skip]\npub(super) const ALIASES: &[(&str, &str, &str)] = &[\n" + "\n".join(alias_rows) + "\n];\n" + ) + open(out, "w").write(rust) + print(f"{len(rows)} cameras, {len(alias_rows)} aliases -> {out}", file=sys.stderr) + + +if __name__ == "__main__": + if len(sys.argv) != 3: + sys.exit("usage: spectral_camera_table.py ") + main(sys.argv[1], sys.argv[2])