Decode through display, on the GPU: black/white normalisation, Bayer demosaic, camera colour transform, and the first seven adjustment operations — white balance, exposure, highlights/shadows, blacks/whites, brilliance, vibrance, saturation. Composable shaders. Each operation contributes a WGSL fragment rather than owning a pass, and dr-pipeline fuses the *active* ones into a single compute shader. One texture read and one write per frame regardless of how many adjustments are in play, while the operations stay independent in Rust — adding one is a new file, with no central shader to edit. An operation at neutral settings contributes no code, no uniform and no branch. Uniforms are prefixed per operation so two may both declare `amount`; helpers dedupe by name from a single source of truth. Pipelines cache on a structure hash covering the op-set and its order but not the values, so dragging a slider uploads uniforms and reuses the compiled pipeline. Measured on a 24 MP CR2: 0.60 ms re-render, one pipeline compiled across ten slider positions. The UI is generated, not written. EditGraph::capabilities() reports parameters with their kinds, ranges, defaults and current values; the panel builds one control per entry chosen by ParamKind. No file in ui/ names an operation, and dr-pipeline has no wgpu dependency, so codegen is testable without a device (ARCH §6.5a). Three defects found against real files, each silent: - rawler 0.7.2's `xyz_to_cam` is all zeros — deprecated and no longer populated. The live matrices are in `color_matrix`, keyed by illuminant. Reading the old field yields no colour transform at all. - `cam_to_xyz_normalized()` returns all NaN on any Bayer sensor: it divides each of four rows by its own sum, and the unused fourth (emerald) row sums to zero. Inverting the 3x3 ourselves avoids it. `wb_coeffs[3]` is NaN for the same reason and is normalised at decode. - As-shot white balance reached the uniform block but no shader read it, so the first render of a real CR2 came out violently green. Green photosites collect roughly twice the signal of red and blue. Now applied unconditionally before any operation, with tests on ordering. Demosaic is Malvar-He-Cutler rather than bilinear: gradient-corrected interpolation at one 5x5 neighbourhood per pixel, where bilinear leaves visible zippering on any high-contrast edge at 1:1. Two of the four packed CFA constants were wrong on the first attempt, so all four layouts are asserted to reconstruct the same colour. Crop origins at odd coordinates re-phase the pattern; without that, red and blue swap. X-Trans reports GpuError::UnsupportedCfa rather than approximating with the Bayer path, which would look like a corrupt file. 206 tests, including GPU tests proving every operation and the full seven-operation chain generate compilable WGSL. Known gaps: the display path still reads back to the CPU each frame, which ARCH §6.1 forbids and AC-8 asserts against — it is gated behind the `readback` feature and waits on spike S1 wiring Slint's texture import. Curve shapes are a first draft and want tuning against real photographs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
720 lines
25 KiB
Rust
720 lines
25 KiB
Rust
//! RAW decoding for DarkRoom.
|
||
//!
|
||
//! Four separate entry points rather than one `decode`, because callers differ
|
||
//! sharply in what they need (ARCH §3.2):
|
||
//!
|
||
//! - **Culling** wants [`embedded_preview`] and nothing else — a ~200 KB read
|
||
//! against a 34 MB file.
|
||
//! - **The grid** wants [`metadata`].
|
||
//! - **Develop and export** need [`decode`], the only path that touches sensor
|
||
//! data.
|
||
//!
|
||
//! Fusing them would force a full decode where a header read suffices, which
|
||
//! is exactly why Lightroom stalls ~2 s per image during culling.
|
||
|
||
mod error;
|
||
mod preview;
|
||
|
||
pub use error::DecodeError;
|
||
pub use preview::{
|
||
decode_jpeg, extract_embedded_preview, extract_preview, Preview, PreviewSize,
|
||
PREVIEW_PROBE_BYTES,
|
||
};
|
||
|
||
use dr_types::Format;
|
||
|
||
/// 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>,
|
||
}
|
||
|
||
/// Decoded sensor data, before demosaic.
|
||
///
|
||
/// Deliberately *not* RGB: demosaic is a GPU pipeline stage (ARCH §5.2), so
|
||
/// this carries CFA-pattern samples plus what the shader needs to interpret
|
||
/// them.
|
||
#[derive(Debug, Clone)]
|
||
pub struct RawImage {
|
||
/// Width of `data` in samples — the *full* sensor row stride, including
|
||
/// any masked border. Not the width the user sees; see [`Self::crop`].
|
||
pub width: u32,
|
||
pub height: u32,
|
||
/// One sample per photosite, in sensor order.
|
||
pub data: Vec<u16>,
|
||
pub cfa_pattern: CfaPattern,
|
||
pub black_level: [u16; 4],
|
||
pub white_level: u16,
|
||
/// As-shot white balance, as per-channel multipliers.
|
||
pub wb_coeffs: [f32; 4],
|
||
/// Camera RGB to linear sRGB (D65), row-major 3×3 (FR-DEV-3e).
|
||
///
|
||
/// `None` where the body is unknown to the decoder, in which case the
|
||
/// pipeline falls back to identity and the result is uncalibrated rather
|
||
/// than wrong-by-a-guess.
|
||
pub color_matrix: Option<[f32; 9]>,
|
||
/// The usable region of `data`, excluding masked and border photosites.
|
||
pub crop: CropRect,
|
||
}
|
||
|
||
/// TRACES: FR-RAW-3
|
||
/// The usable region of a sensor readout.
|
||
///
|
||
/// RAW files carry photosites the image does not include: optically black
|
||
/// columns used to measure the black level, and a few border rows most
|
||
/// demosaics need as context but no viewer should display. Cropping is
|
||
/// therefore not an edit — it is part of reading the file correctly.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct CropRect {
|
||
pub x: u32,
|
||
pub y: u32,
|
||
pub width: u32,
|
||
pub height: u32,
|
||
}
|
||
|
||
impl CropRect {
|
||
/// Whether the crop origin shifts the CFA phase.
|
||
///
|
||
/// A Bayer pattern repeats every 2×2, so a crop starting at an odd
|
||
/// coordinate makes the top-left photosite of the *visible* image a
|
||
/// different colour than the pattern names. Demosaicing without
|
||
/// accounting for it swaps red and blue — the classic symptom being a
|
||
/// correctly-exposed image with wildly wrong colour.
|
||
pub fn shifts_cfa_phase(&self) -> (bool, bool) {
|
||
(self.x % 2 == 1, self.y % 2 == 1)
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-RAW-5
|
||
/// The colour filter array layout.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum CfaPattern {
|
||
Rggb,
|
||
Bggr,
|
||
Grbg,
|
||
Gbrg,
|
||
/// Fujifilm's 6×6 pattern. Needs a different demosaic entirely
|
||
/// (FR-RAW-5), at roughly 2× the cost of Bayer.
|
||
XTrans,
|
||
Unknown,
|
||
}
|
||
|
||
impl CfaPattern {
|
||
/// Whether this needs the X-Trans demosaic path rather than Bayer.
|
||
pub fn is_xtrans(self) -> bool {
|
||
matches!(self, CfaPattern::XTrans)
|
||
}
|
||
|
||
/// The pattern as seen from an origin shifted by `(dx, dy)` photosites.
|
||
///
|
||
/// Used to re-phase the pattern after cropping to the active area
|
||
/// ([`CropRect::shifts_cfa_phase`]). X-Trans is returned unchanged: its
|
||
/// 6×6 cell does not re-phase under a 2×2 shift, so the X-Trans demosaic
|
||
/// handles the offset itself.
|
||
pub fn shifted(self, dx: bool, dy: bool) -> Self {
|
||
use CfaPattern::*;
|
||
if matches!(self, XTrans | Unknown) {
|
||
return self;
|
||
}
|
||
// Shifting one column swaps the pair horizontally; one row swaps
|
||
// vertically. Both together is the diagonal opposite.
|
||
let after_x = if dx {
|
||
match self {
|
||
Rggb => Grbg,
|
||
Grbg => Rggb,
|
||
Bggr => Gbrg,
|
||
Gbrg => Bggr,
|
||
other => other,
|
||
}
|
||
} else {
|
||
self
|
||
};
|
||
if dy {
|
||
match after_x {
|
||
Rggb => Gbrg,
|
||
Gbrg => Rggb,
|
||
Grbg => Bggr,
|
||
Bggr => Grbg,
|
||
other => other,
|
||
}
|
||
} else {
|
||
after_x
|
||
}
|
||
}
|
||
|
||
/// The colour of the photosite at `(x, y)` within the pattern.
|
||
///
|
||
/// Channel indices are 0=R, 1=G, 2=B, matching the shader's convention.
|
||
pub fn colour_at(self, x: u32, y: u32) -> u8 {
|
||
use CfaPattern::*;
|
||
// Each 2×2 cell listed row-major from its own origin.
|
||
let cell: [u8; 4] = match self {
|
||
Rggb => [0, 1, 1, 2],
|
||
Bggr => [2, 1, 1, 0],
|
||
Grbg => [1, 0, 2, 1],
|
||
Gbrg => [1, 2, 0, 1],
|
||
// Not meaningful for a 6×6 pattern or an unknown one; the caller
|
||
// must not be on the Bayer path at all.
|
||
XTrans | Unknown => [1, 1, 1, 1],
|
||
};
|
||
cell[((y % 2) * 2 + (x % 2)) as usize]
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-RAW-1 | M-9
|
||
/// Identify a format from a file header.
|
||
///
|
||
/// Content-based, not extension-based: an extension is a hint, and a
|
||
/// mismatched one should not produce a confusing decode failure downstream.
|
||
pub fn probe(header: &[u8]) -> Option<Format> {
|
||
if header.len() < 16 {
|
||
return None;
|
||
}
|
||
|
||
// JPEG: SOI marker.
|
||
if header.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||
return Some(Format::Jpeg);
|
||
}
|
||
|
||
// Fujifilm RAF carries an ASCII signature.
|
||
if header.starts_with(b"FUJIFILMCCD-RAW") {
|
||
return Some(Format::Raf);
|
||
}
|
||
|
||
// CR3 is ISO-BMFF: a `ftyp` box with a Canon brand.
|
||
if header.len() >= 12 && &header[4..8] == b"ftyp" && &header[8..11] == b"crx" {
|
||
return Some(Format::Cr3);
|
||
}
|
||
|
||
// The TIFF-derived formats share a byte-order mark plus magic. CR2 adds
|
||
// its own marker at offset 8; the rest are indistinguishable from the
|
||
// header alone and need the extension to disambiguate.
|
||
let le = header.starts_with(&[0x49, 0x49, 0x2A, 0x00]);
|
||
let be = header.starts_with(&[0x4D, 0x4D, 0x00, 0x2A]);
|
||
if le || be {
|
||
if header.len() >= 11 && &header[8..10] == b"CR" {
|
||
return Some(Format::Cr2);
|
||
}
|
||
// Ambiguous between NEF, ARW, DNG, ORF, RW2 — caller falls back to
|
||
// the extension.
|
||
return None;
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// TRACES: FR-CAT-5 | M-12
|
||
/// Read capture metadata without decoding sensor data.
|
||
pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
|
||
use rawler::rawsource::RawSource;
|
||
|
||
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;
|
||
Ok(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,
|
||
})
|
||
}
|
||
|
||
/// TRACES: FR-RAW-3 | FR-EXP-9
|
||
/// Fully decode sensor data.
|
||
///
|
||
/// The expensive path — reads the whole file and unpacks every photosite.
|
||
/// Only develop and export should call it; culling and the grid must not
|
||
/// (FR-CULL-1).
|
||
pub fn decode(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
||
use rawler::rawsource::RawSource;
|
||
|
||
let source = RawSource::new_from_slice(bytes);
|
||
let decoder =
|
||
rawler::get_decoder(&source).map_err(|e| DecodeError::Unsupported(e.to_string()))?;
|
||
let image = decoder
|
||
.raw_image(&source, &Default::default(), false)
|
||
.map_err(|e| DecodeError::Decode(e.to_string()))?;
|
||
|
||
// Derived before the match below moves `image.data`.
|
||
let color_matrix = cam_to_srgb(&image);
|
||
|
||
let data = match image.data {
|
||
rawler::RawImageData::Integer(v) => v,
|
||
rawler::RawImageData::Float(v) => {
|
||
// Float sensor data is rare; normalise to the u16 the pipeline
|
||
// expects rather than carrying two representations.
|
||
v.iter()
|
||
.map(|&f| (f * 65535.0).clamp(0.0, 65535.0) as u16)
|
||
.collect()
|
||
}
|
||
};
|
||
|
||
// Black levels are rationals; the pipeline wants plain u16 samples.
|
||
let bl = &image.blacklevel.levels;
|
||
let level_at = |i: usize| -> u16 {
|
||
bl.get(i)
|
||
.map(|r| (r.n as f32 / r.d.max(1) as f32).round() as u16)
|
||
.unwrap_or(0)
|
||
};
|
||
let black_level = [level_at(0), level_at(1), level_at(2), level_at(3)];
|
||
|
||
// Prefer the recommended crop, falling back to the active area, then to
|
||
// the whole readout. `crop_area` is what the camera itself would show;
|
||
// `active_area` merely excludes the masked border.
|
||
let rect = image.crop_area.or(image.active_area);
|
||
let crop = match rect {
|
||
Some(r) => CropRect {
|
||
x: r.p.x as u32,
|
||
y: r.p.y as u32,
|
||
width: r.d.w as u32,
|
||
height: r.d.h as u32,
|
||
},
|
||
None => CropRect {
|
||
x: 0,
|
||
y: 0,
|
||
width: image.width as u32,
|
||
height: image.height as u32,
|
||
},
|
||
};
|
||
|
||
// Re-phase the CFA to the crop origin, or the demosaic swaps R and B on
|
||
// any body whose active area starts at an odd coordinate. Applied exactly
|
||
// once — shifting twice returns the original pattern and reintroduces the
|
||
// very bug it exists to prevent.
|
||
let (dx, dy) = crop.shifts_cfa_phase();
|
||
let cfa = cfa_from_rawler(&image.camera.cfa, image.camera.model.as_str()).shifted(dx, dy);
|
||
|
||
Ok(RawImage {
|
||
width: image.width as u32,
|
||
height: image.height as u32,
|
||
crop,
|
||
data,
|
||
cfa_pattern: cfa,
|
||
black_level,
|
||
white_level: image
|
||
.whitelevel
|
||
.0
|
||
.first()
|
||
.map(|v| *v as u16)
|
||
.unwrap_or(u16::MAX),
|
||
wb_coeffs: sane_wb(image.wb_coeffs),
|
||
color_matrix,
|
||
})
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3e
|
||
/// Compose the camera→sRGB-linear matrix from rawler's XYZ→camera.
|
||
///
|
||
/// **Two rawler traps this avoids**, both measured on a Canon 6D CR2
|
||
/// (2026-08-09):
|
||
///
|
||
/// 1. `RawImage::xyz_to_cam` is **all zeros** — it carries an upstream
|
||
/// deprecation note and 0.7.2 no longer fills it. The live data is
|
||
/// `color_matrix`, keyed by illuminant. Reading the old field silently
|
||
/// yields no colour transform at all.
|
||
/// 2. `cam_to_xyz_normalized()` divides each of four rows by its own sum, and
|
||
/// the fourth row (emerald/white, unused on any Bayer body) sums to zero.
|
||
/// Every element came back `NaN`. Inverting the 3×3 ourselves avoids the
|
||
/// fourth channel entirely.
|
||
fn cam_to_srgb(image: &rawler::RawImage) -> Option<[f32; 9]> {
|
||
use rawler::imgop::xyz::Illuminant;
|
||
|
||
// Prefer D65 — it matches sRGB's white point, so no chromatic adaptation
|
||
// is needed. Illuminant A (tungsten) is a distant fallback for bodies
|
||
// that ship only one matrix; adapting it properly is a v0.2 colour-
|
||
// management concern (ARCH §5.2), not something to fake here.
|
||
let flat = image
|
||
.color_matrix
|
||
.get(&Illuminant::D65)
|
||
.or_else(|| image.color_matrix.get(&Illuminant::A))?;
|
||
if flat.len() < 9 {
|
||
return None;
|
||
}
|
||
|
||
let xyz_to_cam: [[f32; 3]; 3] = [
|
||
[flat[0], flat[1], flat[2]],
|
||
[flat[3], flat[4], flat[5]],
|
||
[flat[6], flat[7], flat[8]],
|
||
];
|
||
cam_to_srgb_from(&xyz_to_cam)
|
||
}
|
||
|
||
/// The matrix maths, split out so it can be tested without a RAW file.
|
||
//
|
||
// The constants below are quoted at their published precision rather than
|
||
// trimmed to what f32 can represent. Truncating a standard matrix to satisfy
|
||
// a linter makes it harder to check against the specification, and the
|
||
// rounding happens identically either way.
|
||
#[allow(clippy::excessive_precision)]
|
||
fn cam_to_srgb_from(xyz_to_cam: &[[f32; 3]; 3]) -> Option<[f32; 9]> {
|
||
// XYZ (D65) → linear sRGB, the standard primaries.
|
||
const XYZ_TO_SRGB: [[f32; 3]; 3] = [
|
||
[3.2404542, -1.5371385, -0.4985314],
|
||
[-0.9692660, 1.8760108, 0.0415560],
|
||
[0.0556434, -0.2040259, 1.0572252],
|
||
];
|
||
// sRGB (D65) → XYZ, for finding the camera response to white.
|
||
const SRGB_TO_XYZ: [[f32; 3]; 3] = [
|
||
[0.4124564, 0.3575761, 0.1804375],
|
||
[0.2126729, 0.7151522, 0.0721750],
|
||
[0.0193339, 0.1191920, 0.9503041],
|
||
];
|
||
|
||
// An absent or unpopulated matrix is all zeros. Using it would render
|
||
// black, so report absence and let the caller fall back to identity.
|
||
if xyz_to_cam.iter().flatten().all(|v| v.abs() < f32::EPSILON) {
|
||
return None;
|
||
}
|
||
if xyz_to_cam.iter().flatten().any(|v| !v.is_finite()) {
|
||
return None;
|
||
}
|
||
|
||
// White balance to D65: find what the camera reports for sRGB white, so
|
||
// the composed matrix maps neutral to neutral. Without this the image
|
||
// carries a strong cast even with correct primaries.
|
||
let mut cam_white = [0.0f32; 3];
|
||
for (i, row) in xyz_to_cam.iter().enumerate() {
|
||
// xyz_to_cam · (XYZ of sRGB white) — the row sums of SRGB_TO_XYZ.
|
||
for k in 0..3 {
|
||
let white_k: f32 = SRGB_TO_XYZ[k].iter().sum();
|
||
cam_white[i] += row[k] * white_k;
|
||
}
|
||
}
|
||
if cam_white.iter().any(|v| v.abs() < 1e-6 || !v.is_finite()) {
|
||
return None;
|
||
}
|
||
|
||
// Scale each row so the camera's own white becomes unity, then invert.
|
||
let balanced = [
|
||
[
|
||
xyz_to_cam[0][0] / cam_white[0],
|
||
xyz_to_cam[0][1] / cam_white[0],
|
||
xyz_to_cam[0][2] / cam_white[0],
|
||
],
|
||
[
|
||
xyz_to_cam[1][0] / cam_white[1],
|
||
xyz_to_cam[1][1] / cam_white[1],
|
||
xyz_to_cam[1][2] / cam_white[1],
|
||
],
|
||
[
|
||
xyz_to_cam[2][0] / cam_white[2],
|
||
xyz_to_cam[2][1] / cam_white[2],
|
||
xyz_to_cam[2][2] / cam_white[2],
|
||
],
|
||
];
|
||
|
||
let cam_to_xyz = invert3(&balanced)?;
|
||
|
||
let mut out = [0.0f32; 9];
|
||
for i in 0..3 {
|
||
for j in 0..3 {
|
||
let mut sum = 0.0;
|
||
for k in 0..3 {
|
||
sum += XYZ_TO_SRGB[i][k] * cam_to_xyz[k][j];
|
||
}
|
||
out[i * 3 + j] = sum;
|
||
}
|
||
}
|
||
|
||
if out.iter().any(|v| !v.is_finite()) {
|
||
return None;
|
||
}
|
||
Some(out)
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3e
|
||
/// Normalise as-shot white balance into usable multipliers.
|
||
///
|
||
/// rawler reports coefficients in RGBE order, and the fourth is `NaN` on
|
||
/// every three-colour sensor — *measured on a Canon 6D CR2 (2026-08-09):
|
||
/// `[1.893, 1.0, 1.797, NaN]`*. Uploaded to the GPU unchecked, that NaN
|
||
/// contaminates the shader's uniform block. It is normalised to 1.0 here,
|
||
/// where the reason can be written down, rather than being defended against
|
||
/// at every use site.
|
||
///
|
||
/// Coefficients are also divided through by green, so green is the reference
|
||
/// channel and exposure does not shift when white balance changes.
|
||
fn sane_wb(raw: [f32; 4]) -> [f32; 4] {
|
||
let usable = |v: f32| if v.is_finite() && v > 0.0 { v } else { 1.0 };
|
||
let (r, g, b) = (usable(raw[0]), usable(raw[1]), usable(raw[2]));
|
||
[r / g, 1.0, b / g, 1.0]
|
||
}
|
||
|
||
/// Invert a 3×3 matrix, or `None` if it is singular.
|
||
fn invert3(m: &[[f32; 3]; 3]) -> Option<[[f32; 3]; 3]> {
|
||
let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
|
||
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
|
||
if det.abs() < 1e-12 || !det.is_finite() {
|
||
return None;
|
||
}
|
||
let inv = 1.0 / det;
|
||
Some([
|
||
[
|
||
(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv,
|
||
(m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv,
|
||
(m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv,
|
||
],
|
||
[
|
||
(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv,
|
||
(m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv,
|
||
(m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv,
|
||
],
|
||
[
|
||
(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv,
|
||
(m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv,
|
||
(m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv,
|
||
],
|
||
])
|
||
}
|
||
|
||
fn cfa_from_rawler(cfa: &rawler::CFA, model: &str) -> CfaPattern {
|
||
// rawler exposes the pattern as a string; X-Trans is 6x6 rather than 2x2.
|
||
let name = cfa.name.to_ascii_uppercase();
|
||
if name.len() > 4 || model.contains("X-") {
|
||
return CfaPattern::XTrans;
|
||
}
|
||
match name.as_str() {
|
||
"RGGB" => CfaPattern::Rggb,
|
||
"BGGR" => CfaPattern::Bggr,
|
||
"GRBG" => CfaPattern::Grbg,
|
||
"GBRG" => CfaPattern::Gbrg,
|
||
_ => CfaPattern::Unknown,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn probe_identifies_jpeg() {
|
||
let mut h = vec![0xFF, 0xD8, 0xFF, 0xE0];
|
||
h.extend_from_slice(&[0u8; 16]);
|
||
assert_eq!(probe(&h), Some(Format::Jpeg));
|
||
}
|
||
|
||
#[test]
|
||
fn probe_identifies_cr2_by_its_marker() {
|
||
// Little-endian TIFF, then CR2's own magic at offset 8.
|
||
let mut h = vec![0x49, 0x49, 0x2A, 0x00, 0x10, 0, 0, 0];
|
||
h.extend_from_slice(b"CR\x02\x00");
|
||
h.extend_from_slice(&[0u8; 8]);
|
||
assert_eq!(probe(&h), Some(Format::Cr2));
|
||
}
|
||
|
||
#[test]
|
||
fn probe_identifies_raf_by_signature() {
|
||
let mut h = b"FUJIFILMCCD-RAW ".to_vec();
|
||
h.extend_from_slice(&[0u8; 16]);
|
||
assert_eq!(probe(&h), Some(Format::Raf));
|
||
}
|
||
|
||
#[test]
|
||
fn probe_returns_none_for_ambiguous_tiff() {
|
||
// NEF, ARW, DNG and ORF share this header; the extension has to
|
||
// disambiguate, and claiming a format here would be a lie.
|
||
let mut h = vec![0x49, 0x49, 0x2A, 0x00];
|
||
h.extend_from_slice(&[0u8; 20]);
|
||
assert_eq!(probe(&h), None);
|
||
}
|
||
|
||
#[test]
|
||
fn probe_rejects_short_input() {
|
||
assert_eq!(probe(&[0xFF, 0xD8]), None);
|
||
}
|
||
|
||
#[test]
|
||
fn xtrans_is_distinguishable() {
|
||
assert!(CfaPattern::XTrans.is_xtrans());
|
||
assert!(!CfaPattern::Rggb.is_xtrans());
|
||
}
|
||
|
||
#[test]
|
||
fn cfa_colours_follow_the_named_pattern() {
|
||
// RGGB: red at the origin, blue diagonally opposite.
|
||
let p = CfaPattern::Rggb;
|
||
assert_eq!(p.colour_at(0, 0), 0, "top-left is red");
|
||
assert_eq!(p.colour_at(1, 0), 1, "top-right is green");
|
||
assert_eq!(p.colour_at(0, 1), 1, "bottom-left is green");
|
||
assert_eq!(p.colour_at(1, 1), 2, "bottom-right is blue");
|
||
}
|
||
|
||
#[test]
|
||
fn cfa_pattern_repeats_every_two_photosites() {
|
||
let p = CfaPattern::Bggr;
|
||
for (x, y) in [(0u32, 0u32), (1, 0), (0, 1), (1, 1)] {
|
||
assert_eq!(p.colour_at(x, y), p.colour_at(x + 2, y + 2));
|
||
assert_eq!(p.colour_at(x, y), p.colour_at(x + 100, y + 64));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_odd_crop_origin_rephases_the_pattern() {
|
||
// The bug this prevents: a body whose active area starts at an odd
|
||
// column renders with red and blue swapped, because the visible
|
||
// top-left photosite is not the one the pattern names.
|
||
let shifted = CfaPattern::Rggb.shifted(true, false);
|
||
assert_eq!(shifted, CfaPattern::Grbg);
|
||
// Reading the shifted pattern at the origin must agree with reading
|
||
// the original one column across.
|
||
assert_eq!(shifted.colour_at(0, 0), CfaPattern::Rggb.colour_at(1, 0));
|
||
assert_eq!(shifted.colour_at(1, 0), CfaPattern::Rggb.colour_at(2, 0));
|
||
}
|
||
|
||
#[test]
|
||
fn shifting_both_axes_gives_the_diagonal_opposite() {
|
||
let s = CfaPattern::Rggb.shifted(true, true);
|
||
assert_eq!(s, CfaPattern::Bggr);
|
||
assert_eq!(s.colour_at(0, 0), CfaPattern::Rggb.colour_at(1, 1));
|
||
}
|
||
|
||
#[test]
|
||
fn shifting_is_its_own_inverse() {
|
||
for p in [
|
||
CfaPattern::Rggb,
|
||
CfaPattern::Bggr,
|
||
CfaPattern::Grbg,
|
||
CfaPattern::Gbrg,
|
||
] {
|
||
assert_eq!(p.shifted(true, false).shifted(true, false), p);
|
||
assert_eq!(p.shifted(false, true).shifted(false, true), p);
|
||
assert_eq!(p.shifted(true, true).shifted(true, true), p);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_even_crop_origin_leaves_the_pattern_alone() {
|
||
let c = CropRect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 100,
|
||
height: 100,
|
||
};
|
||
assert_eq!(c.shifts_cfa_phase(), (false, false));
|
||
assert_eq!(CfaPattern::Rggb.shifted(false, false), CfaPattern::Rggb);
|
||
|
||
let even = CropRect {
|
||
x: 84,
|
||
y: 50,
|
||
width: 100,
|
||
height: 100,
|
||
};
|
||
assert_eq!(even.shifts_cfa_phase(), (false, false));
|
||
}
|
||
|
||
#[test]
|
||
fn neutral_stays_neutral_through_the_colour_matrix() {
|
||
// The property that makes a camera matrix correct: a neutral camera
|
||
// colour must land on a neutral sRGB colour, so every row sums to 1.
|
||
// A matrix that fails this renders a strong global cast.
|
||
//
|
||
// Values are the D65 matrix rawler reports for a Canon EOS 6D.
|
||
let xyz_to_cam = [
|
||
[0.7034, -0.0804, -0.1014],
|
||
[-0.4420, 1.2564, 0.2058],
|
||
[-0.0851, 0.1994, 0.5758],
|
||
];
|
||
let m = cam_to_srgb_from(&xyz_to_cam).expect("a well-formed matrix inverts");
|
||
|
||
for (i, row) in m.chunks(3).enumerate() {
|
||
let sum: f32 = row.iter().sum();
|
||
assert!(
|
||
(sum - 1.0).abs() < 1e-4,
|
||
"row {i} sums to {sum}, not 1.0 — neutral would not stay neutral"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_all_zero_matrix_is_absent_rather_than_black() {
|
||
// rawler's deprecated `xyz_to_cam` is all zeros in 0.7.2. Treating it
|
||
// as a real matrix renders a black image; the pipeline needs to know
|
||
// to fall back to identity instead.
|
||
assert_eq!(cam_to_srgb_from(&[[0.0; 3]; 3]), None);
|
||
}
|
||
|
||
#[test]
|
||
fn a_singular_matrix_is_rejected() {
|
||
// Two identical rows cannot be inverted; returning garbage here would
|
||
// surface as an unexplained colour failure much later.
|
||
let singular = [[1.0, 2.0, 3.0], [1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
|
||
assert_eq!(cam_to_srgb_from(&singular), None);
|
||
}
|
||
|
||
#[test]
|
||
// Indexing by i/j is how the matrix identity is written down; iterators
|
||
// would obscure what is being asserted.
|
||
#[allow(clippy::needless_range_loop)]
|
||
fn inversion_round_trips() {
|
||
let m = [[2.0, 0.0, 1.0], [1.0, 3.0, 0.0], [0.0, 1.0, 4.0]];
|
||
let inv = invert3(&m).expect("invertible");
|
||
// m · inv should be the identity.
|
||
for i in 0..3 {
|
||
for j in 0..3 {
|
||
let mut sum = 0.0;
|
||
for k in 0..3 {
|
||
sum += m[i][k] * inv[k][j];
|
||
}
|
||
let expected = if i == j { 1.0 } else { 0.0 };
|
||
assert!((sum - expected).abs() < 1e-5, "element ({i},{j}) = {sum}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn white_balance_drops_the_nan_fourth_channel() {
|
||
// rawler reports RGBE, and E is NaN on every three-colour sensor.
|
||
// Measured on a Canon 6D: [1.893, 1.0, 1.797, NaN]. Uploaded raw,
|
||
// that NaN poisons the shader's uniform block.
|
||
let wb = sane_wb([1.8925781, 1.0, 1.796875, f32::NAN]);
|
||
assert!(wb.iter().all(|v| v.is_finite()), "no NaN may survive");
|
||
assert_eq!(wb[3], 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn white_balance_is_normalised_to_green() {
|
||
// Green is the reference channel, so overall exposure does not shift
|
||
// when white balance changes.
|
||
let wb = sane_wb([3.0, 2.0, 4.0, f32::NAN]);
|
||
assert_eq!(wb[1], 1.0);
|
||
assert!((wb[0] - 1.5).abs() < 1e-6);
|
||
assert!((wb[2] - 2.0).abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn absent_white_balance_falls_back_to_neutral() {
|
||
// A body reporting nothing must render neutral, not black or
|
||
// infinite.
|
||
let wb = sane_wb([0.0, 0.0, 0.0, 0.0]);
|
||
assert_eq!(wb, [1.0, 1.0, 1.0, 1.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn xtrans_does_not_rephase() {
|
||
// A 6×6 cell does not re-phase under a 2×2 shift; claiming otherwise
|
||
// would corrupt the X-Trans path rather than fix it.
|
||
assert_eq!(CfaPattern::XTrans.shifted(true, true), CfaPattern::XTrans);
|
||
}
|
||
}
|