mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
engine: lens profile corrections from a lensfun-format database
Raws without an embedded lens correction look their EXIF lens up in the user-supplied database; the profile goes through the existing Enable Profile Corrections path (amount sliders, CPU/GPU geometry stage, DNG export). Synthetic-grid test renders a distorted chart through the pipeline. Sources: own design (see lightcraft-lensdb for the model sources). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
00ea386f7f
commit
80e58ed55b
7 files changed
+212
-4
No files matched your search
Generated
+1
@@ -3213,6 +3213,7 @@ dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-gpu",
|
||||
"lightcraft-heif",
|
||||
"lightcraft-lensdb",
|
||||
"lightcraft-merge",
|
||||
"lightcraft-meta",
|
||||
"lightcraft-pipeline",
|
||||
|
||||
@@ -24,6 +24,7 @@ lightcraft-raw = { workspace = true }
|
||||
lightcraft-denoise-core = { workspace = true }
|
||||
lightcraft-denoise = { workspace = true, optional = true }
|
||||
lightcraft-meta = { workspace = true }
|
||||
lightcraft-lensdb = { workspace = true }
|
||||
lightcraft-faces = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
lightcraft-merge = { workspace = true }
|
||||
|
||||
@@ -135,7 +135,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
// Vendor RGB multipliers do not identify an absolute illuminant without camera calibration.
|
||||
let relative = crate::camera_preview::file_local_look(raw.format) && !lightcraft_raw::color::has_matrix(&raw.color);
|
||||
let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) });
|
||||
let embedded_lens = embedded_lens(&raw);
|
||||
let embedded_lens = crate::lens_profiles::for_raw(&raw, || m.clone());
|
||||
return Ok(ProbeInfo {
|
||||
embedded_lens,
|
||||
width: w,
|
||||
@@ -365,7 +365,7 @@ fn load_bytes_now(
|
||||
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in
|
||||
// (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`).
|
||||
let info = raw.info();
|
||||
let lens = embedded_lens(&info);
|
||||
let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes));
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
// Without colour matrices of its own, white balance is relative to the as-shot look (`Photo::relative_wb`).
|
||||
let own_matrix = lightcraft_raw::color::has_matrix(&raw.color);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! 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)?;
|
||||
// the crop factor: the camera's, else what the 35 mm equivalent focal length implies
|
||||
let crop =
|
||||
camera.map(|c| c.crop_factor).or_else(|| m.focal_length_35mm.map(|f35| f35 / focal).filter(|c| c.is_finite() && (0.1..=20.0).contains(c)));
|
||||
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))
|
||||
}
|
||||
|
||||
/// [`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 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})");
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod import;
|
||||
mod import_move;
|
||||
pub mod lens_profiles;
|
||||
pub mod library;
|
||||
mod lightroom_archive;
|
||||
pub mod lightroom_catalog;
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
//! dimensionless scales), so a 400 px preview and a full-size export are warped identically.
|
||||
//!
|
||||
//! Also here: automatic lateral chromatic aberration estimation ([`estimate_lateral_ca`]) and [`defringe`].
|
||||
//! Profile corrections use only lens data embedded in the files: DNG `WarpRectilinear` / `FixVignetteRadial`, and the
|
||||
//! raw readers' equivalent of a camera's own correction (Panasonic / Leica RW2 distortion).
|
||||
//! Profile corrections use lens data embedded in the files (DNG `WarpRectilinear` / `FixVignetteRadial`, and the
|
||||
//! raw readers' equivalent of a camera's own correction), or a lens database profile in the same form (`lightcraft_lensdb`).
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Lens profiles from a lens database
|
||||
|
||||
Raws that carry no lens correction of their own (DNG `OpcodeList3`, or a camera correction a raw reader converts,
|
||||
see [sony-lens-corrections.md](sony-lens-corrections.md)) can be corrected from a **lens database in the lensfun
|
||||
XML format** that you supply. The app does not ship any lens database: lensfun's data is licensed CC-BY-SA 3.0 and
|
||||
stays a separate download. Adobe LCP profiles are never used.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Get the lensfun database (the `data/db/*.xml` files of <https://github.com/lensfun/lensfun>, or the copy a Linux
|
||||
distribution installs under `/usr/share/lensfun/version_1/`).
|
||||
2. Point `LIGHTCRAFT_LENSFUN_DIR` at that directory and start the app (or the CLI).
|
||||
|
||||
Files are read once per process. Invalid files are skipped with a warning in the log. Without the variable nothing
|
||||
changes.
|
||||
|
||||
## What is applied
|
||||
|
||||
The lens is identified from EXIF: camera make / model (for the mount and crop factor; the 35 mm equivalent focal
|
||||
length is used when the camera isn't listed) and the lens model string. Lens names match on their words; every
|
||||
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.
|
||||
|
||||
| Correction | Models | Interpolated over |
|
||||
|---|---|---|
|
||||
| Distortion | `poly3`, `poly5`, `ptlens` | focal length |
|
||||
| Lateral CA (TCA) | `linear`, `poly3` | focal length |
|
||||
| Vignetting | `pa` | focal length, aperture (in stops), focus distance (nearest calibrated, by 1/d; infinity when unknown) |
|
||||
|
||||
Fisheye and other non-rectilinear lenses get no distortion correction. The profile is converted by least squares
|
||||
into the DNG `WarpRectilinear` / `FixVignetteRadial` polynomials, so it goes through the same path as embedded
|
||||
corrections: **Enable Profile Corrections** and its Distortion / Vignetting amounts, the CPU and GPU geometry
|
||||
stage (one resample), orientation handling and DNG export. A conversion whose radial error exceeds 0.001 of the
|
||||
half diagonal, or that would fold the image, is not applied.
|
||||
|
||||
Normalisation follows lensfun's documentation: distortion and TCA radii are 1 at half the shorter side of the
|
||||
calibration frame (its `cropfactor` and `aspect-ratio`), vignetting radii 1 at its corner; the optical centre is
|
||||
the image centre (lensfun's `<center>` offsets are not read).
|
||||
|
||||
## Limits
|
||||
|
||||
- Only raws: rendered images (JPEG, HEIF, TIFF) are often already corrected in camera.
|
||||
- The focus distance is not read from maker notes yet: vignetting uses the farthest calibrated distance.
|
||||
- No manual make / model picker yet; matching is automatic.
|
||||
- Not yet validated against real photos and another renderer; the tests use synthetic profiles and grids.
|
||||
Reference in new issue
Block a user