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"), "