Initial commit

This commit is contained in:
redclawsystems
2026-03-04 00:08:42 +00:00
commit 4d88dc0584
4449 changed files with 1556714 additions and 0 deletions
@@ -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(&current_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);
}
}