Python optimize [Multi-coop multi-floor discovery, Auto .engine compilation, API, mortality webapp trigger, parallel processing/tensor batching/hybrid, etc.] #3
No files matched your search
+77
-278
@@ -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>🐔 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>
|
||||
<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 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">▶</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__":
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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>🐔 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">
|
||||
<img id="frame-img" src="" alt="live stream">
|
||||
<div class="overlay">
|
||||
<div class="tag live" id="cam-tag">CC1 • 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>📊 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>📊 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>
|
||||
Reference in new issue
Block a user