diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index a4d2cdb77c..5b892250e3 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 16; +pub const RENDER_CACHE_VERSION: u64 = 17; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index d38b2fc689..3ea36e500f 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -165,6 +165,10 @@ pub fn probe(bytes: &[u8]) -> Option { if make.starts_with("PENTAX") || make.starts_with("RICOH") { 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") && has_pentax_maker_note(&t, bytes) { + return Some(RawFormat::Pef); + } if make.starts_with("SAMSUNG") { return Some(RawFormat::Srw); } @@ -174,6 +178,12 @@ pub fn probe(bytes: &[u8]) -> Option { None } +/// 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). fn has_raw_ifd(t: &Tiff) -> bool { @@ -919,6 +929,23 @@ mod tests { assert_eq!(probe(&write(&[ifd])), 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]| { + let mut ifd = rgb_ifd(16, 12); + 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")), Some(RawFormat::Pef)); + assert_eq!(probe(&with_note(b"PENTAX \0MM\0\0\0\0\0\0\0")), Some(RawFormat::Pef)); + assert_eq!(probe(&with_note(b"STMN100\0\0\0\0\0\0\0\0\0")), Some(RawFormat::Srw)); + } + #[test] fn probe_rejects_junk() { assert_eq!(probe(b""), None); diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index fbbc18eb4e..02165582b4 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -13,9 +13,28 @@ pub mod raf; mod rafc; pub mod rw2; -use crate::{BlackLevel, Rect}; +use crate::{BlackLevel, Cfa, Rect}; +use lightcraft_tiff::Tiff; use std::ops::Range; +/// Exif `CFAPattern`. +pub(crate) const EXIF_CFA_PATTERN: u16 = 0xa302; + +/// The colour-filter layout from the Exif `CFAPattern` tag (`0xa302`: two 16-bit repeat counts, found in either +/// byte order, then one byte per site: 0 = red, 1 = green, 2 = blue), when it describes a 2×2 Bayer cell. +pub(crate) fn cfa_from_exif(tiff: &Tiff) -> Option { + let &[c0, c1, r0, r1, s0, s1, s2, s3] = tiff.exif()?.bytes(EXIF_CFA_PATTERN)? else { return None }; + let two = |a: u8, b: u8| matches!((a, b), (2, 0) | (0, 2)); + let name = match [s0, s1, s2, s3] { + [0, 1, 1, 2] => "RGGB", + [2, 1, 1, 0] => "BGGR", + [1, 0, 2, 1] => "GRBG", + [1, 2, 0, 1] => "GBRG", + _ => return None, + }; + (two(c0, c1) && two(r0, r1)).then(|| Cfa::bayer_static(name)) +} + /// Black level per 2×2 CFA position (anchored at the active area origin) from masked sensor columns `cols` over /// rows `rows`. Falls back to 0 when the region is empty. pub(crate) fn black_from_columns(data: &[u16], width: usize, cols: Range, rows: Range, active: Rect) -> BlackLevel { diff --git a/crates/raw/src/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index 59c5f14c4c..893ee64a60 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -18,7 +18,7 @@ //! pattern is anchored at the sensor origin (assumed) or at the active area. Files without a usable tag fall back //! to the green diagonal found from the data, which can't tell red from blue (GRBG or RGGB is assumed). -use super::white_from_data; +use super::{cfa_from_exif, white_from_data}; use crate::tiffraw::{Packing, read_image}; use crate::unpack::unpack_msb; use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; @@ -35,8 +35,6 @@ pub(crate) const PREVIEW_LENGTH: u16 = 0x0102; const WB_RB: u16 = 0x0100; const BLACK: u16 = 0x0600; const CROP: [u16; 4] = [0x0612, 0x0613, 0x0614, 0x0615]; -/// Exif `CFAPattern`. -const EXIF_CFA_PATTERN: u16 = 0xa302; /// The Olympus maker note. pub(crate) fn maker_note(bytes: &[u8], tiff: &Tiff) -> Option { @@ -70,21 +68,6 @@ pub(crate) fn unpack_row_le32_msb(src: &[u8], bits: u32, out: &mut [u16]) { unpack_msb(&swapped, bits, out); } -/// The colour-filter layout from the Exif `CFAPattern` tag (`0xa302`: two 16-bit repeat counts, found in either -/// byte order, then one byte per site: 0 = red, 1 = green, 2 = blue), when it describes a 2×2 Bayer cell. -fn cfa_from_exif(tiff: &Tiff) -> Option { - let &[c0, c1, r0, r1, s0, s1, s2, s3] = tiff.exif()?.bytes(EXIF_CFA_PATTERN)? else { return None }; - let two = |a: u8, b: u8| matches!((a, b), (2, 0) | (0, 2)); - let name = match [s0, s1, s2, s3] { - [0, 1, 1, 2] => "RGGB", - [2, 1, 1, 0] => "BGGR", - [1, 0, 2, 1] => "GRBG", - [1, 2, 0, 1] => "GBRG", - _ => return None, - }; - (two(c0, c1) && two(r0, r1)).then(|| Cfa::bayer_static(name)) -} - /// The layout found from the samples, for files without the Exif tag: GRBG when the greens sit on the main /// diagonal of the 2×2 cell at the sensor origin, else RGGB. Over 32×32-pixel blocks of `a` (every fourth in both /// directions) it compares the block totals of the two sites on each diagonal: the two greens of a block see the @@ -223,6 +206,7 @@ pub(crate) fn preview(bytes: &[u8]) -> Option<&[u8]> { #[cfg(test)] mod tests { use super::*; + use crate::vendor::EXIF_CFA_PATTERN; use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter}; fn orf(w: u32, h: u32, bits: u16, strip: Vec) -> Vec { diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index c9421d3bc6..5b80967edb 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -15,12 +15,12 @@ //! (starting at 0). Found by trying candidate predictors and checking the decoded rows are continuous. //! - Maker note `0x0038`/`0x0039`: the image area's left/top and width/height (verified against the data). -use super::{black_from_columns, white_from_data}; +use super::{black_from_columns, cfa_from_exif, white_from_data}; use crate::tiffraw::{Packing, read_image_in}; use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; use lightcraft_geom::Orientation; -use lightcraft_tiff::image::chunk_bytes; +use lightcraft_tiff::image::{ImageInfo, chunk_bytes}; use lightcraft_tiff::{ByteOrder, Tiff, makernote, tags as t}; const CROP_ORIGIN: u16 = 0x0038; @@ -188,6 +188,19 @@ fn dark_trimmed(d: &[u16], w: usize, h: usize) -> Rect { Rect::new(left, 0, (right - left) & !1, h) } +/// Whether every strip or tile of a one-sample-per-pixel image holds exactly the bytes that its rows need when +/// the samples are packed MSB-first at the declared bit depth, rows starting on byte boundaries (TIFF 6.0 +/// section 7). A real PackBits stream of such an image has a different length (almost always shorter), so the +/// exact match is what tells plain samples under a compression tag that says otherwise. +fn is_exactly_packed(info: &ImageInfo, file_len: u64) -> bool { + let bits = u64::from(info.bits()); + if info.samples_per_pixel != 1 || info.planar != 1 || !(1..=16).contains(&bits) || info.byte_counts.len() != info.offsets.len() { + return false; + } + let chunks = info.chunks(file_len); + !chunks.is_empty() && chunks.iter().all(|c| c.len == (u64::from(c.width) * bits).div_ceil(8) * u64::from(c.height)) +} + pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; @@ -226,6 +239,12 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let packing = if strip >= (w * hgt * 2) as u64 { Packing::Word16 } else { Packing::Msb }; read_image_in(read, bytes, &info, tiff.order, packing)? } + // Some bodies tag plain packed samples as PackBits (32773). Only a strip that holds exactly the packed + // size of its rows is read, as the uncompressed MSB-first samples it is; any other size stays unsupported. + 32773 if is_exactly_packed(&info, bytes.len() as u64) => { + let packed = ImageInfo { compression: 1, ..info.clone() }; + read_image_in(read, bytes, &packed, tiff.order, Packing::Msb)? + } c => return Err(RawError::Unsupported(format!("PEF compression {c}"))), }; let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float PEF".into())) }; @@ -236,6 +255,12 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { }; let cfa = match (ifd0.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), ifd0.bytes(t::CFA_PATTERN_EP)) { (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, + // The uncompressed bodies state their pattern in the Exif `CFAPattern`, as seen from the first pixel of the + // image area (checked against each file's camera JPEG); the Huffman-compressed ones don't agree with their + // pictures (a 10 MP body says RGGB and renders correctly as BGGR), so they keep the assumed layout. + _ if matches!(info.compression, 1 | 32773) => { + cfa_from_exif(&tiff).map(|c| c.shifted(active.x, active.y)).unwrap_or_else(|| Cfa::bayer_static("BGGR")) + } _ => Cfa::bayer_static("BGGR"), }; let black = match mn.as_ref().and_then(|m| m.ifd.f64s(BLACK_POINT)).as_deref() { @@ -343,4 +368,59 @@ mod tests { assert!(decode_huffman(&src[..src.len() / 3], &h, w, hgt, 14).is_err()); assert!(table(&tb[..20], ByteOrder::Big).is_err()); } + + /// A big-endian one-strip PEF-style TIFF of `w × h` 12-bit samples with the given compression tag and strip + /// byte count, around `payload`. + fn tiny_pef(w: u16, h: u16, compression: u16, strip_len: u32, payload: &[u8]) -> Vec { + let entries: [(u16, u16, u32); 9] = [ + (0x0100, 3, w as u32 * 0x1_0000), // ImageWidth + (0x0101, 3, h as u32 * 0x1_0000), // ImageLength + (0x0102, 3, 12 * 0x1_0000), // BitsPerSample + (0x0103, 3, compression as u32 * 0x1_0000), // Compression + (0x0106, 3, 32803 * 0x1_0000), // Photometric: CFA + (0x0111, 4, 8 + 2 + 9 * 12 + 4), // StripOffsets + (0x0115, 3, 0x1_0000), // SamplesPerPixel + (0x0116, 3, h as u32 * 0x1_0000), // RowsPerStrip + (0x0117, 4, strip_len), // StripByteCounts + ]; + let mut f = b"MM\0*\0\0\0\x08".to_vec(); + f.extend_from_slice(&9u16.to_be_bytes()); + for (tag, ty, val) in entries { + f.extend_from_slice(&tag.to_be_bytes()); + f.extend_from_slice(&ty.to_be_bytes()); + f.extend_from_slice(&1u32.to_be_bytes()); + f.extend_from_slice(&val.to_be_bytes()); + } + f.extend_from_slice(&0u32.to_be_bytes()); // no next IFD + f.extend_from_slice(payload); + f + } + + /// Eight 12-bit samples, two rows of four, packed MSB-first: 0x123 0x456 0x789 0xabc / 0xdef 0x012 0x345 0x678. + const PACKED: [u8; 12] = [0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78]; + + #[test] + fn packed_samples_under_the_packbits_tag_are_read_when_the_strip_has_exactly_the_packed_size() { + let file = tiny_pef(4, 2, 32773, 12, &PACKED); + let img = decode(&file, Mode::Full).unwrap(); + let RawData::U16(v) = &img.data else { panic!("integer samples") }; + assert_eq!(v, &[0x123, 0x456, 0x789, 0xabc, 0xdef, 0x012, 0x345, 0x678]); + assert_eq!((img.width, img.height, img.bits), (4, 2, 12)); + // the same bytes tagged as plain uncompressed give the same samples + let plain = decode(&tiny_pef(4, 2, 1, 12, &PACKED), Mode::Full).unwrap(); + assert_eq!(plain.data, img.data); + // header-only mode accepts it too + assert!(decode(&file, Mode::Header).is_ok()); + } + + #[test] + fn a_packbits_tagged_strip_of_any_other_size_stays_unsupported() { + for len in [11u32, 13, 6, 24] { + let mut payload = PACKED.to_vec(); + payload.resize(24, 0x55); + let file = tiny_pef(4, 2, 32773, len, &payload); + let err = decode(&file, Mode::Full).unwrap_err(); + assert!(matches!(&err, RawError::Unsupported(m) if m.contains("32773")), "strip of {len} bytes: {err:?}"); + } + } }