How to Load Perfusion Data¶
Load perfusion MRI data from different vendors and formats.
Quick Start¶
osipy exposes three direct loaders — pick the one that matches your input:
from osipy.common.io import (
discover_dicom,
load_dicom_series,
load_nifti,
load_bids,
)
from osipy.common.types import Modality
# NIfTI (optionally with an adjacent BIDS sidecar JSON)
dataset = load_nifti("path/to/dce.nii.gz", modality=Modality.DCE)
# DICOM — two-step: observe, then load.
series = discover_dicom("path/to/dicom_dir")
dataset = load_dicom_series(series[0], modality=Modality.DCE)
# BIDS
dataset = load_bids("path/to/bids_dataset", subject="01", modality=Modality.ASL)
Which Function to Use¶
| Format | Extension | Loader | Notes |
|---|---|---|---|
| NIfTI | .nii, .nii.gz |
load_nifti() |
Auto-reads adjacent JSON sidecar |
| DICOM | .dcm, .IMA, enhanced |
discover_dicom() + load_dicom_series() |
Two-step for transparency |
| BIDS | Directory | load_bids(), load_bids_with_m0(), load_asl_context() |
Full metadata from sidecars |
DICOM: Discover, Then Load¶
DICOM loading is split into two stateless primitives so you can see exactly what was found before any pixels are read:
from osipy.common.io import discover_dicom, load_dicom_series
series = discover_dicom("path/to/dicom_dir")
for s in series:
print(
f"{s.description!r:30s} "
f"role={s.role_hint:14s} "
f"shape={s.rows}x{s.columns} "
f"tpi={s.n_temporal_positions} "
f"FA={s.flip_angle}"
)
discover_dicom() walks the directory, groups files by
SeriesInstanceUID, and annotates each series with a best-effort
role_hint from headers alone. No pixel data is touched at this stage.
The role hints are:
| Hint | Meaning |
|---|---|
dynamic |
A single series containing all timepoints (indexed by TemporalPositionIdentifier) — load it directly, load_dicom_series returns a 4D volume. |
dynamic_frame |
A single 3D volume that represents one timepoint of a dynamic acquisition exported one-series-per-frame (e.g. Siemens TWIST TT=X.Xs). Collect every frame that shares a group_key into a Python list and pass that list to load_dicom_series() — it stacks the per-timepoint volumes into one 4D dataset. |
vfa |
Single flip-angle structural, part of a VFA T1 mapping set. |
t1_look_locker |
Look-Locker T1 mapping series. |
unknown |
Anything else (structurals, localizers, non-image objects). |
Role hints are hints. You can select by any SeriesInfo attribute:
# Load the first dynamic series
dyn = next(s for s in series if s.role_hint == "dynamic")
dataset = load_dicom_series(dyn, modality=Modality.DCE)
# Per-timepoint export: each dynamic frame is its own 3D series.
# Collect them into a list and load_dicom_series stacks them into 4D.
frames = [s for s in series if s.role_hint == "dynamic_frame"]
dataset = load_dicom_series(frames, modality=Modality.DCE)
# Or grab by SeriesInstanceUID
target = next(s for s in series if s.uid == "1.2.840.113619.…")
dataset = load_dicom_series(target, modality=Modality.DCE)
load_dicom_series() accepts either a single SeriesInfo (returns a
3D or 4D PerfusionDataset depending on the TPI count) or a list of
per-timepoint SeriesInfo (stacks into a 4D volume with a derived
time vector).
Loading by Modality¶
DCE-MRI¶
from osipy.common.io import discover_dicom, load_dicom_series, load_nifti
from osipy.common.types import Modality
# From NIfTI (sidecar is picked up automatically when adjacent)
dataset = load_nifti("data/sub-01_dce.nii.gz", modality=Modality.DCE)
# From DICOM
series = discover_dicom("data/dicom/dce_series/")
dataset = load_dicom_series(
next(s for s in series if s.role_hint == "dynamic"),
modality=Modality.DCE,
)
print(f"Shape: {dataset.shape}") # (x, y, z, t)
print(f"TR: {dataset.acquisition_params.tr} ms")
print(f"Flip angle: {dataset.acquisition_params.flip_angle}")
Required Metadata: - TR (RepetitionTime) - Flip angle (FlipAngle)
Vendor Differences:
- GE: TR in RepetitionTime tag (0018,0080)
- Siemens: TR in standard tag; may also expose CSA header
- Philips: Quantitative rescale lives in private tags (2005,100D/100E)
DSC-MRI¶
from osipy.common.io import load_nifti
from osipy.common.types import Modality
dataset = load_nifti("data/dsc_data.nii.gz", modality=Modality.DSC)
print(f"TE: {dataset.acquisition_params.te} ms")
Required Metadata: - TE (EchoTime) - TR (RepetitionTime)
ASL¶
from osipy.common.io import load_bids, load_bids_with_m0, load_asl_context
from osipy.common.types import Modality
# Load ASL with M0 calibration
asl_data, m0_data = load_bids_with_m0("data/bids_dataset/", subject="01")
# Load ASL context (control/label order)
context = load_asl_context("data/bids_dataset/", subject="01")
print(f"Volume types: {set(context)}") # {'control', 'label'}
params = asl_data.acquisition_params
print(f"Labeling type: {params.labeling_type}")
print(f"PLD: {params.pld} ms")
print(f"Labeling duration: {params.labeling_duration} ms")
Required BIDS metadata (per BIDS ASL appendix):
For all ASL types:
- ArterialSpinLabelingType (PCASL, PASL, or CASL)
- PostLabelingDelay
For PCASL / CASL, additionally:
- LabelingDuration
For PASL, additionally (instead of LabelingDuration):
- BolusCutOffFlag
- when BolusCutOffFlag is true: BolusCutOffTechnique and BolusCutOffDelayTime
ASL-BIDS Files:
- *_asl.nii.gz — ASL timeseries
- *_asl.json — Sidecar with parameters
- *_aslcontext.tsv — Volume types (control, label, m0scan, deltam)
- *_m0scan.nii.gz — Separate M0 calibration (optional)
IVIM¶
from osipy.common.io import load_nifti
from osipy.common.types import Modality
dataset = load_nifti("data/dwi_ivim.nii.gz", modality=Modality.IVIM)
print(f"B-values: {dataset.acquisition_params.b_values}")
Required Metadata:
- B-values, provided in an FSL-format *_dwi.bval sibling file (not in
the JSON sidecar). osipy also accepts a non-standard DiffusionBValue
or bValues JSON key for flexibility, but BIDS-compliant writers
must use .bval.
Vendor-Specific Considerations¶
GE Medical Systems¶
DICOM Characteristics:
- Manufacturer tag: "GE MEDICAL SYSTEMS"
- Private groups: 0027, 0043
- B-values: Often in private tag (0043,1039). This is a multi-valued
element; the b-value is in the first component and may be encoded
with a vendor-specific offset (e.g. b + 10⁶ or b + 10⁹). Decoding
typically requires value[0] % 100000 or equivalent; see
dcm2niix issue #149.
- ASL: 3D spiral pCASL product sequence common
Extract GE vendor metadata:
import pydicom
from osipy.common.io.vendors import detect_vendor
from osipy.common.io.vendors.detection import extract_vendor_metadata
ds = pydicom.dcmread("ge_dicom.dcm")
vendor = detect_vendor(ds) # "GE"
metadata = extract_vendor_metadata(ds)
print(f"Vendor: {metadata.vendor}")
print(f"TR: {metadata.tr} ms")
Siemens¶
DICOM Characteristics: - Manufacturer tag: "SIEMENS" - Private groups: 0019, 0021, 0051 - CSA headers contain extended sequence information - B-values: CSA header (Numaris VB/VD/VE) or private tag (0019,100C) on XA (where CSA is absent) — see the dcm2niix Siemens README for per-version details. - ASL: WIP sequences common, PASL and pCASL available
Per-timepoint exports: Siemens TWIST/TWIST-VIBE typically export
one 3D series per timepoint with a TT=X.Xs suffix on the
SeriesDescription. discover_dicom() detects this pattern and tags
each frame as dynamic_frame with a shared group_key. Collect every
frame from the same group_key into a list and pass that list to
load_dicom_series() — it sorts by the embedded timing and stacks the
separate volumes into one 4D dataset.
Philips¶
DICOM Characteristics: - Manufacturer tag: "Philips Medical Systems" or "Philips" - Private groups: 2001, 2005 - Enhanced DICOM format common - B-values: Private tag (2001,1003) - ASL: 2D EPI pCASL product sequence
Pixel scaling: Philips stores a private quantitative-rescale pair
ScaleSlope (2005,100E) and ScaleIntercept (2005,100D) alongside the
standard RescaleSlope (0028,1053) / RescaleIntercept (0028,1052).
The canonical recovery of the floating-point value FP from the stored
value PV is (Chenevert et al., MAGMA 2014, PMC3998685; dcm2niix
Philips README):
where RI, RS are the standard DICOM rescale tags and SS_private is
(2005,100E). load_dicom_series() currently applies a simplified
single-stage (PV − SI_private) / SS_private which is correct when
RescaleSlope = 1 and RescaleIntercept = 0 (the common case) but may
diverge for scans where the standard rescale is non-trivial. See
GitHub issue tracker if you observe a
scale offset with Philips data.
Data Structures¶
PerfusionDataset¶
All loading functions return a PerfusionDataset:
@dataclass
class PerfusionDataset:
data: NDArray[np.floating] # Image data (3D or 4D)
affine: NDArray[np.floating] # 4x4 voxel-to-world transform
modality: Modality # DCE, DSC, ASL, or IVIM
time_points: NDArray | None # Time points for 4D data
acquisition_params: AcquisitionParams # Modality-specific params
source_path: Path # Original file path
source_format: str # "nifti", "dicom", or "bids"
Acquisition Parameters¶
# DCE
@dataclass
class DCEAcquisitionParams:
tr: float
te: float | None
flip_angles: list[float]
temporal_resolution: float | None
# ASL
@dataclass
class ASLAcquisitionParams:
labeling_type: LabelingType
pld: float | list[float]
labeling_duration: float
background_suppression: bool
m0_scale: float | None
# IVIM
@dataclass
class IVIMAcquisitionParams:
b_values: NDArray[np.floating]
tr: float | None
te: float | None
Metadata Priority¶
When loading data, metadata is resolved in this priority order:
- Explicit
acquisition_params=argument (highest priority) - BIDS sidecar JSON
- Vendor-specific DICOM tags
- Standard DICOM tags
- Default values (lowest priority)
Loading DICOM via the CLI¶
The osipy CLI takes the same discovery + load path internally. Set
data.format to dicom (or auto for automatic detection):
modality: dce
data:
format: auto
pipeline:
model: extended_tofts
aif_source: population
population_aif: parker
acquisition:
tr: 5.0
flip_angles: [2, 5, 10, 15]
# Single-series directory
osipy config.yaml /path/to/dicom_series/
# Study directory with per-series subdirs
osipy config.yaml /path/to/study_dir/
# Per-timepoint exports — one 3D series per frame, auto-grouped by SeriesDescription
osipy config.yaml /path/to/twist_study/
For DCE specifically, the CLI additionally extracts VFA single-flip
series (role_hint == "vfa") when present and uses them for T1 mapping
— no manual partitioning required.
BIDS Batch Processing¶
osipy's BIDS reader is a hand-rolled parser over stdlib + nibabel; it
does not depend on pybids. The snippets below use pybids purely
as a convenience for iterating subjects — install it separately
(pip install pybids) if you need it.
from bids import BIDSLayout
import osipy
layout = BIDSLayout("path/to/bids")
for subject in layout.get_subjects():
dce_files = layout.get(subject=subject, suffix="dce", extension="nii.gz")
if not dce_files:
continue
data = osipy.load_nifti(dce_files[0].path)
metadata = dce_files[0].get_metadata()
Multi-PLD ASL from BIDS¶
from bids import BIDSLayout
import numpy as np
layout = BIDSLayout("path/to/bids")
asl_file = layout.get(subject="01", suffix="asl", extension="nii.gz")[0]
metadata = asl_file.get_metadata()
plds = metadata.get("PostLabelingDelay")
if isinstance(plds, list):
plds = np.array(plds)
Common BIDS Fields for Perfusion¶
| Field | Description | Modality |
|---|---|---|
RepetitionTime |
TR in seconds | All |
EchoTime |
TE in seconds | All |
FlipAngle |
Flip angle in degrees | DCE |
ArterialSpinLabelingType |
PASL, CASL, PCASL | ASL |
LabelingDuration |
Label duration in seconds | ASL |
PostLabelingDelay |
PLD in seconds | ASL |
M0Type |
Included, Separate, Estimate, Absent (BIDS 1.8+) |
ASL |
_dwi.bval (sibling file) |
b-values, space-delimited s/mm² (not a JSON field) | IVIM/DWI |
Troubleshooting¶
"Data directory not found"
- Check that subject/session directories exist
- osipy checks both sub-XX/perf/ and sub-XX/ for ASL data
"4D data requires time_points array"
- Ensure TR is specified in sidecar JSON
- Provide RepetitionTime or RepetitionTimePreparation in metadata
discover_dicom returned no series
- The directory contained no DICOM files, or all had unreadable headers
- Enable INFO logging to see the per-file rejection reasons
Per-timepoint frames weren't grouped into one 4D volume
- discover_dicom() groups by (StudyInstanceUID, rows, columns, stripped_description)
- If your exporter doesn't strip the TT=X.Xs suffix, frames may not cluster — inspect SeriesInfo.trigger_time_hint to verify