Migrating from 1.x to 2.0
Migrating from 1.x to 2.0
unitrack 2.0 is a hard break. There are no compatibility shims, and a tracker
written against 1.x will not import unchanged: the primitives were re-cut around
a typed data model and a composable stage tree. This page maps the 1.x surface
onto 2.0 and then migrates a real 1.x tracker — an appearance-embedding matcher
— end to end.
Why 2.0 is a hard break
Four capabilities the companion paper needs forced the redesign: parallel fusion of cost terms, Kalman motion state, first-class gates, and tracklet lifecycle. Each is a cross-cutting concern — it touches the data model and the way stages compose — not a leaf that bolts onto the side. A cross-cutting concern cannot be added to an abstraction that did not anticipate it; you either re-cut the abstraction or encode the change as mode flags, and 1.x's untyped field dicts and flat stage list left only the second route. A compatibility shim would therefore have frozen the very primitives that had to change. 2.0 re-cuts the core instead (decisions Q1–Q2). The five shifts below are what a migration touches.
What changed, conceptually
Five shifts account for almost every edit a migration requires.
- Field extraction → a typed
Detectionsrecord. In 1.x,MultiStageTracker(fields=[...])took a list ofTensorDictModules that selected and renamed tensors out of a raw input dict at the front of the pipeline. 2.0 has no field layer. You construct aDetectionstensorclass whose field names match the state schema and pass it tostepdirectly.Why: the field layer only renamed tensors inside an untyped dict, so a schema error surfaced — if at all — deep inside a stage at runtime. A tensorclass makes the schema a type: an ill-formed record fails to construct, and the now-fixed shapes are what let
torch.compileandvmapspecialize. Checking moves from runtime to construction, and the renaming layer leaves with the dict it served. - Flat stage list → composable stage tree. 1.x nested stages as plain
lists (
stages=[Gate(then=[Association(...)])]). 2.0 expresses the same structure as a typed tree of combinators —Pipe,Gated,Sequential,Parallel— validated at construction time.Why: parallel fusion and cascaded matching are compositions, and a flat list cannot compose — it can only enumerate, so each new combination becomes another global mode flag. A typed tree of combinators is closed under composition: any well-typed subtree is itself a stage, and the constructor rejects the ill-typed ones and names the offending path (
PipelineTypeError). Errors a flat list would defer to runtime become unconstructable (Q4). - Gates are first-class, separate from costs. 1.x folded gating into the
cost with
GateCost(field).wrap(cost). 2.0 makes gatesGateProducers (ClassGate,ScoreGate,SpatialGate2D,MotionGate, …), combines them withSequential, and applies them withGated.Why: binding the gate to the cost couples two independent concerns and forces a full
(N, M)mask even for a per-detection score floor. Split out, a gate is an algebraic type — per-pair, per-side, or cost-bias — closed under conjunction, so gates compose withSequential, attach to any cost throughGated, and let the executor allocate the pairwise mask only when a per-pair gate demands it (Q5). - States moved from the memory to the tracker, and
Valuebecame a triple. 1.x declared states onTrackletMemory(states={...})asstates.Value(dtype, shape=...). 2.0 declares them onTracker(states={...})as aState(schema, process, observation, init). A plain feature cache — what 1.xValuewas — isIdentityprocess +Replaceobservation +FromDetectionFieldinitializer.Why: a dynamics-free
Valuecan neither predict nor update, which Kalman motion requires. Factoring a state intoProcess × Observation × Initializerspans the range from a plain cache to a Kalman filter by composition rather than subclassing — the same three slots, filled differently. The states live on theTrackerbecause it assembles them into one fixedTrackletstype at construction, which givestorch.compilea stable shape and lets the constructor reject type errors before the first allocation (Q3, Q7). SimpleTracker/StatefulTracker→MultiStream;stepis pure. 1.x bundled tracker and memory inSimpleTrackerand exposedread()/write(). In 2.0 aTrackerowns no per-stream state —stepis a pure function over snapshots — andMultiStreamholds one snapshot per stream key.MultiStream.step(key, detections, ctx)is what you call frame to frame.Why:
stepis now referentially transparent — snapshot in, snapshot out, no hiddenread()/write(). That one property is what each wrapper needs:vmapvectorizes only a function with no aliased state,MultiStreamforks a stream only when nothing is shared behind its back, and autodiff needs a functional graph to differentiate. 1.x's in-place memory denied all three at once; 2.0 earns all three from a single change, and the stream and id bookkeeping move out toMultiStream/BatchTracker(Q7, Q10, Q11).
A sixth, smaller change: lifecycle and visibility are now explicit constructor
arguments (NoLifecycle / StandardLifecycle, IncludeAll / ConfirmedOnly)
rather than implicit behavior of the tracker wrapper. Why: birth, death, and
re-ID are themselves research variables — SORT confirmation, occlusion windows,
per-class rules. Making the lifecycle and visibility policies constructor
parameters keeps the tracker open to new rules yet closed to edits for them: a
new policy is a new argument, not a fork (Q8).
Symbol map
| 1.x | 2.0 |
|---|---|
SimpleTracker(tracker=…, memory=…) | MultiStream(tracker) |
MultiStageTracker(fields=…, stages=…) | Tracker(root=…, states=…, lifecycle=…, visibility=…) |
fields=[TensorDictModule(…)] | construct Detections(index=…, **fields, batch_size=[M]) directly |
stages.Gate(gate=…, then=[…]) | Gated(gate=…, then=…) |
stages.Association(cost=…, assignment=…) | Pipe(cost=…, assoc=Associate(…)) |
costs.Cosine(field=…) | costs.Cosine("…") (field is positional) |
costs.GateCost(f).wrap(cost) | a gates.* producer (e.g. ClassGate(f)) under Gated |
costs.FieldCost (base class) | gone; every cost is a small dataclass with a field attribute |
assignment.Jonker(threshold=…) | Associate(Jonker(threshold=…)) — the backend keeps threshold, Associate wraps it |
TrackletMemory(states={k: states.Value(dtype, shape=…)}) | Tracker(states={k: State(schema=TensorSpec(shape, dtype), process=Identity(k), observation=Replace(k), init=FromDetectionField(k))}) |
tracker.read() / tracker.write() | gone — step is pure; MultiStream holds the snapshot |
Several costs were also renamed in the move. The two most likely to
bite: 1.x Softmax is now BiSoftmax, and 1.x BoxIoU (which computed CIoU)
is now BoxCIoU, with BoxIoU reserved for plain IoU.
Worked migration: an appearance-embedding tracker
The 1.x tracker below matches detections by ReID embedding alone, gated by
category and a score floor, with the Jonker–Volgenant solver. It is a typical
1.x builder: field modules at the front, a single Gate → Association stage,
and per-field Value states on the memory.
Before (1.x)
import torch
import unitrack as ut
from tensordict.nn import TensorDictModule
from torch import nn
REID, SCORE, CATEGORY, LABEL = "reid", "score", "category", "label"
def _build_field(name, key=None, module=None):
key = (name,) if key is None else (key,) if isinstance(key, str) else key
return TensorDictModule(module or nn.Identity(), in_keys=[key], out_keys=[name])
class SelectAndFilter(nn.Module):
def __init__(self, key_score, key_label, min_score=0.0):
super().__init__()
self.key_score, self.key_label, self.min_score = key_score, key_label, min_score
def forward(self, ctx, cs, ds):
cs_mask = torch.ones(cs.batch_size[:1], dtype=torch.bool, device=cs.device)
ds_mask = ds.get(self.key_score) > self.min_score
return cs_mask, ds_mask
def build_embedding_tracker(
*,
reid_key="reid",
threshold=0.9,
cost_module=ut.costs.Cosine,
category_gate=True,
min_score=0.0,
):
cost = cost_module(field=REID)
if category_gate:
cost = ut.costs.GateCost(CATEGORY).wrap(cost)
return ut.SimpleTracker(
tracker=ut.MultiStageTracker(
fields=[
_build_field(REID, reid_key),
_build_field(SCORE, "score"),
_build_field(CATEGORY, "category"),
],
stages=[
ut.stages.Gate(
gate=SelectAndFilter(SCORE, LABEL, min_score=min_score),
then=[
ut.stages.Association(
cost=cost,
assignment=ut.assignment.Jonker(threshold=threshold),
)
],
),
],
),
memory=ut.TrackletMemory(
states={
SCORE: ut.states.Value(torch.float),
CATEGORY: ut.states.Value(torch.long),
REID: ut.states.Value(torch.float, shape=(256,)),
}
),
)
After (2.0)
import torch
import unitrack as ut
from unitrack.assignment import Associate, Jonker
from unitrack.costs import Cosine
from unitrack.data import TensorSpec
from unitrack.gates import ClassGate, ScoreGate
from unitrack.lifecycle import IncludeAll, NoLifecycle
from unitrack.pipeline import Gated, Pipe, Sequential
from unitrack.states import FromDetectionField, Identity, Replace, State
REID, SCORE, CATEGORY = "reid", "score", "category"
def _feature_state(name: str, shape: tuple[int, ...], dtype: torch.dtype) -> State:
"""Pure feature cache: no motion model, replace on match, seed from detection."""
return State(
schema=TensorSpec(shape=shape, dtype=dtype),
process=Identity(name),
observation=Replace(name),
init=FromDetectionField(name),
)
def build_embedding_tracker(
*,
threshold: float = 0.1,
cost_cls: type = Cosine,
category_gate: bool = True,
min_score: float = 0.0,
reid_dim: int = 256,
) -> ut.Tracker:
gates = [ScoreGate(SCORE, threshold=min_score)]
if category_gate:
gates.insert(0, ClassGate(CATEGORY))
root = Gated(
gate=Sequential(gates),
then=Pipe(cost=cost_cls(REID), assoc=Associate(Jonker(threshold=threshold))),
)
return ut.Tracker(
root=root,
states={
REID: _feature_state(REID, (reid_dim,), torch.float32),
SCORE: _feature_state(SCORE, (), torch.float32),
CATEGORY: _feature_state(CATEGORY, (), torch.int64),
},
lifecycle=NoLifecycle(),
visibility=IncludeAll(),
)
What dissolved, line by line
_build_fieldand thefields=[…]list. The front-of-pipeline field selection has no analogue in 2.0. You no longer rename tensors inside the tracker; you name them when you build theDetectionsrecord (next section). Thereid_keyparameter — which existed only to rename an input tensor to"reid"— disappears with it.GateCost(CATEGORY).wrap(cost). Category gating is now aClassGate, aGateProducerthat emits a per-pair equality mask. It composes with the score gate throughSequentialand is applied to the cost stage byGated, instead of being woven into the cost object.SelectAndFilter. Its score branch (ds_mask = score > min_score) becomesScoreGate(SCORE, threshold=min_score). Itscs_maskwas always all-True, i.e. a no-op on the tracklet side, so nothing replaces it. If you did filter tracklets by status here, that is now aFilter(StatusFilter(…), on="cs")node.states.Value(dtype, shape=…). Each becomes aState. AValuewas a cache with no dynamics, which is exactlyIdentity(no predict) +Replace(overwrite matched rows) +FromDetectionField(seed new rows).TensorSpeccarries the same(shape, dtype)the 1.xValuedid.SimpleTrackerandTrackletMemory. The builder now returns a bareTracker. The per-stream snapshot thatSimpleTrackerandTrackletMemoryused to own moves intoMultiStream, created at the call site.
Feeding the tracker
In 1.x, an input module (multiformer's TrackerInput) produced a dict of
tensors, the fields=[…] modules selected from it, and SimpleTracker handled
read/write. In 2.0 you build a Detections record and call MultiStream.step.
The record needs a reserved index field (int64, shape (M,)) that threads
your detection ordering through to the MatchOutcome; the remaining fields are
the ones your states read.
from unitrack.data import Detections, FrameContext
tracker = build_embedding_tracker()
ms = ut.MultiStream(tracker)
for frame_idx, frame in enumerate(stream): # your per-frame source
reid = frame["reid"] # (M, 256) float32
score = frame["score"] # (M,) float32
category = frame["category"] # (M,) int64
m = score.shape[0]
det = Detections(
index=torch.arange(m, dtype=torch.int64, device=score.device),
reid=reid,
score=score,
category=category,
batch_size=[m],
)
res = ms.step(0, det, FrameContext.make(frame_idx, stream_key=0))
# res.ids: int64 tracklet IDs visible this frame (per the Visibility policy).
# res.match.matched_pairs: (K, 2) [tracklet_row, detection_row]; recover the
# original detection via det.index[pair[1]], and stable identity via
# res.snapshot.id. See the MatchOutcome reference for the residual indices.
for tracklet_row, det_row in res.match.matched_pairs.tolist():
det_id = det.index[det_row].item()
...
Preprocessing that produced detection tensors in 1.x is unaffected.
multiformer's MaskToBoxes, for instance, is a plain tensor op upstream of the
tracker; its (N, 4) output now becomes a Detections field instead of an
entry in the dict the field modules read.
Behavioral notes
- Re-derive thresholds; do not copy the 1.x number. In 2.0,
Cosinereturns1 − cosine_similarity(a distance),Jonker(threshold=t)masks pairs whose cost is strictly abovet, andAssociatekeeps matched pairs withcost <= t. So a 1.x similarity floor of0.9corresponds to a 2.0 distance ceiling of0.1— hencethreshold=0.1in the migrated builder. Check the cost's range and direction before reusing a tuned value. NoLifecycle()+IncludeAll()reproduces the 1.x embedding tracker. It had no birth/death logic — every tracklet matched and every ID was visible. To add SORT-style confirmation, switch toStandardLifecycle(min_hits, max_age)andConfirmedOnly(); the recipes/sort recipe shows the full form.Detectionsis immutable and validated. Construction checks thatindexis present,int64, and shape(M,). UseDetections.empty()for an empty-frame placeholder.
See also
- notebooks/tutorials/migration — this migration run as an interactive notebook: it ports the tracker above and then showcases the new 2.0 possibilities (parallel fusion, cascaded matching, lifecycle, and differentiable matching) on detections from a real lightweight detector.
- recipes/overlap_tracker — the closest sibling: a single-stage class-
and score-gated matcher, ported from the 1.x
models.overlapbuilder. - recipes/sort — a stateful IoU + Kalman tracker, showing
StandardLifecycle,ConfirmedOnly, and a Kalman state.