Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "ismrmrd-python conda",
"image": "condaforge/miniforge3:24.11.3-0",
"postCreateCommand": "conda install -y conda-build anaconda-client"
}
11 changes: 11 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
version: 2
updates:
- package-ecosystem: pip
directory: /
schedule:
interval: monthly

- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
18 changes: 13 additions & 5 deletions .github/workflows/ismrmrd_python_conda.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,33 @@ on:

jobs:
build-conda-packages:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: conda-incubator/setup-miniconda@v3
with:
miniforge-version: latest
activate-environment: ismrmrd-python-build
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')
Expand Down
48 changes: 38 additions & 10 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,55 @@ on:
workflow_dispatch:

jobs:
build:
name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
test:
name: Test (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 and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
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:
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ dist/
*.egg-info
xsd.py
.eggs
.idea
.idea
.venv/
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 0 additions & 3 deletions conda/conda_build_config.yaml

This file was deleted.

10 changes: 6 additions & 4 deletions conda/meta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions ismrmrd/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
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
Expand Down
6 changes: 3 additions & 3 deletions ismrmrd/acquisition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions ismrmrd/flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,27 @@ 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.
"""

def isChannelActive(self, channel_id):
word, bit = divmod(channel_id, 64)
return bool(self.channel_mask[word] & (1 << bit))

def setChannelActive(self, channel_id):
word, bit = divmod(channel_id, 64)
self.channel_mask[word] |= (1 << bit)

def setChannelNotActive(self, channel_id):
Comment thread
naegelejd marked this conversation as resolved.
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

55 changes: 40 additions & 15 deletions ismrmrd/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand All @@ -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 = {
Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading