8 Commits
Author SHA1 Message Date
proitlab ae86821165 Add DB API endpoints: summary, history, date, camera, location
- api/db/summary — overall totals
- api/db/history — per-date rows
- api/db/date/<date> — single date detail with per-camera breakdown
- api/db/camera/<id> — all runs for a specific camera
- api/db/location/<name> — per-location summary + history
- API.md — full endpoint documentation with schema
2026-07-22 14:29:53 +07:00
proitlab efd4726bc6 DB: write incrementally after each camera, Flask dashboard with single-camera view
- batch_runner: _store_to_db after each camera (not just at end)
- dashboard: Flask app with templates/index.html
- Single-camera full-screen live stream with auto-switch
- DB history panel in sidebar
- store_results.py: standalone script for existing reports
2026-07-22 14:25:35 +07:00
proitlab 6dbaa517dc Dashboard: auto-focus running camera, show date + all-camera totals
- Auto-detect active camera by polling frame_index
- Show All Cameras Total accumulation in sidebar
- Show per-camera totals in camera list
- Add run_date to stats.json and display in dashboard
- Pipeline: thread run_date through build/run/batch
2026-07-22 13:36:20 +07:00
proitlab a54d0e6b2c Optimize counting.py: fast-reject, skip empty tracks, throttle purge
- Return early when tracks list is empty
- Fast-reject centroid by bounding rect before pointPolygonTest
- Purge stale tracks every 30 frames instead of every frame
- Skip deque append when trail_length == 0
2026-07-22 13:18:05 +07:00
proitlab d31ad05f0a Remove shared tracker between cameras, fix task warning, add export script
- Remove shared DetectionTracker across cameras to prevent state leakage
- Add task="detect" to YOLO constructor to suppress warning
- Add export_engine.py script for .pt to .engine conversion
- Regenerate ONNX and TensorRT engine with latest settings
2026-07-22 13:04:30 +07:00
proitlab af4e514357 Fix WARNING and CC2 not writing json to ouput folder 2026-07-22 12:34:32 +07:00
dsutanto 1a3242f611 merge upstream 2026-07-21 16:34:12 +07:00
proitlab 6af7dc1de0 Update with requirements.txt 2026-07-21 16:33:16 +07:00
15 changed files with 1238 additions and 247 deletions

No files matched your search

+149
View File
@@ -0,0 +1,149 @@
# Chicken Counter API
Base URL: `http://<jetson-ip>:8080`
## Live Dashboard
### `GET /`
Returns the dashboard HTML page.
### `GET /api/cameras`
List cameras currently writing to `/dev/shm`.
```json
{"cameras": ["CC1", "CC2", "CC3"]}
```
### `GET /shm/<camera_id>/stats.json`
Live stats for the active pipeline.
```json
{
"frame_index": 5120,
"inside_box_count": 42,
"total_entered_count": 1858,
"track_count": 99,
"backward_active": false,
"smoothed_speed": 4.3,
"count_events": 0,
"run_date": "2026-06-10"
}
```
### `GET /shm/<camera_id>/frame.jpg`
Live JPEG frame from the active pipeline.
---
## Database
All endpoints require the dashboard to be started with `--db <path>`. If no DB exists, endpoints return `[]` or `{}`.
### `GET /api/db/summary`
Overall totals across all dates and locations.
```json
{
"days": 12,
"locations": 2,
"total_runs": 48,
"total_chickens": 125000,
"total_hours": 8.5
}
```
### `GET /api/db/history`
Per-date summary, newest first (max 50 rows).
```json
[
{
"date": "2026-06-10",
"location": "kandang-atas",
"cams": 4,
"total": 5570,
"minutes": 40.2
}
]
```
### `GET /api/db/date/<date>`
Detail for a specific date. Format: `YYYY-MM-DD`.
```json
{
"date": "2026-06-10",
"total": {"total": 5570, "minutes": 40.2},
"cameras": [
{
"camera_id": "CC1",
"total_entered": 1500,
"frames_processed": 24800,
"elapsed_seconds": 600.5,
"stopped_reason": "backward",
"source_video": "kandang_1_camera_1_2026-06-10_120056.mp4",
"location": "kandang-atas"
}
]
}
```
### `GET /api/db/camera/<camera_id>`
History for a specific camera across all dates (max 50 rows).
```json
[
{
"date": "2026-06-10",
"location": "kandang-atas",
"total_entered": 1500,
"frames_processed": 24800,
"elapsed_seconds": 600.5,
"stopped_reason": "backward"
}
]
```
### `GET /api/db/location/<location>`
Summary and history for a specific location.
```json
{
"location": "kandang-atas",
"summary": {"days": 5, "total": 25000, "hours": 3.2},
"history": [
{
"date": "2026-06-10",
"cameras": "CC1, CC2, CC3, CC4",
"total": 5570,
"minutes": 40.2
}
]
}
```
---
## Database Schema
```sql
CREATE TABLE 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)
);
```
Data is inserted automatically by the batch runner when `location` and `db_path` are configured in the batch YAML, or manually via:
```bash
python3 store_results.py output/counts_2026-06-10.json --location kandang-atas --db chicken_counts.db
```
+94
View File
@@ -0,0 +1,94 @@
batch:
root_dir: /media/jetson/DATA/.Codes/VIDEOS/cycle7/kandang-atas
camera_glob: "kandang_*_camera_{num}_*.mp4"
output_subdir: output
compress_max_mb: 200
delete_intermediate: false
checkpoint_every_n_frames: 3000
defaults:
detection:
model_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine
classes: [0]
ignored_classes: [1, 2]
conf: 0.35
iou: 0.55
imgsz: 640
device: "0"
min_box_area_px: 3000
validate_while_inside: true
detection_zone:
enabled: true
buffer_above_px: 250
buffer_below_px: 250
show_in_overlay: true
tracker:
tracker_config_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml
persist: true
track_buffer: 75
gate:
mode: two_line
lines_y: [420, 730]
direction: bottom_to_up
motion:
enabled: true
axis: vertical
forward_sign: 1.0
ema_alpha: 0.2
reverse_enter_threshold: -1.5
reverse_exit_threshold: -0.5
debounce_frames: 12
min_features: 60
max_corners: 80
stride_frames: 2
flow_scale: 0.5
quality_level: 0.01
min_distance: 8
block_radius: 6
overlay:
show_boxes: true
show_track_trails: false
trail_length: 20
show_center_marker: true
show_track_ring: false
count_anchor: [780, 120]
inside_box_only: true
pending_blink: true
pending_colors:
- [255, 255, 0]
- [0, 255, 255]
display:
show_window: false
encoder: auto
output_bitrate_kbps: 4000
codec_preference: [avc1, mp4v, H264]
performance:
half: false
overlay_buffer_reuse: true
inference_stride: 2
stream:
enabled: true
shm_dir: /dev/shm
interval_frames: 5
feedback:
enabled: true
every_n_frames: 3000
save_images: true
log_to_terminal: true
roi:
inset_left_px: 60
inset_right_px: 60
inset_top_px: 0
inset_bottom_px: 0
min_overlap_ratio: 0.30
cameras:
CC2:
camera_num: 2
count_anchor: [900, 120]
roi:
points:
- [20, 380]
- [1880, 380]
- [1880, 720]
- [20, 720]
+121
View File
@@ -0,0 +1,121 @@
batch:
root_dir: /media/jetson/DATA/.Codes/VIDEOS/cycle7/kandang-atas
camera_glob: "kandang_*_camera_{num}_*.mp4"
output_subdir: output
compress_max_mb: 200
delete_intermediate: false
checkpoint_every_n_frames: 3000
defaults:
detection:
model_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine
classes: [0]
ignored_classes: [1, 2]
conf: 0.35
iou: 0.55
imgsz: 640
device: "0"
min_box_area_px: 3000
validate_while_inside: true
detection_zone:
enabled: true
buffer_above_px: 250
buffer_below_px: 250
show_in_overlay: true
tracker:
tracker_config_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml
persist: true
track_buffer: 75
gate:
mode: two_line
lines_y: [420, 730]
direction: bottom_to_up
motion:
enabled: true
axis: vertical
forward_sign: 1.0
ema_alpha: 0.2
reverse_enter_threshold: -1.5
reverse_exit_threshold: -0.5
debounce_frames: 12
min_features: 60
max_corners: 80
stride_frames: 2
flow_scale: 0.5
quality_level: 0.01
min_distance: 8
block_radius: 6
overlay:
show_boxes: true
show_track_trails: false
trail_length: 20
show_center_marker: true
show_track_ring: false
count_anchor: [780, 120]
inside_box_only: true
pending_blink: true
pending_colors:
- [255, 255, 0]
- [0, 255, 255]
display:
show_window: false
encoder: auto
output_bitrate_kbps: 4000
codec_preference: [avc1, mp4v, H264]
performance:
half: false
overlay_buffer_reuse: true
inference_stride: 2
stream:
enabled: true
shm_dir: /dev/shm
interval_frames: 5
feedback:
enabled: true
every_n_frames: 3000
save_images: true
log_to_terminal: true
roi:
inset_left_px: 60
inset_right_px: 60
inset_top_px: 0
inset_bottom_px: 0
min_overlap_ratio: 0.30
cameras:
CC1:
camera_num: 1
count_anchor: [780, 120]
roi:
points:
- [250, 330]
- [1650, 330]
- [1650, 720]
- [250, 720]
CC2:
camera_num: 2
count_anchor: [900, 120]
roi:
points:
- [20, 380]
- [1880, 380]
- [1880, 720]
- [20, 720]
CC3:
camera_num: 3
count_anchor: [900, 120]
roi:
points:
- [20, 330]
- [1880, 330]
- [1880, 720]
- [20, 720]
CC4:
camera_num: 4
count_anchor: [700, 120]
roi:
points:
- [50, 330]
- [1450, 330]
- [1450, 720]
- [50, 720]
+121
View File
@@ -0,0 +1,121 @@
batch:
root_dir: /media/jetson/DATA/.Codes/VIDEOS/cycle7/kandang-atas
camera_glob: "kandang_*_camera_{num}_*.mp4"
output_subdir: output
compress_max_mb: 200
delete_intermediate: false
checkpoint_every_n_frames: 3000
defaults:
detection:
model_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine
classes: [0]
ignored_classes: [1, 2]
conf: 0.35
iou: 0.55
imgsz: 640
device: "0"
min_box_area_px: 3000
validate_while_inside: true
detection_zone:
enabled: true
buffer_above_px: 250
buffer_below_px: 250
show_in_overlay: true
tracker:
tracker_config_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml
persist: true
track_buffer: 75
gate:
mode: two_line
lines_y: [420, 730]
direction: bottom_to_up
motion:
enabled: true
axis: vertical
forward_sign: 1.0
ema_alpha: 0.2
reverse_enter_threshold: -1.5
reverse_exit_threshold: -0.5
debounce_frames: 12
min_features: 60
max_corners: 80
stride_frames: 2
flow_scale: 0.5
quality_level: 0.01
min_distance: 8
block_radius: 6
overlay:
show_boxes: true
show_track_trails: false
trail_length: 20
show_center_marker: true
show_track_ring: false
count_anchor: [780, 120]
inside_box_only: true
pending_blink: true
pending_colors:
- [255, 255, 0]
- [0, 255, 255]
display:
show_window: false
encoder: auto
output_bitrate_kbps: 4000
codec_preference: [avc1, mp4v, H264]
performance:
half: false
overlay_buffer_reuse: true
inference_stride: 2
stream:
enabled: true
shm_dir: /dev/shm
interval_frames: 5
feedback:
enabled: true
every_n_frames: 3000
save_images: true
log_to_terminal: true
roi:
inset_left_px: 60
inset_right_px: 60
inset_top_px: 0
inset_bottom_px: 0
min_overlap_ratio: 0.30
cameras:
CC1:
camera_num: 1
count_anchor: [780, 120]
roi:
points:
- [250, 330]
- [1650, 330]
- [1650, 720]
- [250, 720]
CC2:
camera_num: 2
count_anchor: [900, 120]
roi:
points:
- [20, 380]
- [1880, 380]
- [1880, 720]
- [20, 720]
CC3:
camera_num: 3
count_anchor: [900, 120]
roi:
points:
- [20, 330]
- [1880, 330]
- [1880, 720]
- [20, 720]
CC4:
camera_num: 4
count_anchor: [700, 120]
roi:
points:
- [50, 330]
- [1450, 330]
- [1450, 720]
- [50, 720]
+148 -223
View File
@@ -1,260 +1,185 @@
#!/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>&#x1f414; Chicken Counter</h1>
<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 class="refresh" id="refresh-counter"></span> ago &nbsp;
<button class="refresh-btn" onclick="load()">&#x21bb; 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 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 && !activeCam) selectCam(cameras[0]);
if (!cameras.length) {{ noFrame.textContent = "No cameras found in " + SHM; img.style.display = "none"; }}
}});
}}
function renderCamList() {{
var ul = document.getElementById("cam-list");
ul.innerHTML = cameras.map(function(c) {{
return '<li class="' + (c === activeCam ? "active" : "") + '" onclick="selectCam(\'' + c + '\')">' +
c + '<span class="cam-badge">&#x25b6;</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();
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;
}});
}}
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
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)
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)
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
@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()
conn.close()
return jsonify(dict(row))
@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()
conn.close()
return jsonify([dict(r) for r in rows])
@app.route("/api/db/date/<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()
conn.close()
return jsonify({"date": date, "total": dict(total), "cameras": [dict(r) for r in cameras]})
@app.route("/api/db/camera/<camera_id>")
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()
conn.close()
return jsonify([dict(r) for r in rows])
@app.route("/api/db/location/<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()
conn.close()
return jsonify({"location": location, "summary": dict(summary), "history": [dict(r) for r in history]})
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("--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
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)
print(f"[dashboard] serving at http://0.0.0.0:{args.port}")
print(f"[dashboard] shm_dir={args.shm_dir} 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__":
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Export a YOLO .pt model to TensorRT .engine.
Usage:
python3 export_engine.py chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt
python3 export_engine.py model.pt --imgsz 640 --half --workspace 4
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
def export_engine(
model_path: str | Path,
*,
imgsz: int = 640,
half: bool = True,
int8: bool = False,
batch: int = 1,
workspace: int = 4, # GB
simplify: bool = True,
opset: int = 17,
verbose: bool = True,
) -> str:
from ultralytics import YOLO
model = YOLO(model_path, task="detect")
output = model.export(
format="engine",
imgsz=imgsz,
half=half,
int8=int8,
batch=batch,
workspace=workspace,
simplify=simplify,
opset=opset,
verbose=verbose,
)
print(f"\nExported to: {output}")
return str(output)
def main():
parser = argparse.ArgumentParser(description="Export YOLO .pt → TensorRT .engine")
parser.add_argument("model", help="Path to .pt model file")
parser.add_argument("--imgsz", type=int, default=640, help="Input image size (default: 640)")
parser.add_argument("--half", action="store_true", default=True, help="FP16 precision (default: on)")
parser.add_argument("--no-half", dest="half", action="store_false", help="FP32 precision")
parser.add_argument("--int8", action="store_true", help="INT8 quantization (needs calibration)")
parser.add_argument("--batch", type=int, default=1, help="Batch size (default: 1)")
parser.add_argument("--workspace", type=int, default=4, help="GPU workspace in GB (default: 4)")
parser.add_argument("--opset", type=int, default=17, help="ONNX opset version (default: 17)")
parser.add_argument("--quiet", action="store_true", help="Suppress verbose output")
args = parser.parse_args()
if not Path(args.model).exists():
print(f"error: model file not found: {args.model}", file=sys.stderr)
sys.exit(1)
export_engine(
args.model,
imgsz=args.imgsz,
half=args.half,
int8=args.int8,
batch=args.batch,
workspace=args.workspace,
opset=args.opset,
verbose=not args.quiet,
)
if __name__ == "__main__":
main()
+124
View File
@@ -0,0 +1,124 @@
apturl==0.5.2
bcrypt==3.2.0
beniget==0.4.1
blinker==1.9.0
Brlapi==0.8.3
certifi==2020.6.20
chardet==4.0.0
click==8.4.2
colorama==0.4.4
contourpy==1.3.2
cpuset==1.6
cryptography==3.4.8
cupshelpers==1.0
cycler==0.12.1
dbus-python==1.2.18
decorator==4.4.2
defer==1.0.6
distro==1.7.0
distro-info==1.1+ubuntu0.2
dnspython==2.1.0
duplicity==0.8.21
et_xmlfile==2.0.0
fasteners==0.14.1
filelock==3.29.7
Flask==3.1.3
fonttools==4.63.0
fsspec==2026.6.0
future==0.18.2
gast==0.5.2
gpg==1.16.0
httplib2==0.20.2
idna==3.3
importlib-metadata==4.6.4
itsdangerous==2.2.0
jeepney==0.7.1
jetson-stats==4.3.2
Jetson.GPIO==2.1.7
Jinja2==3.1.6
keyring==23.5.0
kiwisolver==1.5.0
language-selector==0.1
lap==0.5.13
launchpadlib==1.10.16
lazr.restfulclient==0.14.4
lazr.uri==1.0.6
lockfile==0.12.2
louis==3.20.0
lxml==4.8.0
macaroonbakery==1.3.1
Mako==1.1.3
Markdown==3.3.6
MarkupSafe==3.0.3
matplotlib==3.10.9
ml_dtypes==0.5.4
monotonic==1.6
more-itertools==8.10.0
mpmath==1.3.0
networkx==3.4.2
numpy==1.26.4
nvidia-ml-py==13.610.43
oauthlib==3.2.0
olefile==0.46
onboard==1.4.1
onnx==1.22.0
opencv-python==4.11.0.86
openpyxl==3.1.5
packaging==26.2
paho-mqtt==2.1.0
paramiko==2.9.3
pexpect==4.8.0
Pillow==9.0.1
ply==3.11
polars==1.42.1
polars-runtime-32==1.42.1
protobuf==7.35.1
psutil==7.2.2
ptyprocess==0.7.0
pycairo==1.20.1
pycups==2.0.1
Pygments==2.11.2
PyGObject==3.42.1
PyJWT==2.3.0
pymacaroons==0.13.0
PyNaCl==1.5.0
PyOpenGL==3.1.5
pyparsing==3.3.2
pyRFC3339==1.1
pyservicemaker @ file:///opt/nvidia/deepstream/deepstream-7.1/service-maker/python/pyservicemaker-0.0.1-py3-none-linux_aarch64.whl
python-apt==2.4.0+ubuntu4.1
python-dateutil==2.8.1
python-dbusmock==0.27.5
python-debian==0.1.43+ubuntu1.1
python-dotenv==1.2.2
pythran==0.10.0
pytz==2022.1
pyxdg==0.27
PyYAML==6.0.3
ranger-fm==1.9.3
requests==2.25.1
requests-toolbelt==0.9.1
scipy==1.8.0
SecretStorage==3.3.1
six==1.16.0
smbus2==0.5.0
sympy==1.13.1
systemd-python==234
tensorrt==10.3.0
tensorrt_dispatch==10.3.0
tensorrt_lean==10.3.0
torch @ https://developer.download.nvidia.com/compute/redist/jp/v61/pytorch/torch-2.5.0a0+872d972e41.nv24.08.17622132-cp310-cp310-linux_aarch64.whl
torchvision @ file:///home/jetson/Downloads/.torch-2.5.0-nv24.08/torchvision-0.20.1a0%2B3ac97aa-cp310-cp310-linux_aarch64.whl#sha256=06658c32ef66451be301d52f2310c64689af299ea247dc262d5563f2a557ee3d
typing_extensions==4.16.0
ubuntu-drivers-common==0.0.0
ubuntu-pro-client==8001
ufw==0.36.1
ultralytics==8.4.90
ultralytics-thop==2.0.20
urllib3==1.26.5
urwid==2.1.2
wadllib==1.3.6
Werkzeug==3.1.8
xdg==5
xkit==0.0.0
zipp==1.0.0
+59 -16
View File
@@ -10,10 +10,62 @@ from chicken_counter.compress import compress_video_to_target
from chicken_counter.config import BatchSettings, build_camera_config_from_batch
from chicken_counter.pipeline import run_pipeline
from chicken_counter.report import build_batch_report, persist_batch_reports
from chicken_counter.tracking import DetectionTracker
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
@@ -29,20 +81,6 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose
discovery = discover_camera_videos(day_dir, settings)
camera_order = sorted(settings.cameras.items(), key=lambda item: item[1].camera_num)
first_camera_id = next(
camera_id for camera_id, _preset in camera_order if camera_id in discovery.found
)
first_source = discovery.found[first_camera_id]
init_output_path = output_dir / f"{first_camera_id}_vis.mp4" if not no_video else None
init_config = build_camera_config_from_batch(
settings,
first_camera_id,
source=first_source,
output_path=init_output_path,
checkpoint_dir=output_dir / "checkpoints" / first_camera_id,
)
shared_tracker = DetectionTracker(init_config)
camera_results: list[CameraBatchResult] = []
report_path = output_dir / f"counts_{run_date}.json"
@@ -58,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]
@@ -73,7 +112,7 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose
checkpoint_dir=checkpoint_dir,
)
camera_config.performance.verbose = verbose
pipeline_result = run_pipeline(camera_config, tracker=shared_tracker, show_progress=show_progress)
pipeline_result = run_pipeline(camera_config, show_progress=show_progress, run_date=run_date)
camera_results.append(
CameraBatchResult(
camera_id=camera_id,
@@ -85,13 +124,16 @@ 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)
report = build_batch_report(run_date, camera_results, output_dir=output_dir)
print(
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")
@@ -120,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
+22 -4
View File
@@ -51,12 +51,30 @@ class CountingZone:
counting_paused: bool = False,
) -> list[CountEvent]:
events: list[CountEvent] = []
if not tracks:
self.inside_box_count = 0
self.current_inside_ids.clear()
self.prev_inside_ids.clear()
self._purge_stale(frame_index, set())
return events
active_ids = set()
inside_ids = set()
rx1, ry1, rx2, ry2 = self._counting_rect
for track in tracks:
active_ids.add(track.track_id)
self.last_seen_frame[track.track_id] = frame_index
self.histories[track.track_id].append(track.centroid)
if self.trail_length > 0:
self.histories[track.track_id].append(track.centroid)
# fast-reject: bounding rect check before pointPolygonTest
cx, cy = track.centroid
if not (rx1 <= cx <= rx2 and ry1 <= cy <= ry2):
continue
if self._inside_roi(track.centroid):
inside_ids.add(track.track_id)
@@ -64,10 +82,9 @@ class CountingZone:
if counting_paused:
continue
if track.track_id not in inside_ids or track.track_id in self.counted_ids:
if track.track_id in self.counted_ids:
continue
should_validate = False
if self.validate_while_inside:
should_validate = self._meets_validation_thresholds(track)
else:
@@ -102,7 +119,8 @@ class CountingZone:
self.inside_box_count = len(inside_ids)
self.current_inside_ids = inside_ids
self.prev_inside_ids = inside_ids
self._purge_stale(frame_index, active_ids)
if frame_index % 30 == 0:
self._purge_stale(frame_index, active_ids)
return events
def trail_for(self, track_id: int) -> list[tuple[int, int]]:
+9 -3
View File
@@ -90,11 +90,14 @@ class PipelineArtifacts:
total_source_frames: int | None
owns_tracker: bool
detection_zone_rect: tuple[int, int, int, int] | None = None
run_date: str = ""
def build_pipeline(
config: CameraConfig,
tracker: DetectionTracker | None = None,
*,
run_date: str = "",
) -> PipelineArtifacts:
capture = open_capture(config.source)
owns_tracker = tracker is None
@@ -157,6 +160,7 @@ def build_pipeline(
total_source_frames=total_source_frames,
owns_tracker=owns_tracker,
detection_zone_rect=detection_zone_rect,
run_date=run_date,
)
@@ -165,12 +169,13 @@ def run_pipeline(
tracker: DetectionTracker | None = None,
*,
show_progress: bool = False,
run_date: str = "",
) -> PipelineResult:
if tracker is not None:
tracker.config = config
tracker.reset_tracking()
artifacts = build_pipeline(config, tracker=tracker)
artifacts = build_pipeline(config, tracker=tracker, run_date=run_date)
inference_stride = max(1, config.performance.inference_stride)
print(
f"[perf] inference_stride={inference_stride} "
@@ -252,7 +257,7 @@ def run_pipeline(
last_annotated = annotated
if config.stream.enabled and frame_index % max(1, config.stream.interval_frames) == 0:
_write_stream_frame(config.stream.shm_dir, config.camera_id, annotated, result)
_write_stream_frame(config.stream.shm_dir, config.camera_id, annotated, result, run_date=artifacts.run_date)
if verbose:
t_write = time.monotonic()
@@ -366,7 +371,7 @@ def _consume_result(
_emit_periodic_feedback(config, artifacts, annotated, result, progress)
def _write_stream_frame(shm_dir: str, camera_id: str, frame: np.ndarray, result: FrameResult) -> None:
def _write_stream_frame(shm_dir: str, camera_id: str, frame: np.ndarray, result: FrameResult, *, run_date: str = "") -> None:
cam_dir = Path(shm_dir) / f"chicken_counter_{camera_id}"
cam_dir.mkdir(parents=True, exist_ok=True)
@@ -383,6 +388,7 @@ def _write_stream_frame(shm_dir: str, camera_id: str, frame: np.ndarray, result:
"backward_active": result.motion_state.backward_active,
"smoothed_speed": round(result.motion_state.smoothed_speed, 1),
"count_events": len(result.count_events),
"run_date": run_date,
}
stats_path = cam_dir / "stats.json"
stats_tmp = cam_dir / ".stats_tmp.json"
+1 -1
View File
@@ -17,7 +17,7 @@ class DetectionTracker:
self.config = config
model_path = Path(config.detection.model_path)
self.model_kind = model_path.suffix.lower().lstrip(".") or "unknown"
self.model = YOLO(config.detection.model_path)
self.model = YOLO(config.detection.model_path, task="detect")
self.tracker_config_path = str(Path(config.tracker.tracker_config_path))
self.verbose = config.performance.verbose
self._infer_count = 0
+131
View File
@@ -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()
+181
View File
@@ -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>&#x1f414; Chicken Counter</h1>
<span style="color:#888"><span id="stat-date">{{ date }}</span> &nbsp;|&nbsp; <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 &#x2022; 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>&#x1f4ca; 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>&#x1f4ca; 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>