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 glob2import lynx3from lynx.errors import LynxError, ModelNotFound4 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 return15 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 id26 name = model.classes(d.class_id).name # e.g. "PERSON"27 x1, y1, x2, y2 = d.box # [x1, y1, x2, y2] pixels28 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 sys35 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 defaultdict2 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.box9 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-managed3 while True:4 image = grab_next_frame() # your capture -> path or (H,W,3) uint8 RGB5 if image is None:6 break7 frame = tracker.update(image) # -> Frame, tracker ids populated8 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 frames5 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-through4 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_idis a stable positive int for the life of a track;0means "not tracked" (an object the tracker hasn't confirmed into a track yet).d.track_stateis alynx.TrackState:NEW(just created) →TENTATIVE(unconfirmed) →CONFIRMED(stable) →LOST(missed recent frames, awaiting re-match or expiry). ALOSTtrack that isn't re-matched expires and its id is retired; a genuinely new object gets a fresh id. track_ageis seconds, not frames — time since the track was first seen (0when untracked).Track.age_son theframe.tracksview is the same clock.- Vectorized columns. Like detection,
frame.detectionsexposes 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.trackandmodel.trackerbuild the stream for you. Run the firstopenoff 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 cv223def video_frames(path):4 cap = cv2.VideoCapture(path)5 try:6 while True:7 ok, bgr = cap.read()8 if not ok:9 break10 yield cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) # BGR -> RGB (H,W,3) uint811 finally:12 cap.release()1314with lynx.open("lynx-basic") as model:15 for frame in model.track(video_frames("clip.mp4")):16 ... - Same model, more heads.
lynx-basicalso ships segmentation, pose, and depth; tracking composes with them (open withtasks=, or realize ROI heads per frame withframe.detections.run([...])) — seeapi/python.md.