A first segmentation

In the previous chapter we examined a single streamline. In this chapter we will perform a “segmentation” of sorts, wherein we divide up the whole brain tractogram. One of the challenges we experienced in the previous chapter was that the whole brain tractogram is unwieldy and impenetrable. What we need is the ability to look at specific sub-components of the tractogram in order to systematically make insights about our model of the brain’s white matter. But how would we do this? We can find the answer to this by reflecting on our earlier work with satellite images.

Making a tractogram tractable

How could we systematically divide up the white matter?

As we seek to make our tractogram more manageable we find ourselves in a position similar to the one we were in when considering the satellite images. Although we have an intuitive understanding with the familiar geography depicted in the satellite image, we nonetheless needed to narrow our consideration of the available data in order to make good use of it. In the satellite case we performed an ocean-based mask, and in the NiFTI case we used an existing parcellation. But how would we do this with a whole brain tractogram?

Perhaps we can leverage an analogy with the highway system of the United States. If we consider the various regions of the brain (e.g. the ones we find in a parcellation) to be like the states of the United States, and the white matter to be the roads and highways that connect them, an approach presents itself. We could perform a systematc process of pairwise matching wherein, for each pairing of states, we identify those roads and highways (or streamlines) that start and/or finish in those areas. After all, both white matter tracts and roads have to begin and end somewhere.

To achieve our goal of tractogram sub-selection we can leverage the parcellation data we were looking at previously. This data object assigns each voxel a label, and so we can–for each streamline–determine which labels its endpoints are closest to. This will give us a mapping of streamlines which have ends in both regions A and B, A and C, C and B, and so on and so forth, for all possible pairings of labels.

To begin we’ll need to load up a parcellation:

#this code ensures that we can navigate the WiMSE repo across multiple systems
import subprocess
import os
#get top directory path of the current git repository, under the presumption that 
#the notebook was launched from within the repo directory
gitRepoPath=subprocess.check_output(['git', 'rev-parse', '--show-toplevel']).decode('ascii').strip()

#move to the top of the directory
os.chdir(gitRepoPath)

import nibabel as nib
import numpy as np

atlasPath=os.path.join(gitRepoPath,'exampleData','parc.nii.gz')
#load it as an object
atlasImg = nib.load(atlasPath)
atlasData = atlasImg.get_fdata()
#set the print option so it isn't printing in scientific notation
np.set_printoptions(suppress=True)
#condense to unique values
uniqueAtlasEntries=np.unique(atlasData).astype(int)

import pandas as pd
FSTablePath=os.path.join(gitRepoPath,'exampleData','FreesurferLookup.csv')
#read table using pandas
FSTable=pd.read_csv(FSTablePath)
#create a boolean vector for the indexes which are in uniqueAtlasEntries
currentIndexesBool=FSTable['#No.'].isin(uniqueAtlasEntries)
#create new data frame with the relevant entries
currentParcellationEntries=FSTable.loc[currentIndexesBool]
#reset the indexes, which were disrupted by the previous operation
#currentParcellationEntries=currentParcellationEntries.reset_index(drop=True)
currentParcellationEntries.tail(20)
#No. LabelName: R G B A
1120 12156 ctx_rh_S_interm_prim-Jensen 141 60 20 0
1121 12157 ctx_rh_S_intrapariet_and_P_trans 143 20 220 0
1122 12158 ctx_rh_S_oc_middle_and_Lunatus 101 60 220 0
1123 12159 ctx_rh_S_oc_sup_and_transversal 21 20 140 0
1124 12160 ctx_rh_S_occipital_ant 61 20 180 0
1125 12161 ctx_rh_S_oc-temp_lat 221 140 20 0
1126 12162 ctx_rh_S_oc-temp_med_and_Lingual 141 100 220 0
1127 12163 ctx_rh_S_orbital_lateral 221 100 20 0
1128 12164 ctx_rh_S_orbital_med-olfact 181 200 20 0
1129 12165 ctx_rh_S_orbital-H_Shaped 101 20 20 0
1130 12166 ctx_rh_S_parieto_occipital 101 100 180 0
1131 12167 ctx_rh_S_pericallosal 181 220 20 0
1132 12168 ctx_rh_S_postcentral 21 140 200 0
1133 12169 ctx_rh_S_precentral-inf-part 21 20 240 0
1134 12170 ctx_rh_S_precentral-sup-part 21 20 200 0
1135 12171 ctx_rh_S_suborbital 21 20 60 0
1136 12172 ctx_rh_S_subparietal 101 60 60 0
1137 12173 ctx_rh_S_temporal_inf 21 180 180 0
1138 12174 ctx_rh_S_temporal_sup 223 220 60 0
1139 12175 ctx_rh_S_temporal_transverse 221 60 60 0

Now that we have the parcellation loaded, we can load our whole brain tractogram and perform the iterative assignment of streamlines to atlas labels. In this way, each streamline will be assigned two numbers corresponding to the label number closest to its first and last node. This method is at the heart of the burgeoning field of connectomics (e.g. Bullmore, E., Sporns, O., 2009, Behrens, T. E., & Sporns, O., 2012). As such, it is not surprising that a great deal of research and ingenuity has been applied to developing optimized software and algorithms for this approach. In fact, dipy (Garyfallidis et al. 2014) has a very straightforward function for this: dipy.tracking.utils.connectivity_matrix.

Lets apply this method now and look at the outputs. We’ll also plot an interactive visualization of the parcellation that can help serve as a reference for these subdivisions. (Note, this is a non-trivial set of computations, and so executing the next block will take a moment. Be careful not to try and run it multiple times.)

# load the tractography file into the streamsObjIN variable
smallTractogramPath=os.path.join(gitRepoPath,'exampleData','smallTractogram.tck')
streamsObjIN=nib.streamlines.load(smallTractogramPath)

#because of how dipy does connectivity matrices, we have to relabel the atlas
remappingFrame=currentParcellationEntries.reset_index(drop=True)
#establish a copy
relabeledAtlas=atlasData.copy()
#iterate across unique label entries
for iLabels in range(len(remappingFrame)):
    #replace the current uniqueAtlasEntries value with the iLabels value
    #constitutes a simple renumbering schema
    relabeledAtlas[relabeledAtlas==uniqueAtlasEntries[iLabels]]=iLabels

from dipy.tracking import utils
#segment tractome into connectivity matrix from parcellation
M, grouping=utils.connectivity_matrix(streamsObjIN.tractogram.streamlines, atlasImg.affine, label_volume=relabeledAtlas.astype(int),
                        return_mapping=True,
                        mapping_as_streamlines=False)

Now that we have performed that lengthy segmentation, let’s take a quantitative look at how many streamlines are connecting each region. We’ll first do this using the standard method of connectomics, a connectivity matrix. In the brain connectivity matrix plot, each row/column corresponds to a brain area in a given parcellation. Each entry (i.e. row X, column Y; or edge measure) is associated with a particular measure, which is visually depicted by a color from a colormap, which reflects some measure of connectivity between those brain areas. In the matrix we will plot below, that color value will correspond to the number of streamlines connecting those areas.

As a warning, these plots are somewhat difficult to comprehend in that it is difficult to associate particular areas with a trend or insight. Typically these plots are used to depict and infer general patterns in the overall connectivity arrangement of the brain.

import seaborn as sns
sns.heatmap(np.log1p(M))
<matplotlib.axes._subplots.AxesSubplot at 0x1240bc510>

Here we’ll also present the dataframe containing the remapped numberings (in the index column) to reference with the above figure. Remember in the matrix plot, each row (and column) corresponds to a specific label in the parcellation. Due to the renumbering we performed, the index column of the below table indicates which anatomical label is associated with which entry of the above matrix figure.

import itables
resetTable=remappingFrame.reset_index()
itables.show(resetTable,paging=True)
index #No. LabelName: R G B A

In order to make the information in the matrix a bit more digestible, we can look at the information contained in each row/column as a bar graph. Below we’ll do this in an interactive fashion. Be warned: some rows have only a few connections and are fairly straightforward to view in this way, while others may have a large number of connections and may result in a particularly large bar plot.

dropDownList=list(zip(currentParcellationEntries['LabelName:'].to_list(), currentParcellationEntries['#No.'].to_list()))

def plotCurrentLabelCounts(currLabel):
    import seaborn as sns
    from itertools import compress
    import matplotlib.pyplot as plt
    #convert the input FS Label number to the renumbered index
    currentRenumberIndex=resetTable['index'].loc[resetTable['#No.']==currLabel].values[0]    
    if currentRenumberIndex in np.asarray(list(grouping.keys())): 
        #get name to label plot
        currentInputName=currentParcellationEntries['LabelName:'].loc[currentParcellationEntries['#No.']==currLabel].values[0]
        #turn the grouping keys (pairs of integers) into a dataframe
        allKeysFrame=pd.DataFrame.from_dict(grouping.keys())
        #extract from the keyList (of all pairings), a boolean vector corresponding those pairings where either value is the current label of interest
        currentBool=np.logical_or(allKeysFrame[0]==currentRenumberIndex,allKeysFrame[1]==currentRenumberIndex)
        #because the values are lists of streamline indexes, we can get the length of these lists to count the number of streamlines 
        streamCounts = [len(v) for v in grouping.values()]
        #extract those index pairs via the boolean vector
        currentPairs=allKeysFrame[currentBool].to_numpy()
        #very janky method for obtaining current label of interest
        #works because these are integers.  Can't currently think of an edge case for this.
        currentLabelNums=np.sum(currentPairs,axis=1)-currentRenumberIndex
        #get the names of the labels corresponding to the non currLabel labels 
        currentLabelNames=resetTable['LabelName:'].loc[currentLabelNums]
        #extract the counts from the total counts list
        currentCounts=list(compress(streamCounts,currentBool.values))
        #create the data for a dataframe
        currentFrameData={'labelNo':currentLabelNums,'LabelName':currentLabelNames.values,'streamCount':currentCounts}
        #convert that into a dataframe
        currentFrame=pd.DataFrame(currentFrameData)
        #adaptively set dimensions
        plotDim1=len(currentLabelNames)*.3
        plt.figure(figsize=(5,plotDim1))
        #plot it
        ax = sns.barplot(y="LabelName", x="streamCount", data=currentFrameData)
        #set logscale
        ax.set_xscale("log")
        ax.set(title="streamline connections to " + currentInputName, xlabel='log scale streamline count', ylabel='freesurfer label')
     
    else: 
        print('connections not present')
        
from ipywidgets import interact, interactive, fixed, interact_manual
from ipywidgets import Dropdown

#establish interactivity
interact(plotCurrentLabelCounts, 
         currLabel=Dropdown(options=dropDownList, value=2, description="region1"), 
        )
<function __main__.plotCurrentLabelCounts(currLabel)>

Also, to help us remember the actual reference brain parcellation we’ll plot the parcellaton in an interactive fashion below.

from niwidgets import NiftiWidget

renumberedAtlasNifti=nib.Nifti1Image(relabeledAtlas, atlasImg.affine, atlasImg.header)  

atlas_widget = NiftiWidget(renumberedAtlasNifti)
atlas_widget.nifti_plotter(colormap='nipy_spectral')
<Figure size 432x288 with 0 Axes>

Finally, let’s allow ourselves to interact with streamlines connecting particular pairs of labels. In the widget below you’ll be able to select two labels from the FreeSurfer parcellation. In some cases, the widget will inform you that there are no streamlines that connect those regions. In other cases you’ll be able to interact with the visualization for that collection of streamlines.

Be sure to zoom in, as the visualization tends to start from a distant position.

#get tractogram from the Tck holder
sourceTractogram=streamsObjIN.tractogram

#quick and dirty tractogram subsetter by Brad Caron
#https://github.com/bacaron
def extractSubTractogram(sourceTractogram,indexes):
    #import relevant package
    import nibabel as nib
    #extract the desired streamlines into a new streamline object
    streamlines = sourceTractogram.streamlines[indexes]
    #establish tractogram object
    out_tractogram = nib.streamlines.tractogram.Tractogram(streamlines)
    #adjust the relevant header fields
    #don't bother for now, header is only relevant to Tck file
    #for headerFields in ['total_count','count','nb_streamlines']:
        #nb_streamlines is an int, whereas the others are strings, for some reason
    #    if headerFields == 'nb_streamlines':
    #        out_tractogram.header[headerFields] = len(streamlines)
    #    else:
    #        out_tractogram.header[headerFields] = '%s' %len(streamlines)
    return out_tractogram

#interactive plotting via niwidgets?  
#widget within a widget doesn't seem to work
def plotParcellationConnectionWidget(subTractogram):
    #import widget
    from niwidgets import StreamlineWidget
    #set widget object
    
    sw = StreamlineWidget(streamlines=subTractogram)
    #set plotting characteristics
    style = {'axes': {'color': 'red',
                  'label': {'color': 'white'},
                  'ticklabel': {'color': 'white'},
                  'visible': False},
         'background-color': 'black',
         'box': {'visible': False}}
    #plot it
    sw.plot(display_fraction=1, width=1000, height=1000, style=style, percentile=0)

def plotTract(tractIn):
    import numpy as np
    from dipy.viz import window, actor
    renderer = window.Scene()
    stream_actor = actor.line(tractIn)
    #renderer.set_camera(position=(-176.42, 118.52, 128.20),
    #               focal_point=(113.30, 128.31, 76.56),
    #                view_up=(0.18, 0.00, 0.98))
    %matplotlib inline
    renderer.add(stream_actor)
    
    window.show(renderer, size=(600, 600), reset_camera=True)

def updateFunction(regionIndex1,regionIndex2):
    currentRenumberIndex1=resetTable['index'].loc[resetTable['#No.']==regionIndex1].values[0]    
    currentRenumberIndex2=resetTable['index'].loc[resetTable['#No.']==regionIndex2].values[0]    
 
    
    #check to make sure this pairing is actually in the connections
    if (currentRenumberIndex1,currentRenumberIndex2) in grouping.keys(): 
        currentIndexes=grouping[currentRenumberIndex1,currentRenumberIndex2]
        subTractogram=extractSubTractogram(sourceTractogram,currentIndexes)
        %matplotlib inline
        plotParcellationConnectionWidget(subTractogram.streamlines)
    else:
        print('connection not present')

dropDownList=list(zip(currentParcellationEntries['LabelName:'].to_list(), currentParcellationEntries['#No.'].to_list()))

from ipywidgets import interact, interactive, fixed, interact_manual
from ipywidgets import Dropdown

#establish interactivity
interact(updateFunction, 
         regionIndex1=Dropdown(options=dropDownList, value=2, description="region1"), 
         regionIndex2=Dropdown(options=dropDownList, value=2, description="region2"),
        )
<function __main__.updateFunction(regionIndex1, regionIndex2)>

In the above figures and widgets we have depicted the source parcellation, and the resulting map of which streamlines connect to which areas in that parcellations. There are a few overarching things we ought to note about this:

  • All streamlines are assigned: There are no streamlines that are not associated with 2 labels (one per endpoint).

  • “Accurate” labeling of streamlines is hugely dependent on the streamline tractogram being appropriately aligned with the parcellation. If there is a misalignment (i.e. a flip, or a translation on a particular dimension) the labels attributed to streamlines may not be at all appropriate.

  • Given that a streamline is supposed to represent a collection of axons, a streamline must also be subject to many of the same constraints that axons are. Prime among these is that the streamline be biologically plausible. This means that, among other things, it must terminate in reasonable areas and follow a sensible path as it traverses the brain.

Let’s explore this notion of biological plausibility in the next chapter, as it is key to a preliminary thresholding/cleaning of our tractography which permits subsequent, anatomically guided segmentations.