
Biohub - Cell Tracking During Development
Abstruct
- Explains the structure and lazy-loading mechanics of OME-Zarr datasets.
- Step-by-step code walkthrough of the 3D cell detection(Min-Max normalization + blob_dog)and nearest neighbor spatiotemporal tracking(cdist).
- Demonstrates how to avoid Kaggle \"Submission Error\"s through explicit data casting.
Introduction and Background
In this article, I will explain the inner workings of the baseline pipeline for the Kaggle Biohub Cell Tracking competition.
Although this baseline script doesn't use machine learning(and achieves a modest score of 0.505), it covers essential concepts such as OME-Zarr chunk loading, scientific image normalization, 3D centroid extraction, and nearest neighbor matching.
Content
1. Understanding OME-Zarr Directory Structures
Before diving into the code, we must understand how input data is structured. In OME-Zarr, a .zarr file is actually a directory containing metadata and raw binary arrays.
dataset.zarr/
├── .zgroup # Defines that this is a hierarchical group structure
├── .zattrs # Contains physical dimensions and resolution scaling metadata
├── 0/ # The highest resolution image array (store['0'])
│ ├── .zarray # Describes array shape (T,C,Z,Y,X) and chunk compression
│ ├── 0.0.0.0.0 # Chunk binary data (T.C.Z.Y.X index)
│ └── ...
The OME-Zarr specification standardizes how datasets are read:
-
.zarraydefines chunk sizes(e.g.,[1, 1, 30, 256, 256]). This means the large 5D array(T, C, Z, Y, X)is chopped up into small binary files containing exactly 1 frame, 1 channel, 30 slices, and 256x256 pixels. -
0.0.0.0.0corresponds to the first chunk:t=0,c=0,z=0..29,y=0..255, andx=0..255(0-indexed). - When we open a store via
zarr.open(zarr_path, mode='r'), the library only loads setting files like.zgroupand.zattrs. The actual image data is not loaded into memory until we slice it(e.g.,arr[t, 0, :, :, :]). This lazy-loading technique is crucial for managing system memory.
2. Pipeline Architecture
Here is the flowchart and sequence of our cell tracking pipeline:
3. Step-by-Step Code Walkthrough
Step 3.1: Open Zarr Store
We load the Zarr store in read-only mode, fetching only the metadata. We target the highest resolution array named '0'.
# Open the Zarr store
store = zarr.open(zarr_path, mode='r')
# Reference the highest resolution array
arr = store['0']
Step 3.2: Iterate Time Frames
We extract the total frames T and loop through them. We use tqdm to display a progress bar.
num_frames = arr.shape[0]
prev_nodes_info = []
for t in tqdm(range(num_frames), desc="Frames"):
# Process each frame sequentially
Note: tqdm will display real-time statistics like: Frames: 23%|███ | 23/100 [00:45<02:30, 1.95s/it] showing the current progress and expected remaining time.
Step 3.3: Slice 3D Image
We slice the current 3D image (Z, Y, X) for frame t. This operation triggers the disk read and decompresses the specific binary chunk file.
has_channels = (arr.ndim == 5)
if has_channels:
img_3d = arr[t, 0, :, :, :]
else:
img_3d = arr[t, :, :, :]
Step 3.4: Min-Max Normalization & 3D Cell Detection
Microscope images often have uneven illumination. We normalize the values to 0.0..1.0 to maximize contrast, and run blob_dog(Difference of Gaussians)to detect cell nuclei. If no cells are found, we log statistics(Min/Max/Mean)for debugging.
def detect_cells_3d(image_3d, min_sigma=2, max_sigma=5, threshold=0.05):
img_min = image_3d.min()
img_max = image_3d.max()
if img_max > img_min:
img_norm = (image_3d.astype(np.float32) - img_min) / (img_max - img_min)
else:
img_norm = np.zeros_like(image_3d, dtype=np.float32)
blobs = blob_dog(img_norm, min_sigma=min_sigma, max_sigma=max_sigma, threshold=threshold)
if len(blobs) > 0:
return blobs[:, :3] # Return (z, y, x)
return np.empty((0, 3))
Step 3.5: Physical Scale Transformation & Nearest Neighbor Tracking
Microscope pixels are anisotropic(Z-slice depth is 1.625 µm, while XY pixels are 0.40625 µm). We scale our pixel coordinates to micrometer physical space. Then, we use cdist to compute a distance matrix and execute a greedy nearest-neighbor matching algorithm.
# Convert pixel coordinates to physical space (micrometers)
scale_zyx = np.array([1.625, 0.40625, 0.40625])
coords_prev_physical = coords_prev * scale_zyx
coords_curr_physical = coords_curr * scale_zyx
# Link cells within a threshold of 15.0 µm
links = track_frame_to_frame(coords_prev_physical, coords_curr_physical, max_distance=15.0)
Step 3.6: Combine Nodes & Edges and Type Cast
After processing all frames, we merge detected nodes and tracking edges into a unified DataFrame. To prevent \"Submission Error\"s due to NaNs or float types, we cast all columns explicitly to int64.
# Combine DataFrames
df_sub = pd.concat([df_nodes, df_edges], ignore_index=True)
df_sub.insert(0, 'id', range(len(df_sub)))
# Cast types rigidly
df_sub = df_sub.astype({
'id': 'int64',
'node_id': 'int64',
't': 'int64',
'z': 'int64',
'y': 'int64',
'x': 'int64',
'source_id': 'int64',
'target_id': 'int64'
})
Step 3.7: Export CSV
df_sub.to_csv("submission.csv", index=False)
Complete Source Code
Here is the full source code for the baseline pipeline, achieving a score of 0.505 on Kaggle:
!pip install --no-index --find-links=/kaggle/input/datasets/aaaa1597/zarr-offline-installation-wheels/zarr_wheels zarr
import os
import glob
import zarr
import numpy as np
import pandas as pd
from skimage.feature import blob_dog
from scipy.spatial.distance import cdist
import matplotlib.pyplot as plt
from tqdm import tqdm
###########################################
# 1. Setup and Data Path Verification
# Set up the input data paths in the Kaggle environment.
CANDIDATES = [
"/kaggle/input/biohub-cell-tracking-during-development",
"/kaggle/input/competitions/biohub-cell-tracking-during-development",
]
ROOT = "/kaggle/input/biohub-cell-tracking-during-development"
for p in CANDIDATES:
if os.path.exists(os.path.join(p, "test")):
ROOT = p
break
TEST_DIR = os.path.join(ROOT, "test")
print(f"Using TEST_DIR: {TEST_DIR}")
test_zarr_paths = glob.glob(os.path.join(TEST_DIR, "*.zarr"))
print(f"Found {len(test_zarr_paths)} test datasets.")
for p in test_zarr_paths:
print(f" {os.path.basename(p)}")
###########################################
# 2. 3D Blob Detection and Tracking Implementation
# Detect cells in each time frame and track them between adjacent frames.
def detect_cells_3d(image_3d, min_sigma=2, max_sigma=5, threshold=0.05):
"""Detect cell centroids (Z,Y,X) from a 3D image."""
img_min = image_3d.min()
img_max = image_3d.max()
if img_max > img_min:
img_norm = (image_3d.astype(np.float32) - img_min) / (img_max - img_min)
else:
img_norm = np.zeros_like(image_3d, dtype=np.float32)
blobs = blob_dog(img_norm, min_sigma=min_sigma, max_sigma=max_sigma, threshold=threshold)
if len(blobs) > 0:
return blobs[:, :3]
return np.empty((0, 3))
def track_frame_to_frame(coords_prev, coords_curr, max_distance=15.0):
"""Perform nearest neighbor matching between adjacent frames."""
if len(coords_prev) == 0 or len(coords_curr) == 0:
return []
dists = cdist(coords_prev, coords_curr)
links = []
used_curr = set()
for i in range(len(coords_prev)):
js = np.argsort(dists[i])
for j in js:
if j not in used_curr and dists[i, j] <= max_distance:
links.append((i, j))
used_curr.add(j)
break
return links
###########################################
# 3. Pipeline Execution
# Loop through all test datasets to collect node and edge information.
nodes = []
edges = []
print(f"Processing {len(test_zarr_paths)} datasets.")
for zarr_path in test_zarr_paths:
dataset_name = os.path.basename(zarr_path).replace(".zarr", "")
print(f"Processing dataset: {dataset_name}")
node_counter = 1
store = zarr.open(zarr_path, mode='r')
arr = store['0']
has_channels = (arr.ndim == 5)
num_frames = arr.shape[0]
prev_nodes_info = []
for t in tqdm(range(num_frames), desc="Frames"):
if has_channels:
img_3d = arr[t, 0, :, :, :]
else:
img_3d = arr[t, :, :, :]
coords = detect_cells_3d(img_3d, min_sigma=2, max_sigma=5, threshold=0.05)
if len(coords) == 0:
min_val = np.min(img_3d)
max_val = np.max(img_3d)
mean_val = np.mean(img_3d)
print(f"Warning: 0 cells detected in dataset '{dataset_name}' at frame {t}. "
f"Image Stats -> Min: {min_val}, Max: {max_val}, Mean: {mean_val:.4f}")
curr_nodes_info = []
for idx, (z, y, x) in enumerate(coords):
node_id = node_counter
node_counter += 1
nodes.append({
"dataset": dataset_name,
"row_type": "node",
"node_id": int(node_id),
"t": int(t),
"z": int(round(z)),
"y": int(round(y)),
"x": int(round(x)),
"source_id": -1,
"target_id": -1
})
curr_nodes_info.append((idx, node_id, (z, y, x)))
if len(prev_nodes_info) > 0 and len(curr_nodes_info) > 0:
coords_prev = np.array([item[2] for item in prev_nodes_info])
coords_curr = np.array([item[2] for item in curr_nodes_info])
scale_zyx = np.array([1.625, 0.40625, 0.40625])
coords_prev_physical = coords_prev * scale_zyx
coords_curr_physical = coords_curr * scale_zyx
links = track_frame_to_frame(coords_prev_physical, coords_curr_physical, max_distance=15.0)
if len(links) == 0:
print(f"Warning: 0 tracking edges created between frame {t-1} and {t} in dataset '{dataset_name}'. "
f"Previous node count: {len(prev_nodes_info)}, Current node count: {len(curr_nodes_info)}.")
for idx_prev, idx_curr in links:
src_id = prev_nodes_info[idx_prev][1]
tgt_id = curr_nodes_info[idx_curr][1]
edges.append({
"dataset": dataset_name,
"row_type": "edge",
"node_id": -1,
"t": -1,
"z": -1,
"y": -1,
"x": -1,
"source_id": int(src_id),
"target_id": int(tgt_id)
})
prev_nodes_info = curr_nodes_info
###########################################
# 4. Submission File Generation and Verification
columns_order = ["dataset", "row_type", "node_id", "t", "z", "y", "x", "source_id", "target_id"]
if len(nodes) == 0:
df_nodes = pd.DataFrame(columns=columns_order)
else:
df_nodes = pd.DataFrame(nodes)
if len(edges) == 0:
df_edges = pd.DataFrame(columns=columns_order)
else:
df_edges = pd.DataFrame(edges)
df_sub = pd.concat([df_nodes, df_edges], ignore_index=True)
df_sub.insert(0, 'id', range(len(df_sub)))
df_sub = df_sub[["id"] + columns_order]
df_sub = df_sub.astype({
'id': 'int64',
'node_id': 'int64',
't': 'int64',
'z': 'int64',
'y': 'int64',
'x': 'int64',
'source_id': 'int64',
'target_id': 'int64'
})
print(f"Total rows: {len(df_sub)}")
df_sub.to_csv("submission.csv", index=False)
print("submission.csv has been successfully generated!")
Japanese Version of This Series
You can read the original Japanese version of this article on Zenn:
Conclusion
Now that we have established a baseline, the next logical step is to improve scores by replacing blob_dog with deep learning models(like Cellpose or StarDist)and optimizing matching algorithms(like the Hungarian method).
I hope this helps!


Top comments (0)