Compare commits
15
Commits
91786b07c1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25f3baddc9 | ||
|
|
1920ff183b | ||
|
|
a72d35776e | ||
|
|
3af0b8188f | ||
|
|
6071d56dc3 | ||
|
|
d9c04d36c0 | ||
|
|
8f7ecb4321 | ||
|
|
6afffb2184 | ||
|
|
d93ee99c1f | ||
|
|
314b598c66 | ||
|
|
af2854d771 | ||
|
|
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.
|
||||
@@ -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.
|
||||
+49
-7
@@ -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
|
||||
|
||||
+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"
|
||||
|
||||
+305
-42
@@ -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
|
||||
@@ -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_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")
|
||||
)
|
||||
crossing_str2 = (
|
||||
f" line2_crossed: {sorted(object_line2_crossed)}"
|
||||
if object_line2_crossed
|
||||
else ""
|
||||
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:
|
||||
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()
|
||||
|
||||
|
||||
+10
-5
@@ -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
|
||||
@@ -145,3 +147,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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user