Repository navigation
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.
| 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.
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).
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.
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).
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; elseTaskName/Task/task; else the literal'task'(with a warning). -
run—Run/run(optional); recordings that would otherwise collide onsub/ses/taskare auto-numberedrun-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.
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.
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).
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.
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.
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 preservedIt 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.
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.
processFNIRS2
Getting Started
Core Workflow
Group Analysis
Visualization & Export
Reference