Compare commits

...
8 changed files with 378 additions and 29 deletions

No files matched your search

+2 -1
View File
@@ -45,6 +45,7 @@
//! | (none) | early builds: a log without a snapshot | |
//! | 1 | up to v0.2.0 | |
//! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` |
//! | 3 | 2026-10 | `Photo.fallback_matrix` (raw without a colour matrix of its own: relative WB) |
//!
//! Rules:
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
@@ -69,7 +70,7 @@ pub const LOG: &str = "catalog.log";
const FORMAT: &str = "lightcraft-catalog";
/// The catalog format this build writes (and the newest it reads). See the module docs →
/// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.
pub const VERSION: u32 = 2;
pub const VERSION: u32 = 3;
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
#[derive(Clone, Copy, Debug)]
+37 -3
View File
@@ -276,6 +276,13 @@ pub struct Photo {
/// whether the user changed anything since (see [`crate::local`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local_baseline: Option<u64>,
/// Raw files: the decoder had no colour matrix for this file (`matrix_is_fallback`: every
/// non-DNG raw today), so its colour is estimated from its own embedded JPEG and its white
/// balance controls are relative to the as-shot look ([`Photo::relative_wb`]). Recorded at
/// import (catalog format v3). `None`: imported before it was recorded — see
/// [`Photo::relative_wb`] for how those photos keep the white balance they were edited with.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback_matrix: Option<bool>,
}
/// What assisted culling measured on a photo.
@@ -324,6 +331,7 @@ impl Photo {
analysis: None,
preview_only: None,
local_baseline: None,
fallback_matrix: None,
}
}
/// A raw file developed from its sensor data: not a rendered image, and not a raw shown from
@@ -331,10 +339,13 @@ impl Photo {
pub fn develops_raw(&self) -> bool {
self.kind == MediaKind::Raw && self.preview_only.is_none()
}
/// The current ARW reader has vendor WB multipliers but no measured camera illuminant.
/// Use adjustments relative to the camera's as-shot look, as for rendered photographs.
/// White balance controls are relative to the camera's as-shot look, as for rendered
/// photographs: the raw has vendor WB multipliers but no colour matrix
/// ([`Photo::fallback_matrix`]), so no measured illuminant to put an absolute Kelvin on.
/// Photos imported before that fact was recorded keep the rule they were edited under: Sony
/// ARW relative, every other raw absolute (so a saved Custom white balance reads the same).
pub fn relative_wb(&self) -> bool {
self.develops_raw() && self.format.eq_ignore_ascii_case("ARW")
self.develops_raw() && self.fallback_matrix.unwrap_or_else(|| self.format.eq_ignore_ascii_case("ARW"))
}
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
/// default preset applied on top of them ([`Photo::import_look`]).
@@ -421,4 +432,27 @@ mod edited_tests {
p.develop = Arc::new(DevelopSettings::default());
assert!(!p.is_edited(), "a full reset is unedited too");
}
#[test]
fn relative_wb_follows_the_decoder_fact_and_keeps_old_photos_rule() {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.nef", "NEF", 10, 10, "");
p.kind = MediaKind::Raw;
p.as_shot_wb = Some((5200.0, 4.0));
// imported before the fact was recorded: only ARW was relative
assert!(!p.relative_wb());
assert_eq!(p.camera_defaults().wb.temp, 5200.0);
p.fallback_matrix = Some(true);
assert!(p.relative_wb());
assert_eq!(p.camera_defaults().wb.temp, 6500.0);
p.format = "ARW".into();
p.fallback_matrix = None;
assert!(p.relative_wb());
p.fallback_matrix = Some(false);
assert!(!p.relative_wb(), "a raw with a matrix of its own has absolute WB");
p.fallback_matrix = Some(true);
p.preview_only = Some("unsupported".into());
assert!(!p.relative_wb(), "shown from its embedded JPEG");
let json = serde_json::to_string(&Photo::new(PhotoId(2), Source::Demo { scene: 0 }, "b.jpg", "JPEG", 1, 1, "")).unwrap();
assert!(!json.contains("fallback_matrix"), "{json}");
}
}
+26 -1
View File
@@ -47,6 +47,28 @@ fn v1_library_loads_and_is_upgraded() {
assert_eq!((r.replayed, r.upgraded_from), (1, None));
}
/// v3 records whether a raw lacks a colour matrix; v2 photos load without it and keep the white
/// balance rule they were edited under (only ARW relative).
#[test]
fn v2_raws_load_without_the_matrix_fact() {
let mut c = Catalog::new();
for (name, format) in [("a.nef", "NEF"), ("b.arw", "ARW")] {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: format!("/p/{name}") }, name, format, 4, 3, "2026-01-01");
p.kind = MediaKind::Raw;
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
let snap = c.to_snapshot();
assert!(!snap.contains("fallback_matrix"));
let m = MemStore::new();
m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":2,\"seq\":2,\"catalog\":{snap}}}\n").into_bytes());
let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap();
assert_eq!(r.upgraded_from, Some(2));
assert_eq!(snapshot_version(&m), u64::from(VERSION));
let rel: Vec<_> = loaded.photos().map(|p| (p.format.clone(), p.fallback_matrix, p.relative_wb())).collect();
assert!(rel.contains(&("NEF".into(), None, false)) && rel.contains(&("ARW".into(), None, true)), "{rel:?}");
}
#[test]
fn versionless_log_only_library_is_upgraded() {
let mut c = Catalog::new();
@@ -147,6 +169,8 @@ fn bad_crc_is_still_a_torn_tail() {
/// variant here under the new version.
#[test]
fn op_variants_are_versioned() {
/// The newest format that added serialized fields but no op.
const FIELDS_ONLY_SINCE: u32 = 3;
fn since(op: &Op) -> u32 {
match op {
Op::AddPhoto { .. }
@@ -182,5 +206,6 @@ fn op_variants_are_versioned() {
}
}
let newest = since(&Op::SetBrowsed { folder: String::new(), at: None });
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
// v3 added a field only (`Photo.fallback_matrix`); a new op takes the next version.
assert_eq!(newest.max(FIELDS_ONLY_SINCE), VERSION, "the newest op's (or field's) version must be the current format version");
}
@@ -0,0 +1,69 @@
//! Evaluate raw starting looks against each file's own embedded JPEG (docs/camera-preview-colour.md):
//!
//! - `camera_look_eval preview RAW OUT.jpg [EDGE]` writes the raw's embedded camera JPEG, oriented,
//! as sRGB (≤ EDGE px, default 1200), for side-by-side viewing with a render of the raw;
//! - `camera_look_eval delta REFERENCE.jpg RENDER.jpg… [--edge N]` prints, for each render, the
//! mean CIE76 ΔE and mean L* against the reference, both reduced to ≤ N px (default 200).
//!
//! Renders come from `lightcraft-cli render RAW -o OUT.jpg --size 1200`; `LIGHTCRAFT_PROFILE=1`
//! makes the render print the fit's alignment, held-out error and verdict.
use lightcraft_codecs::{DecodeOptions, decode};
use lightcraft_raster::resample::{Filter, fit};
fn lab(rgb2020: [f32; 3]) -> [f64; 3] {
// linear Rec.2020 → XYZ (D65) → CIELAB (D65 white)
let m = [[0.636958, 0.144617, 0.168881], [0.262700, 0.677998, 0.059302], [0.0, 0.028073, 1.060985]];
let p = rgb2020.map(|v| f64::from(v).max(0.0));
let xyz: [f64; 3] = std::array::from_fn(|i| m[i][0] * p[0] + m[i][1] * p[1] + m[i][2] * p[2]);
let white = [0.95047, 1.0, 1.08883];
let f = |t: f64| if t > 216.0 / 24389.0 { t.cbrt() } else { (24389.0 / 27.0 * t + 16.0) / 116.0 };
let [fx, fy, fz] = std::array::from_fn(|i| f(xyz[i] / white[i]));
[116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)]
}
fn load(path: &str, edge: usize) -> Result<lightcraft_raster::Rgb32f, String> {
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let d = decode(&bytes, DecodeOptions::fit(edge as u32 * 2, edge as u32 * 2)).map_err(|e| format!("{path}: {e}"))?;
Ok(fit(&d.to_working(), edge, edge, Filter::Box))
}
fn main() -> Result<(), String> {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("preview") if args.len() >= 3 => {
let bytes = std::fs::read(&args[1]).map_err(|e| e.to_string())?;
let edge = args.get(3).and_then(|e| e.parse().ok()).unwrap_or(1200);
let img = lightcraft_engine::files::embedded_preview_srgb(&bytes, edge).ok_or("no embedded preview")?;
let jpeg = lightcraft_preview::encode_jpeg(&img).ok_or("can't encode")?;
std::fs::write(&args[2], jpeg).map_err(|e| e.to_string())
}
Some("delta") if args.len() >= 3 => {
let mut edge = 200;
let mut files = Vec::new();
let mut it = args[1..].iter();
while let Some(a) = it.next() {
if a == "--edge" {
edge = it.next().and_then(|v| v.parse().ok()).ok_or("--edge N")?;
} else {
files.push(a.clone());
}
}
let reference = load(&files[0], edge)?;
for path in &files[1..] {
let render = load(path, edge)?;
let render = lightcraft_raster::resample::resize(&render, reference.width, reference.height, Filter::Box);
let (mut de, mut l_ref, mut l_out) = (0.0, 0.0, 0.0);
for (a, b) in reference.data.iter().zip(&render.data) {
let (a, b) = (lab(*a), lab(*b));
de += ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
l_ref += a[0];
l_out += b[0];
}
let n = reference.data.len().max(1) as f64;
println!("{path}\tdE76 {:.2}\tL* {:.1} (reference {:.1})", de / n, l_out / n, l_ref / n);
}
Ok(())
}
_ => Err("usage: camera_look_eval preview RAW OUT.jpg [EDGE] | delta REFERENCE.jpg RENDER.jpg… [--edge N]".into()),
}
}
+168 -17
View File
@@ -1,48 +1,199 @@
//! Estimate an ARW starting look from its own JPEG. Colour and luminance are fitted separately;
//! the JPEG supplies correspondences only, never output pixels or a replacement for RAW editing.
//! Estimate a raw's starting look from its own embedded JPEG, for raws whose decoder has no colour
//! matrix (`matrix_is_fallback`: every non-DNG raw today, whatever the make). Colour and luminance
//! are fitted separately; the JPEG supplies correspondences only, never output pixels or a
//! replacement for raw editing. Each file's result (also "no usable fit") is cached by a content
//! fingerprint and [`FIT_VERSION`], so thumbnails, previews and exports of a file fit it once.
use std::sync::Mutex;
use lightcraft_color::{Mat3, luminance_2020};
use lightcraft_pipeline::tone::{CameraTone, ToneMap};
use lightcraft_preview::{Hash128, Hasher128, Lru};
use lightcraft_raster::{
Rgb32f,
resample::{Filter, fit},
};
use lightcraft_raw::{RawImage, color::CameraTransform};
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct CameraLook {
pub matrix: Mat3,
pub tone: CameraTone,
}
/// Bump whenever the fit or its gates change what a file gets: cached results of older versions
/// are then never used (the version is part of the cache key).
pub(crate) const FIT_VERSION: u64 = 2;
/// Results kept: one per recently decoded file (a few hundred bytes each).
const CACHE_ENTRIES: usize = 512;
static CACHE: Mutex<Option<Lru<Hash128, Option<CameraLook>>>> = Mutex::new(None);
/// The file's look, from the cache or fitted now.
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
if !transform.matrix_is_fallback || raw.format != lightcraft_raw::RawFormat::Arw {
if !transform.matrix_is_fallback {
return None;
}
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 jpeg = lightcraft_raw::embedded_preview(bytes);
let key = cache_key(raw, bytes, jpeg.as_deref(), transform);
if let Some(hit) = CACHE.lock().ok().and_then(|mut c| c.as_mut().and_then(|c| c.get(&key).copied())) {
return hit;
}
let t0 = std::time::Instant::now();
let look = jpeg.and_then(|jpeg| fit_file(raw, &jpeg, transform));
if lightcraft_pipeline::profiling() {
eprintln!("[profile] camera look ({:?}) fitted in {:.1} ms: {}", raw.format, t0.elapsed().as_secs_f64() * 1e3, if look.is_some() { "accepted" } else { "rejected" });
}
if let Ok(mut c) = CACHE.lock() {
c.get_or_insert_with(|| Lru::new(CACHE_ENTRIES)).insert(key, look, 1);
}
look
}
/// A fingerprint of everything the fit reads: the file's length, its whole embedded JPEG, a
/// strided sample of the file (the mosaic), the decoder's crop and colour transform, and
/// [`FIT_VERSION`]. Hashing all ~25 MB of a raw would cost a third of the fit it saves.
fn cache_key(raw: &RawImage, bytes: &[u8], jpeg: Option<&[u8]>, t: &CameraTransform) -> Hash128 {
let mut h = Hasher128::new();
h.u64(FIT_VERSION).u64(bytes.len() as u64).u64(jpeg.map_or(u64::MAX, |j| j.len() as u64)).update(jpeg.unwrap_or_default());
let step = (bytes.len() / 4096).max(64);
for chunk in bytes.chunks(step) {
h.update(chunk.get(..32).unwrap_or(chunk));
}
let c = raw.crop;
for v in [c.x, c.y, c.width, c.height, raw.active_area.width, raw.active_area.height] {
h.u64(v as u64);
}
for v in t.matrix.0.iter().flatten().copied().chain(t.wb.map(f64::from)).chain([t.baseline_exposure]) {
h.u64(v.to_bits());
}
h.finish()
}
fn reject<T>(why: &str) -> Option<T> {
if lightcraft_pipeline::profiling() {
eprintln!("[profile] camera look rejected: {why}");
}
None
}
fn fit_file(raw: &RawImage, jpeg: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
let Ok(decoded) = lightcraft_codecs::decode(jpeg, lightcraft_codecs::DecodeOptions { max_size: Some((384, 384)), max_pixels: 64_000_000 }) else {
return reject("embedded JPEG does not decode");
};
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 {
return None;
return reject("empty crop or preview");
}
// Previews are stored in sensor orientation (EXIF orientation applies to both alike). A
// preview of a different shape — a 16:9 or 1:1 picture-style crop, a rotated thumbnail —
// can't be paired pixel for pixel.
let aspect = crop.width as f64 / crop.height as f64;
if (reference.width as f64 / reference.height as f64 / aspect - 1.0).abs() > 0.02 {
return None;
if (reference.width as f64 / reference.height as f64 / aspect - 1.0).abs() > MAX_ASPECT_MISMATCH {
return reject("preview aspect differs from the raw crop");
}
// 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 sensor = raw.develop_binned(k, 0.99).ok()??;
let Some(sensor) = raw.develop_binned(k, 0.99).ok().flatten() else {
return reject("mosaic can't be binned");
};
let mut sensor = fit(&sensor, 96, 96, Filter::Box);
let reference = fit(&reference, sensor.width, sensor.height, Filter::Box);
// Same shape within MAX_ASPECT_MISMATCH: pair the pixels exactly (a fit could round one edge
// differently, e.g. a 3:2 preview of a 4950×3280 crop).
let reference = lightcraft_raster::resample::resize(&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)));
// Same aspect is not the same framing: a preview cropped or shifted differently (some bodies'
// previews leave out sensor borders the raw crop keeps) would pair unrelated pixels.
let alignment = alignment(&sensor, &reference)?;
if lightcraft_pipeline::profiling() {
eprintln!("[profile] camera look alignment: correlation {:.3} at (0, 0), best {:.3} at {:?}", alignment.at_zero, alignment.best, alignment.shift);
}
if alignment.at_zero < MIN_ALIGNMENT || alignment.shift.0.abs() > 1 || alignment.shift.1.abs() > 1 {
return reject("preview framing doesn't match the raw");
}
let look = fit_pairs(&sensor, &reference)?;
if lightcraft_pipeline::profiling() {
eprintln!("[profile] ARW camera look: {:?}, {:?}", look.matrix.0, look.tone);
eprintln!("[profile] camera look: {:?}, {:?}", look.matrix.0, look.tone);
}
Some(look)
}
/// Previews whose width/height ratio differs from the raw crop's by more than this are not used.
const MAX_ASPECT_MISMATCH: f64 = 0.02;
/// Correlation of log luminance (sensor proxy vs preview, both ~96 px) the unshifted pairing must
/// reach; the best of the ±3 px shifts must also be within ±1 px of it.
const MIN_ALIGNMENT: f64 = 0.85;
struct Alignment {
at_zero: f64,
best: f64,
shift: (i32, i32),
}
/// How well the two proxies line up: Pearson correlation of log luminance for whole-pixel shifts
/// of up to ±3 px (the camera's tone curve is monotone, so aligned images correlate strongly).
fn alignment(sensor: &Rgb32f, reference: &Rgb32f) -> Option<Alignment> {
let (w, h) = (sensor.width, sensor.height);
if (w, h) != (reference.width, reference.height) || w < 16 || h < 16 || sensor.data.len() != w * h || reference.data.len() != w * h {
return reject("proxies differ in size");
}
// Log luminance, box-blurred (5×5): fine texture (foliage at ~96 px) and the small geometric
// differences of in-camera distortion correction must not read as misalignment; a crop,
// shift or rotation of the framing still does.
let log = |img: &Rgb32f| -> Vec<f64> {
let l: Vec<f64> = img.data.iter().map(|p| f64::from(luminance_2020(p.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }))).max(1e-4).ln()).collect();
(0..w * h)
.map(|i| {
let (x, y) = ((i % w) as i64, (i / w) as i64);
let (mut sum, mut n) = (0.0, 0.0);
for yy in (y - 2).max(0)..(y + 3).min(h as i64) {
for xx in (x - 2).max(0)..(x + 3).min(w as i64) {
if let Some(v) = l.get(yy as usize * w + xx as usize) {
sum += v;
n += 1.0;
}
}
}
sum / f64::max(n, 1.0)
})
.collect()
};
let (a, b) = (log(sensor), log(reference));
const M: i32 = 3;
let corr = |dx: i32, dy: i32| -> f64 {
let (mut n, mut sa, mut sb, mut saa, mut sbb, mut sab) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
for y in M..h as i32 - M {
for x in M..w as i32 - M {
let (Some(&u), Some(&v)) = (a.get((y * w as i32 + x) as usize), b.get(((y + dy) * w as i32 + x + dx) as usize)) else {
continue;
};
n += 1.0;
sa += u;
sb += v;
saa += u * u;
sbb += v * v;
sab += u * v;
}
}
let cov = sab - sa * sb / n;
let var = (saa - sa * sa / n) * (sbb - sb * sb / n);
if n < 2.0 || var <= 1e-12 { 0.0 } else { cov / var.sqrt() }
};
let at_zero = corr(0, 0);
let mut best = (at_zero, (0, 0));
for dy in -M..=M {
for dx in -M..=M {
let c = corr(dx, dy);
if c > best.0 + 1e-9 {
best = (c, (dx, dy));
}
}
}
Some(Alignment { at_zero, best: best.0, shift: best.1 })
}
/// A fit must cut the held-out squared error to below this share of the fallback's.
const MIN_IMPROVEMENT: f64 = 0.7;
/// ...and stay within this per-channel RMS of the camera JPEG (linear display values). On public
@@ -84,7 +235,7 @@ fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
pairs.push((input.map(f64::from), output.map(f64::from)));
}
if pairs.len() < 256 || colour < pairs.len() / 20 {
return None;
return reject("too few usable or coloured pixels");
}
let mut gram = [[0.0; 3]; 3];
let mut cross = [[0.0; 3]; 3];
@@ -105,7 +256,7 @@ fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
let inverse = Mat3(gram).inverse()?;
let condition = trace * (0..3).map(|i| inverse.0[i][i].abs()).sum::<f64>();
if trace <= 0.0 || !condition.is_finite() || condition > 1e6 {
return None;
return reject("ill-conditioned colour samples");
}
let regularization = trace * 1e-4;
for i in 0..3 {
@@ -114,7 +265,7 @@ fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
}
let matrix = Mat3(cross).mul(&Mat3(gram).inverse()?);
if !matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0) {
return None;
return reject("unbounded colour matrix");
}
let tone_pairs: Vec<_> =
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(matrix.apply(*x).map(|v| v.max(0.0))), luma(*y))).collect();
@@ -137,10 +288,10 @@ fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
}
}
if lightcraft_pipeline::profiling() {
eprintln!("[profile] ARW holdout RMS {:.5} -> {:.5} ({samples} channels)", (before / samples as f64).sqrt(), (after / samples as f64).sqrt());
eprintln!("[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels)", (before / samples as f64).sqrt(), (after / samples as f64).sqrt());
}
if !after.is_finite() || samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) {
return None;
return reject("held-out error gate");
}
Some(CameraLook { matrix, tone: curve })
}
+38 -3
View File
@@ -131,11 +131,13 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
std::mem::swap(&mut w, &mut h);
}
let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw));
// Vendor RGB multipliers do not identify an absolute illuminant without camera calibration.
let relative = raw.format == lightcraft_raw::RawFormat::Arw && !lightcraft_raw::color::has_matrix(&raw.color);
// Vendor RGB multipliers do not identify an absolute illuminant without camera calibration
// (the same decoder fact as `CameraTransform::matrix_is_fallback`).
let relative = !lightcraft_raw::color::has_matrix(&raw.color);
let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) });
let embedded_lens = embedded_lens(&raw);
return Ok(ProbeInfo {
fallback_matrix: Some(relative),
embedded_lens,
width: w,
height: h,
@@ -178,6 +180,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
embedded_lens: None,
xmp: None,
preview_only: None,
fallback_matrix: None,
})
}
@@ -285,7 +288,9 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", "));
}
let (temp, tint) = xy_to_temp_tint(xy);
let relative = raw.format == lightcraft_raw::RawFormat::Arw && t.matrix_is_fallback;
// The catalog's record of this fact decides how saved WB settings are read (see
// `DecodedSource::info_or`); this is the decoder's view of a file nobody catalogued.
let relative = t.matrix_is_fallback;
let camera_tone = camera_look.map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone }));
@@ -516,6 +521,36 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Whether a raw's white balance is relative comes from the decoder (no colour matrix), not
/// from its extension, and import records it on the photo.
#[test]
fn import_records_whether_the_raw_has_a_colour_matrix() {
let with = crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default());
let mut raw = lightcraft_raw::decode(&with).unwrap();
raw.color.color_matrix = [None, None];
raw.color.as_shot_neutral = None;
raw.wb_multipliers = Some([2.0, 1.0, 1.5]);
let without = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
let p = probe_bytes("a.dng", &with).unwrap();
assert_eq!(p.fallback_matrix, Some(false));
assert_ne!(p.as_shot_wb, Some((6500.0, 0.0)));
let p = probe_bytes("a.dng", &without).unwrap();
assert_eq!((p.fallback_matrix, p.as_shot_wb), (Some(true), Some((6500.0, 0.0))));
let (_, info) = load_bytes(&without, 64).unwrap();
assert!(info.relative_wb);
assert!(!load_bytes(&with, 64).unwrap().1.relative_wb);
let dir = std::env::temp_dir().join(format!("lc-fallback-matrix-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("no-matrix.dng"), &without).unwrap();
let mut s = crate::Session::new().with_fs();
let r = s.execute("library.import", &serde_json::json!({"paths": [dir.to_string_lossy()]})).unwrap();
let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap());
let p = s.catalog.photo(id).unwrap();
assert_eq!(p.fallback_matrix, Some(true));
assert!(p.relative_wb() && crate::media::source_info(p).relative_wb);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn quick_jobs_show_embedded_previews_then_cached_renders() {
use crate::media::QuickSource;
+2
View File
@@ -876,6 +876,7 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
p.content_hash = info.content_hash;
p.embedded_lens = info.embedded_lens;
p.preview_only = info.preview_only.clone();
p.fallback_matrix = info.fallback_matrix;
apply_import_defaults(s, &mut p);
if let Some(sc) = &sidecar {
crate::sidecar::merge_into(&mut p, sc, &now);
@@ -986,6 +987,7 @@ impl Session {
p.as_shot_wb = info.as_shot_wb;
p.embedded_lens = info.embedded_lens;
p.preview_only = info.preview_only.clone();
p.fallback_matrix = info.fallback_matrix;
}
p.develop = std::sync::Arc::new(p.import_defaults());
let edge = edge.clamp(64, crate::media::SourceLevel::Thumb.max_edge());
+36 -4
View File
@@ -25,8 +25,9 @@ use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache};
use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
use serde::{Deserialize, Serialize};
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 7;
/// Bump when the pipeline's output changes, to invalidate cached thumbnails. A new camera-look fit
/// ([`crate::camera_preview::FIT_VERSION`]) invalidates them by itself.
pub const RENDER_CACHE_VERSION: u64 = 8 | (crate::camera_preview::FIT_VERSION << 32);
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
@@ -87,9 +88,18 @@ impl DecodedSource {
}
/// What to render these pixels against: the decoder's facts, else `header` (the catalog's)
/// with any stored camera tone curve.
/// with any stored camera tone curve. For a catalogued raw (`header.raw`) the catalog keeps
/// deciding how white balance settings are read — relative or absolute, around which as-shot
/// white — because the photo's saved settings were made under that rule
/// ([`lightcraft_catalog::Photo::relative_wb`]).
pub fn info_or(&self, header: SourceInfo) -> SourceInfo {
self.info.unwrap_or(SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header })
match self.info {
Some(info) if info.raw && header.raw => {
SourceInfo { relative_wb: header.relative_wb, as_shot_temp: header.as_shot_temp, as_shot_tint: header.as_shot_tint, ..info }
}
Some(info) => info,
None => SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header },
}
}
}
@@ -841,6 +851,8 @@ pub struct ProbeInfo {
/// A raw variant that can't be decoded yet: why. The file is described (and will be shown and
/// edited) from its embedded preview; see [`lightcraft_catalog::Photo::preview_only`].
pub preview_only: Option<String>,
/// Raws: the decoder has no colour matrix for the file ([`lightcraft_catalog::Photo::fallback_matrix`]).
pub fallback_matrix: Option<bool>,
}
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;
@@ -849,6 +861,26 @@ pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync
mod tests {
use super::*;
/// The decoder says a NEF has no matrix (relative WB), but a photo catalogued before that
/// fact was recorded was edited under absolute WB: the catalog's rule wins, the decoder's
/// pixels and camera tone are used.
#[test]
fn catalog_decides_how_raw_white_balance_is_read() {
let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| [0.01 * (i + 1) as f32, 0.02 * (i + 1) as f32])).unwrap();
let decoded = SourceInfo { raw: true, relative_wb: true, camera_tone: Some(tone), ..Default::default() };
let source = DecodedSource::new(Arc::new(Rgb32f::new(2, 2)), Some(decoded));
let mut p = Photo::new(PhotoId(1), Source::File { path: "/x/a.nef".into() }, "a.nef", "NEF", 2, 2, "");
p.kind = MediaKind::Raw;
p.as_shot_wb = Some((5100.0, 3.0));
let info = source.info_or(source_info(&p));
assert_eq!((info.relative_wb, info.as_shot_temp, info.as_shot_tint, info.camera_tone), (false, 5100.0, 3.0, Some(tone)));
p.fallback_matrix = Some(true);
let info = source.info_or(source_info(&p));
assert_eq!((info.relative_wb, info.as_shot_temp, info.as_shot_tint), (true, 6500.0, 0.0));
// A file with no catalog record (header not raw) keeps the decoder's view.
assert_eq!(source.info_or(SourceInfo::default()), decoded);
}
#[test]
fn decoder_info_survives_render_jobs_cache_and_eviction() {
let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| {