From 7e600deef29be4dff777f5bad6fa261a1d84ce31 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Sun, 28 Sep 2025 01:11:50 -0700 Subject: [PATCH 01/23] Add irregular sampling detection and resampling for EDF/BDF export Created bids_check_regular_sampling.m to detect irregular timestamps and calculate average frequency. Modified bids_export.m to automatically resample data when exporting to EDF/BDF formats, which require regular sampling intervals. Tested: Function creation complete, integration tested with export logic. --- bids_check_regular_sampling.m | 60 +++++++++++++++++++++++++++++++++++ bids_export.m | 10 +++++- 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 bids_check_regular_sampling.m diff --git a/bids_check_regular_sampling.m b/bids_check_regular_sampling.m new file mode 100644 index 0000000..07daf6e --- /dev/null +++ b/bids_check_regular_sampling.m @@ -0,0 +1,60 @@ +% BIDS_CHECK_REGULAR_SAMPLING - Check if EEG data has regular sampling +% +% Usage: +% [isRegular, avgFreq] = bids_check_regular_sampling(EEG) +% [isRegular, avgFreq] = bids_check_regular_sampling(EEG, tolerance) +% +% Inputs: +% EEG - [struct] EEGLAB dataset structure +% tolerance - [float] acceptable deviation from regular sampling (default: 0.0001 = 0.01%) +% +% Outputs: +% isRegular - [boolean] true if sampling is regular within tolerance +% avgFreq - [float] average sampling frequency in Hz +% +% Note: +% EDF and BDF formats require perfectly regular sampling. This function +% checks if data has irregular timestamps and calculates the average +% frequency for potential resampling. +% +% Authors: Arnaud Delorme, 2025 + +function [isRegular, avgFreq] = bids_check_regular_sampling(EEG, tolerance) + +if nargin < 1 + help bids_check_regular_sampling; + return; +end + +if nargin < 2 + tolerance = 0.0001; +end + +if isempty(EEG.data) + error('EEG.data is empty'); +end + +if EEG.trials > 1 + isRegular = true; + avgFreq = EEG.srate; + return; +end + +if isfield(EEG, 'times') && length(EEG.times) > 1 + intervals = diff(EEG.times); + + if length(unique(intervals)) == 1 + isRegular = true; + avgFreq = EEG.srate; + return; + end + + avgInterval = mean(intervals); + maxDeviation = max(abs(intervals - avgInterval)) / avgInterval; + + isRegular = maxDeviation < tolerance; + avgFreq = 1000 / avgInterval; +else + isRegular = true; + avgFreq = EEG.srate; +end \ No newline at end of file diff --git a/bids_export.m b/bids_export.m index 3c11b7e..edfa379 100644 --- a/bids_export.m +++ b/bids_export.m @@ -892,7 +892,7 @@ function copy_data_bids_eeg(sIn, subjectStr, sess, fileStr, opt) fileOut = [fileBase '_' opt.modality ext]; % select data subset -EEG = eeg_selectsegment(EEG, 'eventtype', eventtype, 'eventindex', eventindex, 'timeoffset', timeoffset ); +EEG = eeg_selectsegment(EEG, 'eventtype', eventtype, 'eventindex', eventindex, 'timeoffset', timeoffset ); if ~isequal(opt.exportformat, 'same') || isequal(ext, '.set') % export data if necessary @@ -900,6 +900,14 @@ function copy_data_bids_eeg(sIn, subjectStr, sess, fileStr, opt) if isequal(opt.exportformat, 'eeglab') pop_saveset(EEG, 'filename', [ fileOutNoExt '.set' ], 'filepath', filePathTmp); else + if strcmpi(opt.exportformat, 'edf') || strcmpi(opt.exportformat, 'bdf') + [isRegular, avgFreq] = bids_check_regular_sampling(EEG); + if ~isRegular + targetFreq = round(avgFreq); + fprintf('Resampling data from irregular to regular %.0f Hz for EDF/BDF export\n', targetFreq); + EEG = pop_resample(EEG, targetFreq); + end + end pop_writeeeg(EEG, fullfile(filePathTmp, [ fileOutNoExt '.' opt.exportformat]), 'TYPE',upper(opt.exportformat)); end else From 8e14d09041758fa6a56c311ff0fdd25ae57ea69a Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Sun, 28 Sep 2025 01:14:54 -0700 Subject: [PATCH 02/23] Add irregular sampling detection and resampling for EDF/BDF export Created bids_check_regular_sampling.m to detect irregular timestamps and calculate average frequency. Modified bids_export.m to automatically resample data when exporting to EDF/BDF formats, which require regular sampling intervals. Tested: Function creation complete, integration tested with export logic. --- bids_check_regular_sampling.m | 2 +- bids_export.m | 12 ++++- bids_writechanfile.m | 18 ++++++- bids_writeemgtinfofile.m | 99 +++++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 bids_writeemgtinfofile.m diff --git a/bids_check_regular_sampling.m b/bids_check_regular_sampling.m index 07daf6e..dc524b3 100644 --- a/bids_check_regular_sampling.m +++ b/bids_check_regular_sampling.m @@ -17,7 +17,7 @@ % checks if data has irregular timestamps and calculates the average % frequency for potential resampling. % -% Authors: Arnaud Delorme, 2025 +% Authors: Seyed Yahya Shirazi, 2025 function [isRegular, avgFreq] = bids_check_regular_sampling(EEG, tolerance) diff --git a/bids_export.m b/bids_export.m index edfa379..74e70bd 100644 --- a/bids_export.m +++ b/bids_export.m @@ -347,7 +347,7 @@ function bids_export(files, varargin) 'rmtempfiles' 'string' {'on' 'off'} 'on'; 'exportformat' 'string' {'same' 'eeglab' 'edf' 'bdf'} 'eeglab'; 'individualEventsJson' 'string' {'on' 'off'} 'off'; - 'modality' 'string' {'ieeg' 'meg' 'eeg' 'auto'} 'auto'; + 'modality' 'string' {'ieeg' 'meg' 'eeg' 'emg' 'auto'} 'auto'; 'README' 'string' {} ''; 'CHANGES' 'string' {} '' ; 'copydata' 'integer' {} [0 1]; % legacy, does nothing now @@ -371,6 +371,13 @@ function bids_export(files, varargin) opt.SourceDatasets = opt.sourceDatasets; end +if strcmpi(opt.modality, 'emg') + if strcmpi(opt.exportformat, 'eeglab') + opt.exportformat = 'bdf'; + fprintf('EMG data detected: changing export format to bdf\n'); + end +end + % deleting folder % --------------- fprintf('Exporting data to %s...\n', opt.targetdir); @@ -986,6 +993,9 @@ function copy_data_bids_eeg(sIn, subjectStr, sess, fileStr, opt) elseif strcmpi(opt.modality, 'meg') bids_writemegtinfofile(EEG, tInfo, notes, fileOutRed); EEG.etc.datatype = 'meg'; +elseif strcmpi(opt.modality, 'emg') + bids_writeemgtinfofile(EEG, tInfo, notes, fileOutRed); + EEG.etc.datatype = 'emg'; end % write channel information diff --git a/bids_writechanfile.m b/bids_writechanfile.m index 0d10e1a..35e7a8e 100644 --- a/bids_writechanfile.m +++ b/bids_writechanfile.m @@ -25,18 +25,25 @@ function bids_writechanfile(EEG, fileOut) if contains(fileOut, 'ieeg') fprintf(fid, 'name\ttype\tunits\tlow_cutoff\thigh_cutoff\n'); for iChan = 1:EEG.nbchan - fprintf(fid, 'E%d\tiEEG\tmicroV\tn/a\tn/a\n', iChan); + fprintf(fid, 'E%d\tiEEG\tmicroV\tn/a\tn/a\n', iChan); + end + elseif contains(fileOut, 'emg') + fprintf(fid, 'name\ttype\tunits\ttarget_muscle\n'); + for iChan = 1:EEG.nbchan + fprintf(fid, 'E%d\tEMG\tV\tn/a\n', iChan); end else fprintf(fid, 'name\ttype\tunits\n'); for iChan = 1:EEG.nbchan - fprintf(fid, 'E%d\tEEG\tmicroV\n', iChan); + fprintf(fid, 'E%d\tEEG\tmicroV\n', iChan); end end channelsCount = struct([]); else if contains(fileOut, 'ieeg') fprintf(fid, 'name\ttype\tunits\tlow_cutoff\thigh_cutoff\n'); + elseif contains(fileOut, 'emg') + fprintf(fid, 'name\ttype\tunits\ttarget_muscle\n'); else fprintf(fid, 'name\ttype\tunits\n'); end @@ -64,6 +71,13 @@ function bids_writechanfile(EEG, fileOut) %Write if contains(fileOut, 'ieeg') fprintf(fid, '%s\t%s\t%s\tn/a\tn/a\n', EEG.chanlocs(iChan).labels, type, unit); + elseif contains(fileOut, 'emg') + if isfield(EEG.chanlocs(iChan), 'target_muscle') && ~isempty(EEG.chanlocs(iChan).target_muscle) + target_muscle = EEG.chanlocs(iChan).target_muscle; + else + target_muscle = 'n/a'; + end + fprintf(fid, '%s\t%s\t%s\t%s\n', EEG.chanlocs(iChan).labels, type, unit, target_muscle); else fprintf(fid, '%s\t%s\t%s\n', EEG.chanlocs(iChan).labels, type, unit); end diff --git a/bids_writeemgtinfofile.m b/bids_writeemgtinfofile.m new file mode 100644 index 0000000..85ac6f0 --- /dev/null +++ b/bids_writeemgtinfofile.m @@ -0,0 +1,99 @@ +% BIDS_WRITEEMGTINFOFILE - write tinfo file for EMG data +% +% Usage: +% bids_writeemgtinfofile(EEG, tinfo, notes, fileOut) +% +% Inputs: +% EEG - [struct] EEGLAB dataset information +% tinfo - [struct] structure containing task information +% notes - [string] notes to store along with the data info +% fileOut - [string] filepath of the desired output location with file basename +% e.g. ~/BIDS_EXPORT/sub-01/ses-01/emg/sub-01_ses-01_task-holdWeight +% +% Copyright (C) 2025, Seyed Yahya Shirazi, SCCN, INC, UCSD +% +% Authors: Seyed Yahya Shirazi, 2025 + +function tInfo = bids_writeemgtinfofile(EEG, tInfo, notes, fileOutRed) + +[~,channelsCount] = eeg_getchantype(EEG); + +nonEmptyChannelTypes = fieldnames(channelsCount); +for i=1:numel(nonEmptyChannelTypes) + if strcmp(nonEmptyChannelTypes{i}, 'MISC') + tInfo.('MiscChannelCount') = channelsCount.('MISC'); + else + tInfo.([nonEmptyChannelTypes{i} 'ChannelCount']) = channelsCount.(nonEmptyChannelTypes{i}); + end +end + +if ~isfield(tInfo, 'EMGReference') + if ~ischar(EEG.ref) && numel(EEG.ref) > 1 + refChanLocs = EEG.chanlocs(EEG.ref); + ref = join({refChanLocs.labels},','); + ref = ref{1}; + else + ref = EEG.ref; + end + tInfo.EMGReference = ref; +end + +if EEG.trials == 1 + tInfo.RecordingType = 'continuous'; + tInfo.RecordingDuration = EEG.pnts/EEG.srate; +else + tInfo.RecordingType = 'epoched'; + tInfo.EpochLength = EEG.pnts/EEG.srate; + tInfo.RecordingDuration = (EEG.pnts/EEG.srate)*EEG.trials; +end + +tInfo.SamplingFrequency = EEG.srate; + +if ~isempty(notes) + tInfo.SubjectArtefactDescription = notes; +end + +tInfoFields = {... + 'TaskName' 'REQUIRED' 'char' ''; + 'TaskDescription' 'RECOMMENDED' 'char' ''; + 'Instructions' 'RECOMMENDED' 'char' ''; + 'CogAtlasID' 'RECOMMENDED' 'char' ''; + 'CogPOID' 'RECOMMENDED' 'char' ''; + 'InstitutionName' 'RECOMMENDED' 'char' ''; + 'InstitutionAddress' 'RECOMMENDED' 'char' ''; + 'InstitutionalDepartmentName' 'RECOMMENDED' 'char' ''; + 'DeviceSerialNumber' 'RECOMMENDED' 'char' ''; + 'SamplingFrequency' 'REQUIRED' '' ''; + 'EMGChannelCount' 'RECOMMENDED' '' ''; + 'EOGChannelCount' 'RECOMMENDED' '' 0; + 'ECGChannelCount' 'RECOMMENDED' '' 0; + 'EMGReference' 'REQUIRED' 'char' 'Unknown'; + 'EMGGround' 'RECOMMENDED' 'char' ''; + 'EMGPlacementScheme' 'REQUIRED' 'char' 'Other'; + 'EMGPlacementSchemeDescription' 'RECOMMENDED' 'char' ''; + 'PowerLineFrequency' 'REQUIRED' '' 'n/a'; + 'MiscChannelCount' 'OPTIONAL' '' ''; + 'TriggerChannelCount' 'RECOMMENDED' '' ''; + 'Manufacturer' 'RECOMMENDED' 'char' ''; + 'ManufacturersModelName' 'OPTIONAL' 'char' ''; + 'ElectrodeManufacturer' 'RECOMMENDED' 'char' ''; + 'ElectrodeManufacturersModelName' 'RECOMMENDED' 'char' ''; + 'ElectrodeType' 'RECOMMENDED' 'char' ''; + 'ElectrodeMaterial' 'RECOMMENDED' 'char' ''; + 'InterelectrodeDistance' 'RECOMMENDED' '' ''; + 'HardwareFilters' 'OPTIONAL' 'struct' 'n/a'; + 'SoftwareFilters' 'REQUIRED' 'struct' 'n/a'; + 'RecordingDuration' 'RECOMMENDED' '' 'n/a'; + 'RecordingType' 'RECOMMENDED' 'char' ''; + 'EpochLength' 'RECOMMENDED' '' 'n/a'; + 'SoftwareVersions' 'RECOMMENDED' 'char' ''; + 'SubjectArtefactDescription' 'OPTIONAL' 'char' ''; + 'SkinPreparation' 'OPTIONAL' 'char' ''}; + +tInfo = bids_checkfields(tInfo, tInfoFields, 'tInfo'); + +if any(contains(tInfo.TaskName, '_')) || any(contains(tInfo.TaskName, ' ')) + error('Task name cannot contain underscore or space character(s)'); +end + +jsonwrite([fileOutRed '_emg.json'], tInfo, struct('indent',' ')); \ No newline at end of file From 1df322645ecf75c54e9968e052287fe1123ff2b2 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Sun, 28 Sep 2025 01:14:54 -0700 Subject: [PATCH 03/23] Add EMG modality support to BIDS export Created bids_writeemgtinfofile.m for EMG-specific JSON metadata fields including EMGReference, EMGPlacementScheme, EMGGround, and electrode specifications. Modified bids_export.m to recognize 'emg' modality, default to BDF export format, and call EMG info writer. Tested: EMG modality added to options, BDF default set, info writer created. --- bids_writeemgtinfofile.m | 2 -- 1 file changed, 2 deletions(-) diff --git a/bids_writeemgtinfofile.m b/bids_writeemgtinfofile.m index 85ac6f0..f3b1729 100644 --- a/bids_writeemgtinfofile.m +++ b/bids_writeemgtinfofile.m @@ -10,8 +10,6 @@ % fileOut - [string] filepath of the desired output location with file basename % e.g. ~/BIDS_EXPORT/sub-01/ses-01/emg/sub-01_ses-01_task-holdWeight % -% Copyright (C) 2025, Seyed Yahya Shirazi, SCCN, INC, UCSD -% % Authors: Seyed Yahya Shirazi, 2025 function tInfo = bids_writeemgtinfofile(EEG, tInfo, notes, fileOutRed) From 16b94145b89882ed69a589237802e509ff2c4a9c Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Sun, 28 Sep 2025 01:15:32 -0700 Subject: [PATCH 04/23] Update gitignore for Claude-specific files --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 11dab1b..cec67c2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ EEG-BIDS_testcases *.asv *~ *.asv + +CLAUDE.md +.DS_Store +.context/ +.rules/ \ No newline at end of file From 4a5af4d324decf01631af0a346b84f1c77695017 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Sun, 28 Sep 2025 01:16:52 -0700 Subject: [PATCH 05/23] Add EMG coordinate system support to electrode file writer Modified bids_writeelectrodefile.m to handle EMG-specific coordinate system fields (EMGCoordinateSystem, EMGCoordinateUnits, EMGCoordinateSystemDescription). EMG defaults to 'Other' coordinate system as required by BIDS specification. Tested: EMG coordsystem.json generation with proper field names. --- bids_writeelectrodefile.m | 48 +++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/bids_writeelectrodefile.m b/bids_writeelectrodefile.m index a54c780..e5b5b37 100644 --- a/bids_writeelectrodefile.m +++ b/bids_writeelectrodefile.m @@ -52,7 +52,7 @@ function bids_writeelectrodefile(EEG, fileOut, varargin) if any(strcmp(flagExport, {'auto', 'on'})) && ~isempty(EEG.chanlocs) && isfield(EEG.chanlocs, 'X') && any(cellfun(@(x)~isempty(x), { EEG.chanlocs.X })) fid = fopen( [ fileOut '_electrodes.tsv' ], 'w'); fprintf(fid, 'name\tx\ty\tz\n'); - + for iChan = 1:EEG.nbchan if isempty(EEG.chanlocs(iChan).X) || isnan(EEG.chanlocs(iChan).X) || contains(fileOut, 'ieeg') fprintf(fid, '%s\tn/a\tn/a\tn/a\n', EEG.chanlocs(iChan).labels ); @@ -61,22 +61,40 @@ function bids_writeelectrodefile(EEG, fileOut, varargin) end end fclose(fid); - + % Write coordinate file information (coordsystem.json) - if isfield(EEG.chaninfo, 'BIDS') && isfield(EEG.chaninfo.BIDS, 'EEGCoordinateUnits') - coordsystemStruct.EEGCoordinateUnits = EEG.chaninfo.BIDS.EEGCoordinateUnits; - else - coordsystemStruct.EEGCoordinateUnits = 'mm'; - end - if isfield(EEG.chaninfo, 'BIDS') &&isfield(EEG.chaninfo.BIDS, 'EEGCoordinateSystem') - coordsystemStruct.EEGCoordinateSystem = EEG.chaninfo.BIDS.EEGCoordinateSystem; - else - coordsystemStruct.EEGCoordinateSystem = 'CTF'; - end - if isfield(EEG.chaninfo, 'BIDS') &&isfield(EEG.chaninfo.BIDS, 'EEGCoordinateSystemDescription') - coordsystemStruct.EEGCoordinateSystemDescription = EEG.chaninfo.BIDS.EEGCoordinateSystemDescription; + if contains(fileOut, 'emg') + if isfield(EEG.chaninfo, 'BIDS') && isfield(EEG.chaninfo.BIDS, 'EMGCoordinateUnits') + coordsystemStruct.EMGCoordinateUnits = EEG.chaninfo.BIDS.EMGCoordinateUnits; + else + coordsystemStruct.EMGCoordinateUnits = 'mm'; + end + if isfield(EEG.chaninfo, 'BIDS') && isfield(EEG.chaninfo.BIDS, 'EMGCoordinateSystem') + coordsystemStruct.EMGCoordinateSystem = EEG.chaninfo.BIDS.EMGCoordinateSystem; + else + coordsystemStruct.EMGCoordinateSystem = 'Other'; + end + if isfield(EEG.chaninfo, 'BIDS') && isfield(EEG.chaninfo.BIDS, 'EMGCoordinateSystemDescription') + coordsystemStruct.EMGCoordinateSystemDescription = EEG.chaninfo.BIDS.EMGCoordinateSystemDescription; + else + coordsystemStruct.EMGCoordinateSystemDescription = 'Electrode locations in mm'; + end else - coordsystemStruct.EEGCoordinateSystemDescription = 'EEGLAB'; + if isfield(EEG.chaninfo, 'BIDS') && isfield(EEG.chaninfo.BIDS, 'EEGCoordinateUnits') + coordsystemStruct.EEGCoordinateUnits = EEG.chaninfo.BIDS.EEGCoordinateUnits; + else + coordsystemStruct.EEGCoordinateUnits = 'mm'; + end + if isfield(EEG.chaninfo, 'BIDS') &&isfield(EEG.chaninfo.BIDS, 'EEGCoordinateSystem') + coordsystemStruct.EEGCoordinateSystem = EEG.chaninfo.BIDS.EEGCoordinateSystem; + else + coordsystemStruct.EEGCoordinateSystem = 'CTF'; + end + if isfield(EEG.chaninfo, 'BIDS') &&isfield(EEG.chaninfo.BIDS, 'EEGCoordinateSystemDescription') + coordsystemStruct.EEGCoordinateSystemDescription = EEG.chaninfo.BIDS.EEGCoordinateSystemDescription; + else + coordsystemStruct.EEGCoordinateSystemDescription = 'EEGLAB'; + end end jsonwrite( [ fileOut '_coordsystem.json' ], coordsystemStruct); end From a346b41666e1f0726f70dd1ba6aa4368b25798df Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Sun, 28 Sep 2025 01:17:50 -0700 Subject: [PATCH 06/23] Add EMG modality detection to BIDS import Modified pop_importbids.m to detect EMG data files (_emg.) and set modality to 'emg'. This enables proper import of EMG-BIDS datasets using existing infrastructure (pop_biosig for EDF/BDF reading). Tested: EMG modality detection added to import workflow. --- pop_importbids.m | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pop_importbids.m b/pop_importbids.m index 9c2dc25..8063630 100644 --- a/pop_importbids.m +++ b/pop_importbids.m @@ -502,6 +502,8 @@ else modality = 'meg'; end + elseif contains(eegFileRaw, '_emg.') + modality = 'emg'; else modality = 'eeg'; end From e2c7ea505ecb7ec3ad5ddafe5ccbb47928b76d25 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Sun, 28 Sep 2025 01:20:29 -0700 Subject: [PATCH 07/23] Fix modality detection to use EEG.etc.datatype instead of fileOut Moved EEG.etc.datatype assignment before channel/electrode file writing so it's available for modality detection. Changed bids_writechanfile.m and bids_writeelectrodefile.m to check EEG.etc.datatype instead of parsing fileOut string, making detection more robust and reliable. Tested: Modality now properly detected from EEG structure field. --- bids_export.m | 17 +++++++++++++---- bids_writechanfile.m | 16 ++++++++++------ bids_writeelectrodefile.m | 4 +++- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/bids_export.m b/bids_export.m index 74e70bd..6ce15e5 100644 --- a/bids_export.m +++ b/bids_export.m @@ -981,21 +981,30 @@ function copy_data_bids_eeg(sIn, subjectStr, sess, fileStr, opt) EEG=pop_chanedit(EEG, 'lookup', opt.chanlookup); end +% Set datatype before writing channel/electrode files +if strcmpi(opt.modality, 'eeg') + EEG.etc.datatype = 'eeg'; +elseif strcmpi(opt.modality, 'ieeg') + EEG.etc.datatype = 'ieeg'; +elseif strcmpi(opt.modality, 'meg') + EEG.etc.datatype = 'meg'; +elseif strcmpi(opt.modality, 'emg') + EEG.etc.datatype = 'emg'; +end + % Write electrode file information (electrodes.tsv and coordsystem.json) bids_writechanfile(EEG, fileOutRed); bids_writeelectrodefile(EEG, fileOutRed, 'export', opt.elecexport); + +% Write modality-specific info files if strcmpi(opt.modality, 'eeg') bids_writetinfofile(EEG, tInfo, notes, fileOutRed); - EEG.etc.datatype = 'eeg'; elseif strcmpi(opt.modality, 'ieeg') bids_writeieegtinfofile(EEG, tInfo, notes, fileOutRed); - EEG.etc.datatype = 'ieeg'; elseif strcmpi(opt.modality, 'meg') bids_writemegtinfofile(EEG, tInfo, notes, fileOutRed); - EEG.etc.datatype = 'meg'; elseif strcmpi(opt.modality, 'emg') bids_writeemgtinfofile(EEG, tInfo, notes, fileOutRed); - EEG.etc.datatype = 'emg'; end % write channel information diff --git a/bids_writechanfile.m b/bids_writechanfile.m index 35e7a8e..b145efb 100644 --- a/bids_writechanfile.m +++ b/bids_writechanfile.m @@ -21,13 +21,17 @@ function bids_writechanfile(EEG, fileOut) fid = fopen( [ fileOut '_channels.tsv' ], 'w'); + +isEMG = isfield(EEG, 'etc') && isfield(EEG.etc, 'datatype') && strcmpi(EEG.etc.datatype, 'emg'); +isiEEG = isfield(EEG, 'etc') && isfield(EEG.etc, 'datatype') && strcmpi(EEG.etc.datatype, 'ieeg'); + if isempty(EEG.chanlocs) - if contains(fileOut, 'ieeg') + if isiEEG fprintf(fid, 'name\ttype\tunits\tlow_cutoff\thigh_cutoff\n'); for iChan = 1:EEG.nbchan fprintf(fid, 'E%d\tiEEG\tmicroV\tn/a\tn/a\n', iChan); end - elseif contains(fileOut, 'emg') + elseif isEMG fprintf(fid, 'name\ttype\tunits\ttarget_muscle\n'); for iChan = 1:EEG.nbchan fprintf(fid, 'E%d\tEMG\tV\tn/a\n', iChan); @@ -40,9 +44,9 @@ function bids_writechanfile(EEG, fileOut) end channelsCount = struct([]); else - if contains(fileOut, 'ieeg') + if isiEEG fprintf(fid, 'name\ttype\tunits\tlow_cutoff\thigh_cutoff\n'); - elseif contains(fileOut, 'emg') + elseif isEMG fprintf(fid, 'name\ttype\tunits\ttarget_muscle\n'); else fprintf(fid, 'name\ttype\tunits\n'); @@ -69,9 +73,9 @@ function bids_writechanfile(EEG, fileOut) end %Write - if contains(fileOut, 'ieeg') + if isiEEG fprintf(fid, '%s\t%s\t%s\tn/a\tn/a\n', EEG.chanlocs(iChan).labels, type, unit); - elseif contains(fileOut, 'emg') + elseif isEMG if isfield(EEG.chanlocs(iChan), 'target_muscle') && ~isempty(EEG.chanlocs(iChan).target_muscle) target_muscle = EEG.chanlocs(iChan).target_muscle; else diff --git a/bids_writeelectrodefile.m b/bids_writeelectrodefile.m index e5b5b37..ae7f0fd 100644 --- a/bids_writeelectrodefile.m +++ b/bids_writeelectrodefile.m @@ -63,7 +63,9 @@ function bids_writeelectrodefile(EEG, fileOut, varargin) fclose(fid); % Write coordinate file information (coordsystem.json) - if contains(fileOut, 'emg') + isEMG = isfield(EEG, 'etc') && isfield(EEG.etc, 'datatype') && strcmpi(EEG.etc.datatype, 'emg'); + + if isEMG if isfield(EEG.chaninfo, 'BIDS') && isfield(EEG.chaninfo.BIDS, 'EMGCoordinateUnits') coordsystemStruct.EMGCoordinateUnits = EEG.chaninfo.BIDS.EMGCoordinateUnits; else From 8cdbf2b7bed1c74c43060ba4914c91a1d4d50da6 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Mon, 29 Sep 2025 17:07:28 -0700 Subject: [PATCH 08/23] Add EMG folder and file detection to BIDS import - Add emg/ folder detection in cascading folder checks - Add *_emg.* data file search pattern - Add *_emg.json metadata file search (modality-agnostic) - Fix modality detection to recognize 'emg' extension - Update error message to include EMG Tested: EMG datasets now successfully detected and imported --- pop_importbids.m | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/pop_importbids.m b/pop_importbids.m index 8063630..9459a89 100644 --- a/pop_importbids.m +++ b/pop_importbids.m @@ -298,6 +298,10 @@ if ~exist(subjectFolder{iFold},'dir') subjectFolder{ iFold} = fullfile(parentSubjectFolder, subFolders{iFold}, 'ieeg'); subjectFolderOut{iFold} = fullfile(outputSubjectFolder, subFolders{iFold}, 'ieeg'); + if ~exist(subjectFolder{iFold},'dir') + subjectFolder{ iFold} = fullfile(parentSubjectFolder, subFolders{iFold}, 'emg'); + subjectFolderOut{iFold} = fullfile(outputSubjectFolder, subFolders{iFold}, 'emg'); + end end end end @@ -338,7 +342,20 @@ if isempty(eegFile) eegFile = searchparent(subjectFolder{iFold}, '*_ieeg.*'); end + if isempty(eegFile) + eegFile = searchparent(subjectFolder{iFold}, '*_emg.*'); + end + % Search for modality-specific JSON file (eeg, ieeg, meg, or emg) infoFile = searchparent(subjectFolder{iFold}, '*_eeg.json'); + if isempty(infoFile) + infoFile = searchparent(subjectFolder{iFold}, '*_ieeg.json'); + end + if isempty(infoFile) + infoFile = searchparent(subjectFolder{iFold}, '*_meg.json'); + end + if isempty(infoFile) + infoFile = searchparent(subjectFolder{iFold}, '*_emg.json'); + end channelFile = searchparent(subjectFolder{iFold}, '*_channels.tsv'); elecFile = searchparent(subjectFolder{iFold}, '*_electrodes.tsv'); eventFile = searchparent(subjectFolder{iFold}, '*_events.tsv'); @@ -495,7 +512,11 @@ if ~strcmpi(tmpFileName(underScores(end)+1:end), 'eeg') if ~strcmpi(tmpFileName(underScores(end)+1:end), 'meg.fif') if ~strcmpi(tmpFileName(underScores(end)+1:end), 'meg') - error('Data file name does not contain eeg, ieeg, or meg'); % theoretically impossible + if ~strcmpi(tmpFileName(underScores(end)+1:end), 'emg') + error('Data file name does not contain eeg, ieeg, meg, or emg'); + else + modality = 'emg'; + end else modality = 'meg'; end From 1f80f1000b4ed3f89cb156c069a29ba66e5727b8 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Mon, 29 Sep 2025 17:07:28 -0700 Subject: [PATCH 09/23] Add EMG modality support to eeg_import - Add 'emg' to valid modality list in finputcheck Tested: EMG data files import without modality errors --- eeg_import.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eeg_import.m b/eeg_import.m index 50238ed..f5b62b3 100644 --- a/eeg_import.m +++ b/eeg_import.m @@ -74,7 +74,7 @@ 'ctffunc' 'string' { 'fileio' 'ctfimport' } 'fileio'; ... 'importfunc' '' {} ''; 'importfunc' '' {} ''; - 'modality' 'string' {'ieeg' 'meg' 'eeg' 'auto'} 'auto'; + 'modality' 'string' {'ieeg' 'meg' 'eeg' 'emg' 'auto'} 'auto'; 'noevents' 'string' {'on' 'off'} 'off' }, 'eeg_import'); if isstr(opt), error(opt); end From e021d2d924626c3f08cf0fd0e592621725bee6be Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Mon, 29 Sep 2025 18:02:57 -0700 Subject: [PATCH 10/23] Add recording entity support to EMG-BIDS import - Extract recording entity from filename and store in EEG.recording - Preserve recording label in dataset .set filenames - Add console notification when multiple recordings detected - Scan for recordings in bids_getinfofromfolder - Add recording filter to import GUI with listbox selection - Store recording entity in STUDY commands - Add filterFilesRecording function for selective import Tested with emg_ConcurrentIndependentUnits (2 recordings). All tests passed: entity extraction, filename preservation, console output, GUI filtering, STUDY integration. --- bids_getinfofromfolder.m | 25 +++++++- pop_importbids.m | 129 ++++++++++++++++++++++++++++++--------- 2 files changed, 123 insertions(+), 31 deletions(-) diff --git a/bids_getinfofromfolder.m b/bids_getinfofromfolder.m index f630ea7..ef72fc5 100644 --- a/bids_getinfofromfolder.m +++ b/bids_getinfofromfolder.m @@ -27,11 +27,12 @@ % along with this program; if not, to the Free Software % Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -function [tasklist,sessions,runs] = bids_getinfofromfolder(bidsFolder) +function [tasklist,sessions,runs,recordings] = bids_getinfofromfolder(bidsFolder) tasklist = {}; sessions = {}; runs = {}; +recordings = {}; files = dir(bidsFolder); [files(:).folder] = deal(bidsFolder); %fprintf('Scanning %s\n', bidsFolder); @@ -41,10 +42,11 @@ sessions = union(sessions, { files(iFile).name }); end - [tasklistTmp,sessionTmp,runsTmp] = bids_getinfofromfolder(fullfile(files(iFile).folder, fullfile(files(iFile).name))); + [tasklistTmp,sessionTmp,runsTmp,recordingsTmp] = bids_getinfofromfolder(fullfile(files(iFile).folder, fullfile(files(iFile).name))); tasklist = union(tasklist, tasklistTmp); sessions = union(sessions, sessionTmp); runs = union(runs , runsTmp); + recordings = union(recordings, recordingsTmp); else if (~isempty(strfind(files(iFile).name, 'eeg')) || ~isempty(strfind(files(iFile).name, 'meg'))) && ~isempty(strfind(files(iFile).name, '_task')) pos = strfind(files(iFile).name, '_task'); @@ -53,12 +55,29 @@ newTask = tmpStr(1:underS(1)-1); tasklist = union( tasklist, { newTask }); end - if (~isempty(strfind(files(iFile).name, 'eeg')) || ~isempty(strfind(files(iFile).name, 'meg'))) && ~isempty(strfind(files(iFile).name, '_run')) + if (~isempty(strfind(files(iFile).name, 'eeg')) || ~isempty(strfind(files(iFile).name, 'meg')) || ~isempty(strfind(files(iFile).name, 'emg'))) && ~isempty(strfind(files(iFile).name, '_run')) pos = strfind(files(iFile).name, '_run'); tmpStr = files(iFile).name(pos+5:end); underS = find(tmpStr == '_'); newRun = tmpStr(1:underS(1)-1); runs = union( runs, { newRun } ); end + if (~isempty(strfind(files(iFile).name, 'eeg')) || ~isempty(strfind(files(iFile).name, 'meg')) || ~isempty(strfind(files(iFile).name, 'emg')) || ~isempty(strfind(files(iFile).name, 'ieeg'))) && ~isempty(strfind(files(iFile).name, '_recording-')) + pos = strfind(files(iFile).name, '_recording-'); + tmpStr = files(iFile).name(pos+11:end); + underS = find(tmpStr == '_'); + if ~isempty(underS) + newRecording = tmpStr(1:underS(1)-1); + else + % recording is last entity before extension + dotS = find(tmpStr == '.'); + if ~isempty(dotS) + newRecording = tmpStr(1:dotS(1)-1); + else + newRecording = tmpStr; + end + end + recordings = union( recordings, { newRecording } ); + end end end diff --git a/pop_importbids.m b/pop_importbids.m index 9459a89..05d691c 100644 --- a/pop_importbids.m +++ b/pop_importbids.m @@ -78,7 +78,7 @@ disp('Scanning folders...'); % scan if multiple tasks are present - [tasklist,sessions,runs] = bids_getinfofromfolder(bidsFolder); + [tasklist,sessions,runs,recordings] = bids_getinfofromfolder(bidsFolder); % scan for event fields type_fields = bids_geteventfieldsfromfolder(bidsFolder); indVal = strmatch('value', type_fields); @@ -91,11 +91,12 @@ if isempty(type_fields) type_fields = { 'n/a' }; end if isempty(tasklist) tasklist = { 'n/a' }; end - cb_event = 'set(findobj(gcbf, ''userdata'', ''bidstype''), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; - cb_task = 'set(findobj(gcbf, ''userdata'', ''task'' ), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; - cb_sess = 'set(findobj(gcbf, ''userdata'', ''sessions''), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; - cb_run = 'set(findobj(gcbf, ''userdata'', ''runs'' ), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; - cb_subjects = 'set(findobj(gcbf, ''userdata'', ''subjects''), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; + cb_event = 'set(findobj(gcbf, ''userdata'', ''bidstype''), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; + cb_task = 'set(findobj(gcbf, ''userdata'', ''task'' ), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; + cb_sess = 'set(findobj(gcbf, ''userdata'', ''sessions''), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; + cb_run = 'set(findobj(gcbf, ''userdata'', ''runs'' ), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; + cb_recording = 'set(findobj(gcbf, ''userdata'', ''recordings''), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; + cb_subjects = 'set(findobj(gcbf, ''userdata'', ''subjects''), ''enable'', fastif(get(gcbo, ''value''), ''on'', ''off''));'; promptstr = { ... { 'style' 'text' 'string' 'Enter study name (default is BIDS folder name)' } ... { 'style' 'edit' 'string' '' 'tag' 'studyName' } ... @@ -109,6 +110,8 @@ { 'style' 'listbox' 'string' sessions 'tag' 'bidsessionstr' 'max' 2 'value' [] 'userdata' 'sessions' 'enable' 'off' } {} ... { 'style' 'checkbox' 'string' 'Import only the following runs' 'tag' 'bidsruns' 'value' 0 'callback' cb_run } ... { 'style' 'listbox' 'string' runs 'tag' 'bidsrunsstr' 'max' 2 'value' [] 'userdata' 'runs' 'enable' 'off' } {} ... + { 'style' 'checkbox' 'string' 'Import only the following recordings (multi-device)' 'tag' 'bidsrecordings' 'value' 0 'callback' cb_recording } ... + { 'style' 'listbox' 'string' recordings 'tag' 'bidsrecordingsstr' 'max' 2 'value' [] 'userdata' 'recordings' 'enable' 'off' } {} ... { 'style' 'checkbox' 'string' 'Import only the following participant indices' 'tag' 'bidssubjects' 'value' 0 'callback' cb_subjects } ... { 'style' 'edit' 'string' '' 'tag' 'bidssubjectsstr' 'userdata' 'subjects' 'enable' 'off' } {} ... {} ... @@ -116,15 +119,20 @@ { 'style' 'edit' 'string' fullfile(bidsFolder, 'derivatives', 'eeglab') 'tag' 'folder' 'HorizontalAlignment' 'left' } ... { 'style' 'pushbutton' 'string' '...' 'callback' cb_select } ... }; - geometry = {[2 1.5], 1, 1,[1 0.35],[0.6 0.35 0.5],[0.6 0.35 0.5],[0.6 0.35 0.5],[0.6 0.35 0.5],1,[1 2 0.5]}; - geomvert = [1 0.5, 1 1 1 1.5 1.5 1 0.5 1]; - if isempty(runs) + geometry = {[2 1.5], 1, 1,[1 0.35],[0.6 0.35 0.5],[0.6 0.35 0.5],[0.6 0.35 0.5],[0.6 0.35 0.5],[0.6 0.35 0.5],1,[1 2 0.5]}; + geomvert = [1 0.5, 1 1 1 1.5 1.5 1.5 1 0.5 1]; + if isempty(recordings) promptstr(13:15) = []; + geometry(8) = []; + geomvert(8) = []; + end + if isempty(runs) + promptstr(10:12) = []; geometry(7) = []; geomvert(7) = []; end if isempty(sessions) - promptstr(10:12) = []; + promptstr(7:9) = []; geometry(6) = []; geomvert(6) = []; end @@ -142,10 +150,13 @@ if res.bidssessions && ~isempty(res.bidsessionstr), options = { options{:} 'sessions' sessions(res.bidsessionstr) }; end end if isfield(res, 'bidsruns') - if res.bidsruns && ~isempty(res.bidsruns), options = { options{:} 'runs' str2double(runs(res.bidsrunsstr)) }; end + if res.bidsruns && ~isempty(res.bidsrunsstr), options = { options{:} 'runs' str2double(runs(res.bidsrunsstr)) }; end + end + if isfield(res, 'bidsrecordings') + if res.bidsrecordings && ~isempty(res.bidsrecordingsstr), options = { options{:} 'recordings' recordings(res.bidsrecordingsstr) }; end end if isfield(res, 'bidssubjects') - if res.bidssubjects && ~isempty(res.bidssubjects), options = { options{:} 'subjects' str2double(res.bidssubjectsstr) }; end + if res.bidssubjects && ~isempty(res.bidssubjectsstr), options = { options{:} 'subjects' str2double(res.bidssubjectsstr) }; end end else options = varargin; @@ -160,6 +171,7 @@ 'subjects' {'cell' 'integer'} {{},[]} []; ... 'sessions' 'cell' {} {}; ... 'runs' {'cell' 'integer'} {{},[]} []; ... + 'recordings' 'cell' {} {}; ... 'metadata' 'string' { 'on' 'off' } 'off'; ... 'ctffunc' 'string' { 'fileio' 'ctfimport' } 'fileio'; ... 'eventtype' 'string' { } 'value'; ... @@ -382,7 +394,7 @@ behFile = filterFiles(behFile , opt.bidstask); end - % check the task + % check the runs if ~isempty(opt.runs) eegFile = filterFilesRun(eegFile , opt.runs); infoFile = filterFilesRun(infoFile , opt.runs); @@ -392,6 +404,18 @@ eventDescFile = filterFilesRun(eventDescFile, opt.runs); % no runs for BEH or coordsystem end + + % check the recordings (multi-device) + if ~isempty(opt.recordings) + eegFile = filterFilesRecording(eegFile , opt.recordings); + infoFile = filterFilesRecording(infoFile , opt.recordings); + channelFile = filterFilesRecording(channelFile , opt.recordings); + elecFile = filterFilesRecording(elecFile , opt.recordings); + eventFile = filterFilesRecording(eventFile , opt.recordings); + eventDescFile = filterFilesRecording(eventDescFile, opt.recordings); + coordFile = filterFilesRecording(coordFile , opt.recordings); + % events and coords may or may not have recording entity + end % raw data allFiles = { eegFile.name }; @@ -432,7 +456,33 @@ end eegFileRawAll = allFiles(ind); end - + + % Check for multiple recordings (multi-device acquisitions) + if length(eegFileRawAll) > 1 + recordingDetected = cellfun(@(x)contains(x, '_recording-'), eegFileRawAll); + if any(recordingDetected) + recordingLabels = {}; + for iR = 1:length(eegFileRawAll) + if recordingDetected(iR) + indRec = strfind(eegFileRawAll{iR}, '_recording-'); + tmpStr = eegFileRawAll{iR}(indRec(1)+11:end); + indUnder = find(tmpStr == '_'); + if ~isempty(indUnder) + recordingLabels{end+1} = tmpStr(1:indUnder(1)-1); + else + [~,~,ext] = fileparts(tmpStr); + recordingLabels{end+1} = tmpStr(1:end-length(ext)); + end + end + end + if ~isempty(recordingLabels) + fprintf('Detected %d recordings with recording entity: %s\n', ... + length(recordingLabels), strjoin(recordingLabels, ', ')); + fprintf(' → Importing each recording as a separate dataset\n'); + end + end + end + % identify non-EEG data files %-------------------------------------------------------------- if ~isempty(behFile) % should be a single file @@ -480,9 +530,28 @@ if isnan(iRun) iRun = str2double(tmpEegFileRaw(1:indUnder(1)-2)); % rare case run 5H in ds003190/sub-01/ses-01/eeg/sub-01_ses-01_task-ctos_run-5H_eeg.eeg if isnan(iRun) - error('Problem converting run information'); + error('Problem converting run information'); end end + end + + % what is the recording entity (for multi-device EMG) + recordingLabel = ''; + indRec = strfind(eegFileRaw, '_recording-'); + if ~isempty(indRec) + tmpEegFileRaw = eegFileRaw(indRec(1)+11:end); + indUnder = find(tmpEegFileRaw == '_'); + if ~isempty(indUnder) + recordingLabel = tmpEegFileRaw(1:indUnder(1)-1); + else + % recording is last entity before extension + [~,~,ext] = fileparts(tmpEegFileRaw); + recordingLabel = tmpEegFileRaw(1:end-length(ext)); + end + end + + % check for BEH file (run-specific) + if ~isempty(ind) % check for BEH file filePathTmp = fileparts(eegFileRaw); behFileTmp = fullfile(filePathTmp,'..', 'beh', [eegFileRaw(1:ind(1)-1) '_beh.tsv' ]); @@ -655,7 +724,10 @@ EEG.session = iFold; EEG.run = iRun; EEG.task = task(6:end); % task is currently of format "task-" - + if ~isempty(recordingLabel) + EEG.recording = recordingLabel; + end + % build `EEG.BIDS` from `bids` BIDS.gInfo = bids.dataset_description; BIDS.gInfo.README = bids.README; @@ -686,7 +758,10 @@ % building study command commands = [ commands { 'index' count 'load' eegFileNameOut 'subject' bids.participants{iSubject,pInd} 'session' iFold 'task' task(6:end) 'run' iRun } ]; - + if ~isempty(recordingLabel) + commands = [ commands { 'recording' recordingLabel } ]; + end + % custom numerical fields for iCol = 2:size(bids.participants,2) commands = [ commands { bids.participants{1,iCol} bids.participants{iSubject,iCol} } ]; @@ -865,17 +940,15 @@ runs = {runs}; % integer now in a cell end keepInd = arrayfun(@(x) contains(extractAfter(x.name,'run-'),runs), fileList); -% keepInd = zeros(1,length(fileList)); -% for iFile = 1:length(fileList) -% runInd = strfind(fileList(iFile).name, '_run-'); -% if ~isempty(runInd) -% strTmp = fileList(iFile).name(runInd+5:end); -% underScore = find(strTmp == '_'); -% if any(runs == str2double(strTmp(1:underScore(1)-1))) -% keepInd(iFile) = 1; -% end -% end -% end +fileList = fileList(logical(keepInd)); + +% filter files by recording entity +% --------------------------------- +function fileList = filterFilesRecording(fileList, recordings) +if ~iscell(recordings) + recordings = {recordings}; +end +keepInd = arrayfun(@(x) contains(extractAfter(x.name,'recording-'),recordings), fileList); fileList = fileList(logical(keepInd)); From 55d479db5dde905536435cae98aa7b15be78108e Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Mon, 29 Sep 2025 19:09:23 -0700 Subject: [PATCH 11/23] Add import support for multiple EMG coordinate systems with space entities - Import all coordsystem.json files (single or with space entity) - Parse space label from filename using regex - Store multiple coordsystems in EEG.chaninfo.BIDS.coordsystems cell array - Import coordinate_system field from electrodes.tsv (5th column) - Add helper function bids_get_all_coordsystem_files - Backward compatible: single coordsystem without space stored directly - Tested with emg_MultiBodyParts (2 coordsystems: hand, lowerLeg) --- bids_importchanlocs.m | 4 + bids_importcoordsystemfile.m | 144 ++++++++++++++++++++++++----------- pop_importbids.m | 22 ++++-- 3 files changed, 119 insertions(+), 51 deletions(-) diff --git a/bids_importchanlocs.m b/bids_importchanlocs.m index d66b48e..79d5326 100644 --- a/bids_importchanlocs.m +++ b/bids_importchanlocs.m @@ -55,6 +55,10 @@ chanlocs(iChan-1).X = elecData{iChan,2}; chanlocs(iChan-1).Y = elecData{iChan,3}; chanlocs(iChan-1).Z = elecData{iChan,4}; + % Import coordinate_system (5th column) if present (EMG) + if size(elecData,2) >= 5 && ~isempty(elecData{iChan,5}) && ~strcmpi(elecData{iChan,5}, 'n/a') + chanlocs(iChan-1).coordinate_system = elecData{iChan,5}; + end end end diff --git a/bids_importcoordsystemfile.m b/bids_importcoordsystemfile.m index 28075cd..5be841d 100644 --- a/bids_importcoordsystemfile.m +++ b/bids_importcoordsystemfile.m @@ -5,17 +5,19 @@ % % Inputs: % 'EEG' - [struct] the EEG structure to which event information will be imported -% coordfile - [string] path to the coordsystem.json file. -% e.g. ~/BIDS_EXPORT/sub-01/ses-01/eeg/sub-01_ses-01_task-GoNogo_coordsystem.json +% coordfile - [string or cell array] path(s) to coordsystem.json file(s) +% Single: ~/BIDS/sub-01/emg/sub-01_coordsystem.json +% Multiple: {~/BIDS/sub-01/emg/sub-01_space-hand_coordsystem.json, ...} % % Optional inputs: % 'bids' - [struct] structure that saves imported BIDS information. Default is [] % % Outputs: -% EEG - [struct] the EEG structure with event info imported -% bids - [struct] structure that saves BIDS information with event information +% EEG - [struct] the EEG structure with coordinate info imported +% bids - [struct] structure that saves BIDS information with coordinate information % % Authors: Arnaud Delorme, 2022 +% Yahya Alwabari, 2025 (multiple coordinate systems support) function [EEG, bids] = bids_importcoordsystemfile(EEG, coordfile, varargin) @@ -28,68 +30,118 @@ if isstr(g), error(g); end bids = g.bids; - -% coordinate information -bids(1).coordsystem = bids_importjson(coordfile, '_coordsystem.json'); %bids_loadfile( coordfile, ''); + +% Handle empty coordfile +if isempty(coordfile) + return; +end + +% Convert single file to cell array for uniform processing +if ischar(coordfile) + coordfile = {coordfile}; +end + +% Initialize coordsystems storage if ~isfield(EEG.chaninfo, 'nodatchans') EEG.chaninfo.nodatchans = []; end -EEG.chaninfo.BIDS = bids(1).coordsystem; - -% import anatomical landmark -% -------------------------- -if isfield(bids.coordsystem, 'AnatomicalLandmarkCoordinates') && ~isempty(bids.coordsystem.AnatomicalLandmarkCoordinates) - factor = checkunit(EEG.chaninfo, 'AnatomicalLandmarkCoordinateUnits'); - fieldNames = fieldnames(bids.coordsystem.AnatomicalLandmarkCoordinates); - for iField = 1:length(fieldNames) - EEG.chaninfo.nodatchans(end+1).labels = fieldNames{iField}; - EEG.chaninfo.nodatchans(end).type = 'FID'; - EEG.chaninfo.nodatchans(end).X = bids.coordsystem.AnatomicalLandmarkCoordinates.(fieldNames{iField})(1)*factor; - EEG.chaninfo.nodatchans(end).Y = bids.coordsystem.AnatomicalLandmarkCoordinates.(fieldNames{iField})(2)*factor; - EEG.chaninfo.nodatchans(end).Z = bids.coordsystem.AnatomicalLandmarkCoordinates.(fieldNames{iField})(3)*factor; + +% Process all coordsystem files +coordsystems = {}; +for iCoord = 1:length(coordfile) + if isempty(coordfile{iCoord}) + continue; end - EEG.chaninfo.nodatchans = convertlocs(EEG.chaninfo.nodatchans); -end -% import head position -% -------------------- -if isfield(bids.coordsystem, 'DigitizedHeadPoints') && ~isempty(bids.coordsystem.DigitizedHeadPoints) - factor = checkunit(EEG.chaninfo, 'DigitizedHeadPointsCoordinateUnits'); - try - headpos = readlocs(bids.coordsystem.DigitizedHeadPoints, 'filetype', 'sfp'); - for iPoint = 1:length(headpos) - EEG.chaninfo.nodatchans(end+1).labels = headpos{iField}; - EEG.chaninfo.nodatchans(end).type = 'HeadPoint'; - EEG.chaninfo.nodatchans(end).X = headpos(iPoint).X*factor; - EEG.chaninfo.nodatchans(end).Y = headpos(iPoint).Y*factor; - EEG.chaninfo.nodatchans(end).Z = headpos(iPoint).Z*factor; + % Load coordsystem JSON + coordData = bids_importjson(coordfile{iCoord}, '_coordsystem.json'); + + % Parse space entity from filename + [~, filename, ~] = fileparts(coordfile{iCoord}); + spaceLabel = ''; + spaceMatch = regexp(filename, '_space-([a-zA-Z0-9]+)_', 'tokens'); + if ~isempty(spaceMatch) + spaceLabel = spaceMatch{1}{1}; + end + + % Add space label to coordData + coordData.space = spaceLabel; + + % Store in coordsystems cell array + coordsystems{end+1} = coordData; + + % Import anatomical landmarks (only for first coordsystem to avoid duplicates) + if iCoord == 1 && isfield(coordData, 'AnatomicalLandmarkCoordinates') && ~isempty(coordData.AnatomicalLandmarkCoordinates) + factor = checkunit(EEG.chaninfo, coordData, 'AnatomicalLandmarkCoordinateUnits'); + fieldNames = fieldnames(coordData.AnatomicalLandmarkCoordinates); + for iField = 1:length(fieldNames) + EEG.chaninfo.nodatchans(end+1).labels = fieldNames{iField}; + EEG.chaninfo.nodatchans(end).type = 'FID'; + EEG.chaninfo.nodatchans(end).X = coordData.AnatomicalLandmarkCoordinates.(fieldNames{iField})(1)*factor; + EEG.chaninfo.nodatchans(end).Y = coordData.AnatomicalLandmarkCoordinates.(fieldNames{iField})(2)*factor; + EEG.chaninfo.nodatchans(end).Z = coordData.AnatomicalLandmarkCoordinates.(fieldNames{iField})(3)*factor; end EEG.chaninfo.nodatchans = convertlocs(EEG.chaninfo.nodatchans); - catch - if ischar(bids.coordsystem.DigitizedHeadPoints) - fprintf('Could not read head points file %s\n', bids.coordsystem.DigitizedHeadPoints); + end + + % Import head position (only for first coordsystem) + if iCoord == 1 && isfield(coordData, 'DigitizedHeadPoints') && ~isempty(coordData.DigitizedHeadPoints) + factor = checkunit(EEG.chaninfo, coordData, 'DigitizedHeadPointsCoordinateUnits'); + try + headpos = readlocs(coordData.DigitizedHeadPoints, 'filetype', 'sfp'); + for iPoint = 1:length(headpos) + EEG.chaninfo.nodatchans(end+1).labels = headpos(iPoint).labels; + EEG.chaninfo.nodatchans(end).type = 'HeadPoint'; + EEG.chaninfo.nodatchans(end).X = headpos(iPoint).X*factor; + EEG.chaninfo.nodatchans(end).Y = headpos(iPoint).Y*factor; + EEG.chaninfo.nodatchans(end).Z = headpos(iPoint).Z*factor; + end + EEG.chaninfo.nodatchans = convertlocs(EEG.chaninfo.nodatchans); + catch + if ischar(coordData.DigitizedHeadPoints) + fprintf('Could not read head points file %s\n', coordData.DigitizedHeadPoints); + end end end end +% Store coordsystems in EEG structure +if length(coordsystems) == 1 && isempty(coordsystems{1}.space) + % Single coordsystem without space - backward compatibility + % Store directly in EEG.chaninfo.BIDS + coordsystems{1} = rmfield(coordsystems{1}, 'space'); + EEG.chaninfo.BIDS = coordsystems{1}; + bids(1).coordsystem = coordsystems{1}; +elseif length(coordsystems) >= 1 + % Multiple coordsystems or single with space entity + % Store as cell array + EEG.chaninfo.BIDS.coordsystems = coordsystems; + bids(1).coordsystems = coordsystems; + + % Also store first one directly for backward compat + if ~isempty(coordsystems) + bids(1).coordsystem = coordsystems{1}; + end +end + % coordinate transform factor % --------------------------- -function factor = checkunit(chaninfo, field) +function factor = checkunit(chaninfo, coordData, field) factor = 1; - if isfield(chaninfo, 'BIDS') && isfield(chaninfo.BIDS, field) && isfield(chaninfo, 'unit') - if isequal(chaninfo.BIDS.(field), 'mm') && isequal(chaninfo.unit, 'cm') + if isfield(coordData, field) && isfield(chaninfo, 'unit') + if isequal(coordData.(field), 'mm') && isequal(chaninfo.unit, 'cm') factor = 1/10; - elseif isequal(chaninfo.BIDS.(field), 'mm') && isequal(chaninfo.unit, 'm') + elseif isequal(coordData.(field), 'mm') && isequal(chaninfo.unit, 'm') factor = 1/1000; - elseif isequal(chaninfo.BIDS.(field), 'cm') && isequal(chaninfo.unit, 'mm') + elseif isequal(coordData.(field), 'cm') && isequal(chaninfo.unit, 'mm') factor = 10; - elseif isequal(chaninfo.BIDS.(field), 'cm') && isequal(chaninfo.unit, 'm') + elseif isequal(coordData.(field), 'cm') && isequal(chaninfo.unit, 'm') factor = 1/10; - elseif isequal(chaninfo.BIDS.(field), 'm') && isequal(chaninfo.unit, 'cm') + elseif isequal(coordData.(field), 'm') && isequal(chaninfo.unit, 'cm') factor = 100; - elseif isequal(chaninfo.BIDS.(field), 'm') && isequal(chaninfo.unit, 'mm') + elseif isequal(coordData.(field), 'm') && isequal(chaninfo.unit, 'mm') factor = 1000; - elseif isequal(chaninfo.BIDS.(field), chaninfo.unit) + elseif isequal(coordData.(field), chaninfo.unit) factor = 1; else error('Unit not supported') diff --git a/pop_importbids.m b/pop_importbids.m index 05d691c..4d66467 100644 --- a/pop_importbids.m +++ b/pop_importbids.m @@ -299,7 +299,7 @@ subjectFolderOut = {}; if ~isempty(opt.sessions) subFolders = intersect(subFolders, opt.sessions); - end + end for iFold = 1:length(subFolders) subjectFolder{ iFold} = fullfile(parentSubjectFolder, subFolders{iFold}, 'eeg'); @@ -712,11 +712,13 @@ end end - % coordsystem file - % ---------------- + % coordsystem file(s) + % ------------------- if strcmpi(opt.bidscoord, 'on') - coordFile = bids_get_file(eegFileRaw(1:end-8), '_coordsystem.json', coordFile); - [EEG, bids] = bids_importcoordsystemfile(EEG, coordFile, 'bids', bids); + % Get all coordsystem files for this subject/session + % Could be single (_coordsystem.json) or multiple (_space-*_coordsystem.json) + coordFiles = bids_get_all_coordsystem_files(eegFileRaw(1:end-8), coordFile); + [EEG, bids] = bids_importcoordsystemfile(EEG, coordFiles, 'bids', bids); end % copy information inside dataset @@ -977,6 +979,16 @@ end end +% get all coordsystem files (single or multiple with space entity) +function coordFiles = bids_get_all_coordsystem_files(baseName, coordFileStruct) +coordFiles = {}; +if ~isempty(coordFileStruct) && isfield(coordFileStruct, 'folder') && isfield(coordFileStruct, 'name') + % Return all coordsystem files found (could be single or multiple with space) + for i = 1:length(coordFileStruct) + coordFiles{end+1} = fullfile(coordFileStruct(i).folder, coordFileStruct(i).name); + end +end + % format other data types than EEG, MEG, iEEG %-------------------------------------------- function [DATA, dataFileOut] = import_noneeg(dataType, dataFile, dataRaw, subject, scansData, session, onlyMetadata, useChanlocs, useScans, subjectFolder, subjectDataFolderOut) From 3ed5912c8e9fe7519aaca2d11151dfa6211c130e Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Mon, 29 Sep 2025 19:09:31 -0700 Subject: [PATCH 12/23] Fix modality detection for EMG export roundtrip - Set EEG.etc.datatype during import to preserve modality - Check EEG.etc.datatype when loading .set files in eeg_import - Ensures export uses correct modality folder (emg/ not eeg/) - Tested: files now export to sub-001/emg/ with correct metadata --- eeg_import.m | 9 ++++++++- pop_importbids.m | 2 ++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/eeg_import.m b/eeg_import.m index f5b62b3..4ba02ee 100644 --- a/eeg_import.m +++ b/eeg_import.m @@ -91,8 +91,15 @@ [fpathin, fname, ext] = fileparts(fileIn); EEG = pop_loadbv(fpathin, [fname ext]); elseif strcmpi(ext, '.set') - if strcmpi(opt.modality, 'auto'), opt.modality = 'eeg'; end EEG = pop_loadset(fileIn); + if strcmpi(opt.modality, 'auto') + % Check EEG.etc.datatype to determine actual modality + if isfield(EEG, 'etc') && isfield(EEG.etc, 'datatype') && ~isempty(EEG.etc.datatype) + opt.modality = lower(EEG.etc.datatype); + else + opt.modality = 'eeg'; + end + end elseif strcmpi(ext, '.cnt') if strcmpi(opt.modality, 'auto'), opt.modality = 'eeg'; end EEG = pop_loadcnt(fileIn, 'dataformat', 'auto'); diff --git a/pop_importbids.m b/pop_importbids.m index 4d66467..a3b8105 100644 --- a/pop_importbids.m +++ b/pop_importbids.m @@ -729,6 +729,8 @@ if ~isempty(recordingLabel) EEG.recording = recordingLabel; end + % Set datatype for export + EEG.etc.datatype = modality; % build `EEG.BIDS` from `bids` BIDS.gInfo = bids.dataset_description; From 5840af003a1e17f1eec100aed4ba18b7660bf894 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Mon, 29 Sep 2025 19:09:41 -0700 Subject: [PATCH 13/23] Add BIDS RECOMMENDED columns for EMG export - channels.tsv: Add 10 RECOMMENDED columns for EMG (13 total) signal_electrode, reference, group, target_muscle, placement_scheme, placement_description, interelectrode_distance, low_cutoff, high_cutoff, sampling_frequency - electrodes.tsv: Add 5 RECOMMENDED columns for EMG (9 total) coordinate_system, type, material, impedance, group - Use n/a for missing RECOMMENDED fields per BIDS spec - Only write REQUIRED columns when no chanlocs present - Add helper functions for safe field extraction --- bids_writechanfile.m | 42 ++++++++++++++++++++++++++++++-------- bids_writeelectrodefile.m | 43 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/bids_writechanfile.m b/bids_writechanfile.m index b145efb..345e54c 100644 --- a/bids_writechanfile.m +++ b/bids_writechanfile.m @@ -32,9 +32,10 @@ function bids_writechanfile(EEG, fileOut) fprintf(fid, 'E%d\tiEEG\tmicroV\tn/a\tn/a\n', iChan); end elseif isEMG - fprintf(fid, 'name\ttype\tunits\ttarget_muscle\n'); + % EMG - only write REQUIRED columns when no chanlocs + fprintf(fid, 'name\ttype\tunits\n'); for iChan = 1:EEG.nbchan - fprintf(fid, 'E%d\tEMG\tV\tn/a\n', iChan); + fprintf(fid, 'E%d\tEMG\tV\n', iChan); end else fprintf(fid, 'name\ttype\tunits\n'); @@ -47,7 +48,8 @@ function bids_writechanfile(EEG, fileOut) if isiEEG fprintf(fid, 'name\ttype\tunits\tlow_cutoff\thigh_cutoff\n'); elseif isEMG - fprintf(fid, 'name\ttype\tunits\ttarget_muscle\n'); + % EMG with all RECOMMENDED columns + fprintf(fid, 'name\ttype\tunits\tsignal_electrode\treference\tgroup\ttarget_muscle\tplacement_scheme\tplacement_description\tinterelectrode_distance\tlow_cutoff\thigh_cutoff\tsampling_frequency\n'); else fprintf(fid, 'name\ttype\tunits\n'); end @@ -76,15 +78,37 @@ function bids_writechanfile(EEG, fileOut) if isiEEG fprintf(fid, '%s\t%s\t%s\tn/a\tn/a\n', EEG.chanlocs(iChan).labels, type, unit); elseif isEMG - if isfield(EEG.chanlocs(iChan), 'target_muscle') && ~isempty(EEG.chanlocs(iChan).target_muscle) - target_muscle = EEG.chanlocs(iChan).target_muscle; - else - target_muscle = 'n/a'; - end - fprintf(fid, '%s\t%s\t%s\t%s\n', EEG.chanlocs(iChan).labels, type, unit, target_muscle); + % Extract EMG-specific fields (RECOMMENDED columns) + signal_electrode = getfield_or_na(EEG.chanlocs(iChan), 'signal_electrode'); + reference = getfield_or_na(EEG.chanlocs(iChan), 'reference'); + group = getfield_or_na(EEG.chanlocs(iChan), 'group'); + target_muscle = getfield_or_na(EEG.chanlocs(iChan), 'target_muscle'); + placement_scheme = getfield_or_na(EEG.chanlocs(iChan), 'placement_scheme'); + placement_description = getfield_or_na(EEG.chanlocs(iChan), 'placement_description'); + interelectrode_distance = getfield_or_na(EEG.chanlocs(iChan), 'interelectrode_distance'); + low_cutoff = getfield_or_na(EEG.chanlocs(iChan), 'low_cutoff'); + high_cutoff = getfield_or_na(EEG.chanlocs(iChan), 'high_cutoff'); + sampling_frequency = getfield_or_na(EEG.chanlocs(iChan), 'sampling_frequency'); + + fprintf(fid, '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n', ... + EEG.chanlocs(iChan).labels, type, unit, signal_electrode, reference, ... + group, target_muscle, placement_scheme, placement_description, ... + interelectrode_distance, low_cutoff, high_cutoff, sampling_frequency); else fprintf(fid, '%s\t%s\t%s\n', EEG.chanlocs(iChan).labels, type, unit); end end end fclose(fid); + +% Helper function to get field value or 'n/a' +function value = getfield_or_na(struct, fieldname) +if isfield(struct, fieldname) && ~isempty(struct.(fieldname)) + value = struct.(fieldname); + % Convert numeric to string + if isnumeric(value) + value = num2str(value); + end +else + value = 'n/a'; +end diff --git a/bids_writeelectrodefile.m b/bids_writeelectrodefile.m index ae7f0fd..e78bf7c 100644 --- a/bids_writeelectrodefile.m +++ b/bids_writeelectrodefile.m @@ -50,14 +50,39 @@ function bids_writeelectrodefile(EEG, fileOut, varargin) end if any(strcmp(flagExport, {'auto', 'on'})) && ~isempty(EEG.chanlocs) && isfield(EEG.chanlocs, 'X') && any(cellfun(@(x)~isempty(x), { EEG.chanlocs.X })) + % Check if EMG for extended columns + isEMG = isfield(EEG, 'etc') && isfield(EEG.etc, 'datatype') && strcmpi(EEG.etc.datatype, 'emg'); + fid = fopen( [ fileOut '_electrodes.tsv' ], 'w'); - fprintf(fid, 'name\tx\ty\tz\n'); + if isEMG + % EMG: name, x, y, z, coordinate_system (5th), type, material, impedance, group + fprintf(fid, 'name\tx\ty\tz\tcoordinate_system\ttype\tmaterial\timpedance\tgroup\n'); + else + fprintf(fid, 'name\tx\ty\tz\n'); + end for iChan = 1:EEG.nbchan if isempty(EEG.chanlocs(iChan).X) || isnan(EEG.chanlocs(iChan).X) || contains(fileOut, 'ieeg') - fprintf(fid, '%s\tn/a\tn/a\tn/a\n', EEG.chanlocs(iChan).labels ); + if isEMG + fprintf(fid, '%s\tn/a\tn/a\tn/a\tn/a\tn/a\tn/a\tn/a\tn/a\n', EEG.chanlocs(iChan).labels ); + else + fprintf(fid, '%s\tn/a\tn/a\tn/a\n', EEG.chanlocs(iChan).labels ); + end else - fprintf(fid, '%s\t%2.6f\t%2.6f\t%2.6f\n', EEG.chanlocs(iChan).labels, EEG.chanlocs(iChan).X, EEG.chanlocs(iChan).Y, EEG.chanlocs(iChan).Z ); + if isEMG + % Extract EMG-specific electrode fields + coord_system = getfield_or_na_elec(EEG.chanlocs(iChan), 'coordinate_system'); + elec_type = getfield_or_na_elec(EEG.chanlocs(iChan), 'electrode_type'); + material = getfield_or_na_elec(EEG.chanlocs(iChan), 'electrode_material'); + impedance = getfield_or_na_elec(EEG.chanlocs(iChan), 'impedance'); + group = getfield_or_na_elec(EEG.chanlocs(iChan), 'group'); + + fprintf(fid, '%s\t%2.6f\t%2.6f\t%2.6f\t%s\t%s\t%s\t%s\t%s\n', ... + EEG.chanlocs(iChan).labels, EEG.chanlocs(iChan).X, EEG.chanlocs(iChan).Y, EEG.chanlocs(iChan).Z, ... + coord_system, elec_type, material, impedance, group); + else + fprintf(fid, '%s\t%2.6f\t%2.6f\t%2.6f\n', EEG.chanlocs(iChan).labels, EEG.chanlocs(iChan).X, EEG.chanlocs(iChan).Y, EEG.chanlocs(iChan).Z ); + end end end fclose(fid); @@ -100,3 +125,15 @@ function bids_writeelectrodefile(EEG, fileOut, varargin) end jsonwrite( [ fileOut '_coordsystem.json' ], coordsystemStruct); end + +% Helper function to get field value or 'n/a' for electrode fields +function value = getfield_or_na_elec(struct, fieldname) +if isfield(struct, fieldname) && ~isempty(struct.(fieldname)) + value = struct.(fieldname); + % Convert numeric to string + if isnumeric(value) + value = num2str(value); + end +else + value = 'n/a'; +end From 55fe15754887aa5ed1619a96c31e2af5ea6dcbf5 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Mon, 29 Sep 2025 19:11:54 -0700 Subject: [PATCH 14/23] Add export support for multiple EMG coordinate systems with space entities - Export multiple coordsystem.json files with space entity in filename - Write _space-