Today Commit

This commit is contained in:
proitlab committed 2026-07-23 20:42:14 +07:00
1 parent 6cddf2c9d3
commit 3b524d4a65
7 files changed
+220 -165

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ After=network.target
Type=simple Type=simple
User=dsutanto User=dsutanto
WorkingDirectory=/media/jetson/DATA/.Codes/chicken-counting-sukawarna-det 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 Restart=always
RestartSec=5 RestartSec=5
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
+1 -1
View File
@@ -6,7 +6,7 @@ batch:
delete_intermediate: false delete_intermediate: false
checkpoint_every_n_frames: 3000 checkpoint_every_n_frames: 3000
location: kandang-atas location: kandang-atas
db_path: chicken_counts.db db_path: db/chicken_counts.db
defaults: defaults:
detection: detection:
+192 -152
View File
@@ -1,37 +1,43 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Live dashboard for chicken-counter pipeline using Flask. """Live dashboard for chicken-counter pipeline."""
Serve from project root:
python3 dashboard.py [--port 8080] [--date 2026-06-10] [--db chicken_counts.db]
"""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json import json
import logging
import sqlite3 import sqlite3
import threading import threading
import time
from http.server import HTTPServer, SimpleHTTPRequestHandler
from pathlib import Path 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 class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
log = logging.getLogger("werkzeug") daemon_threads = True
log.setLevel(logging.ERROR)
DEFAULT_SHM_DIR = "/dev/shm" DEFAULT_SHM_DIR = "/dev/shm"
DEFAULT_PORT = 8080 DEFAULT_PORT = 8080
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
app = Flask(__name__)
app.config["shm_dir"] = DEFAULT_SHM_DIR
app.config["poll_ms"] = 1000
app.config["run_date"] = ""
app.config["db_path"] = ""
_db_conn = None _db_conn = None
_db_lock = threading.Lock() _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: def _init_db(db_path: str) -> None:
@@ -51,22 +57,8 @@ def _init_db(db_path: str) -> None:
conn.close() conn.close()
def _get_db(): def _discover_cameras(shm_dir):
global _db_conn shm = Path(shm_dir)
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"])
cameras = [] cameras = []
if shm.is_dir(): if shm.is_dir():
for entry in sorted(shm.iterdir()): for entry in sorted(shm.iterdir()):
@@ -75,152 +67,200 @@ def _discover_cameras():
return cameras return cameras
@app.route("/") class DashboardHandler(SimpleHTTPRequestHandler):
def index(): shm_dir = DEFAULT_SHM_DIR
return render_template( poll_ms = 1000
"index.html", run_date = ""
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 log_message(self, format, *args):
pass
@app.route("/api/cameras") def do_GET(self):
def api_cameras():
return jsonify({"cameras": _discover_cameras()})
@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()))
@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
try: try:
return send_file(frame_path, mimetype="image/jpeg", max_age=2, download_name=None) self._handle()
except (FileNotFoundError, PermissionError): except (BrokenPipeError, ConnectionResetError):
return jsonify({"error": "not ready"}), 503 pass
def _handle(self):
parsed = urlparse(self.path)
path = unquote(parsed.path)
def _get_db(): if path == "/":
db = app.config["db_path"] self._serve_html()
if not db or not Path(db).exists(): return
return None
conn = sqlite3.connect(db)
conn.row_factory = sqlite3.Row
return conn
if path.startswith("/stream/"):
self._handle_stream(path)
return
@app.route("/api/db/summary") if path == "/api/cameras":
def db_summary(): self._send_json({"cameras": _discover_cameras(self.shm_dir)})
return
if path.startswith("/api/db/"):
self._handle_db(path)
return
if path.startswith("/shm/"):
self._handle_shm(path)
return
self._send_error(404)
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()
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
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)
def _handle_db(self, path):
conn = _get_db() conn = _get_db()
if not conn: if not conn:
return jsonify({}) self._send_json({})
row = conn.execute(""" return
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))
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/history") if path == "/api/db/history":
def 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()
conn = _get_db() self._send_json([dict(r) for r in rows])
if not conn: return
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])
# /api/db/date/<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
@app.route("/api/db/date/<date>") # /api/db/camera/<id>
def db_date(date): prefix = "/api/db/camera/"
conn = _get_db() if path.startswith(prefix):
if not conn: cam_id = path[len(prefix):]
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", (cam_id,)).fetchall()
cameras = conn.execute(""" self._send_json([dict(r) for r in rows])
SELECT camera_id, total_entered, frames_processed, return
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]})
# /api/db/location/<name>
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
@app.route("/api/db/camera/<camera_id>") self._send_json({})
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 _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)
@app.route("/api/db/location/<location>") def _send_json(self, obj):
def db_location(location): data = json.dumps(obj).encode("utf-8")
conn = _get_db() self.send_response(200)
if not conn: self.send_header("Content-Type", "application/json")
return jsonify({}) self.send_header("Content-Length", str(len(data)))
history = conn.execute(""" self.end_headers()
SELECT date, GROUP_CONCAT(camera_id,', ') AS cameras, self.wfile.write(data)
SUM(total_entered) AS total,
ROUND(SUM(elapsed_seconds)/60.0,1) AS minutes def _send_error(self, code):
FROM batch_runs WHERE location=? GROUP BY date ORDER BY date DESC LIMIT 50 self.send_response(code)
""", (location,)).fetchall() self.send_header("Content-Length", "0")
summary = conn.execute(""" self.end_headers()
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]})
def main(): def main():
global _db_path
parser = argparse.ArgumentParser(description="Chicken Counter live dashboard") parser = argparse.ArgumentParser(description="Chicken Counter live dashboard")
parser.add_argument("--port", type=int, default=DEFAULT_PORT) parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument("--shm-dir", default=DEFAULT_SHM_DIR) parser.add_argument("--shm-dir", default=DEFAULT_SHM_DIR)
parser.add_argument("--poll-ms", type=int, default=500) parser.add_argument("--poll-ms", type=int, default=1000)
parser.add_argument("--date", default="", help="Processing date") parser.add_argument("--date", default="")
parser.add_argument("--db", default="chicken_counts.db", help="SQLite database path") parser.add_argument("--db", default="db/chicken_counts.db")
args = parser.parse_args() args = parser.parse_args()
app.config["shm_dir"] = args.shm_dir DashboardHandler.shm_dir = args.shm_dir
app.config["poll_ms"] = args.poll_ms DashboardHandler.poll_ms = args.poll_ms
app.config["run_date"] = args.date DashboardHandler.run_date = args.date
app.config["db_path"] = str(Path(args.db).resolve()) if args.db else "" _db_path = str(Path(args.db).resolve()) if args.db else ""
if app.config["db_path"]: if _db_path:
_init_db(app.config["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 "")) server = ThreadingHTTPServer(("0.0.0.0", args.port), DashboardHandler)
serve(app, host="0.0.0.0", port=args.port, threads=8) 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__": if __name__ == "__main__":
Binary file not shown.
Binary file not shown.
View File
Whitespace-only changes.
+26 -11
View File
@@ -69,6 +69,7 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h
var POLL_MS = {{ poll_ms }}; var POLL_MS = {{ poll_ms }};
var SHM = "{{ shm_dir }}"; var SHM = "{{ shm_dir }}";
var DB_PATH = "{{ db_path }}"; var DB_PATH = "{{ db_path }}";
var RUN_DATE = "{{ date }}";
var cameras = [], activeCam = null, lastUpdate = 0; var cameras = [], activeCam = null, lastUpdate = 0;
var perCam = {}, lastFramePerCam = {}; var perCam = {}, lastFramePerCam = {};
@@ -114,6 +115,7 @@ function selectCam(id) {
document.getElementById("waiting-msg").style.display = "none"; document.getElementById("waiting-msg").style.display = "none";
document.getElementById("frame-overlay").style.display = "block"; document.getElementById("frame-overlay").style.display = "block";
document.getElementById("frame-img").style.display = "block"; document.getElementById("frame-img").style.display = "block";
document.getElementById("frame-img").src = "/stream/" + id;
renderCamList(); renderCamList();
refreshNow(); refreshNow();
} }
@@ -124,7 +126,6 @@ function poll() {
fetch("/shm/" + activeCam + "/stats.json?t=" + t).then(r => r.ok ? r.json() : null).then(s => { fetch("/shm/" + activeCam + "/stats.json?t=" + t).then(r => r.ok ? r.json() : null).then(s => {
if (!s) { setOffline(); return; } if (!s) { setOffline(); return; }
lastUpdate = Date.now(); lastUpdate = Date.now();
var oldFrame = lastFramePerCam[activeCam] || 0;
lastFramePerCam[activeCam] = s.frame_index; 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 }; 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-total").textContent = s.total_entered_count;
@@ -137,18 +138,11 @@ function poll() {
tag.textContent = activeCam + " \u2022 " + status; tag.textContent = activeCam + " \u2022 " + status;
tag.className = "tag " + (s.backward_active ? "off" : "live"); tag.className = "tag " + (s.backward_active ? "off" : "live");
document.getElementById("cam-count").textContent = s.total_entered_count; 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() { function refreshNow() {
poll(); poll();
// also update the frame immediately
var t = Date.now();
document.getElementById("frame-img").src = "/shm/" + activeCam + "/frame.jpg?t=" + t;
} }
function showWaiting(msg) { function showWaiting(msg) {
@@ -163,6 +157,8 @@ function setOffline() {
document.getElementById("cam-tag").className = "tag off"; document.getElementById("cam-tag").className = "tag off";
} }
function updateSidebar() {}
function renderCamList() { function renderCamList() {
var ul = document.getElementById("cam-list"); var ul = document.getElementById("cam-list");
ul.innerHTML = cameras.map(function(c) { ul.innerHTML = cameras.map(function(c) {
@@ -180,15 +176,34 @@ function updateClock() {
function loadHistory() { function loadHistory() {
if (!DB_PATH) return; if (!DB_PATH) return;
fetch("/api/db/history").then(r => r.json()).then(rows => {
var panel = document.getElementById("db-panel"); var panel = document.getElementById("db-panel");
if (!rows.length) { panel.style.display = "none"; return; }
panel.style.display = "block"; panel.style.display = "block";
panel.innerHTML = "<h3>&#x1f4ca; History</h3>" + rows.map(function(r) {
// per-day totals
fetch("/api/db/history").then(r => r.json()).then(rows => {
var html = "<h3>&#x1f4ca; History</h3>";
if (rows.length) {
html += rows.map(function(r) {
return '<div class="db-row"><span>' + r.date + ' ' + r.location + '</span>' + return '<div class="db-row"><span>' + r.date + ' ' + r.location + '</span>' +
'<span class="db-total">' + r.total.toLocaleString() + '</span></div>'; '<span class="db-total">' + r.total.toLocaleString() + '</span></div>';
}).join(""); }).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 += '<h3 style="margin-top:12px">&#x1f4f9; ' + RUN_DATE + ' (' + (data.total.total || 0).toLocaleString() + ')</h3>';
data.cameras.forEach(function(c) {
html += '<div class="db-row"><span>' + c.camera_id + '</span>' +
'<span class="db-total">' + c.total_entered.toLocaleString() + '</span></div>';
});
panel.innerHTML = html;
});
}
} }
setTimeout(function() { if (activeCam) refreshNow(); }, 500); setTimeout(function() { if (activeCam) refreshNow(); }, 500);