Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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