From 3b524d4a657a56dad35c33ee79620d0b2e41ba4e Mon Sep 17 00:00:00 2001 From: dsutanto Date: Thu, 23 Jul 2026 20:42:14 +0700 Subject: [PATCH] Today Commit --- chicken-dashboard.service | 2 +- configs/cycle7_batch_optimized.yaml | 2 +- dashboard.py | 342 ++++++++++++++++------------ db/chicken_counts.db | Bin 16384 -> 24576 bytes db/chicken_counts.db-shm | Bin 0 -> 32768 bytes db/chicken_counts.db-wal | 0 templates/index.html | 45 ++-- 7 files changed, 223 insertions(+), 168 deletions(-) create mode 100644 db/chicken_counts.db-shm create mode 100644 db/chicken_counts.db-wal diff --git a/chicken-dashboard.service b/chicken-dashboard.service index bf40484..eec1cdd 100644 --- a/chicken-dashboard.service +++ b/chicken-dashboard.service @@ -6,7 +6,7 @@ After=network.target Type=simple User=dsutanto 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 RestartSec=5 Environment=PYTHONUNBUFFERED=1 diff --git a/configs/cycle7_batch_optimized.yaml b/configs/cycle7_batch_optimized.yaml index 71a7b7d..6469ea2 100644 --- a/configs/cycle7_batch_optimized.yaml +++ b/configs/cycle7_batch_optimized.yaml @@ -6,7 +6,7 @@ batch: delete_intermediate: false checkpoint_every_n_frames: 3000 location: kandang-atas - db_path: chicken_counts.db + db_path: db/chicken_counts.db defaults: detection: diff --git a/dashboard.py b/dashboard.py index a131c9a..0ee1fa9 100755 --- a/dashboard.py +++ b/dashboard.py @@ -1,37 +1,43 @@ #!/usr/bin/env python3 -"""Live dashboard for chicken-counter pipeline using Flask. - -Serve from project root: - python3 dashboard.py [--port 8080] [--date 2026-06-10] [--db chicken_counts.db] -""" +"""Live dashboard for chicken-counter pipeline.""" from __future__ import annotations import argparse import json -import logging import sqlite3 import threading +import time +from http.server import HTTPServer, SimpleHTTPRequestHandler 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 -log = logging.getLogger("werkzeug") -log.setLevel(logging.ERROR) +class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): + daemon_threads = True + DEFAULT_SHM_DIR = "/dev/shm" DEFAULT_PORT = 8080 - -app = Flask(__name__) -app.config["shm_dir"] = DEFAULT_SHM_DIR -app.config["poll_ms"] = 1000 -app.config["run_date"] = "" -app.config["db_path"] = "" +TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" _db_conn = None _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: @@ -51,22 +57,8 @@ def _init_db(db_path: str) -> None: conn.close() -def _get_db(): - global _db_conn - 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"]) +def _discover_cameras(shm_dir): + shm = Path(shm_dir) cameras = [] if shm.is_dir(): for entry in sorted(shm.iterdir()): @@ -75,152 +67,200 @@ def _discover_cameras(): return cameras -@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"], - ) +class DashboardHandler(SimpleHTTPRequestHandler): + shm_dir = DEFAULT_SHM_DIR + poll_ms = 1000 + run_date = "" + def log_message(self, format, *args): + pass -@app.route("/api/cameras") -def api_cameras(): - return jsonify({"cameras": _discover_cameras()}) + def do_GET(self): + try: + self._handle() + except (BrokenPipeError, ConnectionResetError): + pass + def _handle(self): + parsed = urlparse(self.path) + path = unquote(parsed.path) -@app.route("/shm//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())) + if path == "/": + self._serve_html() + return + if path.startswith("/stream/"): + self._handle_stream(path) + return -@app.route("/shm//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: - 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 == "/api/cameras": + self._send_json({"cameras": _discover_cameras(self.shm_dir)}) + return + if path.startswith("/api/db/"): + self._handle_db(path) + return -def _get_db(): - db = app.config["db_path"] - if not db or not Path(db).exists(): - return None - conn = sqlite3.connect(db) - conn.row_factory = sqlite3.Row - return conn + if path.startswith("/shm/"): + self._handle_shm(path) + return + self._send_error(404) -@app.route("/api/db/summary") -def db_summary(): - conn = _get_db() - if not conn: - return jsonify({}) - 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() - return jsonify(dict(row)) + 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() -@app.route("/api/db/history") -def db_history(): - conn = _get_db() - if not conn: - 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]) + 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 -@app.route("/api/db/date/") -def db_date(date): - conn = _get_db() - 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]}) + 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) -@app.route("/api/db/camera/") -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 _handle_db(self, path): + conn = _get_db() + if not conn: + self._send_json({}) + return + 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/") -def db_location(location): - conn = _get_db() - if not conn: - return jsonify({}) - 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 - """, (location,)).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=? - """, (location,)).fetchone() - return jsonify({"location": location, "summary": dict(summary), "history": [dict(r) for r in history]}) + if path == "/api/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() + self._send_json([dict(r) for r in rows]) + return + + # /api/db/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 + + # /api/db/camera/ + prefix = "/api/db/camera/" + 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/ + 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(): + global _db_path + parser = argparse.ArgumentParser(description="Chicken Counter live dashboard") 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("--poll-ms", type=int, default=1000) + parser.add_argument("--date", default="") + parser.add_argument("--db", default="db/chicken_counts.db") args = parser.parse_args() - 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 "" + DashboardHandler.shm_dir = args.shm_dir + DashboardHandler.poll_ms = args.poll_ms + DashboardHandler.run_date = args.date + _db_path = str(Path(args.db).resolve()) if args.db else "" - if app.config["db_path"]: - _init_db(app.config["db_path"]) + if _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 "")) - serve(app, host="0.0.0.0", port=args.port, threads=8) + server = ThreadingHTTPServer(("0.0.0.0", args.port), DashboardHandler) + 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__": diff --git a/db/chicken_counts.db b/db/chicken_counts.db index 4a9218919c9192cef00f900d9dcb7340982f5dfb..db4a3e7a95d0423aba716e06b2629d8ec8559c35 100644 GIT binary patch literal 24576 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 delta 68 ycmZoTz}V2hI6<0~iGhKEWuk&TBh$u&1^jHhKrVy8W r.json()).then(rows => { - var panel = document.getElementById("db-panel"); - if (!rows.length) { panel.style.display = "none"; return; } - panel.style.display = "block"; - panel.innerHTML = "

📊 History

" + rows.map(function(r) { - return '
' + r.date + ' ' + r.location + '' + - '' + r.total.toLocaleString() + '
'; - }).join(""); + var html = "

📊 History

"; + if (rows.length) { + html += rows.map(function(r) { + return '
' + r.date + ' ' + r.location + '' + + '' + r.total.toLocaleString() + '
'; + }).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 += '

📹 ' + RUN_DATE + ' (' + (data.total.total || 0).toLocaleString() + ')

'; + data.cameras.forEach(function(c) { + html += '
' + c.camera_id + '' + + '' + c.total_entered.toLocaleString() + '
'; + }); + panel.innerHTML = html; + }); + } } setTimeout(function() { if (activeCam) refreshNow(); }, 500);