blog / posts /ObjectModel-v1.html
TobiasLogic's picture
Add ObjectModel-v1 post
3ecc2bf verified
Raw History Blame Contribute Delete
26.3 kB
<!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>