Working with annotations and labels in ggbrain

Categorical images in ggbrain

Many visualizations of brain data rely on continuous-valued images containing intensities or statistics. For example, we might wish to visualize the z-statistics of a general linear model.

Yet, many images contain integers whose values represent a priori regions of interest or clusters identified using familywise error correction methods. Brain atlases are a common example of integer-valued images. Here, we demonstrate the cortical parcellation developed by Schaefer and colleagues (2018).

Schaefer, A., Kong, R., Gordon, E. M., Laumann, T. O., Zuo, X.-N., Holmes, A. J., Eickhoff, S. B., & Yeo, B. T. T. (2018). Local-global parcellation of the human cerebral cortex from intrinsic functional connectivity MRI. Cerebral Cortex, 28, 3095-3114.

This version of the atlas contains 200 cortical parcels (in the paper, they show 100-1000 parcels).

schaefer_img <- readNifti(schaefer200_atlas_file)
sort(unique(as.vector(schaefer_img)))
##   [1]   0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15  16  17
##  [19]  18  19  20  21  22  23  24  25  26  27  28  29  30  31  32  33  34  35
##  [37]  36  37  38  39  40  41  42  43  44  45  46  47  48  49  50  51  52  53
##  [55]  54  55  56  57  58  59  60  61  62  63  64  65  66  67  68  69  70  71
##  [73]  72  73  74  75  76  77  78  79  80  81  82  83  84  85  86  87  88  89
##  [91]  90  91  92  93  94  95  96  97  98  99 100 101 102 103 104 105 106 107
## [109] 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125
## [127] 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143
## [145] 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161
## [163] 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179
## [181] 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197
## [199] 198 199 200

At a basic level, we can visualize this image in the same way as continuous images, as described in ![ggbrain_introduction.html].

gg_obj <- ggbrain() +
  images(c(underlay = underlay_file, atlas = schaefer200_atlas_file)) +
  slices(c("z = 30", "z=40")) +
  geom_brain(definition = "underlay") +
  geom_brain(definition = "atlas", name = "Parcel number")

plot(gg_obj)

As we can see, however, the continuous values represent discrete parcels in the atlas. Thus, we may wish to use a categorical/discrete color scale to visualize things. As with base ggplot2, we can wrap the fill column in factor to force conversion to a discrete data type.

Note that the numeric value of any image in a ggbrain object is always called value. And thus, for geom_brain and geom_outline objects, the default aesthetic mapping is aes(fill=value).

gg_obj <- ggbrain() +
  images(c(underlay = underlay_file, atlas = schaefer200_atlas_file)) +
  slices(c("z = 30", "z=40")) +
  geom_brain(definition = "underlay") +
  geom_brain(definition = "atlas", name = "Parcel number", 
             mapping = aes(fill = factor(value)), fill_scale = scale_fill_hue()) +
  render()

plot(gg_obj)
## Warning: Removed 3855 rows containing missing values or values outside the scale range
## (`geom_raster()`).
## Warning: Removed 3707 rows containing missing values or values outside the scale range
## (`geom_raster()`).

This is a lot to take in! It’s not especially easy to resolve 70+ parcels by their color…

We could use use subsetting syntax in the layer definition to reduce the number of parcels. For example, perhaps we’re interested in just the first 20.

gg_obj <- ggbrain() +
  images(c(underlay = underlay_file, atlas = schaefer200_atlas_file)) +
  slices(c("z = 30", "z=40")) +
  geom_brain(definition = "underlay") +
  geom_brain(definition = "atlas[atlas < 20]", name = "Parcel number", 
             mapping = aes(fill = factor(value)), fill_scale = scale_fill_hue()) +
  render()

plot(gg_obj)
## Warning: Removed 4943 rows containing missing values or values outside the scale range
## (`geom_raster()`).
## Warning: Removed 4972 rows containing missing values or values outside the scale range
## (`geom_raster()`).

This is more manageable, though uninspiring.

Mapping image values to labels

Categorical images typically contain one integer value at each voxel. The values could be the cluster number from a clusterization procedure (e.g., AFNI’s 3dClusterize), a region of interest from a meta-analytically derived mask (e.g., NeuroSynth), or an atlas value from a stereotaxic atlas (e.g., the Schaefer atlas used here).

Regardless, the conceptual view is that each integer in the image represents a category of interest. Moreover, we may wish to label these categories with more descriptive labels, not simply the integer value. Thus, integers in an image give us the locations of regions to be labeled, while a separate data table provides the integers <-> labels mapping.

Our labels could be region names, intrinsic networks, or other features we wish to highlight on the display. Regardless, there are two major ways in which labels can be displayed on a ggbrain plot: mapping the labels to colors displayed in the legend or adding text annotations at the locations of the regions. We will review these two approaches in turn.

First, however, let’s see how we tell ggbrain to merge labels with an integer-valued NIfTI image. Above, we read in a CSV file containing labels for regions in the Schaefer 200 parcellation. These were generated in part using AFNI’s whereami command with the centroids of each region serving as an input. This gives us automated labels that we may wish to display on the plot. The CSV also contains a columns called network that refers to the network mapping to the Yeo 2011 7-network parcellation.

knitr::kable(head(schaefer200_atlas_labels, n=10))
roi_num hemi x y z network MNI_Glasser_HCP_v1.0 Brainnetome_1.0 CA_ML_18_MNI schaefer_region
1 L -24 -53 -9 Vis L_VentroMedial_Visual_Area_2 rLinG_left Left Lingual Gyrus 1
2 L -24 -76 -13 Vis L_Eighth_Visual_Area A37mv_left Left Fusiform Gyrus 2
3 L -43 -70 -9 Vis L_Area_PH A37lv_left Left Inferior Occipital Gyrus 3
4 L -10 -66 -5 Vis L_Third_Visual_Area rLinG_left Left Lingual Gyrus 4
5 L -25 -94 -11 Vis L_Third_Visual_Area iOccG_left Left Inferior Occipital Gyrus 5
6 L -14 -44 -3 Vis L_PreSubiculum A23v_left Left Lingual Gyrus 6
7 L -5 -92 -3 Vis L_Primary_Visual_Cortex cCunG_left Left Calcarine Gyrus 7
8 L -46 -71 8 Vis L_Medial_Superior_Temporal_Area V5/MT+_left Left Middle Occipital Gyrus 8
9 L -23 -96 6 Vis L_Third_Visual_Area OPC_left Left Middle Occipital Gyrus 9
10 L -12 -70 7 Vis L_Primary_Visual_Cortex vmPOS_left Left Calcarine Gyrus 10

The structure of this data.frame is relatively flexible. The primary requirement is that it contain a column called value that maps to the numeric value of the corresponding NIfTI image of interest. Here, we have a column called roi_num that maps to the integer values in the mask. So, we need to rename it to value for ggbrain to accept it as a lookup table for labeling.

schaefer200_atlas_labels <- schaefer200_atlas_labels %>%
  dplyr::rename(value = roi_num)

Now that we have this, we can add the labels to the corresponding NIfTI image. This step does not necessarily change the plot, but instead gives us access to additional columns that we can use for labeling.

We use the labels argument with the images function.

gg_base <- ggbrain() +
  images(c(underlay = underlay_file)) +
  images(c(atlas = schaefer200_atlas_file), labels=schaefer200_atlas_labels) +
  slices(c("z = 30", "z=40")) +
  geom_brain(definition = "underlay")

gg_obj <- gg_base +
  geom_brain(definition = "atlas[atlas < 20]", name = "Parcel number", 
             mapping = aes(fill = factor(value)), fill_scale = scale_fill_hue())

plot(gg_obj)
## Warning in melt.data.table(ll_df, measure.vars = label_columns, variable.name =
## ".label_col", : 'measure.vars' [hemi, network, MNI_Glasser_HCP_v1.0,
## Brainnetome_1.0, ...] are not all of the same type. By order of hierarchy, the
## molten data value column will be of type 'character'. All measure variables not
## of type 'character' will be coerced too. Check DETAILS in ?melt.data.table for
## more on coercion.
## Warning: Removed 4943 rows containing missing values or values outside the scale range
## (`geom_raster()`).
## Warning: Removed 4972 rows containing missing values or values outside the scale range
## (`geom_raster()`).

Notice that I have broken up the addition of images to the object into two images steps. This allows for an unambiguous mapping of the labels to the singular NIfTI. An alternative is to use a named list of the sort: images(c(im1=file1, im2=file2), labels=list(im2=im2labels)).

Also, the plot above is identical to our earlier plot. This is because we have mapped the fill to the numeric value, not another column in the labels file.

How about we use the labels from the Eickhoff-Zilles macro labels from N27?

gg_obj <- gg_base +
  geom_brain(definition = "atlas[atlas < 20]", name = "Eickhoff-Zilles Label", 
             mapping = aes(fill = CA_ML_18_MNI), fill_scale = scale_fill_hue()) +
  render()

plot(gg_obj)
## Warning: Removed 4943 rows containing missing values or values outside the scale range
## (`geom_raster()`).
## Warning: Removed 4972 rows containing missing values or values outside the scale range
## (`geom_raster()`).

Notice how we went from four colors (one per number) in the previous plot to three labels here. Why did this happen? The labels for the four regions were not unique in the lookup atlas.

schaefer200_atlas_labels %>% filter(value %in% c(12, 13, 14, 18)) %>% select(value, CA_ML_18_MNI)
##   value                  CA_ML_18_MNI
## 1    12                   Left Cuneus
## 2    13                   Left Cuneus
## 3    14 Left Superior Occipital Gyrus
## 4    18       Left Rolandic Operculum

This highlights a useful point: there can be a one-to-many mapping between labels and unique integer values in the NIfTI image. Indeed, this is often an explicit goal. For example, what if we want to see the Yeo 7 networks assignments for the nodes on these two slices?

(Note that I’m removing the filter atlas < 20 here to let all of the parcels on these slices get to play.)

gg_obj <- gg_base +
  geom_brain(definition = "atlas", name = "Yeo 7 Assignment", 
             mapping = aes(fill = network))

plot(gg_obj)
## Warning: Removed 3855 rows containing missing values or values outside the scale range
## (`geom_raster()`).
## Warning: Removed 3707 rows containing missing values or values outside the scale range
## (`geom_raster()`).

If we do not provide a color palette using the fill_scale argument, it defaults to ColorBrewer’s “Set3”, scale_fill_brewer(palette = "Set3").

What if we wanted to use the same coloration as in the original Yeo et al. 2011 paper? These colors are provided in the Yeo2011_7Networks_ColorLUT.txt file from Freesurfer.

yeo_colors <- read.table(system.file("extdata", "Yeo2011_7Networks_ColorLUT.txt", package = "ggbrain")) %>%
  setNames(c("index", "name", "r", "g", "b", "zero")) %>% slice(-1)

# Convert RGB to hex. Also, using a named set of colors with scale_fill_manual ensures accurate value -> color mapping
yeo7 <- as.character(glue::glue_data(yeo_colors, "{sprintf('#%.2x%.2x%.2x', r, g, b)}")) %>%
  setNames(c("Vis", "SomMot", "DorsAttn", "SalVentAttn", "Limbic", "Cont", "Default"))

gg_obj <- gg_base +
  geom_brain(definition = "atlas", name = "Yeo 7 Assignment", 
             mapping = aes(fill = network), fill_scale = scale_fill_manual(values=yeo7))

plot(gg_obj)
## Warning: Removed 3855 rows containing missing values or values outside the scale range
## (`geom_raster()`).
## Warning: Removed 3707 rows containing missing values or values outside the scale range
## (`geom_raster()`).

Combining filled areas with outlines

It may be useful to map some labels to the fill of an area and to draw outlines around areas using another label. For example, if regions are nested within networks, we might want to outline the networks with a certain color while having separate colors for regions.

gg_obj <- gg_base +
  geom_outline(definition = "atlas", name = "Yeo 7 Assignment", 
             mapping = aes(outline = network), outline_scale = scale_fill_manual(values=yeo7)) +
  geom_brain(definition = "DAN Region := atlas[atlas.network == 'DorsAttn']", mapping=aes(fill=CA_ML_18_MNI))

plot(gg_obj)
## Warning: Removed 29398 rows containing missing values or values outside the scale range
## (`geom_raster()`).
## Warning: Removed 4911 rows containing missing values or values outside the scale range
## (`geom_raster()`).
## Warning: Removed 29296 rows containing missing values or values outside the scale range
## (`geom_raster()`).
## Warning: Removed 4855 rows containing missing values or values outside the scale range
## (`geom_raster()`).

This admittedly a busy figure!