lightcraft/crates/engine/src/camera_profiles.rs

307 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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 (`<model>.json`) in [`dir`]: `LIGHTCRAFT_CAMERA_PROFILES`, else
//! `<config>/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-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")),
("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<HsvTable>,
}
impl CameraProfile {
pub fn new(model: &str, files: usize, samples: usize, matrix: Mat3, hue_sat: Option<HsvTable>) -> 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); one helper for the app.
pub use crate::config::config_dir;
/// Where camera profiles are read from and written to.
pub fn dir() -> Option<PathBuf> {
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<CameraProfile, String> {
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<CameraProfile, String> {
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<CameraProfile> {
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 `<model>.json`; returns the path.
pub fn save(profile: &CameraProfile, dir: &Path) -> Result<PathBuf, String> {
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<HashMap<String, Option<Arc<CameraProfile>>>> {
static CACHE: OnceLock<Mutex<HashMap<String, Option<Arc<CameraProfile>>>>> = 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<Arc<CameraProfile>> {
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<u64> = 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<String, (usize, Vec<([f64; 3], [f64; 3])>)>,
}
/// 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<Option<String>, 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<Result<CameraProfile, String>> {
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());
}
}