
Figure: Conceptual illustration of 3D cell tracking data analysis and high-performance local CV setup
Abstract
In Kaggle's "Biohub - Cell Tracking During Development" competition, performing model iterations on Kaggle Notebooks for an 81.4GB dataset incurs massive wait times for every "Run All", "Save Version", and "Submit". This article demonstrates how to build a fast and reliable local cross-validation (CV) pipeline on a Windows CPU environment using Astral's uv package manager, resolving Windows DLL security blocks, applying PyTorch CPU patches, and optimizing graph node accessors.
Overview
When competing in computer vision and bioinformatics challenges with massive 3D image datasets (.zarr) and temporal cell tracking graphs (.geff), relying solely on cloud platform execution drastically slows down experimentation.
To achieve a fast feedback loop (seconds to minutes per iteration), we constructed a self-contained local evaluation environment that processes all 199 embryo datasets and outputs a micro-averaged CV score perfectly aligned with Kaggle's official evaluation logic.

Figure: Complete workflow of the local CV pipeline setup
Details
(1) Isolated Directory Structure
We organized the virtual environment, dataset, evaluation repository, and notebooks under a clean local_env folder to maintain portable environment isolation.
007_kaggle_Biohub-Cell_Tracking_During_Development/
├── local_env/ <- Virtual environment created with uv
│ ├── Scripts/python.exe <- Python interpreter path for VS Code
│ ├── data/ <- Downloaded Kaggle dataset (81.4 GB)
│ │ ├── train/
│ │ └── test/
│ ├── src/
│ │ ├── eda_visualizer.py
│ │ └── kaggle_cell_tracking_competition/ <- Organizer's official evaluation code
│ │ └── src/tracking_cellmot/
│ └── notebooks/
│ └── local_validation.ipynb <- Local validation notebook
└── kaggle_Biohub-Cell_Tracking_During_Development/
└── notebooks/
└── baseline_pipeline.ipynb <- Kaggle submission notebook
(2) Fast Package Management with uv
We utilized Astral's Rust-based package manager uv to manage dependencies efficiently.
:: Create local_env virtual environment
uv venv local_env
:: Install dependencies including pre-release tracksdata and CPU PyTorch
uv pip install --prerelease allow --python local_env --extra-index-url https://download.pytorch.org/whl/cpu tracksdata polars zarr scikit-image scipy torch pandas matplotlib
(3) Resolving Windows Security DLL Blocks (Smart App Control)
On Windows environments, binaries installed via pip or uv (such as rustworkx .pyd files) may carry a Mark-of-the-Web (MotW) flag, resulting in DLL load failed errors during import. We unblocked all binaries in PowerShell:
Get-ChildItem -Path local_env -Recurse | Unblock-File
(4) CPU Environment Compatibility Patch (io.py)
The organizer's data loader (tracking_cellmot/io.py) throws a RuntimeError on CPU-only environments when invoking .pin_memory(). We patched the loader to conditionally execute .pin_memory() only when CUDA is active.
if device is not None:
torch_device = torch.device(device)
tensor = torch.from_numpy(image).to(torch_device)
- if pin_memory:
- tensor = tensor.pin_memory()
+ # Execute pin_memory only when CUDA GPU is available
+ if pin_memory and torch_device.type == "cuda" and torch.cuda.is_available():
+ tensor = tensor.pin_memory()
(5) Resolving Graph Counting Performance Bottleneck
When accessing node and edge counts in tracksdata's InMemoryGraph, calling len(list(predicted_graph.nodes)) causes severe Python overhead due to converting tens of thousands of internal C++/Rust objects into Python lists. We resolved this bottleneck by calling the native accessor methods node_ids() and edge_ids().
# Before (Freezes due to converting 100k+ objects to Python list):
# num_nodes = len(list(predicted_graph.nodes))
# After (Instant O(1) attribute access):
num_nodes = len(predicted_graph.node_ids())
num_edges = len(predicted_graph.edge_ids())
(6) Local Validation Notebook Implementation & GitHub Reference
You can download the ready-to-run notebook directly from our GitHub repository:
Alternatively, create local_env/notebooks/local_validation.ipynb and insert the full source code below:
import os
import sys
import glob
import zarr
import numpy as np
import pandas as pd
from skimage.feature import blob_dog
from scipy.spatial.distance import cdist
from tqdm import tqdm
import polars as pl
# 1. Add organizer repo source to system path
repo_path = os.path.abspath(os.path.join(os.getcwd(), "../src/kaggle_cell_tracking_competition/src"))
if repo_path not in sys.path:
sys.path.append(repo_path)
import tracksdata as td
from tracking_cellmot.io import open_dataset
from tracking_cellmot.metrics import evaluate, evaluate_datasets
# 2. Data path setup
DATA_DIR = os.path.abspath(os.path.join(os.getcwd(), "../data/train"))
zarr_paths = glob.glob(os.path.join(DATA_DIR, "*.zarr"))
dataset_names = sorted([os.path.basename(p).replace(".zarr", "") for p in zarr_paths])
# Fast iteration setup: Set to 2 for quick testing, None for full CV
NUM_DATASETS_TO_EVAL = 2
eval_datasets = dataset_names if NUM_DATASETS_TO_EVAL is None else dataset_names[:NUM_DATASETS_TO_EVAL]
# 3. Sanity Check (Evaluate Ground Truth directly to verify max score 1.1000)
ds_sanity = open_dataset(os.path.join(DATA_DIR, dataset_names[0]), normalize=True, require_tracks=True, device="cpu")
sanity_res = evaluate(graph=ds_sanity.tracks, gt_graph=ds_sanity.tracks, scale=ds_sanity.scale)
edge_d = sanity_res.edge_tp + sanity_res.edge_fp + sanity_res.edge_fn
edge_j = sanity_res.edge_tp / edge_d if edge_d > 0 else 1.0
div_d = sanity_res.division_tp + sanity_res.division_fp + sanity_res.division_fn
div_j = sanity_res.division_tp / div_d if div_d > 0 else 1.0
print(f"SANITY SCORE: {edge_j + 0.1 * div_j:.4f} (Expected: 1.1000)")
# 4. Main prediction loop
graph_pairs = []
for dataset_name in eval_datasets:
ds = open_dataset(os.path.join(DATA_DIR, dataset_name), normalize=True, require_tracks=True, device="cpu")
predicted_graph = td.graph.InMemoryGraph()
for key in ("z", "y", "x"):
predicted_graph.add_node_attr_key(key, pl.Float64, 0.0)
num_frames = ds.image.shape[0]
prev_nodes_info = []
for t in tqdm(range(num_frames), desc=f"Frames ({dataset_name})"):
img_3d = ds.image[t]
coords = detect_cells_3d(img_3d, min_sigma=2, max_sigma=5, threshold=0.05)
curr_nodes_info = []
for idx, (z, y, x) in enumerate(coords):
node_id = predicted_graph.add_node({"t": int(t), "z": float(z), "y": float(y), "x": float(x)})
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]) * ds.scale
coords_curr = np.array([item[2] for item in curr_nodes_info]) * ds.scale
links = track_frame_to_frame(coords_prev, coords_curr, max_distance=15.0)
for idx_prev, idx_curr in links:
predicted_graph.add_edge(prev_nodes_info[idx_prev][1], curr_nodes_info[idx_curr][1], {})
prev_nodes_info = curr_nodes_info
num_nodes = len(predicted_graph.node_ids())
num_edges = len(predicted_graph.edge_ids())
print(f"Dataset {dataset_name} finished: {num_nodes} nodes, {num_edges} edges.")
graph_pairs.append((predicted_graph, ds.tracks))
# 5. Compute overall CV score
cv_result = evaluate_datasets(graph_pairs=graph_pairs, scale=ds.scale)
print(f"FINAL CV SCORE: {cv_result.score:.6f}")
(7) VS Code Execution Steps
- Open the project root folder in VS Code.
- Open
local_env/notebooks/local_validation.ipynb. - Select
local_env/Scripts/python.exeas the kernel interpreter. - Click "Run All". Sanity check (1.1000) completes instantly, followed by the local CV evaluation.
(8) Sanity Check & Baseline Local CV Score Report
We conducted a Sanity Check by passing the ground-truth tracks directly into the evaluator, confirming a theoretical maximum score of 1.1000 (Edge Jaccard: 1.0 + 0.1 * Division Jaccard: 1.0).
Next, we evaluated our 3D DoG detection + nearest neighbor tracking baseline across all 199 embryo datasets, establishing our baseline local CV score:
========================================
=== LOCAL CV SCORE REPORT ===
========================================
Evaluated datasets count: 199
Edge Jaccard: 0.505233
Division Jaccard: 0.000000
----------------------------------------
FINAL CV SCORE: 0.505233
(Formula: Edge Jaccard + 0.1 * Division Jaccard)
========================================
The breakdown clearly demonstrates that our baseline achieves 50.5% Edge Jaccard for 1-to-1 cell movements, but scores 0.0 for Division Jaccard because cell division (1-to-2 branching) logic has not yet been implemented.
Conclusion
Establishing a fast local cross-validation environment for an 80GB dataset eliminates cloud platform queueing delays and enables rapid hypothesis testing in seconds to minutes. Next, we will focus on implementing cell division matching logic and global linear assignment matching (Hungarian algorithm) to boost our local CV score.
Hope this helps!
Japanese Series Articles (Zenn)
For Japanese readers, the complete series is published on Zenn:
Top comments (0)