M3.7: Sony lossless-compressed ARW tiles (2×2 CFA cells per LJ92 sample)

Sony's lossless-compressed raw (Compression 7, e.g. ILCE-7M4) stores
512×512 tiles as LJ92 frames of 256×256 with four components: each
frame sample is one 2×2 CFA cell (top-left, top-right, bottom-left,
bottom-right). The generic TIFF path reads a frame as a row-major sample
stream (DNG's convention), which interleaved each tile's left and right
halves row by row: vertical bands, scrambled CFA, purple cast.

arw.rs now detects the layout from the first tile's frame header and
places the cells itself; other lossless-JPEG layouts keep the generic
path. Component order verified on a real ILCE-7M4 file (green
differences to the diagonal neighbour cell match those within a cell).
RENDER_CACHE_VERSION bumped so cached thumbnails of affected files are
re-rendered.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCLtGBctp1CxX2MdCuKCrm
This commit is contained in:
Christian Bretz 2026-10-02 17:51:58 +02:00
parent 75e4e3ced0
commit 5d143cf108
2 changed files with 131 additions and 5 deletions

View File

@ -26,7 +26,7 @@ use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
use serde::{Deserialize, Serialize};
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 3;
pub const RENDER_CACHE_VERSION: u64 = 4;
/// 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];

View File

@ -14,12 +14,14 @@
//! a 128-bit little-endian block: 11-bit max, 11-bit min, 4-bit index of the max, 4-bit index of the min and 14
//! seven-bit deltas above the min, scaled by the smallest shift that fits `max − min` into 7 bits.
//!
//! Also: uncompressed 16-bit ARW, and lossless-JPEG tiled ARW through the generic TIFF path.
//! Also: uncompressed 16-bit ARW, and lossless-compressed ARW (Compression 7, ILCE-7M4 and later): LJ92 tiles whose
//! frames hold one 2×2 CFA cell per four-component sample ([`read_quad_tiles`]). Other lossless-JPEG layouts go
//! through the generic TIFF path.
use crate::tiffraw::{Packing, read_image_in};
use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result};
use crate::tiffraw::{Packing, check_image, read_image_in};
use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg};
use lightcraft_geom::Orientation;
use lightcraft_tiff::image::chunk_bytes;
use lightcraft_tiff::image::{Chunk, ImageInfo, Layout, chunk_bytes};
use lightcraft_tiff::tags::{self as t, photometric};
use lightcraft_tiff::{Ifd, Tiff};
use rayon::prelude::*;
@ -87,6 +89,79 @@ pub(crate) fn decode_row(row: &[u8], out: &mut [u16]) {
}
}
/// Whether `info` holds Sony's lossless-compressed layout: LJ92 tiles whose frames are half the tile in each
/// direction with four components (checked on the first tile).
fn is_quad_tiled(bytes: &[u8], info: &ImageInfo) -> bool {
let Layout::Tiles { tile_width, tile_height } = info.layout else { return false };
info.samples_per_pixel == 1
&& info
.chunks(bytes.len() as u64)
.first()
.and_then(|c| chunk_bytes(bytes, c))
.and_then(|src| ljpeg::frame_info(src).ok())
.is_some_and(|(fw, fh, nc, _)| nc == 4 && fw * 2 == tile_width as usize && fh * 2 == tile_height as usize)
}
/// Decode Sony's lossless-compressed raw data. Each tile is one LJ92 frame of half the tile's width and height whose
/// four components are the tile's 2×2 CFA cells in raster order (top-left, top-right, bottom-left, bottom-right), so
/// all four colour planes are predicted separately. Observed in the files' own frame headers (512×512 tiles holding
/// 256×256×4 frames) and checked on decoded images; the generic TIFF path reads a frame as a row-major sample stream
/// (DNG's convention), which would interleave each tile's left and right halves row by row.
fn read_quad_tiles(bytes: &[u8], info: &ImageInfo) -> Result<Vec<u16>> {
let (w, h) = (info.width as usize, info.height as usize);
let total = check_image(bytes, info)?;
let chunks = info.chunks(bytes.len() as u64);
let decoded: Vec<Result<(Chunk, ljpeg::Frame)>> = chunks
.par_iter()
.map(|c| {
let src = chunk_bytes(bytes, c).ok_or_else(|| RawError::Corrupt("tile offset past end of file".into()))?;
let f = ljpeg::decode(src, (c.width as usize * c.height as usize).max(1 << 16))?;
if f.components != 4 || f.data.len() < f.width * f.height * 4 {
return Err(RawError::Corrupt(format!(
"lossless ARW tile: {} samples in a {}×{}×{} frame",
f.data.len(),
f.width,
f.height,
f.components
)));
}
Ok((*c, f))
})
.collect();
let mut out = vec![0u16; total];
let mut ok = 0usize;
let mut first_err = None;
for r in decoded {
let (c, f) = match r {
Ok(v) => v,
Err(e) => {
first_err.get_or_insert(e);
continue;
}
};
ok += 1;
let (x0, y0) = (c.x as usize, c.y as usize);
for fy in 0..f.height {
for k in 0..4 {
let y = y0 + 2 * fy + (k >> 1);
if y >= h {
continue;
}
for fx in 0..f.width {
let x = x0 + 2 * fx + (k & 1);
if x < w {
out[y * w + x] = f.data[(fy * f.width + fx) * 4 + k];
}
}
}
}
}
if ok == 0 {
return Err(first_err.unwrap_or_else(|| RawError::Corrupt("no decodable tiles".into())));
}
Ok(out)
}
fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
tiff.all_ifds()
.into_iter()
@ -125,6 +200,13 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
(RawData::U16(data), 14)
}
32767 => return Err(RawError::Unsupported("Sony ARW version 1 / packed compressed variant".into())),
7 if is_quad_tiled(bytes, &info) => match mode {
Mode::Full => (RawData::U16(read_quad_tiles(bytes, &info)?), bits),
Mode::Header => {
check_image(bytes, &info)?;
(RawData::U16(Vec::new()), bits)
}
},
1 => {
let packing = if strip_len >= (w * h * 2) as u64 { Packing::Word16 } else { Packing::Msb };
(read_image_in(mode, bytes, &info, tiff.order, packing)?, bits)
@ -245,4 +327,48 @@ mod tests {
let _ = code_curve(&[]);
let _ = code_curve(&[60000, 1, 0, 0]);
}
#[test]
fn quad_tiles_place_cfa_cells() {
// 14×12 mosaic in 8×8 tiles (the right and bottom tiles overhang), each tile a 4×4 frame of 2×2 cells,
// stored column by column in the file as Sony does while TileOffsets stay in raster order
let (w, h, tw) = (14usize, 12usize, 8usize);
let mosaic: Vec<u16> = (0..w * h).map(|i| 100 + (i * 37 % 4000) as u16).collect();
let at = |x: usize, y: usize| if x < w && y < h { mosaic[y * w + x] } else { 0 };
let mut file = vec![0u8; 16];
let mut offsets = vec![0u64; 4];
let mut counts = vec![0u64; 4];
for tx in 0..2 {
for ty in 0..2 {
let mut frame = Vec::new();
for fy in 0..tw / 2 {
for fx in 0..tw / 2 {
let (x, y) = (tx * tw + 2 * fx, ty * tw + 2 * fy);
frame.extend([at(x, y), at(x + 1, y), at(x, y + 1), at(x + 1, y + 1)]);
}
}
let enc = ljpeg::encode(&frame, tw / 2, tw / 2, 4, 14, 1, 0);
offsets[ty * 2 + tx] = file.len() as u64;
counts[ty * 2 + tx] = enc.len() as u64;
file.extend(enc);
}
}
let info = ImageInfo {
width: w as u32,
height: h as u32,
bits_per_sample: vec![14],
samples_per_pixel: 1,
compression: 7,
photometric: photometric::CFA,
planar: 1,
predictor: 1,
sample_format: 1,
new_subfile_type: 0,
layout: Layout::Tiles { tile_width: tw as u32, tile_height: tw as u32 },
offsets,
byte_counts: counts,
};
assert!(is_quad_tiled(&file, &info));
assert_eq!(read_quad_tiles(&file, &info).unwrap(), mosaic);
}
}