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
+223 -168

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:
+191 -151
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(): try:
return jsonify({"cameras": _discover_cameras()}) self._handle()
except (BrokenPipeError, ConnectionResetError):
pass
def _handle(self):
parsed = urlparse(self.path)
path = unquote(parsed.path)
@app.route("/shm/<camera_id>/stats.json") if path == "/":
def shm_stats(camera_id): self._serve_html()
stats_path = Path(app.config["shm_dir"]) / f"chicken_counter_{camera_id}" / "stats.json" return
if not stats_path.exists():
return jsonify({"error": "not found"}), 404
return jsonify(json.loads(stats_path.read_text()))
if path.startswith("/stream/"):
self._handle_stream(path)
return
@app.route("/shm/<camera_id>/frame.jpg") if path == "/api/cameras":
def shm_frame(camera_id): self._send_json({"cameras": _discover_cameras(self.shm_dir)})
frame_path = Path(app.config["shm_dir"]) / f"chicken_counter_{camera_id}" / "frame.jpg" return
if not frame_path.exists():
return jsonify({"error": "not found"}), 404
try:
return send_file(frame_path, mimetype="image/jpeg", max_age=2, download_name=None)
except (FileNotFoundError, PermissionError):
return jsonify({"error": "not ready"}), 503
if path.startswith("/api/db/"):
self._handle_db(path)
return
def _get_db(): if path.startswith("/shm/"):
db = app.config["db_path"] self._handle_shm(path)
if not db or not Path(db).exists(): return
return None
conn = sqlite3.connect(db)
conn.row_factory = sqlite3.Row
return conn
self._send_error(404)
@app.route("/api/db/summary") def _handle_stream(self, path):
def db_summary(): cam_id = path[len("/stream/"):]
conn = _get_db() frame_path = Path(self.shm_dir) / f"chicken_counter_{cam_id}" / "frame.jpg"
if not conn: if not frame_path.exists():
return jsonify({}) self._send_error(404)
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))
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()
@app.route("/api/db/history") last_mtime = 0
def db_history(): try:
conn = _get_db() while True:
if not conn: try:
return jsonify([]) mtime = frame_path.stat().st_mtime
rows = conn.execute(""" if mtime != last_mtime:
SELECT date, location, last_mtime = mtime
COUNT(*) AS cams, data = frame_path.read_bytes()
SUM(total_entered) AS total, self.wfile.write(
ROUND(SUM(elapsed_seconds)/60.0, 1) AS minutes b"--frame\r\n"
FROM batch_runs b"Content-Type: image/jpeg\r\n"
GROUP BY date, location b"Content-Length: " + str(len(data)).encode() + b"\r\n\r\n" +
ORDER BY date DESC, location data + b"\r\n"
LIMIT 50 )
""").fetchall() self.wfile.flush()
return jsonify([dict(r) for r in rows]) 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
@app.route("/api/db/date/<date>") cam_id = parts[0]
def db_date(date): file = parts[1]
conn = _get_db() fpath = Path(self.shm_dir) / f"chicken_counter_{cam_id}" / file
if not conn:
return jsonify({})
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()
return jsonify({"date": date, "total": dict(total), "cameras": [dict(r) for r in cameras]})
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)
@app.route("/api/db/camera/<camera_id>") def _handle_db(self, path):
def db_camera(camera_id): conn = _get_db()
conn = _get_db() if not conn:
if not conn: self._send_json({})
return jsonify([]) return
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])
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/location/<location>") if path == "/api/db/history":
def db_location(location): 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({})
history = conn.execute(""" # /api/db/date/<date>
SELECT date, GROUP_CONCAT(camera_id,', ') AS cameras, prefix = "/api/db/date/"
SUM(total_entered) AS total, if path.startswith(prefix):
ROUND(SUM(elapsed_seconds)/60.0,1) AS minutes date = path[len(prefix):]
FROM batch_runs WHERE location=? GROUP BY date ORDER BY date DESC LIMIT 50 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()
""", (location,)).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()
summary = conn.execute(""" self._send_json({"date": date, "total": dict(total), "cameras": [dict(r) for r in cameras]})
SELECT COUNT(DISTINCT date) AS days, SUM(total_entered) AS total, return
ROUND(SUM(elapsed_seconds)/3600.0,1) AS hours
FROM batch_runs WHERE location=? # /api/db/camera/<id>
""", (location,)).fetchone() prefix = "/api/db/camera/"
return jsonify({"location": location, "summary": dict(summary), "history": [dict(r) for r in history]}) if path.startswith(prefix):
cam_id = path[len(prefix):]
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()
self._send_json([dict(r) for r in rows])
return
# /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
self._send_json({})
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)
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 _send_error(self, code):
self.send_response(code)
self.send_header("Content-Length", "0")
self.end_headers()
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.
+30 -15
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;
var panel = document.getElementById("db-panel");
panel.style.display = "block";
// per-day totals
fetch("/api/db/history").then(r => r.json()).then(rows => { fetch("/api/db/history").then(r => r.json()).then(rows => {
var panel = document.getElementById("db-panel"); var html = "<h3>&#x1f4ca; History</h3>";
if (!rows.length) { panel.style.display = "none"; return; } if (rows.length) {
panel.style.display = "block"; html += rows.map(function(r) {
panel.innerHTML = "<h3>&#x1f4ca; History</h3>" + 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);