//! 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 locate; mod preview; pub use error::DecodeError; pub use locate::{ defects, is_complete_jpeg, locate_preview, BadLine, BadPixel, Defects, PreviewLocation, HEADER_BYTES, }; pub use preview::{ decode_jpeg, extract_embedded_preview, extract_preview, Preview, PreviewSize, PREVIEW_PROBE_BYTES, }; use dr_types::{Format, Orientation}; /// 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, /// How the stored pixels sit relative to how the photograph should be /// seen (EXIF `0x0112`). /// /// `None` where the file carries no tag, which is not the same claim as /// [`Orientation::NORMAL`]: the first says nothing is known, the second /// says the camera was held level. Callers treat them alike — an unknown /// orientation is displayed as-is — but keeping them apart means a future /// "rotate on import" pass can tell a deliberate `1` from a silent gap. pub orientation: Option, /// When the shutter fired, as Unix seconds. /// /// EXIF records wall-clock time with no zone, so this is that reading /// interpreted as UTC. Paired with [`captured_offset`](Self::captured_offset) /// it reconstructs the actual instant; alone it is still correct for /// ordering within one timezone, which is what a timeline needs. pub captured_at: Option, /// Minutes east of UTC, where the camera recorded a zone. /// /// Absent on most bodies before ~2018. A photograph's timestamp is local /// to where it was taken, so without this a shoot in Tokyo displays on the /// wrong day in Paris. pub captured_offset: 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; // rawler has no decoder for a plain JPEG, so without this every JPEG in a // library reports no capture time — and a mixed library's timeline is // silently missing thousands of images. Scanned film and camera JPEGs both // land here. if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { return locate::jpeg_metadata(bytes); } 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; let mut out = 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, orientation: exif.orientation.map(Orientation::from_exif), captured_at: exif .date_time_original .as_deref() .and_then(parse_exif_datetime), captured_offset: exif .offset_time_original .as_deref() .or(exif.offset_time.as_deref()) .and_then(parse_exif_offset), }; // rawler reports no capture time for some TIFF-derived files whose tag is // plainly present — one reference DNG carries it at byte 826 and still // comes back empty. These formats *are* TIFF, so the same reader the JPEG // path uses can find it. Only the missing fields are filled, so rawler // stays authoritative wherever it did answer. // // Orientation joins the trigger for the same reason it joins the fills: a // sideways frame that rawler declined to report is displayed on its side, // which is a louder failure than a missing date and just as recoverable // from the IFD the tag sits in. The extra walk is over bytes already in // memory, and only for files that came back short. if out.captured_at.is_none() || out.orientation.is_none() { if let Ok(fallback) = locate::tiff_metadata(bytes) { out.captured_at = out.captured_at.or(fallback.captured_at); out.captured_offset = out.captured_offset.or(fallback.captured_offset); out.iso = out.iso.or(fallback.iso); out.lens = out.lens.take().or(fallback.lens); out.orientation = out.orientation.or(fallback.orientation); } } Ok(out) } /// TRACES: FR-CAT-5 | FR-DEV-3h /// Read just the stored orientation, from a file header. /// /// Separate from [`metadata`] for the reason the four entry points are /// separate at all (ARCH §3.2): the grid needs this for every cell it draws a /// thumbnail into, and it already holds the header bytes. Going through /// `metadata` would put a full rawler decoder construction behind one tag — /// the same mistake as decoding sensor data to cull. /// /// This is an IFD walk over bytes already in memory, so it costs effectively /// nothing on the TIFF-derived formats and on JPEG. The two containers that /// are neither — Canon's CR3, which is ISO-BMFF, and Fujifilm's RAF — fall /// back to the full read, because the alternative is showing those bodies' /// portrait frames on their side. /// /// `None` means the header carried no orientation, which callers should treat /// as [`Orientation::NORMAL`] rather than as a failure: most files have no tag. pub fn orientation(header: &[u8]) -> Option { let direct = if header.starts_with(&[0xFF, 0xD8, 0xFF]) { locate::jpeg_metadata(header).ok() } else { locate::tiff_metadata(header).ok() }; if let Some(o) = direct.and_then(|m| m.orientation) { return Some(o); } match probe(header) { Some(Format::Cr3) | Some(Format::Raf) => metadata(header).ok().and_then(|m| m.orientation), _ => None, } } /// Parse an EXIF `DateTimeOriginal` into Unix seconds. /// /// The format is `"YYYY:MM:DD HH:MM:SS"` — colons in the date, which is what /// trips generic date parsers. No timezone is present, so the reading is taken /// as UTC and the zone, if any, comes from `OffsetTimeOriginal` separately. /// /// Returns `None` rather than guessing on anything malformed: a wrong /// timestamp puts an image at the wrong point on the timeline, which is worse /// than leaving it unplaced. pub(crate) fn parse_exif_datetime(s: &str) -> Option { let s = s.trim(); let (date, time) = s.split_once(' ')?; // EXIF specifies colons in the date, but real files disagree: the // CanoScan 9000F writes `2013/06/28`, and enough devices use dashes that // rejecting either would leave whole classes of file undated. let mut d = date.split([':', '/', '-']); let (y, mo, da): (i64, i64, i64) = ( d.next()?.parse().ok()?, d.next()?.parse().ok()?, d.next()?.parse().ok()?, ); let mut t = time.split(':'); let (h, mi, se): (i64, i64, i64) = ( t.next()?.parse().ok()?, t.next()?.parse().ok()?, // Some bodies append fractional seconds; take the whole part. t.next()?.split('.').next()?.parse().ok()?, ); // A camera with a dead clock battery reports 1970 or similar. Reject // obvious nonsense rather than clustering those images at the epoch. if !(1900..=2200).contains(&y) || !(1..=12).contains(&mo) || !(1..=31).contains(&da) || !(0..=23).contains(&h) || !(0..=59).contains(&mi) || !(0..=60).contains(&se) { return None; } // Days from the civil date, via the usual era-based algorithm. let y_adj = if mo <= 2 { y - 1 } else { y }; let era = if y_adj >= 0 { y_adj } else { y_adj - 399 } / 400; let yoe = y_adj - era * 400; let mp = (mo + 9) % 12; let doy = (153 * mp + 2) / 5 + da - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; let days = era * 146_097 + doe - 719_468; Some(days * 86_400 + h * 3_600 + mi * 60 + se) } /// Parse an EXIF offset like `"+02:00"` into minutes east of UTC. pub(crate) fn parse_exif_offset(s: &str) -> Option { let s = s.trim(); let (sign, rest) = match s.as_bytes().first()? { b'+' => (1, &s[1..]), b'-' => (-1, &s[1..]), _ => return None, }; let (h, m) = rest.split_once(':')?; let (h, m): (i32, i32) = (h.parse().ok()?, m.parse().ok()?); if !(0..=14).contains(&h) || !(0..=59).contains(&m) { return None; } Some(sign * (h * 60 + m)) } /// 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()))?; // Both derived before the match below moves `image.data`, and from the // same matrix: the balance and the conversion must agree about which white // is neutral or the frame carries a cast that looks like a decode fault. let color_matrix = cam_to_srgb(&image); let wb_coeffs = sane_wb(image.wb_coeffs, xyz_to_cam_of(&image).as_ref()); 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, // The **maximum** of the per-channel saturation points, not the // first. Taking one channel's value under-reports the others', and // every sample above the level it is normalised against becomes a // highlight the pipeline treats as brighter than white. white_level: image .whitelevel .0 .iter() .copied() .max() .map(|v| v as u16) .unwrap_or(u16::MAX), 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 camera's XYZ→camera matrix, as rawler holds it. /// /// Split out so the white-balance fallback and the colour matrix read the same /// data through the same illuminant preference; two readers disagreeing about /// which matrix a body uses would balance to one white and convert from /// another. fn xyz_to_cam_of(image: &rawler::RawImage) -> Option<[[f32; 3]; 3]> { use rawler::imgop::xyz::Illuminant; let flat = image .color_matrix .get(&Illuminant::D65) .or_else(|| image.color_matrix.get(&Illuminant::A))?; if flat.len() < 9 { return None; } Some([ [flat[0], flat[1], flat[2]], [flat[3], flat[4], flat[5]], [flat[6], flat[7], flat[8]], ]) } /// 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], matrix: Option<&[[f32; 3]; 3]>) -> [f32; 4] { let usable = |v: f32| v.is_finite() && v > 0.0; if usable(raw[0]) && usable(raw[1]) && usable(raw[2]) { let (r, g, b) = (raw[0], raw[1], raw[2]); return [r / g, 1.0, b / g, 1.0]; } // **Absent is not neutral, and treating it as neutral is why frames came // out pink.** A Bayer sensor's green photosites collect roughly twice the // signal of its red and blue, so unbalanced data is strongly green. The // camera matrix is built on the assumption that the data reaching it has // already been balanced, and it subtracts green accordingly — fed // green-heavy data it overshoots, and the frame lands in magenta. // // So a missing coefficient falls back to what the camera itself says about // daylight rather than to 1.0. `daylight_wb` derives it from the same // matrix the pipeline is about to apply, which makes the two consistent by // construction: the multipliers neutralise exactly the white the matrix // expects to be neutral. match matrix.and_then(daylight_wb) { Some([r, g, b]) => [r / g, 1.0, b / g, 1.0], // No matrix either — an unknown body. Neutral is then the honest // answer rather than a worse guess, and the result is uncalibrated // rather than wrong in a specific direction. None => [1.0, 1.0, 1.0, 1.0], } } /// TRACES: FR-DEV-3e /// The multipliers that make daylight neutral on this sensor. /// /// The camera's own response to white, which is the quantity `cam_to_srgb_from` /// computes on its way to balancing the matrix and then discards. Exposed /// because two callers need it: the fallback above, and any control that wants /// to express white balance against an absolute illuminant rather than as an /// offset from whatever the camera happened to choose. /// /// Returns the **correcting multipliers**, green-normalised — not the camera's /// response. The two are reciprocals and confusing them inverts the /// correction: a sensor is *least* sensitive to the channel needing the /// largest multiplier, so returning the response boosts exactly the wrong one. pub fn daylight_wb(xyz_to_cam: &[[f32; 3]; 3]) -> Option<[f32; 3]> { // The XYZ of sRGB white is the row sums of sRGB→XYZ. Written out rather // than referencing the constant inside `cam_to_srgb_from` so the two // cannot drift apart silently. #[allow(clippy::excessive_precision)] const WHITE_XYZ: [f32; 3] = [0.9504700, 1.0000000, 1.0888300]; let mut cam_white = [0.0f32; 3]; for (i, row) in xyz_to_cam.iter().enumerate() { for k in 0..3 { cam_white[i] += row[k] * WHITE_XYZ[k]; } } if cam_white.iter().any(|v| !v.is_finite() || *v <= 1e-6) { return None; } // Reciprocal, then normalised so green is 1 and overall exposure does not // move when the balance does. Some([ cam_white[1] / cam_white[0], 1.0, cam_white[1] / cam_white[2], ]) } /// 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::{parse_exif_datetime, parse_exif_offset}; #[test] fn exif_datetime_uses_colon_separated_dates() { // The format that defeats generic parsers: colons in the date. // Checked against a reference implementation, not computed by hand. assert_eq!( parse_exif_datetime("2026:08:09 14:30:00"), Some(1_786_285_800) ); assert_eq!(parse_exif_datetime("1970:01:01 00:00:00"), Some(0)); } #[test] fn fractional_seconds_are_tolerated() { assert_eq!( parse_exif_datetime("2026:08:09 14:30:00.75"), parse_exif_datetime("2026:08:09 14:30:00") ); } #[test] fn a_malformed_datetime_is_none_rather_than_a_guess() { // A wrong timestamp puts an image at the wrong place on the timeline, // which is worse than leaving it unplaced. assert_eq!(parse_exif_datetime(""), None); assert_eq!(parse_exif_datetime("not a date"), None); // Dashes and slashes are accepted: real devices write both, and // rejecting them left every CanoScan-scanned frame undated. assert_eq!( parse_exif_datetime("2026-08-09 14:30:00"), parse_exif_datetime("2026:08:09 14:30:00") ); assert_eq!( parse_exif_datetime("2013/06/28 23:32:54"), parse_exif_datetime("2013:06:28 23:32:54") ); assert_eq!(parse_exif_datetime("2026:13:09 14:30:00"), None, "month 13"); assert_eq!(parse_exif_datetime("2026:08:09 25:00:00"), None, "hour 25"); assert_eq!(parse_exif_datetime("0000:00:00 00:00:00"), None); } #[test] fn exif_offsets_parse_both_signs() { assert_eq!(parse_exif_offset("+02:00"), Some(120)); assert_eq!(parse_exif_offset("-05:30"), Some(-330)); assert_eq!(parse_exif_offset("+00:00"), Some(0)); } #[test] fn an_absent_or_malformed_offset_is_none() { // Most bodies before ~2018 record no zone at all. assert_eq!(parse_exif_offset(""), None); assert_eq!(parse_exif_offset("02:00"), None, "no sign"); assert_eq!(parse_exif_offset("+99:00"), None); } 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], None); 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], None); 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_with_no_matrix_falls_back_to_neutral() { // An unknown body: nothing to derive a better answer from, so neutral // is the honest one. Uncalibrated beats wrong in a specific direction. let wb = sane_wb([0.0, 0.0, 0.0, 0.0], None); assert_eq!(wb, [1.0, 1.0, 1.0, 1.0]); } #[test] fn absent_white_balance_uses_the_camera_daylight_rather_than_neutral() { // **The pink-frame bug.** Substituting 1.0 for a missing coefficient // leaves the sensor's raw green excess in place — green photosites // collect roughly twice what red and blue do — and the camera matrix, // which is built expecting balanced input, subtracts green and // overshoots into magenta. // // A real matrix: the Canon 6D's, D65. Its daylight multipliers are // emphatically not 1:1:1, and that difference is the whole fix. let xyz_to_cam = [ [0.7034, -0.0804, -0.1014], [-0.4420, 1.2564, 0.2058], [-0.0851, 0.1994, 0.5758], ]; let wb = sane_wb([0.0, 0.0, 0.0, 0.0], Some(&xyz_to_cam)); assert_eq!(wb[1], 1.0, "still green-normalised"); assert!(wb.iter().all(|v| v.is_finite() && *v > 0.0)); assert!( (wb[0] - 1.0).abs() > 0.15 && (wb[2] - 1.0).abs() > 0.15, "a real sensor's daylight balance is nowhere near neutral; got {wb:?}" ); // Red and blue both need lifting against green on a Bayer sensor. assert!(wb[0] > 1.0 && wb[2] > 1.0, "got {wb:?}"); } #[test] fn the_daylight_multipliers_neutralise_what_the_matrix_calls_white() { // The property that makes the fallback consistent rather than merely // plausible: balancing by these and then applying the matrix must map // the camera's white to a neutral sRGB. If the two disagreed, the // fallback would trade one cast for another. let xyz_to_cam = [ [0.7034, -0.0804, -0.1014], [-0.4420, 1.2564, 0.2058], [-0.0851, 0.1994, 0.5758], ]; let wb = daylight_wb(&xyz_to_cam).expect("a real matrix has a white"); let m = cam_to_srgb_from(&xyz_to_cam).expect("invertible"); // Camera-space white, balanced: each channel divided by its own // response, which is unity by construction. let balanced = [1.0f32, 1.0, 1.0]; let out = [ m[0] * balanced[0] + m[1] * balanced[1] + m[2] * balanced[2], m[3] * balanced[0] + m[4] * balanced[1] + m[5] * balanced[2], m[6] * balanced[0] + m[7] * balanced[1] + m[8] * balanced[2], ]; let spread = out.iter().cloned().fold(f32::MIN, f32::max) - out.iter().cloned().fold(f32::MAX, f32::min); assert!( spread < 0.02, "balanced white must come out neutral, got {out:?} from wb {wb:?}" ); } #[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); } }