Repository: PennLINC/qsirecon
Pull request #218: Tissue fraction modulated ICVF and OD maps
Author: login: araikes
Labels: name: enhancement
description: New feature or request
Created: 2025-03-20T21:18:04Z | Merged: 2025-04-03T17:29:35Z

<!--
Text in these brackets are comments, and won't be visible when you submit your pull request.
If this is your first contribution, please take the time to read these, in particular the comment
beginning "Welcome, new contributors!".
-->

## Changes proposed in this pull request

<!--
Please describe here the main features / changes proposed for review and integration in qsirecon
If this PR addresses some existing problem, please use GitHub's citing tools
(eg. ref #, closes # or fixes #).
If there is not an existing issue open describing the problem, please consider opening a new
issue first and then link it from here (so the *qsirecon* community has a better understanding
of ongoing development efforts and possible overlaps between contributions).
-->

Parker et al (https://doi.org/10.1016/j.neuroimage.2021.118749) proposed a tissue fraction modulation approach for NODDI metrics to reduce bias in ROI-associated mean computations. Modulation is just `{NDI,OD} * (1 - ISOVF)`. AMICO has these as output options through a global flag, along with the (N)RMSE maps from the fitting. This PR enables outputting of these 4 maps when running NODDI. Addresses #217. 

Includes citations for Parker et al and fixes some typos in the reports (generally) and in the AMICO specific reports as well.

Outputs from a test run:

```
├── ses-01
│   ├── dwi
│   │   ├── sub-002_ses-01_dir-AP_space-ACPC_model-noddi_config.pickle.gz
│   │   ├── sub-002_ses-01_dir-AP_space-ACPC_model-noddi_param-ICVF_dwimap.json
│   │   ├── sub-002_ses-01_dir-AP_space-ACPC_model-noddi_param-ICVF_dwimap.nii.gz
│   │   ├── sub-002_ses-01_dir-AP_space-ACPC_model-noddi_param-ICVFmodulated_dwimap.json
│   │   ├── sub-002_ses-01_dir-AP_space-ACPC_model-noddi_param-ICVFmodulated_dwimap.nii.gz
│   │   ├── sub-002_ses-01_dir-AP_space-ACPC_model-noddi_param-ISOVF_dwimap.json
│   │   ├── sub-002_ses-01_dir-AP_space-ACPC_model-noddi_param-ISOVF_dwimap.nii.gz
│   │   ├── sub-002_ses-01_dir-AP_space-ACPC_model-noddi_param-OD_dwimap.json
│   │   ├── sub-002_ses-01_dir-AP_space-ACPC_model-noddi_param-OD_dwimap.nii.gz
│   │   ├── sub-002_ses-01_dir-AP_space-ACPC_model-noddi_param-ODmodulated_dwimap.json
│   │   ├── sub-002_ses-01_dir-AP_space-ACPC_model-noddi_param-ODmodulated_dwimap.nii.gz
│   │   ├── sub-002_ses-01_dir-AP_space-ACPC_model-noddi_param-direction_dwi.fib.gz
│   │   ├── sub-002_ses-01_dir-AP_space-ACPC_model-noddi_param-direction_dwimap.json
│   │   ├── sub-002_ses-01_dir-AP_space-ACPC_model-noddi_param-direction_dwimap.nii.gz
│   │   ├── sub-002_ses-01_dir-AP_space-ACPC_model-noddi_param-nrmse_dwimap.json
│   │   ├── sub-002_ses-01_dir-AP_space-ACPC_model-noddi_param-nrmse_dwimap.nii.gz
│   │   ├── sub-002_ses-01_dir-AP_space-ACPC_model-noddi_param-rmse_dwimap.json
│   │   └── sub-002_ses-01_dir-AP_space-ACPC_model-noddi_param-rmse_dwimap.nii.gz
│   ├── figures
│   │   ├── sub-002_ses-01_desc-about_T1w.html
│   │   ├── sub-002_ses-01_desc-summary_T1w.html
│   │   └── sub-002_ses-01_dir-AP_space-ACPC_desc-NODDI_peaks.png
│   └── sub-002_ses-01.html
├── ses-02
│   ├── dwi
│   │   ├── sub-002_ses-02_dir-AP_space-ACPC_model-noddi_config.pickle.gz
│   │   ├── sub-002_ses-02_dir-AP_space-ACPC_model-noddi_param-ICVF_dwimap.json
│   │   ├── sub-002_ses-02_dir-AP_space-ACPC_model-noddi_param-ICVF_dwimap.nii.gz
│   │   ├── sub-002_ses-02_dir-AP_space-ACPC_model-noddi_param-ICVFmodulated_dwimap.json
│   │   ├── sub-002_ses-02_dir-AP_space-ACPC_model-noddi_param-ICVFmodulated_dwimap.nii.gz
│   │   ├── sub-002_ses-02_dir-AP_space-ACPC_model-noddi_param-ISOVF_dwimap.json
│   │   ├── sub-002_ses-02_dir-AP_space-ACPC_model-noddi_param-ISOVF_dwimap.nii.gz
│   │   ├── sub-002_ses-02_dir-AP_space-ACPC_model-noddi_param-OD_dwimap.json
│   │   ├── sub-002_ses-02_dir-AP_space-ACPC_model-noddi_param-OD_dwimap.nii.gz
│   │   ├── sub-002_ses-02_dir-AP_space-ACPC_model-noddi_param-ODmodulated_dwimap.json
│   │   ├── sub-002_ses-02_dir-AP_space-ACPC_model-noddi_param-ODmodulated_dwimap.nii.gz
│   │   ├── sub-002_ses-02_dir-AP_space-ACPC_model-noddi_param-direction_dwi.fib.gz
│   │   ├── sub-002_ses-02_dir-AP_space-ACPC_model-noddi_param-direction_dwimap.json
│   │   ├── sub-002_ses-02_dir-AP_space-ACPC_model-noddi_param-direction_dwimap.nii.gz
│   │   ├── sub-002_ses-02_dir-AP_space-ACPC_model-noddi_param-nrmse_dwimap.json
│   │   ├── sub-002_ses-02_dir-AP_space-ACPC_model-noddi_param-nrmse_dwimap.nii.gz
│   │   ├── sub-002_ses-02_dir-AP_space-ACPC_model-noddi_param-rmse_dwimap.json
│   │   └── sub-002_ses-02_dir-AP_space-ACPC_model-noddi_param-rmse_dwimap.nii.gz
│   ├── figures
│   │   ├── sub-002_ses-02_desc-about_T1w.html
│   │   ├── sub-002_ses-02_desc-summary_T1w.html
│   │   └── sub-002_ses-02_dir-AP_space-ACPC_desc-NODDI_peaks.png
│   └── sub-002_ses-02.html
└── sub-002.html
```

Top row: Unmodulated ICVF (left) and OD (right)
Bottom row: Modulated ICVF (left) and OD (right)

![image](https://github.com/user-attachments/assets/735b5be5-8061-415a-ae71-4097ffa3d813)


## Documentation that should be reviewed
<!--
Please summarize here the main changes to the documentation that the reviewers should be aware of.
-->

Need to check docs. I tried to edit them but I'm not sure if I was successful. Probably also needs to be an explanation somewhere that the modulated maps should not, themselves be directly analyzed (per Parker et al) but rather the mean computed per region on them and then re-divided by the mean tissue fraction to get back to the original scale. Can do this when I know whether any of the documentation editing was successful.


<!--
Welcome, new contributors!

We ask you to read through the Contributing Guide:
https://github.com/pennlinc/qsirecon/blob/main/CONTRIBUTING.md

These are guidelines intended to make communication easier by describing a consistent process, but
don't worry if you don't get it everything exactly "right" on the first try.

To boil it down, here are some highlights:

1) Consider starting a conversation in the issues list before submitting a pull request. The discussion might save you a
   lot of time coding.
2) Please use descriptive prefixes in your pull request title, such as "ENH:" for an enhancement or "FIX:" for a bug fix.
   (See the Contributing guide for the full set.) And consider adding a "WIP" tag for works-in-progress.
3) Any code you submit will be licensed under the same terms (BSD 3-Clause) as the rest of QSIRecon.

A pull request is a conversation. We may ask you to make some changes before accepting your PR,
and likewise, you should feel free to ask us any questions you have.

-->



Diff:
diff --git a/docs/builtin_workflows.rst b/docs/builtin_workflows.rst
index c25a268a..b0d0a7e1 100644
--- a/docs/builtin_workflows.rst
+++ b/docs/builtin_workflows.rst
@@ -185,11 +185,15 @@ PyAFQ Outputs
 ===============
 
 This workflow estimates the NODDI :footcite:p:`noddi` model using the implementation from
-AMICO :footcite:p:`amico`. Images with intra-cellular volume fraction (ICVF), isotropic volume
-fraction (ISOVF), orientation dispersion (OD) are written to outputs. Additionally, a DSI
-Studio fib file is created using the peak directions and ICVF as a stand-in for QA to be
+AMICO :footcite:p:`amico` and tissue fraction modulation described in :footcite:p:`parker2021not`. 
+Images with (modulated) intra-cellular volume fraction (ICVF), isotropic volume fraction (ISOVF), 
+(modulated) orientation dispersion (OD), root mean square error (RMSE) and normalized RMSE are written to outputs. 
+Additionally, a DSI Studio fib file is created using the peak directions and ICVF as a stand-in for QA to be
 used for tractography.
 
+Please see Parker 2021 :footcite:p:`parker2021not` for a detailed description of use and application of the
+tissue fraction modulated outputs.
+
 Scalar Maps
 -----------
 .. csv-table::
diff --git a/docs/recon_scalars/amico_noddi.csv b/docs/recon_scalars/amico_noddi.csv
index 0de4cb8e..ec63fcb5 100644
--- a/docs/recon_scalars/amico_noddi.csv
+++ b/docs/recon_scalars/amico_noddi.csv
@@ -2,3 +2,7 @@ noddi,direction,Peak directions from NODDI
 noddi,icvf,Intracellular volume fraction from NODDI
 noddi,isovf,Isotropic volume fraction from NODDI
 noddi,od,Orientation dispersion index from NODDI
+noddi,modulated icvf,Tissue fraction modulated intracellular volume fraction from NODDI
+noddi,modulated od,Tissue fraction modulated orientation dispersion from NODDI
+noddi,rmse,Root mean square error between predicted and observed signal from NODDI
+noddi,rmse,Normalized RMSE between predicted and observed signal from NODDI
diff --git a/docs/sphinxext/github_link.py b/docs/sphinxext/github_link.py
index 47ff2a76..3e7e1898 100644
--- a/docs/sphinxext/github_link.py
+++ b/docs/sphinxext/github_link.py
@@ -2,6 +2,7 @@
 This script comes from scikit-learn:
 https://github.com/scikit-learn/scikit-learn/blob/master/doc/sphinxext/github_link.py
 """
+
 from operator import attrgetter
 import inspect
 import subprocess
@@ -9,16 +10,16 @@
 import sys
 from functools import partial
 
-REVISION_CMD = 'git rev-parse --short HEAD'
+REVISION_CMD = "git rev-parse --short HEAD"
 
 
 def _get_git_revision():
     try:
         revision = subprocess.check_output(REVISION_CMD.split()).strip()
     except (subprocess.CalledProcessError, OSError):
-        print('Failed to execute git to get revision')
+        print("Failed to execute git to get revision")
         return None
-    return revision.decode('utf-8')
+    return revision.decode("utf-8")
 
 
 def _linkcode_resolve(domain, info, package, url_fmt, revision):
@@ -38,17 +39,17 @@ def _linkcode_resolve(domain, info, package, url_fmt, revision):
 
     if revision is None:
         return
-    if domain not in ('py', 'pyx'):
+    if domain not in ("py", "pyx"):
         return
-    if not info.get('module') or not info.get('fullname'):
+    if not info.get("module") or not info.get("fullname"):
         return
 
-    class_name = info['fullname'].split('.')[0]
+    class_name = info["fullname"].split(".")[0]
     if type(class_name) != str:
         # Python 2 only
-        class_name = class_name.encode('utf-8')
-    module = __import__(info['module'], fromlist=[class_name])
-    obj = attrgetter(info['fullname'])(module)
+        class_name = class_name.encode("utf-8")
+    module = __import__(info["module"], fromlist=[class_name])
+    obj = attrgetter(info["fullname"])(module)
 
     try:
         fn = inspect.getsourcefile(obj)
@@ -62,14 +63,12 @@ def _linkcode_resolve(domain, info, package, url_fmt, revision):
     if not fn:
         return
 
-    fn = os.path.relpath(fn,
-                         start=os.path.dirname(__import__(package).__file__))
+    fn = os.path.relpath(fn, start=os.path.dirname(__import__(package).__file__))
     try:
         lineno = inspect.getsourcelines(obj)[1]
     except Exception:
-        lineno = ''
-    return url_fmt.format(revision=revision, package=package,
-                          path=fn, lineno=lineno)
+        lineno = ""
+    return url_fmt.format(revision=revision, package=package, path=fn, lineno=lineno)
 
 
 def make_linkcode_resolve(package, url_fmt):
@@ -84,5 +83,4 @@ def make_linkcode_resolve(package, url_fmt):
                                    '{path}#L{lineno}')
     """
     revision = _get_git_revision()
-    return partial(_linkcode_resolve, revision=revision, package=package,
-                   url_fmt=url_fmt)
+    return partial(_linkcode_resolve, revision=revision, package=package, url_fmt=url_fmt)
diff --git a/qsirecon/data/boilerplate.bib b/qsirecon/data/boilerplate.bib
index 0e36a8f1..cca3cfde 100644
--- a/qsirecon/data/boilerplate.bib
+++ b/qsirecon/data/boilerplate.bib
@@ -827,3 +827,17 @@ @article{najdenovska2018vivo
   url={https://doi.org/10.1038/sdata.2018.270},
   doi={10.1038/sdata.2018.270}
 }
+
+@article{parker2021not,
+title = {Not all voxels are created equal: Reducing estimation bias in regional NODDI metrics using tissue-weighted means},
+journal = {NeuroImage},
+volume = {245},
+pages = {118749},
+year = {2021},
+issn = {1053-8119},
+doi = {https://doi.org/10.1016/j.neuroimage.2021.118749},
+url = {https://www.sciencedirect.com/science/article/pii/S1053811921010211},
+author = {C.S. Parker and T. Veale and M. Bocchetta and C.F. Slattery and I.B. Malone and D.L. Thomas and J.M. Schott and D.M. Cash and H. Zhang},
+keywords = {Diffusion MRI, Microstructure imaging, Region-of-interest, Arithmetic mean, Tissue-weighted mean},
+abstract = {Neurite orientation dispersion and density imaging (NODDI) estimates microstructural properties of brain tissue relating to the organisation and processing capacity of neurites, which are essential elements for neuronal communication. Descriptive statistics of NODDI tissue metrics are commonly analyzed in regions-of-interest (ROI) to identify brain-phenotype associations. Here, the conventional method to calculate the ROI mean weights all voxels equally. However, this produces biased estimates in the presence of CSF partial volume. This study introduces the tissue-weighted mean, which calculates the mean NODDI metric across the tissue within an ROI, utilising the tissue fraction estimate from NODDI to reduce estimation bias. We demonstrate the proposed mean in a study of white matter abnormalities in young onset Alzheimer's disease (YOAD). Results show the conventional mean induces significant bias that correlates with CSF partial volume, primarily affecting periventricular regions and more so in YOAD subjects than in healthy controls. Due to the differential extent of bias between healthy controls and YOAD subjects, the conventional mean under- or over-estimated the effect size for group differences in many ROIs. This demonstrates the importance of using the correct estimation procedure when inferring group differences in studies where the extent of CSF partial volume differs between groups. These findings are robust across different acquisition and processing conditions. Bias persists in ROIs at higher image resolution, as demonstrated using data obtained from the third phase of the Alzheimer's disease neuroimaging initiative (ADNI); and when performing ROI analysis in template space. This suggests that conventional ROI means of NODDI metrics are biased estimates under most contemporary experimental conditions, the correction of which requires the proposed tissue-weighted mean. The tissue-weighted mean produces accurate estimates of ROI means and group differences when ROIs contain voxels with CSF partial volume. In addition to NODDI, the technique can be applied to other multi-compartment models that account for CSF partial volume, such as the free water elimination method. We expect the technique to help generate new insights into normal and abnormal variation in tissue microstructure of regions typically confounded by CSF partial volume, such as those in individuals with larger ventricles due to atrophy associated with neurodegenerative disease.}
+}
\ No newline at end of file
diff --git a/qsirecon/data/scalars/amico_noddi.yaml b/qsirecon/data/scalars/amico_noddi.yaml
index 5697c96c..cca4f1bb 100644
--- a/qsirecon/data/scalars/amico_noddi.yaml
+++ b/qsirecon/data/scalars/amico_noddi.yaml
@@ -23,3 +23,35 @@ od_image:
         param: od
     metadata:
         Description: Orientation dispersion index from NODDI
+modulated_icvf_image:
+    bids:
+        model: noddi
+        param: icvf
+        desc: modulated
+    metadata:
+        Description: Tissue fraction modulated intracellular volume fraction from NODDI
+modulated_od_image:
+    bids:
+        model: noddi
+        param: od
+        desc: modulated
+    metadata:
+        Description: Tissue fraction modulated intracellular volume fraction from NODDI
+rmse_image:
+    bids:
+        model: noddi
+        param: rmse
+    metadata:
+        Description: RMSE between predicted and measured signal from NODDI
+nrmse_image:
+    bids:
+        model: noddi
+        param: nrmse
+    metadata:
+        Description: NRMSE between predicted and measured signal from NODDI
+tf_image:
+    bids:
+        model: noddi
+        param: tf
+    metadata:
+        Description: Tissue fraction from NODDI
\ No newline at end of file
diff --git a/qsirecon/interfaces/amico.py b/qsirecon/interfaces/amico.py
index e8607d22..cf18746f 100644
--- a/qsirecon/interfaces/amico.py
+++ b/qsirecon/interfaces/amico.py
@@ -12,6 +12,7 @@
 import os.path as op
 
 import nibabel as nb
+import nilearn.image as nim
 import numpy as np
 from dipy.core.gradients import gradient_table
 from dipy.core.sphere import HemiSphere
@@ -137,6 +138,10 @@ class NODDIOutputSpec(AmicoOutputSpec):
     icvf_image = File()
     od_image = File()
     isovf_image = File()
+    modulated_icvf_image = File()
+    modulated_od_image = File()
+    rmse_image = File()
+    nrmse_image = File()
     config_file = File()
 
 
@@ -177,12 +182,20 @@ def _run_interface(self, runtime):
         )
         LOGGER.info("Fitting NODDI Model.")
         aeval.set_model("NODDI")
+
+        # Set global configuration
         aeval.set_config("BLAS_nthreads", 1)
         aeval.set_config("nthreads", self.inputs.num_threads)
-        # set the parameters
+        aeval.set_config("doSaveModulatedMaps", True)
+        aeval.set_config("doComputeRMSE", True)
+        aeval.set_config("doComputeNRMSE", True)
+
+        # Set model parameters
         aeval.model.dPar = self.inputs.dPar
         aeval.model.dIso = self.inputs.dIso
         aeval.model.isExvivo = self.inputs.isExvivo
+
+        # Generate kernels and fit
         aeval.generate_kernels()
         aeval.load_kernels()
         aeval.fit()
@@ -193,6 +206,34 @@ def _run_interface(self, runtime):
         self._results["icvf_image"] = shim_dir + "/AMICO/NODDI/fit_NDI.nii.gz"
         self._results["od_image"] = shim_dir + "/AMICO/NODDI/fit_ODI.nii.gz"
         self._results["isovf_image"] = shim_dir + "/AMICO/NODDI/fit_FWF.nii.gz"
+        self._results["modulated_od_image"] = shim_dir + "/AMICO/NODDI/fit_ODI_modulated.nii.gz"
+        self._results["modulated_icvf_image"] = shim_dir + "/AMICO/NODDI/fit_NDI_modulated.nii.gz"
+        self._results["rmse_image"] = shim_dir + "/AMICO/NODDI/fit_RMSE.nii.gz"
+        self._results["nrmse_image"] = shim_dir + "/AMICO/NODDI/fit_NRMSE.nii.gz"
         self._results["config_file"] = shim_dir + "/AMICO/NODDI/config.pickle"
 
         return runtime
+
+
+class _NODDITissueFractionInputSpec(BaseInterfaceInputSpec):
+    isovf_image = File(exists=True, mandatory=True)
+    mask_image = File(exists=True, mandatory=True)
+
+
+class _NODDITissueFractionOutputSpec(TraitedSpec):
+    tf_image = File()
+
+
+class NODDITissueFraction(SimpleInterface):
+    input_spec = _NODDITissueFractionInputSpec
+    output_spec = _NODDITissueFractionOutputSpec
+
+    def _run_interface(self, runtime):
+        isovf_image = self.inputs.isovf_image
+        mask_image = self.inputs.mask_image
+
+        tf_image = nim.math_img("(1 - isovf) * mask", isovf=isovf_image, mask=mask_image)
+        out_file = fname_presuffix(isovf_image, suffix="_tf", newpath=runtime.cwd)
+        tf_image.to_filename(out_file)
+        self._results["tf_image"] = out_file
+        return runtime
diff --git a/qsirecon/interfaces/converters.py b/qsirecon/interfaces/converters.py
index 7777281c..9aef9c36 100644
--- a/qsirecon/interfaces/converters.py
+++ b/qsirecon/interfaces/converters.py
@@ -142,6 +142,8 @@ class NODDItoFIBGZInputSpec(BaseInterfaceInputSpec):
     icvf_file = File(exists=True)
     isovf_file = File(exists=True)
     od_file = File(exists=True)
+    modulated_icvf_file = File(exists=True)
+    modulated_od_file = File(exists=True)
     directions_file = File(exists=True)
     mask_file = File(exists=True)
 
@@ -163,6 +165,8 @@ def _run_interface(self, runtime):
             directions_img=nb.load(self.inputs.directions_file),
             od_img=nb.load(self.inputs.od_file),
             icvf_img=nb.load(self.inputs.icvf_file),
+            modulated_od_img=nb.load(self.inputs.modulated_od_file),
+            modulated_icvf_img=nb.load(self.inputs.modulated_icvf_file),
             isovf_img=nb.load(self.inputs.isovf_file),
             odf_dirs=verts,
             odf_faces=faces,
@@ -425,7 +429,16 @@ def amplitudes_to_fibgz(
 
 
 def amico_directions_to_fibgz(
-    directions_img, od_img, icvf_img, isovf_img, odf_dirs, odf_faces, output_file, mask_img
+    directions_img,
+    od_img,
+    icvf_img,
+    modulated_od_img,
+    modulated_icvf_img,
+    isovf_img,
+    odf_dirs,
+    odf_faces,
+    output_file,
+    mask_img,
 ):
     """Convert a NiftiImage of ODF amplitudes to a DSI Studio fib file.
 
@@ -482,6 +495,8 @@ def amico_directions_to_fibgz(
     isovf_vec = isovf_img.get_fdata().flatten(order="F")
     icvf_vec = icvf_img.get_fdata().flatten(order="F")
     od_vec = od_img.get_fdata().flatten(order="F")
+    mod_icvf_vec = modulated_icvf_img.get_fdata().flatten(order="F")
+    mod_od_vec = modulated_od_img.get_fdata().flatten(order="F")
 
     # z0 = np.nanmax(isovf_vec)
     peak_indices = np.zeros(n_odfs)
@@ -500,9 +515,11 @@ def amico_directions_to_fibgz(
     dir0[flat_mask] = peak_indices
     dsi_mat["index0"] = dir0.astype("int16")
     dsi_mat["fa0"] = icvf_vec
-    dsi_mat["ICVF0"] = icvf_vec
-    dsi_mat["ISOVF0"] = isovf_vec
-    dsi_mat["OD0"] = od_vec
+    dsi_mat["icvf0"] = icvf_vec
+    dsi_mat["isovf0"] = isovf_vec
+    dsi_mat["od0"] = od_vec
+    dsi_mat["mod_icvf0"] = mod_icvf_vec
+    dsi_mat["mod_od0"] = mod_od_vec
     dsi_mat["odf_vertices"] = odf_dirs.T
     dsi_mat["odf_faces"] = odf_faces.T
     savemat(output_file, dsi_mat, format="4", appendmat=False)
diff --git a/qsirecon/tests/data/amico_noddi_outputs.txt b/qsirecon/tests/data/amico_noddi_outputs.txt
index 69679f28..2fc6e48e 100644
--- a/qsirecon/tests/data/amico_noddi_outputs.txt
+++ b/qsirecon/tests/data/amico_noddi_outputs.txt
@@ -15,10 +15,20 @@ derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-nod
 derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-direction_dwimap.nii.gz
 derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-icvf_dwimap.json
 derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-icvf_dwimap.nii.gz
+derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-icvf_desc-modulated_dwimap.json
+derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-icvf_desc-modulated_dwimap.nii.gz
 derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-isovf_dwimap.json
 derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-isovf_dwimap.nii.gz
 derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-od_dwimap.json
 derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-od_dwimap.nii.gz
+derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-od_desc-modulated_dwimap.json
+derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-od_desc-modulated_dwimap.nii.gz
+derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-rmse_dwimap.json
+derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-rmse_dwimap.nii.gz
+derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-nrmse_dwimap.json
+derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-nrmse_dwimap.nii.gz
+derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-tf_dwimap.nii.gz
+derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-tf_dwimap.json
 derivatives/qsirecon-NODDI/sub-PNC/sub-PNC.html
 logs
 logs/CITATION.bib
diff --git a/qsirecon/workflows/base.py b/qsirecon/workflows/base.py
index 6e321f12..acdbab4e 100644
--- a/qsirecon/workflows/base.py
+++ b/qsirecon/workflows/base.py
@@ -107,7 +107,7 @@ def init_single_subject_recon_wf(subject_id):
 
 Many internal operations of *QSIRecon* use
 *Nilearn* {nilearn_ver} [@nilearn, RRID:SCR_001362] and
-*Dipy* {dipy_ver}[@dipy].
+*Dipy* {dipy_ver} [@dipy].
 For more details of the pipeline, see [the section corresponding
 to workflows in *QSIRecon*'s documentation]\
 (https://qsirecon.readthedocs.io/en/latest/workflows.html).
diff --git a/qsirecon/workflows/recon/amico.py b/qsirecon/workflows/recon/amico.py
index cf1cf09b..8d20ee26 100644
--- a/qsirecon/workflows/recon/amico.py
+++ b/qsirecon/workflows/recon/amico.py
@@ -11,7 +11,7 @@
 from niworkflows.engine.workflows import LiterateWorkflow as Workflow
 
 from ... import config
-from ...interfaces.amico import NODDI
+from ...interfaces.amico import NODDI, NODDITissueFraction
 from ...interfaces.bids import DerivativesDataSink
 from ...interfaces.converters import NODDItoFIBGZ
 from ...interfaces.interchange import recon_workflow_input_fields
@@ -45,6 +45,14 @@ def init_amico_noddi_fit_wf(
             Voxelwise Orientation Dispersion
         isovf_image
             Voxelwise ISOVF
+        modulated_icvf_image
+            Voxelwise modulated ICVF (ICVF * (1 - ISOVF))
+        modulated_od_image
+            Voxelwise modulated Orientation Dispersion  (OD * (1 - ISOVF))
+        rmse_image
+            Voxelwise root mean square error between predicted and measured signal
+        nrmse_image
+            Voxelwise normalized root mean square error between predicted and measured signal
         config_file
             Pickle file with model configurations in it
         fibgz
@@ -61,9 +69,14 @@ def init_amico_noddi_fit_wf(
                 "icvf_image",
                 "od_image",
                 "isovf_image",
+                "modulated_icvf_image",
+                "modulated_od_image",
+                "rmse_image",
+                "nrmse_image",
                 "config_file",
                 "fibgz",
                 "recon_scalars",
+                "tf_image",
             ],
         ),
         name="outputnode",
@@ -72,9 +85,10 @@ def init_amico_noddi_fit_wf(
     workflow = Workflow(name=name)
 
     plot_reports = params.pop("plot_reports", True)
-    desc = """NODDI Reconstruction
+    desc = """
+### NODDI Reconstruction
 
-: """
+"""
     desc += """\
 The NODDI model (@noddi) was fit using the AMICO implementation (@amico).
 A value of %.1E was used for parallel diffusivity and %.1E for isotropic
@@ -83,14 +97,19 @@ def init_amico_noddi_fit_wf(
         params["dIso"],
     )
     if params.get("is_exvivo"):
-        desc += " An additional component was added to the model foe ex-vivo data."
+        desc += " An additional component was added to the model for ex-vivo data."
+
+    desc += """\
+ Tissue fraction (1 - ISOVF) modulated ICVF and Orientation Dispersion maps
+were also computed (@parker2021not)."""
 
     recon_scalars = pe.Node(
-        AMICOReconScalars(qsirecon_suffix=qsirecon_suffix),
+        AMICOReconScalars(dismiss_entities=["desc"], qsirecon_suffix=qsirecon_suffix),
         name="recon_scalars",
         run_without_submitting=True,
     )
     noddi_fit = pe.Node(NODDI(**params), name="recon_noddi", n_procs=omp_nthreads)
+    noddi_tissue_fraction = pe.Node(NODDITissueFraction(), name="noddi_tissue_fraction")
     convert_to_fibgz = pe.Node(NODDItoFIBGZ(), name="convert_to_fibgz")
 
     workflow.connect([
@@ -100,11 +119,21 @@ def init_amico_noddi_fit_wf(
             ('bvec_file', 'bvec_file'),
             ('dwi_mask', 'mask_file'),
         ]),
+        (inputnode, noddi_tissue_fraction, [('dwi_mask', 'mask_image')]),
+        (noddi_fit, noddi_tissue_fraction, [
+            ('isovf_image', 'isovf_image'),
+        ]),
+        (noddi_tissue_fraction, outputnode, [('tf_image', 'tf_image')]),
+        (noddi_tissue_fraction, recon_scalars, [('tf_image', 'tf_image')]),
         (noddi_fit, outputnode, [
             ('directions_image', 'directions_image'),
             ('icvf_image', 'icvf_image'),
             ('od_image', 'od_image'),
             ('isovf_image', 'isovf_image'),
+            ('modulated_icvf_image', 'modulated_icvf_image'),
+            ('modulated_od_image', 'modulated_od_image'),
+            ('rmse_image', 'rmse_image'),
+            ('nrmse_image', 'nrmse_image'),
             ('config_file', 'config_file'),
         ]),
         (noddi_fit, recon_scalars, [
@@ -112,6 +141,10 @@ def init_amico_noddi_fit_wf(
             ('od_image', 'od_image'),
             ('isovf_image', 'isovf_image'),
             ('directions_image', 'directions_image'),
+            ('modulated_icvf_image', 'modulated_icvf_image'),
+            ('modulated_od_image', 'modulated_od_image'),
+            ('rmse_image', 'rmse_image'),
+            ('nrmse_image', 'nrmse_image'),
         ]),
         (recon_scalars, outputnode, [("scalar_info", "recon_scalars")]),
         (noddi_fit, convert_to_fibgz, [
@@ -119,6 +152,8 @@ def init_amico_noddi_fit_wf(
             ('icvf_image', 'icvf_file'),
             ('od_image', 'od_file'),
             ('isovf_image', 'isovf_file'),
+            ('modulated_icvf_image', 'modulated_icvf_file'),
+            ('modulated_od_image', 'modulated_od_file'),
         ]),
         (inputnode, convert_to_fibgz, [('dwi_mask', 'mask_file')]),
         (convert_to_fibgz, outputnode, [('fibgz_file', 'fibgz')])
@@ -150,7 +185,9 @@ def init_amico_noddi_fit_wf(
         derivatives_config = load_yaml(load_data("nonscalars/amico_noddi.yaml"))
         ds_fibgz = pe.Node(
             DerivativesDataSink(
-                dismiss_entities=("desc",), compress=True, **derivatives_config["fibgz"]["bids"]
+                dismiss_entities=["desc"],
+                compress=True,
+                **derivatives_config["fibgz"]["bids"],
             ),
             name=f"ds_{qsirecon_suffix}_fibgz",
             run_without_submitting=True,
@@ -165,7 +202,7 @@ def init_amico_noddi_fit_wf(
 
         ds_config = pe.Node(
             DerivativesDataSink(
-                dismiss_entities=("desc",),
+                dismiss_entities=["desc"],
                 compress=True,
                 **derivatives_config["config_file"]["bids"],
             ),
diff --git a/qsirecon/workflows/recon/anatomical.py b/qsirecon/workflows/recon/anatomical.py
index af651903..a7eef6b2 100644
--- a/qsirecon/workflows/recon/anatomical.py
+++ b/qsirecon/workflows/recon/anatomical.py
@@ -570,7 +570,7 @@ def _get_status():
     # up to version 0.14.3
     if has_qsiprep_t1w and not prefer_dwi_mask:
         desc += (
-            f"Brainmasks from {skull_strip_method} were used in all subsequent reconstruction "
+            f"Brain masks from {skull_strip_method} were used in all subsequent reconstruction "
             "steps. "
         )
         # Resample anat mask


Reviews:
user: login: tsalo
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T16:43:23Z
user: login: tsalo
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T17:05:54Z
user: login: araikes
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T17:07:46Z

Review comments:
diff_hunk: @@ -83,7 +96,11 @@ def init_amico_noddi_fit_wf(
         params["dIso"],
     )
     if params.get("is_exvivo"):
-        desc += " An additional component was added to the model foe ex-vivo data."
+        desc += " An additional component was added to the model for ex-vivo data."
+
+    desc += """\
+ Tissue fraction (1 - ISOVF) modulated ICVF and Orientation Dispersion maps
+were also computed (@parker2021not)."""
 
     recon_scalars = pe.Node(
         AMICOReconScalars(qsirecon_suffix=qsirecon_suffix),
path: qsirecon/workflows/recon/amico.py
user: login: tsalo
body: ```suggestion
        AMICOReconScalars(dismiss_entities=["desc"], qsirecon_suffix=qsirecon_suffix),
```

I think you need to dismiss desc here.
created_at: 2025-04-02T16:43:22Z
author_association: MEMBER
diff_hunk: @@ -150,7 +177,9 @@ def init_amico_noddi_fit_wf(
         derivatives_config = load_yaml(load_data("nonscalars/amico_noddi.yaml"))
         ds_fibgz = pe.Node(
             DerivativesDataSink(
-                dismiss_entities=("desc",), compress=True, **derivatives_config["fibgz"]["bids"]
+                dismiss_entities=["desc"],
+                compress=True,
+                **derivatives_config["fibgz"]["bids"]
path: qsirecon/workflows/recon/amico.py
user: login: tsalo
body: ```suggestion
                **derivatives_config["fibgz"]["bids"],
```
To satisfy the linter.
created_at: 2025-04-02T17:05:54Z
author_association: MEMBER
diff_hunk: @@ -150,7 +177,9 @@ def init_amico_noddi_fit_wf(
         derivatives_config = load_yaml(load_data("nonscalars/amico_noddi.yaml"))
         ds_fibgz = pe.Node(
             DerivativesDataSink(
-                dismiss_entities=("desc",), compress=True, **derivatives_config["fibgz"]["bids"]
+                dismiss_entities=["desc"],
+                compress=True,
+                **derivatives_config["fibgz"]["bids"]
path: qsirecon/workflows/recon/amico.py
user: login: araikes
body: Ah... I couldn't figure out what it was mad about
created_at: 2025-04-02T17:07:46Z
author_association: COLLABORATOR

PR comments:
user: login: araikes
created_at: 2025-03-20T21:21:53Z
body: It would probably be helpful to also have an output for the tissue fraction, but AMICO doesn't save that and I wasn't sure how to accomplish.

Internally for AMICO, `tf = 1 - fwf` but that's not retained in its outputs.

Left: ISOVF; Right: TF

![image](https://github.com/user-attachments/assets/e76d97ac-c0d9-4ebb-8006-2ad4d1f88405)

author_association: COLLABORATOR
user: login: tsalo
created_at: 2025-03-25T13:49:45Z
body: It looks like the only remaining change is to the list of output files that is checked agaisnt in the Recon_AMICO test. I also noticed that you capitalized some of the parameters in the filenames, which is a breaking change.
author_association: MEMBER
user: login: tsalo
created_at: 2025-04-02T17:04:57Z
body: It looks like it's working! I think the only thing you need to do is add the following to the expected file list for the recon AMICO test.

```
derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-icvf_desc-modulated_dwimap.json
derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-icvf_desc-modulated_dwimap.nii.gz
derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-nrmse_dwimap.json
derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-nrmse_dwimap.nii.gz
derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-od_desc-modulated_dwimap.json
derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-od_desc-modulated_dwimap.nii.gz
derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-rmse_dwimap.json
derivatives/qsirecon-NODDI/sub-PNC/dwi/sub-PNC_acq-realistic_space-T1w_model-noddi_param-rmse_dwimap.nii.gz
```
author_association: MEMBER
user: login: codecov-commenter
created_at: 2025-04-02T17:36:43Z
body: ## [Codecov](https://app.codecov.io/gh/PennLINC/qsirecon/pull/218?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC) Report
Attention: Patch coverage is `8.69565%` with `21 lines` in your changes missing coverage. Please review.
> Project coverage is 13.72%. Comparing base [(`04385fb`)](https://app.codecov.io/gh/PennLINC/qsirecon/commit/04385fb0df85317743150da0577f201d4fc93c47?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC) to head [(`1a640c7`)](https://app.codecov.io/gh/PennLINC/qsirecon/commit/1a640c7b51a496bf7437593883a195b940d3e0b0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC).
> Report is 2 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/PennLINC/qsirecon/pull/218?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC) | Patch % | Lines |
|---|---|---|
| [qsirecon/interfaces/amico.py](https://app.codecov.io/gh/PennLINC/qsirecon/pull/218?src=pr&el=tree&filepath=qsirecon%2Finterfaces%2Famico.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC#diff-cXNpcmVjb24vaW50ZXJmYWNlcy9hbWljby5weQ==) | 0.00% | [11 Missing :warning: ](https://app.codecov.io/gh/PennLINC/qsirecon/pull/218?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC) |
| [qsirecon/interfaces/converters.py](https://app.codecov.io/gh/PennLINC/qsirecon/pull/218?src=pr&el=tree&filepath=qsirecon%2Finterfaces%2Fconverters.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC#diff-cXNpcmVjb24vaW50ZXJmYWNlcy9jb252ZXJ0ZXJzLnB5) | 22.22% | [7 Missing :warning: ](https://app.codecov.io/gh/PennLINC/qsirecon/pull/218?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC) |
| [qsirecon/workflows/recon/amico.py](https://app.codecov.io/gh/PennLINC/qsirecon/pull/218?src=pr&el=tree&filepath=qsirecon%2Fworkflows%2Frecon%2Famico.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC#diff-cXNpcmVjb24vd29ya2Zsb3dzL3JlY29uL2FtaWNvLnB5) | 0.00% | [3 Missing :warning: ](https://app.codecov.io/gh/PennLINC/qsirecon/pull/218?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##             main     #218       +/-   ##
===========================================
- Coverage   48.74%   13.72%   -35.02%     
===========================================
  Files          56       56               
  Lines        7425     7447       +22     
  Branches     1015     1015               
===========================================
- Hits         3619     1022     -2597     
- Misses       3588     6393     +2805     
+ Partials      218       32      -186     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/PennLINC/qsirecon/pull/218?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: mattcieslak
created_at: 2025-04-03T17:29:53Z
body: Thank you @araikes and @tsalo !!
author_association: CONTRIBUTOR