diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index fcfbbe208..4a997d76f 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 = 23; +pub const RENDER_CACHE_VERSION: u64 = 24; /// 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/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index 893ee64a6..9677c5854 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -10,6 +10,16 @@ //! four bits zero in more than 99% of samples), which we shift down. //! - 12-bit packed (XZ-2): each row is a sequence of little-endian 32-bit words read MSB-first (found by testing //! candidate bit orders for the smoothest image). +//! - 12-bit packed in two fields (a few old compacts): IFD0 says uncompressed, 12 bits, many strips, and the strip +//! byte counts add up to exactly width × height × 1.5. Samples are packed MSB-first, two per three bytes +//! (`b0 << 4 | b1 >> 4`, `(b1 & 15) << 8 | b2`), rows are `width × 1.5` bytes with no padding. Stored rows +//! `0..h0` are the even output rows and the rows from `h0` on the odd ones (`h0 = (height + 1) / 2`, height +//! odd); the strip table has one gap where the second field starts (follow the offsets) and omits the last row +//! of the second field, which sits in the bytes right after the last strip. Derived by black-box analysis of +//! six CC0 files (C5060WZ, C7070WZ, SP-510UZ, SP-550UZ, SP-565UZ, SP-570UZ). Their Exif `CFAPattern` states the +//! layout (it matches the embedded thumbnails), the maker note's entries `0x1017`/`0x1018` hold the red and +//! blue gain (first value, 256 = 1.0; checked against neutral areas of the thumbnail), and nothing states a +//! black level or an active area. //! - Olympus's compressed ORF (most interchangeable-lens bodies since ~2008) is not decoded: no permissively //! licensed description exists. It reports [`RawError::Unsupported`]; the embedded preview still works. //! - The colour-filter layout is the file's Exif `CFAPattern`: GRBG on the E-1 and E-400, RGGB on the XZ-2, where @@ -34,6 +44,8 @@ pub(crate) const PREVIEW_START: u16 = 0x0101; pub(crate) const PREVIEW_LENGTH: u16 = 0x0102; const WB_RB: u16 = 0x0100; const BLACK: u16 = 0x0600; +const MN_WB_RED: u16 = 0x1017; +const MN_WB_BLUE: u16 = 0x1018; const CROP: [u16; 4] = [0x0612, 0x0613, 0x0614, 0x0615]; /// The Olympus maker note. @@ -98,6 +110,50 @@ pub(crate) fn cfa_from_data(d: &[u16], w: usize, a: Rect) -> Cfa { Cfa::bayer_static(if main < anti { "GRBG" } else { "RGGB" }) } +/// Byte offsets of the stored rows of the two-field 12-bit layout, or `None` unless every condition holds +/// exactly: odd height, even width, `strips` (offset, byte count; the whole table, not just the strips that fit the +/// rows-per-strip grid, since the field seam leaves a short strip) that are whole rows summing to +/// `width × height × 1.5` bytes, exactly one gap between consecutive strips (the second field starts right after +/// it, at row `(height + 1) / 2`), and a last row, right after the final strip, inside the file. Returns +/// `height + 1` offsets. +fn field_rows(strips: &[(u64, u64)], w: usize, h: usize, file_len: usize) -> Option> { + let rb = w.checked_mul(3)? / 2; + if rb == 0 || !w.is_multiple_of(2) || h.is_multiple_of(2) || h < 3 || strips.len() < 2 { + return None; + } + let h0 = h.div_ceil(2); + let total = strips.iter().try_fold(0u64, |a, s| a.checked_add(s.1))?; + if total != (rb as u64).checked_mul(h as u64)? { + return None; + } + let mut rows = Vec::with_capacity(h + 1); + let mut gaps = 0; + let mut end = None; + for &(offset, len) in strips { + let next_end = offset.checked_add(len).filter(|e| len > 0 && len % rb as u64 == 0 && *e <= file_len as u64)?; + match end { + Some(e) if offset < e => return None, + // the gap must be the field seam + Some(e) if offset > e => { + if rows.len() != h0 { + return None; + } + gaps += 1; + } + _ => {} + } + end = Some(next_end); + let (offset, rb) = (usize::try_from(offset).ok()?, rb); + rows.extend((0..usize::try_from(len).ok()? / rb).map(|r| offset + r * rb)); + } + let tail = usize::try_from(end?).ok()?; + if gaps != 1 || rows.len() != h || tail.checked_add(rb)? > file_len { + return None; + } + rows.push(tail); + Some(rows) +} + pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; @@ -107,6 +163,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let chunks = info.chunks(bytes.len() as u64); let total: u64 = chunks.iter().map(|c| c.len).sum(); let stated = cfa_from_exif(&tiff); + let mut two_field = false; let (mut data, bits) = if info.compression != 1 { return Err(RawError::Unsupported(format!("ORF compression {}", info.compression))); } else if total >= (n as u64) * 2 { @@ -125,6 +182,26 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { Vec::new() }; (RawData::U16(d), 12) + } else if let Some(rows) = (info.bits() == 12 && info.samples_per_pixel == 1 && info.planar != 2 && info.offsets.len() == info.byte_counts.len()) + .then(|| field_rows(&info.offsets.iter().copied().zip(info.byte_counts.iter().copied()).collect::>(), w, h, bytes.len())) + .flatten() + { + two_field = true; + let d = if mode == Mode::Full || stated.is_none() { + let (rb, h0) = (w * 3 / 2, h.div_ceil(2)); + let mut d = vec![0u16; n]; + d.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let stored = if y % 2 == 0 { y / 2 } else { h0 + y / 2 }; + // every offset was checked against the file by `field_rows` + if let Some(src) = rows.get(stored).and_then(|&at| bytes.get(at..at + rb)) { + unpack_msb(src, 12, row); + } + }); + d + } else { + Vec::new() + }; + (RawData::U16(d), 12) } else { return Err(RawError::Unsupported("Olympus compressed ORF".into())); }; @@ -165,7 +242,13 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { .as_ref() .and_then(|i| i.f64s(WB_RB)) .filter(|v| v.len() >= 2 && v[0] > 0.0 && v[1] > 0.0) - .map(|v| [(v[0] / 256.0) as f32, 1.0, (v[1] / 256.0) as f32]); + .map(|v| [(v[0] / 256.0) as f32, 1.0, (v[1] / 256.0) as f32]) + .or_else(|| { + // two-field bodies: the red and blue gains lead the entries of the main maker note + let m = mn.as_ref().filter(|_| two_field)?; + let g = |tag| m.ifd.f64s(tag).and_then(|v| v.first().copied()).filter(|g| *g > 0.0); + Some([(g(MN_WB_RED)? / 256.0) as f32, 1.0, (g(MN_WB_BLUE)? / 256.0) as f32]) + }); let white = white_from_data(samples, bits); let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(active.width as u32); @@ -337,6 +420,104 @@ mod tests { } } + /// Output sample (x, y) of the two-field tests: distinct per site, all below 4096. + fn field_px(x: usize, y: usize) -> u16 { + ((y * 331 + x * 17 + 5) % 4096) as u16 + } + + /// 12 bits MSB-first, two samples per three bytes, for output row `y`. + fn packed_row(w: usize, y: usize) -> Vec { + (0..w) + .step_by(2) + .flat_map(|x| { + let (a, b) = (field_px(x, y), field_px(x + 1, y)); + [(a >> 4) as u8, ((a & 15) << 4 | b >> 8) as u8, b as u8] + }) + .collect() + } + + /// A two-field file: strips A1 (two rows), A2 (one row), a gap, B (all but the last row of the second field), + /// with the last row of the second field right after B, as the strip table of the old compacts has it (width 8, + /// height 5). `table_gap` keeps the gap in the table (else the strips are contiguous and the table is wrong). + fn two_field(table_gap: bool) -> Vec { + let (w, h) = (8usize, 5usize); + let h0 = h.div_ceil(2); + // stored rows: the even output rows, then the odd ones (the last of them is past the picture) + let stored: Vec> = (0..h0).map(|k| packed_row(w, 2 * k)).chain((0..h0).map(|k| packed_row(w, 2 * k + 1))).collect(); + let a1 = stored[..2].concat(); + let a2 = stored[2].clone(); + let gap = vec![0xffu8; 20]; + let b = stored[3].clone(); + let b2 = stored[4].clone(); + let last = stored[5].clone(); + let mut ifd = IfdBuilder::new(); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w as u32])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h as u32])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd.set(t::MAKE, Value::Ascii("OLYMPUS OPTICAL CO.,LTD".into())); + ifd.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![a1, a2, gap, b, b2, last] }); + let mut exif = IfdBuilder::new(); + exif.set(EXIF_CFA_PATTERN, Value::Undefined(BGGR.to_vec())); + ifd.set_child(t::EXIF_IFD, exif); + let mut b = TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).unwrap(); + b[2] = b'R'; + b[3] = b'S'; + // keep strips A1, A2, B1 and B2 in the table (drop the gap and the last row): four strips for a grid of three + let le = |b: &[u8], at: usize| u32::from_le_bytes(b[at..at + 4].try_into().unwrap()); + let at0 = le(&b, 4) as usize; + let n = u16::from_le_bytes([b[at0], b[at0 + 1]]) as usize; + for e in (0..n).map(|i| at0 + 2 + 12 * i) { + let tag = u16::from_le_bytes([b[e], b[e + 1]]); + if tag != t::STRIP_OFFSETS && tag != t::STRIP_BYTE_COUNTS { + continue; + } + let at = le(&b, e + 8) as usize; + let all: Vec = (0..6).map(|i| le(&b, at + 4 * i)).collect(); + let keep: Vec = if table_gap { vec![all[0], all[1], all[3], all[4]] } else { vec![all[0], all[1], all[2], all[3]] }; + b[e + 4..e + 8].copy_from_slice(&(keep.len() as u32).to_le_bytes()); + for (i, v) in keep.iter().enumerate() { + b[at + 4 * i..at + 4 * i + 4].copy_from_slice(&v.to_le_bytes()); + } + } + b + } + + #[test] + fn two_field_12_bit_layout() { + let bytes = two_field(true); + let r = crate::decode(&bytes).unwrap(); + let want: Vec = (0..5).flat_map(|y| (0..8).map(move |x| field_px(x, y))).collect(); + assert_eq!((r.data.clone(), r.bits, r.width, r.height), (RawData::U16(want), 12, 8, 5)); + assert_eq!(r.cfa.as_ref().unwrap().name(), "BGGR"); + assert_eq!(crate::probe_info(&bytes).unwrap(), r.info()); + // the table without the gap: sizes no longer add up + let e = crate::decode(&two_field(false)).map(|_| ()); + assert!(matches!(e, Err(RawError::Unsupported(_))), "{e:?}"); + } + + #[test] + fn two_field_layout_needs_every_condition() { + let chunk = |_index: usize, offset: u64, rows: u64| (offset, rows * 12); + // width 8, height 5: rows 0..3 are the first field, the gap, rows 3..5, the last row after them + let good = [chunk(0, 100, 2), chunk(1, 124, 1), chunk(2, 200, 2)]; + let rows = field_rows(&good, 8, 5, 236).unwrap(); + assert_eq!(rows, [100, 112, 124, 200, 212, 224]); + // the last row must be inside the file + assert!(field_rows(&good, 8, 5, 235).is_none()); + // the gap is the field seam: not after two rows, not absent, not twice + assert!(field_rows(&[chunk(0, 100, 2), chunk(1, 140, 1), chunk(2, 164, 2)], 8, 5, 300).is_none()); + assert!(field_rows(&[chunk(0, 100, 2), chunk(1, 124, 1), chunk(2, 136, 2)], 8, 5, 300).is_none()); + assert!(field_rows(&[chunk(0, 100, 2), chunk(1, 124, 1), chunk(2, 200, 1), chunk(3, 300, 1)], 8, 5, 400).is_none()); + // overlapping strips, sizes that don't add up, even height, odd width, a single strip + assert!(field_rows(&[chunk(0, 100, 2), chunk(1, 120, 1), chunk(2, 200, 2)], 8, 5, 300).is_none()); + assert!(field_rows(&[chunk(0, 100, 2), chunk(1, 124, 1), chunk(2, 200, 1)], 8, 5, 300).is_none()); + assert!(field_rows(&good, 8, 4, 300).is_none()); + assert!(field_rows(&good, 7, 5, 300).is_none()); + assert!(field_rows(&good[..1], 8, 5, 300).is_none()); + } + #[test] fn word16_shifted_and_packed12() { let (w, h) = (16usize, 4usize);