Skip to main content

bids_meg/
headshape.rs

1//! MEG headshape digitization point parsing (`.pos` files).
2//!
3//! The `_headshape.pos` file contains 3D coordinates of digitized head points
4//! used for MEG-MRI coregistration. Each line has a label and x/y/z coordinates.
5
6use bids_core::error::{BidsError, Result};
7use std::path::Path;
8
9/// A single digitization point from a headshape file.
10#[derive(Debug, Clone, PartialEq)]
11pub struct DigPoint {
12    /// Point label (e.g., "nasion", "lpa", "rpa", or numeric index).
13    pub label: String,
14    /// X coordinate in mm.
15    pub x: f64,
16    /// Y coordinate in mm.
17    pub y: f64,
18    /// Z coordinate in mm.
19    pub z: f64,
20    /// Point category if detectable (fiducial, extra, EEG, HPI).
21    pub kind: PointKind,
22}
23
24/// Category of a digitization point.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum PointKind {
27    /// Anatomical fiducial (nasion, LPA, RPA).
28    Fiducial,
29    /// Head Position Indicator coil.
30    Hpi,
31    /// EEG electrode position.
32    Eeg,
33    /// Extra head surface point (for coregistration).
34    Extra,
35}
36
37/// Read a headshape `.pos` file.
38///
39/// The format is whitespace-delimited with columns: label x y z
40/// (some files omit labels, in which case indices are used).
41///
42/// # Errors
43///
44/// Returns an error if the file can't be read or contains malformed lines.
45pub fn read_headshape_pos(path: &Path) -> Result<Vec<DigPoint>> {
46    let contents = std::fs::read_to_string(path)?;
47    let mut points = Vec::new();
48
49    for (line_no, line) in contents.lines().enumerate() {
50        let line = line.trim();
51        if line.is_empty() || line.starts_with('#') || line.starts_with('%') {
52            continue;
53        }
54
55        let parts: Vec<&str> = line.split_whitespace().collect();
56
57        let (label, x, y, z) = if parts.len() >= 4 {
58            // label x y z
59            let x = parts[1]
60                .parse::<f64>()
61                .map_err(|_| parse_err(path, line_no))?;
62            let y = parts[2]
63                .parse::<f64>()
64                .map_err(|_| parse_err(path, line_no))?;
65            let z = parts[3]
66                .parse::<f64>()
67                .map_err(|_| parse_err(path, line_no))?;
68            (parts[0].to_string(), x, y, z)
69        } else if parts.len() == 3 {
70            // x y z (no label)
71            let x = parts[0]
72                .parse::<f64>()
73                .map_err(|_| parse_err(path, line_no))?;
74            let y = parts[1]
75                .parse::<f64>()
76                .map_err(|_| parse_err(path, line_no))?;
77            let z = parts[2]
78                .parse::<f64>()
79                .map_err(|_| parse_err(path, line_no))?;
80            (format!("{}", points.len() + 1), x, y, z)
81        } else {
82            continue; // skip malformed lines
83        };
84
85        let kind = classify_point(&label);
86        points.push(DigPoint {
87            label,
88            x,
89            y,
90            z,
91            kind,
92        });
93    }
94
95    Ok(points)
96}
97
98fn classify_point(label: &str) -> PointKind {
99    let lower = label.to_lowercase();
100    if lower == "nasion"
101        || lower == "nas"
102        || lower == "lpa"
103        || lower == "rpa"
104        || lower == "nz"
105        || lower == "left"
106        || lower == "right"
107    {
108        PointKind::Fiducial
109    } else if lower.starts_with("hpi") || lower.starts_with("coil") {
110        PointKind::Hpi
111    } else if lower.starts_with("eeg") || lower.starts_with("e") && lower.len() <= 4 {
112        PointKind::Eeg
113    } else {
114        PointKind::Extra
115    }
116}
117
118fn parse_err(path: &Path, line: usize) -> BidsError {
119    BidsError::DataFormat(format!(
120        "Cannot parse headshape file {} at line {}",
121        path.display(),
122        line + 1,
123    ))
124}
125
126/// Count of points by category.
127#[must_use]
128pub fn count_by_kind(points: &[DigPoint]) -> (usize, usize, usize, usize) {
129    let mut fid = 0;
130    let mut hpi = 0;
131    let mut eeg = 0;
132    let mut extra = 0;
133    for p in points {
134        match p.kind {
135            PointKind::Fiducial => fid += 1,
136            PointKind::Hpi => hpi += 1,
137            PointKind::Eeg => eeg += 1,
138            PointKind::Extra => extra += 1,
139        }
140    }
141    (fid, hpi, eeg, extra)
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use std::io::Write;
148
149    #[test]
150    fn test_read_headshape_pos() {
151        let dir = std::env::temp_dir().join("bids_headshape_test");
152        std::fs::create_dir_all(&dir).unwrap();
153        let path = dir.join("test.pos");
154        let mut f = std::fs::File::create(&path).unwrap();
155        writeln!(f, "nasion 0.0 85.0 -40.0").unwrap();
156        writeln!(f, "lpa -80.0 0.0 -40.0").unwrap();
157        writeln!(f, "rpa 80.0 0.0 -40.0").unwrap();
158        writeln!(f, "1 10.0 20.0 30.0").unwrap();
159        writeln!(f, "2 11.0 21.0 31.0").unwrap();
160
161        let points = read_headshape_pos(&path).unwrap();
162        assert_eq!(points.len(), 5);
163        assert_eq!(points[0].kind, PointKind::Fiducial);
164        assert_eq!(points[0].label, "nasion");
165        assert!((points[0].y - 85.0).abs() < 1e-10);
166        assert_eq!(points[3].kind, PointKind::Extra);
167
168        let (fid, _, _, extra) = count_by_kind(&points);
169        assert_eq!(fid, 3);
170        assert_eq!(extra, 2);
171
172        std::fs::remove_dir_all(&dir).unwrap();
173    }
174
175    #[test]
176    fn test_read_no_labels() {
177        let dir = std::env::temp_dir().join("bids_headshape_nolabel");
178        std::fs::create_dir_all(&dir).unwrap();
179        let path = dir.join("test.pos");
180        let mut f = std::fs::File::create(&path).unwrap();
181        writeln!(f, "10.0 20.0 30.0").unwrap();
182        writeln!(f, "11.0 21.0 31.0").unwrap();
183
184        let points = read_headshape_pos(&path).unwrap();
185        assert_eq!(points.len(), 2);
186        assert_eq!(points[0].label, "1");
187
188        std::fs::remove_dir_all(&dir).unwrap();
189    }
190}