Files
lightcraft/crates/raw/src/spectral.rs
T

272 lines
13 KiB
Rust

//! Camera colour matrices from measured spectral sensitivities, for raws that carry no colour matrix.
//!
//! [`find`] looks a camera up by its Exif make and model in a table generated by
//! `tools/spectral_camera_table.py` from rawtoaces-data (Academy Software Foundation, Apache-2.0): per model, DNG
//! `ColorMatrix1/2` and `ForwardMatrix1/2` for Standard Illuminant A and D65, fitted to the camera's measured spectral
//! sensitivities. [`Camera::fill`] writes them into a file's [`ColorData`], and [`crate::color`]'s DNG model does the
//! rest. Other names of a covered camera (Rebel T3i for EOS 600D) come from rawtoaces-data's alias list, and other
//! bodies built on a covered camera's sensor from [`SAME_SENSOR`]. No number
//! comes from Adobe or from a GPL raw decoder. Which colour a raw starts from is the engine's choice
//! (`docs/camera-preview-colour.md`).
use crate::ColorData;
use lightcraft_color::{Mat3, Xy};
#[path = "spectral_table.rs"]
mod table;
/// DNG `CalibrationIlluminant` codes of the two matrices: Standard Illuminant A, D65.
const ILLUMINANTS: [u16; 2] = [17, 21];
/// One camera's colour matrices.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Camera {
pub make: &'static str,
pub model: &'static str,
/// XYZ → camera under Standard Illuminant A and D65 (`ColorMatrix1/2`).
pub color_matrix: [Mat3; 2],
/// White-balanced camera → XYZ D50 (`ForwardMatrix1/2`).
pub forward_matrix: [Mat3; 2],
}
impl Camera {
/// Give `color` this camera's matrices, when it has no colour matrix of its own. Returns whether it did.
pub fn fill(&self, color: &mut ColorData) -> bool {
if crate::color::has_matrix(color) {
return false;
}
color.illuminant = ILLUMINANTS;
color.color_matrix = self.color_matrix.map(Some);
color.forward_matrix = self.forward_matrix.map(Some);
true
}
}
/// `(make, model as sold, table model)`: bodies sharing a table camera's sensor that rawtoaces-data's alias list
/// doesn't name, kept here because the table is generated. Fujifilm's 26.1 MP X-Trans CMOS 4 bodies use the X-T3's
/// sensor (X-T30 issue: a black-and-white Acros camera JPEG gives no colour to fit, and without matrices the photo
/// opened in the muted neutral fallback).
const SAME_SENSOR: &[(&str, &str, &str)] = &[
("Fujifilm", "X-T30", "X-T3"),
("Fujifilm", "X-T30 II", "X-T3"),
("Fujifilm", "X-Pro3", "X-T3"),
("Fujifilm", "X100V", "X-T3"),
("Fujifilm", "X-S10", "X-T3"),
("Fujifilm", "X-E4", "X-T3"),
];
/// Model names compared without case, the brand, a leading `EOS` or a trailing `DIGITAL`
/// (Exif "Canon EOS 400D DIGITAL" is the table's "EOS 400D").
fn key(brand: &str, model: &str) -> String {
let mut words: Vec<String> = model.split_whitespace().map(str::to_uppercase).collect();
if words.first().is_some_and(|w| *w == brand) {
words.remove(0);
}
if words.first().is_some_and(|w| w == "EOS") {
words.remove(0);
}
if words.len() > 1 && words.last().is_some_and(|w| w == "DIGITAL") {
words.pop();
}
words.join(" ")
}
/// First word of a make, upper case ("NIKON CORPORATION" → "NIKON").
fn brand(make: &str) -> String {
make.split_whitespace().next().unwrap_or_default().to_uppercase()
}
/// The table's camera for a file's Exif make and model, directly or through an alias. Without a make, the model
/// alone must match.
pub fn find(make: Option<&str>, model: &str) -> Option<&'static Camera> {
let make = make.map(brand).filter(|b| !b.is_empty());
let same = |row_make: &str, row_model: &str| {
let row_brand = brand(row_make);
let wanted = key(&row_brand, model);
make.as_ref().is_none_or(|m| *m == row_brand) && !wanted.is_empty() && key(&row_brand, row_model) == wanted
};
match table::ALIASES.iter().chain(SAME_SENSOR).find(|(m, alias, _)| same(m, alias)) {
Some((m, _, target)) => table::CAMERAS.iter().find(|c| brand(c.make) == brand(m) && c.model == *target),
None => table::CAMERAS.iter().find(|c| same(c.make, c.model)),
}
}
/// [`find`] for a camera written as one string, Exif make and model joined by a space (the catalog keeps
/// `"SONY ILCE-7M3"`, `"NIKON CORPORATION NIKON D850"`): every split into make and model is tried, then the
/// whole string as a model alone. Returns the same row as `find(Some(make), model)` for the parts it was
/// made of, so the catalog and the decoder agree on which cameras have matrices.
pub fn find_camera(camera: &str) -> Option<&'static Camera> {
let words: Vec<&str> = camera.split_whitespace().collect();
if words.is_empty() {
return None;
}
(1..words.len()).find_map(|i| find(Some(&words[..i].join(" ")), &words[i..].join(" "))).or_else(|| find(None, camera))
}
/// The as-shot white of a file through `camera`'s matrices instead of the file's own colour tags: the DNG
/// reading of its `AsShotNeutral` or vendor white-balance multipliers (`None` when it has neither, so the
/// white is unknown). What the catalog shows as the As Shot temperature of a raw whose colour model is a
/// JPEG fit: the matrices only convert between multipliers and Kelvin here.
pub fn as_shot_white_through(camera: &Camera, color: &ColorData, wb_multipliers: Option<[f32; 3]>) -> Option<Xy> {
if color.as_shot_white_xy.is_none()
&& color.as_shot_neutral.is_none()
&& wb_multipliers.is_none_or(|m| m.iter().any(|v| !(*v > 0.0 && v.is_finite())))
{
return None;
}
let mut c = ColorData { profile: Default::default(), ..color.clone() };
camera.fill(&mut c);
Some(crate::color::as_shot_white_of(&c, wb_multipliers))
}
/// Every camera in the table.
pub fn cameras() -> &'static [Camera] {
table::CAMERAS
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exif_names_find_their_rows() {
for (make, model, row) in [
("Canon", "Canon EOS 5D Mark III", "EOS 5D Mark III"),
("Canon", "Canon EOS 600D", "EOS 600D"),
("Canon", "Canon EOS-1Ds Mark III", "EOS-1Ds Mark III"),
("Canon", "Canon EOS DIGITAL REBEL XTi", "Digital Rebel XTi"),
("Canon", "Canon EOS R5m2", "EOS R5m2"),
("Canon", "Canon PowerShot S90", "PowerShot S90"),
("NIKON CORPORATION", "NIKON D850", "D850"),
("NIKON CORPORATION", "NIKON Z f", "Z f"),
("SONY", "ILCE-7M3", "ILCE-7M3"),
("FUJIFILM", "X-T4", "X-T4"),
("Panasonic", "DC-GX9", "DC-GX9"),
] {
assert_eq!(find(Some(make), model).map(|c| c.model), Some(row), "{make} {model}");
}
assert_eq!(find(None, "NIKON D850").map(|c| c.model), Some("D850"));
assert_eq!(find(None, "ILCE-7M3").map(|c| c.model), Some("ILCE-7M3"));
}
#[test]
fn aliases_reach_the_camera_they_name() {
for (make, model, row) in [
("Canon", "Canon EOS 400D DIGITAL", "Digital Rebel XTi"),
("Canon", "Canon EOS Kiss Digital X", "Digital Rebel XTi"),
("Canon", "Canon EOS REBEL T3i", "EOS 600D"),
("Canon", "Canon EOS Kiss X5", "EOS 600D"),
("Canon", "Canon EOS R5 Mark II", "EOS R5m2"),
("Canon", "Canon EOS R6 Mark II", "EOS R6m2"),
("NIKON CORPORATION", "NIKON D800", "D800E"),
("NIKON CORPORATION", "NIKON Z 6_2", "Z f"),
("SONY", "ILCE-7RM4A", "ILCE-7RM4"),
("FUJIFILM", "GFX100S", "GFX 100"),
("FUJIFILM", "X-T30", "X-T3"),
("FUJIFILM", "X-T30 II", "X-T3"),
("FUJIFILM", "X100V", "X-T3"),
] {
assert_eq!(find(Some(make), model).map(|c| c.model), Some(row), "{make} {model}");
}
}
#[test]
fn other_cameras_and_other_makes_find_nothing() {
assert_eq!(find(Some("Canon"), "Canon EOS 7D"), None);
assert_eq!(find(Some("SONY"), "ILCE-7M2"), None);
assert_eq!(find(Some("OLYMPUS IMAGING CORP."), "E-M5"), None);
// the model of another make
assert_eq!(find(Some("Canon"), "D850"), None);
assert_eq!(find(Some("SONY"), "X-T4"), None);
assert_eq!(find(None, ""), None);
assert_eq!(find(Some(""), ""), None);
}
#[test]
fn a_make_model_string_finds_what_its_parts_find() {
// every row and alias, joined the way the catalog keeps a camera ("Make Model")
for c in cameras() {
let joined = format!("{} {}", c.make, c.model);
assert_eq!(find_camera(&joined), Some(c), "{joined}");
assert_eq!(find_camera(&joined), find(Some(c.make), c.model), "{joined}");
}
for (make, alias, _) in table::ALIASES.iter().chain(SAME_SENSOR) {
let joined = format!("{make} {alias}");
assert_eq!(find_camera(&joined), find(Some(make), alias), "{joined}");
assert!(find_camera(&joined).is_some(), "{joined}");
}
// Exif makes with more words than the brand, and a model that repeats the brand
for (make, model, row) in [
("NIKON CORPORATION", "NIKON D850", "D850"),
("NIKON CORPORATION", "NIKON Z 6_2", "Z f"),
("SONY", "ILCE-7M3", "ILCE-7M3"),
("Canon", "Canon EOS 5D Mark III", "EOS 5D Mark III"),
("Canon", "Canon EOS 400D DIGITAL", "Digital Rebel XTi"),
("FUJIFILM", "GFX100S", "GFX 100"),
("Panasonic", "DC-GX9", "DC-GX9"),
] {
let joined = format!("{make} {model}");
assert_eq!(find_camera(&joined).map(|c| c.model), Some(row), "{joined}");
assert_eq!(find_camera(&joined), find(Some(make), model), "{joined}");
}
// a model alone, as a Lightroom catalog names it
assert_eq!(find_camera("ILCE-7M3").map(|c| c.model), Some("ILCE-7M3"));
assert_eq!(find_camera(" ILCE-7M3 ").map(|c| c.model), Some("ILCE-7M3"));
for none in ["", " ", "SONY", "SONY ILCE-7M2", "Canon EOS 7D", "D850 SONY", "OLYMPUS IMAGING CORP. E-M5"] {
assert_eq!(find_camera(none), None, "{none:?}");
}
}
#[test]
fn as_shot_white_through_the_matrices_reads_the_multipliers() {
let c = find(Some("SONY"), "ILCE-7M3").unwrap();
let color = ColorData::default();
// daylight-ish Sony multipliers: a white near the locus around 5000 K, not the generic model's wild tint
let xy = as_shot_white_through(c, &color, Some([2.2, 1.0, 1.6])).unwrap();
let (t, tint) = lightcraft_color::cct::xy_to_temp_tint(xy);
assert!((3500.0..7000.0).contains(&t) && tint.abs() < 30.0, "{t} {tint}");
// the file's own tags are left alone
assert_eq!(color, ColorData::default());
// unknown white: no multipliers, or unusable ones
assert_eq!(as_shot_white_through(c, &color, None), None);
assert_eq!(as_shot_white_through(c, &color, Some([0.0, 1.0, 1.0])), None);
assert_eq!(as_shot_white_through(c, &color, Some([f32::NAN, 1.0, 1.0])), None);
// a neutral in the tags counts as known
let tagged = ColorData { as_shot_neutral: Some([0.5, 1.0, 0.6]), ..Default::default() };
assert!(as_shot_white_through(c, &tagged, None).is_some());
}
#[test]
fn every_row_is_a_usable_dng_profile() {
assert_eq!(cameras().len(), 52);
for c in cameras() {
assert_eq!(find(Some(c.make), c.model), Some(c), "{} {}", c.make, c.model);
for m in c.color_matrix.iter().chain(&c.forward_matrix) {
assert!(m.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 4.0), "{}", c.model);
assert!(m.inverse().is_some(), "{}", c.model);
}
// a forward matrix maps white-balanced white to D50 (DNG spec, chapter 6)
for fm in &c.forward_matrix {
let w = fm.apply([1.0; 3]);
let d50 = lightcraft_color::D50.to_xyz();
assert!((0..3).all(|i| (w[i] - d50[i]).abs() < 1e-4), "{} {w:?}", c.model);
}
}
}
#[test]
fn fill_keeps_a_files_own_matrices() {
let c = find(Some("Canon"), "Canon EOS 600D").unwrap();
let mut color = ColorData::default();
assert!(c.fill(&mut color));
assert_eq!(color.illuminant, [17, 21]);
assert_eq!(color.color_matrix, [Some(c.color_matrix[0]), Some(c.color_matrix[1])]);
assert_eq!(color.forward_matrix, [Some(c.forward_matrix[0]), Some(c.forward_matrix[1])]);
let own = Mat3([[0.5, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.7]]);
let mut color = ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() };
let before = color.clone();
assert!(!c.fill(&mut color));
assert_eq!(color, before);
}
}