From 9bc7dedc5f9b603cdcd6f604b51b8d845f2f1be5 Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Tue, 18 Apr 2023 10:00:53 +0200 Subject: [PATCH 1/6] enh: finalize migration of reporting interfaces Resolves: #54. --- .circleci/config.yml | 41 +++ nireports/conftest.py | 13 +- nireports/interfaces/reporting/masks.py | 281 +++++++++++++++++ .../interfaces/reporting/registration.py | 298 ++++++++++++++++++ .../interfaces/reporting/segmentation.py | 224 +++++++++++++ nireports/tests/conftest.py | 27 ++ .../tests/test_interfaces_registration.py | 146 +++++++++ .../tests/test_interfaces_segmentation.py | 252 +++++++++++++++ nireports/tests/test_interfaces_utils.py | 94 ++++++ nireports/tests/test_reportlets.py | 29 +- .../tests/{generate_data.py => testing.py} | 56 ++++ nireports/tools/timeseries.py | 4 +- 12 files changed, 1445 insertions(+), 20 deletions(-) create mode 100644 nireports/interfaces/reporting/masks.py create mode 100644 nireports/interfaces/reporting/registration.py create mode 100644 nireports/interfaces/reporting/segmentation.py create mode 100644 nireports/tests/test_interfaces_registration.py create mode 100644 nireports/tests/test_interfaces_segmentation.py create mode 100644 nireports/tests/test_interfaces_utils.py rename nireports/tests/{generate_data.py => testing.py} (60%) diff --git a/.circleci/config.yml b/.circleci/config.yml index 619b1d43..d336751f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -20,6 +20,12 @@ jobs: name: Install package command: pip install .[test] + - 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: - apt-v0 @@ -48,12 +54,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 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 4fb25190..faa98f97 100644 --- a/nireports/conftest.py +++ b/nireports/conftest.py @@ -36,7 +36,7 @@ os.environ["NO_ET"] = "1" _datadir = (Path(__file__).parent / "tests" / "data").absolute() -niprepsdev_path = os.getenv("TEST_DATA_HOME", str(Path.home() / ".cache" / "nipreps-dev")) +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") @@ -58,8 +58,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 @@ -79,10 +79,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/masks.py b/nireports/interfaces/reporting/masks.py new file mode 100644 index 00000000..34d1c6d7 --- /dev/null +++ b/nireports/interfaces/reporting/masks.py @@ -0,0 +1,281 @@ +# 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 nibabel as nb +import numpy as np +from nipype import logging +from nipype.algorithms import confounds +from nipype.interfaces import ants, fsl +from nipype.interfaces.base import ( + File, + InputMultiPath, + Str, + isdefined, + traits, +) +from nipype.interfaces.mixins import reporting + +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 + + levels = self.inputs.levels or [] + colors = 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..4b817db2 --- /dev/null +++ b/nireports/interfaces/reporting/registration.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 registration tools.""" + +import os + +from looseversion import LooseVersion +from nipype import logging +from nipype.interfaces import freesurfer as fs +from nipype.interfaces import fsl +from nipype.interfaces.base import ( + File, + isdefined, + traits, +) +from nipype.interfaces.mixins import reporting +from nipype.utils.filemanip import fname_presuffix + +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..d1213322 --- /dev/null +++ b/nireports/interfaces/reporting/segmentation.py @@ -0,0 +1,224 @@ +# 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 import freesurfer, fsl +from nipype.interfaces.base import File, isdefined +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 index 0a8d706b..570c5fa3 100644 --- a/nireports/tests/conftest.py +++ b/nireports/tests/conftest.py @@ -25,8 +25,35 @@ import os import pytest +from templateflow.api import get as get_template + +from nireports.tests.testing import data_env_canary @pytest.fixture(scope="session") def datadir(): return os.path.abspath(os.path.join(os.path.dirname(__file__), "data") + os.path.sep) + + +@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..bf386279 --- /dev/null +++ b/nireports/tests/test_interfaces_registration.py @@ -0,0 +1,146 @@ +# 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 +from tempfile import TemporaryDirectory + +import pytest +from nipype.pipeline import engine as pe + +from nireports.interfaces.reporting.registration import ( + FLIRTRPT, + ApplyXFMRPT, + BBRegisterRPT, + MRICoregRPT, + SimpleBeforeAfterRPT, +) +from nireports.tests.testing import _run_interface_mock, has_freesurfer, has_fsl + + +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..38fb0ed4 --- /dev/null +++ b/nireports/tests/test_interfaces_segmentation.py @@ -0,0 +1,252 @@ +# 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 nipype.pipeline import engine as pe +from templateflow.api import get as get_template + +from nireports.interfaces.reporting.masks import ( + BETRPT, + BrainExtractionRPT, + ROIsPlot, + SimpleShowMaskRPT, +) +from nireports.interfaces.reporting.segmentation import FASTRPT, ReconAllRPT +from nireports.tests.testing import _run_interface_mock, has_freesurfer, has_fsl + + +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..1152d38c --- /dev/null +++ b/nireports/tests/test_interfaces_utils.py @@ -0,0 +1,94 @@ +# 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 +from shutil import which + +import pytest +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 2f2f9e07..fc56a69d 100644 --- a/nireports/tests/test_reportlets.py +++ b/nireports/tests/test_reportlets.py @@ -38,13 +38,12 @@ from nireports.reportlets.nuisance import plot_carpet, plot_raincloud from nireports.reportlets.surface import cifti_surfaces_plot from nireports.reportlets.xca import compcor_variance_plot, plot_melodic_components +from nireports.tests.testing import _create_dtseries_cifti from nireports.tests.utils import _generate_raincloud_random_data from nireports.tools.timeseries import cifti_timeseries as _cifti_timeseries 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 - @pytest.mark.parametrize("tr", (None, 0.7)) @pytest.mark.parametrize("sorting", (None, "ward", "linkage")) @@ -140,12 +139,14 @@ 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 "" @@ -255,14 +256,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) @@ -291,11 +292,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) @@ -309,15 +310,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) @@ -337,7 +338,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 = f"mosaic_{'_'.join(v or 'none' for v in views)}_{plot_sagittal:d}.svg" @@ -359,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 60% rename from nireports/tests/generate_data.py rename to nireports/tests/testing.py index f0da774d..dd2fdb48 100644 --- a/nireports/tests/generate_data.py +++ b/nireports/tests/testing.py @@ -1,6 +1,62 @@ +import os +from datetime import datetime as dt +from functools import wraps from pathlib import Path import numpy as np +import pytest +from nipype.interfaces import afni, fsl +from nipype.interfaces import freesurfer as fs + +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 e184efff..cd2d6060 100644 --- a/nireports/tools/timeseries.py +++ b/nireports/tools/timeseries.py @@ -38,12 +38,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 From 70bfd85d0de4807e876d74f0f2a0505f58859b2a Mon Sep 17 00:00:00 2001 From: Oscar Esteban Date: Thu, 18 Jan 2024 10:32:20 +0100 Subject: [PATCH 2/6] Update nireports/conftest.py Co-authored-by: Mathias Goncalves --- nireports/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nireports/conftest.py b/nireports/conftest.py index faa98f97..f367679e 100644 --- a/nireports/conftest.py +++ b/nireports/conftest.py @@ -36,7 +36,7 @@ os.environ["NO_ET"] = "1" _datadir = (Path(__file__).parent / "tests" / "data").absolute() -niprepsdev_path = Path(os.getenv("TEST_DATA_HOME", str(Path.home() / ".cache" / "nipreps-dev"))) +niprepsdev_path = Path(os.getenv("TEST_DATA_HOME", Path.home() / ".cache" / "nipreps-dev")) test_output_dir = os.getenv("TEST_OUTPUT_DIR") test_workdir = os.getenv("TEST_WORK_DIR") From dcff97de19db4dd7d674d769b824cba54228bad3 Mon Sep 17 00:00:00 2001 From: Chris Markiewicz Date: Fri, 4 Oct 2024 17:08:07 -0400 Subject: [PATCH 3/6] fix: Switch to test_data_package universally --- nireports/tests/test_dwi.py | 26 +++++++++++++------------- nireports/tests/test_interfaces.py | 6 +++--- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/nireports/tests/test_dwi.py b/nireports/tests/test_dwi.py index 9bb27582..b1e38c22 100644 --- a/nireports/tests/test_dwi.py +++ b/nireports/tests/test_dwi.py @@ -38,15 +38,15 @@ from nireports.tests.utils import _generate_raincloud_random_data -def test_plot_dwi(tmp_path, testdata_path, outdir): +def test_plot_dwi(tmp_path, test_data_package, outdir): """Check the plot of DWI data.""" stem = "ds000114_sub-01_ses-test_desc-trunc_dwi" - dwi_img = nb.load(testdata_path / f"{stem}.nii.gz") + dwi_img = nb.load(test_data_package / f"{stem}.nii.gz") affine = dwi_img.affine - bvecs = np.loadtxt(testdata_path / f"{stem}.bvec").T - bvals = np.loadtxt(testdata_path / f"{stem}.bval") + bvecs = np.loadtxt(test_data_package / f"{stem}.bvec").T + bvals = np.loadtxt(test_data_package / f"{stem}.bval") gradients = np.hstack([bvecs, bvals[:, None]]) @@ -65,11 +65,11 @@ def test_plot_dwi(tmp_path, testdata_path, outdir): "dwi_btable", ["ds000114_singleshell", "hcph_multishell", "ds004737_dsi"], ) -def test_plot_gradients(tmp_path, testdata_path, dwi_btable, outdir): +def test_plot_gradients(tmp_path, test_data_package, dwi_btable, outdir): """Check the plot of DWI gradients.""" - bvecs = np.loadtxt(testdata_path / f"{dwi_btable}.bvec").T - bvals = np.loadtxt(testdata_path / f"{dwi_btable}.bval") + bvecs = np.loadtxt(test_data_package / f"{dwi_btable}.bvec").T + bvals = np.loadtxt(test_data_package / f"{dwi_btable}.bval") b0s_mask = bvals < 50 @@ -120,19 +120,19 @@ def test_plot_tissue_values(tmp_path): ) -def test_nii_to_carpetplot_data(tmp_path, testdata_path, outdir): +def test_nii_to_carpetplot_data(tmp_path, test_data_package, outdir): """Check the nii to carpet plot data function""" testdata_name = "ds000114_sub-01_ses-test_desc-trunc_dwi" - nii = nb.load(testdata_path / f"{testdata_name}.nii.gz") - bvals = np.loadtxt(testdata_path / f"{testdata_name}.bval") + nii = nb.load(test_data_package / f"{testdata_name}.nii.gz") + bvals = np.loadtxt(test_data_package / f"{testdata_name}.bval") mask_data = np.round(82 * np.random.rand(nii.shape[0], nii.shape[1], nii.shape[2])) mask_nii = nb.Nifti1Image(mask_data, np.eye(4)) - filepath = testdata_path / "aseg.auto_noCCseg.label_intensities.txt" + filepath = test_data_package / "aseg.auto_noCCseg.label_intensities.txt" keywords = ["Cerebral_White_Matter", "Cerebral_Cortex", "Ventricle"] segment_labels = get_segment_labels(filepath, keywords) @@ -149,12 +149,12 @@ def test_nii_to_carpetplot_data(tmp_path, testdata_path, outdir): plot_carpet(data, segments, output_file=image_path) -def test_get_segment_labels(tmp_path, testdata_path): +def test_get_segment_labels(tmp_path, test_data_package): """Check the segment label function""" testdata_name = "aseg.auto_noCCseg.label_intensities.txt" - filepath = testdata_path / testdata_name + filepath = test_data_package / testdata_name keywords = ["Cerebral_White_Matter", "Cerebral_Cortex", "Ventricle"] segment_labels = get_segment_labels(filepath, keywords) diff --git a/nireports/tests/test_interfaces.py b/nireports/tests/test_interfaces.py index a31fc8a5..b99f6b53 100644 --- a/nireports/tests/test_interfaces.py +++ b/nireports/tests/test_interfaces.py @@ -101,11 +101,11 @@ def test_RaincloudPlot(orient, density, tmp_path): _smoke_test_report(rc_rpt, f"raincloud_orient-{orient}_density-{density}.svg") -def test_FMRISummary(testdata_path, tmp_path, outdir): +def test_FMRISummary(test_data_package, tmp_path, outdir): """Exercise the FMRISummary interface.""" rng = np.random.default_rng(2010) - in_func = testdata_path / "sub-ds205s03_task-functionallocalizer_run-01_bold_volreg.nii.gz" + in_func = test_data_package / "sub-ds205s03_task-functionallocalizer_run-01_bold_volreg.nii.gz" ntimepoints = nb.load(in_func).shape[-1] np.savetxt( @@ -126,7 +126,7 @@ def test_FMRISummary(testdata_path, tmp_path, outdir): interface = FMRISummary( in_func=str(in_func), in_segm=str( - testdata_path / "sub-ds205s03_task-functionallocalizer_run-01_bold_parc.nii.gz" + test_data_package / "sub-ds205s03_task-functionallocalizer_run-01_bold_parc.nii.gz" ), fd=str(tmp_path / "fd.txt"), outliers=str(tmp_path / "outliers.txt"), From d60c287c4abd5b241e45f6d0be1beacf73d9e37c Mon Sep 17 00:00:00 2001 From: Chris Markiewicz Date: Fri, 4 Oct 2024 20:53:07 -0400 Subject: [PATCH 4/6] CI: Install git-annex and uv, run datalad via uvx --- .circleci/config.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index d336751f..4e8c36d3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -16,6 +16,10 @@ jobs: - TEMPLATEFLOW_HOME: /tmp/templateflow steps: - checkout + - run: + name: Install uv + command: pip install uv + - run: name: Install package command: pip install .[test] @@ -33,10 +37,10 @@ jobs: - /var/lib/apt - run: - name: Install texlive + name: Install texlive and git-annex command: | sudo apt-get update - sudo apt-get install -y --no-install-recommends dvipng texlive texlive-latex-extra cm-super + sudo apt-get install -y --no-install-recommends dvipng texlive texlive-latex-extra cm-super git-annex - save_cache: key: apt-v0 @@ -77,10 +81,10 @@ jobs: mkdir -p /tmp/data pushd /tmp/data if [[ ! -d ds000003 ]]; then - datalad install -r https://github.com/nipreps-data/ds000003.git + uvx --with=datalad-osf 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/* + uvx --with=datalad-osf datalad update -r --merge -d ds000003/ + uvx --with=datalad-osf datalad get -J 2 -r -d ds000003/ ds000003/* popd - save_cache: From c91991c71a6320a26c5f903ecabcb2856cf41980 Mon Sep 17 00:00:00 2001 From: Chris Markiewicz Date: Fri, 4 Oct 2024 21:09:28 -0400 Subject: [PATCH 5/6] fix: Copy input lists we intend to mutate --- nireports/interfaces/reporting/masks.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nireports/interfaces/reporting/masks.py b/nireports/interfaces/reporting/masks.py index 34d1c6d7..4dba645c 100644 --- a/nireports/interfaces/reporting/masks.py +++ b/nireports/interfaces/reporting/masks.py @@ -235,8 +235,9 @@ def _generate_report(self): seg_files = self.inputs.in_rois mask_file = None if not isdefined(self.inputs.in_mask) else self.inputs.in_mask - levels = self.inputs.levels or [] - colors = self.inputs.colors or [] + # Remove trait decoration and replace None with [] + levels = list(self.inputs.levels or []) + colors = list(self.inputs.colors or []) if len(seg_files) == 1: # in_rois is a segmentation nsegs = len(levels) From cc8865377fc5f955067bb9840cd3804876b2b0f8 Mon Sep 17 00:00:00 2001 From: Chris Markiewicz Date: Fri, 4 Oct 2024 21:10:22 -0400 Subject: [PATCH 6/6] enh: Simplify ROI lookups --- nireports/tests/test_interfaces_segmentation.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/nireports/tests/test_interfaces_segmentation.py b/nireports/tests/test_interfaces_segmentation.py index 38fb0ed4..e27b7aa3 100644 --- a/nireports/tests/test_interfaces_segmentation.py +++ b/nireports/tests/test_interfaces_segmentation.py @@ -74,12 +74,8 @@ def test_ROIsPlot(tmp_path): ) ) ) - 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)] + lookup = np.array([0, 1, 4, 2, 3], dtype=np.int16) + newdata = lookup[np.int16(im.dataobj)] hdr = im.header.copy() hdr.set_data_dtype("int16") hdr["scl_slope"] = 1