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
@@ -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> {@@ -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 & 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());+}