diff --git a/CHANGES.rst b/CHANGES.rst index 1af0fe94..ab27a29f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,15 @@ +23.2.2 (August 19, 2024) +======================== +Bug-fix release in the 23.2.x series. + +CHANGES +------- + +**Full Changelog**: https://github.com/nipreps/nireports/compare/23.2.1...23.2.2 + +* ENH: Support PNGs and JPGs in reportlets (#126) + + 23.2.1 (May 07, 2024) ===================== Bug-fix release in the 23.2.x series. diff --git a/nireports/assembler/data/rating-widget/bootstrap.yml b/nireports/assembler/data/rating-widget/bootstrap.yml index 5d73c77b..d41a9d03 100644 --- a/nireports/assembler/data/rating-widget/bootstrap.yml +++ b/nireports/assembler/data/rating-widget/bootstrap.yml @@ -7,7 +7,7 @@ defaults: endpoint: "https://localhost:8000/" access_token: settings: - style: 'width: 30%; top: 100px; left: 65%;' + style: 'width: 30%; top: 100px; left: 65%; max-height: 85%; overflow-y: auto;' navbar_label: Rating widget mintime: 10 components: diff --git a/nireports/assembler/report.py b/nireports/assembler/report.py index 0555fc6c..b58fcfca 100644 --- a/nireports/assembler/report.py +++ b/nireports/assembler/report.py @@ -96,7 +96,7 @@ class Report: ... dirs_exist_ok=True, ... ) >>> REPORT_BASELINE_LENGTH = 41770 - >>> RATING_WIDGET_LENGTH = 83308 + >>> RATING_WIDGET_LENGTH = 83343 Examples diff --git a/nireports/assembler/reportlet.py b/nireports/assembler/reportlet.py index d31d1231..2fba341b 100644 --- a/nireports/assembler/reportlet.py +++ b/nireports/assembler/reportlet.py @@ -33,22 +33,21 @@ from nireports.assembler import data from nireports.assembler.misc import dict2html, read_crashfile -SVG_SNIPPET = [ - """\ +IMG_SNIPPET = """\
- -Problem loading figure {name}. If the link below works, please try \ -reloading the report in your browser. +
Get figure file: {name} -""", - """\ +""" + +SVG_SNIPPET = """\
- + +Problem loading figure {name}. If the link below works, please try \ +reloading the report in your browser.
Get figure file: {name} -""", -] +""" METADATA_ACCORDION_BLOCK = """\
@@ -265,10 +264,36 @@ def __init__(self, layout, config=None, out_dir=None, bids_filters=None, metadat style = {"width": "100%"} if is_static else {} style.update(config.get("style", {})) - contents = SVG_SNIPPET[is_static].format( + snippet = IMG_SNIPPET if is_static else SVG_SNIPPET + contents = snippet.format( + ext=ext[1:], name=html_anchor, style="; ".join(f"{k}: {v}" for k, v in style.items()), ) + elif ext in (".png", ".jpg", ".jpeg"): + entities = dict(bidsfile.entities) + if desc_text: + desc_text = desc_text.format(**entities) + + try: + html_anchor = src.relative_to(out_dir) + except ValueError: + html_anchor = src.relative_to(Path(layout.root)) + dst = out_dir / html_anchor + dst.parent.mkdir(parents=True, exist_ok=True) + copyfile(src, dst, copy=True, use_hardlink=True) + + style = {"width": "100%"} + style.update(config.get("style", {})) + + snippet = IMG_SNIPPET + contents = snippet.format( + ext=ext[1:], + name=html_anchor, + style="; ".join(f"{k}: {v}" for k, v in style.items()), + ) + else: + raise RuntimeError(f"Unsupported file extension: {ext}") if contents: self.components.append((contents, desc_text)) diff --git a/nireports/conftest.py b/nireports/conftest.py index c67ff257..4fb25190 100644 --- a/nireports/conftest.py +++ b/nireports/conftest.py @@ -41,6 +41,14 @@ test_workdir = os.getenv("TEST_WORK_DIR") +@pytest.fixture(scope="session", autouse=True) +def legacy_printoptions(): + from packaging.version import Version + + if Version(np.__version__) >= Version("1.22"): + np.set_printoptions(legacy="1.21") + + @pytest.fixture(autouse=True) def expand_namespace(doctest_namespace): doctest_namespace["PY_VERSION"] = version_info diff --git a/nireports/interfaces/__init__.py b/nireports/interfaces/__init__.py index e190ef32..7445a1ac 100644 --- a/nireports/interfaces/__init__.py +++ b/nireports/interfaces/__init__.py @@ -24,11 +24,16 @@ from nireports.interfaces.fmri import FMRISummary from nireports.interfaces.mosaic import PlotContours, PlotMosaic, PlotSpikes -from nireports.interfaces.nuisance import CompCorVariancePlot, ConfoundsCorrelationPlot +from nireports.interfaces.nuisance import ( + CompCorVariancePlot, + ConfoundsCorrelationPlot, + RaincloudPlot, +) __all__ = ( "CompCorVariancePlot", "ConfoundsCorrelationPlot", + "RaincloudPlot", "FMRISummary", "PlotContours", "PlotMosaic", diff --git a/nireports/interfaces/nuisance.py b/nireports/interfaces/nuisance.py index 734767f4..bbb032b1 100644 --- a/nireports/interfaces/nuisance.py +++ b/nireports/interfaces/nuisance.py @@ -32,7 +32,7 @@ ) from nipype.utils.filemanip import fname_presuffix -from nireports.reportlets.nuisance import confounds_correlation_plot +from nireports.reportlets.nuisance import confounds_correlation_plot, plot_raincloud from nireports.reportlets.xca import compcor_variance_plot @@ -142,3 +142,109 @@ def _run_interface(self, runtime): ignore_initial_volumes=self.inputs.ignore_initial_volumes, ) return runtime + + +class _RaincloudPlotInputSpec(BaseInterfaceInputSpec): + data_file = File(exists=True, mandatory=True, desc="File containing the data") + out_file = traits.Either(None, File, value=None, usedefault=True, desc="Path to save plot") + group_name = traits.Str( + "group_name", + mandatory=True, + desc="Group name of interest", + ) + feature = traits.Str( + "feature", + mandatory=True, + desc="Feature of interest", + ) + palette = traits.Str( + "Set2", + usedefault=True, + desc="Color palette name", + ) + orient = traits.Str( + "v", + usedefault=True, + desc="Orientation", + ) + density = traits.Bool( + True, + usedefault=True, + desc="``True`` to plot the density", + ) + upper_limit_value = traits.Float( + None, + usedefault=True, + desc="Upper limit value over which any value in the data will be styled " + "with a different style", + ) + upper_limit_color = traits.Str( + "gray", + usedefault=True, + desc="Lower limit value under which any value in the data will be styled " + "with a different style", + ) + lower_limit_value = traits.Float( + None, + usedefault=True, + desc="", + ) + lower_limit_color = traits.Str( + "gray", + usedefault=True, + desc="Color name to represent values under ``lower_limit_value``", + ) + limit_offset = traits.Float( + None, + usedefault=True, + desc="Offset to plot the values over/under the upper/lower limit values", + ) + mark_nans = traits.Bool( + True, + usedefault=True, + desc="``True`` to plot NaNs as dots. ``nans_values`` must be provided if True", + ) + nans_value = traits.Float( + None, + usedefault=True, + desc="Value to use for NaN values`", + ) + nans_color = traits.Str( + "black", + usedefault=True, + desc="Color name to represent NaN values", + ) + + +class _RaincloudPlotOutputSpec(TraitedSpec): + out_file = File(exists=True, desc="Path to saved plot") + + +class RaincloudPlot(SimpleInterface): + """Plot a raincloud of values.""" + + input_spec = _RaincloudPlotInputSpec + output_spec = _RaincloudPlotOutputSpec + + def _run_interface(self, runtime, **kwargs): + if self.inputs.out_file is None: + self._results["out_file"] = fname_presuffix( + self.inputs.data_file, + suffix="_raincloud.svg", + use_ext=False, + newpath=runtime.cwd, + ) + else: + self._results["out_file"] = self.inputs.out_file + plot_raincloud( + data_file=self.inputs.data_file, + group_name=self.inputs.group_name, + feature=self.inputs.feature, + palette=self.inputs.palette, + orient=self.inputs.orient, + density=self.inputs.density, + mark_nans=self.inputs.mark_nans, + output_file=self._results["out_file"], + **kwargs, + ) + return runtime diff --git a/nireports/reportlets/modality/dwi.py b/nireports/reportlets/modality/dwi.py index 117b16f5..1cbc1ed6 100644 --- a/nireports/reportlets/modality/dwi.py +++ b/nireports/reportlets/modality/dwi.py @@ -29,6 +29,8 @@ from mpl_toolkits.mplot3d import art3d from nilearn.plotting import plot_anat +from nireports.reportlets.nuisance import plot_raincloud + def plot_dwi(dataobj, affine, gradient=None, **kwargs): """ @@ -405,3 +407,190 @@ def plot_gradients( plt.suptitle(title) return ax + + +def plot_tissue_values(data_file, group_name, feature, **kwargs): + """Generate a raincloud plot with the data points corresponding to the + ``feature`` value contained in the data file. + + Parameters + ---------- + data_file : :obj:`str` + File containing the data of interest. + group_name : :obj:`str` + The group name of interest to be plot. + feature : :obj:`str` + The feature of interest to be plot. + kwargs : :obj:`dict` + Extra args given to :func:~`nireports.reportlets.nuisance.plot_raincloud`. + + Returns + ------- + axes and gridspec + Plotting axes and gridspec. Returned only if ``output_file`` is ``None``. + output_file : :obj:`str` + The file where the figure is saved. + """ + + return plot_raincloud(data_file, group_name, feature, **kwargs) + + +def nii_to_carpetplot_data( + nii, + bvals=None, + divide_by_b0=True, + drop_b0=True, + sort_by_bval=False, + mask_nii=None, + segment_labels=None, +): + """ + Convert nii to data matrix for carpet plot + + Parameters + ---------- + nii : Nifti1Image + DW imaging data + bvals : :obj:`numpy.ndarray`, optional + Rounded bvals + divide_by_b0 : :obj:`bool`, optional + Divide data by mean b0 + drop_b0 : :obj:`bool`, optional + Flag to drop b0 data + sort_by_bval : :obj:`bool`, optional + Flag to reorder time points by bvalue + mask_nii : Nifti1Image, optional + Boolean or segmentation mask of DW imaging data + segment_labels : :obj:`dict`, optional + Dictionary of segment labels + e.g. {'Cerebral_White_Matter': [2, 41], + 'Cerebral_Cortex': [3, 42], + 'Ventricle': [4, 14, 15, 43, 72]} + + Returns + --------- + data : N x T :obj:`numpy.array` + The functional data to be plotted + (*N* sampling locations by *T* timepoints). + segments: :obj:`dict` + A mapping between segment labels (e.g., `"Left Cortex"`) + and list of indexes in the data array. + """ + + nii_data = nii.get_fdata() + + if mask_nii is None: + mask_data = np.ones(nii_data.shape[0:3]) + else: + mask_data = np.asanyarray(mask_nii.dataobj, dtype=np.int16) + + if bvals is not None: + if divide_by_b0: + b0_data = nii_data[..., bvals == 0] + bzero = np.mean(b0_data, -1) + nii_data = nii_data / bzero[..., np.newaxis] + + if drop_b0: + nii_data = nii_data[..., bvals > 0] + bvals = bvals[bvals > 0] + + if sort_by_bval: + sort_inds = np.argsort(bvals) + nii_data = nii_data[..., sort_inds] + + # Reshape + data = nii_data.reshape(-1, nii_data.shape[-1]) + mask_data = mask_data.reshape(-1) + + # Apply mask + data = data[mask_data > 0, :] + mask_data = mask_data[mask_data > 0] + + # Remove bad rows + bad_row_ind = np.where(~np.isfinite(data))[0] + good_row_ind = np.ones(data.shape[0], dtype=bool) + good_row_ind[bad_row_ind] = False + + data = data[good_row_ind, :] + mask_data = mask_data[good_row_ind] + + # Get segments dict + if mask_nii is None or segment_labels is None: + segments = None + else: + segments = get_segments(mask_data, segment_labels) + + return data, segments + + +def get_segments(segment_mask, segment_labels): + """ + Return segments dict for plot_carpet function + + Parameters + ---------- + segment_mask : :obj:`numpy.ndarray` + Segmentation mask of DW imaging data + segment_labels : :obj:`dict` + Dictionary of segment labels + e.g. {'Cerebral_White_Matter': [2, 41], + 'Cerebral_Cortex': [3, 42], + 'Ventricle': [4, 14, 15, 43, 72]} + + Returns + --------- + segments: :obj:`dict` + A mapping between segment labels (e.g., `"Left Cortex"`) + and list of indexes in the data array. + """ + segments = {} + + for label, idx in segment_labels.items(): + indices = np.array([], dtype=int) + for ii in idx: + indices = np.concatenate([indices, np.nonzero(segment_mask == ii)[0]]) + segments[label] = indices + + return segments + + +def get_segment_labels(filepath, keywords, delimiter=" ", index_position=0, label_position=1): + """ + Get segment labels from file by keyword for get_segments function + + Parameters + ---------- + filepath : :obj:`string` + Path to segment label text file, such as freesurfer label file + keywords : list of :obj:`string` + List of label keywords. + All labels containing the keyword will be grouped together. + e.g. ["Cerebral_White_Matter", "Cerebral_Cortex", "Ventricle"] + delimiter : :obj:`string`, optional + Delimiter between label index and label string in label file + (' ' for freesurfer label file) + index_position : :obj:`int`, optional + Position of label index in label file + (0 for freesurfer label file) + label_position : :obj:`int`, optional + Position of label string in label file + (1 for freesurfer label file) + Returns + --------- + dict + e.g. {'Cerebral_White_Matter': [2, 41], + 'Cerebral_Cortex': [3, 42], + 'Ventricle': [4, 14, 15, 43, 72]} + """ + segment_labels = {} + + with open(filepath, "r") as f: + labels = f.read() + + labels_s = [label.split(delimiter) for label in labels.split("\n") if label != ""] + + for keyword in keywords: + ind = [int(i[index_position]) for i in labels_s if keyword in i[label_position]] + segment_labels[keyword] = ind + + return segment_labels diff --git a/nireports/reportlets/nuisance.py b/nireports/reportlets/nuisance.py index 444104ec..e9b58714 100644 --- a/nireports/reportlets/nuisance.py +++ b/nireports/reportlets/nuisance.py @@ -25,11 +25,13 @@ """Plotting distributions.""" import math +import operator import os.path as op import matplotlib as mpl import matplotlib.pyplot as plt import numpy as np +import pandas as pd import seaborn as sns from matplotlib.backends.backend_pdf import FigureCanvasPdf as FigureCanvas from matplotlib.colorbar import ColorbarBase @@ -339,7 +341,7 @@ def plot_carpet( ) label = "" - for i, (label, indices) in enumerate(segments.items()): + for i, (_, indices) in enumerate(segments.items()): # Carpet plot ax = plt.subplot(gs[i]) @@ -626,8 +628,6 @@ def confoundplot( cutoff=None, ylims=None, ): - import seaborn as sns - # Define TR and number of frames notr = False if tr is None: @@ -856,8 +856,6 @@ def confounds_correlation_plot( output_file: :obj:`str` The file where the figure is saved. """ - import pandas as pd - import seaborn as sns confounds_data = pd.read_table(confounds_file) @@ -932,3 +930,277 @@ def confounds_correlation_plot( figure = None return output_file return [ax0, ax1], gs + + +def _plot_density(x, y, df, group_name, palette, orient): + ax = sns.violinplot( + x=x, + y=y, + data=df, + hue=group_name, + dodge=False, + palette=palette, + density_norm="width", + inner=None, + orient=orient, + ) + + # Cut half of the violins + for violin in ax.collections: + bbox = violin.get_paths()[0].get_extents() + x0, y0, width, height = bbox.bounds + width_denom = 2 + height_denom = 1 + if orient == "h": + width_denom = 1 + height_denom = 2 + violin.set_clip_path( + plt.Rectangle( + (x0, y0), width / width_denom, height / height_denom, transform=ax.transData + ) + ) + + return ax + + +def _jitter_data_points(old_len_collections, orient, width, ax): + offset = np.array([width, 0]) + if orient == "h": + offset = np.array([0, width]) + for dots in ax.collections[old_len_collections:]: + dots.set_offsets(dots.get_offsets() + offset) + + +def _plot_nans(df, x, y, color, orient, ax): + df_nans = df[df.isna().any(axis=1)] + sns.stripplot( + x=x, + y=y, + data=df_nans, + color=color, + orient=orient, + ax=ax, + ) + + +def _plot_out_of_range( + df, + x, + feature, + orient, + limit_offset, + limit_value, + limit_color, + limit_name, + color_vble_name, + _op, + ax, +): + if limit_color is None: + raise ValueError( + f"``{color_vble_name}`` must be provided if ``{limit_name}`` is provided." + ) + if limit_offset is None: + raise ValueError(f"``limit_offset`` must be provided if ``{limit_name}`` is provided.") + if _op == operator.gt: + arithm = operator.add + elif _op == operator.lt: + arithm = operator.sub + else: + raise ValueError(f"``{_op}`` must be either ``gt`` or ``lt``.") + + df_overflow = df[_op(df[feature], limit_value)] + sns.stripplot( + x=x, + y=arithm(limit_value, limit_offset), + data=df_overflow, + color=limit_color, + orient=orient, + ax=ax, + ) + + +def plot_raincloud( + data_file, + group_name, + feature, + palette="Set2", + orient="v", + density=True, + upper_limit_value=None, + upper_limit_color="gray", + lower_limit_value=None, + lower_limit_color="gray", + limit_offset=None, + mark_nans=True, + nans_value=None, + nans_color="black", + figure=None, + output_file=None, +): + """ + Generate a raincloud plot with the data points corresponding to the + ``feature`` value contained in the data file. If ``upper_limit_value`` or + ``lower_limit_value`` is provided, the values outside that range are + clipped. Thus, a large density around those values, together with the values + plot with the distinctive ``upper_limit_color`` and ``lower_limit_color`` + styles may be indicative of unexpected values in the data. Similarly, NaN + values, if present, will be marked with the distinctive ``nans_color`` + style, and may again be indicative of unexpected values in the data. + + Parameters + ---------- + data_file : :obj:`str` + File containing the data of interest. + figure : :obj:`matplotlib.pyplot.figure` or None + Existing figure on which to plot. + group_name : :obj:`str` + The group name of interest to be plot. + feature : :obj:`str` + The feature of interest to be plot. + palette : :obj:`str`, optional + Color palette name provided to :func:`sns.stripplot`. + orient : :obj:`str`, optional + Plot orientation (``v`` or ``h``). + density : :obj:`bool`, optional + ``True`` to plot the density of the data points. + upper_limit_value : :obj:`float`, optional + Upper limit value over which any value in the data will be styled with a + different style. + upper_limit_color : :obj:`str`, optional + Color name to represent values over ``upper_limit_value``. + lower_limit_value : :obj:`float`, optional + Lower limit value under which any value in the data will be styled with + a different style. + lower_limit_color : :obj:`str`, optional + Color name to represent values under ``lower_limit_value``. + limit_offset : :obj:`float`, optional + Offset to plot the values over/under the upper/lower limit values. + mark_nans : :obj:`bool`, optional + ``True`` to plot NaNs as dots. ``nans_values`` must be provided if True. + nans_value : :obj:`float`, optional + Value to use for NaN values. + nans_color : :obj:`str`, optional + Color name to represent NaN values. + output_file : :obj:`str` or :obj:`None` + Path where the output figure should be saved. If this is not defined, + then the plotting axes will be returned instead of the saved figure + path. + + Returns + ------- + axes and gridspec + Plotting axes and gridspec. Returned only if ``output_file`` is ``None``. + output_file : :obj:`str` + The file where the figure is saved. + """ + + df = pd.read_csv(data_file, sep=r"[\t\s]+", engine="python") + + df_clip = df.copy(deep=True) + df_clip[feature] = df[feature].clip(lower=lower_limit_value, upper=upper_limit_value) + + if figure is None: + plt.figure(figsize=(7, 5)) + + gs = GridSpec(1, 1) + ax = plt.subplot(gs[0, 0]) + + sns.set(style="white", font_scale=2) + + x = feature + y = group_name + # Swap x/y if the requested orientation is vertical + if orient == "v": + x = group_name + y = feature + + # Plot the density + if density: + ax = _plot_density(x, y, df_clip, group_name, palette, orient) + + # Add boxplots + width = 0.15 + sns.boxplot( + x=x, + y=y, + data=df_clip, + color="black", + width=width, + zorder=10, + showcaps=True, + boxprops={"facecolor": "none", "zorder": 10}, + showfliers=True, + whiskerprops={"linewidth": 2, "zorder": 10}, + saturation=1, + orient=orient, + ax=ax, + ) + + old_len_collections = len(ax.collections) + + # Plot the data points as dots + sns.stripplot( + x=x, + y=y, + hue=group_name, + data=df_clip, + palette=palette, + edgecolor="white", + size=3, + jitter=0.1, + zorder=0, + orient=orient, + ax=ax, + ) + + # Offset the dots that would be otherwise shadowed by the violins + if density: + _jitter_data_points(old_len_collections, orient, width, ax) + + # Draw nans if any + if mark_nans: + if nans_value is None: + raise ValueError("``nans_value`` must be provided if ``mark_nans`` is True.") + _plot_nans(df, x, nans_value, nans_color, orient, ax) + + # If upper/lower limits are provided, draw the points with a different color + if upper_limit_value is not None: + _plot_out_of_range( + df, + x, + feature, + orient, + limit_offset, + upper_limit_value, + upper_limit_color, + "upper_limit_value", + "upper_limit_color", + operator.gt, + ax, + ) + + if lower_limit_value is not None: + _plot_out_of_range( + df, + x, + feature, + orient, + limit_offset, + lower_limit_value, + lower_limit_color, + "lower_limit_value", + "lower_limit_color", + operator.lt, + ax, + ) + + if output_file is not None: + figure = plt.gcf() + plt.tight_layout() + figure.savefig(output_file, bbox_inches="tight") + plt.close(figure) + figure = None + return output_file + + return ax, gs diff --git a/nireports/tests/data/aseg.auto_noCCseg.label_intensities.txt b/nireports/tests/data/aseg.auto_noCCseg.label_intensities.txt new file mode 100644 index 00000000..5c453366 --- /dev/null +++ b/nireports/tests/data/aseg.auto_noCCseg.label_intensities.txt @@ -0,0 +1,47 @@ +1 Left_Cerebral_Exterior 1.64 0.0 0 +2 Left_Cerebral_White_Matter 1.00 0.0 105 +3 Left_Cerebral_Cortex 1.20 0.0 69 +4 Left_Lateral_Ventricle 1.30 0.0 24 +5 Left_Inf_Lat_Vent 1.27 0.0 41 +7 Left_Cerebellum_White_Matter 1.08 0.0 91 +8 Left_Cerebellum_Cortex 1.32 0.0 73 +9 Left_Thalamus 1.26 0.0 103 +10 Left_Thalamus_Proper 1.03 0.0 93 +11 Left_Caudate 1.10 0.0 75 +12 Left_Putamen 1.16 0.0 94 +13 Left_Pallidum 1.05 0.0 101 +14 Third_Ventricle 1.64 0.0 42 +15 Fourth_Ventricle 1.72 0.0 26 +16 Brain_Stem 1.07 0.0 83 +17 Left_Hippocampus 1.27 0.0 72 +18 Left_Amygdala 1.32 0.0 76 +24 CSF 1.64 0.0 60 +26 Left_Accumbens_area 1.10 0.0 68 +28 Left_VentralDC 1.07 0.0 91 +40 Right_Cerebral_Exterior 1.64 0.0 0 +41 Right_Cerebral_White_Matter 1.00 0.0 103 +42 Right_Cerebral_Cortex 1.20 0.0 69 +43 Right_Lateral_Ventricle 1.94 0.0 25 +44 Right_Inf_Lat_Vent 1.29 0.0 30 +46 Right_Cerebellum_White_Matter 1.05 0.0 89 +47 Right_Cerebellum_Cortex 1.28 0.0 70 +48 Right_Thalamus 1.26 0.0 0 +49 Right_Thalamus_Proper 1.05 0.0 85 +50 Right_Caudate 1.24 0.0 81 +51 Right_Putamen 1.16 0.0 88 +52 Right_Pallidum 1.03 0.0 99 +53 Right_Hippocampus 1.29 0.0 72 +54 Right_Amygdala 1.29 0.0 72 +58 Right_Accumbens_area 1.24 0.0 80 +60 Right_VentralDC 1.05 0.0 94 +72 Fifth_Ventricle 1.64 0.0 50 +75 1.64 0.0 0 +76 1.64 0.0 0 +77 WM_hypointensities 1.00 0.0 77 +78 Left_WM_hypointensities 1.00 0.0 0 +79 Right_WM_hypointensities 1.00 0.0 0 +80 non_WM_hypointensities 1.00 0.0 44 +81 Left_non_WM_hypointensities 1.00 0.0 0 +82 Right_non_WM_hypointensities 1.00 0.0 0 +186 1.00 0.0 0 +187 1.00 0.0 0 diff --git a/nireports/tests/test_dwi.py b/nireports/tests/test_dwi.py index 6e43fea4..382eda1f 100644 --- a/nireports/tests/test_dwi.py +++ b/nireports/tests/test_dwi.py @@ -27,7 +27,15 @@ import pytest from matplotlib import pyplot as plt -from nireports.reportlets.modality.dwi import plot_dwi, plot_gradients +from nireports.reportlets.modality.dwi import ( + get_segment_labels, + nii_to_carpetplot_data, + plot_dwi, + plot_gradients, + plot_tissue_values, +) +from nireports.reportlets.nuisance import plot_carpet +from nireports.tests.utils import _generate_raincloud_random_data def test_plot_dwi(tmp_path, testdata_path, outdir): @@ -69,3 +77,84 @@ def test_plot_gradients(tmp_path, testdata_path, dwi_btable, outdir): if outdir is not None: plt.savefig(outdir / f"{dwi_btable}.svg", bbox_inches="tight") + + +def test_plot_tissue_values(tmp_path): + features_label = "fa" + group_label = "tissue" + group_names = ["CSF", "GM", "WM"] + min_val_csf = 0.0 + max_val_csf = 0.2 + min_max_csf = (min_val_csf, max_val_csf) + min_val_gm = 0.0 + max_val_gm = 0.6 + min_max_gm = (min_val_gm, max_val_gm) + min_val_wm = 0.3 + max_val_wm = 1.0 + min_max_wm = (min_val_wm, max_val_wm) + min_max = [min_max_csf, min_max_gm, min_max_wm] + n_grp_samples = 250 + data_file = tmp_path / "tissue_fa.tsv" + + _generate_raincloud_random_data( + min_max, n_grp_samples, features_label, group_label, group_names, data_file + ) + + palette = "Set2" + orient = "v" + density = True + output_file = tmp_path / "tissue_fa.png" + mark_nans = False + + plot_tissue_values( + data_file, + group_label, + features_label, + palette=palette, + orient=orient, + density=density, + mark_nans=mark_nans, + output_file=output_file, + ) + + +def test_nii_to_carpetplot_data(tmp_path, testdata_path, 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") + + 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" + keywords = ["Cerebral_White_Matter", "Cerebral_Cortex", "Ventricle"] + + segment_labels = get_segment_labels(filepath, keywords) + + image_path = None + + if outdir is not None: + image_path = outdir / f"{testdata_name}_nii_to_carpet.svg" + + data, segments = nii_to_carpetplot_data( + nii, bvals=bvals, mask_nii=mask_nii, segment_labels=segment_labels + ) + + plot_carpet(data, segments, output_file=image_path) + + +def test_get_segment_labels(tmp_path, testdata_path): + """Check the segment label function""" + + testdata_name = "aseg.auto_noCCseg.label_intensities.txt" + + filepath = testdata_path / testdata_name + keywords = ["Cerebral_White_Matter", "Cerebral_Cortex", "Ventricle"] + + segment_labels = get_segment_labels(filepath, keywords) + + assert segment_labels is not None diff --git a/nireports/tests/test_interfaces.py b/nireports/tests/test_interfaces.py index d361dd6b..d027c0e1 100644 --- a/nireports/tests/test_interfaces.py +++ b/nireports/tests/test_interfaces.py @@ -27,7 +27,12 @@ import pytest -from nireports.interfaces.nuisance import CompCorVariancePlot, ConfoundsCorrelationPlot +from nireports.interfaces.nuisance import ( + CompCorVariancePlot, + ConfoundsCorrelationPlot, + RaincloudPlot, +) +from nireports.tests.utils import _generate_raincloud_random_data def _smoke_test_report(report_interface, artifact_name): @@ -56,3 +61,38 @@ def test_ConfoundsCorrelationPlot(datadir, ignore_initial_volumes): ignore_initial_volumes=ignore_initial_volumes, ) _smoke_test_report(cc_rpt, f"confounds_correlation_{ignore_initial_volumes}.svg") + + +@pytest.mark.parametrize("orient", ["h", "v"]) +@pytest.mark.parametrize("density", (True, False)) +def test_RaincloudPlot(orient, density, tmp_path): + """Raincloud plot report test""" + features_label = "value" + group_label = "group" + group_names = ["group1", "group2"] + min_val_grp1 = 0.3 + max_val_grp1 = 1.0 + min_max_group1 = (min_val_grp1, max_val_grp1) + min_val_grp2 = 0.0 + max_val_grp2 = 0.6 + min_max_group2 = (min_val_grp2, max_val_grp2) + min_max = [min_max_group1, min_max_group2] + n_grp_samples = 250 + data_file = tmp_path / "data.tsv" + + _generate_raincloud_random_data( + min_max, n_grp_samples, features_label, group_label, group_names, data_file + ) + + palette = "Set2" + mark_nans = False + rc_rpt = RaincloudPlot( + data_file=data_file, + group_name=group_label, + feature=features_label, + palette=palette, + orient=orient, + density=density, + mark_nans=mark_nans, + ) + _smoke_test_report(rc_rpt, f"raincloud_orient-{orient}_density-{density}.svg") diff --git a/nireports/tests/test_reportlets.py b/nireports/tests/test_reportlets.py index 671334e0..3f6cd1ee 100644 --- a/nireports/tests/test_reportlets.py +++ b/nireports/tests/test_reportlets.py @@ -35,9 +35,10 @@ from nireports.reportlets.modality.func import fMRIPlot from nireports.reportlets.mosaic import plot_mosaic -from nireports.reportlets.nuisance import plot_carpet +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.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 @@ -368,3 +369,105 @@ def test_mriqc_plot_mosaic_2(tmp_path, testdata_path, outdir): maxrows=12, annotate=True, ) + + +@pytest.mark.parametrize("orient", ["h", "v"]) +@pytest.mark.parametrize("density", (True, False)) +def test_plot_raincloud(orient, density, tmp_path): + features_label = "value" + group_label = "group" + group_names = ["group1", "group2"] + min_val_grp1 = 0.3 + max_val_grp1 = 1.0 + min_max_group1 = (min_val_grp1, max_val_grp1) + min_val_grp2 = 0.0 + max_val_grp2 = 0.6 + min_max_group2 = (min_val_grp2, max_val_grp2) + min_max = [min_max_group1, min_max_group2] + n_grp_samples = 250 + data_file = tmp_path / "data.tsv" + + _generate_raincloud_random_data( + min_max, n_grp_samples, features_label, group_label, group_names, data_file + ) + + palette = "Set2" + mark_nans = False + output_file = tmp_path / f"raincloud_reg_orient-{orient}_density-{density}.png" + + plot_raincloud( + data_file, + group_label, + features_label, + palette=palette, + orient=orient, + density=density, + mark_nans=mark_nans, + output_file=output_file, + ) + + group_nans = [50, 0] + + _generate_raincloud_random_data( + min_max, + n_grp_samples, + features_label, + group_label, + group_names, + data_file, + group_nans=group_nans, + ) + + mark_nans = True + nans_value = 2.0 + output_file = tmp_path / f"raincloud_nans_orient-{orient}_density-{density}.png" + + plot_raincloud( + data_file, + group_label, + features_label, + palette=palette, + orient=orient, + density=density, + mark_nans=mark_nans, + nans_value=nans_value, + output_file=output_file, + ) + + min_val_grp1 = 0.3 + max_val_grp1 = 8.0 + min_max_group1 = (min_val_grp1, max_val_grp1) + min_val_grp2 = -0.2 + max_val_grp2 = 0.6 + min_max_group2 = (min_val_grp2, max_val_grp2) + min_max = [min_max_group1, min_max_group2] + + _generate_raincloud_random_data( + min_max, + n_grp_samples, + features_label, + group_label, + group_names, + data_file, + group_nans=group_nans, + ) + + upper_limit_value = 1.0 + lower_limit_value = 0.0 + limit_offset = 0.5 + output_file = tmp_path / f"raincloud_nans_limits_orient-{orient}_density-{density}.png" + + plot_raincloud( + data_file, + group_label, + features_label, + palette=palette, + orient=orient, + density=density, + upper_limit_value=upper_limit_value, + lower_limit_value=lower_limit_value, + limit_offset=limit_offset, + mark_nans=mark_nans, + nans_value=nans_value, + output_file=output_file, + ) diff --git a/nireports/tests/utils.py b/nireports/tests/utils.py new file mode 100644 index 00000000..4bcd61d7 --- /dev/null +++ b/nireports/tests/utils.py @@ -0,0 +1,37 @@ +import numpy as np +import pandas as pd + + +def _generate_raincloud_random_data( + min_max, + n_grp_samples, + features_label, + group_label, + group_names, + data_file, + group_nans=None, +): + rng = np.random.default_rng(1234) + + if group_nans is None: + group_nans = [None] * len(min_max) + + # Create some random data in the [min_val, max_val) half-open interval + values = np.array([]) + names = [] + for group_min_max, name, nans in zip(min_max, group_names, group_nans): + min_val = group_min_max[0] + max_val = group_min_max[1] + range_size = max_val - min_val + _values = rng.random(n_grp_samples) * range_size + min_val + + values = np.concatenate((values, _values), axis=0) + names.extend([name] * n_grp_samples) + + if nans: + values = np.concatenate((values, [np.nan] * nans), axis=0) + names.extend([name] * nans) + + df = pd.DataFrame(np.vstack([values, names]).T, columns=[features_label, group_label]) + + df.to_csv(data_file, sep="\t") diff --git a/pyproject.toml b/pyproject.toml index 44ef88db..b67d93e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ dev = [ test = [ "coverage", "matplotlib", + "packaging", "pytest", "pytest-cov", "pytest-env", @@ -130,6 +131,8 @@ select = [ ] ignore = [ "E203", + "B019", + "C901", ] [tool.ruff.lint.flake8-quotes]