Add Snapshot

This commit is contained in:
proitlab committed 2026-07-10 00:17:17 +07:00
1 parent 3b8fb0c312
commit 210df4a038
5 files changed
+359 -4

No files matched your search

+24
View File
@@ -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
View File
@@ -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")
+76
View File
@@ -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)
+3
View File
@@ -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
View File
@@ -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;">&#9673; 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;">&#9635;</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;">&times;</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() {