Files
bytetrack-counter/counter_dashboard.py
T
2026-06-25 18:47:34 +07:00

410 lines
12 KiB
Python

#!/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)