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]