From fafc682443fd256d2f299fa8b8cb5d6d4fa4188e Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Mon, 9 Feb 2026 15:28:08 -0500 Subject: [PATCH 1/9] Parallelize component figure creation. --- tedana/reporting/static_figures.py | 161 ++++++++++++++++------------- tedana/workflows/ica_reclassify.py | 2 - tedana/workflows/tedana.py | 3 +- 3 files changed, 93 insertions(+), 73 deletions(-) diff --git a/tedana/reporting/static_figures.py b/tedana/reporting/static_figures.py index ccdfc3a25..1b0065ecd 100644 --- a/tedana/reporting/static_figures.py +++ b/tedana/reporting/static_figures.py @@ -9,6 +9,7 @@ import nibabel as nb import numpy as np import pandas as pd +from joblib import Parallel, delayed matplotlib.use("AGG") import matplotlib.pyplot as plt @@ -320,7 +321,81 @@ def plot_component( plt.close(fig) -def comp_figures(ts, mask, component_table, mixing, io_generator, png_cmap): +def _generate_single_component_figure( + compnum, + component_table, + mixing, + component_betas_img, + tr, + png_cmap, + out_dir, + prefix, +): + """Generate a figure for a single component. + + Parameters + ---------- + compnum : int + The component number to plot. + component_table : (C x M) :obj:`pandas.DataFrame` + Component metric table. + mixing : (C x T) array_like + Mixing matrix for converting input data to component space. + component_betas_img : :obj:`nibabel.Nifti1Image` + 4D image of the component beta maps. + tr : float + Repetition time of the time series. + png_cmap : str + Colormap to use for the spatial map. + out_dir : str + Output directory path. + prefix : str + Prefix for the output file name. + """ + classification = component_table.loc[compnum, "classification"] + classification_tags = str(component_table.loc[compnum, "classification_tags"]) + + color_map = {"accepted": "g", "rejected": "r", "ignored": "k"} + line_color = color_map.get(classification, "0.75") + + if classification in color_map: + expl_text = f"{classification} reason(s): {classification_tags}" + else: + expl_text = "other classification" + + # Title will include variance from component_table + comp_var = f"{component_table.loc[compnum, 'variance explained']:.2f}" + comp_kappa = f"{component_table.loc[compnum, 'kappa']:.2f}" + comp_rho = f"{component_table.loc[compnum, 'rho']:.2f}" + + plt_title = ( + f"Comp. {compnum}: variance: {comp_var}%, kappa: {comp_kappa}, " + f"rho: {comp_rho}, {expl_text}" + ) + + component_timeseries = mixing[:, compnum] + + # Get fft and freqs for this component + # adapted from @dangom + spectrum, freqs = utils.get_spectrum(component_timeseries, tr) + + plot_name = f"{prefix}comp_{str(compnum).zfill(3)}.png" + compplot_name = os.path.join(out_dir, "figures", plot_name) + + plot_component( + stat_img=component_betas_img.slicer[..., compnum], + component_timeseries=component_timeseries, + power_spectrum=spectrum, + frequencies=freqs, + tr=tr, + classification_color=line_color, + png_cmap=png_cmap, + title=plt_title, + out_file=compplot_name, + ) + + +def comp_figures(component_table, mixing, io_generator, png_cmap, n_threads=1): """Create static figures that highlight certain aspects of tedana processing. This includes a figure for each component showing the component time course, @@ -328,10 +403,6 @@ def comp_figures(ts, mask, component_table, mixing, io_generator, png_cmap): Parameters ---------- - ts : (S x T) array_like - Time series from which to derive ICA betas - mask : (S,) array_like - Boolean mask array component_table : (C x M) :obj:`pandas.DataFrame` Component metric table. One row for each component, with a column for each metric. The index should be the component number. @@ -340,78 +411,30 @@ def comp_figures(ts, mask, component_table, mixing, io_generator, png_cmap): is components and `T` is the same as in `data` io_generator : :obj:`tedana.io.OutputGenerator` Output Generator object to use for this workflow + png_cmap : str + Colormap to use for the spatial map. + n_threads : int, optional + Number of threads to use for parallel processing. Default is 1. """ - # regenerate the beta images - component_maps_arr = stats.get_coeffs(ts, mixing, mask) - component_maps_arr = component_maps_arr.reshape( - io_generator.reference_img.shape[:3] + component_maps_arr.shape[1:], - ) + component_betas_file = io_generator.get_name("ICA components img") + component_betas_img = nb.load(component_betas_file) # Get repetition time from reference image tr = io_generator.reference_img.header.get_zooms()[-1] - # Remove trailing ';' from rationale column - # component_table["rationale"] = component_table["rationale"].str.rstrip(";") - for compnum in component_table.index.values: - if component_table.loc[compnum, "classification"] == "accepted": - line_color = "g" - expl_text = "accepted reason(s): " + str( - component_table.loc[compnum, "classification_tags"] - ) - - elif component_table.loc[compnum, "classification"] == "rejected": - line_color = "r" - expl_text = "rejected reason(s): " + str( - component_table.loc[compnum, "classification_tags"] - ) - - elif component_table.loc[compnum, "classification"] == "ignored": - line_color = "k" - expl_text = "ignored reason(s): " + str( - component_table.loc[compnum, "classification_tags"] - ) - - else: - # Classification not added - # If new, this will keep code running - line_color = "0.75" - expl_text = "other classification" - - # Title will include variance from component_table - comp_var = f"{component_table.loc[compnum, 'variance explained']:.2f}" - comp_kappa = f"{component_table.loc[compnum, 'kappa']:.2f}" - comp_rho = f"{component_table.loc[compnum, 'rho']:.2f}" - - plt_title = ( - f"Comp. {compnum}: variance: {comp_var}%, kappa: {comp_kappa}, " - f"rho: {comp_rho}, {expl_text}" - ) - component_img = nb.Nifti1Image( - component_maps_arr[:, :, :, compnum], - affine=io_generator.reference_img.affine, - header=io_generator.reference_img.header, - ) - - component_timeseries = mixing[:, compnum] - - # Get fft and freqs for this component - # adapted from @dangom - spectrum, freqs = utils.get_spectrum(component_timeseries, tr) - - plot_name = f"{io_generator.prefix}comp_{str(compnum).zfill(3)}.png" - compplot_name = os.path.join(io_generator.out_dir, "figures", plot_name) - - plot_component( - stat_img=component_img, - component_timeseries=component_timeseries, - power_spectrum=spectrum, - frequencies=freqs, + Parallel(n_jobs=n_threads)( + delayed(_generate_single_component_figure)( + compnum=compnum, + component_table=component_table, + mixing=mixing, + component_betas_img=component_betas_img, tr=tr, - classification_color=line_color, png_cmap=png_cmap, - title=plt_title, - out_file=compplot_name, + out_dir=io_generator.out_dir, + prefix=io_generator.prefix, ) + for compnum in component_table.index.values + ) def pca_results(criteria, n_components, all_varex, io_generator): diff --git a/tedana/workflows/ica_reclassify.py b/tedana/workflows/ica_reclassify.py index 5e18803ca..7c9776ca5 100644 --- a/tedana/workflows/ica_reclassify.py +++ b/tedana/workflows/ica_reclassify.py @@ -590,8 +590,6 @@ def ica_reclassify_workflow( gscontrol=gscontrol, ) reporting.static_figures.comp_figures( - data_optcom, - mask=mask_denoise, component_table=component_table, mixing=mixing_orig, io_generator=io_generator, diff --git a/tedana/workflows/tedana.py b/tedana/workflows/tedana.py index 4e0918c19..83e3d082e 100644 --- a/tedana/workflows/tedana.py +++ b/tedana/workflows/tedana.py @@ -1184,12 +1184,11 @@ def tedana_workflow( gscontrol=gscontrol, ) reporting.static_figures.comp_figures( - data_optcom, - mask=mask_denoise, component_table=component_table, mixing=mixing_orig, io_generator=io_generator, png_cmap=png_cmap, + n_threads=n_threads, ) reporting.static_figures.plot_t2star_and_s0(io_generator=io_generator, mask=mask_denoise) if t2smap is None: From 536aba45c579fd94289d68a1b4bb708f1c5cf919 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Mon, 9 Feb 2026 15:33:52 -0500 Subject: [PATCH 2/9] Remove unused import. --- tedana/reporting/static_figures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tedana/reporting/static_figures.py b/tedana/reporting/static_figures.py index 1b0065ecd..6cbd2d312 100644 --- a/tedana/reporting/static_figures.py +++ b/tedana/reporting/static_figures.py @@ -15,7 +15,7 @@ import matplotlib.pyplot as plt from nilearn import masking, plotting -from tedana import io, stats, utils +from tedana import io, utils LGR = logging.getLogger("GENERAL") MPL_LGR = logging.getLogger("matplotlib") From a58de5867a0bd9daab5650145c49e18f1eb2f6af Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Tue, 10 Feb 2026 08:25:17 -0500 Subject: [PATCH 3/9] Don't pickle niimgs. --- tedana/reporting/static_figures.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tedana/reporting/static_figures.py b/tedana/reporting/static_figures.py index 6cbd2d312..f288de587 100644 --- a/tedana/reporting/static_figures.py +++ b/tedana/reporting/static_figures.py @@ -325,7 +325,7 @@ def _generate_single_component_figure( compnum, component_table, mixing, - component_betas_img, + component_betas_file, tr, png_cmap, out_dir, @@ -341,8 +341,8 @@ def _generate_single_component_figure( Component metric table. mixing : (C x T) array_like Mixing matrix for converting input data to component space. - component_betas_img : :obj:`nibabel.Nifti1Image` - 4D image of the component beta maps. + component_betas_file : :obj:`str` + Path to the 4D image of the component beta maps. tr : float Repetition time of the time series. png_cmap : str @@ -382,6 +382,8 @@ def _generate_single_component_figure( plot_name = f"{prefix}comp_{str(compnum).zfill(3)}.png" compplot_name = os.path.join(out_dir, "figures", plot_name) + component_betas_img = nb.load(component_betas_file) + plot_component( stat_img=component_betas_img.slicer[..., compnum], component_timeseries=component_timeseries, @@ -417,7 +419,6 @@ def comp_figures(component_table, mixing, io_generator, png_cmap, n_threads=1): Number of threads to use for parallel processing. Default is 1. """ component_betas_file = io_generator.get_name("ICA components img") - component_betas_img = nb.load(component_betas_file) # Get repetition time from reference image tr = io_generator.reference_img.header.get_zooms()[-1] @@ -427,7 +428,7 @@ def comp_figures(component_table, mixing, io_generator, png_cmap, n_threads=1): compnum=compnum, component_table=component_table, mixing=mixing, - component_betas_img=component_betas_img, + component_betas_file=component_betas_file, tr=tr, png_cmap=png_cmap, out_dir=io_generator.out_dir, From 890a098f33eb593697182cc738a985001bca399e Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Tue, 10 Feb 2026 09:06:47 -0500 Subject: [PATCH 4/9] Fix docstrings. --- tedana/reporting/static_figures.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tedana/reporting/static_figures.py b/tedana/reporting/static_figures.py index f288de587..40cfffcdf 100644 --- a/tedana/reporting/static_figures.py +++ b/tedana/reporting/static_figures.py @@ -339,7 +339,7 @@ def _generate_single_component_figure( The component number to plot. component_table : (C x M) :obj:`pandas.DataFrame` Component metric table. - mixing : (C x T) array_like + mixing : (T x C) array_like Mixing matrix for converting input data to component space. component_betas_file : :obj:`str` Path to the 4D image of the component beta maps. @@ -408,7 +408,7 @@ def comp_figures(component_table, mixing, io_generator, png_cmap, n_threads=1): component_table : (C x M) :obj:`pandas.DataFrame` Component metric table. One row for each component, with a column for each metric. The index should be the component number. - mixing : (C x T) array_like + mixing : (T x C) array_like Mixing matrix for converting input data to component space, where `C` is components and `T` is the same as in `data` io_generator : :obj:`tedana.io.OutputGenerator` From 44bf91a7ba0b64f4531bedad07daac171e05ec92 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Wed, 11 Feb 2026 09:30:27 -0500 Subject: [PATCH 5/9] Update tedana/reporting/static_figures.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tedana/reporting/static_figures.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tedana/reporting/static_figures.py b/tedana/reporting/static_figures.py index 6cbd2d312..04be8d5f0 100644 --- a/tedana/reporting/static_figures.py +++ b/tedana/reporting/static_figures.py @@ -422,7 +422,13 @@ def comp_figures(component_table, mixing, io_generator, png_cmap, n_threads=1): # Get repetition time from reference image tr = io_generator.reference_img.header.get_zooms()[-1] - Parallel(n_jobs=n_threads)( + # Normalize n_threads to joblib semantics: None/<=0 means use all cores + if n_threads is None or n_threads <= 0: + n_jobs = -1 + else: + n_jobs = n_threads + + Parallel(n_jobs=n_jobs)( delayed(_generate_single_component_figure)( compnum=compnum, component_table=component_table, From bbd7f58d113ec3fab4d4cfd6e2318f703661c2e4 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 3 Apr 2026 14:26:39 -0400 Subject: [PATCH 6/9] Work on addressing requests. --- tedana/io.py | 22 +++++++++++++++++++--- tedana/resources/config/outputs.json | 4 ++++ tedana/workflows/tedana.py | 4 +++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/tedana/io.py b/tedana/io.py index 35861f7e2..41267cfaf 100644 --- a/tedana/io.py +++ b/tedana/io.py @@ -728,7 +728,7 @@ def write_split_ts(data, mixing, mask, component_table, io_generator, echo=0): LGR.info(f"Writing denoised time series: {fout}") -def writeresults(data_optcom, mask, component_table, mixing, io_generator): +def writeresults(data_optcom, mask, component_table, mixing, io_generator, mixing_orig=None): """Denoise `ts` and save all resulting files to disk. Parameters @@ -746,6 +746,9 @@ def writeresults(data_optcom, mask, component_table, mixing, io_generator): is components and `T` is the same as in `data` ref_img : :obj:`str` or img_like Reference image to dictate how outputs are saved to disk + mixing_orig : (C x T) array_like or None, optional + Non-orthogonalized mixing matrix. + If defined, it will be used for the component maps, but not for denoising. See Also -------- @@ -766,17 +769,30 @@ def writeresults(data_optcom, mask, component_table, mixing, io_generator): desc-ICAAccepted_components.nii.gz Spatial component maps for accepted components. desc-ICAAccepted_stat-z_components.nii.gz Z-normalized spatial component maps for accepted components. + desc-ICAOrth_components.nii.gz Spatial component maps for all components from + orthogonalized mixing matrix. Only created if + tedort and verbose are enabled. ========================================= =============================================== """ acc = component_table[component_table.classification == "accepted"].index.values write_split_ts(data_optcom, mixing, mask, component_table, io_generator) - ts_pes = get_coeffs(data_optcom, mixing, mask) + if mixing_orig: + mixing_for_denoising = mixing + mixing_for_components = mixing_orig + else: + mixing_for_denoising = mixing_for_components = mixing + + ts_pes = get_coeffs(data_optcom, mixing_for_components, mask) fout = io_generator.save_file(ts_pes, "ICA components img") LGR.info(f"Writing full ICA coefficient feature set: {fout}") + if io_generator.verbose and mixing_orig: + ts_pes_orth = get_coeffs(data_optcom, mixing_for_denoising, mask) + fout = io_generator.save_file(ts_pes_orth, "ICA orthogonalized components img") + LGR.info(f"Writing orthogonalized ICA coefficient feature set: {fout}") data_optcom_z = stats.zscore(data_optcom[mask, :], axis=-1) - mixing_z = stats.zscore(mixing, axis=0) + mixing_z = stats.zscore(mixing_for_components, axis=0) betas_oc = utils.unmask(get_coeffs(data_optcom_z, mixing_z), mask) fout = io_generator.save_file(betas_oc, "z-scored ICA components img") del data_optcom_z, mixing_z diff --git a/tedana/resources/config/outputs.json b/tedana/resources/config/outputs.json index 9a6ad6ea3..ea9548ffe 100644 --- a/tedana/resources/config/outputs.json +++ b/tedana/resources/config/outputs.json @@ -43,6 +43,10 @@ "orig": "ica_components", "bidsv1.5.0": "desc-ICA_components" }, + "ICA orthogonalized components img": { + "orig": "ica_orth_components", + "bidsv1.5.0": "desc-ICAOrth_stat-z_components" + }, "z-scored PCA components img": { "orig": "pca_components", "bidsv1.5.0": "desc-PCA_stat-z_components" diff --git a/tedana/workflows/tedana.py b/tedana/workflows/tedana.py index 83e3d082e..23a8b750e 100644 --- a/tedana/workflows/tedana.py +++ b/tedana/workflows/tedana.py @@ -1054,6 +1054,7 @@ def tedana_workflow( component_table = selector.component_table_ mixing_orig = mixing.copy() + mixing_orig_df = mixing_df.copy() if tedort: comps_accepted = selector.accepted_comps_ comps_rejected = selector.rejected_comps_ @@ -1082,6 +1083,7 @@ def tedana_workflow( component_table=component_table, mixing=mixing, io_generator=io_generator, + mixing_orig=mixing_orig if tedort else None, ) if "mir" in gscontrol: @@ -1213,7 +1215,7 @@ def tedana_workflow( # Compute correlations between external regressors and ICA components corr_df = metrics.external.compute_external_regressor_correlations( external_regressors=external_regressors, - mixing=mixing_df, + mixing=mixing_orig_df, ) # Plot the heatmap From d174d674199557826a78b14292521b853597d425 Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Fri, 3 Apr 2026 14:48:59 -0400 Subject: [PATCH 7/9] Update io.py --- tedana/io.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tedana/io.py b/tedana/io.py index d652e7b6f..472ae5ffb 100644 --- a/tedana/io.py +++ b/tedana/io.py @@ -818,7 +818,7 @@ def writeresults(data_optcom, mask, component_table, mixing, io_generator, mixin acc = component_table[component_table.classification == "accepted"].index.values write_split_ts(data_optcom, mixing, mask, component_table, io_generator) - if mixing_orig: + if mixing_orig is not None: mixing_for_denoising = mixing mixing_for_components = mixing_orig else: @@ -827,7 +827,7 @@ def writeresults(data_optcom, mask, component_table, mixing, io_generator, mixin ts_pes = get_coeffs(data_optcom, mixing_for_components) fout = io_generator.save_file(ts_pes, "ICA components img") LGR.info(f"Writing full ICA coefficient feature set: {fout}") - if io_generator.verbose and mixing_orig: + if io_generator.verbose and (mixing_orig is not None): ts_pes_orth = get_coeffs(data_optcom, mixing_for_denoising) fout = io_generator.save_file(ts_pes_orth, "ICA orthogonalized components img") LGR.info(f"Writing orthogonalized ICA coefficient feature set: {fout}") From d99befed122b8c52ba5529bf00bc99fa5768fb1b Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Thu, 23 Apr 2026 08:34:35 -0400 Subject: [PATCH 8/9] Update nih_five_echo_outputs_verbose.txt --- tedana/tests/data/nih_five_echo_outputs_verbose.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tedana/tests/data/nih_five_echo_outputs_verbose.txt b/tedana/tests/data/nih_five_echo_outputs_verbose.txt index f8c0bf03c..54dfdd56e 100644 --- a/tedana/tests/data/nih_five_echo_outputs_verbose.txt +++ b/tedana/tests/data/nih_five_echo_outputs_verbose.txt @@ -14,6 +14,7 @@ sub-01_desc-tedana_registry.json sub-01_desc-ICACrossComponent_metrics.json sub-01_desc-ICA_status_table.tsv sub-01_desc-ICA_decision_tree.json +sub-01_desc-ICAOrth_stat-z_components.nii.gz sub-01_desc-ICAS0_stat-F_statmap.nii.gz sub-01_desc-ICAT2_stat-F_statmap.nii.gz sub-01_desc-ICA_mixing.tsv From 74229f968675a799900e872c4e87f40fc34c1b9a Mon Sep 17 00:00:00 2001 From: Taylor Salo Date: Mon, 20 Jul 2026 14:21:05 -0400 Subject: [PATCH 9/9] Address review. --- tedana/io.py | 6 +- tedana/reporting/static_figures.py | 36 +++++------ tedana/resources/config/outputs.json | 2 +- .../data/nih_five_echo_outputs_verbose.txt | 2 +- tedana/tests/test_io.py | 43 +++++++++++++ tedana/tests/test_reporting.py | 62 +++++++++++++++++++ tedana/workflows/ica_reclassify.py | 1 + tedana/workflows/tedana.py | 1 - 8 files changed, 126 insertions(+), 27 deletions(-) diff --git a/tedana/io.py b/tedana/io.py index 472ae5ffb..25a4c7c3f 100644 --- a/tedana/io.py +++ b/tedana/io.py @@ -782,12 +782,12 @@ def writeresults(data_optcom, mask, component_table, mixing, io_generator, mixin Component metric table. One row for each component, with a column for each metric. Requires at least two columns: "component" and "classification". - mixing : (C x T) array_like + mixing : (T x C) array_like Mixing matrix for converting input data to component space, where `C` is components and `T` is the same as in `data` - ref_img : :obj:`str` or img_like + io_generator : :obj:`tedana.io.OutputGenerator` Reference image to dictate how outputs are saved to disk - mixing_orig : (C x T) array_like or None, optional + mixing_orig : (T x C) array_like or None, optional Non-orthogonalized mixing matrix. If defined, it will be used for the component maps, but not for denoising. diff --git a/tedana/reporting/static_figures.py b/tedana/reporting/static_figures.py index d8bb50967..6704bc7fc 100644 --- a/tedana/reporting/static_figures.py +++ b/tedana/reporting/static_figures.py @@ -9,7 +9,6 @@ import nibabel as nb import numpy as np import pandas as pd -from joblib import Parallel, delayed matplotlib.use("AGG") import matplotlib.pyplot as plt @@ -338,7 +337,7 @@ def _generate_single_component_figure( compnum, component_table, mixing, - component_betas_file, + component_img, tr, png_cmap, out_dir, @@ -354,8 +353,8 @@ def _generate_single_component_figure( Component metric table. mixing : (T x C) array_like Mixing matrix for converting input data to component space. - component_betas_file : :obj:`str` - Path to the 4D image of the component beta maps. + component_img : :obj:`nibabel.spatialimages.SpatialImage` + Spatial map for the component. tr : float Repetition time of the time series. png_cmap : str @@ -395,10 +394,8 @@ def _generate_single_component_figure( plot_name = f"{prefix}comp_{str(compnum).zfill(3)}.png" compplot_name = os.path.join(out_dir, "figures", plot_name) - component_betas_img = nb.load(component_betas_file) - plot_component( - stat_img=component_betas_img.slicer[..., compnum], + stat_img=component_img, component_timeseries=component_timeseries, power_spectrum=spectrum, frequencies=freqs, @@ -410,7 +407,7 @@ def _generate_single_component_figure( ) -def comp_figures(component_table, mixing, io_generator, png_cmap, n_threads=1): +def comp_figures(component_table, mixing, io_generator, png_cmap): """Create static figures that highlight certain aspects of tedana processing. This includes a figure for each component showing the component time course, @@ -428,33 +425,30 @@ def comp_figures(component_table, mixing, io_generator, png_cmap, n_threads=1): Output Generator object to use for this workflow png_cmap : str Colormap to use for the spatial map. - n_threads : int, optional - Number of threads to use for parallel processing. Default is 1. """ component_betas_file = io_generator.get_name("ICA components img") + component_betas_img = nb.load(component_betas_file) + component_betas_arr = np.asanyarray(component_betas_img.dataobj) # Get repetition time from reference image tr = io_generator.reference_img.header.get_zooms()[-1] - # Normalize n_threads to joblib semantics: None/<=0 means use all cores - if n_threads is None or n_threads <= 0: - n_jobs = -1 - else: - n_jobs = n_threads - - Parallel(n_jobs=n_jobs)( - delayed(_generate_single_component_figure)( + for compnum in component_table.index.values: + component_img = nb.Nifti1Image( + component_betas_arr[..., compnum], + affine=component_betas_img.affine, + header=component_betas_img.header, + ) + _generate_single_component_figure( compnum=compnum, component_table=component_table, mixing=mixing, - component_betas_file=component_betas_file, + component_img=component_img, tr=tr, png_cmap=png_cmap, out_dir=io_generator.out_dir, prefix=io_generator.prefix, ) - for compnum in component_table.index.values - ) def pca_results(criteria, n_components, all_varex, io_generator): diff --git a/tedana/resources/config/outputs.json b/tedana/resources/config/outputs.json index ea9548ffe..c28c13e6e 100644 --- a/tedana/resources/config/outputs.json +++ b/tedana/resources/config/outputs.json @@ -45,7 +45,7 @@ }, "ICA orthogonalized components img": { "orig": "ica_orth_components", - "bidsv1.5.0": "desc-ICAOrth_stat-z_components" + "bidsv1.5.0": "desc-ICAOrth_components" }, "z-scored PCA components img": { "orig": "pca_components", diff --git a/tedana/tests/data/nih_five_echo_outputs_verbose.txt b/tedana/tests/data/nih_five_echo_outputs_verbose.txt index 54dfdd56e..21e5f3e7a 100644 --- a/tedana/tests/data/nih_five_echo_outputs_verbose.txt +++ b/tedana/tests/data/nih_five_echo_outputs_verbose.txt @@ -14,7 +14,7 @@ sub-01_desc-tedana_registry.json sub-01_desc-ICACrossComponent_metrics.json sub-01_desc-ICA_status_table.tsv sub-01_desc-ICA_decision_tree.json -sub-01_desc-ICAOrth_stat-z_components.nii.gz +sub-01_desc-ICAOrth_components.nii.gz sub-01_desc-ICAS0_stat-F_statmap.nii.gz sub-01_desc-ICAT2_stat-F_statmap.nii.gz sub-01_desc-ICA_mixing.tsv diff --git a/tedana/tests/test_io.py b/tedana/tests/test_io.py index 305e96b8f..5e337ed61 100644 --- a/tedana/tests/test_io.py +++ b/tedana/tests/test_io.py @@ -118,6 +118,49 @@ def test_smoke_write_split_ts(): os.remove(fname) +def test_writeresults_uses_original_mixing_for_component_maps(): + """Component maps should use the original mixing matrix when one is provided.""" + rng = np.random.default_rng(42) + n_samples, n_times, n_components = 5, 8, 3 + data = rng.normal(size=(n_samples, n_times)) + mask = np.ones(n_samples, dtype=bool) + mixing = rng.normal(size=(n_times, n_components)) + mixing_orig = rng.normal(size=(n_times, n_components)) + component_table = pd.DataFrame( + {"classification": ["rejected", "ignored", "rejected"]}, + ) + saved = {} + + class StubOutputGenerator: + verbose = True + + def save_file(self, data, description, **kwargs): + del kwargs + saved[description] = np.asarray(data).copy() + return description + + with mock.patch.object(me, "write_split_ts") as write_split_ts: + me.writeresults( + data_optcom=data, + mask=mask, + component_table=component_table, + mixing=mixing, + io_generator=StubOutputGenerator(), + mixing_orig=mixing_orig, + ) + + write_split_ts.assert_called_once() + np.testing.assert_allclose( + saved["ICA components img"], + me.get_coeffs(data, mixing_orig), + ) + np.testing.assert_allclose( + saved["ICA orthogonalized components img"], + me.get_coeffs(data, mixing), + ) + assert "z-scored ICA components img" in saved + + def test_load_data_nilearn_multi_echo_fastpath(tmp_path): """`load_data_nilearn` should return (Mb, E, T) for multi-echo files.""" affine = np.eye(4) diff --git a/tedana/tests/test_reporting.py b/tedana/tests/test_reporting.py index fddd443a4..111ebc520 100644 --- a/tedana/tests/test_reporting.py +++ b/tedana/tests/test_reporting.py @@ -4,7 +4,10 @@ import re import shutil from os.path import dirname, join +from pathlib import Path +from types import SimpleNamespace +import nibabel as nb import numpy as np import pandas as pd import pytest @@ -21,6 +24,65 @@ def test_smoke_trim_edge_zeros(): assert reporting.static_figures._trim_edge_zeros(arr) is not None +def test_comp_figures_smoke(tmp_path, monkeypatch): + """Load component maps once and generate one PNG per component.""" + component_maps = np.arange(16, dtype=np.float32).reshape(2, 2, 2, 2) + component_maps_file = tmp_path / "components.nii.gz" + nb.save(nb.Nifti1Image(component_maps, np.eye(4)), component_maps_file) + + reference_img = nb.Nifti1Image(np.zeros((2, 2, 2, 4)), np.eye(4)) + component_table = pd.DataFrame( + { + "classification": ["accepted", "rejected"], + "classification_tags": ["Likely BOLD", "Low variance"], + "variance explained": [60.0, 40.0], + "kappa": [10.0, 2.0], + "rho": [2.0, 10.0], + }, + ) + mixing = np.arange(8, dtype=float).reshape(4, 2) + figures_dir = tmp_path / "figures" + figures_dir.mkdir() + io_generator = SimpleNamespace( + get_name=lambda name: (str(component_maps_file) if name == "ICA components img" else None), + reference_img=reference_img, + out_dir=str(tmp_path), + prefix="sub-01_", + ) + original_load = nb.load + load_calls = [] + plotted_maps = [] + + def tracked_load(filename): + load_calls.append(filename) + return original_load(filename) + + def fake_plot_component(**kwargs): + assert kwargs["stat_img"].shape == (2, 2, 2) + plotted_maps.append(kwargs["stat_img"].get_fdata()) + Path(kwargs["out_file"]).touch() + + monkeypatch.setattr(reporting.static_figures.nb, "load", tracked_load) + monkeypatch.setattr(reporting.static_figures, "plot_component", fake_plot_component) + + reporting.static_figures.comp_figures( + component_table=component_table, + mixing=mixing, + io_generator=io_generator, + png_cmap="coolwarm", + ) + + assert load_calls == [str(component_maps_file)] + np.testing.assert_array_equal( + np.stack(plotted_maps, axis=-1), + component_maps, + ) + assert sorted(path.name for path in figures_dir.glob("*.png")) == [ + "sub-01_comp_000.png", + "sub-01_comp_001.png", + ] + + def test_calculate_rejected_components_impact(): selector = sample_selector() mixing = sample_mixing_matrix() diff --git a/tedana/workflows/ica_reclassify.py b/tedana/workflows/ica_reclassify.py index d5ed6aa7c..fdd3c3178 100644 --- a/tedana/workflows/ica_reclassify.py +++ b/tedana/workflows/ica_reclassify.py @@ -586,6 +586,7 @@ def ica_reclassify_workflow( component_table=component_table, mixing=mixing, io_generator=io_generator, + mixing_orig=mixing_orig if tedort else None, ) if "mir" in gscontrol: diff --git a/tedana/workflows/tedana.py b/tedana/workflows/tedana.py index af6870768..d56e8574e 100644 --- a/tedana/workflows/tedana.py +++ b/tedana/workflows/tedana.py @@ -1174,7 +1174,6 @@ def tedana_workflow( mixing=mixing_orig, io_generator=io_generator, png_cmap=png_cmap, - n_threads=n_threads, ) reporting.static_figures.plot_t2star_and_s0( io_generator=io_generator,