first commit

This commit is contained in:
Alberto-Audrix committed 2026-07-24 11:04:20 +07:00
commit f7ef038def
14 files changed
+5558

No files matched your search

+220
View File
@@ -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
View File
@@ -0,0 +1,179 @@
# Runtime Control — Start/Stop Counting
The counter can pause and resume object detection & counting **on the fly**,
without restarting the process. There are three interchangeable ways to control
it, and they all converge on a single source of truth: the **control file**.
- **Control file** — a small JSON file the counter polls.
- **TCP control socket** — line commands over the network that update the file.
- **Dashboard button** — a COUNTING ON/OFF toggle that writes the file via its API.
When counting is **OFF**, the counter skips inference entirely (no detection, no
counting, lower CPU/NPU load), the video/live stream keeps running, and a
`COUNTING PAUSED` badge is drawn on the frame. When **ON**, normal counting
resumes.
---
## 1. Enable runtime control
Runtime control is opt-in. In your `.env`:
```ini
# Master switch — required for ALL control methods (file, socket, dashboard).
CONTROL_ENABLED=true
# Shared control file. MUST be identical for the counter and the dashboard.
CONTROL_FILE=/opt/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.
+272
View File
@@ -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.
+245
View File
@@ -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
+770
View File
@@ -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)
+1720
View File
File diff suppressed because it is too large. Load diff
+256
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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
+489
View File
@@ -0,0 +1,489 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZenAI KPC Snapshots</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&family=Orbitron:wght@400;500;700;900&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-deep: #06080e;
--bg-surface: rgba(12, 16, 28, 0.9);
--border-glow: rgba(0, 240, 255, 0.12);
--accent: #00f0ff;
--accent2: #7b2fff;
--accent3: #ff2d78;
--accent4: #00ff88;
--text-primary: #dce2f0;
--text-secondary: #6b7394;
--glass: rgba(12, 16, 32, 0.7);
--cell-bg: rgba(255, 255, 255, 0.02);
--cell-border: rgba(255, 255, 255, 0.05);
--divider: rgba(255, 255, 255, 0.05);
--input-bg: rgba(0, 0, 0, 0.4);
--radius: 18px;
}
[data-theme="light"] {
--bg-deep: #eef1f5;
--bg-surface: rgba(255, 255, 255, 0.9);
--border-glow: rgba(0, 160, 200, 0.15);
--accent: #0090c0;
--accent2: #6a20e0;
--accent3: #d02060;
--accent4: #00a060;
--text-primary: #1a1d30;
--text-secondary: #5a6088;
--glass: rgba(255, 255, 255, 0.8);
--cell-bg: rgba(0, 0, 0, 0.03);
--cell-border: rgba(0, 0, 0, 0.06);
--divider: rgba(0, 0, 0, 0.06);
--input-bg: rgba(0, 0, 0, 0.05);
}
body {
font-family: 'JetBrains Mono', monospace;
background: var(--bg-deep);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
.bg-grid {
position: fixed; inset: 0; z-index: 0;
background-image:
linear-gradient(rgba(0, 240, 255, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 240, 255, 0.025) 1px, transparent 1px);
background-size: 64px 64px;
pointer-events: none;
}
.orb {
position: fixed; border-radius: 50%; filter: blur(140px); pointer-events: none;
}
.orb-1 { width: 600px; height: 600px; background: rgba(0, 240, 255, 0.04); top: -250px; left: -150px; }
.orb-2 { width: 500px; height: 500px; background: rgba(123, 47, 255, 0.04); bottom: -200px; right: -100px; }
.container {
position: relative; z-index: 1;
max-width: 1600px; margin: 0 auto; padding: 24px 20px;
}
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 28px; margin-bottom: 24px;
background: var(--glass);
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-glow); border-radius: var(--radius);
}
.header-left { display: flex; align-items: center; gap: 14px; }
.logo-icon {
width: 44px; height: 44px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius: 11px; font-size: 22px;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 0 28px rgba(0, 240, 255, 0.35);
}
.header h1 {
font-family: 'Orbitron', sans-serif; font-size: 20px; font-weight: 700; letter-spacing: 3px;
background: linear-gradient(90deg, var(--accent), var(--accent2));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.header-sub { font-size: 10px; color: var(--text-secondary); letter-spacing: 2px; text-transform: uppercase; }
.header-right { display: flex; align-items: center; gap: 14px; }
.live-chip {
display: flex; align-items: center; gap: 8px;
font-size: 9px; letter-spacing: 1.5px; padding: 5px 12px; border-radius: 12px;
font-weight: 600; text-transform: uppercase;
background: rgba(0, 255, 136, 0.1); color: var(--accent4);
border: 1px solid rgba(0, 255, 136, 0.2);
}
.live-chip .dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--accent4); box-shadow: 0 0 8px var(--accent4);
animation: blink 1.5s ease-in-out infinite;
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
.nav-link {
display: inline-flex; align-items: center; gap: 6px;
background: none; border: 1px solid var(--cell-border);
border-radius: 8px; padding: 7px 12px; cursor: pointer;
font-family: 'JetBrains Mono', monospace; font-size: 10px; letter-spacing: 1px;
color: var(--text-secondary); text-decoration: none;
transition: border-color 0.3s, color 0.3s, background 0.3s;
}
.nav-link:hover { border-color: var(--accent); color: var(--text-primary); background: var(--cell-bg); }
.nav-link.active { border-color: var(--accent); color: var(--accent); }
.theme-toggle {
background: none; border: 1px solid var(--cell-border);
border-radius: 8px; padding: 6px 10px; cursor: pointer;
font-size: 16px; line-height: 1;
transition: border-color 0.3s, background 0.3s;
}
.theme-toggle:hover { border-color: var(--accent); background: var(--cell-bg); }
.section-title {
font-family: 'Orbitron', sans-serif; font-size: 13px; font-weight: 600; letter-spacing: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent2));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
display: inline-block;
}
.panel {
background: var(--glass);
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-glow); border-radius: var(--radius);
padding: 24px 26px; margin-bottom: 24px;
}
.toolbar {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 20px; flex-wrap: wrap; gap: 12px;
}
.toolbar-group { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.filters { display: flex; gap: 6px; }
.filters button {
font-family: 'JetBrains Mono', monospace; font-size: 10px;
letter-spacing: 1px; padding: 6px 14px; border-radius: 7px;
border: 1px solid var(--cell-border);
background: var(--cell-bg); color: var(--text-secondary);
cursor: pointer; transition: all 0.3s;
}
.filters button:hover { border-color: var(--accent); color: var(--text-primary); }
.filters button.active {
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #000; border-color: transparent; font-weight: 600;
}
input[type="date"] {
font-family: 'JetBrains Mono', monospace; font-size: 11px;
background: var(--input-bg); color: var(--text-primary);
border: 1px solid var(--cell-border); border-radius: 7px;
padding: 7px 12px; outline: none; transition: border-color 0.3s;
}
input[type="date"]:focus { border-color: var(--accent); }
.btn {
font-family: 'Orbitron', sans-serif; font-size: 10px; letter-spacing: 2px;
padding: 8px 16px; border-radius: 7px; cursor: pointer; font-weight: 600;
border: none; transition: opacity 0.3s, box-shadow 0.3s;
}
.btn-primary { background: linear-gradient(135deg, var(--accent), var(--accent2)); color: #000; }
.btn-primary:hover { opacity: 0.85; box-shadow: 0 0 20px rgba(0, 240, 255, 0.3); }
.btn-ghost {
background: var(--cell-bg); color: var(--text-secondary);
border: 1px solid var(--cell-border);
}
.btn-ghost:hover { border-color: var(--accent); color: var(--text-primary); }
.snapshot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 14px;
}
.snap-card {
background: var(--cell-bg);
border: 1px solid var(--cell-border); border-radius: 12px;
overflow: hidden; cursor: pointer;
transition: border-color 0.2s, transform 0.15s;
}
.snap-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.snap-thumb {
width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block; background: #000;
}
.snap-info { padding: 8px 10px; }
.snap-info .si-top {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;
}
.snap-kind {
font-size: 8px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
padding: 2px 8px; border-radius: 8px;
}
.snap-kind.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">&#9670;</div>
<div>
<h1>ZenAI KPC Snapshots</h1>
<div class="header-sub">Detected &amp; counted frames — correlate by track ID</div>
</div>
</div>
<div class="header-right">
<div class="live-chip">
<span class="dot"></span>
<span>{{ site_name }}</span>
</div>
<a class="nav-link" href="/"><i class="fas fa-gauge-high"></i> DASHBOARD</a>
<a class="nav-link active" href="/snapshots"><i class="fas fa-images"></i> SNAPSHOTS</a>
<button class="theme-toggle" id="themeToggle" onclick="toggleTheme()" title="Toggle theme">☀️</button>
</div>
</div>
<!-- Snapshots -->
<div class="panel">
<div class="toolbar">
<div>
<span class="section-title">&#9673; 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">&#9635;</div>
<div style="font-size:10px;letter-spacing:1px;">NO SNAPSHOTS AVAILABLE</div>
<div style="font-size:9px;margin-top:6px;opacity:0.7;">Enable SAVE_CROSS_SNAPSHOT{% if show_detect %} / SAVE_DETECT_SNAPSHOT{% endif %} in counter .env</div>
</div>
</div>
<div class="footer">Edge Nano Counter by ZenAi</div>
</div>
<!-- Snapshot lightbox -->
<div class="modal-overlay hidden" id="snap-modal" onclick="closeSnapModal(event)">
<div class="snap-lightbox" id="snap-lightbox">
<button class="close-btn" onclick="closeSnapModal()" style="position:absolute;top:12px;right:12px;z-index:2;">&times;</button>
<img id="snap-modal-img" alt="Snapshot" />
<div class="snap-modal-meta" id="snap-modal-meta">--</div>
</div>
</div>
<script>
// --- Theme ---
(function() {
const saved = localStorage.getItem('theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
updateToggleIcon();
})();
function toggleTheme() {
const el = document.documentElement;
const current = el.getAttribute('data-theme');
const next = current === 'light' ? '' : 'light';
if (next) el.setAttribute('data-theme', next);
else el.removeAttribute('data-theme');
localStorage.setItem('theme', next || 'dark');
updateToggleIcon();
}
function updateToggleIcon() {
const btn = document.getElementById('themeToggle');
if (!btn) return;
btn.textContent = document.documentElement.getAttribute('data-theme') === 'light' ? '🌙' : '☀️';
}
// --- Snapshots ---
let snapCurrentKind = 'all';
let snapDateFilter = '';
let snapTidFilter = '';
let snapItems = []; // all fetched metadata
let snapRendered = 0; // how many cards are in the DOM
const SNAP_BATCH = 60; // cards added per chunk
let snapObserver = null;
document.addEventListener('DOMContentLoaded', () => {
const sentinel = document.getElementById('snap-sentinel');
snapObserver = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) renderNextBatch();
}, { rootMargin: '600px' });
if (sentinel) snapObserver.observe(sentinel);
// Filter immediately when date changes or track ID is typed.
document.getElementById('snap-date-filter').addEventListener('change', applySnapFilter);
let tidDebounce = null;
document.getElementById('snap-tid-filter').addEventListener('input', () => {
clearTimeout(tidDebounce);
tidDebounce = setTimeout(applySnapFilter, 350);
});
loadSnapshots('all');
setInterval(() => {
if (snapCurrentKind && !snapDateFilter && !snapTidFilter) loadSnapshots(snapCurrentKind, true);
}, 15000);
});
function snapCardHtml(s) {
const imgUrl = `/api/snapshot-image/${s.file}`;
const t = new Date(s.timestamp);
const timeStr = t.toLocaleString('en-US', { hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' });
const kindLabel = s.kind.toUpperCase();
return `
<div class="snap-card" onclick="openSnapModal('${imgUrl}','${kindLabel}',${s.track_id},'${timeStr}',${s.frame})">
<img class="snap-thumb" src="${imgUrl}" loading="lazy" decoding="async" alt="snapshot" />
<div class="snap-info">
<div class="si-top">
<span class="snap-kind ${s.kind}">${kindLabel}</span>
<span class="snap-tid">ID ${s.track_id}</span>
</div>
<div class="snap-time">${timeStr}</div>
</div>
</div>`;
}
function renderNextBatch() {
if (snapRendered >= snapItems.length) return;
const grid = document.getElementById('snapshot-grid');
const next = snapItems.slice(snapRendered, snapRendered + SNAP_BATCH);
grid.insertAdjacentHTML('beforeend', next.map(snapCardHtml).join(''));
snapRendered += next.length;
updateSnapNote();
}
function updateSnapNote() {
const note = document.getElementById('snap-count-note');
note.textContent = `Showing ${snapRendered} of ${snapItems.length}`;
}
async function loadSnapshots(kind, silent) {
if (kind !== undefined) snapCurrentKind = kind;
document.querySelectorAll('#snap-filters button').forEach(b => {
b.classList.toggle('active', b.dataset.kind === snapCurrentKind);
});
try {
let url = `/api/snapshots?kind=${snapCurrentKind}&limit=5000`;
if (snapDateFilter) url += `&date=${snapDateFilter}`;
if (snapTidFilter) url += `&track_id=${snapTidFilter}`;
const res = await fetch(url);
const data = await res.json();
const grid = document.getElementById('snapshot-grid');
const empty = document.getElementById('snapshot-empty');
snapItems = (data && data.snapshots) || [];
snapRendered = 0;
grid.innerHTML = '';
if (snapItems.length === 0) {
empty.style.display = 'block';
document.getElementById('snap-count-note').textContent = '';
return;
}
empty.style.display = 'none';
renderNextBatch();
} catch (err) {
if (!silent) console.error('Failed to load snapshots:', err);
}
}
function applySnapFilter() {
snapDateFilter = document.getElementById('snap-date-filter').value || '';
snapTidFilter = document.getElementById('snap-tid-filter').value || '';
loadSnapshots(snapCurrentKind);
}
function resetSnapFilter() {
snapDateFilter = '';
snapTidFilter = '';
document.getElementById('snap-date-filter').value = '';
document.getElementById('snap-tid-filter').value = '';
snapCurrentKind = 'all';
loadSnapshots('all');
}
function openSnapModal(url, kind, tid, timeStr, frame) {
document.getElementById('snap-modal-img').src = url;
document.getElementById('snap-modal-meta').textContent =
`${kind} \u00b7 Track ID ${tid} \u00b7 Frame ${frame} \u00b7 ${timeStr}`;
document.getElementById('snap-modal').classList.remove('hidden');
}
function closeSnapModal(event) {
if (event && event.target && event.target.id !== 'snap-modal' && event.type === 'click') return;
document.getElementById('snap-modal').classList.add('hidden');
document.getElementById('snap-modal-img').src = '';
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeSnapModal();
});
</script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
[Unit]
Description=ZenAI 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
+32
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB