use super::IoError;
use nalgebra::Vector3;
use std::fs::File;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct BalCamera {
pub rotation: Vector3<f64>,
pub translation: Vector3<f64>,
pub focal_length: f64,
pub k1: f64,
pub k2: f64,
}
#[derive(Debug, Clone)]
pub struct BalPoint {
pub position: Vector3<f64>,
}
#[derive(Debug, Clone)]
pub struct BalObservation {
pub camera_index: usize,
pub point_index: usize,
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone)]
pub struct BalDataset {
pub cameras: Vec<BalCamera>,
pub points: Vec<BalPoint>,
pub observations: Vec<BalObservation>,
}
pub struct BalLoader;
pub const DEFAULT_FOCAL_LENGTH: f64 = 500.0;
impl BalCamera {
fn normalize_focal_length(focal_length: f64) -> f64 {
if focal_length > 0.0 && focal_length.is_finite() {
focal_length
} else {
DEFAULT_FOCAL_LENGTH
}
}
}
impl BalLoader {
pub fn load(path: impl AsRef<Path>) -> Result<BalDataset, IoError> {
let file = File::open(path.as_ref()).map_err(|e| {
IoError::Io(e).log_with_source(format!("Failed to open BAL file: {:?}", path.as_ref()))
})?;
let mmap = unsafe {
memmap2::Mmap::map(&file).map_err(|e| {
IoError::Io(e).log_with_source("Failed to memory-map BAL file".to_string())
})?
};
let content = std::str::from_utf8(&mmap).map_err(|_| IoError::Parse {
line: 0,
message: "File is not valid UTF-8".to_string(),
})?;
let mut lines = content
.lines()
.enumerate()
.map(|(idx, line)| (idx + 1, line.trim()))
.filter(|(_, line)| !line.is_empty());
let (num_cameras, num_points, num_observations) = Self::parse_header(&mut lines)?;
let observations = Self::parse_observations(&mut lines, num_observations)?;
let cameras = Self::parse_cameras(&mut lines, num_cameras)?;
let points = Self::parse_points(&mut lines, num_points)?;
if cameras.len() != num_cameras {
return Err(IoError::Parse {
line: 0,
message: format!(
"Camera count mismatch: header says {}, got {}",
num_cameras,
cameras.len()
),
});
}
if points.len() != num_points {
return Err(IoError::Parse {
line: 0,
message: format!(
"Point count mismatch: header says {}, got {}",
num_points,
points.len()
),
});
}
if observations.len() != num_observations {
return Err(IoError::Parse {
line: 0,
message: format!(
"Observation count mismatch: header says {}, got {}",
num_observations,
observations.len()
),
});
}
Ok(BalDataset {
cameras,
points,
observations,
})
}
fn parse_header<'a>(
lines: &mut impl Iterator<Item = (usize, &'a str)>,
) -> Result<(usize, usize, usize), IoError> {
let (line_num, header_line) = lines.next().ok_or(IoError::Parse {
line: 1,
message: "Missing header line".to_string(),
})?;
let parts: Vec<&str> = header_line.split_whitespace().collect();
if parts.len() != 3 {
return Err(IoError::MissingFields { line: line_num });
}
let num_cameras = parts[0]
.parse::<usize>()
.map_err(|_| IoError::InvalidNumber {
line: line_num,
value: parts[0].to_string(),
})?;
let num_points = parts[1]
.parse::<usize>()
.map_err(|_| IoError::InvalidNumber {
line: line_num,
value: parts[1].to_string(),
})?;
let num_observations = parts[2]
.parse::<usize>()
.map_err(|_| IoError::InvalidNumber {
line: line_num,
value: parts[2].to_string(),
})?;
Ok((num_cameras, num_points, num_observations))
}
fn parse_observations<'a>(
lines: &mut impl Iterator<Item = (usize, &'a str)>,
num_observations: usize,
) -> Result<Vec<BalObservation>, IoError> {
let mut observations = Vec::with_capacity(num_observations);
for _ in 0..num_observations {
let (line_num, line) = lines.next().ok_or(IoError::Parse {
line: 0,
message: "Unexpected end of file in observations section".to_string(),
})?;
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() != 4 {
return Err(IoError::MissingFields { line: line_num });
}
let camera_index = parts[0]
.parse::<usize>()
.map_err(|_| IoError::InvalidNumber {
line: line_num,
value: parts[0].to_string(),
})?;
let point_index = parts[1]
.parse::<usize>()
.map_err(|_| IoError::InvalidNumber {
line: line_num,
value: parts[1].to_string(),
})?;
let x = parts[2]
.parse::<f64>()
.map_err(|_| IoError::InvalidNumber {
line: line_num,
value: parts[2].to_string(),
})?;
let y = parts[3]
.parse::<f64>()
.map_err(|_| IoError::InvalidNumber {
line: line_num,
value: parts[3].to_string(),
})?;
observations.push(BalObservation {
camera_index,
point_index,
x,
y,
});
}
Ok(observations)
}
fn parse_cameras<'a>(
lines: &mut impl Iterator<Item = (usize, &'a str)>,
num_cameras: usize,
) -> Result<Vec<BalCamera>, IoError> {
let mut cameras = Vec::with_capacity(num_cameras);
for camera_idx in 0..num_cameras {
let mut params = Vec::with_capacity(9);
for param_idx in 0..9 {
let (line_num, line) = lines.next().ok_or(IoError::Parse {
line: 0,
message: format!(
"Unexpected end of file in camera {} parameter {}",
camera_idx, param_idx
),
})?;
let value = line
.trim()
.parse::<f64>()
.map_err(|_| IoError::InvalidNumber {
line: line_num,
value: line.to_string(),
})?;
params.push(value);
}
cameras.push(BalCamera {
rotation: Vector3::new(params[0], params[1], params[2]),
translation: Vector3::new(params[3], params[4], params[5]),
focal_length: BalCamera::normalize_focal_length(params[6]),
k1: params[7],
k2: params[8],
});
}
Ok(cameras)
}
fn parse_points<'a>(
lines: &mut impl Iterator<Item = (usize, &'a str)>,
num_points: usize,
) -> Result<Vec<BalPoint>, IoError> {
let mut points = Vec::with_capacity(num_points);
for point_idx in 0..num_points {
let mut coords = Vec::with_capacity(3);
for coord_idx in 0..3 {
let (line_num, line) = lines.next().ok_or(IoError::Parse {
line: 0,
message: format!(
"Unexpected end of file in point {} coordinate {}",
point_idx, coord_idx
),
})?;
let value = line
.trim()
.parse::<f64>()
.map_err(|_| IoError::InvalidNumber {
line: line_num,
value: line.to_string(),
})?;
coords.push(value);
}
points.push(BalPoint {
position: Vector3::new(coords[0], coords[1], coords[2]),
});
}
Ok(points)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn write_minimal_bal() -> Result<NamedTempFile, Box<dyn std::error::Error>> {
let mut f = NamedTempFile::new()?;
writeln!(f, "1 1 1")?; writeln!(f, "0 0 -123.456 456.789")?; for v in [0.1f64, 0.2, 0.3, 0.4, 0.5, 0.6, 500.0, -0.1, 0.05] {
writeln!(f, "{v}")?;
}
for v in [1.0f64, 2.0, 3.0] {
writeln!(f, "{v}")?;
}
f.flush()?;
Ok(f)
}
fn write_bal_with_focal(focal: f64) -> Result<NamedTempFile, Box<dyn std::error::Error>> {
let mut f = NamedTempFile::new()?;
writeln!(f, "1 1 1")?;
writeln!(f, "0 0 0.0 0.0")?; for v in [0.0f64, 0.0, 0.0, 0.0, 0.0, 0.0, focal, 0.0, 0.0] {
writeln!(f, "{v}")?;
}
for v in [0.0f64, 0.0, 0.0] {
writeln!(f, "{v}")?;
}
f.flush()?;
Ok(f)
}
#[test]
fn test_load_minimal_dataset() -> TestResult {
let f = write_minimal_bal()?;
let ds = BalLoader::load(f.path())?;
assert_eq!(ds.cameras.len(), 1);
assert_eq!(ds.points.len(), 1);
assert_eq!(ds.observations.len(), 1);
Ok(())
}
#[test]
fn test_load_camera_values() -> TestResult {
let f = write_minimal_bal()?;
let ds = BalLoader::load(f.path())?;
let cam = &ds.cameras[0];
assert!((cam.rotation.x - 0.1).abs() < 1e-12);
assert!((cam.rotation.y - 0.2).abs() < 1e-12);
assert!((cam.rotation.z - 0.3).abs() < 1e-12);
assert!((cam.translation.x - 0.4).abs() < 1e-12);
assert!((cam.translation.y - 0.5).abs() < 1e-12);
assert!((cam.translation.z - 0.6).abs() < 1e-12);
assert!((cam.focal_length - 500.0).abs() < 1e-12);
assert!((cam.k1 - (-0.1)).abs() < 1e-12);
assert!((cam.k2 - 0.05).abs() < 1e-12);
Ok(())
}
#[test]
fn test_load_observation_values() -> TestResult {
let f = write_minimal_bal()?;
let ds = BalLoader::load(f.path())?;
let obs = &ds.observations[0];
assert_eq!(obs.camera_index, 0);
assert_eq!(obs.point_index, 0);
assert!((obs.x - (-123.456)).abs() < 1e-10);
assert!((obs.y - 456.789).abs() < 1e-10);
Ok(())
}
#[test]
fn test_load_point_values() -> TestResult {
let f = write_minimal_bal()?;
let ds = BalLoader::load(f.path())?;
let pt = &ds.points[0];
assert!((pt.position.x - 1.0).abs() < 1e-12);
assert!((pt.position.y - 2.0).abs() < 1e-12);
assert!((pt.position.z - 3.0).abs() < 1e-12);
Ok(())
}
#[test]
fn test_normalize_focal_length_negative_uses_default() -> TestResult {
let f = write_bal_with_focal(-100.0)?;
let ds = BalLoader::load(f.path())?;
assert!(
(ds.cameras[0].focal_length - DEFAULT_FOCAL_LENGTH).abs() < 1e-12,
"negative focal length should be replaced with DEFAULT_FOCAL_LENGTH"
);
Ok(())
}
#[test]
fn test_normalize_focal_length_zero_uses_default() -> TestResult {
let f = write_bal_with_focal(0.0)?;
let ds = BalLoader::load(f.path())?;
assert!(
(ds.cameras[0].focal_length - DEFAULT_FOCAL_LENGTH).abs() < 1e-12,
"zero focal length should be replaced with DEFAULT_FOCAL_LENGTH"
);
Ok(())
}
#[test]
fn test_normalize_focal_length_positive_preserved() -> TestResult {
let f = write_bal_with_focal(300.0)?;
let ds = BalLoader::load(f.path())?;
assert!(
(ds.cameras[0].focal_length - 300.0).abs() < 1e-12,
"positive focal length should be preserved"
);
Ok(())
}
#[test]
fn test_load_nonexistent_file() {
let result = BalLoader::load("/nonexistent/path/file.bal");
assert!(result.is_err(), "loading a missing file should return Err");
}
#[test]
fn test_load_empty_file() -> TestResult {
let f = NamedTempFile::new()?;
let result = BalLoader::load(f.path());
assert!(result.is_err(), "empty file should fail (missing header)");
Ok(())
}
#[test]
fn test_load_header_wrong_field_count() -> TestResult {
let mut f = NamedTempFile::new()?;
writeln!(f, "1 1")?; f.flush()?;
let result = BalLoader::load(f.path());
assert!(result.is_err(), "header with 2 fields should fail");
Ok(())
}
#[test]
fn test_load_header_invalid_number() -> TestResult {
let mut f = NamedTempFile::new()?;
writeln!(f, "1 abc 1")?;
f.flush()?;
let result = BalLoader::load(f.path());
assert!(result.is_err(), "non-numeric header field should fail");
Ok(())
}
#[test]
fn test_load_truncated_observations() -> TestResult {
let mut f = NamedTempFile::new()?;
writeln!(f, "1 1 2")?; writeln!(f, "0 0 1.0 1.0")?; f.flush()?;
let result = BalLoader::load(f.path());
assert!(result.is_err(), "truncated observation block should fail");
Ok(())
}
#[test]
fn test_load_truncated_cameras() -> TestResult {
let mut f = NamedTempFile::new()?;
writeln!(f, "1 1 1")?;
writeln!(f, "0 0 1.0 1.0")?; for v in [0.0f64, 0.0, 0.0, 0.0, 0.0] {
writeln!(f, "{v}")?;
}
f.flush()?;
let result = BalLoader::load(f.path());
assert!(result.is_err(), "truncated camera block should fail");
Ok(())
}
#[test]
fn test_load_multiple_cameras_and_points() -> TestResult {
let mut f = NamedTempFile::new()?;
writeln!(f, "2 2 3")?; writeln!(f, "0 0 1.0 1.0")?;
writeln!(f, "0 1 2.0 2.0")?;
writeln!(f, "1 0 3.0 3.0")?;
for v in [0.0f64; 9] {
writeln!(f, "{v}")?;
}
for _ in 0..8 {
writeln!(f, "0.0")?;
}
writeln!(f, "200.0")?; for v in [1.0f64, 2.0, 3.0] {
writeln!(f, "{v}")?;
}
for v in [4.0f64, 5.0, 6.0] {
writeln!(f, "{v}")?;
}
f.flush()?;
let ds = BalLoader::load(f.path())?;
assert_eq!(ds.cameras.len(), 2);
assert_eq!(ds.points.len(), 2);
assert_eq!(ds.observations.len(), 3);
Ok(())
}
#[test]
fn test_load_observation_invalid_number() -> TestResult {
let mut f = NamedTempFile::new()?;
writeln!(f, "1 1 1")?;
writeln!(f, "0 0 bad_x 1.0")?; f.flush()?;
let result = BalLoader::load(f.path());
assert!(
result.is_err(),
"invalid observation coordinate should fail"
);
Ok(())
}
#[test]
fn test_load_header_invalid_num_cameras() -> TestResult {
let mut f = NamedTempFile::new()?;
writeln!(f, "bad 1 1")?;
f.flush()?;
let result = BalLoader::load(f.path());
assert!(
matches!(result, Err(IoError::InvalidNumber { .. })),
"invalid num_cameras should return InvalidNumber"
);
Ok(())
}
#[test]
fn test_load_header_invalid_num_observations() -> TestResult {
let mut f = NamedTempFile::new()?;
writeln!(f, "1 1 bad")?;
f.flush()?;
let result = BalLoader::load(f.path());
assert!(
matches!(result, Err(IoError::InvalidNumber { .. })),
"invalid num_observations should return InvalidNumber"
);
Ok(())
}
#[test]
fn test_load_observation_missing_fields() -> TestResult {
let mut f = NamedTempFile::new()?;
writeln!(f, "1 1 1")?;
writeln!(f, "0 1.0")?; f.flush()?;
let result = BalLoader::load(f.path());
assert!(
matches!(result, Err(IoError::MissingFields { .. })),
"observation with too few fields should return MissingFields"
);
Ok(())
}
#[test]
fn test_load_observation_invalid_camera_index() -> TestResult {
let mut f = NamedTempFile::new()?;
writeln!(f, "1 1 1")?;
writeln!(f, "bad 0 1.0 2.0")?;
f.flush()?;
let result = BalLoader::load(f.path());
assert!(
matches!(result, Err(IoError::InvalidNumber { .. })),
"invalid camera_index in observation should return InvalidNumber"
);
Ok(())
}
#[test]
fn test_load_observation_invalid_point_index() -> TestResult {
let mut f = NamedTempFile::new()?;
writeln!(f, "1 1 1")?;
writeln!(f, "0 bad 1.0 2.0")?;
f.flush()?;
let result = BalLoader::load(f.path());
assert!(
matches!(result, Err(IoError::InvalidNumber { .. })),
"invalid point_index in observation should return InvalidNumber"
);
Ok(())
}
#[test]
fn test_load_observation_invalid_y() -> TestResult {
let mut f = NamedTempFile::new()?;
writeln!(f, "1 1 1")?;
writeln!(f, "0 0 1.0 bad")?;
f.flush()?;
let result = BalLoader::load(f.path());
assert!(
matches!(result, Err(IoError::InvalidNumber { .. })),
"invalid y in observation should return InvalidNumber"
);
Ok(())
}
#[test]
fn test_load_camera_invalid_parameter() -> TestResult {
let mut f = NamedTempFile::new()?;
writeln!(f, "1 1 1")?;
writeln!(f, "0 0 1.0 1.0")?; writeln!(f, "bad")?; f.flush()?;
let result = BalLoader::load(f.path());
assert!(
matches!(result, Err(IoError::InvalidNumber { .. })),
"invalid camera parameter should return InvalidNumber"
);
Ok(())
}
#[test]
fn test_load_truncated_points() -> TestResult {
let mut f = NamedTempFile::new()?;
writeln!(f, "1 1 1")?;
writeln!(f, "0 0 1.0 1.0")?; for v in [0.0f64, 0.0, 0.0, 0.0, 0.0, 0.0, 500.0, 0.0, 0.0] {
writeln!(f, "{v}")?;
}
writeln!(f, "1.0")?;
writeln!(f, "2.0")?;
f.flush()?;
let result = BalLoader::load(f.path());
assert!(result.is_err(), "truncated point block should fail");
Ok(())
}
#[test]
fn test_load_point_invalid_coordinate() -> TestResult {
let mut f = NamedTempFile::new()?;
writeln!(f, "1 1 1")?;
writeln!(f, "0 0 1.0 1.0")?; for v in [0.0f64, 0.0, 0.0, 0.0, 0.0, 0.0, 500.0, 0.0, 0.0] {
writeln!(f, "{v}")?;
}
writeln!(f, "bad")?; f.flush()?;
let result = BalLoader::load(f.path());
assert!(
matches!(result, Err(IoError::InvalidNumber { .. })),
"invalid point coordinate should return InvalidNumber"
);
Ok(())
}
}