Files
lightcraft/crates/raw/tests/corpus.rs
T
2026-10-13 11:10:46 -05:00

1171 lines
61 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Corpus test over `corpus/raw/**` (git-ignored CC0 samples from raw.pixls.us, fetched with
//! `cargo xtask corpus --download`; `LIGHTCRAFT_CORPUS` overrides the corpus root). Skips cleanly when absent.
//!
//! Every file must be recognised, carry an embedded JPEG preview (optional for DNG, older Panasonic RAW and HEVC-preview CR3), and either decode to a valid image or report
//! `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, probe_info};
use std::path::{Path, PathBuf};
use std::time::Instant;
fn corpus_root() -> PathBuf {
std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
}
/// Variants known not to decode yet (see the crate docs): matched against the lower-case file name.
const KNOWN_UNSUPPORTED: &[&str] = &[
"cr3-", // Unverified CRX coding variants; exact supported cases live in cr3_corpus.rs.
"orf-olympus-em", // Olympus compressed ORF
"-he.nef", // Nikon High Efficiency (HE / HE★) NEF, issue #193
"sraw", // Canon sRAW / mRAW
];
#[test]
fn corpus_raw_decodes() {
let dir = corpus_root().join("raw");
let Ok(rd) = std::fs::read_dir(&dir) else {
eprintln!("skip: {} absent", dir.display());
return;
};
let mut paths: Vec<PathBuf> = rd.flatten().map(|e| e.path()).filter(|p| p.is_file()).collect();
paths.sort();
let (mut ok, mut unsupported) = (0, 0);
for p in paths {
let name = p.file_name().unwrap().to_string_lossy().to_lowercase();
if name.ends_with(".part") || name.ends_with(".txt") || name.ends_with(".md") {
continue;
}
let bytes = std::fs::read(&p).unwrap();
let fmt = probe(&bytes).unwrap_or_else(|| panic!("{name}: not recognised"));
let t0 = Instant::now();
let preview = embedded_preview(&bytes);
let tp = t0.elapsed().as_secs_f64() * 1e3;
// DNG previews are optional (and some carry only an uncompressed RGB thumbnail); vendor raws embed a JPEG,
// except the Panasonic `.RAW` files of 2005–2007 and Canon's explicit HEVC preview tracks.
if let Some(p) = &preview {
let jpeg = p.starts_with(&[0xff, 0xd8]) && p.ends_with(&[0xff, 0xd9]);
let jxl = p.starts_with(&[0xff, 0x0a]) || p.starts_with(b"\0\0\0\x0cJXL ");
assert!(jpeg || jxl, "{name}: preview is neither a JPEG nor a JPEG XL file");
} else {
let hevc_preview = fmt == RawFormat::Cr3
&& lightcraft_meta::cr3::parse_cr3(&bytes)
.is_some_and(|c| c.tracks.iter().any(|t| t.kind == lightcraft_meta::cr3::Cr3TrackKind::Other(*b"HEVC") && t.data.is_some()));
assert!(fmt == RawFormat::Dng || name.starts_with("raw-panasonic-") || hevc_preview, "{name}: no embedded preview");
}
let preview_kb = preview.as_ref().map_or(0, |p| p.len() / 1024);
let t1 = Instant::now();
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) => {
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;
eprintln!(
"{name:44} {fmt:?} {}x{} {}-bit {:?}: decode {dt:.0} ms ({:.0} MP/s), preview {} KB in {tp:.1} ms",
img.width,
img.height,
img.bits,
img.cfa.as_ref().map(|c| c.name()),
mp / (dt / 1e3),
preview_kb
);
ok += 1;
}
Err(RawError::Unsupported(why)) => {
assert!(KNOWN_UNSUPPORTED.iter().any(|k| name.contains(k)), "{name}: unexpectedly unsupported: {why}");
eprintln!("{name:44} {fmt:?} unsupported ({why}); preview {preview_kb} KB in {tp:.1} ms");
unsupported += 1;
}
Err(e) => panic!("{name}: {e}"),
}
}
eprintln!("corpus/raw: {ok} decoded, {unsupported} known-unsupported (embedded JPEG when available)");
}
/// Green-channel means of 32×32 blocks.
fn block_means(img: &lightcraft_raw::RawImage) -> Vec<f64> {
let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") };
let (w, h, b) = (img.width, img.height, 32);
let mut out = Vec::new();
for by in 0..h / b {
for bx in 0..w / b {
let (mut s, mut n) = (0f64, 0f64);
for y in by * b..by * b + b {
for x in (bx * b..bx * b + b).step_by(2) {
s += d[y * w + x + (y & 1)] as f64;
n += 1.0;
}
}
out.push(s / n);
}
}
out
}
fn correlation(a: &[f64], b: &[f64]) -> f64 {
let n = a.len() as f64;
let (ma, mb) = (a.iter().sum::<f64>() / n, b.iter().sum::<f64>() / n);
let cov: f64 = a.iter().zip(b).map(|(x, y)| (x - ma) * (y - mb)).sum();
let va: f64 = a.iter().map(|x| (x - ma).powi(2)).sum();
let vb: f64 = b.iter().map(|y| (y - mb).powi(2)).sum();
cov / (va * vb).sqrt()
}
/// Which diagonal of each 2×2 cell (anchored at raw pixel (0, 0)) holds the green sites: the two greens of a Bayer
/// cell see nearly the same light, so their mean absolute difference is far smaller than across the other diagonal.
/// Returns `true` when green sits at (0, 0)/(1, 1) (GBRG/GRBG), `false` for (1, 0)/(0, 1) (RGGB/BGGR).
fn green_on_main_diagonal(img: &lightcraft_raw::RawImage) -> bool {
let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") };
let (w, a) = (img.width, img.active_area);
let (mut main, mut anti) = (0f64, 0f64);
for y in ((a.y + 2) & !1..a.y + a.height - 2).step_by(2) {
for x in ((a.x + 2) & !1..a.x + a.width - 2).step_by(2) {
let v = |dx: usize, dy: usize| d[(y + dy) * w + x + dx] as f64;
main += (v(0, 0) - v(1, 1)).abs();
anti += (v(1, 0) - v(0, 1)).abs();
}
}
main < anti
}
/// Canon CR2 colour-filter layouts differ by model (issue #85); the decoder reads them from the `CR2CFAPattern` tag.
/// The expected layouts were checked visually (natural colours vs. the embedded preview) and agree with the
/// green-diagonal statistic of the mosaic itself.
#[test]
fn corpus_cr2_cfa_patterns() {
let dir = corpus_root().join("raw");
let cases = [
("cr2-canon-40d.cr2", "RGGB"),
("cr2-canon-550d.cr2", "GBRG"),
("cr2-canon-5d2.cr2", "GBRG"),
("cr2-canon-5d3.cr2", "RGGB"),
("cr2-canon-5dsr.cr2", "RGGB"),
("cr2-canon-6d.cr2", "RGGB"),
("cr2-canon-7d.cr2", "GBRG"),
("cr2-canon-80d.cr2", "RGGB"),
];
let mut seen = 0;
for (name, want) in cases {
let Ok(bytes) = std::fs::read(dir.join(name)) else {
eprintln!("skip: {name} absent");
continue;
};
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
let got = img.cfa.as_ref().map(|c| c.name()).unwrap_or_default();
assert_eq!(got, want, "{name}: CFA layout");
assert_eq!(green_on_main_diagonal(&img), want.starts_with('G'), "{name}: mosaic statistics disagree with {want}");
seen += 1;
}
eprintln!("CR2 CFA layouts checked on {seen} files");
}
/// The as-shot white balance of Canon CR2s whose ColorData is stored as SHORT words stays readable (the PowerShot /
/// EOS M files that store it as UNDEFINED bytes are covered by the unit test in `vendor/cr2.rs`; none is in this corpus).
/// Daylight-ish CR2s have R/G above 1 and B/G between 1 and 2.5 (observed 1.4-2.7 on these nine bodies).
#[test]
fn corpus_cr2_as_shot_white_balance() {
let dir = corpus_root().join("raw");
let mut seen = 0;
for name in [
"cr2-canon-40d.cr2",
"cr2-canon-550d.cr2",
"cr2-canon-5d2.cr2",
"cr2-canon-5d3.cr2",
"cr2-canon-5dsr.cr2",
"cr2-canon-6d.cr2",
"cr2-canon-7d.cr2",
"cr2-canon-80d.cr2",
] {
let Ok(bytes) = std::fs::read(dir.join(name)) else {
eprintln!("skip: {name} absent");
continue;
};
let wb = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")).wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot WB"));
assert!(wb[1] == 1.0 && wb[0] > 1.0 && wb[2] > 0.5 && wb[0] < 4.0 && wb[2] < 4.0, "{name}: WB {wb:?}");
seen += 1;
}
eprintln!("CR2 as-shot WB checked on {seen} files");
}
/// raw.pixls.us has the same D5100 scene as 14-bit lossless compressed and uncompressed NEF: the Huffman decode must
/// match the uncompressed image (up to the small differences between two exposures).
#[test]
fn corpus_nef_compressed_matches_uncompressed() {
let dir = corpus_root().join("raw");
let (Ok(a), Ok(b)) = (std::fs::read(dir.join("nef-nikon-d5100-lossless.nef")), std::fs::read(dir.join("nef-nikon-d5100-uncompressed.nef")))
else {
eprintln!("skip: D5100 NEF pair absent");
return;
};
let (a, b) = (decode(&a).unwrap(), decode(&b).unwrap());
assert_eq!((a.width, a.height, a.bits), (b.width, b.height, b.bits));
let r = correlation(&block_means(&a), &block_means(&b));
eprintln!("D5100 lossless vs uncompressed: block correlation {r:.4}");
assert!(r > 0.98, "correlation {r}");
// the lossy 12-bit D7000 file decodes into the curve's range and isn't flat
if let Ok(c) = std::fs::read(dir.join("nef-nikon-d7000-lossy12.nef")) {
let c = decode(&c).unwrap();
let m = block_means(&c);
let (lo, hi) = m.iter().fold((f64::MAX, 0f64), |(l, h), &v| (l.min(v), h.max(v)));
assert!(hi <= 4095.0 && hi - lo > 500.0, "D7000 block means {lo}..{hi}");
}
}
/// raw.pixls.us has the same D7500 scene as 12- and 14-bit lossless compressed NEF. Both store black level 400 in
/// maker note 0x003d (14-bit units): after subtracting the black level and scaling to white, the two must agree.
/// Issue #193: the Z f's High Efficiency NEF (no `0x0096` table, a JPEG XS codestream) is named as such and keeps a
/// full-size embedded JPEG to edit, while the same body's Lossless compressed NEF decodes.
#[test]
fn corpus_nef_high_efficiency_is_named() {
let dir = corpus_root().join("raw");
let (Ok(he), Ok(lossless)) = (std::fs::read(dir.join("nef-nikon-zf-he.nef")), std::fs::read(dir.join("nef-nikon-zf-lossless.nef"))) else {
eprintln!("skip: Z f samples absent");
return;
};
for result in [decode(&he).map(|_| ()), probe_info(&he).map(|_| ())] {
let Err(RawError::Unsupported(why)) = result else { panic!("Z f HE: expected unsupported") };
assert!(why.starts_with("Nikon High Efficiency NEF ("), "{why}");
}
assert!(embedded_preview(&he).is_some_and(|p| p.len() > 1 << 20), "Z f HE: no full-size preview");
let img = decode(&lossless).unwrap();
assert_eq!((img.width, img.height, img.bits), (6064, 4040, 14));
}
#[test]
fn corpus_nef_12_bit_black_level_matches_14_bit() {
let dir = corpus_root().join("raw");
let (Ok(a), Ok(b)) = (std::fs::read(dir.join("nef-nikon-d7500-lossless12.nef")), std::fs::read(dir.join("nef-nikon-d7500-lossless14.nef")))
else {
eprintln!("skip: D7500 NEF pair absent");
return;
};
let (a, b) = (decode(&a).unwrap(), decode(&b).unwrap());
assert_eq!((a.bits, b.bits), (12, 14));
assert!((a.black.values[0] - 100.0).abs() < 1.0 && (b.black.values[0] - 400.0).abs() < 1.0, "{:?} {:?}", a.black, b.black);
let normalized = |img: &lightcraft_raw::RawImage| {
let (black, white) = (img.black.values[0] as f64, img.white[0] as f64);
block_means(img).into_iter().map(|v| (v - black) / (white - black)).collect::<Vec<_>>()
};
// the two shots aren't pixel-aligned (handheld): compare the distributions, not block by block
let (mut na, mut nb) = (normalized(&a), normalized(&b));
na.sort_by(f64::total_cmp);
nb.sort_by(f64::total_cmp);
for q in [0.1, 0.5, 0.9] {
let (x, y) = (na[(na.len() as f64 * q) as usize], nb[(nb.len() as f64 * q) as usize]);
eprintln!("D7500 12- vs 14-bit: normalized quantile {q}: {x:.4} vs {y:.4}");
// with the tag read as 12-bit units, the 12-bit values would sit below black (negative)
assert!(x > 0.0 && (0.8..1.25).contains(&(x / y)), "quantile {q}: {x} vs {y}");
}
}
/// Issue #138: DNGs converted by Adobe software carry their camera profile's hue/saturation map and
/// look table; we read them (and render with them). Apple ProRAW carries a tone curve and a gain
/// table map (its local tone mapping, read and kept but not rendered, as in Lightroom Classic).
/// Other camera-written DNGs here carry none.
#[test]
fn corpus_dngs_carry_profile_looks() {
let dir = corpus_root().join("raw");
let Ok(rd) = std::fs::read_dir(&dir) else {
eprintln!("skip: {} absent", dir.display());
return;
};
let mut seen = 0;
for p in rd.flatten().map(|e| e.path()) {
let name = p.file_name().unwrap().to_string_lossy().to_lowercase();
if !name.starts_with("dng-") || !name.ends_with(".dng") {
continue;
}
let info = probe_info(&std::fs::read(&p).unwrap()).unwrap_or_else(|e| panic!("{name}: {e}"));
let look = &info.color.profile;
if name.starts_with("dng-adobe-") {
seen += 1;
let hsm = look.hue_sat_map[0].as_ref().unwrap_or_else(|| panic!("{name}: no hue/sat map"));
assert!(hsm.hue_divisions > 1 && hsm.sat_divisions > 1, "{name}");
assert!(look.look_table.is_some(), "{name}: no look table");
// a profile applied to a mid grey keeps it (close to) neutral
let t = lightcraft_raw::profile::ProfileTables::new(look, 0.5).unwrap();
let g = t.apply([0.18; 3], 1.0);
assert!(g.iter().all(|v| (v - g[0]).abs() < 0.01 * g[0].max(0.01)), "{name}: grey → {g:?}");
}
if name.starts_with("dng-apple-") {
seen += 1;
let map = look.gain_table_map.as_ref().unwrap_or_else(|| panic!("{name}: no gain table map"));
assert!(map.points_v > 1 && map.points_h > 1 && map.points_n > 1, "{name}");
assert!(look.tone_curve.is_some(), "{name}: no tone curve");
// the map lifts dark tones (Apple's local tone mapping), most at the darkest input
let first = &map.gains[..map.points_n];
assert!(first[0] > 1.5 && first[0] >= first[map.points_n - 1], "{name}: first table {first:?}");
}
eprintln!(
"{name:44} profile look: hsm {} look {} tone {} gain map {}",
look.hue_sat_map[0].is_some(),
look.look_table.is_some(),
look.tone_curve.is_some(),
look.gain_table_map.is_some()
);
}
eprintln!("{seen} DNGs with profile looks checked");
}
/// iPhone ProRAW (`dng-apple-iphone12pro-proraw.dng`, iPhone 12 Pro, CC0 from raw.pixls.us, see
/// `cargo xtask corpus`) carries a lossy-JPEG sky matte at half resolution as a DNG semantic mask:
/// it decodes, and it is a sky. The matte is stored like the raw (landscape; the photo is shown
/// rotated 90° clockwise), so the sky is on its left and the lake and shore on its right.
#[test]
fn corpus_proraw_sky_matte() {
let path = corpus_root().join("raw/dng-apple-iphone12pro-proraw.dng");
let Ok(bytes) = std::fs::read(&path) else {
eprintln!("skip: {} absent", path.display());
return;
};
let t0 = Instant::now();
let masks = lightcraft_raw::semantic_masks(&bytes);
eprintln!("semantic masks read in {:.1} ms", t0.elapsed().as_secs_f64() * 1e3);
let names: Vec<&str> = masks.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names, ["urn:com:apple:photo:2020:aux:semanticskymatte"]);
let m = &masks[0];
assert_eq!((m.width, m.height, m.sub_area), (2016, 1512, None));
let cols = |x0: usize, x1: usize| {
let sum: f64 = (0..m.height).flat_map(|y| m.data[y * m.width + x0..y * m.width + x1].iter()).map(|&v| v as f64 / 65535.0).sum();
sum / ((x1 - x0) * m.height) as f64
};
let (left, right) = (cols(0, 100), cols(1916, 2016));
eprintln!("sky matte: left columns {left:.3}, right columns {right:.3}");
assert!(left > 0.5 && right < 0.02, "left {left}, right {right}");
}
/// Issue #148: Sony ARWs from before ~2017 carry no plain white-balance, black-level or crop tags in the raw IFD.
/// White balance and black level come from the encrypted `SR2SubIFD`, the crop from `FullImageSize`; without them
/// the RX100 III opened bright green. Expected values: the black levels agree with each sensor's dark-pixel floor;
/// the gains are the `SR2SubIFD`'s `WB_RGGBLevels`, within 4 % of the gains that make the camera JPEG's neutral
/// pixels neutral (RX100 III 2.59 / 1.69, RX100 2.21 / 2.01, ILCE-7RM2 2.67 / 1.47). Until issue #535 they came
/// from the maker note's `Tag2010` (2.61 / 1.72, 2.23 / 2.00, 2.58 / 1.46), which differs from what the camera
/// applied on other files (see `corpus_sony_sr2_white_balance`).
#[test]
fn corpus_sony_pre2017_colour_metadata() {
let dir = corpus_root().join("raw");
// (file, black, approximate R and B gains, crop width × height)
let cases = [
("arw-sony-rx100m3.arw", 800.0, [2.55, 1.72], (5472, 3648)),
("arw-sony-rx100.arw", 800.0, [2.21, 1.99], (5472, 3648)),
("arw-sony-a7rm2-12bit-uncompressed.arw", 512.0, [2.58, 1.45], (7952, 5304)),
];
let mut seen = 0;
for (name, black, [r, b], (cw, ch)) in cases {
let Ok(bytes) = std::fs::read(dir.join(name)) else {
eprintln!("skip: {name} absent");
continue;
};
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(img.black.mean(), black, "{name}: black level");
let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot white balance"));
assert!((wb[0] - r).abs() < 0.01 && wb[1] == 1.0 && (wb[2] - b).abs() < 0.01, "{name}: white balance {wb:?}");
assert_eq!((img.crop.width, img.crop.height), (cw, ch), "{name}: crop");
assert!(img.white_at(0) > 16000.0, "{name}: white {} (14-bit scale)", img.white_at(0));
seen += 1;
}
eprintln!("pre-2017 Sony ARW colour metadata checked on {seen} files");
}
/// Sony ILCE-7CR (61 MP), three of the camera's raw codings of one scene on raw.pixls.us: lossless compressed L
/// (LJ92 tiles of 2×2 cells), lossless compressed M (subsampled YCbCr, already white-balanced) and compressed
/// (ARW2). Expected values: the files' own black level and default crop, and the as-shot gains of the camera's
/// colour-temperature setting.
#[test]
fn corpus_sony_a7cr_codings() {
let dir = corpus_root().join("raw");
// (file, colour-filter layout, as-shot R and B gains, default crop x, y, width, height)
let cases = [
("arw-sony-a7cr-lossless-l.arw", Some("RGGB"), [2.625, 1.598], (32, 20, 9504, 6336)),
("arw-sony-a7cr-compressed.arw", Some("RGGB"), [2.625, 1.598], (32, 20, 9504, 6336)),
("arw-sony-a7cr-lossless-m.arw", None, [1.0, 1.0], (20, 12, 6240, 4160)),
];
let mut decoded = Vec::new();
for (name, cfa, [r, b], crop) in cases {
let bytes = match std::fs::read(dir.join(name)) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
eprintln!("skip: {name} absent");
continue;
}
Err(e) => panic!("{name}: {e}"),
};
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(img.metadata.model.as_deref(), Some("ILCE-7CR"), "{name}: model");
assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), cfa, "{name}: colour-filter layout");
// Bayer codings use the file's black level; the YCbCr M coding's pedestal is handled separately (#535, item 3).
if cfa.is_some() {
assert_eq!(img.black.mean(), 512.0, "{name}: black level");
}
assert!(img.white_at(0) > 15000.0, "{name}: white {} (14-bit scale)", img.white_at(0));
let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot white balance"));
assert!((wb[0] - r).abs() < 0.01 && wb[1] == 1.0 && (wb[2] - b).abs() < 0.01, "{name}: white balance {wb:?}");
assert_eq!((img.crop.x, img.crop.y, img.crop.width, img.crop.height), crop, "{name}: default crop");
decoded.push((name, img));
}
// The lossless and compressed codings are two exposures of the same scene with the same framing: inside the
// default crop their mosaics must agree (up to the small differences between two exposures).
let crop_means = |img: &lightcraft_raw::RawImage| {
let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") };
let (w, c, b) = (img.width, img.crop, 32);
let mut out = Vec::new();
for by in 0..c.height / b {
for bx in 0..c.width / b {
let (x0, y0) = ((c.x + bx * b) & !1, (c.y + by * b) & !1);
let (mut s, mut n) = (0f64, 0f64);
for y in y0..y0 + b {
for x in (x0..x0 + b).step_by(2) {
s += d[y * w + x + 1 - (y & 1)] as f64;
n += 1.0;
}
}
out.push(s / n);
}
}
out
};
let bayer: Vec<_> = decoded.iter().filter(|(_, img)| img.cfa.is_some()).collect();
if let [(a_name, a), (b_name, b)] = bayer.as_slice() {
let r = correlation(&crop_means(a), &crop_means(b));
eprintln!("ILCE-7CR {a_name} vs {b_name}: green block correlation {r:.4}");
assert!(r > 0.98, "correlation {r}");
}
eprintln!("ILCE-7CR codings checked on {} files", decoded.len());
}
/// Sony ILCE-7CM2 (33 MP, issue #697), two of the camera's codings on raw.pixls.us: lossless compressed L (LJ92 tiles
/// of 2×2 cells) and compressed "M-size" (ARW2). Only its lossless M and S codings are the downsized linear YCbCr
/// whose 1024 pedestal 0.5.0 missed (rendering the issue's file washed out: the darkest half of a dusk scene
/// +11 L* and blue); the compressed and uncompressed M-size files are Bayer at 4736×3132 with the recorded black
/// 512, so a size-based rule would break them. Expected values: the files' own colour-filter layout, black and
/// white levels, default crop and the as-shot gains of the camera's colour-temperature setting.
#[test]
fn corpus_sony_a7cm2_codings() {
let dir = corpus_root().join("raw");
// (file, sensor width and height, as-shot R and B gains, default crop x, y, width, height)
let cases = [
("arw-sony-a7cm2-lossless-l.arw", (7168, 5120), [2.313, 1.654], (12, 8, 7008, 4672)),
("arw-sony-a7cm2-compressed-m.arw", (4736, 3132), [2.309, 1.657], (44, 30, 4608, 3072)),
];
let mut checked = 0;
for (name, (width, height), [r, b], crop) in cases {
let bytes = match std::fs::read(dir.join(name)) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
eprintln!("skip: {name} absent");
continue;
}
Err(e) => panic!("{name}: {e}"),
};
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(probe_info(&bytes).unwrap(), img.info(), "{name}: header and full decode agree");
assert_eq!(img.metadata.model.as_deref(), Some("ILCE-7CM2"), "{name}: model");
assert_eq!((img.width, img.height, img.cpp), (width, height, 1), "{name}: sensor size");
assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some("RGGB"), "{name}: colour-filter layout");
assert_eq!((img.black.mean(), img.white_at(0)), (512.0, 16383.0), "{name}: black and white levels");
let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot white balance"));
assert!((wb[0] - r).abs() < 0.01 && wb[1] == 1.0 && (wb[2] - b).abs() < 0.01, "{name}: white balance {wb:?}");
assert_eq!((img.crop.x, img.crop.y, img.crop.width, img.crop.height), crop, "{name}: default crop");
checked += 1;
}
eprintln!("ILCE-7CM2 codings checked on {checked} files");
}
/// Issue #535: Sony black levels against the data. The SR2SubIFD keeps the black level at a position that depends
/// on its layout (the DSLR-A500 and A700 read 365 and 975 from a fixed position, where ExifTool and the data say
/// 512), and the downsized lossless M and S codings (linear YCbCr) sit 512 above the recorded level (1024, with
/// white moved by the same 512). Each file's darkest 0.1 % per channel must lie just above its black level: a black
/// level that is too low leaves a pedestal, one that is too high clips the shadows.
#[test]
fn corpus_sony_black_levels_sit_at_the_data_floor() {
use lightcraft_raw::RawData;
let dir = corpus_root().join("raw");
// (file, black, white): SR2SubIFD black at three layouts, a plain 0x7310, and the lossless L / M / S codings
let cases = [
("arw-sony-a500.arw", 512.0, None),
("arw-sony-a700.arw", 512.0, None),
("arw-sony-rx100m3.arw", 800.0, None),
("arw-sony-a7m4-lossless-l.arw", 512.0, Some(16383.0)),
("arw-sony-a7m4-lossless-m.arw", 1024.0, Some(16895.0)),
("arw-sony-a7m4-lossless-s.arw", 1024.0, Some(16895.0)),
// ILCE-7CM2 (#697): its compressed "M-size" coding is Bayer at the recorded 512, unlike its lossless M
("arw-sony-a7cm2-lossless-l.arw", 512.0, Some(16383.0)),
("arw-sony-a7cm2-compressed-m.arw", 512.0, Some(16383.0)),
];
let mut seen = 0;
for (name, black, white) in cases {
let bytes = match std::fs::read(dir.join(name)) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
eprintln!("skip: {name} absent");
continue;
}
Err(e) => panic!("{name}: {e}"),
};
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(img.black.mean(), black, "{name}: black level");
if let Some(white) = white {
assert_eq!(img.white_at(0), white, "{name}: white level");
}
let RawData::U16(ref data) = img.data else { panic!("{name}: integer samples expected") };
// per CFA position (2×2) or per colour sample, inside the default crop, about a million samples each
let channels = if img.cpp == 3 { 3 } else { 4 };
let (a, c) = (img.active_area, img.crop);
let step = (c.width * c.height / 1_000_000).max(1);
let mut samples = vec![Vec::new(); channels];
for (k, (x, y)) in (c.y..c.y + c.height).flat_map(|y| (c.x..c.x + c.width).map(move |x| (x + a.x, y + a.y))).enumerate() {
if (k / 2) % step != 0 {
continue;
}
if img.cpp == 3 {
for (s, v) in samples.iter_mut().enumerate() {
v.push(data[(y * img.width + x) * 3 + s]);
}
} else {
samples[(y % 2) * 2 + x % 2].push(data[y * img.width + x]);
}
}
for (i, mut v) in samples.into_iter().enumerate() {
v.sort_unstable();
let floor = f32::from(v[v.len() / 1000]);
assert!((black - 64.0..=black + 128.0).contains(&floor), "{name}: channel {i}: darkest 0.1 % at {floor}, black level {black}");
}
seen += 1;
}
eprintln!("Sony black levels checked against the data floor on {seen} files");
}
/// Issue #535: the white balance a Sony ARW records as applied, `WB_RGGBLevels`, read from the encrypted
/// `SR2SubIFD` when the raw IFD has no plain copy. Before, the first three opened with no white balance (green) and
/// the last two with the maker note's `Tag2010` gains, which there follow the scene rather than the preset used.
/// Expected levels: ExifTool's decrypted `WB_RGGBLevels` (green 1024). In brackets, the gains that make the
/// camera JPEG's neutral pixels neutral.
#[test]
fn corpus_sony_sr2_white_balance() {
let dir = corpus_root().join("raw");
// (file, R and B levels)
let cases = [
("arw-sony-a500.arw", [2212, 1416]), // 27152-byte SR2SubIFD layout (2.04 / 1.35)
("arw-sony-a33.arw", [2344, 1508]), // 29000-byte layout (2.29 / 1.48)
("arw-sony-a700.arw", [2128, 1564]), // 62112-byte layout (too few neutral pixels)
("arw-sony-a3500-5600k.arw", [2932, 1576]), // 5600 K (2.76 / 1.53; Tag2010 1.89 / 3.08)
("arw-sony-a7s-shade.arw", [2932, 1372]), // Shade (2.90 / 1.32; Tag2010 2.63 / 1.52)
("arw-sony-a900-packed12.arw", [2688, 1516]), // packed 12-bit (the maker note's plain 0x0020 block agrees)
];
let mut seen = 0;
for (name, [r, b]) in cases {
let Ok(bytes) = std::fs::read(dir.join(name)) else {
eprintln!("skip: {name} absent");
continue;
};
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot white balance"));
let (er, eb) = (r as f32 / 1024.0, b as f32 / 1024.0);
assert!((wb[0] - er).abs() < 1e-4 && wb[1] == 1.0 && (wb[2] - eb).abs() < 1e-4, "{name}: white balance {wb:?}, expected [{er}, 1, {eb}]");
seen += 1;
}
eprintln!("Sony SR2SubIFD white balance checked on {seen} files");
}
/// The DSLR-A900's packed 12-bit strip decodes (issue #535, item 6): 12 bits on a black of 128, and the camera
/// JPEG's `FullImageSize` window centred in the frame, which has no padding at either edge.
#[test]
fn corpus_sony_a900_packed12() {
let name = "arw-sony-a900-packed12.arw";
let Ok(bytes) = std::fs::read(corpus_root().join("raw").join(name)) else {
eprintln!("skip: {name} absent");
return;
};
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!((img.width, img.height, img.bits), (6080, 4048, 12));
assert_eq!(img.black.values, vec![128.0]);
let c = img.crop;
assert_eq!((c.x, c.y, c.width, c.height), (16, 8, 6048, 4032));
}
/// Issue #535: pre-2017 Sony bodies without crop tags shot in the camera's 16:9 mode. The ILCE-7SM2 records the
/// 16:9 size in `FullImageSize`, the DSLR-A580 only in the Exif image size; both crops are centred vertically (each
/// camera JPEG registered on its raw) and drop the right-edge padding, so the image matches its JPEG's aspect ratio
/// and gets a camera look.
#[test]
fn corpus_sony_16x9_crops() {
let dir = corpus_root().join("raw");
let mut seen = 0;
for (name, (x, y, w, h)) in [("arw-sony-a7sm2-16x9.arw", (0, 232, 4240, 2384)), ("arw-sony-a580-16x9.arw", (0, 260, 4912, 2760))] {
let bytes = match std::fs::read(dir.join(name)) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
eprintln!("skip: {name} absent");
continue;
}
Err(e) => panic!("{name}: {e}"),
};
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
let c = img.crop;
assert_eq!((c.x, c.y, c.width, c.height), (x, y, w, h), "{name}: crop");
let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no preview"));
let mut d = zune_jpeg::JpegDecoder::new(zune_core::bytestream::ZCursor::new(&jpeg));
d.decode_headers().unwrap();
let info = d.info().unwrap();
let (aspect, preview) = (c.width as f64 / c.height as f64, info.width as f64 / info.height as f64);
assert!((aspect / preview - 1.0).abs() < 0.02, "{name}: crop aspect {aspect} vs camera JPEG {preview}");
seen += 1;
}
eprintln!("Sony 16:9 crops checked on {seen} files");
}
/// The embedded JPEG as linear RGB, reduced to `gw × gh` cells.
fn jpeg_cells(jpeg: &[u8], gw: usize, gh: usize) -> Vec<[f64; 3]> {
use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions};
let opts = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::RGB);
let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(jpeg), opts);
let px = d.decode().unwrap();
let info = d.info().unwrap();
let (w, h) = (info.width as usize, info.height as usize);
let (mut sum, mut n) = (vec![[0f64; 3]; gw * gh], vec![0f64; gw * gh]);
for y in 0..h {
for x in 0..w {
let i = (y * gh / h) * gw + x * gw / w;
for c in 0..3 {
let v = px[(y * w + x) * 3 + c] as f64 / 255.0;
sum[i][c] += if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
}
n[i] += 1.0;
}
}
sum.iter().zip(&n).map(|(s, n)| s.map(|v| v / n)).collect()
}
/// Black-subtracted means of the four sites of the 2×2 cells (anchored at sample (0, 0)) of the active area,
/// reduced to `gw × gh` cells.
fn raw_sites(img: &lightcraft_raw::RawImage, gw: usize, gh: usize) -> Vec<[f64; 4]> {
let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") };
let (w, a, black) = (img.width, img.active_area, img.black.mean() as f64);
let (x0, y0) = ((a.x + 1) & !1, (a.y + 1) & !1);
let (cw, ch) = ((a.x + a.width - x0) / 2, (a.y + a.height - y0) / 2);
let (mut sum, mut n) = (vec![[0f64; 4]; gw * gh], vec![0f64; gw * gh]);
for cy in 0..ch {
for cx in 0..cw {
let i = (cy * gh / ch) * gw + cx * gw / cw;
for site in 0..4 {
sum[i][site] += d[(y0 + 2 * cy + site / 2) * w + x0 + 2 * cx + site % 2] as f64 - black;
}
n[i] += 1.0;
}
}
sum.iter().zip(&n).map(|(s, n)| s.map(|v| v / n)).collect()
}
fn ranks(v: &[f64]) -> Vec<f64> {
let mut idx: Vec<usize> = (0..v.len()).collect();
idx.sort_by(|&a, &b| v[a].total_cmp(&v[b]));
let mut r = vec![0f64; v.len()];
for (rank, &i) in idx.iter().enumerate() {
r[i] = rank as f64;
}
r
}
/// How well the raw's chromaticities (log r/g, log b/g per cell) follow the JPEG's when the mosaic is read with
/// `layout` (colour of each 2×2 site at sample (0, 0)).
fn chroma_agreement(sites: &[[f64; 4]], jpeg: &[[f64; 3]], layout: &[u8]) -> f64 {
let (mut x, mut y) = ([vec![], vec![]], [vec![], vec![]]);
for (s, j) in sites.iter().zip(jpeg) {
let (mut rgb, mut n) = ([0f64; 3], [0f64; 3]);
for (site, &c) in layout.iter().enumerate() {
rgb[c as usize] += s[site];
n[c as usize] += 1.0;
}
let rgb = [rgb[0] / n[0], rgb[1] / n[1], rgb[2] / n[2]];
if rgb.iter().all(|v| *v > 1.0) && j.iter().all(|v| (0.002..0.9).contains(v)) {
for (k, c) in [0, 2].into_iter().enumerate() {
x[k].push((rgb[c] / rgb[1]).ln());
y[k].push((j[c] / j[1]).ln());
}
}
}
(correlation(&x[0], &y[0]) + correlation(&x[1], &y[1])) / 2.0
}
/// Panasonic RW2 / RWL / RAW (`crates/raw/src/vendor/rw2.rs`): one file per raw encoding (formats 2, 4, 5, 6, 7 and
/// 8 — at 12, 14 and 16 bits — and the 16-bit words of the oldest bodies) decodes to the image of the camera's own
/// JPEG, with the black levels, crops and colour-filter layouts the module docs derive and no defect markers left.
#[test]
fn corpus_panasonic_encodings() {
let dir = corpus_root().join("raw");
// (file, bits, mean black level, crop width × height, CFA layout)
let cases = [
("raw-panasonic-fz50.raw", 12, 15.0, (3648, 2736), "BGGR"),
("raw-panasonic-fz8.raw", 12, 15.0, (3072, 2304), "RGGB"),
("rw2-panasonic-fz1000m2-4x3.rw2", 12, 143.0, (4864, 3648), "GBRG"),
("rw2-panasonic-g9-b.rw2", 12, 143.0, (5184, 3888), "RGGB"),
("rw2-panasonic-g9.rw2", 12, 127.5, (5184, 3888), "RGGB"),
("rw2-panasonic-gh1.rw2", 12, 15.0, (4000, 3000), "GBRG"),
("rw2-panasonic-gh5.rw2", 12, 143.5, (5184, 3888), "RGGB"),
("rw2-panasonic-gh5m2.rw2", 12, 144.5, (5184, 3888), "RGGB"),
("rw2-panasonic-gh5s.rw2", 14, 509.5, (3680, 2760), "RGGB"),
("rw2-panasonic-gh6.rw2", 16, 2048.0, (5776, 4336), "RGGB"),
("rw2-panasonic-gx80.rw2", 12, 143.0, (4592, 3448), "BGGR"),
("rw2-panasonic-s1.rw2", 14, 526.0, (6000, 4000), "RGGB"),
("rw2-panasonic-s5-format7.rw2", 14, 512.0, (6000, 4000), "RGGB"),
("rw2-panasonic-s5m2.rw2", 14, 512.0, (6000, 4000), "RGGB"),
("rw2-panasonic-s9.rw2", 12, 128.0, (6000, 4000), "RGGB"),
("rwl-leica-dlux7.rwl", 12, 143.0, (4736, 3552), "BGGR"),
];
let mut seen = 0;
for (name, bits, black, (cw, ch), cfa) in cases {
let Ok(bytes) = std::fs::read(dir.join(name)) else {
eprintln!("skip: {name} absent");
continue;
};
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(img.bits, bits, "{name}: bits");
assert_eq!(img.black.mean(), black, "{name}: black level");
assert_eq!((img.crop.width, img.crop.height), (cw, ch), "{name}: crop");
let layout = img.cfa.clone().unwrap_or_else(|| panic!("{name}: no CFA"));
assert_eq!(layout.name(), cfa, "{name}: CFA layout");
let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") };
let a = img.active_area;
let zeros =
(a.y..a.y + a.height).map(|y| d[y * img.width + a.x..y * img.width + a.x + a.width].iter().filter(|&&v| v == 0).count()).sum::<usize>();
assert_eq!(zeros, 0, "{name}: defect markers left in the active area");
let Some(jpeg) = embedded_preview(&bytes) else {
// the 2005–2007 `.RAW` files have no preview; their flat scenes make the green diagonal unambiguous
assert_eq!(green_on_main_diagonal(&img), cfa.starts_with('G'), "{name}: mosaic statistics disagree with {cfa}");
seen += 1;
continue;
};
// the camera's JPEG shows the whole active area (whatever the crop): the raw's green must rank like it, and
// its colours must follow the JPEG's best with the decoded colour-filter layout
let (gw, gh) = (24, 18);
let (sites, jpeg) = (raw_sites(&img, gw, gh), jpeg_cells(&jpeg, gw, gh));
let green: Vec<f64> = sites.iter().map(|s| (0..4).filter(|&i| layout.pattern[i] == 1).map(|i| s[i]).sum::<f64>()).collect();
let jg: Vec<f64> = jpeg.iter().map(|j| j[1]).collect();
let rho = correlation(&ranks(&green), &ranks(&jg));
let agree = |l: &str| chroma_agreement(&sites, &jpeg, &lightcraft_raw::Cfa::bayer(l).unwrap().pattern);
let best_other = ["RGGB", "GRBG", "GBRG", "BGGR"].into_iter().filter(|l| *l != cfa).map(agree).fold(f64::MIN, f64::max);
let own = agree(cfa);
eprintln!(
"{name:32} {}x{} {bits}-bit: green rank correlation {rho:.3}, colour agreement {own:.3} (others ≤ {best_other:.3})",
img.width, img.height
);
assert!(rho > 0.9, "{name}: rank correlation with the camera JPEG {rho}");
assert!(own > 0.5 && own > best_other, "{name}: colours follow the JPEG better with another layout ({own} vs {best_other})");
seen += 1;
}
eprintln!("Panasonic encodings checked on {seen} files");
}
/// Olympus ORF (`crates/raw/src/vendor/orf.rs`): the colour-filter layout comes from the file's Exif `CFAPattern`.
/// With it, the decoded mosaic's colours follow the camera's own JPEG better than with any other layout.
#[test]
fn corpus_orf_cfa_patterns() {
let dir = corpus_root().join("raw");
let cases = [("orf-olympus-e1.orf", "GRBG"), ("orf-olympus-e400.orf", "GRBG"), ("orf-olympus-xz2.orf", "RGGB")];
let mut seen = 0;
for (name, cfa) in cases {
let Ok(bytes) = std::fs::read(dir.join(name)) else {
eprintln!("skip: {name} absent");
continue;
};
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some(cfa), "{name}: CFA layout");
assert_eq!(green_on_main_diagonal(&img), cfa.starts_with('G'), "{name}: mosaic statistics disagree with {cfa}");
let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no embedded preview"));
let (gw, gh) = (24, 18);
let (sites, jpeg) = (raw_sites(&img, gw, gh), jpeg_cells(&jpeg, gw, gh));
let agree = |l: &str| chroma_agreement(&sites, &jpeg, &lightcraft_raw::Cfa::bayer(l).unwrap().pattern);
let best_other = ["RGGB", "GRBG", "GBRG", "BGGR"].into_iter().filter(|l| *l != cfa).map(agree).fold(f64::MIN, f64::max);
let own = agree(cfa);
let a = img.active_area;
eprintln!("{name:32} {cfa}, active area at ({}, {}): colour agreement {own:.3} (others ≤ {best_other:.3})", a.x, a.y);
assert!(own > 0.5 && own > best_other, "{name}: colours follow the JPEG better with another layout ({own} vs {best_other})");
seen += 1;
}
eprintln!("ORF CFA layouts checked on {seen} files");
}
/// Samsung SRW (`crates/raw/src/vendor/srw.rs`): the 12-bit packings of the NX10 and NX20 generations (opposite bit
/// orders, told apart from the samples) and the 16-bit words of the EX1 and WB2000 decode to the image of the camera's own
/// JPEG, with the layout, framing, levels and white balance the file's maker note states.
#[test]
fn corpus_samsung_srw() {
let dir = corpus_root().join("raw");
// (file, bits, active area width x height, crop width x height, CFA layout, JPEG shows the whole active area)
let cases = [
("srw-samsung-nx10.srw", 12, (4592, 3056), (4592, 3056), "BGGR", true),
("srw-samsung-nx20.srw", 12, (5472, 3648), (5472, 3648), "GRBG", true),
("srw-samsung-ex1.srw", 14, (3648, 2736), (3648, 2736), "RGGB", true),
];
let mut seen = 0;
for (name, bits, active, crop, cfa, whole) in cases {
let Ok(bytes) = std::fs::read(dir.join(name)) else {
eprintln!("skip: {name} absent");
continue;
};
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(img.format, RawFormat::Srw, "{name}");
assert_eq!(img.bits, bits, "{name}: bits");
assert_eq!((img.active_area.width, img.active_area.height), active, "{name}: active area");
assert_eq!((img.crop.width, img.crop.height), crop, "{name}: crop");
assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some(cfa), "{name}: CFA layout");
let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot white balance"));
assert!(wb[0] > 1.0 && wb[1] == 1.0 && wb[2] > 0.5, "{name}: white balance {wb:?}");
assert!(img.white_at(0) > (1u32 << bits) as f32 * 0.9 && img.white_at(0) <= ((1u32 << bits) - 1) as f32, "{name}: white {}", img.white_at(0));
assert!(img.black.mean() < 8.0, "{name}: black {}", img.black.mean());
// the mosaic inside the framing holds no marker or padding values above the saturation level
let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") };
let a = img.active_area;
let above = (a.y..a.y + a.height)
.map(|y| d[y * img.width + a.x..y * img.width + a.x + a.width].iter().filter(|&&v| f32::from(v) > ((1u32 << bits) - 1) as f32).count())
.sum::<usize>();
assert_eq!(above, 0, "{name}: samples above the saturation level in the active area");
if whole {
let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no embedded preview"));
let (gw, gh) = (24, 18);
let (sites, jpeg) = (raw_sites(&img, gw, gh), jpeg_cells(&jpeg, gw, gh));
let layout = img.cfa.clone().unwrap_or_else(|| panic!("{name}: no CFA"));
let green: Vec<f64> = sites.iter().map(|s| (0..4).filter(|&i| layout.pattern[i] == 1).map(|i| s[i]).sum::<f64>()).collect();
let rho = correlation(&ranks(&green), &ranks(&jpeg.iter().map(|j| j[1]).collect::<Vec<_>>()));
let agree = |l: &str| chroma_agreement(&sites, &jpeg, &lightcraft_raw::Cfa::bayer(l).unwrap().pattern);
let best_other = ["RGGB", "GRBG", "GBRG", "BGGR"].into_iter().filter(|l| *l != cfa).map(agree).fold(f64::MIN, f64::max);
let own = agree(cfa);
eprintln!(
"{name:26} {}x{} {bits}-bit: green rank correlation {rho:.3}, colour agreement {own:.3} (others <= {best_other:.3})",
img.width, img.height
);
assert!(rho > 0.9, "{name}: rank correlation with the camera JPEG {rho}");
assert!(own > 0.5 && own > best_other, "{name}: colours follow the JPEG better with another layout ({own} vs {best_other})");
}
seen += 1;
}
eprintln!("Samsung SRW checked on {seen} files");
}
/// Reference sensor sums and position-weighted sums from black-box decoding, independent
/// of this implementation. These cover complete images, padding, stripe boundaries and
/// lossy block quantizer changes; the source files are CC0, fetched by `xtask corpus`.
#[test]
fn corpus_fujifilm_compressed_samples() {
use sha2::{Digest, Sha256};
let checksums = include_str!("../../../docs/raf-corpus.sha256");
let cases: &[(&str, usize, usize, u64, u64)] = &[
("raf-fuji-gfx100-3773.raf", 11808, 8754, 483668733090, 6453395759968151080),
("raf-fuji-gfx100-3775.raf", 11808, 8754, 120434414850, 6144091676930008551),
("raf-fuji-gfx100rf-8091.raf", 11808, 8754, 509005112862, 6771145666297463071),
("raf-fuji-gfx100s-4495.raf", 11808, 8754, 941987648006, 4811558780180901389),
("raf-fuji-gfx100s-4503.raf", 11808, 8754, 939580201129, 4821879272388485929),
("raf-fuji-gfx50s-1435.raf", 9216, 6210, 79545293042, 2565169091480702981),
("raf-fuji-xe5-8509.raf", 7872, 5196, 76435253154, 1299804440011842144),
("raf-fuji-xh2-6001.raf", 7872, 5196, 83480264024, 1799924693528487099),
("raf-fuji-xh2-6002.raf", 7872, 5196, 72476569659, 1547275125734054244),
("raf-fuji-xm5-7748.raf", 6336, 4182, 93243605328, 986712869515471030),
("raf-fuji-xt2-865.raf", 6048, 4038, 44212684244, 541983062567959797),
("raf-fuji-xt20-compressed.raf", 6048, 4038, 72690241711, 882852288583469317),
("raf-fuji-xt4-3914.raf", 6384, 4182, 45943146703, 599112804573868025),
("raf-fuji-xt4-3918.raf", 6384, 4182, 46401490361, 605690093061448074),
("raf-fuji-xt5-6122.raf", 7872, 5196, 109559716516, 1653376659872066392),
("raf-fuji-xt5-6123.raf", 7872, 5196, 109489917369, 1673725237502600190),
("raf-fuji-xt50-7807.raf", 7872, 5196, 76453904143, 1262190863834170778),
];
let dir = corpus_root().join("raw");
let mut seen = 0;
for &(name, width, height, sum, weighted) in cases {
let path = format!("corpus/raw/{name}");
let checksum = checksums.lines().filter_map(|s| s.split_once(" ")).find(|(_, p)| *p == path).unwrap().0;
assert_eq!(checksum.len(), 64, "{name}: missing published SHA-256");
let Ok(bytes) = std::fs::read(dir.join(name)) else { continue };
let actual_sha: String = Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(actual_sha, checksum, "{name}: corpus file differs from its pinned identity");
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!((img.width, img.height), (width, height), "{name}");
let lightcraft_raw::RawData::U16(data) = img.data else { panic!("{name}: float data") };
let actual = data
.iter()
.enumerate()
.fold((0u64, 0u64), |(s, w), (i, &v)| (s.wrapping_add(u64::from(v)), w.wrapping_add((i as u64 + 1).wrapping_mul(u64::from(v)))));
assert_eq!(actual, (sum, weighted), "{name}: sensor samples differ from reference");
seen += 1;
}
eprintln!("verified {seen} Fujifilm compressed sensor arrays");
}
/// Checks each available `(name, width, height, sum, weighted)` case against the decoder's sensor array (the file's
/// identity is checked by `cargo xtask corpus --download`); absent corpus files are skipped. Returns how many were verified.
fn verify_sensor_arrays(cases: &[(&str, usize, usize, u64, u64)]) -> usize {
let dir = corpus_root().join("raw");
let mut seen = 0;
for &(name, width, height, sum, weighted) in cases {
let Ok(bytes) = std::fs::read(dir.join(name)) else { continue };
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!((img.width, img.height), (width, height), "{name}");
let lightcraft_raw::RawData::U16(data) = img.data else { panic!("{name}: float data") };
let actual = data
.iter()
.enumerate()
.fold((0u64, 0u64), |(s, w), (i, &v)| (s.wrapping_add(u64::from(v)), w.wrapping_add((i as u64 + 1).wrapping_mul(u64::from(v)))));
assert_eq!(actual, (sum, weighted), "{name}: sensor samples differ from reference");
seen += 1;
}
seen
}
/// Regression values from LightCraft's decoder with the derived companding curve; the 12-bit codes before the curve
/// were verified against the black-box oracle in Phase 0 (docs/arw6-compression.md). The files are CC0 from RawDB, one
/// per geometry and compression mode.
#[test]
fn corpus_sony_arw6_samples() {
let cases: &[(&str, usize, usize, u64, u64)] = &[
("arw6-sony-ilce7rm6-apsc-land-hq.arw", 6592, 4372, 85546367117, 1319631546150087499),
("arw6-sony-ilce7rm6-ff-land-c.arw", 10016, 6672, 257718215153, 9636375794063042132),
];
let seen = verify_sensor_arrays(cases);
eprintln!("verified {seen} Sony ARW6 sensor arrays");
}
/// The container rule for TIFF "shells" (a small IFD0 next to a private raw block) must not touch real
/// raws: every corpus file keeps the format its maker implies, and none is described as a private block.
#[test]
fn corpus_raws_keep_their_container_and_are_not_thumbnail_shells() {
let dir = corpus_root().join("raw");
let Ok(rd) = std::fs::read_dir(&dir) else {
eprintln!("skip: {} absent", dir.display());
return;
};
let mut checked = 0;
for p in rd.flatten().map(|e| e.path()).filter(|p| p.is_file()) {
let name = p.file_name().unwrap().to_string_lossy().to_lowercase();
let expected = match name.split(['-', '.']).next().unwrap_or_default() {
"arw" => RawFormat::Arw,
"cr2" => RawFormat::Cr2,
"cr3" => RawFormat::Cr3,
"dng" => RawFormat::Dng,
"nef" => RawFormat::Nef,
"nrw" => RawFormat::Nef, // probed as Nef when the file has several IFDs
"orf" => RawFormat::Orf,
"pef" => RawFormat::Pef,
"raf" => RawFormat::Raf,
"srw" => RawFormat::Srw,
"rw2" | "rwl" | "raw" => RawFormat::Rw2,
_ => continue,
};
let bytes = std::fs::read(&p).unwrap();
assert_eq!(probe(&bytes), Some(expected), "{name}");
if let Err(RawError::Unsupported(why)) = probe_info(&bytes) {
assert!(!why.contains("private block"), "{name}: {why}");
}
checked += 1;
}
eprintln!("{checked} corpus raws keep their container");
}
/// Matching Sony table dimensions are not evidence of a shared correction model. Exercise the
/// actual corpus headers and decoded files as well as the synthetic model-boundary unit tests.
#[test]
fn sony_embedded_distortion_is_limited_to_validated_models() {
let root = corpus_root().join("raw");
let mut checked = 0;
for (name, model, expected) in [
("arw-sony-a7rm4a-compressed.arw", "ILCE-7RM4A", 1),
("arw-sony-a9m2-compressed.arw", "ILCE-9M2", 0),
("arw-sony-a7m3-compressed.arw", "ILCE-7M3", 0),
("arw-sony-a7m3-uncompressed.arw", "ILCE-7M3", 0),
("arw-sony-a7m4-14bit.arw", "ILCE-7M4", 0),
("arw-sony-a7m4-lossless-l.arw", "ILCE-7M4", 0),
("arw-sony-a7m4-lossless-m.arw", "ILCE-7M4", 0),
("arw-sony-a7m4-lossless-s.arw", "ILCE-7M4", 0),
("arw-sony-a7rm2-12bit-uncompressed.arw", "ILCE-7RM2", 0),
("arw-sony-rx100.arw", "DSC-RX100", 0),
("arw-sony-rx100m3.arw", "DSC-RX100M3", 0),
("arw-sony-a7cr-lossless-l.arw", "ILCE-7CR", 0),
("arw-sony-a7cr-lossless-m.arw", "ILCE-7CR", 0),
("arw-sony-a7cr-compressed.arw", "ILCE-7CR", 0),
("arw-sony-a7rm4-14bit-compressed.arw", "ILCE-7RM4", 0),
("arw-sony-a500.arw", "DSLR-A500", 0),
("arw-sony-a700.arw", "DSLR-A700", 0),
("arw-sony-a33.arw", "SLT-A33", 0),
("arw-sony-a3500-5600k.arw", "ILCE-3500", 0),
("arw-sony-a7s-shade.arw", "ILCE-7S", 0),
("arw-sony-a7sm2-16x9.arw", "ILCE-7SM2", 0),
("arw-sony-a580-16x9.arw", "DSLR-A580", 0),
("arw-sony-a900-packed12.arw", "DSLR-A900", 0),
] {
let path = root.join(name);
if !path.exists() {
eprintln!("skip: {} absent", path.display());
continue;
}
let bytes = std::fs::read(&path).unwrap();
let full = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(probe_info(&bytes).unwrap(), full.info(), "{name}");
assert_eq!(full.metadata.model.as_deref(), Some(model), "{name}");
assert_eq!(full.opcodes.list3.len(), expected, "{name}: only the validated Sony model gets a distortion warp");
eprintln!("{name}: {expected} warp(s); header and full decode agree");
checked += 1;
}
eprintln!("Sony distortion model boundary: {checked} corpus files checked");
}
/// Nikon's in-file distortion correction (docs/nikon-lens-corrections.md) reaches only the validated bodies: the
/// Z f sample (a Tamron 35-150 at 150 mm) gets one warp, the DSLR and Coolpix samples none, and the header probe
/// describes every file as its full decode does.
#[test]
fn nikon_in_file_distortion_is_limited_to_validated_bodies() {
let root = corpus_root().join("raw");
let mut checked = 0;
for (name, model, expected) in [
("nef-nikon-zf-lossless.nef", "NIKON Z f", 1),
("nef-nikon-d5100-lossless.nef", "NIKON D5100", 0),
("nef-nikon-d5100-uncompressed.nef", "NIKON D5100", 0),
("nef-nikon-d7000-lossy12.nef", "NIKON D7000", 0),
("nef-nikon-d7500-lossless12.nef", "NIKON D7500", 0),
("nef-nikon-d7500-lossless14.nef", "NIKON D7500", 0),
("nrw-nikon-b700-uncompressed.nrw", "COOLPIX B700", 0),
] {
let path = root.join(name);
if !path.exists() {
eprintln!("skip: {} absent", path.display());
continue;
}
let bytes = std::fs::read(&path).unwrap();
let full = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(probe_info(&bytes).unwrap(), full.info(), "{name}");
assert_eq!(full.metadata.model.as_deref(), Some(model), "{name}");
assert_eq!(full.opcodes.list3.len(), expected, "{name}");
if let Some(lightcraft_raw::Opcode::WarpRectilinear { planes, .. }) = full.opcodes.list3.first() {
// the stored zoom (0.0557) frames the corrected picture as the camera does
assert!((planes[0][0] - 1.0 / 1.055719).abs() < 1e-4, "{name}: {planes:?}");
}
checked += 1;
}
eprintln!("Nikon distortion boundary: {checked} corpus files checked");
}
/// Kodak DCS520C (`crates/raw/src/vendor/kodak.rs`): the two-component lossless JPEG strip decodes through the
/// file's response curve to a 1736 x 1160 GRBG mosaic with 12-bit white, per-position black levels and the
/// white balance of the vendor IFD. Looks for the corpus copy, then the raw.pixls.us folder; skips without either.
#[test]
fn corpus_kodak_dcs520c() {
let name = "2573_Kodak - DCS520C - 12bit (3_2).TIF";
let candidates = [corpus_root().join("raw").join("kodak-dcs520c.tif"), Path::new("P:/raw-pixls/Kodak/DCS520C").join(name)];
let Some(bytes) = candidates.iter().find_map(|p| std::fs::read(p).ok()) else {
eprintln!("skip: DCS520C sample absent");
return;
};
assert_eq!(probe(&bytes), Some(RawFormat::KodakDcs));
let img = decode(&bytes).unwrap_or_else(|e| panic!("{e}"));
assert_eq!((img.width, img.height, img.bits), (1736, 1160, 12));
assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some("GRBG"));
assert_eq!(img.white_at(0), 4095.0);
let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") };
assert_eq!(d.len(), 1736 * 1160);
// stored 191..=622 expands to 191..=4095
assert_eq!((d.iter().min().copied(), d.iter().max().copied()), (Some(191), Some(4095)));
// black: G and R about 220, B about 197 (the darkest 0.01% of each position)
let b = &img.black.values;
assert_eq!(b.len(), 4);
assert!(
(200.0..=235.0).contains(&b[0]) && (200.0..=235.0).contains(&b[1]) && (185.0..=210.0).contains(&b[2]) && (200.0..=235.0).contains(&b[3]),
"{b:?}"
);
let wb = img.wb_multipliers.expect("white balance");
assert!((wb[0] - 0.9587).abs() < 1e-3 && wb[1] == 1.0 && (wb[2] - 1.9913).abs() < 1e-3, "{wb:?}");
// no column stripes: the two greens sit in different JPEG components (G1 on even columns, G2 on odd columns), so a component mismatch would split them
let mean = |y0: usize, x0: usize| {
let (mut s, mut n) = (0f64, 0f64);
for y in (y0..1160).step_by(2) {
for x in (x0..1736).step_by(2) {
s += f64::from(d[y * 1736 + x]);
n += 1.0;
}
}
s / n
};
let (g1, g2) = (mean(0, 0), mean(1, 1));
assert!((g1 - g2).abs() < 0.01 * g1, "greens {g1} vs {g2}");
}
/// Fujifilm RAF lens tables (`0xf00b` distortion, `0xf010` vignetting) become a warp and a vignette gain for the
/// cameras whose geometry was checked against the camera JPEG (docs/fuji-lens-corrections.md). Reads `LIGHTCRAFT_FUJI` (a folder
/// of RAFs or of per-model folders, e.g. the CC0 pixls set) or the corpus' `raf-fuji-*` files; skips when absent.
#[test]
fn fuji_embedded_lens_tables_follow_the_validated_models() {
fn collect(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
let p = e.path();
if p.is_dir() && depth < 2 {
collect(&p, depth + 1, out);
} else if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("raf")) {
out.push(p);
}
}
}
let mut files = Vec::new();
match std::env::var_os("LIGHTCRAFT_FUJI") {
Some(dir) => collect(Path::new(&dir), 0, &mut files),
None => {
collect(&corpus_root().join("raw"), 1, &mut files);
files.retain(|p| p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("raf-fuji-")));
}
}
files.sort();
// Bodies whose tables did not match the camera JPEG (or have no usable geometry sample): never corrected.
let unvalidated = ["FinePix F550EXR", "FinePix S1", "FinePix SL1000", "X10", "XF1", "XQ1"];
let (mut warped, mut vignetted, mut checked) = (0, 0, 0);
for path in &files {
let Ok(bytes) = std::fs::read(path) else { continue };
if lightcraft_raw::probe(&bytes) != Some(RawFormat::Raf) {
continue;
}
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let Ok(full) = decode(&bytes) else { continue };
assert_eq!(probe_info(&bytes).unwrap(), full.info(), "{name}: header and full decode agree");
let model = full.metadata.model.clone().unwrap_or_default();
let ops = &full.opcodes.list3;
if unvalidated.contains(&model.as_str()) {
assert!(ops.is_empty(), "{name} ({model}) is not a validated model");
}
for op in ops {
match op {
lightcraft_raw::Opcode::WarpRectilinear { planes, center } => {
assert_eq!(*center, [0.5, 0.5], "{name}");
assert_eq!(planes.len(), 1, "{name}: lateral CA is not applied");
// a sane radial mapping: close to 1 at the centre, monotone, never folding
let k = planes[0];
assert!((0.85..1.15).contains(&k[0]), "{name}: {k:?}");
let source = |r: f64| r * (k[0] + r * r * (k[1] + r * r * (k[2] + r * r * k[3])));
assert!((0..100).all(|i| source((i + 1) as f64 / 100.0) > source(i as f64 / 100.0)), "{name}");
warped += 1;
}
lightcraft_raw::Opcode::FixVignetteRadial { k, center } => {
assert_eq!(*center, [0.5, 0.5], "{name}");
let gain = 1.0 + k.iter().enumerate().map(|(i, c)| c * 1.0f64.powi(2 * i as i32 + 2)).sum::<f64>();
assert!((1.0..3.0).contains(&gain), "{name}: corner gain {gain}");
vignetted += 1;
}
other => panic!("{name}: unexpected opcode {other:?}"),
}
}
eprintln!(
"{model}: {} opcode(s) {}",
ops.len(),
if ops.iter().any(|o| matches!(o, lightcraft_raw::Opcode::WarpRectilinear { .. })) { "warp" } else { "no warp" }
);
checked += 1;
}
eprintln!("Fuji lens tables: {checked} RAFs, {warped} warps, {vignetted} vignette gains");
}