Compare commits

...
17 Commits
Author SHA1 Message Date
dsutanto 25f3baddc9 Merge pull request 'update counting logic to use IN and OUT line to determine direction IN or OUT' (#3) from audrix/zenai-kpc-python:main into main
Reviewed-on: #3
2026-07-14 17:11:07 +07:00
Alberto-Audrix 1920ff183b update counting logic to use IN and OUT line to determine direction IN or OUT 2026-07-14 16:15:46 +07:00
dsutanto a72d35776e Merge pull request 'Merging for deployment update with service' (#2) from audrix/zenai-kpc-python:main into main
Reviewed-on: #2
2026-07-14 08:45:25 +07:00
audrix 3af0b8188f fix small typo 2026-07-14 08:38:29 +07:00
Alberto-Audrix 6071d56dc3 merging to main 2026-07-14 08:34:44 +07:00
Alberto-Audrix d9c04d36c0 Merge branch 'dsutanto-main' to main 2026-07-12 15:08:56 +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
Alberto-Audrix 314b598c66 fix : live view buffering 2026-07-10 13:54:36 +07:00
Alberto-Audrix af2854d771 deployment update 2026-07-10 13:30:13 +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
proitlab 91786b07c1 Clean up every hour EnvVar 2026-07-10 00:20:16 +07:00
proitlab 9b0d90ce71 Clean up every hour 2026-07-10 00:19:28 +07:00
10 changed files with 1495 additions and 244 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.
+241
View File
@@ -0,0 +1,241 @@
# ZenAI KPC Counter — Edge Deployment Guide
Production deployment for **RK3588** (or compatible RKNN NPU) edge devices running:
| Component | Script | systemd unit |
|-----------|--------|--------------|
| RTSP counter (RKNN + ByteTrack) | `counter_live_rknn.py` | `zenai-kpc-counter.service` |
| Web dashboard (Flask) | `counter_dashboard.py` | `zenai-kpc-dashboard.service` |
Both processes share a single `.env` file and read/write the same SQLite database and state JSON.
---
## 1. Prerequisites
### Hardware & OS
- RK3588 board (or Jetson/RK device with RKNN Lite runtime)
- Linux with systemd
- Network access to the RTSP camera stream
### System packages
```bash
sudo apt update
sudo apt install -y python3 python3-venv python3-pip ffmpeg libgl1
```
`ffmpeg` is required for low-latency RTSP capture via OpenCV. `libgl1` is often needed for `opencv-python` on headless systems.
### RKNN model
Export or copy your `.rknn` model to the device, e.g.:
```text
/opt/models/your_model.rknn
```
Set `MODEL_PATH` in `.env` to match. The model class count must match `NUM_CLASSES`, and `OBJECT_CLASS_ID` must point at the class you count.
---
## 2. Directory layout
Default paths used by the service files and `env.example`:
```text
/opt/zenai-kpc-python/ # application code (this repo)
├── counter_live_rknn.py
├── counter_dashboard.py
├── counter_store.py
├── templates/
├── venv/ # Python virtual environment (created during install)
├── .env # runtime config (not in git)
├── env.example # template — copy to .env
└── DEPLOY.md
/opt/zenai-kpc-counter/ # persistent runtime data (created automatically)
├── counter.db # SQLite daily records
├── current_counter.json # live counting-day state
├── snapshots/ # crossing/detect JPEGs (if enabled)
└── crossings.csv # optional per-event CSV
/opt/models/ # RKNN models (deploy separately)
/dev/shm/zenai-kpc-counter/ # live JPEG frame for dashboard video (tmpfs)
```
---
## 3. Install application
### 3.1 Copy code to the device
```bash
sudo mkdir -p /opt/zenai-kpc-python
sudo rsync -av --exclude venv --exclude .env --exclude __pycache__ \
./ /opt/zenai-kpc-python/
# Or: sudo git clone <repo-url> /opt/zenai-kpc-python
```
### 3.2 Create virtual environment and install dependencies
```bash
cd /opt/zenai-kpc-python
sudo python3 -m venv venv
sudo ./venv/bin/pip install --upgrade pip
sudo ./venv/bin/pip install -r requirements.txt
```
> `rknn-toolkit-lite2` is platform-specific. Install on the target ARM device, not on a Windows dev machine.
### 3.3 Create runtime config
```bash
cd /opt/zenai-kpc-python
sudo cp env.example .env
sudo nano .env
```
**Minimum values to edit before starting:**
| Variable | Description |
|----------|-------------|
| `SOURCE` | RTSP URL or local video file path |
| `MODEL_PATH` | Path to your `.rknn` model on device |
| `NUM_CLASSES` | Must match the exported model |
| `OBJECT_CLASS_ID` | Class index of the object being counted |
| `CLASS_OBJECT` / `OBJECT_LABEL` | Labels stored in DB (e.g. `karung`) |
| `LINE_Y1_FRAC` / `LINE_Y2_FRAC` | Counting line positions (tune per camera) |
| `SECRET_KEY` | Random string for Flask sessions |
Ensure `STATE_FILE` and `DB_PATH` both live under `/opt/zenai-kpc-counter/` so data survives reboots (avoid `/tmp` in production).
### 3.4 Create data directories (optional — app creates most paths automatically)
```bash
sudo mkdir -p /opt/zenai-kpc-counter /opt/models /dev/shm/zenai-kpc-counter
```
---
## 4. Install systemd services
```bash
cd /opt/zenai-kpc-python
sudo cp zenai-kpc-counter.service zenai-kpc-dashboard.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable zenai-kpc-counter zenai-kpc-dashboard
sudo systemctl start zenai-kpc-counter
sudo systemctl start zenai-kpc-dashboard
```
The dashboard unit starts **after** the counter unit (`After=zenai-kpc-counter.service`).
### Verify
```bash
systemctl status zenai-kpc-counter
systemctl status zenai-kpc-dashboard
journalctl -u zenai-kpc-counter -f
```
Open the dashboard in a browser:
```text
http://<device-ip>:5000
```
(Port is set by `DASHBOARD_PORT` in `.env`, default `5000`.)
---
## 5. Operations
### Restart after config change
```bash
sudo systemctl restart zenai-kpc-counter
sudo systemctl restart zenai-kpc-dashboard
```
### View logs
```bash
journalctl -u zenai-kpc-counter -n 100 --no-pager
journalctl -u zenai-kpc-dashboard -n 100 --no-pager
```
### Stop services
```bash
sudo systemctl stop zenai-kpc-dashboard zenai-kpc-counter
```
The counter handles `SIGTERM` gracefully — it finishes the current frame, persists state to SQLite, then exits.
### Update application code
```bash
cd /opt/zenai-kpc-python
sudo systemctl stop zenai-kpc-dashboard zenai-kpc-counter
# rsync or git pull new code
sudo ./venv/bin/pip install -r requirements.txt # if dependencies changed
sudo systemctl start zenai-kpc-counter zenai-kpc-dashboard
```
---
## 6. Troubleshooting
| Symptom | Things to check |
|---------|-----------------|
| Counter won't start | `journalctl -u zenai-kpc-counter`; verify `MODEL_PATH` exists; RKNN drivers installed |
| No RTSP frames | Ping camera; test with `ffplay <SOURCE>`; check `OPENCV_FFMPEG_CAPTURE_OPTIONS` |
| Dashboard shows 0 count | `STATE_FILE` in `.env` must match between counter and dashboard; check file exists |
| Live video blank | `LIVE_STREAM_ENABLED=true`; path matches `LIVE_STREAM_FRAME_PATH` in both processes; if using nginx, set `proxy_read_timeout` (see §7) |
| Wrong counts | Tune `LINE_Y1_FRAC`/`LINE_Y2_FRAC`, `CONF`, ByteTrack thresholds; enable `DEBUG_TRACKING=true` temporarily |
| Service keeps restarting | `journalctl -u zenai-kpc-counter -e`; often missing model, bad RTSP URL, or venv not created |
### Manual test (without systemd)
```bash
cd /opt/zenai-kpc-python
source venv/bin/activate
python counter_live_rknn.py # terminal 1
python counter_dashboard.py # terminal 2
```
---
## 7. Optional: reverse proxy
For HTTPS or port 80 access, put nginx in front of the dashboard:
```nginx
server {
listen 80;
server_name counter.example.com;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off; # required for /api/live-video MJPEG stream
proxy_read_timeout 3600s; # keep long-lived MJPEG connections open
proxy_send_timeout 3600s;
}
}
```
The live JPEG at `LIVE_STREAM_FRAME_PATH` can also be served statically by nginx if you prefer not to use the Flask MJPEG endpoint.
---
## 8. Security notes
- Change `SECRET_KEY` from the default before exposing the dashboard on a network.
- Services currently run as `root` for simplicity on edge devices. For hardened deployments, create a dedicated user, chown `/opt/zenai-kpc-counter`, and update the `User=` / `Group=` lines in the service files.
- Do not commit `.env` — it may contain RTSP credentials.
- Set `FLASK_DEBUG=false` in production.
+50 -8
View File
@@ -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
@@ -87,9 +92,11 @@ CLASS_OBJECT=object
OBJECT_CLASS_ID=0
# --- Line crossing ---
# Two horizontal counting lines:
# Line 1 (default ~33%): counts top-to-down (IN)
# Line 2 (default ~66%): counts bottom-to-up (OUT)
# Two horizontal counting lines with sequence logic:
# Line 1 (IN): top→down counts IN (OUT→IN sequence, or IN-only)
# Line 2 (OUT): bottom→up counts OUT (IN→OUT sequence, or OUT-only)
# Passing the opposite line first only arms; the destination line still counts
# even if the opposite line was never crossed.
# Fixed y-coordinate for line 1/IN (overrides LINE_Y1_FRAC if set)
LINE_Y1=
# Fraction of frame height for line 1 (default 0.33)
@@ -138,12 +145,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
@@ -183,4 +225,4 @@ DASHBOARD_PORT=5000
# Enable Flask debug mode (true/false) — auto-reloads on code changes; disable in production
FLASK_DEBUG=false
# Fallback name for the active counting-day JSON state file used by the dashboard
CURRENT_COUNTER_PATH=/tmp/bytetrack_current_counter.json
CURRENT_COUNTER_PATH=/tmp/bytetrack_current_counter.json
+68 -5
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")
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"
@@ -475,4 +538,4 @@ if __name__ == "__main__":
print(f"Jetson counter dashboard at http://{DASHBOARD_HOST}:{DASHBOARD_PORT}")
print(f"DB: {DB_PATH}")
print(f"State: {CURRENT_COUNTER_PATH}")
app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG)
app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG)
+310 -47
View File
@@ -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
@@ -84,7 +88,7 @@ CROSS_SNAPSHOT_QUALITY = int(os.getenv("CROSS_SNAPSHOT_QUALITY", "85"))
CROSS_SNAPSHOT_MAX_FILES = int(os.getenv("CROSS_SNAPSHOT_MAX_FILES", "1000"))
CROSS_SNAPSHOT_MAX_AGE_DAYS = float(os.getenv("CROSS_SNAPSHOT_MAX_AGE_DAYS", "7"))
# Run the cleanup sweep at most every N seconds to limit filesystem scans.
CROSS_SNAPSHOT_CLEANUP_SEC = int(os.getenv("CROSS_SNAPSHOT_CLEANUP_SEC", "60"))
CROSS_SNAPSHOT_CLEANUP_SEC = int(os.getenv("CROSS_SNAPSHOT_CLEANUP_SEC", "3600"))
RATE_WINDOW_SEC = int(os.getenv("RATE_WINDOW_SEC", "60"))
WARMUP_FRAMES = int(os.getenv("WARMUP_FRAMES", "30"))
@@ -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
@@ -667,8 +694,9 @@ def is_duplicate_cross(recent, cx, frame_idx):
def _inherit_prev(tracked, new_tid, cx, cy, mono, max_age, max_px):
"""Find a recently-seen nearby track and return its (cx, cy, mono) so a new
track ID can inherit the trajectory across an ID switch. Returns None if none.
"""Find a recently-seen nearby track for ID-switch continuation.
Returns (cx, cy, mono, source_tid) or None.
The predecessor must be close in BOTH x and y (same physical object at the same
spot). Matching on x only would let a new track inherit a far-away y, seeding a
@@ -685,14 +713,118 @@ def _inherit_prev(tracked, new_tid, cx, cy, mono, max_age, max_px):
dist = abs(tcx - cx)
if dist <= best_dist:
best_dist = dist
best = (tcx, tcy, ts)
best = (tcx, tcy, ts, tid)
return best
def _default_cross_state():
"""Per-track line-crossing state for sequence counting.
seen_out / seen_in: ever registered a pass of that line (arm or count).
counted: already contributed one IN or OUT (at most one per physical object).
"""
return {"seen_out": False, "seen_in": False, "counted": False}
def _copy_cross_state(src):
return {
"seen_out": bool(src.get("seen_out", False)),
"seen_in": bool(src.get("seen_in", False)),
"counted": bool(src.get("counted", False)),
}
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 +1179,7 @@ def run():
core_mask=CORE_MASK,
imgsz=IMGSZ,
conf=CONF,
iou=NMS_IOU,
num_classes=NUM_CLASSES,
score_sigmoid=SCORE_SIGMOID,
)
@@ -1061,8 +1194,9 @@ def run():
min_hits=TRACK_MIN_HITS,
)
object_line1_crossed = set()
object_line2_crossed = set()
# Per-track sequence state: OUT→IN / IN-only → IN; IN→OUT / OUT-only → OUT.
# Keys survive ID switches via inheritance (see _inherit_prev).
object_cross_state = {}
recent_cross_in = deque()
recent_cross_out = deque()
@@ -1078,12 +1212,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()
@@ -1093,7 +1245,9 @@ def run():
line_y1 = resolve_line_y1(h)
line_y2 = resolve_line_y2(h)
print(
f"RKNN+ByteTrack counter | {w}x{h} @ {fps}fps | line1 y={line_y1} (in v) line2 y={line_y2} (out ^)"
f"RKNN+ByteTrack counter | {w}x{h} @ {fps}fps | "
f"line1 y={line_y1} (IN ↓) line2 y={line_y2} (OUT ↑) | "
f"seq: OUT→IN/IN-only→in, IN→OUT/OUT-only→out"
)
print(f"Model: {MODEL_PATH} | imgsz={IMGSZ} | core_mask={CORE_MASK}")
print(
@@ -1133,20 +1287,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 = []
@@ -1188,16 +1357,24 @@ def run():
if object_det_to_track
else ""
)
crossing_str1 = (
f" line1_crossed: {sorted(object_line1_crossed)}"
if object_line1_crossed
else ""
)
crossing_str2 = (
f" line2_crossed: {sorted(object_line2_crossed)}"
if object_line2_crossed
else ""
)
crossing_str1 = ""
crossing_str2 = ""
if object_cross_state:
seen_in_ids = sorted(
tid for tid, st in object_cross_state.items() if st.get("seen_in")
)
seen_out_ids = sorted(
tid for tid, st in object_cross_state.items() if st.get("seen_out")
)
counted_ids = sorted(
tid for tid, st in object_cross_state.items() if st.get("counted")
)
if seen_in_ids:
crossing_str1 = f" seen_in: {seen_in_ids}"
if seen_out_ids:
crossing_str2 = f" seen_out: {seen_out_ids}"
if counted_ids:
crossing_str2 += f" counted: {counted_ids}"
print(
f"[DEBUG F{frame_idx}] dets={len(object_boxes_xyxy)} "
f"tracks={len(object_track_map)} "
@@ -1219,36 +1396,92 @@ def run():
detect_snapshot_ids.add(tid)
detect_events_frame.append(tid)
inherited_this_frame = False
if tid not in object_tracked:
inherited = _inherit_prev(
object_tracked, tid, cx, cy, mono, INHERIT_SEC, INHERIT_PX
)
if inherited is not None:
object_tracked[tid] = inherited
object_tracked[tid] = inherited[:3]
src_tid = inherited[3]
if src_tid in object_cross_state:
object_cross_state[tid] = _copy_cross_state(
object_cross_state[src_tid]
)
inherited_this_frame = True
if os.getenv("DEBUG_TRACKING", "").lower() == "true":
src_st = object_cross_state.get(tid, {})
print(
f"[DEBUG F{frame_idx}] INHERIT prev for new tid={tid} "
f"from ({inherited[0]:.1f},{inherited[1]:.1f})"
f"from tid={src_tid} ({inherited[0]:.1f},{inherited[1]:.1f})"
f" seen_in={src_st.get('seen_in', False)}"
f" seen_out={src_st.get('seen_out', False)}"
f" counted={src_st.get('counted', False)}"
)
if tid not in object_cross_state:
object_cross_state[tid] = _default_cross_state()
st = object_cross_state[tid]
if tid in object_tracked:
prev_cy = object_tracked[tid][1]
crossed1 = (
crossed_top_down(prev_cy, cy, line_y1)
and tid not in object_line1_crossed
# Line 1 = IN, line 2 = OUT.
# Arm (no count): OUT top→down, IN bottom→up.
# Count: IN top→down → IN (OUT→IN or IN-only)
# OUT bottom→up → OUT (IN→OUT or OUT-only)
out_down = (
crossed_top_down(prev_cy, cy, line_y2) and not st["seen_out"]
)
crossed2 = (
crossed_bottom_up(prev_cy, cy, line_y2)
and tid not in object_line2_crossed
in_up = (
crossed_bottom_up(prev_cy, cy, line_y1) and not st["seen_in"]
)
if crossed1 or crossed2:
direction = "in" if crossed1 else "out"
recent = recent_cross_in if crossed1 else recent_cross_out
in_down = (
crossed_top_down(prev_cy, cy, line_y1) and not st["seen_in"]
)
out_up = (
crossed_bottom_up(prev_cy, cy, line_y2) and not st["seen_out"]
)
if out_down or in_up or in_down or out_up:
if out_down:
st["seen_out"] = True
if in_up:
st["seen_in"] = True
if in_down:
st["seen_in"] = True
if out_up:
st["seen_out"] = True
# Prefer decisive motion if both count triggers fire in one jump.
count_in = in_down and not st["counted"]
count_out = out_up and not st["counted"]
if count_in and count_out:
if cy >= prev_cy:
count_out = False
else:
count_in = False
direction = "in" if count_in else ("out" if count_out else None)
if direction is None:
# Arm-only pass (OUT↓ or IN↑). No count yet.
if os.getenv("DEBUG_TRACKING", "").lower() == "true":
arm = []
if out_down:
arm.append("out_down")
if in_up:
arm.append("in_up")
print(
f"[DEBUG F{frame_idx}] CROSS ARM: tid={tid} "
f"prev_cy={prev_cy:.1f} -> cy={cy:.1f} "
f"arm={'+'.join(arm)} "
f"seen_in={st['seen_in']} seen_out={st['seen_out']}"
)
object_tracked[tid] = (cx, cy, mono)
continue
recent = recent_cross_in if direction == "in" else recent_cross_out
if is_duplicate_cross(recent, cx, frame_idx):
if crossed1:
object_line1_crossed.add(tid)
if crossed2:
object_line2_crossed.add(tid)
st["counted"] = True
if os.getenv("DEBUG_TRACKING", "").lower() == "true":
print(
f"[DEBUG F{frame_idx}] DUP CROSS IGNORED: tid={tid} "
@@ -1256,20 +1489,34 @@ def run():
)
object_tracked[tid] = (cx, cy, mono)
continue
seq = (
"out→in"
if direction == "in" and st["seen_out"]
else (
"in→out"
if direction == "out" and st["seen_in"]
else ("in-only" if direction == "in" else "out-only")
)
)
if os.getenv("DEBUG_TRACKING", "").lower() == "true":
line_label = "1" if crossed1 else "2"
line_y_val = line_y1 if crossed1 else line_y2
print(
f"[DEBUG F{frame_idx}] CROSS DETECTED: tid={tid} "
f"prev_cy={prev_cy:.1f} -> cy={cy:.1f} line={line_label} y={line_y_val} dir={direction}"
f"prev_cy={prev_cy:.1f} -> cy={cy:.1f} dir={direction} "
f"seq={seq}"
+ (
" (inherit)"
if inherited_this_frame
else ""
)
)
recent.append((frame_idx, cx))
if crossed1:
object_line1_crossed.add(tid)
st["counted"] = True
if direction == "in":
counter_in += 1
count_in_pulse = COUNT_PULSE_FRAMES
if crossed2:
object_line2_crossed.add(tid)
else:
counter_out += 1
count_out_pulse = COUNT_PULSE_FRAMES
store.record_object_crossing(tid, direction)
@@ -1292,7 +1539,7 @@ def run():
"x": int(cx) - 12,
"y": int(cy),
"born": frame_idx,
"text": f"+1" if not (crossed1 and crossed2) else "x2",
"text": "+1",
}
)
object_tracked[tid] = (cx, cy, mono)
@@ -1350,6 +1597,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 +1622,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 +1676,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()
@@ -1429,4 +1692,4 @@ object_tracked = {}
if __name__ == "__main__":
run()
run()
+11 -5
View File
@@ -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
@@ -68,9 +68,11 @@ CLASS_OBJECT=karung
OBJECT_CLASS_ID=0
# --- Line crossing ---
# Two horizontal counting lines:
# Line 1 (default ~33%): counts top-to-down (IN)
# Line 2 (default ~66%): counts bottom-to-up (OUT)
# Two horizontal counting lines with sequence logic:
# Line 1 (IN): top→down counts IN (OUT→IN sequence, or IN-only)
# Line 2 (OUT): bottom→up counts OUT (IN→OUT sequence, or OUT-only)
# Passing the opposite line first only arms; the destination line still counts
# even if the opposite line was never crossed.
# Fixed y-coordinate for line 1/IN (overrides LINE_Y1_FRAC if set)
LINE_Y1=
# Fraction of frame height for line 1 (default 0.33)
@@ -97,7 +99,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
@@ -144,3 +146,7 @@ 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
+83 -179
View File
@@ -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 &minus; 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 &minus; 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 &minus; 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 &minus; 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;">&#9673; Crossing Snapshots</span>
<div style="font-size:10px;color:var(--text-secondary);letter-spacing:1px;">Detected & counted frames — correlate by track ID</div>
</div>
<div class="toolbar-group">
<div class="chart-filters" id="snap-filters">
<button class="active" data-kind="all" onclick="loadSnapshots('all')">ALL</button>
<button data-kind="in" onclick="loadSnapshots('in')">IN</button>
<button data-kind="out" onclick="loadSnapshots('out')">OUT</button>
{% if show_detect %}<button data-kind="detect" onclick="loadSnapshots('detect')">DETECT</button>{% endif %}
</div>
<input type="date" id="snap-date-filter">
<button class="btn btn-primary" onclick="applySnapFilter()">FILTER</button>
<button class="btn btn-ghost" onclick="resetSnapFilter()">RESET</button>
</div>
</div>
<div id="snapshot-grid" class="snapshot-grid">
<!-- Populated by JS -->
</div>
<div id="snapshot-empty" style="display:none;text-align:center;padding:32px;color:var(--text-secondary);">
<div style="font-size:32px;opacity:0.2;margin-bottom:8px;">&#9635;</div>
<div style="font-size:10px;letter-spacing:1px;">NO SNAPSHOTS AVAILABLE</div>
<div style="font-size:9px;margin-top:6px;opacity:0.7;">Enable SAVE_CROSS_SNAPSHOT{% if show_detect %} / SAVE_DETECT_SNAPSHOT{% endif %} in counter .env</div>
</div>
</div>
<div class="footer">Edge Nano Counter by ZenAi</div>
</div>
<!-- Snapshot lightbox -->
<div class="modal-overlay hidden" id="snap-modal" onclick="closeSnapModal(event)">
<div class="snap-lightbox" id="snap-lightbox">
<button class="close-btn" onclick="closeSnapModal()" style="position:absolute;top:12px;right:12px;z-index:2;">&times;</button>
<img id="snap-modal-img" alt="Snapshot" />
<div class="snap-modal-meta" id="snap-modal-meta">--</div>
</div>
</div>
<script>
// --- Theme ---
(function() {
@@ -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() {
@@ -1313,4 +1217,4 @@ function hideVideoError() {
</script>
</body>
</html>
</html>
+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>
+32
View File
@@ -0,0 +1,32 @@
[Unit]
Description=ZenAI KPC Edge Counter (RTSP + RKNN)
Documentation=file:///opt/zenai-kpc-python/DEPLOY.md
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/opt/zenai-kpc-python
EnvironmentFile=/opt/zenai-kpc-python/.env
Environment=PYTHONNOUSERSITE=1
Environment=PATH=/opt/zenai-kpc-python/venv/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/opt/zenai-kpc-python/venv/bin/python counter_live_rknn.py
TimeoutStopSec=30
KillSignal=SIGTERM
Restart=always
RestartSec=10
StartLimitInterval=120s
StartLimitBurst=5
NoNewPrivileges=true
ProtectHome=true
PrivateTmp=false
[Install]
WantedBy=multi-user.target
+32
View File
@@ -0,0 +1,32 @@
[Unit]
Description=ZenAI KPC Dashboard (Flask)
Documentation=file:///opt/zenai-kpc-python/DEPLOY.md
After=network-online.target zenai-kpc-counter.service
Wants=network-online.target
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/opt/zenai-kpc-python
EnvironmentFile=/opt/zenai-kpc-python/.env
Environment=PATH=/opt/zenai-kpc-python/venv/bin:/usr/local/bin:/usr/bin:/bin
Environment=FLASK_DEBUG=false
ExecStart=/opt/zenai-kpc-python/venv/bin/python counter_dashboard.py
TimeoutStopSec=15
KillSignal=SIGTERM
Restart=always
RestartSec=5
StartLimitInterval=60s
StartLimitBurst=3
NoNewPrivileges=true
ProtectHome=true
PrivateTmp=false
[Install]
WantedBy=multi-user.target