Initial commit
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user