Skip to content

Export and Interoperability

Adrian Curtin edited this page Jul 24, 2026 · 1 revision

Export and Interoperability

This page covers writing pf2 data back out to other tools: raw-data interchange formats (SNIRF, .nir), a validator-oriented BIDS-NIRS dataset, flat CSV tables for cross-toolbox benchmarking, a self-describing HDF5 tensor for foundation-model pipelines, and portable descriptions of the probe geometry itself. It is for researchers who need their pf2 results to leave MATLAB — into Homer3, MNE-Python, FieldTrip, R/Python statistics, or a shared dataset.

Choosing a format

Need Function Notes
Share raw data with another fNIRS tool pf2.export.asSNIRF Standardized HDF5; raw intensity + probe + markers only
Round-trip through legacy fNIR Devices/COBI Studio tools pf2.export.asNIR Writes .nir + companion .mrk/.log
Publish a shareable, validator-oriented dataset pf2.export.asBIDS Full sub-/ses-/nirs tree with sidecars
Flat per-subject/per-channel CSV for stats or benchmarking pf2.export.blockAvgToTable / pf2.export.glmToTable Schema-stable across pf2 runs, easy to diff against other toolboxes
Feed a Python/foundation-model pipeline pf2.export.asTensor Self-describing HDF5: numeric tensor + JSON manifest
Let the extension pick the writer pf2.export.export Dispatches by file extension or 'Format'

All exporters accept a single struct or a cell array of structs; passing a cell array with a directory path (instead of a file path) switches to batch mode.

SNIRF export

data = pf2.import.sampleData.fNIR2000();

% Single file
pf2.export.asSNIRF(data, 'subject01.snirf');

% Multiple runs written as /nirs1, /nirs2, ... in one file
pf2.export.asSNIRF({run1, run2, run3}, 'session.snirf');

% Batch: cell array + directory (no .snirf extension) = batch mode
pf2.export.asSNIRF(allData, 'output/');
pf2.export.asSNIRF(allData, 'output/', 'Dir1', 'Group', 'Prefix', {'SubjectID'});

% Re-import
reimported = pf2.import.importSNIRF('subject01.snirf');

Batch mode name-value parameters (also accepted by asNIR):

Parameter Purpose
'Dir1'..'Dir4' .info field names mapped to subdirectory levels
'Prefix' Cell array of .info field names used to build filenames
'NormalizeRaw' Scale raw data before export (SNIRF only; default false)
'StripExtraRawChannels' Drop dark/ambient channels from export (SNIRF only)
'Verbose' Print progress messages (default true)

SNIRF is a raw-data interchange format — it was designed for sharing data across tools before processing, not for storing processed results:

Preserved through a SNIRF round-trip Not preserved
Raw light intensity (.raw), .time, .fs Processed hemoglobin (.HbO, .HbR, .HbTotal, .HbDiff, .CBSI)
Probe geometry (source/detector positions, wavelengths, SD pairs) Channel quality mask (.fchMask) — not part of the SNIRF spec
Event markers (.markers), reorganized into one /nirs/stim group per unique code .processingInfo
Subject/session metadata (.info fields → /nirs/metaDataTags) The pf2.Device object (re-created on import if the probe matches a known config)
Auxiliary data (.Aux), device manufacturer/model Internal pf2-only fields (.OD, .segmentTimes, ...)

Because of this, reprocess after re-importing to get hemoglobin values back:

reimported  = pf2.import.importSNIRF('subject01.snirf');
reprocessed = processFNIRS2(reimported, 'DPFmode', 'Calc', 'defaultSubjectAge', 28);

.info fields map to specific SNIRF metadata tags where a standard slot exists (others are stored verbatim):

.info field SNIRF metaDataTags
SubjectID SubjectID
SubjectName SubjectName
manufacturer ManufacturerName
DateOfBirth DateOfBirth
StudyID StudyID
sex sex

See examples/scripts/example_snirf_export.m for a full worked round-trip (inject markers, add metadata, trim with pf2.data.split, export, re-import, diff what changed).

fNIR Devices / Biopac (.nir) export

data = pf2.import.sampleData();
pf2.export.asNIR(data, 'sample_out.nir');   % also writes .mrk and .log

% Batch, same Dir1..Dir4/Prefix options as asSNIRF
pf2.export.asNIR(allData, 'output/', 'Dir1', 'Group', 'Prefix', {'SubjectID'});

Useful for constructing synthetic .nir files or round-tripping through tools built around the native fNIR Devices/COBI Studio text format.

Auto-detecting exporter

processed = processFNIRS2(pf2.import.sampleData());
pf2.export.export(processed, 'subject01_processed.snirf');   % routes by extension
pf2.export.export(processed);                                  % interactive save dialog

% Batch: extension can't be inferred from a directory, so pass 'Format'
pf2.export.export(allData, 'output/', 'Format', 'snirf', 'Dir1', 'Group');
pf2.export.export(allData, 'bids_out/', 'Format', 'bids', 'Task', 'rest');

'Format' accepts 'snirf', 'nir', or 'bids'; any other name-value pairs are forwarded to the matching exporter (Dir1-Dir4/Prefix/Verbose for snirf/nir, Task/Name/Authors/Participants for bids).

True BIDS-NIRS dataset export

pf2.export.asBIDS writes a complete, validator-oriented dataset for the SNIRF-based BIDS-NIRS specification — not just a SNIRF file, but the full sub-/[ses-/]nirs/ tree with required sidecars.

[ex, allData] = pf2.import.sampleData.group();

pf2.export.asBIDS(allData, 'bids_out/', 'Task', 'rest');

pf2.export.asBIDS(allData, 'bids_out/', 'Task', 'stroop', ...
    'Name', 'My Study', 'Authors', {'Doe J', 'Roe R'}, ...
    'Participants', {'sex', 'age', 'Group'});

% Or via the auto-detecting dispatcher
pf2.export.export(allData, 'bids_out/', 'Format', 'bids', 'Task', 'rest');

'Task' is effectively required — BIDS requires a task entity on every NIRS file, and a generic fallback ('task') triggers a warning if you omit it and no per-record .info.Task/TaskName is present.

Other name-value parameters:

Parameter Purpose
'Name' Dataset name for dataset_description.json (default: rootDir folder name)
'BIDSVersion' Spec version string (default '1.10.0')
'Authors' Cell array of author name strings
'Participants' .info field names to emit as participants.tsv columns (default: auto — sex/age/group when present)
'License' License string for dataset_description.json
'Overwrite' Allow writing into a non-empty rootDir (default false)
'Verbose' Print progress (default true)

Files written:

Level Files
Dataset dataset_description.json, README, participants.tsv, participants.json
Per recording *_nirs.snirf, *_nirs.json, *_channels.tsv, *_events.tsv (when markers are present)
Per subject/session (montage-level, via BIDS inheritance) *_optodes.tsv, *_coordsystem.json

The .snirf and its probe geometry come from pf2.export.asSNIRF internally, so optode positions, the measurement list, and wavelengths stay consistent between the .snirf and its derived sidecars.

Entity resolution (per recording, from .info, case-insensitive):

  • sub — SubjectID/SubjectId/Subject/subject; else a zero-padded index.
  • ses — Session/session/ses (optional).
  • task — the 'Task' parameter; else TaskName/Task/task; else the literal 'task' (with a warning).
  • run — Run/run (optional); recordings that would otherwise collide on sub/ses/task are auto-numbered run-01, run-02, ... in input order.

Labels are sanitized to BIDS-legal alphanumerics, and placeholder values (Unknown, n/a, none, ...) are treated as absent.

Processed hemoglobin data is not part of raw BIDS-NIRS — the .snirf carries raw intensity only, matching pf2.export.asSNIRF. Validate the result with the official bids-validator before sharing; asBIDS targets the required fields but does not itself validate.

Tabular export for cross-toolbox benchmarking

For comparing pf2 against other pipelines (Homer3, MNE-NIRS, Cedalion, AnalyzIR) or for feeding R/Python statistics, two one-call helpers flatten results into a benchmark-schema table keyed by channel_label (S#_D#, matching pf2.probe.montage):

% Block-average results -> one row per subject x session x condition x channel
segments = pf2.data.extractBlocks(proc, blocks, 'PreTime', 5, 'PostTime', 15, 'SetT0', true);
baT = pf2.export.blockAvgToTable(segments);
writetable(baT, 'block_average.csv');

% First-level GLM betas -> one row per subject x channel x condition
gx = exploreFNIRS.core.GLMExperiment(subjects, blockDefs);
gx.fit();
glmT = pf2.export.glmToTable(gx, 'SavePath', 'glm_betas.csv');

Both accept 'Channels', 'Biomarkers', and 'SavePath' (extension picks .csv/.xlsx/.txt/.tsv). See examples/scripts/example_bids_to_csv.m for the full recipe: directory import → QC → an explicit pf2.ProcessingContext → block average AND GLM → group second-level stats (GLMExperiment.groupStats) → CSV, with the consistency caveats between the two paths spelled out in the script's comments.

Self-describing HDF5 tensor (foundation-model export contract)

pf2.export.asTensor writes a single, versioned, cross-language .h5 file: numeric arrays as native HDF5 datasets (zero custom decoding for h5py), and structured metadata (montage descriptor, QC, markers, info, provenance) as UTF-8 JSON strings.

data = pf2.import.sampleData.fNIR2000();
proc = processFNIRS2(data);

% Default: stack the biomarkers present (HbO/HbR/...) -> [T x C x F]
outPath = pf2.export.asTensor(proc, 'sub-01.h5');

% Windowed export for fixed-length model input, with QC manifest
blocks  = pf2.data.slidingWindows(proc, 'Length', 10, 'Embed', false);
windows = pf2.data.extractBlocks(proc, blocks, 'PreTime', 0, 'PostTime', 0);
outPath = pf2.export.asTensor(proc, 'sub-01_win.h5', ...
    'Windows', windows, 'Features', {'HbO', 'HbR'}, 'QC', true);

% Align auxiliary signals (and derived features) onto the tensor grid
outPath = pf2.export.asTensor(proc, 'sub-01.h5', 'Aux', {'heartRate', 'accelerometer'});
outPath = pf2.export.asTensor(proc, 'sub-01.h5', 'Aux', 'all');
Parameter Purpose
'Features' Biomarker field names to stack on the feature axis (default: present fields in priority {'HbO','HbR','HbTotal','HbDiff','CBSI'}, falling back to 'OD' then 'raw')
'Windows' Segment cell array/struct array (e.g. from pf2.data.slidingWindows + pf2.data.extractBlocks); switches the payload to [window x time x channel x feature] and adds /windowOnsets
'QC' true (runs pf2.qc.pipeline.assess) or a precomputed report struct
'Aux' Cellstr of aux base names, or true/'all' for every aux signal present; aligned per-window when 'Windows' is set
'ContractVersion' Contract version string written to the root attribute (default '1.0')

On-disk layout: /tensor (float32), /time, optional /windowOnsets and /aux, /montage (JSON, reused verbatim from pf2.probe.montage), and /manifest/{qc,markers,markerDict,info,processingInfo} (JSON). Root attributes carry pf2ContractVersion, dims, samplingRate, units, featureNames, nWindows, nAux.

Cross-language axis note: MATLAB is column-major and HDF5/h5py is row-major, so the writer permutes the tensor before writing — a Python consumer doing f['/tensor'][()] gets shape (T, C, F) (or (W, T, C, F) windowed) matching the dims attribute directly. A MATLAB reader of the same file must reverse axes again (see pf2.import.importEmbeddings below).

Consumer side: re-importing learned features

pf2.import.importEmbeddings reads features written by a Python sibling pipeline back into MATLAB, time-aligned to the original recording:

proc = pf2.import.importEmbeddings(proc, 'sub-01_embeddings.h5');
size(proc.embeddings.data)                        % [T x E]
isequal(proc.embeddings.time, proc.time(:))        % aligned to the recording

proc = pf2.import.importEmbeddings(proc, 'embeddings.h5', 'Field', 'myFeats');

Once attached, .embeddings behaves like any other biomarker block, so exploreFNIRS.core.Experiment can fold learned features into LME/contrast analyses alongside HbO/HbR. Per-timepoint, per-window, and per-timepoint-and-channel embeddings are all supported; granularity is read from the file's dims attribute.

Portable montage descriptor

pf2.probe.montage serializes a probe's geometry and metadata into a portable per-channel table plus a montage-level descriptor struct — useful for reproducibility, sharing probe geometry, documenting a montage in a paper's supplement, or as the montage sidecar consumed by asTensor.

data = pf2.import.sampleData();
tbl = pf2.probe.montage(data);
tbl.ChannelLabel            % e.g. ["S1_D1"; "S1_D2"; ...]

[tbl, descriptor] = pf2.probe.montage(data);   % descriptor: device/coords/wavelengths/channels

% Portable JSON sidecar (or per-channel CSV/XLSX)
pf2.probe.montage(data, 'SavePath', 'sub-01_montage.json');
pf2.probe.montage(data, 'SavePath', 'montage.csv');

% Straight from a device config name, no atlas lookup
tbl = pf2.probe.montage('fNIR_Devices_fNIR1000.cfg', 'Brodmann', false);

The table carries Channel, ChannelLabel (S#_D#), Source, Detector, X_mni/Y_mni/Z_mni, SD_mm, ShortSep, and (when 3D positions exist) nearest-Brodmann columns BA/BA_name/BA_dist_mm.

Saving a probe as a reusable device config

To turn a montage that only exists as SNIRF geometry into a reloadable, toolbox-native device, write it out with pf2.probe.saveCfg:

data = pf2.import.importSNIRF('sub-01_nirs.snirf');
pf2.probe.saveCfg(data, 'myprobe.cfg');
pf2.probe.saveCfg(data, 'myprobe.cfg', ...
    'CoordinateSystem', 'MNI', 'ReferenceHead', 'MNI152', 'Name', 'My Probe');

dev = pf2.Device.load('myprobe');    % reload the written config
dev.mniPositions()                    % geometry preserved

It accepts a data struct, a pf2.Device object, a .snirf path, or a config name, and reconstructs source/detector coordinates, the per-raw-column channel/wavelength mapping, and source/detector indices from the device tables. 'CoordinateSystem'/'ReferenceHead'/etc. only label the coordinate space — they do not transform the coordinates. Saturation bounds (RawMax/RawMin) and a 2D schematic layout are not in SNIRF and are omitted; pf2.Device.load derives the 2D layout from 3D on reload.

Cross-montage comparison: canonicalization

To compare or pool channel data across different montages/devices, project onto a shared anatomical (Brodmann) region axis with pf2.probe.canonicalize before analysis or export — see the probe-anatomy entries on the API Reference page and Diffuse Optical Tomography for details; a cell array of processed structs yields one common region axis across the group.

See also

Clone this wiki locally