Files
lightcraft/crates/lensdb/src/tests.rs
T
Andrew FrahnandClaude Opus 5.5 00ea386f7f lensdb: lens distortion (poly3/poly5/PTLens), TCA (linear/poly3) and vignetting (pa) profiles, lensfun-format loader
Profiles are interpolated in focal length (vignetting also in aperture and
focus distance), scaled by crop factor, and converted by least squares to the
DNG WarpRectilinear / FixVignetteRadial polynomials the pipeline already
applies. The lensfun database (CC-BY-SA) is only read from a directory the
user supplies; nothing is bundled.

Sources: lensfun's published database format and lens calibration
documentation (no lensfun code read), PanoTools wiki (a, b, c distortion
model), DNG specification 1.6 (WarpRectilinear, FixVignetteRadial);
interpolation and fitting: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 19:30:18 +11:00

246 lines
9.7 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.
use super::*;
/// A synthetic database in the lensfun format (made-up coefficients, not lensfun data).
const DB: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<lensdatabase version="2">
<mount><name>Test Mount</name></mount>
<camera>
<maker>Testcam Corporation</maker>
<model>TC-1</model>
<mount>Test Mount</mount>
<cropfactor>1.5</cropfactor>
</camera>
<camera>
<maker>Testcam</maker>
<model>TC-FF</model>
<mount>Test Mount</mount>
<cropfactor>1</cropfactor>
</camera>
<lens>
<maker>Testcam</maker>
<model>Testcam 24-70mm f/2.8 &amp; Co</model>
<model lang="de">Testcam 24-70mm f/2.8 Zoom</model>
<mount>Test Mount</mount>
<cropfactor>1</cropfactor>
<calibration>
<distortion model="ptlens" focal="24" a="0.01" b="-0.04" c="0" />
<distortion model="poly3" focal="70" k1="0.01" />
<tca model="linear" focal="24" kr="1.0004" kb="0.9997" />
<tca model="poly3" focal="70" vr="1.0001" vb="0.9999" cr="0" cb="0" br="0.0001" bb="-0.0001" />
<vignetting model="pa" focal="24" aperture="2.8" distance="1000" k1="-0.6" k2="0.2" k3="-0.05" />
<vignetting model="pa" focal="24" aperture="8" distance="1000" k1="-0.2" k2="0" k3="0" />
<vignetting model="pa" focal="70" aperture="2.8" distance="1000" k1="-0.3" k2="0" k3="0" />
</calibration>
</lens>
<lens>
<maker>Testcam</maker>
<model>Testcam 50mm f/1.8</model>
<mount>Other Mount</mount>
<calibration>
<distortion model="poly5" focal="50" k1="-0.02" k2="0.0003" />
</calibration>
</lens>
<lens>
<maker>Fishco</maker>
<model>Fishco 8mm f/3.5 Fisheye</model>
<type>equisolid</type>
<calibration>
<distortion model="poly3" focal="8" k1="0.5" />
</calibration>
</lens>
<lens><maker>Broken</maker><model>Broken 1mm</model><calibration><distortion model="poly3" focal="nan" k1="1"/></calibration></lens>
</lensdatabase>"#;
fn db() -> LensDb {
LensDb::parse(DB).unwrap()
}
fn shot(focal: f64) -> Shot {
Shot { focal, aperture: Some(2.8), distance: None, crop_factor: Some(1.0), width: 6000.0, height: 4000.0 }
}
/// The DNG `WarpRectilinear` source position (the pipeline's formula, radial part).
fn dng_source(wp: &EmbeddedWarp, ch: usize, x: f64, y: f64, w: f64, h: f64) -> (f64, f64) {
let (cx, cy) = (wp.center.x * w, wp.center.y * h);
let m = wp.radius * w.max(h);
let (dx, dy) = ((x - cx) / m, (y - cy) / m);
let r2 = dx * dx + dy * dy;
let [k0, k1, k2, k3, ..] = wp.planes[ch];
let f = k0 + r2 * (k1 + r2 * (k2 + r2 * k3));
(cx + dx * f * m, cy + dy * f * m)
}
#[test]
fn parses_cameras_lenses_and_skips_invalid_entries() {
let db = db();
assert_eq!(db.cameras.len(), 2);
assert_eq!(db.lenses.len(), 3, "the lens without valid calibration points is dropped");
let z = &db.lenses[0];
assert_eq!(z.models[0], "Testcam 24-70mm f/2.8 & Co", "entities decoded");
assert_eq!(z.distortion.len(), 2);
assert_eq!(z.tca.len(), 2);
assert_eq!(z.vignetting.len(), 3);
assert_eq!(z.aspect_ratio, 1.5);
assert!(!db.lenses[2].rectilinear);
}
#[test]
fn malformed_documents_are_errors_not_panics() {
assert!(LensDb::parse("").is_err());
assert!(LensDb::parse("<lensdatabase><lens><maker>").is_ok_and(|d| d.lenses.is_empty()));
assert!(LensDb::parse("<other/>").is_err());
let deep = "<a>".repeat(100);
assert_eq!(LensDb::parse(&deep), Err(Error::TooLarge));
}
#[test]
fn finds_cameras_by_exif_strings() {
let db = db();
assert_eq!(db.find_camera("TESTCAM CORPORATION", "TC-1").unwrap().crop_factor, 1.5);
assert_eq!(db.find_camera("Testcam", "tc-ff").unwrap().crop_factor, 1.0);
assert!(db.find_camera("Testcam", "TC-2").is_none());
assert!(db.find_camera("Other", "TC-1").is_none());
}
#[test]
fn finds_lenses_by_exif_model_with_or_without_the_maker() {
let db = db();
let cam = db.find_camera("Testcam", "TC-FF");
let l = db.find_lens(None, "24-70mm F2.8 Zoom", cam).unwrap();
assert_eq!(l.maker, "Testcam");
assert!(db.find_lens(Some("Testcam"), "Testcam 50mm F1.8", cam).is_some());
assert!(db.find_lens(None, "24-105mm F4", cam).is_none(), "focal lengths must agree");
assert!(db.find_lens(Some("Otherco"), "24-70mm f/2.8 Zoom", cam).is_none(), "lens maker must agree");
assert!(db.find_lens(None, "", cam).is_none());
}
#[test]
fn distortion_matches_the_model_within_half_a_pixel_on_a_grid() {
let db = db();
let lens = &db.lenses[0];
let (w, h) = (6000.0, 4000.0);
for focal in [24.0, 40.0, 70.0] {
let wp = lens.correction(&shot(focal)).unwrap().warp.unwrap();
// lensfun normalisation: r = 1 at half the shorter side (full frame, 3:2)
let n = h / 2.0;
let mut worst: f64 = 0.0;
for gy in 0..=20 {
for gx in 0..=30 {
let (x, y) = (gx as f64 * w / 30.0, gy as f64 * h / 20.0);
let (sx, sy) = dng_source(&wp, 1, x, y, w, h);
let (dx, dy) = ((x - w / 2.0) / n, (y - h / 2.0) / n);
let r = dx.hypot(dy);
let k = lens.distortion_ratio(focal, r);
let (ex, ey) = (w / 2.0 + dx * k * n, h / 2.0 + dy * k * n);
worst = worst.max((sx - ex).hypot(sy - ey));
}
}
assert!(worst < 0.5, "{focal} mm: {worst} px");
}
}
#[test]
fn straight_lines_stay_straight_after_correction() {
// A synthetic barrel-distorted chart: the camera recorded straight scene lines at r_d = r_u·ratio(r_u).
// Correcting means: for each output point of a straight line, the warp fetches the recorded point, which must
// be that line's recorded (curved) image. So sample the curved recorded line by inverting the model, then
// check the warp sends straight output points onto it.
let db = db();
let lens = &db.lenses[0];
let (w, h) = (6000.0, 4000.0);
let wp = lens.correction(&shot(24.0)).unwrap().warp.unwrap();
let n = h / 2.0;
let y_line = 300.0; // a horizontal line near the top edge
let mut dev: f64 = 0.0;
for i in 0..=60 {
let x = i as f64 * w / 60.0;
let (sx, sy) = dng_source(&wp, 1, x, y_line, w, h);
// undo the recorded distortion of (sx, sy): find r_u with r_u·ratio(r_u) = r_d (bisection)
let (dx, dy) = ((sx - w / 2.0) / n, (sy - h / 2.0) / n);
let rd = dx.hypot(dy);
let (mut lo, mut hi) = (0.0, 3.0);
for _ in 0..80 {
let mid = (lo + hi) / 2.0;
if mid * lens.distortion_ratio(24.0, mid) < rd {
lo = mid;
} else {
hi = mid;
}
}
let s = if rd > 0.0 { lo / rd } else { 1.0 };
let uy = h / 2.0 + dy * s * n;
dev = dev.max((uy - y_line).abs());
}
assert!(dev < 0.5, "line bent by {dev} px");
}
#[test]
fn tca_scales_red_and_blue_planes() {
let db = db();
let wp = db.lenses[0].correction(&shot(24.0)).unwrap().warp.unwrap();
let (w, h) = (6000.0, 4000.0);
let (rx, _) = dng_source(&wp, 0, w, h / 2.0, w, h);
let (gx, _) = dng_source(&wp, 1, w, h / 2.0, w, h);
let (bx, _) = dng_source(&wp, 2, w, h / 2.0, w, h);
assert!(((rx - w / 2.0) / (gx - w / 2.0) - 1.0004).abs() < 1e-5, "{rx} {gx}");
assert!(((bx - w / 2.0) / (gx - w / 2.0) - 0.9997).abs() < 1e-5, "{bx} {gx}");
}
#[test]
fn vignetting_interpolates_in_aperture_and_focal() {
let db = db();
let l = &db.lenses[0];
let at = |f: f64, n: f64| l.vignetting_attenuation(f, Some(n), None, 1.0).unwrap();
assert!((at(24.0, 2.8) - 0.55).abs() < 1e-12);
assert!((at(24.0, 8.0) - 0.8).abs() < 1e-12);
let mid = at(24.0, 2.8 * 2f64.powf((8f64 / 2.8).log2() / 2.0));
assert!((mid - 0.675).abs() < 1e-9, "{mid}");
assert!((at(47.0, 2.8) - 0.625).abs() < 1e-9);
// the gain undoes the falloff at the corner
let v = l.correction(&shot(24.0)).unwrap().vignette.unwrap();
let g: f64 = 1.0 + v.k.iter().enumerate().map(|(i, k)| k * 1f64.powi(2 * (i as i32 + 1))).sum::<f64>();
assert!((g * 0.55 - 1.0).abs() < 0.01, "{g}");
}
#[test]
fn crop_factor_scales_the_profile() {
// On a 1.5× body the image covers the centre of the full-frame calibration: much less distortion at its corner.
let db = db();
let l = &db.lenses[1];
let ff = l.correction(&shot(50.0)).unwrap().warp.unwrap();
let aps = l.correction(&Shot { crop_factor: Some(1.5), ..shot(50.0) }).unwrap().warp.unwrap();
let corner = |wp: &EmbeddedWarp| dng_source(wp, 1, 0.0, 0.0, 6000.0, 4000.0);
let (a, b) = (corner(&ff), corner(&aps));
let r = |(x, y): (f64, f64)| (x - 3000.0).hypot(y - 2000.0);
let rc = 3000f64.hypot(2000.0);
assert!((r(a) - rc).abs() > (r(b) - rc).abs() * 1.5, "{} {}", r(a) - rc, r(b) - rc);
}
#[test]
fn fisheyes_get_no_rectilinear_warp_and_bad_shots_none() {
let db = db();
assert!(db.lenses[2].correction(&shot(8.0)).is_none());
assert!(db.lenses[0].correction(&Shot { width: f64::NAN, ..shot(24.0) }).is_none());
assert!(db.lenses[0].correction(&Shot { focal: 0.0, ..shot(24.0) }).is_none());
}
#[test]
fn load_dir_reads_xml_files_and_reports_bad_ones() {
let dir = std::env::temp_dir().join(format!("lensdb-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.xml"), DB).unwrap();
std::fs::write(dir.join("b.xml"), "<lensdatabase").unwrap();
std::fs::write(dir.join("c.txt"), "ignored").unwrap();
let (db, errs) = LensDb::load_dir(&dir).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
assert_eq!(db.lenses.len(), 3);
assert_eq!(errs.len(), 1, "{errs:?}");
assert!(LensDb::load_dir(Path::new("/nonexistent/lensdb")).is_err());
}
#[test]
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"]);
}