Read the defect map a raw file carries
Build and test / Desktop (Linux) (push) Failing after 54s
Build and test / Layer separation (push) Successful in 22s
Traceability / Requirement traces (push) Failing after 59s
🐳 Android image / Build and push (push) Successful in 12m59s
Build and test / android-image (push) Successful in 13m1s
Build and test / Android (aarch64) (push) Failing after 9m42s

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.
This commit is contained in:
2026-08-21 23:00:10 +02:00
parent caf61d41a5
commit ecd6df686c
3 changed files with 386 additions and 1 deletions
+4 -1
View File
@@ -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,
+330
View File
@@ -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<u32> {
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<BadPixel>,
pub lines: Vec<BadLine>,
}
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<u32> {
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<u8> {
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<u8>]) -> Vec<u8> {
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<u8> {
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());
}
}