From 80e58ed55becdde80ed86ccbd8cd9d38e589f8e5 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Sun, 11 Oct 2026 09:30:32 +1100 Subject: [PATCH] engine: lens profile corrections from a lensfun-format database Raws without an embedded lens correction look their EXIF lens up in the user-supplied database; the profile goes through the existing Enable Profile Corrections path (amount sliders, CPU/GPU geometry stage, DNG export). Synthetic-grid test renders a distorted chart through the pipeline. Sources: own design (see lightcraft-lensdb for the model sources). Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + crates/engine/Cargo.toml | 1 + crates/engine/src/files.rs | 4 +- crates/engine/src/lens_profiles.rs | 160 +++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/pipeline/src/optics.rs | 4 +- docs/lens-profiles.md | 45 ++++++++ 7 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/lens_profiles.rs create mode 100644 docs/lens-profiles.md diff --git a/Cargo.lock b/Cargo.lock index 3442229c..899cdca4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3213,6 +3213,7 @@ dependencies = [ "lightcraft-geom", "lightcraft-gpu", "lightcraft-heif", + "lightcraft-lensdb", "lightcraft-merge", "lightcraft-meta", "lightcraft-pipeline", diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 4036a5c9..f0a8a533 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -24,6 +24,7 @@ lightcraft-raw = { workspace = true } lightcraft-denoise-core = { workspace = true } lightcraft-denoise = { workspace = true, optional = true } lightcraft-meta = { workspace = true } +lightcraft-lensdb = { workspace = true } lightcraft-faces = { workspace = true } miniz_oxide = { workspace = true } lightcraft-merge = { workspace = true } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 31253f66..aa04cde5 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -135,7 +135,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { // Vendor RGB multipliers do not identify an absolute illuminant without camera calibration. let relative = crate::camera_preview::file_local_look(raw.format) && !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); + let embedded_lens = crate::lens_profiles::for_raw(&raw, || m.clone()); return Ok(ProbeInfo { embedded_lens, width: w, @@ -365,7 +365,7 @@ fn load_bytes_now( // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in // (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`). let info = raw.info(); - let lens = embedded_lens(&info); + let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes)); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); // Without colour matrices of its own, white balance is relative to the as-shot look (`Photo::relative_wb`). let own_matrix = lightcraft_raw::color::has_matrix(&raw.color); diff --git a/crates/engine/src/lens_profiles.rs b/crates/engine/src/lens_profiles.rs new file mode 100644 index 00000000..8a3d846f --- /dev/null +++ b/crates/engine/src/lens_profiles.rs @@ -0,0 +1,160 @@ +//! Lens profile corrections from a lens database the user supplies ([`lightcraft_lensdb`]). +//! +//! When a raw carries no lens correction of its own (DNG opcodes, or a camera correction a raw reader converts, +//! [`crate::files::embedded_lens`]), the lens named in its EXIF is looked up in a lensfun-format database: the +//! directory in `LIGHTCRAFT_LENSFUN_DIR` (e.g. lensfun's `data/db`, CC-BY-SA, which is never bundled with the app). +//! The profile is converted to the same [`lightcraft_develop::EmbeddedLens`] form, so "Enable Profile Corrections", its +//! amount sliders, the GPU path and DNG export treat both alike. Without the variable nothing changes. +use std::path::PathBuf; +use std::sync::OnceLock; + +use lightcraft_develop::EmbeddedLens; +use lightcraft_lensdb::{LensDb, Shot}; + +/// The environment variable naming the lens database directory. +pub const ENV: &str = "LIGHTCRAFT_LENSFUN_DIR"; + +/// The configured database, read once per process; `None` when none is configured or it holds no lenses. +pub fn database() -> Option<&'static LensDb> { + static DB: OnceLock> = OnceLock::new(); + DB.get_or_init(|| { + let dir = PathBuf::from(std::env::var_os(ENV)?); + match LensDb::load_dir(&dir) { + Ok((db, errors)) => { + for (file, e) in errors { + log::warn!("lens database: skipped {file}: {e}"); + } + (!db.lenses.is_empty()).then_some(db) + } + Err(e) => { + log::warn!("{e}"); + None + } + } + }) + .as_ref() +} + +/// The database profile for a photo with metadata `m`, for its `w × h` (unoriented, default-cropped) image, +/// re-expressed after `orientation`. `None` without a database, lens name, focal length or matching profile. +pub fn lookup(db: &LensDb, m: &lightcraft_meta::Metadata, w: f64, h: f64, orientation: lightcraft_geom::Orientation) -> Option { + let focal = m.focal_length.filter(|f| f.is_finite() && *f > 0.0)?; + let camera = db.find_camera(m.make.as_deref().unwrap_or_default(), m.model.as_deref().unwrap_or_default()); + let lens = db.find_lens(m.lens_make.as_deref(), m.lens_model.as_deref()?, camera)?; + // the crop factor: the camera's, else what the 35 mm equivalent focal length implies + let crop = + camera.map(|c| c.crop_factor).or_else(|| m.focal_length_35mm.map(|f35| f35 / focal).filter(|c| c.is_finite() && (0.1..=20.0).contains(c))); + let shot = Shot { focal, aperture: m.f_number, distance: None, crop_factor: crop, width: w, height: h }; + let corr = lens.correction(&shot)?; + Some(lightcraft_pipeline::optics::reorient_lens(&corr, orientation, w, h)) +} + +/// [`crate::files::embedded_lens`], else the database profile ([`lookup`]) for a raw with metadata `meta`. +pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_meta::Metadata) -> Option { + crate::files::embedded_lens(raw).or_else(|| { + let db = database()?; + let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height); + let (w, h) = if c.width == 0 || c.height == 0 { (raw.active_area.width, raw.active_area.height) } else { (c.width, c.height) }; + lookup(db, &meta(), w as f64, h as f64, raw.orientation) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_geom::Orientation; + + const DB: &str = r#" + TestcamTC-1T1.5 + TestcamTestcam 18-55mm f/3.5-5.6T1.5 + + + + + "#; + + fn meta() -> lightcraft_meta::Metadata { + lightcraft_meta::Metadata { + make: Some("Testcam".into()), + model: Some("TC-1".into()), + lens_model: Some("18-55mm F3.5-5.6".into()), + focal_length: Some(18.0), + f_number: Some(3.5), + ..Default::default() + } + } + + #[test] + fn exif_lens_is_found_and_converted() { + let db = LensDb::parse(DB).unwrap(); + let lens = lookup(&db, &meta(), 6000.0, 4000.0, Orientation::Normal).unwrap(); + assert!(lens.warp.is_some() && lens.vignette.is_some()); + let rotated = lookup(&db, &meta(), 6000.0, 4000.0, Orientation::Rotate90).unwrap(); + assert_eq!(rotated.warp.unwrap().center, lightcraft_geom::Point::new(0.5, 0.5)); + // no lens name, no focal length, an unknown lens: nothing + assert!(lookup(&db, &lightcraft_meta::Metadata { lens_model: None, ..meta() }, 6000.0, 4000.0, Orientation::Normal).is_none()); + assert!(lookup(&db, &lightcraft_meta::Metadata { focal_length: None, ..meta() }, 6000.0, 4000.0, Orientation::Normal).is_none()); + let other = lightcraft_meta::Metadata { lens_model: Some("70-200mm F4".into()), ..meta() }; + assert!(lookup(&db, &other, 6000.0, 4000.0, Orientation::Normal).is_none()); + } + + /// A synthetic grid recorded through the profile's barrel distortion comes out of the pipeline's geometry + /// stage with straight lines. + #[test] + fn pipeline_straightens_a_distorted_grid() { + let db = LensDb::parse(DB).unwrap(); + let profile = &db.lenses[0]; + let (w, h) = (600usize, 400usize); + let lens = lookup(&db, &meta(), w as f64, h as f64, Orientation::Normal).unwrap(); + // record the grid: each sensor pixel sees the scene point whose distorted position it is + let n = h as f64 / 2.0; + let undistort = |x: f64, y: f64| { + let (dx, dy) = ((x - w as f64 / 2.0) / n, (y - h as f64 / 2.0) / n); + let rd = dx.hypot(dy); + let (mut lo, mut hi) = (0.0, 3.0); + for _ in 0..60 { + let mid = (lo + hi) / 2.0; + if mid * profile.distortion_ratio(18.0, mid) < rd { + lo = mid; + } else { + hi = mid; + } + } + let s = if rd > 0.0 { lo / rd } else { 1.0 }; + (w as f64 / 2.0 + dx * s * n, h as f64 / 2.0 + dy * s * n) + }; + // horizontal scene lines every 50 px (2 px wide, dark on light) + let line = |v: f64| if v.rem_euclid(50.0) < 2.0 { 0.02f32 } else { 0.8 }; + let src = lightcraft_raster::Rgb32f::from_fn(w, h, |x, y| { + let (_, sy) = undistort(x as f64 + 0.5, y as f64 + 0.5); + [line(sy); 3] + }); + // the dark row nearest y = 51 in each column, sub-pixel (centroid of darkness), as rendered + let line_rows = |img: &lightcraft_raster::Rgba8| -> Vec { + (20..w - 20) + .step_by(10) + .map(|x| { + let (mut sw, mut swy) = (0.0, 0.0); + for y in 35..70 { + let d = 255.0 - img.get(x, y)[1] as f64; + if d > 60.0 { + sw += d; + swy += d * (y as f64 + 0.5); + } + } + swy / sw.max(1e-9) + }) + .collect() + }; + let spread = |rows: &[f64]| rows.iter().cloned().fold(f64::MIN, f64::max) - rows.iter().cloned().fold(f64::MAX, f64::min); + let req = lightcraft_pipeline::RenderRequest::fit(w, h); + let mut s = lightcraft_develop::DevelopSettings::default(); + let info = lightcraft_pipeline::SourceInfo { lens: Some(lens), ..Default::default() }; + let before = spread(&line_rows(&lightcraft_pipeline::render(&src, &info, &s, &req).image)); + s.optics.lens_profile = true; + let after = spread(&line_rows(&lightcraft_pipeline::render(&src, &info, &s, &req).image)); + assert!(before > 2.0, "the recorded line is curved: {before} px"); + // 8-bit output and a 2 px line: the centroid measurement itself is good to about a pixel + assert!(after < 1.0 && after < before / 8.0, "corrected line bent by {after} px (uncorrected {before})"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 66acfe4a..401d1452 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -38,6 +38,7 @@ pub mod fonts; pub mod guard; pub mod import; mod import_move; +pub mod lens_profiles; pub mod library; mod lightroom_archive; pub mod lightroom_catalog; diff --git a/crates/pipeline/src/optics.rs b/crates/pipeline/src/optics.rs index f99a3cd8..f495532e 100644 --- a/crates/pipeline/src/optics.rs +++ b/crates/pipeline/src/optics.rs @@ -11,8 +11,8 @@ //! dimensionless scales), so a 400 px preview and a full-size export are warped identically. //! //! Also here: automatic lateral chromatic aberration estimation ([`estimate_lateral_ca`]) and [`defringe`]. -//! Profile corrections use only lens data embedded in the files: DNG `WarpRectilinear` / `FixVignetteRadial`, and the -//! raw readers' equivalent of a camera's own correction (Panasonic / Leica RW2 distortion). +//! Profile corrections use lens data embedded in the files (DNG `WarpRectilinear` / `FixVignetteRadial`, and the +//! raw readers' equivalent of a camera's own correction), or a lens database profile in the same form (`lightcraft_lensdb`). use std::sync::Mutex; diff --git a/docs/lens-profiles.md b/docs/lens-profiles.md new file mode 100644 index 00000000..3503699e --- /dev/null +++ b/docs/lens-profiles.md @@ -0,0 +1,45 @@ +# Lens profiles from a lens database + +Raws that carry no lens correction of their own (DNG `OpcodeList3`, or a camera correction a raw reader converts, +see [sony-lens-corrections.md](sony-lens-corrections.md)) can be corrected from a **lens database in the lensfun +XML format** that you supply. The app does not ship any lens database: lensfun's data is licensed CC-BY-SA 3.0 and +stays a separate download. Adobe LCP profiles are never used. + +## Setup + +1. Get the lensfun database (the `data/db/*.xml` files of , or the copy a Linux + distribution installs under `/usr/share/lensfun/version_1/`). +2. Point `LIGHTCRAFT_LENSFUN_DIR` at that directory and start the app (or the CLI). + +Files are read once per process. Invalid files are skipped with a warning in the log. Without the variable nothing +changes. + +## What is applied + +The lens is identified from EXIF: camera make / model (for the mount and crop factor; the 35 mm equivalent focal +length is used when the camera isn't listed) and the lens model string. Lens names match on their words; every +number (focal lengths, apertures) in the database name must appear in the EXIF name, and the lens maker must agree +when the file names one. + +| Correction | Models | Interpolated over | +|---|---|---| +| Distortion | `poly3`, `poly5`, `ptlens` | focal length | +| Lateral CA (TCA) | `linear`, `poly3` | focal length | +| Vignetting | `pa` | focal length, aperture (in stops), focus distance (nearest calibrated, by 1/d; infinity when unknown) | + +Fisheye and other non-rectilinear lenses get no distortion correction. The profile is converted by least squares +into the DNG `WarpRectilinear` / `FixVignetteRadial` polynomials, so it goes through the same path as embedded +corrections: **Enable Profile Corrections** and its Distortion / Vignetting amounts, the CPU and GPU geometry +stage (one resample), orientation handling and DNG export. A conversion whose radial error exceeds 0.001 of the +half diagonal, or that would fold the image, is not applied. + +Normalisation follows lensfun's documentation: distortion and TCA radii are 1 at half the shorter side of the +calibration frame (its `cropfactor` and `aspect-ratio`), vignetting radii 1 at its corner; the optical centre is +the image centre (lensfun's `
` offsets are not read). + +## Limits + +- Only raws: rendered images (JPEG, HEIF, TIFF) are often already corrected in camera. +- The focus distance is not read from maker notes yet: vignetting uses the farthest calibrated distance. +- No manual make / model picker yet; matching is automatic. +- Not yet validated against real photos and another renderer; the tests use synthetic profiles and grids.