//! Camera colour profiles of our own, pooled from many photos of one camera model. //! //! A raw file without colour matrices (Sony ARW, Nikon NEF, Fujifilm RAF) gets its look fitted to its own embedded camera //! JPEG (`camera_preview`), but one photo shows too little of some colours: a lime shirt covering a //! few dozen proxy pixels next to a hillside of foliage at the same hue. `lightcraft-cli calibrate` //! pools the colour pairs of many photos per model and fits one matrix and hue/saturation/value //! table; photos of that model then only fit their tone and chroma curves. //! //! Profiles are JSON files (`.json`) in [`dir`]: `LIGHTCRAFT_CAMERA_PROFILES`, else //! `/camera-profiles`; a local profile replaces the one built in ([`BUNDLED`], from //! `assets/camera-profiles/`). They are read once per process; a damaged or hostile file is //! ignored with a warning. They hold aggregate colour statistics only, never image content. use lightcraft_color::Mat3; use lightcraft_raw::profile::HsvTable; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock, PoisonError}; /// Format version of the profile files. const VERSION: u32 = 1; /// Largest profile file read (a 5 × 72 × 5 table is ~60 KB of JSON). const MAX_FILE: u64 = 4 << 20; /// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`): /// `(model, JSON)`. pub const BUNDLED: &[(&str, &str)] = &[ ("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")), ("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")), ("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")), ]; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct CameraProfile { pub version: u32, /// The camera model as the files report it (Exif `Model`), e.g. `ILCE-7M4`. pub model: String, /// Photos and colour pairs the profile was fitted on. pub files: usize, pub samples: usize, /// White-balanced camera RGB (with the baseline exposure) → linear Rec.2020, rows. matrix: [[f64; 3]; 3], /// Hue/saturation/value correction after `matrix` (linear ProPhoto RGB). pub hue_sat: Option, } impl CameraProfile { pub fn new(model: &str, files: usize, samples: usize, matrix: Mat3, hue_sat: Option) -> CameraProfile { CameraProfile { version: VERSION, model: model.to_owned(), files, samples, matrix: matrix.0, hue_sat } } pub fn matrix(&self) -> Mat3 { Mat3(self.matrix) } /// Whether the data is usable (bounded matrix, table shape matching its data). fn valid(&self) -> bool { let matrix = self.matrix.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0) && Mat3(self.matrix).inverse().is_some(); let table = self.hue_sat.as_ref().is_none_or(|t| { let dims = (1..=4096).contains(&t.hue_divisions) && (2..=4096).contains(&t.sat_divisions) && (1..=4096).contains(&t.val_divisions); let len = t.hue_divisions.checked_mul(t.sat_divisions).and_then(|n| n.checked_mul(t.val_divisions)); dims && len == Some(t.data.len()) && t.data.iter().flatten().all(|v| v.is_finite()) && t.data.iter().all(|e| e[1] >= 0.0 && e[2] >= 0.0) }); self.version == VERSION && !self.model.is_empty() && matrix && table } } /// LightCraft's configuration folder (settings, GPU marker, camera profiles). pub fn config_dir() -> Option { if cfg!(target_os = "macos") { std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft")) } else if cfg!(windows) { std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("LightCraft")) } else { std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) .map(|c| c.join("lightcraft")) } } /// Where camera profiles are read from and written to. pub fn dir() -> Option { std::env::var_os("LIGHTCRAFT_CAMERA_PROFILES").map(PathBuf::from).or_else(|| config_dir().map(|d| d.join("camera-profiles"))) } /// File name of `model`'s profile: letters, digits, `-` and `_` kept, anything else `_`. pub fn file_name(model: &str) -> String { let name: String = model.trim().chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect(); format!("{name}.json") } /// Read and validate a profile file. pub fn load(path: &Path) -> Result { let size = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?.len(); if size > MAX_FILE { return Err(format!("{}: {size} bytes is too large for a camera profile", path.display())); } let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; parse(&bytes, &path.display().to_string()) } /// Parse and validate a profile's JSON (`origin` names it in errors). fn parse(json: &[u8], origin: &str) -> Result { let profile: CameraProfile = serde_json::from_slice(json).map_err(|e| format!("{origin}: {e}"))?; if !profile.valid() { return Err(format!("{origin}: not a usable camera profile (version {}, model {:?})", profile.version, profile.model)); } Ok(profile) } /// The built-in profile for `model`, if any. fn bundled(model: &str) -> Option { let (_, json) = BUNDLED.iter().find(|(m, _)| *m == model)?; parse(json.as_bytes(), &format!("built-in profile {model}")).inspect_err(|e| eprintln!("lightcraft: ignoring camera profile {e}")).ok() } /// Write `profile` to `dir` (created if needed) as `.json`; returns the path. pub fn save(profile: &CameraProfile, dir: &Path) -> Result { std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; let path = dir.join(file_name(&profile.model)); let json = serde_json::to_vec_pretty(profile).map_err(|e| e.to_string())?; let tmp = path.with_extension("json.tmp"); std::fs::write(&tmp, json).map_err(|e| format!("{}: {e}", tmp.display()))?; std::fs::rename(&tmp, &path).map_err(|e| format!("{}: {e}", path.display()))?; Ok(path) } fn cache() -> &'static Mutex>>> { static CACHE: OnceLock>>>> = OnceLock::new(); CACHE.get_or_init(Default::default) } /// The profile for camera `model`: from [`dir`], else built in; read once per process. pub fn get(model: &str) -> Option> { let model = model.trim(); if model.is_empty() { return None; } let mut cache = cache().lock().unwrap_or_else(PoisonError::into_inner); if let Some(hit) = cache.get(model) { return hit.clone(); } let path = dir().map(|d| d.join(file_name(model))); let profile = match path { Some(path) if path.is_file() => match load(&path) { Ok(p) if p.model.trim() == model => Some(Arc::new(p)), Ok(p) => { eprintln!("lightcraft: ignoring camera profile {}: it is for {:?}, not {model:?}", path.display(), p.model); None } Err(e) => { eprintln!("lightcraft: ignoring camera profile {e}"); None } }, _ => None, }; let profile = profile.or_else(|| bundled(model).map(Arc::new)); cache.insert(model.to_owned(), profile.clone()); profile } /// Changes when the profiles folder's contents change (per process): part of the render cache /// keys, so thumbnails rendered before a profile existed are not reused after. pub fn cache_key() -> u64 { static KEY: OnceLock = OnceLock::new(); *KEY.get_or_init(|| { let entries = dir().and_then(|d| std::fs::read_dir(d).ok()); let mut files: Vec<(String, u64, u64)> = entries .into_iter() .flatten() .flatten() .filter(|e| e.path().extension().is_some_and(|x| x == "json")) .filter_map(|e| { let meta = e.metadata().ok()?; let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); Some((e.file_name().to_string_lossy().into_owned(), meta.len(), modified)) }) .collect(); files.sort(); let mut h = lightcraft_preview::Hasher128::new(); for (name, len, modified) in &files { h.str(name).u64(*len).u64(*modified); } for (model, json) in BUNDLED { h.str(model).str(json); } h.finish().0 as u64 }) } /// Per camera model: the photos read and their pooled colour pairs. #[derive(Default)] pub struct Pool { models: HashMap)>, } /// Most colour pairs kept per photo, so a few busy photos can't dominate a profile. const PAIRS_PER_FILE: usize = 4000; impl Pool { /// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW, /// NEF or RAF without colour matrices, no usable camera JPEG, too little colour). pub fn add(&mut self, bytes: &[u8]) -> Result, String> { let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?; // colour only: geometric lens corrections would stop the sensor proxy from binning raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let Some(model) = raw.metadata.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) else { return Ok(None) }; let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes) else { return Ok(None) }; let step = pairs.len().div_ceil(PAIRS_PER_FILE).max(1); let entry = self.models.entry(model.to_owned()).or_default(); entry.0 += 1; entry.1.extend(pairs.into_iter().step_by(step)); Ok(Some(model.to_owned())) } /// Fit a profile per model with at least `min_files` photos. pub fn fit(&self, min_files: usize) -> Vec> { let mut models: Vec<_> = self.models.iter().collect(); models.sort_by(|a, b| a.0.cmp(b.0)); models .into_iter() .filter(|(_, (files, _))| *files >= min_files) .map(|(model, (files, pairs))| { let (matrix, hue_sat) = crate::camera_preview::fit_profile(pairs).ok_or_else(|| format!("{model}: no usable colour fit"))?; Ok(CameraProfile::new(model, *files, pairs.len(), matrix, hue_sat)) }) .collect() } /// Add another pool's photos and pairs (pools filled on separate threads). pub fn merge(&mut self, other: Pool) { for (model, (files, pairs)) in other.models { let entry = self.models.entry(model).or_default(); entry.0 += files; entry.1.extend(pairs); } } /// Photos read per model. pub fn files(&self) -> Vec<(String, usize)> { let mut v: Vec<_> = self.models.iter().map(|(m, (n, _))| (m.clone(), *n)).collect(); v.sort(); v } } #[cfg(test)] mod tests { use super::*; fn profile() -> CameraProfile { let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[5.0, 1.2, 1.0]; 8], srgb_value: false }; CameraProfile::new("ILCE-7M4", 12, 3456, Mat3([[1.6, -0.5, -0.1], [-0.2, 1.3, -0.1], [-0.1, -0.2, 1.3]]), Some(table)) } #[test] fn profiles_round_trip_and_reject_bad_files() { let dir = std::env::temp_dir().join(format!("lc-camera-profiles-{}", std::process::id())); let path = save(&profile(), &dir).unwrap(); assert_eq!(path.file_name().unwrap(), "ILCE-7M4.json"); assert_eq!(load(&path).unwrap(), profile()); let bad = |p: CameraProfile| { let path = dir.join("bad.json"); std::fs::write(&path, serde_json::to_vec(&p).unwrap()).unwrap(); load(&path).is_err() }; let mut p = profile(); p.matrix[0][0] = f64::NAN; assert!(bad(p), "non-finite matrix"); let mut p = profile(); p.matrix = [[0.0; 3]; 3]; assert!(bad(p), "singular matrix"); let mut p = profile(); if let Some(t) = p.hue_sat.as_mut() { t.data.truncate(3); } assert!(bad(p), "table data doesn't match its shape"); let mut p = profile(); p.version = 99; assert!(bad(p), "unknown version"); std::fs::write(dir.join("junk.json"), b"{not json").unwrap(); assert!(load(&dir.join("junk.json")).is_err()); assert!(load(&dir.join("missing.json")).is_err()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn bundled_profiles_are_valid_and_named_after_their_model() { assert!(!BUNDLED.is_empty()); for (model, json) in BUNDLED { let p = parse(json.as_bytes(), model).unwrap(); assert_eq!(p.model, *model); assert!(p.files >= 5 && p.hue_sat.is_some(), "{model}: {} photos", p.files); assert_eq!(bundled(model), Some(p)); } assert!(bundled("No Such Camera").is_none()); } #[test] fn file_names_are_safe() { assert_eq!(file_name("ILCE-7M4"), "ILCE-7M4.json"); assert_eq!(file_name(" DSC-RX100M3 "), "DSC-RX100M3.json"); assert_eq!(file_name("../../etc/passwd"), "______etc_passwd.json"); assert_eq!(file_name("Ω 1/2"), "__1_2.json"); } #[test] fn pool_skips_files_that_cannot_contribute() { let mut pool = Pool::default(); assert!(pool.add(b"not a raw file").is_err()); assert!(pool.fit(1).is_empty()); assert!(pool.files().is_empty()); } }