Adding Synthetic Hemodynamic Reponses to Data

This example notebook illustrates the functionality in cedalion.sim.synthetic_hrf to create simulated datasets with added activations.

[1]:
# This cells setups the environment when executed in Google Colab.
try:
    import google.colab
    !curl -s https://raw.githubusercontent.com/ibs-lab/cedalion/dev/scripts/colab_setup.py -o colab_setup.py
    # Select branch with --branch "branch name" (default is "dev")
    %run colab_setup.py
except ImportError:
    pass
[2]:
# set this flag to True to enable interactive 3D plots
INTERACTIVE_PLOTS = False
[3]:
import os

import matplotlib.pyplot as plt
import numpy as np
import pyvista as pv
import xarray as xr

import cedalion
import cedalion.dataclasses as cdc
import cedalion.data
import cedalion.geometry.landmarks as cd_landmarks
import cedalion.dot as dot
import cedalion.models.glm as glm
import cedalion.nirs

import cedalion.vis.anatomy
import cedalion.vis.blocks as vbx

import cedalion.sigproc.quality as quality
import cedalion.sim.synthetic_hrf as synhrf
import cedalion.xrutils as xrutils
from cedalion import units

xr.set_options(display_expand_data=False)

if INTERACTIVE_PLOTS:
    pv.set_jupyter_backend('server')
else:
    pv.set_jupyter_backend('static')

Loading and preprocessing the dataset

This notebook uses a high-density, whole head resting state dataset recorded with a NinjaNIRS 22.

[4]:
rec = cedalion.data.get_nn22_resting_state()

geo3d = rec.geo3d
meas_list = rec._measurement_lists["amp"]

amp = rec["amp"]
amp = amp.pint.dequantify().pint.quantify("V")

display(amp)
<xarray.DataArray (channel: 567, wavelength: 2, time: 3309)> Size: 30MB
[V] 0.0238 0.02385 0.02375 0.02361 0.02364 ... 0.2887 0.2892 0.2897 0.291 0.2923
Coordinates:
  * time        (time) float64 26kB 0.0 0.1112 0.2225 ... 367.8 367.9 368.0
    samples     (time) int64 26kB 0 1 2 3 4 5 ... 3303 3304 3305 3306 3307 3308
  * channel     (channel) object 5kB 'S10D87' 'S10D94' ... 'S47D29' 'S5D137'
    source      (channel) object 5kB 'S10' 'S10' 'S10' ... 'S56' 'S47' 'S5'
    detector    (channel) object 5kB 'D87' 'D94' 'D89' ... 'D129' 'D29' 'D137'
  * wavelength  (wavelength) float64 16B 760.0 850.0
Attributes:
    data_type_group:  unprocessed raw
[5]:
cedalion.vis.anatomy.plot_montage3D(rec["amp"], geo3d)
../../_images/examples_augmentation_62_synthetic_hrfs_example_6_0.png
[6]:
# Select channels which have at least a signal-to-noise ratio of 10
snr_thresh = 10  # the SNR (std/mean) of a channel.
snr, snr_mask = quality.snr(rec["amp"], snr_thresh)
amp_selected, masked_channels = xrutils.apply_mask(
    rec["amp"], snr_mask, "drop", "channel"
)

print(f"Removed {len(masked_channels)} channels because of low SNR.")
Removed 22 channels because of low SNR.
[7]:
# Calculate optical density
od = cedalion.nirs.cw.int2od(amp_selected)

Construct headmodel

We load the the Colin27 headmodel, since we need the geometry for image reconstruction.

[8]:
head_ijk = dot.get_standard_headmodel("colin27")

# transform coordinates to a RAS coordinate system
head_ras = head_ijk.apply_transform(head_ijk.t_ijk2ras)
[9]:
display(head_ijk.brain)
display(head_ras.brain)
TrimeshSurface(faces: 29988 vertices: 15002 crs: ijk units: dimensionless vertex_coords: ['parcel'])
TrimeshSurface(faces: 29988 vertices: 15002 crs: mni units: millimeter vertex_coords: ['parcel'])
[10]:
head_ras.landmarks
[10]:
<xarray.DataArray (label: 73, mni: 3)> Size: 2kB
[mm] 0.0045 -118.6 -23.08 -86.08 -19.99 ... -100.4 37.02 49.81 -99.34 21.47
Coordinates:
  * label    (label) <U3 876B 'Iz' 'LPA' 'RPA' 'Nz' ... 'PO1' 'PO2' 'PO4' 'PO6'
    type     (label) object 584B PointType.LANDMARK ... PointType.LANDMARK
Dimensions without coordinates: mni
[11]:
center_brain = np.mean(head_ras.brain.mesh.vertices, axis=0)

We want to build the synthetic HRFs at C3 and C4 (green dots in the image below)

[12]:
plt_pv = pv.Plotter()
vbx.plot_surface(plt_pv, head_ras.brain, color="#d3a6a1")
vbx.plot_surface(plt_pv, head_ras.scalp, opacity=0.1)
vbx.plot_labeled_points(
    plt_pv, head_ras.landmarks.sel(label=["C3", "C4"]), show_labels=True
)

plt_pv.camera.position = (-400, 500,400)
plt_pv.show()
../../_images/examples_augmentation_62_synthetic_hrfs_example_16_0.png

Build spatial activation pattern on brain surface for landmarks C3 and C4

The function build_spatial_activation is used to place a spatial activation pattern on the brain surface. The activation pattern is a Gaussian function of the geodesic distance to a seed vertex. Hence, the size of the activation is determined by the standard deviation of this Gaussian, specified by the parameter spatial_scale. The peak intensity in HbO is determined by the parameter intensity_scale. The intensity of HbR activation is specified relative to the HbO peak intensity. So if the HbO pattern describes an increase in Hbo then providing a negative factor smaller than 1 yields a decrease in HbR with smaller amplitude. The seed vertex (integer) can be selected as the closest vertex to a given landmark:

[13]:
# obtain the closest vertices to C3 and C4
c3_seed = head_ras.brain.mesh.kdtree.query(head_ras.landmarks.sel(label='C3'))[1]
c4_seed = head_ras.brain.mesh.kdtree.query(head_ras.landmarks.sel(label='C4'))[1]
/home/runner/miniconda3/envs/cedalion/lib/python3.11/site-packages/xarray/core/variable.py:315: UnitStrippedWarning: The unit of the quantity is stripped when downcasting to ndarray.
  data = np.asarray(data)
[14]:
# create the spatial activation
spatial_act = synhrf.build_spatial_activation(
    head_ras,
    c3_seed,
    spatial_scale=2 * cedalion.units.cm,
    intensity_scale=1 * units.micromolar,
    hbr_scale=-0.4,
)
/home/runner/miniconda3/envs/cedalion/lib/python3.11/site-packages/scipy/sparse/linalg/_dsolve/linsolve.py:597: SparseEfficiencyWarning: splu converted its input to CSC format
  return splu(A).solve

The resulting DataArray contains an activation value for each vertex and chromophore on the brain surface.

[15]:
display(spatial_act)
<xarray.DataArray (vertex: 15002, chromo: 2)> Size: 240kB
[M] 1.115e-28 -4.461e-29 2.003e-29 -8.013e-30 5.23e-30 ... 0.0 -0.0 0.0 -0.0
Coordinates:
  * chromo   (chromo) <U3 24B 'HbO' 'HbR'
Dimensions without coordinates: vertex
[16]:
f,ax = plt.subplots(1,2,figsize=(10,5))
cedalion.vis.anatomy.plot_brain_in_axes(
        od,
        head_ras.landmarks,
        spatial_act.sel(chromo="HbO").pint.to("uM"),
        head_ras.brain,
        ax[0],
        camera_pos="C3",
        cmap="RdBu_r",
        vmin=-1,
        vmax=+1,
        cb_label=r"$\Delta$ HbO / µM",
        title=None,
    )
ax[0].set_title("HbO")
cedalion.vis.anatomy.plot_brain_in_axes(
        od,
        head_ras.landmarks,
        spatial_act.sel(chromo="HbR").pint.to("uM"),
        head_ras.brain,
        ax[1],
        camera_pos="C3",
        cmap="RdBu_r",
        vmin=-1,
        vmax=+1,
        cb_label=r"$\Delta$ HbR / µM",
        title=None,
    )
ax[1].set_title("HbR");
../../_images/examples_augmentation_62_synthetic_hrfs_example_22_0.png

The following plot illustrates the effects of the spatial_scale and intensity_scale parameters:

[17]:
f, ax = plt.subplots(2, 3, figsize=(9, 6))
for i, spatial_scale in enumerate([0.5 * units.cm, 2 * units.cm, 3 * units.cm]):
    spatial_act = synhrf.build_spatial_activation(
        head_ras,
        c3_seed,
        spatial_scale=spatial_scale,
        intensity_scale=1 * units.micromolar,
        hbr_scale=-0.4,
    )

    cedalion.vis.anatomy.plot_brain_in_axes(
        od,
        head_ras.landmarks,
        spatial_act.sel(chromo="HbO").pint.to("uM"),
        head_ras.brain,
        ax[0, i],
        camera_pos="C3",
        cmap="RdBu_r",
        vmin=-1,
        vmax=+1,
        cb_label=r"$\Delta$ HbO / µM",
        title=None,
    )
    ax[0, i].set_title(f"spatial_scale: {spatial_scale.magnitude} cm")

for i, intensity_scale in enumerate(
    [
        0.5 * units.micromolar,
        1.0 * units.micromolar,
        2.0 * units.micromolar,
    ]
):
    spatial_act = synhrf.build_spatial_activation(
        head_ras,
        c3_seed,
        spatial_scale=2 * units.cm,
        intensity_scale=intensity_scale,
        hbr_scale=-0.4,
    )

    cedalion.vis.anatomy.plot_brain_in_axes(
        od,
        head_ras.landmarks,
        spatial_act.sel(chromo="HbO").pint.to("uM"),
        head_ras.brain,
        ax[1, i],
        camera_pos="C3",
        cmap="RdBu_r",
        vmin=-2,
        vmax=+2,
        cb_label=r"$\Delta$ HbO / µM",
        title=None,
    )
    ax[1, i].set_title(f"intensity_scale: {intensity_scale.magnitude} µM")

f.tight_layout()


/home/runner/miniconda3/envs/cedalion/lib/python3.11/site-packages/scipy/sparse/linalg/_dsolve/linsolve.py:597: SparseEfficiencyWarning: splu converted its input to CSC format
  return splu(A).solve
/home/runner/miniconda3/envs/cedalion/lib/python3.11/site-packages/scipy/sparse/linalg/_dsolve/linsolve.py:597: SparseEfficiencyWarning: splu converted its input to CSC format
  return splu(A).solve
/home/runner/miniconda3/envs/cedalion/lib/python3.11/site-packages/scipy/sparse/linalg/_dsolve/linsolve.py:597: SparseEfficiencyWarning: splu converted its input to CSC format
  return splu(A).solve
/home/runner/miniconda3/envs/cedalion/lib/python3.11/site-packages/scipy/sparse/linalg/_dsolve/linsolve.py:597: SparseEfficiencyWarning: splu converted its input to CSC format
  return splu(A).solve
/home/runner/miniconda3/envs/cedalion/lib/python3.11/site-packages/scipy/sparse/linalg/_dsolve/linsolve.py:597: SparseEfficiencyWarning: splu converted its input to CSC format
  return splu(A).solve
/home/runner/miniconda3/envs/cedalion/lib/python3.11/site-packages/scipy/sparse/linalg/_dsolve/linsolve.py:597: SparseEfficiencyWarning: splu converted its input to CSC format
  return splu(A).solve
../../_images/examples_augmentation_62_synthetic_hrfs_example_24_1.png

For this example notebook two activations are placed below C3 and C4:

[18]:
spatial_act_c3 = synhrf.build_spatial_activation(
    head_ras,
    c3_seed,
    spatial_scale=2 * cedalion.units.cm,
    intensity_scale=1 * units.micromolar,
    hbr_scale=-0.4,
)
spatial_act_c4 = synhrf.build_spatial_activation(
    head_ras,
    c4_seed,
    spatial_scale=2 * cedalion.units.cm,
    intensity_scale=1 * units.micromolar,
    hbr_scale=-0.4,
)
/home/runner/miniconda3/envs/cedalion/lib/python3.11/site-packages/scipy/sparse/linalg/_dsolve/linsolve.py:597: SparseEfficiencyWarning: splu converted its input to CSC format
  return splu(A).solve
/home/runner/miniconda3/envs/cedalion/lib/python3.11/site-packages/scipy/sparse/linalg/_dsolve/linsolve.py:597: SparseEfficiencyWarning: splu converted its input to CSC format
  return splu(A).solve

We concatenate the two images for C3 and C4 along dimension trial_type to get a single DataArray with the spatial information for both landmarks.

[19]:
# concatenate the two spatial activations along a new dimension
spatial_imgs = xr.concat(
    [spatial_act_c3, spatial_act_c4], dim="trial_type"
).assign_coords(trial_type=["Stim C3", "Stim C4"])
spatial_imgs
[19]:
<xarray.DataArray (trial_type: 2, vertex: 15002, chromo: 2)> Size: 480kB
[M] 1.115e-28 -4.461e-29 2.003e-29 ... -2.887e-14 1.427e-21 -5.706e-22
Coordinates:
  * chromo      (chromo) <U3 24B 'HbO' 'HbR'
  * trial_type  (trial_type) <U7 56B 'Stim C3' 'Stim C4'
Dimensions without coordinates: vertex

Plots of spatial patterns

Using the helper function cedalion.vis.anatomy.plot_brain_in_axes, the created activations on the brain surface below C3 and C4 are plotted:

[20]:
f, ax = plt.subplots(1,2, figsize=(10,5))

cedalion.vis.anatomy.plot_brain_in_axes(
    od,
    head_ras.landmarks,
    spatial_imgs.sel(trial_type="Stim C3", chromo="HbO").pint.to("uM"),
    head_ras.brain,
    ax[0],
    camera_pos="C3",
    cmap="RdBu_r",
    vmin=-1,
    vmax=+1,
    cb_label=r"$\Delta$ HbO / µM",
    title="C3 Activation",
)

cedalion.vis.anatomy.plot_brain_in_axes(
    od,
    head_ras.landmarks,
    spatial_imgs.sel(trial_type="Stim C4", chromo="HbO").pint.to("uM"),
    head_ras.brain,
    ax[1],
    camera_pos="C4",
    cmap="RdBu_r",
    vmin=-1,
    vmax=+1,
    cb_label=r"$\Delta$ HbO / µM",
    title="C4 Activation",
)

f.tight_layout()
../../_images/examples_augmentation_62_synthetic_hrfs_example_30_0.png

Image Reconstruction

We load the precomputed Adot matrix to be able to map from image to channel space. (For details see image_reconstruction example notebook).

[21]:
Adot = cedalion.data.get_precomputed_sensitivity("nn22_resting", "colin27")
[22]:
# we only consider brain vertices, not scalp and drop pruned channels
Adot_brain = Adot.sel(vertex=Adot.is_brain, channel=od.channel)
Adot_brain
[22]:
<xarray.DataArray (channel: 545, vertex: 15002, wavelength: 2)> Size: 131MB
4.881e-18 4.881e-18 7.405e-18 7.405e-18 ... 0.0001423 1.332e-12 1.332e-12
Coordinates:
    parcel      (vertex) object 120kB 'VisCent_ExStr_8_LH' ... 'Background+Fr...
    is_brain    (vertex) bool 15kB True True True True ... True True True True
  * channel     (channel) object 4kB 'S10D87' 'S10D94' ... 'S47D29' 'S5D137'
    detector    (channel) object 4kB 'D87' 'D94' 'D89' ... 'D129' 'D29' 'D137'
    source      (channel) object 4kB 'S10' 'S10' 'S10' ... 'S56' 'S47' 'S5'
  * wavelength  (wavelength) float64 16B 760.0 850.0
Dimensions without coordinates: vertex
Attributes:
    units:    mm

The forward model and image reconstruction translate between timeseries of different wavelengths in channel space and time series of different chromophores in image space. To this end the image reconstruction operates on stacked arrays in which the dimensions ‘channel’ and ‘wavelength’ are stacked to form a new dimension ‘flat_channel’. Likewise the dimensions ‘vertex’ and ‘chromo’ are stacked as ‘flat_vertex’.

To multiply the spatial image with the sensitivity matrix the spatial image’s vertex and chromo dimensions must be stacked, too.

[23]:
# propagate concentration changes in the brain to optical density
# changes in channel space

spatial_chan = dot.forward_model.image_to_channel_space(
    Adot_brain, spatial_imgs, spectrum="prahl"
)
spatial_chan
[23]:
<xarray.DataArray (trial_type: 2, wavelength: 2, channel: 545)> Size: 17kB
[] -1.583e-12 -6.242e-11 -3.406e-11 -2.385e-13 ... 4.92e-06 7.374e-08 5.261e-08
Coordinates:
  * wavelength  (wavelength) float64 16B 760.0 850.0
  * channel     (channel) object 4kB 'S10D112' 'S10D133' ... 'S9D94' 'S9D96'
  * trial_type  (trial_type) <U7 56B 'Stim C3' 'Stim C4'
    source      (channel) object 4kB 'S10' 'S10' 'S10' 'S10' ... 'S9' 'S9' 'S9'
    detector    (channel) object 4kB 'D112' 'D133' 'D135' ... 'D92' 'D94' 'D96'

Show the spatial activation in channel space with a scalp plot.

[24]:
fig, ax = plt.subplots(1, 2)
# adjust plot size
fig.set_size_inches(12, 6)
cedalion.vis.anatomy.scalp_plot(
    od,
    rec.geo3d,
    spatial_chan.sel(trial_type="Stim C3", wavelength=850),
    ax[0],
    cmap="YlOrRd",
    title="850nm, activation under C3",
    vmin=spatial_chan.min().item(),
    vmax=spatial_chan.max().item(),
    cb_label="max peak amplitude",
)
cedalion.vis.anatomy.scalp_plot(
    od,
    rec.geo3d,
    spatial_chan.sel(trial_type="Stim C4", wavelength=850),
    ax[1],
    cmap="YlOrRd",
    title="850nm, activation under C4",
    vmin=spatial_chan.min().item(),
    vmax=spatial_chan.max().item(),
    cb_label="Max peak amplitude",
)
plt.show()
../../_images/examples_augmentation_62_synthetic_hrfs_example_38_0.png

Get top 5 channels for each trial type where synthetic activation is highest

[25]:
roi_chans_c3 = spatial_chan.channel[
    spatial_chan.sel(trial_type="Stim C3").max("wavelength").argsort()[-5:].values
].values
roi_chans_c4 = spatial_chan.channel[
    spatial_chan.sel(trial_type="Stim C4").max("wavelength").argsort()[-5:].values
].values

Concentration Scale

The activations were simulataed in image space with a peak concentration change of 1 µM in one vertex. The change in optical density in one channel reflects concentration changes in the ensemble of vertices that this channel is sensitive to.

When applying the Beer-Lambert-transformation in channel space, a change in concentration is calculated for each channel. However, the scales of these concentration changes and the concentration changes in single vertices are not the same.

Here, a correction factor is calculated to scale the activation in channel space to 1uM.

[26]:
dpf = xr.DataArray(
    [6, 6],
    dims="wavelength",
    coords={"wavelength": rec["amp"].wavelength},
)

# add time axis with one time point so we can convert to conc
spatial_chan_w_time = spatial_chan.expand_dims("time")
spatial_chan_w_time = spatial_chan_w_time.assign_coords(time=[0])
spatial_chan_w_time.time.attrs["units"] = "second"
display(spatial_chan_w_time)
spatial_chan_conc = cedalion.nirs.cw.od2conc(
    spatial_chan_w_time, geo3d, dpf, spectrum="prahl"
)
<xarray.DataArray (time: 1, trial_type: 2, wavelength: 2, channel: 545)> Size: 17kB
[] -1.583e-12 -6.242e-11 -3.406e-11 -2.385e-13 ... 4.92e-06 7.374e-08 5.261e-08
Coordinates:
  * wavelength  (wavelength) float64 16B 760.0 850.0
  * channel     (channel) object 4kB 'S10D112' 'S10D133' ... 'S9D94' 'S9D96'
  * trial_type  (trial_type) <U7 56B 'Stim C3' 'Stim C4'
    source      (channel) object 4kB 'S10' 'S10' 'S10' 'S10' ... 'S9' 'S9' 'S9'
    detector    (channel) object 4kB 'D112' 'D133' 'D135' ... 'D92' 'D94' 'D96'
  * time        (time) int64 8B 0
[27]:
# rescale so that synthetic hrfs add 1 micromolar at peak.
rescale_factor = (1* units.micromolar / spatial_chan_conc.max())

display(rescale_factor)
spatial_chan *= rescale_factor
<xarray.DataArray 'concentration' ()> Size: 8B
[] 9.252
[28]:
dpf = xr.DataArray(
    [6, 6],
    dims="wavelength",
    coords={"wavelength": rec["amp"].wavelength},
)

HRFs in channel space

So far the notebook focused on the spatial extent of the activation. To build the temporal HRF model we use the same functionality that generates hrf regressors for the GLM.

First we select a basis function, which defines the temporal shape of the HRF.

[29]:
basis_fct = glm.Gamma(tau=0 * units.s, sigma=3 * units.s, T=3 * units.s)
[30]:
od.time
[30]:
<xarray.DataArray 'time' (time: 3309)> Size: 26kB
0.0 0.1112 0.2225 0.3337 0.445 0.5562 ... 367.5 367.6 367.7 367.8 367.9 368.0
Coordinates:
  * time     (time) float64 26kB 0.0 0.1112 0.2225 0.3337 ... 367.8 367.9 368.0
    samples  (time) int64 26kB 0 1 2 3 4 5 6 ... 3303 3304 3305 3306 3307 3308
Attributes:
    units:    second

A Stim DataFrame, which contains the onset, duration and amplitude of the synthetic HRFs, is created.

[31]:
stim_df = synhrf.build_stim_df(
    max_time=od.time.values[-1] * units.seconds,
    trial_types=["Stim C3", "Stim C4"],
    min_interval=10 * units.seconds,
    max_interval=20 * units.seconds,
    min_stim_dur = 10 * units.seconds,
    max_stim_dur = 10 * units.seconds,
    min_stim_value = 1.0,
    max_stim_value = 1.0,
    order="random",
)
[32]:
stim_df.head()
[32]:
onset duration value trial_type
0 19.81 10.0 1.0 Stim C3
1 40.53 10.0 1.0 Stim C4
2 66.45 10.0 1.0 Stim C4
3 87.79 10.0 1.0 Stim C4
4 108.98 10.0 1.0 Stim C4

We can now use our stim dataframe, basis function, and spatial information to create the synthetic HRF timeseries

[33]:
syn_ts = synhrf.build_synthetic_hrf_timeseries(od, stim_df, basis_fct, spatial_chan)

We get a synthetic HRF timeseries for each channel, trial_type and chromo / wavelength

[34]:
syn_ts
[34]:
<xarray.DataArray (trial_type: 2, wavelength: 2, channel: 545, time: 3309)> Size: 58MB
[] -0.0 -0.0 -0.0 -0.0 -0.0 -0.0 -0.0 -0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
Coordinates:
  * wavelength  (wavelength) float64 16B 760.0 850.0
  * channel     (channel) object 4kB 'S10D112' 'S10D133' ... 'S9D94' 'S9D96'
  * trial_type  (trial_type) <U7 56B 'Stim C3' 'Stim C4'
    source      (channel) object 4kB 'S10' 'S10' 'S10' 'S10' ... 'S9' 'S9' 'S9'
    detector    (channel) object 4kB 'D112' 'D133' 'D135' ... 'D92' 'D94' 'D96'
  * time        (time) float64 26kB 0.0 0.1112 0.2225 ... 367.8 367.9 368.0

Sum the synthetic timeseries over trial_type dimension, so it has the same shape as the resting state data

[35]:
syn_ts_sum = syn_ts.sum(dim='trial_type')
syn_ts_sum
[35]:
<xarray.DataArray (wavelength: 2, channel: 545, time: 3309)> Size: 29MB
[] 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
Coordinates:
  * wavelength  (wavelength) float64 16B 760.0 850.0
  * channel     (channel) object 4kB 'S10D112' 'S10D133' ... 'S9D94' 'S9D96'
    source      (channel) object 4kB 'S10' 'S10' 'S10' 'S10' ... 'S9' 'S9' 'S9'
    detector    (channel) object 4kB 'D112' 'D133' 'D135' ... 'D92' 'D94' 'D96'
  * time        (time) float64 26kB 0.0 0.1112 0.2225 ... 367.8 367.9 368.0

Adding HRFs to measured data

Here, the simulated activations are combined with physiological noise by adding the synthetic HRFs to the resting state dataset:

[36]:
# set to false to process only the synth. HRF in the following
ADD_RESTING_STATE = True

if ADD_RESTING_STATE:
    od_w_hrf = od + syn_ts_sum
else:
    od_w_hrf = syn_ts_sum
    od_w_hrf = od_w_hrf.assign_coords(
        {
            "samples": ("time", od.samples.values),
            "time": ("time", od.time.data),
        }
    )
    od_w_hrf = od_w_hrf.pint.quantify({"time": "s"})

Recover the HRFs again

In the following, the added activations should be extracted from the simulated dataset again. To this end, the data is frequency filtered and block averages are calculated.

[37]:
od_w_hrf_filtered = od_w_hrf.cd.freq_filter(fmin=0.02, fmax=0.5, butter_order=4)
[38]:
od_w_hrf_filtered
[38]:
<xarray.DataArray (channel: 545, wavelength: 2, time: 3309)> Size: 29MB
[] 0.002072 0.002634 0.00317 0.003662 ... 0.006999 0.006033 0.004973 0.00387
Coordinates:
  * time        (time) float64 26kB 0.0 0.1112 0.2225 ... 367.8 367.9 368.0
  * channel     (channel) object 4kB 'S10D87' 'S10D94' ... 'S47D29' 'S5D137'
  * wavelength  (wavelength) float64 16B 760.0 850.0
    samples     (time) int64 26kB 0 1 2 3 4 5 ... 3303 3304 3305 3306 3307 3308
    source      (channel) object 4kB 'S10' 'S10' 'S10' ... 'S56' 'S47' 'S5'
    detector    (channel) object 4kB 'D87' 'D94' 'D89' ... 'D129' 'D29' 'D137'
[39]:
epochs = od_w_hrf_filtered.cd.to_epochs(
    stim_df,  # stimulus dataframe
    ["Stim C3", "Stim C4"],  # select events
    before=5 * units.seconds,  # seconds before stimulus
    after=20 * units.seconds,  # seconds after stimulus
)

# calculate baseline
baseline = epochs.sel(reltime=(epochs.reltime < 0)).mean("reltime")
# subtract baseline
epochs_blcorrected = epochs - baseline

# group trials by trial_type. For each group individually average the epoch dimension
blockaverage = epochs_blcorrected.groupby("trial_type").mean("epoch")

n_roi = roi_chans_c3.size
# show results
f, ax = plt.subplots(2, n_roi, figsize=(16, 8))
ax = ax.flatten()
for i_ch, ch in enumerate(roi_chans_c3):
    for ls, trial_type in zip(["-", "--"], blockaverage.trial_type):
        ax[i_ch].plot(
            blockaverage.reltime,
            blockaverage.sel(wavelength=760, trial_type=trial_type, channel=ch),
            "r",
            lw=2,
            ls=ls,
        )
        ax[i_ch].plot(
            blockaverage.reltime,
            blockaverage.sel(wavelength=850, trial_type=trial_type, channel=ch),
            "b",
            lw=2,
            ls=ls,
        )
    ax[i_ch].grid(1)
    ax[i_ch].set_title(ch)
    ax[i_ch].set_ylim(-0.05, 0.05)

for i_ch, ch in enumerate(roi_chans_c4):
    for ls, trial_type in zip(["-", "--"], blockaverage.trial_type):
        ax[i_ch + n_roi].plot(
            blockaverage.reltime,
            blockaverage.sel(wavelength=760, trial_type=trial_type, channel=ch),
            "r",
            lw=2,
            ls=ls,
        )
        ax[i_ch + n_roi].plot(
            blockaverage.reltime,
            blockaverage.sel(wavelength=850, trial_type=trial_type, channel=ch),
            "b",
            lw=2,
            ls=ls,
        )
    ax[i_ch + n_roi].grid(1)
    ax[i_ch + n_roi].set_title(ch)
    ax[i_ch + n_roi].set_ylim(-0.05, 0.05)

plt.suptitle(
    "Blockaverage for channels most sensitive to C3 (top) and C4 (bottom): 760nm: r | 850nm: b | C3: - | C4: --"
)
plt.tight_layout()
plt.show()
../../_images/examples_augmentation_62_synthetic_hrfs_example_63_0.png

Map block average back to brain surface

We map our extracted block averages back to the brain surface to visualize the recovered HRFs activation for Stim C3. We can compare it to the synthetic HRF image we created earlier.

[40]:
sbf = dot.GaussianSpatialBasisFunctions(head_ras, Adot, **dot.SBF_GAUSSIANS_DENSE)

100%|██████████| 9680/9680 [00:04<00:00, 2107.60it/s]
100%|██████████| 7057/7057 [00:00<00:00, 8952.27it/s]
100%|██████████| 9324/9324 [00:07<00:00, 1215.85it/s]
100%|██████████| 1949/1949 [00:01<00:00, 1783.71it/s]
[41]:

recon = dot.ImageRecon( Adot, recon_mode="mua2conc", alpha_meas=0.01, brain_only=True, spatial_basis_functions=sbf ) blockaverage_img = recon.reconstruct(blockaverage) blockaverage_img
[41]:
<xarray.DataArray (chromo: 2, vertex: 15002, trial_type: 2, reltime: 226)> Size: 108MB
[µM] 0.008274 0.01045 0.01278 0.01503 0.01702 0.01867 ... 0.0 0.0 0.0 0.0 0.0
Coordinates:
  * chromo      (chromo) <U3 24B 'HbO' 'HbR'
  * reltime     (reltime) float64 2kB -4.995 -4.884 -4.773 ... 19.76 19.87 19.98
  * trial_type  (trial_type) object 16B 'Stim C3' 'Stim C4'
    is_brain    (vertex) bool 15kB True True True True ... True True True True
    parcel      (vertex) object 120kB 'VisCent_ExStr_8_LH' ... 'Background+Fr...
Dimensions without coordinates: vertex
[42]:
# plot HbO time trace of left and right brain hemisphere during FTapping/Right

for view in ["left_hemi", "right_hemi"]:
    trial_type = "Stim C3"
    gif_fname = "Ftapping-right" + "_HbO_" + view + ".gif"

    hbo = blockaverage_img.sel(chromo="HbO", trial_type=trial_type).pint.dequantify()
    hbo_brain = hbo.transpose("vertex", "reltime")

    ntimes = hbo.sizes["reltime"]

    b = cdc.VTKSurface.from_trimeshsurface(head_ras.brain)
    b = pv.wrap(b.mesh)
    b["reco_hbo"] = hbo_brain[:, 0] - hbo_brain[:, 0]

    p = pv.Plotter()

    p.add_mesh(
        b,
        scalars="reco_hbo",
        cmap="seismic",  # 'gist_earth_r',
        clim=(-2.5, 2.5),
        scalar_bar_args={"title": "HbO / µM"},
        smooth_shading=True,
    )

    tl = lambda tt: f"{trial_type} HbO rel. time: {tt:.3f} s"
    time_label = p.add_text(tl(0))

    cog = head_ras.brain.vertices.mean("label")
    cog = cog.pint.dequantify().values
    if view == "left_hemi":
        p.camera.position = cog + [-400, 0, 0]
    else:
        p.camera.position = cog + [400, 0, 0]
    p.camera.focal_point = cog
    p.camera.up = [0, 0, 1]
    p.reset_camera()

    p.open_gif(gif_fname)

    for i in range(0, ntimes, 3):
        b["reco_hbo"] = hbo_brain[:, i] - hbo_brain[:, 0]
        time_label.set_text("upper_left", tl(hbo_brain.reltime[i]))

        p.write_frame()

    p.close()
[43]:
from IPython.display import Image

display(Image(data=open("Ftapping-right_HbO_left_hemi.gif",'rb').read(), format='png'))
display(Image(data=open("Ftapping-right_HbO_right_hemi.gif",'rb').read(), format='png'))
../../_images/examples_augmentation_62_synthetic_hrfs_example_69_0.png
../../_images/examples_augmentation_62_synthetic_hrfs_example_69_1.png
[44]:
for c, channel in [("C3", roi_chans_c3[-1]), ("C4", roi_chans_c4[-1])]:
    trial_type = f"Stim {c}"

    f,ax = plt.subplots(2,4, figsize=(14,6))
    vmin,vmax = -2.5,2.5

    cedalion.vis.anatomy.plot_brain_in_axes(
        od,
        head_ras.landmarks,
        spatial_imgs.sel(trial_type=trial_type, chromo="HbO").pint.to("uM"),
        head_ras.brain,
        ax[0,0],
        camera_pos=c,
        cmap="RdBu_r",
        vmin=vmin,
        vmax=vmax,
        cb_label=r"$\Delta$ HbO / µM",
        #title=f"{c} Activation",
    )
    ax[0,0].set_title(f"true activation at {c}")

    trels = [0., 10., 15.]
    for i_wl, wl in enumerate(blockaverage.wavelength.values):
        tmp = blockaverage.sel(
            trial_type=trial_type, channel=channel, wavelength=wl
        )
        ax[1,0].plot(tmp.reltime, tmp, label=f"{wl} nm")
    for trel in trels:
        ax[1,0].axvline(trel, c="k", ls=":")
    ax[1,0].legend(loc="upper left")
    ax[1,0].set_xlabel("$t_{rel}$")
    ax[1,0].set_ylabel("OD")
    ax[1,0].set_title(channel)

    for i_col, trel in enumerate(trels, start=1):
        for i_row, cam_pos in enumerate(["C3", "C4"]):
            cedalion.vis.anatomy.plot_brain_in_axes(
                od,
                head_ras.landmarks,
                blockaverage_img.sel(chromo="HbO", trial_type=trial_type)
                .sel(reltime=trel, method="nearest"),
                head_ras.brain,
                ax[i_row, i_col],
                camera_pos=cam_pos,
                cmap="RdBu_r",
                vmin=vmin,
                vmax=vmax,
                cb_label=r"$\Delta$ HbO / µM",
                #title="$t_{rel}=" + f"{trel:.1f} s$",
            )
            ax[i_row, i_col].set_title(f"view at {cam_pos} | " + "$t_{rel}=" + f"{trel:.1f} s$")
../../_images/examples_augmentation_62_synthetic_hrfs_example_70_0.png
../../_images/examples_augmentation_62_synthetic_hrfs_example_70_1.png