451 lines
14 KiB
Rust
451 lines
14 KiB
Rust
//! BrainVision format reader (.vhdr/.vmrk/.eeg files).
|
|
//!
|
|
//! BrainVision format consists of three files:
|
|
//! - `.vhdr` - Header file (INI-like format)
|
|
//! - `.vmrk` - Marker file (events)
|
|
//! - `.eeg` or `.dat` - Binary data file
|
|
|
|
use crate::{IoError, IoResult, NeuroReader};
|
|
use std::collections::HashMap;
|
|
use std::fs::File;
|
|
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// BrainVision data format
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum BrainVisionFormat {
|
|
/// Binary INT 16 (little endian)
|
|
Int16,
|
|
/// Binary IEEE float 32
|
|
Float32,
|
|
/// ASCII (text format)
|
|
Ascii,
|
|
}
|
|
|
|
/// BrainVision file header
|
|
#[derive(Debug, Clone)]
|
|
pub struct BrainVisionHeader {
|
|
/// Data file path (relative or absolute)
|
|
pub data_file: PathBuf,
|
|
/// Marker file path
|
|
pub marker_file: Option<PathBuf>,
|
|
/// Data format
|
|
pub format: BrainVisionFormat,
|
|
/// Data orientation (multiplexed or vectorized)
|
|
pub multiplexed: bool,
|
|
/// Number of channels
|
|
pub n_channels: usize,
|
|
/// Sampling interval in microseconds
|
|
pub sampling_interval_us: f64,
|
|
/// Channel information
|
|
pub channels: Vec<BrainVisionChannel>,
|
|
}
|
|
|
|
impl BrainVisionHeader {
|
|
/// Sampling frequency in Hz
|
|
#[must_use]
|
|
pub fn sfreq(&self) -> f64 {
|
|
1_000_000.0 / self.sampling_interval_us
|
|
}
|
|
}
|
|
|
|
/// BrainVision channel information
|
|
#[derive(Debug, Clone)]
|
|
pub struct BrainVisionChannel {
|
|
/// Channel name
|
|
pub name: String,
|
|
/// Reference channel name
|
|
pub reference: Option<String>,
|
|
/// Resolution (scaling factor to uV)
|
|
pub resolution: f64,
|
|
/// Unit (e.g., "µV")
|
|
pub unit: String,
|
|
}
|
|
|
|
/// BrainVision marker (event)
|
|
#[derive(Debug, Clone)]
|
|
pub struct BrainVisionMarker {
|
|
/// Marker type (e.g., "Stimulus", "Response")
|
|
pub marker_type: String,
|
|
/// Marker description
|
|
pub description: String,
|
|
/// Position in samples (1-based in file, 0-based here)
|
|
pub position: usize,
|
|
/// Duration in samples
|
|
pub duration: usize,
|
|
/// Channel (0 = all channels)
|
|
pub channel: usize,
|
|
}
|
|
|
|
/// BrainVision file reader
|
|
pub struct BrainVisionReader {
|
|
/// Header file path
|
|
header_path: PathBuf,
|
|
/// Parsed header
|
|
header: BrainVisionHeader,
|
|
/// Markers
|
|
markers: Vec<BrainVisionMarker>,
|
|
/// Total number of samples (calculated from file size)
|
|
n_samples: usize,
|
|
}
|
|
|
|
impl BrainVisionReader {
|
|
/// Open a BrainVision header file (.vhdr)
|
|
pub fn open(path: impl AsRef<Path>) -> IoResult<Self> {
|
|
let header_path = path.as_ref().to_path_buf();
|
|
|
|
if !header_path.exists() {
|
|
return Err(IoError::FileNotFound(header_path.display().to_string()));
|
|
}
|
|
|
|
let header = Self::parse_header(&header_path)?;
|
|
let markers = if let Some(ref marker_file) = header.marker_file {
|
|
let marker_path = header_path.parent().unwrap().join(marker_file);
|
|
Self::parse_markers(&marker_path)?
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
// Calculate number of samples from data file size
|
|
let data_path = header_path.parent().unwrap().join(&header.data_file);
|
|
let file_size = std::fs::metadata(&data_path)?.len() as usize;
|
|
let bytes_per_sample = match header.format {
|
|
BrainVisionFormat::Int16 => 2,
|
|
BrainVisionFormat::Float32 => 4,
|
|
BrainVisionFormat::Ascii => {
|
|
return Err(IoError::UnsupportedVersion(
|
|
"ASCII format not yet supported".to_string(),
|
|
));
|
|
}
|
|
};
|
|
let n_samples = file_size / (bytes_per_sample * header.n_channels);
|
|
|
|
Ok(Self {
|
|
header_path,
|
|
header,
|
|
markers,
|
|
n_samples,
|
|
})
|
|
}
|
|
|
|
/// Get parsed header
|
|
#[must_use]
|
|
pub fn header(&self) -> &BrainVisionHeader {
|
|
&self.header
|
|
}
|
|
|
|
/// Get markers
|
|
#[must_use]
|
|
pub fn markers(&self) -> &[BrainVisionMarker] {
|
|
&self.markers
|
|
}
|
|
|
|
/// Parse the header file
|
|
fn parse_header(path: &Path) -> IoResult<BrainVisionHeader> {
|
|
let file = File::open(path)?;
|
|
let reader = BufReader::new(file);
|
|
|
|
let mut sections: HashMap<String, HashMap<String, String>> = HashMap::new();
|
|
let mut current_section = String::new();
|
|
|
|
for line in reader.lines() {
|
|
let line = line?;
|
|
let line = line.trim();
|
|
|
|
if line.is_empty() || line.starts_with(';') {
|
|
continue;
|
|
}
|
|
|
|
if line.starts_with('[') && line.ends_with(']') {
|
|
current_section = line[1..line.len() - 1].to_string();
|
|
sections.insert(current_section.clone(), HashMap::new());
|
|
} else if let Some(pos) = line.find('=') {
|
|
let key = line[..pos].trim().to_string();
|
|
let value = line[pos + 1..].trim().to_string();
|
|
if let Some(section) = sections.get_mut(¤t_section) {
|
|
section.insert(key, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse Common Infos
|
|
let common = sections
|
|
.get("Common Infos")
|
|
.ok_or_else(|| IoError::HeaderParse("Missing [Common Infos] section".to_string()))?;
|
|
|
|
let data_file = common
|
|
.get("DataFile")
|
|
.ok_or_else(|| IoError::HeaderParse("Missing DataFile".to_string()))?
|
|
.into();
|
|
|
|
let marker_file = common.get("MarkerFile").map(|s| PathBuf::from(s));
|
|
|
|
let n_channels: usize = common
|
|
.get("NumberOfChannels")
|
|
.ok_or_else(|| IoError::HeaderParse("Missing NumberOfChannels".to_string()))?
|
|
.parse()
|
|
.map_err(|_| IoError::HeaderParse("Invalid NumberOfChannels".to_string()))?;
|
|
|
|
let sampling_interval_us: f64 = common
|
|
.get("SamplingInterval")
|
|
.ok_or_else(|| IoError::HeaderParse("Missing SamplingInterval".to_string()))?
|
|
.parse()
|
|
.map_err(|_| IoError::HeaderParse("Invalid SamplingInterval".to_string()))?;
|
|
|
|
// Parse Binary Infos
|
|
let binary = sections.get("Binary Infos");
|
|
let format = if let Some(binary) = binary {
|
|
match binary.get("BinaryFormat").map(String::as_str) {
|
|
Some("INT_16") => BrainVisionFormat::Int16,
|
|
Some("IEEE_FLOAT_32") => BrainVisionFormat::Float32,
|
|
_ => BrainVisionFormat::Int16,
|
|
}
|
|
} else {
|
|
BrainVisionFormat::Int16
|
|
};
|
|
|
|
let multiplexed = common
|
|
.get("DataOrientation")
|
|
.map(|s| s == "MULTIPLEXED")
|
|
.unwrap_or(true);
|
|
|
|
// Parse Channel Infos
|
|
let channel_info = sections.get("Channel Infos");
|
|
let mut channels = Vec::with_capacity(n_channels);
|
|
|
|
if let Some(ch_info) = channel_info {
|
|
for i in 1..=n_channels {
|
|
let key = format!("Ch{i}");
|
|
if let Some(value) = ch_info.get(&key) {
|
|
let parts: Vec<&str> = value.split(',').collect();
|
|
let name = parts.first().map(|s| s.trim().to_string()).unwrap_or(key);
|
|
let reference = parts.get(1).map(|s| s.trim().to_string());
|
|
let resolution: f64 = parts
|
|
.get(2)
|
|
.and_then(|s| s.trim().parse().ok())
|
|
.unwrap_or(1.0);
|
|
let unit = parts
|
|
.get(3)
|
|
.map(|s| s.trim().to_string())
|
|
.unwrap_or_else(|| "µV".to_string());
|
|
|
|
channels.push(BrainVisionChannel {
|
|
name,
|
|
reference,
|
|
resolution,
|
|
unit,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fill missing channels with defaults
|
|
while channels.len() < n_channels {
|
|
channels.push(BrainVisionChannel {
|
|
name: format!("Ch{}", channels.len() + 1),
|
|
reference: None,
|
|
resolution: 1.0,
|
|
unit: "µV".to_string(),
|
|
});
|
|
}
|
|
|
|
Ok(BrainVisionHeader {
|
|
data_file,
|
|
marker_file,
|
|
format,
|
|
multiplexed,
|
|
n_channels,
|
|
sampling_interval_us,
|
|
channels,
|
|
})
|
|
}
|
|
|
|
/// Parse marker file
|
|
fn parse_markers(path: &Path) -> IoResult<Vec<BrainVisionMarker>> {
|
|
if !path.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let file = File::open(path)?;
|
|
let reader = BufReader::new(file);
|
|
|
|
let mut markers = Vec::new();
|
|
let mut in_marker_section = false;
|
|
|
|
for line in reader.lines() {
|
|
let line = line?;
|
|
let line = line.trim();
|
|
|
|
if line.starts_with("[Marker Infos]") {
|
|
in_marker_section = true;
|
|
continue;
|
|
}
|
|
|
|
if line.starts_with('[') {
|
|
in_marker_section = false;
|
|
continue;
|
|
}
|
|
|
|
if !in_marker_section || line.is_empty() || line.starts_with(';') {
|
|
continue;
|
|
}
|
|
|
|
// Format: Mk<n>=<type>,<description>,<position>,<duration>,<channel>
|
|
if let Some(pos) = line.find('=') {
|
|
let value = &line[pos + 1..];
|
|
let parts: Vec<&str> = value.split(',').collect();
|
|
|
|
if parts.len() >= 4 {
|
|
let marker_type = parts[0].trim().to_string();
|
|
let description = parts[1].trim().to_string();
|
|
let position: usize = parts[2].trim().parse().unwrap_or(1) - 1; // Convert to 0-based
|
|
let duration: usize = parts[3].trim().parse().unwrap_or(1);
|
|
let channel: usize = parts
|
|
.get(4)
|
|
.and_then(|s| s.trim().parse().ok())
|
|
.unwrap_or(0);
|
|
|
|
markers.push(BrainVisionMarker {
|
|
marker_type,
|
|
description,
|
|
position,
|
|
duration,
|
|
channel,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(markers)
|
|
}
|
|
}
|
|
|
|
impl NeuroReader for BrainVisionReader {
|
|
fn read_header(&mut self) -> IoResult<()> {
|
|
// Header is already parsed in open()
|
|
Ok(())
|
|
}
|
|
|
|
fn sfreq(&self) -> f64 {
|
|
self.header.sfreq()
|
|
}
|
|
|
|
fn n_channels(&self) -> usize {
|
|
self.header.n_channels
|
|
}
|
|
|
|
fn n_samples(&self) -> usize {
|
|
self.n_samples
|
|
}
|
|
|
|
fn channel_names(&self) -> Vec<String> {
|
|
self.header
|
|
.channels
|
|
.iter()
|
|
.map(|c| c.name.clone())
|
|
.collect()
|
|
}
|
|
|
|
fn read_raw_data(&mut self, tmin: f64, tmax: f64) -> IoResult<Vec<f64>> {
|
|
let sfreq = self.header.sfreq();
|
|
let start_sample = (tmin * sfreq).floor() as usize;
|
|
let end_sample = (tmax * sfreq).ceil() as usize;
|
|
let n_samples = (end_sample - start_sample).min(self.n_samples - start_sample);
|
|
let n_channels = self.header.n_channels;
|
|
|
|
let data_path = self
|
|
.header_path
|
|
.parent()
|
|
.unwrap()
|
|
.join(&self.header.data_file);
|
|
let mut file = File::open(&data_path)?;
|
|
|
|
let bytes_per_sample = match self.header.format {
|
|
BrainVisionFormat::Int16 => 2,
|
|
BrainVisionFormat::Float32 => 4,
|
|
BrainVisionFormat::Ascii => {
|
|
return Err(IoError::UnsupportedVersion(
|
|
"ASCII format not supported".to_string(),
|
|
));
|
|
}
|
|
};
|
|
|
|
// Seek to start position
|
|
let start_byte = start_sample * n_channels * bytes_per_sample;
|
|
file.seek(SeekFrom::Start(start_byte as u64))?;
|
|
|
|
let mut data = vec![0.0; n_channels * n_samples];
|
|
|
|
if self.header.multiplexed {
|
|
// Data is interleaved: ch1_s1, ch2_s1, ... chN_s1, ch1_s2, ...
|
|
use byteorder::{LittleEndian, ReadBytesExt};
|
|
|
|
for s in 0..n_samples {
|
|
for ch in 0..n_channels {
|
|
let value = match self.header.format {
|
|
BrainVisionFormat::Int16 => {
|
|
let raw = file.read_i16::<LittleEndian>()?;
|
|
f64::from(raw) * self.header.channels[ch].resolution
|
|
}
|
|
BrainVisionFormat::Float32 => {
|
|
let raw = file.read_f32::<LittleEndian>()?;
|
|
f64::from(raw) * self.header.channels[ch].resolution
|
|
}
|
|
BrainVisionFormat::Ascii => unreachable!(),
|
|
};
|
|
|
|
// Store in channel-major order [ch0: s0, s1, ..., ch1: s0, s1, ...]
|
|
data[ch * n_samples + s] = value;
|
|
}
|
|
}
|
|
} else {
|
|
// Vectorized: all samples for ch1, then all for ch2, etc.
|
|
use byteorder::{LittleEndian, ReadBytesExt};
|
|
|
|
for ch in 0..n_channels {
|
|
let ch_start_byte =
|
|
ch * self.n_samples * bytes_per_sample + start_sample * bytes_per_sample;
|
|
file.seek(SeekFrom::Start(ch_start_byte as u64))?;
|
|
|
|
for s in 0..n_samples {
|
|
let value = match self.header.format {
|
|
BrainVisionFormat::Int16 => {
|
|
let raw = file.read_i16::<LittleEndian>()?;
|
|
f64::from(raw) * self.header.channels[ch].resolution
|
|
}
|
|
BrainVisionFormat::Float32 => {
|
|
let raw = file.read_f32::<LittleEndian>()?;
|
|
f64::from(raw) * self.header.channels[ch].resolution
|
|
}
|
|
BrainVisionFormat::Ascii => unreachable!(),
|
|
};
|
|
|
|
data[ch * n_samples + s] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(data)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_sfreq_calculation() {
|
|
let header = BrainVisionHeader {
|
|
data_file: PathBuf::from("test.eeg"),
|
|
marker_file: None,
|
|
format: BrainVisionFormat::Int16,
|
|
multiplexed: true,
|
|
n_channels: 32,
|
|
sampling_interval_us: 2000.0, // 500 Hz
|
|
channels: Vec::new(),
|
|
};
|
|
|
|
assert!((header.sfreq() - 500.0).abs() < 0.01);
|
|
}
|
|
}
|