File size: 41,091 Bytes
a1dd5ba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
"""OBJECT IDENTITY on the write path, text-free, riding on tracks.

Identity is a property of a physical object, not of a name and not of a
category. The same spoon across every demo gets one id. A spatula gets
a different id. A red pepper and a green pepper get different ids, even
though every text-aligned encoder calls them both "pepper".

Nothing here turns pixels into language. Regions come from a
class-agnostic segmenter, identity from a ReID encoder; both vision
only, both real time. This is separate from objects.py, which proposes
regions for SEMANTIC retrieval - that channel answers "does this look
like a cloth", this one answers "is this the same object again".

CONTINUITY IS THE ARCHITECTURE
------------------------------
The earlier version sampled 3 frames per episode and asked the gallery
about every crop independently. That is the wrong question asked ~500
times: it made identity a threshold problem, and the threshold had no
good setting - 0.55 built attractor ids that swallowed a green box, a
broccoli, a toy mouse and a carrot; 0.85 fragmented to 1.15 sightings
per object. Neither end works because a single crop of an object from
one angle simply is not enough evidence.

A tracker answers it for free instead. While an object stays visible,
IoU + Kalman association says "same object" from GEOMETRY, at 0.25
ms/frame, with no appearance model and no threshold. The object store
is consulted only when continuity BREAKS - a new track appears - and
then it is asked once, about a descriptor pooled over the whole track,
not once per frame about one view.

Measured over 24 episodes / 883 frames of Bridge:

    median track length      24-48 frames (episodes are ~37)
    singleton tracks         5%
    object-store uploads     19, against 415 tracked detections
                             = 95% fewer questions asked

That 95% is the design. Fewer questions, each with far more evidence
behind it.

COST, and where it goes
-----------------------
Detection is STATELESS, so it batches across episode boundaries;
association is STATEFUL, so it runs per episode, sequentially. Splitting
them is what makes every-frame tracking affordable - ultralytics' own
track() ties them together and pays batch-size-1 prices for both.

    per-frame track() calls          15.8 ms/frame
    batched track() (list per ep)     9.3
    detect batched + associate        4.8   <- this module
      of which detect  (fp16, 448)    4.16
      of which associate              0.13
      of which reid                   0.50  (was 4.1 per FRAME)

At 18,000 frames per hour of video that is 1.43 min/hour marginal,
against a 1 min/hour write budget of which 0.23 is already spent by the
existing pass. STATED STRAIGHT: identity does not fit the budget. What
riding on tracks bought is the ReID half, which used to dominate and is
now 0.1 min/hour; the detector is the entire remaining cost, so closing
the gap is a detector question, not a design one.

fp16 is worth 14% of detect (4.94 -> 4.16 ms/frame) with the fitted cut
and track count unchanged. That took interleaved runs to establish - a
first attempt compared an fp16 run against fp32 runs taken earlier in a
long session and read the thermal drift as fp16 being SLOWER.

DEVICE. Defaults to the GPU, and this is now measured rather than
assumed. The old 3-frames-per-episode path put CPU and MPS within 5%
(19.3 vs 20.3 ms/frame) because per-call overhead dominated a batch of
three. On real batches the gap is 3.7x - mps 6.7 ms/frame vs cpu 24.8
at imgsz 640 - and the CPU gets WORSE with batching (93.9 ms/frame at
batch 16) as threads contend. Frames are already resident on the GPU
for the FDNN-V pass, and on CUDA a host round trip per frame costs more
than the inference. CPU is the fallback, never the default.
"""
from __future__ import annotations

import os
from types import SimpleNamespace

import numpy as np

_M: dict = {}

# WEIGHTS LIVE IN models/weights, not the repo root. ultralytics
# downloads into the CWD by default, which is how 86 MB of checkpoints
# ended up beside the README with 62 MB of it committed to git. _weights
# resolves a bare name against models/weights and falls back to the bare
# name so a missing file still auto-downloads - into models/weights,
# because that is where we then look.
def _weights(name):
    from pathlib import Path as _P
    d = _P(__file__).resolve().parents[2] / "models" / "weights"
    d.mkdir(parents=True, exist_ok=True)
    p = d / name
    return str(p) if p.exists() else str(d / name)


SEG_MODEL = os.environ.get("ELIDEDB_SEG") or _weights("yolo11n-seg.pt")
REID_MODEL = os.environ.get("ELIDEDB_REID") or _weights("yolo26n-reid.onnx")
# imgsz 448 rather than 384, deliberately paying 0.5 ms/frame for it.
# 384 is cheaper (3.76 vs 4.23 ms/frame with fp16) and finds MORE boxes
# (2.63 vs 2.44 per frame), but the extra boxes are marginal ones: its
# proven-different pairs reach 0.757 against 0.555 at 448, i.e. noisier
# negatives, and it recovered 72 multi-episode objects against 81. A
# cheaper size that degrades the identity is not cheaper.
DET_SZ = int(os.environ.get("ELIDEDB_DET_SZ", "448"))
# free: same 2.44 boxes/frame, 4.74 -> 4.23 ms/frame
DET_FP16 = os.environ.get("ELIDEDB_DET_FP16", "1") not in ("0", "")
# detection is stateless, so the batch may cross episode boundaries;
# 3.87 ms/frame at ~37 (one episode) against 3.32 at 64
DET_BATCH = int(os.environ.get("ELIDEDB_DET_BATCH", "64"))
# Global motion compensation estimates camera movement before
# association. Default OFF from MEASUREMENT, not from an assumption
# about this corpus: with sparseOptFlow, median track length was 47 at
# imgsz 448 and 38 at 640; with none, 48 and 38 - identical - while the
# optical flow cost 1.8 ms/frame, 7x the entire association step. A
# corpus with a moving camera must re-measure and will likely want it
# back; that is what the knob is for.
GMC = os.environ.get("ELIDEDB_GMC", "none")
# how many views of a track to embed. The pooled descriptor is what the
# object store sees, so this trades evidence against ReID calls.
EXEMPLARS = int(os.environ.get("ELIDEDB_OBJ_VIEWS", "4"))

# "same physical object" cut. Not a constant to be tuned by hand - see
# calibrate(), which fits it from the corpus being ingested. This value
# is only the fallback for a corpus too small to fit on.
MATCH = float(os.environ.get("ELIDEDB_OBJ_MATCH", "0.85"))
# quantile of the free-negative distribution used as the cut
CAL_Q = float(os.environ.get("ELIDEDB_OBJ_CAL_Q", "99.5"))
# Adding an exemplar WIDENS the id's acceptance region, because matching
# takes a max over the gallery. Unchecked that is a runaway: absorb ->
# widen -> absorb more. A new view is admitted only if it is close to
# EVERY exemplar already held, which keeps an id tight instead of
# letting it sprawl across a chain of intermediate appearances.
MAX_EXEMPLARS = 5
ADMIT = float(os.environ.get("ELIDEDB_OBJ_ADMIT", "0.70"))
MIN_SIDE = 16
MAX_AREA = 0.5


def device():
    d = os.environ.get("ELIDEDB_DEVICE")
    if d:
        return d
    try:
        import torch
        if torch.cuda.is_available():
            return "cuda"
        if torch.backends.mps.is_available():
            return "mps"
    except Exception:
        pass
    return "cpu"


def _load():
    # check for THIS function's own keys, not for _M being non-empty:
    # propose() also caches into _M, so `if _M` short-circuited here and
    # left reid unloaded the moment a proposer had run first.
    if "reid" in _M:
        return _M
    from ultralytics import YOLO
    from ultralytics.trackers.utils.reid import ReID
    dev = device()
    _M["seg"] = YOLO(SEG_MODEL)
    # the ReID encoder runs under onnxruntime and picks its own
    # execution provider; it does not share the torch device
    _M["reid"] = ReID(REID_MODEL, device="cpu" if dev == "mps" else dev)
    _M["dev"] = dev
    return _M


PROPOSER = os.environ.get("ELIDEDB_PROPOSER") or _weights("FastSAM-s.pt")


def propose(frames, imgsz=DET_SZ, batch=DET_BATCH, conf=0.25):
    """CLASS-AGNOSTIC region proposal. Not YOLO-with-single_cls.

    MEASURED, same frames, same model: single_cls=True and
    single_cls=False give byte-identical output - 3.10 det/frame, all of
    it {oven, sink, bowl, person, spoon, wine glass}. single_cls is a
    TRAINING flag; at inference the detector still fires only on
    COCO-shaped things. Everything built on it inherited an 80-class
    prior that was never intended, which is why looking for a container
    found 0.7 regions per episode and they were ovens.

    FastSAM is trained on SA-1B with no class list at all, and on this
    corpus returns 45.3 regions/frame against COCO YOLO's 2.6, with 3.5
    large structures per frame against 1.1.

    It costs 24.93 ms/frame batched fp16 = 7.48 min per hour of video,
    which is 7x the write budget - so this is a TEACHER. It is paid once
    to make training pairs and then distilled, exactly like every other
    teacher here. Never put it on the write path directly.
    """
    from ultralytics import FastSAM
    if "prop" not in _M:
        _M["prop"] = FastSAM(PROPOSER)
    out = []
    kw = {"quantize": "fp16"} if DET_FP16 else {}
    for i in range(0, len(frames), batch):
        chunk = frames[i:i + batch]
        res = _M["prop"].predict(chunk, device=device(), verbose=False,
                                 imgsz=imgsz, conf=conf, **kw)
        for im, r in zip(chunk, res):
            H, W = im.shape[:2]
            if r.boxes is None or not len(r.boxes):
                out.append(np.zeros((0, 4), np.int32))
                continue
            b = r.boxes.xyxy.cpu().numpy()
            b = np.stack([b[:, 0].clip(0, W), b[:, 1].clip(0, H),
                          b[:, 2].clip(0, W), b[:, 3].clip(0, H)],
                         1).astype(np.int32)
            wh = (b[:, 2] - b[:, 0]), (b[:, 3] - b[:, 1])
            out.append(b[(wh[0] >= MIN_SIDE) & (wh[1] >= MIN_SIDE)])
    return out


def detect(frames, imgsz=DET_SZ, batch=DET_BATCH, conf=0.25):
    """Class-agnostic regions per frame: [(boxes Nx4, conf N, area N)].

    Stateless and therefore batched - the caller may hand in frames from
    several episodes at once. single_cls collapses the 80 COCO classes
    into one anonymous class, so the model reports "a thing is here" and
    never a label.
    """
    m = _load()
    out = []
    kw = {"quantize": "fp16"} if DET_FP16 else {}
    for i in range(0, len(frames), batch):
        chunk = frames[i:i + batch]
        res = m["seg"].predict(chunk, device=m["dev"], single_cls=True,
                               verbose=False, imgsz=imgsz, conf=conf, **kw)
        for im, r in zip(chunk, res):
            H, W = im.shape[:2]
            if r.boxes is None or not len(r.boxes):
                out.append((np.zeros((0, 4), np.int32), np.zeros(0, np.float32),
                            np.zeros(0, np.float32)))
                continue
            xy = r.boxes.xyxy.cpu().numpy()
            cf = r.boxes.conf.cpu().numpy()
            ar = (r.masks.data.sum((1, 2)).cpu().numpy()
                  if r.masks is not None else np.zeros(len(xy)))
            b = np.stack([xy[:, 0].clip(0, W), xy[:, 1].clip(0, H),
                          xy[:, 2].clip(0, W), xy[:, 3].clip(0, H)],
                         1).astype(np.int32)
            wh = (b[:, 2] - b[:, 0]), (b[:, 3] - b[:, 1])
            ok = ((wh[0] >= MIN_SIDE) & (wh[1] >= MIN_SIDE)
                  & (wh[0] * wh[1] <= MAX_AREA * W * H))
            out.append((b[ok], cf[ok].astype(np.float32),
                        ar[ok].astype(np.float32)))
    return out


class _Dets:
    """The minimal shape ultralytics' trackers consume: xywh/conf/cls,
    a length, and boolean-mask indexing (they split detections into
    high- and low-confidence subsets). Handing them this instead of a
    Results object is what lets detection run batched somewhere else."""

    __slots__ = ("xywh", "conf", "cls")

    def __init__(self, xywh, conf, cls):
        self.xywh, self.conf, self.cls = xywh, conf, cls

    def __len__(self):
        return len(self.conf)

    def __getitem__(self, m):
        return _Dets(self.xywh[m], self.conf[m], self.cls[m])

    @property
    def xyxy(self):
        """Corner boxes, which global motion compensation asks for.

        byte_tracker.py calls `self.gmc.apply(img, results_high.xyxy)` to
        mask moving objects out before estimating camera motion, inside a
        try/except that WARNS and falls back to an identity warp. Without
        this property every frame took that fallback: 1,434 warnings in
        the first two minutes of a write, and camera motion silently not
        compensated.

        It was invisible on this corpus because Bridge's camera is fixed,
        where identity is the correct warp anyway - so the bug cost
        nothing here and everything on a corpus that moves. A robot that
        drives or a vehicle camera would have had its tracks fragmented
        by exactly the motion GMC exists to remove.

        xywh is centre-based (built that way in Stream.push, and what
        ultralytics' xywh2ltwh assumes), so the corners are centre +/-
        half-extent.
        """
        cx, cy, w, h = (self.xywh[:, 0], self.xywh[:, 1],
                        self.xywh[:, 2], self.xywh[:, 3])
        return np.stack([cx - w / 2, cy - h / 2,
                         cx + w / 2, cy + h / 2], 1)


def _tracker(gmc=None):
    from ultralytics.trackers.bot_sort import BOTSORT
    from ultralytics.utils import YAML
    import ultralytics
    from pathlib import Path
    cfg = YAML.load(Path(ultralytics.__file__).parent
                    / "cfg/trackers/botsort.yaml")
    cfg["gmc_method"] = gmc or GMC
    # BoT-SORT's own ReID would embed every detection on every frame,
    # which is the cost this design exists to avoid. Association here is
    # geometry only; appearance is consulted once per track, later.
    cfg["with_reid"] = False
    return BOTSORT(args=SimpleNamespace(**cfg))


class Stream:
    """CONTINUOUS tracking over a stream. No episode boundaries.

    link() below takes one episode's frames and builds a fresh tracker
    for each - correct when a corpus ships discrete demos, and an
    assumption the engine has no right to make. Raw capture is a
    continuous recording; episodes are something the engine must
    PRODUCE. A driving log, a surveillance feed and a surgical recording
    have no cuts to reset on.

    So the tracker runs for the life of the stream and a track ends when
    the OBJECT does - it leaves frame, is occluded past the buffer, or
    is carried away. That track's span is exactly a PRESENCE INTERVAL:
    "this object was here, from t0 to t1", one row however long it
    lasted. An object sitting still for four hours is one row, not
    72,000, and it is finally recorded at all - the motion-triggered
    element path never produced a row for anything that did not move.

    Online by construction: detections go in frame by frame, closed
    tracks come out as they close, and only the open tracks are held.
    A stream that does not fit in memory is the normal case, not an
    edge case.

    Exemplar crops are retained per OPEN track and embedded once, at
    close - the same "ask the object store one question per track, with
    a whole sighting behind it" that made identity work, now without
    needing the episode to know when to ask.
    """

    def __init__(self, gmc=None, views=EXEMPLARS, buffer_s=6.0):
        self.tr = _tracker(gmc)
        self.views = views
        # how long a track survives with no detection before it is
        # declared ended, in SECONDS not frames: a frame count means
        # different things at 5 fps and 30 fps, and the corpus chooses
        # the frame rate.
        self.buffer_s = buffer_s
        self.open: dict[int, dict] = {}
        self.last_ts = None

    def update(self, ts, det, frame=None):
        """One frame in; the tracks that CLOSED at this frame out.

        `det` is (boxes Nx4, conf N, area N) from detect()/propose().
        """
        b, c, a = det
        self.last_ts = int(ts)
        seen = set()
        if len(b):
            xywh = np.stack([(b[:, 0] + b[:, 2]) / 2,
                             (b[:, 1] + b[:, 3]) / 2,
                             b[:, 2] - b[:, 0], b[:, 3] - b[:, 1]], 1)
            rows = self.tr.update(
                _Dets(xywh.astype(np.float32), c,
                      np.zeros(len(b), np.float32)), frame)
            for row in rows:
                tid, di = int(row[-4]), int(row[-1])
                if di >= len(b):
                    continue
                seen.add(tid)
                t = self.open.setdefault(tid, {
                    "ts": int(ts), "t1": int(ts), "n": 0, "t": [],
                    "box": [], "conf": [], "area": [], "crops": []})
                t["t1"] = int(ts)
                t["n"] += 1
                # the box list without its timestamps is a SHAPE, not a
                # trajectory; every consumer that wanted motion had to
                # guess the time axis back from (ts, t1, n)
                t["t"].append(int(ts))
                t["box"].append(b[di])
                t["conf"].append(float(c[di]))
                t["area"].append(float(a[di]))
                # retain a bounded, spread set of views for the one
                # ReID call this track will ever cost
                if frame is not None and len(t["crops"]) < self.views:
                    x0, y0, x1, y1 = (int(v) for v in b[di])
                    if x1 > x0 and y1 > y0:
                        t["crops"].append((b[di], frame[y0:y1, x0:x1]))
        return self._reap(seen)

    def _reap(self, seen):
        gap = self.buffer_s * 1e9
        done = []
        for tid in list(self.open):
            if tid in seen:
                continue
            if self.last_ts - self.open[tid]["t1"] > gap:
                done.append((tid, self.open.pop(tid)))
        return done

    def flush(self):
        """End of stream: everything still open is still real."""
        out = list(self.open.items())
        self.open = {}
        return out


def link(dets, frames=None, gmc=None):
    """Associate one episode's consecutive detections into tracks.

    Returns {track_id: {"f": [frame idx], "box": [...], "conf": [...],
    "area": [...]}}. A fresh tracker per call, which is right only when
    the caller really does have a scene cut. Prefer Stream for
    continuous capture; this remains for corpora that ship discrete
    clips, where resetting at a genuine cut avoids carrying Kalman state
    across it.
    """
    tr = _tracker(gmc)
    out: dict[int, dict] = {}
    for j, (b, c, a) in enumerate(dets):
        if not len(b):
            continue
        xywh = np.stack([(b[:, 0] + b[:, 2]) / 2, (b[:, 1] + b[:, 3]) / 2,
                         b[:, 2] - b[:, 0], b[:, 3] - b[:, 1]], 1)
        det = _Dets(xywh.astype(np.float32), c, np.zeros(len(b), np.float32))
        rows = tr.update(det, None if frames is None else frames[j])
        for row in rows:
            # ultralytics packs [*xyxy, track_id, conf, cls, det_idx]
            tid, di = int(row[-4]), int(row[-1])
            if di >= len(b):
                continue
            t = out.setdefault(tid, {"f": [], "box": [], "conf": [],
                                     "area": []})
            t["f"].append(j)
            t["box"].append(b[di])
            t["conf"].append(float(c[di]))
            t["area"].append(float(a[di]))
    return out


def _views(track, k=EXEMPLARS):
    """Pick up to k frames of a track: the best-scoring view in each of
    k equal slices of the track's life. Spread matters more than score -
    k views of the same instant carry one view's worth of evidence."""
    n = len(track["f"])
    if n <= k:
        return list(range(n))
    edges = np.linspace(0, n, k + 1).round().astype(int)
    conf = np.asarray(track["conf"])
    return [int(a + conf[a:b].argmax())
            for a, b in zip(edges[:-1], edges[1:]) if b > a]


def features(frame, boxes):
    """Appearance embedding per region. Vision only, no text."""
    if not len(boxes):
        return np.zeros((0, 512), np.float32)
    m = _load()
    b = np.asarray(boxes, np.float32)
    xywh = np.stack([(b[:, 0] + b[:, 2]) / 2, (b[:, 1] + b[:, 3]) / 2,
                     b[:, 2] - b[:, 0], b[:, 3] - b[:, 1]], 1)
    f = np.asarray(m["reid"](frame, xywh))
    f = f.reshape(len(boxes), -1).astype(np.float32)
    return f / (np.linalg.norm(f, axis=1, keepdims=True) + 1e-8)


def descriptors(frames, tracks, k=EXEMPLARS):
    """One pooled appearance vector per track - the unit of upload.

    This is the "break in continuity" moment: while a track holds, the
    object store hears nothing about it. When the track is done, it is
    described ONCE, by a mean over k views spread across its life, and
    that single descriptor is what the store is asked about.
    """
    ids, out = [], []
    for tid, t in tracks.items():
        V = []
        for i in _views(t, k):
            V.append(features(frames[t["f"][i]], [t["box"][i]])[0])
        v = np.mean(V, 0)
        ids.append(tid)
        out.append(v / (np.linalg.norm(v) + 1e-8))
    return ids, (np.stack(out) if out else np.zeros((0, 512), np.float32))


def _iou(a, b):
    x0, y0 = max(a[0], b[0]), max(a[1], b[1])
    x1, y1 = min(a[2], b[2]), min(a[3], b[3])
    inter = max(x1 - x0, 0) * max(y1 - y0, 0)
    ua = ((a[2] - a[0]) * (a[3] - a[1])
          + (b[2] - b[0]) * (b[3] - b[1]) - inter)
    return inter / ua if ua > 0 else 0.0


def free_negatives(tracks, iou_max=0.1):
    """Track-id pairs that are CERTAINLY different physical objects.

    Two tracks occupying DISJOINT REGIONS OF THE SAME FRAME are two
    different objects: one object cannot be in two places at one
    instant, and the tracker has already linked each object to itself,
    so a second track is a second thing. No annotation, no dataset
    knowledge, no prior - geometry alone.

    The disjointness test is not decoration. Co-existence ALONE is not
    enough, because a detector will sometimes put two boxes on one
    object; those two tracks co-exist and look identical, and they land
    exactly in the high tail that a calibration quantile reads. Fitting
    on co-existence alone drove the cut to its 0.95 ceiling on a 300-
    episode run and fragmented the store to 1.21 sightings per object.
    Requiring the boxes to be apart removes the double detections, which
    are the only way two tracks of the same object can co-exist.
    """
    out, ids = [], list(tracks)
    for x in range(len(ids)):
        a = tracks[ids[x]]
        fa = {f: i for i, f in enumerate(a["f"])}
        for y in range(x + 1, len(ids)):
            b = tracks[ids[y]]
            shared = [f for f in b["f"] if f in fa]
            if not shared:
                continue
            bf = {f: i for i, f in enumerate(b["f"])}
            if all(_iou(a["box"][fa[f]], b["box"][bf[f]]) <= iou_max
                   for f in shared):
                out.append((ids[x], ids[y]))
    return out


def free_positives(tracks, iou_min=0.8):
    """Track-id pairs that are CERTAINLY the SAME physical object.

    The mirror of free_negatives, from the mirror of its argument. That
    one says two tracks in DISJOINT regions of one frame are two objects,
    because one object cannot be in two places at once. This one says two
    tracks in the SAME region of one frame are ONE object, because two
    objects cannot occupy one place at once. Both are geometry; neither
    needs an annotation, a label or a dataset prior.

    These pairs are DOUBLE DETECTIONS - the detector put two boxes on one
    thing - which is why free_negatives works to exclude them. They were
    treated as waste. They are not waste: they are the only proven-same
    evidence this corpus can produce for free, and without them the cut
    was fitted from one side of a two-sided decision.

    iou_min is high on purpose. Overlap alone does not prove identity:
    nested and contacting things - a lid on a jar, a hand on a pot -
    overlap heavily and are two objects. Measured on fresh_bench, the
    positive sample gets monotonically cleaner as the bar rises, and the
    AUC it implies rises with it (0.860 at IoU>0.5 to 0.902 at IoU>0.95),
    i.e. the loose bands are contaminated by exactly those nested pairs.
    0.8 keeps thousands of pairs while paying most of that gap.

    BIAS, stated, because it bounds what this can conclude: a same-frame
    positive is the EASIEST positive there is - one instant, one
    viewpoint, one exposure - while a same-frame negative is the HARDEST
    negative. So the fitted cut is bracketed by two optimistic samples
    pulling in opposite directions, and a genuine cross-episode
    re-identification is harder than anything measured here.
    """
    out, ids = [], list(tracks)
    for x in range(len(ids)):
        a = tracks[ids[x]]
        fa = {f: i for i, f in enumerate(a["f"])}
        for y in range(x + 1, len(ids)):
            b = tracks[ids[y]]
            shared = [f for f in b["f"] if f in fa]
            if not shared:
                continue
            bf = {f: i for i, f in enumerate(b["f"])}
            if all(_iou(a["box"][fa[f]], b["box"][bf[f]]) >= iou_min
                   for f in shared):
                out.append((ids[x], ids[y]))
    return out


def interval_pairs(rows, iou_diff=0.1, iou_same=0.8, start_frac=0.25):
    """free_negatives and free_positives over CLOSED intervals.

    The write path does not hold tracks - it holds closed presence
    intervals, each reduced to one box. Same two geometric arguments,
    applied to what the writer actually has.

    APPROXIMATION, stated: the retained box is the track's FIRST box, so
    two boxes are only comparable when the tracks START together. That is
    always true of the double detections the positive test is looking
    for, and start_frac enforces it rather than assuming it. The negative
    test does not need the guard - a pair wrongly called disjoint is a
    pair dropped, not a pair mislabelled.

    Args:
        rows: (stream, meta) or (stream, meta, vec) as close_track emits;
              meta carries ts, t1 and box.
    Returns:
        (neg, pos) index-pair lists into `rows`.
    """
    n = len(rows)
    key = sorted(range(n), key=lambda i: (rows[i][0], rows[i][1]["ts"]))
    neg, pos = [], []
    for a in range(len(key)):
        i = key[a]
        si, mi = rows[i][0], rows[i][1]
        for b in range(a + 1, len(key)):
            j = key[b]
            sj, mj = rows[j][0], rows[j][1]
            if sj != si or mj["ts"] > mi["t1"]:
                break                       # sorted: no later one overlaps
            ov = _iou(mi["box"], mj["box"])
            # the sweep runs in time order, so emit (min, max) rather
            # than (earlier, later) - a pair is unordered and callers
            # should not have to know which way round it came out.
            e = (i, j) if i < j else (j, i)
            if ov <= iou_diff:
                neg.append(e)
            elif ov >= iou_same:
                span = min(mi["t1"] - mi["ts"], mj["t1"] - mj["ts"])
                if abs(mj["ts"] - mi["ts"]) <= start_frac * max(span, 1):
                    pos.append(e)
    return neg, pos


def handoff_pairs(rows, gap_s=1.0, iou_min=0.5):
    """Proven-same evidence every corpus produces in BULK: track handoffs.

    An interval that ends and another that begins at the same place
    within `gap_s` is one object twice - things do not teleport, and
    nothing else can arrive at that exact spot that fast. This is the
    within-group continuity that double-detection positives cannot
    supply (a clean proposer produces almost none: 77 pairs on the sim
    corpus vs 927k negatives), and it is exactly the decision event
    binding depends on: "is the block after the occlusion the block
    from before it".

    The retained box is the track's FIRST box, so a pair is emitted only
    when the dying track's origin still overlaps the resuming track's
    origin - true for the static objects that dominate any corpus, a
    miss (never a mislabel) for objects whose track broke mid-flight.
    """
    from bisect import bisect_right
    by = {}
    for i, r in enumerate(rows):
        by.setdefault(r[0], []).append(i)
    out = []
    gap = int(gap_s * 1e9)
    for idxs in by.values():
        idxs.sort(key=lambda i: rows[i][1]["ts"])
        ts = [rows[i][1]["ts"] for i in idxs]
        for i in idxs:
            e = rows[i][1]["t1"]
            for k in range(bisect_right(ts, e), bisect_right(ts, e + gap)):
                j = idxs[k]
                if _iou(rows[i][1]["box"], rows[j][1]["box"]) >= iou_min:
                    out.append((i, j) if i < j else (j, i))
    return out


def calibrate(V, pairs, pos=None, q=CAL_Q, floor=0.5, ceil=0.95):
    """Fit the match cut from proven pairs, on this corpus.

    TWO-SIDED when proven-same pairs are supplied, and that is the
    correction. The one-sided form below reads the q-th percentile of the
    proven-DIFFERENT similarities - "tighter than all but q% of what I
    can show is not the same object" - which is sound only if that sample
    is clean. On fresh_bench it was not: double detections are 0.5% of
    co-existing pairs and q=99.5 reads the top 0.5%, so the cut was very
    nearly a readout of the contamination. It sat at 0.739, where only
    26% of proven-same pairs are accepted, and the store fragmented to
    78% singletons.

    Fixing the contamination alone moves it to 0.692 - still 34% recall.
    A one-sided fit cannot do better, because the tail of the negatives
    says nothing about where the positives are. Hence Youden's J over
    both: the cut that maximises (true accept rate - false merge rate),
    the standard criterion when neither error has a stated price. It is
    fitted, not chosen; no number here is hand-picked.

    Fails safe in stages: no positives, use the one-sided percentile; too
    few negatives to estimate at all, keep MATCH.

    Args:
        V: (N, D) unit-norm track descriptors.
        pairs: proven-DIFFERENT index pairs, from free_negatives.
        pos: proven-SAME index pairs, from free_positives. Optional.
    """
    if len(pairs) < 20:
        return MATCH, len(pairs)
    neg = np.array([float(V[i] @ V[j]) for i, j in pairs])
    if pos is None or len(pos) < 20:
        return float(np.clip(np.percentile(neg, q), floor, ceil)), len(neg)
    p = np.array([float(V[i] @ V[j]) for i, j in pos])
    grid = np.linspace(0.0, 1.0, 201)
    j = ((p[None, :] >= grid[:, None]).mean(1)
         - (neg[None, :] >= grid[:, None]).mean(1))
    return float(np.clip(grid[int(j.argmax())], floor, ceil)), len(neg)


def fit_cut(V, groups, neg, pos=None, grid=None, handoff=None):
    """Fit the cut by MAXIMISING what identity is actually for.

    Both free samples - proven-different and proven-same - are SAME-FRAME
    pairs. The decision the gallery actually makes is not that one. It is
    "is this new track the object I saw in a different episode?", and no
    same-frame pair is evidence about it. So a statistic of those pairs
    can bound the descriptor's quality but cannot choose the operating
    point, and choosing it from one anyway is how the cut ended up where
    it did.

    The target quantity is measurable directly and needs no annotation:
    HOW MANY OBJECTS RECUR ACROSS GROUPS. That is the whole point of a
    persistent id, and it is self-limiting as an objective - too tight
    and every sighting is a new id that recurs nowhere; too loose and
    distinct objects collapse into one attractor, which also recurs
    nowhere because there are no longer distinct objects to recur. It
    therefore has an interior maximum, and that maximum is the fit.

    Measured on fresh_bench (2,097 episodes, 73,521 intervals):

        cut    objects  singletons  RECUR   worst false-merge
        0.75    34,063     80.2%    6,447        0.18%
        0.70    26,159     74.0%    6,475        0.22%   <- peak
        0.60    14,011     57.3%    5,732        0.54%
        0.50     6,320     36.8%    3,876        1.05%
        0.42     2,618     19.4%    2,075        1.72%

    which also disposes of the singleton rate as a target: driving it
    from 80% to 19% costs two thirds of the recurrence and multiplies
    proven-wrong merges by eight. Fewer, bigger, wronger objects.

    Sweeping a cut used to be impossible - Gallery.assign was ~20 minutes
    per pass - so the fit had to be a closed-form statistic. It is now
    seconds, and an objective beats a proxy.

    Args:
        V: (N, D) unit-norm descriptors.
        groups: (N,) group label per descriptor - the episode it sits in.
        neg: proven-different pairs, for the reported false-merge rate.
        pos: proven-same pairs, for the reported recovery rate.
        grid: candidate cuts. Defaults to percentiles of the proven-
              different similarities, so the range adapts to the corpus
              rather than being a hand-written span.
        handoff: track-handoff proven-same pairs (handoff_pairs). When
              supplied in bulk they CONSTRAIN the objective - see below.
    Returns:
        (cut, report) - report lists every candidate that was tried.
    """
    g = np.asarray(groups)
    g = g - g.min() + 1
    span = int(g.max()) + 2
    N = np.asarray(neg, np.int64).reshape(-1, 2)
    Pp = np.asarray(pos if pos is not None else [], np.int64).reshape(-1, 2)
    H = np.asarray(handoff if handoff is not None else [],
                   np.int64).reshape(-1, 2)
    if grid is None:
        s = (np.einsum("ij,ij->i", V[N[:, 0]], V[N[:, 1]]) if len(N)
             else np.array([MATCH]))
        grid = np.unique(np.round(np.percentile(
            s, [90, 95, 97.5, 99, 99.3, 99.5, 99.7, 99.9]), 3))

    def _row(cut):
        ids = Gallery(match=float(cut)).assign(V)
        n = int(ids.max()) + 1
        # objects present in more than one group
        u = np.unique(ids.astype(np.int64) * span + g)
        per = np.bincount((u // span).astype(np.int64), minlength=n)
        row = {"cut": float(cut), "objects": n,
               "recur": int((per > 1).sum()),
               "singleton_pct": round(100.0 * float(
                   (np.bincount(ids) == 1).mean()), 1)}
        if len(N):
            row["false_merge_pct"] = round(100.0 * float(
                (ids[N[:, 0]] == ids[N[:, 1]]).mean()), 3)
        if len(Pp):
            row["recovered_pct"] = round(100.0 * float(
                (ids[Pp[:, 0]] == ids[Pp[:, 1]]).mean()), 1)
        if len(H):
            row["handoff_pct"] = round(100.0 * float(
                (ids[H[:, 0]] == ids[H[:, 1]]).mean()), 1)
        return row

    def _pick(rep):
        # Recurrence alone is GAMEABLE BY FRAGMENTATION on a lookalike
        # corpus: identical twins across episodes mean noise-clusters of
        # one kind still span groups, so the count keeps rising as the
        # cut climbs while real objects shatter (measured on the sim
        # store: 21,492 objects for ~450 true blocks, 2.67 ids per
        # block, 8% same-block event agreement). A cut that cannot
        # re-find an object across a one-second track break identifies
        # nothing - so when handoff pairs exist in bulk, continuity
        # recovery is a CONSTRAINT on the recurrence objective, not a
        # tiebreaker: maximise recurrence among cuts that recover >=70%
        # of handoffs, and if none does, take the best recovery there is.
        if len(H) >= 30:
            ok = [r for r in rep if r.get("handoff_pct", 0.0) >= 70.0]
            if ok:
                return max(ok, key=lambda r: r["recur"])
            return max(rep, key=lambda r: (r.get("handoff_pct", 0.0),
                                           r["recur"]))
        return max(rep, key=lambda r: r["recur"])

    report = [_row(cut) for cut in grid]
    best = _pick(report)
    # The grid is percentiles of the proven-DIFFERENT similarities, and
    # that anchor breaks when the descriptor is much better than the
    # negatives are hard: recurrence keeps rising past the negatives'
    # entire tail, the argmax lands on the grid's top edge, and the
    # "interior maximum" was never actually bracketed. Measured on the
    # DINOv3 rebuild: recur still climbing 7,590 -> 10,541 at the last
    # point. So while the best cut IS the top edge, keep extending
    # upward (midpoint steps toward 0.99) until the maximum is interior
    # or the ceiling is reached - the fit must end bracketed, not
    # truncated by an artifact of where the negatives happened to end.
    while best["cut"] == report[-1]["cut"] and best["cut"] < 0.985:
        report.append(_row(round(best["cut"]
                                 + (0.99 - best["cut"]) / 2, 3)))
        nxt = _pick(report)
        if nxt["cut"] == best["cut"]:
            break
        best = nxt
    return best["cut"], report


class Gallery:
    """Persistent identities, matched by appearance.

    A GALLERY of exemplars per object, not a running mean. The mean is
    what broke the earlier attempt: a centroid that updates as it
    absorbs members lets a group CHAIN - green, to a slightly different
    green, to red - so two objects merge through a path of intermediate
    views. Several fixed exemplars, matched against the best of them,
    has no such path. This is ordinary ReID gallery practice.

    What it is asked about changed: entries are now TRACK descriptors
    pooled over many frames, not single crops, so each question carries
    a whole sighting's worth of evidence.
    """

    def __init__(self, match=MATCH, max_ex=MAX_EXEMPLARS, admit=ADMIT):
        self.match, self.max_ex, self.admit = match, max_ex, admit
        self.ex: list[np.ndarray] = []
        self.n: list[int] = []
        # every exemplar of every object in ONE matrix, plus which object
        # owns each row. self.ex stays the public view of the same thing.
        self._M = np.zeros((0, 0), np.float32)
        self._own = np.zeros(0, np.int32)
        self._m = 0

    def _grow(self, dim):
        if self._M.shape[0] > self._m:
            return
        cap = max(1024, self._M.shape[0] * 2)
        M = np.zeros((cap, dim), np.float32)
        if self._m:
            M[:self._m] = self._M[:self._m]
        own = np.zeros(cap, np.int32)
        own[:self._m] = self._own[:self._m]
        self._M, self._own = M, own

    def assign(self, feats):
        """Which object is each descriptor, in arrival order.

        ONE matrix-vector product per descriptor, not one per object.
        The old form looped over self.ex in Python, so a 73,521-track
        corpus that had grown 32,756 objects did 2.4 billion iterations
        of a two-line body - about 20 of the write's 54 minutes, and slow
        enough that the cut could not be swept to find out it was wrong.

        The reduction is exact, not an approximation: the best object is
        the one owning the best EXEMPLAR, because max over objects of
        (max over that object's exemplars) IS the max over all exemplars.
        So a single argmax replaces the per-object max-then-compare.
        """
        ids = []
        for v in feats:
            v = np.ascontiguousarray(v, np.float32)
            best, bs = -1, -1.0
            if self._m:
                s = self._M[:self._m] @ v
                k = int(s.argmax())
                best, bs = int(self._own[k]), float(s[k])
            if best >= 0 and bs >= self.match:
                ids.append(best)
                self.n[best] += 1
                E = self.ex[best]
                # keep a view only if it ADDS one; near-duplicates of a
                # stored exemplar teach the gallery nothing. Admit it
                # only if it agrees with EVERY held exemplar - min(),
                # not max() - so the acceptance region cannot sprawl.
                if (len(E) < self.max_ex and bs < 0.95
                        and float((E @ v).min()) >= self.admit):
                    self.ex[best] = np.vstack([E, v])
                    self._grow(len(v))
                    self._M[self._m] = v
                    self._own[self._m] = best
                    self._m += 1
            else:
                self.ex.append(v[None, :])
                self.n.append(1)
                best = len(self.ex) - 1
                ids.append(best)
                self._grow(len(v))
                self._M[self._m] = v
                self._own[self._m] = best
                self._m += 1
        return np.asarray(ids, np.int32)

    def centroids(self):
        return np.stack([E.mean(0) / (np.linalg.norm(E.mean(0)) + 1e-8)
                         for E in self.ex]) if self.ex else np.zeros(
                             (0, 512), np.float32)

    def __len__(self):
        return len(self.ex)