use super::*;
/// A synthetic database in the lensfun format (made-up coefficients, not lensfun data).
const DB: &str = r#"
Test Mount
Testcam Corporation
TC-1
Test Mount
1.5
Testcam
TC-FF
Test Mount
1
Testcam
Testcam 24-70mm f/2.8 & Co
Testcam 24-70mm f/2.8 Zoom
Test Mount
1
Testcam
Testcam 50mm f/1.8
Other Mount
Fishco
Fishco 8mm f/3.5 Fisheye
equisolid
BrokenBroken 1mm
"#;
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("").is_ok_and(|d| d.lenses.is_empty()));
assert!(LensDb::parse("").is_err());
let deep = "".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::();
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"), "