From 28247afae7806590dcdb149d744408739fb738df Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 15:44:00 +0200 Subject: [PATCH] #1065: clean-room DNG / CinemaDNG decoder crate Problem: camera RAW is at 0% (docs/codec-parity.md, #383). CinemaDNG, the one cinema RAW format with a public specification, could not be read at all. Fix: new L0 crate filmcraft-dng, written from the public DNG 1.4-1.7, TIFF 6.0 / TIFF-EP, ITU-T T.81 and CinemaDNG specifications only (no other decoder's source read; editions in the crate README): - TIFF container with SubIFDs, bounded (64 IFDs, 4 levels, cycles end the walk) and checked offsets; the main raw IFD is the largest NewSubFileType 0 CFA / LinearRaw image. - Raw data: 1-16-bit uncompressed (MSB-first packing) and lossless JPEG (process 14, predictors 1-7, 1-4 components, point transform, line restarts), strips and tiles, tiles decoded in parallel; allocations capped by size and by the data present. - Processing per the DNG model: ActiveArea, LinearizationTable, per-site black levels with deltas, white levels, as-shot white balance with neutral highlight clipping, Malvar-He-Cutler Bayer demosaic (bilinear for other patterns), DefaultCrop, ColorMatrix / CameraCalibration / ForwardMatrix with illuminant interpolation, Bradford to linear Rec. 709, BaselineExposure; output scene-linear RGBA floats. - CinemaDNG FrameRate, TimeCodes and ReelName tags. - A test-only writer (feature `writer`) synthesises DNGs. Tests: raw samples bit-exact for every depth / predictor / layout, processing against hand-computed values, hostile files (every truncation, 12k mutations, cyclic IFDs, huge sizes, bad offsets), and LibRaw's unprocessed_raw as an external oracle reading the same samples (skipped when not installed). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 11 + Cargo.toml | 4 + crates/dng/Cargo.toml | 23 ++ crates/dng/README.md | 88 ++++++ crates/dng/src/color.rs | 385 +++++++++++++++++++++++++ crates/dng/src/demosaic.rs | 255 +++++++++++++++++ crates/dng/src/lib.rs | 358 ++++++++++++++++++++++++ crates/dng/src/ljpeg.rs | 395 ++++++++++++++++++++++++++ crates/dng/src/process.rs | 264 ++++++++++++++++++ crates/dng/src/raw.rs | 254 +++++++++++++++++ crates/dng/src/tiff.rs | 334 ++++++++++++++++++++++ crates/dng/src/writer.rs | 449 ++++++++++++++++++++++++++++++ crates/dng/tests/oracle_libraw.rs | 97 +++++++ crates/dng/tests/roundtrip.rs | 366 ++++++++++++++++++++++++ docs/architecture.md | 3 +- xtask/src/main.rs | 1 + 16 files changed, 3286 insertions(+), 1 deletion(-) create mode 100644 crates/dng/Cargo.toml create mode 100644 crates/dng/README.md create mode 100644 crates/dng/src/color.rs create mode 100644 crates/dng/src/demosaic.rs create mode 100644 crates/dng/src/lib.rs create mode 100644 crates/dng/src/ljpeg.rs create mode 100644 crates/dng/src/process.rs create mode 100644 crates/dng/src/raw.rs create mode 100644 crates/dng/src/tiff.rs create mode 100644 crates/dng/src/writer.rs create mode 100644 crates/dng/tests/oracle_libraw.rs create mode 100644 crates/dng/tests/roundtrip.rs diff --git a/Cargo.lock b/Cargo.lock index 9d1aa3311..05e5816c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1853,6 +1853,15 @@ dependencies = [ "serde", ] +[[package]] +name = "filmcraft-dng" +version = "0.6.0" +dependencies = [ + "filmcraft-dng", + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-dnx" version = "0.6.0" @@ -1884,6 +1893,7 @@ dependencies = [ "filmcraft-captions", "filmcraft-codecs", "filmcraft-color", + "filmcraft-dng", "filmcraft-edit", "filmcraft-export", "filmcraft-format", @@ -2076,6 +2086,7 @@ name = "filmcraft-media" version = "0.6.0" dependencies = [ "filmcraft-color", + "filmcraft-dng", "filmcraft-frame", "filmcraft-time", "image", diff --git a/Cargo.toml b/Cargo.toml index fd94dd632..0c2e7ba70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ filmcraft-vp9 = { path = "crates/vp9" } filmcraft-prores = { path = "crates/prores" } filmcraft-dnx = { path = "crates/dnx" } filmcraft-apv = { path = "crates/apv" } +filmcraft-dng = { path = "crates/dng" } filmcraft-av1 = { path = "crates/av1" } filmcraft-aac = { path = "crates/aac" } filmcraft-opus = { path = "crates/opus" } @@ -109,6 +110,9 @@ opt-level = 3 [profile.dev.package.filmcraft-apv] opt-level = 3 +[profile.dev.package.filmcraft-dng] +opt-level = 3 + [profile.dev.package.filmcraft-av1] opt-level = 3 diff --git a/crates/dng/Cargo.toml b/crates/dng/Cargo.toml new file mode 100644 index 000000000..1fce4930c --- /dev/null +++ b/crates/dng/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "filmcraft-dng" +description = "Clean-room DNG / CinemaDNG camera RAW decoder (TIFF container, lossless JPEG, DNG colour pipeline)" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[features] +default = ["threads"] +threads = ["dep:rayon"] +# A small DNG and lossless JPEG writer for synthesising test files (not used by the app). +writer = [] + +[dependencies] +rayon = { workspace = true, optional = true } +thiserror.workspace = true + +[dev-dependencies] +filmcraft-dng = { path = ".", features = ["writer"] } + +[lints] +workspace = true diff --git a/crates/dng/README.md b/crates/dng/README.md new file mode 100644 index 000000000..4c2b1e7e9 --- /dev/null +++ b/crates/dng/README.md @@ -0,0 +1,88 @@ +# filmcraft-dng + +Clean-room DNG and CinemaDNG camera RAW decoder in pure Rust. Layer L0: depends only on +`thiserror` and (optional, feature `threads`, default on) `rayon`. Builds for +`wasm32-unknown-unknown`; no `unsafe`. Implemented from the public specifications below only; the +source of no other raw decoder (dcraw, LibRaw, rawspeed, darktable, RawTherapee, FFmpeg, the Adobe +DNG SDK…) was read. LibRaw's `unprocessed_raw` sample program is run as an external test oracle +when it is installed, never linked. + +## Specifications + +| Document | Edition | Used for | +|---|---|---| +| Adobe Digital Negative (DNG) Specification | 1.4.0.0 (2012) – 1.7.1.0 (2023) | raw IFD selection (NewSubFileType 0), CFA / LinearRaw data, BitsPerSample packing, LinearizationTable, BlackLevel (+ RepeatDim, DeltaH / DeltaV), WhiteLevel, ActiveArea, DefaultCrop, the colour model of chapter 6 (ColorMatrix, CameraCalibration, AnalogBalance, ForwardMatrix, AsShotNeutral / AsShotWhiteXY, CalibrationIlluminant interpolation, BaselineExposure) | +| TIFF Revision 6.0 | June 1992 | header, IFDs, field types, strips and tiles, PlanarConfiguration, row padding | +| TIFF/EP, ISO 12234-2 | 2001 | CFARepeatPatternDim, CFAPattern | +| TIFF Technical Note 1 (SubIFDs) | 1993 | the SubIFD tree | +| ITU-T T.81 \| ISO/IEC 10918-1 | 09/1992 | lossless JPEG (process 14, SOF3): Annex B syntax, Annex C Huffman tables, F.2.2.3 decoding, Annex H prediction and point transform | +| CinemaDNG Image Data Format Specification | 1.1.0.0 (2009) | frames as numbered DNG files, FrameRate, TimeCodes, ReelName tags | +| SMPTE ST 12-1 | 2014 | time code BCD packing (via the CinemaDNG TimeCodes tag) | +| CIPA DC-008 (Exif) | 2.3 | LightSource codes → illuminant temperatures | +| Malvar, He, Cutler, "High-quality linear interpolation for demosaicing of Bayer-patterned color images", IEEE ICASSP | 2004 | the Bayer demosaic filters | +| McCamy, "Correlated color temperature as an explicit function of chromaticity coordinates", Color Res. Appl. 17(2) | 1992 | white xy → temperature for the calibration interpolation | +| IEC 61966-2-1 (sRGB) | 1999 | Rec. 709 / sRGB primaries and D65 white | + +## API + +```rust +if filmcraft_dng::sniff(&bytes) { + let dng = filmcraft_dng::Dng::parse(&bytes)?; + let info = dng.info()?; // size after crop, bits, CFA or LinearRaw, frame rate, time code, camera, reel + let raw = dng.raw()?; // the stored samples (u16), bit-exact + let img = dng.develop()?; // linear Rec. 709 RGBA f32 (alpha 1), info.width × info.height +} +let img = filmcraft_dng::decode(&bytes)?; // parse + develop +``` + +`writer` (feature `writer`, tests only) writes synthetic DNGs: uncompressed at any depth or +lossless JPEG with any predictor, strips or tiles, either byte order, an optional preview IFD with +the raw image in a SubIFD, and arbitrary extra tags. + +## What it decodes + +- **Container:** little and big-endian TIFF, IFD chains and SubIFDs (at most 64 IFDs, 4 levels, + cycles end the walk). The main image is the largest NewSubFileType 0 IFD with a CFA or LinearRaw + photometric interpretation. +- **Raw data:** 1–16-bit unsigned samples, uncompressed (8 and 16-bit in file byte order, other + depths packed most significant bit first) or lossless JPEG (all predictors 1–7, 1–4 components, + point transform, restart intervals of whole lines), in strips or tiles; tiles decode in + parallel. +- **Processing:** ActiveArea, LinearizationTable, per-site black levels with row / column deltas, + per-sample white levels, as-shot white balance (clipped highlights stay neutral), demosaic + (Malvar–He–Cutler for 2 × 2 Bayer patterns of any phase, bilinear for other patterns up to + 8 × 8), DefaultCrop, the DNG colour model to XYZ D50 and Bradford to linear Rec. 709, and + BaselineExposure. LinearRaw (3 samples) skips the demosaic; monochrome LinearRaw is grey. +- **Output:** scene-linear Rec. 709 floats: a white the sensor clipped is 1.0 × 2^BaselineExposure; + brighter values are kept (FilmCraft's compositor works in linear float). Negative results of the + colour matrix are clipped to 0. Black levels scale per pixel by (white − black). +- **White balance without a ForwardMatrix** follows the specification: the camera matrix takes the + values to XYZ and a Bradford adaptation moves the as-shot white to D50. Decoders in the dcraw + tradition (LibRaw included) scale the camera channels instead, so under a white far from D65 + their saturated colours differ from ours (greys agree). +- **Speed:** a 4096 × 2160 12-bit frame develops in about 80–100 ms on 16 threads (raw decode + ~15 ms, the rest demosaic and colour). + +## Not supported (errors name them) + +Lossy JPEG and JPEG XL DNG, deflate / floating-point DNG (HDR merges), planar raw data, colour +filters other than red / green / blue (CMY, RGBE), non-rectangular CFA layouts, opcode lists +(warp, vignette, bad pixels: ignored), DefaultScale and BestQualityScale (non-square pixels are +shown square), DNG 1.6 IlluminantData for illuminant 255 (calibration 1 is used), lossless JPEG +restart intervals that split a line, and MXF-wrapped CinemaDNG. + +## Tests + +- `tests/roundtrip.rs`: container parsing in both byte orders with and without a preview IFD; + uncompressed samples bit-exact at 8–16 bits, strips and tiles; lossless JPEG bit-exact for every + predictor and component count, full-swing 16-bit data (every difference category, SSSS = 16), + restarts and point transform; developed values against hand-computed ones (black / white, + white balance, ForwardMatrix, BaselineExposure, ActiveArea, DefaultCrop, LinearRaw); CinemaDNG + FrameRate / TimeCodes / ReelName; hostile files (every truncation, 12 000 mutations under + `catch_unwind`, cyclic IFDs, huge sizes, bad offsets, zero denominators). +- `tests/oracle_libraw.rs`: LibRaw's `unprocessed_raw` reads the same samples from our synthetic + files (uncompressed 8–16-bit in both byte orders, lossless JPEG predictors 1–7 with one and two + components). Skipped when it is not installed. +- Unit tests: matrix maths, the published sRGB matrix, McCamy temperatures, calibration + interpolation, sRGB / ForwardMatrix cameras mapping to themselves, demosaic on flat fields + (every Bayer phase), ramps and edges, black / white / linearisation maths. diff --git a/crates/dng/src/color.rs b/crates/dng/src/color.rs new file mode 100644 index 000000000..fb02f2c8d --- /dev/null +++ b/crates/dng/src/color.rs @@ -0,0 +1,385 @@ +//! Camera colour to linear Rec. 709, following the DNG specification's "Mapping Camera Color Space +//! to CIE XYZ Space" (DNG 1.4–1.7 chapter 6): +//! +//! - The two calibrations (ColorMatrix, CameraCalibration, ForwardMatrix 1 and 2) are blended +//! linearly in inverse correlated colour temperature between their CalibrationIlluminants. +//! - The white balance is AsShotNeutral (found as a white xy by the specification's iteration) or +//! AsShotWhiteXY; without either, D65. +//! - With ForwardMatrix: CameraToXYZ_D50 = FM · D · (AB · CC)⁻¹, D = diag((AB · CC)⁻¹ · neutral)⁻¹. +//! Without: CameraToXYZ_D50 = CA · (AB · CC · CM)⁻¹, CA a linear Bradford adaptation from the +//! white to D50. +//! - XYZ (D50) goes to linear Rec. 709 / sRGB primaries (D65 white) through Bradford again. +//! +//! The correlated colour temperature of an xy white comes from McCamy's cubic approximation +//! (C. S. McCamy, "Correlated color temperature as an explicit function of chromaticity +//! coordinates", Color Research & Application 17(2), 1992); the illuminant temperatures are the +//! CIE / EXIF 2.3 LightSource values. + +use crate::{Error, Result}; + +pub type M3 = [[f64; 3]; 3]; +pub type V3 = [f64; 3]; + +pub const IDENTITY: M3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + +/// CIE D50 and D65 white chromaticities (the DNG specification's D50). +pub const D50: (f64, f64) = (0.3457, 0.3585); +pub const D65: (f64, f64) = (0.3127, 0.3290); + +/// Linear Bradford cone response matrix. +const BRADFORD: M3 = [[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]; + +pub fn mul(a: &M3, b: &M3) -> M3 { + let mut m = [[0.0; 3]; 3]; + for (i, row) in m.iter_mut().enumerate() { + for (j, v) in row.iter_mut().enumerate() { + *v = (0..3).map(|k| a[i][k] * b[k][j]).sum(); + } + } + m +} + +pub fn mulv(a: &M3, v: &V3) -> V3 { + [0, 1, 2].map(|i| a[i][0] * v[0] + a[i][1] * v[1] + a[i][2] * v[2]) +} + +pub fn diag(v: &V3) -> M3 { + [[v[0], 0.0, 0.0], [0.0, v[1], 0.0], [0.0, 0.0, v[2]]] +} + +pub fn scale(a: &M3, s: f64) -> M3 { + a.map(|r| r.map(|v| v * s)) +} + +fn blend(a: &M3, b: &M3, w: f64) -> M3 { + let mut m = [[0.0; 3]; 3]; + for i in 0..3 { + for j in 0..3 { + m[i][j] = w * a[i][j] + (1.0 - w) * b[i][j]; + } + } + m +} + +/// The inverse of `a`, or `None` when it is (nearly) singular. +pub fn inverse(a: &M3) -> Option { + let c = |r: usize, s: usize| a[(r + 1) % 3][(s + 1) % 3] * a[(r + 2) % 3][(s + 2) % 3] - a[(r + 1) % 3][(s + 2) % 3] * a[(r + 2) % 3][(s + 1) % 3]; + let det = a[0][0] * c(0, 0) + a[0][1] * c(0, 1) + a[0][2] * c(0, 2); + let norm: f64 = a.iter().flatten().map(|v| v.abs()).fold(0.0, f64::max); + if !det.is_finite() || det.abs() <= 1e-12 * norm.powi(3).max(1e-300) { + return None; + } + let mut m = [[0.0; 3]; 3]; + for (i, row) in m.iter_mut().enumerate() { + for (j, v) in row.iter_mut().enumerate() { + *v = c(j, i) / det; + } + } + Some(m) +} + +/// XYZ (Y = 1) of a chromaticity. +pub fn xy_to_xyz((x, y): (f64, f64)) -> V3 { + let y = y.max(1e-6); + [x / y, 1.0, (1.0 - x - y) / y] +} + +pub fn xyz_to_xy(v: &V3) -> Option<(f64, f64)> { + let s = v[0] + v[1] + v[2]; + (s.is_finite() && s > 1e-12).then(|| (v[0] / s, v[1] / s)) +} + +/// Correlated colour temperature (kelvin) of a chromaticity, McCamy (1992), limited to +/// 1 000–50 000 K. +pub fn cct((x, y): (f64, f64)) -> f64 { + let d = 0.1858 - y; + if d.abs() < 1e-9 { + return 50_000.0; + } + let n = (x - 0.3320) / d; + let t = 449.0 * n.powi(3) + 3525.0 * n.powi(2) + 6823.3 * n + 5520.33; + if t.is_finite() { t.clamp(1_000.0, 50_000.0) } else { 50_000.0 } +} + +/// Colour temperature of an EXIF LightSource code used as a DNG CalibrationIlluminant. +pub fn illuminant_kelvin(code: u64) -> Option { + Some(match code { + 1 | 4 | 9 => 5500.0, // daylight, flash, fine weather + 2 | 14 => 4150.0, // fluorescent, cool white fluorescent (W 3800–4500 K) + 3 => 2850.0, // tungsten + 10 => 6500.0, // cloudy + 11 => 7500.0, // shade + 12 => 6430.0, // daylight fluorescent (D 5700–7100 K) + 13 => 5050.0, // day white fluorescent (N 4600–5500 K) + 15 => 3525.0, // white fluorescent (WW 3250–3800 K) + 16 => 2925.0, // warm white fluorescent (L 2600–3250 K) + 17 => 2856.0, // standard light A + 18 => 4874.0, // standard light B + 19 => 6774.0, // standard light C + 20 => 5503.0, // D55 + 21 => 6504.0, // D65 + 22 => 7504.0, // D75 + 23 => 5003.0, // D50 + 24 => 3200.0, // ISO studio tungsten + _ => return None, + }) +} + +/// Linear Bradford adaptation from one white (XYZ) to another. +pub fn bradford(src: &V3, dst: &V3) -> Option { + let s = mulv(&BRADFORD, src); + let d = mulv(&BRADFORD, dst); + if s.iter().any(|v| v.abs() < 1e-12) { + return None; + } + let g = [d[0] / s[0], d[1] / s[1], d[2] / s[2]]; + Some(mul(&inverse(&BRADFORD)?, &mul(&diag(&g), &BRADFORD))) +} + +/// Linear RGB (Rec. 709 / sRGB primaries, D65 white) → XYZ. +pub fn rec709_to_xyz() -> Option { + let p = [(0.64, 0.33), (0.30, 0.60), (0.15, 0.06)].map(xy_to_xyz); + let pm: M3 = [[p[0][0], p[1][0], p[2][0]], [p[0][1], p[1][1], p[2][1]], [p[0][2], p[1][2], p[2][2]]]; + let s = mulv(&inverse(&pm)?, &xy_to_xyz(D65)); + Some(mul(&pm, &diag(&s))) +} + +/// XYZ relative to D50 → linear Rec. 709. +pub fn xyz_d50_to_rec709() -> Option { + let to_d65 = bradford(&xy_to_xyz(D50), &xy_to_xyz(D65))?; + Some(mul(&inverse(&rec709_to_xyz()?)?, &to_d65)) +} + +/// The colour tags of a three-colour DNG. +#[derive(Clone, Debug, Default)] +pub struct Calibration { + pub color_matrix: [Option; 2], + pub camera_calibration: [Option; 2], + pub forward_matrix: [Option; 2], + /// EXIF LightSource codes of the two calibrations. + pub illuminant: [Option; 2], + pub analog_balance: Option, + pub as_shot_neutral: Option, + pub as_shot_white_xy: Option<(f64, f64)>, + pub baseline_exposure: f64, +} + +/// The result of [`Calibration::solve`]. +#[derive(Clone, Debug, PartialEq)] +pub struct Solved { + /// Camera neutral: raw (normalised) camera values of a white surface. Dividing by it white + /// balances the camera values. + pub neutral: V3, + /// White-balanced camera values → linear Rec. 709, including the baseline exposure. The white + /// balanced white (1, 1, 1) goes to Y = 2^BaselineExposure. + pub matrix: M3, + /// The white point found, for information. + pub white_xy: (f64, f64), +} + +impl Calibration { + /// Weight of calibration 1 for a white of temperature `t` (1 when only one is usable). + fn weight(&self, t: f64) -> f64 { + let (Some(_), Some(_)) = (self.color_matrix[0], self.color_matrix[1]) else { return 1.0 }; + let (Some(t1), Some(t2)) = (self.illuminant[0].and_then(illuminant_kelvin), self.illuminant[1].and_then(illuminant_kelvin)) else { + return 1.0; + }; + if (t1 - t2).abs() < 1.0 { + return 1.0; + } + // the formula needs t1 < t2; swap the roles otherwise + let (lo, hi, lo_is_1) = if t1 < t2 { (t1, t2, true) } else { (t2, t1, false) }; + let w_lo = if t <= lo { + 1.0 + } else if t >= hi { + 0.0 + } else { + (1.0 / t - 1.0 / hi) / (1.0 / lo - 1.0 / hi) + }; + if lo_is_1 { w_lo } else { 1.0 - w_lo } + } + + fn pick(&self, m: &[Option; 2], w: f64, default: M3) -> M3 { + match (m[0], m[1]) { + (Some(a), Some(b)) => blend(&a, &b, w), + (Some(a), None) => a, + (None, Some(b)) if self.color_matrix[0].is_none() => b, + _ => default, + } + } + + /// XYZ → camera at weight `w`: AB · CC · CM. + fn xyz_to_camera(&self, w: f64) -> Option { + let cm = match self.color_matrix { + [None, None] => return None, + _ => self.pick(&self.color_matrix, w, IDENTITY), + }; + let cc = self.pick(&self.camera_calibration, w, IDENTITY); + let ab = diag(&self.analog_balance.unwrap_or([1.0; 3])); + Some(mul(&ab, &mul(&cc, &cm))) + } + + /// Work out the white balance and the camera → linear Rec. 709 matrix. + pub fn solve(&self) -> Result { + let out_matrix = xyz_d50_to_rec709().ok_or(Error::Malformed("colour maths"))?; + let exposure = 2f64.powf(self.baseline_exposure.clamp(-16.0, 16.0)); + if self.color_matrix.iter().all(Option::is_none) { + // no colour calibration: the camera values are taken as Rec. 709, white balanced + let neutral = self.as_shot_neutral.unwrap_or([1.0; 3]); + if neutral.iter().any(|v| !v.is_finite() || *v <= 1e-9) { + return Err(Error::Malformed("camera neutral must be positive")); + } + return Ok(Solved { neutral, matrix: scale(&IDENTITY, exposure), white_xy: D65 }); + } + let bad = || Error::Malformed("singular colour matrix"); + // the white point and the camera neutral + let (white_xy, neutral) = if let Some(n) = self.as_shot_neutral { + let mut xy = D50; + for _ in 0..50 { + let w = self.weight(cct(xy)); + let to_xyz = inverse(&self.xyz_to_camera(w).ok_or_else(bad)?).ok_or_else(bad)?; + let next = xyz_to_xy(&mulv(&to_xyz, &n)).ok_or(Error::Malformed("AsShotNeutral is not a colour"))?; + let done = (next.0 - xy.0).abs() + (next.1 - xy.1).abs() < 1e-9; + xy = next; + if done { + break; + } + } + (xy, n) + } else { + let xy = self.as_shot_white_xy.unwrap_or(D65); + let w = self.weight(cct(xy)); + let n = mulv(&self.xyz_to_camera(w).ok_or_else(bad)?, &xy_to_xyz(xy)); + (xy, n) + }; + if neutral.iter().any(|v| !v.is_finite() || *v <= 1e-9) { + return Err(Error::Malformed("camera neutral must be positive")); + } + let w = self.weight(cct(white_xy)); + let ab_cc = mul(&diag(&self.analog_balance.unwrap_or([1.0; 3])), &self.pick(&self.camera_calibration, w, IDENTITY)); + let camera_to_xyz_d50 = if self.forward_matrix.iter().any(Option::is_some) { + let fm = self.pick(&self.forward_matrix, w, IDENTITY); + let inv_ab_cc = inverse(&ab_cc).ok_or_else(bad)?; + let reference = mulv(&inv_ab_cc, &neutral); + if reference.iter().any(|v| v.abs() < 1e-12) { + return Err(bad()); + } + mul(&fm, &mul(&diag(&reference.map(|v| 1.0 / v)), &inv_ab_cc)) + } else { + let to_xyz = inverse(&self.xyz_to_camera(w).ok_or_else(bad)?).ok_or_else(bad)?; + let ca = bradford(&xy_to_xyz(white_xy), &xy_to_xyz(D50)).ok_or_else(bad)?; + mul(&ca, &to_xyz) + }; + // white-balanced camera values: camera = neutral · wb + let wb_to_xyz = mul(&camera_to_xyz_d50, &diag(&neutral)); + let y = mulv(&wb_to_xyz, &[1.0; 3])[1]; + if !y.is_finite() || y <= 1e-9 { + return Err(bad()); + } + let matrix = scale(&mul(&out_matrix, &wb_to_xyz), exposure / y); + if matrix.iter().flatten().any(|v| !v.is_finite()) { + return Err(bad()); + } + Ok(Solved { neutral, matrix, white_xy }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn close(a: &M3, b: &M3, tol: f64) -> bool { + a.iter().flatten().zip(b.iter().flatten()).all(|(x, y)| (x - y).abs() <= tol) + } + + #[test] + fn rec709_matrix_matches_the_published_one() { + // IEC 61966-2-1 (sRGB) linear RGB → XYZ, four decimals + let m = rec709_to_xyz().unwrap(); + let want = [[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]]; + assert!(close(&m, &want, 1e-3), "{m:?}"); + } + + #[test] + fn inverse_round_trips_and_refuses_singular() { + let a = [[2.0, 1.0, 0.5], [0.1, 3.0, 0.2], [0.3, 0.4, 1.5]]; + assert!(close(&mul(&a, &inverse(&a).unwrap()), &IDENTITY, 1e-12)); + assert!(inverse(&[[1.0, 2.0, 3.0], [2.0, 4.0, 6.0], [0.0, 0.0, 1.0]]).is_none()); + assert!(inverse(&[[f64::NAN; 3]; 3]).is_none()); + } + + #[test] + fn mccamy_temperatures() { + assert!((cct(D65) - 6504.0).abs() < 15.0, "{}", cct(D65)); + assert!((cct(D50) - 5003.0).abs() < 15.0, "{}", cct(D50)); + assert!((cct((0.44757, 0.40745)) - 2856.0).abs() < 15.0, "illuminant A"); + assert_eq!(cct((0.3320, 0.1858)), 50_000.0, "no division by zero"); + } + + #[test] + fn interpolation_weight_follows_inverse_temperature() { + let c = Calibration { color_matrix: [Some(IDENTITY), Some(IDENTITY)], illuminant: [Some(17), Some(21)], ..Default::default() }; + assert_eq!(c.weight(2000.0), 1.0); + assert_eq!(c.weight(9000.0), 0.0); + let mid = c.weight(4000.0); + let want = (1.0 / 4000.0 - 1.0 / 6504.0) / (1.0 / 2856.0 - 1.0 / 6504.0); + assert!((mid - want).abs() < 1e-12); + // swapped order: weight of calibration 1 (now D65) + let s = Calibration { illuminant: [Some(21), Some(17)], ..c.clone() }; + assert!((s.weight(4000.0) - (1.0 - want)).abs() < 1e-12); + // unknown illuminant: calibration 1 only + let u = Calibration { illuminant: [Some(0), Some(21)], ..c }; + assert_eq!(u.weight(4000.0), 1.0); + } + + #[test] + fn an_srgb_camera_maps_to_itself() { + // ColorMatrix = XYZ → linear sRGB, white D65: the camera is Rec. 709 already + let cm = inverse(&rec709_to_xyz().unwrap()).unwrap(); + let c = Calibration { color_matrix: [Some(cm), None], illuminant: [Some(21), None], as_shot_neutral: Some([1.0; 3]), ..Default::default() }; + let s = c.solve().unwrap(); + assert!(close(&s.matrix, &IDENTITY, 1e-6), "{:?}", s.matrix); + assert!((s.white_xy.0 - D65.0).abs() < 1e-4 && (s.white_xy.1 - D65.1).abs() < 1e-4, "{:?}", s.white_xy); + // a baseline exposure of +1 doubles everything + let e = Calibration { baseline_exposure: 1.0, ..c }.solve().unwrap(); + assert!(close(&e.matrix, &scale(&IDENTITY, 2.0), 1e-6)); + } + + #[test] + fn forward_matrix_white_balances_by_the_neutral() { + // FM = linear Rec. 709 → XYZ D50: the white-balanced camera values come out unchanged + let fm = mul(&bradford(&xy_to_xyz(D65), &xy_to_xyz(D50)).unwrap(), &rec709_to_xyz().unwrap()); + let cm = inverse(&rec709_to_xyz().unwrap()).unwrap(); + let c = Calibration { + color_matrix: [Some(cm), None], + forward_matrix: [Some(fm), None], + illuminant: [Some(21), None], + as_shot_neutral: Some([0.5, 1.0, 0.8]), + ..Default::default() + }; + let s = c.solve().unwrap(); + assert_eq!(s.neutral, [0.5, 1.0, 0.8]); + assert!(close(&s.matrix, &IDENTITY, 1e-6), "{:?}", s.matrix); + } + + #[test] + fn white_xy_gives_the_neutral() { + let cm = inverse(&rec709_to_xyz().unwrap()).unwrap(); + let c = Calibration { color_matrix: [Some(cm), None], illuminant: [Some(21), None], as_shot_white_xy: Some(D65), ..Default::default() }; + let s = c.solve().unwrap(); + for v in s.neutral { + assert!((v - s.neutral[1]).abs() < 1e-3, "D65 is neutral for an sRGB camera: {:?}", s.neutral); + } + } + + #[test] + fn hostile_calibrations_are_errors() { + let zero = Calibration { color_matrix: [Some([[0.0; 3]; 3]), None], as_shot_neutral: Some([1.0; 3]), ..Default::default() }; + assert!(zero.solve().is_err()); + let cm = inverse(&rec709_to_xyz().unwrap()).unwrap(); + let neg = Calibration { color_matrix: [Some(cm), None], as_shot_neutral: Some([-1.0, 1.0, 1.0]), ..Default::default() }; + assert!(neg.solve().is_err()); + let none = Calibration { as_shot_neutral: Some([0.5, 1.0, 0.5]), ..Default::default() }.solve().unwrap(); + assert_eq!(none.matrix, IDENTITY); + } +} diff --git a/crates/dng/src/demosaic.rs b/crates/dng/src/demosaic.rs new file mode 100644 index 000000000..911236929 --- /dev/null +++ b/crates/dng/src/demosaic.rs @@ -0,0 +1,255 @@ +//! Demosaicing: one colour sample per pixel (a colour filter array) to three. +//! +//! - A 2 × 2 Bayer pattern (one red, two green, one blue) uses the gradient-corrected linear +//! interpolation of H. S. Malvar, L.-W. He and R. Cutler, "High-quality linear interpolation for +//! demosaicing of Bayer-patterned color images", IEEE ICASSP 2004: bilinear interpolation plus a +//! Laplacian correction from the pixel's own colour, as 5 × 5 filters. +//! - Any other pattern (up to 8 × 8) uses bilinear interpolation: the mean of the nearest samples +//! of each missing colour in the 3 × 3 neighbourhood, else the 5 × 5 one. +//! +//! The picture is extended by two pixels on every side by mirroring (without repeating the edge), +//! which keeps a 2 × 2 pattern's phase. + +#[cfg(feature = "threads")] +use rayon::prelude::*; + +/// A CFA pattern: `rows` × `cols` colour indices (0 red, 1 green, 2 blue), origin at the top left +/// of the image. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Pattern { + pub rows: usize, + pub cols: usize, + pub colors: Vec, +} + +impl Pattern { + pub fn color(&self, x: usize, y: usize) -> u8 { + self.colors.get((y % self.rows) * self.cols + x % self.cols).copied().unwrap_or(1) + } + + /// Whether this is a 2 × 2 Bayer pattern. + pub fn is_bayer(&self) -> bool { + if (self.rows, self.cols) != (2, 2) { + return false; + } + let [a, b, c, d] = self.colors[..] else { return false }; + let count = |k: u8| [a, b, c, d].iter().filter(|&&v| v == k).count(); + // the two greens sit on a diagonal + count(0) == 1 && count(1) == 2 && count(2) == 1 && ((a == 1 && d == 1) || (b == 1 && c == 1)) + } +} + +const PAD: usize = 2; + +/// Mirror index `i` (which may be up to PAD outside) into 0..n. +fn mirror(i: isize, n: usize) -> usize { + let n = n as isize; + let mut i = i; + if i < 0 { + i = -i; + } + if i >= n { + i = 2 * (n - 1) - i; + } + i.clamp(0, n - 1) as usize +} + +/// The plane extended by PAD mirrored pixels on each side. +fn padded(plane: &[f32], w: usize, h: usize) -> (Vec, usize) { + let pw = w + 2 * PAD; + let ph = h + 2 * PAD; + let mut out = vec![0f32; pw * ph]; + for y in 0..ph { + let sy = mirror(y as isize - PAD as isize, h); + for x in 0..pw { + let sx = mirror(x as isize - PAD as isize, w); + out[y * pw + x] = plane.get(sy * w + sx).copied().unwrap_or(0.0); + } + } + (out, pw) +} + +/// Demosaic `plane` (`w` × `h`, one sample per pixel) into interleaved RGB. +pub fn demosaic(plane: &[f32], w: usize, h: usize, pattern: &Pattern) -> Vec { + let mut out = vec![0f32; w * h * 3]; + if w == 0 || h == 0 || plane.len() < w * h { + return out; + } + let (p, pw) = padded(plane, w, h); + let bayer = pattern.is_bayer() && w >= 3 && h >= 3; + let row = |y: usize, o: &mut [f32]| { + if bayer { + malvar_row(&p, pw, y, w, pattern, o); + } else { + bilinear_row(&p, pw, y, w, h, pattern, o); + } + }; + #[cfg(feature = "threads")] + out.par_chunks_mut(w * 3).enumerate().for_each(|(y, o)| row(y, o)); + #[cfg(not(feature = "threads"))] + out.chunks_mut(w * 3).enumerate().for_each(|(y, o)| row(y, o)); + out +} + +fn malvar_row(p: &[f32], pw: usize, y: usize, w: usize, pat: &Pattern, out: &mut [f32]) { + let py = y + PAD; + for x in 0..w { + let px = x + PAD; + let at = |dx: isize, dy: isize| p[((py as isize + dy) as usize) * pw + (px as isize + dx) as usize]; + let c = at(0, 0); + let cross1 = at(-1, 0) + at(1, 0) + at(0, -1) + at(0, 1); + let cross2 = at(-2, 0) + at(2, 0) + at(0, -2) + at(0, 2); + let diag = at(-1, -1) + at(1, -1) + at(-1, 1) + at(1, 1); + let me = pat.color(x, y); + let rgb = match me { + 1 => { + let h1 = at(-1, 0) + at(1, 0); + let h2 = at(-2, 0) + at(2, 0); + let v1 = at(0, -1) + at(0, 1); + let v2 = at(0, -2) + at(0, 2); + // colour along the row and along the column + let along_row = (5.0 * c + 4.0 * h1 - h2 - diag + 0.5 * v2) / 8.0; + let along_col = (5.0 * c + 4.0 * v1 - v2 - diag + 0.5 * h2) / 8.0; + if pat.color(x + 1, y) == 0 { [along_row, c, along_col] } else { [along_col, c, along_row] } + } + _ => { + let g = (4.0 * c + 2.0 * cross1 - cross2) / 8.0; + let other = (6.0 * c + 2.0 * diag - 1.5 * cross2) / 8.0; + if me == 0 { [c, g, other] } else { [other, g, c] } + } + }; + if let Some(o) = out.get_mut(x * 3..x * 3 + 3) { + o.copy_from_slice(&rgb); + } + } +} + +fn bilinear_row(p: &[f32], pw: usize, y: usize, w: usize, h: usize, pat: &Pattern, out: &mut [f32]) { + for x in 0..w { + let me = pat.color(x, y); + let mut rgb = [0f32; 3]; + for (k, v) in rgb.iter_mut().enumerate() { + if usize::from(me) == k { + *v = p[(y + PAD) * pw + x + PAD]; + continue; + } + for r in 1..=PAD as isize { + let (mut sum, mut n) = (0f32, 0u32); + for dy in -r..=r { + for dx in -r..=r { + // the colour of a mirrored neighbour is the colour where it was mirrored from + let sx = mirror(x as isize + dx, w); + let sy = mirror(y as isize + dy, h); + if usize::from(pat.color(sx, sy)) == k { + sum += p[(sy + PAD) * pw + sx + PAD]; + n += 1; + } + } + } + if n > 0 { + *v = sum / n as f32; + break; + } + } + } + if let Some(o) = out.get_mut(x * 3..x * 3 + 3) { + o.copy_from_slice(&rgb); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rggb() -> Pattern { + Pattern { rows: 2, cols: 2, colors: vec![0, 1, 1, 2] } + } + + /// A mosaic of an RGB picture given per pixel. + fn mosaic(w: usize, h: usize, pat: &Pattern, rgb: impl Fn(usize, usize) -> [f32; 3]) -> Vec { + (0..h).flat_map(|y| (0..w).map(move |x| (x, y))).map(|(x, y)| rgb(x, y)[usize::from(pat.color(x, y))]).collect() + } + + #[test] + fn bayer_detection() { + assert!(rggb().is_bayer()); + for c in [[1, 0, 2, 1], [1, 2, 0, 1], [2, 1, 1, 0]] { + assert!(Pattern { rows: 2, cols: 2, colors: c.to_vec() }.is_bayer(), "{c:?}"); + } + assert!(!Pattern { rows: 2, cols: 2, colors: vec![0, 1, 2, 1] }.is_bayer(), "greens side by side"); + assert!(!Pattern { rows: 2, cols: 2, colors: vec![0, 0, 1, 2] }.is_bayer()); + assert!(!Pattern { rows: 1, cols: 3, colors: vec![0, 1, 2] }.is_bayer()); + } + + #[test] + fn flat_fields_stay_flat_for_every_bayer_phase_and_other_patterns() { + let pats = [[0, 1, 1, 2], [1, 0, 2, 1], [1, 2, 0, 1], [2, 1, 1, 0]].map(|c| Pattern { rows: 2, cols: 2, colors: c.to_vec() }); + let stripes = Pattern { rows: 1, cols: 3, colors: vec![0, 1, 2] }; + for pat in pats.iter().chain([&stripes]) { + let (w, h) = (9, 7); + let m = mosaic(w, h, pat, |_, _| [0.25, 0.5, 0.75]); + let out = demosaic(&m, w, h, pat); + for (i, px) in out.chunks(3).enumerate() { + for (k, want) in [0.25, 0.5, 0.75].iter().enumerate() { + assert!((px[k] - want).abs() < 1e-6, "{pat:?} pixel {i} channel {k}: {}", px[k]); + } + } + } + } + + #[test] + fn known_samples_are_kept_and_linear_ramps_are_exact() { + // a horizontal ramp is reconstructed exactly away from the border (all filters are + // symmetric and sum to one, the correction terms sum to zero) + let pat = rggb(); + let (w, h) = (16, 12); + let f = |x: usize, _y: usize| { + let v = x as f32 / 16.0; + [v, 0.5 * v + 0.1, 0.2 + 0.25 * v] + }; + let m = mosaic(w, h, &pat, f); + let out = demosaic(&m, w, h, &pat); + for y in 0..h { + for x in 0..w { + let px = &out[(y * w + x) * 3..][..3]; + let own = usize::from(pat.color(x, y)); + assert_eq!(px[own], m[y * w + x], "own sample kept"); + if (2..w - 2).contains(&x) { + let want = f(x, y); + for k in 0..3 { + assert!((px[k] - want[k]).abs() < 1e-5, "({x},{y}) ch {k}: {} vs {}", px[k], want[k]); + } + } + } + } + } + + #[test] + fn a_vertical_edge_does_not_smear_far() { + // white left half, black right half: pixels two columns from the edge are untouched + let pat = rggb(); + let (w, h) = (12, 8); + let m = mosaic(w, h, &pat, |x, _| if x < 6 { [1.0; 3] } else { [0.0; 3] }); + let out = demosaic(&m, w, h, &pat); + for y in 0..h { + for x in (0..3).chain(9..12) { + let want = if x < 6 { 1.0 } else { 0.0 }; + for k in 0..3 { + assert!((out[(y * w + x) * 3 + k] - want).abs() < 1e-6, "({x},{y}) ch {k}"); + } + } + } + } + + #[test] + fn tiny_and_empty_pictures_do_not_panic() { + let pat = rggb(); + for (w, h) in [(0, 0), (1, 1), (2, 1), (1, 5), (2, 2), (3, 2)] { + let m = vec![0.5; w * h]; + let out = demosaic(&m, w, h, &pat); + assert_eq!(out.len(), w * h * 3); + } + assert_eq!(demosaic(&[0.5; 3], 4, 4, &pat).len(), 48, "short plane"); + } +} diff --git a/crates/dng/src/lib.rs b/crates/dng/src/lib.rs new file mode 100644 index 000000000..c0b3e9e0b --- /dev/null +++ b/crates/dng/src/lib.rs @@ -0,0 +1,358 @@ +//! Clean-room DNG and CinemaDNG decoder, implemented from the public specifications only (see the +//! crate README for editions): the TIFF 6.0 / TIFF-EP container, uncompressed and lossless JPEG +//! (ITU-T T.81 process 14) raw data, and the DNG processing model (linearisation, black and white +//! levels, white balance, demosaic, default crop, colour matrices) to a scene-linear Rec. 709 +//! picture. +//! +//! - [`sniff`]: is this a DNG (a TIFF with a DNGVersion tag)? +//! - [`Dng::parse`] → [`Dng::info`] (size, bit depth, CinemaDNG frame rate and time code), +//! [`Dng::raw`] (the raw samples) and [`Dng::develop`] (linear RGBA floats). +//! - [`decode`]: parse and develop in one call. +//! +//! Every number in the file is hostile: offsets, counts and sizes are checked, allocations are +//! capped by [`raw::MAX_SAMPLES`] and by the amount of data present, and the IFD walk is bounded. + +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] + +pub mod color; +pub mod demosaic; +pub mod ljpeg; +pub mod process; +pub mod raw; +pub mod tiff; +#[cfg(any(test, feature = "writer"))] +pub mod writer; + +use color::{Calibration, M3, V3}; +use demosaic::Pattern; +use process::Params; +use raw::{PHOTOMETRIC_CFA, RawImage}; +use tiff::{Entry, Ifd, Tiff, tags}; + +/// DNG decoding errors. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("not a DNG file")] + NotDng, + #[error("truncated DNG data")] + Truncated, + #[error("raw image too large")] + TooLarge, + #[error("malformed DNG: {0}")] + Malformed(&'static str), + #[error("unsupported DNG: {0}")] + Unsupported(String), +} + +pub type Result = std::result::Result; + +/// Whether `b` is a DNG: a TIFF file whose first IFD has a DNGVersion tag. +pub fn sniff(b: &[u8]) -> bool { + let Ok((t, first)) = Tiff::new(b) else { return false }; + t.read_ifd(first, 0).is_ok_and(|(ifd, _)| ifd.get(tags::DNG_VERSION).is_some()) +} + +/// SMPTE ST 12-1 time code of a CinemaDNG frame. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Timecode { + pub hours: u8, + pub minutes: u8, + pub seconds: u8, + pub frames: u8, + pub drop_frame: bool, +} + +impl Timecode { + /// The first time code of a CinemaDNG TimeCodes tag (SMPTE ST 331 / 12-1 packing: BCD frames, + /// seconds, minutes, hours in bytes 0–3, the drop-frame flag in bit 6 of byte 0). + pub fn from_bytes(b: &[u8]) -> Option { + let bcd = |v: u8, tens_mask: u8| { + let (u, t) = (v & 0x0F, (v >> 4) & tens_mask); + (u <= 9).then_some(t * 10 + u) + }; + let [f, s, m, h, ..] = *b else { return None }; + let tc = Timecode { frames: bcd(f, 0x3)?, seconds: bcd(s, 0x7)?, minutes: bcd(m, 0x7)?, hours: bcd(h, 0x3)?, drop_frame: f & 0x40 != 0 }; + (tc.seconds < 60 && tc.minutes < 60 && tc.hours < 24).then_some(tc) + } +} + +/// What a DNG holds, without decoding the pixels. +#[derive(Clone, Debug, PartialEq)] +pub struct Info { + /// Size of the developed picture (the default crop). + pub width: usize, + pub height: usize, + /// Size of the stored raw image. + pub raw_width: usize, + pub raw_height: usize, + pub bits: u32, + pub samples_per_pixel: usize, + /// Colour filter array data (else LinearRaw). + pub cfa: bool, + /// "uncompressed" or "lossless JPEG". + pub compression: &'static str, + pub dng_version: [u8; 4], + /// CinemaDNG FrameRate (numerator, denominator). + pub frame_rate: Option<(i64, i64)>, + /// CinemaDNG time code of this frame. + pub timecode: Option, + /// UniqueCameraModel, else Make and Model. + pub camera: Option, + /// CinemaDNG ReelName. + pub reel: Option, +} + +/// A developed DNG: premultiplied, scene-linear Rec. 709 RGBA floats (alpha 1), top row first. +/// A white surface the sensor saw at its clipping level is 1.0 times 2^BaselineExposure. +pub struct Image { + pub info: Info, + pub width: usize, + pub height: usize, + pub rgba: Vec, +} + +/// A parsed DNG file. +pub struct Dng<'a> { + tiff: Tiff<'a>, + ifds: Vec, + /// Index of the main raw IFD in `ifds` (the first is IFD 0). + raw: usize, +} + +impl<'a> Dng<'a> { + /// Parse the container and find the main raw image. + pub fn parse(bytes: &'a [u8]) -> Result> { + let (tiff, first) = Tiff::new(bytes)?; + let ifds = tiff.all_ifds(first)?; + if ifds.first().and_then(|i| i.get(tags::DNG_VERSION)).is_none() { + return Err(Error::NotDng); + } + let main = raw::find_raw_ifd(&tiff, &ifds)?; + let raw = ifds.iter().position(|i| i.offset == main.offset).ok_or(Error::Malformed("raw IFD"))?; + Ok(Dng { tiff, ifds, raw }) + } + + fn ifd0(&self) -> &Ifd { + static EMPTY: Ifd = Ifd { offset: 0, entries: Vec::new(), depth: 0 }; + self.ifds.first().unwrap_or(&EMPTY) + } + + fn raw_ifd(&self) -> &Ifd { + self.ifds.get(self.raw).unwrap_or(self.ifd0()) + } + + /// A raw-image tag: from the raw IFD, else IFD 0. + fn raw_tag(&self, tag: u16) -> Option<&Entry> { + self.raw_ifd().get(tag).or_else(|| self.ifd0().get(tag)) + } + + /// A camera-wide tag: from IFD 0, else the raw IFD. + fn main_tag(&self, tag: u16) -> Option<&Entry> { + self.ifd0().get(tag).or_else(|| self.raw_ifd().get(tag)) + } + + /// A numeric tag's values; a value outside the file (or a zero denominator) is an error, so a + /// damaged file does not silently lose its white balance. + fn floats(&self, e: Option<&Entry>) -> Result>> { + e.map(|e| self.tiff.floats(e)).transpose() + } + + /// A 3 × 3 matrix tag (`None` when absent or not 3 × 3, e.g. for other colour counts). + fn matrix(&self, tag: u16) -> Result> { + let Some(v) = self.floats(self.main_tag(tag))? else { return Ok(None) }; + let Ok(v) = <[f64; 9]>::try_from(v.as_slice()) else { return Ok(None) }; + Ok(Some([[v[0], v[1], v[2]], [v[3], v[4], v[5]], [v[6], v[7], v[8]]])) + } + + fn vec3(&self, tag: u16) -> Result> { + Ok(self.floats(self.main_tag(tag))?.and_then(|v| <[f64; 3]>::try_from(v.as_slice()).ok())) + } + + fn text(&self, tag: u16) -> Option { + self.main_tag(tag).and_then(|e| self.tiff.ascii(e).ok()).filter(|s| !s.is_empty()) + } + + /// The CFA pattern in camera planes (`None` for LinearRaw). + fn cfa(&self) -> Result> { + let l = raw::layout(&self.tiff, self.raw_ifd())?; + if l.photometric != PHOTOMETRIC_CFA { + return Ok(None); + } + let dims = self.raw_tag(tags::CFA_REPEAT_PATTERN_DIM).map(|e| self.tiff.uints(e)).transpose()?.unwrap_or_else(|| vec![2, 2]); + let (rows, cols) = match dims[..] { + [r, c, ..] => (r as usize, c as usize), + _ => return Err(Error::Malformed("CFARepeatPatternDim")), + }; + if !(1..=8).contains(&rows) || !(1..=8).contains(&cols) { + return Err(Error::Unsupported(format!("{rows} × {cols} CFA pattern"))); + } + let pattern = self.tiff.uints(self.raw_tag(tags::CFA_PATTERN).ok_or(Error::Malformed("no CFAPattern"))?)?; + if pattern.len() < rows * cols { + return Err(Error::Malformed("CFAPattern shorter than its repeat")); + } + let planes = match self.raw_tag(tags::CFA_PLANE_COLOR) { + Some(e) => self.tiff.uints(e)?, + None => vec![0, 1, 2], + }; + let mut sorted = planes.clone(); + sorted.sort_unstable(); + if sorted != [0, 1, 2] { + return Err(Error::Unsupported("CFA colours other than red, green and blue".into())); + } + if let Some(e) = self.raw_tag(tags::CFA_LAYOUT) + && self.tiff.uint(e)? != 1 + { + return Err(Error::Unsupported("a non-rectangular CFA layout".into())); + } + let colors = pattern[..rows * cols] + .iter() + .map(|&v| planes.iter().position(|&p| p == v).map(|i| i as u8).ok_or(Error::Malformed("CFAPattern colour not in CFAPlaneColor"))) + .collect::>>()?; + Ok(Some(Pattern { rows, cols, colors })) + } + + /// The processing tags of the raw image. + pub fn params(&self) -> Result { + let uint_pair = |tag| -> Result> { + match self.raw_tag(tag) { + Some(e) => match self.tiff.uints(e)?[..] { + [a, b, ..] => Ok(Some((a as usize, b as usize))), + _ => Err(Error::Malformed("short field")), + }, + None => Ok(None), + } + }; + let active_area = match self.raw_tag(tags::ACTIVE_AREA) { + Some(e) => match self.tiff.uints(e)?[..] { + [t, l, b, r, ..] => Some([t, l, b, r].map(|v| usize::try_from(v).unwrap_or(usize::MAX))), + _ => return Err(Error::Malformed("ActiveArea")), + }, + None => None, + }; + let linearization = match self.raw_tag(tags::LINEARIZATION_TABLE) { + Some(e) => { + let v = self.tiff.uints(e)?; + if v.len() > 65_536 { + return Err(Error::Malformed("LinearizationTable longer than 65536")); + } + Some(v.into_iter().map(|x| x.min(65_535) as u16).collect()) + } + None => None, + }; + let pair = |tag| -> Result> { + match self.raw_tag(tag) { + Some(e) => match self.tiff.floats(e)?[..] { + [a, b, ..] => Ok(Some((a, b))), + _ => Err(Error::Malformed("short field")), + }, + None => Ok(None), + } + }; + let list = |tag| -> Result> { self.raw_tag(tag).map(|e| self.tiff.floats(e)).transpose().map(Option::unwrap_or_default) }; + Ok(Params { + active_area, + linearization, + black_repeat: uint_pair(tags::BLACK_LEVEL_REPEAT_DIM)?.unwrap_or((1, 1)), + black: list(tags::BLACK_LEVEL)?, + black_delta_h: list(tags::BLACK_LEVEL_DELTA_H)?, + black_delta_v: list(tags::BLACK_LEVEL_DELTA_V)?, + white: list(tags::WHITE_LEVEL)?, + crop_origin: pair(tags::DEFAULT_CROP_ORIGIN)?, + crop_size: pair(tags::DEFAULT_CROP_SIZE)?, + cfa: self.cfa()?, + }) + } + + /// The colour tags. + pub fn calibration(&self) -> Result { + let illum = |tag| self.main_tag(tag).map(|e| self.tiff.uint(e)).transpose(); + let white_xy = self.floats(self.main_tag(tags::AS_SHOT_WHITE_XY))?.and_then(|v| match v[..] { + [x, y] => Some((x, y)), + _ => None, + }); + Ok(Calibration { + color_matrix: [self.matrix(tags::COLOR_MATRIX_1)?, self.matrix(tags::COLOR_MATRIX_2)?], + camera_calibration: [self.matrix(tags::CAMERA_CALIBRATION_1)?, self.matrix(tags::CAMERA_CALIBRATION_2)?], + forward_matrix: [self.matrix(tags::FORWARD_MATRIX_1)?, self.matrix(tags::FORWARD_MATRIX_2)?], + illuminant: [illum(tags::CALIBRATION_ILLUMINANT_1)?, illum(tags::CALIBRATION_ILLUMINANT_2)?], + analog_balance: self.vec3(tags::ANALOG_BALANCE)?, + as_shot_neutral: self.vec3(tags::AS_SHOT_NEUTRAL)?, + as_shot_white_xy: white_xy, + baseline_exposure: self.floats(self.main_tag(tags::BASELINE_EXPOSURE))?.and_then(|v| v.first().copied()).unwrap_or(0.0), + }) + } + + /// Size, depth and CinemaDNG metadata. + pub fn info(&self) -> Result { + let l = raw::layout(&self.tiff, self.raw_ifd())?; + let p = self.params()?; + let (_, _, aw, ah) = process::active_rect(&p, l.width, l.height)?; + let (_, _, width, height) = process::crop_rect(&p, aw, ah); + let dng_version = self + .main_tag(tags::DNG_VERSION) + .and_then(|e| self.tiff.uints(e).ok()) + .and_then(|v| v.get(..4).map(|v| [0, 1, 2, 3].map(|i| v[i] as u8))) + .unwrap_or_default(); + let frame_rate = self.main_tag(tags::FRAME_RATE).and_then(|e| { + let b = self.tiff.value_bytes(e).ok()?; + if !matches!(e.ty, tiff::Type::SRational | tiff::Type::Rational) { + return None; + } + let n = i64::from(self.tiff.u32_of(b.get(0..4)?.try_into().ok()?) as i32); + let d = i64::from(self.tiff.u32_of(b.get(4..8)?.try_into().ok()?) as i32); + (n > 0 && d > 0 && n <= 1000 * d && n * 1000 >= d).then_some((n, d)) + }); + let timecode = self.main_tag(tags::TIME_CODES).and_then(|e| self.tiff.value_bytes(e).ok()).and_then(Timecode::from_bytes); + let camera = self.text(tags::UNIQUE_CAMERA_MODEL).or_else(|| { + let mm = [self.text(tags::MAKE), self.text(tags::MODEL)].into_iter().flatten().collect::>().join(" "); + (!mm.is_empty()).then_some(mm) + }); + Ok(Info { + width, + height, + raw_width: l.width, + raw_height: l.height, + bits: l.bits, + samples_per_pixel: l.samples_per_pixel, + cfa: p.cfa.is_some(), + compression: if l.compression == 7 { "lossless JPEG" } else { "uncompressed" }, + dng_version, + frame_rate, + timecode, + camera, + reel: self.text(tags::REEL_NAME), + }) + } + + /// The raw samples of the main image, as stored (before any processing). + pub fn raw(&self) -> Result { + raw::read(&self.tiff, self.raw_ifd()) + } + + /// Develop the main image to scene-linear Rec. 709. + pub fn develop(&self) -> Result { + let info = self.info()?; + let p = self.params()?; + let raw = self.raw()?; + let calibration = self.calibration()?; + let solved = if p.cfa.is_none() && raw.samples_per_pixel == 1 { + // monochrome: no colour, only the exposure + let e = 2f64.powf(calibration.baseline_exposure.clamp(-16.0, 16.0)); + color::Solved { neutral: [1.0; 3], matrix: color::scale(&color::IDENTITY, e), white_xy: color::D65 } + } else { + calibration.solve()? + }; + let d = process::develop(&raw, &p, &solved.neutral, &solved.matrix)?; + Ok(Image { info, width: d.width, height: d.height, rgba: d.rgba }) + } +} + +/// Parse and develop a DNG file. +pub fn decode(bytes: &[u8]) -> Result { + Dng::parse(bytes)?.develop() +} + +/// Parse a DNG file's metadata. +pub fn probe(bytes: &[u8]) -> Result { + Dng::parse(bytes)?.info() +} diff --git a/crates/dng/src/ljpeg.rs b/crates/dng/src/ljpeg.rs new file mode 100644 index 000000000..e76dbd707 --- /dev/null +++ b/crates/dng/src/ljpeg.rs @@ -0,0 +1,395 @@ +//! Lossless JPEG decoding: ITU-T T.81 | ISO/IEC 10918-1 Annex H (process 14, Huffman coded, +//! "SOF3"), the compression DNG calls lossless JPEG (Compression = 7). +//! +//! - Frame header (B.2.2): 2–16-bit precision, 1–4 components, each sampled 1×1. +//! - Huffman tables (B.2.4.2, Annex C) decoded with a 9-bit lookup and the F.2.2.3 code-length +//! walk for longer codes. +//! - Scan header (B.2.3): one scan holding every component, predictor `Ss` 1–7 (Table H.1) and +//! point transform `Al`. +//! - Prediction (H.1.2.1): the first line (and the first line after a restart) predicts from the +//! left neighbour with 2^(P − Pt − 1) for its first sample; later lines predict their first +//! sample from the one above and the rest with the selected predictor. Differences are added +//! modulo 2^16 (H.1.2.2) and the SSSS = 16 category is 32768 with no extra bits. +//! - Restart intervals (DRI, RSTn) that span whole lines. + +use crate::{Error, Result}; + +/// A decoded lossless JPEG image. +#[derive(Debug)] +pub struct Decoded { + pub width: usize, + pub height: usize, + pub components: usize, + pub precision: u8, + pub predictor: u8, + /// Samples, interleaved by component, line after line, shifted back up by the point transform. + pub samples: Vec, +} + +const LOOKUP_BITS: u32 = 9; + +/// A Huffman table for decoding (T.81 Annex C and F.2.2.3). +#[derive(Clone)] +struct Huffman { + /// For codes of up to LOOKUP_BITS bits: (length, value) indexed by the next LOOKUP_BITS bits; + /// length 0 means "longer code". + fast: Vec<(u8, u8)>, + maxcode: [i32; 17], + valptr: [i32; 17], + mincode: [i32; 17], + values: Vec, +} + +impl Huffman { + fn new(bits: &[u8; 16], values: Vec) -> Result { + // C.2: code sizes and codes + let mut sizes = Vec::new(); + for (l, &n) in bits.iter().enumerate() { + for _ in 0..n { + sizes.push(l as u8 + 1); + } + } + if sizes.len() != values.len() || sizes.is_empty() { + return Err(Error::Malformed("lossless JPEG: Huffman table counts")); + } + let mut codes = Vec::with_capacity(sizes.len()); + let mut code: u32 = 0; + let mut si = sizes[0]; + let mut k = 0; + while k < sizes.len() { + while k < sizes.len() && sizes[k] == si { + codes.push(code); + code += 1; + k += 1; + } + // the codes of one length must fit in that length + if code > (1u32 << si) { + return Err(Error::Malformed("lossless JPEG: Huffman table over-subscribed")); + } + code <<= 1; + si += 1; + } + // F.2.2.3 decoder tables + let mut maxcode = [-1i32; 17]; + let mut valptr = [0i32; 17]; + let mut mincode = [0i32; 17]; + let mut j = 0usize; + for l in 1..=16usize { + let n = usize::from(bits[l - 1]); + if n == 0 { + maxcode[l] = -1; + } else { + valptr[l] = j as i32; + mincode[l] = codes[j] as i32; + j += n; + maxcode[l] = codes[j - 1] as i32; + } + } + let mut fast = vec![(0u8, 0u8); 1 << LOOKUP_BITS]; + for (i, (&c, &s)) in codes.iter().zip(&sizes).enumerate() { + let s = u32::from(s); + if s <= LOOKUP_BITS { + let shift = LOOKUP_BITS - s; + let base = (c << shift) as usize; + for f in fast.iter_mut().skip(base).take(1 << shift) { + *f = (s as u8, values[i]); + } + } + } + Ok(Huffman { fast, maxcode, valptr, mincode, values }) + } +} + +/// Entropy-coded data reader: removes stuffed zero bytes and stops at markers (feeding zeros). +struct Bits<'a> { + data: &'a [u8], + pos: usize, + acc: u64, + n: u32, + /// The marker that ended the data (after 0xFF), if one was met. + marker: Option, +} + +impl<'a> Bits<'a> { + fn new(data: &'a [u8], pos: usize) -> Self { + Bits { data, pos, acc: 0, n: 0, marker: None } + } + + fn fill(&mut self) { + while self.n <= 56 { + let mut byte = 0u8; + if self.marker.is_none() { + match self.data.get(self.pos) { + Some(&0xFF) => match self.data.get(self.pos + 1) { + Some(&0x00) => { + byte = 0xFF; + self.pos += 2; + } + Some(&m) => self.marker = Some(m), + None => self.marker = Some(0xD9), + }, + Some(&b) => { + byte = b; + self.pos += 1; + } + None => self.marker = Some(0xD9), + } + } + self.acc |= u64::from(byte) << (56 - self.n); + self.n += 8; + } + } + + fn peek(&mut self, k: u32) -> u32 { + if self.n < k { + self.fill(); + } + (self.acc >> (64 - k)) as u32 + } + + fn skip(&mut self, k: u32) { + self.acc <<= k; + self.n -= k; + } + + fn bits(&mut self, k: u32) -> u32 { + if k == 0 { + return 0; + } + let v = self.peek(k); + self.skip(k); + v + } + + /// Decode one Huffman symbol (F.2.2.3). + fn decode(&mut self, h: &Huffman) -> Result { + let p = self.peek(LOOKUP_BITS) as usize; + if let Some(&(len, v)) = h.fast.get(p) + && len > 0 + { + self.skip(u32::from(len)); + return Ok(v); + } + let mut code = self.bits(1) as i32; + for l in 1..=16usize { + if code <= h.maxcode[l] { + let i = h.valptr[l] + code - h.mincode[l]; + return h.values.get(i as usize).copied().ok_or(Error::Malformed("lossless JPEG: bad Huffman code")); + } + code = (code << 1) | self.bits(1) as i32; + } + Err(Error::Malformed("lossless JPEG: bad Huffman code")) + } + + /// Skip to the byte after the next RSTn marker, starting a new bit stream there. + fn restart(&mut self) -> Result<()> { + // the decoder may have looked ahead into fill bits; find the marker in the bytes + let mut p = self.pos; + loop { + match (self.data.get(p), self.data.get(p + 1)) { + (Some(0xFF), Some(&m)) if (0xD0..=0xD7).contains(&m) => { + *self = Bits::new(self.data, p + 2); + return Ok(()); + } + (Some(_), Some(_)) => p += 1, + _ => return Err(Error::Malformed("lossless JPEG: missing restart marker")), + } + } + } +} + +struct Component { + id: u8, + table: usize, +} + +/// Decode a lossless JPEG stream. `max_samples` caps the picture (width × height × components). +pub fn decode(data: &[u8], max_samples: usize) -> Result { + if data.get(0..2) != Some(&[0xFF, 0xD8]) { + return Err(Error::Malformed("lossless JPEG: no SOI marker")); + } + let mut pos = 2usize; + let mut tables: [Option; 4] = [None, None, None, None]; + let mut frame: Option<(u8, usize, usize, Vec)> = None; + let mut restart_interval = 0usize; + loop { + // markers may be preceded by fill bytes 0xFF + while data.get(pos) == Some(&0xFF) && data.get(pos + 1) == Some(&0xFF) { + pos += 1; + } + let (Some(&0xFF), Some(&m)) = (data.get(pos), data.get(pos + 1)) else { + return Err(Error::Malformed("lossless JPEG: expected a marker")); + }; + pos += 2; + if m == 0xD9 { + return Err(Error::Malformed("lossless JPEG: no scan")); + } + if (0xD0..=0xD8).contains(&m) || m == 0x01 { + continue; // markers without a segment + } + let len_b = data.get(pos..pos + 2).ok_or(Error::Truncated)?; + let len = usize::from(u16::from_be_bytes([len_b[0], len_b[1]])); + if len < 2 { + return Err(Error::Malformed("lossless JPEG: segment length")); + } + let seg = data.get(pos + 2..pos + len).ok_or(Error::Truncated)?; + match m { + 0xC3 => { + let [p, y0, y1, x0, x1, nf, rest @ ..] = seg else { + return Err(Error::Malformed("lossless JPEG: short frame header")); + }; + let (precision, nf) = (*p, usize::from(*nf)); + let height = usize::from(u16::from_be_bytes([*y0, *y1])); + let width = usize::from(u16::from_be_bytes([*x0, *x1])); + if !(2..=16).contains(&precision) { + return Err(Error::Unsupported(format!("lossless JPEG precision {precision}"))); + } + if height == 0 { + return Err(Error::Unsupported("lossless JPEG with the height in a DNL marker".into())); + } + if width == 0 || !(1..=4).contains(&nf) || rest.len() < nf * 3 { + return Err(Error::Malformed("lossless JPEG: frame header")); + } + let mut comps = Vec::with_capacity(nf); + for c in rest.as_chunks::<3>().0.iter().take(nf) { + if c[1] != 0x11 { + return Err(Error::Unsupported("subsampled lossless JPEG components".into())); + } + comps.push(Component { id: c[0], table: 0 }); + } + frame = Some((precision, width, height, comps)); + } + 0xC4 => { + let mut s = seg; + while !s.is_empty() { + let [tcth, counts @ ..] = s else { break }; + let bits: [u8; 16] = counts.get(..16).and_then(|b| b.try_into().ok()).ok_or(Error::Truncated)?; + let n: usize = bits.iter().map(|&b| usize::from(b)).sum(); + let values = counts.get(16..16 + n).ok_or(Error::Truncated)?.to_vec(); + let th = usize::from(tcth & 0x0F); + if tcth >> 4 != 0 || th > 3 { + return Err(Error::Malformed("lossless JPEG: Huffman table class or id")); + } + if values.iter().any(|&v| v > 16) { + return Err(Error::Malformed("lossless JPEG: difference category above 16")); + } + tables[th] = Some(Huffman::new(&bits, values)?); + s = counts.get(16 + n..).unwrap_or(&[]); + } + } + 0xDD => { + let r = seg.get(0..2).ok_or(Error::Truncated)?; + restart_interval = usize::from(u16::from_be_bytes([r[0], r[1]])); + } + 0xDA => { + let Some((precision, width, height, mut comps)) = frame.take() else { + return Err(Error::Malformed("lossless JPEG: scan before the frame header")); + }; + let ns = usize::from(*seg.first().ok_or(Error::Truncated)?); + if ns != comps.len() { + return Err(Error::Unsupported("lossless JPEG with more than one scan".into())); + } + for k in 0..ns { + let c = seg.get(1 + 2 * k..3 + 2 * k).ok_or(Error::Truncated)?; + let comp = comps.iter_mut().find(|x| x.id == c[0]).ok_or(Error::Malformed("lossless JPEG: unknown scan component"))?; + comp.table = usize::from(c[1] >> 4); + if comp.table > 3 { + return Err(Error::Malformed("lossless JPEG: Huffman table id")); + } + } + let tail = seg.get(1 + 2 * ns..4 + 2 * ns).ok_or(Error::Truncated)?; + let (predictor, pt) = (tail[0], tail[2] & 0x0F); + if !(1..=7).contains(&predictor) { + return Err(Error::Unsupported(format!("lossless JPEG predictor {predictor}"))); + } + if pt >= precision { + return Err(Error::Malformed("lossless JPEG: point transform")); + } + let n = width.checked_mul(height).and_then(|v| v.checked_mul(comps.len())).ok_or(Error::TooLarge)?; + if n > max_samples { + return Err(Error::TooLarge); + } + let mut hufs = Vec::with_capacity(comps.len()); + for c in &comps { + hufs.push(tables.get(c.table).and_then(|t| t.as_ref()).ok_or(Error::Malformed("lossless JPEG: missing Huffman table"))?); + } + let samples = scan(data, pos + len, &hufs, width, height, precision, predictor, pt, restart_interval)?; + return Ok(Decoded { width, height, components: comps.len(), precision, predictor, samples }); + } + 0xC0..=0xC2 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => { + return Err(Error::Unsupported("lossy (DCT) JPEG in a DNG".into())); + } + _ => {} // APPn, COM, DQT … are skipped + } + pos += len; + } +} + +/// Decode the entropy-coded data of one scan (H.1.2). +fn scan(data: &[u8], start: usize, hufs: &[&Huffman], width: usize, height: usize, precision: u8, predictor: u8, pt: u8, restart: usize) -> Result> { + let nc = hufs.len(); + let row_len = width * nc; + if restart != 0 && !restart.is_multiple_of(width) { + return Err(Error::Unsupported("lossless JPEG restart interval that splits a line".into())); + } + let lines_per_interval = if restart == 0 { usize::MAX } else { restart / width }; + let mut out = vec![0u16; row_len * height]; + let mut bits = Bits::new(data, start); + let initial = 1i32 << (precision - pt - 1); + let mut line_in_interval = 0usize; + for y in 0..height { + if line_in_interval == lines_per_interval { + bits.restart()?; + line_in_interval = 0; + } + let first_line = line_in_interval == 0; + let (above, cur) = if y == 0 { + (None, &mut out[..row_len]) + } else { + let (a, c) = out.split_at_mut(y * row_len); + (Some(&a[(y - 1) * row_len..]), &mut c[..row_len]) + }; + for x in 0..width { + for (c, h) in hufs.iter().enumerate() { + let i = x * nc + c; + let ssss = u32::from(bits.decode(h)?); + let diff = match ssss { + 0 => 0, + 16 => 32768, + s => { + let v = bits.bits(s) as i32; + if v < (1 << (s - 1)) { v - (1 << s) + 1 } else { v } + } + }; + let px = match above { + _ if first_line && x == 0 => initial, + _ if first_line => i32::from(cur[i - nc]), + Some(a) if x == 0 => i32::from(a[i]), + Some(a) => predict(predictor, i32::from(cur[i - nc]), i32::from(a[i]), i32::from(a[i - nc])), + None => initial, + }; + cur[i] = ((px + diff) & 0xFFFF) as u16; + } + } + line_in_interval += 1; + } + if pt > 0 { + for s in &mut out { + *s <<= pt; + } + } + Ok(out) +} + +/// Table H.1 predictors (`ra` left, `rb` above, `rc` above left). +pub fn predict(p: u8, ra: i32, rb: i32, rc: i32) -> i32 { + match p { + 1 => ra, + 2 => rb, + 3 => rc, + 4 => ra + rb - rc, + 5 => ra + ((rb - rc) >> 1), + 6 => rb + ((ra - rc) >> 1), + _ => (ra + rb) >> 1, + } +} diff --git a/crates/dng/src/process.rs b/crates/dng/src/process.rs new file mode 100644 index 000000000..41d9b25ae --- /dev/null +++ b/crates/dng/src/process.rs @@ -0,0 +1,264 @@ +//! Raw samples to a linear Rec. 709 picture, in the order of the DNG specification's chapter 5 +//! ("Mapping Raw Values to Linear Reference Values") and chapter 6 (colour): +//! +//! 1. ActiveArea: only the active (unmasked) sensor area is used; black levels, the CFA pattern +//! and the default crop are relative to its top left corner. +//! 2. LinearizationTable maps each sample. +//! 3. The black level (BlackLevel over its BlackLevelRepeatDim pattern, plus BlackLevelDeltaH for +//! the column and BlackLevelDeltaV for the row) is subtracted and the range up to WhiteLevel +//! scaled to 0–1 (clipped there). +//! 4. White balance: each sample is divided by the camera neutral of its colour and clipped at +//! 1, so highlights the sensor clipped stay neutral. +//! 5. A CFA is demosaiced ([`crate::demosaic`]). +//! 6. DefaultCropOrigin / DefaultCropSize cut the picture. +//! 7. The colour matrix of [`crate::color`] takes the white-balanced camera values to linear +//! Rec. 709 (including BaselineExposure); negative results are clipped to 0. + +#[cfg(feature = "threads")] +use rayon::prelude::*; + +use crate::color::{M3, V3}; +use crate::demosaic::{Pattern, demosaic}; +use crate::raw::RawImage; +use crate::{Error, Result}; + +/// The processing tags of a raw image. +#[derive(Clone, Debug, Default)] +pub struct Params { + /// top, left, bottom, right + pub active_area: Option<[usize; 4]>, + pub linearization: Option>, + /// rows, columns of the BlackLevel pattern + pub black_repeat: (usize, usize), + /// rows × columns × samples per pixel values + pub black: Vec, + pub black_delta_h: Vec, + pub black_delta_v: Vec, + /// per sample + pub white: Vec, + pub crop_origin: Option<(f64, f64)>, + pub crop_size: Option<(f64, f64)>, + /// CFA pattern in camera planes (`None`: LinearRaw). + pub cfa: Option, +} + +/// A rectangle: x, y, width, height. +type Rect = (usize, usize, usize, usize); + +/// The active area of a `w` × `h` raw image. +pub fn active_rect(p: &Params, w: usize, h: usize) -> Result { + match p.active_area { + None => Ok((0, 0, w, h)), + Some([top, left, bottom, right]) => { + if top >= bottom || left >= right || bottom > h || right > w { + return Err(Error::Malformed("ActiveArea outside the image")); + } + Ok((left, top, right - left, bottom - top)) + } + } +} + +/// The default crop inside an active area of `aw` × `ah`. +pub fn crop_rect(p: &Params, aw: usize, ah: usize) -> Rect { + let (ox, oy) = p.crop_origin.unwrap_or((0.0, 0.0)); + let (sw, sh) = p.crop_size.unwrap_or((aw as f64, ah as f64)); + let to = |v: f64, max: usize| if v.is_finite() && v > 0.0 { (v.min(max as f64)) as usize } else { 0 }; + let x = to(ox.floor(), aw.saturating_sub(1)); + let y = to(oy.floor(), ah.saturating_sub(1)); + let w = to(sw.round(), aw - x).max(1); + let h = to(sh.round(), ah - y).max(1); + (x, y, w, h) +} + +/// Normalised, white-balanced samples of the active area (steps 1–4), one plane per sample. +fn normalise(raw: &RawImage, p: &Params, (ax, ay, aw, ah): Rect, neutral: &V3, planes: usize) -> Result> { + let spp = raw.samples_per_pixel; + let (br, bc) = (p.black_repeat.0.max(1), p.black_repeat.1.max(1)); + if br > 64 || bc > 64 { + return Err(Error::Unsupported("a BlackLevelRepeatDim larger than 64".into())); + } + let need = br * bc * spp; + let black: Vec = match p.black.len() { + 0 => vec![0.0; need], + 1 => vec![p.black[0]; need], + n if n >= need => p.black[..need].to_vec(), + _ => return Err(Error::Malformed("BlackLevel has too few values")), + }; + let max_code = ((1u32 << raw.bits) - 1) as f64; + let white: Vec = (0..spp).map(|s| p.white.get(s).or(p.white.first()).copied().unwrap_or(max_code)).collect(); + let lin = p.linearization.as_deref().filter(|t| !t.is_empty()); + let inv_neutral: Vec = (0..planes).map(|c| (1.0 / neutral.get(c).copied().unwrap_or(1.0)) as f32).collect(); + let cfa = p.cfa.as_ref(); + let mut out = vec![0f32; aw * ah * spp]; + let row = |y: usize, o: &mut [f32]| { + let dv = p.black_delta_v.get(y).copied().unwrap_or(0.0); + let src_row = (ay + y) * raw.width * spp; + for x in 0..aw { + let dh = p.black_delta_h.get(x).copied().unwrap_or(0.0); + for s in 0..spp { + let code = raw.data.get(src_row + (ax + x) * spp + s).copied().unwrap_or(0); + let v = match lin { + Some(t) => f64::from(t.get(usize::from(code)).or(t.last()).copied().unwrap_or(0)), + None => f64::from(code), + }; + let b = black[((y % br) * bc + x % bc) * spp + s] + dh + dv; + let range = white[s] - b; + let n = if range > 0.0 { ((v - b) / range).clamp(0.0, 1.0) } else { 0.0 }; + let plane = match cfa { + Some(c) => usize::from(c.color(x, y)), + None => s, + }; + let wb = inv_neutral.get(plane).copied().unwrap_or(1.0); + if let Some(o) = o.get_mut(x * spp + s) { + *o = (n as f32 * wb).min(1.0); + } + } + } + }; + #[cfg(feature = "threads")] + out.par_chunks_mut((aw * spp).max(1)).enumerate().for_each(|(y, o)| row(y, o)); + #[cfg(not(feature = "threads"))] + out.chunks_mut((aw * spp).max(1)).enumerate().for_each(|(y, o)| row(y, o)); + Ok(out) +} + +/// A developed picture: premultiplied linear Rec. 709 RGBA floats (alpha 1). +pub struct Developed { + pub width: usize, + pub height: usize, + pub rgba: Vec, +} + +/// Develop `raw` (steps 1–7). `neutral` and `matrix` come from [`crate::color::Calibration::solve`]. +pub fn develop(raw: &RawImage, p: &Params, neutral: &V3, matrix: &M3) -> Result { + let spp = raw.samples_per_pixel; + if p.cfa.is_some() && spp != 1 { + return Err(Error::Malformed("a CFA image with more than one sample per pixel")); + } + if p.cfa.is_none() && !matches!(spp, 1 | 3) { + return Err(Error::Unsupported(format!("LinearRaw with {spp} samples per pixel"))); + } + let active = active_rect(p, raw.width, raw.height)?; + let (_, _, aw, ah) = active; + let planes = if p.cfa.is_some() { 3 } else { spp }; + let norm = normalise(raw, p, active, neutral, planes)?; + // three samples per pixel + let rgb = match (&p.cfa, spp) { + (Some(pat), _) => demosaic(&norm, aw, ah, pat), + (None, 3) => norm, + _ => norm.iter().flat_map(|&v| [v, v, v]).collect(), + }; + let (cx, cy, cw, ch) = crop_rect(p, aw, ah); + let m: [[f32; 3]; 3] = matrix.map(|r| r.map(|v| v as f32)); + let mono = p.cfa.is_none() && spp == 1; + let mut rgba = vec![0f32; cw * ch * 4]; + let row = |y: usize, o: &mut [f32]| { + let src = (cy + y) * aw + cx; + for x in 0..cw { + let Some(c) = rgb.get((src + x) * 3..(src + x) * 3 + 3) else { continue }; + let px = if mono { + // grey: the matrix's luminance gain only + let g = c[0] * (m[1][0] + m[1][1] + m[1][2]); + [g, g, g] + } else { + [0, 1, 2].map(|i| m[i][0] * c[0] + m[i][1] * c[1] + m[i][2] * c[2]) + }; + if let Some(d) = o.get_mut(x * 4..x * 4 + 4) { + d.copy_from_slice(&[px[0].max(0.0), px[1].max(0.0), px[2].max(0.0), 1.0]); + } + } + }; + #[cfg(feature = "threads")] + rgba.par_chunks_mut(cw * 4).enumerate().for_each(|(y, o)| row(y, o)); + #[cfg(not(feature = "threads"))] + rgba.chunks_mut(cw * 4).enumerate().for_each(|(y, o)| row(y, o)); + Ok(Developed { width: cw, height: ch, rgba }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::color::IDENTITY; + + fn raw(w: usize, h: usize, spp: usize, data: Vec) -> RawImage { + RawImage { width: w, height: h, samples_per_pixel: spp, bits: 12, data } + } + + #[test] + fn black_white_linearisation_and_white_balance() { + // LinearRaw 2 × 1: black 100, white 1100; neutral (0.5, 1, 0.25) + let r = raw(2, 1, 3, vec![600, 600, 600, 100, 1100, 2000]); + let p = Params { black: vec![100.0], white: vec![1100.0], ..Default::default() }; + let d = develop(&r, &p, &[0.5, 1.0, 0.25], &IDENTITY).unwrap(); + assert_eq!((d.width, d.height), (2, 1)); + // 0.5 / 0.5 = 1, 0.5 / 1, 0.5 / 0.25 = 2 → clipped to 1 + assert_eq!(&d.rgba[..4], &[1.0, 0.5, 1.0, 1.0]); + // below black → 0, at white → 1, above white → 1 + assert_eq!(&d.rgba[4..], &[0.0, 1.0, 1.0, 1.0]); + // a linearisation table doubling the codes + let t: Vec = (0..4096u32).map(|v| (v * 2).min(65_535) as u16).collect(); + let p = Params { linearization: Some(t), black: vec![200.0], white: vec![2200.0], ..p }; + let d = develop(&r, &p, &[1.0; 3], &IDENTITY).unwrap(); + assert_eq!(&d.rgba[..3], &[0.5, 0.5, 0.5]); + } + + #[test] + fn per_cfa_black_levels_and_deltas() { + // 2 × 2 CFA, black repeat 2 × 2 with a different black per site, plus row/column deltas + let pat = Pattern { rows: 2, cols: 2, colors: vec![0, 1, 1, 2] }; + let p = Params { + black_repeat: (2, 2), + black: vec![10.0, 20.0, 30.0, 40.0], + black_delta_h: vec![0.0, 5.0], + black_delta_v: vec![0.0, 1.0], + white: vec![1010.0], + cfa: Some(pat), + ..Default::default() + }; + let r = raw(2, 2, 1, vec![510, 525, 531, 546]); + let n = normalise(&r, &p, (0, 0, 2, 2), &[1.0; 3], 3).unwrap(); + // (v - black) / (white - black) for black 10, 25, 31, 46 + let want = [500.0 / 1000.0, 500.0 / 985.0, 500.0 / 979.0, 500.0 / 964.0]; + for (a, b) in n.iter().zip(want) { + assert!((a - b).abs() < 1e-6, "{a} vs {b}"); + } + } + + #[test] + fn active_area_and_default_crop() { + // 6 × 4 LinearRaw grey ramp; active area cuts a 1-pixel border, crop takes 2 × 1 at (1, 1) + let data: Vec = (0..24u16).flat_map(|i| [i * 10; 3]).collect(); + let r = raw(6, 4, 3, data); + let p = + Params { active_area: Some([1, 1, 3, 5]), white: vec![4095.0], crop_origin: Some((1.0, 1.0)), crop_size: Some((2.0, 1.0)), ..Default::default() }; + let d = develop(&r, &p, &[1.0; 3], &IDENTITY).unwrap(); + assert_eq!((d.width, d.height), (2, 1)); + // active (1,1)–(5,3); crop origin (1,1) inside it → raw (2, 2) and (3, 2): samples 14, 15 + assert!((d.rgba[0] - 140.0 / 4095.0).abs() < 1e-6); + assert!((d.rgba[4] - 150.0 / 4095.0).abs() < 1e-6); + // hostile active areas and crops + let bad = Params { active_area: Some([3, 0, 1, 6]), ..Default::default() }; + assert!(develop(&r, &bad, &[1.0; 3], &IDENTITY).is_err()); + let bad = Params { active_area: Some([0, 0, 4, 7]), ..Default::default() }; + assert!(develop(&r, &bad, &[1.0; 3], &IDENTITY).is_err()); + let huge = Params { crop_origin: Some((1e12, -5.0)), crop_size: Some((f64::NAN, 1e9)), white: vec![4095.0], ..Default::default() }; + let d = develop(&r, &huge, &[1.0; 3], &IDENTITY).unwrap(); + assert!(d.width >= 1 && d.width <= 6 && d.height >= 1 && d.height <= 4); + } + + #[test] + fn matrix_and_clipping_of_negatives() { + let r = raw(1, 1, 3, vec![4095, 0, 0]); + let p = Params { white: vec![4095.0], ..Default::default() }; + let m = [[1.0, 0.0, 0.0], [-0.5, 1.0, 0.0], [0.0, 0.0, 2.0]]; + let d = develop(&r, &p, &[1.0; 3], &m).unwrap(); + assert_eq!(&d.rgba, &[1.0, 0.0, 0.0, 1.0]); + } + + #[test] + fn short_black_level_is_an_error() { + let r = raw(2, 2, 1, vec![0; 4]); + let p = Params { black_repeat: (2, 2), black: vec![1.0, 2.0], cfa: Some(Pattern { rows: 2, cols: 2, colors: vec![0, 1, 1, 2] }), ..Default::default() }; + assert!(develop(&r, &p, &[1.0; 3], &IDENTITY).is_err()); + } +} diff --git a/crates/dng/src/raw.rs b/crates/dng/src/raw.rs new file mode 100644 index 000000000..b1c448ffc --- /dev/null +++ b/crates/dng/src/raw.rs @@ -0,0 +1,254 @@ +//! The raw image data of a DNG: finding the main raw IFD, and reading its strips or tiles +//! (uncompressed or lossless JPEG) into one array of 16-bit samples. + +#[cfg(feature = "threads")] +use rayon::prelude::*; + +use crate::tiff::{Ifd, Tiff, tags}; +use crate::{Error, Result, ljpeg}; + +/// Largest raw image decoded, in samples (width × height × samples per pixel). +pub const MAX_SAMPLES: usize = 1 << 27; + +/// Photometric interpretations of DNG raw data. +pub const PHOTOMETRIC_CFA: u64 = 32803; +pub const PHOTOMETRIC_LINEAR_RAW: u64 = 34892; + +/// The layout of a raw IFD's image data. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RawLayout { + pub width: usize, + pub height: usize, + pub samples_per_pixel: usize, + pub bits: u32, + /// 1 uncompressed, 7 lossless JPEG. + pub compression: u64, + pub photometric: u64, +} + +/// Raw samples: `width` × `height` pixels of `samples_per_pixel` interleaved 16-bit samples. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RawImage { + pub width: usize, + pub height: usize, + pub samples_per_pixel: usize, + pub bits: u32, + pub data: Vec, +} + +fn uint_or(t: &Tiff, ifd: &Ifd, tag: u16, default: u64) -> Result { + match ifd.get(tag) { + Some(e) => t.uint(e), + None => Ok(default), + } +} + +/// The main raw image IFD: the largest full-resolution (NewSubFileType 0) IFD whose photometric +/// interpretation is CFA or LinearRaw. +pub fn find_raw_ifd<'i>(t: &Tiff, ifds: &'i [Ifd]) -> Result<&'i Ifd> { + let mut best: Option<(&Ifd, u64)> = None; + for ifd in ifds { + let Ok(kind) = uint_or(t, ifd, tags::NEW_SUBFILE_TYPE, 0) else { continue }; + let Ok(photometric) = uint_or(t, ifd, tags::PHOTOMETRIC, 0) else { continue }; + if kind != 0 || !matches!(photometric, PHOTOMETRIC_CFA | PHOTOMETRIC_LINEAR_RAW) { + continue; + } + let w = uint_or(t, ifd, tags::IMAGE_WIDTH, 0).unwrap_or(0); + let h = uint_or(t, ifd, tags::IMAGE_LENGTH, 0).unwrap_or(0); + let area = w.saturating_mul(h); + if best.is_none_or(|(_, a)| area > a) { + best = Some((ifd, area)); + } + } + best.map(|(i, _)| i).ok_or(Error::Unsupported("no raw image in this DNG".into())) +} + +/// Read and check the layout tags of a raw IFD. +pub fn layout(t: &Tiff, ifd: &Ifd) -> Result { + let width = uint_or(t, ifd, tags::IMAGE_WIDTH, 0)?; + let height = uint_or(t, ifd, tags::IMAGE_LENGTH, 0)?; + let spp = uint_or(t, ifd, tags::SAMPLES_PER_PIXEL, 1)?; + let photometric = uint_or(t, ifd, tags::PHOTOMETRIC, 0)?; + let compression = uint_or(t, ifd, tags::COMPRESSION, 1)?; + let bits = match ifd.get(tags::BITS_PER_SAMPLE) { + Some(e) => { + let b = t.uints(e)?; + let first = *b.first().ok_or(Error::Malformed("empty BitsPerSample"))?; + if b.iter().any(|&x| x != first) { + return Err(Error::Unsupported("different bit depths per sample".into())); + } + first + } + None => 1, + }; + if width == 0 || height == 0 || width > 65_535 || height > 65_535 { + return Err(Error::Malformed("raw image size")); + } + if !(1..=4).contains(&spp) { + return Err(Error::Unsupported(format!("{spp} samples per pixel"))); + } + if photometric == PHOTOMETRIC_CFA && spp != 1 { + return Err(Error::Malformed("a CFA image with more than one sample per pixel")); + } + if !(1..=16).contains(&bits) { + return Err(Error::Unsupported(format!("{bits}-bit raw samples"))); + } + if uint_or(t, ifd, tags::SAMPLE_FORMAT, 1)? != 1 { + return Err(Error::Unsupported("floating-point raw data".into())); + } + if spp > 1 && uint_or(t, ifd, tags::PLANAR_CONFIGURATION, 1)? != 1 { + return Err(Error::Unsupported("planar raw data".into())); + } + match compression { + 1 => { + if uint_or(t, ifd, tags::PREDICTOR, 1)? != 1 { + return Err(Error::Unsupported("a TIFF predictor on uncompressed raw data".into())); + } + } + 7 => {} + 8 => return Err(Error::Unsupported("deflate-compressed DNG".into())), + 34892 => return Err(Error::Unsupported("lossy JPEG DNG".into())), + 52546 => return Err(Error::Unsupported("JPEG XL DNG".into())), + c => return Err(Error::Unsupported(format!("DNG compression {c}"))), + } + Ok(RawLayout { width: width as usize, height: height as usize, samples_per_pixel: spp as usize, bits: bits as u32, compression, photometric }) +} + +/// One strip or tile: where it sits in the image and its bytes. +struct Chunk<'a> { + x: usize, + y: usize, + /// Pixel size of the chunk as coded (tiles are whole even at the image edge). + w: usize, + h: usize, + bytes: &'a [u8], +} + +fn chunks<'a>(t: &Tiff<'a>, ifd: &Ifd, l: &RawLayout) -> Result>> { + let (offs, counts, tw, th) = if let (Some(o), Some(c)) = (ifd.get(tags::TILE_OFFSETS), ifd.get(tags::TILE_BYTE_COUNTS)) { + let tw = uint_or(t, ifd, tags::TILE_WIDTH, 0)? as usize; + let th = uint_or(t, ifd, tags::TILE_LENGTH, 0)? as usize; + if tw == 0 || th == 0 || tw > 65_535 || th > 65_535 { + return Err(Error::Malformed("tile size")); + } + (t.uints(o)?, t.uints(c)?, tw, th) + } else if let (Some(o), Some(c)) = (ifd.get(tags::STRIP_OFFSETS), ifd.get(tags::STRIP_BYTE_COUNTS)) { + let rows = (uint_or(t, ifd, tags::ROWS_PER_STRIP, l.height as u64)? as usize).clamp(1, l.height); + (t.uints(o)?, t.uints(c)?, l.width, rows) + } else { + return Err(Error::Malformed("no strips or tiles")); + }; + let across = l.width.div_ceil(tw); + let down = l.height.div_ceil(th); + let n = across * down; + if offs.len() < n || counts.len() < n { + return Err(Error::Malformed("fewer strips or tiles than the image needs")); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let (x, y) = ((i % across) * tw, (i / across) * th); + let start = usize::try_from(offs[i]).map_err(|_| Error::Truncated)?; + let len = usize::try_from(counts[i]).map_err(|_| Error::Truncated)?; + let end = start.checked_add(len).ok_or(Error::Truncated)?; + let bytes = t.data.get(start..end).ok_or(Error::Truncated)?; + // strips end at the image; tiles are coded whole + let h = if ifd.get(tags::TILE_OFFSETS).is_some() { th } else { th.min(l.height - y) }; + out.push(Chunk { x, y, w: tw, h, bytes }); + } + Ok(out) +} + +/// Unpack `w` × `h` × `spp` uncompressed samples of `bits` bits: 8 and 16-bit samples as bytes / +/// words in the file's byte order, other depths packed most significant bit first with each row +/// starting on a byte boundary (TIFF 6.0 §7, DNG BitsPerSample). +pub fn unpack(bytes: &[u8], w: usize, h: usize, spp: usize, bits: u32, big_endian: bool) -> Result> { + let per_row = w * spp; + let row_bytes = (per_row * bits as usize).div_ceil(8); + let need = row_bytes.checked_mul(h).ok_or(Error::TooLarge)?; + if bytes.len() < need { + return Err(Error::Truncated); + } + let mut out = Vec::with_capacity(per_row * h); + for row in bytes.chunks_exact(row_bytes.max(1)).take(h) { + match bits { + 8 => out.extend(row.iter().map(|&b| u16::from(b))), + 16 => out.extend(row.as_chunks::<2>().0.iter().map(|&b| if big_endian { u16::from_be_bytes(b) } else { u16::from_le_bytes(b) })), + _ => { + let mut acc: u32 = 0; + let mut n: u32 = 0; + let mut it = row.iter(); + for _ in 0..per_row { + while n < bits { + acc = (acc << 8) | u32::from(*it.next().ok_or(Error::Truncated)?); + n += 8; + } + n -= bits; + out.push(((acc >> n) & ((1 << bits) - 1)) as u16); + acc &= (1 << n) - 1; + } + } + } + } + Ok(out) +} + +/// Decode one chunk into `w` × `h` × `spp` samples. +fn decode_chunk(c: &Chunk, l: &RawLayout, big_endian: bool) -> Result> { + let need = c.w * c.h * l.samples_per_pixel; + match l.compression { + 1 => unpack(c.bytes, c.w, c.h, l.samples_per_pixel, l.bits, big_endian), + _ => { + // the JPEG's samples fill the chunk in raster order (its own width × components may + // differ from the chunk's, e.g. two components of half the width) + let d = ljpeg::decode(c.bytes, need.saturating_add(65_536 * 4))?; + if d.samples.len() < need { + return Err(Error::Malformed("lossless JPEG smaller than its tile or strip")); + } + let mut s = d.samples; + s.truncate(need); + Ok(s) + } + } +} + +/// Read the raw image of `ifd`. +pub fn read(t: &Tiff, ifd: &Ifd) -> Result { + let l = layout(t, ifd)?; + let spp = l.samples_per_pixel; + let total = l.width.checked_mul(l.height).and_then(|v| v.checked_mul(spp)).ok_or(Error::TooLarge)?; + if total > MAX_SAMPLES { + return Err(Error::TooLarge); + } + let chunks = chunks(t, ifd, &l)?; + // every sample takes at least one bit of file (a Huffman code is at least one bit long) + let coded: usize = chunks.iter().map(|c| c.bytes.len()).fold(0usize, usize::saturating_add); + if total / 8 > coded { + return Err(Error::Truncated); + } + for c in &chunks { + let n = c.w.checked_mul(c.h).and_then(|v| v.checked_mul(spp)).ok_or(Error::TooLarge)?; + if n > MAX_SAMPLES { + return Err(Error::TooLarge); + } + if n / 8 > c.bytes.len() { + return Err(Error::Truncated); + } + } + #[cfg(feature = "threads")] + let decoded: Vec>> = chunks.par_iter().map(|c| decode_chunk(c, &l, t.big_endian)).collect(); + #[cfg(not(feature = "threads"))] + let decoded: Vec>> = chunks.iter().map(|c| decode_chunk(c, &l, t.big_endian)).collect(); + let mut data = vec![0u16; total]; + let row_len = l.width * spp; + for (c, d) in chunks.iter().zip(decoded) { + let d = d?; + let cw = c.w.min(l.width - c.x); + let ch = c.h.min(l.height - c.y); + for r in 0..ch { + let src = d.get(r * c.w * spp..(r * c.w + cw) * spp).ok_or(Error::Truncated)?; + let at = (c.y + r) * row_len + c.x * spp; + data.get_mut(at..at + cw * spp).ok_or(Error::Truncated)?.copy_from_slice(src); + } + } + Ok(RawImage { width: l.width, height: l.height, samples_per_pixel: spp, bits: l.bits, data }) +} diff --git a/crates/dng/src/tiff.rs b/crates/dng/src/tiff.rs new file mode 100644 index 000000000..ba479649c --- /dev/null +++ b/crates/dng/src/tiff.rs @@ -0,0 +1,334 @@ +//! The TIFF container (TIFF 6.0 §2, as DNG uses it): byte order, image file directories (IFDs), +//! their entries and values, and the SubIFD tree. +//! +//! Every offset and count comes from the file and is checked before use: an entry whose value lies +//! outside the file is an error when it is read, the walk visits at most [`MAX_IFDS`] directories, +//! never the same one twice (cyclic chains end), and goes at most [`MAX_DEPTH`] SubIFD levels deep. + +use crate::{Error, Result}; + +/// Most IFDs one file may have (main chain and SubIFDs together). +pub const MAX_IFDS: usize = 64; +/// Deepest SubIFD nesting followed. +pub const MAX_DEPTH: usize = 4; + +/// TIFF field types (TIFF 6.0 §2, plus type 13 IFD from TIFF Technical Note 1). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Type { + Byte, + Ascii, + Short, + Long, + Rational, + SByte, + Undefined, + SShort, + SLong, + SRational, + Float, + Double, + Ifd, +} + +impl Type { + fn from_code(c: u16) -> Option { + Some(match c { + 1 => Type::Byte, + 2 => Type::Ascii, + 3 => Type::Short, + 4 => Type::Long, + 5 => Type::Rational, + 6 => Type::SByte, + 7 => Type::Undefined, + 8 => Type::SShort, + 9 => Type::SLong, + 10 => Type::SRational, + 11 => Type::Float, + 12 => Type::Double, + 13 => Type::Ifd, + _ => return None, + }) + } + + /// Bytes per value. + pub fn size(self) -> usize { + match self { + Type::Byte | Type::Ascii | Type::SByte | Type::Undefined => 1, + Type::Short | Type::SShort => 2, + Type::Long | Type::SLong | Type::Float | Type::Ifd => 4, + Type::Rational | Type::SRational | Type::Double => 8, + } + } +} + +/// One directory entry. +#[derive(Clone, Copy, Debug)] +pub struct Entry { + pub tag: u16, + pub ty: Type, + pub count: u32, + /// Where the value bytes start: inside the entry when they fit in four bytes, else the offset + /// the entry holds (not yet checked against the file length). + value_at: u64, +} + +/// One image file directory. +#[derive(Clone, Debug, Default)] +pub struct Ifd { + /// File offset of the directory. + pub offset: u64, + pub entries: Vec, + /// Nesting: 0 for the main chain, 1 for its SubIFDs, … + pub depth: usize, +} + +impl Ifd { + pub fn get(&self, tag: u16) -> Option<&Entry> { + self.entries.iter().find(|e| e.tag == tag) + } +} + +/// A TIFF file in memory. +#[derive(Clone, Copy)] +pub struct Tiff<'a> { + pub data: &'a [u8], + pub big_endian: bool, +} + +impl<'a> Tiff<'a> { + /// Read the header. Classic TIFF only (BigTIFF, version 43, is not used by DNG). + pub fn new(data: &'a [u8]) -> Result<(Tiff<'a>, u64)> { + let big_endian = match data.get(0..4) { + Some(b"II*\0") => false, + Some(b"MM\0*") => true, + _ => return Err(Error::NotDng), + }; + let t = Tiff { data, big_endian }; + let first = u64::from(t.u32_at(4)?); + Ok((t, first)) + } + + fn bytes_at(&self, off: u64, len: usize) -> Result<&'a [u8]> { + let start = usize::try_from(off).map_err(|_| Error::Malformed("offset outside the file"))?; + let end = start.checked_add(len).ok_or(Error::Malformed("offset outside the file"))?; + self.data.get(start..end).ok_or(Error::Malformed("offset outside the file")) + } + + pub fn u16_of(&self, b: [u8; 2]) -> u16 { + if self.big_endian { u16::from_be_bytes(b) } else { u16::from_le_bytes(b) } + } + + pub fn u32_of(&self, b: [u8; 4]) -> u32 { + if self.big_endian { u32::from_be_bytes(b) } else { u32::from_le_bytes(b) } + } + + fn u16_at(&self, off: u64) -> Result { + let b = self.bytes_at(off, 2)?; + Ok(self.u16_of([b[0], b[1]])) + } + + fn u32_at(&self, off: u64) -> Result { + let b = self.bytes_at(off, 4)?; + Ok(self.u32_of([b[0], b[1], b[2], b[3]])) + } + + /// Read the IFD at `off`: its entries and the offset of the next IFD (0 at the end of a chain). + pub fn read_ifd(&self, off: u64, depth: usize) -> Result<(Ifd, u64)> { + let n = self.u16_at(off)?; + let mut entries = Vec::with_capacity(usize::from(n)); + for i in 0..u64::from(n) { + let at = off + 2 + i * 12; + let e = self.bytes_at(at, 12)?; + let tag = self.u16_of([e[0], e[1]]); + let code = self.u16_of([e[2], e[3]]); + let count = self.u32_of([e[4], e[5], e[6], e[7]]); + // unknown field types are skipped (TIFF 6.0: readers ignore them) + let Some(ty) = Type::from_code(code) else { continue }; + let len = u64::from(count).saturating_mul(ty.size() as u64); + let value_at = if len <= 4 { at + 8 } else { u64::from(self.u32_of([e[8], e[9], e[10], e[11]])) }; + entries.push(Entry { tag, ty, count, value_at }); + } + let next = u64::from(self.u32_at(off + 2 + u64::from(n) * 12)?); + Ok((Ifd { offset: off, entries, depth }, next)) + } + + /// Every IFD of the file: the main chain from `first` and, depth first, the SubIFDs (tag 330) of + /// each. Bounded by [`MAX_IFDS`] and [`MAX_DEPTH`]; a directory reached twice ends that branch. + pub fn all_ifds(&self, first: u64) -> Result> { + let mut out: Vec = Vec::new(); + let mut seen: Vec = Vec::new(); + let mut stack: Vec<(u64, usize)> = vec![(first, 0)]; + while let Some((off, depth)) = stack.pop() { + let mut next = off; + while next != 0 { + if seen.contains(&next) { + break; + } + if out.len() >= MAX_IFDS { + return Err(Error::Malformed("too many IFDs")); + } + seen.push(next); + let (ifd, after) = match self.read_ifd(next, depth) { + Ok(x) => x, + // a broken directory ends the chain; the first one must be readable + Err(e) if out.is_empty() => return Err(e), + Err(_) => break, + }; + if depth < MAX_DEPTH + && let Some(sub) = ifd.get(tags::SUB_IFDS) + && let Ok(offs) = self.uints(sub) + { + for o in offs.into_iter().rev() { + stack.push((o, depth + 1)); + } + } + out.push(ifd); + next = after; + } + } + Ok(out) + } + + /// The value bytes of an entry. + pub fn value_bytes(&self, e: &Entry) -> Result<&'a [u8]> { + let len = (e.count as usize).checked_mul(e.ty.size()).ok_or(Error::Malformed("entry too large"))?; + self.bytes_at(e.value_at, len) + } + + /// The `i`-th value of a numeric entry as f64 (rationals divided; a zero denominator is an + /// error). + fn number(&self, e: &Entry, b: &[u8], i: usize) -> Result { + let s = e.ty.size(); + let v = b.get(i * s..i * s + s).ok_or(Error::Malformed("entry too short"))?; + let u16v = |o: usize| self.u16_of([v[o], v[o + 1]]); + let u32v = |o: usize| self.u32_of([v[o], v[o + 1], v[o + 2], v[o + 3]]); + Ok(match e.ty { + Type::Byte | Type::Undefined => f64::from(v[0]), + Type::SByte => f64::from(v[0] as i8), + Type::Short => f64::from(u16v(0)), + Type::SShort => f64::from(u16v(0) as i16), + Type::Long | Type::Ifd => f64::from(u32v(0)), + Type::SLong => f64::from(u32v(0) as i32), + Type::Rational => { + let d = u32v(4); + if d == 0 { + return Err(Error::Malformed("rational with a zero denominator")); + } + f64::from(u32v(0)) / f64::from(d) + } + Type::SRational => { + let d = u32v(4) as i32; + if d == 0 { + return Err(Error::Malformed("rational with a zero denominator")); + } + f64::from(u32v(0) as i32) / f64::from(d) + } + Type::Float => f64::from(f32::from_bits(u32v(0))), + Type::Double => { + let mut a = [0u8; 8]; + a.copy_from_slice(&v[..8]); + if self.big_endian { f64::from_be_bytes(a) } else { f64::from_le_bytes(a) } + } + Type::Ascii => return Err(Error::Malformed("text where a number belongs")), + }) + } + + /// All values of a numeric entry as f64. Non-finite values are an error. + pub fn floats(&self, e: &Entry) -> Result> { + let b = self.value_bytes(e)?; + let mut out = Vec::with_capacity(e.count as usize); + for i in 0..e.count as usize { + let v = self.number(e, b, i)?; + if !v.is_finite() { + return Err(Error::Malformed("non-finite number")); + } + out.push(v); + } + Ok(out) + } + + /// All values of an unsigned integer entry (BYTE, SHORT, LONG, IFD). + pub fn uints(&self, e: &Entry) -> Result> { + if !matches!(e.ty, Type::Byte | Type::Undefined | Type::Short | Type::Long | Type::Ifd) { + return Err(Error::Malformed("expected an unsigned integer field")); + } + let b = self.value_bytes(e)?; + let s = e.ty.size(); + Ok(b.chunks_exact(s) + .map(|v| match s { + 1 => u64::from(v[0]), + 2 => u64::from(self.u16_of([v[0], v[1]])), + _ => u64::from(self.u32_of([v[0], v[1], v[2], v[3]])), + }) + .collect()) + } + + /// The first value of an unsigned integer entry. + pub fn uint(&self, e: &Entry) -> Result { + self.uints(e)?.first().copied().ok_or(Error::Malformed("empty field")) + } + + /// An ASCII entry up to its first NUL, lossily decoded. + pub fn ascii(&self, e: &Entry) -> Result { + let b = self.value_bytes(e)?; + let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + Ok(String::from_utf8_lossy(&b[..end]).trim().to_string()) + } +} + +/// Tag numbers (TIFF 6.0, TIFF/EP, DNG 1.7, CinemaDNG). +pub mod tags { + pub const NEW_SUBFILE_TYPE: u16 = 254; + pub const IMAGE_WIDTH: u16 = 256; + pub const IMAGE_LENGTH: u16 = 257; + pub const BITS_PER_SAMPLE: u16 = 258; + pub const COMPRESSION: u16 = 259; + pub const PHOTOMETRIC: u16 = 262; + pub const MAKE: u16 = 271; + pub const MODEL: u16 = 272; + pub const STRIP_OFFSETS: u16 = 273; + pub const SAMPLES_PER_PIXEL: u16 = 277; + pub const ROWS_PER_STRIP: u16 = 278; + pub const STRIP_BYTE_COUNTS: u16 = 279; + pub const PLANAR_CONFIGURATION: u16 = 284; + pub const PREDICTOR: u16 = 317; + pub const TILE_WIDTH: u16 = 322; + pub const TILE_LENGTH: u16 = 323; + pub const TILE_OFFSETS: u16 = 324; + pub const TILE_BYTE_COUNTS: u16 = 325; + pub const SUB_IFDS: u16 = 330; + pub const SAMPLE_FORMAT: u16 = 339; + pub const CFA_REPEAT_PATTERN_DIM: u16 = 33421; + pub const CFA_PATTERN: u16 = 33422; + pub const DNG_VERSION: u16 = 50706; + pub const UNIQUE_CAMERA_MODEL: u16 = 50708; + pub const CFA_PLANE_COLOR: u16 = 50710; + pub const CFA_LAYOUT: u16 = 50711; + pub const LINEARIZATION_TABLE: u16 = 50712; + pub const BLACK_LEVEL_REPEAT_DIM: u16 = 50713; + pub const BLACK_LEVEL: u16 = 50714; + pub const BLACK_LEVEL_DELTA_H: u16 = 50715; + pub const BLACK_LEVEL_DELTA_V: u16 = 50716; + pub const WHITE_LEVEL: u16 = 50717; + pub const DEFAULT_CROP_ORIGIN: u16 = 50719; + pub const DEFAULT_CROP_SIZE: u16 = 50720; + pub const COLOR_MATRIX_1: u16 = 50721; + pub const COLOR_MATRIX_2: u16 = 50722; + pub const CAMERA_CALIBRATION_1: u16 = 50723; + pub const CAMERA_CALIBRATION_2: u16 = 50724; + pub const ANALOG_BALANCE: u16 = 50727; + pub const AS_SHOT_NEUTRAL: u16 = 50728; + pub const AS_SHOT_WHITE_XY: u16 = 50729; + pub const BASELINE_EXPOSURE: u16 = 50730; + pub const CALIBRATION_ILLUMINANT_1: u16 = 50778; + pub const CALIBRATION_ILLUMINANT_2: u16 = 50779; + pub const ACTIVE_AREA: u16 = 50829; + pub const FORWARD_MATRIX_1: u16 = 50964; + pub const FORWARD_MATRIX_2: u16 = 50965; + /// CinemaDNG: SMPTE ST 12-1 time codes, 8 bytes each. + pub const TIME_CODES: u16 = 51043; + /// CinemaDNG: frames per second (SRATIONAL). + pub const FRAME_RATE: u16 = 51044; + /// CinemaDNG: the camera's reel name. + pub const REEL_NAME: u16 = 51081; +} diff --git a/crates/dng/src/writer.rs b/crates/dng/src/writer.rs new file mode 100644 index 000000000..fc53be648 --- /dev/null +++ b/crates/dng/src/writer.rs @@ -0,0 +1,449 @@ +//! A small DNG writer for synthesising test files (feature `writer`; not used by the app): a +//! TIFF container with the raw image in IFD 0 or in a SubIFD behind an 8-bit preview, strips or +//! tiles, uncompressed samples of any depth or lossless JPEG (T.81 process 14) with any predictor, +//! and whatever extra tags the caller adds. + +use crate::ljpeg::predict; +use crate::tiff::tags; + +/// A tag value. +#[derive(Clone, Debug)] +pub enum Value { + Byte(Vec), + Ascii(String), + Short(Vec), + Long(Vec), + Rational(Vec<(u32, u32)>), + SRational(Vec<(i32, i32)>), + Undefined(Vec), +} + +impl Value { + fn code(&self) -> u16 { + match self { + Value::Byte(_) => 1, + Value::Ascii(_) => 2, + Value::Short(_) => 3, + Value::Long(_) => 4, + Value::Rational(_) => 5, + Value::Undefined(_) => 7, + Value::SRational(_) => 10, + } + } + + fn count(&self) -> usize { + match self { + Value::Byte(v) | Value::Undefined(v) => v.len(), + Value::Ascii(s) => s.len() + 1, + Value::Short(v) => v.len(), + Value::Long(v) => v.len(), + Value::Rational(v) => v.len(), + Value::SRational(v) => v.len(), + } + } + + fn bytes(&self, be: bool) -> Vec { + let w16 = |v: u16| if be { v.to_be_bytes() } else { v.to_le_bytes() }; + let w32 = |v: u32| if be { v.to_be_bytes() } else { v.to_le_bytes() }; + match self { + Value::Byte(v) | Value::Undefined(v) => v.clone(), + Value::Ascii(s) => s.bytes().chain([0]).collect(), + Value::Short(v) => v.iter().flat_map(|&x| w16(x)).collect(), + Value::Long(v) => v.iter().flat_map(|&x| w32(x)).collect(), + Value::Rational(v) => v.iter().flat_map(|&(n, d)| w32(n).into_iter().chain(w32(d))).collect(), + Value::SRational(v) => v.iter().flat_map(|&(n, d)| w32(n as u32).into_iter().chain(w32(d as u32))).collect(), + } + } + + /// Signed rationals with denominator 10000 from floats (colour matrices and the like). + pub fn srationals(v: &[f64]) -> Value { + Value::SRational(v.iter().map(|&x| ((x * 10_000.0).round() as i32, 10_000)).collect()) + } + + /// Unsigned rationals with denominator 10000. + pub fn rationals(v: &[f64]) -> Value { + Value::Rational(v.iter().map(|&x| ((x * 10_000.0).round().max(0.0) as u32, 10_000)).collect()) + } +} + +/// How the raw data is stored. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Compression { + None, + /// Lossless JPEG: `predictor` 1–7; each tile / strip is coded as a JPEG of `components` + /// interleaved components (its width × samples per pixel divided by `components`), with a + /// restart marker every `restart_lines` lines (0: none) and point transform `pt`. + LosslessJpeg { + predictor: u8, + components: usize, + restart_lines: usize, + pt: u8, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Layout { + Strips { rows: usize }, + Tiles { width: usize, height: usize }, +} + +/// What to write. +#[derive(Clone, Debug)] +pub struct Options { + pub width: usize, + pub height: usize, + pub samples_per_pixel: usize, + pub bits: u32, + pub big_endian: bool, + pub compression: Compression, + pub layout: Layout, + /// CFA repeat (rows, columns) and pattern colour codes; `None` writes LinearRaw. + pub cfa: Option<(usize, usize, Vec)>, + /// Put an 8-bit preview in IFD 0 and the raw image in a SubIFD (as cameras do). + pub preview: bool, + /// Extra tags of IFD 0 (colour, CinemaDNG…). + pub main_tags: Vec<(u16, Value)>, + /// Extra tags of the raw IFD (black and white levels, crop…). + pub raw_tags: Vec<(u16, Value)>, +} + +impl Default for Options { + fn default() -> Self { + Options { + width: 8, + height: 8, + samples_per_pixel: 1, + bits: 16, + big_endian: false, + compression: Compression::None, + layout: Layout::Strips { rows: 8 }, + cfa: Some((2, 2, vec![0, 1, 1, 2])), + preview: false, + main_tags: Vec::new(), + raw_tags: Vec::new(), + } + } +} + +/// Pack `rows` rows of `per_row` samples of `bits` bits (8/16-bit in byte order `be`, other +/// depths most significant bit first, each row starting on a byte boundary). +pub fn pack(samples: &[u16], per_row: usize, bits: u32, be: bool) -> Vec { + let mut out = Vec::new(); + for row in samples.chunks(per_row.max(1)) { + match bits { + 8 => out.extend(row.iter().map(|&v| v as u8)), + 16 => out.extend(row.iter().flat_map(|&v| if be { v.to_be_bytes() } else { v.to_le_bytes() })), + _ => { + let (mut acc, mut n) = (0u32, 0u32); + for &v in row { + acc = (acc << bits) | (u32::from(v) & ((1 << bits) - 1)); + n += bits; + while n >= 8 { + n -= 8; + out.push((acc >> n) as u8); + } + acc &= (1 << n) - 1; + } + if n > 0 { + out.push((acc << (8 - n)) as u8); + } + } + } + } + out +} + +/// Entropy-coded bit output with 0xFF stuffing. +struct BitOut { + out: Vec, + acc: u32, + n: u32, +} + +impl BitOut { + fn put(&mut self, v: u32, len: u32) { + for i in (0..len).rev() { + self.acc = (self.acc << 1) | ((v >> i) & 1); + self.n += 1; + if self.n == 8 { + let b = self.acc as u8; + self.out.push(b); + if b == 0xFF { + self.out.push(0); + } + self.acc = 0; + self.n = 0; + } + } + } + + /// Pad the last byte with 1 bits. + fn flush(&mut self) { + while self.n != 0 { + self.put(1, 1); + } + } +} + +/// Encode `samples` (`width` × `height` × `comps`, interleaved) as lossless JPEG. +pub fn lj92(samples: &[u16], width: usize, height: usize, comps: usize, precision: u8, predictor: u8, restart_lines: usize, pt: u8) -> Vec { + let comps = comps.clamp(1, 4); + let row = width * comps; + let initial = 1i32 << (precision - pt - 1); + // differences, line by line + let mut cats: Vec> = vec![vec![0; 17]; comps]; + let mut coded: Vec<(usize, u32, u32, i32)> = Vec::with_capacity(samples.len()); // (comp, ssss, extra, line) + let s = |i: usize| i32::from(samples.get(i).copied().unwrap_or(0) >> pt); + for y in 0..height { + let first_line = restart_lines == 0 && y == 0 || restart_lines != 0 && y % restart_lines == 0; + for x in 0..width { + for c in 0..comps { + let i = y * row + x * comps + c; + let px = if first_line && x == 0 { + initial + } else if first_line { + s(i - comps) + } else if x == 0 { + s(i - row) + } else { + predict(predictor, s(i - comps), s(i - row), s(i - row - comps)) + }; + let d16 = (s(i) - px).rem_euclid(65_536); + let (ssss, extra) = if d16 == 32_768 { + (16, 0) + } else { + let d = if d16 >= 32_768 { d16 - 65_536 } else { d16 }; + let ssss = 32 - d.unsigned_abs().leading_zeros(); + let extra = if d >= 0 { d as u32 } else { (d + (1 << ssss) - 1) as u32 }; + (ssss, extra) + }; + cats[c][ssss as usize] += 1; + coded.push((c, ssss, extra, y as i32)); + } + } + } + // per component: the used categories by frequency get 2-bit then 6-bit codes + let mut tables = Vec::new(); + for counts in &cats { + let mut syms: Vec = (0..17u8).filter(|&k| counts[usize::from(k)] > 0).collect(); + syms.sort_by_key(|&k| std::cmp::Reverse(counts[usize::from(k)])); + if syms.is_empty() { + syms.push(0); + } + let mut code_of = [(0u32, 0u32); 17]; + for (i, &k) in syms.iter().enumerate() { + code_of[usize::from(k)] = if i < 3 { (i as u32, 2) } else { (0b110000 + (i as u32 - 3), 6) }; + } + let mut bits = [0u8; 16]; + bits[1] = syms.len().min(3) as u8; + bits[5] = syms.len().saturating_sub(3) as u8; + tables.push((bits, syms, code_of)); + } + let mut out = vec![0xFF, 0xD8]; + // SOF3 + let mut sof = vec![precision, (height >> 8) as u8, height as u8, (width >> 8) as u8, width as u8, comps as u8]; + for c in 0..comps { + sof.extend([c as u8, 0x11, 0]); + } + segment(&mut out, 0xC3, &sof); + let mut dht = Vec::new(); + for (c, (bits, syms, _)) in tables.iter().enumerate() { + dht.push(c as u8); + dht.extend(bits); + dht.extend(syms); + } + segment(&mut out, 0xC4, &dht); + if restart_lines != 0 { + let ri = (restart_lines * width) as u16; + segment(&mut out, 0xDD, &ri.to_be_bytes()); + } + let mut sos = vec![comps as u8]; + for c in 0..comps { + sos.extend([c as u8, (c as u8) << 4]); + } + sos.extend([predictor, 0, pt]); + segment(&mut out, 0xDA, &sos); + let mut bo = BitOut { out, acc: 0, n: 0 }; + let mut line = 0; + let mut rst = 0u8; + for (c, ssss, extra, y) in coded { + if restart_lines != 0 && y != line && (y as usize).is_multiple_of(restart_lines) { + bo.flush(); + bo.out.extend([0xFF, 0xD0 + rst]); + rst = (rst + 1) % 8; + } + line = y; + let (code, len) = tables[c].2[ssss as usize]; + bo.put(code, len); + if ssss != 16 { + bo.put(extra, ssss); + } + } + bo.flush(); + let mut out = bo.out; + out.extend([0xFF, 0xD9]); + out +} + +fn segment(out: &mut Vec, marker: u8, body: &[u8]) { + out.extend([0xFF, marker]); + out.extend(((body.len() + 2) as u16).to_be_bytes()); + out.extend(body); +} + +/// Append an IFD at the end of `buf` (word aligned) and return its offset. +fn put_ifd(buf: &mut Vec, mut entries: Vec<(u16, Value)>, be: bool) -> u32 { + if buf.len() % 2 == 1 { + buf.push(0); + } + entries.sort_by_key(|e| e.0); + let at = buf.len(); + let n = entries.len(); + let w16 = |v: u16| if be { v.to_be_bytes() } else { v.to_le_bytes() }; + let w32 = |v: u32| if be { v.to_be_bytes() } else { v.to_le_bytes() }; + let mut ext_at = at + 2 + 12 * n + 4; + let mut head = Vec::new(); + let mut ext = Vec::new(); + head.extend(w16(n as u16)); + for (tag, v) in &entries { + let b = v.bytes(be); + head.extend(w16(*tag)); + head.extend(w16(v.code())); + head.extend(w32(v.count() as u32)); + if b.len() <= 4 { + let mut inline = b.clone(); + inline.resize(4, 0); + head.extend(inline); + } else { + head.extend(w32(ext_at as u32)); + ext.extend(&b); + if b.len() % 2 == 1 { + ext.push(0); + } + ext_at += b.len().div_ceil(2) * 2; + } + } + head.extend(w32(0)); + buf.extend(head); + buf.extend(ext); + at as u32 +} + +/// Encode the strips or tiles of `samples`, appending them to `buf`; returns (offsets, counts). +fn put_chunks(buf: &mut Vec, samples: &[u16], o: &Options) -> (Vec, Vec) { + let spp = o.samples_per_pixel; + let (cw, ch) = match o.layout { + Layout::Strips { rows } => (o.width, rows.max(1)), + Layout::Tiles { width, height } => (width.max(1), height.max(1)), + }; + let tiles = matches!(o.layout, Layout::Tiles { .. }); + let (mut offs, mut counts) = (Vec::new(), Vec::new()); + for ty in (0..o.height).step_by(ch) { + for tx in (0..o.width).step_by(cw) { + let rows = if tiles { ch } else { ch.min(o.height - ty) }; + // the chunk's samples; tiles past the image edge repeat the edge pixel + let mut chunk = Vec::with_capacity(cw * rows * spp); + for r in 0..rows { + let y = (ty + r).min(o.height - 1); + for c in 0..cw { + let x = (tx + c).min(o.width - 1); + for s in 0..spp { + chunk.push(samples.get((y * o.width + x) * spp + s).copied().unwrap_or(0)); + } + } + } + let data = match o.compression { + Compression::None => pack(&chunk, cw * spp, o.bits, o.big_endian), + Compression::LosslessJpeg { predictor, components, restart_lines, pt } => { + let comps = components.max(1); + lj92(&chunk, cw * spp / comps, rows, comps, o.bits as u8, predictor, restart_lines, pt) + } + }; + if buf.len() % 2 == 1 { + buf.push(0); + } + offs.push(buf.len() as u32); + counts.push(data.len() as u32); + buf.extend(data); + } + } + (offs, counts) +} + +/// Write a DNG holding `samples` (`width` × `height` × `samples_per_pixel`, interleaved). +pub fn write(samples: &[u16], o: &Options) -> Vec { + let be = o.big_endian; + let mut buf = if be { b"MM\0*\0\0\0\0".to_vec() } else { b"II*\0\0\0\0\0".to_vec() }; + let (offs, counts) = put_chunks(&mut buf, samples, o); + let mut raw = vec![ + (tags::NEW_SUBFILE_TYPE, Value::Long(vec![0])), + (tags::IMAGE_WIDTH, Value::Long(vec![o.width as u32])), + (tags::IMAGE_LENGTH, Value::Long(vec![o.height as u32])), + (tags::BITS_PER_SAMPLE, Value::Short(vec![o.bits as u16; o.samples_per_pixel])), + (tags::COMPRESSION, Value::Short(vec![if o.compression == Compression::None { 1 } else { 7 }])), + (tags::SAMPLES_PER_PIXEL, Value::Short(vec![o.samples_per_pixel as u16])), + (tags::PLANAR_CONFIGURATION, Value::Short(vec![1])), + ]; + match o.layout { + Layout::Strips { rows } => { + raw.push((tags::ROWS_PER_STRIP, Value::Long(vec![rows as u32]))); + raw.push((tags::STRIP_OFFSETS, Value::Long(offs))); + raw.push((tags::STRIP_BYTE_COUNTS, Value::Long(counts))); + } + Layout::Tiles { width, height } => { + raw.push((tags::TILE_WIDTH, Value::Long(vec![width as u32]))); + raw.push((tags::TILE_LENGTH, Value::Long(vec![height as u32]))); + raw.push((tags::TILE_OFFSETS, Value::Long(offs))); + raw.push((tags::TILE_BYTE_COUNTS, Value::Long(counts))); + } + } + match &o.cfa { + Some((r, c, p)) => { + raw.push((tags::PHOTOMETRIC, Value::Short(vec![32803]))); + raw.push((tags::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![*r as u16, *c as u16]))); + raw.push((tags::CFA_PATTERN, Value::Byte(p.clone()))); + } + None => raw.push((tags::PHOTOMETRIC, Value::Short(vec![34892]))), + } + // later entries replace earlier ones with the same tag + let merge = |base: &mut Vec<(u16, Value)>, extra: &[(u16, Value)]| { + for (t, v) in extra { + base.retain(|e| e.0 != *t); + base.push((*t, v.clone())); + } + }; + merge(&mut raw, &o.raw_tags); + let mut main = vec![(tags::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])), (tags::UNIQUE_CAMERA_MODEL, Value::Ascii("FilmCraft Synthetic".into()))]; + let ifd0 = if o.preview { + let raw_at = put_ifd(&mut buf, raw, be); + // a 2 × 2 RGB preview + if buf.len() % 2 == 1 { + buf.push(0); + } + let px_at = buf.len() as u32; + buf.extend([128u8; 12]); + let mut p = vec![ + (tags::NEW_SUBFILE_TYPE, Value::Long(vec![1])), + (tags::IMAGE_WIDTH, Value::Long(vec![2])), + (tags::IMAGE_LENGTH, Value::Long(vec![2])), + (tags::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])), + (tags::COMPRESSION, Value::Short(vec![1])), + (tags::PHOTOMETRIC, Value::Short(vec![2])), + (tags::STRIP_OFFSETS, Value::Long(vec![px_at])), + (tags::SAMPLES_PER_PIXEL, Value::Short(vec![3])), + (tags::ROWS_PER_STRIP, Value::Long(vec![2])), + (tags::STRIP_BYTE_COUNTS, Value::Long(vec![12])), + (tags::SUB_IFDS, Value::Long(vec![raw_at])), + ]; + p.append(&mut main); + merge(&mut p, &o.main_tags); + put_ifd(&mut buf, p, be) + } else { + raw.append(&mut main); + merge(&mut raw, &o.main_tags); + put_ifd(&mut buf, raw, be) + }; + let off = if be { ifd0.to_be_bytes() } else { ifd0.to_le_bytes() }; + buf[4..8].copy_from_slice(&off); + buf +} diff --git a/crates/dng/tests/oracle_libraw.rs b/crates/dng/tests/oracle_libraw.rs new file mode 100644 index 000000000..5f5776790 --- /dev/null +++ b/crates/dng/tests/oracle_libraw.rs @@ -0,0 +1,97 @@ +//! External oracle: LibRaw's `unprocessed_raw` sample program (an independent DNG reader) must +//! read the raw samples of our synthetic DNGs exactly as written and as we decode them: packed +//! uncompressed data at every depth in both byte orders, and lossless JPEG with every predictor. +//! Skipped when `unprocessed_raw` is not installed (or `FILMCRAFT_UNPROCESSED_RAW` names it). +//! LibRaw is only run, never linked or read. + +use std::path::PathBuf; +use std::process::Command; + +use filmcraft_dng::Dng; +use filmcraft_dng::writer::{Compression, Layout, Options, write}; + +fn tool() -> Option { + let t = std::env::var("FILMCRAFT_UNPROCESSED_RAW").unwrap_or_else(|_| "unprocessed_raw".into()); + Command::new(&t).arg("-q").output().ok().map(|_| t) +} + +fn picture(w: usize, h: usize, bits: u32, seed: u32) -> Vec { + let mut s = seed.wrapping_mul(2_654_435_761).wrapping_add(7); + let max = (1u32 << bits) - 1; + (0..w * h) + .map(|i| { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + let (x, y) = (i % w, i / w); + (((x * 97 + y * 41) as u32 % (max + 1)) ^ (s & 15)).min(max) as u16 + }) + .collect() +} + +/// The 16-bit samples of a binary PGM. +fn pgm(b: &[u8]) -> Option<(usize, usize, Vec)> { + let mut fields = Vec::new(); + let mut i = 0; + while fields.len() < 4 { + while b.get(i)?.is_ascii_whitespace() { + i += 1; + } + let start = i; + while !b.get(i)?.is_ascii_whitespace() { + i += 1; + } + fields.push(std::str::from_utf8(&b[start..i]).ok()?.to_string()); + } + let (w, h): (usize, usize) = (fields[1].parse().ok()?, fields[2].parse().ok()?); + let data = b.get(i + 1..)?; + Some((w, h, data.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect())) +} + +#[test] +fn libraw_reads_our_raw_samples() { + let Some(tool) = tool() else { + eprintln!("SKIPPED: unprocessed_raw (LibRaw samples) not installed"); + return; + }; + let dir: PathBuf = std::env::temp_dir().join(format!("filmcraft-dng-oracle-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (w, h) = (40, 26); + let mut cases: Vec<(String, Options, u32)> = Vec::new(); + for bits in [8, 10, 12, 14, 16] { + for be in [false, true] { + cases.push(( + format!("u{bits}{}", if be { "be" } else { "le" }), + Options { width: w, height: h, bits, big_endian: be, layout: Layout::Strips { rows: 7 }, ..Default::default() }, + bits, + )); + } + } + for predictor in 1..=7 { + for components in [1, 2] { + let o = Options { + width: w, + height: h, + bits: 14, + layout: Layout::Tiles { width: 16, height: 16 }, + compression: Compression::LosslessJpeg { predictor, components, restart_lines: 0, pt: 0 }, + ..Default::default() + }; + cases.push((format!("lj{predictor}c{components}"), o, 14)); + } + } + for (i, (name, o, bits)) in cases.iter().enumerate() { + let px = picture(w, h, *bits, i as u32); + let f = write(&px, o); + assert_eq!(Dng::parse(&f).unwrap().raw().unwrap().data, px, "{name}: ours"); + let path = dir.join(format!("{name}.dng")); + std::fs::write(&path, &f).unwrap(); + let out = Command::new(&tool).arg("-q").arg(&path).output().unwrap(); + assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr)); + let (pw, ph, theirs) = pgm(&std::fs::read(dir.join(format!("{name}.dng.pgm"))).unwrap()).unwrap(); + assert_eq!((pw, ph), (w, h), "{name}"); + assert_eq!(theirs, px, "{name}: LibRaw"); + eprintln!("{name}: LibRaw agrees"); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/dng/tests/roundtrip.rs b/crates/dng/tests/roundtrip.rs new file mode 100644 index 000000000..386aef5b2 --- /dev/null +++ b/crates/dng/tests/roundtrip.rs @@ -0,0 +1,366 @@ +//! Round trips through the test writer: container parsing, bit-exact raw decoding (uncompressed at +//! every depth, lossless JPEG with every predictor), the processing maths against hand-computed +//! values, CinemaDNG metadata, and hostile files. + +use filmcraft_dng::color::{D50, D65, bradford, inverse, mul, rec709_to_xyz, xy_to_xyz}; +use filmcraft_dng::tiff::tags; +use filmcraft_dng::writer::{Compression, Layout, Options, Value, lj92, write}; +use filmcraft_dng::{Dng, Error, Timecode, decode, ljpeg, probe, sniff}; + +/// A deterministic pseudo-random sequence. +fn noise(n: usize, bits: u32, seed: u32) -> Vec { + let mut s = seed.wrapping_mul(2_654_435_761).wrapping_add(1); + (0..n) + .map(|_| { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + (s & ((1u32 << bits) - 1)) as u16 + }) + .collect() +} + +/// A smooth picture with some noise (like a real sensor), `bits` deep. +fn picture(w: usize, h: usize, spp: usize, bits: u32, seed: u32) -> Vec { + let max = (1u32 << bits) - 1; + let n = noise(w * h * spp, 4.min(bits), seed); + (0..w * h * spp) + .map(|i| { + let (x, y) = ((i / spp) % w, (i / spp) / w); + let v = (x * 37 + y * 23 + (i % spp) * 101) as u32 % (max + 1); + (v ^ u32::from(n[i])).min(max) as u16 + }) + .collect() +} + +#[test] +fn sniff_and_container() { + let px = picture(16, 8, 1, 12, 1); + for be in [false, true] { + for preview in [false, true] { + let o = Options { width: 16, height: 8, bits: 12, big_endian: be, preview, ..Default::default() }; + let f = write(&px, &o); + assert!(sniff(&f), "be {be} preview {preview}"); + let d = Dng::parse(&f).unwrap(); + let info = d.info().unwrap(); + assert_eq!((info.raw_width, info.raw_height, info.width, info.height, info.bits), (16, 8, 16, 8, 12)); + assert!(info.cfa); + assert_eq!(info.dng_version, [1, 4, 0, 0]); + assert_eq!(info.camera.as_deref(), Some("FilmCraft Synthetic")); + assert_eq!(d.raw().unwrap().data, px, "be {be} preview {preview}"); + } + } + // a plain TIFF is not a DNG + let mut plain = write(&px, &Options::default()); + // remove the DNGVersion tag by renaming it to an unknown tag number + let pos = find_tag(&plain, tags::DNG_VERSION).unwrap(); + plain[pos..pos + 2].copy_from_slice(&40000u16.to_le_bytes()); + assert!(!sniff(&plain)); + assert_eq!(Dng::parse(&plain).err(), Some(Error::NotDng)); + assert!(!sniff(b"\x89PNG\r\n\x1a\n")); + assert!(!sniff(b"")); +} + +/// Offset of the directory entry for `tag` in a little-endian file's first IFD. +fn find_tag(f: &[u8], tag: u16) -> Option { + let ifd = u32::from_le_bytes(f[4..8].try_into().unwrap()) as usize; + let n = u16::from_le_bytes(f[ifd..ifd + 2].try_into().unwrap()) as usize; + (0..n).map(|i| ifd + 2 + 12 * i).find(|&e| u16::from_le_bytes(f[e..e + 2].try_into().unwrap()) == tag) +} + +#[test] +fn uncompressed_every_depth_strips_and_tiles() { + for bits in [8, 9, 10, 12, 14, 16] { + for (spp, cfa) in [(1, true), (3, false)] { + for layout in [Layout::Strips { rows: 3 }, Layout::Strips { rows: 100 }, Layout::Tiles { width: 16, height: 8 }] { + for be in [false, true] { + let (w, h) = (21, 13); + let px = picture(w, h, spp, bits, bits + spp as u32); + let o = Options { + width: w, + height: h, + samples_per_pixel: spp, + bits, + big_endian: be, + layout, + cfa: cfa.then(|| (2, 2, vec![0, 1, 1, 2])), + ..Default::default() + }; + let raw = Dng::parse(&write(&px, &o)).unwrap().raw().unwrap(); + assert_eq!(raw.data, px, "bits {bits} spp {spp} {layout:?} be {be}"); + assert_eq!(raw.bits, bits); + } + } + } + } +} + +#[test] +fn lossless_jpeg_every_predictor_bit_exact() { + for predictor in 1..=7u8 { + for bits in [8u32, 12, 14, 16] { + for (components, layout) in + [(1, Layout::Strips { rows: 5 }), (2, Layout::Tiles { width: 16, height: 16 }), (4, Layout::Tiles { width: 8, height: 4 })] + { + let (w, h) = (36, 22); + let px = picture(w, h, 1, bits, u32::from(predictor) * 7 + bits); + let o = Options { + width: w, + height: h, + bits, + layout, + compression: Compression::LosslessJpeg { predictor, components, restart_lines: 0, pt: 0 }, + ..Default::default() + }; + let f = write(&px, &o); + assert_eq!(probe(&f).unwrap().compression, "lossless JPEG"); + let raw = Dng::parse(&f).unwrap().raw().unwrap(); + assert_eq!(raw.data, px, "predictor {predictor} bits {bits} components {components}"); + } + } + } +} + +#[test] +fn lossless_jpeg_extremes_restarts_and_point_transform() { + // full-swing 16-bit noise hits every difference category including SSSS = 16 + let (w, h) = (17, 9); + let mut px = noise(w * h * 2, 16, 3); + px[0] = 0; + px[2] = 32_768; // 0 after the 2^15 initial prediction is a difference of 32768 (SSSS 16) + for predictor in 1..=7 { + let j = lj92(&px, w, h, 2, 16, predictor, 0, 0); + let d = ljpeg::decode(&j, 1 << 20).unwrap(); + assert_eq!((d.width, d.height, d.components, d.precision, d.predictor), (w, h, 2, 16, predictor)); + assert_eq!(d.samples, px, "predictor {predictor}"); + // restart markers every 2 and every 3 lines + for r in [2, 3] { + let j = lj92(&px, w, h, 2, 16, predictor, r, 0); + assert!(j.windows(2).any(|b| b == [0xFF, 0xD0]), "has RST0"); + assert_eq!(ljpeg::decode(&j, 1 << 20).unwrap().samples, px, "predictor {predictor} restart {r}"); + } + } + // point transform: the low bits are dropped and the rest shifted back + let px = picture(12, 6, 1, 14, 9); + let j = lj92(&px, 12, 6, 1, 14, 4, 0, 2); + let want: Vec = px.iter().map(|v| v >> 2 << 2).collect(); + assert_eq!(ljpeg::decode(&j, 1 << 20).unwrap().samples, want); + // the sample cap + assert_eq!(ljpeg::decode(&lj92(&px, 12, 6, 1, 14, 1, 0, 0), 71).err(), Some(Error::TooLarge)); +} + +/// The colour tags of a camera whose white-balanced values are Rec. 709 already. +fn rec709_camera_tags(neutral: [f64; 3]) -> Vec<(u16, Value)> { + let cm = inverse(&rec709_to_xyz().unwrap()).unwrap(); + let fm = mul(&bradford(&xy_to_xyz(D65), &xy_to_xyz(D50)).unwrap(), &rec709_to_xyz().unwrap()); + let flat = |m: [[f64; 3]; 3]| m.iter().flatten().copied().collect::>(); + vec![ + (tags::COLOR_MATRIX_1, Value::srationals(&flat(cm))), + (tags::FORWARD_MATRIX_1, Value::srationals(&flat(fm))), + (tags::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])), + (tags::AS_SHOT_NEUTRAL, Value::rationals(&neutral)), + ] +} + +#[test] +fn develop_matches_hand_computed_values() { + // 12-bit RGGB, black 64, white 4064 (range 4000); four colour quadrants seen through a camera + // with neutral (0.5, 1, 0.8). White-balanced values come out as Rec. 709 (the ForwardMatrix + // is Rec. 709 → XYZ D50), so a quadrant of colour c reads back c. + let (w, h) = (32, 24); + let neutral: [f64; 3] = [0.5, 1.0, 0.8]; + let quad = |x: usize, y: usize| -> [f64; 3] { + match (x < w / 2, y < h / 2) { + (true, true) => [0.5, 0.5, 0.5], + (false, true) => [0.8, 0.2, 0.1], + (true, false) => [0.05, 0.6, 0.3], + (false, false) => [0.25, 0.25, 0.9], + } + }; + let pat = [0u8, 1, 1, 2]; + let px: Vec = (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let c = usize::from(pat[(y % 2) * 2 + x % 2]); + let camera = quad(x, y)[c] * neutral[c]; + (64.0 + camera * 4000.0).round() as u16 + }) + .collect(); + let mut main = rec709_camera_tags(neutral); + main.push((tags::BASELINE_EXPOSURE, Value::SRational(vec![(0, 1)]))); + let raw_tags = vec![(tags::BLACK_LEVEL, Value::Short(vec![64])), (tags::WHITE_LEVEL, Value::Short(vec![4064]))]; + for compression in [Compression::None, Compression::LosslessJpeg { predictor: 6, components: 2, restart_lines: 0, pt: 0 }] { + let o = Options { width: w, height: h, bits: 12, compression, main_tags: main.clone(), raw_tags: raw_tags.clone(), ..Default::default() }; + let img = decode(&write(&px, &o)).unwrap(); + assert_eq!((img.width, img.height), (w, h)); + for y in 0..h { + for x in 0..w { + // away from the quadrant edges (the demosaic reaches two pixels) + if (x as isize - w as isize / 2).abs() < 3 || (y as isize - h as isize / 2).abs() < 3 { + continue; + } + let got = &img.rgba[(y * w + x) * 4..][..4]; + let want = quad(x, y); + for k in 0..3 { + assert!((got[k] - want[k] as f32).abs() < 2e-3, "({x},{y}) ch {k}: {} vs {}", got[k], want[k]); + } + assert_eq!(got[3], 1.0); + } + } + } + // BaselineExposure +1: twice as bright + let mut main1 = rec709_camera_tags(neutral); + main1.push((tags::BASELINE_EXPOSURE, Value::SRational(vec![(1, 1)]))); + let o = Options { width: w, height: h, bits: 12, main_tags: main1, raw_tags, ..Default::default() }; + let img = decode(&write(&px, &o)).unwrap(); + assert!((img.rgba[(4 * w + 4) * 4] - 1.0).abs() < 4e-3, "{}", img.rgba[(4 * w + 4) * 4]); +} + +#[test] +fn active_area_default_crop_and_linear_raw() { + // LinearRaw 3 samples, 10 × 6, active area cuts one pixel off each side, crop 4 × 2 at (2, 1) + let (w, h) = (10, 6); + let px: Vec = (0..w * h).flat_map(|i| [i as u16 * 100, 50, 7]).collect(); + let o = Options { + width: w, + height: h, + samples_per_pixel: 3, + bits: 16, + cfa: None, + raw_tags: vec![ + (tags::ACTIVE_AREA, Value::Long(vec![1, 1, 5, 9])), + (tags::DEFAULT_CROP_ORIGIN, Value::Long(vec![2, 1])), + (tags::DEFAULT_CROP_SIZE, Value::Rational(vec![(8, 2), (2, 1)])), + (tags::WHITE_LEVEL, Value::Long(vec![65_535])), + ], + ..Default::default() + }; + let f = write(&px, &o); + let info = probe(&f).unwrap(); + assert_eq!((info.width, info.height, info.cfa, info.samples_per_pixel), (4, 2, false, 3)); + let img = decode(&f).unwrap(); + assert_eq!((img.width, img.height), (4, 2)); + // output (0, 0) is raw (1 + 2, 1 + 1) = sample 23 + assert!((img.rgba[0] - 2300.0 / 65_535.0).abs() < 1e-6); + assert!((img.rgba[1] - 50.0 / 65_535.0).abs() < 1e-6); + // output (3, 1) is raw (6, 3) = sample 36 + assert!((img.rgba[(4 + 3) * 4] - 3600.0 / 65_535.0).abs() < 1e-6); +} + +#[test] +fn cinemadng_frame_rate_time_code_and_reel() { + let px = picture(8, 8, 1, 12, 2); + let o = Options { + bits: 12, + main_tags: vec![ + (tags::FRAME_RATE, Value::SRational(vec![(24_000, 1001)])), + // 01:02:03:04, drop frame + (tags::TIME_CODES, Value::Byte(vec![0x44, 0x03, 0x02, 0x01, 0, 0, 0, 0])), + (tags::REEL_NAME, Value::Ascii("A001".into())), + ], + ..Default::default() + }; + let info = probe(&write(&px, &o)).unwrap(); + assert_eq!(info.frame_rate, Some((24_000, 1001))); + assert_eq!(info.timecode, Some(Timecode { hours: 1, minutes: 2, seconds: 3, frames: 4, drop_frame: true })); + assert_eq!(info.reel.as_deref(), Some("A001")); + // nonsense rates and time codes are ignored + let o = Options { + bits: 12, + main_tags: vec![(tags::FRAME_RATE, Value::SRational(vec![(-25, 1)])), (tags::TIME_CODES, Value::Byte(vec![0x0A, 0x99, 0, 0, 0, 0, 0, 0]))], + ..Default::default() + }; + let info = probe(&write(&px, &o)).unwrap(); + assert_eq!((info.frame_rate, info.timecode), (None, None)); +} + +/// Every hostile variant must give an error or a picture, never a panic. +fn survives(f: &[u8]) -> bool { + std::panic::catch_unwind(|| { + let _ = probe(f); + let _ = decode(f); + }) + .is_ok() +} + +#[test] +fn hostile_files_are_errors_not_panics() { + let px = picture(24, 16, 1, 12, 5); + let files: Vec> = [ + Options { width: 24, height: 16, bits: 12, ..Default::default() }, + Options { width: 24, height: 16, bits: 12, preview: true, layout: Layout::Tiles { width: 16, height: 16 }, ..Default::default() }, + Options { + width: 24, + height: 16, + bits: 12, + compression: Compression::LosslessJpeg { predictor: 4, components: 2, restart_lines: 2, pt: 0 }, + main_tags: rec709_camera_tags([0.5, 1.0, 0.7]), + ..Default::default() + }, + ] + .iter() + .map(|o| write(&px, o)) + .collect(); + for f in &files { + assert!(decode(f).is_ok()); + // every truncation + for n in 0..f.len() { + assert!(survives(&f[..n]), "truncated to {n}"); + if n < f.len() / 2 { + assert!(decode(&f[..n]).is_err(), "truncated to {n}"); + } + } + // bit flips and corrupt bytes everywhere + let mut s = 12345u32; + for i in 0..4000 { + let mut g = f.clone(); + for _ in 0..1 + i % 4 { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + let at = s as usize % g.len(); + g[at] = if i % 3 == 0 { 0xFF } else { g[at] ^ (1 << (s >> 29)) }; + } + assert!(survives(&g), "mutation {i}"); + } + } +} + +#[test] +fn cyclic_ifds_huge_sizes_and_bad_offsets() { + let px = picture(8, 8, 1, 16, 4); + let f = write(&px, &Options { preview: true, ..Default::default() }); + let ifd0 = u32::from_le_bytes(f[4..8].try_into().unwrap()) as usize; + let n = u16::from_le_bytes(f[ifd0..ifd0 + 2].try_into().unwrap()) as usize; + // IFD 0's next pointer to itself + let mut g = f.clone(); + g[ifd0 + 2 + 12 * n..ifd0 + 6 + 12 * n].copy_from_slice(&(ifd0 as u32).to_le_bytes()); + assert!(decode(&g).is_ok(), "a self-loop ends the chain"); + // the SubIFD pointing back at IFD 0 + let mut g = f.clone(); + let sub = find_tag(&g, tags::SUB_IFDS).unwrap(); + g[sub + 8..sub + 12].copy_from_slice(&(ifd0 as u32).to_le_bytes()); + assert!(matches!(decode(&g), Err(Error::Unsupported(_))), "no raw image left"); + // huge dimensions with a tiny file + for (wtag, v) in [(tags::IMAGE_WIDTH, 65_535u32), (tags::IMAGE_LENGTH, 65_535), (tags::IMAGE_WIDTH, 0), (tags::IMAGE_WIDTH, u32::MAX)] { + let mut g = write(&px, &Options::default()); + let e = find_tag(&g, wtag).unwrap(); + g[e + 8..e + 12].copy_from_slice(&v.to_le_bytes()); + assert!(decode(&g).is_err(), "{wtag} = {v}"); + } + // a strip offset past the end of the file + let mut g = write(&px, &Options::default()); + let e = find_tag(&g, tags::STRIP_OFFSETS).unwrap(); + g[e + 8..e + 12].copy_from_slice(&0xFFFF_FF00u32.to_le_bytes()); + assert_eq!(decode(&g).err(), Some(Error::Truncated)); + // an IFD offset past the end + let mut g = write(&px, &Options::default()); + g[4..8].copy_from_slice(&0x7FFF_FFFFu32.to_le_bytes()); + assert!(decode(&g).is_err()); + // a rational with a zero denominator in a raw tag + let o = Options { raw_tags: vec![(tags::DEFAULT_CROP_SIZE, Value::Rational(vec![(8, 0), (8, 1)]))], ..Default::default() }; + assert!(matches!(decode(&write(&px, &o)), Err(Error::Malformed(_)))); + // unsupported compressions and depths are named + let o = Options { raw_tags: vec![(tags::COMPRESSION, Value::Short(vec![34892]))], ..Default::default() }; + assert_eq!(decode(&write(&px, &o)).err(), Some(Error::Unsupported("lossy JPEG DNG".into()))); +} diff --git a/docs/architecture.md b/docs/architecture.md index daaa470f0..9b722be3b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,7 +29,7 @@ Design principles: L2 edit · codecs · interchange · captions · speech · tts · tts-text L1 frame · media · project · audio-dsp · text L0 foundation: time · geom · color · bitstream · testkit (dev-dependency only) - codecs/containers: isobmff · matroska · mxf · cfb · mpegts · ogg · h264 · h264enc · hevc · vp9 · av1 · mpeg2v · prores · dnx · apv · aac · ac3 · opus + codecs/containers: isobmff · matroska · mxf · cfb · mpegts · ogg · h264 · h264enc · hevc · vp9 · av1 · mpeg2v · prores · dnx · apv · dng · aac · ac3 · opus ``` Crates are named `filmcraft-` (`crates/time` is `filmcraft-time`). The apps are `filmcraft` @@ -52,6 +52,7 @@ and `filmcraft-cli`. | `prores` | L0 | ProRes decoder and encoder | | `dnx` | L0 | DNxHD / DNxHR (SMPTE ST 2019-1 VC-3) decoder and DNxHR encoder | | `apv` | L0 | Advanced Professional Video (IETF RFC 9924) decoder and encoder ([README](../crates/apv/README.md)) | +| `dng` | L0 | DNG / CinemaDNG camera RAW decoder: TIFF container, uncompressed and lossless JPEG raw data, DNG processing to linear Rec. 709 ([README](../crates/dng/README.md)) | | `av1` | L0 | AV1 decoder (Main profile; bit-exact with libdav1d; see its README for the stage table) | | `mpeg2v` | L0 | MPEG-2 video (Main / 4:2:2 profile; frame and field pictures, dual prime) and MPEG-1 video decoder ([README](../crates/mpeg2v/README.md)) | | `aac` | L0 | AAC-LC decoder and encoder | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 2e1908687..f0e57eb1b 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -49,6 +49,7 @@ const LAYERS: &[(&str, u8)] = &[ ("mjpeg", 0), ("dnx", 0), ("apv", 0), + ("dng", 0), ("av1", 0), ("aac", 0), ("opus", 0),