Files
DarkRoom/core/dr-decode/src/lib.rs
T
dtourolleandClaude Opus 5 489465faf0 Show the photograph the way it was taken
Nothing read EXIF orientation, so every frame from a body held sideways
lay on its side — in the grid, in develop, and in the read-only preview.

The tag is honoured as part of *reading the file*, at the same standing
as a RAW's masked-photosite crop, never as an edit. It lives as a
baseline on Framing rather than as a starting value for quarter_turns,
which is what keeps four things true: a sideways file opens unmodified,
reset returns it to upright rather than to the sensor's scan order, its
sidecar stays empty, and the rotate button still moves the image 90°
whatever the file underneath it says.

Framing::effective composes the baseline with the user's own turns
through the group law rather than by adding turns and OR-ing flags. The
naive version gets one case wrong — an odd baseline turn plus a user
mirror — and gets it wrong quietly, because the result is still a
plausible orientation. The composition collapses to a single
permutation, so obeying the tag costs nothing per pixel.

dr_decode::orientation is a header-only IFD walk, separate from
metadata() for the reason the entry points are separate at all: the grid
asks once per cell and must not build a rawler decoder to get one tag.
CR3 and RAF fall back to the full read, being neither TIFF nor JPEG.

Written down as FR-DEV-3h.

Known gap: thumbnails cached before this stay sideways. The store is
keyed by file and size, and its shards sync — invalidating them would
have every client re-download 25 MB a shard, which is not this commit's
call to make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:37:05 +02:00

944 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::{is_complete_jpeg, locate_preview, 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<String>,
pub model: Option<String>,
pub lens: Option<String>,
/// Exposure time in seconds.
pub shutter: Option<f32>,
pub aperture: Option<f32>,
pub iso: Option<u32>,
pub focal_length: Option<f32>,
/// Full sensor dimensions, before crop.
pub width: Option<u32>,
pub height: Option<u32>,
/// 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<Orientation>,
/// 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<i64>,
/// 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<i32>,
}
/// 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<u16>,
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<Format> {
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<Metadata, DecodeError> {
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<Orientation> {
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<i64> {
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<i32> {
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<RawImage, DecodeError> {
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::{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]);
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);
}
}