Zenai Karung Pakan Counter

This commit is contained in:
proitlab committed 2026-07-09 23:46:18 +07:00
commit 3b8fb0c312
8 files changed
+3674

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
+162
View File
@@ -0,0 +1,162 @@
# =============================================================================
# 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 daily counter records & crossing logs
DB_PATH=/opt/bytetrack-counter/bytetrack_counter.db
# JSON file persisting the current active counting day state
STATE_FILE=/tmp/bytetrack_current_counter.json
# --- Input source ---
# RTSP / HTTP live stream, or a local video file path
SOURCE=rtsp://user:pass@192.168.0.100:554/stream1
# FFmpeg capture options passed to cv2.VideoCapture (RTSP low-latency flags)
OPENCV_FFMPEG_CAPTURE_OPTIONS=rtsp_transport;tcp|fflags;nobuffer|flags;low_delay
# --- RKNN model ---
# Path to exported .rknn model (YOLO format, e.g. yolo11n.rknn)
MODEL_PATH=/opt/models/yolo9t.rknn
# Input image size for the model (square, e.g. 320 → 320×320)
IMGSZ=320
# Use FP16 inference on NPU (true/false); currently unused in ByteTrack variant
HALF=false
# NPU core mask: 1=core0, 2=core1, 3=core0+core1, 7=all three
CORE_MASK=7
# Compute device index (reserved; not used at runtime)
DEVICE=0
# --- YOLO decoder ---
# Number of object classes the model outputs
NUM_CLASSES=2
# Apply sigmoid to raw class scores (true/false); set true if model head uses BCE logits
SCORE_SIGMOID=false
# --- Detection ---
# Confidence threshold – detections below this are discarded before NMS
CONF=0.3
# --- ByteTrack tracking ---
# Detections with score >= this get priority matching in the first association stage
TRACK_HIGH_THRESH=0.5
# Detections with score between this and TRACK_HIGH_THRESH are matched in the second stage
TRACK_LOW_THRESH=0.1
# IoU threshold for the first-stage association (0–1). Higher = stricter overlap required
TRACK_MATCH_THRESH=0.8
# Frames a track survives without a match before being permanently removed
TRACK_BUFFER=30
# Minimum consecutive (or total) hits needed before a track is considered confirmed
TRACK_MIN_HITS=3
# --- ID-switch counting guards ---
# When a track's ID changes right at the counting line, one physical object can be
# counted twice (two IDs cross) or missed (neither ID sees the full transition).
# These two guards correct for that.
#
# Dedup guard (prevents double counting): after a crossing, a second crossing in
# the SAME direction within DEDUP_FRAMES frames and DEDUP_PX horizontal pixels is
# ignored (treated as the same object under a new ID).
DEDUP_FRAMES=15
DEDUP_PX=60
# To DISABLE the dedup guard, set DEDUP_PX=-1 (distance check can never match).
#
# Inheritance guard (prevents missed counting): when a brand-new track appears, it
# inherits the last position of a recently-seen nearby track (within INHERIT_SEC
# seconds and INHERIT_PX horizontal pixels) so the crossing is still detected
# across the ID switch.
INHERIT_SEC=1.0
INHERIT_PX=60
# To DISABLE the inheritance guard, set INHERIT_PX=-1 (distance check can never match).
# --- Display ---
# Site name shown on the dashboard header (top-right)
SITE_NAME=ZenAi
# --- Object class names ---
# Camera / location identifier shown in HUD and stored in DB
CAMERA_NAME=ZenAi
# Label used for batch grouping in the database
OBJECT_LABEL=object
# Class name for the counted object (must match model class order)
CLASS_OBJECT=object
# Model class ID for the object being counted (default 0)
OBJECT_CLASS_ID=0
# --- Line crossing ---
# Two horizontal counting lines:
# Line 1 (default ~33%): counts top-to-down (IN)
# Line 2 (default ~66%): counts bottom-to-up (OUT)
# Fixed y-coordinate for line 1/IN (overrides LINE_Y1_FRAC if set)
LINE_Y1=
# Fraction of frame height for line 1 (default 0.33)
LINE_Y1_FRAC=0.33
# Fixed y-coordinate for line 2 (overrides LINE_Y2_FRAC if set)
LINE_Y2=
# Fraction of frame height for line 2 (default 0.66)
LINE_Y2_FRAC=0.66
# --- Counting day management ---
# Daily cutoff time (HH:MM) – a new counting day starts after this time and the
# previous day's counter_in / counter_out totals are finalized in the database.
# CUTOFF_TIME is an alias used by the dashboard; DAILY_CUTOFF_TIME takes priority in counter_live_rknn.py.
DAILY_CUTOFF_TIME=20:00
CUTOFF_TIME=20:00
# --- CSV export ---
# Write per-crossing events to a CSV file (true/false)
EXPORT_CSV=true
# Path where the crossing CSV is written
CROSS_CSV=/opt/batch-counter/crossings.csv
# --- 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
# 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 counting-day JSON state file used by the dashboard
CURRENT_COUNTER_PATH=/tmp/bytetrack_current_counter.json
+390
View File
@@ -0,0 +1,390 @@
#!/usr/bin/env python3
"""
Edge Jetson production counter dashboard.
Reads jetson_counter.db + current_counter.json from the counter stack.
Tracks daily counter_in / counter_out and total per counting day (no batches).
Default port 5000.
"""
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_COUNTER_PATH = os.getenv("STATE_FILE", os.getenv("CURRENT_COUNTER_PATH", f"{_DEFAULT_DIR}/current_counter.json"))
CUTOFF_TIME = os.getenv("CUTOFF_TIME", os.getenv("DAILY_CUTOFF_TIME", "20:00"))
LIVE_STREAM_FRAME_PATH = os.getenv("LIVE_STREAM_FRAME_PATH", "/dev/shm/jetson-counter/live_frame.jpg")
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 daily_counters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
counting_date TEXT NOT NULL,
camera_name TEXT NOT NULL,
object_label TEXT NOT NULL,
total_count INTEGER NOT NULL DEFAULT 0,
total_in INTEGER NOT NULL DEFAULT 0,
total_out INTEGER NOT NULL DEFAULT 0,
start_time TEXT,
end_time TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(counting_date, camera_name, object_label)
)
"""
)
conn.commit()
conn.close()
_ensure_db()
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME):
if dt is None:
dt = datetime.now()
cutoff = datetime.strptime(cutoff_str, "%H:%M").time()
if dt.time() < cutoff:
return dt.date().isoformat()
return (dt.date() + timedelta(days=1)).isoformat()
@app.route("/")
def index():
return render_template("dashboard.html", site_name=SITE_NAME)
@app.route("/api/current-counter")
def api_current_counter():
try:
with open(CURRENT_COUNTER_PATH, "r") as f:
data = json.load(f)
return jsonify(
{
"success": True,
"counting_date": data.get("counting_date"),
"count": data.get("count", 0),
"count_in": data.get("count_in", 0),
"count_out": data.get("count_out", 0),
"start_time": data.get("start_time"),
"last_detection_time": data.get("last_detection_time"),
}
)
except FileNotFoundError:
return jsonify(
{
"success": False,
"error": "No active counter",
"count": 0,
"count_in": 0,
"count_out": 0,
"counting_date": None,
}
), 200
except Exception as e:
return jsonify(
{
"success": False,
"error": str(e),
"count": 0,
"count_in": 0,
"count_out": 0,
"counting_date": None,
}
), 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_in, 0) as total_in,
COALESCE(total_out, 0) as total_out
FROM daily_counters
WHERE counting_date = ?
""",
(today,),
)
today_row = cur.fetchone()
yesterday = (datetime.strptime(today, "%Y-%m-%d").date() - timedelta(days=1)).isoformat()
cur.execute(
"""
SELECT COALESCE(total_count, 0) as total_count,
COALESCE(total_in, 0) as total_in,
COALESCE(total_out, 0) as total_out
FROM daily_counters
WHERE counting_date = ?
""",
(yesterday,),
)
yesterday_row = cur.fetchone()
cur.execute(
"""
SELECT COALESCE(SUM(total_count), 0) as grand_total,
COALESCE(SUM(total_in), 0) as grand_in,
COALESCE(SUM(total_out), 0) as grand_out,
COUNT(DISTINCT counting_date) as total_days
FROM daily_counters
"""
)
all_time = cur.fetchone()
cur.execute("SELECT ROUND(AVG(total_count), 1) as avg_per_day FROM daily_counters")
avg = cur.fetchone()
cur.execute(
"""
SELECT counting_date, total_count
FROM daily_counters
ORDER BY total_count DESC
LIMIT 1
"""
)
best = cur.fetchone()
conn.close()
return jsonify(
{
"today": {
"date": today,
"total_count": today_row["total_count"] if today_row else 0,
"total_in": today_row["total_in"] if today_row else 0,
"total_out": today_row["total_out"] if today_row else 0,
},
"yesterday": {
"date": yesterday,
"total_count": yesterday_row["total_count"] if yesterday_row else 0,
"total_in": yesterday_row["total_in"] if yesterday_row else 0,
"total_out": yesterday_row["total_out"] if yesterday_row else 0,
},
"all_time": {
"grand_total": all_time["grand_total"],
"grand_in": all_time["grand_in"],
"grand_out": all_time["grand_out"],
"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_in": 0, "total_out": 0}, "yesterday": {"date": "", "total_count": 0, "total_in": 0, "total_out": 0}, "all_time": {"grand_total": 0, "grand_in": 0, "grand_out": 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_in, total_out
FROM daily_counters
WHERE counting_date >= ?
ORDER BY counting_date ASC
""",
(date_from,),
)
daily_data = [
{
"date": row["counting_date"],
"total_count": row["total_count"],
"total_in": row["total_in"],
"total_out": row["total_out"],
}
for row in cur.fetchall()
]
conn.close()
return jsonify(daily_data)
except sqlite3.OperationalError:
return jsonify([]), 200
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/available-dates")
def api_available_dates():
try:
conn = get_db()
cur = conn.cursor()
cur.execute(
"""
SELECT counting_date, total_count, total_in, total_out, start_time, end_time
FROM daily_counters
ORDER BY counting_date DESC
"""
)
dates = [
{
"date": row["counting_date"],
"total_count": row["total_count"],
"total_in": row["total_in"],
"total_out": row["total_out"],
"start_time": row["start_time"],
"end_time": row["end_time"],
}
for row in cur.fetchall()
]
conn.close()
return jsonify(dates)
except sqlite3.OperationalError:
return jsonify([]), 200
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
def _excel_response(wb, filename):
output = BytesIO()
wb.save(output)
output.seek(0)
return Response(
output.getvalue(),
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
def _style_header(ws, cols):
header_font = Font(bold=True, color="FFFFFF", size=11)
header_fill = PatternFill(start_color="2F5496", end_color="2F5496", fill_type="solid")
thin_border = Border(
left=Side(style="thin"), right=Side(style="thin"),
top=Side(style="thin"), bottom=Side(style="thin"),
)
for col_idx, (col_letter, text) in enumerate(cols, 1):
cell = ws.cell(row=1, column=col_idx, value=text)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center")
cell.border = thin_border
ws.freeze_panes = "A2"
def _auto_width(ws):
for col in ws.columns:
max_len = 0
col_letter = col[0].column_letter
for cell in col:
if cell.value is not None:
max_len = max(max_len, len(str(cell.value)))
ws.column_dimensions[col_letter].width = max_len + 4
@app.route("/api/export-daily-csv")
def export_daily_xlsx():
try:
days = request.args.get("days", 30, type=int)
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
conn = get_db()
cur = conn.cursor()
cur.execute(
"""
SELECT counting_date, total_count, total_in, total_out, start_time, end_time
FROM daily_counters
WHERE counting_date >= ?
ORDER BY counting_date ASC
""",
(date_from,),
)
rows = cur.fetchall()
conn.close()
except sqlite3.OperationalError as e:
return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 503
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
wb = Workbook()
ws = wb.active
ws.title = "Daily Counters"
_style_header(ws, [("A", "Date"), ("B", "Total"), ("C", "In"), ("D", "Out"), ("E", "First Count"), ("F", "Last Count")])
for r_idx, row in enumerate(rows, 2):
ws.cell(row=r_idx, column=1, value=row["counting_date"])
ws.cell(row=r_idx, column=2, value=row["total_count"])
ws.cell(row=r_idx, column=3, value=row["total_in"])
ws.cell(row=r_idx, column=4, value=row["total_out"])
ws.cell(row=r_idx, column=5, value=row["start_time"])
ws.cell(row=r_idx, column=6, value=row["end_time"])
_auto_width(ws)
filename = f"{SITE_NAME}_daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return _excel_response(wb, filename)
if __name__ == "__main__":
WSGIRequestHandler.protocol_version = "HTTP/1.1"
print(f"Jetson counter dashboard at http://{DASHBOARD_HOST}:{DASHBOARD_PORT}")
print(f"DB: {DB_PATH}")
print(f"State: {CURRENT_COUNTER_PATH}")
app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG)
+1356
View File
File diff suppressed because it is too large. Load diff
+245
View File
@@ -0,0 +1,245 @@
"""
Production daily counter persistence for edge counter.
No batches: tracks counter_in / counter_out and the daily total per counting day,
delimited by the daily cutoff time. SQLite schema + current_counter.json state.
"""
import json
import sqlite3
import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
class CounterStore:
def __init__(
self,
db_path,
state_file,
camera_name,
object_label='object',
cutoff_time='20:00',
carry_ids=50,
logger=print,
):
self.db_path = db_path
self.state_file = Path(state_file)
self.camera_name = camera_name
self.object_label = object_label
self.cutoff_time_str = cutoff_time
datetime.strptime(cutoff_time, '%H:%M')
self.carry_ids = int(carry_ids)
self.log = logger
self.state_lock = threading.Lock()
self.shutdown_event = threading.Event()
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self.state_file.parent.mkdir(parents=True, exist_ok=True)
self.db = sqlite3.connect(db_path, check_same_thread=False)
self._init_db()
self.current_state = self._load_state()
def _init_db(self):
cur = self.db.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS daily_counters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
counting_date TEXT NOT NULL,
camera_name TEXT NOT NULL,
object_label TEXT NOT NULL,
total_count INTEGER NOT NULL DEFAULT 0,
total_in INTEGER NOT NULL DEFAULT 0,
total_out INTEGER NOT NULL DEFAULT 0,
start_time TEXT,
end_time TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(counting_date, camera_name, object_label)
)
"""
)
self.db.commit()
def get_counting_date(self, dt=None):
if dt is None:
dt = datetime.now()
cutoff = datetime.strptime(self.cutoff_time_str, '%H:%M').time()
if dt.time() < cutoff:
return dt.date().isoformat()
return (dt.date() + timedelta(days=1)).isoformat()
def _load_state(self):
if not self.state_file.exists():
return None
try:
with open(self.state_file, 'r', encoding='utf-8') as f:
state = json.load(f)
current_date = self.get_counting_date()
if state.get('counting_date') != current_date:
self.log(
f"State file belongs to previous counting day "
f"({state.get('counting_date')}). Starting fresh."
)
self.state_file.unlink(missing_ok=True)
return None
state.setdefault('count_in', 0)
state.setdefault('count_out', 0)
state.setdefault('count', state['count_in'] + state['count_out'])
state.setdefault('counted_event_ids', [])
self.log(
f"Resumed {current_date} with total={state['count']} "
f"(in={state['count_in']} out={state['count_out']})"
)
return state
except Exception as exc:
self.log(f'Failed to load state file: {exc}')
return None
def save_state(self):
if self.current_state is None:
self.state_file.unlink(missing_ok=True)
return
with open(self.state_file, 'w', encoding='utf-8') as f:
json.dump(self.current_state, f, indent=2, ensure_ascii=False)
def _start_new_day(self, counting_date):
now = datetime.now().isoformat()
carried = []
if self.current_state is not None:
try:
carried = self.current_state['counted_event_ids'][-self.carry_ids:]
except (KeyError, TypeError):
carried = []
self.current_state = {
'counting_date': counting_date,
'count': 0,
'count_in': 0,
'count_out': 0,
'start_time': now,
'last_detection_time': now,
'counted_event_ids': carried,
}
self.save_state()
self.log(f'Started counting day {counting_date} ({self.object_label})')
def record_object_crossing(self, track_id, direction):
"""Record an object crossing a counting line. direction: 'in' | 'out'."""
with self.state_lock:
counting_date = self.get_counting_date()
day_started = False
if self.current_state is None or self.current_state['counting_date'] != counting_date:
self._start_new_day(counting_date)
day_started = True
event_key = f"{track_id}_{direction}"
if event_key not in self.current_state['counted_event_ids']:
self.current_state['count'] += 1
if direction == 'in':
self.current_state['count_in'] += 1
else:
self.current_state['count_out'] += 1
self.current_state['counted_event_ids'].append(event_key)
self.log(
f'Counted {direction} (track {track_id}) | {counting_date} '
f'total: {self.current_state["count"]} '
f'(in={self.current_state["count_in"]} out={self.current_state["count_out"]})'
)
self._persist_day()
self.current_state['last_detection_time'] = datetime.now().isoformat()
self.save_state()
return self.current_state['count'], day_started
def _persist_day(self):
state = self.current_state
cur = self.db.cursor()
cur.execute(
"""
INSERT INTO daily_counters
(counting_date, camera_name, object_label,
total_count, total_in, total_out, start_time, end_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(counting_date, camera_name, object_label)
DO UPDATE SET
total_count = excluded.total_count,
total_in = excluded.total_in,
total_out = excluded.total_out,
end_time = excluded.end_time,
updated_at = CURRENT_TIMESTAMP
""",
(
state['counting_date'], self.camera_name, self.object_label,
state['count'], state['count_in'], state['count_out'],
state['start_time'], datetime.now().isoformat(),
),
)
self.db.commit()
def cutoff_watcher_loop(self):
while not self.shutdown_event.is_set():
time.sleep(60)
with self.state_lock:
if self.current_state is None:
continue
if self.current_state['counting_date'] != self.get_counting_date():
self.log('Daily cutoff reached - finalizing day totals')
self._persist_day()
self.current_state = None
self.save_state()
def start_cutoff_watcher(self):
t = threading.Thread(target=self.cutoff_watcher_loop, daemon=True)
t.start()
return t
@property
def current_count(self):
if self.current_state is None:
return 0
return self.current_state['count']
@property
def current_count_in(self):
if self.current_state is None:
return 0
return self.current_state.get('count_in', 0)
@property
def current_count_out(self):
if self.current_state is None:
return 0
return self.current_state.get('count_out', 0)
def _day_totals(self, counting_date=None):
if counting_date is None:
counting_date = self.get_counting_date()
cur = self.db.cursor()
cur.execute(
"""
SELECT COALESCE(total_count, 0), COALESCE(total_in, 0), COALESCE(total_out, 0)
FROM daily_counters
WHERE counting_date = ? AND camera_name = ? AND object_label = ?
""",
(counting_date, self.camera_name, self.object_label),
)
row = cur.fetchone()
return row if row else (0, 0, 0)
def display_total(self):
return self._day_totals()[0]
def display_in(self):
return self._day_totals()[1]
def display_out(self):
return self._day_totals()[2]
def shutdown(self):
self.shutdown_event.set()
with self.state_lock:
if self.current_state is not None:
self._persist_day()
self.db.close()
+143
View File
@@ -0,0 +1,143 @@
# =============================================================================
# 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/zenai-kpc-bt-counter
# SQLite database path for daily counter records & crossing logs
DB_PATH=/tmp/counter.db
# JSON file persisting the current active counting day state
STATE_FILE=/tmp/current_counter.json
# --- Input source ---
# RTSP / HTTP live stream, or a local video file path
#SOURCE=rtsp://user:pass@192.168.0.100:554/stream1
SOURCE=rtsp://10.38.30.64:8554/my_stream
# 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/zenai_kac_sukawarna_20260702.rknn
# Input image size for the model (square, e.g. 320 → 320×320)
IMGSZ=320
# Use FP16 inference on NPU (true/false); currently unused in ByteTrack variant
HALF=false
# NPU core mask: 1=core0, 2=core1, 3=core0+core1, 7=all three
CORE_MASK=7
# Compute device index (reserved; not used at runtime)
DEVICE=0
# --- YOLO decoder ---
# Number of object classes the model outputs
NUM_CLASSES=4
# 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.5
# --- ByteTrack tracking ---
# Detections with score >= this get priority matching in the first association stage
TRACK_HIGH_THRESH=0.5
# Detections with score between this and TRACK_HIGH_THRESH are matched in the second stage
TRACK_LOW_THRESH=0.3
# IoU threshold for the first-stage association (0–1). Higher = stricter overlap required
TRACK_MATCH_THRESH=0.7
# Frames a track survives without a match before being permanently removed
TRACK_BUFFER=60
# Minimum consecutive (or total) hits needed before a track is considered confirmed
TRACK_MIN_HITS=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=karung
# Class name for the counted object (must match model class order)
CLASS_OBJECT=karung
# Model class ID for the object being counted (default 0)
OBJECT_CLASS_ID=0
# --- Line crossing ---
# Two horizontal counting lines:
# Line 1 (default ~33%): counts top-to-down (IN)
# Line 2 (default ~66%): counts bottom-to-up (OUT)
# Fixed y-coordinate for line 1/IN (overrides LINE_Y1_FRAC if set)
LINE_Y1=
# Fraction of frame height for line 1 (default 0.33)
LINE_Y1_FRAC=0.70
# Fixed y-coordinate for line 2 (overrides LINE_Y2_FRAC if set)
LINE_Y2=
# Fraction of frame height for line 2 (default 0.66)
LINE_Y2_FRAC=0.30
# --- Counting day management ---
# Daily cutoff time (HH:MM) – a new counting day starts after this time and the
# previous day's counter_in / counter_out totals are finalized in the database.
# CUTOFF_TIME is an alias used by the dashboard; DAILY_CUTOFF_TIME takes priority in counter_live_rknn.py.
DAILY_CUTOFF_TIME=20:00
CUTOFF_TIME=20:00
# --- CSV export ---
# Write per-crossing events to a CSV file (true/false)
EXPORT_CSV=false
# Path where the crossing CSV is written
CROSS_CSV=/tmp/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
# 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=true
# 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 counting-day JSON state file used by the dashboard
CURRENT_COUNTER_PATH=/tmp/bytetrack_current_counter.json
+6
View File
@@ -0,0 +1,6 @@
numpy<2
rknn-toolkit-lite2
opencv-python
flask
python-dotenv
openpyxl
File diff suppressed because it is too large. Load diff