M5.6b: header-only raw probe (lightcraft_raw::probe_info); import no longer decodes pixels

Decoders take a Mode: Header reads everything but the samples (DNG, CR2, ARW,
RAF, RW2; PEF when it has crop tags). NEF/ORF (and PEF without crop tags)
derive geometry/CFA from the samples and still unpack them. decode() stays
bit-exact (debug-hash of every corpus/raw decode identical before/after); the
corpus test asserts probe_info == decode().info() for all 37 files.

Import of 14 corpus raws: 3.4 s -> 1.4-2.0 s; peak RSS through import
1.41-1.49 GB -> 1.12-1.22 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 10:46:54 -04:00
co-authored by Claude Opus 5.5
parent 78e50b5fc5
commit c937ff97ee
13 changed files with 259 additions and 68 deletions
+4 -1
View File
@@ -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]| {
+6 -5
View File
@@ -36,7 +36,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
/// 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<lightcraft_develop::EmbeddedLens> {
pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option<lightcraft_develop::EmbeddedLens> {
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<ProbeInfo, String> {
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<ProbeInfo, String> {
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.
+10 -2
View File
@@ -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
+5 -5
View File
@@ -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<RawImage> {
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> 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()))?;
@@ -63,7 +63,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
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<RawImage> {
opcodes,
metadata,
};
img.validate()?;
img.validate_for(mode)?;
Ok(img)
}
+110 -7
View File
@@ -145,19 +145,75 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
/// Decode a raw file.
pub fn decode(bytes: &[u8]) -> Result<RawImage> {
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<RawInfo> {
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<RawImage> {
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<Cfa>,
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
+26 -2
View File
@@ -23,8 +23,9 @@ enum ChunkPx {
F32(Vec<f32>),
}
/// 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<RawData> {
/// 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<usize> {
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<RawData> {
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<RawData> {
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<Result<(Chunk, ChunkPx)>> =
+10 -6
View File
@@ -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<RawImage> {
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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<RawImage> {
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<RawImage> {
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<RawImage> {
opcodes: OpcodeLists::default(),
metadata,
};
img.validate()?;
img.validate_for(mode)?;
Ok(img)
}
+34 -13
View File
@@ -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<RawImage> {
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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<RawImage> {
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<usize> = match &slices {
Some(s) => {
@@ -74,16 +90,20 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
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<RawImage> {
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));
+16 -10
View File
@@ -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<RawImage> {
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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<RawImage> {
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<RawImage> {
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<RawImage> {
opcodes: OpcodeLists::default(),
metadata,
};
img.validate()?;
img.validate_for(mode)?;
Ok(img)
}
+12 -8
View File
@@ -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<RawImage> {
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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<RawImage> {
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<RawImage> {
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));
+10 -6
View File
@@ -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<RawImage> {
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
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<RawImage> {
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<RawImage> {
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");
+14 -3
View File
@@ -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;
+2
View File
@@ -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());
}
}
}