3D cell tracking with Ultrack

This document shows how Ultrack can track cells in large fluorescence microscopy data. We use the zebrafish tail data from zebrahub acquired with DaXi.

Here, we use the image processing functions provided with Ultrack, but any other segmentation method could be used. For example, refer to the stardist_2 example to see how to use Ultrack with segmentation labels.

Note: This is not the same methodology used in the zebrahub paper. There, we used a convolutional neural network to predict the nuclei boundaries and the initial version of Ultrack software.

First, we import the required packages. You can install them using the conda environment file on this folder.

[1]:
import napari
import numpy as np
import dask.array as da
from napari.utils.notebook_display import nbscreenshot
from rich.pretty import pprint

from ultrack import MainConfig, load_config, track, to_tracks_layer, tracks_to_zarr
from ultrack.imgproc import robust_invert, detect_foreground
from ultrack.utils.array import array_apply, create_zarr

We open the data remotely using the napari-ome-zarr plugin.

[2]:
viewer = napari.Viewer()
viewer.window.resize(1800, 1000)

im_layer, = viewer.open(
    "http://public.czbiohub.org/royerlab/zebrahub/imaging/single-objective/ZSNS001_tail.ome.zarr/",
    plugin="napari-ome-zarr",
    rendering="attenuated_mip",
    gamma=0.7,
    contrast_limits=(0, 500),
)

nbscreenshot(viewer)
[2]:

We setup to process only a subset of frames from the dataset, feel free to change the values below.

[3]:
voxel_size = im_layer.scale[1:]  # (z, y, x) pixel size
start_idx = 400  # starting frame
viewer.dims.set_point(0, start_idx + 5)

image = im_layer.data[0]
image = image[start_idx:(start_idx + 10)]  # processing only a subset of time points
image.shape
[3]:
(10, 420, 1217, 1091)

We detect which pixels contains cells and which are background using the array_apply function to apply the detect_foreground to each individual time point while saving it to the detection.zarr path.

[4]:
detection = create_zarr(image.shape, bool, store_or_path="detection.zarr", overwrite=True)
array_apply(
    image,
    out_array=detection,
    func=detect_foreground,
    sigma=25.0,
    voxel_size=voxel_size,
)

viewer.add_labels(
    detection,
    visible=True,
    translate=(start_idx, 0, 0, 0),
    scale=voxel_size,
).contour = 2

nbscreenshot(viewer)
Applying detect_foreground ...: 100%|██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 10/10 [01:38<00:00,  9.90s/it]
[4]:

Ultrack requires an estimate of the cell boundaries, we approximate this by inverting the image using the robust_invert function and saving it to boundaries.zarr.

[5]:
boundaries = create_zarr(image.shape, np.float16, store_or_path="boundaries.zarr", overwrite=True)
array_apply(
    image,
    out_array=boundaries,
    func=robust_invert,
    voxel_size=voxel_size,
)

viewer.add_image(boundaries, visible=False, translate=(start_idx, 0, 0, 0), scale=voxel_size)
Applying robust_invert ...: 100%|██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 10/10 [00:36<00:00,  3.61s/it]
[5]:
<Image layer 'boundaries' at 0x7f3f8bdf54e0>

Now we start the tracking stage. Ultrack parameters are provided in a configuration file which is equivalent to the MainConfig object.

Using a configuration file helps reproducing previous runs and it can be easily shared when using distributed processing.

In this example the configuration is already configured so we only need to load it. We change the segmentation threshold parameter as an example.

The configuration documentation can be found here.

[6]:
cfg = load_config("config.toml")
# cfg =  MainConfig()  # or load default config
cfg.segmentation_config.threshold = 0.5
pprint(cfg)
MainConfig(
data_config=DataConfig(working_dir=PosixPath('.'), database='sqlite', address=None, n_workers=1),
segmentation_config=SegmentationConfig(
│   │   threshold=0.5,
│   │   min_area=500,
│   │   max_area=10000,
│   │   min_frontier=0.0,
│   │   anisotropy_penalization=0.0,
│   │   max_noise=0.0,
│   │   ws_hierarchy=<function watershed_hierarchy_by_area at 0x7f404268c670>,
│   │   n_workers=1
),
linking_config=LinkingConfig(
│   │   n_workers=1,
│   │   max_neighbors=5,
│   │   max_distance=5.0,
│   │   distance_weight=0.0,
│   │   z_score_threshold=5.0
),
tracking_config=TrackingConfig(
│   │   appear_weight=-0.002,
│   │   disappear_weight=-0.01,
│   │   division_weight=-0.001,
│   │   dismiss_weight_guess=None,
│   │   include_weight_guess=None,
│   │   window_size=100,
│   │   overlap_size=5,
│   │   solution_gap=0.0,
│   │   time_limit=120000,
│   │   method=0,
│   │   n_threads=0,
│   │   link_function='power',
│   │   power=4.0,
│   │   bias=0.0
)
)

Once the configuration is set, it can be used with the cell detection and boundaries to compute the tracks.

[7]:
track(
    cfg,
    detection=detection,
    edges=boundaries,
    scale=voxel_size,
    overwrite=True,
)
Adding nodes to database: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 10/10 [14:15<00:00, 85.53s/it]
Linking nodes.: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 9/9 [00:21<00:00,  2.34s/it]
Academic license - for non-commercial use only - expires 2024-08-06
Using GRB solver
Solving ILP batch 0
Constructing ILP ...
Solving ILP ...
Gurobi Optimizer version 9.1.2 build v9.1.2rc0 (linux64)
Thread count: 8 physical cores, 16 logical processors, using up to 16 threads
Optimize a model with 694321 rows, 901725 columns and 2153390 nonzeros
Model fingerprint: 0xc491f3a0
Variable types: 0 continuous, 901725 integer (901725 binary)
Coefficient statistics:
  Matrix range     [1e+00, 1e+00]
  Objective range  [2e-16, 7e-01]
  Bounds range     [1e+00, 1e+00]
  RHS range        [1e+00, 1e+00]
Found heuristic solution: objective -0.0000000
Presolve removed 348598 rows and 358070 columns (presolve time = 8s) ...
Presolve removed 348598 rows and 358070 columns
Presolve time: 7.91s
Presolved: 345723 rows, 543655 columns, 1274833 nonzeros
Found heuristic solution: objective 5043.3524392
Variable types: 0 continuous, 543655 integer (543655 binary)

Concurrent LP optimizer: primal simplex, dual simplex, and barrier
Showing barrier log only...

Root barrier log...

Ordering time: 0.27s

Barrier statistics:
 AA' NZ     : 1.181e+06
 Factor NZ  : 5.885e+06 (roughly 400 MBytes of memory)
 Factor Ops : 1.648e+08 (less than 1 second per iteration)
 Threads    : 6

                  Objective                Residual
Iter       Primal          Dual         Primal    Dual     Compl     Time
   0   4.21353902e+04  3.99028854e+05  2.47e+01 3.77e-01  1.78e+00    10s
   1   2.12386917e+04  2.47108458e+05  1.03e+01 1.25e-15  6.30e-01    10s
   2   7.87747235e+03  8.70116483e+04  6.09e-01 8.97e-16  7.84e-02    10s
   3   8.08051876e+03  3.09158317e+04  5.61e-02 6.94e-16  1.79e-02    11s
   4   9.84651687e+03  2.00505628e+04  5.16e-03 4.72e-16  7.71e-03    11s
   5   1.08136667e+04  1.62059692e+04  2.42e-03 3.75e-16  4.07e-03    11s
   6   1.16018041e+04  1.45098500e+04  1.01e-03 5.13e-16  2.19e-03    11s
   7   1.20816818e+04  1.36345628e+04  4.26e-04 4.44e-16  1.17e-03    11s
   8   1.24176057e+04  1.31090427e+04  1.26e-04 3.89e-16  5.20e-04    12s
   9   1.25344957e+04  1.29002122e+04  5.41e-05 3.61e-16  2.75e-04    12s
  10   1.25908505e+04  1.27840755e+04  2.44e-05 4.16e-16  1.45e-04    12s
  11   1.26307457e+04  1.27115438e+04  6.41e-06 4.55e-16  6.07e-05    12s
  12   1.26401826e+04  1.26726147e+04  2.85e-06 5.27e-16  2.44e-05    13s
  13   1.26445408e+04  1.26610521e+04  1.37e-06 4.72e-16  1.24e-05    13s
  14   1.26472290e+04  1.26553951e+04  4.52e-07 4.37e-16  6.14e-06    13s
  15   1.26487004e+04  1.26520030e+04  7.88e-08 3.89e-16  2.48e-06    13s

Barrier performed 15 iterations in 13.02 seconds
Barrier solve interrupted - model solved by another algorithm


Solved with dual simplex

Root relaxation: objective 1.264910e+04, 131331 iterations, 4.67 seconds

    Nodes    |    Current Node    |     Objective Bounds      |     Work
 Expl Unexpl |  Obj  Depth IntInf | Incumbent    BestBd   Gap | It/Node Time

     0     0 12649.0980    0 27003 5043.35244 12649.0980   151%     -   15s
H    0     0                    12459.677634 12649.0980  1.52%     -   16s
H    0     0                    12476.150138 12649.0980  1.39%     -   17s
     0     0 12580.7032    0 5199 12476.1501 12580.7032  0.84%     -   21s
H    0     0                    12535.283769 12580.7032  0.36%     -   22s
H    0     0                    12553.596884 12580.7032  0.22%     -   22s
H    0     0                    12562.441568 12580.7032  0.15%     -   23s
H    0     0                    12574.963919 12580.7032  0.05%     -   24s
     0     0 12580.7016    0 5198 12574.9639 12580.7016  0.05%     -   25s
     0     0 12575.9946    0  880 12574.9639 12575.9946  0.01%     -   26s
H    0     0                    12574.984242 12575.9946  0.01%     -   27s
     0     0 12575.9817    0  856 12574.9842 12575.9817  0.01%     -   29s
     0     0 12575.6498    0  126 12574.9842 12575.6498  0.01%     -   32s
H    0     0                    12575.620168 12575.6498  0.00%     -   34s
     0     0 12575.6412    0  104 12575.6202 12575.6412  0.00%     -   35s
*    0     0               0    12575.635833 12575.6358  0.00%     -   37s

Cutting planes:
  Gomory: 142
  Cover: 45
  Clique: 2203
  MIR: 38
  Zero half: 4202
  RLT: 5058

Explored 1 nodes (139255 simplex iterations) in 37.43 seconds
Thread count was 16 (of 16 available processors)

Solution count 10: 12575.6 12575.6 12575 ... 5043.35

Optimal solution found (tolerance 0.00e+00)
Best objective 1.257563583284e+04, best bound 1.257563583284e+04, gap 0.0000%
Saving solution ...
Done!

Finally, we export the resulting tracks into the napari tracks format and a zarr with the instance segmentation masks.

[8]:
tracks_df, graph = to_tracks_layer(cfg)
tracks_df.to_csv("tracks.csv", index=False)

segments = tracks_to_zarr(
    cfg,
    tracks_df,
    store_or_path="segments.zarr",
    overwrite=True,
)

viewer.add_tracks(
    tracks_df[["track_id", "t", "z", "y", "x"]],
    name="tracks",
    graph=graph,
    scale=voxel_size,
    translate=(start_idx, 0, 0, 0),
    visible=False,
)

viewer.add_labels(
    da.from_zarr(segments),
    name="segments",
    scale=voxel_size,
    translate=(start_idx, 0, 0, 0),
).contour = 2

nbscreenshot(viewer)
Exporting segmentation masks: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 10/10 [00:19<00:00,  1.91s/it]
[8]: