262 lines
10 KiB
Python
Executable File
262 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Standalone live dashboard for chicken-counter pipeline.
|
|
|
|
Serve from project root:
|
|
PYTHONPATH=src python3 dashboard.py [--port 8080]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
|
from pathlib import Path
|
|
from socketserver import ThreadingMixIn
|
|
from urllib.parse import unquote, urlparse
|
|
|
|
|
|
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
|
daemon_threads = True
|
|
|
|
DEFAULT_SHM_DIR = "/dev/shm"
|
|
DEFAULT_PORT = 8080
|
|
|
|
DASHBOARD_HTML = r"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Chicken Counter — Live Dashboard</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:hidden}
|
|
#app{display:flex;height:100vh}
|
|
#sidebar{width:260px;background:#16161e;padding:16px;overflow-y:auto;flex-shrink:0}
|
|
#sidebar h1{font-size:18px;color:#80dc5a;margin-bottom:16px}
|
|
#sidebar .stat{margin-bottom:12px}
|
|
#sidebar .stat label{display:block;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:1px}
|
|
#sidebar .stat .value{font-size:22px;font-weight:700;color:#e0e0e0}
|
|
#sidebar .stat .value.warn{color:#ff9f43}
|
|
#sidebar .stat .value.good{color:#80dc5a}
|
|
#cam-list{list-style:none;margin-top:16px}
|
|
#cam-list li{padding:8px 10px;margin:2px 0;border-radius:6px;cursor:pointer;font-size:13px;transition:background .2s}
|
|
#cam-list li:hover{background:#222}
|
|
#cam-list li.active{background:#1a3a2a;color:#80dc5a;font-weight:700}
|
|
#cam-list li .cam-badge{float:right;font-size:10px;padding:1px 6px;border-radius:8px;background:#222;color:#888}
|
|
#cam-list li.active .cam-badge{background:#2a5a3a;color:#80dc5a}
|
|
#main{flex:1;display:flex;flex-direction:column}
|
|
#frame-container{flex:1;display:flex;align-items:center;justify-content:center;background:#000;position:relative}
|
|
#frame-img{max-width:100%;max-height:100%;object-fit:contain}
|
|
#no-frame{color:#555;font-size:18px}
|
|
#top-bar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:#16161e;font-size:12px}
|
|
#top-bar .refresh{color:#888}
|
|
#top-bar .status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
|
|
#top-bar .status-dot.online{background:#80dc5a;box-shadow:0 0 6px #80dc5a}
|
|
#top-bar .status-dot.offline{background:#555}
|
|
.refresh-btn{padding:4px 12px;border-radius:4px;background:#222;border:1px solid #444;color:#ccc;cursor:pointer;font-size:11px}
|
|
.refresh-btn:hover{background:#333}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app">
|
|
<div id="sidebar">
|
|
<h1>🐔 Chicken Counter</h1>
|
|
<div class="stat"><label>Total Entered</label><div class="value good" id="stat-total">--</div></div>
|
|
<div class="stat"><label>Inside Box</label><div class="value" id="stat-inside">--</div></div>
|
|
<div class="stat"><label>Tracks</label><div class="value" id="stat-tracks">--</div></div>
|
|
<div class="stat"><label>Frame</label><div class="value" id="stat-frame">--</div></div>
|
|
<div class="stat"><label>Motion Speed</label><div class="value" id="stat-speed">--</div></div>
|
|
<div class="stat"><label>Status</label><div class="value" id="stat-status">--</div></div>
|
|
<ul id="cam-list"></ul>
|
|
</div>
|
|
<div id="main">
|
|
<div id="top-bar">
|
|
<span><span class="status-dot" id="status-dot"></span><span id="status-text">waiting for pipeline...</span></span>
|
|
<span><span class="refresh" id="refresh-counter"></span> ago
|
|
<button class="refresh-btn" onclick="load()">↻ Refresh</button></span>
|
|
</div>
|
|
<div id="frame-container">
|
|
<img id="frame-img" alt="live stream">
|
|
<div id="no-frame"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
var POLL_MS = %%POLL_MS%%;
|
|
var SHM = "%%SHM_DIR%%";
|
|
var cameras = [];
|
|
var activeCam = null;
|
|
var lastUpdate = 0;
|
|
var img = document.getElementById("frame-img");
|
|
var noFrame = document.getElementById("no-frame");
|
|
|
|
function loadCameras() {{
|
|
fetch("/api/cameras").then(r => r.json()).then(data => {{
|
|
cameras = data.cameras || [];
|
|
renderCamList();
|
|
if (cameras.length && !activeCam) selectCam(cameras[0]);
|
|
if (!cameras.length) {{ noFrame.textContent = "No cameras found in " + SHM; img.style.display = "none"; }}
|
|
}});
|
|
}}
|
|
|
|
function renderCamList() {{
|
|
var ul = document.getElementById("cam-list");
|
|
ul.innerHTML = cameras.map(function(c) {{
|
|
return '<li class="' + (c === activeCam ? "active" : "") + '" onclick="selectCam(\'' + c + '\')">' +
|
|
c + '<span class="cam-badge">▶</span></li>';
|
|
}}).join("");
|
|
}}
|
|
|
|
function selectCam(id) {{
|
|
activeCam = id;
|
|
renderCamList();
|
|
load();
|
|
}}
|
|
|
|
function load() {{
|
|
if (!activeCam) return;
|
|
var t = Date.now();
|
|
img.src = "/shm/" + activeCam + "/frame.jpg?t=" + t;
|
|
fetch("/shm/" + activeCam + "/stats.json?t=" + t).then(function(r) {{
|
|
if (!r.ok) {{ setOffline(); return; }}
|
|
return r.json();
|
|
}}).then(function(s) {{
|
|
if (!s) return;
|
|
lastUpdate = Date.now();
|
|
document.getElementById("stat-total").textContent = s.total_entered_count;
|
|
document.getElementById("stat-inside").textContent = s.inside_box_count;
|
|
document.getElementById("stat-tracks").textContent = s.track_count;
|
|
document.getElementById("stat-frame").textContent = s.frame_index;
|
|
document.getElementById("stat-speed").textContent = s.smoothed_speed;
|
|
document.getElementById("stat-status").textContent = s.backward_active ? "BACKWARD STOP" : "RUNNING";
|
|
var el = document.getElementById("stat-status");
|
|
el.className = "value" + (s.backward_active ? " warn" : " good");
|
|
document.getElementById("status-dot").className = "status-dot online";
|
|
document.getElementById("status-text").textContent = activeCam + " \u2022 frame " + s.frame_index;
|
|
}});
|
|
}}
|
|
|
|
function setOffline() {{
|
|
document.getElementById("status-dot").className = "status-dot offline";
|
|
document.getElementById("status-text").textContent = activeCam + " \u2022 offline";
|
|
}}
|
|
|
|
function updateRefresh() {{
|
|
var ago = Math.round((Date.now() - lastUpdate) / 1000);
|
|
document.getElementById("refresh-counter").textContent = ago + "s";
|
|
}}
|
|
|
|
img.onerror = function() {{ img.style.display = "none"; noFrame.style.display = "block"; noFrame.textContent = "Waiting for frame..."; }};
|
|
img.onload = function() {{ img.style.display = "block"; noFrame.style.display = "none"; }};
|
|
|
|
setInterval(function() {{ load(); }}, POLL_MS);
|
|
setInterval(loadCameras, 3000);
|
|
setInterval(updateRefresh, 1000);
|
|
loadCameras();
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
class DashboardHandler(SimpleHTTPRequestHandler):
|
|
shm_dir = DEFAULT_SHM_DIR
|
|
poll_ms = 500
|
|
|
|
def log_message(self, format, *args):
|
|
pass
|
|
|
|
def do_GET(self):
|
|
try:
|
|
self._handle_request()
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
pass
|
|
|
|
def _handle_request(self):
|
|
parsed = urlparse(self.path)
|
|
path = unquote(parsed.path)
|
|
|
|
if path == "/" or path == "/index.html":
|
|
html = DASHBOARD_HTML.replace("%%POLL_MS%%", str(self.poll_ms)).replace("%%SHM_DIR%%", self.shm_dir)
|
|
self._send_html(html)
|
|
return
|
|
|
|
if path == "/api/cameras":
|
|
cameras = self._discover_cameras()
|
|
self._send_json({"cameras": cameras})
|
|
return
|
|
|
|
if path.startswith("/shm/"):
|
|
rel = path[len("/shm/"):]
|
|
parts = rel.split("/", 1)
|
|
if len(parts) >= 1:
|
|
parts[0] = f"chicken_counter_{parts[0]}"
|
|
rel = "/".join(parts)
|
|
shm_path = Path(self.shm_dir) / rel
|
|
resolved = shm_path.resolve()
|
|
if not str(resolved).startswith(str(Path(self.shm_dir).resolve())):
|
|
self.send_error(403)
|
|
return
|
|
if not resolved.exists():
|
|
self.send_error(404)
|
|
return
|
|
ct = "image/jpeg" if resolved.suffix in (".jpg", ".jpeg") 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(resolved.read_bytes())
|
|
return
|
|
|
|
self.send_error(404)
|
|
|
|
def _discover_cameras(self):
|
|
shm = Path(self.shm_dir)
|
|
cameras = []
|
|
if shm.is_dir():
|
|
for entry in sorted(shm.iterdir()):
|
|
if entry.is_dir() and entry.name.startswith("chicken_counter_"):
|
|
cam_id = entry.name[len("chicken_counter_"):]
|
|
cameras.append(cam_id)
|
|
return cameras
|
|
|
|
def _send_html(self, html: str):
|
|
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 main():
|
|
parser = argparse.ArgumentParser(description="Chicken Counter live dashboard")
|
|
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"HTTP port (default: {DEFAULT_PORT})")
|
|
parser.add_argument("--shm-dir", default=DEFAULT_SHM_DIR, help=f"Shared memory directory (default: {DEFAULT_SHM_DIR})")
|
|
parser.add_argument("--poll-ms", type=int, default=500, help="Image poll interval in ms (default: 500)")
|
|
args = parser.parse_args()
|
|
|
|
DashboardHandler.shm_dir = args.shm_dir
|
|
DashboardHandler.poll_ms = args.poll_ms
|
|
|
|
server = ThreadingHTTPServer(("0.0.0.0", args.port), DashboardHandler)
|
|
print(f"[dashboard] serving at http://0.0.0.0:{args.port}")
|
|
print(f"[dashboard] shm_dir={args.shm_dir} poll={args.poll_ms}ms")
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print("\n[dashboard] stopped")
|
|
server.server_close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|