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}