commit f7ef038def7c9c27406322658e1057338043f888 Author: Alberto-Audrix Date: Fri Jul 24 11:04:20 2026 +0700 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3ec7d5 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CONTROL.md b/CONTROL.md new file mode 100644 index 0000000..2579ac7 --- /dev/null +++ b/CONTROL.md @@ -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. diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..6e84133 --- /dev/null +++ b/DEPLOY.md @@ -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 /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://: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 `; 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. diff --git a/config.env.example b/config.env.example new file mode 100644 index 0000000..1961f5f --- /dev/null +++ b/config.env.example @@ -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 /cross/ +# (filename: __id_f.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 /detect/ with the same track id so +# it can be correlated with the zone-entry snapshot +# (filename: _detect_id_f.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 \ No newline at end of file diff --git a/counter_dashboard.py b/counter_dashboard.py new file mode 100644 index 0000000..357d077 --- /dev/null +++ b/counter_dashboard.py @@ -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\d{8}_\d{6}_\d{3})_(?Pdetect|left|right)_id(?P\d+)_f(?P\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//") +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/") +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) \ No newline at end of file diff --git a/counter_live_rknn.py b/counter_live_rknn.py new file mode 100644 index 0000000..0dbe288 --- /dev/null +++ b/counter_live_rknn.py @@ -0,0 +1,1720 @@ +""" +Edge production live counter — RTSP + YOLO RKNN + ByteTrack + zone counting. +Runs on RK3588 hardware with RKNN model (320×320 input). +Uses ByteTrack (Kalman filter + two-stage IoU association) for tracking. + +Left / right rectangular zones mark the left and right sack feeders. +A sack is counted once when its centroid enters a zone. +""" + +import numpy as np +import cv2 +import csv +import json +import os +import signal +import socket +import threading +import time +from collections import deque +from datetime import datetime +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +from rknnlite.api import RKNNLite +from counter_store import CounterStore + +# --- config (override via env / .env) --- +OUTPUT_DIR = os.getenv("OUTPUT_DIR", "/opt/jetson-counter") +DB_PATH = os.getenv("DB_PATH", f"{OUTPUT_DIR}/jetson_counter.db") +STATE_FILE = os.getenv("STATE_FILE", f"{OUTPUT_DIR}/current_counter.json") +SOURCE = os.getenv("SOURCE", "rtsp://user:pass@192.168.0.100:554/stream1") +MODEL_PATH = os.getenv("MODEL_PATH", "/opt/jetson-counter/yolo11n.rknn") +CAMERA_NAME = os.getenv("CAMERA_NAME", "CC1") +OBJECT_LABEL = os.getenv("OBJECT_LABEL", "object") +CLASS_OBJECT = os.getenv("CLASS_OBJECT", "object") + +# Left feeder zone (fractions of frame width/height). Absolute pixel overrides +# win when set (ZONE_LEFT_X1 … ZONE_LEFT_Y2). +ZONE_LEFT_X1 = int(os.getenv("ZONE_LEFT_X1")) if os.getenv("ZONE_LEFT_X1") else None +ZONE_LEFT_Y1 = int(os.getenv("ZONE_LEFT_Y1")) if os.getenv("ZONE_LEFT_Y1") else None +ZONE_LEFT_X2 = int(os.getenv("ZONE_LEFT_X2")) if os.getenv("ZONE_LEFT_X2") else None +ZONE_LEFT_Y2 = int(os.getenv("ZONE_LEFT_Y2")) if os.getenv("ZONE_LEFT_Y2") else None +ZONE_LEFT_X1_FRAC = float(os.getenv("ZONE_LEFT_X1_FRAC", "0.00")) +ZONE_LEFT_Y1_FRAC = float(os.getenv("ZONE_LEFT_Y1_FRAC", "0.20")) +ZONE_LEFT_X2_FRAC = float(os.getenv("ZONE_LEFT_X2_FRAC", "0.35")) +ZONE_LEFT_Y2_FRAC = float(os.getenv("ZONE_LEFT_Y2_FRAC", "0.85")) + +# Right feeder zone +ZONE_RIGHT_X1 = int(os.getenv("ZONE_RIGHT_X1")) if os.getenv("ZONE_RIGHT_X1") else None +ZONE_RIGHT_Y1 = int(os.getenv("ZONE_RIGHT_Y1")) if os.getenv("ZONE_RIGHT_Y1") else None +ZONE_RIGHT_X2 = int(os.getenv("ZONE_RIGHT_X2")) if os.getenv("ZONE_RIGHT_X2") else None +ZONE_RIGHT_Y2 = int(os.getenv("ZONE_RIGHT_Y2")) if os.getenv("ZONE_RIGHT_Y2") else None +ZONE_RIGHT_X1_FRAC = float(os.getenv("ZONE_RIGHT_X1_FRAC", "0.65")) +ZONE_RIGHT_Y1_FRAC = float(os.getenv("ZONE_RIGHT_Y1_FRAC", "0.20")) +ZONE_RIGHT_X2_FRAC = float(os.getenv("ZONE_RIGHT_X2_FRAC", "1.00")) +ZONE_RIGHT_Y2_FRAC = float(os.getenv("ZONE_RIGHT_Y2_FRAC", "0.85")) + +IMGSZ = int(os.getenv("IMGSZ", "320")) +HALF = os.getenv("HALF", "false").lower() == "true" +CONF = float(os.getenv("CONF", "0.3")) +NMS_IOU = float(os.getenv("NMS_IOU", "0.45")) +DEVICE = int(os.getenv("DEVICE", "0")) + +# RKNN NPU core mask +CORE_MASK = int(os.getenv("CORE_MASK", "1")) + +# YOLO decoder config +NUM_CLASSES = int(os.getenv("NUM_CLASSES", "2")) +SCORE_SIGMOID = os.getenv("SCORE_SIGMOID", "false").lower() == "true" + +# ByteTrack settings +TRACK_HIGH_THRESH = float(os.getenv("TRACK_HIGH_THRESH", "0.5")) +TRACK_LOW_THRESH = float(os.getenv("TRACK_LOW_THRESH", "0.1")) +TRACK_MATCH_THRESH = float(os.getenv("TRACK_MATCH_THRESH", "0.8")) +TRACK_BUFFER = int(os.getenv("TRACK_BUFFER", "30")) +TRACK_MIN_HITS = int(os.getenv("TRACK_MIN_HITS", "3")) + +# Dedup guard against ID-switch double counts: ignore a second zone entry in the +# same feeder within DEDUP_FRAMES and DEDUP_PX (centroid distance) of a recent count. +DEDUP_FRAMES = int(os.getenv("DEDUP_FRAMES", "15")) +DEDUP_PX = float(os.getenv("DEDUP_PX", "60")) + +# Trajectory inheritance across ID switches: when a new track appears, inherit the +# last position of a recently-seen nearby track so zone entry is not missed when +# the ID changes at the zone boundary. +INHERIT_SEC = float(os.getenv("INHERIT_SEC", "1.0")) +INHERIT_PX = float(os.getenv("INHERIT_PX", "60")) + +# Per-feeder cooldown: after a count on that feeder, ignore further counts on the +# same feeder for N seconds. 0 = disabled. +ZONE_COOLDOWN_LEFT_SEC = float(os.getenv("ZONE_COOLDOWN_LEFT_SEC", "0")) +ZONE_COOLDOWN_RIGHT_SEC = float(os.getenv("ZONE_COOLDOWN_RIGHT_SEC", "0")) + +DAILY_CUTOFF_TIME = os.getenv("DAILY_CUTOFF_TIME", "20:00") + +EXPORT_CSV = os.getenv("EXPORT_CSV", "true").lower() == "true" +CROSS_CSV = os.getenv("CROSS_CSV", f"{OUTPUT_DIR}/crossings.csv") + +# Save an annotated frame snapshot each time an object enters a feeder zone and +# the counter increases. +SAVE_CROSS_SNAPSHOT = os.getenv("SAVE_CROSS_SNAPSHOT", "false").lower() == "true" +# Also save one snapshot the first time each object is detected (before it enters +# a zone), named with the same track id so it can be correlated with the count snapshot. +SAVE_DETECT_SNAPSHOT = os.getenv("SAVE_DETECT_SNAPSHOT", "false").lower() == "true" +CROSS_SNAPSHOT_DIR = os.getenv("CROSS_SNAPSHOT_DIR", f"{OUTPUT_DIR}/snapshots") +CROSS_SNAPSHOT_QUALITY = int(os.getenv("CROSS_SNAPSHOT_QUALITY", "85")) +# Retention: delete oldest snapshots when either limit is exceeded (0 = disabled). +CROSS_SNAPSHOT_MAX_FILES = int(os.getenv("CROSS_SNAPSHOT_MAX_FILES", "1000")) +CROSS_SNAPSHOT_MAX_AGE_DAYS = float(os.getenv("CROSS_SNAPSHOT_MAX_AGE_DAYS", "7")) +# Run the cleanup sweep at most every N seconds to limit filesystem scans. +CROSS_SNAPSHOT_CLEANUP_SEC = int(os.getenv("CROSS_SNAPSHOT_CLEANUP_SEC", "3600")) + +RATE_WINDOW_SEC = int(os.getenv("RATE_WINDOW_SEC", "60")) +WARMUP_FRAMES = int(os.getenv("WARMUP_FRAMES", "30")) +RECONNECT_DELAY_SEC = int(os.getenv("RECONNECT_DELAY_SEC", "3")) +MAX_RECONNECT_ATTEMPTS = int(os.getenv("MAX_RECONNECT_ATTEMPTS", "0")) +TRACKED_PRUNE_SEC = int(os.getenv("TRACKED_PRUNE_SEC", "300")) +RECORD_VIDEO = os.getenv("RECORD_VIDEO", "false").lower() == "true" +VIDEO_SEGMENT_SEC = int(os.getenv("VIDEO_SEGMENT_SEC", "3600")) +OUTPUT_FPS = int(os.getenv("OUTPUT_FPS", "15")) + +LIVE_STREAM_ENABLED = os.getenv("LIVE_STREAM_ENABLED", "false").lower() == "true" +LIVE_STREAM_FRAME_PATH = os.getenv( + "LIVE_STREAM_FRAME_PATH", "/dev/shm/jetson-counter/live_frame.jpg" +) +LIVE_STREAM_QUALITY = int(os.getenv("LIVE_STREAM_QUALITY", "75")) +LIVE_STREAM_EVERY_N = int(os.getenv("LIVE_STREAM_EVERY_N", "2")) + +RTSP_FFMPEG_OPTIONS = os.getenv( + "OPENCV_FFMPEG_CAPTURE_OPTIONS", + "rtsp_transport;tcp|fflags;nobuffer|flags;low_delay", +) + +IS_LIVE = SOURCE.lower().startswith(("rtsp://", "http://")) + +MOTION_DETECTION_ENABLED = os.getenv("MOTION_DETECTION_ENABLED", "false").lower() == "true" +MOTION_THRESHOLD = float(os.getenv("MOTION_THRESHOLD", "5.0")) +# Per-pixel intensity change (0-255) for a pixel to count as "moved". +MOTION_PIXEL_DELTA = int(os.getenv("MOTION_PIXEL_DELTA", "25")) +# Fraction of frame pixels that must change (0-1) to trigger inference. Small, +# so an object entering the edge of the frame is detected immediately. +MOTION_MIN_AREA_FRAC = float(os.getenv("MOTION_MIN_AREA_FRAC", "0.002")) +# Always run inference at least every N frames even if no motion (heartbeat), so a +# slow/stationary object is never missed for long. +MOTION_HEARTBEAT_FRAMES = int(os.getenv("MOTION_HEARTBEAT_FRAMES", "15")) + +# --- Runtime control (toggle counting on/off on the fly) --- +# When enabled, the process watches a small JSON control file and honors its +# "counting" flag. Set false to always count (ignore the control file). +CONTROL_ENABLED = os.getenv("CONTROL_ENABLED", "false").lower() == "true" +CONTROL_FILE = os.getenv("CONTROL_FILE", f"{OUTPUT_DIR}/control.json") +# Whether counting is active on startup when no control file exists yet. +CONTROL_DEFAULT_COUNTING = os.getenv("CONTROL_DEFAULT_COUNTING", "true").lower() == "true" +# Re-read the control file at most every N seconds. +CONTROL_POLL_SEC = float(os.getenv("CONTROL_POLL_SEC", "1.0")) +# Optional TCP control socket. When enabled, the counter listens for line-based +# commands so counting can be toggled over the network (in addition to the file). +CONTROL_SOCKET_ENABLED = os.getenv("CONTROL_SOCKET_ENABLED", "false").lower() == "true" +CONTROL_SOCKET_HOST = os.getenv("CONTROL_SOCKET_HOST", "127.0.0.1") +CONTROL_SOCKET_PORT = int(os.getenv("CONTROL_SOCKET_PORT", "5090")) + +CROSS_FLASH_FRAMES = 12 +POPUP_LIFETIME = 20 +ZONE_PULSE_FRAMES = 12 +COUNT_PULSE_FRAMES = 15 + +C_ZONE_LEFT = (80, 220, 100) # green — left feeder +C_ZONE_RIGHT = (0, 165, 255) # orange — right feeder +C_ZONE_FILL_ALPHA = 0.18 + +SKELETON = [(0, 1), (4, 3), (1, 2), (3, 2), (2, 6), (2, 5), (2, 7), (7, 8)] +SK_COLORS = [ + (0, 255, 255), + (0, 255, 255), + (255, 0, 255), + (255, 0, 255), + (0, 255, 0), + (255, 255, 0), + (0, 0, 255), + (200, 200, 0), +] + +C_PANEL = (28, 24, 18) +C_BORDER = (90, 85, 75) +C_ACCENT = (255, 200, 60) +C_GREEN = (80, 220, 100) +C_TEXT = (235, 235, 235) +C_MUTED = (150, 150, 150) +C_OBJECT_BOX = (0, 165, 255) + +shutdown_requested = False + + +def request_shutdown(signum, frame): + global shutdown_requested + shutdown_requested = True + print("\nShutdown requested — finishing current frame...") + + +signal.signal(signal.SIGINT, request_shutdown) +signal.signal(signal.SIGTERM, request_shutdown) + + +# ============================================================================= +# YOLO output decoder (NMS only — boxes are pre-decoded by the model) +# ============================================================================= + + +def _nms(boxes, scores, iou_thr=0.45): + order = np.argsort(scores)[::-1] + keep = [] + while len(order) > 0: + idx = order[0] + keep.append(idx) + if len(order) == 1: + break + xx1 = np.maximum(boxes[idx, 0], boxes[order[1:], 0]) + yy1 = np.maximum(boxes[idx, 1], boxes[order[1:], 1]) + xx2 = np.minimum(boxes[idx, 2], boxes[order[1:], 2]) + yy2 = np.minimum(boxes[idx, 3], boxes[order[1:], 3]) + w = np.maximum(0.0, xx2 - xx1) + h = np.maximum(0.0, yy2 - yy1) + inter = w * h + area_i = (boxes[idx, 2] - boxes[idx, 0]) * (boxes[idx, 3] - boxes[idx, 1]) + area_o = (boxes[order[1:], 2] - boxes[order[1:], 0]) * ( + boxes[order[1:], 3] - boxes[order[1:], 1] + ) + iou = inter / (area_i + area_o - inter + 1e-16) + order = order[1:][iou < iou_thr] + return np.array(keep) + + +# ============================================================================= +# IoU helpers (xyxy format) +# ============================================================================= + + +def _ious_xyxy(boxes_a, boxes_b): + """Pairwise IoU: (N,4) vs (M,4) → (N,M) matrix.""" + n, m = len(boxes_a), len(boxes_b) + if n == 0 or m == 0: + return np.zeros((n, m), dtype=np.float32) + xx1 = np.maximum(boxes_a[:, None, 0], boxes_b[None, :, 0]) + yy1 = np.maximum(boxes_a[:, None, 1], boxes_b[None, :, 1]) + xx2 = np.minimum(boxes_a[:, None, 2], boxes_b[None, :, 2]) + yy2 = np.minimum(boxes_a[:, None, 3], boxes_b[None, :, 3]) + iw = np.maximum(0.0, xx2 - xx1) + ih = np.maximum(0.0, yy2 - yy1) + inter = iw * ih + area_a = (boxes_a[:, 2] - boxes_a[:, 0]) * (boxes_a[:, 3] - boxes_a[:, 1]) + area_b = (boxes_b[:, 2] - boxes_b[:, 0]) * (boxes_b[:, 3] - boxes_b[:, 1]) + return inter / (area_a[:, None] + area_b[None, :] - inter + 1e-16) + + +def _greedy_match(cost_matrix, threshold=0.3): + """Greedy linear assignment. Returns pairs (row_idx, col_idx).""" + if cost_matrix.size == 0: + return [] + n, m = cost_matrix.shape + flat = [(cost_matrix[i, j], i, j) for i in range(n) for j in range(m)] + flat.sort() + row_used = set() + col_used = set() + pairs = [] + for cost, i, j in flat: + if cost >= threshold: + break + if i in row_used or j in col_used: + continue + row_used.add(i) + col_used.add(j) + pairs.append((i, j)) + return pairs + + +# ============================================================================= +# Kalman filter box tracker (state: x, y, w, h, vx, vy, vw, vh) +# ============================================================================= + + +class KalmanBoxTracker: + count = 0 + + def __init__(self, bbox_xyxy): + KalmanBoxTracker.count += 1 + self.track_id = KalmanBoxTracker.count + + x1, y1, x2, y2 = bbox_xyxy + w, h = x2 - x1, y2 - y1 + x, y = x1 + w / 2, y1 + h / 2 + + self.kf = _KalmanFilter() + self.kf.x[:4, 0] = np.array([x, y, w, h], dtype=np.float32) + + self.time_since_update = 0 + self.hits = 1 + self.hit_streak = 1 + self.age = 1 + + def predict(self): + if self.kf.x[6] + self.kf.x[2] <= 0: + self.kf.x[6] *= 0.0 + self.kf.predict() + self.age += 1 + self.time_since_update += 1 + + def update(self, bbox_xyxy): + self.time_since_update = 0 + self.hits += 1 + self.hit_streak += 1 + + x1, y1, x2, y2 = bbox_xyxy + w, h = x2 - x1, y2 - y1 + x, y = x1 + w / 2, y1 + h / 2 + self.kf.update(np.array([x, y, w, h], dtype=np.float32)) + + def get_state(self): + """Returns xyxy bbox from Kalman state.""" + xx = self.kf.x[:4, 0] + x, y, w, h = xx[0], xx[1], xx[2], xx[3] + x1 = x - w / 2 + y1 = y - h / 2 + x2 = x + w / 2 + y2 = y + h / 2 + return np.array([x1, y1, x2, y2], dtype=np.float32) + + def get_cx(self): + return float(self.kf.x[0, 0]) + + def get_cy(self): + return float(self.kf.x[1, 0]) + + +class _KalmanFilter: + """8-state constant-velocity Kalman filter for bounding box tracking.""" + + def __init__(self): + ndim, dt = 4, 1.0 + self.motion_mat = np.eye(2 * ndim, 2 * ndim, dtype=np.float32) + for i in range(ndim): + self.motion_mat[i, ndim + i] = dt + self.update_mat = np.eye(ndim, 2 * ndim, dtype=np.float32) + + self._std_weight_position = 1.0 / 20 + self._std_weight_velocity = 1.0 / 160 + + self.x = np.zeros((8, 1), dtype=np.float32) + self.P = np.eye(8, dtype=np.float32) * 10.0 + + def predict(self): + std_pos = [ + self._std_weight_position * self.x[2], + self._std_weight_position * self.x[3], + self._std_weight_position * self.x[2], + self._std_weight_position * self.x[3], + ] + std_vel = [ + self._std_weight_velocity * self.x[2], + self._std_weight_velocity * self.x[3], + self._std_weight_velocity * self.x[2], + self._std_weight_velocity * self.x[3], + ] + Q = np.diag(np.square(np.concatenate([std_pos, std_vel]))) + + self.x = self.motion_mat @ self.x + self.P = self.motion_mat @ self.P @ self.motion_mat.T + Q + + def update(self, z): + R = np.diag( + np.square( + [ + self._std_weight_position * z[2], + self._std_weight_position * z[3], + self._std_weight_position * z[2], + self._std_weight_position * z[3], + ] + ) + ) + + H = self.update_mat + S = H @ self.P @ H.T + R + K = self.P @ H.T @ np.linalg.inv(S) + + y = z.reshape(4, 1) - H @ self.x + self.x = self.x + K @ y + I_KH = np.eye(8) - K @ H + self.P = I_KH @ self.P @ I_KH.T + K @ R @ K.T + + +# ============================================================================= +# ByteTrack multi-object tracker +# ============================================================================= + + +class ByteTracker: + """ByteTrack: two-stage association with Kalman filter prediction.""" + + def __init__( + self, + track_high_thresh=0.5, + track_low_thresh=0.1, + match_thresh=0.8, + track_buffer=30, + min_hits=3, + ): + self.high_thresh = track_high_thresh + self.low_thresh = track_low_thresh + self.match_thresh = match_thresh + self.track_buffer = track_buffer + self.min_hits = min_hits + self.tracked_tracks = [] + self.lost_tracks = [] + self.removed_tracks = [] + self.frame_id = 0 + + def update(self, boxes_xyxy, scores): + self.frame_id += 1 + + # --- separate detections by score --- + if len(boxes_xyxy) > 0: + remain = scores > self.low_thresh + remain_orig_idx = np.where(remain)[0] + dets = boxes_xyxy[remain] + det_scores = scores[remain] + is_high = det_scores > self.high_thresh + is_low = ~is_high + else: + remain_orig_idx = np.zeros(0, dtype=np.int64) + dets = np.zeros((0, 4), dtype=np.float32) + det_scores = np.zeros(0, dtype=np.float32) + is_high = np.zeros(0, dtype=bool) + is_low = np.zeros(0, dtype=bool) + + # --- Kalman predict all existing tracks --- + track_pool = self.tracked_tracks + self.lost_tracks + num_tracks = len(track_pool) + + # Per-frame tracking results + matched_track_idx = set() + det_to_track = {} + tracked_map = {} + lost_map = {} + + # Pre-allocate these for scoping + high_idx = np.array([], dtype=np.int64) + low_idx = np.array([], dtype=np.int64) + match_pairs_high = [] + + if num_tracks > 0: + track_boxes = np.zeros((num_tracks, 4), dtype=np.float32) + for ti, trk in enumerate(track_pool): + trk.predict() + track_boxes[ti] = trk.get_state() + + # --- first association: high-score ↔ all tracks --- + high_idx = np.where(is_high)[0] + high_dets = dets[is_high] + + unmatched_tracks = list(range(num_tracks)) + + if len(high_dets) > 0: + iou_mat = _ious_xyxy(high_dets, track_boxes) + cost_mat = 1.0 - iou_mat + matches = _greedy_match(cost_mat, threshold=1.0 - self.match_thresh) + + for di, ti in matches: + det_global = int(high_idx[di]) + orig_idx = int(remain_orig_idx[det_global]) + track_pool[ti].update(dets[det_global]) + track_pool[ti].hit_streak = max(1, track_pool[ti].hit_streak) + matched_track_idx.add(ti) + det_to_track[orig_idx] = track_pool[ti].track_id + tracked_map[track_pool[ti].track_id] = (track_pool[ti].get_cx(), track_pool[ti].get_cy()) + match_pairs_high.append((det_global, ti)) + + unmatched_tracks = [ + t for t in range(num_tracks) if t not in matched_track_idx + ] + + # --- second association: low-score ↔ unmatched tracks --- + low_idx = np.where(is_low)[0] + low_dets = dets[is_low] + + if len(low_dets) > 0 and len(unmatched_tracks) > 0: + unmatched_boxes = track_boxes[unmatched_tracks] + iou_mat = _ious_xyxy(low_dets, unmatched_boxes) + cost_mat = 1.0 - iou_mat + matches2 = _greedy_match( + cost_mat, threshold=1.0 - self.match_thresh + ) + + for di, uti in matches2: + det_global = int(low_idx[di]) + pool_idx = unmatched_tracks[uti] + orig_idx = int(remain_orig_idx[det_global]) + track_pool[pool_idx].update(dets[det_global]) + track_pool[pool_idx].hit_streak = max( + 1, track_pool[pool_idx].hit_streak + ) + matched_track_idx.add(pool_idx) + det_to_track[orig_idx] = track_pool[pool_idx].track_id + tracked_map[track_pool[pool_idx].track_id] = ( + track_pool[pool_idx].get_cx(), + track_pool[pool_idx].get_cy(), + ) + + # --- reset hit_streak for unmatched tracks --- + for ti, trk in enumerate(track_pool): + if ti not in matched_track_idx: + trk.hit_streak = 0 + + # --- lifecycle management --- + new_tracked = [] + new_lost = [] + for trk in track_pool: + if trk.time_since_update > self.track_buffer: + self.removed_tracks.append(trk) + elif trk.time_since_update > 0: + new_lost.append(trk) + else: + new_tracked.append(trk) + self.tracked_tracks = new_tracked + self.lost_tracks = new_lost + + # --- confirmed tracks (both tracked and lost) --- + for trk in self.tracked_tracks + self.lost_tracks: + if trk.hit_streak >= self.min_hits or trk.hits >= self.min_hits: + tracked_map.setdefault(trk.track_id, (trk.get_cx(), trk.get_cy())) + + for trk in self.lost_tracks: + if trk.hit_streak >= self.min_hits or trk.hits >= self.min_hits: + lost_map[trk.track_id] = (trk.get_cx(), trk.get_cy()) + + # --- new tracks from unmatched high-score dets --- + high_all = np.where(is_high)[0] + matched_det_ids = set(det_to_track.keys()) + for dg in high_all: + orig_idx = int(remain_orig_idx[int(dg)]) + if orig_idx not in matched_det_ids: + trk = KalmanBoxTracker(dets[int(dg)]) + self.tracked_tracks.append(trk) + det_to_track[orig_idx] = trk.track_id + tracked_map[trk.track_id] = (trk.get_cx(), trk.get_cy()) + + return tracked_map, det_to_track, lost_map + + +# ============================================================================= +# RKNN YOLO wrapper (detect output format: (1, 4+num_classes, N)) +# ============================================================================= + + +class RKNNYOLO: + def __init__( + self, + model_path, + core_mask=1, + imgsz=320, + conf=0.3, + iou=0.45, + num_classes=2, + num_keypoints=0, + score_sigmoid=False, + ): + self.imgsz = imgsz + self.conf = conf + self.iou = iou + self.num_classes = num_classes + self.num_keypoints = num_keypoints + self.score_sigmoid = score_sigmoid + + self.rknn = RKNNLite(verbose=False) + ret = self.rknn.load_rknn(model_path) + if ret != 0: + raise RuntimeError(f"Failed to load RKNN model: {model_path}") + ret = self.rknn.init_runtime(core_mask=core_mask) + if ret != 0: + raise RuntimeError(f"Failed to init RKNN runtime (core_mask={core_mask})") + + try: + sdk_ver = self.rknn.get_sdk_version() + print(f"RKNN SDK version: {sdk_ver}") + except Exception: + pass + + print(f"RKNN model loaded: {model_path} imgsz={imgsz} core_mask={core_mask}") + + def _preprocess(self, frame): + h0, w0 = frame.shape[:2] + scale = min(self.imgsz / h0, self.imgsz / w0) + nh, nw = int(h0 * scale), int(w0 * scale) + resized = cv2.resize(frame, (nw, nh), interpolation=cv2.INTER_LINEAR) + + letterbox = np.full((self.imgsz, self.imgsz, 3), 114, dtype=np.uint8) + dy = (self.imgsz - nh) // 2 + dx = (self.imgsz - nw) // 2 + letterbox[dy : dy + nh, dx : dx + nw] = resized + + rgb = cv2.cvtColor(letterbox, cv2.COLOR_BGR2RGB) + gains = np.array([scale, scale, dy, dx], dtype=np.float32) + return rgb, gains + + def __call__(self, frame): + h0, w0 = frame.shape[:2] + rgb, gains = self._preprocess(frame) + scale, _, pad_y, pad_x = gains + + inp = np.expand_dims(rgb, axis=0) + inp = np.ascontiguousarray(inp.astype(np.uint8)) + + outputs = self.rknn.inference(inputs=[inp]) + + if len(outputs) == 0: + return [] + + out = outputs[0] + out = np.squeeze(out, axis=0) + + if out.shape[0] == self.num_classes + 4: + out = out.T + + boxes_cxcywh = out[:, :4].copy() + cls_raw = out[:, 4:].copy() + + if self.score_sigmoid: + cls_scores = 1.0 / (1.0 + np.exp(-np.clip(cls_raw, -10, 10))) + else: + cls_scores = cls_raw + + boxes_xyxy = np.stack( + [ + boxes_cxcywh[:, 0] - boxes_cxcywh[:, 2] / 2, + boxes_cxcywh[:, 1] - boxes_cxcywh[:, 3] / 2, + boxes_cxcywh[:, 0] + boxes_cxcywh[:, 2] / 2, + boxes_cxcywh[:, 1] + boxes_cxcywh[:, 3] / 2, + ], + axis=1, + ) + + max_scores = cls_scores.max(axis=1) + class_ids = cls_scores.argmax(axis=1) + mask = max_scores > self.conf + + if mask.sum() == 0: + return [] + + bboxes = boxes_xyxy[mask].astype(np.float32) + scores = max_scores[mask].astype(np.float32) + clses = class_ids[mask] + + bboxes[:, 0] = (bboxes[:, 0] - pad_x) / scale + bboxes[:, 1] = (bboxes[:, 1] - pad_y) / scale + bboxes[:, 2] = (bboxes[:, 2] - pad_x) / scale + bboxes[:, 3] = (bboxes[:, 3] - pad_y) / scale + + bboxes[:, 0] = np.clip(bboxes[:, 0], 0, w0) + bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h0) + bboxes[:, 2] = np.clip(bboxes[:, 2], 0, w0) + bboxes[:, 3] = np.clip(bboxes[:, 3], 0, h0) + + detections = [] + for cls_id in range(self.num_classes): + idx = np.where(clses == cls_id)[0] + if len(idx) == 0: + continue + keep = _nms(bboxes[idx], scores[idx], iou_thr=self.iou) + for k in keep: + j = idx[k] + detections.append( + { + "bbox": bboxes[j].tolist(), + "score": float(scores[j]), + "cls": int(clses[j]), + "keypoints": None, + } + ) + return detections + + def release(self): + self.rknn.release() + + +# ============================================================================= +# Drawing helpers +# ============================================================================= + + +def _zone_coord(abs_val, frac, span): + if abs_val is not None: + return int(abs_val) + return int(span * frac) + + +def resolve_zone_left(frame_width, frame_height): + """Return (x1, y1, x2, y2) for the left feeder zone.""" + x1 = _zone_coord(ZONE_LEFT_X1, ZONE_LEFT_X1_FRAC, frame_width) + y1 = _zone_coord(ZONE_LEFT_Y1, ZONE_LEFT_Y1_FRAC, frame_height) + x2 = _zone_coord(ZONE_LEFT_X2, ZONE_LEFT_X2_FRAC, frame_width) + y2 = _zone_coord(ZONE_LEFT_Y2, ZONE_LEFT_Y2_FRAC, frame_height) + return (min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)) + + +def resolve_zone_right(frame_width, frame_height): + """Return (x1, y1, x2, y2) for the right feeder zone.""" + x1 = _zone_coord(ZONE_RIGHT_X1, ZONE_RIGHT_X1_FRAC, frame_width) + y1 = _zone_coord(ZONE_RIGHT_Y1, ZONE_RIGHT_Y1_FRAC, frame_height) + x2 = _zone_coord(ZONE_RIGHT_X2, ZONE_RIGHT_X2_FRAC, frame_width) + y2 = _zone_coord(ZONE_RIGHT_Y2, ZONE_RIGHT_Y2_FRAC, frame_height) + return (min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)) + + +def point_in_zone(cx, cy, zone): + x1, y1, x2, y2 = zone + return x1 <= cx <= x2 and y1 <= cy <= y2 + + +def is_duplicate_cross(recent, cx, cy, frame_idx): + """True if a count near (cx, cy) happened within the dedup window (ID-switch guard).""" + while recent and frame_idx - recent[0][0] > DEDUP_FRAMES: + recent.popleft() + for _, prev_cx, prev_cy in recent: + if abs(prev_cx - cx) <= DEDUP_PX and abs(prev_cy - cy) <= DEDUP_PX: + return True + return False + + +def feeder_cooldown_sec(feeder): + if feeder == "left": + return ZONE_COOLDOWN_LEFT_SEC + return ZONE_COOLDOWN_RIGHT_SEC + + +def feeder_in_cooldown(feeder, mono, last_count_mono): + """True if this feeder is still inside its post-count cooldown window.""" + cooldown = feeder_cooldown_sec(feeder) + if cooldown <= 0: + return False + last = last_count_mono.get(feeder) + if last is None: + return False + return (mono - last) < cooldown + + +def _inherit_prev(tracked, new_tid, cx, cy, mono, max_age, max_px): + """Find a recently-seen nearby track for ID-switch continuation. + + Returns (cx, cy, mono, source_tid) or None. + """ + best = None + best_dist = max_px + for tid, (tcx, tcy, ts) in tracked.items(): + if tid == new_tid: + continue + if mono - ts > max_age: + continue + if abs(tcy - cy) > max_px: + continue + dist = abs(tcx - cx) + if dist <= best_dist: + best_dist = dist + best = (tcx, tcy, ts, tid) + return best + + +def _default_zone_state(): + """Per-track feeder-zone state. + + counted: already contributed one LEFT or RIGHT count (at most one per object). + side: 'left' | 'right' | None after a count. + """ + return {"counted": False, "side": None} + + +def _copy_zone_state(src): + return { + "counted": bool(src.get("counted", False)), + "side": src.get("side"), + } + + +def now_str(): + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def read_counting_flag(default=True): + """Read the 'counting' flag from the control file. Returns default on any error.""" + try: + with open(CONTROL_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + return bool(data.get("counting", default)) + except FileNotFoundError: + return default + except Exception: + return default + + +def write_control_file(counting): + """Create/update the control file atomically (used to seed defaults).""" + try: + Path(CONTROL_FILE).parent.mkdir(parents=True, exist_ok=True) + tmp = f"{CONTROL_FILE}.tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump({"counting": bool(counting)}, f) + os.replace(tmp, CONTROL_FILE) + except Exception as exc: + print(f"[{now_str()}] Failed to write control file: {exc}") + + +def start_control_socket(): + """Start a TCP server for runtime control. Commands (newline-terminated): + START | RESUME | ON -> counting on + STOP | PAUSE | OFF -> counting off + TOGGLE -> flip + STATUS | GET -> report current state + It writes the shared control file, so the main loop's file-poll applies it. + Returns the server socket (call .close() to stop).""" + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((CONTROL_SOCKET_HOST, CONTROL_SOCKET_PORT)) + srv.listen(5) + + def handle(conn, addr): + with conn: + conn.settimeout(30) + try: + buf = b"" + while not shutdown_requested: + try: + chunk = conn.recv(256) + except socket.timeout: + break + if not chunk: + break + buf += chunk + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + cmd = line.decode("utf-8", "ignore").strip().upper() + if not cmd: + continue + current = read_counting_flag(CONTROL_DEFAULT_COUNTING) + if cmd in ("START", "RESUME", "ON"): + write_control_file(True) + resp = "OK counting=on" + elif cmd in ("STOP", "PAUSE", "OFF"): + write_control_file(False) + resp = "OK counting=off" + elif cmd == "TOGGLE": + write_control_file(not current) + resp = f"OK counting={'off' if current else 'on'}" + elif cmd in ("STATUS", "GET"): + resp = f"OK counting={'on' if current else 'off'}" + else: + resp = "ERR unknown command" + conn.sendall((resp + "\n").encode("utf-8")) + except Exception: + pass + + def loop(): + print(f"Control socket listening on {CONTROL_SOCKET_HOST}:{CONTROL_SOCKET_PORT}") + while not shutdown_requested: + try: + conn, addr = srv.accept() + except OSError: + break + t = threading.Thread(target=handle, args=(conn, addr), daemon=True) + t.start() + + threading.Thread(target=loop, daemon=True).start() + return srv + + +def open_capture(source): + if source.lower().startswith(("rtsp://", "http://")): + os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = RTSP_FFMPEG_OPTIONS + cap = cv2.VideoCapture(source, cv2.CAP_FFMPEG) + cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) + return cap + + +def warmup_stream(cap, n=WARMUP_FRAMES): + print("Warming up stream...") + for _ in range(n): + cap.read() + print("Stream ready!") + + +def open_video_writer(path, w, h, fps): + return cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*"avc1"), fps, (w, h)) + + +class CsvLogger: + def __init__(self, path, header): + Path(path).parent.mkdir(parents=True, exist_ok=True) + new_file = not Path(path).exists() or Path(path).stat().st_size == 0 + self.file = open(path, "a", newline="", buffering=1) + self.writer = csv.writer(self.file) + if new_file: + self.writer.writerow(header) + self.file.flush() + + def write_row(self, row): + self.writer.writerow(row) + self.file.flush() + + def close(self): + self.file.close() + + +class VideoSegmentWriter: + def __init__(self, output_dir, w, h, fps, segment_sec): + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + self.w, self.h, self.fps = w, h, fps + self.segment_sec = segment_sec + self.segment_start = time.monotonic() + self.writer = None + self._open_next() + + def _segment_path(self): + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + return str(self.output_dir / f"live_{ts}.mp4") + + def _open_next(self): + if self.writer is not None: + self.writer.release() + path = self._segment_path() + self.writer = open_video_writer(path, self.w, self.h, self.fps) + self.segment_start = time.monotonic() + print(f"Recording segment: {path}") + + def write(self, frame): + if time.monotonic() - self.segment_start >= self.segment_sec: + self._open_next() + self.writer.write(frame) + + def release(self): + if self.writer is not None: + self.writer.release() + + +def prune_stale_tracks(tracked, now_mono): + stale = [ + tid for tid, (_, _, ts) in tracked.items() if now_mono - ts > TRACKED_PRUNE_SEC + ] + for tid in stale: + del tracked[tid] + + +def cleanup_snapshots(snapshot_dir, max_files, max_age_days): + """Delete oldest / expired crossing snapshots to bound disk usage.""" + d = Path(snapshot_dir) + if not d.is_dir(): + return + files = sorted(d.rglob("*.jpg"), key=lambda p: p.stat().st_mtime) + if max_age_days > 0: + cutoff = time.time() - max_age_days * 86400 + for p in list(files): + if p.stat().st_mtime < cutoff: + p.unlink(missing_ok=True) + files.remove(p) + if max_files > 0 and len(files) > max_files: + for p in files[: len(files) - max_files]: + p.unlink(missing_ok=True) + + +def overlay_rect(img, x1, y1, x2, y2, color, alpha=0.65): + x1, y1 = max(0, x1), max(0, y1) + x2, y2 = min(img.shape[1], x2), min(img.shape[0], y2) + if x2 <= x1 or y2 <= y1: + return + roi = img[y1:y2, x1:x2] + patch = np.full_like(roi, color, dtype=np.uint8) + cv2.addWeighted(patch, alpha, roi, 1 - alpha, 0, roi) + + +def draw_pill(img, text, x, y, bg, fg=C_TEXT, font_scale=0.45, pad_x=6, pad_y=4): + font = cv2.FONT_HERSHEY_SIMPLEX + (tw, th), baseline = cv2.getTextSize(text, font, font_scale, 1) + x1, y1 = x, y - th - pad_y + x2, y2 = x + tw + pad_x * 2, y + baseline + pad_y + cv2.rectangle(img, (x1, y1), (x2, y2), bg, -1) + cv2.rectangle(img, (x1, y1), (x2, y2), C_BORDER, 1) + cv2.putText(img, text, (x + pad_x, y), font, font_scale, fg, 1, cv2.LINE_AA) + + +def draw_elegant_zone(img, zone, pulse_remaining=0, label="ZONE", color=C_ZONE_LEFT): + x1, y1, x2, y2 = zone + strength = pulse_remaining / max(ZONE_PULSE_FRAMES, 1) + fill_alpha = C_ZONE_FILL_ALPHA + 0.12 * strength + overlay_rect(img, x1, y1, x2, y2, color, alpha=fill_alpha) + thickness = 2 + int(2 * strength) + cv2.rectangle(img, (x1, y1), (x2, y2), color, thickness, cv2.LINE_AA) + # Corner ticks + tick = max(12, min(x2 - x1, y2 - y1) // 8) + for (ax, ay, dx, dy) in ( + (x1, y1, 1, 1), + (x2, y1, -1, 1), + (x1, y2, 1, -1), + (x2, y2, -1, -1), + ): + cv2.line(img, (ax, ay), (ax + dx * tick, ay), color, 2, cv2.LINE_AA) + cv2.line(img, (ax, ay), (ax, ay + dy * tick), color, 2, cv2.LINE_AA) + cv2.putText( + img, + label, + (x1 + 10, y1 + 22), + cv2.FONT_HERSHEY_SIMPLEX, + 0.5, + color, + 1, + cv2.LINE_AA, + ) + + +def draw_zone_count(img, zone, count, label, color, pulse_remaining=0): + text = str(count) + font = cv2.FONT_HERSHEY_SIMPLEX + boost = 0.35 * (pulse_remaining / max(COUNT_PULSE_FRAMES, 1)) + font_scale, thickness = 1.2 + boost, 3 + (tw, th), _ = cv2.getTextSize(text, font, font_scale, thickness) + (lw, lh), _ = cv2.getTextSize(label, font, 0.45, 1) + pad = 12 + box_w = max(tw, lw) + pad * 2 + box_h = th + lh + pad * 2 + 6 + + x1, y1, x2, y2 = zone + bx1 = x1 + ((x2 - x1) - box_w) // 2 + by1 = max(8, y1 - box_h - 8) + bx2 = bx1 + box_w + by2 = by1 + box_h + + overlay_rect(img, bx1, by1, bx2, by2, C_PANEL, alpha=0.78) + cv2.rectangle(img, (bx1, by1), (bx2, by2), color, 2) + + tx = bx1 + (box_w - tw) // 2 + ty = by1 + pad + th + cv2.putText(img, text, (tx, ty), font, font_scale, color, thickness, cv2.LINE_AA) + + slx = bx1 + (box_w - lw) // 2 + sly = ty + lh + 6 + cv2.putText(img, label, (slx, sly), font, 0.45, C_MUTED, 1, cv2.LINE_AA) + + +def draw_hud(img, w, total, total_left, total_right, elapsed_sec, rate): + bar_h = 40 + overlay_rect(img, 0, 0, w, bar_h, C_PANEL, alpha=0.72) + cv2.line(img, (0, bar_h), (w, bar_h), C_BORDER, 1) + cv2.putText( + img, "TOTAL", (14, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.32, C_MUTED, 1, cv2.LINE_AA + ) + cv2.putText( + img, + str(total), + (14, 32), + cv2.FONT_HERSHEY_SIMPLEX, + 0.55, + C_GREEN, + 1, + cv2.LINE_AA, + ) + cv2.putText( + img, "LEFT", (100, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.32, C_MUTED, 1, cv2.LINE_AA + ) + cv2.putText( + img, + str(total_left), + (100, 32), + cv2.FONT_HERSHEY_SIMPLEX, + 0.55, + C_ZONE_LEFT, + 1, + cv2.LINE_AA, + ) + cv2.putText( + img, "RIGHT", (180, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.32, C_MUTED, 1, cv2.LINE_AA + ) + cv2.putText( + img, + str(total_right), + (180, 32), + cv2.FONT_HERSHEY_SIMPLEX, + 0.55, + C_ZONE_RIGHT, + 1, + cv2.LINE_AA, + ) + cv2.putText( + img, + "UPTIME", + (270, 14), + cv2.FONT_HERSHEY_SIMPLEX, + 0.32, + C_MUTED, + 1, + cv2.LINE_AA, + ) + cv2.putText( + img, + f"{elapsed_sec / 3600:.1f}h", + (270, 32), + cv2.FONT_HERSHEY_SIMPLEX, + 0.45, + C_TEXT, + 1, + cv2.LINE_AA, + ) + cv2.putText( + img, "RATE", (360, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.32, C_MUTED, 1, cv2.LINE_AA + ) + cv2.putText( + img, + f"{rate:.1f}/min", + (360, 32), + cv2.FONT_HERSHEY_SIMPLEX, + 0.45, + C_ACCENT, + 1, + cv2.LINE_AA, + ) + + +def draw_footer(img, w, h, frame_idx, live_tag, inf_ms=0.0, model_name=""): + bar_h = 28 + overlay_rect(img, 0, h - bar_h, w, h, C_PANEL, alpha=0.55) + cv2.putText( + img, + f"{live_tag} | {model_name} | Frame {frame_idx} | Inf {inf_ms:.1f}ms", + (12, h - 9), + cv2.FONT_HERSHEY_SIMPLEX, + 0.45, + C_MUTED, + 1, + cv2.LINE_AA, + ) + + +def draw_skeleton_bold(img, kpts): + for (a, b), color in zip(SKELETON, SK_COLORS): + if a < len(kpts) and b < len(kpts): + xa, ya = int(kpts[a][0]), int(kpts[a][1]) + xb, yb = int(kpts[b][0]), int(kpts[b][1]) + if xa > 0 and ya > 0 and xb > 0 and yb > 0: + cv2.line(img, (xa, ya), (xb, yb), color, 3, cv2.LINE_AA) + for kp in kpts: + x, y = int(kp[0]), int(kp[1]) + if x > 0 and y > 0: + cv2.circle(img, (x, y), 6, (255, 255, 255), -1, cv2.LINE_AA) + cv2.circle(img, (x, y), 6, (40, 40, 40), 2, cv2.LINE_AA) + + +def draw_popups(img, popups, frame_idx): + alive = [] + for pop in popups: + age = frame_idx - pop["born"] + if age > POPUP_LIFETIME: + continue + alive.append(pop) + fade = 1.0 - age / POPUP_LIFETIME + y = pop["y"] - int(age * 1.8) + color = (int(C_GREEN[0] * fade), int(C_GREEN[1] * fade), int(C_GREEN[2] * fade)) + cv2.putText( + img, + pop["text"], + (pop["x"], y), + cv2.FONT_HERSHEY_SIMPLEX, + 0.7, + color, + 2, + cv2.LINE_AA, + ) + return alive + + +def connect_stream(source, warmup=WARMUP_FRAMES): + attempts = 0 + while not shutdown_requested: + cap = open_capture(source) + if not cap.isOpened(): + attempts += 1 + if MAX_RECONNECT_ATTEMPTS and attempts >= MAX_RECONNECT_ATTEMPTS: + raise RuntimeError( + f"Cannot open source after {attempts} attempts: {source}" + ) + print(f"Cannot open source, retry in {RECONNECT_DELAY_SEC}s...") + time.sleep(RECONNECT_DELAY_SEC) + continue + if warmup > 0 and source.lower().startswith(("rtsp://", "http://")): + warmup_stream(cap, warmup) + w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + fps = cap.get(cv2.CAP_PROP_FPS) + if not fps or fps <= 1: + fps = OUTPUT_FPS + return cap, w, h, fps + return None, 0, 0, OUTPUT_FPS + + +# ============================================================================= +# Main loop +# ============================================================================= + + +def run(): + global shutdown_requested + + store = CounterStore( + db_path=DB_PATH, + state_file=STATE_FILE, + camera_name=CAMERA_NAME, + object_label=OBJECT_LABEL, + cutoff_time=DAILY_CUTOFF_TIME, + logger=lambda msg: print(f"[{now_str()}] {msg}"), + ) + store.start_cutoff_watcher() + + cross_logger = None + if EXPORT_CSV: + cross_logger = CsvLogger( + CROSS_CSV, ["counting_date", "frame", "feeder", "object_id"] + ) + + model = RKNNYOLO( + model_path=MODEL_PATH, + core_mask=CORE_MASK, + imgsz=IMGSZ, + conf=CONF, + iou=NMS_IOU, + num_classes=NUM_CLASSES, + score_sigmoid=SCORE_SIGMOID, + ) + + object_cls = int(os.getenv("OBJECT_CLASS_ID", "0")) + + object_tracker = ByteTracker( + track_high_thresh=TRACK_HIGH_THRESH, + track_low_thresh=TRACK_LOW_THRESH, + match_thresh=TRACK_MATCH_THRESH, + track_buffer=TRACK_BUFFER, + min_hits=TRACK_MIN_HITS, + ) + + # Per-track zone state: enter left → LEFT feeder; enter right → RIGHT feeder. + # Keys survive ID switches via inheritance (see _inherit_prev). + object_zone_state = {} + + recent_cross_left = deque() + recent_cross_right = deque() + # Monotonic timestamp of last successful count per feeder (for cooldown). + last_count_mono = {"left": None, "right": None} + + detect_snapshot_ids = set() + + object_cross_flash = {} + zone_pulse = count_left_pulse = count_right_pulse = 0 + popups = [] + + session_start = time.time() + frame_idx = 0 + inf_ms = 0.0 + prev_gray = None + frames_since_infer = 0 + video_writer = None + crossing_times = deque() + counter_left = 0 + counter_right = 0 + last_snapshot_cleanup = 0.0 + + counting_active = True + last_control_poll = 0.0 + control_socket = None + if CONTROL_ENABLED: + if not Path(CONTROL_FILE).exists(): + write_control_file(CONTROL_DEFAULT_COUNTING) + counting_active = read_counting_flag(CONTROL_DEFAULT_COUNTING) + print( + f"Runtime control enabled | file={CONTROL_FILE} | " + f"counting={'ON' if counting_active else 'OFF'}" + ) + if CONTROL_SOCKET_ENABLED: + try: + control_socket = start_control_socket() + except Exception as exc: + print(f"[{now_str()}] Failed to start control socket: {exc}") + + cap, w, h, fps = connect_stream(SOURCE) + if cap is None: + store.shutdown() + model.release() + return + + zone_left = resolve_zone_left(w, h) + zone_right = resolve_zone_right(w, h) + print( + f"RKNN+ByteTrack zone counter | {w}x{h} @ {fps}fps | " + f"LEFT feeder {zone_left} RIGHT feeder {zone_right}" + ) + print( + f"Cooldownoldown: left={ZONE_COOLDOWN_LEFT_SEC}s right={ZONE_COOLDOWN_RIGHT_SEC}s " + f"(0=off)" + ) + print(f"Model: {MODEL_PATH} | imgsz={IMGSZ} | core_mask={CORE_MASK}") + print( + f"ByteTrack: high_thresh={TRACK_HIGH_THRESH} low_thresh={TRACK_LOW_THRESH} " + f"match_thresh={TRACK_MATCH_THRESH} buffer={TRACK_BUFFER}" + ) + print(f"DB: {DB_PATH}") + print(f"State: {STATE_FILE}") + + if RECORD_VIDEO: + video_writer = VideoSegmentWriter(OUTPUT_DIR, w, h, fps, VIDEO_SEGMENT_SEC) + + reconnect_count = 0 + + while not shutdown_requested: + ret, frame = cap.read() + if not ret: + if not IS_LIVE: + break + reconnect_count += 1 + print( + f"Stream dropped (attempt {reconnect_count}), reconnecting in {RECONNECT_DELAY_SEC}s..." + ) + cap.release() + time.sleep(RECONNECT_DELAY_SEC) + cap, w, h, fps = connect_stream(SOURCE) + if cap is None: + break + zone_left = resolve_zone_left(w, h) + zone_right = resolve_zone_right(w, h) + continue + + now = time.time() + elapsed = now - session_start + mono = time.monotonic() + object_crossed_frame = False + cross_events_frame = [] + detect_events_frame = [] + + if CONTROL_ENABLED and (now - last_control_poll) >= CONTROL_POLL_SEC: + last_control_poll = now + new_flag = read_counting_flag(CONTROL_DEFAULT_COUNTING) + if new_flag != counting_active: + counting_active = new_flag + print(f"[{now_str()}] Counting {'RESUMED' if counting_active else 'PAUSED'} via control file") + + skip_inference = False + if not counting_active: + skip_inference = True + elif MOTION_DETECTION_ENABLED: + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + if prev_gray is not None: + diff = cv2.absdiff(gray, prev_gray) + moved = int(np.count_nonzero(diff > MOTION_PIXEL_DELTA)) + moved_frac = moved / diff.size + skip_inference = moved_frac < MOTION_MIN_AREA_FRAC + if frames_since_infer >= MOTION_HEARTBEAT_FRAMES: + skip_inference = False + prev_gray = gray + + detections = [] + if not skip_inference: + frames_since_infer = 0 + inf_start = time.time() + detections = model(frame) + inf_ms = inf_ms * 0.9 + (time.time() - inf_start) * 1000 * 0.1 + else: + frames_since_infer += 1 + + object_boxes_xyxy = [] + object_scores = [] + object_kpts_list = [] + object_cx_list = [] + object_cy_list = [] + + for det in detections: + bbox = det["bbox"] + score = det["score"] + cls_id = det["cls"] + kpts = det["keypoints"] + cx = (bbox[0] + bbox[2]) / 2.0 + cy = (bbox[1] + bbox[3]) / 2.0 + + if cls_id == object_cls: + object_boxes_xyxy.append(bbox) + object_scores.append(score) + object_kpts_list.append(kpts) + object_cx_list.append(cx) + object_cy_list.append(cy) + + object_boxes_xyxy = np.array(object_boxes_xyxy, dtype=np.float32).reshape(-1, 4) + object_scores = np.array(object_scores, dtype=np.float32) + + object_track_map, object_det_to_track, object_lost_map = object_tracker.update( + object_boxes_xyxy, object_scores + ) + + if os.getenv("DEBUG_TRACKING", "").lower() == "true": + if len(object_boxes_xyxy) > 0: + scores_str = ( + f" scores: {object_scores.round(3).tolist()}" + if len(object_scores) > 0 + else "" + ) + tracks_str = ( + f" det->track: {dict(object_det_to_track)}" + if object_det_to_track + else "" + ) + zone_str = "" + if object_zone_state: + left_ids = sorted( + tid + for tid, st in object_zone_state.items() + if st.get("side") == "left" + ) + right_ids = sorted( + tid + for tid, st in object_zone_state.items() + if st.get("side") == "right" + ) + if left_ids: + zone_str += f" left_counted: {left_ids}" + if right_ids: + zone_str += f" right_counted: {right_ids}" + print( + f"[DEBUG F{frame_idx}] dets={len(object_boxes_xyxy)} " + f"tracks={len(object_track_map)} " + f"zone_L={zone_left} zone_R={zone_right}{scores_str}" + f"{tracks_str}{zone_str}" + ) + + # --- zone entry detection only on DETECTED objects this frame --- + # (tracker still updates every frame to preserve IDs, but we do NOT + # count on Kalman-predicted/coasting tracks to avoid double counts) + for di in range(len(object_boxes_xyxy)): + tid = object_det_to_track.get(di) + if tid is None: + continue + cx = object_cx_list[di] + cy = object_cy_list[di] + + if tid not in detect_snapshot_ids: + detect_snapshot_ids.add(tid) + detect_events_frame.append(tid) + + if tid not in object_tracked: + inherited = _inherit_prev( + object_tracked, tid, cx, cy, mono, INHERIT_SEC, INHERIT_PX + ) + if inherited is not None: + object_tracked[tid] = inherited[:3] + src_tid = inherited[3] + if src_tid in object_zone_state: + object_zone_state[tid] = _copy_zone_state( + object_zone_state[src_tid] + ) + if os.getenv("DEBUG_TRACKING", "").lower() == "true": + src_st = object_zone_state.get(tid, {}) + print( + f"[DEBUG F{frame_idx}] INHERIT prev for new tid={tid} " + f"from tid={src_tid} ({inherited[0]:.1f},{inherited[1]:.1f})" + f" counted={src_st.get('counted', False)}" + f" side={src_st.get('side')}" + ) + + if tid not in object_zone_state: + object_zone_state[tid] = _default_zone_state() + st = object_zone_state[tid] + + now_left = point_in_zone(cx, cy, zone_left) + now_right = point_in_zone(cx, cy, zone_right) + + entered_left = False + entered_right = False + if tid in object_tracked: + prev_cx, prev_cy = object_tracked[tid][0], object_tracked[tid][1] + prev_left = point_in_zone(prev_cx, prev_cy, zone_left) + prev_right = point_in_zone(prev_cx, prev_cy, zone_right) + entered_left = now_left and not prev_left + entered_right = now_right and not prev_right + else: + # First observation: count if the centroid already sits in a zone. + entered_left = now_left + entered_right = now_right + + if (entered_left or entered_right) and not st["counted"]: + # Prefer the zone whose center is closer if both fire in one jump. + feeder = None + if entered_left and entered_right: + lx1, ly1, lx2, ly2 = zone_left + rx1, ry1, rx2, ry2 = zone_right + dl = abs(cx - (lx1 + lx2) / 2) + abs(cy - (ly1 + ly2) / 2) + dr = abs(cx - (rx1 + rx2) / 2) + abs(cy - (ry1 + ry2) / 2) + feeder = "left" if dl <= dr else "right" + elif entered_left: + feeder = "left" + else: + feeder = "right" + + recent = ( + recent_cross_left if feeder == "left" else recent_cross_right + ) + if is_duplicate_cross(recent, cx, cy, frame_idx): + st["counted"] = True + st["side"] = feeder + if os.getenv("DEBUG_TRACKING", "").lower() == "true": + print( + f"[DEBUG F{frame_idx}] DUP ZONE IGNORED: tid={tid} " + f"cx={cx:.1f} cy={cy:.1f} feeder={feeder}" + ) + object_tracked[tid] = (cx, cy, mono) + continue + + if feeder_in_cooldown(feeder, mono, last_count_mono): + st["counted"] = True + st["side"] = feeder + if os.getenv("DEBUG_TRACKING", "").lower() == "true": + cool = feeder_cooldown_sec(feeder) + elapsed = mono - (last_count_mono.get(feeder) or mono) + print( + f"[DEBUG F{frame_idx}] COOLDOWN IGNORED: tid={tid} " + f"feeder={feeder} elapsed={elapsed:.2f}s / {cool}s" + ) + object_tracked[tid] = (cx, cy, mono) + continue + + if os.getenv("DEBUG_TRACKING", "").lower() == "true": + print( + f"[DEBUG F{frame_idx}] ZONE ENTER: tid={tid} " + f"cx={cx:.1f} cy={cy:.1f} feeder={feeder}" + ) + + recent.append((frame_idx, cx, cy)) + st["counted"] = True + st["side"] = feeder + last_count_mono[feeder] = mono + if feeder == "left": + counter_left += 1 + count_left_pulse = COUNT_PULSE_FRAMES + else: + counter_right += 1 + count_right_pulse = COUNT_PULSE_FRAMES + store.record_zone_entry(tid, feeder) + if cross_logger: + cross_logger.write_row( + [ + store.get_counting_date(), + frame_idx, + feeder, + tid, + ] + ) + object_crossed_frame = True + cross_events_frame.append((tid, feeder)) + crossing_times.append(mono) + object_cross_flash[tid] = CROSS_FLASH_FRAMES + popups.append( + { + "x": int(cx) - 12, + "y": int(cy), + "born": frame_idx, + "text": f"+1 {feeder.upper()}", + } + ) + object_tracked[tid] = (cx, cy, mono) + + for tid, (cx, cy) in object_lost_map.items(): + if tid not in object_tracked: + object_tracked[tid] = (cx, cy, mono) + + for di in range(len(object_boxes_xyxy)): + tid = object_det_to_track.get(di) + if tid is None: + continue + bbox = object_boxes_xyxy[di] + x1, y1, x2, y2 = int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3]) + flash = object_cross_flash.get(tid, 0) + color = C_GREEN if flash > 0 else C_OBJECT_BOX + cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3 if flash > 0 else 2) + draw_pill(frame, f"ID {tid}", x1, y1 - 4, color) + kpts = object_kpts_list[di] if di < len(object_kpts_list) else None + if kpts is not None: + draw_skeleton_bold(frame, kpts) + + if object_crossed_frame: + zone_pulse = ZONE_PULSE_FRAMES + + display_total = store.display_total() + while crossing_times and mono - crossing_times[0] > RATE_WINDOW_SEC: + crossing_times.popleft() + rate = (len(crossing_times) / RATE_WINDOW_SEC * 60) if crossing_times else 0.0 + + draw_elegant_zone( + frame, zone_left, zone_pulse, label="LEFT FEEDER", color=C_ZONE_LEFT + ) + draw_elegant_zone( + frame, zone_right, zone_pulse, label="RIGHT FEEDER", color=C_ZONE_RIGHT + ) + draw_zone_count( + frame, zone_left, counter_left, "LEFT", C_ZONE_LEFT, count_left_pulse + ) + draw_zone_count( + frame, zone_right, counter_right, "RIGHT", C_ZONE_RIGHT, count_right_pulse + ) + draw_hud( + frame, + w, + counter_left + counter_right, + counter_left, + counter_right, + elapsed, + rate, + ) + draw_footer( + frame, + w, + h, + frame_idx, + "LIVE" if IS_LIVE else "FILE", + inf_ms, + Path(MODEL_PATH).name, + ) + popups = draw_popups(frame, popups, frame_idx) + + if CONTROL_ENABLED and not counting_active: + badge = "COUNTING PAUSED" + (bw, bh), _ = cv2.getTextSize(badge, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2) + bx = w // 2 - bw // 2 + overlay_rect(frame, bx - 14, 48, bx + bw + 14, 48 + bh + 18, C_PANEL, alpha=0.75) + cv2.rectangle(frame, (bx - 14, 48), (bx + bw + 14, 48 + bh + 18), C_ACCENT, 2) + cv2.putText(frame, badge, (bx, 48 + bh + 6), cv2.FONT_HERSHEY_SIMPLEX, 0.6, C_ACCENT, 2, cv2.LINE_AA) + + for tid in list(object_cross_flash): + object_cross_flash[tid] -= 1 + if object_cross_flash[tid] <= 0: + del object_cross_flash[tid] + zone_pulse = max(0, zone_pulse - 1) + count_left_pulse = max(0, count_left_pulse - 1) + count_right_pulse = max(0, count_right_pulse - 1) + + if video_writer is not None: + video_writer.write(frame) + + if LIVE_STREAM_ENABLED and frame_idx % LIVE_STREAM_EVERY_N == 0: + try: + Path(LIVE_STREAM_FRAME_PATH).parent.mkdir(parents=True, exist_ok=True) + ok, jpeg = cv2.imencode( + ".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, LIVE_STREAM_QUALITY] + ) + if ok: + tmp_path = f"{LIVE_STREAM_FRAME_PATH}.tmp" + with open(tmp_path, "wb") as f: + f.write(jpeg.tobytes()) + os.replace(tmp_path, LIVE_STREAM_FRAME_PATH) + except Exception: + pass + + if (SAVE_DETECT_SNAPSHOT and detect_events_frame) or ( + SAVE_CROSS_SNAPSHOT and cross_events_frame + ): + try: + ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] + if SAVE_DETECT_SNAPSHOT and detect_events_frame: + detect_dir = Path(CROSS_SNAPSHOT_DIR) / "detect" + detect_dir.mkdir(parents=True, exist_ok=True) + for tid in detect_events_frame: + fname = f"{ts}_detect_id{tid}_f{frame_idx}.jpg" + cv2.imwrite( + str(detect_dir / fname), + frame, + [cv2.IMWRITE_JPEG_QUALITY, CROSS_SNAPSHOT_QUALITY], + ) + if SAVE_CROSS_SNAPSHOT and cross_events_frame: + cross_dir = Path(CROSS_SNAPSHOT_DIR) / "cross" + cross_dir.mkdir(parents=True, exist_ok=True) + for tid, feeder in cross_events_frame: + fname = f"{ts}_{feeder}_id{tid}_f{frame_idx}.jpg" + cv2.imwrite( + str(cross_dir / fname), + frame, + [cv2.IMWRITE_JPEG_QUALITY, CROSS_SNAPSHOT_QUALITY], + ) + if now - last_snapshot_cleanup >= CROSS_SNAPSHOT_CLEANUP_SEC: + cleanup_snapshots( + CROSS_SNAPSHOT_DIR, + CROSS_SNAPSHOT_MAX_FILES, + CROSS_SNAPSHOT_MAX_AGE_DAYS, + ) + last_snapshot_cleanup = now + except Exception as exc: + print(f"[{now_str()}] Failed to save snapshot: {exc}") + + frame_idx += 1 + prune_stale_tracks(object_tracked, mono) + + cap.release() + if video_writer is not None: + video_writer.release() + if cross_logger: + cross_logger.close() + if control_socket is not None: + try: + control_socket.close() + except Exception: + pass + model.release() + store.shutdown() + + print("\n=== Daily Counter Summary (SQLite) ===") + print(f"Database: {DB_PATH}") + + +object_tracked = {} + + +if __name__ == "__main__": + run() \ No newline at end of file diff --git a/counter_store.py b/counter_store.py new file mode 100644 index 0000000..3de4e73 --- /dev/null +++ b/counter_store.py @@ -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() diff --git a/env.example b/env.example new file mode 100644 index 0000000..cd4ca2c --- /dev/null +++ b/env.example @@ -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 + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f9305e3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +numpy<2 +rknn-toolkit-lite2 +opencv-python +flask +python-dotenv +openpyxl diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..9e54ef0 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,1220 @@ + + + + + +ZenAI KTC Dashboard + + + + + + +
+
+
+
+ +
+ +
+
+
◆
+
+

ZenAI KTC Module

+
Sack feeder zone counter — YOLO RockChip + ByteTrack
+
+
+
+
+ -- + Counting Day +
+
+ + {{ site_name }} +
+ SNAPSHOTS + {% if control_enabled %}{% endif %} + +
+
+ + +
+
+ LIVE +
+
--
+
Total Today (Left + Right)
+
+
+
Left
+
--
+
+
+
Right
+
--
+
+
+
+
+ Yesterday +
+
--
+
Total Yesterday (Left + Right)
+
+
+
Left
+
--
+
+
+
Right
+
--
+
+
+
+
+ + +
+ -- + -- + -- + -- + -- + -- + -- + -- + -- +
+ + +
+ -- + -- +
+ + +
+
+ ◉ Live Camera Feed +
+ + + waiting... + + +
+
+
+
+
▦
+
Live Feed Unavailable
+
Enable LIVE_STREAM_ENABLED=true in counter .env
+
+ +
Connection lost — retrying...
+
+
+ + +
+
+
+ ◉ Daily Trends +
+ + + +
+
+
+ +
+
+
+ ◉ Quick Stats +
+
+
+
+
+
Grand Total
+
All time (Left + Right)
+
+
+ -- +
+
+
+
+
+
Total Left
+
All time
+
+
+ -- +
+
+
+
+
+
Total Right
+
All time
+
+
+ -- +
+
+ +
+
◉ Recent Days
+
+ +
+
+
+
+ + +
+
+
+ ◉ Daily Records +
Daily counter totals at cut-off
+
+
+ + + + +
+
+
+ + + + + + + + + + + + + +
DateTotalLeftRightStatus
+
+
+ + +
+ + + + + + + \ No newline at end of file diff --git a/templates/snapshots.html b/templates/snapshots.html new file mode 100644 index 0000000..37dee24 --- /dev/null +++ b/templates/snapshots.html @@ -0,0 +1,489 @@ + + + + + +ZenAI KPC Snapshots + + + + + +
+
+
+ +
+ +
+
+
◆
+
+

ZenAI KPC Snapshots

+
Detected & counted frames — correlate by track ID
+
+
+
+
+ + {{ site_name }} +
+ DASHBOARD + SNAPSHOTS + +
+
+ + +
+
+
+ ◉ Zone Entry Snapshots +
--
+
+
+
+ + + + {% if show_detect %}{% endif %} +
+ + + + +
+
+
+ +
+
+ +
+ + +
+ + + + + + + + diff --git a/zenai-ktc-counter.service b/zenai-ktc-counter.service new file mode 100644 index 0000000..9bbb47b --- /dev/null +++ b/zenai-ktc-counter.service @@ -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 diff --git a/zenai-ktc-dashboard.service b/zenai-ktc-dashboard.service new file mode 100644 index 0000000..e156bab --- /dev/null +++ b/zenai-ktc-dashboard.service @@ -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 diff --git a/zone_preview.jpg b/zone_preview.jpg new file mode 100644 index 0000000..af58de0 Binary files /dev/null and b/zone_preview.jpg differ