diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 2f36513ef..246fd96c7 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -270,7 +270,10 @@ fn snapshot(args: &[String]) -> Result<(), String> { None => Session::new().with_fs(), }; if !files.is_empty() { - session.execute("library.import", &json!({"paths": expand_paths(&files)})).map_err(|e| e.to_string())?; + let ti = Instant::now(); + let r = session.execute("library.import", &json!({"paths": expand_paths(&files)})).map_err(|e| e.to_string())?; + let n = r["imported"].as_array().map_or(0, Vec::len); + eprintln!("lightcraft-cli snapshot: imported {n} files in {:.0} ms", ti.elapsed().as_secs_f64() * 1e3); } let services = lightcraft_ui_egui::Services { write: Some(Box::new(|p: &str, b: &[u8]| { diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 884235016..153922c9a 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -36,7 +36,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { /// Lens corrections embedded in a DNG's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`), re-expressed for /// the default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies. -pub fn embedded_lens(raw: &lightcraft_raw::RawImage) -> Option { +pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option { use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp}; use lightcraft_geom::Point; let (aw, ah) = (raw.active_area.width as f64, raw.active_area.height as f64); @@ -86,13 +86,14 @@ fn ext_upper(name: &str) -> String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default() } -/// Probe a file's bytes: kind, dimensions (oriented), metadata. +/// Probe a file's bytes: kind, dimensions (oriented), metadata. Raws are described from their +/// headers ([`lightcraft_raw::probe_info`]): no pixel data is decompressed. pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { let content_hash = Some(lightcraft_preview::hash_bytes(bytes).to_string()); let m = lightcraft_meta::extract(bytes); let (meta, captured) = meta_of(&m); if lightcraft_raw::probe(bytes).is_some() { - let raw = match lightcraft_raw::decode(bytes) { + let raw = match lightcraft_raw::probe_info(bytes) { Ok(r) => r, Err(lightcraft_raw::RawError::Unsupported(why)) => { // a raw variant we can't decode yet: describe it from its embedded preview @@ -120,7 +121,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { if raw.orientation.swaps_axes() { std::mem::swap(&mut w, &mut h); } - let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy(&raw)); + let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw)); let as_shot_wb = Some((t.round(), tint.round())); let embedded_lens = embedded_lens(&raw); return Ok(ProbeInfo { @@ -204,7 +205,7 @@ fn load_bytes_now(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), Err(e) => return Err(e.to_string()), }; // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in. - let lens = embedded_lens(&raw); + let lens = embedded_lens(&raw.info()); raw.opcodes.list3.retain(|op| !is_lens_opcode(op)); // Previews and thumbnails bin the mosaic straight to (about) the size they need; only // larger levels (exports, 1:1) demosaic the whole sensor. diff --git a/crates/raw/src/color.rs b/crates/raw/src/color.rs index 5180745f3..050d21eec 100644 --- a/crates/raw/src/color.rs +++ b/crates/raw/src/color.rs @@ -126,14 +126,22 @@ pub fn neutral_to_xy(color: &ColorData, neutral: [f64; 3]) -> Xy { /// The as-shot white point: `AsShotWhiteXY`, `AsShotNeutral`, vendor multipliers, else D65. pub fn as_shot_white_xy(raw: &RawImage) -> Xy { - let c = &raw.color; + as_shot_white(&raw.color, raw.wb_multipliers) +} + +/// [`as_shot_white_xy`] from a header-only [`crate::RawInfo`]. +pub fn as_shot_white_xy_of(info: &crate::RawInfo) -> Xy { + as_shot_white(&info.color, info.wb_multipliers) +} + +fn as_shot_white(c: &ColorData, wb_multipliers: Option<[f32; 3]>) -> Xy { if let Some(xy) = c.as_shot_white_xy { return xy; } if let Some(n) = c.as_shot_neutral { return neutral_to_xy(c, n); } - if let Some(m) = raw.wb_multipliers.filter(|m| m.iter().all(|v| *v > 0.0 && v.is_finite())) { + if let Some(m) = wb_multipliers.filter(|m| m.iter().all(|v| *v > 0.0 && v.is_finite())) { return neutral_to_xy(c, [1.0 / m[0] as f64, 1.0 / m[1] as f64, 1.0 / m[2] as f64]); } D65 diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 4ffb0666c..2b0db2bb9 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -1,8 +1,8 @@ //! DNG (Adobe Digital Negative Specification 1.7): raw IFD selection, pixel data (via [`crate::tiffraw`]), //! linearization, black/white levels, active area, default crop, CFA description, colour tags, opcode lists. -use crate::tiffraw::{Packing, read_image}; -use crate::{BlackLevel, Cfa, ColorData, Mat3, RawData, RawError, RawFormat, RawImage, Rect, Result, opcodes}; +use crate::tiffraw::{Packing, read_image_in}; +use crate::{BlackLevel, Cfa, ColorData, Mat3, Mode, RawData, RawError, RawFormat, RawImage, Rect, Result, opcodes}; use lightcraft_color::Xy; use lightcraft_geom::Orientation; use lightcraft_tiff::tags::{self as t, photometric}; @@ -48,7 +48,7 @@ pub(crate) fn color_data(ifd0: &Ifd, raw: &Ifd) -> ColorData { } } -pub(crate) fn decode(bytes: &[u8]) -> Result { +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { 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()))?; @@ -63,7 +63,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { if info.compression == t::compression::JPEG_XL { return Err(RawError::Unsupported("JPEG XL DNG".into())); } - let mut data = read_image(bytes, &info, tiff.order, Packing::Msb)?; + let mut data = read_image_in(mode, bytes, &info, tiff.order, Packing::Msb)?; let bits = info.bits() as u32; // linearization table (integer data only) @@ -162,6 +162,6 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { opcodes, metadata, }; - img.validate()?; + img.validate_for(mode)?; Ok(img) } diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 453c75231..d61e313b6 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -145,19 +145,75 @@ pub fn probe(bytes: &[u8]) -> Option { /// Decode a raw file. pub fn decode(bytes: &[u8]) -> Result { + decode_with(bytes, Mode::Full) +} + +/// 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 +/// [`RawImage::info`] of the decoded image. Black and white levels are not included (some formats +/// measure them from the samples). +/// +/// A few uncompressed vendor formats derive part of this from the samples themselves (Nikon +/// NEF: optically masked trailing columns; Olympus ORF: the CFA phase and bit depth; Pentax PEF +/// without crop tags: dark borders); for those the samples are read (unpacked, nothing to +/// decompress) and dropped. +pub fn probe_info(bytes: &[u8]) -> Result { + decode_with(bytes, Mode::Header).map(RawImage::into_info) +} + +/// How much of a file a decoder reads. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Mode { + /// Headers and samples. + Full, + /// Headers only: the returned image has no samples, and black/white levels measured from + /// the samples are placeholders. Only [`RawImage::into_info`] may look at it. + Header, +} + +fn decode_with(bytes: &[u8], mode: Mode) -> Result { match probe(bytes).ok_or(RawError::NotRaw)? { - RawFormat::Dng => dng::decode(bytes), - RawFormat::Cr2 => vendor::cr2::decode(bytes), + RawFormat::Dng => dng::decode(bytes, mode), + RawFormat::Cr2 => vendor::cr2::decode(bytes, mode), RawFormat::Nef | RawFormat::Nrw => vendor::nef::decode(bytes), - RawFormat::Arw => vendor::arw::decode(bytes), - RawFormat::Raf => vendor::raf::decode(bytes), - RawFormat::Rw2 => vendor::rw2::decode(bytes), - RawFormat::Pef => vendor::pef::decode(bytes), + RawFormat::Arw => vendor::arw::decode(bytes, mode), + RawFormat::Raf => vendor::raf::decode(bytes, mode), + RawFormat::Rw2 => vendor::rw2::decode(bytes, mode), + RawFormat::Pef => vendor::pef::decode(bytes, mode), RawFormat::Orf => vendor::orf::decode(bytes), other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))), } } +/// A raw file's description without its samples (see [`probe_info`]). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RawInfo { + pub format: RawFormat, + /// Full sensor data dimensions (including masked borders). + pub width: usize, + pub height: usize, + pub cpp: usize, + pub cfa: Option, + pub bits: u32, + pub active_area: Rect, + /// Default crop relative to the active area. + pub crop: Rect, + pub orientation: Orientation, + pub color: ColorData, + pub wb_multipliers: Option<[f32; 3]>, + pub opcodes: OpcodeLists, + pub metadata: Metadata, +} + +impl RawInfo { + /// Size of the developed image before orientation: the default crop, else the active area. + pub fn developed_size(&self) -> (usize, usize) { + let c = self.crop.clipped(self.active_area.width, self.active_area.height); + if c.width > 1 && c.height > 1 { (c.width, c.height) } else { (self.active_area.width, self.active_area.height) } + } +} + /// Sensor samples. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum RawData { @@ -377,12 +433,59 @@ pub struct Normalized { } impl RawImage { + /// The description without the samples (what [`probe_info`] returns for the same file). + pub fn info(&self) -> RawInfo { + RawInfo { + format: self.format, + width: self.width, + height: self.height, + cpp: self.cpp, + cfa: self.cfa.clone(), + bits: self.bits, + active_area: self.active_area, + crop: self.crop, + orientation: self.orientation, + color: self.color.clone(), + wb_multipliers: self.wb_multipliers, + opcodes: self.opcodes.clone(), + metadata: self.metadata.clone(), + } + } + + /// [`Self::info`], dropping the samples. + pub fn into_info(self) -> RawInfo { + RawInfo { + format: self.format, + width: self.width, + height: self.height, + cpp: self.cpp, + cfa: self.cfa, + bits: self.bits, + active_area: self.active_area, + crop: self.crop, + orientation: self.orientation, + color: self.color, + wb_multipliers: self.wb_multipliers, + opcodes: self.opcodes, + metadata: self.metadata, + } + } + + /// Validate what a decoder read in `mode` ([`Mode::Header`]: everything but the samples). + pub(crate) fn validate_for(&self, mode: Mode) -> Result<()> { + self.check(mode == Mode::Full) + } + /// Validate internal consistency (dimensions vs data length, CFA shape, rectangles). pub fn validate(&self) -> Result<()> { + self.check(true) + } + + fn check(&self, samples: bool) -> Result<()> { if self.width == 0 || self.height == 0 || !(1..=4).contains(&self.cpp) { return Err(RawError::Corrupt("bad dimensions".into())); } - if self.data.len() != self.width * self.height * self.cpp { + if samples && self.data.len() != self.width * self.height * self.cpp { return Err(RawError::Corrupt("data length mismatch".into())); } if let Some(c) = &self.cfa diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index fbf7e922e..7da804033 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -23,8 +23,9 @@ enum ChunkPx { F32(Vec), } -/// Decode all chunks of `info` into one buffer of `width × height × cpp` samples. -pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Packing) -> Result { +/// The checks [`read_image`] makes before decoding anything (sample format, size limits, data +/// present); returns the number of samples. +pub fn check_image(data: &[u8], info: &ImageInfo) -> Result { let (w, h) = (info.width as usize, info.height as usize); let cpp = info.samples_per_pixel as usize; let total = w.checked_mul(h).and_then(|v| v.checked_mul(cpp)).ok_or(RawError::Limit("image too large"))?; @@ -49,6 +50,29 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack if available.saturating_mul(2048) < total as u64 { return Err(RawError::Corrupt(format!("{available} bytes of image data cannot hold {total} samples"))); } + Ok(total) +} + +/// Read the samples of `info` in `mode`: [`Mode::Header`] only checks them ([`check_image`]) and +/// returns no samples (floating-point data as an empty `F32`). +pub(crate) fn read_image_in(mode: crate::Mode, data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Packing) -> Result { + match mode { + crate::Mode::Full => read_image(data, info, order, packing), + crate::Mode::Header => { + check_image(data, info)?; + Ok(if info.sample_format == 3 { RawData::F32(Vec::new()) } else { RawData::U16(Vec::new()) }) + } + } +} + +/// Decode all chunks of `info` into one buffer of `width × height × cpp` samples. +pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Packing) -> Result { + let (w, h) = (info.width as usize, info.height as usize); + let total = check_image(data, info)?; + let cpp = info.samples_per_pixel as usize; + let bits = info.bits() as u32; + let float = info.sample_format == 3; + let chunks = info.chunks(data.len() as u64); let planar = info.planar == 2 && cpp > 1; let ccpp = if planar { 1 } else { cpp }; let decoded: Vec> = diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 890200384..0e82a3810 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -16,8 +16,8 @@ //! //! Also: uncompressed 16-bit ARW, and lossless-JPEG tiled ARW through the generic TIFF path. -use crate::tiffraw::{Packing, read_image}; -use crate::{BlackLevel, Cfa, ColorData, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +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::tags::{self as t, photometric}; @@ -94,7 +94,7 @@ fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { .max_by_key(|i| i.u64(t::IMAGE_WIDTH).unwrap_or(0).saturating_mul(i.u64(t::IMAGE_LENGTH).unwrap_or(0))) } -pub(crate) fn decode(bytes: &[u8]) -> Result { +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Unsupported("ARW without a CFA image IFD (old ARW or SR2)".into()))?; @@ -107,6 +107,10 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { 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 { + 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) + } 32767 if chunks.len() == 1 && strip_len >= (w * h) as u64 && strip_len < (w * h) as u64 * 5 / 4 => { let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; let curve = code_curve(&raw.u64s(TONE_CURVE).unwrap_or_else(|| vec![8000, 10400, 12900, 14100])); @@ -123,9 +127,9 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { 32767 => return Err(RawError::Unsupported("Sony ARW version 1 / packed compressed variant".into())), 1 => { let packing = if strip_len >= (w * h * 2) as u64 { Packing::Word16 } else { Packing::Msb }; - (read_image(bytes, &info, tiff.order, packing)?, bits) + (read_image_in(mode, bytes, &info, tiff.order, packing)?, bits) } - _ => (read_image(bytes, &info, tiff.order, Packing::Msb)?, bits), + _ => (read_image_in(mode, bytes, &info, tiff.order, Packing::Msb)?, bits), }; let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float ARW".into())) }; @@ -171,7 +175,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { opcodes: OpcodeLists::default(), metadata, }; - img.validate()?; + img.validate_for(mode)?; Ok(img) } diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 601666d66..8629f75cf 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -14,7 +14,7 @@ //! - sRAW / mRAW (YCbCr, subsampled) are not supported yet. use super::{black_from_columns, white_from_data}; -use crate::{BlackLevel, Cfa, ColorData, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; +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::{Ifd, Tiff, makernote, tags as t}; @@ -45,7 +45,7 @@ fn wb_from_color_balance(v: &[u64]) -> Option<[f32; 3]> { None } -pub(crate) fn decode(bytes: &[u8]) -> Result { +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Corrupt("CR2 without raw IFD".into()))?; @@ -57,8 +57,24 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { let chunk = lightcraft_tiff::image::Chunk { index: 0, x: 0, y: 0, width: 0, height: 0, plane: 0, offset: off, len }; let src = chunk_bytes(bytes, &chunk).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; let (fw, fh, nc, prec) = ljpeg::frame_info(src)?; - let frame = ljpeg::decode(src, (fw * fh * nc).min(crate::MAX_SAMPLES))?; - let total = frame.data.len(); + let frame = match mode { + Mode::Full => Some(ljpeg::decode(src, (fw * fh * nc).min(crate::MAX_SAMPLES))?), + Mode::Header => None, + }; + let total = match &frame { + Some(f) => f.data.len(), + None => { + let total = fw + .checked_mul(fh) + .and_then(|v| v.checked_mul(nc)) + .filter(|t| *t > 0) + .ok_or_else(|| RawError::Corrupt("bad CR2 frame size".into()))?; + if total > crate::MAX_SAMPLES { + return Err(RawError::Limit("lossless JPEG frame larger than expected")); + } + total + } + }; let slices = raw.u64s(CR2_SLICE).filter(|s| s.len() == 3 && s[1] > 0 && s[2] > 0); let widths: Vec = match &slices { Some(s) => { @@ -74,16 +90,20 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { return Err(RawError::Corrupt(format!("CR2 slices ({width}) do not divide the frame ({total} samples)"))); } let height = total / width; - let mut data = vec![0u16; total]; - let mut i = 0; - let mut x0 = 0; - for &sw in &widths { - for y in 0..height { - data[y * width + x0..y * width + x0 + sw].copy_from_slice(&frame.data[i..i + sw]); - i += sw; + let mut data = Vec::new(); + if let Some(frame) = &frame { + data = vec![0u16; total]; + let mut i = 0; + let mut x0 = 0; + for &sw in &widths { + for y in 0..height { + data[y * width + x0..y * width + x0 + sw].copy_from_slice(&frame.data[i..i + sw]); + i += sw; + } + x0 += sw; } - x0 += sw; } + drop(frame); // maker note: sensor borders and white balance let make = ifd0.string(t::MAKE).unwrap_or_default(); @@ -126,7 +146,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { opcodes: OpcodeLists::default(), metadata, }; - img.validate()?; + img.validate_for(mode)?; Ok(img) } @@ -183,6 +203,7 @@ pub(crate) mod tests { assert_eq!(crate::probe(&bytes), Some(RawFormat::Cr2)); let r = crate::decode(&bytes).unwrap(); assert_eq!((r.width, r.height), (64, 10)); + assert_eq!(crate::probe_info(&bytes).unwrap(), r.info()); assert_eq!(r.data, RawData::U16(img)); assert_eq!(r.orientation, Orientation::Rotate270); assert_eq!(r.metadata.iso, Some(400)); diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index 57034d772..a6fb8c579 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -17,8 +17,8 @@ use super::{black_from_columns, white_from_data}; -use crate::tiffraw::{Packing, read_image}; -use crate::{BlackLevel, Cfa, ColorData, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +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::{ByteOrder, Tiff, makernote, tags as t}; @@ -184,7 +184,7 @@ fn dark_trimmed(d: &[u16], w: usize, h: usize) -> Rect { Rect::new(left, 0, (right - left) & !1, h) } -pub(crate) fn decode(bytes: &[u8]) -> Result { +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; let info = ifd0.image()?; @@ -196,6 +196,13 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { let make = ifd0.string(t::MAKE).unwrap_or_default(); let mn = tiff.exif().and_then(|e| e.get(t::MAKER_NOTE)).and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)); + let pair = |tag: u16| mn.as_ref().and_then(|m| m.ifd.u64s(tag)).filter(|v| v.len() == 2).map(|v| (v[0] as usize, v[1] as usize)); + let tagged = match (pair(CROP_ORIGIN), pair(CROP_SIZE)) { + (Some((x, y)), Some((cw, ch))) if cw > 0 && ch > 0 && x + cw <= w && y + ch <= hgt => Some(Rect::new(x, y, cw, ch)), + _ => None, + }; + // without crop tags the image area is found from the samples (dark borders) + let read = if tagged.is_some() { mode } else { Mode::Full }; let data = match info.compression { 65535 => { let mn = mn.as_ref().ok_or_else(|| RawError::Corrupt("compressed PEF without maker note".into()))?; @@ -208,21 +215,20 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { let src = chunk_bytes(bytes, &c) .or_else(|| bytes.get(first.offset as usize..)) .ok_or_else(|| RawError::Corrupt("PEF data outside file".into()))?; - RawData::U16(decode_huffman(src, &huff, w, hgt, bits)?) + if read == Mode::Full { RawData::U16(decode_huffman(src, &huff, w, hgt, bits)?) } else { RawData::U16(Vec::new()) } } 1 => { let strip: u64 = info.chunks(bytes.len() as u64).iter().map(|c| c.len).sum(); let packing = if strip >= (w * hgt * 2) as u64 { Packing::Word16 } else { Packing::Msb }; - read_image(bytes, &info, tiff.order, packing)? + read_image_in(read, bytes, &info, tiff.order, packing)? } c => return Err(RawError::Unsupported(format!("PEF compression {c}"))), }; let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float PEF".into())) }; - let pair = |tag: u16| mn.as_ref().and_then(|m| m.ifd.u64s(tag)).filter(|v| v.len() == 2).map(|v| (v[0] as usize, v[1] as usize)); - let active = match (pair(CROP_ORIGIN), pair(CROP_SIZE)) { - (Some((x, y)), Some((cw, ch))) if cw > 0 && ch > 0 && x + cw <= w && y + ch <= hgt => Rect::new(x, y, cw, ch), - _ => dark_trimmed(samples, w, hgt), + let active = match tagged { + Some(r) => r, + None => dark_trimmed(samples, w, hgt), }; 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() }, @@ -265,7 +271,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { opcodes: OpcodeLists::default(), metadata, }; - img.validate()?; + img.validate_for(mode)?; Ok(img) } diff --git a/crates/raw/src/vendor/raf.rs b/crates/raw/src/vendor/raf.rs index 45dd3e93c..e8ee126e7 100644 --- a/crates/raw/src/vendor/raf.rs +++ b/crates/raw/src/vendor/raf.rs @@ -21,7 +21,7 @@ use super::white_from_data; use crate::unpack::{unpack_lsb, unpack_msb}; -use crate::{BlackLevel, Cfa, ColorData, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; use lightcraft_geom::Orientation; use lightcraft_tiff::{ByteOrder, Ifd, Tiff}; use rayon::prelude::*; @@ -114,7 +114,7 @@ pub(crate) fn unpack_row(src: &[u8], bits: u32, out: &mut [u16]) { } } -pub(crate) fn decode(bytes: &[u8]) -> Result { +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let h = header(bytes)?; let raw = h.raw.ok_or_else(|| RawError::Corrupt("RAF without raw data".into()))?; let ifd = raw_ifd(raw)?; @@ -137,11 +137,14 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { return Err(RawError::Unsupported(format!("Fujifilm compressed RAF ({len} bytes for {w}x{hgt} {bits}-bit)"))); } let src = raw.get(off..off.saturating_add(len).min(raw.len())).ok_or_else(|| RawError::Corrupt("RAF strip outside file".into()))?; - let mut data = vec![0u16; n]; - data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { - let s = src.get(y * stride..(y * stride + packed_row).min(src.len())).unwrap_or(&[]); - unpack_row(s, store, row); - }); + let mut data = Vec::new(); + if mode == Mode::Full { + data = vec![0u16; n]; + data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let s = src.get(y * stride..(y * stride + packed_row).min(src.len())).unwrap_or(&[]); + unpack_row(s, store, row); + }); + } let cfa = cfa(&h); let active = match (pair(&h, CROP_TOP_LEFT), pair(&h, CROPPED_SIZE)) { @@ -186,7 +189,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { opcodes: OpcodeLists::default(), metadata, }; - img.validate()?; + img.validate_for(mode)?; Ok(img) } @@ -298,6 +301,7 @@ mod tests { let bytes = raf(w, h, bits, pack(&px, store), Some(layout), b"\xff\xd8\xff\xd9"); assert_eq!(crate::probe(&bytes), Some(RawFormat::Raf)); let r = crate::decode(&bytes).unwrap_or_else(|e| panic!("{bits}: {e}")); + assert_eq!(crate::probe_info(&bytes).unwrap(), r.info()); assert_eq!(r.data, RawData::U16(px), "{bits}-bit"); assert_eq!(r.cfa, Some(Cfa::xtrans())); assert_eq!(r.active_area, Rect::new(4, 2, 12, 4)); diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index 47b9e5436..d996d5b21 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -20,7 +20,7 @@ //! reconstruction rule for every scale, and no permissively licensed description exists. Those files report //! [`RawError::Unsupported`]; their embedded preview still works. -use crate::{BlackLevel, Cfa, ColorData, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; use lightcraft_geom::Orientation; use lightcraft_tiff::{Tiff, tags as t}; use rayon::prelude::*; @@ -97,7 +97,7 @@ fn cfa(code: u16) -> Cfa { Cfa::bayer(s).expect("static") } -pub(crate) fn decode(bytes: &[u8]) -> Result { +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; let get = |tag: u16| ifd0.u64(tag).map(|v| v as usize); @@ -118,9 +118,12 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { if src.len() < need { return Err(RawError::Corrupt(format!("RW2 raw data truncated ({} of {need} bytes)", src.len()))); } - let stream = unrotate(&src[..(need.div_ceil(CHUNK) * CHUNK).min(src.len())]); - let mut data = vec![0u16; n]; - unpack(&stream, bits, &mut data)?; + let mut data = Vec::new(); + if mode == Mode::Full { + let stream = unrotate(&src[..(need.div_ceil(CHUNK) * CHUNK).min(src.len())]); + data = vec![0u16; n]; + unpack(&stream, bits, &mut data)?; + } let active = match (get(BORDER_TOP), get(BORDER_LEFT), get(BORDER_BOTTOM), get(BORDER_RIGHT)) { (Some(tp), Some(l), Some(b), Some(r)) if b > tp && r > l => Rect::new(l, tp, r - l, b - tp).clipped(w, h), @@ -167,7 +170,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { opcodes: OpcodeLists::default(), metadata, }; - img.validate()?; + img.validate_for(mode)?; Ok(img) } @@ -235,6 +238,7 @@ mod tests { let bytes = rw2(w as u16, h as u16, bits as u16, 5, &encode(&px, bits)); assert_eq!(crate::probe(&bytes), Some(RawFormat::Rw2)); let r = crate::decode(&bytes).unwrap_or_else(|e| panic!("{bits}: {e}")); + assert_eq!(crate::probe_info(&bytes).unwrap(), r.info()); assert_eq!(r.data, RawData::U16(px), "{bits}-bit"); assert_eq!(r.active_area, Rect::new(4, 2, w - 4, h - 2)); assert_eq!(r.cfa.as_ref().unwrap().name(), "RGGB"); diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index e8f055cff..af47b84ae 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -5,7 +5,7 @@ //! `Unsupported` for one of the variants we know we don't decode yet. Prints decode times //! (`cargo test -p lightcraft-raw --release --test corpus -- --nocapture`). -use lightcraft_raw::{RawError, RawFormat, decode, embedded_preview, probe}; +use lightcraft_raw::{RawError, RawFormat, decode, embedded_preview, probe, probe_info}; use std::path::{Path, PathBuf}; use std::time::Instant; @@ -55,9 +55,20 @@ fn corpus_raw_decodes() { } let preview_kb = preview.as_ref().map_or(0, |p| p.len() / 1024); let t1 = Instant::now(); - match decode(&bytes) { + let decoded = decode(&bytes); + let dt = t1.elapsed().as_secs_f64() * 1e3; + // the header-only probe agrees with the full decode, faster + let t2 = Instant::now(); + let info = probe_info(&bytes); + let di = t2.elapsed().as_secs_f64() * 1e3; + match (&decoded, &info) { + (Ok(img), Ok(info)) => assert_eq!(&img.info(), info, "{name}: probe_info differs from decode"), + (Err(RawError::Unsupported(_)), Err(RawError::Unsupported(_))) => {} + (d, i) => panic!("{name}: decode {:?} but probe_info {:?}", d.as_ref().err(), i.as_ref().err()), + } + eprintln!("{name:44} probe_info {di:.1} ms"); + match decoded { Ok(img) => { - let dt = t1.elapsed().as_secs_f64() * 1e3; img.validate().unwrap(); assert!(img.white_at(0) > img.black.mean(), "{name}: white {} <= black {}", img.white_at(0), img.black.mean()); let mp = (img.width * img.height) as f64 / 1e6; diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index 6fa7b6703..654faa995 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -100,6 +100,8 @@ fn writer_roundtrip_all_layouts() { assert_eq!(probe(&bytes), Some(RawFormat::Dng)); let back = decode(&bytes).unwrap(); assert_same(&raw, &back); + // the header-only probe describes the same image + assert_eq!(lightcraft_raw::probe_info(&bytes).unwrap(), back.info()); } } }