FR-RAW-2 says a second decoder may be added for broader camera coverage without changing callers, and D2 names LibRaw as that second decoder. Nothing tested the claim: dr_decode was one decoder reached through free functions, so adding another would have meant editing every caller at the moment there was most pressure not to. Decoder is an object-safe trait over bytes: header_bytes, metadata, orientation, locate_preview, preview and decode. Rawler implements it by delegating to the existing free functions, so behaviour is unchanged, and dr_decode::default() hands it out as a &'static dyn Decoder, which is what the places that start work will name. JPEG recognition, decoding and completeness checks stay free functions: they are not a RAW decoder's to vary. Nothing in the trait takes a path or a SourceRef; the decoder states how much of a file it needs and where its preview sits, and the caller's storage fetches that.
1226 lines
47 KiB
Rust
1226 lines
47 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.
|
||
//!
|
||
//! Callers reach these through the [`Decoder`] trait rather than by name, so a
|
||
//! second decoder can be put behind them without changing any of them
|
||
//! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out.
|
||
|
||
pub mod base_curve;
|
||
mod decoder;
|
||
mod error;
|
||
mod locate;
|
||
mod preview;
|
||
pub mod profile;
|
||
|
||
pub use base_curve::BaseCurve;
|
||
pub use decoder::{default, Decoder, Rawler};
|
||
pub use error::DecodeError;
|
||
pub use locate::{
|
||
defects, is_complete_jpeg, jpeg_metadata, locate_preview, tiff_metadata, BadLine, BadPixel,
|
||
Defects, PreviewLocation, HEADER_BYTES,
|
||
};
|
||
pub use preview::{
|
||
decode_jpeg, extract_embedded_preview, extract_preview, Preview, PreviewSize,
|
||
PREVIEW_PROBE_BYTES,
|
||
};
|
||
pub use profile::CameraProfile;
|
||
|
||
use dr_types::{Format, Location, 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>,
|
||
/// TRACES: FR-EXP-8
|
||
/// Who made the photograph (EXIF `Artist`, 0x013B).
|
||
///
|
||
/// Read for the sake of exporting it again: a photographer who set a byline
|
||
/// in-camera set it so that it would still be there in the copy they hand
|
||
/// over, and an export that dropped it would be quietly removing the one
|
||
/// piece of metadata that says whose work this is.
|
||
pub artist: Option<String>,
|
||
/// TRACES: FR-EXP-8
|
||
/// The rights statement (EXIF `Copyright`, 0x8298).
|
||
pub copyright: Option<String>,
|
||
/// TRACES: FR-EXP-8
|
||
/// Where the shutter fired (the EXIF GPS directory, 0x8825).
|
||
///
|
||
/// **Read, but treated as radioactive downstream.** This is the field
|
||
/// FR-EXP-8's strip option exists for, and the export path drops it unless
|
||
/// the user has explicitly said otherwise (`ExportSettings::strip_location`
|
||
/// defaults to on). Parsing it here rather than refusing to look is what
|
||
/// makes "keep my coordinates" possible at all, and what lets the exporter
|
||
/// prove the field is gone rather than hope it was never present.
|
||
pub location: Option<Location>,
|
||
}
|
||
|
||
/// 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.
|
||
///
|
||
/// Where the body carries two calibrations this is already *interpolated*
|
||
/// for the light the frame was shot under; see [`profile::CameraProfile`].
|
||
pub color_matrix: Option<[f32; 9]>,
|
||
/// TRACES: FR-DEV-3e
|
||
/// The per-body rendering curve, the other half of the camera profile.
|
||
///
|
||
/// The matrix above decides what the colours *are*; this decides what the
|
||
/// picture looks like. Carried on the decoded image rather than looked up
|
||
/// downstream because this is the only point in the system that knows
|
||
/// which body took the frame, and because it is not an edit: it belongs to
|
||
/// the file in the same way the masked-photosite crop does, and must never
|
||
/// reach a sidecar (FR-NC-9).
|
||
///
|
||
/// [`BaseCurve::IDENTITY`] for an unknown body with no default in the
|
||
/// database, which renders exactly as this decoder did before profiles
|
||
/// existed.
|
||
pub base_curve: BaseCurve,
|
||
/// The usable region of `data`, excluding masked and border photosites.
|
||
pub crop: CropRect,
|
||
/// TRACES: FR-MRG-3
|
||
/// Samples per photosite in `data`: 1 for a colour-filter-array capture,
|
||
/// 3 for a *linear* DNG — demosaiced RGB, still camera-space, which is
|
||
/// what a merge writes. With 3, `cfa_pattern` means nothing, `data` is
|
||
/// `width × height × 3` interleaved, and the GPU uploads it as it is
|
||
/// rather than demosaicing.
|
||
pub samples_per_pixel: u8,
|
||
/// TRACES: FR-MRG-3
|
||
/// The body's colour profile as the file carried it, for a composite to
|
||
/// carry on: calibrations and the as-shot neutral. `None` for a body the
|
||
/// decoder has no matrix for.
|
||
pub profile: Option<profile::CameraProfile>,
|
||
/// The body, as rawler cleans the names: what `Make`/`Model` say and what
|
||
/// the base-curve database matches on.
|
||
pub make: String,
|
||
pub model: String,
|
||
}
|
||
|
||
/// 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> {
|
||
error::guarded("metadata", || metadata_unguarded(bytes))
|
||
}
|
||
|
||
/// TRACES: FR-CAT-5
|
||
/// Where a TIFF-shaped file keeps its first IFD, when the head handed to
|
||
/// [`metadata`] does not reach it — the linear DNG a merge writes puts its
|
||
/// IFDs after the pixels, and rawler, given the head alone, finds no
|
||
/// decoder in it. The caller fetches from this offset to the end and
|
||
/// reads the two ranges with [`metadata_split`].
|
||
pub fn trailing_ifd(head: &[u8]) -> Option<u64> {
|
||
locate::trailing_ifd(head)
|
||
}
|
||
|
||
/// TRACES: FR-CAT-5
|
||
/// [`metadata`] for a file read in two ranges: `head` from offset 0 and
|
||
/// `tail` from `tail_at`. The EXIF sub-IFD such a file wrote before its
|
||
/// pixels is in the head; the first IFD and its values are in the tail.
|
||
pub fn metadata_split(head: &[u8], tail: &[u8], tail_at: u64) -> Result<Metadata, DecodeError> {
|
||
error::guarded("metadata", || {
|
||
locate::tiff_metadata_split(head, tail, tail_at)
|
||
})
|
||
}
|
||
|
||
fn metadata_unguarded(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),
|
||
// TRACES: FR-EXP-8
|
||
artist: exif.artist.clone().filter(|s| !s.trim().is_empty()),
|
||
copyright: exif.copyright.clone().filter(|s| !s.trim().is_empty()),
|
||
location: exif.gps.as_ref().and_then(rawler_location),
|
||
};
|
||
|
||
// 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);
|
||
// TRACES: FR-EXP-8
|
||
// Filled from the same walk for the same reason: a file rawler
|
||
// answered short on is one whose byline and rights statement would
|
||
// otherwise be dropped at export, and both sit in the IFD this has
|
||
// already read.
|
||
out.artist = out.artist.take().or(fallback.artist);
|
||
out.copyright = out.copyright.take().or(fallback.copyright);
|
||
out.location = out.location.or(fallback.location);
|
||
}
|
||
}
|
||
|
||
Ok(out)
|
||
}
|
||
|
||
/// TRACES: FR-EXP-8
|
||
/// rawler's GPS directory as a position.
|
||
///
|
||
/// The three-rational form is the tag's, not a position's: degrees, minutes
|
||
/// and seconds, each a fraction, with the hemisphere in a separate letter.
|
||
/// Everything downstream wants a number it can compare and write back, so the
|
||
/// conversion happens once, here.
|
||
fn rawler_location(gps: &rawler::exif::ExifGPS) -> Option<Location> {
|
||
/// A `Rational` as a float, with a zero denominator refused rather than
|
||
/// divided by — some bodies write `0/0` into an unfilled slot.
|
||
fn ratio(r: &rawler::formats::tiff::Rational) -> Option<f64> {
|
||
(r.d != 0).then(|| r.n as f64 / r.d as f64)
|
||
}
|
||
|
||
fn degrees(
|
||
dms: &[rawler::formats::tiff::Rational; 3],
|
||
reference: Option<&String>,
|
||
) -> Option<f64> {
|
||
let d = ratio(&dms[0])? + ratio(&dms[1])? / 60.0 + ratio(&dms[2])? / 3600.0;
|
||
// South and west are stored as positive magnitudes with a letter.
|
||
let negative = matches!(
|
||
reference.map(|s| s.trim().to_ascii_uppercase()).as_deref(),
|
||
Some("S") | Some("W")
|
||
);
|
||
Some(if negative { -d } else { d })
|
||
}
|
||
|
||
let latitude = degrees(gps.gps_latitude.as_ref()?, gps.gps_latitude_ref.as_ref())?;
|
||
let longitude = degrees(gps.gps_longitude.as_ref()?, gps.gps_longitude_ref.as_ref())?;
|
||
// Reference 1 means below sea level; the altitude itself is unsigned.
|
||
let altitude = gps.gps_altitude.as_ref().and_then(ratio).map(|a| {
|
||
if gps.gps_altitude_ref == Some(1) {
|
||
-a
|
||
} else {
|
||
a
|
||
}
|
||
});
|
||
Location::new(latitude, longitude, altitude)
|
||
}
|
||
|
||
/// 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> {
|
||
error::guarded("decode", || decode_unguarded(bytes))
|
||
}
|
||
|
||
fn decode_unguarded(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()))?;
|
||
|
||
// Read before decoding, while the decoder is still the cheapest thing in
|
||
// the room. These are the DNG tags rawler parses into its IFD and then
|
||
// never surfaces — `ForwardMatrix1/2` above all — and they are empty for
|
||
// every non-DNG file, which is not a failure (FR-DEV-3e).
|
||
let dng = profile::read_dng_matrices(decoder.as_ref());
|
||
|
||
let image = decoder
|
||
.raw_image(&source, &Default::default(), false)
|
||
.map_err(|e| DecodeError::Decode(e.to_string()))?;
|
||
|
||
// The camera profile, and both of the things derived from it, are built
|
||
// before the match below moves `image.data`.
|
||
//
|
||
// They come from *one* profile deliberately: the balance and the
|
||
// conversion must agree about which white is neutral, or the frame carries
|
||
// a cast that looks like a decode fault. That agreement used to be
|
||
// maintained by hand — two functions reading the same illuminant key — and
|
||
// is now structural, because there is only one interpolated matrix and
|
||
// both callers ask the same object for it.
|
||
let profile = profile::CameraProfile::extract(&image, &dng);
|
||
let color_matrix = profile.as_ref().and_then(|p| p.cam_to_srgb());
|
||
let wb_coeffs = sane_wb(
|
||
image.wb_coeffs,
|
||
profile.as_ref().map(|p| p.xyz_to_cam()).as_ref(),
|
||
);
|
||
|
||
// The rendering half of the profile (FR-DEV-3e). rawler's cleaned strings
|
||
// are preferred where it has them — they are what the shipped database is
|
||
// written against — and the matching folds the variants either way, so a
|
||
// DNG naming the same body differently still finds its curve.
|
||
let base_curve = base_curve::for_body(
|
||
image.camera.clean_make.as_str(),
|
||
image.camera.clean_model.as_str(),
|
||
);
|
||
|
||
// TRACES: FR-MRG-3
|
||
// A linear DNG — three samples per pixel, no colour filter array — is a
|
||
// composite this application wrote (or any other demosaiced DNG). It
|
||
// carries the same scale, matrices and neutral as a CFA file and goes
|
||
// through the same profile; only the demosaic is skipped.
|
||
let samples_per_pixel = match image.cpp {
|
||
1 => 1u8,
|
||
3 => 3,
|
||
other => {
|
||
return Err(DecodeError::Unsupported(format!(
|
||
"{other} samples per pixel; only CFA (1) and linear RGB (3) are handled"
|
||
)))
|
||
}
|
||
};
|
||
|
||
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,
|
||
base_curve,
|
||
samples_per_pixel,
|
||
profile,
|
||
make: image.camera.clean_make.clone(),
|
||
model: image.camera.clean_model.clone(),
|
||
})
|
||
}
|
||
|
||
/// 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)]
|
||
pub(crate) 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.
|
||
pub(crate) 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);
|
||
}
|
||
}
|