From e87ee8e36d460abe6c62df02e9b1be7c475f2b0d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 5 Oct 2026 07:24:16 -0400 Subject: [PATCH] Issue #85: Raw: CR2 colour pattern from the file's CR2CFAPattern tag, not a constant The Bayer layout of Canon CR2 files differs by model (GBRG on the 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II, 1D Mark IV; RGGB on the 40D, 5D Mark III, 6D, 5DS R and others). Read it from raw IFD tag 0xc5e0 (CR2CFAPattern: 1 RGGB, 2 BGGR, 3 GBRG, 4 GRBG), anchored at the full decoded sensor; fall back to RGGB when the tag is missing. No model table. Checked on 35 decoded CC0 raw.pixls.us CR2s (35 models): the tag agrees with the mosaic's green-diagonal statistic on every file; the SensorInfo border parity does not predict the layout. Corpus manifest gains the reporter's samples; a corpus test asserts each layout. Co-Authored-By: Claude Opus 5.5 --- crates/raw/src/vendor/cr2.rs | 34 ++++++++++++++++++++++++- crates/raw/tests/corpus.rs | 48 ++++++++++++++++++++++++++++++++++++ docs/parity.md | 2 +- docs/whats-new.md | 3 +++ xtask/src/main.rs | 7 ++++++ 5 files changed, 92 insertions(+), 2 deletions(-) diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index fa15456a..d29e45f4 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -7,6 +7,11 @@ //! - The raw data is IFD#3's single strip: a lossless-JPEG frame (usually 2 or 4 components). //! - The decoded sample stream fills vertical slices left to right: `cr2_slice = [n, w, last]` means `n` slices of //! width `w` then one of width `last`; each slice is filled top to bottom, row by row. +//! - Colour filter layout: IFD#3 tag `0xc5e0` (`CR2CFAPattern` in the ExifTool EXIF tag-name docs): 1 = RGGB, +//! 2 = BGGR, 3 = GBRG, 4 = GRBG, anchored at the top-left of the full decoded sensor (masked borders included). +//! It differs by model (e.g. 3 on the 50D/60D/7D/550D/5D Mark II, 1 on the 40D/5D Mark III/6D/5DS R), so it is +//! read per file (no model table); files without the tag fall back to RGGB (issue #85). The parity of the +//! `SensorInfo` borders does *not* predict it (RGGB files come with both even and odd top borders). //! - `SensorInfo` (maker note `0x00e0`): sensor width/height and the left/top/right/bottom borders of the image //! area; the masked columns left of it give the black level. //! - `ColorBalance` (maker note `0x4001`): as-shot `RGGB` levels at a model-dependent offset; we probe the known @@ -21,6 +26,7 @@ use lightcraft_tiff::{Ifd, Tiff, makernote, tags as t}; const CR2_SLICE: u16 = 0xc640; const SRAW_TYPE: u16 = 0xc6c5; +const CR2_CFA_PATTERN: u16 = 0xc5e0; const SENSOR_INFO: u16 = 0x00e0; const COLOR_BALANCE: u16 = 0x4001; @@ -28,6 +34,18 @@ fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { tiff.ifds.get(3).or_else(|| tiff.ifds.iter().rev().find(|i| i.contains(CR2_SLICE))) } +/// Bayer layout from the raw IFD's `CR2CFAPattern` (`0xc5e0`) value; `None` for missing/unknown values. +fn cfa_from_tag(v: Option) -> Option { + let name = match v? { + 1 => "RGGB", + 2 => "BGGR", + 3 => "GBRG", + 4 => "GRBG", + _ => return None, + }; + Some(Cfa::bayer_static(name)) +} + /// As-shot WB multipliers (R, G, B; G = 1) from the ColorBalance array. fn wb_from_color_balance(v: &[u64]) -> Option<[f32; 3]> { for off in [63usize, 25, 24, 34, 71, 85, 105, 69, 77] { @@ -117,7 +135,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { } } let wb = mn.as_ref().and_then(|m| m.ifd.u64s(COLOR_BALANCE)).and_then(|v| wb_from_color_balance(&v)); - let cfa = Cfa::bayer_static("RGGB"); + let cfa = cfa_from_tag(raw.u64(CR2_CFA_PATTERN)).unwrap_or_else(|| Cfa::bayer_static("RGGB")); let black = if active.x >= 8 { black_from_columns(&data, width, 2..active.x - 2, active.y..active.y + active.height, active) } else { @@ -185,6 +203,7 @@ pub(crate) mod tests { ifd0.set_child(t::EXIF_IFD, exif); let mut ifd3 = IfdBuilder::new(); ifd3.set(t::COMPRESSION, Value::Short(vec![6])); + ifd3.set(CR2_CFA_PATTERN, Value::Long(vec![3])); if let Some(s) = slices { ifd3.set(CR2_SLICE, Value::Short(s.to_vec())); } @@ -206,11 +225,24 @@ pub(crate) mod tests { assert_eq!(crate::probe_info(&bytes).unwrap(), r.info()); assert_eq!(r.data, RawData::U16(img)); assert_eq!(r.orientation, Orientation::Rotate270); + assert_eq!(r.cfa.as_ref().map(Cfa::name).as_deref(), Some("GBRG"), "CR2CFAPattern 3"); assert_eq!(r.metadata.iso, Some(400)); assert!(r.develop(crate::Method::Ahd).is_ok()); } } + #[test] + fn cfa_pattern_tag() { + let name = |v| cfa_from_tag(v).map(|c| c.name()); + assert_eq!(name(Some(1)).as_deref(), Some("RGGB")); + assert_eq!(name(Some(2)).as_deref(), Some("BGGR")); + assert_eq!(name(Some(3)).as_deref(), Some("GBRG")); + assert_eq!(name(Some(4)).as_deref(), Some("GRBG")); + assert_eq!(name(Some(0)), None); + assert_eq!(name(Some(7)), None); + assert_eq!(name(None), None); + } + #[test] fn wb_probe() { let mut v = vec![0u64; 120]; diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index fa8a2626..8d9ab89f 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -122,6 +122,54 @@ fn correlation(a: &[f64], b: &[f64]) -> f64 { cov / (va * vb).sqrt() } +/// Which diagonal of each 2×2 cell (anchored at raw pixel (0, 0)) holds the green sites: the two greens of a Bayer +/// cell see nearly the same light, so their mean absolute difference is far smaller than across the other diagonal. +/// Returns `true` when green sits at (0, 0)/(1, 1) (GBRG/GRBG), `false` for (1, 0)/(0, 1) (RGGB/BGGR). +fn green_on_main_diagonal(img: &lightcraft_raw::RawImage) -> bool { + let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") }; + let (w, a) = (img.width, img.active_area); + let (mut main, mut anti) = (0f64, 0f64); + for y in ((a.y + 2) & !1..a.y + a.height - 2).step_by(2) { + for x in ((a.x + 2) & !1..a.x + a.width - 2).step_by(2) { + let v = |dx: usize, dy: usize| d[(y + dy) * w + x + dx] as f64; + main += (v(0, 0) - v(1, 1)).abs(); + anti += (v(1, 0) - v(0, 1)).abs(); + } + } + main < anti +} + +/// Canon CR2 colour-filter layouts differ by model (issue #85); the decoder reads them from the `CR2CFAPattern` tag. +/// The expected layouts were checked visually (natural colours vs. the embedded preview) and agree with the +/// green-diagonal statistic of the mosaic itself. +#[test] +fn corpus_cr2_cfa_patterns() { + let dir = corpus_root().join("raw"); + let cases = [ + ("cr2-canon-40d.cr2", "RGGB"), + ("cr2-canon-550d.cr2", "GBRG"), + ("cr2-canon-5d2.cr2", "GBRG"), + ("cr2-canon-5d3.cr2", "RGGB"), + ("cr2-canon-5dsr.cr2", "RGGB"), + ("cr2-canon-6d.cr2", "RGGB"), + ("cr2-canon-7d.cr2", "GBRG"), + ("cr2-canon-80d.cr2", "RGGB"), + ]; + let mut seen = 0; + for (name, want) in cases { + let Ok(bytes) = std::fs::read(dir.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + let got = img.cfa.as_ref().map(|c| c.name()).unwrap_or_default(); + assert_eq!(got, want, "{name}: CFA layout"); + assert_eq!(green_on_main_diagonal(&img), want.starts_with('G'), "{name}: mosaic statistics disagree with {want}"); + seen += 1; + } + eprintln!("CR2 CFA layouts checked on {seen} files"); +} + /// raw.pixls.us has the same D5100 scene as 14-bit lossless compressed and uncompressed NEF: the Huffman decode must /// match the uncompressed image (up to the small differences between two exposures). #[test] diff --git a/docs/parity.md b/docs/parity.md index 6479d7d6..fd90d913 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -110,7 +110,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2, ARW, NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. Missing: CR3, compressed RAF/ORF, RW2 v4, NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW, NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. Missing: CR3, compressed RAF/ORF, RW2 v4, NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | | LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 05de11b2..dbbadf90 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -10,6 +10,9 @@ - 23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners. ### Reliability +- Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other + models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter + layout is read from each file instead of assumed. - Exports are never black because of the GPU (issue #78): a GPU render that runs out of device memory, exceeds the GPU's buffer limits, hits a driver error or reset, or comes back incomplete is redone on the CPU — the file is the same image either way. Work is sent to the GPU diff --git a/xtask/src/main.rs b/xtask/src/main.rs index b91e6b86..073aecec 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -234,6 +234,13 @@ const RAW_SAMPLES: &[(&str, &str)] = &[ ("arw-sony-a7m4-lossless-l.arw", "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06674_FullFrame-LossLess-Compressed-Large.ARW"), ("arw-sony-a7m4-lossless-m.arw", "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06675_FullFrame-LossLess-Compressed-Medium.ARW"), ("arw-sony-a7m4-lossless-s.arw", "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06676_FullFrame-LossLess-Compressed-Small.ARW"), + // CR2 colour-filter layouts differ by model (issue #85): CR2CFAPattern 3 (GBRG) and 1 (RGGB) samples + ("cr2-canon-40d.cr2", "https://raw.pixls.us/data/Canon/EOS%2040D/_MG_0153.CR2"), + ("cr2-canon-550d.cr2", "https://raw.pixls.us/data/Canon/EOS%20550D/IMG_4047.CR2"), + ("cr2-canon-5d2.cr2", "https://raw.pixls.us/data/Canon/EOS%205D%20Mark%20II/08.canon.raw.cr2"), + ("cr2-canon-5dsr.cr2", "https://raw.pixls.us/data/Canon/EOS%205DS%20R/_DSR2002.CR2"), + ("cr2-canon-6d.cr2", "https://raw.pixls.us/data/Canon/EOS%206D/EOS_6D_RAW.CR2"), + ("cr2-canon-7d.cr2", "https://raw.pixls.us/data/Canon/EOS%207D/RAW_CANON_EOS_7D-raw.CR2"), ("cr2-canon-5d3-sraw2.cr2", "https://raw.pixls.us/getfile.php/773/nice/Canon%20-%20EOS%205D%20Mark%20III%20-%20sRAW2%20%28sRAW%29.CR2"), ("cr2-canon-5d3.cr2", "https://raw.pixls.us/getfile.php/771/nice/Canon%20-%20EOS%205D%20Mark%20III.CR2"), ("cr2-canon-80d.cr2", "https://raw.pixls.us/getfile.php/1294/nice/Canon%20-%20EOS%2080D%20-%20RAW%20%283:2%29.CR2"),