First commit

This commit is contained in:
proitlab committed 2026-06-25 18:47:34 +07:00
commit 945eebfe89
17 files changed
+5061

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
OUTPUT_DIR=/opt/jetson-counter
DB_PATH=/opt/jetson-counter/jetson_counter.db
STATE_FILE=/opt/jetson-counter/current_batch.json
SOURCE=rtsp://user:pass@192.168.0.100:554/stream1
MODEL_PATH=/opt/jetson-counter/yolo9t.rknn
CAMERA_NAME=CC1
OBJECT_LABEL=ayam-potong
CLASS_AYAM=ayam
CLASS_TALENAN=talenan
LINE_X=
LINE_X_FRAC=0.5
CROSS_DIRECTION=rtl
IMGSZ=320
HALF=false
CONF=0.3
DEVICE=0
CORE_MASK=1
NUM_CLASSES=2
SCORE_SIGMOID=false
DAILY_CUTOFF_TIME=20:00
BATCH_TIMEOUT_SECONDS=300
IGNORE_BATCH_LABEL_TIMEOUT_SECONDS=30
MIN_OBJECT_PER_BATCH=60
MIN_DURATION_PER_BATCH=60
EXPORT_CSV=true
CROSS_CSV=/opt/jetson-counter/batch_crossings.csv
WARMUP_FRAMES=30
RECONNECT_DELAY_SEC=3
MAX_RECONNECT_ATTEMPTS=0
FLUSH_EVERY_N_FRAMES=100
TRACKED_PRUNE_SEC=300
RECORD_VIDEO=false
VIDEO_SEGMENT_SEC=3600
OUTPUT_FPS=15
LIVE_STREAM_ENABLED=false
LIVE_STREAM_FRAME_PATH=/dev/shm/jetson-counter/live_frame.jpg
LIVE_STREAM_QUALITY=75
LIVE_STREAM_EVERY_N=2
OPENCV_FFMPEG_CAPTURE_OPTIONS=rtsp_transport
tcp | fflags
nobuffer | flags
low_delay
+87
View File
@@ -0,0 +1,87 @@
# Copy to .env and adjust values for your deployment
# Paths
OUTPUT_DIR=/opt/jetson-counter
DB_PATH=/opt/jetson-counter/jetson_counter.db
STATE_FILE=/opt/jetson-counter/current_batch.json
# Camera stream (rtsp://, http://, or file path for offline testing)
SOURCE=rtsp://user:pass@192.168.0.100:554/stream1
# RKNN model
MODEL_PATH=/opt/jetson-counter/yolo9t.rknn
# Identity
CAMERA_NAME=CC1
OBJECT_LABEL=ayam-potong
CLASS_AYAM=ayam
CLASS_TALENAN=talenan
# Counting line: pixel position (empty = auto from LINE_X_FRAC)
LINE_X=
LINE_X_FRAC=0.5
CROSS_DIRECTION=rtl
# Model input size
IMGSZ=320
# Inference (ignored by RKNN scripts; used by Jetson/TensorRT variant)
HALF=false
# Confidence threshold for detections
CONF=0.3
# TensorRT device index (ignored by RKNN scripts; used by Jetson variant)
DEVICE=0
# RKNN NPU core mask: 1=core0, 2=core1, 3=dual, 7=all
CORE_MASK=1
# YOLO decoder: number of classes
NUM_CLASSES=2
# Set to true if model outputs raw logits instead of sigmoid probabilities
SCORE_SIGMOID=false
# ByteTrack parameters (counter_live_rknn_bytetrack.py only)
TRACK_HIGH_THRESH=0.5
TRACK_LOW_THRESH=0.1
TRACK_MATCH_THRESH=0.8
TRACK_BUFFER=30
TRACK_MIN_HITS=3
# Batch / cutoff
DAILY_CUTOFF_TIME=20:00
BATCH_TIMEOUT_SECONDS=300
IGNORE_BATCH_LABEL_TIMEOUT_SECONDS=30
MIN_OBJECT_PER_BATCH=60
MIN_DURATION_PER_BATCH=60
# CSV export
EXPORT_CSV=true
CROSS_CSV=/opt/jetson-counter/batch_crossings.csv
# Stream connection
WARMUP_FRAMES=30
RECONNECT_DELAY_SEC=3
MAX_RECONNECT_ATTEMPTS=0
# Health logging interval
FLUSH_EVERY_N_FRAMES=100
# Stale track pruning (seconds)
TRACKED_PRUNE_SEC=300
# Video recording
RECORD_VIDEO=false
VIDEO_SEGMENT_SEC=3600
OUTPUT_FPS=15
# Live stream (writes JPEG snapshot to disk for nginx)
LIVE_STREAM_ENABLED=false
LIVE_STREAM_FRAME_PATH=/dev/shm/jetson-counter/live_frame.jpg
LIVE_STREAM_QUALITY=75
LIVE_STREAM_EVERY_N=2
# OpenCV FFmpeg backend options (semicolon/pipe separated)
OPENCV_FFMPEG_CAPTURE_OPTIONS=rtsp_transport;tcp|fflags;nobuffer|flags;low_delay
+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).
+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()
+54
View File
@@ -0,0 +1,54 @@
# Edge RK3588 production counter — copy to .env on device
# cp config.env.example .env && nano .env
OUTPUT_DIR=/opt/jetson-counter
DB_PATH=/opt/jetson-counter/jetson_counter.db
STATE_FILE=/opt/jetson-counter/current_batch.json
# Direct LAN camera RTSP (low latency)
SOURCE=rtsp://user:pass@192.168.0.100:554/stream1
OPENCV_FFMPEG_CAPTURE_OPTIONS=rtsp_transport;tcp|fflags;nobuffer|flags;low_delay
# RKNN model — export'd from YOLO9t with imgsz=320
MODEL_PATH=/opt/jetson-counter/yolo9t.rknn
IMGSZ=320
HALF=false
CONF=0.3
# RKNN NPU core mask: 1=core0, 2=core1, 3=core0+core1, 7=all three
CORE_MASK=1
# YOLO decoder params (must match model export)
NUM_CLASSES=2
NUM_KEYPOINTS=9
REG_MAX=16
STRIDES=8,16,32
CAMERA_NAME=CC1
OBJECT_LABEL=ayam-potong
CLASS_AYAM=ayam
CLASS_TALENAN=talenan
# Line crossing: rtl (default) | ltr | both
CROSS_DIRECTION=rtl
LINE_X=
LINE_X_FRAC=0.5
DAILY_CUTOFF_TIME=20:00
CUTOFF_TIME=20:00
BATCH_TIMEOUT_SECONDS=300
IGNORE_BATCH_LABEL_TIMEOUT_SECONDS=30
MIN_OBJECT_PER_BATCH=60
MIN_DURATION_PER_BATCH=60
DASHBOARD_HOST=0.0.0.0
DASHBOARD_PORT=5000
SECRET_KEY=change-me-in-production
EXPORT_CSV=true
RECORD_VIDEO=false
LIVE_STREAM_ENABLED=false
LIVE_STREAM_FRAME_PATH=/dev/shm/jetson-counter/live_frame.jpg
LIVE_STREAM_QUALITY=75
LIVE_STREAM_EVERY_N=2
+409
View File
@@ -0,0 +1,409 @@
#!/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 csv
import time
from io import StringIO
from datetime import datetime, timedelta
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")
@app.route("/api/live-video")
def api_live_video():
def generate():
while True:
try:
with open(LIVE_STREAM_FRAME_PATH, "rb") as f:
jpeg = f.read()
yield (b"--frame\r\n"
b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n")
except FileNotFoundError:
time.sleep(1.0)
continue
except Exception:
time.sleep(0.5)
continue
time.sleep(0.05)
return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")
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")
@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():
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
@app.route("/api/summary")
def api_summary():
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,
},
}
)
@app.route("/api/daily-data")
def api_daily_data():
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)
@app.route("/api/day-detail/<date>")
def api_day_detail(date):
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,
}
)
@app.route("/api/recent-batches")
def api_recent_batches():
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)
@app.route("/api/available-dates")
def api_available_dates():
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)
@app.route("/api/export-daily-csv")
def export_daily_csv():
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) / NULLIF(total_batches, 0), 1) as avg_per_batch
FROM daily_summaries
WHERE counting_date >= ?
ORDER BY counting_date ASC
""",
(date_from,),
)
output = StringIO()
writer = csv.writer(output)
writer.writerow(["Date", "Total Count", "Total Batches", "Avg per Batch"])
for row in cur.fetchall():
writer.writerow([row["counting_date"], row["total_count"], row["total_batches"], row["avg_per_batch"] or 0])
conn.close()
filename = f"daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
return Response(
output.getvalue(),
mimetype="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@app.route("/api/export-day-csv/<date>")
def export_day_csv(date):
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,),
)
output = StringIO()
writer = csv.writer(output)
writer.writerow(["Batch Number", "Count", "Start Time", "End Time", "Duration (min)"])
for row in cur.fetchall():
writer.writerow([row["batch_number"], row["count"], row["start_time"], row["end_time"], row["duration_minutes"] or 0])
conn.close()
return Response(
output.getvalue(),
mimetype="text/csv",
headers={"Content-Disposition": f"attachment; filename=day_detail_{date}.csv"},
)
if __name__ == "__main__":
WSGIRequestHandler.protocol_version = "HTTP/1.1"
port = int(os.getenv("DASHBOARD_PORT", "5000"))
host = os.getenv("DASHBOARD_HOST", "0.0.0.0")
debug = os.getenv("FLASK_DEBUG", "false").lower() == "true"
print(f"Jetson counter dashboard at http://{host}:{port}")
print(f"DB: {DB_PATH}")
print(f"State: {CURRENT_BATCH_PATH}")
app.run(host=host, port=port, debug=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()
+868
View File
@@ -0,0 +1,868 @@
"""
Edge production live counter — RTSP + YOLO RKNN + line crossing.
Runs on RK3588 hardware with RKNN model (320×320 input).
Replaces the Jetson/TensorRT variant.
"""
import numpy as np
import cv2
import csv
import os
import signal
import time
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
from rknnlite.api import RKNNLite
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', '/opt/jetson-counter/yolo11n.rknn')
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', '320'))
HALF = os.getenv('HALF', 'false').lower() == 'true'
CONF = float(os.getenv('CONF', '0.3'))
DEVICE = int(os.getenv('DEVICE', '0'))
# RKNN-specific — core mask for NPU
# 1 = core0, 2 = core1, 3 = core0+core1 (dual), 7 = all three
CORE_MASK = int(os.getenv('CORE_MASK', '1'))
# YOLO decoder config
NUM_CLASSES = int(os.getenv('NUM_CLASSES', '2'))
SCORE_SIGMOID = os.getenv('SCORE_SIGMOID', 'false').lower() == 'true'
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)
# =============================================================================
# YOLO output decoder (NMS only — boxes are pre-decoded by the model)
# =============================================================================
def _nms(boxes, scores, iou_thr=0.45):
order = np.argsort(scores)[::-1]
keep = []
while len(order) > 0:
idx = order[0]
keep.append(idx)
if len(order) == 1:
break
xx1 = np.maximum(boxes[idx, 0], boxes[order[1:], 0])
yy1 = np.maximum(boxes[idx, 1], boxes[order[1:], 1])
xx2 = np.minimum(boxes[idx, 2], boxes[order[1:], 2])
yy2 = np.minimum(boxes[idx, 3], boxes[order[1:], 3])
w = np.maximum(0.0, xx2 - xx1)
h = np.maximum(0.0, yy2 - yy1)
inter = w * h
area_i = (boxes[idx, 2] - boxes[idx, 0]) * (boxes[idx, 3] - boxes[idx, 1])
area_o = (boxes[order[1:], 2] - boxes[order[1:], 0]) * (boxes[order[1:], 3] - boxes[order[1:], 1])
iou = inter / (area_i + area_o - inter + 1e-16)
order = order[1:][iou < iou_thr]
return np.array(keep)
def _compute_iou(box1, boxes2):
"""IoU of one box (cxcywh) against a set (2D) or single box (1D)."""
if boxes2.ndim == 1:
boxes2 = boxes2.reshape(1, -1)
cx, cy, w, h = box1
x1, y1 = cx - w / 2, cy - h / 2
x2, y2 = cx + w / 2, cy + h / 2
area1 = w * h
cxs, cys, ws, hs = boxes2[:, 0], boxes2[:, 1], boxes2[:, 2], boxes2[:, 3]
x1s, y1s = cxs - ws / 2, cys - hs / 2
x2s, y2s = cxs + ws / 2, cys + hs / 2
areas2 = ws * hs
xx1 = np.maximum(x1, x1s)
yy1 = np.maximum(y1, y1s)
xx2 = np.minimum(x2, x2s)
yy2 = np.minimum(y2, y2s)
inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
return inter / (area1 + areas2 - inter + 1e-16)
# =============================================================================
# Simple IoU tracker (replaces bytetrack — same persist behaviour)
# =============================================================================
class SimpleTracker:
def __init__(self, max_age=30, min_hits=1, iou_threshold=0.3):
self.max_age = max_age
self.min_hits = min_hits
self.iou_threshold = iou_threshold
self.tracks = {} # track_id -> {box, cx, age, hits, time_since_update}
self.next_id = 1
def update(self, detections):
"""detections: list of (cx, box_cxcywh). Returns (track_map, det_to_track)."""
now = time.monotonic()
for tid in self.tracks:
self.tracks[tid]['time_since_update'] += 1
matched_det = set()
matched_track = set()
assignments = [] # (track_id, det_idx)
det_to_track = {} # det_idx → track_id
if detections and self.tracks:
track_ids = list(self.tracks.keys())
track_boxes = np.stack([self.tracks[t]['box'] for t in track_ids], axis=0)
for di, det in enumerate(detections):
_, det_box = det
ious = np.array([_compute_iou(det_box, track_boxes[t:t + 1]) for t in range(len(track_ids))])
best_j = int(np.argmax(ious))
if ious[best_j] >= self.iou_threshold and track_ids[best_j] not in matched_track:
assignments.append((track_ids[best_j], di))
matched_track.add(track_ids[best_j])
matched_det.add(di)
for tid, di in assignments:
cx, box = detections[di]
self.tracks[tid]['cx'] = cx
self.tracks[tid]['box'] = box
self.tracks[tid]['hits'] += 1
self.tracks[tid]['time_since_update'] = 0
self.tracks[tid]['last_update'] = now
det_to_track[di] = tid
for di, det in enumerate(detections):
if di not in matched_det:
cx, box = det
new_id = self.next_id
self.next_id += 1
self.tracks[new_id] = {
'cx': cx, 'box': box, 'hits': 1,
'time_since_update': 0, 'last_update': now,
}
det_to_track[di] = new_id
stale = [tid for tid, t in self.tracks.items()
if t['time_since_update'] > self.max_age]
for tid in stale:
del self.tracks[tid]
track_map = {tid: self.tracks[tid]['cx']
for tid in self.tracks
if self.tracks[tid]['hits'] >= self.min_hits}
return track_map, det_to_track
# =============================================================================
# RKNN YOLO wrapper (detect output format: (1, 4+num_classes, N))
# =============================================================================
class RKNNYOLO:
def __init__(self, model_path, core_mask=1, imgsz=320, conf=0.3, iou=0.45,
num_classes=2, num_keypoints=0, score_sigmoid=False):
self.imgsz = imgsz
self.conf = conf
self.iou = iou
self.num_classes = num_classes
self.num_keypoints = num_keypoints
self.score_sigmoid = score_sigmoid
self.rknn = RKNNLite(verbose=False)
ret = self.rknn.load_rknn(model_path)
if ret != 0:
raise RuntimeError(f'Failed to load RKNN model: {model_path}')
ret = self.rknn.init_runtime(core_mask=core_mask)
if ret != 0:
raise RuntimeError(f'Failed to init RKNN runtime (core_mask={core_mask})')
try:
from rknnlite.api import RKNNLite as _RK
sdk_ver = self.rknn.get_sdk_version()
print(f'RKNN SDK version: {sdk_ver}')
except Exception:
pass
print(f'RKNN model loaded: {model_path} imgsz={imgsz} core_mask={core_mask}')
def _preprocess(self, frame):
"""Letterbox-resize to imgsz×imgsz, maintain aspect ratio, BGR→RGB, normalize."""
h0, w0 = frame.shape[:2]
scale = min(self.imgsz / h0, self.imgsz / w0)
nh, nw = int(h0 * scale), int(w0 * scale)
resized = cv2.resize(frame, (nw, nh), interpolation=cv2.INTER_LINEAR)
letterbox = np.full((self.imgsz, self.imgsz, 3), 114, dtype=np.uint8)
dy = (self.imgsz - nh) // 2
dx = (self.imgsz - nw) // 2
letterbox[dy:dy + nh, dx:dx + nw] = resized
rgb = cv2.cvtColor(letterbox, cv2.COLOR_BGR2RGB)
gains = np.array([scale, scale, dy, dx], dtype=np.float32)
return rgb, gains
def __call__(self, frame):
"""Run inference on BGR frame. Returns list of detection dicts."""
h0, w0 = frame.shape[:2]
rgb, gains = self._preprocess(frame)
scale, _, pad_y, pad_x = gains
inp = np.expand_dims(rgb, axis=0)
inp = np.ascontiguousarray(inp.astype(np.uint8))
outputs = self.rknn.inference(inputs=[inp])
if len(outputs) == 0:
return []
out = outputs[0] # (1, 4+num_classes, N) or (1, N, 4+num_classes)
out = np.squeeze(out, axis=0) # (C, N) or (N, C)
if out.shape[0] == self.num_classes + 4:
out = out.T # (C, N) → (N, C)
boxes_cxcywh = out[:, :4].copy() # cx, cy, w, h at model resolution
cls_raw = out[:, 4:].copy()
if self.score_sigmoid:
cls_scores = 1.0 / (1.0 + np.exp(-np.clip(cls_raw, -10, 10)))
else:
cls_scores = cls_raw
boxes_xyxy = np.stack([
boxes_cxcywh[:, 0] - boxes_cxcywh[:, 2] / 2,
boxes_cxcywh[:, 1] - boxes_cxcywh[:, 3] / 2,
boxes_cxcywh[:, 0] + boxes_cxcywh[:, 2] / 2,
boxes_cxcywh[:, 1] + boxes_cxcywh[:, 3] / 2,
], axis=1)
max_scores = cls_scores.max(axis=1)
class_ids = cls_scores.argmax(axis=1)
mask = max_scores > self.conf
if mask.sum() == 0:
return []
bboxes = boxes_xyxy[mask].astype(np.float32)
scores = max_scores[mask].astype(np.float32)
clses = class_ids[mask]
bboxes[:, 0] = (bboxes[:, 0] - pad_x) / scale
bboxes[:, 1] = (bboxes[:, 1] - pad_y) / scale
bboxes[:, 2] = (bboxes[:, 2] - pad_x) / scale
bboxes[:, 3] = (bboxes[:, 3] - pad_y) / scale
bboxes[:, 0] = np.clip(bboxes[:, 0], 0, w0)
bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h0)
bboxes[:, 2] = np.clip(bboxes[:, 2], 0, w0)
bboxes[:, 3] = np.clip(bboxes[:, 3], 0, h0)
detections = []
for cls_id in range(self.num_classes):
idx = np.where(clses == cls_id)[0]
if len(idx) == 0:
continue
keep = _nms(bboxes[idx], scores[idx], iou_thr=self.iou)
for k in keep:
j = idx[k]
detections.append({
'bbox': bboxes[j].tolist(),
'score': float(scores[j]),
'cls': int(clses[j]),
'keypoints': None,
})
return detections
def release(self):
self.rknn.release()
# =============================================================================
# Drawing helpers (unchanged from original)
# =============================================================================
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
# =============================================================================
# Main loop
# =============================================================================
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'])
# Load RKNN model
model = RKNNYOLO(
model_path=MODEL_PATH,
core_mask=CORE_MASK,
imgsz=IMGSZ,
conf=CONF,
num_classes=NUM_CLASSES,
score_sigmoid=SCORE_SIGMOID,
)
# Class IDs — order comes from RKNN model output (class index)
# class index 0 → CLASS_AYAM, index 1 → CLASS_TALENAN (or env-specified)
# Use class names in env order: first CLASS_AYAM → id 0, then CLASS_TALENAN → id 1
CLASS_IDS = {
os.getenv('CLASS_AYAM', 'ayam'): 0,
os.getenv('CLASS_TALENAN', 'talenan'): 1,
}
ayam_cls = CLASS_IDS[CLASS_AYAM]
talenan_cls = CLASS_IDS[CLASS_TALENAN]
ayam_tracker = SimpleTracker(max_age=60)
talenan_tracker = SimpleTracker(max_age=60)
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()
model.release()
return
line_x = resolve_line_x(w)
print(f'RKNN counter | {w}x{h} @ {fps}fps | line x={line_x} | cross={CROSS_DIRECTION}')
print(f'Model: {MODEL_PATH} | imgsz={IMGSZ} | core_mask={CORE_MASK}')
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
# RKNN inference
detections = model(frame)
if detections:
ayam_dets = [] # list of (cx, xywh_box)
talenan_dets = []
ayam_kpts_map = {} # det_idx → keypoints
talenan_kpts_map = {}
for di, det in enumerate(detections):
bbox = det['bbox']
cls_id = det['cls']
cx = (bbox[0] + bbox[2]) / 2.0
x1, y1, x2, y2 = bbox
wb, hb = x2 - x1, y2 - y1
box_cxcywh = np.array([cx, (y1 + y2) / 2, wb, hb], dtype=np.float32)
if cls_id == talenan_cls:
talenan_dets.append((cx, box_cxcywh))
if det['keypoints'] is not None:
talenan_kpts_map[len(talenan_dets) - 1] = det['keypoints']
elif cls_id == ayam_cls:
ayam_dets.append((cx, box_cxcywh))
if det['keypoints'] is not None:
ayam_kpts_map[len(ayam_dets) - 1] = det['keypoints']
# Track ayam — returns (track_id → cx, detection_idx → track_id)
ayam_cx_map, ayam_det_to_track = ayam_tracker.update(ayam_dets)
# Track talenan
talenan_cx_map, talenan_det_to_track = talenan_tracker.update(talenan_dets)
# Process talenan crossings
for di, (cx, box) in enumerate(talenan_dets):
tid = talenan_det_to_track.get(di)
if tid is None:
continue
if tid in talenan_tracker.tracks:
if tid in talenan_tracked:
prev_cx = talenan_tracked[tid][0]
if crossed_line(prev_cx, cx, line_x) and tid not in talenan_line_crossed:
talenan_line_crossed.add(tid)
if store.record_talenan_crossing(tid):
batch_closed_frame = True
talenan_cross_flash[tid] = CROSS_FLASH_FRAMES
popups.append({
'x': int(cx) - 20,
'y': int(box[1]),
'born': frame_idx,
'text': 'BATCH CLOSED',
})
talenan_tracked[tid] = (cx, mono)
# Process ayam crossings
for di, (cx, box) in enumerate(ayam_dets):
tid = ayam_det_to_track.get(di)
if tid is None:
continue
if tid in ayam_tracker.tracks:
if tid in ayam_tracked:
prev_cx = ayam_tracked[tid][0]
if crossed_line(prev_cx, cx, line_x) and tid not in ayam_line_crossed:
ayam_line_crossed.add(tid)
_, started_new = store.record_ayam_crossing(tid)
if cross_logger:
cross_logger.write_row([
store.current_batch_number, frame_idx,
datetime.now().isoformat(), tid,
])
ayam_crossed_frame = True
if started_new:
batch_started_frame = True
ayam_cross_flash[tid] = CROSS_FLASH_FRAMES
popups.append({
'x': int(cx) - 12,
'y': int(box[1]),
'born': frame_idx,
'text': '+1',
})
ayam_tracked[tid] = (cx, mono)
# Draw talenan
for di, (cx, box) in enumerate(talenan_dets):
tid = talenan_det_to_track.get(di)
if tid is None:
continue
x1 = int(box[0] - box[2] / 2)
y1 = int(box[1] - box[3] / 2)
x2 = int(box[0] + box[2] / 2)
y2 = int(box[1] + box[3] / 2)
flash = talenan_cross_flash.get(tid, 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 {tid}', x1, y1 - 4, color)
# Draw ayam
for di, (cx, box) in enumerate(ayam_dets):
tid = ayam_det_to_track.get(di)
if tid is None:
continue
x1 = int(box[0] - box[2] / 2)
y1 = int(box[1] - box[3] / 2)
x2 = int(box[0] + box[2] / 2)
y2 = int(box[1] + box[3] / 2)
flash = ayam_cross_flash.get(tid, 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 {tid}', x1, y1 - 4, color)
kpts = ayam_kpts_map.get(di)
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-RKNN' if IS_LIVE else 'FILE-RKNN')
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()
model.release()
store.shutdown()
print('\n=== Batch Summary (SQLite) ===')
print(f'Database: {DB_PATH}')
# Tracked state dicts: track_id → (cx, monotonic_time)
ayam_tracked = {}
talenan_tracked = {}
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"
+33
View File
@@ -0,0 +1,33 @@
[Unit]
Description=Jetson Edge Counter Dashboard (Flask, port 5000)
Documentation=file:///opt/jetson-counter/DEPLOY.md
After=network-online.target jetson-counter.service
Wants=network-online.target
[Service]
Type=simple
User=jetson
Group=jetson
WorkingDirectory=/opt/jetson-counter
EnvironmentFile=/opt/jetson-counter/.env
Environment=PATH=/opt/jetson-counter/venv/bin:/usr/local/bin:/usr/bin:/bin
Environment=FLASK_DEBUG=false
ExecStart=/opt/jetson-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=true
[Install]
WantedBy=multi-user.target
+35
View File
@@ -0,0 +1,35 @@
[Unit]
Description=Jetson Edge YOLO Batch Counter (RTSP + TensorRT)
Documentation=file:///opt/jetson-counter/DEPLOY.md
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=jetson
Group=jetson
WorkingDirectory=/opt/jetson-counter
EnvironmentFile=/opt/jetson-counter/.env
Environment=PYTHONNOUSERSITE=1
Environment=YOLO_CONFIG_DIR=/opt/jetson-counter/.ultralytics
Environment=TORCH_HOME=/opt/jetson-counter/.torch
Environment=PATH=/opt/jetson-counter/venv/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/opt/jetson-counter/venv/bin/python counter_live.py
TimeoutStopSec=30
KillSignal=SIGTERM
Restart=on-failure
RestartSec=10
StartLimitInterval=120s
StartLimitBurst=5
NoNewPrivileges=true
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+5
View File
@@ -0,0 +1,5 @@
numpy<2
rknn-toolkit-lite2
opencv-python
flask
python-dotenv
+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."