执行摘要

在这篇博文中,我们展示了如何使用 Dask 修改骨架网络分析,使其在内存受限的环境下工作(例如:在你的笔记本电脑上)。这使得它更容易访问:可以在小型笔记本电脑上运行,而无需访问超级计算集群。示例代码也在此提供

目录

骨架结构无处不在

许多生物结构具有骨架或网络状的形状。我们在各种地方都能看到它们,包括

  • 血管分支
  • 气道分支
  • 大脑中的神经元网络
  • 植物的根结构
  • 叶脉
  • ……以及更多

分析这些骨架的结构可以为我们提供关于该系统生物学的重要信息。

科学问题

对于这篇博文,我们将研究肺内的血管。这些数据由 Marcus KitchenAndrew Stainsby 及其协作团队与我们共享。

Skeleton network of blood vessels within a healthy lung

这个研究小组专注于肺部发育。我们想比较健康肺部的血管与疝气模型肺部的血管。在疝气模型中,肺部发育不全,受到挤压,且体积更小。

计算问题

这些图像体大约是 1000x1000x1000 像素。这看起来不大,但考虑到处理分析所需的高内存消耗,在笔记本电脑上运行时会崩溃。

如果你内存不足,有两种可能的方法

  1. 获取更多内存。在更大的计算机上运行,或将其转移到超级计算集群。这样做的好处是你无需重写代码,但这确实需要访问更强大的计算机硬件。

  2. 管理你现有的内存。Dask 在这方面表现出色。如果我们使用 Dask 并对数组进行合理的分块,我们可以有效地管理内存,从而不会达到内存上限并崩溃。这样做的好处是你无需购买更多计算机硬件,但这确实需要重写一些代码。

我们的方法

我们采取了第二种方法,使用 Dask,以便在内存受限的小型笔记本电脑上运行分析而不会崩溃。这使得它对更多人来说更容易访问。

所有图像预处理步骤将使用 dask-image 以及 scikit-imageskeletonize 函数完成。

我们使用 skan 作为我们分析管道的骨干。skan 是一个用于骨架图像分析的库。给定一个骨架图像,它可以描述分支的统计信息。为了提高速度,该库使用 numba 进行了加速(如果你好奇,可以在这次演讲及其相关笔记本中了解更多)。

此处提供了一个包含骨架分析完整细节的示例笔记本。你可以继续阅读以了解其亮点。

结果

健康肺和疝气肺血管分支的统计数据显示两者之间存在明显差异。

最显著的是血管分支数量的差异。疝气肺的血管分支数量不到健康肺的 40%。

血管尺寸也存在定量差异。这里是一个小提琴图,显示了每个血管分支起点和终点之间距离的分布。我们可以看到,总体而言,疝气肺中的血管分支起点和终点距离更近。这与我们预期的结果一致,因为健康肺比疝气模型中的肺发育更充分,而且疝气将该肺压缩到了更小的整体空间。

Violin plot comparing blood vessel thickness between a healthy and herniated lung

编辑:这篇博文之前将欧几里得距离小提琴图描述为测量血管厚度。这是不正确的,并且在发布前的评审过程中没有发现此错误。此博文已更新,正确描述了 euclidean-distance 测量是分支起点和终点之间的距离,就像在这两点之间拉紧一条线一样。另一种测量 branch-length 描述了包括所有弯曲曲折在内的总分支长度。

局限性

我们依赖一个重要假设:一旦骨架化,减少的非零像素数据将能够放入内存。虽然对于这种大小的数据集(裁剪后的兔子肺数据集大约是 1000 x 1000 x 1000 像素)来说,这是成立的,但对于更大的数据可能不成立。

在我们的原型工作流程中,Dask 计算也在几个点被触发。理想情况下,所有计算都应延迟到最后阶段。

遇到的问题

这个项目最初打算是一个快速简单的项目。真是flag!

我本来想做的是将图像数据放入 Dask 数组,然后使用 map_overlap 函数进行图像滤波、阈值处理、骨架化和骨架分析。但我很快发现,虽然图像滤波、阈值处理和骨架化工作得很好,但骨架分析步骤存在一些问题

  • Dask 的 map_overlap 函数不能很好地处理来自不同图像块的不规则或非均匀形状的结果,并且……

  • skan 库中的内部函数编写方式与分布式计算不兼容。

我们如何解决它们

问题 1:scikit-image 的 skeletonize 函数由于内存不足而崩溃

scikit-image 提供的 skeletonize 函数非常占用内存,并在 16GB RAM 的笔记本电脑上崩溃。

我们通过以下方法解决了这个问题

  • 使用 dask-image imread 将图像数据放入 Dask 数组中,
  • 对 Dask 数组进行重新分块。我们需要将分块形状从 2D 切片更改为小的立方体体积,以便下一步计算更高效。我们可以选择分块的总体大小,使其能够保持在 skeletonize 所需的内存阈值以下。
  • 最后,我们使用 map_overlap 函数在 Dask 数组块上运行 skeletonize 函数。通过限制数组块的大小,我们保持在内存阈值以下!

问题 2:Dask 数组块产生不规则或非均匀输出

骨架分析函数将为每个图像块返回长度不规则或非均匀的结果。这并不奇怪,因为不同的块在我们的骨架形状中会有不同数量的非零像素。

在使用 Dask 数组时,有两个非常常用的函数:map_blocksmap_overlap。当我们尝试使用 map_blocksmap_overlap 处理具有不规则输出的函数时,会发生以下情况。

import dask.array as da
import numpy as np

x = da.ones((100, 10), chunks=(10, 10))

def foo(a):  # our dummy analysis function
    random_length = np.random.randint(1, 7)
    return np.arange(random_length)

使用 map_blocks 时,一切正常

result = da.map_blocks(foo, x, drop_axis=1)
result.compute()  # this works well

但是如果我们函数 foo 需要一些重叠才能正确工作,那么就会遇到问题

result = da.map_overlap(foo, x, depth=1, drop_axis=1)
result.compute()  # incorrect results

在这里,在结果被连接之前,foo 的第一个和最后一个元素被截掉了,这不是我们想要的!设置关键字参数 trim=False 有助于避免这个问题,但那样我们就会得到一个错误

result = da.map_overlap(foo, x, depth=1, trim=False, drop_axis=1)
result.compute()  # ValueError

不幸的是,对于我们来说,在数组块中保留 1 像素的重叠非常重要,这样我们才能判断骨架分支是结束了还是延续到了下一个块。

map_overlap 结果连接回来的方式有些复杂,因此与其深入研究,更直接的解决方案是使用 Dask delayedChris Roat 提供了一个很好的例子,展示了我们如何在列表推导式中使用 Dask delayed,然后用 Dask 进行连接(原始讨论链接)。

import numpy as np
import pandas as pd

import dask
import dask.array as da
import dask.dataframe as dd

x = da.ones((20, 10), chunks=(10, 10))

@dask.delayed
def foo(a):
    size = np.random.randint(1,10)  # Make each dataframe a different size
    return pd.DataFrame({'x': np.arange(size),
                         'y': np.arange(10, 10+size)})

meta = dd.utils.make_meta([('x', np.int64), ('y', np.int64)])
blocks = x.to_delayed().ravel()  # no overlap
results = [dd.from_delayed(foo(b), meta=meta) for b in blocks]
ddf = dd.concat(results)
ddf.compute()

警告:meta 关键字参数传递给 from_delayed 函数非常重要。否则,效率会非常低下!

如果未提供 meta 关键字参数,Dask 将尝试自行确定其值。通常这可能是好事,但在列表推导式中,这意味着这些任务在主计算开始之前就会缓慢地按顺序计算,效率极低。由于我们提前知道分析函数会返回什么样的结果(我们只是不知道每组结果的长度),因此可以使用 utils.make_meta 函数来帮助我们。

问题 3:抓取带有重叠部分的图像块

现在我们使用 Dask delayed 来组合我们的骨架分析结果,我们需要自己处理数组块的重叠部分。

我们将通过修改 Dask 的 dask.array.core.slices_from_chunks 函数来实现这一点,使其能够处理重叠。在 Dask 数组的边界需要进行一些特殊处理,以便我们不会尝试切片超出数组边缘。

代码如下所示(gist

from itertools import product
from dask.array.slicing import cached_cumsum

def slices_from_chunks_overlap(chunks, array_shape, depth=1):
    cumdims = [cached_cumsum(bds, initial_zero=True) for bds in chunks]

    slices = []
    for starts, shapes in zip(cumdims, chunks):
        inner_slices = []
        for s, dim, maxshape in zip(starts, shapes, array_shape):
            slice_start = s
            slice_stop = s + dim
            if slice_start > 0:
                slice_start -= depth
            if slice_stop >= maxshape:
                slice_stop += depth
            inner_slices.append(slice(slice_start, slice_stop))
        slices.append(inner_slices)

    return list(product(*slices))

现在我们可以切取一个图像块并增加一个额外的重叠像素,我们只需要一种方法对数组中的所有块执行此操作。借鉴此处的块迭代,我们创建了一个类似的迭代器。

block_iter = zip(
    np.ndindex(*image.numblocks),
    map(functools.partial(operator.getitem, image),
        slices_from_chunks_overlap(image.chunks, image.shape, depth=1))
)

meta = dd.utils.make_meta([('row', np.int64), ('col', np.int64), ('data', np.float64)])
intermediate_results = [dd.from_delayed(skeleton_graph_func(block), meta=meta) for _, block in block_iter]
results = dd.concat(intermediate_results)
results = results.drop_duplicates()  # we need to drop duplicates because it counts pixels in the overlapping region twice

有了这些结果,我们就能创建稀疏骨架图了。

问题 4:使用 skan 计算摘要统计

骨架分支统计可以使用 skan summarize 函数计算。这里的问题是该函数需要一个 Skeleton 对象实例,但是初始化 Skeleton 对象会调用与分布式分析不兼容的方法。

我们将通过以下方法解决这个问题:首先使用一个微小的虚拟数据集初始化一个 Skeleton 对象实例,然后用我们的真实结果覆盖骨架对象的属性。这是一个权宜之计,但它能让我们达到目标:为我们的大数据集计算摘要分支统计。

首先,我们使用虚拟数据创建一个 Skeleton 对象实例

from skan._testdata import skeleton0

skeleton_object = Skeleton(skeleton0)  # initialize with dummy data

然后我们用之前计算的结果覆盖属性

skeleton_object.skeleton_image = ...
skeleton_object.graph = ...
skeleton_object.coordinates
skeleton_object.degrees = ...
skeleton_object.distances = ...
...

最后,我们可以计算摘要分支统计

from skan import summarize

statistics = summarize(skel_obj)
statistics.head()
  骨架 ID 源节点 ID 目标节点 ID 分支距离 分支类型 平均像素值 像素值标准差 图像坐标源 0 图像坐标源 1 图像坐标源 2 图像坐标目标 0 图像坐标目标 1 图像坐标目标 2 坐标源 0 坐标源 1 坐标源 2 坐标目标 0 坐标目标 1 坐标目标 2 欧几里得距离
0 1 1 2 1 2 0.474584 0.00262514 22 400 595 22 400 596 22 400 595 22 400 596 1
1 2 3 9 8.19615 2 0.464662 0.00299629 37 400 622 43 392 590 37 400 622 43 392 590 33.5261
2 3 10 11 1 2 0.483393 0.00771038 49 391 589 50 391 589 49 391 589 50 391 589 1
3 5 13 19 6.82843 2 0.464325 0.0139064 52 389 588 55 385 588 52 389 588 55 385 588 5
4 7 21 23 2 2 0.45862 0.0104024 57 382 587 58 380 586 57 382 587 58 380 586 2.44949
statistics.describe()
  骨架 ID 源节点 ID 目标节点 ID 分支距离 分支类型 平均像素值 像素值标准差 图像坐标源 0 图像坐标源 1 图像坐标源 2 图像坐标目标 0 图像坐标目标 1 图像坐标目标 2 坐标源 0 坐标源 1 坐标源 2 坐标目标 0 坐标目标 1 坐标目标 2 欧几里得距离
计数 1095 1095 1095 1095 1095 1095 1095 1095 1095 1095 1095 1095 1095 1095 1095 1095 1095 1095 1095 1095
平均值 2089.38 11520.1 11608.6 22.9079 2.00091 0.663422 0.0418607 591.939 430.303 377.409 594.325 436.596 373.419 591.939 430.303 377.409 594.325 436.596 373.419 190.13
标准差 636.377 6057.61 6061.18 24.2646 0.0302199 0.242828 0.0559064 174.04 194.499 97.0219 173.353 188.708 96.8276 174.04 194.499 97.0219 173.353 188.708 96.8276 151.171
最小值 1 1 2 1 2 0.414659 6.79493e-06 22 39 116 22 39 114 22 39 116 22 39 114 0
25% 1586 6215.5 6429.5 1.73205 2 0.482 0.00710439 468.5 278.5 313 475 299.5 307 468.5 278.5 313 475 299.5 307 72.6946
50% 2431 11977 12010 16.6814 2 0.552626 0.0189069 626 405 388 627 410 381 626 405 388 627 410 381 161.059
75% 2542.5 16526.5 16583 35.0433 2 0.768359 0.0528814 732 579 434 734 590 432 732 579 434 734 590 432 265.948
最大值 8034 26820 26822 197.147 3 1.29687 0.357193 976 833 622 976 841 606 976 833 622 976 841 606 737.835

成功!

我们已经使用 Dask 实现了分布式骨架分析。您可以在此处查看包含骨架分析完整细节的示例笔记本。

下一步是什么?

一个好的下一步是修改 skan 库代码,使其直接支持分布式骨架分析。

你能如何提供帮助

如果你想参与进来,有几个选择

  1. 尝试在你自己的数据上进行类似的分析。包含完整示例代码的笔记本在此提供。你可以在 Dask slacktwitter 上分享或提问。
  2. 帮助为 skan 添加分布式骨架分析支持。请前往 skan issues 页面,如果你想加入,请留言。

博客评论由 Disqus 提供支持