mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 04:27:30 -06:00
The D1X/D1H raw SubIFD is 4028x1324 with XResolution 600 / YResolution 300: the pixel is twice as tall as it is wide. Nothing read that, so the picture came out about 3:1. Add a pixel aspect to RawImage/RawInfo, read it from the raw IFD resolution ratio (NEF) and from DefaultScale (DNG), accept only 1.5..4, and resample the rows after demosaic. developed_size() reports the stretched size, binning is off for non-square pixels, and the catalog and lens-profile sizing use developed_size().
211 lines
11 KiB
Rust
211 lines
11 KiB
Rust
//! 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 (w, h) = raw.developed_size();
|
||
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})");
|
||
}
|
||
}
|