diff --git a/NOTICE b/NOTICE index 6729833e3..3bca3ae0d 100644 --- a/NOTICE +++ b/NOTICE @@ -93,7 +93,7 @@ crates/raw (craft-raw) is moved from LightCraft, with behaviour ported from two - LightCraft, crates/raw (src/lib.rs, dng.rs, dngwrite.rs, tiffraw.rs, unpack.rs, ljpeg.rs, jxl.rs, opcodes.rs, profile.rs, gaintable.rs, semantic.rs, preview.rs, vendor/crw.rs, vendor/hasselblad.rs, vendor.rs (shared level helpers), vendor/mrw.rs, vendor/erf.rs, vendor/mef.rs, vendor/kodak.rs, vendor/pef.rs, - vendor/srw.rs, tests/it/vendor_robust.rs and their tests) and crates/meta/src/mrw.rs (the MRW block container), + vendor/srw.rs, vendor/cr2.rs, vendor/cr2/sraw.rs, kelvin_presets.rs (the preset ordering), tests/it/vendor_robust.rs and their tests) and crates/meta/src/mrw.rs (the MRW block container), revision 5ff567a4a2e067bef1b6bce152c11e7f727ccbaf. https://github.com/storytold/lightcraft Copyright (c) 2026 ArtCraft Team and the LightCraft contributors. diff --git a/README.md b/README.md index 24d3485ef..575974164 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Option<&Ifd> { + tiff.ifds.get(3).or_else(|| tiff.ifds.iter().rev().find(|i| i.contains(CR2_SLICE))) +} + +/// Bayer layout from the raw IFD's `CR2CFAPattern` (`0xc5e0`) value; `None` for missing/unknown values. +fn cfa_from_tag(v: Option) -> Option { + let name = match v? { + 1 => "RGGB", + 2 => "BGGR", + 3 => "GBRG", + 4 => "GRBG", + _ => return None, + }; + Some(Cfa::bayer_static(name)) +} + +/// The 16-bit words of a maker-note array. Canon writes `ColorData` as SHORT in most models, but as UNDEFINED +/// bytes (count = byte length, e.g. 5120) in the PowerShot / EOS M models with ColorData versions -3 and -4; +/// those bytes are the same 16-bit words in the maker note's byte order, so they are paired up, not read one by +/// one. Other types are returned value by value. +fn words(v: &Value, order: ByteOrder) -> Vec { + match v { + Value::Byte(b) | Value::Undefined(b) => b.as_chunks::<2>().0.iter().map(|c| u64::from(order.u16(*c))).collect(), + _ => v.to_u64_vec(), + } +} + +/// WB multipliers (R, G, B; G = 1) from one set of levels, when they are plausible. +fn wb_from_levels(r: f32, g1: f32, g2: f32, b: f32) -> Option<[f32; 3]> { + if g1 < 256.0 || g2 < 256.0 || r < 64.0 || b < 64.0 || g1 > 16384.0 || (g1 - g2).abs() > 0.05 * g1 { + return None; + } + let g = (g1 + g2) / 2.0; + let (mr, mb) = (r / g, b / g); + ((0.25..6.0).contains(&mr) && (0.25..6.0).contains(&mb)).then_some([mr, 1.0, mb]) +} + +/// 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)?; + if let Some(wb) = wb_from_levels(q[0] as f32, q[1] as f32, q[2] as f32, q[3] as f32) { + return Some(wb); + } + } + None +} + +/// Where the planckian white-balance presets sit in `ColorData`, by `ColorDataVersion` (word 0, signed): the word +/// index of each preset's `R G G B` levels and of its colour temperature. Daylight, Shade, Cloudy, Tungsten and +/// Flash, in that order. Fluorescent is left out (a green light off the locus) and so is the Kelvin row (it holds the +/// dial's last setting, and on some bodies a temperature that doesn't match its own levels: 6036 K with the camera at +/// 5500 K on the PowerShot G1 X Mark III, Daylight's levels at 5213 K on the EOS 2000D). Positions from the ExifTool +/// Canon tag-name documentation's `ColorData5` / `ColorData8` tables, confirmed with ExifTool run as a black-box tool +/// on files of each version: -4 on a PowerShot G1 X Mark III (`ColorData5`: 8-word records, the second record after +/// Daylight a repeat of it), 13, 14 and 15 on the CC0 corpus samples of the EOS 5D Mark IV, 2000D and 6D Mark II +/// (`ColorData8`: 5-word records). Other versions give no presets. +fn preset_rows(version: i16) -> Option<[(usize, usize); 5]> { + match version { + -4 => Some([(95, 102), (111, 118), (119, 126), (127, 134), (151, 158)]), + 13..=15 => Some([(133, 137), (138, 142), (143, 147), (148, 152), (163, 167)]), + _ => None, + } +} + +/// The camera's planckian white-balance presets from the `ColorData` words ([`preset_rows`]) as a Kelvin curve; +/// `None` for an unknown version, a short array or presets that don't make a curve. +fn kelvin_presets_from_color_data(v: &[u64]) -> Option { + let version = *v.first()? as u16 as i16; + let rows = preset_rows(version)?; + let mut points = Vec::with_capacity(rows.len()); + for (at, temp_at) in rows { + let q = v.get(at..at + 4)?; + let temp = *v.get(temp_at)? as f32; + if let Some(wb) = wb_from_levels(q[0] as f32, q[1] as f32, q[2] as f32, q[3] as f32) + && (1000.0..=20000.0).contains(&temp) + { + points.push((temp, wb)); + } + } + crate::kelvin::KelvinPresets::from_unordered(points) +} + +/// As-shot WB multipliers of a body without `ColorData` (the PowerShot G9): the WBInfo row of the preset that +/// ShotInfo's white-balance value names. WBInfo is int32 values in the maker note's byte order, four per preset in +/// G R B G order, at the rows the ExifTool Canon tag-name documentation gives (2 Auto, 10 Daylight, 18 Cloudy, +/// 26 Tungsten, 34 Fluorescent, 50 Flash, 58 Underwater, 66 Custom 1, 74 Custom 2); other settings (14, Daylight +/// Fluorescent, has no documented row) give none. Measured on the CC0 file IMG_1188.CR2 (Custom 1, row 66: +/// 848 1747 1676 848), whose camera JPEG's neutral areas imply the same gains within 0.1 %. +fn wb_from_wb_info(m: &makernote::MakerNote) -> Option<[f32; 3]> { + let row = match m.ifd.u64s(SHOT_INFO)?.get(7)? { + 0 => 2, + 1 => 10, + 2 => 18, + 3 => 26, + 4 => 34, + 5 => 50, + 15 => 66, + 16 => 74, + 17 => 58, + _ => return None, + }; + let b = m.ifd.bytes(WB_INFO)?; + let level = |i: usize| Some(m.order.u32(b.get(4 * (row + i)..4 * (row + i + 1))?.try_into().ok()?) as i32 as f32); + wb_from_levels(level(1)?, level(0)?, level(3)?, level(2)?) +} + +/// The optically black columns left of the image area: from column 2 up to two columns before the first column +/// whose mean (over the image rows) rises above the leftmost columns' level by more than 1/256 of the remaining +/// range (at least 16), else up to two columns before the image area. Empty when the border is too narrow. +fn masked_columns(data: &[u16], width: usize, active: Rect, white: f32) -> Range { + let limit = active.x.saturating_sub(2).min(width); + if limit < 4 || active.height == 0 { + return 0..0; + } + let step = (active.height / 256).max(1); + let column_mean = |x: usize| { + let (mut sum, mut n) = (0.0, 0usize); + for y in (active.y..active.y.saturating_add(active.height)).step_by(step) { + if let Some(&v) = y.checked_mul(width).and_then(|i| data.get(i.checked_add(x)?)) { + sum += f64::from(v); + n += 1; + } + } + (n > 0).then(|| sum / n as f64) + }; + let means: Vec = (2..limit).map_while(column_mean).collect(); + let mut first: Vec = means.iter().take(8).copied().collect(); + first.sort_by(f64::total_cmp); + let Some(&level) = first.get(first.len() / 2) else { return 0..0 }; + let tolerance = ((f64::from(white) - level) / 256.0).max(16.0); + let end = match means.iter().position(|&m| m > level + tolerance) { + // `means[k]` is column `2 + k`: stop two columns before it + Some(k) => k, + None => 2 + means.len(), + }; + 2..end.max(2) +} + +pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result { + let tiff = Tiff::parse(bytes)?; + let ifd0 = tiff.ifds.first().ok_or_else(|| RawError::Corrupt("TIFF without IFDs".into()))?; + let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Corrupt("CR2 without raw IFD".into()))?; + match raw.u32(SRAW_TYPE) { + Some(SRAW_YCC) => return sraw::decode(bytes, &tiff, raw, mode, limits), + Some(v) if v != 1 => return Err(RawError::Unsupported(format!("Canon raw type {v}"))), + _ => {} + } + let off = raw.u64(t::STRIP_OFFSETS).ok_or(RawError::Tiff(craft_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 = craft_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 = match mode { + Mode::Full => { + let total = (fw as u64) + .checked_mul(fh as u64) + .and_then(|v| v.checked_mul(nc as u64)) + .filter(|t| *t > 0 && *t <= limits.samples() as u64) + .ok_or_else(|| RawError::Corrupt("bad CR2 frame size".into()))?; + Some(ljpeg::decode(src, total as usize)?) + } + Mode::Header => None, + }; + let total = match &frame { + Some(f) => f.data.len(), + None => { + let total = fw + .checked_mul(fh) + .and_then(|v| v.checked_mul(nc)) + .filter(|t| *t > 0) + .ok_or_else(|| RawError::Corrupt("bad CR2 frame size".into()))?; + if total > limits.samples() { + return Err(RawError::Limit("lossless JPEG frame larger than expected")); + } + total + } + }; + 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 => { + let w = (fw as u64).checked_mul(nc as u64).ok_or_else(|| RawError::Corrupt("bad CR2 frame size".into()))?; + vec![w as usize] + } + }; + 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::new(); + if let Some(frame) = &frame { + 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; + } + } + drop(frame); + + // 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 color_data = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))); + let kelvin_presets = color_data.as_deref().and_then(kelvin_presets_from_color_data); + let wb = color_data.as_deref().and_then(wb_from_color_balance).or_else(|| wb_from_wb_info(mn.as_ref()?)); + let cfa = cfa_from_tag(raw.u64(CR2_CFA_PATTERN)).unwrap_or_else(|| Cfa::bayer_static("RGGB")); + let white = white_from_data(&active_rows(&data, width, active), prec as u32); + let black = match masked_columns(&data, width, active, white) { + cols if cols.len() >= 2 => black_from_columns(&data, width, cols, active.y..active.y + active.height, active), + _ => BlackLevel::uniform(0.0), + }; + 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 { kelvin_presets, ..Default::default() }, + wb_multipliers: wb, + linearized: false, + pixel_aspect: 1.0, + opcodes: OpcodeLists::default(), + camera: tiff.ifds.first().map(Camera::from_ifd0).unwrap_or_default(), + cinema: Default::default(), + }; + img.validate_for(mode)?; + Ok(img) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use craft_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) { + synthetic_cr2_note(w, h, slices, None) + } + + /// Like [`synthetic_cr2`], with `note` as the Exif maker note (bytes as stored). + fn synthetic_cr2_note(w: usize, h: usize, slices: Option<[u16; 3]>, note: Option>) -> (Vec, Vec) { + let img: Vec = (0..w * h).map(|i| 1024 + ((i * 7919) % 12000) as u16).collect(); + (synthetic_cr2_of(&img, w, h, slices, note), img) + } + + /// A synthetic CR2 whose sensor samples are `img` (`w`×`h`, 14-bit), with `note` as the Exif maker note. + fn synthetic_cr2_of(img: &[u16], w: usize, h: usize, slices: Option<[u16; 3]>, note: Option>) -> Vec { + 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])); + if let Some(n) = note { + exif.set(t::MAKER_NOTE, Value::Undefined(n)); + } + ifd0.set_child(t::EXIF_IFD, exif); + let mut ifd3 = IfdBuilder::new(); + ifd3.set(t::COMPRESSION, Value::Short(vec![6])); + ifd3.set(CR2_CFA_PATTERN, Value::Long(vec![3])); + 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) + 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() + } + + #[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!(crate::probe_info(&bytes).unwrap(), r.info()); + assert_eq!(r.data, RawData::U16(img)); + assert_eq!(r.orientation, Orientation::Rotate270); + assert_eq!(r.cfa.as_ref().map(Cfa::name).as_deref(), Some("GBRG"), "CR2CFAPattern 3"); + } + } + + #[test] + fn cfa_pattern_tag() { + let name = |v| cfa_from_tag(v).map(|c| c.name()); + assert_eq!(name(Some(1)).as_deref(), Some("RGGB")); + assert_eq!(name(Some(2)).as_deref(), Some("BGGR")); + assert_eq!(name(Some(3)).as_deref(), Some("GBRG")); + assert_eq!(name(Some(4)).as_deref(), Some("GRBG")); + assert_eq!(name(Some(0)), None); + assert_eq!(name(Some(7)), None); + assert_eq!(name(None), None); + } + + #[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()); + } + + #[test] + fn wb_from_undefined_bytes() { + // hand-coded: ColorData of an S110-like file, UNDEFINED bytes, little-endian words; word 71.. = 1988 745 745 1641 + let mut b = vec![0u8; 2 * 120]; + b[0..2].copy_from_slice(&[0xfd, 0xff]); // version -3 + b[142..150].copy_from_slice(&[0xc4, 0x07, 0xe9, 0x02, 0xe9, 0x02, 0x69, 0x06]); + let le = words(&Value::Undefined(b.clone()), ByteOrder::Little); + assert_eq!(&le[71..75], &[1988, 745, 745, 1641]); + let wb = wb_from_color_balance(&le).unwrap(); + assert!((wb[0] - 1988.0 / 745.0).abs() < 1e-5 && wb[1] == 1.0 && (wb[2] - 1641.0 / 745.0).abs() < 1e-5); + // read as single bytes (the old behaviour) nothing is plausible + assert!(wb_from_color_balance(&b.iter().map(|&x| u64::from(x)).collect::>()).is_none()); + // big-endian note: same words, bytes swapped + let mut be = b.clone(); + be.as_chunks_mut::<2>().0.iter_mut().for_each(|c| c.swap(0, 1)); + assert_eq!(words(&Value::Undefined(be), ByteOrder::Big), le); + // SHORT values pass through, an odd trailing byte is ignored + assert_eq!(words(&Value::Short(vec![1, 2, 3]), ByteOrder::Little), vec![1, 2, 3]); + assert_eq!(words(&Value::Undefined(vec![1, 0, 2]), ByteOrder::Little), vec![1]); + } + + /// Light reaches columns left of the `SensorInfo` border (as on the EOS 6D): the black level comes from the + /// masked columns only, not from the image columns between them and the border. + #[test] + fn black_from_masked_columns_only() { + let (w, h, border, light) = (40, 12, 20, 14); + let data: Vec = (0..w * h).map(|i| if i % w < light { 2048 + (i % 2) as u16 } else { 6000 }).collect(); + let active = Rect::new(border, 2, w - border, h - 2); + let cols = masked_columns(&data, w, active, 15000.0); + assert_eq!(cols, 2..light - 2); + let black = black_from_columns(&data, w, cols, 2..h, active); + assert!(black.values.iter().all(|v| (2048.0..=2049.0).contains(v)), "{:?}", black.values); + // the whole border masked: every column but the two next to the image + let dark: Vec = (0..w * h).map(|i| if i % w < border { 2048 } else { 6000 }).collect(); + assert_eq!(masked_columns(&dark, w, active, 15000.0), 2..border - 2); + // too narrow a border, or no samples (headers-only decode): none + assert!(masked_columns(&dark, w, Rect::new(5, 0, 30, h), 15000.0).is_empty()); + assert!(masked_columns(&[], w, active, 15000.0).is_empty()); + } + + /// A maker note holding only `tag` as `ty` (3 = SHORT, 7 = UNDEFINED) with `data`, a plain IFD whose value + /// offset is relative to the TIFF header (patched by [`patch_note`] once the note's position is known). + fn note_with(tag: u16, ty: u16, data: &[u8]) -> Vec { + let count = if ty == 3 { data.len() / 2 } else { data.len() } as u32; + let mut note = Vec::new(); + note.extend_from_slice(&1u16.to_le_bytes()); + note.extend_from_slice(&tag.to_le_bytes()); + note.extend_from_slice(&ty.to_le_bytes()); + note.extend_from_slice(&count.to_le_bytes()); + note.extend_from_slice(&0u32.to_le_bytes()); // value offset, patched below + note.extend_from_slice(&0u32.to_le_bytes()); // next IFD + note.extend_from_slice(data); + note + } + + fn patch_note(bytes: &mut [u8]) { + let tiff = Tiff::parse(bytes).unwrap(); + let e = tiff.exif().unwrap().get(t::MAKER_NOTE).unwrap(); + let off = e.offset as usize; + bytes[off + 10..off + 14].copy_from_slice(&(e.offset as u32 + 18).to_le_bytes()); + } + + fn decode_with_color_data(ty: u16, data: &[u8]) -> RawImage { + let (mut bytes, _) = synthetic_cr2_note(64, 10, None, Some(note_with(COLOR_BALANCE, ty, data))); + patch_note(&mut bytes); + crate::decode(&bytes).unwrap() + } + + /// A maker note of `entries` (tag, TIFF type, value bytes), each value stored after the IFD, decoded; the value + /// offsets (relative to the TIFF header) are patched once the note's position is known. + fn decode_with_note(entries: &[(u16, u16, Vec)]) -> RawImage { + let ifd_len = 2 + 12 * entries.len() + 4; + let (mut note, mut data, mut at) = (Vec::new(), Vec::new(), Vec::new()); + note.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + for (tag, ty, value) in entries { + let count = if *ty == 3 { value.len() / 2 } else { value.len() } as u32; + note.extend_from_slice(&tag.to_le_bytes()); + note.extend_from_slice(&ty.to_le_bytes()); + note.extend_from_slice(&count.to_le_bytes()); + at.push((note.len(), ifd_len + data.len())); + note.extend_from_slice(&0u32.to_le_bytes()); + data.extend_from_slice(value); + } + note.extend_from_slice(&0u32.to_le_bytes()); + note.extend_from_slice(&data); + let (mut bytes, _) = synthetic_cr2_note(64, 10, None, Some(note)); + let tiff = Tiff::parse(&bytes).unwrap(); + let off = tiff.exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as usize; + for (field, value) in at { + bytes[off + field..off + field + 4].copy_from_slice(&((off + value) as u32).to_le_bytes()); + } + crate::decode(&bytes).unwrap() + } + + /// storytold/lightcraft#1273: the PowerShot G9 has no `ColorData`; its preset levels are WBInfo (`0x0029`: BYTE, int32 values, + /// four per preset in G R B G order), and ShotInfo (`0x0004`) value 7 says which preset was used (15 = Custom 1, + /// row 66: 848 1747 1676 848 on the CC0 file IMG_1188.CR2). + #[test] + fn white_balance_from_wb_info_when_there_is_no_color_data() { + let shot_info = |wb: u16| { + let mut v = [0u16; 34]; + v[7] = wb; + v.iter().flat_map(|x| x.to_le_bytes()).collect::>() + }; + let mut rows = vec![0i32; 512]; + rows[2..6].copy_from_slice(&[850, 1805, 1684, 850]); + rows[66..70].copy_from_slice(&[848, 1747, 1676, 848]); + rows[74..78].copy_from_slice(&[820, 1901, 4000, 820]); + rows[18..22].copy_from_slice(&[850, 1805, 6000, 850]); + let wb_info: Vec = rows.iter().flat_map(|x| x.to_le_bytes()).collect(); + let wb = |si: u16| decode_with_note(&[(0x0004, 3, shot_info(si)), (0x0029, 1, wb_info.clone())]).wb_multipliers; + assert_eq!(wb(15), Some([1747.0 / 848.0, 1.0, 1676.0 / 848.0])); + assert_eq!(wb(0), Some([1805.0 / 850.0, 1.0, 1684.0 / 850.0])); + // each preset its own row (Custom 2: row 74); an empty row (Daylight here), an implausible one (Cloudy: + // B 7.1) or a value without a documented row (14) gives none + assert_eq!(wb(16), Some([1901.0 / 820.0, 1.0, 4000.0 / 820.0])); + assert_eq!(wb(1), None); + assert_eq!(wb(2), None); + assert_eq!(wb(14), None); + // without ShotInfo, or with WBInfo too short for the row + assert_eq!(decode_with_note(&[(0x0029, 1, wb_info.clone())]).wb_multipliers, None); + assert_eq!(decode_with_note(&[(0x0004, 3, shot_info(15)), (0x0029, 1, wb_info[..4 * 68].to_vec())]).wb_multipliers, None); + // ColorData, when it gives levels, wins + let mut color_data = vec![0u8; 2 * 120]; + color_data[0..2].copy_from_slice(&[0xfd, 0xff]); + color_data[142..150].copy_from_slice(&[0xc4, 0x07, 0xe9, 0x02, 0xe9, 0x02, 0x69, 0x06]); + let both = decode_with_note(&[(0x0004, 3, shot_info(15)), (0x0029, 1, wb_info.clone()), (COLOR_BALANCE, 7, color_data)]); + assert_eq!(both.wb_multipliers, Some([1988.0 / 745.0, 1.0, 1641.0 / 745.0])); + } + + /// `ColorData` stored as UNDEFINED bytes (versions -3 / -4: PowerShot, EOS M) gives the as-shot WB from word 71, + /// exactly as the same words stored as SHORT do. + #[test] + fn color_data_stored_as_bytes() { + let mut bytes = vec![0u8; 2 * 120]; + bytes[0..2].copy_from_slice(&[0xfd, 0xff]); // version -3 + bytes[142..150].copy_from_slice(&[0xc4, 0x07, 0xe9, 0x02, 0xe9, 0x02, 0x69, 0x06]); // 1988 745 745 1641 + for ty in [7, 3] { + let wb = decode_with_color_data(ty, &bytes).wb_multipliers.unwrap_or_else(|| panic!("type {ty}: no WB")); + assert!((wb[0] - 1988.0 / 745.0).abs() < 1e-5 && wb[1] == 1.0 && (wb[2] - 1641.0 / 745.0).abs() < 1e-5, "type {ty}: {wb:?}"); + } + } + + /// `ColorData` words of `version` with the planckian presets (R, B over G = 1024, Kelvin) at its + /// [`preset_rows`], a Fluorescent-like green row between them, and the as-shot levels at word 71 / 63. + fn color_data_with_presets(version: i16, presets: &[(u16, u16, u16)]) -> Vec { + let mut v = vec![0u64; 400]; + v[0] = u64::from(version as u16); + for ((at, temp_at), &(r, b, k)) in preset_rows(version).unwrap().into_iter().zip(presets) { + v[at..at + 4].copy_from_slice(&[r.into(), 1024, 1024, b.into()]); + v[temp_at] = k.into(); + } + v + } + + /// storytold/lightcraft#157 of a downstream fork: the PowerShot G1 X Mark III (`ColorData` -4) gets a Kelvin curve from its + /// Daylight, Shade, Cloudy, Tungsten and Flash presets (levels and temperatures as its files carry them), which + /// puts a preset's own levels at the preset's temperature; the EOS bodies of versions 13–15 likewise. + #[test] + fn kelvin_presets_from_color_data_presets() { + let g1x3 = [(1418, 1357, 5651), (1653, 1171, 7505), (1533, 1259, 6594), (963, 2016, 3174), (1807, 1080, 8529)]; + let v = color_data_with_presets(-4, &g1x3); + let p = kelvin_presets_from_color_data(&v).unwrap(); + // every preset is on the curve at its own temperature, in Kelvin order + let mut want: Vec<(f32, [f32; 3])> = g1x3.iter().map(|&(r, b, k)| (k as f32, [r as f32 / 1024.0, 1.0, b as f32 / 1024.0])).collect(); + want.sort_by(|a, b| a.0.total_cmp(&b.0)); + assert_eq!(p.points(), want); + let eos = [(2013, 1479, 5200), (2300, 1280, 7000), (2153, 1374, 6000), (1442, 2089, 3200), (2203, 1371, 6156)]; + for version in [13, 14, 15] { + assert!(kelvin_presets_from_color_data(&color_data_with_presets(version, &eos)).is_some(), "{version}"); + } + // a preset that would turn the curve back (Flash cooler than Shade's levels say) is dropped, not fatal + let mut odd = g1x3; + odd[4] = (1500, 1300, 9000); + assert!(kelvin_presets_from_color_data(&color_data_with_presets(-4, &odd)).is_some()); + } + + /// Unknown versions, short or garbled arrays give no presets and never panic. + #[test] + fn kelvin_presets_from_hostile_color_data() { + let g1x3 = [(1418, 1357, 5651), (1653, 1171, 7505), (1533, 1259, 6594), (963, 2016, 3174), (1807, 1080, 8529)]; + let good = color_data_with_presets(-4, &g1x3); + let mut other = good.clone(); + other[0] = u64::from(-3i16 as u16); + assert!(kelvin_presets_from_color_data(&other).is_none(), "unverified version"); + assert!(kelvin_presets_from_color_data(&[]).is_none()); + for len in [1, 96, 120, 157, 158] { + assert!(kelvin_presets_from_color_data(&good[..len]).is_none(), "{len}"); + } + let mut zeros = good.clone(); + for at in [102, 118, 126] { + zeros[at] = 0; + } + assert!(kelvin_presets_from_color_data(&zeros).is_none(), "two points left"); + let mut seed = 0x2545_f491_4f6c_dd1du64; + for _ in 0..2000 { + let v: Vec = (0..200) + .map(|i| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + if i == 0 { [0xfffc, 13, 14, 15][(seed % 4) as usize] } else { seed % 70000 } + }) + .collect(); + let _ = kelvin_presets_from_color_data(&v); + } + // through a whole decode, ColorData as UNDEFINED bytes + let bytes: Vec = good.iter().flat_map(|&w| (w as u16).to_le_bytes()).collect(); + assert!(decode_with_color_data(7, &bytes).color.kelvin_presets.is_some()); + assert!(decode_with_color_data(7, &bytes[..200]).color.kelvin_presets.is_none()); + } + + /// Masked rows above the image full of samples at 16383 (EOS 50D, storytold/lightcraft#1272): the white level is the image's + /// own saturation plateau, not the edge of that strip, so clipped highlights still reach 1.0. + #[test] + fn white_from_active_area_only() { + let (w, h, top, left) = (64, 40, 4, 8); + let img: Vec = (0..w * h) + .map(|i| match (i % w, i / w) { + (_, y) if y < top => 16383, + (x, _) if x < left => 1024, + _ if i % 7 == 0 => 13433, + _ => 1024 + ((i * 7919) % 11000) as u16, + }) + .collect(); + // SensorInfo: width, height, three unused words, then the inclusive left/top/right/bottom borders + let si: Vec = [w, h, 0, 0, 0, left, top, w - 1, h - 1].iter().flat_map(|&v| (v as u16).to_le_bytes()).collect(); + let mut bytes = synthetic_cr2_of(&img, w, h, None, Some(note_with(SENSOR_INFO, 3, &si))); + patch_note(&mut bytes); + let r = crate::decode(&bytes).unwrap(); + assert_eq!(r.active_area, Rect::new(left, top, w - left, h - top)); + assert!((13300.0..=13433.0).contains(&r.white[0]), "{:?}", r.white); + } +} diff --git a/crates/raw/src/cr2/sraw.rs b/crates/raw/src/cr2/sraw.rs new file mode 100644 index 000000000..f8c1344e2 --- /dev/null +++ b/crates/raw/src/cr2/sraw.rs @@ -0,0 +1,601 @@ +//! Canon sRAW / mRAW: reduced-resolution YCbCr "raw" frames in CR2 (raw IFD tag `0xc6c5` = 4). +//! +//! What the files contain (measured on the 31 CC0 sRAW / mRAW samples of 16 models, see the evidence below): +//! +//! - The raw strip is one lossless-JPEG frame with three components. Luma is sampled 2×1 (sRAW, 4:2:2, `0x21`) or +//! 2×2 (mRAW, 4:2:0, `0x22`), both chroma components 1×1; precision 15, predictor 1, no restart markers. +//! The frame's width and height are only a factorisation of the sample count: they are not the image size. +//! - The decoded sample stream (MCU after MCU: `Y Y Cb Cr` or `Y Y Y Y Cb Cr`) is cut into vertical slices exactly +//! like the CFA data (`cr2_slice = [n, w, last]`, widths counted in samples). A slice row is a whole number of +//! MCUs, so the image is `Σw / MCU-size × 2` pixels wide, and one slice row is one pixel row (4:2:2) or two (4:2:0). +//! - Prediction: each component is a one-dimensional chain in decoding order (so the second luma row of a 4:2:0 MCU +//! continues from the end of the first), and the first sample of every frame row starts from the first sample of +//! the previous one ([`ljpeg::Prediction::Sequential`]). The Sony prediction (geometric) gives vertical stripes on +//! these files. +//! - The samples are linear. Luma is the BT.601 luma (0.299, 0.587, 0.114; also the TIFF 6.0 default +//! `YCbCrCoefficients`) of white-balanced camera RGB, about 7/8 of the black-subtracted sensor counts; chroma is +//! centred on 16384. White balance is baked in (neutral surfaces have neutral chroma); which of the maker note's +//! white-balance sets was used is not always the as-shot one, so it is not undone. +//! - Chroma is stored as a scaled colour difference, `R − Y = a·(Cr − 16384)` and `B − Y = b·(Cb − 16384)`, with +//! `G` from the luma equation. `a` and `b` are about 1.0 on bodies from 2012 on, but about 5.7 and 7.4 (the TIFF +//! 6.0 YCbCr ratio 1.402 : 1.772, scaled by about 4.1) on the 2008–2010 bodies (5D Mark II, 50D, 7D, 60D, 1D Mark IV). +//! Rather than keying a table on the model, the gains are measured per file against the 16-bit RGB image the +//! camera stores in IFD2 for these files (see [`chroma_gains`]). +//! +//! Only these files were used as evidence: the sRAW samples themselves, the same cameras' CFA files of the same +//! scenes (raw counts against the decoded samples), and the IFD2 image. The ExifTool tag documentation was used for +//! tag names. + +use super::{COLOR_BALANCE, CR2_SLICE, SENSOR_INFO, words}; +use crate::{BlackLevel, Camera, ColorData, Limits, Mode, OpcodeLists, Orientation, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; +use craft_tiff::image::{Chunk, chunk_bytes}; +use craft_tiff::{ByteOrder, Ifd, Tiff, makernote, tags as t}; +use rayon::prelude::*; + +/// Chroma samples are centred on this value. +const CHROMA_CENTRE: f64 = 16384.0; +/// BT.601 luma weights, the TIFF 6.0 default `YCbCrCoefficients`. +const KR: f64 = 0.299; +const KG: f64 = 0.587; +const KB: f64 = 0.114; +/// Decoded luma relative to black-subtracted sensor counts (white-balanced): 0.877, 0.877, 0.896, 0.880, 0.871, 0.851 +/// and 0.846 on the 7D Mark II, 5DS R, 6D Mark II, 5DS, 5D Mark II, 1D X Mark II and 50D (CFA and sRAW files of the +/// same scene), so 7/8. It sets the white level, so that a file renders about as bright as the CFA file of the same +/// scene. +const LUMA_GAIN: f32 = 0.875; +/// Full scale of a 14-bit sensor, the white level of a CFA CR2 without a saturation plateau. +const SENSOR_FULL: f32 = 16383.0; +/// Mid-tone limit (IFD2 units) of the pixels used for the chroma fit: above about a quarter of full scale the +/// camera's own RGB image rolls off. +const FIT_LIMIT: f64 = 4096.0; + +/// How the decoded frame maps to the image. +#[derive(Debug, PartialEq)] +pub(super) struct Layout { + pub width: usize, + pub height: usize, + /// Vertical subsampling of the chroma planes: 1 (4:2:2, sRAW) or 2 (4:2:0, mRAW). + pub vertical: usize, + /// Slice widths in samples. + widths: Vec, + /// Slice height in MCU rows. + rows: usize, + /// Samples per MCU. + mcu: usize, + /// Frame size from the SOF3 header. + frame_width: usize, + frame_height: usize, +} + +impl Layout { + /// The layout of a `frame_width × frame_height` frame with `vertical` chroma subsampling and `cr2_slice` tag + /// values `slices` (`[n, w, last]`). + pub(super) fn new(frame_width: usize, frame_height: usize, vertical: usize, slices: &[u64], limits: &Limits) -> Result { + let &[n, w, last] = slices else { return Err(RawError::Corrupt("sRAW without slice layout".into())) }; + if n > 64 || w == 0 || last == 0 { + return Err(RawError::Corrupt("sRAW slice layout".into())); + } + let mcu = 2 * vertical + 2; + let mut widths = vec![w as usize; n as usize]; + widths.push(last as usize); + if widths.iter().any(|w| w % mcu != 0) { + return Err(RawError::Corrupt("sRAW slice is not a whole number of MCUs".into())); + } + let samples = + frame_width.checked_mul(frame_height).and_then(|p| p.checked_add(p / vertical)).ok_or(RawError::Limit("sRAW frame too large"))?; + let row = widths.iter().try_fold(0usize, |a, &w| a.checked_add(w)).ok_or(RawError::Limit("sRAW slice widths"))?; + if samples == 0 || row == 0 || samples % row != 0 { + return Err(RawError::Corrupt(format!("sRAW slices ({row} samples per row) do not divide the frame ({samples} samples)"))); + } + let rows = samples / row; + let (width, height) = (row / mcu * 2, rows * vertical); + if width.checked_mul(height).and_then(|p| p.checked_mul(3)).is_none_or(|s| s > limits.samples()) { + return Err(RawError::Limit("sRAW image too large")); + } + Ok(Layout { width, height, vertical, widths, rows, mcu, frame_width, frame_height }) + } +} + +/// Planar YCbCr at image geometry: luma `width × height`, chroma `width/2 × height/vertical`. +struct Planes { + width: usize, + height: usize, + vertical: usize, + y: Vec, + cb: Vec, + cr: Vec, +} + +impl Planes { + fn chroma_width(&self) -> usize { + self.width / 2 + } + + /// Re-chop the decoded MCU stream into image planes. + fn arrange(frame: &ljpeg::FrameSubsampled, layout: &Layout) -> Planes { + let (w, h, v, m) = (layout.width, layout.height, layout.vertical, layout.mcu); + let cw = w / 2; + let mut p = Planes { width: w, height: h, vertical: v, y: vec![0; w * h], cb: vec![0; cw * (h / v)], cr: vec![0; cw * (h / v)] }; + let mcus_per_frame_row = layout.frame_width / 2; + let mut x0 = 0; + for &sw in &layout.widths { + let per_row = sw / m; + let first = layout.rows * (x0 / m); + for r in 0..layout.rows { + for k in 0..per_row { + let q = first + r * per_row + k; + let (qy, qx) = (q / mcus_per_frame_row, q % mcus_per_frame_row); + let px = (x0 / m + k) * 2; + for dy in 0..v { + let src = (qy * v + dy) * layout.frame_width + qx * 2; + let dst = (r * v + dy) * w + px; + p.y[dst..dst + 2].copy_from_slice(&frame.planes[0][src..src + 2]); + } + p.cb[r * cw + px / 2] = frame.planes[1][qy * mcus_per_frame_row + qx]; + p.cr[r * cw + px / 2] = frame.planes[2][qy * mcus_per_frame_row + qx]; + } + } + x0 += sw; + } + p + } + + /// Mean luma / Cb / Cr over the pixel window `[x0, x1) × [y0, y1)` (non-empty, inside the image). + fn window_mean(&self, x0: usize, x1: usize, y0: usize, y1: usize) -> (f64, f64, f64) { + let (mut sy, mut ny) = (0f64, 0f64); + for y in y0..y1 { + let row = &self.y[y * self.width + x0..y * self.width + x1]; + sy += row.iter().map(|&v| f64::from(v)).sum::(); + ny += row.len() as f64; + } + let (cx0, cx1) = (x0 / 2, x1.div_ceil(2).max(x0 / 2 + 1)); + let (cy0, cy1) = (y0 / self.vertical, y1.div_ceil(self.vertical).max(y0 / self.vertical + 1)); + let (mut sb, mut sr, mut nc) = (0f64, 0f64, 0f64); + for y in cy0..cy1.min(self.height / self.vertical) { + for x in cx0..cx1.min(self.chroma_width()) { + sb += f64::from(self.cb[y * self.chroma_width() + x]); + sr += f64::from(self.cr[y * self.chroma_width() + x]); + nc += 1.0; + } + } + let nc = nc.max(1.0); + (sy / ny.max(1.0), sb / nc - CHROMA_CENTRE, sr / nc - CHROMA_CENTRE) + } + + /// Linear RGB (`width × height × 3`): `R = Y + a·Cr`, `B = Y + b·Cb`, `G` from the luma equation. + fn to_rgb(&self, a: f64, b: f64) -> Vec { + let (gr, gb) = ((KR / KG * a) as f32, (KB / KG * b) as f32); + let (a, b) = (a as f32, b as f32); + let cw = self.chroma_width(); + let centre = CHROMA_CENTRE as f32; + let mut out = vec![0u16; self.width * self.height * 3]; + out.par_chunks_mut(self.width * 3).enumerate().for_each(|(y, row)| { + let luma = &self.y[y * self.width..(y + 1) * self.width]; + let chroma = (y / self.vertical) * cw; + for (x, px) in row.as_chunks_mut::<3>().0.iter_mut().enumerate() { + let (cb, cr) = (f32::from(self.cb[chroma + x / 2]) - centre, f32::from(self.cr[chroma + x / 2]) - centre); + let l = f32::from(luma[x]); + px[0] = (l + a * cr).round().clamp(0.0, 65535.0) as u16; + px[1] = (l - gr * cr - gb * cb).round().clamp(0.0, 65535.0) as u16; + px[2] = (l + b * cb).round().clamp(0.0, 65535.0) as u16; + } + }); + out + } +} + +/// The 16-bit RGB image in IFD2 (`SRawType` 3): the camera's own rendering of the same frame at a fraction of the +/// size, white-balanced and linear below about a quarter of full scale. +struct Preview { + width: usize, + height: usize, + rgb: Vec, +} + +fn read_preview(bytes: &[u8], tiff: &Tiff) -> Option { + let info = tiff.ifds.get(2)?.image().ok()?; + let (w, h) = (info.width as usize, info.height as usize); + if info.samples_per_pixel != 3 || info.bits_per_sample != [16, 16, 16] || info.compression != 1 || info.planar != 1 || info.sample_format != 1 { + return None; + } + let n = w.checked_mul(h)?.checked_mul(3)?; + // uncompressed 16-bit samples: more than the whole file holds is refused before the buffer is reserved + if n == 0 || n > 1 << 24 || n * 2 > bytes.len() { + return None; + } + let mut raw: Vec = Vec::with_capacity(n * 2); + for c in info.chunks(bytes.len() as u64) { + raw.extend_from_slice(chunk_bytes(bytes, &c)?); + if raw.len() >= n * 2 { + break; + } + } + if raw.len() < n * 2 { + return None; + } + let order: ByteOrder = tiff.order; + let rgb = raw.as_chunks::<2>().0.iter().take(n).map(|&c| order.u16(c)).collect(); + Some(Preview { width: w, height: h, rgb }) +} + +#[derive(Default)] +struct Regression { + n: f64, + sx: f64, + st: f64, + sxx: f64, + sxt: f64, + stt: f64, +} + +impl Regression { + fn add(&mut self, x: f64, t: f64) { + self.n += 1.0; + self.sx += x; + self.st += t; + self.sxx += x * x; + self.sxt += x * t; + self.stt += t * t; + } + + /// Least-squares slope of `t` on `x` (with intercept) and its residual sum of squares. + fn slope(&self) -> Option<(f64, f64)> { + let var = self.n * self.sxx - self.sx * self.sx; + if self.n < 500.0 || var <= 1e-9 * self.n * self.n { + return None; + } + let slope = (self.n * self.sxt - self.sx * self.st) / var; + let icpt = (self.st - slope * self.sx) / self.n; + let rss = + self.stt - 2.0 * slope * self.sxt - 2.0 * icpt * self.st + slope * slope * self.sxx + 2.0 * slope * icpt * self.sx + self.n * icpt * icpt; + Some((slope, rss.max(0.0))) + } +} + +/// How the IFD2 image sits on the full-size planes: its pixel `(i, j)` covers the window starting at +/// `(x0 + i·sx, y0 + j·sy)` and `sx × sy` pixels large. +struct Grid { + x0: f64, + y0: f64, + sx: f64, + sy: f64, +} + +/// The chroma gains `(a, b)` of this file (`R − Y = a·Cr`, `B − Y = b·Cb`), by least squares of the IFD2 image +/// against the planes averaged to its size. Where IFD2 sits on the frame depends on the model (the whole frame, +/// the sensor's active area, or a whole or half-integer reduction of the frame that overhangs it by a few pixels), +/// so these are tried and the best fit is kept. `None` when the file has no usable IFD2 or the fit is not well +/// conditioned. +fn chroma_gains(planes: &Planes, preview: &Preview, active: Rect) -> Option<(f64, f64)> { + let (pw, ph) = (preview.width as f64, preview.height as f64); + let (w, h) = (planes.width as f64, planes.height as f64); + let grids = [ + Grid { x0: 0.0, y0: 0.0, sx: w / pw, sy: h / ph }, + Grid { x0: active.x as f64, y0: active.y as f64, sx: active.width as f64 / pw, sy: active.height as f64 / ph }, + Grid { x0: 0.0, y0: 0.0, sx: (2.0 * w / pw).round() / 2.0, sy: (2.0 * h / ph).round() / 2.0 }, + ]; + let mut best: Option<(f64, f64, f64)> = None; + for g in &grids { + if !(g.sx >= 1.0 && g.sy >= 1.0) { + continue; + } + let (mut fr, mut fb) = (Regression::default(), Regression::default()); + for j in 0..preview.height { + let y0 = (g.y0 + j as f64 * g.sy) as usize; + let y1 = ((g.y0 + (j + 1) as f64 * g.sy) as usize).min(planes.height); + for i in 0..preview.width { + let p = &preview.rgb[(j * preview.width + i) * 3..][..3]; + let [r, gr, b] = [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]; + if r.max(gr).max(b) >= FIT_LIMIT || r.min(gr).min(b) <= 0.0 { + continue; + } + let x0 = (g.x0 + i as f64 * g.sx) as usize; + let x1 = ((g.x0 + (i + 1) as f64 * g.sx) as usize).min(planes.width); + if x1 <= x0 || y1 <= y0 { + continue; + } + let (y, cb, cr) = planes.window_mean(x0, x1, y0, y1); + if y < 150.0 { + continue; + } + fr.add(cr, r - y); + fb.add(cb, b - y); + } + } + if let (Some((a, ra)), Some((b, rb))) = (fr.slope(), fb.slope()) { + let cost = (ra + rb) / (fr.n + fb.n); + if a.is_finite() && b.is_finite() && best.is_none_or(|(_, _, c)| cost < c) { + best = Some((a, b, cost)); + } + } + } + best.map(|(a, b, _)| (a, b)).filter(|(a, b)| (0.25..=16.0).contains(a) && (0.25..=16.0).contains(b)) +} + +/// Word index of `AverageBlackLevel` (four equal-ish values) in the maker note's `ColorData` by `ColorDataVersion` +/// (word 0), located by searching the CC0 samples for the values ExifTool reports. +fn average_black_at(version: i16) -> Option { + Some(match version { + 6 | 7 | 9 => 231, + 10 | 11 => 276, + 12 | 13 | 15 => 326, + _ => return None, + }) +} + +/// The sensor's black level in raw counts from `ColorData` (`None` for versions whose position is not known, and +/// when the four values are not plausible and near-equal). +fn colour_data_black(words: &[u64]) -> Option { + let version = words.first().map(|&v| v as u16 as i16)?; + let q = words.get(average_black_at(version)?..)?.get(..4)?; + let (lo, hi) = (q.iter().min()?, q.iter().max()?); + ((256..=4096).contains(lo) && hi - lo <= 8).then(|| q.iter().sum::() as f32 / 4.0) +} + +/// Decode a Canon sRAW / mRAW CR2 into linear white-balanced camera RGB (`cpp` 3). +pub(super) fn decode(bytes: &[u8], tiff: &Tiff, raw: &Ifd, mode: Mode, limits: &Limits) -> Result { + let ifd0 = tiff.ifds.first().ok_or_else(|| RawError::Corrupt("TIFF without IFDs".into()))?; + let off = raw.u64(t::STRIP_OFFSETS).ok_or(RawError::Tiff(craft_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 = 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, vertical) = ljpeg::frame_info_subsampled(src)?; + let slices = raw.u64s(CR2_SLICE).unwrap_or_default(); + let layout = Layout::new(fw, fh, vertical, &slices, limits)?; + + 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 (w, h) = (layout.width, layout.height); + let mut active = Rect::new(0, 0, w, h); + 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 < w && b < h { + active = Rect::new(l, tp, r - l + 1, b - tp + 1); + } + } + let colour_data = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))); + let black_counts = colour_data.as_deref().and_then(colour_data_black); + // 1.0 = a CFA CR2 of the same body at full 14-bit scale; see LUMA_GAIN + let white = LUMA_GAIN * (SENSOR_FULL - black_counts.unwrap_or(0.0)); + + let data = match mode { + Mode::Header => Vec::new(), + Mode::Full => { + let samples = fw * fh + fw * fh / vertical; + let frame = ljpeg::decode_subsampled(src, samples.min(limits.samples()), ljpeg::Prediction::Sequential)?; + let planes = Planes::arrange(&frame, &layout); + drop(frame); + let (a, b) = read_preview(bytes, tiff).and_then(|p| chroma_gains(&planes, &p, active)).unwrap_or((1.0, 1.0)); + planes.to_rgb(a, b) + } + }; + + let img = RawImage { + format: RawFormat::Cr2, + width: w, + height: h, + cpp: 3, + data: RawData::U16(data), + cfa: None, + bits: 14, + black: BlackLevel::uniform(0.0), + 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(), + // the camera's white balance is already applied, as in Sony's linear YCbCr + wb_multipliers: Some([1.0; 3]), + linearized: false, + pixel_aspect: 1.0, + opcodes: OpcodeLists::default(), + camera: tiff.ifds.first().map(Camera::from_ifd0).unwrap_or_default(), + cinema: Default::default(), + }; + img.validate_for(mode)?; + Ok(img) +} + +#[cfg(test)] +mod tests { + use super::*; + use craft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + + const SRAW_TYPE_TAG: u16 = 0xc6c5; + + /// The 4:2:2 stream of `ljpeg`'s hand-coded fixture (4 × 2 frame, two MCUs per frame row) decodes to luma + /// 1000 1001 / 1002 1003 / 1100 1101 / 1102 1103 in MCU order, Cb 16384 16385 16484 16485, Cr 16484 16486 + /// 16584 16586. + fn stream_422() -> Vec { + ljpeg::tests::fixture_subsampled(4, 2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2]) + } + + /// A CR2 whose raw IFD holds `stream` cut into the slices `slices` (IFD2 = a 2 × 2 16-bit RGB image when + /// `preview` is given). + fn sraw_file(stream: Vec, slices: [u16; 3], sraw_type: u32, preview: Option<[u16; 12]>) -> Vec { + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Canon".into())); + ifd0.set(t::MODEL, Value::Ascii("Canon EOS Test".into())); + let mut ifd2 = IfdBuilder::new(); + if let Some(p) = preview { + ifd2.set(t::COMPRESSION, Value::Short(vec![1])); + ifd2.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd2.set(t::IMAGE_WIDTH, Value::Long(vec![2])); + ifd2.set(t::IMAGE_LENGTH, Value::Long(vec![2])); + ifd2.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])); + ifd2.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd2.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![p.iter().flat_map(|v| v.to_le_bytes()).collect()] }); + } else { + ifd2.set(1, Value::Short(vec![0])); + } + let mut ifd3 = IfdBuilder::new(); + ifd3.set(t::COMPRESSION, Value::Short(vec![6])); + ifd3.set(SRAW_TYPE_TAG, Value::Long(vec![sraw_type])); + ifd3.set(CR2_SLICE, Value::Short(slices.to_vec())); + ifd3.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![stream] }); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0, IfdBuilder::new().with(1, Value::Short(vec![0])), ifd2, ifd3]).unwrap() + } + + #[test] + fn slices_are_cut_back_into_image_columns_and_converted() { + // slices [1, 4, 4]: two slices of one MCU per row; slice 0 holds MCUs 0 and 1 (image rows 0 and 1), slice 1 + // holds MCUs 2 and 3, so the image is 4 × 2 with luma 1000 1001 1100 1101 / 1002 1003 1102 1103. + let bytes = sraw_file(stream_422(), [1, 4, 4], 4, None); + let img = crate::decode(&bytes).unwrap(); + assert_eq!((img.width, img.height, img.cpp), (4, 2, 3)); + assert!(img.cfa.is_none()); + assert_eq!(img.wb_multipliers, Some([1.0; 3])); + let RawData::U16(px) = &img.data else { panic!("integer data") }; + // no IFD2: chroma gains 1; R = Y + Cr', B = Y + Cb', G from the BT.601 luma + let expect = |y: f64, cb: f64, cr: f64| { + let (r, b) = (y + cr, y + cb); + [r, (y - KR * r - KB * b) / KG, b].map(|v| v.round() as i32) + }; + let cases = [ + ((0, 0), expect(1000.0, 0.0, 100.0)), + ((1, 0), expect(1001.0, 0.0, 100.0)), + ((2, 0), expect(1100.0, 100.0, 200.0)), + ((3, 0), expect(1101.0, 100.0, 200.0)), + ((0, 1), expect(1002.0, 1.0, 102.0)), + ((3, 1), expect(1103.0, 101.0, 202.0)), + ]; + for ((x, y), want) in cases { + let got: Vec = px[(y * 4 + x) * 3..][..3].iter().map(|&v| i32::from(v)).collect(); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1), "({x},{y}): {got:?} vs {want:?}"); + } + // header-only decoding reports the same image + assert_eq!(crate::probe_info(&bytes).unwrap(), img.info()); + } + + #[test] + fn a_preview_too_small_to_fit_leaves_the_gains_at_one() { + // IFD2 of 2 × 2 pixels cannot support a fit (it needs hundreds of samples): the file still decodes + let with = crate::decode(&sraw_file(stream_422(), [1, 4, 4], 4, Some([900; 12]))).unwrap(); + let without = crate::decode(&sraw_file(stream_422(), [1, 4, 4], 4, None)).unwrap(); + assert_eq!(with.data, without.data); + } + + /// storytold/lightcraft#2561: IFD2's 16-bit RGB preview is read when its samples fit in the file, and refused before its buffer is + /// reserved when they cannot: 64 × 2000 pixels (768000 bytes) whose one-row strips all point at the same bytes of + /// a file a few tens of KiB long. + #[test] + fn a_preview_larger_than_the_file_is_refused() { + let bytes = sraw_file(stream_422(), [1, 4, 4], 4, Some([900; 12])); + let p = read_preview(&bytes, &Tiff::parse(&bytes).unwrap()).expect("a 2 x 2 preview that fits"); + assert_eq!((p.width, p.height, p.rgb), (2, 2, vec![900; 12])); + let (w, rows) = (64u32, 2000u32); + let mut ifd2 = IfdBuilder::new(); + ifd2.set(t::COMPRESSION, Value::Short(vec![1])); + ifd2.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd2.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd2.set(t::IMAGE_LENGTH, Value::Long(vec![rows])); + ifd2.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])); + ifd2.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd2.set(t::ROWS_PER_STRIP, Value::Long(vec![1])); + ifd2.set(t::STRIP_OFFSETS, Value::Long(vec![0; rows as usize])); + ifd2.set(t::STRIP_BYTE_COUNTS, Value::Long(vec![w * 6; rows as usize])); + let one = || IfdBuilder::new().with(1, Value::Short(vec![0])); + let big = TiffWriter::new(ByteOrder::Little, false).write(&[one(), one(), ifd2]).unwrap(); + assert!(big.len() > (w * 6) as usize && big.len() < (w * rows * 6) as usize, "{} bytes", big.len()); + assert!(read_preview(&big, &Tiff::parse(&big).unwrap()).is_none()); + } + + #[test] + fn only_ycbcr_frames_take_this_path() { + // other raw types (3 is the preview image's) stay unsupported, as before + for kind in [0, 2, 3, 5] { + let bytes = sraw_file(stream_422(), [1, 4, 4], kind, None); + assert!(matches!(crate::decode(&bytes), Err(RawError::Unsupported(_))), "type {kind}"); + } + } + + #[test] + fn slice_layouts_must_fit_the_frame() { + let ok = Layout::new(4, 2, 1, &[1, 4, 4], &Limits::default()).unwrap(); + assert_eq!((ok.width, ok.height), (4, 2)); + // 4:2:0: six samples per MCU, one slice row is two image rows + let l = Layout::new(8, 4, 2, &[1, 12, 12], &Limits::default()).unwrap(); + assert_eq!((l.width, l.height), (8, 4)); + assert!(Layout::new(4, 2, 1, &[1, 3, 5], &Limits::default()).is_err()); // not whole MCUs + assert!(Layout::new(4, 2, 1, &[1, 4, 8], &Limits::default()).is_err()); // rows do not divide the frame + assert!(Layout::new(4, 2, 1, &[1, 0, 4], &Limits::default()).is_err()); + assert!(Layout::new(4, 2, 1, &[4, 4], &Limits::default()).is_err()); // not three values + assert!(Layout::new(4, 2, 1, &[65, 4, 4], &Limits::default()).is_err()); + // slice widths whose sum overflows (or wraps to zero) are rejected, not divided by + assert!(Layout::new(4, 2, 1, &[3, 1 << 62, 1 << 62], &Limits::default()).is_err()); + assert!(Layout::new(4, 2, 1, &[1, u64::MAX - 3, 4], &Limits::default()).is_err()); + let bytes = sraw_file(stream_422(), [1, 4, 8], 4, None); + assert!(crate::decode(&bytes).is_err()); + } + + /// Textured luma and smooth chroma on `w × h` planes. + fn test_planes(w: usize, h: usize, vertical: usize) -> Planes { + let y: Vec = (0..w * h).map(|i| (600 + ((i % w) * 13 + (i / w) * 29) % 1500) as u16).collect(); + let cw = w / 2; + let ch = h / vertical; + let cb = (0..cw * ch).map(|i| (16384 + (i % cw) as i32 * 7 - (i / cw) as i32 * 3 - 150) as u16).collect(); + let cr = (0..cw * ch).map(|i| (16384 + (i / cw) as i32 * 5 + (i % cw) as i32 * 2 - 120) as u16).collect(); + Planes { width: w, height: h, vertical, y, cb, cr } + } + + /// An IFD2 image of `pw × ph` pixels at `scale` per pixel, built the way the files relate it to the planes. + fn preview_of(p: &Planes, pw: usize, ph: usize, scale: usize, a: f64, b: f64, pedestal: f64) -> Preview { + let mut rgb = Vec::new(); + for j in 0..ph { + for i in 0..pw { + let (y, cb, cr) = p.window_mean(i * scale, (i + 1) * scale, j * scale, (j + 1) * scale); + let (r, bl) = (y + a * cr, y + b * cb); + let g = (y - KR * r - KB * bl) / KG; + rgb.extend([r, g, bl].map(|v| (v + pedestal).round().clamp(0.0, 65535.0) as u16)); + } + } + Preview { width: pw, height: ph, rgb } + } + + #[test] + fn chroma_gains_are_measured_against_the_preview() { + for vertical in [1, 2] { + for (a, b) in [(1.0, 1.0), (5.7, 7.4), (1.13, 1.12)] { + let planes = test_planes(160, 96, vertical); + // IFD2 is a plain 1/4 reduction, optionally with a black pedestal (some bodies add one) + for pedestal in [0.0, 520.0] { + let preview = preview_of(&planes, 40, 24, 4, a, b, pedestal); + let (ga, gb) = + chroma_gains(&planes, &preview, Rect::new(0, 0, 160, 96)).unwrap_or_else(|| panic!("no fit for {a}, {b}, {vertical}")); + assert!((ga / a - 1.0).abs() < 0.02 && (gb / b - 1.0).abs() < 0.02, "vertical {vertical}: ({a}, {b}) measured as ({ga}, {gb})"); + } + } + } + // the preview overhangs the frame by a few pixels: the whole-frame scale (4.1) is wrong, 4 is right + let planes = test_planes(164, 96, 1); + let mut preview = preview_of(&planes, 40, 24, 4, 5.7, 7.4, 0.0); + let (ga, gb) = chroma_gains(&planes, &preview, Rect::new(0, 0, 164, 96)).unwrap(); + assert!((ga / 5.7 - 1.0).abs() < 0.02 && (gb / 7.4 - 1.0).abs() < 0.02, "({ga}, {gb})"); + // a preview of something else gives no usable fit rather than a wild gain + preview.rgb.iter_mut().enumerate().for_each(|(i, v)| *v = (1000 + (i * 7919) % 3000) as u16); + assert!( + chroma_gains(&planes, &preview, Rect::new(0, 0, 164, 96)).is_none_or(|(a, b)| (0.25..=16.0).contains(&a) && (0.25..=16.0).contains(&b)) + ); + } + + #[test] + fn colour_data_black_by_version() { + let mut w = vec![0u64; 400]; + w[0] = 7; + w[231..235].copy_from_slice(&[2048, 2047, 2048, 2048]); + assert_eq!(colour_data_black(&w), Some(2047.75)); + w[0] = 12; + assert_eq!(colour_data_black(&w), None); // the black level of another version lives elsewhere + w[326..330].copy_from_slice(&[512, 512, 511, 512]); + assert_eq!(colour_data_black(&w), Some(511.75)); + w[326..330].copy_from_slice(&[512, 512, 100, 512]); // implausible + assert_eq!(colour_data_black(&w), None); + w[0] = 3; + assert_eq!(colour_data_black(&w), None); // no position known: black 0 + w[0] = 65533; // -3 + assert_eq!(colour_data_black(&w), None); + assert_eq!(colour_data_black(&[]), None); + assert_eq!(colour_data_black(&[7, 1, 2]), None); + } +} diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index d65881b92..325a7e15c 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -125,6 +125,7 @@ pub(crate) fn color_data(ifd0: &Ifd, raw: &Ifd, order: ByteOrder) -> ColorData { // DNG 1.7 BaselineSharpness (IFD 0 or the enhanced IFD); implausible values are ignored baseline_sharpness: get(t::BASELINE_SHARPNESS).and_then(|v| v.first().copied()).filter(|v| v.is_finite() && *v > 0.0 && *v <= 16.0), profile: profile_look(ifd0, raw, order), + kelvin_presets: None, } } diff --git a/crates/raw/src/kelvin.rs b/crates/raw/src/kelvin.rs new file mode 100644 index 000000000..4f750b8b2 --- /dev/null +++ b/crates/raw/src/kelvin.rs @@ -0,0 +1,113 @@ +//! A camera's white-balance presets at stated colour temperatures, as data. +//! +//! Some cameras record the multipliers they would apply for each of their white-balance presets, with the +//! temperature the camera gives the preset (Canon CR2: the maker note's `ColorData`). For a raw without colour +//! matrices they are the camera's own Kelvin scale. This crate reads and orders them; placing a white on the curve +//! through them (temperature and tint) is the developer's arithmetic. + +use serde::{Deserialize, Serialize}; + +/// Most points a curve keeps (Canon writes about seven at a stated temperature). +const MAX_POINTS: usize = 32; + +/// A camera's multipliers at stated temperatures (at least three), ascending, green = 1. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct KelvinPresets { + points: Vec<(f32, [f32; 3])>, +} + +fn usable(m: [f32; 3]) -> bool { + m.iter().all(|v| *v > 0.0 && v.is_finite()) +} + +fn log_point(m: [f32; 3]) -> [f64; 2] { + [(m[0] as f64 / m[1] as f64).ln(), (m[2] as f64 / m[1] as f64).ln()] +} + +impl KelvinPresets { + /// `points` as (Kelvin, multipliers), ascending in Kelvin; `None` unless they make a curve: three to 32 of + /// them, positive multipliers, red rising and blue falling from one preset to the next. + pub fn new(points: impl IntoIterator) -> Option { + let points: Vec<_> = points.into_iter().take(MAX_POINTS + 1).collect(); + let ok = (3..=MAX_POINTS).contains(&points.len()) + && points.iter().all(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m)) + && points.windows(2).all(|w| { + let (a, b) = (log_point(w[0].1), log_point(w[1].1)); + w[0].0 < w[1].0 && b[0] > a[0] && b[1] < a[1] + }); + ok.then(|| KelvinPresets { points: points.into_iter().map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])).collect() }) + } + + /// A curve from presets in any order, as a camera lists them by name (Canon's Daylight, Shade, Cloudy, …): + /// sorted by Kelvin, a repeated temperature kept once, unusable points and any that would turn the curve back + /// (red not rising or blue not falling from the warmer neighbour kept) dropped; then [`Self::new`]. + pub fn from_unordered(points: impl IntoIterator) -> Option { + let mut pts: Vec<_> = points.into_iter().take(MAX_POINTS).filter(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m)).collect(); + pts.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut kept: Vec<(f32, [f32; 3])> = Vec::with_capacity(pts.len()); + for p in pts { + let fits = kept.last().is_none_or(|q| { + let (a, b) = (log_point(q.1), log_point(p.1)); + q.0 < p.0 && b[0] > a[0] && b[1] < a[1] + }); + if fits { + kept.push(p); + } + } + KelvinPresets::new(kept) + } + + /// The presets as (Kelvin, multipliers with green = 1), ascending in Kelvin. + pub fn points(&self) -> &[(f32, [f32; 3])] { + &self.points + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn five() -> Vec<(f32, [f32; 3])> { + [(2500.0, 1.0, 3.2), (3200.0, 1.3, 2.6), (4500.0, 1.8, 1.9), (6000.0, 2.3, 1.5), (8500.0, 2.8, 1.2)] + .map(|(k, r, b)| (k, [r, 1.0, b])) + .to_vec() + } + + #[test] + fn unusable_presets_give_nothing() { + assert!(KelvinPresets::new(five()).is_some()); + let mut pts = five(); + pts.swap(1, 2); + assert_eq!(KelvinPresets::new(pts), None, "out of order"); + let mut pts = five(); + pts[4].1 = pts[3].1; + assert_eq!(KelvinPresets::new(pts), None, "no step"); + let mut pts = five(); + pts[0].1[0] = 0.0; + assert_eq!(KelvinPresets::new(pts), None); + assert_eq!(KelvinPresets::new(five()[..2].to_vec()), None, "too few"); + assert_eq!(KelvinPresets::new((0..33).map(|i| (2000.0 + i as f32, [1.0 + i as f32, 1.0, 40.0 - i as f32]))), None, "too many"); + } + + /// Canon lists its presets by name, not by temperature, with repeats and an off-curve one among them: the + /// curve takes them in Kelvin order, each temperature once, and leaves out a point that would turn it back. + #[test] + fn a_curve_from_presets_in_any_order() { + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + let p = KelvinPresets::from_unordered([ + (5651.0, g(1418.0, 1357.0)), + (7505.0, g(1653.0, 1171.0)), + (6594.0, g(1533.0, 1259.0)), + (3174.0, g(963.0, 2016.0)), + (5651.0, g(1418.0, 1357.0)), + (8000.0, g(1400.0, 1400.0)), + (8529.0, g(1807.0, 1080.0)), + (f32::NAN, g(1000.0, 1000.0)), + ]) + .unwrap(); + assert_eq!(p.points().len(), 5); + assert!(p.points().windows(2).all(|w| w[0].0 < w[1].0)); + assert_eq!(KelvinPresets::from_unordered([(5000.0, g(1400.0, 1400.0)), (6000.0, g(1500.0, 1300.0))]), None); + assert_eq!(KelvinPresets::from_unordered(std::iter::empty()), None); + } +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index a3ac3b594..5509a2811 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -25,6 +25,7 @@ //! | `dng` | Adobe DNG: uncompressed (any depth, packed), lossless JPEG, lossy JPEG, Deflate (integer and floating point), strips and tiles, CFA and LinearRaw, DNG 1.7 interleaved storage; reading and writing | //! | `jxl` | JPEG XL compressed DNG (DNG 1.7, compression 52546) through `jxl-oxide`; implies `dng` | //! | `crw` | Canon CRW (CIFF), decoded by `craft-crw` and returned through this crate's types | +//! | `cr2` | Canon CR2: the sliced lossless-JPEG mosaic, and sRAW / mRAW (subsampled YCbCr) as linear RGB | //! | `mrw` | Minolta / Konica Minolta MRW: 12-bit packed and 16-bit word mosaics in the `\0MRM` block container | //! | `erf` | Epson ERF (R-D1 family): 12-bit samples in 16-byte blocks | //! | `mef` | Mamiya MEF (ZD): uncompressed 12-bit packed | @@ -49,6 +50,8 @@ //! and from observation of files. No other raw decoder's source was consulted; see `README.md`. #![forbid(unsafe_code)] +#[cfg(feature = "cr2")] +mod cr2; #[cfg(feature = "crw")] mod crw; #[cfg(feature = "dng")] @@ -65,6 +68,7 @@ mod hasselblad; mod huffman; #[cfg(feature = "jxl")] mod jxl; +pub mod kelvin; #[cfg(feature = "kodak")] mod kodak; pub mod ljpeg; @@ -323,6 +327,7 @@ impl RawFormat { || (self == RawFormat::Mrw && cfg!(feature = "mrw")) || (self == RawFormat::Erf && cfg!(feature = "erf")) || (self == RawFormat::Mef && cfg!(feature = "mef")) + || (self == RawFormat::Cr2 && cfg!(feature = "cr2")) || (self == RawFormat::Pef && cfg!(feature = "pef")) || (self == RawFormat::Srw && cfg!(feature = "srw")) } @@ -605,6 +610,8 @@ fn decode_in(bytes: &[u8], mode: Mode, limits: &Limits) -> Result { RawFormat::Erf => erf::decode(bytes, mode, limits), #[cfg(feature = "mef")] RawFormat::Mef => mef::decode(bytes, mode, limits), + #[cfg(feature = "cr2")] + RawFormat::Cr2 => cr2::decode(bytes, mode, limits), #[cfg(feature = "pef")] RawFormat::Pef => pef::decode(bytes, mode, limits), #[cfg(feature = "srw")] @@ -848,6 +855,10 @@ pub struct ColorData { /// `ProfileGainTableMap*`, as data: the develop pipeline applies them, and a DNG export writes them back. #[serde(default)] pub profile: profile::ProfileLook, + /// The camera's white-balance presets at stated temperatures (Canon CR2), which give a raw without colour + /// matrices a Kelvin scale ([`kelvin`]). Not a DNG tag: the DNG writer does not write it. + #[serde(default)] + pub kelvin_presets: Option, } /// A decoded raw image. diff --git a/crates/raw/tests/it/vendor_robust.rs b/crates/raw/tests/it/vendor_robust.rs index 7e348f07f..68eccf554 100644 --- a/crates/raw/tests/it/vendor_robust.rs +++ b/crates/raw/tests/it/vendor_robust.rs @@ -44,8 +44,23 @@ fn pef(compression: u16) -> Vec { TiffWriter::new(ByteOrder::Big, false).write(&[ifd]).unwrap() } +/// A Canon CR2: three placeholder IFDs and IFD3 with a 2-component lossless JPEG frame. +fn cr2() -> Vec { + let img: Vec = (0..(W * H) as usize).map(|i| 1024 + (i * 7919 % 12000) as u16).collect(); + let enc = craft_raw::ljpeg::encode(&img, W as usize / 2, H as usize, 2, 14, 1, 0); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Canon".into())); + let mut ifd3 = IfdBuilder::new(); + ifd3.set(t::COMPRESSION, Value::Short(vec![6])); + ifd3.set(0xc640, Value::Short(vec![1, 16, 16])); + ifd3.set_image(ImageData::Strips { rows_per_strip: H, strips: vec![enc] }); + let blank = || IfdBuilder::new().with(1, Value::Short(vec![0])); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0, blank(), blank(), ifd3]).unwrap() +} + fn samples() -> Vec> { vec![ + cr2(), pef(1), pef(65535), cfa_tiff("SAMSUNG", 1, 12, ByteOrder::Little),