apex_solver/io/bal.rs
1//! Bundle Adjustment in the Large (BAL) dataset loader.
2//!
3//! This module provides functionality to load BAL format files, which are used
4//! for bundle adjustment benchmarks in computer vision.
5//!
6//! ## Format
7//!
8//! BAL files contain bundle adjustment problems with the following sequential structure:
9//! 1. **Header line**: `<num_cameras> <num_points> <num_observations>`
10//! 2. **Observations block**: One observation per line with format `<camera_idx> <point_idx> <x> <y>`
11//! 3. **Cameras block**: 9 sequential parameter lines per camera (one value per line)
12//! 4. **Points block**: 3 sequential coordinate lines per point (one value per line)
13//!
14//! ## Camera Model
15//!
16//! Uses Snavely's 9-parameter camera model from the Bundler structure-from-motion system:
17//! - **Rotation**: 3D axis-angle representation (rx, ry, rz) - 3 parameters
18//! - **Translation**: 3D vector (tx, ty, tz) - 3 parameters
19//! - **Focal length**: Single parameter (f) - 1 parameter
20//! - **Radial distortion**: Two coefficients (k1, k2) - 2 parameters
21//!
22//! For more details, see: <https://grail.cs.washington.edu/projects/bal/>
23//!
24//! ## Example
25//!
26//! ```no_run
27//! use apex_solver::io::BalLoader;
28//!
29//! let dataset = BalLoader::load("data/bundle_adjustment/problem-21-11315-pre.txt")?;
30//! println!("Loaded {} cameras, {} points, {} observations",
31//! dataset.cameras.len(),
32//! dataset.points.len(),
33//! dataset.observations.len());
34//! # Ok::<(), apex_solver::io::IoError>(())
35//! ```
36
37use super::IoError;
38use nalgebra::Vector3;
39use std::fs::File;
40use std::path::Path;
41
42/// Represents a camera using Snavely's 9-parameter camera model.
43///
44/// The camera model from Bundler uses:
45/// - Axis-angle rotation representation (compact 3-parameter rotation)
46/// - 3D translation vector
47/// - Single focal length parameter
48/// - Two radial distortion coefficients
49#[derive(Debug, Clone)]
50pub struct BalCamera {
51 /// Rotation as axis-angle representation (rx, ry, rz)
52 pub rotation: Vector3<f64>,
53 /// Translation vector (tx, ty, tz)
54 pub translation: Vector3<f64>,
55 /// Focal length
56 pub focal_length: f64,
57 /// First radial distortion coefficient
58 pub k1: f64,
59 /// Second radial distortion coefficient
60 pub k2: f64,
61}
62
63/// Represents a 3D point (landmark) in the bundle adjustment problem.
64#[derive(Debug, Clone)]
65pub struct BalPoint {
66 /// 3D position (x, y, z)
67 pub position: Vector3<f64>,
68}
69
70/// Represents an observation of a 3D point by a camera.
71///
72/// Each observation links a camera to a 3D point via a 2D pixel measurement.
73#[derive(Debug, Clone)]
74pub struct BalObservation {
75 /// Index of the observing camera
76 pub camera_index: usize,
77 /// Index of the observed 3D point
78 pub point_index: usize,
79 /// Pixel x-coordinate
80 pub x: f64,
81 /// Pixel y-coordinate
82 pub y: f64,
83}
84
85/// Complete bundle adjustment dataset loaded from a BAL file.
86#[derive(Debug, Clone)]
87pub struct BalDataset {
88 /// All cameras in the dataset
89 pub cameras: Vec<BalCamera>,
90 /// All 3D points (landmarks) in the dataset
91 pub points: Vec<BalPoint>,
92 /// All observations (camera-point correspondences)
93 pub observations: Vec<BalObservation>,
94}
95
96/// Loader for BAL (Bundle Adjustment in the Large) dataset files.
97pub struct BalLoader;
98
99impl BalLoader {
100 /// Loads a BAL dataset from a file.
101 ///
102 /// # Arguments
103 ///
104 /// * `path` - Path to the BAL format file
105 ///
106 /// # Returns
107 ///
108 /// Returns a `BalDataset` containing all cameras, points, and observations,
109 /// or an `IoError` if parsing fails.
110 ///
111 /// # Example
112 ///
113 /// ```no_run
114 /// use apex_solver::io::BalLoader;
115 ///
116 /// let dataset = BalLoader::load("data/bundle_adjustment/problem-21-11315-pre.txt")?;
117 /// assert_eq!(dataset.cameras.len(), 21);
118 /// assert_eq!(dataset.points.len(), 11315);
119 /// # Ok::<(), apex_solver::io::IoError>(())
120 /// ```
121 pub fn load(path: impl AsRef<Path>) -> Result<BalDataset, IoError> {
122 // Open file with error context
123 let file = File::open(path.as_ref()).map_err(|e| {
124 IoError::Io(e).log_with_source(format!("Failed to open BAL file: {:?}", path.as_ref()))
125 })?;
126
127 // Memory-map file for performance (following g2o.rs pattern)
128 let mmap = unsafe {
129 memmap2::Mmap::map(&file).map_err(|e| {
130 IoError::Io(e).log_with_source("Failed to memory-map BAL file".to_string())
131 })?
132 };
133
134 // Convert to UTF-8 string
135 let content = std::str::from_utf8(&mmap).map_err(|_| IoError::Parse {
136 line: 0,
137 message: "File is not valid UTF-8".to_string(),
138 })?;
139
140 // Create line iterator (skip empty lines, trim whitespace)
141 let mut lines = content
142 .lines()
143 .enumerate()
144 .map(|(idx, line)| (idx + 1, line.trim()))
145 .filter(|(_, line)| !line.is_empty());
146
147 // Parse header
148 let (num_cameras, num_points, num_observations) = Self::parse_header(&mut lines)?;
149
150 // Parse observations
151 let observations = Self::parse_observations(&mut lines, num_observations)?;
152
153 // Parse cameras
154 let cameras = Self::parse_cameras(&mut lines, num_cameras)?;
155
156 // Parse points
157 let points = Self::parse_points(&mut lines, num_points)?;
158
159 // Validate counts match header
160 if cameras.len() != num_cameras {
161 return Err(IoError::Parse {
162 line: 0,
163 message: format!(
164 "Camera count mismatch: header says {}, got {}",
165 num_cameras,
166 cameras.len()
167 ),
168 });
169 }
170
171 if points.len() != num_points {
172 return Err(IoError::Parse {
173 line: 0,
174 message: format!(
175 "Point count mismatch: header says {}, got {}",
176 num_points,
177 points.len()
178 ),
179 });
180 }
181
182 if observations.len() != num_observations {
183 return Err(IoError::Parse {
184 line: 0,
185 message: format!(
186 "Observation count mismatch: header says {}, got {}",
187 num_observations,
188 observations.len()
189 ),
190 });
191 }
192
193 Ok(BalDataset {
194 cameras,
195 points,
196 observations,
197 })
198 }
199
200 /// Parses the header line containing dataset dimensions.
201 fn parse_header<'a>(
202 lines: &mut impl Iterator<Item = (usize, &'a str)>,
203 ) -> Result<(usize, usize, usize), IoError> {
204 let (line_num, header_line) = lines.next().ok_or(IoError::Parse {
205 line: 1,
206 message: "Missing header line".to_string(),
207 })?;
208
209 let parts: Vec<&str> = header_line.split_whitespace().collect();
210 if parts.len() != 3 {
211 return Err(IoError::MissingFields { line: line_num });
212 }
213
214 let num_cameras = parts[0]
215 .parse::<usize>()
216 .map_err(|_| IoError::InvalidNumber {
217 line: line_num,
218 value: parts[0].to_string(),
219 })?;
220
221 let num_points = parts[1]
222 .parse::<usize>()
223 .map_err(|_| IoError::InvalidNumber {
224 line: line_num,
225 value: parts[1].to_string(),
226 })?;
227
228 let num_observations = parts[2]
229 .parse::<usize>()
230 .map_err(|_| IoError::InvalidNumber {
231 line: line_num,
232 value: parts[2].to_string(),
233 })?;
234
235 Ok((num_cameras, num_points, num_observations))
236 }
237
238 /// Parses the observations block.
239 fn parse_observations<'a>(
240 lines: &mut impl Iterator<Item = (usize, &'a str)>,
241 num_observations: usize,
242 ) -> Result<Vec<BalObservation>, IoError> {
243 let mut observations = Vec::with_capacity(num_observations);
244
245 for _ in 0..num_observations {
246 let (line_num, line) = lines.next().ok_or(IoError::Parse {
247 line: 0,
248 message: "Unexpected end of file in observations section".to_string(),
249 })?;
250
251 let parts: Vec<&str> = line.split_whitespace().collect();
252 if parts.len() != 4 {
253 return Err(IoError::MissingFields { line: line_num });
254 }
255
256 let camera_index = parts[0]
257 .parse::<usize>()
258 .map_err(|_| IoError::InvalidNumber {
259 line: line_num,
260 value: parts[0].to_string(),
261 })?;
262
263 let point_index = parts[1]
264 .parse::<usize>()
265 .map_err(|_| IoError::InvalidNumber {
266 line: line_num,
267 value: parts[1].to_string(),
268 })?;
269
270 let x = parts[2]
271 .parse::<f64>()
272 .map_err(|_| IoError::InvalidNumber {
273 line: line_num,
274 value: parts[2].to_string(),
275 })?;
276
277 let y = parts[3]
278 .parse::<f64>()
279 .map_err(|_| IoError::InvalidNumber {
280 line: line_num,
281 value: parts[3].to_string(),
282 })?;
283
284 observations.push(BalObservation {
285 camera_index,
286 point_index,
287 x,
288 y,
289 });
290 }
291
292 Ok(observations)
293 }
294
295 /// Parses the cameras block.
296 ///
297 /// Each camera has 9 parameters on sequential lines:
298 /// - 3 lines for rotation (rx, ry, rz)
299 /// - 3 lines for translation (tx, ty, tz)
300 /// - 1 line for focal length (f)
301 /// - 2 lines for radial distortion (k1, k2)
302 fn parse_cameras<'a>(
303 lines: &mut impl Iterator<Item = (usize, &'a str)>,
304 num_cameras: usize,
305 ) -> Result<Vec<BalCamera>, IoError> {
306 let mut cameras = Vec::with_capacity(num_cameras);
307
308 for camera_idx in 0..num_cameras {
309 let mut params = Vec::with_capacity(9);
310
311 // Read 9 consecutive lines for camera parameters
312 for param_idx in 0..9 {
313 let (line_num, line) = lines.next().ok_or(IoError::Parse {
314 line: 0,
315 message: format!(
316 "Unexpected end of file in camera {} parameter {}",
317 camera_idx, param_idx
318 ),
319 })?;
320
321 let value = line
322 .trim()
323 .parse::<f64>()
324 .map_err(|_| IoError::InvalidNumber {
325 line: line_num,
326 value: line.to_string(),
327 })?;
328
329 params.push(value);
330 }
331
332 cameras.push(BalCamera {
333 rotation: Vector3::new(params[0], params[1], params[2]),
334 translation: Vector3::new(params[3], params[4], params[5]),
335 focal_length: params[6],
336 k1: params[7],
337 k2: params[8],
338 });
339 }
340
341 Ok(cameras)
342 }
343
344 /// Parses the points block.
345 ///
346 /// Each point has 3 coordinates on sequential lines (x, y, z).
347 fn parse_points<'a>(
348 lines: &mut impl Iterator<Item = (usize, &'a str)>,
349 num_points: usize,
350 ) -> Result<Vec<BalPoint>, IoError> {
351 let mut points = Vec::with_capacity(num_points);
352
353 for point_idx in 0..num_points {
354 let mut coords = Vec::with_capacity(3);
355
356 // Read 3 consecutive lines for point coordinates
357 for coord_idx in 0..3 {
358 let (line_num, line) = lines.next().ok_or(IoError::Parse {
359 line: 0,
360 message: format!(
361 "Unexpected end of file in point {} coordinate {}",
362 point_idx, coord_idx
363 ),
364 })?;
365
366 let value = line
367 .trim()
368 .parse::<f64>()
369 .map_err(|_| IoError::InvalidNumber {
370 line: line_num,
371 value: line.to_string(),
372 })?;
373
374 coords.push(value);
375 }
376
377 points.push(BalPoint {
378 position: Vector3::new(coords[0], coords[1], coords[2]),
379 });
380 }
381
382 Ok(points)
383 }
384}