From ecd6df686cd2875425aceefa0371747b28d1a55d Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 21 Aug 2026 23:00:10 +0200 Subject: [PATCH] Read the defect map a raw file carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of dead pixel removal, and the one that decides whether the rest is worth building: where the map comes from. `rawler` is no help. It knows `OpcodeList1/2/3` exist — it copies them through when *writing* a DNG — but it never decodes them, and the `dng_tags` map it exposes is only ever filled by callers, never by a decoder. So the bytes are read from the IFD directly, which this module was already walking for previews, including the SubIFDs where a DNG keeps its raw IFD. `OpcodeList1` specifically: lists 2 and 3 run after demosaic and after the colour transform, so neither can carry a correction that has to happen on the mosaic. Two opcodes describe defects — `FixBadPixelsList`, which is explicit coordinates plus whole dead rows and columns, and `FixBadPixelsConstant`, which names a sentinel value rather than any coordinates and is left unimplemented until there is a stage to consume it. A half-implementation that guessed at coordinates would be worse than the absence, because it would look like it worked. Two things the tests pin down because both are silent when wrong: a point is stored (row, column) and reading it the other way round lands the correction on the wrong photosite — invisibly, on a square crop — and opcode payloads are big-endian whatever the container's byte order is, so a little-endian TIFF still writes these the other way round. An unknown opcode is stepped over using its declared length rather than abandoning the list, because a camera that corrected its lens as well as its sensor writes both, and losing the map whenever a warp is present would be losing it on most files that have one. Includes `--example defects`, because whether any of this fires is a question about a particular library rather than about the specification. --- core/dr-decode/examples/defects.rs | 52 +++++ core/dr-decode/src/lib.rs | 5 +- core/dr-decode/src/locate.rs | 330 +++++++++++++++++++++++++++++ 3 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 core/dr-decode/examples/defects.rs diff --git a/core/dr-decode/examples/defects.rs b/core/dr-decode/examples/defects.rs new file mode 100644 index 0000000..00b9cad --- /dev/null +++ b/core/dr-decode/examples/defects.rs @@ -0,0 +1,52 @@ +//! Report the defect map a raw file carries, if it carries one. +//! +//! ```text +//! cargo run -p dr-decode --example defects -- IMG_6320.dng photo.cr2 +//! ``` +//! +//! Exists because whether this is worth building a correction stage for is a +//! question about *your files*, not about the specification: DNGs written by +//! cameras that map their own sensors carry `OpcodeList1`, conversions from a +//! proprietary raw usually do not, and no CR2 or scanner TIFF ever does. +//! Rather than guess, point this at the library and see. + +fn main() { + let files: Vec = std::env::args().skip(1).collect(); + if files.is_empty() { + eprintln!("usage: defects ..."); + std::process::exit(2); + } + + for path in &files { + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) => { + println!("{path}: unreadable — {e}"); + continue; + } + }; + + let found = dr_decode::defects(&bytes); + if found.is_empty() { + println!("{path}: no defect map"); + continue; + } + + println!( + "{path}: {} bad pixel(s), {} bad line(s)", + found.pixels.len(), + found.lines.len() + ); + // A handful, so the output stays readable on a sensor reporting + // hundreds — the count above is the number that matters. + for p in found.pixels.iter().take(8) { + println!(" pixel at {},{}", p.x, p.y); + } + for l in found.lines.iter().take(8) { + match l { + dr_decode::BadLine::Column(x) => println!(" dead column {x}"), + dr_decode::BadLine::Row(y) => println!(" dead row {y}"), + } + } + } +} diff --git a/core/dr-decode/src/lib.rs b/core/dr-decode/src/lib.rs index c430b6e..6f8d457 100644 --- a/core/dr-decode/src/lib.rs +++ b/core/dr-decode/src/lib.rs @@ -17,7 +17,10 @@ mod locate; mod preview; pub use error::DecodeError; -pub use locate::{is_complete_jpeg, locate_preview, PreviewLocation, HEADER_BYTES}; +pub use locate::{ + defects, is_complete_jpeg, locate_preview, BadLine, BadPixel, Defects, PreviewLocation, + HEADER_BYTES, +}; pub use preview::{ decode_jpeg, extract_embedded_preview, extract_preview, Preview, PreviewSize, PREVIEW_PROBE_BYTES, diff --git a/core/dr-decode/src/locate.rs b/core/dr-decode/src/locate.rs index fdc7d15..025ab7b 100644 --- a/core/dr-decode/src/locate.rs +++ b/core/dr-decode/src/locate.rs @@ -100,6 +100,11 @@ mod tag { pub const NEW_SUBFILE_TYPE: u16 = 0x00FE; pub const COMPRESSION: u16 = 0x0103; pub const SUB_IFDS: u16 = 0x014A; + /// DNG `OpcodeList1` — the opcodes a reader must apply to the raw mosaic + /// *before* anything else touches it, which is exactly where a bad pixel + /// has to be dealt with. Lists 2 and 3 run after demosaic and after the + /// colour transform, so neither can carry these. + pub const OPCODE_LIST_1: u16 = 51008; } /// JPEG compression, as opposed to raw sensor data. @@ -333,6 +338,29 @@ impl<'a> TiffReader<'a> { } /// An entry's values as a list of offsets (for SubIFDs). + /// An entry's bytes, wherever they live. + /// + /// Values of four bytes or fewer sit in the entry itself; anything longer + /// is an offset. An opcode list is always longer, but the inline case is + /// handled rather than assumed away — a file claiming a three-byte opcode + /// list is malformed, and reading it from the wrong place would be reading + /// somebody else's bytes. + fn value_bytes(&self, e: &Entry) -> Option<&'a [u8]> { + // UNDEFINED and BYTE are one byte per element; nothing else is a blob. + if e.kind != 1 && e.kind != 7 { + return None; + } + let len = e.count as usize; + if len <= 4 { + // The value field, in file order. It is stored as a u32 that was + // read with the file's endianness, so it has to be put back the + // same way to recover the original byte order. + return None; + } + let start = e.value as usize; + self.data.get(start..start.checked_add(len)?) + } + fn offsets(&self, e: &Entry) -> Vec { if e.kind != 4 { return Vec::new(); @@ -971,3 +999,305 @@ mod tests { assert!(!is_complete_jpeg(b"PNG\r\n")); } } + +// --------------------------------------------------------------------------- +// Bad pixels, as the file itself reports them +// --------------------------------------------------------------------------- + +/// A photosite the camera says is defective. +/// +/// Sensor coordinates, before any crop to the active area — which is what the +/// DNG specification defines them against, and what the mosaic is indexed by +/// at the point the correction has to run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BadPixel { + pub x: u32, + pub y: u32, +} + +/// A defective *column* or *row*, which cameras report far more often than +/// they report scattered points: a failed readout line takes out a whole file +/// of photosites at once. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BadLine { + Column(u32), + Row(u32), +} + +/// What a file says is wrong with its own sensor. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Defects { + pub pixels: Vec, + pub lines: Vec, +} + +impl Defects { + pub fn is_empty(&self) -> bool { + self.pixels.is_empty() && self.lines.is_empty() + } +} + +/// DNG opcode ids. Only the two that describe defects are read; the rest of +/// `OpcodeList1` is warp and vignette correction that belongs to other stages. +const OP_FIX_BAD_PIXELS_CONSTANT: u32 = 4; +const OP_FIX_BAD_PIXELS_LIST: u32 = 5; + +/// TRACES: FR-RAW-3 +/// Read the defect map the file carries, if it carries one. +/// +/// # Why this is parsed here and not taken from the decoder +/// +/// `rawler` knows these tags exist — it copies them through when *writing* a +/// DNG — but it never decodes them, and its `dng_tags` map is only ever filled +/// by callers. So the bytes have to be read from the IFD directly, which this +/// module was already walking for previews. +/// +/// # Coverage +/// +/// This is what the *file* claims, which is not the same as what is wrong with +/// the sensor. DNGs written by cameras that do their own mapping carry it; +/// most conversions from a proprietary raw do not, and no CR2 or scanner TIFF +/// has it at all. An empty result is therefore the normal case rather than a +/// failure, and means only that this source had nothing to say. +/// +/// Opcode payloads are **always big-endian**, whatever the TIFF's own byte +/// order — the specification fixes it, and a file whose IFDs are little-endian +/// still writes its opcodes the other way round. Reading these with the +/// container's endianness is the mistake this comment exists to prevent. +pub fn defects(tiff_data: &[u8]) -> Defects { + let mut found = Defects::default(); + let Some(tiff) = TiffReader::new(tiff_data) else { + return found; + }; + + for offset in tiff.ifd_offsets() { + let Some(entries) = tiff.read_ifd(offset) else { + continue; + }; + let Some(entry) = entries.iter().find(|e| e.tag == tag::OPCODE_LIST_1) else { + continue; + }; + let Some(bytes) = tiff.value_bytes(entry) else { + continue; + }; + read_opcode_list(bytes, &mut found); + } + + found +} + +/// Walk an opcode stream, collecting the defect opcodes and stepping over the +/// rest. +/// +/// Each opcode declares its own byte count, which is what makes it safe to +/// skip one this build does not implement rather than abandoning the list — +/// and lists mixing a warp with a defect map are ordinary. +fn read_opcode_list(bytes: &[u8], out: &mut Defects) { + let Some(count) = be_u32(bytes, 0) else { return }; + // A sensor has a handful of opcodes, not thousands. A huge count is a + // corrupt or hostile file. + if count > 256 { + return; + } + + let mut at = 4usize; + for _ in 0..count { + // id, version, flags, byte count — four u32s of header. + let (Some(id), Some(size)) = (be_u32(bytes, at), be_u32(bytes, at + 12)) else { + return; + }; + let payload = at + 16; + let Some(end) = payload.checked_add(size as usize) else { + return; + }; + let Some(body) = bytes.get(payload..end) else { + return; + }; + + match id { + OP_FIX_BAD_PIXELS_CONSTANT => read_bad_pixels_constant(body, out), + OP_FIX_BAD_PIXELS_LIST => read_bad_pixels_list(body, out), + // Warp, vignette, deltas — other stages' business. + _ => {} + } + + at = end; + } +} + +/// `FixBadPixelsConstant`: every photosite holding `constant` is dead. +/// +/// Not expanded into coordinates here, and it cannot be: the value names a +/// *condition*, and which photosites meet it is only knowable once the mosaic +/// is in hand. Recorded as nothing for now — the correction stage will need +/// the constant itself, not a list. +fn read_bad_pixels_constant(_body: &[u8], _out: &mut Defects) { + // Deliberately empty until the raw-domain stage exists to consume it. A + // half-implementation that guessed at coordinates would be worse than the + // absence, because it would look like it worked. +} + +/// `FixBadPixelsList`: explicit coordinates, and whole dead rows and columns. +/// +/// Layout after the two-field spacing header: a point count, a rect count, +/// then that many points as (row, column) and that many rects as +/// (top, left, bottom, right). Rows and columns are *rectangles* one unit +/// wide in the specification, which is why a single "bad column" arrives here +/// as a rect rather than as an index. +fn read_bad_pixels_list(body: &[u8], out: &mut Defects) { + // bayerPhase, then the two counts. + let (Some(points), Some(rects)) = (be_u32(body, 4), be_u32(body, 8)) else { + return; + }; + if points > 100_000 || rects > 10_000 { + return; + } + + let mut at = 12usize; + for _ in 0..points { + let (Some(row), Some(col)) = (be_u32(body, at), be_u32(body, at + 4)) else { + return; + }; + out.pixels.push(BadPixel { x: col, y: row }); + at += 8; + } + + for _ in 0..rects { + let (Some(top), Some(left), Some(bottom), Some(right)) = ( + be_u32(body, at), + be_u32(body, at + 4), + be_u32(body, at + 8), + be_u32(body, at + 12), + ) else { + return; + }; + // One unit wide in either direction is a line; anything else is an + // area, which no camera has been observed to report and which this + // does not invent a meaning for. + if right == left + 1 { + out.lines.push(BadLine::Column(left)); + } else if bottom == top + 1 { + out.lines.push(BadLine::Row(top)); + } + at += 16; + } +} + +/// Opcode payloads are big-endian regardless of the container's byte order. +fn be_u32(bytes: &[u8], at: usize) -> Option { + let slice = bytes.get(at..at.checked_add(4)?)?; + Some(u32::from_be_bytes([slice[0], slice[1], slice[2], slice[3]])) +} + +#[cfg(test)] +mod defect_tests { + use super::*; + + /// One opcode, framed as the specification frames it: id, version, flags, + /// payload length, payload. All big-endian, whatever the container is. + fn opcode(id: u32, body: &[u8]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&id.to_be_bytes()); + out.extend_from_slice(&1u32.to_be_bytes()); // version + out.extend_from_slice(&0u32.to_be_bytes()); // flags + out.extend_from_slice(&(body.len() as u32).to_be_bytes()); + out.extend_from_slice(body); + out + } + + fn opcode_list(opcodes: &[Vec]) -> Vec { + let mut out = (opcodes.len() as u32).to_be_bytes().to_vec(); + for o in opcodes { + out.extend_from_slice(o); + } + out + } + + /// A `FixBadPixelsList` payload: bayer phase, then points, then rects. + fn bad_pixel_list(points: &[(u32, u32)], rects: &[(u32, u32, u32, u32)]) -> Vec { + let mut out = 0u32.to_be_bytes().to_vec(); // bayerPhase + out.extend_from_slice(&(points.len() as u32).to_be_bytes()); + out.extend_from_slice(&(rects.len() as u32).to_be_bytes()); + for (row, col) in points { + out.extend_from_slice(&row.to_be_bytes()); + out.extend_from_slice(&col.to_be_bytes()); + } + for (t, l, b, r) in rects { + out.extend_from_slice(&t.to_be_bytes()); + out.extend_from_slice(&l.to_be_bytes()); + out.extend_from_slice(&b.to_be_bytes()); + out.extend_from_slice(&r.to_be_bytes()); + } + out + } + + #[test] + fn a_list_of_points_is_read_as_sensor_coordinates() { + // The specification orders a point (row, column). Reading it the other + // way round is the mistake that produces a correction which lands on + // the wrong photosite — and on a square crop, silently. + let list = bad_pixel_list(&[(7, 3), (100, 200)], &[]); + let mut out = Defects::default(); + read_opcode_list(&opcode_list(&[opcode(5, &list)]), &mut out); + + assert_eq!( + out.pixels, + vec![BadPixel { x: 3, y: 7 }, BadPixel { x: 200, y: 100 }] + ); + assert!(out.lines.is_empty()); + } + + #[test] + fn a_one_wide_rectangle_is_a_dead_column_and_a_one_tall_one_is_a_row() { + // Which is how a failed readout line is reported: the specification has + // no "column" type, only a rectangle that happens to be one wide. + let list = bad_pixel_list(&[], &[(0, 42, 4000, 43), (17, 0, 18, 6000)]); + let mut out = Defects::default(); + read_opcode_list(&opcode_list(&[opcode(5, &list)]), &mut out); + + assert_eq!(out.lines, vec![BadLine::Column(42), BadLine::Row(17)]); + } + + #[test] + fn an_opcode_this_build_does_not_implement_is_stepped_over() { + // Lists mixing a warp with a defect map are ordinary, and each opcode + // declares its own length precisely so an unknown one can be skipped. + // Abandoning the list at the first unfamiliar id would lose the map + // whenever the camera also corrected its lens. + let warp = opcode(1, &[0xAB; 40]); + let list = opcode(5, &bad_pixel_list(&[(1, 2)], &[])); + let mut out = Defects::default(); + read_opcode_list(&opcode_list(&[warp, list]), &mut out); + + assert_eq!(out.pixels, vec![BadPixel { x: 2, y: 1 }]); + } + + #[test] + fn a_truncated_list_yields_what_was_read_rather_than_a_panic() { + // Files are damaged in transit and cameras write bugs. Nothing here + // may index past the end. + let full = opcode_list(&[opcode(5, &bad_pixel_list(&[(1, 2), (3, 4)], &[]))]); + for cut in 0..full.len() { + let mut out = Defects::default(); + read_opcode_list(&full[..cut], &mut out); + } + } + + #[test] + fn a_file_with_no_opcodes_reports_no_defects() { + // The normal case, and not a failure: a CR2 or a scanner TIFF has + // nothing to say about its own sensor. + assert!(defects(&[]).is_empty()); + assert!(defects(b"II*\0\x08\0\0\0\0\0").is_empty()); + } + + #[test] + fn an_absurd_opcode_count_is_refused() { + // A corrupt length field must not become an allocation. + let mut bytes = u32::MAX.to_be_bytes().to_vec(); + bytes.extend_from_slice(&[0u8; 32]); + let mut out = Defects::default(); + read_opcode_list(&bytes, &mut out); + assert!(out.is_empty()); + } +}