diff --git a/chicken-dashboard.service b/chicken-dashboard.service index bf40484..eec1cdd 100644 --- a/chicken-dashboard.service +++ b/chicken-dashboard.service @@ -6,7 +6,7 @@ After=network.target Type=simple User=dsutanto WorkingDirectory=/media/jetson/DATA/.Codes/chicken-counting-sukawarna-det -ExecStart=/media/jetson/DATA/karung-sukawarna/venv/bin/python dashboard.py --port 8080 --db chicken_counts.db +ExecStart=/media/jetson/DATA/karung-sukawarna/venv/bin/python dashboard.py --port 8080 --db db/chicken_counts.db Restart=always RestartSec=5 Environment=PYTHONUNBUFFERED=1 diff --git a/configs/cycle7_batch_optimized.yaml b/configs/cycle7_batch_optimized.yaml index 71a7b7d..6469ea2 100644 --- a/configs/cycle7_batch_optimized.yaml +++ b/configs/cycle7_batch_optimized.yaml @@ -6,7 +6,7 @@ batch: delete_intermediate: false checkpoint_every_n_frames: 3000 location: kandang-atas - db_path: chicken_counts.db + db_path: db/chicken_counts.db defaults: detection: diff --git a/dashboard.py b/dashboard.py index a131c9a..0ee1fa9 100755 --- a/dashboard.py +++ b/dashboard.py @@ -1,37 +1,43 @@ #!/usr/bin/env python3 -"""Live dashboard for chicken-counter pipeline using Flask. - -Serve from project root: - python3 dashboard.py [--port 8080] [--date 2026-06-10] [--db chicken_counts.db] -""" +"""Live dashboard for chicken-counter pipeline.""" from __future__ import annotations import argparse import json -import logging import sqlite3 import threading +import time +from http.server import HTTPServer, SimpleHTTPRequestHandler from pathlib import Path +from socketserver import ThreadingMixIn +from urllib.parse import unquote, urlparse -from flask import Flask, jsonify, render_template, send_file -from waitress import serve -# Suppress Flask request logs -log = logging.getLogger("werkzeug") -log.setLevel(logging.ERROR) +class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): + daemon_threads = True + DEFAULT_SHM_DIR = "/dev/shm" DEFAULT_PORT = 8080 - -app = Flask(__name__) -app.config["shm_dir"] = DEFAULT_SHM_DIR -app.config["poll_ms"] = 1000 -app.config["run_date"] = "" -app.config["db_path"] = "" +TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" _db_conn = None _db_lock = threading.Lock() +_db_path = "" + + +def _get_db(): + global _db_conn + if not _db_path: + return None + with _db_lock: + if _db_conn is None: + _db_conn = sqlite3.connect(_db_path, check_same_thread=False) + _db_conn.row_factory = sqlite3.Row + _db_conn.execute("PRAGMA journal_mode=WAL") + _db_conn.execute("PRAGMA cache_size=-8000") + return _db_conn def _init_db(db_path: str) -> None: @@ -51,22 +57,8 @@ def _init_db(db_path: str) -> None: conn.close() -def _get_db(): - global _db_conn - db = app.config["db_path"] - if not db: - return None - with _db_lock: - if _db_conn is None: - _db_conn = sqlite3.connect(db, check_same_thread=False) - _db_conn.row_factory = sqlite3.Row - _db_conn.execute("PRAGMA journal_mode=WAL") - _db_conn.execute("PRAGMA cache_size=-8000") - return _db_conn - - -def _discover_cameras(): - shm = Path(app.config["shm_dir"]) +def _discover_cameras(shm_dir): + shm = Path(shm_dir) cameras = [] if shm.is_dir(): for entry in sorted(shm.iterdir()): @@ -75,152 +67,200 @@ def _discover_cameras(): return cameras -@app.route("/") -def index(): - return render_template( - "index.html", - poll_ms=app.config["poll_ms"], - shm_dir=app.config["shm_dir"], - date=app.config["run_date"] or "today", - db_path=app.config["db_path"], - ) +class DashboardHandler(SimpleHTTPRequestHandler): + shm_dir = DEFAULT_SHM_DIR + poll_ms = 1000 + run_date = "" + def log_message(self, format, *args): + pass -@app.route("/api/cameras") -def api_cameras(): - return jsonify({"cameras": _discover_cameras()}) + def do_GET(self): + try: + self._handle() + except (BrokenPipeError, ConnectionResetError): + pass + def _handle(self): + parsed = urlparse(self.path) + path = unquote(parsed.path) -@app.route("/shm//stats.json") -def shm_stats(camera_id): - stats_path = Path(app.config["shm_dir"]) / f"chicken_counter_{camera_id}" / "stats.json" - if not stats_path.exists(): - return jsonify({"error": "not found"}), 404 - return jsonify(json.loads(stats_path.read_text())) + if path == "/": + self._serve_html() + return + if path.startswith("/stream/"): + self._handle_stream(path) + return -@app.route("/shm//frame.jpg") -def shm_frame(camera_id): - frame_path = Path(app.config["shm_dir"]) / f"chicken_counter_{camera_id}" / "frame.jpg" - if not frame_path.exists(): - return jsonify({"error": "not found"}), 404 - try: - return send_file(frame_path, mimetype="image/jpeg", max_age=2, download_name=None) - except (FileNotFoundError, PermissionError): - return jsonify({"error": "not ready"}), 503 + if path == "/api/cameras": + self._send_json({"cameras": _discover_cameras(self.shm_dir)}) + return + if path.startswith("/api/db/"): + self._handle_db(path) + return -def _get_db(): - db = app.config["db_path"] - if not db or not Path(db).exists(): - return None - conn = sqlite3.connect(db) - conn.row_factory = sqlite3.Row - return conn + if path.startswith("/shm/"): + self._handle_shm(path) + return + self._send_error(404) -@app.route("/api/db/summary") -def db_summary(): - conn = _get_db() - if not conn: - return jsonify({}) - row = conn.execute(""" - SELECT COUNT(DISTINCT date) AS days, - COUNT(DISTINCT location) AS locations, - COUNT(*) AS total_runs, - SUM(total_entered) AS total_chickens, - ROUND(SUM(elapsed_seconds)/3600.0, 1) AS total_hours - FROM batch_runs - """).fetchone() - return jsonify(dict(row)) + def _handle_stream(self, path): + cam_id = path[len("/stream/"):] + frame_path = Path(self.shm_dir) / f"chicken_counter_{cam_id}" / "frame.jpg" + if not frame_path.exists(): + self._send_error(404) + return + self.send_response(200) + self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame") + self.send_header("Cache-Control", "no-cache") + self.end_headers() -@app.route("/api/db/history") -def db_history(): - conn = _get_db() - if not conn: - return jsonify([]) - rows = conn.execute(""" - SELECT date, location, - COUNT(*) AS cams, - SUM(total_entered) AS total, - ROUND(SUM(elapsed_seconds)/60.0, 1) AS minutes - FROM batch_runs - GROUP BY date, location - ORDER BY date DESC, location - LIMIT 50 - """).fetchall() - return jsonify([dict(r) for r in rows]) + last_mtime = 0 + try: + while True: + try: + mtime = frame_path.stat().st_mtime + if mtime != last_mtime: + last_mtime = mtime + data = frame_path.read_bytes() + self.wfile.write( + b"--frame\r\n" + b"Content-Type: image/jpeg\r\n" + b"Content-Length: " + str(len(data)).encode() + b"\r\n\r\n" + + data + b"\r\n" + ) + self.wfile.flush() + except (FileNotFoundError, OSError): + pass + time.sleep(0.1) + except (BrokenPipeError, ConnectionResetError): + pass + def _handle_shm(self, path): + rel = path[len("/shm/"):] + parts = rel.split("/", 1) + if len(parts) < 2: + self._send_error(404) + return -@app.route("/api/db/date/") -def db_date(date): - conn = _get_db() - if not conn: - return jsonify({}) - cameras = conn.execute(""" - SELECT camera_id, total_entered, frames_processed, - ROUND(elapsed_seconds,1) AS elapsed_seconds, - stopped_reason, source_video, location - FROM batch_runs WHERE date=? ORDER BY camera_id - """, (date,)).fetchall() - total = conn.execute( - "SELECT SUM(total_entered) AS total, ROUND(SUM(elapsed_seconds)/60.0,1) AS minutes " - "FROM batch_runs WHERE date=?", (date,)).fetchone() - return jsonify({"date": date, "total": dict(total), "cameras": [dict(r) for r in cameras]}) + cam_id = parts[0] + file = parts[1] + fpath = Path(self.shm_dir) / f"chicken_counter_{cam_id}" / file + if str(fpath.resolve()).startswith(str(Path(self.shm_dir).resolve())): + if fpath.exists(): + ct = "image/jpeg" if file.endswith(".jpg") else "application/json" + self.send_response(200) + self.send_header("Content-Type", ct) + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + self.end_headers() + self.wfile.write(fpath.read_bytes()) + return + self._send_error(404) -@app.route("/api/db/camera/") -def db_camera(camera_id): - conn = _get_db() - if not conn: - return jsonify([]) - rows = conn.execute(""" - SELECT date, location, total_entered, frames_processed, - ROUND(elapsed_seconds,1) AS elapsed_seconds, stopped_reason - FROM batch_runs WHERE camera_id=? ORDER BY date DESC LIMIT 50 - """, (camera_id,)).fetchall() - return jsonify([dict(r) for r in rows]) + def _handle_db(self, path): + conn = _get_db() + if not conn: + self._send_json({}) + return + if path == "/api/db/summary": + row = conn.execute("SELECT COUNT(DISTINCT date) AS days, COUNT(DISTINCT location) AS locations, COUNT(*) AS total_runs, SUM(total_entered) AS total_chickens, ROUND(SUM(elapsed_seconds)/3600.0,1) AS total_hours FROM batch_runs").fetchone() + self._send_json(dict(row)) + return -@app.route("/api/db/location/") -def db_location(location): - conn = _get_db() - if not conn: - return jsonify({}) - history = conn.execute(""" - SELECT date, GROUP_CONCAT(camera_id,', ') AS cameras, - SUM(total_entered) AS total, - ROUND(SUM(elapsed_seconds)/60.0,1) AS minutes - FROM batch_runs WHERE location=? GROUP BY date ORDER BY date DESC LIMIT 50 - """, (location,)).fetchall() - summary = conn.execute(""" - SELECT COUNT(DISTINCT date) AS days, SUM(total_entered) AS total, - ROUND(SUM(elapsed_seconds)/3600.0,1) AS hours - FROM batch_runs WHERE location=? - """, (location,)).fetchone() - return jsonify({"location": location, "summary": dict(summary), "history": [dict(r) for r in history]}) + if path == "/api/db/history": + rows = conn.execute("SELECT date, location, COUNT(*) AS cams, SUM(total_entered) AS total, ROUND(SUM(elapsed_seconds)/60.0,1) AS minutes FROM batch_runs GROUP BY date, location ORDER BY date DESC, location LIMIT 50").fetchall() + self._send_json([dict(r) for r in rows]) + return + + # /api/db/date/ + prefix = "/api/db/date/" + if path.startswith(prefix): + date = path[len(prefix):] + cameras = conn.execute("SELECT camera_id, total_entered, frames_processed, ROUND(elapsed_seconds,1) AS elapsed_seconds, stopped_reason, source_video, location FROM batch_runs WHERE date=? ORDER BY camera_id", (date,)).fetchall() + total = conn.execute("SELECT SUM(total_entered) AS total, ROUND(SUM(elapsed_seconds)/60.0,1) AS minutes FROM batch_runs WHERE date=?", (date,)).fetchone() + self._send_json({"date": date, "total": dict(total), "cameras": [dict(r) for r in cameras]}) + return + + # /api/db/camera/ + prefix = "/api/db/camera/" + if path.startswith(prefix): + cam_id = path[len(prefix):] + rows = conn.execute("SELECT date, location, total_entered, frames_processed, ROUND(elapsed_seconds,1) AS elapsed_seconds, stopped_reason FROM batch_runs WHERE camera_id=? ORDER BY date DESC LIMIT 50", (cam_id,)).fetchall() + self._send_json([dict(r) for r in rows]) + return + + # /api/db/location/ + prefix = "/api/db/location/" + if path.startswith(prefix): + loc = path[len(prefix):] + history = conn.execute("SELECT date, GROUP_CONCAT(camera_id,', ') AS cameras, SUM(total_entered) AS total, ROUND(SUM(elapsed_seconds)/60.0,1) AS minutes FROM batch_runs WHERE location=? GROUP BY date ORDER BY date DESC LIMIT 50", (loc,)).fetchall() + summary = conn.execute("SELECT COUNT(DISTINCT date) AS days, SUM(total_entered) AS total, ROUND(SUM(elapsed_seconds)/3600.0,1) AS hours FROM batch_runs WHERE location=?", (loc,)).fetchone() + self._send_json({"location": loc, "summary": dict(summary), "history": [dict(r) for r in history]}) + return + + self._send_json({}) + + def _serve_html(self): + html_path = TEMPLATE_DIR / "index.html" + html = html_path.read_text(encoding="utf-8") + html = html.replace("{{ poll_ms }}", str(self.poll_ms)) + html = html.replace("{{ shm_dir }}", self.shm_dir) + html = html.replace("{{ date }}", self.run_date or "today") + html = html.replace("{{ db_path }}", _db_path) + data = html.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _send_json(self, obj): + data = json.dumps(obj).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _send_error(self, code): + self.send_response(code) + self.send_header("Content-Length", "0") + self.end_headers() def main(): + global _db_path + parser = argparse.ArgumentParser(description="Chicken Counter live dashboard") parser.add_argument("--port", type=int, default=DEFAULT_PORT) parser.add_argument("--shm-dir", default=DEFAULT_SHM_DIR) - parser.add_argument("--poll-ms", type=int, default=500) - parser.add_argument("--date", default="", help="Processing date") - parser.add_argument("--db", default="chicken_counts.db", help="SQLite database path") + parser.add_argument("--poll-ms", type=int, default=1000) + parser.add_argument("--date", default="") + parser.add_argument("--db", default="db/chicken_counts.db") args = parser.parse_args() - app.config["shm_dir"] = args.shm_dir - app.config["poll_ms"] = args.poll_ms - app.config["run_date"] = args.date - app.config["db_path"] = str(Path(args.db).resolve()) if args.db else "" + DashboardHandler.shm_dir = args.shm_dir + DashboardHandler.poll_ms = args.poll_ms + DashboardHandler.run_date = args.date + _db_path = str(Path(args.db).resolve()) if args.db else "" - if app.config["db_path"]: - _init_db(app.config["db_path"]) + if _db_path: + _init_db(_db_path) - print(f"[dashboard] http://0.0.0.0:{args.port} shm={args.shm_dir} db={args.db}" + (f" date={args.date}" if args.date else "")) - serve(app, host="0.0.0.0", port=args.port, threads=8) + server = ThreadingHTTPServer(("0.0.0.0", args.port), DashboardHandler) + date_info = f" date={args.date}" if args.date else "" + print(f"[dashboard] http://0.0.0.0:{args.port} shm={args.shm_dir} db={args.db}{date_info}") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\n[dashboard] stopped") + server.server_close() if __name__ == "__main__": diff --git a/db/chicken_counts.db b/db/chicken_counts.db index 4a92189..db4a3e7 100644 Binary files a/db/chicken_counts.db and b/db/chicken_counts.db differ diff --git a/db/chicken_counts.db-shm b/db/chicken_counts.db-shm new file mode 100644 index 0000000..fe9ac28 Binary files /dev/null and b/db/chicken_counts.db-shm differ diff --git a/db/chicken_counts.db-wal b/db/chicken_counts.db-wal new file mode 100644 index 0000000..e69de29 diff --git a/templates/index.html b/templates/index.html index fb56837..3d121c6 100644 --- a/templates/index.html +++ b/templates/index.html @@ -69,6 +69,7 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h var POLL_MS = {{ poll_ms }}; var SHM = "{{ shm_dir }}"; var DB_PATH = "{{ db_path }}"; +var RUN_DATE = "{{ date }}"; var cameras = [], activeCam = null, lastUpdate = 0; var perCam = {}, lastFramePerCam = {}; @@ -114,6 +115,7 @@ function selectCam(id) { document.getElementById("waiting-msg").style.display = "none"; document.getElementById("frame-overlay").style.display = "block"; document.getElementById("frame-img").style.display = "block"; + document.getElementById("frame-img").src = "/stream/" + id; renderCamList(); refreshNow(); } @@ -124,7 +126,6 @@ function poll() { fetch("/shm/" + activeCam + "/stats.json?t=" + t).then(r => r.ok ? r.json() : null).then(s => { if (!s) { setOffline(); return; } lastUpdate = Date.now(); - var oldFrame = lastFramePerCam[activeCam] || 0; lastFramePerCam[activeCam] = s.frame_index; perCam[activeCam] = { total: s.total_entered_count, inside: s.inside_box_count, frame: s.frame_index, tracks: s.track_count, speed: s.smoothed_speed }; document.getElementById("s-total").textContent = s.total_entered_count; @@ -137,18 +138,11 @@ function poll() { tag.textContent = activeCam + " \u2022 " + status; tag.className = "tag " + (s.backward_active ? "off" : "live"); document.getElementById("cam-count").textContent = s.total_entered_count; - // only refresh frame when index changes (every 5 frames with interval_frames=5) - if (s.frame_index != oldFrame) { - document.getElementById("frame-img").src = "/shm/" + activeCam + "/frame.jpg?t=" + t; - } }); } function refreshNow() { poll(); - // also update the frame immediately - var t = Date.now(); - document.getElementById("frame-img").src = "/shm/" + activeCam + "/frame.jpg?t=" + t; } function showWaiting(msg) { @@ -163,6 +157,8 @@ function setOffline() { document.getElementById("cam-tag").className = "tag off"; } +function updateSidebar() {} + function renderCamList() { var ul = document.getElementById("cam-list"); ul.innerHTML = cameras.map(function(c) { @@ -180,15 +176,34 @@ function updateClock() { function loadHistory() { if (!DB_PATH) return; + var panel = document.getElementById("db-panel"); + panel.style.display = "block"; + + // per-day totals fetch("/api/db/history").then(r => r.json()).then(rows => { - var panel = document.getElementById("db-panel"); - if (!rows.length) { panel.style.display = "none"; return; } - panel.style.display = "block"; - panel.innerHTML = "

📊 History

" + rows.map(function(r) { - return '
' + r.date + ' ' + r.location + '' + - '' + r.total.toLocaleString() + '
'; - }).join(""); + var html = "

📊 History

"; + if (rows.length) { + html += rows.map(function(r) { + return '
' + r.date + ' ' + r.location + '' + + '' + r.total.toLocaleString() + '
'; + }).join(""); + } + panel.innerHTML = html; }); + + // per-camera breakdown for current date + if (RUN_DATE && RUN_DATE !== "today") { + fetch("/api/db/date/" + RUN_DATE).then(r => r.json()).then(data => { + if (!data.cameras || !data.cameras.length) return; + var html = panel.innerHTML; + html += '

📹 ' + RUN_DATE + ' (' + (data.total.total || 0).toLocaleString() + ')

'; + data.cameras.forEach(function(c) { + html += '
' + c.camera_id + '' + + '' + c.total_entered.toLocaleString() + '
'; + }); + panel.innerHTML = html; + }); + } } setTimeout(function() { if (activeCam) refreshNow(); }, 500);