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>
944 lines
34 KiB
Rust
944 lines
34 KiB
Rust
//! 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);
|
||
}
|
||
}
|