Compare commits

...
8 Commits
Author SHA1 Message Date
proitlab 657f4f80d9 TRACK_MATCH_THRESH 2026-07-15 10:46:06 +07:00
proitlab 8f7ecb4321 Add Control mechanism 2026-07-11 17:19:14 +07:00
proitlab 6afffb2184 Fix blnak live stream 2026-07-11 10:36:40 +07:00
proitlab d93ee99c1f NMS_IOU 2026-07-11 10:29:15 +07:00
proitlab 85dbbdd111 Disable DEDUP 2026-07-10 06:37:54 +07:00
proitlab ff87201a76 Motion detect fraction small area of frame 2026-07-10 06:28:53 +07:00
proitlab 9d9d5f903c Use 1 Core NPU 2026-07-10 06:09:23 +07:00
proitlab 660b9abef4 Tidur 2026-07-10 00:45:53 +07:00
8 changed files with 1061 additions and 193 deletions

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
# Runtime Control — Start/Stop Counting
The counter can pause and resume object detection & counting **on the fly**,
without restarting the process. There are three interchangeable ways to control
it, and they all converge on a single source of truth: the **control file**.
- **Control file** — a small JSON file the counter polls.
- **TCP control socket** — line commands over the network that update the file.
- **Dashboard button** — a COUNTING ON/OFF toggle that writes the file via its API.
When counting is **OFF**, the counter skips inference entirely (no detection, no
counting, lower CPU/NPU load), the video/live stream keeps running, and a
`COUNTING PAUSED` badge is drawn on the frame. When **ON**, normal counting
resumes.
---
## 1. Enable runtime control
Runtime control is opt-in. In your `.env`:
```ini
# Master switch — required for ALL control methods (file, socket, dashboard).
CONTROL_ENABLED=true
# Shared control file. MUST be identical for the counter and the dashboard.
CONTROL_FILE=/opt/bytetrack-counter/control.json
# Counting state on startup / when the control file does not exist yet.
CONTROL_DEFAULT_COUNTING=true
# How often (seconds) the counter re-reads the control file.
CONTROL_POLL_SEC=1.0
```
When `CONTROL_ENABLED=false`, the counter always counts, the control file is
ignored, and the dashboard hides the toggle button.
> Changes take effect within `CONTROL_POLL_SEC` seconds (default 1s), because the
> counter re-reads the file on a timer.
---
## 2. Control file
### Format
```json
{ "counting": true }
```
- `"counting": true` → counting **ON**
- `"counting": false` → counting **OFF** (paused)
The counter creates this file on startup (seeded from `CONTROL_DEFAULT_COUNTING`)
if it does not exist. All writers (counter, dashboard, socket) write it
**atomically** (temp file + rename), so readers never see a half-written file.
### Toggle by editing the file
Pause counting:
```bash
printf '{"counting": false}\n' > /opt/bytetrack-counter/control.json
```
Resume counting:
```bash
printf '{"counting": true}\n' > /opt/bytetrack-counter/control.json
```
Check current state:
```bash
cat /opt/bytetrack-counter/control.json
```
> Use the exact path from your `CONTROL_FILE` setting. If you write it by hand,
> keep it valid JSON — an unreadable file falls back to `CONTROL_DEFAULT_COUNTING`.
---
## 3. TCP control socket
The socket lets you toggle counting over the network. It updates the same control
file, so changes still apply within `CONTROL_POLL_SEC`.
### Enable
```ini
# Requires CONTROL_ENABLED=true as well.
CONTROL_SOCKET_ENABLED=true
# 127.0.0.1 = local only. Use 0.0.0.0 to allow remote clients.
CONTROL_SOCKET_HOST=127.0.0.1
# TCP port.
CONTROL_SOCKET_PORT=5090
```
### Commands
Newline-terminated, case-insensitive. One connection can send multiple commands.
| Command | Effect | Reply |
|----------------------------|-------------------------------|-----------------------|
| `START` / `RESUME` / `ON` | Counting ON | `OK counting=on` |
| `STOP` / `PAUSE` / `OFF` | Counting OFF | `OK counting=off` |
| `TOGGLE` | Flip current state | `OK counting=on/off` |
| `STATUS` / `GET` | Report state (no change) | `OK counting=on/off` |
| *(anything else)* | — | `ERR unknown command` |
### Examples
Using `nc` (netcat):
```bash
printf 'STOP\n' | nc 127.0.0.1 5090
printf 'START\n' | nc 127.0.0.1 5090
printf 'TOGGLE\n' | nc 127.0.0.1 5090
printf 'STATUS\n' | nc 127.0.0.1 5090
```
Using bash `/dev/tcp` (no netcat needed):
```bash
exec 3<>/dev/tcp/127.0.0.1/5090
printf 'STATUS\n' >&3
head -n1 <&3
exec 3>&-
```
Python client:
```python
import socket
def control(cmd, host="127.0.0.1", port=5090):
with socket.create_connection((host, port), timeout=2) as s:
s.sendall((cmd + "\n").encode())
return s.recv(256).decode().strip()
print(control("STATUS")) # OK counting=on
print(control("STOP")) # OK counting=off
```
> **Security:** the socket has **no authentication**. Keep `CONTROL_SOCKET_HOST`
> on `127.0.0.1`, or restrict access with a firewall / trusted network if you
> bind to `0.0.0.0`.
---
## 4. Dashboard button
When `CONTROL_ENABLED=true`, the dashboard header shows a **COUNTING ON/OFF**
button (green when on, red when off). Clicking it flips the state immediately.
The dashboard must point at the **same** `CONTROL_FILE` as the counter (set it in
the dashboard's environment too). The dashboard exposes:
- `GET /api/control` → `{ "enabled": true, "counting": true }`
- `POST /api/control` with body `{ "counting": false }` → writes the control file
(returns `403` if `CONTROL_ENABLED=false`)
---
## Notes & behavior
- **Single source of truth:** the socket and dashboard both write the control
file; the counter reacts only to the file. This avoids race conditions between
control methods.
- **Latency:** expect up to `CONTROL_POLL_SEC` (default 1s) between issuing a
command and the counter reacting.
- **Live stream keeps running** while paused, so you still see the camera feed
with the `COUNTING PAUSED` overlay.
- **Shared path requirement:** counter and dashboard must use the same
`CONTROL_FILE`. If they run on different machines, use the TCP socket (or a
shared network path) instead.
+44 -4
View File
@@ -39,6 +39,11 @@ SCORE_SIGMOID=false
# --- Detection --- # --- Detection ---
# Confidence threshold – detections below this are discarded before NMS # Confidence threshold – detections below this are discarded before NMS
CONF=0.3 CONF=0.3
# NMS IoU threshold (0-1): boxes overlapping the top box by more than this are
# suppressed. RAISE it (e.g. 0.6-0.7) if two close/overlapping objects are being
# merged into one and their separate boxes get suppressed. Lower = more aggressive
# merging. Default 0.45.
NMS_IOU=0.45
# --- ByteTrack tracking --- # --- ByteTrack tracking ---
# Detections with score >= this get priority matching in the first association stage # Detections with score >= this get priority matching in the first association stage
@@ -138,12 +143,47 @@ CROSS_SNAPSHOT_CLEANUP_SEC=60
# --- Rate / performance --- # --- Rate / performance ---
# Enable motion detection pre-filter: skip inference on frames with no movement # Enable motion detection pre-filter: skip inference on frames with no movement
# (true/false, default: false). When enabled, frames below MOTION_THRESHOLD are # (true/false, default: false), saving NPU/CPU load. Motion is measured by the
# skipped, saving NPU/CPU load. # fraction of pixels that changed (localized-motion aware), NOT the whole-frame
# average, so an object entering the edge of the frame is detected immediately.
MOTION_DETECTION_ENABLED=false MOTION_DETECTION_ENABLED=false
# Mean absolute pixel difference threshold (0–255) to consider a frame as having # Per-pixel intensity change (0-255) for a pixel to count as "moved". Lower = more
# motion. Lower = more sensitive. Default 5.0. # sensitive to subtle movement. Default 25.
MOTION_PIXEL_DELTA=25
# Fraction of frame pixels (0-1) that must change to trigger inference. Lower =
# more sensitive / detects smaller or farther objects sooner. Default 0.002 (0.2%).
MOTION_MIN_AREA_FRAC=0.002
# Heartbeat: always run inference at least every N frames even with no detected
# motion, so a slow or barely-moving object is never missed for long. Default 15.
MOTION_HEARTBEAT_FRAMES=15
# (Deprecated) old whole-frame mean-difference threshold; no longer used.
MOTION_THRESHOLD=5.0 MOTION_THRESHOLD=5.0
# --- Runtime control (start/stop counting on the fly) ---
# When true, the counter watches a JSON control file and pauses/resumes object
# detection & counting based on its "counting" flag. The dashboard shows a
# COUNTING ON/OFF toggle button that writes this file. When false, the counter
# always counts and the dashboard hides the toggle. (true/false, default: false)
CONTROL_ENABLED=false
# Path to the shared control file. MUST be identical for the counter and the
# dashboard so the toggle takes effect. Contents: {"counting": true|false}
CONTROL_FILE=/opt/bytetrack-counter/control.json
# Counting state to assume on startup / when the control file does not exist yet.
CONTROL_DEFAULT_COUNTING=true
# How often (seconds) the counter re-reads the control file. Default 1.0.
CONTROL_POLL_SEC=1.0
# Optional TCP control socket (requires CONTROL_ENABLED=true). Lets you toggle
# counting over the network with line commands. It updates the same control file,
# so changes apply within CONTROL_POLL_SEC. (true/false, default: false)
# Commands (newline-terminated): START|RESUME|ON, STOP|PAUSE|OFF, TOGGLE, STATUS
# e.g. printf 'STOP\n' | nc 127.0.0.1 5090
CONTROL_SOCKET_ENABLED=false
# Bind address for the control socket. Use 127.0.0.1 for local-only, 0.0.0.0 to
# allow remote clients (no auth — protect with firewall / trusted network).
CONTROL_SOCKET_HOST=127.0.0.1
# Control socket TCP port.
CONTROL_SOCKET_PORT=5090
# Sliding window in seconds for computing the crossing rate (objects/minute) # Sliding window in seconds for computing the crossing rate (objects/minute)
RATE_WINDOW_SEC=60 RATE_WINDOW_SEC=60
# Number of frames to discard at startup to let the stream buffer stabilise # Number of frames to discard at startup to let the stream buffer stabilise
+67 -4
View File
@@ -38,6 +38,10 @@ LIVE_STREAM_FRAME_PATH = os.getenv("LIVE_STREAM_FRAME_PATH", "/dev/shm/jetson-co
CROSS_SNAPSHOT_DIR = os.getenv("CROSS_SNAPSHOT_DIR", f"{_DEFAULT_DIR}/snapshots") CROSS_SNAPSHOT_DIR = os.getenv("CROSS_SNAPSHOT_DIR", f"{_DEFAULT_DIR}/snapshots")
SAVE_DETECT_SNAPSHOT = os.getenv("SAVE_DETECT_SNAPSHOT", "false").lower() == "true" SAVE_DETECT_SNAPSHOT = os.getenv("SAVE_DETECT_SNAPSHOT", "false").lower() == "true"
CONTROL_ENABLED = os.getenv("CONTROL_ENABLED", "false").lower() == "true"
CONTROL_FILE = os.getenv("CONTROL_FILE", f"{_DEFAULT_DIR}/control.json")
CONTROL_DEFAULT_COUNTING = os.getenv("CONTROL_DEFAULT_COUNTING", "true").lower() == "true"
SITE_NAME = os.getenv("SITE_NAME", "LIVE") SITE_NAME = os.getenv("SITE_NAME", "LIVE")
DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000")) DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000"))
@@ -56,6 +60,12 @@ def api_live_video():
try: try:
with open(LIVE_STREAM_FRAME_PATH, "rb") as f: with open(LIVE_STREAM_FRAME_PATH, "rb") as f:
jpeg = f.read() jpeg = f.read()
if not jpeg or len(jpeg) < 2 or jpeg[:2] != b"\xff\xd8":
consecutive_fails += 1
if consecutive_fails >= MAX_FAILS:
return
time.sleep(0.05)
continue
consecutive_fails = 0 consecutive_fails = 0
yield (b"--frame\r\n" yield (b"--frame\r\n"
b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n") b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n")
@@ -201,7 +211,59 @@ def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME):
@app.route("/") @app.route("/")
def index(): def index():
return render_template("dashboard.html", site_name=SITE_NAME, show_detect=SAVE_DETECT_SNAPSHOT) return render_template(
"dashboard.html",
site_name=SITE_NAME,
show_detect=SAVE_DETECT_SNAPSHOT,
control_enabled=CONTROL_ENABLED,
)
def _read_counting_flag():
try:
with open(CONTROL_FILE, "r", encoding="utf-8") as f:
return bool(json.load(f).get("counting", CONTROL_DEFAULT_COUNTING))
except FileNotFoundError:
return CONTROL_DEFAULT_COUNTING
except Exception:
return CONTROL_DEFAULT_COUNTING
def _write_counting_flag(counting):
os.makedirs(os.path.dirname(CONTROL_FILE) or ".", exist_ok=True)
tmp = f"{CONTROL_FILE}.tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump({"counting": bool(counting)}, f)
os.replace(tmp, CONTROL_FILE)
@app.route("/api/control", methods=["GET"])
def api_control_get():
return jsonify({
"success": True,
"enabled": CONTROL_ENABLED,
"counting": _read_counting_flag(),
})
@app.route("/api/control", methods=["POST"])
def api_control_set():
if not CONTROL_ENABLED:
return jsonify({"success": False, "error": "Runtime control is disabled (set CONTROL_ENABLED=true)"}), 403
data = request.get_json(silent=True) or {}
if "counting" not in data:
return jsonify({"success": False, "error": "Missing 'counting' field"}), 400
try:
counting = bool(data["counting"])
_write_counting_flag(counting)
return jsonify({"success": True, "counting": counting})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/snapshots")
def snapshots_page():
return render_template("snapshots.html", site_name=SITE_NAME, show_detect=SAVE_DETECT_SNAPSHOT)
@app.route("/api/current-counter") @app.route("/api/current-counter")
@@ -455,15 +517,16 @@ def export_daily_xlsx():
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
ws.title = "Daily Counters" ws.title = "Daily Counters"
_style_header(ws, [("A", "Date"), ("B", "Total"), ("C", "In"), ("D", "Out"), ("E", "First Count"), ("F", "Last Count")]) _style_header(ws, [("A", "Date"), ("B", "Total"), ("C", "In"), ("D", "Out"), ("E", "Diff (In-Out)"), ("F", "First Count"), ("G", "Last Count")])
for r_idx, row in enumerate(rows, 2): for r_idx, row in enumerate(rows, 2):
ws.cell(row=r_idx, column=1, value=row["counting_date"]) ws.cell(row=r_idx, column=1, value=row["counting_date"])
ws.cell(row=r_idx, column=2, value=row["total_count"]) ws.cell(row=r_idx, column=2, value=row["total_count"])
ws.cell(row=r_idx, column=3, value=row["total_in"]) ws.cell(row=r_idx, column=3, value=row["total_in"])
ws.cell(row=r_idx, column=4, value=row["total_out"]) ws.cell(row=r_idx, column=4, value=row["total_out"])
ws.cell(row=r_idx, column=5, value=row["start_time"]) ws.cell(row=r_idx, column=5, value=(row["total_in"] or 0) - (row["total_out"] or 0))
ws.cell(row=r_idx, column=6, value=row["end_time"]) ws.cell(row=r_idx, column=6, value=row["start_time"])
ws.cell(row=r_idx, column=7, value=row["end_time"])
_auto_width(ws) _auto_width(ws)
filename = f"{SITE_NAME}_daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" filename = f"{SITE_NAME}_daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
+194 -5
View File
@@ -7,8 +7,11 @@ Uses ByteTrack (Kalman filter + two-stage IoU association) for tracking.
import numpy as np import numpy as np
import cv2 import cv2
import csv import csv
import json
import os import os
import signal import signal
import socket
import threading
import time import time
from collections import deque from collections import deque
from datetime import datetime from datetime import datetime
@@ -39,6 +42,7 @@ LINE_Y2_FRAC = float(os.getenv("LINE_Y2_FRAC", "0.66"))
IMGSZ = int(os.getenv("IMGSZ", "320")) IMGSZ = int(os.getenv("IMGSZ", "320"))
HALF = os.getenv("HALF", "false").lower() == "true" HALF = os.getenv("HALF", "false").lower() == "true"
CONF = float(os.getenv("CONF", "0.3")) CONF = float(os.getenv("CONF", "0.3"))
NMS_IOU = float(os.getenv("NMS_IOU", "0.45"))
DEVICE = int(os.getenv("DEVICE", "0")) DEVICE = int(os.getenv("DEVICE", "0"))
# RKNN NPU core mask # RKNN NPU core mask
@@ -111,6 +115,29 @@ IS_LIVE = SOURCE.lower().startswith(("rtsp://", "http://"))
MOTION_DETECTION_ENABLED = os.getenv("MOTION_DETECTION_ENABLED", "false").lower() == "true" MOTION_DETECTION_ENABLED = os.getenv("MOTION_DETECTION_ENABLED", "false").lower() == "true"
MOTION_THRESHOLD = float(os.getenv("MOTION_THRESHOLD", "5.0")) MOTION_THRESHOLD = float(os.getenv("MOTION_THRESHOLD", "5.0"))
# Per-pixel intensity change (0-255) for a pixel to count as "moved".
MOTION_PIXEL_DELTA = int(os.getenv("MOTION_PIXEL_DELTA", "25"))
# Fraction of frame pixels that must change (0-1) to trigger inference. Small,
# so an object entering the edge of the frame is detected immediately.
MOTION_MIN_AREA_FRAC = float(os.getenv("MOTION_MIN_AREA_FRAC", "0.002"))
# Always run inference at least every N frames even if no motion (heartbeat), so a
# slow/stationary object is never missed for long.
MOTION_HEARTBEAT_FRAMES = int(os.getenv("MOTION_HEARTBEAT_FRAMES", "15"))
# --- Runtime control (toggle counting on/off on the fly) ---
# When enabled, the process watches a small JSON control file and honors its
# "counting" flag. Set false to always count (ignore the control file).
CONTROL_ENABLED = os.getenv("CONTROL_ENABLED", "false").lower() == "true"
CONTROL_FILE = os.getenv("CONTROL_FILE", f"{OUTPUT_DIR}/control.json")
# Whether counting is active on startup when no control file exists yet.
CONTROL_DEFAULT_COUNTING = os.getenv("CONTROL_DEFAULT_COUNTING", "true").lower() == "true"
# Re-read the control file at most every N seconds.
CONTROL_POLL_SEC = float(os.getenv("CONTROL_POLL_SEC", "1.0"))
# Optional TCP control socket. When enabled, the counter listens for line-based
# commands so counting can be toggled over the network (in addition to the file).
CONTROL_SOCKET_ENABLED = os.getenv("CONTROL_SOCKET_ENABLED", "false").lower() == "true"
CONTROL_SOCKET_HOST = os.getenv("CONTROL_SOCKET_HOST", "127.0.0.1")
CONTROL_SOCKET_PORT = int(os.getenv("CONTROL_SOCKET_PORT", "5090"))
CROSS_FLASH_FRAMES = 12 CROSS_FLASH_FRAMES = 12
POPUP_LIFETIME = 20 POPUP_LIFETIME = 20
@@ -414,6 +441,7 @@ class ByteTracker:
cost_mat = 1.0 - iou_mat cost_mat = 1.0 - iou_mat
matches = _greedy_match(cost_mat, threshold=1.0 - self.match_thresh) matches = _greedy_match(cost_mat, threshold=1.0 - self.match_thresh)
matched_det_idx = set()
for di, ti in matches: for di, ti in matches:
det_global = int(high_idx[di]) det_global = int(high_idx[di])
orig_idx = int(remain_orig_idx[det_global]) orig_idx = int(remain_orig_idx[det_global])
@@ -423,6 +451,30 @@ class ByteTracker:
det_to_track[orig_idx] = track_pool[ti].track_id det_to_track[orig_idx] = track_pool[ti].track_id
tracked_map[track_pool[ti].track_id] = (track_pool[ti].get_cx(), track_pool[ti].get_cy()) tracked_map[track_pool[ti].track_id] = (track_pool[ti].get_cx(), track_pool[ti].get_cy())
match_pairs_high.append((det_global, ti)) match_pairs_high.append((det_global, ti))
matched_det_idx.add(di)
# --- second pass: any unmatched high-score detection tries to
# pair with ANY track that overlaps above threshold, even one
# that already has a match. This prevents a close/overlapping
# object from spawning a spurious new ID when the greedy 1:1
# match paired its detection elsewhere. ---
for di in range(len(high_dets)):
if di in matched_det_idx:
continue
best_ti = None
best_iou = 0.0
for ti in range(num_tracks):
if iou_mat[di, ti] > best_iou and iou_mat[di, ti] >= self.match_thresh:
best_iou = iou_mat[di, ti]
best_ti = ti
if best_ti is not None:
det_global = int(high_idx[di])
orig_idx = int(remain_orig_idx[det_global])
track_pool[best_ti].update(dets[det_global])
track_pool[best_ti].hit_streak = max(1, track_pool[best_ti].hit_streak)
matched_track_idx.add(best_ti)
det_to_track[orig_idx] = track_pool[best_ti].track_id
tracked_map[track_pool[best_ti].track_id] = (track_pool[best_ti].get_cx(), track_pool[best_ti].get_cy())
unmatched_tracks = [ unmatched_tracks = [
t for t in range(num_tracks) if t not in matched_track_idx t for t in range(num_tracks) if t not in matched_track_idx
@@ -693,6 +745,93 @@ def now_str():
return datetime.now().strftime("%Y-%m-%d %H:%M:%S") return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def read_counting_flag(default=True):
"""Read the 'counting' flag from the control file. Returns default on any error."""
try:
with open(CONTROL_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return bool(data.get("counting", default))
except FileNotFoundError:
return default
except Exception:
return default
def write_control_file(counting):
"""Create/update the control file atomically (used to seed defaults)."""
try:
Path(CONTROL_FILE).parent.mkdir(parents=True, exist_ok=True)
tmp = f"{CONTROL_FILE}.tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump({"counting": bool(counting)}, f)
os.replace(tmp, CONTROL_FILE)
except Exception as exc:
print(f"[{now_str()}] Failed to write control file: {exc}")
def start_control_socket():
"""Start a TCP server for runtime control. Commands (newline-terminated):
START | RESUME | ON -> counting on
STOP | PAUSE | OFF -> counting off
TOGGLE -> flip
STATUS | GET -> report current state
It writes the shared control file, so the main loop's file-poll applies it.
Returns the server socket (call .close() to stop)."""
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((CONTROL_SOCKET_HOST, CONTROL_SOCKET_PORT))
srv.listen(5)
def handle(conn, addr):
with conn:
conn.settimeout(30)
try:
buf = b""
while not shutdown_requested:
try:
chunk = conn.recv(256)
except socket.timeout:
break
if not chunk:
break
buf += chunk
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
cmd = line.decode("utf-8", "ignore").strip().upper()
if not cmd:
continue
current = read_counting_flag(CONTROL_DEFAULT_COUNTING)
if cmd in ("START", "RESUME", "ON"):
write_control_file(True)
resp = "OK counting=on"
elif cmd in ("STOP", "PAUSE", "OFF"):
write_control_file(False)
resp = "OK counting=off"
elif cmd == "TOGGLE":
write_control_file(not current)
resp = f"OK counting={'off' if current else 'on'}"
elif cmd in ("STATUS", "GET"):
resp = f"OK counting={'on' if current else 'off'}"
else:
resp = "ERR unknown command"
conn.sendall((resp + "\n").encode("utf-8"))
except Exception:
pass
def loop():
print(f"Control socket listening on {CONTROL_SOCKET_HOST}:{CONTROL_SOCKET_PORT}")
while not shutdown_requested:
try:
conn, addr = srv.accept()
except OSError:
break
t = threading.Thread(target=handle, args=(conn, addr), daemon=True)
t.start()
threading.Thread(target=loop, daemon=True).start()
return srv
def open_capture(source): def open_capture(source):
if source.lower().startswith(("rtsp://", "http://")): if source.lower().startswith(("rtsp://", "http://")):
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = RTSP_FFMPEG_OPTIONS os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = RTSP_FFMPEG_OPTIONS
@@ -1047,6 +1186,7 @@ def run():
core_mask=CORE_MASK, core_mask=CORE_MASK,
imgsz=IMGSZ, imgsz=IMGSZ,
conf=CONF, conf=CONF,
iou=NMS_IOU,
num_classes=NUM_CLASSES, num_classes=NUM_CLASSES,
score_sigmoid=SCORE_SIGMOID, score_sigmoid=SCORE_SIGMOID,
) )
@@ -1078,12 +1218,30 @@ def run():
frame_idx = 0 frame_idx = 0
inf_ms = 0.0 inf_ms = 0.0
prev_gray = None prev_gray = None
frames_since_infer = 0
video_writer = None video_writer = None
crossing_times = deque() crossing_times = deque()
counter_in = 0 counter_in = 0
counter_out = 0 counter_out = 0
last_snapshot_cleanup = 0.0 last_snapshot_cleanup = 0.0
counting_active = True
last_control_poll = 0.0
control_socket = None
if CONTROL_ENABLED:
if not Path(CONTROL_FILE).exists():
write_control_file(CONTROL_DEFAULT_COUNTING)
counting_active = read_counting_flag(CONTROL_DEFAULT_COUNTING)
print(
f"Runtime control enabled | file={CONTROL_FILE} | "
f"counting={'ON' if counting_active else 'OFF'}"
)
if CONTROL_SOCKET_ENABLED:
try:
control_socket = start_control_socket()
except Exception as exc:
print(f"[{now_str()}] Failed to start control socket: {exc}")
cap, w, h, fps = connect_stream(SOURCE) cap, w, h, fps = connect_stream(SOURCE)
if cap is None: if cap is None:
store.shutdown() store.shutdown()
@@ -1133,20 +1291,35 @@ def run():
cross_events_frame = [] cross_events_frame = []
detect_events_frame = [] detect_events_frame = []
if CONTROL_ENABLED and (now - last_control_poll) >= CONTROL_POLL_SEC:
last_control_poll = now
new_flag = read_counting_flag(CONTROL_DEFAULT_COUNTING)
if new_flag != counting_active:
counting_active = new_flag
print(f"[{now_str()}] Counting {'RESUMED' if counting_active else 'PAUSED'} via control file")
skip_inference = False skip_inference = False
if MOTION_DETECTION_ENABLED: if not counting_active:
skip_inference = True
elif MOTION_DETECTION_ENABLED:
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
if prev_gray is not None: if prev_gray is not None:
diff = cv2.absdiff(gray, prev_gray) diff = cv2.absdiff(gray, prev_gray)
mean_diff = cv2.mean(diff)[0] moved = int(np.count_nonzero(diff > MOTION_PIXEL_DELTA))
skip_inference = mean_diff < MOTION_THRESHOLD moved_frac = moved / diff.size
skip_inference = moved_frac < MOTION_MIN_AREA_FRAC
if frames_since_infer >= MOTION_HEARTBEAT_FRAMES:
skip_inference = False
prev_gray = gray prev_gray = gray
detections = [] detections = []
if not skip_inference: if not skip_inference:
frames_since_infer = 0
inf_start = time.time() inf_start = time.time()
detections = model(frame) detections = model(frame)
inf_ms = inf_ms * 0.9 + (time.time() - inf_start) * 1000 * 0.1 inf_ms = inf_ms * 0.9 + (time.time() - inf_start) * 1000 * 0.1
else:
frames_since_infer += 1
object_boxes_xyxy = [] object_boxes_xyxy = []
object_scores = [] object_scores = []
@@ -1350,6 +1523,14 @@ def run():
) )
popups = draw_popups(frame, popups, frame_idx) popups = draw_popups(frame, popups, frame_idx)
if CONTROL_ENABLED and not counting_active:
badge = "COUNTING PAUSED"
(bw, bh), _ = cv2.getTextSize(badge, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)
bx = w // 2 - bw // 2
overlay_rect(frame, bx - 14, 48, bx + bw + 14, 48 + bh + 18, C_PANEL, alpha=0.75)
cv2.rectangle(frame, (bx - 14, 48), (bx + bw + 14, 48 + bh + 18), C_ACCENT, 2)
cv2.putText(frame, badge, (bx, 48 + bh + 6), cv2.FONT_HERSHEY_SIMPLEX, 0.6, C_ACCENT, 2, cv2.LINE_AA)
for flash_store in ( for flash_store in (
object_cross_flash1, object_cross_flash2, object_cross_flash1, object_cross_flash2,
): ):
@@ -1367,11 +1548,14 @@ def run():
if LIVE_STREAM_ENABLED and frame_idx % LIVE_STREAM_EVERY_N == 0: if LIVE_STREAM_ENABLED and frame_idx % LIVE_STREAM_EVERY_N == 0:
try: try:
Path(LIVE_STREAM_FRAME_PATH).parent.mkdir(parents=True, exist_ok=True) Path(LIVE_STREAM_FRAME_PATH).parent.mkdir(parents=True, exist_ok=True)
_, jpeg = cv2.imencode( ok, jpeg = cv2.imencode(
".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, LIVE_STREAM_QUALITY] ".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, LIVE_STREAM_QUALITY]
) )
with open(LIVE_STREAM_FRAME_PATH, "wb") as f: if ok:
tmp_path = f"{LIVE_STREAM_FRAME_PATH}.tmp"
with open(tmp_path, "wb") as f:
f.write(jpeg.tobytes()) f.write(jpeg.tobytes())
os.replace(tmp_path, LIVE_STREAM_FRAME_PATH)
except Exception: except Exception:
pass pass
@@ -1418,6 +1602,11 @@ def run():
video_writer.release() video_writer.release()
if cross_logger: if cross_logger:
cross_logger.close() cross_logger.close()
if control_socket is not None:
try:
control_socket.close()
except Exception:
pass
model.release() model.release()
store.shutdown() store.shutdown()
+1
View File
@@ -0,0 +1 @@
Jul 14 22:15:51 frigate-karung-masuk-tuang-dev python[2390681]: , 5806, 5808, 5809, 5810, 5811, 5812, 5813, 5814, 5815, 5816, 5817, 5818, 5819, 5820, 5821, 5822, 5824, 5825, 5826, 5827, 5828, 5829, 5830, 5831, 5832, 5833, 5834, 5835, 5837, 5838, 5839, 5840, 5843, 5844, 5845, 5846, 5847, 5848, 5849, 5854, 5855, 5856, 5857, 5858, 5860, 5861, 5862, 5863, 5864, 5865, 5866, 5867, 5868, 5870, 5871, 5872, 5873, 5874, 5876, 5877, 5878, 5879, 5880, 5881, 5882, 5883, 5884, 5885, 5886, 5887, 5888, 5889, 5892, 5893, 5894, 5895, 5896, 5897, 5899, 5900, 5901, 5902, 5903, 5904, 5907, 5908, 5909, 5910, 5911, 5912, 5913, 5914, 5915, 5916, 5917, 5918, 5919, 5920, 5921, 5922, 5923, 5925, 5926, 5927, 5928, 5929, 5930, 5931, 5932, 5935, 5936, 5937, 5938, 5939, 5942, 5943, 5944, 5945, 5946, 5947, 5948, 5949, 5950, 5951, 5953, 5954, 5955, 5956, 5957, 5958, 5959, 5961, 5962, 5963, 5964, 5965, 5966, 5967, 5968, 5969, 5970, 5971, 5972, 5974, 5975, 5976, 5977, 5978, 5979, 5980, 5982, 5983, 5985, 5986, 5987, 5988, 5989, 5990, 5991, 5992, 5993, 5995, 5996, 5997, 5998, 5999, 6000, 6001, 6002, 6003, 6004, 6005, 6007, 6008, 6009, 6010, 6011, 6012, 6013, 6014, 6015, 6020, 6021, 6022, 6023, 6024, 6025, 6026, 6030, 6031, 6032, 6033, 6034, 6035, 6036, 6037, 6038, 6042, 6043, 6044, 6045, 6046, 6047, 6048, 6050, 6051, 6052, 6056, 6057, 6058, 6059, 6060, 6061, 6062, 6063, 6064, 6065, 6068, 6069, 6070, 6071, 6072, 6073, 6074, 6075, 6076, 6080, 6081, 6082, 6083, 6084, 6085, 6087, 6088, 6089, 6090, 6091, 6092, 6093, 6094, 6095, 6096, 6097, 6098, 6099, 6100, 6102, 6103, 6104, 6105, 6106, 6107, 6108, 6115, 6116, 6117, 6118, 6119, 6120, 6121, 6122, 6123, 6124, 6131, 6132, 6133, 6134, 6135, 6136, 6137, 6138, 6139, 6140, 6141, 6142, 6143, 6144, 6145, 6146, 6147, 6148, 6149, 6150, 6151, 6152, 6154, 6155, 6156, 6157, 6158, 6159, 6160, 6161, 6162, 6163, 6164, 6165, 6166, 6168, 6169, 6170, 6171, 6172, 6173, 6175, 6176, 6177, 6178, 6179, 6180, 6181, 6183, 6184, 6185, 6186, 6190, 6191, 6192, 6193, 6194, 6195, 6196, 6197, 6198, 6199, 6200, 6201, 6202, 6203, 6204, 6205, 6206, 6207, 6208, 6209, 6210, 6212, 6213, 6214, 6215, 6216, 6217, 6218, 6219, 6220, 6227, 6228, 6229, 6230, 6231, 6232, 6233, 6234, 6235, 6236, 6237, 6239, 6240, 6241, 6242, 6243, 6244, 6245, 6246, 6247, 6250, 6251, 6252, 6253, 6254, 6255, 6256, 6257, 6258, 6259, 6260, 6261, 6267, 6268, 6269, 6270, 6271, 6272, 6273, 6274, 6275, 6276, 6277, 6278, 6280, 6281, 6282, 6283, 6284, 6285, 6286, 6287, 6288, 6290, 6291, 6292, 6293, 6294, 6295, 6296, 6297, 6298, 6303, 6304, 6305, 6306, 6307, 6308, 6309, 6310, 6311, 6312, 6313, 6314, 6315, 6316, 6317, 6318, 6319, 6320, 6321, 6322, 6323, 6324, 6325, 6327, 6328, 6329, 6330, 6331, 6332, 6333, 6334, 6335, 6336, 6338, 6339, 6340, 6341, 6342, 6344, 6345, 6346, 6347, 6348, 6349, 6350, 6351, 6352, 6353, 6355, 6356, 6357, 6358, 6359, 6360, 6361, 6366, 6367, 6368, 6369, 6370, 6371, 6372, 6373, 6374, 6375, 6376, 6377, 6378, 6379, 6380, 6381, 6382, 6383, 6384, 6385, 6386, 6387, 6388, 6389, 6390, 6391, 6392, 6393, 6394, 6395, 6396, 6397, 6398, 6401, 6402, 6403, 6404, 6405, 6406, 6407, 6408, 6409, 6410, 6411, 6412, 6415, 6416, 6417, 6418, 6419, 6420, 6421, 6422, 6428, 6429, 6430, 6431, 6440, 6441, 6442, 6443, 6444, 6445, 6446, 6447, 6450, 6451, 6452, 6453, 6454, 6455, 6456, 6457, 6458, 6459, 6460, 6461, 6464, 6465, 6466, 6467, 6468, 6469, 6470, 6471, 6472, 6475, 6476, 6477, 6478, 6479, 6480, 6481, 6482, 6483, 6484, 6485, 6486, 6487, 6488, 6489, 6490, 6491, 6492, 6493, 6494, 6495, 6496, 6498, 6499, 6500, 6502, 6503, 6504, 6505, 6506, 6507, 6508, 6509, 6510, 6511, 6512, 6513, 6514, 6515, 6516, 6517, 6518, 6519, 6520, 6521, 6522, 6523, 6528, 6529, 6530, 6531, 6532, 6533, 6534, 6535, 6538, 6539, 6540, 6541, 6542, 6546, 6547, 6548, 6549, 6550, 6551, 6552, 6553, 6554, 6555, 6556, 6557, 6558, 6559, 6560, 6561, 6562, 6563, 6564, 6565, 6566, 6567, 6568, 6569, 6570, 6571, 6572, 6573, 6574, 6575, 6576, 6577, 6580, 6581, 6582, 6583, 6584, 6585, 6586, 6587, 6588, 6589, 6590, 6591, 6592, 6593, 6594, 6595, 6596, 6597, 6598, 6599, 6600, 6601, 6602, 6603, 6605, 6606, 6607, 6608, 6609, 6610, 6611, 6614, 6615, 6616, 6617, 6619, 6620, 6621, 6622, 6623, 6624, 6625, 6626, 6627, 6628, 6630, 6631, 6632, 6633, 6634, 6636, 6642, 6643, 6644, 6645, 6646, 6647, 6648, 6649, 6650, 6652, 6653, 6654, 6655, 6656, 6657, 6658, 6659, 6660, 6661, 6662, 6663, 6664, 6668, 6669, 6670, 6671, 6672, 6673, 6674, 6676, 6677, 6678, 6679, 6680, 6681, 6682, 6683, 6684, 6685, 6686, 6687, 6688, 6694, 6695, 6696, 6697, 6698, 6699, 6700, 6701, 6702, 6703, 6704, 6705, 6706, 6707, 6708, 6709, 6710, 6711, 6712, 6713, 6714, 6722, 6723, 6724, 6725, 6726, 6727, 6728, 6729, 6730, 6731, 6732, 6733, 6738, 6739, 6740, 6741, 6742, 6743, 6744, 6745, 6746, 6747, 6748, 6749, 6750, 6754, 6755, 6756, 6757, 6758, 6759, 6760, 6761, 6762, 6763, 6764, 6765, 6766, 6767, 6768, 6769, 6772, 6773, 6774, 6777, 6778, 6779, 6780, 6781, 6782, 6788, 6789, 6790, 6791, 6792, 6793, 6794, 6795, 6797, 6798, 6799, 6800, 6801, 6802, 6803, 6804, 6805, 6806, 6Line truncated
+5 -2
View File
@@ -27,7 +27,7 @@ IMGSZ=320
# Use FP16 inference on NPU (true/false); currently unused in ByteTrack variant # Use FP16 inference on NPU (true/false); currently unused in ByteTrack variant
HALF=false HALF=false
# NPU core mask: 1=core0, 2=core1, 3=core0+core1, 7=all three # NPU core mask: 1=core0, 2=core1, 3=core0+core1, 7=all three
CORE_MASK=7 CORE_MASK=1
# Compute device index (reserved; not used at runtime) # Compute device index (reserved; not used at runtime)
DEVICE=0 DEVICE=0
@@ -97,7 +97,7 @@ CROSS_CSV=/tmp/crossings.csv
# Enable motion detection pre-filter: skip inference on frames with no movement # Enable motion detection pre-filter: skip inference on frames with no movement
# (true/false, default: false). When enabled, frames below MOTION_THRESHOLD are # (true/false, default: false). When enabled, frames below MOTION_THRESHOLD are
# skipped, saving NPU/CPU load. # skipped, saving NPU/CPU load.
MOTION_DETECTION_ENABLED=false MOTION_DETECTION_ENABLED=true
# Mean absolute pixel difference threshold (0–255) to consider a frame as having # Mean absolute pixel difference threshold (0–255) to consider a frame as having
# motion. Lower = more sensitive. Default 5.0. # motion. Lower = more sensitive. Default 5.0.
MOTION_THRESHOLD=5.0 MOTION_THRESHOLD=5.0
@@ -145,3 +145,6 @@ CURRENT_COUNTER_PATH=/tmp/bytetrack_current_counter.json
SAVE_CROSS_SNAPSHOT=true SAVE_CROSS_SNAPSHOT=true
CROSS_SNAPSHOT_DIR=/opt/zenai-kpc-snaps/snapshots CROSS_SNAPSHOT_DIR=/opt/zenai-kpc-snaps/snapshots
CROSS_SNAPSHOT_CLEANUP_SEC=3600 CROSS_SNAPSHOT_CLEANUP_SEC=3600
CROSS_SNAPSHOT_MAX_AGE_DAYS=3
DEDUP_PX=-1
+82 -178
View File
@@ -149,6 +149,19 @@
} }
.theme-toggle:hover { border-color: var(--accent); background: var(--cell-bg); } .theme-toggle:hover { border-color: var(--accent); background: var(--cell-bg); }
.nav-link {
display: inline-flex; align-items: center; gap: 6px;
background: none; border: 1px solid var(--cell-border);
border-radius: 8px; padding: 7px 12px; cursor: pointer;
font-family: 'JetBrains Mono', monospace; font-size: 10px; letter-spacing: 1px;
color: var(--text-secondary); text-decoration: none;
transition: border-color 0.3s, color 0.3s, background 0.3s;
}
.nav-link:hover { border-color: var(--accent); color: var(--text-primary); background: var(--cell-bg); }
.nav-link.active { border-color: var(--accent); color: var(--accent); }
.nav-link.counting-on { border-color: rgba(0,255,136,0.5); color: var(--accent4); }
.nav-link.counting-off { border-color: rgba(255,45,120,0.5); color: var(--accent3); }
/* Section title */ /* Section title */
.section-title { .section-title {
font-family: 'Orbitron', sans-serif; font-size: 13px; font-weight: 600; letter-spacing: 2px; font-family: 'Orbitron', sans-serif; font-size: 13px; font-weight: 600; letter-spacing: 2px;
@@ -406,55 +419,6 @@
} }
.table-toolbar .toolbar-group { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .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"] { .table-toolbar input[type="date"] {
font-family: 'JetBrains Mono', monospace; font-size: 11px; font-family: 'JetBrains Mono', monospace; font-size: 11px;
background: var(--input-bg); color: var(--text-primary); background: var(--input-bg); color: var(--text-primary);
@@ -645,6 +609,8 @@
<span class="dot"></span> <span class="dot"></span>
<span>{{ site_name }}</span> <span>{{ site_name }}</span>
</div> </div>
<a class="nav-link" href="/snapshots" title="Snapshots"><i class="fas fa-images"></i> SNAPSHOTS</a>
{% if control_enabled %}<button class="nav-link" id="countingToggle" onclick="toggleCounting()" title="Start/stop counting"><i class="fas fa-power-off"></i> <span id="counting-label">--</span></button>{% endif %}
<button class="theme-toggle" id="themeToggle" onclick="toggleTheme()" title="Toggle theme">☀️</button> <button class="theme-toggle" id="themeToggle" onclick="toggleTheme()" title="Toggle theme">☀️</button>
</div> </div>
</div> </div>
@@ -655,7 +621,7 @@
<span class="s-chip live-chip-tag"><span class="ldot"></span>LIVE</span> <span class="s-chip live-chip-tag"><span class="ldot"></span>LIVE</span>
<div class="s-icon primary"><i class="fas fa-calendar-day"></i></div> <div class="s-icon primary"><i class="fas fa-calendar-day"></i></div>
<div class="s-val" id="today-count">--</div> <div class="s-val" id="today-count">--</div>
<div class="s-label">Total Count Today</div> <div class="s-label">Diff Today (In &minus; Out)</div>
<div class="sub-cards"> <div class="sub-cards">
<div class="sub-card in"> <div class="sub-card in">
<div class="sc-head"><i class="fas fa-arrow-right-to-bracket"></i> In</div> <div class="sc-head"><i class="fas fa-arrow-right-to-bracket"></i> In</div>
@@ -671,7 +637,7 @@
<span class="s-chip info">Yesterday</span> <span class="s-chip info">Yesterday</span>
<div class="s-icon warm"><i class="fas fa-history"></i></div> <div class="s-icon warm"><i class="fas fa-history"></i></div>
<div class="s-val" id="yesterday-count">--</div> <div class="s-val" id="yesterday-count">--</div>
<div class="s-label">Total Count Yesterday</div> <div class="s-label">Diff Yesterday (In &minus; Out)</div>
<div class="sub-cards"> <div class="sub-cards">
<div class="sub-card in"> <div class="sub-card in">
<div class="sc-head"><i class="fas fa-arrow-right-to-bracket"></i> In</div> <div class="sc-head"><i class="fas fa-arrow-right-to-bracket"></i> In</div>
@@ -749,8 +715,8 @@
<div class="qs-left"> <div class="qs-left">
<div class="qs-icon primary"><i class="fas fa-layer-group"></i></div> <div class="qs-icon primary"><i class="fas fa-layer-group"></i></div>
<div> <div>
<div class="qs-label">Grand Total</div> <div class="qs-label">Grand Diff</div>
<div class="qs-sub">All time</div> <div class="qs-sub">All time (In &minus; Out)</div>
</div> </div>
</div> </div>
<span class="qs-val" id="grand-total">--</span> <span class="qs-val" id="grand-total">--</span>
@@ -805,7 +771,7 @@
<thead> <thead>
<tr> <tr>
<th style="padding-left:4px;">Date</th> <th style="padding-left:4px;">Date</th>
<th>Total Count</th> <th>Diff (In &minus; Out)</th>
<th>In</th> <th>In</th>
<th>Out</th> <th>Out</th>
<th style="padding-right:4px;">Status</th> <th style="padding-right:4px;">Status</th>
@@ -818,47 +784,9 @@
</div> </div>
</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 class="footer">Edge Nano Counter by ZenAi</div>
</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> <script>
// --- Theme --- // --- Theme ---
(function() { (function() {
@@ -893,7 +821,6 @@ document.addEventListener('DOMContentLoaded', () => {
loadChartData(7); loadChartData(7);
loadRecentDays(); loadRecentDays();
resetFilter(); resetFilter();
loadSnapshots('all');
setInterval(() => { setInterval(() => {
loadSummary(); loadSummary();
@@ -904,20 +831,69 @@ document.addEventListener('DOMContentLoaded', () => {
loadCurrentCounter(); loadCurrentCounter();
}, 2000); }, 2000);
setInterval(() => { if (document.getElementById('countingToggle')) {
if (snapCurrentKind && !snapDateFilter) loadSnapshots(snapCurrentKind, true); loadControlState();
}, 15000); setInterval(loadControlState, 5000);
}
}); });
let countingActive = null;
async function loadControlState() {
try {
const res = await fetch('/api/control');
const data = await res.json();
countingActive = !!data.counting;
renderCountingToggle();
} catch (err) { /* ignore */ }
}
function renderCountingToggle() {
const btn = document.getElementById('countingToggle');
const label = document.getElementById('counting-label');
if (!btn || !label) return;
if (countingActive) {
label.textContent = 'COUNTING ON';
btn.classList.add('counting-on');
btn.classList.remove('counting-off');
} else {
label.textContent = 'COUNTING OFF';
btn.classList.add('counting-off');
btn.classList.remove('counting-on');
}
}
async function toggleCounting() {
const next = !countingActive;
try {
const res = await fetch('/api/control', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ counting: next }),
});
const data = await res.json();
if (data.success) {
countingActive = !!data.counting;
renderCountingToggle();
} else {
alert('Control error: ' + (data.error || 'unknown'));
}
} catch (err) {
alert('Control request failed: ' + err.message);
}
}
async function loadCurrentCounter() { async function loadCurrentCounter() {
try { try {
const res = await fetch('/api/current-counter'); const res = await fetch('/api/current-counter');
if (!res.ok) throw new Error('No active counter'); if (!res.ok) throw new Error('No active counter');
const data = await res.json(); const data = await res.json();
document.getElementById('today-count').textContent = (data.count || 0).toLocaleString(); const tin = data.count_in || 0;
document.getElementById('today-in').textContent = (data.count_in || 0).toLocaleString(); const tout = data.count_out || 0;
document.getElementById('today-out').textContent = (data.count_out || 0).toLocaleString(); document.getElementById('today-count').textContent = (tin - tout).toLocaleString();
document.getElementById('today-in').textContent = tin.toLocaleString();
document.getElementById('today-out').textContent = tout.toLocaleString();
} catch (err) { } catch (err) {
document.getElementById('today-count').textContent = '0'; document.getElementById('today-count').textContent = '0';
document.getElementById('today-in').textContent = '0'; document.getElementById('today-in').textContent = '0';
@@ -932,7 +908,7 @@ async function loadSummary() {
document.getElementById('current-date').textContent = data.today.date; document.getElementById('current-date').textContent = data.today.date;
document.getElementById('yesterday-count').textContent = data.yesterday.total_count.toLocaleString(); document.getElementById('yesterday-count').textContent = (data.yesterday.total_in - data.yesterday.total_out).toLocaleString();
document.getElementById('yesterday-in').textContent = data.yesterday.total_in.toLocaleString(); document.getElementById('yesterday-in').textContent = data.yesterday.total_in.toLocaleString();
document.getElementById('yesterday-out').textContent = data.yesterday.total_out.toLocaleString(); document.getElementById('yesterday-out').textContent = data.yesterday.total_out.toLocaleString();
@@ -942,7 +918,7 @@ async function loadSummary() {
document.getElementById('best-count').textContent = data.best_day.count.toLocaleString(); document.getElementById('best-count').textContent = data.best_day.count.toLocaleString();
document.getElementById('best-date').textContent = data.best_day.date || '--'; document.getElementById('best-date').textContent = data.best_day.date || '--';
document.getElementById('grand-total').textContent = data.all_time.grand_total.toLocaleString(); document.getElementById('grand-total').textContent = (data.all_time.grand_in - data.all_time.grand_out).toLocaleString();
document.getElementById('grand-in').textContent = data.all_time.grand_in.toLocaleString(); document.getElementById('grand-in').textContent = data.all_time.grand_in.toLocaleString();
document.getElementById('grand-out').textContent = data.all_time.grand_out.toLocaleString(); document.getElementById('grand-out').textContent = data.all_time.grand_out.toLocaleString();
@@ -965,7 +941,7 @@ async function loadChartData(days) {
const date = new Date(d.date); const date = new Date(d.date);
return date.toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short' }); return date.toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short' });
}); });
const counts = data.map(d => d.total_count); const counts = data.map(d => d.total_in - d.total_out);
const ins = data.map(d => d.total_in); const ins = data.map(d => d.total_in);
const outs = data.map(d => d.total_out); const outs = data.map(d => d.total_out);
@@ -1001,7 +977,7 @@ async function loadChartData(days) {
yAxisID: 'y' yAxisID: 'y'
}, },
{ {
label: 'Total', label: 'Diff',
data: counts, data: counts,
type: 'line', type: 'line',
borderColor: 'rgba(0, 240, 255, 1)', borderColor: 'rgba(0, 240, 255, 1)',
@@ -1072,7 +1048,7 @@ async function loadRecentDays() {
</div> </div>
</div> </div>
<div style="text-align:right;"> <div style="text-align:right;">
<div class="ri-count">${d.total_count.toLocaleString()}</div> <div class="ri-count">${(d.total_in - d.total_out).toLocaleString()}</div>
</div> </div>
</div> </div>
`).join(''); `).join('');
@@ -1125,7 +1101,7 @@ function renderDailyTable(dates) {
</div> </div>
</div> </div>
</td> </td>
<td><span style="font-family:'Orbitron',sans-serif;font-size:15px;font-weight:700;">${d.total_count.toLocaleString()}</span></td> <td><span style="font-family:'Orbitron',sans-serif;font-size:15px;font-weight:700;">${(d.total_in - d.total_out).toLocaleString()}</span></td>
<td><span class="badge badge-green">${d.total_in.toLocaleString()}</span></td> <td><span class="badge badge-green">${d.total_in.toLocaleString()}</span></td>
<td><span class="badge badge-gray">${d.total_out.toLocaleString()}</span></td> <td><span class="badge badge-gray">${d.total_out.toLocaleString()}</span></td>
<td style="padding-right:4px;"> <td style="padding-right:4px;">
@@ -1156,81 +1132,9 @@ function formatTime(isoString) {
} }
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { hideVideoError(); closeSnapModal(); } if (e.key === 'Escape') { hideVideoError(); }
}); });
// --- 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 --- // --- Live Video Feed ---
function toggleVideo() { function toggleVideo() {
+489
View File
@@ -0,0 +1,489 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZenAI KPC Snapshots</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&family=Orbitron:wght@400;500;700;900&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-deep: #06080e;
--bg-surface: rgba(12, 16, 28, 0.9);
--border-glow: rgba(0, 240, 255, 0.12);
--accent: #00f0ff;
--accent2: #7b2fff;
--accent3: #ff2d78;
--accent4: #00ff88;
--text-primary: #dce2f0;
--text-secondary: #6b7394;
--glass: rgba(12, 16, 32, 0.7);
--cell-bg: rgba(255, 255, 255, 0.02);
--cell-border: rgba(255, 255, 255, 0.05);
--divider: rgba(255, 255, 255, 0.05);
--input-bg: rgba(0, 0, 0, 0.4);
--radius: 18px;
}
[data-theme="light"] {
--bg-deep: #eef1f5;
--bg-surface: rgba(255, 255, 255, 0.9);
--border-glow: rgba(0, 160, 200, 0.15);
--accent: #0090c0;
--accent2: #6a20e0;
--accent3: #d02060;
--accent4: #00a060;
--text-primary: #1a1d30;
--text-secondary: #5a6088;
--glass: rgba(255, 255, 255, 0.8);
--cell-bg: rgba(0, 0, 0, 0.03);
--cell-border: rgba(0, 0, 0, 0.06);
--divider: rgba(0, 0, 0, 0.06);
--input-bg: rgba(0, 0, 0, 0.05);
}
body {
font-family: 'JetBrains Mono', monospace;
background: var(--bg-deep);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
.bg-grid {
position: fixed; inset: 0; z-index: 0;
background-image:
linear-gradient(rgba(0, 240, 255, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 240, 255, 0.025) 1px, transparent 1px);
background-size: 64px 64px;
pointer-events: none;
}
.orb {
position: fixed; border-radius: 50%; filter: blur(140px); pointer-events: none;
}
.orb-1 { width: 600px; height: 600px; background: rgba(0, 240, 255, 0.04); top: -250px; left: -150px; }
.orb-2 { width: 500px; height: 500px; background: rgba(123, 47, 255, 0.04); bottom: -200px; right: -100px; }
.container {
position: relative; z-index: 1;
max-width: 1600px; margin: 0 auto; padding: 24px 20px;
}
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 28px; margin-bottom: 24px;
background: var(--glass);
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-glow); border-radius: var(--radius);
}
.header-left { display: flex; align-items: center; gap: 14px; }
.logo-icon {
width: 44px; height: 44px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius: 11px; font-size: 22px;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 0 28px rgba(0, 240, 255, 0.35);
}
.header h1 {
font-family: 'Orbitron', sans-serif; font-size: 20px; font-weight: 700; letter-spacing: 3px;
background: linear-gradient(90deg, var(--accent), var(--accent2));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.header-sub { font-size: 10px; color: var(--text-secondary); letter-spacing: 2px; text-transform: uppercase; }
.header-right { display: flex; align-items: center; gap: 14px; }
.live-chip {
display: flex; align-items: center; gap: 8px;
font-size: 9px; letter-spacing: 1.5px; padding: 5px 12px; border-radius: 12px;
font-weight: 600; text-transform: uppercase;
background: rgba(0, 255, 136, 0.1); color: var(--accent4);
border: 1px solid rgba(0, 255, 136, 0.2);
}
.live-chip .dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--accent4); box-shadow: 0 0 8px var(--accent4);
animation: blink 1.5s ease-in-out infinite;
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
.nav-link {
display: inline-flex; align-items: center; gap: 6px;
background: none; border: 1px solid var(--cell-border);
border-radius: 8px; padding: 7px 12px; cursor: pointer;
font-family: 'JetBrains Mono', monospace; font-size: 10px; letter-spacing: 1px;
color: var(--text-secondary); text-decoration: none;
transition: border-color 0.3s, color 0.3s, background 0.3s;
}
.nav-link:hover { border-color: var(--accent); color: var(--text-primary); background: var(--cell-bg); }
.nav-link.active { border-color: var(--accent); color: var(--accent); }
.theme-toggle {
background: none; border: 1px solid var(--cell-border);
border-radius: 8px; padding: 6px 10px; cursor: pointer;
font-size: 16px; line-height: 1;
transition: border-color 0.3s, background 0.3s;
}
.theme-toggle:hover { border-color: var(--accent); background: var(--cell-bg); }
.section-title {
font-family: 'Orbitron', sans-serif; font-size: 13px; font-weight: 600; letter-spacing: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent2));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
display: inline-block;
}
.panel {
background: var(--glass);
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-glow); border-radius: var(--radius);
padding: 24px 26px; margin-bottom: 24px;
}
.toolbar {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 20px; flex-wrap: wrap; gap: 12px;
}
.toolbar-group { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.filters { display: flex; gap: 6px; }
.filters button {
font-family: 'JetBrains Mono', monospace; font-size: 10px;
letter-spacing: 1px; padding: 6px 14px; border-radius: 7px;
border: 1px solid var(--cell-border);
background: var(--cell-bg); color: var(--text-secondary);
cursor: pointer; transition: all 0.3s;
}
.filters button:hover { border-color: var(--accent); color: var(--text-primary); }
.filters button.active {
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #000; border-color: transparent; font-weight: 600;
}
input[type="date"] {
font-family: 'JetBrains Mono', monospace; font-size: 11px;
background: var(--input-bg); color: var(--text-primary);
border: 1px solid var(--cell-border); border-radius: 7px;
padding: 7px 12px; outline: none; transition: border-color 0.3s;
}
input[type="date"]:focus { border-color: var(--accent); }
.btn {
font-family: 'Orbitron', sans-serif; font-size: 10px; letter-spacing: 2px;
padding: 8px 16px; border-radius: 7px; cursor: pointer; font-weight: 600;
border: none; transition: opacity 0.3s, box-shadow 0.3s;
}
.btn-primary { background: linear-gradient(135deg, var(--accent), var(--accent2)); color: #000; }
.btn-primary:hover { opacity: 0.85; box-shadow: 0 0 20px rgba(0, 240, 255, 0.3); }
.btn-ghost {
background: var(--cell-bg); color: var(--text-secondary);
border: 1px solid var(--cell-border);
}
.btn-ghost:hover { border-color: var(--accent); color: var(--text-primary); }
.snapshot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 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); }
.empty {
text-align: center; padding: 48px; color: var(--text-secondary);
}
.empty .e-icon { font-size: 42px; opacity: 0.2; margin-bottom: 12px; }
.count-note { font-size: 10px; color: var(--text-secondary); letter-spacing: 1px; }
.modal-overlay {
position: fixed; inset: 0; z-index: 100;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center; padding: 20px;
}
.modal-overlay.hidden { display: none; }
.close-btn {
background: none; border: 1px solid var(--cell-border); color: var(--text-secondary);
width: 32px; height: 32px; border-radius: 8px; cursor: pointer;
font-size: 16px; display: flex; align-items: center; justify-content: center;
transition: border-color 0.3s, color 0.3s;
}
.close-btn:hover { border-color: var(--accent3); color: var(--accent3); }
.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;
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--text-secondary); border-radius: 3px; }
.footer {
margin-top: 24px; text-align: center;
font-size: 9px; letter-spacing: 2px; color: var(--text-secondary); text-transform: uppercase;
}
@media (max-width: 768px) {
.container { padding: 16px 12px; }
.header { flex-direction: column; gap: 10px; padding: 14px 20px; }
.header-right { flex-wrap: wrap; justify-content: center; }
.toolbar { flex-direction: column; align-items: flex-start; }
}
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
<div class="container">
<!-- Header -->
<div class="header">
<div class="header-left">
<div class="logo-icon">&#9670;</div>
<div>
<h1>ZenAI KPC Snapshots</h1>
<div class="header-sub">Detected &amp; counted frames — correlate by track ID</div>
</div>
</div>
<div class="header-right">
<div class="live-chip">
<span class="dot"></span>
<span>{{ site_name }}</span>
</div>
<a class="nav-link" href="/"><i class="fas fa-gauge-high"></i> DASHBOARD</a>
<a class="nav-link active" href="/snapshots"><i class="fas fa-images"></i> SNAPSHOTS</a>
<button class="theme-toggle" id="themeToggle" onclick="toggleTheme()" title="Toggle theme">☀️</button>
</div>
</div>
<!-- Snapshots -->
<div class="panel">
<div class="toolbar">
<div>
<span class="section-title">&#9673; Crossing Snapshots</span>
<div class="count-note" id="snap-count-note">--</div>
</div>
<div class="toolbar-group">
<div class="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="number" id="snap-tid-filter" placeholder="Track ID" min="0"
style="width:110px;font-family:'JetBrains Mono',monospace;font-size:11px;background:var(--input-bg);color:var(--text-primary);border:1px solid var(--cell-border);border-radius:7px;padding:7px 12px;outline:none;">
<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="snap-sentinel" style="height:1px;"></div>
<div id="snapshot-empty" class="empty" style="display:none;">
<div class="e-icon">&#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() {
const saved = localStorage.getItem('theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
updateToggleIcon();
})();
function toggleTheme() {
const el = document.documentElement;
const current = el.getAttribute('data-theme');
const next = current === 'light' ? '' : 'light';
if (next) el.setAttribute('data-theme', next);
else el.removeAttribute('data-theme');
localStorage.setItem('theme', next || 'dark');
updateToggleIcon();
}
function updateToggleIcon() {
const btn = document.getElementById('themeToggle');
if (!btn) return;
btn.textContent = document.documentElement.getAttribute('data-theme') === 'light' ? '🌙' : '☀️';
}
// --- Snapshots ---
let snapCurrentKind = 'all';
let snapDateFilter = '';
let snapTidFilter = '';
let snapItems = []; // all fetched metadata
let snapRendered = 0; // how many cards are in the DOM
const SNAP_BATCH = 60; // cards added per chunk
let snapObserver = null;
document.addEventListener('DOMContentLoaded', () => {
const sentinel = document.getElementById('snap-sentinel');
snapObserver = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) renderNextBatch();
}, { rootMargin: '600px' });
if (sentinel) snapObserver.observe(sentinel);
// Filter immediately when date changes or track ID is typed.
document.getElementById('snap-date-filter').addEventListener('change', applySnapFilter);
let tidDebounce = null;
document.getElementById('snap-tid-filter').addEventListener('input', () => {
clearTimeout(tidDebounce);
tidDebounce = setTimeout(applySnapFilter, 350);
});
loadSnapshots('all');
setInterval(() => {
if (snapCurrentKind && !snapDateFilter && !snapTidFilter) loadSnapshots(snapCurrentKind, true);
}, 15000);
});
function snapCardHtml(s) {
const imgUrl = `/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('${imgUrl}','${kindLabel}',${s.track_id},'${timeStr}',${s.frame})">
<img class="snap-thumb" src="${imgUrl}" loading="lazy" decoding="async" 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>`;
}
function renderNextBatch() {
if (snapRendered >= snapItems.length) return;
const grid = document.getElementById('snapshot-grid');
const next = snapItems.slice(snapRendered, snapRendered + SNAP_BATCH);
grid.insertAdjacentHTML('beforeend', next.map(snapCardHtml).join(''));
snapRendered += next.length;
updateSnapNote();
}
function updateSnapNote() {
const note = document.getElementById('snap-count-note');
note.textContent = `Showing ${snapRendered} of ${snapItems.length}`;
}
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=5000`;
if (snapDateFilter) url += `&date=${snapDateFilter}`;
if (snapTidFilter) url += `&track_id=${snapTidFilter}`;
const res = await fetch(url);
const data = await res.json();
const grid = document.getElementById('snapshot-grid');
const empty = document.getElementById('snapshot-empty');
snapItems = (data && data.snapshots) || [];
snapRendered = 0;
grid.innerHTML = '';
if (snapItems.length === 0) {
empty.style.display = 'block';
document.getElementById('snap-count-note').textContent = '';
return;
}
empty.style.display = 'none';
renderNextBatch();
} catch (err) {
if (!silent) console.error('Failed to load snapshots:', err);
}
}
function applySnapFilter() {
snapDateFilter = document.getElementById('snap-date-filter').value || '';
snapTidFilter = document.getElementById('snap-tid-filter').value || '';
loadSnapshots(snapCurrentKind);
}
function resetSnapFilter() {
snapDateFilter = '';
snapTidFilter = '';
document.getElementById('snap-date-filter').value = '';
document.getElementById('snap-tid-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 = '';
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeSnapModal();
});
</script>
</body>
</html>