use std::fmt::Write as _;
use std::path::Path;
use nalgebra::Vector3;
use apex_manifolds::so3::SO3;
use super::error::{Result, TrajectoryError};
use super::types::{NANOS_PER_SEC, NANOS_PER_SECOND};
use super::{Trajectory, TrajectoryLoader, TrajectoryPose, parse_field, read_file, write_file};
use crate::csv;
const TUM_COLUMNS: usize = 8;
const POSE_PRECISION: usize = 9;
pub struct TumLoader;
impl TrajectoryLoader for TumLoader {
fn load<P: AsRef<Path>>(path: P) -> Result<Trajectory> {
let path = path.as_ref();
let content = read_file(path)?;
Self::parse(&content, path)
}
fn write<P: AsRef<Path>>(trajectory: &Trajectory, path: P) -> Result<()> {
write_file(path.as_ref(), &Self::render(trajectory))
}
}
impl TumLoader {
pub fn parse(text: &str, path: &Path) -> Result<Trajectory> {
let rows = csv::split_rows(text, None);
let mut poses = Vec::with_capacity(rows.len());
for (index, row) in rows.iter().enumerate() {
let line = index + 1;
if row.len() != TUM_COLUMNS {
return Err(TrajectoryError::MissingColumns {
path: path.to_path_buf(),
line,
expected: TUM_COLUMNS,
got: row.len(),
});
}
let timestamp_ns = parse_timestamp(row[0], path, line)?;
let qx = parse_field(row, 4, "qx", path, line)?;
let qy = parse_field(row, 5, "qy", path, line)?;
let qz = parse_field(row, 6, "qz", path, line)?;
let qw = parse_field(row, 7, "qw", path, line)?;
let orientation = SO3::try_from_quaternion_wxyz(qw, qx, qy, qz).ok_or_else(|| {
TrajectoryError::InvalidQuaternion {
path: path.to_path_buf(),
line,
norm: (qw * qw + qx * qx + qy * qy + qz * qz).sqrt(),
}
})?;
poses.push(TrajectoryPose::new(
timestamp_ns,
Vector3::new(
parse_field(row, 1, "tx", path, line)?,
parse_field(row, 2, "ty", path, line)?,
parse_field(row, 3, "tz", path, line)?,
),
orientation,
));
}
Ok(Trajectory::from_poses(poses))
}
pub fn render(trajectory: &Trajectory) -> String {
let mut out = String::with_capacity(trajectory.len() * 128);
out.push_str("# trajectory written by apex-io\n");
let _ = writeln!(out, "# Timestamp: {}", chrono::Local::now());
out.push_str("# timestamp tx ty tz qx qy qz qw\n");
for sample in trajectory.poses() {
let [qw, qx, qy, qz] = sample.orientation.coeffs();
let seconds = sample.timestamp_ns / NANOS_PER_SEC;
let nanos = sample.timestamp_ns % NANOS_PER_SEC;
let p = sample.position;
let _ = writeln!(
out,
"{seconds}.{nanos:09} {:.*} {:.*} {:.*} {:.*} {:.*} {:.*} {:.*}",
POSE_PRECISION,
p.x,
POSE_PRECISION,
p.y,
POSE_PRECISION,
p.z,
POSE_PRECISION,
qx,
POSE_PRECISION,
qy,
POSE_PRECISION,
qz,
POSE_PRECISION,
qw
);
}
out
}
}
fn parse_timestamp(raw: &str, path: &Path, line: usize) -> Result<u64> {
let invalid = || TrajectoryError::InvalidNumber {
path: path.to_path_buf(),
line,
field: "timestamp".to_owned(),
value: raw.to_owned(),
};
if let Some((seconds, fraction)) = raw.split_once('.') {
if seconds.bytes().all(|b| b.is_ascii_digit())
&& !seconds.is_empty()
&& fraction.bytes().all(|b| b.is_ascii_digit())
&& fraction.len() <= 9
{
let seconds: u64 = seconds.parse().map_err(|_| invalid())?;
let scale = 10u64.pow(9 - fraction.len() as u32);
let nanos: u64 = if fraction.is_empty() {
0
} else {
fraction.parse::<u64>().map_err(|_| invalid())? * scale
};
return seconds
.checked_mul(NANOS_PER_SEC)
.and_then(|s| s.checked_add(nanos))
.ok_or_else(invalid);
}
} else if raw.bytes().all(|b| b.is_ascii_digit()) && !raw.is_empty() {
let seconds: u64 = raw.parse().map_err(|_| invalid())?;
return seconds.checked_mul(NANOS_PER_SEC).ok_or_else(invalid);
}
let seconds = raw.parse::<f64>().map_err(|_| invalid())?;
if !seconds.is_finite() || seconds < 0.0 {
return Err(TrajectoryError::NegativeTimestamp {
path: path.to_path_buf(),
line,
value: seconds,
});
}
Ok((seconds * NANOS_PER_SECOND).round() as u64)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use apex_manifolds::so3::SO3;
type TestResult = std::result::Result<(), Box<dyn std::error::Error>>;
fn path() -> &'static Path {
Path::new("t.tum")
}
fn sample_trajectory() -> Trajectory {
let axis = Vector3::new(0.3, 0.7, 0.1);
Trajectory::from_poses(vec![
TrajectoryPose::new(
1_403_636_579_763_555_584,
Vector3::new(1.0, -2.0, 3.0),
SO3::from_axis_angle(&axis, 0.7),
),
TrajectoryPose::new(
1_403_636_579_813_555_584,
Vector3::new(1.5, -2.5, 3.5),
SO3::from_axis_angle(&axis, 0.9),
),
])
}
#[test]
fn round_trip_preserves_positions_and_rotations() -> TestResult {
let original = sample_trajectory();
let parsed = TumLoader::parse(&TumLoader::render(&original), path())?;
assert_eq!(parsed.len(), original.len());
for (a, b) in parsed.poses().iter().zip(original.poses().iter()) {
assert!((a.position - b.position).norm() < 1e-9);
assert!(
a.orientation.distance(&b.orientation) < 1e-8,
"round-trip rotation error {:e} exceeds the 9-decimal format bound",
a.orientation.distance(&b.orientation)
);
}
Ok(())
}
#[test]
fn the_file_is_w_last() -> TestResult {
let q = SO3::from_axis_angle(&Vector3::x(), 0.2); let t = Trajectory::from_poses(vec![TrajectoryPose::new(0, Vector3::zeros(), q)]);
let text = TumLoader::render(&t);
let row = text
.lines()
.find(|l| !l.starts_with('#'))
.ok_or("a data row")?;
let fields: Vec<f64> = row
.split_whitespace()
.skip(1)
.filter_map(|f| f.parse().ok())
.collect();
assert_eq!(fields.len(), 7);
let qw = fields[6];
assert!(qw > 0.99, "field 7 must be w, got {qw}");
assert!(
fields[3].abs() < 0.1,
"field 4 must be qx, got {}",
fields[3]
);
Ok(())
}
#[test]
fn timestamps_round_trip_exactly_at_euroc_magnitudes() -> TestResult {
let ns = 1_403_636_579_763_555_584u64;
let t = Trajectory::from_poses(vec![TrajectoryPose::new(
ns,
Vector3::zeros(),
SO3::identity(),
)]);
let parsed = TumLoader::parse(&TumLoader::render(&t), path())?;
assert_eq!(parsed.poses()[0].timestamp_ns, ns);
Ok(())
}
#[test]
fn a_foreign_scientific_notation_timestamp_is_read_lossily() -> TestResult {
let text = "1.4036365797635556e9 0 0 0 0 0 0 1\n";
let parsed = TumLoader::parse(text, path())?;
let ns = parsed.poses()[0].timestamp_ns;
assert!(ns.abs_diff(1_403_636_579_763_555_584) < 1_000, "got {ns}");
Ok(())
}
#[test]
fn integer_seconds_without_a_fraction_are_accepted() -> TestResult {
let parsed = TumLoader::parse("12 0 0 0 0 0 0 1\n", path())?;
assert_eq!(parsed.poses()[0].timestamp_ns, 12_000_000_000);
Ok(())
}
#[test]
fn comments_and_blank_lines_are_skipped() -> TestResult {
let text = "# header\n\n1.0 0 0 0 0 0 0 1\n\n2.0 1 1 1 0 0 0 1\n";
assert_eq!(TumLoader::parse(text, path())?.len(), 2);
Ok(())
}
#[test]
fn a_seven_field_row_is_rejected() -> TestResult {
let Err(TrajectoryError::MissingColumns { expected, got, .. }) =
TumLoader::parse("1.0 0 0 0 0 0 0\n", path())
else {
return Err("a 7-field row must be refused".into());
};
assert_eq!((expected, got), (8, 7));
Ok(())
}
#[test]
fn a_non_numeric_field_is_rejected() -> TestResult {
let Err(TrajectoryError::InvalidNumber { field, .. }) =
TumLoader::parse("1.0 abc 0 0 0 0 0 1\n", path())
else {
return Err("a non-numeric field must be refused".into());
};
assert_eq!(field, "tx");
Ok(())
}
#[test]
fn a_non_unit_quaternion_is_rejected() -> TestResult {
let Err(TrajectoryError::InvalidQuaternion { .. }) =
TumLoader::parse("1.0 0 0 0 0 0 0 2\n", path())
else {
return Err("a norm-2 quaternion must be refused".into());
};
Ok(())
}
#[test]
fn a_negative_timestamp_is_rejected() -> TestResult {
let Err(TrajectoryError::NegativeTimestamp { .. }) =
TumLoader::parse("-1.0 0 0 0 0 0 0 1\n", path())
else {
return Err("a negative timestamp must be refused".into());
};
Ok(())
}
#[test]
fn rows_are_sorted_by_timestamp() -> TestResult {
let parsed = TumLoader::parse("2.0 0 0 0 0 0 0 1\n1.0 0 0 0 0 0 0 1\n", path())?;
assert_eq!(parsed.poses()[0].timestamp_ns, 1_000_000_000);
assert_eq!(parsed.poses()[1].timestamp_ns, 2_000_000_000);
Ok(())
}
#[test]
fn an_asl_csv_row_errors_rather_than_misparsing() -> TestResult {
let Err(TrajectoryError::MissingColumns { got, .. }) =
TumLoader::parse("1000,1.0,2.0,3.0,1.0,0.0,0.0,0.0\n", path())
else {
return Err("an ASL row must not parse as TUM".into());
};
assert_eq!(got, 1);
Ok(())
}
#[test]
fn writing_drops_inertial_columns_without_error() -> TestResult {
let poses = vec![TrajectoryPose::new(0, Vector3::zeros(), SO3::identity())];
let t = Trajectory::from_poses_and_inertial(
poses,
vec![super::super::InertialState::default()],
)?;
let parsed = TumLoader::parse(&TumLoader::render(&t), path())?;
assert_eq!(parsed.len(), 1);
assert!(!parsed.has_inertial());
Ok(())
}
}