summaryrefslogtreecommitdiff
path: root/crates/gipfel-core/tests
diff options
context:
space:
mode:
Diffstat (limited to 'crates/gipfel-core/tests')
-rw-r--r--crates/gipfel-core/tests/golden.rs511
1 files changed, 511 insertions, 0 deletions
diff --git a/crates/gipfel-core/tests/golden.rs b/crates/gipfel-core/tests/golden.rs
new file mode 100644
index 0000000..fb12e40
--- /dev/null
+++ b/crates/gipfel-core/tests/golden.rs
@@ -0,0 +1,511 @@
+//! Golden-value tests against the original C++ implementation.
+//!
+//! Goldens are generated by `tests/gen_golden.sh` (needs the built C++ tree).
+//! Run with `cargo test --test golden` (unit tests: `cargo test --lib`).
+
+use gipfel_core::hill;
+use gipfel_core::metadata::ImageMetaData;
+use gipfel_core::panorama::Panorama;
+use gipfel_core::projection::{
+ cylindrical::ProjectionCylindrical, rectilinear::ProjectionRectilinear, ProjArgs, Projection, ProjectionType,
+};
+
+fn golden(name: &str) -> Vec<String> {
+ let path = format!(
+ "{}/../../tests/golden/{}",
+ env!("CARGO_MANIFEST_DIR"),
+ name
+ );
+ std::fs::read_to_string(&path)
+ .unwrap_or_else(|_| panic!("missing golden file {} (run tests/gen_golden.sh)", path))
+ .lines()
+ .map(|l| l.to_string())
+ .collect()
+}
+
+fn data_path() -> Option<String> {
+ if let Ok(p) = std::env::var("GIPFEL_DATA") {
+ if std::path::Path::new(&p).exists() {
+ return Some(p);
+ }
+ }
+ let home = std::env::var("HOME").unwrap_or_default();
+ let p = format!("{}/gipfel/gipfel.dat", home);
+ std::path::Path::new(&p).exists().then_some(p)
+}
+
+/// relative-or-absolute closeness (libm differences between glibc and Rust)
+fn close(a: f64, b: f64) -> bool {
+ if a.is_nan() && b.is_nan() {
+ return true;
+ }
+ if a.is_infinite() || b.is_infinite() {
+ return a == b;
+ }
+ (a - b).abs() <= 1e-9f64.max(a.abs() * 1e-9)
+}
+
+#[test]
+fn generated_functions_match_c() {
+ let golden = golden("funcs.txt");
+ let rect = ProjectionRectilinear;
+ let cyl = ProjectionCylindrical;
+
+ assert_eq!(golden.len(), 200);
+
+ for (k, line) in golden.iter().enumerate() {
+ let kk = k as f64;
+ let c_view = -3.0 + 0.03 * kk;
+ let c_pitch = 0.10 * (0.11 * kk).sin();
+ let c_roll = 0.05 * (0.07 * kk).cos();
+ let scale = 2000.0 + 37.0 * (kk % 19.0);
+ let k0 = 0.001 * (0.31 * kk).sin();
+ let k1 = 0.0001 * (0.17 * kk).cos();
+ let x0 = 50.0 * (0.13 * kk).sin();
+ let m_view = c_view + 0.5 * (0.23 * kk).sin();
+ let m_elev = 0.05 * (0.19 * kk).cos();
+
+ let a = ProjArgs { c_view, c_pitch, c_roll, scale, k0, k1, x0, m_view, m_elev };
+
+ let mine: Vec<f64> = vec![
+ rect.mac_x(a),
+ rect.mac_y(a),
+ rect.mac_x_dc_view(a),
+ rect.mac_x_dc_pitch(a),
+ rect.mac_x_dc_roll(a),
+ rect.mac_x_dscale(a),
+ rect.mac_x_dk0(a),
+ rect.mac_x_dk1(a),
+ rect.mac_x_dx0(a),
+ rect.mac_y_dc_view(a),
+ rect.mac_y_dc_pitch(a),
+ rect.mac_y_dc_roll(a),
+ rect.mac_y_dscale(a),
+ rect.mac_y_dk0(a),
+ rect.mac_y_dk1(a),
+ rect.mac_y_dx0(a),
+ cyl.mac_x(a),
+ cyl.mac_y(a),
+ cyl.mac_x_dc_view(a),
+ cyl.mac_x_dc_pitch(a),
+ cyl.mac_x_dc_roll(a),
+ cyl.mac_x_dscale(a),
+ cyl.mac_y_dc_view(a),
+ cyl.mac_y_dc_pitch(a),
+ cyl.mac_y_dc_roll(a),
+ cyl.mac_y_dscale(a),
+ ];
+
+ let theirs: Vec<f64> = line.split_whitespace().map(|t| t.parse().unwrap()).collect();
+ assert_eq!(mine.len(), theirs.len(), "line {}", k);
+
+ for (i, (m, t)) in mine.iter().zip(theirs.iter()).enumerate() {
+ assert!(close(*m, *t), "k={} fn#{}: rust {} != c {}", k, i, m, t);
+ }
+ }
+}
+
+/// derivatives of the generated code vs. central finite differences
+#[test]
+fn derivatives_match_finite_differences() {
+ let rect = ProjectionRectilinear;
+ let base = ProjArgs {
+ c_view: 0.3,
+ c_pitch: 0.05,
+ c_roll: 0.02,
+ scale: 3000.0,
+ k0: 0.001,
+ k1: 0.0002,
+ x0: 12.0,
+ m_view: 0.5,
+ m_elev: 0.04,
+ };
+
+ let h = 1e-6f64;
+
+ // (arg index, analytic derivative fn)
+ let dx: [(usize, &dyn Fn(ProjArgs) -> f64); 7] = [
+ (0, &|a| rect.mac_x_dc_view(a)),
+ (1, &|a| rect.mac_x_dc_pitch(a)),
+ (2, &|a| rect.mac_x_dc_roll(a)),
+ (3, &|a| rect.mac_x_dscale(a)),
+ (4, &|a| rect.mac_x_dk0(a)),
+ (5, &|a| rect.mac_x_dk1(a)),
+ (6, &|a| rect.mac_x_dx0(a)),
+ ];
+ let dy: [(usize, &dyn Fn(ProjArgs) -> f64); 7] = [
+ (0, &|a| rect.mac_y_dc_view(a)),
+ (1, &|a| rect.mac_y_dc_pitch(a)),
+ (2, &|a| rect.mac_y_dc_roll(a)),
+ (3, &|a| rect.mac_y_dscale(a)),
+ (4, &|a| rect.mac_y_dk0(a)),
+ (5, &|a| rect.mac_y_dk1(a)),
+ (6, &|a| rect.mac_y_dx0(a)),
+ ];
+
+ let set = |a: ProjArgs, idx: usize, v: f64| -> ProjArgs {
+ let mut b = a;
+ match idx {
+ 0 => b.c_view = v,
+ 1 => b.c_pitch = v,
+ 2 => b.c_roll = v,
+ 3 => b.scale = v,
+ 4 => b.k0 = v,
+ 5 => b.k1 = v,
+ _ => b.x0 = v,
+ }
+ b
+ };
+
+ for (idx, f) in dx {
+ let plus = rect.mac_x(set(base, idx, base_val(&base, idx) + h));
+ let minus = rect.mac_x(set(base, idx, base_val(&base, idx) - h));
+ let fd = (plus - minus) / (2.0 * h);
+ let an = f(base);
+ assert!(
+ (an - fd).abs() <= 1e-4f64.max(an.abs() * 1e-5),
+ "dx deriv arg {}: analytic {} finite-diff {}",
+ idx,
+ an,
+ fd
+ );
+ }
+ for (idx, f) in dy {
+ let plus = rect.mac_y(set(base, idx, base_val(&base, idx) + h));
+ let minus = rect.mac_y(set(base, idx, base_val(&base, idx) - h));
+ let fd = (plus - minus) / (2.0 * h);
+ let an = f(base);
+ assert!(
+ (an - fd).abs() <= 1e-4f64.max(an.abs() * 1e-5),
+ "dy deriv arg {}: analytic {} finite-diff {}",
+ idx,
+ an,
+ fd
+ );
+ }
+}
+
+fn base_val(a: &ProjArgs, idx: usize) -> f64 {
+ match idx {
+ 0 => a.c_view,
+ 1 => a.c_pitch,
+ 2 => a.c_roll,
+ 3 => a.scale,
+ 4 => a.k0,
+ 5 => a.k1,
+ _ => a.x0,
+ }
+}
+
+/// full fit (geodesy + projection + GSL LM) against the C++ fitdump
+fn fit_golden(projtype: ProjectionType, file: &str) {
+ let Some(data) = data_path() else {
+ eprintln!("gipfel.dat not found, skipping");
+ return;
+ };
+ let golden = golden(file);
+
+ let mut pan = Panorama::new();
+ pan.load_data(&data).unwrap();
+ pan.set_projection(projtype);
+
+ pan.set_view_lat(47.36);
+ pan.set_view_long(11.15);
+ pan.set_view_height(2100.0);
+
+ // ground truth
+ pan.set_center_angle(20.0);
+ pan.set_pitch_angle(0.0);
+ pan.set_roll_angle(1.0);
+ pan.set_scale(3000.0);
+
+ // snapshot first 6 visible hills as observations
+ let mut obs: Vec<hill::Hill> = pan.visible[..6].iter().map(|&i| pan.hills[i].clone()).collect();
+ let n = obs.len();
+ let known: Vec<usize> = (0..n).collect();
+ // the C harness copies a_elev explicitly (C copy ctor omits it)
+ for (k, &i) in pan.visible[..6].iter().enumerate() {
+ obs[k].a_elev = pan.hills[i].a_elev;
+ }
+
+ // perturb initial guess
+ pan.set_center_angle(pan.get_center_angle() + 0.5);
+ pan.set_pitch_angle(0.3);
+ pan.set_roll_angle(0.5);
+ pan.set_scale(2800.0);
+
+ let ret = pan.comp_params_with(&obs, &known, true);
+
+ let g_ret: i32 = golden[0]["ret ".len()..].parse().unwrap();
+ assert_eq!(ret.is_ok(), g_ret == 0);
+
+ let g: Vec<f64> = golden[1..8]
+ .iter()
+ .map(|l| l.split_whitespace().nth(1).unwrap().parse().unwrap())
+ .collect();
+
+ let mine = [
+ pan.parms.a_center,
+ pan.parms.a_pitch,
+ pan.parms.a_roll,
+ pan.parms.scale,
+ pan.parms.k0,
+ pan.parms.k1,
+ pan.parms.x0,
+ ];
+ for (m, t) in mine.iter().zip(g.iter()) {
+ // solver results: allow small drift, exact zeros must stay ~zero
+ assert!(
+ (m - t).abs() <= 1e-6f64.max(m.abs() * 1e-6),
+ "fit param {} vs {}",
+ m,
+ t
+ );
+ }
+
+ // final coordinates
+ let proj = gipfel_core::projection::projection_from_type(projtype);
+ for line in &golden[8..] {
+ let parts: Vec<&str> = line.split_whitespace().collect();
+ if parts.len() != 4 {
+ continue;
+ }
+ let i: usize = parts[1].parse().unwrap();
+ let tx: f64 = parts[2].parse().unwrap();
+ let ty: f64 = parts[3].parse().unwrap();
+ let (x, y) = proj.get_coordinates(obs[i].alph, obs[i].a_elev, &pan.parms);
+ assert!(close(x, tx), "hill {} x: {} vs {}", i, x, tx);
+ assert!(close(y, ty), "hill {} y: {} vs {}", i, y, ty);
+ }
+}
+
+#[test]
+fn fit_rectilinear_matches_c() {
+ fit_golden(ProjectionType::Rectilinear, "fit_rect.txt");
+}
+
+#[test]
+fn fit_cylindrical_matches_c() {
+ fit_golden(ProjectionType::Cylindrical, "fit_cyl.txt");
+}
+
+#[test]
+fn real_database_loads() {
+ let Some(data) = data_path() else {
+ eprintln!("gipfel.dat not found, skipping");
+ return;
+ };
+ let hills = hill::load(&data).unwrap();
+ assert!(hills.len() > 21000, "loaded {}", hills.len());
+ assert!(hills.iter().any(|h| h.name == "ZUGSPITZE"));
+}
+
+#[test]
+fn viewpoint_geometry() {
+ let Some(data) = data_path() else {
+ eprintln!("gipfel.dat not found, skipping");
+ return;
+ };
+ let mut pan = Panorama::new();
+ pan.load_data(&data).unwrap();
+ let idx = pan.get_pos("ZUGSPITZE").expect("ZUGSPITZE in db");
+ pan.set_viewpoint(Some(idx));
+
+ // NOTE: set_viewpoint re-sorts hills by azimuth; look the name up again
+ let idx = pan.get_pos("ZUGSPITZE").unwrap();
+ let m = &pan.hills[idx];
+ assert!((m.height - 2963.0).abs() < 1.0);
+ // distance to self is zero
+ assert!(pan.distance(m.phi, m.lam).abs() < 1e-9);
+ // a far hill: Mont Blanc (~45.83 N, 6.86 E)
+ let mb = pan.get_pos("MONT BLANC").expect("MONT BLANC in db");
+ let d = pan.distance(pan.hills[mb].phi, pan.hills[mb].lam);
+ let km = d * 6371000.785 / 1000.0;
+ assert!(km > 340.0 && km < 380.0, "Mont Blanc distance {} km", km);
+}
+
+#[test]
+fn export_hills_matches_c() {
+ let Some(data) = data_path() else {
+ eprintln!("gipfel.dat not found, skipping");
+ return;
+ };
+ let jpg = format!(
+ "{}/../../tests/golden/test.jpg",
+ env!("CARGO_MANIFEST_DIR")
+ );
+ let golden = golden("export.txt");
+
+ let (w, h) = gipfel_core::metadata::image_dimensions(&jpg).expect("test.jpg readable");
+ let mut md = ImageMetaData::new();
+ md.load_image(&jpg);
+
+ let mut pan = Panorama::new();
+ assert!(gipfel_core::metadata::apply_image_metadata(&mut pan, &md, w, h));
+ pan.load_data(&data).unwrap();
+ pan.set_height_dist_ratio(0.07);
+
+ let mut out = vec![
+ "#".to_string(),
+ "# name\theight\tx\ty\tdistance\tflags".to_string(),
+ "#".to_string(),
+ ];
+ out.extend(pan.export_hill_rows(w as i64, h as i64, false));
+
+ assert_eq!(out.len(), golden.len());
+ for (a, b) in out.iter().zip(golden.iter()) {
+ assert_eq!(a, b);
+ }
+}
+
+#[test]
+fn metadata_comment_roundtrip_and_c_format() {
+ let mut md = ImageMetaData::new();
+ md.longitude = 11.1533;
+ md.latitude = 47.3601;
+ md.height = 2100.5;
+ md.direction = 20.0;
+ md.pitch = 0.3;
+ md.roll = 1.0;
+ md.focal_length_35mm = 35.0;
+ md.projection_type = 1;
+ md.k0 = 0.001;
+ md.k1 = -0.0002;
+ md.x0 = 12.0;
+
+ let s = md.format_comment();
+ // exact C printf("%f") compatibility
+ assert_eq!(
+ s,
+ "gipfel: longitude 11.153300, latitude 47.360100, height 2100.500000, direction 20.000000, nick 0.300000, tilt 1.000000, focal_length_35mm 35.000000, projection type 1, k0 0.001000, k1 -0.000200, x0 12.000000"
+ );
+
+ let mut md2 = ImageMetaData::new();
+ assert!(md2.parse_gipfel_comment(s.as_bytes()));
+ assert!((md2.longitude - 11.1533).abs() < 1e-9);
+ assert!((md2.latitude - 47.3601).abs() < 1e-9);
+ assert!((md2.height - 2100.5).abs() < 1e-9);
+ assert!((md2.direction - 20.0).abs() < 1e-9);
+ assert_eq!(md2.projection_type, 1);
+ assert!((md2.k0 - 0.001).abs() < 1e-9);
+ assert!((md2.k1 + 0.0002).abs() < 1e-9);
+ assert!((md2.x0 - 12.0).abs() < 1e-9);
+
+ // short form (8 fields, pre-distortion files): k0/k1/x0 stay NaN
+ let short = b"gipfel: longitude 1.000000, latitude 2.000000, height 3.000000, direction 4.000000, nick 5.000000, tilt 6.000000, focal_length_35mm 7.000000, projection type 0";
+ let mut md3 = ImageMetaData::new();
+ assert!(md3.parse_gipfel_comment(short));
+ assert!(md3.k0.is_nan());
+ assert_eq!(md3.projection_type, 0);
+
+ // trailing garbage after x0 must not break parsing (sscanf semantics)
+ let mut md4 = ImageMetaData::new();
+ let mut s4 = s.clone();
+ s4.push_str(" some other comment junk");
+ assert!(md4.parse_gipfel_comment(s4.as_bytes()));
+ assert!((md4.x0 - 12.0).abs() < 1e-9);
+}
+
+/// The least-squares fit must run on a small stack: the LM solver keeps its
+/// working buffers (residuals, Jacobian, normal equations) on the heap, so a
+/// 128 KiB thread stack is plenty. Guards against regressions that move those
+/// buffers back onto the stack.
+#[test]
+fn fit_survives_small_stack() {
+ let Some(data) = data_path() else {
+ eprintln!("gipfel.dat not found, skipping");
+ return;
+ };
+ std::thread::Builder::new()
+ .stack_size(128 * 1024)
+ .spawn(move || {
+ let mut pan = Panorama::new();
+ pan.load_data(&data).unwrap();
+ pan.set_view_lat(47.36);
+ pan.set_view_long(11.15);
+ pan.set_view_height(2100.0);
+ pan.set_center_angle(20.0);
+ pan.set_pitch_angle(0.0);
+ pan.set_roll_angle(1.0);
+ pan.set_scale(3000.0);
+ let known: Vec<usize> = pan.visible[..4].to_vec();
+ // perturb so the solver actually iterates
+ pan.set_center_angle(20.5);
+ pan.set_scale(2800.0);
+ pan.comp_params(&known, true).unwrap();
+ })
+ .unwrap()
+ .join()
+ .unwrap();
+}
+
+/// Stitch resample against the C Stitch::resample + ScanImage pipeline.
+fn stitch_golden(
+ name: &str,
+ proj: ProjectionType,
+ center: f64,
+ pitch: f64,
+ roll: f64,
+ scale: f64,
+ k0: f64,
+ k1: f64,
+ bicubic: bool,
+) {
+ use gipfel_core::stitch::{self, ScanMode};
+
+ let ppm = format!(
+ "{}/../../tests/golden/stitch.ppm",
+ env!("CARGO_MANIFEST_DIR")
+ );
+ let img = image::open(&ppm)
+ .unwrap_or_else(|e| panic!("missing {}: {e}", ppm))
+ .to_rgb8();
+
+ let mut pan = Panorama::new();
+ pan.set_projection(proj);
+ pan.set_center_angle(center);
+ pan.set_pitch_angle(pitch);
+ pan.set_roll_angle(roll);
+ pan.set_scale(scale);
+ pan.set_distortion_params(k0, k1, 0.0);
+
+ let src = stitch::StitchSource { pan, img };
+ let mode = if bicubic { ScanMode::Bicubic } else { ScanMode::Nearest };
+ let out = stitch::resample(&[src], mode, 64, 48, 10.0, 30.0);
+
+ let mut checked = 0;
+ for line in golden(name) {
+ let f: Vec<&str> = line.split_whitespace().collect();
+ let (x, y, hit): (usize, usize, i32) = (f[0].parse().unwrap(), f[1].parse().unwrap(), f[2].parse().unwrap());
+ let want = [
+ f[3].parse::<u16>().unwrap(),
+ f[4].parse::<u16>().unwrap(),
+ f[5].parse::<u16>().unwrap(),
+ ];
+ let i = y * 64 + x;
+ assert_eq!(out.hit[i], hit == 1, "{name} pixel ({x},{y}) hit flag");
+ assert_eq!(out.pixels[i], want, "{name} pixel ({x},{y})");
+ checked += 1;
+ }
+ assert!(checked > 1000);
+}
+
+#[test]
+fn stitch_rect_nearest_matches_c() {
+ stitch_golden("stitch_rect_near.txt", ProjectionType::Rectilinear, 20.0, 2.0, 0.0, 200.0, 0.0, 0.0, false);
+}
+
+#[test]
+fn stitch_rect_bicubic_matches_c() {
+ stitch_golden("stitch_rect_bic.txt", ProjectionType::Rectilinear, 20.0, 2.0, 0.0, 200.0, 0.0, 0.0, true);
+}
+
+#[test]
+fn stitch_cyl_bicubic_matches_c() {
+ stitch_golden("stitch_cyl_bic.txt", ProjectionType::Cylindrical, 20.0, 2.0, 0.0, 200.0, 0.0, 0.0, true);
+}
+
+#[test]
+fn stitch_distorted_bicubic_matches_c() {
+ stitch_golden("stitch_dist_bic.txt", ProjectionType::Rectilinear, 20.0, 2.0, 3.0, 200.0, -0.3, 0.1, true);
+}