2D cell tracking with Ultrack and MicroSAM¶
This notebook shows how Ultrack can be combined with MicroSAM to track multiple cells.
Ultrack’s capability to allow multiple candidate segmentation and SAM’s ability to predict multiple possible segments allows them to be very effective together.
First, we download the sample data from the cell-tracking challenge.
[1]:
!wget -nc http://data.celltrackingchallenge.net/training-datasets/Fluo-N2DL-HeLa.zip
!unzip -n Fluo-N2DL-HeLa.zip
File ‘Fluo-N2DL-HeLa.zip’ already there; not retrieving.
Archive: Fluo-N2DL-HeLa.zip
Next, we import the required packages. We are using our wrapper of the original MicroSAM package.
[2]:
from pathlib import Path
import napari
from rich import print
from napari.utils.notebook_display import nbscreenshot
from ultrack import MainConfig, track, to_tracks_layer, tracks_to_zarr
from ultrack.utils.array import array_apply, create_zarr
from ultrack.imgproc import normalize
from ultrack.imgproc.sam import MicroSAM, set_peak_maxima_prompt
from ultrack.utils.cuda import import_module, to_cpu, on_gpu, torch_default_device
We open the image using napari.
[3]:
dataset_path = Path("Fluo-N2DL-HeLa/01")
image_files = list(sorted(dataset_path.glob("*.tif")))
image_files = image_files[-15:] # uncomment to process the whole dataset
viewer = napari.Viewer()
viewer.window.resize(1800, 1000)
viewer.open(image_files, stack=True)
image = viewer.layers[0].data
nbscreenshot(viewer)
[3]:
Ultrack expects a detection (foreground vs background) and a cell contour map as input. Our MicroSAM
wrapper assists in obtaining that.
Even though SAM has an automatic segmentation functionality, it does that by querying points in a uniform grid over the image and selecting them by estimating their segmentation quality. However, this can be time-consuming and missing several cells, so we provide a set_peak_maxima_prompt
that uses the maxima of the image intensity as the segmentation cues, similarly to how they are often used in watershed for segmentation.
The MicroSAM
wrapper assigns negative values to the background. Therefore, boundaries >= 0
obtains the cell detection map.
[4]:
predictor = MicroSAM(
model_type="vit_h", # "vit_h_lm" is also a valid alternative
min_mask_region_area=100,
pred_iou_thresh=0.25,
stability_score_thresh=0.7,
)
# peak maxima prompt can be used to detect cells in fluorescence images
# comment it to use the default uniform points prompt
predictor = set_peak_maxima_prompt(
predictor,
sigma=5.0,
min_distance=10,
threshold_rel=0.025,
)
def predict(arr: np.ndarray) -> np.ndarray:
# normalizing the images before predicting
norm_arr = normalize(np.asarray(arr), lower_q=0.1, gamma=0.5)
return predictor(norm_arr)
boundaries = create_zarr(image.shape, np.float16, store_or_path="boundaries.zarr", overwrite=True)
array_apply(
image,
out_array=boundaries,
func=predict,
)
boundaries = da.from_zarr(boundaries)
detection = boundaries >= 0.0
viewer.add_labels(detection)
viewer.add_image(boundaries, visible=False)
nbscreenshot(viewer)
Applying predict ...: 100%|█████████████████████████████████████████████████████████| 15/15 [11:59<00:00, 47.96s/it]
[4]:
Now that we have our detection
and boundaries
you can call the track
function for the tracking on the contour representation of the cells.
The track
function is composed of three steps that can also be called individually: - segment
: Computes the segmentation hypothesis for tracking; - link
: Links and assign edge weights to the segmentation hypothesis; - solve
: Solves the tracking problem by selecting the strongly connected segmentation hypothesis.
Each of these steps requires its configuration, which we’ll set up below. The config documentation can be found here.
[5]:
config = MainConfig()
config.segmentation_config.min_area = 50
config.segmentation_config.max_area = 950
config.segmentation_config.n_workers = 8
config.linking_config.max_distance = 25
config.linking_config.n_workers = 8
config.tracking_config.appear_weight = -1
config.tracking_config.disappear_weight = -1
config.tracking_config.division_weight = -0.1
config.tracking_config.power = 4
config.tracking_config.bias = -0.001
config.tracking_config.solution_gap = 0.0
print(config)
MainConfig( data_config=DataConfig(working_dir=PosixPath('.'), database='sqlite', address=None, n_workers=1), segmentation_config=SegmentationConfig( threshold=0.5, min_area=50, max_area=950, min_frontier=0.0, anisotropy_penalization=0.0, max_noise=0.0, ws_hierarchy=<function watershed_hierarchy_by_area at 0x7fbc7c07bd00>, n_workers=8 ), linking_config=LinkingConfig( n_workers=8, max_neighbors=5, max_distance=25, distance_weight=0.0, z_score_threshold=5.0 ), tracking_config=TrackingConfig( appear_weight=-1, disappear_weight=-1, division_weight=-0.1, dismiss_weight_guess=None, include_weight_guess=None, window_size=None, overlap_size=1, solution_gap=0.0, time_limit=36000, method=0, n_threads=-1, link_function='power', power=4, bias=-0.001 ) )
Now, we only need to execute the track
functions with the configuration we just created and the input images.
[6]:
track(
detection=detection,
edges=boundaries,
config=config,
overwrite=True,
)
Adding nodes to database: 100%|█████████████████████████████████████████████████████| 15/15 [00:15<00:00, 1.00s/it]
Linking nodes.: 100%|███████████████████████████████████████████████████████████████| 14/14 [00:10<00:00, 1.38it/s]
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 13149 rows, 22847 columns and 45371 nonzeros
Model fingerprint: 0x974bf476
Variable types: 0 continuous, 22847 integer (22847 binary)
Coefficient statistics:
Matrix range [1e+00, 1e+00]
Objective range [4e-06, 1e+00]
Bounds range [1e+00, 1e+00]
RHS range [1e+00, 1e+00]
Found heuristic solution: objective -0.0000000
Presolve removed 12782 rows and 22136 columns
Presolve time: 0.51s
Presolved: 367 rows, 711 columns, 1531 nonzeros
Found heuristic solution: objective 630.3203082
Variable types: 0 continuous, 711 integer (711 binary)
Concurrent LP optimizer: primal simplex, dual simplex, and barrier
Showing barrier log only...
Root barrier log...
Ordering time: 0.00s
Barrier statistics:
AA' NZ : 1.098e+03
Factor NZ : 4.365e+03
Factor Ops : 5.861e+04 (less than 1 second per iteration)
Threads : 1
Barrier performed 0 iterations in 0.52 seconds
Barrier solve interrupted - model solved by another algorithm
Solved with dual simplex
Root relaxation: objective 6.379900e+02, 153 iterations, 0.01 seconds
Nodes | Current Node | Objective Bounds | Work
Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time
0 0 637.99002 0 8 630.32031 637.99002 1.22% - 0s
H 0 0 637.9890235 637.99002 0.00% - 0s
Explored 1 nodes (153 simplex iterations) in 0.54 seconds
Thread count was 16 (of 16 available processors)
Solution count 3: 637.989 630.32 -0
Optimal solution found (tolerance 0.00e+00)
Best objective 6.379890235133e+02, best bound 6.379890235133e+02, gap 0.0000%
Saving solution ...
Done!
Theto_tracks_layer
and tracks_to_zarr
export the solution into a napari compatible format.
[7]:
tracks_df, graph = to_tracks_layer(config)
labels = tracks_to_zarr(config, tracks_df)
viewer.add_tracks(tracks_df[["track_id", "t", "y", "x"]].values, graph=graph)
viewer.add_labels(labels)
viewer.layers["detection"].visible = False
nbscreenshot(viewer)
Exporting segmentation masks: 100%|█████████████████████████████████████████████████| 15/15 [00:00<00:00, 45.86it/s]
[7]: