Skip to content

Commit 2845efe

Browse files
authored
Merge pull request #245 from sccn/EMG-BIDS-dev
Add EMG-BIDS import/export support with multiple coordinate systems
2 parents 2f40cf3 + 72eda81 commit 2845efe

11 files changed

+878
-126
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,8 @@ EEG-BIDS_testcases
22
*.asv
33
*~
44
*.asv
5+
6+
CLAUDE.md
7+
.DS_Store
8+
.context/
9+
.rules/

bids_check_regular_sampling.m

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
% BIDS_CHECK_REGULAR_SAMPLING - Check if EEG data has regular sampling
2+
%
3+
% Usage:
4+
% [isRegular, avgFreq] = bids_check_regular_sampling(EEG)
5+
% [isRegular, avgFreq] = bids_check_regular_sampling(EEG, tolerance)
6+
%
7+
% Inputs:
8+
% EEG - [struct] EEGLAB dataset structure
9+
% tolerance - [float] acceptable deviation from regular sampling (default: 0.0001 = 0.01%)
10+
%
11+
% Outputs:
12+
% isRegular - [boolean] true if sampling is regular within tolerance
13+
% avgFreq - [float] average sampling frequency in Hz
14+
%
15+
% Note:
16+
% EDF and BDF formats require perfectly regular sampling. This function
17+
% checks if data has irregular timestamps and calculates the average
18+
% frequency for potential resampling.
19+
%
20+
% Authors: Seyed Yahya Shirazi, 2025
21+
22+
function [isRegular, avgFreq] = bids_check_regular_sampling(EEG, tolerance)
23+
24+
if nargin < 1
25+
help bids_check_regular_sampling;
26+
return;
27+
end
28+
29+
if nargin < 2
30+
tolerance = 0.01; % 1% tolerance for regular sampling detection
31+
end
32+
33+
if isempty(EEG.data)
34+
error('EEG.data is empty');
35+
end
36+
37+
if EEG.trials > 1
38+
isRegular = true;
39+
avgFreq = EEG.srate;
40+
return;
41+
end
42+
43+
if isfield(EEG, 'times') && length(EEG.times) > 1
44+
intervals = diff(EEG.times);
45+
46+
if length(unique(intervals)) == 1
47+
isRegular = true;
48+
avgFreq = EEG.srate;
49+
return;
50+
end
51+
52+
avgInterval = mean(intervals);
53+
maxDeviation = max(abs(intervals - avgInterval)) / avgInterval;
54+
55+
isRegular = maxDeviation < tolerance;
56+
% Check if times are in seconds (continuous) or milliseconds (epoched)
57+
if EEG.trials == 1 && avgInterval < 1
58+
% Continuous data: times in seconds
59+
avgFreq = 1 / avgInterval;
60+
else
61+
% Epoched data: times in milliseconds
62+
avgFreq = 1000 / avgInterval;
63+
end
64+
else
65+
isRegular = true;
66+
avgFreq = EEG.srate;
67+
end

bids_export.m

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ function bids_export(files, varargin)
347347
'rmtempfiles' 'string' {'on' 'off'} 'on';
348348
'exportformat' 'string' {'same' 'eeglab' 'edf' 'bdf'} 'eeglab';
349349
'individualEventsJson' 'string' {'on' 'off'} 'off';
350-
'modality' 'string' {'ieeg' 'meg' 'eeg' 'auto'} 'auto';
350+
'modality' 'string' {'ieeg' 'meg' 'eeg' 'emg' 'auto'} 'auto';
351351
'README' 'string' {} '';
352352
'CHANGES' 'string' {} '' ;
353353
'copydata' 'integer' {} [0 1]; % legacy, does nothing now
@@ -371,6 +371,13 @@ function bids_export(files, varargin)
371371
opt.SourceDatasets = opt.sourceDatasets;
372372
end
373373

374+
if strcmpi(opt.modality, 'emg')
375+
if strcmpi(opt.exportformat, 'eeglab')
376+
opt.exportformat = 'bdf';
377+
fprintf('EMG data detected: changing export format to bdf\n');
378+
end
379+
end
380+
374381
% deleting folder
375382
% ---------------
376383
fprintf('Exporting data to %s...\n', opt.targetdir);
@@ -872,6 +879,13 @@ function copy_data_bids_eeg(sIn, subjectStr, sess, fileStr, opt)
872879
[pathIn,fileInNoExt,ext] = fileparts(fileIn);
873880
fprintf('Processing file %s\n', fileIn);
874881
[EEG,opt.modality] = eeg_import(fileIn, 'modality', opt.modality, 'noevents', opt.noevents, 'importfunc', opt.importfunc);
882+
883+
% Set default export format to EDF for EMG data (after modality is determined)
884+
if strcmpi(opt.modality, 'emg') && strcmpi(opt.exportformat, 'eeglab')
885+
opt.exportformat = 'edf';
886+
fprintf('Note: EMG data will be exported to EDF format (default for EMG)\n');
887+
end
888+
875889
if contains(fileIn, '_bids_tmp_') && strcmpi(opt.rmtempfiles, 'on')
876890
fprintf('Deleting temporary file %s\n', fileIn);
877891
delete(fileIn);
@@ -892,15 +906,37 @@ function copy_data_bids_eeg(sIn, subjectStr, sess, fileStr, opt)
892906
fileOut = [fileBase '_' opt.modality ext];
893907

894908
% select data subset
895-
EEG = eeg_selectsegment(EEG, 'eventtype', eventtype, 'eventindex', eventindex, 'timeoffset', timeoffset );
909+
EEG = eeg_selectsegment(EEG, 'eventtype', eventtype, 'eventindex', eventindex, 'timeoffset', timeoffset );
896910

897911
if ~isequal(opt.exportformat, 'same') || isequal(ext, '.set')
898912
% export data if necessary
899913
[filePathTmp,fileOutNoExt,~] = fileparts(fileOut);
900914
if isequal(opt.exportformat, 'eeglab')
901915
pop_saveset(EEG, 'filename', [ fileOutNoExt '.set' ], 'filepath', filePathTmp);
902916
else
903-
pop_writeeeg(EEG, fullfile(filePathTmp, [ fileOutNoExt '.' opt.exportformat]), 'TYPE',upper(opt.exportformat));
917+
% Check for regular sampling when exporting to EDF/BDF
918+
if strcmpi(opt.exportformat, 'edf') || strcmpi(opt.exportformat, 'bdf')
919+
[isRegular, avgFreq] = bids_check_regular_sampling(EEG);
920+
if ~isRegular
921+
error(['EDF/BDF export requires regular sampling. Your data has irregular sampling (avg %.2f Hz).\n' ...
922+
'Please resample your data before exporting:\n' ...
923+
' EEG = pop_resample(EEG, %.0f);\n' ...
924+
'Then re-run bids_export.'], avgFreq, round(avgFreq));
925+
end
926+
end
927+
928+
% For EMG (and other modalities), save without events in EDF
929+
% Events are saved separately in events.tsv
930+
if strcmpi(opt.modality, 'emg') && (strcmpi(opt.exportformat, 'edf') || strcmpi(opt.exportformat, 'bdf'))
931+
% Temporarily remove events before writing EDF
932+
tmpEvents = EEG.event;
933+
EEG.event = [];
934+
pop_writeeeg(EEG, fullfile(filePathTmp, [ fileOutNoExt '.' opt.exportformat]), 'TYPE',upper(opt.exportformat));
935+
% Restore events for events.tsv export
936+
EEG.event = tmpEvents;
937+
else
938+
pop_writeeeg(EEG, fullfile(filePathTmp, [ fileOutNoExt '.' opt.exportformat]), 'TYPE',upper(opt.exportformat));
939+
end
904940
end
905941
else
906942
% copy the file
@@ -966,18 +1002,30 @@ function copy_data_bids_eeg(sIn, subjectStr, sess, fileStr, opt)
9661002
EEG=pop_chanedit(EEG, 'lookup', opt.chanlookup);
9671003
end
9681004

1005+
% Set datatype before writing channel/electrode files
1006+
if strcmpi(opt.modality, 'eeg')
1007+
EEG.etc.datatype = 'eeg';
1008+
elseif strcmpi(opt.modality, 'ieeg')
1009+
EEG.etc.datatype = 'ieeg';
1010+
elseif strcmpi(opt.modality, 'meg')
1011+
EEG.etc.datatype = 'meg';
1012+
elseif strcmpi(opt.modality, 'emg')
1013+
EEG.etc.datatype = 'emg';
1014+
end
1015+
9691016
% Write electrode file information (electrodes.tsv and coordsystem.json)
9701017
bids_writechanfile(EEG, fileOutRed);
971-
bids_writeelectrodefile(EEG, fileOutRed, 'export', opt.elecexport);
1018+
bids_writeelectrodefile(EEG, fileOutRed, 'export', opt.elecexport, 'rootdir', opt.targetdir);
1019+
1020+
% Write modality-specific info files
9721021
if strcmpi(opt.modality, 'eeg')
9731022
bids_writetinfofile(EEG, tInfo, notes, fileOutRed);
974-
EEG.etc.datatype = 'eeg';
9751023
elseif strcmpi(opt.modality, 'ieeg')
9761024
bids_writeieegtinfofile(EEG, tInfo, notes, fileOutRed);
977-
EEG.etc.datatype = 'ieeg';
9781025
elseif strcmpi(opt.modality, 'meg')
9791026
bids_writemegtinfofile(EEG, tInfo, notes, fileOutRed);
980-
EEG.etc.datatype = 'meg';
1027+
elseif strcmpi(opt.modality, 'emg')
1028+
bids_writeemgtinfofile(EEG, tInfo, notes, fileOutRed);
9811029
end
9821030

9831031
% write channel information

bids_getinfofromfolder.m

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,12 @@
2727
% along with this program; if not, to the Free Software
2828
% Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
2929

30-
function [tasklist,sessions,runs] = bids_getinfofromfolder(bidsFolder)
30+
function [tasklist,sessions,runs,recordings] = bids_getinfofromfolder(bidsFolder)
3131

3232
tasklist = {};
3333
sessions = {};
3434
runs = {};
35+
recordings = {};
3536
files = dir(bidsFolder);
3637
[files(:).folder] = deal(bidsFolder);
3738
%fprintf('Scanning %s\n', bidsFolder);
@@ -41,10 +42,11 @@
4142
sessions = union(sessions, { files(iFile).name });
4243
end
4344

44-
[tasklistTmp,sessionTmp,runsTmp] = bids_getinfofromfolder(fullfile(files(iFile).folder, fullfile(files(iFile).name)));
45+
[tasklistTmp,sessionTmp,runsTmp,recordingsTmp] = bids_getinfofromfolder(fullfile(files(iFile).folder, fullfile(files(iFile).name)));
4546
tasklist = union(tasklist, tasklistTmp);
4647
sessions = union(sessions, sessionTmp);
4748
runs = union(runs , runsTmp);
49+
recordings = union(recordings, recordingsTmp);
4850
else
4951
if (~isempty(strfind(files(iFile).name, 'eeg')) || ~isempty(strfind(files(iFile).name, 'meg'))) && ~isempty(strfind(files(iFile).name, '_task'))
5052
pos = strfind(files(iFile).name, '_task');
@@ -53,12 +55,29 @@
5355
newTask = tmpStr(1:underS(1)-1);
5456
tasklist = union( tasklist, { newTask });
5557
end
56-
if (~isempty(strfind(files(iFile).name, 'eeg')) || ~isempty(strfind(files(iFile).name, 'meg'))) && ~isempty(strfind(files(iFile).name, '_run'))
58+
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'))
5759
pos = strfind(files(iFile).name, '_run');
5860
tmpStr = files(iFile).name(pos+5:end);
5961
underS = find(tmpStr == '_');
6062
newRun = tmpStr(1:underS(1)-1);
6163
runs = union( runs, { newRun } );
6264
end
65+
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-'))
66+
pos = strfind(files(iFile).name, '_recording-');
67+
tmpStr = files(iFile).name(pos+11:end);
68+
underS = find(tmpStr == '_');
69+
if ~isempty(underS)
70+
newRecording = tmpStr(1:underS(1)-1);
71+
else
72+
% recording is last entity before extension
73+
dotS = find(tmpStr == '.');
74+
if ~isempty(dotS)
75+
newRecording = tmpStr(1:dotS(1)-1);
76+
else
77+
newRecording = tmpStr;
78+
end
79+
end
80+
recordings = union( recordings, { newRecording } );
81+
end
6382
end
6483
end

bids_importchanlocs.m

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@
5555
chanlocs(iChan-1).X = elecData{iChan,2};
5656
chanlocs(iChan-1).Y = elecData{iChan,3};
5757
chanlocs(iChan-1).Z = elecData{iChan,4};
58+
% Import coordinate_system (5th column) if present (EMG)
59+
if size(elecData,2) >= 5 && ~isempty(elecData{iChan,5}) && ~strcmpi(elecData{iChan,5}, 'n/a')
60+
chanlocs(iChan-1).coordinate_system = elecData{iChan,5};
61+
end
5862
end
5963
end
6064

0 commit comments

Comments
 (0)