Skip to content

Perfusion (DSC-MRI)

pydcm.dsc recovers cerebral perfusion from a dynamic-susceptibility-contrast MRI series — the bolus of contrast transiently drops the T2*-weighted signal, and deconvolving the tissue concentration against an arterial input gives per-voxel CBF, CBV, MTT, Tmax and TTP maps. The deconvolution runs in the native engine; this is a thin NumPy surface over it.

Units are seconds for time and MTT/Tmax; CBF/CBV are relative (calibration-free) — multiply by your absolute scaling (k·ρ·(1−Hct), and the s→min / fraction→mL·100mL⁻¹ factors) if you need physical units.

The arterial input function

DSC has no population AIF — measure one from an arterial ROI (e.g. an MCA voxel cluster). measure_aif averages the ROI signal per frame and converts it to ΔR2*:

from pydcm import dsc
import numpy as np

aif = dsc.measure_aif(series, artery_mask, te_s=0.030)   # ROI signal → ΔR2* AIF

Fit a whole study

fit_series assembles the dynamic series, reads the acquisition times (and the echo time, for the signal → ΔR2* step) off the DICOM tags, and deconvolves every slice — the AIF is factorised once and the slices run across cores:

maps = dsc.fit_series(
    "dsc_study/", aif=aif, method="osvd", input="raw",   # raw T2*-weighted signal
)
maps["cbf"], maps["cbv"], maps["mtt"]    # per-voxel perfusion maps (Z, H, W)
maps["tmax"], maps["ttp"]                # residue peak time / enhancement peak time

Deconvolution methods:

method matrix when
ssvd causal Toeplitz no bolus delay (least peak damping)
csvd block-circulant delay-insensitive — robust to bolus arrival differences
osvd block-circulant, adaptive per-voxel oscillation-index threshold (default)

Pass input="concentration" if your series is already ΔR2*. times_s and te_s are read from the tags when omitted; pass them for a raw array.

Tumour leakage correction

Where the blood–brain barrier is disrupted (tumour), contrast extravasates and biases CBV. Enable the Boxerman–Schmainda correction, which fits each voxel against the whole-brain mean reference and removes the leakage term:

maps = dsc.fit_series("dsc_study/", aif=aif, method="osvd", input="raw",
                      leakage=True, mask=brain_mask)   # mask should cover brain

The reference is the mean over the masked tissue, so mask should cover brain, not just the enhancing lesion.

Fit an in-memory slice or a single curve

# one slice time-course, [T, H, W] (time first)
maps = dsc.fit(slice_series, times_s, aif, method="csvd")

# a single tissue curve → parameters + the recovered residue
r = dsc.deconvolve(ct, aif, dt_s=1.5, method="osvd")
r["cbf"], r["cbv"], r["mtt"], r["tmax"], r["residue"]

Single-curve building blocks

When you assemble your own tumour workflow — or want the brain-tumour rCBV (Boxerman/ASFNR) that needs no AIF at all — the pieces the map pipeline uses are exposed per curve:

area  = dsc.cumtrapz(ct, dt_s=1.5)            # running trapezoidal integral, starts at 0
rcbv  = dsc.cbv(ct, ref, dt_s=1.5)            # blood volume as ∫ct / ∫ref
lc    = dsc.leakage_correct(ct, ref, dt_s=1.5)
lc["corrected"], lc["k2"]                     # curve with the leakage term added back, and K2

cbv is just that area ratio — no deconvolution, and dt cancels (it is taken only so the call states its units). What ref is decides the meaning: an AIF gives CBV in the Østergaard/stroke sense (equal to CBF·MTT), a normal-appearing white-matter ROI mean gives rCBV in the brain-tumour sense.

leakage_correct is the Boxerman–Schmainda–Weisskoff fit: it regresses ct ≈ K1·ref − K2·∫ref against a non-leaking reference and returns the curve with the extravasation term added back. The reference must be leakage-free — the whole-brain mean works in vivo (leaking voxels are a small minority), but an unmasked reference is destroyed by air, where the log floor drives ΔR2* to hundreds of s⁻¹; for a phantom or a masked volume pass the NAWM ROI mean.

Choose the first-pass window

Recirculation — the second, lower bump as the bolus comes round again — is not part of the first transit and inflates every area that includes it. first_pass_end reads how many leading samples make up the first pass off the curve's own pre-contrast scatter, so you can integrate the first pass only:

n = dsc.first_pass_end(ct, n_baseline=10)     # count of first-pass samples
if n < 0:
    n = len(ct)                               # inputs can't support a reading → full curve

It takes the ΔR2* concentration curve (not raw signal); n_baseline must cover pre-contrast frames and be at least 2, and k_sigma=0 selects the library default of 4. It returns the whole length when the curve never turns back up, and -1 when the inputs can't support the reading — guard for that.

One window feeds both steps

The count first_pass_end returns must be the same one you pass to leakage_correct and to cbv. K2 is fitted to whatever stretch of curve it is shown, so correcting over one range while integrating over another throws away more than half the benefit of correcting at all.

n = dsc.first_pass_end(ct, n_baseline=10)
if n < 0:
    n = len(ct)
lc   = dsc.leakage_correct(ct[:n], ref[:n], dt_s=1.5)   # same window ...
rcbv = dsc.cbv(lc["corrected"], ref[:n], dt_s=1.5)      # ... in both

Write parameter maps as DICOM

dsc.write_param_maps("dsc_study/", maps,
                     params=("cbf", "cbv", "mtt", "tmax", "ttp"),
                     output_dir="./paramaps")   # one DICOM Parametric Map per parameter

Scope

sSVD / cSVD / oSVD deconvolution with a measured AIF, ΔR2* signal conversion, CBF / CBV / MTT / Tmax / TTP maps and optional leakage correction. CBF/CBV are relative unless you apply the absolute calibration. Outputs are for research and engineering only.