Skip to content
Open
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 pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ requires-python = ">=3.10"
dependencies = [
"attrs >=24.1",
"bidsschematools >=1.1",
"nibabel>=5.1.1",
"orjson>=3.11.3",
"universal_pathlib >=0.2.1",
]
Expand Down Expand Up @@ -145,6 +146,9 @@ inline-quotes = "single"
quote-style = "single"

[dependency-groups]
dev = [
"ipython>=8.37.0",
]
test = [
"pytest >=8",
"pytest-cov >=5",
Expand Down
55 changes: 52 additions & 3 deletions src/bids_validator/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from functools import cache

import attrs
import nibabel as nb
import orjson
from bidsschematools.types import Namespace
from bidsschematools.types import context as ctx
Expand All @@ -18,11 +19,15 @@
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Generator
from pathlib import Path
from typing import TypeVar

from bidsschematools.types import protocols as proto

# pyright does not treat cached_property like property
cached_property = property

ImgT = TypeVar('ImgT', bound=nb.FileBasedImage)
else:
from functools import cached_property

Expand Down Expand Up @@ -87,6 +92,49 @@ def load_json(file: FileTree) -> dict[str]:
return orjson.loads(file.path_obj.read_bytes())


def load_image(path: Path, api: type[ImgT]) -> ImgT:
"""Load neuroimaging file with a given nibabel API."""
img = nb.load(path)
if not isinstance(img, api):
raise ValueError(f'Expected image of type {api}, got {type(img)}')
return img


def load_nifti_header(file: FileTree) -> ctx.NiftiHeader:
"""Load NIfTI header contents."""
img = load_image(file.path_obj, nb.Nifti1Image)

# Nibabel uses None,0,1,2, BIDS context uses 1,2,3 with 0 as absent
freq, phase, slc = (dim + 1 if dim is not None else 0 for dim in img.header.get_dim_info())
dim_info = ctx.DimInfo(freq, phase, slc)

xyz, t = img.header.get_xyzt_units()
if t not in ('unknown', 'sec', 'msec', 'usec'):
# The NIfTI standard allows for 'Hz', 'ppm' and 'rads', but BIDS currently
# considers temporal units.
t = 'unknown'
xyzt_units = ctx.XYZTUnits(xyz, t)

nifti_header = ctx.NiftiHeader(
dim_info=dim_info,
dim=img.header['dim'],
pixdim=img.header['pixdim'],
shape=img.shape,
voxel_size=img.header.get_zooms(),
xyzt_units=xyzt_units,
qform_code=int(img.header['qform_code']),
sform_code=int(img.header['sform_code']),
axis_codes=nb.aff2axcodes(img.affine),
)
try:
mrs_header = next(ext for ext in img.header.extensions if ext.get_code() == 44)
nifti_header.mrs = mrs_header.json()
except StopIteration:
pass

return nifti_header


class Subjects:
"""Collections of subjects in the dataset."""

Expand Down Expand Up @@ -400,10 +448,11 @@ def gzip(self) -> None:
"""Parsed contents of gzip header."""
pass

@property
def nifti_header(self) -> None:
@cached_property
def nifti_header(self) -> ctx.NiftiHeader | None:
"""Parsed contents of NIfTI header referenced elsewhere in schema."""
pass
if self.extension in ('.nii', '.nii.gz'):
return load_nifti_header(self.file)

@property
def ome(self) -> None:
Expand Down
Loading
Loading