diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..380db91 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,5 @@ +{ + "name": "ismrmrd-python conda", + "image": "condaforge/miniforge3:24.11.3-0", + "postCreateCommand": "conda install -y conda-build anaconda-client" +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9ab3cc0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: / + schedule: + interval: monthly + + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly diff --git a/.github/workflows/ismrmrd_python_conda.yml b/.github/workflows/ismrmrd_python_conda.yml index 8939ba0..8b51d17 100644 --- a/.github/workflows/ismrmrd_python_conda.yml +++ b/.github/workflows/ismrmrd_python_conda.yml @@ -1,4 +1,4 @@ -name: Publish Conda package +name: Conda on: push: @@ -10,12 +10,11 @@ on: jobs: build-conda-packages: - strategy: - matrix: - os: [ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} + name: Build and publish conda package + runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + - uses: conda-incubator/setup-miniconda@v3 with: miniforge-version: latest @@ -23,12 +22,22 @@ jobs: environment-file: conda/environment.yml python-version: 3.12 auto-activate-base: false + + - name: Check generated schema files are up to date + shell: bash -l {0} + run: | + pip install ".[dev]" -q + python setup.py generate_schema + git diff --exit-code ismrmrd/xsd/ismrmrdschema/ || \ + (echo "ERROR: Generated schema files are out of date. Run 'python setup.py generate_schema' and commit the result." && exit 1) + - name: Build conda package shell: bash -l {0} working-directory: conda run: | ./package.sh - echo "Packages built: $(find build_pkg -name ismrmrd-python*.tar.bz2)" + echo "Packages built: $(find build_pkg -name 'ismrmrd-python*.conda' -or -name 'ismrmrd-python*.tar.bz2')" + - name: Push conda package shell: bash -l {0} if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' && startsWith(github.ref, 'refs/tags') diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 8f7a79e..8b2270b 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,4 +1,4 @@ -name: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI +name: Python on: push: @@ -9,27 +9,55 @@ on: workflow_dispatch: jobs: - build: - name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI + test: + name: Test with Python ${{ matrix.python-version }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v5 - - name: Set up Python 3.x + + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: - python-version: 3.12 - - name: Install runtime dependencies - run: python -m pip install -r requirements.txt --user - - name: Perform editable installation to generate the schema subpackage - run: python -m pip install -e . + python-version: ${{ matrix.python-version }} + + - name: Install package and dev dependencies + run: python -m pip install -e ".[dev]" + + - name: Check generated schema files are up to date + if: matrix.python-version == '3.12' + run: | + python setup.py generate_schema + git diff --exit-code ismrmrd/xsd/ismrmrdschema/ || \ + (echo "ERROR: Generated schema files are out of date. Run 'python setup.py generate_schema' and commit the result." && exit 1) + - name: Run library tests run: python -m pytest + - name: Run end-to-end tests run: bash tests/end-to-end/test-reconstruction.sh - - name: Install pypa/build - run: python -m pip install build --user + + build: + name: Build PyPI package + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v5 + + - name: Set up Python 3.x + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install package and dev dependencies + run: python -m pip install -e ".[dev]" + - name: Build a binary wheel and a source tarball run: python -m build --sdist --wheel --outdir dist/ . + - name: Store the distribution packages uses: actions/upload-artifact@v4 with: @@ -37,7 +65,7 @@ jobs: path: dist/ publish-to-pypi: - name: Publish Python 🐍 distribution 📦 to PyPI + name: Publish PyPI package if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' && startsWith(github.ref, 'refs/tags') needs: - build diff --git a/.gitignore b/.gitignore index b69ebc8..bf9364c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ dist/ *.egg-info xsd.py .eggs -.idea \ No newline at end of file +.idea +.venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..219e753 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,54 @@ +# Changelog + +## v1.15.0 (unreleased) + +### Breaking changes + +- `Image.from_array()` default changed from `transpose=True` to `transpose=False`. + The old default silently transposed array data to match a legacy column-major + convention inconsistent with the C++ library. Passing `transpose=True` + explicitly now raises a `DeprecationWarning`. +- `Image.matrix_size` now returns the header's `(x, y, z)` tuple. Previously + returned `(z, y, x)` from the data array shape, inconsistent with the header + and the C++ API. +- `requires-python` bumped to `>=3.10` (`@dataclass(kw_only=True)` in the + xsdata-generated schema code requires 3.10+). + +### New features + +- `ismrmrd.util`: new module with `sign_of_directions`, + `directions_to_quaternion`, and `quaternion_to_directions` (all three + available at the top-level package). +- `AcquisitionHeader` and `Acquisition` gained channel-mask methods: + `isChannelActive`, `setChannelActive`, `setChannelNotActive`, + `setAllChannelsNotActive` (matches C++ `ISMRMRD_ChannelMask`). +- `ProtocolDeserializer` gained `peek()` and `peek_image_data_type()` for + non-consuming look-ahead. +- `ProtocolSerializer`/`ProtocolDeserializer` handle `CONFIG_FILE` (ID 1) and + `CONFIG_TEXT` (ID 2) message types. +- `ismrmrd.__version__` exposed via `importlib.metadata`. + +### Bug fixes + +- `Waveform.setHead()` called non-existent `active_channels` field; corrected + to `channels`. + +### Infrastructure + +- xsdata-generated schema files committed to the repo; no longer regenerated at + install time. Run `python setup.py generate_schema` to update them. +- CI (both PyPI and conda workflows) now fails fast if committed schema files + diverge from a fresh regeneration. +- `requirements.txt` removed; dev setup: `pip install -e ".[dev]"`. +- Python version matrix added to CI (3.10, 3.11, 3.12, 3.13). +- `dependabot.yml` added for monthly pip and GitHub Actions updates. + +## v1.14.2 + +- Fix compatibility with xsdata 26.1. +- Enable re-building and publishing Conda package. +- Remove deprecated macos-13 runner. + +## v1.14.1 and earlier + +See git history. diff --git a/conda/conda_build_config.yaml b/conda/conda_build_config.yaml deleted file mode 100644 index a042c9f..0000000 --- a/conda/conda_build_config.yaml +++ /dev/null @@ -1,3 +0,0 @@ -python: - - 3.10 - - 3.12 \ No newline at end of file diff --git a/conda/meta.yaml b/conda/meta.yaml index dafc854..65b6406 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -7,18 +7,20 @@ package: source: path: ../ +build: + noarch: python + requirements: build: - - python {{ python }} - - xsdata>=24.0 + - python - setuptools - pip run: - - python + - python >=3.10 - numpy>=1.22.0 - h5py>=2.3 - - xsdata>=24.0 + - xsdata>=26.1 test: imports: diff --git a/ismrmrd/__init__.py b/ismrmrd/__init__.py index 435a692..3ee344e 100644 --- a/ismrmrd/__init__.py +++ b/ismrmrd/__init__.py @@ -1,11 +1,18 @@ +from importlib.metadata import version as _metadata_version, PackageNotFoundError as _PackageNotFoundError +try: + __version__ = _metadata_version("ismrmrd") +except _PackageNotFoundError: + __version__ = "unknown" + from .constants import * from .acquisition import AcquisitionHeader, Acquisition, EncodingCounters +from .util import sign_of_directions, directions_to_quaternion, quaternion_to_directions from .image import ImageHeader, Image from .hdf5 import Dataset from .meta import Meta from .waveform import WaveformHeader, Waveform from .file import File -from .serialization import ProtocolSerializer, ProtocolDeserializer +from .serialization import ProtocolSerializer, ProtocolDeserializer, ConfigFile, ConfigText from . import xsd diff --git a/ismrmrd/acquisition.py b/ismrmrd/acquisition.py index 8dd5c1b..bbf9df6 100644 --- a/ismrmrd/acquisition.py +++ b/ismrmrd/acquisition.py @@ -4,7 +4,7 @@ import io from .constants import * -from .flags import FlagsMixin +from .flags import FlagsMixin, ChannelMaskMixin from .equality import EqualityMixin from . import decorators @@ -33,7 +33,7 @@ def __str__(self): return retstr -class AcquisitionHeader(FlagsMixin, EqualityMixin, ctypes.Structure): +class AcquisitionHeader(FlagsMixin, ChannelMaskMixin, EqualityMixin, ctypes.Structure): _pack_ = 2 _fields_ = [("version", ctypes.c_uint16), ("flags", ctypes.c_uint64), @@ -71,7 +71,7 @@ def __str__(self): @decorators.expose_header_fields(AcquisitionHeader) -class Acquisition(FlagsMixin): +class Acquisition(FlagsMixin, ChannelMaskMixin): _readonly = ('number_of_samples', 'active_channels', 'trajectory_dimensions') @staticmethod diff --git a/ismrmrd/flags.py b/ismrmrd/flags.py index 16b18bb..f357c99 100644 --- a/ismrmrd/flags.py +++ b/ismrmrd/flags.py @@ -26,3 +26,36 @@ def set_flag(self, flag): def clear_flag(self, flag): self.flags &= ~(1 << (flag - 1)) + +class ChannelMaskMixin(object): + """Mixin providing channel mask helpers for structs with a channel_mask field. + + channel_mask is uint64[16], supporting up to 1024 channels. + Channel N lives in word N // 64, bit position N % 64. + """ + + _MAX_CHANNELS = 1024 + + def _check_channel_id(self, channel_id): + if not (0 <= channel_id < self._MAX_CHANNELS): + raise ValueError(f"channel_id {channel_id} is out of range [0, {self._MAX_CHANNELS})") + + def isChannelActive(self, channel_id): + self._check_channel_id(channel_id) + word, bit = divmod(channel_id, 64) + return bool(self.channel_mask[word] & (1 << bit)) + + def setChannelActive(self, channel_id): + self._check_channel_id(channel_id) + word, bit = divmod(channel_id, 64) + self.channel_mask[word] |= (1 << bit) + + def setChannelNotActive(self, channel_id): + self._check_channel_id(channel_id) + word, bit = divmod(channel_id, 64) + self.channel_mask[word] &= ~(1 << bit) + + def setAllChannelsNotActive(self): + for i in range(len(self.channel_mask)): + self.channel_mask[i] = 0 + diff --git a/ismrmrd/image.py b/ismrmrd/image.py index 71973ff..2b2044b 100644 --- a/ismrmrd/image.py +++ b/ismrmrd/image.py @@ -135,6 +135,15 @@ class Image(FlagsMixin): def deserialize_from(read_exactly): header_bytes = read_exactly(ctypes.sizeof(ImageHeader)) + return Image.deserialize_from_with_header(header_bytes, read_exactly) + + @staticmethod + def deserialize_from_with_header(header_bytes, read_exactly): + """Deserialize an Image when the header bytes have already been read. + + Used by :class:`~ismrmrd.serialization.ProtocolDeserializer` when + ``peek()`` has already consumed the header from the stream. + """ attribute_length_bytes = read_exactly(ctypes.sizeof(ctypes.c_uint64)) attribute_length = ctypes.c_uint64.from_buffer_copy(attribute_length_bytes) attribute_bytes = read_exactly(attribute_length.value).rstrip(b'\0') @@ -176,8 +185,36 @@ def to_bytes(self): return stream.getvalue() @staticmethod - def from_array(array, acquisition=Acquisition(), transpose=True, **kwargs): + def from_array(array, acquisition=Acquisition(), transpose=False, **kwargs): + """Create an :class:`Image` from a numpy array. + + The array must be in row-major (C) order with shape one of: + - ``(x,)`` + - ``(y, x)`` + - ``(z, y, x)`` + - ``(channels, z, y, x)`` + + The ``matrix_size`` header field is set to ``(x, y, z)``. + + .. deprecated:: 1.14.x + Passing ``transpose=True`` (the old default) is deprecated. The + default is now ``False`` (row-major / numpy-native). If you were + relying on the old behaviour, transpose your array explicitly before + calling this function. + """ + + if transpose: + warnings.warn( + "transpose=True is deprecated and will be removed in a future version. " + "Pass your array in row-major order (shape (..., z, y, x)) and use " + "transpose=False (the new default).", + DeprecationWarning, + stacklevel=2, + ) + array = array.transpose() + # After possible transposition the array is (…, z, y, x) or (…, x). + # We need to derive (nchannels, x, y, z) for the header. def input_shape_to_header_format(array): def with_defaults(first=1, second=1, third=1, nchannels=1): return nchannels, (first, second, third) @@ -187,16 +224,6 @@ def with_defaults(first=1, second=1, third=1, nchannels=1): def header_format_to_resize_shape(nchannels, first, second, third): return nchannels, third, second, first - if transpose: - warnings.warn( - "The default behavior of this function is currently column-major which " + - "is inconsistent with numpy using row-major by default. In a future " + - "version this will be changed. Please switch to setting transpose in " + - "this function to false to switch to the new behavior.", - PendingDeprecationWarning - ) - array = array.transpose() - nchannels, matrix_size = input_shape_to_header_format(array) image_properties = { @@ -294,10 +321,8 @@ def meta(self, val): @property def matrix_size(self): - """This function currently returns a result that is inconsistent (transposed) - compared to the matrix_size in the ImageHeader and from .getHead().matrix_size. - This function will be made consistent in a future version and this message will be removed.""" - return self.__data.shape[1:4] + """Return ``(x, y, z)`` matching :attr:`ImageHeader.matrix_size`.""" + return tuple(self._head.matrix_size) @property def attribute_string_len(self): diff --git a/ismrmrd/serialization.py b/ismrmrd/serialization.py index 93df092..49752e3 100644 --- a/ismrmrd/serialization.py +++ b/ismrmrd/serialization.py @@ -12,8 +12,28 @@ from enum import IntEnum +# Fixed size of a CONFIG_FILE message payload (matches C++ ConfigFile struct) +_CONFIG_FILE_SIZE = 1024 + + +class ConfigFile(str): + """Wraps a config filename/path for serialization as a CONFIG_FILE (ID=1) message. + + CONFIG_FILE messages carry a fixed 1024-byte null-padded payload, matching + the C++ ``ConfigFile`` struct. + """ + + +class ConfigText(str): + """Wraps config XML text for serialization as a CONFIG_TEXT (ID=2) message. + + CONFIG_TEXT messages use a 4-byte length prefix, matching the C++ + ``ConfigText`` struct. + """ + + # Type alias for serializable objects -SerializableObject = Union[Acquisition, Image, Waveform, ismrmrdHeader, np.ndarray, str] +SerializableObject = Union[Acquisition, Image, Waveform, ismrmrdHeader, np.ndarray, ConfigFile, ConfigText, str] class ISMRMRDMessageID(IntEnum): UNPEEKED = 0 @@ -62,8 +82,18 @@ def _write_message_id(self, msgid: ISMRMRDMessageID) -> None: def serialize(self, obj: SerializableObject) -> None: """ Serializes an ISMRMRD object and writes to the configured stream. + + Pass a :class:`ConfigFile` or :class:`ConfigText` instance to emit the + corresponding CONFIG_FILE (ID=1) or CONFIG_TEXT (ID=2) message type. + A plain ``str`` is serialized as TEXT (ID=5). """ - if isinstance(obj, Acquisition): + if isinstance(obj, ConfigFile): + self._write_message_id(ISMRMRDMessageID.CONFIG_FILE) + self._serialize_config_file(obj) + elif isinstance(obj, ConfigText): + self._write_message_id(ISMRMRDMessageID.CONFIG_TEXT) + self._serialize_text(obj) + elif isinstance(obj, Acquisition): self._write_message_id(ISMRMRDMessageID.ACQUISITION) obj.serialize_into(self._stream.write) elif isinstance(obj, Image): @@ -98,6 +128,14 @@ def _serialize_ndarray(self, arr: np.ndarray) -> None: self._stream.write(struct.pack('<' + 'Q' * ndim, *dims)) self._stream.write(arr.tobytes()) + def _serialize_config_file(self, text: str) -> None: + """Writes a fixed 1024-byte null-padded CONFIG_FILE payload.""" + encoded = text.encode('utf-8') + if len(encoded) >= _CONFIG_FILE_SIZE: + raise ValueError(f"config_file string too long (max {_CONFIG_FILE_SIZE - 1} bytes encoded)") + payload = encoded + b'\x00' * (_CONFIG_FILE_SIZE - len(encoded)) + self._stream.write(payload) + def _serialize_text(self, text: str) -> None: text_bytes = text.encode('utf-8') self._stream.write(struct.pack(' None: else: self._stream = stream self._owns_stream = False + # peek() state + self._peeked_id: int = ISMRMRDMessageID.UNPEEKED + self._peeked_image_header: Union[None, bytes] = None def __enter__(self) -> 'ProtocolDeserializer': return self @@ -129,25 +170,76 @@ def close(self) -> None: if self._owns_stream: self._stream.close() + def peek(self) -> int: + """Return the message ID of the next message without consuming it. + + For IMAGE messages, the full :class:`ImageHeader` bytes are also buffered + so that :meth:`peek_image_data_type` can return the data type without + requiring a separate read. Matches the C++ ``ProtocolDeserializer::peek()``. + """ + if self._peeked_id == ISMRMRDMessageID.UNPEEKED: + msg_id_bytes = self._stream.read(2) + if not msg_id_bytes or len(msg_id_bytes) < 2: + raise EOFError("End of stream or incomplete message ID") + self._peeked_id = struct.unpack(' int: + """Return the data type of the next IMAGE message. + + :raises RuntimeError: if the next message is not an IMAGE. + """ + if self._peeked_id != ISMRMRDMessageID.IMAGE: + raise RuntimeError("Cannot peek image data type: next message is not IMAGE") + from ismrmrd.image import ImageHeader + import ctypes + header = ImageHeader.from_buffer_copy(self._peeked_image_header) + return header.data_type + def deserialize(self) -> Generator[SerializableObject, None, None]: """ Reads from the stream, yielding each ISMRMRD object as a generator. """ while True: - msg_id_bytes = self._stream.read(2) - if not msg_id_bytes or len(msg_id_bytes) < 2: - raise EOFError("End of stream or incomplete message ID") - msg_id = struct.unpack(' Generator[SerializableObject, None, None]: else: raise ValueError(f"Unknown MessageID: {msg_id}") + def _deserialize_config_file(self) -> ConfigFile: + """Reads the fixed 1024-byte CONFIG_FILE payload and returns it as a ConfigFile.""" + payload = self._stream.read(_CONFIG_FILE_SIZE) + if len(payload) < _CONFIG_FILE_SIZE: + raise EOFError("Incomplete CONFIG_FILE payload") + return ConfigFile(payload.rstrip(b'\x00').decode('utf-8')) + def _deserialize_ismrmrd_header(self) -> ismrmrdHeader: length_bytes = self._stream.read(4) if len(length_bytes) < 4: diff --git a/ismrmrd/util.py b/ismrmrd/util.py new file mode 100644 index 0000000..5f140b4 --- /dev/null +++ b/ismrmrd/util.py @@ -0,0 +1,87 @@ +"""Utility functions for working with ISMRMRD data structures.""" + +import numpy as np + + +def sign_of_directions(read_dir, phase_dir, slice_dir): + """Return +1 if the rotation matrix formed by the three direction cosines + has a non-negative determinant, -1 otherwise. + + Parameters correspond to the ``read_dir``, ``phase_dir``, and + ``slice_dir`` fields of :class:`~ismrmrd.AcquisitionHeader` / + :class:`~ismrmrd.ImageHeader`. + """ + R = np.column_stack([ + np.asarray(read_dir, dtype=np.float64), + np.asarray(phase_dir, dtype=np.float64), + np.asarray(slice_dir, dtype=np.float64), + ]) + return 1 if np.linalg.det(R) >= 0 else -1 + + +def directions_to_quaternion(read_dir, phase_dir, slice_dir): + """Convert direction cosines to a normalized quaternion ``[a, b, c, d]``. + + Uses the same algorithm as the C library (Princeton quat FAQ Q55). + The sign of the rotation is checked first; if negative the slice + direction is flipped before computing the quaternion. + """ + r = np.asarray(read_dir, dtype=np.float64) + p = np.asarray(phase_dir, dtype=np.float64) + s = np.asarray(slice_dir, dtype=np.float64) + + if sign_of_directions(r, p, s) < 0: + s = -s + + r11, r21, r31 = r + r12, r22, r32 = p + r13, r23, r33 = s + + trace = 1.0 + r11 + r22 + r33 + if trace > 1e-5: + sv = np.sqrt(trace) * 2 + a = (r32 - r23) / sv + b = (r13 - r31) / sv + c = (r21 - r12) / sv + d = 0.25 * sv + else: + xd = 1.0 + r11 - r22 - r33 + yd = 1.0 + r22 - r11 - r33 + zd = 1.0 + r33 - r11 - r22 + if xd > 1.0: + sv = 2.0 * np.sqrt(xd) + a = 0.25 * sv + b = (r21 + r12) / sv + c = (r31 + r13) / sv + d = (r32 - r23) / sv + elif yd > 1.0: + sv = 2.0 * np.sqrt(yd) + a = (r21 + r12) / sv + b = 0.25 * sv + c = (r32 + r23) / sv + d = (r13 - r31) / sv + else: + sv = 2.0 * np.sqrt(zd) + a = (r13 + r31) / sv + b = (r23 + r32) / sv + c = 0.25 * sv + d = (r21 - r12) / sv + if a < 0.0: + a, b, c, d = -a, -b, -c, -d + + return np.array([a, b, c, d], dtype=np.float32) + + +def quaternion_to_directions(quat): + """Convert a normalized quaternion ``[a, b, c, d]`` to direction cosines. + + Returns ``(read_dir, phase_dir, slice_dir)`` as three float32 arrays of + length 3 (Princeton quat FAQ Q54). + """ + a, b, c, d = (float(x) for x in quat) + + read_dir = np.array([1 - 2*(b*b + c*c), 2*(a*b + c*d), 2*(a*c - b*d)], dtype=np.float32) + phase_dir = np.array([2*(a*b - c*d), 1 - 2*(a*a + c*c), 2*(b*c + a*d)], dtype=np.float32) + slice_dir = np.array([2*(a*c + b*d), 2*(b*c - a*d), 1 - 2*(a*a + b*b)], dtype=np.float32) + + return read_dir, phase_dir, slice_dir diff --git a/ismrmrd/waveform.py b/ismrmrd/waveform.py index f128ffa..ce07080 100644 --- a/ismrmrd/waveform.py +++ b/ismrmrd/waveform.py @@ -108,7 +108,7 @@ def getHead(self): def setHead(self, hdr): self._head = self._head.__class__.from_buffer_copy(hdr) - self.resize(self._head.number_of_samples, self._head.active_channels) + self.resize(self._head.number_of_samples, self._head.channels) @property def data(self): diff --git a/ismrmrd/xsd/ismrmrdschema/__init__.py b/ismrmrd/xsd/ismrmrdschema/__init__.py new file mode 100644 index 0000000..b42d355 --- /dev/null +++ b/ismrmrd/xsd/ismrmrdschema/__init__.py @@ -0,0 +1,79 @@ +from .ismrmrd import ( + accelerationFactorType, + acquisitionSystemInformationType, + calibrationModeType, + coilLabelType, + diffusionDimensionType, + diffusionType, + encodingLimitsType, + encodingSpaceType, + encodingType, + experimentalConditionsType, + fieldOfViewMm, + gradientDirectionType, + interleavingDimensionType, + ismrmrdHeader, + limitType, + matrixSizeType, + measurementDependencyType, + measurementInformationType, + multibandCalibrationType, + multibandSpacingType, + multibandType, + parallelImagingType, + patientPositionType, + referencedImageSequenceType, + sequenceParametersType, + studyInformationType, + subjectInformationType, + threeDimensionalFloat, + trajectoryDescriptionType, + trajectoryType, + userParameterBase64Type, + userParameterDoubleType, + userParameterLongType, + userParameterStringType, + userParametersType, + waveformInformationType, + waveformInformationTypeWaveformType, +) + +__all__ = [ + "accelerationFactorType", + "acquisitionSystemInformationType", + "calibrationModeType", + "coilLabelType", + "diffusionDimensionType", + "diffusionType", + "encodingLimitsType", + "encodingSpaceType", + "encodingType", + "experimentalConditionsType", + "fieldOfViewMm", + "gradientDirectionType", + "interleavingDimensionType", + "ismrmrdHeader", + "limitType", + "matrixSizeType", + "measurementDependencyType", + "measurementInformationType", + "multibandCalibrationType", + "multibandSpacingType", + "multibandType", + "parallelImagingType", + "patientPositionType", + "referencedImageSequenceType", + "sequenceParametersType", + "studyInformationType", + "subjectInformationType", + "threeDimensionalFloat", + "trajectoryDescriptionType", + "trajectoryType", + "userParameterBase64Type", + "userParameterDoubleType", + "userParameterLongType", + "userParameterStringType", + "userParametersType", + "waveformInformationType", + "waveformInformationTypeWaveformType", +] diff --git a/ismrmrd/xsd/ismrmrdschema/ismrmrd.py b/ismrmrd/xsd/ismrmrdschema/ismrmrd.py new file mode 100644 index 0000000..cd8a775 --- /dev/null +++ b/ismrmrd/xsd/ismrmrdschema/ismrmrd.py @@ -0,0 +1,1172 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum + +from xsdata.models.datatype import XmlDate, XmlTime + +__NAMESPACE__ = "http://www.ismrm.org/ISMRMRD" + + +@dataclass(kw_only=True) +class accelerationFactorType: + kspace_encoding_step_1: int = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + kspace_encoding_step_2: int = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + + +class calibrationModeType(Enum): + EMBEDDED = "embedded" + INTERLEAVED = "interleaved" + SEPARATE = "separate" + EXTERNAL = "external" + OTHER = "other" + + +@dataclass(kw_only=True) +class coilLabelType: + coilNumber: int = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + coilName: str = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + + +class diffusionDimensionType(Enum): + AVERAGE = "average" + CONTRAST = "contrast" + PHASE = "phase" + REPETITION = "repetition" + SET = "set" + SEGMENT = "segment" + USER_0 = "user_0" + USER_1 = "user_1" + USER_2 = "user_2" + USER_3 = "user_3" + USER_4 = "user_4" + USER_5 = "user_5" + USER_6 = "user_6" + USER_7 = "user_7" + + +@dataclass(kw_only=True) +class experimentalConditionsType: + H1resonanceFrequency_Hz: int = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + + +@dataclass(kw_only=True) +class fieldOfViewMm: + class Meta: + name = "fieldOfView_mm" + + x: float = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + y: float = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + z: float = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + + +@dataclass(kw_only=True) +class gradientDirectionType: + rl: float = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + ap: float = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + fh: float = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + + +class interleavingDimensionType(Enum): + PHASE = "phase" + REPETITION = "repetition" + CONTRAST = "contrast" + AVERAGE = "average" + OTHER = "other" + + +@dataclass(kw_only=True) +class limitType: + minimum: int = field( + default=0, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + }, + ) + maximum: int = field( + default=0, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + }, + ) + center: int = field( + default=0, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + }, + ) + + +@dataclass(kw_only=True) +class matrixSizeType: + x: int = field( + default=1, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + }, + ) + y: int = field( + default=1, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + }, + ) + z: int = field( + default=1, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + }, + ) + + +@dataclass(kw_only=True) +class measurementDependencyType: + dependencyType: str = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + measurementID: str = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + + +class multibandCalibrationType(Enum): + SEPARABLE2_D = "separable2D" + FULL3_D = "full3D" + OTHER = "other" + + +@dataclass(kw_only=True) +class multibandSpacingType: + dZ: list[float] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "min_occurs": 1, + }, + ) + + +class patientPositionType(Enum): + HFP = "HFP" + HFS = "HFS" + HFDR = "HFDR" + HFDL = "HFDL" + FFP = "FFP" + FFS = "FFS" + FFDR = "FFDR" + FFDL = "FFDL" + + +@dataclass(kw_only=True) +class referencedImageSequenceType: + referencedSOPInstanceUID: list[str] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + + +@dataclass(kw_only=True) +class studyInformationType: + studyDate: None | XmlDate = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + studyTime: None | XmlTime = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + studyID: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + accessionNumber: None | int = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + referringPhysicianName: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + studyDescription: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + studyInstanceUID: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + bodyPartExamined: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + + +@dataclass(kw_only=True) +class subjectInformationType: + patientName: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + patientWeight_kg: None | float = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + patientHeight_m: None | float = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + patientID: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + patientBirthdate: None | XmlDate = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + patientGender: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "pattern": r"[MFO]", + }, + ) + + +@dataclass(kw_only=True) +class threeDimensionalFloat: + x: float = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + y: float = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + z: float = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + + +class trajectoryType(Enum): + CARTESIAN = "cartesian" + EPI = "epi" + RADIAL = "radial" + GOLDENANGLE = "goldenangle" + SPIRAL = "spiral" + OTHER = "other" + + +@dataclass(kw_only=True) +class userParameterBase64Type: + name: str = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + value: bytes = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + "format": "base64", + } + ) + + +@dataclass(kw_only=True) +class userParameterDoubleType: + name: str = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + value: float = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + + +@dataclass(kw_only=True) +class userParameterLongType: + name: str = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + value: int = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + + +@dataclass(kw_only=True) +class userParameterStringType: + name: str = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + value: str = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + + +class waveformInformationTypeWaveformType(Enum): + ECG = "ecg" + PULSE = "pulse" + RESPIRATORY = "respiratory" + TRIGGER = "trigger" + GRADIENTWAVEFORM = "gradientwaveform" + OTHER = "other" + + +@dataclass(kw_only=True) +class acquisitionSystemInformationType: + systemVendor: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + systemModel: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + systemFieldStrength_T: None | float = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + relativeReceiverNoiseBandwidth: None | float = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + receiverChannels: None | int = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + coilLabel: list[coilLabelType] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + institutionName: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + stationName: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + deviceID: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + deviceSerialNumber: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + + +@dataclass(kw_only=True) +class diffusionType: + gradientDirection: gradientDirectionType = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + bvalue: float = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + + +@dataclass(kw_only=True) +class encodingLimitsType: + kspace_encoding_step_0: None | limitType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + kspace_encoding_step_1: None | limitType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + kspace_encoding_step_2: None | limitType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + average: None | limitType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + slice: None | limitType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + contrast: None | limitType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + phase: None | limitType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + repetition: None | limitType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + set: None | limitType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + segment: None | limitType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + user_0: None | limitType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + user_1: None | limitType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + user_2: None | limitType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + user_3: None | limitType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + user_4: None | limitType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + user_5: None | limitType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + user_6: None | limitType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + user_7: None | limitType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + + +@dataclass(kw_only=True) +class encodingSpaceType: + matrixSize: matrixSizeType = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + fieldOfView_mm: fieldOfViewMm = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + + +@dataclass(kw_only=True) +class measurementInformationType: + measurementID: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + seriesDate: None | XmlDate = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + seriesTime: None | XmlTime = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + patientPosition: patientPositionType = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + relativeTablePosition: None | threeDimensionalFloat = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + initialSeriesNumber: None | int = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + protocolName: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + sequenceName: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + seriesDescription: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + measurementDependency: list[measurementDependencyType] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + seriesInstanceUIDRoot: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + frameOfReferenceUID: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + referencedImageSequence: None | referencedImageSequenceType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + + +@dataclass(kw_only=True) +class multibandType: + spacing: list[multibandSpacingType] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "min_occurs": 1, + }, + ) + deltaKz: float = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + multiband_factor: int = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + calibration: multibandCalibrationType = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + calibration_encoding: int = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + + +@dataclass(kw_only=True) +class trajectoryDescriptionType: + identifier: str = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + userParameterLong: list[userParameterLongType] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + userParameterDouble: list[userParameterDoubleType] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + userParameterString: list[userParameterStringType] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + comment: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + + +@dataclass(kw_only=True) +class userParametersType: + userParameterLong: list[userParameterLongType] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + userParameterDouble: list[userParameterDoubleType] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + userParameterString: list[userParameterStringType] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + userParameterBase64: list[userParameterBase64Type] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + + +@dataclass(kw_only=True) +class parallelImagingType: + accelerationFactor: accelerationFactorType = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + calibrationMode: None | calibrationModeType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + interleavingDimension: None | interleavingDimensionType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + multiband: None | multibandType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + + +@dataclass(kw_only=True) +class sequenceParametersType: + TR: list[float] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + TE: list[float] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + TI: list[float] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + flipAngle_deg: list[float] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + sequence_type: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + echo_spacing: list[float] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + diffusionDimension: None | diffusionDimensionType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + diffusion: list[diffusionType] = field( + default_factory=list, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + diffusionScheme: None | str = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + + +@dataclass(kw_only=True) +class waveformInformationType: + waveformName: str = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + waveformType: waveformInformationTypeWaveformType = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + userParameters: userParametersType = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + + +@dataclass(kw_only=True) +class encodingType: + encodedSpace: encodingSpaceType = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + reconSpace: encodingSpaceType = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + encodingLimits: encodingLimitsType = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + trajectory: trajectoryType = field( + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + "required": True, + } + ) + trajectoryDescription: None | trajectoryDescriptionType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + parallelImaging: None | parallelImagingType = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + echoTrainLength: None | int = field( + default=None, + metadata={ + "type": "Element", + "namespace": "http://www.ismrm.org/ISMRMRD", + }, + ) + + +@dataclass(kw_only=True) +class ismrmrdHeader: + class Meta: + namespace = "http://www.ismrm.org/ISMRMRD" + + version: None | int = field( + default=None, + metadata={ + "type": "Element", + }, + ) + subjectInformation: None | subjectInformationType = field( + default=None, + metadata={ + "type": "Element", + }, + ) + studyInformation: None | studyInformationType = field( + default=None, + metadata={ + "type": "Element", + }, + ) + measurementInformation: None | measurementInformationType = field( + default=None, + metadata={ + "type": "Element", + }, + ) + acquisitionSystemInformation: None | acquisitionSystemInformationType = ( + field( + default=None, + metadata={ + "type": "Element", + }, + ) + ) + experimentalConditions: experimentalConditionsType = field( + metadata={ + "type": "Element", + "required": True, + } + ) + encoding: list[encodingType] = field( + default_factory=list, + metadata={ + "type": "Element", + "min_occurs": 1, + }, + ) + sequenceParameters: None | sequenceParametersType = field( + default=None, + metadata={ + "type": "Element", + }, + ) + userParameters: None | userParametersType = field( + default=None, + metadata={ + "type": "Element", + }, + ) + waveformInformation: list[waveformInformationType] = field( + default_factory=list, + metadata={ + "type": "Element", + "max_occurs": 32, + }, + ) diff --git a/pyproject.toml b/pyproject.toml index 34dbfcf..b6701c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,14 @@ [build-system] -requires = ["setuptools", "xsdata[cli]"] +requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] name = "ismrmrd" dynamic = ["version"] -dependencies = ["h5py>=2.3", "numpy>=1.22.0", "xsdata>=22.12"] -requires-python = ">=3.9" +dependencies = ["h5py>=2.3", "numpy>=1.22.0", "xsdata>=26.1"] +requires-python = ">=3.10" authors = [{name = "ISMRMRD Developers"}] - description = "Python implementation of ISMRMRD" readme = "README.md" license-files = ["LICENSE"] @@ -27,3 +26,6 @@ classifiers = [ Homepage = "https://github.com/ismrmrd/ismrmrd-python" Documentation = "https://github.com/ismrmrd/ismrmrd-python" Repository = "https://github.com/ismrmrd/ismrmrd-python" + +[project.optional-dependencies] +dev = ["pytest", "build", "setuptools", "xsdata[cli]==26.1"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 001a40a..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -h5py==3.14.0 -pytest==8.4.1 -numpy==2.3.2 -xsdata==26.1 \ No newline at end of file diff --git a/setup.py b/setup.py index c4b284f..f14f06a 100644 --- a/setup.py +++ b/setup.py @@ -2,18 +2,22 @@ from setuptools import setup, find_packages, Command import shutil -from pathlib import Path import re schema_file = os.path.join('schema','ismrmrd.xsd') config_file = os.path.join('schema','.xsdata.xml') -import setuptools.command.build -setuptools.command.build.build.sub_commands.append(("generate_schema", None)) +# The xsdata-generated files are committed to the repository. +# The generate_schema command is kept here for maintainers to use when the +# schema or the xsdata version changes: +# +# python setup.py generate_schema +# +# After running it, commit the updated files under ismrmrd/xsd/ismrmrdschema/. class GenerateSchemaCommand(Command): - description = "Generate Python code from ISMRMRD XML schema using xsdata" + description = "Regenerate Python code from ISMRMRD XML schema using xsdata (maintainers only)" user_options = [] def __init__(self, *args, **kwargs): @@ -25,16 +29,11 @@ def initialize_options(self): pass def finalize_options(self): - # Set build_lib for non-editable installs - self.set_undefined_options("build_py", ("build_lib", "build_lib")) + pass def run(self): - # Use editable_mode if present (PEP 660) - if self.editable_mode: - outdir = 'ismrmrd/xsd/' - else: - outdir = os.path.join(self.build_lib, 'ismrmrd/xsd/') - self.announce(f'Generating schema to {outdir} (editable_mode={self.editable_mode})', level=3) + outdir = 'ismrmrd/xsd/' + self.announce(f'Generating schema to {outdir}', level=3) self.generate_schema(schema_file, config_file, 'ismrmrdschema', outdir) def get_source_files(self): @@ -42,14 +41,14 @@ def get_source_files(self): def get_outputs(self): return [ - "{build_lib}/ismrmrd/xsd/ismrmrdschema/__init__.py", - "{build_lib}/ismrmrd/xsd/ismrmrdschema/ismrmrd.py" + "ismrmrd/xsd/ismrmrdschema/__init__.py", + "ismrmrd/xsd/ismrmrdschema/ismrmrd.py" ] - def fix_init_file(self, package_name,filepath): + def fix_init_file(self, package_name, filepath): with open(filepath,'r+') as f: text = f.read() - text = re.sub(f'from {package_name}.ismrmrd', 'from .ismrmrd',text) + text = re.sub(f'from {package_name}.ismrmrd', 'from .ismrmrd', text) f.seek(0) f.write(text) f.truncate() @@ -57,16 +56,15 @@ def fix_init_file(self, package_name,filepath): def generate_schema(self, schema_filename, config_filename, subpackage_name, outdir): import sys import subprocess - # subpackage_name = 'ismrmrdschema' args = [sys.executable, '-m', 'xsdata', 'generate', str(schema_filename), '--config', str(config_filename), '--package', subpackage_name] - subprocess.run(args) + subprocess.run(args, check=True) self.fix_init_file(subpackage_name, f"{subpackage_name}/__init__.py") destination = os.path.join(outdir, subpackage_name) shutil.rmtree(destination, ignore_errors=True) shutil.move(subpackage_name, destination) setup( - version='1.14.3', + version='1.15.0', packages=find_packages(), cmdclass={ 'generate_schema': GenerateSchemaCommand diff --git a/tests/end-to-end/test-reconstruction.sh b/tests/end-to-end/test-reconstruction.sh index 10f6981..d7a9425 100755 --- a/tests/end-to-end/test-reconstruction.sh +++ b/tests/end-to-end/test-reconstruction.sh @@ -2,7 +2,7 @@ set -euo pipefail -ISMRMRD_IMAGE=ghcr.io/ismrmrd/ismrmrd:v1.14.2 +ISMRMRD_IMAGE=ghcr.io/ismrmrd/ismrmrd:v1.15.0 SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}") PROJECT_DIR=$(realpath "${SCRIPT_DIR}/../..") diff --git a/tests/test_acquisition.py b/tests/test_acquisition.py index f1b7512..d027916 100644 --- a/tests/test_acquisition.py +++ b/tests/test_acquisition.py @@ -1,4 +1,5 @@ import ismrmrd +from ismrmrd.util import sign_of_directions, directions_to_quaternion, quaternion_to_directions import ctypes import numpy as np import io @@ -208,3 +209,83 @@ def test_serialization_with_header_fields(): def test_deserialization_from_too_few_bytes(): with pytest.raises(ValueError): ismrmrd.Acquisition.from_bytes(b'') + + +def test_channel_mask(): + acq = ismrmrd.Acquisition() + + # All channels off initially + for ch in range(1024): + assert not acq.isChannelActive(ch) + + # Set and verify individual channels + for ch in [0, 63, 64, 127, 511, 1023]: + acq.setChannelActive(ch) + assert acq.isChannelActive(ch) + + # Clear one channel + acq.setChannelNotActive(63) + assert not acq.isChannelActive(63) + + # setAllChannelsNotActive clears everything + acq.setAllChannelsNotActive() + for ch in [0, 64, 127, 511, 1023]: + assert not acq.isChannelActive(ch) + + +def test_channel_mask_on_header(): + head = ismrmrd.AcquisitionHeader() + + head.setChannelActive(0) + head.setChannelActive(1023) + assert head.isChannelActive(0) + assert head.isChannelActive(1023) + assert not head.isChannelActive(1) + + head.setAllChannelsNotActive() + assert not head.isChannelActive(0) + assert not head.isChannelActive(1023) + + +def test_sign_of_directions_positive(): + # Standard orthonormal basis — determinant +1 + read = [1.0, 0.0, 0.0] + phase = [0.0, 1.0, 0.0] + slice_ = [0.0, 0.0, 1.0] + assert sign_of_directions(read, phase, slice_) == 1 + + +def test_sign_of_directions_negative(): + # Flip one axis — determinant -1 + read = [1.0, 0.0, 0.0] + phase = [0.0, 1.0, 0.0] + slice_ = [0.0, 0.0, -1.0] + assert sign_of_directions(read, phase, slice_) == -1 + + +def test_directions_quaternion_roundtrip(): + # Standard identity rotation + read = np.array([1.0, 0.0, 0.0], dtype=np.float32) + phase = np.array([0.0, 1.0, 0.0], dtype=np.float32) + slice_ = np.array([0.0, 0.0, 1.0], dtype=np.float32) + + quat = directions_to_quaternion(read, phase, slice_) + r2, p2, s2 = quaternion_to_directions(quat) + + assert np.allclose(read, r2, atol=1e-6) + assert np.allclose(phase, p2, atol=1e-6) + assert np.allclose(slice_, s2, atol=1e-6) + + +def test_directions_quaternion_arbitrary_rotation(): + # 90-degree rotation about z: read→y, phase→-x, slice→z + read = np.array([0.0, 1.0, 0.0], dtype=np.float32) + phase = np.array([-1.0, 0.0, 0.0], dtype=np.float32) + slice_ = np.array([0.0, 0.0, 1.0], dtype=np.float32) + + quat = directions_to_quaternion(read, phase, slice_) + r2, p2, s2 = quaternion_to_directions(quat) + + assert np.allclose(read, r2, atol=1e-6) + assert np.allclose(phase, p2, atol=1e-6) + assert np.allclose(slice_, s2, atol=1e-6) diff --git a/tests/test_image.py b/tests/test_image.py index 17ae87e..d60e28c 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -62,13 +62,13 @@ def test_initialization_sets_nonzero_version(): def test_initialization_with_array(): image_data = common.create_random_array((256, 128), dtype=np.float32) image = ismrmrd.Image.from_array(image_data) - assert np.array_equal(image_data.transpose(), image.data.squeeze()) + assert np.array_equal(image_data, image.data.squeeze()) def test_initialization_with_array_and_acquisition(): acquisition = common.create_random_acquisition() image_data = common.create_random_array((256, 128), dtype=np.float32) image = ismrmrd.Image.from_array(image_data, acquisition=acquisition) - assert np.array_equal(image_data.transpose(), image.data.squeeze()) + assert np.array_equal(image_data, image.data.squeeze()) for field in ['version', 'measurement_uid', 'position', 'read_dir', 'phase_dir', 'slice_dir', 'patient_table_position', 'acquisition_time_stamp', 'physiology_time_stamp']: assert bytes(getattr(acquisition, field)) == bytes(getattr(image, field)) @@ -94,21 +94,21 @@ def test_initialization_with_array_and_header_properties(): def test_initialization_with_2d_image(): image_data = common.create_random_array((128, 64), dtype=np.float32) image = ismrmrd.Image.from_array(image_data) - assert np.array_equal(image_data.transpose(), image.data.squeeze()) + assert np.array_equal(image_data, image.data.squeeze()) assert image.channels == 1 - assert image.matrix_size == (1, 64, 128) + assert image.matrix_size == (64, 128, 1) def test_initialization_with_3d_image(): image_data = common.create_random_array((128, 64, 32), dtype=np.float32) image = ismrmrd.Image.from_array(image_data) - assert np.array_equal(image_data.transpose(), image.data.squeeze()) + assert np.array_equal(image_data, image.data.squeeze()) assert image.channels == 1 assert image.matrix_size == (32, 64, 128) def test_initialization_with_3d_image_and_channels(): - image_data = common.create_random_array((128, 64, 32, 16), dtype=np.float32) + image_data = common.create_random_array((16, 128, 64, 32), dtype=np.float32) # (nchannels, z, y, x) image = ismrmrd.Image.from_array(image_data) - assert np.array_equal(image_data.transpose(), image.data.squeeze()) + assert np.array_equal(image_data, image.data.squeeze()) assert image.channels == 16 assert image.matrix_size == (32, 64, 128) diff --git a/tests/test_serialization.py b/tests/test_serialization.py index b7fe0b0..176a4f1 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -6,7 +6,7 @@ from ismrmrd.image import Image, ImageHeader from ismrmrd.meta import Meta from ismrmrd.waveform import Waveform, WaveformHeader -from ismrmrd.serialization import ProtocolSerializer, ProtocolDeserializer +from ismrmrd.serialization import ProtocolSerializer, ProtocolDeserializer, ConfigFile, ConfigText from ismrmrd.xsd import ismrmrdHeader, CreateFromDocument from test_file import example_header @@ -266,4 +266,74 @@ def test_interleaved_serialization(): assert isinstance(obj6, Waveform) assert np.allclose(wf.data, obj6.data) assert wf.number_of_samples == obj6.number_of_samples - assert wf.channels == obj6.channels \ No newline at end of file + assert wf.channels == obj6.channels + + +def test_config_file_serialization(): + config = ConfigFile("path/to/config.xml") + stream = io.BytesIO() + serializer = ProtocolSerializer(stream) + serializer.serialize(config) + serializer.close() + stream.seek(0) + deserializer = ProtocolDeserializer(stream) + objects = list(deserializer.deserialize()) + assert len(objects) == 1 + assert isinstance(objects[0], ConfigFile) + assert objects[0] == config + + +def test_config_text_serialization(): + config = ConfigText("128000000") + stream = io.BytesIO() + serializer = ProtocolSerializer(stream) + serializer.serialize(config) + serializer.close() + stream.seek(0) + deserializer = ProtocolDeserializer(stream) + objects = list(deserializer.deserialize()) + assert len(objects) == 1 + assert isinstance(objects[0], ConfigText) + assert objects[0] == config + + +def test_peek_acquisition(): + from ismrmrd.serialization import ISMRMRDMessageID + acq = common.create_random_acquisition() + stream = io.BytesIO() + with ProtocolSerializer(stream) as s: + s.serialize(acq) + stream.seek(0) + d = ProtocolDeserializer(stream) + assert d.peek() == ISMRMRDMessageID.ACQUISITION + assert d.peek() == ISMRMRDMessageID.ACQUISITION # idempotent + objects = list(d.deserialize()) + assert len(objects) == 1 + assert isinstance(objects[0], Acquisition) + + +def test_peek_image_data_type(): + from ismrmrd.serialization import ISMRMRDMessageID + img = common.create_random_image() + stream = io.BytesIO() + with ProtocolSerializer(stream) as s: + s.serialize(img) + stream.seek(0) + d = ProtocolDeserializer(stream) + assert d.peek() == ISMRMRDMessageID.IMAGE + assert d.peek_image_data_type() == img.data_type + objects = list(d.deserialize()) + assert len(objects) == 1 + assert np.allclose(img.data, objects[0].data) + + +def test_peek_close(): + from ismrmrd.serialization import ISMRMRDMessageID + stream = io.BytesIO() + with ProtocolSerializer(stream) as s: + pass # writes only CLOSE + stream.seek(0) + d = ProtocolDeserializer(stream) + assert d.peek() == ISMRMRDMessageID.CLOSE + objects = list(d.deserialize()) + assert objects == [] \ No newline at end of file diff --git a/tests/test_xsd.py b/tests/test_xsd.py new file mode 100644 index 0000000..5273730 --- /dev/null +++ b/tests/test_xsd.py @@ -0,0 +1,53 @@ +import ismrmrd.xsd +from ismrmrd.xsd.ismrmrdschema import ( + ismrmrdHeader, + experimentalConditionsType, + encodingType, + encodingSpaceType, + matrixSizeType, + fieldOfViewMm, + encodingLimitsType, + trajectoryType, +) + + +def make_encoding_space(x, y, z, fov_x, fov_y, fov_z): + return encodingSpaceType( + matrixSize=matrixSizeType(x=x, y=y, z=z), + fieldOfView_mm=fieldOfViewMm(x=fov_x, y=fov_y, z=fov_z), + ) + + +def make_header(): + return ismrmrdHeader( + experimentalConditions=experimentalConditionsType( + H1resonanceFrequency_Hz=128000000, + ), + encoding=[ + encodingType( + encodedSpace=make_encoding_space(256, 256, 1, 300.0, 300.0, 6.0), + reconSpace=make_encoding_space(256, 256, 1, 300.0, 300.0, 6.0), + encodingLimits=encodingLimitsType(), + trajectory=trajectoryType.CARTESIAN, + ) + ], + ) + + +def test_construct_header(): + """Constructing ismrmrdHeader and its sub-types using required keyword args should not raise.""" + header = make_header() + assert header.experimentalConditions.H1resonanceFrequency_Hz == 128000000 + assert len(header.encoding) == 1 + enc = header.encoding[0] + assert enc.trajectory == trajectoryType.CARTESIAN + assert enc.encodedSpace.matrixSize.x == 256 + assert enc.encodedSpace.fieldOfView_mm.z == 6.0 + + +def test_header_roundtrip(): + """A header built from constructors should survive a ToXML -> CreateFromDocument round-trip.""" + header = make_header() + xml = ismrmrd.xsd.ToXML(header) + reparsed = ismrmrd.xsd.CreateFromDocument(xml) + assert ismrmrd.xsd.ToXML(reparsed) == xml