Add Snapshot
This commit is contained in:
1 parent
3b8fb0c312
commit
210df4a038
5 files changed
+359
-4
No files matched your search
@@ -112,6 +112,30 @@ EXPORT_CSV=true
|
||||
# Path where the crossing CSV is written
|
||||
CROSS_CSV=/opt/batch-counter/crossings.csv
|
||||
|
||||
# --- Crossing snapshots ---
|
||||
# Save an annotated frame image every time an object crosses a line and the
|
||||
# counter increases (true/false, default: false). Written to <DIR>/cross/
|
||||
# (filename: <YYYYmmdd_HHMMSS_mmm>_<in|out>_id<track>_f<frame>.jpg)
|
||||
SAVE_CROSS_SNAPSHOT=false
|
||||
# Also save one snapshot the first time each object is detected, before it crosses
|
||||
# (true/false, default: false). Written to <DIR>/detect/ with the same track id so
|
||||
# it can be correlated with the crossing snapshot
|
||||
# (filename: <YYYYmmdd_HHMMSS_mmm>_detect_id<track>_f<frame>.jpg)
|
||||
SAVE_DETECT_SNAPSHOT=false
|
||||
# Base directory for snapshots (detect/ and cross/ subfolders are created inside).
|
||||
# The dashboard reads this same path to display the snapshot gallery, so keep it
|
||||
# identical for both the counter and the dashboard.
|
||||
CROSS_SNAPSHOT_DIR=/opt/batch-counter/snapshots
|
||||
# JPEG quality for snapshots (1-100)
|
||||
CROSS_SNAPSHOT_QUALITY=85
|
||||
# Retention: keep at most this many snapshot files (detect + cross combined);
|
||||
# oldest are deleted first (0 = unlimited)
|
||||
CROSS_SNAPSHOT_MAX_FILES=1000
|
||||
# Retention: delete snapshots older than this many days (0 = never by age)
|
||||
CROSS_SNAPSHOT_MAX_AGE_DAYS=7
|
||||
# Run the cleanup sweep at most once every N seconds
|
||||
CROSS_SNAPSHOT_CLEANUP_SEC=60
|
||||
|
||||
# --- Rate / performance ---
|
||||
# Enable motion detection pre-filter: skip inference on frames with no movement
|
||||
# (true/false, default: false). When enabled, frames below MOTION_THRESHOLD are
|
||||
|
||||
+90
-2
@@ -8,15 +8,17 @@ Default port 5000.
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||
|
||||
from flask import Flask, render_template, jsonify, request, Response
|
||||
from flask import Flask, render_template, jsonify, request, Response, send_file
|
||||
from werkzeug.serving import WSGIRequestHandler
|
||||
|
||||
from dotenv import load_dotenv
|
||||
@@ -33,6 +35,9 @@ CUTOFF_TIME = os.getenv("CUTOFF_TIME", os.getenv("DAILY_CUTOFF_TIME", "20:00"))
|
||||
|
||||
LIVE_STREAM_FRAME_PATH = os.getenv("LIVE_STREAM_FRAME_PATH", "/dev/shm/jetson-counter/live_frame.jpg")
|
||||
|
||||
CROSS_SNAPSHOT_DIR = os.getenv("CROSS_SNAPSHOT_DIR", f"{_DEFAULT_DIR}/snapshots")
|
||||
SAVE_DETECT_SNAPSHOT = os.getenv("SAVE_DETECT_SNAPSHOT", "false").lower() == "true"
|
||||
|
||||
SITE_NAME = os.getenv("SITE_NAME", "LIVE")
|
||||
|
||||
DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000"))
|
||||
@@ -70,6 +75,89 @@ def api_live_video():
|
||||
return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")
|
||||
|
||||
|
||||
_SNAP_RE = re.compile(
|
||||
r"^(?P<ts>\d{8}_\d{6}_\d{3})_(?P<kind>detect|in|out)_id(?P<tid>\d+)_f(?P<frame>\d+)\.jpg$"
|
||||
)
|
||||
|
||||
|
||||
def _parse_snapshot(path, category):
|
||||
m = _SNAP_RE.match(path.name)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.strptime(m.group("ts"), "%Y%m%d_%H%M%S_%f")
|
||||
except ValueError:
|
||||
dt = datetime.fromtimestamp(path.stat().st_mtime)
|
||||
kind = m.group("kind")
|
||||
return {
|
||||
"file": f"{category}/{path.name}",
|
||||
"category": category,
|
||||
"kind": kind,
|
||||
"track_id": int(m.group("tid")),
|
||||
"frame": int(m.group("frame")),
|
||||
"timestamp": dt.isoformat(),
|
||||
"mtime": path.stat().st_mtime,
|
||||
}
|
||||
|
||||
|
||||
def _collect_snapshots():
|
||||
base = os.path.abspath(CROSS_SNAPSHOT_DIR)
|
||||
items = []
|
||||
for category in ("cross", "detect"):
|
||||
sub = os.path.join(base, category)
|
||||
if not os.path.isdir(sub):
|
||||
continue
|
||||
for name in os.listdir(sub):
|
||||
if not name.lower().endswith(".jpg"):
|
||||
continue
|
||||
info = _parse_snapshot(Path(sub) / name, category)
|
||||
if info:
|
||||
items.append(info)
|
||||
items.sort(key=lambda x: x["mtime"], reverse=True)
|
||||
return items
|
||||
|
||||
|
||||
@app.route("/api/snapshots")
|
||||
def api_snapshots():
|
||||
try:
|
||||
kind = request.args.get("kind", "all")
|
||||
track_id = request.args.get("track_id", type=int)
|
||||
date = request.args.get("date")
|
||||
limit = request.args.get("limit", 200, type=int)
|
||||
|
||||
items = _collect_snapshots()
|
||||
if kind and kind != "all":
|
||||
if kind == "cross":
|
||||
items = [i for i in items if i["category"] == "cross"]
|
||||
elif kind == "detect":
|
||||
items = [i for i in items if i["category"] == "detect"]
|
||||
elif kind in ("in", "out"):
|
||||
items = [i for i in items if i["kind"] == kind]
|
||||
if track_id is not None:
|
||||
items = [i for i in items if i["track_id"] == track_id]
|
||||
if date:
|
||||
items = [i for i in items if i["timestamp"][:10] == date]
|
||||
|
||||
total = len(items)
|
||||
items = items[:limit]
|
||||
for i in items:
|
||||
i.pop("mtime", None)
|
||||
return jsonify({"success": True, "total": total, "count": len(items), "snapshots": items})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e), "snapshots": []}), 200
|
||||
|
||||
|
||||
@app.route("/api/snapshot-image/<category>/<path:filename>")
|
||||
def api_snapshot_image(category, filename):
|
||||
if category not in ("cross", "detect"):
|
||||
return jsonify({"success": False, "error": "invalid category"}), 404
|
||||
base = os.path.abspath(os.path.join(CROSS_SNAPSHOT_DIR, category))
|
||||
requested = os.path.abspath(os.path.join(base, filename))
|
||||
if not requested.startswith(base + os.sep) or not os.path.isfile(requested):
|
||||
return jsonify({"success": False, "error": "not found"}), 404
|
||||
return send_file(requested, mimetype="image/jpeg")
|
||||
|
||||
|
||||
def _ensure_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
@@ -113,7 +201,7 @@ def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME):
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("dashboard.html", site_name=SITE_NAME)
|
||||
return render_template("dashboard.html", site_name=SITE_NAME, show_detect=SAVE_DETECT_SNAPSHOT)
|
||||
|
||||
|
||||
@app.route("/api/current-counter")
|
||||
|
||||
@@ -72,6 +72,20 @@ DAILY_CUTOFF_TIME = os.getenv("DAILY_CUTOFF_TIME", "20:00")
|
||||
EXPORT_CSV = os.getenv("EXPORT_CSV", "true").lower() == "true"
|
||||
CROSS_CSV = os.getenv("CROSS_CSV", f"{OUTPUT_DIR}/crossings.csv")
|
||||
|
||||
# Save an annotated frame snapshot each time an object crosses a line and the
|
||||
# counter increases.
|
||||
SAVE_CROSS_SNAPSHOT = os.getenv("SAVE_CROSS_SNAPSHOT", "false").lower() == "true"
|
||||
# Also save one snapshot the first time each object is detected (before it crosses),
|
||||
# named with the same track id so it can be correlated with the crossing snapshot.
|
||||
SAVE_DETECT_SNAPSHOT = os.getenv("SAVE_DETECT_SNAPSHOT", "false").lower() == "true"
|
||||
CROSS_SNAPSHOT_DIR = os.getenv("CROSS_SNAPSHOT_DIR", f"{OUTPUT_DIR}/snapshots")
|
||||
CROSS_SNAPSHOT_QUALITY = int(os.getenv("CROSS_SNAPSHOT_QUALITY", "85"))
|
||||
# Retention: delete oldest snapshots when either limit is exceeded (0 = disabled).
|
||||
CROSS_SNAPSHOT_MAX_FILES = int(os.getenv("CROSS_SNAPSHOT_MAX_FILES", "1000"))
|
||||
CROSS_SNAPSHOT_MAX_AGE_DAYS = float(os.getenv("CROSS_SNAPSHOT_MAX_AGE_DAYS", "7"))
|
||||
# Run the cleanup sweep at most every N seconds to limit filesystem scans.
|
||||
CROSS_SNAPSHOT_CLEANUP_SEC = int(os.getenv("CROSS_SNAPSHOT_CLEANUP_SEC", "60"))
|
||||
|
||||
RATE_WINDOW_SEC = int(os.getenv("RATE_WINDOW_SEC", "60"))
|
||||
WARMUP_FRAMES = int(os.getenv("WARMUP_FRAMES", "30"))
|
||||
RECONNECT_DELAY_SEC = int(os.getenv("RECONNECT_DELAY_SEC", "3"))
|
||||
@@ -756,6 +770,23 @@ def prune_stale_tracks(tracked, now_mono):
|
||||
del tracked[tid]
|
||||
|
||||
|
||||
def cleanup_snapshots(snapshot_dir, max_files, max_age_days):
|
||||
"""Delete oldest / expired crossing snapshots to bound disk usage."""
|
||||
d = Path(snapshot_dir)
|
||||
if not d.is_dir():
|
||||
return
|
||||
files = sorted(d.rglob("*.jpg"), key=lambda p: p.stat().st_mtime)
|
||||
if max_age_days > 0:
|
||||
cutoff = time.time() - max_age_days * 86400
|
||||
for p in list(files):
|
||||
if p.stat().st_mtime < cutoff:
|
||||
p.unlink(missing_ok=True)
|
||||
files.remove(p)
|
||||
if max_files > 0 and len(files) > max_files:
|
||||
for p in files[: len(files) - max_files]:
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def overlay_rect(img, x1, y1, x2, y2, color, alpha=0.65):
|
||||
x1, y1 = max(0, x1), max(0, y1)
|
||||
x2, y2 = min(img.shape[1], x2), min(img.shape[0], y2)
|
||||
@@ -1036,6 +1067,8 @@ def run():
|
||||
recent_cross_in = deque()
|
||||
recent_cross_out = deque()
|
||||
|
||||
detect_snapshot_ids = set()
|
||||
|
||||
object_cross_flash1 = {}
|
||||
object_cross_flash2 = {}
|
||||
line_pulse = count_in_pulse = count_out_pulse = 0
|
||||
@@ -1049,6 +1082,7 @@ def run():
|
||||
crossing_times = deque()
|
||||
counter_in = 0
|
||||
counter_out = 0
|
||||
last_snapshot_cleanup = 0.0
|
||||
|
||||
cap, w, h, fps = connect_stream(SOURCE)
|
||||
if cap is None:
|
||||
@@ -1096,6 +1130,8 @@ def run():
|
||||
elapsed = now - session_start
|
||||
mono = time.monotonic()
|
||||
object_crossed_frame = False
|
||||
cross_events_frame = []
|
||||
detect_events_frame = []
|
||||
|
||||
skip_inference = False
|
||||
if MOTION_DETECTION_ENABLED:
|
||||
@@ -1179,6 +1215,10 @@ def run():
|
||||
cx = object_cx_list[di]
|
||||
cy = object_cy_list[di]
|
||||
|
||||
if tid not in detect_snapshot_ids:
|
||||
detect_snapshot_ids.add(tid)
|
||||
detect_events_frame.append(tid)
|
||||
|
||||
if tid not in object_tracked:
|
||||
inherited = _inherit_prev(
|
||||
object_tracked, tid, cx, cy, mono, INHERIT_SEC, INHERIT_PX
|
||||
@@ -1243,6 +1283,7 @@ def run():
|
||||
]
|
||||
)
|
||||
object_crossed_frame = True
|
||||
cross_events_frame.append((tid, direction))
|
||||
crossing_times.append(mono)
|
||||
object_cross_flash1[tid] = CROSS_FLASH_FRAMES
|
||||
object_cross_flash2[tid] = CROSS_FLASH_FRAMES
|
||||
@@ -1334,6 +1375,41 @@ def run():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if (SAVE_DETECT_SNAPSHOT and detect_events_frame) or (
|
||||
SAVE_CROSS_SNAPSHOT and cross_events_frame
|
||||
):
|
||||
try:
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
|
||||
if SAVE_DETECT_SNAPSHOT and detect_events_frame:
|
||||
detect_dir = Path(CROSS_SNAPSHOT_DIR) / "detect"
|
||||
detect_dir.mkdir(parents=True, exist_ok=True)
|
||||
for tid in detect_events_frame:
|
||||
fname = f"{ts}_detect_id{tid}_f{frame_idx}.jpg"
|
||||
cv2.imwrite(
|
||||
str(detect_dir / fname),
|
||||
frame,
|
||||
[cv2.IMWRITE_JPEG_QUALITY, CROSS_SNAPSHOT_QUALITY],
|
||||
)
|
||||
if SAVE_CROSS_SNAPSHOT and cross_events_frame:
|
||||
cross_dir = Path(CROSS_SNAPSHOT_DIR) / "cross"
|
||||
cross_dir.mkdir(parents=True, exist_ok=True)
|
||||
for tid, direction in cross_events_frame:
|
||||
fname = f"{ts}_{direction}_id{tid}_f{frame_idx}.jpg"
|
||||
cv2.imwrite(
|
||||
str(cross_dir / fname),
|
||||
frame,
|
||||
[cv2.IMWRITE_JPEG_QUALITY, CROSS_SNAPSHOT_QUALITY],
|
||||
)
|
||||
if now - last_snapshot_cleanup >= CROSS_SNAPSHOT_CLEANUP_SEC:
|
||||
cleanup_snapshots(
|
||||
CROSS_SNAPSHOT_DIR,
|
||||
CROSS_SNAPSHOT_MAX_FILES,
|
||||
CROSS_SNAPSHOT_MAX_AGE_DAYS,
|
||||
)
|
||||
last_snapshot_cleanup = now
|
||||
except Exception as exc:
|
||||
print(f"[{now_str()}] Failed to save snapshot: {exc}")
|
||||
|
||||
frame_idx += 1
|
||||
prune_stale_tracks(object_tracked, mono)
|
||||
|
||||
|
||||
@@ -141,3 +141,6 @@ DASHBOARD_PORT=5000
|
||||
FLASK_DEBUG=false
|
||||
# Fallback name for the active counting-day JSON state file used by the dashboard
|
||||
CURRENT_COUNTER_PATH=/tmp/bytetrack_current_counter.json
|
||||
|
||||
SAVE_CROSS_SNAPSHOT=true
|
||||
CROSS_SNAPSHOT_DIR=/opt/zenai-kpc-snaps/snapshots
|
||||
+166
-2
@@ -404,7 +404,56 @@
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 20px; flex-wrap: wrap; gap: 12px;
|
||||
}
|
||||
.table-toolbar .toolbar-group { display: flex; align-items: center; gap: 8px; }
|
||||
.table-toolbar .toolbar-group { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* Snapshot gallery */
|
||||
.snapshot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.snap-card {
|
||||
background: var(--cell-bg);
|
||||
border: 1px solid var(--cell-border); border-radius: 12px;
|
||||
overflow: hidden; cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.15s;
|
||||
}
|
||||
.snap-card:hover { border-color: var(--accent); transform: translateY(-2px); }
|
||||
.snap-thumb {
|
||||
width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block;
|
||||
background: #000;
|
||||
}
|
||||
.snap-info { padding: 8px 10px; }
|
||||
.snap-info .si-top {
|
||||
display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;
|
||||
}
|
||||
.snap-kind {
|
||||
font-size: 8px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
|
||||
padding: 2px 8px; border-radius: 8px;
|
||||
}
|
||||
.snap-kind.in { background: rgba(0,255,136,0.12); color: var(--accent4); }
|
||||
.snap-kind.out { background: rgba(255,45,120,0.12); color: var(--accent3); }
|
||||
.snap-kind.detect { background: rgba(0,240,255,0.10); color: var(--accent); }
|
||||
.snap-tid {
|
||||
font-family: 'Orbitron', sans-serif; font-size: 11px; font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.snap-time { font-size: 9px; color: var(--text-secondary); }
|
||||
|
||||
.snap-lightbox {
|
||||
position: relative;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
||||
padding: 14px; max-width: 92vw; max-height: 90vh;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
}
|
||||
.snap-lightbox img {
|
||||
max-width: 88vw; max-height: 78vh; border-radius: 10px; display: block;
|
||||
}
|
||||
.snap-modal-meta {
|
||||
margin-top: 10px; font-size: 11px; color: var(--text-secondary); letter-spacing: 1px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-toolbar input[type="date"] {
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
||||
@@ -769,9 +818,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Snapshots -->
|
||||
<div class="table-panel">
|
||||
<div class="table-toolbar">
|
||||
<div>
|
||||
<span class="section-title" style="margin-bottom:0;">◉ Crossing Snapshots</span>
|
||||
<div style="font-size:10px;color:var(--text-secondary);letter-spacing:1px;">Detected & counted frames — correlate by track ID</div>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<div class="chart-filters" id="snap-filters">
|
||||
<button class="active" data-kind="all" onclick="loadSnapshots('all')">ALL</button>
|
||||
<button data-kind="in" onclick="loadSnapshots('in')">IN</button>
|
||||
<button data-kind="out" onclick="loadSnapshots('out')">OUT</button>
|
||||
{% if show_detect %}<button data-kind="detect" onclick="loadSnapshots('detect')">DETECT</button>{% endif %}
|
||||
</div>
|
||||
<input type="date" id="snap-date-filter">
|
||||
<button class="btn btn-primary" onclick="applySnapFilter()">FILTER</button>
|
||||
<button class="btn btn-ghost" onclick="resetSnapFilter()">RESET</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="snapshot-grid" class="snapshot-grid">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<div id="snapshot-empty" style="display:none;text-align:center;padding:32px;color:var(--text-secondary);">
|
||||
<div style="font-size:32px;opacity:0.2;margin-bottom:8px;">▣</div>
|
||||
<div style="font-size:10px;letter-spacing:1px;">NO SNAPSHOTS AVAILABLE</div>
|
||||
<div style="font-size:9px;margin-top:6px;opacity:0.7;">Enable SAVE_CROSS_SNAPSHOT{% if show_detect %} / SAVE_DETECT_SNAPSHOT{% endif %} in counter .env</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">Edge Nano Counter by ZenAi</div>
|
||||
</div>
|
||||
|
||||
<!-- Snapshot lightbox -->
|
||||
<div class="modal-overlay hidden" id="snap-modal" onclick="closeSnapModal(event)">
|
||||
<div class="snap-lightbox" id="snap-lightbox">
|
||||
<button class="close-btn" onclick="closeSnapModal()" style="position:absolute;top:12px;right:12px;z-index:2;">×</button>
|
||||
<img id="snap-modal-img" alt="Snapshot" />
|
||||
<div class="snap-modal-meta" id="snap-modal-meta">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// --- Theme ---
|
||||
(function() {
|
||||
@@ -806,6 +893,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadChartData(7);
|
||||
loadRecentDays();
|
||||
resetFilter();
|
||||
loadSnapshots('all');
|
||||
|
||||
setInterval(() => {
|
||||
loadSummary();
|
||||
@@ -815,6 +903,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
setInterval(() => {
|
||||
loadCurrentCounter();
|
||||
}, 2000);
|
||||
|
||||
setInterval(() => {
|
||||
if (snapCurrentKind && !snapDateFilter) loadSnapshots(snapCurrentKind, true);
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
async function loadCurrentCounter() {
|
||||
@@ -1064,9 +1156,81 @@ function formatTime(isoString) {
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') hideVideoError();
|
||||
if (e.key === 'Escape') { hideVideoError(); closeSnapModal(); }
|
||||
});
|
||||
|
||||
// --- Crossing Snapshots ---
|
||||
let snapCurrentKind = 'all';
|
||||
let snapDateFilter = '';
|
||||
|
||||
async function loadSnapshots(kind, silent) {
|
||||
if (kind !== undefined) snapCurrentKind = kind;
|
||||
document.querySelectorAll('#snap-filters button').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.kind === snapCurrentKind);
|
||||
});
|
||||
|
||||
try {
|
||||
let url = `/api/snapshots?kind=${snapCurrentKind}&limit=120`;
|
||||
if (snapDateFilter) url += `&date=${snapDateFilter}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
const grid = document.getElementById('snapshot-grid');
|
||||
const empty = document.getElementById('snapshot-empty');
|
||||
const items = (data && data.snapshots) || [];
|
||||
|
||||
if (items.length === 0) {
|
||||
grid.innerHTML = '';
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
grid.innerHTML = items.map(s => {
|
||||
const url = `/api/snapshot-image/${s.file}`;
|
||||
const t = new Date(s.timestamp);
|
||||
const timeStr = t.toLocaleString('en-US', { hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const kindLabel = s.kind.toUpperCase();
|
||||
return `
|
||||
<div class="snap-card" onclick="openSnapModal('${url}','${kindLabel}',${s.track_id},'${timeStr}',${s.frame})">
|
||||
<img class="snap-thumb" src="${url}" loading="lazy" alt="snapshot" />
|
||||
<div class="snap-info">
|
||||
<div class="si-top">
|
||||
<span class="snap-kind ${s.kind}">${kindLabel}</span>
|
||||
<span class="snap-tid">ID ${s.track_id}</span>
|
||||
</div>
|
||||
<div class="snap-time">${timeStr}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
if (!silent) console.error('Failed to load snapshots:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function applySnapFilter() {
|
||||
snapDateFilter = document.getElementById('snap-date-filter').value || '';
|
||||
loadSnapshots(snapCurrentKind);
|
||||
}
|
||||
|
||||
function resetSnapFilter() {
|
||||
snapDateFilter = '';
|
||||
document.getElementById('snap-date-filter').value = '';
|
||||
snapCurrentKind = 'all';
|
||||
loadSnapshots('all');
|
||||
}
|
||||
|
||||
function openSnapModal(url, kind, tid, timeStr, frame) {
|
||||
document.getElementById('snap-modal-img').src = url;
|
||||
document.getElementById('snap-modal-meta').textContent =
|
||||
`${kind} \u00b7 Track ID ${tid} \u00b7 Frame ${frame} \u00b7 ${timeStr}`;
|
||||
document.getElementById('snap-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeSnapModal(event) {
|
||||
if (event && event.target && event.target.id !== 'snap-modal' && event.type === 'click') return;
|
||||
document.getElementById('snap-modal').classList.add('hidden');
|
||||
document.getElementById('snap-modal-img').src = '';
|
||||
}
|
||||
|
||||
// --- Live Video Feed ---
|
||||
|
||||
function toggleVideo() {
|
||||
|
||||
Reference in new issue
Block a user