LR-IMP-FORMATS: decode lossy (DCT JPEG) DNG — Smart Previews and lossy DNG exports
DNG compression 34892: each tile / strip is a baseline JPEG of 8-bit samples (decoded with zune-jpeg, pure Rust), then the usual linearization table, levels and crop apply. Per the DNG specification. Lossy DNGs (Smart Previews, "lossy compression" DNG exports, many phone DNGs) now open as raw files instead of falling back to their embedded preview. Unit test with synthetic JPEG tiles (edge tile cut short); the corpus sample decodes (5760×3840 in 144 ms). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f81c81b7a0
commit
8a3489da6b
|
|
@ -2294,6 +2294,7 @@ dependencies = [
|
|||
name = "lightcraft-raw"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-meta",
|
||||
|
|
@ -2304,6 +2305,8 @@ dependencies = [
|
|||
"rayon",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ serde = { workspace = true }
|
|||
thiserror = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
zune-jpeg = { workspace = true }
|
||||
zune-core = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
jpeg-encoder = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -57,9 +57,6 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
|||
if !(1..=4).contains(&cpp) {
|
||||
return Err(RawError::Unsupported(format!("{cpp} samples per pixel")));
|
||||
}
|
||||
if info.compression == t::compression::LOSSY_JPEG {
|
||||
return Err(RawError::Unsupported("lossy (DCT) JPEG DNG".into()));
|
||||
}
|
||||
if info.compression == t::compression::JPEG_XL {
|
||||
return Err(RawError::Unsupported("JPEG XL DNG".into()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
//! - [`color`] implements the DNG colour model (dual-illuminant interpolation, forward matrices, white balance)
|
||||
//! and produces camera → linear Rec.2020 D65 matrices.
|
||||
//!
|
||||
//! Formats: DNG (uncompressed, lossless JPEG, Deflate incl. floating point, tiled/stripped, CFA and LinearRaw),
|
||||
//! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, tiled/stripped, CFA and LinearRaw),
|
||||
//! Canon CR2, Nikon NEF/NRW (uncompressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer
|
||||
//! and X-Trans), Panasonic RW2 (packed 12/14-bit), Pentax PEF (uncompressed, Huffman), Olympus ORF (uncompressed).
|
||||
//! [`embedded_preview`] covers all of them plus CR3. Variants we can't decode yet (Nikon Huffman NEF, Panasonic
|
||||
|
|
|
|||
|
|
@ -150,6 +150,19 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
|
|||
v.resize(n, 0);
|
||||
Ok(ChunkPx::U16(v))
|
||||
}
|
||||
comp::LOSSY_JPEG => {
|
||||
// lossy DNG: each chunk is a baseline (DCT) JPEG of 8-bit samples
|
||||
let (px, jw, jh, jc) = lossy_jpeg(src, cpp)?;
|
||||
let mut v = vec![0u16; n];
|
||||
for y in 0..ch.min(jh) {
|
||||
for x in 0..cw.min(jw) {
|
||||
for k in 0..cpp {
|
||||
v[(y * cw + x) * cpp + k] = px[(y * jw + x) * jc + k.min(jc - 1)] as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ChunkPx::U16(v))
|
||||
}
|
||||
comp::ADOBE_DEFLATE | comp::DEFLATE => {
|
||||
let bytes_per = if float { bits.div_ceil(8) as usize } else { 0 };
|
||||
let row_bytes = if float { cw * cpp * bytes_per } else { (cw * cpp * bits as usize).div_ceil(8) };
|
||||
|
|
@ -160,6 +173,24 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
|
|||
}
|
||||
}
|
||||
|
||||
/// Decode one baseline JPEG chunk to 8-bit samples: (samples, width, height, channels).
|
||||
fn lossy_jpeg(src: &[u8], cpp: usize) -> Result<(Vec<u8>, usize, usize, usize)> {
|
||||
use zune_core::bytestream::ZCursor;
|
||||
use zune_core::colorspace::ColorSpace;
|
||||
use zune_core::options::DecoderOptions;
|
||||
let cs = if cpp == 1 { ColorSpace::Luma } else { ColorSpace::RGB };
|
||||
let opts = DecoderOptions::default().set_max_width(1 << 16).set_max_height(1 << 16).set_strict_mode(false).jpeg_set_out_colorspace(cs);
|
||||
let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(src), opts);
|
||||
let px = d.decode().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?;
|
||||
let info = d.info().ok_or_else(|| RawError::Corrupt("lossy JPEG tile without a header".into()))?;
|
||||
let (w, h) = (info.width as usize, info.height as usize);
|
||||
let c = if cpp == 1 { 1 } else { 3 };
|
||||
if w == 0 || h == 0 || px.len() < w * h * c {
|
||||
return Err(RawError::Corrupt("lossy JPEG tile: short output".into()));
|
||||
}
|
||||
Ok((px, w, h, c))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn unpack_chunk(
|
||||
src: &[u8],
|
||||
|
|
@ -248,3 +279,49 @@ fn unpack_chunk(
|
|||
}
|
||||
Ok(ChunkPx::U16(out))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lossy_tests {
|
||||
use super::*;
|
||||
use lightcraft_tiff::image::Layout;
|
||||
|
||||
/// A lossy-DNG-style image: two 16×8 tiles, each a baseline JPEG, the right one cut short
|
||||
/// by the image edge.
|
||||
#[test]
|
||||
fn lossy_jpeg_tiles_decode() {
|
||||
let (tw, th) = (16usize, 8usize);
|
||||
let tile = |shade: u8| {
|
||||
let px: Vec<u8> = (0..tw * th).flat_map(|i| [shade, (i % tw * 15) as u8, 200]).collect();
|
||||
let mut out = Vec::new();
|
||||
jpeg_encoder::Encoder::new(&mut out, 100).encode(&px, tw as u16, th as u16, jpeg_encoder::ColorType::Rgb).unwrap();
|
||||
out
|
||||
};
|
||||
let (a, b) = (tile(40), tile(220));
|
||||
let mut file = vec![0u8; 8];
|
||||
let oa = file.len() as u64;
|
||||
file.extend_from_slice(&a);
|
||||
let ob = file.len() as u64;
|
||||
file.extend_from_slice(&b);
|
||||
let info = ImageInfo {
|
||||
width: 24,
|
||||
height: 8,
|
||||
bits_per_sample: vec![8, 8, 8],
|
||||
samples_per_pixel: 3,
|
||||
compression: comp::LOSSY_JPEG,
|
||||
photometric: 34892,
|
||||
planar: 1,
|
||||
predictor: 1,
|
||||
sample_format: 1,
|
||||
new_subfile_type: 0,
|
||||
layout: Layout::Tiles { tile_width: tw as u32, tile_height: th as u32 },
|
||||
offsets: vec![oa, ob],
|
||||
byte_counts: vec![a.len() as u64, b.len() as u64],
|
||||
};
|
||||
let RawData::U16(v) = read_image(&file, &info, ByteOrder::Little, Packing::Msb).unwrap() else { panic!("integer samples") };
|
||||
assert_eq!(v.len(), 24 * 8 * 3);
|
||||
let px = |x: usize, y: usize| &v[(y * 24 + x) * 3..(y * 24 + x) * 3 + 3];
|
||||
assert!((px(2, 3)[0] as i32 - 40).abs() <= 3 && (px(2, 3)[2] as i32 - 200).abs() <= 3, "{:?}", px(2, 3));
|
||||
assert!((px(20, 3)[0] as i32 - 220).abs() <= 3, "second tile: {:?}", px(20, 3));
|
||||
assert!((px(10, 5)[1] as i32 - 150).abs() <= 6, "green ramp: {:?}", px(10, 5));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ const KNOWN_UNSUPPORTED: &[&str] = &[
|
|||
"nef-nikon-d5100-lossless", // Nikon Huffman NEF
|
||||
"nef-nikon-d7000-lossy", // "
|
||||
"sraw", // Canon sRAW / mRAW
|
||||
"-lossy.dng", // lossy (DCT) DNG
|
||||
];
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -453,7 +453,8 @@ fn rejects_unsupported_and_broken() {
|
|||
let mut raw = base_ifd(w, h, 8, 1, true);
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![compression::LOSSY_JPEG]));
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: 8, strips: vec![vec![0; 10]] });
|
||||
assert!(matches!(decode(&dng(raw, ByteOrder::Little)), Err(lightcraft_raw::RawError::Unsupported(_))));
|
||||
// lossy JPEG DNGs decode now; a strip that isn't a JPEG is reported as corrupt
|
||||
assert!(matches!(decode(&dng(raw, ByteOrder::Little)), Err(lightcraft_raw::RawError::Corrupt(_))));
|
||||
let mut raw = base_ifd(w, h, 16, 1, true);
|
||||
raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 3, 1]));
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
|
|
|
|||
|
|
@ -109,7 +109,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`, `crates/engine/src/preset_import.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), .zip bundles, folders (folder → group), drag & drop; masks (gradients, radial, brush, subject/sky, luminance/depth range) carried over; unmapped settings reported; no profile import (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), `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; edits go to XMP sidecars; importing promotes them |
|
||||
| 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, CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only), 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, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only), HEIC/AVIF decode |
|
||||
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | ⬜ | | |
|
||||
| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ⬜ | `crates/raw/src/dngwrite.rs` | DNG writer exists, not wired to import |
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue