Skip to content

ASL CBF Quantification Tutorial

Quantify Cerebral Blood Flow (CBF) from Arterial Spin Labeling (ASL) MRI data: label/control differencing, M0 calibration, single- and multi-PLD quantification.

Prerequisites

  • Completed Getting Started tutorial
  • ASL data with known acquisition parameters
  • M0 calibration scan (recommended)

Using the CLI? Generate a config with osipy --dump-defaults asl > config.yaml, edit it, then run osipy config.yaml data.nii.gz. See How to Run Pipeline from YAML. The tutorial below covers the Python API for step-by-step control.

Background

Arterial Spin Labeling is a non-contrast MRI technique that magnetically labels arterial blood water as an endogenous tracer. The CBF is calculated from the difference between labeled and control images.

Key parameters:

Parameter Symbol Units Description
Cerebral Blood Flow CBF ml/100g/min Tissue perfusion rate
Arterial Transit Time ATT ms Time for blood to reach tissue
Labeling duration τ seconds Duration of labeling pulse
Post-labeling delay PLD seconds Wait time after labeling

For theory details, see Understanding ASL Physics.

Step 1: Load ASL Data

Load ASL and M0 calibration data

import numpy as np
import osipy

# Load 4D ASL data (alternating label/control)
asl_dataset = osipy.load_nifti("asl_data.nii.gz")

# Load M0 calibration image
m0_dataset = osipy.load_nifti("m0_calibration.nii.gz")

print(f"ASL data shape: {asl_dataset.shape}")  # (x, y, z, volumes)
print(f"M0 data shape: {m0_dataset.shape}")    # (x, y, z) or (x, y, z, 1)

# Determine number of label/control pairs
# Access .data for the raw numpy array
n_volumes = asl_dataset.shape[-1]
n_pairs = n_volumes // 2
print(f"Label/control pairs: {n_pairs}")

Data Ordering

ASL data is typically organized as:

  • Control-Label: Volumes alternate starting with control (C, L, C, L, ...)
  • Label-Control: Volumes alternate starting with label (L, C, L, C, ...)

Check your scanner's convention in the DICOM headers or BIDS sidecar.

Step 2: Compute Difference Images

Compute label-control difference images

# Separate label and control images
# Assuming control-label ordering (even=control, odd=label)
control_idx = np.arange(0, n_volumes, 2)
label_idx = np.arange(1, n_volumes, 2)

control_images = asl_dataset.data[..., control_idx]
label_images = asl_dataset.data[..., label_idx]

# Compute mean difference
delta_m = np.mean(control_images - label_images, axis=-1)

# Also compute mean control for M0 estimation if needed
mean_control = np.mean(control_images, axis=-1)

print(f"ΔM shape: {delta_m.shape}")
print(f"ΔM range: {delta_m.min():.1f} to {delta_m.max():.1f}")

Sign Convention

The difference should be Control - Label (positive ΔM indicates perfusion). If your ΔM values are predominantly negative, swap the order.

Step 3: Configure Labeling Scheme

Configure pCASL acquisition parameters

from osipy.asl import ASLQuantificationParams, LabelingScheme

# Configure pCASL (pseudo-continuous ASL) quantification parameters
quant_params = ASLQuantificationParams(
    labeling_scheme=LabelingScheme.PCASL,
    label_duration=1800.0,        # ms
    pld=1800.0,                   # ms
    labeling_efficiency=0.85,
    t1_blood=1650.0,              # ms at 3T
    partition_coefficient=0.9,    # λ (ml/g)
)

print(f"Labeling type: {quant_params.labeling_scheme.value}")
print(f"τ = {quant_params.label_duration} ms")
print(f"PLD = {quant_params.pld} ms")

Labeling Type Parameters

The three main labeling types are pulsed ASL (PASL), continuous ASL (CASL), and pseudo-continuous ASL (pCASL):

Parameter PASL CASL pCASL
labeling_duration TI₁ τ τ
labeling_efficiency 0.95-0.98 0.68-0.73 0.80-0.90
Typical PLD 0.8-1.2 s 1.5-2.0 s 1.5-2.0 s

Step 4: Perform M0 Calibration

Apply M0 calibration

The M0 image provides the equilibrium magnetization needed for absolute CBF quantification:

from osipy.asl import M0CalibrationParams

# Apply M0 calibration
# M0 should have long TR (>5s) for full relaxation
m0_params = M0CalibrationParams(
    method="voxelwise",
    t1_tissue=1330.0,       # T1 of gray matter (ms)
    tr_m0=6000.0,           # TR of M0 scan (ms)
)

# apply_m0_calibration takes (asl_data, m0_image, params) and returns a tuple
calibrated_data, m0_corrected = osipy.apply_m0_calibration(
    asl_data=delta_m,
    m0_image=m0_dataset.data,
    params=m0_params,
)

# Create brain mask from corrected M0
m0_threshold = np.percentile(m0_corrected[m0_corrected > 0], 10)
brain_mask = m0_corrected > m0_threshold

print(f"M0 range: {m0_corrected[brain_mask].min():.0f} - {m0_corrected[brain_mask].max():.0f}")
print(f"Brain voxels: {brain_mask.sum()}")

No M0 Scan?

If you don't have a dedicated M0 scan, you can use the mean control image as a proxy, but this reduces quantification accuracy:

m0_proxy = mean_control

Step 5: Quantify CBF

Quantify CBF using the ASL kinetic model

# Quantify CBF
# quantify_cbf returns an ASLQuantificationResult object
cbf_result = osipy.quantify_cbf(
    delta_m=delta_m,
    m0=m0_corrected,
    params=quant_params,
    mask=brain_mask,
)

# Access the CBF map (a ParameterMap with .values attribute)
cbf_map = cbf_result.cbf_map.values

# Check CBF values
valid_cbf = cbf_map[brain_mask]
print(f"\nCBF Statistics (ml/100g/min):")
print(f"  Mean: {valid_cbf.mean():.1f}")
print(f"  Std:  {valid_cbf.std():.1f}")
print(f"  Range: {valid_cbf.min():.1f} - {valid_cbf.max():.1f}")

Expected CBF Values

Tissue CBF (ml/100g/min)
Gray matter 50-80
White matter 20-30
Whole brain average 40-60
Tumor (enhancing) 50-150+

Physiological Plausibility

CBF values outside 0-200 ml/100g/min typically indicate:

  • Incorrect labeling parameters
  • M0 calibration issues
  • Motion artifacts
  • Partial volume effects

Step 6: Multi-PLD Analysis (Optional)

Estimate CBF and ATT from multi-PLD data

# For multi-PLD data
plds = np.array([500, 1000, 1500, 2000, 2500, 3000])  # milliseconds

# Load multi-PLD data (shape: x, y, z, n_plds)
# Each PLD should have averaged label/control pairs
multi_pld_data = osipy.load_nifti("asl_multi_pld.nii.gz")

# Quantify CBF and ATT
from osipy.asl.quantification import MultiPLDParams, quantify_multi_pld

multi_pld_params = MultiPLDParams(
    plds=plds,
    labeling_scheme=LabelingScheme.PCASL,
    labeling_efficiency=0.85,
    label_duration=1800.0,  # ms
)

result = quantify_multi_pld(
    delta_m=multi_pld_data.data,
    m0=m0_corrected,
    params=multi_pld_params,
    mask=brain_mask,
)

# result is a MultiPLDResult object with ParameterMap attributes
cbf_map = result.cbf_map.values
att_map = result.att_map.values
r_squared = result.r_squared

print(f"\nMulti-PLD Results:")
print(f"  CBF mean: {cbf_map[brain_mask].mean():.1f} ml/100g/min")
print(f"  ATT mean: {att_map[brain_mask].mean():.2f} ms")
print(f"  R² mean:  {r_squared[brain_mask].mean():.3f}")

Multi-PLD Benefits

  • More accurate CBF quantification
  • Arterial transit time maps
  • Reduced sensitivity to timing assumptions
  • Better partial volume correction

Step 7: Visualize Results

Visualize CBF maps and quality metrics

import matplotlib.pyplot as plt

# Select axial slice
slice_idx = delta_m.shape[2] // 2

fig, axes = plt.subplots(2, 3, figsize=(12, 8))

# Row 1: Input data
im0 = axes[0, 0].imshow(mean_control[:, :, slice_idx], cmap='gray')
axes[0, 0].set_title('Mean Control')
plt.colorbar(im0, ax=axes[0, 0])

im1 = axes[0, 1].imshow(delta_m[:, :, slice_idx], cmap='bwr', vmin=-50, vmax=50)
axes[0, 1].set_title('ΔM (a.u.)')
plt.colorbar(im1, ax=axes[0, 1])

im2 = axes[0, 2].imshow(m0_corrected[:, :, slice_idx], cmap='gray')
axes[0, 2].set_title('M0')
plt.colorbar(im2, ax=axes[0, 2])

# Row 2: Results
im3 = axes[1, 0].imshow(cbf_map[:, :, slice_idx], cmap='hot', vmin=0, vmax=100)
axes[1, 0].set_title('CBF (ml/100g/min)')
plt.colorbar(im3, ax=axes[1, 0])

im4 = axes[1, 1].imshow(brain_mask[:, :, slice_idx], cmap='binary')
axes[1, 1].set_title('Brain Mask')
plt.colorbar(im4, ax=axes[1, 1])

# Histogram
axes[1, 2].hist(valid_cbf, bins=50, color='steelblue', edgecolor='white')
axes[1, 2].axvline(valid_cbf.mean(), color='red', linestyle='--', label=f'Mean: {valid_cbf.mean():.1f}')
axes[1, 2].set_xlabel('CBF (ml/100g/min)')
axes[1, 2].set_ylabel('Voxel Count')
axes[1, 2].set_title('CBF Distribution')
axes[1, 2].legend()

for ax in axes.flat[:5]:
    ax.axis('off')
axes[1, 2].axis('on')

plt.tight_layout()
plt.savefig('asl_cbf_results.png', dpi=300, bbox_inches='tight')
plt.show()

Multi-slice Montage

Create a CBF montage across slices

# Create CBF montage
n_slices = cbf_map.shape[2]
n_cols = 6
n_rows = (n_slices + n_cols - 1) // n_cols

fig, axes = plt.subplots(n_rows, n_cols, figsize=(2*n_cols, 2*n_rows))
axes = axes.flatten()

for i in range(n_slices):
    axes[i].imshow(cbf_map[:, :, i], cmap='hot', vmin=0, vmax=100)
    axes[i].axis('off')
    axes[i].set_title(f'z={i}', fontsize=8)

# Hide unused axes
for i in range(n_slices, len(axes)):
    axes[i].axis('off')

fig.suptitle('CBF Maps (ml/100g/min)', y=1.02)
plt.tight_layout()
plt.savefig('asl_cbf_montage.png', dpi=200)
plt.show()

Step 8: Export Results

Save results in BIDS format:

Experimental Feature

BIDS derivative export is partially implemented and may not produce fully compliant output for all use cases.

Export to BIDS derivatives

# Export to BIDS derivatives
# export_bids signature: (parameter_maps, output_dir, subject_id, session_id, metadata)
osipy.export_bids(
    parameter_maps={"CBF": cbf_result.cbf_map},
    output_dir="derivatives/osipy",
    subject_id="01",
    session_id="01",
    metadata={
        "labeling_type": quant_params.labeling_scheme.value,
        "label_duration": quant_params.label_duration,
        "pld": quant_params.pld,
        "labeling_efficiency": quant_params.labeling_efficiency,
    },
)

print("Results exported to derivatives/osipy/")

Complete Example

Complete ASL CBF quantification workflow

import numpy as np
import osipy
from osipy.asl import ASLQuantificationParams, LabelingScheme, M0CalibrationParams

# 1. Load data (returns PerfusionDataset)
asl_dataset = osipy.load_nifti("asl_data.nii.gz")
m0_dataset = osipy.load_nifti("m0_calibration.nii.gz")

# 2. Compute difference images (use .data for raw array)
n_volumes = asl_dataset.shape[-1]
control_images = asl_dataset.data[..., 0::2]
label_images = asl_dataset.data[..., 1::2]
delta_m = np.mean(control_images - label_images, axis=-1)

# 3. Configure quantification parameters
quant_params = ASLQuantificationParams(
    labeling_scheme=LabelingScheme.PCASL,
    label_duration=1800.0,      # ms
    pld=1800.0,                 # ms
    labeling_efficiency=0.85,
    t1_blood=1650.0,            # ms at 3T
    partition_coefficient=0.9,
)

# 4. M0 calibration
calibrated_data, m0_corrected = osipy.apply_m0_calibration(delta_m, m0_dataset.data)
brain_mask = m0_corrected > np.percentile(m0_corrected[m0_corrected > 0], 10)

# 5. Quantify CBF
cbf_result = osipy.quantify_cbf(delta_m, m0_corrected, quant_params, mask=brain_mask)
cbf_map = cbf_result.cbf_map.values

# 6. Export (export_bids expects dict[str, ParameterMap])
osipy.export_bids({"CBF": cbf_result.cbf_map}, "derivatives/osipy", "01", "01")

print(f"CBF mean: {cbf_map[brain_mask].mean():.1f} ml/100g/min")

Next Steps

Troubleshooting

Negative or Zero CBF

  • Check label/control ordering (try swapping)
  • Verify M0 values are positive
  • Check brain mask coverage

CBF Too High (>200 ml/100g/min)

  • Verify labeling efficiency (may be lower than expected)
  • Check M0 TR (should be >5s for full relaxation)
  • Look for motion artifacts

Patchy CBF Maps

  • Increase averaging (more label/control pairs)
  • Check for susceptibility artifacts near sinuses
  • Consider partial volume correction