File size: 26,252 Bytes
3ecc2bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ObjectModel-v1: Betting on a Fixed Latent Memory</title>
  <link rel="stylesheet" href="../style.css">
</head>
<body>
<script type="module" src="../summarize.js" defer></script>
<script src="../script.js" defer></script>

<div class="background"></div>

<main class="article">

<a class="back" href="../index.html">
← Back
</a>

<div class="meta">
August 2026
</div>

<h1>
ObjectModel-v1: Betting on a Fixed Latent Memory
</h1>

<p>
Everything Bench Labs has shipped so far generates something: pixels, voxels, tokenizer output. <strong>ObjectModel-v1</strong> is the first thing that looks at an image and tells you what's in it. It's a from-scratch, NMS-free object detector, 40.8M parameters, trained end to end on COCO2017. No pretraining, no hyperparameter search across seeds, none of the planned ablations run yet.
</p>

<p>
Peak validation AP is <strong>0.358</strong>, at epoch 95 of 100. That's below the 40-50 range we set as a competitiveness bar going in, and we're saying so up front rather than burying it. This post is the honest version of that number: what the model is testing, what it actually does on real footage, and what the gap is telling us to try next.
</p>

<h2>
The bet
</h2>

<p>
Most transformer detectors do global reasoning by attending over the full feature map, every pixel token, every layer. ObjectModel-v1 compresses that down first: a small convolutional backbone builds a multi-scale pyramid, that pyramid gets pooled into a handful of tokens, and those tokens are packed into a <strong>fixed latent memory</strong>. Object queries cross-attend to that memory for global context.
</p>

<p>
The obvious risk is that compressing global context loses the geometric precision a detector needs for tight boxes. ObjectModel-v1's answer is to get precision back a different way: each query also runs a <strong>local sampler</strong> that reads directly from the full-resolution pyramid, centered on and scaled to the query's current box estimate. Early decoder layers can sample broadly since the box guess is still rough; later layers narrow in as the box refines.
</p>

<pre><code>image
  -> compact convolutional backbone (strides 8/16/32)
  -> top-down pyramid fusion
  -> pooled multi-scale tokens
  -> fixed latent memory (global semantics)
  -> learned object queries
       -> query self-attention
       -> cross-attention to latent memory
       -> local sampling around the current query box
       -> iterative class and box prediction
  -> object set (no anchors, no NMS)</code></pre>

<p>
The model is <strong>NMS-free</strong>. It predicts a fixed set of objects directly and is trained with Hungarian bipartite matching against the ground truth, the same set-prediction idea DETR introduced. There's also an optional dense auxiliary head active only during training, adding one-to-many spatial supervision on top of the one-to-one matched loss. It's discarded at inference and, like everything else here, needs to be run as an ablation rather than assumed to help.
</p>

<h2>
Numbers
</h2>

<table>
<thead><tr><th>metric</th><th>value</th></tr></thead>
<tbody>
<tr><td>Parameters</td><td>40.8M</td></tr>
<tr><td>Epochs</td><td>100 / 100 (complete)</td></tr>
<tr><td>Peak val AP (IoU 0.50:0.95)</td><td>0.358 (epoch 95)</td></tr>
<tr><td>Final val AP (epoch 100)</td><td>0.356</td></tr>
<tr><td>Val AP50 / AP75</td><td>0.544 / 0.381</td></tr>
<tr><td>Val AP small / medium / large</td><td>0.188 / 0.387 / 0.493</td></tr>
<tr><td>Val AR@100</td><td>0.573</td></tr>
<tr><td>Train loss</td><td>4.11 (from 15.77 at epoch 1)</td></tr>
<tr><td>Throughput</td><td>~90-110 img/s, batch 32, RTX 5090</td></tr>
<tr><td>Single-frame inference</td><td>30.7 ms / 32.5 FPS (batch 1, eager, RTX 5090)</td></tr>
</tbody>
</table>

<p>
<img src="https://huggingface.co/bench-labs/objectmodel-v1/resolve/main/assets/ap_progress.svg" alt="Validation AP by epoch across the full 100-epoch run, rising from near-zero to a peak of 0.358 at epoch 95" style="width: 100%; max-width: 860px;">
</p>

<p>
Train loss over the same run, log-shaped like most detectors trained from scratch: a sharp drop in the first few epochs, then a long gradual decline from around 6 down to 4.1:
</p>

<p>
<img src="https://huggingface.co/bench-labs/objectmodel-v1/resolve/main/assets/loss_progress.svg" alt="Train loss by epoch, dropping sharply early then declining gradually to about 4.1" style="width: 100%; max-width: 860px;">
</p>

<p>
AP50 (a loose IoU 0.50 localization threshold) against AP75 (strict, IoU 0.75). Both climb together early, then AP75 plateaus lower. Coarse localization got easy faster than precise localization did:
</p>

<p>
<img src="https://huggingface.co/bench-labs/objectmodel-v1/resolve/main/assets/ap50_75_progress.svg" alt="AP50 and AP75 by epoch, AP50 rising to about 0.54 and AP75 to about 0.38" style="width: 100%; max-width: 860px;">
</p>

<p>
And AP broken out by object size. Large objects were detected reliably well before small ones caught up, and small ones never really did:
</p>

<p>
<img src="https://huggingface.co/bench-labs/objectmodel-v1/resolve/main/assets/ap_by_size_progress.svg" alt="AP by object size, large objects highest at about 0.49, medium at about 0.39, small lowest at about 0.18" style="width: 100%; max-width: 860px;">
</p>

<h2>
Honesty section
</h2>

<p>
0.358 AP is a real number from a real, single-seed, un-pretrained run, not a benchmark claim. It's below the 40-50 range that would put it in the neighborhood of 20-40M-parameter real-time detectors, which was the bar we set for "competitive" before training started. We're not rounding that up.
</p>

<p>
The size-broken-out AP is the more useful signal than the headline number: 0.188 on small objects against 0.493 on large ones is a wider gap than the usual small-vs-large spread for this class of detector. That points somewhere specific, the fixed latent memory or the local sampler's pyramid resolution, rather than just "needs more epochs." More on that below.
</p>

<p>
Getting the run to actually finish in reasonable time took real debugging, separate from the architecture question. Two things cost the most: per-step GPU-to-CPU syncs (calling <code>.item()</code> on loss tensors every step) were quietly stalling <code>torch.compile</code>'s CUDA-graph replay, and the loss computation had several nested Python loops over images and decoder layers building cost matrices and dense targets one at a time. Vectorizing those, batched Hungarian cost-matrix construction, batched dense-target assignment, batched set-prediction loss targets, took throughput from around 48 img/s to the ~100 img/s the numbers above report, on the same 5090, mid-run, without changing what the loss actually computes.
</p>

<h2>
Examples
</h2>

<p>
Detections from the epoch-95 checkpoint on COCO val2017 images, confidence &ge; 0.35. Picked for variety, not cherry-picked for perfection:
</p>

<p>
<img src="https://huggingface.co/bench-labs/objectmodel-v1/resolve/main/assets/examples/street_market.jpg" alt="A street market with a person at 0.94 confidence and an umbrella awning detected" style="width: 100%; max-width: 640px;">
</p>

<p>
Street market: person at 0.94, market umbrella correctly boxed.
</p>

<p>
<img src="https://huggingface.co/bench-labs/objectmodel-v1/resolve/main/assets/examples/rodeo.jpg" alt="A rodeo scene with multiple people correctly boxed in a crowd" style="width: 100%; max-width: 640px;">
</p>

<p>
A crowd at a rodeo, most people correctly boxed, one animal still mislabeled, left in rather than cropped out.
</p>

<p>
<img src="https://huggingface.co/bench-labs/objectmodel-v1/resolve/main/assets/examples/kite_beach.jpg" alt="A rainbow kite in flight above a beach with people and a distant boat" style="width: 100%; max-width: 640px;">
</p>

<p>
A kite at 0.80 confidence, people along the shore, and a distant boat, three very different scales in one frame.
</p>

<h2>
From detector to tracker
</h2>

<p>
ObjectModel-v1 has no temporal component of its own, every frame is detected independently. To show what that looks like on video, we built a small from-scratch SORT-style tracker on top of it (<code>src/objectmodel_v1/tracking.py</code>): a constant-velocity Kalman motion model plus IoU/Hungarian frame-to-frame association, giving each box a persistent id and a short motion trail.
</p>

<p>
<img src="https://huggingface.co/bench-labs/objectmodel-v1/resolve/main/assets/tracking_demo.gif" alt="ObjectModel-v1 detections chained through a SORT tracker on real pedestrian footage, boxes and ids tracking people across frames" style="width: 100%; max-width: 480px;">
</p>

<p>
<a href="https://huggingface.co/bench-labs/objectmodel-v1/resolve/main/assets/tracking_demo.mp4">Full-length video (20s, MP4) &rarr;</a>
</p>

<p>
Source footage is <code>vtest.avi</code>, OpenCV's standard pedestrian test clip (BSD-3, ships with OpenCV), genuine video the model never trained on. We ran the same clip through the same unchanged tracker at three points in training, so the only thing that changed between rows is the detector's checkpoint:
</p>

<table>
<thead><tr><th>checkpoint</th><th>ids issued over 20s</th><th>longest-lived ids</th></tr></thead>
<tbody>
<tr><td>epoch 13 (AP 0.227)</td><td>~89</td><td>none survive past a few seconds</td></tr>
<tr><td>epoch 26 (AP 0.288)</td><td>~84</td><td>3 ids survive nearly the full clip</td></tr>
<tr><td>epoch 95 (AP 0.358, peak)</td><td>92</td><td>4 ids survive nearly the full clip</td></tr>
</tbody>
</table>

<p>
Id count issued doesn't fall much across checkpoints, new people keep entering frame throughout the clip and each earns a new id, which is correct behavior. What actually improved is persistence: ids for people already in frame survive longer as the detector gets better. A separate test on a genuinely different scene, an eye-level warehouse clip not shown here, surfaced a distinct limitation instead: the detector still occasionally hallucinates objects on plain background surfaces, reading a support pillar as "refrigerator", even at this peak checkpoint. Tracking quality rides directly on detection quality, and detection quality on footage that doesn't look like COCO's own photography, different camera angles, lighting, compression, is visibly weaker than on COCO's own validation images.
</p>

<h2>
Restricted zone detection
</h2>

<p>
One layer further: a polygon zone monitor (<code>src/objectmodel_v1/zones.py</code>) that fires an event only on the transition into or out of a region, not on every frame a track spends inside it. It keys off each box's bottom-center point, roughly where feet touch the ground, rather than the box centroid, since a zone drawn on a floor plane should care where someone is standing, not where their torso is.
</p>

<p>
<img src="https://huggingface.co/bench-labs/objectmodel-v1/resolve/main/assets/zone_demo.gif" alt="A marked polygon zone over a plaza walkway, with tracked people highlighted and a red ALERT banner firing when someone enters the zone" style="width: 100%; max-width: 480px;">
</p>

<p>
<a href="https://huggingface.co/bench-labs/objectmodel-v1/resolve/main/assets/zone_demo.mp4">Full-length video (20s, MP4) &rarr;</a>
</p>

<p>
We checked this on a synthetic sequence before trusting it on video: a track walking through a rectangle produces exactly one <code>entered</code> and one <code>exited</code> event, none of the frames spent inside re-fire, a track that never enters never fires, and a fresh id gets independent state. That test lives with the module.
</p>

<p>
On the plaza clip above, the zone fired <strong>91</strong> enter/exit events over 20 seconds. Read that carefully: most of it is not 91 different real crossings. It's the same tracker id churn from the section above, the same person's track resetting and re-entering the zone as a "new" id, plus a handful of misclassified objects (a backpack or handbag read as its own tracked object) that shouldn't have triggered an event at all. The event mechanics are verified correct. The real-world event count is exactly as reliable as the detector and tracker underneath it, which right now is usable for a demo, not for anything where a false alert has a real cost.
</p>

<pre><code>from objectmodel_v1.zones import RestrictedZoneMonitor

zone = [(300, 150), (650, 150), (650, 320), (300, 320)]  # pixel-space polygon
monitor = RestrictedZoneMonitor(zone)

for frame_index, frame in enumerate(video_frames):
    boxes, labels, scores = detect(frame)
    tracks = tracker.update(boxes, labels, scores)
    for event in monitor.update(tracks, frame_index):
        print(event.track_id, event.kind, event.position)  # "entered" or "exited"</code></pre>

<p>
Neither of these two layers retrains the detector. Persistent ids from the tracker are enough on their own to build most counting and monitoring logic on top: unique counting, movement heatmaps from the same trail data the demos already compute, dwell-time timers, line-crossing counters, class-based counting across any of the 80 COCO categories. Behavior/anomaly detection and multi-camera re-identification are real extensions but not close ones, they need motion modeling beyond a Kalman filter and cross-camera identity matching, neither of which this repository does yet.
</p>

<h2>
What v2 needs
</h2>

<p>
Three things came out of this run as specific findings, not a generic "train it bigger":
</p>

<ul>
<li>The small-vs-large AP gap (0.188 vs 0.493) is wider than usual for this class of detector, and points at the fixed latent memory or the local sampler's pyramid resolution as the first places to look. The latent-layer-count and latent-count ablations in the repo's validation protocol exist specifically to test that.</li>
<li>Detection quality drops visibly on footage that doesn't look like COCO's own photography, the warehouse clip above is the clearest example, which caps every extension built on top of it. Broader training data or augmentation is the direct lever, and it's not currently in the required ablation list, it should be.</li>
<li>v1 trained from random initialization only. A pretrained comparison is already called for in the validation protocol, and based on how much pretraining typically moves the needle at this parameter count in the baselines we're citing, it's the single change most likely to move AP the most.</li>
</ul>

<h2>
Try it
</h2>

<pre><code>git clone https://huggingface.co/bench-labs/objectmodel-v1
cd objectmodel-v1
python3.11 -m venv .venv
.venv/bin/pip install -e '.[coco,export,dev]'

objectmodel-eval \
  --config configs/objectmodel_v1.yaml \
  --checkpoint objectmodel_v1_best.pt \
  --data-root /path/to/coco</code></pre>

<p>
Full architecture notes, the required-ablations list, and the research this builds on (DETR, Conditional/Deformable DETR, RT-DETR, D-FINE, DEIM, LW-DETR) are in the <a href="https://huggingface.co/bench-labs/objectmodel-v1">ObjectModel-v1 repository</a>.
</p>

<blockquote>
First detector out of Bench Labs. It works well on the data it was trained on, worse off that data, and the README says exactly where. That's what v1 is for.
</blockquote>

</main>

<script>
/* ==========================================================
   SYNTAX HIGHLIGHTING
   ========================================================== */
const PY_KW = new Set([
  'def','class','return','if','elif','else','for','while','import','from','as',
  'try','except','finally','with','yield','lambda','pass','break','continue',
  'raise','and','or','not','in','is','global','nonlocal','assert','del',
  'async','await'
]);
const JS_KW = new Set([
  'const','let','var','function','return','if','else','for','while','do',
  'switch','case','break','continue','new','class','extends','import','export',
  'from','default','try','catch','finally','throw','typeof','instanceof',
  'in','of','async','await','yield','void','delete','with'
]);
const PY_BI = new Set([
  'print','len','range','int','str','float','list','dict','set','tuple','bool',
  'type','isinstance','input','open','map','filter','zip','enumerate','sorted',
  'reversed','sum','min','max','abs','round','any','all','super','property',
  'staticmethod','classmethod','True','False','None','Exception','ValueError',
  'TypeError','KeyError','IndexError','AttributeError','NotImplementedError',
  'RuntimeError','StopIteration','FileNotFoundError','object'
]);
const JS_BI = new Set([
  'console','Math','Array','Object','String','Number','Boolean','Date','RegExp',
  'Error','TypeError','RangeError','SyntaxError','JSON','Promise','Map','Set',
  'Symbol','Proxy','Reflect','parseInt','parseFloat','isNaN','isFinite',
  'undefined','null','true','false','NaN','Infinity'
]);
function escapeHtml(s) {
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function detectLang(code) {
  let py = 0, js = 0;
  const pyP = /\b(def |class |import |from \w+ import|elif |print\(|True|False|None|self[\.\[])\b/g;
  const jsP = /\b(const |let |var |function |=>|console\.|undefined|null|async |await )\b/g;
  let m;
  while ((m = pyP.exec(code)) !== null) py++;
  while ((m = jsP.exec(code)) !== null) js++;
  if (py > js) return 'python';
  if (js > 0)  return 'javascript';
  return 'text';
}
function highlight(code) {
  const lang = detectLang(code);
  const kw = lang === 'python' ? PY_KW : lang === 'javascript' ? JS_KW : new Set();
  const bi = lang === 'python' ? PY_BI : lang === 'javascript' ? JS_BI : new Set();
  const tokens = [];
  let i = 0;
  const len = code.length;
  while (i < len) {
    if (lang === 'python') {
      const t3 = code.slice(i, i + 3);
      if (t3 === '"""' || t3 === "'''") {
        const end = code.indexOf(t3, i + 3);
        const span = end !== -1 ? code.slice(i, end + 3) : code.slice(i);
        tokens.push({ type: 'string', text: span });
        i += span.length;
        continue;
      }
    }
    {
      const pm = code.slice(i).match(/^[frbFRB]{0,3}/);
      if (pm) {
        const pLen = pm[0].length;
        const nc = code[i + pLen];
        if (nc === '"' || nc === "'") {
          const q = nc;
          const isF = pm[0].toLowerCase().includes('f');
          let depth = 0, k = i + pLen + 1;
          let found = false;
          while (k < len) {
            const ch = code[k];
            if (ch === '\\') { k += 2; continue; }
            if (isF) {
              if (ch === '{') { depth++; k++; continue; }
              if (ch === '}') { depth = Math.max(0, depth - 1); k++; continue; }
            }
            if (ch === q && depth === 0) { found = true; break; }
            k++;
          }
          if (found) {
            tokens.push({ type: 'string', text: code.slice(i, k + 1) });
            i = k + 1;
            continue;
          }
        }
      }
    }
    if (lang === 'python' && code[i] === '#') {
      let end = code.indexOf('\n', i);
      if (end === -1) end = len;
      tokens.push({ type: 'comment', text: code.slice(i, end) });
      i = end;
      continue;
    }
    if (lang === 'javascript' && code.slice(i, i + 2) === '//') {
      let end = code.indexOf('\n', i);
      if (end === -1) end = len;
      tokens.push({ type: 'comment', text: code.slice(i, end) });
      i = end;
      continue;
    }
    if (lang === 'javascript' && code.slice(i, i + 2) === '/*') {
      let end = code.indexOf('*/', i + 2);
      if (end === -1) end = len; else end += 2;
      tokens.push({ type: 'comment', text: code.slice(i, end) });
      i = end;
      continue;
    }
    {
      const m = code.slice(i).match(
        /^(0x[0-9a-fA-F_]+|0b[01_]+|0o[0-7_]+|\d[\d_]*\.?\d*(?:e[+-]?\d+)?)/
      );
      if (m) {
        tokens.push({ type: 'number', text: m[0] });
        i += m[0].length;
        continue;
      }
    }
    if (lang === 'python' && code[i] === '@') {
      const m = code.slice(i).match(/^@[\w.]+/);
      if (m) {
        tokens.push({ type: 'decorator', text: m[0] });
        i += m[0].length;
        continue;
      }
    }
    {
      const m = code.slice(i).match(/^\w+/);
      if (m) {
        const w = m[0];
        let type = 'plain';
        if (kw.has(w))              type = 'keyword';
        else if (bi.has(w))         type = 'builtin';
        else if (w === 'self' || w === 'cls' || w === 'this') type = 'self';
        else {
          let j = i + w.length;
          while (j < len && code[j] === ' ') j++;
          if (code[j] === '(') type = 'function';
        }
        tokens.push({ type, text: w });
        i += w.length;
        continue;
      }
    }
    {
      const m = code.slice(i).match(/^[+\-*/%=<>!&|^~:]+/);
      if (m) {
        tokens.push({ type: 'operator', text: m[0] });
        i += m[0].length;
        continue;
      }
    }
    if ('(){}[],;.'.includes(code[i])) {
      tokens.push({ type: 'punctuation', text: code[i] });
      i++;
      continue;
    }
    {
      const m = code.slice(i).match(/^\s+/);
      if (m) {
        tokens.push({ type: 'plain', text: m[0] });
        i += m[0].length;
      } else {
        tokens.push({ type: 'plain', text: code[i] });
        i++;
      }
    }
  }
  return tokens.map(t => {
    const esc = escapeHtml(t.text);
    return t.type === 'plain' ? esc : `<span class="tok-${t.type}">${esc}</span>`;
  }).join('');
}
document.querySelectorAll('pre code').forEach(block => {
  block.innerHTML = highlight(block.textContent);
});
/* ==========================================================
   LIGHTBOX
   ========================================================== */
class Lightbox {
  constructor() {
    this.images = [];
    this.currentIndex = 0;
    this.isOpen = false;
    this._build();
    this._bindImages();
    this._bindKeys();
    this._bindSwipe();
  }
  _build() {
    this.el = document.createElement('div');
    this.el.className = 'lightbox-overlay';
    this.el.setAttribute('role', 'dialog');
    this.el.setAttribute('aria-modal', 'true');
    this.el.setAttribute('aria-label', 'Image viewer');
    this.el.innerHTML = `
      <button class="lightbox-btn lightbox-close" aria-label="Close">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none"
             stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
          <line x1="18" y1="6" x2="6" y2="18"/>
          <line x1="6" y1="6" x2="18" y2="18"/>
        </svg>
      </button>
      <button class="lightbox-btn lightbox-arrow lightbox-prev" aria-label="Previous image">
        <svg width="22" height="22" viewBox="0 0 24 24" fill="none"
             stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
          <polyline points="15 18 9 12 15 6"/>
        </svg>
      </button>
      <button class="lightbox-btn lightbox-arrow lightbox-next" aria-label="Next image">
        <svg width="22" height="22" viewBox="0 0 24 24" fill="none"
             stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
          <polyline points="9 6 15 12 9 18"/>
        </svg>
      </button>
      <img class="lightbox-img" src="" alt="">
      <div class="lightbox-counter"></div>
    `;
    document.body.appendChild(this.el);
    this.imgEl     = this.el.querySelector('.lightbox-img');
    this.counterEl = this.el.querySelector('.lightbox-counter');
    this.prevBtn   = this.el.querySelector('.lightbox-prev');
    this.nextBtn   = this.el.querySelector('.lightbox-next');
    this.closeBtn  = this.el.querySelector('.lightbox-close');
    this.closeBtn.addEventListener('click', () => this.close());
    this.prevBtn.addEventListener('click', (e) => { e.stopPropagation(); this.prev(); });
    this.nextBtn.addEventListener('click', (e) => { e.stopPropagation(); this.next(); });
    this.el.addEventListener('click', (e) => {
      if (e.target === this.el) this.close();
    });
  }
  _bindImages() {
    this.images = Array.from(document.querySelectorAll('.article img'));
    this.images.forEach((img, idx) => {
      img.addEventListener('click', () => this.open(idx));
    });
  }
  _bindKeys() {
    document.addEventListener('keydown', (e) => {
      if (!this.isOpen) return;
      if (e.key === 'Escape')     this.close();
      if (e.key === 'ArrowLeft')  this.prev();
      if (e.key === 'ArrowRight') this.next();
    });
  }
  _bindSwipe() {
    let startX = 0;
    this.el.addEventListener('touchstart', (e) => {
      startX = e.changedTouches[0].clientX;
    }, { passive: true });
    this.el.addEventListener('touchend', (e) => {
      const dx = startX - e.changedTouches[0].clientX;
      if (Math.abs(dx) > 60) {
        dx > 0 ? this.next() : this.prev();
      }
    }, { passive: true });
  }
  open(idx) {
    this.currentIndex = idx;
    this.isOpen = true;
    this._update();
    requestAnimationFrame(() => {
      this.el.classList.add('active');
    });
    document.body.style.overflow = 'hidden';
    this.closeBtn.focus();
  }
  close() {
    this.isOpen = false;
    this.el.classList.remove('active');
    document.body.style.overflow = '';
    this.images[this.currentIndex]?.focus();
  }
  prev() {
    this.currentIndex = (this.currentIndex - 1 + this.images.length) % this.images.length;
    this._update();
  }
  next() {
    this.currentIndex = (this.currentIndex + 1) % this.images.length;
    this._update();
  }
  _update() {
    const srcImg = this.images[this.currentIndex];
    this.imgEl.style.opacity = '0';
    this.imgEl.style.transform = 'scale(0.96)';
    setTimeout(() => {
      this.imgEl.src = srcImg.src;
      this.imgEl.alt = srcImg.alt;
      this.imgEl.removeAttribute('loading');
      this.imgEl.style.opacity = '1';
      this.imgEl.style.transform = 'scale(1)';
    }, 120);
    const total = this.images.length;
    if (total > 1) {
      this.counterEl.textContent = `${this.currentIndex + 1} / ${total}`;
      this.prevBtn.style.display = '';
      this.nextBtn.style.display = '';
    } else {
      this.counterEl.textContent = '';
      this.prevBtn.style.display = 'none';
      this.nextBtn.style.display = 'none';
    }
  }
}
new Lightbox();
</script>

</body>
</html>