mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-03 06:59:53 -06:00
LR-IMP-FORMATS: read plain packed 12-bit PEF strips tagged as PackBits (older Pentax, Samsung GX-1L)
Compression 32773 is accepted in the PEF reader only when every strip has exactly the packed size of its rows at the declared bit depth, and is then read as uncompressed MSB-first samples. Any other 32773 size stays unsupported. Uncompressed PEFs take their colour filter from the Exif CFAPattern as seen from the image area origin. A Samsung-branded TIFF whose maker note has the Pentax layout is probed as PEF. RENDER_CACHE_VERSION 16 to 17.
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -165,6 +165,10 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
|
||||
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<RawFormat> {
|
||||
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);
|
||||
|
||||
Vendored
+20
-1
@@ -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<Cfa> {
|
||||
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<usize>, rows: Range<usize>, active: Rect) -> BlackLevel {
|
||||
|
||||
Vendored
+2
-18
@@ -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<MakerNote> {
|
||||
@@ -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<Cfa> {
|
||||
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<u8>) -> Vec<u8> {
|
||||
|
||||
Vendored
+82
-2
@@ -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<RawImage> {
|
||||
let tiff = Tiff::parse(bytes)?;
|
||||
let ifd0 = &tiff.ifds[0];
|
||||
@@ -226,6 +239,12 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
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<RawImage> {
|
||||
};
|
||||
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<u8> {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user