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..41de7ca5f --- /dev/null +++ b/src/bids_importer/lib/build_pspm_eye_channels.m @@ -0,0 +1,150 @@ +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 = {}; + + if isempty(eye_data_cell) + fprintf('No eye data available\n'); + return + end + + eyes = normalize_eye_entries(eye_data_cell); + + 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'} ... + ); + + order = {'r', 'l'}; + ch = 0; + + for s = 1:numel(order) + side = order{s}; + entry = eyes.(side); + + if isempty(entry) + continue + end + + [meta, T] = split_eye_entry(entry); + + 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 + + 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 + + +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/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..c3f33a6d1 --- /dev/null +++ b/src/bids_importer/lib/find_bids_file.m @@ -0,0 +1,68 @@ +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 + 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..45dd92d4d --- /dev/null +++ b/src/bids_importer/lib/find_eye_files.m @@ -0,0 +1,81 @@ +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, ... + 'eye' ..., + ); + + 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..b9e0be384 --- /dev/null +++ b/src/bids_importer/lib/find_physio_file.m @@ -0,0 +1,29 @@ +function [ev_tsv, ev_json] = 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( ... + physio_path, ... + 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..497632c98 --- /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.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, 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..0967cfded 100644 --- a/src/bids_importer/lib/get_eyes_list.m +++ b/src/bids_importer/lib/get_eyes_list.m @@ -1,30 +1,16 @@ -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]); +for i = 1:numel(files) + filename = files{i}; -% Loop through each file in the directory -for i = 1:length(files) - % Get the filename - filename = files(i).name; + token = regexp(filename, 'recording-(eye\d+)', 'tokens', 'once'); - % 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 + 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 715f6ef34..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(subject_id, session_id, task_name, physio_path) +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 @@ -15,65 +15,84 @@ % Example: % [eye_data, dur, info] = get_eye_data('sub-CalinetWuerzburg01','01','FearAcquisition', '/path/to/physio'); -%% Initialize the cell array and info variables - -sts = -1; -eye_signals = get_eyes_list(physio_path); -eye_data_cell = {}; - -if isempty(eye_signals) - warning('No eye data found for subject %s sesssion %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: - % 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); - - 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; - - % 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 - - % 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 +%% Find all 'tsv.gz' files in session directory + 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_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_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_data.m b/src/bids_importer/lib/get_physio_data.m index ea740d768..49de261d2 100644 --- a/src/bids_importer/lib/get_physio_data.m +++ b/src/bids_importer/lib/get_physio_data.m @@ -1,15 +1,15 @@ -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 %% 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,37 +18,49 @@ for i = 1:num_signals signal = physio_signals{i}; - - % 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_json_filepath = fullfile(physio_path, physio_json_filename); - physio_tsv_filepath = fullfile(physio_path, physio_tsv_filename); - - % Check if files exist + % 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) 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 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, 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; @@ -63,22 +75,21 @@ 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; + 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 03eebd286..7beaf6ee5 100644 --- a/src/bids_importer/lib/get_physio_eye_data.m +++ b/src/bids_importer/lib/get_physio_eye_data.m @@ -1,397 +1,304 @@ -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 = {}; +data = {}; infos = struct(); infos.source = struct(); -infos.source.file = struct(); -file_paths = {}; -%% % Process eye data +%% Process eye data +[ests, eye_data_cell] = get_eyetrack_data( ... + candidate_paths, ... + task_id, ... + run_id ... +); -[ests , eye_data_cell] = get_eyetrack_data(subject_id, session_id, task_name, physio_eye_path); +if ests < 1 || isempty(eye_data_cell) + sts = -1; + return +end -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.'); +%% 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 +%% Physioevents search dirs +[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 + + % read physioevents.tsv.gz + fprintf('PEVs:\t%s\n', events_tsv_filepath); + data_events = get_physio_events_data( ... + events_json_filepath, ... + 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.']; + end +else + 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 -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; +%% Build infos.source.file +file_paths = {}; - 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 +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 +sts = 1; end -else - warning('No data for physio eye data was imported.'); -end % if ests == 1 - - +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 - -%% Process physio eye event data -> header eyedata maybe somewhere else? - -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); - -% 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); -else - % Imports the eye event data - data_events = get_physio_events_data(events_json_filepath,events_tsv_filepath,false); % has ColumnField + 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); - % 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 + 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 - file_paths{end+1,1} = {events_json_filepath,events_tsv_filepath}; - data = [ data; data_events]; -end % - -%% - -if isempty(data) - warning('No physio eye event data has been imported.'); - return -end -sts = 1; -infos.source.file = file_paths; -return + 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 -end + % Try to recover sampling rate from RECCFG message + indices_reccfg = find(contains(string(marker_tsv_data_table.message), 'RECCFG'), 1); -% 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))); + 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 - - 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 -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'}; + % Remove header/config rows if present + idx_header = strcmp(event_type, 'n/a') & ... + ~strcmp(string(marker_tsv_data_table.message), 'CS'); -% 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_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 -idx_header = strcmp(marker_tsv_data_table.event_type, 'n/a') & ~strcmp(marker_tsv_data_table.message, 'CS'); + % Shift first usable event to zero + onsets = onsets - onsets(1); -idx_data = ~idx_header; + 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)); -% 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 + for s = 1:numel(signal_names) + idx_signal = strcmp(event_type, signal_names{s}); + data_signal = zeros(n_samples, 1); -% 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 any(idx_signal) + starts_sec = onsets(idx_signal); + ends_sec = onsets(idx_signal) + duration(idx_signal); -signal = {'blink','saccade','fixation'}; -singal_chan = {'blink_c','saccade_c','fixation_c'}; + starts_idx = max(1, floor(starts_sec * sr) + 1); + ends_idx = min(n_samples, ceil(ends_sec * sr)); -for s = 1:numel(signal) + 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 -% Index of the onsets of the signal -idx_signal = find(strcmp(event_type, signal{1})); % excludes NaNs + 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 -% 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 +% adapted from in pspm_get_viewpoint and pspm_get_smi +function best_eye = eye_with_smaller_nan_ratio(data, eyes_observed) + + if isscalar(eyes_observed) + best_eye = lower(eyes_observed); + else -idx_signal = unique(all_indices); % removes overlaps -data_signal = zeros(idx_signal(end),1); + eye_L_max_nan_ratio = 0; + eye_R_max_nan_ratio = 0; -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 + n = numel(data); + for i = 1:n + chantype = data{i}.header.chantype; + eye_side = lower(chantype(end)); + nan_count = sum(isnan(data{i}.data)); -% 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_side == 'l' + eye_L_max_nan_ratio = max(eye_L_max_nan_ratio, nan_count); + elseif eye_side == 'r' + eye_R_max_nan_ratio = max(eye_R_max_nan_ratio, nan_count); + end + end -end + 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 \ 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..e31bb48c9 --- /dev/null +++ b/src/bids_importer/lib/normalize_eye_entries.m @@ -0,0 +1,29 @@ +function eyes = normalize_eye_entries(eye_data_cell) + + eyes = struct('r', [], 'l', []); + + for i = 1:numel(eye_data_cell) + entry = eye_data_cell{i}; + + if ~isstruct(entry) || ~isfield(entry, 'meta') || ~isstruct(entry.meta) + warning('Eye entry %d missing metadata; skipping.', i); + continue + end + + meta = entry.meta; + + if ~isfield(meta, 'RecordedEye') || isempty(meta.RecordedEye) + warning('Eye entry %d missing RecordedEye; skipping.', i); + continue + end + + 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 + end +end \ No newline at end of file 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_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) diff --git a/src/helper/pspm_peakscore.m b/src/helper/pspm_peakscore.m index b5ed1f1b0..b43739679 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 @@ -50,7 +50,7 @@ function pspm_peakscore(datafile, regfile, modelfile, timeunits, normalize, chan % $Rev$ global settings; -if isempty(settings), pspm_init; end; +if isempty(settings), pspm_init; end % check input arguments if nargin<1 @@ -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_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_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 80ebece68..8a308c46d 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 @@ -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; diff --git a/src/pspm_import_bids.m b/src/pspm_import_bids.m index 89b9345ab..2debc5fbb 100644 --- a/src/pspm_import_bids.m +++ b/src/pspm_import_bids.m @@ -1,20 +1,88 @@ 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-/ +% └── ses- +% └── physio +% ├── 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): +% +% sub-/ +% └── 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.gz +% ├── sub-_ses-01_recording-eye2_physio.json +% └── sub-_ses-01_recording-eye2_physio.tsv.gz +% % ● 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) - -%% 1. Initialize ----------------------------------------------------------- +% Written in 2024 by Sourav Koulkarni, +% Dominik R. Bach, +% Bernhard A. von Raußendorf (University of Bonn) +% +% 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 (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.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 +% separated in time if the number of samples do not align. +% +%% 1. Initialize global settings if isempty(settings) pspm_init; @@ -49,7 +117,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-'); @@ -71,40 +139,45 @@ % 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 ~ischar(save_path) - save_path = fullfile(dataset_path, 'out'); +if nargin<2 || ~(isstring(save_path) || ischar(save_path)) + % save_path = [dataset_path, filesep, 'out']; + save_path = fullfile(dataset_path, "out"); disp(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); + warning("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 -mkdir(save_path) -fprintf('\nImported files will be saved to: %s\n',save_path); - - +nSubjects = 0; +nSessions = 0; +nTasks = 0; +nRuns = 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 sub_path = fullfile(dataset_path, subject_full_id); @@ -114,144 +187,238 @@ [~, 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}, {'.','..'})); + + 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 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'); - %% 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)); - - 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 isempty(session_dirs(j).name) + % No session folders → operate directly in subject folder + session_id = ''; + ses_path = sub_path; else - warning('ID:nonexistent_file','No BIDS event or physio files for %s session %s', subject_full_id, session_id); - continue; + session_id = session_dirs(j).name(5:end); % e.g., '01' + ses_path = fullfile(sub_path, session_dirs(j).name); 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); + % 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] + physio_search_dirs = { ... + fullfile(ses_path, 'beh'), ... + fullfile(ses_path, 'physio'), ... + fullfile(ses_path, 'func') ... + }; + + % keep only those that exist + physio_search_dirs = physio_search_dirs(cellfun(@isfolder, physio_search_dirs)); - 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 % + fprintf('\n--------------------------------------------------------------------------------\n'); - %% --- 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); + if isempty(session_id) + fprintf('Subject-level import (no session folder)\n'); 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); + fprintf('Session: ses-%s\n', session_id); 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 + %% Extract task name + % Look for any event JSON in the beh and physio folders + task_ids = get_bids_task_ids(physio_search_dirs); - - % 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]; + % loop over tasks + for t = 1:numel(task_ids) + + %% 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( ... + physio_search_dirs, ... + subject_full_id, ... + session_id, ... + task_id ... + ); + + if isempty(run_ids) + run_ids = {''}; % placeholder: “no run” + end + + % If none found → process the session once without a task name + 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'); + [~, 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( ... + 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' + [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 + + % 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; - % 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; + % infos.source + 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 + + % 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]; + + % Calculates the nan_ratio for all channels + fprintf("\nCalculate 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(event_json); + for ii = 1:numel(fn) + ses.infos.(fn{ii}) = event_json.(fn{ii}); + end + + % Aligns all channels + 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 + parts = {sprintf('pspm_%s', char(subject_full_id))}; + + if ~isempty(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', 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); + + %% 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 - fn = fieldnames(beh_json); - for ii = 1:numel(fn) - ses.infos.(fn{ii}) = beh_json.(fn{ii}); - end - - % 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); + % saves as pspm file (overwrite) + data = ses.data; + infos = ses.infos; + save(ses_filepath,'infos', 'data'); + fprintf('Saved PsPM-file to ''%s''\n', ses_filepath); + fprintf('\n--------------------------------------------------------------------------------\n'); + + nRuns = nRuns + 1; + + end % close run loop + nTasks = nTasks + 1; + end % close task loop + nSessions = nSessions + 1; + end % close ses loop + nSubjects = nSubjects + 1; +end % close subj loop - end - -end rmpath(libpath); % What if the function breaks at another path sts = 1; + +%% footer +pspm_bids_importer_footer( ... + nSubjects, ... + nSessions, ... + nRuns, ... + nTasks, ... + save_path ... +) end %% 4. Sub-functions --------------------------------------------------------- @@ -275,6 +442,8 @@ 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) @@ -293,70 +462,231 @@ 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); -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 +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 + + if ~(isnumeric(induration) && isscalar(induration)) + warning('ID:invalid_input', 'induration must be a numeric scalar'); + duration = []; + return + end + + n = numel(data); + is_event = false(1, n); + durations = nan(1, n); + + 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 + + is_event(k) = strcmpi(units, "events"); + + 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 + + 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 [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 task_ids = get_bids_task_ids(search_dirs) + task_ids = {}; + 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 + task_ids = unique(task_ids, 'stable'); +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 -global_min = min(startTimes(~isnan(startTimes))); % excludes marker -finalLengths = zeros(num_channels,1); +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'); -for i = 1:num_channels - shift_sec = data{i}.header.StartTime - global_min; +end - % 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 probelms later.', i); - continue; + +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(' 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(nRuns) + fprintf(' Runs processed : %d\n', nRuns); +end + +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 + +fprintf(' Finished at : %s\n', timestamp); +fprintf('================================================================================\n\n'); + +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 - 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]; + chan_stats{r} = struct( ... + 'nan_ratio', nan_ratio ... + ); + end - % Record the new length. - finalLengths(i) = length(data{i}.data)/sr; - data{i}.header.StartTime = 0; - end + % Insert into ses + ses.infos.source.chan_stats = chan_stats; 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 +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