diff --git a/.circleci/config.yml b/.circleci/config.yml index aa871e1b..bea8fbd6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -17,6 +17,12 @@ jobs: steps: - checkout + - run: + name: Git config - pacify datalad + command: | + git config --global user.name 'NiPreps Bot' + git config --global user.email 'nipreps@gmail.com' + - restore_cache: keys: - tf-v0-{{ .Branch }}-{{ .Revision }} @@ -32,12 +38,47 @@ jobs: command: | python -c "from templateflow.api import get; get('Fischer344', desc=None, suffix='T2w')" python -c "from templateflow.api import get; get('MNI152NLin6Asym', resolution=2, desc='LR', suffix='T1w')" + python -c "from templateflow.api import get; get('OASIS30ANTs', resolution=1, desc=None, suffix='T1w')" + python -c "from templateflow.api import get; get('OASIS30ANTs', resolution=1, desc='brain', suffix='mask')" + python -c "from templateflow.api import get; get('OASIS30ANTs', resolution=1, label='brain', suffix='probseg')" + python -c "from templateflow.api import get; get('OASIS30ANTs', resolution=1, desc='BrainCerebellumRegistration', suffix='mask')" + python -c "from templateflow.api import get; get('OASIS30ANTs', resolution=1, desc='4', suffix='dseg')" + python -c "from templateflow.api import get; get('fsLR', density='32k', hemi=['R', 'L'], suffix='inflated', extension='surf.gii')" - save_cache: key: tf-v0-{{ .Branch }}-{{ .Revision }} paths: - /tmp/templateflow + - restore_cache: + keys: + - data-v1-{{ .Branch }}- + - data-v1-master- + - data-v1- + - run: + name: Get test data from ds000003 + command: | + mkdir -p /tmp/data + pushd /tmp/data + if [[ ! -d ds000003 ]]; then + datalad install -r https://github.com/nipreps-data/ds000003.git + fi + datalad update -r --merge -d ds000003/ + datalad get -J 2 -r -d ds000003/ ds000003/* + popd + + - save_cache: + key: data-v1-{{ .Branch }}-{{ epoch }} + paths: + - /tmp/data/ds000003 + + - run: + name: Store FreeSurfer license file + command: | + mkdir -p /tmp/fslicense + cd /tmp/fslicense + echo "cHJpbnRmICJrcnp5c3p0b2YuZ29yZ29sZXdza2lAZ21haWwuY29tXG41MTcyXG4gKkN2dW12RVYzelRmZ1xuRlM1Si8yYzFhZ2c0RVxuIiA+IGxpY2Vuc2UudHh0Cg==" | base64 -d | sh + - run: name: Run unit tests no_output_timeout: 2h diff --git a/nireports/conftest.py b/nireports/conftest.py index ae016f4c..febdd90d 100644 --- a/nireports/conftest.py +++ b/nireports/conftest.py @@ -30,13 +30,14 @@ import pytest import tempfile + # disable ET os.environ['NO_ET'] = '1' _datadir = (Path(__file__).parent / "tests" / "data").resolve(strict=True) -niprepsdev_path = os.getenv( +niprepsdev_path = Path(os.getenv( "TEST_DATA_HOME", str(Path.home() / ".cache" / "nipreps-dev") -) +)) test_output_dir = os.getenv("TEST_OUTPUT_DIR") test_workdir = os.getenv("TEST_WORK_DIR") @@ -50,8 +51,8 @@ def expand_namespace(doctest_namespace): doctest_namespace["os"] = os doctest_namespace["pytest"] = pytest doctest_namespace["Path"] = Path - doctest_namespace["testdata_path"] = _datadir - doctest_namespace["niprepsdev_path"] = niprepsdev_path + doctest_namespace["test_data_package"] = _datadir + doctest_namespace["test_data_home"] = niprepsdev_path doctest_namespace["os"] = os doctest_namespace["Path"] = Path @@ -72,10 +73,15 @@ def expand_namespace(doctest_namespace): @pytest.fixture -def testdata_path(): +def test_data_package(): return _datadir +@pytest.fixture +def test_data_home(): + return niprepsdev_path + + @pytest.fixture def workdir(): return None if test_workdir is None else Path(test_workdir) diff --git a/nireports/interfaces/reporting/__init__.py b/nireports/interfaces/reporting/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nireports/interfaces/reporting/base.py b/nireports/interfaces/reporting/base.py new file mode 100644 index 00000000..e9a13ca1 --- /dev/null +++ b/nireports/interfaces/reporting/base.py @@ -0,0 +1,199 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +# +# Copyright 2023 The NiPreps Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# We support and encourage derived works from this project, please read +# about our expectations at +# +# https://www.nipreps.org/community/licensing/ +# +"""class mixin and utilities for enabling reports for nipype interfaces.""" +from nipype.interfaces.base import File, traits +from nipype.interfaces.mixins import reporting +from nipype import logging +from nireports.reportlets.utils import cuts_from_bbox, compose_view + +_LOGGER = logging.getLogger("nipype.interface") + + +class _SVGReportCapableInputSpec(reporting.ReportCapableInputSpec): + out_report = File( + "report.svg", usedefault=True, desc="filename for the visual report" + ) + compress_report = traits.Enum( + "auto", + True, + False, + usedefault=True, + desc="Compress the reportlet using SVGO or" + "WEBP. 'auto' - compress if relevant " + "software is installed, True = force," + "False - don't attempt to compress", + ) + + +class RegistrationRC(reporting.ReportCapableInterface): + """An abstract mixin to registration nipype interfaces.""" + + _fixed_image = None + _moving_image = None + _fixed_image_mask = None + _fixed_image_label = "fixed" + _moving_image_label = "moving" + _contour = None + _dismiss_affine = False + + def _generate_report(self): + """Generate the visual report.""" + from nilearn.image import threshold_img, load_img + from nilearn.masking import apply_mask, unmask + from nireports.reportlets.mosaic import plot_registration + + _LOGGER.info("Generating visual report") + + fixed_image_nii = load_img(self._fixed_image) + moving_image_nii = load_img(self._moving_image) + contour_nii = load_img(self._contour) if self._contour is not None else None + + if self._fixed_image_mask: + fixed_image_nii = unmask( + apply_mask(fixed_image_nii, self._fixed_image_mask), + self._fixed_image_mask, + ) + # since the moving image is already in the fixed image space we + # should apply the same mask + moving_image_nii = unmask( + apply_mask(moving_image_nii, self._fixed_image_mask), + self._fixed_image_mask, + ) + mask_nii = load_img(self._fixed_image_mask) + else: + mask_nii = threshold_img(fixed_image_nii, 1e-3) + + n_cuts = 7 + if not self._fixed_image_mask and contour_nii: + cuts = cuts_from_bbox(contour_nii, cuts=n_cuts) + else: + cuts = cuts_from_bbox(mask_nii, cuts=n_cuts) + + # Call composer + compose_view( + plot_registration( + fixed_image_nii, + "fixed-image", + estimate_brightness=True, + cuts=cuts, + label=self._fixed_image_label, + contour=contour_nii, + compress=self.inputs.compress_report, + dismiss_affine=self._dismiss_affine, + ), + plot_registration( + moving_image_nii, + "moving-image", + estimate_brightness=True, + cuts=cuts, + label=self._moving_image_label, + contour=contour_nii, + compress=self.inputs.compress_report, + dismiss_affine=self._dismiss_affine, + ), + out_file=self._out_report, + ) + + +class SegmentationRC(reporting.ReportCapableInterface): + """An abstract mixin to segmentation nipype interfaces.""" + + def _generate_report(self): + from nireports.reportlets.mosaic import plot_segs + + compose_view( + plot_segs( + image_nii=self._anat_file, + seg_niis=self._seg_files, + bbox_nii=self._mask_file, + out_file=self.inputs.out_report, + masked=self._masked, + compress=self.inputs.compress_report, + ), + fg_svgs=None, + out_file=self._out_report, + ) + + +class SurfaceSegmentationRC(reporting.ReportCapableInterface): + """An abstract mixin to registration nipype interfaces.""" + + _anat_file = None + _mask_file = None + _contour = None + + def _generate_report(self): + """Generate the visual report.""" + from nilearn.image import threshold_img, load_img + from nilearn.masking import apply_mask, unmask + from nireports.reportlets.mosaic import plot_registration + + _LOGGER.info("Generating visual report") + + anat = load_img(self._anat_file) + contour_nii = load_img(self._contour) if self._contour is not None else None + + if self._mask_file: + anat = unmask(apply_mask(anat, self._mask_file), self._mask_file) + mask_nii = load_img(self._mask_file) + else: + mask_nii = threshold_img(anat, 1e-3) + + n_cuts = 7 + if not self._mask_file and contour_nii: + cuts = cuts_from_bbox(contour_nii, cuts=n_cuts) + else: + cuts = cuts_from_bbox(mask_nii, cuts=n_cuts) + + # Call composer + compose_view( + plot_registration( + anat, + "fixed-image", + estimate_brightness=True, + cuts=cuts, + contour=contour_nii, + compress=self.inputs.compress_report, + ), + [], + out_file=self._out_report, + ) + + +class ReportingInterface(reporting.ReportCapableInterface): + """ + Interface that always generates a report. + + A subclass must define an ``input_spec`` and override ``_generate_report``. + + """ + + output_spec = reporting.ReportCapableOutputSpec + + def __init__(self, generate_report=True, **kwargs): + super(ReportingInterface, self).__init__( + generate_report=generate_report, **kwargs + ) + + def _run_interface(self, runtime): + return runtime diff --git a/nireports/interfaces/reporting/masks.py b/nireports/interfaces/reporting/masks.py new file mode 100644 index 00000000..8645ddd5 --- /dev/null +++ b/nireports/interfaces/reporting/masks.py @@ -0,0 +1,298 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +# +# Copyright 2021 The NiPreps Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# We support and encourage derived works from this project, please read +# about our expectations at +# +# https://www.nipreps.org/community/licensing/ +# +"""ReportCapableInterfaces for masks tools.""" +import os +import numpy as np +import nibabel as nb + +from nipype.interfaces import fsl, ants +from nipype.interfaces.base import ( + File, + traits, + isdefined, + InputMultiPath, + Str, +) +from nipype.interfaces.mixins import reporting +from nipype.algorithms import confounds +from nipype import logging +from nireports.interfaces.reporting import base as nrb + +_LOGGER = logging.getLogger("nipype.interface") + + +class _BETInputSpecRPT(nrb._SVGReportCapableInputSpec, fsl.preprocess.BETInputSpec): + pass + + +class _BETOutputSpecRPT( + reporting.ReportCapableOutputSpec, fsl.preprocess.BETOutputSpec +): + pass + + +class BETRPT(nrb.SegmentationRC, fsl.BET): + input_spec = _BETInputSpecRPT + output_spec = _BETOutputSpecRPT + + def _run_interface(self, runtime): + if self.generate_report: + self.inputs.mask = True + + return super(BETRPT, self)._run_interface(runtime) + + def _post_run_hook(self, runtime): + """generates a report showing slices from each axis of an arbitrary + volume of in_file, with the resulting binary brain mask overlaid""" + + self._anat_file = self.inputs.in_file + self._mask_file = self.aggregate_outputs(runtime=runtime).mask_file + self._seg_files = [self._mask_file] + self._masked = self.inputs.mask + + _LOGGER.info( + 'Generating report for BET. file "%s", and mask file "%s"', + self._anat_file, + self._mask_file, + ) + + return super(BETRPT, self)._post_run_hook(runtime) + + +class _BrainExtractionInputSpecRPT( + nrb._SVGReportCapableInputSpec, ants.segmentation.BrainExtractionInputSpec +): + pass + + +class _BrainExtractionOutputSpecRPT( + reporting.ReportCapableOutputSpec, ants.segmentation.BrainExtractionOutputSpec +): + pass + + +class BrainExtractionRPT(nrb.SegmentationRC, ants.segmentation.BrainExtraction): + input_spec = _BrainExtractionInputSpecRPT + output_spec = _BrainExtractionOutputSpecRPT + + def _post_run_hook(self, runtime): + """ generates a report showing slices from each axis """ + + brain_extraction_mask = self.aggregate_outputs( + runtime=runtime + ).BrainExtractionMask + + if ( + isdefined(self.inputs.keep_temporary_files) + and self.inputs.keep_temporary_files == 1 + ): + self._anat_file = self.aggregate_outputs(runtime=runtime).N4Corrected0 + else: + self._anat_file = self.inputs.anatomical_image + self._mask_file = brain_extraction_mask + self._seg_files = [brain_extraction_mask] + self._masked = False + + _LOGGER.info( + 'Generating report for ANTS BrainExtraction. file "%s", mask "%s"', + self._anat_file, + self._mask_file, + ) + + return super(BrainExtractionRPT, self)._post_run_hook(runtime) + + +class _ACompCorInputSpecRPT(nrb._SVGReportCapableInputSpec, confounds.CompCorInputSpec): + pass + + +class _ACompCorOutputSpecRPT( + reporting.ReportCapableOutputSpec, confounds.CompCorOutputSpec +): + pass + + +class ACompCorRPT(nrb.SegmentationRC, confounds.ACompCor): + input_spec = _ACompCorInputSpecRPT + output_spec = _ACompCorOutputSpecRPT + + def _post_run_hook(self, runtime): + """ generates a report showing slices from each axis """ + + if len(self.inputs.mask_files) != 1: + raise ValueError( + "ACompCorRPT only supports a single input mask. " + "A list %s was found." % self.inputs.mask_files + ) + self._anat_file = self.inputs.realigned_file + self._mask_file = self.inputs.mask_files[0] + self._seg_files = self.inputs.mask_files + self._masked = False + + _LOGGER.info( + 'Generating report for aCompCor. file "%s", mask "%s"', + self.inputs.realigned_file, + self._mask_file, + ) + + return super(ACompCorRPT, self)._post_run_hook(runtime) + + +class _TCompCorInputSpecRPT( + nrb._SVGReportCapableInputSpec, confounds.TCompCorInputSpec +): + pass + + +class _TCompCorOutputSpecRPT( + reporting.ReportCapableOutputSpec, confounds.TCompCorOutputSpec +): + pass + + +class TCompCorRPT(nrb.SegmentationRC, confounds.TCompCor): + input_spec = _TCompCorInputSpecRPT + output_spec = _TCompCorOutputSpecRPT + + def _post_run_hook(self, runtime): + """ generates a report showing slices from each axis """ + + high_variance_masks = self.aggregate_outputs( + runtime=runtime + ).high_variance_masks + + if isinstance(high_variance_masks, list): + raise ValueError( + "TCompCorRPT only supports a single output high variance mask. " + "A list %s was found." % high_variance_masks + ) + self._anat_file = self.inputs.realigned_file + self._mask_file = high_variance_masks + self._seg_files = [high_variance_masks] + self._masked = False + + _LOGGER.info( + 'Generating report for tCompCor. file "%s", mask "%s"', + self.inputs.realigned_file, + self.aggregate_outputs(runtime=runtime).high_variance_masks, + ) + + return super(TCompCorRPT, self)._post_run_hook(runtime) + + +class _SimpleShowMaskInputSpec(nrb._SVGReportCapableInputSpec): + background_file = File(exists=True, mandatory=True, desc="file before") + mask_file = File(exists=True, mandatory=True, desc="file before") + + +class SimpleShowMaskRPT(nrb.SegmentationRC, nrb.ReportingInterface): + input_spec = _SimpleShowMaskInputSpec + + def _post_run_hook(self, runtime): + self._anat_file = self.inputs.background_file + self._mask_file = self.inputs.mask_file + self._seg_files = [self.inputs.mask_file] + self._masked = True + + return super(SimpleShowMaskRPT, self)._post_run_hook(runtime) + + +class _ROIsPlotInputSpecRPT(nrb._SVGReportCapableInputSpec): + in_file = File( + exists=True, mandatory=True, desc="the volume where ROIs are defined" + ) + in_rois = InputMultiPath( + File(exists=True), mandatory=True, desc="a list of regions to be plotted" + ) + in_mask = File(exists=True, desc="a special region, eg. the brain mask") + masked = traits.Bool(False, usedefault=True, desc="mask in_file prior plotting") + colors = traits.Either( + None, traits.List(Str), usedefault=True, desc="use specific colors for contours" + ) + levels = traits.Either( + None, + traits.List(traits.Float), + usedefault=True, + desc="pass levels to nilearn.plotting", + ) + mask_color = Str("r", usedefault=True, desc="color for mask") + + +class ROIsPlot(nrb.ReportingInterface): + input_spec = _ROIsPlotInputSpecRPT + + def _generate_report(self): + from seaborn import color_palette + from nireports.reportlets.mosaic import plot_segs + from nireports.reportlets.utils import compose_view + + seg_files = self.inputs.in_rois + mask_file = None if not isdefined(self.inputs.in_mask) else self.inputs.in_mask + + # Remove trait decoration and replace None with [] + levels = [level for level in self.inputs.levels or []] + colors = [c for c in self.inputs.colors or []] + + if len(seg_files) == 1: # in_rois is a segmentation + nsegs = len(levels) + if nsegs == 0: + levels = np.unique( + np.round(nb.load(seg_files[0]).get_fdata(dtype="float32")) + ) + levels = (levels[levels > 0] - 0.5).tolist() + nsegs = len(levels) + + levels = [levels] + missing = nsegs - len(colors) + if missing > 0: + colors = colors + color_palette("husl", missing) + colors = [colors] + else: # in_rois is a list of masks + nsegs = len(seg_files) + levels = [[0.5]] * nsegs + missing = nsegs - len(colors) + if missing > 0: + colors = [[c] for c in colors + color_palette("husl", missing)] + + if mask_file: + seg_files.insert(0, mask_file) + if levels: + levels.insert(0, [0.5]) + colors.insert(0, [self.inputs.mask_color]) + nsegs += 1 + + self._out_report = os.path.abspath(self.inputs.out_report) + compose_view( + plot_segs( + image_nii=self.inputs.in_file, + seg_niis=seg_files, + bbox_nii=mask_file, + levels=levels, + colors=colors, + out_file=self.inputs.out_report, + masked=self.inputs.masked, + compress=self.inputs.compress_report, + ), + fg_svgs=None, + out_file=self._out_report, + ) diff --git a/nireports/interfaces/reporting/registration.py b/nireports/interfaces/reporting/registration.py new file mode 100644 index 00000000..76ff520a --- /dev/null +++ b/nireports/interfaces/reporting/registration.py @@ -0,0 +1,321 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +# +# Copyright 2021 The NiPreps Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# We support and encourage derived works from this project, please read +# about our expectations at +# +# https://www.nipreps.org/community/licensing/ +# +"""ReportCapableInterfaces for registration tools.""" +import os + +from looseversion import LooseVersion +from nipype import logging +from nipype.utils.filemanip import fname_presuffix +from nipype.interfaces.base import ( + traits, + isdefined, + File, +) +from nipype.interfaces.mixins import reporting +from nipype.interfaces import freesurfer as fs +from nipype.interfaces import fsl + +from nireports.interfaces.reporting import base as nrb + +_LOGGER = logging.getLogger("nipype.interface") + + +class _ApplyTOPUPInputSpecRPT( + nrb._SVGReportCapableInputSpec, fsl.epi.ApplyTOPUPInputSpec +): + wm_seg = File(argstr="-wmseg %s", desc="reference white matter segmentation mask") + + +class _ApplyTOPUPOutputSpecRPT( + reporting.ReportCapableOutputSpec, fsl.epi.ApplyTOPUPOutputSpec +): + pass + + +class ApplyTOPUPRPT(nrb.RegistrationRC, fsl.ApplyTOPUP): + input_spec = _ApplyTOPUPInputSpecRPT + output_spec = _ApplyTOPUPOutputSpecRPT + + def _post_run_hook(self, runtime): + from nilearn.image import index_img + + self._fixed_image_label = "after" + self._moving_image_label = "before" + self._fixed_image = index_img( + self.aggregate_outputs(runtime=runtime).out_corrected, 0 + ) + self._moving_image = index_img(self.inputs.in_files[0], 0) + self._contour = self.inputs.wm_seg if isdefined(self.inputs.wm_seg) else None + _LOGGER.info( + "Report - setting corrected (%s) and warped (%s) images", + self._fixed_image, + self._moving_image, + ) + + return super(ApplyTOPUPRPT, self)._post_run_hook(runtime) + + +class _FUGUEInputSpecRPT(nrb._SVGReportCapableInputSpec, fsl.preprocess.FUGUEInputSpec): + wm_seg = File(argstr="-wmseg %s", desc="reference white matter segmentation mask") + + +class _FUGUEOutputSpecRPT( + reporting.ReportCapableOutputSpec, fsl.preprocess.FUGUEOutputSpec +): + pass + + +class FUGUERPT(nrb.RegistrationRC, fsl.FUGUE): + input_spec = _FUGUEInputSpecRPT + output_spec = _FUGUEOutputSpecRPT + + def _post_run_hook(self, runtime): + self._fixed_image_label = "after" + self._moving_image_label = "before" + self._fixed_image = self.aggregate_outputs(runtime=runtime).unwarped_file + self._moving_image = self.inputs.in_file + self._contour = self.inputs.wm_seg if isdefined(self.inputs.wm_seg) else None + _LOGGER.info( + "Report - setting corrected (%s) and warped (%s) images", + self._fixed_image, + self._moving_image, + ) + + return super(FUGUERPT, self)._post_run_hook(runtime) + + +class _FLIRTInputSpecRPT(nrb._SVGReportCapableInputSpec, fsl.preprocess.FLIRTInputSpec): + pass + + +class _FLIRTOutputSpecRPT( + reporting.ReportCapableOutputSpec, fsl.preprocess.FLIRTOutputSpec +): + pass + + +class FLIRTRPT(nrb.RegistrationRC, fsl.FLIRT): + input_spec = _FLIRTInputSpecRPT + output_spec = _FLIRTOutputSpecRPT + + def _post_run_hook(self, runtime): + self._fixed_image = self.inputs.reference + self._moving_image = self.aggregate_outputs(runtime=runtime).out_file + self._contour = self.inputs.wm_seg if isdefined(self.inputs.wm_seg) else None + _LOGGER.info( + "Report - setting fixed (%s) and moving (%s) images", + self._fixed_image, + self._moving_image, + ) + + return super(FLIRTRPT, self)._post_run_hook(runtime) + + +class _ApplyXFMInputSpecRPT( + nrb._SVGReportCapableInputSpec, fsl.preprocess.ApplyXFMInputSpec +): + pass + + +class ApplyXFMRPT(FLIRTRPT, fsl.ApplyXFM): + input_spec = _ApplyXFMInputSpecRPT + output_spec = _FLIRTOutputSpecRPT + + +if LooseVersion("0.0.0") < fs.Info.looseversion() < LooseVersion("6.0.0"): + _BBRegisterInputSpec = fs.preprocess.BBRegisterInputSpec +else: + _BBRegisterInputSpec = fs.preprocess.BBRegisterInputSpec6 + + +class _BBRegisterInputSpecRPT(nrb._SVGReportCapableInputSpec, _BBRegisterInputSpec): + # Adds default=True, usedefault=True + out_lta_file = traits.Either( + traits.Bool, + File, + default=True, + usedefault=True, + argstr="--lta %s", + min_ver="5.2.0", + desc="write the transformation matrix in LTA format", + ) + + +class _BBRegisterOutputSpecRPT( + reporting.ReportCapableOutputSpec, fs.preprocess.BBRegisterOutputSpec +): + pass + + +class BBRegisterRPT(nrb.RegistrationRC, fs.BBRegister): + input_spec = _BBRegisterInputSpecRPT + output_spec = _BBRegisterOutputSpecRPT + + def _post_run_hook(self, runtime): + outputs = self.aggregate_outputs(runtime=runtime) + mri_dir = os.path.join(self.inputs.subjects_dir, self.inputs.subject_id, "mri") + target_file = os.path.join(mri_dir, "brainmask.mgz") + + # Apply transform for simplicity + mri_vol2vol = fs.ApplyVolTransform( + source_file=self.inputs.source_file, + target_file=target_file, + lta_file=outputs.out_lta_file, + interp="nearest", + ) + res = mri_vol2vol.run() + + self._fixed_image = target_file + self._moving_image = res.outputs.transformed_file + self._contour = os.path.join(mri_dir, "ribbon.mgz") + _LOGGER.info( + "Report - setting fixed (%s) and moving (%s) images", + self._fixed_image, + self._moving_image, + ) + + return super(BBRegisterRPT, self)._post_run_hook(runtime) + + +class _MRICoregInputSpecRPT( + nrb._SVGReportCapableInputSpec, fs.registration.MRICoregInputSpec +): + pass + + +class _MRICoregOutputSpecRPT( + reporting.ReportCapableOutputSpec, fs.registration.MRICoregOutputSpec +): + pass + + +class MRICoregRPT(nrb.RegistrationRC, fs.MRICoreg): + input_spec = _MRICoregInputSpecRPT + output_spec = _MRICoregOutputSpecRPT + + def _post_run_hook(self, runtime): + outputs = self.aggregate_outputs(runtime=runtime) + mri_dir = None + if isdefined(self.inputs.subject_id): + mri_dir = os.path.join( + self.inputs.subjects_dir, self.inputs.subject_id, "mri" + ) + + if isdefined(self.inputs.reference_file): + target_file = self.inputs.reference_file + else: + target_file = os.path.join(mri_dir, "brainmask.mgz") + + # Apply transform for simplicity + mri_vol2vol = fs.ApplyVolTransform( + source_file=self.inputs.source_file, + target_file=target_file, + lta_file=outputs.out_lta_file, + interp="nearest", + ) + res = mri_vol2vol.run() + + self._fixed_image = target_file + self._moving_image = res.outputs.transformed_file + if mri_dir is not None: + self._contour = os.path.join(mri_dir, "ribbon.mgz") + _LOGGER.info( + "Report - setting fixed (%s) and moving (%s) images", + self._fixed_image, + self._moving_image, + ) + + return super(MRICoregRPT, self)._post_run_hook(runtime) + + +class _SimpleBeforeAfterInputSpecRPT(nrb._SVGReportCapableInputSpec): + before = File(exists=True, mandatory=True, desc="file before") + after = File(exists=True, mandatory=True, desc="file after") + wm_seg = File(desc="reference white matter segmentation mask") + before_label = traits.Str("before", usedefault=True) + after_label = traits.Str("after", usedefault=True) + dismiss_affine = traits.Bool( + False, usedefault=True, desc="rotate image(s) to cardinal axes" + ) + + +class SimpleBeforeAfterRPT(nrb.RegistrationRC, nrb.ReportingInterface): + input_spec = _SimpleBeforeAfterInputSpecRPT + + def _post_run_hook(self, runtime): + """ there is not inner interface to run """ + self._fixed_image_label = self.inputs.after_label + self._moving_image_label = self.inputs.before_label + self._fixed_image = self.inputs.after + self._moving_image = self.inputs.before + self._contour = self.inputs.wm_seg if isdefined(self.inputs.wm_seg) else None + self._dismiss_affine = self.inputs.dismiss_affine + _LOGGER.info( + "Report - setting before (%s) and after (%s) images", + self._fixed_image, + self._moving_image, + ) + + return super(SimpleBeforeAfterRPT, self)._post_run_hook(runtime) + + +class _ResampleBeforeAfterInputSpecRPT(_SimpleBeforeAfterInputSpecRPT): + base = traits.Enum("before", "after", usedefault=True, mandatory=True) + + +class ResampleBeforeAfterRPT(SimpleBeforeAfterRPT): + input_spec = _ResampleBeforeAfterInputSpecRPT + + def _post_run_hook(self, runtime): + from nilearn import image as nli + + self._fixed_image = self.inputs.after + self._moving_image = self.inputs.before + if self.inputs.base == "before": + resampled_after = nli.resample_to_img(self._fixed_image, self._moving_image) + fname = fname_presuffix( + self._fixed_image, suffix="_resampled", newpath=runtime.cwd + ) + resampled_after.to_filename(fname) + self._fixed_image = fname + else: + resampled_before = nli.resample_to_img( + self._moving_image, self._fixed_image + ) + fname = fname_presuffix( + self._moving_image, suffix="_resampled", newpath=runtime.cwd + ) + resampled_before.to_filename(fname) + self._moving_image = fname + self._contour = self.inputs.wm_seg if isdefined(self.inputs.wm_seg) else None + _LOGGER.info( + "Report - setting before (%s) and after (%s) images", + self._fixed_image, + self._moving_image, + ) + + runtime = super(ResampleBeforeAfterRPT, self)._post_run_hook(runtime) + _LOGGER.info("Successfully created report (%s)", self._out_report) + os.unlink(fname) + + return runtime diff --git a/nireports/interfaces/reporting/segmentation.py b/nireports/interfaces/reporting/segmentation.py new file mode 100644 index 00000000..6d1e3a3d --- /dev/null +++ b/nireports/interfaces/reporting/segmentation.py @@ -0,0 +1,240 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +# +# Copyright 2021 The NiPreps Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# We support and encourage derived works from this project, please read +# about our expectations at +# +# https://www.nipreps.org/community/licensing/ +# +"""ReportCapableInterfaces for segmentation tools.""" +import os + +from nipype import logging +from nipype.interfaces.base import File, isdefined +from nipype.interfaces import fsl, freesurfer +from nipype.interfaces.mixins import reporting +from nireports.interfaces.reporting import base as nrb + +_LOGGER = logging.getLogger("nipype.interface") + + +class _FASTInputSpecRPT(nrb._SVGReportCapableInputSpec, fsl.preprocess.FASTInputSpec): + pass + + +class _FASTOutputSpecRPT( + reporting.ReportCapableOutputSpec, fsl.preprocess.FASTOutputSpec +): + pass + + +class FASTRPT(nrb.SegmentationRC, fsl.FAST): + input_spec = _FASTInputSpecRPT + output_spec = _FASTOutputSpecRPT + + def _run_interface(self, runtime): + if self.generate_report: + self.inputs.segments = True + + return super(FASTRPT, self)._run_interface(runtime) + + def _post_run_hook(self, runtime): + """generates a report showing nine slices, three per axis, of an + arbitrary volume of `in_files`, with the resulting segmentation + overlaid""" + self._anat_file = self.inputs.in_files[0] + outputs = self.aggregate_outputs(runtime=runtime) + self._mask_file = outputs.tissue_class_map + # We are skipping the CSF class because with combination with others + # it only shows the skullstriping mask + self._seg_files = outputs.tissue_class_files[1:] + self._masked = False + + _LOGGER.info( + "Generating report for FAST (in_files %s, " + "segmentation %s, individual tissue classes %s).", + self.inputs.in_files, + outputs.tissue_class_map, + outputs.tissue_class_files, + ) + + return super(FASTRPT, self)._post_run_hook(runtime) + + +class _ReconAllInputSpecRPT( + nrb._SVGReportCapableInputSpec, freesurfer.preprocess.ReconAllInputSpec +): + pass + + +class _ReconAllOutputSpecRPT( + reporting.ReportCapableOutputSpec, freesurfer.preprocess.ReconAllOutputSpec +): + pass + + +class ReconAllRPT(nrb.SurfaceSegmentationRC, freesurfer.preprocess.ReconAll): + input_spec = _ReconAllInputSpecRPT + output_spec = _ReconAllOutputSpecRPT + + def _post_run_hook(self, runtime): + """generates a report showing nine slices, three per axis, of an + arbitrary volume of `in_files`, with the resulting segmentation + overlaid""" + outputs = self.aggregate_outputs(runtime=runtime) + self._anat_file = os.path.join( + outputs.subjects_dir, outputs.subject_id, "mri", "brain.mgz" + ) + self._contour = os.path.join( + outputs.subjects_dir, outputs.subject_id, "mri", "ribbon.mgz" + ) + self._masked = False + + _LOGGER.info( + "Generating report for ReconAll (subject %s)", outputs.subject_id + ) + + return super(ReconAllRPT, self)._post_run_hook(runtime) + + +class _MELODICInputSpecRPT(nrb._SVGReportCapableInputSpec, fsl.model.MELODICInputSpec): + out_report = File( + "melodic_reportlet.svg", + usedefault=True, + desc="Filename for the visual" " report generated " "by Nipype.", + ) + report_mask = File( + desc="Mask used to draw the outline on the reportlet. " + "If not set the mask will be derived from the data." + ) + + +class _MELODICOutputSpecRPT( + reporting.ReportCapableOutputSpec, fsl.model.MELODICOutputSpec +): + pass + + +class MELODICRPT(fsl.MELODIC): + input_spec = _MELODICInputSpecRPT + output_spec = _MELODICOutputSpecRPT + _out_report = None + + def __init__(self, generate_report=False, **kwargs): + super(MELODICRPT, self).__init__(**kwargs) + self.generate_report = generate_report + + def _post_run_hook(self, runtime): + # Run _post_run_hook of super class + runtime = super(MELODICRPT, self)._post_run_hook(runtime) + # leave early if there's nothing to do + if not self.generate_report: + return runtime + + _LOGGER.info("Generating report for MELODIC.") + _melodic_dir = runtime.cwd + if isdefined(self.inputs.out_dir): + _melodic_dir = self.inputs.out_dir + self._melodic_dir = os.path.abspath(_melodic_dir) + + self._out_report = self.inputs.out_report + if not os.path.isabs(self._out_report): + self._out_report = os.path.abspath( + os.path.join(runtime.cwd, self._out_report) + ) + + mix = os.path.join(self._melodic_dir, "melodic_mix") + if not os.path.exists(mix): + _LOGGER.warning( + "MELODIC outputs not found, assuming it didn't converge." + ) + self._out_report = self._out_report.replace(".svg", ".html") + snippet = "

MELODIC did not converge, no output

" + with open(self._out_report, "w") as fobj: + fobj.write(snippet) + return runtime + + self._generate_report() + return runtime + + def _list_outputs(self): + try: + outputs = super(MELODICRPT, self)._list_outputs() + except NotImplementedError: + outputs = {} + if self._out_report is not None: + outputs["out_report"] = self._out_report + return outputs + + def _generate_report(self): + from nireports.reportlets.xca import plot_melodic_components + + plot_melodic_components( + melodic_dir=self._melodic_dir, + in_file=self.inputs.in_files[0], + tr=self.inputs.tr_sec, + out_file=self._out_report, + compress=self.inputs.compress_report, + report_mask=self.inputs.report_mask, + ) + + +class _ICA_AROMAInputSpecRPT( + nrb._SVGReportCapableInputSpec, fsl.aroma.ICA_AROMAInputSpec +): + out_report = File( + "ica_aroma_reportlet.svg", + usedefault=True, + desc="Filename for the visual" " report generated " "by Nipype.", + ) + report_mask = File( + desc="Mask used to draw the outline on the reportlet. " + "If not set the mask will be derived from the data." + ) + + +class _ICA_AROMAOutputSpecRPT( + reporting.ReportCapableOutputSpec, fsl.aroma.ICA_AROMAOutputSpec +): + pass + + +class ICA_AROMARPT(reporting.ReportCapableInterface, fsl.ICA_AROMA): + input_spec = _ICA_AROMAInputSpecRPT + output_spec = _ICA_AROMAOutputSpecRPT + + def _generate_report(self): + from nireports.reportlets.xca import plot_melodic_components + + plot_melodic_components( + melodic_dir=self.inputs.melodic_dir, + in_file=self.inputs.in_file, + out_file=self.inputs.out_report, + compress=self.inputs.compress_report, + report_mask=self.inputs.report_mask, + noise_components_file=self._noise_components_file, + ) + + def _post_run_hook(self, runtime): + outputs = self.aggregate_outputs(runtime=runtime) + self._noise_components_file = os.path.join( + outputs.out_dir, "classified_motion_ICs.txt" + ) + + _LOGGER.info("Generating report for ICA AROMA") + + return super(ICA_AROMARPT, self)._post_run_hook(runtime) diff --git a/nireports/tests/conftest.py b/nireports/tests/conftest.py new file mode 100644 index 00000000..1a26f480 --- /dev/null +++ b/nireports/tests/conftest.py @@ -0,0 +1,50 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +# +# Copyright 2021 The NiPreps Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# We support and encourage derived works from this project, please read +# about our expectations at +# +# https://www.nipreps.org/community/licensing/ +# +""" py.test configuration file """ +import pytest +from templateflow.api import get as get_template +from nireports.tests.testing import data_env_canary + + +@pytest.fixture +def reference(): + return str(get_template("MNI152Lin", resolution=2, desc=None, suffix="T1w")) + + +@pytest.fixture +def reference_mask(): + return str(get_template("MNI152Lin", resolution=2, desc="brain", suffix="mask")) + + +@pytest.fixture +def moving(test_data_home): + data_env_canary() + return str(test_data_home / "ds000003/sub-01/anat/sub-01_T1w.nii.gz") + + +@pytest.fixture +def nthreads(): + from os import cpu_count, getenv + + # Tests are linear, so don't worry about leaving space for a control thread + return min(int(getenv("CIRCLE_NPROCS", "8")), cpu_count()) diff --git a/nireports/tests/test_interfaces_registration.py b/nireports/tests/test_interfaces_registration.py new file mode 100644 index 00000000..d8c1e628 --- /dev/null +++ b/nireports/tests/test_interfaces_registration.py @@ -0,0 +1,145 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +# +# Copyright 2021 The NiPreps Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# We support and encourage derived works from this project, please read +# about our expectations at +# +# https://www.nipreps.org/community/licensing/ +# +""" Registration tests """ +import os +from shutil import copy +import pytest +from tempfile import TemporaryDirectory + +from nipype.pipeline import engine as pe +from nireports.interfaces.reporting.registration import ( + FLIRTRPT, + BBRegisterRPT, + MRICoregRPT, + ApplyXFMRPT, + SimpleBeforeAfterRPT, +) +from nireports.tests.testing import has_fsl, has_freesurfer, _run_interface_mock + + +def _smoke_test_report(report_interface, artifact_name): + with TemporaryDirectory() as tmpdir: + res = pe.Node(report_interface, name="smoke_test", base_dir=tmpdir).run() + out_report = res.outputs.out_report + + save_artifacts = os.getenv("SAVE_CIRCLE_ARTIFACTS", False) + if save_artifacts: + copy(out_report, os.path.join(save_artifacts, artifact_name)) + assert os.path.isfile(out_report), "Report does not exist" + + +@pytest.mark.skipif(not has_fsl, reason="No FSL") +def test_FLIRTRPT(reference, moving): + """ the FLIRT report capable test """ + flirt_rpt = FLIRTRPT(generate_report=True, in_file=moving, reference=reference) + _smoke_test_report(flirt_rpt, "testFLIRT.svg") + + +@pytest.mark.skipif(not has_freesurfer, reason="No FreeSurfer") +def test_MRICoregRPT(monkeypatch, test_data_package, reference, moving, nthreads): + """ the MRICoreg report capable test """ + + def _agg(objekt, runtime): + outputs = objekt.output_spec() + outputs.out_lta_file = str(( + test_data_package + / "testMRICoregRPT-out_lta_file.lta" + ).resolve()) + outputs.out_report = os.path.join(runtime.cwd, objekt.inputs.out_report) + return outputs + + # Patch the _run_interface method + monkeypatch.setattr(MRICoregRPT, "_run_interface", _run_interface_mock) + monkeypatch.setattr(MRICoregRPT, "aggregate_outputs", _agg) + + mri_coreg_rpt = MRICoregRPT( + generate_report=True, + source_file=moving, + reference_file=reference, + num_threads=nthreads, + ) + _smoke_test_report(mri_coreg_rpt, "testMRICoreg.svg") + + +@pytest.mark.skipif(not has_fsl, reason="No FSL") +def test_ApplyXFMRPT(reference, moving): + """ the ApplyXFM report capable test """ + flirt_rpt = FLIRTRPT(generate_report=False, in_file=moving, reference=reference) + + applyxfm_rpt = ApplyXFMRPT( + generate_report=True, + in_file=moving, + in_matrix_file=flirt_rpt.run().outputs.out_matrix_file, + reference=reference, + apply_xfm=True, + ) + _smoke_test_report(applyxfm_rpt, "testApplyXFM.svg") + + +@pytest.mark.skipif(not has_fsl, reason="No FSL") +def test_SimpleBeforeAfterRPT(reference, moving): + """ the SimpleBeforeAfterRPT report capable test """ + flirt_rpt = FLIRTRPT(generate_report=False, in_file=moving, reference=reference) + + ba_rpt = SimpleBeforeAfterRPT( + generate_report=True, before=reference, after=flirt_rpt.run().outputs.out_file + ) + _smoke_test_report(ba_rpt, "test_SimpleBeforeAfterRPT.svg") + + +@pytest.mark.skipif(not has_fsl, reason="No FSL") +def test_FLIRTRPT_w_BBR(reference, reference_mask, moving): + """ test FLIRTRPT with input `wm_seg` set. + For the sake of testing ONLY, `wm_seg` is set to the filename of a brain mask """ + flirt_rpt = FLIRTRPT( + generate_report=True, in_file=moving, reference=reference, wm_seg=reference_mask + ) + _smoke_test_report(flirt_rpt, "testFLIRTRPTBBR.svg") + + +@pytest.mark.skipif(not has_freesurfer, reason="No FreeSurfer") +def test_BBRegisterRPT(monkeypatch, moving, test_data_package): + """ the BBRegister report capable test """ + + def _agg(objekt, runtime): + outputs = objekt.output_spec() + outputs.out_lta_file = os.path.join( + test_data_package, "testBBRegisterRPT-out_lta_file.lta" + ) + outputs.out_report = os.path.join(runtime.cwd, objekt.inputs.out_report) + return outputs + + # Patch the _run_interface method + monkeypatch.setattr(BBRegisterRPT, "_run_interface", _run_interface_mock) + monkeypatch.setattr(BBRegisterRPT, "aggregate_outputs", _agg) + + subject_id = "fsaverage" + bbregister_rpt = BBRegisterRPT( + generate_report=True, + contrast_type="t1", + init="fsl", + source_file=moving, + subject_id=subject_id, + registered_file=True, + ) + _smoke_test_report(bbregister_rpt, "testBBRegister.svg") diff --git a/nireports/tests/test_interfaces_segmentation.py b/nireports/tests/test_interfaces_segmentation.py new file mode 100644 index 00000000..54a7e574 --- /dev/null +++ b/nireports/tests/test_interfaces_segmentation.py @@ -0,0 +1,258 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +# +# Copyright 2021 The NiPreps Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# We support and encourage derived works from this project, please read +# about our expectations at +# +# https://www.nipreps.org/community/licensing/ +# +""" Segmentation tests """ +import os +from shutil import copy +from tempfile import TemporaryDirectory +import pytest +from templateflow.api import get as get_template + +from nipype.pipeline import engine as pe +from nireports.interfaces.reporting.segmentation import FASTRPT, ReconAllRPT +from nireports.interfaces.reporting.masks import ( + BETRPT, + BrainExtractionRPT, + SimpleShowMaskRPT, + ROIsPlot, +) +from nireports.tests.testing import has_fsl, has_freesurfer, _run_interface_mock + + +def _smoke_test_report(report_interface, artifact_name): + with TemporaryDirectory() as tmpdir: + res = pe.Node(report_interface, name="smoke_test", base_dir=tmpdir).run() + out_report = res.outputs.out_report + + save_artifacts = os.getenv("SAVE_CIRCLE_ARTIFACTS", False) + if save_artifacts: + copy(out_report, os.path.join(save_artifacts, artifact_name)) + assert os.path.isfile(out_report), 'Report "%s" does not exist' % out_report + + +@pytest.mark.skipif(not has_fsl, reason="No FSL") +def test_BETRPT(moving): + """ the BET report capable test """ + bet_rpt = BETRPT(generate_report=True, in_file=moving) + _smoke_test_report(bet_rpt, "testBET.svg") + + +def test_ROIsPlot(tmp_path): + """ the BET report capable test """ + import nibabel as nb + import numpy as np + + im = nb.load( + str( + get_template( + "OASIS30ANTs", + resolution=1, + desc="4", + suffix="dseg", + extension=[".nii", ".nii.gz"], + ) + ) + ) + lookup = np.zeros(5, dtype=int) + lookup[1] = 1 + lookup[2] = 4 + lookup[3] = 2 + lookup[4] = 3 + newdata = lookup[np.round(im.get_fdata()).astype(int)] + hdr = im.header.copy() + hdr.set_data_dtype("int16") + hdr["scl_slope"] = 1 + hdr["scl_inter"] = 0 + out_file = str(tmp_path / "segments.nii.gz") + nb.Nifti1Image(newdata, im.affine, hdr).to_filename(out_file) + roi_rpt = ROIsPlot( + generate_report=True, + in_file=str(get_template("OASIS30ANTs", resolution=1, desc=None, suffix="T1w")), + in_mask=str( + get_template("OASIS30ANTs", resolution=1, desc="brain", suffix="mask") + ), + in_rois=[out_file], + levels=[1.5, 2.5, 3.5], + colors=["gold", "magenta", "b"], + ) + _smoke_test_report(roi_rpt, "testROIsPlot.svg") + + +def test_ROIsPlot2(tmp_path): + """ the BET report capable test """ + import nibabel as nb + import numpy as np + + im = nb.load( + str( + get_template( + "OASIS30ANTs", + resolution=1, + desc="4", + suffix="dseg", + extension=[".nii", ".nii.gz"], + ) + ) + ) + lookup = np.zeros(5, dtype=int) + lookup[1] = 1 + lookup[2] = 4 + lookup[3] = 2 + lookup[4] = 3 + newdata = lookup[np.round(im.get_fdata()).astype(int)] + hdr = im.header.copy() + hdr.set_data_dtype("int16") + hdr["scl_slope"] = 1 + hdr["scl_inter"] = 0 + + out_files = [] + for i in range(1, 5): + seg = np.zeros_like(newdata, dtype="uint8") + seg[(newdata > 0) & (newdata <= i)] = 1 + out_file = str(tmp_path / ("segments%02d.nii.gz" % i)) + nb.Nifti1Image(seg, im.affine, hdr).to_filename(out_file) + out_files.append(out_file) + roi_rpt = ROIsPlot( + generate_report=True, + in_file=str(get_template("OASIS30ANTs", resolution=1, desc=None, suffix="T1w")), + in_mask=str( + get_template("OASIS30ANTs", resolution=1, desc="brain", suffix="mask") + ), + in_rois=out_files, + colors=["gold", "lightblue", "b", "g"], + ) + _smoke_test_report(roi_rpt, "testROIsPlot2.svg") + + +def test_SimpleShowMaskRPT(): + """ the BET report capable test """ + + msk_rpt = SimpleShowMaskRPT( + generate_report=True, + background_file=str( + get_template("OASIS30ANTs", resolution=1, desc=None, suffix="T1w") + ), + mask_file=str( + get_template( + "OASIS30ANTs", + resolution=1, + desc="BrainCerebellumRegistration", + suffix="mask", + ) + ), + ) + _smoke_test_report(msk_rpt, "testSimpleMask.svg") + + +def test_BrainExtractionRPT(monkeypatch, moving, nthreads, test_data_package): + """ test antsBrainExtraction with reports""" + + def _agg(objekt, runtime): + outputs = objekt.output_spec() + outputs.BrainExtractionMask = os.path.join( + test_data_package, "testBrainExtractionRPTBrainExtractionMask.nii.gz" + ) + outputs.out_report = os.path.join(runtime.cwd, objekt.inputs.out_report) + return outputs + + # Patch the _run_interface method + monkeypatch.setattr(BrainExtractionRPT, "_run_interface", _run_interface_mock) + monkeypatch.setattr(BrainExtractionRPT, "aggregate_outputs", _agg) + + bex_rpt = BrainExtractionRPT( + generate_report=True, + dimension=3, + use_floatingpoint_precision=1, + anatomical_image=moving, + brain_template=str( + get_template("OASIS30ANTs", resolution=1, desc=None, suffix="T1w") + ), + brain_probability_mask=str( + get_template("OASIS30ANTs", resolution=1, label="brain", suffix="probseg") + ), + extraction_registration_mask=str( + get_template( + "OASIS30ANTs", + resolution=1, + desc="BrainCerebellumRegistration", + suffix="mask", + ) + ), + out_prefix="testBrainExtractionRPT", + debug=True, # run faster for testing purposes + num_threads=nthreads, + ) + _smoke_test_report(bex_rpt, "testANTSBrainExtraction.svg") + + +@pytest.mark.skipif(not has_fsl, reason="No FSL") +@pytest.mark.parametrize("segments", [True, False]) +def test_FASTRPT(monkeypatch, segments, reference, reference_mask, test_data_package): + """ test FAST with the two options for segments """ + from nipype.interfaces.fsl.maths import ApplyMask + + def _agg(objekt, runtime): + outputs = objekt.output_spec() + outputs.out_report = os.path.join(runtime.cwd, objekt.inputs.out_report) + outputs.tissue_class_map = os.path.join( + test_data_package, "testFASTRPT-tissue_class_map.nii.gz" + ) + outputs.tissue_class_files = [ + os.path.join(test_data_package, "testFASTRPT-tissue_class_files0.nii.gz"), + os.path.join(test_data_package, "testFASTRPT-tissue_class_files1.nii.gz"), + os.path.join(test_data_package, "testFASTRPT-tissue_class_files2.nii.gz"), + ] + return outputs + + # Patch the _run_interface method + monkeypatch.setattr(FASTRPT, "_run_interface", _run_interface_mock) + monkeypatch.setattr(FASTRPT, "aggregate_outputs", _agg) + + brain = ( + pe.Node(ApplyMask(in_file=reference, mask_file=reference_mask), name="brain") + .run() + .outputs.out_file + ) + fast_rpt = FASTRPT( + in_files=brain, + generate_report=True, + no_bias=True, + probability_maps=True, + segments=segments, + out_basename="test", + ) + _smoke_test_report(fast_rpt, "testFAST_%ssegments.svg" % ("no" * int(not segments))) + + +@pytest.mark.skipif(not has_freesurfer, reason="No FreeSurfer") +def test_ReconAllRPT(monkeypatch): + # Patch the _run_interface method + monkeypatch.setattr(ReconAllRPT, "_run_interface", _run_interface_mock) + + rall_rpt = ReconAllRPT( + subject_id="fsaverage", + directive="all", + subjects_dir=os.getenv("SUBJECTS_DIR"), + generate_report=True, + ) + + _smoke_test_report(rall_rpt, "testReconAll.svg") diff --git a/nireports/tests/test_interfaces_utils.py b/nireports/tests/test_interfaces_utils.py new file mode 100644 index 00000000..b8ff3ace --- /dev/null +++ b/nireports/tests/test_interfaces_utils.py @@ -0,0 +1,93 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +# +# Copyright 2021 The NiPreps Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# We support and encourage derived works from this project, please read +# about our expectations at +# +# https://www.nipreps.org/community/licensing/ +# +""" Utilities tests """ +import os +import pytest +from shutil import which + +from nipype.pipeline import engine as pe +from templateflow.api import get as get_template + +from nireports.interfaces.reporting.masks import SimpleShowMaskRPT + + +@pytest.mark.skipif( + which("svgo") is None or which("cwebp") is None, reason="svgo or cwebp missing" +) +def test_compression(tmp_path): + """ the BET report capable test """ + + uncompressed = ( + pe.Node( + SimpleShowMaskRPT( + generate_report=True, + background_file=str( + get_template("OASIS30ANTs", resolution=1, desc=None, suffix="T1w") + ), + mask_file=str( + get_template( + "OASIS30ANTs", + resolution=1, + desc="BrainCerebellumRegistration", + suffix="mask", + ) + ), + compress_report=False, + ), + name="uncompressed", + base_dir=str(tmp_path), + ) + .run() + .outputs.out_report + ) + + compressed = ( + pe.Node( + SimpleShowMaskRPT( + generate_report=True, + background_file=str( + get_template("OASIS30ANTs", resolution=1, desc=None, suffix="T1w") + ), + mask_file=str( + get_template( + "OASIS30ANTs", + resolution=1, + desc="BrainCerebellumRegistration", + suffix="mask", + ) + ), + compress_report=True, + ), + name="compressed", + base_dir=str(tmp_path), + ) + .run() + .outputs.out_report + ) + + size = int(os.stat(uncompressed).st_size) + size_compress = int(os.stat(compressed).st_size) + assert size >= size_compress, ( + "The uncompressed report is smaller (%d)" + "than the compressed report (%d)" % (size, size_compress) + ) diff --git a/nireports/tests/test_reportlets.py b/nireports/tests/test_reportlets.py index 85779baa..180acdc2 100644 --- a/nireports/tests/test_reportlets.py +++ b/nireports/tests/test_reportlets.py @@ -42,7 +42,7 @@ from nireports.tools.timeseries import get_tr as _get_tr from nireports.tools.timeseries import nifti_timeseries as _nifti_timeseries -from .generate_data import _create_dtseries_cifti +from nireports.tests.testing import _create_dtseries_cifti @pytest.mark.parametrize("tr", (None, 0.7)) @@ -139,12 +139,12 @@ def test_carpetplot(tr, sorting, outdir): ), ], ) -def test_fmriplot(input_files, testdata_path, outdir): +def test_fmriplot(input_files, test_data_package, outdir): """Exercise the fMRIPlot class.""" rng = np.random.default_rng(2010) - in_file = os.path.join(testdata_path, input_files[0]) - seg_file = os.path.join(testdata_path, input_files[1]) if input_files[1] is not None else None + in_file = os.path.join(test_data_package, input_files[0]) + seg_file = os.path.join(test_data_package, input_files[1]) if input_files[1] is not None else None dtype = "nifti" if input_files[0].endswith("volreg.nii.gz") else "cifti" has_seg = "_parc" if seg_file else "" @@ -253,14 +253,14 @@ def test_plot_melodic_components(tmp_path, outdir): ) -def test_compcor_variance_plot(tmp_path, testdata_path, outdir): +def test_compcor_variance_plot(tmp_path, test_data_package, outdir): """Test plotting CompCor variance""" if outdir is None: outdir = Path(str(tmp_path)) out_file = str(outdir / "variance_plot_short.svg") - metadata_file = os.path.join(testdata_path, "confounds_metadata_short_test.tsv") + metadata_file = os.path.join(test_data_package, "confounds_metadata_short_test.tsv") compcor_variance_plot([metadata_file], output_file=out_file) @@ -289,11 +289,11 @@ def test_cifti_surfaces_plot(tmp_path, create_surface_dtseries, outdir): cifti_surfaces_plot(create_surface_dtseries, output_file=out_file) -def test_cifti_carpetplot(tmp_path, testdata_path, outdir): +def test_cifti_carpetplot(tmp_path, test_data_package, outdir): """Exercise extraction of timeseries from CIFTI2.""" cifti_file = os.path.join( - testdata_path, + test_data_package, "sub-01_task-mixedgamblestask_run-02_space-fsLR_den-91k_bold.dtseries.nii", ) data, segments = _cifti_timeseries(cifti_file) @@ -307,15 +307,15 @@ def test_cifti_carpetplot(tmp_path, testdata_path, outdir): ) -def test_nifti_carpetplot(tmp_path, testdata_path, outdir): +def test_nifti_carpetplot(tmp_path, test_data_package, outdir): """Exercise extraction of timeseries from CIFTI2.""" nifti_file = os.path.join( - testdata_path, + test_data_package, "sub-ds205s03_task-functionallocalizer_run-01_bold_volreg.nii.gz", ) seg_file = os.path.join( - testdata_path, + test_data_package, "sub-ds205s03_task-functionallocalizer_run-01_bold_parc.nii.gz", ) data, segments = _nifti_timeseries(nifti_file, seg_file) @@ -336,7 +336,7 @@ def test_nifti_carpetplot(tmp_path, testdata_path, outdir): @pytest.mark.parametrize("views", _views) @pytest.mark.parametrize("plot_sagittal", (True, False)) -def test_mriqc_plot_mosaic(tmp_path, testdata_path, outdir, views, plot_sagittal): +def test_mriqc_plot_mosaic(tmp_path, test_data_package, outdir, views, plot_sagittal): """Exercise the generation of mosaics.""" fname = ( @@ -360,7 +360,7 @@ def test_mriqc_plot_mosaic(tmp_path, testdata_path, outdir, views, plot_sagittal testfunc() -def test_mriqc_plot_mosaic_2(tmp_path, testdata_path, outdir): +def test_mriqc_plot_mosaic_2(tmp_path, test_data_package, outdir): """Exercise the generation of mosaics.""" plot_mosaic( get("Fischer344", desc=None, suffix="T2w"), diff --git a/nireports/tests/generate_data.py b/nireports/tests/testing.py similarity index 61% rename from nireports/tests/generate_data.py rename to nireports/tests/testing.py index dd8ecf81..3919c85f 100644 --- a/nireports/tests/generate_data.py +++ b/nireports/tests/testing.py @@ -1,6 +1,58 @@ +import os +from functools import wraps from pathlib import Path - +from datetime import datetime as dt import numpy as np +import pytest + +from nipype.interfaces import fsl, freesurfer as fs, afni +from nireports.conftest import niprepsdev_path + + +has_fsl = fsl.Info.version() is not None +has_freesurfer = fs.Info.version() is not None +has_afni = afni.Info.version() is not None + +test_output_dir = os.getenv("TEST_OUTPUT_DIR") +test_workdir = os.getenv("TEST_WORK_DIR") + +data_dir = Path(niprepsdev_path) / "BIDS-examples-1-enh-ds054" + + +def create_canary(predicate, message): + def canary(): + if predicate: + pytest.skip(message) + + def decorator(f): + @wraps(f) + def wrapper(*args, **kwargs): + canary() + return f(*args, **kwargs) + return wrapper + + return canary, decorator + + +data_env_canary, needs_data_env = create_canary( + not Path(niprepsdev_path).is_dir(), + "Test data must be made available in ~/.cache/stanford-crn or in a " + "directory referenced by the TEST_DATA_HOME environment variable.") + +data_dir_canary, needs_data_dir = create_canary( + not Path(niprepsdev_path).is_dir(), + "Test data must be made available in ~/.cache/stanford-crn or in a " + "directory referenced by the TEST_DATA_HOME environment variable.") + + +def _run_interface_mock(objekt, runtime): + runtime.returncode = 0 + runtime.endTime = dt.isoformat(dt.utcnow()) + + objekt._out_report = str(Path(objekt.inputs.out_report).absolute()) + objekt._post_run_hook(runtime) + objekt._generate_report() + return runtime def _create_dtseries_cifti(timepoints, models): diff --git a/nireports/tools/timeseries.py b/nireports/tools/timeseries.py index 5f2e46f2..24971b71 100644 --- a/nireports/tools/timeseries.py +++ b/nireports/tools/timeseries.py @@ -37,12 +37,12 @@ def get_tr(img): Examples -------- >>> get_tr(nb.load( - ... testdata_path + ... test_data_package ... / 'sub-ds205s03_task-functionallocalizer_run-01_bold_volreg.nii.gz' ... )) 2.2 >>> get_tr(nb.load( - ... testdata_path + ... test_data_package ... / 'sub-01_task-mixedgamblestask_run-02_space-fsLR_den-91k_bold.dtseries.nii' ... )) 2.0