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 @@ + +
+
+
+ ◉ Crossing Snapshots +
Detected & counted frames — correlate by track ID
+
+
+
+ + + + {% if show_detect %}{% endif %} +
+ + + +
+
+
+ +
+ +
+ + + +