modewise_dataset/
channels.rs

1//! Channel tables from atlas label files. Several formats, because atlases name and describe
2//! their regions differently (docs/regions.md).
3
4use std::path::Path;
5
6use modewise_brain::{ChannelInfo, ChannelTable, Hemisphere, Region};
7use serde::Deserialize;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum TableFormat {
12    /// Tab-separated with a header: `name` (required), `region`, `hemisphere`; `index` and
13    /// `color` are ignored; every other column becomes a tag. Covers BIDS `dseg.tsv`.
14    Tsv,
15    /// Schaefer or Yan (homotopic) cortical labels such as `17Networks_LH_DefaultB_PFCm_1`, one
16    /// per line or as a column of a lookup table: hemisphere, `yeo7`/`yeo17` network tag, region.
17    Schaefer,
18    /// Tian subcortical labels such as `lAMY-rh`, one per line: region and hemisphere.
19    Tian,
20    /// One name per line, with no further description.
21    Names,
22}
23
24pub fn read_table(path: &Path, format: TableFormat) -> Result<ChannelTable, String> {
25    let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
26    let lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
27    let channels: Vec<ChannelInfo> = match format {
28        TableFormat::Tsv => {
29            return parse_tsv(&text)
30                .and_then(|c| ChannelTable::new(c).map_err(|e| e.to_string()))
31                .map_err(|e| format!("{}: {e}", path.display()));
32        }
33        TableFormat::Schaefer => lines
34            .filter_map(|l| l.split_whitespace().find_map(schaefer_channel))
35            .collect(),
36        TableFormat::Tian => lines.map(tian_channel).collect(),
37        TableFormat::Names => lines.map(ChannelInfo::new).collect(),
38    };
39    if channels.is_empty() {
40        return Err(format!("{}: no channel labels found", path.display()));
41    }
42    ChannelTable::new(channels).map_err(|e| format!("{}: {e}", path.display()))
43}
44
45/// `<k>Networks_<LH|RH>_<network>[_<region>…][_<n>]`, as in the Schaefer and Yan parcellations.
46/// Returns `None` for text that is not such a label.
47pub fn schaefer_channel(label: &str) -> Option<ChannelInfo> {
48    let mut parts = label.split('_');
49    let prefix = parts.next()?;
50    let networks = prefix
51        .strip_suffix("Networks")
52        .or_else(|| prefix.strip_suffix("networks"))?;
53    if networks.is_empty() || !networks.bytes().all(|b| b.is_ascii_digit()) {
54        return None;
55    }
56    let hemisphere = Hemisphere::parse(parts.next()?)?;
57    let network = parts.next()?;
58    let mut rest: Vec<&str> = parts.collect();
59    if rest
60        .last()
61        .is_some_and(|p| p.bytes().all(|b| b.is_ascii_digit()))
62    {
63        rest.pop();
64    }
65    let channel = ChannelInfo::new(label)
66        .with_hemisphere(hemisphere)
67        .with_tag(format!("yeo{networks}"), network);
68    Some(if rest.is_empty() {
69        channel
70    } else {
71        channel.with_region(Region::new(rest.join("_")))
72    })
73}
74
75/// `<region>-<lh|rh>`, as in the Tian subcortex atlas; without a hemisphere suffix the whole
76/// label is the region.
77pub fn tian_channel(label: &str) -> ChannelInfo {
78    match label
79        .rsplit_once('-')
80        .and_then(|(region, side)| Some((region, Hemisphere::parse(side)?)))
81    {
82        Some((region, side)) => ChannelInfo::new(label)
83            .with_region(Region::new(region))
84            .with_hemisphere(side),
85        None => ChannelInfo::new(label).with_region(Region::new(label)),
86    }
87}
88
89fn parse_tsv(text: &str) -> Result<Vec<ChannelInfo>, String> {
90    let mut lines = text.lines().filter(|l| !l.trim().is_empty());
91    let header: Vec<&str> = lines
92        .next()
93        .ok_or("empty file")?
94        .split('\t')
95        .map(str::trim)
96        .collect();
97    let column = |name: &str| header.iter().position(|h| *h == name);
98    let name = column("name").ok_or("no `name` column")?;
99    let (region, hemisphere) = (column("region"), column("hemisphere"));
100    lines
101        .enumerate()
102        .map(|(row, line)| {
103            let cells: Vec<&str> = line.split('\t').map(str::trim).collect();
104            let cell = |i: usize| {
105                cells
106                    .get(i)
107                    .copied()
108                    .filter(|v| !v.is_empty() && *v != "n/a")
109            };
110            let mut channel =
111                ChannelInfo::new(cell(name).ok_or(format!("row {}: empty name", row + 2))?);
112            if let Some(r) = region.and_then(cell) {
113                channel = channel.with_region(Region::new(r));
114            }
115            if let Some(h) = hemisphere.and_then(cell) {
116                let side = Hemisphere::parse(h).ok_or(format!(
117                    "row {}: hemisphere {h:?} is not left or right",
118                    row + 2
119                ))?;
120                channel = channel.with_hemisphere(side);
121            }
122            for (i, key) in header.iter().enumerate() {
123                let described = [Some(name), region, hemisphere].contains(&Some(i));
124                if !described
125                    && !["index", "color"].contains(key)
126                    && let Some(value) = cell(i)
127                {
128                    channel = channel.with_tag(*key, value);
129                }
130            }
131            Ok(channel)
132        })
133        .collect()
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn schaefer_labels_give_hemisphere_network_and_region() {
142        let c = schaefer_channel("17networks_LH_DefaultB_PFCm_1").unwrap();
143        assert_eq!(
144            (
145                c.hemisphere(),
146                c.tag("yeo17"),
147                c.region().map(Region::as_str)
148            ),
149            (Some(Hemisphere::Left), Some("DefaultB"), Some("PFCm"))
150        );
151        let striate = schaefer_channel("17networks_RH_VisCent_Striate").unwrap();
152        assert_eq!(
153            striate.region().map(Region::as_str),
154            Some("Striate"),
155            "no trailing index"
156        );
157        let sommot = schaefer_channel("17networks_LH_SomMotB_7").unwrap();
158        assert_eq!(
159            (sommot.region(), sommot.tag("yeo17")),
160            (None, Some("SomMotB")),
161            "no region, only a network"
162        );
163        let seven = schaefer_channel("7Networks_RH_Default_pCunPCC_3").unwrap();
164        assert_eq!(
165            (seven.tag("yeo7"), seven.hemisphere()),
166            (Some("Default"), Some(Hemisphere::Right))
167        );
168        let two_part = schaefer_channel("17Networks_LH_ContA_PFCl_post_2").unwrap();
169        assert_eq!(two_part.region().map(Region::as_str), Some("PFCl_post"));
170        for not_a_label in [
171            "aHIP-rh",
172            "Networks_LH_X_1",
173            "17Networks_XH_X_1",
174            "17Networks_LH",
175            "1 12 48 255 255",
176        ] {
177            assert!(schaefer_channel(not_a_label).is_none(), "{not_a_label}");
178        }
179    }
180
181    #[test]
182    fn tian_labels_give_region_and_hemisphere() {
183        let c = tian_channel("NAc-shell-rh");
184        assert_eq!(
185            (c.region().map(Region::as_str), c.hemisphere()),
186            (Some("NAc-shell"), Some(Hemisphere::Right))
187        );
188        let c = tian_channel("lAMY-lh");
189        assert_eq!(
190            (c.region().map(Region::as_str), c.hemisphere()),
191            (Some("lAMY"), Some(Hemisphere::Left))
192        );
193        let c = tian_channel("brainstem");
194        assert_eq!(
195            (c.region().map(Region::as_str), c.hemisphere()),
196            (Some("brainstem"), None)
197        );
198    }
199
200    #[test]
201    fn tsv_tables_read_columns_and_tags() {
202        let text = "index\tname\tregion\themisphere\tcolor\tlobe\n1\tFp1\tfrontopolar\tL\t#fff\tfrontal\n2\tCz\tcentral\tn/a\t#000\t\n";
203        let channels = parse_tsv(text).unwrap();
204        assert_eq!(channels[0].tag("lobe"), Some("frontal"));
205        assert_eq!(channels[0].tag("color"), None);
206        assert_eq!(channels[0].hemisphere(), Some(Hemisphere::Left));
207        assert_eq!(
208            (channels[1].hemisphere(), channels[1].tag("lobe")),
209            (None, None)
210        );
211        assert!(parse_tsv("label\n").unwrap_err().contains("`name`"));
212        assert!(
213            parse_tsv("name\themisphere\nx\tup\n")
214                .unwrap_err()
215                .contains("row 2")
216        );
217        assert!(
218            parse_tsv("name\tregion\n\tPFC\n")
219                .unwrap_err()
220                .contains("empty name")
221        );
222        assert!(parse_tsv("").is_err());
223    }
224}