CR2: measure the colour filter row phase instead of assuming RGGB (#193) (#244) (#244)

This commit is contained in:
Brandon Thomas 2026-10-05 11:15:58 -04:00 committed by GitHub
parent 15eb3a8ba5
commit eb11e1af46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 256 additions and 8 deletions

View File

@ -26,7 +26,8 @@ fn dng_opens_as_16_bit_prophoto() {
fn cr2_opens() {
let (w, h) = (48, 16);
let data = mosaic(&scene(w, h), w, [0, 1, 1, 2], 256, 12000);
let spec = Cr2Spec { width: w, height: h, data, precision: 14, components: 2, slices: vec![24, 24], borders: None, wb_rggb: None, orientation: 6 };
let spec =
Cr2Spec { width: w, height: h, data, precision: 14, components: 2, slices: vec![24, 24], borders: None, wb_rggb: None, orientation: 6, model_id: None };
let r = import("IMG_0001.CR2", &spec.build()).unwrap();
// Orientation 6 rotates the 48×16 sensor image to 16×48.
assert_eq!((r.document.size.width, r.document.size.height), (16, 48));

View File

@ -23,8 +23,8 @@ Implemented only from public specifications, papers and observation of files:
* TIFF 6.0, TIFF/EP (ISO 12234-2) and the Adobe DNG Specification 1.7.
* ITU-T T.81 (ISO 10918-1) Annex H: lossless JPEG, process 14 ("LJ92").
* The published description of Canon's CR2 container (header, raw IFD, slice tag 0xC640).
* Publicly documented maker-note / private tags (ExifTool's tag tables): Canon SensorInfo
(0x00E0) and ColorData (0x4001), Nikon WB_RBLevels (0x000C) and BlackLevel (0x003D), Sony
* Publicly documented maker-note / private tags (ExifTool's tag tables): Canon ModelID (0x0010),
SensorInfo (0x00E0) and ColorData (0x4001), Nikon WB_RBLevels (0x000C) and BlackLevel (0x003D), Sony
BlackLevel (0x7310), WB_RGGBLevels (0x7313), SonyRawFileType (0x7000) and SonyToneCurve
(0x7010), the PanasonicRaw IFD0 tags, Olympus ImageProcessing (0x2040) and CameraSettings
(0x2020) preview tags.
@ -46,7 +46,7 @@ camera colour tables were copied.
|---|---|
| DNG | Uncompressed (8–16 bit, packed or not) and lossless JPEG; strips and tiles; CFA (Bayer) and LinearRaw; LinearizationTable, BlackLevel (+ repeat, DeltaH/V), WhiteLevel, ActiveArea, DefaultCrop, ColorMatrix1/2, CameraCalibration, ForwardMatrix, AnalogBalance, AsShotNeutral / AsShotWhiteXY, BaselineExposure, Orientation, OpcodeList2 GainMap (lens shading) |
| DNG (lossy JPEG, JPEG XL, floating point; opcodes other than GainMap) | Unsupported / not applied (reported) |
| CR2 | Lossless JPEG with slices, borders and as-shot white balance from the maker note, black measured on the masked border |
| CR2 | Lossless JPEG with slices, borders and as-shot white balance from the maker note, black measured on the masked border. CR2 has no CFA tag and the row phase varies by model, so it is measured from the data (the green diagonal), with a Canon model-ID table as the fallback (see `src/cr2.rs`) |
| CR2 sRAW / mRAW | Unsupported |
| NEF / NRW, ARW, PEF and other TIFF/EP raws | Uncompressed and lossless-JPEG (incl. Sony lossless ARW) CFA data |
| Sony compressed ARW ("cRAW", SonyRawFileType 2) | Decoded: 11-bit min/max + 7-bit delta blocks, SonyToneCurve to 14 bits |

View File

@ -49,6 +49,7 @@ fn main() {
borders: None,
wb_rggb: None,
orientation: 1,
model_id: None,
};
let _ = std::fs::write(format!("{dir}/synthetic.cr2"), cr2.build());
}
@ -167,7 +168,18 @@ fn synthetic(w: usize, h: usize, opts: &DevelopOptions) {
spec.white = 15000;
spec.as_shot_neutral = Some([0.5, 1.0, 0.7]);
spec.color_matrix1 = Some((21, [0.9, -0.3, -0.1, -0.4, 1.3, 0.1, -0.1, 0.2, 0.6]));
let cr2 = Cr2Spec { width: w, height: h, data, precision: 14, components: 4, slices: vec![w / 2, w - w / 2], borders: None, wb_rggb: None, orientation: 1 };
let cr2 = Cr2Spec {
width: w,
height: h,
data,
precision: 14,
components: 4,
slices: vec![w / 2, w - w / 2],
borders: None,
wb_rggb: None,
orientation: 1,
model_id: None,
};
let files = [
("DNG uncompressed", spec.build()),
("DNG LJ92 256x256 tiles", {

View File

@ -14,6 +14,33 @@
//! position; candidates are validated before use). The black level is
//! measured on the masked border; the white level is the clipping point
//! found in the data.
//!
//! ## Colour filter phase
//!
//! CR2 has no `CFAPattern` tag, and the phase is not a function of the
//! SensorInfo borders: on every model measured, red sits in the even data
//! columns, but whether the even or the odd data rows hold red varies by model
//! and has no relation to the border offsets (EOS 7D, 550D, 600D, 1200D and
//! 5D Mark II start with a green/blue row with their top border at 56; the 40D,
//! 400D, 450D, 500D, 1000D, 1100D, 5D, 1D Mark III and 1Ds Mark III have an odd
//! top border and the 5D Mark III, 6D, 5DS R, 100D, 650D, 700D, 760D and 1D X an
//! even one, and all of them start with a red/green row). Anchoring a fixed
//! pattern at the image-area origin (or at the sensor origin) is therefore
//! wrong for some models whichever anchor is chosen.
//!
//! So the row phase is measured from the data ([`measured_red_row`]): the two
//! green sites of a 2×2 cell sit on one diagonal, carry the same filter, and
//! so have (a) the smaller difference between diagonal neighbours and (b)
//! nearly equal means, while the red/blue diagonal differs by the scene colour
//! and the white-balance gains. Both cues must agree. When they don't (a flat,
//! black or clipped frame) a small table of Canon model IDs whose data starts
//! with a green/blue row decides, then red/green first (the common case).
//!
//! Sources: Phil Harvey's ExifTool Canon tag documentation (MakerNote tag
//! 0x0010 CanonModelID; 0x00E0 SensorInfo: SensorWidth, SensorHeight,
//! SensorLeft/Top/Right/BottomBorder at indices 1, 2, 5–8), and observation of
//! CC0 samples from raw.pixls.us for the 26 models named above (developed with
//! each candidate phase and compared by eye on test charts and natural scenes).
use crate::error::{RawError, Result};
use crate::sensor::{BlackLevels, Cfa, Rect, Sensor};
@ -22,6 +49,63 @@ use crate::{Limits, RawFormat, ljpeg};
const CANON_SENSOR_INFO: u16 = 0x00E0;
const CANON_COLOR_DATA: u16 = 0x4001;
const CANON_MODEL_ID: u16 = 0x0010;
/// Canon model IDs (MakerNote 0x0010) whose raw data starts with a green/blue
/// row (red in the odd data rows), from observed samples. Only consulted when
/// the measurement is inconclusive.
const GREEN_FIRST_MODELS: [(u32, &str); 5] = [
(0x8000_0218, "EOS 5D Mark II"),
(0x8000_0250, "EOS 7D"),
(0x8000_0270, "EOS 550D / Rebel T2i / Kiss X4"),
(0x8000_0286, "EOS 600D / Rebel T3i / Kiss X5"),
(0x8000_0327, "EOS 1200D / Rebel T5 / Kiss X70"),
];
/// Which data rows hold red (0 = even, 1 = odd), measured over `area`; `None`
/// when the two cues (see the module docs) disagree or there is too little
/// unclipped signal. Samples every other row pair; red is in the even columns.
pub(crate) fn measured_red_row(data: &[u16], width: usize, area: Rect, black: f32, white: f32) -> Option<usize> {
// Per column parity p of the upper sample: p = 0 is the diagonal (even row, even col) /
// (odd row, odd col), green when red rows are odd; p = 1 is the other one.
let mut diff = [0f64; 2];
let mut upper = [0f64; 2];
let mut lower = [0f64; 2];
let mut n = [0u64; 2];
let (lo, hi) = (black + 1.0, black + (white - black) * 0.98);
let x_end = area.x.saturating_add(area.width).min(width).saturating_sub(1);
let y_end = area.y.saturating_add(area.height).saturating_sub(1);
let row = |y: usize| data.get(y.checked_mul(width)?..y.checked_add(1)?.checked_mul(width)?);
let mut y = area.y.saturating_add(area.y & 1);
while y < y_end {
let (Some(r0), Some(r1)) = (row(y), y.checked_add(1).and_then(row)) else { break };
for x in area.x..x_end {
let (Some(&a), Some(&b)) = (r0.get(x), r1.get(x + 1)) else { break };
let (a, b) = (f32::from(a), f32::from(b));
if a < lo || b < lo || a > hi || b > hi {
continue;
}
let p = x & 1;
diff[p] += f64::from((a - b).abs());
upper[p] += f64::from(a - black);
lower[p] += f64::from(b - black);
n[p] += 1;
}
y = y.saturating_add(4);
}
if n.iter().any(|&k| k < 256) {
return None;
}
let d = [diff[0] / n[0] as f64, diff[1] / n[1] as f64];
let rel = [0, 1].map(|p| (upper[p] - lower[p]).abs() / (upper[p] + lower[p]).max(1.0));
if d[0] < 0.8 * d[1] && rel[0] < rel[1] {
Some(1)
} else if d[1] < 0.8 * d[0] && rel[1] < rel[0] {
Some(0)
} else {
None
}
}
/// The Canon maker note IFD, if present.
pub(crate) fn maker_note(t: &Tiff, ifds: &[Ifd]) -> Option<Ifd> {
@ -163,8 +247,21 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result<Sensor> {
}
};
let white = clip_level(&data, u32::from(frame.precision));
// Canon sensors are RGGB from the top-left of the image area.
let cfa = Cfa { width: 2, height: 2, colors: vec![0, 1, 1, 2], origin_x: active.x, origin_y: active.y };
// Red is in the even data columns; the red rows are measured (see the module docs).
let mean_black = black.values.iter().sum::<f32>() / black.values.len().max(1) as f32;
let red_row = match measured_red_row(&data, width, active, mean_black, white) {
Some(r) => r,
None => {
let id = mn.as_ref().and_then(|m| t.tag_uint(m, CANON_MODEL_ID));
let known = GREEN_FIRST_MODELS.iter().any(|(m, _)| Some(*m) == id);
if !known {
warnings.push("CR2 colour filter phase could not be measured; assumed red/green in the first row".to_string());
}
usize::from(known)
}
};
let colors = if red_row == 0 { vec![0, 1, 1, 2] } else { vec![1, 2, 0, 1] };
let cfa = Cfa { width: 2, height: 2, colors, origin_x: 0, origin_y: 0 };
let camera_wb = mn.as_ref().and_then(|m| as_shot_wb(t, m));
// The black-level values index from the active area origin, but the masked
// measurement used data parity: realign them to active-area parity.
@ -207,3 +304,43 @@ pub(crate) fn realign_black(b: BlackLevels, active: Rect) -> BlackLevels {
.collect();
BlackLevels { values, ..b }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn red_row_measurement_never_panics_on_odd_geometry() {
let data = vec![1000u16; 64 * 32];
for area in [
Rect::new(0, 0, 0, 0),
Rect::new(0, 0, 64, 32),
Rect::new(60, 30, 100, 100),
Rect::new(usize::MAX - 1, 0, 4, 4),
Rect::new(0, usize::MAX - 1, 4, 4),
] {
// Flat data: inconclusive, never a guess.
assert_eq!(measured_red_row(&data, 64, area, 0.0, 4000.0), None, "{area:?}");
}
assert_eq!(measured_red_row(&[], 64, Rect::new(0, 0, 64, 32), 0.0, 4000.0), None);
assert_eq!(measured_red_row(&data, 0, Rect::new(0, 0, 64, 32), 0.0, 4000.0), None);
assert_eq!(measured_red_row(&data, 64, Rect::new(0, 0, 64, 32), f32::NAN, f32::NAN), None);
}
#[test]
fn red_row_measurement_finds_both_phases() {
// Grey scene under white-balance gains R 2, B 1.5: green 1000, red 500, blue 667, plus texture.
for red_row in [0usize, 1] {
let (w, h) = (64usize, 64usize);
let data: Vec<u16> = (0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
let tex = 1.0 + 0.2 * (((x / 3) ^ (y / 5)) & 1) as f32;
let c = if (y & 1) == red_row { [0, 1][x & 1] } else { [1, 2][x & 1] };
(100.0 + [500.0, 1000.0, 667.0][c] * tex) as u16
})
.collect();
assert_eq!(measured_red_row(&data, w, Rect::new(0, 0, w, h), 100.0, 4000.0), Some(red_row));
}
}
}

View File

@ -640,7 +640,7 @@ pub fn gain_map_opcode_list(area: [u32; 4], pitch: u32, gains: [[f32; 2]; 2]) ->
pub struct Cr2Spec {
pub width: usize,
pub height: usize,
/// Sensor samples, row-major, RGGB from (`left`, `top`).
/// Sensor samples, row-major.
pub data: Vec<u16>,
pub precision: u8,
pub components: usize,
@ -651,6 +651,8 @@ pub struct Cr2Spec {
/// As-shot RGGB levels written to ColorData at word offset 0x3F.
pub wb_rggb: Option<[u16; 4]>,
pub orientation: u16,
/// Canon model ID written to MakerNote tag 0x0010.
pub model_id: Option<u32>,
}
impl Cr2Spec {
@ -679,6 +681,9 @@ impl Cr2Spec {
}
let raw_ifd = t.ifd(raw);
let mut mn: Vec<(u16, Val)> = Vec::new();
if let Some(id) = self.model_id {
mn.push((0x0010, Val::Long(vec![id])));
}
if let Some([l, tp, r, b]) = self.borders {
mn.push((0x00E0, Val::Short(vec![34, w as u16, h as u16, 0, 0, l, tp, r, b, 0, 0, 0, 0, 0, 0, 0, 0])));
}

View File

@ -37,6 +37,7 @@ fn samples() -> Vec<Vec<u8>> {
borders: Some([8, 2, 39, 9]),
wb_rggb: Some([2000, 1024, 1024, 1500]),
orientation: 8,
model_id: None,
}
.build(),
);

View File

@ -24,6 +24,7 @@ fn cr2_spec(w: usize, h: usize, comps: usize, slices: Vec<usize>) -> Cr2Spec {
borders: Some([l as u16, t as u16, w as u16 - 1, h as u16 - 1]),
wb_rggb: Some([2048, 1024, 1024, 1536]),
orientation: 1,
model_id: None,
}
}
@ -130,3 +131,94 @@ fn embedded_preview_is_found() {
// The lossless-JPEG raw data of a CR2 is not mistaken for a preview.
assert!(embedded_preview(&cr2_spec(32, 8, 2, vec![]).build()).is_none());
}
/// #193: a CR2 whose sensor data is mosaicked with `cfa` (row-major 2×2 colours) anchored at
/// data (0, 0), image area from (`l`, `t`). Left half: a colourful scene; right half: a neutral
/// grey patch. Raw values are scene / white-balance gain, as a camera records them.
fn cr2_phase_spec(cfa: [u8; 4], l: usize, t: usize, model_id: Option<u32>) -> Cr2Spec {
let (w, h) = (192usize, 96usize);
let wb = [2.0f32, 1.0, 1.5];
let colourful = scene(w, h);
let rgb: Vec<[f32; 3]> = (0..w * h)
.map(|i| {
let p = if i % w >= w / 2 { [0.4; 3] } else { colourful[i] };
[p[0] / wb[0], p[1] / wb[1], p[2] / wb[2]]
})
.collect();
let mut data = mosaic(&rgb, w, cfa, 512, 13000);
// Masked border: black.
for y in 0..h {
for x in 0..w {
if x < l || y < t {
data[y * w + x] = 512;
}
}
}
Cr2Spec {
width: w,
height: h,
data,
precision: 14,
components: 2,
slices: vec![96, 96],
borders: Some([l as u16, t as u16, w as u16 - 1, h as u16 - 1]),
wb_rggb: Some([2048, 1024, 1024, 1536]),
orientation: 1,
model_id,
}
}
/// Mean developed RGB over the interior of the grey patch (from column `x0` of the image area).
fn grey_patch(d: &Developed, x0: usize) -> [f64; 3] {
let (w, h) = (d.width as usize, d.height as usize);
let mut sum = [0f64; 3];
let mut n = 0.0;
for y in 8..h - 8 {
for x in x0 + 8..w - 8 {
for (c, s) in sum.iter_mut().enumerate() {
*s += f64::from(d.rgb[(y * w + x) * 3 + c]);
}
n += 1.0;
}
}
sum.map(|s| s / n)
}
#[test]
fn cr2_cfa_phase_is_measured_for_every_row_phase_and_border_parity() {
// Canon data has red in the even columns; the red rows are even (RGGB at the data
// origin: 40D, 5D Mark III, …) or odd (GBRG: 7D, 550D, 5D Mark II, …). Odd image-area
// borders used to shift the assumed pattern and swap green with red/blue.
for cfa in [[0, 1, 1, 2], [1, 2, 0, 1]] {
for (l, t) in [(16, 4), (17, 4), (16, 5), (17, 5)] {
let b = cr2_phase_spec(cfa, l, t, None).build();
let s = decode(&b, &Limits::default()).unwrap();
assert_eq!(s.cfa.as_ref().unwrap().phase(0, 0), cfa, "cfa {cfa:?} borders ({l}, {t})");
assert!(s.warnings.is_empty(), "{:?}", s.warnings);
let d = develop_sensor(&s, &DevelopOptions { orient: false, ..Default::default() }).unwrap();
let [r, g, bl] = grey_patch(&d, 192 / 2 - l);
// Neutral grey decodes neutral (a swapped phase gives a strong magenta or green cast).
assert!((r / g - 1.0).abs() < 0.02 && (bl / g - 1.0).abs() < 0.02, "cfa {cfa:?} borders ({l}, {t}): grey patch {r:.0} {g:.0} {bl:.0}");
}
}
}
#[test]
fn cr2_cfa_phase_falls_back_to_the_model_table() {
// A flat black frame gives the measurement nothing to go on.
let flat = |model_id| {
let mut spec = cr2_phase_spec([0, 1, 1, 2], 16, 4, model_id);
spec.data.iter_mut().for_each(|v| *v = 512);
decode(&spec.build(), &Limits::default()).unwrap()
};
// EOS 7D (Canon model ID 0x80000250): green/blue first.
let s = flat(Some(0x8000_0250));
assert_eq!(s.cfa.as_ref().unwrap().phase(0, 0), [1, 2, 0, 1]);
assert!(!s.warnings.iter().any(|w| w.contains("colour filter phase")), "{:?}", s.warnings);
// Unknown model: red/green first, with a warning.
let s = flat(Some(0x8000_0001));
assert_eq!(s.cfa.as_ref().unwrap().phase(0, 0), [0, 1, 1, 2]);
assert!(s.warnings.iter().any(|w| w.contains("colour filter phase")), "{:?}", s.warnings);
let s = flat(None);
assert_eq!(s.cfa.as_ref().unwrap().phase(0, 0), [0, 1, 1, 2]);
}