Python optimize [Multi-coop multi-floor discovery, Auto .engine compilation, API, mortality webapp trigger, parallel processing/tensor batching/hybrid, etc.] #3

Merged
zakaria merged 39 commits from andrew/chicken-counting-sukawarna-det:python-optimize into python-optimize 2026-08-20 11:00:31 +07:00
4 changed files with 446 additions and 278 deletions
Showing only changes of commit efd4726bc6 - Show all commits

No files matched your search

+77 -278
View File
@@ -1,315 +1,114 @@
#!/usr/bin/env python3
"""Standalone live dashboard for chicken-counter pipeline.
"""Live dashboard for chicken-counter pipeline using Flask.
Serve from project root:
PYTHONPATH=src python3 dashboard.py [--port 8080]
python3 dashboard.py [--port 8080] [--date 2026-06-10] [--db chicken_counts.db]
"""
from __future__ import annotations
import argparse
import json
import os
from http.server import HTTPServer, SimpleHTTPRequestHandler
import sqlite3
from pathlib import Path
from socketserver import ThreadingMixIn
from urllib.parse import unquote, urlparse
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
from flask import Flask, Response, jsonify, render_template, send_file
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>&#x1f414; Chicken Counter</h1>
<div class="stat"><label>All Cameras Total</label><div class="value good" id="stat-all-total">--</div></div>
<div class="stat"><label>Active Camera</label><div class="value" id="stat-cam-name">--</div></div>
<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 id="stat-date">%%DATE%%</span> &nbsp;
<span class="refresh" id="refresh-counter"></span> ago &nbsp;
<button class="refresh-btn" onclick="load()">&#x21bb; 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 perCam = {{}};
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) {{ noFrame.textContent = "No cameras found in " + SHM; img.style.display = "none"; updateSidebar(); return; }}
detectActive();
if (!activeCam) selectCam(cameras[0]);
}});
}}
function detectActive() {{
var pending = cameras.length;
cameras.forEach(function(cam) {{
fetch("/shm/" + cam + "/stats.json?t=" + Date.now()).then(function(r) {{
if (!r.ok) return;
return r.json();
}}).then(function(s) {{
pending--;
if (!s) return;
var prev = (perCam[cam] && perCam[cam].frame) || 0;
perCam[cam] = {{
total: s.total_entered_count,
inside: s.inside_box_count,
frame: s.frame_index,
tracks: s.track_count
}};
if (s.frame_index > prev && activeCam !== cam) selectCam(cam);
}}).finally(function() {{
if (pending === 0) updateSidebar();
}});
}});
}}
function updateSidebar() {{
var allTotal = 0;
cameras.forEach(function(cam) {{
if (perCam[cam]) allTotal += perCam[cam].total || 0;
}});
document.getElementById("stat-all-total").textContent = allTotal || "--";
document.getElementById("stat-cam-name").textContent = activeCam || "--";
}}
function renderCamList() {{
var ul = document.getElementById("cam-list");
ul.innerHTML = cameras.map(function(c) {{
var info = perCam[c] || {{}};
var total = info.total || 0;
return '<li class="' + (c === activeCam ? "active" : "") + '" onclick="selectCam(\'' + c + '\')">' +
c + ' <span style="font-size:10px;color:#888">(' + total + ')</span>' +
'<span class="cam-badge">&#x25b6;</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();
perCam[activeCam] = {{
total: s.total_entered_count,
inside: s.inside_box_count,
frame: s.frame_index,
tracks: s.track_count
}};
if (s.run_date) {{
document.getElementById("stat-date").textContent = s.run_date;
}}
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;
updateSidebar();
renderCamList();
}});
}}
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>"""
app = Flask(__name__)
app.config["shm_dir"] = DEFAULT_SHM_DIR
app.config["poll_ms"] = 500
app.config["run_date"] = ""
app.config["db_path"] = ""
class DashboardHandler(SimpleHTTPRequestHandler):
shm_dir = DEFAULT_SHM_DIR
poll_ms = 500
run_date = ""
def _discover_cameras():
shm = Path(app.config["shm_dir"])
cameras = []
if shm.is_dir():
for entry in sorted(shm.iterdir()):
if entry.is_dir() and entry.name.startswith("chicken_counter_"):
cameras.append(entry.name[len("chicken_counter_"):])
return cameras
def log_message(self, format, *args):
pass
def do_GET(self):
try:
self._handle_request()
except (BrokenPipeError, ConnectionResetError):
pass
@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"],
)
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).replace("%%DATE%%", self.run_date or "today")
self._send_html(html)
return
@app.route("/api/cameras")
def api_cameras():
return jsonify({"cameras": _discover_cameras()})
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
@app.route("/shm/<camera_id>/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()))
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
@app.route("/shm/<camera_id>/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
return send_file(frame_path, mimetype="image/jpeg", max_age=0, download_name=None)
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)
@app.route("/api/db/history")
def db_history():
db = app.config["db_path"]
if not db or not Path(db).exists():
return jsonify([])
conn = sqlite3.connect(db)
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()
conn.close()
return jsonify([
{"date": r[0], "location": r[1], "cams": r[2],
"total": r[3], "minutes": r[4]}
for r in rows
])
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)")
parser.add_argument("--date", default="", help="Processing date to display (e.g. 2026-06-10)")
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("--debug", action="store_true")
args = parser.parse_args()
DashboardHandler.shm_dir = args.shm_dir
DashboardHandler.poll_ms = args.poll_ms
DashboardHandler.run_date = args.date
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 ""
server = ThreadingHTTPServer(("0.0.0.0", args.port), DashboardHandler)
date_info = f" date={args.date}" if args.date else ""
print(f"[dashboard] serving at http://0.0.0.0:{args.port}")
print(f"[dashboard] shm_dir={args.shm_dir}{date_info} poll={args.poll_ms}ms")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n[dashboard] stopped")
server.server_close()
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 ""))
app.run(host="0.0.0.0", port=args.port, debug=args.debug, threaded=True)
if __name__ == "__main__":
+57
View File
@@ -13,6 +13,59 @@ from chicken_counter.report import build_batch_report, persist_batch_reports
from chicken_counter.types import CameraBatchResult
def _store_to_db(report_path: Path, location: str, db_path: str) -> None:
if not location or not db_path:
return
import json
import sqlite3
try:
with open(report_path) as f:
report = json.load(f)
except Exception:
return
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("""CREATE TABLE IF NOT EXISTS batch_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL, location TEXT NOT NULL, camera_id TEXT NOT NULL,
total_entered INTEGER NOT NULL DEFAULT 0,
frames_processed INTEGER NOT NULL DEFAULT 0,
elapsed_seconds REAL NOT NULL DEFAULT 0.0,
stopped_reason TEXT NOT NULL DEFAULT '',
source_video TEXT NOT NULL DEFAULT '',
generated_at TEXT NOT NULL DEFAULT '',
UNIQUE(date, location, camera_id))""")
date = report["date"]
for camera_id, entry in report["cameras"].items():
if entry.get("skipped"):
continue
conn.execute("""INSERT INTO batch_runs
(date, location, camera_id, total_entered, frames_processed,
elapsed_seconds, stopped_reason, source_video, generated_at)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(date, location, camera_id) DO UPDATE SET
total_entered=excluded.total_entered,
frames_processed=excluded.frames_processed,
elapsed_seconds=excluded.elapsed_seconds,
stopped_reason=excluded.stopped_reason,
source_video=excluded.source_video,
generated_at=excluded.generated_at""",
(date, location, camera_id,
entry.get("total_entered", 0),
entry.get("frames_processed", 0),
entry.get("elapsed_seconds", 0),
entry.get("stopped_reason", ""),
entry.get("source_video", ""),
report.get("generated_at", "")))
conn.commit()
conn.close()
print(f"[db] stored {date} ({location}) → {db_path}")
def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose: bool = False, no_video: bool = False, show_progress: bool = False) -> Path:
run_date = date or date_type.today().isoformat()
day_dir = Path(settings.batch.root_dir) / run_date
@@ -43,6 +96,7 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose
)
)
persist_batch_reports(run_date, camera_results, output_dir)
_store_to_db(report_path, settings.batch.location, settings.batch.db_path)
continue
source_path = discovery.found[camera_id]
@@ -70,6 +124,7 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose
f"frames={pipeline_result.frames_processed} reason={pipeline_result.stopped_reason}"
)
persist_batch_reports(run_date, camera_results, output_dir)
_store_to_db(report_path, settings.batch.location, settings.batch.db_path)
if no_video:
persist_batch_reports(run_date, camera_results, output_dir)
@@ -78,6 +133,7 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose
f"[batch] complete for {run_date}: total_entered_sum={report.total_entered_sum} "
f"report={report_path}"
)
_store_to_db(report_path, settings.batch.location, settings.batch.db_path)
return report_path
print("[batch] all cameras complete; starting compression")
@@ -106,4 +162,5 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose
f"[batch] complete for {run_date}: total_entered_sum={report.total_entered_sum} "
f"report={report_path}"
)
_store_to_db(report_path, settings.batch.location, settings.batch.db_path)
return report_path
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Store batch run results into a SQLite database.
Reads the aggregate JSON report written by the batch runner and inserts
all camera-level + summary data. Safe to run multiple times — uses
(date, location, camera_id) as the unique key, so re-runs update
existing rows instead of duplicating.
Usage:
python3 store_results.py /path/to/output/counts_2026-06-10.json --location kandang-atas
python3 store_results.py /path/to/output/counts_2026-06-10.json --db /var/lib/chickens.db
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import sys
from pathlib import Path
CREATE_TABLE = """
CREATE TABLE IF NOT EXISTS batch_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
location TEXT NOT NULL,
camera_id TEXT NOT NULL,
total_entered INTEGER NOT NULL DEFAULT 0,
frames_processed INTEGER NOT NULL DEFAULT 0,
elapsed_seconds REAL NOT NULL DEFAULT 0.0,
stopped_reason TEXT NOT NULL DEFAULT '',
source_video TEXT NOT NULL DEFAULT '',
generated_at TEXT NOT NULL DEFAULT '',
UNIQUE(date, location, camera_id)
)
"""
INSERT_SQL = """
INSERT INTO batch_runs
(date, location, camera_id, total_entered, frames_processed,
elapsed_seconds, stopped_reason, source_video, generated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(date, location, camera_id) DO UPDATE SET
total_entered = excluded.total_entered,
frames_processed = excluded.frames_processed,
elapsed_seconds = excluded.elapsed_seconds,
stopped_reason = excluded.stopped_reason,
source_video = excluded.source_video,
generated_at = excluded.generated_at
"""
SUMMARY_QUERY = """
SELECT
date,
location,
COUNT(*) AS camera_count,
SUM(total_entered) AS total_chickens,
SUM(elapsed_seconds) AS total_seconds,
ROUND(SUM(elapsed_seconds) / 60.0, 1) AS total_minutes
FROM batch_runs
WHERE date = ? AND location = ?
GROUP BY date, location
"""
def store_report(report_path: str, location: str, db_path: str) -> None:
with open(report_path) as f:
report = json.load(f)
date = report["date"]
cameras = report["cameras"]
generated_at = report.get("generated_at", "")
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(CREATE_TABLE)
rows = 0
for camera_id, entry in cameras.items():
if entry.get("skipped"):
continue
conn.execute(INSERT_SQL, (
date, location, camera_id,
entry.get("total_entered", 0),
entry.get("frames_processed", 0),
entry.get("elapsed_seconds", 0),
entry.get("stopped_reason", ""),
entry.get("source_video", ""),
generated_at,
))
rows += 1
conn.commit()
# print summary
row = conn.execute(SUMMARY_QUERY, (date, location)).fetchone()
if row:
print(f"\n[db] {row[0]} | {row[1]} | {row[2]} cameras | "
f"{row[3]} chickens | {row[4]:.0f}s ({row[5]} min)")
# also print per-camera breakdown
cur = conn.execute(
"SELECT camera_id, total_entered, elapsed_seconds "
"FROM batch_runs WHERE date=? AND location=? ORDER BY camera_id",
(date, location))
for cam_id, count, secs in cur:
print(f" {cam_id}: {count} chickens, {secs:.0f}s")
conn.close()
print(f"\n[db] wrote {rows} rows to {db_path}")
def main():
parser = argparse.ArgumentParser(
description="Store batch run results into SQLite")
parser.add_argument("report", help="Path to counts_YYYY-MM-DD.json")
parser.add_argument("--location", required=True, help="Location name (e.g. kandang-atas)")
parser.add_argument("--db", default="chicken_counts.db", help="SQLite database path")
args = parser.parse_args()
if not Path(args.report).exists():
print(f"error: report not found: {args.report}", file=sys.stderr)
sys.exit(1)
store_report(args.report, args.location, args.db)
if __name__ == "__main__":
main()
+181
View File
@@ -0,0 +1,181 @@
<!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>&#x1f414; Chicken Counter</h1>
<span style="color:#888"><span id="stat-date">{{ date }}</span> &nbsp;|&nbsp; <span id="clock"></span></span>
</div>
<div id="main">
<div id="frame-area">
<img id="frame-img" src="" alt="live stream">
<div class="overlay">
<div class="tag live" id="cam-tag">CC1 &#x2022; RUNNING</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>&#x1f4ca; 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, 30000);
updateClock();
}
function loadCameras() {
fetch("/api/cameras").then(r => r.json()).then(data => {
cameras = data.cameras || [];
renderCamList();
if (cameras.length && !activeCam) selectCam(cameras[0]);
detectActive();
});
}
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;
renderCamList();
refreshNow();
}
function poll() {
if (!activeCam) return;
var t = Date.now();
var img = document.getElementById("frame-img");
img.src = "/shm/" + activeCam + "/frame.jpg?t=" + t;
fetch("/shm/" + activeCam + "/stats.json?t=" + t).then(r => r.ok ? r.json() : null).then(s => {
if (!s) { setOffline(); return; }
lastUpdate = Date.now();
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;
});
}
function refreshNow() {
poll();
// also update the frame immediately
var t = Date.now();
document.getElementById("frame-img").src = "/shm/" + activeCam + "/frame.jpg?t=" + t;
}
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>&#x1f4ca; 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>