Files
zenai-ktc-python/counter_dashboard.py
T
2026-07-24 11:04:20 +07:00

770 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Edge production counter dashboard.
Reads counter.db + current_counter.json from the counter stack.
Tracks daily left/right feeder sack counts per counting day (no batches).
Default port 5000.
"""
import json
import os
import re
import sqlite3
import time
from io import BytesIO
from pathlib import Path
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, send_file
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")
CROSS_SNAPSHOT_DIR = os.getenv("CROSS_SNAPSHOT_DIR", f"{_DEFAULT_DIR}/snapshots")
SAVE_DETECT_SNAPSHOT = os.getenv("SAVE_DETECT_SNAPSHOT", "false").lower() == "true"
CONTROL_ENABLED = os.getenv("CONTROL_ENABLED", "false").lower() == "true"
CONTROL_FILE = os.getenv("CONTROL_FILE", f"{_DEFAULT_DIR}/control.json")
CONTROL_DEFAULT_COUNTING = os.getenv("CONTROL_DEFAULT_COUNTING", "true").lower() == "true"
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()
if not jpeg or len(jpeg) < 2 or jpeg[:2] != b"\xff\xd8":
consecutive_fails += 1
if consecutive_fails >= MAX_FAILS:
return
time.sleep(0.05)
continue
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")
_SNAP_RE = re.compile(
r"^(?P<ts>\d{8}_\d{6}_\d{3})_(?P<kind>detect|left|right)_id(?P<tid>\d+)_f(?P<frame>\d+)\.jpg$"
)
def _parse_snapshot(path, category):
m = _SNAP_RE.match(path.name)
if not m:
return None
try:
dt = datetime.strptime(m.group("ts"), "%Y%m%d_%H%M%S_%f")
except ValueError:
dt = datetime.fromtimestamp(path.stat().st_mtime)
kind = m.group("kind")
return {
"file": f"{category}/{path.name}",
"category": category,
"kind": kind,
"track_id": int(m.group("tid")),
"frame": int(m.group("frame")),
"timestamp": dt.isoformat(),
"mtime": path.stat().st_mtime,
}
def _collect_snapshots():
base = os.path.abspath(CROSS_SNAPSHOT_DIR)
items = []
for category in ("cross", "detect"):
sub = os.path.join(base, category)
if not os.path.isdir(sub):
continue
for name in os.listdir(sub):
if not name.lower().endswith(".jpg"):
continue
info = _parse_snapshot(Path(sub) / name, category)
if info:
items.append(info)
items.sort(key=lambda x: x["mtime"], reverse=True)
return items
@app.route("/api/snapshots")
def api_snapshots():
try:
kind = request.args.get("kind", "all")
track_id = request.args.get("track_id", type=int)
date = request.args.get("date")
limit = request.args.get("limit", 200, type=int)
items = _collect_snapshots()
if kind and kind != "all":
if kind == "cross":
items = [i for i in items if i["category"] == "cross"]
elif kind == "detect":
items = [i for i in items if i["category"] == "detect"]
elif kind in ("left", "right"):
items = [i for i in items if i["kind"] == kind]
if track_id is not None:
items = [i for i in items if i["track_id"] == track_id]
if date:
items = [i for i in items if i["timestamp"][:10] == date]
total = len(items)
items = items[:limit]
for i in items:
i.pop("mtime", None)
return jsonify({"success": True, "total": total, "count": len(items), "snapshots": items})
except Exception as e:
return jsonify({"success": False, "error": str(e), "snapshots": []}), 200
@app.route("/api/snapshot-image/<category>/<path:filename>")
def api_snapshot_image(category, filename):
if category not in ("cross", "detect"):
return jsonify({"success": False, "error": "invalid category"}), 404
base = os.path.abspath(os.path.join(CROSS_SNAPSHOT_DIR, category))
requested = os.path.abspath(os.path.join(base, filename))
if not requested.startswith(base + os.sep) or not os.path.isfile(requested):
return jsonify({"success": False, "error": "not found"}), 404
return send_file(requested, mimetype="image/jpeg")
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_left INTEGER NOT NULL DEFAULT 0,
total_right 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,
show_detect=SAVE_DETECT_SNAPSHOT,
control_enabled=CONTROL_ENABLED,
)
def _read_counting_flag():
try:
with open(CONTROL_FILE, "r", encoding="utf-8") as f:
return bool(json.load(f).get("counting", CONTROL_DEFAULT_COUNTING))
except FileNotFoundError:
return CONTROL_DEFAULT_COUNTING
except Exception:
return CONTROL_DEFAULT_COUNTING
def _write_counting_flag(counting):
os.makedirs(os.path.dirname(CONTROL_FILE) or ".", exist_ok=True)
tmp = f"{CONTROL_FILE}.tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump({"counting": bool(counting)}, f)
os.replace(tmp, CONTROL_FILE)
@app.route("/api/control", methods=["GET"])
def api_control_get():
return jsonify({
"success": True,
"enabled": CONTROL_ENABLED,
"counting": _read_counting_flag(),
})
@app.route("/api/control", methods=["POST"])
def api_control_set():
if not CONTROL_ENABLED:
return jsonify({"success": False, "error": "Runtime control is disabled (set CONTROL_ENABLED=true)"}), 403
data = request.get_json(silent=True) or {}
if "counting" not in data:
return jsonify({"success": False, "error": "Missing 'counting' field"}), 400
try:
counting = bool(data["counting"])
_write_counting_flag(counting)
return jsonify({"success": True, "counting": counting})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/snapshots")
def snapshots_page():
return render_template("snapshots.html", site_name=SITE_NAME, show_detect=SAVE_DETECT_SNAPSHOT)
def _empty_current():
return {
"counting_date": get_counting_date(),
"count": 0,
"count_left": 0,
"count_right": 0,
"start_time": None,
"last_detection_time": None,
}
def _load_current_state():
"""Load live counter state from current_counter.json."""
with open(CURRENT_COUNTER_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
return {
"counting_date": data.get("counting_date") or get_counting_date(),
"count": int(data.get("count", 0) or 0),
"count_left": int(data.get("count_left", 0) or 0),
"count_right": int(data.get("count_right", 0) or 0),
"start_time": data.get("start_time"),
"last_detection_time": data.get("last_detection_time"),
}
def _query_history(date_from=None, date_to=None, days=None, limit=None, offset=0):
"""Query daily_counters with optional date range and pagination."""
conn = get_db()
cur = conn.cursor()
clauses = []
params = []
if days is not None and date_from is None and date_to is None:
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
if date_from:
clauses.append("counting_date >= ?")
params.append(date_from)
if date_to:
clauses.append("counting_date <= ?")
params.append(date_to)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
cur.execute(f"SELECT COUNT(*) AS total FROM daily_counters {where}", params)
total = cur.fetchone()["total"]
sql = f"""
SELECT counting_date, camera_name, object_label,
total_count, total_left, total_right, start_time, end_time, updated_at
FROM daily_counters
{where}
ORDER BY counting_date DESC
"""
page_params = list(params)
if limit is not None:
sql += " LIMIT ? OFFSET ?"
page_params.extend([limit, offset])
cur.execute(sql, page_params)
rows = [
{
"date": row["counting_date"],
"camera_name": row["camera_name"],
"object_label": row["object_label"],
"total_count": row["total_count"],
"total_left": row["total_left"],
"total_right": row["total_right"],
"diff": (row["total_left"] or 0) + (row["total_right"] or 0),
"start_time": row["start_time"],
"end_time": row["end_time"],
"updated_at": row["updated_at"],
}
for row in cur.fetchall()
]
conn.close()
return rows, total
@app.route("/api/current")
@app.route("/api/current-counter")
def api_current():
"""Current counting-day totals from live state file."""
try:
state = _load_current_state()
return jsonify(
{
"success": True,
"active": True,
"site_name": SITE_NAME,
"counting": _read_counting_flag() if CONTROL_ENABLED else True,
**state,
}
)
except FileNotFoundError:
return jsonify(
{
"success": True,
"site_name": SITE_NAME,
"counting": _read_counting_flag() if CONTROL_ENABLED else True,
"active": False,
"error": "No active counter — state file missing",
**_empty_current(),
}
), 200
except Exception as e:
return jsonify(
{
"success": False,
"error": f"Failed to read current counter from {CURRENT_COUNTER_PATH}: {e}",
"site_name": SITE_NAME,
**_empty_current(),
}
), 500
@app.route("/api/history")
def api_history():
"""
Historical daily counters.
Query params:
days – last N calendar days (default 30; ignored if date_from/date_to set)
date_from – inclusive YYYY-MM-DD
date_to – inclusive YYYY-MM-DD
limit – page size (default: all matching)
offset – page offset (default 0)
"""
try:
days = request.args.get("days", type=int)
date_from = request.args.get("date_from")
date_to = request.args.get("date_to")
limit = request.args.get("limit", type=int)
offset = request.args.get("offset", 0, type=int)
if date_from:
try:
datetime.strptime(date_from, "%Y-%m-%d")
except ValueError:
return jsonify({
"success": False,
"error": {
"code": "VALIDATION_ERROR",
"message": f"Invalid date_from '{date_from}'. Use YYYY-MM-DD.",
},
}), 400
if date_to:
try:
datetime.strptime(date_to, "%Y-%m-%d")
except ValueError:
return jsonify({
"success": False,
"error": {
"code": "VALIDATION_ERROR",
"message": f"Invalid date_to '{date_to}'. Use YYYY-MM-DD.",
},
}), 400
if days is None and date_from is None and date_to is None:
days = 30
if offset < 0:
offset = 0
if limit is not None and limit < 1:
return jsonify({
"success": False,
"error": {
"code": "VALIDATION_ERROR",
"message": "limit must be a positive integer",
},
}), 400
rows, total = _query_history(
date_from=date_from,
date_to=date_to,
days=days,
limit=limit,
offset=offset,
)
payload = {
"success": True,
"site_name": SITE_NAME,
"filters": {
"days": days,
"date_from": date_from,
"date_to": date_to,
},
"count": len(rows),
"data": rows,
}
if limit is not None:
payload["pagination"] = {
"offset": offset,
"limit": limit,
"total": total,
"has_next": offset + limit < total,
"has_prev": offset > 0,
}
else:
payload["total"] = total
return jsonify(payload)
except sqlite3.OperationalError as e:
return jsonify({
"success": False,
"error": {
"code": "DATABASE_UNAVAILABLE",
"message": f"Database unavailable at {DB_PATH}: {e}",
},
"data": [],
"count": 0,
"total": 0,
}), 200
except Exception as e:
return jsonify({
"success": False,
"error": {
"code": "INTERNAL_ERROR",
"message": str(e),
},
}), 500
@app.route("/api/history/<counting_date>")
def api_history_day(counting_date):
"""Single counting-day record by YYYY-MM-DD."""
try:
datetime.strptime(counting_date, "%Y-%m-%d")
except ValueError:
return jsonify({
"success": False,
"error": {
"code": "VALIDATION_ERROR",
"message": f"Invalid counting_date '{counting_date}'. Use YYYY-MM-DD.",
},
}), 400
try:
rows, _ = _query_history(date_from=counting_date, date_to=counting_date)
if not rows:
return jsonify({
"success": False,
"error": {
"code": "NOT_FOUND",
"message": f"No history found for counting date {counting_date}",
},
}), 404
return jsonify({
"success": True,
"site_name": SITE_NAME,
"data": rows[0] if len(rows) == 1 else rows,
})
except sqlite3.OperationalError as e:
return jsonify({
"success": False,
"error": {
"code": "DATABASE_UNAVAILABLE",
"message": f"Database unavailable at {DB_PATH}: {e}",
},
}), 503
except Exception as e:
return jsonify({
"success": False,
"error": {
"code": "INTERNAL_ERROR",
"message": str(e),
},
}), 500
@app.route("/api/summary")
def api_summary():
try:
conn = get_db()
cur = conn.cursor()
today = get_counting_date()
cur.execute(
"""
SELECT COALESCE(total_count, 0) as total_count,
COALESCE(total_left, 0) as total_left,
COALESCE(total_right, 0) as total_right
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_left, 0) as total_left,
COALESCE(total_right, 0) as total_right
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_left), 0) as grand_left,
COALESCE(SUM(total_right), 0) as grand_right,
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_left": today_row["total_left"] if today_row else 0,
"total_right": today_row["total_right"] if today_row else 0,
},
"yesterday": {
"date": yesterday,
"total_count": yesterday_row["total_count"] if yesterday_row else 0,
"total_left": yesterday_row["total_left"] if yesterday_row else 0,
"total_right": yesterday_row["total_right"] if yesterday_row else 0,
},
"all_time": {
"grand_total": all_time["grand_total"],
"grand_left": all_time["grand_left"],
"grand_right": all_time["grand_right"],
"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_left": 0, "total_right": 0}, "yesterday": {"date": "", "total_count": 0, "total_left": 0, "total_right": 0}, "all_time": {"grand_total": 0, "grand_left": 0, "grand_right": 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_left, total_right
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_left": row["total_left"],
"total_right": row["total_right"],
}
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_left, total_right, start_time, end_time
FROM daily_counters
ORDER BY counting_date DESC
"""
)
dates = [
{
"date": row["counting_date"],
"total_count": row["total_count"],
"total_left": row["total_left"],
"total_right": row["total_right"],
"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_left, total_right, 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", "Left"), ("D", "Right"), ("E", "Total (L+R)"), ("F", "First Count"), ("G", "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_left"])
ws.cell(row=r_idx, column=4, value=row["total_right"])
ws.cell(row=r_idx, column=5, value=(row["total_left"] or 0) + (row["total_right"] or 0))
ws.cell(row=r_idx, column=6, value=row["start_time"])
ws.cell(row=r_idx, column=7, 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"ZenAI KTC zone 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)