Skip to content

ENH: Implicitly-pipelined and modality-agnostic Estimator #62

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/notebooks/bold_realignment.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@
"metadata": {},
"outputs": [],
"source": [
"from nifreeze.model.base import AverageModel\n",
"from nifreeze.model.base import ExpectationModel\n",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Average is too specific about the statistic used. If Expectation meets opposition, I'm happy to settle whatever others think is best (or even revert back to Average).

"from nifreeze.utils.iterators import random_iterator"
]
},
Expand All @@ -358,7 +358,7 @@
" t_mask[t] = True\n",
"\n",
" # Fit and predict using the model\n",
" model = AverageModel()\n",
" model = ExpectationModel()\n",
" model.fit(\n",
" data[..., ~t_mask],\n",
" stat=\"median\",\n",
Expand All @@ -376,7 +376,7 @@
" fixedmask_path=brainmask_path,\n",
" output_transform_prefix=f\"conversion-{t:02d}\",\n",
" num_threads=8,\n",
" )\n",
" ).cmdline\n",
"\n",
" # Run the command\n",
" proc = await asyncio.create_subprocess_shell(\n",
Expand Down
2 changes: 1 addition & 1 deletion scripts/optimize_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ async def train_coro(
moving_path,
fixedmask_path=brainmask_path,
**_kwargs,
)
).cmdline
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generate_command function has been modified to return the nipype interface and that way unify the antsRegistration command line generation in a single function.


tasks.append(
ants(
Expand Down
19 changes: 12 additions & 7 deletions src/nifreeze/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from pathlib import Path

from nifreeze.cli.parser import parse_args
from nifreeze.data.dmri import DWI
from nifreeze.data.base import BaseDataset
from nifreeze.estimator import Estimator


Expand All @@ -40,14 +40,19 @@
args = parse_args(argv)

# Open the data with the given file path
dwi_dataset: DWI = DWI.from_filename(args.input_file)
dataset: BaseDataset = BaseDataset.from_filename(args.input_file)

Check warning on line 43 in src/nifreeze/cli/run.py

View check run for this annotation

Codecov / codecov/patch

src/nifreeze/cli/run.py#L43

Added line #L43 was not covered by tests
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generalize beyond DWI. In the end, we most likely will not expose the dataset HDF5 and accept NIfTIs + metadata (e.g., bvecs/bvals) and build the appropriate object. For a future PR.


estimator: Estimator = Estimator()
prev_model: Estimator | None = None

Check warning on line 45 in src/nifreeze/cli/run.py

View check run for this annotation

Codecov / codecov/patch

src/nifreeze/cli/run.py#L45

Added line #L45 was not covered by tests
for _model in args.models:
estimator: Estimator = Estimator(

Check warning on line 47 in src/nifreeze/cli/run.py

View check run for this annotation

Codecov / codecov/patch

src/nifreeze/cli/run.py#L47

Added line #L47 was not covered by tests
_model,
prev=prev_model,
)
prev_model = estimator

Check warning on line 51 in src/nifreeze/cli/run.py

View check run for this annotation

Codecov / codecov/patch

src/nifreeze/cli/run.py#L51

Added line #L51 was not covered by tests

_ = estimator.estimate(
dwi_dataset,
_ = estimator.run(

Check warning on line 53 in src/nifreeze/cli/run.py

View check run for this annotation

Codecov / codecov/patch

src/nifreeze/cli/run.py#L53

Added line #L53 was not covered by tests
dataset,
align_kwargs=args.align_config,
models=args.models,
omp_nthreads=args.nthreads,
njobs=args.njobs,
seed=args.seed,
Expand All @@ -58,7 +63,7 @@
output_path: Path = Path(args.output_dir) / output_filename

# Save the DWI dataset to the output path
dwi_dataset.to_filename(output_path)
dataset.to_filename(output_path)

Check warning on line 66 in src/nifreeze/cli/run.py

View check run for this annotation

Codecov / codecov/patch

src/nifreeze/cli/run.py#L66

Added line #L66 was not covered by tests


if __name__ == "__main__":
Expand Down
110 changes: 109 additions & 1 deletion src/nifreeze/data/dmri.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,30 @@
from nifreeze.data.base import BaseDataset, _cmp, _data_repr
from nifreeze.utils.ndimage import load_api

DEFAULT_CLIP_PERCENTILE = 75
"""Upper percentile threshold for intensity clipping."""

DEFAULT_MIN_S0 = 1e-5
"""Minimum value when considering the :math:`S_{0}` DWI signal."""

DEFAULT_MAX_S0 = 1.0
"""Maximum value when considering the :math:`S_{0}` DWI signal."""

DEFAULT_LOWB_THRESHOLD = 50
"""The lower bound for the b-value so that the orientation is considered a DW volume."""

DEFAULT_HIGHB_THRESHOLD = 8000
"""A b-value cap for DWI data."""

DEFAULT_NUM_BINS = 15
"""Number of bins to classify b-values."""

DEFAULT_MULTISHELL_BIN_COUNT_THR = 7
"""Default bin count to consider a multishell scheme."""

DTI_MIN_ORIENTATIONS = 6
"""Minimum number of nonzero b-values in a DWI dataset."""
Comment on lines +42 to +64
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These variables better belong in the data representation object rather than models (perhaps the two first could be returned back to model).



@attr.s(slots=True)
class DWI(BaseDataset[np.ndarray | None]):
Expand Down Expand Up @@ -226,7 +250,7 @@
bvec_file: Path | str | None = None,
bval_file: Path | str | None = None,
b0_file: Path | str | None = None,
b0_thres: float = 50.0,
b0_thres: float = DEFAULT_LOWB_THRESHOLD,
) -> DWI:
"""
Load DWI data and construct a DWI object.
Expand Down Expand Up @@ -342,3 +366,87 @@
dwi_obj.brainmask = np.asanyarray(mask_img.dataobj, dtype=bool)

return dwi_obj


def find_shelling_scheme(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This felt like a dwi-specific manipulation routine, I moved it here for that reason.

bvals,
num_bins=DEFAULT_NUM_BINS,
multishell_nonempty_bin_count_thr=DEFAULT_MULTISHELL_BIN_COUNT_THR,
bval_cap=DEFAULT_HIGHB_THRESHOLD,
):
"""
Find the shelling scheme on the given b-values.

Computes the histogram of the b-values according to ``num_bins``
and depending on the nonempty bin count, classify the shelling scheme
as single-shell if they are 2 (low-b and a shell); multi-shell if they are
below the ``multishell_nonempty_bin_count_thr`` value; and DSI otherwise.

Parameters
----------
bvals : :obj:`list` or :obj:`~numpy.ndarray`
List or array of b-values.
num_bins : :obj:`int`, optional
Number of bins.
multishell_nonempty_bin_count_thr : :obj:`int`, optional
Bin count to consider a multi-shell scheme.

Returns
-------
scheme : :obj:`str`
Shelling scheme.
bval_groups : :obj:`list`
List of grouped b-values.
bval_estimated : :obj:`list`
List of 'estimated' b-values as the median value of each b-value group.

"""

# Bin the b-values: use -1 as the lower bound to be able to appropriately
# include b0 values
hist, bin_edges = np.histogram(bvals, bins=num_bins, range=(-1, min(max(bvals), bval_cap)))

# Collect values in each bin
bval_groups = []
bval_estimated = []
for lower, upper in zip(bin_edges[:-1], bin_edges[1:], strict=False):
# Add only if a nonempty b-values mask
if (mask := (bvals > lower) & (bvals <= upper)).sum():
bval_groups.append(bvals[mask])
bval_estimated.append(np.median(bvals[mask]))

nonempty_bins = len(bval_groups)

if nonempty_bins < 2:
raise ValueError("DWI must have at least one high-b shell")

Check warning on line 421 in src/nifreeze/data/dmri.py

View check run for this annotation

Codecov / codecov/patch

src/nifreeze/data/dmri.py#L421

Added line #L421 was not covered by tests

if nonempty_bins == 2:
scheme = "single-shell"
elif nonempty_bins < multishell_nonempty_bin_count_thr:
scheme = "multi-shell"
else:
scheme = "DSI"

return scheme, bval_groups, bval_estimated


def _rasb2dipy(gradient):
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one also felt misplaced. And it's not really necessary (see #3)

import warnings

Check warning on line 434 in src/nifreeze/data/dmri.py

View check run for this annotation

Codecov / codecov/patch

src/nifreeze/data/dmri.py#L434

Added line #L434 was not covered by tests

gradient = np.asanyarray(gradient)

Check warning on line 436 in src/nifreeze/data/dmri.py

View check run for this annotation

Codecov / codecov/patch

src/nifreeze/data/dmri.py#L436

Added line #L436 was not covered by tests
if gradient.ndim == 1:
if gradient.size != 4:
raise ValueError("Missing gradient information.")
gradient = gradient[..., np.newaxis]

Check warning on line 440 in src/nifreeze/data/dmri.py

View check run for this annotation

Codecov / codecov/patch

src/nifreeze/data/dmri.py#L439-L440

Added lines #L439 - L440 were not covered by tests

if gradient.shape[0] != 4:
gradient = gradient.T

Check warning on line 443 in src/nifreeze/data/dmri.py

View check run for this annotation

Codecov / codecov/patch

src/nifreeze/data/dmri.py#L443

Added line #L443 was not covered by tests
elif gradient.shape == (4, 4):
print("Warning: make sure gradient information is not transposed!")

Check warning on line 445 in src/nifreeze/data/dmri.py

View check run for this annotation

Codecov / codecov/patch

src/nifreeze/data/dmri.py#L445

Added line #L445 was not covered by tests

with warnings.catch_warnings():
from dipy.core.gradients import gradient_table

Check warning on line 448 in src/nifreeze/data/dmri.py

View check run for this annotation

Codecov / codecov/patch

src/nifreeze/data/dmri.py#L447-L448

Added lines #L447 - L448 were not covered by tests

warnings.filterwarnings("ignore", category=UserWarning)
retval = gradient_table(gradient[3, :], bvecs=gradient[:3, :].T)
return retval

Check warning on line 452 in src/nifreeze/data/dmri.py

View check run for this annotation

Codecov / codecov/patch

src/nifreeze/data/dmri.py#L450-L452

Added lines #L450 - L452 were not covered by tests
Loading
Loading