diff --git a/ROADMAP.md b/ROADMAP.md index dcfba5183..aeb07567b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -162,6 +162,9 @@ The milestone estimates in the table above were made before work started and are ## Raw format coverage and known gaps +Sony ILCE-7M4 downsized lossless ARW (subsampled YCbCr 4:2:0 / 4:2:2 tiles) now decodes to linear RGB; +private 3:2 & 4:3 examples verified through native rendering & full-resolution 16-bit export. + Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed, LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed (Bayer and diff --git a/crates/codecs/src/sniff.rs b/crates/codecs/src/sniff.rs index d31629047..a02d87d24 100644 --- a/crates/codecs/src/sniff.rs +++ b/crates/codecs/src/sniff.rs @@ -163,6 +163,9 @@ fn tiff_is_raw(b: &[u8]) -> bool { } let Some(t) = Tiff::new(b) else { return false }; let Some(ifd0) = t.first_ifd() else { return false }; + let sony = t.ifd(ifd0).is_some_and(|(entries, _)| { + entries.iter().any(|e| e.tag == 0x010f && t.bytes(e).and_then(|v| v.get(..4)).is_some_and(|v| v.eq_ignore_ascii_case(b"SONY"))) + }); let mut stack = vec![ifd0]; let mut seen = 0; while let Some(pos) = stack.pop() { @@ -171,6 +174,12 @@ fn tiff_is_raw(b: &[u8]) -> bool { break; } let Some((entries, _)) = t.ifd(pos) else { continue }; + // Sony M/S lossless ARWs retain a dummy CFA pattern, but store linear YCbCr. + // Requiring the raw pattern plus this layout keeps ordinary Sony TIFFs as TIFFs. + let uint = |tag| entries.iter().find(|e| e.tag == tag).and_then(|e| t.uint(e)); + if sony && entries.iter().any(|e| e.tag == 0x828e) && uint(0x0103) == Some(7) && uint(0x0106) == Some(6) && uint(0x0115) == Some(3) { + return true; + } for e in &entries { match e.tag { 0xC612 => return true, // DNGVersion @@ -233,4 +242,38 @@ mod tests { b.extend_from_slice(&0u32.to_le_bytes()); assert_eq!(sniff(&b), Some(Format::RawTiffLike)); } + + #[test] + fn sony_linear_ycbcr_subifd_is_raw_but_ordinary_tiff_is_not() { + fn fixture(compression: u32, pattern: bool) -> Vec { + let mut b = b"II*\0\x08\0\0\0".to_vec(); + let entry = |b: &mut Vec, tag: u16, typ: u16, count: u32, value: u32| { + b.extend_from_slice(&tag.to_le_bytes()); + b.extend_from_slice(&typ.to_le_bytes()); + b.extend_from_slice(&count.to_le_bytes()); + b.extend_from_slice(&value.to_le_bytes()); + }; + b.extend_from_slice(&2u16.to_le_bytes()); + entry(&mut b, 0x010f, 2, 5, 38); + entry(&mut b, 0x014a, 4, 1, 44); + b.extend_from_slice(&0u32.to_le_bytes()); + b.extend_from_slice(b"SONY\0\0"); + b.extend_from_slice(&(if pattern { 4u16 } else { 3 }).to_le_bytes()); + entry(&mut b, 0x0103, 3, 1, compression); + entry(&mut b, 0x0106, 3, 1, 6); + entry(&mut b, 0x0115, 3, 1, 3); + if pattern { + entry(&mut b, 0x828e, 1, 4, u32::MAX); + } + b.extend_from_slice(&0u32.to_le_bytes()); + b + } + let mut raw = fixture(7, true); + assert_eq!(sniff(&raw), Some(Format::RawTiffLike)); + assert_eq!(sniff(&fixture(7, false)), Some(Format::Tiff)); + assert_eq!(sniff(&fixture(1, true)), Some(Format::Tiff)); + assert_eq!(sniff(&raw[..38]), Some(Format::Tiff)); + raw[38..42].copy_from_slice(b"TEST"); + assert_eq!(sniff(&raw), Some(Format::Tiff)); + } } diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 1a3e9a9b9..cd0d475d2 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -84,7 +84,7 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz } // Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution. let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2; - let sensor = raw.develop_binned(k, 0.99).ok()??; + let sensor = sensor_proxy(raw, k, edge as usize)?; let mut sensor = fit(&sensor, size, size, Filter::Box); let reference = fit(&reference, sensor.width, sensor.height, Filter::Box); let gain = 2f32.powf(transform.baseline_exposure as f32); @@ -113,6 +113,14 @@ pub(crate) fn fit_profile(pairs: &[([f64; 3], [f64; 3])]) -> Option<(Mat3, Optio Some((matrix, fit_hue_sat(pairs, &matrix))) } +fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option { + Some(match raw.develop_binned(k, 0.99).ok()? { + Some(sensor) => sensor, + None if raw.cpp == 3 && raw.cfa.is_none() => fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box), + None => return None, + }) +} + /// A fit must cut the held-out squared error to below this share of the fallback's. const MIN_IMPROVEMENT: f64 = 0.7; /// ...and stay within this per-channel RMS of the camera JPEG (linear display values). On public @@ -521,6 +529,35 @@ fn fit_tone(mut pairs: Vec<(f64, f64)>) -> Option { #[cfg(test)] mod tests { use super::*; + + #[test] + fn linear_arw_gets_a_sensor_proxy_without_demosaicing() { + use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, RawFormat, Rect}; + let mut raw = RawImage { + format: RawFormat::Arw, + width: 32, + height: 32, + cpp: 3, + data: RawData::F32([0.2, 0.3, 0.4].repeat(32 * 32)), + cfa: None, + bits: 16, + black: BlackLevel::uniform(0.0), + white: vec![1.0], + active_area: Rect::new(0, 0, 32, 32), + crop: Rect::new(0, 0, 32, 32), + orientation: Orientation::from_exif(1), + color: ColorData::default(), + wb_multipliers: Some([1.0; 3]), + linearized: true, + opcodes: OpcodeLists::default(), + metadata: lightcraft_meta::Metadata::default(), + }; + let proxy = sensor_proxy(&raw, 2, 384).unwrap(); + assert_eq!((proxy.width, proxy.height), (32, 32)); + assert_eq!(proxy.data[0], [0.2, 0.3, 0.4]); + raw.data = RawData::F32(Vec::new()); + assert!(sensor_proxy(&raw, 2, 384).is_none()); + } #[test] fn separates_nonlinear_tone_from_colour_and_keeps_sensor_headroom() { let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); diff --git a/crates/raw/src/ljpeg.rs b/crates/raw/src/ljpeg.rs index cb2d04675..c8087a8e8 100644 --- a/crates/raw/src/ljpeg.rs +++ b/crates/raw/src/ljpeg.rs @@ -207,6 +207,7 @@ struct Header { height: usize, width: usize, comps: Vec, + sampling: Vec, tables: [Option; 4], table_for: Vec, predictor: u8, @@ -228,6 +229,7 @@ fn parse_header(d: &[u8]) -> Result { height: 0, width: 0, comps: vec![], + sampling: vec![], tables: [None, None, None, None], table_for: vec![], predictor: 1, @@ -272,10 +274,11 @@ fn parse_header(d: &[u8]) -> Result { } for c in 0..n { let (id, samp) = (seg[6 + 3 * c], seg[7 + 3 * c]); - if samp != 0x11 { - return Err(RawError::Unsupported("lossless JPEG with subsampled components".into())); + if !(1..=4).contains(&(samp >> 4)) || !(1..=4).contains(&(samp & 15)) { + return Err(err("invalid sampling factors")); } h.comps.push(id); + h.sampling.push(samp); } if !(2..=16).contains(&h.precision) { return Err(err("bad precision")); @@ -340,12 +343,119 @@ fn parse_header(d: &[u8]) -> Result { /// Parse only the frame header: (width, height, components, precision). pub fn frame_info(d: &[u8]) -> Result<(usize, usize, usize, u8), RawError> { let h = parse_header(d)?; + check_full_resolution(&h)?; Ok((h.width, h.height, h.comps.len(), h.precision)) } +fn check_full_resolution(h: &Header) -> Result<(), RawError> { + if h.sampling.iter().any(|&s| s != 0x11) { + return Err(RawError::Unsupported("lossless JPEG with subsampled components".into())); + } + Ok(()) +} + +fn check_subsampled(h: &Header) -> Result { + let vertical = match h.sampling.as_slice() { + [0x22, 0x11, 0x11] => 2, + [0x21, 0x11, 0x11] => 1, + _ => return Err(RawError::Unsupported("lossless JPEG subsampling layout".into())), + }; + if h.predictor != 1 || h.restart != 0 { + return Err(RawError::Unsupported("lossless JPEG subsampled predictor / restarts".into())); + } + if h.width == 0 || h.height == 0 || !h.width.is_multiple_of(2) || !h.height.is_multiple_of(vertical) { + return Err(err("subsampled frame dimensions do not match MCU size")); + } + Ok(vertical) +} + +/// Sony M/S tiles: horizontal prediction, no restart markers. Keep subsampled planes separate; +/// expanding them into a mosaic would silently corrupt the existing CFA callers. +pub(crate) fn frame_info_subsampled(d: &[u8]) -> Result<(usize, usize), RawError> { + let h = parse_header(d)?; + check_subsampled(&h)?; + Ok((h.width, h.height)) +} + +pub(crate) struct FrameSubsampled { + pub width: usize, + pub height: usize, + pub vertical_subsampling: usize, + /// Y at full resolution; Cb and Cr at half width, with 1× or 2× vertical subsampling. + pub planes: [Vec; 3], +} + +/// Sony's 4:2:0 / 4:2:2 LJ92 variants: T.81 MCU ordering and differences, modulo 2^16. +/// At the first column, the top luma row predicts from the previous MCU row's top +/// sample (two image rows above), and the bottom row from the current top sample. +/// Observed black-box: using the immediately preceding image row introduces tile seams. +pub(crate) fn decode_subsampled(d: &[u8], max_samples: usize) -> Result { + let h = parse_header(d)?; + let vertical = check_subsampled(&h)?; + let pixels = h.width.checked_mul(h.height).ok_or_else(|| err("frame too large"))?; + let chroma = pixels / (2 * vertical); + let total = pixels.checked_add(chroma.checked_mul(2).ok_or_else(|| err("frame too large"))?).ok_or_else(|| err("frame too large"))?; + if total > max_samples { + return Err(RawError::Limit("lossless JPEG frame larger than expected")); + } + let entropy = &d[h.scan_start..]; + if (entropy.len() as u64 + 64) * 8 < total as u64 { + return Err(err("entropy data too short for frame")); + } + let tables: Vec<&Huffman> = h + .table_for + .iter() + .map(|&t| h.tables.get(t).and_then(Option::as_ref).ok_or_else(|| err("missing Huffman table"))) + .collect::>()?; + let mut planes = [vec![0u16; pixels], vec![0u16; chroma], vec![0u16; chroma]]; + let mut br = BitReader::new(entropy); + let init = 1i32 << (h.precision - h.pt - 1); + for my in 0..h.height / vertical { + for mx in 0..h.width / 2 { + for (c, plane) in planes.iter_mut().enumerate() { + let horizontal_factor = if c == 0 { 2 } else { 1 }; + let vertical_factor = if c == 0 { vertical } else { 1 }; + let width = h.width * horizontal_factor / 2; + for dy in 0..vertical_factor { + for dx in 0..horizontal_factor { + let (x, y) = (mx * horizontal_factor + dx, my * vertical_factor + dy); + let i = y * width + x; + let pred = if x > 0 { + plane[i - 1] as i32 + } else if c == 0 && dy == 0 && y >= vertical { + plane[i - vertical * width] as i32 + } else if y > 0 { + plane[i - width] as i32 + } else { + init + }; + let ssss = tables[c].decode(&mut br)?; + if ssss > 16 { + return Err(err("invalid difference category")); + } + plane[i] = ((pred + diff_value(&mut br, ssss)) & 0xffff) as u16; + } + } + } + } + if br.overrun > br.bits { + return Err(err("entropy data exhausted")); + } + } + if h.pt > 0 { + for plane in &mut planes { + for v in plane { + *v <<= h.pt; + } + } + } + Ok(FrameSubsampled { width: h.width, height: h.height, vertical_subsampling: vertical, planes }) +} + /// Decode a lossless JPEG stream. `max_samples` bounds the allocation (hostile headers). pub fn decode(d: &[u8], max_samples: usize) -> Result { let h = parse_header(d)?; + check_full_resolution(&h)?; let (w, ht, nc) = (h.width, h.height, h.comps.len()); if w == 0 || ht == 0 { return Err(err("zero frame size")); @@ -701,9 +811,70 @@ pub fn encode(data: &[u16], width: usize, height: usize, components: usize, prec } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; + /// Independent 4×4, 16-bit Sony 4:2:0 stream. Hand-specified differences in MCU order. + pub(crate) fn fixture_420() -> Vec { + fixture_subsampled(4, 0x22, &[-31768, 1, 1000, 1, -16384, -16284, 1, 1, 1, 1, 1, 2, 100, 1, 1000, 1, 100, 100, 1, 1, 1, 1, 1, 2]) + } + + fn fixture_subsampled(height: u8, sampling: u8, differences: &[i32]) -> Vec { + let mut out = vec![0xff, 0xd8, 0xff, 0xc3, 0, 17, 16, 0, height, 0, 4, 3, 1, sampling, 0, 2, 0x11, 0, 3, 0x11, 0]; + out.extend_from_slice(&[0xff, 0xc4, 0, 36, 0]); + let mut counts = [0; 16]; + counts[4] = 17; + out.extend_from_slice(&counts); + out.extend(0..=16); + out.extend_from_slice(&[0xff, 0xda, 0, 12, 3, 1, 0, 2, 0, 3, 0, 1, 0, 0]); + let mut bw = BitWriter { out, acc: 0, n: 0 }; + for &d in differences { + let s = ssss_of(d); + bw.put(s as u32, 5); + if s > 0 && s < 16 { + let v = if d < 0 { d - 1 } else { d }; + bw.put(v as u32 & ((1 << s) - 1), s as u32); + } + } + bw.flush(); + bw.out.extend_from_slice(&[0xff, 0xd9]); + bw.out + } + + #[test] + fn horizontal_only_subsampling_preserves_rows_and_chroma() { + // Independent 4×2, 16-bit 4:2:2 stream: two luma samples, Cb, Cr per MCU. + let enc = fixture_subsampled(2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2]); + let frame = decode_subsampled(&enc, 16).unwrap(); + assert_eq!((frame.width, frame.height, frame.vertical_subsampling), (4, 2, 1)); + assert_eq!(frame.planes[0], [1000, 1001, 1002, 1003, 1100, 1101, 1102, 1103]); + assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]); + assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]); + assert!(decode(&enc, 24).is_err()); + assert!(matches!(decode_subsampled(&enc, 15), Err(RawError::Limit(_)))); + assert!(decode_subsampled(&enc[..enc.len() - 6], 16).is_err()); + } + + #[test] + fn subsampled_mcu_order_preserves_each_plane() { + let enc = fixture_420(); + let frame = decode_subsampled(&enc, 24).unwrap(); + assert_eq!((frame.width, frame.height), (4, 4)); + assert_eq!(frame.planes[0], [1000, 1001, 1002, 1003, 2000, 2001, 2002, 2003, 1100, 1101, 1102, 1103, 2100, 2101, 2102, 2103]); + assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]); + assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]); + assert!(decode(&enc, 48).is_err()); // CFA API must keep rejecting subsampling. + assert!(matches!(decode_subsampled(&enc, 23), Err(RawError::Limit(_)))); + assert!(decode_subsampled(&enc[..enc.len() - 6], 24).is_err()); + let mut bad = enc.clone(); + bad[10] = 3; // odd frame width + assert!(frame_info_subsampled(&bad).is_err()); + let sos = bad.windows(2).position(|w| w == [0xff, 0xda]).unwrap(); + bad = enc.clone(); + bad[sos + 11] = 2; // unsupported predictor + assert!(frame_info_subsampled(&bad).is_err()); + } + fn noise(n: usize, bits: u32, seed: u64) -> Vec { let mut s = seed; (0..n) diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index fc019743e..acaacf0b5 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -37,6 +37,8 @@ const BLACK_LEVEL: u16 = 0x7310; const WB_RGGB: u16 = 0x7313; const CROP_TOP_LEFT: u16 = 0x74c7; const CROP_SIZE: u16 = 0x74c8; +const YCBCR_COEFFICIENTS: u16 = 529; +const REFERENCE_BLACK_WHITE: u16 = 532; /// Maker-note tags (ExifTool Sony tag names): the enciphered `Tag2010` block and `FullImageSize` (height, width). const MN_TAG2010: u16 = 0x2010; const MN_FULL_IMAGE_SIZE: u16 = 0xb02b; @@ -300,6 +302,71 @@ fn read_quad_tiles(bytes: &[u8], info: &ImageInfo) -> Result> { Ok(out) } +/// Downsized M/S ARWs are linear YCbCr 4:2:0 / 4:2:2, not CFA, despite retaining dummy CFA tags. +/// T.81 supplies the lossless coding; TIFF 6.0 supplies the YCbCr coefficients and reference +/// levels. Sony's reference chroma black and white are identical (the neutral offset). +fn read_ycbcr_tiles(bytes: &[u8], info: &ImageInfo, raw: &Ifd, mode: Mode) -> Result> { + let total = check_image(bytes, info)?; + if info.samples_per_pixel != 3 || info.planar != 1 || !matches!(info.layout, Layout::Tiles { .. }) { + return Err(RawError::Unsupported("Sony linear YCbCr tile layout".into())); + } + let chunks = info.chunks(bytes.len() as u64); + let first = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("missing YCbCr tile".into()))?; + ljpeg::frame_info_subsampled(first)?; + let coefficients = raw.f64s(YCBCR_COEFFICIENTS).unwrap_or_else(|| vec![0.299, 0.587, 0.114]); + let [kr, kg, kb] = coefficients.as_slice() else { return Err(RawError::Corrupt("YCbCr coefficients".into())) }; + if !coefficients.iter().all(|v| v.is_finite() && *v > 0.0 && *v < 1.0) || (kr + kg + kb - 1.0).abs() > 0.001 { + return Err(RawError::Corrupt("invalid YCbCr coefficients".into())); + } + let references = raw.f64s(REFERENCE_BLACK_WHITE).unwrap_or_else(|| vec![0.0, 16383.0, 16384.0, 16384.0, 16384.0, 16384.0]); + let [yblack, ywhite, cbzero, cbwhite, crzero, crwhite] = references.as_slice() else { + return Err(RawError::Corrupt("YCbCr reference levels".into())); + }; + if !references.iter().all(|v| v.is_finite() && (0.0..=65535.0).contains(v)) + || *yblack != 0.0 + || *ywhite != 16383.0 + || cbzero != cbwhite + || crzero != crwhite + { + return Err(RawError::Unsupported("Sony YCbCr reference levels".into())); + } + if mode == Mode::Header { + return Ok(Vec::new()); + } + let decoded: Vec<(Chunk, ljpeg::FrameSubsampled)> = chunks + .par_iter() + .map(|c| { + let src = chunk_bytes(bytes, c).ok_or_else(|| RawError::Corrupt("YCbCr tile outside file".into()))?; + let limit = (c.width as usize).checked_mul(c.height as usize).and_then(|n| n.checked_mul(3)).ok_or(RawError::Limit("tile too large"))?; + let f = ljpeg::decode_subsampled(src, limit)?; + if f.width != c.width as usize || f.height != c.height as usize { + return Err(RawError::Corrupt("Sony YCbCr tile dimensions".into())); + } + Ok((*c, f)) + }) + .collect::>()?; + let (w, h) = (info.width as usize, info.height as usize); + let mut out = vec![0u16; total]; + for (c, f) in decoded { + for y in 0..f.height.min(h.saturating_sub(c.y as usize)) { + for x in 0..f.width.min(w.saturating_sub(c.x as usize)) { + let luma = f.planes[0][y * f.width + x] as f64; + let ci = (y / f.vertical_subsampling) * (f.width / 2) + x / 2; + let cb = f.planes[1][ci] as f64 - cbzero; + let cr = f.planes[2][ci] as f64 - crzero; + let r = luma + (2.0 - 2.0 * kr) * cr; + let b = luma + (2.0 - 2.0 * kb) * cb; + let g = (luma - kr * r - kb * b) / kg; + let dst = (((c.y as usize + y) * w) + c.x as usize + x) * 3; + for (s, value) in [r, g, b].into_iter().enumerate() { + out[dst + s] = value.round().clamp(0.0, 65535.0) as u16; + } + } + } + } + Ok(out) +} + /// `SR2Private` tags (ExifTool "Sony SR2Private" table; the IFD is referenced by IFD0's `DNGPrivateData`). const SR2_SUBIFD_OFFSET: u16 = 0x7200; const SR2_SUBIFD_LENGTH: u16 = 0x7201; @@ -384,9 +451,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { return Err(RawError::Limit("image too large")); } let bits = info.bits() as u32; + let linear_rgb = info.compression == 7 && info.photometric == photometric::YCBCR; let chunks = info.chunks(bytes.len() as u64); let strip_len: u64 = chunks.iter().map(|c| c.len).sum(); let (data, out_bits) = match info.compression { + 7 if linear_rgb => (RawData::U16(read_ycbcr_tiles(bytes, &info, raw, mode)?), 14), 32767 if chunks.len() == 1 && strip_len >= (w * h) as u64 && strip_len < (w * h) as u64 * 5 / 4 && mode == Mode::Header => { chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; (RawData::U16(Vec::new()), 14) @@ -434,12 +503,17 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { }; let default_black = if scale_bits >= 14 { 512.0 } else { 128.0 }; let black = match raw.f64s(BLACK_LEVEL).as_deref() { + Some(v) if linear_rgb && !v.is_empty() => BlackLevel::uniform((v.iter().sum::() / v.len() as f64) as f32), Some([a, b, c, d]) => { BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![*a as f32, *b as f32, *c as f32, *d as f32], ..Default::default() } } _ => BlackLevel::uniform(sr2_black(bytes, ifd0, tiff.order).filter(|_| scale_bits >= 14).unwrap_or(default_black)), }; - let white = raw.f64(t::WHITE_LEVEL).map(|v| v as f32).filter(|v| *v > 0.0).unwrap_or_else(|| super::white_from_data(samples, scale_bits)); + let white = if linear_rgb { + 16383.0 + } else { + raw.f64(t::WHITE_LEVEL).map(|v| v as f32).filter(|v| *v > 0.0).unwrap_or_else(|| super::white_from_data(samples, scale_bits)) + }; let model = ifd0.string(t::MODEL).unwrap_or_default(); let mn = tiff .exif() @@ -464,9 +538,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { format: RawFormat::Arw, width: w, height: h, - cpp: 1, + cpp: if linear_rgb { 3 } else { 1 }, data, - cfa: Some(cfa), + cfa: if linear_rgb { None } else { Some(cfa) }, bits: out_bits, black, white: vec![white], @@ -474,7 +548,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { crop, orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), color: ColorData::default(), - wb_multipliers: wb, + // Sony's linear YCbCr already carries as-shot WB. Applying the CFA gains again + // makes a neutral surface magenta. WB edits remain relative to this as-shot RGB. + wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb }, linearized: false, opcodes: OpcodeLists::default(), metadata, @@ -487,6 +563,38 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { mod tests { use super::*; + #[test] + fn downsized_lossless_is_linear_rgb_not_cfa() { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("ILCE-7M4".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![15, 15, 15])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::YCBCR])); + raw.set(t::COMPRESSION, Value::Short(vec![7])); + raw.set(TONE_CURVE, Value::Short(vec![0; 4])); + raw.set(BLACK_LEVEL, Value::Short(vec![512; 4])); + raw.set(WB_RGGB, Value::Short(vec![2048, 1024, 1024, 2048])); + raw.set_image(ImageData::Tiles { tile_width: 4, tile_height: 4, tiles: vec![ljpeg::tests::fixture_420()] }); + let file = TiffWriter::default().write(&[raw]).unwrap(); + let header = decode(&file, Mode::Header).unwrap(); + let full = decode(&file, Mode::Full).unwrap(); + assert_eq!(header.info(), full.info()); + assert_eq!((full.width, full.height, full.cpp), (4, 4, 3)); + assert!(full.cfa.is_none()); + assert_eq!(full.data.len(), 48); + assert_eq!(full.wb_multipliers, Some([1.0; 3])); + let RawData::U16(ref pixels) = full.data else { + panic!("integer ARW"); + }; + assert_eq!(&pixels[..3], &[1140, 929, 1000]); + let developed = full.develop(crate::Method::Bilinear).unwrap(); + assert_eq!((developed.width, developed.height), (4, 4)); + } + /// Encode one 16-value set as an ARW2 block (the paper's scheme, used here to test the decoder). pub(crate) fn encode_block(v: &[u16; 16]) -> [u8; 16] { let (imax, &max) = v.iter().enumerate().max_by_key(|(i, x)| (**x, usize::MAX - *i)).unwrap(); diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 6d497b4e1..e4761eaef 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -16,8 +16,6 @@ fn corpus_root() -> PathBuf { /// Variants known not to decode yet (see the crate docs): matched against the lower-case file name. const KNOWN_UNSUPPORTED: &[&str] = &[ "cr3-", // CR3 / CRX (M11.1) - "arw-sony-a7m4-lossless-m", // Sony lossless compressed M/S: subsampled (YCbCr) lossless JPEG - "arw-sony-a7m4-lossless-s", // " "raf-fuji-xt20-compressed", // Fujifilm compressed RAF "orf-olympus-em", // Olympus compressed ORF "sraw", // Canon sRAW / mRAW diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 67bec906f..1ebce4964 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -546,6 +546,14 @@ mod tests { fn keyword_list_filters_renames_and_suggests() { // tall: the demo library's own keywords come first in the list let mut h = demo([1300.0, 1800.0]); + // Host folders arrive asynchronously above Keywords. Keep this keyword fixture's + // geometry independent of their existence and the background stat timing. + if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) { + h.app.ui.hidden_locations.extend( + ["Pictures", "Desktop", "Downloads", ""] + .map(|sub| if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() }), + ); + } let t = Duration::from_secs(10); let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); let ex = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), Duration::from_secs(10)); @@ -576,6 +584,7 @@ mod tests { h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[1]]}}), t); ex(&mut h, "photo.setMeta", json!({"ids": [vis[0]], "addKeywords": ["gelato"]})); h.request("ui.set", json!({"right": "keywords"}), t); + assert!(h.settle(SETTLE), "keyword suggestions did not settle"); let r = h.request("ui.clickWidget", json!({"id": "kwSuggest:gelato"}), t); assert_eq!(r["ok"], true, "{r}"); assert!(h.app.session.catalog.photo(lightcraft_catalog::PhotoId(vis[1])).unwrap().meta.keywords.contains(&"gelato".to_string())); diff --git a/docs/parity.md b/docs/parity.md index 956bc0c02..d5a31ea9c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -124,7 +124,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF. 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). CR3 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), compressed RAF/ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF. 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). CR3 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), compressed RAF/ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies). Grow the CC0 corpus per model and test each sample decodes with plausible colour | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | diff --git a/docs/whats-new.md b/docs/whats-new.md index d55c91d0c..ea0243f42 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,10 @@ ## October 2026 +### RAW decoding +- Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB, + preserving RAW editing & full-resolution export instead of using embedded JPEG previews. + ### Presets and profiles - Import presets from other editors: XMP presets, classic `.lrtemplate` files, "DNG presets" from mobile apps and `.zip` bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.