Zenai Karung Pakan Counter
This commit is contained in:
commit
3b8fb0c312
8 files changed
+3674
No files matched your search
+220
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
|
||||||
@@ -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
Reference in new issue
Block a user