diff --git a/dashboard.py b/dashboard.py index 2829b26..d849f71 100755 --- a/dashboard.py +++ b/dashboard.py @@ -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""" - - - - -Chicken Counter — Live Dashboard - - - -
- -
-
-waiting for pipeline... -%%DATE%%   - ago   - -
-
-live stream -
-
-
-
- - -""" +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//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//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__": diff --git a/src/chicken_counter/batch_runner.py b/src/chicken_counter/batch_runner.py index cb4ef1a..b1fdd1d 100755 --- a/src/chicken_counter/batch_runner.py +++ b/src/chicken_counter/batch_runner.py @@ -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 diff --git a/store_results.py b/store_results.py new file mode 100644 index 0000000..a9f2f63 --- /dev/null +++ b/store_results.py @@ -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() diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..0245579 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,181 @@ + + + + + +Chicken Counter — Live + + + +
+

🐔 Chicken Counter

+{{ date }}  |  +
+
+
+live stream +
+
CC1 • RUNNING
+
--
+
+
+ +
+ + +