forked from zakaria/chicken-counting-sukawarna-det
- Switch Flask dev server -> waitress (8 threads, production WSGI) - Only refresh frame.jpg when frame_index changes, not every second - Safety try/except on send_file for edge-case file conflicts - Fix test_run.sh: export PYTHONPATH, use venv python - Update service file to use venv python
199 lines
8.7 KiB
HTML
199 lines
8.7 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Chicken Counter — Live</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:hidden;height:100vh;display:flex;flex-direction:column}
|
|
#top-bar{display:flex;justify-content:space-between;align-items:center;padding:8px 16px;background:#16161e;font-size:12px;flex-shrink:0}
|
|
#top-bar h1{font-size:16px;color:#80dc5a}
|
|
#top-bar .dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin:0 6px}
|
|
#top-bar .dot.online{background:#80dc5a;box-shadow:0 0 4px #80dc5a}
|
|
#top-bar .dot.offline{background:#555}
|
|
#main{flex:1;display:flex}
|
|
#frame-area{flex:1;background:#000;display:flex;align-items:center;justify-content:center;position:relative}
|
|
#frame-area img{max-width:100%;max-height:100%;object-fit:contain}
|
|
#frame-area .overlay{position:absolute;top:12px;left:12px;pointer-events:none}
|
|
#frame-area .overlay .tag{display:inline-block;padding:4px 12px;border-radius:6px;font-size:12px;font-weight:700;margin-bottom:4px}
|
|
#frame-area .overlay .tag.live{background:#1a3a2a;color:#80dc5a}
|
|
#frame-area .overlay .tag.off{background:#333;color:#888}
|
|
#frame-area .overlay .count{font-size:36px;font-weight:900;color:#fff;text-shadow:0 0 16px rgba(0,0,0,.8)}
|
|
#sidebar{width:280px;background:#12121a;padding:16px;overflow-y:auto;flex-shrink:0;display:flex;flex-direction:column;gap:12px}
|
|
#sidebar .stat{padding:12px;background:#16161e;border-radius:8px}
|
|
#sidebar .stat label{display:block;font-size:9px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-bottom:2px}
|
|
#sidebar .stat .val{font-size:22px;font-weight:700}
|
|
#sidebar .stat .val.good{color:#80dc5a}
|
|
#sidebar .stat .val.warn{color:#ff9f43}
|
|
#sidebar .cam-list{}
|
|
#sidebar .cam-list .cam-row{display:flex;justify-content:space-between;align-items:center;padding:6px 10px;margin:2px 0;border-radius:6px;font-size:12px;cursor:pointer;transition:background .15s}
|
|
#sidebar .cam-list .cam-row:hover{background:#1a1a24}
|
|
#sidebar .cam-list .cam-row.act{background:#1a3a2a;color:#80dc5a;font-weight:700}
|
|
#sidebar .cam-list .cam-row .cam-total{font-size:11px;color:#666}
|
|
#sidebar .cam-list .cam-row.act .cam-total{color:#5a9a4a}
|
|
#db-panel{padding:12px;background:#16161e;border-radius:8px;font-size:11px;max-height:200px;overflow-y:auto}
|
|
#db-panel h3{font-size:10px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px}
|
|
#db-panel .db-row{display:flex;justify-content:space-between;padding:2px 0;color:#888}
|
|
#db-panel .db-row .db-total{color:#aaa;font-weight:600}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="top-bar">
|
|
<h1>🐔 Chicken Counter</h1>
|
|
<span style="color:#888"><span id="stat-date">{{ date }}</span> | <span id="clock"></span></span>
|
|
</div>
|
|
<div id="main">
|
|
<div id="frame-area">
|
|
<canvas id="frame-canvas" style="display:none"></canvas>
|
|
<img id="frame-img" src="" alt="live stream" style="display:none">
|
|
<div id="waiting-msg" style="color:#555;font-size:16px;text-align:center">waiting for pipeline...</div>
|
|
<div class="overlay" id="frame-overlay" style="display:none">
|
|
<div class="tag live" id="cam-tag"></div>
|
|
<div class="count" id="cam-count"></div>
|
|
</div>
|
|
</div>
|
|
<div id="sidebar">
|
|
<div class="stat"><label>Total Entered</label><div class="val good" id="s-total">--</div></div>
|
|
<div class="stat"><label>Inside Box</label><div class="val" id="s-inside">--</div></div>
|
|
<div class="stat"><label>Tracks</label><div class="val" id="s-tracks">--</div></div>
|
|
<div class="stat"><label>Frame</label><div class="val" id="s-frame">--</div></div>
|
|
<div class="stat"><label>Motion</label><div class="val" id="s-speed">--</div></div>
|
|
<div class="cam-list" id="cam-list"></div>
|
|
<div class="db-panel" id="db-panel">
|
|
<h3>📊 History</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
var POLL_MS = {{ poll_ms }};
|
|
var SHM = "{{ shm_dir }}";
|
|
var DB_PATH = "{{ db_path }}";
|
|
var cameras = [], activeCam = null, lastUpdate = 0;
|
|
var perCam = {}, lastFramePerCam = {};
|
|
|
|
function boot() {
|
|
loadCameras();
|
|
setInterval(loadCameras, 3000);
|
|
setInterval(poll, POLL_MS);
|
|
setInterval(updateClock, 1000);
|
|
loadHistory();
|
|
setInterval(loadHistory, 60000);
|
|
updateClock();
|
|
}
|
|
|
|
function loadCameras() {
|
|
fetch("/api/cameras").then(r => r.json()).then(data => {
|
|
cameras = data.cameras || [];
|
|
renderCamList();
|
|
if (cameras.length && !activeCam) selectCam(cameras[0]);
|
|
detectActive();
|
|
if (!cameras.length) showWaiting("no cameras detected");
|
|
});
|
|
updateSidebar();
|
|
}
|
|
|
|
function detectActive() {
|
|
var pending = cameras.length;
|
|
cameras.forEach(function(cam) {
|
|
fetch("/shm/" + cam + "/stats.json?t=" + Date.now()).then(r => r.ok ? r.json() : null).then(s => {
|
|
pending--;
|
|
if (!s) return;
|
|
var prev = lastFramePerCam[cam] || 0;
|
|
perCam[cam] = { total: s.total_entered_count, inside: s.inside_box_count, frame: s.frame_index, tracks: s.track_count, speed: s.smoothed_speed };
|
|
if (s.frame_index > prev) {
|
|
lastFramePerCam[cam] = s.frame_index;
|
|
if (activeCam !== cam) selectCam(cam);
|
|
}
|
|
}).finally(function() { if (pending === 0) renderCamList(); });
|
|
});
|
|
}
|
|
|
|
function selectCam(id) {
|
|
activeCam = id;
|
|
document.getElementById("waiting-msg").style.display = "none";
|
|
document.getElementById("frame-overlay").style.display = "block";
|
|
document.getElementById("frame-img").style.display = "block";
|
|
renderCamList();
|
|
refreshNow();
|
|
}
|
|
|
|
function poll() {
|
|
if (!activeCam) return;
|
|
var t = Date.now();
|
|
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;
|
|
document.getElementById("s-inside").textContent = s.inside_box_count;
|
|
document.getElementById("s-tracks").textContent = s.track_count;
|
|
document.getElementById("s-frame").textContent = s.frame_index;
|
|
document.getElementById("s-speed").textContent = s.smoothed_speed.toFixed(1);
|
|
var tag = document.getElementById("cam-tag");
|
|
var status = s.backward_active ? "BACKWARD STOP" : "RUNNING";
|
|
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) {
|
|
document.getElementById("waiting-msg").textContent = msg;
|
|
document.getElementById("waiting-msg").style.display = "block";
|
|
document.getElementById("frame-overlay").style.display = "none";
|
|
document.getElementById("frame-img").style.display = "none";
|
|
}
|
|
|
|
function setOffline() {
|
|
document.getElementById("cam-tag").textContent = activeCam + " \u2022 OFFLINE";
|
|
document.getElementById("cam-tag").className = "tag off";
|
|
}
|
|
|
|
function renderCamList() {
|
|
var ul = document.getElementById("cam-list");
|
|
ul.innerHTML = cameras.map(function(c) {
|
|
var info = perCam[c] || {};
|
|
var cls = c === activeCam ? " act" : "";
|
|
return '<div class="cam-row' + cls + '" onclick="selectCam(\'' + c + '\')">' +
|
|
'<span>' + c + '</span>' +
|
|
'<span class="cam-total">' + (info.total || 0) + '</span></div>';
|
|
}).join("");
|
|
}
|
|
|
|
function updateClock() {
|
|
document.getElementById("clock").textContent = new Date().toLocaleTimeString();
|
|
}
|
|
|
|
function loadHistory() {
|
|
if (!DB_PATH) return;
|
|
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 = "<h3>📊 History</h3>" + rows.map(function(r) {
|
|
return '<div class="db-row"><span>' + r.date + ' ' + r.location + '</span>' +
|
|
'<span class="db-total">' + r.total.toLocaleString() + '</span></div>';
|
|
}).join("");
|
|
});
|
|
}
|
|
|
|
setTimeout(function() { if (activeCam) refreshNow(); }, 500);
|
|
boot();
|
|
</script>
|
|
</body>
|
|
</html>
|