From 067f4fdf1e80227dab21a6cdf6f7025e21c647a6 Mon Sep 17 00:00:00 2001 From: nerdislb <202516400+nerdislb@users.noreply.github.com> Date: Sun, 18 Oct 2026 08:25:56 +0200 Subject: [PATCH] LR-EDIT-OPTICS-PROFILE: look up Nikon DSLR lenses by their lens specification Nikon DSLRs write no Exif LensModel, only the lens specification (maker note 0x0084, read into Metadata::lens_spec), so the lensfun-format lens database never corrected their raws. A raw without a lens model is now looked up by a name built from its specification ("24-70mm f/2.8", lightcraft_lensdb::spec_name), and a profile is used only when one of its names is exactly that (Profile::names_exactly): the specification fits every maker's lens with that range, so a partial match or a maker-named entry is never taken. lensfun's own database has no such names; a user aliases the one lens they own. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lens_profiles.rs | 31 +++++++++++++++++++++++++++++- crates/lensdb/src/lib.rs | 23 ++++++++++++++++++++++ crates/lensdb/src/tests.rs | 22 +++++++++++++++++++++ docs/lens-profiles.md | 9 +++++++++ docs/parity-checklist.md | 5 +++-- 5 files changed, 87 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/lens_profiles.rs b/crates/engine/src/lens_profiles.rs index 6338b3b40..65144d5b7 100644 --- a/crates/engine/src/lens_profiles.rs +++ b/crates/engine/src/lens_profiles.rs @@ -40,7 +40,15 @@ pub fn database() -> Option<&'static LensDb> { 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)?; + let lens = match m.lens_model.as_deref() { + Some(model) => db.find_lens(m.lens_make.as_deref(), model, camera)?, + // Nikon DSLRs record only the lens specification ("24-70mm f/2.8"); a profile must be named exactly that + // (a user's alias), since the specification fits other makers' lenses too + None => { + let name = lightcraft_lensdb::spec_name(m.lens_spec?); + db.find_lens(m.lens_make.as_deref(), &name, camera).filter(|l| l.names_exactly(&name))? + } + }; let crop = frame_crop_factor(camera.map(|c| c.crop_factor), focal, m.focal_length_35mm); let shot = Shot { focal, aperture: m.f_number, distance: None, crop_factor: crop, width: w, height: h }; let corr = lens.correction(&shot)?; @@ -108,6 +116,27 @@ mod tests { assert!(lookup(&db, &other, 6000.0, 4000.0, Orientation::Normal).is_none()); } + /// A Nikon DSLR raw names no lens, only its specification (maker note `0x0084`): it is looked up by the name + /// built from it, and only a profile named exactly that is used, never another maker's lens that merely shares + /// the focal range and aperture. + #[test] + fn lens_specification_needs_a_profile_named_by_it() { + let nikon = lightcraft_meta::Metadata { lens_model: None, lens_spec: Some([18.0, 55.0, 3.5, 5.6]), ..meta() }; + let at = |db: &LensDb| lookup(db, &nikon, 6000.0, 4000.0, Orientation::Normal); + // "Testcam 18-55mm f/3.5-5.6" fits the specification, but so would any maker's 18-55 f/3.5-5.6: refused + assert!(at(&LensDb::parse(DB).unwrap()).is_none()); + let aliased = LensDb::parse( + &DB.replace("Testcam 18-55mm f/3.5-5.6", "Testcam 18-55mm f/3.5-5.618-55mm f/3.5-5.6"), + ) + .unwrap(); + let lens = at(&aliased).unwrap(); + assert!(lens.warp.is_some() && lens.vignette.is_some()); + assert_eq!(Some(lens), lookup(&aliased, &meta(), 6000.0, 4000.0, Orientation::Normal), "same profile as by its name"); + // a lens model, when the file has one, still decides + let named_other = lightcraft_meta::Metadata { lens_model: Some("70-200mm F4".into()), ..nikon.clone() }; + assert!(lookup(&aliased, &named_other, 6000.0, 4000.0, Orientation::Normal).is_none()); + } + /// A full-frame body in APS-C / Super 35 mode (an ILCE-1M2 at 70 mm records 105 mm equivalent) frames only the /// middle of the image circle: the profile is applied at the frame's crop factor, not the body's, so the crop's /// corners don't get the full frame's corner correction. diff --git a/crates/lensdb/src/lib.rs b/crates/lensdb/src/lib.rs index 185ae09cc..cfdda2eaf 100644 --- a/crates/lensdb/src/lib.rs +++ b/crates/lensdb/src/lib.rs @@ -372,7 +372,30 @@ fn by_focal(points: &[(f64, T)], focal: f64, eval: impl Fn(&T) -> f64) Some(a + (b - a) * t) } +/// A lens name built from a lens specification (min / max focal length, f-number at each), for files that name +/// no lens model, only its specification (Nikon DSLRs' maker note): "24-70mm f/2.8", "18-55mm f/3.5-5.6", +/// "85mm f/1.8". Such a name says nothing about the maker, so [`Profile::names_exactly`] must confirm a match. +pub fn spec_name(spec: [f64; 4]) -> String { + fn num(v: f64) -> String { + let t = format!("{v:.1}"); + t.trim_end_matches('0').trim_end_matches('.').to_string() + } + fn range(a: f64, b: f64) -> String { + if (a - b).abs() < 0.05 { num(a) } else { format!("{}-{}", num(a), num(b)) } + } + format!("{}mm f/{}", range(spec[0], spec[1]), range(spec[2], spec[3])) +} + impl Profile { + /// Whether one of the profile's names is exactly `name` (same words and numbers, ignoring case and + /// punctuation): the check for names that carry no maker, such as [`spec_name`]s, where the best partial match + /// could be another maker's lens with the same focal range. The maker's words count too, so "Samyang 85mm f/1.4" + /// is not "85mm f/1.4"; only a name given for the purpose (a user's alias) matches. + pub fn names_exactly(&self, name: &str) -> bool { + let want = tokens(name); + !want.is_empty() && self.models.iter().any(|m| tokens(m) == want) + } + /// Distortion ratio `r_d / r_u` at lensfun radius `r` for `focal` (1 when the profile has none). pub fn distortion_ratio(&self, focal: f64, r: f64) -> f64 { if !self.rectilinear { diff --git a/crates/lensdb/src/tests.rs b/crates/lensdb/src/tests.rs index d15106931..f87193fbc 100644 --- a/crates/lensdb/src/tests.rs +++ b/crates/lensdb/src/tests.rs @@ -243,3 +243,25 @@ fn tokens_split_names_like_exif_writes_them() { assert_eq!(tokens("FE 24-105mm F4.0 G OSS"), ["fe", "24", "105", "mm", "f", "4", "g", "oss"]); assert_eq!(tokens("f/2.8"), ["f", "2.8"]); } + +#[test] +fn spec_names_read_like_lens_names() { + assert_eq!(spec_name([24.0, 70.0, 2.8, 2.8]), "24-70mm f/2.8"); + assert_eq!(spec_name([18.0, 55.0, 3.5, 5.6]), "18-55mm f/3.5-5.6"); + assert_eq!(spec_name([85.0, 85.0, 1.8, 1.8]), "85mm f/1.8"); + assert_eq!(spec_name([10.5, 10.5, 2.8, 2.8]), "10.5mm f/2.8"); +} + +#[test] +fn spec_names_need_an_exact_profile_name() { + let db = db(); + let lens = db.find_lens(None, "24-70mm f/2.8", None).expect("a partial match"); + // the profile's names carry more words than the specification, so the maker-less name is not confirmed + assert!(!lens.names_exactly("24-70mm f/2.8")); + assert!(lens.names_exactly("testcam 24-70MM F/2.8 zoom"), "case and punctuation don't matter"); + assert!(!lens.names_exactly("24-70mm f/2.8 Zoom"), "the maker's words count: another maker's lens isn't this one"); + let mut aliased = lens.clone(); + aliased.models.push("24-70mm f/2.8".into()); + assert!(aliased.names_exactly(&spec_name([24.0, 70.0, 2.8, 2.8]))); + assert!(!aliased.names_exactly("24-105mm f/4")); +} diff --git a/docs/lens-profiles.md b/docs/lens-profiles.md index 1d4127d47..13d1b2b7a 100644 --- a/docs/lens-profiles.md +++ b/docs/lens-profiles.md @@ -21,6 +21,15 @@ length is used when the camera isn't listed) and the lens model string. Lens nam 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. +Some cameras record no lens model at all, only the lens specification: Nikon DSLRs write the lens's focal range and +maximum apertures in their maker note (tag `0x0084`) and no Exif `LensModel`, whatever lens is mounted. For such a +file the lens is looked up by a name built from the specification, `24-70mm f/2.8` (`18-55mm f/3.5-5.6`, +`85mm f/1.8`), and a profile is used only when one of its names is exactly that (ignoring case and punctuation). +The specification fits every maker's lens with that range (a Nikkor, a Sigma, a Tamron), so neither a partial match +nor a name with a maker in it ("Samyang 85mm f/1.4" for "85mm f/1.4") is taken; a database that should correct such +files names its profile that way, e.g. a lensfun entry with an extra `24-70mm f/2.8` for the one +24-70 f/2.8 the photographer owns. lensfun's own database has no such names, so it doesn't correct these files. + | Correction | Models | Interpolated over | |---|---|---| | Distortion | `poly3`, `poly5`, `ptlens` | focal length | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 4e0531793..211115dbc 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (LR-EDIT-OPTICS-PROFILE: Nikon DSLR raws look up the lens database by their lens specification) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -214,7 +214,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | | LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | | LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | | -| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; ILCE-7RM6 verified against the camera JPEG on 54 photos with three lenses (`docs/sony-ilce7rm6-lens-corrections.md`); other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus raws not read yet; Sony ARW shading, lateral CA and distortion tables (raw IFD `0x7031`–`0x7037`, with the encrypted `SR2SubIFD` copies as a checked fallback) are decoded with their camera settings, provenance and framing evidence for every body that records them (`crates/raw/src/lens_metadata.rs`), but only the ILCE-7RM4A and ILCE-7RM6 distortion above is applied: on the ILCE-7CR the unchanged distortion interpretation misses the camera JPEG at 24 mm (median 1.6 px, p95 6.2 px at 2376 px), and Sony shading and CA semantics are not validated beyond the ILCE-7RM6's (lateral CA off) (`docs/sony-lens-corrections.md`); Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | +| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; ILCE-7RM6 verified against the camera JPEG on 54 photos with three lenses (`docs/sony-ilce7rm6-lens-corrections.md`); other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus raws not read yet; Sony ARW shading, lateral CA and distortion tables (raw IFD `0x7031`–`0x7037`, with the encrypted `SR2SubIFD` copies as a checked fallback) are decoded with their camera settings, provenance and framing evidence for every body that records them (`crates/raw/src/lens_metadata.rs`), but only the ILCE-7RM4A and ILCE-7RM6 distortion above is applied: on the ILCE-7CR the unchanged distortion interpretation misses the camera JPEG at 24 mm (median 1.6 px, p95 6.2 px at 2376 px), and Sony shading and CA semantics are not validated beyond the ILCE-7RM6's (lateral CA off) (`docs/sony-lens-corrections.md`); Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); raws that name no lens model (Nikon DSLRs: maker-note lens specification only) look it up by a name built from the specification (`24-70mm f/2.8`) and take only a profile named exactly that; no settings switch or bundled database yet | ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | @@ -891,3 +891,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LRC-LIB-CATALOG-IMPORT review: offline Local promotion and result dismissal across library switches covered; redundant operation/settings copies removed. Status and summary counts unchanged. | | 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. | | 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` (also `+`) increase / decrease the grid thumbnail size (`view.thumbSizeUp` / `view.thumbSizeDown`, View menu), grids only; status unchanged (🟡) | +| 2026-10-18 | minor | LR-EDIT-OPTICS-PROFILE: raws that name no lens model (Nikon DSLRs record only the lens specification in their maker note) look up the lens database by a name built from it (`24-70mm f/2.8`) and use only a profile named exactly that; status unchanged (🟡) |