In this blogpost, we show how to modify a skeleton network analysis with Dask to work with constrained RAM (eg: on your laptop). This makes it more accessible: it can run on a small laptop, instead of requiring access to a supercomputing cluster. Example code is also provided here.
Skeleton structures are everywhere
Lots of biological structures have a skeleton or network-like shape. We see these in all kinds of places, including:
Analysing the structure of these skeletons can give us important information about the biology of that system.
For this blogpost, we will look at the blood vessels inside of a lung. This data was shared with us by Marcus Kitchen, Andrew Stainsby, and their team of collaborators.
This research group focusses on lung development. We want to compare the blood vessels in a healthy lung, against a lung from a hernia model. In the hernia model the lung is underdeveloped, squashed, and smaller.
These image volumes have a shape of roughly 1000x1000x1000 pixels. That doesn’t seem huge but given the high RAM consumption involved in processing the analysis, it crashes when running on a laptop.
If you’re running out of RAM, there are two possible approaches:
We took the second approach, using Dask so we can run our analysis on a small laptop with constrained RAM without crashing. This makes it more accessible, to more people.
All the image pre-processing steps will be done with dask-image, and the skeletonize function of scikit-image.
We use skan as the backbone of our analysis pipeline. skan is a library for skeleton image analysis. Given a skeleton image, it can describe statistics of the branches. To make it fast, the library is accelerated with numba (if you’re curious, you can hear more about that in this talk and its related notebook).
There is an example notebook containing the full details of the skeleton analysis available here. You can read on to hear just the highlights.
The statistics from the blood vessel branches in the healthy and herniated lung shows clear differences between the two.
Most striking is the difference in the number of blood vessel branches.The herniated lung has less than 40% of the number of blood vessel branches in the healthy lung.
There are also quantitative differences in the sizes of the blood vessels.Here is a violin plot showing the distribution of the thickness of blood vessel branches. We can see that there are more thick blood vessel branches in the healthy lung. This is consistent with what we might expect, since the healthy lung is more well developed than the lung from the hernia model.
We rely on one big assumption: once skeletonized the reduced non-zero pixel data will fit into memory. While this holds true for datasets of this size (the cropped rabbit lung datasets are roughly 1000 x 1000 x 1000 pixels), it may not hold true for much larger data.
Dask computation is also triggered at a few points through our prototype workflow. Ideally all computation would be delayed until the very final stage.
This project was originally intended to be a quick & easy one. Famous last words!
What I wanted to do was to put the image data in a Dask array, and then use the map_overlap function to do the image filtering, thresholding, skeletonizing, and skeleton analysis. What I soon found was that although the image filtering, thresholding, and skeletonization worked well, the skeleton analysis step had some problems:
The skeletonize function of scikit-image is very memory intensive, and was crashing on a laptop with 16GB RAM.
We solved this by:
The skeleton analysis functions will return results with ragged or non-uniform length for each image chunk. This is unsurprising, because different chunks will have different numbers of non-zero pixels in our skeleton shape.
When working with Dask arrays, there are two very commonly used functions: map_blocks and map_overlap. Here’s what happens when we try a function with ragged outputs with map_blocks versus map_overlap.
With map_blocks, everything works well:
But if we need some overlap for function foo to work correctly, then we run into problems:
Here, the first and last element of the results from foo are trimmed off before the results are concatenated, which we don’t want! Setting the keyword argument trim=False would help avoid this problem, except then we get an error:
Unfortunately for us, it’s really important to have a 1 pixel overlap in our array chunks, so that we can tell if a skeleton branch is ending or continuing on into the next chunk.
There’s some complexity in the way map_overlap results are concatenated back together so rather than diving into that, a more straightforward solution is to use Dask delayed instead. Chris Roat shows a nice example of how we can use Dask delayed in a list comprehension that is then concatenated with Dask (link to original discussion).
Warning: It’s very important to pass in a meta keyword argument to the function from_delayed. Without it, things will be extremely inefficient!
If the meta keyword argument is not given, Dask will try and work out what it should be. Ordinarily that might be a good thing, but inside a list comprehension that means those tasks are computed slowly and sequentially before the main computation even begins, which is horribly inefficient. Since we know ahead of time what kinds of results we expect from our analysis function (we just don’t know the length of each set of results), we can use the utils.make_meta function to help us here.
Now that we’re using Dask delayed to piece together our skeleton analysis results, it’s up to us to handle the array chunks overlap ourselves.
We’ll do that by modifying Dask’s dask.array.core.slices_from_chunks function, into something that will be able to handle an overlap. Some special handling is required at the boundaries of the Dask array, so that we don’t try to slice past the edge of the array.
Here’s what that looks like (gist):
Now that we can slice an image chunk plus an extra pixel of overlap, all we need is a way to do that for all the chunks in an array. Drawing inspiration from this block iteration we make a similar iterator.
With these results, we’re able to create the sparse skeleton graph.
Skeleton branch statistics can be calculate with the skan summarize function. The problem here is that the function expects a Skeleton object instance, but initializing a Skeleton object calls methods that are not compatible for distributed analysis.
We’ll solve this problem by first initializing a Skeleton object instance with a tiny dummy dataset, then overwriting the attributes of the skeleton object with our real results. This is a hack, but it lets us achieve our goal: summary branch statistics for our large dataset.
First we make a Skeleton object instance with dummy data:
Then we overwrite the attributes with the previously calculated results:
Then finally we can calculate the summary branch statistics:
Success!
We’ve achieved distributed skeleton analysis with Dask. You can see the example notebook containing the full details of the skeleton analysis here.
A good next step is modifying the skan library code so that it directly supports distributed skeleton analysis.
If you’d like to get involved, there are a couple of options: