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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
- 1.3.0
- use the official [`bifti`](https://pypi.org/project/bifti/) package for BIfTI phantoms instead of the internal implementation (#201). `NiftiPhantom`, `NiftiTissue` and `ResliceConfig` are now aliases of `bifti.BiftiPhantom`, `bifti.BiftiTissue` and `bifti.ResliceTo`; `TissueDict.load` uses the `bifti` loader (density-weighted resampling, `patient` position support, complex B1 maps, `func` mappings are parsed instead of `eval`ed)
- `TissueDict.save` uses `bifti.NumpyPhantom.save`: NIfTIs are named `<json name>_<property>.nii.gz` (e.g. `phantom_B1plus_0.nii.gz`) and stored as float64 / complex128; the `.json` no longer contains `reslice_to`
- requires Python >= 3.12 (required by `bifti`)
- 1.2.0
- fix: phantoms with zero size along an axis (2D, single voxel) no longer crash the prepass with `Encountered a NaN while sorting + dists by mag` when simulating normalized-gradient sequences; such axes are not scaled
- add `fid_graph`: builds a graph with only `z0` and one `+` state per repetition, as an alternative to `compute_graph` without running the prepass
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mrzero_core"
version = "1.2.0"
version = "1.3.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
Expand Down
11 changes: 8 additions & 3 deletions documentation/src/api-nifti.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ You can load a complete phantom directly with [`mr0.TissueDict.load`](api-phanto
*But you can also* load only the configuration with [`mr0.NiftiPhantom.load`](#niftiphantom).
This allows you to modify it as needed before loading the phantom or saving the changes.

These classes are provided by the official [`bifti`](https://github.com/mrx-org/bifti-phantoms) package and re-exported by MRzero.
`NiftiPhantom`, `NiftiTissue` and `ResliceConfig` are aliases of `bifti.BiftiPhantom`, `bifti.BiftiTissue` and `bifti.ResliceTo`.
See the [bifti documentation](https://github.com/mrx-org/bifti-phantoms/blob/main/python/bifti/README.md) for the full reference.

All classes are [dataclasses](https://docs.python.org/3/library/dataclasses.html), augmented with additional functions.

## `PhantomUnits`
Expand Down Expand Up @@ -118,16 +122,17 @@ where `Prop = float | NiftiRef | NiftiMapping`
Represents the configuration given by a phantom `.json` file.

```python
NiftiPhantom(units, system, tissues)
NiftiPhantom(units, system, tissues, reslice_to=None, schema=NiftiPhantom.DEFAULT_SCHEMA, patient=None)
```

| Parameter | Description |
| --------- | ----------- |
| `units` | [`PhantomUnits`](#phantomunits) object |
| `system` | [`PhantomSystem`](#phantomsystem) object |
| `tissues` | Dictionary of tissues; key specifies name, values are [`NiftiTissue`](#niftitissue) objects |

The class attribute `schema_uri` is always `"https://github.com/mrx-org/bifti-phantoms/bifti-phantom-v1"` and is not a constructor parameter.
| `reslice_to` | Optional `ResliceConfig` (`affine`, `resolution`): target grid the tissues are resampled to on load |
| `schema` | Value of the `$schema` field; defaults to the schema hosted in the bifti-phantoms repository |
| `patient` | Optional `Patient` (`position`, e.g. `"HFS"`); omitted means `FFS` (no transformation) |

- `NiftiPhantom.default(gyro=42.5764, B0=3)`: return a `NiftiPhantom` without tissues
- `NiftiPhantom.load(path)`: load from `.json` file, given by the path
Expand Down
2 changes: 1 addition & 1 deletion documentation/src/api-phantom.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ TissueDict(dict[str, VoxelGridPhantom])
```

- `TissueDict.load(path, config=None)`: either load from `.json` or from a dir + [`NiftiPhantom`](api-nifti.md#niftiphantom)
- `TissueDict.save(path, gyro, B0)`: save as [NIfTI phantom](nifti.md)
- `TissueDict.save(path, gyro, B0)`: save as [NIfTI phantom](nifti.md) (via `bifti.NumpyPhantom.save`; NIfTIs are named after the `.json`, nearly constant maps are stored as values)
- `TissueDict.interpolate()`: apply [`VoxelGridPhantom.interpolate()] to all tissues
- `TissueDict.slices()`: apply [`VoxelGridPhantom.slices()] to all tissues
- `TissueDict.combine()`: merge all tissues into a single [`VoxelGridPhantom`](#voxelgridphantom)
Expand Down
1 change: 1 addition & 0 deletions documentation/src/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import MRzeroCore as mr0
- [`mr0.NiftiMapping`](api-nifti.md#niftimapping)
- [`mr0.NiftiTissue`](api-nifti.md#niftitissue)
- [`mr0.NiftiPhantom`](api-nifti.md#niftiphantom)
- [`mr0.ResliceConfig`](api-nifti.md#niftiphantom)
- [Simulation](api-simulation.md)
- [`mr0.isochromat_sim`](api-simulation.md#isochromat_sim)
- [`mr0.compute_graph`](api-simulation.md#compute_graph)
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "maturin"

[project]
name = "mrzerocore"
version = "1.2.0"
version = "1.3.0"
description = "Core functionality of MRzero"
authors = [
{name = "Jonathan Endres", email = "jonathan.endres@uk-erlangen.de"},
Expand All @@ -16,8 +16,9 @@ classifiers = [
"Programming Language :: Python :: Implementation :: PyPy",
"License :: OSI Approved :: GNU Affero General Public License v3",
]
requires-python = ">=3.10"
requires-python = ">=3.12"
dependencies = [
"bifti>=0.3.0",
"ismrmrd",
"matplotlib>=3.5",
"pydisseqt>=0.1.13",
Expand Down
5 changes: 4 additions & 1 deletion python/MRzeroCore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
from .phantom.custom_voxel_phantom import CustomVoxelPhantom
from .phantom.sim_data import SimData
from .phantom.brainweb import generate_brainweb_phantoms
from .phantom.nifti_phantom import NiftiPhantom, NiftiTissue, NiftiRef, NiftiMapping, ResliceConfig
from .phantom.nifti_phantom import (
NiftiPhantom, NiftiTissue, NiftiRef, NiftiMapping, ResliceConfig,
BiftiPhantom, BiftiTissue, ResliceTo, Patient, PhantomUnits, PhantomSystem,
)
from .phantom.tissue_dict import TissueDict
from .simulation.isochromat_sim import isochromat_sim
from .simulation.pre_pass import compute_graph, compute_graph_ext, merge_graphs, Graph
Expand Down
250 changes: 17 additions & 233 deletions python/MRzeroCore/phantom/nifti_phantom.py
Original file line number Diff line number Diff line change
@@ -1,233 +1,17 @@
from dataclasses import dataclass, field
from typing import Literal, Any
from pathlib import Path


@dataclass
class PhantomUnits:
gyro: Literal["MHz/T"]
B0: Literal["T"]
T1: Literal["s"]
T2: Literal["s"]
T2dash: Literal["s"]
ADC: Literal["10^-3 mm^2/s"]
dB0: Literal["Hz"]
B1_tx: Literal["rel"]
B1_rx: Literal["rel"]

@classmethod
def default(cls):
return cls(
gyro="MHz/T",
B0="T",
T1="s",
T2="s",
T2dash="s",
ADC="10^-3 mm^2/s",
dB0="Hz",
B1_tx="rel",
B1_rx="rel",
)

@classmethod
def from_dict(cls, config: dict[str, str]):
# Currently this is only for documentation and no other units than the
# default units are supported. This definetly can change in the future
# but the implementation has no priority right now
default = cls.default()
assert default.to_dict() == config, "Only default units are supported for now"
return default

def to_dict(self) -> dict[str, str]:
return {
"gyro": self.gyro,
"B0": self.B0,
"T1": self.T1,
"T2": self.T2,
"T2'": self.T2dash,
"ADC": self.ADC,
"dB0": self.dB0,
"B1+": self.B1_tx,
"B1-": self.B1_rx,
}


@dataclass
class PhantomSystem:
gyro: float
B0: float

@classmethod
def from_dict(cls, config: dict[str, float]):
return cls(**config)

def to_dict(self) -> dict[str, float]:
return {"gyro": self.gyro, "B0": self.B0}


@dataclass
class ResliceConfig:
"""Target resampling grid declared inside the phantom JSON."""
resolution: list # [nx, ny, nz] — output voxel count
affine: list # 3×4 NIfTI-style affine in mm (rotation×voxel_size | origin)

@classmethod
def from_dict(cls, config: dict):
return cls(resolution=config["resolution"], affine=config["affine"])

def to_dict(self) -> dict:
return {"resolution": self.resolution, "affine": self.affine}


@dataclass
class NiftiRef:
file_name: Path
tissue_index: int

@classmethod
def parse(cls, config: str):
import re

regex = re.compile(r"(?P<file>.+?)\[(?P<idx>\d+)\]$")
m = regex.match(config)
if not m:
raise ValueError("Invalid file_ref", m)
return cls(file_name=Path(m.group("file")), tissue_index=int(m.group("idx")))
def to_str(self) -> str:
return f"{self.file_name}[{self.tissue_index}]"


@dataclass
class NiftiMapping:
file: NiftiRef
func: str

@classmethod
def parse(cls, config: dict[str, Any]):
return cls(file=NiftiRef.parse(config["file"]), func=config["func"])

def to_dict(self) -> dict[str, Any]:
return {"file": self.file.to_str(), "func": self.func}


@dataclass
class NiftiTissue:
density: NiftiRef
T1: float | NiftiRef | NiftiMapping
T2: float | NiftiRef | NiftiMapping
T2dash: float | NiftiRef | NiftiMapping
ADC: float | NiftiRef | NiftiMapping
dB0: float | NiftiRef | NiftiMapping
B1_tx: list[float | NiftiRef | NiftiMapping]
B1_rx: list[float | NiftiRef | NiftiMapping]

@classmethod
def default(cls, density: NiftiRef):
return cls.from_dict({"density": density})

@classmethod
def from_dict(cls, config: dict[str, Any]):
def parse_prop(prop):
if isinstance(prop, (float, int)):
return float(prop)
elif isinstance(prop, str):
return NiftiRef.parse(prop)
else:
return NiftiMapping.parse(prop)

return cls(
density=NiftiRef.parse(config["density"]),
T1=parse_prop(config.get("T1", float("inf"))),
T2=parse_prop(config.get("T2", float("inf"))),
T2dash=parse_prop(config.get("T2'", float("inf"))),
ADC=parse_prop(config.get("ADC", 0.0)),
dB0=parse_prop(config.get("dB0", 0.0)),
B1_tx=[parse_prop(ch) for ch in config.get("B1+", [1.0])],
B1_rx=[parse_prop(ch) for ch in config.get("B1-", [1.0])],
)

def to_dict(self) -> dict:
def serialize_prop(prop):
if isinstance(prop, (float, int)):
return prop
elif isinstance(prop, NiftiRef):
return prop.to_str()
elif isinstance(prop, NiftiMapping):
return prop.to_dict()
else:
raise ValueError("Unsupported property type", type(prop))

return {
"density": self.density.to_str(),
"T1": serialize_prop(self.T1),
"T2": serialize_prop(self.T2),
"T2'": serialize_prop(self.T2dash),
"ADC": serialize_prop(self.ADC),
"dB0": serialize_prop(self.dB0),
"B1+": [serialize_prop(ch) for ch in self.B1_tx],
"B1-": [serialize_prop(ch) for ch in self.B1_rx],
}


@dataclass
class NiftiPhantom:
schema_uri = "https://github.com/mrx-org/bifti-phantoms/bifti-phantom-v1"
units: PhantomUnits
system: PhantomSystem
tissues: dict[str, NiftiTissue]
reslice_to: ResliceConfig | None = field(default=None)

@classmethod
def default(cls, gyro=42.5764, B0=3.0):
return cls(PhantomUnits.default(), PhantomSystem(gyro, B0), {})

@classmethod
def load(cls, path: Path | str):
import json

with open(path, "r") as f:
config = json.load(f)
return cls.from_dict(config)

def save(self, path: Path | str):
import json
import re
import os
path = Path(path)

os.makedirs(path.parent, exist_ok=True)
text = json.dumps(self.to_dict(), indent=2)
# Compact arrays of numbers onto a single line
text = re.sub(
r'\[(\n\s+[-\d.eE+]+,?)+\n\s*\]',
lambda m: '[' + ', '.join(re.findall(r'[-\d.eE+]+', m.group(0))) + ']',
text
)
with open(path, "w") as f:
f.write(text)

@classmethod
def from_dict(cls, config: dict):
units = PhantomUnits.from_dict(config["units"])
system = PhantomSystem.from_dict(config["system"])
tissues = {
name: NiftiTissue.from_dict(tissue)
for name, tissue in config["tissues"].items()
}
reslice_to = (ResliceConfig.from_dict(config["reslice_to"])
if "reslice_to" in config else None)

return cls(units, system, tissues, reslice_to)

def to_dict(self) -> dict:
d = {
"$schema": self.schema_uri,
"units": self.units.to_dict(),
"system": self.system.to_dict(),
"tissues": {
name: tissue.to_dict() for name, tissue in self.tissues.items()
},
}
if self.reslice_to is not None:
d["reslice_to"] = self.reslice_to.to_dict()
return d
# The BIfTI phantom config is implemented by the official `bifti` package.
# This module only re-exports it under the names MRzero used before, so that
# existing code like `mr0.NiftiPhantom.load(...)` keeps working.
from bifti import (
BiftiPhantom,
BiftiTissue,
NiftiMapping,
NiftiRef,
Patient,
PhantomSystem,
PhantomUnits,
ResliceTo,
)

NiftiPhantom = BiftiPhantom
NiftiTissue = BiftiTissue
ResliceConfig = ResliceTo
Loading
Loading