The decoding half of camera-profiles.md §11-§12. RawImage gains baseline_exposure: the file's BaselineExposure plus the chosen profile's BaselineExposureOffset, as the DNG SDK sums them (+0.25 for the library's 6D DNGs). A profile copied out of a DNG carries that DNG's baseline as its offset, so the body's CR2s, which have none, land at the same total. ProfileTables gains the profile's ProfileToneCurve, resampled at decode onto 1025 points with a natural cubic spline; an identity curve counts as none. dr-types now holds Camera Raw's ACR3 default curve, RawTherapee's adobe_camera_raw_default_curve copied value for value, for every raw whose profile has no curve. Nothing renders through either yet.
1248 lines
48 KiB
Rust
1248 lines
48 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 dcp;
|
||
mod decoder;
|
||
mod error;
|
||
mod locate;
|
||
mod preview;
|
||
pub mod profile;
|
||
|
||
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 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>,
|
||
/// TRACES: FR-DEV-3e
|
||
/// The camera profile's HueSatMap and LookTable, resolved for this frame
|
||
/// (D20): embedded in the DNG, or from a matched `.dcp`. `None` renders
|
||
/// through the matrix alone.
|
||
///
|
||
/// Carried with the image, as `color_matrix` is, so that every path that
|
||
/// renders a decoded file renders it through the same profile without
|
||
/// having to be told — see camera-profiles.md §3.
|
||
pub profile_tables: Option<std::sync::Arc<dr_types::ProfileTables>>,
|
||
/// TRACES: FR-DEV-3e
|
||
/// Stops the render adds before anything else: the file's
|
||
/// `BaselineExposure` plus the profile's `BaselineExposureOffset`
|
||
/// (camera-profiles.md §11). Applied by the GPU side as a gain on the
|
||
/// camera matrix; `color_matrix` itself stays the file's.
|
||
pub baseline_exposure: f32,
|
||
/// The body, as rawler cleans the names: what `Make`/`Model` say, and
|
||
/// what a `.dcp`'s `UniqueCameraModel` is matched against.
|
||
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-DEV-3g
|
||
/// The DNG `NoiseProfile` of a file, if it carries one: `(S, O)` per CFA
|
||
/// colour plane, variance `S·x + O` in black-to-white normalised units. See
|
||
/// [`profile::read_noise_profile`]. Reads the header, not the image.
|
||
pub fn noise_profile(bytes: &[u8]) -> Option<Vec<(f32, f32)>> {
|
||
use rawler::rawsource::RawSource;
|
||
let source = RawSource::new_from_slice(bytes);
|
||
let decoder = rawler::get_decoder(&source).ok()?;
|
||
profile::read_noise_profile(decoder.as_ref())
|
||
}
|
||
|
||
/// 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);
|
||
// TRACES: FR-DEV-3e
|
||
// The tables, and — where a `.dcp` supplies them — the matrices they were
|
||
// built against, which then stand in for the file's (D20).
|
||
let root = decoder
|
||
.ifd(rawler::decoders::WellKnownIFD::Root)
|
||
.ok()
|
||
.flatten();
|
||
let dcp::Resolved {
|
||
profile,
|
||
tables: profile_tables,
|
||
baseline_exposure,
|
||
..
|
||
} = dcp::resolve(
|
||
profile,
|
||
root.as_deref(),
|
||
&image.camera.clean_make,
|
||
&image.camera.clean_model,
|
||
);
|
||
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(),
|
||
);
|
||
|
||
// 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,
|
||
samples_per_pixel,
|
||
profile,
|
||
profile_tables,
|
||
baseline_exposure,
|
||
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);
|
||
}
|
||
}
|