//! RAW decoding for LightCraft. //! //! - [`probe`] recognises raw containers; [`decode`] turns a file into a [`RawImage`] (sensor data + everything //! needed to render it: CFA, black/white levels, active area, default crop, orientation, DNG colour tags, //! opcode lists, [`Metadata`]); [`embedded_preview`] returns the largest embedded JPEG (or DNG 1.7 JPEG XL) preview; //! [`semantic_masks`] reads a DNG's semantic masks (segmentation mattes, e.g. iPhone ProRAW's sky matte). //! - [`RawImage::normalized`] subtracts black, scales white to 1.0 and crops to the active area (applying DNG //! `OpcodeList1`/`OpcodeList2`); [`demosaic`] turns CFA data into camera-RGB [`Rgb32f`]; //! [`RawImage::develop`] does all of it plus `OpcodeList3` and the default crop; [`RawImage::develop_binned`] //! produces the same at 1/k of the size straight from the mosaic (previews, thumbnails; //! [`RawImage::develop_binned_masked`] with the mask of blocks holding clipped samples, for [`highlight`]). //! - [`color`] implements the DNG colour model (dual-illuminant interpolation, forward matrices, white balance) //! and produces camera → linear Rec.2020 D65 matrices; [`profile`] reads and applies a DNG's own profile //! look tables and tone curve, and [`gaintable`] its gain table map (Apple ProRAW's local tone mapping). //! //! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, JPEG XL (DNG 1.7, //! `jxl` feature, on by default), tiled/stripped, CFA and LinearRaw), //! Canon CR2 / CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer //! and X-Trans, lossless and lossy compressed), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format: compressed 4 and 6, the prefix-coded strips of 8, //! packed 2/5/7, the 16-bit words of the oldest bodies), Pentax PEF (uncompressed, Huffman), Olympus ORF (uncompressed: 16-bit words, 12-bit XZ-2 words, 12-bit 16-byte blocks of the E-300/E-330/E-500). //! [`embedded_preview`] covers these containers' JPEG previews. Variants we can't decode yet (compressed ORF, CR3 unverified marker families / C-RAW configurations) return [`RawError::Unsupported`]; each vendor module documents its sources //! (public specifications, tag-name documentation, black-box analysis of CC0 samples) and gaps. Non-DNG files carry no //! colour matrix: [`spectral`] has matrices fitted to measured spectral sensitivities for 52 models, and //! otherwise [`color`] falls back to a documented neutral model. The decoders never panic on malformed input. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod binned; pub mod color; pub mod demosaic; mod dng; pub mod dngwrite; pub mod gaintable; pub mod highlight; #[cfg(feature = "jxl")] mod jxl; pub mod kelvin_presets; pub mod ljpeg; pub(crate) mod llvc; pub mod opcodes; mod preview; pub mod profile; mod saturation; pub mod semantic; pub mod sparse; pub mod spectral; mod tiffraw; mod unpack; mod vendor; pub use demosaic::{Method, demosaic}; pub use dngwrite::{DngCompression, DngWriteOptions, write_dng, write_rendered_dng}; pub use lightcraft_color::Mat3; pub use lightcraft_geom::Orientation; pub use lightcraft_meta::Metadata; pub use lightcraft_raster::Rgb32f; pub use opcodes::{Opcode, OpcodeLists}; pub use preview::{ PREVIEW_PREFIX, PreviewColorSpace, PreviewRange, bounded_preview, embedded_preview, embedded_preview_color_space, embedded_preview_dynamic_range_optimized, embedded_preview_from_range, }; pub use semantic::{SemanticMask, semantic_masks}; use lightcraft_color::Xy; use lightcraft_tiff::{Tiff, TiffError}; use serde::{Deserialize, Serialize}; /// Upper bound on decoded samples (guards allocations driven by header values). pub const MAX_SAMPLES: usize = 1 << 30; /// Errors from raw decoding. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum RawError { #[error("not a recognised raw file")] NotRaw, #[error("unsupported raw variant: {0}")] Unsupported(String), #[error("corrupt raw data: {0}")] Corrupt(String), #[error("limit exceeded: {0}")] Limit(&'static str), #[error(transparent)] Tiff(#[from] TiffError), } pub type Result = std::result::Result; /// Raw container formats recognised by [`probe`]. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum RawFormat { Dng, Cr2, Cr3, Nef, Nrw, Arw, Raf, Orf, Rw2, Pef, Srw, /// Canon CRW (CIFF heap), the bodies before the CR2 era. Crw, /// Minolta MRW (`\0MRM` block container, `vendor::mrw`). Mrw, /// Sigma / Foveon X3F (`FOVb`). X3f, /// A TIFF-based raw that has no DNG version tag but describes itself the way DNG does (a /// full-resolution CFA image with black/white levels, a default crop and `AsShotNeutral`): /// Hasselblad 3FR and FFF. Read by the DNG reader. CfaTiff, /// Kodak DCS520C TIFF: a two-component lossless JPEG CFA sub-IFD with a response curve (`vendor::kodak`). KodakDcs, /// Another TIFF-based raw (3FR, IIQ, ERF, KDC, DCR, MOS, …). OtherTiff, } impl RawFormat { /// Whether [`decode`] supports this container (possibly not every compression inside it). pub fn is_supported(self) -> bool { matches!( self, RawFormat::Dng | RawFormat::Cr2 | RawFormat::Cr3 | RawFormat::Nef | RawFormat::Nrw | RawFormat::Arw | RawFormat::Raf | RawFormat::Rw2 | RawFormat::Pef | RawFormat::CfaTiff | RawFormat::Srw | RawFormat::KodakDcs | RawFormat::Mrw ) } } /// Recognise a raw file from its first bytes / TIFF structure. pub fn probe(bytes: &[u8]) -> Option { if bytes.len() >= 12 && &bytes[4..12] == b"ftypcrx " { return Some(RawFormat::Cr3); } if bytes.starts_with(b"FUJIFILMCCD-RAW") { return Some(RawFormat::Raf); } if bytes.starts_with(b"IIRO") || bytes.starts_with(b"IIRS") || bytes.starts_with(b"MMOR") { return Some(RawFormat::Orf); } if bytes.starts_with(b"IIU\0") { return Some(RawFormat::Rw2); } if bytes.starts_with(b"\0MRM") { return Some(RawFormat::Mrw); } if bytes.starts_with(b"FOVb") { return Some(RawFormat::X3f); } if bytes.len() >= 14 && bytes.starts_with(b"II") && &bytes[6..14] == b"HEAPCCDR" { return Some(RawFormat::Crw); } if bytes.len() >= 10 && bytes.starts_with(b"II*\0") && &bytes[8..10] == b"CR" { return Some(RawFormat::Cr2); } let (_, _) = Tiff::sniff(bytes)?; let opts = lightcraft_tiff::ParseOptions { max_ifds: 256, ..Default::default() }; let t = Tiff::parse_with(bytes, &opts).ok()?; let ifd0 = t.ifds.first()?; if ifd0.contains(lightcraft_tiff::tags::DNG_VERSION) { return Some(RawFormat::Dng); } let make = t.find(lightcraft_tiff::tags::MAKE).and_then(|e| e.value.as_str()).unwrap_or_default().to_ascii_uppercase(); let has_cfa = has_raw_ifd(&t); // Pentax's reader takes the layout from the Exif `CFAPattern` when no IFD has a CFA photometric (uncompressed // and PackBits PEFs), so for the Pentax family that tag counts as raw evidence too let pentax_raw = has_cfa || t.exif().is_some_and(|e| e.contains(vendor::EXIF_CFA_PATTERN)); // Samsung's raw IFD uses private compressions 32769..=32773 (`vendor::srw`), 32773 being PackBits elsewhere let samsung_raw = has_cfa || t.all_ifds().iter().any(|i| i.u16(lightcraft_tiff::tags::COMPRESSION).is_some_and(|c| (32769..=32773).contains(&c))); // Sony raws without a CFA IFD (#702): SRF (DSC-F828), whose IFD0 looks like RGB but holds 14-bit samples (12 or // 14 count: raw sensor depths; the raw is in Sony's SRF blocks), and the DSLR-A100, whose IFD0 is an old-style // JPEG thumbnail (compression 6, which today's TIFF writers don't produce) and whose `SubIFDs` tag addresses its // raw data. A camera-authored or exported TIFF has 8- or 16-bit samples, and its SubIFDs hold ordinary images let model = t.find(lightcraft_tiff::tags::MODEL).and_then(|e| e.value.as_str()).unwrap_or_default().trim().to_ascii_uppercase(); let sony_raw = has_cfa || ifd0.u64s(lightcraft_tiff::tags::BITS_PER_SAMPLE).is_some_and(|b| b.iter().any(|v| matches!(v, 12 | 14))) || (model == "DSLR-A100" && ifd0.u16(lightcraft_tiff::tags::COMPRESSION) == Some(6) && ifd0.contains(lightcraft_tiff::tags::SUB_IFDS)); if make.starts_with("CANON") && t.ifds.len() >= 4 && t.ifds[3].u16(lightcraft_tiff::tags::COMPRESSION) == Some(6) { return Some(RawFormat::Cr2); } if make.starts_with("NIKON") && has_cfa { return Some(if t.all_ifds().len() > 1 { RawFormat::Nef } else { RawFormat::Nrw }); } // Hasselblad's Sony-built bodies (the Lunar is a NEX-7 inside) write a Sony ARW under their own Make. Sony's // raw coding gives them away: compression 32767 or the raw IFD's tone-curve tag 0x7010, which no 3FR / FFF has let sony_coded = || t.all_ifds().iter().any(|i| i.u16(lightcraft_tiff::tags::COMPRESSION) == Some(32767) || i.contains(0x7010)); if (make.starts_with("SONY") && sony_raw) || (make.starts_with("HASSELBLAD") && sony_coded()) { return Some(RawFormat::Arw); } if (make.starts_with("PENTAX") || make.starts_with("RICOH")) && pentax_raw { return Some(RawFormat::Pef); } // a Samsung-branded body built on a Pentax design writes a Pentax-style maker note (the note's own magic) if make.starts_with("SAMSUNG") && pentax_raw && has_pentax_maker_note(&t, bytes) { return Some(RawFormat::Pef); } if make.starts_with("SAMSUNG") && samsung_raw { return Some(RawFormat::Srw); } if vendor::kodak::raw_ifd(&t, bytes).is_some() { return Some(RawFormat::KodakDcs); } if dng::is_plain_cfa_tiff(&t, bytes) { return Some(RawFormat::CfaTiff); } if has_cfa || thumbnail_shell(&t, bytes.len()).is_some() || is_preview_container(ifd0) || is_leaf_mosaic(ifd0) { return Some(RawFormat::OtherTiff); } None } /// Leaf's private data block in IFD0, tag `0x8606` (`LeafData` in the ExifTool EXIF tag-name documentation, which /// says the Leaf tags "are found in .MOS images from Leaf digital camera backs"). const LEAF_DATA: u16 = 0x8606; /// An uncompressed Leaf MOS: IFD0 is the 16-bit mosaic itself (compression 1, BlackIsZero, one sample per pixel), /// with no Make tag and no CFA photometric, so only Leaf's data block marks it as a raw (issue #1275). A rendered /// RGB image that kept the block stays an ordinary image. fn is_leaf_mosaic(ifd0: &lightcraft_tiff::Ifd) -> bool { ifd0.contains(LEAF_DATA) && ifd0.u16(lightcraft_tiff::tags::SAMPLES_PER_PIXEL).unwrap_or(1) == 1 } /// Whether the Exif maker note starts with the signature of the Pentax layouts (`AOC\0` or `PENTAX \0`). fn has_pentax_maker_note(t: &Tiff, bytes: &[u8]) -> bool { let Some(e) = t.exif().and_then(|e| e.get(lightcraft_tiff::tags::MAKER_NOTE)) else { return false }; bytes.get(e.offset as usize..).is_some_and(|n| n.starts_with(b"AOC\0") || n.starts_with(b"PENTAX \0")) } /// Whether some IFD is marked as raw: CFA photometric, or a raw-only compression value (99 is not a /// registered TIFF compression; Leaf MOS files use it for their tiled 16-bit lossless-JPEG raw), /// or vendor-specific raw tags (Sony tone curve, CFA pattern, or Pentax compression 65535). fn has_raw_ifd(t: &Tiff) -> bool { t.all_ifds().iter().any(|i| { i.u16(lightcraft_tiff::tags::PHOTOMETRIC) == Some(lightcraft_tiff::tags::photometric::CFA) || i.u16(lightcraft_tiff::tags::COMPRESSION).is_some_and(|c| matches!(c, 34713 | 32767 | 32769 | 32770 | 99 | 65535)) || i.contains(0x7010) || i.contains(lightcraft_tiff::tags::CFA_PATTERN_EP) || i.contains(lightcraft_tiff::tags::CFA_REPEAT_PATTERN_DIM) }) } /// IFD0 that is not an image at all: no `ImageWidth` / `ImageLength`, but a JPEG preview pointer or /// `SubIFDs` (the layout of Kodak KDC files, whose pictures sit in private tags). A plain TIFF reader /// rejects such a file as malformed even though it carries a preview. fn is_preview_container(ifd0: &lightcraft_tiff::Ifd) -> bool { use lightcraft_tiff::tags::{IMAGE_LENGTH, IMAGE_WIDTH, JPEG_INTERCHANGE_FORMAT, SUB_IFDS}; !ifd0.contains(IMAGE_WIDTH) && !ifd0.contains(IMAGE_LENGTH) && (ifd0.contains(JPEG_INTERCHANGE_FORMAT) || ifd0.contains(SUB_IFDS)) } /// A TIFF whose first image is only a reduced copy of a picture stored somewhere else. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct ThumbnailShell { /// Size of the first image (IFD0). pub thumb: (u32, u32), /// Size of the picture it stands in for. pub full: (u32, u32), } /// Some raw files are a TIFF "shell" around a private block: IFD0 holds a small RGB preview and nothing /// in the TIFF structure marks the real image as raw (no CFA photometric, no raw compression). The /// structure still gives them away, with a generic rule that names no maker or model: the file /// declares a picture at least 4x larger than IFD0 in both directions, and either /// - a `SubIFDs` child of IFD0 is that bigger image (an 8-bit grey mosaic in one such file), or /// - the Exif `PixelXDimension` / `PixelYDimension` say so *and* the file holds enough bytes outside /// every IFD's strips and tiles to carry that many pixels at one bit each (a downsized copy that kept /// its old Exif size has no such block). /// /// A multi-page TIFF is not caught (its pages are the IFD chain, not `SubIFDs`), nor a pyramid whose /// IFD0 is the full image. fn thumbnail_shell(t: &Tiff, len: usize) -> Option { use lightcraft_tiff::tags::{IMAGE_LENGTH, IMAGE_WIDTH, PIXEL_X_DIMENSION, PIXEL_Y_DIMENSION}; let ifd0 = t.ifds.first()?; let (w0, h0) = (ifd0.u32(IMAGE_WIDTH)?, ifd0.u32(IMAGE_LENGTH)?); if w0 == 0 || h0 == 0 { return None; } let much_bigger = |(w, h): (u32, u32)| u64::from(w) >= 4 * u64::from(w0) && u64::from(h) >= 4 * u64::from(h0); let shell = |full| Some(ThumbnailShell { thumb: (w0, h0), full }); let mut best: Option<(u32, u32)> = None; for s in &ifd0.sub_ifds { if s.image().is_ok() && let (Some(w), Some(h)) = (s.u32(IMAGE_WIDTH), s.u32(IMAGE_LENGTH)) && much_bigger((w, h)) && best.is_none_or(|(bw, bh)| u64::from(w) * u64::from(h) > u64::from(bw) * u64::from(bh)) { best = Some((w, h)); } } if let Some(full) = best { return shell(full); } let exif = t.exif()?; let full = (exif.u32(PIXEL_X_DIMENSION)?, exif.u32(PIXEL_Y_DIMENSION)?); if !much_bigger(full) { return None; } // bytes covered by image data (strips and tiles shared between IFDs, e.g. IFD0 and its thumbnail, count once) let mut seen = std::collections::BTreeMap::new(); for ifd in t.all_ifds() { if let Ok(info) = ifd.image() { for c in info.chunks(len as u64) { seen.insert(c.offset, c.len); } } } let covered = seen.values().fold(0u64, |a, &l| a.saturating_add(l)); let spare = (len as u64).saturating_sub(covered); (spare.saturating_mul(8) >= u64::from(full.0) * u64::from(full.1)).then_some(())?; shell(full) } /// Why [`decode`] gives up on a file [`probe`] called [`RawFormat::OtherTiff`]. fn other_tiff_reason(bytes: &[u8]) -> String { let t = Tiff::parse_with(bytes, &lightcraft_tiff::ParseOptions { max_ifds: 256, ..Default::default() }).ok(); // a DNG-style CFA IFD whose lossless JPEG the lossless decoder rejects: say why if let Some(t) = &t && let Some(info) = dng::raw_ifd(t).and_then(|i| i.image().ok()) && info.compression == lightcraft_tiff::tags::compression::JPEG && let Some(src) = info.chunks(bytes.len() as u64).first().and_then(|c| lightcraft_tiff::image::chunk_bytes(bytes, c)) && let Err(e) = ljpeg::frame_info(src) { return format!("raw image coded as lossless JPEG that is not decoded yet ({e})"); } match t.as_ref().filter(|t| !has_raw_ifd(t)).and_then(|t| thumbnail_shell(t, bytes.len())) { Some(s) => format!( "{}x{} raw image in a private block, not decoded yet (the file's first image is a {}x{} reduced copy)", s.full.0, s.full.1, s.thumb.0, s.thumb.1 ), None => "OtherTiff files are not decoded yet".to_string(), } } /// Decode a raw file. pub fn decode(bytes: &[u8]) -> Result { decode_with(bytes, Mode::Full) } /// Everything [`decode`] learns about a raw file except its samples: geometry, orientation, CFA, /// colour data, as-shot white balance, opcode lists (embedded lens corrections), metadata — read /// from the headers without decompressing the pixel data, for imports. Equal to /// [`RawImage::info`] of the decoded image. Black and white levels are not included (some formats /// measure them from the samples). /// /// A few vendor formats derive part of this from the samples themselves (Nikon NEF: optically /// masked trailing columns; Olympus ORF: the bit depth of 16-bit files, and the CFA phase of files /// without an Exif `CFAPattern`; Pentax PEF without crop tags: dark borders). Those read the /// samples they need and drop them: ORF and PEF unpack their uncompressed data; NEF reads only the /// first 128 rows (`vendor::nef::MASK_ROWS`), which both the probe and the full decode judge the masked /// columns from, so a compressed NEF probes in a few milliseconds instead of decoding its whole /// Huffman stream (issue #708). pub fn probe_info(bytes: &[u8]) -> Result { decode_with(bytes, Mode::Header).map(RawImage::into_info) } /// How much of a file a decoder reads. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Mode { /// Headers and samples. Full, /// Headers only: the returned image has no samples, and black/white levels measured from /// the samples are placeholders. Only [`RawImage::into_info`] may look at it. Header, } fn decode_with(bytes: &[u8], mode: Mode) -> Result { match probe(bytes).ok_or(RawError::NotRaw)? { RawFormat::Dng => dng::decode(bytes, mode).map(lift_clipped), RawFormat::Cr2 => vendor::cr2::decode(bytes, mode), RawFormat::Cr3 => vendor::cr3::decode(bytes, mode), RawFormat::Nef | RawFormat::Nrw => vendor::nef::decode(bytes, mode), RawFormat::Arw => vendor::arw::decode(bytes, mode), RawFormat::Raf => vendor::raf::decode(bytes, mode), RawFormat::Rw2 => vendor::rw2::decode(bytes, mode), RawFormat::Pef => vendor::pef::decode(bytes, mode), RawFormat::Orf => vendor::orf::decode(bytes, mode), RawFormat::CfaTiff => dng::decode_as(bytes, mode, RawFormat::CfaTiff).map(lift_clipped), RawFormat::Srw => vendor::srw::decode(bytes, mode), RawFormat::KodakDcs => vendor::kodak::decode(bytes, mode), RawFormat::Mrw => vendor::mrw::decode(bytes, mode), RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))), other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))), } } /// DNG writers state a white level the sensor may never reach; samples stuck at the real saturation point are /// raised to the white level so they count as clipped (see [`RawImage::lift_clipped_samples`]). fn lift_clipped(mut r: RawImage) -> RawImage { r.lift_clipped_samples(); r } /// A raw file's description without its samples (see [`probe_info`]). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RawInfo { pub format: RawFormat, /// Full sensor data dimensions (including masked borders). pub width: usize, pub height: usize, pub cpp: usize, pub cfa: Option, pub bits: u32, pub active_area: Rect, /// Default crop relative to the active area. pub crop: Rect, pub orientation: Orientation, pub color: ColorData, pub wb_multipliers: Option<[f32; 3]>, pub opcodes: OpcodeLists, pub metadata: Metadata, } impl RawInfo { /// Size of the developed image before orientation: the default crop, else the active area. pub fn developed_size(&self) -> (usize, usize) { let c = self.crop.clipped(self.active_area.width, self.active_area.height); if c.width > 1 && c.height > 1 { (c.width, c.height) } else { (self.active_area.width, self.active_area.height) } } } /// Sensor samples. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum RawData { U16(Vec), /// Floating-point DNGs (already linear, typically white = 1.0). F32(Vec), } impl RawData { pub fn len(&self) -> usize { match self { RawData::U16(v) => v.len(), RawData::F32(v) => v.len(), } } pub fn is_empty(&self) -> bool { self.len() == 0 } #[inline] pub fn get(&self, i: usize) -> f32 { match self { RawData::U16(v) => v[i] as f32, RawData::F32(v) => v[i], } } } /// Colour filter array pattern. Colour indices: 0 = red, 1 = green, 2 = blue. #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct Cfa { pub width: usize, pub height: usize, /// `width × height` colour indices, row-major, anchored at image pixel (0, 0). pub pattern: Vec, } impl Cfa { /// A 2×2 Bayer pattern from a string such as `"RGGB"`. pub fn bayer(s: &str) -> Option { let p: Vec = s .chars() .map(|c| { Some(match c { 'R' => 0, 'G' => 1, 'B' => 2, _ => return None, }) }) .collect::>()?; (p.len() == 4).then_some(Cfa { width: 2, height: 2, pattern: p }) } /// A 2×2 Bayer layout named in code (`"RGGB"`, `"BGGR"`, `"GRBG"`, `"GBRG"`). Only for literal /// names (tested below); anything else falls back to RGGB instead of failing. pub(crate) fn bayer_static(s: &'static str) -> Cfa { Cfa::bayer(s).unwrap_or(Cfa { width: 2, height: 2, pattern: vec![0, 1, 1, 2] }) } /// The Fujifilm X-Trans 6×6 layout (as commonly documented), anchored at (0, 0). pub fn xtrans() -> Cfa { let rows = ["GGRGGB", "GGBGGR", "BRGRBG", "GGBGGR", "GGRGGB", "RBGBRG"]; let pattern = rows .iter() .flat_map(|r| { r.chars().map(|c| match c { 'R' => 0, 'G' => 1, _ => 2, }) }) .collect(); Cfa { width: 6, height: 6, pattern } } #[inline] pub fn color_at(&self, x: usize, y: usize) -> u8 { self.pattern[(y % self.height) * self.width + (x % self.width)] } /// The same pattern anchored at `(dx, dy)` of the current anchor (e.g. after cropping to an active area). pub fn shifted(&self, dx: usize, dy: usize) -> Cfa { let pattern = (0..self.height).flat_map(|y| (0..self.width).map(move |x| (x, y))).map(|(x, y)| self.color_at(x + dx, y + dy)).collect(); Cfa { width: self.width, height: self.height, pattern } } pub fn is_bayer(&self) -> bool { if self.width != 2 || self.height != 2 { return false; } let mut c = [0; 3]; for &p in &self.pattern { if p > 2 { return false; } c[p as usize] += 1; } c == [1, 2, 1] } /// Pattern name like `"RGGB"` for 2×2 patterns. pub fn name(&self) -> String { self.pattern.iter().map(|&c| ['R', 'G', 'B', '?'][c.min(3) as usize]).collect() } fn valid(&self) -> bool { self.width > 0 && self.height > 0 && self.width <= 16 && self.height <= 16 && self.pattern.len() == self.width * self.height } } /// Integer rectangle (pixels). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct Rect { pub x: usize, pub y: usize, pub width: usize, pub height: usize, } impl Rect { pub fn new(x: usize, y: usize, width: usize, height: usize) -> Rect { Rect { x, y, width, height } } /// Clip to a `w × h` area. pub fn clipped(self, w: usize, h: usize) -> Rect { let x = self.x.min(w); let y = self.y.min(h); Rect { x, y, width: self.width.min(w - x), height: self.height.min(h - y) } } } /// Black level model (DNG `BlackLevelRepeatDim`, `BlackLevel`, `BlackLevelDeltaH/V`). The repeat pattern and /// the deltas are anchored at the active area's top-left corner. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct BlackLevel { pub repeat_rows: usize, pub repeat_cols: usize, /// `repeat_rows × repeat_cols × cpp` values. pub values: Vec, /// Per active-area column. pub delta_h: Vec, /// Per active-area row. pub delta_v: Vec, } impl Default for BlackLevel { fn default() -> Self { BlackLevel { repeat_rows: 1, repeat_cols: 1, values: vec![0.0], delta_h: vec![], delta_v: vec![] } } } impl BlackLevel { pub fn uniform(v: f32) -> BlackLevel { BlackLevel { values: vec![v], ..Default::default() } } /// Black level at active-area coordinates (x, y) for sample `s` of `cpp`. #[inline] pub fn at(&self, x: usize, y: usize, s: usize, cpp: usize) -> f32 { let (r, c) = (self.repeat_rows.max(1), self.repeat_cols.max(1)); let idx = ((y % r) * c + (x % c)) * cpp + s; let base = self.values.get(idx).or_else(|| self.values.first()).copied().unwrap_or(0.0); base + self.delta_h.get(x).copied().unwrap_or(0.0) + self.delta_v.get(y).copied().unwrap_or(0.0) } /// Maximum computed black of one sample plane over the active area (DNG 1.7.1, p. 99). /// Delta maxima must share the repeat-pattern phase of the base value: independent global /// maxima can describe a black level that occurs nowhere in the image. fn maximum(&self, width: usize, height: usize, s: usize, cpp: usize) -> f32 { fn phase_maxima(deltas: &[f32], len: usize, period: usize) -> Vec { if deltas.is_empty() { return vec![0.0; len.min(period)]; } let mut maxima = vec![f32::NEG_INFINITY; len.min(period)]; for i in 0..len { if let Some(v) = maxima.get_mut(i % period) { *v = v.max(deltas.get(i).copied().unwrap_or(0.0)); } } maxima } let (r, c) = (self.repeat_rows.max(1), self.repeat_cols.max(1)); let dx = phase_maxima(&self.delta_h, width, c); let dy = phase_maxima(&self.delta_v, height, r); let mut max = f32::NEG_INFINITY; for (y, &v) in dy.iter().enumerate() { for (x, &h) in dx.iter().enumerate() { let idx = y.saturating_mul(c).saturating_add(x).saturating_mul(cpp).saturating_add(s); let base = self.values.get(idx).or_else(|| self.values.first()).copied().unwrap_or(0.0); max = max.max(base + h + v); } } max } /// Mean of the repeat-pattern values (for display / heuristics). pub fn mean(&self) -> f32 { if self.values.is_empty() { 0.0 } else { self.values.iter().sum::() / self.values.len() as f32 } } } /// DNG colour tags (DNG 1.7 chapter 6). Matrices are 3×3 (three-colour cameras). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct ColorData { /// `CalibrationIlluminant1/2` (Exif LightSource codes; 0 = unknown). pub illuminant: [u16; 2], /// `ColorMatrix1/2`: XYZ → reference camera. pub color_matrix: [Option; 2], /// `ForwardMatrix1/2`: white-balanced camera → XYZ D50. pub forward_matrix: [Option; 2], /// `CameraCalibration1/2`. pub camera_calibration: [Option; 2], pub analog_balance: Option<[f64; 3]>, pub as_shot_neutral: Option<[f64; 3]>, pub as_shot_white_xy: Option, /// EV to add for a "normal" rendering (`BaselineExposure` + `BaselineExposureOffset`). pub baseline_exposure: f64, /// `BaselineSharpness`: sharpening relative to a reference camera (`None`: the DNG default, /// 1). Read and written back by DNG export; rendering doesn't use it yet. #[serde(default)] pub baseline_sharpness: Option, /// The file's own camera-profile look (`ProfileHueSatMap*`, `ProfileLookTable*`, /// `ProfileToneCurve`), applied by [`color`]'s users at render time, and its /// `ProfileGainTableMap*`, kept (a DNG export writes it back) and rendered only when a photo's /// "Camera local tone mapping" option asks for it (`lightcraft_pipeline::local_tone`). #[serde(default)] pub profile: profile::ProfileLook, /// The camera's white-balance presets at stated temperatures (Sony ARW), which give a raw without colour /// matrices a Kelvin scale ([`kelvin_presets`]). Not a DNG tag: nothing else reads it. #[serde(default)] pub kelvin_presets: Option, } /// A decoded raw image. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RawImage { pub format: RawFormat, /// Full sensor data dimensions (including masked borders). pub width: usize, pub height: usize, /// Samples per pixel: 1 = CFA or monochrome, 3 = linear RGB (LinearRaw / demosaiced DNG). pub cpp: usize, pub data: RawData, /// CFA pattern anchored at pixel (0, 0) of the full data; `None` for linear / monochrome data. pub cfa: Option, /// Significant bits of the stored samples (informational). pub bits: u32, pub black: BlackLevel, /// White (clip) level per sample (after linearization). pub white: Vec, /// Area of valid image data within the full data. pub active_area: Rect, /// Default crop relative to the active area. pub crop: Rect, pub orientation: Orientation, pub color: ColorData, /// As-shot white-balance multipliers from vendor maker notes (green = 1), when known (non-DNG). pub wb_multipliers: Option<[f32; 3]>, /// Whether a `LinearizationTable` was applied to `data`. pub linearized: bool, pub opcodes: OpcodeLists, pub metadata: Metadata, } /// Black-subtracted, white-normalised data covering the active area. #[derive(Clone, Debug, PartialEq)] pub struct Normalized { pub width: usize, pub height: usize, pub cpp: usize, /// Nominal range [0, 1] (values may exceed 1 above white or be negative from noise). pub data: Vec, /// CFA anchored at (0, 0) of this buffer. pub cfa: Option, } impl RawImage { /// The description without the samples (what [`probe_info`] returns for the same file). pub fn info(&self) -> RawInfo { RawInfo { format: self.format, width: self.width, height: self.height, cpp: self.cpp, cfa: self.cfa.clone(), bits: self.bits, active_area: self.active_area, crop: self.crop, orientation: self.orientation, color: self.color.clone(), wb_multipliers: self.wb_multipliers, opcodes: self.opcodes.clone(), metadata: self.metadata.clone(), } } /// [`Self::info`], dropping the samples. pub fn into_info(self) -> RawInfo { RawInfo { format: self.format, width: self.width, height: self.height, cpp: self.cpp, cfa: self.cfa, bits: self.bits, active_area: self.active_area, crop: self.crop, orientation: self.orientation, color: self.color, wb_multipliers: self.wb_multipliers, opcodes: self.opcodes, metadata: self.metadata, } } /// Validate what a decoder read in `mode` ([`Mode::Header`]: everything but the samples). pub(crate) fn validate_for(&self, mode: Mode) -> Result<()> { self.check(mode == Mode::Full) } /// Validate internal consistency (dimensions vs data length, CFA shape, rectangles). pub fn validate(&self) -> Result<()> { self.check(true) } fn check(&self, samples: bool) -> Result<()> { if self.width == 0 || self.height == 0 || !(1..=4).contains(&self.cpp) { return Err(RawError::Corrupt("bad dimensions".into())); } if samples && self.data.len() != self.width * self.height * self.cpp { return Err(RawError::Corrupt("data length mismatch".into())); } if let Some(c) = &self.cfa && (!c.valid() || c.pattern.iter().any(|&p| p > 2)) { return Err(RawError::Unsupported(format!("CFA pattern {}", c.name()))); } let a = self.active_area; if a.width == 0 || a.height == 0 || a.x + a.width > self.width || a.y + a.height > self.height { return Err(RawError::Corrupt("active area outside image".into())); } Ok(()) } /// White level for sample `s`. pub fn white_at(&self, s: usize) -> f32 { self.white.get(s).or_else(|| self.white.first()).copied().unwrap_or(65535.0) } /// Shared by full-resolution and binned development. DNG's scale uses the maximum computed /// black independently for each sample plane; vendor formats keep their existing scaling. pub(crate) fn normalization_scales(&self) -> Vec { let dng = matches!(self.format, RawFormat::Dng | RawFormat::CfaTiff); (0..self.cpp) .map(|s| { let black = if dng { self.black.maximum(self.active_area.width, self.active_area.height, s, self.cpp) } else { self.black.mean() }; let range = self.white_at(s) - black; if range.is_finite() && range > 0.0 && range.recip().is_finite() { range.recip() } else { 1.0 } }) .collect() } /// Black-subtract, white-scale and crop to the active area, applying `OpcodeList1` (on raw values) first and /// `OpcodeList2` (on normalised values) after. 1.0 = white level. pub fn normalized(&self) -> Result { self.validate()?; let (w, cpp) = (self.width, self.cpp); let a = self.active_area; // stage 1 (full image) opcodes need a mutable float copy only when present let stage1: Option> = if self.opcodes.list1.is_empty() { None } else { let mut v: Vec = (0..self.data.len()).map(|i| self.data.get(i)).collect(); opcodes::apply_list(&self.opcodes.list1, &mut v, self.width, self.height, cpp, self.cfa.as_ref(), 65535.0); Some(v) }; let src = |i: usize| match &stage1 { Some(v) => v[i], None => self.data.get(i), }; let scale = self.normalization_scales(); let mut out = vec![0f32; a.width * a.height * cpp]; let row_len = a.width * cpp; use rayon::prelude::*; out.par_chunks_mut(row_len).enumerate().for_each(|(y, row)| { let base = ((a.y + y) * w + a.x) * cpp; for x in 0..a.width { for s in 0..cpp { let v = src(base + x * cpp + s); let b = self.black.at(x, y, s, cpp); row[x * cpp + s] = (v - b) * scale[s]; } } }); let cfa = self.cfa.as_ref().map(|c| c.shifted(a.x, a.y)); opcodes::apply_list(&self.opcodes.list2, &mut out, a.width, a.height, cpp, cfa.as_ref(), 1.0); Ok(Normalized { width: a.width, height: a.height, cpp, data: out, cfa }) } /// Full "raw → camera RGB" path: normalise, demosaic with `method`, apply `OpcodeList3`, then the default crop. /// The result is camera RGB (not white balanced), white level = 1.0, not oriented. pub fn develop(&self, method: Method) -> Result { let n = self.normalized()?; let rgb = demosaic(&n, method); drop(n); Ok(self.finish_demosaiced(rgb)) } /// The last steps of [`Self::develop`] for camera RGB made some other way (a denoiser that demosaics too) from /// [`Self::normalized`]'s mosaic: apply `OpcodeList3`, then the default crop. pub fn finish_demosaiced(&self, mut rgb: Rgb32f) -> Rgb32f { opcodes::apply_list3(&self.opcodes.list3, &mut rgb); let c = self.develop_crop(rgb.width, rgb.height); if c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height { return rgb; } rgb.into_crop(c.x, c.y, c.width, c.height) } /// The part of a `width × height` demosaiced picture (the active area) that [`Self::develop`] keeps: the default /// crop, or all of it when there is none. pub fn develop_crop(&self, width: usize, height: usize) -> Rect { let c = self.crop.clipped(width, height); if c.width == 0 || c.height == 0 { Rect::new(0, 0, width, height) } else { c } } } #[cfg(test)] mod tests { use super::*; #[test] fn cfa_helpers() { let c = Cfa::bayer("RGGB").unwrap(); assert!(c.is_bayer()); assert_eq!(c.color_at(0, 0), 0); assert_eq!(c.color_at(3, 3), 2); assert_eq!(c.shifted(1, 0).name(), "GRBG"); assert_eq!(c.shifted(1, 1).name(), "BGGR"); assert!(Cfa::bayer("RGGX").is_none()); for name in ["RGGB", "BGGR", "GRBG", "GBRG"] { assert_eq!(Cfa::bayer_static(name).name(), name); } let x = Cfa::xtrans(); assert!(!x.is_bayer()); let greens = x.pattern.iter().filter(|&&p| p == 1).count(); assert_eq!(greens, 20); // every 3×3 block of the X-Trans layout has 5 greens for by in 0..2 { for bx in 0..2 { let g = (0..9).filter(|i| x.color_at(bx * 3 + i % 3, by * 3 + i / 3) == 1).count(); assert_eq!(g, 5); } } } #[test] fn a_picture_made_elsewhere_is_finished_like_develop_does() { let (w, h) = (12usize, 10usize); let raw = RawImage { format: RawFormat::Dng, width: w, height: h, cpp: 1, data: RawData::U16((0..w * h).map(|i| (100 + (i * 37) % 900) as u16).collect()), cfa: Cfa::bayer("RGGB"), bits: 12, black: BlackLevel::uniform(64.0), white: vec![1023.0], active_area: Rect::new(2, 2, 8, 6), crop: Rect::new(1, 1, 5, 4), orientation: Orientation::Normal, color: ColorData::default(), wb_multipliers: None, linearized: false, opcodes: OpcodeLists::default(), metadata: Metadata::default(), }; let direct = raw.develop(Method::Bilinear).unwrap(); let by_hand = raw.finish_demosaiced(demosaic(&raw.normalized().unwrap(), Method::Bilinear)); assert_eq!((direct.width, direct.height), (5, 4)); assert_eq!(direct, by_hand); // the crop is what develop keeps; no usable crop keeps everything assert_eq!(raw.develop_crop(8, 6), Rect::new(1, 1, 5, 4)); let mut whole = raw.clone(); whole.crop = Rect::default(); assert_eq!(whole.develop_crop(8, 6), Rect::new(0, 0, 8, 6)); assert_eq!(whole.develop(Method::Bilinear).unwrap().width, 8); // a crop that pokes out is clipped to the picture whole.crop = Rect::new(6, 4, 10, 10); assert_eq!(whole.develop_crop(8, 6), Rect::new(6, 4, 2, 2)); } #[test] fn black_levels() { let b = BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![1.0, 2.0, 3.0, 4.0], delta_h: vec![0.5, 0.0], delta_v: vec![0.0, 10.0] }; assert_eq!(b.at(0, 0, 0, 1), 1.5); assert_eq!(b.at(1, 1, 0, 1), 14.0); assert_eq!(b.at(3, 2, 0, 1), 2.0); assert_eq!(b.mean(), 2.5); assert_eq!(BlackLevel::uniform(7.0).at(9, 9, 0, 3), 7.0); } // --- TIFF shells around a private raw block (thumbnail-only IFD0) --- use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; /// An uncompressed 8-bit RGB image IFD. fn rgb_ifd(w: u32, h: u32) -> IfdBuilder { let mut ifd = IfdBuilder::new(); ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); ifd.set(t::PHOTOMETRIC, Value::Short(vec![2])); ifd.set(t::COMPRESSION, Value::Short(vec![1])); ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![90u8; (w * h * 3) as usize]] }); ifd } fn exif_size(w: u32, h: u32) -> IfdBuilder { IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h])) } fn write(chain: &[IfdBuilder]) -> Vec { TiffWriter::default().write(chain).unwrap() } const SHELL_REASON: &str = "raw image in a private block"; /// A bigger image in a `SubIFDs` child of a small IFD0 (the layout of an 8-bit-mosaic TIFF raw). #[test] fn thumbnail_with_a_bigger_sub_ifd_is_an_undecodable_raw() { let mut ifd0 = rgb_ifd(16, 12); ifd0.add_sub_ifd(rgb_ifd(64, 48)); let bytes = write(&[ifd0]); assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; assert!(why.contains("64x48") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}"); // a sub-IFD that is not much bigger (a pyramid level) does not count let mut ifd0 = rgb_ifd(16, 12); ifd0.add_sub_ifd(rgb_ifd(32, 24)); assert_eq!(probe(&write(&[ifd0])), None); } /// Exif says the picture is far bigger than IFD0 and the file holds a block that could carry it. #[test] fn thumbnail_with_a_private_block_behind_the_exif_size_is_an_undecodable_raw() { let mut ifd0 = rgb_ifd(16, 12); ifd0.set_child(t::EXIF_IFD, exif_size(400, 300)); let mut bytes = write(&[ifd0.clone()]); // no private block: a downsized copy that kept its old Exif size is an ordinary image assert_eq!(probe(&bytes), None); // 400 x 300 pixels at one bit each is 15000 bytes: 14000 is not enough, 16000 is bytes.extend(std::iter::repeat_n(7u8, 14_000)); assert_eq!(probe(&bytes), None); bytes.extend(std::iter::repeat_n(7u8, 2_000)); assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; assert!(why.contains("400x300") && why.contains("16x12") && why.contains(SHELL_REASON), "{why}"); // truncating it never panics for n in (0..bytes.len()).step_by(37) { let _ = probe(&bytes[..n]); let _ = decode(&bytes[..n]); } } /// Ordinary TIFFs stay images: the full picture first, equal Exif size, or later pages that are bigger. #[test] fn ordinary_tiffs_are_not_shells() { let padded = |chain: &[IfdBuilder]| { let mut b = write(chain); b.extend(std::iter::repeat_n(0u8, 100_000)); b }; let mut full = rgb_ifd(400, 300); full.set_child(t::EXIF_IFD, exif_size(400, 300)); assert_eq!(probe(&padded(&[full])), None); // the Exif size is much bigger than IFD0, with spare bytes, but IFD0 is not tiny next to it let mut near = rgb_ifd(120, 90); near.set_child(t::EXIF_IFD, exif_size(400, 300)); assert_eq!(probe(&padded(&[near])), None); // a multi-page TIFF whose first page is small assert_eq!(probe(&padded(&[rgb_ifd(16, 12), rgb_ifd(400, 300)])), None); // no dimensions at all assert_eq!(probe(&padded(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("X".into()))])), None); // raw evidence without a CFA photometric: Pentax's Exif `CFAPattern` (uncompressed / PackBits PEFs) and // Samsung's private compressions (here 32773, PackBits elsewhere: NX1, NX500) let mut pef = rgb_ifd(400, 300); pef.set(t::MAKE, Value::Ascii("PENTAX Corporation".into())); let mut exif = exif_size(400, 300); exif.set(vendor::EXIF_CFA_PATTERN, Value::Undefined(vec![0, 2, 0, 2, 0, 1, 1, 2])); pef.set_child(t::EXIF_IFD, exif); assert_eq!(probe(&padded(&[pef])), Some(RawFormat::Pef)); let mut srw = rgb_ifd(400, 300); srw.set(t::MAKE, Value::Ascii("SAMSUNG".into())); srw.set(t::COMPRESSION, Value::Short(vec![32773])); assert_eq!(probe(&padded(&[srw])), Some(RawFormat::Srw)); // camera-authored or exported TIFFs preserving camera Make tags remain ordinary images for make in ["SONY", "NIKON CORPORATION", "PENTAX", "RICOH", "SAMSUNG"] { let mut cam = rgb_ifd(400, 300); cam.set(t::MAKE, Value::Ascii(make.into())); cam.set_child(t::EXIF_IFD, exif_size(400, 300)); assert_eq!(probe(&padded(&[cam])), None, "{make} TIFF should not be probed as raw"); } } /// #702: two Sony layouts carry no CFA IFD, so the raw evidence #281 asks of Sony files has to /// include them: SRF (an RGB-looking IFD0 of 14-bit samples) and the DSLR-A100 (raw data addressed /// by `SubIFDs`). An 8-bit TIFF from the same bodies, or a SubIFDs TIFF from another Sony model, /// stays an ordinary image. #[test] fn sony_raws_without_a_cfa_ifd_are_still_raws() { let sony = |model: &str, bits: u16, compression: u16, sub_ifd: Option| { let mut ifd = rgb_ifd(400, 300); ifd.set(t::MAKE, Value::Ascii("SONY".into())); ifd.set(t::MODEL, Value::Ascii(model.into())); ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; 3])); ifd.set(t::COMPRESSION, Value::Short(vec![compression])); if let Some(child) = sub_ifd { ifd.set_child(t::SUB_IFDS, child); } probe(&write(&[ifd])) }; let raw_block = || Some(rgb_ifd(64, 48)); // stands in for the A100's raw-data pointer assert_eq!(sony("DSC-F828", 14, 1, None), Some(RawFormat::Arw), "SRF"); assert_eq!(sony("DSC-F828", 12, 1, None), Some(RawFormat::Arw), "12-bit samples count too"); assert_eq!(sony("DSLR-A100", 8, 6, raw_block()), Some(RawFormat::Arw), "A100: old-JPEG IFD0 and SubIFDs"); assert_eq!(sony("DSC-F828", 8, 1, None), None, "an 8-bit TIFF"); assert_eq!(sony("DSC-F828", 16, 1, None), None, "a 16-bit TIFF"); // TIFFs exported from A100 photos, with a thumbnail in SubIFDs (Codex review of #702) assert_eq!(sony("DSLR-A100", 8, 1, Some(rgb_ifd(64, 48))), None, "an 8-bit A100 TIFF with a SubIFDs thumbnail"); assert_eq!(sony("DSLR-A100", 16, 8, Some(rgb_ifd(64, 48))), None, "a 16-bit A100 TIFF with a SubIFDs thumbnail"); assert_eq!(sony("ILCE-7M4", 8, 6, raw_block()), None, "the A100's layout under another model"); } // --- containers that are recognised but not decoded, with a preview --- #[test] fn private_containers_are_recognised_by_their_magic() { assert_eq!(probe(b"II\x1a\0\0\0HEAPCCDR\0\0"), Some(RawFormat::Crw)); assert_eq!(probe(b"\0MRM\0\x01\0\0"), Some(RawFormat::Mrw)); assert_eq!(probe(b"FOVb\x02\0\x02\0"), Some(RawFormat::X3f)); for f in [RawFormat::Crw, RawFormat::X3f] { assert!(!f.is_supported()); } assert!(matches!(decode(b"FOVb\x02\0\x02\0"), Err(RawError::Unsupported(w)) if w.contains("X3f"))); // too short for the CIFF signature assert_eq!(probe(b"II\x1a\0\0\0HEAP"), None); } /// An IFD0 with a JPEG pointer but no `ImageWidth` / `ImageLength` (the Kodak KDC layout) is a raw /// container with a preview; a TIFF reader would call it malformed. #[test] fn ifd0_without_dimensions_but_with_a_jpeg_is_a_preview_container() { let jpeg: Vec = [&[0xffu8, 0xd8, 0xff, 0xc0, 0, 0x0b, 8, 0, 1, 0, 1, 1, 1, 0x11, 0][..], &[0x55; 40], &[0xff, 0xd9]].concat(); let off = 8 + 2 + 24 + 4; let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec(); for (tag, v) in [(513u16, off as u32), (514, jpeg.len() as u32)] { f.extend_from_slice(&tag.to_le_bytes()); f.extend_from_slice(&[4, 0, 1, 0, 0, 0]); f.extend_from_slice(&v.to_le_bytes()); } f.extend_from_slice(&[0, 0, 0, 0]); f.extend_from_slice(&jpeg); assert_eq!(probe(&f), Some(RawFormat::OtherTiff)); assert!(matches!(decode(&f), Err(RawError::Unsupported(_)))); assert_eq!(embedded_preview(&f), Some(jpeg)); // no pointer and no sub-IFDs: just a broken TIFF let g = write(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("KODAK".into()))]); assert_eq!(probe(&g), None); } /// A GoPro GPR (a DNG with VC-5 coded raw data, Compression 9) is refused by the full decode with /// a reason that names the format, whatever its tile bytes hold; the header probe still /// describes it (so it imports). #[test] fn gopro_vc5_dng_is_unsupported_with_a_clear_reason() { let mut ifd = IfdBuilder::new(); ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); ifd.set(t::IMAGE_WIDTH, Value::Long(vec![32])); ifd.set(t::IMAGE_LENGTH, Value::Long(vec![16])); ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); ifd.set(t::PHOTOMETRIC, Value::Short(vec![32803])); ifd.set(t::COMPRESSION, Value::Short(vec![9])); ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); ifd.set_image(ImageData::Tiles { tile_width: 32, tile_height: 16, tiles: vec![vec![0x5au8; 400]] }); let bytes = write(&[ifd]); assert_eq!(probe(&bytes), Some(RawFormat::Dng)); let want = "DNG compression 9 (GoPro VC-5) is not decoded yet"; let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported from decode") }; assert_eq!(why, want); let info = probe_info(&bytes).expect("header probe describes the file"); assert_eq!((info.width, info.height), (32, 16)); } /// Compression 99 (not a registered TIFF value; Leaf MOS tiles) marks a raw even without a CFA tag. #[test] fn private_compression_99_is_a_raw() { let mut ifd = rgb_ifd(16, 12); ifd.set(t::COMPRESSION, Value::Short(vec![99])); assert_eq!(probe(&write(&[ifd])), Some(RawFormat::OtherTiff)); } /// Issue #1275: an uncompressed Leaf MOS (Aptus 22) is a full-size 16-bit grey IFD0 with no Make tag; only Leaf's /// data block (`0x8606`) says it is a raw. It must not import as a greyscale image of its mosaic. The same IFD /// without the block, or an RGB image carrying it, stays an ordinary image. #[test] fn uncompressed_leaf_mos_is_a_raw() { let mosaic = |leaf_data: bool| { let mut ifd = IfdBuilder::new(); ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); ifd.set(t::IMAGE_WIDTH, Value::Long(vec![16])); ifd.set(t::IMAGE_LENGTH, Value::Long(vec![12])); ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::BLACK_IS_ZERO])); ifd.set(t::COMPRESSION, Value::Short(vec![1])); ifd.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x40; 16 * 12 * 2]] }); if leaf_data { ifd.set(LEAF_DATA, Value::Undefined(b"PKTS\0\0\0\x01".to_vec())); } ifd }; let bytes = TiffWriter::new(lightcraft_tiff::ByteOrder::Big, false).write(&[mosaic(true)]).unwrap(); assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); assert!(matches!(decode(&bytes), Err(RawError::Unsupported(_)))); assert!(matches!(probe_info(&bytes), Err(RawError::Unsupported(_)))); assert_eq!(probe(&write(&[mosaic(false)])), None, "a 16-bit grey TIFF"); let mut rgb = rgb_ifd(16, 12); rgb.set(LEAF_DATA, Value::Undefined(b"PKTS\0\0\0\x01".to_vec())); assert_eq!(probe(&write(&[rgb])), None, "a rendered RGB image that kept the block"); } /// Issue #1275: a Hasselblad Lunar is a Sony NEX-7 ARW under Make HASSELBLAD. Sony's raw coding (compression 32767 /// or the tone-curve tag `0x7010`) sends it to the ARW reader; a Hasselblad file without it does not go there. #[test] fn hasselblad_make_with_sony_raw_coding_is_arw() { let file = |make: &str, compression: u16, tone_curve: bool| { let mut raw = IfdBuilder::new(); raw.set(t::IMAGE_WIDTH, Value::Long(vec![64])); raw.set(t::IMAGE_LENGTH, Value::Long(vec![2])); raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); raw.set(t::COMPRESSION, Value::Short(vec![compression])); raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); if tone_curve { raw.set(0x7010, Value::Short(vec![8000, 10400, 12900, 14100])); } raw.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![0; 128]] }); let mut ifd0 = rgb_ifd(4, 3); ifd0.set(t::MAKE, Value::Ascii(make.into())); ifd0.set(t::MODEL, Value::Ascii("Lunar".into())); ifd0.add_sub_ifd(raw); write(&[ifd0]) }; assert_eq!(probe(&file("HASSELBLAD", 32767, false)), Some(RawFormat::Arw)); assert_eq!(probe(&file("Hasselblad", 1, true)), Some(RawFormat::Arw)); let lunar = decode(&file("HASSELBLAD", 32767, true)).unwrap(); assert_eq!((lunar.format, lunar.width, lunar.height, lunar.bits), (RawFormat::Arw, 64, 2, 14)); // a Hasselblad CFA image in another coding is not an ARW assert_ne!(probe(&file("HASSELBLAD", 1, false)), Some(RawFormat::Arw)); assert_ne!(probe(&file("HASSELBLAD", 7, false)), Some(RawFormat::Arw)); } // --- TIFF raws that describe their raw IFD the way DNG does but carry no DNG version tag --- /// 8 x 6 samples, 16 bits, value `1000 + 100 y + x`. fn cfa_samples() -> Vec { (0..48u16).map(|i| 1000 + 100 * (i / 8) + i % 8).collect() } /// A small raw in the layout of the Hasselblad 3FR/FFF family: IFD0 is a reduced RGB image with the /// colour tags, a SubIFD holds the full-resolution CFA image (no `CFAPattern`, DNG-style levels and /// crop). `strip` is the CFA image's data and `compression` its coding. fn plain_cfa_tiff(compression: u16, strip: Vec, pattern: Option<[u8; 4]>) -> Vec { let mut raw = IfdBuilder::new(); raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); raw.set(t::IMAGE_LENGTH, Value::Long(vec![6])); raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); raw.set(t::COMPRESSION, Value::Short(vec![compression])); raw.set(t::BLACK_LEVEL, Value::Rational(vec![(256, 1)])); raw.set(t::WHITE_LEVEL, Value::Long(vec![60000])); raw.set(t::DEFAULT_CROP_ORIGIN, Value::Short(vec![2, 2])); raw.set(t::DEFAULT_CROP_SIZE, Value::Short(vec![4, 2])); if let Some(p) = pattern { raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); raw.set(t::CFA_PATTERN_EP, Value::Byte(p.to_vec())); } raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![strip] }); let mut ifd0 = rgb_ifd(4, 3); ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into())); ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(5, 10), (-1, 10), (0, 10), (-5, 10), (12, 10), (3, 10), (-1, 10), (2, 10), (6, 10)])); ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(2, 5), (1, 1), (3, 5)])); ifd0.add_sub_ifd(raw); write(&[ifd0]) } fn le_words(v: &[u16]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } /// Known answer: samples, black and white level, crop and colour come from the file's own tags; the /// layout it does not state is RGGB. #[test] fn uncompressed_cfa_tiff_without_a_dng_version_decodes() { let bytes = plain_cfa_tiff(1, le_words(&cfa_samples()), None); assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); let r = decode(&bytes).unwrap(); assert_eq!((r.format, r.width, r.height, r.bits), (RawFormat::CfaTiff, 8, 6, 16)); assert_eq!(r.data, RawData::U16(cfa_samples())); assert_eq!(r.cfa.as_ref().unwrap().name(), "RGGB"); assert_eq!((r.black.mean(), r.white.clone()), (256.0, vec![60000.0])); assert_eq!(r.crop, Rect::new(2, 2, 4, 2)); assert!(color::has_matrix(&r.color)); assert_eq!(r.color.as_shot_neutral, Some([0.4, 1.0, 0.6])); // headers only: the same description without samples let i = probe_info(&bytes).unwrap(); assert_eq!((i.format, i.cfa.as_ref().map(Cfa::name), i.crop), (RawFormat::CfaTiff, Some("RGGB".to_string()), Rect::new(2, 2, 4, 2))); } /// Without DNG's white-balance tag the file is left alone (a Kodak or Sinar TIFF with a CFA IFD, say). #[test] fn cfa_tiff_without_a_white_balance_tag_is_not_claimed() { let mut raw = IfdBuilder::new(); raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); raw.set(t::IMAGE_LENGTH, Value::Long(vec![6])); raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); raw.set(t::COMPRESSION, Value::Short(vec![1])); raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![le_words(&cfa_samples())] }); let mut ifd0 = rgb_ifd(4, 3); ifd0.add_sub_ifd(raw); assert_eq!(probe(&write(&[ifd0])), Some(RawFormat::OtherTiff)); } /// A stated `CFAPattern` wins over the RGGB default. #[test] fn cfa_tiff_keeps_a_stated_pattern() { let bytes = plain_cfa_tiff(1, le_words(&cfa_samples()), Some([2, 1, 1, 0])); assert_eq!(decode(&bytes).unwrap().cfa.unwrap().name(), "BGGR"); } /// The lossless-JPEG coding of the same layout goes through the lossless decoder (one strip for the /// whole image, as the 3FR/FFF files have it). #[test] fn lossless_jpeg_cfa_tiff_decodes() { let strip = ljpeg::encode(&cfa_samples(), 8, 6, 1, 16, 1, 0); let bytes = plain_cfa_tiff(7, strip, None); assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); let r = decode(&bytes).unwrap(); assert_eq!(r.data, RawData::U16(cfa_samples())); assert_eq!(r.cfa.unwrap().name(), "RGGB"); } /// Hasselblad's own lossless-JPEG coding (selection value 8, as in FFF and most compressed 3FR files) of the /// same layout decodes: the file is read like the uncompressed one. #[test] fn selection_value_8_cfa_tiff_decodes() { let strip = ljpeg::tests::encode_hasselblad(&cfa_samples(), 8, 6); let bytes = plain_cfa_tiff(7, strip, None); assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); let r = decode(&bytes).unwrap(); assert_eq!((r.format, r.width, r.height), (RawFormat::CfaTiff, 8, 6)); assert_eq!(r.data, RawData::U16(cfa_samples())); assert_eq!(r.cfa.unwrap().name(), "RGGB"); assert_eq!(probe_info(&bytes).unwrap().format, RawFormat::CfaTiff); } /// A lossless-JPEG scan whose predictor selection value is neither 0 to 7 (T.81) nor 8 (Hasselblad) is not a /// coding the lossless decoder knows: the file stays an undecodable raw, with that as the reason, and keeps the /// stand-in preview an unsupported container gets. #[test] fn cfa_tiff_with_an_unknown_predictor_stays_undecodable() { let mut strip = ljpeg::encode(&cfa_samples(), 8, 6, 1, 16, 1, 0); let sos = strip.windows(2).position(|w| w == [0xff, 0xda]).unwrap(); let ss = sos + 2 + 2 + 1 + 2; assert_eq!(strip[ss], 1, "selection value byte"); strip[ss] = 9; assert!(matches!(ljpeg::decode(&strip, 1 << 20), Err(RawError::Unsupported(w)) if w.contains("selection value 9"))); let bytes = plain_cfa_tiff(7, strip, None); assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); assert!(!RawFormat::OtherTiff.is_supported()); let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; assert!(why.contains("lossless JPEG") && why.contains("selection value 9"), "{why}"); } /// Other codings of a CFA IFD (here Deflate) are not claimed by the plain reader. #[test] fn cfa_tiff_with_another_coding_is_not_claimed() { let bytes = plain_cfa_tiff(8, vec![0; 96], None); assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); } /// A Samsung-branded body whose maker note has a Pentax layout is read by the Pentax reader; another /// Samsung file stays with the Samsung format. #[test] fn samsung_make_with_a_pentax_maker_note_is_pef() { let with_note = |note: &[u8], cfa: bool| { let mut ifd = rgb_ifd(16, 12); if cfa { ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); } ifd.set(t::MAKE, Value::Ascii("SAMSUNG TECHWIN".into())); let mut exif = IfdBuilder::new(); exif.set(t::MAKER_NOTE, Value::Undefined(note.to_vec())); ifd.set_child(t::EXIF_IFD, exif); write(&[ifd]) }; assert_eq!(probe(&with_note(b"AOC\0MM\0\x01\0\0\0\0\0\0", true)), Some(RawFormat::Pef)); assert_eq!(probe(&with_note(b"PENTAX \0MM\0\0\0\0\0\0\0", true)), Some(RawFormat::Pef)); assert_eq!(probe(&with_note(b"STMN100\0\0\0\0\0\0\0\0\0", true)), Some(RawFormat::Srw)); // without raw evidence (an RGB image from such a body) neither reader claims it (#281) assert_eq!(probe(&with_note(b"AOC\0MM\0\x01\0\0\0\0\0\0", false)), None); assert_eq!(probe(&with_note(b"STMN100\0\0\0\0\0\0\0\0\0", false)), None); } #[test] fn normalization_scales_fall_back_for_empty_or_non_finite_ranges() { let mut raw = RawImage { format: RawFormat::Dng, width: 1, height: 1, cpp: 1, data: RawData::U16(vec![0]), cfa: None, bits: 16, black: BlackLevel::default(), white: vec![1024.0], active_area: Rect::new(0, 0, 1, 1), crop: Rect::new(0, 0, 1, 1), orientation: Orientation::Rotate90, color: ColorData::default(), wb_multipliers: None, linearized: false, opcodes: OpcodeLists::default(), metadata: Metadata::default(), }; assert_eq!(raw.normalization_scales(), vec![1.0 / 1024.0]); for (width, height) in [(0, 1), (1, 0), (0, 0)] { raw.active_area = Rect::new(0, 0, width, height); assert_eq!(raw.normalization_scales(), vec![1.0], "empty area {width}×{height}"); assert!(raw.normalized().is_err()); } raw.active_area = Rect::new(0, 0, 1, 1); for white in [f32::INFINITY, f32::NEG_INFINITY, f32::NAN, 0.0, f32::from_bits(1)] { raw.white = vec![white]; assert_eq!(raw.normalization_scales(), vec![1.0], "white {white}"); } } #[test] fn probe_rejects_junk() { assert_eq!(probe(b""), None); assert_eq!(probe(b"hello world, not a raw"), None); assert_eq!(probe(b"\0\0\0\x18ftypcrx \0\0\0\x01"), Some(RawFormat::Cr3)); assert_eq!(probe(b"FUJIFILMCCD-RAW 0201"), Some(RawFormat::Raf)); assert!(decode(b"FUJIFILMCCD-RAW 0201").is_err()); assert_eq!(decode(b"junk"), Err(RawError::NotRaw)); } }