From 89dd58e7b32c93e81de3586edf121bb4e1475db1 Mon Sep 17 00:00:00 2001 From: Andreas Kofler Date: Wed, 10 Jul 2024 10:52:49 +0200 Subject: [PATCH 01/34] Fix issue if dim is not in (-3,-2,-1) for FastFourierOp --- src/mrpro/operators/FastFourierOp.py | 6 ++++-- tests/operators/test_fast_fourier_op.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/mrpro/operators/FastFourierOp.py b/src/mrpro/operators/FastFourierOp.py index 76cda0f1..024ffcbb 100644 --- a/src/mrpro/operators/FastFourierOp.py +++ b/src/mrpro/operators/FastFourierOp.py @@ -79,22 +79,24 @@ def __init__( self._pad_op: ZeroPadOp if isinstance(recon_matrix, SpatialDimension): - original_shape: Sequence[int] | None = [int(astuple(recon_matrix)[d]) for d in dim] if not all(d in (-1, -2, -3) for d in dim): raise NotImplementedError( f'recon_matrix can only be a SpatialDimension if each value in dim is in (-3,-2,-1),' f'got {dim=}\nInstead, you can also supply a list of values of same length as dim' ) + original_shape: Sequence[int] | None = [int(astuple(recon_matrix)[d]) for d in dim] + else: original_shape = recon_matrix if isinstance(encoding_matrix, SpatialDimension): - padded_shape: Sequence[int] | None = [int(astuple(encoding_matrix)[d]) for d in dim] if not all(d in (-1, -2, -3) for d in dim): raise NotImplementedError( f'encoding_matrix can only be a SpatialDimension if each value in dim is in (-3,-2,-1),' f'got {dim=}\nInstead, you can also supply a list of values of same length as dim' ) + padded_shape: Sequence[int] | None = [int(astuple(encoding_matrix)[d]) for d in dim] + else: padded_shape = encoding_matrix diff --git a/tests/operators/test_fast_fourier_op.py b/tests/operators/test_fast_fourier_op.py index 27da623e..d194955b 100644 --- a/tests/operators/test_fast_fourier_op.py +++ b/tests/operators/test_fast_fourier_op.py @@ -101,3 +101,17 @@ def test_fast_fourier_op_onematrix(): FastFourierOp(recon_matrix=recon_matrix, encoding_matrix=None) with pytest.raises(ValueError, match='None'): FastFourierOp(recon_matrix=None, encoding_matrix=encoding_matrix) + + +def test_invalid_dim(): + """Tests that dims are in (-3,-2,-1) if recon_matrix + or encoding_matrix is SpatialDimension""" + + recon_matrix = SpatialDimension(z=101, y=201, x=61) + encoding_matrix = SpatialDimension(z=14, y=220, x=61) + + with pytest.raises(NotImplementedError, match='recon_matrix'): + FastFourierOp(recon_matrix=recon_matrix, encoding_matrix=None, dim=(-4, -2, -1)) + + with pytest.raises(NotImplementedError, match='encoding_matrix'): + FastFourierOp(recon_matrix=None, encoding_matrix=encoding_matrix, dim=(-4, -2, -1)) From 62b3fa3749169cbf2450fea3df38f6de5ddb39a2 Mon Sep 17 00:00:00 2001 From: Johannes Hammacher <135006694+JoHa0811@users.noreply.github.com> Date: Wed, 10 Jul 2024 11:02:28 +0200 Subject: [PATCH 02/34] Allow multiple imports per line and use typing.Self where possible Co-authored-by: Patrick Schuenke --- examples/pulseq_2d_radial_golden_angle.ipynb | 12 +++--------- examples/pulseq_2d_radial_golden_angle.py | 12 +++--------- examples/t1_mapping_with_grad_acq.ipynb | 8 ++------ examples/t1_mapping_with_grad_acq.py | 8 ++------ pyproject.toml | 3 ++- src/mrpro/__init__.py | 6 +----- src/mrpro/algorithms/__init__.py | 4 +--- src/mrpro/algorithms/prewhiten_kspace.py | 6 +----- .../reconstruction/DirectReconstruction.py | 5 +---- .../algorithms/reconstruction/Reconstruction.py | 6 ++---- src/mrpro/data/AcqInfo.py | 5 ++--- src/mrpro/data/CsmData.py | 4 ++-- src/mrpro/data/Data.py | 2 -- src/mrpro/data/DcfData.py | 7 +++---- src/mrpro/data/EncodingLimits.py | 8 +++----- src/mrpro/data/IData.py | 16 +++++++--------- src/mrpro/data/IHeader.py | 10 ++++------ src/mrpro/data/KHeader.py | 4 ++-- src/mrpro/data/KNoise.py | 5 ++--- src/mrpro/data/KTrajectory.py | 5 ++--- src/mrpro/data/KTrajectoryRawShape.py | 2 -- src/mrpro/data/MoveDataMixin.py | 10 +--------- src/mrpro/data/QData.py | 5 ++--- src/mrpro/data/QHeader.py | 9 ++++----- src/mrpro/data/SpatialDimension.py | 4 +--- src/mrpro/data/TrajectoryDescription.py | 5 ++--- src/mrpro/data/__init__.py | 10 +++------- src/mrpro/data/_kdata/KData.py | 10 +++------- src/mrpro/data/_kdata/KDataProtocol.py | 6 +----- src/mrpro/data/_kdata/KDataRearrangeMixin.py | 2 -- src/mrpro/data/_kdata/KDataRemoveOsMixin.py | 2 -- src/mrpro/data/_kdata/KDataSelectMixin.py | 5 +---- src/mrpro/data/_kdata/KDataSplitMixin.py | 8 ++------ src/mrpro/data/enums.py | 4 +--- .../traj_calculators/KTrajectoryCalculator.py | 5 +---- .../data/traj_calculators/KTrajectoryIsmrmrd.py | 2 -- .../data/traj_calculators/KTrajectoryPulseq.py | 2 -- .../data/traj_calculators/KTrajectoryRpe.py | 2 -- .../KTrajectorySunflowerGoldenRpe.py | 2 -- src/mrpro/operators/ConstraintsOp.py | 3 +-- src/mrpro/operators/DensityCompensationOp.py | 2 -- src/mrpro/operators/EndomorphOperator.py | 10 +--------- src/mrpro/operators/FourierOp.py | 8 +++----- src/mrpro/operators/GridSamplingOp.py | 3 +-- src/mrpro/operators/LinearOperator.py | 17 +++++++++-------- src/mrpro/operators/MagnitudeOp.py | 3 +-- src/mrpro/operators/Operator.py | 8 ++------ src/mrpro/operators/PhaseOp.py | 3 +-- src/mrpro/operators/SensitivityOp.py | 2 -- src/mrpro/operators/SignalModel.py | 2 -- src/mrpro/operators/SliceProjectionOp.py | 6 ++---- src/mrpro/operators/__init__.py | 3 +-- src/mrpro/utils/Rotation.py | 9 ++------- src/mrpro/utils/filters.py | 2 -- src/mrpro/utils/sliding_window.py | 2 -- src/mrpro/utils/smap.py | 3 +-- tests/algorithms/test_optimizers.py | 3 +-- tests/algorithms/test_prewhiten_kspace.py | 4 +--- tests/conftest.py | 6 ++---- tests/data/_IsmrmrdRawTestData.py | 2 -- tests/data/test_csm_data.py | 4 +--- tests/data/test_dcf_data.py | 3 +-- tests/data/test_kdata.py | 13 ++++--------- tests/data/test_ktraj_raw_shape.py | 3 +-- tests/data/test_movedatamixin.py | 3 +-- tests/data/test_qdata.py | 3 +-- tests/data/test_traj_calculators.py | 14 ++++++++------ .../operators/models/test_inversion_recovery.py | 3 +-- tests/operators/models/test_molli.py | 3 +-- .../models/test_mono_exponential_decay.py | 3 +-- .../models/test_saturation_recovery.py | 3 +-- ...t_transient_steady_state_with_preparation.py | 3 +-- tests/operators/models/test_wasabi.py | 3 +-- tests/operators/models/test_wasabiti.py | 3 +-- tests/operators/test_cartesian_sampling_op.py | 3 +-- tests/operators/test_operator_norm.py | 7 ++----- tests/operators/test_operators.py | 3 +-- tests/operators/test_sensitivity_op.py | 4 +--- tests/operators/test_slice_projection_op.py | 4 +--- tests/phantoms/_EllipsePhantomTestData.py | 3 +-- tests/utils/test_filters.py | 4 +--- 81 files changed, 128 insertions(+), 296 deletions(-) diff --git a/examples/pulseq_2d_radial_golden_angle.ipynb b/examples/pulseq_2d_radial_golden_angle.ipynb index 109ad3ca..729d1d5b 100644 --- a/examples/pulseq_2d_radial_golden_angle.ipynb +++ b/examples/pulseq_2d_radial_golden_angle.ipynb @@ -27,15 +27,9 @@ "import matplotlib.pyplot as plt\n", "import requests\n", "import torch\n", - "from mrpro.data import CsmData\n", - "from mrpro.data import DcfData\n", - "from mrpro.data import IData\n", - "from mrpro.data import KData\n", - "from mrpro.data.traj_calculators import KTrajectoryIsmrmrd\n", - "from mrpro.data.traj_calculators import KTrajectoryPulseq\n", - "from mrpro.data.traj_calculators import KTrajectoryRadial2D\n", - "from mrpro.operators import FourierOp\n", - "from mrpro.operators import SensitivityOp" + "from mrpro.data import CsmData, DcfData, IData, KData\n", + "from mrpro.data.traj_calculators import KTrajectoryIsmrmrd, KTrajectoryPulseq, KTrajectoryRadial2D\n", + "from mrpro.operators import FourierOp, SensitivityOp" ] }, { diff --git a/examples/pulseq_2d_radial_golden_angle.py b/examples/pulseq_2d_radial_golden_angle.py index 06ea0a5f..f30a9911 100644 --- a/examples/pulseq_2d_radial_golden_angle.py +++ b/examples/pulseq_2d_radial_golden_angle.py @@ -14,15 +14,9 @@ import matplotlib.pyplot as plt import requests import torch -from mrpro.data import CsmData -from mrpro.data import DcfData -from mrpro.data import IData -from mrpro.data import KData -from mrpro.data.traj_calculators import KTrajectoryIsmrmrd -from mrpro.data.traj_calculators import KTrajectoryPulseq -from mrpro.data.traj_calculators import KTrajectoryRadial2D -from mrpro.operators import FourierOp -from mrpro.operators import SensitivityOp +from mrpro.data import CsmData, DcfData, IData, KData +from mrpro.data.traj_calculators import KTrajectoryIsmrmrd, KTrajectoryPulseq, KTrajectoryRadial2D +from mrpro.operators import FourierOp, SensitivityOp # %% # define zenodo records URL and create a temporary directory and h5-file diff --git a/examples/t1_mapping_with_grad_acq.ipynb b/examples/t1_mapping_with_grad_acq.ipynb index 263d08f9..a9295b17 100644 --- a/examples/t1_mapping_with_grad_acq.ipynb +++ b/examples/t1_mapping_with_grad_acq.ipynb @@ -23,13 +23,9 @@ "import matplotlib.pyplot as plt\n", "import torch\n", "import zenodo_get\n", - "from mrpro.data import CsmData\n", - "from mrpro.data import DcfData\n", - "from mrpro.data import IData\n", - "from mrpro.data import KData\n", + "from mrpro.data import CsmData, DcfData, IData, KData\n", "from mrpro.data.traj_calculators import KTrajectoryIsmrmrd\n", - "from mrpro.operators import FourierOp\n", - "from mrpro.operators import SensitivityOp" + "from mrpro.operators import FourierOp, SensitivityOp" ] }, { diff --git a/examples/t1_mapping_with_grad_acq.py b/examples/t1_mapping_with_grad_acq.py index 7fb37b97..99a9cd8f 100644 --- a/examples/t1_mapping_with_grad_acq.py +++ b/examples/t1_mapping_with_grad_acq.py @@ -10,13 +10,9 @@ import matplotlib.pyplot as plt import torch import zenodo_get -from mrpro.data import CsmData -from mrpro.data import DcfData -from mrpro.data import IData -from mrpro.data import KData +from mrpro.data import CsmData, DcfData, IData, KData from mrpro.data.traj_calculators import KTrajectoryIsmrmrd -from mrpro.operators import FourierOp -from mrpro.operators import SensitivityOp +from mrpro.operators import FourierOp, SensitivityOp # %% # Download raw data in ISMRMRD format from zenodo into a temporary directory diff --git a/pyproject.toml b/pyproject.toml index 67368a2c..41a48708 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,7 +138,8 @@ ignore = [ ] [tool.ruff.lint.isort] -force-single-line = true +force-single-line = false +split-on-trailing-comma = false [tool.ruff.lint.flake8-quotes] docstring-quotes = "double" diff --git a/src/mrpro/__init__.py b/src/mrpro/__init__.py index 0eddd33d..9088c5d3 100644 --- a/src/mrpro/__init__.py +++ b/src/mrpro/__init__.py @@ -1,6 +1,2 @@ -from mrpro import algorithms -from mrpro import operators -from mrpro import data -from mrpro import phantoms -from mrpro import utils +from mrpro import algorithms, operators, data, phantoms, utils diff --git a/src/mrpro/algorithms/__init__.py b/src/mrpro/algorithms/__init__.py index c859cd53..48f9bb23 100644 --- a/src/mrpro/algorithms/__init__.py +++ b/src/mrpro/algorithms/__init__.py @@ -1,4 +1,2 @@ -from mrpro.algorithms import csm -from mrpro.algorithms import optimizers -from mrpro.algorithms import reconstruction +from mrpro.algorithms import csm, optimizers, reconstruction from mrpro.algorithms.prewhiten_kspace import prewhiten_kspace diff --git a/src/mrpro/algorithms/prewhiten_kspace.py b/src/mrpro/algorithms/prewhiten_kspace.py index 1d82e46a..130fed3f 100644 --- a/src/mrpro/algorithms/prewhiten_kspace.py +++ b/src/mrpro/algorithms/prewhiten_kspace.py @@ -14,14 +14,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - from copy import deepcopy import torch -from einops import einsum -from einops import parse_shape -from einops import rearrange +from einops import einsum, parse_shape, rearrange from mrpro.data._kdata.KData import KData from mrpro.data.KNoise import KNoise diff --git a/src/mrpro/algorithms/reconstruction/DirectReconstruction.py b/src/mrpro/algorithms/reconstruction/DirectReconstruction.py index 20f94443..6e778f7c 100644 --- a/src/mrpro/algorithms/reconstruction/DirectReconstruction.py +++ b/src/mrpro/algorithms/reconstruction/DirectReconstruction.py @@ -14,10 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - -from typing import Literal -from typing import Self +from typing import Literal, Self from mrpro.algorithms.prewhiten_kspace import prewhiten_kspace from mrpro.algorithms.reconstruction.Reconstruction import Reconstruction diff --git a/src/mrpro/algorithms/reconstruction/Reconstruction.py b/src/mrpro/algorithms/reconstruction/Reconstruction.py index a1733983..b117dd31 100644 --- a/src/mrpro/algorithms/reconstruction/Reconstruction.py +++ b/src/mrpro/algorithms/reconstruction/Reconstruction.py @@ -14,13 +14,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -from abc import ABC -from abc import abstractmethod +from abc import ABC, abstractmethod import torch -from mrpro.data import IData -from mrpro.data import KData +from mrpro.data import IData, KData class Reconstruction(torch.nn.Module, ABC): diff --git a/src/mrpro/data/AcqInfo.py b/src/mrpro/data/AcqInfo.py index 24e2fe7f..2b472cf8 100644 --- a/src/mrpro/data/AcqInfo.py +++ b/src/mrpro/data/AcqInfo.py @@ -14,10 +14,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - from collections.abc import Sequence from dataclasses import dataclass +from typing import Self import ismrmrd import numpy as np @@ -84,7 +83,7 @@ class AcqInfo(MoveDataMixin): version: torch.Tensor @classmethod - def from_ismrmrd_acquisitions(cls, acquisitions: Sequence[ismrmrd.Acquisition]) -> AcqInfo: + def from_ismrmrd_acquisitions(cls, acquisitions: Sequence[ismrmrd.Acquisition]) -> Self: """Read the header of a list of acquisition and store information. Parameters diff --git a/src/mrpro/data/CsmData.py b/src/mrpro/data/CsmData.py index 54c88725..d60cb0a3 100644 --- a/src/mrpro/data/CsmData.py +++ b/src/mrpro/data/CsmData.py @@ -16,7 +16,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Self import torch @@ -38,7 +38,7 @@ def from_idata_walsh( smoothing_width: int | SpatialDimension[int] = 5, power_iterations: int = 3, chunk_size_otherdim: int | None = None, - ) -> CsmData: + ) -> Self: """Create csm object from image data using iterative Walsh method. Parameters diff --git a/src/mrpro/data/Data.py b/src/mrpro/data/Data.py index 869bffbe..e176440c 100644 --- a/src/mrpro/data/Data.py +++ b/src/mrpro/data/Data.py @@ -14,8 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import dataclasses from abc import ABC from typing import Any diff --git a/src/mrpro/data/DcfData.py b/src/mrpro/data/DcfData.py index 162e46c7..f239ac8b 100644 --- a/src/mrpro/data/DcfData.py +++ b/src/mrpro/data/DcfData.py @@ -20,13 +20,12 @@ from concurrent.futures import ProcessPoolExecutor from functools import reduce from itertools import product -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Self import numpy as np import torch from numpy.typing import ArrayLike -from scipy.spatial import ConvexHull -from scipy.spatial import Voronoi +from scipy.spatial import ConvexHull, Voronoi from mrpro.data.KTrajectory import KTrajectory from mrpro.data.MoveDataMixin import MoveDataMixin @@ -159,7 +158,7 @@ def _dcf_2d3d_voronoi(traj: torch.Tensor) -> torch.Tensor: return torch.tensor(dcf, dtype=torch.float32, device=traj.device) @classmethod - def from_traj_voronoi(cls, traj: KTrajectory) -> DcfData: + def from_traj_voronoi(cls, traj: KTrajectory) -> Self: """Calculate dcf using voronoi approach for 2D or 3D trajectories. Parameters diff --git a/src/mrpro/data/EncodingLimits.py b/src/mrpro/data/EncodingLimits.py index fba0bca9..2b8e4cdb 100644 --- a/src/mrpro/data/EncodingLimits.py +++ b/src/mrpro/data/EncodingLimits.py @@ -14,13 +14,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import dataclasses from dataclasses import dataclass +from typing import Self -from ismrmrd.xsd.ismrmrdschema.ismrmrd import encodingLimitsType -from ismrmrd.xsd.ismrmrdschema.ismrmrd import limitType +from ismrmrd.xsd.ismrmrdschema.ismrmrd import encodingLimitsType, limitType @dataclass(slots=True) @@ -32,7 +30,7 @@ class Limits: center: int = 0 @classmethod - def from_ismrmrd(cls, limit_type: limitType) -> Limits: + def from_ismrmrd(cls, limit_type: limitType) -> Self: """Create Limits from ismrmrd.limitType.""" if limit_type is None: return cls() diff --git a/src/mrpro/data/IData.py b/src/mrpro/data/IData.py index 5d9cd6af..0265db31 100644 --- a/src/mrpro/data/IData.py +++ b/src/mrpro/data/IData.py @@ -14,12 +14,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import dataclasses -from collections.abc import Generator -from collections.abc import Sequence +from collections.abc import Generator, Sequence from pathlib import Path +from typing import Self import numpy as np import torch @@ -85,7 +83,7 @@ def rss(self, keepdim: bool = False) -> torch.Tensor: return self.data.abs().square().sum(dim=coildim, keepdim=keepdim).sqrt() @classmethod - def from_tensor_and_kheader(cls, data: torch.Tensor, kheader: KHeader) -> IData: + def from_tensor_and_kheader(cls, data: torch.Tensor, kheader: KHeader) -> Self: """Create IData object from a tensor and a KHeader object. Parameters @@ -99,7 +97,7 @@ def from_tensor_and_kheader(cls, data: torch.Tensor, kheader: KHeader) -> IData: return cls(header=header, data=data) @classmethod - def from_single_dicom(cls, filename: str | Path) -> IData: + def from_single_dicom(cls, filename: str | Path) -> Self: """Read single DICOM file and return IData object. Parameters @@ -115,7 +113,7 @@ def from_single_dicom(cls, filename: str | Path) -> IData: return cls(data=idata, header=header) @classmethod - def from_dicom_files(cls, filenames: Sequence[str] | Sequence[Path] | Generator[Path, None, None]) -> IData: + def from_dicom_files(cls, filenames: Sequence[str] | Sequence[Path] | Generator[Path, None, None]) -> Self: """Read multiple DICOM files and return IData object. Parameters @@ -158,7 +156,7 @@ def get_unique_slice_positions(slice_pos_tag: TagType = 0x00191015): return cls(data=idata, header=header) @classmethod - def from_dicom_folder(cls, foldername: str | Path, suffix: str | None = 'dcm') -> IData: + def from_dicom_folder(cls, foldername: str | Path, suffix: str | None = 'dcm') -> Self: """Read all DICOM files from a folder and return IData object. Parameters @@ -175,4 +173,4 @@ def from_dicom_folder(cls, foldername: str | Path, suffix: str | None = 'dcm') - if len(file_paths) == 0: raise ValueError(f'No dicom files with suffix {suffix} found in {foldername}') - return IData.from_dicom_files(filenames=file_paths) + return cls.from_dicom_files(filenames=file_paths) diff --git a/src/mrpro/data/IHeader.py b/src/mrpro/data/IHeader.py index 7906c5cd..1b7dba69 100644 --- a/src/mrpro/data/IHeader.py +++ b/src/mrpro/data/IHeader.py @@ -14,17 +14,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import dataclasses from collections.abc import Sequence from dataclasses import dataclass +from typing import Self import numpy as np import torch from pydicom.dataset import Dataset -from pydicom.tag import Tag -from pydicom.tag import TagType +from pydicom.tag import Tag, TagType from mrpro.data.KHeader import KHeader from mrpro.data.MoveDataMixin import MoveDataMixin @@ -46,7 +44,7 @@ class IHeader(MoveDataMixin): misc: dict = dataclasses.field(default_factory=dict) @classmethod - def from_kheader(cls, kheader: KHeader) -> IHeader: + def from_kheader(cls, kheader: KHeader) -> Self: """Create IHeader object from KHeader object. Parameters @@ -57,7 +55,7 @@ def from_kheader(cls, kheader: KHeader) -> IHeader: return cls(fov=kheader.recon_fov, te=kheader.te, ti=kheader.ti, fa=kheader.fa, tr=kheader.tr) @classmethod - def from_dicom_list(cls, dicom_datasets: Sequence[Dataset]) -> IHeader: + def from_dicom_list(cls, dicom_datasets: Sequence[Dataset]) -> Self: """Read DICOM files and return IHeader object. Parameters diff --git a/src/mrpro/data/KHeader.py b/src/mrpro/data/KHeader.py index 5d8115cb..0306b216 100644 --- a/src/mrpro/data/KHeader.py +++ b/src/mrpro/data/KHeader.py @@ -20,7 +20,7 @@ import datetime import warnings from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Self import ismrmrd.xsd.ismrmrdschema.ismrmrd as ismrmrdschema import torch @@ -95,7 +95,7 @@ def from_ismrmrd( defaults: dict | None = None, overwrite: dict | None = None, encoding_number: int = 0, - ) -> KHeader: + ) -> Self: """Create an Header from ISMRMRD Data. Parameters diff --git a/src/mrpro/data/KNoise.py b/src/mrpro/data/KNoise.py index 3a91f2cd..d77ecebf 100644 --- a/src/mrpro/data/KNoise.py +++ b/src/mrpro/data/KNoise.py @@ -14,11 +14,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import dataclasses from collections.abc import Callable from pathlib import Path +from typing import Self import ismrmrd import torch @@ -43,7 +42,7 @@ class KNoise(MoveDataMixin): @classmethod def from_file( cls, filename: str | Path, dataset_idx: int = -1, acquisition_filter_criterion: Callable = is_noise_acquisition - ) -> KNoise: + ) -> Self: """Load noise measurements from ISMRMRD file. Parameters diff --git a/src/mrpro/data/KTrajectory.py b/src/mrpro/data/KTrajectory.py index abf7e780..e0052639 100644 --- a/src/mrpro/data/KTrajectory.py +++ b/src/mrpro/data/KTrajectory.py @@ -14,9 +14,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - from dataclasses import dataclass +from typing import Self import numpy as np import torch @@ -85,7 +84,7 @@ def from_tensor( stack_dim: int = 0, repeat_detection_tolerance: float | None = 1e-8, grid_detection_tolerance: float = 1e-3, - ) -> KTrajectory: + ) -> Self: """Create a KTrajectory from a tensor representation of the trajectory. Reduces repeated dimensions to singletons if repeat_detection_tolerance diff --git a/src/mrpro/data/KTrajectoryRawShape.py b/src/mrpro/data/KTrajectoryRawShape.py index 419ef39d..64121fe1 100644 --- a/src/mrpro/data/KTrajectoryRawShape.py +++ b/src/mrpro/data/KTrajectoryRawShape.py @@ -14,8 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - from dataclasses import dataclass import numpy as np diff --git a/src/mrpro/data/MoveDataMixin.py b/src/mrpro/data/MoveDataMixin.py index 4aacf5a0..d714d71b 100644 --- a/src/mrpro/data/MoveDataMixin.py +++ b/src/mrpro/data/MoveDataMixin.py @@ -14,19 +14,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import dataclasses from collections.abc import Iterator from copy import copy as shallowcopy from copy import deepcopy -from typing import Any -from typing import ClassVar -from typing import Protocol -from typing import Self -from typing import TypeAlias -from typing import overload -from typing import runtime_checkable +from typing import Any, ClassVar, Protocol, Self, TypeAlias, overload, runtime_checkable import torch diff --git a/src/mrpro/data/QData.py b/src/mrpro/data/QData.py index 63ff4b48..4964f980 100644 --- a/src/mrpro/data/QData.py +++ b/src/mrpro/data/QData.py @@ -14,10 +14,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import dataclasses from pathlib import Path +from typing import Self import numpy as np import torch @@ -59,7 +58,7 @@ def __init__(self, data: torch.Tensor, header: KHeader | IHeader | QHeader) -> N object.__setattr__(self, 'header', qheader) @classmethod - def from_single_dicom(cls, filename: str | Path) -> QData: + def from_single_dicom(cls, filename: str | Path) -> Self: """Read single DICOM file and return QData object. Parameters diff --git a/src/mrpro/data/QHeader.py b/src/mrpro/data/QHeader.py index 8a4704c5..791f3f3f 100644 --- a/src/mrpro/data/QHeader.py +++ b/src/mrpro/data/QHeader.py @@ -14,9 +14,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - from dataclasses import dataclass +from typing import Self from pydicom.dataset import Dataset from pydicom.tag import Tag @@ -35,7 +34,7 @@ class QHeader(MoveDataMixin): fov: SpatialDimension[float] @classmethod - def from_iheader(cls, iheader: IHeader) -> QHeader: + def from_iheader(cls, iheader: IHeader) -> Self: """Create QHeader object from KHeader object. Parameters @@ -46,7 +45,7 @@ def from_iheader(cls, iheader: IHeader) -> QHeader: return cls(fov=iheader.fov) @classmethod - def from_kheader(cls, kheader: KHeader) -> QHeader: + def from_kheader(cls, kheader: KHeader) -> Self: """Create QHeader object from KHeader object. Parameters @@ -57,7 +56,7 @@ def from_kheader(cls, kheader: KHeader) -> QHeader: return cls(fov=kheader.recon_fov) @classmethod - def from_dicom(cls, dicom_dataset: Dataset) -> QHeader: + def from_dicom(cls, dicom_dataset: Dataset) -> Self: """Read DICOM file containing qMRI data and return QHeader object. Parameters diff --git a/src/mrpro/data/SpatialDimension.py b/src/mrpro/data/SpatialDimension.py index 3ae94fa9..cadf8e3a 100644 --- a/src/mrpro/data/SpatialDimension.py +++ b/src/mrpro/data/SpatialDimension.py @@ -18,9 +18,7 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import Generic -from typing import Protocol -from typing import TypeVar +from typing import Generic, Protocol, TypeVar import numpy as np import torch diff --git a/src/mrpro/data/TrajectoryDescription.py b/src/mrpro/data/TrajectoryDescription.py index bc4d92a3..527583b1 100644 --- a/src/mrpro/data/TrajectoryDescription.py +++ b/src/mrpro/data/TrajectoryDescription.py @@ -14,10 +14,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import dataclasses from dataclasses import dataclass +from typing import Self from ismrmrd.xsd.ismrmrdschema.ismrmrd import trajectoryDescriptionType @@ -33,7 +32,7 @@ class TrajectoryDescription: comment: str = '' @classmethod - def from_ismrmrd(cls, trajectory_description: trajectoryDescriptionType) -> TrajectoryDescription: + def from_ismrmrd(cls, trajectory_description: trajectoryDescriptionType) -> Self: """Create TrajectoryDescription from ismrmrd traj description.""" return cls( user_parameter_long={p.name: int(p.value) for p in trajectory_description.userParameterLong}, diff --git a/src/mrpro/data/__init__.py b/src/mrpro/data/__init__.py index 59a04984..7e19e278 100644 --- a/src/mrpro/data/__init__.py +++ b/src/mrpro/data/__init__.py @@ -1,13 +1,9 @@ -from mrpro.data import enums -from mrpro.data import traj_calculators -from mrpro.data import acq_filters -from mrpro.data.AcqInfo import AcqIdx -from mrpro.data.AcqInfo import AcqInfo +from mrpro.data import enums, traj_calculators, acq_filters +from mrpro.data.AcqInfo import AcqIdx, AcqInfo from mrpro.data.CsmData import CsmData from mrpro.data.Data import Data from mrpro.data.DcfData import DcfData -from mrpro.data.EncodingLimits import EncodingLimits -from mrpro.data.EncodingLimits import Limits +from mrpro.data.EncodingLimits import EncodingLimits, Limits from mrpro.data.IData import IData from mrpro.data.IHeader import IHeader from mrpro.data._kdata.KData import KData diff --git a/src/mrpro/data/_kdata/KData.py b/src/mrpro/data/_kdata/KData.py index e48973bc..f2913b73 100644 --- a/src/mrpro/data/_kdata/KData.py +++ b/src/mrpro/data/_kdata/KData.py @@ -14,14 +14,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import dataclasses import datetime import warnings from collections.abc import Callable from pathlib import Path -from typing import Protocol +from typing import Protocol, Self import h5py import ismrmrd @@ -64,7 +62,7 @@ def from_file( header_overwrites: dict[str, object] | None = None, dataset_idx: int = -1, acquisition_filter_criterion: Callable = is_image_acquisition, - ) -> KData: + ) -> Self: """Load k-space data from an ISMRMRD file. Parameters @@ -226,6 +224,4 @@ def traj(self) -> KTrajectory: ... def __init__(self, header: KHeader, data: torch.Tensor, traj: KTrajectory): ... - def _split_k2_or_k1_into_other( - self, split_idx: torch.Tensor, other_label: str, split_dir: str - ) -> _KDataProtocol: ... + def _split_k2_or_k1_into_other(self, split_idx: torch.Tensor, other_label: str, split_dir: str) -> Self: ... diff --git a/src/mrpro/data/_kdata/KDataProtocol.py b/src/mrpro/data/_kdata/KDataProtocol.py index 15c8cbc0..97836983 100644 --- a/src/mrpro/data/_kdata/KDataProtocol.py +++ b/src/mrpro/data/_kdata/KDataProtocol.py @@ -14,11 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - -from typing import Literal -from typing import Protocol -from typing import Self +from typing import Literal, Protocol, Self import torch from mrpro.data.KHeader import KHeader diff --git a/src/mrpro/data/_kdata/KDataRearrangeMixin.py b/src/mrpro/data/_kdata/KDataRearrangeMixin.py index c174f2b8..c10410ba 100644 --- a/src/mrpro/data/_kdata/KDataRearrangeMixin.py +++ b/src/mrpro/data/_kdata/KDataRearrangeMixin.py @@ -14,8 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import copy from typing import Self diff --git a/src/mrpro/data/_kdata/KDataRemoveOsMixin.py b/src/mrpro/data/_kdata/KDataRemoveOsMixin.py index d962099a..1d9e4645 100644 --- a/src/mrpro/data/_kdata/KDataRemoveOsMixin.py +++ b/src/mrpro/data/_kdata/KDataRemoveOsMixin.py @@ -12,8 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - from copy import deepcopy from typing import Self diff --git a/src/mrpro/data/_kdata/KDataSelectMixin.py b/src/mrpro/data/_kdata/KDataSelectMixin.py index ef08f78e..ed31a1af 100644 --- a/src/mrpro/data/_kdata/KDataSelectMixin.py +++ b/src/mrpro/data/_kdata/KDataSelectMixin.py @@ -14,11 +14,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import copy -from typing import Literal -from typing import Self +from typing import Literal, Self import torch from mrpro.data._kdata.KDataProtocol import _KDataProtocol diff --git a/src/mrpro/data/_kdata/KDataSplitMixin.py b/src/mrpro/data/_kdata/KDataSplitMixin.py index f0e89b6b..cfc050b9 100644 --- a/src/mrpro/data/_kdata/KDataSplitMixin.py +++ b/src/mrpro/data/_kdata/KDataSplitMixin.py @@ -14,15 +14,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import copy -from typing import Literal -from typing import Self +from typing import Literal, Self import torch -from einops import rearrange -from einops import repeat +from einops import rearrange, repeat from mrpro.data._kdata.KDataProtocol import _KDataProtocol from mrpro.data.EncodingLimits import Limits from mrpro.utils import modify_acq_info diff --git a/src/mrpro/data/enums.py b/src/mrpro/data/enums.py index bb63994d..dee04a2e 100644 --- a/src/mrpro/data/enums.py +++ b/src/mrpro/data/enums.py @@ -14,9 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from enum import Enum -from enum import Flag -from enum import auto +from enum import Enum, Flag, auto class AcqFlags(Flag): diff --git a/src/mrpro/data/traj_calculators/KTrajectoryCalculator.py b/src/mrpro/data/traj_calculators/KTrajectoryCalculator.py index 2ab9d496..775e27bc 100644 --- a/src/mrpro/data/traj_calculators/KTrajectoryCalculator.py +++ b/src/mrpro/data/traj_calculators/KTrajectoryCalculator.py @@ -14,10 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - -from abc import ABC -from abc import abstractmethod +from abc import ABC, abstractmethod import torch diff --git a/src/mrpro/data/traj_calculators/KTrajectoryIsmrmrd.py b/src/mrpro/data/traj_calculators/KTrajectoryIsmrmrd.py index c328a982..c79abf14 100644 --- a/src/mrpro/data/traj_calculators/KTrajectoryIsmrmrd.py +++ b/src/mrpro/data/traj_calculators/KTrajectoryIsmrmrd.py @@ -14,8 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - from collections.abc import Sequence import ismrmrd diff --git a/src/mrpro/data/traj_calculators/KTrajectoryPulseq.py b/src/mrpro/data/traj_calculators/KTrajectoryPulseq.py index 3b78b416..e3b892cf 100644 --- a/src/mrpro/data/traj_calculators/KTrajectoryPulseq.py +++ b/src/mrpro/data/traj_calculators/KTrajectoryPulseq.py @@ -14,8 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - from pathlib import Path import pypulseq as pp diff --git a/src/mrpro/data/traj_calculators/KTrajectoryRpe.py b/src/mrpro/data/traj_calculators/KTrajectoryRpe.py index 179ec92c..9cd4b404 100644 --- a/src/mrpro/data/traj_calculators/KTrajectoryRpe.py +++ b/src/mrpro/data/traj_calculators/KTrajectoryRpe.py @@ -14,8 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import torch from mrpro.data.KHeader import KHeader diff --git a/src/mrpro/data/traj_calculators/KTrajectorySunflowerGoldenRpe.py b/src/mrpro/data/traj_calculators/KTrajectorySunflowerGoldenRpe.py index a242385d..a966bfdd 100644 --- a/src/mrpro/data/traj_calculators/KTrajectorySunflowerGoldenRpe.py +++ b/src/mrpro/data/traj_calculators/KTrajectorySunflowerGoldenRpe.py @@ -14,8 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import numpy as np import torch diff --git a/src/mrpro/operators/ConstraintsOp.py b/src/mrpro/operators/ConstraintsOp.py index fa5f266d..f1e13fb7 100644 --- a/src/mrpro/operators/ConstraintsOp.py +++ b/src/mrpro/operators/ConstraintsOp.py @@ -19,8 +19,7 @@ import torch import torch.nn.functional as F # noqa: N812 -from mrpro.operators.EndomorphOperator import EndomorphOperator -from mrpro.operators.EndomorphOperator import endomorph +from mrpro.operators.EndomorphOperator import EndomorphOperator, endomorph class ConstraintsOp(EndomorphOperator): diff --git a/src/mrpro/operators/DensityCompensationOp.py b/src/mrpro/operators/DensityCompensationOp.py index ee681dab..f0b2939a 100644 --- a/src/mrpro/operators/DensityCompensationOp.py +++ b/src/mrpro/operators/DensityCompensationOp.py @@ -14,8 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import torch from mrpro.data.DcfData import DcfData diff --git a/src/mrpro/operators/EndomorphOperator.py b/src/mrpro/operators/EndomorphOperator.py index 8d8e9b27..6a66aa50 100644 --- a/src/mrpro/operators/EndomorphOperator.py +++ b/src/mrpro/operators/EndomorphOperator.py @@ -14,17 +14,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - from abc import abstractmethod from collections.abc import Callable -from typing import ParamSpec -from typing import Protocol -from typing import TypeAlias -from typing import TypeVar -from typing import TypeVarTuple -from typing import cast -from typing import overload +from typing import ParamSpec, Protocol, TypeAlias, TypeVar, TypeVarTuple, cast, overload import torch diff --git a/src/mrpro/operators/FourierOp.py b/src/mrpro/operators/FourierOp.py index e999e633..8bc403b6 100644 --- a/src/mrpro/operators/FourierOp.py +++ b/src/mrpro/operators/FourierOp.py @@ -14,14 +14,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - from collections.abc import Sequence +from typing import Self import numpy as np import torch -from torchkbnufft import KbNufft -from torchkbnufft import KbNufftAdjoint +from torchkbnufft import KbNufft, KbNufftAdjoint from mrpro.data._kdata.KData import KData from mrpro.data.enums import TrajType @@ -134,7 +132,7 @@ def get_traj(traj: KTrajectory, dims: Sequence[int]): self._kshape = traj.broadcasted_shape @classmethod - def from_kdata(cls, kdata: KData, recon_shape: SpatialDimension[int] | None = None) -> FourierOp: + def from_kdata(cls, kdata: KData, recon_shape: SpatialDimension[int] | None = None) -> Self: """Create an instance of FourierOp from kdata with default settings. Parameters diff --git a/src/mrpro/operators/GridSamplingOp.py b/src/mrpro/operators/GridSamplingOp.py index 563dd9fd..51937957 100644 --- a/src/mrpro/operators/GridSamplingOp.py +++ b/src/mrpro/operators/GridSamplingOp.py @@ -15,8 +15,7 @@ # limitations under the License. import warnings -from collections.abc import Callable -from collections.abc import Sequence +from collections.abc import Callable, Sequence from typing import Literal import torch diff --git a/src/mrpro/operators/LinearOperator.py b/src/mrpro/operators/LinearOperator.py index cf160d4b..d8ae3e9d 100644 --- a/src/mrpro/operators/LinearOperator.py +++ b/src/mrpro/operators/LinearOperator.py @@ -17,18 +17,19 @@ from __future__ import annotations from abc import abstractmethod -from collections.abc import Callable -from collections.abc import Sequence +from collections.abc import Callable, Sequence from typing import overload import torch -from mrpro.operators.Operator import Operator -from mrpro.operators.Operator import OperatorComposition -from mrpro.operators.Operator import OperatorElementwiseProductLeft -from mrpro.operators.Operator import OperatorElementwiseProductRight -from mrpro.operators.Operator import OperatorSum -from mrpro.operators.Operator import Tin2 +from mrpro.operators.Operator import ( + Operator, + OperatorComposition, + OperatorElementwiseProductLeft, + OperatorElementwiseProductRight, + OperatorSum, + Tin2, +) class LinearOperator(Operator[torch.Tensor, tuple[torch.Tensor]]): diff --git a/src/mrpro/operators/MagnitudeOp.py b/src/mrpro/operators/MagnitudeOp.py index dac0638a..eb8fb4d9 100644 --- a/src/mrpro/operators/MagnitudeOp.py +++ b/src/mrpro/operators/MagnitudeOp.py @@ -16,8 +16,7 @@ import torch -from mrpro.operators.EndomorphOperator import EndomorphOperator -from mrpro.operators.EndomorphOperator import endomorph +from mrpro.operators.EndomorphOperator import EndomorphOperator, endomorph class MagnitudeOp(EndomorphOperator): diff --git a/src/mrpro/operators/Operator.py b/src/mrpro/operators/Operator.py index 85741b7c..2655d545 100644 --- a/src/mrpro/operators/Operator.py +++ b/src/mrpro/operators/Operator.py @@ -16,12 +16,8 @@ from __future__ import annotations -from abc import ABC -from abc import abstractmethod -from typing import Generic -from typing import TypeVar -from typing import TypeVarTuple -from typing import cast +from abc import ABC, abstractmethod +from typing import Generic, TypeVar, TypeVarTuple, cast import torch diff --git a/src/mrpro/operators/PhaseOp.py b/src/mrpro/operators/PhaseOp.py index c2e50b04..9527cc2c 100644 --- a/src/mrpro/operators/PhaseOp.py +++ b/src/mrpro/operators/PhaseOp.py @@ -16,8 +16,7 @@ import torch -from mrpro.operators.EndomorphOperator import EndomorphOperator -from mrpro.operators.EndomorphOperator import endomorph +from mrpro.operators.EndomorphOperator import EndomorphOperator, endomorph class PhaseOp(EndomorphOperator): diff --git a/src/mrpro/operators/SensitivityOp.py b/src/mrpro/operators/SensitivityOp.py index 4d735c0a..d4e75a93 100644 --- a/src/mrpro/operators/SensitivityOp.py +++ b/src/mrpro/operators/SensitivityOp.py @@ -14,8 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import torch from mrpro.data.CsmData import CsmData diff --git a/src/mrpro/operators/SignalModel.py b/src/mrpro/operators/SignalModel.py index 366749d5..ab474428 100644 --- a/src/mrpro/operators/SignalModel.py +++ b/src/mrpro/operators/SignalModel.py @@ -14,8 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - from typing import TypeVarTuple import torch diff --git a/src/mrpro/operators/SliceProjectionOp.py b/src/mrpro/operators/SliceProjectionOp.py index ae0e1faa..f1fc1f48 100644 --- a/src/mrpro/operators/SliceProjectionOp.py +++ b/src/mrpro/operators/SliceProjectionOp.py @@ -16,10 +16,8 @@ import itertools import warnings -from collections.abc import Callable -from collections.abc import Sequence -from typing import Literal -from typing import TypeAlias +from collections.abc import Callable, Sequence +from typing import Literal, TypeAlias import einops import numpy as np diff --git a/src/mrpro/operators/__init__.py b/src/mrpro/operators/__init__.py index 4d7b5e99..099e4c72 100644 --- a/src/mrpro/operators/__init__.py +++ b/src/mrpro/operators/__init__.py @@ -1,8 +1,7 @@ from mrpro.operators.Operator import Operator from mrpro.operators.LinearOperator import LinearOperator -from mrpro.operators import functionals -from mrpro.operators import models +from mrpro.operators import functionals, models from mrpro.operators.CartesianSamplingOp import CartesianSamplingOp from mrpro.operators.ConstraintsOp import ConstraintsOp from mrpro.operators.DensityCompensationOp import DensityCompensationOp diff --git a/src/mrpro/utils/Rotation.py b/src/mrpro/utils/Rotation.py index 89cbda3c..e343109e 100644 --- a/src/mrpro/utils/Rotation.py +++ b/src/mrpro/utils/Rotation.py @@ -45,10 +45,7 @@ import re import warnings from collections.abc import Sequence -from typing import TYPE_CHECKING -from typing import Literal -from typing import Self -from typing import overload +from typing import TYPE_CHECKING, Literal, Self, overload import numpy as np import torch @@ -59,9 +56,7 @@ if TYPE_CHECKING: from types import EllipsisType - from typing import TYPE_CHECKING - from typing import SupportsIndex - from typing import TypeAlias + from typing import TYPE_CHECKING, SupportsIndex, TypeAlias from torch._C import _NestedSequence diff --git a/src/mrpro/utils/filters.py b/src/mrpro/utils/filters.py index c9bd1073..8e45e6bf 100644 --- a/src/mrpro/utils/filters.py +++ b/src/mrpro/utils/filters.py @@ -14,8 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import warnings from collections.abc import Sequence from math import ceil diff --git a/src/mrpro/utils/sliding_window.py b/src/mrpro/utils/sliding_window.py index 055ad899..fa6650b0 100644 --- a/src/mrpro/utils/sliding_window.py +++ b/src/mrpro/utils/sliding_window.py @@ -12,8 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import warnings from collections.abc import Sequence diff --git a/src/mrpro/utils/smap.py b/src/mrpro/utils/smap.py index 4a5a6e29..a2f22a53 100644 --- a/src/mrpro/utils/smap.py +++ b/src/mrpro/utils/smap.py @@ -14,8 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from collections.abc import Callable -from collections.abc import Sequence +from collections.abc import Callable, Sequence import torch diff --git a/tests/algorithms/test_optimizers.py b/tests/algorithms/test_optimizers.py index d674d8f3..5e5e7a36 100644 --- a/tests/algorithms/test_optimizers.py +++ b/tests/algorithms/test_optimizers.py @@ -14,8 +14,7 @@ import pytest import torch -from mrpro.algorithms.optimizers import adam -from mrpro.algorithms.optimizers import lbfgs +from mrpro.algorithms.optimizers import adam, lbfgs from mrpro.operators import ConstraintsOp from tests.operators._OptimizationTestFunctions import Rosenbrock diff --git a/tests/algorithms/test_prewhiten_kspace.py b/tests/algorithms/test_prewhiten_kspace.py index 93be3ed6..20842daa 100644 --- a/tests/algorithms/test_prewhiten_kspace.py +++ b/tests/algorithms/test_prewhiten_kspace.py @@ -16,9 +16,7 @@ import torch from einops import rearrange from mrpro.algorithms.prewhiten_kspace import prewhiten_kspace -from mrpro.data import KData -from mrpro.data import KNoise -from mrpro.data import KTrajectory +from mrpro.data import KData, KNoise, KTrajectory from tests import RandomGenerator diff --git a/tests/conftest.py b/tests/conftest.py index 83333bf3..7d55b830 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,11 +20,9 @@ import pytest import torch from ismrmrd import xsd -from mrpro.data import AcqInfo -from mrpro.data import KHeader +from mrpro.data import AcqInfo, KHeader from mrpro.data.enums import AcqFlags -from xsdata.models.datatype import XmlDate -from xsdata.models.datatype import XmlTime +from xsdata.models.datatype import XmlDate, XmlTime from tests import RandomGenerator from tests.data import Dicom2DTestImage diff --git a/tests/data/_IsmrmrdRawTestData.py b/tests/data/_IsmrmrdRawTestData.py index 90083b28..d6d29df8 100644 --- a/tests/data/_IsmrmrdRawTestData.py +++ b/tests/data/_IsmrmrdRawTestData.py @@ -12,8 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - from pathlib import Path from typing import Literal diff --git a/tests/data/test_csm_data.py b/tests/data/test_csm_data.py index 5300f239..83270fd5 100644 --- a/tests/data/test_csm_data.py +++ b/tests/data/test_csm_data.py @@ -16,9 +16,7 @@ import pytest import torch -from mrpro.data import CsmData -from mrpro.data import IData -from mrpro.data import SpatialDimension +from mrpro.data import CsmData, IData, SpatialDimension from mrpro.phantoms.coils import birdcage_2d from tests.helper import relative_image_difference diff --git a/tests/data/test_dcf_data.py b/tests/data/test_dcf_data.py index 81926d72..1debf978 100644 --- a/tests/data/test_dcf_data.py +++ b/tests/data/test_dcf_data.py @@ -16,8 +16,7 @@ import pytest import torch -from mrpro.data import DcfData -from mrpro.data import KTrajectory +from mrpro.data import DcfData, KTrajectory def example_traj_rpe(n_kr, n_ka, n_k0, broadcast=True): diff --git a/tests/data/test_kdata.py b/tests/data/test_kdata.py index a5f3affb..ade31138 100644 --- a/tests/data/test_kdata.py +++ b/tests/data/test_kdata.py @@ -14,19 +14,14 @@ import pytest import torch -from einops import rearrange -from einops import repeat -from mrpro.data import KData -from mrpro.data import KTrajectory -from mrpro.data import SpatialDimension +from einops import rearrange, repeat +from mrpro.data import KData, KTrajectory, SpatialDimension from mrpro.data.acq_filters import is_coil_calibration_acquisition from mrpro.data.traj_calculators.KTrajectoryCalculator import DummyTrajectory from mrpro.operators import FastFourierOp -from mrpro.utils import modify_acq_info -from mrpro.utils import split_idx +from mrpro.utils import modify_acq_info, split_idx -from tests.conftest import RandomGenerator -from tests.conftest import generate_random_data +from tests.conftest import RandomGenerator, generate_random_data from tests.data import IsmrmrdRawTestData from tests.helper import relative_image_difference from tests.phantoms._EllipsePhantomTestData import EllipsePhantomTestData diff --git a/tests/data/test_ktraj_raw_shape.py b/tests/data/test_ktraj_raw_shape.py index e2df2d5e..55a3bde7 100644 --- a/tests/data/test_ktraj_raw_shape.py +++ b/tests/data/test_ktraj_raw_shape.py @@ -14,8 +14,7 @@ import numpy as np import torch -from einops import rearrange -from einops import repeat +from einops import rearrange, repeat from mrpro.data import KTrajectoryRawShape diff --git a/tests/data/test_movedatamixin.py b/tests/data/test_movedatamixin.py index 118f0857..c6fda5fb 100644 --- a/tests/data/test_movedatamixin.py +++ b/tests/data/test_movedatamixin.py @@ -12,8 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from dataclasses import dataclass -from dataclasses import field +from dataclasses import dataclass, field from typing import Any import pytest diff --git a/tests/data/test_qdata.py b/tests/data/test_qdata.py index 508feec4..108983c5 100644 --- a/tests/data/test_qdata.py +++ b/tests/data/test_qdata.py @@ -14,8 +14,7 @@ import pytest import torch -from mrpro.data import IHeader -from mrpro.data import QData +from mrpro.data import IHeader, QData def test_QData_from_kheader_and_tensor(random_kheader, random_test_data): diff --git a/tests/data/test_traj_calculators.py b/tests/data/test_traj_calculators.py index 80d32403..6e37cb12 100644 --- a/tests/data/test_traj_calculators.py +++ b/tests/data/test_traj_calculators.py @@ -18,12 +18,14 @@ from einops import repeat from mrpro.data import KData from mrpro.data.enums import AcqFlags -from mrpro.data.traj_calculators import KTrajectoryCartesian -from mrpro.data.traj_calculators import KTrajectoryIsmrmrd -from mrpro.data.traj_calculators import KTrajectoryPulseq -from mrpro.data.traj_calculators import KTrajectoryRadial2D -from mrpro.data.traj_calculators import KTrajectoryRpe -from mrpro.data.traj_calculators import KTrajectorySunflowerGoldenRpe +from mrpro.data.traj_calculators import ( + KTrajectoryCartesian, + KTrajectoryIsmrmrd, + KTrajectoryPulseq, + KTrajectoryRadial2D, + KTrajectoryRpe, + KTrajectorySunflowerGoldenRpe, +) from tests.data import IsmrmrdRawTestData from tests.data._PulseqRadialTestSeq import PulseqRadialTestSeq diff --git a/tests/operators/models/test_inversion_recovery.py b/tests/operators/models/test_inversion_recovery.py index 0397855d..b71fe76c 100644 --- a/tests/operators/models/test_inversion_recovery.py +++ b/tests/operators/models/test_inversion_recovery.py @@ -17,8 +17,7 @@ import pytest import torch from mrpro.operators.models import InversionRecovery -from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS -from tests.conftest import create_parameter_tensor_tuples +from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples @pytest.mark.parametrize( diff --git a/tests/operators/models/test_molli.py b/tests/operators/models/test_molli.py index f875d638..e5bf2d71 100644 --- a/tests/operators/models/test_molli.py +++ b/tests/operators/models/test_molli.py @@ -17,8 +17,7 @@ import pytest import torch from mrpro.operators.models import MOLLI -from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS -from tests.conftest import create_parameter_tensor_tuples +from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples @pytest.mark.parametrize( diff --git a/tests/operators/models/test_mono_exponential_decay.py b/tests/operators/models/test_mono_exponential_decay.py index 98048d26..a639bd38 100644 --- a/tests/operators/models/test_mono_exponential_decay.py +++ b/tests/operators/models/test_mono_exponential_decay.py @@ -17,8 +17,7 @@ import pytest import torch from mrpro.operators.models import MonoExponentialDecay -from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS -from tests.conftest import create_parameter_tensor_tuples +from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples @pytest.mark.parametrize( diff --git a/tests/operators/models/test_saturation_recovery.py b/tests/operators/models/test_saturation_recovery.py index 68cdecbe..392dfb29 100644 --- a/tests/operators/models/test_saturation_recovery.py +++ b/tests/operators/models/test_saturation_recovery.py @@ -17,8 +17,7 @@ import pytest import torch from mrpro.operators.models import SaturationRecovery -from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS -from tests.conftest import create_parameter_tensor_tuples +from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples @pytest.mark.parametrize( diff --git a/tests/operators/models/test_transient_steady_state_with_preparation.py b/tests/operators/models/test_transient_steady_state_with_preparation.py index 51ad8a52..1b6babe8 100644 --- a/tests/operators/models/test_transient_steady_state_with_preparation.py +++ b/tests/operators/models/test_transient_steady_state_with_preparation.py @@ -17,8 +17,7 @@ import pytest import torch from mrpro.operators.models import TransientSteadyStateWithPreparation -from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS -from tests.conftest import create_parameter_tensor_tuples +from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples @pytest.mark.parametrize( diff --git a/tests/operators/models/test_wasabi.py b/tests/operators/models/test_wasabi.py index e205e1d2..a3d6a9fb 100644 --- a/tests/operators/models/test_wasabi.py +++ b/tests/operators/models/test_wasabi.py @@ -16,8 +16,7 @@ import torch from mrpro.operators.models import WASABI -from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS -from tests.conftest import create_parameter_tensor_tuples +from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples def create_data(offset_max=500, n_offsets=101, b0_shift=0, rb1=1.0, c=1.0, d=2.0): diff --git a/tests/operators/models/test_wasabiti.py b/tests/operators/models/test_wasabiti.py index b0ceeb38..cffdcf61 100644 --- a/tests/operators/models/test_wasabiti.py +++ b/tests/operators/models/test_wasabiti.py @@ -17,8 +17,7 @@ import pytest import torch from mrpro.operators.models import WASABITI -from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS -from tests.conftest import create_parameter_tensor_tuples +from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples def create_data(offset_max=500, n_offsets=101, b0_shift=0, rb1=1.0, t1=1.0): diff --git a/tests/operators/test_cartesian_sampling_op.py b/tests/operators/test_cartesian_sampling_op.py index 73a6e962..6a8fc699 100644 --- a/tests/operators/test_cartesian_sampling_op.py +++ b/tests/operators/test_cartesian_sampling_op.py @@ -14,8 +14,7 @@ import pytest import torch -from mrpro.data import KTrajectory -from mrpro.data import SpatialDimension +from mrpro.data import KTrajectory, SpatialDimension from mrpro.operators import CartesianSamplingOp from tests import RandomGenerator diff --git a/tests/operators/test_operator_norm.py b/tests/operators/test_operator_norm.py index 8d79feeb..bbf2668b 100644 --- a/tests/operators/test_operator_norm.py +++ b/tests/operators/test_operator_norm.py @@ -11,14 +11,11 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from math import prod -from math import sqrt +from math import prod, sqrt import pytest import torch -from mrpro.operators import EinsumOp -from mrpro.operators import FastFourierOp -from mrpro.operators import FiniteDifferenceOp +from mrpro.operators import EinsumOp, FastFourierOp, FiniteDifferenceOp from tests import RandomGenerator diff --git a/tests/operators/test_operators.py b/tests/operators/test_operators.py index ef4682e0..8984a2ae 100644 --- a/tests/operators/test_operators.py +++ b/tests/operators/test_operators.py @@ -4,8 +4,7 @@ import pytest import torch -from mrpro.operators import LinearOperator -from mrpro.operators import Operator +from mrpro.operators import LinearOperator, Operator from tests import RandomGenerator from tests.helper import dotproduct_adjointness_test diff --git a/tests/operators/test_sensitivity_op.py b/tests/operators/test_sensitivity_op.py index 379d645f..ad187580 100644 --- a/tests/operators/test_sensitivity_op.py +++ b/tests/operators/test_sensitivity_op.py @@ -14,9 +14,7 @@ import pytest import torch -from mrpro.data import CsmData -from mrpro.data import QHeader -from mrpro.data import SpatialDimension +from mrpro.data import CsmData, QHeader, SpatialDimension from mrpro.operators import SensitivityOp from tests import RandomGenerator diff --git a/tests/operators/test_slice_projection_op.py b/tests/operators/test_slice_projection_op.py index a1285274..e286579a 100644 --- a/tests/operators/test_slice_projection_op.py +++ b/tests/operators/test_slice_projection_op.py @@ -19,9 +19,7 @@ from mrpro.data import SpatialDimension from mrpro.operators import SliceProjectionOp from mrpro.utils import Rotation -from mrpro.utils.slice_profiles import SliceGaussian -from mrpro.utils.slice_profiles import SliceInterpolate -from mrpro.utils.slice_profiles import SliceSmoothedRectangular +from mrpro.utils.slice_profiles import SliceGaussian, SliceInterpolate, SliceSmoothedRectangular from tests import RandomGenerator from tests.helper import dotproduct_adjointness_test diff --git a/tests/phantoms/_EllipsePhantomTestData.py b/tests/phantoms/_EllipsePhantomTestData.py index 6bad085b..1a6dec6b 100644 --- a/tests/phantoms/_EllipsePhantomTestData.py +++ b/tests/phantoms/_EllipsePhantomTestData.py @@ -13,8 +13,7 @@ # limitations under the License. import torch -from mrpro.phantoms import EllipseParameters -from mrpro.phantoms import EllipsePhantom +from mrpro.phantoms import EllipseParameters, EllipsePhantom class EllipsePhantomTestData: diff --git a/tests/utils/test_filters.py b/tests/utils/test_filters.py index 020905f6..5d890c1f 100644 --- a/tests/utils/test_filters.py +++ b/tests/utils/test_filters.py @@ -14,9 +14,7 @@ import pytest import torch -from mrpro.utils.filters import filter_separable -from mrpro.utils.filters import gaussian_filter -from mrpro.utils.filters import uniform_filter +from mrpro.utils.filters import filter_separable, gaussian_filter, uniform_filter @pytest.fixture() From bee304a69f84b6d2f48d7df38c812a6691b8c211 Mon Sep 17 00:00:00 2001 From: Pierrick Bouilloux <148056480+Pierrickkk@users.noreply.github.com> Date: Wed, 10 Jul 2024 11:13:44 +0200 Subject: [PATCH 03/34] Remove duplicate KDataProtocol (#356) Co-authored-by: Patrick Schuenke <37338697+schuenke@users.noreply.github.com> --- src/mrpro/data/_kdata/KData.py | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/src/mrpro/data/_kdata/KData.py b/src/mrpro/data/_kdata/KData.py index f2913b73..be4dcce1 100644 --- a/src/mrpro/data/_kdata/KData.py +++ b/src/mrpro/data/_kdata/KData.py @@ -19,7 +19,7 @@ import warnings from collections.abc import Callable from pathlib import Path -from typing import Protocol, Self +from typing import Self import h5py import ismrmrd @@ -199,29 +199,3 @@ def sort_and_reshape_tensor_fields(input_tensor: torch.Tensor): ) from None return cls(kheader, kdata, ktrajectory_final) - - -class _KDataProtocol(Protocol): - """Protocol for KData used for type hinting in KData mixins. - - Note that the actual KData class can have more properties and methods than those defined here. - - If you want to use a property or method of KData in a new KDataMixin class, - you must add it to this Protocol to make sure that the type hinting works. - - For more information about Protocols see: - https://typing.readthedocs.io/en/latest/spec/protocol.html#protocols - """ - - @property - def header(self) -> KHeader: ... - - @property - def data(self) -> torch.Tensor: ... - - @property - def traj(self) -> KTrajectory: ... - - def __init__(self, header: KHeader, data: torch.Tensor, traj: KTrajectory): ... - - def _split_k2_or_k1_into_other(self, split_idx: torch.Tensor, other_label: str, split_dir: str) -> Self: ... From f27c1c8f3dcd20feddb702f0447f4cafae905ecd Mon Sep 17 00:00:00 2001 From: Patrick Schuenke <37338697+schuenke@users.noreply.github.com> Date: Wed, 10 Jul 2024 11:26:18 +0200 Subject: [PATCH 04/34] Manage dependencies (#348) - remove unused dependency category "lint" - remove upper boundaries of pip dependencies if possible - change pypulseq dependency from GitHub to PyPi - add dependabot version updates for gh actions --- .github/dependabot.yml | 10 ++++++++++ pyproject.toml | 9 ++++----- 2 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..3c4ad8ce --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + groups: + all-actions: + patterns: [ "*" ] diff --git a/pyproject.toml b/pyproject.toml index 41a48708..8549707c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,12 +28,12 @@ classifiers = [ "Programming Language :: Python :: 3 :: Only", ] dependencies = [ - "numpy>=1.23,<2.0", - "torch>=2.3,<3.0", - "ismrmrd>=1.14.1,<2.0", + "numpy>=1.23, <2.0", + "torch>=2.3", + "ismrmrd>=1.14.1", "einops", "pydicom", - "pypulseq@git+https://github.com/imr-framework/pypulseq", + "pypulseq>=1.4.2", "torchkbnufft>=1.4.0", "scipy>=1.12", ] @@ -47,7 +47,6 @@ test = [ "pytest-cov", "pytest-xdist", ] -lint = ["mypy", "flake8", "isort", "pre-commit", "autopep8", "pydocstyle"] docs = ["sphinx", "sphinx_rtd_theme", "sphinx-pyproject"] notebook = [ "zenodo_get", From c7fb050dc9c15c0da4652be8fe94ec7cd87b12b9 Mon Sep 17 00:00:00 2001 From: Pierrick Bouilloux <148056480+Pierrickkk@users.noreply.github.com> Date: Wed, 10 Jul 2024 11:37:06 +0200 Subject: [PATCH 05/34] Consider user indices when sorting kdata (#354) We ignore user indexes 5 and 6. The ismrmrd converter abuses these to store other information. We now print a warning if these are used and use all other user idx for sorting. closes #32 Co-authored-by: Felix F Zimmermann --- src/mrpro/data/_kdata/KData.py | 50 ++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/src/mrpro/data/_kdata/KData.py b/src/mrpro/data/_kdata/KData.py index be4dcce1..04690c1e 100644 --- a/src/mrpro/data/_kdata/KData.py +++ b/src/mrpro/data/_kdata/KData.py @@ -41,9 +41,37 @@ from mrpro.data.traj_calculators.KTrajectoryIsmrmrd import KTrajectoryIsmrmrd from mrpro.utils import modify_acq_info -KDIM_SORT_LABELS = ('k1', 'k2', 'average', 'slice', 'contrast', 'phase', 'repetition', 'set') -# TODO: Consider adding the users labels here, but remember issue #32 and NOT add user5 and user6. -OTHER_LABELS = ('average', 'slice', 'contrast', 'phase', 'repetition', 'set') +KDIM_SORT_LABELS = ( + 'k1', + 'k2', + 'average', + 'slice', + 'contrast', + 'phase', + 'repetition', + 'set', + 'user0', + 'user1', + 'user2', + 'user3', + 'user4', + 'user7', +) + +OTHER_LABELS = ( + 'average', + 'slice', + 'contrast', + 'phase', + 'repetition', + 'set', + 'user0', + 'user1', + 'user2', + 'user3', + 'user4', + 'user7', +) @dataclasses.dataclass(slots=True, frozen=True) @@ -94,6 +122,22 @@ def from_file( acqinfo = AcqInfo.from_ismrmrd_acquisitions(acquisitions) + if len(torch.unique(acqinfo.idx.user5)) > 1: + warnings.warn( + 'The Siemens to ismrmrd converter currently (ab)uses ' + 'the user 5 indices for storing the kspace center line number.\n' + 'User 5 indices will be ignored', + stacklevel=1, + ) + + if len(torch.unique(acqinfo.idx.user6)) > 1: + warnings.warn( + 'The Siemens to ismrmrd converter currently (ab)uses ' + 'the user 6 indices for storing the kspace center partition number.\n' + 'User 6 indices will be ignored', + stacklevel=1, + ) + # Raises ValueError if required fields are missing in the header kheader = KHeader.from_ismrmrd( ismrmrd_header, From 1e466179db3034895489019d6f813b08b17ac9c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jul 2024 12:41:26 +0200 Subject: [PATCH 06/34] Bump the all-actions group with 3 updates (#358) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Patrick Schuenke <37338697+schuenke@users.noreply.github.com> --- .github/workflows/docs.yml | 4 ++-- .github/workflows/pytest.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5cbb1d76..b06e925b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -44,7 +44,7 @@ jobs: done - name: Check if any notebooks have been changed - uses: tj-actions/verify-changed-files@v19 + uses: tj-actions/verify-changed-files@v20 id: verify-changed-notebooks with: files: ./examples/*.ipynb @@ -184,7 +184,7 @@ jobs: run: sphinx-build -b html ./docs/source ./docs/build/html - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v3 + uses: peaceiris/actions-gh-pages@v4 with: publish_branch: github-pages github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index c0e614c7..9edc7fef 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -81,7 +81,7 @@ jobs: - name: Pytest coverage comment id: coverageComment - uses: MishaKav/pytest-coverage-comment@v1.1.51 + uses: MishaKav/pytest-coverage-comment@v1.1.52 with: pytest-coverage-path: ./pytest-coverage.txt junitxml-path: ./pytest.xml From 1477c153f8c98dd50b3a0ef05de1af5c28a1994d Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Wed, 10 Jul 2024 12:53:08 +0200 Subject: [PATCH 07/34] Separate test for iterative_walsh algo (#350) --- tests/algorithms/csm/test_iterative_walsh.py | 49 ++++++++++++++++++++ tests/data/test_csm_data.py | 31 +------------ 2 files changed, 51 insertions(+), 29 deletions(-) create mode 100644 tests/algorithms/csm/test_iterative_walsh.py diff --git a/tests/algorithms/csm/test_iterative_walsh.py b/tests/algorithms/csm/test_iterative_walsh.py new file mode 100644 index 00000000..7078334e --- /dev/null +++ b/tests/algorithms/csm/test_iterative_walsh.py @@ -0,0 +1,49 @@ +"""Tests the iterative Walsh algorithm.""" + +# Copyright 2023 Physikalisch-Technische Bundesanstalt +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import torch +from mrpro.algorithms.csm import iterative_walsh +from mrpro.data import IData, SpatialDimension +from mrpro.phantoms.coils import birdcage_2d +from tests.helper import relative_image_difference + + +def multi_coil_image(n_coils, ph_ellipse, random_kheader): + """Create multi-coil image.""" + image_dimensions = SpatialDimension(z=1, y=ph_ellipse.n_y, x=ph_ellipse.n_x) + + # Create reference coil sensitivities + csm_ref = birdcage_2d(n_coils, image_dimensions) + + # Create multi-coil phantom image data + img = ph_ellipse.phantom.image_space(image_dimensions) + # +1 to ensure that there is signal everywhere, for voxel == 0 csm cannot be determined. + img_multi_coil = (img + 1) * csm_ref + idata = IData.from_tensor_and_kheader(data=img_multi_coil, kheader=random_kheader) + return (idata, csm_ref) + + +def test_iterative_Walsh(ellipse_phantom, random_kheader): + """Test the iterative Walsh method.""" + idata, csm_ref = multi_coil_image(n_coils=4, ph_ellipse=ellipse_phantom, random_kheader=random_kheader) + + # Estimate coil sensitivity maps. + # iterative_walsh should be applied for each other dimension separately + smoothing_width = SpatialDimension(z=1, y=5, x=5) + csm = iterative_walsh(idata.data[0, ...], smoothing_width, power_iterations=3) + + # Phase is only relative in csm calculation, therefore only the abs values are compared. + assert relative_image_difference(torch.abs(csm), torch.abs(csm_ref[0, ...])) <= 0.01 diff --git a/tests/data/test_csm_data.py b/tests/data/test_csm_data.py index 83270fd5..87f6f426 100644 --- a/tests/data/test_csm_data.py +++ b/tests/data/test_csm_data.py @@ -16,27 +16,12 @@ import pytest import torch -from mrpro.data import CsmData, IData, SpatialDimension -from mrpro.phantoms.coils import birdcage_2d +from mrpro.data import CsmData, SpatialDimension +from tests.algorithms.csm.test_iterative_walsh import multi_coil_image from tests.helper import relative_image_difference -def multi_coil_image(n_coils, ph_ellipse, random_kheader): - """Create multi-coil image.""" - image_dimensions = SpatialDimension(z=1, y=ph_ellipse.n_y, x=ph_ellipse.n_x) - - # Create reference coil sensitivities - csm_ref = birdcage_2d(n_coils, image_dimensions) - - # Create multi-coil phantom image data - img = ph_ellipse.phantom.image_space(image_dimensions) - # +1 to ensure that there is signal everywhere, for voxel == 0 csm cannot be determined. - img_multi_coil = (img + 1) * csm_ref - idata = IData.from_tensor_and_kheader(data=img_multi_coil, kheader=random_kheader) - return (idata, csm_ref) - - def test_CsmData_is_frozen_dataclass(random_test_data, random_kheader): """CsmData inherits frozen dataclass property from QData.""" csm = CsmData(data=random_test_data, header=random_kheader) @@ -44,18 +29,6 @@ def test_CsmData_is_frozen_dataclass(random_test_data, random_kheader): csm.data = random_test_data # type: ignore[misc] -def test_CsmData_iterative_Walsh(ellipse_phantom, random_kheader): - """CsmData obtained with the iterative Walsh method.""" - idata, csm_ref = multi_coil_image(n_coils=4, ph_ellipse=ellipse_phantom, random_kheader=random_kheader) - - # Estimate coil sensitivity maps - smoothing_width = SpatialDimension(z=1, y=5, x=5) - csm = CsmData.from_idata_walsh(idata, smoothing_width) - - # Phase is only relative in csm calculation, therefore only the abs values are compared. - assert relative_image_difference(torch.abs(csm.data), torch.abs(csm_ref)) <= 0.01 - - def test_CsmData_interactive_Walsh_smoothing_width(ellipse_phantom, random_kheader): """CsmData from iterative Walsh method using SpatialDimension and int for smoothing width.""" From b797d90e87476c82674948196242dd4ec6b6d6bd Mon Sep 17 00:00:00 2001 From: Johannes Hammacher <135006694+JoHa0811@users.noreply.github.com> Date: Wed, 10 Jul 2024 13:03:15 +0200 Subject: [PATCH 08/34] Change references to numpydoc-style (#352) Co-authored-by: Patrick Schuenke <37338697+schuenke@users.noreply.github.com> Co-authored-by: Christoph Kolbitsch --- src/mrpro/algorithms/csm/iterative_walsh.py | 10 +++++---- src/mrpro/algorithms/prewhiten_kspace.py | 12 +++++++--- src/mrpro/data/EncodingLimits.py | 10 ++++++--- src/mrpro/data/IData.py | 7 ++++-- src/mrpro/data/_kdata/KDataProtocol.py | 7 +++--- src/mrpro/data/_kdata/KDataRemoveOsMixin.py | 7 ++++-- src/mrpro/data/enums.py | 8 ++++--- .../traj_calculators/KTrajectoryIsmrmrd.py | 8 ++++--- .../data/traj_calculators/KTrajectoryRpe.py | 22 ++++++++++++++----- src/mrpro/operators/FastFourierOp.py | 6 ++++- .../TransientSteadyStateWithPreparation.py | 14 ++++++------ src/mrpro/operators/models/WASABI.py | 10 ++++++--- src/mrpro/operators/models/WASABITI.py | 8 ++++++- src/mrpro/phantoms/EllipsePhantom.py | 10 +++++++-- src/mrpro/phantoms/coils.py | 6 ++++- src/mrpro/utils/Rotation.py | 6 ++--- 16 files changed, 105 insertions(+), 46 deletions(-) diff --git a/src/mrpro/algorithms/csm/iterative_walsh.py b/src/mrpro/algorithms/csm/iterative_walsh.py index 31d3a637..22aeb4fd 100644 --- a/src/mrpro/algorithms/csm/iterative_walsh.py +++ b/src/mrpro/algorithms/csm/iterative_walsh.py @@ -30,13 +30,10 @@ def iterative_walsh( This is for a single set of coil images. The input should be a tensor with dimensions (coils, z, y, x). The output will have the same dimensions. Either apply this function individually to each set of coil images, - or see CsmData.from_idata_walsh which performs this operation on a whole dataset. + or see CsmData.from_idata_walsh which performs this operation on a whole dataset [1]_. This function is inspired by https://github.com/ismrmrd/ismrmrd-python-tools. - More information on the method can be found in - https://doi.org/10.1002/(SICI)1522-2594(200005)43:5<682::AID-MRM10>3.0.CO;2-G - Parameters ---------- coil_images @@ -45,6 +42,11 @@ def iterative_walsh( width of the smoothing filter power_iterations number of iterations used to determine dominant eigenvector + + References + ---------- + .. [1] Daval-Frerot G, Ciuciu P (2022) Iterative static field map estimation for off-resonance correction in + non-Cartesian susceptibility weighted imaging. MRM 88(4): mrm.29297. """ if isinstance(smoothing_width, int): smoothing_width = SpatialDimension(smoothing_width, smoothing_width, smoothing_width) diff --git a/src/mrpro/algorithms/prewhiten_kspace.py b/src/mrpro/algorithms/prewhiten_kspace.py index 130fed3f..201fcba7 100644 --- a/src/mrpro/algorithms/prewhiten_kspace.py +++ b/src/mrpro/algorithms/prewhiten_kspace.py @@ -24,9 +24,7 @@ def prewhiten_kspace(kdata: KData, knoise: KNoise, scale_factor: float | torch.Tensor = 1.0) -> KData: - """Calculate noise prewhitening matrix and decorrelate coils. - - This function is inspired by https://github.com/ismrmrd/ismrmrd-python-tools. + """Calculate noise prewhitening matrix and decorrelate coils [1]_ [2]_ [3]_. Step 1: Calculate noise correlation matrix N Step 2: Carry out Cholesky decomposition L L^H = N @@ -56,6 +54,14 @@ def prewhiten_kspace(kdata: KData, knoise: KNoise, scale_factor: float | torch.T Returns ------- Prewhitened copy of k-space data + + References + ---------- + .. [1] https://github.com/ismrmrd/ismrmrd-python-tools + .. [2] Hansen M, Kellman P (2014) Image reconstruction: An overview for clinicians. JMRI 41(3): jmri.24687. + https://doi.org/10.1002/jmri.24687 + .. [3] Roemer P, Mueller O (1990) The NMR phased array. MRM 16(2): mrm.1910160203. + https://doi.org/10.1002/mrm.1910160203 """ # Reshape noise to (coil, everything else) noise = rearrange(knoise.data, '... coils k2 k1 k0->coils (... k2 k1 k0)') diff --git a/src/mrpro/data/EncodingLimits.py b/src/mrpro/data/EncodingLimits.py index 2b8e4cdb..54705217 100644 --- a/src/mrpro/data/EncodingLimits.py +++ b/src/mrpro/data/EncodingLimits.py @@ -44,10 +44,14 @@ def length(self) -> int: @dataclass(slots=True) class EncodingLimits: - """Encoding limits dataclass with limits for each attribute. + """Encoding limits dataclass with limits for each attribute [1]_. + + References + ---------- + .. [1] Inati S, Hanse M (2016) ISMRM Raw data format: + A proposed standard for MRI raw datasets. MRM 77(1): mrm.26089. + https://doi.org/10.1002/mrm.26089 - Reference: Magnetic Resonance in Medicine, 29 Jan 2016, 77(1):411-421, - DOI: 10.1002/mrm.26089 (Fig. 3) """ k0: Limits = dataclasses.field(default_factory=Limits) diff --git a/src/mrpro/data/IData.py b/src/mrpro/data/IData.py index 0265db31..210decc7 100644 --- a/src/mrpro/data/IData.py +++ b/src/mrpro/data/IData.py @@ -39,8 +39,11 @@ def _dcm_pixelarray_to_tensor(dataset: Dataset) -> torch.Tensor: their stored on disk representation to their in memory representation. U = m*SV + b where U is in output units, m is the rescale slope, SV is the stored value, and b is the rescale - intercept." (taken from - https://www.kitware.com/dicom-rescale-intercept-rescale-slope-and-itk/) + intercept." [1]_ + + References + ---------- + .. [1] https://www.kitware.com/dicom-rescale-intercept-rescale-slope-and-itk/ """ slope = ( float(element.value) diff --git a/src/mrpro/data/_kdata/KDataProtocol.py b/src/mrpro/data/_kdata/KDataProtocol.py index 97836983..f0436798 100644 --- a/src/mrpro/data/_kdata/KDataProtocol.py +++ b/src/mrpro/data/_kdata/KDataProtocol.py @@ -27,10 +27,11 @@ class _KDataProtocol(Protocol): Note that the actual KData class can have more properties and methods than those defined here. If you want to use a property or method of KData in a new KDataMixin class, - you must add it to this Protocol to make sure that the type hinting works. + you must add it to this Protocol to make sure that the type hinting works [1]_. - For more information about Protocols see: - https://typing.readthedocs.io/en/latest/spec/protocol.html#protocols + References + ---------- + .. [1] https://typing.readthedocs.io/en/latest/spec/protocol.html#protocols """ @property diff --git a/src/mrpro/data/_kdata/KDataRemoveOsMixin.py b/src/mrpro/data/_kdata/KDataRemoveOsMixin.py index 1d9e4645..cd436529 100644 --- a/src/mrpro/data/_kdata/KDataRemoveOsMixin.py +++ b/src/mrpro/data/_kdata/KDataRemoveOsMixin.py @@ -24,9 +24,8 @@ class KDataRemoveOsMixin(_KDataProtocol): """Remove oversampling along readout dimension.""" def remove_readout_os(self: Self) -> Self: - """Remove any oversampling along the readout (k0) direction. + """Remove any oversampling along the readout (k0) direction [1]_. - This function is inspired by https://github.com/gadgetron/gadgetron-python. Returns a copy of the data. Parameters @@ -42,6 +41,10 @@ def remove_readout_os(self: Self) -> Self: ------ ValueError If the recon matrix along x is larger than the encoding matrix along x. + + References + ---------- + .. [1] https://github.com/gadgetron/gadgetron-python """ from mrpro.operators.FastFourierOp import FastFourierOp diff --git a/src/mrpro/data/enums.py b/src/mrpro/data/enums.py index dee04a2e..e078fc92 100644 --- a/src/mrpro/data/enums.py +++ b/src/mrpro/data/enums.py @@ -20,10 +20,12 @@ class AcqFlags(Flag): """Acquisition flags. - Reference: - https://github.com/ismrmrd/ismrmrd/blob/master/include/ismrmrd/ismrmrd.h NOTE: values in enum ISMRMRD_AcquisitionFlags start at 1 and not 0, but - 1 << (val-1) is used in 'ismrmrd_is_flag_set' function to calc bitmask value. + 1 << (val-1) is used in 'ismrmrd_is_flag_set' function to calc bitmask value [1]_. + + References + ---------- + .. [1] https://github.com/ismrmrd/ismrmrd/blob/master/include/ismrmrd/ismrmrd.h """ ACQ_NO_FLAG = 0 diff --git a/src/mrpro/data/traj_calculators/KTrajectoryIsmrmrd.py b/src/mrpro/data/traj_calculators/KTrajectoryIsmrmrd.py index c79abf14..433f99e7 100644 --- a/src/mrpro/data/traj_calculators/KTrajectoryIsmrmrd.py +++ b/src/mrpro/data/traj_calculators/KTrajectoryIsmrmrd.py @@ -25,13 +25,15 @@ class KTrajectoryIsmrmrd: """Get trajectory in ISMRMRD raw data file. - The trajectory in the ISMRMRD raw data file is read out. Information - on the ISMRMRD trajectory can be found here: - https://ismrmrd.readthedocs.io/en/latest/mrd_raw_data.html#k-space-trajectory + The trajectory in the ISMRMRD raw data file is read out [1]_. The value range of the trajectory in the ISMRMRD file is not well defined. Here we simple normalize everything based on the highest value and ensure it is within [-pi, pi]. The trajectory is in the shape of the unsorted raw data. + + References + ---------- + .. [1] https://ismrmrd.readthedocs.io/en/latest/mrd_raw_data.html#k-space-trajectory """ def __call__(self, acquisitions: Sequence[ismrmrd.Acquisition]) -> KTrajectoryRawShape: diff --git a/src/mrpro/data/traj_calculators/KTrajectoryRpe.py b/src/mrpro/data/traj_calculators/KTrajectoryRpe.py index 9cd4b404..a4e5420e 100644 --- a/src/mrpro/data/traj_calculators/KTrajectoryRpe.py +++ b/src/mrpro/data/traj_calculators/KTrajectoryRpe.py @@ -25,8 +25,16 @@ class KTrajectoryRpe(KTrajectoryCalculator): """Radial phase encoding trajectory. Frequency encoding along kx is carried out in a standard Cartesian way. The phase encoding points along ky and kz - are positioned along radial lines. More details can be found in: https://doi.org/10.1002/mrm.22102 and - https://doi.org/10.1118/1.4890095 (open access). + are positioned along radial lines [1]_ [2]_. + + References + ---------- + .. [1] Boubertakh R, Schaeffter T (2009) Whole-heart imaging using undersampled radial phase encoding (RPE) + and iterative sensitivity encoding (SENSE) reconstruction. MRM 62(5): mrm.22102. + https://doi.org/10.1002/mrm.22102 + .. [2] Kolbitsch C, Schaeffter T (2014) A 3D MR-acquisition scheme for nonrigid bulk motion correction + in simultaneous PET-MR. Medical Physics 41(8): 1.4890095. + https://doi.org/10.1118/1.4890095 """ def __init__(self, angle: float, shift_between_rpe_lines: tuple | torch.Tensor = (0, 0.5, 0.25, 0.75)) -> None: @@ -50,7 +58,7 @@ def _apply_shifts_between_rpe_lines(self, krad: torch.Tensor, kang_idx: torch.Te Example: shift_between_rpe_lines = [0, 0.5, 0.25, 0.75] leads to a shift of the 0th line by 0, the 1st line by 0.5, the 2nd line by 0.25, the 3rd line by 0.75, the 4th line by 0, the 5th line - by 0.5 and so on. Phase encoding points in k-space center are not shifted. + by 0.5 and so on. Phase encoding points in k-space center are not shifted [1]_. Line # k-space points before shift k-space points after shift 0 + + + + + + + + + + + + + + @@ -60,14 +68,18 @@ def _apply_shifts_between_rpe_lines(self, krad: torch.Tensor, kang_idx: torch.Te 4 + + + + + + + + + + + + + + 5 + + + + + + + + + + + + + + - More information can be found here: https://doi.org/10.1002/mrm.22446 - Parameters ---------- krad k-space positions along each phase encoding line kang_idx indices of angles to be used for shift calculation + + References + ---------- + .. [1] Prieto C, Schaeffter T (2010) 3D undersampled golden-radial phase encoding + for DCE-MRA using inherently regularized iterative SENSE. MRM 64(2): mrm.22446. + https://doi.org/10.1002/mrm.22446 """ for ind, shift in enumerate(self.shift_between_rpe_lines): curr_angle_idx = torch.nonzero( diff --git a/src/mrpro/operators/FastFourierOp.py b/src/mrpro/operators/FastFourierOp.py index 024ffcbb..79c713ac 100644 --- a/src/mrpro/operators/FastFourierOp.py +++ b/src/mrpro/operators/FastFourierOp.py @@ -31,7 +31,7 @@ class FastFourierOp(LinearOperator): along these selected dimensions The transformation is done with 'ortho' normalization, i.e. the normalization constant is split between - forward and adjoint. See https://numpy.org/doc/stable/reference/routines.fft.html for an explanation. + forward and adjoint [1]_. Remark regarding the fftshift/ifftshift: fftshift shifts the zero-frequency point to the center of the data, ifftshift undoes this operation. @@ -39,6 +39,10 @@ class FastFourierOp(LinearOperator): Torch.fft.fftn and torch.fft.ifftn expect the zero-frequency to be the first entry in the tensor. Therefore for forward and ajoint first ifftshift needs to be applied, then fftn or ifftn and then ifftshift. + References + ---------- + .. [1] https://numpy.org/doc/stable/reference/routines.fft.html + """ def __init__( diff --git a/src/mrpro/operators/models/TransientSteadyStateWithPreparation.py b/src/mrpro/operators/models/TransientSteadyStateWithPreparation.py index f1813cb5..65cbc0fd 100644 --- a/src/mrpro/operators/models/TransientSteadyStateWithPreparation.py +++ b/src/mrpro/operators/models/TransientSteadyStateWithPreparation.py @@ -26,13 +26,7 @@ class TransientSteadyStateWithPreparation(SignalModel[torch.Tensor, torch.Tensor a preparation pulse. The effect of the preparation pulse is modelled by a scaling factor applied to the equilibrium magnetisation. A delay after the preparation pulse can be defined. During this time T1 relaxation to M0 occurs. Data acquisition starts after this delay. Perfect spoiling is assumed and hence T2 effects are not - considered in the model. In addition this model assumes TR << T1 and TR << T1* (see definition below). More - information can be found here: - - Deichmann, R. & Haase, A. Quantification of T1 values by SNAPSHOT-FLASH NMR imaging. J. Magn. Reson. 612, 608-612 - (1992) [http://doi.org/10.1016/0022-2364(92)90347-A]. - Look, D. C. & Locker, D. R. Time Saving in Measurement of NMR and EPR Relaxation Times. Rev. Sci. Instrum 41, 250 - (1970) [https://doi.org/10.1063/1.1684482]. + considered in the model. In addition this model assumes TR << T1 and TR << T1* (see definition below) [1]_ [2]_. Let's assume we want to describe a continuous acquisition after an inversion pulse, then we have three parts: [Part A: 180° inversion pulse][Part B: spoiler gradient][Part C: Continuous data acquisition] @@ -54,6 +48,12 @@ class TransientSteadyStateWithPreparation(SignalModel[torch.Tensor, torch.Tensor and the steady-state magnetisation is M0* = M0 T1* / T1 + References + ---------- + .. [1] Deichmann R, Haase A (1992) Quantification of T1 values by SNAPSHOT-FLASH NMR imaging. J. Magn. Reson. 612 + http://doi.org/10.1016/0022-2364(92)90347-A + .. [2] Look D, Locker R (1970) Time Saving in Measurement of NMR and EPR Relaxation Times. Rev. Sci. Instrum 41 + https://doi.org/10.1063/1.1684482 """ def __init__( diff --git a/src/mrpro/operators/models/WASABI.py b/src/mrpro/operators/models/WASABI.py index debd6b52..8b3e5808 100644 --- a/src/mrpro/operators/models/WASABI.py +++ b/src/mrpro/operators/models/WASABI.py @@ -31,9 +31,7 @@ def __init__( gamma: float | torch.Tensor = 42.5764, freq: float | torch.Tensor = 127.7292, ) -> None: - """Initialize WASABI signal model for mapping of B0 and B1. - - For more details see: https://doi.org/10.1002/mrm.26133 + """Initialize WASABI signal model for mapping of B0 and B1 [1]_. Parameters ---------- @@ -48,6 +46,12 @@ def __init__( gyromagnetic ratio [MHz/T] freq larmor frequency [MHz] + + References + ---------- + .. [1] Schuenke P, Zaiss M (2016) Simultaneous mapping of water shift + and B1(WASABI)—Application to field-Inhomogeneity correction of CEST MRI data. MRM 77(2): mrm.26133. + https://doi.org/10.1002/mrm.26133 """ super().__init__() # convert all parameters to tensors diff --git a/src/mrpro/operators/models/WASABITI.py b/src/mrpro/operators/models/WASABITI.py index e53bb392..89a06d8a 100644 --- a/src/mrpro/operators/models/WASABITI.py +++ b/src/mrpro/operators/models/WASABITI.py @@ -32,7 +32,7 @@ def __init__( gamma: float | torch.Tensor = 42.5764, freq: float | torch.Tensor = 127.7292, ) -> None: - """Initialize WASABITI signal model for mapping of B0, B1 and T1. + """Initialize WASABITI signal model for mapping of B0, B1 and T1 [1]_. For more details see: Proc. Intl. Soc. Mag. Reson. Med. 31 (2023): 0906 @@ -52,6 +52,12 @@ def __init__( gyromagnetic ratio [MHz/T] freq larmor frequency [MHz] + + References + ---------- + .. [1] Papageorgakis C, Casagranda S (2023) Fast WASABI post-processing: + Access to rapid B0 and B1 correction in clinical routine for CEST MRI. MRM 102(203-2011). + https://doi.org/10.1016/j.mri.2023.06.001 """ super().__init__() # convert all parameters to tensors diff --git a/src/mrpro/phantoms/EllipsePhantom.py b/src/mrpro/phantoms/EllipsePhantom.py index 4f30c6e3..85b00e40 100644 --- a/src/mrpro/phantoms/EllipsePhantom.py +++ b/src/mrpro/phantoms/EllipsePhantom.py @@ -57,8 +57,7 @@ def kspace(self, ky: torch.Tensor, kx: torch.Tensor) -> torch.Tensor: For a corresponding image with 256 x 256 voxel, the k-space locations should be defined within [-128, 127] - The Fourier representation of ellipses can be analytically described by Bessel functions. Further information - and derivations can be found e.g. here: https://doi.org/10.1002/mrm.21292 + The Fourier representation of ellipses can be analytically described by Bessel functions [1]_. Parameters ---------- @@ -66,6 +65,13 @@ def kspace(self, ky: torch.Tensor, kx: torch.Tensor) -> torch.Tensor: k-space locations in ky kx k-space locations in kx (frequency encoding direction). Same shape as ky. + + References + ---------- + .. [1] Koay C, Sarlls J, Özarslan E (2007) Three-dimensional analytical magnetic resonance imaging + phantom in the Fourier domain. MRM 58(2): mrm.21292. + https://doi.org/10.1002/mrm.21292 + .. """ # kx and ky have to be of same shape if kx.shape != ky.shape: diff --git a/src/mrpro/phantoms/coils.py b/src/mrpro/phantoms/coils.py index 123036f3..5594b621 100644 --- a/src/mrpro/phantoms/coils.py +++ b/src/mrpro/phantoms/coils.py @@ -41,8 +41,12 @@ def birdcage_2d( normalize_with_rss If set to true, the calculated sensitivities are normalized by the root-sum-of-squares - This function is strongly inspired by https://github.com/ismrmrd/ismrmrd-python-tools. The associated license + This function is strongly inspired by ISMRMRD Python Tools [1]_. The associated license information can be found at the end of this file. + + References + ---------- + .. [1] https://github.com/ismrmrd/ismrmrd-python-tools """ dim = [number_of_coils, image_dimensions.y, image_dimensions.x] x_co, y_co = torch.meshgrid( diff --git a/src/mrpro/utils/Rotation.py b/src/mrpro/utils/Rotation.py index e343109e..0e2ad73a 100644 --- a/src/mrpro/utils/Rotation.py +++ b/src/mrpro/utils/Rotation.py @@ -1247,9 +1247,9 @@ def mean( References ---------- - .. [1] Hartley, Richard, et al., - "Rotation Averaging", International Journal of Computer Vision - 103, 2013, pp. 267-305. + .. [1] Hartley R, Li H (2013) Rotation Averaging. International Journal of Computer Vision (103) + https://link.springer.com/article/10.1007/s11263-012-0601-0 + """ if weights is None: weights = torch.ones(*self.shape) From ee9ee6fa55bd856ea0cd74de908cf76b79eb1a8a Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Wed, 10 Jul 2024 14:10:32 +0200 Subject: [PATCH 09/34] Separate conftest into subdirectories (#360) --- tests/algorithms/test_optimizers.py | 2 +- tests/conftest.py | 167 ++++-------------- tests/data/__init__.py | 1 + tests/data/conftest.py | 115 ++++++++++++ tests/data/test_kdata.py | 2 +- tests/data/test_traj_calculators.py | 3 +- tests/data/test_trajectory.py | 36 +--- tests/operators/_OptimizationTestFunctions.py | 10 -- tests/operators/__init__.py | 1 + tests/operators/models/conftest.py | 48 +++++ .../models/test_inversion_recovery.py | 2 +- tests/operators/models/test_molli.py | 2 +- .../models/test_mono_exponential_decay.py | 2 +- .../models/test_saturation_recovery.py | 2 +- ...transient_steady_state_with_preparation.py | 2 +- tests/operators/models/test_wasabi.py | 2 +- tests/operators/models/test_wasabiti.py | 2 +- tests/operators/test_cartesian_sampling_op.py | 2 +- tests/operators/test_fourier_op.py | 3 +- tests/phantoms/__init__.py | 1 + 20 files changed, 218 insertions(+), 187 deletions(-) create mode 100644 tests/data/conftest.py create mode 100644 tests/operators/models/conftest.py diff --git a/tests/algorithms/test_optimizers.py b/tests/algorithms/test_optimizers.py index 5e5e7a36..ffbe67ac 100644 --- a/tests/algorithms/test_optimizers.py +++ b/tests/algorithms/test_optimizers.py @@ -16,7 +16,7 @@ import torch from mrpro.algorithms.optimizers import adam, lbfgs from mrpro.operators import ConstraintsOp -from tests.operators._OptimizationTestFunctions import Rosenbrock +from tests.operators import Rosenbrock @pytest.mark.parametrize('enforce_bounds_on_x1', [True, False]) diff --git a/tests/conftest.py b/tests/conftest.py index 7d55b830..cca7c061 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,13 +20,12 @@ import pytest import torch from ismrmrd import xsd -from mrpro.data import AcqInfo, KHeader +from mrpro.data import AcqInfo, KHeader, KTrajectory from mrpro.data.enums import AcqFlags from xsdata.models.datatype import XmlDate, XmlTime from tests import RandomGenerator -from tests.data import Dicom2DTestImage -from tests.phantoms._EllipsePhantomTestData import EllipsePhantomTestData +from tests.phantoms import EllipsePhantomTestData def generate_random_encodingcounter_properties(generator: RandomGenerator): @@ -83,24 +82,6 @@ def ellipse_phantom(): return EllipsePhantomTestData() -@pytest.fixture(params=({'seed': 0},)) -def cartesian_grid(request): - generator = RandomGenerator(request.param['seed']) - - def generate(n_k2: int, n_k1: int, n_k0: int, jitter: float) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: - k0_range = torch.arange(n_k0) - k1_range = torch.arange(n_k1) - k2_range = torch.arange(n_k2) - ky, kz, kx = torch.meshgrid(k1_range, k2_range, k0_range, indexing='xy') - if jitter > 0: - kx = kx + generator.float32_tensor((n_k2, n_k1, n_k0), high=jitter) - ky = ky + generator.float32_tensor((n_k2, n_k1, n_k0), high=jitter) - kz = kz + generator.float32_tensor((n_k2, n_k1, n_k0), high=jitter) - return kz.unsqueeze(0), ky.unsqueeze(0), kx.unsqueeze(0) - - return generate - - @pytest.fixture(params=({'seed': 0, 'n_coils': 32, 'n_samples': 256},)) def random_acquisition(request): seed, n_coils, n_samples = ( @@ -191,29 +172,6 @@ def random_full_ismrmrd_header(request) -> xsd.ismrmrdschema.ismrmrdHeader: ) -@pytest.fixture(params=({'seed': 0},)) -def random_mandatory_ismrmrd_header(request) -> xsd.ismrmrdschema.ismrmrdHeader: - """Generate a full header, i.e. all values used in - KHeader.from_ismrmrd_header() are set.""" - - seed = request.param['seed'] - generator = RandomGenerator(seed) - encoding = xsd.encodingType( - trajectory=xsd.trajectoryType('other'), - encodedSpace=xsd.encodingSpaceType( - matrixSize=xsd.matrixSizeType(x=generator.int16(), y=generator.uint8(), z=generator.uint8()), - fieldOfView_mm=xsd.fieldOfViewMm(x=generator.uint8(), y=generator.uint8(), z=generator.uint8()), - ), - reconSpace=xsd.encodingSpaceType( - matrixSize=xsd.matrixSizeType(x=generator.uint8(), y=generator.uint8(), z=generator.uint8()), - fieldOfView_mm=xsd.fieldOfViewMm(x=generator.uint8(), y=generator.uint8(), z=generator.uint8()), - ), - encodingLimits=xsd.encodingLimitsType(), - ) - experimental_conditions = xsd.experimentalConditionsType(H1resonanceFrequency_Hz=generator.int32()) - return xsd.ismrmrdschema.ismrmrdHeader(encoding=[encoding], experimentalConditions=experimental_conditions) - - @pytest.fixture() def random_ismrmrd_file(random_acquisition, random_noise_acquisition, full_header): with tempfile.NamedTemporaryFile(suffix='.h5') as file: @@ -226,13 +184,6 @@ def random_ismrmrd_file(random_acquisition, random_noise_acquisition, full_heade yield file.name -@pytest.fixture() -def random_acq_info(random_acquisition): - """Random (not necessarily valid) AcqInfo.""" - acq_info = AcqInfo.from_ismrmrd_acquisitions([random_acquisition]) - return acq_info - - @pytest.fixture(params=({'seed': 0},)) def random_kheader(request, random_full_ismrmrd_header, random_acq_info): """Random (not necessarily valid) KHeader.""" @@ -247,6 +198,13 @@ def random_kheader(request, random_full_ismrmrd_header, random_acq_info): return kheader +@pytest.fixture() +def random_acq_info(random_acquisition): + """Random (not necessarily valid) AcqInfo.""" + acq_info = AcqInfo.from_ismrmrd_acquisitions([random_acquisition]) + return acq_info + + @pytest.fixture(params=({'seed': 0, 'n_other': 10, 'n_k2': 40, 'n_k1': 20},)) def random_kheader_shape(request, random_acquisition, random_full_ismrmrd_header): """Random (not necessarily valid) KHeader with defined shape.""" @@ -272,62 +230,37 @@ def random_kheader_shape(request, random_acquisition, random_full_ismrmrd_header return kheader, n_other, n_coils, n_k2, n_k1, n_k0 -@pytest.fixture(params=({'seed': 0, 'n_other': 2, 'n_coils': 16, 'n_z': 32, 'n_y': 128, 'n_x': 256},)) -def random_test_data(request): - seed, n_other, n_coils, n_z, n_y, n_x = ( - request.param['seed'], - request.param['n_other'], - request.param['n_coils'], - request.param['n_z'], - request.param['n_y'], - request.param['n_x'], - ) - generator = RandomGenerator(seed) - test_data = generate_random_data(generator, (n_other, n_coils, n_z, n_y, n_x)) - return test_data - - -@pytest.fixture(scope='session') -def dcm_2d(ellipse_phantom, tmp_path_factory): - """Single 2D dicom image.""" - dcm_filename = tmp_path_factory.mktemp('mrpro') / 'dicom_2d.dcm' - dcm_idata = Dicom2DTestImage(filename=dcm_filename, phantom=ellipse_phantom.phantom) - return dcm_idata - - -@pytest.fixture(scope='session', params=({'n_images': 7},)) -def dcm_multi_echo_times(request, ellipse_phantom, tmp_path_factory): - """Multiple 2D dicom images with different echo times.""" - n_images = request.param['n_images'] - path = tmp_path_factory.mktemp('mrpro_multi_dcm') - te = 2.0 - dcm_image_data = [] - for _ in range(n_images): - dcm_filename = path / f'dicom_te_{int(te)}.dcm' - dcm_image_data.append(Dicom2DTestImage(filename=dcm_filename, phantom=ellipse_phantom.phantom, te=te)) - te += 1.0 - return dcm_image_data - - -@pytest.fixture(scope='session', params=({'n_images': 7},)) -def dcm_multi_echo_times_multi_folders(request, ellipse_phantom, tmp_path_factory): - """Multiple 2D dicom images with different echo times each saved in a different folder.""" - n_images = request.param['n_images'] - te = 2.0 - dcm_image_data = [] - for _ in range(n_images): - path = tmp_path_factory.mktemp(f'mrpro_multi_dcm_te_{int(te)}') - dcm_filename = path / f'dicom_te_{int(te)}.dcm' - dcm_image_data.append(Dicom2DTestImage(filename=dcm_filename, phantom=ellipse_phantom.phantom, te=te)) - te += 1.0 - return dcm_image_data - - -def create_parameter_tensor_tuples(parameter_shape=(10, 5, 100, 100, 100), number_of_tensors=2): - """Create tuples of tensors as input to operators.""" +def create_uniform_traj(nk, k_shape): + """Create a tensor of uniform points with predefined shape nk.""" + kidx = torch.where(torch.tensor(nk[1:]) > 1)[0] + if len(kidx) > 1: + raise ValueError('nk is allowed to have at most one non-singleton dimension') + if len(kidx) >= 1: + # kidx+1 because we searched in nk[1:] + n_kpoints = nk[kidx + 1] + # kidx+2 because k_shape also includes coils dimensions + k = torch.linspace(-k_shape[kidx + 2] // 2, k_shape[kidx + 2] // 2 - 1, n_kpoints, dtype=torch.float32) + views = [1 if i != n_kpoints else -1 for i in nk] + k = k.view(*views).expand(list(nk)) + else: + k = torch.zeros(nk) + return k + + +def create_traj(k_shape, nkx, nky, nkz, sx, sy, sz): + """Create trajectory with random entries.""" random_generator = RandomGenerator(seed=0) - parameter_tensors = random_generator.float32_tensor(size=(number_of_tensors, *parameter_shape), low=1e-10) - return torch.unbind(parameter_tensors) + k_list = [] + for spacing, nk in zip([sz, sy, sx], [nkz, nky, nkx], strict=True): + if spacing == 'nuf': + k = random_generator.float32_tensor(size=nk) + elif spacing == 'uf': + k = create_uniform_traj(nk, k_shape=k_shape) + elif spacing == 'z': + k = torch.zeros(nk) + k_list.append(k) + trajectory = KTrajectory(k_list[0], k_list[1], k_list[2], repeat_detection_tolerance=None) + return trajectory COMMON_MR_TRAJECTORIES = pytest.mark.parametrize( @@ -489,25 +422,3 @@ def create_parameter_tensor_tuples(parameter_shape=(10, 5, 100, 100, 100), numbe ), ], ) - -# Shape combinations for signal models -SHAPE_VARIATIONS_SIGNAL_MODELS = pytest.mark.parametrize( - ('parameter_shape', 'contrast_dim_shape', 'signal_shape'), - [ - ((1, 1, 10, 20, 30), (5,), (5, 1, 1, 10, 20, 30)), # single map with different inversion times - ((1, 1, 10, 20, 30), (5, 1), (5, 1, 1, 10, 20, 30)), - ((4, 1, 1, 10, 20, 30), (5, 1), (5, 4, 1, 1, 10, 20, 30)), # multiple maps along additional batch dimension - ((4, 1, 1, 10, 20, 30), (5,), (5, 4, 1, 1, 10, 20, 30)), - ((4, 1, 1, 10, 20, 30), (5, 4), (5, 4, 1, 1, 10, 20, 30)), - ((3, 1, 10, 20, 30), (5,), (5, 3, 1, 10, 20, 30)), # multiple maps along other dimension - ((3, 1, 10, 20, 30), (5, 1), (5, 3, 1, 10, 20, 30)), - ((3, 1, 10, 20, 30), (5, 3), (5, 3, 1, 10, 20, 30)), - ((4, 3, 1, 10, 20, 30), (5,), (5, 4, 3, 1, 10, 20, 30)), # multiple maps along other and batch dimension - ((4, 3, 1, 10, 20, 30), (5, 4), (5, 4, 3, 1, 10, 20, 30)), - ((4, 3, 1, 10, 20, 30), (5, 4, 1), (5, 4, 3, 1, 10, 20, 30)), - ((4, 3, 1, 10, 20, 30), (5, 1, 3), (5, 4, 3, 1, 10, 20, 30)), - ((4, 3, 1, 10, 20, 30), (5, 4, 3), (5, 4, 3, 1, 10, 20, 30)), - ((1,), (5,), (5, 1)), # single voxel - ((4, 3, 1), (5, 4, 3), (5, 4, 3, 1)), - ], -) diff --git a/tests/data/__init__.py b/tests/data/__init__.py index d3777681..dd7da7a0 100644 --- a/tests/data/__init__.py +++ b/tests/data/__init__.py @@ -1,2 +1,3 @@ from ._IsmrmrdRawTestData import IsmrmrdRawTestData from ._Dicom2DTestImage import Dicom2DTestImage +from ._PulseqRadialTestSeq import PulseqRadialTestSeq diff --git a/tests/data/conftest.py b/tests/data/conftest.py new file mode 100644 index 00000000..fd77f1b4 --- /dev/null +++ b/tests/data/conftest.py @@ -0,0 +1,115 @@ +"""PyTest fixtures for the data tests.""" + +# Copyright 2024 Physikalisch-Technische Bundesanstalt +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +import torch +from ismrmrd import xsd + +from tests import RandomGenerator +from tests.conftest import generate_random_data +from tests.data import Dicom2DTestImage + + +@pytest.fixture(params=({'seed': 0},)) +def cartesian_grid(request): + generator = RandomGenerator(request.param['seed']) + + def generate(n_k2: int, n_k1: int, n_k0: int, jitter: float) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + k0_range = torch.arange(n_k0) + k1_range = torch.arange(n_k1) + k2_range = torch.arange(n_k2) + ky, kz, kx = torch.meshgrid(k1_range, k2_range, k0_range, indexing='xy') + if jitter > 0: + kx = kx + generator.float32_tensor((n_k2, n_k1, n_k0), high=jitter) + ky = ky + generator.float32_tensor((n_k2, n_k1, n_k0), high=jitter) + kz = kz + generator.float32_tensor((n_k2, n_k1, n_k0), high=jitter) + return kz.unsqueeze(0), ky.unsqueeze(0), kx.unsqueeze(0) + + return generate + + +@pytest.fixture(params=({'seed': 0},)) +def random_mandatory_ismrmrd_header(request) -> xsd.ismrmrdschema.ismrmrdHeader: + """Generate a full header, i.e. all values used in + KHeader.from_ismrmrd_header() are set.""" + + seed = request.param['seed'] + generator = RandomGenerator(seed) + encoding = xsd.encodingType( + trajectory=xsd.trajectoryType('other'), + encodedSpace=xsd.encodingSpaceType( + matrixSize=xsd.matrixSizeType(x=generator.int16(), y=generator.uint8(), z=generator.uint8()), + fieldOfView_mm=xsd.fieldOfViewMm(x=generator.uint8(), y=generator.uint8(), z=generator.uint8()), + ), + reconSpace=xsd.encodingSpaceType( + matrixSize=xsd.matrixSizeType(x=generator.uint8(), y=generator.uint8(), z=generator.uint8()), + fieldOfView_mm=xsd.fieldOfViewMm(x=generator.uint8(), y=generator.uint8(), z=generator.uint8()), + ), + encodingLimits=xsd.encodingLimitsType(), + ) + experimental_conditions = xsd.experimentalConditionsType(H1resonanceFrequency_Hz=generator.int32()) + return xsd.ismrmrdschema.ismrmrdHeader(encoding=[encoding], experimentalConditions=experimental_conditions) + + +@pytest.fixture(params=({'seed': 0, 'n_other': 2, 'n_coils': 16, 'n_z': 32, 'n_y': 128, 'n_x': 256},)) +def random_test_data(request): + seed, n_other, n_coils, n_z, n_y, n_x = ( + request.param['seed'], + request.param['n_other'], + request.param['n_coils'], + request.param['n_z'], + request.param['n_y'], + request.param['n_x'], + ) + generator = RandomGenerator(seed) + test_data = generate_random_data(generator, (n_other, n_coils, n_z, n_y, n_x)) + return test_data + + +@pytest.fixture(scope='session') +def dcm_2d(ellipse_phantom, tmp_path_factory): + """Single 2D dicom image.""" + dcm_filename = tmp_path_factory.mktemp('mrpro') / 'dicom_2d.dcm' + dcm_idata = Dicom2DTestImage(filename=dcm_filename, phantom=ellipse_phantom.phantom) + return dcm_idata + + +@pytest.fixture(scope='session', params=({'n_images': 7},)) +def dcm_multi_echo_times(request, ellipse_phantom, tmp_path_factory): + """Multiple 2D dicom images with different echo times.""" + n_images = request.param['n_images'] + path = tmp_path_factory.mktemp('mrpro_multi_dcm') + te = 2.0 + dcm_image_data = [] + for _ in range(n_images): + dcm_filename = path / f'dicom_te_{int(te)}.dcm' + dcm_image_data.append(Dicom2DTestImage(filename=dcm_filename, phantom=ellipse_phantom.phantom, te=te)) + te += 1.0 + return dcm_image_data + + +@pytest.fixture(scope='session', params=({'n_images': 7},)) +def dcm_multi_echo_times_multi_folders(request, ellipse_phantom, tmp_path_factory): + """Multiple 2D dicom images with different echo times each saved in a different folder.""" + n_images = request.param['n_images'] + te = 2.0 + dcm_image_data = [] + for _ in range(n_images): + path = tmp_path_factory.mktemp(f'mrpro_multi_dcm_te_{int(te)}') + dcm_filename = path / f'dicom_te_{int(te)}.dcm' + dcm_image_data.append(Dicom2DTestImage(filename=dcm_filename, phantom=ellipse_phantom.phantom, te=te)) + te += 1.0 + return dcm_image_data diff --git a/tests/data/test_kdata.py b/tests/data/test_kdata.py index ade31138..143e1925 100644 --- a/tests/data/test_kdata.py +++ b/tests/data/test_kdata.py @@ -24,7 +24,7 @@ from tests.conftest import RandomGenerator, generate_random_data from tests.data import IsmrmrdRawTestData from tests.helper import relative_image_difference -from tests.phantoms._EllipsePhantomTestData import EllipsePhantomTestData +from tests.phantoms import EllipsePhantomTestData @pytest.fixture(scope='session') diff --git a/tests/data/test_traj_calculators.py b/tests/data/test_traj_calculators.py index 6e37cb12..4d264be5 100644 --- a/tests/data/test_traj_calculators.py +++ b/tests/data/test_traj_calculators.py @@ -27,8 +27,7 @@ KTrajectorySunflowerGoldenRpe, ) -from tests.data import IsmrmrdRawTestData -from tests.data._PulseqRadialTestSeq import PulseqRadialTestSeq +from tests.data import IsmrmrdRawTestData, PulseqRadialTestSeq @pytest.fixture() diff --git a/tests/data/test_trajectory.py b/tests/data/test_trajectory.py index 28925078..3a5afacc 100644 --- a/tests/data/test_trajectory.py +++ b/tests/data/test_trajectory.py @@ -17,41 +17,7 @@ from mrpro.data import KTrajectory from mrpro.data.enums import TrajType -from tests import RandomGenerator -from tests.conftest import COMMON_MR_TRAJECTORIES - - -def create_uniform_traj(nk, k_shape): - """Create a tensor of uniform points with predefined shape nk.""" - kidx = torch.where(torch.tensor(nk[1:]) > 1)[0] - if len(kidx) > 1: - raise ValueError('nk is allowed to have at most one non-singleton dimension') - if len(kidx) >= 1: - # kidx+1 because we searched in nk[1:] - n_kpoints = nk[kidx + 1] - # kidx+2 because k_shape also includes coils dimensions - k = torch.linspace(-k_shape[kidx + 2] // 2, k_shape[kidx + 2] // 2 - 1, n_kpoints, dtype=torch.float32) - views = [1 if i != n_kpoints else -1 for i in nk] - k = k.view(*views).expand(list(nk)) - else: - k = torch.zeros(nk) - return k - - -def create_traj(k_shape, nkx, nky, nkz, sx, sy, sz): - """Create trajectory with random entries.""" - random_generator = RandomGenerator(seed=0) - k_list = [] - for spacing, nk in zip([sz, sy, sx], [nkz, nky, nkx], strict=True): - if spacing == 'nuf': - k = random_generator.float32_tensor(size=nk) - elif spacing == 'uf': - k = create_uniform_traj(nk, k_shape=k_shape) - elif spacing == 'z': - k = torch.zeros(nk) - k_list.append(k) - trajectory = KTrajectory(k_list[0], k_list[1], k_list[2], repeat_detection_tolerance=None) - return trajectory +from tests.conftest import COMMON_MR_TRAJECTORIES, create_traj def test_trajectory_repeat_detection_tol(cartesian_grid): diff --git a/tests/operators/_OptimizationTestFunctions.py b/tests/operators/_OptimizationTestFunctions.py index 9de67902..97e77f94 100644 --- a/tests/operators/_OptimizationTestFunctions.py +++ b/tests/operators/_OptimizationTestFunctions.py @@ -27,13 +27,3 @@ def forward(self, x1: torch.Tensor, x2: torch.Tensor) -> tuple[torch.Tensor,]: fval = (self.a - x1) ** 2 + self.b * (x1 - x2**2) ** 2 return (fval,) - - -class Booth(Operator[torch.Tensor, torch.Tensor, tuple[torch.Tensor,]]): - def __init__(self) -> None: - super().__init__() - - def forward(self, x1: torch.Tensor, x2: torch.Tensor) -> tuple[torch.Tensor,]: - fval = (x1 + 2.0 * x2 - 7.0) ** 2 + (2.0 * x1 - +x2 - 5.0) ** 2 - - return (fval,) diff --git a/tests/operators/__init__.py b/tests/operators/__init__.py index e69de29b..63f382f5 100644 --- a/tests/operators/__init__.py +++ b/tests/operators/__init__.py @@ -0,0 +1 @@ +from ._OptimizationTestFunctions import Rosenbrock diff --git a/tests/operators/models/conftest.py b/tests/operators/models/conftest.py new file mode 100644 index 00000000..d3a6a841 --- /dev/null +++ b/tests/operators/models/conftest.py @@ -0,0 +1,48 @@ +"""PyTest fixtures for signal models.""" + +# Copyright 2024 Physikalisch-Technische Bundesanstalt +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +import torch +from tests import RandomGenerator + +# Shape combinations for signal models +SHAPE_VARIATIONS_SIGNAL_MODELS = pytest.mark.parametrize( + ('parameter_shape', 'contrast_dim_shape', 'signal_shape'), + [ + ((1, 1, 10, 20, 30), (5,), (5, 1, 1, 10, 20, 30)), # single map with different inversion times + ((1, 1, 10, 20, 30), (5, 1), (5, 1, 1, 10, 20, 30)), + ((4, 1, 1, 10, 20, 30), (5, 1), (5, 4, 1, 1, 10, 20, 30)), # multiple maps along additional batch dimension + ((4, 1, 1, 10, 20, 30), (5,), (5, 4, 1, 1, 10, 20, 30)), + ((4, 1, 1, 10, 20, 30), (5, 4), (5, 4, 1, 1, 10, 20, 30)), + ((3, 1, 10, 20, 30), (5,), (5, 3, 1, 10, 20, 30)), # multiple maps along other dimension + ((3, 1, 10, 20, 30), (5, 1), (5, 3, 1, 10, 20, 30)), + ((3, 1, 10, 20, 30), (5, 3), (5, 3, 1, 10, 20, 30)), + ((4, 3, 1, 10, 20, 30), (5,), (5, 4, 3, 1, 10, 20, 30)), # multiple maps along other and batch dimension + ((4, 3, 1, 10, 20, 30), (5, 4), (5, 4, 3, 1, 10, 20, 30)), + ((4, 3, 1, 10, 20, 30), (5, 4, 1), (5, 4, 3, 1, 10, 20, 30)), + ((4, 3, 1, 10, 20, 30), (5, 1, 3), (5, 4, 3, 1, 10, 20, 30)), + ((4, 3, 1, 10, 20, 30), (5, 4, 3), (5, 4, 3, 1, 10, 20, 30)), + ((1,), (5,), (5, 1)), # single voxel + ((4, 3, 1), (5, 4, 3), (5, 4, 3, 1)), + ], +) + + +def create_parameter_tensor_tuples(parameter_shape=(10, 5, 100, 100, 100), number_of_tensors=2): + """Create tuples of tensors as input to operators.""" + random_generator = RandomGenerator(seed=0) + parameter_tensors = random_generator.float32_tensor(size=(number_of_tensors, *parameter_shape), low=1e-10) + return torch.unbind(parameter_tensors) diff --git a/tests/operators/models/test_inversion_recovery.py b/tests/operators/models/test_inversion_recovery.py index b71fe76c..fd2f4d2f 100644 --- a/tests/operators/models/test_inversion_recovery.py +++ b/tests/operators/models/test_inversion_recovery.py @@ -17,7 +17,7 @@ import pytest import torch from mrpro.operators.models import InversionRecovery -from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples +from tests.operators.models.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples @pytest.mark.parametrize( diff --git a/tests/operators/models/test_molli.py b/tests/operators/models/test_molli.py index e5bf2d71..bb238d6e 100644 --- a/tests/operators/models/test_molli.py +++ b/tests/operators/models/test_molli.py @@ -17,7 +17,7 @@ import pytest import torch from mrpro.operators.models import MOLLI -from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples +from tests.operators.models.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples @pytest.mark.parametrize( diff --git a/tests/operators/models/test_mono_exponential_decay.py b/tests/operators/models/test_mono_exponential_decay.py index a639bd38..367ab376 100644 --- a/tests/operators/models/test_mono_exponential_decay.py +++ b/tests/operators/models/test_mono_exponential_decay.py @@ -17,7 +17,7 @@ import pytest import torch from mrpro.operators.models import MonoExponentialDecay -from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples +from tests.operators.models.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples @pytest.mark.parametrize( diff --git a/tests/operators/models/test_saturation_recovery.py b/tests/operators/models/test_saturation_recovery.py index 392dfb29..f9d8e44b 100644 --- a/tests/operators/models/test_saturation_recovery.py +++ b/tests/operators/models/test_saturation_recovery.py @@ -17,7 +17,7 @@ import pytest import torch from mrpro.operators.models import SaturationRecovery -from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples +from tests.operators.models.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples @pytest.mark.parametrize( diff --git a/tests/operators/models/test_transient_steady_state_with_preparation.py b/tests/operators/models/test_transient_steady_state_with_preparation.py index 1b6babe8..2f769d3e 100644 --- a/tests/operators/models/test_transient_steady_state_with_preparation.py +++ b/tests/operators/models/test_transient_steady_state_with_preparation.py @@ -17,7 +17,7 @@ import pytest import torch from mrpro.operators.models import TransientSteadyStateWithPreparation -from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples +from tests.operators.models.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples @pytest.mark.parametrize( diff --git a/tests/operators/models/test_wasabi.py b/tests/operators/models/test_wasabi.py index a3d6a9fb..5935f9c8 100644 --- a/tests/operators/models/test_wasabi.py +++ b/tests/operators/models/test_wasabi.py @@ -16,7 +16,7 @@ import torch from mrpro.operators.models import WASABI -from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples +from tests.operators.models.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples def create_data(offset_max=500, n_offsets=101, b0_shift=0, rb1=1.0, c=1.0, d=2.0): diff --git a/tests/operators/models/test_wasabiti.py b/tests/operators/models/test_wasabiti.py index cffdcf61..3cbdaeef 100644 --- a/tests/operators/models/test_wasabiti.py +++ b/tests/operators/models/test_wasabiti.py @@ -17,7 +17,7 @@ import pytest import torch from mrpro.operators.models import WASABITI -from tests.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples +from tests.operators.models.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples def create_data(offset_max=500, n_offsets=101, b0_shift=0, rb1=1.0, t1=1.0): diff --git a/tests/operators/test_cartesian_sampling_op.py b/tests/operators/test_cartesian_sampling_op.py index 6a8fc699..8f91329a 100644 --- a/tests/operators/test_cartesian_sampling_op.py +++ b/tests/operators/test_cartesian_sampling_op.py @@ -18,7 +18,7 @@ from mrpro.operators import CartesianSamplingOp from tests import RandomGenerator -from tests.data.test_trajectory import create_traj +from tests.conftest import create_traj from tests.helper import dotproduct_adjointness_test diff --git a/tests/operators/test_fourier_op.py b/tests/operators/test_fourier_op.py index 69587692..b139d073 100644 --- a/tests/operators/test_fourier_op.py +++ b/tests/operators/test_fourier_op.py @@ -17,8 +17,7 @@ from mrpro.operators import FourierOp from tests import RandomGenerator -from tests.conftest import COMMON_MR_TRAJECTORIES -from tests.data.test_trajectory import create_traj +from tests.conftest import COMMON_MR_TRAJECTORIES, create_traj from tests.helper import dotproduct_adjointness_test diff --git a/tests/phantoms/__init__.py b/tests/phantoms/__init__.py index e69de29b..f2ad3529 100644 --- a/tests/phantoms/__init__.py +++ b/tests/phantoms/__init__.py @@ -0,0 +1 @@ +from ._EllipsePhantomTestData import EllipsePhantomTestData From 640f3f580f8b771e9ae9e953b7cc59f1f093039c Mon Sep 17 00:00:00 2001 From: Johannes Hammacher <135006694+JoHa0811@users.noreply.github.com> Date: Wed, 10 Jul 2024 16:29:39 +0200 Subject: [PATCH 10/34] Remove unused parameters in adam (#363) --- src/mrpro/algorithms/optimizers/adam.py | 48 +++++++++---------------- 1 file changed, 16 insertions(+), 32 deletions(-) diff --git a/src/mrpro/algorithms/optimizers/adam.py b/src/mrpro/algorithms/optimizers/adam.py index accf4e59..1488ad7c 100644 --- a/src/mrpro/algorithms/optimizers/adam.py +++ b/src/mrpro/algorithms/optimizers/adam.py @@ -17,7 +17,7 @@ from collections.abc import Sequence import torch -from torch.optim import Adam +from torch.optim import Adam, AdamW from mrpro.operators.Operator import Operator @@ -31,10 +31,7 @@ def adam( eps: float = 1e-8, weight_decay: float = 0, amsgrad: bool = False, - foreach: bool | None = None, - maximize: bool = False, - differentiable: bool = False, - fused: bool | None = None, + decoupled_weight_decay: bool = False, ) -> tuple[torch.Tensor, ...]: """Adam for non-linear minimization problems. @@ -59,39 +56,26 @@ def adam( amsgrad whether to use the AMSGrad variant of this algorithm from the paper `On the Convergence of Adam and Beyond` - foreach - whether `foreach` implementation of optimizer is used - maximize - maximize the objective with respect to the params, instead of minimizing - differentiable - whether autograd should occur through the optimizer step. This is currently not implemented. - fused - whether the fused implementation (CUDA only) is used. Currently, torch.float64, torch.float32, - torch.float16, and torch.bfloat16 are supported. + decoupled_weight_decay + whether to use Adam (default) or AdamW (if set to true) [1]_ Returns ------- list of optimized parameters + + References + ---------- + .. [1] Loshchilov I, Hutter F (2019) Decoupled Weight Decay Regularization. ICLR + https://doi.org/10.48550/arXiv.1711.05101 """ - if not differentiable: - parameters = [p.detach().clone().requires_grad_(True) for p in initial_parameters] - else: - # TODO: If differentiable is set, it is reasonable to expect that the result backpropagates to - # initial parameters. This is currently not implemented (due to detach). - raise NotImplementedError('Differentiable Optimization is not implemented') + parameters = [p.detach().clone().requires_grad_(True) for p in initial_parameters] - optim = Adam( - params=parameters, - lr=lr, - betas=betas, - eps=eps, - weight_decay=weight_decay, - amsgrad=amsgrad, - foreach=foreach, - maximize=maximize, - differentiable=differentiable, - fused=fused, - ) + optim: AdamW | Adam + + if not decoupled_weight_decay: + optim = Adam(params=parameters, lr=lr, betas=betas, eps=eps, weight_decay=weight_decay, amsgrad=amsgrad) + else: + optim = AdamW(params=parameters, lr=lr, betas=betas, eps=eps, weight_decay=weight_decay, amsgrad=amsgrad) def closure(): optim.zero_grad() From f93fb5fff23375970078e76587824b6838496286 Mon Sep 17 00:00:00 2001 From: Stefan Martin <67423672+Stef-Martin@users.noreply.github.com> Date: Wed, 10 Jul 2024 17:10:01 +0200 Subject: [PATCH 11/34] Move dcf calculation with voronoi to algorithms (#349) --- src/mrpro/algorithms/dcf/__init__.py | 1 + src/mrpro/algorithms/dcf/dcf_voronoi.py | 138 ++++++++++++++++++++ src/mrpro/data/DcfData.py | 125 +----------------- tests/algorithms/dcf/test_dcf_voronoi.py | 157 +++++++++++++++++++++++ tests/data/test_dcf_data.py | 141 +------------------- 5 files changed, 300 insertions(+), 262 deletions(-) create mode 100644 src/mrpro/algorithms/dcf/__init__.py create mode 100644 src/mrpro/algorithms/dcf/dcf_voronoi.py create mode 100644 tests/algorithms/dcf/test_dcf_voronoi.py diff --git a/src/mrpro/algorithms/dcf/__init__.py b/src/mrpro/algorithms/dcf/__init__.py new file mode 100644 index 00000000..01986c15 --- /dev/null +++ b/src/mrpro/algorithms/dcf/__init__.py @@ -0,0 +1 @@ +from mrpro.algorithms.dcf.dcf_voronoi import dcf_1d, dcf_2d3d_voronoi \ No newline at end of file diff --git a/src/mrpro/algorithms/dcf/dcf_voronoi.py b/src/mrpro/algorithms/dcf/dcf_voronoi.py new file mode 100644 index 00000000..2887ac61 --- /dev/null +++ b/src/mrpro/algorithms/dcf/dcf_voronoi.py @@ -0,0 +1,138 @@ +"""1D, 2D and 3D density compensation function calculation with voronoi method.""" + +# Copyright 2024 Physikalisch-Technische Bundesanstalt +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from concurrent.futures import ProcessPoolExecutor +from itertools import product + +import numpy as np +import torch +from numpy.typing import ArrayLike +from scipy.spatial import ConvexHull, Voronoi + +UNIQUE_ROUNDING_DECIMALS = 15 + + +def _volume(v: ArrayLike): + return ConvexHull(v).volume + + +def dcf_1d(traj: torch.Tensor) -> torch.Tensor: + """Calculate sample density compensation function for 1D trajectory. + + Parameters + ---------- + traj + k-space positions, 1D tensor + """ + traj_sorted, inverse, counts = torch.unique( + torch.round(traj, decimals=UNIQUE_ROUNDING_DECIMALS), + sorted=True, + return_inverse=True, + return_counts=True, + ) + + # For a sorted trajectory: x0 x1 x2 ... xN + # we assign the point at x1 the area (x1 - x0) / 2 + (x2 - x1) / 2, + # this is done by central differences (-1,0,1). + # For the edges, we append/prepend the values afterwards, such that: + # we assign the point at x0 the area (x1 - x0) / 2 + (x1 - x0) / 2, and + # we assign the point at xN the area (xN - xN-1) / 2 + (xN - xN-1) / 2. + + kernel = torch.tensor([-1 / 2, 0, 1 / 2], dtype=torch.float32, device=traj.device).reshape(1, 1, 3) + + if (elements := len(traj_sorted)) >= 3: + central_diff = torch.nn.functional.conv1d(traj_sorted[None, None, :], kernel)[0, 0] + first = traj_sorted[1] - traj_sorted[0] + last = traj_sorted[-1] - traj_sorted[-2] + central_diff = torch.cat((first[None], central_diff, last[None]), -1) + elif elements == 2: + diff = traj_sorted[1] - traj_sorted[0] + central_diff = torch.cat((diff[None], diff[None]), -1) + else: + central_diff = torch.ones_like(traj_sorted) + + # Repeated points are reduced by the number of repeats + dcf = torch.nan_to_num(central_diff / counts)[inverse] + return dcf + + +def dcf_2d3d_voronoi(traj: torch.Tensor) -> torch.Tensor: + """Calculate sample density compensation function using voronoi method. + + Points at the edge of k-space are detected as outliers and assigned the + area of the 1% largest dcf values. + + Parameters + ---------- + traj + k-space positions (2 or 3, k2, k1, k0) + + Returns + ------- + density compensation values (1, k2, k1, k0) + """ + # 2D and 3D trajectories supported + dim = traj.shape[0] + if dim not in (2, 3): + raise ValueError(f'Only 2D or 3D trajectories supported, not {dim}D.') + + # Calculate dcf only for unique k-space positions + traj_dim = traj.shape + traj_numpy = np.round(traj.cpu().numpy(), decimals=UNIQUE_ROUNDING_DECIMALS) + traj_numpy = traj_numpy.reshape(dim, -1) + traj_unique, inverse, counts = np.unique(traj_numpy, return_inverse=True, return_counts=True, axis=1) + + # Especially in 3D, errors in the calculation of the convex hull can occur for edge points. To avoid this, + # the corner points of a cube bounding box are added here. The bounding box is chosen very large to ensure these + # edge points of the trajectory can still be accurately detected in the outlier detection further down. + furthest_corner = np.max(np.abs(traj_unique)) + corner_points = np.array(list(product([-1, 1], repeat=dim))) * furthest_corner * 10 + traj_extendend = np.concatenate((traj_unique, corner_points.transpose()), axis=1) + + # Carry out voronoi tessellation + vdiagram = Voronoi(traj_extendend.transpose()) + regions = [vdiagram.regions[r] for r in vdiagram.point_region[: -len(corner_points)]] # Ignore corner points + vertices = [vdiagram.vertices[region] for region in regions] + + if dim == 2: + # Shoelace equation for 2d + dcf = np.array([np.abs(np.cross(v[:-1], v[1:]).sum(0) + np.cross(v[-1], v[0])) / 2 for v in vertices]) + + else: + # Calculate volume/area of voronoi cells using processes, as this is a very time-consuming operation + # and ConvexHull is singlethreaded and does not seem to drop the GIL + # TODO: this could maybe be made faster as the polyhedrons are known to be convex + future = ProcessPoolExecutor(max_workers=torch.get_num_threads()).map(_volume, vertices, chunksize=100) + dcf = np.array(list(future)) + + # Get outliers (i.e. voronoi cell which are unbound) and set them to a reasonable value + # Outliers are defined as values larger than 1.5 * inter quartile range of the values + # Outliers are set to the average of the 1% largest values. + dcf_sorted = np.sort(dcf) + q1, q3 = np.percentile(dcf_sorted, [25, 75]) + iqr = q3 - q1 + upper_bound = q3 + 1.5 * iqr + idx_outliers = np.nonzero(dcf > upper_bound) + n_outliers = len(idx_outliers[0]) + high_values_start = int(0.99 * (len(dcf_sorted) - n_outliers)) + high_values_end = len(dcf_sorted) - n_outliers # this works also for n_outliers==0 + fill_value = np.average(dcf_sorted[high_values_start:high_values_end]) + dcf[idx_outliers] = fill_value + + # Sort dcf values back into the original order (i.e. before calling unique) + dcf = np.reshape((dcf / counts)[inverse], traj_dim[1:]) + + return torch.tensor(dcf, dtype=torch.float32, device=traj.device) diff --git a/src/mrpro/data/DcfData.py b/src/mrpro/data/DcfData.py index f239ac8b..941ffd59 100644 --- a/src/mrpro/data/DcfData.py +++ b/src/mrpro/data/DcfData.py @@ -17,16 +17,12 @@ from __future__ import annotations import dataclasses -from concurrent.futures import ProcessPoolExecutor from functools import reduce -from itertools import product from typing import TYPE_CHECKING, Self -import numpy as np import torch -from numpy.typing import ArrayLike -from scipy.spatial import ConvexHull, Voronoi +from mrpro.algorithms.dcf.dcf_voronoi import dcf_1d, dcf_2d3d_voronoi from mrpro.data.KTrajectory import KTrajectory from mrpro.data.MoveDataMixin import MoveDataMixin from mrpro.utils import smap @@ -34,12 +30,6 @@ if TYPE_CHECKING: from mrpro.operators.DensityCompensationOp import DensityCompensationOp -UNIQUE_ROUNDING_DECIMALS = 15 - - -def _volume(v: ArrayLike): - return ConvexHull(v).volume - @dataclasses.dataclass(slots=True, frozen=False) class DcfData(MoveDataMixin): @@ -48,115 +38,6 @@ class DcfData(MoveDataMixin): data: torch.Tensor """Density compensation values. Shape (... other, k2, k1, k0)""" - @staticmethod - def _dcf_1d(traj: torch.Tensor) -> torch.Tensor: - """Calculate sample density compensation function for 1D trajectory. - - Parameters - ---------- - traj - k-space positions, 1D tensor - """ - traj_sorted, inverse, counts = torch.unique( - torch.round(traj, decimals=UNIQUE_ROUNDING_DECIMALS), - sorted=True, - return_inverse=True, - return_counts=True, - ) - - # For a sorted trajectory: x0 x1 x2 ... xN - # we assign the point at x1 the area (x1 - x0) / 2 + (x2 - x1) / 2, - # this is done by central differences (-1,0,1). - # For the edges, we append/prepend the values afterwards, such that: - # we assign the point at x0 the area (x1 - x0) / 2 + (x1 - x0) / 2, and - # we assign the point at xN the area (xN - xN-1) / 2 + (xN - xN-1) / 2. - - kernel = torch.tensor([-1 / 2, 0, 1 / 2], dtype=torch.float32, device=traj.device).reshape(1, 1, 3) - - if (elements := len(traj_sorted)) >= 3: - central_diff = torch.nn.functional.conv1d(traj_sorted[None, None, :], kernel)[0, 0] - first = traj_sorted[1] - traj_sorted[0] - last = traj_sorted[-1] - traj_sorted[-2] - central_diff = torch.cat((first[None], central_diff, last[None]), -1) - elif elements == 2: - diff = traj_sorted[1] - traj_sorted[0] - central_diff = torch.cat((diff[None], diff[None]), -1) - else: - central_diff = torch.ones_like(traj_sorted) - - # Repeated points are reduced by the number of repeats - dcf = torch.nan_to_num(central_diff / counts)[inverse] - return dcf - - @staticmethod - def _dcf_2d3d_voronoi(traj: torch.Tensor) -> torch.Tensor: - """Calculate sample density compensation function using voronoi method. - - Points at the edge of k-space are detected as outliers and assigned the - area of the 1% largest dcf values. - - Parameters - ---------- - traj - k-space positions (2 or 3, k2, k1, k0) - - Returns - ------- - density compensation values (1, k2, k1, k0) - """ - # 2D and 3D trajectories supported - dim = traj.shape[0] - if dim not in (2, 3): - raise ValueError(f'Only 2D or 3D trajectories supported, not {dim}D.') - - # Calculate dcf only for unique k-space positions - traj_dim = traj.shape - traj_numpy = np.round(traj.cpu().numpy(), decimals=UNIQUE_ROUNDING_DECIMALS) - traj_numpy = traj_numpy.reshape(dim, -1) - traj_unique, inverse, counts = np.unique(traj_numpy, return_inverse=True, return_counts=True, axis=1) - - # Especially in 3D, errors in the calculation of the convex hull can occur for edge points. To avoid this, - # the corner points of a cube bounding box are added here. The bounding box is chosen very large to ensure these - # edge points of the trajectory can still be accurately detected in the outlier detection further down. - furthest_corner = np.max(np.abs(traj_unique)) - corner_points = np.array(list(product([-1, 1], repeat=dim))) * furthest_corner * 10 - traj_extendend = np.concatenate((traj_unique, corner_points.transpose()), axis=1) - - # Carry out voronoi tessellation - vdiagram = Voronoi(traj_extendend.transpose()) - regions = [vdiagram.regions[r] for r in vdiagram.point_region[: -len(corner_points)]] # Ignore corner points - vertices = [vdiagram.vertices[region] for region in regions] - - if dim == 2: - # Shoelace equation for 2d - dcf = np.array([np.abs(np.cross(v[:-1], v[1:]).sum(0) + np.cross(v[-1], v[0])) / 2 for v in vertices]) - - else: - # Calculate volume/area of voronoi cells using processes, as this is a very time-consuming operation - # and ConvexHull is singlethreaded and does not seem to drop the GIL - # TODO: this could maybe be made faster as the polyhedrons are known to be convex - future = ProcessPoolExecutor(max_workers=torch.get_num_threads()).map(_volume, vertices, chunksize=100) - dcf = np.array(list(future)) - - # Get outliers (i.e. voronoi cell which are unbound) and set them to a reasonable value - # Outliers are defined as values larger than 1.5 * inter quartile range of the values - # Outliers are set to the average of the 1% largest values. - dcf_sorted = np.sort(dcf) - q1, q3 = np.percentile(dcf_sorted, [25, 75]) - iqr = q3 - q1 - upper_bound = q3 + 1.5 * iqr - idx_outliers = np.nonzero(dcf > upper_bound) - n_outliers = len(idx_outliers[0]) - high_values_start = int(0.99 * (len(dcf_sorted) - n_outliers)) - high_values_end = len(dcf_sorted) - n_outliers # this works also for n_outliers==0 - fill_value = np.average(dcf_sorted[high_values_start:high_values_end]) - dcf[idx_outliers] = fill_value - - # Sort dcf values back into the original order (i.e. before calling unique) - dcf = np.reshape((dcf / counts)[inverse], traj_dim[1:]) - - return torch.tensor(dcf, dtype=torch.float32, device=traj.device) - @classmethod def from_traj_voronoi(cls, traj: KTrajectory) -> Self: """Calculate dcf using voronoi approach for 2D or 3D trajectories. @@ -176,7 +57,7 @@ def from_traj_voronoi(cls, traj: KTrajectory) -> Self: if len(non_singleton_ks) == 1: # Found a dimension with only one non-singleton axes in ks # --> Can handle this as a 1D trajectory - dcfs.append(smap(DcfData._dcf_1d, non_singleton_ks.pop(), (dim,))) + dcfs.append(smap(dcf_1d, non_singleton_ks.pop(), (dim,))) elif len(non_singleton_ks) > 0: # More than one of the ks is non-singleton # --> A full dimension needing voronoi @@ -188,7 +69,7 @@ def from_traj_voronoi(cls, traj: KTrajectory) -> Self: if ks_needing_voronoi: # Handle full dimensions needing voronoi - dcfs.append(smap(DcfData._dcf_2d3d_voronoi, torch.stack(list(ks_needing_voronoi), -4), 4)) + dcfs.append(smap(dcf_2d3d_voronoi, torch.stack(list(ks_needing_voronoi), -4), 4)) if dcfs: # Multiply all dcfs together diff --git a/tests/algorithms/dcf/test_dcf_voronoi.py b/tests/algorithms/dcf/test_dcf_voronoi.py new file mode 100644 index 00000000..49e7e406 --- /dev/null +++ b/tests/algorithms/dcf/test_dcf_voronoi.py @@ -0,0 +1,157 @@ +"""Tests for algorithms to calculate the DCF with voronoi.""" + +# Copyright 2024 Physikalisch-Technische Bundesanstalt +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math + +import pytest +import torch +from mrpro.algorithms.dcf import dcf_1d, dcf_2d3d_voronoi +from mrpro.data import KTrajectory + + +def example_traj_rad_2d(n_kr, n_ka, phi0=0.0, broadcast=True): + """Create 2D radial trajectory with uniform angular gap.""" + krad = torch.linspace(-n_kr // 2, n_kr // 2 - 1, n_kr) / n_kr + kang = torch.linspace(0, n_ka - 1, n_ka) * (torch.pi / n_ka) + phi0 + kz = torch.zeros(1, 1, 1, 1) + ky = (torch.sin(kang[:, None]) * krad[None, :])[None, None, :, :] + kx = (torch.cos(kang[:, None]) * krad[None, :])[None, None, :, :] + trajectory = KTrajectory(kz, ky, kx, repeat_detection_tolerance=1e-8 if broadcast else None) + return trajectory + + +@pytest.mark.parametrize( + ('n_kr', 'n_ka', 'phi0', 'broadcast'), + [ + (100, 20, 0, True), + (100, 1, 0, True), + (100, 20, torch.pi / 4, True), + (100, 1, torch.pi / 4, True), + (100, 1, 0, False), + ], +) +def test_dcf_rad_traj_voronoi(n_kr, n_ka, phi0, broadcast): + """Compare voronoi-based dcf calculation for 2D radial trajectory to + analytical solution.""" + # 2D radial trajectory + traj = example_traj_rad_2d(n_kr, n_ka, phi0, broadcast) + trajectory = traj.as_tensor() + + if n_ka > 1: # only for for multiple spokes, analytical dcf is defined + dcf = dcf_2d3d_voronoi(trajectory[1:3, 0, ...]) + krad_idx = torch.linspace(-n_kr // 2, n_kr // 2 - 1, n_kr) + dcf_analytical = torch.pi / n_ka * torch.abs(krad_idx) * (1 / n_kr) ** 2 + dcf_analytical[krad_idx == 0] = 2 * torch.pi / n_ka * 1 / 8 * (1 / n_kr) ** 2 + dcf_analytical = torch.repeat_interleave(dcf_analytical[None, ...], n_ka, dim=0)[None, :, :] + # Do not test outer points because they have to be approximated and cannot be calculated + # accurately using voronoi + torch.testing.assert_close(dcf_analytical[:, :, 1:-1], dcf[:, :, 1:-1]) + else: + dcf = dcf_1d(trajectory[0, ...]) + dcf_ptp = dcf.max() - dcf.min() + assert dcf_ptp / dcf.max() < 0.1, 'DCF for a single spoke should be constant-ish' + assert dcf.sum() > 1e-3, 'DCF sum should not be zero' + assert dcf.shape == traj.broadcasted_shape, 'DCF shape should match broadcasted trajectory shape' + + +@pytest.mark.parametrize(('n_k2', 'n_k1', 'n_k0'), [(40, 16, 20), (1, 2, 2)]) +def test_dcf_3d_cart_traj_broadcast_voronoi(n_k2, n_k1, n_k0): + """Compare voronoi-based dcf calculation for broadcasted 3D regular + Cartesian trajectory to analytical solution which is 1 for each k-space + point.""" + # 3D trajectory with points on Cartesian grid with step size of 1 + kx = torch.linspace(-n_k0 // 2, n_k0 // 2 - 1, n_k0)[None, None, None, :] + ky = torch.linspace(-n_k1 // 2, n_k1 // 2 - 1, n_k1)[None, None, :, None] + kz = torch.linspace(-n_k2 // 2, n_k2 // 2 - 1, n_k2)[None, :, None, None] + trajectory = KTrajectory(kx, ky, kz) + + # Analytical dcf + dcf_analytical = torch.ones((1, n_k2, n_k1, n_k0)) + # calculate dcf + dcf = dcf_2d3d_voronoi(trajectory.as_tensor()) + # Do not test outer points because they have to be approximated and cannot be calculated + # accurately using voronoi. + torch.testing.assert_close(dcf[:, 1:-1, 1:-1, 1:-1], dcf_analytical[:, 1:-1, 1:-1, 1:-1]) + + +@pytest.mark.parametrize(('n_k2', 'n_k1', 'n_k0'), [(40, 16, 20), (1, 2, 2)]) +def test_dcf_3d_cart_full_traj_voronoi(n_k2, n_k1, n_k0): + """Compare voronoi-based dcf calculation for full 3D regular Cartesian + trajectory to analytical solution which is 1 for each k-space point.""" + # 3D trajectory with points on Cartesian grid with step size of 1 + ky, kz, kx = torch.meshgrid( + torch.linspace(-n_k1 // 2, n_k1 // 2 - 1, n_k1), + torch.linspace(-n_k2 // 2, n_k2 // 2 - 1, n_k2), + torch.linspace(-n_k0 // 2, n_k0 // 2 - 1, n_k0), + indexing='xy', + ) + trajectory = KTrajectory(kz[None, ...], ky[None, ...], kx[None, ...], repeat_detection_tolerance=None) + # Analytical dcf + dcf_analytical = torch.ones((1, n_k2, n_k1, n_k0)) + dcf = dcf_2d3d_voronoi(trajectory.as_tensor()) + # Do not test outer points because they have to be approximated and cannot be calculated + # accurately using voronoi + torch.testing.assert_close(dcf[:, 1:-1, 1:-1, 1:-1], dcf_analytical[:, 1:-1, 1:-1, 1:-1]) + + +@pytest.mark.parametrize( + ('n_k2', 'n_k1', 'n_k0', 'k2_steps', 'k1_steps', 'k0_steps'), + [(30, 20, 10, (1.0, 0.5, 0.25), (1.0, 0.5), (1.0,))], +) +def test_dcf_3d_cart_nonuniform_traj_voronoi(n_k2, n_k1, n_k0, k2_steps, k1_steps, k0_steps): + """Compare voronoi-based dcf calculation for 3D nonuniform Cartesian + trajectory to analytical solution which is 1 for each k-space point.""" + + def k_range(n: int, *steps: float): + """Create a tensor with n values, steps apart.""" + r = torch.tensor(steps).repeat(math.ceil(n / len(steps))).ravel()[:n] + r = torch.cumsum(r, 0) + r -= r.mean() + return r + + k0_range = k_range(n_k0, *k0_steps) + k1_range = k_range(n_k1, *k1_steps) + k2_range = k_range(n_k2, *k2_steps) + + ky_full, kz_full, kx_full = torch.meshgrid(k1_range, k2_range, k0_range, indexing='xy') + trajectory_full = KTrajectory( + kz_full[None, ...], + ky_full[None, ...], + kx_full[None, ...], + repeat_detection_tolerance=None, + ) + + kx_broadcast = k0_range[None, None, :] + ky_broadcast = k1_range[None, :, None] + kz_broadcast = k2_range[:, None, None] + trajectory_broadcast = KTrajectory( + kz_broadcast[None, ...], + ky_broadcast[None, ...], + kx_broadcast[None, ...], + repeat_detection_tolerance=None, + ) + + # Sanity check inputs + torch.testing.assert_close(trajectory_full.as_tensor(), trajectory_broadcast.as_tensor()) + assert trajectory_full.broadcasted_shape == (1, len(k2_range), len(k1_range), len(k0_range)) + torch.testing.assert_close(kx_full[0, 0, :], k0_range) # kx changes along k0 + torch.testing.assert_close(ky_full[0, :, 0], k1_range) # ky changes along k1 + torch.testing.assert_close(kz_full[:, 0, 0], k2_range) # kz changes along k2 + + dcf_full = dcf_2d3d_voronoi(trajectory_full.as_tensor()[1:3, 0, ...]) + dcf_broadcast = dcf_2d3d_voronoi(trajectory_broadcast.as_tensor()[1:3, 0, ...]) + + # Do not test outer points because they have to be approximated and cannot be calculated + # accurately using voronoi + torch.testing.assert_close(dcf_full[1:-1, 1:-1, 1:-1], dcf_broadcast[1:-1, 1:-1, 1:-1]) diff --git a/tests/data/test_dcf_data.py b/tests/data/test_dcf_data.py index 1debf978..8ef2d8bf 100644 --- a/tests/data/test_dcf_data.py +++ b/tests/data/test_dcf_data.py @@ -1,6 +1,6 @@ """Tests for DcfData class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt +# Copyright 2024 Physikalisch-Technische Bundesanstalt # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,8 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import math - import pytest import torch from mrpro.data import DcfData, KTrajectory @@ -30,17 +28,6 @@ def example_traj_rpe(n_kr, n_ka, n_k0, broadcast=True): return trajectory -def example_traj_rad_2d(n_kr, n_ka, phi0=0.0, broadcast=True): - """Create 2D radial trajectory with uniform angular gap.""" - krad = torch.linspace(-n_kr // 2, n_kr // 2 - 1, n_kr) / n_kr - kang = torch.linspace(0, n_ka - 1, n_ka) * (torch.pi / n_ka) + phi0 - kz = torch.zeros(1, 1, 1, 1) - ky = (torch.sin(kang[:, None]) * krad[None, :])[None, None, :, :] - kx = (torch.cos(kang[:, None]) * krad[None, :])[None, None, :, :] - trajectory = KTrajectory(kz, ky, kx, repeat_detection_tolerance=1e-8 if broadcast else None) - return trajectory - - def example_traj_spiral_2d(n_kr, n_ki, n_ka, broadcast=True) -> KTrajectory: """Create 2D spiral trajectory with n_kr points along each spiral arm, n_ki turns per spiral arm and n_ka spiral arms.""" @@ -53,132 +40,6 @@ def example_traj_spiral_2d(n_kr, n_ki, n_ka, broadcast=True) -> KTrajectory: return trajectory -@pytest.mark.parametrize( - ('n_kr', 'n_ka', 'phi0', 'broadcast'), - [ - (100, 20, 0, True), - (100, 1, 0, True), - (100, 20, torch.pi / 4, True), - (100, 1, torch.pi / 4, True), - (100, 1, 0, False), - ], -) -def test_dcf_2d_rad_traj_voronoi(n_kr, n_ka, phi0, broadcast): - """Compare voronoi-based dcf calculation for 2D radial trajectory to - analytical solution.""" - # 2D radial trajectory - trajectory = example_traj_rad_2d(n_kr, n_ka, phi0, broadcast) - - # calculate dcf - dcf = DcfData.from_traj_voronoi(trajectory) - - if n_ka > 1: # only for for multiple spokes, analytical dcf is defined - krad_idx = torch.linspace(-n_kr // 2, n_kr // 2 - 1, n_kr) - dcf_analytical = torch.pi / n_ka * torch.abs(krad_idx) * (1 / n_kr) ** 2 - dcf_analytical[krad_idx == 0] = 2 * torch.pi / n_ka * 1 / 8 * (1 / n_kr) ** 2 - dcf_analytical = torch.repeat_interleave(dcf_analytical[None, ...], n_ka, dim=0)[None, None, :, :] - # Do not test outer points because they have to be approximated and cannot be calculated - # accurately using voronoi - torch.testing.assert_close(dcf_analytical[:, :, :, 1:-1], dcf.data[:, :, :, 1:-1]) - else: - dcf_ptp = dcf.data.max() - dcf.data.min() - assert dcf_ptp / dcf.data.max() < 0.1, 'DCF for a single spoke should be constant-ish' - assert dcf.data.sum() > 1e-3, 'DCF sum should not be zero' - assert dcf.data.shape == trajectory.broadcasted_shape, 'DCF shape should match broadcasted trajectory shape' - - -@pytest.mark.parametrize(('n_k2', 'n_k1', 'n_k0'), [(40, 16, 20), (1, 2, 2)]) -def test_dcf_3d_cart_traj_broadcast_voronoi(n_k2, n_k1, n_k0): - """Compare voronoi-based dcf calculation for broadcasted 3D regular - Cartesian trajectory to analytical solution which is 1 for each k-space - point.""" - # 3D trajectory with points on Cartesian grid with step size of 1 - kx = torch.linspace(-n_k0 // 2, n_k0 // 2 - 1, n_k0)[None, None, None, :] - ky = torch.linspace(-n_k1 // 2, n_k1 // 2 - 1, n_k1)[None, None, :, None] - kz = torch.linspace(-n_k2 // 2, n_k2 // 2 - 1, n_k2)[None, :, None, None] - trajectory = KTrajectory(kx, ky, kz) - - # Analytical dcf - dcf_analytical = torch.ones((1, n_k2, n_k1, n_k0)) - dcf = DcfData.from_traj_voronoi(trajectory) - # Do not test outer points because they have to be approximated and cannot be calculated - # accurately using voronoi. - torch.testing.assert_close(dcf.data[:, 1:-1, 1:-1, 1:-1], dcf_analytical[:, 1:-1, 1:-1, 1:-1]) - - -@pytest.mark.parametrize(('n_k2', 'n_k1', 'n_k0'), [(40, 16, 20), (1, 2, 2)]) -def test_dcf_3d_cart_full_traj_voronoi(n_k2, n_k1, n_k0): - """Compare voronoi-based dcf calculation for full 3D regular Cartesian - trajectory to analytical solution which is 1 for each k-space point.""" - # 3D trajectory with points on Cartesian grid with step size of 1 - ky, kz, kx = torch.meshgrid( - torch.linspace(-n_k1 // 2, n_k1 // 2 - 1, n_k1), - torch.linspace(-n_k2 // 2, n_k2 // 2 - 1, n_k2), - torch.linspace(-n_k0 // 2, n_k0 // 2 - 1, n_k0), - indexing='xy', - ) - trajectory = KTrajectory(kz[None, ...], ky[None, ...], kx[None, ...], repeat_detection_tolerance=None) - - # Analytical dcf - dcf_analytical = torch.ones((1, n_k2, n_k1, n_k0)) - dcf = DcfData.from_traj_voronoi(trajectory) - # Do not test outer points because they have to be approximated and cannot be calculated - # accurately using voronoi - torch.testing.assert_close(dcf.data[:, 1:-1, 1:-1, 1:-1], dcf_analytical[:, 1:-1, 1:-1, 1:-1]) - - -@pytest.mark.parametrize( - ('n_k2', 'n_k1', 'n_k0', 'k2_steps', 'k1_steps', 'k0_steps'), - [(30, 20, 10, (1.0, 0.5, 0.25), (1.0, 0.5), (1.0,))], -) -def test_dcf_3d_cart_nonuniform_traj_voronoi(n_k2, n_k1, n_k0, k2_steps, k1_steps, k0_steps): - """Compare voronoi-based dcf calculation for 3D nonunifrm Cartesian - trajectory to analytical solution which is 1 for each k-space point.""" - - def k_range(n: int, *steps: float): - """Create a tensor with n values, steps apart.""" - r = torch.tensor(steps).repeat(math.ceil(n / len(steps))).ravel()[:n] - r = torch.cumsum(r, 0) - r -= r.mean() - return r - - k0_range = k_range(n_k0, *k0_steps) - k1_range = k_range(n_k1, *k1_steps) - k2_range = k_range(n_k2, *k2_steps) - - ky_full, kz_full, kx_full = torch.meshgrid(k1_range, k2_range, k0_range, indexing='xy') - trajectory_full = KTrajectory( - kz_full[None, ...], - ky_full[None, ...], - kx_full[None, ...], - repeat_detection_tolerance=None, - ) - - kx_broadcast = k0_range[None, None, :] - ky_broadcast = k1_range[None, :, None] - kz_broadcast = k2_range[:, None, None] - trajectory_broadcast = KTrajectory( - kz_broadcast[None, ...], - ky_broadcast[None, ...], - kx_broadcast[None, ...], - repeat_detection_tolerance=None, - ) - - # Sanity check inputs - torch.testing.assert_close(trajectory_full.as_tensor(), trajectory_broadcast.as_tensor()) - assert trajectory_full.broadcasted_shape == (1, len(k2_range), len(k1_range), len(k0_range)) - torch.testing.assert_close(kx_full[0, 0, :], k0_range) # kx changes along k0 - torch.testing.assert_close(ky_full[0, :, 0], k1_range) # ky changes along k1 - torch.testing.assert_close(kz_full[:, 0, 0], k2_range) # kz changes along k2 - - dcf_full = DcfData.from_traj_voronoi(trajectory_full) - dcf_broadcast = DcfData.from_traj_voronoi(trajectory_broadcast) - - # Do not test outer points because they have to be approximated and cannot be calculated - # accurately using voronoi - torch.testing.assert_close(dcf_full.data[:, 1:-1, 1:-1, 1:-1], dcf_broadcast.data[:, 1:-1, 1:-1, 1:-1]) - - @pytest.mark.parametrize(('n_kr', 'n_ka', 'n_k0'), [(10, 6, 20), (10, 1, 20), (10, 6, 1)]) def test_dcf_rpe_traj_voronoi(n_kr, n_ka, n_k0): """Voronoi-based dcf calculation for RPE trajectory.""" From ae60c0a9b2311f48ad0c1c4f5a7f7a91f171b954 Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Wed, 10 Jul 2024 17:35:08 +0200 Subject: [PATCH 12/34] Add parameter/attribute doc strings for data classes (#365) Co-authored-by: Patrick Schuenke <37338697+schuenke@users.noreply.github.com> --- src/mrpro/data/AcqInfo.py | 77 ++++++++++++++++++++++++++++++++ src/mrpro/data/Data.py | 3 ++ src/mrpro/data/EncodingLimits.py | 40 +++++++++++++++++ src/mrpro/data/IData.py | 1 + src/mrpro/data/IHeader.py | 11 +++++ src/mrpro/data/KHeader.py | 55 +++++++++++++++++++++++ src/mrpro/data/KNoise.py | 9 +--- src/mrpro/data/QData.py | 1 + src/mrpro/data/QHeader.py | 1 + src/mrpro/data/_kdata/KData.py | 5 +++ 10 files changed, 196 insertions(+), 7 deletions(-) diff --git a/src/mrpro/data/AcqInfo.py b/src/mrpro/data/AcqInfo.py index 2b472cf8..1fbbc1c3 100644 --- a/src/mrpro/data/AcqInfo.py +++ b/src/mrpro/data/AcqInfo.py @@ -31,22 +31,55 @@ class AcqIdx(MoveDataMixin): """Acquisition index for each readout.""" k1: torch.Tensor + """First phase encoding.""" + k2: torch.Tensor + """Second phase encoding.""" + average: torch.Tensor + """Signal average.""" + slice: torch.Tensor + """Slice number (multi-slice 2D).""" + contrast: torch.Tensor + """Echo number in multi-echo.""" + phase: torch.Tensor + """Cardiac phase.""" + repetition: torch.Tensor + """Counter in repeated/dynamic acquisitions.""" + set: torch.Tensor + """Sets of different preparation, e.g. flow encoding, diffusion weighting.""" + segment: torch.Tensor + """Counter for segmented acquisitions.""" + user0: torch.Tensor + """User index 0.""" + user1: torch.Tensor + """User index 1.""" + user2: torch.Tensor + """User index 2.""" + user3: torch.Tensor + """User index 3.""" + user4: torch.Tensor + """User index 4.""" + user5: torch.Tensor + """User index 5.""" + user6: torch.Tensor + """User index 6.""" + user7: torch.Tensor + """User index 7.""" @dataclass(slots=True) @@ -54,33 +87,77 @@ class AcqInfo(MoveDataMixin): """Acquisition information for each readout.""" idx: AcqIdx + """Indices describing acquisitions (i.e. readouts).""" + acquisition_time_stamp: torch.Tensor + """Clock time stamp (e.g. milliseconds since midnight).""" + active_channels: torch.Tensor + """Number of active receiver coil elements.""" + available_channels: torch.Tensor + """Number of available receiver coil elements.""" + center_sample: torch.Tensor + """Index of the readout sample corresponding to k-space center (zero indexed).""" + channel_mask: torch.Tensor + """Bit mask indicating active coils (64*16 = 1024 bits).""" + discard_post: torch.Tensor + """Number of readout samples to be discarded at the end (e.g. if the ADC is active during gradient events).""" + discard_pre: torch.Tensor + """Number of readout samples to be discarded at the beginning (e.g. if the ADC is active during gradient events)""" + encoding_space_ref: torch.Tensor + """Indexed reference to the encoding spaces enumerated in the MRD (xml) header.""" + flags: torch.Tensor + """A bit mask of common attributes applicable to individual acquisition readouts.""" + measurement_uid: torch.Tensor + """Unique ID corresponding to the readout.""" number_of_samples: torch.Tensor """Number of readout sample points per readout (readouts may have different number of sample points).""" patient_table_position: SpatialDimension[torch.Tensor] + """Offset position of the patient table, in LPS coordinates.""" + phase_dir: SpatialDimension[torch.Tensor] + """Directional cosine of phase encoding (2D).""" + physiology_time_stamp: torch.Tensor + """Time stamps relative to physiological triggering, e.g. ECG, pulse oximetry, respiratory.""" + position: SpatialDimension[torch.Tensor] + """Center of the excited volume, in LPS coordinates relative to isocenter in millimeters.""" + read_dir: SpatialDimension[torch.Tensor] + """Directional cosine of readout/frequency encoding.""" + sample_time_us: torch.Tensor + """Readout bandwidth, as time between samples in microseconds.""" + scan_counter: torch.Tensor + """Zero-indexed incrementing counter for readouts.""" + slice_dir: SpatialDimension[torch.Tensor] + """Directional cosine of slice normal, i.e. cross-product of read_dir and phase_dir.""" + trajectory_dimensions: torch.Tensor # =3. We only support 3D Trajectories: kz always exists. + """Dimensionality of the k-space trajectory vector.""" + user_float: torch.Tensor + """User-defined float parameters.""" + user_int: torch.Tensor + """User-defined int parameters.""" + version: torch.Tensor + """Major version number.""" @classmethod def from_ismrmrd_acquisitions(cls, acquisitions: Sequence[ismrmrd.Acquisition]) -> Self: diff --git a/src/mrpro/data/Data.py b/src/mrpro/data/Data.py index e176440c..28156c6f 100644 --- a/src/mrpro/data/Data.py +++ b/src/mrpro/data/Data.py @@ -28,4 +28,7 @@ class Data(MoveDataMixin, ABC): """A general data class with field data and header.""" data: torch.Tensor + """Data. Shape (...other coils k2 k1 k0)""" + header: Any + """Header information for data.""" diff --git a/src/mrpro/data/EncodingLimits.py b/src/mrpro/data/EncodingLimits.py index 54705217..69da95ff 100644 --- a/src/mrpro/data/EncodingLimits.py +++ b/src/mrpro/data/EncodingLimits.py @@ -26,8 +26,13 @@ class Limits: """Limits dataclass with min, max, and center attributes.""" min: int = 0 + """Lower boundary.""" + max: int = 0 + """Upper boundary.""" + center: int = 0 + """Center.""" @classmethod def from_ismrmrd(cls, limit_type: limitType) -> Self: @@ -55,23 +60,58 @@ class EncodingLimits: """ k0: Limits = dataclasses.field(default_factory=Limits) + """First k-space encoding.""" + k1: Limits = dataclasses.field(default_factory=Limits) + """Second k-space encoding.""" + k2: Limits = dataclasses.field(default_factory=Limits) + """Third k-space encoding.""" + average: Limits = dataclasses.field(default_factory=Limits) + """Signal average.""" + slice: Limits = dataclasses.field(default_factory=Limits) + """Slice number (multi-slice 2D).""" + contrast: Limits = dataclasses.field(default_factory=Limits) + """Echo number in multi-echo.""" + phase: Limits = dataclasses.field(default_factory=Limits) + """Cardiac phase.""" + repetition: Limits = dataclasses.field(default_factory=Limits) + """Repeated/dynamic acquisitions.""" + set: Limits = dataclasses.field(default_factory=Limits) + """Sets of different preparation.""" + segment: Limits = dataclasses.field(default_factory=Limits) + """Segments of segmented acquisition.""" + user_0: Limits = dataclasses.field(default_factory=Limits) + """User index 0.""" + user_1: Limits = dataclasses.field(default_factory=Limits) + """User index 1.""" + user_2: Limits = dataclasses.field(default_factory=Limits) + """User index 2.""" + user_3: Limits = dataclasses.field(default_factory=Limits) + """User index 3.""" + user_4: Limits = dataclasses.field(default_factory=Limits) + """User index 4.""" + user_5: Limits = dataclasses.field(default_factory=Limits) + """User index 5.""" + user_6: Limits = dataclasses.field(default_factory=Limits) + """User index 6.""" + user_7: Limits = dataclasses.field(default_factory=Limits) + """User index 7.""" @classmethod def from_ismrmrd_encoding_limits_type(cls, encoding_limits: encodingLimitsType): diff --git a/src/mrpro/data/IData.py b/src/mrpro/data/IData.py index 210decc7..f1478f9c 100644 --- a/src/mrpro/data/IData.py +++ b/src/mrpro/data/IData.py @@ -65,6 +65,7 @@ class IData(Data): """MR image data (IData) class.""" header: IHeader + """Header for image data.""" def rss(self, keepdim: bool = False) -> torch.Tensor: """Root-sum-of-squares over coils image data. diff --git a/src/mrpro/data/IHeader.py b/src/mrpro/data/IHeader.py index 1b7dba69..af8c6426 100644 --- a/src/mrpro/data/IHeader.py +++ b/src/mrpro/data/IHeader.py @@ -37,11 +37,22 @@ class IHeader(MoveDataMixin): # ToDo: decide which attributes to store in the header fov: SpatialDimension[float] + """Field of view.""" + te: torch.Tensor | None + """Echo time.""" + ti: torch.Tensor | None + """Inversion time.""" + fa: torch.Tensor | None + """Flip angle.""" + tr: torch.Tensor | None + """Repetition time.""" + misc: dict = dataclasses.field(default_factory=dict) + """Dictionary with miscellaneous parameters.""" @classmethod def from_kheader(cls, kheader: KHeader) -> Self: diff --git a/src/mrpro/data/KHeader.py b/src/mrpro/data/KHeader.py index 0306b216..a00c30b0 100644 --- a/src/mrpro/data/KHeader.py +++ b/src/mrpro/data/KHeader.py @@ -50,33 +50,88 @@ class KHeader(MoveDataMixin): """ trajectory: KTrajectoryCalculator + """Function to calculate the k-space trajectory.""" + b0: float + """Magnetic field strength.""" + encoding_limits: EncodingLimits + """K-space encoding limits.""" + recon_matrix: SpatialDimension[int] + """Dimensions of the reconstruction matrix.""" + recon_fov: SpatialDimension[float] + """Field-of-view of the reconstructed image.""" + encoding_matrix: SpatialDimension[int] + """Dimensions of the encoded k-space matrix.""" + encoding_fov: SpatialDimension[float] + """Field of view of the image encoded by the k-space trajectory.""" + acq_info: AcqInfo + """Information of the acquisitions (i.e. readout lines).""" + h1_freq: float + """Lamor frequency of hydrogen nuclei.""" + n_coils: int | None = None + """Number of receiver coils.""" + datetime: datetime.datetime | None = None + """Date and time of acquisition.""" + te: torch.Tensor | None = None + """Echo time.""" + ti: torch.Tensor | None = None + """Inversion time.""" + fa: torch.Tensor | None = None + """Flip angle.""" + tr: torch.Tensor | None = None + """Repetition time.""" + echo_spacing: torch.Tensor | None = None + """Echo spacing.""" + echo_train_length: int = 1 + """Number of echoes in a multi-echo acquisition.""" + seq_type: str = UNKNOWN + """Type of sequence.""" + model: str = UNKNOWN + """Scanner model.""" + vendor: str = UNKNOWN + """Scanner vendor.""" + protocol_name: str = UNKNOWN + """Name of the acquisition protocol.""" + misc: dict = dataclasses.field(default_factory=dict) # do not use {} here! + """Dictionary with miscellaneous parameters.""" + calibration_mode: enums.CalibrationMode = enums.CalibrationMode.OTHER + """Mode of how calibration data is acquired. """ + interleave_dim: enums.InterleavingDimension = enums.InterleavingDimension.OTHER + """Interleaving dimension.""" + traj_type: enums.TrajectoryType = enums.TrajectoryType.OTHER + """Type of trajectory.""" + measurement_id: str = UNKNOWN + """Measurement ID.""" + patient_name: str = UNKNOWN + """Name of the patient.""" + trajectory_description: TrajectoryDescription = dataclasses.field(default_factory=TrajectoryDescription) + """Description of the trajectory.""" @property def fa_degree(self) -> torch.Tensor | None: diff --git a/src/mrpro/data/KNoise.py b/src/mrpro/data/KNoise.py index d77ecebf..cb297e79 100644 --- a/src/mrpro/data/KNoise.py +++ b/src/mrpro/data/KNoise.py @@ -29,15 +29,10 @@ @dataclasses.dataclass(slots=True, frozen=True) class KNoise(MoveDataMixin): - """MR raw data / k-space data class for noise measurements. - - Attributes - ---------- - data - k-space data of noise measurements as complex tensor - """ + """MR raw data / k-space data class for noise measurements.""" data: torch.Tensor + """K-space data of noise measurements. Shape (...other coils k2 k1 k0)""" @classmethod def from_file( diff --git a/src/mrpro/data/QData.py b/src/mrpro/data/QData.py index 4964f980..cae64f92 100644 --- a/src/mrpro/data/QData.py +++ b/src/mrpro/data/QData.py @@ -34,6 +34,7 @@ class QData(Data): """MR quantitative data (QData) class.""" header: QHeader + """Header describing quantitative data.""" def __init__(self, data: torch.Tensor, header: KHeader | IHeader | QHeader) -> None: """Create QData object from a tensor and an arbitrary MRpro header. diff --git a/src/mrpro/data/QHeader.py b/src/mrpro/data/QHeader.py index 791f3f3f..329b3e9c 100644 --- a/src/mrpro/data/QHeader.py +++ b/src/mrpro/data/QHeader.py @@ -32,6 +32,7 @@ class QHeader(MoveDataMixin): # ToDo: decide which attributes to store in the header fov: SpatialDimension[float] + """Field of view.""" @classmethod def from_iheader(cls, iheader: IHeader) -> Self: diff --git a/src/mrpro/data/_kdata/KData.py b/src/mrpro/data/_kdata/KData.py index 04690c1e..30511d8a 100644 --- a/src/mrpro/data/_kdata/KData.py +++ b/src/mrpro/data/_kdata/KData.py @@ -79,8 +79,13 @@ class KData(KDataSplitMixin, KDataRearrangeMixin, KDataSelectMixin, KDataRemoveO """MR raw data / k-space data class.""" header: KHeader + """Header information for k-space data""" + data: torch.Tensor + """K-space data. Shape (...other coils k2 k1 k0)""" + traj: KTrajectory + """K-space trajectory along kz, ky and kx. Shape (...other k2 k1 k0)""" @classmethod def from_file( From b0b5a4f2ba964994a029d8b4611086f0df6b0c40 Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Thu, 11 Jul 2024 09:08:23 +0200 Subject: [PATCH 13/34] Run pytest also for changes to .md (#366) Pytest needs to complete before any merge and hence it also needs to run for changes to md-files. --- .github/workflows/pytest.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 9edc7fef..c3b420cb 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -2,8 +2,6 @@ name: PyTest on: pull_request: - paths-ignore: - - "**.md" jobs: get_dockerfiles: From bdf849c7e876d6ec96810a43479ae64a24bd0956 Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Thu, 11 Jul 2024 09:29:06 +0200 Subject: [PATCH 14/34] Update readme (#362) Co-authored-by: Patrick Schuenke --- README.md | 89 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 08e10784..3a518c8a 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,90 @@ # MRpro -MR image reconstruction and processing package specifically developed for PyTorch. - -This package supports ismrmrd-format for MR raw data. All data containers utilize PyTorch tensors to ensure easy integration in PyTorch-based network schemes. - ![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12-blue) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) ![Coverage Bagde](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/ckolbPTB/48e334a10caf60e6708d7c712e56d241/raw/coverage.json) -If you want to give MRpro a try you can use -[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/PTB-MR/mrpro.git/main?labpath=examples) +MR image reconstruction and processing package specifically developed for PyTorch. + +- **Source code:** +- **Documentation:** +- **Bug reports:** +- **Try it out:** [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/PTB-MR/mrpro.git/main?labpath=examples) + +## Awards + +- 2024 ISMRM QMRI Study Group Challenge, 2nd prize for Relaxometry (T2* and T1) + +## Main features + +- **ISMRMRD support** MRpro supports [ismrmrd-format](https://ismrmrd.readthedocs.io/en/latest/) for MR raw data. +- **PyTorch** All data containers utilize PyTorch tensors to ensure easy integration in PyTorch-based network schemes. +- **Cartesian and non-Cartesian trajectories** MRpro can reconstruct data obtained with Cartesian and non-Cartesian (e.g. radial, spiral...) sapling schemes. MRpro automatically detects if FFT or nuFFT is required to reconstruction the k-space data. +- **Pulseq support** If the data acquisition was carried out using a [pulseq-based](http://pulseq.github.io/) sequence, the seq-file can be provided to MRpro and the used trajectory is automatically calculated. +- **Signal models** A range of different MR signal models are implemented (e.g. T1 recovery, WASABI). +- **Regularised image reconstruction** Regularised image reconstruction algorithms including Wavelet-based compressed sensing or total variation regularised image reconstruction are available. + +## Examples + +In the following we show some code snippets to highlight the use of MRpro. Each code snippet only shows the main steps. A complete working notebook can be found in the provided link. + +### Simple reconstruction -You find the documentation [here](https://ptb-mr.github.io/mrpro/) +Read in the data and trajectoy and reconstruct an image by applying a density compensation function and then the adjoint of the Fourier operator and the adjoint of the coil sensitivity operator. + +```python +# Read the trajectory from the ISMRMRD file +trajectory = mrpro.data.traj_calculators.KTrajectoryIsmrmrd() +# Load in the Data from the ISMRMRD file +kdata = mrpro.data.KData.from_file(data_file.name, trajectory) +# Perform the reconstruction +reconstruction = mrpro.algorithms.reconstruction.DirectReconstruction.from_kdata(kdata) +img = reconstruction(kdata) +``` + +Full example: + +### Estimate quantitative parameters + +Quantitative parameter maps can be obtained by creating a functional to be minimised and calling a non-linear solver such as ADAM. + +```python +# Define signal model +model = MagnitudeOp() @ InversionRecovery(ti=idata_multi_ti.header.ti) +# Define loss function and combine with signal model +mse = MSEDataDiscrepancy(idata_multi_ti.data.abs()) +functional = mse @ model +[...] +# Run optimization +params_result = adam(functional, [m0_start, t1_start], max_iter=max_iter, lr=lr) +``` + +Full example: + +### Pulseq support + +The trajectory can be calculated directly from a provided pulseq-file. + +```python +# Read raw data and calculate trajectory using KTrajectoryPulseq +kdata = KData.from_file(data_file.name, KTrajectoryPulseq(seq_path=seq_file.name)) +``` + +Full example: ## Contributing ### Installation for developers -1. Clone the repo +1. Clone the MRpro repository 2. Create/select a python environment -3. Open a terminal in the "MRpro" main folder -4. Install "MRpro" in editable mode with linting and testing: ``` pip install -e ".[lint,test]" ``` -5. Setup Pre-Commit Hook: ``` pre-commit install ``` +3. Install "MRpro" in editable mode including test dependencies: ``` pip install -e ".[test]" ``` +4. Setup pre-commit hook: ``` pre-commit install ``` ### Recommended IDE and Extensions -We recommend to use [Microsoft Visual Studio Code](https://code.visualstudio.com/download). - -A list of recommended extensions for VSCode is given in the [.vscode/extensions.json](.vscode\extensions.json) - +We recommend to use [Microsoft Visual Studio Code](https://code.visualstudio.com/download). A list of recommended extensions for VSCode is given in the [.vscode/extensions.json](.vscode\extensions.json) -### Documentation +### Style -Please have a look at our [contributor guide](https://ptb-mr.github.io/mrpro/contributor_guide.html) for more information on the structure of the repository, naming conventions and other useful information. +Please have a look at our [contributor guide](https://ptb-mr.github.io/mrpro/contributor_guide.html) for more information on the structure of the repository, naming conventions and other useful information. From 9b3a2722633fe1e22cd594810ce1f3b3a903b2ec Mon Sep 17 00:00:00 2001 From: Felix F Zimmermann Date: Tue, 16 Jul 2024 16:04:33 +0200 Subject: [PATCH 15/34] Add dtype promotion of result in separable filter (#342) Fixes #341 Now filtering should work with any mix of dtypes: kernel and data are first promoted to the result type. Thus, for example, filtering a complex128 iamge with integer kernel will result in complex128 output. --- src/mrpro/algorithms/csm/iterative_walsh.py | 2 +- src/mrpro/operators/FiniteDifferenceOp.py | 6 +- src/mrpro/utils/filters.py | 81 +++++++++++---------- tests/utils/test_filters.py | 19 ++++- 4 files changed, 63 insertions(+), 45 deletions(-) diff --git a/src/mrpro/algorithms/csm/iterative_walsh.py b/src/mrpro/algorithms/csm/iterative_walsh.py index 22aeb4fd..69e87077 100644 --- a/src/mrpro/algorithms/csm/iterative_walsh.py +++ b/src/mrpro/algorithms/csm/iterative_walsh.py @@ -54,7 +54,7 @@ def iterative_walsh( coil_covariance = torch.einsum('azyx,bzyx->abzyx', coil_images, coil_images.conj()) # Smooth the covariance along y-x for 2D and z-y-x for 3D data - coil_covariance = uniform_filter(coil_covariance, width=smoothing_width.zyx, axis=(-3, -2, -1)) + coil_covariance = uniform_filter(coil_covariance, width=smoothing_width.zyx, dim=(-3, -2, -1)) # At each point in the image, find the dominant eigenvector # of the signal covariance matrix using the power method diff --git a/src/mrpro/operators/FiniteDifferenceOp.py b/src/mrpro/operators/FiniteDifferenceOp.py index 867f3059..2a317d9d 100644 --- a/src/mrpro/operators/FiniteDifferenceOp.py +++ b/src/mrpro/operators/FiniteDifferenceOp.py @@ -99,8 +99,8 @@ def forward(self, x: torch.Tensor) -> tuple[torch.Tensor,]: return ( torch.stack( [ - filter_separable(x, (self.kernel,), axis=(d,), pad_mode=self.pad_mode, pad_value=0.0) - for d in self.dim + filter_separable(x, (self.kernel,), dim=(dim,), pad_mode=self.pad_mode, pad_value=0.0) + for dim in self.dim ] ), ) @@ -132,7 +132,7 @@ def adjoint(self, y: torch.Tensor) -> tuple[torch.Tensor,]: filter_separable( yi, (torch.flip(self.kernel, dims=(-1,)),), - axis=(dim,), + dim=(dim,), pad_mode=self.pad_mode, pad_value=0.0, ) diff --git a/src/mrpro/utils/filters.py b/src/mrpro/utils/filters.py index 8e45e6bf..237ccb35 100644 --- a/src/mrpro/utils/filters.py +++ b/src/mrpro/utils/filters.py @@ -16,6 +16,7 @@ import warnings from collections.abc import Sequence +from functools import reduce from math import ceil from typing import Literal @@ -26,11 +27,11 @@ def filter_separable( x: torch.Tensor, kernels: Sequence[torch.Tensor], - axis: Sequence[int], + dim: Sequence[int], pad_mode: Literal['constant', 'reflect', 'replicate', 'circular', 'none'] = 'constant', pad_value: float = 0.0, ) -> torch.Tensor: - """Apply the separable filter kernels to the tensor x along the axes axis. + """Apply the separable filter kernels to the tensor x along the axes dim. Does padding to keep the output the same size as the input. @@ -40,20 +41,25 @@ def filter_separable( Tensor to filter kernels List of 1D kernels to apply to the tensor x - axis + dim Axes to filter over. Must have the same length as kernels. pad_mode Padding mode pad_value Padding value for pad_mode = constant + + Returns + ------- + The filtered tensor, with the same shape as the input unless pad_mode is 'none' and + and promoted dtype of the input and the kernels. """ - if len(axis) != len(kernels): - raise ValueError('Must provide matching length kernels and axis arguments.') + if len(dim) != len(kernels): + raise ValueError('Must provide matching length kernels and dim arguments.') - # normalize axis to allow negative indexing in input - axis = tuple([a % x.ndim for a in axis]) - if len(axis) != len(set(axis)): - raise ValueError(f'Axis must be unique. Normalized axis are {axis}') + # normalize dim to allow negative indexing in input + dim = tuple([a % x.ndim for a in dim]) + if len(dim) != len(set(dim)): + raise ValueError(f'Dim must be unique. Normalized dims are {dim}') if pad_mode == 'constant' and pad_value == 0: # padding is done inside the convolution @@ -62,18 +68,17 @@ def filter_separable( # padding is done with pad() before the convolution padding_conv = 'valid' - for kernel, ax in zip(kernels, axis, strict=False): - # either both are complex or both are real - if x.is_complex() and not kernel.is_complex(): - kernel = kernel + 0.0j - elif kernel.is_complex() and not x.is_complex(): - x = x + 0.0j - kernel = kernel.to(x.device) + # output will be of the promoted type of the input and the kernels + target_dtype = reduce(torch.promote_types, [k.dtype for k in kernels], x.dtype) + x = x.to(target_dtype) + + for kernel, d in zip(kernels, dim, strict=False): + kernel = kernel.to(device=x.device, dtype=target_dtype) # moveaxis is not implemented for batched tensors, so vmap would fail. # thus we use permute. idx = list(range(x.ndim)) # swapping the last axis and the axis to filter over - idx[ax], idx[-1] = idx[-1], idx[ax] + idx[d], idx[-1] = idx[-1], idx[d] x = x.permute(idx) # flatten first to allow for circular, replicate and reflection padding for arbitrary tensor size x_flat = x.flatten(end_dim=-2) @@ -92,7 +97,7 @@ def filter_separable( def gaussian_filter( x: torch.Tensor, sigmas: float | Sequence[float] | torch.Tensor, - axis: int | Sequence[int] | None = None, + dim: int | Sequence[int] | None = None, truncate: int = 3, pad_mode: Literal['constant', 'reflect', 'replicate', 'circular'] = 'constant', pad_value: float = 0.0, @@ -105,7 +110,7 @@ def gaussian_filter( Tensor to filter sigmas Standard deviation for Gaussian kernel. If iterable, must have length equal to the number of axes. - axis + dim Axis or axes to filter over. If None, filters over all axes. truncate Truncate the filter at this many standard deviations. @@ -114,16 +119,16 @@ def gaussian_filter( pad_value Padding value for pad_mode = constant """ - if axis is None: - axis = tuple(range(x.ndim)) - elif isinstance(axis, int): - axis = (axis,) - sigmas = torch.as_tensor(sigmas) if np.iterable(sigmas) else torch.tensor([sigmas] * len(axis)) + if dim is None: + dim = tuple(range(x.ndim)) + elif isinstance(dim, int): + dim = (dim,) + sigmas = torch.as_tensor(sigmas) if np.iterable(sigmas) else torch.tensor([sigmas] * len(dim)) if not torch.all(sigmas > 0): raise ValueError('`sigmas` must be positive') - if len(sigmas) != len(axis): - raise ValueError('Must provide matching length sigmas and axis arguments. ') + if len(sigmas) != len(dim): + raise ValueError('Must provide matching length sigmas and dim arguments. ') kernels = tuple( [ @@ -132,14 +137,14 @@ def gaussian_filter( ] ) kernels = tuple([(k / k.sum()).to(device=x.device) for k in kernels]) - x_filtered = filter_separable(x, kernels, axis, pad_mode, pad_value) + x_filtered = filter_separable(x, kernels, dim, pad_mode, pad_value) return x_filtered def uniform_filter( x: torch.Tensor, width: int | Sequence[int] | torch.Tensor, - axis: int | Sequence[int] | None = None, + dim: int | Sequence[int] | None = None, pad_mode: Literal['constant', 'reflect', 'replicate', 'circular'] = 'constant', pad_value: float = 0.0, ) -> torch.Tensor: @@ -151,26 +156,26 @@ def uniform_filter( Tensor to filter width Width of uniform kernel. If iterable, must have length equal to the number of axes. - axis + dim Axis or axes to filter over. If None, filters over all axes. pad_mode Padding mode pad_value Padding value for pad_mode = constant """ - if axis is None: - axis = tuple(range(x.ndim)) - elif isinstance(axis, int): - axis = (axis,) - width = torch.as_tensor(width) if np.iterable(width) else torch.tensor([width] * len(axis)) + if dim is None: + dim = tuple(range(x.ndim)) + elif isinstance(dim, int): + dim = (dim,) + width = torch.as_tensor(width) if np.iterable(width) else torch.tensor([width] * len(dim)) if not torch.all(width > 0): raise ValueError('width must be positive.') if torch.any(width % 2 != 1): warnings.warn('width should be odd.', stacklevel=2) - if len(width) != len(axis): - raise ValueError('Must provide matching length width and axis arguments. ') - width = torch.minimum(width, torch.tensor(x.shape)[(axis), ...]) + if len(width) != len(dim): + raise ValueError('Must provide matching length width and dim arguments. ') + width = torch.minimum(width, torch.tensor(x.shape)[(dim), ...]) kernels = tuple([torch.ones(width, device=x.device) / width for width in width]) - x_filtered = filter_separable(x, kernels, axis, pad_mode, pad_value) + x_filtered = filter_separable(x, kernels, dim, pad_mode, pad_value) return x_filtered diff --git a/tests/utils/test_filters.py b/tests/utils/test_filters.py index 5d890c1f..a43a2db9 100644 --- a/tests/utils/test_filters.py +++ b/tests/utils/test_filters.py @@ -35,7 +35,7 @@ def test_filter_separable(pad_mode, center_value, edge_value): data = torch.arange(1, 21)[None, :].to(dtype=torch.float32) kernels = (torch.as_tensor([1.0, 2.0, 1.0]),) result = filter_separable( - data, kernels, axis=(1,), pad_mode=pad_mode, pad_value=3.0 if pad_mode == 'constant' else 0.0 + data, kernels, dim=(1,), pad_mode=pad_mode, pad_value=3.0 if pad_mode == 'constant' else 0.0 ) if pad_mode == 'none': assert result.shape == (data.shape[0], data.shape[1] - len(kernels[0]) + 1) @@ -45,6 +45,19 @@ def test_filter_separable(pad_mode, center_value, edge_value): assert result[0, 0] == edge_value +@pytest.mark.parametrize('filter_dtype', [torch.float32, torch.float64, torch.int32, torch.complex64, torch.complex128]) +@pytest.mark.parametrize('data_dtype', [torch.float32, torch.float64, torch.int32, torch.complex64, torch.complex128]) +def test_filter_separable_dtype(filter_dtype, data_dtype): + """Test filter_separable and different padding modes.""" + + data = torch.arange(1, 21)[None, :].to(dtype=data_dtype) + kernels = (torch.tensor([1, 2, 1], dtype=filter_dtype),) + result = filter_separable(data, kernels, dim=(1,)) + expected_dtype = torch.result_type(data, kernels[0]) + assert result.dtype == expected_dtype + assert result[0, 10] == 44 + + def test_gaussian_filter_int_axis(data): """Test Gaussian filter.""" result = gaussian_filter(data, 0.5, -1) @@ -86,7 +99,7 @@ def test_gaussian_filter_noaxis(data): def test_gaussian_invalid_sigmas(data): """Test Gaussian filter with invalid sigma values.""" with pytest.raises(ValueError, match='positive'): - gaussian_filter(data, axis=(-1, 2), sigmas=torch.tensor([0.2, 0.0])) + gaussian_filter(data, dim=(-1, 2), sigmas=torch.tensor([0.2, 0.0])) with pytest.raises(ValueError, match='positive'): gaussian_filter(data, sigmas=torch.tensor(-1.0)) with pytest.raises(ValueError, match='positive'): @@ -134,7 +147,7 @@ def test_uniform_filter_noaxis(data): def test_uniform_invalid_width(data): """Test uniform filter with invalid width.""" with pytest.raises(ValueError, match='positive'): - uniform_filter(data, axis=(-1, 2), width=torch.tensor([3, 0])) + uniform_filter(data, dim=(-1, 2), width=torch.tensor([3, 0])) with pytest.raises(ValueError, match='positive'): uniform_filter(data, width=torch.tensor(-1.0)) with pytest.raises(ValueError, match='positive'): From 2f37508218a346b28cb5f0327f0f44c6d6331aaf Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Tue, 30 Jul 2024 11:53:10 +0200 Subject: [PATCH 16/34] Fix warning in pickle test (#372) --- tests/utils/test_rotation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/utils/test_rotation.py b/tests/utils/test_rotation.py index 36004e34..bb8777f0 100644 --- a/tests/utils/test_rotation.py +++ b/tests/utils/test_rotation.py @@ -1209,6 +1209,8 @@ def test_rotation_within_numpy_object_array(): assert array.shape == (3, 2) +# Needed because of bug in torch 2.4.0. Should be fixed with 2.4.1 +@pytest.mark.filterwarnings('ignore::FutureWarning') def test_pickling(): """Test pickling a Rotation""" r = Rotation.from_quat([0, 0, np.sin(torch.pi / 4), np.cos(torch.pi / 4)]) From 29c59c39fc5d5cf1726204c5fbef606e76c18690 Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Wed, 31 Jul 2024 17:34:26 +0200 Subject: [PATCH 17/34] Remove copyright statement from individual files (#375) --- LICENSE | 2 +- examples/direct_reconstruction.ipynb | 13 +------------ examples/direct_reconstruction.py | 3 --- examples/pulseq_2d_radial_golden_angle.ipynb | 9 --------- examples/pulseq_2d_radial_golden_angle.py | 4 ---- examples/qmri_sg_challenge_2024_t1.ipynb | 9 --------- examples/qmri_sg_challenge_2024_t1.py | 4 ---- examples/qmri_sg_challenge_2024_t2_star.ipynb | 9 --------- examples/qmri_sg_challenge_2024_t2_star.py | 4 ---- examples/t1_mapping_with_grad_acq.ipynb | 9 --------- examples/t1_mapping_with_grad_acq.py | 4 ---- src/mrpro/algorithms/csm/iterative_walsh.py | 14 -------------- src/mrpro/algorithms/dcf/dcf_voronoi.py | 14 -------------- src/mrpro/algorithms/optimizers/adam.py | 14 -------------- src/mrpro/algorithms/optimizers/cg.py | 14 -------------- src/mrpro/algorithms/optimizers/lbfgs.py | 14 -------------- src/mrpro/algorithms/prewhiten_kspace.py | 14 -------------- .../reconstruction/DirectReconstruction.py | 14 -------------- .../algorithms/reconstruction/Reconstruction.py | 14 -------------- src/mrpro/data/AcqInfo.py | 14 -------------- src/mrpro/data/CsmData.py | 14 -------------- src/mrpro/data/Data.py | 14 -------------- src/mrpro/data/DcfData.py | 14 -------------- src/mrpro/data/EncodingLimits.py | 14 -------------- src/mrpro/data/IData.py | 14 -------------- src/mrpro/data/IHeader.py | 14 -------------- src/mrpro/data/KHeader.py | 14 -------------- src/mrpro/data/KNoise.py | 14 -------------- src/mrpro/data/KTrajectory.py | 14 -------------- src/mrpro/data/KTrajectoryRawShape.py | 14 -------------- src/mrpro/data/MoveDataMixin.py | 14 -------------- src/mrpro/data/QData.py | 14 -------------- src/mrpro/data/QHeader.py | 14 -------------- src/mrpro/data/SpatialDimension.py | 14 -------------- src/mrpro/data/TrajectoryDescription.py | 14 -------------- src/mrpro/data/_kdata/KData.py | 14 -------------- src/mrpro/data/_kdata/KDataProtocol.py | 14 -------------- src/mrpro/data/_kdata/KDataRearrangeMixin.py | 14 -------------- src/mrpro/data/_kdata/KDataRemoveOsMixin.py | 12 ------------ src/mrpro/data/_kdata/KDataSelectMixin.py | 14 -------------- src/mrpro/data/_kdata/KDataSplitMixin.py | 14 -------------- src/mrpro/data/acq_filters.py | 14 -------------- src/mrpro/data/enums.py | 14 -------------- .../data/traj_calculators/KTrajectoryCalculator.py | 14 -------------- .../data/traj_calculators/KTrajectoryCartesian.py | 14 -------------- .../data/traj_calculators/KTrajectoryIsmrmrd.py | 14 -------------- .../data/traj_calculators/KTrajectoryPulseq.py | 14 -------------- .../data/traj_calculators/KTrajectoryRadial2D.py | 14 -------------- src/mrpro/data/traj_calculators/KTrajectoryRpe.py | 14 -------------- .../KTrajectorySunflowerGoldenRpe.py | 14 -------------- src/mrpro/operators/CartesianSamplingOp.py | 14 -------------- src/mrpro/operators/ConstraintsOp.py | 14 -------------- src/mrpro/operators/DensityCompensationOp.py | 14 -------------- src/mrpro/operators/EinsumOp.py | 12 ------------ src/mrpro/operators/EndomorphOperator.py | 14 -------------- src/mrpro/operators/FastFourierOp.py | 14 -------------- src/mrpro/operators/FiniteDifferenceOp.py | 14 -------------- src/mrpro/operators/FourierOp.py | 14 -------------- src/mrpro/operators/GridSamplingOp.py | 14 -------------- src/mrpro/operators/LinearOperator.py | 14 -------------- src/mrpro/operators/MagnitudeOp.py | 14 -------------- src/mrpro/operators/Operator.py | 14 -------------- src/mrpro/operators/PhaseOp.py | 14 -------------- src/mrpro/operators/SensitivityOp.py | 14 -------------- src/mrpro/operators/SignalModel.py | 14 -------------- src/mrpro/operators/SliceProjectionOp.py | 14 -------------- src/mrpro/operators/ZeroPadOp.py | 14 -------------- .../operators/functionals/MSEDataDiscrepancy.py | 14 -------------- src/mrpro/operators/models/InversionRecovery.py | 14 -------------- src/mrpro/operators/models/MOLLI.py | 14 -------------- src/mrpro/operators/models/MonoExponentialDecay.py | 14 -------------- src/mrpro/operators/models/SaturationRecovery.py | 14 -------------- .../models/TransientSteadyStateWithPreparation.py | 14 -------------- src/mrpro/operators/models/WASABI.py | 14 -------------- src/mrpro/operators/models/WASABITI.py | 14 -------------- src/mrpro/phantoms/EllipsePhantom.py | 14 -------------- src/mrpro/phantoms/coils.py | 14 -------------- src/mrpro/phantoms/phantom_elements.py | 14 -------------- src/mrpro/utils/filters.py | 14 -------------- src/mrpro/utils/modify_acq_info.py | 14 -------------- src/mrpro/utils/remove_repeat.py | 14 -------------- src/mrpro/utils/slice_profiles.py | 14 -------------- src/mrpro/utils/sliding_window.py | 12 ------------ src/mrpro/utils/smap.py | 14 -------------- src/mrpro/utils/split_idx.py | 14 -------------- src/mrpro/utils/zero_pad_or_crop.py | 14 -------------- tests/_RandomGenerator.py | 2 ++ tests/algorithms/csm/test_iterative_walsh.py | 14 -------------- tests/algorithms/dcf/test_dcf_voronoi.py | 12 ------------ tests/algorithms/test_cg.py | 12 ------------ tests/algorithms/test_optimizers.py | 12 ------------ tests/algorithms/test_prewhiten_kspace.py | 12 ------------ tests/conftest.py | 14 -------------- tests/data/_Dicom2DTestImage.py | 12 ------------ tests/data/_IsmrmrdRawTestData.py | 12 ------------ tests/data/_PulseqRadialTestSeq.py | 12 ------------ tests/data/conftest.py | 14 -------------- tests/data/test_csm_data.py | 12 ------------ tests/data/test_dcf_data.py | 12 ------------ tests/data/test_idata.py | 12 ------------ tests/data/test_kdata.py | 12 ------------ tests/data/test_kheader.py | 12 ------------ tests/data/test_knoise.py | 12 ------------ tests/data/test_ktraj_raw_shape.py | 12 ------------ tests/data/test_movedatamixin.py | 12 ------------ tests/data/test_qdata.py | 12 ------------ tests/data/test_spatial_dimension.py | 12 ------------ tests/data/test_traj_calculators.py | 12 ------------ tests/data/test_trajectory.py | 12 ------------ tests/helper.py | 12 ------------ tests/operators/_OptimizationTestFunctions.py | 12 ------------ tests/operators/functionals/test_mse_functional.py | 12 ------------ tests/operators/models/conftest.py | 14 -------------- tests/operators/models/test_inversion_recovery.py | 14 -------------- tests/operators/models/test_molli.py | 14 -------------- .../models/test_mono_exponential_decay.py | 14 -------------- tests/operators/models/test_saturation_recovery.py | 14 -------------- ...test_transient_steady_state_with_preparation.py | 14 -------------- tests/operators/models/test_wasabi.py | 14 -------------- tests/operators/models/test_wasabiti.py | 14 -------------- tests/operators/test_cartesian_sampling_op.py | 12 ------------ tests/operators/test_constraints_op.py | 12 ------------ tests/operators/test_density_compensation_op.py | 12 ------------ tests/operators/test_einsum_op.py | 12 ------------ tests/operators/test_fast_fourier_op.py | 12 ------------ tests/operators/test_finite_difference_op.py | 12 ------------ tests/operators/test_fourier_op.py | 12 ------------ tests/operators/test_grid_sampling_op.py | 12 ------------ tests/operators/test_magnitude_op.py | 12 ------------ tests/operators/test_operator_norm.py | 11 ----------- tests/operators/test_phase_op.py | 12 ------------ tests/operators/test_sensitivity_op.py | 12 ------------ tests/operators/test_slice_projection_op.py | 11 ----------- tests/operators/test_zero_pad_op.py | 12 ------------ tests/phantoms/_EllipsePhantomTestData.py | 12 ------------ tests/phantoms/test_coil_sensitivities.py | 12 ------------ tests/phantoms/test_ellipse_phantom.py | 12 ------------ tests/utils/test_filters.py | 12 ------------ tests/utils/test_modify_acq_info.py | 12 ------------ tests/utils/test_split_idx.py | 12 ------------ tests/utils/test_window.py | 12 ------------ tests/utils/test_zero_pad_or_crop.py | 12 ------------ 142 files changed, 4 insertions(+), 1792 deletions(-) diff --git a/LICENSE b/LICENSE index 261eeb9e..ee2a3d66 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2023 Physikalisch-Technische Bundesanstalt Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/examples/direct_reconstruction.ipynb b/examples/direct_reconstruction.ipynb index 47c6f78c..eff59cbe 100644 --- a/examples/direct_reconstruction.ipynb +++ b/examples/direct_reconstruction.ipynb @@ -213,23 +213,12 @@ "cell_type": "code", "execution_count": null, "id": "1c9c3378", - "metadata": { - "lines_to_next_cell": 0 - }, + "metadata": {}, "outputs": [], "source": [ "# Clean-up by removing temporary directory\n", "shutil.rmtree(data_folder)" ] - }, - { - "cell_type": "markdown", - "id": "6b76b8d4", - "metadata": {}, - "source": [ - "Copyright 2024 Physikalisch-Technische Bundesanstalt\n", - "Apache License 2.0. See LICENSE file for details." - ] } ], "metadata": { diff --git a/examples/direct_reconstruction.py b/examples/direct_reconstruction.py index ca445c28..8d7de0c4 100644 --- a/examples/direct_reconstruction.py +++ b/examples/direct_reconstruction.py @@ -100,6 +100,3 @@ # %% # Clean-up by removing temporary directory shutil.rmtree(data_folder) -# %% [markdown] -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# Apache License 2.0. See LICENSE file for details. diff --git a/examples/pulseq_2d_radial_golden_angle.ipynb b/examples/pulseq_2d_radial_golden_angle.ipynb index 729d1d5b..21e6f894 100644 --- a/examples/pulseq_2d_radial_golden_angle.ipynb +++ b/examples/pulseq_2d_radial_golden_angle.ipynb @@ -232,15 +232,6 @@ "# Clean-up by removing temporary directory\n", "shutil.rmtree(data_folder)" ] - }, - { - "cell_type": "markdown", - "id": "5b31339a", - "metadata": {}, - "source": [ - "Copyright 2024 Physikalisch-Technische Bundesanstalt\n", - "Apache License 2.0. See LICENSE file for details." - ] } ], "metadata": { diff --git a/examples/pulseq_2d_radial_golden_angle.py b/examples/pulseq_2d_radial_golden_angle.py index f30a9911..00865afd 100644 --- a/examples/pulseq_2d_radial_golden_angle.py +++ b/examples/pulseq_2d_radial_golden_angle.py @@ -137,7 +137,3 @@ # %% # Clean-up by removing temporary directory shutil.rmtree(data_folder) - -# %% [markdown] -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# Apache License 2.0. See LICENSE file for details. diff --git a/examples/qmri_sg_challenge_2024_t1.ipynb b/examples/qmri_sg_challenge_2024_t1.ipynb index 724f2860..1832e5b4 100644 --- a/examples/qmri_sg_challenge_2024_t1.ipynb +++ b/examples/qmri_sg_challenge_2024_t1.ipynb @@ -324,15 +324,6 @@ "# Clean-up by removing temporary directory\n", "shutil.rmtree(data_folder)" ] - }, - { - "cell_type": "markdown", - "id": "6146f751", - "metadata": {}, - "source": [ - "Copyright 2024 Physikalisch-Technische Bundesanstalt\n", - "Apache License 2.0. See LICENSE file for details." - ] } ], "metadata": { diff --git a/examples/qmri_sg_challenge_2024_t1.py b/examples/qmri_sg_challenge_2024_t1.py index ce8f8b24..148f5bdd 100644 --- a/examples/qmri_sg_challenge_2024_t1.py +++ b/examples/qmri_sg_challenge_2024_t1.py @@ -171,7 +171,3 @@ # %% # Clean-up by removing temporary directory shutil.rmtree(data_folder) - -# %% [markdown] -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# Apache License 2.0. See LICENSE file for details. diff --git a/examples/qmri_sg_challenge_2024_t2_star.ipynb b/examples/qmri_sg_challenge_2024_t2_star.ipynb index b4905bfe..e2adcde8 100644 --- a/examples/qmri_sg_challenge_2024_t2_star.ipynb +++ b/examples/qmri_sg_challenge_2024_t2_star.ipynb @@ -279,15 +279,6 @@ "# Clean-up by removing temporary directory\n", "shutil.rmtree(data_folder)" ] - }, - { - "cell_type": "markdown", - "id": "3eaa0929", - "metadata": {}, - "source": [ - "Copyright 2024 Physikalisch-Technische Bundesanstalt\n", - "Apache License 2.0. See LICENSE file for details." - ] } ], "metadata": { diff --git a/examples/qmri_sg_challenge_2024_t2_star.py b/examples/qmri_sg_challenge_2024_t2_star.py index 5f6ef583..ced49ae4 100644 --- a/examples/qmri_sg_challenge_2024_t2_star.py +++ b/examples/qmri_sg_challenge_2024_t2_star.py @@ -142,7 +142,3 @@ # %% # Clean-up by removing temporary directory shutil.rmtree(data_folder) - -# %% [markdown] -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# Apache License 2.0. See LICENSE file for details. diff --git a/examples/t1_mapping_with_grad_acq.ipynb b/examples/t1_mapping_with_grad_acq.ipynb index a9295b17..af952a0f 100644 --- a/examples/t1_mapping_with_grad_acq.ipynb +++ b/examples/t1_mapping_with_grad_acq.ipynb @@ -119,15 +119,6 @@ "# Clean-up by removing temporary directory\n", "shutil.rmtree(data_folder)" ] - }, - { - "cell_type": "markdown", - "id": "1ab5a9dd", - "metadata": {}, - "source": [ - "Copyright 2024 Physikalisch-Technische Bundesanstalt\n", - "Apache License 2.0. See LICENSE file for details." - ] } ], "metadata": { diff --git a/examples/t1_mapping_with_grad_acq.py b/examples/t1_mapping_with_grad_acq.py index 99a9cd8f..3723dea3 100644 --- a/examples/t1_mapping_with_grad_acq.py +++ b/examples/t1_mapping_with_grad_acq.py @@ -58,7 +58,3 @@ # %% # Clean-up by removing temporary directory shutil.rmtree(data_folder) - -# %% [markdown] -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# Apache License 2.0. See LICENSE file for details. diff --git a/src/mrpro/algorithms/csm/iterative_walsh.py b/src/mrpro/algorithms/csm/iterative_walsh.py index 69e87077..6e423dbb 100644 --- a/src/mrpro/algorithms/csm/iterative_walsh.py +++ b/src/mrpro/algorithms/csm/iterative_walsh.py @@ -1,19 +1,5 @@ """Iterative Walsh method for coil sensitivity map calculation.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.data.SpatialDimension import SpatialDimension diff --git a/src/mrpro/algorithms/dcf/dcf_voronoi.py b/src/mrpro/algorithms/dcf/dcf_voronoi.py index 2887ac61..9218948e 100644 --- a/src/mrpro/algorithms/dcf/dcf_voronoi.py +++ b/src/mrpro/algorithms/dcf/dcf_voronoi.py @@ -1,19 +1,5 @@ """1D, 2D and 3D density compensation function calculation with voronoi method.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from concurrent.futures import ProcessPoolExecutor from itertools import product diff --git a/src/mrpro/algorithms/optimizers/adam.py b/src/mrpro/algorithms/optimizers/adam.py index 1488ad7c..2ac27fe2 100644 --- a/src/mrpro/algorithms/optimizers/adam.py +++ b/src/mrpro/algorithms/optimizers/adam.py @@ -1,19 +1,5 @@ """ADAM for solving non-linear minimization problems.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from collections.abc import Sequence import torch diff --git a/src/mrpro/algorithms/optimizers/cg.py b/src/mrpro/algorithms/optimizers/cg.py index c40d2882..10436381 100644 --- a/src/mrpro/algorithms/optimizers/cg.py +++ b/src/mrpro/algorithms/optimizers/cg.py @@ -1,19 +1,5 @@ """Conjugate Gradient for linear systems with self-adjoint linear operator.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from collections.abc import Callable import torch diff --git a/src/mrpro/algorithms/optimizers/lbfgs.py b/src/mrpro/algorithms/optimizers/lbfgs.py index 0a98b084..ab272450 100644 --- a/src/mrpro/algorithms/optimizers/lbfgs.py +++ b/src/mrpro/algorithms/optimizers/lbfgs.py @@ -1,19 +1,5 @@ """LBFGS for solving non-linear minimization problems.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from collections.abc import Sequence from typing import Literal diff --git a/src/mrpro/algorithms/prewhiten_kspace.py b/src/mrpro/algorithms/prewhiten_kspace.py index 201fcba7..953d4e44 100644 --- a/src/mrpro/algorithms/prewhiten_kspace.py +++ b/src/mrpro/algorithms/prewhiten_kspace.py @@ -1,19 +1,5 @@ """Prewhiten k-space data.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from copy import deepcopy import torch diff --git a/src/mrpro/algorithms/reconstruction/DirectReconstruction.py b/src/mrpro/algorithms/reconstruction/DirectReconstruction.py index 6e778f7c..206ea386 100644 --- a/src/mrpro/algorithms/reconstruction/DirectReconstruction.py +++ b/src/mrpro/algorithms/reconstruction/DirectReconstruction.py @@ -1,19 +1,5 @@ """Direct Reconstruction by Adjoint Fourier Transform.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from typing import Literal, Self from mrpro.algorithms.prewhiten_kspace import prewhiten_kspace diff --git a/src/mrpro/algorithms/reconstruction/Reconstruction.py b/src/mrpro/algorithms/reconstruction/Reconstruction.py index b117dd31..1a9e655e 100644 --- a/src/mrpro/algorithms/reconstruction/Reconstruction.py +++ b/src/mrpro/algorithms/reconstruction/Reconstruction.py @@ -1,19 +1,5 @@ """Reconstruction module.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from abc import ABC, abstractmethod import torch diff --git a/src/mrpro/data/AcqInfo.py b/src/mrpro/data/AcqInfo.py index 1fbbc1c3..f30d545a 100644 --- a/src/mrpro/data/AcqInfo.py +++ b/src/mrpro/data/AcqInfo.py @@ -1,19 +1,5 @@ """Acquisition information dataclass.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from collections.abc import Sequence from dataclasses import dataclass from typing import Self diff --git a/src/mrpro/data/CsmData.py b/src/mrpro/data/CsmData.py index d60cb0a3..9f59bc93 100644 --- a/src/mrpro/data/CsmData.py +++ b/src/mrpro/data/CsmData.py @@ -1,19 +1,5 @@ """Class for coil sensitivity maps (csm).""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from __future__ import annotations from typing import TYPE_CHECKING, Self diff --git a/src/mrpro/data/Data.py b/src/mrpro/data/Data.py index 28156c6f..b82a7cc5 100644 --- a/src/mrpro/data/Data.py +++ b/src/mrpro/data/Data.py @@ -1,19 +1,5 @@ """Base class for data objects.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import dataclasses from abc import ABC from typing import Any diff --git a/src/mrpro/data/DcfData.py b/src/mrpro/data/DcfData.py index 941ffd59..32e70d59 100644 --- a/src/mrpro/data/DcfData.py +++ b/src/mrpro/data/DcfData.py @@ -1,19 +1,5 @@ """Density compensation data (DcfData) class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from __future__ import annotations import dataclasses diff --git a/src/mrpro/data/EncodingLimits.py b/src/mrpro/data/EncodingLimits.py index 69da95ff..cdf1fecd 100644 --- a/src/mrpro/data/EncodingLimits.py +++ b/src/mrpro/data/EncodingLimits.py @@ -1,19 +1,5 @@ """Encoding limits dataclass.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import dataclasses from dataclasses import dataclass from typing import Self diff --git a/src/mrpro/data/IData.py b/src/mrpro/data/IData.py index f1478f9c..3ecfa5e8 100644 --- a/src/mrpro/data/IData.py +++ b/src/mrpro/data/IData.py @@ -1,19 +1,5 @@ """MR image data (IData) class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import dataclasses from collections.abc import Generator, Sequence from pathlib import Path diff --git a/src/mrpro/data/IHeader.py b/src/mrpro/data/IHeader.py index af8c6426..7477e83b 100644 --- a/src/mrpro/data/IHeader.py +++ b/src/mrpro/data/IHeader.py @@ -1,19 +1,5 @@ """MR image data header (IHeader) dataclass.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import dataclasses from collections.abc import Sequence from dataclasses import dataclass diff --git a/src/mrpro/data/KHeader.py b/src/mrpro/data/KHeader.py index a00c30b0..93ef48b8 100644 --- a/src/mrpro/data/KHeader.py +++ b/src/mrpro/data/KHeader.py @@ -1,19 +1,5 @@ """MR raw data / k-space data header dataclass.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from __future__ import annotations import dataclasses diff --git a/src/mrpro/data/KNoise.py b/src/mrpro/data/KNoise.py index cb297e79..a1e465f4 100644 --- a/src/mrpro/data/KNoise.py +++ b/src/mrpro/data/KNoise.py @@ -1,19 +1,5 @@ """MR noise measurements class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import dataclasses from collections.abc import Callable from pathlib import Path diff --git a/src/mrpro/data/KTrajectory.py b/src/mrpro/data/KTrajectory.py index e0052639..80b3dbc7 100644 --- a/src/mrpro/data/KTrajectory.py +++ b/src/mrpro/data/KTrajectory.py @@ -1,19 +1,5 @@ """KTrajectory dataclass.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from dataclasses import dataclass from typing import Self diff --git a/src/mrpro/data/KTrajectoryRawShape.py b/src/mrpro/data/KTrajectoryRawShape.py index 64121fe1..3730e666 100644 --- a/src/mrpro/data/KTrajectoryRawShape.py +++ b/src/mrpro/data/KTrajectoryRawShape.py @@ -1,19 +1,5 @@ """KTrajectoryRawShape dataclass.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from dataclasses import dataclass import numpy as np diff --git a/src/mrpro/data/MoveDataMixin.py b/src/mrpro/data/MoveDataMixin.py index d714d71b..e2b8d9ef 100644 --- a/src/mrpro/data/MoveDataMixin.py +++ b/src/mrpro/data/MoveDataMixin.py @@ -1,19 +1,5 @@ """MoveDataMixin.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import dataclasses from collections.abc import Iterator from copy import copy as shallowcopy diff --git a/src/mrpro/data/QData.py b/src/mrpro/data/QData.py index cae64f92..fa2a8957 100644 --- a/src/mrpro/data/QData.py +++ b/src/mrpro/data/QData.py @@ -1,19 +1,5 @@ """MR quantitative data (QData) class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import dataclasses from pathlib import Path from typing import Self diff --git a/src/mrpro/data/QHeader.py b/src/mrpro/data/QHeader.py index 329b3e9c..b3aec710 100644 --- a/src/mrpro/data/QHeader.py +++ b/src/mrpro/data/QHeader.py @@ -1,19 +1,5 @@ """MR quantitative data header (QHeader) dataclass.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from dataclasses import dataclass from typing import Self diff --git a/src/mrpro/data/SpatialDimension.py b/src/mrpro/data/SpatialDimension.py index cadf8e3a..96dc4dd7 100644 --- a/src/mrpro/data/SpatialDimension.py +++ b/src/mrpro/data/SpatialDimension.py @@ -1,19 +1,5 @@ """SpatialDimension dataclass.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from __future__ import annotations from collections.abc import Callable diff --git a/src/mrpro/data/TrajectoryDescription.py b/src/mrpro/data/TrajectoryDescription.py index 527583b1..f1a9cce5 100644 --- a/src/mrpro/data/TrajectoryDescription.py +++ b/src/mrpro/data/TrajectoryDescription.py @@ -1,19 +1,5 @@ """TrajectoryDescription dataclass.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import dataclasses from dataclasses import dataclass from typing import Self diff --git a/src/mrpro/data/_kdata/KData.py b/src/mrpro/data/_kdata/KData.py index 30511d8a..fa57ed9d 100644 --- a/src/mrpro/data/_kdata/KData.py +++ b/src/mrpro/data/_kdata/KData.py @@ -1,19 +1,5 @@ """MR raw data / k-space data class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import dataclasses import datetime import warnings diff --git a/src/mrpro/data/_kdata/KDataProtocol.py b/src/mrpro/data/_kdata/KDataProtocol.py index f0436798..3267b53d 100644 --- a/src/mrpro/data/_kdata/KDataProtocol.py +++ b/src/mrpro/data/_kdata/KDataProtocol.py @@ -1,19 +1,5 @@ """Protocol for KData.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from typing import Literal, Protocol, Self import torch diff --git a/src/mrpro/data/_kdata/KDataRearrangeMixin.py b/src/mrpro/data/_kdata/KDataRearrangeMixin.py index c10410ba..0ae1d413 100644 --- a/src/mrpro/data/_kdata/KDataRearrangeMixin.py +++ b/src/mrpro/data/_kdata/KDataRearrangeMixin.py @@ -1,19 +1,5 @@ """Rearrange KData.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import copy from typing import Self diff --git a/src/mrpro/data/_kdata/KDataRemoveOsMixin.py b/src/mrpro/data/_kdata/KDataRemoveOsMixin.py index cd436529..91d6623b 100644 --- a/src/mrpro/data/_kdata/KDataRemoveOsMixin.py +++ b/src/mrpro/data/_kdata/KDataRemoveOsMixin.py @@ -1,17 +1,5 @@ """Remove oversampling along readout dimension.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from copy import deepcopy from typing import Self diff --git a/src/mrpro/data/_kdata/KDataSelectMixin.py b/src/mrpro/data/_kdata/KDataSelectMixin.py index ed31a1af..351f4193 100644 --- a/src/mrpro/data/_kdata/KDataSelectMixin.py +++ b/src/mrpro/data/_kdata/KDataSelectMixin.py @@ -1,19 +1,5 @@ """Select subset along other dimensions of KData.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import copy from typing import Literal, Self diff --git a/src/mrpro/data/_kdata/KDataSplitMixin.py b/src/mrpro/data/_kdata/KDataSplitMixin.py index cfc050b9..aa6a0548 100644 --- a/src/mrpro/data/_kdata/KDataSplitMixin.py +++ b/src/mrpro/data/_kdata/KDataSplitMixin.py @@ -1,19 +1,5 @@ """Mixin class to split KData into other subsets.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import copy from typing import Literal, Self diff --git a/src/mrpro/data/acq_filters.py b/src/mrpro/data/acq_filters.py index 2f1b28cb..d64c4d9a 100644 --- a/src/mrpro/data/acq_filters.py +++ b/src/mrpro/data/acq_filters.py @@ -1,19 +1,5 @@ """Test ISMRMRD acquisitions based on their flags.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import ismrmrd from mrpro.data.enums import AcqFlags diff --git a/src/mrpro/data/enums.py b/src/mrpro/data/enums.py index e078fc92..670d8b37 100644 --- a/src/mrpro/data/enums.py +++ b/src/mrpro/data/enums.py @@ -1,19 +1,5 @@ """All acquisition enums.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from enum import Enum, Flag, auto diff --git a/src/mrpro/data/traj_calculators/KTrajectoryCalculator.py b/src/mrpro/data/traj_calculators/KTrajectoryCalculator.py index 775e27bc..1893d761 100644 --- a/src/mrpro/data/traj_calculators/KTrajectoryCalculator.py +++ b/src/mrpro/data/traj_calculators/KTrajectoryCalculator.py @@ -1,19 +1,5 @@ """K-space trajectory base class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from abc import ABC, abstractmethod import torch diff --git a/src/mrpro/data/traj_calculators/KTrajectoryCartesian.py b/src/mrpro/data/traj_calculators/KTrajectoryCartesian.py index 463eac66..428041ac 100644 --- a/src/mrpro/data/traj_calculators/KTrajectoryCartesian.py +++ b/src/mrpro/data/traj_calculators/KTrajectoryCartesian.py @@ -1,19 +1,5 @@ """Cartesian trajectory class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from einops import repeat diff --git a/src/mrpro/data/traj_calculators/KTrajectoryIsmrmrd.py b/src/mrpro/data/traj_calculators/KTrajectoryIsmrmrd.py index 433f99e7..05f6404b 100644 --- a/src/mrpro/data/traj_calculators/KTrajectoryIsmrmrd.py +++ b/src/mrpro/data/traj_calculators/KTrajectoryIsmrmrd.py @@ -1,19 +1,5 @@ """Returns the trajectory saved in an ISMRMRD raw data file.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from collections.abc import Sequence import ismrmrd diff --git a/src/mrpro/data/traj_calculators/KTrajectoryPulseq.py b/src/mrpro/data/traj_calculators/KTrajectoryPulseq.py index e3b892cf..7c843572 100644 --- a/src/mrpro/data/traj_calculators/KTrajectoryPulseq.py +++ b/src/mrpro/data/traj_calculators/KTrajectoryPulseq.py @@ -1,19 +1,5 @@ """K-space trajectory from .seq file class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from pathlib import Path import pypulseq as pp diff --git a/src/mrpro/data/traj_calculators/KTrajectoryRadial2D.py b/src/mrpro/data/traj_calculators/KTrajectoryRadial2D.py index 13b832d8..f8b8cd04 100644 --- a/src/mrpro/data/traj_calculators/KTrajectoryRadial2D.py +++ b/src/mrpro/data/traj_calculators/KTrajectoryRadial2D.py @@ -1,19 +1,5 @@ """2D radial trajectory class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.data.KHeader import KHeader diff --git a/src/mrpro/data/traj_calculators/KTrajectoryRpe.py b/src/mrpro/data/traj_calculators/KTrajectoryRpe.py index a4e5420e..c1ef4dd6 100644 --- a/src/mrpro/data/traj_calculators/KTrajectoryRpe.py +++ b/src/mrpro/data/traj_calculators/KTrajectoryRpe.py @@ -1,19 +1,5 @@ """Radial phase encoding (RPE) trajectory class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.data.KHeader import KHeader diff --git a/src/mrpro/data/traj_calculators/KTrajectorySunflowerGoldenRpe.py b/src/mrpro/data/traj_calculators/KTrajectorySunflowerGoldenRpe.py index a966bfdd..7f53f45b 100644 --- a/src/mrpro/data/traj_calculators/KTrajectorySunflowerGoldenRpe.py +++ b/src/mrpro/data/traj_calculators/KTrajectorySunflowerGoldenRpe.py @@ -1,19 +1,5 @@ """Radial phase encoding (RPE) trajectory class with sunflower pattern.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import numpy as np import torch diff --git a/src/mrpro/operators/CartesianSamplingOp.py b/src/mrpro/operators/CartesianSamplingOp.py index 239f8f88..e70322c9 100644 --- a/src/mrpro/operators/CartesianSamplingOp.py +++ b/src/mrpro/operators/CartesianSamplingOp.py @@ -1,19 +1,5 @@ """Cartesian Sampling Operator.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from einops import rearrange diff --git a/src/mrpro/operators/ConstraintsOp.py b/src/mrpro/operators/ConstraintsOp.py index f1e13fb7..d865a21e 100644 --- a/src/mrpro/operators/ConstraintsOp.py +++ b/src/mrpro/operators/ConstraintsOp.py @@ -1,19 +1,5 @@ """Operator enforcing constraints by variable transformations.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from collections.abc import Sequence import torch diff --git a/src/mrpro/operators/DensityCompensationOp.py b/src/mrpro/operators/DensityCompensationOp.py index f0b2939a..205a4a18 100644 --- a/src/mrpro/operators/DensityCompensationOp.py +++ b/src/mrpro/operators/DensityCompensationOp.py @@ -1,19 +1,5 @@ """Class for Density Compensation Operator.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.data.DcfData import DcfData diff --git a/src/mrpro/operators/EinsumOp.py b/src/mrpro/operators/EinsumOp.py index 67090fc1..b6309011 100644 --- a/src/mrpro/operators/EinsumOp.py +++ b/src/mrpro/operators/EinsumOp.py @@ -1,17 +1,5 @@ """Generalized Sum Multiplication Operator.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import re import torch diff --git a/src/mrpro/operators/EndomorphOperator.py b/src/mrpro/operators/EndomorphOperator.py index 6a66aa50..c06405bd 100644 --- a/src/mrpro/operators/EndomorphOperator.py +++ b/src/mrpro/operators/EndomorphOperator.py @@ -1,19 +1,5 @@ """Endomorph Operators.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from abc import abstractmethod from collections.abc import Callable from typing import ParamSpec, Protocol, TypeAlias, TypeVar, TypeVarTuple, cast, overload diff --git a/src/mrpro/operators/FastFourierOp.py b/src/mrpro/operators/FastFourierOp.py index 79c713ac..9838c7fe 100644 --- a/src/mrpro/operators/FastFourierOp.py +++ b/src/mrpro/operators/FastFourierOp.py @@ -1,19 +1,5 @@ """Class for Fast Fourier Operator.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from collections.abc import Sequence from dataclasses import astuple diff --git a/src/mrpro/operators/FiniteDifferenceOp.py b/src/mrpro/operators/FiniteDifferenceOp.py index 2a317d9d..07d18531 100644 --- a/src/mrpro/operators/FiniteDifferenceOp.py +++ b/src/mrpro/operators/FiniteDifferenceOp.py @@ -1,19 +1,5 @@ """Class for Finite Difference Operator.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from collections.abc import Sequence from typing import Literal diff --git a/src/mrpro/operators/FourierOp.py b/src/mrpro/operators/FourierOp.py index 8bc403b6..c7c16435 100644 --- a/src/mrpro/operators/FourierOp.py +++ b/src/mrpro/operators/FourierOp.py @@ -1,19 +1,5 @@ """Fourier Operator.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from collections.abc import Sequence from typing import Self diff --git a/src/mrpro/operators/GridSamplingOp.py b/src/mrpro/operators/GridSamplingOp.py index 51937957..7e280463 100644 --- a/src/mrpro/operators/GridSamplingOp.py +++ b/src/mrpro/operators/GridSamplingOp.py @@ -1,19 +1,5 @@ """Class for Grid Sampling Operator.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import warnings from collections.abc import Callable, Sequence from typing import Literal diff --git a/src/mrpro/operators/LinearOperator.py b/src/mrpro/operators/LinearOperator.py index d8ae3e9d..eab35f7e 100644 --- a/src/mrpro/operators/LinearOperator.py +++ b/src/mrpro/operators/LinearOperator.py @@ -1,19 +1,5 @@ """Linear Operators.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from __future__ import annotations from abc import abstractmethod diff --git a/src/mrpro/operators/MagnitudeOp.py b/src/mrpro/operators/MagnitudeOp.py index eb8fb4d9..29d98e0e 100644 --- a/src/mrpro/operators/MagnitudeOp.py +++ b/src/mrpro/operators/MagnitudeOp.py @@ -1,19 +1,5 @@ """Operator returning the magnitude of the input.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.operators.EndomorphOperator import EndomorphOperator, endomorph diff --git a/src/mrpro/operators/Operator.py b/src/mrpro/operators/Operator.py index 2655d545..d2485ad6 100644 --- a/src/mrpro/operators/Operator.py +++ b/src/mrpro/operators/Operator.py @@ -1,19 +1,5 @@ """General Operators.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from __future__ import annotations from abc import ABC, abstractmethod diff --git a/src/mrpro/operators/PhaseOp.py b/src/mrpro/operators/PhaseOp.py index 9527cc2c..1d0b400c 100644 --- a/src/mrpro/operators/PhaseOp.py +++ b/src/mrpro/operators/PhaseOp.py @@ -1,19 +1,5 @@ """Operator returning the phase of the input.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.operators.EndomorphOperator import EndomorphOperator, endomorph diff --git a/src/mrpro/operators/SensitivityOp.py b/src/mrpro/operators/SensitivityOp.py index d4e75a93..56294881 100644 --- a/src/mrpro/operators/SensitivityOp.py +++ b/src/mrpro/operators/SensitivityOp.py @@ -1,19 +1,5 @@ """Class for Sensitivity Operator.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.data.CsmData import CsmData diff --git a/src/mrpro/operators/SignalModel.py b/src/mrpro/operators/SignalModel.py index ab474428..caa935a0 100644 --- a/src/mrpro/operators/SignalModel.py +++ b/src/mrpro/operators/SignalModel.py @@ -1,19 +1,5 @@ """Signal Model Operators.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from typing import TypeVarTuple import torch diff --git a/src/mrpro/operators/SliceProjectionOp.py b/src/mrpro/operators/SliceProjectionOp.py index f1fc1f48..e58239c8 100644 --- a/src/mrpro/operators/SliceProjectionOp.py +++ b/src/mrpro/operators/SliceProjectionOp.py @@ -1,19 +1,5 @@ """Class for 3D->2D Projection Operator.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# input_shape.you may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANinput_shape.y KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import itertools import warnings from collections.abc import Callable, Sequence diff --git a/src/mrpro/operators/ZeroPadOp.py b/src/mrpro/operators/ZeroPadOp.py index cd4d0fdb..b7d5cabb 100644 --- a/src/mrpro/operators/ZeroPadOp.py +++ b/src/mrpro/operators/ZeroPadOp.py @@ -1,19 +1,5 @@ """Class for Zero Pad Operator.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from collections.abc import Sequence import torch diff --git a/src/mrpro/operators/functionals/MSEDataDiscrepancy.py b/src/mrpro/operators/functionals/MSEDataDiscrepancy.py index d38f6dc8..e07f599b 100644 --- a/src/mrpro/operators/functionals/MSEDataDiscrepancy.py +++ b/src/mrpro/operators/functionals/MSEDataDiscrepancy.py @@ -1,19 +1,5 @@ """Mean squared error (MSE) data-discrepancy function.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch import torch.nn.functional as F # noqa: N812 diff --git a/src/mrpro/operators/models/InversionRecovery.py b/src/mrpro/operators/models/InversionRecovery.py index 3a690424..42ed1fcd 100644 --- a/src/mrpro/operators/models/InversionRecovery.py +++ b/src/mrpro/operators/models/InversionRecovery.py @@ -1,19 +1,5 @@ """Inversion recovery signal model for T1 mapping.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.operators.SignalModel import SignalModel diff --git a/src/mrpro/operators/models/MOLLI.py b/src/mrpro/operators/models/MOLLI.py index 1d4a085e..fef5ab2a 100644 --- a/src/mrpro/operators/models/MOLLI.py +++ b/src/mrpro/operators/models/MOLLI.py @@ -1,19 +1,5 @@ """Modified Look-Locker inversion recovery (MOLLI) signal model for T1 mapping.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.operators.SignalModel import SignalModel diff --git a/src/mrpro/operators/models/MonoExponentialDecay.py b/src/mrpro/operators/models/MonoExponentialDecay.py index 7df7066f..90834f54 100644 --- a/src/mrpro/operators/models/MonoExponentialDecay.py +++ b/src/mrpro/operators/models/MonoExponentialDecay.py @@ -1,19 +1,5 @@ """Mono-exponential decay signal model.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.operators.SignalModel import SignalModel diff --git a/src/mrpro/operators/models/SaturationRecovery.py b/src/mrpro/operators/models/SaturationRecovery.py index 8cd15459..48aec24f 100644 --- a/src/mrpro/operators/models/SaturationRecovery.py +++ b/src/mrpro/operators/models/SaturationRecovery.py @@ -1,19 +1,5 @@ """Saturation recovery signal model for T1 mapping.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.operators.SignalModel import SignalModel diff --git a/src/mrpro/operators/models/TransientSteadyStateWithPreparation.py b/src/mrpro/operators/models/TransientSteadyStateWithPreparation.py index 65cbc0fd..1ad86b5c 100644 --- a/src/mrpro/operators/models/TransientSteadyStateWithPreparation.py +++ b/src/mrpro/operators/models/TransientSteadyStateWithPreparation.py @@ -1,19 +1,5 @@ """Transient steady state signal models.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.operators.SignalModel import SignalModel diff --git a/src/mrpro/operators/models/WASABI.py b/src/mrpro/operators/models/WASABI.py index 8b3e5808..a31c0835 100644 --- a/src/mrpro/operators/models/WASABI.py +++ b/src/mrpro/operators/models/WASABI.py @@ -1,19 +1,5 @@ """WASABI signal model for mapping of B0 and B1.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from torch import nn diff --git a/src/mrpro/operators/models/WASABITI.py b/src/mrpro/operators/models/WASABITI.py index 89a06d8a..ff7daae8 100644 --- a/src/mrpro/operators/models/WASABITI.py +++ b/src/mrpro/operators/models/WASABITI.py @@ -1,19 +1,5 @@ """WASABITI signal model for mapping of B0, B1 and T1.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from torch import nn diff --git a/src/mrpro/phantoms/EllipsePhantom.py b/src/mrpro/phantoms/EllipsePhantom.py index 85b00e40..a39ffc45 100644 --- a/src/mrpro/phantoms/EllipsePhantom.py +++ b/src/mrpro/phantoms/EllipsePhantom.py @@ -1,19 +1,5 @@ """Numerical phantom with ellipses.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from collections.abc import Sequence import numpy as np diff --git a/src/mrpro/phantoms/coils.py b/src/mrpro/phantoms/coils.py index 5594b621..76b78d76 100644 --- a/src/mrpro/phantoms/coils.py +++ b/src/mrpro/phantoms/coils.py @@ -1,19 +1,5 @@ """Numerical coil simulations.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import numpy as np import torch from einops import repeat diff --git a/src/mrpro/phantoms/phantom_elements.py b/src/mrpro/phantoms/phantom_elements.py index b0e38010..ab0f2e78 100644 --- a/src/mrpro/phantoms/phantom_elements.py +++ b/src/mrpro/phantoms/phantom_elements.py @@ -1,19 +1,5 @@ """Building blocks for numerical phantoms.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from dataclasses import dataclass diff --git a/src/mrpro/utils/filters.py b/src/mrpro/utils/filters.py index 237ccb35..6d338cde 100644 --- a/src/mrpro/utils/filters.py +++ b/src/mrpro/utils/filters.py @@ -1,19 +1,5 @@ """Spatial and temporal filters.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import warnings from collections.abc import Sequence from functools import reduce diff --git a/src/mrpro/utils/modify_acq_info.py b/src/mrpro/utils/modify_acq_info.py index f663bbbb..5ce5e9c9 100644 --- a/src/mrpro/utils/modify_acq_info.py +++ b/src/mrpro/utils/modify_acq_info.py @@ -1,19 +1,5 @@ """Modify AcqInfo.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import dataclasses from collections.abc import Callable diff --git a/src/mrpro/utils/remove_repeat.py b/src/mrpro/utils/remove_repeat.py index 3f2ac91a..176e5dbe 100644 --- a/src/mrpro/utils/remove_repeat.py +++ b/src/mrpro/utils/remove_repeat.py @@ -1,19 +1,5 @@ """remove_repeat utility function.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch diff --git a/src/mrpro/utils/slice_profiles.py b/src/mrpro/utils/slice_profiles.py index d770735a..265a6562 100644 --- a/src/mrpro/utils/slice_profiles.py +++ b/src/mrpro/utils/slice_profiles.py @@ -1,19 +1,5 @@ """Slice Profiles.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import abc from collections.abc import Sequence from math import log diff --git a/src/mrpro/utils/sliding_window.py b/src/mrpro/utils/sliding_window.py index fa6650b0..febecf4b 100644 --- a/src/mrpro/utils/sliding_window.py +++ b/src/mrpro/utils/sliding_window.py @@ -1,17 +1,5 @@ """Sliding window view.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import warnings from collections.abc import Sequence diff --git a/src/mrpro/utils/smap.py b/src/mrpro/utils/smap.py index a2f22a53..b05e4921 100644 --- a/src/mrpro/utils/smap.py +++ b/src/mrpro/utils/smap.py @@ -1,19 +1,5 @@ """Smap utility function.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from collections.abc import Callable, Sequence import torch diff --git a/src/mrpro/utils/split_idx.py b/src/mrpro/utils/split_idx.py index cb657da3..5d2dd535 100644 --- a/src/mrpro/utils/split_idx.py +++ b/src/mrpro/utils/split_idx.py @@ -1,19 +1,5 @@ """Index for splitting data.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch diff --git a/src/mrpro/utils/zero_pad_or_crop.py b/src/mrpro/utils/zero_pad_or_crop.py index 1219f300..42adda43 100644 --- a/src/mrpro/utils/zero_pad_or_crop.py +++ b/src/mrpro/utils/zero_pad_or_crop.py @@ -1,19 +1,5 @@ """Zero pad and crop data tensor.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import math from collections.abc import Sequence diff --git a/tests/_RandomGenerator.py b/tests/_RandomGenerator.py index c64931ac..f4453e26 100644 --- a/tests/_RandomGenerator.py +++ b/tests/_RandomGenerator.py @@ -1,3 +1,5 @@ +"""Random generator.""" + from collections.abc import Sequence import torch diff --git a/tests/algorithms/csm/test_iterative_walsh.py b/tests/algorithms/csm/test_iterative_walsh.py index 7078334e..774aa0ce 100644 --- a/tests/algorithms/csm/test_iterative_walsh.py +++ b/tests/algorithms/csm/test_iterative_walsh.py @@ -1,19 +1,5 @@ """Tests the iterative Walsh algorithm.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.algorithms.csm import iterative_walsh from mrpro.data import IData, SpatialDimension diff --git a/tests/algorithms/dcf/test_dcf_voronoi.py b/tests/algorithms/dcf/test_dcf_voronoi.py index 49e7e406..34785db8 100644 --- a/tests/algorithms/dcf/test_dcf_voronoi.py +++ b/tests/algorithms/dcf/test_dcf_voronoi.py @@ -1,17 +1,5 @@ """Tests for algorithms to calculate the DCF with voronoi.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import math import pytest diff --git a/tests/algorithms/test_cg.py b/tests/algorithms/test_cg.py index 61c8c012..efa26ab0 100644 --- a/tests/algorithms/test_cg.py +++ b/tests/algorithms/test_cg.py @@ -1,17 +1,5 @@ """Tests for the conjugate gradient method.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import scipy import torch diff --git a/tests/algorithms/test_optimizers.py b/tests/algorithms/test_optimizers.py index ffbe67ac..75a8d965 100644 --- a/tests/algorithms/test_optimizers.py +++ b/tests/algorithms/test_optimizers.py @@ -1,17 +1,5 @@ """Tests for non-linear optimization algorithms.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.algorithms.optimizers import adam, lbfgs diff --git a/tests/algorithms/test_prewhiten_kspace.py b/tests/algorithms/test_prewhiten_kspace.py index 20842daa..10bf2acc 100644 --- a/tests/algorithms/test_prewhiten_kspace.py +++ b/tests/algorithms/test_prewhiten_kspace.py @@ -1,17 +1,5 @@ """Tests for k-space prewhitening function.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from einops import rearrange diff --git a/tests/conftest.py b/tests/conftest.py index cca7c061..1cd98833 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,19 +1,5 @@ """PyTest fixtures for the mrpro package.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import tempfile import ismrmrd diff --git a/tests/data/_Dicom2DTestImage.py b/tests/data/_Dicom2DTestImage.py index b79927c9..d5692bb8 100644 --- a/tests/data/_Dicom2DTestImage.py +++ b/tests/data/_Dicom2DTestImage.py @@ -1,17 +1,5 @@ """Create 2D dicom image datasets for testing.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from pathlib import Path import numpy as np diff --git a/tests/data/_IsmrmrdRawTestData.py b/tests/data/_IsmrmrdRawTestData.py index d6d29df8..1e845209 100644 --- a/tests/data/_IsmrmrdRawTestData.py +++ b/tests/data/_IsmrmrdRawTestData.py @@ -1,17 +1,5 @@ """Create ismrmrd datasets.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from pathlib import Path from typing import Literal diff --git a/tests/data/_PulseqRadialTestSeq.py b/tests/data/_PulseqRadialTestSeq.py index 46ab7c6c..79119fb2 100644 --- a/tests/data/_PulseqRadialTestSeq.py +++ b/tests/data/_PulseqRadialTestSeq.py @@ -1,17 +1,5 @@ """Create Pulseq test file with radial trajectory.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pypulseq import torch from mrpro.data import KTrajectory diff --git a/tests/data/conftest.py b/tests/data/conftest.py index fd77f1b4..bd37b28c 100644 --- a/tests/data/conftest.py +++ b/tests/data/conftest.py @@ -1,19 +1,5 @@ """PyTest fixtures for the data tests.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from ismrmrd import xsd diff --git a/tests/data/test_csm_data.py b/tests/data/test_csm_data.py index 87f6f426..28a19eb2 100644 --- a/tests/data/test_csm_data.py +++ b/tests/data/test_csm_data.py @@ -1,17 +1,5 @@ """Tests the CsmData class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import dataclasses import pytest diff --git a/tests/data/test_dcf_data.py b/tests/data/test_dcf_data.py index 8ef2d8bf..07fd1cfb 100644 --- a/tests/data/test_dcf_data.py +++ b/tests/data/test_dcf_data.py @@ -1,17 +1,5 @@ """Tests for DcfData class.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.data import DcfData, KTrajectory diff --git a/tests/data/test_idata.py b/tests/data/test_idata.py index 8c4e44e9..33225a2a 100644 --- a/tests/data/test_idata.py +++ b/tests/data/test_idata.py @@ -1,17 +1,5 @@ """Tests the IData class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from pathlib import Path import pytest diff --git a/tests/data/test_kdata.py b/tests/data/test_kdata.py index 143e1925..d520b0c8 100644 --- a/tests/data/test_kdata.py +++ b/tests/data/test_kdata.py @@ -1,17 +1,5 @@ """Tests for the KData class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from einops import rearrange, repeat diff --git a/tests/data/test_kheader.py b/tests/data/test_kheader.py index c81a0cd2..55cbfdf6 100644 --- a/tests/data/test_kheader.py +++ b/tests/data/test_kheader.py @@ -1,17 +1,5 @@ """Tests for KHeader class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.data import KHeader diff --git a/tests/data/test_knoise.py b/tests/data/test_knoise.py index 52d1121c..25d61155 100644 --- a/tests/data/test_knoise.py +++ b/tests/data/test_knoise.py @@ -1,17 +1,5 @@ """Tests the Knoise class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.data import KNoise diff --git a/tests/data/test_ktraj_raw_shape.py b/tests/data/test_ktraj_raw_shape.py index 55a3bde7..a9bf0793 100644 --- a/tests/data/test_ktraj_raw_shape.py +++ b/tests/data/test_ktraj_raw_shape.py @@ -1,17 +1,5 @@ """Tests for KTrajectoryRawShape class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import numpy as np import torch from einops import rearrange, repeat diff --git a/tests/data/test_movedatamixin.py b/tests/data/test_movedatamixin.py index c6fda5fb..e2a7c852 100644 --- a/tests/data/test_movedatamixin.py +++ b/tests/data/test_movedatamixin.py @@ -1,17 +1,5 @@ """Tests the MoveDataMixin class.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from dataclasses import dataclass, field from typing import Any diff --git a/tests/data/test_qdata.py b/tests/data/test_qdata.py index 108983c5..df6f3b24 100644 --- a/tests/data/test_qdata.py +++ b/tests/data/test_qdata.py @@ -1,17 +1,5 @@ """Tests the QData class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.data import IHeader, QData diff --git a/tests/data/test_spatial_dimension.py b/tests/data/test_spatial_dimension.py index 12c71656..4bd0ba09 100644 --- a/tests/data/test_spatial_dimension.py +++ b/tests/data/test_spatial_dimension.py @@ -1,17 +1,5 @@ """Tests the Spatial class.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.data import SpatialDimension diff --git a/tests/data/test_traj_calculators.py b/tests/data/test_traj_calculators.py index 4d264be5..91a97318 100644 --- a/tests/data/test_traj_calculators.py +++ b/tests/data/test_traj_calculators.py @@ -1,17 +1,5 @@ """Tests for KTrajectory Calculator classes.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import numpy as np import pytest import torch diff --git a/tests/data/test_trajectory.py b/tests/data/test_trajectory.py index 3a5afacc..fd26e546 100644 --- a/tests/data/test_trajectory.py +++ b/tests/data/test_trajectory.py @@ -1,17 +1,5 @@ """Tests for KTrajectory class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.data import KTrajectory diff --git a/tests/helper.py b/tests/helper.py index e059f394..0dfb08c4 100644 --- a/tests/helper.py +++ b/tests/helper.py @@ -1,17 +1,5 @@ """Helper/Utilities for test functions.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch diff --git a/tests/operators/_OptimizationTestFunctions.py b/tests/operators/_OptimizationTestFunctions.py index 97e77f94..c8fd35ab 100644 --- a/tests/operators/_OptimizationTestFunctions.py +++ b/tests/operators/_OptimizationTestFunctions.py @@ -1,17 +1,5 @@ """Test functions for non-linear optimization.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.operators import Operator diff --git a/tests/operators/functionals/test_mse_functional.py b/tests/operators/functionals/test_mse_functional.py index fd89e9b3..3ff9b1ee 100644 --- a/tests/operators/functionals/test_mse_functional.py +++ b/tests/operators/functionals/test_mse_functional.py @@ -1,17 +1,5 @@ """Tests for MSE-functional.""" -# Copyright 20234 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.operators.functionals.MSEDataDiscrepancy import MSEDataDiscrepancy diff --git a/tests/operators/models/conftest.py b/tests/operators/models/conftest.py index d3a6a841..dab40849 100644 --- a/tests/operators/models/conftest.py +++ b/tests/operators/models/conftest.py @@ -1,19 +1,5 @@ """PyTest fixtures for signal models.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from tests import RandomGenerator diff --git a/tests/operators/models/test_inversion_recovery.py b/tests/operators/models/test_inversion_recovery.py index fd2f4d2f..71bb4dfe 100644 --- a/tests/operators/models/test_inversion_recovery.py +++ b/tests/operators/models/test_inversion_recovery.py @@ -1,19 +1,5 @@ """Tests for inversion recovery signal model.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.operators.models import InversionRecovery diff --git a/tests/operators/models/test_molli.py b/tests/operators/models/test_molli.py index bb238d6e..10f55281 100644 --- a/tests/operators/models/test_molli.py +++ b/tests/operators/models/test_molli.py @@ -1,19 +1,5 @@ """Tests for MOLLI signal model.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.operators.models import MOLLI diff --git a/tests/operators/models/test_mono_exponential_decay.py b/tests/operators/models/test_mono_exponential_decay.py index 367ab376..c56e86e1 100644 --- a/tests/operators/models/test_mono_exponential_decay.py +++ b/tests/operators/models/test_mono_exponential_decay.py @@ -1,19 +1,5 @@ """Tests for the mono-exponential decay signal model.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.operators.models import MonoExponentialDecay diff --git a/tests/operators/models/test_saturation_recovery.py b/tests/operators/models/test_saturation_recovery.py index f9d8e44b..1a821282 100644 --- a/tests/operators/models/test_saturation_recovery.py +++ b/tests/operators/models/test_saturation_recovery.py @@ -1,19 +1,5 @@ """Tests for saturation recovery signal model.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.operators.models import SaturationRecovery diff --git a/tests/operators/models/test_transient_steady_state_with_preparation.py b/tests/operators/models/test_transient_steady_state_with_preparation.py index 2f769d3e..9ae48ae3 100644 --- a/tests/operators/models/test_transient_steady_state_with_preparation.py +++ b/tests/operators/models/test_transient_steady_state_with_preparation.py @@ -1,19 +1,5 @@ """Tests for transient steady state signal model.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.operators.models import TransientSteadyStateWithPreparation diff --git a/tests/operators/models/test_wasabi.py b/tests/operators/models/test_wasabi.py index 5935f9c8..1aa36384 100644 --- a/tests/operators/models/test_wasabi.py +++ b/tests/operators/models/test_wasabi.py @@ -1,19 +1,5 @@ """Tests for the WASABI signal model.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.operators.models import WASABI from tests.operators.models.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples diff --git a/tests/operators/models/test_wasabiti.py b/tests/operators/models/test_wasabiti.py index 3cbdaeef..bd451a03 100644 --- a/tests/operators/models/test_wasabiti.py +++ b/tests/operators/models/test_wasabiti.py @@ -1,19 +1,5 @@ """Tests for the WASABITI signal model.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.operators.models import WASABITI diff --git a/tests/operators/test_cartesian_sampling_op.py b/tests/operators/test_cartesian_sampling_op.py index 8f91329a..6a1120e7 100644 --- a/tests/operators/test_cartesian_sampling_op.py +++ b/tests/operators/test_cartesian_sampling_op.py @@ -1,17 +1,5 @@ """Tests for the Cartesian sampling operator.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.data import KTrajectory, SpatialDimension diff --git a/tests/operators/test_constraints_op.py b/tests/operators/test_constraints_op.py index 19dfd42e..5d0ba55b 100644 --- a/tests/operators/test_constraints_op.py +++ b/tests/operators/test_constraints_op.py @@ -1,17 +1,5 @@ """Tests for the Constraints Operator.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.operators import ConstraintsOp diff --git a/tests/operators/test_density_compensation_op.py b/tests/operators/test_density_compensation_op.py index 075d7bf9..616d0e8f 100644 --- a/tests/operators/test_density_compensation_op.py +++ b/tests/operators/test_density_compensation_op.py @@ -1,17 +1,5 @@ """Tests for density compensation operator.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.data import DcfData from mrpro.operators import DensityCompensationOp diff --git a/tests/operators/test_einsum_op.py b/tests/operators/test_einsum_op.py index e251e52d..1db7c0ac 100644 --- a/tests/operators/test_einsum_op.py +++ b/tests/operators/test_einsum_op.py @@ -1,17 +1,5 @@ """Tests for Einsum Operator.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.operators.EinsumOp import EinsumOp diff --git a/tests/operators/test_fast_fourier_op.py b/tests/operators/test_fast_fourier_op.py index d194955b..7ac1a821 100644 --- a/tests/operators/test_fast_fourier_op.py +++ b/tests/operators/test_fast_fourier_op.py @@ -1,17 +1,5 @@ """Tests for Fast Fourier Operator class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import numpy as np import pytest import torch diff --git a/tests/operators/test_finite_difference_op.py b/tests/operators/test_finite_difference_op.py index 2d0135bf..0e27a4cb 100644 --- a/tests/operators/test_finite_difference_op.py +++ b/tests/operators/test_finite_difference_op.py @@ -1,17 +1,5 @@ """Tests for finite difference operator.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.operators import FiniteDifferenceOp diff --git a/tests/operators/test_fourier_op.py b/tests/operators/test_fourier_op.py index b139d073..89a4bbc1 100644 --- a/tests/operators/test_fourier_op.py +++ b/tests/operators/test_fourier_op.py @@ -1,17 +1,5 @@ """Tests for Fourier operator.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest from mrpro.data import SpatialDimension from mrpro.operators import FourierOp diff --git a/tests/operators/test_grid_sampling_op.py b/tests/operators/test_grid_sampling_op.py index 11b5c089..ed020956 100644 --- a/tests/operators/test_grid_sampling_op.py +++ b/tests/operators/test_grid_sampling_op.py @@ -1,17 +1,5 @@ """Tests for grid sampling operator.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import contextlib import pytest diff --git a/tests/operators/test_magnitude_op.py b/tests/operators/test_magnitude_op.py index 8d55cac3..88fc2820 100644 --- a/tests/operators/test_magnitude_op.py +++ b/tests/operators/test_magnitude_op.py @@ -1,17 +1,5 @@ """Tests for Magnitude Operator.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.operators import MagnitudeOp diff --git a/tests/operators/test_operator_norm.py b/tests/operators/test_operator_norm.py index bbf2668b..90814761 100644 --- a/tests/operators/test_operator_norm.py +++ b/tests/operators/test_operator_norm.py @@ -1,16 +1,5 @@ """Tests for computing the operator norm of linear operators.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. from math import prod, sqrt import pytest diff --git a/tests/operators/test_phase_op.py b/tests/operators/test_phase_op.py index c9a09a6f..aadfbdf2 100644 --- a/tests/operators/test_phase_op.py +++ b/tests/operators/test_phase_op.py @@ -1,17 +1,5 @@ """Tests for Phase Operator.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.operators import PhaseOp diff --git a/tests/operators/test_sensitivity_op.py b/tests/operators/test_sensitivity_op.py index ad187580..25efc5be 100644 --- a/tests/operators/test_sensitivity_op.py +++ b/tests/operators/test_sensitivity_op.py @@ -1,17 +1,5 @@ """Tests for sensitivity operator.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.data import CsmData, QHeader, SpatialDimension diff --git a/tests/operators/test_slice_projection_op.py b/tests/operators/test_slice_projection_op.py index e286579a..a2737152 100644 --- a/tests/operators/test_slice_projection_op.py +++ b/tests/operators/test_slice_projection_op.py @@ -1,16 +1,5 @@ """Tests for projection operator.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. import math import numpy as np diff --git a/tests/operators/test_zero_pad_op.py b/tests/operators/test_zero_pad_op.py index 034e539b..a56635f5 100644 --- a/tests/operators/test_zero_pad_op.py +++ b/tests/operators/test_zero_pad_op.py @@ -1,17 +1,5 @@ """Tests for Zero Pad Operator class.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.operators import ZeroPadOp diff --git a/tests/phantoms/_EllipsePhantomTestData.py b/tests/phantoms/_EllipsePhantomTestData.py index 1a6dec6b..72b40641 100644 --- a/tests/phantoms/_EllipsePhantomTestData.py +++ b/tests/phantoms/_EllipsePhantomTestData.py @@ -1,17 +1,5 @@ """Ellipse phantom for testing.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.phantoms import EllipseParameters, EllipsePhantom diff --git a/tests/phantoms/test_coil_sensitivities.py b/tests/phantoms/test_coil_sensitivities.py index 66760495..a9388207 100644 --- a/tests/phantoms/test_coil_sensitivities.py +++ b/tests/phantoms/test_coil_sensitivities.py @@ -1,17 +1,5 @@ """Tests for simulation of coil sensitivities.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from mrpro.data import SpatialDimension from mrpro.phantoms.coils import birdcage_2d diff --git a/tests/phantoms/test_ellipse_phantom.py b/tests/phantoms/test_ellipse_phantom.py index 0d82f341..2b34b06b 100644 --- a/tests/phantoms/test_ellipse_phantom.py +++ b/tests/phantoms/test_ellipse_phantom.py @@ -1,17 +1,5 @@ """Tests for ellipse phantom.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.data import SpatialDimension diff --git a/tests/utils/test_filters.py b/tests/utils/test_filters.py index a43a2db9..90402cdc 100644 --- a/tests/utils/test_filters.py +++ b/tests/utils/test_filters.py @@ -1,17 +1,5 @@ """Tests for filters.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.utils.filters import filter_separable, gaussian_filter, uniform_filter diff --git a/tests/utils/test_modify_acq_info.py b/tests/utils/test_modify_acq_info.py index 5e41a273..451303d0 100644 --- a/tests/utils/test_modify_acq_info.py +++ b/tests/utils/test_modify_acq_info.py @@ -1,17 +1,5 @@ """Tests for modification of acquisition infos.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from einops import rearrange from mrpro.utils import modify_acq_info diff --git a/tests/utils/test_split_idx.py b/tests/utils/test_split_idx.py index 3e7b4737..6997fc1b 100644 --- a/tests/utils/test_split_idx.py +++ b/tests/utils/test_split_idx.py @@ -1,17 +1,5 @@ """Tests of split index calculation.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from einops import repeat diff --git a/tests/utils/test_window.py b/tests/utils/test_window.py index c460e8d6..100f0468 100644 --- a/tests/utils/test_window.py +++ b/tests/utils/test_window.py @@ -1,17 +1,5 @@ """Tests for sliding windows.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import pytest import torch from mrpro.utils.sliding_window import sliding_window diff --git a/tests/utils/test_zero_pad_or_crop.py b/tests/utils/test_zero_pad_or_crop.py index b2be44d2..7a0529e7 100644 --- a/tests/utils/test_zero_pad_or_crop.py +++ b/tests/utils/test_zero_pad_or_crop.py @@ -1,17 +1,5 @@ """Tests for zero padding and cropping of data tensors.""" -# Copyright 2023 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import torch from mrpro.utils.zero_pad_or_crop import zero_pad_or_crop From 627a0416a7b32a0ac66432079ef96f47da628d48 Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Wed, 31 Jul 2024 17:45:58 +0200 Subject: [PATCH 18/34] Add file.flush() after file.write() in examples (#374) --- examples/direct_reconstruction.ipynb | 7 +++---- examples/direct_reconstruction.py | 2 ++ examples/pulseq_2d_radial_golden_angle.ipynb | 10 +++++----- examples/pulseq_2d_radial_golden_angle.py | 3 ++- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/examples/direct_reconstruction.ipynb b/examples/direct_reconstruction.ipynb index eff59cbe..0bf314b2 100644 --- a/examples/direct_reconstruction.ipynb +++ b/examples/direct_reconstruction.ipynb @@ -29,9 +29,7 @@ "cell_type": "code", "execution_count": null, "id": "0c08b3fd", - "metadata": { - "lines_to_next_cell": 0 - }, + "metadata": {}, "outputs": [], "source": [ "# Download raw data\n", @@ -44,7 +42,8 @@ "data_folder = Path(tempfile.mkdtemp())\n", "data_file = tempfile.NamedTemporaryFile(dir=data_folder, mode='wb', delete=False, suffix='.h5')\n", "response = requests.get(zenodo_url + fname, timeout=30)\n", - "data_file.write(response.content)" + "data_file.write(response.content)\n", + "data_file.flush()" ] }, { diff --git a/examples/direct_reconstruction.py b/examples/direct_reconstruction.py index 8d7de0c4..df13fc56 100644 --- a/examples/direct_reconstruction.py +++ b/examples/direct_reconstruction.py @@ -17,6 +17,8 @@ data_file = tempfile.NamedTemporaryFile(dir=data_folder, mode='wb', delete=False, suffix='.h5') response = requests.get(zenodo_url + fname, timeout=30) data_file.write(response.content) +data_file.flush() + # %% [markdown] # ### Image reconstruction # We use the DirectReconstruction class to reconstruct images from 2D radial data. diff --git a/examples/pulseq_2d_radial_golden_angle.ipynb b/examples/pulseq_2d_radial_golden_angle.ipynb index 21e6f894..99833d95 100644 --- a/examples/pulseq_2d_radial_golden_angle.ipynb +++ b/examples/pulseq_2d_radial_golden_angle.ipynb @@ -55,7 +55,8 @@ "source": [ "# Download raw data using requests\n", "response = requests.get(zenodo_url + fname, timeout=30)\n", - "data_file.write(response.content)" + "data_file.write(response.content)\n", + "data_file.flush()" ] }, { @@ -148,9 +149,7 @@ "cell_type": "code", "execution_count": null, "id": "fec370a5", - "metadata": { - "lines_to_next_cell": 2 - }, + "metadata": {}, "outputs": [], "source": [ "# download the sequence file from zenodo\n", @@ -158,7 +157,8 @@ "seq_fname = 'pulseq_radial_2D_402spokes_golden_angle.seq'\n", "seq_file = tempfile.NamedTemporaryFile(dir=data_folder, mode='wb', delete=False, suffix='.seq')\n", "response = requests.get(zenodo_url + seq_fname, timeout=30)\n", - "seq_file.write(response.content)" + "seq_file.write(response.content)\n", + "seq_file.flush()" ] }, { diff --git a/examples/pulseq_2d_radial_golden_angle.py b/examples/pulseq_2d_radial_golden_angle.py index 00865afd..9948e034 100644 --- a/examples/pulseq_2d_radial_golden_angle.py +++ b/examples/pulseq_2d_radial_golden_angle.py @@ -29,6 +29,7 @@ # Download raw data using requests response = requests.get(zenodo_url + fname, timeout=30) data_file.write(response.content) +data_file.flush() # %% [markdown] # ### Image reconstruction using KTrajectoryIsmrmrd @@ -94,7 +95,7 @@ seq_file = tempfile.NamedTemporaryFile(dir=data_folder, mode='wb', delete=False, suffix='.seq') response = requests.get(zenodo_url + seq_fname, timeout=30) seq_file.write(response.content) - +seq_file.flush() # %% # Read raw data and calculate trajectory using KTrajectoryPulseq From a8d75e5848652d6bd7799c77b957a056cdd3c968 Mon Sep 17 00:00:00 2001 From: Andreas Kofler Date: Thu, 1 Aug 2024 11:03:15 +0200 Subject: [PATCH 19/34] Add callback to lbfgs, adam and CG (#353) Include the possibility to use a callback function for lbfgs, adam and CG. Further, the base-class for the OptimizerStatus is implemented. --- .../algorithms/optimizers/OptimizerStatus.py | 27 ++++++++++++++++++ src/mrpro/algorithms/optimizers/__init__.py | 1 + src/mrpro/algorithms/optimizers/adam.py | 18 ++++++++---- src/mrpro/algorithms/optimizers/cg.py | 20 +++++++++++-- src/mrpro/algorithms/optimizers/lbfgs.py | 20 +++++++++++-- tests/algorithms/test_cg.py | 4 ++- tests/algorithms/test_optimizers.py | 28 ++++++++++++++++++- 7 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 src/mrpro/algorithms/optimizers/OptimizerStatus.py diff --git a/src/mrpro/algorithms/optimizers/OptimizerStatus.py b/src/mrpro/algorithms/optimizers/OptimizerStatus.py new file mode 100644 index 00000000..17e74164 --- /dev/null +++ b/src/mrpro/algorithms/optimizers/OptimizerStatus.py @@ -0,0 +1,27 @@ +"""Optimizer Status base class.""" + +# Copyright 2024 Physikalisch-Technische Bundesanstalt +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import TypedDict + +import torch + + +class OptimizerStatus(TypedDict): + """Base class for OptimizerStatus.""" + + solution: tuple[torch.Tensor, ...] + """Current estimate(s) of the solution. """ + + iteration_number: int + """Current iteration of the (iterative) algorithm.""" diff --git a/src/mrpro/algorithms/optimizers/__init__.py b/src/mrpro/algorithms/optimizers/__init__.py index 5ae3074b..5076e454 100644 --- a/src/mrpro/algorithms/optimizers/__init__.py +++ b/src/mrpro/algorithms/optimizers/__init__.py @@ -1,3 +1,4 @@ +from mrpro.algorithms.optimizers.OptimizerStatus import OptimizerStatus from mrpro.algorithms.optimizers.adam import adam from mrpro.algorithms.optimizers.cg import cg from mrpro.algorithms.optimizers.lbfgs import lbfgs diff --git a/src/mrpro/algorithms/optimizers/adam.py b/src/mrpro/algorithms/optimizers/adam.py index 2ac27fe2..f9667ea1 100644 --- a/src/mrpro/algorithms/optimizers/adam.py +++ b/src/mrpro/algorithms/optimizers/adam.py @@ -1,10 +1,11 @@ """ADAM for solving non-linear minimization problems.""" -from collections.abc import Sequence +from collections.abc import Callable, Sequence import torch from torch.optim import Adam, AdamW +from mrpro.algorithms.optimizers.OptimizerStatus import OptimizerStatus from mrpro.operators.Operator import Operator @@ -18,6 +19,7 @@ def adam( weight_decay: float = 0, amsgrad: bool = False, decoupled_weight_decay: bool = False, + callback: Callable[[OptimizerStatus], None] | None = None, ) -> tuple[torch.Tensor, ...]: """Adam for non-linear minimization problems. @@ -38,12 +40,14 @@ def adam( eps term added to the denominator to improve numerical stability weight_decay - weight decay (L2 penalty) + weight decay (L2 penalty if decoupled_weight_decay is False) amsgrad whether to use the AMSGrad variant of this algorithm from the paper `On the Convergence of Adam and Beyond` decoupled_weight_decay whether to use Adam (default) or AdamW (if set to true) [1]_ + callback + function to be called after each iteration Returns ------- @@ -54,7 +58,7 @@ def adam( .. [1] Loshchilov I, Hutter F (2019) Decoupled Weight Decay Regularization. ICLR https://doi.org/10.48550/arXiv.1711.05101 """ - parameters = [p.detach().clone().requires_grad_(True) for p in initial_parameters] + parameters = tuple(p.detach().clone().requires_grad_(True) for p in initial_parameters) optim: AdamW | Adam @@ -67,10 +71,14 @@ def closure(): optim.zero_grad() (objective,) = f(*parameters) objective.backward() + + if callback is not None: + callback({'solution': parameters, 'iteration_number': iteration}) + return objective # run adam - for _ in range(max_iter): + for iteration in range(max_iter): # noqa: B007 optim.step(closure) - return tuple(parameters) + return parameters diff --git a/src/mrpro/algorithms/optimizers/cg.py b/src/mrpro/algorithms/optimizers/cg.py index 10436381..7f1c74af 100644 --- a/src/mrpro/algorithms/optimizers/cg.py +++ b/src/mrpro/algorithms/optimizers/cg.py @@ -4,16 +4,24 @@ import torch +from mrpro.algorithms.optimizers.OptimizerStatus import OptimizerStatus from mrpro.operators.LinearOperator import LinearOperator +class CGStatus(OptimizerStatus): + """Conjugate gradient callback base class.""" + + residual: torch.Tensor + """Residual of the current estimate.""" + + def cg( operator: LinearOperator, right_hand_side: torch.Tensor, initial_value: torch.Tensor | None = None, max_iterations: int = 128, tolerance: float = 1e-4, - callback: Callable | None = None, + callback: Callable[[CGStatus], None] | None = None, ) -> torch.Tensor: """CG for solving a linear system Hx=b. @@ -47,7 +55,7 @@ def cg( tolerance for the residual; if set to zero, the maximal number of iterations is the only stopping criterion used to stop the cg callback - user-provided function to be called at each iteration + function to be called at each iteration Returns ------- @@ -102,6 +110,12 @@ def cg( residual_norm_squared_previous = residual_norm_squared if callback is not None: - callback(solution) + callback( + { + 'solution': (solution,), + 'iteration_number': iteration, + 'residual': residual, + } + ) return solution diff --git a/src/mrpro/algorithms/optimizers/lbfgs.py b/src/mrpro/algorithms/optimizers/lbfgs.py index ab272450..b6825d88 100644 --- a/src/mrpro/algorithms/optimizers/lbfgs.py +++ b/src/mrpro/algorithms/optimizers/lbfgs.py @@ -1,11 +1,12 @@ """LBFGS for solving non-linear minimization problems.""" -from collections.abc import Sequence +from collections.abc import Callable, Sequence from typing import Literal import torch from torch.optim import LBFGS +from mrpro.algorithms.optimizers.OptimizerStatus import OptimizerStatus from mrpro.operators.Operator import Operator @@ -19,6 +20,7 @@ def lbfgs( tolerance_change: float = 1e-09, history_size: int = 10, line_search_fn: None | Literal['strong_wolfe'] = 'strong_wolfe', + callback: Callable[[OptimizerStatus], None] | None = None, ) -> tuple[torch.Tensor, ...]: """LBFGS for non-linear minimization problems. @@ -44,12 +46,15 @@ def lbfgs( update history size line_search_fn line search algorithm, either 'strong_wolfe' or None (meaning constant step size) + callback + function to be called after each iteration. + N.B. the callback is NOT called within the line search of LBFGS Returns ------- list of optimized parameters """ - parameters = [p.detach().clone().requires_grad_(True) for p in initial_parameters] + parameters = tuple(p.detach().clone().requires_grad_(True) for p in initial_parameters) optim = LBFGS( params=parameters, lr=lr, @@ -61,13 +66,22 @@ def lbfgs( line_search_fn=line_search_fn, ) + iteration = 0 + def closure(): + nonlocal iteration optim.zero_grad() (objective,) = f(*parameters) objective.backward() + if callback is not None: + state = optim.state[optim.param_groups[0]['params'][0]] + if state['n_iter'] > iteration: + callback({'solution': parameters, 'iteration_number': iteration}) + iteration = state['n_iter'] + return objective # run lbfgs optim.step(closure) - return tuple(parameters) + return parameters diff --git a/tests/algorithms/test_cg.py b/tests/algorithms/test_cg.py index efa26ab0..16c16e78 100644 --- a/tests/algorithms/test_cg.py +++ b/tests/algorithms/test_cg.py @@ -4,6 +4,7 @@ import scipy import torch from mrpro.algorithms.optimizers import cg +from mrpro.algorithms.optimizers.cg import CGStatus from mrpro.operators import EinsumOp from scipy.sparse.linalg import cg as cg_scp from tests import RandomGenerator @@ -139,7 +140,8 @@ def test_callback(system): # callback function; if the function is called during the iterations, the # test is successful - def callback(solution): + def callback(cg_status: CGStatus) -> None: + _, _, _ = cg_status['iteration_number'], cg_status['solution'][0], cg_status['residual'].norm() assert True cg(h_operator, right_hand_side, callback=callback) diff --git a/tests/algorithms/test_optimizers.py b/tests/algorithms/test_optimizers.py index 75a8d965..bda6820e 100644 --- a/tests/algorithms/test_optimizers.py +++ b/tests/algorithms/test_optimizers.py @@ -2,7 +2,7 @@ import pytest import torch -from mrpro.algorithms.optimizers import adam, lbfgs +from mrpro.algorithms.optimizers import OptimizerStatus, adam, lbfgs from mrpro.operators import ConstraintsOp from tests.operators import Rosenbrock @@ -50,3 +50,29 @@ def test_optimizers_rosenbrock(optimizer, enforce_bounds_on_x1, optimizer_kwargs for p, before, grad_before in zip(params_init, params_init_before, params_init_grad_before, strict=True): assert p == before, 'the initial parameter should not have changed during optimization' assert p.grad == grad_before, 'the initial parameters gradient should not have changed during optimization' + + +@pytest.mark.parametrize('optimizer', [adam, lbfgs]) +def test_callback_optimizers(optimizer): + """Test if a callback function is called within the optimizers.""" + + # use Rosenbrock function as test case with 2D test data + a, b = 1.0, 100.0 + rosen_brock = Rosenbrock(a, b) + + # initial point of optimization + parameter1 = torch.tensor([a / 3.14], requires_grad=True) + parameter2 = torch.tensor([3.14], requires_grad=True) + parameters = [parameter1, parameter2] + + # maximum number of iterations + max_iter = 10 + + # callback function; if the function is called during the iterations, the + # test is successful + def callback(optimizer_status: OptimizerStatus) -> None: + _, _ = optimizer_status['iteration_number'], optimizer_status['solution'][0] + assert True + + # run optimizer + _ = optimizer(rosen_brock, parameters, max_iter=max_iter, callback=callback) From a5964be6307123350fc327e2e84f83362964e17a Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Sat, 3 Aug 2024 20:19:19 +0200 Subject: [PATCH 20/34] Fix doc string formatting (#376) - Change references from numbering (e.g. [1]_) to letters (e.g. [KOL2012]_) - Use :math:'some nice latex' rather than .. math:: some nice latex - Make sure no symbols are misinterpreted as e.g. emphasis --- src/mrpro/algorithms/csm/iterative_walsh.py | 5 +- src/mrpro/algorithms/optimizers/adam.py | 7 +- src/mrpro/algorithms/optimizers/cg.py | 21 ++--- src/mrpro/algorithms/prewhiten_kspace.py | 22 ++--- src/mrpro/data/EncodingLimits.py | 5 +- src/mrpro/data/IData.py | 15 ++-- src/mrpro/data/MoveDataMixin.py | 15 ++-- src/mrpro/data/_kdata/KDataProtocol.py | 4 +- src/mrpro/data/_kdata/KDataRemoveOsMixin.py | 4 +- src/mrpro/data/enums.py | 4 +- .../traj_calculators/KTrajectoryIsmrmrd.py | 4 +- .../data/traj_calculators/KTrajectoryRpe.py | 20 +++-- src/mrpro/operators/EinsumOp.py | 44 +++++----- src/mrpro/operators/FastFourierOp.py | 4 +- src/mrpro/operators/GridSamplingOp.py | 4 +- .../functionals/MSEDataDiscrepancy.py | 13 ++- .../operators/models/InversionRecovery.py | 3 +- src/mrpro/operators/models/MOLLI.py | 3 +- .../operators/models/MonoExponentialDecay.py | 3 +- .../operators/models/SaturationRecovery.py | 3 +- .../TransientSteadyStateWithPreparation.py | 42 +++++----- src/mrpro/operators/models/WASABI.py | 10 +-- src/mrpro/operators/models/WASABITI.py | 19 ++--- src/mrpro/phantoms/EllipsePhantom.py | 7 +- src/mrpro/phantoms/coils.py | 8 +- src/mrpro/utils/Rotation.py | 81 +++++++++---------- 26 files changed, 170 insertions(+), 200 deletions(-) diff --git a/src/mrpro/algorithms/csm/iterative_walsh.py b/src/mrpro/algorithms/csm/iterative_walsh.py index 6e423dbb..5e95431f 100644 --- a/src/mrpro/algorithms/csm/iterative_walsh.py +++ b/src/mrpro/algorithms/csm/iterative_walsh.py @@ -16,7 +16,7 @@ def iterative_walsh( This is for a single set of coil images. The input should be a tensor with dimensions (coils, z, y, x). The output will have the same dimensions. Either apply this function individually to each set of coil images, - or see CsmData.from_idata_walsh which performs this operation on a whole dataset [1]_. + or see CsmData.from_idata_walsh which performs this operation on a whole dataset [WAL2000]_. This function is inspired by https://github.com/ismrmrd/ismrmrd-python-tools. @@ -31,8 +31,7 @@ def iterative_walsh( References ---------- - .. [1] Daval-Frerot G, Ciuciu P (2022) Iterative static field map estimation for off-resonance correction in - non-Cartesian susceptibility weighted imaging. MRM 88(4): mrm.29297. + .. [WAL2000] Walsh DO, Gmitro AF, Marcellin MW (2000) Adaptive reconstruction of phased array MR imagery. MRM 43 """ if isinstance(smoothing_width, int): smoothing_width = SpatialDimension(smoothing_width, smoothing_width, smoothing_width) diff --git a/src/mrpro/algorithms/optimizers/adam.py b/src/mrpro/algorithms/optimizers/adam.py index f9667ea1..ed145b41 100644 --- a/src/mrpro/algorithms/optimizers/adam.py +++ b/src/mrpro/algorithms/optimizers/adam.py @@ -45,18 +45,19 @@ def adam( whether to use the AMSGrad variant of this algorithm from the paper `On the Convergence of Adam and Beyond` decoupled_weight_decay - whether to use Adam (default) or AdamW (if set to true) [1]_ + whether to use Adam (default) or AdamW (if set to true) [LOS2019]_ callback function to be called after each iteration + Returns ------- list of optimized parameters References ---------- - .. [1] Loshchilov I, Hutter F (2019) Decoupled Weight Decay Regularization. ICLR - https://doi.org/10.48550/arXiv.1711.05101 + .. [LOS2019] Loshchilov I, Hutter F (2019) Decoupled Weight Decay Regularization. ICLR + https://doi.org/10.48550/arXiv.1711.05101 """ parameters = tuple(p.detach().clone().requires_grad_(True) for p in initial_parameters) diff --git a/src/mrpro/algorithms/optimizers/cg.py b/src/mrpro/algorithms/optimizers/cg.py index 7f1c74af..54cb1d78 100644 --- a/src/mrpro/algorithms/optimizers/cg.py +++ b/src/mrpro/algorithms/optimizers/cg.py @@ -23,22 +23,23 @@ def cg( tolerance: float = 1e-4, callback: Callable[[CGStatus], None] | None = None, ) -> torch.Tensor: - """CG for solving a linear system Hx=b. + r"""CG for solving a linear system :math:`Hx=b`. - Thereby, H is a linear self-adjoint operator, b is the right-hand-side - of the system and x is the sought solution. + Thereby, :math:`H` is a linear self-adjoint operator, :math:`b` is the right-hand-side + of the system and :math:`x` is the sought solution. - Note that this implementation allows for simultaneously solving a batch of N problems - of the form - H_i x_i = b_i, i=1,...,N. - Thereby, the underlying assumption is that the considered problem is H x = b with - H:= diag(H_1, ..., H_N), b:= [b_1, ..., b_N]^T. - Thus, if all H_i are self-adjoint, so is H and the CG can be applied. + Note that this implementation allows for simultaneously solving a batch of :math:`N` problems + of the form :math:`H_i x_i = b_i` with :math:`i=1,...,N`. + + Thereby, the underlying assumption is that the considered problem is :math:`Hx=b` with + :math:`H:= diag(H_1, ..., H_N)` and :math:`b:= [b_1, ..., b_N]^T`. + + Thus, if all :math:`H_i` are self-adjoint, so is :math:`H` and the CG can be applied. Note however, that the accuracy of the obtained solutions might vary among the different problems. Note also that we don't test if the input operator is self-adjoint or not. - Further, note that if the condition of H is very large, a small residual does not necessarily + Further, note that if the condition of :math:`H` is very large, a small residual does not necessarily imply that the solution is accurate. Parameters diff --git a/src/mrpro/algorithms/prewhiten_kspace.py b/src/mrpro/algorithms/prewhiten_kspace.py index 953d4e44..71f1ba56 100644 --- a/src/mrpro/algorithms/prewhiten_kspace.py +++ b/src/mrpro/algorithms/prewhiten_kspace.py @@ -10,16 +10,16 @@ def prewhiten_kspace(kdata: KData, knoise: KNoise, scale_factor: float | torch.Tensor = 1.0) -> KData: - """Calculate noise prewhitening matrix and decorrelate coils [1]_ [2]_ [3]_. + """Calculate noise prewhitening matrix and decorrelate coils. - Step 1: Calculate noise correlation matrix N - Step 2: Carry out Cholesky decomposition L L^H = N - Step 3: Estimate noise decorrelation matrix D = inv(L) - Step 4: Apply D to k-space data + Steps: - More information can be found in - http://onlinelibrary.wiley.com/doi/10.1002/jmri.24687/full - https://doi.org/10.1002/mrm.1910160203 + - Calculate noise correlation matrix N + - Carry out Cholesky decomposition L L^H = N + - Estimate noise decorrelation matrix D = inv(L) + - Apply D to k-space data + + More information can be found in [ISMa]_ [HAN2014]_ [ROE1990]_. If the the data has more samples in the 'other'-dimensions (batch/slice/...), the noise covariance matrix is calculated jointly over all samples. @@ -43,10 +43,10 @@ def prewhiten_kspace(kdata: KData, knoise: KNoise, scale_factor: float | torch.T References ---------- - .. [1] https://github.com/ismrmrd/ismrmrd-python-tools - .. [2] Hansen M, Kellman P (2014) Image reconstruction: An overview for clinicians. JMRI 41(3): jmri.24687. + .. [ISMa] ISMRMRD Python tools https://github.com/ismrmrd/ismrmrd-python-tools + .. [HAN2014] Hansen M, Kellman P (2014) Image reconstruction: An overview for clinicians. JMRI 41(3) https://doi.org/10.1002/jmri.24687 - .. [3] Roemer P, Mueller O (1990) The NMR phased array. MRM 16(2): mrm.1910160203. + .. [ROE1990] Roemer P, Mueller O (1990) The NMR phased array. MRM 16(2) https://doi.org/10.1002/mrm.1910160203 """ # Reshape noise to (coil, everything else) diff --git a/src/mrpro/data/EncodingLimits.py b/src/mrpro/data/EncodingLimits.py index cdf1fecd..546cff88 100644 --- a/src/mrpro/data/EncodingLimits.py +++ b/src/mrpro/data/EncodingLimits.py @@ -35,12 +35,11 @@ def length(self) -> int: @dataclass(slots=True) class EncodingLimits: - """Encoding limits dataclass with limits for each attribute [1]_. + """Encoding limits dataclass with limits for each attribute [INA2016]_. References ---------- - .. [1] Inati S, Hanse M (2016) ISMRM Raw data format: - A proposed standard for MRI raw datasets. MRM 77(1): mrm.26089. + .. [INA2016] Inati S, Hansen M (2016) ISMRM Raw data format: A proposed standard for MRI raw datasets. MRM 77(1) https://doi.org/10.1002/mrm.26089 """ diff --git a/src/mrpro/data/IData.py b/src/mrpro/data/IData.py index 3ecfa5e8..c48a8ed0 100644 --- a/src/mrpro/data/IData.py +++ b/src/mrpro/data/IData.py @@ -20,16 +20,16 @@ def _dcm_pixelarray_to_tensor(dataset: Dataset) -> torch.Tensor: """Transform pixel array in dicom file to tensor. - "Rescale intercept, (0028|1052), and rescale slope (0028|1053) are + Rescale intercept, (0028|1052), and rescale slope (0028|1053) are DICOM tags that specify the linear transformation from pixels in their stored on disk representation to their in memory representation. U = m*SV + b where U is in output units, m is the rescale slope, SV is the stored value, and b is the rescale - intercept." [1]_ + intercept. [RES]_ References ---------- - .. [1] https://www.kitware.com/dicom-rescale-intercept-rescale-slope-and-itk/ + .. [RES] Rescale intercept and slope https://www.kitware.com/dicom-rescale-intercept-rescale-slope-and-itk/ """ slope = ( float(element.value) @@ -59,15 +59,12 @@ def rss(self, keepdim: bool = False) -> torch.Tensor: Parameters ---------- keepdim - if True, the output tensor has the same number of dimensions as the data tensor, - and the coil dimension is kept as a singleton dimension. - If False, the coil dimension is removed. + if True, the output tensor has the same number of dimensions as the data tensor, and the coil dimension is + kept as a singleton dimension. If False, the coil dimension is removed. Returns ------- - image data tensor with shape either - (..., 1, z, y, x) if keepdim is True. - or (..., z, y, x), if keepdim if False. + image data tensor with shape (..., 1, z, y, x) if keepdim is True or (..., z, y, x) if keepdim is False. """ coildim = -4 return self.data.abs().square().sum(dim=coildim, keepdim=keepdim).sqrt() diff --git a/src/mrpro/data/MoveDataMixin.py b/src/mrpro/data/MoveDataMixin.py index e2b8d9ef..90f189e4 100644 --- a/src/mrpro/data/MoveDataMixin.py +++ b/src/mrpro/data/MoveDataMixin.py @@ -59,7 +59,7 @@ def to(self, *args, **kwargs) -> Self: """Perform dtype and/or device conversion of data. A torch.dtype and torch.device are inferred from the arguments - of self.to(*args, **kwargs). Please have a look at the + args and kwargs. Please have a look at the documentation of torch.Tensor.to() for more details. A new instance of the dataclass will be returned. @@ -68,15 +68,16 @@ def to(self, *args, **kwargs) -> Self: fields of the dataclass, and to all fields that implement the MoveDataMixin. - The dtype-type, i.e. float/complex will always be preserved, + The dtype-type, i.e. float or complex will always be preserved, but the precision of floating point dtypes might be changed. Example: - If called with dtype=torch.float32 OR dtype=torch.complex64: - - A complex128 tensor will be converted to complex64 - - A float64 tensor will be converted to float32 - - A bool tensor will remain bool - - An int64 tensor will remain int64 + If called with dtype=torch.float32 OR dtype=torch.complex64: + + - A complex128 tensor will be converted to complex64 + - A float64 tensor will be converted to float32 + - A bool tensor will remain bool + - An int64 tensor will remain int64 If other conversions are desired, please use the torch.Tensor.to() method of the fields directly. diff --git a/src/mrpro/data/_kdata/KDataProtocol.py b/src/mrpro/data/_kdata/KDataProtocol.py index 3267b53d..2371af96 100644 --- a/src/mrpro/data/_kdata/KDataProtocol.py +++ b/src/mrpro/data/_kdata/KDataProtocol.py @@ -13,11 +13,11 @@ class _KDataProtocol(Protocol): Note that the actual KData class can have more properties and methods than those defined here. If you want to use a property or method of KData in a new KDataMixin class, - you must add it to this Protocol to make sure that the type hinting works [1]_. + you must add it to this Protocol to make sure that the type hinting works [PRO]_. References ---------- - .. [1] https://typing.readthedocs.io/en/latest/spec/protocol.html#protocols + .. [PRO] Protocols https://typing.readthedocs.io/en/latest/spec/protocol.html#protocols """ @property diff --git a/src/mrpro/data/_kdata/KDataRemoveOsMixin.py b/src/mrpro/data/_kdata/KDataRemoveOsMixin.py index 91d6623b..a040fc87 100644 --- a/src/mrpro/data/_kdata/KDataRemoveOsMixin.py +++ b/src/mrpro/data/_kdata/KDataRemoveOsMixin.py @@ -12,7 +12,7 @@ class KDataRemoveOsMixin(_KDataProtocol): """Remove oversampling along readout dimension.""" def remove_readout_os(self: Self) -> Self: - """Remove any oversampling along the readout (k0) direction [1]_. + """Remove any oversampling along the readout (k0) direction [GAD]_. Returns a copy of the data. @@ -32,7 +32,7 @@ def remove_readout_os(self: Self) -> Self: References ---------- - .. [1] https://github.com/gadgetron/gadgetron-python + .. [GAD] Gadgetron https://github.com/gadgetron/gadgetron-python """ from mrpro.operators.FastFourierOp import FastFourierOp diff --git a/src/mrpro/data/enums.py b/src/mrpro/data/enums.py index 670d8b37..b14de615 100644 --- a/src/mrpro/data/enums.py +++ b/src/mrpro/data/enums.py @@ -7,11 +7,11 @@ class AcqFlags(Flag): """Acquisition flags. NOTE: values in enum ISMRMRD_AcquisitionFlags start at 1 and not 0, but - 1 << (val-1) is used in 'ismrmrd_is_flag_set' function to calc bitmask value [1]_. + 1 << (val-1) is used in 'ismrmrd_is_flag_set' function to calc bitmask value [ISMb]_. References ---------- - .. [1] https://github.com/ismrmrd/ismrmrd/blob/master/include/ismrmrd/ismrmrd.h + .. [ISMb] ISMRMRD https://github.com/ismrmrd/ismrmrd/blob/master/include/ismrmrd/ismrmrd.h """ ACQ_NO_FLAG = 0 diff --git a/src/mrpro/data/traj_calculators/KTrajectoryIsmrmrd.py b/src/mrpro/data/traj_calculators/KTrajectoryIsmrmrd.py index 05f6404b..2b6aad36 100644 --- a/src/mrpro/data/traj_calculators/KTrajectoryIsmrmrd.py +++ b/src/mrpro/data/traj_calculators/KTrajectoryIsmrmrd.py @@ -11,7 +11,7 @@ class KTrajectoryIsmrmrd: """Get trajectory in ISMRMRD raw data file. - The trajectory in the ISMRMRD raw data file is read out [1]_. + The trajectory in the ISMRMRD raw data file is read out [TRA]_. The value range of the trajectory in the ISMRMRD file is not well defined. Here we simple normalize everything based on the highest value and ensure it is within [-pi, pi]. The trajectory is in the shape of the unsorted @@ -19,7 +19,7 @@ class KTrajectoryIsmrmrd: References ---------- - .. [1] https://ismrmrd.readthedocs.io/en/latest/mrd_raw_data.html#k-space-trajectory + .. [TRA] ISMRMRD trajectory https://ismrmrd.readthedocs.io/en/latest/mrd_raw_data.html#k-space-trajectory """ def __call__(self, acquisitions: Sequence[ismrmrd.Acquisition]) -> KTrajectoryRawShape: diff --git a/src/mrpro/data/traj_calculators/KTrajectoryRpe.py b/src/mrpro/data/traj_calculators/KTrajectoryRpe.py index c1ef4dd6..7873de7f 100644 --- a/src/mrpro/data/traj_calculators/KTrajectoryRpe.py +++ b/src/mrpro/data/traj_calculators/KTrajectoryRpe.py @@ -11,16 +11,15 @@ class KTrajectoryRpe(KTrajectoryCalculator): """Radial phase encoding trajectory. Frequency encoding along kx is carried out in a standard Cartesian way. The phase encoding points along ky and kz - are positioned along radial lines [1]_ [2]_. + are positioned along radial lines [BOU2009]_ [KOL2014]_. References ---------- - .. [1] Boubertakh R, Schaeffter T (2009) Whole-heart imaging using undersampled radial phase encoding (RPE) - and iterative sensitivity encoding (SENSE) reconstruction. MRM 62(5): mrm.22102. - https://doi.org/10.1002/mrm.22102 - .. [2] Kolbitsch C, Schaeffter T (2014) A 3D MR-acquisition scheme for nonrigid bulk motion correction - in simultaneous PET-MR. Medical Physics 41(8): 1.4890095. - https://doi.org/10.1118/1.4890095 + .. [BOU2009] Boubertakh R, Schaeffter T (2009) Whole-heart imaging using undersampled radial phase encoding (RPE) + and iterative sensitivity encoding (SENSE) reconstruction. MRM 62(5) https://doi.org/10.1002/mrm.22102 + + .. [KOL2014] Kolbitsch C, Schaeffter T (2014) A 3D MR-acquisition scheme for nonrigid bulk motion correction + in simultaneous PET-MR. Medical Physics 41(8) https://doi.org/10.1118/1.4890095 """ def __init__(self, angle: float, shift_between_rpe_lines: tuple | torch.Tensor = (0, 0.5, 0.25, 0.75)) -> None: @@ -44,7 +43,7 @@ def _apply_shifts_between_rpe_lines(self, krad: torch.Tensor, kang_idx: torch.Te Example: shift_between_rpe_lines = [0, 0.5, 0.25, 0.75] leads to a shift of the 0th line by 0, the 1st line by 0.5, the 2nd line by 0.25, the 3rd line by 0.75, the 4th line by 0, the 5th line - by 0.5 and so on. Phase encoding points in k-space center are not shifted [1]_. + by 0.5 and so on. Phase encoding points in k-space center are not shifted [PRI2010]_. Line # k-space points before shift k-space points after shift 0 + + + + + + + + + + + + + + @@ -63,9 +62,8 @@ def _apply_shifts_between_rpe_lines(self, krad: torch.Tensor, kang_idx: torch.Te References ---------- - .. [1] Prieto C, Schaeffter T (2010) 3D undersampled golden-radial phase encoding - for DCE-MRA using inherently regularized iterative SENSE. MRM 64(2): mrm.22446. - https://doi.org/10.1002/mrm.22446 + .. [PRI2010] Prieto C, Schaeffter T (2010) 3D undersampled golden-radial phase encoding + for DCE-MRA using inherently regularized iterative SENSE. MRM 64(2). https://doi.org/10.1002/mrm.22446 """ for ind, shift in enumerate(self.shift_between_rpe_lines): curr_angle_idx = torch.nonzero( diff --git a/src/mrpro/operators/EinsumOp.py b/src/mrpro/operators/EinsumOp.py index b6309011..868fe2dc 100644 --- a/src/mrpro/operators/EinsumOp.py +++ b/src/mrpro/operators/EinsumOp.py @@ -9,36 +9,34 @@ class EinsumOp(LinearOperator): - """A Linear Operator that implements sum products in einstein notation. + r"""A Linear Operator that implements sum products in Einstein notation. - Implements A_{indices_A}*x^{indices_x} = y{indices_y} + Implements :math:`A_{indices_A}*x^{indices_x} = y_{indices_y}` with Einstein summation rules over the indices, see torch.einsum or einops.einsum for more information. Note, that the indices must be space separated (einops convention). It can be used to implement tensor contractions, such as for example, different versions of - matrix-vector / matrix-matrix products of the form A @ x, depending on the chosen einsum rules and - shapes of A and x. + matrix-vector or matrix-matrix products of the form :math:`A @ x`, depending on the chosen einsum rules and + shapes of :math:`A` and :math:`x`. Examples are: - - matrix-vector multiplication of A and the batched vector x = [x1,...,xN] consisting - of N vectors x1, x2, ..., xN. Then, the operation defined by - A @ x := diag(A, A, ..., A) * [x1, x2, ..., xN]^T = [A*x1, A*x2, ..., A*xN]^T - can be implemented by the einsum rule - "i j, ... j -> ... i" - - - matrix-vector multiplication of a matrix A consisting of N different matrices - A1, A2, ... AN with one vector x. Then, the operation defined by - A @ x: = diag(A1, A2,..., AN) * [x, x, ..., x]^T - can be implemented by the einsum rule - "... i j, j -> ... i" - - - matrix-vector multiplication of a matrix A consisting of N different matrices - A1, A2, ... AN with a vector x = [x1,...,xN] consisting - of N vectors x1, x2, ..., xN. Then, the operation defined by - A @ x: = diag(A1, A2,..., AN) * [x1, x2, ..., xN]^T - can be implemented by the einsum rule - "... i j, ... j -> ... i" + + - matrix-vector multiplication of :math:`A` and the batched vector :math:`x = [x1,...,xN]` consisting + of :math:`N` vectors :math:`x1, x2, ..., xN`. Then, the operation defined by + :math:`A @ x := diag(A, A, ..., A) * [x1, x2, ..., xN]^T = [A*x1, A*x2, ..., A*xN]^T` + can be implemented by the einsum rule ``"i j, ... j -> ... i"``. + + - matrix-vector multiplication of a matrix :math:`A` consisting of :math:`N` different matrices + :math:`A1, A2, ... AN` with one vector :math:`x`. Then, the operation defined by + :math:`A @ x: = diag(A1, A2,..., AN) * [x, x, ..., x]^T` + can be implemented by the einsum rule ``"... i j, j -> ... i"``. + + - matrix-vector multiplication of a matrix :math:`A` consisting of :math:`N` different matrices + :math:`A1, A2, ... AN` with a vector :math:`x = [x1,...,xN]` consisting + of :math:`N` vectors :math:`x1, x2, ..., xN`. Then, the operation defined by + :math:`A @ x: = diag(A1, A2,..., AN) * [x1, x2, ..., xN]^T` + can be implemented by the einsum rule ``"... i j, ... j -> ... i"``. This is the default behaviour of the operator. """ @@ -48,7 +46,7 @@ def __init__(self, matrix: torch.Tensor, einsum_rule: str = '... i j, ... j -> . Parameters ---------- matrix - 'Matrix' `A` to be used as first factor in the sum product A*x + 'Matrix' :math:`A` to be used as first factor in the sum product :math:`A*x` einsum_rule Einstein summation rule describing the forward of the operator. diff --git a/src/mrpro/operators/FastFourierOp.py b/src/mrpro/operators/FastFourierOp.py index 9838c7fe..53f3f6eb 100644 --- a/src/mrpro/operators/FastFourierOp.py +++ b/src/mrpro/operators/FastFourierOp.py @@ -17,7 +17,7 @@ class FastFourierOp(LinearOperator): along these selected dimensions The transformation is done with 'ortho' normalization, i.e. the normalization constant is split between - forward and adjoint [1]_. + forward and adjoint [FFT]_. Remark regarding the fftshift/ifftshift: fftshift shifts the zero-frequency point to the center of the data, ifftshift undoes this operation. @@ -27,7 +27,7 @@ class FastFourierOp(LinearOperator): References ---------- - .. [1] https://numpy.org/doc/stable/reference/routines.fft.html + .. [FFT] FFT https://numpy.org/doc/stable/reference/routines.fft.html """ diff --git a/src/mrpro/operators/GridSamplingOp.py b/src/mrpro/operators/GridSamplingOp.py index 7e280463..ef906ef9 100644 --- a/src/mrpro/operators/GridSamplingOp.py +++ b/src/mrpro/operators/GridSamplingOp.py @@ -163,12 +163,12 @@ def __init__( padding_mode: Literal['zeros', 'border', 'reflection'] = 'zeros', align_corners: bool = False, ): - """Initialize Sampling Operator. + r"""Initialize Sampling Operator. Parameters ---------- grid - sampling grid. Shape *batchdim, z,y,x,3 / *batchdim, y,x,2. + sampling grid. Shape \*batchdim, z,y,x,3 / \*batchdim, y,x,2. Values should be in [-1, 1.] input_shape Used in the adjoint. The z,y,x shape of the domain of the operator. diff --git a/src/mrpro/operators/functionals/MSEDataDiscrepancy.py b/src/mrpro/operators/functionals/MSEDataDiscrepancy.py index e07f599b..df44746e 100644 --- a/src/mrpro/operators/functionals/MSEDataDiscrepancy.py +++ b/src/mrpro/operators/functionals/MSEDataDiscrepancy.py @@ -9,14 +9,11 @@ class MSEDataDiscrepancy(Operator[torch.Tensor, tuple[torch.Tensor]]): """Mean Squared Error (MSE) loss function. - This class implements the function - 1./N * || . - data ||_2^2, - where N equals to the number of elements of the tensor. - - N.B. if one of data or input is complex-valued, we - identify the space C^N with R^2N and multiply the output - by 2. By this, we achieve that for example - MSE(1) = MSE(1+1j*0) = 1. + This class implements the function :math:`1./N * || . - data ||_2^2`, where :math:`N` equals to the number of + elements of the tensor. + + Note: if one of data or input is complex-valued, we identify the space :math:`C^N` with :math:`R^{2N}` and + multiply the output by 2. By this, we achieve that for example :math:`MSE(1)` = :math:`MSE(1+1j*0)` = 1. Parameters ---------- diff --git a/src/mrpro/operators/models/InversionRecovery.py b/src/mrpro/operators/models/InversionRecovery.py index 42ed1fcd..0b23bb57 100644 --- a/src/mrpro/operators/models/InversionRecovery.py +++ b/src/mrpro/operators/models/InversionRecovery.py @@ -35,8 +35,7 @@ def forward(self, m0: torch.Tensor, t1: torch.Tensor) -> tuple[torch.Tensor,]: Returns ------- - signal - with shape (time ... other, coils, z, y, x) + signal with shape (time ... other, coils, z, y, x) """ delta_ndim = m0.ndim - (self.ti.ndim - 1) # -1 for time ti = self.ti[..., *[None] * (delta_ndim)] if delta_ndim > 0 else self.ti diff --git a/src/mrpro/operators/models/MOLLI.py b/src/mrpro/operators/models/MOLLI.py index fef5ab2a..311f8b2e 100644 --- a/src/mrpro/operators/models/MOLLI.py +++ b/src/mrpro/operators/models/MOLLI.py @@ -38,8 +38,7 @@ def forward(self, a: torch.Tensor, b: torch.Tensor, t1: torch.Tensor) -> tuple[t Returns ------- - signal - with shape (time ... other, coils, z, y, x) + signal with shape (time ... other, coils, z, y, x) """ delta_ndim = a.ndim - (self.ti.ndim - 1) # -1 for time ti = self.ti[..., *[None] * (delta_ndim)] if delta_ndim > 0 else self.ti diff --git a/src/mrpro/operators/models/MonoExponentialDecay.py b/src/mrpro/operators/models/MonoExponentialDecay.py index 90834f54..a17f79dd 100644 --- a/src/mrpro/operators/models/MonoExponentialDecay.py +++ b/src/mrpro/operators/models/MonoExponentialDecay.py @@ -35,8 +35,7 @@ def forward(self, m0: torch.Tensor, decay_constant: torch.Tensor) -> tuple[torch Returns ------- - signal - with shape (time ... other, coils, z, y, x) + signal with shape (time ... other, coils, z, y, x) """ delta_ndim = m0.ndim - (self.decay_time.ndim - 1) # -1 for time decay_time = self.decay_time[..., *[None] * (delta_ndim)] if delta_ndim > 0 else self.decay_time diff --git a/src/mrpro/operators/models/SaturationRecovery.py b/src/mrpro/operators/models/SaturationRecovery.py index 48aec24f..4182dab2 100644 --- a/src/mrpro/operators/models/SaturationRecovery.py +++ b/src/mrpro/operators/models/SaturationRecovery.py @@ -35,8 +35,7 @@ def forward(self, m0: torch.Tensor, t1: torch.Tensor) -> tuple[torch.Tensor,]: Returns ------- - signal - with shape (time ... other, coils, z, y, x) + signal with shape (time ... other, coils, z, y, x) """ delta_ndim = m0.ndim - (self.ti.ndim - 1) # -1 for time ti = self.ti[..., *[None] * (delta_ndim)] if delta_ndim > 0 else self.ti diff --git a/src/mrpro/operators/models/TransientSteadyStateWithPreparation.py b/src/mrpro/operators/models/TransientSteadyStateWithPreparation.py index 1ad86b5c..ac36473e 100644 --- a/src/mrpro/operators/models/TransientSteadyStateWithPreparation.py +++ b/src/mrpro/operators/models/TransientSteadyStateWithPreparation.py @@ -6,40 +6,39 @@ class TransientSteadyStateWithPreparation(SignalModel[torch.Tensor, torch.Tensor, torch.Tensor]): - """Signal model for transient steady state. + r"""Signal model for transient steady state. This signal model describes the behavior of the longitudinal magnetisation during continuous acquisition after a preparation pulse. The effect of the preparation pulse is modelled by a scaling factor applied to the equilibrium magnetisation. A delay after the preparation pulse can be defined. During this time T1 relaxation to M0 occurs. Data acquisition starts after this delay. Perfect spoiling is assumed and hence T2 effects are not - considered in the model. In addition this model assumes TR << T1 and TR << T1* (see definition below) [1]_ [2]_. + considered in the model. In addition this model assumes :math:`TR << T1` and :math:`TR << T1^*` + (see definition below) [DEI1992]_ [LOO1970]_. Let's assume we want to describe a continuous acquisition after an inversion pulse, then we have three parts: [Part A: 180° inversion pulse][Part B: spoiler gradient][Part C: Continuous data acquisition] - Part A: The 180° pulse leads to an inversion of the equilibrium magnetisation (M0) to -M0. This can be described by - setting the scaling factor m0_scaling_preparation to -1 + - Part A: The 180° pulse leads to an inversion of the equilibrium magnetisation (:math:`M_0`) to :math:`-M_0`. + This can be described by setting the scaling factor ``m0_scaling_preparation`` to -1. - Part B: Commonly after an inversion pulse a strong spoiler gradient is played out to compensate for non-perfect - inversion. During this time the magnetisation follows Mz(t) the signal model: - Mz(t) = M0 + (m0_scaling_preparation*M0 - M0)e^(-t / T1) + - Part B: Commonly after an inversion pulse a strong spoiler gradient is played out to compensate for non-perfect + inversion. During this time the magnetisation :math:`M_z(t)` follows the signal model: + :math:`M_z(t) = M_0 + (s * M_0 - M_0)e^{(-t / T1)}` where :math:`s` is ``m0_scaling_preparation``. - Part C: After the spoiler gradient the data acquisition starts and the magnetisation Mz(t) can be described by the - signal model: - Mz(t) = M0* + (M0_init - M0*)e^(-t / T1*) - where the initial magnetisation is - M0_init = M0 + (m0_scaling_preparation*M0 - M0)e^(-delay_after_preparation / T1) - the effective longitudinal relaxation time is - T1* = 1/(1/T1 - 1/repetition_time ln(cos(flip_angle))) - and the steady-state magnetisation is - M0* = M0 T1* / T1 + - Part C: After the spoiler gradient the data acquisition starts and the magnetisation :math:`M_z(t)` can be + described by the signal model: :math:`M_z(t) = M_0^* + (M_{init} - M_0^*)e^{(-t / T1^*)}` where the initial + magnetisation is :math:`M_{init} = M_0 + (s*M_0 - M_0)e^{(-\Delta t / T1)}` where :math:`s` is + ``m0_scaling_preparation`` and :math:`\Delta t` is ``delay_after_preparation``. The effective longitudinal + relaxation time is :math:`T1^* = 1/(1/T1 - ln(cos(\alpha)/TR)` + where :math:`TR` is ``repetition_time`` and :math:`\alpha` is ``flip_angle``. + The steady-state magnetisation is :math:`M_0^* = M_0 T1^* / T1`. References ---------- - .. [1] Deichmann R, Haase A (1992) Quantification of T1 values by SNAPSHOT-FLASH NMR imaging. J. Magn. Reson. 612 - http://doi.org/10.1016/0022-2364(92)90347-A - .. [2] Look D, Locker R (1970) Time Saving in Measurement of NMR and EPR Relaxation Times. Rev. Sci. Instrum 41 - https://doi.org/10.1063/1.1684482 + .. [DEI1992] Deichmann R, Haase A (1992) Quantification of T1 values by SNAPSHOT-FLASH NMR imaging. J. Magn. Reson. + 612 http://doi.org/10.1016/0022-2364(92)90347-A + .. [LOO1970] Look D, Locker R (1970) Time Saving in Measurement of NMR and EPR Relaxation Times. Rev. Sci. Instrum + 41 https://doi.org/10.1063/1.1684482 """ def __init__( @@ -91,8 +90,7 @@ def forward(self, m0: torch.Tensor, t1: torch.Tensor, flip_angle: torch.Tensor) Returns ------- - signal - with shape (time ... other, coils, z, y, x) + signal with shape (time ... other, coils, z, y, x) """ delta_ndim = m0.ndim - (self.sampling_time.ndim - 1) # -1 for time sampling_time = self.sampling_time[..., *[None] * (delta_ndim)] if delta_ndim > 0 else self.sampling_time diff --git a/src/mrpro/operators/models/WASABI.py b/src/mrpro/operators/models/WASABI.py index a31c0835..830dddde 100644 --- a/src/mrpro/operators/models/WASABI.py +++ b/src/mrpro/operators/models/WASABI.py @@ -17,7 +17,7 @@ def __init__( gamma: float | torch.Tensor = 42.5764, freq: float | torch.Tensor = 127.7292, ) -> None: - """Initialize WASABI signal model for mapping of B0 and B1 [1]_. + """Initialize WASABI signal model for mapping of B0 and B1 [SCHU2016]_. Parameters ---------- @@ -35,9 +35,8 @@ def __init__( References ---------- - .. [1] Schuenke P, Zaiss M (2016) Simultaneous mapping of water shift - and B1(WASABI)—Application to field-Inhomogeneity correction of CEST MRI data. MRM 77(2): mrm.26133. - https://doi.org/10.1002/mrm.26133 + .. [SCHU2016] Schuenke P, Zaiss M (2016) Simultaneous mapping of water shift and B1(WASABI)—Application to + field-Inhomogeneity correction of CEST MRI data. MRM 77(2). https://doi.org/10.1002/mrm.26133 """ super().__init__() # convert all parameters to tensors @@ -79,8 +78,7 @@ def forward( Returns ------- - signal - with shape (offsets ... other, coils, z, y, x) + signal with shape (offsets ... other, coils, z, y, x) """ delta_ndim = b0_shift.ndim - (self.offsets.ndim - 1) # -1 for offset offsets = self.offsets[..., *[None] * (delta_ndim)] if delta_ndim > 0 else self.offsets diff --git a/src/mrpro/operators/models/WASABITI.py b/src/mrpro/operators/models/WASABITI.py index ff7daae8..f89e12fa 100644 --- a/src/mrpro/operators/models/WASABITI.py +++ b/src/mrpro/operators/models/WASABITI.py @@ -18,18 +18,14 @@ def __init__( gamma: float | torch.Tensor = 42.5764, freq: float | torch.Tensor = 127.7292, ) -> None: - """Initialize WASABITI signal model for mapping of B0, B1 and T1 [1]_. - - For more details see: Proc. Intl. Soc. Mag. Reson. Med. 31 (2023): 0906 + """Initialize WASABITI signal model for mapping of B0, B1 and T1 [SCH2023]_. Parameters ---------- offsets - frequency offsets [Hz] - with shape (offsets, ...) + frequency offsets [Hz] with shape (offsets, ...) trec - recovery time between offsets [s] - with shape (offsets, ...) + recovery time between offsets [s] with shape (offsets, ...) tp RF pulse duration [s] b1_nom @@ -41,9 +37,9 @@ def __init__( References ---------- - .. [1] Papageorgakis C, Casagranda S (2023) Fast WASABI post-processing: - Access to rapid B0 and B1 correction in clinical routine for CEST MRI. MRM 102(203-2011). - https://doi.org/10.1016/j.mri.2023.06.001 + .. [SCH2023] Schuenke P, Zimmermann F, Kaspar K, Zaiss M, Kolbitsch C (2023) An Analytic Solution for the + Modified WASABI Method: Application to Simultaneous B0, B1 and T1 Mapping and Correction of CEST MRI, + Proceedings of the Annual Meeting of ISMRM """ super().__init__() # convert all parameters to tensors @@ -80,8 +76,7 @@ def forward(self, b0_shift: torch.Tensor, rb1: torch.Tensor, t1: torch.Tensor) - Returns ------- - signal - with shape (offsets ... other, coils, z, y, x) + signal with shape (offsets ... other, coils, z, y, x) """ delta_ndim = b0_shift.ndim - (self.offsets.ndim - 1) # -1 for offset offsets = self.offsets[..., *[None] * (delta_ndim)] if delta_ndim > 0 else self.offsets diff --git a/src/mrpro/phantoms/EllipsePhantom.py b/src/mrpro/phantoms/EllipsePhantom.py index a39ffc45..277e1bb1 100644 --- a/src/mrpro/phantoms/EllipsePhantom.py +++ b/src/mrpro/phantoms/EllipsePhantom.py @@ -43,7 +43,7 @@ def kspace(self, ky: torch.Tensor, kx: torch.Tensor) -> torch.Tensor: For a corresponding image with 256 x 256 voxel, the k-space locations should be defined within [-128, 127] - The Fourier representation of ellipses can be analytically described by Bessel functions [1]_. + The Fourier representation of ellipses can be analytically described by Bessel functions [KOA2007]_. Parameters ---------- @@ -54,9 +54,8 @@ def kspace(self, ky: torch.Tensor, kx: torch.Tensor) -> torch.Tensor: References ---------- - .. [1] Koay C, Sarlls J, Özarslan E (2007) Three-dimensional analytical magnetic resonance imaging - phantom in the Fourier domain. MRM 58(2): mrm.21292. - https://doi.org/10.1002/mrm.21292 + .. [KOA2007] Koay C, Sarlls J, Oezarslan E (2007) Three-dimensional analytical magnetic resonance imaging + phantom in the Fourier domain. MRM 58(2) https://doi.org/10.1002/mrm.21292 .. """ # kx and ky have to be of same shape diff --git a/src/mrpro/phantoms/coils.py b/src/mrpro/phantoms/coils.py index 76b78d76..0f984b51 100644 --- a/src/mrpro/phantoms/coils.py +++ b/src/mrpro/phantoms/coils.py @@ -15,6 +15,9 @@ def birdcage_2d( ) -> torch.Tensor: """Numerical simulation of 2D Birdcage coil sensitivities. + This function is strongly inspired by ISMRMRD Python Tools [ISMc]_. The associated license + information can be found at the end of this file. + Parameters ---------- number_of_coils @@ -27,12 +30,9 @@ def birdcage_2d( normalize_with_rss If set to true, the calculated sensitivities are normalized by the root-sum-of-squares - This function is strongly inspired by ISMRMRD Python Tools [1]_. The associated license - information can be found at the end of this file. - References ---------- - .. [1] https://github.com/ismrmrd/ismrmrd-python-tools + .. [ISMc] ISMRMRD Python tools https://github.com/ismrmrd/ismrmrd-python-tools """ dim = [number_of_coils, image_dimensions.y, image_dimensions.x] x_co, y_co = torch.meshgrid( diff --git a/src/mrpro/utils/Rotation.py b/src/mrpro/utils/Rotation.py index 0e2ad73a..caa48e69 100644 --- a/src/mrpro/utils/Rotation.py +++ b/src/mrpro/utils/Rotation.py @@ -255,6 +255,7 @@ class Rotation(torch.nn.Module): """A pytorch implementation of scipy.spatial.transform.Rotation. Differences compared to scipy.spatial.transform.Rotation: + - torch.nn.Module based, the quaternions are a Parameter - .apply is replaced by call/forward. - not all features are implemented. Notably, mrp, davenport, and reduce are missing. @@ -264,8 +265,7 @@ class Rotation(torch.nn.Module): def __init__(self, quaternions: torch.Tensor | _NestedSequence[float], normalize: bool = True, copy: bool = True): """Initialize a new Rotation. - Instead of calling this method, also consider the different - from_* class methods to construct a Rotation. + Instead of calling this method, also consider the different ``from_*`` class methods to construct a Rotation. Parameters ---------- @@ -314,7 +314,7 @@ def single(self) -> bool: def from_quat(cls, quaternions: torch.Tensor | _NestedSequence[float]) -> Self: """Initialize from quaternions. - 3D rotations can be represented using unit-norm quaternions [1]_. + 3D rotations can be represented using unit-norm quaternions [QUAa]_. Parameters ---------- @@ -331,7 +331,7 @@ def from_quat(cls, quaternions: torch.Tensor | _NestedSequence[float]) -> Self: References ---------- - .. [1] https://en.wikipedia.org/wiki/Quaternions_and_spatial_rotation + .. [QUAa] Quaternions and spatial rotation https://en.wikipedia.org/wiki/Quaternions_and_spatial_rotation """ if not isinstance(quaternions, torch.Tensor): quaternions = torch.as_tensor(quaternions) @@ -342,8 +342,8 @@ def from_matrix(cls, matrix: torch.Tensor | _NestedSequence[float]) -> Self: """Initialize from rotation matrix. Rotations in 3 dimensions can be represented with 3 x 3 proper - orthogonal matrices [1]_. If the input is not proper orthogonal, - an approximation is created using the method described in [2]_. + orthogonal matrices [ROTa]_. If the input is not proper orthogonal, + an approximation is created using the method described in [MAR2008]_. Parameters ---------- @@ -358,10 +358,9 @@ def from_matrix(cls, matrix: torch.Tensor | _NestedSequence[float]) -> Self: References ---------- - .. [1] https://en.wikipedia.org/wiki/Rotation_matrix#In_three_dimensions - .. [2] F. Landis Markley, "Unit Quaternion from Rotation Matrix", - Journal of guidance, control, and dynamics vol. 31.2, pp. - 440-442, 2008. + .. [ROTa] Rotation matrix https://en.wikipedia.org/wiki/Rotation_matrix#In_three_dimensions + .. [MAR2008] Landis Markley F (2008) Unit Quaternion from Rotation Matrix, Journal of guidance, control, and + dynamics 31(2),440-442. """ if not isinstance(matrix, torch.Tensor): matrix = torch.as_tensor(matrix) @@ -421,7 +420,7 @@ def from_euler(cls, seq: str, angles: torch.Tensor | _NestedSequence[float] | fl The three rotations can either be in a global frame of reference (extrinsic) or in a body centred frame of reference (intrinsic), which - is attached to, and moves with, the object under rotation [1]_. + is attached to, and moves with, the object under rotation [EULa]_. Parameters ---------- @@ -446,7 +445,7 @@ def from_euler(cls, seq: str, angles: torch.Tensor | _NestedSequence[float] | fl References ---------- - .. [1] https://en.wikipedia.org/wiki/Euler_angles#Definition_by_intrinsic_rotations + .. [EULa] Euler angles https://en.wikipedia.org/wiki/Euler_angles#Definition_by_intrinsic_rotations """ n_axes = len(seq) if n_axes < 1 or n_axes > 3: @@ -501,7 +500,7 @@ def as_quat(self, canonical: bool = False) -> torch.Tensor: """Represent as quaternions. Active rotations in 3 dimensions can be represented using unit norm - quaternions [1]_. The mapping from quaternions to rotations is + quaternions [QUAb]_. The mapping from quaternions to rotations is two-to-one, i.e. quaternions ``q`` and ``-q``, where ``-q`` simply reverses the sign of each component, represent the same spatial rotation. The returned value is in scalar-last (x, y, z, w) format. @@ -522,7 +521,7 @@ def as_quat(self, canonical: bool = False) -> torch.Tensor: References ---------- - .. [1] https://en.wikipedia.org/wiki/Quaternions_and_spatial_rotation + .. [QUAb] Quaternions https://en.wikipedia.org/wiki/Quaternions_and_spatial_rotation """ quaternions: torch.Tensor = self._quaternions if canonical: @@ -535,7 +534,7 @@ def as_matrix(self) -> torch.Tensor: """Represent as rotation matrix. 3D rotations can be represented using rotation matrices, which - are 3 x 3 real orthogonal matrices with determinant equal to +1 [1]_. + are 3 x 3 real orthogonal matrices with determinant equal to +1 [ROTb]_. Returns ------- @@ -544,7 +543,7 @@ def as_matrix(self) -> torch.Tensor: References ---------- - .. [1] https://en.wikipedia.org/wiki/Rotation_matrix#In_three_dimensions + .. [ROTb] Rotation matrix https://en.wikipedia.org/wiki/Rotation_matrix#In_three_dimensions """ quaternions = self._quaternions matrix = _quaternion_to_matrix(quaternions) @@ -557,7 +556,7 @@ def as_rotvec(self, degrees: bool = False) -> torch.Tensor: """Represent as rotation vectors. A rotation vector is a 3 dimensional vector which is co-directional to - the axis of rotation and whose norm gives the angle of rotation [1]_. + the axis of rotation and whose norm gives the angle of rotation [ROTc]_. Parameters ---------- @@ -571,7 +570,7 @@ def as_rotvec(self, degrees: bool = False) -> torch.Tensor: References ---------- - .. [1] https://en.wikipedia.org/wiki/Axis%E2%80%93angle_representation#Rotation_vector + .. [ROTc] Rotation vector https://en.wikipedia.org/wiki/Axis%E2%80%93angle_representation#Rotation_vector """ quaternions: torch.Tensor = self._quaternions quaternions = _canonical_quaternion(quaternions) # w > 0 ensures that 0 <= angle <= pi @@ -594,12 +593,12 @@ def as_euler(self, seq: str, degrees: bool = False) -> torch.Tensor: Any orientation can be expressed as a composition of 3 elementary rotations. Once the axis sequence has been chosen, Euler angles define - the angle of rotation around each respective axis [1]_. + the angle of rotation around each respective axis [EULb]_. - The algorithm from [2]_ has been used to calculate Euler angles for the + The algorithm from [BER2022]_ has been used to calculate Euler angles for the rotation about a given sequence of axes. - Euler angles suffer from the problem of gimbal lock [3]_, where the + Euler angles suffer from the problem of gimbal lock [GIM]_, where the representation loses a degree of freedom and it is not possible to determine the first and third angles uniquely. In this case, a warning is raised, and the third angle is set to zero. Note however @@ -609,7 +608,7 @@ def as_euler(self, seq: str, degrees: bool = False) -> torch.Tensor: ---------- seq 3 characters belonging to the set {'X', 'Y', 'Z'} for intrinsic - rotations, or {'x', 'y', 'z'} for extrinsic rotations [1]_. + rotations, or {'x', 'y', 'z'} for extrinsic rotations [EULb]_. Adjacent axes cannot be the same. Extrinsic and intrinsic rotations cannot be mixed in one function call. @@ -622,21 +621,20 @@ def as_euler(self, seq: str, degrees: bool = False) -> torch.Tensor: angles shape (3,) or (..., 3), depending on shape of inputs used to initialize object. The returned angles are in the range: + - First angle belongs to [-180, 180] degrees (both inclusive) - Third angle belongs to [-180, 180] degrees (both inclusive) - Second angle belongs to: - - [-90, 90] degrees if all axes are different (like xyz) - - [0, 180] degrees if first and third axes are the same - (like zxz) + + + [-90, 90] degrees if all axes are different (like xyz) + + [0, 180] degrees if first and third axes are the same (like zxz) References ---------- - .. [1] https://en.wikipedia.org/wiki/Euler_angles#Definition_by_intrinsic_rotations - .. [2] Bernardes E, Viollet S (2022) Quaternion to Euler angles - conversion: A direct, general and computationally efficient - method. PLoS ONE 17(11): e0276302. - https://doi.org/10.1371/journal.pone.0276302 - .. [3] https://en.wikipedia.org/wiki/Gimbal_lock#In_applied_mathematics + .. [EULb] Euler Angles https://en.wikipedia.org/wiki/Euler_angles#Definition_by_intrinsic_rotations + .. [BER2022] Bernardes E, Viollet S (2022) Quaternion to Euler angles conversion: A direct, general and + computationally efficient method. PLoS ONE 17(11) https://doi.org/10.1371/journal.pone.0276302 + .. [GIM] Gimbal lock https://en.wikipedia.org/wiki/Gimbal_lock#In_applied_mathematics """ if len(seq) != 3: raise ValueError(f'Expected 3 axes, got {seq}.') @@ -699,11 +697,9 @@ def forward( If the original frame rotates to the final frame by this rotation, then its application to a vector can be seen in two ways: - - As a projection of vector components expressed in the final frame - to the original frame. - - As the physical rotation of a vector being glued to the original - frame as it rotates. In this case the vector components are - expressed in the original frame before and after the rotation. + - As a projection of vector components expressed in the final frame to the original frame. + - As the physical rotation of a vector being glued to the original frame as it rotates. In this case the vector + components are expressed in the original frame before and after the rotation. In terms of rotation matrices, this application is the same as ``self.as_matrix() @ vectors``. @@ -724,6 +720,7 @@ def forward( rotated_vectors Result of applying rotation on input vectors. Shape depends on the following cases: + - If object contains a single rotation (as opposed to a stack with a single rotation) and a single vector is specified with shape ``(3,)``, then `rotated_vectors` has shape ``(3,)``. @@ -1213,14 +1210,10 @@ def mean( r"""Get the mean of the rotations. The mean used is the chordal L2 mean (also called the projected or - induced arithmetic mean) [1]_. If ``A`` is a set of rotation matrices, + induced arithmetic mean) [HAR2013]_. If ``A`` is a set of rotation matrices, then the mean ``M`` is the rotation matrix that minimizes the following loss function: - - .. math:: - - L(M) = \\sum_{i = 1}^{n} w_i \\lVert \\mathbf{A}_i - - \\mathbf{M} \\rVert^2 , + :math:`L(M) = \sum_{i = 1}^{n} w_i \lVert \mathbf{A}_i - \mathbf{M} \rVert^2`, where :math:`w_i`'s are the `weights` corresponding to each matrix. @@ -1247,8 +1240,8 @@ def mean( References ---------- - .. [1] Hartley R, Li H (2013) Rotation Averaging. International Journal of Computer Vision (103) - https://link.springer.com/article/10.1007/s11263-012-0601-0 + .. [HAR2013] Hartley R, Li H (2013) Rotation Averaging. International Journal of Computer Vision (103) + https://link.springer.com/article/10.1007/s11263-012-0601-0 """ if weights is None: From 296fd305e6ce2a16b2e95b38df18f8e8269b6a23 Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Sat, 3 Aug 2024 20:33:24 +0200 Subject: [PATCH 21/34] Allow tensor input for transient steady state model (#378) --- src/mrpro/operators/SignalModel.py | 23 ++++++++ .../operators/models/InversionRecovery.py | 3 +- src/mrpro/operators/models/MOLLI.py | 3 +- .../operators/models/MonoExponentialDecay.py | 3 +- .../operators/models/SaturationRecovery.py | 3 +- .../TransientSteadyStateWithPreparation.py | 54 +++++++++++++------ src/mrpro/operators/models/WASABI.py | 3 +- src/mrpro/operators/models/WASABITI.py | 4 +- tests/operators/models/conftest.py | 1 + ...transient_steady_state_with_preparation.py | 12 ++++- 10 files changed, 79 insertions(+), 30 deletions(-) diff --git a/src/mrpro/operators/SignalModel.py b/src/mrpro/operators/SignalModel.py index caa935a0..9a081a40 100644 --- a/src/mrpro/operators/SignalModel.py +++ b/src/mrpro/operators/SignalModel.py @@ -12,3 +12,26 @@ # SignalModel has multiple inputs and one output class SignalModel(Operator[*Tin, tuple[torch.Tensor,]]): """Signal Model Operator.""" + + @staticmethod + def expand_tensor_dim(parameter: torch.Tensor, n_dim_to_expand: int) -> torch.Tensor: + """Extend the number of dimensions of a parameter tensor. + + This is commonly used in the `model.forward` to ensure the model parameters can be broadcasted to the + quantitative maps. E.g. a simple `InversionRecovery` model is evaluated for six different inversion times `ti`. + The inversion times are commonly the same for each voxel and hence `ti` could be of shape (6,) and the T1 and M0 + map could be of shape (100,100,100). To make sure `ti` can be broadcasted to the maps it needs to be extended to + the shape (6,1,1,1) which then yields a signal of shape (6,100,100,100). + + Parameters + ---------- + parameter + Parameter (e.g with shape (m,n)) + n_dim_to_expand + Number of dimensions to expand. If <= 0 then parameter is not changed. + + Returns + ------- + Parameter with expanded dimensions (e.g. (m,n,1,1) for n_dim_to_expand = 2) + """ + return parameter[..., *[None] * (n_dim_to_expand)] if n_dim_to_expand > 0 else parameter diff --git a/src/mrpro/operators/models/InversionRecovery.py b/src/mrpro/operators/models/InversionRecovery.py index 0b23bb57..cbf02536 100644 --- a/src/mrpro/operators/models/InversionRecovery.py +++ b/src/mrpro/operators/models/InversionRecovery.py @@ -37,7 +37,6 @@ def forward(self, m0: torch.Tensor, t1: torch.Tensor) -> tuple[torch.Tensor,]: ------- signal with shape (time ... other, coils, z, y, x) """ - delta_ndim = m0.ndim - (self.ti.ndim - 1) # -1 for time - ti = self.ti[..., *[None] * (delta_ndim)] if delta_ndim > 0 else self.ti + ti = self.expand_tensor_dim(self.ti, m0.ndim - (self.ti.ndim - 1)) # -1 for time signal = m0 * (1 - 2 * torch.exp(-(ti / t1))) return (signal,) diff --git a/src/mrpro/operators/models/MOLLI.py b/src/mrpro/operators/models/MOLLI.py index 311f8b2e..2f04e771 100644 --- a/src/mrpro/operators/models/MOLLI.py +++ b/src/mrpro/operators/models/MOLLI.py @@ -40,8 +40,7 @@ def forward(self, a: torch.Tensor, b: torch.Tensor, t1: torch.Tensor) -> tuple[t ------- signal with shape (time ... other, coils, z, y, x) """ - delta_ndim = a.ndim - (self.ti.ndim - 1) # -1 for time - ti = self.ti[..., *[None] * (delta_ndim)] if delta_ndim > 0 else self.ti + ti = self.expand_tensor_dim(self.ti, a.ndim - (self.ti.ndim - 1)) # -1 for time c = b / torch.where(a == 0, 1e-10, a) t1 = torch.where(t1 == 0, t1 + 1e-10, t1) signal = a * (1 - c * torch.exp(ti / t1 * (1 - c))) diff --git a/src/mrpro/operators/models/MonoExponentialDecay.py b/src/mrpro/operators/models/MonoExponentialDecay.py index a17f79dd..cf84221d 100644 --- a/src/mrpro/operators/models/MonoExponentialDecay.py +++ b/src/mrpro/operators/models/MonoExponentialDecay.py @@ -37,7 +37,6 @@ def forward(self, m0: torch.Tensor, decay_constant: torch.Tensor) -> tuple[torch ------- signal with shape (time ... other, coils, z, y, x) """ - delta_ndim = m0.ndim - (self.decay_time.ndim - 1) # -1 for time - decay_time = self.decay_time[..., *[None] * (delta_ndim)] if delta_ndim > 0 else self.decay_time + decay_time = self.expand_tensor_dim(self.decay_time, m0.ndim - (self.decay_time.ndim - 1)) # -1 for time signal = m0 * torch.exp(-(decay_time / decay_constant)) return (signal,) diff --git a/src/mrpro/operators/models/SaturationRecovery.py b/src/mrpro/operators/models/SaturationRecovery.py index 4182dab2..d24c0f49 100644 --- a/src/mrpro/operators/models/SaturationRecovery.py +++ b/src/mrpro/operators/models/SaturationRecovery.py @@ -37,7 +37,6 @@ def forward(self, m0: torch.Tensor, t1: torch.Tensor) -> tuple[torch.Tensor,]: ------- signal with shape (time ... other, coils, z, y, x) """ - delta_ndim = m0.ndim - (self.ti.ndim - 1) # -1 for time - ti = self.ti[..., *[None] * (delta_ndim)] if delta_ndim > 0 else self.ti + ti = self.expand_tensor_dim(self.ti, m0.ndim - (self.ti.ndim - 1)) # -1 for time signal = m0 * (1 - torch.exp(-(ti / t1))) return (signal,) diff --git a/src/mrpro/operators/models/TransientSteadyStateWithPreparation.py b/src/mrpro/operators/models/TransientSteadyStateWithPreparation.py index ac36473e..ef677667 100644 --- a/src/mrpro/operators/models/TransientSteadyStateWithPreparation.py +++ b/src/mrpro/operators/models/TransientSteadyStateWithPreparation.py @@ -8,9 +8,9 @@ class TransientSteadyStateWithPreparation(SignalModel[torch.Tensor, torch.Tensor, torch.Tensor]): r"""Signal model for transient steady state. - This signal model describes the behavior of the longitudinal magnetisation during continuous acquisition after + This signal model describes the behavior of the longitudinal magnetization during continuous acquisition after a preparation pulse. The effect of the preparation pulse is modelled by a scaling factor applied to the - equilibrium magnetisation. A delay after the preparation pulse can be defined. During this time T1 relaxation to M0 + equilibrium magnetization. A delay after the preparation pulse can be defined. During this time T1 relaxation to M0 occurs. Data acquisition starts after this delay. Perfect spoiling is assumed and hence T2 effects are not considered in the model. In addition this model assumes :math:`TR << T1` and :math:`TR << T1^*` (see definition below) [DEI1992]_ [LOO1970]_. @@ -44,34 +44,44 @@ class TransientSteadyStateWithPreparation(SignalModel[torch.Tensor, torch.Tensor def __init__( self, sampling_time: float | torch.Tensor, - repetition_time: float, - m0_scaling_preparation: float = 1.0, - delay_after_preparation: float = 0.0, + repetition_time: float | torch.Tensor, + m0_scaling_preparation: float | torch.Tensor = 1.0, + delay_after_preparation: float | torch.Tensor = 0.0, ): """Initialize transient steady state signal model. + `repetition_time`, `m0_scaling_preparation` and `delay_after_preparation` can vary for each voxel and will be + broadcasted starting from the front (i.e. from the other dimension). + Parameters ---------- sampling_time - time points when model is evaluated. A sampling_time of 0 describes the first acquired data point after the + Time points when model is evaluated. A sampling_time of 0 describes the first acquired data point after the inversion pulse and spoiler gradients. To take the T1 relaxation during the delay between inversion pulse and start of data acquisition into account, set the delay_after_preparation > 0. with shape (time, ...) repetition_time repetition time m0_scaling_preparation - scaling of the equilibrium magnetisation due to the preparation pulse before the data acquisition + Scaling of the equilibrium magnetization due to the preparation pulse before the data acquisition. delay_after_preparation - Time between preparation pulse and start of data acquisition. - During this time standard longitudinal relaxation occurs. + Time between preparation pulse and start of data acquisition. During this time, standard longitudinal + relaxation occurs. """ super().__init__() sampling_time = torch.as_tensor(sampling_time) self.sampling_time = torch.nn.Parameter(sampling_time, requires_grad=sampling_time.requires_grad) - self.repetition_time = repetition_time - self.m0_scaling_preparation = m0_scaling_preparation - self.delay_after_preparation = delay_after_preparation + repetition_time = torch.as_tensor(repetition_time) + self.repetition_time = torch.nn.Parameter(repetition_time, requires_grad=repetition_time.requires_grad) + m0_scaling_preparation = torch.as_tensor(m0_scaling_preparation) + self.m0_scaling_preparation = torch.nn.Parameter( + m0_scaling_preparation, requires_grad=m0_scaling_preparation.requires_grad + ) + delay_after_preparation = torch.as_tensor(delay_after_preparation) + self.delay_after_preparation = torch.nn.Parameter( + delay_after_preparation, requires_grad=delay_after_preparation.requires_grad + ) def forward(self, m0: torch.Tensor, t1: torch.Tensor, flip_angle: torch.Tensor) -> tuple[torch.Tensor,]: """Apply transient steady state signal model. @@ -92,17 +102,27 @@ def forward(self, m0: torch.Tensor, t1: torch.Tensor, flip_angle: torch.Tensor) ------- signal with shape (time ... other, coils, z, y, x) """ - delta_ndim = m0.ndim - (self.sampling_time.ndim - 1) # -1 for time - sampling_time = self.sampling_time[..., *[None] * (delta_ndim)] if delta_ndim > 0 else self.sampling_time + m0_ndim = m0.ndim + + # -1 for time + sampling_time = self.expand_tensor_dim(self.sampling_time, m0_ndim - (self.sampling_time.ndim - 1)) + + repetition_time = self.expand_tensor_dim(self.repetition_time, m0_ndim - self.repetition_time.ndim) + m0_scaling_preparation = self.expand_tensor_dim( + self.m0_scaling_preparation, m0_ndim - self.m0_scaling_preparation.ndim + ) + delay_after_preparation = self.expand_tensor_dim( + self.delay_after_preparation, m0_ndim - self.delay_after_preparation.ndim + ) # effect of preparation pulse - m_start = m0 * self.m0_scaling_preparation + m_start = m0 * m0_scaling_preparation # relaxation towards M0 - m_start = m0 + (m_start - m0) * torch.exp(-(self.delay_after_preparation / t1)) + m_start = m0 + (m_start - m0) * torch.exp(-(delay_after_preparation / t1)) # transient steady state - ln_cos_tr = torch.log(torch.cos(flip_angle)) / self.repetition_time + ln_cos_tr = torch.log(torch.cos(flip_angle)) / repetition_time r1_star = 1 / t1 - ln_cos_tr m0_star = m0 / (1 - t1 * ln_cos_tr) signal = m0_star + (m_start - m0_star) * torch.exp(-sampling_time * r1_star) diff --git a/src/mrpro/operators/models/WASABI.py b/src/mrpro/operators/models/WASABI.py index 830dddde..66f48847 100644 --- a/src/mrpro/operators/models/WASABI.py +++ b/src/mrpro/operators/models/WASABI.py @@ -80,8 +80,7 @@ def forward( ------- signal with shape (offsets ... other, coils, z, y, x) """ - delta_ndim = b0_shift.ndim - (self.offsets.ndim - 1) # -1 for offset - offsets = self.offsets[..., *[None] * (delta_ndim)] if delta_ndim > 0 else self.offsets + offsets = self.expand_tensor_dim(self.offsets, b0_shift.ndim - (self.offsets.ndim - 1)) # -1 for offset delta_x = offsets - b0_shift b1 = self.b1_nom * relative_b1 diff --git a/src/mrpro/operators/models/WASABITI.py b/src/mrpro/operators/models/WASABITI.py index f89e12fa..e5954ce7 100644 --- a/src/mrpro/operators/models/WASABITI.py +++ b/src/mrpro/operators/models/WASABITI.py @@ -79,8 +79,8 @@ def forward(self, b0_shift: torch.Tensor, rb1: torch.Tensor, t1: torch.Tensor) - signal with shape (offsets ... other, coils, z, y, x) """ delta_ndim = b0_shift.ndim - (self.offsets.ndim - 1) # -1 for offset - offsets = self.offsets[..., *[None] * (delta_ndim)] if delta_ndim > 0 else self.offsets - trec = self.trec[..., *[None] * (delta_ndim)] if delta_ndim > 0 else self.trec + offsets = self.expand_tensor_dim(self.offsets, delta_ndim) + trec = self.expand_tensor_dim(self.trec, delta_ndim) b1 = self.b1_nom * rb1 da = offsets - b0_shift diff --git a/tests/operators/models/conftest.py b/tests/operators/models/conftest.py index dab40849..570fa2f1 100644 --- a/tests/operators/models/conftest.py +++ b/tests/operators/models/conftest.py @@ -21,6 +21,7 @@ ((4, 3, 1, 10, 20, 30), (5, 4, 1), (5, 4, 3, 1, 10, 20, 30)), ((4, 3, 1, 10, 20, 30), (5, 1, 3), (5, 4, 3, 1, 10, 20, 30)), ((4, 3, 1, 10, 20, 30), (5, 4, 3), (5, 4, 3, 1, 10, 20, 30)), + ((4, 3, 1, 10, 20, 30), (5, 4, 3, 1, 10, 20, 30), (5, 4, 3, 1, 10, 20, 30)), # different value for each voxel ((1,), (5,), (5, 1)), # single voxel ((4, 3, 1), (5, 4, 3), (5, 4, 3, 1)), ], diff --git a/tests/operators/models/test_transient_steady_state_with_preparation.py b/tests/operators/models/test_transient_steady_state_with_preparation.py index 9ae48ae3..1da7e25f 100644 --- a/tests/operators/models/test_transient_steady_state_with_preparation.py +++ b/tests/operators/models/test_transient_steady_state_with_preparation.py @@ -64,7 +64,17 @@ def test_transient_steady_state_inversion_recovery(): def test_transient_steady_state_shape(parameter_shape, contrast_dim_shape, signal_shape): """Test correct signal shapes.""" (sampling_time,) = create_parameter_tensor_tuples(contrast_dim_shape, number_of_tensors=1) - model_op = TransientSteadyStateWithPreparation(sampling_time, repetition_time=5) + if len(parameter_shape) == 1: + repetition_time = 5 + m0_scaling_preparation = 1 + delay_after_preparation = 0.01 + else: + repetition_time, m0_scaling_preparation, delay_after_preparation = create_parameter_tensor_tuples( + contrast_dim_shape[1:], number_of_tensors=3 + ) + model_op = TransientSteadyStateWithPreparation( + sampling_time, repetition_time, m0_scaling_preparation, delay_after_preparation + ) m0, t1, flip_angle = create_parameter_tensor_tuples(parameter_shape, number_of_tensors=3) (signal,) = model_op.forward(m0, t1, flip_angle) assert signal.shape == signal_shape From 12c60731bc2b995242303d66019b1cf9e856eaae Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Mon, 5 Aug 2024 13:25:56 +0200 Subject: [PATCH 22/34] Select number of cores for pytest in VSCode automatically (#322) Co-authored-by: Patrick Schuenke <37338697+schuenke@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8549707c..15cd0eab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ notebook = [ [tool.pytest.ini_options] testpaths = ["tests"] filterwarnings = ["error"] -# addopts = "-n auto" # TODO: debug vscode missing tests if enabled +addopts = "-n auto" markers = ["cuda : Tests only to be run when cuda device is available"] # MyPy section From 024379c4cebbcc8849b76dfb4372f771af07e7db Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Mon, 12 Aug 2024 09:33:26 +0200 Subject: [PATCH 23/34] Add Iterative SENSE Reconstruction (#287) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Patrick Schuenke <37338697+schuenke@users.noreply.github.com> --- examples/direct_reconstruction.ipynb | 20 +- examples/direct_reconstruction.py | 8 +- examples/iterative_sense_reconstruction.ipynb | 290 ++++++++++++++++++ examples/iterative_sense_reconstruction.py | 131 ++++++++ examples/pulseq_2d_radial_golden_angle.ipynb | 18 +- examples/pulseq_2d_radial_golden_angle.py | 11 +- .../reconstruction/DirectReconstruction.py | 79 +---- .../IterativeSENSEReconstruction.py | 128 ++++++++ .../reconstruction/Reconstruction.py | 87 +++++- .../algorithms/reconstruction/__init__.py | 1 + 10 files changed, 652 insertions(+), 121 deletions(-) create mode 100644 examples/iterative_sense_reconstruction.ipynb create mode 100644 examples/iterative_sense_reconstruction.py create mode 100644 src/mrpro/algorithms/reconstruction/IterativeSENSEReconstruction.py diff --git a/examples/direct_reconstruction.ipynb b/examples/direct_reconstruction.ipynb index 0bf314b2..6aa95eb9 100644 --- a/examples/direct_reconstruction.ipynb +++ b/examples/direct_reconstruction.ipynb @@ -33,14 +33,11 @@ "outputs": [], "source": [ "# Download raw data\n", - "import shutil\n", "import tempfile\n", - "from pathlib import Path\n", "\n", "import requests\n", "\n", - "data_folder = Path(tempfile.mkdtemp())\n", - "data_file = tempfile.NamedTemporaryFile(dir=data_folder, mode='wb', delete=False, suffix='.h5')\n", + "data_file = tempfile.NamedTemporaryFile(mode='wb', delete=False, suffix='.h5')\n", "response = requests.get(zenodo_url + fname, timeout=30)\n", "data_file.write(response.content)\n", "data_file.flush()" @@ -196,9 +193,7 @@ "cell_type": "code", "execution_count": null, "id": "52d306e3", - "metadata": { - "lines_to_next_cell": 0 - }, + "metadata": {}, "outputs": [], "source": [ "import torch\n", @@ -207,17 +202,6 @@ "assert torch.allclose(img.data, img_manual.data)\n", "assert torch.allclose(img.data, img_more_manual.data)" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1c9c3378", - "metadata": {}, - "outputs": [], - "source": [ - "# Clean-up by removing temporary directory\n", - "shutil.rmtree(data_folder)" - ] } ], "metadata": { diff --git a/examples/direct_reconstruction.py b/examples/direct_reconstruction.py index df13fc56..b8528cda 100644 --- a/examples/direct_reconstruction.py +++ b/examples/direct_reconstruction.py @@ -7,14 +7,11 @@ fname = 'pulseq_radial_2D_402spokes_golden_angle_with_traj.h5' # %% # Download raw data -import shutil import tempfile -from pathlib import Path import requests -data_folder = Path(tempfile.mkdtemp()) -data_file = tempfile.NamedTemporaryFile(dir=data_folder, mode='wb', delete=False, suffix='.h5') +data_file = tempfile.NamedTemporaryFile(mode='wb', delete=False, suffix='.h5') response = requests.get(zenodo_url + fname, timeout=30) data_file.write(response.content) data_file.flush() @@ -99,6 +96,3 @@ # If the assert statement did not raise an exception, the results are equal. assert torch.allclose(img.data, img_manual.data) assert torch.allclose(img.data, img_more_manual.data) -# %% -# Clean-up by removing temporary directory -shutil.rmtree(data_folder) diff --git a/examples/iterative_sense_reconstruction.ipynb b/examples/iterative_sense_reconstruction.ipynb new file mode 100644 index 00000000..83571eef --- /dev/null +++ b/examples/iterative_sense_reconstruction.ipynb @@ -0,0 +1,290 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "97b14e1c", + "metadata": { + "lines_to_next_cell": 0 + }, + "source": [ + "# Iterative SENSE Reconstruction of 2D golden angle radial data\n", + "Here we use the IterativeSENSEReconstruction class to reconstruct images from ISMRMRD 2D radial data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a554df82", + "metadata": { + "lines_to_next_cell": 0 + }, + "outputs": [], + "source": [ + "# define zenodo URL of the example ismrmd data\n", + "zenodo_url = 'https://zenodo.org/records/10854057/files/'\n", + "fname = 'pulseq_radial_2D_402spokes_golden_angle_with_traj.h5'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7f969a53", + "metadata": {}, + "outputs": [], + "source": [ + "# Download raw data\n", + "import tempfile\n", + "\n", + "import requests\n", + "\n", + "data_file = tempfile.NamedTemporaryFile(mode='wb', delete=False, suffix='.h5')\n", + "response = requests.get(zenodo_url + fname, timeout=30)\n", + "data_file.write(response.content)\n", + "data_file.flush()" + ] + }, + { + "cell_type": "markdown", + "id": "b48681af", + "metadata": { + "lines_to_next_cell": 0 + }, + "source": [ + "### Image reconstruction\n", + "We use the IterativeSENSEReconstruction class to reconstruct images from 2D radial data.\n", + "IterativeSENSEReconstruction solves the following reconstruction problem:\n", + "\n", + "Let's assume we have obtained the k-space data $y$ from an image $x$ with an acquisition model (Fourier transforms,\n", + "coil sensitivity maps...) $A$ then we can formulate the forward problem as:\n", + "\n", + "$ y = Ax + n $\n", + "\n", + "where $n$ describes complex Gaussian noise. The image $x$ can be obtained by minimising the functionl $F$\n", + "\n", + "$ F(x) = ||W^{\\frac{1}{2}}(Ax - y)||_2^2 $\n", + "\n", + "where $W^\\frac{1}{2}$ is the square root of the density compensation function (which corresponds to a diagonal\n", + "operator).\n", + "\n", + "Setting the derivative of the functional $F$ to zero and rearranging yields\n", + "\n", + "$ A^H W A x = A^H W y$\n", + "\n", + "which is a linear system $Hx = b$ that needs to be solved for $x$." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "38fe6ce8", + "metadata": {}, + "outputs": [], + "source": [ + "import mrpro\n", + "\n", + "# Use the trajectory that is stored in the ISMRMRD file\n", + "trajectory = mrpro.data.traj_calculators.KTrajectoryIsmrmrd()\n", + "# Load in the Data from the ISMRMRD file\n", + "kdata = mrpro.data.KData.from_file(data_file.name, trajectory)\n", + "kdata.header.recon_matrix.x = 256\n", + "kdata.header.recon_matrix.y = 256" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5dafe6fd", + "metadata": { + "lines_to_next_cell": 2 + }, + "outputs": [], + "source": [ + "iterative_sense_reconstruction = mrpro.algorithms.reconstruction.IterativeSENSEReconstruction.from_kdata(\n", + " kdata, n_iterations=4\n", + ")\n", + "img = iterative_sense_reconstruction(kdata)" + ] + }, + { + "cell_type": "markdown", + "id": "48f03f8c", + "metadata": {}, + "source": [ + "### Behind the scenes" + ] + }, + { + "cell_type": "markdown", + "id": "78a39ad8", + "metadata": {}, + "source": [ + "##### Set-up the density compensation operator $W$" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2b25396e", + "metadata": { + "lines_to_next_cell": 2 + }, + "outputs": [], + "source": [ + "# The density compensation operator is calculated based on the k-space locations of the acquired data.\n", + "dcf_operator = mrpro.data.DcfData.from_traj_voronoi(kdata.traj).as_operator()" + ] + }, + { + "cell_type": "markdown", + "id": "fd634152", + "metadata": {}, + "source": [ + "##### Set-up the acquisition model $A$" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "adfcbd02", + "metadata": {}, + "outputs": [], + "source": [ + "# Define Fourier operator using the trajectory and header information in kdata\n", + "fourier_operator = mrpro.operators.FourierOp.from_kdata(kdata)\n", + "\n", + "# Calculate coil maps\n", + "# Note that operators return a tuple of tensors, so we need to unpack it,\n", + "# even though there is only one tensor returned from adjoint operator.\n", + "img_coilwise = mrpro.data.IData.from_tensor_and_kheader(*fourier_operator.H(*dcf_operator(kdata.data)), kdata.header)\n", + "csm_operator = mrpro.data.CsmData.from_idata_walsh(img_coilwise).as_operator()\n", + "\n", + "# Create the acquisition operator A\n", + "acquisition_operator = fourier_operator @ csm_operator" + ] + }, + { + "cell_type": "markdown", + "id": "7a9dd464", + "metadata": {}, + "source": [ + "##### Calculate the right-hand-side of the linear system $b = A^H W y$" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "330c39dc", + "metadata": { + "lines_to_next_cell": 2 + }, + "outputs": [], + "source": [ + "(right_hand_side,) = acquisition_operator.H(dcf_operator(kdata.data)[0])" + ] + }, + { + "cell_type": "markdown", + "id": "6c9aaa40", + "metadata": {}, + "source": [ + "##### Set-up the linear self-adjoint operator $H = A^H W A$" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c65f82b1", + "metadata": {}, + "outputs": [], + "source": [ + "operator = acquisition_operator.H @ dcf_operator @ acquisition_operator" + ] + }, + { + "cell_type": "markdown", + "id": "41d178f6", + "metadata": {}, + "source": [ + "##### Run conjugate gradient" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7f262a76", + "metadata": { + "lines_to_next_cell": 2 + }, + "outputs": [], + "source": [ + "img_manual = mrpro.algorithms.optimizers.cg(\n", + " operator, right_hand_side, initial_value=right_hand_side, max_iterations=4, tolerance=0.0\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "64976733", + "metadata": {}, + "outputs": [], + "source": [ + "# For comparison we can also carry out a direct reconstruction\n", + "direct_reconstruction = mrpro.algorithms.reconstruction.DirectReconstruction.from_kdata(kdata)\n", + "img_direct = direct_reconstruction(kdata)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cbc7849c", + "metadata": {}, + "outputs": [], + "source": [ + "# Display the reconstructed image\n", + "import matplotlib.pyplot as plt\n", + "import torch\n", + "\n", + "fig, ax = plt.subplots(1, 3, squeeze=False)\n", + "ax[0, 0].imshow(img_direct.rss()[0, 0, :, :])\n", + "ax[0, 0].set_title('Direct Reconstruction', fontsize=10)\n", + "ax[0, 1].imshow(img.rss()[0, 0, :, :])\n", + "ax[0, 1].set_title('Iterative SENSE', fontsize=10)\n", + "ax[0, 2].imshow(img_manual.abs()[0, 0, 0, :, :])\n", + "ax[0, 2].set_title('\"Manual\" Iterative SENSE', fontsize=10)" + ] + }, + { + "cell_type": "markdown", + "id": "fcc1810b", + "metadata": {}, + "source": [ + "### Check for equal results\n", + "The two versions result should in the same image data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f5f825f", + "metadata": {}, + "outputs": [], + "source": [ + "# If the assert statement did not raise an exception, the results are equal.\n", + "assert torch.allclose(img.data, img_manual)" + ] + } + ], + "metadata": { + "jupytext": { + "cell_metadata_filter": "-all" + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/iterative_sense_reconstruction.py b/examples/iterative_sense_reconstruction.py new file mode 100644 index 00000000..9e4d69d8 --- /dev/null +++ b/examples/iterative_sense_reconstruction.py @@ -0,0 +1,131 @@ +# %% [markdown] +# # Iterative SENSE Reconstruction of 2D golden angle radial data +# Here we use the IterativeSENSEReconstruction class to reconstruct images from ISMRMRD 2D radial data +# %% +# define zenodo URL of the example ismrmd data +zenodo_url = 'https://zenodo.org/records/10854057/files/' +fname = 'pulseq_radial_2D_402spokes_golden_angle_with_traj.h5' +# %% +# Download raw data +import tempfile + +import requests + +data_file = tempfile.NamedTemporaryFile(mode='wb', delete=False, suffix='.h5') +response = requests.get(zenodo_url + fname, timeout=30) +data_file.write(response.content) +data_file.flush() + +# %% [markdown] +# ### Image reconstruction +# We use the IterativeSENSEReconstruction class to reconstruct images from 2D radial data. +# IterativeSENSEReconstruction solves the following reconstruction problem: +# +# Let's assume we have obtained the k-space data $y$ from an image $x$ with an acquisition model (Fourier transforms, +# coil sensitivity maps...) $A$ then we can formulate the forward problem as: +# +# $ y = Ax + n $ +# +# where $n$ describes complex Gaussian noise. The image $x$ can be obtained by minimising the functionl $F$ +# +# $ F(x) = ||W^{\frac{1}{2}}(Ax - y)||_2^2 $ +# +# where $W^\frac{1}{2}$ is the square root of the density compensation function (which corresponds to a diagonal +# operator). +# +# Setting the derivative of the functional $F$ to zero and rearranging yields +# +# $ A^H W A x = A^H W y$ +# +# which is a linear system $Hx = b$ that needs to be solved for $x$. +# %% +import mrpro + +# Use the trajectory that is stored in the ISMRMRD file +trajectory = mrpro.data.traj_calculators.KTrajectoryIsmrmrd() +# Load in the Data from the ISMRMRD file +kdata = mrpro.data.KData.from_file(data_file.name, trajectory) +kdata.header.recon_matrix.x = 256 +kdata.header.recon_matrix.y = 256 + +# %% +iterative_sense_reconstruction = mrpro.algorithms.reconstruction.IterativeSENSEReconstruction.from_kdata( + kdata, n_iterations=4 +) +img = iterative_sense_reconstruction(kdata) + + +# %% [markdown] +# ### Behind the scenes + +# %% [markdown] +# ##### Set-up the density compensation operator $W$ + +# %% +# The density compensation operator is calculated based on the k-space locations of the acquired data. +dcf_operator = mrpro.data.DcfData.from_traj_voronoi(kdata.traj).as_operator() + + +# %% [markdown] +# ##### Set-up the acquisition model $A$ + +# %% +# Define Fourier operator using the trajectory and header information in kdata +fourier_operator = mrpro.operators.FourierOp.from_kdata(kdata) + +# Calculate coil maps +# Note that operators return a tuple of tensors, so we need to unpack it, +# even though there is only one tensor returned from adjoint operator. +img_coilwise = mrpro.data.IData.from_tensor_and_kheader(*fourier_operator.H(*dcf_operator(kdata.data)), kdata.header) +csm_operator = mrpro.data.CsmData.from_idata_walsh(img_coilwise).as_operator() + +# Create the acquisition operator A +acquisition_operator = fourier_operator @ csm_operator + +# %% [markdown] +# ##### Calculate the right-hand-side of the linear system $b = A^H W y$ + +# %% +(right_hand_side,) = acquisition_operator.H(dcf_operator(kdata.data)[0]) + + +# %% [markdown] +# ##### Set-up the linear self-adjoint operator $H = A^H W A$ + +# %% +operator = acquisition_operator.H @ dcf_operator @ acquisition_operator + +# %% [markdown] +# ##### Run conjugate gradient + +# %% +img_manual = mrpro.algorithms.optimizers.cg( + operator, right_hand_side, initial_value=right_hand_side, max_iterations=4, tolerance=0.0 +) + + +# %% +# For comparison we can also carry out a direct reconstruction +direct_reconstruction = mrpro.algorithms.reconstruction.DirectReconstruction.from_kdata(kdata) +img_direct = direct_reconstruction(kdata) + +# %% +# Display the reconstructed image +import matplotlib.pyplot as plt +import torch + +fig, ax = plt.subplots(1, 3, squeeze=False) +ax[0, 0].imshow(img_direct.rss()[0, 0, :, :]) +ax[0, 0].set_title('Direct Reconstruction', fontsize=10) +ax[0, 1].imshow(img.rss()[0, 0, :, :]) +ax[0, 1].set_title('Iterative SENSE', fontsize=10) +ax[0, 2].imshow(img_manual.abs()[0, 0, 0, :, :]) +ax[0, 2].set_title('"Manual" Iterative SENSE', fontsize=10) + +# %% [markdown] +# ### Check for equal results +# The two versions result should in the same image data. + +# %% +# If the assert statement did not raise an exception, the results are equal. +assert torch.allclose(img.data, img_manual) diff --git a/examples/pulseq_2d_radial_golden_angle.ipynb b/examples/pulseq_2d_radial_golden_angle.ipynb index 99833d95..98ddb394 100644 --- a/examples/pulseq_2d_radial_golden_angle.ipynb +++ b/examples/pulseq_2d_radial_golden_angle.ipynb @@ -20,9 +20,7 @@ "outputs": [], "source": [ "# Imports\n", - "import shutil\n", "import tempfile\n", - "from pathlib import Path\n", "\n", "import matplotlib.pyplot as plt\n", "import requests\n", @@ -42,8 +40,7 @@ "# define zenodo records URL and create a temporary directory and h5-file\n", "zenodo_url = 'https://zenodo.org/records/10854057/files/'\n", "fname = 'pulseq_radial_2D_402spokes_golden_angle_with_traj.h5'\n", - "data_folder = Path(tempfile.mkdtemp())\n", - "data_file = tempfile.NamedTemporaryFile(dir=data_folder, mode='wb', delete=False, suffix='.h5')" + "data_file = tempfile.NamedTemporaryFile(mode='wb', delete=False, suffix='.h5')" ] }, { @@ -155,7 +152,7 @@ "# download the sequence file from zenodo\n", "zenodo_url = 'https://zenodo.org/records/10868061/files/'\n", "seq_fname = 'pulseq_radial_2D_402spokes_golden_angle.seq'\n", - "seq_file = tempfile.NamedTemporaryFile(dir=data_folder, mode='wb', delete=False, suffix='.seq')\n", + "seq_file = tempfile.NamedTemporaryFile(mode='wb', delete=False, suffix='.seq')\n", "response = requests.get(zenodo_url + seq_fname, timeout=30)\n", "seq_file.write(response.content)\n", "seq_file.flush()" @@ -221,17 +218,6 @@ " plt.title(titles[i])\n", " plt.axis('off')" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b56871ec", - "metadata": {}, - "outputs": [], - "source": [ - "# Clean-up by removing temporary directory\n", - "shutil.rmtree(data_folder)" - ] } ], "metadata": { diff --git a/examples/pulseq_2d_radial_golden_angle.py b/examples/pulseq_2d_radial_golden_angle.py index 9948e034..4b276aa7 100644 --- a/examples/pulseq_2d_radial_golden_angle.py +++ b/examples/pulseq_2d_radial_golden_angle.py @@ -7,9 +7,7 @@ # %% # Imports -import shutil import tempfile -from pathlib import Path import matplotlib.pyplot as plt import requests @@ -22,8 +20,7 @@ # define zenodo records URL and create a temporary directory and h5-file zenodo_url = 'https://zenodo.org/records/10854057/files/' fname = 'pulseq_radial_2D_402spokes_golden_angle_with_traj.h5' -data_folder = Path(tempfile.mkdtemp()) -data_file = tempfile.NamedTemporaryFile(dir=data_folder, mode='wb', delete=False, suffix='.h5') +data_file = tempfile.NamedTemporaryFile(mode='wb', delete=False, suffix='.h5') # %% # Download raw data using requests @@ -92,7 +89,7 @@ # download the sequence file from zenodo zenodo_url = 'https://zenodo.org/records/10868061/files/' seq_fname = 'pulseq_radial_2D_402spokes_golden_angle.seq' -seq_file = tempfile.NamedTemporaryFile(dir=data_folder, mode='wb', delete=False, suffix='.seq') +seq_file = tempfile.NamedTemporaryFile(mode='wb', delete=False, suffix='.seq') response = requests.get(zenodo_url + seq_fname, timeout=30) seq_file.write(response.content) seq_file.flush() @@ -134,7 +131,3 @@ plt.imshow(torch.abs(img[0, 0, 0, :, :])) plt.title(titles[i]) plt.axis('off') - -# %% -# Clean-up by removing temporary directory -shutil.rmtree(data_folder) diff --git a/src/mrpro/algorithms/reconstruction/DirectReconstruction.py b/src/mrpro/algorithms/reconstruction/DirectReconstruction.py index 206ea386..e3530955 100644 --- a/src/mrpro/algorithms/reconstruction/DirectReconstruction.py +++ b/src/mrpro/algorithms/reconstruction/DirectReconstruction.py @@ -1,6 +1,6 @@ """Direct Reconstruction by Adjoint Fourier Transform.""" -from typing import Literal, Self +from typing import Self from mrpro.algorithms.prewhiten_kspace import prewhiten_kspace from mrpro.algorithms.reconstruction.Reconstruction import Reconstruction @@ -16,30 +16,18 @@ class DirectReconstruction(Reconstruction): """Direct Reconstruction by Adjoint Fourier Transform.""" - dcf: DcfData | None - """Density Compensation Data.""" - - csm: CsmData | None - """Coil Sensitivity Data.""" - - noise: KNoise | None - """Noise Data used for prewhitening.""" - - fourier_op: LinearOperator - """Fourier Operator used for the adjoint.""" - def __init__( self, - fourier_operator: LinearOperator, - csm: None | CsmData = None, - noise: None | KNoise = None, + fourier_op: LinearOperator, + csm: CsmData | None = None, + noise: KNoise | None = None, dcf: DcfData | None = None, ): """Initialize DirectReconstruction. Parameters ---------- - fourier_operator + fourier_op Instance of the FourierOperator which adjoint is used for reconstruction. csm Sensitivity maps for coil combination. If None, no coil combination will be performed. @@ -50,14 +38,14 @@ def __init__( Also set to None, if the FourierOperator is already density compensated. """ super().__init__() - self.fourier_op = fourier_operator + self.fourier_op = fourier_op # TODO: Make this buffers once DataBufferMixin is merged self.dcf = dcf self.csm = csm self.noise = noise @classmethod - def from_kdata(cls, kdata: KData, noise: KNoise | None = None, coil_combine: bool = True) -> Self: + def from_kdata(cls, kdata: KData, noise: KNoise | None = None, *, coil_combine: bool = True) -> Self: """Create a DirectReconstruction from kdata with default settings. Parameters @@ -67,8 +55,7 @@ def from_kdata(cls, kdata: KData, noise: KNoise | None = None, coil_combine: boo noise KNoise used for prewhitening. If None, no prewhitening is performed. coil_combine - if True (default), uses kdata to estimate sensitivity maps - and perform adaptive coil combine reconstruction + if True (default), uses kdata to estimate sensitivity maps and perform adaptive coil combine reconstruction in the reconstruction. """ if noise is not None: @@ -81,43 +68,6 @@ def from_kdata(cls, kdata: KData, noise: KNoise | None = None, coil_combine: boo self.recalculate_csm_walsh(kdata, noise=False) return self - def recalculate_fourierop(self, kdata: KData) -> Self: - """Update (in place) the Fourier Operator, e.g. for a new trajectory. - - Also recalculates the DCF. - - Parameters - ---------- - kdata - KData to determine trajectory and recon/encoding matrix from. - """ - self.fourier_op = FourierOp.from_kdata(kdata) - self.dcf = DcfData.from_traj_voronoi(kdata.traj) - return self - - def recalculate_csm_walsh(self, kdata: KData, noise: KNoise | None | Literal[False] = None) -> Self: - """Update (in place) the CSM from KData using Walsh. - - Parameters - ---------- - kdata - KData used for adjoint reconstruction, which is then used for - Walsh CSM estimation. - noise - Noise measurement for prewhitening. - If None, self.noise (if previously set) is used. - If False, no prewithening is performed even if self.noise is set. - Use this if the kdata is already prewhitened. - """ - if noise is False: - noise = None - elif noise is None: - noise = self.noise - adjoint = type(self)(self.fourier_op, dcf=self.dcf, noise=noise) - image = adjoint(kdata) - self.csm = CsmData.from_idata_walsh(image) - return self - def forward(self, kdata: KData) -> IData: """Apply the reconstruction. @@ -130,15 +80,4 @@ def forward(self, kdata: KData) -> IData: ------- the reconstruced image. """ - device = kdata.data.device - if self.noise is not None: - kdata = prewhiten_kspace(kdata, self.noise.to(device)) - operator = self.fourier_op - if self.csm is not None: - operator = operator @ self.csm.as_operator() - if self.dcf is not None: - operator = self.dcf.as_operator() @ operator - operator = operator.to(device) - (img_tensor,) = operator.H(kdata.data) - img = IData.from_tensor_and_kheader(img_tensor, kdata.header) - return img + return self.direct_reconstruction(kdata) diff --git a/src/mrpro/algorithms/reconstruction/IterativeSENSEReconstruction.py b/src/mrpro/algorithms/reconstruction/IterativeSENSEReconstruction.py new file mode 100644 index 00000000..bffa2234 --- /dev/null +++ b/src/mrpro/algorithms/reconstruction/IterativeSENSEReconstruction.py @@ -0,0 +1,128 @@ +"""Iterative SENSE Reconstruction by adjoint Fourier transform.""" + +from __future__ import annotations + +from typing import Self + +from mrpro.algorithms.optimizers.cg import cg +from mrpro.algorithms.prewhiten_kspace import prewhiten_kspace +from mrpro.algorithms.reconstruction.DirectReconstruction import DirectReconstruction +from mrpro.algorithms.reconstruction.Reconstruction import Reconstruction +from mrpro.data._kdata.KData import KData +from mrpro.data.CsmData import CsmData +from mrpro.data.DcfData import DcfData +from mrpro.data.IData import IData +from mrpro.data.KNoise import KNoise +from mrpro.operators.FourierOp import FourierOp +from mrpro.operators.LinearOperator import LinearOperator + + +class IterativeSENSEReconstruction(Reconstruction): + r"""Iterative SENSE reconstruction. + + This algorithm solves the problem :math:`min_x \frac{1}{2}||W^\frac{1}{2} (Ax - y)||_2^2` + by using a conjugate gradient algorithm to solve + :math:`H x = b` with :math:`H = A^H W A` and :math:`b = A^H W y` where :math:`A` is the acquisition model + (coil sensitivity maps, Fourier operator, k-space sampling), :math:`y` is the acquired k-space data and :math:`W` + describes the density compensation [PRU2001]_ . + + Note: In [PRU2001]_ a k-space filter is applied as a final step to null all k-space values outside the k-space + coverage. This is not done here. + + .. [PRU2001] Pruessmann K, Weiger M, Boernert P, and Boesiger P (2001), Advances in sensitivity encoding with + arbitrary k-space trajectories. MRI 46, 638-651. https://doi.org/10.1002/mrm.1241 + + """ + + n_iterations: int + """Number of CG iterations.""" + + def __init__( + self, + fourier_op: LinearOperator, + n_iterations: int, + csm: CsmData | None = None, + noise: KNoise | None = None, + dcf: DcfData | None = None, + ) -> None: + """Initialize IterativeSENSEReconstruction. + + Parameters + ---------- + fourier_op + Instance of the FourierOperator used for reconstruction + n_iterations + Number of CG iterations + csm + Sensitivity maps for coil combination + noise + Used for prewhitening + dcf + Density compensation. If None, no dcf will be performed. + Also set to None, if the FourierOperator is already density compensated. + """ + super().__init__() + self.fourier_op = fourier_op + self.n_iterations = n_iterations + # TODO: Make this buffers once DataBufferMixin is merged + self.csm = csm + self.noise = noise + self.dcf = dcf + + @classmethod + def from_kdata(cls, kdata: KData, noise: KNoise | None = None, *, n_iterations: int = 10) -> Self: + """Create a IterativeSENSEReconstruction from kdata with default settings. + + Parameters + ---------- + kdata + KData to use for trajectory and header information + noise + KNoise used for prewhitening. If None, no prewhitening is performed + n_iterations + Number of CG iterations + """ + if noise is not None: + kdata = prewhiten_kspace(kdata, noise) + dcf = DcfData.from_traj_voronoi(kdata.traj) + fourier_op = FourierOp.from_kdata(kdata) + recon = DirectReconstruction(fourier_op, dcf=dcf, noise=noise) + image = recon.direct_reconstruction(kdata) + csm = CsmData.from_idata_walsh(image) + return cls(fourier_op, n_iterations, csm, noise, dcf) + + def forward(self, kdata: KData) -> IData: + """Apply the reconstruction. + + Parameters + ---------- + kdata + k-space data to reconstruct. + + Returns + ------- + the reconstruced image. + """ + device = kdata.data.device + if self.noise is not None: + kdata = prewhiten_kspace(kdata, self.noise.to(device)) + + operator = self.fourier_op @ self.csm.as_operator() if self.csm is not None else self.fourier_op + + if self.dcf is not None: + dcf_operator = self.dcf.as_operator() + # Calculate b = A^H W y + (right_hand_side,) = operator.to(device).H(dcf_operator(kdata.data)[0]) + # Create H = A^H W A + operator = operator.H @ dcf_operator @ operator + else: + # Calculate b = A^H y + (right_hand_side,) = operator.to(device).H(kdata.data) + # Create H = A^H A + operator = operator.H @ operator + + img_tensor = cg( + operator, right_hand_side, initial_value=right_hand_side, max_iterations=self.n_iterations, tolerance=0.0 + ) + img = IData.from_tensor_and_kheader(img_tensor, kdata.header) + return img diff --git a/src/mrpro/algorithms/reconstruction/Reconstruction.py b/src/mrpro/algorithms/reconstruction/Reconstruction.py index 1a9e655e..172c3f7f 100644 --- a/src/mrpro/algorithms/reconstruction/Reconstruction.py +++ b/src/mrpro/algorithms/reconstruction/Reconstruction.py @@ -1,15 +1,35 @@ """Reconstruction module.""" from abc import ABC, abstractmethod +from typing import Literal, Self import torch -from mrpro.data import IData, KData +from mrpro.algorithms.prewhiten_kspace import prewhiten_kspace +from mrpro.data._kdata.KData import KData +from mrpro.data.CsmData import CsmData +from mrpro.data.DcfData import DcfData +from mrpro.data.IData import IData +from mrpro.data.KNoise import KNoise +from mrpro.operators.FourierOp import FourierOp +from mrpro.operators.LinearOperator import LinearOperator class Reconstruction(torch.nn.Module, ABC): """A Reconstruction.""" + dcf: DcfData | None + """Density Compensation Data.""" + + csm: CsmData | None + """Coil Sensitivity Data.""" + + noise: KNoise | None + """Noise Data used for prewhitening.""" + + fourier_op: LinearOperator + """Fourier Operator.""" + @abstractmethod def forward(self, kdata: KData) -> IData: """Apply the reconstruction.""" @@ -18,3 +38,68 @@ def forward(self, kdata: KData) -> IData: def __call__(self, kdata: KData) -> IData: """Apply the reconstruction.""" return super().__call__(kdata) + + def recalculate_fourierop(self, kdata: KData) -> Self: + """Update (in place) the Fourier Operator, e.g. for a new trajectory. + + Also recalculates the DCF. + + Parameters + ---------- + kdata + KData to determine trajectory and recon/encoding matrix from. + """ + self.fourier_op = FourierOp.from_kdata(kdata) + self.dcf = DcfData.from_traj_voronoi(kdata.traj) + return self + + def recalculate_csm_walsh(self, kdata: KData, noise: KNoise | None | Literal[False] = None) -> Self: + """Update (in place) the CSM from KData using Walsh. + + Parameters + ---------- + kdata + KData used for adjoint reconstruction (including DCF-weighting if available), which is then used for + Walsh CSM estimation. + noise + Noise measurement for prewhitening. + If None, self.noise (if previously set) is used. + If False, no prewithening is performed even if self.noise is set. + Use this if the kdata is already prewhitened. + """ + if noise is False: + noise = None + elif noise is None: + noise = self.noise + recon = type(self)(self.fourier_op, dcf=self.dcf, noise=noise) + image = recon.direct_reconstruction(kdata) + self.csm = CsmData.from_idata_walsh(image) + return self + + def direct_reconstruction(self, kdata: KData) -> IData: + """Direct reconstruction of the MR acquisition. + + Here we use S^H F^H W to calculate the image data using the coil sensitivity operator S, the Fourier operator F + and the density compensation operator W. S and W are optional. + + Parameters + ---------- + kdata + k-space data + + Returns + ------- + image data + """ + device = kdata.data.device + if self.noise is not None: + kdata = prewhiten_kspace(kdata, self.noise.to(device)) + operator = self.fourier_op + if self.csm is not None: + operator = operator @ self.csm.as_operator() + if self.dcf is not None: + operator = self.dcf.as_operator() @ operator + operator = operator.to(device) + (img_tensor,) = operator.H(kdata.data) + img = IData.from_tensor_and_kheader(img_tensor, kdata.header) + return img diff --git a/src/mrpro/algorithms/reconstruction/__init__.py b/src/mrpro/algorithms/reconstruction/__init__.py index 3c2cf59d..fbf38eb1 100644 --- a/src/mrpro/algorithms/reconstruction/__init__.py +++ b/src/mrpro/algorithms/reconstruction/__init__.py @@ -1,2 +1,3 @@ from mrpro.algorithms.reconstruction.Reconstruction import Reconstruction from mrpro.algorithms.reconstruction.DirectReconstruction import DirectReconstruction +from mrpro.algorithms.reconstruction.IterativeSENSEReconstruction import IterativeSENSEReconstruction From 1d9a4f59389997c0a52c180d039ff3cb3cd58e62 Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Tue, 20 Aug 2024 10:32:56 +0200 Subject: [PATCH 24/34] SI unit conversion in dicom and unit info in docstring (#367) Co-authored-by: Patrick Schuenke --- src/mrpro/data/AcqInfo.py | 40 ++++++++++++++++++++++++++------------- src/mrpro/data/IHeader.py | 25 +++++++++++++++--------- src/mrpro/data/KHeader.py | 28 ++++++++++----------------- tests/data/test_idata.py | 9 ++++++--- 4 files changed, 59 insertions(+), 43 deletions(-) diff --git a/src/mrpro/data/AcqInfo.py b/src/mrpro/data/AcqInfo.py index f30d545a..525c164e 100644 --- a/src/mrpro/data/AcqInfo.py +++ b/src/mrpro/data/AcqInfo.py @@ -1,8 +1,8 @@ """Acquisition information dataclass.""" -from collections.abc import Sequence +from collections.abc import Callable, Sequence from dataclasses import dataclass -from typing import Self +from typing import Self, TypeVar import ismrmrd import numpy as np @@ -11,6 +11,19 @@ from mrpro.data.MoveDataMixin import MoveDataMixin from mrpro.data.SpatialDimension import SpatialDimension +# Conversion functions for units +T = TypeVar('T', float, torch.Tensor) + + +def ms_to_s(ms: T) -> T: + """Convert ms to s.""" + return ms / 1000 + + +def mm_to_m(m: T) -> T: + """Convert mm to m.""" + return m / 1000 + @dataclass(slots=True) class AcqIdx(MoveDataMixin): @@ -76,7 +89,7 @@ class AcqInfo(MoveDataMixin): """Indices describing acquisitions (i.e. readouts).""" acquisition_time_stamp: torch.Tensor - """Clock time stamp (e.g. milliseconds since midnight).""" + """Clock time stamp. Not in s but in vendor-specific time units (e.g. 2.5ms for Siemens)""" active_channels: torch.Tensor """Number of active receiver coil elements.""" @@ -106,26 +119,25 @@ class AcqInfo(MoveDataMixin): """Unique ID corresponding to the readout.""" number_of_samples: torch.Tensor - """Number of readout sample points per readout (readouts may have different - number of sample points).""" + """Number of sample points per readout (readouts may have different number of sample points).""" patient_table_position: SpatialDimension[torch.Tensor] - """Offset position of the patient table, in LPS coordinates.""" + """Offset position of the patient table, in LPS coordinates [m].""" phase_dir: SpatialDimension[torch.Tensor] """Directional cosine of phase encoding (2D).""" physiology_time_stamp: torch.Tensor - """Time stamps relative to physiological triggering, e.g. ECG, pulse oximetry, respiratory.""" + """Time stamps relative to physiological triggering, e.g. ECG. Not in s but in vendor-specific time units""" position: SpatialDimension[torch.Tensor] - """Center of the excited volume, in LPS coordinates relative to isocenter in millimeters.""" + """Center of the excited volume, in LPS coordinates relative to isocenter [m].""" read_dir: SpatialDimension[torch.Tensor] """Directional cosine of readout/frequency encoding.""" sample_time_us: torch.Tensor - """Readout bandwidth, as time between samples in microseconds.""" + """Readout bandwidth, as time between samples [us].""" scan_counter: torch.Tensor """Zero-indexed incrementing counter for readouts.""" @@ -194,13 +206,15 @@ def tensor_2d(data: np.ndarray) -> torch.Tensor: data_tensor = data_tensor[None, None] return data_tensor - def spatialdimension_2d(data: np.ndarray) -> SpatialDimension[torch.Tensor]: + def spatialdimension_2d( + data: np.ndarray, conversion: Callable[[torch.Tensor], torch.Tensor] | None = None + ) -> SpatialDimension[torch.Tensor]: # Ensure spatial dimension is (k1*k2*other, 1, 3) if data.ndim != 2: raise ValueError('Spatial dimension is expected to be of shape (N,3)') data = data[:, None, :] # all spatial dimensions are float32 - return SpatialDimension[torch.Tensor].from_array_xyz(torch.tensor(data.astype(np.float32))) + return SpatialDimension[torch.Tensor].from_array_xyz(torch.tensor(data.astype(np.float32)), conversion) acq_idx = AcqIdx( k1=tensor(idx['kspace_encode_step_1']), @@ -235,10 +249,10 @@ def spatialdimension_2d(data: np.ndarray) -> SpatialDimension[torch.Tensor]: flags=tensor_2d(headers['flags']), measurement_uid=tensor_2d(headers['measurement_uid']), number_of_samples=tensor_2d(headers['number_of_samples']), - patient_table_position=spatialdimension_2d(headers['patient_table_position']), + patient_table_position=spatialdimension_2d(headers['patient_table_position'], mm_to_m), phase_dir=spatialdimension_2d(headers['phase_dir']), physiology_time_stamp=tensor_2d(headers['physiology_time_stamp']), - position=spatialdimension_2d(headers['position']), + position=spatialdimension_2d(headers['position'], mm_to_m), read_dir=spatialdimension_2d(headers['read_dir']), sample_time_us=tensor_2d(headers['sample_time_us']), scan_counter=tensor_2d(headers['scan_counter']), diff --git a/src/mrpro/data/IHeader.py b/src/mrpro/data/IHeader.py index 7477e83b..3a1e0699 100644 --- a/src/mrpro/data/IHeader.py +++ b/src/mrpro/data/IHeader.py @@ -23,19 +23,19 @@ class IHeader(MoveDataMixin): # ToDo: decide which attributes to store in the header fov: SpatialDimension[float] - """Field of view.""" + """Field of view [m].""" te: torch.Tensor | None - """Echo time.""" + """Echo time [s].""" ti: torch.Tensor | None - """Inversion time.""" + """Inversion time [s].""" fa: torch.Tensor | None - """Flip angle.""" + """Flip angle [rad].""" tr: torch.Tensor | None - """Repetition time.""" + """Repetition time [s].""" misc: dict = dataclasses.field(default_factory=dict) """Dictionary with miscellaneous parameters.""" @@ -93,15 +93,22 @@ def make_unique_tensor(values: Sequence[float]) -> torch.Tensor | None: else: return torch.as_tensor(values) - fa = make_unique_tensor(get_float_items_from_all_dicoms('FlipAngle')) - ti = make_unique_tensor(get_float_items_from_all_dicoms('InversionTime')) - tr = make_unique_tensor(get_float_items_from_all_dicoms('RepetitionTime')) + # Conversion functions for units + def ms_to_s(ms: torch.Tensor | None) -> torch.Tensor | None: + return None if ms is None else ms / 1000 + + def deg_to_rad(deg: torch.Tensor | None) -> torch.Tensor | None: + return None if deg is None else torch.deg2rad(deg) + + fa = deg_to_rad(make_unique_tensor(get_float_items_from_all_dicoms('FlipAngle'))) + ti = ms_to_s(make_unique_tensor(get_float_items_from_all_dicoms('InversionTime'))) + tr = ms_to_s(make_unique_tensor(get_float_items_from_all_dicoms('RepetitionTime'))) # get echo time(s). Some scanners use 'EchoTime', some use 'EffectiveEchoTime' te_list = get_float_items_from_all_dicoms('EchoTime') if all(val is None for val in te_list): # check if all entries are None te_list = get_float_items_from_all_dicoms('EffectiveEchoTime') - te = make_unique_tensor(te_list) + te = ms_to_s(make_unique_tensor(te_list)) fov_x_mm = get_float_items_from_all_dicoms('Rows')[0] * float(get_items_from_all_dicoms('PixelSpacing')[0][0]) fov_y_mm = get_float_items_from_all_dicoms('Columns')[0] * float( diff --git a/src/mrpro/data/KHeader.py b/src/mrpro/data/KHeader.py index 93ef48b8..2117c885 100644 --- a/src/mrpro/data/KHeader.py +++ b/src/mrpro/data/KHeader.py @@ -12,7 +12,7 @@ import torch from mrpro.data import enums -from mrpro.data.AcqInfo import AcqInfo +from mrpro.data.AcqInfo import AcqInfo, mm_to_m, ms_to_s from mrpro.data.EncodingLimits import EncodingLimits from mrpro.data.MoveDataMixin import MoveDataMixin from mrpro.data.SpatialDimension import SpatialDimension @@ -39,7 +39,7 @@ class KHeader(MoveDataMixin): """Function to calculate the k-space trajectory.""" b0: float - """Magnetic field strength.""" + """Magnetic field strength [T].""" encoding_limits: EncodingLimits """K-space encoding limits.""" @@ -48,19 +48,19 @@ class KHeader(MoveDataMixin): """Dimensions of the reconstruction matrix.""" recon_fov: SpatialDimension[float] - """Field-of-view of the reconstructed image.""" + """Field-of-view of the reconstructed image [m].""" encoding_matrix: SpatialDimension[int] """Dimensions of the encoded k-space matrix.""" encoding_fov: SpatialDimension[float] - """Field of view of the image encoded by the k-space trajectory.""" + """Field of view of the image encoded by the k-space trajectory [m].""" acq_info: AcqInfo """Information of the acquisitions (i.e. readout lines).""" h1_freq: float - """Lamor frequency of hydrogen nuclei.""" + """Lamor frequency of hydrogen nuclei [Hz].""" n_coils: int | None = None """Number of receiver coils.""" @@ -69,19 +69,19 @@ class KHeader(MoveDataMixin): """Date and time of acquisition.""" te: torch.Tensor | None = None - """Echo time.""" + """Echo time [s].""" ti: torch.Tensor | None = None - """Inversion time.""" + """Inversion time [s].""" fa: torch.Tensor | None = None - """Flip angle.""" + """Flip angle [rad].""" tr: torch.Tensor | None = None - """Repetition time.""" + """Repetition time [s].""" echo_spacing: torch.Tensor | None = None - """Echo spacing.""" + """Echo spacing [s].""" echo_train_length: int = 1 """Number of echoes in a multi-echo acquisition.""" @@ -152,14 +152,6 @@ def from_ismrmrd( encoding_number as ismrmrdHeader can contain multiple encodings, selects which to consider """ - - # Conversion functions for units - def ms_to_s(ms: torch.Tensor) -> torch.Tensor: - return ms / 1000 - - def mm_to_m(m: float) -> float: - return m / 1000 - if not 0 <= encoding_number < len(header.encoding): raise ValueError(f'encoding_number must be between 0 and {len(header.encoding)}') diff --git a/tests/data/test_idata.py b/tests/data/test_idata.py index 33225a2a..8884cf01 100644 --- a/tests/data/test_idata.py +++ b/tests/data/test_idata.py @@ -21,7 +21,8 @@ def test_IData_from_dcm_folder(dcm_multi_echo_times): # Verify correct echo times original_echo_times = torch.as_tensor([ds.te for ds in dcm_multi_echo_times]) assert idata.header.te is not None - assert torch.allclose(torch.sort(original_echo_times)[0], torch.sort(idata.header.te)[0]) + # dicom expects echo times in ms, mrpro in s + assert torch.allclose(torch.sort(original_echo_times)[0] / 1000, torch.sort(idata.header.te)[0]) # Verify all images were read in assert idata.data.shape[0] == original_echo_times.shape[0] @@ -32,7 +33,8 @@ def test_IData_from_dcm_folder_via_path(dcm_multi_echo_times): # Verify correct echo times original_echo_times = torch.as_tensor([ds.te for ds in dcm_multi_echo_times]) assert idata.header.te is not None - assert torch.allclose(torch.sort(original_echo_times)[0], torch.sort(idata.header.te)[0]) + # dicom expects echo times in ms, mrpro in s + assert torch.allclose(torch.sort(original_echo_times)[0] / 1000, torch.sort(idata.header.te)[0]) # Verify all images were read in assert idata.data.shape[0] == len(original_echo_times) @@ -55,7 +57,8 @@ def test_IData_from_dcm_files(dcm_multi_echo_times_multi_folders): # Verify correct echo times original_echo_times = torch.as_tensor([ds.te for ds in dcm_multi_echo_times_multi_folders]) assert idata.header.te is not None - assert torch.allclose(torch.sort(original_echo_times)[0], torch.sort(idata.header.te)[0]) + # dicom expects echo times in ms, mrpro in s + assert torch.allclose(torch.sort(original_echo_times)[0] / 1000, torch.sort(idata.header.te)[0]) # Verify all images were read in assert idata.data.shape[0] == len(original_echo_times) From 549768ea1906e84f8e0017d7dd265f9c6690897b Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Tue, 27 Aug 2024 09:17:46 +0200 Subject: [PATCH 25/34] Improve readability of dimension operations (#380) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Patrick Schuenke <37338697+schuenke@users.noreply.github.com> --- examples/pulseq_2d_radial_golden_angle.ipynb | 72 ++++--------------- examples/pulseq_2d_radial_golden_angle.py | 67 ++++------------- src/mrpro/data/IData.py | 14 +--- src/mrpro/data/KNoise.py | 4 +- src/mrpro/data/QData.py | 5 +- .../traj_calculators/KTrajectoryCartesian.py | 4 +- .../traj_calculators/KTrajectoryRadial2D.py | 9 +-- .../data/traj_calculators/KTrajectoryRpe.py | 9 +-- .../KTrajectorySunflowerGoldenRpe.py | 9 ++- src/mrpro/operators/CartesianSamplingOp.py | 8 +-- src/mrpro/operators/GridSamplingOp.py | 5 +- src/mrpro/phantoms/coils.py | 9 ++- src/mrpro/utils/filters.py | 9 ++- tests/algorithms/dcf/test_dcf_voronoi.py | 44 +++++++----- tests/data/_IsmrmrdRawTestData.py | 12 ++-- tests/data/_PulseqRadialTestSeq.py | 9 +-- tests/data/test_dcf_data.py | 21 +++--- tests/data/test_kdata.py | 2 +- tests/data/test_traj_calculators.py | 8 +-- tests/data/test_trajectory.py | 7 +- ...transient_steady_state_with_preparation.py | 5 +- tests/operators/test_finite_difference_op.py | 5 +- tests/utils/test_filters.py | 5 +- 23 files changed, 142 insertions(+), 200 deletions(-) diff --git a/examples/pulseq_2d_radial_golden_angle.ipynb b/examples/pulseq_2d_radial_golden_angle.ipynb index 98ddb394..7e18581c 100644 --- a/examples/pulseq_2d_radial_golden_angle.ipynb +++ b/examples/pulseq_2d_radial_golden_angle.ipynb @@ -24,10 +24,9 @@ "\n", "import matplotlib.pyplot as plt\n", "import requests\n", - "import torch\n", - "from mrpro.data import CsmData, DcfData, IData, KData\n", - "from mrpro.data.traj_calculators import KTrajectoryIsmrmrd, KTrajectoryPulseq, KTrajectoryRadial2D\n", - "from mrpro.operators import FourierOp, SensitivityOp" + "from mrpro.algorithms.reconstruction import DirectReconstruction\n", + "from mrpro.data import KData\n", + "from mrpro.data.traj_calculators import KTrajectoryIsmrmrd, KTrajectoryPulseq, KTrajectoryRadial2D" ] }, { @@ -75,22 +74,9 @@ "# Read the raw data and the trajectory from ISMRMRD file\n", "kdata = KData.from_file(data_file.name, KTrajectoryIsmrmrd())\n", "\n", - "# Calculate dcf using the trajectory\n", - "dcf = DcfData.from_traj_voronoi(kdata.traj)\n", - "\n", - "# Define Fourier operator and reconstruct coil images\n", - "fourier_op = FourierOp(\n", - " recon_matrix=kdata.header.recon_matrix,\n", - " encoding_matrix=kdata.header.encoding_matrix,\n", - " traj=kdata.traj,\n", - ")\n", - "(img,) = fourier_op.adjoint(kdata.data * dcf.data[:, None, ...])\n", - "\n", - "# Calculate and apply coil maps\n", - "idata = IData.from_tensor_and_kheader(img, kdata.header)\n", - "csm = CsmData.from_idata_walsh(idata)\n", - "csm_op = SensitivityOp(csm)\n", - "(img_using_ismrmrd_traj,) = csm_op.adjoint(img)" + "# Reconstruct image\n", + "direct_reconstruction = DirectReconstruction.from_kdata(kdata)\n", + "img_using_ismrmrd_traj = direct_reconstruction.forward(kdata)" ] }, { @@ -112,22 +98,9 @@ "# Read raw data and calculate trajectory using KTrajectoryRadial2D\n", "kdata = KData.from_file(data_file.name, KTrajectoryRadial2D())\n", "\n", - "# Calculate dcf using the calculated trajectory\n", - "dcf = DcfData.from_traj_voronoi(kdata.traj)\n", - "\n", - "# Define Fourier operator and reconstruct coil images\n", - "fourier_op = FourierOp(\n", - " recon_matrix=kdata.header.recon_matrix,\n", - " encoding_matrix=kdata.header.encoding_matrix,\n", - " traj=kdata.traj,\n", - ")\n", - "(img,) = fourier_op.adjoint(kdata.data * dcf.data[:, None, ...])\n", - "\n", - "# Calculate and apply coil maps\n", - "idata = IData.from_tensor_and_kheader(img, kdata.header)\n", - "csm = CsmData.from_idata_walsh(idata)\n", - "csm_op = SensitivityOp(csm)\n", - "(img_using_rad2d_traj,) = csm_op.adjoint(img)" + "# Reconstruct image\n", + "direct_reconstruction = DirectReconstruction.from_kdata(kdata)\n", + "img_using_rad2d_traj = direct_reconstruction.forward(kdata)" ] }, { @@ -162,30 +135,15 @@ "cell_type": "code", "execution_count": null, "id": "49dee7ad", - "metadata": { - "lines_to_next_cell": 2 - }, + "metadata": {}, "outputs": [], "source": [ "# Read raw data and calculate trajectory using KTrajectoryPulseq\n", "kdata = KData.from_file(data_file.name, KTrajectoryPulseq(seq_path=seq_file.name))\n", "\n", - "# Calculate dcf using the calculated trajectory\n", - "dcf = DcfData.from_traj_voronoi(kdata.traj)\n", - "\n", - "# Define Fourier operator and reconstruct coil images\n", - "fourier_op = FourierOp(\n", - " recon_matrix=kdata.header.recon_matrix,\n", - " encoding_matrix=kdata.header.encoding_matrix,\n", - " traj=kdata.traj,\n", - ")\n", - "(img,) = fourier_op.adjoint(kdata.data * dcf.data[:, None, ...])\n", - "\n", - "# Calculate and apply coil maps\n", - "idata = IData.from_tensor_and_kheader(img, kdata.header)\n", - "csm = CsmData.from_idata_walsh(idata)\n", - "csm_op = SensitivityOp(csm)\n", - "(img_using_pulseq_traj,) = csm_op.adjoint(img)" + "# Reconstruct image\n", + "direct_reconstruction = DirectReconstruction.from_kdata(kdata)\n", + "img_using_pulseq_traj = direct_reconstruction.forward(kdata)" ] }, { @@ -212,9 +170,9 @@ "source": [ "titles = ['KTrajectoryIsmrmrd', 'KTrajectoryRadial2D', 'KTrajectoryPulseq']\n", "plt.subplots(1, len(titles))\n", - "for i, img in enumerate([img_using_ismrmrd_traj, img_using_rad2d_traj, img_using_pulseq_traj]):\n", + "for i, img in enumerate([img_using_ismrmrd_traj.rss(), img_using_rad2d_traj.rss(), img_using_pulseq_traj.rss()]):\n", " plt.subplot(1, len(titles), i + 1)\n", - " plt.imshow(torch.abs(img[0, 0, 0, :, :]))\n", + " plt.imshow(img[0, 0, :, :])\n", " plt.title(titles[i])\n", " plt.axis('off')" ] diff --git a/examples/pulseq_2d_radial_golden_angle.py b/examples/pulseq_2d_radial_golden_angle.py index 4b276aa7..34dd706f 100644 --- a/examples/pulseq_2d_radial_golden_angle.py +++ b/examples/pulseq_2d_radial_golden_angle.py @@ -11,10 +11,9 @@ import matplotlib.pyplot as plt import requests -import torch -from mrpro.data import CsmData, DcfData, IData, KData +from mrpro.algorithms.reconstruction import DirectReconstruction +from mrpro.data import KData from mrpro.data.traj_calculators import KTrajectoryIsmrmrd, KTrajectoryPulseq, KTrajectoryRadial2D -from mrpro.operators import FourierOp, SensitivityOp # %% # define zenodo records URL and create a temporary directory and h5-file @@ -36,22 +35,9 @@ # Read the raw data and the trajectory from ISMRMRD file kdata = KData.from_file(data_file.name, KTrajectoryIsmrmrd()) -# Calculate dcf using the trajectory -dcf = DcfData.from_traj_voronoi(kdata.traj) - -# Define Fourier operator and reconstruct coil images -fourier_op = FourierOp( - recon_matrix=kdata.header.recon_matrix, - encoding_matrix=kdata.header.encoding_matrix, - traj=kdata.traj, -) -(img,) = fourier_op.adjoint(kdata.data * dcf.data[:, None, ...]) - -# Calculate and apply coil maps -idata = IData.from_tensor_and_kheader(img, kdata.header) -csm = CsmData.from_idata_walsh(idata) -csm_op = SensitivityOp(csm) -(img_using_ismrmrd_traj,) = csm_op.adjoint(img) +# Reconstruct image +direct_reconstruction = DirectReconstruction.from_kdata(kdata) +img_using_ismrmrd_traj = direct_reconstruction.forward(kdata) # %% [markdown] # ### Image reconstruction using KTrajectoryRadial2D @@ -61,22 +47,9 @@ # Read raw data and calculate trajectory using KTrajectoryRadial2D kdata = KData.from_file(data_file.name, KTrajectoryRadial2D()) -# Calculate dcf using the calculated trajectory -dcf = DcfData.from_traj_voronoi(kdata.traj) - -# Define Fourier operator and reconstruct coil images -fourier_op = FourierOp( - recon_matrix=kdata.header.recon_matrix, - encoding_matrix=kdata.header.encoding_matrix, - traj=kdata.traj, -) -(img,) = fourier_op.adjoint(kdata.data * dcf.data[:, None, ...]) - -# Calculate and apply coil maps -idata = IData.from_tensor_and_kheader(img, kdata.header) -csm = CsmData.from_idata_walsh(idata) -csm_op = SensitivityOp(csm) -(img_using_rad2d_traj,) = csm_op.adjoint(img) +# Reconstruct image +direct_reconstruction = DirectReconstruction.from_kdata(kdata) +img_using_rad2d_traj = direct_reconstruction.forward(kdata) # %% [markdown] # ### Image reconstruction using KTrajectoryPulseq @@ -98,23 +71,9 @@ # Read raw data and calculate trajectory using KTrajectoryPulseq kdata = KData.from_file(data_file.name, KTrajectoryPulseq(seq_path=seq_file.name)) -# Calculate dcf using the calculated trajectory -dcf = DcfData.from_traj_voronoi(kdata.traj) - -# Define Fourier operator and reconstruct coil images -fourier_op = FourierOp( - recon_matrix=kdata.header.recon_matrix, - encoding_matrix=kdata.header.encoding_matrix, - traj=kdata.traj, -) -(img,) = fourier_op.adjoint(kdata.data * dcf.data[:, None, ...]) - -# Calculate and apply coil maps -idata = IData.from_tensor_and_kheader(img, kdata.header) -csm = CsmData.from_idata_walsh(idata) -csm_op = SensitivityOp(csm) -(img_using_pulseq_traj,) = csm_op.adjoint(img) - +# Reconstruct image +direct_reconstruction = DirectReconstruction.from_kdata(kdata) +img_using_pulseq_traj = direct_reconstruction.forward(kdata) # %% [markdown] # ### Plot the different reconstructed images @@ -126,8 +85,8 @@ # %% titles = ['KTrajectoryIsmrmrd', 'KTrajectoryRadial2D', 'KTrajectoryPulseq'] plt.subplots(1, len(titles)) -for i, img in enumerate([img_using_ismrmrd_traj, img_using_rad2d_traj, img_using_pulseq_traj]): +for i, img in enumerate([img_using_ismrmrd_traj.rss(), img_using_rad2d_traj.rss(), img_using_pulseq_traj.rss()]): plt.subplot(1, len(titles), i + 1) - plt.imshow(torch.abs(img[0, 0, 0, :, :])) + plt.imshow(img[0, 0, :, :]) plt.title(titles[i]) plt.axis('off') diff --git a/src/mrpro/data/IData.py b/src/mrpro/data/IData.py index c48a8ed0..d3b8f779 100644 --- a/src/mrpro/data/IData.py +++ b/src/mrpro/data/IData.py @@ -7,7 +7,7 @@ import numpy as np import torch -from einops import rearrange +from einops import repeat from pydicom import dcmread from pydicom.dataset import Dataset from pydicom.tag import TagType @@ -93,9 +93,7 @@ def from_single_dicom(cls, filename: str | Path) -> Self: path to DICOM file. """ dataset = dcmread(filename) - idata = _dcm_pixelarray_to_tensor(dataset)[None, :] - idata = rearrange(idata, '(other coils z) y x -> other coils z y x', other=1, coils=1, z=1) - + idata = repeat(_dcm_pixelarray_to_tensor(dataset), 'y x -> other coils z y x', other=1, coils=1, z=1) header = IHeader.from_dicom_list([dataset]) return cls(data=idata, header=header) @@ -131,13 +129,7 @@ def get_unique_slice_positions(slice_pos_tag: TagType = 0x00191015): raise ValueError('Only dicoms with the same orientation can be read in.') # stack required due to mypy: einops rearrange list[tensor]->tensor not recognized idata = torch.stack([_dcm_pixelarray_to_tensor(ds) for ds in dataset_list]) - idata = rearrange( - idata, - '(other coils z) y x -> other coils z y x', - other=len(idata), - coils=1, - z=1, - ) + idata = repeat(idata, 'other y x -> other coils z y x', coils=1, z=1) header = IHeader.from_dicom_list(dataset_list) return cls(data=idata, header=header) diff --git a/src/mrpro/data/KNoise.py b/src/mrpro/data/KNoise.py index a1e465f4..58216497 100644 --- a/src/mrpro/data/KNoise.py +++ b/src/mrpro/data/KNoise.py @@ -7,7 +7,7 @@ import ismrmrd import torch -from einops import rearrange +from einops import repeat from mrpro.data.acq_filters import is_noise_acquisition from mrpro.data.MoveDataMixin import MoveDataMixin @@ -47,6 +47,6 @@ def from_file( noise_data = torch.stack([torch.as_tensor(acq.data, dtype=torch.complex64) for acq in acquisitions]) # Reshape to standard dimensions - noise_data = rearrange(noise_data, 'other coils (k2 k1 k0)->other coils k2 k1 k0', k1=1, k2=1) + noise_data = repeat(noise_data, '... coils k0->... coils k2 k1 k0', k1=1, k2=1) return cls(noise_data) diff --git a/src/mrpro/data/QData.py b/src/mrpro/data/QData.py index fa2a8957..43aa07cd 100644 --- a/src/mrpro/data/QData.py +++ b/src/mrpro/data/QData.py @@ -6,7 +6,7 @@ import numpy as np import torch -from einops import rearrange +from einops import repeat from pydicom import dcmread from mrpro.data.Data import Data @@ -56,7 +56,6 @@ def from_single_dicom(cls, filename: str | Path) -> Self: dataset = dcmread(filename) # Image data is 2D np.array of Uint16, which cannot directly be converted to tensor qdata = torch.as_tensor(dataset.pixel_array.astype(np.complex64)) - qdata = rearrange(qdata[None, ...], '(other coils z) y x -> other coils z y x', other=1, coils=1, z=1) - + qdata = repeat(qdata, 'y x -> other coils z y x', other=1, coils=1, z=1) header = QHeader.from_dicom(dataset) return cls(data=qdata, header=header) diff --git a/src/mrpro/data/traj_calculators/KTrajectoryCartesian.py b/src/mrpro/data/traj_calculators/KTrajectoryCartesian.py index 428041ac..1b0742ee 100644 --- a/src/mrpro/data/traj_calculators/KTrajectoryCartesian.py +++ b/src/mrpro/data/traj_calculators/KTrajectoryCartesian.py @@ -31,6 +31,6 @@ def __call__(self, kheader: KHeader) -> KTrajectory: kz = (kheader.acq_info.idx.k2 - kheader.encoding_limits.k2.center).to(torch.float32) # Bring to correct dimensions - ky = repeat(ky, 'other k2 k1-> other k2 k1 k0', k0=1) - kz = repeat(kz, 'other k2 k1-> other k2 k1 k0', k0=1) + ky = repeat(ky, '... k2 k1-> ... k2 k1 k0', k0=1) + kz = repeat(kz, '... k2 k1-> ... k2 k1 k0', k0=1) return KTrajectory(kz, ky, kx) diff --git a/src/mrpro/data/traj_calculators/KTrajectoryRadial2D.py b/src/mrpro/data/traj_calculators/KTrajectoryRadial2D.py index f8b8cd04..1616af90 100644 --- a/src/mrpro/data/traj_calculators/KTrajectoryRadial2D.py +++ b/src/mrpro/data/traj_calculators/KTrajectoryRadial2D.py @@ -1,6 +1,7 @@ """2D radial trajectory class.""" import torch +from einops import repeat from mrpro.data.KHeader import KHeader from mrpro.data.KTrajectory import KTrajectory @@ -37,11 +38,11 @@ def __call__(self, kheader: KHeader) -> KTrajectory: krad = self._kfreq(kheader) # Angles of readout lines - kang = kheader.acq_info.idx.k1 * self.angle + kang = repeat(kheader.acq_info.idx.k1 * self.angle, '... k2 k1 -> ... k2 k1 k0', k0=1) - # K-space cartesian coordinates - kx = krad * torch.cos(kang)[..., None] - ky = krad * torch.sin(kang)[..., None] + # K-space radial coordinates + kx = krad * torch.cos(kang) + ky = krad * torch.sin(kang) kz = torch.zeros(1, 1, 1, 1) return KTrajectory(kz, ky, kx) diff --git a/src/mrpro/data/traj_calculators/KTrajectoryRpe.py b/src/mrpro/data/traj_calculators/KTrajectoryRpe.py index 7873de7f..377a7ae9 100644 --- a/src/mrpro/data/traj_calculators/KTrajectoryRpe.py +++ b/src/mrpro/data/traj_calculators/KTrajectoryRpe.py @@ -1,6 +1,7 @@ """Radial phase encoding (RPE) trajectory class.""" import torch +from einops import repeat from mrpro.data.KHeader import KHeader from mrpro.data.KTrajectory import KTrajectory @@ -90,7 +91,7 @@ def _kang(self, kheader: KHeader) -> torch.Tensor: ------- angles of phase encoding lines """ - return kheader.acq_info.idx.k2 * self.angle + return repeat(kheader.acq_info.idx.k2 * self.angle, '... k2 k1 -> ... k2 k1 k0', k0=1) def _krad(self, kheader: KHeader) -> torch.Tensor: """Calculate the k-space locations along the phase encoding lines. @@ -106,7 +107,7 @@ def _krad(self, kheader: KHeader) -> torch.Tensor: """ krad = (kheader.acq_info.idx.k1 - kheader.encoding_limits.k1.center).to(torch.float32) krad = self._apply_shifts_between_rpe_lines(krad, kheader.acq_info.idx.k2) - return krad + return repeat(krad, '... k2 k1 -> ... k2 k1 k0', k0=1) def __call__(self, kheader: KHeader) -> KTrajectory: """Calculate radial phase encoding trajectory for given KHeader. @@ -129,6 +130,6 @@ def __call__(self, kheader: KHeader) -> KTrajectory: # K-space locations along phase encoding lines krad = self._krad(kheader) - kz = (krad * torch.sin(kang))[..., None] - ky = (krad * torch.cos(kang))[..., None] + kz = krad * torch.sin(kang) + ky = krad * torch.cos(kang) return KTrajectory(kz, ky, kx) diff --git a/src/mrpro/data/traj_calculators/KTrajectorySunflowerGoldenRpe.py b/src/mrpro/data/traj_calculators/KTrajectorySunflowerGoldenRpe.py index 7f53f45b..8ab4abd9 100644 --- a/src/mrpro/data/traj_calculators/KTrajectorySunflowerGoldenRpe.py +++ b/src/mrpro/data/traj_calculators/KTrajectorySunflowerGoldenRpe.py @@ -2,6 +2,7 @@ import numpy as np import torch +from einops import repeat from mrpro.data.KHeader import KHeader from mrpro.data.traj_calculators.KTrajectoryRpe import KTrajectoryRpe @@ -68,7 +69,7 @@ def _kang(self, kheader: KHeader) -> torch.Tensor: ------- angles of phase encoding lines """ - return (kheader.acq_info.idx.k2 * self.angle) % torch.pi + return repeat((kheader.acq_info.idx.k2 * self.angle) % torch.pi, '... k2 k1 -> ... k2 k1 k0', k0=1) def _krad(self, kheader: KHeader) -> torch.Tensor: """Calculate the k-space locations along the phase encoding lines. @@ -83,6 +84,10 @@ def _krad(self, kheader: KHeader) -> torch.Tensor: k-space locations along the phase encoding lines """ kang = self._kang(kheader) - krad = (kheader.acq_info.idx.k1 - kheader.encoding_limits.k1.center).to(torch.float32) + krad = repeat( + (kheader.acq_info.idx.k1 - kheader.encoding_limits.k1.center).to(torch.float32), + '... k2 k1 -> ... k2 k1 k0', + k0=1, + ) krad = self._apply_sunflower_shift_between_rpe_lines(krad, kang, kheader) return krad diff --git a/src/mrpro/operators/CartesianSamplingOp.py b/src/mrpro/operators/CartesianSamplingOp.py index e70322c9..e534567f 100644 --- a/src/mrpro/operators/CartesianSamplingOp.py +++ b/src/mrpro/operators/CartesianSamplingOp.py @@ -1,7 +1,7 @@ """Cartesian Sampling Operator.""" import torch -from einops import rearrange +from einops import rearrange, repeat from mrpro.data.enums import TrajType from mrpro.data.KTrajectory import KTrajectory @@ -47,19 +47,19 @@ def __init__(self, encoding_matrix: SpatialDimension[int], traj: KTrajectory) -> kx_idx = ktraj_tensor[-1, ...].round().to(dtype=torch.int64) + sorted_grid_shape.x // 2 else: sorted_grid_shape.x = ktraj_tensor.shape[-1] - kx_idx = rearrange(torch.arange(ktraj_tensor.shape[-1]), 'kx->1 1 1 kx') + kx_idx = repeat(torch.arange(ktraj_tensor.shape[-1]), 'k0->other k1 k2 k0', other=1, k2=1, k1=1) if traj_type_kzyx[-2] == TrajType.ONGRID: # ky ky_idx = ktraj_tensor[-2, ...].round().to(dtype=torch.int64) + sorted_grid_shape.y // 2 else: sorted_grid_shape.y = ktraj_tensor.shape[-2] - ky_idx = rearrange(torch.arange(ktraj_tensor.shape[-2]), 'ky->1 1 ky 1') + ky_idx = repeat(torch.arange(ktraj_tensor.shape[-2]), 'k1->other k1 k2 k0', other=1, k2=1, k0=1) if traj_type_kzyx[-3] == TrajType.ONGRID: # kz kz_idx = ktraj_tensor[-3, ...].round().to(dtype=torch.int64) + sorted_grid_shape.z // 2 else: sorted_grid_shape.z = ktraj_tensor.shape[-3] - kz_idx = rearrange(torch.arange(ktraj_tensor.shape[-3]), 'kz->1 kz 1 1') + kz_idx = repeat(torch.arange(ktraj_tensor.shape[-3]), 'k2->other k1 k2 k0', other=1, k1=1, k0=1) # 1D indices into a flattened tensor. kidx = kz_idx * sorted_grid_shape.y * sorted_grid_shape.x + ky_idx * sorted_grid_shape.x + kx_idx diff --git a/src/mrpro/operators/GridSamplingOp.py b/src/mrpro/operators/GridSamplingOp.py index ef906ef9..3294f86e 100644 --- a/src/mrpro/operators/GridSamplingOp.py +++ b/src/mrpro/operators/GridSamplingOp.py @@ -5,6 +5,7 @@ from typing import Literal import torch +from einops import rearrange from mrpro.data.SpatialDimension import SpatialDimension from mrpro.operators.LinearOperator import LinearOperator @@ -224,7 +225,7 @@ def __reshape_wrapper( ) # The gridsample operator only works for real data, thus we handle complex inputs as an additional channel - x_real = torch.view_as_real(x).moveaxis(-1, -dim - 1) if x.is_complex() else x + x_real = rearrange(torch.view_as_real(x), '... real_imag -> real_imag ...') if x.is_complex() else x shape_grid_batch = self.grid.shape[: -dim - 1] # the batch dimensions of grid n_batchdim = len(shape_grid_batch) shape_x_batch = x_real.shape[:n_batchdim] # the batch dimensions of the input @@ -252,7 +253,7 @@ def __reshape_wrapper( # .. and reshape back. result = sampled.reshape(*shape_batch, *shape_channels, *sampled.shape[-dim:]) if x.is_complex(): - result = torch.view_as_complex(result.moveaxis(-dim - 1, -1).contiguous()) + result = torch.view_as_complex(rearrange(result, 'real_imag ... -> ... real_imag').contiguous()) return (result,) def _forward_implementation( diff --git a/src/mrpro/phantoms/coils.py b/src/mrpro/phantoms/coils.py index 0f984b51..e936900d 100644 --- a/src/mrpro/phantoms/coils.py +++ b/src/mrpro/phantoms/coils.py @@ -41,13 +41,16 @@ def birdcage_2d( indexing='xy', ) - c = torch.linspace(0, dim[0] - 1, dim[0])[:, None, None] + x_co = repeat(x_co, 'y x -> coils y x', coils=1) + y_co = repeat(y_co, 'y x -> coils y x', coils=1) + + c = repeat(torch.linspace(0, dim[0] - 1, dim[0]), 'coils -> coils y x', y=1, x=1) coil_center_x = dim[2] * relative_radius * np.cos(c * (2 * torch.pi / dim[0])) coil_center_y = dim[1] * relative_radius * np.sin(c * (2 * torch.pi / dim[0])) coil_phase = -c * (2 * torch.pi / dim[0]) - rr = torch.sqrt((x_co[None, ...] - coil_center_x) ** 2 + (y_co[None, ...] - coil_center_y) ** 2) - phi = torch.arctan2((x_co[None, ...] - coil_center_x), -(y_co[None, ...] - coil_center_y)) + coil_phase + rr = torch.sqrt((x_co - coil_center_x) ** 2 + (y_co - coil_center_y) ** 2) + phi = torch.arctan2((x_co - coil_center_x), -(y_co - coil_center_y)) + coil_phase sensitivities = (1 / rr) * np.exp(1j * phi) if normalize_with_rss: diff --git a/src/mrpro/utils/filters.py b/src/mrpro/utils/filters.py index 6d338cde..74d5556c 100644 --- a/src/mrpro/utils/filters.py +++ b/src/mrpro/utils/filters.py @@ -8,6 +8,7 @@ import numpy as np import torch +from einops import repeat def filter_separable( @@ -72,9 +73,11 @@ def filter_separable( left_pad = (len(kernel) - 1) // 2 right_pad = (len(kernel) - 1) - left_pad x_flat = torch.nn.functional.pad(x_flat, pad=(left_pad, right_pad), mode=pad_mode, value=pad_value) - x = torch.nn.functional.conv1d(x_flat[:, None, :], kernel[None, None, :], padding=padding_conv).reshape( - *x.shape[:-1], -1 - ) + x = torch.nn.functional.conv1d( + repeat(x_flat, 'batch x -> batch channels x', channels=1), + repeat(kernel, 'x -> batch channels x', batch=1, channels=1), + padding=padding_conv, + ).reshape(*x.shape[:-1], -1) # for a single permutation, this undoes the permutation x = x.permute(idx) return x diff --git a/tests/algorithms/dcf/test_dcf_voronoi.py b/tests/algorithms/dcf/test_dcf_voronoi.py index 34785db8..d88e7d2c 100644 --- a/tests/algorithms/dcf/test_dcf_voronoi.py +++ b/tests/algorithms/dcf/test_dcf_voronoi.py @@ -4,17 +4,20 @@ import pytest import torch +from einops import repeat from mrpro.algorithms.dcf import dcf_1d, dcf_2d3d_voronoi from mrpro.data import KTrajectory def example_traj_rad_2d(n_kr, n_ka, phi0=0.0, broadcast=True): """Create 2D radial trajectory with uniform angular gap.""" - krad = torch.linspace(-n_kr // 2, n_kr // 2 - 1, n_kr) / n_kr - kang = torch.linspace(0, n_ka - 1, n_ka) * (torch.pi / n_ka) + phi0 + krad = repeat(torch.linspace(-n_kr // 2, n_kr // 2 - 1, n_kr) / n_kr, 'k0 -> other k2 k1 k0', other=1, k2=1, k1=1) + kang = repeat( + torch.linspace(0, n_ka - 1, n_ka) * (torch.pi / n_ka) + phi0, 'k1 -> other k2 k1 k0', other=1, k2=1, k0=1 + ) kz = torch.zeros(1, 1, 1, 1) - ky = (torch.sin(kang[:, None]) * krad[None, :])[None, None, :, :] - kx = (torch.cos(kang[:, None]) * krad[None, :])[None, None, :, :] + ky = torch.sin(kang) * krad + kx = torch.cos(kang) * krad trajectory = KTrajectory(kz, ky, kx, repeat_detection_tolerance=1e-8 if broadcast else None) return trajectory @@ -41,7 +44,7 @@ def test_dcf_rad_traj_voronoi(n_kr, n_ka, phi0, broadcast): krad_idx = torch.linspace(-n_kr // 2, n_kr // 2 - 1, n_kr) dcf_analytical = torch.pi / n_ka * torch.abs(krad_idx) * (1 / n_kr) ** 2 dcf_analytical[krad_idx == 0] = 2 * torch.pi / n_ka * 1 / 8 * (1 / n_kr) ** 2 - dcf_analytical = torch.repeat_interleave(dcf_analytical[None, ...], n_ka, dim=0)[None, :, :] + dcf_analytical = torch.repeat_interleave(repeat(dcf_analytical, 'k0->k2 k1 k0', k1=1, k2=1), n_ka, dim=-2) # Do not test outer points because they have to be approximated and cannot be calculated # accurately using voronoi torch.testing.assert_close(dcf_analytical[:, :, 1:-1], dcf[:, :, 1:-1]) @@ -59,9 +62,9 @@ def test_dcf_3d_cart_traj_broadcast_voronoi(n_k2, n_k1, n_k0): Cartesian trajectory to analytical solution which is 1 for each k-space point.""" # 3D trajectory with points on Cartesian grid with step size of 1 - kx = torch.linspace(-n_k0 // 2, n_k0 // 2 - 1, n_k0)[None, None, None, :] - ky = torch.linspace(-n_k1 // 2, n_k1 // 2 - 1, n_k1)[None, None, :, None] - kz = torch.linspace(-n_k2 // 2, n_k2 // 2 - 1, n_k2)[None, :, None, None] + kx = repeat(torch.linspace(-n_k0 // 2, n_k0 // 2 - 1, n_k0), 'k0 -> other k2 k1 k0', other=1, k2=1, k1=1) + ky = repeat(torch.linspace(-n_k1 // 2, n_k1 // 2 - 1, n_k1), 'k1 -> other k2 k1 k0', other=1, k2=1, k0=1) + kz = repeat(torch.linspace(-n_k2 // 2, n_k2 // 2 - 1, n_k2), 'k2 -> other k2 k1 k0', other=1, k1=1, k0=1) trajectory = KTrajectory(kx, ky, kz) # Analytical dcf @@ -84,7 +87,12 @@ def test_dcf_3d_cart_full_traj_voronoi(n_k2, n_k1, n_k0): torch.linspace(-n_k0 // 2, n_k0 // 2 - 1, n_k0), indexing='xy', ) - trajectory = KTrajectory(kz[None, ...], ky[None, ...], kx[None, ...], repeat_detection_tolerance=None) + trajectory = KTrajectory( + repeat(kz, '... -> other ...', other=1), + repeat(ky, '... -> other ...', other=1), + repeat(kx, '... -> other ...', other=1), + repeat_detection_tolerance=None, + ) # Analytical dcf dcf_analytical = torch.ones((1, n_k2, n_k1, n_k0)) dcf = dcf_2d3d_voronoi(trajectory.as_tensor()) @@ -114,19 +122,19 @@ def k_range(n: int, *steps: float): ky_full, kz_full, kx_full = torch.meshgrid(k1_range, k2_range, k0_range, indexing='xy') trajectory_full = KTrajectory( - kz_full[None, ...], - ky_full[None, ...], - kx_full[None, ...], + repeat(kz_full, '... -> other ...', other=1), + repeat(ky_full, '... -> other ...', other=1), + repeat(kx_full, '... -> other ...', other=1), repeat_detection_tolerance=None, ) - kx_broadcast = k0_range[None, None, :] - ky_broadcast = k1_range[None, :, None] - kz_broadcast = k2_range[:, None, None] + kx_broadcast = repeat(k0_range, 'k0 -> k2 k1 k0', k2=1, k1=1) + ky_broadcast = repeat(k1_range, 'k1 -> k2 k1 k0', k2=1, k0=1) + kz_broadcast = repeat(k2_range, 'k2 -> k2 k1 k0', k1=1, k0=1) trajectory_broadcast = KTrajectory( - kz_broadcast[None, ...], - ky_broadcast[None, ...], - kx_broadcast[None, ...], + repeat(kz_broadcast, '... -> other ...', other=1), + repeat(ky_broadcast, '... -> other ...', other=1), + repeat(kx_broadcast, '... -> other ...', other=1), repeat_detection_tolerance=None, ) diff --git a/tests/data/_IsmrmrdRawTestData.py b/tests/data/_IsmrmrdRawTestData.py index 1e845209..59c42be1 100644 --- a/tests/data/_IsmrmrdRawTestData.py +++ b/tests/data/_IsmrmrdRawTestData.py @@ -371,13 +371,13 @@ def _radial_trajectory( acceleration undersampling factor """ - # Fully sampled frequency encoding - kfe = torch.arange(-n_freq_encoding // 2, n_freq_encoding // 2) + # Fully sampled frequency encoding (sorting of ISMRMD is x,y,z) + kfe = repeat(torch.arange(-n_freq_encoding // 2, n_freq_encoding // 2), 'k0 -> k0 k1', k1=1) - # Uniform angular sampling + # Uniform angular sampling (sorting of ISMRMD is x,y,z) kpe = torch.linspace(0, n_phase_encoding - 1, n_phase_encoding // acceleration, dtype=torch.int32) - kang = kpe * (torch.pi / len(kpe)) + kang = repeat(kpe * (torch.pi / len(kpe)), 'k1 -> k0 k1', k0=1) - traj_ky = torch.sin(kang[None, :]) * kfe[:, None] - traj_kx = torch.cos(kang[None, :]) * kfe[:, None] + traj_ky = torch.sin(kang) * kfe + traj_kx = torch.cos(kang) * kfe return traj_ky, traj_kx, kpe diff --git a/tests/data/_PulseqRadialTestSeq.py b/tests/data/_PulseqRadialTestSeq.py index 79119fb2..82cab557 100644 --- a/tests/data/_PulseqRadialTestSeq.py +++ b/tests/data/_PulseqRadialTestSeq.py @@ -2,6 +2,7 @@ import pypulseq import torch +from einops import repeat from mrpro.data import KTrajectory @@ -49,9 +50,9 @@ def __init__(self, seq_filename: str, n_x=256, n_spokes=10): self.seq_filename = seq_filename kz = torch.zeros(1, 1, n_spokes, n_x) - angle = torch.pi / n_spokes * torch.arange(n_spokes) - k0 = delta_k * torch.linspace(-n_x / 2, n_x / 2 - 1, n_x) - kx = (torch.cos(angle)[:, None] * k0)[None, None, ...] - ky = (torch.sin(angle)[:, None] * k0)[None, None, ...] + angle = repeat(torch.pi / n_spokes * torch.arange(n_spokes), 'k1 -> other k2 k1 k0', other=1, k2=1, k0=1) + k0 = repeat(delta_k * torch.linspace(-n_x / 2, n_x / 2 - 1, n_x), 'k0 -> other k2 k1 k0', other=1, k2=1, k1=1) + kx = torch.cos(angle) * k0 + ky = torch.sin(angle) * k0 self.traj_analytical = KTrajectory(kz, ky, kx) diff --git a/tests/data/test_dcf_data.py b/tests/data/test_dcf_data.py index 07fd1cfb..bc67bcd5 100644 --- a/tests/data/test_dcf_data.py +++ b/tests/data/test_dcf_data.py @@ -2,16 +2,17 @@ import pytest import torch +from einops import repeat from mrpro.data import DcfData, KTrajectory def example_traj_rpe(n_kr, n_ka, n_k0, broadcast=True): """Create RPE trajectory with uniform angular gap.""" - krad = torch.linspace(-n_kr // 2, n_kr // 2 - 1, n_kr) / n_kr - kang = torch.linspace(0, n_ka - 1, n_ka) * (torch.pi / n_ka) - kz = (torch.sin(kang[:, None]) * krad[None, :])[None, :, :, None] - ky = (torch.cos(kang[:, None]) * krad[None, :])[None, :, :, None] - kx = (torch.linspace(-n_k0 // 2, n_k0 // 2 - 1, n_k0) / n_k0)[None, None, None, :] + krad = repeat(torch.linspace(-n_kr // 2, n_kr // 2 - 1, n_kr) / n_kr, 'k1 -> other k2 k1 k0', other=1, k2=1, k0=1) + kang = repeat(torch.linspace(0, n_ka - 1, n_ka) * (torch.pi / n_ka), 'k2 -> other k2 k1 k0', other=1, k1=1, k0=1) + kz = torch.sin(kang) * krad + ky = torch.cos(kang) * krad + kx = repeat(torch.linspace(-n_k0 // 2, n_k0 // 2 - 1, n_k0) / n_k0, 'k0 -> other k2 k1 k0', other=1, k2=1, k1=1) trajectory = KTrajectory(kz, ky, kx, repeat_detection_tolerance=1e-8 if broadcast else None) return trajectory @@ -19,11 +20,13 @@ def example_traj_rpe(n_kr, n_ka, n_k0, broadcast=True): def example_traj_spiral_2d(n_kr, n_ki, n_ka, broadcast=True) -> KTrajectory: """Create 2D spiral trajectory with n_kr points along each spiral arm, n_ki turns per spiral arm and n_ka spiral arms.""" - ang = torch.linspace(0, 2 * torch.pi * n_ki, n_kr) - start_ang = torch.linspace(0, 2 * torch.pi * (1 - 1 / n_ka), n_ka) + ang = repeat(torch.linspace(0, 2 * torch.pi * n_ki, n_kr), 'k0 -> other k2 k1 k0', other=1, k2=1, k1=1) + start_ang = repeat( + torch.linspace(0, 2 * torch.pi * (1 - 1 / n_ka), n_ka), 'k1 -> other k2 k1 k0', other=1, k2=1, k0=1 + ) kz = torch.zeros(1, 1, 1, 1) - kx = (ang[None, :] * torch.cos(ang[None, :] + start_ang[:, None]))[None, None, :, :] - ky = (ang[None, :] * torch.sin(ang[None, :] + start_ang[:, None]))[None, None, :, :] + kx = ang * torch.cos(ang + start_ang) + ky = ang * torch.sin(ang + start_ang) trajectory = KTrajectory(kz, ky, kx, repeat_detection_tolerance=1e-8 if broadcast else None) return trajectory diff --git a/tests/data/test_kdata.py b/tests/data/test_kdata.py index d520b0c8..68f9dfd0 100644 --- a/tests/data/test_kdata.py +++ b/tests/data/test_kdata.py @@ -426,7 +426,7 @@ def test_KData_remove_readout_os(monkeypatch, random_kheader): random_generator = RandomGenerator(seed=0) # List of k1 indices in the shape - idx_k1 = torch.arange(n_k1, dtype=torch.int32)[None, None, ...] + idx_k1 = repeat(torch.arange(n_k1, dtype=torch.int32), 'k1 -> other k2 k1', other=1, k2=1) # Set parameters need in remove_os monkeypatch.setattr(random_kheader.encoding_matrix, 'x', n_k0_oversampled) diff --git a/tests/data/test_traj_calculators.py b/tests/data/test_traj_calculators.py index 91a97318..4bf472da 100644 --- a/tests/data/test_traj_calculators.py +++ b/tests/data/test_traj_calculators.py @@ -27,9 +27,9 @@ def valid_rad2d_kheader(monkeypatch, random_kheader): n_k2 = 1 # List of k1 indices in the shape - idx_k1 = torch.arange(n_k1, dtype=torch.int32)[None, None, ...] + idx_k1 = repeat(torch.arange(n_k1, dtype=torch.int32), 'k1 -> other k2 k1', other=1, k2=1) - # Set parameters for radial 2D trajectory + # Set parameters for radial 2D trajectory (AcqInfo is of shape (other k2 k1 dim=1 or 3)) monkeypatch.setattr(random_kheader.acq_info, 'number_of_samples', torch.zeros_like(idx_k1)[..., None] + n_k0) monkeypatch.setattr(random_kheader.acq_info, 'center_sample', torch.zeros_like(idx_k1)[..., None] + n_k0 // 2) monkeypatch.setattr(random_kheader.acq_info, 'flags', torch.zeros_like(idx_k1)[..., None]) @@ -81,7 +81,7 @@ def valid_rpe_kheader(monkeypatch, random_kheader): idx_k1 = torch.reshape(idx_k1, (1, n_k2, n_k1)) idx_k2 = torch.reshape(idx_k2, (1, n_k2, n_k1)) - # Set parameters for RPE trajectory + # Set parameters for RPE trajectory (AcqInfo is of shape (other k2 k1 dim=1 or 3)) monkeypatch.setattr(random_kheader.acq_info, 'number_of_samples', torch.zeros_like(idx_k1)[..., None] + n_k0) monkeypatch.setattr(random_kheader.acq_info, 'center_sample', torch.zeros_like(idx_k1)[..., None] + n_k0 // 2) monkeypatch.setattr(random_kheader.acq_info, 'flags', torch.zeros_like(idx_k1)[..., None]) @@ -167,7 +167,7 @@ def valid_cartesian_kheader(monkeypatch, random_kheader): idx_k1 = repeat(torch.reshape(idx_k1, (n_k2, n_k1)), 'k2 k1->other k2 k1', other=n_other) idx_k2 = repeat(torch.reshape(idx_k2, (n_k2, n_k1)), 'k2 k1->other k2 k1', other=n_other) - # Set parameters for Cartesian trajectory + # Set parameters for Cartesian trajectory (AcqInfo is of shape (other k2 k1 dim=1 or 3)) monkeypatch.setattr(random_kheader.acq_info, 'number_of_samples', torch.zeros_like(idx_k1)[..., None] + n_k0) monkeypatch.setattr(random_kheader.acq_info, 'center_sample', torch.zeros_like(idx_k1)[..., None] + n_k0 // 2) monkeypatch.setattr(random_kheader.acq_info, 'flags', torch.zeros_like(idx_k1)[..., None]) diff --git a/tests/data/test_trajectory.py b/tests/data/test_trajectory.py index fd26e546..7ea298f2 100644 --- a/tests/data/test_trajectory.py +++ b/tests/data/test_trajectory.py @@ -2,6 +2,7 @@ import pytest import torch +from einops import rearrange from mrpro.data import KTrajectory from mrpro.data.enums import TrajType @@ -45,8 +46,10 @@ def test_trajectory_tensor_conversion(cartesian_grid): tensor = torch.stack((kz_full, ky_full, kx_full), dim=0).to(torch.float32) tensor_from_traj = trajectory.as_tensor() # stack_dim=0 - tensor_from_traj_dim2 = trajectory.as_tensor(stack_dim=2).moveaxis(2, 0) - tensor_from_traj_from_tensor_dim3 = KTrajectory.from_tensor(tensor.moveaxis(0, 3), stack_dim=3).as_tensor() + tensor_from_traj_dim2 = rearrange(trajectory.as_tensor(stack_dim=2), 'other k2 dim k1 k0->dim other k2 k1 k0') + tensor_from_traj_from_tensor_dim3 = KTrajectory.from_tensor( + rearrange(tensor, 'dim other k2 k1 k0->other k2 k1 dim k0'), stack_dim=3 + ).as_tensor() tensor_from_traj_from_tensor = KTrajectory.from_tensor(tensor).as_tensor() # stack_dim=0 torch.testing.assert_close(tensor, tensor_from_traj) diff --git a/tests/operators/models/test_transient_steady_state_with_preparation.py b/tests/operators/models/test_transient_steady_state_with_preparation.py index 1da7e25f..87d21a33 100644 --- a/tests/operators/models/test_transient_steady_state_with_preparation.py +++ b/tests/operators/models/test_transient_steady_state_with_preparation.py @@ -2,6 +2,7 @@ import pytest import torch +from einops import repeat from mrpro.operators.models import TransientSteadyStateWithPreparation from tests.operators.models.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples @@ -49,10 +50,10 @@ def test_transient_steady_state_inversion_recovery(): t1 = torch.as_tensor([100, 200, 300, 400, 500, 1000, 2000, 4000]) flip_angle = torch.ones_like(t1) * 0.0001 m0 = torch.ones_like(t1) - sampling_time = torch.as_tensor([0, 100, 400, 800, 2000]) + sampling_time = repeat(torch.as_tensor([0, 100, 400, 800, 2000]), 'time -> time m0_t1_values', m0_t1_values=1) # analytical signal - analytical_signal = m0 * (1 - 2 * torch.exp(-(sampling_time[..., None] / t1))) + analytical_signal = m0 * (1 - 2 * torch.exp(-(sampling_time / t1))) model = TransientSteadyStateWithPreparation(sampling_time, repetition_time=100, m0_scaling_preparation=-1) (signal,) = model.forward(m0, t1, flip_angle) diff --git a/tests/operators/test_finite_difference_op.py b/tests/operators/test_finite_difference_op.py index 0e27a4cb..f79da441 100644 --- a/tests/operators/test_finite_difference_op.py +++ b/tests/operators/test_finite_difference_op.py @@ -2,6 +2,7 @@ import pytest import torch +from einops import repeat from mrpro.operators import FiniteDifferenceOp from tests import RandomGenerator @@ -12,7 +13,9 @@ def test_finite_difference_op_forward(mode): """Test correct finite difference of simple object.""" # Test object with positive linear gradient in real and negative linear gradient imaginary part - linear_gradient_object = torch.arange(1, 21)[None, :] + 2 * torch.arange(1, 21)[:, None].to(dtype=torch.float32) + linear_gradient_object = ( + repeat(torch.arange(1, 21), 'x -> y x', y=1) + 2 * repeat(torch.arange(1, 21), 'y -> y x', x=1) + ).to(dtype=torch.float32) linear_gradient_object = linear_gradient_object - 1j * linear_gradient_object # Generate and apply finite difference operator diff --git a/tests/utils/test_filters.py b/tests/utils/test_filters.py index 90402cdc..b69a5d8e 100644 --- a/tests/utils/test_filters.py +++ b/tests/utils/test_filters.py @@ -2,6 +2,7 @@ import pytest import torch +from einops import repeat from mrpro.utils.filters import filter_separable, gaussian_filter, uniform_filter @@ -20,7 +21,7 @@ def data(): def test_filter_separable(pad_mode, center_value, edge_value): """Test filter_separable and different padding modes.""" - data = torch.arange(1, 21)[None, :].to(dtype=torch.float32) + data = repeat(torch.arange(1, 21), 'x -> y x', y=1).to(dtype=torch.float32) kernels = (torch.as_tensor([1.0, 2.0, 1.0]),) result = filter_separable( data, kernels, dim=(1,), pad_mode=pad_mode, pad_value=3.0 if pad_mode == 'constant' else 0.0 @@ -38,7 +39,7 @@ def test_filter_separable(pad_mode, center_value, edge_value): def test_filter_separable_dtype(filter_dtype, data_dtype): """Test filter_separable and different padding modes.""" - data = torch.arange(1, 21)[None, :].to(dtype=data_dtype) + data = repeat(torch.arange(1, 21), 'x -> y x', y=1).to(dtype=data_dtype) kernels = (torch.tensor([1, 2, 1], dtype=filter_dtype),) result = filter_separable(data, kernels, dim=(1,)) expected_dtype = torch.result_type(data, kernels[0]) From 5124114d2d0ce3287f461a93943f0c18651eca1d Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Tue, 3 Sep 2024 16:31:12 +0200 Subject: [PATCH 26/34] Remove copyright from OptimizerStatus (#383) --- src/mrpro/algorithms/optimizers/OptimizerStatus.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/mrpro/algorithms/optimizers/OptimizerStatus.py b/src/mrpro/algorithms/optimizers/OptimizerStatus.py index 17e74164..b682cf38 100644 --- a/src/mrpro/algorithms/optimizers/OptimizerStatus.py +++ b/src/mrpro/algorithms/optimizers/OptimizerStatus.py @@ -1,17 +1,5 @@ """Optimizer Status base class.""" -# Copyright 2024 Physikalisch-Technische Bundesanstalt -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - from typing import TypedDict import torch From 27128024f93f38c8f4d13caf1a3d4ad67f9c8153 Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Tue, 3 Sep 2024 16:55:18 +0200 Subject: [PATCH 27/34] Add Csm Inati 2D and 3D (#345) Co-authored-by: Felix F Zimmermann --- src/mrpro/algorithms/csm/__init__.py | 1 + src/mrpro/algorithms/csm/inati.py | 67 ++++++++++++++++++++ src/mrpro/data/CsmData.py | 26 ++++++++ tests/algorithms/csm/conftest.py | 19 ++++++ tests/algorithms/csm/test_inati.py | 20 ++++++ tests/algorithms/csm/test_iterative_walsh.py | 19 +----- tests/data/test_csm_data.py | 19 +++--- 7 files changed, 145 insertions(+), 26 deletions(-) create mode 100644 src/mrpro/algorithms/csm/inati.py create mode 100644 tests/algorithms/csm/conftest.py create mode 100644 tests/algorithms/csm/test_inati.py diff --git a/src/mrpro/algorithms/csm/__init__.py b/src/mrpro/algorithms/csm/__init__.py index feec5198..431770cb 100644 --- a/src/mrpro/algorithms/csm/__init__.py +++ b/src/mrpro/algorithms/csm/__init__.py @@ -1 +1,2 @@ from mrpro.algorithms.csm.iterative_walsh import iterative_walsh +from mrpro.algorithms.csm.inati import inati diff --git a/src/mrpro/algorithms/csm/inati.py b/src/mrpro/algorithms/csm/inati.py new file mode 100644 index 00000000..1f83e8f8 --- /dev/null +++ b/src/mrpro/algorithms/csm/inati.py @@ -0,0 +1,67 @@ +"""Inati method for coil sensitivity map calculation.""" + +import torch + +from mrpro.data.SpatialDimension import SpatialDimension +from mrpro.utils.sliding_window import sliding_window + + +def inati( + coil_images: torch.Tensor, + smoothing_width: SpatialDimension[int] | int, +) -> torch.Tensor: + """Calculate a coil sensitivity map (csm) using an the Inati method [INA2013]_ [INA2014]_. + + This is for a single set of coil images. The input should be a tensor with dimensions (coils, z, y, x). The output + will have the same dimensions. Either apply this function individually to each set of coil images, or see + CsmData.from_idata_inati which performs this operation on a whole dataset. + + .. [INA2013] Inati S, Hansen M, Kellman P (2013) A solution to the phase problem in adaptvie coil combination. + in Proceedings of the 21st Annual Meeting of ISMRM, Salt Lake City, USA, 2672. + + .. [INA2014] Inati S, Hansen M (2014) A Fast Optimal Method for Coil Sensitivity Estimation and Adaptive Coil + Combination for Complex Images. in Proceedings of Joint Annual Meeting ISMRM-ESMRMB, Milan, Italy, 7115. + + Parameters + ---------- + coil_images + Images for each coil element + smoothing_width + Size of the smoothing kernel + """ + # After 10 power iterations we will have a very good estimate of the singular vector + n_power_iterations = 10 + + # Padding at the edge of the images + padding_mode = 'replicate' + + if isinstance(smoothing_width, int): + smoothing_width = SpatialDimension( + z=smoothing_width if coil_images.shape[-3] > 1 else 1, y=smoothing_width, x=smoothing_width + ) + + if any(ks % 2 != 1 for ks in [smoothing_width.z, smoothing_width.y, smoothing_width.x]): + raise ValueError('kernel_size must be odd') + + ks_halved = [ks // 2 for ks in smoothing_width.zyx] + padded_coil_images = torch.nn.functional.pad( + coil_images, + (ks_halved[-1], ks_halved[-1], ks_halved[-2], ks_halved[-2], ks_halved[-3], ks_halved[-3]), + mode=padding_mode, + ) + # Get the voxels in an ROI defined by the smoothing_width around each voxel leading to shape + # (coils z y x prod(smoothing_width)) + coil_images_roi = sliding_window(padded_coil_images, smoothing_width.zyx, axis=(-3, -2, -1)).flatten(-3) + # Covariance with shape (z y x coils coils) + coil_images_covariance = torch.einsum('i...j,k...j->...ik', coil_images_roi.conj(), coil_images_roi) + singular_vector = torch.sum(coil_images_roi, dim=-1) # coils z y x + singular_vector /= singular_vector.norm(dim=0, keepdim=True) + for _ in range(n_power_iterations): + singular_vector = torch.einsum('...ij,j...->i...', coil_images_covariance, singular_vector) # coils z y x + singular_vector /= singular_vector.norm(dim=0, keepdim=True) + + singular_value = torch.einsum('i...j,i...->...j', coil_images_roi, singular_vector) # z y x prod(smoothing_width) + phase = singular_value.sum(-1) + phase /= phase.abs() # z y x + csm = singular_vector.conj() * phase[None, ...] + return csm diff --git a/src/mrpro/data/CsmData.py b/src/mrpro/data/CsmData.py index 9f59bc93..7f98d635 100644 --- a/src/mrpro/data/CsmData.py +++ b/src/mrpro/data/CsmData.py @@ -53,6 +53,32 @@ def from_idata_walsh( csm = cls(header=idata.header, data=csm_tensor) return csm + @classmethod + def from_idata_inati( + cls, + idata: IData, + smoothing_width: int | SpatialDimension[int] = 5, + chunk_size_otherdim: int | None = None, + ) -> Self: + """Create csm object from image data using Inati method. + + Parameters + ---------- + idata + IData object containing the images for each coil element. + smoothing_width + Size of the smoothing kernel. + chunk_size_otherdim: + How many elements of the other dimensions should be processed at once. + Default is None, which means that all elements are processed at once. + """ + from mrpro.algorithms.csm.inati import inati + + csm_fun = torch.vmap(lambda img: inati(img, smoothing_width), chunk_size=chunk_size_otherdim) + csm_tensor = csm_fun(idata.data) + csm = cls(header=idata.header, data=csm_tensor) + return csm + def as_operator(self) -> SensitivityOp: """Create SensitivityOp using a copy of the CSMs.""" from mrpro.operators.SensitivityOp import SensitivityOp diff --git a/tests/algorithms/csm/conftest.py b/tests/algorithms/csm/conftest.py new file mode 100644 index 00000000..573a143d --- /dev/null +++ b/tests/algorithms/csm/conftest.py @@ -0,0 +1,19 @@ +"""PyTest fixtures for the csm tests.""" + +from mrpro.data import IData, SpatialDimension +from mrpro.phantoms.coils import birdcage_2d + + +def multi_coil_image(n_coils, ph_ellipse, random_kheader): + """Create multi-coil image.""" + image_dimensions = SpatialDimension(z=1, y=ph_ellipse.n_y, x=ph_ellipse.n_x) + + # Create reference coil sensitivities + csm_ref = birdcage_2d(n_coils, image_dimensions) + + # Create multi-coil phantom image data + img = ph_ellipse.phantom.image_space(image_dimensions) + # +1 to ensure that there is signal everywhere, for voxel == 0 csm cannot be determined. + img_multi_coil = (img + 1) * csm_ref + idata = IData.from_tensor_and_kheader(data=img_multi_coil, kheader=random_kheader) + return (idata, csm_ref) diff --git a/tests/algorithms/csm/test_inati.py b/tests/algorithms/csm/test_inati.py new file mode 100644 index 00000000..0e179b2e --- /dev/null +++ b/tests/algorithms/csm/test_inati.py @@ -0,0 +1,20 @@ +"""Tests the iterative Walsh algorithm.""" + +import torch +from mrpro.algorithms.csm import inati +from mrpro.data import SpatialDimension +from tests.algorithms.csm.conftest import multi_coil_image +from tests.helper import relative_image_difference + + +def test_inati(ellipse_phantom, random_kheader): + """Test the Inati method.""" + idata, csm_ref = multi_coil_image(n_coils=4, ph_ellipse=ellipse_phantom, random_kheader=random_kheader) + + # Estimate coil sensitivity maps. + # inati should be applied for each other dimension separately + smoothing_width = SpatialDimension(z=1, y=5, x=5) + csm = inati(idata.data[0, ...], smoothing_width) + + # Phase is only relative in csm calculation, therefore only the abs values are compared. + assert relative_image_difference(torch.abs(csm), torch.abs(csm_ref[0, ...])) <= 0.01 diff --git a/tests/algorithms/csm/test_iterative_walsh.py b/tests/algorithms/csm/test_iterative_walsh.py index 774aa0ce..a8005e3b 100644 --- a/tests/algorithms/csm/test_iterative_walsh.py +++ b/tests/algorithms/csm/test_iterative_walsh.py @@ -2,26 +2,11 @@ import torch from mrpro.algorithms.csm import iterative_walsh -from mrpro.data import IData, SpatialDimension -from mrpro.phantoms.coils import birdcage_2d +from mrpro.data import SpatialDimension +from tests.algorithms.csm.conftest import multi_coil_image from tests.helper import relative_image_difference -def multi_coil_image(n_coils, ph_ellipse, random_kheader): - """Create multi-coil image.""" - image_dimensions = SpatialDimension(z=1, y=ph_ellipse.n_y, x=ph_ellipse.n_x) - - # Create reference coil sensitivities - csm_ref = birdcage_2d(n_coils, image_dimensions) - - # Create multi-coil phantom image data - img = ph_ellipse.phantom.image_space(image_dimensions) - # +1 to ensure that there is signal everywhere, for voxel == 0 csm cannot be determined. - img_multi_coil = (img + 1) * csm_ref - idata = IData.from_tensor_and_kheader(data=img_multi_coil, kheader=random_kheader) - return (idata, csm_ref) - - def test_iterative_Walsh(ellipse_phantom, random_kheader): """Test the iterative Walsh method.""" idata, csm_ref = multi_coil_image(n_coils=4, ph_ellipse=ellipse_phantom, random_kheader=random_kheader) diff --git a/tests/data/test_csm_data.py b/tests/data/test_csm_data.py index 28a19eb2..fd7459f6 100644 --- a/tests/data/test_csm_data.py +++ b/tests/data/test_csm_data.py @@ -17,30 +17,31 @@ def test_CsmData_is_frozen_dataclass(random_test_data, random_kheader): csm.data = random_test_data # type: ignore[misc] -def test_CsmData_interactive_Walsh_smoothing_width(ellipse_phantom, random_kheader): - """CsmData from iterative Walsh method using SpatialDimension and int for - smoothing width.""" +@pytest.mark.parametrize('csm_method', [CsmData.from_idata_walsh, CsmData.from_idata_inati]) +def test_CsmData_smoothing_width(csm_method, ellipse_phantom, random_kheader): + """CsmData SpatialDimension and int for smoothing width.""" idata, csm_ref = multi_coil_image(n_coils=4, ph_ellipse=ellipse_phantom, random_kheader=random_kheader) # Estimate coil sensitivity maps using SpatialDimension for smoothing width - smoothing_width = SpatialDimension(z=5, y=5, x=5) - csm_using_spatial_dimension = CsmData.from_idata_walsh(idata, smoothing_width) + smoothing_width = SpatialDimension(z=1, y=5, x=5) + csm_using_spatial_dimension = csm_method(idata, smoothing_width) # Estimate coil sensitivity maps using int for smoothing width - csm_using_int = CsmData.from_idata_walsh(idata, smoothing_width=5) + csm_using_int = csm_method(idata, smoothing_width=5) # assert that both coil sensitivity maps are equal, not just close assert torch.equal(csm_using_spatial_dimension.data, csm_using_int.data) @pytest.mark.cuda() -def test_CsmData_iterative_Walsh_cuda(ellipse_phantom, random_kheader): - """CsmData obtained with the iterative Walsh method in CUDA memory.""" +@pytest.mark.parametrize('csm_method', [CsmData.from_idata_walsh, CsmData.from_idata_inati]) +def test_CsmData_cuda(csm_method, ellipse_phantom, random_kheader): + """CsmData obtained on GPU in CUDA memory.""" idata, csm_ref = multi_coil_image(n_coils=4, ph_ellipse=ellipse_phantom, random_kheader=random_kheader) # Estimate coil sensitivity maps smoothing_width = SpatialDimension(z=1, y=5, x=5) - csm = CsmData.from_idata_walsh(idata.cuda(), smoothing_width) + csm = csm_method(idata.cuda(), smoothing_width) # Phase is only relative in csm calculation, therefore only the abs values are compared. assert relative_image_difference(torch.abs(csm.data), torch.abs(csm_ref.cuda())) <= 0.01 From faddf9b1460f582f2de36210f13eb92a5b665ca9 Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Tue, 3 Sep 2024 18:12:52 +0200 Subject: [PATCH 28/34] Allow init of reconstructions from kdata (#379) - allow kdata in the init of DirectRekonstruction and IterativeSENSEReconstruction - CsmData can be a csm map, None or a callable to calculate CsmData from kdata - remove from_kdata --- examples/direct_reconstruction.ipynb | 2 +- examples/direct_reconstruction.py | 2 +- examples/iterative_sense_reconstruction.ipynb | 73 +++++++---- examples/iterative_sense_reconstruction.py | 27 ++-- examples/pulseq_2d_radial_golden_angle.ipynb | 6 +- examples/pulseq_2d_radial_golden_angle.py | 6 +- .../reconstruction/DirectReconstruction.py | 74 +++++------ .../IterativeSENSEReconstruction.py | 119 ++++++++++-------- .../reconstruction/Reconstruction.py | 19 ++- 9 files changed, 199 insertions(+), 129 deletions(-) diff --git a/examples/direct_reconstruction.ipynb b/examples/direct_reconstruction.ipynb index 6aa95eb9..3b6dc930 100644 --- a/examples/direct_reconstruction.ipynb +++ b/examples/direct_reconstruction.ipynb @@ -72,7 +72,7 @@ "# Load in the Data from the ISMRMRD file\n", "kdata = mrpro.data.KData.from_file(data_file.name, trajectory)\n", "# Perform the reconstruction\n", - "reconstruction = mrpro.algorithms.reconstruction.DirectReconstruction.from_kdata(kdata)\n", + "reconstruction = mrpro.algorithms.reconstruction.DirectReconstruction(kdata)\n", "# Use this to run on gpu: kdata = kdata.cuda()\n", "img = reconstruction(kdata)" ] diff --git a/examples/direct_reconstruction.py b/examples/direct_reconstruction.py index b8528cda..7672aa7e 100644 --- a/examples/direct_reconstruction.py +++ b/examples/direct_reconstruction.py @@ -29,7 +29,7 @@ # Load in the Data from the ISMRMRD file kdata = mrpro.data.KData.from_file(data_file.name, trajectory) # Perform the reconstruction -reconstruction = mrpro.algorithms.reconstruction.DirectReconstruction.from_kdata(kdata) +reconstruction = mrpro.algorithms.reconstruction.DirectReconstruction(kdata) # Use this to run on gpu: kdata = kdata.cuda() img = reconstruction(kdata) # %% diff --git a/examples/iterative_sense_reconstruction.ipynb b/examples/iterative_sense_reconstruction.ipynb index 83571eef..2ef7b517 100644 --- a/examples/iterative_sense_reconstruction.ipynb +++ b/examples/iterative_sense_reconstruction.ipynb @@ -73,6 +73,24 @@ "which is a linear system $Hx = b$ that needs to be solved for $x$." ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "ca3e27d2", + "metadata": {}, + "outputs": [], + "source": [ + "import mrpro" + ] + }, + { + "cell_type": "markdown", + "id": "c3e84bc8", + "metadata": {}, + "source": [ + "##### Read-in the raw data" + ] + }, { "cell_type": "code", "execution_count": null, @@ -80,8 +98,6 @@ "metadata": {}, "outputs": [], "source": [ - "import mrpro\n", - "\n", "# Use the trajectory that is stored in the ISMRMRD file\n", "trajectory = mrpro.data.traj_calculators.KTrajectoryIsmrmrd()\n", "# Load in the Data from the ISMRMRD file\n", @@ -90,17 +106,44 @@ "kdata.header.recon_matrix.y = 256" ] }, + { + "cell_type": "markdown", + "id": "c6c1975b", + "metadata": {}, + "source": [ + "##### Direct reconstruction for comparison" + ] + }, { "cell_type": "code", "execution_count": null, "id": "5dafe6fd", - "metadata": { - "lines_to_next_cell": 2 - }, + "metadata": {}, "outputs": [], "source": [ - "iterative_sense_reconstruction = mrpro.algorithms.reconstruction.IterativeSENSEReconstruction.from_kdata(\n", - " kdata, n_iterations=4\n", + "# For comparison we can carry out a direct reconstruction\n", + "direct_reconstruction = mrpro.algorithms.reconstruction.DirectReconstruction(kdata)\n", + "img_direct = direct_reconstruction(kdata)" + ] + }, + { + "cell_type": "markdown", + "id": "15833b4b", + "metadata": {}, + "source": [ + "##### Iterative SENSE reconstruction" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6aee812a", + "metadata": {}, + "outputs": [], + "source": [ + "# We can use the direct reconstruction to obtain the coil maps.\n", + "iterative_sense_reconstruction = mrpro.algorithms.reconstruction.IterativeSENSEReconstruction(\n", + " kdata, csm=direct_reconstruction.csm, n_iterations=4\n", ")\n", "img = iterative_sense_reconstruction(kdata)" ] @@ -212,9 +255,7 @@ "cell_type": "code", "execution_count": null, "id": "7f262a76", - "metadata": { - "lines_to_next_cell": 2 - }, + "metadata": {}, "outputs": [], "source": [ "img_manual = mrpro.algorithms.optimizers.cg(\n", @@ -222,18 +263,6 @@ ")" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "64976733", - "metadata": {}, - "outputs": [], - "source": [ - "# For comparison we can also carry out a direct reconstruction\n", - "direct_reconstruction = mrpro.algorithms.reconstruction.DirectReconstruction.from_kdata(kdata)\n", - "img_direct = direct_reconstruction(kdata)" - ] - }, { "cell_type": "code", "execution_count": null, diff --git a/examples/iterative_sense_reconstruction.py b/examples/iterative_sense_reconstruction.py index 9e4d69d8..87433b01 100644 --- a/examples/iterative_sense_reconstruction.py +++ b/examples/iterative_sense_reconstruction.py @@ -41,6 +41,10 @@ # %% import mrpro +# %% [markdown] +# ##### Read-in the raw data + +# %% # Use the trajectory that is stored in the ISMRMRD file trajectory = mrpro.data.traj_calculators.KTrajectoryIsmrmrd() # Load in the Data from the ISMRMRD file @@ -48,13 +52,24 @@ kdata.header.recon_matrix.x = 256 kdata.header.recon_matrix.y = 256 +# %% [markdown] +# ##### Direct reconstruction for comparison + # %% -iterative_sense_reconstruction = mrpro.algorithms.reconstruction.IterativeSENSEReconstruction.from_kdata( - kdata, n_iterations=4 +# For comparison we can carry out a direct reconstruction +direct_reconstruction = mrpro.algorithms.reconstruction.DirectReconstruction(kdata) +img_direct = direct_reconstruction(kdata) + +# %% [markdown] +# ##### Iterative SENSE reconstruction + +# %% +# We can use the direct reconstruction to obtain the coil maps. +iterative_sense_reconstruction = mrpro.algorithms.reconstruction.IterativeSENSEReconstruction( + kdata, csm=direct_reconstruction.csm, n_iterations=4 ) img = iterative_sense_reconstruction(kdata) - # %% [markdown] # ### Behind the scenes @@ -103,12 +118,6 @@ operator, right_hand_side, initial_value=right_hand_side, max_iterations=4, tolerance=0.0 ) - -# %% -# For comparison we can also carry out a direct reconstruction -direct_reconstruction = mrpro.algorithms.reconstruction.DirectReconstruction.from_kdata(kdata) -img_direct = direct_reconstruction(kdata) - # %% # Display the reconstructed image import matplotlib.pyplot as plt diff --git a/examples/pulseq_2d_radial_golden_angle.ipynb b/examples/pulseq_2d_radial_golden_angle.ipynb index 7e18581c..d0e6e0ad 100644 --- a/examples/pulseq_2d_radial_golden_angle.ipynb +++ b/examples/pulseq_2d_radial_golden_angle.ipynb @@ -75,7 +75,7 @@ "kdata = KData.from_file(data_file.name, KTrajectoryIsmrmrd())\n", "\n", "# Reconstruct image\n", - "direct_reconstruction = DirectReconstruction.from_kdata(kdata)\n", + "direct_reconstruction = DirectReconstruction(kdata)\n", "img_using_ismrmrd_traj = direct_reconstruction.forward(kdata)" ] }, @@ -99,7 +99,7 @@ "kdata = KData.from_file(data_file.name, KTrajectoryRadial2D())\n", "\n", "# Reconstruct image\n", - "direct_reconstruction = DirectReconstruction.from_kdata(kdata)\n", + "direct_reconstruction = DirectReconstruction(kdata)\n", "img_using_rad2d_traj = direct_reconstruction.forward(kdata)" ] }, @@ -142,7 +142,7 @@ "kdata = KData.from_file(data_file.name, KTrajectoryPulseq(seq_path=seq_file.name))\n", "\n", "# Reconstruct image\n", - "direct_reconstruction = DirectReconstruction.from_kdata(kdata)\n", + "direct_reconstruction = DirectReconstruction(kdata)\n", "img_using_pulseq_traj = direct_reconstruction.forward(kdata)" ] }, diff --git a/examples/pulseq_2d_radial_golden_angle.py b/examples/pulseq_2d_radial_golden_angle.py index 34dd706f..955b20a3 100644 --- a/examples/pulseq_2d_radial_golden_angle.py +++ b/examples/pulseq_2d_radial_golden_angle.py @@ -36,7 +36,7 @@ kdata = KData.from_file(data_file.name, KTrajectoryIsmrmrd()) # Reconstruct image -direct_reconstruction = DirectReconstruction.from_kdata(kdata) +direct_reconstruction = DirectReconstruction(kdata) img_using_ismrmrd_traj = direct_reconstruction.forward(kdata) # %% [markdown] @@ -48,7 +48,7 @@ kdata = KData.from_file(data_file.name, KTrajectoryRadial2D()) # Reconstruct image -direct_reconstruction = DirectReconstruction.from_kdata(kdata) +direct_reconstruction = DirectReconstruction(kdata) img_using_rad2d_traj = direct_reconstruction.forward(kdata) # %% [markdown] @@ -72,7 +72,7 @@ kdata = KData.from_file(data_file.name, KTrajectoryPulseq(seq_path=seq_file.name)) # Reconstruct image -direct_reconstruction = DirectReconstruction.from_kdata(kdata) +direct_reconstruction = DirectReconstruction(kdata) img_using_pulseq_traj = direct_reconstruction.forward(kdata) # %% [markdown] diff --git a/src/mrpro/algorithms/reconstruction/DirectReconstruction.py b/src/mrpro/algorithms/reconstruction/DirectReconstruction.py index e3530955..3feab5cd 100644 --- a/src/mrpro/algorithms/reconstruction/DirectReconstruction.py +++ b/src/mrpro/algorithms/reconstruction/DirectReconstruction.py @@ -1,8 +1,7 @@ """Direct Reconstruction by Adjoint Fourier Transform.""" -from typing import Self +from collections.abc import Callable -from mrpro.algorithms.prewhiten_kspace import prewhiten_kspace from mrpro.algorithms.reconstruction.Reconstruction import Reconstruction from mrpro.data._kdata.KData import KData from mrpro.data.CsmData import CsmData @@ -18,8 +17,9 @@ class DirectReconstruction(Reconstruction): def __init__( self, - fourier_op: LinearOperator, - csm: CsmData | None = None, + kdata: KData | None = None, + fourier_op: LinearOperator | None = None, + csm: Callable[[IData], CsmData] | CsmData | None = CsmData.from_idata_walsh, noise: KNoise | None = None, dcf: DcfData | None = None, ): @@ -27,46 +27,48 @@ def __init__( Parameters ---------- + kdata + KData. If kdata is provided and fourier_op or dcf are None, then fourier_op and dcf are estimated based on + kdata. Otherwise fourier_op and dcf are used as provided. fourier_op - Instance of the FourierOperator which adjoint is used for reconstruction. + Instance of the FourierOperator used for reconstruction. If None, set up based on kdata. csm - Sensitivity maps for coil combination. If None, no coil combination will be performed. + Sensitivity maps for coil combination. If None, no coil combination is carried out, i.e. images for each + coil are returned. If a callable is provided, coil images are reconstructed using the adjoint of the + FourierOperator (including density compensation) and then sensitivity maps are calculated using the + callable. For this, kdata needs also to be provided. For examples have a look at the CsmData class + e.g. from_idata_walsh or from_idata_inati. noise - Used for prewhitening + KNoise used for prewhitening. If None, no prewhitening is performed dcf - Density compensation. If None, no dcf will be performed. - Also set to None, if the FourierOperator is already density compensated. + K-space sampling density compensation. If None, set up based on kdata. + + Raises + ------ + ValueError + If the kdata and fourier_op are None or if csm is a Callable but kdata is None. """ super().__init__() - self.fourier_op = fourier_op - # TODO: Make this buffers once DataBufferMixin is merged - self.dcf = dcf - self.csm = csm - self.noise = noise + if fourier_op is None: + if kdata is None: + raise ValueError('Either kdata or fourier_op needs to be defined.') + self.fourier_op = FourierOp.from_kdata(kdata) + else: + self.fourier_op = fourier_op - @classmethod - def from_kdata(cls, kdata: KData, noise: KNoise | None = None, *, coil_combine: bool = True) -> Self: - """Create a DirectReconstruction from kdata with default settings. + if kdata is not None and dcf is None: + self.dcf = DcfData.from_traj_voronoi(kdata.traj) + else: + self.dcf = dcf - Parameters - ---------- - kdata - KData to use for trajektory and header information. - noise - KNoise used for prewhitening. If None, no prewhitening is performed. - coil_combine - if True (default), uses kdata to estimate sensitivity maps and perform adaptive coil combine reconstruction - in the reconstruction. - """ - if noise is not None: - kdata = prewhiten_kspace(kdata, noise) - dcf = DcfData.from_traj_voronoi(kdata.traj) - fourier_op = FourierOp.from_kdata(kdata) - self = cls(fourier_op, None, noise, dcf) - if coil_combine: - # kdata is prewhitened - self.recalculate_csm_walsh(kdata, noise=False) - return self + self.noise = noise + + if csm is None or isinstance(csm, CsmData): + self.csm = csm + else: + if kdata is None: + raise ValueError('kdata needs to be defined to calculate the sensitivity maps.') + self.recalculate_csm(kdata, csm) def forward(self, kdata: KData) -> IData: """Apply the reconstruction. diff --git a/src/mrpro/algorithms/reconstruction/IterativeSENSEReconstruction.py b/src/mrpro/algorithms/reconstruction/IterativeSENSEReconstruction.py index bffa2234..04d37128 100644 --- a/src/mrpro/algorithms/reconstruction/IterativeSENSEReconstruction.py +++ b/src/mrpro/algorithms/reconstruction/IterativeSENSEReconstruction.py @@ -2,22 +2,22 @@ from __future__ import annotations -from typing import Self +from collections.abc import Callable + +import torch from mrpro.algorithms.optimizers.cg import cg from mrpro.algorithms.prewhiten_kspace import prewhiten_kspace from mrpro.algorithms.reconstruction.DirectReconstruction import DirectReconstruction -from mrpro.algorithms.reconstruction.Reconstruction import Reconstruction from mrpro.data._kdata.KData import KData from mrpro.data.CsmData import CsmData from mrpro.data.DcfData import DcfData from mrpro.data.IData import IData from mrpro.data.KNoise import KNoise -from mrpro.operators.FourierOp import FourierOp from mrpro.operators.LinearOperator import LinearOperator -class IterativeSENSEReconstruction(Reconstruction): +class IterativeSENSEReconstruction(DirectReconstruction): r"""Iterative SENSE reconstruction. This algorithm solves the problem :math:`min_x \frac{1}{2}||W^\frac{1}{2} (Ax - y)||_2^2` @@ -39,57 +39,89 @@ class IterativeSENSEReconstruction(Reconstruction): def __init__( self, - fourier_op: LinearOperator, - n_iterations: int, - csm: CsmData | None = None, + kdata: KData | None = None, + fourier_op: LinearOperator | None = None, + csm: Callable | CsmData | None = CsmData.from_idata_walsh, noise: KNoise | None = None, dcf: DcfData | None = None, + *, + n_iterations: int = 5, ) -> None: """Initialize IterativeSENSEReconstruction. Parameters ---------- + kdata + KData. If kdata is provided and fourier_op or dcf are None, then fourier_op and dcf are estimated based on + kdata. Otherwise fourier_op and dcf are used as provided. fourier_op - Instance of the FourierOperator used for reconstruction - n_iterations - Number of CG iterations + Instance of the FourierOperator used for reconstruction. If None, set up based on kdata. csm - Sensitivity maps for coil combination + Sensitivity maps for coil combination. If None, no coil combination is carried out, i.e. images for each + coil are returned. If a callable is provided, coil images are reconstructed using the adjoint of the + FourierOperator (including density compensation) and then sensitivity maps are calculated using the + callable. For this, kdata needs also to be provided. For examples have a look at the CsmData class + e.g. from_idata_walsh or from_idata_inati. noise - Used for prewhitening + KNoise used for prewhitening. If None, no prewhitening is performed dcf - Density compensation. If None, no dcf will be performed. - Also set to None, if the FourierOperator is already density compensated. + K-space sampling density compensation. If None, set up based on kdata. + n_iterations + Number of CG iterations + + Raises + ------ + ValueError + If the kdata and fourier_op are None or if csm is a Callable but kdata is None. """ - super().__init__() - self.fourier_op = fourier_op + super().__init__(kdata, fourier_op, csm, noise, dcf) self.n_iterations = n_iterations - # TODO: Make this buffers once DataBufferMixin is merged - self.csm = csm - self.noise = noise - self.dcf = dcf - @classmethod - def from_kdata(cls, kdata: KData, noise: KNoise | None = None, *, n_iterations: int = 10) -> Self: - """Create a IterativeSENSEReconstruction from kdata with default settings. + def _self_adjoint_operator(self) -> LinearOperator: + """Create the self-adjoint operator. + + Create the acquisition model as :math:`A = F S` if the CSM :math:`S` is defined otherwise :math:`A = F` with + the Fourier operator :math:`F`. + + Create the self-adjoint operator as :math:`H = A^H W A` if the DCF is not None otherwise as :math:`H = A^H A`. + """ + operator = self.fourier_op @ self.csm.as_operator() if self.csm is not None else self.fourier_op + + if self.dcf is not None: + dcf_operator = self.dcf.as_operator() + # Create H = A^H W A + operator = operator.H @ dcf_operator @ operator + else: + # Create H = A^H A + operator = operator.H @ operator + + return operator + + def _right_hand_side(self, kdata: KData) -> torch.Tensor: + """Calculate the right-hand-side of the normal equation. + + Create the acquisition model as :math:`A = F S` if the CSM :math:`S` is defined otherwise :math:`A = F` with + the Fourier operator :math:`F`. + + Calculate the right-hand-side as :math:`b = A^H W y` if the DCF is not None otherwise as :math:`b = A^H y`. Parameters ---------- kdata - KData to use for trajectory and header information - noise - KNoise used for prewhitening. If None, no prewhitening is performed - n_iterations - Number of CG iterations + k-space data to reconstruct. """ - if noise is not None: - kdata = prewhiten_kspace(kdata, noise) - dcf = DcfData.from_traj_voronoi(kdata.traj) - fourier_op = FourierOp.from_kdata(kdata) - recon = DirectReconstruction(fourier_op, dcf=dcf, noise=noise) - image = recon.direct_reconstruction(kdata) - csm = CsmData.from_idata_walsh(image) - return cls(fourier_op, n_iterations, csm, noise, dcf) + device = kdata.data.device + operator = self.fourier_op @ self.csm.as_operator() if self.csm is not None else self.fourier_op + + if self.dcf is not None: + dcf_operator = self.dcf.as_operator() + # Calculate b = A^H W y + (right_hand_side,) = operator.to(device).H(dcf_operator(kdata.data)[0]) + else: + # Calculate b = A^H y + (right_hand_side,) = operator.to(device).H(kdata.data) + + return right_hand_side def forward(self, kdata: KData) -> IData: """Apply the reconstruction. @@ -107,19 +139,8 @@ def forward(self, kdata: KData) -> IData: if self.noise is not None: kdata = prewhiten_kspace(kdata, self.noise.to(device)) - operator = self.fourier_op @ self.csm.as_operator() if self.csm is not None else self.fourier_op - - if self.dcf is not None: - dcf_operator = self.dcf.as_operator() - # Calculate b = A^H W y - (right_hand_side,) = operator.to(device).H(dcf_operator(kdata.data)[0]) - # Create H = A^H W A - operator = operator.H @ dcf_operator @ operator - else: - # Calculate b = A^H y - (right_hand_side,) = operator.to(device).H(kdata.data) - # Create H = A^H A - operator = operator.H @ operator + operator = self._self_adjoint_operator().to(device) + right_hand_side = self._right_hand_side(kdata) img_tensor = cg( operator, right_hand_side, initial_value=right_hand_side, max_iterations=self.n_iterations, tolerance=0.0 diff --git a/src/mrpro/algorithms/reconstruction/Reconstruction.py b/src/mrpro/algorithms/reconstruction/Reconstruction.py index 172c3f7f..f26e9273 100644 --- a/src/mrpro/algorithms/reconstruction/Reconstruction.py +++ b/src/mrpro/algorithms/reconstruction/Reconstruction.py @@ -1,6 +1,7 @@ """Reconstruction module.""" from abc import ABC, abstractmethod +from collections.abc import Callable from typing import Literal, Self import torch @@ -53,14 +54,22 @@ def recalculate_fourierop(self, kdata: KData) -> Self: self.dcf = DcfData.from_traj_voronoi(kdata.traj) return self - def recalculate_csm_walsh(self, kdata: KData, noise: KNoise | None | Literal[False] = None) -> Self: - """Update (in place) the CSM from KData using Walsh. + def recalculate_csm( + self, + kdata: KData, + csm_calculation: Callable[[IData], CsmData] = CsmData.from_idata_walsh, + noise: KNoise | None | Literal[False] = None, + ) -> Self: + """Update (in place) the CSM from KData. Parameters ---------- kdata KData used for adjoint reconstruction (including DCF-weighting if available), which is then used for - Walsh CSM estimation. + CSM estimation. + csm_calculation + Function to calculate csm expecting idata as input and returning csmdata. For examples have a look at the + CsmData class e.g. from_idata_walsh or from_idata_inati. noise Noise measurement for prewhitening. If None, self.noise (if previously set) is used. @@ -71,9 +80,9 @@ def recalculate_csm_walsh(self, kdata: KData, noise: KNoise | None | Literal[Fal noise = None elif noise is None: noise = self.noise - recon = type(self)(self.fourier_op, dcf=self.dcf, noise=noise) + recon = type(self)(fourier_op=self.fourier_op, dcf=self.dcf, noise=noise, csm=None) image = recon.direct_reconstruction(kdata) - self.csm = CsmData.from_idata_walsh(image) + self.csm = csm_calculation(image) return self def direct_reconstruction(self, kdata: KData) -> IData: From 8f75ea94ecc12c0b4e9df4adca8c06779fef8276 Mon Sep 17 00:00:00 2001 From: Stefan Martin <67423672+Stef-Martin@users.noreply.github.com> Date: Thu, 5 Sep 2024 10:07:02 +0200 Subject: [PATCH 29/34] Add __repr__ method to dataclasses and header (#364) Add __repr__ method to dataclasses and header. Add helper function to summarize tensor values in str. Co-authored-by: Patrick Schuenke <37338697+schuenke@users.noreply.github.com> --- src/mrpro/data/DcfData.py | 9 +++++++ src/mrpro/data/IData.py | 12 ++++++++++ src/mrpro/data/IHeader.py | 9 +++++++ src/mrpro/data/KHeader.py | 16 +++++++++++++ src/mrpro/data/KNoise.py | 9 +++++++ src/mrpro/data/KTrajectory.py | 9 +++++++ src/mrpro/data/QData.py | 12 ++++++++++ src/mrpro/data/SpatialDimension.py | 4 ++++ src/mrpro/data/_kdata/KData.py | 15 ++++++++++++ src/mrpro/utils/summarize_tensorvalues.py | 29 +++++++++++++++++++++++ 10 files changed, 124 insertions(+) create mode 100644 src/mrpro/utils/summarize_tensorvalues.py diff --git a/src/mrpro/data/DcfData.py b/src/mrpro/data/DcfData.py index 32e70d59..baeb64db 100644 --- a/src/mrpro/data/DcfData.py +++ b/src/mrpro/data/DcfData.py @@ -71,3 +71,12 @@ def as_operator(self) -> DensityCompensationOp: from mrpro.operators.DensityCompensationOp import DensityCompensationOp return DensityCompensationOp(self.data.clone()) + + def __repr__(self): + """Representation method for DcfData class.""" + try: + device = str(self.device) + except RuntimeError: + device = 'mixed' + name = type(self).__name__ + return f'{name} with shape: {list(self.data.shape)!s} and dtype {self.data.dtype}\nDevice: {device}.' diff --git a/src/mrpro/data/IData.py b/src/mrpro/data/IData.py index d3b8f779..58ff2913 100644 --- a/src/mrpro/data/IData.py +++ b/src/mrpro/data/IData.py @@ -153,3 +153,15 @@ def from_dicom_folder(cls, foldername: str | Path, suffix: str | None = 'dcm') - raise ValueError(f'No dicom files with suffix {suffix} found in {foldername}') return cls.from_dicom_files(filenames=file_paths) + + def __repr__(self): + """Representation method for IData class.""" + try: + device = str(self.device) + except RuntimeError: + device = 'mixed' + out = ( + f'{type(self).__name__} with shape: {list(self.data.shape)!s} and dtype {self.data.dtype}\n' + f'Device: {device}\n{self.header}' + ) + return out diff --git a/src/mrpro/data/IHeader.py b/src/mrpro/data/IHeader.py index 3a1e0699..15c48c21 100644 --- a/src/mrpro/data/IHeader.py +++ b/src/mrpro/data/IHeader.py @@ -13,6 +13,7 @@ from mrpro.data.KHeader import KHeader from mrpro.data.MoveDataMixin import MoveDataMixin from mrpro.data.SpatialDimension import SpatialDimension +from mrpro.utils.summarize_tensorvalues import summarize_tensorvalues MISC_TAGS = {'TimeAfterStart': 0x00191016} @@ -122,3 +123,11 @@ def deg_to_rad(deg: torch.Tensor | None) -> torch.Tensor | None: for name in MISC_TAGS: misc[name] = make_unique_tensor(get_float_items_from_all_dicoms(MISC_TAGS[name])) return cls(fov=fov, te=te, ti=ti, fa=fa, tr=tr, misc=misc) + + def __repr__(self): + """Representation method for IHeader class.""" + te = summarize_tensorvalues(self.te) + ti = summarize_tensorvalues(self.ti) + fa = summarize_tensorvalues(self.fa) + out = f'FOV [m]: {self.fov!s}\n' f'TE [s]: {te}\nTI [s]: {ti}\nFlip angle [rad]: {fa}.' + return out diff --git a/src/mrpro/data/KHeader.py b/src/mrpro/data/KHeader.py index 2117c885..0cf5fcc8 100644 --- a/src/mrpro/data/KHeader.py +++ b/src/mrpro/data/KHeader.py @@ -17,6 +17,7 @@ from mrpro.data.MoveDataMixin import MoveDataMixin from mrpro.data.SpatialDimension import SpatialDimension from mrpro.data.TrajectoryDescription import TrajectoryDescription +from mrpro.utils.summarize_tensorvalues import summarize_tensorvalues if TYPE_CHECKING: # avoid circular imports by importing only when type checking @@ -268,3 +269,18 @@ def from_ismrmrd( 'Consider setting them via the defaults dictionary', ) from None return instance + + def __repr__(self): + """Representation method for KHeader class.""" + te = summarize_tensorvalues(self.te) + ti = summarize_tensorvalues(self.ti) + fa = summarize_tensorvalues(self.fa) + out = ( + f'FOV [m]: {self.encoding_fov!s}\n' + f'TE [s]: {te}\n' + f'TI [s]: {ti}\n' + f'Flip angle [rad]: {fa}\n' + f'Encoding matrix: {self.encoding_matrix!s} \n' + f'Recon matrix: {self.recon_matrix!s} \n' + ) + return out diff --git a/src/mrpro/data/KNoise.py b/src/mrpro/data/KNoise.py index 58216497..4e7e8fb7 100644 --- a/src/mrpro/data/KNoise.py +++ b/src/mrpro/data/KNoise.py @@ -50,3 +50,12 @@ def from_file( noise_data = repeat(noise_data, '... coils k0->... coils k2 k1 k0', k1=1, k2=1) return cls(noise_data) + + def __repr__(self): + """Representation method for KNoise class.""" + try: + device = str(self.device) + except RuntimeError: + device = 'mixed' + name = type(self).__name__ + return f'{name} with shape: {list(self.data.shape)!s} and dtype {self.data.dtype}\nDevice: {device}.' diff --git a/src/mrpro/data/KTrajectory.py b/src/mrpro/data/KTrajectory.py index 80b3dbc7..f458d0d3 100644 --- a/src/mrpro/data/KTrajectory.py +++ b/src/mrpro/data/KTrajectory.py @@ -9,6 +9,7 @@ from mrpro.data.enums import TrajType from mrpro.data.MoveDataMixin import MoveDataMixin from mrpro.utils import remove_repeat +from mrpro.utils.summarize_tensorvalues import summarize_tensorvalues @dataclass(slots=True, frozen=True) @@ -171,3 +172,11 @@ def as_tensor(self, stack_dim: int = 0) -> torch.Tensor: """ shape = self.broadcasted_shape return torch.stack([traj.expand(*shape) for traj in (self.kz, self.ky, self.kx)], dim=stack_dim) + + def __repr__(self): + """Representation method for KTrajectory class.""" + z = summarize_tensorvalues(torch.tensor(self.kz.shape)) + y = summarize_tensorvalues(torch.tensor(self.ky.shape)) + x = summarize_tensorvalues(torch.tensor(self.kx.shape)) + out = f'{type(self).__name__} with shape: kz={z}, ky={y}, kx={x}' + return out diff --git a/src/mrpro/data/QData.py b/src/mrpro/data/QData.py index 43aa07cd..c55a55fa 100644 --- a/src/mrpro/data/QData.py +++ b/src/mrpro/data/QData.py @@ -59,3 +59,15 @@ def from_single_dicom(cls, filename: str | Path) -> Self: qdata = repeat(qdata, 'y x -> other coils z y x', other=1, coils=1, z=1) header = QHeader.from_dicom(dataset) return cls(data=qdata, header=header) + + def __repr__(self): + """Representation method for QData class.""" + try: + device = str(self.device) + except RuntimeError: + device = 'mixed' + out = ( + f'{type(self).__name__} with shape: {list(self.data.shape)!s} and dtype {self.data.dtype}\n' + f'Device: {device}\nFOV [m]: {self.header.fov!s}.' + ) + return out diff --git a/src/mrpro/data/SpatialDimension.py b/src/mrpro/data/SpatialDimension.py index 96dc4dd7..aaea2d5e 100644 --- a/src/mrpro/data/SpatialDimension.py +++ b/src/mrpro/data/SpatialDimension.py @@ -97,3 +97,7 @@ def from_array_zyx( def zyx(self) -> tuple[T, T, T]: """Return a z,y,x tuple.""" return (self.z, self.y, self.x) + + def __str__(self) -> str: + """Return a string representation of the SpatialDimension.""" + return f'z={self.z}, y={self.y}, x={self.x}' diff --git a/src/mrpro/data/_kdata/KData.py b/src/mrpro/data/_kdata/KData.py index fa57ed9d..81b4f900 100644 --- a/src/mrpro/data/_kdata/KData.py +++ b/src/mrpro/data/_kdata/KData.py @@ -234,3 +234,18 @@ def sort_and_reshape_tensor_fields(input_tensor: torch.Tensor): ) from None return cls(kheader, kdata, ktrajectory_final) + + def __repr__(self): + """Representation method for KData class.""" + traj = KTrajectory(self.traj.kz, self.traj.ky, self.traj.kx) + try: + device = str(self.device) + except RuntimeError: + device = 'mixed' + out = ( + f'{type(self).__name__} with shape {list(self.data.shape)!s} and dtype {self.data.dtype}\n' + f'Device: {device}\n' + f'{traj}\n' + f'{self.header}' + ) + return out diff --git a/src/mrpro/utils/summarize_tensorvalues.py b/src/mrpro/utils/summarize_tensorvalues.py new file mode 100644 index 00000000..46911587 --- /dev/null +++ b/src/mrpro/utils/summarize_tensorvalues.py @@ -0,0 +1,29 @@ +"""Summarize the values of a tensor to a string.""" + +import torch + + +def summarize_tensorvalues(tensor: torch.Tensor | None, summarization_threshold: int = 7) -> str: + """Summarize the values of a tensor to a string. + + Returns a string representation of the tensor values. If the tensor is None, the string 'None' is returned. + + Parameters + ---------- + tensor + The tensor to summarize. + summarization_threshold + The threshold of total array elements triggering summarization. + edgeitems + Number of elements at the beginning and end of each dimension to show. + """ + if tensor is None: + return 'None' + if summarization_threshold < 4: + edgeitems = 1 + elif summarization_threshold < 7: + edgeitems = 2 + else: + edgeitems = 3 + with torch._tensor_str.printoptions(threshold=summarization_threshold, edgeitems=edgeitems): + return torch._tensor_str._tensor_str(tensor, 0) From ca2f07b7030fe8ce3674f23207f974efcbc09fd3 Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Thu, 5 Sep 2024 19:16:13 +0200 Subject: [PATCH 30/34] Notebook for Golden Radial T1 mapping (#377) Co-authored-by: Patrick Schuenke --- examples/t1_mapping_with_grad_acq.ipynb | 376 ++++++++++++++++++++++-- examples/t1_mapping_with_grad_acq.py | 221 ++++++++++++-- 2 files changed, 547 insertions(+), 50 deletions(-) diff --git a/examples/t1_mapping_with_grad_acq.ipynb b/examples/t1_mapping_with_grad_acq.ipynb index af952a0f..743f7ad8 100644 --- a/examples/t1_mapping_with_grad_acq.ipynb +++ b/examples/t1_mapping_with_grad_acq.ipynb @@ -5,7 +5,7 @@ "id": "83bfb574", "metadata": {}, "source": [ - "# T1 mapping from a continuous golden radial acquisition" + "# T1 mapping from a continuous Golden radial acquisition" ] }, { @@ -23,16 +23,61 @@ "import matplotlib.pyplot as plt\n", "import torch\n", "import zenodo_get\n", - "from mrpro.data import CsmData, DcfData, IData, KData\n", + "from mpl_toolkits.axes_grid1 import make_axes_locatable # type: ignore [import-untyped]\n", + "from mrpro.algorithms.optimizers import adam\n", + "from mrpro.algorithms.reconstruction import DirectReconstruction\n", + "from mrpro.data import KData\n", "from mrpro.data.traj_calculators import KTrajectoryIsmrmrd\n", - "from mrpro.operators import FourierOp, SensitivityOp" + "from mrpro.operators import ConstraintsOp, MagnitudeOp\n", + "from mrpro.operators.functionals import MSEDataDiscrepancy\n", + "from mrpro.operators.models import TransientSteadyStateWithPreparation\n", + "from mrpro.utils import split_idx" + ] + }, + { + "cell_type": "markdown", + "id": "7f7c1229", + "metadata": {}, + "source": [ + "### Overview\n", + "In this acquisition, a single inversion pulse is played out, followed by a continuous data acquisition with a\n", + "a constant flip angle $\\alpha$. Data acquisition is carried out with a 2D Golden angle radial trajectory. The acquired\n", + "data can be divided into different dynamic time frames, each corresponding to a different inversion time. A signal\n", + "model can then be fitted to this data to obtain a $T_1$ map. More information can be found in:\n", + "\n", + "Kerkering KM, Schulz-Menger J, Schaeffter T, Kolbitsch C (2023) Motion-corrected model-based reconstruction for 2D\n", + "myocardial T1 mapping, MRM 90 https://doi.org/10.1002/mrm.29699\n", + "\n", + "The number of time frames and hence the number of radial lines per time frame, can in principle be chosen arbitrarily.\n", + "However, a tradeoff between image quality (more radial lines per dynamic) and\n", + "temporal resolution to accurately capture the signal behavior (fewer radial lines) needs to be found.\n", + "\n", + "During data acquisition, the magnetization $M_z(t)$ can be described by the signal model:\n", + " $$ M_z(t) = M_0^* + (M_0^{init} - M_0^*)e^{(-t / T_1^*)} \\quad (1) $$\n", + "where the effective longitudinal relaxation time is given by:\n", + " $$ T_1^* = \\frac{1}{\\frac{1}{T1} - \\frac{1}{T_R} ln(cos(\\alpha))} $$\n", + "and the steady-state magnetization is\n", + " $$ M_0^* = M_0 \\frac{T_1^*}{T_1} .$$\n", + "\n", + "The initial magnetization $M_0^{init}$ after an inversion pulse is $-M_0$. Nevertheless, commonly after an inversion\n", + "pulse, a strong spoiler gradient is played out to remove any residual transversal magnetization due to\n", + "imperfections of the inversion pulse. During the spoiler gradient, the magnetization recovers with $T_1$. Commonly,\n", + "the duration of this spoiler gradient $\\Delta t$ is between 10 to 20 ms. This leads to the initial magnetization\n", + " $$ M_0^{init} = M_0(1 - 2e^{(-\\Delta t / T_1)}) .$$\n", + "\n", + "In this example, we are going to:\n", + "- Reconstruct a single high quality image using all acquired radial lines.\n", + "- Split the data into multiple dynamics and reconstruct these dynamic images\n", + "- Define a signal model and a loss function to obtain the $T_1$ maps" ] }, { "cell_type": "code", "execution_count": null, "id": "94484d00", - "metadata": {}, + "metadata": { + "lines_to_next_cell": 2 + }, "outputs": [], "source": [ "# Download raw data in ISMRMRD format from zenodo into a temporary directory\n", @@ -46,11 +91,8 @@ "id": "dbc75fbb", "metadata": {}, "source": [ - "## Image reconstruction\n", - "Image reconstruction involves the following steps:\n", - "- Reading in the raw data and the trajectory from the ismrmrd raw data file\n", - "- Calculating the density compensation function (dcf)\n", - "- Reconstructing one image averaging over the entire relaxation period" + "## Reconstruct average image\n", + "Reconstruct one image as the average over all radial lines" ] }, { @@ -63,14 +105,9 @@ "# Read raw data and trajectory\n", "kdata = KData.from_file(data_folder / '2D_GRad_map_t1.h5', KTrajectoryIsmrmrd())\n", "\n", - "# Calculate dcf\n", - "dcf = DcfData.from_traj_voronoi(kdata.traj)\n", - "\n", - "# Reconstruct average image for coil map estimation\n", - "fourier_op = FourierOp(\n", - " recon_matrix=kdata.header.recon_matrix, encoding_matrix=kdata.header.encoding_matrix, traj=kdata.traj\n", - ")\n", - "(img,) = fourier_op.adjoint(kdata.data * dcf.data[:, None, ...])" + "# Perform the reconstruction\n", + "reconstruction = DirectReconstruction(kdata)\n", + "img_average = reconstruction(kdata)" ] }, { @@ -80,10 +117,21 @@ "metadata": {}, "outputs": [], "source": [ - "# Calculate coilmaps\n", - "idata = IData.from_tensor_and_kheader(img, kdata.header)\n", - "csm = CsmData.from_idata_walsh(idata)\n", - "csm_op = SensitivityOp(csm)" + "# Visualize average image\n", + "plt.figure()\n", + "plt.imshow(img_average.rss()[0, 0, :, :], cmap='gray')\n", + "plt.title('Average image')" + ] + }, + { + "cell_type": "markdown", + "id": "a0930f07", + "metadata": {}, + "source": [ + "## Split the data into dynamics and reconstruct dynamic images\n", + "We split the k-space data into different dynamics with 30 radial lines, each and no data overlap between the different\n", + "dynamics. Then we again perform a simple direct reconstruction, where we use the same coil sensitivity map (which we\n", + "estimated above) for each dynamic." ] }, { @@ -93,20 +141,26 @@ "metadata": {}, "outputs": [], "source": [ - "# Coil combination\n", - "(img,) = csm_op.adjoint(img)" + "idx_dynamic = split_idx(torch.argsort(kdata.header.acq_info.acquisition_time_stamp[0, 0, :, 0]), 30, 0)\n", + "kdata_dynamic = kdata.split_k1_into_other(idx_dynamic, other_label='repetition')" ] }, { "cell_type": "code", "execution_count": null, "id": "417eff6c", - "metadata": {}, + "metadata": { + "lines_to_next_cell": 2 + }, "outputs": [], "source": [ - "# Visualize results\n", - "plt.figure()\n", - "plt.imshow(torch.abs(img[0, 0, 0, :, :]))" + "# Perform the reconstruction\n", + "# Here we use the same coil sensitivity map for all dynamics\n", + "reconstruction_dynamic = DirectReconstruction(kdata_dynamic, csm=reconstruction.csm)\n", + "img_dynamic = reconstruction_dynamic(kdata_dynamic)\n", + "# Get absolute value of complex image and normalize the images\n", + "img_rss_dynamic = img_dynamic.rss()\n", + "img_rss_dynamic /= img_rss_dynamic.max()" ] }, { @@ -115,6 +169,274 @@ "id": "82f87630", "metadata": {}, "outputs": [], + "source": [ + "# Visualize the first six dynamic images\n", + "fig, ax = plt.subplots(2, 3, squeeze=False)\n", + "for idx, cax in enumerate(ax.flatten()):\n", + " cax.imshow(img_rss_dynamic[idx, 0, :, :], cmap='gray', vmin=0, vmax=0.8)\n", + " cax.set_title(f'Dynamic {idx}')" + ] + }, + { + "cell_type": "markdown", + "id": "87260553", + "metadata": {}, + "source": [ + "## Estimate T1 map" + ] + }, + { + "cell_type": "markdown", + "id": "7153f06b", + "metadata": {}, + "source": [ + "### Signal model\n", + "We use a three parameter signal model $q(M_0, T_1, \\alpha)$.\n", + "\n", + "As known input, the model needs information about the time $t$ (`sampling_time`) in Eq. (1) since the inversion pulse.\n", + "This can be calculated from the `acquisition_time_stamp`. If we average the `acquisition_time_stamp`-values for each\n", + "dynamic image and subtract the first `acquisition_time_stamp`, we get the mean time since the inversion pulse for each\n", + "dynamic. Note: The time taken by the spoiler gradient is taken into consideration in the\n", + "`TransientSteadyStateWithPreparation`-model and does not have to be added here. Another important thing to note is\n", + "that the `acquisition_time_stamp` is not given in time units but in vendor-specific time stamp units. For the Siemens\n", + "data used here, one time stamp corresponds to 2.5 ms." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2235a00a", + "metadata": {}, + "outputs": [], + "source": [ + "sampling_time = torch.mean(kdata_dynamic.header.acq_info.acquisition_time_stamp[:, 0, :, 0].to(torch.float32), dim=-1)\n", + "# Subtract time stamp of first radial line\n", + "sampling_time -= kdata_dynamic.header.acq_info.acquisition_time_stamp[0, 0, 0, 0]\n", + "# Convert to seconds\n", + "sampling_time *= 2.5 / 1000" + ] + }, + { + "cell_type": "markdown", + "id": "1d9711d3", + "metadata": {}, + "source": [ + "We also need the repetition time between two RF-pulses. There is a parameter `tr` in the header, but this describes\n", + "the time \"between the beginning of a pulse sequence and the beginning of the succeeding (essentially identical) pulse\n", + "sequence\" (see https://dicom.innolitics.com/ciods/mr-image/mr-image/00180080). We have one inversion pulse at the\n", + "beginning, which is never repeated and hence `tr` is the duration of the entire scan. Therefore, we have to use the\n", + "parameter `echo_spacing`, which describes the time between two gradient echoes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d006738e", + "metadata": {}, + "outputs": [], + "source": [ + "if kdata_dynamic.header.echo_spacing is None:\n", + " raise ValueError('Echo spacing needs to be defined.')\n", + "else:\n", + " repetition_time = kdata_dynamic.header.echo_spacing[0]" + ] + }, + { + "cell_type": "markdown", + "id": "42a19af3", + "metadata": {}, + "source": [ + "Finally, we have to specify the duration of the spoiler gradient. Unfortunately, we cannot get this information from\n", + "the acquired data, but we have to know the value and set it by hand to 20 ms. Now we can define the signal model." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6bf4023a", + "metadata": {}, + "outputs": [], + "source": [ + "model_op = TransientSteadyStateWithPreparation(\n", + " sampling_time, repetition_time, m0_scaling_preparation=-1, delay_after_preparation=0.02\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "42026962", + "metadata": {}, + "source": [ + "The reconstructed image data is complex-valued. We could fit a complex $M_0$ to the data, but in this case it is more\n", + "robust to fit $|q(M_0, T_1, \\alpha)|$ to the magnitude of the image data. We therefore combine our model with a\n", + "`MagnitudeOp`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0ec8ecd1", + "metadata": {}, + "outputs": [], + "source": [ + "magnitude_model_op = MagnitudeOp() @ model_op" + ] + }, + { + "cell_type": "markdown", + "id": "ba8433ab", + "metadata": {}, + "source": [ + "### Constraints\n", + "$T_1$ and $\\alpha$ need to be positive. Based on the knowledge of the phantom, we can constrain $T_1$ between 50 ms\n", + "and 3 s. Further, we can constrain $\\alpha$. Although the effective flip angle can vary, it can only vary by a\n", + "certain percentage relative to the nominal flip angle. Here, we chose a maximum deviation from the nominal flip angle\n", + "of 50%." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "57bafca9", + "metadata": {}, + "outputs": [], + "source": [ + "if kdata_dynamic.header.fa is None:\n", + " raise ValueError('Nominal flip angle needs to be defined.')\n", + "else:\n", + " nominal_flip_angle = float(kdata_dynamic.header.fa[0])\n", + "\n", + "constraints_op = ConstraintsOp(bounds=((None, None), (0.05, 3.0), (nominal_flip_angle * 0.5, nominal_flip_angle * 1.5)))" + ] + }, + { + "cell_type": "markdown", + "id": "df2a7d2f", + "metadata": { + "lines_to_next_cell": 0 + }, + "source": [ + "### Loss function\n", + "As a loss function for the optimizer, we calculate the mean-squared error between the image data $x$ and our signal\n", + "model $q$." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3bf4779b", + "metadata": {}, + "outputs": [], + "source": [ + "mse_loss = MSEDataDiscrepancy(img_rss_dynamic)" + ] + }, + { + "cell_type": "markdown", + "id": "534f05d2", + "metadata": { + "lines_to_next_cell": 0 + }, + "source": [ + "Now we can simply combine the loss function, the signal model and the constraints to solve\n", + "\n", + "$$ \\min_{M_0, T_1, \\alpha} || |q(M_0, T_1, \\alpha)| - x||_2^2$$" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "579b0f42", + "metadata": {}, + "outputs": [], + "source": [ + "functional = mse_loss @ magnitude_model_op @ constraints_op" + ] + }, + { + "cell_type": "markdown", + "id": "71c02cca", + "metadata": {}, + "source": [ + "### Carry out fit" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b16dda3f", + "metadata": { + "lines_to_next_cell": 2 + }, + "outputs": [], + "source": [ + "# The shortest echo time is a good approximation for the equilibrium magnetization\n", + "m0_start = img_rss_dynamic[0, ...]\n", + "# 1 s a good starting value for T1\n", + "t1_start = torch.ones(m0_start.shape, dtype=torch.float32)\n", + "# and the nominal flip angle a good starting value for the actual flip angle\n", + "flip_angle_start = torch.ones(m0_start.shape, dtype=torch.float32) * kdata_dynamic.header.fa" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ebbc4ac7", + "metadata": {}, + "outputs": [], + "source": [ + "# Hyperparameters for optimizer\n", + "max_iter = 500\n", + "lr = 1e-2\n", + "\n", + "# Run optimization\n", + "params_result = adam(functional, [m0_start, t1_start, flip_angle_start], max_iter=max_iter, lr=lr)\n", + "params_result = constraints_op(*params_result)\n", + "m0, t1, flip_angle = (p.detach() for p in params_result)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3143204f", + "metadata": {}, + "outputs": [], + "source": [ + "# Visualize parametric maps\n", + "fig, axes = plt.subplots(1, 3, figsize=(10, 2), squeeze=False)\n", + "colorbar_ax = [make_axes_locatable(ax).append_axes('right', size='5%', pad=0.05) for ax in axes[0, :]]\n", + "im = axes[0, 0].imshow(m0[0, ...].abs(), cmap='gray')\n", + "axes[0, 0].set_title('M0')\n", + "fig.colorbar(im, cax=colorbar_ax[0])\n", + "im = axes[0, 1].imshow(t1[0, ...], vmin=0, vmax=2)\n", + "axes[0, 1].set_title('T1 (s)')\n", + "fig.colorbar(im, cax=colorbar_ax[1])\n", + "im = axes[0, 2].imshow(flip_angle[0, ...] / torch.pi * 180, vmin=0, vmax=8)\n", + "axes[0, 2].set_title('Flip angle (°)')\n", + "fig.colorbar(im, cax=colorbar_ax[2])" + ] + }, + { + "cell_type": "markdown", + "id": "8b8b4c32", + "metadata": { + "lines_to_next_cell": 2 + }, + "source": [ + "### Next steps\n", + "The quality of the final $T_1$ maps depends on the quality of the individual dynamic images. Using more advanced image\n", + "reconstruction methods, we can improve the image quality and hence the quality of the maps.\n", + "\n", + "Try to exchange `DirectReconstruction` above with `IterativeSENSEReconstruction` and compare the quality of the\n", + "$T_1$ maps for different number of iterations (`n_iterations`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1e83ba68", + "metadata": {}, + "outputs": [], "source": [ "# Clean-up by removing temporary directory\n", "shutil.rmtree(data_folder)" diff --git a/examples/t1_mapping_with_grad_acq.py b/examples/t1_mapping_with_grad_acq.py index 3723dea3..29c08b03 100644 --- a/examples/t1_mapping_with_grad_acq.py +++ b/examples/t1_mapping_with_grad_acq.py @@ -1,5 +1,5 @@ # %% [markdown] -# # T1 mapping from a continuous golden radial acquisition +# # T1 mapping from a continuous Golden radial acquisition # %% # Imports @@ -10,9 +10,47 @@ import matplotlib.pyplot as plt import torch import zenodo_get -from mrpro.data import CsmData, DcfData, IData, KData +from mpl_toolkits.axes_grid1 import make_axes_locatable # type: ignore [import-untyped] +from mrpro.algorithms.optimizers import adam +from mrpro.algorithms.reconstruction import DirectReconstruction +from mrpro.data import KData from mrpro.data.traj_calculators import KTrajectoryIsmrmrd -from mrpro.operators import FourierOp, SensitivityOp +from mrpro.operators import ConstraintsOp, MagnitudeOp +from mrpro.operators.functionals import MSEDataDiscrepancy +from mrpro.operators.models import TransientSteadyStateWithPreparation +from mrpro.utils import split_idx + +# %% [markdown] +# ### Overview +# In this acquisition, a single inversion pulse is played out, followed by a continuous data acquisition with a +# a constant flip angle $\alpha$. Data acquisition is carried out with a 2D Golden angle radial trajectory. The acquired +# data can be divided into different dynamic time frames, each corresponding to a different inversion time. A signal +# model can then be fitted to this data to obtain a $T_1$ map. More information can be found in: +# +# Kerkering KM, Schulz-Menger J, Schaeffter T, Kolbitsch C (2023) Motion-corrected model-based reconstruction for 2D +# myocardial T1 mapping, MRM 90 https://doi.org/10.1002/mrm.29699 +# +# The number of time frames and hence the number of radial lines per time frame, can in principle be chosen arbitrarily. +# However, a tradeoff between image quality (more radial lines per dynamic) and +# temporal resolution to accurately capture the signal behavior (fewer radial lines) needs to be found. +# +# During data acquisition, the magnetization $M_z(t)$ can be described by the signal model: +# $$ M_z(t) = M_0^* + (M_0^{init} - M_0^*)e^{(-t / T_1^*)} \quad (1) $$ +# where the effective longitudinal relaxation time is given by: +# $$ T_1^* = \frac{1}{\frac{1}{T1} - \frac{1}{T_R} ln(cos(\alpha))} $$ +# and the steady-state magnetization is +# $$ M_0^* = M_0 \frac{T_1^*}{T_1} .$$ +# +# The initial magnetization $M_0^{init}$ after an inversion pulse is $-M_0$. Nevertheless, commonly after an inversion +# pulse, a strong spoiler gradient is played out to remove any residual transversal magnetization due to +# imperfections of the inversion pulse. During the spoiler gradient, the magnetization recovers with $T_1$. Commonly, +# the duration of this spoiler gradient $\Delta t$ is between 10 to 20 ms. This leads to the initial magnetization +# $$ M_0^{init} = M_0(1 - 2e^{(-\Delta t / T_1)}) .$$ +# +# In this example, we are going to: +# - Reconstruct a single high quality image using all acquired radial lines. +# - Split the data into multiple dynamics and reconstruct these dynamic images +# - Define a signal model and a loss function to obtain the $T_1$ maps # %% # Download raw data in ISMRMRD format from zenodo into a temporary directory @@ -20,40 +58,177 @@ dataset = '10671597' zenodo_get.zenodo_get([dataset, '-r', 5, '-o', data_folder]) # r: retries + # %% [markdown] -# ## Image reconstruction -# Image reconstruction involves the following steps: -# - Reading in the raw data and the trajectory from the ismrmrd raw data file -# - Calculating the density compensation function (dcf) -# - Reconstructing one image averaging over the entire relaxation period +# ## Reconstruct average image +# Reconstruct one image as the average over all radial lines # %% # Read raw data and trajectory kdata = KData.from_file(data_folder / '2D_GRad_map_t1.h5', KTrajectoryIsmrmrd()) -# Calculate dcf -dcf = DcfData.from_traj_voronoi(kdata.traj) +# Perform the reconstruction +reconstruction = DirectReconstruction(kdata) +img_average = reconstruction(kdata) + +# %% +# Visualize average image +plt.figure() +plt.imshow(img_average.rss()[0, 0, :, :], cmap='gray') +plt.title('Average image') + +# %% [markdown] +# ## Split the data into dynamics and reconstruct dynamic images +# We split the k-space data into different dynamics with 30 radial lines, each and no data overlap between the different +# dynamics. Then we again perform a simple direct reconstruction, where we use the same coil sensitivity map (which we +# estimated above) for each dynamic. + +# %% +idx_dynamic = split_idx(torch.argsort(kdata.header.acq_info.acquisition_time_stamp[0, 0, :, 0]), 30, 0) +kdata_dynamic = kdata.split_k1_into_other(idx_dynamic, other_label='repetition') + +# %% +# Perform the reconstruction +# Here we use the same coil sensitivity map for all dynamics +reconstruction_dynamic = DirectReconstruction(kdata_dynamic, csm=reconstruction.csm) +img_dynamic = reconstruction_dynamic(kdata_dynamic) +# Get absolute value of complex image and normalize the images +img_rss_dynamic = img_dynamic.rss() +img_rss_dynamic /= img_rss_dynamic.max() + -# Reconstruct average image for coil map estimation -fourier_op = FourierOp( - recon_matrix=kdata.header.recon_matrix, encoding_matrix=kdata.header.encoding_matrix, traj=kdata.traj +# %% +# Visualize the first six dynamic images +fig, ax = plt.subplots(2, 3, squeeze=False) +for idx, cax in enumerate(ax.flatten()): + cax.imshow(img_rss_dynamic[idx, 0, :, :], cmap='gray', vmin=0, vmax=0.8) + cax.set_title(f'Dynamic {idx}') + +# %% [markdown] +# ## Estimate T1 map + +# %% [markdown] +# ### Signal model +# We use a three parameter signal model $q(M_0, T_1, \alpha)$. +# +# As known input, the model needs information about the time $t$ (`sampling_time`) in Eq. (1) since the inversion pulse. +# This can be calculated from the `acquisition_time_stamp`. If we average the `acquisition_time_stamp`-values for each +# dynamic image and subtract the first `acquisition_time_stamp`, we get the mean time since the inversion pulse for each +# dynamic. Note: The time taken by the spoiler gradient is taken into consideration in the +# `TransientSteadyStateWithPreparation`-model and does not have to be added here. Another important thing to note is +# that the `acquisition_time_stamp` is not given in time units but in vendor-specific time stamp units. For the Siemens +# data used here, one time stamp corresponds to 2.5 ms. + +# %% +sampling_time = torch.mean(kdata_dynamic.header.acq_info.acquisition_time_stamp[:, 0, :, 0].to(torch.float32), dim=-1) +# Subtract time stamp of first radial line +sampling_time -= kdata_dynamic.header.acq_info.acquisition_time_stamp[0, 0, 0, 0] +# Convert to seconds +sampling_time *= 2.5 / 1000 + +# %% [markdown] +# We also need the repetition time between two RF-pulses. There is a parameter `tr` in the header, but this describes +# the time "between the beginning of a pulse sequence and the beginning of the succeeding (essentially identical) pulse +# sequence" (see https://dicom.innolitics.com/ciods/mr-image/mr-image/00180080). We have one inversion pulse at the +# beginning, which is never repeated and hence `tr` is the duration of the entire scan. Therefore, we have to use the +# parameter `echo_spacing`, which describes the time between two gradient echoes. + +# %% +if kdata_dynamic.header.echo_spacing is None: + raise ValueError('Echo spacing needs to be defined.') +else: + repetition_time = kdata_dynamic.header.echo_spacing[0] + +# %% [markdown] +# Finally, we have to specify the duration of the spoiler gradient. Unfortunately, we cannot get this information from +# the acquired data, but we have to know the value and set it by hand to 20 ms. Now we can define the signal model. + +# %% +model_op = TransientSteadyStateWithPreparation( + sampling_time, repetition_time, m0_scaling_preparation=-1, delay_after_preparation=0.02 ) -(img,) = fourier_op.adjoint(kdata.data * dcf.data[:, None, ...]) + +# %% [markdown] +# The reconstructed image data is complex-valued. We could fit a complex $M_0$ to the data, but in this case it is more +# robust to fit $|q(M_0, T_1, \alpha)|$ to the magnitude of the image data. We therefore combine our model with a +# `MagnitudeOp`. # %% -# Calculate coilmaps -idata = IData.from_tensor_and_kheader(img, kdata.header) -csm = CsmData.from_idata_walsh(idata) -csm_op = SensitivityOp(csm) +magnitude_model_op = MagnitudeOp() @ model_op + +# %% [markdown] +# ### Constraints +# $T_1$ and $\alpha$ need to be positive. Based on the knowledge of the phantom, we can constrain $T_1$ between 50 ms +# and 3 s. Further, we can constrain $\alpha$. Although the effective flip angle can vary, it can only vary by a +# certain percentage relative to the nominal flip angle. Here, we chose a maximum deviation from the nominal flip angle +# of 50%. # %% -# Coil combination -(img,) = csm_op.adjoint(img) +if kdata_dynamic.header.fa is None: + raise ValueError('Nominal flip angle needs to be defined.') +else: + nominal_flip_angle = float(kdata_dynamic.header.fa[0]) + +constraints_op = ConstraintsOp(bounds=((None, None), (0.05, 3.0), (nominal_flip_angle * 0.5, nominal_flip_angle * 1.5))) +# %% [markdown] +# ### Loss function +# As a loss function for the optimizer, we calculate the mean-squared error between the image data $x$ and our signal +# model $q$. # %% -# Visualize results -plt.figure() -plt.imshow(torch.abs(img[0, 0, 0, :, :])) +mse_loss = MSEDataDiscrepancy(img_rss_dynamic) + +# %% [markdown] +# Now we can simply combine the loss function, the signal model and the constraints to solve +# +# $$ \min_{M_0, T_1, \alpha} || |q(M_0, T_1, \alpha)| - x||_2^2$$ +# %% +functional = mse_loss @ magnitude_model_op @ constraints_op + +# %% [markdown] +# ### Carry out fit + +# %% +# The shortest echo time is a good approximation for the equilibrium magnetization +m0_start = img_rss_dynamic[0, ...] +# 1 s a good starting value for T1 +t1_start = torch.ones(m0_start.shape, dtype=torch.float32) +# and the nominal flip angle a good starting value for the actual flip angle +flip_angle_start = torch.ones(m0_start.shape, dtype=torch.float32) * kdata_dynamic.header.fa + + +# %% +# Hyperparameters for optimizer +max_iter = 500 +lr = 1e-2 + +# Run optimization +params_result = adam(functional, [m0_start, t1_start, flip_angle_start], max_iter=max_iter, lr=lr) +params_result = constraints_op(*params_result) +m0, t1, flip_angle = (p.detach() for p in params_result) + +# %% +# Visualize parametric maps +fig, axes = plt.subplots(1, 3, figsize=(10, 2), squeeze=False) +colorbar_ax = [make_axes_locatable(ax).append_axes('right', size='5%', pad=0.05) for ax in axes[0, :]] +im = axes[0, 0].imshow(m0[0, ...].abs(), cmap='gray') +axes[0, 0].set_title('M0') +fig.colorbar(im, cax=colorbar_ax[0]) +im = axes[0, 1].imshow(t1[0, ...], vmin=0, vmax=2) +axes[0, 1].set_title('T1 (s)') +fig.colorbar(im, cax=colorbar_ax[1]) +im = axes[0, 2].imshow(flip_angle[0, ...] / torch.pi * 180, vmin=0, vmax=8) +axes[0, 2].set_title('Flip angle (°)') +fig.colorbar(im, cax=colorbar_ax[2]) + +# %% [markdown] +# ### Next steps +# The quality of the final $T_1$ maps depends on the quality of the individual dynamic images. Using more advanced image +# reconstruction methods, we can improve the image quality and hence the quality of the maps. +# +# Try to exchange `DirectReconstruction` above with `IterativeSENSEReconstruction` and compare the quality of the +# $T_1$ maps for different number of iterations (`n_iterations`). + # %% # Clean-up by removing temporary directory From b9ea06821e616aeb89e75b9263611402bdf4f565 Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Wed, 11 Sep 2024 14:38:19 +0200 Subject: [PATCH 31/34] Fix errors due to pydicom 3.0 (#391) --- pyproject.toml | 7 +++++-- src/mrpro/data/IHeader.py | 10 ++++------ tests/data/_Dicom2DTestImage.py | 3 +-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 15cd0eab..4336c891 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "torch>=2.3", "ismrmrd>=1.14.1", "einops", - "pydicom", + "pydicom>=2.3", "pypulseq>=1.4.2", "torchkbnufft>=1.4.0", "scipy>=1.12", @@ -63,7 +63,10 @@ notebook = [ # PyTest section [tool.pytest.ini_options] testpaths = ["tests"] -filterwarnings = ["error"] +filterwarnings = [ + "error", + "ignore:'write_like_original':DeprecationWarning:pydicom:" +] addopts = "-n auto" markers = ["cuda : Tests only to be run when cuda device is available"] diff --git a/src/mrpro/data/IHeader.py b/src/mrpro/data/IHeader.py index 15c48c21..f500863b 100644 --- a/src/mrpro/data/IHeader.py +++ b/src/mrpro/data/IHeader.py @@ -62,12 +62,10 @@ def from_dicom_list(cls, dicom_datasets: Sequence[Dataset]) -> Self: list of dataset objects containing the DICOM file. """ - def get_item(dataset: Dataset, name: str | TagType): + def get_item(dataset: Dataset, name: TagType): """Get item with a given name or Tag from a pydicom dataset.""" - tag = Tag(name) if isinstance(name, str) else name # find item via value name - # iterall is recursive, so it will find all items with the given name - found_item = [item.value for item in dataset.iterall() if item.tag == tag] + found_item = [item.value for item in dataset.iterall() if item.tag == Tag(name)] if len(found_item) == 0: return None @@ -76,11 +74,11 @@ def get_item(dataset: Dataset, name: str | TagType): else: raise ValueError(f'Item {name} found {len(found_item)} times.') - def get_items_from_all_dicoms(name: str | TagType): + def get_items_from_all_dicoms(name: TagType): """Get list of items for all dataset objects in the list.""" return [get_item(ds, name) for ds in dicom_datasets] - def get_float_items_from_all_dicoms(name: str | TagType): + def get_float_items_from_all_dicoms(name: TagType): """Convert items to float.""" items = get_items_from_all_dicoms(name) return [float(val) if val is not None else None for val in items] diff --git a/tests/data/_Dicom2DTestImage.py b/tests/data/_Dicom2DTestImage.py index d5692bb8..a6d07acb 100644 --- a/tests/data/_Dicom2DTestImage.py +++ b/tests/data/_Dicom2DTestImage.py @@ -4,7 +4,6 @@ import numpy as np import pydicom -import pydicom._storage_sopclass_uids import torch from mrpro.data import SpatialDimension from mrpro.phantoms import EllipsePhantom @@ -51,7 +50,7 @@ def __init__( # Metadata file_meta = pydicom.dataset.FileMetaDataset() - file_meta.MediaStorageSOPClassUID = pydicom._storage_sopclass_uids.MRImageStorage + file_meta.MediaStorageSOPClassUID = pydicom.uid.MRImageStorage file_meta.MediaStorageSOPInstanceUID = pydicom.uid.generate_uid() file_meta.TransferSyntaxUID = pydicom.uid.ExplicitVRLittleEndian From f64add5fca56a20bcd837a2e45c5085c86c2a4eb Mon Sep 17 00:00:00 2001 From: Patrick Schuenke <37338697+schuenke@users.noreply.github.com> Date: Wed, 11 Sep 2024 20:12:30 +0200 Subject: [PATCH 32/34] Add Wavelet Operator (#294) Co-authored-by: ckolbPTB Co-authored-by: Felix F Zimmermann --- pyproject.toml | 5 + src/mrpro/operators/WaveletOp.py | 374 +++++++++++++++++++++++++++++ src/mrpro/operators/__init__.py | 1 + tests/helper.py | 65 ++++- tests/operators/test_wavelet_op.py | 171 +++++++++++++ 5 files changed, 613 insertions(+), 3 deletions(-) create mode 100644 src/mrpro/operators/WaveletOp.py create mode 100644 tests/operators/test_wavelet_op.py diff --git a/pyproject.toml b/pyproject.toml index 4336c891..3ac2adcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "pypulseq>=1.4.2", "torchkbnufft>=1.4.0", "scipy>=1.12", + "ptwt>=0.1.8", ] [project.optional-dependencies] @@ -89,6 +90,8 @@ module = [ "torchkbnufft", "pypulseq", "zenodo_get", + "ptwt.*", + "pywt.*" ] ignore_missing_imports = true @@ -160,6 +163,8 @@ locale = "en-us" [tool.typos.default.extend-words] Reson = "Reson" # required for Proc. Intl. Soc. Mag. Reson. Med. iy = "iy" +daa = 'daa' # required for wavelet operator +gaus = 'gaus' # required for wavelet operator [tool.typos.files] extend-exclude = [ diff --git a/src/mrpro/operators/WaveletOp.py b/src/mrpro/operators/WaveletOp.py new file mode 100644 index 00000000..62567715 --- /dev/null +++ b/src/mrpro/operators/WaveletOp.py @@ -0,0 +1,374 @@ +"""Wavelet operator.""" + +from collections.abc import Sequence +from typing import Literal + +import numpy as np +import torch +from ptwt.conv_transform import wavedec, waverec +from ptwt.conv_transform_2 import wavedec2, waverec2 +from ptwt.conv_transform_3 import wavedec3, waverec3 +from pywt import Wavelet +from pywt._multilevel import _check_level + +from mrpro.operators.LinearOperator import LinearOperator + +# Switch off formatter to avoid having only one wavelet type per line +# fmt: off +WaveletType = Literal[ + 'haar', + 'db1', 'db2', 'db3', 'db4', 'db5', 'db6', 'db7', 'db8', 'db9', + 'db10', 'db11', 'db12', 'db13', 'db14', 'db15', 'db16', 'db17', 'db18', 'db19', + 'db20', 'db21', 'db22', 'db23', 'db24', 'db25', 'db26', 'db27', 'db28', 'db29', + 'db30', 'db31', 'db32', 'db33', 'db34', 'db35', 'db36', 'db37', 'db38', + 'sym2', 'sym3', 'sym4', 'sym5', 'sym6', 'sym7', 'sym8', 'sym9', + 'sym10', 'sym11', 'sym12', 'sym13', 'sym14', 'sym15', 'sym16', 'sym17', 'sym18', 'sym19', 'sym20', + 'coif1', 'coif2', 'coif3', 'coif4', 'coif5', 'coif6', 'coif7', 'coif8', 'coif9', + 'coif10', 'coif11', 'coif12', 'coif13', 'coif14', 'coif15', 'coif16', 'coif17', + 'bior1.1', 'bior1.3', 'bior1.5', 'bior2.2', 'bior2.4', 'bior2.6', 'bior2.8', + 'bior3.1', 'bior3.3', 'bior3.5', 'bior3.7', 'bior3.9', 'bior4.4', 'bior5.5', 'bior6.8', + 'rbio1.1', 'rbio1.3', 'rbio1.5', 'rbio2.2', 'rbio2.4', 'rbio2.6', 'rbio2.8', + 'rbio3.1', 'rbio3.3', 'rbio3.5', 'rbio3.7', 'rbio3.9', 'rbio4.4', 'rbio5.5', 'rbio6.8', + 'dmey', + 'gaus1', 'gaus2', 'gaus3', 'gaus4', 'gaus5', 'gaus6', 'gaus7', 'gaus8', + 'mexh', + 'morl', + 'cgau1', 'cgau2', 'cgau3', 'cgau4', 'cgau5', 'cgau6', 'cgau7', 'cgau8', + 'shan', + 'fbsp', + 'cmor' +] +class WaveletOp(LinearOperator): + """Wavelet operator class.""" + + def __init__( + self, + domain_shape: Sequence[int] | None = None, + dim: tuple[int] | tuple[int, int] | tuple[int, int, int] = (-2, -1), + wavelet_name: WaveletType = 'db4', + level: int | None = None, + ): + """Wavelet operator. + + For complex images the wavelet coefficients are calculated for real and imaginary part separately. + + For a 2D image, the coefficients are labelled [aa, (ad_n, da_n, dd_n), ..., (ad_1, da_1, dd_1)] where a refers + to the approximation coefficients and d to the detail coefficients. The index indicates the level. + + Parameters + ---------- + domain_shape + Shape of domain where wavelets are calculated. If set to None the shape is taken from the input of the + forward operator. The adjoint operator will raise an error. + dim + Dimensions (axes) where wavelets are calculated + wavelet_name + Name of wavelets + level + Highest wavelet level. If set to None, the highest possible level is calculated based on the domain shape. + + Raises + ------ + ValueError + If wavelets are calculated for more than three dimensions. + ValueError + If wavelet dimensions and domain shape do not match. + NotImplementedError + If any dimension of the domain shape is odd. Adjoint will lead to the wrong domain shape. + """ + super().__init__() + self._domain_shape = domain_shape + self._wavelet_name = wavelet_name + self._dim = dim + self._level = level + + # number of wavelet directions + if len(dim) == 1: + self.n_wavelet_directions = 1 + elif len(dim) == 2: + self.n_wavelet_directions = 3 + elif len(dim) == 3: + self.n_wavelet_directions = 7 + else: + raise ValueError('Only 1D, 2D and 3D wavelet transforms are supported.') + + if domain_shape is not None: + if len(dim) != len(domain_shape): + raise ValueError( + 'Number of dimensions along which the wavelet transform should be calculated needs to', + 'be the same as the domain shape', + ) + + if any(d % 2 for d in domain_shape): + raise NotImplementedError( + 'ptwt only supports wavelet transforms for tensors with even number of ' + 'entries for all considered dimensions.' + ) + + # size of wavelets + wavelet_length = torch.as_tensor((Wavelet(wavelet_name).dec_len,) * len(domain_shape)) + + # calculate shape of wavelet coefficients at each level + current_shape = torch.as_tensor(domain_shape) + + # if level is None, select the highest possible level. + # raise error/warnings if level is not possible or lead to boundary effects + verified_level = _check_level(domain_shape, wavelet_length, level) + + if verified_level == 0: # only a/aa/aaa component possible + self.coefficients_shape = [domain_shape] + else: + self.coefficients_shape = [] + for _ in range(verified_level): + # Add padding + current_shape = (current_shape / 2).ceil() + wavelet_length // 2 - 1 + self.coefficients_shape.extend( + [tuple(current_shape.to(dtype=torch.int64))] * self.n_wavelet_directions + ) + + self.coefficients_shape = self.coefficients_shape[::-1] + self.coefficients_shape.insert(0, self.coefficients_shape[0]) # shape of a/aa/aaa term + + def forward(self, x: torch.Tensor) -> tuple[torch.Tensor,]: + """Calculate wavelet coefficients from (image) data. + + Parameters + ---------- + x + (Image) data + + Returns + ------- + Wavelet coefficients stacked along one dimension + + Raises + ------ + ValueError + If the dimensions along which wavelets are to be calculated are not unique. + """ + # normalize axes to allow negative indexing in input + dim = tuple(d % x.ndim for d in self._dim) + if len(dim) != len(set(dim)): + raise ValueError(f'Axis must be unique. Normalized axis are {dim}') + + # move axes where wavelets are calculated to the end + x = torch.moveaxis(x, dim, list(range(-len(self._dim), 0))) + + # the ptwt functions work only for real data, thus we handle complex inputs as an additional channel + x_real = torch.view_as_real(x).moveaxis(-1, 0) if x.is_complex() else x + + if len(self._dim) == 1: + coeffs_1d = wavedec(x_real, self._wavelet_name, level=self._level, mode='zero', axis=-1) + coefficients_list = self._format_coeffs_1d(coeffs_1d) + elif len(self._dim) == 2: + coeffs_2d = wavedec2(x_real, self._wavelet_name, level=self._level, mode='zero', axes=(-2, -1)) + coefficients_list = self._format_coeffs_2d(coeffs_2d) + elif len(self._dim) == 3: + coeffs_3d = wavedec3(x_real, self._wavelet_name, level=self._level, mode='zero', axes=(-3, -2, -1)) + coefficients_list = self._format_coeffs_3d(coeffs_3d) + else: + raise ValueError(f'Wavelets are only available for 1D, 2D and 3D and not {self._dim}D') + + # stack multi-resolution wavelets along single dimension + coefficients_stack = self._coeff_to_stacked_tensor(coefficients_list) + if x.is_complex(): + coefficients_stack = torch.moveaxis( + coefficients_stack, -1, min(dim) + 1 + ) # +1 because first dim is real/imag + coefficients_stack = torch.view_as_complex(coefficients_stack.moveaxis(0, -1).contiguous()) + else: + coefficients_stack = torch.moveaxis(coefficients_stack, -1, min(dim)) + + # move stacked coefficients to first wavelet dimension + return (coefficients_stack,) + + def adjoint(self, coefficients_stack: torch.Tensor) -> tuple[torch.Tensor]: + """Transform wavelet coefficients to (image) data. + + Parameters + ---------- + coefficients_stack + Wavelet coefficients stacked along one dimension + + Returns + ------- + (Image) data + + Raises + ------ + ValueError + If the domain_shape is not defined. + ValueError + If the dimensions along which wavelets are to be calculated are not unique. + """ + if self._domain_shape is None: + raise ValueError('Adjoint requires to define the domain_shape in init()') + + # normalize axes to allow negative indexing in input + dim = tuple(d % (coefficients_stack.ndim + len(self._dim) - 1) for d in self._dim) + if len(dim) != len(set(dim)): + raise ValueError(f'Axis must be unique. Normalized axis are {dim}') + + coefficients_stack = torch.moveaxis(coefficients_stack, min(dim), -1) + + # the ptwt functions work only for real data, thus we handle complex inputs as an additional channel + coefficients_stack_real = ( + torch.view_as_real(coefficients_stack).moveaxis(-1, 0) + if coefficients_stack.is_complex() + else coefficients_stack + ) + + coefficients_list = self._stacked_tensor_to_coeff(coefficients_stack_real) + + if len(self._dim) == 1: + coeffs_1d = self._undo_format_coeffs_1d(coefficients_list) + data = waverec(coeffs_1d, self._wavelet_name, axis=-1) + elif len(self._dim) == 2: + coeffs_2d = self._undo_format_coeffs_2d(coefficients_list) + data = waverec2(coeffs_2d, self._wavelet_name, axes=(-2, -1)) + elif len(self._dim) == 3: + coeffs_3d = self._undo_format_coeffs_3d(coefficients_list) + data = waverec3(coeffs_3d, self._wavelet_name, axes=(-3, -2, -1)) + else: + raise ValueError(f'Wavelets are only available for 1D, 2D and 3D and not {self._dim}D') + + # undo moving of axes + if coefficients_stack.is_complex(): + data = torch.moveaxis( + data, list(range(-len(self._dim), 0)), [d + 1 for d in dim] + ) # +1 because first dim is real/imag + # if we deal with complex coefficients, we also return complex data + data = torch.view_as_complex(data.moveaxis(0, -1).contiguous()) + else: + data = torch.moveaxis(data, list(range(-len(self._dim), 0)), dim) + + return (data,) + + @staticmethod + def _format_coeffs_1d(coefficients: list[torch.Tensor]) -> list[torch.Tensor]: + """Format 1D wavelet coefficients to MRpro format. + + At the moment, this function just returns the input coefficients as is: + [a, d_n, ..., d_1] + """ + return coefficients + + @staticmethod + def _undo_format_coeffs_1d(coefficients: list[torch.Tensor]) -> list[torch.Tensor]: + """Undo format 1D wavelet coefficients to MRpro format. + + At the moment, this function just returns the input coefficients as is: + [a, d_n, ..., d_1] + """ + return coefficients + + @staticmethod + def _format_coeffs_2d( + coefficients: list[torch.Tensor | tuple[torch.Tensor, torch.Tensor, torch.Tensor]], + ) -> list[torch.Tensor]: + """Format 2D wavelet coefficients to MRpro format. + + Converts from [aa, (ad_n, da_n, dd_n), ..., (ad_1, da_1, dd_1)] + to [aa, ad_n, da_n, dd_n, ..., ad_1, da_1, dd_1] + """ + coeffs_mrpro_format: list = [coefficients[0]] + for c_tuple in coefficients[1:]: + coeffs_mrpro_format.extend(c_tuple) + return coeffs_mrpro_format + + def _undo_format_coeffs_2d( + self, + coefficients: list[torch.Tensor], + ) -> list[torch.Tensor | tuple[torch.Tensor, torch.Tensor, torch.Tensor]]: + """Undo format 2D wavelet coefficients to MRpro format. + + Converts from [aa, ad_n, da_n, dd_n, ..., ad_1, da_1, dd_1] + to [aa, (ad_n, da_n, dd_n), ..., (ad_1, da_1, dd_1)] + """ + coeffs_ptwt_format: list = [coefficients[0]] + for i in range(1, len(coefficients), self.n_wavelet_directions): + coeffs_ptwt_format.append(tuple(coefficients[i : i + self.n_wavelet_directions])) + return coeffs_ptwt_format + + @staticmethod + def _format_coeffs_3d(coefficients: list[torch.Tensor | dict[str, torch.Tensor]]) -> list[torch.Tensor]: + """Format 3D wavelet coefficients to MRpro format. + + Converts from [aaa, {aad_n, ada_n, add_n, ...}, ..., {aad_1, ada_1, add_1, ...}] + to [aaa, aad_n, ada_n, add_n, ..., ..., aad_1, ada_1, add_1, ...] + """ + coeffs_mrpro_format: list = [coefficients[0]] + for c_dict in coefficients[1:]: + coeffs_mrpro_format.extend(c_dict.values()) + return coeffs_mrpro_format + + def _undo_format_coeffs_3d( + self, + coefficients: list[torch.Tensor], + ) -> list[torch.Tensor | dict[str, torch.Tensor]]: + """Undo format 3D wavelet coefficients to MRpro format. + + Converts from [aaa, aad_n, ada_n, add_n, ..., ..., aad_1, ada_1, add_1, ...] + to [aaa, {aad_n, ada_n, add_n, ...}, ..., {aad_1, ada_1, add_1, ...}] + """ + coeffs_ptwt_format: list = [coefficients[0]] + for i in range(1, len(coefficients), self.n_wavelet_directions): + coeffs_ptwt_format.append( + dict( + zip( + ['aad', 'ada', 'add', 'daa', 'dad', 'dda', 'ddd'], + coefficients[i : i + self.n_wavelet_directions], + strict=True, + ) + ) + ) + return coeffs_ptwt_format + + def _coeff_to_stacked_tensor(self, coefficients: list[torch.Tensor]) -> torch.Tensor: + """Stack wavelet coefficients into 1D tensor. + + During the calculation of the of the wavelet coefficient ptwt uses padding. To ensure the wavelet operator is + an isometry, cropping is needed. + + Parameters + ---------- + coefficients + wavelet coefficients in the format + 1D: [a, d_n, ..., d_1] + 2D: [aa, ad_n, da_n, dd_n, ..., ad_1, da_1, dd_1] + 3D: [aaa, aad_n, ada_n, add_n, ..., ..., aad_1, ada_1, add_1, ...] + + Returns + ------- + stacked coefficients in tensor [...,coeff] + """ + return torch.cat( + [coeff.flatten(start_dim=-len(self._dim)) for coeff in coefficients] + if len(self._dim) > 1 + else coefficients, + dim=-1, + ) + + def _stacked_tensor_to_coeff(self, coefficients_stack: torch.Tensor) -> list[torch.Tensor]: + """Unstack wavelet coefficients. + + Parameters + ---------- + coefficients_stack + stacked coefficients in tensor [...,coeff] + + + Returns + ------- + wavelet coefficients in the format + 1D: [a, d_n, ..., d_1] + 2D: [aa, ad_n, da_n, dd_n, ..., ad_1, da_1, dd_1] + 3D: [aaa, aad_n, ada_n, add_n, ..., ..., aad_1, ada_1, add_1, ...] + """ + coefficients = torch.split( + coefficients_stack, [int(np.prod(shape)) for shape in self.coefficients_shape], dim=-1 + ) + return [ + torch.reshape(coeff, (*coeff.shape[:-1], *shape)) + for coeff, shape in zip(coefficients, self.coefficients_shape, strict=True) + ] diff --git a/src/mrpro/operators/__init__.py b/src/mrpro/operators/__init__.py index 099e4c72..721dabb7 100644 --- a/src/mrpro/operators/__init__.py +++ b/src/mrpro/operators/__init__.py @@ -15,4 +15,5 @@ from mrpro.operators.SensitivityOp import SensitivityOp from mrpro.operators.SignalModel import SignalModel from mrpro.operators.SliceProjectionOp import SliceProjectionOp +from mrpro.operators.WaveletOp import WaveletOp from mrpro.operators.ZeroPadOp import ZeroPadOp diff --git a/tests/helper.py b/tests/helper.py index 0dfb08c4..794f5685 100644 --- a/tests/helper.py +++ b/tests/helper.py @@ -1,9 +1,10 @@ """Helper/Utilities for test functions.""" import torch +from mrpro.operators import Operator -def relative_image_difference(img1, img2): +def relative_image_difference(img1: torch.Tensor, img2: torch.Tensor) -> torch.Tensor: """Calculate mean absolute relative difference between two images. Parameters @@ -25,9 +26,9 @@ def relative_image_difference(img1, img2): def dotproduct_adjointness_test( - operator, u: torch.Tensor, v: torch.Tensor, relative_tolerance: float = 1e-3, absolute_tolerance=1e-5 + operator: Operator, u: torch.Tensor, v: torch.Tensor, relative_tolerance: float = 1e-3, absolute_tolerance=1e-5 ): - """Test the adjointness of operator and operator.H + """Test the adjointness of operator and operator.H. Test if == @@ -70,3 +71,61 @@ def dotproduct_adjointness_test( dotproduct_range = torch.vdot(forward_u.flatten(), v.flatten()) dotproduct_domain = torch.vdot(u.flatten().flatten(), adjoint_v.flatten()) torch.testing.assert_close(dotproduct_range, dotproduct_domain, rtol=relative_tolerance, atol=absolute_tolerance) + + +def operator_isometry_test( + operator: Operator, u: torch.Tensor, relative_tolerance: float = 1e-3, absolute_tolerance=1e-5 +): + """Test the isometry of an operator. + + Test if + ||Operator(u)|| == ||u|| + for u ∈ domain of Operator. + + Parameters + ---------- + operator + operator + u + element of the domain of the operator + relative_tolerance + default is pytorch's default for float16 + absolute_tolerance + default is pytorch's default for float16 + + Raises + ------ + AssertionError + if the adjointness property does not hold + """ + torch.testing.assert_close( + torch.norm(u), torch.norm(operator(u)[0]), rtol=relative_tolerance, atol=absolute_tolerance + ) + + +def operator_unitary_test( + operator: Operator, u: torch.Tensor, relative_tolerance: float = 1e-3, absolute_tolerance=1e-5 +): + """Test if an operator is unitary. + + Test if + Operator.adjoint(Operator(u)) == u + for u ∈ domain of Operator. + + Parameters + ---------- + operator + operator + u + element of the domain of the operator + relative_tolerance + default is pytorch's default for float16 + absolute_tolerance + default is pytorch's default for float16 + + Raises + ------ + AssertionError + if the adjointness property does not hold + """ + torch.testing.assert_close(u, operator.adjoint(operator(u)[0])[0], rtol=relative_tolerance, atol=absolute_tolerance) diff --git a/tests/operators/test_wavelet_op.py b/tests/operators/test_wavelet_op.py new file mode 100644 index 00000000..46e90da4 --- /dev/null +++ b/tests/operators/test_wavelet_op.py @@ -0,0 +1,171 @@ +"""Tests for Wavelet Operator.""" + +import numpy as np +import pytest +import torch +from mrpro.operators import WaveletOp +from ptwt.conv_transform import wavedec +from ptwt.conv_transform_2 import wavedec2 +from ptwt.conv_transform_3 import wavedec3 + +from tests import RandomGenerator +from tests.helper import dotproduct_adjointness_test, operator_isometry_test, operator_unitary_test + + +@pytest.mark.parametrize( + ('im_shape', 'domain_shape', 'dim'), + [ + ((5, 16, 16, 16), (16,), (-1,)), + ((5, 16, 16, 16), (16, 16), (-2, -1)), + ((5, 16, 16, 16), (16, 16, 16), (-3, -2, -1)), + ], +) +def test_wavelet_op_coefficient_transform(im_shape, domain_shape, dim): + """Test transform between ptwt and mrpro coefficient format.""" + random_generator = RandomGenerator(seed=0) + img = random_generator.float32_tensor(size=im_shape) + wavelet_op = WaveletOp(domain_shape=domain_shape, dim=dim) + if len(dim) == 1: + coeff_ptwt = wavedec(img, 'haar', level=2, mode='reflect') + coeff_mrpro = wavelet_op._format_coeffs_1d(coeff_ptwt) + coeff_ptwt_transformed_1d = wavelet_op._undo_format_coeffs_1d(coeff_mrpro) + + # all entries are single tensors + for i in range(len(coeff_ptwt_transformed_1d)): + assert torch.allclose(coeff_ptwt[i], coeff_ptwt_transformed_1d[i]) + + elif len(dim) == 2: + coeff_ptwt = wavedec2(img, 'haar', level=2, mode='reflect') + coeff_mrpro = wavelet_op._format_coeffs_2d(coeff_ptwt) + coeff_ptwt_transformed_2d = wavelet_op._undo_format_coeffs_2d(coeff_mrpro) + + # first entry is tensor, the rest is list of tensors + assert torch.allclose(coeff_ptwt[0], coeff_ptwt_transformed_2d[0]) # type: ignore[arg-type] + for i in range(1, len(coeff_ptwt_transformed_2d)): + assert all( + torch.allclose(coeff_ptwt[i][j], coeff_ptwt_transformed_2d[i][j]) for j in range(len(coeff_ptwt[i])) + ) + + elif len(dim) == 3: + coeff_ptwt = wavedec3(img, 'haar', level=2, mode='reflect') + coeff_mrpro = wavelet_op._format_coeffs_3d(coeff_ptwt) + coeff_ptwt_transformed_3d = wavelet_op._undo_format_coeffs_3d(coeff_mrpro) + + # first entry is tensor, the rest is dict + assert torch.allclose(coeff_ptwt[0], coeff_ptwt_transformed_3d[0]) # type: ignore[arg-type] + for i in range(1, len(coeff_ptwt_transformed_3d)): + assert all(torch.allclose(coeff_ptwt[i][key], coeff_ptwt_transformed_3d[i][key]) for key in coeff_ptwt[i]) + + +def test_wavelet_op_wrong_dim(): + """Wavelet only works for 1D, 2D and 3D data.""" + with pytest.raises(ValueError, match='Only 1D, 2D and 3D wavelet'): + WaveletOp(dim=(0, 1, 2, 3)) # type: ignore[arg-type] + + +def test_wavelet_op_mismatch_dim_domain_shape(): + """Dimensions and shapes need to be of same length.""" + with pytest.raises(ValueError, match='Number of dimensions along which'): + WaveletOp(domain_shape=(10, 20), dim=(-2,)) + + +def test_wavelet_op_error_for_odd_domain_shape(): + with pytest.raises(NotImplementedError, match='ptwt only supports wavelet transforms for tensors with even'): + WaveletOp(domain_shape=(11, 20), dim=(-2, -1)) + + +def test_wavelet_op_complex_real_shape(): + """Test that the shape of the output tensors is the same for complex/real data.""" + im_shape = (6, 10, 20, 30) + domain_shape = (20, 30, 6) + dim = (-2, -1, -4) + random_generator = RandomGenerator(seed=0) + img_complex = random_generator.complex64_tensor(size=im_shape) + img_real = random_generator.float32_tensor(size=im_shape) + wavelet_op = WaveletOp(domain_shape=domain_shape, dim=dim, wavelet_name='db4', level=None) + (coeff_complex,) = wavelet_op(img_complex) + (coeff_real,) = wavelet_op(img_real) + assert coeff_complex.shape == coeff_real.shape + assert wavelet_op.adjoint(coeff_complex)[0].shape == wavelet_op.adjoint(coeff_real)[0].shape + + +@pytest.mark.parametrize('wavelet_name', ['haar', 'db4']) +@pytest.mark.parametrize( + ('im_shape', 'domain_shape', 'dim'), + [ + ((1, 5, 20, 30), (30,), (-1,)), + ((5, 1, 10, 20, 30), (10,), (-3,)), + ((1, 5, 20, 30), (20, 30), (-2, -1)), + ((4, 10, 20, 30), (20, 30), (-2, -1)), + ((4, 10, 20, 30), (10, 30), (-3, -1)), + ((5, 10, 20, 30), (10, 20, 30), (-3, -2, -1)), + ((6, 10, 20, 30), (6, 20, 30), (-4, -2, -1)), + ((6, 10, 20, 30), (20, 30, 6), (-2, -1, -4)), + ((6, 10, 20, 30), (20, 30, 6), (2, 3, 0)), + ((5, 10, 20, 30), None, (-3, -2, -1)), + ], +) +def test_wavelet_op_isometry(im_shape, domain_shape, dim, wavelet_name): + """Test that the wavelet operator is a linear isometry.""" + random_generator = RandomGenerator(seed=0) + img = random_generator.complex64_tensor(size=im_shape) + wavelet_op = WaveletOp(domain_shape=domain_shape, dim=dim, wavelet_name=wavelet_name, level=None) + operator_isometry_test(wavelet_op, img) + + +@pytest.mark.parametrize('wavelet_name', ['haar', 'db4']) +@pytest.mark.parametrize( + ('im_shape', 'domain_shape', 'dim'), + [ + ((1, 5, 20, 30), (30,), (-1,)), + ((5, 1, 10, 20, 30), (10,), (-3,)), + ((1, 5, 20, 30), (20, 30), (-2, -1)), + ((4, 10, 20, 30), (20, 30), (-2, -1)), + ((4, 10, 20, 30), (10, 30), (-3, -1)), + ((5, 10, 20, 30), (10, 20, 30), (-3, -2, -1)), + ((6, 10, 20, 30), (6, 20, 30), (-4, -2, -1)), + ((6, 10, 20, 30), (20, 30, 6), (-2, -1, -4)), + ((6, 10, 20, 30), (20, 30, 6), (2, 3, 0)), + ], +) +def test_wavelet_op_adjointness(im_shape, domain_shape, dim, wavelet_name): + """Test adjoint property; i.e. == for all u,v.""" + random_generator = RandomGenerator(seed=0) + + wavelet_op = WaveletOp(domain_shape=domain_shape, dim=dim, wavelet_name=wavelet_name) + + # calculate 1D length of wavelet coefficients + wavelet_stack_length = torch.sum(torch.as_tensor([(np.prod(shape)) for shape in wavelet_op.coefficients_shape])) + + # sorted and normed dimensions needed to correctly calculate range + dim_sorted = sorted([d % len(im_shape) for d in dim], reverse=True) + range_shape = list(im_shape) + range_shape[dim_sorted[-1]] = int(wavelet_stack_length) + [range_shape.pop(d) for d in dim_sorted[:-1]] + + u = random_generator.complex64_tensor(size=im_shape) + v = random_generator.complex64_tensor(size=range_shape) + dotproduct_adjointness_test(wavelet_op, u, v) + + +@pytest.mark.parametrize('wavelet_name', ['haar', 'db4']) +@pytest.mark.parametrize( + ('im_shape', 'domain_shape', 'dim'), + [ + ((1, 5, 20, 30), (30,), (-1,)), + ((5, 1, 10, 20, 30), (10,), (-3,)), + ((1, 5, 20, 30), (20, 30), (-2, -1)), + ((4, 10, 20, 30), (20, 30), (-2, -1)), + ((4, 10, 20, 30), (10, 30), (-3, -1)), + ((5, 10, 20, 30), (10, 20, 30), (-3, -2, -1)), + ((6, 10, 20, 30), (6, 20, 30), (-4, -2, -1)), + ((6, 10, 20, 30), (20, 30, 6), (-2, -1, -4)), + ((6, 10, 20, 30), (20, 30, 6), (2, 3, 0)), + ], +) +def test_wavelet_op_unitary(im_shape, domain_shape, dim, wavelet_name): + """Test if wavelet operator is unitary.""" + random_generator = RandomGenerator(seed=0) + img = random_generator.complex64_tensor(size=im_shape) + wavelet_op = WaveletOp(domain_shape=domain_shape, dim=dim, wavelet_name=wavelet_name) + operator_unitary_test(wavelet_op, img) From 567089ac2efe4c83aeaecb18b818f3cb9ed75202 Mon Sep 17 00:00:00 2001 From: Felix F Zimmermann Date: Mon, 16 Sep 2024 11:24:38 +0200 Subject: [PATCH 33/34] Add Identity LinearOperator (#390) Add a do-nothing linear operator. This might be further extended to allow for multiple inputs (i.e. an endomorph overload). --- src/mrpro/operators/IdentityOp.py | 44 +++++++++++++++++++++++++++++ src/mrpro/operators/__init__.py | 1 + tests/operators/test_identity_op.py | 36 +++++++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 src/mrpro/operators/IdentityOp.py create mode 100644 tests/operators/test_identity_op.py diff --git a/src/mrpro/operators/IdentityOp.py b/src/mrpro/operators/IdentityOp.py new file mode 100644 index 00000000..79abbc06 --- /dev/null +++ b/src/mrpro/operators/IdentityOp.py @@ -0,0 +1,44 @@ +"""Identity Operator.""" + +import torch + +from mrpro.operators.LinearOperator import LinearOperator + + +class IdentityOp(LinearOperator): + r"""The Identity Operator. + + A Linear Operator that returns a single input unchanged. + """ + + def __init__(self) -> None: + """Initialize Identity Operator.""" + super().__init__() + + def forward(self, x: torch.Tensor) -> tuple[torch.Tensor]: + """Identity of input. + + Parameters + ---------- + x + input tensor + + Returns + ------- + the input tensor + """ + return (x,) + + def adjoint(self, x: torch.Tensor) -> tuple[torch.Tensor]: + """Adjoint Identity. + + Parameters + ---------- + x + input tensor + + Returns + ------- + the input tensor + """ + return (x,) diff --git a/src/mrpro/operators/__init__.py b/src/mrpro/operators/__init__.py index 721dabb7..71993bc6 100644 --- a/src/mrpro/operators/__init__.py +++ b/src/mrpro/operators/__init__.py @@ -10,6 +10,7 @@ from mrpro.operators.FiniteDifferenceOp import FiniteDifferenceOp from mrpro.operators.FourierOp import FourierOp from mrpro.operators.GridSamplingOp import GridSamplingOp +from mrpro.operators.IdentityOp import IdentityOp from mrpro.operators.MagnitudeOp import MagnitudeOp from mrpro.operators.PhaseOp import PhaseOp from mrpro.operators.SensitivityOp import SensitivityOp diff --git a/tests/operators/test_identity_op.py b/tests/operators/test_identity_op.py new file mode 100644 index 00000000..85c182a9 --- /dev/null +++ b/tests/operators/test_identity_op.py @@ -0,0 +1,36 @@ +"""Tests for Identity Linear Operator.""" + +import torch +from mrpro.operators import IdentityOp + +from tests import RandomGenerator + + +def test_identity_op(): + """Test forward identity.""" + generator = RandomGenerator(seed=0) + tensor = generator.complex64_tensor(2, 3, 4) + operator = IdentityOp() + torch.testing.assert_close(tensor, *operator(tensor)) + assert tensor is operator(tensor)[0] + + +def test_identity_op_adjoint(): + """Test adjoint identity.""" + generator = RandomGenerator(seed=0) + tensor = generator.complex64_tensor(2, 3, 4) + operator = IdentityOp().H + torch.testing.assert_close(tensor, *operator(tensor)) + assert tensor is operator(tensor)[0] + + +def test_identity_op_operatorsyntax(): + """Test Identity@(Identity*alpha) + (beta*Identity.H).H""" + generator = RandomGenerator(seed=0) + tensor = generator.complex64_tensor(2, 3, 4) + alpha = generator.complex64_tensor(2, 3, 4) + beta = generator.complex64_tensor(2, 3, 4) + composition = IdentityOp() @ (IdentityOp() * alpha) + (beta * IdentityOp().H).H + expected = tensor * alpha + tensor * beta.conj() + (actual,) = composition(tensor) + torch.testing.assert_close(actual, expected) From f86717d8296ff6b200b2fa3ba7ceca8b9d16092c Mon Sep 17 00:00:00 2001 From: Christoph Kolbitsch Date: Thu, 19 Sep 2024 20:08:55 +0200 Subject: [PATCH 34/34] Change MOLLI from (a,b,T1) to (a,c=b/a,T1) (#406) --- src/mrpro/operators/models/MOLLI.py | 21 +++++++++++++++------ tests/operators/models/test_molli.py | 26 +++++++++++++------------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/mrpro/operators/models/MOLLI.py b/src/mrpro/operators/models/MOLLI.py index 2f04e771..b85ffe94 100644 --- a/src/mrpro/operators/models/MOLLI.py +++ b/src/mrpro/operators/models/MOLLI.py @@ -6,7 +6,18 @@ class MOLLI(SignalModel[torch.Tensor, torch.Tensor, torch.Tensor]): - """Signal model for Modified Look-Locker inversion recovery (MOLLI).""" + """Signal model for Modified Look-Locker inversion recovery (MOLLI). + + This model describes + :math:`M_z(t) = a(1 - c)e^{(-t / T1^*)}` with :math:`T1^* = T1 / (c - 1)`. + + This is a small modification from the original MOLLI signal model [MESS2004]_: + :math:`M_z(t) = a - be^{(-t / T1^*)}` with :math:`T1^* = T1 / (b/a - 1)`. + + .. [MESS2004] Messroghli DR, Radjenovic A, Kozerke S, Higgins DM, Sivananthan MU, Ridgway JP (2004) Modified + look-locker inversion recovery (MOLLI) for high-resolution T 1 mapping of the heart. MRM, 52(1). + https://doi.org/10.1002/mrm.20110 + """ def __init__(self, ti: float | torch.Tensor): """Initialize MOLLI signal model for T1 mapping. @@ -21,7 +32,7 @@ def __init__(self, ti: float | torch.Tensor): ti = torch.as_tensor(ti) self.ti = torch.nn.Parameter(ti, requires_grad=ti.requires_grad) - def forward(self, a: torch.Tensor, b: torch.Tensor, t1: torch.Tensor) -> tuple[torch.Tensor,]: + def forward(self, a: torch.Tensor, c: torch.Tensor, t1: torch.Tensor) -> tuple[torch.Tensor,]: """Apply MOLLI signal model. Parameters @@ -29,8 +40,8 @@ def forward(self, a: torch.Tensor, b: torch.Tensor, t1: torch.Tensor) -> tuple[t a parameter a in MOLLI signal model with shape (... other, coils, z, y, x) - b - parameter b in MOLLI signal model + c + parameter c = b/a in MOLLI signal model with shape (... other, coils, z, y, x) t1 longitudinal relaxation time T1 @@ -41,7 +52,5 @@ def forward(self, a: torch.Tensor, b: torch.Tensor, t1: torch.Tensor) -> tuple[t signal with shape (time ... other, coils, z, y, x) """ ti = self.expand_tensor_dim(self.ti, a.ndim - (self.ti.ndim - 1)) # -1 for time - c = b / torch.where(a == 0, 1e-10, a) - t1 = torch.where(t1 == 0, t1 + 1e-10, t1) signal = a * (1 - c * torch.exp(ti / t1 * (1 - c))) return (signal,) diff --git a/tests/operators/models/test_molli.py b/tests/operators/models/test_molli.py index 10f55281..8ee5f911 100644 --- a/tests/operators/models/test_molli.py +++ b/tests/operators/models/test_molli.py @@ -3,13 +3,14 @@ import pytest import torch from mrpro.operators.models import MOLLI +from tests import RandomGenerator from tests.operators.models.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS, create_parameter_tensor_tuples @pytest.mark.parametrize( ('ti', 'result'), [ - (0, 'a-b'), # short ti + (0, 'a(1-c)'), # short ti (20, 'a'), # long ti ], ) @@ -17,21 +18,20 @@ def test_molli(ti, result): """Test for MOLLI. Checking that idata output tensor at ti=0 is close to a. Checking - that idata output tensor at large ti is close to a-b. + that idata output tensor at large ti is close to a(1-c). """ - # Generate qdata tensor, not random as a= 0 - other, coils, z, y, x = 10, 5, 100, 100, 100 - a = torch.ones((other, coils, z, y, x)) * 2 - b = torch.ones((other, coils, z, y, x)) * 5 - t1 = torch.ones((other, coils, z, y, x)) * 2 + a, t1 = create_parameter_tensor_tuples() + # c>2 is necessary for t1_star to be >= 0 and to ensure t1_star < t1 + random_generator = RandomGenerator(seed=0) + c = random_generator.float32_tensor(size=a.shape, low=2.0, high=4.0) # Generate signal model and torch tensor for comparison model = MOLLI(ti) - (image,) = model.forward(a, b, t1) + (image,) = model.forward(a, c, t1) - # Assert closeness to a-b for large ti - if result == 'a-b': - torch.testing.assert_close(image[0, ...], a - b) + # Assert closeness to a(1-c) for large ti + if result == 'a(1-c)': + torch.testing.assert_close(image[0, ...], a * (1 - c)) # Assert closeness to a for ti=0 elif result == 'a': torch.testing.assert_close(image[0, ...], a) @@ -42,6 +42,6 @@ def test_molli_shape(parameter_shape, contrast_dim_shape, signal_shape): """Test correct signal shapes.""" (ti,) = create_parameter_tensor_tuples(contrast_dim_shape, number_of_tensors=1) model_op = MOLLI(ti) - a, b, t1 = create_parameter_tensor_tuples(parameter_shape, number_of_tensors=3) - (signal,) = model_op.forward(a, b, t1) + a, c, t1 = create_parameter_tensor_tuples(parameter_shape, number_of_tensors=3) + (signal,) = model_op.forward(a, c, t1) assert signal.shape == signal_shape