First Commit

This commit is contained in:
proitlab committed 2026-07-07 07:43:20 +07:00
commit b451b84885
17 files changed
+387789

No files matched your search

+220
View File
@@ -0,0 +1,220 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
*.lcov
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
# Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
# poetry.lock
# poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
# pdm.lock
# pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
# pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi/*
!.pixi/config.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule*
celerybeat.pid
# Redis
*.rdb
*.aof
*.pid
# RabbitMQ
mnesia/
rabbitmq/
rabbitmq-data/
# ActiveMQ
activemq-data/
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
# .idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Temporary file for partial code execution
tempCodeRunnerFile.py
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml
+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 and Nano 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).
+382942
View File
File diff suppressed because it is too large. Load diff
+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-python
EnvironmentFile=/opt/bytetrack-counter-python/.env
Environment=PATH=/opt/bytetrack-counter-python/venv/bin:/usr/local/bin:/usr/bin:/bin
Environment=FLASK_DEBUG=false
ExecStart=/opt/bytetrack-counter-python/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
+33
View File
@@ -0,0 +1,33 @@
[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-python
EnvironmentFile=/opt/bytetrack-counter-python/.env
Environment=PYTHONNOUSERSITE=1
Environment=PATH=/opt/bytetrack-counter-python/venv/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/opt/bytetrack-counter-python/venv/bin/python counter_live_rknn.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
Binary file not shown.
+161
View File
@@ -0,0 +1,161 @@
# =============================================================================
# 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
+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()
+1373
View File
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"
+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
+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."