collective

Browse / github.com/storytold/lightcraft / patch

LR-EDIT-OPTICS-PROFILE: lens profiles for compact cameras with a built-in lens

LR-EDIT-OPTICS-PROFILE: lens profiles for compact cameras with a built-in lens ## Problem Compact cameras with a built-in lens (e.g. Canon PowerShot S5 IS) never got a lens profile from a lensfun-format database: lensfun names the lens after the camera and gives it the camera's own mount, while the file's EXIF only carries a focal/aperture range such as `6.0-72.0 mm f/2.7-3.5`, which matches no lens name. Camera lookup also failed when one side included the maker in the model name (`Canon PowerShot S5 IS` vs `PowerShot S5 IS`). ## Change (`crates/lensdb`) - `find_lens` falls back to `find_fixed_lens` when no name matches and a camera is known: candidates are profiles on

Status
open
Score
0.00
Adopters
0 other operators
Author
emmertex (imported from pull request #3013, open)
Base
387bed199fca44d76af5aa1c24dd7cc3de4c8db2
Size
+147 −3 in 2 files
Review
pending
Shared
2026-10-28
Id
e3af02118811774491503cff

How it went

No other operator has reported using it yet.

Diff

raw · crates/lensdb/src/lib.rs crates/lensdb/src/tests.rs

diff --git a/crates/lensdb/src/lib.rs b/crates/lensdb/src/lib.rs--- a/crates/lensdb/src/lib.rs+++ b/crates/lensdb/src/lib.rs@@ -159,17 +159,24 @@ impl LensDb {      /// The camera with this EXIF make and model.     pub fn find_camera(&self, make: &str, model: &str) -> Option<&Camera> {-        let (mk, md) = (tokens(make), tokens(model));+        let mk = tokens(make);+        let raw = tokens(model);+        let md = strip_maker(raw.clone(), &mk);         if md.is_empty() {             return None;         }-        self.cameras.iter().find(|c| tokens(&c.model) == md && maker_matches(&tokens(&c.maker), &mk))+        self.cameras.iter().find(|c| {+            let cm = tokens(&c.maker);+            // with no EXIF make, the model may still start with the maker's word+            let md = if mk.is_empty() { strip_maker(raw.clone(), &cm) } else { md.clone() };+            maker_matches(&cm, &mk) && strip_maker(tokens(&c.model), &cm) == md+        })     }      /// The best profile for an EXIF lens model (and lens make, when the file has one), preferring lenses for the     /// camera's mount. `None` when no name is a convincing match.     pub fn find_lens(&self, lens_make: Option<&str>, lens_model: &str, camera: Option<&Camera>) -> Option<&Profile> {-        self.best_lens(lens_make, lens_model, camera, |_| true)+        self.best_lens(lens_make, lens_model, camera, |_| true).or_else(|| self.find_fixed_lens(&tokens(lens_model), camera?))     }      /// [`find_lens`](Self::find_lens) among the profiles one of whose names is exactly `name`@@ -204,6 +211,64 @@ impl LensDb {         }         best.map(|(_, l)| l)     }++    /// Compact cameras with a built-in lens: lensfun names the lens after the camera and gives it the camera's own+    /// mount, while EXIF reports only a focal / aperture range ("6.0-72.0 mm f/2.7-3.5"). Match by mount, then by+    /// how much of the calibrated focal range lies inside the EXIF focal range.+    fn find_fixed_lens(&self, exif: &[String], cam: &Camera) -> Option<&Profile> {+        if cam.mount.is_empty() {+            return None;+        }+        let cands: Vec<&Profile> = self+            .lenses+            .iter()+            .filter(|l| l.mounts.iter().any(|m| m.eq_ignore_ascii_case(&cam.mount)))+            .filter(|l| !l.models.iter().any(|m| tokens(m).iter().any(|t| t == "mm")))+            .collect();+        // an interchangeable mount has many lenses; a built-in lens has one or a few profiles+        if cands.is_empty() || cands.len() > MAX_FIXED_CANDIDATES {+            return None;+        }+        let Some((lo, hi)) = exif_focal_range(exif) else {+            return (cands.len() == 1).then(|| cands[0]);+        };+        let (lo, hi) = (lo * 0.9, hi * 1.1);+        if !lo.is_finite() || !hi.is_finite() {+            return None;+        }+        let mut best: Option<(f64, &Profile)> = None;+        for p in cands {+            let focals: Vec<f64> =+                p.distortion.iter().map(|d| d.0).chain(p.tca.iter().map(|t| t.0)).chain(p.vignetting.iter().map(|v| v.focal)).collect();+            if focals.is_empty() {+                continue;+            }+            let score = focals.iter().filter(|f| (lo..=hi).contains(*f)).count() as f64 / focals.len() as f64;+            if score > 0.0 && best.is_none_or(|(s, _)| score > s) {+                best = Some((score, p));+            }+        }+        best.map(|(_, p)| p)+    }+}++/// Most profiles a camera's mount may have for it to count as a built-in-lens mount.+const MAX_FIXED_CANDIDATES: usize = 4;++/// Drop the maker's words from a model name ("Canon PowerShot S5 IS" → "powershot s5 is").+fn strip_maker(model: Vec<String>, maker: &[String]) -> Vec<String> {+    let first = maker.first();+    let stripped: Vec<String> = model.iter().filter(|t| Some(*t) != first).cloned().collect();+    if stripped.is_empty() { model } else { stripped }+}++/// The numbers before the first "mm" in tokenised EXIF lens text: (shortest, longest) focal length.+fn exif_focal_range(exif: &[String]) -> Option<(f64, f64)> {+    let end = exif.iter().position(|t| t == "mm")?;+    let nums: Vec<f64> = exif.get(..end)?.iter().filter_map(|t| t.parse::<f64>().ok()).filter(|v| v.is_finite() && *v > 0.0).collect();+    let lo = nums.iter().copied().fold(f64::INFINITY, f64::min);+    let hi = nums.iter().copied().fold(0.0, f64::max);+    (lo.is_finite() && hi > 0.0).then_some((lo, hi)) }  fn read_capped(path: &Path) -> Result<String, Error> {diff --git a/crates/lensdb/src/tests.rs b/crates/lensdb/src/tests.rs--- a/crates/lensdb/src/tests.rs+++ b/crates/lensdb/src/tests.rs@@ -287,3 +287,82 @@ fn a_named_profile_is_found_behind_a_tie() {     assert_eq!(db.find_lens_named(None, &name, None).unwrap().maker, "Testcam");     assert!(db.find_lens_named(None, "70-200mm f/4", None).is_none()); }++#[test]+fn built_in_lens_matches_by_camera_mount_and_focal_range() {+    let xml = r#"<lensdatabase>+  <camera><maker>Canon</maker><model>Canon PowerShot S5 IS</model><model lang="en">PowerShot S5 IS</model><mount>canonS5</mount><cropfactor>6.0</cropfactor></camera>+  <lens><maker>Canon</maker><model>Canon PowerShot S5 IS &amp; compatibles</model><mount>canonS5</mount><cropfactor>6.0</cropfactor>+    <calibration><distortion model="poly3" focal="6" k1="0.01" /><distortion model="poly3" focal="72" k1="0.0" /></calibration></lens>+  <lens><maker>Canon</maker><model>Canon EF 50mm f/1.8</model><mount>Canon EF</mount>+    <calibration><distortion model="poly3" focal="50" k1="0.01" /></calibration></lens>+</lensdatabase>"#;+    let db = LensDb::parse(xml).unwrap();+    let cam = db.find_camera("Canon", "Canon PowerShot S5 IS");+    assert!(cam.is_some());+    assert!(db.find_camera("Canon", "PowerShot S5 IS").is_some());+    let l = db.find_lens(None, "6.0-72.0 mm f/2.7-3.5", cam).unwrap();+    assert_eq!(l.mounts, ["canonS5"]);+    assert!(db.find_lens(None, "6.0-72.0 mm f/2.7-3.5", None).is_none());+    // a file with no make still finds the camera by its full model name+    assert!(db.find_camera("", "Canon PowerShot S5 IS").is_some());+    // the exact-name lookup never falls back to the camera's mount+    assert!(db.find_lens_named(None, "6.0-72.0 mm f/2.7-3.5", cam).is_none());+    // a focal length that overflows to infinity is ignored, not taken as an unbounded range+    let huge = format!("6.0-{}9 mm f/2.7-3.5", "9".repeat(400));+    assert_eq!(db.find_lens(None, &huge, cam).unwrap().mounts, ["canonS5"]);+    assert_eq!(exif_focal_range(&tokens(&huge)), Some((6.0, 6.0)));+}++/// A hand-edited or corrupt database may carry any number: infinities, NaN, enormous or negative+/// coefficients, absurd crop factors and aspect ratios. Whatever loads must give corrections that+/// are `None` or entirely finite, for any shot including nonsense ones; names of any shape find+/// nothing rather than panic.+#[test]+fn hostile_numbers_give_finite_corrections_or_none() {+    let values = ["inf", "-inf", "NaN", "1e300", "-1e300", "1e-300", "0", "-1", "1e6", "99999"];+    let mut cases = 0;+    for v in values {+        for xml in [+            format!(+                r#"<lens><maker>M</maker><model>M 1</model><cropfactor>{v}</cropfactor><aspect-ratio>{v}</aspect-ratio><calibration><distortion model="poly3" focal="24" k1="0.01"/><vignetting model="pa" focal="24" aperture="2.8" k1="-0.3"/></calibration></lens>"#+            ),+            format!(+                r#"<lens><maker>M</maker><model>M 1</model><calibration cropfactor="{v}" aspect-ratio="{v}"><distortion model="ptlens" focal="24" a="{v}" b="{v}" c="{v}"/><tca model="poly3" focal="24" vr="{v}" vb="{v}" cr="{v}" cb="{v}" br="{v}" bb="{v}"/></calibration></lens>"#+            ),+            format!(+                r#"<lens><maker>M</maker><model>M 1</model><calibration><distortion model="poly5" focal="{v}" k1="{v}" k2="{v}"/><distortion model="poly3" focal="50" k1="{v}"/><vignetting model="pa" focal="{v}" aperture="{v}" distance="{v}" k1="{v}" k2="{v}" k3="{v}"/><tca model="linear" focal="{v}" kr="{v}" kb="{v}"/></calibration></lens>"#+            ),+        ] {+            let Ok(db) = LensDb::parse(&format!("<lensdatabase>{xml}</lensdatabase>")) else { continue };+            for p in &db.lenses {+                for focal in [f64::NAN, f64::INFINITY, -1.0, 0.0, 1e-9, 8.0, 24.0, 50.0, 1e9] {+                    for (w, h) in [(0.0, 0.0), (1.0, 1.0), (2.0, 2.0), (6000.0, 4000.0), (1e9, 1.0), (f64::NAN, 10.0), (f64::INFINITY, 4000.0)] {+                        for aperture in [None, Some(f64::NAN), Some(-2.0), Some(0.0), Some(2.8), Some(1e9)] {+                            for crop in [None, Some(0.0), Some(f64::NAN), Some(-1.0), Some(1.5), Some(1e9)] {+                                let shot = Shot { focal, aperture, distance: Some(f64::NAN), crop_factor: crop, width: w, height: h };+                                cases += 1;+                                let Some(c) = p.correction(&shot) else { continue };+                                let planes =+                                    c.warp.iter().flat_map(|wp| wp.planes.iter().flatten().copied().chain([wp.radius, wp.center.x, wp.center.y]));+                                let vig = c.vignette.iter().flat_map(|vg| vg.k.iter().copied().chain([vg.radius, vg.center.x, vg.center.y]));+                                assert!(planes.chain(vig).all(f64::is_finite), "{v}: focal {focal} {w}x{h} {aperture:?} {crop:?}");+                            }+                        }+                    }+                }+            }+        }+    }+    assert!(cases > 1000);++    let db = db();+    let long = "x".repeat(100_000);+    for s in ["", " ", "\t\n", "\0", "ß İ Dž", "....", "1.2.3.4mm", "-mm", long.as_str()] {+        let _ = db.find_camera(s, s);+        let _ = db.find_lens(Some(s), s, db.cameras.first());+        let _ = db.find_lens(None, s, None);+    }+    assert!(db.find_camera("", "").is_none());+    assert!(db.find_lens(None, "  ", None).is_none());+}