From feb941cc199785743f24762c43553899162c2d74 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 16:23:13 -0500 Subject: [PATCH] craft-raw: Hasselblad 3FR and FFF (feature hasselblad) (#867) * craft-raw: the file-format layer of camera raw files, with DNG and CRW A new crate for #587 and #778: bytes in, the sensor data out as a neutral RawImage (samples, CFA, levels, active area, crop, orientation, colour tags, opcode lists, profile tables), and back for DNG. One Cargo feature per format: dng (read and write), jxl (JPEG XL DNG), crw (through craft-crw). The base is LightCraft's crates/raw at 5ff567a4: dng, dngwrite, tiffraw, unpack, ljpeg, jxl, semantic, preview, the parsing half of opcodes, profile and gaintable, the model and probe. Demosaicing and everything after it stay with the develop pipeline. Ported from PhotoCraft's and FilmCraft's DNG readers, each with a test: the default crop's per-tag defaults, an unusable crop falling back to the active area, non-finite white levels, the white default of a linearized image, the AsShotWhiteXY range, 8-bit-only lossy JPEG, lossy tiles that must cover their part of the image, and the CinemaDNG frame tags. * craft-raw: Hasselblad 3FR and FFF (feature hasselblad) RawFormat::CfaTiff decodes: the DNG-layout reader plus what these files add. Uncompressed data or Hasselblad's lossless JPEG (selection value 8), RGGB when no CFAPattern is stated, an FFF's colour matrix from the 3FR header it embeds, and the 8-bit RGB preview handed on as a baseline TIFF. Moved from LightCraft's vendor/hasselblad.rs at 5ff567a4 with its tests. Ported from PhotoCraft's reader: four BlackLevel values without a BlackLevelRepeatDim are one per CFA site in a file that is not a DNG. --- NOTICE | 8 +- README.md | 4 +- crates/raw/Cargo.toml | 6 +- crates/raw/README.md | 9 +- crates/raw/src/dng.rs | 7 + crates/raw/src/hasselblad.rs | 300 +++++++++++++++++++++++++++++++++++ crates/raw/src/lib.rs | 9 +- crates/raw/src/preview.rs | 9 +- crates/raw/src/tests.rs | 171 ++++++++++++++++++++ 9 files changed, 512 insertions(+), 11 deletions(-) create mode 100644 crates/raw/src/hasselblad.rs diff --git a/NOTICE b/NOTICE index 152dce0aa..3fb688500 100644 --- a/NOTICE +++ b/NOTICE @@ -91,12 +91,14 @@ Source provenance crates/raw (craft-raw) is moved from LightCraft, with behaviour ported from two other readers: - LightCraft, crates/raw (src/lib.rs, dng.rs, dngwrite.rs, tiffraw.rs, unpack.rs, ljpeg.rs, jxl.rs, - opcodes.rs, profile.rs, gaintable.rs, semantic.rs, preview.rs, vendor/crw.rs and their tests), + opcodes.rs, profile.rs, gaintable.rs, semantic.rs, preview.rs, vendor/crw.rs, vendor/hasselblad.rs + and their tests), revision 5ff567a4a2e067bef1b6bce152c11e7f727ccbaf. https://github.com/storytold/lightcraft Copyright (c) 2026 ArtCraft Team and the LightCraft contributors. -- PhotoCraft, crates/raw/src/dng.rs and tests/it/dng.rs (default-crop defaults, white-level - fallbacks, the 8-bit rule for lossy JPEG), revision c1b22bdaae2a1359ba3d164eca4d4b0c7378b547. +- PhotoCraft, crates/raw/src/dng.rs, hasselblad.rs and tests/it/dng.rs (default-crop defaults, + white-level fallbacks, the 8-bit rule for lossy JPEG, Hasselblad's per-site black levels), + revision c1b22bdaae2a1359ba3d164eca4d4b0c7378b547. https://github.com/storytold/photocraft Copyright (c) 2026 ArtCraft Team and the PhotoCraft contributors. - FilmCraft, crates/dng/src/lib.rs (the CinemaDNG frame tags and time code), revision diff --git a/README.md b/README.md index 7808a13a8..508c5ecb9 100644 --- a/README.md +++ b/README.md @@ -87,8 +87,8 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " = levels(t::BLACK_LEVEL).f64s(t::BLACK_LEVEL).unwrap_or_default().into_iter().map(level).collect(); + // A TIFF that is not a DNG may state four black levels without `BlackLevelRepeatDim`: one per site of its 2 × 2 + // CFA, row-major (PhotoCraft's reading of Hasselblad files). In a DNG the count has to match the repeat. + let (br, bc) = if format != RawFormat::Dng && cpp == 1 && values.len() == 4 && !levels(t::BLACK_LEVEL).contains(t::BLACK_LEVEL_REPEAT_DIM) { + (2, 2) + } else { + (br, bc) + }; if values.len() != br * bc * cpp { values = vec![values.first().copied().unwrap_or(0.0)]; } diff --git a/crates/raw/src/hasselblad.rs b/crates/raw/src/hasselblad.rs new file mode 100644 index 000000000..83f759f74 --- /dev/null +++ b/crates/raw/src/hasselblad.rs @@ -0,0 +1,300 @@ +//! Hasselblad 3FR and FFF raws ([`crate::RawFormat::CfaTiff`]). +//! +//! Sources: TIFF 6.0 (container), the DNG Specification (the tags these files borrow) and black-box analysis, done +//! for LightCraft, of a CC0 H4D-40 FFF from raw.pixls.us (931, "Hasselblad - H4D-40 - 16bit (4:3).fff"); every +//! number below was measured on it. PhotoCraft's reader measured the same layout independently on an X2D II 100C +//! (red at the data origin, against the camera's embedded JPEG). +//! +//! - Both are TIFFs without a DNG version tag that describe their raw image with DNG's tags: a full-resolution +//! CFA sub-IFD with `BlackLevel`, `WhiteLevel`, `DefaultCropOrigin` / `DefaultCropSize`, and `AsShotNeutral`. +//! [`crate::dng::decode_as`] reads all of that; this module adds what that reader does not know about. +//! - The raw image is uncompressed (X1D, X1D II, X2D, CFV 100C) or Hasselblad's lossless JPEG with predictor +//! selection value 8 ([`crate::ljpeg::decode_hasselblad`]); the H4D-40 FFF's 7410 x 5586 strip is the latter. +//! - No `CFAPattern`. The layout is RGGB from the sensor origin: averaged over the 6 x 6 sensor blocks behind each +//! pixel of the file's 1217 x 913 RGB IFD0 image (the default crop is exactly six times its size), the sites at +//! (0, 0) correlate 0.976 with its red and 0.726 with its blue, the sites at (1, 1) 0.896 with blue and 0.847 +//! with red, and the two green sites' diagonal differences are half those of the other pair. +//! - An FFF is a 3FR converted on the desktop. It has no colour matrix of its own: the camera's matrix stays in +//! the original 3FR header, which the FFF carries whole as a little-endian TIFF in the private IFD0 tag +//! `0xC51B` (4096 bytes in the sample: an IFD0 with Make, Model, `ColorMatrix1` and `AsShotNeutral`, a +//! `SubIFDs` pair for the raw and preview images, whose data offsets point into the old 3FR and are not read). +//! [`decode`] takes the matrices from there when the outer file has none. The white balance stays the outer +//! file's, which is the one the converter last saved. +//! - No JPEG preview. IFD0 is an uncompressed 8-bit RGB image (1217 x 913 in the H4D-40 FFF, IFD1 pointing at the +//! same strip; 218 x 218 in the CFV 3FR, under the file's own `Orientation`); the FFF also has a 16-bit RGB +//! copy in a `SubIFDs` child, with a `TransferFunction` whose meaning is not known, so it is not used. +//! [`rgb_preview`] hands the largest 8-bit one on as a small baseline TIFF. +use crate::dng; +use crate::{ColorData, Limits, Mode, RawFormat, RawImage, Result}; +use craft_tiff::image::{Layout, chunk_bytes}; +use craft_tiff::{ByteOrder, IfdBuilder, ImageData, ParseOptions, Tiff, TiffWriter, Value, tags as t}; + +/// Private IFD0 tag holding the original 3FR header (a complete TIFF) inside an FFF. +pub(crate) const ORIGINAL_HEADER: u16 = 0xc51b; + +/// Upper bound on the embedded header's size: the sample's is 4 KiB; a larger block is not a header. +const MAX_HEADER: usize = 1 << 20; + +/// Decode a 3FR / FFF (or another TIFF raw in the DNG layout, see [`dng::is_plain_cfa_tiff`]). +pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result { + let mut img = dng::decode_as(bytes, mode, RawFormat::CfaTiff, limits)?; + if !has_matrix(&img.color) + && let Some(header) = original_header_color(bytes) + { + adopt_matrices(&mut img.color, &header); + } + Ok(img) +} + +/// The colour tags of the original 3FR header an FFF embeds in [`ORIGINAL_HEADER`], when it has a colour matrix. +fn original_header_color(bytes: &[u8]) -> Option { + let outer = Tiff::parse(bytes).ok()?; + let block = outer.ifds.first()?.bytes(ORIGINAL_HEADER)?; + if block.len() > MAX_HEADER { + return None; + } + let opts = ParseOptions { max_ifds: 16, max_depth: 2, ..Default::default() }; + let header = Tiff::parse_with(block, &opts).ok()?; + let ifd0 = header.ifds.first()?; + let raw = dng::raw_ifd(&header).unwrap_or(ifd0); + let color = dng::color_data(ifd0, raw, header.order); + has_matrix(&color).then_some(color) +} + +/// Whether the colour tags hold a camera matrix. +fn has_matrix(color: &ColorData) -> bool { + color.color_matrix.iter().any(Option::is_some) +} + +/// Upper bound on a preview's pixel bytes (8-bit RGB): 64 MP, far above the 1.1 MP of the largest one seen. +const MAX_PREVIEW_BYTES: usize = (64 << 20) * 3; + +/// The file's largest uncompressed 8-bit RGB image (chunky, in strips), as a little-endian baseline TIFF holding +/// just those pixels, which any TIFF reader decodes. No `Orientation` tag is written: the viewer +/// applies the raw file's, which is the one that stands over this image in IFD0. +pub(crate) fn rgb_preview(tiff: &Tiff, bytes: &[u8]) -> Option> { + let info = tiff + .all_ifds() + .into_iter() + .filter_map(|i| i.image().ok()) + .filter(|i| { + i.photometric == t::photometric::RGB + && i.compression == t::compression::NONE + && i.samples_per_pixel == 3 + && i.bits_per_sample.iter().all(|&b| b == 8) + && i.planar == 1 + && i.sample_format == 1 + && matches!(i.layout, Layout::Strips { .. }) + && i.width > 0 + && i.height > 0 + }) + .max_by_key(|i| u64::from(i.width) * u64::from(i.height))?; + let (w, h) = (info.width as usize, info.height as usize); + let size = w.checked_mul(h)?.checked_mul(3)?; + if size > MAX_PREVIEW_BYTES { + return None; + } + let mut pixels = Vec::with_capacity(size); + for chunk in info.chunks(bytes.len() as u64) { + pixels.extend_from_slice(chunk_bytes(bytes, &chunk)?); + if pixels.len() >= size { + break; + } + } + // a short last strip is a truncated file: no preview rather than a picture with a black band + if pixels.len() < size { + return None; + } + pixels.truncate(size); + let mut ifd = IfdBuilder::new(); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![info.width])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![info.height])); + 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![t::photometric::RGB])); + ifd.set(t::COMPRESSION, Value::Short(vec![t::compression::NONE])); + ifd.set(t::PLANAR_CONFIGURATION, Value::Short(vec![1])); + ifd.set_image(ImageData::Strips { rows_per_strip: info.height, strips: vec![pixels] }); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).ok() +} + +/// Take the camera matrices (and the illuminants they belong to) of `from`; the rest of `color` stays. +fn adopt_matrices(color: &mut ColorData, from: &ColorData) { + color.illuminant = from.illuminant; + color.color_matrix = from.color_matrix; + color.forward_matrix = from.forward_matrix; + color.camera_calibration = from.camera_calibration; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RawData, RawError, decode as decode_file, probe, probe_info}; + use craft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + + const MATRIX: [(i32, i32); 9] = + [(686, 1000), (-196, 1000), (-32, 1000), (-437, 1000), (1228, 1000), (235, 1000), (-165, 1000), (317, 1000), (675, 1000)]; + + fn rgb_ifd() -> IfdBuilder { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![3])); + 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: 3, strips: vec![(0..36).collect()] }); + ifd + } + + /// The camera's 3FR header as an FFF embeds it: a little-endian TIFF whose IFD0 has the colour matrix. + fn original_header(matrix: bool) -> Vec { + let mut ifd0 = rgb_ifd(); + ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into())); + ifd0.set(t::MODEL, Value::Ascii("Hasselblad H4D".into())); + if matrix { + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(MATRIX.to_vec())); + } + ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(43, 100), (1, 1), (79, 100)])); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + } + + /// An FFF in miniature: big-endian, IFD0 an RGB image with `AsShotNeutral` and the embedded header `header`, + /// a 16-bit uncompressed CFA sub-IFD of 8 x 6 samples, no colour matrix of its own unless `own_matrix`. + fn fff(header: Option>, own_matrix: bool) -> Vec { + fff_with(header, own_matrix, vec![]) + } + + /// [`fff`] with more `SubIFDs` children of IFD0 (other preview images). + fn fff_with(header: Option>, own_matrix: bool, extra: Vec) -> 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![1])); + raw.set(t::BLACK_LEVEL, Value::Rational(vec![(256, 1)])); + raw.set(t::WHITE_LEVEL, Value::Short(vec![65535])); + raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![(0..96u8).collect()] }); + let mut ifd0 = rgb_ifd(); + ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into())); + ifd0.set(t::MODEL, Value::Ascii("Hasselblad H4D-40".into())); + ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(2, 5), (1, 1), (4, 5)])); + if own_matrix { + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); + } + if let Some(h) = header { + ifd0.set(ORIGINAL_HEADER, Value::Undefined(h)); + } + ifd0.add_sub_ifd(raw); + for e in extra { + ifd0.add_sub_ifd(e); + } + TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() + } + + fn matrix_row0(c: &ColorData) -> Option<[f64; 3]> { + c.color_matrix[0].map(|m| m[0]) + } + + /// The FFF's own tags give the samples and white balance; the colour matrix comes from the 3FR header it + /// embeds, so a developer has the camera's matrix and needs no fallback colour. + #[test] + fn fff_takes_the_colour_matrix_from_the_embedded_3fr_header() { + let bytes = fff(Some(original_header(true)), false); + assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); + let r = decode_file(&bytes).unwrap(); + assert_eq!(r.data, RawData::U16((0..48u16).map(|i| u16::from_be_bytes([2 * i as u8, 2 * i as u8 + 1])).collect())); + assert_eq!(r.cfa.as_ref().unwrap().name(), "RGGB"); + assert_eq!(matrix_row0(&r.color), Some([0.686, -0.196, -0.032])); + assert_eq!(r.color.as_shot_neutral, Some([0.4, 1.0, 0.8]), "the outer file's white balance"); + // the import probe says the same + assert_eq!(matrix_row0(&probe_info(&bytes).unwrap().color), Some([0.686, -0.196, -0.032])); + } + + /// A matrix of the file's own wins; without one anywhere (no header, or a header without a matrix) the + /// file still decodes, with the fallback colour. + #[test] + fn own_matrix_wins_and_a_missing_one_is_not_an_error() { + let own = decode_file(&fff(Some(original_header(true)), true)).unwrap(); + assert_eq!(matrix_row0(&own.color), Some([1.0, 0.0, 0.0])); + for header in [None, Some(original_header(false)), Some(b"not a tiff".to_vec())] { + let r = decode_file(&fff(header, false)).unwrap(); + assert!(!has_matrix(&r.color)); + } + } + + /// A truncated or damaged file never panics: an error, or a decode. + #[test] + fn damaged_files_do_not_panic() { + let bytes = fff(Some(original_header(true)), false); + for n in (0..bytes.len()).step_by(7) { + let _ = decode_file(&bytes[..n]); + let _ = probe_info(&bytes[..n]); + let _ = crate::embedded_preview(&bytes[..n]); + } + let mut header = original_header(true); + for i in (8..header.len()).step_by(3) { + header[i] ^= 0x5a; + match decode_file(&fff(Some(header.clone()), false)) { + Ok(_) | Err(RawError::Corrupt(_) | RawError::Tiff(_) | RawError::Unsupported(_) | RawError::Limit(_) | RawError::NotRaw) => {} + } + } + } + + /// `(width, height, pixels, has an Orientation tag)` of a wrapped preview. + fn wrapped(tiff: &[u8]) -> (u32, u32, Vec, bool) { + let t = Tiff::parse(tiff).unwrap(); + let ifd = &t.ifds[0]; + let info = ifd.image().unwrap(); + let chunks = info.chunks(tiff.len() as u64); + let pixels = chunks.iter().flat_map(|c| chunk_bytes(tiff, c).unwrap().to_vec()).collect(); + (info.width, info.height, pixels, ifd.contains(t::ORIENTATION)) + } + + /// No JPEG in the file: the 8-bit RGB IFD0 comes out as a baseline TIFF of the same pixels, without an + /// orientation of its own (the raw file's applies). + #[test] + fn rgb_ifd0_is_the_embedded_preview() { + let bytes = fff(Some(original_header(true)), false); + let preview = crate::embedded_preview(&bytes).expect("a preview"); + assert_eq!(wrapped(&preview), (4, 3, (0..36).collect(), false)); + assert_eq!(crate::embedded_preview_covering(&bytes, 2000), Some(preview)); + } + + /// The largest 8-bit RGB image wins; a bigger 16-bit one (the FFF's copy with a `TransferFunction`) is not + /// used, and nor is a planar or compressed one. + #[test] + fn only_8_bit_chunky_uncompressed_rgb_is_taken() { + let image = |w: u32, h: u32, bits: u16, planar: u16, compression: u16| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + 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![bits; 3])); + 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![compression])); + ifd.set(t::PLANAR_CONFIGURATION, Value::Short(vec![planar])); + let n = (w * h * 3 * u32::from(bits / 8)) as usize; + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![7; n]] }); + ifd + }; + let bytes = fff_with(None, false, vec![image(16, 12, 16, 1, 1), image(12, 9, 8, 2, 1), image(12, 9, 8, 1, 8)]); + assert_eq!(wrapped(&crate::embedded_preview(&bytes).unwrap()).0, 4, "IFD0's 4 x 3"); + let bytes = fff_with(None, false, vec![image(6, 6, 8, 1, 1)]); + assert_eq!(wrapped(&crate::embedded_preview(&bytes).unwrap()), (6, 6, vec![7; 108], false)); + } + + /// A preview strip cut short by a truncated file gives no preview, not a partial picture. + #[test] + fn short_preview_strip_gives_none() { + let mut ifd0 = rgb_ifd(); + ifd0.set_image(ImageData::Strips { rows_per_strip: 3, strips: vec![vec![1; 30]] }); + let tiff = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + assert_eq!(rgb_preview(&Tiff::parse(&tiff).unwrap(), &tiff), None); + } +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index cc2e3aa3a..3b4422614 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -25,6 +25,7 @@ //! | `dng` | Adobe DNG: uncompressed (any depth, packed), lossless JPEG, lossy JPEG, Deflate (integer and floating point), strips and tiles, CFA and LinearRaw, DNG 1.7 interleaved storage; reading and writing | //! | `jxl` | JPEG XL compressed DNG (DNG 1.7, compression 52546) through `jxl-oxide`; implies `dng` | //! | `crw` | Canon CRW (CIFF), decoded by `craft-crw` and returned through this crate's types | +//! | `hasselblad` | Hasselblad 3FR and FFF (and other CFA TIFFs in the DNG layout without a DNG version): uncompressed and Hasselblad's lossless JPEG; implies `dng` | //! //! [`probe`] names every container it can tell apart whatever the features; [`RawFormat::is_supported`] says //! whether this build decodes one. @@ -49,6 +50,8 @@ mod dng; #[cfg(feature = "dng")] pub mod dngwrite; pub mod gaintable; +#[cfg(feature = "hasselblad")] +mod hasselblad; #[cfg(feature = "jxl")] mod jxl; pub mod ljpeg; @@ -290,7 +293,9 @@ impl RawFormat { /// Whether [`decode`] reads this container in this build (possibly not every compression inside it): the /// format's decoder is in the crate and its Cargo feature is on. pub fn is_supported(self) -> bool { - (self == RawFormat::Dng && cfg!(feature = "dng")) || (self == RawFormat::Crw && cfg!(feature = "crw")) + (self == RawFormat::Dng && cfg!(feature = "dng")) + || (self == RawFormat::Crw && cfg!(feature = "crw")) + || (self == RawFormat::CfaTiff && cfg!(feature = "hasselblad")) } } @@ -549,6 +554,8 @@ fn decode_in(bytes: &[u8], mode: Mode, limits: &Limits) -> Result { RawFormat::Dng => dng::decode(bytes, mode, limits), #[cfg(feature = "crw")] RawFormat::Crw => crw::decode(bytes, mode, limits), + #[cfg(feature = "hasselblad")] + RawFormat::CfaTiff => hasselblad::decode(bytes, mode, limits), RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))), other => Err(RawError::Unsupported(format!("{other:?} files are not decoded by this build"))), } diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 8967ecacf..90cabf862 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -200,7 +200,8 @@ fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>, } } -/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file. +/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file, or for a Hasselblad 3FR / FFF, which +/// has no JPEG, its uncompressed RGB preview wrapped as a baseline TIFF. pub fn embedded_preview(bytes: &[u8]) -> Option> { preview_covering(bytes, None) } @@ -265,7 +266,11 @@ fn preview_covering(bytes: &[u8], min_edge: Option) -> Option> { .or(largest) .map(|s| if is_jxl(s) { s.to_vec() } else { trim_eoi(s).to_vec() }); // a TIFF raw whose preview no tag points to: one we can't decode (Leaf MOS), or Epson ERF (in its maker note) - best.or_else(|| format.filter(|f| !f.is_supported() || *f == crate::RawFormat::Erf).and_then(|_| scan_for_jpeg(bytes))) + let best = best.or_else(|| format.filter(|f| !f.is_supported() || *f == crate::RawFormat::Erf).and_then(|_| scan_for_jpeg(bytes))); + // Hasselblad 3FR / FFF carry no JPEG, only uncompressed RGB images: one wrapped as a baseline TIFF + #[cfg(feature = "hasselblad")] + let best = best.or_else(|| (format == Some(crate::RawFormat::CfaTiff)).then(|| crate::hasselblad::rgb_preview(&tiff, bytes)).flatten()); + best } /// The end of the DCT JPEG whose SOI is at `soi` (exclusive index after its EOI) and its component count, diff --git a/crates/raw/src/tests.rs b/crates/raw/src/tests.rs index 7b902dd02..19362c707 100644 --- a/crates/raw/src/tests.rs +++ b/crates/raw/src/tests.rs @@ -392,6 +392,177 @@ fn hasselblad_make_with_sony_raw_coding_is_arw() { assert_ne!(probe(&file("HASSELBLAD", 7, false)), Some(RawFormat::Arw)); } +#[cfg(feature = "hasselblad")] +mod cfa_tiff { + use super::*; + + // --- 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!(r.color.color_matrix[0].is_some()); + 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()); + assert_eq!(embedded_preview(&bytes), None, "no JPEG in the file, and an undecodable raw gets no RGB stand-in"); + 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)); + } + + /// PhotoCraft's reader: four black levels without a `BlackLevelRepeatDim` are one per site of the 2 × 2 CFA in a + /// file that is not a DNG. The DNG reader took the first for every site. In a DNG the count must match the repeat. + #[test] + fn four_black_levels_without_a_repeat_are_per_cfa_site_outside_dng() { + let with_blacks = |dng: bool| { + 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![1])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set(t::BLACK_LEVEL, Value::Short(vec![250, 252, 254, 256])); + raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![le_words(&cfa_samples())] }); + let mut ifd0 = rgb_ifd(4, 3); + ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into())); + ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(2, 5), (1, 1), (3, 5)])); + if dng { + ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); + } + ifd0.add_sub_ifd(raw); + decode(&write(&[ifd0])).unwrap().black + }; + let b = with_blacks(false); + assert_eq!((b.repeat_rows, b.repeat_cols, b.values.clone()), (2, 2, vec![250.0, 252.0, 254.0, 256.0])); + assert_eq!((b.at(0, 0, 0, 1), b.at(1, 0, 0, 1), b.at(0, 1, 0, 1), b.at(3, 3, 0, 1)), (250.0, 252.0, 254.0, 256.0)); + assert_eq!(with_blacks(true), BlackLevel::uniform(250.0), "a DNG's count has to match its repeat"); + } +} + /// 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]