From 37d36215056838bc705900984a5f503961a1b14d Mon Sep 17 00:00:00 2001 From: radialmonster Date: Fri, 9 Oct 2026 17:23:50 -0400 Subject: [PATCH] LR-IMP-FORMATS: read the two-field 12-bit ORF layout of old Olympus compacts C5060WZ, C7070WZ, SP-510UZ, SP-550UZ, SP-565UZ and SP-570UZ files say uncompressed 12-bit with many strips whose byte counts sum to exactly width x height x 1.5. main refused them because the 12-bit branch wanted a single strip, so they fell to "Olympus compressed ORF" and opened preview-only. The samples are packed MSB-first, the strips hold two fields (even output rows, then odd ones) with one gap at the seam, and the last row of the second field sits right after the last strip. Accepted only when all of that holds exactly. CFA comes from the Exif tag, white balance from maker-note entries 0x1017/0x1018, black 0. RENDER_CACHE_VERSION 27 (adjust at merge). --- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/orf.rs | 183 ++++++++++++++++++++++++++++++++++- 2 files changed, 183 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 1231221b9..b73d3f1de 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 = 22; +pub const RENDER_CACHE_VERSION: u64 = 27; /// 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);