From 741e93e63202cd589faa01cbe7a04e9380b0d71e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 11:43:46 -0400 Subject: [PATCH] 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 --- crates/raw/examples/rawinfo.rs | 133 ++++++++++++++++++++ crates/raw/examples/rawrender.rs | 64 ++++++++++ crates/raw/src/lib.rs | 3 + crates/raw/src/vendor/cr2.rs | 202 ++++++++++++++++++++++++++++++- crates/raw/src/vendor/mod.rs | 81 ++++++++++++- 5 files changed, 479 insertions(+), 4 deletions(-) create mode 100644 crates/raw/examples/rawinfo.rs create mode 100644 crates/raw/examples/rawrender.rs diff --git a/crates/raw/examples/rawinfo.rs b/crates/raw/examples/rawinfo.rs new file mode 100644 index 00000000..d5cc21b1 --- /dev/null +++ b/crates/raw/examples/rawinfo.rs @@ -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 = 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::() / 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::() / 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"), + } + } +} diff --git a/crates/raw/examples/rawrender.rs b/crates/raw/examples/rawrender.rs new file mode 100644 index 00000000..abb1fe7c --- /dev/null +++ b/crates/raw/examples/rawrender.rs @@ -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 = 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); +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index cde39418..da4f4e41 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -113,6 +113,9 @@ pub fn probe(bytes: &[u8]) -> Option { 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 }); } diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 7452793d..601666d6 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -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 { - 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 { + 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 = 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, Vec) { + let img: Vec = (0..w * h).map(|i| 1024 + ((i * 7919) % 12000) as u16).collect(); + let widths: Vec = 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()); + } } diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index 54cea8b6..f15e6415 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -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, rows: Range, 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 = (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 = (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); + } +}