Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
74e3e90
FIX: allow multi-task inputs [#876]
gjheij Dec 5, 2025
d22c6d0
read beh.json and store in header
gjheij Dec 7, 2025
6187194
verify output of get_physio_events_data
gjheij Dec 8, 2025
49945eb
save_path can be char too
gjheij Dec 8, 2025
9619ca4
clean up indices generation
gjheij Dec 24, 2025
232cc11
also default to save_path if nargin<2
gjheij Dec 24, 2025
42c6835
fix
gjheij Dec 24, 2025
fa4a674
other minor fixes to get rid of warnings + screen dimensions
gjheij Feb 16, 2026
ef0b0dd
Merge remote-tracking branch 'origin/develop' into 876_bids_taskid
gjheij Feb 16, 2026
92a314d
follow BEP020
gjheij Feb 25, 2026
37f6ad4
[ENH]: make bids importer more agnostic, read in BEP020/BEP045
gjheij Mar 23, 2026
591c1f6
Merge remote-tracking branch 'origin/develop' into 876_bids_taskid
gjheij Mar 23, 2026
78721b9
docs + fprintf
gjheij Mar 23, 2026
a28828f
fix date
gjheij Mar 23, 2026
09158aa
FIX crucial error: read data from signal-column, rather than 1st index
gjheij Mar 24, 2026
3dfc84a
Simplify callsign
gjheij Mar 24, 2026
d9e4153
formatting
gjheij Mar 24, 2026
5a357a6
[BUG]: enforce cell is Columns is string
gjheij Mar 25, 2026
58a647d
[FIX]: construct output file properly
gjheij Mar 31, 2026
5f9028b
made input options open(removed hardcoded input)
MadniAbdulWahab Apr 1, 2026
3137f92
improve filename handling
gjheij Apr 28, 2026
2f3c3a5
Merge branch '876_bids_taskid' of github.com:bachlab/PsPM into 876_bi…
gjheij Apr 28, 2026
ac281b5
Merge remote-tracking branch 'origin/develop' into 876_bids_taskid
gjheij Apr 29, 2026
8279ca2
aesthetics
gjheij Apr 29, 2026
d0bedab
Merge origin/develop (force incoming changes)
gjheij May 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions src/bids_importer/lib/FindFiles.m
Original file line number Diff line number Diff line change
@@ -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<AGROW>
else
if FindFiles.match_pattern(item.name, pattern)
files{end+1} = fullfile(current, item.name); %#ok<AGROW>
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
150 changes: 150 additions & 0 deletions src/bids_importer/lib/build_pspm_eye_channels.m
Original file line number Diff line number Diff line change
@@ -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 '<signal>_<side>' (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_<side>'
% - 'x_coordinate' -> chantype 'gaze_x_<side>'
% - 'y_coordinate' -> chantype 'gaze_y_<side>'
%
% 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
Loading
Loading