first commit
This commit is contained in:
commit
f7ef038def
14 files changed
+5558
No files matched your search
+220
@@ -0,0 +1,220 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py.cover
|
||||
*.lcov
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
# Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
# poetry.lock
|
||||
# poetry.toml
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||
# pdm.lock
|
||||
# pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# pixi
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||
# pixi.lock
|
||||
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||
.pixi/*
|
||||
!.pixi/config.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule*
|
||||
celerybeat.pid
|
||||
|
||||
# Redis
|
||||
*.rdb
|
||||
*.aof
|
||||
*.pid
|
||||
|
||||
# RabbitMQ
|
||||
mnesia/
|
||||
rabbitmq/
|
||||
rabbitmq-data/
|
||||
|
||||
# ActiveMQ
|
||||
activemq-data/
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.envrc
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
# .idea/
|
||||
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
# .vscode/
|
||||
# Temporary file for partial code execution
|
||||
tempCodeRunnerFile.py
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Marimo
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# Streamlit
|
||||
.streamlit/secrets.toml
|
||||
+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/zenai-ktc-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,272 @@
|
||||
# ZenAI KTC Counter — Edge Deployment Guide
|
||||
|
||||
Production deployment for **RK3588** (or compatible RKNN NPU) edge devices running a
|
||||
**zone-based sack feeder counter** (left / right feeders):
|
||||
|
||||
| Component | Script | systemd unit |
|
||||
|-----------|--------|--------------|
|
||||
| RTSP counter (RKNN + ByteTrack + zones) | `counter_live_rknn.py` | `zenai-ktc-counter.service` |
|
||||
| Web dashboard (Flask) | `counter_dashboard.py` | `zenai-ktc-dashboard.service` |
|
||||
|
||||
Both processes share a single `.env` file and read/write the same SQLite database and state JSON.
|
||||
|
||||
**Counting model:** two rectangular zones (left + right). A sack is counted once when its
|
||||
centroid **enters** a zone. Left zone → left feeder; right zone → right feeder.
|
||||
|
||||
---
|
||||
|
||||
## 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-ktc-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-ktc-counter/ # persistent runtime data (created automatically)
|
||||
├── counter.db # SQLite daily records
|
||||
├── current_counter.json # live counting-day state
|
||||
├── snapshots/ # zone-entry/detect JPEGs (if enabled)
|
||||
└── crossings.csv # optional per-event CSV
|
||||
|
||||
/opt/models/ # RKNN models (deploy separately)
|
||||
/dev/shm/zenai-ktc-counter/ # live JPEG frame for dashboard video (tmpfs)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Install application
|
||||
|
||||
### 3.1 Copy code to the device
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/zenai-ktc-python
|
||||
sudo rsync -av --exclude venv --exclude .env --exclude __pycache__ \
|
||||
./ /opt/zenai-ktc-python/
|
||||
# Or: sudo git clone <repo-url> /opt/zenai-ktc-python
|
||||
```
|
||||
|
||||
### 3.2 Create virtual environment and install dependencies
|
||||
|
||||
```bash
|
||||
cd /opt/zenai-ktc-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-ktc-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`) |
|
||||
| `ZONE_LEFT_*_FRAC` / `ZONE_RIGHT_*_FRAC` | Feeder zone rectangles (tune per camera) |
|
||||
| `SECRET_KEY` | Random string for Flask sessions |
|
||||
|
||||
Ensure `STATE_FILE` and `DB_PATH` both live under `/opt/zenai-ktc-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-ktc-counter /opt/models /dev/shm/zenai-ktc-counter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Install systemd services
|
||||
|
||||
```bash
|
||||
cd /opt/zenai-ktc-python
|
||||
sudo cp zenai-ktc-counter.service zenai-ktc-dashboard.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable zenai-ktc-counter zenai-ktc-dashboard
|
||||
sudo systemctl start zenai-ktc-counter
|
||||
sudo systemctl start zenai-ktc-dashboard
|
||||
```
|
||||
|
||||
The dashboard unit starts **after** the counter unit (`After=zenai-ktc-counter.service`).
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
systemctl status zenai-ktc-counter
|
||||
systemctl status zenai-ktc-dashboard
|
||||
journalctl -u zenai-ktc-counter -f
|
||||
```
|
||||
|
||||
Open the dashboard in a browser:
|
||||
|
||||
```text
|
||||
http://<device-ip>:5000
|
||||
```
|
||||
|
||||
(Port is set by `DASHBOARD_PORT` in `.env`, default `5000`.)
|
||||
|
||||
---
|
||||
|
||||
## 5. Tuning feeder zones
|
||||
|
||||
Zones are axis-aligned rectangles. Defaults cover the left and right sides of the frame:
|
||||
|
||||
```ini
|
||||
ZONE_LEFT_X1_FRAC=0.00
|
||||
ZONE_LEFT_Y1_FRAC=0.20
|
||||
ZONE_LEFT_X2_FRAC=0.35
|
||||
ZONE_LEFT_Y2_FRAC=0.85
|
||||
|
||||
ZONE_RIGHT_X1_FRAC=0.65
|
||||
ZONE_RIGHT_Y1_FRAC=0.20
|
||||
ZONE_RIGHT_X2_FRAC=1.00
|
||||
ZONE_RIGHT_Y2_FRAC=0.85
|
||||
```
|
||||
|
||||
For pixel-perfect placement, set absolute coordinates instead (they override fractions):
|
||||
|
||||
```ini
|
||||
ZONE_LEFT_X1=40
|
||||
ZONE_LEFT_Y1=120
|
||||
ZONE_LEFT_X2=420
|
||||
ZONE_LEFT_Y2=900
|
||||
```
|
||||
|
||||
Enable `DEBUG_TRACKING=true` temporarily to log zone entries in the journal.
|
||||
|
||||
---
|
||||
|
||||
## 6. Operations
|
||||
|
||||
### Restart after config change
|
||||
|
||||
```bash
|
||||
sudo systemctl restart zenai-ktc-counter
|
||||
sudo systemctl restart zenai-ktc-dashboard
|
||||
```
|
||||
|
||||
### View logs
|
||||
|
||||
```bash
|
||||
journalctl -u zenai-ktc-counter -n 100 --no-pager
|
||||
journalctl -u zenai-ktc-dashboard -n 100 --no-pager
|
||||
```
|
||||
|
||||
### Stop services
|
||||
|
||||
```bash
|
||||
sudo systemctl stop zenai-ktc-dashboard zenai-ktc-counter
|
||||
```
|
||||
|
||||
The counter handles `SIGTERM` gracefully — it finishes the current frame, persists state to SQLite, then exits.
|
||||
|
||||
### Update application code
|
||||
|
||||
```bash
|
||||
cd /opt/zenai-ktc-python
|
||||
sudo systemctl stop zenai-ktc-dashboard zenai-ktc-counter
|
||||
# rsync or git pull new code
|
||||
sudo ./venv/bin/pip install -r requirements.txt # if dependencies changed
|
||||
sudo systemctl start zenai-ktc-counter zenai-ktc-dashboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Troubleshooting
|
||||
|
||||
| Symptom | Things to check |
|
||||
|---------|-----------------|
|
||||
| Counter won't start | `journalctl -u zenai-ktc-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 |
|
||||
| Wrong feeder / missed counts | Tune `ZONE_*_FRAC`, `CONF`, ByteTrack thresholds; enable `DEBUG_TRACKING=true` |
|
||||
| Service keeps restarting | `journalctl -u zenai-ktc-counter -e`; often missing model, bad RTSP URL, or venv not created |
|
||||
|
||||
### Manual test (without systemd)
|
||||
|
||||
```bash
|
||||
cd /opt/zenai-ktc-python
|
||||
source venv/bin/activate
|
||||
python counter_live_rknn.py # terminal 1
|
||||
python counter_dashboard.py # terminal 2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 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-ktc-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.
|
||||
@@ -0,0 +1,245 @@
|
||||
# =============================================================================
|
||||
# ZenAI KTC — Edge RK3588 sack feeder zone counter + dashboard
|
||||
# Shared config for: counter_live_rknn.py + counter_dashboard.py
|
||||
# Copy to .env on device: cp config.env.example .env && nano .env
|
||||
# =============================================================================
|
||||
|
||||
# --- Core paths ---
|
||||
# Root output directory (logs, DB, video, CSV)
|
||||
OUTPUT_DIR=/opt/zenai-ktc-counter
|
||||
# SQLite database path for daily counter records & zone-entry logs
|
||||
DB_PATH=/opt/zenai-ktc-counter/ktc_counter.db
|
||||
# JSON file persisting the current active counting day state
|
||||
STATE_FILE=/opt/zenai-ktc-counter/current_counter.json
|
||||
|
||||
# --- Input source ---
|
||||
# RTSP / HTTP live stream, or a local video file path
|
||||
SOURCE=rtsp://user:pass@192.168.0.100:554/stream1
|
||||
# FFmpeg capture options passed to cv2.VideoCapture (RTSP low-latency flags)
|
||||
OPENCV_FFMPEG_CAPTURE_OPTIONS=rtsp_transport;tcp|fflags;nobuffer|flags;low_delay
|
||||
|
||||
# --- RKNN model ---
|
||||
# Path to exported .rknn model (YOLO format, e.g. yolo11n.rknn)
|
||||
MODEL_PATH=/opt/models/yolo9t.rknn
|
||||
# Input image size for the model (square, e.g. 320 → 320×320)
|
||||
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
|
||||
# Compute device index (reserved; not used at runtime)
|
||||
DEVICE=0
|
||||
|
||||
# --- YOLO decoder ---
|
||||
# Number of object classes the model outputs
|
||||
NUM_CLASSES=2
|
||||
# Apply sigmoid to raw class scores (true/false); set true if model head uses BCE logits
|
||||
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
|
||||
TRACK_HIGH_THRESH=0.5
|
||||
# Detections with score between this and TRACK_HIGH_THRESH are matched in the second stage
|
||||
TRACK_LOW_THRESH=0.1
|
||||
# IoU threshold for the first-stage association (0–1). Higher = stricter overlap required
|
||||
TRACK_MATCH_THRESH=0.8
|
||||
# Frames a track survives without a match before being permanently removed
|
||||
TRACK_BUFFER=30
|
||||
# Minimum consecutive (or total) hits needed before a track is considered confirmed
|
||||
TRACK_MIN_HITS=3
|
||||
|
||||
# --- ID-switch counting guards ---
|
||||
# When a track's ID changes right at the counting line, one physical object can be
|
||||
# counted twice (two IDs cross) or missed (neither ID sees the full transition).
|
||||
# These two guards correct for that.
|
||||
#
|
||||
# Dedup guard (prevents double counting): after a zone entry, a second entry in
|
||||
# the SAME feeder within DEDUP_FRAMES frames and DEDUP_PX centroid pixels is
|
||||
# ignored (treated as the same object under a new ID).
|
||||
DEDUP_FRAMES=15
|
||||
DEDUP_PX=60
|
||||
# To DISABLE the dedup guard, set DEDUP_PX=-1 (distance check can never match).
|
||||
#
|
||||
# Inheritance guard (prevents missed counting): when a brand-new track appears, it
|
||||
# inherits the last position of a recently-seen nearby track (within INHERIT_SEC
|
||||
# seconds and INHERIT_PX horizontal pixels) so zone entry is still detected
|
||||
# across the ID switch.
|
||||
INHERIT_SEC=1.0
|
||||
INHERIT_PX=60
|
||||
# To DISABLE the inheritance guard, set INHERIT_PX=-1 (distance check can never match).
|
||||
|
||||
# --- Feeder cooldown (seconds) ---
|
||||
# After a sack is counted on a feeder, ignore further counts on that same feeder
|
||||
# for this many seconds. Useful when multiple detections/IDs fire for one sack.
|
||||
# 0 = disabled.
|
||||
ZONE_COOLDOWN_LEFT_SEC=3
|
||||
ZONE_COOLDOWN_RIGHT_SEC=3
|
||||
|
||||
# --- Display ---
|
||||
# Site name shown on the dashboard header (top-right)
|
||||
SITE_NAME=ZenAi
|
||||
|
||||
# --- Object class names ---
|
||||
# Camera / location identifier shown in HUD and stored in DB
|
||||
CAMERA_NAME=ZenAi
|
||||
# Label used for batch grouping in the database
|
||||
OBJECT_LABEL=object
|
||||
# Class name for the counted object (must match model class order)
|
||||
CLASS_OBJECT=object
|
||||
# Model class ID for the object being counted (default 0)
|
||||
OBJECT_CLASS_ID=0
|
||||
|
||||
# --- Feeder zones (left / right) ---
|
||||
# Rectangular zones mark the left and right sack feeders.
|
||||
# Count once when a track centroid ENTERS a zone (outside → inside).
|
||||
# Absolute pixel overrides (ZONE_*_X1/Y1/X2/Y2) win over fractions when set.
|
||||
#
|
||||
# Left feeder zone (default: left third of the frame)
|
||||
ZONE_LEFT_X1=
|
||||
ZONE_LEFT_Y1=
|
||||
ZONE_LEFT_X2=
|
||||
ZONE_LEFT_Y2=
|
||||
ZONE_LEFT_X1_FRAC=0.02
|
||||
ZONE_LEFT_Y1_FRAC=0.10
|
||||
ZONE_LEFT_X2_FRAC=0.440
|
||||
ZONE_LEFT_Y2_FRAC=1.00
|
||||
#
|
||||
# Right feeder zone (full right column)
|
||||
ZONE_RIGHT_X1=
|
||||
ZONE_RIGHT_Y1=
|
||||
ZONE_RIGHT_X2=
|
||||
ZONE_RIGHT_Y2=
|
||||
ZONE_RIGHT_X1_FRAC=0.490
|
||||
ZONE_RIGHT_Y1_FRAC=0.10
|
||||
ZONE_RIGHT_X2_FRAC=0.98
|
||||
ZONE_RIGHT_Y2_FRAC=1.00
|
||||
|
||||
# --- Counting day management ---
|
||||
# Daily cutoff time (HH:MM) – a new counting day starts after this time and the
|
||||
# previous day's counter_left / counter_right totals are finalized in the database.
|
||||
# CUTOFF_TIME is an alias used by the dashboard; DAILY_CUTOFF_TIME takes priority in counter_live_rknn.py.
|
||||
DAILY_CUTOFF_TIME=17:00
|
||||
CUTOFF_TIME=17:00
|
||||
|
||||
# --- CSV export ---
|
||||
# Write per-crossing events to a CSV file (true/false)
|
||||
EXPORT_CSV=true
|
||||
# Path where the crossing CSV is written
|
||||
CROSS_CSV=/opt/batch-counter/crossings.csv
|
||||
|
||||
# --- Crossing snapshots ---
|
||||
# Save an annotated frame image every time an object enters a feeder zone and the
|
||||
# counter increases (true/false, default: false). Written to <DIR>/cross/
|
||||
# (filename: <YYYYmmdd_HHMMSS_mmm>_<left|right>_id<track>_f<frame>.jpg)
|
||||
SAVE_CROSS_SNAPSHOT=false
|
||||
# Also save one snapshot the first time each object is detected, before it enters
|
||||
# a zone (true/false, default: false). Written to <DIR>/detect/ with the same track id so
|
||||
# it can be correlated with the zone-entry snapshot
|
||||
# (filename: <YYYYmmdd_HHMMSS_mmm>_detect_id<track>_f<frame>.jpg)
|
||||
SAVE_DETECT_SNAPSHOT=false
|
||||
# Base directory for snapshots (detect/ and cross/ subfolders are created inside).
|
||||
# The dashboard reads this same path to display the snapshot gallery, so keep it
|
||||
# identical for both the counter and the dashboard.
|
||||
CROSS_SNAPSHOT_DIR=/opt/batch-counter/snapshots
|
||||
# JPEG quality for snapshots (1-100)
|
||||
CROSS_SNAPSHOT_QUALITY=85
|
||||
# Retention: keep at most this many snapshot files (detect + cross combined);
|
||||
# oldest are deleted first (0 = unlimited)
|
||||
CROSS_SNAPSHOT_MAX_FILES=1000
|
||||
# Retention: delete snapshots older than this many days (0 = never by age)
|
||||
CROSS_SNAPSHOT_MAX_AGE_DAYS=7
|
||||
# Run the cleanup sweep at most once every N seconds
|
||||
CROSS_SNAPSHOT_CLEANUP_SEC=60
|
||||
|
||||
# --- Rate / performance ---
|
||||
# Enable motion detection pre-filter: skip inference on frames with no movement
|
||||
# (true/false, default: false), 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
|
||||
# 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
|
||||
WARMUP_FRAMES=30
|
||||
# Delay in seconds between stream reconnection attempts
|
||||
RECONNECT_DELAY_SEC=3
|
||||
# Maximum reconnection attempts (0 = infinite)
|
||||
MAX_RECONNECT_ATTEMPTS=0
|
||||
# Seconds after which a tracked but unseen object is pruned from the active set
|
||||
TRACKED_PRUNE_SEC=300
|
||||
|
||||
# --- Video recording ---
|
||||
# Save annotated frames to segmented MP4 files (true/false)
|
||||
RECORD_VIDEO=false
|
||||
# Duration in seconds of each video segment file
|
||||
VIDEO_SEGMENT_SEC=3600
|
||||
# Output video FPS (fallback if source FPS is unknown or ≤ 1)
|
||||
OUTPUT_FPS=15
|
||||
|
||||
# --- Live stream snapshot ---
|
||||
# Periodically write the latest annotated frame as JPEG for an external web server
|
||||
LIVE_STREAM_ENABLED=false
|
||||
# Path to the shared-memory snapshot file (served by nginx / lighttpd)
|
||||
LIVE_STREAM_FRAME_PATH=/dev/shm/zenai-ktc-counter/live_frame.jpg
|
||||
# JPEG quality (1–100)
|
||||
LIVE_STREAM_QUALITY=75
|
||||
# Write the snapshot every N frames (lower = more frequent updates)
|
||||
LIVE_STREAM_EVERY_N=2
|
||||
|
||||
# --- Dashboard (counter_dashboard.py) ---
|
||||
# Flask secret key for session/cookie signing — change in production!
|
||||
SECRET_KEY=change-me-in-production
|
||||
# Bind address for the Flask web server
|
||||
DASHBOARD_HOST=0.0.0.0
|
||||
# Listen port for the dashboard web UI
|
||||
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=/opt/zenai-ktc-counter/current_counter.json
|
||||
@@ -0,0 +1,770 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Edge production counter dashboard.
|
||||
Reads counter.db + current_counter.json from the counter stack.
|
||||
Tracks daily left/right feeder sack counts per counting day (no batches).
|
||||
Default port 5000.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||
|
||||
from flask import Flask, render_template, jsonify, request, Response, send_file
|
||||
from werkzeug.serving import WSGIRequestHandler
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
|
||||
app = Flask(__name__, template_folder="templates")
|
||||
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-me-in-production")
|
||||
|
||||
_DEFAULT_DIR = "/opt/jetson-counter"
|
||||
DB_PATH = os.getenv("DB_PATH", f"{_DEFAULT_DIR}/jetson_counter.db")
|
||||
CURRENT_COUNTER_PATH = os.getenv("STATE_FILE", os.getenv("CURRENT_COUNTER_PATH", f"{_DEFAULT_DIR}/current_counter.json"))
|
||||
CUTOFF_TIME = os.getenv("CUTOFF_TIME", os.getenv("DAILY_CUTOFF_TIME", "20:00"))
|
||||
|
||||
LIVE_STREAM_FRAME_PATH = os.getenv("LIVE_STREAM_FRAME_PATH", "/dev/shm/jetson-counter/live_frame.jpg")
|
||||
|
||||
CROSS_SNAPSHOT_DIR = os.getenv("CROSS_SNAPSHOT_DIR", f"{_DEFAULT_DIR}/snapshots")
|
||||
SAVE_DETECT_SNAPSHOT = os.getenv("SAVE_DETECT_SNAPSHOT", "false").lower() == "true"
|
||||
|
||||
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"))
|
||||
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
||||
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true"
|
||||
|
||||
@app.route("/api/live-video")
|
||||
def api_live_video():
|
||||
if not os.path.isfile(LIVE_STREAM_FRAME_PATH):
|
||||
return jsonify({"success": False, "error": "Live stream frame not available yet"}), 503
|
||||
|
||||
def generate():
|
||||
consecutive_fails = 0
|
||||
MAX_FAILS = 30
|
||||
while True:
|
||||
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")
|
||||
except FileNotFoundError:
|
||||
consecutive_fails += 1
|
||||
if consecutive_fails >= MAX_FAILS:
|
||||
return
|
||||
time.sleep(1.0)
|
||||
continue
|
||||
except Exception:
|
||||
consecutive_fails += 1
|
||||
if consecutive_fails >= MAX_FAILS:
|
||||
return
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
time.sleep(0.05)
|
||||
return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")
|
||||
|
||||
|
||||
_SNAP_RE = re.compile(
|
||||
r"^(?P<ts>\d{8}_\d{6}_\d{3})_(?P<kind>detect|left|right)_id(?P<tid>\d+)_f(?P<frame>\d+)\.jpg$"
|
||||
)
|
||||
|
||||
|
||||
def _parse_snapshot(path, category):
|
||||
m = _SNAP_RE.match(path.name)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.strptime(m.group("ts"), "%Y%m%d_%H%M%S_%f")
|
||||
except ValueError:
|
||||
dt = datetime.fromtimestamp(path.stat().st_mtime)
|
||||
kind = m.group("kind")
|
||||
return {
|
||||
"file": f"{category}/{path.name}",
|
||||
"category": category,
|
||||
"kind": kind,
|
||||
"track_id": int(m.group("tid")),
|
||||
"frame": int(m.group("frame")),
|
||||
"timestamp": dt.isoformat(),
|
||||
"mtime": path.stat().st_mtime,
|
||||
}
|
||||
|
||||
|
||||
def _collect_snapshots():
|
||||
base = os.path.abspath(CROSS_SNAPSHOT_DIR)
|
||||
items = []
|
||||
for category in ("cross", "detect"):
|
||||
sub = os.path.join(base, category)
|
||||
if not os.path.isdir(sub):
|
||||
continue
|
||||
for name in os.listdir(sub):
|
||||
if not name.lower().endswith(".jpg"):
|
||||
continue
|
||||
info = _parse_snapshot(Path(sub) / name, category)
|
||||
if info:
|
||||
items.append(info)
|
||||
items.sort(key=lambda x: x["mtime"], reverse=True)
|
||||
return items
|
||||
|
||||
|
||||
@app.route("/api/snapshots")
|
||||
def api_snapshots():
|
||||
try:
|
||||
kind = request.args.get("kind", "all")
|
||||
track_id = request.args.get("track_id", type=int)
|
||||
date = request.args.get("date")
|
||||
limit = request.args.get("limit", 200, type=int)
|
||||
|
||||
items = _collect_snapshots()
|
||||
if kind and kind != "all":
|
||||
if kind == "cross":
|
||||
items = [i for i in items if i["category"] == "cross"]
|
||||
elif kind == "detect":
|
||||
items = [i for i in items if i["category"] == "detect"]
|
||||
elif kind in ("left", "right"):
|
||||
items = [i for i in items if i["kind"] == kind]
|
||||
if track_id is not None:
|
||||
items = [i for i in items if i["track_id"] == track_id]
|
||||
if date:
|
||||
items = [i for i in items if i["timestamp"][:10] == date]
|
||||
|
||||
total = len(items)
|
||||
items = items[:limit]
|
||||
for i in items:
|
||||
i.pop("mtime", None)
|
||||
return jsonify({"success": True, "total": total, "count": len(items), "snapshots": items})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e), "snapshots": []}), 200
|
||||
|
||||
|
||||
@app.route("/api/snapshot-image/<category>/<path:filename>")
|
||||
def api_snapshot_image(category, filename):
|
||||
if category not in ("cross", "detect"):
|
||||
return jsonify({"success": False, "error": "invalid category"}), 404
|
||||
base = os.path.abspath(os.path.join(CROSS_SNAPSHOT_DIR, category))
|
||||
requested = os.path.abspath(os.path.join(base, filename))
|
||||
if not requested.startswith(base + os.sep) or not os.path.isfile(requested):
|
||||
return jsonify({"success": False, "error": "not found"}), 404
|
||||
return send_file(requested, mimetype="image/jpeg")
|
||||
|
||||
|
||||
def _ensure_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS daily_counters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
counting_date TEXT NOT NULL,
|
||||
camera_name TEXT NOT NULL,
|
||||
object_label TEXT NOT NULL,
|
||||
total_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_left INTEGER NOT NULL DEFAULT 0,
|
||||
total_right INTEGER NOT NULL DEFAULT 0,
|
||||
start_time TEXT,
|
||||
end_time TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(counting_date, camera_name, object_label)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
_ensure_db()
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME):
|
||||
if dt is None:
|
||||
dt = datetime.now()
|
||||
cutoff = datetime.strptime(cutoff_str, "%H:%M").time()
|
||||
if dt.time() < cutoff:
|
||||
return dt.date().isoformat()
|
||||
return (dt.date() + timedelta(days=1)).isoformat()
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
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)
|
||||
|
||||
|
||||
def _empty_current():
|
||||
return {
|
||||
"counting_date": get_counting_date(),
|
||||
"count": 0,
|
||||
"count_left": 0,
|
||||
"count_right": 0,
|
||||
"start_time": None,
|
||||
"last_detection_time": None,
|
||||
}
|
||||
|
||||
|
||||
def _load_current_state():
|
||||
"""Load live counter state from current_counter.json."""
|
||||
with open(CURRENT_COUNTER_PATH, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return {
|
||||
"counting_date": data.get("counting_date") or get_counting_date(),
|
||||
"count": int(data.get("count", 0) or 0),
|
||||
"count_left": int(data.get("count_left", 0) or 0),
|
||||
"count_right": int(data.get("count_right", 0) or 0),
|
||||
"start_time": data.get("start_time"),
|
||||
"last_detection_time": data.get("last_detection_time"),
|
||||
}
|
||||
|
||||
|
||||
def _query_history(date_from=None, date_to=None, days=None, limit=None, offset=0):
|
||||
"""Query daily_counters with optional date range and pagination."""
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
|
||||
clauses = []
|
||||
params = []
|
||||
|
||||
if days is not None and date_from is None and date_to is None:
|
||||
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
|
||||
|
||||
if date_from:
|
||||
clauses.append("counting_date >= ?")
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
clauses.append("counting_date <= ?")
|
||||
params.append(date_to)
|
||||
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
|
||||
cur.execute(f"SELECT COUNT(*) AS total FROM daily_counters {where}", params)
|
||||
total = cur.fetchone()["total"]
|
||||
|
||||
sql = f"""
|
||||
SELECT counting_date, camera_name, object_label,
|
||||
total_count, total_left, total_right, start_time, end_time, updated_at
|
||||
FROM daily_counters
|
||||
{where}
|
||||
ORDER BY counting_date DESC
|
||||
"""
|
||||
page_params = list(params)
|
||||
if limit is not None:
|
||||
sql += " LIMIT ? OFFSET ?"
|
||||
page_params.extend([limit, offset])
|
||||
|
||||
cur.execute(sql, page_params)
|
||||
rows = [
|
||||
{
|
||||
"date": row["counting_date"],
|
||||
"camera_name": row["camera_name"],
|
||||
"object_label": row["object_label"],
|
||||
"total_count": row["total_count"],
|
||||
"total_left": row["total_left"],
|
||||
"total_right": row["total_right"],
|
||||
"diff": (row["total_left"] or 0) + (row["total_right"] or 0),
|
||||
"start_time": row["start_time"],
|
||||
"end_time": row["end_time"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
conn.close()
|
||||
return rows, total
|
||||
|
||||
|
||||
@app.route("/api/current")
|
||||
@app.route("/api/current-counter")
|
||||
def api_current():
|
||||
"""Current counting-day totals from live state file."""
|
||||
try:
|
||||
state = _load_current_state()
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"active": True,
|
||||
"site_name": SITE_NAME,
|
||||
"counting": _read_counting_flag() if CONTROL_ENABLED else True,
|
||||
**state,
|
||||
}
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"site_name": SITE_NAME,
|
||||
"counting": _read_counting_flag() if CONTROL_ENABLED else True,
|
||||
"active": False,
|
||||
"error": "No active counter — state file missing",
|
||||
**_empty_current(),
|
||||
}
|
||||
), 200
|
||||
except Exception as e:
|
||||
return jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to read current counter from {CURRENT_COUNTER_PATH}: {e}",
|
||||
"site_name": SITE_NAME,
|
||||
**_empty_current(),
|
||||
}
|
||||
), 500
|
||||
|
||||
|
||||
@app.route("/api/history")
|
||||
def api_history():
|
||||
"""
|
||||
Historical daily counters.
|
||||
|
||||
Query params:
|
||||
days – last N calendar days (default 30; ignored if date_from/date_to set)
|
||||
date_from – inclusive YYYY-MM-DD
|
||||
date_to – inclusive YYYY-MM-DD
|
||||
limit – page size (default: all matching)
|
||||
offset – page offset (default 0)
|
||||
"""
|
||||
try:
|
||||
days = request.args.get("days", type=int)
|
||||
date_from = request.args.get("date_from")
|
||||
date_to = request.args.get("date_to")
|
||||
limit = request.args.get("limit", type=int)
|
||||
offset = request.args.get("offset", 0, type=int)
|
||||
|
||||
if date_from:
|
||||
try:
|
||||
datetime.strptime(date_from, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": f"Invalid date_from '{date_from}'. Use YYYY-MM-DD.",
|
||||
},
|
||||
}), 400
|
||||
if date_to:
|
||||
try:
|
||||
datetime.strptime(date_to, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": f"Invalid date_to '{date_to}'. Use YYYY-MM-DD.",
|
||||
},
|
||||
}), 400
|
||||
|
||||
if days is None and date_from is None and date_to is None:
|
||||
days = 30
|
||||
if offset < 0:
|
||||
offset = 0
|
||||
if limit is not None and limit < 1:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "limit must be a positive integer",
|
||||
},
|
||||
}), 400
|
||||
|
||||
rows, total = _query_history(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
days=days,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
payload = {
|
||||
"success": True,
|
||||
"site_name": SITE_NAME,
|
||||
"filters": {
|
||||
"days": days,
|
||||
"date_from": date_from,
|
||||
"date_to": date_to,
|
||||
},
|
||||
"count": len(rows),
|
||||
"data": rows,
|
||||
}
|
||||
if limit is not None:
|
||||
payload["pagination"] = {
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"has_next": offset + limit < total,
|
||||
"has_prev": offset > 0,
|
||||
}
|
||||
else:
|
||||
payload["total"] = total
|
||||
return jsonify(payload)
|
||||
except sqlite3.OperationalError as e:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "DATABASE_UNAVAILABLE",
|
||||
"message": f"Database unavailable at {DB_PATH}: {e}",
|
||||
},
|
||||
"data": [],
|
||||
"count": 0,
|
||||
"total": 0,
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": str(e),
|
||||
},
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route("/api/history/<counting_date>")
|
||||
def api_history_day(counting_date):
|
||||
"""Single counting-day record by YYYY-MM-DD."""
|
||||
try:
|
||||
datetime.strptime(counting_date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": f"Invalid counting_date '{counting_date}'. Use YYYY-MM-DD.",
|
||||
},
|
||||
}), 400
|
||||
|
||||
try:
|
||||
rows, _ = _query_history(date_from=counting_date, date_to=counting_date)
|
||||
if not rows:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "NOT_FOUND",
|
||||
"message": f"No history found for counting date {counting_date}",
|
||||
},
|
||||
}), 404
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"site_name": SITE_NAME,
|
||||
"data": rows[0] if len(rows) == 1 else rows,
|
||||
})
|
||||
except sqlite3.OperationalError as e:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "DATABASE_UNAVAILABLE",
|
||||
"message": f"Database unavailable at {DB_PATH}: {e}",
|
||||
},
|
||||
}), 503
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": str(e),
|
||||
},
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route("/api/summary")
|
||||
def api_summary():
|
||||
try:
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
today = get_counting_date()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(total_count, 0) as total_count,
|
||||
COALESCE(total_left, 0) as total_left,
|
||||
COALESCE(total_right, 0) as total_right
|
||||
FROM daily_counters
|
||||
WHERE counting_date = ?
|
||||
""",
|
||||
(today,),
|
||||
)
|
||||
today_row = cur.fetchone()
|
||||
yesterday = (datetime.strptime(today, "%Y-%m-%d").date() - timedelta(days=1)).isoformat()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(total_count, 0) as total_count,
|
||||
COALESCE(total_left, 0) as total_left,
|
||||
COALESCE(total_right, 0) as total_right
|
||||
FROM daily_counters
|
||||
WHERE counting_date = ?
|
||||
""",
|
||||
(yesterday,),
|
||||
)
|
||||
yesterday_row = cur.fetchone()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(SUM(total_count), 0) as grand_total,
|
||||
COALESCE(SUM(total_left), 0) as grand_left,
|
||||
COALESCE(SUM(total_right), 0) as grand_right,
|
||||
COUNT(DISTINCT counting_date) as total_days
|
||||
FROM daily_counters
|
||||
"""
|
||||
)
|
||||
all_time = cur.fetchone()
|
||||
cur.execute("SELECT ROUND(AVG(total_count), 1) as avg_per_day FROM daily_counters")
|
||||
avg = cur.fetchone()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT counting_date, total_count
|
||||
FROM daily_counters
|
||||
ORDER BY total_count DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
best = cur.fetchone()
|
||||
conn.close()
|
||||
return jsonify(
|
||||
{
|
||||
"today": {
|
||||
"date": today,
|
||||
"total_count": today_row["total_count"] if today_row else 0,
|
||||
"total_left": today_row["total_left"] if today_row else 0,
|
||||
"total_right": today_row["total_right"] if today_row else 0,
|
||||
},
|
||||
"yesterday": {
|
||||
"date": yesterday,
|
||||
"total_count": yesterday_row["total_count"] if yesterday_row else 0,
|
||||
"total_left": yesterday_row["total_left"] if yesterday_row else 0,
|
||||
"total_right": yesterday_row["total_right"] if yesterday_row else 0,
|
||||
},
|
||||
"all_time": {
|
||||
"grand_total": all_time["grand_total"],
|
||||
"grand_left": all_time["grand_left"],
|
||||
"grand_right": all_time["grand_right"],
|
||||
"total_days": all_time["total_days"],
|
||||
},
|
||||
"average_per_day": avg["avg_per_day"] or 0,
|
||||
"best_day": {
|
||||
"date": best["counting_date"] if best else None,
|
||||
"count": best["total_count"] if best else 0,
|
||||
},
|
||||
}
|
||||
)
|
||||
except sqlite3.OperationalError as e:
|
||||
return jsonify({"success": False, "error": f"Database unavailable: {e}", "today": {"date": datetime.now().date().isoformat(), "total_count": 0, "total_left": 0, "total_right": 0}, "yesterday": {"date": "", "total_count": 0, "total_left": 0, "total_right": 0}, "all_time": {"grand_total": 0, "grand_left": 0, "grand_right": 0, "total_days": 0}, "average_per_day": 0, "best_day": {"date": None, "count": 0}}), 200
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/daily-data")
|
||||
def api_daily_data():
|
||||
try:
|
||||
days = request.args.get("days", 30, type=int)
|
||||
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT counting_date, total_count, total_left, total_right
|
||||
FROM daily_counters
|
||||
WHERE counting_date >= ?
|
||||
ORDER BY counting_date ASC
|
||||
""",
|
||||
(date_from,),
|
||||
)
|
||||
daily_data = [
|
||||
{
|
||||
"date": row["counting_date"],
|
||||
"total_count": row["total_count"],
|
||||
"total_left": row["total_left"],
|
||||
"total_right": row["total_right"],
|
||||
}
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
conn.close()
|
||||
return jsonify(daily_data)
|
||||
except sqlite3.OperationalError:
|
||||
return jsonify([]), 200
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/available-dates")
|
||||
def api_available_dates():
|
||||
try:
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT counting_date, total_count, total_left, total_right, start_time, end_time
|
||||
FROM daily_counters
|
||||
ORDER BY counting_date DESC
|
||||
"""
|
||||
)
|
||||
dates = [
|
||||
{
|
||||
"date": row["counting_date"],
|
||||
"total_count": row["total_count"],
|
||||
"total_left": row["total_left"],
|
||||
"total_right": row["total_right"],
|
||||
"start_time": row["start_time"],
|
||||
"end_time": row["end_time"],
|
||||
}
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
conn.close()
|
||||
return jsonify(dates)
|
||||
except sqlite3.OperationalError:
|
||||
return jsonify([]), 200
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
def _excel_response(wb, filename):
|
||||
output = BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
return Response(
|
||||
output.getvalue(),
|
||||
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
|
||||
|
||||
def _style_header(ws, cols):
|
||||
header_font = Font(bold=True, color="FFFFFF", size=11)
|
||||
header_fill = PatternFill(start_color="2F5496", end_color="2F5496", fill_type="solid")
|
||||
thin_border = Border(
|
||||
left=Side(style="thin"), right=Side(style="thin"),
|
||||
top=Side(style="thin"), bottom=Side(style="thin"),
|
||||
)
|
||||
for col_idx, (col_letter, text) in enumerate(cols, 1):
|
||||
cell = ws.cell(row=1, column=col_idx, value=text)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal="center")
|
||||
cell.border = thin_border
|
||||
ws.freeze_panes = "A2"
|
||||
|
||||
|
||||
def _auto_width(ws):
|
||||
for col in ws.columns:
|
||||
max_len = 0
|
||||
col_letter = col[0].column_letter
|
||||
for cell in col:
|
||||
if cell.value is not None:
|
||||
max_len = max(max_len, len(str(cell.value)))
|
||||
ws.column_dimensions[col_letter].width = max_len + 4
|
||||
|
||||
|
||||
@app.route("/api/export-daily-csv")
|
||||
def export_daily_xlsx():
|
||||
try:
|
||||
days = request.args.get("days", 30, type=int)
|
||||
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT counting_date, total_count, total_left, total_right, start_time, end_time
|
||||
FROM daily_counters
|
||||
WHERE counting_date >= ?
|
||||
ORDER BY counting_date ASC
|
||||
""",
|
||||
(date_from,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
except sqlite3.OperationalError as e:
|
||||
return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 503
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Daily Counters"
|
||||
_style_header(ws, [("A", "Date"), ("B", "Total"), ("C", "Left"), ("D", "Right"), ("E", "Total (L+R)"), ("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_left"])
|
||||
ws.cell(row=r_idx, column=4, value=row["total_right"])
|
||||
ws.cell(row=r_idx, column=5, value=(row["total_left"] or 0) + (row["total_right"] 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"
|
||||
return _excel_response(wb, filename)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
WSGIRequestHandler.protocol_version = "HTTP/1.1"
|
||||
print(f"ZenAI KTC zone 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)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
Production daily counter persistence for edge sack feeder counter.
|
||||
Tracks left / right feeder zone entries and the daily total per counting day,
|
||||
delimited by the daily cutoff time. SQLite schema + current_counter.json state.
|
||||
|
||||
Feeders:
|
||||
left → left feeder zone
|
||||
right → right feeder zone
|
||||
"""
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class CounterStore:
|
||||
def __init__(
|
||||
self,
|
||||
db_path,
|
||||
state_file,
|
||||
camera_name,
|
||||
object_label='object',
|
||||
cutoff_time='20:00',
|
||||
carry_ids=50,
|
||||
logger=print,
|
||||
):
|
||||
self.db_path = db_path
|
||||
self.state_file = Path(state_file)
|
||||
self.camera_name = camera_name
|
||||
self.object_label = object_label
|
||||
self.cutoff_time_str = cutoff_time
|
||||
datetime.strptime(cutoff_time, '%H:%M')
|
||||
|
||||
self.carry_ids = int(carry_ids)
|
||||
self.log = logger
|
||||
|
||||
self.state_lock = threading.Lock()
|
||||
self.shutdown_event = threading.Event()
|
||||
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.db = sqlite3.connect(db_path, check_same_thread=False)
|
||||
self._init_db()
|
||||
self.current_state = self._load_state()
|
||||
|
||||
def _init_db(self):
|
||||
cur = self.db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS daily_counters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
counting_date TEXT NOT NULL,
|
||||
camera_name TEXT NOT NULL,
|
||||
object_label TEXT NOT NULL,
|
||||
total_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_left INTEGER NOT NULL DEFAULT 0,
|
||||
total_right INTEGER NOT NULL DEFAULT 0,
|
||||
start_time TEXT,
|
||||
end_time TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(counting_date, camera_name, object_label)
|
||||
)
|
||||
"""
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
def get_counting_date(self, dt=None):
|
||||
if dt is None:
|
||||
dt = datetime.now()
|
||||
cutoff = datetime.strptime(self.cutoff_time_str, '%H:%M').time()
|
||||
if dt.time() < cutoff:
|
||||
return dt.date().isoformat()
|
||||
return (dt.date() + timedelta(days=1)).isoformat()
|
||||
|
||||
def _normalize_state(self, state):
|
||||
state.setdefault('count_left', 0)
|
||||
state.setdefault('count_right', 0)
|
||||
state.setdefault('count', state['count_left'] + state['count_right'])
|
||||
state.setdefault('counted_event_ids', [])
|
||||
return state
|
||||
|
||||
def _load_state(self):
|
||||
if not self.state_file.exists():
|
||||
return None
|
||||
try:
|
||||
with open(self.state_file, 'r', encoding='utf-8') as f:
|
||||
state = json.load(f)
|
||||
current_date = self.get_counting_date()
|
||||
if state.get('counting_date') != current_date:
|
||||
self.log(
|
||||
f"State file belongs to previous counting day "
|
||||
f"({state.get('counting_date')}). Starting fresh."
|
||||
)
|
||||
self.state_file.unlink(missing_ok=True)
|
||||
return None
|
||||
state = self._normalize_state(state)
|
||||
self.log(
|
||||
f"Resumed {current_date} with total={state['count']} "
|
||||
f"(left={state['count_left']} right={state['count_right']})"
|
||||
)
|
||||
return state
|
||||
except Exception as exc:
|
||||
self.log(f'Failed to load state file: {exc}')
|
||||
return None
|
||||
|
||||
def save_state(self):
|
||||
if self.current_state is None:
|
||||
self.state_file.unlink(missing_ok=True)
|
||||
return
|
||||
with open(self.state_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.current_state, f, indent=2, ensure_ascii=False)
|
||||
|
||||
def _start_new_day(self, counting_date):
|
||||
now = datetime.now().isoformat()
|
||||
carried = []
|
||||
if self.current_state is not None:
|
||||
try:
|
||||
carried = self.current_state['counted_event_ids'][-self.carry_ids:]
|
||||
except (KeyError, TypeError):
|
||||
carried = []
|
||||
self.current_state = {
|
||||
'counting_date': counting_date,
|
||||
'count': 0,
|
||||
'count_left': 0,
|
||||
'count_right': 0,
|
||||
'start_time': now,
|
||||
'last_detection_time': now,
|
||||
'counted_event_ids': carried,
|
||||
}
|
||||
self.save_state()
|
||||
self.log(f'Started counting day {counting_date} ({self.object_label})')
|
||||
|
||||
def record_zone_entry(self, track_id, feeder):
|
||||
"""Record a sack entering a feeder zone. feeder: 'left' | 'right'."""
|
||||
if feeder not in ('left', 'right'):
|
||||
raise ValueError(f"feeder must be 'left' or 'right', got {feeder!r}")
|
||||
with self.state_lock:
|
||||
counting_date = self.get_counting_date()
|
||||
day_started = False
|
||||
if self.current_state is None or self.current_state['counting_date'] != counting_date:
|
||||
self._start_new_day(counting_date)
|
||||
day_started = True
|
||||
|
||||
event_key = f"{track_id}_{feeder}"
|
||||
if event_key not in self.current_state['counted_event_ids']:
|
||||
self.current_state['count'] += 1
|
||||
if feeder == 'left':
|
||||
self.current_state['count_left'] += 1
|
||||
else:
|
||||
self.current_state['count_right'] += 1
|
||||
self.current_state['counted_event_ids'].append(event_key)
|
||||
self.log(
|
||||
f'Counted {feeder} feeder (track {track_id}) | {counting_date} '
|
||||
f'total: {self.current_state["count"]} '
|
||||
f'(left={self.current_state["count_left"]} '
|
||||
f'right={self.current_state["count_right"]})'
|
||||
)
|
||||
self._persist_day()
|
||||
|
||||
self.current_state['last_detection_time'] = datetime.now().isoformat()
|
||||
self.save_state()
|
||||
return self.current_state['count'], day_started
|
||||
|
||||
def _persist_day(self):
|
||||
state = self.current_state
|
||||
cur = self.db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO daily_counters
|
||||
(counting_date, camera_name, object_label,
|
||||
total_count, total_left, total_right, start_time, end_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(counting_date, camera_name, object_label)
|
||||
DO UPDATE SET
|
||||
total_count = excluded.total_count,
|
||||
total_left = excluded.total_left,
|
||||
total_right = excluded.total_right,
|
||||
end_time = excluded.end_time,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(
|
||||
state['counting_date'], self.camera_name, self.object_label,
|
||||
state['count'], state['count_left'], state['count_right'],
|
||||
state['start_time'], datetime.now().isoformat(),
|
||||
),
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
def cutoff_watcher_loop(self):
|
||||
while not self.shutdown_event.is_set():
|
||||
time.sleep(60)
|
||||
with self.state_lock:
|
||||
if self.current_state is None:
|
||||
continue
|
||||
if self.current_state['counting_date'] != self.get_counting_date():
|
||||
self.log('Daily cutoff reached - finalizing day totals')
|
||||
self._persist_day()
|
||||
self.current_state = None
|
||||
self.save_state()
|
||||
|
||||
def start_cutoff_watcher(self):
|
||||
t = threading.Thread(target=self.cutoff_watcher_loop, daemon=True)
|
||||
t.start()
|
||||
return t
|
||||
|
||||
@property
|
||||
def current_count(self):
|
||||
if self.current_state is None:
|
||||
return 0
|
||||
return self.current_state['count']
|
||||
|
||||
@property
|
||||
def current_count_left(self):
|
||||
if self.current_state is None:
|
||||
return 0
|
||||
return self.current_state.get('count_left', 0)
|
||||
|
||||
@property
|
||||
def current_count_right(self):
|
||||
if self.current_state is None:
|
||||
return 0
|
||||
return self.current_state.get('count_right', 0)
|
||||
|
||||
def _day_totals(self, counting_date=None):
|
||||
if counting_date is None:
|
||||
counting_date = self.get_counting_date()
|
||||
cur = self.db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(total_count, 0), COALESCE(total_left, 0), COALESCE(total_right, 0)
|
||||
FROM daily_counters
|
||||
WHERE counting_date = ? AND camera_name = ? AND object_label = ?
|
||||
""",
|
||||
(counting_date, self.camera_name, self.object_label),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row if row else (0, 0, 0)
|
||||
|
||||
def display_total(self):
|
||||
return self._day_totals()[0]
|
||||
|
||||
def display_left(self):
|
||||
return self._day_totals()[1]
|
||||
|
||||
def display_right(self):
|
||||
return self._day_totals()[2]
|
||||
|
||||
def shutdown(self):
|
||||
self.shutdown_event.set()
|
||||
with self.state_lock:
|
||||
if self.current_state is not None:
|
||||
self._persist_day()
|
||||
self.db.close()
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
# =============================================================================
|
||||
# ZenAI KTC — sack feeder zone counter (ByteTrack + left/right zones)
|
||||
# Shared config for: counter_live_rknn.py + counter_dashboard.py
|
||||
# Copy to .env on device: cp env.example .env && nano .env
|
||||
# =============================================================================
|
||||
|
||||
# --- Core paths ---
|
||||
OUTPUT_DIR=/opt/zenai-ktc-counter
|
||||
DB_PATH=/opt/zenai-ktc-counter/counter.db
|
||||
STATE_FILE=/opt/zenai-ktc-counter/current_counter.json
|
||||
|
||||
# --- Input source ---
|
||||
#SOURCE=rtsp://user:pass@192.168.0.100:554/stream1
|
||||
SOURCE=rtsp://10.38.30.64:8554/my_stream
|
||||
OPENCV_FFMPEG_CAPTURE_OPTIONS=rtsp_transport;tcp|fflags;nobuffer|flags;low_delay
|
||||
|
||||
# --- RKNN model ---
|
||||
MODEL_PATH=/opt/models/zenai_kac_sukawarna_20260716.rknn
|
||||
IMGSZ=320
|
||||
HALF=false
|
||||
CORE_MASK=1
|
||||
DEVICE=0
|
||||
|
||||
# --- YOLO decoder ---
|
||||
NUM_CLASSES=4
|
||||
SCORE_SIGMOID=false
|
||||
|
||||
# --- Detection ---
|
||||
CONF=0.5
|
||||
|
||||
# --- ByteTrack tracking ---
|
||||
TRACK_HIGH_THRESH=0.5
|
||||
TRACK_LOW_THRESH=0.3
|
||||
TRACK_MATCH_THRESH=0.7
|
||||
TRACK_BUFFER=60
|
||||
TRACK_MIN_HITS=3
|
||||
|
||||
# --- Display ---
|
||||
SITE_NAME=ZenAi
|
||||
|
||||
# --- Object class names ---
|
||||
CAMERA_NAME=ZenAi
|
||||
OBJECT_LABEL=karung
|
||||
CLASS_OBJECT=karung
|
||||
OBJECT_CLASS_ID=0
|
||||
|
||||
# --- Feeder zones (left / right) ---
|
||||
# Rectangular zones as fractions of frame width/height.
|
||||
# Absolute pixel overrides: ZONE_LEFT_X1, ZONE_LEFT_Y1, ZONE_LEFT_X2, ZONE_LEFT_Y2
|
||||
# (and the matching ZONE_RIGHT_*). Absolute values win when set.
|
||||
#
|
||||
# A sack is counted once when its centroid ENTERS a zone.
|
||||
# LEFT zone → left feeder
|
||||
# RIGHT zone → right feeder
|
||||
ZONE_LEFT_X1_FRAC=0.02
|
||||
ZONE_LEFT_Y1_FRAC=0.10
|
||||
ZONE_LEFT_X2_FRAC=0.440
|
||||
ZONE_LEFT_Y2_FRAC=1.00
|
||||
|
||||
ZONE_RIGHT_X1_FRAC=0.490
|
||||
ZONE_RIGHT_Y1_FRAC=0.10
|
||||
ZONE_RIGHT_X2_FRAC=0.98
|
||||
ZONE_RIGHT_Y2_FRAC=1.00
|
||||
|
||||
# --- Counting day management ---
|
||||
DAILY_CUTOFF_TIME=17:00
|
||||
CUTOFF_TIME=17:00
|
||||
|
||||
# --- CSV export ---
|
||||
EXPORT_CSV=false
|
||||
CROSS_CSV=/tmp/ktc_crossings.csv
|
||||
|
||||
# --- Rate / performance ---
|
||||
MOTION_DETECTION_ENABLED=true
|
||||
MOTION_THRESHOLD=5.0
|
||||
RATE_WINDOW_SEC=60
|
||||
WARMUP_FRAMES=30
|
||||
RECONNECT_DELAY_SEC=3
|
||||
MAX_RECONNECT_ATTEMPTS=0
|
||||
TRACKED_PRUNE_SEC=300
|
||||
|
||||
# --- ID-switch counting guards ---
|
||||
DEDUP_FRAMES=15
|
||||
DEDUP_PX=60
|
||||
INHERIT_SEC=1.0
|
||||
INHERIT_PX=60
|
||||
|
||||
# --- Feeder cooldown (seconds) ---
|
||||
# After a sack is counted on a feeder, ignore further counts on that same feeder
|
||||
# for this many seconds. 0 = disabled.
|
||||
ZONE_COOLDOWN_LEFT_SEC=15
|
||||
ZONE_COOLDOWN_RIGHT_SEC=15
|
||||
|
||||
# --- Video recording ---
|
||||
RECORD_VIDEO=false
|
||||
VIDEO_SEGMENT_SEC=3600
|
||||
OUTPUT_FPS=15
|
||||
|
||||
# --- Live stream snapshot ---
|
||||
LIVE_STREAM_ENABLED=true
|
||||
LIVE_STREAM_FRAME_PATH=/dev/shm/zenai-ktc-counter/live_frame.jpg
|
||||
LIVE_STREAM_QUALITY=75
|
||||
LIVE_STREAM_EVERY_N=2
|
||||
|
||||
# --- Dashboard ---
|
||||
SECRET_KEY=change-me-in-production
|
||||
DASHBOARD_HOST=0.0.0.0
|
||||
DASHBOARD_PORT=5000
|
||||
FLASK_DEBUG=false
|
||||
CURRENT_COUNTER_PATH=/tmp/ktc_current_counter.json
|
||||
|
||||
SAVE_CROSS_SNAPSHOT=true
|
||||
CROSS_SNAPSHOT_DIR=/opt/zenai-ktc-snaps/snapshots
|
||||
CROSS_SNAPSHOT_CLEANUP_SEC=3600
|
||||
CROSS_SNAPSHOT_MAX_AGE_DAYS=7
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
numpy<2
|
||||
rknn-toolkit-lite2
|
||||
opencv-python
|
||||
flask
|
||||
python-dotenv
|
||||
openpyxl
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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.left { background: rgba(0,255,136,0.12); color: var(--accent4); }
|
||||
.snap-kind.right { 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">◉ Zone Entry 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="left" onclick="loadSnapshots('left')">LEFT</button>
|
||||
<button data-kind="right" onclick="loadSnapshots('right')">RIGHT</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 KTC Edge Zone Counter (RTSP + RKNN + ByteTrack)
|
||||
Documentation=file:///opt/zenai-ktc-python/DEPLOY.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
WorkingDirectory=/opt/zenai-ktc-python
|
||||
EnvironmentFile=/opt/zenai-ktc-python/.env
|
||||
Environment=PYTHONNOUSERSITE=1
|
||||
Environment=PATH=/opt/zenai-ktc-python/venv/bin:/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
ExecStart=/opt/zenai-ktc-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 KTC Dashboard (Flask)
|
||||
Documentation=file:///opt/zenai-ktc-python/DEPLOY.md
|
||||
After=network-online.target zenai-ktc-counter.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
WorkingDirectory=/opt/zenai-ktc-python
|
||||
EnvironmentFile=/opt/zenai-ktc-python/.env
|
||||
Environment=PATH=/opt/zenai-ktc-python/venv/bin:/usr/local/bin:/usr/bin:/bin
|
||||
Environment=FLASK_DEBUG=false
|
||||
|
||||
ExecStart=/opt/zenai-ktc-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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 234 KiB |
Reference in new issue
Block a user