diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 9bf0e301..719e5f10 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -9,6 +9,7 @@ //! lightcraft-cli synth-merge hdr|panorama -o DIR //! lightcraft-cli commands [--json] //! lightcraft-cli controls [--json] +//! lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES… //! ``` #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -84,6 +85,12 @@ USAGE: Write synthetic merge inputs (procedural scene; bracketed DNGs or overlapping PNG views). lightcraft-cli commands [--json] list every command id with its parameters lightcraft-cli controls [--json] list every develop control id with its range + lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES… + Fit a colour profile per camera model from raw files and their embedded camera JPEGs + (Sony ARW): up to N files spread over the folders (default 300; 0 = all), pooled per + model, written as .json to DIR (default: the profiles folder LightCraft reads, + /camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then + take their colour from the profile and only their tone from their own JPEG. lightcraft-cli --version | --help "; @@ -125,6 +132,7 @@ fn main() -> ExitCode { Some("merge") => merge(&args[1..]), Some("synth-merge") => synth_merge(&args[1..]), Some("controls") => controls(&args[1..]), + Some("calibrate") => calibrate(&args[1..]), Some("--version" | "-V" | "version") => { println!("lightcraft-cli {}", env!("CARGO_PKG_VERSION")); Ok(()) @@ -144,6 +152,114 @@ fn main() -> ExitCode { } } +/// Raw files below `path` (or `path` itself), skipping hidden and NAS metadata folders. +fn raw_files(path: &Path, out: &mut Vec, depth: usize) { + let is_raw = |p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("arw")); + if path.is_file() { + if is_raw(path) { + out.push(path.to_path_buf()); + } + return; + } + let Ok(entries) = std::fs::read_dir(path) else { return }; + if depth > 32 { + return; + } + for entry in entries.flatten() { + let name = entry.file_name(); + if name.to_string_lossy().starts_with(['.', '@']) { + continue; + } + raw_files(&entry.path(), out, depth + 1); + } +} + +fn calibrate(args: &[String]) -> Result<(), String> { + let mut max = 300usize; + let mut out: Option = None; + let mut inputs = Vec::new(); + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--max" => max = take_value(args, &mut i, "--max")?.parse().map_err(|_| "--max needs a number".to_string())?, + "--out" => out = Some(take_value(args, &mut i, "--out")?.into()), + a if a.starts_with("--") => return Err(format!("unknown option `{a}`")), + f => inputs.push(f.to_string()), + } + i += 1; + } + if inputs.is_empty() { + return Err("calibrate needs folders or raw files".into()); + } + let mut files = Vec::new(); + for input in &inputs { + raw_files(Path::new(input), &mut files, 0); + } + files.sort(); + files.dedup(); + let found = files.len(); + if max > 0 && files.len() > max { + // spread over all folders (dates, scenes) rather than the first N + files = (0..max).filter_map(|k| files.get(k * found / max).cloned()).collect(); + } + eprintln!("calibrate: {} of {found} raw files", files.len()); + let dir = out.or_else(lightcraft_engine::camera_profiles::dir).ok_or("no profiles folder: pass --out DIR")?; + let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).clamp(1, 6); + let next = std::sync::atomic::AtomicUsize::new(0); + let done = std::sync::atomic::AtomicUsize::new(0); + let pools: Vec = std::thread::scope(|scope| { + let handles: Vec<_> = (0..workers) + .map(|_| { + scope.spawn(|| { + let mut pool = lightcraft_engine::camera_profiles::Pool::default(); + while let Some(path) = files.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) { + let result = std::fs::read(path).map_err(|e| e.to_string()).and_then(|bytes| pool.add(&bytes)); + let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + match result { + Ok(Some(model)) => eprintln!("[{n}/{}] {} ({model})", files.len(), path.display()), + Ok(None) => eprintln!("[{n}/{}] {}: skipped (no usable camera JPEG or colour)", files.len(), path.display()), + Err(e) => eprintln!("[{n}/{}] {}: {e}", files.len(), path.display()), + } + } + pool + }) + }) + .collect(); + handles.into_iter().filter_map(|h| h.join().ok()).collect() + }); + let mut pool = lightcraft_engine::camera_profiles::Pool::default(); + for p in pools { + pool.merge(p); + } + let mut written = 0; + for result in pool.fit(5) { + match result { + Ok(profile) => { + let path = lightcraft_engine::camera_profiles::save(&profile, &dir)?; + println!( + "{}: {} photos, {} colour pairs, hue/saturation table {} → {}", + profile.model, + profile.files, + profile.samples, + profile.hue_sat.is_some(), + path.display() + ); + written += 1; + } + Err(e) => eprintln!("calibrate: {e}"), + } + } + for (model, n) in pool.files() { + if n < 5 { + eprintln!("calibrate: {model}: only {n} usable photo(s), at least 5 needed"); + } + } + if written == 0 { + return Err("no profile written".into()); + } + Ok(()) +} + fn mcp(args: &[String]) -> Result<(), String> { let mut connect: Option = None; let mut demo = false; diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 2bdfa16f..bb5714ff 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -109,16 +109,7 @@ fn gpu_crash_notice(what: &str) -> String { } fn config_dir() -> Option { - if cfg!(target_os = "macos") { - std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/LightCraft")) - } else if cfg!(windows) { - std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("LightCraft")) - } else { - std::env::var_os("XDG_CONFIG_HOME") - .map(std::path::PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) - .map(|c| c.join("lightcraft")) - } + lightcraft_engine::camera_profiles::config_dir() } /// The saved UI state and app settings (`/ui.json`), if any, and a warning for the user diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index ac8c4780..da4fbccb 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -19,12 +19,40 @@ pub(crate) struct CameraLook { pub hue_sat: Option, } +/// Long edge of the sensor/JPEG proxy a single photo's look is fitted on (the acceptance gates +/// below were set at this size). +const PROXY: usize = 96; +/// Long edge of the proxies pooled for a camera profile: small objects (a shirt) get 4× the samples. +pub(crate) const PROFILE_PROXY: usize = 192; + pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option { if !transform.matrix_is_fallback || raw.format != lightcraft_raw::RawFormat::Arw { return None; } + let (sensor, reference) = proxies(raw, bytes, transform, PROXY)?; + // A camera profile pooled from many photos knows colours this photo shows too little of; + // only the tone and chroma curves are fitted per photo (DRO and picture styles vary). + let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); + let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); + let look = fit_pairs_with(&sensor, &reference, colour)?; + if lightcraft_pipeline::profiling() { + eprintln!( + "[profile] ARW camera look: {:?}, {:?}, hue/sat table {}, camera profile {}", + look.matrix.0, + look.tone, + look.hue_sat.is_some(), + profile.is_some() + ); + } + Some(look) +} + +/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s +/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020). +fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f)> { let jpeg = lightcraft_raw::embedded_preview(bytes)?; - let decoded = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions { max_size: Some((384, 384)), max_pixels: 64_000_000 }).ok()?; + let edge = (2 * size).max(384) as u32; + let decoded = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 }).ok()?; let reference = decoded.to_working(); let crop = raw.crop.clipped(raw.active_area.width, raw.active_area.height); if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 { @@ -35,17 +63,33 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf return None; } // Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution. - let k = (crop.width.max(crop.height).div_ceil(384).max(2)).div_ceil(2) * 2; + let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2; let sensor = raw.develop_binned(k, 0.99).ok()??; - let mut sensor = fit(&sensor, 96, 96, Filter::Box); + let mut sensor = fit(&sensor, size, size, Filter::Box); let reference = fit(&reference, sensor.width, sensor.height, Filter::Box); let gain = 2f32.powf(transform.baseline_exposure as f32); sensor.map_in_place(|p| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain))); - let look = fit_pairs(&sensor, &reference)?; - if lightcraft_pipeline::profiling() { - eprintln!("[profile] ARW camera look: {:?}, {:?}, hue/sat table {}", look.matrix.0, look.tone, look.hue_sat.is_some()); + Some((sensor, reference)) +} + +/// Colour training pairs of one ARW for a camera profile: white-balanced camera RGB (with the +/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for other files or unusable previews. +pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option> { + if raw.format != lightcraft_raw::RawFormat::Arw || lightcraft_raw::color::has_matrix(&raw.color) { + return None; } - Some(look) + let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw)); + let (sensor, reference) = proxies(raw, bytes, &transform, PROFILE_PROXY)?; + let to_camera = transform.matrix.inverse()?; + let (pairs, _) = collect_pairs(&sensor, &reference)?; + Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect()) +} + +/// A camera profile's colour model (matrix from white-balanced camera RGB, hue/saturation table) +/// fitted to pairs pooled from many photos. +pub(crate) fn fit_profile(pairs: &[([f64; 3], [f64; 3])]) -> Option<(Mat3, Option)> { + let matrix = fit_matrix(pairs)?; + Some((matrix, fit_hue_sat(pairs, &matrix))) } /// A fit must cut the held-out squared error to below this share of the fallback's. @@ -72,7 +116,15 @@ fn displayed(scene: [f64; 3], tone: &ToneMap) -> [f64; 3] { scene.map(|v| o + (v * o / y - o) * k) } +#[cfg(test)] fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option { + fit_pairs_with(sensor, reference, None) +} + +/// Training pairs (sensor → JPEG, unclipped midtones) and the wider set including highlights +/// (for the chroma curve); `None` when too few, or the photo has too little colour. +type Pairs = Vec<([f64; 3], [f64; 3])>; +fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<(Pairs, Pairs)> { if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() { return None; } @@ -96,9 +148,11 @@ fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option { colour += usize::from(max - min > 0.05); pairs.push((input.map(f64::from), output.map(f64::from))); } - if pairs.len() < 256 || colour < pairs.len() / 20 { - return None; - } + (pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright)) +} + +/// Ridge-regularised 3×3 chromaticity matrix (luminance-normalised RGB) on the training pairs. +fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option { let mut gram = [[0.0; 3]; 3]; let mut cross = [[0.0; 3]; 3]; for (i, (x, y)) in pairs.iter().enumerate() { @@ -106,6 +160,9 @@ fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option { continue; } let (lx, ly) = (luma(*x), luma(*y)); + if lx <= 0.0 || ly <= 0.0 { + continue; + } for row in 0..3 { for col in 0..3 { // Normalising by luminance prevents a camera S-curve from corrupting colour. @@ -126,14 +183,26 @@ fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option { cross[i][i] += regularization; } let matrix = Mat3(cross).mul(&Mat3(gram).inverse()?); - if !matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0) { - return None; - } + matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0).then_some(matrix) +} + +/// The photo's look: its own matrix (with and without a hue/saturation table), or the given +/// colour model (a camera profile's, in the sensor proxy's space), each completed with a tone +/// and chroma curve fitted to this photo. +fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { + let (pairs, bright) = collect_pairs(sensor, reference)?; + let candidates = match colour { + Some(given) => vec![given], + None => { + let matrix = fit_matrix(&pairs)?; + vec![(matrix, fit_hue_sat(&pairs, &matrix)), (matrix, None)] + } + }; // Matrix + table when the table helps the held-out pixels, else the matrix alone. Chosen by // error in gamma-encoded display values (closer to what is seen: in linear values a slightly // missed bright rock outweighs a clearly wrong dark shirt); the acceptance gates below stay linear. let mut best: Option<(f64, f64, usize, CameraLook)> = None; - for hue_sat in [fit_hue_sat(&pairs, &matrix), None] { + for (matrix, hue_sat) in candidates { let correction = hue_sat.as_ref().and_then(HueSat::new); let colour = |x: [f64; 3]| { let p = matrix.apply(x); @@ -268,15 +337,28 @@ fn fit_hue_sat(pairs: &[([f64; 3], [f64; 3])], matrix: &Mat3) -> Option> = vec![Vec::new(); TABLE_HUES]; + for sample in samples { + if let Some(bin) = by_hue.get_mut(((sample.0.rem_euclid(360.0) / step) as usize).min(TABLE_HUES - 1)) { + bin.push(sample); + } + } + let reach = (4.0 * KERNEL_HUE / step).ceil() as usize + 1; + use rayon::prelude::*; + let data: Vec<[f32; 3]> = (0..TABLE_VALS * TABLE_HUES * TABLE_SATS) + .into_par_iter() + .map(|index| { + let (v, h, s) = (index / (TABLE_HUES * TABLE_SATS), index / TABLE_SATS % TABLE_HUES, index % TABLE_SATS); + if s == 0 { + return [0.0, 1.0, 1.0]; + } + let (val, hue, sat) = (v as f64 / (TABLE_VALS - 1) as f64, h as f64 * step, s as f64 / (TABLE_SATS - 1) as f64); + let (mut weight, mut shift, mut log_scale) = (0.0, 0.0, 0.0); + for offset in 0..=2 * reach { + let Some(bin) = by_hue.get((h + TABLE_HUES * 2 + offset - reach) % TABLE_HUES) else { continue }; + for &(hp, sp, vp, dh, ls) in bin { let dhue = (hp - hue + 180.0).rem_euclid(360.0) - 180.0; if dhue.abs() > 4.0 * KERNEL_HUE { continue; @@ -287,15 +369,14 @@ fn fit_hue_sat(pairs: &[([f64; 3], [f64; 3])], matrix: &Mat3) -> Option 0.0 - && let Some(entry) = data.get_mut((v * TABLE_HUES + h) * TABLE_SATS + s) - { - let shrink = 1.0 / (weight + SHRINK_WEIGHT); - *entry = [(shift * shrink) as f32, (log_scale * shrink).exp() as f32, 1.0]; - } } - } - } + if weight <= 0.0 { + return [0.0, 1.0, 1.0]; + } + let shrink = 1.0 / (weight + SHRINK_WEIGHT); + [(shift * shrink) as f32, (log_scale * shrink).exp() as f32, 1.0] + }) + .collect(); data.iter().all(|e| e.iter().all(|v| v.is_finite())).then_some(HsvTable { hue_divisions: TABLE_HUES, sat_divisions: TABLE_SATS, @@ -575,6 +656,55 @@ mod tests { assert!(b < 0.5 * a, "highlight error {a:.4} -> {b:.4}"); } + /// Pooled pairs of several "photos" recover the camera's matrix, and a photo given that + /// colour model (a camera profile) keeps it, fitting only its own tone. + #[test] + fn profile_colour_is_pooled_and_then_used_as_given() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let photo = |seed: usize, strength: f32| { + let mut sensor = Rgb32f::new(64, 48); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let i = i + seed * 7; + let ev = 0.04 + (i % 23) as f32 * 0.02; + *src = [ev * (0.7 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.05)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + // each photo has its own tone (DRO, picture style) + *dst = p.map(|v| v * (1.0 - (-strength * y).exp()) / y); + } + (sensor, reference) + }; + let mut pool = Vec::new(); + for seed in 0..4 { + let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5); + pool.extend(collect_pairs(&sensor, &reference).unwrap().0); + } + let (matrix, _) = fit_profile(&pool).unwrap(); + // the camera's colours (luminance-normalised: the tone curve sets brightness) + for x in [[0.8, 1.0, 0.75], [1.1, 1.0, 0.8], [0.75, 1.0, 1.2], [1.0; 3]] { + let (a, b) = (matrix.apply(x), known.apply(x)); + let (la, lb) = (luma(a), luma(b)); + assert!((0..3).all(|c| (a[c] / la - b[c] / lb).abs() < 0.01), "{x:?}: {a:?} vs {b:?}"); + } + let (sensor, reference) = photo(9, 3.5); + let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap(); + assert_eq!(look.matrix, matrix); + assert!(look.hue_sat.is_none()); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let error = sensor + .data + .iter() + .zip(&reference.data) + .map(|(x, y)| { + let p = displayed(look.matrix.apply(x.map(f64::from)), &tone); + (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() / 3.0 + }) + .sum::() + / sensor.data.len() as f64; + assert!(error.sqrt() < 0.02, "this photo's own tone is followed: RMS {}", error.sqrt()); + } + #[test] fn hue_sat_passes_black_and_non_finite_pixels_through() { let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs new file mode 100644 index 00000000..a936163c --- /dev/null +++ b/crates/engine/src/camera_profiles.rs @@ -0,0 +1,276 @@ +//! Camera colour profiles of our own, pooled from many photos of one camera model. +//! +//! A raw file without colour matrices (Sony ARW) 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`. 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; + +#[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()))?; + let profile: CameraProfile = serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display()))?; + if !profile.valid() { + return Err(format!("{}: not a usable camera profile (version {}, model {:?})", path.display(), profile.version, profile.model)); + } + Ok(profile) +} + +/// 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`], 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, + }; + 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 Some(entries) = dir().and_then(|d| std::fs::read_dir(d).ok()) else { return 0 }; + let mut files: Vec<(String, u64, u64)> = entries + .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); + } + 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 + /// without colour matrices, no usable camera JPEG, too little colour). + pub fn add(&mut self, bytes: &[u8]) -> Result, String> { + let raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?; + 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 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()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 68200a35..9455ea49 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -12,6 +12,7 @@ pub mod availability; mod camera_preview; +pub mod camera_profiles; pub mod cmd; pub mod crs; pub mod crs_masks; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index f1532edd..27c9ca32 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -647,7 +647,13 @@ impl crate::Session { ^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ content.rotate_left(17); let cache = thumb_bucket.map(|b| { - let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish(); + let k = Hasher128::new() + .str(&content_key(&p)) + .u64(settings.hash64()) + .u64(b as u64) + .u64(RENDER_CACHE_VERSION) + .u64(crate::camera_profiles::cache_key()) + .finish(); (self.media.rendered.clone(), k) }); Some(RenderJob { @@ -676,7 +682,14 @@ impl crate::Session { /// Cache key of a variant thumbnail ([`Self::variant_job`]). pub fn variant_key(p: &Photo, settings: &DevelopSettings, edge: usize) -> Hash128 { - Hasher128::new().str(&content_key(p)).str("variant").u64(settings.hash64()).u64(edge as u64).u64(RENDER_CACHE_VERSION).finish() + Hasher128::new() + .str(&content_key(p)) + .str("variant") + .u64(settings.hash64()) + .u64(edge as u64) + .u64(RENDER_CACHE_VERSION) + .u64(crate::camera_profiles::cache_key()) + .finish() } /// A thumbnail of `id` rendered with `settings` instead of its own (profile and preset @@ -707,7 +720,14 @@ impl crate::Session { /// Size-independent cache key of a photo's view render (loupe) for its current settings. fn view_key(p: &Photo, apply_crop: bool) -> Hash128 { - Hasher128::new().str(&content_key(p)).str("view").u64(p.develop.hash64()).u64(apply_crop as u64).u64(RENDER_CACHE_VERSION).finish() + Hasher128::new() + .str(&content_key(p)) + .str("view") + .u64(p.develop.hash64()) + .u64(apply_crop as u64) + .u64(RENDER_CACHE_VERSION) + .u64(crate::camera_profiles::cache_key()) + .finish() } /// The loupe's render job: like [`Self::render_job`], and a full-quality result is kept as the diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 9654cc30..b8e88d04 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -35,3 +35,10 @@ Public CC0 samples (raw.pixls.us; 10 files, ILCE-6000, -6400 ×2, -6700, -7M3, - On one sample, Exposure +1 EV, Saturation +100, Contrast +50, warm/cool WB, tint, highlights and shadows all changed the output. The same matrix and curve were selected for 400-pixel and full-resolution renders. Headless UI checks wait until the loupe reports `source: render` and no jobs remain; the camera JPEG stand-in does not count as verification. This is a per-file camera-look estimate, **not measured spectral calibration or Lightroom parity**. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour. + +## Camera profiles (pooled per model) + +One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos. + +Profiles are JSON files `.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`). They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (DRO and picture styles vary per shot); the acceptance gates still apply, and a rejected fit falls back as before. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. + diff --git a/docs/mcp.md b/docs/mcp.md index 492e655d..826a4454 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -176,6 +176,7 @@ lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.5 --set light.con lightcraft-cli render in.jpg -o out.png --settings look.json --preset lightcraft-cli commands [--json] # the command registry lightcraft-cli controls [--json] # develop control ids and ranges +lightcraft-cli calibrate --max 300 ~/Pictures/2026 # camera colour profiles (docs/camera-preview-colour.md) ``` ## Tests diff --git a/docs/parity.md b/docs/parity.md index 46dae0ba..678d2232 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -274,7 +274,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW gets a guarded file-local fit to its own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB, kept with smart previews (see docs/camera-preview-colour.md); on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW gets a guarded file-local fit to its own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a local per-model profile pooled from many photos (`lightcraft-cli calibrate`, `crates/engine/src/camera_profiles.rs`), the profile's colour and only its own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF |