//! RAW decoding for DarkRoom. //! //! Four separate entry points rather than one `decode`, because callers differ //! sharply in what they need (ARCH §3.2): //! //! - **Culling** wants [`embedded_preview`] and nothing else — a ~200 KB read //! against a 34 MB file. //! - **The grid** wants [`metadata`]. //! - **Develop and export** need [`decode`], the only path that touches sensor //! data. //! //! Fusing them would force a full decode where a header read suffices, which //! is exactly why Lightroom stalls ~2 s per image during culling. mod error; mod preview; pub use error::DecodeError; pub use preview::{ decode_jpeg, extract_embedded_preview, extract_preview, Preview, PreviewSize, PREVIEW_PROBE_BYTES, }; use dr_types::Format; /// Capture metadata read from a file header. #[derive(Debug, Clone, Default, PartialEq)] pub struct Metadata { pub make: Option, pub model: Option, pub lens: Option, /// Exposure time in seconds. pub shutter: Option, pub aperture: Option, pub iso: Option, pub focal_length: Option, /// Full sensor dimensions, before crop. pub width: Option, pub height: Option, } /// Decoded sensor data, before demosaic. /// /// Deliberately *not* RGB: demosaic is a GPU pipeline stage (ARCH §5.2), so /// this carries CFA-pattern samples plus what the shader needs to interpret /// them. #[derive(Debug, Clone)] pub struct RawImage { /// Width of `data` in samples — the *full* sensor row stride, including /// any masked border. Not the width the user sees; see [`Self::crop`]. pub width: u32, pub height: u32, /// One sample per photosite, in sensor order. pub data: Vec, pub cfa_pattern: CfaPattern, pub black_level: [u16; 4], pub white_level: u16, /// As-shot white balance, as per-channel multipliers. pub wb_coeffs: [f32; 4], /// Camera RGB to linear sRGB (D65), row-major 3×3 (FR-DEV-3e). /// /// `None` where the body is unknown to the decoder, in which case the /// pipeline falls back to identity and the result is uncalibrated rather /// than wrong-by-a-guess. pub color_matrix: Option<[f32; 9]>, /// The usable region of `data`, excluding masked and border photosites. pub crop: CropRect, } /// TRACES: FR-RAW-3 /// The usable region of a sensor readout. /// /// RAW files carry photosites the image does not include: optically black /// columns used to measure the black level, and a few border rows most /// demosaics need as context but no viewer should display. Cropping is /// therefore not an edit — it is part of reading the file correctly. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CropRect { pub x: u32, pub y: u32, pub width: u32, pub height: u32, } impl CropRect { /// Whether the crop origin shifts the CFA phase. /// /// A Bayer pattern repeats every 2×2, so a crop starting at an odd /// coordinate makes the top-left photosite of the *visible* image a /// different colour than the pattern names. Demosaicing without /// accounting for it swaps red and blue — the classic symptom being a /// correctly-exposed image with wildly wrong colour. pub fn shifts_cfa_phase(&self) -> (bool, bool) { (self.x % 2 == 1, self.y % 2 == 1) } } /// TRACES: FR-RAW-5 /// The colour filter array layout. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CfaPattern { Rggb, Bggr, Grbg, Gbrg, /// Fujifilm's 6×6 pattern. Needs a different demosaic entirely /// (FR-RAW-5), at roughly 2× the cost of Bayer. XTrans, Unknown, } impl CfaPattern { /// Whether this needs the X-Trans demosaic path rather than Bayer. pub fn is_xtrans(self) -> bool { matches!(self, CfaPattern::XTrans) } /// The pattern as seen from an origin shifted by `(dx, dy)` photosites. /// /// Used to re-phase the pattern after cropping to the active area /// ([`CropRect::shifts_cfa_phase`]). X-Trans is returned unchanged: its /// 6×6 cell does not re-phase under a 2×2 shift, so the X-Trans demosaic /// handles the offset itself. pub fn shifted(self, dx: bool, dy: bool) -> Self { use CfaPattern::*; if matches!(self, XTrans | Unknown) { return self; } // Shifting one column swaps the pair horizontally; one row swaps // vertically. Both together is the diagonal opposite. let after_x = if dx { match self { Rggb => Grbg, Grbg => Rggb, Bggr => Gbrg, Gbrg => Bggr, other => other, } } else { self }; if dy { match after_x { Rggb => Gbrg, Gbrg => Rggb, Grbg => Bggr, Bggr => Grbg, other => other, } } else { after_x } } /// The colour of the photosite at `(x, y)` within the pattern. /// /// Channel indices are 0=R, 1=G, 2=B, matching the shader's convention. pub fn colour_at(self, x: u32, y: u32) -> u8 { use CfaPattern::*; // Each 2×2 cell listed row-major from its own origin. let cell: [u8; 4] = match self { Rggb => [0, 1, 1, 2], Bggr => [2, 1, 1, 0], Grbg => [1, 0, 2, 1], Gbrg => [1, 2, 0, 1], // Not meaningful for a 6×6 pattern or an unknown one; the caller // must not be on the Bayer path at all. XTrans | Unknown => [1, 1, 1, 1], }; cell[((y % 2) * 2 + (x % 2)) as usize] } } /// TRACES: FR-RAW-1 | M-9 /// Identify a format from a file header. /// /// Content-based, not extension-based: an extension is a hint, and a /// mismatched one should not produce a confusing decode failure downstream. pub fn probe(header: &[u8]) -> Option { if header.len() < 16 { return None; } // JPEG: SOI marker. if header.starts_with(&[0xFF, 0xD8, 0xFF]) { return Some(Format::Jpeg); } // Fujifilm RAF carries an ASCII signature. if header.starts_with(b"FUJIFILMCCD-RAW") { return Some(Format::Raf); } // CR3 is ISO-BMFF: a `ftyp` box with a Canon brand. if header.len() >= 12 && &header[4..8] == b"ftyp" && &header[8..11] == b"crx" { return Some(Format::Cr3); } // The TIFF-derived formats share a byte-order mark plus magic. CR2 adds // its own marker at offset 8; the rest are indistinguishable from the // header alone and need the extension to disambiguate. let le = header.starts_with(&[0x49, 0x49, 0x2A, 0x00]); let be = header.starts_with(&[0x4D, 0x4D, 0x00, 0x2A]); if le || be { if header.len() >= 11 && &header[8..10] == b"CR" { return Some(Format::Cr2); } // Ambiguous between NEF, ARW, DNG, ORF, RW2 — caller falls back to // the extension. return None; } None } /// TRACES: FR-CAT-5 | M-12 /// Read capture metadata without decoding sensor data. pub fn metadata(bytes: &[u8]) -> Result { use rawler::rawsource::RawSource; let source = RawSource::new_from_slice(bytes); let decoder = rawler::get_decoder(&source).map_err(|e| DecodeError::Unsupported(e.to_string()))?; let md = decoder .raw_metadata(&source, &Default::default()) .map_err(|e| DecodeError::Metadata(e.to_string()))?; let exif = &md.exif; Ok(Metadata { make: Some(md.make.clone()).filter(|s| !s.is_empty()), model: Some(md.model.clone()).filter(|s| !s.is_empty()), lens: exif.lens_model.clone(), shutter: exif.exposure_time.map(|r| r.n as f32 / r.d.max(1) as f32), aperture: exif.fnumber.map(|r| r.n as f32 / r.d.max(1) as f32), iso: exif.iso_speed_ratings.map(|v| v as u32), focal_length: exif.focal_length.map(|r| r.n as f32 / r.d.max(1) as f32), width: None, height: None, }) } /// TRACES: FR-RAW-3 | FR-EXP-9 /// Fully decode sensor data. /// /// The expensive path — reads the whole file and unpacks every photosite. /// Only develop and export should call it; culling and the grid must not /// (FR-CULL-1). pub fn decode(bytes: &[u8]) -> Result { use rawler::rawsource::RawSource; let source = RawSource::new_from_slice(bytes); let decoder = rawler::get_decoder(&source).map_err(|e| DecodeError::Unsupported(e.to_string()))?; let image = decoder .raw_image(&source, &Default::default(), false) .map_err(|e| DecodeError::Decode(e.to_string()))?; // Derived before the match below moves `image.data`. let color_matrix = cam_to_srgb(&image); let data = match image.data { rawler::RawImageData::Integer(v) => v, rawler::RawImageData::Float(v) => { // Float sensor data is rare; normalise to the u16 the pipeline // expects rather than carrying two representations. v.iter() .map(|&f| (f * 65535.0).clamp(0.0, 65535.0) as u16) .collect() } }; // Black levels are rationals; the pipeline wants plain u16 samples. let bl = &image.blacklevel.levels; let level_at = |i: usize| -> u16 { bl.get(i) .map(|r| (r.n as f32 / r.d.max(1) as f32).round() as u16) .unwrap_or(0) }; let black_level = [level_at(0), level_at(1), level_at(2), level_at(3)]; // Prefer the recommended crop, falling back to the active area, then to // the whole readout. `crop_area` is what the camera itself would show; // `active_area` merely excludes the masked border. let rect = image.crop_area.or(image.active_area); let crop = match rect { Some(r) => CropRect { x: r.p.x as u32, y: r.p.y as u32, width: r.d.w as u32, height: r.d.h as u32, }, None => CropRect { x: 0, y: 0, width: image.width as u32, height: image.height as u32, }, }; // Re-phase the CFA to the crop origin, or the demosaic swaps R and B on // any body whose active area starts at an odd coordinate. Applied exactly // once — shifting twice returns the original pattern and reintroduces the // very bug it exists to prevent. let (dx, dy) = crop.shifts_cfa_phase(); let cfa = cfa_from_rawler(&image.camera.cfa, image.camera.model.as_str()).shifted(dx, dy); Ok(RawImage { width: image.width as u32, height: image.height as u32, crop, data, cfa_pattern: cfa, black_level, white_level: image .whitelevel .0 .first() .map(|v| *v as u16) .unwrap_or(u16::MAX), wb_coeffs: sane_wb(image.wb_coeffs), color_matrix, }) } /// TRACES: FR-DEV-3e /// Compose the camera→sRGB-linear matrix from rawler's XYZ→camera. /// /// **Two rawler traps this avoids**, both measured on a Canon 6D CR2 /// (2026-08-09): /// /// 1. `RawImage::xyz_to_cam` is **all zeros** — it carries an upstream /// deprecation note and 0.7.2 no longer fills it. The live data is /// `color_matrix`, keyed by illuminant. Reading the old field silently /// yields no colour transform at all. /// 2. `cam_to_xyz_normalized()` divides each of four rows by its own sum, and /// the fourth row (emerald/white, unused on any Bayer body) sums to zero. /// Every element came back `NaN`. Inverting the 3×3 ourselves avoids the /// fourth channel entirely. fn cam_to_srgb(image: &rawler::RawImage) -> Option<[f32; 9]> { use rawler::imgop::xyz::Illuminant; // Prefer D65 — it matches sRGB's white point, so no chromatic adaptation // is needed. Illuminant A (tungsten) is a distant fallback for bodies // that ship only one matrix; adapting it properly is a v0.2 colour- // management concern (ARCH §5.2), not something to fake here. let flat = image .color_matrix .get(&Illuminant::D65) .or_else(|| image.color_matrix.get(&Illuminant::A))?; if flat.len() < 9 { return None; } let xyz_to_cam: [[f32; 3]; 3] = [ [flat[0], flat[1], flat[2]], [flat[3], flat[4], flat[5]], [flat[6], flat[7], flat[8]], ]; cam_to_srgb_from(&xyz_to_cam) } /// The matrix maths, split out so it can be tested without a RAW file. // // The constants below are quoted at their published precision rather than // trimmed to what f32 can represent. Truncating a standard matrix to satisfy // a linter makes it harder to check against the specification, and the // rounding happens identically either way. #[allow(clippy::excessive_precision)] fn cam_to_srgb_from(xyz_to_cam: &[[f32; 3]; 3]) -> Option<[f32; 9]> { // XYZ (D65) → linear sRGB, the standard primaries. const XYZ_TO_SRGB: [[f32; 3]; 3] = [ [3.2404542, -1.5371385, -0.4985314], [-0.9692660, 1.8760108, 0.0415560], [0.0556434, -0.2040259, 1.0572252], ]; // sRGB (D65) → XYZ, for finding the camera response to white. const SRGB_TO_XYZ: [[f32; 3]; 3] = [ [0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], [0.0193339, 0.1191920, 0.9503041], ]; // An absent or unpopulated matrix is all zeros. Using it would render // black, so report absence and let the caller fall back to identity. if xyz_to_cam.iter().flatten().all(|v| v.abs() < f32::EPSILON) { return None; } if xyz_to_cam.iter().flatten().any(|v| !v.is_finite()) { return None; } // White balance to D65: find what the camera reports for sRGB white, so // the composed matrix maps neutral to neutral. Without this the image // carries a strong cast even with correct primaries. let mut cam_white = [0.0f32; 3]; for (i, row) in xyz_to_cam.iter().enumerate() { // xyz_to_cam · (XYZ of sRGB white) — the row sums of SRGB_TO_XYZ. for k in 0..3 { let white_k: f32 = SRGB_TO_XYZ[k].iter().sum(); cam_white[i] += row[k] * white_k; } } if cam_white.iter().any(|v| v.abs() < 1e-6 || !v.is_finite()) { return None; } // Scale each row so the camera's own white becomes unity, then invert. let balanced = [ [ xyz_to_cam[0][0] / cam_white[0], xyz_to_cam[0][1] / cam_white[0], xyz_to_cam[0][2] / cam_white[0], ], [ xyz_to_cam[1][0] / cam_white[1], xyz_to_cam[1][1] / cam_white[1], xyz_to_cam[1][2] / cam_white[1], ], [ xyz_to_cam[2][0] / cam_white[2], xyz_to_cam[2][1] / cam_white[2], xyz_to_cam[2][2] / cam_white[2], ], ]; let cam_to_xyz = invert3(&balanced)?; let mut out = [0.0f32; 9]; for i in 0..3 { for j in 0..3 { let mut sum = 0.0; for k in 0..3 { sum += XYZ_TO_SRGB[i][k] * cam_to_xyz[k][j]; } out[i * 3 + j] = sum; } } if out.iter().any(|v| !v.is_finite()) { return None; } Some(out) } /// TRACES: FR-DEV-3e /// Normalise as-shot white balance into usable multipliers. /// /// rawler reports coefficients in RGBE order, and the fourth is `NaN` on /// every three-colour sensor — *measured on a Canon 6D CR2 (2026-08-09): /// `[1.893, 1.0, 1.797, NaN]`*. Uploaded to the GPU unchecked, that NaN /// contaminates the shader's uniform block. It is normalised to 1.0 here, /// where the reason can be written down, rather than being defended against /// at every use site. /// /// Coefficients are also divided through by green, so green is the reference /// channel and exposure does not shift when white balance changes. fn sane_wb(raw: [f32; 4]) -> [f32; 4] { let usable = |v: f32| if v.is_finite() && v > 0.0 { v } else { 1.0 }; let (r, g, b) = (usable(raw[0]), usable(raw[1]), usable(raw[2])); [r / g, 1.0, b / g, 1.0] } /// Invert a 3×3 matrix, or `None` if it is singular. fn invert3(m: &[[f32; 3]; 3]) -> Option<[[f32; 3]; 3]> { let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]); if det.abs() < 1e-12 || !det.is_finite() { return None; } let inv = 1.0 / det; Some([ [ (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv, (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv, (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv, ], [ (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv, (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv, (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv, ], [ (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv, (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv, (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv, ], ]) } fn cfa_from_rawler(cfa: &rawler::CFA, model: &str) -> CfaPattern { // rawler exposes the pattern as a string; X-Trans is 6x6 rather than 2x2. let name = cfa.name.to_ascii_uppercase(); if name.len() > 4 || model.contains("X-") { return CfaPattern::XTrans; } match name.as_str() { "RGGB" => CfaPattern::Rggb, "BGGR" => CfaPattern::Bggr, "GRBG" => CfaPattern::Grbg, "GBRG" => CfaPattern::Gbrg, _ => CfaPattern::Unknown, } } #[cfg(test)] mod tests { use super::*; #[test] fn probe_identifies_jpeg() { let mut h = vec![0xFF, 0xD8, 0xFF, 0xE0]; h.extend_from_slice(&[0u8; 16]); assert_eq!(probe(&h), Some(Format::Jpeg)); } #[test] fn probe_identifies_cr2_by_its_marker() { // Little-endian TIFF, then CR2's own magic at offset 8. let mut h = vec![0x49, 0x49, 0x2A, 0x00, 0x10, 0, 0, 0]; h.extend_from_slice(b"CR\x02\x00"); h.extend_from_slice(&[0u8; 8]); assert_eq!(probe(&h), Some(Format::Cr2)); } #[test] fn probe_identifies_raf_by_signature() { let mut h = b"FUJIFILMCCD-RAW ".to_vec(); h.extend_from_slice(&[0u8; 16]); assert_eq!(probe(&h), Some(Format::Raf)); } #[test] fn probe_returns_none_for_ambiguous_tiff() { // NEF, ARW, DNG and ORF share this header; the extension has to // disambiguate, and claiming a format here would be a lie. let mut h = vec![0x49, 0x49, 0x2A, 0x00]; h.extend_from_slice(&[0u8; 20]); assert_eq!(probe(&h), None); } #[test] fn probe_rejects_short_input() { assert_eq!(probe(&[0xFF, 0xD8]), None); } #[test] fn xtrans_is_distinguishable() { assert!(CfaPattern::XTrans.is_xtrans()); assert!(!CfaPattern::Rggb.is_xtrans()); } #[test] fn cfa_colours_follow_the_named_pattern() { // RGGB: red at the origin, blue diagonally opposite. let p = CfaPattern::Rggb; assert_eq!(p.colour_at(0, 0), 0, "top-left is red"); assert_eq!(p.colour_at(1, 0), 1, "top-right is green"); assert_eq!(p.colour_at(0, 1), 1, "bottom-left is green"); assert_eq!(p.colour_at(1, 1), 2, "bottom-right is blue"); } #[test] fn cfa_pattern_repeats_every_two_photosites() { let p = CfaPattern::Bggr; for (x, y) in [(0u32, 0u32), (1, 0), (0, 1), (1, 1)] { assert_eq!(p.colour_at(x, y), p.colour_at(x + 2, y + 2)); assert_eq!(p.colour_at(x, y), p.colour_at(x + 100, y + 64)); } } #[test] fn an_odd_crop_origin_rephases_the_pattern() { // The bug this prevents: a body whose active area starts at an odd // column renders with red and blue swapped, because the visible // top-left photosite is not the one the pattern names. let shifted = CfaPattern::Rggb.shifted(true, false); assert_eq!(shifted, CfaPattern::Grbg); // Reading the shifted pattern at the origin must agree with reading // the original one column across. assert_eq!(shifted.colour_at(0, 0), CfaPattern::Rggb.colour_at(1, 0)); assert_eq!(shifted.colour_at(1, 0), CfaPattern::Rggb.colour_at(2, 0)); } #[test] fn shifting_both_axes_gives_the_diagonal_opposite() { let s = CfaPattern::Rggb.shifted(true, true); assert_eq!(s, CfaPattern::Bggr); assert_eq!(s.colour_at(0, 0), CfaPattern::Rggb.colour_at(1, 1)); } #[test] fn shifting_is_its_own_inverse() { for p in [ CfaPattern::Rggb, CfaPattern::Bggr, CfaPattern::Grbg, CfaPattern::Gbrg, ] { assert_eq!(p.shifted(true, false).shifted(true, false), p); assert_eq!(p.shifted(false, true).shifted(false, true), p); assert_eq!(p.shifted(true, true).shifted(true, true), p); } } #[test] fn an_even_crop_origin_leaves_the_pattern_alone() { let c = CropRect { x: 0, y: 0, width: 100, height: 100, }; assert_eq!(c.shifts_cfa_phase(), (false, false)); assert_eq!(CfaPattern::Rggb.shifted(false, false), CfaPattern::Rggb); let even = CropRect { x: 84, y: 50, width: 100, height: 100, }; assert_eq!(even.shifts_cfa_phase(), (false, false)); } #[test] fn neutral_stays_neutral_through_the_colour_matrix() { // The property that makes a camera matrix correct: a neutral camera // colour must land on a neutral sRGB colour, so every row sums to 1. // A matrix that fails this renders a strong global cast. // // Values are the D65 matrix rawler reports for a Canon EOS 6D. let xyz_to_cam = [ [0.7034, -0.0804, -0.1014], [-0.4420, 1.2564, 0.2058], [-0.0851, 0.1994, 0.5758], ]; let m = cam_to_srgb_from(&xyz_to_cam).expect("a well-formed matrix inverts"); for (i, row) in m.chunks(3).enumerate() { let sum: f32 = row.iter().sum(); assert!( (sum - 1.0).abs() < 1e-4, "row {i} sums to {sum}, not 1.0 — neutral would not stay neutral" ); } } #[test] fn an_all_zero_matrix_is_absent_rather_than_black() { // rawler's deprecated `xyz_to_cam` is all zeros in 0.7.2. Treating it // as a real matrix renders a black image; the pipeline needs to know // to fall back to identity instead. assert_eq!(cam_to_srgb_from(&[[0.0; 3]; 3]), None); } #[test] fn a_singular_matrix_is_rejected() { // Two identical rows cannot be inverted; returning garbage here would // surface as an unexplained colour failure much later. let singular = [[1.0, 2.0, 3.0], [1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]; assert_eq!(cam_to_srgb_from(&singular), None); } #[test] // Indexing by i/j is how the matrix identity is written down; iterators // would obscure what is being asserted. #[allow(clippy::needless_range_loop)] fn inversion_round_trips() { let m = [[2.0, 0.0, 1.0], [1.0, 3.0, 0.0], [0.0, 1.0, 4.0]]; let inv = invert3(&m).expect("invertible"); // m · inv should be the identity. for i in 0..3 { for j in 0..3 { let mut sum = 0.0; for k in 0..3 { sum += m[i][k] * inv[k][j]; } let expected = if i == j { 1.0 } else { 0.0 }; assert!((sum - expected).abs() < 1e-5, "element ({i},{j}) = {sum}"); } } } #[test] fn white_balance_drops_the_nan_fourth_channel() { // rawler reports RGBE, and E is NaN on every three-colour sensor. // Measured on a Canon 6D: [1.893, 1.0, 1.797, NaN]. Uploaded raw, // that NaN poisons the shader's uniform block. let wb = sane_wb([1.8925781, 1.0, 1.796875, f32::NAN]); assert!(wb.iter().all(|v| v.is_finite()), "no NaN may survive"); assert_eq!(wb[3], 1.0); } #[test] fn white_balance_is_normalised_to_green() { // Green is the reference channel, so overall exposure does not shift // when white balance changes. let wb = sane_wb([3.0, 2.0, 4.0, f32::NAN]); assert_eq!(wb[1], 1.0); assert!((wb[0] - 1.5).abs() < 1e-6); assert!((wb[2] - 2.0).abs() < 1e-6); } #[test] fn absent_white_balance_falls_back_to_neutral() { // A body reporting nothing must render neutral, not black or // infinite. let wb = sane_wb([0.0, 0.0, 0.0, 0.0]); assert_eq!(wb, [1.0, 1.0, 1.0, 1.0]); } #[test] fn xtrans_does_not_rephase() { // A 6×6 cell does not re-phase under a 2×2 shift; claiming otherwise // would corrupt the X-Trans path rather than fix it. assert_eq!(CfaPattern::XTrans.shifted(true, true), CfaPattern::XTrans); } }