mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 12:37:51 -06:00
LR-IMP-PREVIEW-READ: thumbnails of Lightroom HDR/panorama DNGs develop from the file's reduced raw copies
A DNG that keeps reduced-resolution copies of its LinearRaw image (Lightroom merges keep 256, 512 and 2048 px ones) now develops a thumbnail or preview from the smallest copy that covers it, with the main image's levels and colour and its crop scaled. A 23836 x 7078 drone panorama's 512 px render went from 38 s to 1.1 s. Copies are skipped when the main image has opcode lists, a linearization table or black-level deltas, and for denoised photos. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
31a677fe3d
commit
7f97bdbb5f
@@ -410,7 +410,14 @@ fn load_bytes_now(
|
||||
denoise: Option<&DenoiseSpec>,
|
||||
) -> Result<(Rgb32f, Option<Rgb32f>, SourceInfo), String> {
|
||||
if lightcraft_raw::probe(&bytes).is_some() {
|
||||
let mut raw = match lightcraft_raw::decode(&bytes).map_err(|e| preview_reason(&bytes, e)) {
|
||||
// a thumbnail or preview of a DNG that keeps smaller copies of its raw image (Lightroom's merges) develops
|
||||
// the smallest that covers it; a denoised photo's twin needs the full mosaic
|
||||
let reduced = (denoise.is_none() && max_edge < usize::MAX).then(|| lightcraft_raw::decode_reduced(&bytes, max_edge).ok().flatten()).flatten();
|
||||
let decoded = match reduced {
|
||||
Some(r) => Ok(r),
|
||||
None => lightcraft_raw::decode(&bytes),
|
||||
};
|
||||
let mut raw = match decoded.map_err(|e| preview_reason(&bytes, e)) {
|
||||
Ok(r) => r,
|
||||
Err(Ok(why)) => {
|
||||
// show the camera's embedded JPEG (rendered, not raw) until the variant is supported
|
||||
|
||||
+95
-13
@@ -194,18 +194,74 @@ pub(crate) fn is_plain_cfa_tiff(tiff: &Tiff, bytes: &[u8]) -> bool {
|
||||
/// anchored at the sensor origin.
|
||||
pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<RawImage> {
|
||||
let tiff = Tiff::parse(bytes)?;
|
||||
let ifd0 = &tiff.ifds[0];
|
||||
let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Corrupt("DNG without a raw image IFD".into()))?;
|
||||
decode_ifd(bytes, &tiff, raw, raw, mode, format)
|
||||
}
|
||||
|
||||
/// The smallest reduced-resolution copy of a DNG's LinearRaw image whose default crop still has a long edge of
|
||||
/// `min_long` px: Lightroom keeps 256, 512 and 2048 px copies beside the full image of its HDR and panorama
|
||||
/// merges, so a thumbnail decodes a 512 px copy instead of a 24k × 7k image (about 19 s). Only a copy that the main
|
||||
/// image's tags describe as well: same samples per pixel and aspect ratio (within 1 %), and a main image without
|
||||
/// opcode lists, a linearization table or black-level deltas (all in full-size pixel coordinates).
|
||||
fn reduced_ifd<'a>(tiff: &'a Tiff, main: &Ifd, min_long: usize) -> Option<&'a Ifd> {
|
||||
if main.u16(t::PHOTOMETRIC) != Some(photometric::LINEAR_RAW) {
|
||||
return None;
|
||||
}
|
||||
let full_size_tags =
|
||||
[t::OPCODE_LIST_1, t::OPCODE_LIST_2, t::OPCODE_LIST_3, t::LINEARIZATION_TABLE, t::BLACK_LEVEL_DELTA_H, t::BLACK_LEVEL_DELTA_V];
|
||||
if full_size_tags.iter().any(|&tag| main.contains(tag)) {
|
||||
return None;
|
||||
}
|
||||
let (mw, mh) = (main.u64(t::IMAGE_WIDTH)?, main.u64(t::IMAGE_LENGTH)?);
|
||||
if mw == 0 || mh == 0 {
|
||||
return None;
|
||||
}
|
||||
let crop_long = match main.f64s(t::DEFAULT_CROP_SIZE).as_deref() {
|
||||
Some([w, h]) if w.is_finite() && h.is_finite() && *w >= 1.0 && *h >= 1.0 => w.max(*h).min(mw.max(mh) as f64),
|
||||
_ => mw.max(mh) as f64,
|
||||
};
|
||||
let cpp = main.u16(t::SAMPLES_PER_PIXEL).unwrap_or(1);
|
||||
tiff.all_ifds()
|
||||
.into_iter()
|
||||
.filter(|i| i.u32(t::NEW_SUBFILE_TYPE) == Some(1) && i.u16(t::PHOTOMETRIC) == Some(photometric::LINEAR_RAW))
|
||||
.filter(|i| i.u16(t::SAMPLES_PER_PIXEL).unwrap_or(1) == cpp)
|
||||
.filter_map(|i| {
|
||||
let (w, h) = (i.u64(t::IMAGE_WIDTH)?, i.u64(t::IMAGE_LENGTH)?);
|
||||
let (a, b) = (w.checked_mul(mh)?, h.checked_mul(mw)?);
|
||||
let same_aspect = w > 0 && h > 0 && a.abs_diff(b).saturating_mul(100) <= a;
|
||||
let covers = crop_long * w as f64 / mw as f64 >= min_long as f64;
|
||||
(same_aspect && covers && w < mw && h < mh).then_some((i, w))
|
||||
})
|
||||
.min_by_key(|&(_, w)| w)
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// [`decode`] from the smallest reduced-resolution copy of the raw image that covers `min_long` ([`reduced_ifd`]):
|
||||
/// the main image's levels, colour and metadata, its active area and default crop scaled to the copy. `None` when the
|
||||
/// file keeps no such copy.
|
||||
pub(crate) fn decode_reduced(bytes: &[u8], min_long: usize) -> Result<Option<RawImage>> {
|
||||
let tiff = Tiff::parse(bytes)?;
|
||||
let Some(main) = raw_ifd(&tiff) else { return Ok(None) };
|
||||
let Some(small) = reduced_ifd(&tiff, main, min_long) else { return Ok(None) };
|
||||
decode_ifd(bytes, &tiff, small, main, Mode::Full, RawFormat::Dng).map(Some)
|
||||
}
|
||||
|
||||
/// [`decode_as`] of the samples in `pixels`, described by the raw IFD `main`: the same IFD, or (see
|
||||
/// [`decode_reduced`]) a reduced-resolution copy of it, whose own level tags win and whose geometry is `main`'s
|
||||
/// scaled to its size.
|
||||
fn decode_ifd(bytes: &[u8], tiff: &Tiff, pixels: &Ifd, main: &Ifd, mode: Mode, format: RawFormat) -> Result<RawImage> {
|
||||
let ifd0 = &tiff.ifds[0];
|
||||
let raw = main;
|
||||
// GoPro's GPR: a DNG whose raw data is VC-5 coded, which is not decoded (yet). Named here, before
|
||||
// the generic "TIFF compression 9" of the chunk decoder. Header mode still describes the file
|
||||
// (a GPR has no embedded preview, and a library keeps the photo with this reason shown, as it
|
||||
// does for any file whose pixels fail to decode, rather than refusing to import it).
|
||||
if mode == Mode::Full
|
||||
&& let Some(c) = raw.u16(t::COMPRESSION).filter(|&c| c == t::compression::VC5)
|
||||
&& let Some(c) = pixels.u16(t::COMPRESSION).filter(|&c| c == t::compression::VC5)
|
||||
{
|
||||
return Err(RawError::Unsupported(format!("DNG compression {c} (GoPro VC-5) is not decoded yet")));
|
||||
}
|
||||
let info = raw.image()?;
|
||||
let info = pixels.image()?;
|
||||
let (w, h, cpp) = (info.width as usize, info.height as usize, info.samples_per_pixel as usize);
|
||||
// one sample (CFA or monochrome), RGB or four colours; two are neither and can't be developed (issue #1511)
|
||||
if !matches!(cpp, 1 | 3 | 4) {
|
||||
@@ -217,22 +273,25 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
|
||||
// DNG 1.7 interleaved storage: the raster is a permutation of the mosaic, so undo it before
|
||||
// anything reads a sample as a pixel.
|
||||
if mode == Mode::Full {
|
||||
let factor = |tag| raw.u16(tag).map(usize::from).unwrap_or(1);
|
||||
let factor = |tag| pixels.u16(tag).map(usize::from).unwrap_or(1);
|
||||
deinterleave(&mut data, w, h, cpp, factor(t::ROW_INTERLEAVE_FACTOR), factor(t::COLUMN_INTERLEAVE_FACTOR))?;
|
||||
}
|
||||
|
||||
// linearization table (integer data only)
|
||||
let mut linearized = false;
|
||||
if let (RawData::U16(v), Some(table)) = (&mut data, raw.u64s(t::LINEARIZATION_TABLE).filter(|t| !t.is_empty())) {
|
||||
if let (RawData::U16(v), Some(table)) = (&mut data, pixels.u64s(t::LINEARIZATION_TABLE).filter(|t| !t.is_empty())) {
|
||||
let table: Vec<u16> = table.iter().map(|&x| x.min(65535) as u16).collect();
|
||||
let last = table.len() - 1;
|
||||
v.iter_mut().for_each(|s| *s = table[(*s as usize).min(last)]);
|
||||
linearized = true;
|
||||
}
|
||||
|
||||
// a reduced copy's own levels, where it states them
|
||||
let levels = |tag| if pixels.contains(tag) { pixels } else { raw };
|
||||
let float = matches!(data, RawData::F32(_));
|
||||
let default_white = if float { 1.0 } else { ((1u64 << bits.min(16)) - 1) as f32 };
|
||||
let mut white: Vec<f32> = raw.f64s(t::WHITE_LEVEL).unwrap_or_default().into_iter().map(|v| v as f32).filter(|v| *v > 0.0).collect();
|
||||
let mut white: Vec<f32> =
|
||||
levels(t::WHITE_LEVEL).f64s(t::WHITE_LEVEL).unwrap_or_default().into_iter().map(|v| v as f32).filter(|v| *v > 0.0).collect();
|
||||
if white.is_empty() {
|
||||
white = vec![default_white];
|
||||
}
|
||||
@@ -247,14 +306,14 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
|
||||
}
|
||||
}
|
||||
|
||||
let dim = raw.u64s(t::BLACK_LEVEL_REPEAT_DIM).unwrap_or_default();
|
||||
let dim = levels(t::BLACK_LEVEL).u64s(t::BLACK_LEVEL_REPEAT_DIM).unwrap_or_default();
|
||||
let (br, bc) = match dim.as_slice() {
|
||||
[r, c] if (1..=16).contains(r) && (1..=16).contains(c) => (*r as usize, *c as usize),
|
||||
_ => (1, 1),
|
||||
};
|
||||
// (a FLOAT / DOUBLE tag can hold NaN or infinity: such a level reads as 0, in its place)
|
||||
let level = |v: f64| Some(v as f32).filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let mut values: Vec<f32> = raw.f64s(t::BLACK_LEVEL).unwrap_or_default().into_iter().map(level).collect();
|
||||
let mut values: Vec<f32> = levels(t::BLACK_LEVEL).f64s(t::BLACK_LEVEL).unwrap_or_default().into_iter().map(level).collect();
|
||||
if values.len() != br * bc * cpp {
|
||||
values = vec![values.first().copied().unwrap_or(0.0)];
|
||||
}
|
||||
@@ -263,11 +322,20 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
|
||||
let black =
|
||||
BlackLevel { repeat_rows: br, repeat_cols: bc, values, delta_h: floats(t::BLACK_LEVEL_DELTA_H), delta_v: floats(t::BLACK_LEVEL_DELTA_V) };
|
||||
|
||||
// geometry in the main image's pixels (a reduced copy's is scaled below)
|
||||
let (full_w, full_h) = if std::ptr::eq(pixels, raw) {
|
||||
(w, h)
|
||||
} else {
|
||||
(raw.u64(t::IMAGE_WIDTH).unwrap_or(0) as usize, raw.u64(t::IMAGE_LENGTH).unwrap_or(0) as usize)
|
||||
};
|
||||
if full_w == 0 || full_h == 0 {
|
||||
return Err(RawError::Corrupt("raw image IFD without a size".into()));
|
||||
}
|
||||
let active_area = match raw.u64s(t::ACTIVE_AREA).as_deref() {
|
||||
Some([top, left, bottom, right]) if bottom > top && right > left && (*bottom as usize) <= h && (*right as usize) <= w => {
|
||||
Some([top, left, bottom, right]) if bottom > top && right > left && (*bottom as usize) <= full_h && (*right as usize) <= full_w => {
|
||||
Rect::new(*left as usize, *top as usize, (*right - *left) as usize, (*bottom - *top) as usize)
|
||||
}
|
||||
_ => Rect::new(0, 0, w, h),
|
||||
_ => Rect::new(0, 0, full_w, full_h),
|
||||
};
|
||||
let crop = match (raw.f64s(t::DEFAULT_CROP_ORIGIN).as_deref(), raw.f64s(t::DEFAULT_CROP_SIZE).as_deref()) {
|
||||
(Some([x, y]), Some([cw, ch])) if *cw >= 1.0 && *ch >= 1.0 && *x >= 0.0 && *y >= 0.0 => {
|
||||
@@ -275,6 +343,20 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
|
||||
}
|
||||
_ => Rect::new(0, 0, active_area.width, active_area.height),
|
||||
};
|
||||
// the developed size, which the metadata reports whatever copy the samples come from
|
||||
let developed = (crop.width, crop.height);
|
||||
let (active_area, crop) = if (full_w, full_h) == (w, h) {
|
||||
(active_area, crop)
|
||||
} else {
|
||||
let (sx, sy) = (w as f64 / full_w as f64, h as f64 / full_h as f64);
|
||||
let scale = |r: Rect| {
|
||||
let (x, y) = ((r.x as f64 * sx).round() as usize, (r.y as f64 * sy).round() as usize);
|
||||
let (x1, y1) = (((r.x + r.width) as f64 * sx).round() as usize, ((r.y + r.height) as f64 * sy).round() as usize);
|
||||
Rect::new(x, y, x1.saturating_sub(x).max(1), y1.saturating_sub(y).max(1))
|
||||
};
|
||||
let area = scale(active_area).clipped(w, h);
|
||||
(area, scale(crop).clipped(area.width, area.height))
|
||||
};
|
||||
|
||||
let cfa = if info.photometric == photometric::CFA {
|
||||
if raw.u16(t::CFA_LAYOUT).unwrap_or(1) != 1 {
|
||||
@@ -319,9 +401,9 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
|
||||
let opcodes =
|
||||
crate::OpcodeLists { list1: opcode_list(t::OPCODE_LIST_1), list2: opcode_list(t::OPCODE_LIST_2), list3: opcode_list(t::OPCODE_LIST_3) };
|
||||
|
||||
let mut metadata = lightcraft_meta::from_tiff(&tiff);
|
||||
metadata.width = Some(crop.width as u32);
|
||||
metadata.height = Some(crop.height as u32);
|
||||
let mut metadata = lightcraft_meta::from_tiff(tiff);
|
||||
metadata.width = Some(developed.0 as u32);
|
||||
metadata.height = Some(developed.1 as u32);
|
||||
let img = RawImage {
|
||||
format,
|
||||
width: w,
|
||||
|
||||
@@ -359,6 +359,17 @@ pub fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
decode_with(bytes, Mode::Full)
|
||||
}
|
||||
|
||||
/// [`decode`] at a smaller size, for a picture whose default crop needs a long edge of only `min_long` px: a DNG
|
||||
/// that keeps reduced-resolution copies of its LinearRaw image (Lightroom's HDR and panorama merges keep 256 to 2048
|
||||
/// px ones) decodes the smallest that covers it. The image has the full one's levels, colour and metadata; its
|
||||
/// active area and crop are scaled to its size. `Ok(None)` when the file keeps no such copy: decode it whole.
|
||||
pub fn decode_reduced(bytes: &[u8], min_long: usize) -> Result<Option<RawImage>> {
|
||||
if probe(bytes) != Some(RawFormat::Dng) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(dng::decode_reduced(bytes, min_long)?.map(lift_clipped))
|
||||
}
|
||||
|
||||
/// Everything [`decode`] learns about a raw file except its samples: geometry, orientation, CFA,
|
||||
/// colour data, as-shot white balance, opcode lists (embedded lens corrections), metadata — read
|
||||
/// from the headers without decompressing the pixel data, for imports. Equal to
|
||||
@@ -1182,6 +1193,69 @@ mod tests {
|
||||
assert_eq!((info.width, info.height), (32, 16));
|
||||
}
|
||||
|
||||
/// A LinearRaw DNG in the layout of Lightroom's merges: a `w` × `h` main image (samples 1000) with a default
|
||||
/// crop, and reduced copies (samples 2000 + their width) of the given sizes beside it.
|
||||
fn linear_dng_with_copies(w: u32, h: u32, copies: &[(u32, u32)], main_tags: &[(u16, Value)]) -> Vec<u8> {
|
||||
let linear = |subfile: u32, w: u32, h: u32, sample: u16| {
|
||||
let mut ifd = IfdBuilder::new();
|
||||
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile]));
|
||||
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
|
||||
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
|
||||
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16]));
|
||||
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
|
||||
ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::LINEAR_RAW]));
|
||||
ifd.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768]));
|
||||
let samples: Vec<u8> = std::iter::repeat_n(sample.to_le_bytes(), (w * h * 3) as usize).flatten().collect();
|
||||
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![samples] });
|
||||
ifd
|
||||
};
|
||||
let mut main = linear(0, w, h, 1000);
|
||||
main.set(t::BLACK_LEVEL, Value::Short(vec![100]));
|
||||
main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2]));
|
||||
main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4]));
|
||||
for (tag, v) in main_tags {
|
||||
main.set(*tag, v.clone());
|
||||
}
|
||||
let mut ifd0 = rgb_ifd(4, 2);
|
||||
ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
|
||||
ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0]));
|
||||
ifd0.set(t::MAKE, Value::Ascii("DJI".into()));
|
||||
ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)]));
|
||||
ifd0.add_sub_ifd(main);
|
||||
for &(cw, ch) in copies {
|
||||
ifd0.add_sub_ifd(linear(1, cw, ch, 2000 + cw as u16));
|
||||
}
|
||||
write(&[ifd0])
|
||||
}
|
||||
|
||||
/// A thumbnail of a Lightroom merge decodes the smallest reduced copy of its raw image that covers it, with the
|
||||
/// main image's levels and its crop scaled; nothing covers it, or the main image has full-size opcodes: `None`.
|
||||
#[test]
|
||||
fn reduced_copies_of_a_linear_raw_dng_decode_at_their_size() {
|
||||
let file = linear_dng_with_copies(64, 32, &[(32, 16), (16, 8)], &[]);
|
||||
let full = decode(&file).unwrap();
|
||||
assert_eq!((full.width, full.height, full.crop), (64, 32, Rect::new(4, 2, 56, 28)));
|
||||
// a 12 px picture: the 16 px copy (its crop is 14 px long)
|
||||
let small = decode_reduced(&file, 12).unwrap().expect("a copy covers 12 px");
|
||||
assert_eq!((small.width, small.height), (16, 8));
|
||||
assert_eq!(small.crop, Rect::new(1, 1, 14, 7));
|
||||
let RawData::U16(v) = &small.data else { panic!("16-bit samples") };
|
||||
assert!(v.iter().all(|&s| s == 2016), "the 16 px copy's samples");
|
||||
assert_eq!((small.white.clone(), small.black.values.clone()), (vec![32768.0], vec![100.0]), "its own white, the main black");
|
||||
assert_eq!((small.metadata.width, small.metadata.height), (Some(56), Some(28)), "the developed size is the photo's");
|
||||
// 20 px: the 32 px copy (28 px); 40 px: none covers it
|
||||
assert_eq!(decode_reduced(&file, 20).unwrap().map(|r| r.width), Some(32));
|
||||
assert!(decode_reduced(&file, 40).unwrap().is_none());
|
||||
// a different aspect ratio is not a copy of the image
|
||||
assert!(decode_reduced(&linear_dng_with_copies(64, 32, &[(16, 16)], &[]), 8).unwrap().is_none());
|
||||
// opcodes are in full-size pixels
|
||||
let opcodes = linear_dng_with_copies(64, 32, &[(16, 8)], &[(t::OPCODE_LIST_3, Value::Undefined(vec![0, 0, 0, 0]))]);
|
||||
assert!(decode_reduced(&opcodes, 8).unwrap().is_none());
|
||||
// not a DNG
|
||||
assert!(decode_reduced(&write(&[rgb_ifd(16, 8)]), 8).unwrap().is_none());
|
||||
}
|
||||
|
||||
/// Compression 99 (not a registered TIFF value; Leaf MOS tiles) marks a raw even without a CFA tag.
|
||||
#[test]
|
||||
fn private_compression_99_is_a_raw() {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,13 @@
|
||||
# What's new in LightCraft
|
||||
|
||||
## 2026-10-18
|
||||
|
||||
### Thumbnails of Lightroom HDR and panorama merges in a second
|
||||
- Grid and filmstrip thumbnails of DNGs that Lightroom merged (HDR, Panorama, HDR Panorama) now develop from the
|
||||
small copies of the raw image Lightroom keeps inside the file (256 to 2048 px), not from the whole merge. A 23836 ×
|
||||
7078 drone panorama's 512 px thumbnail took 38 s and now takes about a second, with the same look; your edits still
|
||||
apply. Loupe previews larger than the biggest copy, 1:1 views and exports still read the full image.
|
||||
|
||||
## 2026-10-14
|
||||
|
||||
### Model downloads time out on a name that doesn't resolve
|
||||
|
||||
Reference in New Issue
Block a user