diff --git a/config.env.example b/config.env.example
index f10424d..1d1a19c 100644
--- a/config.env.example
+++ b/config.env.example
@@ -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
/cross/
+# (filename: __id_f .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 /detect/ with the same track id so
+# it can be correlated with the crossing snapshot
+# (filename: _detect_id_f .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
diff --git a/counter_dashboard.py b/counter_dashboard.py
index c8d98c6..3e6cb0c 100644
--- a/counter_dashboard.py
+++ b/counter_dashboard.py
@@ -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\d{8}_\d{6}_\d{3})_(?Pdetect|in|out)_id(?P\d+)_f(?P \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//")
+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")
diff --git a/counter_live_rknn.py b/counter_live_rknn.py
index 2b77cc0..69c0e44 100644
--- a/counter_live_rknn.py
+++ b/counter_live_rknn.py
@@ -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)
diff --git a/env.example b/env.example
index 84b6b41..b856f5f 100644
--- a/env.example
+++ b/env.example
@@ -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
diff --git a/templates/dashboard.html b/templates/dashboard.html
index f06c7af..e3611cf 100644
--- a/templates/dashboard.html
+++ b/templates/dashboard.html
@@ -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 @@
+
+
+
+
+
+
+
+
▣
+
NO SNAPSHOTS AVAILABLE
+
Enable SAVE_CROSS_SNAPSHOT{% if show_detect %} / SAVE_DETECT_SNAPSHOT{% endif %} in counter .env
+
+
+
+
+
+
+
×
+
+
--
+
+
+