Recipe — Python object tracking

For LYNX SDK 1.0. Complete, end-to-end. Install: install/python.md. API: api/python.md. Conventions: api/conventions.md.

Goal: run detection across a video/frame sequence and follow each object with a stable id as it moves frame to frame. Model: lynx-basic (keyless, 80 COCO classes — see models/catalog.md).

The whole thing

Tracking is stateful: instead of model.predict (one independent image), you drive a stream. model.track(source) is the sugar — it opens a tracker, feeds it each image from your iterable source, and yields one Frame per image with tracker ids populated. Each Detection then carries d.tracker_id (a stable int, 0 = untracked), d.track_state (a TrackState), and d.track_age (seconds since the track was first seen). Class name is resolved through model.classes — there's no Detection.class_name.

source is any iterable of images, where each image is a path str or a (H, W, 3) uint8 RGB numpy array — exactly what predict accepts. Below the source is a sorted folder of frames (frame_0001.jpg, …); swap in a video decoder (see Notes) without changing the tracking loop.

1import glob
2import lynx
3from lynx.errors import LynxError, ModelNotFound
4
5
6def track_sequence(frames_glob="frames/*.jpg", min_confidence=0.4):
7 """Track objects across a frame sequence with lynx-basic (keyless).
8
9 Prints, per frame, each object's stable tracker id + class name + box.
10 """
11 paths = sorted(glob.glob(frames_glob))
12 if not paths:
13 print(f"No frames matched {frames_glob!r}.")
14 return
15
16 # Open once. First call downloads + verifies + caches the model.
17 with lynx.open("lynx-basic") as model:
18 # model.track(source) yields one Frame per image, tracker ids populated.
19 # `source` here is the list of frame paths; conf is a bare float threshold.
20 for frame_no, frame in enumerate(model.track(paths)):
21 live = [d for d in frame.detections if d.confidence >= min_confidence]
22 print(f"frame {frame_no:04d}: {len(live)} tracked object(s)")
23 for d in live:
24 if d.tracker_id == 0:
25 continue # not yet assigned an id
26 name = model.classes(d.class_id).name # e.g. "PERSON"
27 x1, y1, x2, y2 = d.box # [x1, y1, x2, y2] pixels
28 print(f" id={d.tracker_id:<4} {name:16} "
29 f"{d.track_state.name:9} age={d.track_age:5.1f}s "
30 f"box=({x1:.0f}, {y1:.0f}, {x2:.0f}, {y2:.0f})")
31
32
33if __name__ == "__main__":
34 import sys
35 pattern = sys.argv[1] if len(sys.argv) > 1 else "frames/*.jpg"
36 try:
37 track_sequence(pattern)
38 except ModelNotFound as e:
39 print("model not found (bad slug/version):", e.message)
40 except LynxError as e:
41 print(f"lynx error [{e.code}]: {e.message}")

Because the tracker id is stable, you can follow one object across the run by keying on d.tracker_id — e.g. accumulate a per-id trail of box centers:

1from collections import defaultdict
2
3trails = defaultdict(list)
4with lynx.open("lynx-basic") as model:
5 for frame in model.track(paths):
6 for d in frame.detections:
7 if d.tracker_id: # skip 0 (untracked)
8 x1, y1, x2, y2 = d.box
9 trails[d.tracker_id].append(((x1 + x2) / 2, (y1 + y2) / 2))
10
11for tid, points in trails.items():
12 print(f"track {tid}: seen in {len(points)} frame(s)")

Driving the tracker yourself

model.track is a thin loop over a Tracker. Open one directly when you pull frames from a live source (a camera, a socket) rather than a ready-made iterable — call update(image) per frame:

1with lynx.open("lynx-basic") as model:
2 with model.tracker() as tracker: # a lynx.Tracker; context-managed
3 while True:
4 image = grab_next_frame() # your capture -> path or (H,W,3) uint8 RGB
5 if image is None:
6 break
7 frame = tracker.update(image) # -> Frame, tracker ids populated
8 for d in frame.detections:
9 if d.tracker_id:
10 print(d.tracker_id, model.classes(d.class_id).name, d.box)

A lynx.Camera source drops straight in — cam.read() returns the (H, W, 3) uint8 RGB array update wants:

1with lynx.open("lynx-basic") as model, \
2 lynx.camera_open(0) as cam, \
3 model.tracker() as tracker:
4 for _ in range(300): # ~first 300 frames
5 frame = tracker.update(cam.read())
6 print(len(frame.detections), "objects")

The active-track view

Every Frame also exposes frame.tracks — the full set of active tracks this frame, including ones the detector missed here (a track can stay alive briefly while LOST). Each is a lynx.Track with .id, .state, .age_s, .box, and .matched (whether it was matched to a detection this frame):

1for frame in model.track(paths):
2 for t in frame.tracks:
3 mark = "seen" if t.matched else "coasting" # unmatched = predicted-through
4 print(f" track {t.id} {t.state.name:9} {mark} age={t.age_s:.1f}s")

frame.index_of(track_id) maps a track id back to its detection row index in this frame (-1 if that track has no detection here) — handy to jump from a Track to the matching Detection:

1i = frame.index_of(t.id)
2if i >= 0:
3 d = frame.detections[i]
4 print("matched detection:", model.classes(d.class_id).name, d.confidence)

Notes

  • Ids and lifecycle. d.tracker_id is a stable positive int for the life of a track; 0 means "not tracked" (an object the tracker hasn't confirmed into a track yet). d.track_state is a lynx.TrackState: NEW (just created) → TENTATIVE (unconfirmed) → CONFIRMED (stable) → LOST (missed recent frames, awaiting re-match or expiry). A LOST track that isn't re-matched expires and its id is retired; a genuinely new object gets a fresh id.
  • track_age is seconds, not frames — time since the track was first seen (0 when untracked). Track.age_s on the frame.tracks view is the same clock.
  • Vectorized columns. Like detection, frame.detections exposes parallel numpy columns for tracking too: frame.detections.tracker_id (N,), frame.detections.track_state (N,), frame.detections.track_age (N,) — alongside .boxes / .confidence / .class_id. Use them to filter without a Python loop, e.g. dets[dets.tracker_id != 0].
  • Open once, stream once. One Tracker = one temporal stream; don't reopen it per frame or ids reset. model.track and model.tracker build the stream for you. Run the first open off the UI thread (it downloads the model).
  • From a video file. Replace the frame-glob source with any generator of RGB arrays. With OpenCV (pip install opencv-python), remembering LYNX wants RGB:
    1import cv2
    2
    3def video_frames(path):
    4 cap = cv2.VideoCapture(path)
    5 try:
    6 while True:
    7 ok, bgr = cap.read()
    8 if not ok:
    9 break
    10 yield cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) # BGR -> RGB (H,W,3) uint8
    11 finally:
    12 cap.release()
    13
    14with lynx.open("lynx-basic") as model:
    15 for frame in model.track(video_frames("clip.mp4")):
    16 ...
  • Same model, more heads. lynx-basic also ships segmentation, pose, and depth; tracking composes with them (open with tasks=, or realize ROI heads per frame with frame.detections.run([...])) — see api/python.md.