Files
lightcraft/crates/engine/src/lens_profiles.rs
T
DennisandClaude Opus 5.5 4ddbb5a398 LR-EDIT-OPTICS-PROFILE: in-camera crop modes get the frame's crop factor, not the body's
A lensfun profile is scaled by the crop factor of the recorded frame. `lookup` took the body's from the
database whenever the camera was in it, so a full-frame body shooting a crop of its sensor (APS-C /
Super 35 mode, or switched there by an APS-C lens) had the profile applied as if the crop were the full
frame: the crop's corners got the full frame's corner distortion and vignetting. On a Sony ILCE-1M2 in
APS-C mode with the FE 100-400mm F4.5-5.6 GM OSS at 400 mm, the warp's r³ coefficient was the full
frame's 0.0215 instead of 0.0096.

The 35 mm equivalent focal length shows the crop: when it implies a crop factor more than 10 % above the
body's, that wins (`frame_crop_factor`); within 10 % the difference is the tag's whole-millimetre
rounding and the body's value stands. Without the body in the database the 35 mm equivalent is used, as
before.

Tests: `in_camera_crop_mode_uses_the_frames_crop_factor` (fails on main: in its profile the APS-C frame's
vignetting gain is 0.40 instead of 0.18) covers APS-C mode, full frame, a 35 mm equivalent rounded by a
millimetre, no body in the database and a hostile focal length. Checked on 19 private ILCE-1M2 ARWs
(FE 100-400mm GM, Sigma 24-70mm F2.8 DG DN | Art, full frame and APS-C, firmware 2.01-4.00): every
APS-C coefficient is the full-frame one scaled by 1/1.5².

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:50:54 +02:00

212 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Option<LensDb>> = 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<EmbeddedLens> {
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 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)?;
Some(lightcraft_pipeline::optics::reorient_lens(&corr, orientation, w, h))
}
/// The crop factor of the recorded frame, which scales the profile's radii: the body's (`camera`), else what the
/// 35 mm equivalent focal length implies. A body shooting a crop of its sensor (a full-frame camera in APS-C /
/// Super 35 mode, or switched there by an APS-C lens) records a larger 35 mm equivalent than its own crop factor
/// gives; the frame's corners are then nearer the optical axis, so that crop wins. Within 10 % of the body's, the
/// difference is the tag's whole-millimetre rounding and the body's value stands.
fn frame_crop_factor(camera: Option<f64>, focal: f64, focal_35mm: Option<f64>) -> Option<f64> {
let frame = focal_35mm.map(|f35| f35 / focal).filter(|c| c.is_finite() && (0.1..=20.0).contains(c));
match (camera, frame) {
(Some(body), Some(frame)) if frame > body * 1.1 => Some(frame),
(body, frame) => body.or(frame),
}
}
/// [`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<EmbeddedLens> {
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#"<lensdatabase>
<camera><maker>Testcam</maker><model>TC-1</model><mount>T</mount><cropfactor>1.5</cropfactor></camera>
<lens><maker>Testcam</maker><model>Testcam 18-55mm f/3.5-5.6</model><mount>T</mount><cropfactor>1.5</cropfactor>
<calibration>
<distortion model="poly3" focal="18" k1="-0.03" />
<vignetting model="pa" focal="18" aperture="3.5" distance="1000" k1="-0.4" k2="0" k3="0" />
</calibration>
</lens></lensdatabase>"#;
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 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.
#[test]
fn in_camera_crop_mode_uses_the_frames_crop_factor() {
let db = LensDb::parse(
r#"<lensdatabase>
<camera><maker>Sony</maker><model>ILCE-1M2</model><mount>Sony E</mount><cropfactor>1</cropfactor></camera>
<lens><maker>Sigma</maker><model>24-70mm F2.8 DG DN | Art 019</model><mount>Sony E</mount><cropfactor>1.0</cropfactor>
<calibration>
<distortion model="ptlens" focal="70" a="0.0019947" b="0.0035263" c="0.0005016" />
<vignetting model="pa" focal="70" aperture="2.8" distance="1000" k1="-0.4" k2="0" k3="0" />
</calibration>
</lens></lensdatabase>"#,
)
.unwrap();
let m = |f35: Option<f64>| lightcraft_meta::Metadata {
make: Some("SONY".into()),
model: Some("ILCE-1M2".into()),
lens_model: Some("24-70mm F2.8 DG DN | Art 019".into()),
focal_length: Some(70.0),
focal_length_35mm: f35,
f_number: Some(2.8),
..Default::default()
};
let at = |crop: f64, w: f64, h: f64| {
let shot = Shot { focal: 70.0, aperture: Some(2.8), distance: None, crop_factor: Some(crop), width: w, height: h };
db.lenses[0].correction(&shot).unwrap()
};
let aps_c = lookup(&db, &m(Some(105.0)), 5616.0, 3744.0, Orientation::Normal).unwrap();
assert_eq!(aps_c, at(1.5, 5616.0, 3744.0));
assert_ne!(aps_c, at(1.0, 5616.0, 3744.0), "the crop is not corrected as a full frame");
// full frame, a 35 mm equivalent rounded off by a millimetre, or none at all: the body's crop factor
for f35 in [Some(70.0), Some(71.0), None] {
assert_eq!(lookup(&db, &m(f35), 8640.0, 5760.0, Orientation::Normal).unwrap(), at(1.0, 8640.0, 5760.0), "{f35:?}");
}
assert_eq!(frame_crop_factor(None, 70.0, Some(105.0)), Some(1.5), "no body in the database: the 35 mm equivalent's");
assert_eq!(frame_crop_factor(Some(1.0), 0.0, Some(105.0)), Some(1.0), "a hostile focal length is ignored");
}
/// 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<f64> {
(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})");
}
}