From 74e3e90bf6b2d2a4d04d4abde515e0609abc2bcc Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Fri, 5 Dec 2025 16:19:20 +0100 Subject: [PATCH 01/20] FIX: allow multi-task inputs [#876] --- src/bids_importer/lib/get_eyetrack_data.m | 21 +- src/bids_importer/lib/get_physio_data.m | 48 +- src/bids_importer/lib/get_physio_eye_data.m | 585 ++++++++++---------- src/pspm_check_data.m | 13 +- src/pspm_import_bids.m | 528 +++++++++++++----- 5 files changed, 735 insertions(+), 460 deletions(-) diff --git a/src/bids_importer/lib/get_eyetrack_data.m b/src/bids_importer/lib/get_eyetrack_data.m index 715f6ef34..4ba8851ff 100644 --- a/src/bids_importer/lib/get_eyetrack_data.m +++ b/src/bids_importer/lib/get_eyetrack_data.m @@ -37,9 +37,22 @@ signal = eye_signals{i}; % Construct filenames based on BIDS naming convention: - % e.g., sub-CalinetWuerzburg01_ses-01_task-FearAcquisition_recording-eye1_physio.json - eye_json_filename = sprintf('%s_ses-%s_task-%s_recording-%s_physio.json', subject_id, session_id, task_name, signal); - eye_tsv_filename = sprintf('%s_ses-%s_task-%s_recording-%s_physio.tsv', subject_id, session_id, task_name, signal); + if isempty(task_name) + % No task entity → standard BIDS physio filename + eye_json_filename = sprintf('%s_ses-%s_recording-%s_physio.json', ... + subject_id, session_id, signal); + + eye_tsv_filename = sprintf('%s_ses-%s_recording-%s_physio.tsv', ... + subject_id, session_id, signal); + + else + % Task entity present → include _task-_ in filename + eye_json_filename = sprintf('%s_ses-%s_task-%s_recording-%s_physio.json', ... + subject_id, session_id, task_name, signal); + + eye_tsv_filename = sprintf('%s_ses-%s_task-%s_recording-%s_physio.tsv', ... + subject_id, session_id, task_name, signal); + end eye_json_filepath = fullfile(physio_path, eye_json_filename); eye_tsv_filepath = fullfile(physio_path, eye_tsv_filename); @@ -52,6 +65,8 @@ if ~isfile(eye_json_filepath); warning('File not found: %s', eye_json_filepath); sts = -1 ;end if ~isfile(eye_tsv_filepath); warning('File not found: %s', eye_tsv_filepath); sts = -1 ; end + fprintf('%s:\t%s\n', signal, eye_tsv_filepath); + % Read JSON metadata (assumed to be converted into a struct) eye_json = extract_json_as_struct(eye_json_filepath); diff --git a/src/bids_importer/lib/get_physio_data.m b/src/bids_importer/lib/get_physio_data.m index ea740d768..99897bc08 100644 --- a/src/bids_importer/lib/get_physio_data.m +++ b/src/bids_importer/lib/get_physio_data.m @@ -4,12 +4,12 @@ % UPDATE HELPTEXT %% Initialize the physio data cell array -sts = -1; -physio_data = {}; -file_paths = {}; -infos.source.file = {}; -physio_signals = {'ecg','ppg', 'scr'}; -num_signals = length(physio_signals); +sts = -1; +physio_data = {}; +file_paths = {}; +infos.source.file = {}; +physio_signals = {'ecg', 'ppg', 'scr'}; +num_signals = length(physio_signals); % Index to keep track of the cell array @@ -18,22 +18,35 @@ for i = 1:num_signals signal = physio_signals{i}; + + %% Construct filenames depending on whether task is present + if isempty(task_name) + % No task entity → standard BIDS physio filename + physio_json_filename = sprintf('%s_ses-%s_recording-%s_physio.json', ... + subject_id, session_id, signal); - % Construct filenames - physio_json_filename = sprintf('%s_ses-%s_recording-%s_physio.json', subject_id, session_id, signal); - physio_tsv_filename = sprintf('%s_ses-%s_recording-%s_physio.tsv', subject_id, session_id, signal); + physio_tsv_filename = sprintf('%s_ses-%s_recording-%s_physio.tsv', ... + subject_id, session_id, signal); + + else + % Task entity present → include _task-_ in filename + physio_json_filename = sprintf('%s_ses-%s_task-%s_recording-%s_physio.json', ... + subject_id, session_id, task_name, signal); + + physio_tsv_filename = sprintf('%s_ses-%s_task-%s_recording-%s_physio.tsv', ... + subject_id, session_id, task_name, signal); + end + physio_json_filepath = fullfile(physio_path, physio_json_filename); physio_tsv_filepath = fullfile(physio_path, physio_tsv_filename); - - - % Check if files exist + %% Check if files exist % The warning could be confusing if ~isfile(physio_json_filepath) || ~isfile(physio_tsv_filepath) continue; end - - - % Collect file paths for infos + + fprintf('%s:\t%s\n', signal, physio_tsv_filepath); + %% Collect file paths for infos file_paths{cell_index,1} = {physio_json_filepath,physio_tsv_filepath}; % Read JSON metadata @@ -66,19 +79,18 @@ chan.data = physio_data_table.(headings{1}); % Add to physio data cell array - physio_data{cell_index,1} = chan; + physio_data{cell_index,1} = chan; %#ok<*AGROW> % index cell_index = cell_index +1; end if isempty(physio_data) - fprintf(">No physio data ('ecg','ppg','scr') were imported for subject %s, session %s.", subject_id, session_id); + fprintf(">No physio data ('ecg','ppg','scr') were imported for %s, ses-%s.\n", subject_id, session_id); return end infos.source.file = file_paths; - sts = 1; end diff --git a/src/bids_importer/lib/get_physio_eye_data.m b/src/bids_importer/lib/get_physio_eye_data.m index 91eac75a3..4e5411461 100644 --- a/src/bids_importer/lib/get_physio_eye_data.m +++ b/src/bids_importer/lib/get_physio_eye_data.m @@ -1,6 +1,6 @@ -function [sts , data, infos] = get_physio_eye_data(subject_id, session_id, task_name, physio_eye_path) +function [sts, data, infos] = get_physio_eye_data(subject_id, session_id, task_name, physio_eye_path) sts = -1; -data = {}; +data = {}; infos = struct(); infos.source = struct(); infos.source.file = struct(); @@ -11,303 +11,313 @@ [ests , eye_data_cell] = get_eyetrack_data(subject_id, session_id, task_name, physio_eye_path); if ests == 1 - -%% --- Add the eye data to the channels --- -num_eyes = length(eye_data_cell); -switch num_eyes - case 0; warning('No eye data available.'); - case 1 - eyeSide = lower(eye_data_cell{1}.RecordedEye); - warning('Only %s eye data available.', eyeSide); - - if strcmp(eyeSide, 'right') - pupil_r = eye_data_cell{1}.Columns{:,'pupil_size'}; - gaze_x_r = eye_data_cell{1}.Columns{:,'x_coordinate'}; - gaze_y_r = eye_data_cell{1}.Columns{:,'y_coordinate'}; - - data{1}.data = pupil_r; - data{1}.header.chantype = 'pupil_r'; - data{2}.data = gaze_x_r; - data{2}.header.chantype = 'gaze_x_r'; - data{3}.data = gaze_y_r; - data{3}.header.chantype = 'gaze_y_r'; - - elseif strcmp(eyeSide, 'left') - pupil_l = eye_data_cell{1}.Columns{:,'pupil_size'}; - gaze_x_l = eye_data_cell{1}.Columns{:,'x_coordinate'}; - gaze_y_l = eye_data_cell{1}.Columns{:,'y_coordinate'}; - - data{1}.data = pupil_l; - data{1}.header.chantype = 'pupil_l'; - data{2}.data = gaze_x_l; - data{2}.header.chantype = 'gaze_x_l'; - data{3}.data = gaze_y_l; - data{3}.header.chantype = 'gaze_y_l'; - - else - warning('Unknown RecordedEye eye_data_cell.'); - return - end - case 2 - eyes = lower({eye_data_cell{1}.RecordedEye, eye_data_cell{2}.RecordedEye}); - if strcmp(eyes{1}, eyes{2}) - warning('Both recorded eyes are %s.', eyes{1}); - % Maybe choose the better eye? -> it chooses the better depends - % on l or eye - else - % Correctly assign each cell to the corresponding eye. - idxRight = find(strcmp(eyes, 'right'), 1); - idxLeft = find(strcmp(eyes, 'left'), 1); - - if isempty(idxRight) || isempty(idxLeft); warning('...');end % ??? - - pupil_r = eye_data_cell{idxRight}.Columns{:,'pupil_size'}; - gaze_x_r = eye_data_cell{idxRight}.Columns{:,'x_coordinate'}; - gaze_y_r = eye_data_cell{idxRight}.Columns{:,'y_coordinate'}; - - pupil_l = eye_data_cell{idxLeft}.Columns{:,'pupil_size'}; - gaze_x_l = eye_data_cell{idxLeft}.Columns{:,'x_coordinate'}; - gaze_y_l = eye_data_cell{idxLeft}.Columns{:,'y_coordinate'}; - - % right eye channels - data{1}.header.chantype = 'pupil_r'; - data{1}.data = pupil_r; - - data{2}.header.chantype = 'gaze_x_r'; - data{2}.data = gaze_x_r; - data{3}.header.chantype = 'gaze_y_r'; - data{3}.data = gaze_y_r; - - % left eye channels - data{4}.header.chantype = 'pupil_l'; - data{4}.data = pupil_l; - data{5}.header.chantype = 'gaze_x_l'; - data{5}.data = gaze_x_l; - data{6}.header.chantype = 'gaze_y_l'; - data{6}.data = gaze_y_l; - - end - - otherwise; error('Unexpected number of eye data cells.'); - - -end - -data = data'; -%% Add header data for pupil and gaze data + %% --- Add the eye data to the channels --- + num_eyes = length(eye_data_cell); + switch num_eyes + case 0; warning('No eye data available.'); + case 1 + eyeSide = lower(eye_data_cell{1}.RecordedEye); + warning('Only %s eye data available.', eyeSide); + + if strcmp(eyeSide, 'right') + pupil_r = eye_data_cell{1}.Columns{:,'pupil_size'}; + gaze_x_r = eye_data_cell{1}.Columns{:,'x_coordinate'}; + gaze_y_r = eye_data_cell{1}.Columns{:,'y_coordinate'}; + + data{1}.data = pupil_r; + data{1}.header.chantype = 'pupil_r'; + data{2}.data = gaze_x_r; + data{2}.header.chantype = 'gaze_x_r'; + data{3}.data = gaze_y_r; + data{3}.header.chantype = 'gaze_y_r'; + + elseif strcmp(eyeSide, 'left') + pupil_l = eye_data_cell{1}.Columns{:,'pupil_size'}; + gaze_x_l = eye_data_cell{1}.Columns{:,'x_coordinate'}; + gaze_y_l = eye_data_cell{1}.Columns{:,'y_coordinate'}; + + data{1}.data = pupil_l; + data{1}.header.chantype = 'pupil_l'; + data{2}.data = gaze_x_l; + data{2}.header.chantype = 'gaze_x_l'; + data{3}.data = gaze_y_l; + data{3}.header.chantype = 'gaze_y_l'; + + else + warning('Unknown RecordedEye eye_data_cell.'); + return + end + case 2 + eyes = lower({eye_data_cell{1}.RecordedEye, eye_data_cell{2}.RecordedEye}); + if strcmp(eyes{1}, eyes{2}) + warning('Both recorded eyes are %s.', eyes{1}); + % Maybe choose the better eye? -> it chooses the better depends + % on l or eye + else + % Correctly assign each cell to the corresponding eye. + idxRight = find(strcmp(eyes, 'right'), 1); + idxLeft = find(strcmp(eyes, 'left'), 1); + + if isempty(idxRight) || isempty(idxLeft); warning('...');end % ??? + + pupil_r = eye_data_cell{idxRight}.Columns{:,'pupil_size'}; + gaze_x_r = eye_data_cell{idxRight}.Columns{:,'x_coordinate'}; + gaze_y_r = eye_data_cell{idxRight}.Columns{:,'y_coordinate'}; + + pupil_l = eye_data_cell{idxLeft}.Columns{:,'pupil_size'}; + gaze_x_l = eye_data_cell{idxLeft}.Columns{:,'x_coordinate'}; + gaze_y_l = eye_data_cell{idxLeft}.Columns{:,'y_coordinate'}; + + % right eye channels + data{1}.header.chantype = 'pupil_r'; + data{1}.data = pupil_r; + + data{2}.header.chantype = 'gaze_x_r'; + data{2}.data = gaze_x_r; + data{3}.header.chantype = 'gaze_y_r'; + data{3}.data = gaze_y_r; + + % left eye channels + data{4}.header.chantype = 'pupil_l'; + data{4}.data = pupil_l; + data{5}.header.chantype = 'gaze_x_l'; + data{5}.data = gaze_x_l; + data{6}.header.chantype = 'gaze_y_l'; + data{6}.data = gaze_y_l; + + end + + otherwise; error('Unexpected number of eye data cells.'); -% For one eye -if num_eyes == 1; idxRight = 1; idxLeft = 1; end -for i = 1:length(data) - % pupil - if strcmp(data{i}.header.chantype(1:end-1) , 'pupil_') - if strcmp(data{i}.header.chantype(end:end) , 'r') - data{i}.header.Description = eye_data_cell{idxRight}.pupil_size.Description; - data{i}.header.units = eye_data_cell{idxRight}.pupil_size.Units; - data{i}.header.sr = eye_data_cell{idxRight}.SamplingFrequency; + end - elseif strcmp(data{i}.header.chantype(end:end) , 'l') - data{i}.header.Description = eye_data_cell{idxLeft}.pupil_size.Description; - data{i}.header.units = eye_data_cell{idxLeft}.pupil_size.Units; - data{i}.header.sr = eye_data_cell{idxLeft}.SamplingFrequency; - - else - warning('No valid pupil channel found.'); - end - % gaze - elseif strcmp(data{i}.header.chantype(1:end-4) , 'gaze') - if strcmp(data{i}.header.chantype(6) , 'x') - if strcmp(data{i}.header.chantype(8) , 'r') - % gaze_x_r - if any(strcmp(fieldnames(eye_data_cell{idxRight}),'SampleCoordinateUnits')) - data{i}.header.units = eye_data_cell{idxRight}.SampleCoordinateUnits; % "pixel" - elseif any(strcmp(fieldnames(eye_data_cell{idxRight}),'x_coordinate')) - data{i}.header.units = eye_data_cell{idxRight}.x_coordinate.Units; - else - warning('ID:missing_units', 'Units could not be determined for gaze_x_r channel.'); - end - - data{i}.header.sr = eye_data_cell{idxRight}.SamplingFrequency; - data{i}.header.r = [eye_data_cell{idxRight}.GazeRange.xmin, eye_data_cell{idxRight}.GazeRange.xmax] ; % e.g. [0 1151] - elseif strcmp(data{i}.header.chantype(8) , 'l') - % gaze_x_l - if any(strcmp(fieldnames(eye_data_cell{idxLeft}),'SampleCoordinateUnits')) - data{i}.header.units = eye_data_cell{idxLeft}.SampleCoordinateUnits; % "pixel" - elseif any(strcmp(fieldnames(eye_data_cell{idxLeft}),'x_coordinate')) - data{i}.header.units = eye_data_cell{idxLeft}.x_coordinate.Units; - else - warning('ID:missing_units', 'Units could not be determined for gaze_x_l channel.'); - end - - data{i}.header.sr = eye_data_cell{idxLeft}.SamplingFrequency; - data{i}.header.range = [eye_data_cell{idxLeft}.GazeRange.xmin, eye_data_cell{idxLeft}.GazeRange.xmax] ; % e.g. [0 1151] - else - warning('Something went worng with gaze x channels') - end - - elseif strcmp(data{i}.header.chantype(6) , 'y') - if strcmp(data{i}.header.chantype(8) , 'r') - % gaze_y_r - if any(strcmp(fieldnames(eye_data_cell{idxRight}),'SampleCoordinateUnits')) - data{i}.header.units = eye_data_cell{idxRight}.SampleCoordinateUnits; % "pixel" - elseif any(strcmp(fieldnames(eye_data_cell{idxRight}),'y_coordinate')) - data{i}.header.units = eye_data_cell{idxRight}.y_coordinate.Units; - else - warning('ID:missing_units', 'Units could not be determined for gaze_y_r channel.'); - end - data{i}.header.sr = eye_data_cell{idxRight}.SamplingFrequency; - data{i}.header.range = [eye_data_cell{idxRight}.GazeRange.ymin, eye_data_cell{idxRight}.GazeRange.ymax] ; % e.g. [0 1151] - - elseif strcmp(data{i}.header.chantype(8) , 'l') - % gaze_y_l - if any(strcmp(fieldnames(eye_data_cell{idxLeft}),'SampleCoordinateUnits')) - data{i}.header.units = eye_data_cell{idxLeft}.SampleCoordinateUnits; % "pixel" - elseif any(strcmp(fieldnames(eye_data_cell{idxLeft}),'y_coordinate')) - data{i}.header.units = eye_data_cell{idxLeft}.y_coordinate.Units; % should i add a check that x and y are the same units? - else - warning('ID:missing_units', 'Units could not be determined for gaze_y_l channel.'); - end - - data{i}.header.sr = eye_data_cell{idxLeft}.SamplingFrequency; - data{i}.header.range = [eye_data_cell{idxLeft}.GazeRange.ymin, eye_data_cell{idxLeft}.GazeRange.ymax] ; % e.g. [0 1151] - - else - warning('Something went worng with gaze y channels') - end + data = data'; + + %% Add header data for pupil and gaze data + + % For one eye + if num_eyes == 1; idxRight = 1; idxLeft = 1; end + + for i = 1:length(data) + % pupil + if strcmp(data{i}.header.chantype(1:end-1) , 'pupil_') + if strcmp(data{i}.header.chantype(end:end) , 'r') + data{i}.header.Description = eye_data_cell{idxRight}.pupil_size.Description; + data{i}.header.units = eye_data_cell{idxRight}.pupil_size.Units; + data{i}.header.sr = eye_data_cell{idxRight}.SamplingFrequency; + + elseif strcmp(data{i}.header.chantype(end:end) , 'l') + data{i}.header.Description = eye_data_cell{idxLeft}.pupil_size.Description; + data{i}.header.units = eye_data_cell{idxLeft}.pupil_size.Units; + data{i}.header.sr = eye_data_cell{idxLeft}.SamplingFrequency; + + else + warning('No valid pupil channel found.'); + end + % gaze + elseif strcmp(data{i}.header.chantype(1:end-4) , 'gaze') + if strcmp(data{i}.header.chantype(6) , 'x') + if strcmp(data{i}.header.chantype(8) , 'r') + % gaze_x_r + if any(strcmp(fieldnames(eye_data_cell{idxRight}),'SampleCoordinateUnits')) + data{i}.header.units = eye_data_cell{idxRight}.SampleCoordinateUnits; % "pixel" + elseif any(strcmp(fieldnames(eye_data_cell{idxRight}),'x_coordinate')) + data{i}.header.units = eye_data_cell{idxRight}.x_coordinate.Units; + else + warning('ID:missing_units', 'Units could not be determined for gaze_x_r channel.'); + end + + data{i}.header.sr = eye_data_cell{idxRight}.SamplingFrequency; + data{i}.header.range = [eye_data_cell{idxRight}.GazeRange.xmin, eye_data_cell{idxRight}.GazeRange.xmax] ; % e.g. [0 1151] + elseif strcmp(data{i}.header.chantype(8) , 'l') + % gaze_x_l + if any(strcmp(fieldnames(eye_data_cell{idxLeft}),'SampleCoordinateUnits')) + data{i}.header.units = eye_data_cell{idxLeft}.SampleCoordinateUnits; % "pixel" + elseif any(strcmp(fieldnames(eye_data_cell{idxLeft}),'x_coordinate')) + data{i}.header.units = eye_data_cell{idxLeft}.x_coordinate.Units; + else + warning('ID:missing_units', 'Units could not be determined for gaze_x_l channel.'); + end + + data{i}.header.sr = eye_data_cell{idxLeft}.SamplingFrequency; + data{i}.header.range = [eye_data_cell{idxLeft}.GazeRange.xmin, eye_data_cell{idxLeft}.GazeRange.xmax] ; % e.g. [0 1151] + else + warning('Something went worng with gaze x channels') + end + + elseif strcmp(data{i}.header.chantype(6) , 'y') + if strcmp(data{i}.header.chantype(8) , 'r') + % gaze_y_r + if any(strcmp(fieldnames(eye_data_cell{idxRight}),'SampleCoordinateUnits')) + data{i}.header.units = eye_data_cell{idxRight}.SampleCoordinateUnits; % "pixel" + elseif any(strcmp(fieldnames(eye_data_cell{idxRight}),'y_coordinate')) + data{i}.header.units = eye_data_cell{idxRight}.y_coordinate.Units; + else + warning('ID:missing_units', 'Units could not be determined for gaze_y_r channel.'); + end + data{i}.header.sr = eye_data_cell{idxRight}.SamplingFrequency; + data{i}.header.range = [eye_data_cell{idxRight}.GazeRange.ymin, eye_data_cell{idxRight}.GazeRange.ymax] ; % e.g. [0 1151] + + elseif strcmp(data{i}.header.chantype(8) , 'l') + % gaze_y_l + if any(strcmp(fieldnames(eye_data_cell{idxLeft}),'SampleCoordinateUnits')) + data{i}.header.units = eye_data_cell{idxLeft}.SampleCoordinateUnits; % "pixel" + elseif any(strcmp(fieldnames(eye_data_cell{idxLeft}),'y_coordinate')) + data{i}.header.units = eye_data_cell{idxLeft}.y_coordinate.Units; % should i add a check that x and y are the same units? + else + warning('ID:missing_units', 'Units could not be determined for gaze_y_l channel.'); + end + + data{i}.header.sr = eye_data_cell{idxLeft}.SamplingFrequency; + data{i}.header.range = [eye_data_cell{idxLeft}.GazeRange.ymin, eye_data_cell{idxLeft}.GazeRange.ymax] ; % e.g. [0 1151] + + else + warning('Something went worng with gaze y channels') + end + end end - end -end + end -%% --- Build the eye infos.source ---- - -% --- infos.source --- -infos.source = struct(); -infos.source.chan = {} ;% {'Column 02'} {'Column 01'}? -infos.source.chan_stats = cell(length(data), 1); % nan_stats - -% Calculating the nan ratio -for i = 1:length(data) - n_data = size(data{i}.data, 1); - n_inv = sum(isnan(data{i}.data)); - infos.source.chan_stats{i,1} = struct(); - infos.source.chan_stats{i,1}.nan_ratio = n_inv / n_data; -end + %% --- Build the eye infos.source ---- -if ~isequal(eye_data_cell{idxRight}.GazeRange, eye_data_cell{idxLeft}.GazeRange) - warning("GazeRange is not equal"); -end + % --- infos.source --- + infos.source = struct(); + infos.source.chan = {} ;% {'Column 02'} {'Column 01'}? + infos.source.chan_stats = cell(length(data), 1); % nan_stats -infos.source.gaze_coords = eye_data_cell{idxRight}.GazeRange; - -if any(strcmp(fieldnames(eye_data_cell{idxRight}),'PupilFitMethod')) - infos.source.elcl_proc = lower(eye_data_cell{idxRight}.PupilFitMethod); % or should it be called PupilFitMethod? lowercase! -elseif any(strcmp(fieldnames(eye_data_cell{idxRight}),'ElclProc')) - infos.source.elcl_proc = lower(eye_data_cell{idxRight}.ElclProc); % like in the Calinet dataset -end + % Calculating the nan ratio + for i = 1:length(data) + n_data = size(data{i}.data, 1); + n_inv = sum(isnan(data{i}.data)); + infos.source.chan_stats{i,1} = struct(); + infos.source.chan_stats{i,1}.nan_ratio = n_inv / n_data; + end -% eyesObserved and best_eye -if num_eyes == 2 - infos.source.eyesObserved = 'lr'; -elseif num_eyes == 1 - infos.source.eyesObserved = data{1}.header.chantype(end); -end + if ~isequal(eye_data_cell{idxRight}.GazeRange, eye_data_cell{idxLeft}.GazeRange) + warning("GazeRange is not equal"); + end -infos.source.best_eye = eye_with_smaller_nan_ratio(data, infos.source.eyesObserved); -infos.source.type = 'BIDS (json/tsv)' ; + infos.source.gaze_coords = eye_data_cell{idxRight}.GazeRange; + if any(strcmp(fieldnames(eye_data_cell{idxRight}),'PupilFitMethod')) + infos.source.elcl_proc = lower(eye_data_cell{idxRight}.PupilFitMethod); % or should it be called PupilFitMethod? lowercase! + elseif any(strcmp(fieldnames(eye_data_cell{idxRight}),'ElclProc')) + infos.source.elcl_proc = lower(eye_data_cell{idxRight}.ElclProc); % like in the Calinet dataset + end -if num_eyes == 2 - % physio_infos.source.file = [eye_data_cell{1}.source.file, eye_data_cell{2}.source.file] ; % {1},{2} gives the right order - file_paths{1,1} = eye_data_cell{1}.source.file; - file_paths{2,1} = eye_data_cell{2}.source.file; -else - file_paths{1,1} = eye_data_cell{1}.source.file ; -end + % eyesObserved and best_eye + if num_eyes == 2 + infos.source.eyesObserved = 'lr'; + elseif num_eyes == 1 + infos.source.eyesObserved = data{1}.header.chantype(end); + end + infos.source.best_eye = eye_with_smaller_nan_ratio(data, infos.source.eyesObserved); + infos.source.type = 'BIDS (json/tsv)' ; -% Check if the first data has the StartTime field -if isfield(data{1}.header, 'StartTime') - % Check if all StartTimes are the same - start_times = cellfun(@(x) x.header.StartTime, data, 'UniformOutput', false); - if ~isequal(start_times{:}) ; warning('Not all data have the same StartTime. Please check the input data.'); end -else - % If there is no StartTime field start time will set to 0 - for i = 1:length(data); data{i}.header.StartTime = 0; end -end + if num_eyes == 2 + % physio_infos.source.file = [eye_data_cell{1}.source.file, eye_data_cell{2}.source.file] ; % {1},{2} gives the right order + file_paths{1,1} = eye_data_cell{1}.source.file; + file_paths{2,1} = eye_data_cell{2}.source.file; + else + file_paths{1,1} = eye_data_cell{1}.source.file ; + end + + % Check if the first data has the StartTime field + if isfield(data{1}.header, 'StartTime') + % Check if all StartTimes are the same + start_times = cellfun(@(x) x.header.StartTime, data, 'UniformOutput', false); + if ~isequal(start_times{:}) ; warning('Not all data have the same StartTime. Please check the input data.'); end + else + % If there is no StartTime field start time will set to 0 + for i = 1:length(data); data{i}.header.StartTime = 0; end + end else warning('No data for physio eye data was imported.'); end % if ests == 1 - - - - %% Process physio eye event data -> header eyedata maybe somewhere else? +% Base physioevent pattern (JSON/TSV extension added later) +if isempty(task_name) + base_pattern = sprintf('%s_ses-%s_*physioevents', subject_id, session_id); +else + base_pattern = sprintf('%s_ses-%s_task-%s_physioevents', subject_id, session_id, task_name); +end -events_json_filename = sprintf('%s_ses-%s_task-%s_physioevents.json', subject_id, session_id, task_name); -events_tsv_filename = sprintf('%s_ses-%s_task-%s_physioevents.tsv', subject_id, session_id, task_name); -events_json_filepath = fullfile(physio_eye_path, events_json_filename); -events_tsv_filepath = fullfile(physio_eye_path, events_tsv_filename); +% Add JSON/TSV extensions +json_files = dir(fullfile(physio_eye_path, base_pattern + ".json")); +tsv_files = dir(fullfile(physio_eye_path, base_pattern + ".tsv")); -% Checks if the event files exist -if ~isfile(events_json_filepath) || ~isfile(events_tsv_filepath) - warning('No physio events for task "%s" in %s. Skipping event processing.', task_name, physio_eye_path); +% Check existence +if isempty(json_files) || isempty(tsv_files) + if isempty(task_name) + warning('No blink/saccade events in %s. Skipping event processing.', physio_eye_path); + else + warning('No blink/saccade events for task "%s" in %s. Skipping event processing.', ... + task_name, physio_eye_path); + end else - % Imports the eye event data - data_events = get_physio_events_data(events_json_filepath,events_tsv_filepath,false); % has ColumnField + % Construct final full file paths + events_json_filepath = fullfile(physio_eye_path, json_files(1).name); + events_tsv_filepath = fullfile(physio_eye_path, tsv_files(1).name); - % Gives the events the StartTime time as the eye data - if ~isempty(data) % if there are eye data but eye_events - for i = 1:length(data_events); data_events{i}.header.StartTime = data{1}.header.StartTime; end - end - file_paths{end+1,1} = {events_json_filepath,events_tsv_filepath}; - data = [ data; data_events]; -end % + % Checks if the event files exist + if ~isfile(events_json_filepath) || ~isfile(events_tsv_filepath) + warning('Event file %s does not exist.', events_json_filepath); + else + % Imports the eye event data + data_events = get_physio_events_data(events_json_filepath, events_tsv_filepath, false); % has ColumnField + + % Gives the events the StartTime time as the eye data + if ~isempty(data) % if there are eye data but eye_events + for i = 1:length(data_events); data_events{i}.header.StartTime = data{1}.header.StartTime; end + end + file_paths{end+1,1} = {events_json_filepath,events_tsv_filepath}; + data = [data; data_events]; + end % +end %% - if isempty(data) warning('No physio eye event data has been imported.'); return end -sts = 1; +sts = 1; infos.source.file = file_paths; -return - end % adapted from in pspm_get_viewpoint and pspm_get_smi function best_eye = eye_with_smaller_nan_ratio(data, eyes_observed) - if length(eyes_observed) == 1 - best_eye = lower(eyes_observed); - else - eye_L_max_nan_ratio = 0; - eye_R_max_nan_ratio = 0; - for i = 1:numel(data) - left_data = strcmpi(data{i}.header.chantype(end),'l'); +if length(eyes_observed) == 1 + best_eye = lower(eyes_observed); +else + eye_L_max_nan_ratio = 0; + eye_R_max_nan_ratio = 0; + for i = 1:numel(data) + left_data = strcmpi(data{i}.header.chantype(end),'l'); right_data = strcmpi(data{i}.header.chantype(end),'r'); - + if left_data - eye_L_max_nan_ratio = max(eye_L_max_nan_ratio, sum(isnan(data{i}.data))); + eye_L_max_nan_ratio = max(eye_L_max_nan_ratio, sum(isnan(data{i}.data))); elseif right_data - eye_R_max_nan_ratio = max(eye_R_max_nan_ratio, sum(isnan(data{i}.data))); + eye_R_max_nan_ratio = max(eye_R_max_nan_ratio, sum(isnan(data{i}.data))); end - end + end - if eye_L_max_nan_ratio > eye_R_max_nan_ratio - best_eye = 'r'; - else + if eye_L_max_nan_ratio > eye_R_max_nan_ratio + best_eye = 'r'; + else best_eye = 'l'; % if equal set 'l' - end end end +end function data = get_physio_events_data(events_json_filepath, events_tsv_filepath, noColumnField) sr = 1; % default @@ -318,11 +328,11 @@ data{3,1}.data.header = struct(); col_types = {'double', 'double', 'char', 'char', 'char'}; - + % Get the event json event_json = extract_json_as_struct(events_json_filepath); -if noColumnField +if noColumnField headings = fieldnames(event_json).'; elseif isfield(event_json, 'Columns') headings = event_json.Columns; @@ -335,63 +345,62 @@ % Checks if it is a proper physio eye event data -if ~any(ismember(marker_tsv_data_table.Properties.VariableNames, {'blink','message'})) +if ~any(ismember(marker_tsv_data_table.Properties.VariableNames, {'blink','message'})) warining('No physio events') data = -1; return ; end -idx_header = strcmp(marker_tsv_data_table.event_type, 'n/a') & ~strcmp(marker_tsv_data_table.message, 'CS'); +idx_header = strcmp(marker_tsv_data_table.event_type, 'n/a') & ~strcmp(marker_tsv_data_table.message, 'CS'); -idx_data = ~idx_header; +idx_data = ~idx_header; % Find Record Configuration indices_reccfg = find(contains(marker_tsv_data_table.message, 'RECCFG')); % find Record Configuration reccfg = split(marker_tsv_data_table.message(indices_reccfg)); -sr = str2double(reccfg{3}); -eyes = reccfg{6}; % could be used in the future to choose the rigth blink channel - -% Set first measurment to zero -onsets = marker_tsv_data_table.onset(idx_data); -onsets = (onsets - onsets(1)); % shifting onset times -duration = marker_tsv_data_table.duration(idx_data); -event_type = marker_tsv_data_table.event_type(idx_data); % including CS (NaN) will be excluted later +if ~isempty(reccfg) + sr = str2double(reccfg{3}); + eyes = reccfg{6}; % could be used in the future to choose the rigth blink channel -signal = {'blink','saccade','fixation'}; -singal_chan = {'blink_c','saccade_c','fixation_c'}; -for s = 1:numel(signal) + % Set first measurment to zero + onsets = marker_tsv_data_table.onset(idx_data); + onsets = (onsets - onsets(1)); % shifting onset times + duration = marker_tsv_data_table.duration(idx_data); + event_type = marker_tsv_data_table.event_type(idx_data); % including CS (NaN) will be excluted later -% Index of the onsets of the signal -idx_signal = find(strcmp(event_type, signal{1})); % excludes NaNs + signal = {'blink','saccade','fixation'}; + singal_chan = {'blink_c','saccade_c','fixation_c'}; -% get onset start to onset end(onset+duration) -starts = onsets(idx_signal); -ends = onsets(idx_signal) + duration(idx_signal); + for s = 1:numel(signal) -all_indices = []; -for i = 1:length(starts); all_indices = [all_indices, starts(i):ends(i)]; end + % Index of the onsets of the signal + idx_signal = find(strcmp(event_type, signal{1})); % excludes NaNs -idx_signal = unique(all_indices); % removes overlaps -data_signal = zeros(idx_signal(end),1); + % get onset start to onset end(onset+duration) + starts = onsets(idx_signal); + ends = onsets(idx_signal) + duration(idx_signal); -for i = 1:length(idx_signal); data_signal(idx_signal(i),1) = 1; end % Map values to these indices (set them to 1) -if ~(sum(data_signal) == length(idx_signal)); warning('Not same length.'); return; end % sanitiy check + all_indices = []; + for i = 1:length(starts); all_indices = [all_indices, starts(i):ends(i)]; end + idx_signal = unique(all_indices); % removes overlaps + data_signal = zeros(idx_signal(end),1); + for i = 1:length(idx_signal); data_signal(idx_signal(i),1) = 1; end % Map values to these indices (set them to 1) + if ~(sum(data_signal) == length(idx_signal)); warning('Not same length.'); return; end % sanitiy check - -% assign pupil data -data{s,1}.data = data_signal; -% add header -data{s,1}.header.chantype = singal_chan{s}; -data{s,1}.header.units = signal{s}; -data{s,1}.header.sr = sr; -data{s,1}.header.StartTime = onsets(1)/sr; % to get it in secondes - + % assign pupil data + data{s,1}.data = data_signal; + % add header + data{s,1}.header.chantype = singal_chan{s}; + data{s,1}.header.units = signal{s}; + data{s,1}.header.sr = sr; + data{s,1}.header.StartTime = onsets(1)/sr; % to get it in secondes + end end end \ No newline at end of file diff --git a/src/pspm_check_data.m b/src/pspm_check_data.m index a8d1461fc..e49a0f559 100644 --- a/src/pspm_check_data.m +++ b/src/pspm_check_data.m @@ -26,8 +26,17 @@ % check infos if nargin > 1 flag_infos = 0; - if ~isstruct(infos) || isempty(fieldnames(infos)) || ~isfield(infos, 'duration') - warning('ID:invalid_data_structure', 'Invalid infos structure.'); + msg = ''; + if ~isstruct(infos) + msg = ".infos is not a struct"; + elseif isempty(fieldnames(infos)) + msg = ".infos is empty"; + elseif ~isfield(infos, 'duration') + msg = ".infos does not contain duration"; + end + + if ~isempty(msg) + warning('ID:invalid_data_structure', 'Invalid infos structure: %s.', msg); return end else diff --git a/src/pspm_import_bids.m b/src/pspm_import_bids.m index bf6c52440..a3682b777 100644 --- a/src/pspm_import_bids.m +++ b/src/pspm_import_bids.m @@ -1,19 +1,85 @@ function [sts, outfile] = pspm_import_bids(dataset_path, save_path) % ● Description -% pspm_import_bids reads a BIDS formatted dataset for a set of -% participants from a given data path and stores data as PsPM file(s). +% pspm_import_bids reads a BIDS-formatted dataset (BEP020/BEP045) containing +% physiology and/or eye-tracking recordings for one or more participants. +% The function detects available tasks, sessions, and modalities, imports +% all BIDS-compliant JSON/TSV files, and stores the result as PsPM .mat files. +% +% The importer supports datasets with: +% • multiple tasks per session (e.g., Acquisition, Extinction, Habituation) +% • separate beh/ and physio/ folders +% • optional task- entities (task-) in filenames +% • sessions with or without behavioral events +% +% Example supported layout (multi-task session, no 'beh' folder): +% +% sub-/ +% └── +% └── physio +% ├── sub-__task-Acquisition_events.json +% ├── sub-__task-Acquisition_events.tsv +% ├── sub-__task-Acquisition_physioevents.json +% ├── sub-__task-Acquisition_physioevents.tsv +% ├── sub-__task-Acquisition_recording-ecg_physio.json +% ├── sub-__task-Acquisition_recording-ecg_physio.tsv +% ├── sub-__task-Acquisition_recording-eye1_physio.json +% ├── sub-__task-Acquisition_recording-eye1_physio.tsv +% ├── sub-__task-Acquisition_recording-eye2_physio.json +% ├── sub-__task-Acquisition_recording-eye2_physio.tsv +% ├── sub-__task-Acquisition_recording-scr_physio.json +% ├── sub-__task-Acquisition_recording-scr_physio.tsv +% ├── sub-__task-Extinction_... (same pattern) +% ├── sub-__task-Habituation_... (same pattern) +% +% Example supported layout (tasks split per session, no 'task' ID): +% +% sub-/ +% ├── ses-01 +% │ ├── beh +% │ │ ├── sub-_ses-01_beh.json +% │ │ ├── sub-_ses-01_events.json +% │ │ └── sub-_ses-01_events.tsv +% │ └── physio +% │ ├── sub-_ses-01_recording-ecg_physio.json +% │ ├── sub-_ses-01_recording-ecg_physio.tsv +% │ ├── sub-_ses-01_recording-eye1_physio.json +% │ ├── sub-_ses-01_recording-eye1_physio.tsv +% │ ├── sub-_ses-01_recording-eye2_physio.json +% │ ├── sub-_ses-01_recording-eye2_physio.tsv +% │ ├── sub-_ses-01_recording-ppg_physio.json +% │ ├── sub-_ses-01_recording-ppg_physio.tsv +% │ ├── sub-_ses-01_recording-scr_physio.json +% │ └── sub-_ses-01_recording-scr_physio.tsv +% └── ses-02 +% ├── beh +% └── physio +% % ● Format % [sts, outfile] = pspm_import_bids(dataset_path, save_path) +% % ● Arguments -% dataset_path: path to the data set / subject / session folder -% save_path: path to save the PsPM files / if non mention one level -% above dataset_path in the folder out +% dataset_path: path to the dataset, subject, or session folder +% save_path: path where PsPM .mat files will be written. +% If omitted, files are stored in ./out one level +% above dataset_path. +% % ● Output -% outfile: cell array of generated PsPM file names +% outfile: cell array of full paths to generated PsPM .mat files +% sts: status flag (1 = success, 0 = failure) +% % ● History % Introduced in PsPM 7.0 -% Written in 2024 by Sourav Koulkarni & Dominik R Bach & Bernhard A. von Raußendorf (Uni Bonn) - +% Written in 2024 by Sourav Koulkarni, +% Dominik R. Bach, +% Bernhard A. von Raußendorf (University of Bonn) +% +% 05.12.2025: +% - Overall updates on logic and flow +% - Addded support for multiple tasks within a single session. +% - Abstracted away some logic in separate functions +% - Updated handling of 'save_path' argument +% - Prettify interface +% %% 1. Initialize ----------------------------------------------------------- global settings if isempty(settings) @@ -30,11 +96,6 @@ elseif ~exist(dataset_path, 'dir') error('ID:invalid_input','dataset_path has to be a folder'); end -if nargin < 2 - save_path = 0; -elseif ~(ischar(save_path) || isstring(save_path)) - save_path = 0; -end if nargin > 2; warning('More than two inputs have been provided; any additional inputs will be ignored.' ); end @@ -89,27 +150,29 @@ end % output folder (save_path) -if ~save_path - save_path = [dataset_path, filesep, 'out']; - warning("ID:nonexistent_folder","No or invalid save path specified; using '%s' instead.", save_path); -end - -if ~exist(save_path, 'dir'); mkdir(save_path); end -fprintf('\nImported files will be saved to: %s\n',save_path); - - - +if ~isstring(save_path) + % save_path = [dataset_path, filesep, 'out']; + save_path = fullfile(dataset_path, "out"); + disp(save_path); + % warning("ID:nonexistent_folder","No or invalid save path specified; using '%s' instead.", save_path); + warning(sprintf("ID:nonexistent_folder: No or invalid save path specified; using '%s' instead.", save_path)); +end + +%% Start message +pspm_bids_importer_header(dataset_path, length(subject_list), save_path) +if ~exist(save_path, 'dir') + mkdir(save_path); +end +nSubjects = 0; +nSessions = 0; %% 3. Loop over subjects --------------------------------------------------- for i = 1:length(subject_list) subject_full_id = subject_list(i).name; % e.g., 'sub-CalinetBonn01 sub_idx_str = regexp(subject_full_id, '\d+$', 'match', 'once'); - - fprintf('\n------------------------------------------------------------------------------------------------------------------------'); - fprintf('\n------------------------------------------------------------------------------------------------------------------------'); - fprintf('\n\nImporting %s ... \n', subject_full_id); + fprintf('Importing %s ... \n', subject_full_id); if dataset_mode % current subject path @@ -120,140 +183,147 @@ [~, session_dirs(1).name] = fileparts(ses_path); else % subject mode or dataset_mode session_dirs = dir(fullfile(sub_path,'ses-*')); - session_dirs = session_dirs([session_dirs.isdir]); + session_dirs = session_dirs([session_dirs.isdir]); + session_dirs = session_dirs(~ismember({session_dirs.name}, {'.','..'})); end % checks if there are sessions if isempty(session_dirs); warning('ID:nonexistent_folder','No session folder (''ses-%s'') found in %s', sub_idx_str ,sub_path); continue; end - %% Process each session + %% Process each session for j = 1:length(session_dirs) session_id = session_dirs(j).name(5:end); % e.g., '01' or '02' (could there be more 100 sessions?) ses_path = fullfile(sub_path,session_dirs(j).name); % if it is ses_mode it will be overwriten but that is okay - beh_dir = fullfile(ses_path,'beh'); - physio_dir = fullfile(ses_path,'physio'); - + physio_dir = fullfile(ses_path, 'physio'); + + fprintf('\n--------------------------------------------------------------------------------\n'); %% Extract task name % Look for any event JSON in the beh and physio folders - pattern_beh = sprintf('%s_ses-%s_task-*_events.*', subject_full_id, session_id); % both json and tsv - beh_files = dir(fullfile(beh_dir, pattern_beh)); - pattern_physio = sprintf('%s_ses-%s_task-*_physioevents.*', subject_full_id, session_id); - physio_files = dir(fullfile(physio_dir, pattern_physio)); + task_ids = get_bids_task_ids(physio_dir); - if ~isempty(beh_files) && length(beh_files) == 2 - fname = beh_files(1).name; - elseif ~isempty(physio_files) && length(physio_files) == 2 - fname = physio_files(1).name; + % If none found → process the session once without a task name + if isempty(task_ids) + task_ids = {''}; % placeholder for “no task” + fprintf('Processing %s\n', session_dirs(j).name); else - warning('ID:nonexistent_file','No BIDS event or physio files for %s session %s', subject_full_id, session_id); - continue; + task_list_str = strjoin(task_ids, ', '); + fprintf('Processing %s with %d task(s): %s\n', ... + session_dirs(j).name, length(task_ids), task_list_str); end - % Extract the token after 'task-' and before the next underscore - tk = regexp(fname, '_task-([^_]+)_', 'tokens', 'once'); - task_name = tk{1}; - - %% Processing start - fprintf('\n------------------------------------------------------------------------------------------------------------------------'); - fprintf('\n\n Processing session %s with task %s ...\n\n', session_dirs(j).name, task_name); - - % --- get physio data --- - physio_path = fullfile(ses_path,'physio'); - [psts, physio_data, physio_infos] = get_physio_data(subject_full_id, session_id, task_name, physio_path); - [pests, physio_eye_data, physio_eye_infos] = get_physio_eye_data(subject_full_id, session_id, task_name, physio_path); - - if psts < 1; warning('ID:no_import','No physiology data were imported.'); end % - if pests < 1; warning('ID:no_import','No physiology eye data were imported.'); end % - - %% --- Get beh data --- - - beh_path = fullfile(ses_path,'beh'); - - % Marker beh channel - events_json_filename = sprintf('%s_ses-%s_task-%s_events.json', subject_full_id, session_id, task_name); - events_tsv_filename = sprintf('%s_ses-%s_task-%s_events.tsv', subject_full_id, session_id, task_name); - events_json_filepath = fullfile(beh_path, events_json_filename); - events_tsv_filepath = fullfile(beh_path, events_tsv_filename); - - - if isfile(events_json_filepath) && isfile(events_tsv_filepath) - marker_chan{1} = get_marker_data(events_json_filepath, events_tsv_filepath, true); - else - marker_chan = [ ]; - warning('ID:nonexistent_file','File not found: %s', events_json_filepath); - warning('ID:nonexistent_file','File not found: %s', events_tsv_filepath); - end - - % get behave json - beh_json = get_beh_json(subject_full_id, session_id, task_name, beh_path); - - %% --- Build the file structure --- - % Build sessions infos - - - % ses.infos.duration - will be added after alignment - - % infos.importfile - will be added before saving - dt = datetime('now'); - ses.infos.importdate = sprintf('%.2d.%.2d.%.2d', dt.Day, dt.Month, dt.Year); % same as import_eyelink and importviewpoint; - % durationinfo = 'Recording duration in seconds'; - % ses.infos.recdate - no information; - % ses.infos.rectime - no information; - - % infos.source - % ses.infos.source = struct(); - ses.infos.source = physio_eye_infos.source; - ses.infos.source.file = [physio_infos.source.file; physio_eye_infos.source.file]; - ses.infos.source.type = 'BIDS (json/tsv)'; % physio_infos.infos; - % ses.infos.source.chan_stats - will be calculted later - - if ~isempty(dataset_description); infos.DatasetDescription = dataset_description; end - % if ~isempty(fieldnames(currentParticipant)); infos.Participant = currentParticipant; end - - % data - ses.data = {}; - ses.data = [marker_chan ; physio_data; physio_eye_data]; - - % Calculates the nan_ratio for all channels - for r = 1:length(ses.data) - n_data = size(ses.data{r}.data, 1); - n_inv = sum(isnan(ses.data{r}.data)); - ses.infos.source.chan_stats{r,1} = struct(); - ses.infos.source.chan_stats{r,1}.nan_ratio = n_inv / n_data; - end - - - % Aligns all channels - [asts, ses.data, ses.infos.duration] = align_channels(ses.data); - if asts ~= 1; continue; end - - % Save session - ses_filename = sprintf('pspm_%s_ses-%s.mat', subject_full_id,session_id); - ses_filepath = fullfile(save_path, ses_filename); - outfile{end+1} = ses_filepath; - - ses.infos.importfile = ses_filepath; - - % Check the pspm structure - [lsts, ~, ~, ~] = pspm_load_data(ses); - if lsts < 1 - warning('ID:could_not_be_saved','The file struture has a problem'); % better warning text - continue; - end - - % saves as pspm file (overwrite) - data = ses.data; - infos = ses.infos; - save(ses_filepath,'infos', 'data'); - fprintf('\n\nSaved cogent file to ''%s''\n', ses_filepath); - - end + % loop over tasks + for t = 1:numel(task_ids) + + %% Build file patterns depending on task_name + task_name = task_ids{t}; + if isempty(task_name) + fprintf("\nReading data\n"); + beh_base = sprintf('%s_ses-%s_', subject_full_id, session_id); + else + fprintf("\nReading data from task-%s\n", task_name); + beh_base = sprintf('%s_ses-%s_task-%s_', subject_full_id, session_id, task_name); + end + %% Processing start + % read in data + physio_path = fullfile(ses_path, 'physio'); + [~, physio_data, physio_infos] = get_physio_data(subject_full_id, session_id, task_name, physio_path); + [~, physio_eye_data, physio_eye_infos] = get_physio_eye_data(subject_full_id, session_id, task_name, physio_path); + + %% Get beh data --- + % *events file can be in 'beh' or 'physio' folder | prioritize + % 'beh' + [events_json_filepath, events_tsv_filepath, ~, ~] = bids_find_events(ses_path, beh_base, task_name); + + if isfile(events_json_filepath) && isfile(events_tsv_filepath) + marker_chan{1} = get_marker_data(events_json_filepath, events_tsv_filepath, true); + else + marker_chan = [ ]; + warning('ID:nonexistent_file','File not found: %s', events_json_filepath); + warning('ID:nonexistent_file','File not found: %s', events_tsv_filepath); + end + + % get behave json + beh_json = get_beh_json(events_json_filepath); + + %% --- Build the file structure --- + % Build sessions infos + + + % ses.infos.duration - will be added after alignment + + % infos.importfile - will be added before saving + dt = datetime('now'); + ses.infos.importdate = sprintf('%.2d.%.2d.%.2d', dt.Day, dt.Month, dt.Year); % same as import_eyelink and importviewpoint; + % durationinfo = 'Recording duration in seconds'; + % ses.infos.recdate - no information; + % ses.infos.rectime - no information; + + % infos.source + % ses.infos.source = struct(); + ses.infos.source = physio_eye_infos.source; + ses.infos.source.file = [physio_infos.source.file; physio_eye_infos.source.file]; + ses.infos.source.type = 'BIDS (json/tsv)'; % physio_infos.infos; + % ses.infos.source.chan_stats - will be calculted later + + if ~isempty(dataset_description); infos.DatasetDescription = dataset_description; end + % if ~isempty(fieldnames(currentParticipant)); infos.Participant = currentParticipant; end + + % data + ses.data = {}; + ses.data = [marker_chan; physio_data; physio_eye_data]; + + % Calculates the nan_ratio for all channels + fprintf("Calculate the nan_ratio for all channels\n"); + ses = pspm_update_nan_stats(ses); + + % populate fields from json + fprintf("Adding info from %s to channel headers\n", events_json_filepath); + fn = fieldnames(beh_json); + for ii = 1:numel(fn) + ses.infos.(fn{ii}) = beh_json.(fn{ii}); + end + + % Aligns all channels + [asts, ses.data, ses.infos.duration] = align_channels(ses.data); + if asts ~= 1; continue; end + + % Save session + if isempty(task_name) || length(task_ids) == 1 + ses_filename = sprintf('pspm_%s_ses-%s.mat', subject_full_id, session_id); + else + ses_filename = sprintf('pspm_%s_ses-%s_task-%s.mat', subject_full_id, session_id, task_name); + end + + ses_filepath = fullfile(save_path, ses_filename); + outfile{end+1} = ses_filepath; + ses.infos.importfile = ses_filepath; + + % Check the pspm structure + [lsts, ~, ~, ~] = pspm_load_data(ses); + if lsts < 1 + warning('ID:could_not_be_saved','The file struture has a problem'); % better warning text + continue; + end + + % saves as pspm file (overwrite) + data = ses.data; + infos = ses.infos; + save(ses_filepath,'infos', 'data'); + fprintf('\nSaved PsPM-file to ''%s''\n', ses_filepath); + + end % close task loop + nSessions = nSessions + 1; + end % close ses loop + nSubjects = nSubjects + 1; +end % close subj loop -end rmpath(libpath); % What if the function breaks at another path sts = 1; + +%% footer +pspm_bids_importer_footer(nSubjects, nSessions, save_path) end %% 4. Sub-functions --------------------------------------------------------- @@ -277,6 +347,7 @@ dataset_description = []; end end + % Could be implemented in the future function [participants_data, column_headings] = read_participants_data(dataset_path) % Imports the participant data from participants.tsv (independent the participiants.json) @@ -295,12 +366,9 @@ end end -function [infos] = get_beh_json(subject_id, session_id, task_name, beh_path) -infos = struct(); -beh_json_filename = sprintf('%s_ses-%s_task-%s_beh.json', subject_id, session_id, task_name); -%beh_json_filename = sprintf('%s_ses-%s_beh.json', subject_id, session_id); -beh_json_filepath = fullfile(beh_path, beh_json_filename); +function [infos] = get_beh_json(beh_json_filepath) +infos = struct(); if ~isfile(beh_json_filepath) warning('ID:non_existent_file','Behavior sidecar JSON file not found: %s', beh_json_filepath); return @@ -336,7 +404,7 @@ data{i}.header.StartTime = data{i}.data(1); else if ~isfield(data{i}.header, 'sr') - warning('ID:non_existent_field','Channel %d is missing sampling rate (sr) in its header. This will lead to probelms later.', i); + warning('ID:non_existent_field','Channel %d is missing sampling rate (sr) in its header. This will lead to problems later.', i); continue; end sr = data{i}.header.sr; @@ -353,12 +421,174 @@ % Padding at the end [sts, data, new_duration] = pspm_align_channels(data); % can the fprint be turned off? - if sts ~= 1 % if all are the same size does it give en error? warning('ID:channel_alignment_failed','Channel alignment failed.'); return end +end + +function task_ids = get_bids_task_ids(physio_dir) + + files = dir(fullfile(physio_dir, '*.json')); % look at metadata files + task_ids = {}; + + % regexp pattern for `_task-` + expr = '(?<=_task-)[a-zA-Z0-9]+'; + + for i = 1:numel(files) + tokens = regexp(files(i).name, expr, 'match'); + if ~isempty(tokens) + task_ids{end+1} = tokens{1}; + end + end + + % return uniques (preserve order) + if ~isempty(task_ids) + task_ids = unique(task_ids, 'stable'); + end +end + +function pspm_bids_importer_header(dataset_path, nSubjects, save_path) + +% Detect PsPM version if available +pspm_ver = "unknown"; +try + pspm_ver = string(pspm_version); +end + +timestamp = string(datetime('now','Format','yyyy-MM-dd HH:mm:ss')); + +fprintf('\n'); +fprintf('================================================================================\n'); +fprintf(' PsPM BIDS Importer\n'); +fprintf('--------------------------------------------------------------------------------\n'); +fprintf(' Version : %s\n', pspm_ver); +fprintf(' Started : %s\n', timestamp); +fprintf(' Description : Import BIDS-compliant (BEP020/BEP045) eye tracking & physiology\n'); +fprintf(' data into PsPM format.\n'); +fprintf(' BIDS path : %s\n', dataset_path); +fprintf(' Output path : %s\n', save_path); +fprintf(' N subjects : %d\n', nSubjects); +fprintf('================================================================================\n\n'); + +end + +function pspm_bids_importer_footer(nSubjects, nSessions, output_dir) + +timestamp = string(datetime('now', 'Format', 'yyyy-MM-dd HH:mm:ss')); +fprintf('\n'); +fprintf('================================================================================\n'); +fprintf(' BIDS Import Completed Successfully\n'); +fprintf('--------------------------------------------------------------------------------\n'); + +if nargin >= 1 && ~isempty(nSubjects) + fprintf(' Subjects processed : %d\n', nSubjects); +end + +if nargin >= 2 && ~isempty(nSessions) + fprintf(' Sessions processed : %d\n', nSessions); +end + +if nargin >= 3 && ~isempty(output_dir) + fprintf(' Output directory : %s\n', output_dir); +end + +fprintf(' Finished at : %s\n', timestamp); +fprintf('================================================================================\n\n'); + +end + +function [json_path, tsv_path, source_dir, status] = bids_find_events(ses_path, beh_base, task_name) +% BIDS_FIND_EVENTS Locate behavioral/physio event files for a PsPM session. +% +% [json_path, tsv_path, source_dir, status] = bids_find_events(ses_path, beh_base, task_name) +% +% - Searches in priority order: "beh" → "physio" +% - beh_base is the filename prefix (including task- if applicable) +% - task_name may be '' for sessions without tasks +% +% Returns: +% json_path Full path to events.json +% tsv_path Full path to events.tsv +% source_dir Directory used ('beh' or 'physio') +% status 1 if found, 0 otherwise + + json_path = ""; + tsv_path = ""; + source_dir = ""; + status = 0; + + event_dirs = ["beh", "physio"]; % priority order + + for d = event_dirs + candidate_dir = fullfile(ses_path, d); + + % Build patterns + pattern_json = beh_base + "events.json"; + pattern_tsv = beh_base + "events.tsv"; + + % Search using dir() + json_files = dir(fullfile(candidate_dir, pattern_json)); + tsv_files = dir(fullfile(candidate_dir, pattern_tsv)); + + if ~isempty(json_files) && ~isempty(tsv_files) + % Found matching pair + json_path = fullfile(candidate_dir, json_files(1).name); + tsv_path = fullfile(candidate_dir, tsv_files(1).name); + source_dir = d; + status = 1; + return; + end + end + + % No match found + if isempty(task_name) + warning('No event files found in %s (no task).', ses_path); + else + warning('No event files found for task "%s" in %s.', task_name, ses_path); + end + +end + +function ses = pspm_update_nan_stats(ses) +% PSPM_UPDATE_NAN_STATS +% Computes NaN ratios for each channel in ses.data +% and inserts them into ses.infos.source.chan_stats. +% +% INPUT: +% ses : PsPM session struct with fields: +% - ses.data (cell array of channel structs) +% - ses.infos.source (struct) +% +% OUTPUT: +% ses : same struct, but with: +% ses.infos.source.chan_stats updated + + data_cells = ses.data; + nChannels = numel(data_cells); + + chan_stats = cell(nChannels, 1); + + for r = 1:nChannels + chan = data_cells{r}.data; + + if isnumeric(chan) + % Numeric channel → compute NaN ratio + n_data = numel(chan); + n_inv = sum(isnan(chan), 'all'); % count all NaNs + nan_ratio = n_inv / n_data; + else + % Non-numeric channel (e.g., event markers) → no NaN concept + nan_ratio = NaN; + end + + chan_stats{r} = struct( ... + 'nan_ratio', nan_ratio ... + ); + end + % Insert into ses + ses.infos.source.chan_stats = chan_stats; end From d22c6d0e17d013da7fff8e98e7b697388bc8b4a3 Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Sun, 7 Dec 2025 16:01:09 +0100 Subject: [PATCH 02/20] read beh.json and store in header --- src/pspm_import_bids.m | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/pspm_import_bids.m b/src/pspm_import_bids.m index a3682b777..3f5f3e84a 100644 --- a/src/pspm_import_bids.m +++ b/src/pspm_import_bids.m @@ -234,7 +234,7 @@ %% Get beh data --- % *events file can be in 'beh' or 'physio' folder | prioritize % 'beh' - [events_json_filepath, events_tsv_filepath, ~, ~] = bids_find_events(ses_path, beh_base, task_name); + [events_json_filepath, events_tsv_filepath, beh_json_filepath, ~, ~] = bids_find_events(ses_path, beh_base, task_name); if isfile(events_json_filepath) && isfile(events_tsv_filepath) marker_chan{1} = get_marker_data(events_json_filepath, events_tsv_filepath, true); @@ -243,9 +243,16 @@ warning('ID:nonexistent_file','File not found: %s', events_json_filepath); warning('ID:nonexistent_file','File not found: %s', events_tsv_filepath); end + + % beh-file contains relevant info about stimulus presentation; + % required for eye-data + if ~isfile(beh_json_filepath) + beh_json_filepath = events_json_filepath; + warning('ID:nonexistent_file','File not found: %s. Attempting to use %s, but may result in issues downstream', beh_json_filepath, events_json_filepath); + end % get behave json - beh_json = get_beh_json(events_json_filepath); + beh_json = get_beh_json(beh_json_filepath); %% --- Build the file structure --- % Build sessions infos @@ -499,10 +506,10 @@ function pspm_bids_importer_footer(nSubjects, nSessions, output_dir) end -function [json_path, tsv_path, source_dir, status] = bids_find_events(ses_path, beh_base, task_name) +function [json_path, tsv_path, beh_path, source_dir, status] = bids_find_events(ses_path, beh_base, task_name) % BIDS_FIND_EVENTS Locate behavioral/physio event files for a PsPM session. % -% [json_path, tsv_path, source_dir, status] = bids_find_events(ses_path, beh_base, task_name) +% [json_path, tsv_path, beh_path, source_dir, status] = bids_find_events(ses_path, beh_base, task_name) % % - Searches in priority order: "beh" → "physio" % - beh_base is the filename prefix (including task- if applicable) @@ -511,11 +518,13 @@ function pspm_bids_importer_footer(nSubjects, nSessions, output_dir) % Returns: % json_path Full path to events.json % tsv_path Full path to events.tsv +% beh_path Full path to beh.json % source_dir Directory used ('beh' or 'physio') % status 1 if found, 0 otherwise json_path = ""; tsv_path = ""; + beh_path = ""; source_dir = ""; status = 0; @@ -527,10 +536,12 @@ function pspm_bids_importer_footer(nSubjects, nSessions, output_dir) % Build patterns pattern_json = beh_base + "events.json"; pattern_tsv = beh_base + "events.tsv"; + pattern_beh = beh_base + "beh.json"; % Search using dir() json_files = dir(fullfile(candidate_dir, pattern_json)); tsv_files = dir(fullfile(candidate_dir, pattern_tsv)); + beh_files = dir(fullfile(candidate_dir, pattern_beh)); if ~isempty(json_files) && ~isempty(tsv_files) % Found matching pair @@ -538,8 +549,13 @@ function pspm_bids_importer_footer(nSubjects, nSessions, output_dir) tsv_path = fullfile(candidate_dir, tsv_files(1).name); source_dir = d; status = 1; - return; end + + if ~isempty(beh_files) + % Found matching pair + beh_path = fullfile(candidate_dir, beh_files(1).name); + end + return; end % No match found From 618719474651ef8e32865ed8c6090ff755500336 Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Mon, 8 Dec 2025 11:44:02 +0100 Subject: [PATCH 03/20] verify output of get_physio_events_data --- src/bids_importer/lib/get_physio_eye_data.m | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/bids_importer/lib/get_physio_eye_data.m b/src/bids_importer/lib/get_physio_eye_data.m index 4e5411461..416379bfa 100644 --- a/src/bids_importer/lib/get_physio_eye_data.m +++ b/src/bids_importer/lib/get_physio_eye_data.m @@ -225,7 +225,7 @@ else file_paths{1,1} = eye_data_cell{1}.source.file ; end - + % Check if the first data has the StartTime field if isfield(data{1}.header, 'StartTime') % Check if all StartTimes are the same @@ -274,8 +274,10 @@ data_events = get_physio_events_data(events_json_filepath, events_tsv_filepath, false); % has ColumnField % Gives the events the StartTime time as the eye data - if ~isempty(data) % if there are eye data but eye_events + if ~isempty(data_events) % if there are eye data but eye_events for i = 1:length(data_events); data_events{i}.header.StartTime = data{1}.header.StartTime; end + else + warning('No events for physio eye data were imported.'); end file_paths{end+1,1} = {events_json_filepath,events_tsv_filepath}; data = [data; data_events]; @@ -401,6 +403,7 @@ data{s,1}.header.sr = sr; data{s,1}.header.StartTime = onsets(1)/sr; % to get it in secondes end - +else + data = {}; end end \ No newline at end of file From 49945eb3feb9d180fa8e62a59bf98a3cbed6b752 Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Mon, 8 Dec 2025 12:20:40 +0100 Subject: [PATCH 04/20] save_path can be char too --- src/pspm_import_bids.m | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pspm_import_bids.m b/src/pspm_import_bids.m index 3f5f3e84a..941555c88 100644 --- a/src/pspm_import_bids.m +++ b/src/pspm_import_bids.m @@ -150,7 +150,7 @@ end % output folder (save_path) -if ~isstring(save_path) +if ~(isstring(save_path) || ischar(save_path)) % save_path = [dataset_path, filesep, 'out']; save_path = fullfile(dataset_path, "out"); disp(save_path); @@ -304,8 +304,8 @@ end ses_filepath = fullfile(save_path, ses_filename); - outfile{end+1} = ses_filepath; - ses.infos.importfile = ses_filepath; + outfile{end+1} = char(ses_filepath); + ses.infos.importfile = char(ses_filepath); % Check the pspm structure [lsts, ~, ~, ~] = pspm_load_data(ses); From 9619ca4eec1404e12014bee72d5f36599b7ebcac Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Wed, 24 Dec 2025 13:52:07 +0100 Subject: [PATCH 05/20] clean up indices generation --- src/bids_importer/lib/get_physio_eye_data.m | 38 +++++++++++++++------ 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/bids_importer/lib/get_physio_eye_data.m b/src/bids_importer/lib/get_physio_eye_data.m index 416379bfa..d9bcd12f1 100644 --- a/src/bids_importer/lib/get_physio_eye_data.m +++ b/src/bids_importer/lib/get_physio_eye_data.m @@ -376,7 +376,7 @@ signal = {'blink','saccade','fixation'}; singal_chan = {'blink_c','saccade_c','fixation_c'}; - + for s = 1:numel(signal) % Index of the onsets of the signal @@ -385,15 +385,33 @@ % get onset start to onset end(onset+duration) starts = onsets(idx_signal); ends = onsets(idx_signal) + duration(idx_signal); - - all_indices = []; - for i = 1:length(starts); all_indices = [all_indices, starts(i):ends(i)]; end - - idx_signal = unique(all_indices); % removes overlaps - data_signal = zeros(idx_signal(end),1); - - for i = 1:length(idx_signal); data_signal(idx_signal(i),1) = 1; end % Map values to these indices (set them to 1) - if ~(sum(data_signal) == length(idx_signal)); warning('Not same length.'); return; end % sanitiy check + + % safer + ranges = cell(numel(starts),1); + + for k = 1:numel(starts) + if starts(k) <= ends(k) + ranges{k} = (starts(k):ends(k)).'; + else + ranges{k} = []; + end + end + + all_indices = vertcat(ranges{:}); + + % remove overlaps + invalid index + idx_signal = unique(all_indices); + idx_signal = idx_signal(idx_signal > 0); % drop 0 + + % create signal + data_signal = zeros(max(idx_signal),1); + data_signal(idx_signal) = 1; + + % sanitiy check + if ~(sum(data_signal) == length(idx_signal)) + warning('Not same length.') + return + end % assign pupil data data{s,1}.data = data_signal; From 232cc1180caaf27a12e1618e5d7d45712dc4fcc4 Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Wed, 24 Dec 2025 13:54:05 +0100 Subject: [PATCH 06/20] also default to save_path if nargin<2 --- src/pspm_import_bids.m | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/pspm_import_bids.m b/src/pspm_import_bids.m index 941555c88..13dad277b 100644 --- a/src/pspm_import_bids.m +++ b/src/pspm_import_bids.m @@ -115,7 +115,7 @@ end % checks if what needs to be imported -dataset_mode = ~(startsWith(currentFolder, 'sub-') || startsWith(currentFolder, 'ses-')); +dataset_mode = exist(fullfile(dataset_path, 'dataset_description.json'), 'file') == 2; ses_mode = startsWith(currentFolder, 'ses-'); sub_mode = startsWith(currentFolder, 'sub-'); @@ -137,20 +137,20 @@ % dataset_path now becomes the real dataset path dataset_path = fileparts(sub_path); - - % imports dataset description - dataset_description = read_dataset_description(dataset_path); [~, subject_list(1).name] = fileparts(sub_path); % only one subject end +% imports dataset description +dataset_description = read_dataset_description(dataset_path); + % checks if there are subject if isempty(subject_list) error('ID:nonexistent_folder','No subject folders found.'); end % output folder (save_path) -if ~(isstring(save_path) || ischar(save_path)) +if nargin<2 || ~(isstring(save_path) || ischar(save_path)) % save_path = [dataset_path, filesep, 'out']; save_path = fullfile(dataset_path, "out"); disp(save_path); @@ -227,14 +227,29 @@ end %% Processing start % read in data - physio_path = fullfile(ses_path, 'physio'); - [~, physio_data, physio_infos] = get_physio_data(subject_full_id, session_id, task_name, physio_path); - [~, physio_eye_data, physio_eye_infos] = get_physio_eye_data(subject_full_id, session_id, task_name, physio_path); + physio_path = fullfile(ses_path, 'physio'); + [~, physio_data, physio_infos] = get_physio_data( ... + subject_full_id, ... + session_id, ... + task_name, ... + physio_path ... + ); + + [~, physio_eye_data, physio_eye_infos] = get_physio_eye_data( ... + subject_full_id, ... + session_id, ... + task_name, ... + physio_path ... + ); %% Get beh data --- % *events file can be in 'beh' or 'physio' folder | prioritize % 'beh' - [events_json_filepath, events_tsv_filepath, beh_json_filepath, ~, ~] = bids_find_events(ses_path, beh_base, task_name); + [events_json_filepath, events_tsv_filepath, beh_json_filepath, ~, ~] = bids_find_events( ... + ses_path, ... + beh_base, ... + task_name ... + ); if isfile(events_json_filepath) && isfile(events_tsv_filepath) marker_chan{1} = get_marker_data(events_json_filepath, events_tsv_filepath, true); From 42c68358b607692e0786dc902c2d1bb234b10427 Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Wed, 24 Dec 2025 13:55:09 +0100 Subject: [PATCH 07/20] fix --- src/ext/pupil-size/code/helperFunctions/rawDataFilter.m | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ext/pupil-size/code/helperFunctions/rawDataFilter.m b/src/ext/pupil-size/code/helperFunctions/rawDataFilter.m index 6fd75408e..6dc39f7d5 100644 --- a/src/ext/pupil-size/code/helperFunctions/rawDataFilter.m +++ b/src/ext/pupil-size/code/helperFunctions/rawDataFilter.m @@ -389,10 +389,10 @@ %-------------------------------------------------------------------------- % Calc the median: -med_d = nanmedian(d); +med_d = median(d, 'omitnan'); -% Calc the mad: -mad = nanmedian(abs(d - med_d)); +% Calc the MAD: +mad = median(abs(d - med_d), 'omitnan'); % Calc the threshold: thresh = med_d + (n * mad); From fa4a674852ce6e8e0b55f0a004a439785f75ad8e Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Mon, 16 Feb 2026 11:03:23 +0100 Subject: [PATCH 08/20] other minor fixes to get rid of warnings + screen dimensions --- src/helper/pspm_peakscore.m | 5 +- src/pspm_data_editor.m | 2 + src/pspm_find_valid_fixations.m | 409 +++++++++++++++++--------------- 3 files changed, 217 insertions(+), 199 deletions(-) diff --git a/src/helper/pspm_peakscore.m b/src/helper/pspm_peakscore.m index b5ed1f1b0..1c4f8662d 100644 --- a/src/helper/pspm_peakscore.m +++ b/src/helper/pspm_peakscore.m @@ -1,4 +1,4 @@ - function pspm_peakscore(datafile, regfile, modelfile, timeunits, normalize, chan, options) +function [sts, glm] = pspm_peakscore(datafile, regfile, modelfile, timeunits, normalize, chan, options) % pspm_peakscore calculates event-related responses by scoring the peak % response against a pre-stimulus baseline. The input is similar to @@ -373,9 +373,10 @@ function pspm_peakscore(datafile, regfile, modelfile, timeunits, normalize, chan %------------------------------------------------------------------------- fprintf(' done. \n'); +sts = 1; % cleanup -clear glm scr +clear scr return %------------------------------------------------------------------------- diff --git a/src/pspm_data_editor.m b/src/pspm_data_editor.m index db7eb7d68..57500e755 100644 --- a/src/pspm_data_editor.m +++ b/src/pspm_data_editor.m @@ -370,6 +370,8 @@ function RemovePlot(hObject, chan_id) % handles structure with handles and user data (see GUIDATA) % UIWAIT makes pspm_data_editor wait for user response (see UIRESUME) % handles.lbEpochsvarargout{1} = handles.output; +varargout{1} = []; +varargout{2} = []; delete(hObject); function lbEpochs_Callback(hObject, ~, ~) diff --git a/src/pspm_find_valid_fixations.m b/src/pspm_find_valid_fixations.m index 5bab91f91..42b89355a 100644 --- a/src/pspm_find_valid_fixations.m +++ b/src/pspm_find_valid_fixations.m @@ -31,7 +31,7 @@ % ● Arguments % * fn : The actual data file containing the eyelink recording with gaze % data converted to cm. -% * bitmap : A nxm matrix of the same size as the display, with 1 +% * bitmap : A nxm matrix of the same size as the display, with 1 % for valid and 0 for invalid gaze points. IMPORTANT: the bitmap has to % be defined in terms of the eyetracker coordinate system, i.e. % bitmap(1,1) must correpond to the origin of the eyetracker @@ -53,6 +53,10 @@ % │ pixels (e.g. (1280 1024)) or the width and height of the screen % │ in cm (e.g. (50 30)). Default is (1 1). Only taken into account % │ if there is no bitmap. +% ├────.screen_dim : Only considered if .plot_gaze_coords is passed; used +% │ plot the gaze data and circle on the actual screen +% │ dimensions rather than using auto scaling. Input +% │ should follow the format: [x_dim, y_dim]. % ├.plot_gaze_coords: Define whether to plot the gaze coordinates for visual % │ inspection of the validation process. Default is false. % ├.channel_action: Define whether to add or replace the data. Default is @@ -102,72 +106,72 @@ %% validate input if numel(varargin) < 1 - warning('ID:invalid_input', ['Not enough input arguments.', ... - ' You have to either pass a bitmap or circle_degree, distance and unit',... - ' to compute the valid fixations']); return; + warning('ID:invalid_input', ['Not enough input arguments.', ... + ' You have to either pass a bitmap or circle_degree, distance and unit',... + ' to compute the valid fixations']); return; end if numel(varargin{1}) > 1 - mode = 'bitmap'; - bitmap = varargin{1}; - if ~ismatrix(bitmap) || (~isnumeric(bitmap) && ~islogical(bitmap)) - warning('ID:invalid_input', ['The bitmap must be a matrix and must',... - ' contain numeric or logical values.']); return; - end - if numel(varargin) < 2 - options = struct(); - options.mode = 'bitmap'; - else - options = varargin{2}; - options.mode = 'bitmap'; - end + mode = 'bitmap'; + bitmap = varargin{1}; + if ~ismatrix(bitmap) || (~isnumeric(bitmap) && ~islogical(bitmap)) + warning('ID:invalid_input', ['The bitmap must be a matrix and must',... + ' contain numeric or logical values.']); return; + end + if numel(varargin) < 2 + options = struct(); + options.mode = 'bitmap'; + else + options = varargin{2}; + options.mode = 'bitmap'; + end else - mode = 'fixation'; - if numel(varargin) < 3 - warning('ID:invalid_input', ['Not enough input arguments.', ... - ' You have to set circle_degree, distance and unit',... - ' to compute the valid fixations']); return; - end - circle_degree = varargin{1}; - distance = varargin{2}; - unit = varargin{3}; - if numel(varargin) < 4 - options = struct(); - options.mode = 'fixation'; - else - options = varargin{4}; - if ~isstruct(options) - warning('ID:invalid_input', 'Options must be a struct.'); - return; + mode = 'fixation'; + if numel(varargin) < 3 + warning('ID:invalid_input', ['Not enough input arguments.', ... + ' You have to set circle_degree, distance and unit',... + ' to compute the valid fixations']); return; + end + circle_degree = varargin{1}; + distance = varargin{2}; + unit = varargin{3}; + if numel(varargin) < 4 + options = struct(); + options.mode = 'fixation'; else - options.mode = 'fixation'; + options = varargin{4}; + if ~isstruct(options) + warning('ID:invalid_input', 'Options must be a struct.'); + return; + else + options.mode = 'fixation'; + end + end + if ~isnumeric(circle_degree) + warning('ID:invalid_input', 'Circle_degree is not numeric.'); + return; + elseif ~isnumeric(distance) + warning('ID:invalid_input', 'Distance is not set or not numeric.'); + return; + elseif ~ischar(unit) + warning('ID:invalid_input', 'Unit should be a char.'); + return; end - end - if ~isnumeric(circle_degree) - warning('ID:invalid_input', 'Circle_degree is not numeric.'); - return; - elseif ~isnumeric(distance) - warning('ID:invalid_input', 'Distance is not set or not numeric.'); - return; - elseif ~ischar(unit) - warning('ID:invalid_input', 'Unit should be a char.'); - return; - end end % check & change distance to 'mm' if strcmpi(mode,'fixation') - if ~strcmpi(unit,'mm') - [nsts,distance] = pspm_convert_unit(distance,unit ,'mm'); - if nsts~=1 - warning('ID:invalid_input', 'Failed to convert distance to mm.'); + if ~strcmpi(unit,'mm') + [nsts,distance] = pspm_convert_unit(distance,unit ,'mm'); + if nsts~=1 + warning('ID:invalid_input', 'Failed to convert distance to mm.'); + end end - end end % check options options = pspm_options(options, 'find_valid_fixations'); if options.invalid - return + return end @@ -196,9 +200,9 @@ if sts_gaze < 1 warning('ID:invalid_input', ['Unable to perform gaze ', ... - 'validation. Cannot find gaze channels with distance ',... - 'unit values. Maybe you need to convert them with ', ... - 'pspm_convert_pixel2unit()']); + 'validation. Cannot find gaze channels with distance ',... + 'unit values. Maybe you need to convert them with ', ... + 'pspm_convert_pixel2unit()']); return; end @@ -206,45 +210,45 @@ y_unit = gaze_y.header.units; switch mode - case 'fixation' - % expand fixation point to size of data - fix_point = options.fixation_point; - if size(fix_point, 1) == 1 - fix_point = repmat(fix_point(:)', numel(gaze_x.data), 1); - elseif size(fix_point, 1) ~= numel(gaze_x) - warning('ID:invalid_input', ['Fixation point has wrong ', ... - 'dimensions - it should be 1x2 or nx2 where n is the ', ... - 'number of gaze data points.']); - return - end + case 'fixation' + % expand fixation point to size of data + fix_point = options.fixation_point; + if size(fix_point, 1) == 1 + fix_point = repmat(fix_point(:)', numel(gaze_x.data), 1); + elseif size(fix_point, 1) ~= numel(gaze_x) + warning('ID:invalid_input', ['Fixation point has wrong ', ... + 'dimensions - it should be 1x2 or nx2 where n is the ', ... + 'number of gaze data points.']); + return + end - % normalise fixation point to fraction of full screen - fix_point = fix_point ./ repmat(options.resolution(:)', ... - size(fix_point, 1), 1); + % normalise fixation point to fraction of full screen + fix_point = fix_point ./ repmat(options.resolution(:)', ... + size(fix_point, 1), 1); - % convert data to mm - if ~strcmpi(x_unit,'mm') + % convert data to mm + if ~strcmpi(x_unit,'mm') [nsts,x_data] = pspm_convert_unit(gaze_x.data, x_unit, 'mm'); [msts,x_range] = pspm_convert_unit(transpose(gaze_x.header.range), x_unit, 'mm'); - if nsts~=1 || msts~=1 + if nsts~=1 || msts~=1 warning('ID:invalid_input', 'Failed to convert data.'); return - end - else + end + else x_data = gaze_x.data; x_range = gaze_x.header.range; - end - if ~strcmpi(y_unit,'mm') - [nsts,y_data] = pspm_convert_unit(gaze_y.data, y_unit, 'mm'); - [msts,y_range] = pspm_convert_unit(transpose(gaze_y.header.range), y_unit, 'mm'); - if nsts~=1 || msts~=1 - warning('ID:invalid_input', 'Failed to convert data.'); - return - end - else - y_data = gaze_y.data; - y_range = gaze_y.header.range; - end + end + if ~strcmpi(y_unit,'mm') + [nsts,y_data] = pspm_convert_unit(gaze_y.data, y_unit, 'mm'); + [msts,y_range] = pspm_convert_unit(transpose(gaze_y.header.range), y_unit, 'mm'); + if nsts~=1 || msts~=1 + warning('ID:invalid_input', 'Failed to convert data.'); + return + end + else + y_data = gaze_y.data; + y_range = gaze_y.header.range; + end % convert normalized fixation points to data resolution fix_point_temp = zeros(size(fix_point)); @@ -261,116 +265,127 @@ % check plotting if options.plot_gaze_coords - fg = figure('Name', 'Fixation plot'); - ax = axes('NextPlot', 'add'); - set(ax, 'Parent', handle(fg)); - - % first fixation point - x_point = fix_point_temp(1,1); - y_point = fix_point_temp(1,2); - radius = tan(deg2rad(circle_degree)/2) * 2 * distance; - - % plot the circle around the first fixation point - th = 0:pi/50:2*pi; - x_unit = radius(1) * cos(th) + x_point; - y_unit = radius(1) * sin(th) + y_point; - - % plot gaze coordinates - mi=min(min(x_data),min(y_data)); - ma=max(max(x_data),max(y_data)); - - axis([mi ma mi ma]); - scatter(ax, x_data, y_data, 'k.'); - plot(x_unit, y_unit, 'r'); + fg = figure('Name', 'Fixation plot'); + ax = axes('NextPlot', 'add'); + set(ax, 'Parent', handle(fg)); + + % first fixation point + x_point = fix_point_temp(1,1); + y_point = fix_point_temp(1,2); + radius = tan(deg2rad(circle_degree)/2) * 2 * distance; + + % plot the circle around the first fixation point + th = 0:pi/50:2*pi; + x_unit = radius(1) * cos(th) + x_point; + y_unit = radius(1) * sin(th) + y_point; + + % plot gaze coordinates + mi=min(min(x_data),min(y_data)); + ma=max(max(x_data),max(y_data)); + + if isfield(options, 'screen_dim') + axs = [0 options.screen_dim(1) 0 options.screen_dim(2)]; + else + axs = [mi ma mi ma]; + end + + axis(axs); + scatter(ax, x_data, y_data, 'k.'); + plot(x_unit, y_unit, 'r'); + end + case 'bitmap' + [ylim,xlim] = size(bitmap); + map_x_range = [1,xlim]; + map_y_range = [1,ylim]; + + x_data = gaze_x.data; + y_data = gaze_y.data; + x_range = gaze_x.header.range; + y_range = gaze_y.header.range; + + N = numel(x_data); + + % change bitmap to logical + bitmap = logical(bitmap); + + % normalize recorded data to adjust to right range + % of the bitmap + x_data = (x_data - x_range(1))/diff(x_range); + y_data = (y_data - y_range(1))/diff(y_range); + + % adapt to bitmap range + x_data = map_x_range(1)+ x_data * diff(map_x_range); + y_data = map_y_range(1)+ y_data * diff(map_y_range); + + % round gaze data such that we can use them as + % indexed + x_data = round(x_data); + y_data = round(y_data); + + % set all gaze values which are out of the display + % window range to NaN + x_data(x_data > map_x_range(2) | x_data < map_x_range(1)) = NaN; + y_data(y_data > map_y_range(2) | y_data < map_y_range(1)) = NaN; + + % only take gaze coordinates which both aren't NaNs + valid_gaze_idx = find(~isnan(x_data) & ~isnan(y_data)); + valid_gaze = [x_data(valid_gaze_idx),y_data(valid_gaze_idx)]; + + val= zeros(N,1); + for k=1:numel(valid_gaze_idx) + val(valid_gaze_idx(k)) = bitmap(valid_gaze(k,2),valid_gaze(k,1)); + end + val = logical(val); + excl = ~val; + + if options.plot_gaze_coords + fg = figure; + ax = axes('NextPlot', 'add'); + set(ax, 'Parent', handle(fg)); + + mi=min(min(x_data),min(y_data)); + ma=max(max(x_data),max(y_data)); + if isfield(options, 'screen_dim') + axs = [0 options.screen_dim(1) 0 options.screen_dim(2)]; + else + axs = [mi ma mi ma]; + end + + axis(axs); + imshow(bitmap); + hold on; + scatter( x_data, y_data); + end - case 'bitmap' - [ylim,xlim] = size(bitmap); - map_x_range = [1,xlim]; - map_y_range = [1,ylim]; - - x_data = gaze_x.data; - y_data = gaze_y.data; - x_range = gaze_x.header.range; - y_range = gaze_y.header.range; - - N = numel(x_data); - - % change bitmap to logical - bitmap = logical(bitmap); - - % normalize recorded data to adjust to right range - % of the bitmap - x_data = (x_data - x_range(1))/diff(x_range); - y_data = (y_data - y_range(1))/diff(y_range); - - % adapt to bitmap range - x_data = map_x_range(1)+ x_data * diff(map_x_range); - y_data = map_y_range(1)+ y_data * diff(map_y_range); - - % round gaze data such that we can use them as - % indexed - x_data = round(x_data); - y_data = round(y_data); - - % set all gaze values which are out of the display - % window range to NaN - x_data(x_data > map_x_range(2) | x_data < map_x_range(1)) = NaN; - y_data(y_data > map_y_range(2) | y_data < map_y_range(1)) = NaN; - - % only take gaze coordinates which both aren't NaNs - valid_gaze_idx = find(~isnan(x_data) & ~isnan(y_data)); - valid_gaze = [x_data(valid_gaze_idx),y_data(valid_gaze_idx)]; - - val= zeros(N,1); - for k=1:numel(valid_gaze_idx) - val(valid_gaze_idx(k)) = bitmap(valid_gaze(k,2),valid_gaze(k,1)); - end - val = logical(val); - excl = ~val; - - if options.plot_gaze_coords - fg = figure; - ax = axes('NextPlot', 'add'); - set(ax, 'Parent', handle(fg)); - - - mi=min(min(x_data),min(y_data)); - ma=max(max(x_data),max(y_data)); - axis([mi ma mi ma]); - imshow(bitmap); - hold on; - scatter( x_data, y_data); - - end - end - - % set excluded periods in data to NaN - data.data(excl == 1) = NaN; - if all(isnan(data.data)) - warning('ID:invalid_input', ['All values of channel ''%s'' ', ... - 'completely set to NaN. Please reconsider your parameters.'], ... - data.header.chantype); - end - - % add to alldata and update infos - if ~strcmpi(options.channel_action, 'replace') - pos_of_channel = numel(alldata.data) + 1; - end - - alldata.data{pos_of_channel} = data; - n_inv = sum(isnan(data.data)); - n_data = numel(data.data); - alldata.infos.source.chan_stats{pos_of_channel}.nan_ratio = n_inv/n_data; - - % add invalid fixations if requested - if options.add_invalid - - [sts, ~, new_chantype] = pspm_find_eye(data.header.chantype); - excl_hdr = struct('chantype', [new_chantype, '_missing_', eye],... - 'units', '', 'sr', data.header.sr); - excl_data = struct('data', double(excl), 'header', excl_hdr); - alldata.data{end+1} = excl_data; - end + end + + % set excluded periods in data to NaN + data.data(excl == 1) = NaN; + if all(isnan(data.data)) + warning('ID:invalid_input', ['All values of channel ''%s'' ', ... + 'completely set to NaN. Please reconsider your parameters.'], ... + data.header.chantype); + end + + % add to alldata and update infos + if ~strcmpi(options.channel_action, 'replace') + pos_of_channel = numel(alldata.data) + 1; + end + + alldata.data{pos_of_channel} = data; + n_inv = sum(isnan(data.data)); + n_data = numel(data.data); + alldata.infos.source.chan_stats{pos_of_channel}.nan_ratio = n_inv/n_data; + + % add invalid fixations if requested + if options.add_invalid + + [sts, ~, new_chantype] = pspm_find_eye(data.header.chantype); + excl_hdr = struct('chantype', [new_chantype, '_missing_', eye],... + 'units', '', 'sr', data.header.sr); + excl_data = struct('data', double(excl), 'header', excl_hdr); + alldata.data{end+1} = excl_data; + end elseif strcmpi(options.channel, 'both') % call this function recursively channels = {'pupil_r', 'pupil_l'}; @@ -398,8 +413,8 @@ % write to file or return data if ischar(fn) - alldata.options = struct('overwrite', 1); - [sts, ~, ~, ~] = pspm_load_data(fn, alldata); + alldata.options = struct('overwrite', 1); + [sts, ~, ~, ~] = pspm_load_data(fn, alldata); elseif isstruct(fn) sts = 1; fn = alldata; From 92a314dacbef244421b5f5d7f33fcc120ca899b2 Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Wed, 25 Feb 2026 16:39:41 +0100 Subject: [PATCH 09/20] follow BEP020 --- src/bids_importer/lib/FindFiles.m | 188 +++++ .../lib/build_pspm_eye_channels.m | 128 ++++ .../lib/fill_eye_channel_header.m | 98 +++ src/bids_importer/lib/finalize_eye_source.m | 142 ++++ src/bids_importer/lib/find_bids_file.m | 71 ++ src/bids_importer/lib/find_eye_files.m | 80 ++ src/bids_importer/lib/find_physio_file.m | 29 + .../lib/find_physioevents_pair.m | 38 + src/bids_importer/lib/get_eyes_list.m | 25 +- src/bids_importer/lib/get_eyetrack_data.m | 64 +- .../lib/get_file_from_substring.m | 155 ++++ .../lib/get_first_existing_file.m | 33 + src/bids_importer/lib/get_gaze_units.m | 15 + src/bids_importer/lib/get_physio_data.m | 39 +- src/bids_importer/lib/get_physio_eye_data.m | 694 +++--------------- src/bids_importer/lib/normalize_eye_entries.m | 43 ++ src/bids_importer/lib/read_data_from_tsv.m | 52 +- src/helper/pspm_peakscore.m | 2 +- src/pspm_find_valid_fixations.m | 2 +- src/pspm_import_bids.m | 470 ++++++------ 20 files changed, 1445 insertions(+), 923 deletions(-) create mode 100644 src/bids_importer/lib/FindFiles.m create mode 100644 src/bids_importer/lib/build_pspm_eye_channels.m create mode 100644 src/bids_importer/lib/fill_eye_channel_header.m create mode 100644 src/bids_importer/lib/finalize_eye_source.m create mode 100644 src/bids_importer/lib/find_bids_file.m create mode 100644 src/bids_importer/lib/find_eye_files.m create mode 100644 src/bids_importer/lib/find_physio_file.m create mode 100644 src/bids_importer/lib/find_physioevents_pair.m create mode 100644 src/bids_importer/lib/get_file_from_substring.m create mode 100644 src/bids_importer/lib/get_first_existing_file.m create mode 100644 src/bids_importer/lib/get_gaze_units.m create mode 100644 src/bids_importer/lib/normalize_eye_entries.m diff --git a/src/bids_importer/lib/FindFiles.m b/src/bids_importer/lib/FindFiles.m new file mode 100644 index 000000000..9f28197ad --- /dev/null +++ b/src/bids_importer/lib/FindFiles.m @@ -0,0 +1,188 @@ +classdef FindFiles + %FINDFILES Search for files in a directory based on extension, filters, and depth. + % + % Usage: + % ff = FindFiles("/data", ".nii"); + % disp(ff.files) + % + % % With max depth + filters + exclude + % ff = FindFiles("/data", ".tsv.gz", "exclude", "._", "filters", {"task-rest","physio"}, "maxdepth", 2); + + properties + directory (1,1) string + extension (1,1) string + exclude % [] | string | cellstr + maxdepth % [] | integer + filters % [] | string | cellstr + files % char | cellstr (mirrors python behavior) + end + + methods + function obj = FindFiles(directory, extension, varargin) + % FindFiles(directory, extension, 'exclude', ..., 'maxdepth', ..., 'filters', ...) + % + % directory: folder to search + % extension: like ".nii" or ".csv" or ".tsv.gz" + + p = inputParser; + p.addRequired("directory", @(x) ischar(x) || isstring(x)); + p.addRequired("extension", @(x) ischar(x) || isstring(x)); + p.addParameter("exclude", [], @(x) isempty(x) || ischar(x) || isstring(x) || iscell(x)); + p.addParameter("maxdepth", [], @(x) isempty(x) || (isscalar(x) && isnumeric(x) && x>=0)); + p.addParameter("filters", [], @(x) isempty(x) || ischar(x) || isstring(x) || iscell(x)); + p.parse(directory, extension, varargin{:}); + + obj.directory = string(p.Results.directory); + obj.extension = string(p.Results.extension); + obj.exclude = p.Results.exclude; + obj.maxdepth = p.Results.maxdepth; + obj.filters = p.Results.filters; + + % --- collect files recursively --- + pattern = "*" + obj.extension; % e.g. "*.nii" or "*.tsv.gz" + file_list = FindFiles.find_files(obj.directory, pattern, obj.maxdepth); + + % remove macOS resource fork files "._*" + keep = true(1, numel(file_list)); + for i = 1:numel(file_list) + [~, nm, ext] = fileparts(file_list{i}); + base = [nm ext]; + if startsWith(base, "._") + keep(i) = false; + end + end + file_list = file_list(keep); + + % sort + file_list = sort(file_list); + + % --- apply filters/exclude like python (using get_file_from_substring) --- + if ~isempty(obj.exclude) || ~isempty(obj.filters) + filt = obj.filters; + if isempty(filt) + filt = {}; % no filters means "everything", but python only filters when provided. + end + + % If filters are empty but exclude exists, we still want exclusion applied. + % We'll emulate that by filtering with an empty filt (= match all), + % then applying exclusion. + if isempty(filt) + % match all files + if isempty(obj.exclude) + out = file_list; + else + out = apply_exclude_only(file_list, obj.exclude); + end + else + out = get_file_from_substring(filt, file_list, 'error', obj.exclude); + end + + obj.files = out; + else + obj.files = file_list; + end + end + end + + methods (Static) + function files = find_files(directory, pattern, maxdepth) + %FINDFILES Recursively find files matching pattern with optional max depth. + % + % directory: string/char + % pattern: e.g. "*.nii", "*.csv", "*.tsv.gz" + % maxdepth: [] for unlimited, or non-negative integer + % + % Returns cell array of full paths. + + directory = char(directory); + pattern = char(pattern); + + if nargin < 3 + maxdepth = []; + end + + if ~isfolder(directory) + error('FindFiles:NotAFolder', 'Directory does not exist: %s', directory); + end + + files = {}; + rootDepth = count_seps(directory); + + % BFS stack of directories + stack = {directory}; + + while ~isempty(stack) + current = stack{1}; + stack(1) = []; + + curDepth = count_seps(current) - rootDepth; + if ~isempty(maxdepth) && curDepth > maxdepth + continue; + end + + listing = dir(current); + + for i = 1:numel(listing) + item = listing(i); + + if item.isdir + nm = item.name; + if strcmp(nm, '.') || strcmp(nm, '..') + continue; + end + stack{end+1} = fullfile(current, nm); %#ok + else + if FindFiles.match_pattern(item.name, pattern) + files{end+1} = fullfile(current, item.name); %#ok + end + end + end + end + end + + function tf = match_pattern(filename, pattern) + % Simple glob match supporting '*' wildcard. + % pattern like "*.nii" or "*.tsv.gz" + % + % Convert glob -> regex + expr = regexptranslate('wildcard', pattern); + tf = ~isempty(regexp(filename, ['^' expr '$'], 'once')); + end + end +end + +% ---- local helpers (same file is okay in modern MATLAB; otherwise split) ---- + +function n = count_seps(p) + % count path separators to approximate depth + p = char(p); + n = sum(p == filesep); +end + +function out = apply_exclude_only(file_list, exclude) + % Apply exclude to a cell array of paths; return char if single like python helper. + + if ischar(exclude) || isstring(exclude) + exclude = {char(exclude)}; + elseif iscell(exclude) + % ok + else + exclude = {}; + end + + keep = true(1, numel(file_list)); + for i = 1:numel(file_list) + f = file_list{i}; + for ex = 1:numel(exclude) + if contains(f, exclude{ex}) + keep(i) = false; + break; + end + end + end + out = file_list(keep); + + if numel(out) == 1 + out = out{1}; + end +end diff --git a/src/bids_importer/lib/build_pspm_eye_channels.m b/src/bids_importer/lib/build_pspm_eye_channels.m new file mode 100644 index 000000000..54f0769c9 --- /dev/null +++ b/src/bids_importer/lib/build_pspm_eye_channels.m @@ -0,0 +1,128 @@ +function data = build_pspm_eye_channels(eye_data_cell) +%BUILD_PSPM_EYE_CHANNELS Build PsPM eye-tracking channels from imported BIDS-like entries. +% +% data = BUILD_PSPM_EYE_CHANNELS(eye_data_cell) +% +% Converts eye-tracking data/metadata entries (typically parsed from BIDS +% physio JSON/TSV pairs) into a PsPM-style channel cell array containing +% pupil size and gaze coordinate signals for right and/or left eye. +% +% The function: +% - Normalizes input entries to a consistent right/left representation +% (via NORMALIZE_EYE_ENTRIES). +% - Creates up to 6 channels total (3 signals × 2 eyes) in a stable +% order: right eye first, then left eye. +% - For each created channel: +% * Copies the data vector from the corresponding table column. +% * Sets hdr.chantype to '_' (e.g., 'pupil_r'). +% * Populates additional header fields using FILL_EYE_CHANNEL_HEADER +% (e.g., sampling rate, units, description, gaze range). +% +% Inputs +% ------ +% eye_data_cell : cell array +% Cell array of eye entries. Each entry is expected (typically) to +% contain: +% - RecordedEye : 'right'/'left' recommended (used by the +% normalization step) +% - Columns : table containing some/all of: +% 'pupil_size', 'x_coordinate', 'y_coordinate' +% - SamplingFrequency : numeric (optional) +% - pupil_size.Description: string/char (optional) +% - pupil_size.Units : string/char (optional) +% - SampleCoordinateUnits : string/char (optional) OR +% x_coordinate.Units / y_coordinate.Units (optional) +% - GazeRange.xmin/xmax/ymin/ymax (optional) +% +% Output +% ------ +% data : cell array +% Cell array of PsPM channels. Each channel is a struct with fields: +% - data{i}.data : numeric column vector (samples x 1) +% - data{i}.header : struct with at least: +% * chantype (e.g., 'gaze_x_r') +% and potentially: +% * sr, units, Description, range +% +% Channel mapping +% --------------- +% The following columns (if present) are mapped to channels: +% - 'pupil_size' -> chantype 'pupil_' +% - 'x_coordinate' -> chantype 'gaze_x_' +% - 'y_coordinate' -> chantype 'gaze_y_' +% +% Warnings / edge cases +% --------------------- +% - If eye_data_cell is empty: warns and returns {}. +% - If no valid right/left entries are found: warns and returns {}. +% - If only one eye is present: warns once per encountered side. +% - If an entry lacks a valid Columns table: warns and skips that eye. +% - If a required column is missing: warns and skips that channel. +% +% See also +% -------- +% NORMALIZE_EYE_ENTRIES, FILL_EYE_CHANNEL_HEADER + +data = {}; % output cell array of PsPM channels + +if isempty(eye_data_cell) + warning('No eye data available.'); + return +end + +% normalize to "eyes.r" and "eyes.l" (robust to ordering) +eyes = normalize_eye_entries(eye_data_cell); + +if isempty(eyes.r) && isempty(eyes.l) + warning('No valid right/left eye entries found.'); + return +end + +% define channel mapping once +sig = struct( ... + 'col', {'pupil_size', 'x_coordinate', 'y_coordinate'}, ... + 'name', {'pupil', 'gaze_x', 'gaze_y'} ... +); + +% create channels in consistent order: right then left +order = {'r','l'}; +ch = 0; + +for s = 1:numel(order) + side = order{s}; + m = eyes.(side); + if isempty(m), continue; end + + % warn if only one eye present + if xor(isempty(eyes.r), isempty(eyes.l)) + if side == 'r' + warning('Only right eye data available.'); + else + warning('Only left eye data available.'); + end + end + + T = m.Columns; + if ~istable(T) + warning('Eye "%s" has no valid Columns table; skipping.', side); + continue + end + + for k = 1:numel(sig) + if ~ismember(sig(k).col, T.Properties.VariableNames) + warning('Missing column "%s" for eye "%s"; skipping channel.', sig(k).col, side); + continue + end + + ch = ch + 1; + + % base channel + data{ch}.data = T{:, sig(k).col}; + data{ch}.header.chantype = sprintf('%s_%s', sig(k).name, side); + + % populate header fields from metadata + data{ch}.header = fill_eye_channel_header(data{ch}.header, m, sig(k).name); + end +end + +end diff --git a/src/bids_importer/lib/fill_eye_channel_header.m b/src/bids_importer/lib/fill_eye_channel_header.m new file mode 100644 index 000000000..394b31f16 --- /dev/null +++ b/src/bids_importer/lib/fill_eye_channel_header.m @@ -0,0 +1,98 @@ +function hdr = fill_eye_channel_header(hdr, m, kind) +%FILL_EYE_CHANNEL_HEADER Populate an eye-tracking channel header from BIDS metadata. +% +% hdr = FILL_EYE_CHANNEL_HEADER(hdr, m, kind) +% +% Updates fields in the channel header struct `hdr` based on the provided +% metadata struct `m` and the requested channel `kind`. +% +% This helper is intended for eye-tracking channels such as pupil size and +% gaze coordinates. It copies common metadata (e.g., sampling frequency) +% and then fills channel-specific fields (units, description, range). +% +% Inputs +% ------ +% hdr : struct +% Channel header to be updated. The function may set/overwrite: +% - hdr.sr : sampling rate (Hz) +% - hdr.Description : channel description (text) +% - hdr.units : physical units (string) +% - hdr.range : valid data range [min max] +% The function may also read: +% - hdr.chantype : used to resolve gaze coordinate units +% +% m : struct +% Metadata structure, typically parsed from a BIDS sidecar JSON (or +% equivalent). The function uses the following optional fields: +% - m.SamplingFrequency (numeric) +% - m.pupil_size.Description (char/string) +% - m.pupil_size.Units (char/string) +% - m.GazeRange.xmin, m.GazeRange.xmax (numeric) +% - m.GazeRange.ymin, m.GazeRange.ymax (numeric) +% +% kind : char | string +% Channel type selector. Supported values: +% - 'pupil' : pupil size channel +% - 'gaze_x' : horizontal gaze coordinate channel +% - 'gaze_y' : vertical gaze coordinate channel +% +% Behavior +% -------- +% 1) If m.SamplingFrequency exists and is non-empty, sets hdr.sr. +% 2) For 'pupil': +% - If m.pupil_size.Description exists, sets hdr.Description. +% - If m.pupil_size.Units exists, sets hdr.units. +% 3) For 'gaze_x' and 'gaze_y': +% - Sets hdr.units using get_gaze_units(...), based on metadata and +% hdr.chantype. +% - If m.GazeRange contains the corresponding min/max fields, sets +% hdr.range to [min max]. +% +% Outputs +% ------- +% hdr : struct +% Updated channel header struct. +% +% See also +% -------- +% GET_GAZE_UNITS +% +% Notes +% ----- +% - Missing metadata fields are silently ignored (no error is thrown). +% - For gaze channels, hdr.chantype should be set before calling this +% function, as it is passed into GET_GAZE_UNITS to resolve units. + +% kind is 'pupil' or 'gaze_x' or 'gaze_y' + +% sampling rate for everything if available +if isfield(m, 'SamplingFrequency') && ~isempty(m.SamplingFrequency) + hdr.sr = m.SamplingFrequency; +end + +switch kind + case 'pupil' + % description + units from pupil_size metadata if present + if isfield(m, 'pupil_size') + if isfield(m.pupil_size, 'Description') && ~isempty(m.pupil_size.Description) + hdr.Description = m.pupil_size.Description; + end + if isfield(m.pupil_size, 'Units') && ~isempty(m.pupil_size.Units) + hdr.units = m.pupil_size.Units; + end + end + + case 'gaze_x' + hdr.units = get_gaze_units(m, 'x_coordinate', hdr.chantype); + if isfield(m, 'GazeRange') && isfield(m.GazeRange, 'xmin') && isfield(m.GazeRange, 'xmax') + hdr.range = [m.GazeRange.xmin, m.GazeRange.xmax]; + end + + case 'gaze_y' + hdr.units = get_gaze_units(m, 'y_coordinate', hdr.chantype); + if isfield(m, 'GazeRange') && isfield(m.GazeRange, 'ymin') && isfield(m.GazeRange, 'ymax') + hdr.range = [m.GazeRange.ymin, m.GazeRange.ymax]; + end +end + +end diff --git a/src/bids_importer/lib/finalize_eye_source.m b/src/bids_importer/lib/finalize_eye_source.m new file mode 100644 index 000000000..bd140eee5 --- /dev/null +++ b/src/bids_importer/lib/finalize_eye_source.m @@ -0,0 +1,142 @@ +function [infos, data] = finalize_eye_source(infos, data, eye_data_cell) +%FINALIZE_EYE_SOURCE Finalize imported eye-tracking source metadata and headers. +% +% [infos, data] = FINALIZE_EYE_SOURCE(infos, data, eye_data_cell) +% +% Adds eye-related information under `infos.source.*` and performs basic +% consistency checks on imported eye-tracking channels. In particular, it: +% +% - Computes per-channel NaN ratios and stores them in +% infos.source.chan_stats. +% - Normalizes right/left eye metadata entries (via NORMALIZE_EYE_ENTRIES). +% - Checks gaze range consistency across eyes and stores a preferred +% gaze coordinate range in infos.source.gaze_coords. +% - Stores pupil processing method (if provided by metadata) in +% infos.source.elcl_proc. +% - Derives which eyes were observed (left/right/both) and stores it in +% infos.source.eyesObserved. +% - Selects a "best eye" using NaN ratio (via EYE_WITH_SMALLER_NAN_RATIO) +% and stores it in infos.source.best_eye. +% - Ensures every channel header contains StartTime; if missing, sets it +% to 0 for all channels. If StartTime exists, checks consistency across +% channels and warns if they differ. +% +% Inputs +% ------ +% infos : struct +% PsPM infos structure. This function initializes/overwrites +% `infos.source` with a struct containing eye-related fields. +% +% data : cell array +% Cell array of imported channel structs. Each element is expected to +% have at least: +% - data{i}.data : numeric vector/matrix (samples x 1) +% - data{i}.header : struct +% - data{i}.header.StartTime (optional) +% +% eye_data_cell : cell array +% Collection of eye metadata entries from earlier import stages. This +% is passed to NORMALIZE_EYE_ENTRIES, which is expected to return a +% struct with fields: +% - eyes.r : metadata struct for right eye (or []) +% - eyes.l : metadata struct for left eye (or []) +% +% Outputs +% ------- +% infos : struct +% Updated infos structure with `infos.source` populated. Common fields: +% - infos.source.chan : reserved for channel labels (currently {}) +% - infos.source.chan_stats : {N x 1} cell array of structs, each with: +% .nan_ratio = (#NaN samples) / (#samples) +% - infos.source.gaze_coords : gaze range struct (if available) +% - infos.source.elcl_proc : pupil fit / processing method (lowercase) +% - infos.source.eyesObserved : 'l', 'r', or 'lr' +% - infos.source.best_eye : best eye label (as returned by helper) +% - infos.source.type : fixed string, 'BIDS (json/tsv)' +% +% data : cell array +% Same cell array as input, potentially modified to include +% data{i}.header.StartTime when missing. +% +% Warnings +% -------- +% - If `data` is empty, emits a warning and returns early. +% - If both eyes provide GazeRange and they differ, warns. +% - If StartTime exists but differs across channels, warns. +% +% See also +% -------- +% NORMALIZE_EYE_ENTRIES, EYE_WITH_SMALLER_NAN_RATIO + +% Adds infos.source.* for eye channels and ensures StartTime exists. +if isempty(data) + warning('No data for physio eye data was imported.'); + return +end + +% --- chan_stats (nan_ratio) --- +infos.source = struct(); +infos.source.chan = {}; +infos.source.chan_stats = cell(numel(data),1); + +for i = 1:numel(data) + x = data{i}.data; + n_data = size(x,1); + n_inv = sum(isnan(x)); + infos.source.chan_stats{i,1} = struct('nan_ratio', n_inv / max(n_data,1)); +end + +% --- normalize right/left metadata --- +eyes = normalize_eye_entries(eye_data_cell); % from earlier (returns .r .l) + +% --- gaze range consistency + store --- +if ~isempty(eyes.r) && ~isempty(eyes.l) && isfield(eyes.r,'GazeRange') && isfield(eyes.l,'GazeRange') + if ~isequal(eyes.r.GazeRange, eyes.l.GazeRange) + warning("GazeRange is not equal"); + end +end + +% prefer right if present, else left +m = eyes.r; if isempty(m), m = eyes.l; end +if ~isempty(m) && isfield(m,'GazeRange') + infos.source.gaze_coords = m.GazeRange; +end + +% --- pupil processing method --- +if ~isempty(m) + if isfield(m,'PupilFitMethod') && ~isempty(m.PupilFitMethod) + infos.source.elcl_proc = lower(string(m.PupilFitMethod)); + elseif isfield(m,'ElclProc') && ~isempty(m.ElclProc) + infos.source.elcl_proc = lower(string(m.ElclProc)); + end +end + +% --- eyesObserved --- +% derive from available eyes (.r/.l), not from num_eyes +hasR = ~isempty(eyes.r); +hasL = ~isempty(eyes.l); +if hasR && hasL + infos.source.eyesObserved = 'lr'; +elseif hasR + infos.source.eyesObserved = 'r'; +elseif hasL + infos.source.eyesObserved = 'l'; +end + +% --- best eye by nan ratio (your function) --- +infos.source.best_eye = eye_with_smaller_nan_ratio(data, infos.source.eyesObserved); +infos.source.type = 'BIDS (json/tsv)'; + +% --- StartTime consistency: ensure StartTime exists on all channels --- +if isfield(data{1}.header,'StartTime') + st = cellfun(@(x) x.header.StartTime, data, 'UniformOutput', false); + if ~isequal(st{:}) + warning('Not all data have the same StartTime. Please check the input data.'); + end +else + for i = 1:numel(data) + data{i}.header.StartTime = 0; + end +end + +end diff --git a/src/bids_importer/lib/find_bids_file.m b/src/bids_importer/lib/find_bids_file.m new file mode 100644 index 000000000..0b8aa60d0 --- /dev/null +++ b/src/bids_importer/lib/find_bids_file.m @@ -0,0 +1,71 @@ +function [data_file, json_file] = find_bids_file( ... + ses_path, suffix, task_id, run_id, filters) +% FIND_BIDS_FILE Locate a BIDS file using flexible entity filters. +% +% [data_file, json_file] = find_bids_file( ... +% ses_path, suffix, task_id, run_id, filters) +% +% Inputs: +% ses_path - session directory +% suffix - file suffix to search (e.g. 'events.tsv') +% task_id - task label (optional) +% run_id - run label (optional) +% filters - additional BIDS entity filters (optional) +% string or cell array of strings +% +% Returns: +% data_file - matched file path +% json_file - corresponding JSON file (if applicable) + + % Handle optional inputs safely + if nargin < 3 || isempty(task_id) + task_id = ''; + end + + if nargin < 4 || isempty(run_id) + run_id = ''; + end + + if nargin < 5 || isempty(filters) + filters = {}; + end + + % Ensure filters is a cell array + if ischar(filters) || isstring(filters) + filters = {char(filters)}; + end + + % Find candidate files + all_files = FindFiles(ses_path, suffix).files; + + search = {}; + + % Add task filter + if ~isempty(task_id) + search{end+1} = sprintf('task-%s', task_id); + end + + % Add run filter + if ~isempty(run_id) + search{end+1} = sprintf('run-%s', run_id); + end + + % Add custom filters + search = [search, filters]; + + % Locate file + data_file = get_file_from_substring(search, all_files, 'none'); + + % Find corresponding JSON + if ~isempty(data_file) + json_file = regexprep(data_file, '\.tsv(\.gz)?$', '.json'); + return; + end + + % No match found + warning('No files found in "%s" with filters: %s.', ... + ses_path, strjoin(search, ', ')); + + data_file = ''; + json_file = ''; +end diff --git a/src/bids_importer/lib/find_eye_files.m b/src/bids_importer/lib/find_eye_files.m new file mode 100644 index 000000000..813a94a53 --- /dev/null +++ b/src/bids_importer/lib/find_eye_files.m @@ -0,0 +1,80 @@ +function ev_tsv = find_eye_files(candidate_paths, task_id, run_id) +%FIND_EYE_FILES Locate eye-related physio TSV files across candidate directories. +% +% ev_tsv = FIND_EYE_FILES(candidate_paths, task_id, run_id) +% +% Searches a list of candidate directories for a BIDS-like eye/physio TSV +% file matching a given suffix and optional task/run entities. This is +% typically used to locate eye-tracking physio exports stored as TSV (often +% compressed as .tsv.gz). +% +% The function iterates over `candidate_paths` in order and returns the +% first matching file. If no matching file is found, returns an empty +% string (""). +% +% Inputs +% ------ +% candidate_paths : cell array +% Cell array of directory paths (char/string). Each directory is +% checked in order. Non-existent directories are skipped. +% +% task_id : char | string +% Optional task label (BIDS entity value for `task-`). +% Pass '' or [] to disable task filtering. +% +% run_id : char | string +% Optional run label (BIDS entity value for `run-`). +% Pass '' or [] to disable run filtering. +% +% Output +% ------ +% ev_tsv : string +% Full path to the matched physio TSV (or TSV.GZ) file, or "" if none +% is found. +% +% Search logic +% ------------ +% - Uses a fixed filename suffix (currently 'physio.tsv.gz') to find +% candidate files in each directory. +% - If any candidates exist, delegates to FIND_BIDS_FILE to apply entity +% filters (task/run) and select the best match. +% - Stops at the first directory containing a match. +% +% Notes +% ----- +% - This function assumes FindFiles can locate files matching `suffix`. +% - To support uncompressed files (e.g., 'physio.tsv'), either adjust the +% suffix here or enhance FIND_BIDS_FILE / FindFiles usage to search both. +% +% See also +% -------- +% FIND_BIDS_FILE, FINDFILES + +ev_tsv = ""; +suffix = 'physio.tsv.gz'; + +% look in dirs +for k = 1:numel(candidate_paths) + d = candidate_paths{k}; + if ~isfolder(d), continue; end + + % find json + match_files = FindFiles(d, suffix).files; + + % continue if no *physioevents.tsv are present + if isempty(match_files); continue; end + + % find tsv file + [ev_tsv, ~] = find_bids_file( ... + d, ... + suffix, ... + task_id, ... + run_id ... + ); + + if ~isempty(ev_tsv) + return + end +end + +end diff --git a/src/bids_importer/lib/find_physio_file.m b/src/bids_importer/lib/find_physio_file.m new file mode 100644 index 000000000..7c61d5360 --- /dev/null +++ b/src/bids_importer/lib/find_physio_file.m @@ -0,0 +1,29 @@ +function [ev_json, ev_tsv] = find_physio_file(physio_path, modality, task_id, run_id) + +ev_json = ""; +ev_tsv = ""; +suffix = 'physio.tsv.gz'; + +% it could be that data exists without 'physio' folder (e.g., 'eyetracking' +% data only +if ~isfolder(physio_path); return; end + +% find physio files +match_files = FindFiles(physio_path, suffix).files; + +% continue if no *physioevents.tsv are present +if isempty(match_files); return; end + +% find tsv file +[ev_tsv, ev_json] = find_bids_file( ... + d, ... + suffix, ... + task_id, ... + run_id, ... + sprintf('recording-%s', modality) ... +); + +if ~isempty(ev_tsv) && ~isempty(ev_json) + return +end +end diff --git a/src/bids_importer/lib/find_physioevents_pair.m b/src/bids_importer/lib/find_physioevents_pair.m new file mode 100644 index 000000000..3941bbff4 --- /dev/null +++ b/src/bids_importer/lib/find_physioevents_pair.m @@ -0,0 +1,38 @@ +function [ev_tsv, ev_json] = find_physioevents_pair(candidate_paths, task_id, run_id) +% base_stem example: +% sub-999_ses-01_task-fearconditioning_physioevents +% It will match: +% .json +% .tsv OR .tsv.gz +% And also allow optional recording entity: +% _recording-eye1.json, etc. (if you want) + +ev_json = ""; +ev_tsv = ""; +suffix = 'physioevents.tsv'; + +% look in dirs +for k = 1:numel(candidate_paths) + d = candidate_paths{k}; + if ~isfolder(d), continue; end + + % find json + match_files = FindFiles(d, suffix).files; + + % continue if no *physioevents.tsv are present + if isempty(match_files); continue; end + + % find tsv file + [ev_tsv, ev_json] = find_bids_file( ... + d, ... + suffix, ... + task_id, ... + run_id ... + ); + + if ~isempty(ev_tsv) && ~isempty(ev_json) + return + end +end + +end diff --git a/src/bids_importer/lib/get_eyes_list.m b/src/bids_importer/lib/get_eyes_list.m index ecec9ae06..4c9bec395 100644 --- a/src/bids_importer/lib/get_eyes_list.m +++ b/src/bids_importer/lib/get_eyes_list.m @@ -1,28 +1,13 @@ -function eyes = get_eyes_list(physio_folder_path) -% Get list of all files in the directory -files = dir(physio_folder_path); +function eyes = get_eyes_list(files) -% Initialize an empty cell array to store eye types eyes = {}; -% filter for files -files = files(~[files.isdir]); - -% Loop through each file in the directory +% Loop through each file for i = 1:length(files) % Get the filename - filename = files(i).name; - - % Check if the filename matches the expected pattern - % We assume that the file name contains '_recording-eye' followed by a number and '_physio.tsv' - expression = 'recording-eye(\d)_physio.tsv'; - match = regexp(filename, expression, 'tokens'); - - % If there is a match, extract the eye type - if ~isempty(match) - eyeType = ['eye' match{1}{1}]; % Extract '1' from 'eye1' and form 'eye1' - eyes{end+1} = eyeType; % Add to the list of eyes - end + filename = files{i}; + token = regexp(filename, 'recording-(eye\d+)', 'tokens', 'once'); + eyes{end+1} = token{1}; end % Get unique list of eyes diff --git a/src/bids_importer/lib/get_eyetrack_data.m b/src/bids_importer/lib/get_eyetrack_data.m index 4ba8851ff..7750e93f8 100644 --- a/src/bids_importer/lib/get_eyetrack_data.m +++ b/src/bids_importer/lib/get_eyetrack_data.m @@ -1,4 +1,4 @@ -function [sts, eye_data_cell] = get_eyetrack_data(subject_id, session_id, task_name, physio_path) +function [sts, eye_data_cell] = get_eyetrack_data(candidate_paths, subject_id, session_id, task_id, run_id) % get_eye_data Extracts eye-tracking data for a given subject, session, and task. % % This function returns a 2x1 cell array where each cell contains a struct @@ -15,52 +15,43 @@ % Example: % [eye_data, dur, info] = get_eye_data('sub-CalinetWuerzburg01','01','FearAcquisition', '/path/to/physio'); +%% Find all 'tsv.gz' files in session directory +eye_files = find_eye_files( ... + candidate_paths, ... + task_id, ... + run_id ... +); + +% enfore cell +if isstring(eye_files) + eye_files = cellstr(eye_files); +end + +% If eye_files is a char (single path): +if ischar(eye_files) + eye_files = {eye_files}; +end + %% Initialize the cell array and info variables sts = -1; -eye_signals = get_eyes_list(physio_path); +eye_signals = get_eyes_list(eye_files); eye_data_cell = {}; if isempty(eye_signals) - warning('No eye data found for subject %s sesssion %s', subject_id,session_id); + warning('No eye data found for subject %s session %s', subject_id,session_id); else % ------ % - num_signals = length(eye_signals); eye_data_cell = cell(num_signals, 1); - chan_names = cell(num_signals, 1); -file_paths = cell(num_signals, 1); %% Process each eye channel for i = 1:num_signals signal = eye_signals{i}; - - % Construct filenames based on BIDS naming convention: - if isempty(task_name) - % No task entity → standard BIDS physio filename - eye_json_filename = sprintf('%s_ses-%s_recording-%s_physio.json', ... - subject_id, session_id, signal); - - eye_tsv_filename = sprintf('%s_ses-%s_recording-%s_physio.tsv', ... - subject_id, session_id, signal); - - else - % Task entity present → include _task-_ in filename - eye_json_filename = sprintf('%s_ses-%s_task-%s_recording-%s_physio.json', ... - subject_id, session_id, task_name, signal); - - eye_tsv_filename = sprintf('%s_ses-%s_task-%s_recording-%s_physio.tsv', ... - subject_id, session_id, task_name, signal); - end - - eye_json_filepath = fullfile(physio_path, eye_json_filename); - eye_tsv_filepath = fullfile(physio_path, eye_tsv_filename); - - % Save file path and channel name for info - file_paths{i} = eye_tsv_filepath; - chan_names{i} = signal; - + eye_tsv_filepath = eye_files{i}; + eye_json_filepath = regexprep(eye_tsv_filepath, '\.tsv\.gz$', '.json'); + % Check if files exist if ~isfile(eye_json_filepath); warning('File not found: %s', eye_json_filepath); sts = -1 ;end if ~isfile(eye_tsv_filepath); warning('File not found: %s', eye_tsv_filepath); sts = -1 ; end @@ -75,14 +66,17 @@ col_types = repmat({'double'}, 1, length(headings)); % read_data_from_tsv is assumed to return a numeric matrix with dimensions [n_samples x n_columns] - eye_data_table = read_data_from_tsv(eye_tsv_filepath, false, headings.', col_types); + eye_data_table = read_data_from_tsv( ... + eye_tsv_filepath, ... + false, ... + headings.', ... + col_types ... + ); % Combine the JSON metadata with the TSV data. % I the futrure some kind of check maybe? eye_json.Columns = eye_data_table; - - % Store the combined struct into the cell array eye_data_cell{i} = eye_json; eye_data_cell{i}.source.file = [{eye_json_filepath}, {eye_tsv_filepath}]; diff --git a/src/bids_importer/lib/get_file_from_substring.m b/src/bids_importer/lib/get_file_from_substring.m new file mode 100644 index 000000000..780bbc8bf --- /dev/null +++ b/src/bids_importer/lib/get_file_from_substring.m @@ -0,0 +1,155 @@ +function out = get_file_from_substring(filt, path, return_msg, exclude) +%GET_FILE_FROM_SUBSTRING Find file(s) whose names contain all substrings in filt. +% +% out = get_file_from_substring(filt, path, return_msg, exclude) +% +% filt: char/string or cellstr/string array of required substrings +% path: folder path (char/string) OR cell array of filenames +% return_msg: 'error' (default) to throw if none found, otherwise returns [] +% exclude: optional char/string or cellstr/string array of substrings to exclude +% +% Returns: +% - char (full path) if exactly one match +% - cell array of char (full paths) if multiple matches +% - [] if none found and return_msg ~= 'error' + + if nargin < 3 || isempty(return_msg) + return_msg = 'error'; + end + if nargin < 4 + exclude = []; + end + + % Normalize filt -> cellstr + filt = normalize_to_cellstr(filt, 'filt'); + + % Normalize exclude -> cellstr (or empty) + if ~isempty(exclude) + exclude = normalize_to_cellstr(exclude, 'exclude'); + else + exclude = {}; + end + + input_is_list = false; + + % Get list of files + if ischar(path) || isstring(path) + folder = char(path); + if ~isfolder(folder) + error('get_file_from_substring:NotAFolder', ... + 'Path is not a folder: %s', folder); + end + listing = dir(folder); + listing = listing(~[listing.isdir]); % files only + files_in_directory = sort({listing.name}); + elseif iscell(path) + input_is_list = true; + files_in_directory = path(:)'; % row cell + folder = ''; % unused + else + error('get_file_from_substring:BadInputType', ... + 'path must be a folder path (char/string) or a cell array of filenames.'); + end + + % Build match mask: file matches if it contains ALL filt substrings + nFiles = numel(files_in_directory); + match_mask = true(1, nFiles); + + for fi = 1:numel(filt) + this_f = filt{fi}; + contains_mask = false(1, nFiles); + for i = 1:nFiles + contains_mask(i) = contains(files_in_directory{i}, this_f); + end + match_mask = match_mask & contains_mask; + end + + match_idx = find(match_mask); + + % No matches + if isempty(match_idx) + if strcmpi(return_msg, 'error') + error('get_file_from_substring:NotFound', ... + 'Could not find file with filters: [%s] in %s', strjoin(filt, ', '), path_to_str(path)); + else + out = []; + return; + end + end + + % Build match list (filenames or fullpaths) + if input_is_list + match_list = files_in_directory(match_idx); + else + match_list = cellfun(@(fn) fullfile(folder, fn), files_in_directory(match_idx), 'UniformOutput', false); + end + + % Apply exclusions (exclude after matching) + if ~isempty(exclude) + keep = true(1, numel(match_list)); + for i = 1:numel(match_list) + f = match_list{i}; + % exclude checks against full path (like your python version) + for ex = 1:numel(exclude) + if contains(f, exclude{ex}) + keep(i) = false; + break; + end + end + end + match_list = match_list(keep); + + if isempty(match_list) + if strcmpi(return_msg, 'error') + error('get_file_from_substring:NotFoundAfterExclude', ... + 'Could not find file with filters: [%s] and exclusion of [%s] in %s', ... + strjoin(filt, ', '), strjoin(exclude, ', '), path_to_str(path)); + else + out = []; + return; + end + end + end + + % Return scalar as char, multiple as cellstr + if numel(match_list) == 1 + out = match_list{1}; + else + out = match_list; + end +end + +% ---------- helpers ---------- + +function c = normalize_to_cellstr(x, name) + if ischar(x) + c = {x}; + elseif isstring(x) + x = x(:); + c = cellstr(x); + elseif iscell(x) + % ensure cell array of char + c = cell(1, numel(x)); + for i = 1:numel(x) + if isstring(x{i}) + c{i} = char(x{i}); + elseif ischar(x{i}) + c{i} = x{i}; + else + error('get_file_from_substring:Bad%s', name, ... + '%s must be char/string or a cell array of char/string.', name); + end + end + else + error('get_file_from_substring:Bad%s', name, ... + '%s must be char/string or a cell array of char/string.', name); + end +end + +function s = path_to_str(p) + if ischar(p) || isstring(p) + s = char(p); + else + s = ''; + end +end diff --git a/src/bids_importer/lib/get_first_existing_file.m b/src/bids_importer/lib/get_first_existing_file.m new file mode 100644 index 000000000..917aeba5b --- /dev/null +++ b/src/bids_importer/lib/get_first_existing_file.m @@ -0,0 +1,33 @@ +function fp = get_first_existing_file(search_dirs, required_pattern) +% required_pattern example: 'physio.tsv.gz' +% Function returns first file containing BOTH: +% - 'eye' +% - required_pattern +% +% search order follows the order of search_dirs. + +fp = ""; + +for k = 1:numel(search_dirs) + d = search_dirs{k}; + if ~isfolder(d) + continue; + end + + % list all files in directory + files = dir(d); + files = files(~[files.isdir]); % remove folders + + for i = 1:numel(files) + fname = files(i).name; + + if contains(fname, 'eye', 'IgnoreCase', true) && ... + contains(fname, required_pattern, 'IgnoreCase', true) + + fp = string(fullfile(d, fname)); + return; + end + end +end + +end diff --git a/src/bids_importer/lib/get_gaze_units.m b/src/bids_importer/lib/get_gaze_units.m new file mode 100644 index 000000000..9d8fb6eb2 --- /dev/null +++ b/src/bids_importer/lib/get_gaze_units.m @@ -0,0 +1,15 @@ +function units = get_gaze_units(m, coordField, chanLabel) +units = ""; + +if isfield(m, 'SampleCoordinateUnits') && ~isempty(m.SampleCoordinateUnits) + units = m.SampleCoordinateUnits; + return +end + +if isfield(m, coordField) && isfield(m.(coordField), 'Units') && ~isempty(m.(coordField).Units) + units = m.(coordField).Units; + return +end + +warning('ID:missing_units', 'Units could not be determined for %s channel.', chanLabel); +end diff --git a/src/bids_importer/lib/get_physio_data.m b/src/bids_importer/lib/get_physio_data.m index 99897bc08..37633ecad 100644 --- a/src/bids_importer/lib/get_physio_data.m +++ b/src/bids_importer/lib/get_physio_data.m @@ -1,4 +1,4 @@ -function [sts , physio_data, infos] = get_physio_data(subject_id, session_id, task_name, physio_path) +function [sts , physio_data, infos] = get_physio_data(physio_path, subject_id, session_id, task_id, run_id) % Returns a cell array where each cell contains a struct with fields header and data (and markerinfo for events) % Also returns physio_info_data needed to create 'info' struct % UPDATE HELPTEXT @@ -18,27 +18,15 @@ for i = 1:num_signals signal = physio_signals{i}; - - %% Construct filenames depending on whether task is present - if isempty(task_name) - % No task entity → standard BIDS physio filename - physio_json_filename = sprintf('%s_ses-%s_recording-%s_physio.json', ... - subject_id, session_id, signal); - - physio_tsv_filename = sprintf('%s_ses-%s_recording-%s_physio.tsv', ... - subject_id, session_id, signal); - - else - % Task entity present → include _task-_ in filename - physio_json_filename = sprintf('%s_ses-%s_task-%s_recording-%s_physio.json', ... - subject_id, session_id, task_name, signal); - - physio_tsv_filename = sprintf('%s_ses-%s_task-%s_recording-%s_physio.tsv', ... - subject_id, session_id, task_name, signal); - end - physio_json_filepath = fullfile(physio_path, physio_json_filename); - physio_tsv_filepath = fullfile(physio_path, physio_tsv_filename); + % find modality-specific physio file + [physio_tsv_filepath, physio_json_filepath] = find_physio_file( ... + physio_path, ... + signal, ... + task_id, ... + run_id ... + ); + %% Check if files exist % The warning could be confusing if ~isfile(physio_json_filepath) || ~isfile(physio_tsv_filepath) @@ -55,13 +43,16 @@ % Read TSV data headings = physio_json.Columns; col_types = repmat({'double'}, 1, length(headings)); - physio_data_table = read_data_from_tsv(physio_tsv_filepath, false, headings.', col_types); - + physio_data_table = read_data_from_tsv( ... + physio_tsv_filepath, ... + false, ... + headings.', ... + col_types ... + ); % Create channel struct chan = struct(); - % header chantype, sr, StartTime and units chan.header = struct(); chan.header.chantype = signal; chan.header.sr = physio_json.SamplingFrequency; diff --git a/src/bids_importer/lib/get_physio_eye_data.m b/src/bids_importer/lib/get_physio_eye_data.m index 28b6d4850..76563f245 100644 --- a/src/bids_importer/lib/get_physio_eye_data.m +++ b/src/bids_importer/lib/get_physio_eye_data.m @@ -1,643 +1,143 @@ -function [sts, data, infos] = get_physio_eye_data(subject_id, session_id, task_name, physio_eye_path) +function [sts, data, infos] = get_physio_eye_data(candidate_paths, subject_id, session_id, task_id, run_id) + sts = -1; data = {}; infos = struct(); infos.source = struct(); -infos.source.file = struct(); -file_paths = {}; - -%% % Process eye data - -[ests , eye_data_cell] = get_eyetrack_data(subject_id, session_id, task_name, physio_eye_path); - -if ests == 1 - - %% --- Add the eye data to the channels --- - num_eyes = length(eye_data_cell); - switch num_eyes - case 0; warning('No eye data available.'); - case 1 - eyeSide = lower(eye_data_cell{1}.RecordedEye); - warning('Only %s eye data available.', eyeSide); - if strcmp(eyeSide, 'right') - pupil_r = eye_data_cell{1}.Columns{:,'pupil_size'}; - gaze_x_r = eye_data_cell{1}.Columns{:,'x_coordinate'}; - gaze_y_r = eye_data_cell{1}.Columns{:,'y_coordinate'}; - - data{1}.data = pupil_r; - data{1}.header.chantype = 'pupil_r'; - data{2}.data = gaze_x_r; - data{2}.header.chantype = 'gaze_x_r'; - data{3}.data = gaze_y_r; - data{3}.header.chantype = 'gaze_y_r'; - - elseif strcmp(eyeSide, 'left') - pupil_l = eye_data_cell{1}.Columns{:,'pupil_size'}; - gaze_x_l = eye_data_cell{1}.Columns{:,'x_coordinate'}; - gaze_y_l = eye_data_cell{1}.Columns{:,'y_coordinate'}; - - data{1}.data = pupil_l; - data{1}.header.chantype = 'pupil_l'; - data{2}.data = gaze_x_l; - data{2}.header.chantype = 'gaze_x_l'; - data{3}.data = gaze_y_l; - data{3}.header.chantype = 'gaze_y_l'; - - else - warning('Unknown RecordedEye eye_data_cell.'); - return - end - case 2 - eyes = lower({eye_data_cell{1}.RecordedEye, eye_data_cell{2}.RecordedEye}); - if strcmp(eyes{1}, eyes{2}) - warning('Both recorded eyes are %s.', eyes{1}); - % Maybe choose the better eye? -> it chooses the better depends - % on l or eye - else - % Correctly assign each cell to the corresponding eye. - idxRight = find(strcmp(eyes, 'right'), 1); - idxLeft = find(strcmp(eyes, 'left'), 1); - - if isempty(idxRight) || isempty(idxLeft); warning('...');end % ??? - - pupil_r = eye_data_cell{idxRight}.Columns{:,'pupil_size'}; - gaze_x_r = eye_data_cell{idxRight}.Columns{:,'x_coordinate'}; - gaze_y_r = eye_data_cell{idxRight}.Columns{:,'y_coordinate'}; - - pupil_l = eye_data_cell{idxLeft}.Columns{:,'pupil_size'}; - gaze_x_l = eye_data_cell{idxLeft}.Columns{:,'x_coordinate'}; - gaze_y_l = eye_data_cell{idxLeft}.Columns{:,'y_coordinate'}; - - % right eye channels - data{1}.header.chantype = 'pupil_r'; - data{1}.data = pupil_r; - - data{2}.header.chantype = 'gaze_x_r'; - data{2}.data = gaze_x_r; - data{3}.header.chantype = 'gaze_y_r'; - data{3}.data = gaze_y_r; - - % left eye channels - data{4}.header.chantype = 'pupil_l'; - data{4}.data = pupil_l; - data{5}.header.chantype = 'gaze_x_l'; - data{5}.data = gaze_x_l; - data{6}.header.chantype = 'gaze_y_l'; - data{6}.data = gaze_y_l; - - end +%% Process eye data +[ests, eye_data_cell] = get_eyetrack_data( ... + candidate_paths, ... + subject_id, ... + session_id, ... + task_id, ... + run_id ... +); + +if ests < 1 || isempty(eye_data_cell) + warning('No eye data imported.'); + sts = -1; + return +end - otherwise; error('Unexpected number of eye data cells.'); +num_eyes = length(eye_data_cell); +%% Build eye channels +eye_channels = build_pspm_eye_channels(eye_data_cell); +data = eye_channels; +% Determine a StartTime reference for events +if ~isempty(data) && isfield(data{1}.header, 'StartTime') + startTimeRef = data{1}.header.StartTime; +else + startTimeRef = 0; + for i = 1:numel(data) + data{i}.header.StartTime = 0; end +end - data = data'; - - %% Add header data for pupil and gaze data - - % For one eye - if num_eyes == 1; idxRight = 1; idxLeft = 1; end - - for i = 1:length(data) - % pupil - if strcmp(data{i}.header.chantype(1:end-1) , 'pupil_') - if strcmp(data{i}.header.chantype(end:end) , 'r') - data{i}.header.Description = eye_data_cell{idxRight}.pupil_size.Description; - data{i}.header.units = eye_data_cell{idxRight}.pupil_size.Units; - data{i}.header.sr = eye_data_cell{idxRight}.SamplingFrequency; - - elseif strcmp(data{i}.header.chantype(end:end) , 'l') - data{i}.header.Description = eye_data_cell{idxLeft}.pupil_size.Description; - data{i}.header.units = eye_data_cell{idxLeft}.pupil_size.Units; - data{i}.header.sr = eye_data_cell{idxLeft}.SamplingFrequency; - - else - warning('No valid pupil channel found.'); - end - % gaze - elseif strcmp(data{i}.header.chantype(1:end-4) , 'gaze') - if strcmp(data{i}.header.chantype(6) , 'x') - if strcmp(data{i}.header.chantype(8) , 'r') - % gaze_x_r - if any(strcmp(fieldnames(eye_data_cell{idxRight}),'SampleCoordinateUnits')) - data{i}.header.units = eye_data_cell{idxRight}.SampleCoordinateUnits; % "pixel" - elseif any(strcmp(fieldnames(eye_data_cell{idxRight}),'x_coordinate')) - data{i}.header.units = eye_data_cell{idxRight}.x_coordinate.Units; - else - warning('ID:missing_units', 'Units could not be determined for gaze_x_r channel.'); - end - - data{i}.header.sr = eye_data_cell{idxRight}.SamplingFrequency; - data{i}.header.range = [eye_data_cell{idxRight}.GazeRange.xmin, eye_data_cell{idxRight}.GazeRange.xmax] ; % e.g. [0 1151] - elseif strcmp(data{i}.header.chantype(8) , 'l') - % gaze_x_l - if any(strcmp(fieldnames(eye_data_cell{idxLeft}),'SampleCoordinateUnits')) - data{i}.header.units = eye_data_cell{idxLeft}.SampleCoordinateUnits; % "pixel" - elseif any(strcmp(fieldnames(eye_data_cell{idxLeft}),'x_coordinate')) - data{i}.header.units = eye_data_cell{idxLeft}.x_coordinate.Units; - else - warning('ID:missing_units', 'Units could not be determined for gaze_x_l channel.'); - end - - data{i}.header.sr = eye_data_cell{idxLeft}.SamplingFrequency; - data{i}.header.range = [eye_data_cell{idxLeft}.GazeRange.xmin, eye_data_cell{idxLeft}.GazeRange.xmax] ; % e.g. [0 1151] - else - warning('Something went worng with gaze x channels') - end - - elseif strcmp(data{i}.header.chantype(6) , 'y') - if strcmp(data{i}.header.chantype(8) , 'r') - % gaze_y_r - if any(strcmp(fieldnames(eye_data_cell{idxRight}),'SampleCoordinateUnits')) - data{i}.header.units = eye_data_cell{idxRight}.SampleCoordinateUnits; % "pixel" - elseif any(strcmp(fieldnames(eye_data_cell{idxRight}),'y_coordinate')) - data{i}.header.units = eye_data_cell{idxRight}.y_coordinate.Units; - else - warning('ID:missing_units', 'Units could not be determined for gaze_y_r channel.'); - end - data{i}.header.sr = eye_data_cell{idxRight}.SamplingFrequency; - data{i}.header.range = [eye_data_cell{idxRight}.GazeRange.ymin, eye_data_cell{idxRight}.GazeRange.ymax] ; % e.g. [0 1151] - - elseif strcmp(data{i}.header.chantype(8) , 'l') - % gaze_y_l - if any(strcmp(fieldnames(eye_data_cell{idxLeft}),'SampleCoordinateUnits')) - data{i}.header.units = eye_data_cell{idxLeft}.SampleCoordinateUnits; % "pixel" - elseif any(strcmp(fieldnames(eye_data_cell{idxLeft}),'y_coordinate')) - data{i}.header.units = eye_data_cell{idxLeft}.y_coordinate.Units; % should i add a check that x and y are the same units? - else - warning('ID:missing_units', 'Units could not be determined for gaze_y_l channel.'); - end - - data{i}.header.sr = eye_data_cell{idxLeft}.SamplingFrequency; - data{i}.header.range = [eye_data_cell{idxLeft}.GazeRange.ymin, eye_data_cell{idxLeft}.GazeRange.ymax] ; % e.g. [0 1151] - - else - warning('Something went worng with gaze y channels') - end - end +%% Physioevents search dirs +% Prefer the folder where the eye file actually is, then try siblings under ses-* +sf = eye_data_cell{1}.source.file; +if iscell(sf), sf = sf{1}; end + +[events_tsv_filepath, events_json_filepath] = find_physioevents_pair( ... + candidate_paths, ... + task_id, ... + run_id ... +); + +if strlength(events_json_filepath) > 0 && strlength(events_tsv_filepath) > 0 + data_events = get_physio_events_data( ... + char(events_json_filepath), ... + char(events_tsv_filepath), ... + false ... + ); + + if ~isempty(data_events) + for i = 1:numel(data_events) + data_events{i}.header.StartTime = startTimeRef; end + data = [data; data_events]; + else + warning('No events for physio eye data were imported.'); end - - %% --- Build the eye infos.source ---- - - % --- infos.source --- - infos.source = struct(); - infos.source.chan = {} ;% {'Column 02'} {'Column 01'}? - infos.source.chan_stats = cell(length(data), 1); % nan_stats - - % Calculating the nan ratio - for i = 1:length(data) - n_data = size(data{i}.data, 1); - n_inv = sum(isnan(data{i}.data)); - infos.source.chan_stats{i,1} = struct(); - infos.source.chan_stats{i,1}.nan_ratio = n_inv / n_data; - end - - if ~isequal(eye_data_cell{idxRight}.GazeRange, eye_data_cell{idxLeft}.GazeRange) - warning("GazeRange is not equal"); - end - - infos.source.gaze_coords = eye_data_cell{idxRight}.GazeRange; - - if any(strcmp(fieldnames(eye_data_cell{idxRight}),'PupilFitMethod')) - infos.source.elcl_proc = lower(eye_data_cell{idxRight}.PupilFitMethod); % or should it be called PupilFitMethod? lowercase! - elseif any(strcmp(fieldnames(eye_data_cell{idxRight}),'ElclProc')) - infos.source.elcl_proc = lower(eye_data_cell{idxRight}.ElclProc); % like in the Calinet dataset - end - - % eyesObserved and best_eye - if num_eyes == 2 - infos.source.eyesObserved = 'lr'; - elseif num_eyes == 1 - infos.source.eyesObserved = data{1}.header.chantype(end); - end - - infos.source.best_eye = eye_with_smaller_nan_ratio(data, infos.source.eyesObserved); - infos.source.type = 'BIDS (json/tsv)' ; - - - if num_eyes == 2 - % physio_infos.source.file = [eye_data_cell{1}.source.file, eye_data_cell{2}.source.file] ; % {1},{2} gives the right order - file_paths{1,1} = eye_data_cell{1}.source.file; - file_paths{2,1} = eye_data_cell{2}.source.file; +else + % keep as warning or make it silent, your call + if isempty(task_id) + warning('No physioevents found for %s (ses-%s).', subject_id, session_id); else - file_paths{1,1} = eye_data_cell{1}.source.file ; + warning('No physioevents found for %s (ses-%s, task-%s).', subject_id, session_id, task_id); end - -%% --- Add the eye data to the channels --- -num_eyes = length(eye_data_cell); -switch num_eyes - case 0; warning('No eye data available.'); - case 1 - eyeSide = lower(eye_data_cell{1}.RecordedEye); - warning('Only %s eye data available.', eyeSide); - - if strcmp(eyeSide, 'right') - pupil_r = eye_data_cell{1}.Columns{:,'pupil_size'}; - gaze_x_r = eye_data_cell{1}.Columns{:,'x_coordinate'}; - gaze_y_r = eye_data_cell{1}.Columns{:,'y_coordinate'}; - - data{1}.data = pupil_r; - data{1}.header.chantype = 'pupil_r'; - data{2}.data = gaze_x_r; - data{2}.header.chantype = 'gaze_x_r'; - data{3}.data = gaze_y_r; - data{3}.header.chantype = 'gaze_y_r'; - - elseif strcmp(eyeSide, 'left') - pupil_l = eye_data_cell{1}.Columns{:,'pupil_size'}; - gaze_x_l = eye_data_cell{1}.Columns{:,'x_coordinate'}; - gaze_y_l = eye_data_cell{1}.Columns{:,'y_coordinate'}; - - data{1}.data = pupil_l; - data{1}.header.chantype = 'pupil_l'; - data{2}.data = gaze_x_l; - data{2}.header.chantype = 'gaze_x_l'; - data{3}.data = gaze_y_l; - data{3}.header.chantype = 'gaze_y_l'; - - else - warning('Unknown RecordedEye eye_data_cell.'); - return - end - case 2 - eyes = lower({eye_data_cell{1}.RecordedEye, eye_data_cell{2}.RecordedEye}); - if strcmp(eyes{1}, eyes{2}) - warning('Both recorded eyes are %s.', eyes{1}); - % Maybe choose the better eye? -> it chooses the better depends - % on l or eye - else - % Correctly assign each cell to the corresponding eye. - idxRight = find(strcmp(eyes, 'right'), 1); - idxLeft = find(strcmp(eyes, 'left'), 1); - - if isempty(idxRight) || isempty(idxLeft); warning('...');end % ??? - - pupil_r = eye_data_cell{idxRight}.Columns{:,'pupil_size'}; - gaze_x_r = eye_data_cell{idxRight}.Columns{:,'x_coordinate'}; - gaze_y_r = eye_data_cell{idxRight}.Columns{:,'y_coordinate'}; - - pupil_l = eye_data_cell{idxLeft}.Columns{:,'pupil_size'}; - gaze_x_l = eye_data_cell{idxLeft}.Columns{:,'x_coordinate'}; - gaze_y_l = eye_data_cell{idxLeft}.Columns{:,'y_coordinate'}; - - % right eye channels - data{1}.header.chantype = 'pupil_r'; - data{1}.data = pupil_r; - - data{2}.header.chantype = 'gaze_x_r'; - data{2}.data = gaze_x_r; - data{3}.header.chantype = 'gaze_y_r'; - data{3}.data = gaze_y_r; - - % left eye channels - data{4}.header.chantype = 'pupil_l'; - data{4}.data = pupil_l; - data{5}.header.chantype = 'gaze_x_l'; - data{5}.data = gaze_x_l; - data{6}.header.chantype = 'gaze_y_l'; - data{6}.data = gaze_y_l; - - end - - otherwise; error('Unexpected number of eye data cells.'); - - end -data = data'; - -%% Add header data for pupil and gaze data - -% For one eye -if num_eyes == 1; idxRight = 1; idxLeft = 1; end - -for i = 1:length(data) - % pupil - if strcmp(data{i}.header.chantype(1:end-1) , 'pupil_') - if strcmp(data{i}.header.chantype(end:end) , 'r') - data{i}.header.Description = eye_data_cell{idxRight}.pupil_size.Description; - data{i}.header.units = eye_data_cell{idxRight}.pupil_size.Units; - data{i}.header.sr = eye_data_cell{idxRight}.SamplingFrequency; - - elseif strcmp(data{i}.header.chantype(end:end) , 'l') - data{i}.header.Description = eye_data_cell{idxLeft}.pupil_size.Description; - data{i}.header.units = eye_data_cell{idxLeft}.pupil_size.Units; - data{i}.header.sr = eye_data_cell{idxLeft}.SamplingFrequency; - - else - warning('No valid pupil channel found.'); - end - % gaze - elseif strcmp(data{i}.header.chantype(1:end-4) , 'gaze') - if strcmp(data{i}.header.chantype(6) , 'x') - if strcmp(data{i}.header.chantype(8) , 'r') - % gaze_x_r - if any(strcmp(fieldnames(eye_data_cell{idxRight}),'SampleCoordinateUnits')) - data{i}.header.units = eye_data_cell{idxRight}.SampleCoordinateUnits; % "pixel" - elseif any(strcmp(fieldnames(eye_data_cell{idxRight}),'x_coordinate')) - data{i}.header.units = eye_data_cell{idxRight}.x_coordinate.Units; - else - warning('ID:missing_units', 'Units could not be determined for gaze_x_r channel.'); - end - - data{i}.header.sr = eye_data_cell{idxRight}.SamplingFrequency; - data{i}.header.range = [eye_data_cell{idxRight}.GazeRange.xmin, eye_data_cell{idxRight}.GazeRange.xmax] ; % e.g. [0 1151] - elseif strcmp(data{i}.header.chantype(8) , 'l') - % gaze_x_l - if any(strcmp(fieldnames(eye_data_cell{idxLeft}),'SampleCoordinateUnits')) - data{i}.header.units = eye_data_cell{idxLeft}.SampleCoordinateUnits; % "pixel" - elseif any(strcmp(fieldnames(eye_data_cell{idxLeft}),'x_coordinate')) - data{i}.header.units = eye_data_cell{idxLeft}.x_coordinate.Units; - else - warning('ID:missing_units', 'Units could not be determined for gaze_x_l channel.'); - end - - data{i}.header.sr = eye_data_cell{idxLeft}.SamplingFrequency; - data{i}.header.range = [eye_data_cell{idxLeft}.GazeRange.xmin, eye_data_cell{idxLeft}.GazeRange.xmax] ; % e.g. [0 1151] - else - warning('Something went worng with gaze x channels') - end +%% Build infos.source.file +file_paths = {}; - elseif strcmp(data{i}.header.chantype(6) , 'y') - if strcmp(data{i}.header.chantype(8) , 'r') - % gaze_y_r - if any(strcmp(fieldnames(eye_data_cell{idxRight}),'SampleCoordinateUnits')) - data{i}.header.units = eye_data_cell{idxRight}.SampleCoordinateUnits; % "pixel" - elseif any(strcmp(fieldnames(eye_data_cell{idxRight}),'y_coordinate')) - data{i}.header.units = eye_data_cell{idxRight}.y_coordinate.Units; - else - warning('ID:missing_units', 'Units could not be determined for gaze_y_r channel.'); - end - data{i}.header.sr = eye_data_cell{idxRight}.SamplingFrequency; - data{i}.header.range = [eye_data_cell{idxRight}.GazeRange.ymin, eye_data_cell{idxRight}.GazeRange.ymax] ; % e.g. [0 1151] - - elseif strcmp(data{i}.header.chantype(8) , 'l') - % gaze_y_l - if any(strcmp(fieldnames(eye_data_cell{idxLeft}),'SampleCoordinateUnits')) - data{i}.header.units = eye_data_cell{idxLeft}.SampleCoordinateUnits; % "pixel" - elseif any(strcmp(fieldnames(eye_data_cell{idxLeft}),'y_coordinate')) - data{i}.header.units = eye_data_cell{idxLeft}.y_coordinate.Units; % should i add a check that x and y are the same units? - else - warning('ID:missing_units', 'Units could not be determined for gaze_y_l channel.'); - end - - data{i}.header.sr = eye_data_cell{idxLeft}.SamplingFrequency; - data{i}.header.range = [eye_data_cell{idxLeft}.GazeRange.ymin, eye_data_cell{idxLeft}.GazeRange.ymax] ; % e.g. [0 1151] - - else - warning('Something went worng with gaze y channels') - end +for i = 1:numel(eye_data_cell) + if isfield(eye_data_cell{i}, 'source') && isfield(eye_data_cell{i}.source, 'file') + sf = eye_data_cell{i}.source.file; + if iscell(sf) + for k = 1:numel(sf) + file_paths{end+1,1} = char(sf{k}); %#ok + end + else + file_paths{end+1,1} = char(sf); %#ok end - end + end end -%% --- Build the eye infos.source ---- - -% --- infos.source --- -infos.source = struct(); -infos.source.chan = {} ;% {'Column 02'} {'Column 01'}? -infos.source.chan_stats = cell(length(data), 1); % nan_stats - -% Calculating the nan ratio -for i = 1:length(data) - n_data = size(data{i}.data, 1); - n_inv = sum(isnan(data{i}.data)); - infos.source.chan_stats{i,1} = struct(); - infos.source.chan_stats{i,1}.nan_ratio = n_inv / n_data; +if strlength(events_json_filepath) > 0 + file_paths{end+1,1} = char(events_json_filepath); + file_paths{end+1,1} = char(events_tsv_filepath); end -if ~isequal(eye_data_cell{idxRight}.GazeRange, eye_data_cell{idxLeft}.GazeRange) - warning("GazeRange is not equal"); -end - -infos.source.gaze_coords = eye_data_cell{idxRight}.GazeRange; - -if any(strcmp(fieldnames(eye_data_cell{idxRight}),'PupilFitMethod')) - infos.source.elcl_proc = lower(eye_data_cell{idxRight}.PupilFitMethod); % or should it be called PupilFitMethod? lowercase! -elseif any(strcmp(fieldnames(eye_data_cell{idxRight}),'ElclProc')) - infos.source.elcl_proc = lower(eye_data_cell{idxRight}.ElclProc); % like in the Calinet dataset -end +infos.source.file = file_paths; -% eyesObserved and best_eye +% add eyesObserved if num_eyes == 2 infos.source.eyesObserved = 'lr'; elseif num_eyes == 1 - infos.source.eyesObserved = data{1}.header.chantype(end); -end + infos.source.eyesObserved = data{1}.header.chantype(end); +end infos.source.best_eye = eye_with_smaller_nan_ratio(data, infos.source.eyesObserved); infos.source.type = 'BIDS (json/tsv)' ; - -if num_eyes == 2 - % physio_infos.source.file = [eye_data_cell{1}.source.file, eye_data_cell{2}.source.file] ; % {1},{2} gives the right order - file_paths{1,1} = eye_data_cell{1}.source.file; - file_paths{2,1} = eye_data_cell{2}.source.file; -else - file_paths{1,1} = eye_data_cell{1}.source.file ; -end - - - -% Check if the first data has the StartTime field -if isfield(data{1}.header, 'StartTime') - % Check if all StartTimes are the same - start_times = cellfun(@(x) x.header.StartTime, data, 'UniformOutput', false); - if ~isequal(start_times{:}) ; warning('Not all data have the same StartTime. Please check the input data.'); end -else - % If there is no StartTime field start time will set to 0 - for i = 1:length(data); data{i}.header.StartTime = 0; end -end - -else - warning('No data for physio eye data was imported.'); -end % if ests == 1 - - -%% Process physio eye event data -> header eyedata maybe somewhere else? -% Base physioevent pattern (JSON/TSV extension added later) -if isempty(task_name) - base_pattern = sprintf('%s_ses-%s_*physioevents', subject_id, session_id); -else - base_pattern = sprintf('%s_ses-%s_task-%s_physioevents', subject_id, session_id, task_name); -end - -% Add JSON/TSV extensions -json_files = dir(fullfile(physio_eye_path, base_pattern + ".json")); -tsv_files = dir(fullfile(physio_eye_path, base_pattern + ".tsv")); - -% Check existence -if isempty(json_files) || isempty(tsv_files) - if isempty(task_name) - warning('No blink/saccade events in %s. Skipping event processing.', physio_eye_path); - else - warning('No blink/saccade events for task "%s" in %s. Skipping event processing.', ... - task_name, physio_eye_path); - end -else - % Construct final full file paths - events_json_filepath = fullfile(physio_eye_path, json_files(1).name); - events_tsv_filepath = fullfile(physio_eye_path, tsv_files(1).name); - - % Checks if the event files exist - if ~isfile(events_json_filepath) || ~isfile(events_tsv_filepath) - warning('Event file %s does not exist.', events_json_filepath); - else - % Imports the eye event data - data_events = get_physio_events_data(events_json_filepath, events_tsv_filepath, false); % has ColumnField - - % Gives the events the StartTime time as the eye data - if ~isempty(data_events) % if there are eye data but eye_events - for i = 1:length(data_events); data_events{i}.header.StartTime = data{1}.header.StartTime; end - else - warning('No events for physio eye data were imported.'); - end - file_paths{end+1,1} = {events_json_filepath,events_tsv_filepath}; - data = [data; data_events]; - end % -end - -%% -if isempty(data) - warning('No physio eye event data has been imported.'); - return -end - sts = 1; -infos.source.file = file_paths; - end % adapted from in pspm_get_viewpoint and pspm_get_smi function best_eye = eye_with_smaller_nan_ratio(data, eyes_observed) -if length(eyes_observed) == 1 - best_eye = lower(eyes_observed); -else - eye_L_max_nan_ratio = 0; - eye_R_max_nan_ratio = 0; - for i = 1:numel(data) - left_data = strcmpi(data{i}.header.chantype(end),'l'); - right_data = strcmpi(data{i}.header.chantype(end),'r'); - - if left_data - eye_L_max_nan_ratio = max(eye_L_max_nan_ratio, sum(isnan(data{i}.data))); - elseif right_data - eye_R_max_nan_ratio = max(eye_R_max_nan_ratio, sum(isnan(data{i}.data))); - end - end - - if eye_L_max_nan_ratio > eye_R_max_nan_ratio - best_eye = 'r'; + + if isscalar(eyes_observed) + best_eye = lower(eyes_observed); else - best_eye = 'l'; % if equal set 'l' - end -end -end - -function data = get_physio_events_data(events_json_filepath, events_tsv_filepath, noColumnField) -sr = 1; % default -has_headings = true; -% better way? -data{1,1}.data.header = struct(); -data{2,1}.data.header = struct(); -data{3,1}.data.header = struct(); - -col_types = {'double', 'double', 'char', 'char', 'char'}; - -% Get the event json -event_json = extract_json_as_struct(events_json_filepath); - -if noColumnField - headings = fieldnames(event_json).'; -elseif isfield(event_json, 'Columns') - headings = event_json.Columns; -else - headings = []; -end - -% Get marker tsv data -marker_tsv_data_table = read_data_from_tsv(events_tsv_filepath, has_headings, headings, col_types ); - - -% Checks if it is a proper physio eye event data -if ~any(ismember(marker_tsv_data_table.Properties.VariableNames, {'blink','message'})) - warining('No physio events') - data = -1; - return ; -end - - -idx_header = strcmp(marker_tsv_data_table.event_type, 'n/a') & ~strcmp(marker_tsv_data_table.message, 'CS'); - -idx_data = ~idx_header; + eye_L_max_nan_ratio = 0; + eye_R_max_nan_ratio = 0; -% Find Record Configuration -indices_reccfg = find(contains(marker_tsv_data_table.message, 'RECCFG')); % find Record Configuration -reccfg = split(marker_tsv_data_table.message(indices_reccfg)); + n = numel(data); -if ~isempty(reccfg) - sr = str2double(reccfg{3}); - eyes = reccfg{6}; % could be used in the future to choose the rigth blink channel + for i = 1:n + chantype = data{i}.header.chantype; + eye_side = lower(chantype(end)); - % Set first measurment to zero - onsets = marker_tsv_data_table.onset(idx_data); - onsets = (onsets - onsets(1)); % shifting onset times - duration = marker_tsv_data_table.duration(idx_data); - event_type = marker_tsv_data_table.event_type(idx_data); % including CS (NaN) will be excluted later + nan_count = sum(isnan(data{i}.data)); - signal = {'blink','saccade','fixation'}; - singal_chan = {'blink_c','saccade_c','fixation_c'}; - - for s = 1:numel(signal) + if eye_side == 'l' + eye_L_max_nan_ratio = max(eye_L_max_nan_ratio, nan_count); - % Index of the onsets of the signal - idx_signal = find(strcmp(event_type, signal{1})); % excludes NaNs + elseif eye_side == 'r' + eye_R_max_nan_ratio = max(eye_R_max_nan_ratio, nan_count); + end + end - % get onset start to onset end(onset+duration) - starts = onsets(idx_signal); - ends = onsets(idx_signal) + duration(idx_signal); - - % safer - ranges = cell(numel(starts),1); - - for k = 1:numel(starts) - if starts(k) <= ends(k) - ranges{k} = (starts(k):ends(k)).'; - else - ranges{k} = []; - end - end - - all_indices = vertcat(ranges{:}); - - % remove overlaps + invalid index - idx_signal = unique(all_indices); - idx_signal = idx_signal(idx_signal > 0); % drop 0 - - % create signal - data_signal = zeros(max(idx_signal),1); - data_signal(idx_signal) = 1; - - % sanitiy check - if ~(sum(data_signal) == length(idx_signal)) - warning('Not same length.') - return - end - - % assign pupil data - data{s,1}.data = data_signal; - % add header - data{s,1}.header.chantype = singal_chan{s}; - data{s,1}.header.units = signal{s}; - data{s,1}.header.sr = sr; - data{s,1}.header.StartTime = onsets(1)/sr; % to get it in secondes + if eye_L_max_nan_ratio > eye_R_max_nan_ratio + best_eye = 'r'; + else + best_eye = 'l'; % if equal set 'l' + end end -else - data = {}; -end end \ No newline at end of file diff --git a/src/bids_importer/lib/normalize_eye_entries.m b/src/bids_importer/lib/normalize_eye_entries.m new file mode 100644 index 000000000..9ff43006e --- /dev/null +++ b/src/bids_importer/lib/normalize_eye_entries.m @@ -0,0 +1,43 @@ +function eyes = normalize_eye_entries(eye_data_cell) +% Returns struct with fields .r and .l containing the chosen metadata entry for each eye. + +eyes = struct('r', [], 'l', []); + +for i = 1:numel(eye_data_cell) + e = eye_data_cell{i}; + + if ~isstruct(e) || ~isfield(e, 'RecordedEye') || isempty(e.RecordedEye) + warning('Eye entry %d missing RecordedEye; skipping.', i); + continue + end + + side = lower(string(e.RecordedEye)); + if side == "right" + if isempty(eyes.r) + eyes.r = e; + else + warning('Multiple right-eye entries found; keeping the first.'); + end + elseif side == "left" + if isempty(eyes.l) + eyes.l = e; + else + warning('Multiple left-eye entries found; keeping the first.'); + end + else + warning('Unknown RecordedEye "%s" in entry %d; skipping.', side, i); + end +end + +% If RecordedEye wasn’t reliable but there is exactly 1 entry, use it for both +if isempty(eyes.r) && isempty(eyes.l) && numel(eye_data_cell) == 1 + warning('RecordedEye not usable; using the only entry as both r and l.'); + eyes.r = eye_data_cell{1}; + eyes.l = eye_data_cell{1}; +elseif numel(eye_data_cell) == 1 + % single-eye case: mirror onto missing side (so downstream doesn’t crash) + if isempty(eyes.r) && ~isempty(eyes.l), eyes.r = eyes.l; end + if isempty(eyes.l) && ~isempty(eyes.r), eyes.l = eyes.r; end +end + +end diff --git a/src/bids_importer/lib/read_data_from_tsv.m b/src/bids_importer/lib/read_data_from_tsv.m index c4fa43e14..92f16f3f1 100644 --- a/src/bids_importer/lib/read_data_from_tsv.m +++ b/src/bids_importer/lib/read_data_from_tsv.m @@ -4,24 +4,49 @@ error('If the file has no header, you must provide column headings.'); end +cleanupFile = ""; % track temporary file for deletion +originalFile = tsv_filepath; -opts = detectImportOptions(tsv_filepath, 'FileType', 'text', 'Delimiter', '\t'); +% ------------------------------------------------------------------------- +% Handle .tsv.gz files +if endsWith(tsv_filepath, '.gz', 'IgnoreCase', true) + + if ~isfile(tsv_filepath) + error('File not found: %s', tsv_filepath); + end + + tmpDir = tempname; + mkdir(tmpDir); + + % unzip into temporary folder + gunzip(tsv_filepath, tmpDir); + + % get unzipped filename + [~, name, ~] = fileparts(tsv_filepath); % removes .gz + unzippedFile = fullfile(tmpDir, name); + + tsv_filepath = unzippedFile; + cleanupFile = unzippedFile; +end + +% ------------------------------------------------------------------------- +% Detect import options +opts = detectImportOptions(tsv_filepath, ... + 'FileType', 'text', ... + 'Delimiter', '\t'); if ~has_headings opts.VariableNamingRule = 'preserve'; opts.VariableNames = headings; - opts.DataLines = [1 inf]; % Read all data lines + opts.DataLines = [1 inf]; opts.EmptyLineRule = 'read'; end - -% Determine the number of columns in the file. +% ------------------------------------------------------------------------- +% Adjust column types numCols = numel(opts.VariableNames); -% Adjust the col_types list to match the file's number of columns. if length(col_types) < numCols - % Append default type 'char' for extra columns. % what if it is not a - % char ???? additional = repmat({'char'}, 1, numCols - length(col_types)); col_types = [col_types, additional]; elseif length(col_types) > numCols @@ -30,6 +55,19 @@ opts.VariableTypes = col_types; +% ------------------------------------------------------------------------- +% Read table data = readtable(tsv_filepath, opts); +% ------------------------------------------------------------------------- +% Cleanup temporary file +if strlength(cleanupFile) > 0 + try + delete(cleanupFile); + rmdir(fileparts(cleanupFile)); + catch + % silently ignore cleanup failure + end +end + end diff --git a/src/helper/pspm_peakscore.m b/src/helper/pspm_peakscore.m index 1c4f8662d..b43739679 100644 --- a/src/helper/pspm_peakscore.m +++ b/src/helper/pspm_peakscore.m @@ -50,7 +50,7 @@ % $Rev$ global settings; -if isempty(settings), pspm_init; end; +if isempty(settings), pspm_init; end % check input arguments if nargin<1 diff --git a/src/pspm_find_valid_fixations.m b/src/pspm_find_valid_fixations.m index 42b89355a..9f2351a7a 100644 --- a/src/pspm_find_valid_fixations.m +++ b/src/pspm_find_valid_fixations.m @@ -188,7 +188,7 @@ % load corresponding gaze channels in correct units - channelunits_list = cellfun(@(x) data.header.units, alldata.data, 'uni', false); + channelunits_list = cellfun(@(x) x.header.units, alldata.data, 'uni', false); if strcmpi(mode, 'fixation') channels_correct_units = find(~contains(channelunits_list, 'degree') & ~contains(channelunits_list, 'pixel')); elseif strcmpi(mode, 'bitmap') diff --git a/src/pspm_import_bids.m b/src/pspm_import_bids.m index 13dad277b..d65398456 100644 --- a/src/pspm_import_bids.m +++ b/src/pspm_import_bids.m @@ -31,28 +31,17 @@ % ├── sub-__task-Extinction_... (same pattern) % ├── sub-__task-Habituation_... (same pattern) % -% Example supported layout (tasks split per session, no 'task' ID): +% Example supported layout (tasks split per session, no 'task' ID, eyetracker data only): % % sub-/ -% ├── ses-01 -% │ ├── beh -% │ │ ├── sub-_ses-01_beh.json -% │ │ ├── sub-_ses-01_events.json -% │ │ └── sub-_ses-01_events.tsv -% │ └── physio -% │ ├── sub-_ses-01_recording-ecg_physio.json -% │ ├── sub-_ses-01_recording-ecg_physio.tsv -% │ ├── sub-_ses-01_recording-eye1_physio.json -% │ ├── sub-_ses-01_recording-eye1_physio.tsv -% │ ├── sub-_ses-01_recording-eye2_physio.json -% │ ├── sub-_ses-01_recording-eye2_physio.tsv -% │ ├── sub-_ses-01_recording-ppg_physio.json -% │ ├── sub-_ses-01_recording-ppg_physio.tsv -% │ ├── sub-_ses-01_recording-scr_physio.json -% │ └── sub-_ses-01_recording-scr_physio.tsv -% └── ses-02 -% ├── beh -% └── physio +% └── ses-01 +% └── beh +% ├── sub-_ses-01_events.json +% ├── sub-_ses-01_events.tsv +% ├── sub-_ses-01_recording-eye1_physio.json +% ├── sub-_ses-01_recording-eye1_physio.tsv +% ├── sub-_ses-01_recording-eye2_physio.json +% └── sub-_ses-01_recording-eye2_physio.tsv % % ● Format % [sts, outfile] = pspm_import_bids(dataset_path, save_path) @@ -69,9 +58,9 @@ % % ● History % Introduced in PsPM 7.0 -% Written in 2024 by Sourav Koulkarni, -% Dominik R. Bach, -% Bernhard A. von Raußendorf (University of Bonn) +% Written in 2024 by Sourav Koulkarni, +% Dominik R. Bach, +% Bernhard A. von Raußendorf (University of Bonn) % % 05.12.2025: % - Overall updates on logic and flow @@ -80,6 +69,13 @@ % - Updated handling of 'save_path' argument % - Prettify interface % +% 16.02.2026: +% - Update to support BEP020 +% - No specific 'beh'-folder -> events are linked by task/run +% - tsv.gz files instead of tsv +% - +% - Extra support for run-specific inputs +% %% 1. Initialize ----------------------------------------------------------- global settings if isempty(settings) @@ -155,7 +151,7 @@ save_path = fullfile(dataset_path, "out"); disp(save_path); % warning("ID:nonexistent_folder","No or invalid save path specified; using '%s' instead.", save_path); - warning(sprintf("ID:nonexistent_folder: No or invalid save path specified; using '%s' instead.", save_path)); + warning("ID:nonexistent_folder: No or invalid save path specified; using '%s' instead.", save_path); end %% Start message @@ -166,6 +162,7 @@ nSubjects = 0; nSessions = 0; +nRuns = 0; %% 3. Loop over subjects --------------------------------------------------- for i = 1:length(subject_list) @@ -190,151 +187,183 @@ % checks if there are sessions if isempty(session_dirs); warning('ID:nonexistent_folder','No session folder (''ses-%s'') found in %s', sub_idx_str ,sub_path); continue; end - %% Process each session for j = 1:length(session_dirs) session_id = session_dirs(j).name(5:end); % e.g., '01' or '02' (could there be more 100 sessions?) - ses_path = fullfile(sub_path,session_dirs(j).name); % if it is ses_mode it will be overwriten but that is okay - physio_dir = fullfile(ses_path, 'physio'); - + ses_path = fullfile(sub_path,session_dirs(j).name); + + % eye-tracking files can live in 'beh', 'physio', or any modality they have been acquired in + % concurrently (e.g., 'func' during fMRI) [BEP020]. + % SCR and other data will live in 'physio' [BEP045] + eye_search_dirs = { ... + fullfile(ses_path,'beh'), ... + fullfile(ses_path,'physio'), ... + fullfile(ses_path,'func') ... + }; + + % keep only those that exist + eye_search_dirs = eye_search_dirs(cellfun(@isfolder, eye_search_dirs)); + fprintf('\n--------------------------------------------------------------------------------\n'); %% Extract task name % Look for any event JSON in the beh and physio folders - task_ids = get_bids_task_ids(physio_dir); - - % If none found → process the session once without a task name - if isempty(task_ids) - task_ids = {''}; % placeholder for “no task” - fprintf('Processing %s\n', session_dirs(j).name); - else - task_list_str = strjoin(task_ids, ', '); - fprintf('Processing %s with %d task(s): %s\n', ... - session_dirs(j).name, length(task_ids), task_list_str); - end - + task_ids = get_bids_task_ids(eye_search_dirs); + % loop over tasks for t = 1:numel(task_ids) - %% Build file patterns depending on task_name - task_name = task_ids{t}; - if isempty(task_name) - fprintf("\nReading data\n"); - beh_base = sprintf('%s_ses-%s_', subject_full_id, session_id); - else - fprintf("\nReading data from task-%s\n", task_name); - beh_base = sprintf('%s_ses-%s_task-%s_', subject_full_id, session_id, task_name); - end - %% Processing start - % read in data - physio_path = fullfile(ses_path, 'physio'); - [~, physio_data, physio_infos] = get_physio_data( ... - subject_full_id, ... - session_id, ... - task_name, ... - physio_path ... - ); - - [~, physio_eye_data, physio_eye_infos] = get_physio_eye_data( ... + %% Build file patterns depending on task_id + task_id = task_ids{t}; + + % Detect runs for this task (if any) + run_ids = get_bids_run_ids( ... + eye_search_dirs, ... subject_full_id, ... session_id, ... - task_name, ... - physio_path ... - ); - - %% Get beh data --- - % *events file can be in 'beh' or 'physio' folder | prioritize - % 'beh' - [events_json_filepath, events_tsv_filepath, beh_json_filepath, ~, ~] = bids_find_events( ... - ses_path, ... - beh_base, ... - task_name ... + task_id ... ); - if isfile(events_json_filepath) && isfile(events_tsv_filepath) - marker_chan{1} = get_marker_data(events_json_filepath, events_tsv_filepath, true); + if isempty(run_ids) + run_ids = {''}; % placeholder: “no run” + n_runs = 1; else - marker_chan = [ ]; - warning('ID:nonexistent_file','File not found: %s', events_json_filepath); - warning('ID:nonexistent_file','File not found: %s', events_tsv_filepath); + n_runs = length(run_ids); end - % beh-file contains relevant info about stimulus presentation; - % required for eye-data - if ~isfile(beh_json_filepath) - beh_json_filepath = events_json_filepath; - warning('ID:nonexistent_file','File not found: %s. Attempting to use %s, but may result in issues downstream', beh_json_filepath, events_json_filepath); - end - - % get behave json - beh_json = get_beh_json(beh_json_filepath); - - %% --- Build the file structure --- - % Build sessions infos - - - % ses.infos.duration - will be added after alignment - - % infos.importfile - will be added before saving - dt = datetime('now'); - ses.infos.importdate = sprintf('%.2d.%.2d.%.2d', dt.Day, dt.Month, dt.Year); % same as import_eyelink and importviewpoint; - % durationinfo = 'Recording duration in seconds'; - % ses.infos.recdate - no information; - % ses.infos.rectime - no information; - - % infos.source - % ses.infos.source = struct(); - ses.infos.source = physio_eye_infos.source; - ses.infos.source.file = [physio_infos.source.file; physio_eye_infos.source.file]; - ses.infos.source.type = 'BIDS (json/tsv)'; % physio_infos.infos; - % ses.infos.source.chan_stats - will be calculted later - - if ~isempty(dataset_description); infos.DatasetDescription = dataset_description; end - % if ~isempty(fieldnames(currentParticipant)); infos.Participant = currentParticipant; end - - % data - ses.data = {}; - ses.data = [marker_chan; physio_data; physio_eye_data]; - - % Calculates the nan_ratio for all channels - fprintf("Calculate the nan_ratio for all channels\n"); - ses = pspm_update_nan_stats(ses); - - % populate fields from json - fprintf("Adding info from %s to channel headers\n", events_json_filepath); - fn = fieldnames(beh_json); - for ii = 1:numel(fn) - ses.infos.(fn{ii}) = beh_json.(fn{ii}); - end - - % Aligns all channels - [asts, ses.data, ses.infos.duration] = align_channels(ses.data); - if asts ~= 1; continue; end - - % Save session - if isempty(task_name) || length(task_ids) == 1 - ses_filename = sprintf('pspm_%s_ses-%s.mat', subject_full_id, session_id); + % If none found → process the session once without a task name + if isempty(task_ids) + task_ids = {''}; % placeholder for “no task” + fprintf('Processing %s\n [%d run(s)]', session_dirs(j).name, n_runs); else - ses_filename = sprintf('pspm_%s_ses-%s_task-%s.mat', subject_full_id, session_id, task_name); + task_list_str = strjoin(task_ids, ', '); + fprintf('Processing %s with %d task(s): %s [%d run(s)]\n', ... + session_dirs(j).name, length(task_ids), task_list_str, n_runs); end + + % loop over runs + for r = 1:numel(run_ids) + + run_id = run_ids{r}; + + %% Processing start + % read in physio data + physio_path = fullfile(ses_path, 'physio'); + [~, physio_data, physio_infos] = get_physio_data( ... + physio_path, ... + subject_full_id, ... + session_id, ... + task_id, ... + run_id ... + ); + + % read in eye-tracking data + [~, physio_eye_data, physio_eye_infos] = get_physio_eye_data( ... + eye_search_dirs, ... + subject_full_id, ... + session_id, ... + task_id, ... + run_id ... + ); + + %% Get events + % *events file can be in 'beh' or 'physio' folder | prioritize + % 'beh' + [events_tsv_filepath, events_json_filepath] = find_bids_file( ... + ses_path, ... + 'events.tsv', ... + task_id, ... + run_id ... + ); + + % read events + if isfile(events_json_filepath) && isfile(events_tsv_filepath) + fprintf('Events:\t%s\n', events_tsv_filepath); + marker_chan{1} = get_marker_data( ... + events_json_filepath, ... + events_tsv_filepath, ... + true ... + ); + else + marker_chan = [ ]; + warning('ID:nonexistent_file','File not found: %s', events_json_filepath); + warning('ID:nonexistent_file','File not found: %s', events_tsv_filepath); + end - ses_filepath = fullfile(save_path, ses_filename); - outfile{end+1} = char(ses_filepath); - ses.infos.importfile = char(ses_filepath); + % events_json_filepath contains relevant info about stimulus presentation; + event_json = extract_json_as_struct(events_json_filepath); + + %% Build the file structure + dt = datetime('now'); + ses.infos.importdate = sprintf('%.2d.%.2d.%.2d', dt.Day, dt.Month, dt.Year); % same as import_eyelink and importviewpoint; + + % infos.source + ses.infos.source = physio_eye_infos.source; + ses.infos.source.file = [physio_infos.source.file; physio_eye_infos.source.file]; + ses.infos.source.type = 'BIDS (json/tsv)'; % physio_infos.infos; + + if ~isempty(dataset_description); infos.DatasetDescription = dataset_description; end + + % data + ses.data = {}; + + % Ensure column cell arrays + marker_chan = marker_chan(:); + physio_data = physio_data(:); + physio_eye_data = physio_eye_data(:); + + ses.data = [marker_chan; physio_data; physio_eye_data]; - % Check the pspm structure - [lsts, ~, ~, ~] = pspm_load_data(ses); - if lsts < 1 - warning('ID:could_not_be_saved','The file struture has a problem'); % better warning text - continue; - end + % Calculates the nan_ratio for all channels + fprintf("Calculate the nan_ratio for all channels\n"); + ses = pspm_update_nan_stats(ses); - % saves as pspm file (overwrite) - data = ses.data; - infos = ses.infos; - save(ses_filepath,'infos', 'data'); - fprintf('\nSaved PsPM-file to ''%s''\n', ses_filepath); - + % populate fields from json + fprintf("Adding info from %s to channel headers\n", events_json_filepath); + fn = fieldnames(event_json); + for ii = 1:numel(fn) + ses.infos.(fn{ii}) = event_json.(fn{ii}); + end + + % Aligns all channels + fprintf("Aligning all channels in temporal domain\n"); + [asts, ses.data, ses.infos.duration] = align_channels(ses.data); + if asts ~= 1; continue; end + + %% Build output file + % Save session + if isempty(task_id) || length(task_ids) == 1 + ses_filename = sprintf('pspm_%s_ses-%s', subject_full_id, session_id); + else + ses_filename = sprintf('pspm_%s_ses-%s_task-%s', subject_full_id, session_id, task_id); + end + + if ~isempty(run_id) + ses_filename = sprintf('%s_run-%s', ses_filename, run_id); + end + + ses_filename = sprintf('%s.mat', ses_filename); + + ses_filepath = fullfile(save_path, ses_filename); + outfile{end+1} = char(ses_filepath); + ses.infos.importfile = char(ses_filepath); + + %% Verify output structure + % Check the pspm structure + [lsts, ~, ~, ~] = pspm_load_data(ses); + if lsts < 1 + warning('ID:could_not_be_saved','The file struture has a problem'); % better warning text + continue; + end + + % saves as pspm file (overwrite) + data = ses.data; + infos = ses.infos; + save(ses_filepath,'infos', 'data'); + fprintf('\nSaved PsPM-file to ''%s''\n', ses_filepath); + + end % close run loop + nRuns = nRuns + 1; end % close task loop nSessions = nSessions + 1; end % close ses loop @@ -345,7 +374,12 @@ sts = 1; %% footer -pspm_bids_importer_footer(nSubjects, nSessions, save_path) +pspm_bids_importer_footer( ... + nSubjects, ... + nSessions, ... + nRuns, ... + save_path ... +) end %% 4. Sub-functions --------------------------------------------------------- @@ -388,17 +422,6 @@ end end -function [infos] = get_beh_json(beh_json_filepath) - -infos = struct(); -if ~isfile(beh_json_filepath) - warning('ID:non_existent_file','Behavior sidecar JSON file not found: %s', beh_json_filepath); - return -else - infos = extract_json_as_struct(beh_json_filepath); -end -end - function [sts, data, new_duration] = align_channels(data) sts = -1; num_channels = length(data); % the marker channels have to be taken away @@ -450,25 +473,23 @@ end -function task_ids = get_bids_task_ids(physio_dir) - - files = dir(fullfile(physio_dir, '*.json')); % look at metadata files +function task_ids = get_bids_task_ids(search_dirs) task_ids = {}; - - % regexp pattern for `_task-` - expr = '(?<=_task-)[a-zA-Z0-9]+'; - - for i = 1:numel(files) - tokens = regexp(files(i).name, expr, 'match'); - if ~isempty(tokens) - task_ids{end+1} = tokens{1}; + for k = 1:numel(search_dirs) + d = search_dirs{k}; + if ~isfolder(d), continue; end + + % grab anything with task-... in the filename (events, eyetrack, physio) + files = dir(fullfile(d, '*task-*_*.tsv*')); + names = {files.name}; + for i = 1:numel(names) + tok = regexp(names{i}, 'task-([A-Za-z0-9]+)', 'tokens', 'once'); + if ~isempty(tok) + task_ids{end+1} = tok{1}; %#ok + end end end - - % return uniques (preserve order) - if ~isempty(task_ids) - task_ids = unique(task_ids, 'stable'); - end + task_ids = unique(task_ids, 'stable'); end function pspm_bids_importer_header(dataset_path, nSubjects, save_path) @@ -496,7 +517,7 @@ function pspm_bids_importer_header(dataset_path, nSubjects, save_path) end -function pspm_bids_importer_footer(nSubjects, nSessions, output_dir) +function pspm_bids_importer_footer(nSubjects, nSessions, nRuns, output_dir) timestamp = string(datetime('now', 'Format', 'yyyy-MM-dd HH:mm:ss')); fprintf('\n'); @@ -512,7 +533,11 @@ function pspm_bids_importer_footer(nSubjects, nSessions, output_dir) fprintf(' Sessions processed : %d\n', nSessions); end -if nargin >= 3 && ~isempty(output_dir) +if nargin >= 3 && ~isempty(nRuns) + fprintf(' Runs processed : %d\n', nRuns); +end + +if nargin >= 4 && ~isempty(output_dir) fprintf(' Output directory : %s\n', output_dir); end @@ -521,67 +546,6 @@ function pspm_bids_importer_footer(nSubjects, nSessions, output_dir) end -function [json_path, tsv_path, beh_path, source_dir, status] = bids_find_events(ses_path, beh_base, task_name) -% BIDS_FIND_EVENTS Locate behavioral/physio event files for a PsPM session. -% -% [json_path, tsv_path, beh_path, source_dir, status] = bids_find_events(ses_path, beh_base, task_name) -% -% - Searches in priority order: "beh" → "physio" -% - beh_base is the filename prefix (including task- if applicable) -% - task_name may be '' for sessions without tasks -% -% Returns: -% json_path Full path to events.json -% tsv_path Full path to events.tsv -% beh_path Full path to beh.json -% source_dir Directory used ('beh' or 'physio') -% status 1 if found, 0 otherwise - - json_path = ""; - tsv_path = ""; - beh_path = ""; - source_dir = ""; - status = 0; - - event_dirs = ["beh", "physio"]; % priority order - - for d = event_dirs - candidate_dir = fullfile(ses_path, d); - - % Build patterns - pattern_json = beh_base + "events.json"; - pattern_tsv = beh_base + "events.tsv"; - pattern_beh = beh_base + "beh.json"; - - % Search using dir() - json_files = dir(fullfile(candidate_dir, pattern_json)); - tsv_files = dir(fullfile(candidate_dir, pattern_tsv)); - beh_files = dir(fullfile(candidate_dir, pattern_beh)); - - if ~isempty(json_files) && ~isempty(tsv_files) - % Found matching pair - json_path = fullfile(candidate_dir, json_files(1).name); - tsv_path = fullfile(candidate_dir, tsv_files(1).name); - source_dir = d; - status = 1; - end - - if ~isempty(beh_files) - % Found matching pair - beh_path = fullfile(candidate_dir, beh_files(1).name); - end - return; - end - - % No match found - if isempty(task_name) - warning('No event files found in %s (no task).', ses_path); - else - warning('No event files found for task "%s" in %s.', task_name, ses_path); - end - -end - function ses = pspm_update_nan_stats(ses) % PSPM_UPDATE_NAN_STATS % Computes NaN ratios for each channel in ses.data @@ -623,3 +587,33 @@ function pspm_bids_importer_footer(nSubjects, nSessions, output_dir) ses.infos.source.chan_stats = chan_stats; end +function run_ids = get_bids_run_ids(search_dirs, subject_full_id, session_id, task_id) +% Returns cell array of run strings like {'01','02'} or {} if none. + +run_ids = {}; + +% Build stem up to task (if any) +if isempty(task_id) + stem = sprintf('%s_ses-%s_', subject_full_id, session_id); +else + stem = sprintf('%s_ses-%s_task-%s_', subject_full_id, session_id, task_id); +end + +for k = 1:numel(search_dirs) + d = search_dirs{k}; + if ~isfolder(d), continue; end + + % look for any run-XX entity after stem + files = dir(fullfile(d, [stem 'run-*_*.tsv*'])); + names = {files.name}; + + for i = 1:numel(names) + tok = regexp(names{i}, 'run-([0-9]+)', 'tokens', 'once'); + if ~isempty(tok) + run_ids{end+1} = tok{1}; %#ok + end + end +end + +run_ids = unique(run_ids, 'stable'); +end From 37f6ad40cf1ae73203fc176950d4f3789c13ccf7 Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Mon, 23 Mar 2026 17:26:30 +0100 Subject: [PATCH 10/20] [ENH]: make bids importer more agnostic, read in BEP020/BEP045 --- .../lib/build_pspm_eye_channels.m | 120 +++++---- src/bids_importer/lib/find_bids_file.m | 3 - src/bids_importer/lib/find_eye_files.m | 5 +- src/bids_importer/lib/find_physio_file.m | 4 +- .../lib/find_physioevents_pair.m | 2 +- src/bids_importer/lib/get_eyes_list.m | 11 +- src/bids_importer/lib/get_eyetrack_data.m | 146 ++++++----- src/bids_importer/lib/get_physio_data.m | 2 +- src/bids_importer/lib/get_physio_eye_data.m | 190 +++++++++++++- src/bids_importer/lib/normalize_eye_entries.m | 56 ++-- src/pspm_import_bids.m | 239 +++++++++++------- 11 files changed, 514 insertions(+), 264 deletions(-) diff --git a/src/bids_importer/lib/build_pspm_eye_channels.m b/src/bids_importer/lib/build_pspm_eye_channels.m index 54f0769c9..41de7ca5f 100644 --- a/src/bids_importer/lib/build_pspm_eye_channels.m +++ b/src/bids_importer/lib/build_pspm_eye_channels.m @@ -62,67 +62,89 @@ % See also % -------- % NORMALIZE_EYE_ENTRIES, FILL_EYE_CHANNEL_HEADER + data = {}; -data = {}; % output cell array of PsPM channels + if isempty(eye_data_cell) + fprintf('No eye data available\n'); + return + end -if isempty(eye_data_cell) - warning('No eye data available.'); - return -end + eyes = normalize_eye_entries(eye_data_cell); -% normalize to "eyes.r" and "eyes.l" (robust to ordering) -eyes = normalize_eye_entries(eye_data_cell); + if isempty(eyes.r) && isempty(eyes.l) + warning('No valid right/left eye entries found.'); + return + end -if isempty(eyes.r) && isempty(eyes.l) - warning('No valid right/left eye entries found.'); - return -end + sig = struct( ... + 'col', {'pupil_size', 'x_coordinate', 'y_coordinate'}, ... + 'name', {'pupil', 'gaze_x', 'gaze_y'} ... + ); -% define channel mapping once -sig = struct( ... - 'col', {'pupil_size', 'x_coordinate', 'y_coordinate'}, ... - 'name', {'pupil', 'gaze_x', 'gaze_y'} ... -); - -% create channels in consistent order: right then left -order = {'r','l'}; -ch = 0; - -for s = 1:numel(order) - side = order{s}; - m = eyes.(side); - if isempty(m), continue; end - - % warn if only one eye present - if xor(isempty(eyes.r), isempty(eyes.l)) - if side == 'r' - warning('Only right eye data available.'); - else - warning('Only left eye data available.'); - end - end + order = {'r', 'l'}; + ch = 0; - T = m.Columns; - if ~istable(T) - warning('Eye "%s" has no valid Columns table; skipping.', side); - continue - end + for s = 1:numel(order) + side = order{s}; + entry = eyes.(side); - for k = 1:numel(sig) - if ~ismember(sig(k).col, T.Properties.VariableNames) - warning('Missing column "%s" for eye "%s"; skipping channel.', sig(k).col, side); + if isempty(entry) continue end - ch = ch + 1; + [meta, T] = split_eye_entry(entry); - % base channel - data{ch}.data = T{:, sig(k).col}; - data{ch}.header.chantype = sprintf('%s_%s', sig(k).name, side); + if ~istable(T) + warning('Eye "%s" has no valid sample table; skipping.', side); + continue + end + + for k = 1:numel(sig) + if ~ismember(sig(k).col, T.Properties.VariableNames) + warning('Missing column "%s" for eye "%s"; skipping channel.', sig(k).col, side); + continue + end - % populate header fields from metadata - data{ch}.header = fill_eye_channel_header(data{ch}.header, m, sig(k).name); + ch = ch + 1; + data{ch}.data = T{:, sig(k).col}; + data{ch}.header.chantype = sprintf('%s_%s', sig(k).name, side); + data{ch}.header = fill_eye_channel_header(data{ch}.header, meta, sig(k).name); + end end end -end + +function [meta, T] = split_eye_entry(entry) +% Try to separate metadata struct from samples table. + + meta = entry; + T = []; + + % Preferred structure: entry.meta + entry.table + if isstruct(entry) + if isfield(entry, 'meta') && isstruct(entry.meta) + meta = entry.meta; + end + + if isfield(entry, 'table') && istable(entry.table) + T = entry.table; + return + end + + if isfield(entry, 'data') && istable(entry.data) + T = entry.data; + return + end + + if isfield(entry, 'tsv') && istable(entry.tsv) + T = entry.tsv; + return + end + end + + % Backward-compatible fallback: if entry.Columns is already a table + if isstruct(entry) && isfield(entry, 'Columns') && istable(entry.Columns) + T = entry.Columns; + return + end +end \ No newline at end of file diff --git a/src/bids_importer/lib/find_bids_file.m b/src/bids_importer/lib/find_bids_file.m index 0b8aa60d0..c3f33a6d1 100644 --- a/src/bids_importer/lib/find_bids_file.m +++ b/src/bids_importer/lib/find_bids_file.m @@ -63,9 +63,6 @@ end % No match found - warning('No files found in "%s" with filters: %s.', ... - ses_path, strjoin(search, ', ')); - data_file = ''; json_file = ''; end diff --git a/src/bids_importer/lib/find_eye_files.m b/src/bids_importer/lib/find_eye_files.m index 813a94a53..45dd92d4d 100644 --- a/src/bids_importer/lib/find_eye_files.m +++ b/src/bids_importer/lib/find_eye_files.m @@ -69,8 +69,9 @@ d, ... suffix, ... task_id, ... - run_id ... - ); + run_id, ... + 'eye' ..., + ); if ~isempty(ev_tsv) return diff --git a/src/bids_importer/lib/find_physio_file.m b/src/bids_importer/lib/find_physio_file.m index 7c61d5360..b9e0be384 100644 --- a/src/bids_importer/lib/find_physio_file.m +++ b/src/bids_importer/lib/find_physio_file.m @@ -1,4 +1,4 @@ -function [ev_json, ev_tsv] = find_physio_file(physio_path, modality, task_id, run_id) +function [ev_tsv, ev_json] = find_physio_file(physio_path, modality, task_id, run_id) ev_json = ""; ev_tsv = ""; @@ -16,7 +16,7 @@ % find tsv file [ev_tsv, ev_json] = find_bids_file( ... - d, ... + physio_path, ... suffix, ... task_id, ... run_id, ... diff --git a/src/bids_importer/lib/find_physioevents_pair.m b/src/bids_importer/lib/find_physioevents_pair.m index 3941bbff4..497632c98 100644 --- a/src/bids_importer/lib/find_physioevents_pair.m +++ b/src/bids_importer/lib/find_physioevents_pair.m @@ -9,7 +9,7 @@ ev_json = ""; ev_tsv = ""; -suffix = 'physioevents.tsv'; +suffix = 'physioevents.tsv.gz'; % look in dirs for k = 1:numel(candidate_paths) diff --git a/src/bids_importer/lib/get_eyes_list.m b/src/bids_importer/lib/get_eyes_list.m index 4c9bec395..0967cfded 100644 --- a/src/bids_importer/lib/get_eyes_list.m +++ b/src/bids_importer/lib/get_eyes_list.m @@ -2,14 +2,15 @@ eyes = {}; -% Loop through each file -for i = 1:length(files) - % Get the filename +for i = 1:numel(files) filename = files{i}; + token = regexp(filename, 'recording-(eye\d+)', 'tokens', 'once'); - eyes{end+1} = token{1}; + + if ~isempty(token) + eyes{end+1} = token{1}; + end end -% Get unique list of eyes eyes = unique(eyes); end \ No newline at end of file diff --git a/src/bids_importer/lib/get_eyetrack_data.m b/src/bids_importer/lib/get_eyetrack_data.m index 7750e93f8..c6e0c9d6c 100644 --- a/src/bids_importer/lib/get_eyetrack_data.m +++ b/src/bids_importer/lib/get_eyetrack_data.m @@ -16,73 +16,83 @@ % [eye_data, dur, info] = get_eye_data('sub-CalinetWuerzburg01','01','FearAcquisition', '/path/to/physio'); %% Find all 'tsv.gz' files in session directory -eye_files = find_eye_files( ... - candidate_paths, ... - task_id, ... - run_id ... -); - -% enfore cell -if isstring(eye_files) - eye_files = cellstr(eye_files); -end - -% If eye_files is a char (single path): -if ischar(eye_files) - eye_files = {eye_files}; -end - -%% Initialize the cell array and info variables - -sts = -1; -eye_signals = get_eyes_list(eye_files); -eye_data_cell = {}; - -if isempty(eye_signals) - warning('No eye data found for subject %s session %s', subject_id,session_id); -else % ------ % - -num_signals = length(eye_signals); -eye_data_cell = cell(num_signals, 1); -chan_names = cell(num_signals, 1); - -%% Process each eye channel -for i = 1:num_signals - signal = eye_signals{i}; - eye_tsv_filepath = eye_files{i}; - eye_json_filepath = regexprep(eye_tsv_filepath, '\.tsv\.gz$', '.json'); - - % Check if files exist - if ~isfile(eye_json_filepath); warning('File not found: %s', eye_json_filepath); sts = -1 ;end - if ~isfile(eye_tsv_filepath); warning('File not found: %s', eye_tsv_filepath); sts = -1 ; end - - fprintf('%s:\t%s\n', signal, eye_tsv_filepath); - - % Read JSON metadata (assumed to be converted into a struct) - eye_json = extract_json_as_struct(eye_json_filepath); - - % Read TSV data. - headings = eye_json.Columns; - col_types = repmat({'double'}, 1, length(headings)); - - % read_data_from_tsv is assumed to return a numeric matrix with dimensions [n_samples x n_columns] - eye_data_table = read_data_from_tsv( ... - eye_tsv_filepath, ... - false, ... - headings.', ... - col_types ... - ); - - % Combine the JSON metadata with the TSV data. - % I the futrure some kind of check maybe? - eye_json.Columns = eye_data_table; - - % Store the combined struct into the cell array - eye_data_cell{i} = eye_json; - eye_data_cell{i}.source.file = [{eye_json_filepath}, {eye_tsv_filepath}]; - -end - sts = 1; -end + eye_files = find_eye_files(candidate_paths, task_id, run_id); + if isstring(eye_files) + eye_files = cellstr(eye_files); + end + if ischar(eye_files) + eye_files = {eye_files}; + end + + sts = -1; + eye_data_cell = {}; + + eye_signals = get_eyes_list(eye_files); + + if isempty(eye_signals) + return + end + + num_signals = numel(eye_signals); + eye_data_cell = cell(num_signals, 1); + + for i = 1:num_signals + signal = eye_signals{i}; + + % Find the matching TSV for this eye signal + match_idx = find(contains(eye_files, ['recording-' signal '_']), 1); + + if isempty(match_idx) + warning('No TSV file found for eye signal %s', signal); + continue + end + + eye_tsv_filepath = eye_files{match_idx}; + eye_json_filepath = regexprep(eye_tsv_filepath, '\.tsv(\.gz)?$', '.json'); + + if ~isfile(eye_json_filepath) + warning('File not found: %s', eye_json_filepath); + continue + end + if ~isfile(eye_tsv_filepath) + warning('File not found: %s', eye_tsv_filepath); + continue + end + + fprintf('%s:\t%s\n', signal, eye_tsv_filepath); + + % Read metadata + eye_meta = extract_json_as_struct(eye_json_filepath); + + % Read samples + headings = eye_meta.Columns; + col_types = repmat({'double'}, 1, numel(headings)); + + eye_table = read_data_from_tsv( ... + eye_tsv_filepath, ... + false, ... + headings.', ... + col_types ... + ); + + % Store metadata + table explicitly + entry = struct(); + entry.meta = eye_meta; + entry.table = eye_table; + entry.signal = signal; + entry.source = struct( ... + 'json_file', eye_json_filepath, ... + 'tsv_file', eye_tsv_filepath ... + ); + + eye_data_cell{i} = entry; + end + + % remove empty cells if any entries were skipped + eye_data_cell = eye_data_cell(~cellfun('isempty', eye_data_cell)); + + if ~isempty(eye_data_cell) + sts = 1; + end end \ No newline at end of file diff --git a/src/bids_importer/lib/get_physio_data.m b/src/bids_importer/lib/get_physio_data.m index 37633ecad..17012e449 100644 --- a/src/bids_importer/lib/get_physio_data.m +++ b/src/bids_importer/lib/get_physio_data.m @@ -26,7 +26,7 @@ task_id, ... run_id ... ); - + %% Check if files exist % The warning could be confusing if ~isfile(physio_json_filepath) || ~isfile(physio_tsv_filepath) diff --git a/src/bids_importer/lib/get_physio_eye_data.m b/src/bids_importer/lib/get_physio_eye_data.m index 76563f245..cce1e7bcc 100644 --- a/src/bids_importer/lib/get_physio_eye_data.m +++ b/src/bids_importer/lib/get_physio_eye_data.m @@ -15,7 +15,6 @@ ); if ests < 1 || isempty(eye_data_cell) - warning('No eye data imported.'); sts = -1; return end @@ -37,10 +36,6 @@ end %% Physioevents search dirs -% Prefer the folder where the eye file actually is, then try siblings under ses-* -sf = eye_data_cell{1}.source.file; -if iscell(sf), sf = sf{1}; end - [events_tsv_filepath, events_json_filepath] = find_physioevents_pair( ... candidate_paths, ... task_id, ... @@ -48,9 +43,11 @@ ); if strlength(events_json_filepath) > 0 && strlength(events_tsv_filepath) > 0 + + % read physioevents.tsv.gz data_events = get_physio_events_data( ... - char(events_json_filepath), ... - char(events_tsv_filepath), ... + events_json_filepath, ... + events_tsv_filepath, ... false ... ); @@ -58,17 +55,28 @@ for i = 1:numel(data_events) data_events{i}.header.StartTime = startTimeRef; end - data = [data; data_events]; + data = [data, data_events.']; else warning('No events for physio eye data were imported.'); end else - % keep as warning or make it silent, your call - if isempty(task_id) - warning('No physioevents found for %s (ses-%s).', subject_id, session_id); - else - warning('No physioevents found for %s (ses-%s, task-%s).', subject_id, session_id, task_id); + parts = {subject_id}; + + if ~isempty(session_id) + parts{end+1} = sprintf('ses-%s', session_id); + end + + if ~isempty(task_id) + parts{end+1} = sprintf('task-%s', task_id); + end + + if exist('run_id','var') && ~isempty(run_id) + parts{end+1} = sprintf('run-%s', run_id); end + + msg = strjoin(parts, ', '); + + warning('No physioevents found for %s.', msg); end %% Build infos.source.file @@ -107,6 +115,162 @@ sts = 1; end + +function data = get_physio_events_data(events_json_filepath, events_tsv_filepath, noColumnField) +%GET_PHYSIO_EVENTS_DATA Read BIDS physio/events data and build binary PsPM channels. +% +% Reads events metadata from JSON and sample/event rows from TSV/TSV.GZ. +% Creates binary channels for: +% - blink +% - saccade +% - fixation +% +% Output: +% data{s,1}.data binary vector +% data{s,1}.header.chantype e.g. 'blink_c' +% data{s,1}.header.units event label +% data{s,1}.header.sr sampling rate +% data{s,1}.header.StartTime + + data = {}; + sr = 1; % default fallback + has_headings = true; + col_types = {'double', 'double', 'char', 'char', 'char'}; + + % Read JSON metadata + event_json = extract_json_as_struct(events_json_filepath); + + if noColumnField + headings = fieldnames(event_json).'; + elseif isfield(event_json, 'Columns') + headings = event_json.Columns; + else + headings = []; + end + + % Read TSV / TSV.GZ + marker_tsv_data_table = read_data_from_tsv( ... + events_tsv_filepath, ... + false, ... + headings.', ... + col_types ... + ); + + if ~istable(marker_tsv_data_table) + warning('Could not read physio events table from %s', events_tsv_filepath); + data = -1; + return + end + + required_vars = {'onset', 'duration'}; + if ~all(ismember(required_vars, marker_tsv_data_table.Properties.VariableNames)) + warning('Physio events table is missing required columns in %s', events_tsv_filepath); + data = -1; + return + end + + has_event_type = ismember('event_type', marker_tsv_data_table.Properties.VariableNames); + has_trial_type = ismember('trial_type', marker_tsv_data_table.Properties.VariableNames); + + if ~has_event_type && ~has_trial_type + warning('Physio events table must contain either "event_type" or "trial_type" in %s', events_tsv_filepath); + data = -1; + return + end + + if has_event_type + event_type = string(marker_tsv_data_table.event_type); + else + event_type = string(marker_tsv_data_table.trial_type); + end + + if ~ismember('message', marker_tsv_data_table.Properties.VariableNames) + marker_tsv_data_table.message = repmat({''}, height(marker_tsv_data_table), 1); + end + + % Checks if it is a proper physio eye event data + if ~any(ismember(marker_tsv_data_table.Properties.VariableNames, {'blink', 'message'})) ... + && ~any(strcmp(marker_tsv_data_table.event_type, 'blink')) ... + && ~any(strcmp(marker_tsv_data_table.event_type, 'saccade')) ... + && ~any(strcmp(marker_tsv_data_table.event_type, 'fixation')) + warning('No physio events found in %s', events_tsv_filepath); + data = -1; + return + end + + % Try to recover sampling rate from RECCFG message + indices_reccfg = find(contains(string(marker_tsv_data_table.message), 'RECCFG'), 1); + + if ~isempty(indices_reccfg) + reccfg = split(string(marker_tsv_data_table.message(indices_reccfg))); + if numel(reccfg) >= 3 + sr_candidate = str2double(reccfg{3}); + if ~isnan(sr_candidate) && sr_candidate > 0 + sr = sr_candidate; + end + end + elseif isfield(event_json, 'SamplingFrequency') + sr_candidate = event_json.SamplingFrequency; + if isnumeric(sr_candidate) && isscalar(sr_candidate) && sr_candidate > 0 + sr = sr_candidate; + end + end + + % Remove header/config rows if present + idx_header = strcmp(event_type, 'n/a') & ... + ~strcmp(string(marker_tsv_data_table.message), 'CS'); + + idx_data = ~idx_header; + + onsets = marker_tsv_data_table.onset(idx_data); + duration = marker_tsv_data_table.duration(idx_data); + event_type = event_type(idx_data); + + if isempty(onsets) + warning('No usable physio events found in %s', events_tsv_filepath); + data = -1; + return + end + + % Shift first usable event to zero + onsets = onsets - onsets(1); + + signal_names = {'blink', 'saccade', 'fixation'}; + channel_names = {'blink_c', 'saccade_c', 'fixation_c'}; + + % Determine output length in samples + end_times = onsets + duration; + n_samples = max(1, ceil(max(end_times) * sr)); + + for s = 1:numel(signal_names) + idx_signal = strcmp(event_type, signal_names{s}); + + data_signal = zeros(n_samples, 1); + + if any(idx_signal) + starts_sec = onsets(idx_signal); + ends_sec = onsets(idx_signal) + duration(idx_signal); + + starts_idx = max(1, floor(starts_sec * sr) + 1); + ends_idx = min(n_samples, ceil(ends_sec * sr)); + + for i = 1:numel(starts_idx) + if ends_idx(i) >= starts_idx(i) + data_signal(starts_idx(i):ends_idx(i)) = 1; + end + end + end + + data{s,1}.data = data_signal; + data{s,1}.header = struct(); + data{s,1}.header.chantype = channel_names{s}; + data{s,1}.header.units = signal_names{s}; + data{s,1}.header.sr = sr; + data{s,1}.header.StartTime = 0; + end +end + + % adapted from in pspm_get_viewpoint and pspm_get_smi function best_eye = eye_with_smaller_nan_ratio(data, eyes_observed) diff --git a/src/bids_importer/lib/normalize_eye_entries.m b/src/bids_importer/lib/normalize_eye_entries.m index 9ff43006e..e31bb48c9 100644 --- a/src/bids_importer/lib/normalize_eye_entries.m +++ b/src/bids_importer/lib/normalize_eye_entries.m @@ -1,43 +1,29 @@ function eyes = normalize_eye_entries(eye_data_cell) -% Returns struct with fields .r and .l containing the chosen metadata entry for each eye. -eyes = struct('r', [], 'l', []); + eyes = struct('r', [], 'l', []); -for i = 1:numel(eye_data_cell) - e = eye_data_cell{i}; + for i = 1:numel(eye_data_cell) + entry = eye_data_cell{i}; - if ~isstruct(e) || ~isfield(e, 'RecordedEye') || isempty(e.RecordedEye) - warning('Eye entry %d missing RecordedEye; skipping.', i); - continue - end + if ~isstruct(entry) || ~isfield(entry, 'meta') || ~isstruct(entry.meta) + warning('Eye entry %d missing metadata; skipping.', i); + continue + end + + meta = entry.meta; - side = lower(string(e.RecordedEye)); - if side == "right" - if isempty(eyes.r) - eyes.r = e; - else - warning('Multiple right-eye entries found; keeping the first.'); + if ~isfield(meta, 'RecordedEye') || isempty(meta.RecordedEye) + warning('Eye entry %d missing RecordedEye; skipping.', i); + continue end - elseif side == "left" - if isempty(eyes.l) - eyes.l = e; - else - warning('Multiple left-eye entries found; keeping the first.'); + + switch lower(string(meta.RecordedEye)) + case "right" + eyes.r = entry; + case "left" + eyes.l = entry; + otherwise + warning('Eye entry %d has unknown RecordedEye: %s', i, string(meta.RecordedEye)); end - else - warning('Unknown RecordedEye "%s" in entry %d; skipping.', side, i); end -end - -% If RecordedEye wasn’t reliable but there is exactly 1 entry, use it for both -if isempty(eyes.r) && isempty(eyes.l) && numel(eye_data_cell) == 1 - warning('RecordedEye not usable; using the only entry as both r and l.'); - eyes.r = eye_data_cell{1}; - eyes.l = eye_data_cell{1}; -elseif numel(eye_data_cell) == 1 - % single-eye case: mirror onto missing side (so downstream doesn’t crash) - if isempty(eyes.r) && ~isempty(eyes.l), eyes.r = eyes.l; end - if isempty(eyes.l) && ~isempty(eyes.r), eyes.l = eyes.r; end -end - -end +end \ No newline at end of file diff --git a/src/pspm_import_bids.m b/src/pspm_import_bids.m index d65398456..69594e86e 100644 --- a/src/pspm_import_bids.m +++ b/src/pspm_import_bids.m @@ -162,6 +162,7 @@ nSubjects = 0; nSessions = 0; +nTasks = 0; nRuns = 0; %% 3. Loop over subjects --------------------------------------------------- for i = 1:length(subject_list) @@ -169,8 +170,8 @@ subject_full_id = subject_list(i).name; % e.g., 'sub-CalinetBonn01 sub_idx_str = regexp(subject_full_id, '\d+$', 'match', 'once'); - fprintf('Importing %s ... \n', subject_full_id); - + fprintf('Importing %s\n', subject_full_id); + if dataset_mode % current subject path sub_path = fullfile(dataset_path, subject_full_id); @@ -182,6 +183,12 @@ session_dirs = dir(fullfile(sub_path,'ses-*')); session_dirs = session_dirs([session_dirs.isdir]); session_dirs = session_dirs(~ismember({session_dirs.name}, {'.','..'})); + + if isempty(session_dirs) + % Treat subject folder as a single "session" + session_dirs = struct('name', ''); % empty name signals no ses level + end + end % checks if there are sessions @@ -189,26 +196,38 @@ %% Process each session for j = 1:length(session_dirs) - session_id = session_dirs(j).name(5:end); % e.g., '01' or '02' (could there be more 100 sessions?) - ses_path = fullfile(sub_path,session_dirs(j).name); + + if isempty(session_dirs(j).name) + % No session folders → operate directly in subject folder + session_id = ''; + ses_path = sub_path; + else + session_id = session_dirs(j).name(5:end); % e.g., '01' + ses_path = fullfile(sub_path, session_dirs(j).name); + end % eye-tracking files can live in 'beh', 'physio', or any modality they have been acquired in % concurrently (e.g., 'func' during fMRI) [BEP020]. % SCR and other data will live in 'physio' [BEP045] - eye_search_dirs = { ... - fullfile(ses_path,'beh'), ... - fullfile(ses_path,'physio'), ... - fullfile(ses_path,'func') ... + physio_search_dirs = { ... + fullfile(ses_path, 'beh'), ... + fullfile(ses_path, 'physio'), ... + fullfile(ses_path, 'func') ... }; % keep only those that exist - eye_search_dirs = eye_search_dirs(cellfun(@isfolder, eye_search_dirs)); - + physio_search_dirs = physio_search_dirs(cellfun(@isfolder, physio_search_dirs)); + fprintf('\n--------------------------------------------------------------------------------\n'); + if isempty(session_id) + fprintf('Subject-level import (no session folder)\n'); + else + fprintf('Session: ses-%s\n', session_id); + end %% Extract task name % Look for any event JSON in the beh and physio folders - task_ids = get_bids_task_ids(eye_search_dirs); + task_ids = get_bids_task_ids(physio_search_dirs); % loop over tasks for t = 1:numel(task_ids) @@ -218,7 +237,7 @@ % Detect runs for this task (if any) run_ids = get_bids_run_ids( ... - eye_search_dirs, ... + physio_search_dirs, ... subject_full_id, ... session_id, ... task_id ... @@ -226,26 +245,21 @@ if isempty(run_ids) run_ids = {''}; % placeholder: “no run” - n_runs = 1; else - n_runs = length(run_ids); end % If none found → process the session once without a task name - if isempty(task_ids) - task_ids = {''}; % placeholder for “no task” - fprintf('Processing %s\n [%d run(s)]', session_dirs(j).name, n_runs); - else - task_list_str = strjoin(task_ids, ', '); - fprintf('Processing %s with %d task(s): %s [%d run(s)]\n', ... - session_dirs(j).name, length(task_ids), task_list_str, n_runs); + if ~isempty(task_id) + fprintf('Task:\t%s\n', task_id); end - % loop over runs for r = 1:numel(run_ids) run_id = run_ids{r}; - + if ~isempty(run_id) + fprintf('Run:\trun-%d\n', run_id); + end + %% Processing start % read in physio data physio_path = fullfile(ses_path, 'physio'); @@ -259,13 +273,13 @@ % read in eye-tracking data [~, physio_eye_data, physio_eye_infos] = get_physio_eye_data( ... - eye_search_dirs, ... + physio_search_dirs, ... subject_full_id, ... session_id, ... task_id, ... run_id ... ); - + %% Get events % *events file can be in 'beh' or 'physio' folder | prioritize % 'beh' @@ -298,9 +312,21 @@ ses.infos.importdate = sprintf('%.2d.%.2d.%.2d', dt.Day, dt.Month, dt.Year); % same as import_eyelink and importviewpoint; % infos.source - ses.infos.source = physio_eye_infos.source; - ses.infos.source.file = [physio_infos.source.file; physio_eye_infos.source.file]; - ses.infos.source.type = 'BIDS (json/tsv)'; % physio_infos.infos; + ses.infos.source = struct(); + ses.infos.source.type = 'BIDS (json/tsv)'; + ses.infos.source.file = {}; + + if exist('physio_infos', 'var') && ~isempty(physio_infos) && ... + isfield(physio_infos, 'source') && isfield(physio_infos.source, 'file') && ... + ~isempty(physio_infos.source.file) + ses.infos.source.file = [ses.infos.source.file; physio_infos.source.file]; + end + + if exist('physio_eye_infos', 'var') && ~isempty(physio_eye_infos) && ... + isfield(physio_eye_infos, 'source') && isfield(physio_eye_infos.source, 'file') && ... + ~isempty(physio_eye_infos.source.file) + ses.infos.source.file = [ses.infos.source.file; physio_eye_infos.source.file]; + end if ~isempty(dataset_description); infos.DatasetDescription = dataset_description; end @@ -315,7 +341,7 @@ ses.data = [marker_chan; physio_data; physio_eye_data]; % Calculates the nan_ratio for all channels - fprintf("Calculate the nan_ratio for all channels\n"); + fprintf("\nCalculate the nan_ratio for all channels\n"); ses = pspm_update_nan_stats(ses); % populate fields from json @@ -326,23 +352,28 @@ end % Aligns all channels - fprintf("Aligning all channels in temporal domain\n"); - [asts, ses.data, ses.infos.duration] = align_channels(ses.data); + fprintf("Clip to shortest duration\n"); + [asts, ses.data, duration] = pspm_clip_channels_to_shortest(ses.data); if asts ~= 1; continue; end - + fprintf("New duration: %.2f seconds\n", duration); + ses.infos.duration = duration; + %% Build output file - % Save session - if isempty(task_id) || length(task_ids) == 1 - ses_filename = sprintf('pspm_%s_ses-%s', subject_full_id, session_id); - else - ses_filename = sprintf('pspm_%s_ses-%s_task-%s', subject_full_id, session_id, task_id); + parts = {['pspm_' subject_full_id]}; + + if ~isempty(session_id) + parts{end+1} = sprintf('ses-%s', session_id); + end + + if ~isempty(task_id) && numel(task_ids) > 1 + parts{end+1} = sprintf('task-%s', task_id); end - if ~isempty(run_id) - ses_filename = sprintf('%s_run-%s', ses_filename, run_id); + if ~isempty(run_id) && numel(run_ids) > 1 + parts{end+1} = sprintf('run-%d', run_id); end - ses_filename = sprintf('%s.mat', ses_filename); + ses_filename = [strjoin(parts, '_') '.mat']; ses_filepath = fullfile(save_path, ses_filename); outfile{end+1} = char(ses_filepath); @@ -360,10 +391,13 @@ data = ses.data; infos = ses.infos; save(ses_filepath,'infos', 'data'); - fprintf('\nSaved PsPM-file to ''%s''\n', ses_filepath); + fprintf('Saved PsPM-file to ''%s''\n', ses_filepath); + fprintf('\n--------------------------------------------------------------------------------\n'); + nRuns = nRuns + 1; + end % close run loop - nRuns = nRuns + 1; + nTasks = nTasks + 1; end % close task loop nSessions = nSessions + 1; end % close ses loop @@ -378,6 +412,7 @@ nSubjects, ... nSessions, ... nRuns, ... + nTasks, ... save_path ... ) end @@ -404,6 +439,7 @@ end end + % Could be implemented in the future function [participants_data, column_headings] = read_participants_data(dataset_path) % Imports the participant data from participants.tsv (independent the participiants.json) @@ -422,57 +458,83 @@ end end -function [sts, data, new_duration] = align_channels(data) -sts = -1; -num_channels = length(data); % the marker channels have to be taken away -startTimes = zeros(num_channels,1); - -% Determine start time for each channel (assume 0 if missing) -for i = 1:num_channels - if isfield(data{i}.header, 'StartTime') - startTimes(i) = data{i}.header.StartTime; % assuming seconds - else - startTimes(i) = 0; - data{i}.header.StartTime = 0; + +function [sts, data, duration] = pspm_clip_channels_to_shortest(data, induration) +% Clip continuous channels to the shortest common duration. +% Optionally also clip to induration if provided (>0). + + sts = -1; + + if nargin < 2 || isempty(induration) + induration = 0; end -end -global_min = min(startTimes(~isnan(startTimes))); % excludes marker -finalLengths = zeros(num_channels,1); + if ~(isnumeric(induration) && isscalar(induration)) + warning('ID:invalid_input', 'induration must be a numeric scalar'); + duration = []; + return + end -for i = 1:num_channels - shift_sec = data{i}.header.StartTime - global_min; + n = numel(data); + is_event = false(1, n); + durations = nan(1, n); - % Check if this channel is an event channel. - if isfield(data{i}, 'markerinfo') - data{i}.data = data{i}.data - global_min; - data{i}.header.StartTime = data{i}.data(1); - else - if ~isfield(data{i}.header, 'sr') - warning('ID:non_existent_field','Channel %d is missing sampling rate (sr) in its header. This will lead to problems later.', i); - continue; + for k = 1:n + units = ""; + if isfield(data{k}, 'header') && isfield(data{k}.header, 'units') && ~isempty(data{k}.header.units) + units = string(data{k}.header.units); end - sr = data{i}.header.sr; - numPad = round(shift_sec * sr); - % Prepadded zeros to the data vector. - data{i}.data = [zeros(numPad, 1); data{i}.data]; + is_event(k) = strcmpi(units, "events"); - % Record the new length. - finalLengths(i) = length(data{i}.data)/sr; - data{i}.header.StartTime = 0; - end -end + if is_event(k) + % event channels do not define the target duration + durations(k) = NaN; + else + if ~isfield(data{k}.header, 'sr') || isempty(data{k}.header.sr) || data{k}.header.sr <= 0 + warning('Channel %d (%s) has invalid sampling rate.', k, data{k}.header.chantype); + duration = []; + return + end + durations(k) = numel(data{k}.data) / double(data{k}.header.sr); + end + end -% Padding at the end -[sts, data, new_duration] = pspm_align_channels(data); % can the fprint be turned off? -if sts ~= 1 % if all are the same size does it give en error? - warning('ID:channel_alignment_failed','Channel alignment failed.'); - return -end + cont_durations = durations(~isnan(durations)); + if isempty(cont_durations) + warning('No continuous channels found.'); + duration = []; + return + end + + duration = min(cont_durations); + if induration > 0 + duration = min(duration, induration); + end + for k = 1:n + if is_event(k) + if ~isempty(data{k}.data) + data{k}.data = data{k}.data(data{k}.data <= duration); + end + else + sr = double(data{k}.header.sr); + n_keep = floor(duration * sr); + n_keep = min(n_keep, numel(data{k}.data)); + + data{k}.data = data{k}.data(1:n_keep); + + % update header + data{k}.header.duration = duration; + data{k}.header.nsamples = n_keep; + data{k}.header.StartTime = 0; + end + end + + sts = 1; end + function task_ids = get_bids_task_ids(search_dirs) task_ids = {}; for k = 1:numel(search_dirs) @@ -492,6 +554,7 @@ task_ids = unique(task_ids, 'stable'); end + function pspm_bids_importer_header(dataset_path, nSubjects, save_path) % Detect PsPM version if available @@ -517,10 +580,10 @@ function pspm_bids_importer_header(dataset_path, nSubjects, save_path) end -function pspm_bids_importer_footer(nSubjects, nSessions, nRuns, output_dir) + +function pspm_bids_importer_footer(nSubjects, nSessions, nRuns, nTasks, output_dir) timestamp = string(datetime('now', 'Format', 'yyyy-MM-dd HH:mm:ss')); -fprintf('\n'); fprintf('================================================================================\n'); fprintf(' BIDS Import Completed Successfully\n'); fprintf('--------------------------------------------------------------------------------\n'); @@ -537,7 +600,11 @@ function pspm_bids_importer_footer(nSubjects, nSessions, nRuns, output_dir) fprintf(' Runs processed : %d\n', nRuns); end -if nargin >= 4 && ~isempty(output_dir) +if nargin >= 4 && ~isempty(nTasks) + fprintf(' Tasks processed : %d\n', nTasks); +end + +if nargin >= 5 && ~isempty(output_dir) fprintf(' Output directory : %s\n', output_dir); end @@ -546,6 +613,7 @@ function pspm_bids_importer_footer(nSubjects, nSessions, nRuns, output_dir) end + function ses = pspm_update_nan_stats(ses) % PSPM_UPDATE_NAN_STATS % Computes NaN ratios for each channel in ses.data @@ -587,6 +655,7 @@ function pspm_bids_importer_footer(nSubjects, nSessions, nRuns, output_dir) ses.infos.source.chan_stats = chan_stats; end + function run_ids = get_bids_run_ids(search_dirs, subject_full_id, session_id, task_id) % Returns cell array of run strings like {'01','02'} or {} if none. From 78721b96c7084898d9de1dccf30096b2249ab712 Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Mon, 23 Mar 2026 17:46:16 +0100 Subject: [PATCH 11/20] docs + fprintf --- src/bids_importer/lib/get_physio_eye_data.m | 3 +- src/pspm_import_bids.m | 44 +++++++++++---------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/src/bids_importer/lib/get_physio_eye_data.m b/src/bids_importer/lib/get_physio_eye_data.m index cce1e7bcc..d185deade 100644 --- a/src/bids_importer/lib/get_physio_eye_data.m +++ b/src/bids_importer/lib/get_physio_eye_data.m @@ -45,6 +45,7 @@ if strlength(events_json_filepath) > 0 && strlength(events_tsv_filepath) > 0 % read physioevents.tsv.gz + fprintf('PEVs:\t%s\n', events_tsv_filepath); data_events = get_physio_events_data( ... events_json_filepath, ... events_tsv_filepath, ... @@ -56,8 +57,6 @@ data_events{i}.header.StartTime = startTimeRef; end data = [data, data_events.']; - else - warning('No events for physio eye data were imported.'); end else parts = {subject_id}; diff --git a/src/pspm_import_bids.m b/src/pspm_import_bids.m index 69594e86e..81648122d 100644 --- a/src/pspm_import_bids.m +++ b/src/pspm_import_bids.m @@ -14,22 +14,22 @@ % Example supported layout (multi-task session, no 'beh' folder): % % sub-/ -% └── +% └── ses- % └── physio -% ├── sub-__task-Acquisition_events.json -% ├── sub-__task-Acquisition_events.tsv -% ├── sub-__task-Acquisition_physioevents.json -% ├── sub-__task-Acquisition_physioevents.tsv -% ├── sub-__task-Acquisition_recording-ecg_physio.json -% ├── sub-__task-Acquisition_recording-ecg_physio.tsv -% ├── sub-__task-Acquisition_recording-eye1_physio.json -% ├── sub-__task-Acquisition_recording-eye1_physio.tsv -% ├── sub-__task-Acquisition_recording-eye2_physio.json -% ├── sub-__task-Acquisition_recording-eye2_physio.tsv -% ├── sub-__task-Acquisition_recording-scr_physio.json -% ├── sub-__task-Acquisition_recording-scr_physio.tsv -% ├── sub-__task-Extinction_... (same pattern) -% ├── sub-__task-Habituation_... (same pattern) +% ├── sub-_ses-_task-Acquisition_events.json +% ├── sub-_ses-_task-Acquisition_events.tsv +% ├── sub-_ses-_task-Acquisition_physioevents.json +% ├── sub-_ses-_task-Acquisition_physioevents.tsv.gz +% ├── sub-_ses-_task-Acquisition_recording-ecg_physio.json +% ├── sub-_ses-_task-Acquisition_recording-ecg_physio.tsv.gz +% ├── sub-_ses-_task-Acquisition_recording-eye1_physio.json +% ├── sub-_ses-_task-Acquisition_recording-eye1_physio.tsv.gz +% ├── sub-_ses-_task-Acquisition_recording-eye2_physio.json +% ├── sub-_ses-_task-Acquisition_recording-eye2_physio.tsv.gz +% ├── sub-_ses-_task-Acquisition_recording-scr_physio.json +% ├── sub-_ses-_task-Acquisition_recording-scr_physio.tsv.gz +% ├── sub-_ses-_task-Extinction_... (same pattern) +% ├── sub-_ses-_task-Habituation_... (same pattern) % % Example supported layout (tasks split per session, no 'task' ID, eyetracker data only): % @@ -39,9 +39,9 @@ % ├── sub-_ses-01_events.json % ├── sub-_ses-01_events.tsv % ├── sub-_ses-01_recording-eye1_physio.json -% ├── sub-_ses-01_recording-eye1_physio.tsv +% ├── sub-_ses-01_recording-eye1_physio.tsv.gz % ├── sub-_ses-01_recording-eye2_physio.json -% └── sub-_ses-01_recording-eye2_physio.tsv +% └── sub-_ses-01_recording-eye2_physio.tsv.gz % % ● Format % [sts, outfile] = pspm_import_bids(dataset_path, save_path) @@ -76,7 +76,13 @@ % - % - Extra support for run-specific inputs % -%% 1. Initialize ----------------------------------------------------------- +% 23.34.2026: +% - Update for BEP045 +% - Assume channels are aligned already, just clip to shortest dura- +% tion. This avoids the scenario where already-aligned channels are +% separated in time if the number of samples do not align. +% +%% 1. Initialize global settings if isempty(settings) pspm_init; @@ -188,7 +194,6 @@ % Treat subject folder as a single "session" session_dirs = struct('name', ''); % empty name signals no ses level end - end % checks if there are sessions @@ -245,7 +250,6 @@ if isempty(run_ids) run_ids = {''}; % placeholder: “no run” - else end % If none found → process the session once without a task name From a28828f3b674b985cd4a8e54a4aae4d3c479c163 Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Mon, 23 Mar 2026 17:47:39 +0100 Subject: [PATCH 12/20] fix date --- src/pspm_import_bids.m | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pspm_import_bids.m b/src/pspm_import_bids.m index 81648122d..5466c1531 100644 --- a/src/pspm_import_bids.m +++ b/src/pspm_import_bids.m @@ -62,21 +62,21 @@ % Dominik R. Bach, % Bernhard A. von Raußendorf (University of Bonn) % -% 05.12.2025: +% 05.12.2025 (Jurjen Heij): % - Overall updates on logic and flow % - Addded support for multiple tasks within a single session. % - Abstracted away some logic in separate functions % - Updated handling of 'save_path' argument % - Prettify interface % -% 16.02.2026: +% 16.02.2026 (Jurjen Heij): % - Update to support BEP020 % - No specific 'beh'-folder -> events are linked by task/run % - tsv.gz files instead of tsv % - % - Extra support for run-specific inputs % -% 23.34.2026: +% 23.03.2026 (Jurjen Heij): % - Update for BEP045 % - Assume channels are aligned already, just clip to shortest dura- % tion. This avoids the scenario where already-aligned channels are From 09158aa90b7aeef8d23b5c123bbceb8aa5c131fb Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Tue, 24 Mar 2026 19:35:19 +0100 Subject: [PATCH 13/20] FIX crucial error: read data from signal-column, rather than 1st index --- src/bids_importer/lib/get_physio_data.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bids_importer/lib/get_physio_data.m b/src/bids_importer/lib/get_physio_data.m index 17012e449..c557cb01a 100644 --- a/src/bids_importer/lib/get_physio_data.m +++ b/src/bids_importer/lib/get_physio_data.m @@ -49,7 +49,7 @@ headings.', ... col_types ... ); - + % Create channel struct chan = struct(); @@ -67,7 +67,7 @@ end % Assign data - chan.data = physio_data_table.(headings{1}); + chan.data = physio_data_table.(headings{strcmp(headings, signal)}); % Add to physio data cell array physio_data{cell_index,1} = chan; %#ok<*AGROW> From 3dfc84a9a102b166fc0df19663eb6e9015abb1a5 Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Tue, 24 Mar 2026 19:35:37 +0100 Subject: [PATCH 14/20] Simplify callsign --- src/bids_importer/lib/get_eyetrack_data.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bids_importer/lib/get_eyetrack_data.m b/src/bids_importer/lib/get_eyetrack_data.m index c6e0c9d6c..82c35d814 100644 --- a/src/bids_importer/lib/get_eyetrack_data.m +++ b/src/bids_importer/lib/get_eyetrack_data.m @@ -1,4 +1,4 @@ -function [sts, eye_data_cell] = get_eyetrack_data(candidate_paths, subject_id, session_id, task_id, run_id) +function [sts, eye_data_cell] = get_eyetrack_data(candidate_paths, task_id, run_id) % get_eye_data Extracts eye-tracking data for a given subject, session, and task. % % This function returns a 2x1 cell array where each cell contains a struct From d9e41539bc86ebdc1c1ec20c29bdc432e79303c1 Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Tue, 24 Mar 2026 19:35:56 +0100 Subject: [PATCH 15/20] formatting --- src/bids_importer/lib/get_marker_data.m | 7 ++++++- src/bids_importer/lib/get_physio_eye_data.m | 2 -- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/bids_importer/lib/get_marker_data.m b/src/bids_importer/lib/get_marker_data.m index 932dadb27..8a04a1240 100644 --- a/src/bids_importer/lib/get_marker_data.m +++ b/src/bids_importer/lib/get_marker_data.m @@ -17,7 +17,12 @@ end % Get marker tsv data -marker_tsv_data_table = read_data_from_tsv(events_tsv_filepath, has_headings, headings, col_types ); +marker_tsv_data_table = read_data_from_tsv( ... + events_tsv_filepath, ... + has_headings, ... + headings, ... + col_types ... +); % --------- markerinfo from tsv --------- marker_data.data = marker_tsv_data_table.onset; diff --git a/src/bids_importer/lib/get_physio_eye_data.m b/src/bids_importer/lib/get_physio_eye_data.m index d185deade..7beaf6ee5 100644 --- a/src/bids_importer/lib/get_physio_eye_data.m +++ b/src/bids_importer/lib/get_physio_eye_data.m @@ -8,8 +8,6 @@ %% Process eye data [ests, eye_data_cell] = get_eyetrack_data( ... candidate_paths, ... - subject_id, ... - session_id, ... task_id, ... run_id ... ); From 5a357a6cc70575bedbaa70ec6c06409fcca8b23b Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Wed, 25 Mar 2026 17:00:54 +0100 Subject: [PATCH 16/20] [BUG]: enforce cell is Columns is string --- src/bids_importer/lib/get_physio_data.m | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/bids_importer/lib/get_physio_data.m b/src/bids_importer/lib/get_physio_data.m index c557cb01a..49de261d2 100644 --- a/src/bids_importer/lib/get_physio_data.m +++ b/src/bids_importer/lib/get_physio_data.m @@ -40,8 +40,16 @@ % Read JSON metadata physio_json = extract_json_as_struct(physio_json_filepath); - % Read TSV data + % Read columns and format into cell to string doesn't get separated headings = physio_json.Columns; + + if ischar(headings) + headings = {headings}; % wrap into cell array + elseif isstring(headings) + headings = cellstr(headings); + end + + % read TSV file col_types = repmat({'double'}, 1, length(headings)); physio_data_table = read_data_from_tsv( ... physio_tsv_filepath, ... From 58a647dc3a1ed358fe0f19779969a61b9ac4b52a Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Tue, 31 Mar 2026 09:32:47 +0200 Subject: [PATCH 17/20] [FIX]: construct output file properly --- src/pspm_import_bids.m | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pspm_import_bids.m b/src/pspm_import_bids.m index 5466c1531..2debc5fbb 100644 --- a/src/pspm_import_bids.m +++ b/src/pspm_import_bids.m @@ -363,22 +363,22 @@ ses.infos.duration = duration; %% Build output file - parts = {['pspm_' subject_full_id]}; + parts = {sprintf('pspm_%s', char(subject_full_id))}; if ~isempty(session_id) - parts{end+1} = sprintf('ses-%s', session_id); + parts{end+1} = sprintf('ses-%s', char(session_id)); end if ~isempty(task_id) && numel(task_ids) > 1 - parts{end+1} = sprintf('task-%s', task_id); + parts{end+1} = sprintf('task-%s', char(task_id)); end - + if ~isempty(run_id) && numel(run_ids) > 1 parts{end+1} = sprintf('run-%d', run_id); end - + ses_filename = [strjoin(parts, '_') '.mat']; - + ses_filepath = fullfile(save_path, ses_filename); outfile{end+1} = char(ses_filepath); ses.infos.importfile = char(ses_filepath); From 5f9028bf05bcc1290985c16843026db11519dc68 Mon Sep 17 00:00:00 2001 From: MadniAbdulWahab Date: Wed, 1 Apr 2026 15:21:39 +0200 Subject: [PATCH 18/20] made input options open(removed hardcoded input) --- src/helper/pspm_ledalab.m | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/helper/pspm_ledalab.m b/src/helper/pspm_ledalab.m index c3cd107d8..51397be59 100644 --- a/src/helper/pspm_ledalab.m +++ b/src/helper/pspm_ledalab.m @@ -59,6 +59,9 @@ end; options.filter = 1; try options.norm; catch, options.norm = 0; end; +try options.optimize; catch, options.optimize = 2; end +try options.export_era; catch, options.export_era = [1 4 0.01 1]; end +try options.ledalab_args; catch, options.ledalab_args = {}; end % does result file exist? if exist(outfile, 'file') @@ -101,8 +104,10 @@ mkdir(workpath); copyfile(fullfile(cpth, ledafn{1}), fullfile(workpath, ledafn{1})); % do the analysis - Ledalab(workpath, 'open', 'leda', 'analyze', options.method{k}, 'optimize', 2, ... - 'export_era', [1 4 0.01 1]); + Ledalab(workpath, 'open', 'leda', 'analyze', options.method{k}, ... + 'optimize', options.optimize, ... + 'export_era', options.export_era, ... + options.ledalab_args{:}); % rename files and copy to current path ledafiles = dir(workpath); for f = 3:numel(ledafiles) From 3137f92d355dd35e3b24ff26c7858e3f3c44d8d8 Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Tue, 28 Apr 2026 13:39:39 +0200 Subject: [PATCH 19/20] improve filename handling --- src/pspm_pipeline_fc_scr.m | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/pspm_pipeline_fc_scr.m b/src/pspm_pipeline_fc_scr.m index e1fa4b6cb..136e8c496 100644 --- a/src/pspm_pipeline_fc_scr.m +++ b/src/pspm_pipeline_fc_scr.m @@ -58,6 +58,12 @@ onsets = {onsets}; isi = {isi}; end +if ~iscell(fn) + fn = {fn}; +end +if ~iscell(missing) && ~isempty(missing) + missing = {missing}; +end for i_sn = 1:numel(onsets) timing{i_sn}{1} = onsets{i_sn}(:) + isi{i_sn}(:); @@ -80,12 +86,19 @@ %% Setup model % set (dummy) filename -[pth, fn_m, ext] = fileparts(fn); -model_fn = fullfile(pth{1}, ['mdl_', fn_m{1}, '.mat']); +[pth, fn_m, ~] = fileparts(fn); + +if iscell(pth) + model_fn = fullfile(pth{1}, ['mdl_', fn_m{1}, '.mat']); +else + model_fn = fullfile(pth, ['mdl_', fn_m, '.mat']); +end model = struct( ... 'modelfile', model_fn, ... - 'norm', normalize); + 'norm', normalize ... +); + model.datafile = fn; model.missing = missing; model.timing = timing; From 8279ca24976ee950bf509ed0a34e7ae511029938 Mon Sep 17 00:00:00 2001 From: Jurjen Heij Date: Wed, 29 Apr 2026 12:20:58 +0200 Subject: [PATCH 20/20] aesthetics --- src/pspm_import_bids.m | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pspm_import_bids.m b/src/pspm_import_bids.m index 2debc5fbb..8e0e43675 100644 --- a/src/pspm_import_bids.m +++ b/src/pspm_import_bids.m @@ -170,7 +170,8 @@ nSessions = 0; nTasks = 0; nRuns = 0; -%% 3. Loop over subjects --------------------------------------------------- + +%% 3. Loop over subjects for i = 1:length(subject_list) subject_full_id = subject_list(i).name; % e.g., 'sub-CalinetBonn01