Skip to content

Commit

Permalink
enh: large overhaul of the functional workflow
Browse files Browse the repository at this point in the history
  • Loading branch information
oesteban committed Nov 17, 2023
1 parent 309a54c commit e298a88
Show file tree
Hide file tree
Showing 11 changed files with 465 additions and 288 deletions.
17 changes: 10 additions & 7 deletions mriqc/cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,7 @@ def parse_args(args=None, namespace=None):
config.execution.participant_label,
session_id=config.execution.session_id,
task=config.execution.task_id,
group_echos=False,
group_echos=True,
bids_filters=config.execution.bids_filters,
queries={mod: DEFAULT_BIDS_QUERIES[mod] for mod in lc_modalities}
)
Expand Down Expand Up @@ -575,7 +575,7 @@ def parse_args(args=None, namespace=None):
# Estimate the biggest file size / leave 1GB if some file does not exist (datalad)
with suppress(FileNotFoundError):
config.workflow.biggest_file_gb = _get_biggest_file_size_gb(
[i for sublist in config.workflow.inputs.values() for i in sublist]
config.workflow.inputs.values()
)

# set specifics for alternative populations
Expand All @@ -593,11 +593,14 @@ def parse_args(args=None, namespace=None):


def _get_biggest_file_size_gb(files):
"""Identify the largest file size (allows multi-echo groups)."""

import os

max_size = 0
sizes = []
for file in files:
size = os.path.getsize(file) / (1024**3)
if size > max_size:
max_size = size
return max_size
if isinstance(file, (list, tuple)):
sizes.append(_get_biggest_file_size_gb(file))
else:
sizes.append(os.path.getsize(file))
return max(sizes) / (1024**3)
59 changes: 20 additions & 39 deletions mriqc/data/bootstrap-func.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,69 +26,50 @@
###########################################################################

packagename: mriqc
title: '{filename} :: Functional MRIQC report'
title: "{filename} :: MRIQC's BOLD fMRI report"
sections:
- name: Summary
reportlets:
- bids: {datatype: figures, desc: summary, extension: [.html]}
- name: Basic visual report
- name: Basic echo-wise reports
ordering: echo
reportlets:
- bids: {datatype: figures, desc: zoomed}
caption: This panel shows a mosaic of the brain. This mosaic is the most suitable to
screen head-motion intensity inhomogeneities, global/local noise, signal leakage
(for example, from the eyeballs and across the phase-encoding axis), etc.
subtitle: Voxel-wise average of BOLD time-series, zoomed-in covering just the brain
- bids: {datatype: figures, desc: stdev}
subtitle: Standard deviation of signal through time
caption: The voxel-wise standard deviation of the signal (variability along time).
- bids: {datatype: figures, desc: carpet}
subtitle: Carpetplot and nuisance signals
caption: The so-called «carpetplot» may assist in assessing head-motion
derived artifacts and respiation effects.
- name: Extended visual report
- name: Extended reports shared across echos
reportlets:
- bids: {datatype: figures, desc: background}
caption: This panel shows a mosaic enhancing the background around the head.
Artifacts usually unveil themselves in the air surrounding the head, where no signal
sources are present.
subtitle: View of the background of the voxel-wise average of the BOLD timeseries
- bids: {datatype: figures, desc: mean}
subtitle: Average signal through time
caption: The average signal calculated across the last axis (time).
- bids: {datatype: figures, desc: airmask}
caption: The <em>hat</em>-mask calculated internally by MRIQC. Some metrics will use this
mask, for instance, to find out artifacts and estimate the spread of gaussian noise
added to the signal. This mask leaves out the air around the face to avoid measuring
noise sourcing from the eyeballs and their movement.
subtitle: '&laquo;Hat&raquo;-mask'
- bids: {datatype: figures, desc: noisefit}
caption: The noise fit internally estimated by MRIQC to calculate the QI<sub>1</sub> index
proposed by <a href="https://doi.org/10.1002/mrm.21992" target="_blank">Mortamet et al. (2009)</a>.
subtitle: Distribution of the noise within the <em>hat</em> mask
style:
max-width: 450px
- bids: {datatype: figures, desc: artifacts}
caption: Mask of artifactual intensities identified within the <em>hat</em>-mask.
subtitle: Artifactual intensities on the background
- bids: {datatype: figures, desc: brainmask}
caption: Brain mask as internally extracted by MRIQC. Defects on the brainmask could
indicate problematic aspects of the image quality-wise.
subtitle: Brain extraction performance
- bids: {datatype: figures, desc: head}
caption: A mask of the head calculated internally by MRIQC.
subtitle: Head mask
- bids: {datatype: figures, desc: segmentation}
caption: Brain tissue segmentation, as internally extracted by MRIQC.
Defects on this segmentation, as well as noisy tissue labels could
indicate problematic aspects of the image quality-wise.
subtitle: Brain tissue segmentation
- bids: {datatype: figures, desc: norm}
caption: This panel shows a <em>quick-and-dirty</em> nonlinear registration into
the <code>MNI152NLin2009cAsym</code> template accessed with
<a href="https://templateflow.org/browse" target="_blank"><em>TemplateFlow</em></a>.
subtitle: Spatial normalization of the anatomical image
static: false

- name: Extended echo-wise reports
ordering: echo
reportlets:
- bids: {datatype: figures, desc: background}
caption: This panel shows a mosaic enhancing the background around the head.
Artifacts usually unveil themselves in the air surrounding the head, where no signal
sources are present.
subtitle: View of the background of the voxel-wise average of the BOLD timeseries
- bids: {datatype: figures, desc: mean}
subtitle: Average signal through time
caption: The average signal calculated across the last axis (time).
- bids: {datatype: figures, desc: zoomed}
caption: This panel shows a mosaic of the brain. This mosaic is the most suitable to
screen head-motion intensity inhomogeneities, global/local noise, signal leakage
(for example, from the eyeballs and across the phase-encoding axis), etc.
subtitle: Voxel-wise average of BOLD time-series, zoomed-in covering just the brain

- name: About
nested: true
Expand Down
12 changes: 12 additions & 0 deletions mriqc/interfaces/bids.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class IQMFileSinkInputSpec(DynamicTraitedSpec, BaseInterfaceInputSpec):
rec_id = traits.Either(None, Str, usedefault=True)
run_id = traits.Either(None, traits.Int, usedefault=True)
dataset = Str(desc="dataset identifier")
dismiss_entities = traits.List(["part"], usedefault=True)
metadata = traits.Dict()
provenance = traits.Dict()

Expand Down Expand Up @@ -111,6 +112,17 @@ def _gen_outfile(self):
break
in_file = str(path.relative_to(bids_root))

if (
isdefined(self.inputs.dismiss_entities)
and (dismiss := self.inputs.dismiss_entities)
):
for entity in dismiss:
bids_chunks = [
chunk for chunk in path.name.split("_")
if not chunk.startswith(f"{entity}-")
]
path = path.parent / "_".join(bids_chunks)

# Build path and ensure directory exists
bids_path = out_dir / in_file.replace("".join(Path(in_file).suffixes), ".json")
bids_path.parent.mkdir(parents=True, exist_ok=True)
Expand Down
4 changes: 3 additions & 1 deletion mriqc/reports/individual.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,13 @@ def _single_report(in_file):
from mriqc import config

# Ensure it's a Path
in_file = Path(in_file)
in_file = Path(in_file if not isinstance(in_file, list) else in_file[0])

# Extract BIDS entities
entities = config.execution.layout.get_file(in_file).get_entities()
entities.pop("extension", None)
entities.pop("echo", None)
entities.pop("part", None)
report_type = entities.pop("datatype", None)

# Read output file:
Expand Down
98 changes: 98 additions & 0 deletions mriqc/utils/bids.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
# https://www.nipreps.org/community/licensing/
#
"""PyBIDS tooling."""
from __future__ import annotations

import json
import os
from pathlib import Path
Expand Down Expand Up @@ -90,3 +92,99 @@ def write_derivative_description(bids_dir, deriv_dir):
desc["License"] = orig_desc["License"]

Path.write_text(deriv_dir / "dataset_description.json", json.dumps(desc, indent=4))


def derive_bids_fname(
orig_path: str | Path,
entity: str | None = None,
newsuffix: str | None = None,
newpath: str | Path | None = None,
newext: str | None = None,
position: int = -1,
absolute: bool = True,
) -> Path | str:
"""
Derive a new file name from a BIDS-formatted path.
Parameters
----------
orig_path : :obj:`str` or :obj:`os.pathlike`
A filename (may or may not include path).
entity : :obj:`str`, optional
A new BIDS-like key-value pair.
newsuffix : :obj:`str`, optional
Replace the BIDS suffix.
newpath : :obj:`str` or :obj:`os.pathlike`, optional
Path to replace the path of the input orig_path.
newext : :obj:`str`, optional
Replace the extension of the file.
position : :obj:`int`, optional
Position to insert the entity in the filename.
absolute : :obj:`bool`, optional
If True (default), returns the absolute path of the modified filename.
Returns
-------
Absolute path of the modified filename
Examples
--------
>>> derive_bids_fname(
... 'sub-001/ses-01/anat/sub-001_ses-01_T1w.nii.gz',
... entity='desc-preproc',
... absolute=False,
... )
PosixPath('sub-001/ses-01/anat/sub-001_ses-01_desc-preproc_T1w.nii.gz')
>>> derive_bids_fname(
... 'sub-001/ses-01/anat/sub-001_ses-01_T1w.nii.gz',
... entity='desc-brain',
... newsuffix='mask',
... newext=".nii",
... absolute=False,
... ) # doctest: +ELLIPSIS
PosixPath('sub-001/ses-01/anat/sub-001_ses-01_desc-brain_mask.nii')
>>> derive_bids_fname(
... 'sub-001/ses-01/anat/sub-001_ses-01_T1w.nii.gz',
... entity='desc-brain',
... newsuffix='mask',
... newext=".nii",
... newpath="/output/node",
... absolute=True,
... ) # doctest: +ELLIPSIS
PosixPath('/output/node/sub-001_ses-01_desc-brain_mask.nii')
>>> derive_bids_fname(
... 'sub-001/ses-01/anat/sub-001_ses-01_T1w.nii.gz',
... entity='desc-brain',
... newsuffix='mask',
... newext=".nii",
... newpath=".",
... absolute=False,
... ) # doctest: +ELLIPSIS
PosixPath('sub-001_ses-01_desc-brain_mask.nii')
"""

orig_path = Path(orig_path)
newpath = orig_path.parent if newpath is None else Path(newpath)

ext = "".join(orig_path.suffixes)
newext = newext if newext is not None else ext
orig_stem = orig_path.name.replace(ext, "")

suffix = orig_stem.rsplit("_", maxsplit=1)[-1].strip("_")
newsuffix = newsuffix.strip("_") if newsuffix is not None else suffix

orig_stem = orig_stem.replace(suffix, "").strip("_")
bidts = [bit for bit in orig_stem.split("_") if bit]
if entity:
if position == -1:
bidts.append(entity)
else:
bidts.insert(position, entity.strip("_"))

retval = (newpath / f"{'_'.join(bidts)}_{newsuffix}.{newext.strip('.')}")

return retval.absolute() if absolute else retval
96 changes: 1 addition & 95 deletions mriqc/workflows/anatomical/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def anat_qc_workflow(name="anatMRIQC"):
wf = anat_qc_workflow()
"""
from mriqc.workflows.shared import synthstrip_wf

dataset = config.workflow.inputs.get("t1w", []) + config.workflow.inputs.get("t2w", [])

Expand Down Expand Up @@ -682,101 +683,6 @@ def airmsk_wf(name="AirMaskWorkflow"):
return workflow


def synthstrip_wf(name="synthstrip_wf", omp_nthreads=None):
"""Create a brain-extraction workflow using SynthStrip."""
from nipype.interfaces.ants import N4BiasFieldCorrection
from niworkflows.interfaces.nibabel import IntensityClip, ApplyMask
from mriqc.interfaces.synthstrip import SynthStrip

inputnode = pe.Node(niu.IdentityInterface(fields=["in_files"]), name="inputnode")
outputnode = pe.Node(
niu.IdentityInterface(fields=["out_corrected", "out_brain", "bias_image", "out_mask"]),
name="outputnode",
)

# truncate target intensity for N4 correction
pre_clip = pe.Node(IntensityClip(p_min=10, p_max=99.9), name="pre_clip")

pre_n4 = pe.Node(
N4BiasFieldCorrection(
dimension=3,
num_threads=omp_nthreads,
rescale_intensities=True,
copy_header=True,
),
name="pre_n4",
)

post_n4 = pe.Node(
N4BiasFieldCorrection(
dimension=3,
save_bias=True,
num_threads=omp_nthreads,
n_iterations=[50] * 4,
copy_header=True,
),
name="post_n4",
)

synthstrip = pe.Node(
SynthStrip(num_threads=omp_nthreads),
name="synthstrip",
num_threads=omp_nthreads,
)

final_masked = pe.Node(ApplyMask(), name="final_masked")
final_inu = pe.Node(niu.Function(function=_apply_bias_correction), name="final_inu")

workflow = pe.Workflow(name=name)
# fmt: off
workflow.connect([
(inputnode, final_inu, [("in_files", "in_file")]),
(inputnode, pre_clip, [("in_files", "in_file")]),
(pre_clip, pre_n4, [("out_file", "input_image")]),
(pre_n4, synthstrip, [("output_image", "in_file")]),
(synthstrip, post_n4, [("out_mask", "weight_image")]),
(synthstrip, final_masked, [("out_mask", "in_mask")]),
(pre_clip, post_n4, [("out_file", "input_image")]),
(post_n4, final_inu, [("bias_image", "bias_image")]),
(post_n4, final_masked, [("output_image", "in_file")]),
(final_masked, outputnode, [("out_file", "out_brain")]),
(post_n4, outputnode, [("bias_image", "bias_image")]),
(synthstrip, outputnode, [("out_mask", "out_mask")]),
(post_n4, outputnode, [("output_image", "out_corrected")]),
])
# fmt: on
return workflow


def _apply_bias_correction(in_file, bias_image, out_file=None):
import os.path as op

import numpy as np
import nibabel as nb

img = nb.load(in_file)
data = np.clip(
img.get_fdata() * nb.load(bias_image).get_fdata(),
a_min=0,
a_max=None,
)
out_img = img.__class__(
data.astype(img.get_data_dtype()),
img.affine,
img.header,
)

if out_file is None:
fname, ext = op.splitext(op.basename(in_file))
if ext == ".gz":
fname, ext2 = op.splitext(fname)
ext = ext2 + ext
out_file = op.abspath(f"{fname}_inu{ext}")

out_img.to_filename(out_file)
return out_file


def _binarize(in_file, threshold=0.5, out_file=None):
import os.path as op

Expand Down
Loading

0 comments on commit e298a88

Please sign in to comment.