Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
657f4f80d9 | ||
|
|
8f7ecb4321 | ||
|
|
6afffb2184 | ||
|
|
d93ee99c1f | ||
|
|
85dbbdd111 | ||
|
|
ff87201a76 | ||
|
|
9d9d5f903c | ||
|
|
660b9abef4 |
No files matched your search
+179
@@ -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
@@ -39,6 +39,11 @@ SCORE_SIGMOID=false
|
||||
# --- Detection ---
|
||||
# Confidence threshold – detections below this are discarded before NMS
|
||||
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 ---
|
||||
# Detections with score >= this get priority matching in the first association stage
|
||||
@@ -138,12 +143,47 @@ 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
|
||||
# skipped, saving NPU/CPU load.
|
||||
# (true/false, default: false), saving NPU/CPU load. Motion is measured by the
|
||||
# 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
|
||||
# Mean absolute pixel difference threshold (0–255) to consider a frame as having
|
||||
# motion. Lower = more sensitive. Default 5.0.
|
||||
# Per-pixel intensity change (0-255) for a pixel to count as "moved". Lower = more
|
||||
# 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
|
||||
|
||||
# --- 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)
|
||||
RATE_WINDOW_SEC=60
|
||||
# Number of frames to discard at startup to let the stream buffer stabilise
|
||||
|
||||
+67
-4
@@ -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")
|
||||
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")
|
||||
|
||||
DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000"))
|
||||
@@ -56,6 +60,12 @@ def api_live_video():
|
||||
try:
|
||||
with open(LIVE_STREAM_FRAME_PATH, "rb") as f:
|
||||
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
|
||||
yield (b"--frame\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("/")
|
||||
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")
|
||||
@@ -455,15 +517,16 @@ def export_daily_xlsx():
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
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):
|
||||
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=3, value=row["total_in"])
|
||||
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=6, value=row["end_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["start_time"])
|
||||
ws.cell(row=r_idx, column=7, value=row["end_time"])
|
||||
|
||||
_auto_width(ws)
|
||||
filename = f"{SITE_NAME}_daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
|
||||
+195
-6
@@ -7,8 +7,11 @@ Uses ByteTrack (Kalman filter + two-stage IoU association) for tracking.
|
||||
import numpy as np
|
||||
import cv2
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
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"))
|
||||
HALF = os.getenv("HALF", "false").lower() == "true"
|
||||
CONF = float(os.getenv("CONF", "0.3"))
|
||||
NMS_IOU = float(os.getenv("NMS_IOU", "0.45"))
|
||||
DEVICE = int(os.getenv("DEVICE", "0"))
|
||||
|
||||
# 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_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
|
||||
POPUP_LIFETIME = 20
|
||||
@@ -414,6 +441,7 @@ class ByteTracker:
|
||||
cost_mat = 1.0 - iou_mat
|
||||
matches = _greedy_match(cost_mat, threshold=1.0 - self.match_thresh)
|
||||
|
||||
matched_det_idx = set()
|
||||
for di, ti in matches:
|
||||
det_global = int(high_idx[di])
|
||||
orig_idx = int(remain_orig_idx[det_global])
|
||||
@@ -423,6 +451,30 @@ class ByteTracker:
|
||||
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())
|
||||
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 = [
|
||||
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")
|
||||
|
||||
|
||||
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):
|
||||
if source.lower().startswith(("rtsp://", "http://")):
|
||||
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = RTSP_FFMPEG_OPTIONS
|
||||
@@ -1047,6 +1186,7 @@ def run():
|
||||
core_mask=CORE_MASK,
|
||||
imgsz=IMGSZ,
|
||||
conf=CONF,
|
||||
iou=NMS_IOU,
|
||||
num_classes=NUM_CLASSES,
|
||||
score_sigmoid=SCORE_SIGMOID,
|
||||
)
|
||||
@@ -1078,12 +1218,30 @@ def run():
|
||||
frame_idx = 0
|
||||
inf_ms = 0.0
|
||||
prev_gray = None
|
||||
frames_since_infer = 0
|
||||
video_writer = None
|
||||
crossing_times = deque()
|
||||
counter_in = 0
|
||||
counter_out = 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)
|
||||
if cap is None:
|
||||
store.shutdown()
|
||||
@@ -1133,20 +1291,35 @@ def run():
|
||||
cross_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
|
||||
if MOTION_DETECTION_ENABLED:
|
||||
if not counting_active:
|
||||
skip_inference = True
|
||||
elif MOTION_DETECTION_ENABLED:
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
if prev_gray is not None:
|
||||
diff = cv2.absdiff(gray, prev_gray)
|
||||
mean_diff = cv2.mean(diff)[0]
|
||||
skip_inference = mean_diff < MOTION_THRESHOLD
|
||||
moved = int(np.count_nonzero(diff > MOTION_PIXEL_DELTA))
|
||||
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
|
||||
|
||||
detections = []
|
||||
if not skip_inference:
|
||||
frames_since_infer = 0
|
||||
inf_start = time.time()
|
||||
detections = model(frame)
|
||||
inf_ms = inf_ms * 0.9 + (time.time() - inf_start) * 1000 * 0.1
|
||||
else:
|
||||
frames_since_infer += 1
|
||||
|
||||
object_boxes_xyxy = []
|
||||
object_scores = []
|
||||
@@ -1350,6 +1523,14 @@ def run():
|
||||
)
|
||||
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 (
|
||||
object_cross_flash1, object_cross_flash2,
|
||||
):
|
||||
@@ -1367,11 +1548,14 @@ def run():
|
||||
if LIVE_STREAM_ENABLED and frame_idx % LIVE_STREAM_EVERY_N == 0:
|
||||
try:
|
||||
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]
|
||||
)
|
||||
with open(LIVE_STREAM_FRAME_PATH, "wb") as f:
|
||||
f.write(jpeg.tobytes())
|
||||
if ok:
|
||||
tmp_path = f"{LIVE_STREAM_FRAME_PATH}.tmp"
|
||||
with open(tmp_path, "wb") as f:
|
||||
f.write(jpeg.tobytes())
|
||||
os.replace(tmp_path, LIVE_STREAM_FRAME_PATH)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1418,6 +1602,11 @@ def run():
|
||||
video_writer.release()
|
||||
if cross_logger:
|
||||
cross_logger.close()
|
||||
if control_socket is not None:
|
||||
try:
|
||||
control_socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
model.release()
|
||||
store.shutdown()
|
||||
|
||||
|
||||
@@ -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
@@ -27,7 +27,7 @@ IMGSZ=320
|
||||
# Use FP16 inference on NPU (true/false); currently unused in ByteTrack variant
|
||||
HALF=false
|
||||
# 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)
|
||||
DEVICE=0
|
||||
|
||||
@@ -97,7 +97,7 @@ CROSS_CSV=/tmp/crossings.csv
|
||||
# Enable motion detection pre-filter: skip inference on frames with no movement
|
||||
# (true/false, default: false). When enabled, frames below MOTION_THRESHOLD are
|
||||
# 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
|
||||
# motion. Lower = more sensitive. Default 5.0.
|
||||
MOTION_THRESHOLD=5.0
|
||||
@@ -145,3 +145,6 @@ CURRENT_COUNTER_PATH=/tmp/bytetrack_current_counter.json
|
||||
SAVE_CROSS_SNAPSHOT=true
|
||||
CROSS_SNAPSHOT_DIR=/opt/zenai-kpc-snaps/snapshots
|
||||
CROSS_SNAPSHOT_CLEANUP_SEC=3600
|
||||
CROSS_SNAPSHOT_MAX_AGE_DAYS=3
|
||||
|
||||
DEDUP_PX=-1
|
||||
+82
-178
@@ -149,6 +149,19 @@
|
||||
}
|
||||
.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 {
|
||||
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; }
|
||||
|
||||
/* 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;
|
||||
background: var(--input-bg); color: var(--text-primary);
|
||||
@@ -645,6 +609,8 @@
|
||||
<span class="dot"></span>
|
||||
<span>{{ site_name }}</span>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -655,7 +621,7 @@
|
||||
<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-val" id="today-count">--</div>
|
||||
<div class="s-label">Total Count Today</div>
|
||||
<div class="s-label">Diff Today (In − Out)</div>
|
||||
<div class="sub-cards">
|
||||
<div class="sub-card in">
|
||||
<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>
|
||||
<div class="s-icon warm"><i class="fas fa-history"></i></div>
|
||||
<div class="s-val" id="yesterday-count">--</div>
|
||||
<div class="s-label">Total Count Yesterday</div>
|
||||
<div class="s-label">Diff Yesterday (In − Out)</div>
|
||||
<div class="sub-cards">
|
||||
<div class="sub-card in">
|
||||
<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-icon primary"><i class="fas fa-layer-group"></i></div>
|
||||
<div>
|
||||
<div class="qs-label">Grand Total</div>
|
||||
<div class="qs-sub">All time</div>
|
||||
<div class="qs-label">Grand Diff</div>
|
||||
<div class="qs-sub">All time (In − Out)</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="qs-val" id="grand-total">--</span>
|
||||
@@ -805,7 +771,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding-left:4px;">Date</th>
|
||||
<th>Total Count</th>
|
||||
<th>Diff (In − Out)</th>
|
||||
<th>In</th>
|
||||
<th>Out</th>
|
||||
<th style="padding-right:4px;">Status</th>
|
||||
@@ -818,47 +784,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Snapshots -->
|
||||
<div class="table-panel">
|
||||
<div class="table-toolbar">
|
||||
<div>
|
||||
<span class="section-title" style="margin-bottom:0;">◉ Crossing Snapshots</span>
|
||||
<div style="font-size:10px;color:var(--text-secondary);letter-spacing:1px;">Detected & counted frames — correlate by track ID</div>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<div class="chart-filters" id="snap-filters">
|
||||
<button class="active" data-kind="all" onclick="loadSnapshots('all')">ALL</button>
|
||||
<button data-kind="in" onclick="loadSnapshots('in')">IN</button>
|
||||
<button data-kind="out" onclick="loadSnapshots('out')">OUT</button>
|
||||
{% if show_detect %}<button data-kind="detect" onclick="loadSnapshots('detect')">DETECT</button>{% endif %}
|
||||
</div>
|
||||
<input type="date" id="snap-date-filter">
|
||||
<button class="btn btn-primary" onclick="applySnapFilter()">FILTER</button>
|
||||
<button class="btn btn-ghost" onclick="resetSnapFilter()">RESET</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="snapshot-grid" class="snapshot-grid">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<div id="snapshot-empty" style="display:none;text-align:center;padding:32px;color:var(--text-secondary);">
|
||||
<div style="font-size:32px;opacity:0.2;margin-bottom:8px;">▣</div>
|
||||
<div style="font-size:10px;letter-spacing:1px;">NO SNAPSHOTS AVAILABLE</div>
|
||||
<div style="font-size:9px;margin-top:6px;opacity:0.7;">Enable SAVE_CROSS_SNAPSHOT{% if show_detect %} / SAVE_DETECT_SNAPSHOT{% endif %} in counter .env</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">Edge Nano Counter by ZenAi</div>
|
||||
</div>
|
||||
|
||||
<!-- Snapshot lightbox -->
|
||||
<div class="modal-overlay hidden" id="snap-modal" onclick="closeSnapModal(event)">
|
||||
<div class="snap-lightbox" id="snap-lightbox">
|
||||
<button class="close-btn" onclick="closeSnapModal()" style="position:absolute;top:12px;right:12px;z-index:2;">×</button>
|
||||
<img id="snap-modal-img" alt="Snapshot" />
|
||||
<div class="snap-modal-meta" id="snap-modal-meta">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// --- Theme ---
|
||||
(function() {
|
||||
@@ -893,7 +821,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadChartData(7);
|
||||
loadRecentDays();
|
||||
resetFilter();
|
||||
loadSnapshots('all');
|
||||
|
||||
setInterval(() => {
|
||||
loadSummary();
|
||||
@@ -904,20 +831,69 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadCurrentCounter();
|
||||
}, 2000);
|
||||
|
||||
setInterval(() => {
|
||||
if (snapCurrentKind && !snapDateFilter) loadSnapshots(snapCurrentKind, true);
|
||||
}, 15000);
|
||||
if (document.getElementById('countingToggle')) {
|
||||
loadControlState();
|
||||
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() {
|
||||
try {
|
||||
const res = await fetch('/api/current-counter');
|
||||
if (!res.ok) throw new Error('No active counter');
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('today-count').textContent = (data.count || 0).toLocaleString();
|
||||
document.getElementById('today-in').textContent = (data.count_in || 0).toLocaleString();
|
||||
document.getElementById('today-out').textContent = (data.count_out || 0).toLocaleString();
|
||||
const tin = data.count_in || 0;
|
||||
const tout = data.count_out || 0;
|
||||
document.getElementById('today-count').textContent = (tin - tout).toLocaleString();
|
||||
document.getElementById('today-in').textContent = tin.toLocaleString();
|
||||
document.getElementById('today-out').textContent = tout.toLocaleString();
|
||||
} catch (err) {
|
||||
document.getElementById('today-count').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('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-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-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-out').textContent = data.all_time.grand_out.toLocaleString();
|
||||
|
||||
@@ -965,7 +941,7 @@ async function loadChartData(days) {
|
||||
const date = new Date(d.date);
|
||||
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 outs = data.map(d => d.total_out);
|
||||
|
||||
@@ -1001,7 +977,7 @@ async function loadChartData(days) {
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: 'Total',
|
||||
label: 'Diff',
|
||||
data: counts,
|
||||
type: 'line',
|
||||
borderColor: 'rgba(0, 240, 255, 1)',
|
||||
@@ -1072,7 +1048,7 @@ async function loadRecentDays() {
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
`).join('');
|
||||
@@ -1125,7 +1101,7 @@ function renderDailyTable(dates) {
|
||||
</div>
|
||||
</div>
|
||||
</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-gray">${d.total_out.toLocaleString()}</span></td>
|
||||
<td style="padding-right:4px;">
|
||||
@@ -1156,81 +1132,9 @@ function formatTime(isoString) {
|
||||
}
|
||||
|
||||
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 ---
|
||||
|
||||
function toggleVideo() {
|
||||
|
||||
@@ -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">◆</div>
|
||||
<div>
|
||||
<h1>ZenAI KPC Snapshots</h1>
|
||||
<div class="header-sub">Detected & 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">◉ 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">▣</div>
|
||||
<div style="font-size:10px;letter-spacing:1px;">NO SNAPSHOTS AVAILABLE</div>
|
||||
<div style="font-size:9px;margin-top:6px;opacity:0.7;">Enable SAVE_CROSS_SNAPSHOT{% if show_detect %} / SAVE_DETECT_SNAPSHOT{% endif %} in counter .env</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">Edge Nano Counter by ZenAi</div>
|
||||
</div>
|
||||
|
||||
<!-- Snapshot lightbox -->
|
||||
<div class="modal-overlay hidden" id="snap-modal" onclick="closeSnapModal(event)">
|
||||
<div class="snap-lightbox" id="snap-lightbox">
|
||||
<button class="close-btn" onclick="closeSnapModal()" style="position:absolute;top:12px;right:12px;z-index:2;">×</button>
|
||||
<img id="snap-modal-img" alt="Snapshot" />
|
||||
<div class="snap-modal-meta" id="snap-modal-meta">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// --- Theme ---
|
||||
(function() {
|
||||
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>
|
||||
Reference in new issue
Block a user