Recount Dashboard

This commit is contained in:
proitlab committed 2026-07-29 14:04:26 +07:00
commit fe220504fd
21 files changed
+11350

No files matched your search

+227
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+50
View File
@@ -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.
+122
View File
@@ -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.
+59
View File
@@ -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).
+155
View File
@@ -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
View File
@@ -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()
+33
View File
@@ -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
+35
View File
@@ -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
+34
View File
@@ -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
+173
View File
@@ -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
+556
View File
@@ -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
View File
@@ -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
+68
View File
@@ -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"
+126
View File
@@ -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)
+6
View File
@@ -0,0 +1,6 @@
numpy<2
rknn-toolkit-lite2
opencv-python
flask
python-dotenv
openpyxl
+43
View File
@@ -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
+630
View File
@@ -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">&#10006;</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">&#9673; 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> &middot; <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">&#9673; 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> &middot; <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;">&#9673; 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">&#9654;</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;">&#9673; 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>
&#9654; START RECOUNT
</button>
<button class="btn btn-danger" id="btn-stop" onclick="stopRecount()" disabled>
&#9632; 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 ? '&#9673;' : '&#9654;'}</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>
+18
View File
@@ -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."