M3.7: Canon CR2 decoding + rawinfo/rawrender examples

IFD#3 lossless JPEG with 0xc640 slice reassembly, SensorInfo borders,
masked-column black levels per CFA position, saturation-plateau white,
as-shot WB from ColorBalance. Verified bit-identical to Adobe's DNG of
the same 5D Mark III frame. sRAW/mRAW reported as unsupported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 11:43:46 -04:00
parent 05450c791c
commit 741e93e632
5 changed files with 479 additions and 4 deletions

View File

@ -0,0 +1,133 @@
//! `cargo run --release -p lightcraft-raw --example rawinfo -- [--tree] FILE...`
//!
//! Prints the TIFF structure (with `--tree`), decode result, timings (MP/s) and basic statistics.
use lightcraft_raw::{Method, RawData, decode, embedded_preview, probe};
use lightcraft_tiff::{Ifd, Tiff, Value};
use std::time::Instant;
fn short(v: &Value) -> String {
let s = match v {
Value::Ascii(s) => format!("{s:?}"),
Value::Byte(b) | Value::Undefined(b) => format!("{} bytes {:02x?}", b.len(), &b[..b.len().min(12)]),
other => {
let f = other.to_f64_vec();
format!("{:?}{}", &f[..f.len().min(12)], if f.len() > 12 { format!(" …({})", f.len()) } else { String::new() })
}
};
s.chars().take(160).collect()
}
fn dump(ifd: &Ifd, depth: usize, label: &str) {
let pad = " ".repeat(depth);
println!("{pad}{label} @{} ({} entries)", ifd.offset, ifd.entries.len());
for e in &ifd.entries {
println!("{pad} {:5} 0x{:04x} {:?}: {}", e.tag, e.tag, e.field_type(), short(&e.value));
}
for (i, s) in ifd.sub_ifds.iter().enumerate() {
dump(s, depth + 1, &format!("SubIFD[{i}]"));
}
for (n, c) in [("Exif", &ifd.exif), ("GPS", &ifd.gps), ("Interop", &ifd.interop)] {
if let Some(c) = c {
dump(c, depth + 1, n);
}
}
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let tree = args.iter().any(|a| a == "--tree");
for path in args.iter().filter(|a| !a.starts_with("--")) {
let bytes = std::fs::read(path).expect("read");
println!("== {path} ({:.1} MB) format {:?}", bytes.len() as f64 / 1e6, probe(&bytes));
if tree && let Ok(t) = Tiff::parse(&bytes) {
for (i, ifd) in t.ifds.iter().enumerate() {
dump(ifd, 0, &format!("IFD{i}"));
}
}
let t0 = Instant::now();
match decode(&bytes) {
Ok(r) => {
let dt = t0.elapsed().as_secs_f64();
let mp = (r.width * r.height) as f64 / 1e6;
let (mn, mx, mean) = match &r.data {
RawData::U16(v) => {
let mn = *v.iter().min().unwrap_or(&0) as f64;
let mx = *v.iter().max().unwrap_or(&0) as f64;
(mn, mx, v.iter().map(|&x| x as f64).sum::<f64>() / v.len() as f64)
}
RawData::F32(v) => (
v.iter().cloned().fold(f32::MAX, f32::min) as f64,
v.iter().cloned().fold(f32::MIN, f32::max) as f64,
v.iter().map(|&x| x as f64).sum::<f64>() / v.len() as f64,
),
};
println!(
" {}x{} cpp {} bits {} cfa {:?} black {:.1} white {:?} active {:?} crop {:?} orient {:?}",
r.width,
r.height,
r.cpp,
r.bits,
r.cfa.as_ref().map(|c| c.name()),
r.black.mean(),
r.white,
r.active_area,
r.crop,
r.orientation
);
println!(
" data min {mn} max {mx} mean {mean:.1}; wb {:?}; opcodes {}/{}/{}",
r.wb_multipliers,
r.opcodes.list1.len(),
r.opcodes.list2.len(),
r.opcodes.list3.len()
);
println!(" decode {:.0} ms ({:.0} MP/s)", dt * 1e3, mp / dt);
println!(
" meta: {:?} {:?} lens {:?} iso {:?} f {:?} t {:?} date {:?}",
r.metadata.make,
r.metadata.model,
r.metadata.lens_model,
r.metadata.iso,
r.metadata.f_number,
r.metadata.exposure_display(),
r.metadata.capture_time.map(|d| d.to_iso())
);
let xy = lightcraft_raw::color::as_shot_white_xy(&r);
let ct = lightcraft_raw::color::camera_transform(&r, xy);
println!(" as-shot xy ({:.4}, {:.4}) wb {:?} fallback {}", xy.x, xy.y, ct.wb, ct.matrix_is_fallback);
for m in [Method::Bilinear, Method::Ppg, Method::Ahd] {
let t1 = Instant::now();
match r.develop(m) {
Ok(img) => {
let dt = t1.elapsed().as_secs_f64();
let mut s = [0f64; 3];
for p in &img.data {
for c in 0..3 {
s[c] += p[c] as f64;
}
}
let n = img.data.len() as f64;
println!(
" develop {m:?}: {}x{} in {:.0} ms ({:.0} MP/s), mean rgb {:.3} {:.3} {:.3}",
img.width,
img.height,
dt * 1e3,
mp / dt,
s[0] / n,
s[1] / n,
s[2] / n
);
}
Err(e) => println!(" develop {m:?}: {e}"),
}
}
}
Err(e) => println!(" decode error: {e}"),
}
match embedded_preview(&bytes) {
Some(p) => println!(" preview: {} bytes", p.len()),
None => println!(" preview: none"),
}
}
}

View File

@ -0,0 +1,64 @@
//! `cargo run --release -p lightcraft-raw --example rawrender -- IN OUT.tif [bilinear|ppg|ahd] [max_edge]`
//!
//! Quick visual check: develop → as-shot WB + camera matrix → sRGB, oriented, box-downscaled, 8-bit TIFF.
use lightcraft_color::{REC2020, SRGB, transfer::encode_srgb8};
use lightcraft_raw::{Method, color, decode};
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags};
fn main() {
let a: Vec<String> = std::env::args().skip(1).collect();
let bytes = std::fs::read(&a[0]).expect("read");
let method = match a.get(2).map(String::as_str) {
Some("bilinear") => Method::Bilinear,
Some("ppg") => Method::Ppg,
_ => Method::Ahd,
};
let max_edge: usize = a.get(3).and_then(|s| s.parse().ok()).unwrap_or(1600);
let raw = decode(&bytes).expect("decode");
let mut img = raw.develop(method).expect("develop");
let xy = color::as_shot_white_xy(&raw);
let t = color::camera_transform(&raw, xy);
let wb = t.wb;
lightcraft_raw::highlight::reconstruct(&mut img, wb, 0.99);
let to_srgb = REC2020.to_space(&SRGB).mul(&t.matrix);
let gain = 2f32.powf(t.baseline_exposure as f32);
let img = img.map(|p| {
let q = to_srgb.apply_f32([p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]);
// simple highlight roll-off so clipped areas are not harsh
q.map(|v| {
let v = (v * gain).max(0.0);
if v < 0.8 { v } else { 0.8 + 0.2 * (1.0 - (-(v - 0.8) / 0.2).exp()) }
})
});
let img = img.oriented(raw.orientation);
let f = img.width.max(img.height).div_ceil(max_edge).max(1);
let (w, h) = (img.width / f, img.height / f);
let mut px = Vec::with_capacity(w * h * 3);
for y in 0..h {
for x in 0..w {
let mut s = [0f32; 3];
for dy in 0..f {
for dx in 0..f {
let p = img.get(x * f + dx, y * f + dy);
for c in 0..3 {
s[c] += p[c];
}
}
}
for c in 0..3 {
px.push(encode_srgb8(s[c] / (f * f) as f32));
}
}
}
let mut ifd = IfdBuilder::new();
ifd.set(tags::IMAGE_WIDTH, Value::Long(vec![w as u32]));
ifd.set(tags::IMAGE_LENGTH, Value::Long(vec![h as u32]));
ifd.set(tags::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]));
ifd.set(tags::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
ifd.set(tags::PHOTOMETRIC, Value::Short(vec![2]));
ifd.set(tags::COMPRESSION, Value::Short(vec![1]));
ifd.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![px] });
std::fs::write(&a[1], TiffWriter::default().write(&[ifd]).unwrap()).expect("write");
println!("{}x{} wb {:?} xy ({:.4},{:.4}) fallback {}", w, h, wb, xy.x, xy.y, t.matrix_is_fallback);
}

View File

@ -113,6 +113,9 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
i.u16(lightcraft_tiff::tags::PHOTOMETRIC) == Some(lightcraft_tiff::tags::photometric::CFA)
|| i.u16(lightcraft_tiff::tags::COMPRESSION).is_some_and(|c| c == 34713 || c == 32767 || c == 32769 || c == 32770)
});
if make.starts_with("CANON") && t.ifds.len() >= 4 && t.ifds[3].u16(lightcraft_tiff::tags::COMPRESSION) == Some(6) {
return Some(RawFormat::Cr2);
}
if make.starts_with("NIKON") {
return Some(if has_cfa || t.all_ifds().len() > 1 { RawFormat::Nef } else { RawFormat::Nrw });
}

View File

@ -1,5 +1,201 @@
use crate::{RawError, RawImage, Result};
//! Canon CR2.
//!
//! Source: Laurent Clévy, "Understanding what is stored in a Canon RAW .CR2 file" (prose sections on the file
//! structure, IFD#3, the `0xc640` slice layout and the maker-note `SensorInfo` table), plus the ExifTool Canon
//! tag-name documentation for maker-note tag meanings. Only the structural description was used.
//!
//! - The raw data is IFD#3's single strip: a lossless-JPEG frame (usually 2 or 4 components).
//! - The decoded sample stream fills vertical slices left to right: `cr2_slice = [n, w, last]` means `n` slices of
//! width `w` then one of width `last`; each slice is filled top to bottom, row by row.
//! - `SensorInfo` (maker note `0x00e0`): sensor width/height and the left/top/right/bottom borders of the image
//! area; the masked columns left of it give the black level.
//! - `ColorBalance` (maker note `0x4001`): as-shot `RGGB` levels at a model-dependent offset; we probe the known
//! offsets and accept the first plausible quadruple.
//! - sRAW / mRAW (YCbCr, subsampled) are not supported yet.
pub(crate) fn decode(_bytes: &[u8]) -> Result<RawImage> {
Err(RawError::Unsupported("cr2: not yet".into()))
use super::{black_from_columns, white_from_data};
use crate::{BlackLevel, Cfa, ColorData, 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};
const CR2_SLICE: u16 = 0xc640;
const SRAW_TYPE: u16 = 0xc6c5;
const SENSOR_INFO: u16 = 0x00e0;
const COLOR_BALANCE: u16 = 0x4001;
fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
tiff.ifds.get(3).or_else(|| tiff.ifds.iter().rev().find(|i| i.contains(CR2_SLICE)))
}
/// As-shot WB multipliers (R, G, B; G = 1) from the ColorBalance array.
fn wb_from_color_balance(v: &[u64]) -> Option<[f32; 3]> {
for off in [63usize, 25, 24, 34, 71, 85, 105, 69, 77] {
let q = v.get(off..off + 4)?;
let (r, g1, g2, b) = (q[0] as f32, q[1] as f32, q[2] as f32, q[3] as f32);
if g1 < 256.0 || g2 < 256.0 || r < 64.0 || b < 64.0 || g1 > 16384.0 || (g1 - g2).abs() > 0.05 * g1 {
continue;
}
let g = (g1 + g2) / 2.0;
let (mr, mb) = (r / g, b / g);
if (0.25..6.0).contains(&mr) && (0.25..6.0).contains(&mb) {
return Some([mr, 1.0, mb]);
}
}
None
}
pub(crate) fn decode(bytes: &[u8]) -> 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()))?;
if raw.u32(SRAW_TYPE).is_some_and(|v| v != 1) {
return Err(RawError::Unsupported("Canon sRAW/mRAW".into()));
}
let off = raw.u64(t::STRIP_OFFSETS).ok_or(RawError::Tiff(lightcraft_tiff::TiffError::MissingTag(t::STRIP_OFFSETS)))?;
let len = raw.u64(t::STRIP_BYTE_COUNTS).unwrap_or(bytes.len() as u64 - off.min(bytes.len() as u64));
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 slices = raw.u64s(CR2_SLICE).filter(|s| s.len() == 3 && s[1] > 0 && s[2] > 0);
let widths: Vec<usize> = match &slices {
Some(s) => {
let n = s[0].min(64) as usize;
let mut v = vec![s[1] as usize; n];
v.push(s[2] as usize);
v
}
None => vec![fw * nc],
};
let width: usize = widths.iter().sum();
if width == 0 || total % width != 0 {
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;
}
x0 += sw;
}
// maker note: sensor borders and white balance
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 mut active = Rect::new(0, 0, width, height);
if let Some(si) = mn.as_ref().and_then(|m| m.ifd.u64s(SENSOR_INFO)).filter(|v| v.len() >= 9) {
let (l, tp, r, b) = (si[5] as usize, si[6] as usize, si[7] as usize, si[8] as usize);
if r > l && b > tp && r < width && b < height {
active = Rect::new(l, tp, r - l + 1, b - tp + 1);
}
}
let wb = mn.as_ref().and_then(|m| m.ifd.u64s(COLOR_BALANCE)).and_then(|v| wb_from_color_balance(&v));
let cfa = Cfa::bayer("RGGB").expect("static");
let black = if active.x >= 8 {
black_from_columns(&data, width, 2..active.x - 2, active.y..active.y + active.height, active)
} else {
BlackLevel::uniform(0.0)
};
let white = white_from_data(&data, prec as u32);
let mut metadata = lightcraft_meta::from_tiff(&tiff);
metadata.width = Some(active.width as u32);
metadata.height = Some(active.height as u32);
let img = RawImage {
format: RawFormat::Cr2,
width,
height,
cpp: 1,
data: RawData::U16(data),
cfa: Some(cfa),
bits: prec as u32,
black,
white: vec![white],
active_area: active,
crop: Rect::new(0, 0, active.width, active.height),
orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)),
color: ColorData::default(),
wb_multipliers: wb,
linearized: false,
opcodes: OpcodeLists::default(),
metadata,
};
img.validate()?;
Ok(img)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value};
/// Build a synthetic CR2: IFD0..IFD2 placeholders + IFD3 holding a sliced 2-component LJ92 frame.
pub(crate) fn synthetic_cr2(w: usize, h: usize, slices: Option<[u16; 3]>) -> (Vec<u8>, Vec<u16>) {
let img: Vec<u16> = (0..w * h).map(|i| 1024 + ((i * 7919) % 12000) as u16).collect();
let widths: Vec<usize> = match slices {
Some([n, sw, last]) => {
let mut v = vec![sw as usize; n as usize];
v.push(last as usize);
v
}
None => vec![w],
};
let mut stream = Vec::with_capacity(w * h);
let mut x0 = 0;
for &sw in &widths {
for y in 0..h {
stream.extend_from_slice(&img[y * w + x0..y * w + x0 + sw]);
}
x0 += sw;
}
let enc = crate::ljpeg::encode(&stream, w / 2, h, 2, 14, 1, 0);
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::MAKE, Value::Ascii("Canon".into()));
ifd0.set(t::MODEL, Value::Ascii("Canon EOS Test".into()));
ifd0.set(t::ORIENTATION, Value::Short(vec![8]));
// maker note: SensorInfo + ColorBalance, plain IFD with offsets relative to the TIFF header
let mut exif = IfdBuilder::new();
exif.set(t::ISO_SPEED, Value::Short(vec![400]));
ifd0.set_child(t::EXIF_IFD, exif);
let mut ifd3 = IfdBuilder::new();
ifd3.set(t::COMPRESSION, Value::Short(vec![6]));
if let Some(s) = slices {
ifd3.set(CR2_SLICE, Value::Short(s.to_vec()));
}
ifd3.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![enc] });
// (real files also carry a "CR\x02\0" signature at offset 8; probe recognises the structure without it)
let bytes = TiffWriter::new(ByteOrder::Little, false)
.write(&[ifd0, IfdBuilder::new().with(1, Value::Short(vec![0])), IfdBuilder::new().with(1, Value::Short(vec![0])), ifd3])
.unwrap();
(bytes, img)
}
#[test]
fn synthetic_roundtrip() {
for slices in [None, Some([2u16, 24, 16]), Some([1, 40, 24])] {
let (bytes, img) = synthetic_cr2(64, 10, slices);
assert_eq!(crate::probe(&bytes), Some(RawFormat::Cr2));
let r = crate::decode(&bytes).unwrap();
assert_eq!((r.width, r.height), (64, 10));
assert_eq!(r.data, RawData::U16(img));
assert_eq!(r.orientation, Orientation::Rotate270);
assert_eq!(r.metadata.iso, Some(400));
assert!(r.develop(crate::Method::Ahd).is_ok());
}
}
#[test]
fn wb_probe() {
let mut v = vec![0u64; 120];
v[63..67].copy_from_slice(&[2000, 1024, 1026, 1500]);
let wb = wb_from_color_balance(&v).unwrap();
assert!((wb[0] - 2000.0 / 1025.0).abs() < 1e-4 && (wb[2] - 1500.0 / 1025.0).abs() < 1e-4);
assert!(wb_from_color_balance(&[0; 10]).is_none());
}
}

View File

@ -1,5 +1,84 @@
//! Vendor raw formats (TIFF-based).
//! Vendor raw formats (TIFF-based) and helpers shared by them.
pub mod arw;
pub mod cr2;
pub mod nef;
use crate::{BlackLevel, Rect};
use std::ops::Range;
/// Black level per 2×2 CFA position (anchored at the active area origin) from masked sensor columns `cols` over
/// rows `rows`. Falls back to 0 when the region is empty.
pub(crate) fn black_from_columns(data: &[u16], width: usize, cols: Range<usize>, rows: Range<usize>, active: Rect) -> BlackLevel {
let height = data.len() / width.max(1);
let (mut sum, mut n) = ([0f64; 4], [0u64; 4]);
for y in rows.start..rows.end.min(height) {
let py = (y as isize - active.y as isize).rem_euclid(2) as usize;
for x in cols.start..cols.end.min(width) {
let px = (x as isize - active.x as isize).rem_euclid(2) as usize;
sum[py * 2 + px] += data[y * width + x] as f64;
n[py * 2 + px] += 1;
}
}
if n.contains(&0) {
return BlackLevel::uniform(0.0);
}
BlackLevel { repeat_rows: 2, repeat_cols: 2, values: (0..4).map(|i| (sum[i] / n[i] as f64) as f32).collect(), delta_h: vec![], delta_v: vec![] }
}
/// White level estimate: the saturation plateau if a noticeable number of samples sit at the maximum value,
/// otherwise the full `bits` range.
pub(crate) fn white_from_data(data: &[u16], bits: u32) -> f32 {
let full = ((1u32 << bits.clamp(1, 16)) - 1) as f32;
let Some(&mx) = data.iter().max() else { return full };
let band = (mx / 256).max(1);
let near = mx.saturating_sub(band);
let below = near.saturating_sub(band);
let step = (data.len() / 2_000_000).max(1);
let (mut top, mut under) = (0usize, 0usize);
for &v in data.iter().step_by(step) {
if v >= near {
top += 1;
} else if v >= below {
under += 1;
}
}
// a saturation plateau is a spike: many more samples in the top band than in the band just below it
if mx as f32 > full * 0.5 && top * step >= (data.len() / 20_000).max(16) && top >= 4 * under + 4 {
// plateau: use its lower edge so everything at saturation maps to ≥ 1.0
near as f32
} else {
full.max(mx as f32)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn black_columns_by_parity() {
// 8 wide, masked columns 0..4, active starts at x=4 (even)
let data: Vec<u16> = (0..8 * 6)
.map(|i| {
let (x, y) = (i % 8, i / 8);
if x < 4 { [100, 101, 102, 103][(y % 2) * 2 + x % 2] } else { 5000 }
})
.collect();
let b = black_from_columns(&data, 8, 0..4, 0..6, Rect::new(4, 0, 4, 6));
assert_eq!(b.values, vec![100.0, 101.0, 102.0, 103.0]);
let b = black_from_columns(&data, 8, 0..4, 0..6, Rect::new(3, 1, 4, 5));
assert_eq!(b.at(0, 0, 0, 1), 103.0);
assert_eq!(black_from_columns(&data, 8, 0..0, 0..6, Rect::new(0, 0, 8, 6)), BlackLevel::uniform(0.0));
}
#[test]
fn white_plateau() {
let mut d: Vec<u16> = (0..100_000).map(|i| (i % 10000) as u16).collect();
assert_eq!(white_from_data(&d, 14), 16383.0);
d.extend(std::iter::repeat_n(15000u16, 1000));
let w = white_from_data(&d, 14);
assert!((14900.0..=15000.0).contains(&w), "{w}");
assert_eq!(white_from_data(&[], 12), 4095.0);
}
}