//! Lens profile lookup — the Lensfun database, reduced to coefficients. //! //! # What this crate is for //! //! `dr-pipeline` knows the *maths* of lens correction: the `ptlens` //! polynomial, the `poly3` per-channel scale, the `pa` vignetting curve. It //! does not know which coefficients belong to which lens, and deliberately //! has no dependencies with which to find out. //! //! This crate closes that gap. Given what EXIF reports — a lens name, a focal //! length, an aperture — it returns the coefficients for that shot, and //! nothing else. The pipeline consumes plain `f32`s and never links the //! database. //! //! # Why a separate crate rather than a module //! //! Two reasons, both about keeping a dependency contained: //! //! - **`dr-pipeline` has no dependencies on purpose.** Its codegen is testable //! without a GPU, and adding an XML parser plus 5.5 MB of profile data to it //! would cost exactly the property it is organised around (ARCH §6.5a). //! - **The `lensfun` crate is a third-party port**, not upstream Lensfun. //! Confining it behind [`LensProfile`] means replacing it — with the C //! library, with our own parser, with vendor-supplied profiles — touches //! this crate and nothing downstream. //! //! # Matching is best-effort, and says so //! //! EXIF lens names are not clean identifiers. Different bodies report the same //! lens differently, third-party lenses often report nothing, and adapted //! manual lenses report nothing at all. So every lookup returns an `Option`, //! and the UI is expected to say plainly whether a profile was found — an //! automatic correction that silently did nothing is worse than one the user //! can see is unavailable (ARCH §9.4 applies the same honesty rule to sync). use std::sync::OnceLock; /// The `ptlens` distortion coefficients, as Lensfun stores them. /// /// Mirrors `dr_pipeline::ops::distortion::PtLens`. Duplicated rather than /// shared because the dependency would have to run the wrong way: the /// pipeline must not depend on the profile database. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Distortion { pub a: f32, pub b: f32, pub c: f32, } /// Lateral chromatic aberration: a per-channel radial scale. /// /// Red and blue are scaled about the optical axis; green is the reference and /// is never moved, so a correction that is wrong still leaves the image /// sharp in one channel rather than softening all three. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Tca { pub red_scale: f32, pub blue_scale: f32, } /// The `pa` vignetting polynomial: `1 + k1·r² + k2·r⁴ + k3·r⁶`. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Vignetting { pub k1: f32, pub k2: f32, pub k3: f32, } /// Everything known about one lens at one set of shooting parameters. /// /// Each field is independently optional: the Lensfun database frequently /// carries distortion for a lens but no vignetting, or covers only part of a /// zoom's range. A partial profile is useful and must not be discarded. #[derive(Debug, Clone, Copy, PartialEq, Default)] pub struct LensProfile { pub distortion: Option, pub tca: Option, pub vignetting: Option, } impl LensProfile { /// Whether this profile carries anything at all. pub fn is_empty(&self) -> bool { self.distortion.is_none() && self.tca.is_none() && self.vignetting.is_none() } } /// What EXIF reports about a shot, as far as lens correction cares. #[derive(Debug, Clone, PartialEq)] pub struct ShotInfo<'a> { /// The lens model string. Frequently absent or unhelpful. pub lens: &'a str, /// Focal length in mm. Selects between a zoom's calibration points. pub focal_length: f32, /// Aperture as an f-number. Vignetting depends on it strongly — a lens /// wide open can be two stops down in the corners and clean by f/8. pub aperture: f32, /// Focus distance in metres, when known. Vignetting varies with it, but /// EXIF rarely reports it, so the default stands in for "far away". pub distance: f32, } impl<'a> ShotInfo<'a> { /// The distance Lensfun's calibrations use for "not a close-up". /// /// Most database entries are measured at 1000 m — effectively infinity — /// and EXIF almost never carries focus distance, so this is the value /// nearly every lookup uses. pub const FAR: f32 = 1000.0; pub fn new(lens: &'a str, focal_length: f32, aperture: f32) -> Self { Self { lens, focal_length, aperture, distance: Self::FAR, } } } /// The bundled Lensfun database, loaded once on first use. /// /// Decompressing 56 XML files costs enough to be worth doing once, and little /// enough not to be worth doing in the background. `OnceLock` rather than a /// constructor the callers must thread through: this is a read-only reference /// table, and making every call site own a handle to it would buy nothing. fn database() -> Option<&'static lensfun::Database> { static DB: OnceLock> = OnceLock::new(); DB.get_or_init(|| match lensfun::Database::load_bundled() { Ok(db) => Some(db), Err(e) => { // Not fatal. Manual correction still works, so the develop panel // stays usable with the automatic profile absent. log::warn!("lens profile database unavailable: {e}"); None } }) .as_ref() } /// Look up correction coefficients for a shot. /// /// Returns `None` when the lens is unknown — which is common and not an /// error. A blank lens name short-circuits, because matching on one returns /// arbitrary entries rather than no entries. pub fn lookup(shot: &ShotInfo<'_>) -> Option { if shot.lens.trim().is_empty() { return None; } let db = database()?; let matches = db.find_lenses(None, shot.lens); // The database returns candidates ranked by match quality; anything // beyond the best is a different lens that merely reads similarly. let lens = matches.first()?; let profile = LensProfile { distortion: lens .interpolate_distortion(shot.focal_length) .and_then(|d| match d.model { lensfun::DistortionModel::Ptlens { a, b, c } => Some(Distortion { a, b, c }), // Other models exist in the database (`poly3`, `fov1`). Rather // than approximate one with another — which would correct by // the wrong curve and look like a bad profile — report nothing // and let the manual control take over. _ => None, }), tca: lens .interpolate_tca(shot.focal_length) .and_then(|t| match t.model { lensfun::TcaModel::Poly3 { red, blue } => Some(Tca { // Index 0 is the linear radial scale `v`, which carries // essentially all of the correction; the higher terms are // zero throughout the database in practice. red_scale: red[0], blue_scale: blue[0], }), _ => None, }), vignetting: lens .interpolate_vignetting(shot.focal_length, shot.aperture, shot.distance) .and_then(|v| match v.model { lensfun::VignettingModel::Pa { k1, k2, k3 } => Some(Vignetting { k1, k2, k3 }), _ => None, }), }; // A match that yielded no usable coefficients is the same as no match, and // reporting it as a hit would tell the user a correction is active when // nothing is being corrected. (!profile.is_empty()).then_some(profile) } #[cfg(test)] mod tests { use super::*; /// A lens with dense calibration across a zoom range, so interpolation is /// genuinely exercised. const KNOWN: &str = "Canon EF 16-35mm f/2.8L USM"; #[test] fn the_bundled_database_loads() { // The property that makes this crate work on Android: no system // library, no data directory, no filesystem path (ARCH §6.9). assert!(database().is_some(), "the bundled database must load"); } #[test] fn a_known_lens_resolves() { let profile = lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).expect("a stocked lens"); assert!(profile.distortion.is_some(), "distortion expected"); assert!(!profile.is_empty()); } #[test] fn an_unknown_lens_is_none_rather_than_a_panic() { // Adapted and third-party lenses report names absent from the // database. That is the normal case, not an error. assert!(lookup(&ShotInfo::new("Nonexistent 999mm f/0.5", 50.0, 2.0)).is_none()); } #[test] fn a_blank_lens_name_does_not_match_arbitrarily() { // EXIF often carries no lens at all. Matching on an empty string // returns unrelated entries, which would silently apply another // lens's correction — worse than applying none. for name in ["", " "] { assert!( lookup(&ShotInfo::new(name, 50.0, 2.0)).is_none(), "{name:?}" ); } } #[test] fn coefficients_interpolate_between_calibration_points() { // 20mm and 22mm are calibrated; 21mm is not. If interpolation were // absent, a zoom would snap between corrections mid-range. let at20 = lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).and_then(|p| p.distortion); let at21 = lookup(&ShotInfo::new(KNOWN, 21.0, 2.8)).and_then(|p| p.distortion); let at22 = lookup(&ShotInfo::new(KNOWN, 22.0, 2.8)).and_then(|p| p.distortion); let (a, b, c) = (at20.unwrap(), at21.unwrap(), at22.unwrap()); assert_ne!(a, b, "21mm must not reuse the 20mm coefficients verbatim"); let between = (a.a.min(c.a)..=a.a.max(c.a)).contains(&b.a); assert!( between, "21mm coefficient {} is outside [{}, {}]", b.a, a.a, c.a ); } #[test] fn vignetting_responds_to_aperture() { // The reason aperture is in `ShotInfo` at all: a lens wide open // vignettes heavily and is clean stopped down, so a correction that // ignored aperture would be wrong at both ends. let wide = lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).and_then(|p| p.vignetting); let stopped = lookup(&ShotInfo::new(KNOWN, 20.0, 8.0)).and_then(|p| p.vignetting); if let (Some(w), Some(s)) = (wide, stopped) { assert_ne!(w, s, "aperture must change the vignetting curve"); } } #[test] fn tca_leaves_green_as_the_reference() { // Green is never scaled, so red and blue are corrected towards it. // Both scales sit within a fraction of a percent of 1.0; anything // far from that would be a unit error rather than a real correction. if let Some(tca) = lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).and_then(|p| p.tca) { for s in [tca.red_scale, tca.blue_scale] { assert!( (0.99..=1.01).contains(&s), "{s} is not a plausible per-channel scale" ); } } } #[test] fn a_far_focus_distance_is_the_default() { // EXIF rarely carries focus distance, so nearly every real lookup // relies on this standing in. assert_eq!(ShotInfo::new(KNOWN, 20.0, 2.8).distance, ShotInfo::FAR); } #[test] fn repeated_lookups_reuse_one_database() { // The database is decompressed on first use; a second lookup must not // pay for it again. assert!(lookup(&ShotInfo::new(KNOWN, 20.0, 2.8)).is_some()); assert!(lookup(&ShotInfo::new(KNOWN, 24.0, 4.0)).is_some()); assert!(std::ptr::eq( database().expect("loaded"), database().expect("loaded") )); } }