Recount Dashboard
This commit is contained in:
commit
fe220504fd
21 files changed
+11350
No files matched your search
+227
@@ -0,0 +1,227 @@
|
||||
# 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
|
||||
|
||||
# csv and db
|
||||
*.csv
|
||||
*.db
|
||||
batch_crossings.csv
|
||||
bytetrack_counter.db
|
||||
|
||||
+5487
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,50 @@
|
||||
# AGENTS.md — bytetrack-counter
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Edge AI counter**: RTSP camera → YOLO RKNN (NPU) → tracking → line-crossing → SQLite + JSON state → Flask dashboard.
|
||||
- **2 counter scripts**, only 1 deployed:
|
||||
- `counter_live.py` — Jetson TensorRT variant (CUDA, NOT used on RK3588).
|
||||
- **`counter_live_rknn_bytetrack.py`** — RK3588 with ByteTrack. **This is what systemd runs.** Reference for C++ port.
|
||||
- `batch_store.py` — shared SQLite persistence + batch state machine.
|
||||
- `counter_dashboard.py` — Flask dashboard on port 5000, same DB.
|
||||
|
||||
## No build / test / lint
|
||||
|
||||
There is no build system, no test framework, no linter config, no typechecker.
|
||||
Do not try to run `pytest`, `ruff`, `mypy`, etc. — they don't exist here.
|
||||
|
||||
## How to run
|
||||
|
||||
```bash
|
||||
# Copy env (required, .env is gitignored)
|
||||
cp config.env.example .env
|
||||
|
||||
# Venv (must use system-site-packages for RKNN toolkit)
|
||||
python3 -m venv --system-site-packages venv
|
||||
source venv/bin/pip install -r requirements.txt
|
||||
|
||||
# Run counter (RK3588 only — needs rknn-toolkit-lite2 & RKNN model)
|
||||
PYTHONNOUSERSITE=1 venv/bin/python counter_live_rknn_bytetrack.py
|
||||
|
||||
# Run dashboard
|
||||
PYTHONNOUSERSITE=1 venv/bin/python counter_dashboard.py
|
||||
```
|
||||
|
||||
## Key environment & install quirks
|
||||
|
||||
- **`PYTHONNOUSERSITE=1`** is mandatory when running from the venv — without it, system/user packages leak in.
|
||||
- **`.env` is gitignored** — always copy from `config.env.example` first.
|
||||
- **`numpy<2`** is required for `rknn-toolkit-lite2` compatibility.
|
||||
- **Install path in service files is `/opt/bytetrack-counter`** (not the `/opt/jetson-counter` mentioned in README/DEPLOY). The `.env.example` also reflects `/opt/bytetrack-counter`.
|
||||
- Service user is **`root`**, not `jetson` (despite README saying otherwise).
|
||||
- Two systemd units: `bytetrack-counter.service` + `bytetrack-counter-dashboard.service`.
|
||||
- `counter_live.py` (TensorRT) is Jetson-only and won't work on RK3588.
|
||||
|
||||
## Code conventions
|
||||
|
||||
- All config lives in `.env` (dotenv), read via `os.getenv()` at module top-level in each script.
|
||||
- The 3 counter scripts duplicate ~80% of each other (drawing helpers, batch loop, etc). Changes to logic may need replication across variants.
|
||||
- `batch_store.py` has its own threading (cutoff watcher, batch timeout timer) — thread safety is via a single `state_lock`.
|
||||
- The dashboard re-creates DB tables on startup (`_ensure_db()`) independently from `batch_store.py`.
|
||||
- No formal version tracking exists anywhere in this codebase.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Edge Jetson Deploy
|
||||
|
||||
Production counter: **direct LAN RTSP** + **YOLO11n TensorRT** + SQLite batch store.
|
||||
Replaces MQTT `frigate-counter` on the edge Jetson.
|
||||
|
||||
## Quick install
|
||||
|
||||
```bash
|
||||
# 1. Copy this folder to Jetson
|
||||
sudo mkdir -p /opt/jetson-counter
|
||||
sudo cp -r jetson-counter/* /opt/jetson-counter/
|
||||
sudo chown -R jetson:jetson /opt/jetson-counter
|
||||
|
||||
# 2. Configure
|
||||
cd /opt/jetson-counter
|
||||
cp config.env.example .env
|
||||
nano .env # SOURCE, MODEL_PATH, CAMERA_NAME, etc.
|
||||
sed -i 's/\r$//' .env
|
||||
|
||||
# 3. Venv + services
|
||||
chmod +x setup-venv.sh install-services.sh
|
||||
sudo ./setup-venv.sh
|
||||
sudo ./install-services.sh
|
||||
```
|
||||
|
||||
Dashboard: `http://<jetson-ip>:5000`
|
||||
|
||||
---
|
||||
|
||||
## YOLO11n TensorRT engine (one-time)
|
||||
|
||||
On the Jetson (must match `IMGSZ` / `HALF` in `.env`):
|
||||
|
||||
```bash
|
||||
source /opt/jetson-counter/venv/bin/activate
|
||||
export PYTHONNOUSERSITE=1
|
||||
|
||||
yolo export model=/media/jetson/DATA/yolo11n.pt format=engine half=True imgsz=416 device=0
|
||||
```
|
||||
|
||||
Verify classes:
|
||||
|
||||
```bash
|
||||
PYTHONNOUSERSITE=1 python -c "
|
||||
from ultralytics import YOLO
|
||||
m = YOLO('/media/jetson/DATA/yolo11n.engine')
|
||||
print(m.names)
|
||||
"
|
||||
```
|
||||
|
||||
Expect `ayam` and `talenan`.
|
||||
|
||||
---
|
||||
|
||||
## Direct camera RTSP
|
||||
|
||||
Set in `.env`:
|
||||
|
||||
```env
|
||||
SOURCE=rtsp://user:pass@192.168.x.x:554/stream1
|
||||
```
|
||||
|
||||
Test before install:
|
||||
|
||||
```bash
|
||||
ffplay -rtsp_transport tcp -t 5 "$SOURCE"
|
||||
nc -zv <camera-ip> 554
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cutover from MQTT frigate-counter
|
||||
|
||||
`install-services.sh` automatically:
|
||||
|
||||
1. Disables `frigate-counter` and `frigate-counter-dashboard`
|
||||
2. Enables `jetson-counter` + `jetson-counter-dashboard`
|
||||
|
||||
Archive old DB (optional):
|
||||
|
||||
```bash
|
||||
sudo cp /opt/frigate-counter/frigate_counter.db ~/frigate_counter.db.backup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation checklist
|
||||
|
||||
```bash
|
||||
sudo systemctl is-active jetson-counter jetson-counter-dashboard
|
||||
PYTHONNOUSERSITE=1 /opt/jetson-counter/venv/bin/python -c "import torch; print('cuda', torch.cuda.is_available())"
|
||||
sudo journalctl -u jetson-counter -n 20 --no-pager
|
||||
```
|
||||
|
||||
Good signs:
|
||||
|
||||
- `Stream ready!`
|
||||
- `Loaded engine size: ... MiB`
|
||||
- `Frame 100 | Batch ...`
|
||||
|
||||
---
|
||||
|
||||
## Logs & restart
|
||||
|
||||
```bash
|
||||
sudo journalctl -u jetson-counter -f
|
||||
sudo systemctl restart jetson-counter # after .env change
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JetPack 6.0 torch wheel
|
||||
|
||||
If `setup-venv.sh` fails on torch URL, list wheels:
|
||||
|
||||
```bash
|
||||
curl -s https://developer.download.nvidia.com/compute/redist/jp/v60/pytorch/ | grep cp310
|
||||
```
|
||||
|
||||
Set `TORCH_WHEEL_URL=...` when running `setup-venv.sh`.
|
||||
|
||||
See also [jetson-counter-dev/GO_LIVE_TROUBLESHOOT.md](../jetson-counter-dev/GO_LIVE_TROUBLESHOOT.md) for torchvision and RTSP issues.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Jetson Edge Counter (Production)
|
||||
|
||||
RTSP + YOLO TensorRT line-crossing counter for edge Jetson. Replaces MQTT `frigate-counter` on site.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Input:** Direct LAN camera RTSP (low latency)
|
||||
- **Inference:** YOLO11n `.engine` (TensorRT) on Jetson GPU
|
||||
- **Logic:** Line crossing (`ayam` count, `talenan` closes batch) via `batch_store.py`
|
||||
- **Output:** `jetson_counter.db` + `current_batch.json`
|
||||
- **Dashboard:** Flask on port **5000**
|
||||
|
||||
Batch lifecycle: talenan closes batch → idle until next ayam line cross (count starts at 1).
|
||||
|
||||
## Deploy
|
||||
|
||||
See **[DEPLOY.md](DEPLOY.md)**.
|
||||
|
||||
| Item | Default |
|
||||
|------|---------|
|
||||
| Install path | `/opt/jetson-counter` |
|
||||
| Venv | `/opt/jetson-counter/venv` |
|
||||
| DB | `/opt/jetson-counter/jetson_counter.db` |
|
||||
| Dashboard | `http://<jetson-ip>:5000` |
|
||||
| Cutoff | `20:00` |
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `sudo systemctl status jetson-counter` | Counter running? |
|
||||
| `sudo journalctl -u jetson-counter -f` | Live logs |
|
||||
| `sudo systemctl restart jetson-counter` | After `.env` change |
|
||||
| `sudo ./uninstall-services.sh` | Remove services |
|
||||
|
||||
## Key env vars
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `SOURCE` | Direct camera RTSP URL |
|
||||
| `MODEL_PATH` | `.engine` file path |
|
||||
| `IMGSZ` / `HALF` | Must match engine export |
|
||||
| `CROSS_DIRECTION` | `rtl` (default), `ltr`, or `both` |
|
||||
| `LINE_X` / `LINE_X_FRAC` | Counting line position |
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `counter_live.py` | RTSP + YOLO + line crossing |
|
||||
| `batch_store.py` | SQLite persistence |
|
||||
| `counter_dashboard.py` | Flask UI |
|
||||
| `config.env.example` | Env template |
|
||||
| `jetson-counter.service` | Counter systemd unit |
|
||||
| `install-services.sh` | Install + disable legacy MQTT counter |
|
||||
|
||||
## Dev stack
|
||||
|
||||
Lab / comparison: [`jetson-counter-dev/`](../jetson-counter-dev/) (port 8081, separate DB).
|
||||
@@ -0,0 +1,155 @@
|
||||
# SPEC — bytetrack-counter
|
||||
|
||||
## §G — Goal
|
||||
|
||||
RTSP camera → YOLO RKNN (NPU) → ByteTrack → line-crossing counter → SQLite + Flask dashboard.
|
||||
Count `ayam` (chicken) crossing counting line. Close batch on `talenan` (cutting-board) crossing.
|
||||
Daily cutoff @ HH:MM resets batch numbering. RK3588 hardware.
|
||||
**Reference implementation for C++ port in separate repo.**
|
||||
|
||||
## §C — Constraints
|
||||
|
||||
- Python 3.10, `rknn-toolkit-lite2` (NPU), `opencv-python` (RTSP/FFmpeg)
|
||||
- `numpy<2` (rknn-toolkit-lite2 incompatible with numpy≥2)
|
||||
- `PYTHONNOUSERSITE=1` ! set or venv breaks
|
||||
- SQLite for persistence, JSON file for active-batch state
|
||||
- Flask on port 5000 (dashboard) + 5002 (recounting), systemd supervision
|
||||
- `counter_live.py` ⊥ run on RK3588 — Jetson TensorRT artifact, ! port target
|
||||
- `counter_live_rknn_bytetrack.py` — reference for C++ port, this is what systemd runs
|
||||
- Single deployment: `counter_live_rknn_bytetrack.py` + `counter_dashboard.py` + `recounting_dashboard.py`
|
||||
- `go2rtc` ! running for recounting MP4 streaming (port 1984)
|
||||
|
||||
## §I — Interfaces
|
||||
|
||||
### Systemd
|
||||
```
|
||||
unit: bytetrack-counter.service → `venv/bin/python counter_live_rknn_bytetrack.py`
|
||||
unit: bytetrack-counter-dashboard.service → `venv/bin/python counter_dashboard.py`
|
||||
unit: bytetrack-recounting-dashboard.service → `venv/bin/python recounting_dashboard.py`
|
||||
env: PYTHONNOUSERSITE=1 ! set in all units
|
||||
env: EnvironmentFile=/opt/bytetrack-counter/.env
|
||||
user: root (not jetson)
|
||||
path: /opt/bytetrack-counter (fixed in service file)
|
||||
```
|
||||
|
||||
### `.env` config (40+ vars, gitignored)
|
||||
```
|
||||
env: SOURCE ! set → RTSP URL | file path
|
||||
env: MODEL_PATH ! set → .rknn file
|
||||
env: IMGSZ ! set → model input size (e.g. 320)
|
||||
env: CORE_MASK → NPU core mask (1=core0, 2=core1, 3=dual, 7=all)
|
||||
env: NUM_CLASSES ! match model output count
|
||||
env: CONF → detection confidence threshold (default 0.3)
|
||||
env: DAILY_CUTOFF_TIME → HH:MM, default "20:00"
|
||||
env: CROSS_DIRECTION → rtl | ltr | both (default rtl)
|
||||
env: LINE_X | LINE_X_FRAC → counting line position
|
||||
env: CLASS_AYAM → class name for counted object (index 0)
|
||||
env: CLASS_TALENAN → class name for batch-close trigger (index 1)
|
||||
env: RESET_COUNTERS_AT_CUTOFF → defined in example, ! consumed by code ? zombie
|
||||
env: MOTION_DETECTION_ENABLED | MOTION_THRESHOLD → skip inference on still frames ?
|
||||
env: TRACK_HIGH_THRESH_{0,1} | TRACK_LOW_THRESH_{0,1} | TRACK_MATCH_THRESH_{0,1} → ByteTrack params per class
|
||||
env: BATCH_TIMEOUT_SECONDS → auto-close after inactivity (default 300)
|
||||
env: IGNORE_BATCH_LABEL_TIMEOUT_SECONDS → suppress talenan close after batch start (default 30)
|
||||
env: MIN_OBJECT_PER_BATCH → min count to persist batch (default 60)
|
||||
env: MIN_DURATION_PER_BATCH → min seconds to persist batch (default 60)
|
||||
env: LIVE_STREAM_ENABLED → write annotated JPEG snapshot each N frames
|
||||
env: EXPORT_CSV → write per-crossing CSV (default true)
|
||||
|
||||
### Recounting dashboard config
|
||||
```
|
||||
env: RECOUNTING_DASHBOARD_PORT → port for recounting UI (default 5002)
|
||||
env: LIVE_API_URL → base URL of live counter API (default http://localhost:5000)
|
||||
env: RECOUNT_API_URL → base URL of recounting counter API (second node)
|
||||
env: GO2RTC_API_URL → go2rtc REST API (default http://localhost:1984)
|
||||
env: GO2RTC_STREAM_NAME → go2rtc stream name for recount preview (default "recount")
|
||||
```
|
||||
```
|
||||
|
||||
### SQLite
|
||||
```
|
||||
table: batches (date, batch#, camera, label, count, start, end, created_at)
|
||||
UNIQUE(counting_date, batch_number, camera_name, object_label)
|
||||
table: daily_summaries (date, camera, label, total_count, total_batches, updated_at)
|
||||
UNIQUE(counting_date, camera_name, object_label)
|
||||
```
|
||||
|
||||
### JSON state file
|
||||
```
|
||||
path: /tmp/bytetrack_current_batch.json (default)
|
||||
schema: {counting_date, batch_number, count, start_time, last_detection_time, counted_event_ids[]}
|
||||
```
|
||||
|
||||
### Flask API
|
||||
```
|
||||
api: GET / → dashboard HTML
|
||||
api: GET /api/current-batch → {count, batch_number, counting_date, start_time, last_detection_time}
|
||||
api: GET /api/previous-batch → {date, batch#, count, start/end, duration_minutes}
|
||||
api: GET /api/summary → {today, yesterday, all_time, average_per_day, best_day}
|
||||
api: GET /api/daily-data?days=N → [ {date, total_count, total_batches, avg_per_batch} ]
|
||||
api: GET /api/day-detail/<date> → {date, total_count, total_batches, total_duration, avg_duration, batches[]}
|
||||
api: GET /api/recent-batches?limit=N → [ {date, batch#, count, start/end, duration} ]
|
||||
api: GET /api/available-dates → [ {date, total_count, total_batches} ]
|
||||
api: GET /api/export-daily-csv?days=N → .xlsx download (named csv, emits xlsx)
|
||||
api: GET /api/export-day-csv/<date> → .xlsx download (named csv, emits xlsx)
|
||||
api: GET /api/live-video → MJPEG stream from shared-memory JPEG
|
||||
|
||||
### Recounting dashboard
|
||||
```
|
||||
api: GET / → recounting HTML
|
||||
api: GET /api/live-progress → proxy to LIVE_API_URL:/api/current-batch
|
||||
api: GET /api/recount-progress → proxy to RECOUNT_API_URL:/api/current-batch
|
||||
api: GET /api/mp4-files → [ {name, path, size, mtime} ... ]
|
||||
api: POST /api/start-recount {path} → configure go2rtc stream, return {stream_url}
|
||||
api: POST /api/stop-recount → tear down go2rtc stream
|
||||
```
|
||||
```
|
||||
|
||||
## §V — Invariants
|
||||
|
||||
```
|
||||
V1: NUM_CLASSES must match model output → class 0=ayam, class 1=talenan
|
||||
V2: line crossing → prev_cx > line_x ≥ cx (rtl) | prev_cx < line_x ≤ cx (ltr)
|
||||
V3: ∀ track_id → counted at most once per batch (counted_event_ids set)
|
||||
V4: batch persisted → count ≥ MIN_OBJECT_PER_BATCH & duration ≥ MIN_DURATION_PER_BATCH
|
||||
V5: dt.time() < DAILY_CUTOFF_TIME → counting_date = today, else tomorrow
|
||||
V6: talenan crossing → close batch, but ignored ∀ IGNORE_BATCH_LABEL_TIMEOUT_SEC after batch start
|
||||
V7: batch inactivity ≥ BATCH_TIMEOUT_SECONDS → auto-close
|
||||
V8: PYTHONNOUSERSITE=1 ! set for venv isolation
|
||||
V9: .env ! exist before counter or dashboard starts
|
||||
V10: DB tables ! exist on startup (created if absent, both store & dashboard)
|
||||
V11: previous batch → last CARRY_IDS (default 50) track IDs carried forward to next batch
|
||||
V12: ∃ ! batch per (date, batch#, camera, label) — UNIQUE constraint in DB
|
||||
V13: counter & dashboard share DB path → no process-level coordination
|
||||
V14: stream disconnect → reconnect with delay (RECONNECT_DELAY_SEC), ! block main loop
|
||||
V15: model inference → letterbox-resize to IMGSZ×IMGSZ, BGR→RGB, run NPU
|
||||
V16: NMS postprocessing → iou_thr=0.45, class-aware grouping, score > CONF
|
||||
V17: tracks pruned after TRACKED_PRUNE_SEC (default 300s) without update
|
||||
V18: .env missing → scripts fail at import (os.getenv falls back to defaults, may mismatch)
|
||||
V19: stream reconnect → reset motion detection state (prev_gray = None)
|
||||
```
|
||||
|
||||
## §T — Tasks
|
||||
|
||||
```
|
||||
id|status|task|cites
|
||||
T1|.|unify install paths — README/DEPLOY/setup-venv.sh/uninstall say `/opt/jetson-counter`, service files & .env.example say `/opt/bytetrack-counter`|
|
||||
T2|.|zombie var RESET_COUNTERS_AT_CUTOFF — defined in .env.example, unused in code (confirmed by review)|
|
||||
T3|.|add version flag — no `--version` or git-derived version exists|
|
||||
T4|.|keep reference Python clean — counter_live.py is artifact; focus edits on counter_live_rknn_bytetrack.py as port source|
|
||||
T5|.|rename export routes — routes named `export-daily-csv` but emit `.xlsx`|I.flask
|
||||
T6|.|fix live-stream snapshot path — some scripts default `/dev/shm/jetson-counter/`, example says `bytetrack-counter`|
|
||||
T7|.|add dashboard health-check endpoint (no `/health` or `/api/status` exists)|
|
||||
T8|.|add model checks on startup — model class names ! validated against CLASS_AYAM/CLASS_TALENAN (TensorRT variant validates; RKNN variants do not)|
|
||||
T9|.|reset prev_gray on stream reconnect — stale gray ref causes crash or false motion|V19,B1
|
||||
T10|.|decay inf_ms toward 0 when inference skipped — stale display misleads operator|
|
||||
T11|x|reduce live-counter poll interval 2000→200ms for real-time feel|
|
||||
T12|x|add recounting dashboard — dual-API counter panels, MP4 browser, go2rtc streaming|
|
||||
```
|
||||
|
||||
## §B — Bugs
|
||||
|
||||
```
|
||||
id|date|cause|fix
|
||||
B1|2026-07-29|prev_gray not reset on stream reconnect → cv2.absdiff crash or false motion|V19
|
||||
```
|
||||
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
"""
|
||||
Production batch persistence for edge Jetson counter.
|
||||
Mirrors frigate-counter SQLite schema + current_batch.json contract.
|
||||
"""
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class BatchStore:
|
||||
def __init__(
|
||||
self,
|
||||
db_path,
|
||||
state_file,
|
||||
camera_name,
|
||||
object_label='ayam-potong',
|
||||
cutoff_time='20:00',
|
||||
batch_timeout=300.0,
|
||||
ignore_batch_label_timeout=30.0,
|
||||
min_object_per_batch=60,
|
||||
min_duration_per_batch=60,
|
||||
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.batch_timeout = float(batch_timeout)
|
||||
self.ignore_batch_label_timeout = float(ignore_batch_label_timeout)
|
||||
self.min_object_per_batch = int(min_object_per_batch)
|
||||
self.min_duration_per_batch = int(min_duration_per_batch)
|
||||
self.carry_ids = int(carry_ids)
|
||||
self.log = logger
|
||||
|
||||
self.state_lock = threading.Lock()
|
||||
self.batch_timer = None
|
||||
self.ignore_batch_label = False
|
||||
self.ignore_batch_label_timer = None
|
||||
self.previous_state = None
|
||||
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()
|
||||
self.previous_state = self.current_state
|
||||
|
||||
def _init_db(self):
|
||||
cur = self.db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS batches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
counting_date TEXT NOT NULL,
|
||||
batch_number INTEGER NOT NULL,
|
||||
camera_name TEXT NOT NULL,
|
||||
object_label TEXT NOT NULL,
|
||||
count INTEGER NOT NULL,
|
||||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(counting_date, batch_number, camera_name, object_label)
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS daily_summaries (
|
||||
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_batches INTEGER NOT NULL DEFAULT 0,
|
||||
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 _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 ({state.get('counting_date')}). "
|
||||
'Finalizing before fresh start.'
|
||||
)
|
||||
self._insert_batch(
|
||||
state['counting_date'],
|
||||
state['batch_number'],
|
||||
state['count'],
|
||||
state['start_time'],
|
||||
datetime.now().isoformat(),
|
||||
)
|
||||
self.state_file.unlink(missing_ok=True)
|
||||
return None
|
||||
self.log(
|
||||
f"Resumed batch #{state['batch_number']} from {state['start_time']} "
|
||||
f"with count={state['count']}"
|
||||
)
|
||||
self._reset_batch_timer()
|
||||
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 get_next_batch_number(self, counting_date):
|
||||
cur = self.db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(MAX(batch_number), 0)
|
||||
FROM batches
|
||||
WHERE counting_date = ? AND camera_name = ? AND object_label = ?
|
||||
""",
|
||||
(counting_date, self.camera_name, self.object_label),
|
||||
)
|
||||
return cur.fetchone()[0] + 1
|
||||
|
||||
def start_new_batch(self, counting_date):
|
||||
batch_number = self.get_next_batch_number(counting_date)
|
||||
now = datetime.now().isoformat()
|
||||
counted_ids = []
|
||||
if self.previous_state is not None:
|
||||
try:
|
||||
counted_ids = self.previous_state['counted_event_ids'][-self.carry_ids:]
|
||||
except (KeyError, TypeError):
|
||||
counted_ids = []
|
||||
self.current_state = {
|
||||
'counting_date': counting_date,
|
||||
'batch_number': batch_number,
|
||||
'count': 0,
|
||||
'start_time': now,
|
||||
'last_detection_time': now,
|
||||
'counted_event_ids': counted_ids,
|
||||
}
|
||||
self.save_state()
|
||||
self.log(f'Started batch #{batch_number} for {counting_date} ({self.object_label})')
|
||||
|
||||
def _reset_batch_timer(self):
|
||||
if self.batch_timer:
|
||||
self.batch_timer.cancel()
|
||||
self.batch_timer = threading.Timer(self.batch_timeout, self._on_batch_timeout)
|
||||
self.batch_timer.daemon = True
|
||||
self.batch_timer.start()
|
||||
|
||||
def _on_batch_timeout(self):
|
||||
self.log(f'Batch inactivity timeout ({self.batch_timeout}s) reached')
|
||||
self.end_batch(closed_by='timeout')
|
||||
|
||||
def _ignore_batch_label(self):
|
||||
if not self.ignore_batch_label_timer:
|
||||
self.ignore_batch_label = True
|
||||
self.ignore_batch_label_timer = threading.Timer(
|
||||
self.ignore_batch_label_timeout, self._on_ignore_batch_label_timeout
|
||||
)
|
||||
self.ignore_batch_label_timer.daemon = True
|
||||
self.ignore_batch_label_timer.start()
|
||||
self.log(
|
||||
f'Ignore batch label for {self.ignore_batch_label_timeout}s'
|
||||
)
|
||||
|
||||
def _on_ignore_batch_label_timeout(self):
|
||||
self.ignore_batch_label_timer = None
|
||||
self.ignore_batch_label = False
|
||||
self.log('Ignore batch label cooldown finished')
|
||||
|
||||
def record_ayam_crossing(self, track_id):
|
||||
"""Line-cross equivalent of production ayam-potong MQTT event."""
|
||||
with self.state_lock:
|
||||
counting_date = self.get_counting_date()
|
||||
started_new = False
|
||||
if self.current_state is None:
|
||||
self.start_new_batch(counting_date)
|
||||
started_new = True
|
||||
elif self.current_state['counting_date'] != counting_date:
|
||||
self._end_batch_locked(closed_by='cutoff')
|
||||
self.start_new_batch(counting_date)
|
||||
started_new = True
|
||||
|
||||
event_key = str(track_id)
|
||||
if event_key not in self.current_state['counted_event_ids']:
|
||||
self.current_state['count'] += 1
|
||||
self.current_state['counted_event_ids'].append(event_key)
|
||||
self.log(
|
||||
f'Counted ayam (track {track_id}) | batch #{self.current_state["batch_number"]} '
|
||||
f'total: {self.current_state["count"]}'
|
||||
)
|
||||
|
||||
self.current_state['last_detection_time'] = datetime.now().isoformat()
|
||||
self.save_state()
|
||||
self._reset_batch_timer()
|
||||
return self.current_state['count'], started_new
|
||||
|
||||
def record_talenan_crossing(self, track_id):
|
||||
"""Line-cross equivalent of production telenan MQTT batch close."""
|
||||
if self.ignore_batch_label:
|
||||
return False
|
||||
with self.state_lock:
|
||||
self._ignore_batch_label()
|
||||
self._end_batch_locked(closed_by='talenan')
|
||||
self.log(f'Batch closed by talenan (track {track_id})')
|
||||
if self.batch_timer:
|
||||
self.batch_timer.cancel()
|
||||
self.batch_timer = None
|
||||
return True
|
||||
|
||||
def end_batch(self, closed_by='manual'):
|
||||
with self.state_lock:
|
||||
self._end_batch_locked(closed_by=closed_by)
|
||||
|
||||
def _end_batch_locked(self, closed_by='manual'):
|
||||
if self.current_state is None:
|
||||
return False
|
||||
|
||||
self.previous_state = self.current_state
|
||||
state = self.current_state
|
||||
|
||||
start_time_obj = datetime.fromisoformat(state['start_time'])
|
||||
end_time_obj = datetime.now()
|
||||
duration_seconds = (end_time_obj - start_time_obj).total_seconds()
|
||||
|
||||
if (state['count'] < self.min_object_per_batch
|
||||
or duration_seconds < self.min_duration_per_batch):
|
||||
self.current_state = None
|
||||
self.save_state()
|
||||
if self.batch_timer:
|
||||
self.batch_timer.cancel()
|
||||
self.batch_timer = None
|
||||
self.log(
|
||||
f'Batch #{state["batch_number"]} discarded '
|
||||
f'(count={state["count"]}, duration={duration_seconds:.0f}s)'
|
||||
)
|
||||
return False
|
||||
|
||||
end_time = end_time_obj.isoformat()
|
||||
try:
|
||||
self._insert_batch(
|
||||
state['counting_date'],
|
||||
state['batch_number'],
|
||||
state['count'],
|
||||
state['start_time'],
|
||||
end_time,
|
||||
)
|
||||
cps = state['count'] / duration_seconds if duration_seconds > 0 else 0
|
||||
self.log(
|
||||
f'Batch #{state["batch_number"]} ended | count={state["count"]} | '
|
||||
f'duration={duration_seconds:.0f}s | cps={cps:.3f} | closed_by={closed_by}'
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log(f'Failed to persist batch: {exc}')
|
||||
return False
|
||||
|
||||
self.current_state = None
|
||||
self.save_state()
|
||||
if self.batch_timer:
|
||||
self.batch_timer.cancel()
|
||||
self.batch_timer = None
|
||||
return True
|
||||
|
||||
def _insert_batch(self, counting_date, batch_number, count, start_time, end_time):
|
||||
cur = self.db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO batches
|
||||
(counting_date, batch_number, camera_name, object_label, count, start_time, end_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(counting_date, batch_number, self.camera_name, self.object_label, count, start_time, end_time),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO daily_summaries
|
||||
(counting_date, camera_name, object_label, total_count, total_batches)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
ON CONFLICT(counting_date, camera_name, object_label)
|
||||
DO UPDATE SET
|
||||
total_count = total_count + excluded.total_count,
|
||||
total_batches = total_batches + excluded.total_batches,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(counting_date, self.camera_name, self.object_label, count),
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT total_count, total_batches
|
||||
FROM daily_summaries
|
||||
WHERE counting_date = ? AND camera_name = ? AND object_label = ?
|
||||
""",
|
||||
(counting_date, self.camera_name, self.object_label),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
self.log(
|
||||
f'Daily totals for {counting_date}: {row[0]} objects across {row[1]} batch(es)'
|
||||
)
|
||||
|
||||
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 batch')
|
||||
self._end_batch_locked(closed_by='cutoff')
|
||||
|
||||
def start_cutoff_watcher(self):
|
||||
t = threading.Thread(target=self.cutoff_watcher_loop, daemon=True)
|
||||
t.start()
|
||||
return t
|
||||
|
||||
@property
|
||||
def current_batch_number(self):
|
||||
if self.current_state is None:
|
||||
return 0
|
||||
return self.current_state['batch_number']
|
||||
|
||||
@property
|
||||
def current_batch_count(self):
|
||||
if self.current_state is None:
|
||||
return 0
|
||||
return self.current_state['count']
|
||||
|
||||
def get_closed_total_for_day(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)
|
||||
FROM daily_summaries
|
||||
WHERE counting_date = ? AND camera_name = ? AND object_label = ?
|
||||
""",
|
||||
(counting_date, self.camera_name, self.object_label),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
def display_total(self):
|
||||
return self.get_closed_total_for_day() + self.current_batch_count
|
||||
|
||||
def shutdown(self):
|
||||
self.shutdown_event.set()
|
||||
self.end_batch(closed_by='shutdown')
|
||||
if self.batch_timer:
|
||||
self.batch_timer.cancel()
|
||||
self.db.close()
|
||||
@@ -0,0 +1,33 @@
|
||||
[Unit]
|
||||
Description=Nano Edge Counter Dashboard (Flask, port 5000)
|
||||
Documentation=file:///opt/bytetrack-counter/DEPLOY.md
|
||||
After=network-online.target bytetrack-counter.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
WorkingDirectory=/opt/bytetrack-counter
|
||||
|
||||
EnvironmentFile=/opt/bytetrack-counter/.env
|
||||
Environment=PATH=/opt/bytetrack-counter/venv/bin:/usr/local/bin:/usr/bin:/bin
|
||||
Environment=FLASK_DEBUG=false
|
||||
|
||||
ExecStart=/opt/bytetrack-counter/venv/bin/python counter_dashboard.py
|
||||
|
||||
TimeoutStopSec=15
|
||||
KillSignal=SIGTERM
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StartLimitInterval=60s
|
||||
StartLimitBurst=3
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectHome=true
|
||||
PrivateTmp=false
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,35 @@
|
||||
[Unit]
|
||||
Description=NanoPi Edge YOLO Batch Counter (RTSP + RKNN)
|
||||
Documentation=file:///opt/bytetrack-counter/DEPLOY.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
WorkingDirectory=/opt/bytetrack-counter
|
||||
|
||||
EnvironmentFile=/opt/bytetrack-counter/.env
|
||||
Environment=PYTHONNOUSERSITE=1
|
||||
Environment=YOLO_CONFIG_DIR=/opt/bytetrack-counter/.ultralytics
|
||||
Environment=TORCH_HOME=/opt/bytetrack-counter/.torch
|
||||
Environment=PATH=/opt/bytetrack-counter/venv/bin:/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
ExecStart=/opt/bytetrack-counter/venv/bin/python counter_live_rknn_bytetrack.py
|
||||
|
||||
TimeoutStopSec=30
|
||||
KillSignal=SIGTERM
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StartLimitInterval=120s
|
||||
StartLimitBurst=5
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectHome=true
|
||||
PrivateTmp=false
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,34 @@
|
||||
[Unit]
|
||||
Description=Edge Counter Recounting Dashboard (Flask, port 5002)
|
||||
Documentation=file:///opt/bytetrack-counter/DEPLOY.md
|
||||
After=network-online.target bytetrack-counter-dashboard.service
|
||||
Wants=network-online.target bytetrack-counter-dashboard.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
WorkingDirectory=/opt/bytetrack-counter
|
||||
|
||||
EnvironmentFile=/opt/bytetrack-counter/.env
|
||||
Environment=PATH=/opt/bytetrack-counter/venv/bin:/usr/local/bin:/usr/bin:/bin
|
||||
Environment=PYTHONNOUSERSITE=1
|
||||
Environment=FLASK_DEBUG=false
|
||||
|
||||
ExecStart=/opt/bytetrack-counter/venv/bin/python recounting_dashboard.py
|
||||
|
||||
TimeoutStopSec=15
|
||||
KillSignal=SIGTERM
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StartLimitInterval=60s
|
||||
StartLimitBurst=3
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectHome=true
|
||||
PrivateTmp=false
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,173 @@
|
||||
# =============================================================================
|
||||
# Edge RK3588 production counter + dashboard
|
||||
# Shared config for: counter_live_rknn_bytetrack.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/bytetrack-counter
|
||||
# SQLite database path for batch entries & crossing logs
|
||||
DB_PATH=/opt/bytetrack-counter/bytetrack_counter.db
|
||||
# JSON file persisting the current active batch state
|
||||
STATE_FILE=/tmp/bytetrack_current_batch.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 (e.g. 2 = ayam + talenan)
|
||||
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
|
||||
|
||||
# --- ByteTrack tracking ---
|
||||
# General for ayam, index 0
|
||||
# Detections with score ≥ this get priority matching in the first association stage
|
||||
TRACK_HIGH_THRESH_0=0.5
|
||||
# Detections with score between this and TRACK_HIGH_THRESH are matched in the second stage
|
||||
TRACK_LOW_THRESH_0=0.1
|
||||
# IoU threshold for the first-stage association (0–1). Higher = stricter overlap required
|
||||
TRACK_MATCH_THRESH_0=0.8
|
||||
# Frames a track survives without a match before being permanently removed
|
||||
TRACK_BUFFER_0=30
|
||||
# Minimum consecutive (or total) hits needed before a track is considered confirmed
|
||||
TRACK_MIN_HITS_0=3
|
||||
|
||||
# For talenan, index 1
|
||||
# Detections with score ≥ this get priority matching in the first association stage
|
||||
TRACK_HIGH_THRESH_1=0.5
|
||||
# Detections with score between this and TRACK_HIGH_THRESH are matched in the second stage
|
||||
TRACK_LOW_THRESH_1=0.1
|
||||
# IoU threshold for the first-stage association (0–1). Higher = stricter overlap required
|
||||
TRACK_MATCH_THRESH_1=0.6
|
||||
# Frames a track survives without a match before being permanently removed
|
||||
TRACK_BUFFER_1=30
|
||||
# Minimum consecutive (or total) hits needed before a track is considered confirmed
|
||||
TRACK_MIN_HITS_1=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=ayam-potong
|
||||
# Class name for the counted object (must match NUM_CLASSES order, index 0)
|
||||
CLASS_AYAM=ayam
|
||||
# Class name for the batch-closing trigger object (must match NUM_CLASSES order, index 1)
|
||||
CLASS_TALENAN=talenan
|
||||
|
||||
# --- Line crossing ---
|
||||
# Direction for counting: rtl (right-to-left, default) | ltr (left-to-right) | both
|
||||
CROSS_DIRECTION=rtl
|
||||
# Fixed x-coordinate for the counting line (overrides LINE_X_FRAC if set)
|
||||
LINE_X=
|
||||
# Fraction of frame width where the counting line is drawn (default 0.5 = centre)
|
||||
LINE_X_FRAC=0.5
|
||||
|
||||
# --- Batch management ---
|
||||
# Daily cutoff time (HH:MM) – a new day's batch numbering starts after this time.
|
||||
# CUTOFF_TIME is an alias used by the dashboard; DAILY_CUTOFF_TIME takes priority in counter_live_rknn_bytetrack.py.
|
||||
DAILY_CUTOFF_TIME=20:00
|
||||
CUTOFF_TIME=20:00
|
||||
# Reset frame counter and ByteTrack track-ID counter back to 0 when the daily cutoff is reached (true/false, default: true)
|
||||
RESET_COUNTERS_AT_CUTOFF=true
|
||||
# Seconds of inactivity after which the current batch is auto-closed
|
||||
BATCH_TIMEOUT_SECONDS=300
|
||||
# Seconds a newly-opened batch ignores the talenan label before accepting a close trigger
|
||||
IGNORE_BATCH_LABEL_TIMEOUT_SECONDS=30
|
||||
# Minimum number of objects required for a batch to be saved as valid
|
||||
MIN_OBJECT_PER_BATCH=60
|
||||
# Minimum duration in seconds a batch must be open to be saved as valid
|
||||
MIN_DURATION_PER_BATCH=60
|
||||
|
||||
# --- 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/batch_crossings.csv
|
||||
|
||||
# --- Rate / performance ---
|
||||
# Enable motion detection pre-filter: skip inference on frames with no movement
|
||||
# (true/false, default: false). When enabled, frames below MOTION_THRESHOLD are
|
||||
# skipped, saving NPU/CPU load.
|
||||
MOTION_DETECTION_ENABLED=false
|
||||
# Mean absolute pixel difference threshold (0–255) to consider a frame as having
|
||||
# motion. Lower = more sensitive. Default 5.0.
|
||||
MOTION_THRESHOLD=5.0
|
||||
# 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
|
||||
# Print status log every N processed frames
|
||||
FLUSH_EVERY_N_FRAMES=100
|
||||
# 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/byetrack-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-batch JSON state file used by the dashboard
|
||||
CURRENT_BATCH_PATH=/tmp/bytetrack_current_batch.json
|
||||
|
||||
# --- Recounting Dashboard (recounting_dashboard.py) ---
|
||||
# Bind address and port
|
||||
RECOUNTING_DASHBOARD_PORT=5002
|
||||
# Base URL of the live counter dashboard API (same host)
|
||||
LIVE_API_URL=http://localhost:5000
|
||||
# Base URL of the recounting counter dashboard API (second node)
|
||||
RECOUNT_API_URL=http://localhost:5001
|
||||
# go2rtc REST API base URL for streaming MP4 files
|
||||
GO2RTC_API_URL=http://localhost:1984
|
||||
# go2rtc stream name used for recounting preview
|
||||
GO2RTC_STREAM_NAME=recount
|
||||
@@ -0,0 +1,556 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Edge Jetson production counter dashboard.
|
||||
Reads jetson_counter.db + current_batch.json from jetson-counter stack.
|
||||
Default port 5000 (replaces frigate-counter dashboard role).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from io import BytesIO
|
||||
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
|
||||
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_BATCH_PATH = os.getenv("STATE_FILE", os.getenv("CURRENT_BATCH_PATH", f"{_DEFAULT_DIR}/current_batch.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")
|
||||
|
||||
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()
|
||||
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")
|
||||
|
||||
|
||||
def _ensure_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS batches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
counting_date TEXT NOT NULL,
|
||||
batch_number INTEGER NOT NULL,
|
||||
camera_name TEXT NOT NULL,
|
||||
object_label TEXT NOT NULL,
|
||||
count INTEGER NOT NULL,
|
||||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(counting_date, batch_number, camera_name, object_label)
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS daily_summaries (
|
||||
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_batches INTEGER NOT NULL DEFAULT 0,
|
||||
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)
|
||||
|
||||
|
||||
@app.route("/api/current-batch")
|
||||
def api_current_batch():
|
||||
try:
|
||||
with open(CURRENT_BATCH_PATH, "r") as f:
|
||||
data = json.load(f)
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"counting_date": data.get("counting_date"),
|
||||
"batch_number": data.get("batch_number"),
|
||||
"count": data.get("count", 0),
|
||||
"start_time": data.get("start_time"),
|
||||
"last_detection_time": data.get("last_detection_time"),
|
||||
}
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": "No active batch",
|
||||
"count": 0,
|
||||
"batch_number": None,
|
||||
"counting_date": None,
|
||||
}
|
||||
), 200
|
||||
except Exception as e:
|
||||
return jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"count": 0,
|
||||
"batch_number": None,
|
||||
"counting_date": None,
|
||||
}
|
||||
), 500
|
||||
|
||||
|
||||
@app.route("/api/previous-batch")
|
||||
def api_previous_batch():
|
||||
try:
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT counting_date, batch_number, count, start_time, end_time,
|
||||
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
|
||||
FROM batches
|
||||
ORDER BY end_time DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"date": row["counting_date"],
|
||||
"batch_number": row["batch_number"],
|
||||
"count": row["count"],
|
||||
"start_time": row["start_time"],
|
||||
"end_time": row["end_time"],
|
||||
"duration_minutes": row["duration_minutes"],
|
||||
}
|
||||
)
|
||||
return jsonify({"success": False, "error": "No previous batch"}), 200
|
||||
except sqlite3.OperationalError as e:
|
||||
return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 200
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": 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_batches, 0) as total_batches
|
||||
FROM daily_summaries
|
||||
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_batches, 0) as total_batches
|
||||
FROM daily_summaries
|
||||
WHERE counting_date = ?
|
||||
""",
|
||||
(yesterday,),
|
||||
)
|
||||
yesterday_row = cur.fetchone()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(SUM(total_count), 0) as grand_total,
|
||||
COALESCE(SUM(total_batches), 0) as grand_batches,
|
||||
COUNT(DISTINCT counting_date) as total_days
|
||||
FROM daily_summaries
|
||||
"""
|
||||
)
|
||||
all_time = cur.fetchone()
|
||||
cur.execute("SELECT ROUND(AVG(total_count), 1) as avg_per_day FROM daily_summaries")
|
||||
avg = cur.fetchone()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT counting_date, total_count
|
||||
FROM daily_summaries
|
||||
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_batches": today_row["total_batches"] if today_row else 0,
|
||||
},
|
||||
"yesterday": {
|
||||
"date": yesterday,
|
||||
"total_count": yesterday_row["total_count"] if yesterday_row else 0,
|
||||
"total_batches": yesterday_row["total_batches"] if yesterday_row else 0,
|
||||
},
|
||||
"all_time": {
|
||||
"grand_total": all_time["grand_total"],
|
||||
"grand_batches": all_time["grand_batches"],
|
||||
"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_batches": 0}, "yesterday": {"date": "", "total_count": 0, "total_batches": 0}, "all_time": {"grand_total": 0, "grand_batches": 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_batches,
|
||||
ROUND(CAST(total_count AS FLOAT) / total_batches, 1) as avg_per_batch
|
||||
FROM daily_summaries
|
||||
WHERE counting_date >= ?
|
||||
ORDER BY counting_date ASC
|
||||
""",
|
||||
(date_from,),
|
||||
)
|
||||
daily_data = [
|
||||
{
|
||||
"date": row["counting_date"],
|
||||
"total_count": row["total_count"],
|
||||
"total_batches": row["total_batches"],
|
||||
"avg_per_batch": row["avg_per_batch"] or 0,
|
||||
}
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
conn.close()
|
||||
return jsonify(daily_data)
|
||||
except sqlite3.OperationalError as e:
|
||||
return jsonify([]), 200
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/day-detail/<date>")
|
||||
def api_day_detail(date):
|
||||
try:
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT batch_number, count, start_time, end_time,
|
||||
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
|
||||
FROM batches
|
||||
WHERE counting_date = ?
|
||||
ORDER BY batch_number ASC
|
||||
""",
|
||||
(date,),
|
||||
)
|
||||
batches = []
|
||||
total_duration = 0
|
||||
for row in cur.fetchall():
|
||||
duration = row["duration_minutes"] or 0
|
||||
total_duration += duration
|
||||
batches.append(
|
||||
{
|
||||
"batch_number": row["batch_number"],
|
||||
"count": row["count"],
|
||||
"start_time": row["start_time"],
|
||||
"end_time": row["end_time"],
|
||||
"duration_minutes": duration,
|
||||
}
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT total_count, total_batches
|
||||
FROM daily_summaries
|
||||
WHERE counting_date = ?
|
||||
""",
|
||||
(date,),
|
||||
)
|
||||
summary = cur.fetchone()
|
||||
conn.close()
|
||||
return jsonify(
|
||||
{
|
||||
"date": date,
|
||||
"total_count": summary["total_count"] if summary else 0,
|
||||
"total_batches": summary["total_batches"] if summary else 0,
|
||||
"total_duration_minutes": round(total_duration, 1),
|
||||
"avg_duration_minutes": round(total_duration / len(batches), 1) if batches else 0,
|
||||
"batches": batches,
|
||||
}
|
||||
)
|
||||
except sqlite3.OperationalError as e:
|
||||
return jsonify({"date": date, "total_count": 0, "total_batches": 0, "total_duration_minutes": 0, "avg_duration_minutes": 0, "batches": [], "error": f"Database unavailable: {e}"}), 200
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/recent-batches")
|
||||
def api_recent_batches():
|
||||
try:
|
||||
limit = request.args.get("limit", 10, type=int)
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT counting_date, batch_number, count, start_time, end_time,
|
||||
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
|
||||
FROM batches
|
||||
ORDER BY end_time DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
batches = [
|
||||
{
|
||||
"date": row["counting_date"],
|
||||
"batch_number": row["batch_number"],
|
||||
"count": row["count"],
|
||||
"start_time": row["start_time"],
|
||||
"end_time": row["end_time"],
|
||||
"duration_minutes": row["duration_minutes"] or 0,
|
||||
}
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
conn.close()
|
||||
return jsonify(batches)
|
||||
except sqlite3.OperationalError as e:
|
||||
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_batches
|
||||
FROM daily_summaries
|
||||
ORDER BY counting_date DESC
|
||||
"""
|
||||
)
|
||||
dates = [
|
||||
{
|
||||
"date": row["counting_date"],
|
||||
"total_count": row["total_count"],
|
||||
"total_batches": row["total_batches"],
|
||||
}
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
conn.close()
|
||||
return jsonify(dates)
|
||||
except sqlite3.OperationalError as e:
|
||||
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, batch_number, count, start_time, end_time,
|
||||
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
|
||||
FROM batches
|
||||
WHERE counting_date >= ?
|
||||
ORDER BY counting_date ASC, batch_number 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 = "Batch Details"
|
||||
_style_header(ws, [("A", "Date"), ("B", "Batch #"), ("C", "Count"), ("D", "Start Time"), ("E", "End Time"), ("F", "Duration (min)")])
|
||||
|
||||
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["batch_number"])
|
||||
ws.cell(row=r_idx, column=3, value=row["count"])
|
||||
ws.cell(row=r_idx, column=4, value=row["start_time"])
|
||||
ws.cell(row=r_idx, column=5, value=row["end_time"])
|
||||
ws.cell(row=r_idx, column=6, value=row["duration_minutes"] or 0)
|
||||
|
||||
_auto_width(ws)
|
||||
filename = f"{SITE_NAME}_daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
return _excel_response(wb, filename)
|
||||
|
||||
|
||||
@app.route("/api/export-day-csv/<date>")
|
||||
def export_day_xlsx(date):
|
||||
try:
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT batch_number, count, start_time, end_time,
|
||||
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
|
||||
FROM batches
|
||||
WHERE counting_date = ?
|
||||
ORDER BY batch_number ASC
|
||||
""",
|
||||
(date,),
|
||||
)
|
||||
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 = f"Day {date}"
|
||||
_style_header(ws, [("A", "Batch Number"), ("B", "Count"), ("C", "Start Time"), ("D", "End Time"), ("E", "Duration (min)")])
|
||||
|
||||
for r_idx, row in enumerate(rows, 2):
|
||||
ws.cell(row=r_idx, column=1, value=row["batch_number"])
|
||||
ws.cell(row=r_idx, column=2, value=row["count"])
|
||||
ws.cell(row=r_idx, column=3, value=row["start_time"])
|
||||
ws.cell(row=r_idx, column=4, value=row["end_time"])
|
||||
ws.cell(row=r_idx, column=5, value=row["duration_minutes"] or 0)
|
||||
|
||||
_auto_width(ws)
|
||||
filename = f"{SITE_NAME}_day_detail_{date}.xlsx"
|
||||
return _excel_response(wb, filename)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
WSGIRequestHandler.protocol_version = "HTTP/1.1"
|
||||
print(f"Jetson counter dashboard at http://{DASHBOARD_HOST}:{DASHBOARD_PORT}")
|
||||
print(f"DB: {DB_PATH}")
|
||||
print(f"State: {CURRENT_BATCH_PATH}")
|
||||
app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG)
|
||||
+559
@@ -0,0 +1,559 @@
|
||||
"""
|
||||
Edge production live counter — RTSP + YOLO TensorRT + line crossing.
|
||||
Replaces MQTT frigate-counter on Jetson with local LAN camera inference.
|
||||
"""
|
||||
from ultralytics import YOLO
|
||||
import cv2
|
||||
import csv
|
||||
import numpy as np
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from batch_store import BatchStore
|
||||
|
||||
# --- 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_batch.json')
|
||||
SOURCE = os.getenv('SOURCE', 'rtsp://user:pass@192.168.0.100:554/stream1')
|
||||
MODEL_PATH = os.getenv('MODEL_PATH', '/media/jetson/DATA/yolo11n.engine')
|
||||
CAMERA_NAME = os.getenv('CAMERA_NAME', 'CC1')
|
||||
OBJECT_LABEL = os.getenv('OBJECT_LABEL', 'ayam-potong')
|
||||
CLASS_AYAM = os.getenv('CLASS_AYAM', 'ayam')
|
||||
CLASS_TALENAN = os.getenv('CLASS_TALENAN', 'talenan')
|
||||
|
||||
LINE_X = int(os.getenv('LINE_X')) if os.getenv('LINE_X') else None
|
||||
LINE_X_FRAC = float(os.getenv('LINE_X_FRAC', '0.5'))
|
||||
CROSS_DIRECTION = os.getenv('CROSS_DIRECTION', 'rtl').lower()
|
||||
|
||||
IMGSZ = int(os.getenv('IMGSZ', '416'))
|
||||
HALF = os.getenv('HALF', 'true').lower() == 'true'
|
||||
CONF = float(os.getenv('CONF', '0.3'))
|
||||
DEVICE = int(os.getenv('DEVICE', '0'))
|
||||
TRACKER = os.getenv('TRACKER', 'bytetrack.yaml')
|
||||
|
||||
DAILY_CUTOFF_TIME = os.getenv('DAILY_CUTOFF_TIME', '20:00')
|
||||
BATCH_TIMEOUT_SECONDS = float(os.getenv('BATCH_TIMEOUT_SECONDS', '300'))
|
||||
IGNORE_BATCH_LABEL_TIMEOUT = float(os.getenv('IGNORE_BATCH_LABEL_TIMEOUT_SECONDS', '30'))
|
||||
MIN_OBJECT_PER_BATCH = int(os.getenv('MIN_OBJECT_PER_BATCH', '60'))
|
||||
MIN_DURATION_PER_BATCH = int(os.getenv('MIN_DURATION_PER_BATCH', '60'))
|
||||
|
||||
EXPORT_CSV = os.getenv('EXPORT_CSV', 'true').lower() == 'true'
|
||||
CROSS_CSV = os.getenv('CROSS_CSV', f'{OUTPUT_DIR}/batch_crossings.csv')
|
||||
|
||||
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'))
|
||||
FLUSH_EVERY_N_FRAMES = int(os.getenv('FLUSH_EVERY_N_FRAMES', '100'))
|
||||
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://'))
|
||||
|
||||
CROSS_FLASH_FRAMES = 12
|
||||
POPUP_LIFETIME = 20
|
||||
LINE_PULSE_FRAMES = 12
|
||||
COUNT_PULSE_FRAMES = 15
|
||||
BATCH_PULSE_FRAMES = 20
|
||||
|
||||
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_AYAM_BOX = (0, 165, 255)
|
||||
C_TALENAN_BOX = (220, 120, 60)
|
||||
C_LINE_CORE = (180, 220, 255)
|
||||
C_LINE_GLOW = (100, 160, 220)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def resolve_class_ids(names):
|
||||
name_to_id = {v: k for k, v in names.items()}
|
||||
missing = [n for n in (CLASS_AYAM, CLASS_TALENAN) if n not in name_to_id]
|
||||
if missing:
|
||||
raise ValueError(f'Model missing classes {missing}. Available: {list(names.values())}')
|
||||
return name_to_id[CLASS_AYAM], name_to_id[CLASS_TALENAN]
|
||||
|
||||
|
||||
def box_cx(box):
|
||||
return (int(box[0]) + int(box[2])) // 2
|
||||
|
||||
|
||||
def resolve_line_x(frame_width):
|
||||
if LINE_X is not None:
|
||||
return LINE_X
|
||||
if LINE_X_FRAC != 0.5:
|
||||
return int(frame_width * LINE_X_FRAC)
|
||||
return frame_width // 2
|
||||
|
||||
|
||||
def crossed_line(prev_cx, cx, line_x, direction=CROSS_DIRECTION):
|
||||
if direction == 'ltr':
|
||||
return prev_cx < line_x <= cx
|
||||
if direction == 'both':
|
||||
return (prev_cx > line_x >= cx) or (prev_cx < line_x <= cx)
|
||||
return prev_cx > line_x >= cx
|
||||
|
||||
|
||||
def now_str():
|
||||
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
|
||||
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 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_counting_line(img, line_x, h, pulse_remaining=0):
|
||||
strength = pulse_remaining / max(LINE_PULSE_FRAMES, 1)
|
||||
glow_alpha = 0.12 + 0.18 * strength
|
||||
for offset in (14, 9, 5):
|
||||
color = tuple(int(c * glow_alpha) for c in C_LINE_GLOW)
|
||||
cv2.line(img, (line_x - offset, 0), (line_x - offset, h), color, 1, cv2.LINE_AA)
|
||||
cv2.line(img, (line_x + offset, 0), (line_x + offset, h), color, 1, cv2.LINE_AA)
|
||||
dash_len, gap = 18, 12
|
||||
y = 0
|
||||
while y < h:
|
||||
y_end = min(y + dash_len, h)
|
||||
cv2.line(img, (line_x, y), (line_x, y_end), C_LINE_CORE, 2, cv2.LINE_AA)
|
||||
y += dash_len + gap
|
||||
cv2.putText(img, 'COUNT LINE', (line_x - 46, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.42, C_LINE_CORE, 1, cv2.LINE_AA)
|
||||
|
||||
|
||||
def draw_hero_count(img, line_x, h, count, 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.6 + boost, 3
|
||||
(tw, th), _ = cv2.getTextSize(text, font, font_scale, thickness)
|
||||
pad = 14
|
||||
tx, ty = line_x - tw // 2, h // 2 + th // 2
|
||||
overlay_rect(img, tx - pad, ty - th - pad, tx + tw + pad, ty + pad // 2, C_PANEL, alpha=0.78)
|
||||
cv2.rectangle(img, (tx - pad, ty - th - pad), (tx + tw + pad, ty + pad // 2), C_LINE_CORE, 2)
|
||||
cv2.putText(img, text, (tx, ty), font, font_scale, C_GREEN, thickness, cv2.LINE_AA)
|
||||
|
||||
|
||||
def draw_hud(img, w, batch_num, batch_count, total_ayam, elapsed_sec, rate, camera_id, clock):
|
||||
bar_h = 52
|
||||
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, 'BATCH', (16, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)
|
||||
batch_label = str(batch_num) if batch_num else '—'
|
||||
cv2.putText(img, batch_label, (16, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.9, C_ACCENT, 2, cv2.LINE_AA)
|
||||
cv2.putText(img, 'COUNT', (100, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)
|
||||
cv2.putText(img, str(batch_count), (100, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.9, C_GREEN, 2, cv2.LINE_AA)
|
||||
cv2.putText(img, 'TOTAL', (190, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)
|
||||
cv2.putText(img, str(total_ayam), (190, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.7, C_TEXT, 1, cv2.LINE_AA)
|
||||
cv2.putText(img, 'UPTIME', (280, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)
|
||||
cv2.putText(img, f'{elapsed_sec / 3600:.1f}h', (280, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.7, C_TEXT, 1, cv2.LINE_AA)
|
||||
cv2.putText(img, 'RATE', (380, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)
|
||||
cv2.putText(img, f'{rate:.1f}/min', (380, 44), cv2.FONT_HERSHEY_SIMPLEX, 0.7, C_ACCENT, 1, cv2.LINE_AA)
|
||||
cv2.putText(img, clock, (w - 180, 36), cv2.FONT_HERSHEY_SIMPLEX, 0.55, C_TEXT, 1, cv2.LINE_AA)
|
||||
cv2.putText(img, f'CAM {camera_id}', (w - 180, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, C_MUTED, 1, cv2.LINE_AA)
|
||||
|
||||
|
||||
def draw_footer(img, w, h, frame_idx, live_tag):
|
||||
bar_h = 28
|
||||
overlay_rect(img, 0, h - bar_h, w, h, C_PANEL, alpha=0.55)
|
||||
cv2.putText(img, f'{live_tag} | Frame {frame_idx}', (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 draw_batch_banner(img, w, batch_num, pulse_remaining):
|
||||
if pulse_remaining <= 0:
|
||||
return
|
||||
text = f'NEW BATCH {batch_num}'
|
||||
font = cv2.FONT_HERSHEY_SIMPLEX
|
||||
(tw, th), _ = cv2.getTextSize(text, font, 0.8, 2)
|
||||
x1, y1 = w // 2 - tw // 2 - 16, 62
|
||||
x2, y2 = w // 2 + tw // 2 + 16, 62 + th + 20
|
||||
overlay_rect(img, x1, y1, x2, y2, C_PANEL, alpha=0.7)
|
||||
cv2.rectangle(img, (x1, y1), (x2, y2), C_ACCENT, 2)
|
||||
cv2.putText(img, text, (w // 2 - tw // 2, 62 + th + 4), font, 0.8, C_ACCENT, 2, cv2.LINE_AA)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def run():
|
||||
global shutdown_requested
|
||||
|
||||
store = BatchStore(
|
||||
db_path=DB_PATH,
|
||||
state_file=STATE_FILE,
|
||||
camera_name=CAMERA_NAME,
|
||||
object_label=OBJECT_LABEL,
|
||||
cutoff_time=DAILY_CUTOFF_TIME,
|
||||
batch_timeout=BATCH_TIMEOUT_SECONDS,
|
||||
ignore_batch_label_timeout=IGNORE_BATCH_LABEL_TIMEOUT,
|
||||
min_object_per_batch=MIN_OBJECT_PER_BATCH,
|
||||
min_duration_per_batch=MIN_DURATION_PER_BATCH,
|
||||
logger=lambda msg: print(f'[{now_str()}] {msg}'),
|
||||
)
|
||||
store.start_cutoff_watcher()
|
||||
|
||||
cross_logger = None
|
||||
if EXPORT_CSV:
|
||||
cross_logger = CsvLogger(CROSS_CSV, ['batch', 'frame', 'timestamp', 'chicken_id'])
|
||||
|
||||
model = YOLO(MODEL_PATH)
|
||||
ayam_cls, talenan_cls = resolve_class_ids(model.names)
|
||||
|
||||
ayam_tracked = {}
|
||||
talenan_tracked = {}
|
||||
ayam_line_crossed = set()
|
||||
talenan_line_crossed = set()
|
||||
|
||||
ayam_cross_flash = {}
|
||||
talenan_cross_flash = {}
|
||||
line_pulse = count_pulse = batch_pulse = 0
|
||||
popups = []
|
||||
|
||||
session_start = time.time()
|
||||
frame_idx = 0
|
||||
video_writer = None
|
||||
|
||||
cap, w, h, fps = connect_stream(SOURCE)
|
||||
if cap is None:
|
||||
store.shutdown()
|
||||
return
|
||||
|
||||
line_x = resolve_line_x(w)
|
||||
print(f'Jetson counter | {w}x{h} @ {fps}fps | line x={line_x} | cross={CROSS_DIRECTION}')
|
||||
print(f'Model: {MODEL_PATH} | imgsz={IMGSZ} half={HALF}')
|
||||
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
|
||||
line_x = resolve_line_x(w)
|
||||
continue
|
||||
|
||||
now = time.time()
|
||||
elapsed = now - session_start
|
||||
mono = time.monotonic()
|
||||
ayam_crossed_frame = batch_closed_frame = batch_started_frame = False
|
||||
|
||||
results = model.track(
|
||||
frame,
|
||||
device=DEVICE,
|
||||
persist=True,
|
||||
conf=CONF,
|
||||
imgsz=IMGSZ,
|
||||
half=HALF,
|
||||
tracker=TRACKER,
|
||||
verbose=False,
|
||||
)
|
||||
r = results[0]
|
||||
|
||||
if r.boxes.id is not None:
|
||||
ids = r.boxes.id.int().tolist()
|
||||
boxes = r.boxes.xyxy.tolist()
|
||||
clss = r.boxes.cls.int().tolist()
|
||||
kpts_all = r.keypoints.xy.tolist() if r.keypoints else []
|
||||
|
||||
talenan_items, ayam_items = [], []
|
||||
for i, (track_id, box, cls_id) in enumerate(zip(ids, boxes, clss)):
|
||||
cx = box_cx(box)
|
||||
x1, y1, x2, y2 = [int(v) for v in box]
|
||||
kpts = kpts_all[i] if i < len(kpts_all) else None
|
||||
item = (track_id, cx, x1, y1, x2, y2, kpts)
|
||||
if cls_id == talenan_cls:
|
||||
talenan_items.append(item)
|
||||
elif cls_id == ayam_cls:
|
||||
ayam_items.append(item)
|
||||
|
||||
for track_id, cx, x1, y1, x2, y2, _ in talenan_items:
|
||||
if track_id in talenan_tracked:
|
||||
prev_cx, _ = talenan_tracked[track_id]
|
||||
if crossed_line(prev_cx, cx, line_x) and track_id not in talenan_line_crossed:
|
||||
talenan_line_crossed.add(track_id)
|
||||
if store.record_talenan_crossing(track_id):
|
||||
batch_closed_frame = True
|
||||
talenan_cross_flash[track_id] = CROSS_FLASH_FRAMES
|
||||
popups.append({'x': cx - 20, 'y': (y1 + y2) // 2, 'born': frame_idx, 'text': 'BATCH CLOSED'})
|
||||
talenan_tracked[track_id] = (cx, mono)
|
||||
|
||||
for track_id, cx, x1, y1, x2, y2, kpts in ayam_items:
|
||||
if track_id in ayam_tracked:
|
||||
prev_cx, _ = ayam_tracked[track_id]
|
||||
if crossed_line(prev_cx, cx, line_x) and track_id not in ayam_line_crossed:
|
||||
ayam_line_crossed.add(track_id)
|
||||
_, started_new = store.record_ayam_crossing(track_id)
|
||||
if cross_logger:
|
||||
cross_logger.write_row([
|
||||
store.current_batch_number, frame_idx,
|
||||
datetime.now().isoformat(), track_id,
|
||||
])
|
||||
ayam_crossed_frame = True
|
||||
if started_new:
|
||||
batch_started_frame = True
|
||||
ayam_cross_flash[track_id] = CROSS_FLASH_FRAMES
|
||||
popups.append({'x': cx - 12, 'y': (y1 + y2) // 2, 'born': frame_idx, 'text': '+1'})
|
||||
ayam_tracked[track_id] = (cx, mono)
|
||||
|
||||
for track_id, cx, x1, y1, x2, y2, _ in talenan_items:
|
||||
flash = talenan_cross_flash.get(track_id, 0)
|
||||
color = C_GREEN if flash > 0 else C_TALENAN_BOX
|
||||
cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3 if flash > 0 else 2)
|
||||
draw_pill(frame, f'TALENAN {track_id}', x1, y1 - 4, color)
|
||||
|
||||
for track_id, cx, x1, y1, x2, y2, kpts in ayam_items:
|
||||
flash = ayam_cross_flash.get(track_id, 0)
|
||||
color = C_GREEN if flash > 0 else C_AYAM_BOX
|
||||
cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3 if flash > 0 else 2)
|
||||
draw_pill(frame, f'ID {track_id}', x1, y1 - 4, color)
|
||||
if kpts is not None:
|
||||
draw_skeleton_bold(frame, kpts)
|
||||
|
||||
if ayam_crossed_frame:
|
||||
line_pulse = LINE_PULSE_FRAMES
|
||||
count_pulse = COUNT_PULSE_FRAMES
|
||||
if batch_closed_frame:
|
||||
line_pulse = LINE_PULSE_FRAMES
|
||||
if batch_started_frame:
|
||||
batch_pulse = BATCH_PULSE_FRAMES
|
||||
|
||||
batch_num = store.current_batch_number or 0
|
||||
batch_count = store.current_batch_count
|
||||
display_total = store.display_total()
|
||||
rate = (display_total / elapsed * 60) if elapsed > 0 else 0.0
|
||||
|
||||
draw_elegant_counting_line(frame, line_x, h, line_pulse)
|
||||
draw_hero_count(frame, line_x, h, batch_count, count_pulse)
|
||||
draw_hud(frame, w, batch_num, batch_count, display_total, elapsed, rate, CAMERA_NAME, now_str())
|
||||
draw_batch_banner(frame, w, batch_num, batch_pulse)
|
||||
draw_footer(frame, w, h, frame_idx, 'LIVE' if IS_LIVE else 'FILE')
|
||||
popups = draw_popups(frame, popups, frame_idx)
|
||||
|
||||
for flash_store in (ayam_cross_flash, talenan_cross_flash):
|
||||
for tid in list(flash_store):
|
||||
flash_store[tid] -= 1
|
||||
if flash_store[tid] <= 0:
|
||||
del flash_store[tid]
|
||||
line_pulse = max(0, line_pulse - 1)
|
||||
count_pulse = max(0, count_pulse - 1)
|
||||
batch_pulse = max(0, batch_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)
|
||||
_, jpeg = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, LIVE_STREAM_QUALITY])
|
||||
with open(LIVE_STREAM_FRAME_PATH, 'wb') as f:
|
||||
f.write(jpeg.tobytes())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
frame_idx += 1
|
||||
if frame_idx % FLUSH_EVERY_N_FRAMES == 0:
|
||||
print(
|
||||
f'[{now_str()}] Frame {frame_idx} | Batch {batch_num}: {batch_count} '
|
||||
f'| Total: {display_total} | Uptime {elapsed / 3600:.2f}h'
|
||||
)
|
||||
prune_stale_tracks(ayam_tracked, mono)
|
||||
prune_stale_tracks(talenan_tracked, mono)
|
||||
|
||||
cap.release()
|
||||
if video_writer is not None:
|
||||
video_writer.release()
|
||||
if cross_logger:
|
||||
cross_logger.close()
|
||||
store.shutdown()
|
||||
|
||||
print('\n=== Batch Summary (SQLite) ===')
|
||||
print(f'Database: {DB_PATH}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install edge Jetson counter + dashboard; disable legacy MQTT frigate-counter.
|
||||
# Run on the Jetson: sudo ./install-services.sh
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/jetson-counter}"
|
||||
VENV_DIR="${VENV_DIR:-/opt/jetson-counter/venv}"
|
||||
SERVICE_USER="${SERVICE_USER:-jetson}"
|
||||
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
echo "Run as root: sudo ./install-services.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${INSTALL_DIR}/.env" ]]; then
|
||||
echo "Missing ${INSTALL_DIR}/.env"
|
||||
echo " cp ${INSTALL_DIR}/config.env.example ${INSTALL_DIR}/.env && nano ${INSTALL_DIR}/.env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -x "${VENV_DIR}/bin/python" ]]; then
|
||||
echo "Missing venv: ${VENV_DIR}/bin/python"
|
||||
echo " sudo ./setup-venv.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sed -i 's/\r$//' "${INSTALL_DIR}/.env" 2>/dev/null || true
|
||||
|
||||
mkdir -p "${INSTALL_DIR}/.ultralytics" "${INSTALL_DIR}/.torch"
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}"
|
||||
|
||||
# Disable legacy MQTT counter (replace mode)
|
||||
for legacy in frigate-counter frigate-counter-dashboard; do
|
||||
if systemctl is-enabled "${legacy}" &>/dev/null; then
|
||||
systemctl disable --now "${legacy}" || true
|
||||
echo "Disabled legacy ${legacy}"
|
||||
fi
|
||||
done
|
||||
|
||||
for unit in jetson-counter jetson-counter-dashboard; do
|
||||
sed -e "s|/opt/jetson-counter|${INSTALL_DIR}|g" \
|
||||
-e "s|User=jetson|User=${SERVICE_USER}|g" \
|
||||
-e "s|Group=jetson|Group=${SERVICE_USER}|g" \
|
||||
"${INSTALL_DIR}/${unit}.service" > "/etc/systemd/system/${unit}.service"
|
||||
echo "Installed /etc/systemd/system/${unit}.service"
|
||||
done
|
||||
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}"
|
||||
|
||||
PYTHONNOUSERSITE=1 "${VENV_DIR}/bin/python" -c "
|
||||
from ultralytics import YOLO
|
||||
import torch
|
||||
print('import ok | cuda', torch.cuda.is_available())
|
||||
" || {
|
||||
echo "Import check failed — fix venv before starting services."
|
||||
exit 1
|
||||
}
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl reset-failed jetson-counter jetson-counter-dashboard 2>/dev/null || true
|
||||
systemctl enable jetson-counter jetson-counter-dashboard
|
||||
systemctl restart jetson-counter jetson-counter-dashboard
|
||||
|
||||
echo ""
|
||||
systemctl --no-pager status jetson-counter jetson-counter-dashboard || true
|
||||
echo ""
|
||||
echo "Logs: sudo journalctl -u jetson-counter -f"
|
||||
echo "Dashboard: http://$(hostname -I | awk '{print $1}'):5000"
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Recounting dashboard — consumes live counter + recount APIs,
|
||||
lists OUTPUT_DIR MP4 files, streams via go2rtc for preview.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, render_template, jsonify, request, Response
|
||||
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")
|
||||
|
||||
OUTPUT_DIR = os.getenv("OUTPUT_DIR", os.getenv("OUTPUT_DIR", "/opt/bytetrack-counter"))
|
||||
LIVE_API_URL = os.getenv("LIVE_API_URL", "http://localhost:5000")
|
||||
RECOUNT_API_URL = os.getenv("RECOUNT_API_URL", "http://localhost:5001")
|
||||
GO2RTC_API_URL = os.getenv("GO2RTC_API_URL", "http://localhost:1984")
|
||||
GO2RTC_STREAM_NAME = os.getenv("GO2RTC_STREAM_NAME", "recount")
|
||||
|
||||
SITE_NAME = os.getenv("SITE_NAME", "RECOUNT")
|
||||
|
||||
DASHBOARD_PORT = int(os.getenv("RECOUNTING_DASHBOARD_PORT", "5002"))
|
||||
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
||||
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true"
|
||||
|
||||
_http_session = requests.Session()
|
||||
_http_session.timeout = 3
|
||||
|
||||
|
||||
def _api_get(base_url, path, default=None):
|
||||
try:
|
||||
resp = _http_session.get(f"{base_url}{path}")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("recounting.html", site_name=SITE_NAME, output_dir=OUTPUT_DIR)
|
||||
|
||||
|
||||
@app.route("/api/live-progress")
|
||||
def api_live_progress():
|
||||
data = _api_get(LIVE_API_URL, "/api/current-batch")
|
||||
if data and data.get("success"):
|
||||
return jsonify(data)
|
||||
return jsonify({"success": False, "count": 0, "batch_number": None, "error": "Live unreachable"}), 200
|
||||
|
||||
|
||||
@app.route("/api/recount-progress")
|
||||
def api_recount_progress():
|
||||
data = _api_get(RECOUNT_API_URL, "/api/current-batch")
|
||||
if data and data.get("success"):
|
||||
return jsonify(data)
|
||||
return jsonify({"success": False, "count": 0, "batch_number": None, "error": "Recount unreachable"}), 200
|
||||
|
||||
|
||||
@app.route("/api/mp4-files")
|
||||
def api_mp4_files():
|
||||
files = []
|
||||
output = Path(OUTPUT_DIR)
|
||||
if output.exists():
|
||||
for f in sorted(output.rglob("*.mp4"), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
st = f.stat()
|
||||
files.append({
|
||||
"name": f.name,
|
||||
"path": str(f),
|
||||
"size": st.st_size,
|
||||
"mtime": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
||||
})
|
||||
return jsonify(files)
|
||||
|
||||
|
||||
@app.route("/api/start-recount", methods=["POST"])
|
||||
def start_recount():
|
||||
data = request.get_json(force=True) or {}
|
||||
mp4_path = data.get("path", "")
|
||||
if not mp4_path:
|
||||
return jsonify({"success": False, "error": "Missing 'path'"}), 400
|
||||
if not os.path.isfile(mp4_path):
|
||||
return jsonify({"success": False, "error": f"File not found: {mp4_path}"}), 404
|
||||
|
||||
src = f"ffmpeg:{mp4_path}#video=h264#hardware"
|
||||
try:
|
||||
resp = requests.put(
|
||||
f"{GO2RTC_API_URL}/api/streams",
|
||||
params={"name": GO2RTC_STREAM_NAME, "src": src},
|
||||
timeout=5,
|
||||
)
|
||||
if resp.status_code not in (200, 201):
|
||||
return jsonify({"success": False, "error": f"go2rtc returned {resp.status_code}: {resp.text}"}), 502
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": f"go2rtc unreachable: {e}"}), 502
|
||||
|
||||
stream_url = f"{GO2RTC_API_URL}/api/stream.mjpeg?src={GO2RTC_STREAM_NAME}"
|
||||
return jsonify({"success": True, "stream_url": stream_url, "file": Path(mp4_path).name})
|
||||
|
||||
|
||||
@app.route("/api/stop-recount", methods=["POST"])
|
||||
def stop_recount():
|
||||
try:
|
||||
requests.delete(f"{GO2RTC_API_URL}/api/streams", params={"name": GO2RTC_STREAM_NAME}, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
WSGIRequestHandler.protocol_version = "HTTP/1.1"
|
||||
print(f"Recounting dashboard at http://{DASHBOARD_HOST}:{DASHBOARD_PORT}")
|
||||
print(f"Live API: {LIVE_API_URL}")
|
||||
print(f"Recount API: {RECOUNT_API_URL}")
|
||||
print(f"go2rtc API: {GO2RTC_API_URL}")
|
||||
print(f"OUTPUT_DIR: {OUTPUT_DIR}")
|
||||
app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG)
|
||||
@@ -0,0 +1,6 @@
|
||||
numpy<2
|
||||
rknn-toolkit-lite2
|
||||
opencv-python
|
||||
flask
|
||||
python-dotenv
|
||||
openpyxl
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time venv for edge Jetson counter — NVIDIA torch required (not PyPI).
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/jetson-counter}"
|
||||
VENV_DIR="${VENV_DIR:-/opt/jetson-counter/venv}"
|
||||
SERVICE_USER="${SERVICE_USER:-jetson}"
|
||||
TORCH_WHEEL_URL="${TORCH_WHEEL_URL:-https://developer.download.nvidia.com/compute/redist/jp/v60/pytorch/torch-2.4.0a0+3bcc3cddb5.nv24.07.16234504-cp310-cp310-linux_aarch64.whl}"
|
||||
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
echo "Run as root: sudo ./setup-venv.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
apt-get install -y libopenblas-base libopenmpi-dev libomp-dev 2>/dev/null || true
|
||||
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
if [[ ! -x "${VENV_DIR}/bin/python" ]]; then
|
||||
python3 -m venv --system-site-packages "${VENV_DIR}"
|
||||
fi
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}"
|
||||
|
||||
sudo -u "${SERVICE_USER}" "${VENV_DIR}/bin/pip" install --upgrade pip
|
||||
sudo -u "${SERVICE_USER}" "${VENV_DIR}/bin/pip" install "numpy<2"
|
||||
|
||||
if ! sudo -u "${SERVICE_USER}" PYTHONNOUSERSITE=1 "${VENV_DIR}/bin/python" -c "import torch; assert torch.cuda.is_available()" 2>/dev/null; then
|
||||
echo "Installing NVIDIA Jetson torch wheel..."
|
||||
sudo -u "${SERVICE_USER}" "${VENV_DIR}/bin/pip" install --no-cache-dir "${TORCH_WHEEL_URL}"
|
||||
fi
|
||||
|
||||
sudo -u "${SERVICE_USER}" "${VENV_DIR}/bin/pip" install ultralytics flask opencv-python
|
||||
|
||||
sudo -u "${SERVICE_USER}" PYTHONNOUSERSITE=1 "${VENV_DIR}/bin/python" -c "
|
||||
import torch
|
||||
from ultralytics import YOLO
|
||||
import cv2
|
||||
import flask
|
||||
print('venv ok | torch', torch.__version__, '| cuda', torch.cuda.is_available())
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "If torchvision import fails for ultralytics, copy/build torchvision into venv."
|
||||
echo "Next: cp config.env.example .env && nano .env && sudo ./install-services.sh"
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,630 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ site_name }} Recounting</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&family=Orbitron:wght@400;500;700;900&display=swap');
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--bg-deep: #06080e;
|
||||
--bg-surface: rgba(12, 16, 28, 0.9);
|
||||
--border-glow: rgba(0, 240, 255, 0.12);
|
||||
--accent: #00f0ff;
|
||||
--accent2: #7b2fff;
|
||||
--accent3: #ff2d78;
|
||||
--accent4: #00ff88;
|
||||
--text-primary: #dce2f0;
|
||||
--text-secondary: #6b7394;
|
||||
--glass: rgba(12, 16, 32, 0.7);
|
||||
--cell-bg: rgba(255, 255, 255, 0.02);
|
||||
--cell-border: rgba(255, 255, 255, 0.05);
|
||||
--card-bg: rgba(255, 255, 255, 0.025);
|
||||
--divider: rgba(255, 255, 255, 0.05);
|
||||
--input-bg: rgba(0, 0, 0, 0.4);
|
||||
--radius: 18px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background: var(--bg-deep);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
position: fixed; inset: 0; z-index: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 240, 255, 0.025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 240, 255, 0.025) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
animation: gridScroll 20s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes gridScroll {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 64px 64px; }
|
||||
}
|
||||
|
||||
.orb {
|
||||
position: fixed; border-radius: 50%; filter: blur(140px); pointer-events: none;
|
||||
animation: orbFloat 18s ease-in-out infinite;
|
||||
}
|
||||
.orb-1 { width: 600px; height: 600px; background: rgba(0, 240, 255, 0.04); top: -250px; left: -150px; }
|
||||
.orb-2 { width: 500px; height: 500px; background: rgba(123, 47, 255, 0.04); bottom: -200px; right: -100px; animation-delay: -6s; }
|
||||
@keyframes orbFloat {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
33% { transform: translate(50px, -40px) scale(1.06); }
|
||||
66% { transform: translate(-30px, 30px) scale(0.94); }
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative; z-index: 1;
|
||||
max-width: 1600px; margin: 0 auto; padding: 24px 20px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 18px 28px; margin-bottom: 24px;
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 14px; }
|
||||
.logo-icon {
|
||||
width: 44px; height: 44px;
|
||||
background: linear-gradient(135deg, var(--accent3), var(--accent2));
|
||||
border-radius: 11px; font-size: 22px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 0 28px rgba(255, 45, 120, 0.35);
|
||||
animation: logoPulse 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes logoPulse {
|
||||
0%, 100% { box-shadow: 0 0 22px rgba(255, 45, 120, 0.3); }
|
||||
50% { box-shadow: 0 0 44px rgba(255, 45, 120, 0.6); }
|
||||
}
|
||||
.header h1 {
|
||||
font-family: 'Orbitron', sans-serif; font-size: 20px; font-weight: 700; letter-spacing: 3px;
|
||||
background: linear-gradient(90deg, var(--accent3), var(--accent2));
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
}
|
||||
.header-sub { font-size: 10px; color: var(--text-secondary); letter-spacing: 2px; text-transform: uppercase; }
|
||||
.header-right { display: flex; align-items: center; gap: 18px; }
|
||||
.live-chip {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 9px; letter-spacing: 1.5px; padding: 5px 12px; border-radius: 12px;
|
||||
font-weight: 600; text-transform: uppercase;
|
||||
background: rgba(255, 45, 120, 0.1); color: var(--accent3);
|
||||
border: 1px solid rgba(255, 45, 120, 0.2);
|
||||
}
|
||||
.live-chip .dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--accent3); box-shadow: 0 0 8px var(--accent3);
|
||||
animation: blink 1.5s ease-in-out infinite;
|
||||
}
|
||||
.live-chip.live-chip-green {
|
||||
background: rgba(0, 255, 136, 0.1); color: var(--accent4);
|
||||
border: 1px solid rgba(0, 255, 136, 0.2);
|
||||
}
|
||||
.live-chip.live-chip-green .dot {
|
||||
background: var(--accent4); box-shadow: 0 0 8px var(--accent4);
|
||||
}
|
||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
|
||||
|
||||
.section-title {
|
||||
font-family: 'Orbitron', sans-serif; font-size: 13px; font-weight: 600; letter-spacing: 2px;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Top row: two counter panels */
|
||||
.counters-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.panel {
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
||||
padding: 24px 26px;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.counter-card {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.counter-card .counter-val {
|
||||
font-family: 'Orbitron', sans-serif; font-size: 56px; font-weight: 700;
|
||||
background: linear-gradient(180deg, #fff 0%, var(--accent) 100%);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.counter-card.recount .counter-val {
|
||||
background: linear-gradient(180deg, #fff 0%, var(--accent3) 100%);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
}
|
||||
.counter-card .counter-label { font-size: 10px; color: var(--text-secondary); letter-spacing: 2px; text-transform: uppercase; }
|
||||
.counter-card .counter-sub {
|
||||
font-size: 9px; color: var(--text-secondary); margin-top: 6px;
|
||||
}
|
||||
.counter-status {
|
||||
font-size: 9px; letter-spacing: 1px; margin-top: 10px;
|
||||
padding: 4px 12px; border-radius: 6px; display: inline-block;
|
||||
}
|
||||
.counter-status.connected {
|
||||
color: var(--accent4); background: rgba(0, 255, 136, 0.06);
|
||||
}
|
||||
.counter-status.disconnected {
|
||||
color: var(--accent3); background: rgba(255, 45, 120, 0.06);
|
||||
}
|
||||
|
||||
/* Bottom row: video + file browser */
|
||||
.bottom-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.video-panel {
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
position: relative; overflow: hidden;
|
||||
min-height: 340px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.video-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 10px; padding: 0 12px;
|
||||
}
|
||||
.video-container {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
background: #000; border-radius: 10px;
|
||||
overflow: hidden; position: relative;
|
||||
min-height: 280px;
|
||||
}
|
||||
.video-container img {
|
||||
max-width: 100%; max-height: 100%; display: block;
|
||||
}
|
||||
.video-placeholder {
|
||||
text-align: center; color: var(--text-secondary);
|
||||
padding: 40px;
|
||||
}
|
||||
.video-placeholder .vp-icon {
|
||||
font-size: 48px; opacity: 0.15; margin-bottom: 12px;
|
||||
}
|
||||
.video-placeholder .vp-text {
|
||||
font-size: 11px; letter-spacing: 1px;
|
||||
}
|
||||
.video-placeholder .vp-hint {
|
||||
font-size: 9px; margin-top: 8px; opacity: 0.5;
|
||||
}
|
||||
.video-status {
|
||||
font-size: 9px; letter-spacing: 1px;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border-radius: 6px;
|
||||
}
|
||||
.video-status.connected {
|
||||
color: var(--accent4); background: rgba(0, 255, 136, 0.06);
|
||||
}
|
||||
.video-status.stopped {
|
||||
color: var(--text-secondary); background: var(--cell-bg);
|
||||
}
|
||||
|
||||
/* File browser panel */
|
||||
.file-panel {
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.file-panel .fp-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.file-panel .fp-dir {
|
||||
font-size: 9px; color: var(--accent); letter-spacing: 1px;
|
||||
word-break: break-all; margin-bottom: 14px;
|
||||
padding: 8px 12px; background: var(--cell-bg);
|
||||
border-radius: 8px; border: 1px solid var(--cell-border);
|
||||
}
|
||||
.file-list {
|
||||
flex: 1; overflow-y: auto; max-height: 400px;
|
||||
}
|
||||
.file-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 12px; margin-bottom: 4px;
|
||||
background: var(--cell-bg);
|
||||
border: 1px solid var(--cell-border); border-radius: 8px;
|
||||
cursor: pointer; transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
.file-item:hover { border-color: var(--border-glow); background: var(--card-bg); }
|
||||
.file-item.selected {
|
||||
border-color: var(--accent2);
|
||||
background: rgba(123, 47, 255, 0.08);
|
||||
}
|
||||
.file-item.streaming {
|
||||
border-color: var(--accent3);
|
||||
background: rgba(255, 45, 120, 0.08);
|
||||
}
|
||||
.file-item .fi-name {
|
||||
font-size: 10px; color: var(--text-primary);
|
||||
word-break: break-all; flex: 1;
|
||||
}
|
||||
.file-item .fi-size {
|
||||
font-size: 8px; color: var(--text-secondary);
|
||||
margin-left: 8px; white-space: nowrap;
|
||||
}
|
||||
.file-item .fi-icon {
|
||||
font-size: 14px; margin-right: 8px;
|
||||
color: var(--accent3);
|
||||
}
|
||||
.file-item.streaming .fi-icon { color: var(--accent4); animation: blink 1.5s ease-in-out infinite; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
font-family: 'Orbitron', sans-serif; font-size: 10px; letter-spacing: 2px;
|
||||
padding: 8px 16px; border-radius: 7px; cursor: pointer; font-weight: 600;
|
||||
border: none; transition: opacity 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
color: #000;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.85; box-shadow: 0 0 20px rgba(0, 240, 255, 0.3); }
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, var(--accent3), var(--accent2));
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger:hover { opacity: 0.85; box-shadow: 0 0 20px rgba(255, 45, 120, 0.4); }
|
||||
.btn-ghost {
|
||||
background: var(--cell-bg); color: var(--text-secondary);
|
||||
border: 1px solid var(--cell-border);
|
||||
}
|
||||
.btn-ghost:hover { border-color: var(--accent); color: var(--text-primary); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
|
||||
.btn-row { display: flex; gap: 8px; margin-top: 12px; }
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
margin-top: 24px; text-align: center;
|
||||
font-size: 9px; letter-spacing: 2px; color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--text-secondary); border-radius: 3px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.counters-row, .bottom-row { grid-template-columns: 1fr; }
|
||||
.container { padding: 12px 8px; }
|
||||
.header { flex-direction: column; gap: 10px; padding: 14px 16px; }
|
||||
.counter-card .counter-val { font-size: 40px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bg-grid"></div>
|
||||
<div class="orb orb-1"></div>
|
||||
<div class="orb orb-2"></div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<div class="logo-icon">✖</div>
|
||||
<div>
|
||||
<h1>{{ site_name }} Recounting</h1>
|
||||
<div class="header-sub">Edge counter batch replay</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="live-chip live-chip-green">
|
||||
<span class="dot"></span>
|
||||
<span>LIVE</span>
|
||||
</div>
|
||||
<div class="live-chip">
|
||||
<span class="dot"></span>
|
||||
<span>RECOUNT</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Counter panels -->
|
||||
<div class="counters-row">
|
||||
<div class="panel">
|
||||
<span class="section-title">◉ Live Counter</span>
|
||||
<div class="counter-card">
|
||||
<div class="counter-val" id="live-count">--</div>
|
||||
<div class="counter-label">Objects Counted</div>
|
||||
<div class="counter-sub">Batch #<span id="live-batch">--</span> · <span id="live-time">--</span></div>
|
||||
<div class="counter-status disconnected" id="live-status">
|
||||
waiting...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<span class="section-title">◉ Recount Progress</span>
|
||||
<div class="counter-card recount">
|
||||
<div class="counter-val" id="recount-count">--</div>
|
||||
<div class="counter-label">Objects Recounted</div>
|
||||
<div class="counter-sub">Batch #<span id="recount-batch">--</span> · <span id="recount-time">--</span></div>
|
||||
<div class="counter-status disconnected" id="recount-status">
|
||||
waiting...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: video + file list -->
|
||||
<div class="bottom-row">
|
||||
<div class="video-panel">
|
||||
<div class="video-header">
|
||||
<span class="section-title" style="margin-bottom:0;">◉ Preview</span>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<span class="video-status stopped" id="video-status">
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:var(--text-secondary);"></span>
|
||||
stopped
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-container" id="video-container">
|
||||
<div class="video-placeholder" id="video-placeholder">
|
||||
<div class="vp-icon">▶</div>
|
||||
<div class="vp-text">Select an MP4 file to stream</div>
|
||||
<div class="vp-hint">Requires go2rtc on this host</div>
|
||||
</div>
|
||||
<img id="stream-img" alt="Stream" style="display:none;" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-panel">
|
||||
<div class="fp-header">
|
||||
<span class="section-title" style="margin-bottom:0;font-size:11px;">◉ Recordings</span>
|
||||
<button class="btn btn-ghost" onclick="loadFiles()" style="font-size:8px;padding:4px 8px;">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="fp-dir" id="output-dir">{{ output_dir }}</div>
|
||||
<div class="file-list" id="file-list">
|
||||
<div style="font-size:10px;color:var(--text-secondary);text-align:center;padding:20px;">
|
||||
Loading files...
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-primary" id="btn-start" onclick="startRecount()" disabled>
|
||||
▶ START RECOUNT
|
||||
</button>
|
||||
<button class="btn btn-danger" id="btn-stop" onclick="stopRecount()" disabled>
|
||||
■ STOP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">{{ site_name }} Recounting Dashboard</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedFile = null;
|
||||
let streaming = false;
|
||||
let streamUrl = '';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadLiveProgress();
|
||||
loadRecountProgress();
|
||||
loadFiles();
|
||||
|
||||
setInterval(loadLiveProgress, 200);
|
||||
setInterval(loadRecountProgress, 500);
|
||||
setInterval(loadFiles, 30000);
|
||||
});
|
||||
|
||||
// --- File list ---
|
||||
|
||||
async function loadFiles() {
|
||||
try {
|
||||
const res = await fetch('/api/mp4-files');
|
||||
const files = await res.json();
|
||||
const container = document.getElementById('file-list');
|
||||
if (files.length === 0) {
|
||||
container.innerHTML = '<div style="font-size:10px;color:var(--text-secondary);text-align:center;padding:20px;">No MP4 files found</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = files.map(f => {
|
||||
const sel = selectedFile === f.path;
|
||||
const str = streaming && selectedFile === f.path;
|
||||
let cls = 'file-item';
|
||||
if (str) cls += ' streaming';
|
||||
else if (sel) cls += ' selected';
|
||||
return `
|
||||
<div class="${cls}" onclick="selectFile('${f.path.replace(/'/g, "\\'")}', '${f.name.replace(/'/g, "\\'")}')">
|
||||
<span class="fi-icon">${str ? '◉' : '▶'}</span>
|
||||
<span class="fi-name">${f.name}</span>
|
||||
<span class="fi-size">${formatSize(f.size)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('Failed to load files:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function selectFile(path, name) {
|
||||
selectedFile = path;
|
||||
document.getElementById('btn-start').disabled = false;
|
||||
document.getElementById('stream-file-name').textContent = name;
|
||||
loadFiles(); // refresh selection
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||
}
|
||||
|
||||
// --- Streaming ---
|
||||
|
||||
async function startRecount() {
|
||||
if (!selectedFile) return;
|
||||
const btnStart = document.getElementById('btn-start');
|
||||
const btnStop = document.getElementById('btn-stop');
|
||||
btnStart.disabled = true;
|
||||
btnStart.textContent = 'CONNECTING...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/start-recount', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: selectedFile })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
alert('Failed: ' + (data.error || 'unknown'));
|
||||
btnStart.disabled = false;
|
||||
btnStart.textContent = '▶ START RECOUNT';
|
||||
return;
|
||||
}
|
||||
streamUrl = data.stream_url;
|
||||
streaming = true;
|
||||
|
||||
const img = document.getElementById('stream-img');
|
||||
const placeholder = document.getElementById('video-placeholder');
|
||||
img.src = streamUrl + '?' + Date.now();
|
||||
img.style.display = 'block';
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
|
||||
btnStop.disabled = false;
|
||||
btnStart.textContent = 'STREAMING';
|
||||
btnStart.classList.add('btn-ghost');
|
||||
|
||||
document.getElementById('video-status').innerHTML =
|
||||
'<span style="width:6px;height:6px;border-radius:50%;background:var(--accent4);box-shadow:0 0 6px var(--accent4);animation:blink 1.5s ease-in-out infinite;"></span> streaming ' + data.file;
|
||||
document.getElementById('video-status').className = 'video-status connected';
|
||||
|
||||
loadFiles();
|
||||
} catch (err) {
|
||||
alert('Error: ' + err.message);
|
||||
btnStart.disabled = false;
|
||||
btnStart.textContent = '▶ START RECOUNT';
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecount() {
|
||||
try {
|
||||
await fetch('/api/stop-recount', { method: 'POST' });
|
||||
} catch (err) {}
|
||||
streaming = false;
|
||||
streamUrl = '';
|
||||
|
||||
const img = document.getElementById('stream-img');
|
||||
const placeholder = document.getElementById('video-placeholder');
|
||||
img.src = '';
|
||||
img.style.display = 'none';
|
||||
if (placeholder) placeholder.style.display = '';
|
||||
|
||||
const btnStart = document.getElementById('btn-start');
|
||||
const btnStop = document.getElementById('btn-stop');
|
||||
btnStart.disabled = selectedFile ? false : true;
|
||||
btnStart.textContent = '▶ START RECOUNT';
|
||||
btnStart.classList.remove('btn-ghost');
|
||||
btnStop.disabled = true;
|
||||
|
||||
document.getElementById('video-status').innerHTML =
|
||||
'<span style="width:6px;height:6px;border-radius:50%;background:var(--text-secondary);"></span> stopped';
|
||||
document.getElementById('video-status').className = 'video-status stopped';
|
||||
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
// --- Live counter ---
|
||||
|
||||
async function loadLiveProgress() {
|
||||
const countEl = document.getElementById('live-count');
|
||||
const batchEl = document.getElementById('live-batch');
|
||||
const timeEl = document.getElementById('live-time');
|
||||
const statusEl = document.getElementById('live-status');
|
||||
try {
|
||||
const res = await fetch('/api/live-progress');
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
countEl.textContent = '--';
|
||||
batchEl.textContent = '--';
|
||||
timeEl.textContent = data.error || 'unreachable';
|
||||
statusEl.className = 'counter-status disconnected';
|
||||
statusEl.textContent = 'no connection';
|
||||
return;
|
||||
}
|
||||
countEl.textContent = data.count.toLocaleString();
|
||||
batchEl.textContent = data.batch_number || '--';
|
||||
if (data.last_detection_time) {
|
||||
const d = new Date(data.last_detection_time);
|
||||
timeEl.textContent = d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} else {
|
||||
timeEl.textContent = 'waiting...';
|
||||
}
|
||||
statusEl.className = 'counter-status connected';
|
||||
statusEl.textContent = 'connected';
|
||||
} catch (err) {
|
||||
countEl.textContent = '--';
|
||||
batchEl.textContent = '--';
|
||||
timeEl.textContent = 'unreachable';
|
||||
statusEl.className = 'counter-status disconnected';
|
||||
statusEl.textContent = 'no connection';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Recount counter ---
|
||||
|
||||
async function loadRecountProgress() {
|
||||
const countEl = document.getElementById('recount-count');
|
||||
const batchEl = document.getElementById('recount-batch');
|
||||
const timeEl = document.getElementById('recount-time');
|
||||
const statusEl = document.getElementById('recount-status');
|
||||
try {
|
||||
const res = await fetch('/api/recount-progress');
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
countEl.textContent = '--';
|
||||
batchEl.textContent = '--';
|
||||
timeEl.textContent = data.error || 'unreachable';
|
||||
statusEl.className = 'counter-status disconnected';
|
||||
statusEl.textContent = 'no connection';
|
||||
return;
|
||||
}
|
||||
countEl.textContent = data.count.toLocaleString();
|
||||
batchEl.textContent = data.batch_number || '--';
|
||||
if (data.last_detection_time) {
|
||||
const d = new Date(data.last_detection_time);
|
||||
timeEl.textContent = d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} else {
|
||||
timeEl.textContent = 'waiting...';
|
||||
}
|
||||
statusEl.className = 'counter-status connected';
|
||||
statusEl.textContent = 'connected';
|
||||
} catch (err) {
|
||||
countEl.textContent = '--';
|
||||
batchEl.textContent = '--';
|
||||
timeEl.textContent = 'unreachable';
|
||||
statusEl.className = 'counter-status disconnected';
|
||||
statusEl.textContent = 'no connection';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Remove Jetson edge counter systemd services.
|
||||
# Run on the Jetson: sudo ./uninstall-services.sh
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
echo "Run as root: sudo ./uninstall-services.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for unit in jetson-counter jetson-counter-dashboard; do
|
||||
systemctl stop "${unit}" 2>/dev/null || true
|
||||
systemctl disable "${unit}" 2>/dev/null || true
|
||||
rm -f "/etc/systemd/system/${unit}.service"
|
||||
done
|
||||
|
||||
systemctl daemon-reload
|
||||
echo "Removed jetson-counter and jetson-counter-dashboard services."
|
||||
Reference in new issue
Block a user