Skip to content

Quality Control

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

Quality Control

processFNIRS2 ships two parallel ways to review channel quality: an interactive GUI for eyes-on review, and a headless programmatic pipeline for scripts and batch runs. This page is for anyone deciding which bad channels to drop before processing, or automating that decision across many subjects.

Two paths, one goal

Path Use when Entry point
Interactive GUI Reviewing one dataset (or a stack of files) by eye, on a display pf2.qc.ChannelCheck
Headless pipeline Batch/-batch runs, unattended processing, reproducible thresholds pf2.qc.pipeline.assessapply
One-call summary A quick QC report + figures to disk, no GUI, no manual pipeline calls pf2.qc.snapshot

All three read/write the same data.fchMask convention: 1 = good, 0.5 = marginal/noisy, 0 = rejected.

Interactive: pf2.qc.ChannelCheck

pf2.qc.ChannelCheck is an App Designer GUI for interactive channel review — per-channel raw/Hb traces, a probe-arranged mini-plot grid, undo/redo, bulk accept/reject, and integrated QC metrics.

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

% Single dataset
app = pf2.qc.ChannelCheck(data);
% ... review channels in the window, then close it ...
data = app.OutputData;
delete(app);

% Multiple files: prev/next navigation between datasets in the cell array
allData = {data1, data2, data3};
app = pf2.qc.ChannelCheck(allData);
allData = app.OutputData;
delete(app);

After the window closes, read the result from app.OutputData (a struct for single-dataset mode, or a cell array for multi-file mode) — always follow with delete(app).

Headless-safety flag

Under -batch MATLAB, the GUI's save/close path opens a blocking uiconfirm dialog and errors — there is no display to click through it. Pass 'SkipConfirmation', true to suppress all confirmation dialogs and make the app usable non-interactively:

app = pf2.qc.ChannelCheck(data, 'SkipConfirmation', true);
data = app.OutputData;
delete(app);

For fully unattended runs (no GUI at all), prefer the programmatic pipeline below instead of driving the GUI headlessly.

One-call headless summary: pf2.qc.snapshot

pf2.qc.snapshot runs the full QC pipeline and writes a set of diagnostic figures straight to disk — no GUI, no manual assess/plotReport calls. It is the fast, scriptable counterpart to ChannelCheck.

data = pf2.import.sampleData.fNIR2000();
report = pf2.qc.snapshot(data, 'SaveDir', 'qc_out');
pf2.qc.pipeline.report(report);   % also print the text summary

Files written under SaveDir (optionally prefixed with 'Prefix'):

File Content
qc_dashboard.png SCI / cardiac / CoV / Takizawa 4-panel dashboard
qc_psd.png Per-channel power spectra (tiled)
qc_sci.png SCI bar chart (only written when SCI was not skipped)

Any extra name-value pairs ('Checks', 'SCIThreshold', 'CoVThreshold', ...) forward straight to pf2.qc.pipeline.assess. The returned report is the same QC report struct as assess, with an added report.snapshot.files listing the saved paths.

Programmatic pipeline: assessapplyreport/plotReport

This is the recommended path for unattended/batch runs, since it needs no display and its thresholds are explicit and reproducible.

1. pf2.qc.pipeline.assess — run the checks

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

report = pf2.qc.pipeline.assess(data);                    % all applicable checks, defaults
report = pf2.qc.pipeline.assess(data, ...                 % custom thresholds
    'SCIThreshold', 0.8, 'CoVThreshold', 0.15);
report = pf2.qc.pipeline.assess(data, ...                 % run only some checks
    'Checks', {'saturation', 'sci', 'cov'});

assess does its own lightweight internal Raw → OD → Hb conversion (simple Beer-Lambert, default DPF) purely to feed the Takizawa rules — it does not touch or require the main processFNIRS2 pipeline, and it does not modify data.

Checks (run in this order, default set {'saturation','sci','cardiac','cov','takizawa'}):

Check What it measures Signal stage Key parameters
saturation Fraction of samples at the raw intensity floor (0) or device ceiling raw SaturationThreshold (default 0.1)
sci Scalp Coupling Index — cross-correlation of cardiac pulsations across the two wavelengths (Pollonini et al. 2014, DOI: 10.1016/j.heares.2013.11.007) raw SCIThreshold (default 0.75), CardiacBand (default [0.5 2.5])
cardiac Cardiac peak presence/SNR in the power spectrum raw CardiacSNR (default 3), CardiacBand
cov Coefficient of variation of the raw signal (works at any sampling rate) raw CoVThreshold (default 0.2)
takizawa Four empirical hemoglobin-signal rules — high-freq noise, low-freq/anti-correlation noise, zero variance, body-movement jumps (Takizawa et al. 2008, DOI: 10.1016/j.schres.2007.10.025) filtered Hb (internal) TakizawaStrict (default false)

sci and cardiac need cardiac-band content and are only meaningful above roughly 5 Hz; at lower sampling rates (e.g. the fNIR 1200 at ~2 Hz) they are automatically skipped — every channel passes them by default so a low-fs device is never penalized for a check it structurally cannot run — and the reason is recorded on report.<check>.skipReason. cov and takizawa work at any sampling rate ≥ 2 Hz. For a low-fs device, restrict to what applies: pf2.qc.pipeline.assess(data, 'Checks', {'cov','takizawa'}).

2. pf2.qc.pipeline.apply — update fchMask

data = pf2.qc.pipeline.apply(data, report);                          % reject on all checks
data = pf2.qc.pipeline.apply(data, report, 'Checks', {'sci', 'cov'}); % only these two
data = pf2.qc.pipeline.apply(data, report, 'MarkNoisy', true);        % 0.5 instead of 0

apply ANDs the selected checks' pass/fail with the existing fchMask, so a channel that was already rejected (e.g. by a prior manual review) is never promoted back to good. With 'MarkNoisy', true, channels that fail QC but were previously good are set to 0.5 (marginal) rather than 0 (rejected). The full report is stored back on data.qcReport for traceability.

3. pf2.qc.pipeline.report / plotReport — inspect the result

pf2.qc.pipeline.report(report);                 % channel-by-channel text table

fig = pf2.qc.pipeline.plotReport(report, 'Title', 'Subject 01');
pf2.qc.pipeline.plotReport(report, 'Visible', 'off', 'SavePath', 'qc.png');  % headless-safe

report prints a per-channel table (one row per channel, one column per check, * marking a failed value) plus a summary line and any skipped-check notes. plotReport renders the same 4-panel dashboard snapshot saves to disk (SCI bars, cardiac SNR bars, CoV bars, Takizawa rule heatmap) — pass 'SavePath' for headless saving (2D figure, so the ordinary figure('Visible','off') + saveas pattern also works, but 'SavePath' is preferred).

Individual metrics can also be computed and plotted standalone:

sciResult = pf2.qc.sci(data);                          % SCI only
psdResult = pf2.qc.powerSpectrum(data, 'Signal', 'raw'); % PSD + peak detection
pf2.qc.plotQuality(sciResult);                          % dispatches on result type
pf2.qc.plotQuality(psdResult, 'Layout', 'tiled');

The report structure

report.channels     % 1:nChannels
report.fs            % sampling rate used
report.checkNames    % cellstr of checks that were run, e.g. {'saturation','sci','cardiac','cov','takizawa'}
report.pass          % [1 x nChannels] logical — AND across ALL run checks
report.summary        % per-channel summary table

% Per-check sub-structs, one per name in report.checkNames:
report.sci.values     report.sci.pass     report.sci.skipped     report.sci.threshold
report.cardiac.detected  report.cardiac.snr  report.cardiac.pass  report.cardiac.skipped
report.cov.values     report.cov.pass
report.takizawa.rules  report.takizawa.ruleNames  report.takizawa.pass
report.saturation.floorPct  report.saturation.ceilPct  report.saturation.pass

Every check struct carries a .pass mask sized [1 x nChannels]; checks that can measure a continuous quantity also carry .values (or a check-specific name like .snr), and checks that can be skipped at low sampling rates carry .skipped (+ .skipReason when true). report.pass is the AND of every .pass mask across report.checkNames — this is what apply uses by default.

Recommended workflow

% Step 1: Import (raw data)
raw = pf2.import.sampleData.fNIR2000();

% Step 2: QC assessment (headless, reproducible)
qcReport = pf2.qc.pipeline.assess(raw);

% Step 3: Apply QC — reject bad channels BEFORE processing
raw = pf2.qc.pipeline.apply(raw, qcReport);

% Step 4: Process
processed = processFNIRS2(raw);

% Step 5: The QC report travels with the data for traceability
disp(raw.qcReport.checkNames);

QC runs on raw data, before the three-stage processing pipeline — see Processing Pipeline.

GUI auto-suppression on import and qcStatus

Importers (pf2.import.importNIR, importNIRX, importSNIRF, importHitachiMES, ...) normally open the interactive channel-check GUI when no saved *_CH.mat mask exists for a recording. That GUI is automatically skipped whenever it cannot or should not block:

  • a headless/-batch MATLAB session,
  • code running under the matlab.unittest framework,
  • or the GUI explicitly disabled via the flag below.

When suppressed, import instead loads a saved mask if one exists, or defaults to all channels good — emitting a pf2:loadExistingMaskOrCheck:guiSuppressed warning so the omission is not silent. This means an unattended -batch import will not reject anything on its own; always follow batch imports with the programmatic QC pipeline (assessapply) so bad/saturated channels are actually rejected.

The path taken is recorded on data.info.qcStatus:

qcStatus value Meaning
'mask_loaded' A saved *_CH.mat sidecar was found and loaded
'gui_reviewed' The channel-check GUI ran and a human reviewed channels
'unreviewed_default' GUI was suppressed; all channels defaulted to good — run assess/apply

To force the GUI off explicitly (e.g. scripting on a machine with a live display, or forcing it on inside a test):

prev = pf2_base.channelCheckGUIEnabled(false);        % force the GUI off
c = onCleanup(@() pf2_base.channelCheckGUIEnabled(prev));  % restore on exit
data = pf2.import.sampleData();                        % no GUI, even on a display

See also

Clone this wiki locally