Skip to main content

bids_io/
gradient.rs

1//! DWI gradient table parsing (`.bval` and `.bvec` files).
2//!
3//! BIDS diffusion-weighted imaging stores gradient information in two
4//! companion files alongside the NIfTI image:
5//!
6//! - `_dwi.bval` — b-values, one per volume, whitespace-separated
7//! - `_dwi.bvec` — gradient directions, 3 rows × N columns
8//!
9//! This module parses both files into typed structures.
10//!
11//! # Example
12//!
13//! ```no_run
14//! use std::path::Path;
15//! use bids_io::gradient::{read_bvals, read_bvecs, GradientTable};
16//!
17//! let bvals = read_bvals(Path::new("sub-01_dwi.bval")).unwrap();
18//! let bvecs = read_bvecs(Path::new("sub-01_dwi.bvec")).unwrap();
19//! let table = GradientTable::new(bvals, bvecs).unwrap();
20//!
21//! println!("{} volumes, {} non-zero", table.n_volumes(), table.n_diffusion_volumes());
22//! for (i, (bval, dir)) in table.iter().enumerate() {
23//!     println!("vol {i}: b={bval:.0}, dir=[{:.3}, {:.3}, {:.3}]", dir[0], dir[1], dir[2]);
24//! }
25//! ```
26
27use bids_core::error::{BidsError, Result};
28use std::path::Path;
29
30/// A parsed b-value file: one f64 per volume.
31pub type Bvals = Vec<f64>;
32
33/// A parsed b-vector file: one [x, y, z] direction per volume.
34pub type Bvecs = Vec<[f64; 3]>;
35
36/// Read a `.bval` file — whitespace-separated b-values on one or more lines.
37///
38/// # Errors
39///
40/// Returns an error if the file can't be read or contains non-numeric values.
41pub fn read_bvals(path: &Path) -> Result<Bvals> {
42    let contents = std::fs::read_to_string(path)?;
43    let values: std::result::Result<Vec<f64>, _> = contents
44        .split_whitespace()
45        .map(|s| s.parse::<f64>())
46        .collect();
47    values.map_err(|e| BidsError::DataFormat(format!("Invalid bval file {}: {e}", path.display())))
48}
49
50/// Read a `.bvec` file — 3 rows of whitespace-separated values (x, y, z directions).
51///
52/// # Errors
53///
54/// Returns an error if the file can't be read, doesn't have exactly 3 rows,
55/// or contains non-numeric values.
56pub fn read_bvecs(path: &Path) -> Result<Bvecs> {
57    let contents = std::fs::read_to_string(path)?;
58    let rows: Vec<Vec<f64>> = contents
59        .lines()
60        .filter(|l| !l.trim().is_empty())
61        .map(|line| {
62            line.split_whitespace()
63                .map(|s| s.parse::<f64>())
64                .collect::<std::result::Result<Vec<f64>, _>>()
65        })
66        .collect::<std::result::Result<Vec<_>, _>>()
67        .map_err(|e| BidsError::DataFormat(format!("Invalid bvec file {}: {e}", path.display())))?;
68
69    if rows.len() != 3 {
70        return Err(BidsError::DataFormat(format!(
71            "bvec file {} has {} rows, expected 3",
72            path.display(),
73            rows.len()
74        )));
75    }
76
77    let n = rows[0].len();
78    if rows[1].len() != n || rows[2].len() != n {
79        return Err(BidsError::DataFormat(format!(
80            "bvec file {} has inconsistent row lengths ({}, {}, {})",
81            path.display(),
82            rows[0].len(),
83            rows[1].len(),
84            rows[2].len()
85        )));
86    }
87
88    let bvecs: Vec<[f64; 3]> = (0..n)
89        .map(|i| [rows[0][i], rows[1][i], rows[2][i]])
90        .collect();
91    Ok(bvecs)
92}
93
94/// Combined b-value + b-vector gradient table for a DWI acquisition.
95///
96/// Provides convenience methods for counting volumes, identifying b=0 images,
97/// and iterating over gradient directions.
98#[derive(Debug, Clone)]
99pub struct GradientTable {
100    /// B-values for each volume.
101    pub bvals: Bvals,
102    /// Gradient directions for each volume (unit vectors for diffusion, zero for b=0).
103    pub bvecs: Bvecs,
104}
105
106impl GradientTable {
107    /// Create a gradient table from b-values and b-vectors.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if the lengths don't match.
112    pub fn new(bvals: Bvals, bvecs: Bvecs) -> Result<Self> {
113        if bvals.len() != bvecs.len() {
114            return Err(BidsError::DataFormat(format!(
115                "bval count ({}) != bvec count ({})",
116                bvals.len(),
117                bvecs.len()
118            )));
119        }
120        Ok(Self { bvals, bvecs })
121    }
122
123    /// Load from companion `.bval` and `.bvec` files.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if either file can't be read/parsed or they have
128    /// different volume counts.
129    pub fn from_files(bval_path: &Path, bvec_path: &Path) -> Result<Self> {
130        let bvals = read_bvals(bval_path)?;
131        let bvecs = read_bvecs(bvec_path)?;
132        Self::new(bvals, bvecs)
133    }
134
135    /// Number of volumes.
136    #[must_use]
137    pub fn n_volumes(&self) -> usize {
138        self.bvals.len()
139    }
140
141    /// Number of diffusion-weighted volumes (b > 0).
142    #[must_use]
143    pub fn n_diffusion_volumes(&self) -> usize {
144        self.bvals.iter().filter(|&&b| b > 0.0).count()
145    }
146
147    /// Number of b=0 volumes.
148    #[must_use]
149    pub fn n_b0_volumes(&self) -> usize {
150        self.bvals.iter().filter(|&&b| b == 0.0).count()
151    }
152
153    /// Unique b-values (sorted).
154    #[must_use]
155    pub fn unique_bvals(&self) -> Vec<f64> {
156        let mut unique: Vec<f64> = Vec::new();
157        for &b in &self.bvals {
158            let rounded = (b * 10.0).round() / 10.0;
159            if !unique.iter().any(|&u| (u - rounded).abs() < 1.0) {
160                unique.push(rounded);
161            }
162        }
163        unique.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
164        unique
165    }
166
167    /// Iterate over (b-value, direction) pairs.
168    pub fn iter(&self) -> impl Iterator<Item = (f64, [f64; 3])> + '_ {
169        self.bvals.iter().copied().zip(self.bvecs.iter().copied())
170    }
171
172    /// Get indices of b=0 volumes.
173    #[must_use]
174    pub fn b0_indices(&self) -> Vec<usize> {
175        self.bvals
176            .iter()
177            .enumerate()
178            .filter(|(_, b)| **b == 0.0)
179            .map(|(i, _)| i)
180            .collect()
181    }
182
183    /// Check if the gradient directions are approximately unit-normalized
184    /// for non-zero b-values.
185    #[must_use]
186    pub fn is_normalized(&self) -> bool {
187        self.iter().all(|(b, [x, y, z])| {
188            if b == 0.0 {
189                true
190            } else {
191                let norm = (x * x + y * y + z * z).sqrt();
192                (norm - 1.0).abs() < 0.1
193            }
194        })
195    }
196}
197
198impl std::fmt::Display for GradientTable {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        let unique = self.unique_bvals();
201        write!(
202            f,
203            "GradientTable({} volumes: {} b=0, {} diffusion, b-values: {:?})",
204            self.n_volumes(),
205            self.n_b0_volumes(),
206            self.n_diffusion_volumes(),
207            unique
208        )
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use std::io::Write;
216
217    #[test]
218    fn test_read_bvals() {
219        let dir = std::env::temp_dir().join("bids_gradient_test");
220        std::fs::create_dir_all(&dir).unwrap();
221        let path = dir.join("test.bval");
222        std::fs::write(&path, "0 1000 1000 2000 0\n").unwrap();
223
224        let bvals = read_bvals(&path).unwrap();
225        assert_eq!(bvals, vec![0.0, 1000.0, 1000.0, 2000.0, 0.0]);
226        std::fs::remove_dir_all(&dir).unwrap();
227    }
228
229    #[test]
230    fn test_read_bvecs() {
231        let dir = std::env::temp_dir().join("bids_bvec_test");
232        std::fs::create_dir_all(&dir).unwrap();
233        let path = dir.join("test.bvec");
234        let mut f = std::fs::File::create(&path).unwrap();
235        writeln!(f, "1.0 0.0 0.5").unwrap();
236        writeln!(f, "0.0 1.0 0.5").unwrap();
237        writeln!(f, "0.0 0.0 0.707").unwrap();
238
239        let bvecs = read_bvecs(&path).unwrap();
240        assert_eq!(bvecs.len(), 3);
241        assert_eq!(bvecs[0], [1.0, 0.0, 0.0]);
242        assert_eq!(bvecs[1], [0.0, 1.0, 0.0]);
243        std::fs::remove_dir_all(&dir).unwrap();
244    }
245
246    #[test]
247    fn test_gradient_table() {
248        let bvals = vec![0.0, 1000.0, 1000.0, 2000.0, 0.0];
249        let bvecs = vec![
250            [0.0, 0.0, 0.0],
251            [1.0, 0.0, 0.0],
252            [0.0, 1.0, 0.0],
253            [0.0, 0.0, 1.0],
254            [0.0, 0.0, 0.0],
255        ];
256        let table = GradientTable::new(bvals, bvecs).unwrap();
257
258        assert_eq!(table.n_volumes(), 5);
259        assert_eq!(table.n_b0_volumes(), 2);
260        assert_eq!(table.n_diffusion_volumes(), 3);
261        assert_eq!(table.b0_indices(), vec![0, 4]);
262
263        let unique = table.unique_bvals();
264        assert_eq!(unique.len(), 3); // 0, 1000, 2000
265    }
266
267    #[test]
268    fn test_gradient_table_mismatch() {
269        let bvals = vec![0.0, 1000.0];
270        let bvecs = vec![[0.0, 0.0, 0.0]]; // length mismatch
271        assert!(GradientTable::new(bvals, bvecs).is_err());
272    }
273
274    #[test]
275    fn test_display() {
276        let bvals = vec![0.0, 1000.0, 1000.0];
277        let bvecs = vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
278        let table = GradientTable::new(bvals, bvecs).unwrap();
279        let s = table.to_string();
280        assert!(s.contains("3 volumes"));
281        assert!(s.contains("1 b=0"));
282    }
283}