feat: add manual & auto batch counting modes, dual-port operator and monitoring dashboards, and historical batch data corrections
This commit is contained in:
commit
0235a0f597
53 files changed
+14127
No files matched your search
@@ -0,0 +1,19 @@
|
|||||||
|
# Core variables for Karung Counter
|
||||||
|
OUTPUT_DIR=/opt/jetson-counter
|
||||||
|
DB_PATH=/opt/jetson-counter/jetson_counter.db
|
||||||
|
STATE_FILE=/opt/jetson-counter/current_batch.json
|
||||||
|
BATCH_MODE_FILE=/opt/jetson-counter/batch_mode.json
|
||||||
|
LIVE_STREAM_FRAME_PATH=/dev/shm/jetson-counter/live_frame.jpg
|
||||||
|
CAMERA_NAME=CC1
|
||||||
|
OBJECT_LABEL=karung-pakan
|
||||||
|
DAILY_CUTOFF_TIME=06:00
|
||||||
|
|
||||||
|
# Dashboard variables
|
||||||
|
SECRET_KEY=change-me-in-production
|
||||||
|
DASHBOARD_HOST=0.0.0.0
|
||||||
|
DASHBOARD_PORT=5000
|
||||||
|
OFFICE_PORT=5721
|
||||||
|
FLASK_DEBUG=false
|
||||||
|
|
||||||
|
# Stream URL
|
||||||
|
RTSP_URL=rtsp://user:pass@192.168.192.209:8554/camera_stream_640
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
**/__pycache__/
|
||||||
|
src/__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Environment & local state
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.db.bak*
|
||||||
|
*.db.before*
|
||||||
|
current_batch.json
|
||||||
|
batch_mode.json
|
||||||
|
hasil_perhitungan.json
|
||||||
|
live_status.json
|
||||||
|
batch_*.json
|
||||||
|
*.tmp
|
||||||
|
*.tmp.*
|
||||||
|
|
||||||
|
# Media & large model binaries
|
||||||
|
*.mp4
|
||||||
|
*.avi
|
||||||
|
*.mkv
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
*.png
|
||||||
|
*.engine
|
||||||
|
*.onnx
|
||||||
|
*.pt
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
|
|
||||||
|
# IDE & OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
import cv2
|
||||||
|
import time
|
||||||
|
|
||||||
|
def main():
|
||||||
|
source = "rtsp://192.168.192.96:8554/cam"
|
||||||
|
print("Connecting to stream...")
|
||||||
|
cap = cv2.VideoCapture(source)
|
||||||
|
if not cap.isOpened():
|
||||||
|
print("Error: Could not open RTSP source.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Warming up reader...")
|
||||||
|
time.sleep(3.0)
|
||||||
|
|
||||||
|
# Read a few frames to clear the buffer
|
||||||
|
for _ in range(15):
|
||||||
|
ret, frame = cap.read()
|
||||||
|
|
||||||
|
if ret and frame is not None:
|
||||||
|
h, w, c = frame.shape
|
||||||
|
print(f"Captured frame with resolution: {w}x{h}")
|
||||||
|
cv2.imwrite("/home/jetson/karung/live_frame_native.png", frame)
|
||||||
|
print("Frame saved successfully on Jetson.")
|
||||||
|
else:
|
||||||
|
print("Error: Failed to read frame from stream.")
|
||||||
|
|
||||||
|
cap.release()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Custom FastTrack config tuned for sack counting:
|
||||||
|
# - track_buffer=60: hold lost tracks for 60 frames (~2.4s at 25fps)
|
||||||
|
# to survive worker occlusion
|
||||||
|
# - new_track_thresh=0.3: harder to spawn duplicate IDs
|
||||||
|
# - track_low_thresh=0.05: recover faint detections behind workers
|
||||||
|
# - active_occ_to_lost_thresh=15: tolerate 15 occluded frames
|
||||||
|
# - occ_reappear_window=60: re-find tracks after long occlusion
|
||||||
|
# - enlarge_bbox_occ=1.15: widen search region during occlusion
|
||||||
|
|
||||||
|
tracker_type: bytetrack
|
||||||
|
track_high_thresh: 0.20
|
||||||
|
track_low_thresh: 0.05
|
||||||
|
new_track_thresh: 0.30
|
||||||
|
track_buffer: 60
|
||||||
|
match_thresh: 0.85
|
||||||
|
fuse_score: true
|
||||||
|
|
||||||
|
# Occlusion handling (FastTrack-specific)
|
||||||
|
reset_velocity_offset_occ: 5
|
||||||
|
reset_pos_offset_occ: 3
|
||||||
|
enlarge_bbox_occ: 1.15
|
||||||
|
dampen_motion_occ: 0.4
|
||||||
|
active_occ_to_lost_thresh: 15
|
||||||
|
occ_cover_thresh: 0.6
|
||||||
|
occ_reappear_window: 60
|
||||||
|
init_iou_suppress: 0.65
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import paramiko
|
||||||
|
import base64
|
||||||
|
|
||||||
|
def run():
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect('192.168.192.96', username='jetson', password='jetson', timeout=10)
|
||||||
|
|
||||||
|
code = """
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
conn = sqlite3.connect('/opt/jetson-counter/jetson_counter.db')
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
for dt in ['2026-08-21', '2026-08-22']:
|
||||||
|
print(f'=== BATCHES FOR {dt} ===')
|
||||||
|
rows = cur.execute('SELECT * FROM batches WHERE counting_date = ? ORDER BY batch_number', (dt,)).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
print(f"Batch {d['batch_number']:02d}: count={d['count']}, start={d['start_time']}, end={d['end_time']}")
|
||||||
|
|
||||||
|
print(f'=== DAILY SUMMARY FOR {dt} ===')
|
||||||
|
rows = cur.execute('SELECT * FROM daily_summaries WHERE counting_date = ?', (dt,)).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
print(dict(r))
|
||||||
|
"""
|
||||||
|
b64 = base64.b64encode(code.encode()).decode()
|
||||||
|
stdin, stdout, stderr = client.exec_command(f"python3 -c \"import base64; exec(base64.b64decode('{b64}'))\"")
|
||||||
|
print(stdout.read().decode())
|
||||||
|
print("ERR:", stderr.read().decode())
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
@@ -0,0 +1,807 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Edge Jetson production counter dashboard.
|
||||||
|
Reads jetson_counter.db + current_batch.json from jetson-counter stack.
|
||||||
|
Default port 5000 (replaces frigate-counter dashboard role).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from io import BytesIO
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||||
|
|
||||||
|
from flask import Flask, render_template, jsonify, request, Response
|
||||||
|
from werkzeug.serving import WSGIRequestHandler
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
app = Flask(__name__, template_folder="templates")
|
||||||
|
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-me-in-production")
|
||||||
|
|
||||||
|
if os.name == "nt":
|
||||||
|
_DEFAULT_DIR = "d:/Belajar/menghitung karung"
|
||||||
|
DB_PATH = f"{_DEFAULT_DIR}/jetson_counter.db"
|
||||||
|
CURRENT_BATCH_PATH = f"{_DEFAULT_DIR}/current_batch.json"
|
||||||
|
BATCH_MODE_PATH = f"{_DEFAULT_DIR}/batch_mode.json"
|
||||||
|
LIVE_STREAM_FRAME_PATH = f"{_DEFAULT_DIR}/live_frame.jpg"
|
||||||
|
else:
|
||||||
|
_DEFAULT_DIR = "/opt/jetson-counter"
|
||||||
|
DB_PATH = os.getenv("DB_PATH", f"{_DEFAULT_DIR}/jetson_counter.db")
|
||||||
|
CURRENT_BATCH_PATH = os.getenv("STATE_FILE", os.getenv("CURRENT_BATCH_PATH", f"{_DEFAULT_DIR}/current_batch.json"))
|
||||||
|
BATCH_MODE_PATH = os.getenv("BATCH_MODE_FILE", f"{_DEFAULT_DIR}/batch_mode.json")
|
||||||
|
LIVE_STREAM_FRAME_PATH = os.getenv("LIVE_STREAM_FRAME_PATH", "/dev/shm/jetson-counter/live_frame.jpg")
|
||||||
|
|
||||||
|
CUTOFF_TIME = os.getenv("CUTOFF_TIME", os.getenv("DAILY_CUTOFF_TIME", "20:00"))
|
||||||
|
|
||||||
|
SITE_NAME = os.getenv("SITE_NAME", "LIVE")
|
||||||
|
|
||||||
|
DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000"))
|
||||||
|
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
||||||
|
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true"
|
||||||
|
|
||||||
|
@app.route("/api/live-video")
|
||||||
|
def api_live_video():
|
||||||
|
if not os.path.isfile(LIVE_STREAM_FRAME_PATH):
|
||||||
|
return jsonify({"success": False, "error": "Live stream frame not available yet"}), 503
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
consecutive_fails = 0
|
||||||
|
MAX_FAILS = 30
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
with open(LIVE_STREAM_FRAME_PATH, "rb") as f:
|
||||||
|
jpeg = f.read()
|
||||||
|
consecutive_fails = 0
|
||||||
|
yield (b"--frame\r\n"
|
||||||
|
b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n")
|
||||||
|
except FileNotFoundError:
|
||||||
|
consecutive_fails += 1
|
||||||
|
if consecutive_fails >= MAX_FAILS:
|
||||||
|
return
|
||||||
|
time.sleep(1.0)
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
consecutive_fails += 1
|
||||||
|
if consecutive_fails >= MAX_FAILS:
|
||||||
|
return
|
||||||
|
time.sleep(0.5)
|
||||||
|
continue
|
||||||
|
time.sleep(0.05)
|
||||||
|
return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS batches (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
counting_date TEXT NOT NULL,
|
||||||
|
batch_number INTEGER NOT NULL,
|
||||||
|
camera_name TEXT NOT NULL,
|
||||||
|
object_label TEXT NOT NULL,
|
||||||
|
count INTEGER NOT NULL,
|
||||||
|
start_time TEXT NOT NULL,
|
||||||
|
end_time TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(counting_date, batch_number, camera_name, object_label)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_summaries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
counting_date TEXT NOT NULL,
|
||||||
|
camera_name TEXT NOT NULL,
|
||||||
|
object_label TEXT NOT NULL,
|
||||||
|
total_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_batches INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(counting_date, camera_name, object_label)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
_ensure_db()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME):
|
||||||
|
if dt is None:
|
||||||
|
dt = datetime.now()
|
||||||
|
cutoff = datetime.strptime(cutoff_str, "%H:%M").time()
|
||||||
|
|
||||||
|
if cutoff.hour == 0 and cutoff.minute == 0:
|
||||||
|
return dt.date().isoformat()
|
||||||
|
|
||||||
|
if dt.time() < cutoff:
|
||||||
|
return (dt.date() - timedelta(days=1)).isoformat()
|
||||||
|
return dt.date().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
CAMERA_NAME = os.getenv("CAMERA_NAME", "CC1")
|
||||||
|
OBJECT_LABEL = os.getenv("OBJECT_LABEL", "karung-pakan")
|
||||||
|
OFFICE_PORT = int(os.getenv("OFFICE_PORT", "5721"))
|
||||||
|
|
||||||
|
def is_office_request():
|
||||||
|
"""Check if request comes from office port."""
|
||||||
|
server_port = request.environ.get("SERVER_PORT", str(DASHBOARD_PORT))
|
||||||
|
# Also check Host header if port is in Host (e.g. 192.168.192.96:5721)
|
||||||
|
host_header = request.headers.get("Host", "")
|
||||||
|
if f":{OFFICE_PORT}" in host_header or str(server_port) == str(OFFICE_PORT):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
@app.route("/monitoring")
|
||||||
|
def index():
|
||||||
|
if is_office_request():
|
||||||
|
return render_template("monitoring.html", site_name=SITE_NAME, show_nav=True)
|
||||||
|
# Port 5000 (Operator)
|
||||||
|
return render_template("operator.html", site_name=SITE_NAME, show_nav=False)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/operator")
|
||||||
|
def operator_page():
|
||||||
|
return render_template("operator.html", site_name=SITE_NAME, show_nav=False)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/history")
|
||||||
|
def history_page():
|
||||||
|
if not is_office_request():
|
||||||
|
return render_template("operator.html", site_name=SITE_NAME, show_nav=False)
|
||||||
|
return render_template("history.html", site_name=SITE_NAME, show_nav=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/analytics")
|
||||||
|
def analytics_page():
|
||||||
|
if not is_office_request():
|
||||||
|
return render_template("operator.html", site_name=SITE_NAME, show_nav=False)
|
||||||
|
return render_template("analytics.html", site_name=SITE_NAME, show_nav=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_batch_number(counting_date):
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(MAX(batch_number), 0)
|
||||||
|
FROM batches
|
||||||
|
WHERE counting_date = ? AND camera_name = ? AND object_label = ?
|
||||||
|
""",
|
||||||
|
(counting_date, CAMERA_NAME, OBJECT_LABEL),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
return row[0] + 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DB Error] Gagal mendapatkan batch_number: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/batch/start", methods=["POST"])
|
||||||
|
def api_batch_start():
|
||||||
|
try:
|
||||||
|
# Check if already active
|
||||||
|
if os.path.exists(CURRENT_BATCH_PATH):
|
||||||
|
try:
|
||||||
|
with open(CURRENT_BATCH_PATH, "r") as f:
|
||||||
|
curr = json.load(f)
|
||||||
|
if curr and curr.get("batch_number"):
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"message": "Batch already active",
|
||||||
|
"batch_number": curr.get("batch_number"),
|
||||||
|
"counting_date": curr.get("counting_date"),
|
||||||
|
"start_time": curr.get("start_time")
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
counting_date = get_counting_date()
|
||||||
|
batch_num = get_next_batch_number(counting_date)
|
||||||
|
now_iso = datetime.now().isoformat()
|
||||||
|
|
||||||
|
batch_state = {
|
||||||
|
"counting_date": counting_date,
|
||||||
|
"batch_number": batch_num,
|
||||||
|
"count": 0,
|
||||||
|
"start_time": now_iso,
|
||||||
|
"last_detection_time": now_iso,
|
||||||
|
"manual_control": True
|
||||||
|
}
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(CURRENT_BATCH_PATH), exist_ok=True)
|
||||||
|
tmp_file = f"{CURRENT_BATCH_PATH}.tmp"
|
||||||
|
with open(tmp_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(batch_state, f, indent=2)
|
||||||
|
os.replace(tmp_file, CURRENT_BATCH_PATH)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"message": f"Batch #{batch_num} started",
|
||||||
|
"batch_number": batch_num,
|
||||||
|
"counting_date": counting_date,
|
||||||
|
"start_time": now_iso
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/batch/stop", methods=["POST"])
|
||||||
|
def api_batch_stop():
|
||||||
|
try:
|
||||||
|
if not os.path.exists(CURRENT_BATCH_PATH):
|
||||||
|
return jsonify({"success": False, "error": "No active batch to stop"}), 400
|
||||||
|
|
||||||
|
with open(CURRENT_BATCH_PATH, "r") as f:
|
||||||
|
curr = json.load(f)
|
||||||
|
|
||||||
|
if not curr or not curr.get("batch_number"):
|
||||||
|
return jsonify({"success": False, "error": "No active batch data"}), 400
|
||||||
|
|
||||||
|
counting_date = curr["counting_date"]
|
||||||
|
batch_num = curr["batch_number"]
|
||||||
|
final_count = curr.get("count", 0)
|
||||||
|
start_time_iso = curr.get("start_time", datetime.now().isoformat())
|
||||||
|
end_time_iso = datetime.now().isoformat()
|
||||||
|
|
||||||
|
if final_count > 0:
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO batches
|
||||||
|
(counting_date, batch_number, camera_name, object_label, count, start_time, end_time)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL, final_count, start_time_iso, end_time_iso),
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT SUM(count), COUNT(id)
|
||||||
|
FROM batches
|
||||||
|
WHERE counting_date = ? AND camera_name = ? AND object_label = ?
|
||||||
|
""",
|
||||||
|
(counting_date, CAMERA_NAME, OBJECT_LABEL),
|
||||||
|
)
|
||||||
|
sum_row = cur.fetchone()
|
||||||
|
tot_count = sum_row[0] if sum_row[0] is not None else 0
|
||||||
|
tot_batches = sum_row[1] if sum_row[1] is not None else 0
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO daily_summaries
|
||||||
|
(counting_date, camera_name, object_label, total_count, total_batches, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
""",
|
||||||
|
(counting_date, CAMERA_NAME, OBJECT_LABEL, tot_count, tot_batches),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Remove active batch state
|
||||||
|
try:
|
||||||
|
os.remove(CURRENT_BATCH_PATH)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"message": f"Batch #{batch_num} stopped",
|
||||||
|
"batch_number": batch_num,
|
||||||
|
"final_count": final_count,
|
||||||
|
"start_time": start_time_iso,
|
||||||
|
"end_time": end_time_iso
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/batch/mode", methods=["GET", "POST"])
|
||||||
|
def api_batch_mode():
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
req_data = request.get_json(silent=True) or request.form
|
||||||
|
mode = req_data.get("mode", "manual").lower()
|
||||||
|
if mode not in ["auto", "manual"]:
|
||||||
|
return jsonify({"success": False, "error": "Invalid mode. Use 'auto' or 'manual'"}), 400
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(BATCH_MODE_PATH), exist_ok=True)
|
||||||
|
with open(BATCH_MODE_PATH, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"mode": mode, "updated_at": datetime.now().isoformat()}, f, indent=2)
|
||||||
|
|
||||||
|
return jsonify({"success": True, "mode": mode, "message": f"Batch mode switched to {mode}"})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
# GET method
|
||||||
|
current_mode = "manual"
|
||||||
|
if os.path.exists(BATCH_MODE_PATH):
|
||||||
|
try:
|
||||||
|
with open(BATCH_MODE_PATH, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
current_mode = data.get("mode", "manual")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return jsonify({"success": True, "mode": current_mode})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/current-batch")
|
||||||
|
def api_current_batch():
|
||||||
|
fps_val = 0.0
|
||||||
|
status_file = os.getenv('LIVE_STATUS_FILE', '/dev/shm/jetson-counter/live_status.json' if os.name != 'nt' else 'd:/Belajar/menghitung karung/live_status.json')
|
||||||
|
try:
|
||||||
|
if os.path.exists(status_file):
|
||||||
|
with open(status_file, "r") as sf:
|
||||||
|
sdata = json.load(sf)
|
||||||
|
fps_val = sdata.get("fps", 0.0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(CURRENT_BATCH_PATH, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"counting_date": data.get("counting_date"),
|
||||||
|
"batch_number": data.get("batch_number"),
|
||||||
|
"count": data.get("count", 0),
|
||||||
|
"start_time": data.get("start_time"),
|
||||||
|
"last_detection_time": data.get("last_detection_time"),
|
||||||
|
"fps": fps_val
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "No active batch",
|
||||||
|
"count": 0,
|
||||||
|
"batch_number": None,
|
||||||
|
"counting_date": None,
|
||||||
|
"fps": fps_val
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"count": 0,
|
||||||
|
"batch_number": None,
|
||||||
|
"counting_date": None,
|
||||||
|
"fps": fps_val
|
||||||
|
}
|
||||||
|
), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/previous-batch")
|
||||||
|
def api_previous_batch():
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT counting_date, batch_number, count, start_time, end_time,
|
||||||
|
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
|
||||||
|
FROM batches
|
||||||
|
ORDER BY end_time DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"date": row["counting_date"],
|
||||||
|
"batch_number": row["batch_number"],
|
||||||
|
"count": row["count"],
|
||||||
|
"start_time": row["start_time"],
|
||||||
|
"end_time": row["end_time"],
|
||||||
|
"duration_minutes": row["duration_minutes"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return jsonify({"success": False, "error": "No previous batch"}), 200
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/summary")
|
||||||
|
def api_summary():
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
today = get_counting_date()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(total_count, 0) as total_count,
|
||||||
|
COALESCE(total_batches, 0) as total_batches
|
||||||
|
FROM daily_summaries
|
||||||
|
WHERE counting_date = ?
|
||||||
|
""",
|
||||||
|
(today,),
|
||||||
|
)
|
||||||
|
today_row = cur.fetchone()
|
||||||
|
yesterday = (datetime.strptime(today, "%Y-%m-%d").date() - timedelta(days=1)).isoformat()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(total_count, 0) as total_count,
|
||||||
|
COALESCE(total_batches, 0) as total_batches
|
||||||
|
FROM daily_summaries
|
||||||
|
WHERE counting_date = ?
|
||||||
|
""",
|
||||||
|
(yesterday,),
|
||||||
|
)
|
||||||
|
yesterday_row = cur.fetchone()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(SUM(total_count), 0) as grand_total,
|
||||||
|
COALESCE(SUM(total_batches), 0) as grand_batches,
|
||||||
|
COUNT(DISTINCT counting_date) as total_days
|
||||||
|
FROM daily_summaries
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
all_time = cur.fetchone()
|
||||||
|
cur.execute("SELECT ROUND(AVG(total_count), 1) as avg_per_day FROM daily_summaries")
|
||||||
|
avg = cur.fetchone()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT counting_date, total_count
|
||||||
|
FROM daily_summaries
|
||||||
|
ORDER BY total_count DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
best = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"today": {
|
||||||
|
"date": today,
|
||||||
|
"total_count": today_row["total_count"] if today_row else 0,
|
||||||
|
"total_batches": today_row["total_batches"] if today_row else 0,
|
||||||
|
},
|
||||||
|
"yesterday": {
|
||||||
|
"date": yesterday,
|
||||||
|
"total_count": yesterday_row["total_count"] if yesterday_row else 0,
|
||||||
|
"total_batches": yesterday_row["total_batches"] if yesterday_row else 0,
|
||||||
|
},
|
||||||
|
"all_time": {
|
||||||
|
"grand_total": all_time["grand_total"],
|
||||||
|
"grand_batches": all_time["grand_batches"],
|
||||||
|
"total_days": all_time["total_days"],
|
||||||
|
},
|
||||||
|
"average_per_day": avg["avg_per_day"] or 0,
|
||||||
|
"best_day": {
|
||||||
|
"date": best["counting_date"] if best else None,
|
||||||
|
"count": best["total_count"] if best else 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
return jsonify({"success": False, "error": f"Database unavailable: {e}", "today": {"date": datetime.now().date().isoformat(), "total_count": 0, "total_batches": 0}, "yesterday": {"date": "", "total_count": 0, "total_batches": 0}, "all_time": {"grand_total": 0, "grand_batches": 0, "total_days": 0}, "average_per_day": 0, "best_day": {"date": None, "count": 0}}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/daily-data")
|
||||||
|
def api_daily_data():
|
||||||
|
try:
|
||||||
|
days = request.args.get("days", 30, type=int)
|
||||||
|
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT counting_date, total_count, total_batches,
|
||||||
|
ROUND(CAST(total_count AS FLOAT) / total_batches, 1) as avg_per_batch
|
||||||
|
FROM daily_summaries
|
||||||
|
WHERE counting_date >= ?
|
||||||
|
ORDER BY counting_date ASC
|
||||||
|
""",
|
||||||
|
(date_from,),
|
||||||
|
)
|
||||||
|
daily_data = [
|
||||||
|
{
|
||||||
|
"date": row["counting_date"],
|
||||||
|
"total_count": row["total_count"],
|
||||||
|
"total_batches": row["total_batches"],
|
||||||
|
"avg_per_batch": row["avg_per_batch"] or 0,
|
||||||
|
}
|
||||||
|
for row in cur.fetchall()
|
||||||
|
]
|
||||||
|
conn.close()
|
||||||
|
return jsonify(daily_data)
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
return jsonify([]), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/day-detail/<date>")
|
||||||
|
def api_day_detail(date):
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT batch_number, count, start_time, end_time,
|
||||||
|
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
|
||||||
|
FROM batches
|
||||||
|
WHERE counting_date = ?
|
||||||
|
ORDER BY batch_number ASC
|
||||||
|
""",
|
||||||
|
(date,),
|
||||||
|
)
|
||||||
|
batches = []
|
||||||
|
total_duration = 0
|
||||||
|
for row in cur.fetchall():
|
||||||
|
duration = row["duration_minutes"] or 0
|
||||||
|
total_duration += duration
|
||||||
|
batches.append(
|
||||||
|
{
|
||||||
|
"batch_number": row["batch_number"],
|
||||||
|
"count": row["count"],
|
||||||
|
"start_time": row["start_time"],
|
||||||
|
"end_time": row["end_time"],
|
||||||
|
"duration_minutes": duration,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT total_count, total_batches
|
||||||
|
FROM daily_summaries
|
||||||
|
WHERE counting_date = ?
|
||||||
|
""",
|
||||||
|
(date,),
|
||||||
|
)
|
||||||
|
summary = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"date": date,
|
||||||
|
"total_count": summary["total_count"] if summary else 0,
|
||||||
|
"total_batches": summary["total_batches"] if summary else 0,
|
||||||
|
"total_duration_minutes": round(total_duration, 1),
|
||||||
|
"avg_duration_minutes": round(total_duration / len(batches), 1) if batches else 0,
|
||||||
|
"batches": batches,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
return jsonify({"date": date, "total_count": 0, "total_batches": 0, "total_duration_minutes": 0, "avg_duration_minutes": 0, "batches": [], "error": f"Database unavailable: {e}"}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/recent-batches")
|
||||||
|
def api_recent_batches():
|
||||||
|
try:
|
||||||
|
limit = request.args.get("limit", 10, type=int)
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT counting_date, batch_number, count, start_time, end_time,
|
||||||
|
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
|
||||||
|
FROM batches
|
||||||
|
ORDER BY end_time DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
batches = [
|
||||||
|
{
|
||||||
|
"date": row["counting_date"],
|
||||||
|
"batch_number": row["batch_number"],
|
||||||
|
"count": row["count"],
|
||||||
|
"start_time": row["start_time"],
|
||||||
|
"end_time": row["end_time"],
|
||||||
|
"duration_minutes": row["duration_minutes"] or 0,
|
||||||
|
}
|
||||||
|
for row in cur.fetchall()
|
||||||
|
]
|
||||||
|
conn.close()
|
||||||
|
return jsonify(batches)
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
return jsonify([]), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/available-dates")
|
||||||
|
def api_available_dates():
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT counting_date, total_count, total_batches
|
||||||
|
FROM daily_summaries
|
||||||
|
ORDER BY counting_date DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
dates = [
|
||||||
|
{
|
||||||
|
"date": row["counting_date"],
|
||||||
|
"total_count": row["total_count"],
|
||||||
|
"total_batches": row["total_batches"],
|
||||||
|
}
|
||||||
|
for row in cur.fetchall()
|
||||||
|
]
|
||||||
|
conn.close()
|
||||||
|
return jsonify(dates)
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
return jsonify([]), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
def _excel_response(wb, filename):
|
||||||
|
output = BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
output.seek(0)
|
||||||
|
return Response(
|
||||||
|
output.getvalue(),
|
||||||
|
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _style_header(ws, cols):
|
||||||
|
header_font = Font(bold=True, color="FFFFFF", size=11)
|
||||||
|
header_fill = PatternFill(start_color="2F5496", end_color="2F5496", fill_type="solid")
|
||||||
|
thin_border = Border(
|
||||||
|
left=Side(style="thin"), right=Side(style="thin"),
|
||||||
|
top=Side(style="thin"), bottom=Side(style="thin"),
|
||||||
|
)
|
||||||
|
for col_idx, (col_letter, text) in enumerate(cols, 1):
|
||||||
|
cell = ws.cell(row=1, column=col_idx, value=text)
|
||||||
|
cell.font = header_font
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.alignment = Alignment(horizontal="center")
|
||||||
|
cell.border = thin_border
|
||||||
|
ws.freeze_panes = "A2"
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_width(ws):
|
||||||
|
for col in ws.columns:
|
||||||
|
max_len = 0
|
||||||
|
col_letter = col[0].column_letter
|
||||||
|
for cell in col:
|
||||||
|
if cell.value is not None:
|
||||||
|
max_len = max(max_len, len(str(cell.value)))
|
||||||
|
ws.column_dimensions[col_letter].width = max_len + 4
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/export-daily-csv")
|
||||||
|
def export_daily_xlsx():
|
||||||
|
try:
|
||||||
|
days = request.args.get("days", 30, type=int)
|
||||||
|
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT counting_date, batch_number, count, start_time, end_time,
|
||||||
|
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
|
||||||
|
FROM batches
|
||||||
|
WHERE counting_date >= ?
|
||||||
|
ORDER BY counting_date ASC, batch_number ASC
|
||||||
|
""",
|
||||||
|
(date_from,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
conn.close()
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 503
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Batch Details"
|
||||||
|
_style_header(ws, [("A", "Date"), ("B", "Batch #"), ("C", "Count"), ("D", "Start Time"), ("E", "End Time"), ("F", "Duration (min)")])
|
||||||
|
|
||||||
|
for r_idx, row in enumerate(rows, 2):
|
||||||
|
ws.cell(row=r_idx, column=1, value=row["counting_date"])
|
||||||
|
ws.cell(row=r_idx, column=2, value=row["batch_number"])
|
||||||
|
ws.cell(row=r_idx, column=3, value=row["count"])
|
||||||
|
ws.cell(row=r_idx, column=4, value=row["start_time"])
|
||||||
|
ws.cell(row=r_idx, column=5, value=row["end_time"])
|
||||||
|
ws.cell(row=r_idx, column=6, value=row["duration_minutes"] or 0)
|
||||||
|
|
||||||
|
_auto_width(ws)
|
||||||
|
filename = f"{SITE_NAME}_daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||||
|
return _excel_response(wb, filename)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/export-day-csv/<date>")
|
||||||
|
def export_day_xlsx(date):
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT batch_number, count, start_time, end_time,
|
||||||
|
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
|
||||||
|
FROM batches
|
||||||
|
WHERE counting_date = ?
|
||||||
|
ORDER BY batch_number ASC
|
||||||
|
""",
|
||||||
|
(date,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
conn.close()
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 503
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = f"Day {date}"
|
||||||
|
_style_header(ws, [("A", "Batch Number"), ("B", "Count"), ("C", "Start Time"), ("D", "End Time"), ("E", "Duration (min)")])
|
||||||
|
|
||||||
|
for r_idx, row in enumerate(rows, 2):
|
||||||
|
ws.cell(row=r_idx, column=1, value=row["batch_number"])
|
||||||
|
ws.cell(row=r_idx, column=2, value=row["count"])
|
||||||
|
ws.cell(row=r_idx, column=3, value=row["start_time"])
|
||||||
|
ws.cell(row=r_idx, column=4, value=row["end_time"])
|
||||||
|
ws.cell(row=r_idx, column=5, value=row["duration_minutes"] or 0)
|
||||||
|
|
||||||
|
_auto_width(ws)
|
||||||
|
filename = f"{SITE_NAME}_day_detail_{date}.xlsx"
|
||||||
|
return _excel_response(wb, filename)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import threading
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
|
WSGIRequestHandler.protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"[*] JETSON BATCH CONTROL & DASHBOARD SERVER STARTED")
|
||||||
|
print(f"[*] Port Operator (Panel Tombol): http://{DASHBOARD_HOST}:{DASHBOARD_PORT}")
|
||||||
|
print(f"[*] Port Kantor (Monitoring AI): http://{DASHBOARD_HOST}:{OFFICE_PORT}")
|
||||||
|
print(f"[*] DB Path: {DB_PATH}")
|
||||||
|
print(f"[*] State : {CURRENT_BATCH_PATH}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Server 1: Operator Port (default 5000)
|
||||||
|
server_operator = make_server(DASHBOARD_HOST, DASHBOARD_PORT, app, threaded=True)
|
||||||
|
t_op = threading.Thread(target=server_operator.serve_forever, daemon=True)
|
||||||
|
t_op.start()
|
||||||
|
|
||||||
|
# Server 2: Office Monitoring Port (default 5721)
|
||||||
|
server_office = make_server(DASHBOARD_HOST, OFFICE_PORT, app, threaded=True)
|
||||||
|
try:
|
||||||
|
server_office.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nStopping servers...")
|
||||||
|
server_operator.shutdown()
|
||||||
|
server_office.shutdown()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import paramiko
|
||||||
|
import os
|
||||||
|
|
||||||
|
files_to_sync = [
|
||||||
|
("templates/operator.html", "/home/jetson/karung/templates/operator.html"),
|
||||||
|
("templates/monitoring.html", "/home/jetson/karung/templates/monitoring.html"),
|
||||||
|
("templates/base.html", "/home/jetson/karung/templates/base.html"),
|
||||||
|
("counter_dashboard.py", "/home/jetson/karung/counter_dashboard.py"),
|
||||||
|
("predict.py", "/home/jetson/karung/predict.py"),
|
||||||
|
(".env", "/home/jetson/karung/.env")
|
||||||
|
]
|
||||||
|
|
||||||
|
def run():
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect('192.168.192.96', username='jetson', password='jetson', timeout=10)
|
||||||
|
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
|
||||||
|
print("Uploading updated files to Jetson...")
|
||||||
|
for local_f, remote_f in files_to_sync:
|
||||||
|
print(f"Uploading {local_f} -> {remote_f}")
|
||||||
|
sftp.put(local_f, remote_f)
|
||||||
|
|
||||||
|
sftp.close()
|
||||||
|
print("All files uploaded successfully!")
|
||||||
|
|
||||||
|
print("Restarting services on Jetson...")
|
||||||
|
cmd = "echo jetson | sudo -S systemctl restart karung-counter karung-counter-dashboard"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd)
|
||||||
|
print("STDOUT:", stdout.read().decode('utf-8', errors='ignore'))
|
||||||
|
print("STDERR:", stderr.read().decode('utf-8', errors='ignore'))
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
stdin, stdout, stderr = client.exec_command("systemctl status karung-counter-dashboard karung-counter --no-pager")
|
||||||
|
print("=== SERVICE STATUS ===")
|
||||||
|
print(stdout.read().decode('utf-8', errors='ignore').encode('ascii', errors='ignore').decode())
|
||||||
|
|
||||||
|
# Check listening ports
|
||||||
|
stdin, stdout, stderr = client.exec_command("netstat -tuln | grep -E '5000|5721'")
|
||||||
|
print("=== LISTENING PORTS ===")
|
||||||
|
print(stdout.read().decode('utf-8', errors='ignore'))
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import cv2
|
||||||
|
import torch
|
||||||
|
import time
|
||||||
|
import numpy as np
|
||||||
|
from ultralytics import YOLO
|
||||||
|
from shapely.geometry import Polygon, Point
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=== Detailed Jetson Detection Test ===")
|
||||||
|
|
||||||
|
# 1. Load Zones
|
||||||
|
zones_path = "/home/jetson/karung/zones.json"
|
||||||
|
truck_pts = []
|
||||||
|
if os.path.exists(zones_path):
|
||||||
|
import json
|
||||||
|
with open(zones_path, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
truck_pts = data.get('truck', [])
|
||||||
|
print(f"Zones.json truck points: {truck_pts}")
|
||||||
|
|
||||||
|
# Target resolution
|
||||||
|
target_w, target_h = 1280, 720
|
||||||
|
|
||||||
|
# Scale factors assuming zones were drawn on 1920x1080
|
||||||
|
orig_w, orig_h = 1920, 1080
|
||||||
|
scale_x = target_w / orig_w
|
||||||
|
scale_y = target_h / orig_h
|
||||||
|
|
||||||
|
scaled_truck_pts = [[int(p[0] * scale_x), int(p[1] * scale_y)] for p in truck_pts] if truck_pts else [
|
||||||
|
[389, 294], [398, 718], [885, 719], [885, 277]
|
||||||
|
]
|
||||||
|
truck_polygon = Polygon(scaled_truck_pts)
|
||||||
|
print(f"Scaled truck polygon: {scaled_truck_pts}")
|
||||||
|
|
||||||
|
# 2. Open Stream
|
||||||
|
source = "rtsp://192.168.192.96:8554/cam"
|
||||||
|
print(f"Connecting to RTSP stream: {source}...")
|
||||||
|
cap = cv2.VideoCapture(source)
|
||||||
|
if not cap.isOpened():
|
||||||
|
print("Error: Could not open RTSP source.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Wait for the stream to warm up and buffer
|
||||||
|
print("Warming up stream reader for 3 seconds...")
|
||||||
|
time.sleep(3.0)
|
||||||
|
|
||||||
|
# 3. Load Model
|
||||||
|
model_path = "/home/jetson/karung/model_karung_truk.engine"
|
||||||
|
print(f"Loading TensorRT Model: {model_path}...")
|
||||||
|
model = YOLO(model_path)
|
||||||
|
|
||||||
|
print("Running 10 frames of inference...")
|
||||||
|
detections_summary = {}
|
||||||
|
|
||||||
|
frame_count = 0
|
||||||
|
attempts = 0
|
||||||
|
while frame_count < 10 and attempts < 100:
|
||||||
|
ret, frame = cap.read()
|
||||||
|
attempts += 1
|
||||||
|
if not ret or frame is None:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
frame_count += 1
|
||||||
|
frame_resized = cv2.resize(frame, (target_w, target_h))
|
||||||
|
results = model(frame_resized, conf=0.01, imgsz=640, device="cuda", verbose=False)
|
||||||
|
result = results[0]
|
||||||
|
|
||||||
|
detected_in_frame = []
|
||||||
|
for box in result.boxes:
|
||||||
|
cls_id = int(box.cls[0])
|
||||||
|
name = model.names[cls_id]
|
||||||
|
conf = float(box.conf[0])
|
||||||
|
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
||||||
|
tcx = (x1 + x2) / 2.0
|
||||||
|
tcy = (y1 + y2) / 2.0
|
||||||
|
|
||||||
|
# Check if inside truck polygon
|
||||||
|
pt = Point(tcx, tcy)
|
||||||
|
in_poly = truck_polygon.contains(pt)
|
||||||
|
|
||||||
|
detected_in_frame.append(f"{name} ({conf:.3f}) at ({tcx:.1f},{tcy:.1f}) in_poly={in_poly}")
|
||||||
|
detections_summary[name] = detections_summary.get(name, 0) + 1
|
||||||
|
|
||||||
|
print(f"Frame {frame_count} (attempt {attempts}): {', '.join(detected_in_frame) if detected_in_frame else 'None'}")
|
||||||
|
|
||||||
|
cap.release()
|
||||||
|
print("\nSummary of detected objects over processed frames:")
|
||||||
|
print(detections_summary)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from ultralytics import YOLO
|
||||||
|
import torch
|
||||||
|
import os
|
||||||
|
|
||||||
|
def main():
|
||||||
|
model_path = "karung-dimuat-detection-di-feedmill-yolo26n-seg-200e.pt"
|
||||||
|
if not os.path.exists(model_path):
|
||||||
|
print(f"Error: {model_path} tidak ditemukan di folder ini!")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("--- PROSES EKSPOR MODEL KE TENSORRT (.engine) ---")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"CUDA Terdeteksi: {torch.cuda.is_available()}")
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
print(f"GPU Device Name: {torch.cuda.get_device_name(0)}")
|
||||||
|
|
||||||
|
print("\nLoading model ke memori...")
|
||||||
|
model = YOLO(model_path)
|
||||||
|
|
||||||
|
print("\nMengekspor model ke format TensorRT (FP16)...")
|
||||||
|
try:
|
||||||
|
# Eksport ke TensorRT (.engine)
|
||||||
|
# half=True mengaktifkan kuantisasi FP16 (sangat cepat di GPU Jetson)
|
||||||
|
engine_path = model.export(format="engine", device=0, half=True)
|
||||||
|
print(f"\n[SUKSES] Model berhasil diekspor ke: {engine_path}")
|
||||||
|
print("\nSelanjutnya:")
|
||||||
|
print("1. Ganti MODEL_FILE di predict.py menjadi nama file .engine yang baru dibuat.")
|
||||||
|
print("2. Jalankan kembali predict.py untuk performa GPU maksimal!")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n[Gagal ekspor langsung ke Engine]: {e}")
|
||||||
|
print("\nMencoba metode alternatif: Ekspor ke ONNX terlebih dahulu...")
|
||||||
|
try:
|
||||||
|
onnx_path = model.export(format="onnx", half=True, dynamic=False, opset=12)
|
||||||
|
print(f"[SUKSES] Model berhasil diekspor ke ONNX: {onnx_path}")
|
||||||
|
|
||||||
|
onnx_file = os.path.basename(onnx_path)
|
||||||
|
engine_file = onnx_file.replace(".onnx", ".engine")
|
||||||
|
print("\nAnda bisa mengompilasi file ONNX tersebut ke Engine secara manual di Jetson dengan menjalankan:")
|
||||||
|
print(f" /usr/src/tensorrt/bin/trtexec --onnx={onnx_file} --saveEngine={engine_file} --fp16")
|
||||||
|
print("\nSetelah kompilasi manual selesai, ganti MODEL_FILE di predict.py dengan file .engine hasil kompilasi tersebut.")
|
||||||
|
except Exception as ex:
|
||||||
|
print(f"[Gagal ekspor ke ONNX]: {ex}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import os
|
||||||
|
import random
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
def extract_random_frames(video_path, output_dir, num_frames=500):
|
||||||
|
# Ensure output directory exists
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Open the video file
|
||||||
|
cap = cv2.VideoCapture(video_path)
|
||||||
|
if not cap.isOpened():
|
||||||
|
print(f"Error: Gagal membuka video '{video_path}'")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get total number of frames
|
||||||
|
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||||
|
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||||
|
duration_sec = total_frames / fps if fps > 0 else 0
|
||||||
|
|
||||||
|
print(f"Video Info:")
|
||||||
|
print(f" - Path: {video_path}")
|
||||||
|
print(f" - Total Frame: {total_frames}")
|
||||||
|
print(f" - FPS: {fps:.2f}")
|
||||||
|
print(f" - Durasi: {duration_sec:.2f} detik (~{duration_sec/60:.2f} menit)")
|
||||||
|
print(f" - Mengambil {num_frames} frame secara acak...\n")
|
||||||
|
|
||||||
|
if total_frames < num_frames:
|
||||||
|
print(f"Warning: Total frame pada video ({total_frames}) lebih kecil dari jumlah frame yang diminta ({num_frames}).")
|
||||||
|
print("Akan mengekstrak semua frame yang tersedia.")
|
||||||
|
selected_frames = list(range(total_frames))
|
||||||
|
else:
|
||||||
|
# Select 500 unique random frames
|
||||||
|
selected_frames = random.sample(range(total_frames), num_frames)
|
||||||
|
|
||||||
|
# Sort the indices so we seek sequentially (faster and more stable)
|
||||||
|
selected_frames.sort()
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for idx, frame_id in enumerate(selected_frames):
|
||||||
|
# Set the frame position
|
||||||
|
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_id)
|
||||||
|
ret, frame = cap.read()
|
||||||
|
|
||||||
|
if not ret:
|
||||||
|
print(f"Warning: Gagal membaca frame ke-{frame_id}. Mencoba membaca frame selanjutnya...")
|
||||||
|
# Try reading the next frame sequentially
|
||||||
|
ret, frame = cap.read()
|
||||||
|
if not ret:
|
||||||
|
print(f"Error: Tetap gagal membaca frame setelah frame ke-{frame_id}. Melewati...")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Save frame as image
|
||||||
|
output_filename = os.path.join(output_dir, f"frame_{frame_id:06d}.jpg")
|
||||||
|
cv2.imwrite(output_filename, frame)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# Print progress every 50 frames or at the end
|
||||||
|
if count % 50 == 0 or count == len(selected_frames):
|
||||||
|
print(f"Progress: [{count}/{len(selected_frames)}] Berhasil mengekstrak {output_filename}")
|
||||||
|
|
||||||
|
cap.release()
|
||||||
|
print(f"\nSelesai! Berhasil mengekstrak {count} frame acak ke folder '{output_dir}'.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
video_file = "0727.mp4"
|
||||||
|
output_directory = "extracted_frames"
|
||||||
|
number_of_frames = 500
|
||||||
|
|
||||||
|
extract_random_frames(video_file, output_directory, number_of_frames)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import cv2
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def main():
|
||||||
|
source = "2026-07-27 09-11-50.mp4"
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
source = sys.argv[1]
|
||||||
|
|
||||||
|
print(f"Connecting to: {source}")
|
||||||
|
cap = cv2.VideoCapture(source)
|
||||||
|
if not cap.isOpened():
|
||||||
|
print("Error: Could not open source!")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Read a few frames to let the camera stabilize exposure/stream
|
||||||
|
print("Reading frames...")
|
||||||
|
frame = None
|
||||||
|
for i in range(10):
|
||||||
|
ret, temp_frame = cap.read()
|
||||||
|
if ret:
|
||||||
|
frame = temp_frame
|
||||||
|
|
||||||
|
if frame is None:
|
||||||
|
print("Error: Could not read any frame from the source!")
|
||||||
|
cap.release()
|
||||||
|
return
|
||||||
|
|
||||||
|
output_filename = "calib_frame.jpg"
|
||||||
|
cv2.imwrite(output_filename, frame)
|
||||||
|
print(f"Successfully saved clean frame as {output_filename}!")
|
||||||
|
print(f"Resolution: {frame.shape[1]}x{frame.shape[0]}")
|
||||||
|
cap.release()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import cv2
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# State constants
|
||||||
|
STATE_TRUCK = 0
|
||||||
|
STATE_DETECTION = 1
|
||||||
|
STATE_LINE = 2
|
||||||
|
STATE_DONE = 3
|
||||||
|
|
||||||
|
state = STATE_TRUCK
|
||||||
|
points_truck = []
|
||||||
|
points_detection = []
|
||||||
|
points_line = []
|
||||||
|
|
||||||
|
def click_event(event, x, y, flags, params):
|
||||||
|
global state
|
||||||
|
if event == cv2.EVENT_LBUTTONDOWN:
|
||||||
|
if state == STATE_TRUCK:
|
||||||
|
points_truck.append([x, y])
|
||||||
|
print(f"Truck Area - Point {len(points_truck)}: [{x}, {y}]")
|
||||||
|
if len(points_truck) == 4:
|
||||||
|
state = STATE_DETECTION
|
||||||
|
print("\n-> Area Truk Berhasil Dipilih (4 titik).")
|
||||||
|
print("-> SILAHKAN PILIH AREA DETEKSI (Klik Kiri 4 Titik secara berurutan: Top-Left, Top-Right, Bottom-Right, Bottom-Left).")
|
||||||
|
elif state == STATE_DETECTION:
|
||||||
|
points_detection.append([x, y])
|
||||||
|
print(f"Detection Area - Point {len(points_detection)}: [{x}, {y}]")
|
||||||
|
if len(points_detection) == 4:
|
||||||
|
state = STATE_LINE
|
||||||
|
print("\n-> Area Deteksi Berhasil Dipilih (4 titik).")
|
||||||
|
print("-> SILAHKAN PILIH COUNT LINE (Klik Kiri Titik Mulai dan Titik Selesai).")
|
||||||
|
elif state == STATE_LINE:
|
||||||
|
points_line.append([x, y])
|
||||||
|
print(f"Count Line - Point {len(points_line)}: [{x}, {y}]")
|
||||||
|
if len(points_line) == 2:
|
||||||
|
state = STATE_DONE
|
||||||
|
print("\n-> Count Line Berhasil Dipilih.")
|
||||||
|
print("-> Semua koordinat telah lengkap! Tekan 's' untuk mencetak & menyimpan koordinat.")
|
||||||
|
|
||||||
|
draw_frame()
|
||||||
|
|
||||||
|
def draw_frame():
|
||||||
|
img_copy = img.copy()
|
||||||
|
h, w, _ = img_copy.shape
|
||||||
|
|
||||||
|
# 1. Draw Area Truk (Orange Polygon)
|
||||||
|
for pt in points_truck:
|
||||||
|
cv2.circle(img_copy, tuple(pt), 5, (0, 165, 255), -1)
|
||||||
|
if len(points_truck) == 4:
|
||||||
|
pts = np.array(points_truck, np.int32)
|
||||||
|
cv2.polylines(img_copy, [pts], True, (0, 165, 255), 2)
|
||||||
|
cv2.putText(img_copy, "TRUCK AREA", tuple(points_truck[0]),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 165, 255), 1)
|
||||||
|
|
||||||
|
# 2. Draw Area Deteksi (Cyan Polygon)
|
||||||
|
for pt in points_detection:
|
||||||
|
cv2.circle(img_copy, tuple(pt), 5, (255, 255, 0), -1)
|
||||||
|
if len(points_detection) == 4:
|
||||||
|
pts = np.array(points_detection, np.int32)
|
||||||
|
cv2.polylines(img_copy, [pts], True, (255, 255, 0), 2)
|
||||||
|
cv2.putText(img_copy, "DETECTION AREA", tuple(points_detection[0]),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 0), 1)
|
||||||
|
|
||||||
|
# 3. Draw Count Line (Magenta Line)
|
||||||
|
if len(points_line) > 0:
|
||||||
|
cv2.circle(img_copy, tuple(points_line[0]), 5, (255, 0, 255), -1)
|
||||||
|
if len(points_line) == 2:
|
||||||
|
cv2.line(img_copy, tuple(points_line[0]), tuple(points_line[1]), (255, 0, 255), 3)
|
||||||
|
# Draw midpoint circle
|
||||||
|
mid_x = int((points_line[0][0] + points_line[1][0]) / 2)
|
||||||
|
mid_y = int((points_line[0][1] + points_line[1][1]) / 2)
|
||||||
|
cv2.circle(img_copy, (mid_x, mid_y), 6, (0, 255, 0), -1)
|
||||||
|
cv2.putText(img_copy, f"LINE (y={mid_y})", (mid_x + 10, mid_y - 10),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 255), 1)
|
||||||
|
|
||||||
|
# Draw Instruction Overlay on Top
|
||||||
|
overlay_y = 35
|
||||||
|
if state == STATE_TRUCK:
|
||||||
|
txt = f"1. PILIH AREA TRUK (Klik Kiri 4 Titik, saat ini: {len(points_truck)}/4)"
|
||||||
|
color = (0, 165, 255)
|
||||||
|
elif state == STATE_DETECTION:
|
||||||
|
txt = f"2. PILIH AREA DETEKSI (Klik Kiri 4 Titik, saat ini: {len(points_detection)}/4)"
|
||||||
|
color = (255, 255, 0)
|
||||||
|
elif state == STATE_LINE:
|
||||||
|
txt = f"3. PILIH COUNT LINE (Klik Kiri Titik Awal lalu Titik Akhir, saat ini: {len(points_line)}/2)"
|
||||||
|
color = (255, 0, 255)
|
||||||
|
else:
|
||||||
|
txt = "SELESAI! Tekan 's' untuk simpan atau 'c' untuk ulang."
|
||||||
|
color = (0, 255, 0)
|
||||||
|
|
||||||
|
# Draw background panel for text
|
||||||
|
cv2.rectangle(img_copy, (10, 10), (w - 10, 50), (0, 0, 0), -1)
|
||||||
|
cv2.putText(img_copy, txt, (20, overlay_y), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
|
||||||
|
|
||||||
|
cv2.imshow('Interactive 4-Point Zone Selector', img_copy)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
source_img = "gambar_terbaru.jpg" if os.path.exists("gambar_terbaru.jpg") else "calib_frame.jpg"
|
||||||
|
video_source = "0727.mp4"
|
||||||
|
|
||||||
|
if os.path.exists(source_img):
|
||||||
|
img = cv2.imread(source_img)
|
||||||
|
print(f"Loaded image: {source_img}")
|
||||||
|
elif os.path.exists(video_source):
|
||||||
|
print(f"Grabbing frame from video: {video_source}")
|
||||||
|
cap = cv2.VideoCapture(video_source)
|
||||||
|
for _ in range(10):
|
||||||
|
ret, img = cap.read()
|
||||||
|
cap.release()
|
||||||
|
if not ret:
|
||||||
|
print("Error: Could not grab frame from video.")
|
||||||
|
exit(1)
|
||||||
|
else:
|
||||||
|
print("Error: Neither calib_frame.jpg nor the video file exists.")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
cv2.namedWindow('Interactive 4-Point Zone Selector')
|
||||||
|
cv2.setMouseCallback('Interactive 4-Point Zone Selector', click_event)
|
||||||
|
|
||||||
|
print("\n=== OpenCV 4-Point Zone Coordinate Selector ===")
|
||||||
|
print("Instructions:")
|
||||||
|
print("1. Left-click to select coordinates for each step.")
|
||||||
|
print("2. Press 'c' at any time to clear selection and restart.")
|
||||||
|
print("3. Press 's' when done to print python snippets and save to zones_output.json.")
|
||||||
|
print("4. Press 'q' or 'ESC' to quit.")
|
||||||
|
print("========================================\n")
|
||||||
|
print("-> SILAHKAN PILIH AREA TRUK (Klik Kiri 4 Titik secara berurutan: Top-Left, Top-Right, Bottom-Right, Bottom-Left).")
|
||||||
|
|
||||||
|
draw_frame()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
key = cv2.waitKey(1) & 0xFF
|
||||||
|
if key == ord('c') or key == ord('C'):
|
||||||
|
state = STATE_TRUCK
|
||||||
|
points_truck = []
|
||||||
|
points_detection = []
|
||||||
|
points_line = []
|
||||||
|
print("\nCleared selection. Restarting from Step 1 (Area Truk)...")
|
||||||
|
draw_frame()
|
||||||
|
elif key == ord('s') or key == ord('S'):
|
||||||
|
if state != STATE_DONE:
|
||||||
|
print(f"Warning: Harap selesaikan semua langkah terlebih dahulu. State saat ini: {state}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
lx1, ly1 = points_line[0]
|
||||||
|
lx2, ly2 = points_line[1]
|
||||||
|
line_y_avg = int((ly1 + ly2) / 2)
|
||||||
|
|
||||||
|
output_data = {
|
||||||
|
"truck_poly": points_truck,
|
||||||
|
"detection_poly": points_detection,
|
||||||
|
"count_line": {
|
||||||
|
"x_start": lx1, "x_end": lx2, "y": line_y_avg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Print configuration code snippets
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("KOORDINAT BERHASIL DI-GENERATE!")
|
||||||
|
print("="*50)
|
||||||
|
print("\n--- SALIN KODE DI BAWAH INI KE predict.py ---\n")
|
||||||
|
print(f" # 1. Detection Area (4-point Polygon)")
|
||||||
|
print(f" detection_poly_pts = [")
|
||||||
|
for pt in points_detection:
|
||||||
|
print(f" [int({pt[0]} * scale_x), int({pt[1]} * scale_y)],")
|
||||||
|
print(f" ]")
|
||||||
|
print(f" detection_polygon = Polygon(detection_poly_pts)")
|
||||||
|
print()
|
||||||
|
print(f" # 2. Count Line coordinates")
|
||||||
|
print(f" static_line_y = int({line_y_avg} * scale_y)")
|
||||||
|
print(f" static_line_x_start = int({lx1} * scale_x)")
|
||||||
|
print(f" static_line_x_end = int({lx2} * scale_x)")
|
||||||
|
print()
|
||||||
|
print(f" # 3. Truck Area (4-point Polygon for presence check)")
|
||||||
|
print(f" truck_poly_pts = [")
|
||||||
|
for pt in points_truck:
|
||||||
|
print(f" [int({pt[0]} * scale_x), int({pt[1]} * scale_y)],")
|
||||||
|
print(f" ]")
|
||||||
|
print(f" truck_polygon = Polygon(truck_poly_pts)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Calculate bounding box of truck_polygon to maintain backward compatibility with static_roi
|
||||||
|
tx_coords = [p[0] for p in points_truck]
|
||||||
|
ty_coords = [p[1] for p in points_truck]
|
||||||
|
min_tx, max_tx = min(tx_coords), max(tx_coords)
|
||||||
|
min_ty, max_ty = min(ty_coords), max(ty_coords)
|
||||||
|
print(f" static_roi = TruckROI(")
|
||||||
|
print(f" x1=int({min_tx} * scale_x),")
|
||||||
|
print(f" y1=int({min_ty} * scale_y),")
|
||||||
|
print(f" x2=int({max_tx} * scale_x),")
|
||||||
|
print(f" y2=int({max_ty} * scale_y),")
|
||||||
|
print(f" line_y=static_line_y,")
|
||||||
|
print(f" confidence=1.0")
|
||||||
|
print(f" )")
|
||||||
|
print("\n" + "="*50)
|
||||||
|
|
||||||
|
# Save to json file
|
||||||
|
with open("zones_output.json", "w") as f:
|
||||||
|
json.dump(output_data, f, indent=4)
|
||||||
|
print("Koordinat juga telah disimpan ke 'zones_output.json'\n")
|
||||||
|
|
||||||
|
elif key == ord('q') or key == 27:
|
||||||
|
break
|
||||||
|
|
||||||
|
cv2.destroyAllWindows()
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Karung Counter History & Excel Dashboard
|
||||||
|
After=network.target karung-counter.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=jetson
|
||||||
|
WorkingDirectory=/home/jetson/karung
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
EnvironmentFile=/home/jetson/karung/.env
|
||||||
|
ExecStart=/usr/bin/python3 counter_dashboard.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Karung Counter AI Inference Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=jetson
|
||||||
|
WorkingDirectory=/home/jetson/karung
|
||||||
|
Environment=QT_QPA_PLATFORM=offscreen
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
EnvironmentFile=/home/jetson/karung/.env
|
||||||
|
ExecStart=/usr/bin/python3 predict.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=MediaMTX RTSP/WebRTC Streaming Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=jetson
|
||||||
|
WorkingDirectory=/home/jetson/mediamtx
|
||||||
|
ExecStart=/home/jetson/mediamtx/mediamtx
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import paramiko
|
||||||
|
import base64
|
||||||
|
|
||||||
|
def run():
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect('192.168.192.96', username='jetson', password='jetson', timeout=10)
|
||||||
|
|
||||||
|
# 1. Backup DB first
|
||||||
|
backup_cmd = "cp /opt/jetson-counter/jetson_counter.db /opt/jetson-counter/jetson_counter.db.bak_$(date +%Y%m%d_%H%M%S)"
|
||||||
|
stdin, stdout, stderr = client.exec_command(backup_cmd)
|
||||||
|
print("Backup output:", stdout.read().decode(), stderr.read().decode())
|
||||||
|
|
||||||
|
# 2. Python migration script on Jetson
|
||||||
|
code = """
|
||||||
|
import sqlite3
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
db_path = '/opt/jetson-counter/jetson_counter.db'
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
def process_date_2026_08_21():
|
||||||
|
print("=== Processing 2026-08-21 ===")
|
||||||
|
# Fetch all batches
|
||||||
|
rows = cur.execute("SELECT * FROM batches WHERE counting_date = '2026-08-21' ORDER BY batch_number ASC").fetchall()
|
||||||
|
batches = [dict(r) for r in rows]
|
||||||
|
print(f"Initial batches count: {len(batches)}")
|
||||||
|
|
||||||
|
# Rules:
|
||||||
|
# 1. Batch 15 & 16 merge -> start_time = batch 15 start_time, end_time = batch 16 end_time, count = count15 + count16
|
||||||
|
# 2. Batch 20 hapus
|
||||||
|
|
||||||
|
new_batches = []
|
||||||
|
i = 0
|
||||||
|
while i < len(batches):
|
||||||
|
b = batches[i]
|
||||||
|
b_num = b['batch_number']
|
||||||
|
|
||||||
|
if b_num == 15:
|
||||||
|
# Look for batch 16
|
||||||
|
b_next = batches[i+1] if i+1 < len(batches) and batches[i+1]['batch_number'] == 16 else None
|
||||||
|
if b_next:
|
||||||
|
merged = {
|
||||||
|
'camera_name': b['camera_name'],
|
||||||
|
'object_label': b['object_label'],
|
||||||
|
'count': b['count'] + b_next['count'],
|
||||||
|
'start_time': b['start_time'],
|
||||||
|
'end_time': b_next['end_time']
|
||||||
|
}
|
||||||
|
new_batches.append(merged)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
new_batches.append(b)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
elif b_num == 20:
|
||||||
|
# Delete / skip
|
||||||
|
print(f"Deleting batch 20 (count: {b['count']})")
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
new_batches.append({
|
||||||
|
'camera_name': b['camera_name'],
|
||||||
|
'object_label': b['object_label'],
|
||||||
|
'count': b['count'],
|
||||||
|
'start_time': b['start_time'],
|
||||||
|
'end_time': b['end_time']
|
||||||
|
})
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Delete existing batches for 2026-08-21
|
||||||
|
cur.execute("DELETE FROM batches WHERE counting_date = '2026-08-21'")
|
||||||
|
|
||||||
|
# Re-insert with renumbered batch_number (1 to N)
|
||||||
|
total_count = 0
|
||||||
|
for idx, b in enumerate(new_batches, start=1):
|
||||||
|
total_count += b['count']
|
||||||
|
cur.execute(\"\"\"
|
||||||
|
INSERT INTO batches (counting_date, batch_number, camera_name, object_label, count, start_time, end_time)
|
||||||
|
VALUES ('2026-08-21', ?, ?, ?, ?, ?, ?)
|
||||||
|
\"\"\", (idx, b['camera_name'], b['object_label'], b['count'], b['start_time'], b['end_time']))
|
||||||
|
|
||||||
|
total_batches = len(new_batches)
|
||||||
|
print(f"New total batches for 2026-08-21: {total_batches}, total count: {total_count}")
|
||||||
|
|
||||||
|
# Update daily_summaries
|
||||||
|
cur.execute(\"\"\"
|
||||||
|
INSERT INTO daily_summaries (counting_date, camera_name, object_label, total_count, total_batches, updated_at)
|
||||||
|
VALUES ('2026-08-21', 'CC1', 'karung-pakan', ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(counting_date, camera_name, object_label) DO UPDATE SET
|
||||||
|
total_count = excluded.total_count,
|
||||||
|
total_batches = excluded.total_batches,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
\"\"\", (total_count, total_batches))
|
||||||
|
|
||||||
|
|
||||||
|
def process_date_2026_08_22():
|
||||||
|
print("=== Processing 2026-08-22 ===")
|
||||||
|
# Fetch all batches
|
||||||
|
rows = cur.execute("SELECT * FROM batches WHERE counting_date = '2026-08-22' ORDER BY batch_number ASC").fetchall()
|
||||||
|
batches = [dict(r) for r in rows]
|
||||||
|
print(f"Initial batches count: {len(batches)}")
|
||||||
|
|
||||||
|
# Rules:
|
||||||
|
# 1. Batch 5 & batch 6 gabungkan
|
||||||
|
# 2. Batch 27 hapus
|
||||||
|
# 3. Batch 28 & batch 29 gabungkan
|
||||||
|
|
||||||
|
new_batches = []
|
||||||
|
i = 0
|
||||||
|
while i < len(batches):
|
||||||
|
b = batches[i]
|
||||||
|
b_num = b['batch_number']
|
||||||
|
|
||||||
|
if b_num == 5:
|
||||||
|
# merge with 6
|
||||||
|
b_next = batches[i+1] if i+1 < len(batches) and batches[i+1]['batch_number'] == 6 else None
|
||||||
|
if b_next:
|
||||||
|
merged = {
|
||||||
|
'camera_name': b['camera_name'],
|
||||||
|
'object_label': b['object_label'],
|
||||||
|
'count': b['count'] + b_next['count'],
|
||||||
|
'start_time': b['start_time'],
|
||||||
|
'end_time': b_next['end_time']
|
||||||
|
}
|
||||||
|
new_batches.append(merged)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
new_batches.append(b)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
elif b_num == 27:
|
||||||
|
# Delete / skip
|
||||||
|
print(f"Deleting batch 27 (count: {b['count']})")
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
elif b_num == 28:
|
||||||
|
# merge with 29
|
||||||
|
b_next = batches[i+1] if i+1 < len(batches) and batches[i+1]['batch_number'] == 29 else None
|
||||||
|
if b_next:
|
||||||
|
merged = {
|
||||||
|
'camera_name': b['camera_name'],
|
||||||
|
'object_label': b['object_label'],
|
||||||
|
'count': b['count'] + b_next['count'],
|
||||||
|
'start_time': b['start_time'],
|
||||||
|
'end_time': b_next['end_time']
|
||||||
|
}
|
||||||
|
new_batches.append(merged)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
new_batches.append(b)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
new_batches.append({
|
||||||
|
'camera_name': b['camera_name'],
|
||||||
|
'object_label': b['object_label'],
|
||||||
|
'count': b['count'],
|
||||||
|
'start_time': b['start_time'],
|
||||||
|
'end_time': b['end_time']
|
||||||
|
})
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Delete existing batches for 2026-08-22
|
||||||
|
cur.execute("DELETE FROM batches WHERE counting_date = '2026-08-22'")
|
||||||
|
|
||||||
|
# Re-insert with renumbered batch_number (1 to N)
|
||||||
|
total_count = 0
|
||||||
|
for idx, b in enumerate(new_batches, start=1):
|
||||||
|
total_count += b['count']
|
||||||
|
cur.execute(\"\"\"
|
||||||
|
INSERT INTO batches (counting_date, batch_number, camera_name, object_label, count, start_time, end_time)
|
||||||
|
VALUES ('2026-08-22', ?, ?, ?, ?, ?, ?)
|
||||||
|
\"\"\", (idx, b['camera_name'], b['object_label'], b['count'], b['start_time'], b['end_time']))
|
||||||
|
|
||||||
|
total_batches = len(new_batches)
|
||||||
|
print(f"New total batches for 2026-08-22: {total_batches}, total count: {total_count}")
|
||||||
|
|
||||||
|
# Update daily_summaries
|
||||||
|
cur.execute(\"\"\"
|
||||||
|
INSERT INTO daily_summaries (counting_date, camera_name, object_label, total_count, total_batches, updated_at)
|
||||||
|
VALUES ('2026-08-22', 'CC1', 'karung-pakan', ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(counting_date, camera_name, object_label) DO UPDATE SET
|
||||||
|
total_count = excluded.total_count,
|
||||||
|
total_batches = excluded.total_batches,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
\"\"\", (total_count, total_batches))
|
||||||
|
|
||||||
|
process_date_2026_08_21()
|
||||||
|
process_date_2026_08_22()
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("Migration completed successfully!")
|
||||||
|
"""
|
||||||
|
b64 = base64.b64encode(code.encode()).decode()
|
||||||
|
stdin, stdout, stderr = client.exec_command(f"python3 -c \"import base64; exec(base64.b64decode('{b64}'))\"")
|
||||||
|
print("Migration stdout:\n", stdout.read().decode())
|
||||||
|
print("Migration stderr:\n", stderr.read().decode())
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
+1515
File diff suppressed because it is too large.
Load diff
+840
@@ -0,0 +1,840 @@
|
|||||||
|
import os
|
||||||
|
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp|threads;1|buffer_size;20480000|max_delay;500000|reorder_queue_size;500"
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from shapely.geometry import Point, Polygon, box
|
||||||
|
from ultralytics import YOLO
|
||||||
|
|
||||||
|
|
||||||
|
class RTSPBufferlessCapture:
|
||||||
|
"""Bufferless Capture using cap.grab() in main thread - 100% thread-safe on Windows."""
|
||||||
|
def __init__(self, source_path):
|
||||||
|
self.source_path = source_path
|
||||||
|
self.cap = cv2.VideoCapture(source_path, cv2.CAP_FFMPEG)
|
||||||
|
if self.cap.isOpened():
|
||||||
|
self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||||
|
self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
self.fps = self.cap.get(cv2.CAP_PROP_FPS)
|
||||||
|
else:
|
||||||
|
self.width, self.height, self.fps = 1920, 1080, 25.0
|
||||||
|
|
||||||
|
if self.fps <= 0 or np.isnan(self.fps):
|
||||||
|
self.fps = 25.0
|
||||||
|
|
||||||
|
def isOpened(self):
|
||||||
|
return self.cap is not None and self.cap.isOpened()
|
||||||
|
|
||||||
|
def get(self, propId):
|
||||||
|
if propId == cv2.CAP_PROP_FRAME_WIDTH:
|
||||||
|
return self.width
|
||||||
|
elif propId == cv2.CAP_PROP_FRAME_HEIGHT:
|
||||||
|
return self.height
|
||||||
|
elif propId == cv2.CAP_PROP_FPS:
|
||||||
|
return self.fps
|
||||||
|
elif self.cap is not None:
|
||||||
|
return self.cap.get(propId)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
if self.cap is None or not self.cap.isOpened():
|
||||||
|
return False, None
|
||||||
|
# Flush buffer to get latest live frame
|
||||||
|
self.cap.grab()
|
||||||
|
ret, frame = self.cap.retrieve()
|
||||||
|
if not ret or frame is None:
|
||||||
|
ret, frame = self.cap.read()
|
||||||
|
return ret, frame
|
||||||
|
|
||||||
|
def release(self):
|
||||||
|
if self.cap is not None:
|
||||||
|
self.cap.release()
|
||||||
|
self.cap = None
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# SYSTEM DATABASES AND CONFIGURATION FOR LIVE DASHBOARD
|
||||||
|
# =====================================================================
|
||||||
|
if os.name == 'nt':
|
||||||
|
_DEFAULT_DIR = "d:/Belajar/menghitung karung"
|
||||||
|
DB_PATH = os.getenv('DB_PATH', f"{_DEFAULT_DIR}/jetson_counter.db")
|
||||||
|
STATE_FILE = os.getenv('STATE_FILE', f"{_DEFAULT_DIR}/current_batch.json")
|
||||||
|
LIVE_STREAM_FRAME_PATH = os.getenv('LIVE_STREAM_FRAME_PATH', f"{_DEFAULT_DIR}/live_frame.jpg")
|
||||||
|
else:
|
||||||
|
_DEFAULT_DIR = "/opt/jetson-counter"
|
||||||
|
DB_PATH = os.getenv('DB_PATH', f"{_DEFAULT_DIR}/jetson_counter.db")
|
||||||
|
STATE_FILE = os.getenv('STATE_FILE', f"{_DEFAULT_DIR}/current_batch.json")
|
||||||
|
LIVE_STREAM_FRAME_PATH = os.getenv('LIVE_STREAM_FRAME_PATH', '/dev/shm/jetson-counter/live_frame.jpg')
|
||||||
|
|
||||||
|
CAMERA_NAME = os.getenv('CAMERA_NAME', 'CC1')
|
||||||
|
OBJECT_LABEL = os.getenv('OBJECT_LABEL', 'karung-pakan')
|
||||||
|
DAILY_CUTOFF_TIME = os.getenv('DAILY_CUTOFF_TIME', '20:00')
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS batches (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
counting_date TEXT NOT NULL,
|
||||||
|
batch_number INTEGER NOT NULL,
|
||||||
|
camera_name TEXT NOT NULL,
|
||||||
|
object_label TEXT NOT NULL,
|
||||||
|
count INTEGER NOT NULL,
|
||||||
|
start_time TEXT NOT NULL,
|
||||||
|
end_time TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(counting_date, batch_number, camera_name, object_label)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_summaries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
counting_date TEXT NOT NULL,
|
||||||
|
camera_name TEXT NOT NULL,
|
||||||
|
object_label TEXT NOT NULL,
|
||||||
|
total_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_batches INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(counting_date, camera_name, object_label)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DB Error] Gagal inisialisasi database: {e}")
|
||||||
|
|
||||||
|
def get_counting_date(dt=None, cutoff_str=DAILY_CUTOFF_TIME):
|
||||||
|
if dt is None:
|
||||||
|
dt = datetime.now()
|
||||||
|
try:
|
||||||
|
cutoff = datetime.strptime(cutoff_str, "%H:%M").time()
|
||||||
|
except Exception:
|
||||||
|
cutoff = datetime.strptime("20:00", "%H:%M").time()
|
||||||
|
|
||||||
|
if cutoff.hour == 0 and cutoff.minute == 0:
|
||||||
|
return dt.date().isoformat()
|
||||||
|
|
||||||
|
if dt.time() < cutoff:
|
||||||
|
return (dt.date() - timedelta(days=1)).isoformat()
|
||||||
|
return dt.date().isoformat()
|
||||||
|
|
||||||
|
def get_next_batch_number(date_str):
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COALESCE(MAX(batch_number), 0) + 1
|
||||||
|
FROM batches
|
||||||
|
WHERE counting_date = ? AND camera_name = ? AND object_label = ?
|
||||||
|
""", (date_str, CAMERA_NAME, OBJECT_LABEL))
|
||||||
|
num = cur.fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
return num
|
||||||
|
except Exception:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def finalize_batch(final_count, start_time_str, end_time_str, batch_num, counting_date):
|
||||||
|
try:
|
||||||
|
init_db()
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT OR REPLACE INTO batches (counting_date, batch_number, camera_name, object_label, count, start_time, end_time)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL, final_count, start_time_str, end_time_str))
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COALESCE(SUM(count), 0) as tot_count, COUNT(id) as tot_batches
|
||||||
|
FROM batches
|
||||||
|
WHERE counting_date = ? AND camera_name = ? AND object_label = ?
|
||||||
|
""", (counting_date, CAMERA_NAME, OBJECT_LABEL))
|
||||||
|
row = cur.fetchone()
|
||||||
|
tot_count = row[0]
|
||||||
|
tot_batches = row[1]
|
||||||
|
cur.execute("""
|
||||||
|
INSERT OR REPLACE INTO daily_summaries (counting_date, camera_name, object_label, total_count, total_batches, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
""", (counting_date, CAMERA_NAME, OBJECT_LABEL, tot_count, tot_batches))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"[DB Info] Sesi batch #{batch_num} disimpan ke database SQLite: {final_count} karung.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DB Error] Gagal menyimpan batch ke database: {e}")
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 0. PARAMETER KONFIGURASI KALIBRASI (RANCANGAN BRAIN-STORMING)
|
||||||
|
# =====================================================================
|
||||||
|
CAMERA_NOISE_DEADBAND = 5 # Filter getaran kamera (pixel)
|
||||||
|
JARAK_TOLERANSI_DUPLIKAT = 80 # Jarak spasial maksimal untuk anti-double check (pixel)
|
||||||
|
TOLERANSI_FRAME_HILANG = 120 # Frame timeout untuk Re-ID lost track
|
||||||
|
MAX_REID_TRANSIT_DISTANCE = 400 # Jarak dasar pencarian Re-ID (pixel)
|
||||||
|
MAX_REID_FRAMES = 120 # Frame maks untuk memulihkan ID yang hilang
|
||||||
|
CONFIRM_DELAY_SEC = 0.5 # Delay debounce statis sebelum dihitung (detik)
|
||||||
|
MAX_STATIC_SPEED = 80.0 # Batas kecepatan maks untuk dikategorikan statis (px/s)
|
||||||
|
INFERENCE_STRIDE = 2 # Frame skipping (1 = proses semua, 2 = skip 1 frame)
|
||||||
|
|
||||||
|
# --- Path Model ---
|
||||||
|
TRUCK_MODEL_PATH = "truck-detector.pt"
|
||||||
|
SACK_MODEL_PATH = "karung-dimuat-detection-di-feedmill-yolo26n-seg-200e.pt"
|
||||||
|
|
||||||
|
# --- Konstanta State Machine ---
|
||||||
|
STATE_WAITING_FOR_TRUCK = "WAITING_FOR_TRUCK"
|
||||||
|
STATE_COUNTING_SACKS = "COUNTING_SACKS"
|
||||||
|
STATE_TRUCK_LEAVING = "TRUCK_LEAVING"
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 1. UTILITY AKURASI (VISUAL SIMILARITY & PERSPECTIVE PROFILE)
|
||||||
|
# =====================================================================
|
||||||
|
def get_visual_features(crop):
|
||||||
|
"""Mengekstrak fitur visual berupa histogram HSV (warna) dan grayscale image (struktur/tekstur) dari crop karung."""
|
||||||
|
if crop is None or crop.size == 0:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
resized = cv2.resize(crop, (64, 64))
|
||||||
|
# 1. Color Profile: HSV Hist
|
||||||
|
hsv = cv2.cvtColor(resized, cv2.COLOR_BGR2HSV)
|
||||||
|
hist = cv2.calcHist([hsv], [0, 1], None, [16, 16], [0, 180, 0, 256])
|
||||||
|
cv2.normalize(hist, hist, 0, 1, cv2.NORM_MINMAX)
|
||||||
|
|
||||||
|
# 2. Structural Profile: Grayscale NCC
|
||||||
|
gray = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY)
|
||||||
|
return hist, gray
|
||||||
|
except Exception as e:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def compare_visual_similarity(feat1, feat2):
|
||||||
|
"""Membandingkan kemiripan visual karung (gabungan korelasi warna HSV 60% dan struktur grayscale NCC 40%)."""
|
||||||
|
if feat1 is None or feat2 is None:
|
||||||
|
return 0.0
|
||||||
|
hist1, gray1 = feat1
|
||||||
|
hist2, gray2 = feat2
|
||||||
|
if hist1 is None or hist2 is None or gray1 is None or gray2 is None:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
# Kemiripan Warna HSV
|
||||||
|
color_sim = cv2.compareHist(hist1, hist2, cv2.HISTCMP_CORREL)
|
||||||
|
color_sim = max(0.0, color_sim) if not np.isnan(color_sim) else 0.0
|
||||||
|
|
||||||
|
# Kemiripan Struktur Grayscale NCC
|
||||||
|
res = cv2.matchTemplate(gray1, gray2, cv2.TM_CCOEFF_NORMED)
|
||||||
|
struct_sim = max(0.0, res[0][0]) if not np.isnan(res[0][0]) else 0.0
|
||||||
|
|
||||||
|
return 0.6 * color_sim + 0.4 * struct_sim
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def get_min_valid_area(cy, scale_x=1.0, scale_y=1.0):
|
||||||
|
"""Menghitung batas luas area minimum secara dinamis berdasarkan perspektif Y (Interpolasi Linier)."""
|
||||||
|
top_y = 200 * scale_y
|
||||||
|
top_area = 8000 * scale_x * scale_y
|
||||||
|
bot_y = 1080 * scale_y
|
||||||
|
bot_area = 25000 * scale_x * scale_y
|
||||||
|
|
||||||
|
if cy <= top_y:
|
||||||
|
return top_area
|
||||||
|
if cy >= bot_y:
|
||||||
|
return bot_area
|
||||||
|
ratio = (cy - top_y) / (bot_y - top_y)
|
||||||
|
return top_area + ratio * (bot_area - top_area)
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 2. SISTEM DEBOUNCE STATIS & PENYARING DUPLIKAT SPASIAL-VISUAL
|
||||||
|
# =====================================================================
|
||||||
|
class SackCounterPipeline:
|
||||||
|
def __init__(self, output_json_path="hasil_perhitungan.json"):
|
||||||
|
self.output_json_path = output_json_path
|
||||||
|
self.system_state = STATE_WAITING_FOR_TRUCK
|
||||||
|
|
||||||
|
# Area Deteksi (Poligon Shapely)
|
||||||
|
self.poly_truck = None
|
||||||
|
self.poly_palet = None # Ditentukan manual jika zones.json dimuat
|
||||||
|
|
||||||
|
# State Monitoring Truk
|
||||||
|
self.truck_initial_bbox = None
|
||||||
|
self.truck_static_frames = 0
|
||||||
|
|
||||||
|
# Tracking Karung Aktif
|
||||||
|
self.static_frames = defaultdict(int)
|
||||||
|
self.moving_frames = defaultdict(int)
|
||||||
|
self.already_counted = defaultdict(bool)
|
||||||
|
self.blocked_without_counting = defaultdict(bool)
|
||||||
|
self.track_positions = defaultdict(lambda: deque(maxlen=30))
|
||||||
|
self.track_areas = defaultdict(float)
|
||||||
|
self.track_is_valid_bag = defaultdict(bool)
|
||||||
|
self.track_visited_palet = defaultdict(bool) # --- TAMBAHAN BARU: LINE CROSSING TRACKER ---
|
||||||
|
|
||||||
|
# Registry Visual Karung Terhitung (Anti-Double Count)
|
||||||
|
self.static_sack_visuals = {} # track_id -> (hist, gray)
|
||||||
|
|
||||||
|
# Re-ID Lost Tracks
|
||||||
|
self.lost_tracks = {} # lost_id -> dict properties
|
||||||
|
|
||||||
|
# Metrik Penghitungan Batch
|
||||||
|
self.total_masuk = 0
|
||||||
|
self.total_keluar = 0
|
||||||
|
self.entry_points = {} # track_id -> (ex, ey)
|
||||||
|
|
||||||
|
# Database & Active State initialization
|
||||||
|
init_db()
|
||||||
|
self.counting_date = get_counting_date()
|
||||||
|
self.batch_number = get_next_batch_number(self.counting_date)
|
||||||
|
self.start_time = datetime.now().isoformat()
|
||||||
|
self.last_detection_time = self.start_time
|
||||||
|
self.save_active_batch_state()
|
||||||
|
|
||||||
|
def save_active_batch_state(self):
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
|
||||||
|
state_data = {
|
||||||
|
"counting_date": self.counting_date,
|
||||||
|
"batch_number": self.batch_number,
|
||||||
|
"count": self.total_masuk,
|
||||||
|
"start_time": self.start_time,
|
||||||
|
"last_detection_time": self.last_detection_time
|
||||||
|
}
|
||||||
|
with open(STATE_FILE, 'w') as f:
|
||||||
|
json.dump(state_data, f, indent=4)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def clear_active_batch_state(self):
|
||||||
|
try:
|
||||||
|
if os.path.exists(STATE_FILE):
|
||||||
|
os.remove(STATE_FILE)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def reset_batch(self):
|
||||||
|
"""Reset state tracking dan counter untuk memulai batch truk baru."""
|
||||||
|
self.static_frames.clear()
|
||||||
|
self.moving_frames.clear()
|
||||||
|
self.already_counted.clear()
|
||||||
|
self.blocked_without_counting.clear()
|
||||||
|
self.track_positions.clear()
|
||||||
|
self.track_areas.clear()
|
||||||
|
self.track_is_valid_bag.clear()
|
||||||
|
self.track_visited_palet.clear()
|
||||||
|
self.static_sack_visuals.clear()
|
||||||
|
self.lost_tracks.clear()
|
||||||
|
self.entry_points.clear()
|
||||||
|
self.total_masuk = 0
|
||||||
|
self.total_keluar = 0
|
||||||
|
self.counting_date = get_counting_date()
|
||||||
|
self.batch_number = get_next_batch_number(self.counting_date)
|
||||||
|
self.start_time = datetime.now().isoformat()
|
||||||
|
self.last_detection_time = self.start_time
|
||||||
|
self.save_active_batch_state()
|
||||||
|
|
||||||
|
def save_batch_report(self):
|
||||||
|
"""Menulis file laporan batch JSON ketika truk meninggalkan area."""
|
||||||
|
timestamp_str = time.strftime("%Y%m%d_%H%M%S")
|
||||||
|
batch_file = f"batch_{timestamp_str}.json"
|
||||||
|
report_data = {
|
||||||
|
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"total_masuk_truck": self.total_masuk,
|
||||||
|
"total_keluar_truck": self.total_keluar,
|
||||||
|
"net_karung_di_truck": self.total_masuk - self.total_keluar
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with open(batch_file, 'w') as f:
|
||||||
|
json.dump(report_data, f, indent=4)
|
||||||
|
print(f"\n[REPORT] Laporan Batch disimpan ke: {batch_file}")
|
||||||
|
|
||||||
|
# Update juga file output kumulatif
|
||||||
|
with open(self.output_json_path, 'w') as f:
|
||||||
|
json.dump(report_data, f, indent=4)
|
||||||
|
|
||||||
|
# Simpan ke SQLite database dan bersihkan berkas state aktif
|
||||||
|
finalize_batch(self.total_masuk, self.start_time, datetime.now().isoformat(), self.batch_number, self.counting_date)
|
||||||
|
self.clear_active_batch_state()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Gagal menyimpan laporan batch: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 3. PIPELINE PREDIKSI UTAMA (DUO-MODEL PIPELINE)
|
||||||
|
# =====================================================================
|
||||||
|
def run_prediction(source_path, max_frames=None, save_output_video=True, show_live=True):
|
||||||
|
print("=" * 60)
|
||||||
|
print("AI SACK COUNTER PIPELINE - DIKEMBANGKAN DARI AWAL (BRAIN-STORMING)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 1. Load Model
|
||||||
|
print("[INFO] Model Truk dinonaktifkan (area truk di-hardcode)...")
|
||||||
|
model_truck = None
|
||||||
|
print(f"[INFO] Memuat Model Karung: {SACK_MODEL_PATH}...")
|
||||||
|
model_sack = YOLO(SACK_MODEL_PATH)
|
||||||
|
|
||||||
|
# Deteksi otomatis ID kelas karung dan pekerja
|
||||||
|
global sack_class_id, person_class_id
|
||||||
|
sack_class_id = 1
|
||||||
|
person_class_id = 0
|
||||||
|
if hasattr(model_sack, 'names') and model_sack.names:
|
||||||
|
for cid, name in model_sack.names.items():
|
||||||
|
name_str = str(name).lower()
|
||||||
|
if any(w in name_str for w in ['karung', 'cuval', 'sack', 'bag']):
|
||||||
|
sack_class_id = int(cid)
|
||||||
|
elif any(w in name_str for w in ['person', 'human', 'pekerja', 'manusia']):
|
||||||
|
person_class_id = int(cid)
|
||||||
|
print(f"[INFO] Auto-detected Kelas: Karung ID = {sack_class_id}, Pekerja ID = {person_class_id}")
|
||||||
|
|
||||||
|
# 2. Buka Video Input (Threaded untuk RTSP stream, direct untuk file lokal)
|
||||||
|
is_stream = any(str(source_path).startswith(p) for p in ["rtsp://", "rtmp://", "http://", "https://"])
|
||||||
|
if is_stream:
|
||||||
|
print(f"[INFO] Membuka RTSP Stream menggunakan RTSPBufferlessCapture: {source_path}")
|
||||||
|
cap = RTSPBufferlessCapture(source_path)
|
||||||
|
else:
|
||||||
|
print(f"[INFO] Membuka file video lokal: {source_path}")
|
||||||
|
cap = cv2.VideoCapture(source_path)
|
||||||
|
|
||||||
|
if not cap.isOpened():
|
||||||
|
print(f"[ERROR] Gagal membuka video source: {source_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||||
|
if fps <= 0 or np.isnan(fps):
|
||||||
|
fps = 25.0
|
||||||
|
|
||||||
|
# Scale faktor terhadap resolusi dasar 1920x1080
|
||||||
|
scale_x = width / 1920.0
|
||||||
|
scale_y = height / 1080.0
|
||||||
|
|
||||||
|
# Setup Video Writer (jika diaktifkan)
|
||||||
|
writer = None
|
||||||
|
if save_output_video:
|
||||||
|
output_name = "annotated_output.mp4"
|
||||||
|
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
||||||
|
writer = cv2.VideoWriter(output_name, fourcc, fps, (width, height))
|
||||||
|
print(f"[INFO] Output video akan disimpan ke: {output_name}")
|
||||||
|
|
||||||
|
# Inisialisasi Pipeline State
|
||||||
|
pipeline = SackCounterPipeline()
|
||||||
|
# Mulai langsung di mode penghitungan (tidak perlu mendeteksi truk)
|
||||||
|
pipeline.system_state = STATE_COUNTING_SACKS
|
||||||
|
|
||||||
|
# Set default area palet dari pengguna (menggunakan koordinat referensi 1920x1080)
|
||||||
|
default_palet_pts = np.array([[514, 437], [1112, 439], [1112, 818], [500, 817]], dtype=np.int32)
|
||||||
|
scaled_palet_pts = np.array([[int(p[0] * scale_x), int(p[1] * scale_y)] for p in default_palet_pts], dtype=np.int32)
|
||||||
|
pipeline.poly_palet = Polygon(scaled_palet_pts)
|
||||||
|
print(f"[INFO] Poligon Zona Palet berhasil diinisialisasi: {scaled_palet_pts.tolist()}")
|
||||||
|
|
||||||
|
# Set default area truk dari pengguna (menggunakan koordinat referensi 1920x1080)
|
||||||
|
default_truck_pts = np.array([[566, 1], [547, 496], [1090, 502], [1072, 5]], dtype=np.int32)
|
||||||
|
scaled_truck_pts = np.array([[int(p[0] * scale_x), int(p[1] * scale_y)] for p in default_truck_pts], dtype=np.int32)
|
||||||
|
pipeline.poly_truck = Polygon(scaled_truck_pts)
|
||||||
|
print(f"[INFO] Poligon Zona Truk (Hardcoded) berhasil diinisialisasi: {scaled_truck_pts.tolist()}")
|
||||||
|
|
||||||
|
# Muat zones.json default jika ada untuk override
|
||||||
|
if os.path.exists("zones.json"):
|
||||||
|
try:
|
||||||
|
with open("zones.json", 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if 'palet' in data and len(data['palet']) >= 3:
|
||||||
|
pts = np.array([[int(p[0] * scale_x), int(p[1] * scale_y)] for p in data['palet']], dtype=np.int32)
|
||||||
|
pipeline.poly_palet = Polygon(pts)
|
||||||
|
print("[INFO] Poligon Zona Palet berhasil dimuat dari zones.json (override)")
|
||||||
|
if 'truck' in data and len(data['truck']) >= 3:
|
||||||
|
pts = np.array([[int(p[0] * scale_x), int(p[1] * scale_y)] for p in data['truck']], dtype=np.int32)
|
||||||
|
pipeline.poly_truck = Polygon(pts)
|
||||||
|
print("[INFO] Poligon Zona Truk berhasil dimuat dari zones.json (override)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARNING] Gagal memuat zones.json: {e}")
|
||||||
|
|
||||||
|
frame_idx = 0
|
||||||
|
last_time = time.time()
|
||||||
|
current_fps = 0.0
|
||||||
|
|
||||||
|
while cap.isOpened():
|
||||||
|
ret, frame = cap.read()
|
||||||
|
if not ret:
|
||||||
|
break
|
||||||
|
|
||||||
|
frame_idx += 1
|
||||||
|
if max_frames is not None and frame_idx > max_frames:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Hitung durasi interval frame aktual untuk kompensasi FPS rendah
|
||||||
|
dt = (INFERENCE_STRIDE / fps) if fps > 0 else 0.04
|
||||||
|
required_frames = max(1, int(CONFIRM_DELAY_SEC * fps))
|
||||||
|
required_static_updates = max(1, int(required_frames / INFERENCE_STRIDE))
|
||||||
|
|
||||||
|
# Bbox list untuk HUD visualizer
|
||||||
|
visual_bboxes = []
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# STATE MACHINE LOGIC
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
# STATE 1: WAITING_FOR_TRUCK
|
||||||
|
if pipeline.system_state == STATE_WAITING_FOR_TRUCK:
|
||||||
|
if model_truck is None:
|
||||||
|
pipeline.system_state = STATE_COUNTING_SACKS
|
||||||
|
continue
|
||||||
|
res_truck = model_truck(frame, conf=0.5, verbose=False)
|
||||||
|
best_box = None
|
||||||
|
best_conf = -1.0
|
||||||
|
|
||||||
|
if res_truck[0].boxes is not None and len(res_truck[0].boxes) > 0:
|
||||||
|
for box_obj in res_truck[0].boxes:
|
||||||
|
conf = float(box_obj.conf[0].cpu().item())
|
||||||
|
if conf > best_conf:
|
||||||
|
best_conf = conf
|
||||||
|
best_box = box_obj.xyxy[0].cpu().numpy()
|
||||||
|
|
||||||
|
if best_box is not None:
|
||||||
|
x1_t, y1_t, x2_t, y2_t = best_box
|
||||||
|
cx_t = int((x1_t + x2_t) / 2)
|
||||||
|
cy_t = int((y1_t + y2_t) / 2)
|
||||||
|
|
||||||
|
# Cek stabilitas posisi truk
|
||||||
|
if pipeline.truck_initial_bbox is None:
|
||||||
|
pipeline.truck_initial_bbox = best_box
|
||||||
|
pipeline.truck_static_frames = 0
|
||||||
|
else:
|
||||||
|
cx_old = int((pipeline.truck_initial_bbox[0] + pipeline.truck_initial_bbox[2]) / 2)
|
||||||
|
cy_old = int((pipeline.truck_initial_bbox[1] + pipeline.truck_initial_bbox[3]) / 2)
|
||||||
|
disp = np.sqrt((cx_t - cx_old)**2 + (cy_t - cy_old)**2)
|
||||||
|
|
||||||
|
if disp < CAMERA_NOISE_DEADBAND:
|
||||||
|
pipeline.truck_static_frames += 1
|
||||||
|
else:
|
||||||
|
pipeline.truck_initial_bbox = best_box
|
||||||
|
pipeline.truck_static_frames = 0
|
||||||
|
|
||||||
|
# Truk dianggap berhenti jika stabil selama 45 frame (~1.5s)
|
||||||
|
if pipeline.truck_static_frames >= 45:
|
||||||
|
# Kunci area truk dengan margin aman 5% ke dalam bak
|
||||||
|
w_t = x2_t - x1_t
|
||||||
|
h_t = y2_t - y1_t
|
||||||
|
x1_t += w_t * 0.05
|
||||||
|
x2_t -= w_t * 0.05
|
||||||
|
y1_t += h_t * 0.05
|
||||||
|
y2_t -= h_t * 0.05
|
||||||
|
|
||||||
|
pts_truck = np.array([[x1_t, y1_t], [x2_t, y1_t], [x2_t, y2_t], [x1_t, y2_t]], dtype=np.int32)
|
||||||
|
pipeline.poly_truck = Polygon(pts_truck)
|
||||||
|
|
||||||
|
# Reset data untuk batch baru
|
||||||
|
pipeline.reset_batch()
|
||||||
|
pipeline.system_state = STATE_COUNTING_SACKS
|
||||||
|
print(f"\n[STATE] Truk diam terkunci di koordinat: {best_box}. Mulai menghitung karung...")
|
||||||
|
|
||||||
|
# Append box truk ke visualizer
|
||||||
|
visual_bboxes.append({
|
||||||
|
"bbox": [int(x1_t), int(y1_t), int(x2_t), int(y2_t)],
|
||||||
|
"label": f"MONITORING TRUK: {pipeline.truck_static_frames}/45",
|
||||||
|
"color": (0, 204, 255),
|
||||||
|
"thick": 3
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
pipeline.truck_initial_bbox = None
|
||||||
|
pipeline.truck_static_frames = 0
|
||||||
|
|
||||||
|
# STATE 3: TRUCK_LEAVING
|
||||||
|
elif pipeline.system_state == STATE_TRUCK_LEAVING:
|
||||||
|
pipeline.save_batch_report()
|
||||||
|
pipeline.poly_truck = None
|
||||||
|
pipeline.truck_initial_bbox = None
|
||||||
|
pipeline.truck_static_frames = 0
|
||||||
|
pipeline.system_state = STATE_WAITING_FOR_TRUCK
|
||||||
|
|
||||||
|
# STATE 2: COUNTING_SACKS
|
||||||
|
elif pipeline.system_state == STATE_COUNTING_SACKS:
|
||||||
|
# Pengecekan keberadaan truk dinonaktifkan (area truk di-hardcode)
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Jalankan Tracker Karung dan Pekerja (Inference Stride)
|
||||||
|
if INFERENCE_STRIDE <= 1 or frame_idx % INFERENCE_STRIDE == 0 or 'last_results' not in locals():
|
||||||
|
results_sack = model_sack.track(frame, persist=True, tracker="bytetrack.yaml", conf=0.05, classes=[person_class_id, sack_class_id], verbose=False)
|
||||||
|
last_results = results_sack
|
||||||
|
else:
|
||||||
|
results_sack = last_results
|
||||||
|
|
||||||
|
current_active_ids = set()
|
||||||
|
if results_sack[0].boxes.id is not None:
|
||||||
|
boxes = results_sack[0].boxes.xyxy.cpu().numpy()
|
||||||
|
track_ids = results_sack[0].boxes.id.int().cpu().numpy()
|
||||||
|
classes_ids = results_sack[0].boxes.cls.int().cpu().numpy()
|
||||||
|
|
||||||
|
for box_coord, track_id, cls_id in zip(boxes, track_ids, classes_ids):
|
||||||
|
# Jika terdeteksi sebagai pekerja/manusia, gambarkan bbox merah dan lewati logika hitung
|
||||||
|
if cls_id == person_class_id:
|
||||||
|
if save_output_video:
|
||||||
|
x1, y1, x2, y2 = box_coord
|
||||||
|
cv2.rectangle(frame, (int(x1), int(y1)), (int(x2), int(y2)), (0, 0, 255), 2)
|
||||||
|
cv2.putText(frame, f"PEKERJA #{track_id}", (int(x1), int(y1) - 8),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
x1, y1, x2, y2 = box_coord
|
||||||
|
cx = int((x1 + x2) / 2)
|
||||||
|
cy = int((y1 + y2) / 2)
|
||||||
|
box_area = (x2 - x1) * (y2 - y1)
|
||||||
|
pt = Point(cx, cy)
|
||||||
|
|
||||||
|
# 1. Filter Perspektif Adaptif (Perspective Profile)
|
||||||
|
min_area_thresh = get_min_valid_area(cy, scale_x, scale_y)
|
||||||
|
is_fragment = (box_area < min_area_thresh) and not pipeline.track_is_valid_bag[track_id]
|
||||||
|
|
||||||
|
if is_fragment:
|
||||||
|
# Abaikan objek kecil/sampah yang terdeteksi
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
pipeline.track_is_valid_bag[track_id] = True
|
||||||
|
|
||||||
|
current_active_ids.add(track_id)
|
||||||
|
pipeline.track_positions[track_id].append((cx, cy))
|
||||||
|
pipeline.track_areas[track_id] = box_area
|
||||||
|
|
||||||
|
# 2. Cek Re-ID Lost Tracks (Dynamic Search Window)
|
||||||
|
if len(pipeline.track_positions[track_id]) == 1:
|
||||||
|
# Jika baru muncul, coba pulihkan dari registry lost track
|
||||||
|
closest_old_id = None
|
||||||
|
min_d = float('inf')
|
||||||
|
for old_id, info in pipeline.lost_tracks.items():
|
||||||
|
frame_diff = frame_idx - info['frame_idx']
|
||||||
|
if frame_diff > MAX_REID_FRAMES:
|
||||||
|
continue
|
||||||
|
lc = info['last_centroid']
|
||||||
|
dist_reid = np.sqrt((cx - lc[0])**2 + (cy - lc[1])**2)
|
||||||
|
|
||||||
|
# Jendela pencarian melebar seiring pertambahan frame drop (Kompensasi Lag FPS)
|
||||||
|
dynamic_search_radius = MAX_REID_TRANSIT_DISTANCE * (1.0 + 0.01 * frame_diff)
|
||||||
|
if dist_reid < dynamic_search_radius:
|
||||||
|
if dist_reid < min_d:
|
||||||
|
min_d = dist_reid
|
||||||
|
closest_old_id = old_id
|
||||||
|
|
||||||
|
if closest_old_id is not None:
|
||||||
|
# Pulihkan state data track lama
|
||||||
|
old_info = pipeline.lost_tracks[closest_old_id]
|
||||||
|
pipeline.already_counted[track_id] = old_info['already_counted']
|
||||||
|
pipeline.blocked_without_counting[track_id] = old_info['blocked_without_counting']
|
||||||
|
pipeline.static_frames[track_id] = old_info['static_frames']
|
||||||
|
pipeline.track_visited_palet[track_id] = old_info.get('visited_palet', False)
|
||||||
|
if old_info['already_counted'] and closest_old_id in pipeline.static_sack_visuals:
|
||||||
|
pipeline.static_sack_visuals[track_id] = pipeline.static_sack_visuals[closest_old_id]
|
||||||
|
del pipeline.lost_tracks[closest_old_id]
|
||||||
|
print(f"[RE-ID] Tracker #{track_id} berhasil dipulihkan dari ID lama #{closest_old_id}")
|
||||||
|
|
||||||
|
# 3. Hitung Vektor Kecepatan & Debounce Statis (Velocity Filtering)
|
||||||
|
speed = 0.0
|
||||||
|
if len(pipeline.track_positions[track_id]) > 1:
|
||||||
|
prev_cx, prev_cy = pipeline.track_positions[track_id][-2]
|
||||||
|
disp = np.sqrt((cx - prev_cx)**2 + (cy - prev_cy)**2)
|
||||||
|
|
||||||
|
# Filter getaran kamera (Noise Deadband)
|
||||||
|
if disp < CAMERA_NOISE_DEADBAND:
|
||||||
|
disp = 0.0
|
||||||
|
if dt > 0:
|
||||||
|
speed = disp / dt
|
||||||
|
|
||||||
|
# Update status gerak
|
||||||
|
if speed < MAX_STATIC_SPEED:
|
||||||
|
pipeline.static_frames[track_id] += 1
|
||||||
|
pipeline.moving_frames[track_id] = 0
|
||||||
|
else:
|
||||||
|
pipeline.static_frames[track_id] = 0
|
||||||
|
pipeline.moving_frames[track_id] += 1
|
||||||
|
|
||||||
|
# Deteksi zona aktual centroid
|
||||||
|
in_truck_polygon = pipeline.poly_truck is not None and pipeline.poly_truck.contains(pt)
|
||||||
|
in_palet_polygon = pipeline.poly_palet is not None and pipeline.poly_palet.contains(pt)
|
||||||
|
|
||||||
|
# Logika Perhitungan Sederhana: Bergerak > 50px dari Titik Masuk Area Truk
|
||||||
|
if in_truck_polygon:
|
||||||
|
if track_id not in pipeline.entry_points:
|
||||||
|
pipeline.entry_points[track_id] = (cx, cy)
|
||||||
|
pipeline.already_counted[track_id] = False
|
||||||
|
|
||||||
|
if track_id in pipeline.entry_points:
|
||||||
|
if not pipeline.already_counted[track_id]:
|
||||||
|
ex, ey = pipeline.entry_points[track_id]
|
||||||
|
dist_from_entry = np.sqrt((cx - ex)**2 + (cy - ey)**2)
|
||||||
|
if dist_from_entry > 50:
|
||||||
|
pipeline.total_masuk += 1
|
||||||
|
pipeline.already_counted[track_id] = True
|
||||||
|
pipeline.last_detection_time = datetime.now().isoformat()
|
||||||
|
pipeline.save_active_batch_state()
|
||||||
|
print(f"[COUNTER] Karung #{track_id} terhitung masuk! (Jarak gerak: {dist_from_entry:.1f}px > 50px). Total: {pipeline.total_masuk}")
|
||||||
|
|
||||||
|
# 5. Penentuan Kategori Label Visual HUD
|
||||||
|
if pipeline.blocked_without_counting[track_id]:
|
||||||
|
color = (128, 128, 128) # Abu-abu
|
||||||
|
label = f"DUPLIKAT #{track_id}"
|
||||||
|
elif pipeline.already_counted[track_id]:
|
||||||
|
color = (0, 255, 0) # Hijau terang
|
||||||
|
label = f"VERIFIED #{track_id}"
|
||||||
|
elif in_truck_polygon:
|
||||||
|
if speed >= MAX_STATIC_SPEED:
|
||||||
|
color = (0, 255, 255) # Kuning
|
||||||
|
label = f"TRANSIT #{track_id} ({speed:.0f}px/s)"
|
||||||
|
else:
|
||||||
|
color = (0, 165, 255) # Oranye
|
||||||
|
label = f"NEW_STATIC #{track_id} ({pipeline.static_frames[track_id]}/{required_static_updates})"
|
||||||
|
elif in_palet_polygon:
|
||||||
|
color = (255, 255, 0) # Cyan
|
||||||
|
label = f"PALET #{track_id}"
|
||||||
|
else:
|
||||||
|
color = (255, 0, 255) # Magenta
|
||||||
|
label = f"SACK #{track_id}"
|
||||||
|
|
||||||
|
# Tampilkan bounding box, titik tengah, dan label di frame
|
||||||
|
if True:
|
||||||
|
cv2.rectangle(frame, (int(x1), int(y1)), (int(x2), int(y2)), color, 2)
|
||||||
|
cv2.putText(frame, label, (int(x1), int(y1) - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
|
||||||
|
|
||||||
|
# 1. Gambar Point (Titik Tengah BBox)
|
||||||
|
cv2.circle(frame, (cx, cy), 5, (0, 255, 255), -1)
|
||||||
|
|
||||||
|
# 2. Menggambar titik acuan masuk, radius 50px, dan indikator perpindahan
|
||||||
|
if track_id in pipeline.entry_points:
|
||||||
|
ex, ey = pipeline.entry_points[track_id]
|
||||||
|
is_counted = pipeline.already_counted[track_id]
|
||||||
|
|
||||||
|
# Warna: Hijau jika terhitung (>50px), Oranye jika masih di dalam radius 50px
|
||||||
|
viz_color = (0, 255, 0) if is_counted else (0, 140, 255)
|
||||||
|
|
||||||
|
# Gambar Titik Acuan Awal saat Masuk Area Truk
|
||||||
|
cv2.circle(frame, (ex, ey), 4, viz_color, -1)
|
||||||
|
|
||||||
|
# Gambar Lingkaran Radius 50px
|
||||||
|
cv2.circle(frame, (ex, ey), 50, viz_color, 2, lineType=cv2.LINE_AA)
|
||||||
|
|
||||||
|
# Gambar garis hubung dari titik awal ke titik bbox saat ini
|
||||||
|
cv2.line(frame, (ex, ey), (cx, cy), viz_color, 1)
|
||||||
|
|
||||||
|
# Tampilkan label status jarak
|
||||||
|
dist_val = np.sqrt((cx - ex)**2 + (cy - ey)**2)
|
||||||
|
dist_label = f"COUNTED (+1)" if is_counted else f"{dist_val:.0f}/50px"
|
||||||
|
cv2.putText(frame, dist_label, (ex - 20, ey - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.45, viz_color, 2)
|
||||||
|
|
||||||
|
# Daftarkan track yang hilang pada frame ini ke registry Re-ID
|
||||||
|
for old_id in list(pipeline.track_positions.keys()):
|
||||||
|
if old_id not in current_active_ids:
|
||||||
|
# Masukkan ke lost tracks
|
||||||
|
if len(pipeline.track_positions[old_id]) > 0:
|
||||||
|
pipeline.lost_tracks[old_id] = {
|
||||||
|
"frame_idx": frame_idx,
|
||||||
|
"last_centroid": pipeline.track_positions[old_id][-1],
|
||||||
|
"already_counted": pipeline.already_counted[old_id],
|
||||||
|
"blocked_without_counting": pipeline.blocked_without_counting[old_id],
|
||||||
|
"static_frames": pipeline.static_frames[old_id],
|
||||||
|
"visited_palet": pipeline.track_visited_palet[old_id],
|
||||||
|
"positions": pipeline.track_positions[old_id].copy()
|
||||||
|
}
|
||||||
|
# Bersihkan dari tracker aktif
|
||||||
|
pipeline.track_positions.pop(old_id, None)
|
||||||
|
pipeline.static_frames.pop(old_id, None)
|
||||||
|
pipeline.moving_frames.pop(old_id, None)
|
||||||
|
pipeline.track_visited_palet.pop(old_id, None)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# RENDER PREMIUM HUD OVERLAY (BURNT INTO FRAME)
|
||||||
|
# =====================================================================
|
||||||
|
if True:
|
||||||
|
# 1. Gambar Batas Zona
|
||||||
|
if pipeline.poly_palet is not None:
|
||||||
|
pts = np.array(pipeline.poly_palet.exterior.coords, dtype=np.int32)
|
||||||
|
cv2.polylines(frame, [pts], True, (255, 255, 0), 2)
|
||||||
|
cv2.putText(frame, "ZONA PALET", (pts[0][0], pts[0][1] - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 0), 2)
|
||||||
|
|
||||||
|
if pipeline.poly_truck is not None:
|
||||||
|
pts = np.array(pipeline.poly_truck.exterior.coords, dtype=np.int32)
|
||||||
|
cv2.polylines(frame, [pts], True, (0, 204, 255), 2)
|
||||||
|
cv2.putText(frame, "ZONA TRUK BATCH", (pts[0][0], pts[0][1] - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 204, 255), 2)
|
||||||
|
|
||||||
|
# 2. Gambar Background HUD Panel (Top-Left)
|
||||||
|
# HUD Glassmorphic Rectangle
|
||||||
|
overlay = frame.copy()
|
||||||
|
cv2.rectangle(overlay, (20, 20), (450, 180), (15, 17, 24), -1)
|
||||||
|
cv2.addWeighted(overlay, 0.75, frame, 0.25, 0, frame)
|
||||||
|
cv2.rectangle(frame, (20, 20), (450, 180), (255, 255, 255), 1, lineType=cv2.LINE_AA)
|
||||||
|
|
||||||
|
# Text HUD info
|
||||||
|
cv2.putText(frame, "AI SACK COUNTER PIPELINE v2.0", (35, 45), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 240, 255), 2)
|
||||||
|
cv2.line(frame, (35, 55), (435, 55), (100, 100, 100), 1)
|
||||||
|
|
||||||
|
# State System
|
||||||
|
state_color = (0, 255, 0) if pipeline.system_state == STATE_COUNTING_SACKS else (0, 204, 255)
|
||||||
|
cv2.putText(frame, f"STATUS: {pipeline.system_state}", (35, 80), cv2.FONT_HERSHEY_SIMPLEX, 0.5, state_color, 2)
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
cv2.putText(frame, f"TOTAL MASUK : {pipeline.total_masuk}", (35, 115), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
|
||||||
|
cv2.putText(frame, f"TOTAL KELUAR : {pipeline.total_keluar}", (35, 145), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
|
||||||
|
|
||||||
|
# FPS & Frame counter
|
||||||
|
if frame_idx % 25 == 0:
|
||||||
|
elapsed = time.time() - last_time
|
||||||
|
current_fps = 25.0 / elapsed if elapsed > 0 else 0.0
|
||||||
|
last_time = time.time()
|
||||||
|
cv2.putText(frame, f"FPS: {current_fps:.1f} | Frame: {frame_idx}", (35, 168), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (200, 200, 200), 1)
|
||||||
|
|
||||||
|
# Write annotated frame to output video file if enabled
|
||||||
|
if save_output_video and writer is not None:
|
||||||
|
writer.write(frame)
|
||||||
|
|
||||||
|
# Write live frame to shared memory RAM disk for dashboard streaming (every 2 frames)
|
||||||
|
if frame_idx % 2 == 0:
|
||||||
|
try:
|
||||||
|
live_path = LIVE_STREAM_FRAME_PATH
|
||||||
|
os.makedirs(os.path.dirname(live_path), exist_ok=True)
|
||||||
|
tmp_path = live_path.replace(".jpg", ".tmp.jpg")
|
||||||
|
cv2.imwrite(tmp_path, frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
||||||
|
os.replace(tmp_path, live_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Tampilkan Live Preview jika show_live aktif
|
||||||
|
if show_live:
|
||||||
|
display_frame = cv2.resize(frame, (1280, 720)) if (width > 1280 or height > 720) else frame
|
||||||
|
cv2.imshow("AI Sack Counter - Live Preview", display_frame)
|
||||||
|
if cv2.waitKey(1) & 0xFF == ord('q'):
|
||||||
|
print("\n[INFO] Live preview dihentikan oleh pengguna (menekan tombol 'q').")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Log status periodic ke konsol
|
||||||
|
if frame_idx % 25 == 0:
|
||||||
|
print(f"[INFO] Frame {frame_idx} - State: {pipeline.system_state} - Masuk: {pipeline.total_masuk} - Keluar: {pipeline.total_keluar} ({current_fps:.1f} FPS)")
|
||||||
|
|
||||||
|
# Clean resources
|
||||||
|
cap.release()
|
||||||
|
if writer is not None:
|
||||||
|
writer.release()
|
||||||
|
cv2.destroyAllWindows()
|
||||||
|
|
||||||
|
# Save final batch report
|
||||||
|
pipeline.save_batch_report()
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("PROSES PIPELINE SELESAI!")
|
||||||
|
print(f"Hasil Akhir Batch: Masuk = {pipeline.total_masuk}, Keluar = {pipeline.total_keluar}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# RTSP Camera Live Stream
|
||||||
|
SOURCE_INPUT = "rtsp://192.168.192.96:8554/cam"
|
||||||
|
|
||||||
|
is_stream = any(str(SOURCE_INPUT).startswith(p) for p in ["http://", "https://", "rtsp://", "rtmp://"])
|
||||||
|
if is_stream or os.path.exists(SOURCE_INPUT):
|
||||||
|
try:
|
||||||
|
run_prediction(
|
||||||
|
source_path=SOURCE_INPUT,
|
||||||
|
max_frames=None, # Proses seluruh video
|
||||||
|
save_output_video=True,
|
||||||
|
show_live=True # Aktifkan window GUI OpenCV untuk live preview langsung
|
||||||
|
)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n[INFO] Program dihentikan secara manual (Ctrl+C).")
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] Video/Stream '{SOURCE_INPUT}' tidak ditemukan.")
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"video_width": 1920,
|
||||||
|
"video_height": 1080,
|
||||||
|
"orientation": "horizontal",
|
||||||
|
"direction": "bottom_to_top",
|
||||||
|
"zone_x_min": 858,
|
||||||
|
"zone_x_max": 1231,
|
||||||
|
"zone_y_min": 7,
|
||||||
|
"zone_y_max": 581,
|
||||||
|
"line_y": 580,
|
||||||
|
"line_x": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"video_width": 1920,
|
||||||
|
"video_height": 1080,
|
||||||
|
"orientation": "horizontal",
|
||||||
|
"direction": "bottom_to_top",
|
||||||
|
"zone_x_min": 598,
|
||||||
|
"zone_x_max": 1919,
|
||||||
|
"zone_y_min": 215,
|
||||||
|
"zone_y_max": 1079,
|
||||||
|
"line_y": 993,
|
||||||
|
"line_x": null,
|
||||||
|
"auto_calibrate_mode": "sack_cluster",
|
||||||
|
"source_video": "Camera2_segments\\clip_033.mp4",
|
||||||
|
"model": "karung-dimuat-seg-200e.pt"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"video_width": 1920,
|
||||||
|
"video_height": 1080,
|
||||||
|
"orientation": "horizontal",
|
||||||
|
"direction": "bottom_to_top",
|
||||||
|
"zone_x_min": 858,
|
||||||
|
"zone_x_max": 1231,
|
||||||
|
"zone_y_min": 7,
|
||||||
|
"zone_y_max": 581,
|
||||||
|
"line_y": 580,
|
||||||
|
"line_x": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"line_y": 590,
|
||||||
|
"zone_x_min": 747,
|
||||||
|
"zone_x_max": 1314,
|
||||||
|
"zone_y_min": 0,
|
||||||
|
"zone_y_max": 595,
|
||||||
|
"video_width": 1920,
|
||||||
|
"video_height": 1080,
|
||||||
|
"orientation": "horizontal",
|
||||||
|
"direction": "bottom_to_top"
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"cameras": [
|
||||||
|
{
|
||||||
|
"id": "cam1",
|
||||||
|
"name": "Kamera Utama (Gate 1)",
|
||||||
|
"rtsp_url": "rtsp://admin:K0l0r4n123@10.38.250.21/cam/realmonitor?channel=1&subtype=1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cam2",
|
||||||
|
"name": "Kamera Samping (Gate 2)",
|
||||||
|
"rtsp_url": "rtsp://admin:K0l0r4n123@10.38.250.22/cam/realmonitor?channel=1&subtype=1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"same_sack_radius": 78.0,
|
||||||
|
"stack_sack_radius": 45.0,
|
||||||
|
"min_approach_depth": 10.0,
|
||||||
|
"outside_confirm_frames": 2,
|
||||||
|
"crossing_point_ratio": 0.82,
|
||||||
|
"count_cooldown_dist": 95.0,
|
||||||
|
"count_cooldown_frames": 40,
|
||||||
|
"staging_cooldown_frames": 8,
|
||||||
|
"min_staging_depth": 45.0,
|
||||||
|
"min_track_frames": 0,
|
||||||
|
"ghost_track_frames": 999,
|
||||||
|
"conf": 0.2,
|
||||||
|
"tuned_accuracy": 71.8,
|
||||||
|
"tuned_exact": "4/6",
|
||||||
|
"tuned_total_ai": 25,
|
||||||
|
"tuned_total_manual": 25,
|
||||||
|
"tuned_mae": 0.333,
|
||||||
|
"counting_logic": "geometric_v15",
|
||||||
|
"updated_at": "2026-07-08T23:29:00"
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"same_sack_radius": 78.0,
|
||||||
|
"stack_sack_radius": 45.0,
|
||||||
|
"min_approach_depth": 4.0,
|
||||||
|
"outside_confirm_frames": 2,
|
||||||
|
"crossing_point_ratio": 0.82,
|
||||||
|
"count_cooldown_dist": 95.0,
|
||||||
|
"count_cooldown_frames": 40,
|
||||||
|
"staging_cooldown_frames": 8,
|
||||||
|
"min_staging_depth": 45.0,
|
||||||
|
"min_track_frames": 8,
|
||||||
|
"ghost_track_frames": 6,
|
||||||
|
"clip_warmup_frames": 25,
|
||||||
|
"min_post_cross_inside_depth": 0.0,
|
||||||
|
"burst_cooldown_frames": 10,
|
||||||
|
"burst_cooldown_dist": 50.0,
|
||||||
|
"conf": 0.2,
|
||||||
|
"counting_logic": "geometric_v15",
|
||||||
|
"model": "karung-dimuat-seg-200e.pt",
|
||||||
|
"note": "Override klip last_truck — burst dedup + conf gelap"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"active_model_id": "karung-dimuat-seg-200e",
|
||||||
|
"models": {
|
||||||
|
"karung-dimuat-seg-200e": {
|
||||||
|
"filename": "karung-dimuat-seg-200e.pt",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"released_at": "2026-07-09T08:00:00Z",
|
||||||
|
"download_url": "https://github.com/rrabbanifasha-alt/feedmill-semarang/releases/download/v1.0.0/karung-dimuat-seg-200e.pt",
|
||||||
|
"md5": "d41d8cd98f00b204e9800998ecf8427e",
|
||||||
|
"description": "Baseline model trained for 200 epochs on feedmill sacks dataset",
|
||||||
|
"metrics": {
|
||||||
|
"mAP50_mask": 0.899,
|
||||||
|
"validation_mae": 0.67
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"token": "8654129536:AAHrx4x7OPm84WRDhRLj3oIMgecUDKSfqgs",
|
||||||
|
"chat_id": "-5147118224"
|
||||||
|
}
|
||||||
+1533
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,625 @@
|
|||||||
|
import os
|
||||||
|
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp|buffer_size;20480000|max_delay;500000|reorder_queue_size;500"
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import datetime
|
||||||
|
from ultralytics import YOLO
|
||||||
|
|
||||||
|
# Import kustom dari repositori rpo iki (Count Engine buatan teman)
|
||||||
|
from count import (
|
||||||
|
LineCounter, BoundarySettings, SACK_CLASS_ID, DEFAULT_MASK_ALPHA,
|
||||||
|
annotate_tracks, draw_persisted_sacks, draw_count_hud,
|
||||||
|
draw_truck_counter_box, draw_count_flashes, draw_boundary,
|
||||||
|
draw_blind_truck_overlay, tick_flashes, track_points, tracking_point,
|
||||||
|
CountFlash, load_counting_params
|
||||||
|
)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# PATH DATABASES & CONFIGURATION FOR DASHBOARD (PORT 5000 & 8000)
|
||||||
|
# =====================================================================
|
||||||
|
if os.name == 'nt':
|
||||||
|
_DEFAULT_DIR = "d:/Belajar/menghitung karung"
|
||||||
|
else:
|
||||||
|
_DEFAULT_DIR = "/opt/jetson-counter"
|
||||||
|
|
||||||
|
DB_PATH = os.getenv('DB_PATH', f"{_DEFAULT_DIR}/jetson_counter.db")
|
||||||
|
STATE_FILE = os.getenv('STATE_FILE', f"{_DEFAULT_DIR}/current_batch.json")
|
||||||
|
LIVE_STREAM_FRAME_PATH = os.getenv('LIVE_STREAM_FRAME_PATH', f"{_DEFAULT_DIR}/live_frame.jpg")
|
||||||
|
SHM_LIVE_FRAME_PATH = "/dev/shm/jetson-counter/live_frame.jpg"
|
||||||
|
|
||||||
|
CAMERA_NAME = "CC1"
|
||||||
|
OBJECT_LABEL = "Karung Feedmill (RPO IKI Engine)"
|
||||||
|
|
||||||
|
|
||||||
|
class RTSPBufferlessCapture:
|
||||||
|
"""Thread-safe RTSP Reader untuk Jetson / Windows."""
|
||||||
|
def __init__(self, source_path):
|
||||||
|
self.source_path = source_path
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.cap = cv2.VideoCapture(source_path, cv2.CAP_FFMPEG)
|
||||||
|
self.frame = None
|
||||||
|
self.ret = False
|
||||||
|
self.running = True
|
||||||
|
|
||||||
|
if self.cap.isOpened():
|
||||||
|
self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||||
|
self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
self.fps = self.cap.get(cv2.CAP_PROP_FPS)
|
||||||
|
else:
|
||||||
|
self.width, self.height, self.fps = 1920, 1080, 25.0
|
||||||
|
|
||||||
|
if self.fps <= 0 or np.isnan(self.fps):
|
||||||
|
self.fps = 25.0
|
||||||
|
|
||||||
|
self.thread = threading.Thread(target=self._update, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def _update(self):
|
||||||
|
while self.running:
|
||||||
|
if self.cap is None or not self.cap.isOpened():
|
||||||
|
time.sleep(0.05)
|
||||||
|
continue
|
||||||
|
ret, frame = self.cap.read()
|
||||||
|
if ret and frame is not None:
|
||||||
|
with self.lock:
|
||||||
|
self.frame = frame
|
||||||
|
self.ret = True
|
||||||
|
else:
|
||||||
|
time.sleep(0.005)
|
||||||
|
|
||||||
|
def isOpened(self):
|
||||||
|
return self.cap is not None and self.cap.isOpened()
|
||||||
|
|
||||||
|
def get(self, propId):
|
||||||
|
if propId == cv2.CAP_PROP_FRAME_WIDTH:
|
||||||
|
return self.width
|
||||||
|
elif propId == cv2.CAP_PROP_FRAME_HEIGHT:
|
||||||
|
return self.height
|
||||||
|
elif propId == cv2.CAP_PROP_FPS:
|
||||||
|
return self.fps
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
with self.lock:
|
||||||
|
if self.ret and self.frame is not None:
|
||||||
|
return True, self.frame.copy()
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
def release(self):
|
||||||
|
self.running = False
|
||||||
|
if self.cap is not None:
|
||||||
|
self.cap.release()
|
||||||
|
self.cap = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS batches (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
batch_number TEXT UNIQUE,
|
||||||
|
start_time TEXT,
|
||||||
|
end_time TEXT,
|
||||||
|
total_masuk INTEGER,
|
||||||
|
total_keluar INTEGER,
|
||||||
|
net_count INTEGER,
|
||||||
|
status TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_summaries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT UNIQUE,
|
||||||
|
camera_name TEXT,
|
||||||
|
object_label TEXT,
|
||||||
|
total_count INTEGER,
|
||||||
|
total_batches INTEGER,
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"[DB Info] Inisialisasi SQLite database berhasil: {DB_PATH}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DB Error] Gagal inisialisasi SQLite database: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
from socketserver import ThreadingMixIn
|
||||||
|
|
||||||
|
streaming_frame = None
|
||||||
|
streaming_lock = threading.Lock()
|
||||||
|
live_stream_enabled = True
|
||||||
|
|
||||||
|
|
||||||
|
class StreamingHandler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
global streaming_frame
|
||||||
|
if self.path == '/' or self.path == '/stream.mjpg' or self.path == '/stream':
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Age', '0')
|
||||||
|
self.send_header('Cache-Control', 'no-cache, private')
|
||||||
|
self.send_header('Pragma', 'no-cache')
|
||||||
|
self.send_header('Content-Type', 'multipart/x-mixed-replace; boundary=frame')
|
||||||
|
self.end_headers()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
with streaming_lock:
|
||||||
|
frame_to_stream = streaming_frame.copy() if streaming_frame is not None else None
|
||||||
|
|
||||||
|
if frame_to_stream is None:
|
||||||
|
time.sleep(0.05)
|
||||||
|
continue
|
||||||
|
|
||||||
|
h, w = frame_to_stream.shape[:2]
|
||||||
|
if w > 960:
|
||||||
|
frame_to_stream = cv2.resize(frame_to_stream, (960, int(h * 960 / w)))
|
||||||
|
ret, jpeg = cv2.imencode('.jpg', frame_to_stream, [cv2.IMWRITE_JPEG_QUALITY, 75])
|
||||||
|
if not ret:
|
||||||
|
time.sleep(0.05)
|
||||||
|
continue
|
||||||
|
frame_bytes = jpeg.tobytes()
|
||||||
|
|
||||||
|
self.wfile.write(b'--frame\r\n')
|
||||||
|
self.send_header('Content-Type', 'image/jpeg')
|
||||||
|
self.send_header('Content-Length', len(frame_bytes))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(frame_bytes)
|
||||||
|
self.wfile.write(b'\r\n')
|
||||||
|
time.sleep(0.04) # ~25 FPS
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
self.send_error(404, "Path not found")
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
|
||||||
|
def start_streaming_server(port=8000):
|
||||||
|
try:
|
||||||
|
server = ThreadedHTTPServer(('0.0.0.0', port), StreamingHandler)
|
||||||
|
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
server_thread.start()
|
||||||
|
print(f"[RPO IKI] Live View Server HTTP berjalan di http://0.0.0.0:{port}/")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARNING] Gagal membuka HTTP Streaming Server di port {port}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def write_live_frame(frame):
|
||||||
|
"""Simpan frame preview live ke memori & disk untuk Web Server Port 8000."""
|
||||||
|
global streaming_frame
|
||||||
|
with streaming_lock:
|
||||||
|
streaming_frame = frame
|
||||||
|
try:
|
||||||
|
if os.path.exists("/dev/shm"):
|
||||||
|
os.makedirs("/dev/shm/jetson-counter", exist_ok=True)
|
||||||
|
cv2.imwrite(SHM_LIVE_FRAME_PATH, frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
||||||
|
os.makedirs(os.path.dirname(LIVE_STREAM_FRAME_PATH), exist_ok=True)
|
||||||
|
cv2.imwrite(LIVE_STREAM_FRAME_PATH, frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def save_active_batch_state(count=0, status="COUNTING_SACKS", truck_state="LOCKED"):
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
|
||||||
|
state_data = {
|
||||||
|
"batch_number": "BATCH-RPO-01",
|
||||||
|
"start_time": datetime.now().isoformat(),
|
||||||
|
"count": count,
|
||||||
|
"status": status,
|
||||||
|
"truck_state": truck_state,
|
||||||
|
"last_detection_time": datetime.now().isoformat(),
|
||||||
|
"engine": "rpo_iki"
|
||||||
|
}
|
||||||
|
with open(STATE_FILE, 'w') as f:
|
||||||
|
json.dump(state_data, f, indent=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_live_boundary(width=960, height=540):
|
||||||
|
"""Muat koordinat zona dari zones.json (Web Dashboard) atau configs/area_truk.json."""
|
||||||
|
# 1. Cek zones.json dari Web Dashboard
|
||||||
|
zones_json = Path(_DEFAULT_DIR) / "zones.json"
|
||||||
|
if not zones_json.exists():
|
||||||
|
zones_json = Path(__file__).parent.parent / "zones.json"
|
||||||
|
|
||||||
|
if zones_json.exists():
|
||||||
|
try:
|
||||||
|
with open(zones_json, 'r') as f:
|
||||||
|
zdata = json.load(f)
|
||||||
|
if 'truck' in zdata and len(zdata['truck']) >= 3:
|
||||||
|
pts = np.array(zdata['truck'], dtype=np.float32)
|
||||||
|
scale_x = width / 1920.0
|
||||||
|
scale_y = height / 1080.0
|
||||||
|
x_min = int(np.min(pts[:, 0]) * scale_x)
|
||||||
|
x_max = int(np.max(pts[:, 0]) * scale_x)
|
||||||
|
y_min = int(np.min(pts[:, 1]) * scale_y)
|
||||||
|
y_max = int(np.max(pts[:, 1]) * scale_y)
|
||||||
|
line_y = int(y_max - 5)
|
||||||
|
print(f"[RPO IKI] Memuat Zona Dinamis dari Web (zones.json): x={x_min}..{x_max}, y={y_min}..{y_max}, line_y={line_y}")
|
||||||
|
return BoundarySettings(
|
||||||
|
line_pos=line_y,
|
||||||
|
orientation="horizontal",
|
||||||
|
direction="bottom_to_top",
|
||||||
|
zone_x_min=x_min,
|
||||||
|
zone_x_max=x_max,
|
||||||
|
zone_y_min=y_min,
|
||||||
|
zone_y_max=y_max
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARNING] Gagal membaca zones.json web: {e}")
|
||||||
|
|
||||||
|
# 2. Fallback ke configs/area_truk.json
|
||||||
|
config_file = Path(__file__).parent / "configs" / "area_truk.json"
|
||||||
|
if config_file.exists():
|
||||||
|
try:
|
||||||
|
cdata = json.loads(config_file.read_text(encoding="utf-8"))
|
||||||
|
vw = cdata.get("video_width", 1920)
|
||||||
|
vh = cdata.get("video_height", 1080)
|
||||||
|
scale_x = width / float(vw)
|
||||||
|
scale_y = height / float(vh)
|
||||||
|
|
||||||
|
x_min = int(cdata.get("zone_x_min", 858) * scale_x)
|
||||||
|
x_max = int(cdata.get("zone_x_max", 1231) * scale_x)
|
||||||
|
y_min = int(cdata.get("zone_y_min", 7) * scale_y)
|
||||||
|
y_max = int(cdata.get("zone_y_max", 581) * scale_y)
|
||||||
|
line_y = int(cdata.get("line_y", 580) * scale_y)
|
||||||
|
|
||||||
|
print(f"[RPO IKI] Memuat & Rescale boundary dari configs/area_truk.json: x={x_min}..{x_max}, y={y_min}..{y_max}, line_y={line_y} (scale={scale_x:.2f})")
|
||||||
|
return BoundarySettings(
|
||||||
|
line_pos=line_y,
|
||||||
|
orientation=cdata.get("orientation", "horizontal"),
|
||||||
|
direction=cdata.get("direction", "bottom_to_top"),
|
||||||
|
zone_x_min=x_min,
|
||||||
|
zone_x_max=x_max,
|
||||||
|
zone_y_min=y_min,
|
||||||
|
zone_y_max=y_max
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARNING] Gagal membaca configs/area_truk.json: {e}")
|
||||||
|
|
||||||
|
return BoundarySettings(
|
||||||
|
line_pos=290 if height <= 600 else 580,
|
||||||
|
orientation="horizontal",
|
||||||
|
direction="bottom_to_top",
|
||||||
|
zone_x_min=429 if width <= 960 else 858,
|
||||||
|
zone_x_max=615 if width <= 960 else 1231,
|
||||||
|
zone_y_min=4 if height <= 600 else 7,
|
||||||
|
zone_y_max=290 if height <= 600 else 581
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_friend_hud_overlay(frame, count, truck_state, current_fps):
|
||||||
|
"""Visualisasi HUD Glassmorphic persis buatan rpo iki (step02_count_live.py)."""
|
||||||
|
scale = frame.shape[1] / 1280.0
|
||||||
|
card_w = int(430 * scale)
|
||||||
|
card_h = int(210 * scale)
|
||||||
|
cx1, cy1 = int(20 * scale), int(20 * scale)
|
||||||
|
cx2, cy2 = cx1 + card_w, cy1 + card_h
|
||||||
|
|
||||||
|
overlay = frame.copy()
|
||||||
|
cv2.rectangle(overlay, (cx1, cy1), (cx2, cy2), (20, 24, 33), -1)
|
||||||
|
cv2.addWeighted(overlay, 0.72, frame, 0.28, 0, frame)
|
||||||
|
|
||||||
|
accent_w = int(6 * scale)
|
||||||
|
accent_color = (0, 180, 255) # Orange default
|
||||||
|
if truck_state == "LOCKED":
|
||||||
|
accent_color = (16, 185, 129) # Emerald Green
|
||||||
|
elif truck_state == "WAITING":
|
||||||
|
accent_color = (59, 130, 246) # Blue
|
||||||
|
|
||||||
|
cv2.rectangle(frame, (cx1, cy1), (cx1 + accent_w, cy2), accent_color, -1)
|
||||||
|
cv2.rectangle(frame, (cx1, cy1), (cx2, cy2), (64, 74, 95), max(1, int(1 * scale)))
|
||||||
|
|
||||||
|
tx = cx1 + int(18 * scale)
|
||||||
|
|
||||||
|
cv2.putText(
|
||||||
|
frame,
|
||||||
|
f"AI COUNTER MONITOR | CC1 | LATCH: {truck_state}",
|
||||||
|
(tx, cy1 + int(24 * scale)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.44 * scale,
|
||||||
|
(148, 163, 184),
|
||||||
|
max(1, int(1 * scale)),
|
||||||
|
)
|
||||||
|
|
||||||
|
cv2.putText(
|
||||||
|
frame,
|
||||||
|
"MUAT TRUK:",
|
||||||
|
(tx, cy1 + int(64 * scale)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.55 * scale,
|
||||||
|
(226, 232, 240),
|
||||||
|
max(1, int(1 * scale)),
|
||||||
|
)
|
||||||
|
|
||||||
|
cv2.putText(
|
||||||
|
frame,
|
||||||
|
f"{count}",
|
||||||
|
(tx + int(130 * scale), cy1 + int(72 * scale)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
1.35 * scale,
|
||||||
|
accent_color,
|
||||||
|
max(1, int(3 * scale)),
|
||||||
|
)
|
||||||
|
|
||||||
|
cv2.putText(
|
||||||
|
frame,
|
||||||
|
f"Status Truk: {truck_state}",
|
||||||
|
(tx, cy1 + int(108 * scale)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.56 * scale,
|
||||||
|
(241, 245, 249),
|
||||||
|
max(1, int(1 * scale)),
|
||||||
|
)
|
||||||
|
|
||||||
|
cv2.putText(
|
||||||
|
frame,
|
||||||
|
f"FPS: {current_fps:.1f} | Engine: RPO IKI (Friends)",
|
||||||
|
(tx, cy1 + int(192 * scale)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.44 * scale,
|
||||||
|
(100, 116, 139),
|
||||||
|
max(1, int(1 * scale)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_rpo_iki_prediction():
|
||||||
|
print("=" * 60)
|
||||||
|
print("MEMULAI LIVE PREDICTION ENGINE (RPO IKI - FRIENDS ALGORITHM)")
|
||||||
|
print("============================================================")
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
start_streaming_server(port=8000)
|
||||||
|
|
||||||
|
# Path model karung
|
||||||
|
model_path = Path(__file__).parent / "DATA" / "models" / "karung-dimuat-seg-200e.pt"
|
||||||
|
if not model_path.exists():
|
||||||
|
model_path = Path(_DEFAULT_DIR) / "karung-dimuat-detection-di-feedmill-yolo26n-seg-200e.pt"
|
||||||
|
if not model_path.exists():
|
||||||
|
model_path = Path("karung-dimuat-detection-di-feedmill-yolo26n-seg-200e.pt")
|
||||||
|
|
||||||
|
print(f"[RPO IKI] Memuat Model YOLO Karung: {model_path}")
|
||||||
|
model = YOLO(str(model_path))
|
||||||
|
|
||||||
|
# Path model truk
|
||||||
|
truck_model_path = Path(_DEFAULT_DIR) / "truck-detector.pt"
|
||||||
|
if not truck_model_path.exists():
|
||||||
|
truck_model_path = Path("/home/jetson/karung/truck-detector.pt")
|
||||||
|
if not truck_model_path.exists():
|
||||||
|
truck_model_path = Path(__file__).parent.parent / "truck-detector.pt"
|
||||||
|
if not truck_model_path.exists():
|
||||||
|
truck_model_path = Path("truck-detector.pt")
|
||||||
|
|
||||||
|
truck_model = None
|
||||||
|
truck_class_id = 0
|
||||||
|
if truck_model_path.exists():
|
||||||
|
print(f"[RPO IKI AI Latch] Memuat model detektor truk: {truck_model_path}")
|
||||||
|
truck_model = YOLO(str(truck_model_path))
|
||||||
|
truck_class_id = 0 if "truck-detector" in str(truck_model_path) else 7
|
||||||
|
else:
|
||||||
|
print(f"[RPO IKI] Model detektor truk tidak ditemukan, menggunakan mode ROI Statis Locked.")
|
||||||
|
|
||||||
|
# Tentukan device inferensi
|
||||||
|
import torch
|
||||||
|
device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
||||||
|
print(f"[RPO IKI] Device inferensi diset ke: {device}")
|
||||||
|
|
||||||
|
# Source RTSP
|
||||||
|
source_path = os.getenv("RTSP_URL", "rtsp://admin:K0l0r4n123@10.38.250.21/cam/realmonitor?channel=1&subtype=0")
|
||||||
|
is_stream = any(str(source_path).startswith(p) for p in ["rtsp://", "rtmp://", "http://", "https://"])
|
||||||
|
|
||||||
|
if is_stream:
|
||||||
|
print(f"[RPO IKI] Membuka RTSP Stream via Threaded Bufferless Reader: {source_path}")
|
||||||
|
cap = RTSPBufferlessCapture(source_path)
|
||||||
|
else:
|
||||||
|
print(f"[RPO IKI] Membuka File Video Lokal: {source_path}")
|
||||||
|
cap = cv2.VideoCapture(source_path)
|
||||||
|
|
||||||
|
if not cap.isOpened():
|
||||||
|
print(f"[ERROR] Gagal membuka stream / video: {source_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 1920
|
||||||
|
raw_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 1080
|
||||||
|
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
|
||||||
|
|
||||||
|
is_1080p = (raw_width == 1920 and raw_height == 1080)
|
||||||
|
width = 960 if is_1080p else raw_width
|
||||||
|
height = 540 if is_1080p else raw_height
|
||||||
|
|
||||||
|
boundary = resolve_live_boundary(width, height)
|
||||||
|
params = load_counting_params()
|
||||||
|
|
||||||
|
counter = LineCounter(
|
||||||
|
line_pos=boundary.line_pos,
|
||||||
|
orientation=boundary.orientation,
|
||||||
|
direction=boundary.direction,
|
||||||
|
min_approach_depth=params.get("min_approach_depth", 15.0),
|
||||||
|
same_sack_radius=params.get("same_sack_radius", 70.0),
|
||||||
|
stack_sack_radius=params.get("stack_sack_radius", 58.0),
|
||||||
|
outside_confirm_frames=params.get("outside_confirm_frames", 2),
|
||||||
|
count_cooldown_dist=params.get("count_cooldown_dist", 85.0),
|
||||||
|
count_cooldown_frames=params.get("count_cooldown_frames", 40),
|
||||||
|
staging_cooldown_frames=params.get("staging_cooldown_frames", 15),
|
||||||
|
min_staging_depth=params.get("min_staging_depth", 40.0),
|
||||||
|
min_track_frames=params.get("min_track_frames", 8),
|
||||||
|
ghost_track_frames=params.get("ghost_track_frames", 0),
|
||||||
|
clip_warmup_frames=params.get("clip_warmup_frames", 25),
|
||||||
|
zone_x_min=boundary.zone_x_min,
|
||||||
|
zone_x_max=boundary.zone_x_max,
|
||||||
|
zone_y_min=boundary.zone_y_min,
|
||||||
|
zone_y_max=boundary.zone_y_max,
|
||||||
|
)
|
||||||
|
|
||||||
|
# State machine truk
|
||||||
|
truck_state = "LOCKED" # Default locked agar langsung menghitung karung
|
||||||
|
consecutive_truck_detections = 0
|
||||||
|
locked_bbox = None
|
||||||
|
|
||||||
|
flashes: list[CountFlash] = []
|
||||||
|
frame_idx = 0
|
||||||
|
last_time = time.time()
|
||||||
|
current_fps = 0.0
|
||||||
|
|
||||||
|
print(f"[RPO IKI] Counter Siap! Resized Frame: {width}x{height}, Line: {boundary.orientation} @ {counter._line_pos}, Zone: x={counter.zone_x_min}..{counter.zone_x_max}, y={counter.zone_y_min}..{counter.zone_y_max}")
|
||||||
|
|
||||||
|
config_file = Path(__file__).parent / "configs" / "area_truk.json"
|
||||||
|
last_config_mtime = config_file.stat().st_mtime if config_file.exists() else 0.0
|
||||||
|
|
||||||
|
while cap.isOpened():
|
||||||
|
ret, frame = cap.read()
|
||||||
|
if not ret or frame is None:
|
||||||
|
time.sleep(0.01)
|
||||||
|
continue
|
||||||
|
|
||||||
|
frame_idx += 1
|
||||||
|
|
||||||
|
# Hot-reload konfigurasi jika configs/area_truk.json diperbarui di disk / web
|
||||||
|
if frame_idx % 25 == 0 and config_file.exists():
|
||||||
|
try:
|
||||||
|
mtime = config_file.stat().st_mtime
|
||||||
|
if mtime > last_config_mtime:
|
||||||
|
last_config_mtime = mtime
|
||||||
|
boundary = resolve_live_boundary(width, height)
|
||||||
|
counter._line_pos = boundary.line_pos
|
||||||
|
counter.zone_x_min = boundary.zone_x_min
|
||||||
|
counter.zone_x_max = boundary.zone_x_max
|
||||||
|
counter.zone_y_min = boundary.zone_y_min
|
||||||
|
counter.zone_y_max = boundary.zone_y_max
|
||||||
|
print(f"[Live Config Reload] Boundary diperbarui secara dinamis: x={boundary.zone_x_min}..{boundary.zone_x_max}, line_y={boundary.line_pos}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Resize ke 960x540 jika 1080p agar presisi dengan area_truk.json rpo iki
|
||||||
|
if is_1080p and frame.shape[1] == 1920 and frame.shape[0] == 1080:
|
||||||
|
frame = cv2.resize(frame, (960, 540))
|
||||||
|
|
||||||
|
annotated = frame.copy()
|
||||||
|
|
||||||
|
# 1. Dynamic Truk Latch State Machine (Jika truck_model aktif)
|
||||||
|
if truck_model is not None and truck_state == "WAITING":
|
||||||
|
if frame_idx % 10 == 0:
|
||||||
|
results_t = truck_model(frame, classes=[truck_class_id], conf=0.40, verbose=False)
|
||||||
|
boxes_t = results_t[0].boxes
|
||||||
|
if len(boxes_t) > 0:
|
||||||
|
sorted_boxes = sorted(boxes_t, key=lambda b: (b.xyxy[0][2] - b.xyxy[0][0]) * (b.xyxy[0][3] - b.xyxy[0][1]), reverse=True)
|
||||||
|
t_box = sorted_boxes[0].xyxy[0].cpu().numpy()
|
||||||
|
tx1, ty1, tx2, ty2 = map(int, t_box)
|
||||||
|
|
||||||
|
consecutive_truck_detections += 1
|
||||||
|
if consecutive_truck_detections >= 3:
|
||||||
|
locked_bbox = (tx1, ty1, tx2, ty2)
|
||||||
|
truck_state = "LOCKED"
|
||||||
|
consecutive_truck_detections = 0
|
||||||
|
|
||||||
|
line_y = int(ty2 - 5)
|
||||||
|
boundary = replace(
|
||||||
|
boundary,
|
||||||
|
line_pos=line_y,
|
||||||
|
zone_x_min=tx1,
|
||||||
|
zone_x_max=tx2,
|
||||||
|
zone_y_min=ty1,
|
||||||
|
zone_y_max=ty2,
|
||||||
|
)
|
||||||
|
counter._line_pos = boundary.line_pos
|
||||||
|
counter.zone_x_min = boundary.zone_x_min
|
||||||
|
counter.zone_x_max = boundary.zone_x_max
|
||||||
|
counter.zone_y_min = boundary.zone_y_min
|
||||||
|
counter.zone_y_max = boundary.zone_y_max
|
||||||
|
|
||||||
|
print(f"[AI Latch] Truk Terdeteksi Stabil! Mengunci ROI Bak Truk: x={tx1}..{tx2}, y={ty1}..{ty2}, line_y={line_y}")
|
||||||
|
|
||||||
|
# 2. Tracking Karung & Counting (Saat truck_state == "LOCKED")
|
||||||
|
boxes = None
|
||||||
|
masks = None
|
||||||
|
if truck_state in ("LOCKED", "DEPARTING"):
|
||||||
|
results = model.track(
|
||||||
|
frame,
|
||||||
|
persist=True,
|
||||||
|
classes=[SACK_CLASS_ID],
|
||||||
|
conf=params.get("conf", 0.15),
|
||||||
|
tracker="bytetrack.yaml",
|
||||||
|
device=device,
|
||||||
|
verbose=False
|
||||||
|
)
|
||||||
|
boxes = results[0].boxes
|
||||||
|
masks = results[0].masks
|
||||||
|
|
||||||
|
if boxes is not None and boxes.id is not None:
|
||||||
|
for box_coord, track_id in zip(boxes.xyxy.cpu().numpy(), boxes.id.int().cpu().tolist()):
|
||||||
|
cross, foot = track_points(box_coord)
|
||||||
|
count_event = counter.update(track_id, cross, frame_idx, foot)
|
||||||
|
if count_event is not None:
|
||||||
|
flashes.append(CountFlash(number=counter.count, x=int(cross[0]), y=int(cross[1]), frames_left=int(fps * 0.35)))
|
||||||
|
print(f"[RPO IKI COUNTER] Karung #{track_id} TERHITUNG! Total: {counter.count}")
|
||||||
|
save_active_batch_state(count=counter.count, truck_state=truck_state)
|
||||||
|
|
||||||
|
# 3. Render Visualisasi Asli rpo iki (Garis Hijau Batas, Kotak Truk Orange, HUD)
|
||||||
|
draw_blind_truck_overlay(annotated, boundary, boundary.line_pos)
|
||||||
|
draw_boundary(
|
||||||
|
annotated,
|
||||||
|
boundary.line_pos,
|
||||||
|
boundary.orientation,
|
||||||
|
boundary.zone_x_min,
|
||||||
|
boundary.zone_x_max,
|
||||||
|
boundary.zone_y_min,
|
||||||
|
boundary.zone_y_max,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Gambar BBox Karung yang sedang mendekati garis
|
||||||
|
if boxes is not None and boxes.id is not None:
|
||||||
|
annotate_tracks(
|
||||||
|
annotated,
|
||||||
|
boxes,
|
||||||
|
counter,
|
||||||
|
flashes,
|
||||||
|
fps,
|
||||||
|
frame_idx,
|
||||||
|
blind_truck=True,
|
||||||
|
masks=masks,
|
||||||
|
show_mask=True
|
||||||
|
)
|
||||||
|
|
||||||
|
tick_flashes(flashes)
|
||||||
|
draw_count_flashes(annotated, flashes, fps)
|
||||||
|
draw_friend_hud_overlay(annotated, counter.count, truck_state, current_fps)
|
||||||
|
|
||||||
|
# Hitung FPS
|
||||||
|
if frame_idx % 25 == 0:
|
||||||
|
now = time.time()
|
||||||
|
elapsed = now - last_time
|
||||||
|
if elapsed > 0:
|
||||||
|
current_fps = 25.0 / elapsed
|
||||||
|
last_time = now
|
||||||
|
print(f"[INFO] Frame {frame_idx} - State: {truck_state} - Terhitung: {counter.count} karung ({current_fps:.2f} FPS)")
|
||||||
|
save_active_batch_state(count=counter.count, truck_state=truck_state)
|
||||||
|
|
||||||
|
# Tulis live frame preview untuk Web Server Port 8000
|
||||||
|
write_live_frame(annotated)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
run_rpo_iki_prediction()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n[RPO IKI] Program dihentikan secara manual (Ctrl+C).")
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import os
|
||||||
|
# Optimize OpenMP and MKL thread allocation for AMD Ryzen 5 6600H (6 Cores)
|
||||||
|
os.environ["OMP_NUM_THREADS"] = "6"
|
||||||
|
os.environ["MKL_NUM_THREADS"] = "6"
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import torch
|
||||||
|
from ultralytics import YOLO
|
||||||
|
|
||||||
|
# 1. Load the PyTorch YOLO segmentation model
|
||||||
|
model_path = "best.pt"
|
||||||
|
model = YOLO(model_path)
|
||||||
|
|
||||||
|
# Optimize PyTorch CPU thread pools for 6 physical cores to avoid SMT hyperthreading overhead
|
||||||
|
torch.set_num_threads(6)
|
||||||
|
print("Thread PyTorch diset ke 6 (Physical Cores) untuk optimalisasi CPU AMD Ryzen 5.")
|
||||||
|
|
||||||
|
# Auto-detect device
|
||||||
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
print(f"Device inferensi diset ke: {device}")
|
||||||
|
|
||||||
|
# 2. Open the video file
|
||||||
|
video_path = r"D:\Belajar\Menghitung karung\0727.mp4"
|
||||||
|
cap = cv2.VideoCapture(video_path)
|
||||||
|
|
||||||
|
if not cap.isOpened():
|
||||||
|
print(f"Error: Gagal membuka video di {video_path}")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# Optimasi 1: Frame Stride (Frame Skipping)
|
||||||
|
# FRAME_STRIDE = 3 artinya memproses 1 dari setiap 3 frame (sangat berguna untuk video 60fps agar CPU tidak overload)
|
||||||
|
FRAME_STRIDE = 3
|
||||||
|
frame_idx = 0
|
||||||
|
|
||||||
|
print("=== Simple Predict (Optimized for AMD Ryzen) Running ===")
|
||||||
|
print("Tekan 'q' di jendela video untuk keluar.\n")
|
||||||
|
|
||||||
|
annotated_frame = None
|
||||||
|
|
||||||
|
while cap.isOpened():
|
||||||
|
ret, frame = cap.read()
|
||||||
|
if not ret:
|
||||||
|
print("Video selesai diputar atau tidak terbaca.")
|
||||||
|
break
|
||||||
|
|
||||||
|
frame_idx += 1
|
||||||
|
|
||||||
|
# Hanya jalankan deteksi model pada frame tertentu berdasarkan STRIDE
|
||||||
|
if FRAME_STRIDE <= 1 or frame_idx % FRAME_STRIDE == 0 or annotated_frame is None:
|
||||||
|
# Optimasi 2: perkecil resolusi inferensi imgsz=320 untuk kecepatan maksimal
|
||||||
|
# Optimasi 3: gunakan device yang sesuai (cpu)
|
||||||
|
results = model(frame, conf=0.15, classes=[0], imgsz=320, device=device, verbose=False)
|
||||||
|
|
||||||
|
# Optimasi 4: Gambar hasil deteksi (diset masks=False untuk kecepatan menggambar di CPU)
|
||||||
|
annotated_frame = results[0].plot(masks=False)
|
||||||
|
|
||||||
|
# 5. Tampilkan frame di jendela
|
||||||
|
resized_frame = cv2.resize(annotated_frame, (960, 540))
|
||||||
|
cv2.imshow("YOLO Live Predict - Karung (Optimized)", resized_frame)
|
||||||
|
|
||||||
|
# Keluar jika tombol 'q' ditekan
|
||||||
|
if cv2.waitKey(1) & 0xFF == ord('q'):
|
||||||
|
break
|
||||||
|
|
||||||
|
# 6. Bersihkan resource
|
||||||
|
cap.release()
|
||||||
|
cv2.destroyAllWindows()
|
||||||
|
print("Proses selesai.")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Package marker."""
|
||||||
+395
@@ -0,0 +1,395 @@
|
|||||||
|
"""Batch lifecycle manager — 4-state machine for truck+sack sessions.
|
||||||
|
|
||||||
|
State machine:
|
||||||
|
IDLE ──truck detected──▶ TRUCK_STABILIZING ──stable 5s──▶ COUNTING_SACKS
|
||||||
|
▲ │ truck gone │ ▲
|
||||||
|
│ └──────▶ IDLE │ │
|
||||||
|
│ │ │
|
||||||
|
│ 10s no sack activity │ │ sacks resume
|
||||||
|
│ ▼ │
|
||||||
|
│ WAITING_FOR_ACTIVITY
|
||||||
|
│ (batch OPEN)
|
||||||
|
│ │
|
||||||
|
└──────────────── truck leaves ─────────────────────────────┘
|
||||||
|
(batch finalized)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum, auto
|
||||||
|
|
||||||
|
|
||||||
|
class BatchState(Enum):
|
||||||
|
IDLE = auto()
|
||||||
|
TRUCK_STABILIZING = auto()
|
||||||
|
COUNTING_SACKS = auto()
|
||||||
|
WAITING_FOR_ACTIVITY = auto() # Paused: no sacks, but truck still here
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BatchRecord:
|
||||||
|
"""Completed batch summary."""
|
||||||
|
|
||||||
|
batch_id: int
|
||||||
|
start_time: float
|
||||||
|
end_time: float
|
||||||
|
loading_count: int
|
||||||
|
unloading_count: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def net_count(self) -> int:
|
||||||
|
return self.loading_count - self.unloading_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration_seconds(self) -> float:
|
||||||
|
return self.end_time - self.start_time
|
||||||
|
|
||||||
|
|
||||||
|
class BatchLifecycleManager:
|
||||||
|
"""Manages batch transitions based on truck stability and sack activity.
|
||||||
|
|
||||||
|
State flow:
|
||||||
|
- IDLE: waiting for truck to appear in ROI polygon
|
||||||
|
- TRUCK_STABILIZING: truck seen, tracking centroid stability
|
||||||
|
- COUNTING_SACKS: actively counting sacks crossing line
|
||||||
|
- WAITING_FOR_ACTIVITY: sacks idle, but truck still present — batch stays open
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
stabilize_seconds: float = 5.0,
|
||||||
|
stabilize_threshold_px: float = 15.0,
|
||||||
|
sack_idle_timeout: float = 10.0,
|
||||||
|
min_batch_duration: float = 30.0,
|
||||||
|
truck_gone_tolerance: float = 3.0,
|
||||||
|
timeout_seconds: float = 30.0, # kept for backward compat (unused)
|
||||||
|
) -> None:
|
||||||
|
# Tunable parameters
|
||||||
|
self._stabilize_seconds = stabilize_seconds
|
||||||
|
self._stabilize_threshold_px = stabilize_threshold_px
|
||||||
|
self._sack_idle_timeout = sack_idle_timeout
|
||||||
|
self._min_batch_duration = min_batch_duration
|
||||||
|
self._truck_gone_tolerance = truck_gone_tolerance
|
||||||
|
|
||||||
|
# Internal state
|
||||||
|
self._state = BatchState.IDLE
|
||||||
|
self._batch_counter = 0
|
||||||
|
self._current_batch_id: int | None = None
|
||||||
|
self._batch_start_time = 0.0
|
||||||
|
self._history: list[BatchRecord] = []
|
||||||
|
|
||||||
|
# Truck stabilization tracking
|
||||||
|
self._truck_first_seen_time = 0.0
|
||||||
|
self._truck_last_centroid: tuple[float, float] | None = None
|
||||||
|
self._truck_stable_since = 0.0
|
||||||
|
self._truck_is_stable = False
|
||||||
|
self._truck_last_seen = 0.0 # timestamp when truck was last detected
|
||||||
|
|
||||||
|
# Sack activity tracking (for pause condition)
|
||||||
|
self._last_sack_crossing_time = 0.0
|
||||||
|
self._last_sack_seen_in_area_time = 0.0
|
||||||
|
|
||||||
|
# Waiting state tracking
|
||||||
|
self._waiting_since = 0.0
|
||||||
|
|
||||||
|
# Callbacks
|
||||||
|
self._on_batch_start: list = []
|
||||||
|
self._on_batch_end: list = []
|
||||||
|
|
||||||
|
# -- Public API: Register callbacks --
|
||||||
|
|
||||||
|
def on_batch_start(self, callback) -> None:
|
||||||
|
"""Register callback: fn(batch_id, timestamp)."""
|
||||||
|
self._on_batch_start.append(callback)
|
||||||
|
|
||||||
|
def on_batch_end(self, callback) -> None:
|
||||||
|
"""Register callback: fn(BatchRecord)."""
|
||||||
|
self._on_batch_end.append(callback)
|
||||||
|
|
||||||
|
# -- Public API: State update methods --
|
||||||
|
|
||||||
|
def update_truck(
|
||||||
|
self,
|
||||||
|
truck_detected: bool,
|
||||||
|
truck_centroid: tuple[float, float] | None,
|
||||||
|
timestamp: float,
|
||||||
|
) -> None:
|
||||||
|
"""Called during IDLE, TRUCK_STABILIZING, and WAITING_FOR_ACTIVITY states.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
truck_detected: whether a truck is detected in the ROI polygon
|
||||||
|
truck_centroid: (cx, cy) of the truck bounding box, or None
|
||||||
|
timestamp: current time.time()
|
||||||
|
"""
|
||||||
|
if self._state == BatchState.IDLE:
|
||||||
|
if truck_detected and truck_centroid is not None:
|
||||||
|
# Transition to STABILIZING
|
||||||
|
self._state = BatchState.TRUCK_STABILIZING
|
||||||
|
self._truck_first_seen_time = timestamp
|
||||||
|
self._truck_last_centroid = truck_centroid
|
||||||
|
self._truck_stable_since = timestamp
|
||||||
|
self._truck_is_stable = False
|
||||||
|
self._truck_last_seen = timestamp
|
||||||
|
print(f"[BATCH] Truk terdeteksi di area. Memantau stabilitas...")
|
||||||
|
if self._stabilize_seconds <= 0.0:
|
||||||
|
self._truck_is_stable = True
|
||||||
|
print(f"[BATCH] Instant start batch (stabilize_seconds <= 0). Memulai counting...")
|
||||||
|
self._start_batch(timestamp)
|
||||||
|
|
||||||
|
elif self._state == BatchState.TRUCK_STABILIZING:
|
||||||
|
if truck_detected:
|
||||||
|
self._truck_last_seen = timestamp
|
||||||
|
|
||||||
|
# Check if truck has been gone for too long (tolerance)
|
||||||
|
time_since_last_seen = timestamp - self._truck_last_seen
|
||||||
|
if not truck_detected and time_since_last_seen >= self._truck_gone_tolerance:
|
||||||
|
print(f"[BATCH] Truk hilang selama {time_since_last_seen:.1f}s. Kembali ke IDLE.")
|
||||||
|
self._state = BatchState.IDLE
|
||||||
|
self._truck_last_centroid = None
|
||||||
|
self._truck_is_stable = False
|
||||||
|
return
|
||||||
|
|
||||||
|
if truck_centroid is not None and self._truck_last_centroid is not None:
|
||||||
|
# Calculate centroid displacement
|
||||||
|
dx = truck_centroid[0] - self._truck_last_centroid[0]
|
||||||
|
dy = truck_centroid[1] - self._truck_last_centroid[1]
|
||||||
|
displacement = math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
|
if displacement > self._stabilize_threshold_px:
|
||||||
|
# Truck moved too much -> reset stability timer
|
||||||
|
self._truck_stable_since = timestamp
|
||||||
|
self._truck_is_stable = False
|
||||||
|
|
||||||
|
self._truck_last_centroid = truck_centroid
|
||||||
|
|
||||||
|
# Check if stable long enough
|
||||||
|
stable_duration = timestamp - self._truck_stable_since
|
||||||
|
if stable_duration >= self._stabilize_seconds:
|
||||||
|
if not self._truck_is_stable:
|
||||||
|
self._truck_is_stable = True
|
||||||
|
print(f"[BATCH] Truk stabil selama {stable_duration:.1f}s. Memulai counting...")
|
||||||
|
self._start_batch(timestamp)
|
||||||
|
|
||||||
|
elif self._state == BatchState.WAITING_FOR_ACTIVITY:
|
||||||
|
if truck_detected:
|
||||||
|
self._truck_last_seen = timestamp
|
||||||
|
|
||||||
|
# Check if truck has been gone for tolerance period
|
||||||
|
time_since_last_seen = timestamp - self._truck_last_seen
|
||||||
|
if not truck_detected and time_since_last_seen >= self._truck_gone_tolerance:
|
||||||
|
# Truck has truly left! NOW we finalize the batch.
|
||||||
|
wait_duration = timestamp - self._waiting_since
|
||||||
|
print(
|
||||||
|
f"[BATCH] Truk pergi setelah menunggu {wait_duration:.0f}s. "
|
||||||
|
f"Batch selesai."
|
||||||
|
)
|
||||||
|
self._end_batch(timestamp, self._pending_loading, self._pending_unloading)
|
||||||
|
|
||||||
|
def update_sacks(
|
||||||
|
self,
|
||||||
|
has_crossing_event: bool,
|
||||||
|
sacks_in_area_count: int,
|
||||||
|
timestamp: float,
|
||||||
|
loading_count: int = 0,
|
||||||
|
unloading_count: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""Called during COUNTING_SACKS and WAITING_FOR_ACTIVITY states.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
has_crossing_event: True if a sack crossed the counting line this frame
|
||||||
|
sacks_in_area_count: number of sacks currently detected in truck area
|
||||||
|
timestamp: current time.time()
|
||||||
|
loading_count: current cumulative loading count
|
||||||
|
unloading_count: current cumulative unloading count
|
||||||
|
"""
|
||||||
|
# WAITING_FOR_ACTIVITY: if sacks appear again, resume counting in the SAME batch
|
||||||
|
if self._state == BatchState.WAITING_FOR_ACTIVITY:
|
||||||
|
if has_crossing_event or sacks_in_area_count > 0:
|
||||||
|
wait_duration = timestamp - self._waiting_since
|
||||||
|
print(
|
||||||
|
f"[BATCH] Aktivitas karung terdeteksi setelah {wait_duration:.0f}s menunggu. "
|
||||||
|
f"Melanjutkan counting batch #{self._current_batch_id}..."
|
||||||
|
)
|
||||||
|
self._state = BatchState.COUNTING_SACKS
|
||||||
|
self._last_sack_crossing_time = timestamp
|
||||||
|
self._last_sack_seen_in_area_time = timestamp
|
||||||
|
# Fall through to counting logic below
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._state != BatchState.COUNTING_SACKS:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update activity timers
|
||||||
|
if has_crossing_event:
|
||||||
|
self._last_sack_crossing_time = timestamp
|
||||||
|
|
||||||
|
if sacks_in_area_count > 0:
|
||||||
|
self._last_sack_seen_in_area_time = timestamp
|
||||||
|
|
||||||
|
# Store latest counts for when batch eventually ends
|
||||||
|
self._pending_loading = loading_count
|
||||||
|
self._pending_unloading = unloading_count
|
||||||
|
|
||||||
|
# Check pause condition: no sack activity for timeout period
|
||||||
|
batch_duration = timestamp - self._batch_start_time
|
||||||
|
time_since_last_crossing = timestamp - self._last_sack_crossing_time
|
||||||
|
time_since_last_sack_seen = timestamp - self._last_sack_seen_in_area_time
|
||||||
|
|
||||||
|
if (
|
||||||
|
batch_duration >= self._min_batch_duration
|
||||||
|
and time_since_last_crossing >= self._sack_idle_timeout
|
||||||
|
and time_since_last_sack_seen >= self._sack_idle_timeout
|
||||||
|
):
|
||||||
|
print(
|
||||||
|
f"[BATCH] Tidak ada aktivitas karung selama {self._sack_idle_timeout}s. "
|
||||||
|
f"Menunggu truk pergi atau palet selanjutnya..."
|
||||||
|
)
|
||||||
|
self._state = BatchState.WAITING_FOR_ACTIVITY
|
||||||
|
self._waiting_since = timestamp
|
||||||
|
self._truck_last_seen = timestamp # Reset agar tolerance timer mulai dari 0, bukan dari awal batch
|
||||||
|
|
||||||
|
# -- Public API: Backward-compatible update (legacy) --
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
truck_detected: bool,
|
||||||
|
timestamp: float,
|
||||||
|
loading_count: int = 0,
|
||||||
|
unloading_count: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""Legacy update method — kept for backward compatibility."""
|
||||||
|
if self._state in (BatchState.IDLE, BatchState.TRUCK_STABILIZING):
|
||||||
|
self.update_truck(truck_detected, None, timestamp)
|
||||||
|
elif self._state in (BatchState.COUNTING_SACKS, BatchState.WAITING_FOR_ACTIVITY):
|
||||||
|
self.update_sacks(
|
||||||
|
has_crossing_event=False,
|
||||||
|
sacks_in_area_count=1 if truck_detected else 0,
|
||||||
|
timestamp=timestamp,
|
||||||
|
loading_count=loading_count,
|
||||||
|
unloading_count=unloading_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Properties --
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> str:
|
||||||
|
"""Return current state as human-readable string."""
|
||||||
|
return self._state.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_batch_id(self) -> int | None:
|
||||||
|
return self._current_batch_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
"""True during COUNTING or WAITING (batch is still open)."""
|
||||||
|
return self._state in (BatchState.COUNTING_SACKS, BatchState.WAITING_FOR_ACTIVITY)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_counting(self) -> bool:
|
||||||
|
"""True only during active sack counting."""
|
||||||
|
return self._state == BatchState.COUNTING_SACKS
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_waiting(self) -> bool:
|
||||||
|
"""True when paused waiting for next pallet or truck departure."""
|
||||||
|
return self._state == BatchState.WAITING_FOR_ACTIVITY
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_stabilizing(self) -> bool:
|
||||||
|
return self._state == BatchState.TRUCK_STABILIZING
|
||||||
|
|
||||||
|
@property
|
||||||
|
def history(self) -> list[BatchRecord]:
|
||||||
|
return list(self._history)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def batch_duration(self) -> float:
|
||||||
|
"""Duration of current batch in seconds (0 if not active)."""
|
||||||
|
if not self.is_active:
|
||||||
|
return 0.0
|
||||||
|
return time.time() - self._batch_start_time
|
||||||
|
|
||||||
|
@property
|
||||||
|
def time_since_last_sack_activity(self) -> float:
|
||||||
|
"""Seconds since last sack crossed line or seen in area."""
|
||||||
|
if not self.is_active:
|
||||||
|
return 0.0
|
||||||
|
now = time.time()
|
||||||
|
last_activity = max(self._last_sack_crossing_time, self._last_sack_seen_in_area_time)
|
||||||
|
return now - last_activity if last_activity > 0 else 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def waiting_duration(self) -> float:
|
||||||
|
"""How long we've been in WAITING_FOR_ACTIVITY state."""
|
||||||
|
if self._state != BatchState.WAITING_FOR_ACTIVITY:
|
||||||
|
return 0.0
|
||||||
|
return time.time() - self._waiting_since
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stabilize_progress(self) -> float:
|
||||||
|
"""Progress of truck stabilization (0.0 to 1.0)."""
|
||||||
|
if self._state != BatchState.TRUCK_STABILIZING:
|
||||||
|
return 0.0
|
||||||
|
if self._stabilize_seconds <= 0.0:
|
||||||
|
return 1.0
|
||||||
|
elapsed = time.time() - self._truck_stable_since
|
||||||
|
return min(1.0, elapsed / self._stabilize_seconds)
|
||||||
|
|
||||||
|
def resume_batch(
|
||||||
|
self,
|
||||||
|
batch_id: int,
|
||||||
|
start_time: float,
|
||||||
|
loading_count: int,
|
||||||
|
unloading_count: int,
|
||||||
|
) -> None:
|
||||||
|
"""Resume a previously finalized batch."""
|
||||||
|
self._current_batch_id = batch_id
|
||||||
|
self._batch_counter = max(self._batch_counter, batch_id)
|
||||||
|
self._batch_start_time = start_time
|
||||||
|
self._pending_loading = loading_count
|
||||||
|
self._pending_unloading = unloading_count
|
||||||
|
self._state = BatchState.COUNTING_SACKS
|
||||||
|
|
||||||
|
# Pop from history if it was just completed
|
||||||
|
if self._history and self._history[-1].batch_id == batch_id:
|
||||||
|
self._history.pop()
|
||||||
|
|
||||||
|
# -- Private methods --
|
||||||
|
|
||||||
|
def _start_batch(self, timestamp: float) -> None:
|
||||||
|
self._batch_counter += 1
|
||||||
|
self._current_batch_id = self._batch_counter
|
||||||
|
self._batch_start_time = timestamp
|
||||||
|
self._last_sack_crossing_time = timestamp # Grace period
|
||||||
|
self._last_sack_seen_in_area_time = timestamp # Grace period
|
||||||
|
self._pending_loading = 0
|
||||||
|
self._pending_unloading = 0
|
||||||
|
self._state = BatchState.COUNTING_SACKS
|
||||||
|
for cb in self._on_batch_start:
|
||||||
|
cb(self._current_batch_id, timestamp)
|
||||||
|
|
||||||
|
def _end_batch(
|
||||||
|
self,
|
||||||
|
timestamp: float,
|
||||||
|
loading_count: int,
|
||||||
|
unloading_count: int,
|
||||||
|
) -> None:
|
||||||
|
record = BatchRecord(
|
||||||
|
batch_id=self._current_batch_id or 0,
|
||||||
|
start_time=self._batch_start_time,
|
||||||
|
end_time=timestamp,
|
||||||
|
loading_count=loading_count,
|
||||||
|
unloading_count=unloading_count,
|
||||||
|
)
|
||||||
|
self._history.append(record)
|
||||||
|
self._state = BatchState.IDLE
|
||||||
|
self._current_batch_id = None
|
||||||
|
self._truck_last_centroid = None
|
||||||
|
self._truck_is_stable = False
|
||||||
|
for cb in self._on_batch_end:
|
||||||
|
cb(record)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Configuration loader — reads .env and exposes typed settings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
"""Immutable application configuration."""
|
||||||
|
|
||||||
|
# Stream sources
|
||||||
|
local_rtsp: str = ""
|
||||||
|
jetson_rtsp: str = ""
|
||||||
|
|
||||||
|
# Model paths
|
||||||
|
sack_model_path: str = "./models/sack-model.pt"
|
||||||
|
truck_model_path: str = "./models/truck-model.pt"
|
||||||
|
|
||||||
|
# Counting line (fractions of frame dimensions)
|
||||||
|
counting_line_y: float = 0.60
|
||||||
|
counting_line_x_start: float = 0.38
|
||||||
|
counting_line_x_end: float = 0.72
|
||||||
|
|
||||||
|
# Detection confidence
|
||||||
|
sack_conf: float = 0.40
|
||||||
|
truck_conf: float = 0.50
|
||||||
|
|
||||||
|
# Batch management
|
||||||
|
batch_timeout_seconds: float = 30.0
|
||||||
|
|
||||||
|
# Output
|
||||||
|
csv_output_dir: str = "./output"
|
||||||
|
seed: int = 42
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(env_path: str = ".env") -> Config:
|
||||||
|
"""Load configuration from .env file and environment variables."""
|
||||||
|
load_dotenv(env_path)
|
||||||
|
|
||||||
|
return Config(
|
||||||
|
local_rtsp=os.getenv("LOCAL_RTSP", ""),
|
||||||
|
jetson_rtsp=os.getenv("JETSON_RTSP", ""),
|
||||||
|
sack_model_path=os.getenv("MODEL_SACK_PATH", "./models/sack-model.pt"),
|
||||||
|
truck_model_path=os.getenv(
|
||||||
|
"MODEL_TRUCK_PATH", "./models/truck-model.pt"
|
||||||
|
),
|
||||||
|
counting_line_y=float(os.getenv("COUNTING_LINE_Y", "0.60")),
|
||||||
|
counting_line_x_start=float(
|
||||||
|
os.getenv("COUNTING_LINE_X_START", "0.38")
|
||||||
|
),
|
||||||
|
counting_line_x_end=float(os.getenv("COUNTING_LINE_X_END", "0.72")),
|
||||||
|
sack_conf=float(os.getenv("SACK_CONF_THRESHOLD", "0.40")),
|
||||||
|
truck_conf=float(os.getenv("TRUCK_CONF_THRESHOLD", "0.50")),
|
||||||
|
batch_timeout_seconds=float(
|
||||||
|
os.getenv("BATCH_TIMEOUT_SECONDS", "30")
|
||||||
|
),
|
||||||
|
csv_output_dir=os.getenv("CSV_OUTPUT_DIR", "./output"),
|
||||||
|
seed=int(os.getenv("DATA_SEED", "42")),
|
||||||
|
)
|
||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
"""Line-crossing counter — hybrid zone-based state tracking.
|
||||||
|
|
||||||
|
Counting logic (Low-FPS robust):
|
||||||
|
Uses y1 (top edge) of the stabilized sack bounding box.
|
||||||
|
|
||||||
|
Each track_id goes through states:
|
||||||
|
UNKNOWN → ABOVE → COUNTED (when seen below line)
|
||||||
|
UNKNOWN → BELOW (ghost/appeared below line first → never counted)
|
||||||
|
|
||||||
|
Loading: track had state ABOVE, now detected BELOW the zone
|
||||||
|
Unloading: track had state BELOW, now detected ABOVE the zone (if needed)
|
||||||
|
|
||||||
|
3-Layer deduplication:
|
||||||
|
Layer 1: State guard — must have been ABOVE before counting
|
||||||
|
Layer 2: Spatial dedup radius — same position can't trigger twice
|
||||||
|
Layer 3: Track ID — one track_id can only be counted once per direction
|
||||||
|
|
||||||
|
This approach is immune to low FPS because it doesn't require
|
||||||
|
detecting the exact frame of crossing. It only needs the track
|
||||||
|
to have been seen ABOVE the line at ANY point in its lifetime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from src.interfaces import Detection
|
||||||
|
|
||||||
|
|
||||||
|
class LineCrossCounter:
|
||||||
|
"""Counts sacks crossing a horizontal zone using y1 (top edge).
|
||||||
|
|
||||||
|
The zone is a band [line_y - margin, line_y + margin].
|
||||||
|
A sack is "above" if y1 < line_y - margin,
|
||||||
|
"below" if y1 > line_y + margin.
|
||||||
|
While y1 is inside the band, state is held (no trigger).
|
||||||
|
|
||||||
|
Loading = track was ever "above", now "below" (entered truck)
|
||||||
|
Unloading = track was ever "below", now "above" (left truck)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
line_y: int,
|
||||||
|
line_x_start: int,
|
||||||
|
line_x_end: int,
|
||||||
|
margin: int = 20,
|
||||||
|
dedup_radius: float = 30.0,
|
||||||
|
) -> None:
|
||||||
|
self._line_y = line_y
|
||||||
|
self._line_x_start = line_x_start
|
||||||
|
self._line_x_end = line_x_end
|
||||||
|
self._margin = margin
|
||||||
|
self._dedup_radius = dedup_radius
|
||||||
|
|
||||||
|
self._loading_count = 0
|
||||||
|
self._unloading_count = 0
|
||||||
|
|
||||||
|
# track_id -> zone state for y1: "above" | "below" | None
|
||||||
|
self._state: dict[int, str | None] = {}
|
||||||
|
# track_id -> whether this track has EVER been in each zone
|
||||||
|
self._has_been_above: dict[int, bool] = {}
|
||||||
|
self._has_been_below: dict[int, bool] = {}
|
||||||
|
# track_id -> set of directions already counted
|
||||||
|
self._counted: dict[int, set[str]] = {}
|
||||||
|
# track_id -> initial coordinates (cx, y1) when first tracked
|
||||||
|
self._entry_points: dict[int, tuple[float, float]] = {}
|
||||||
|
# list of active deduplication circles
|
||||||
|
self._dedup_circles: list[dict] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def entry_points(self) -> dict[int, tuple[float, float]]:
|
||||||
|
return self._entry_points
|
||||||
|
|
||||||
|
@property
|
||||||
|
def counted_tracks(self) -> dict[int, set[str]]:
|
||||||
|
return self._counted
|
||||||
|
|
||||||
|
@property
|
||||||
|
def line_y(self) -> int:
|
||||||
|
return self._line_y
|
||||||
|
|
||||||
|
@line_y.setter
|
||||||
|
def line_y(self, value: int) -> None:
|
||||||
|
self._line_y = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def line_x_start(self) -> int:
|
||||||
|
return self._line_x_start
|
||||||
|
|
||||||
|
@line_x_start.setter
|
||||||
|
def line_x_start(self, value: int) -> None:
|
||||||
|
self._line_x_start = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def line_x_end(self) -> int:
|
||||||
|
return self._line_x_end
|
||||||
|
|
||||||
|
@line_x_end.setter
|
||||||
|
def line_x_end(self, value: int) -> None:
|
||||||
|
self._line_x_end = value
|
||||||
|
|
||||||
|
def update(self, detections: list[Detection]) -> list[dict]:
|
||||||
|
"""Process detections, return list of crossing events.
|
||||||
|
|
||||||
|
Hybrid approach:
|
||||||
|
- Tracks zone state per frame (above/below/in-band)
|
||||||
|
- BUT uses accumulated history (has_been_above) for counting decision
|
||||||
|
- A track counts as "loading" when:
|
||||||
|
1. It has been seen ABOVE the line at any previous point
|
||||||
|
2. Its current y1 is now BELOW the line
|
||||||
|
3. It hasn't been counted for loading yet
|
||||||
|
4. It passes spatial dedup check
|
||||||
|
"""
|
||||||
|
now_t = time.time()
|
||||||
|
events: list[dict] = []
|
||||||
|
upper = self._line_y - self._margin
|
||||||
|
lower = self._line_y + self._margin
|
||||||
|
|
||||||
|
# Clean up expired dedup circles (older than 3.0 seconds)
|
||||||
|
self._dedup_circles = [c for c in self._dedup_circles if (now_t - c["time"]) <= 3.0]
|
||||||
|
|
||||||
|
for det in detections:
|
||||||
|
if det.track_id is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
x1, y1, x2, y2 = det.bbox
|
||||||
|
cx = (x1 + x2) / 2.0
|
||||||
|
tid = det.track_id
|
||||||
|
|
||||||
|
if tid not in self._entry_points:
|
||||||
|
self._entry_points[tid] = (cx, y1)
|
||||||
|
|
||||||
|
# Skip if centroid X outside counting bounds
|
||||||
|
if cx < self._line_x_start or cx > self._line_x_end:
|
||||||
|
continue
|
||||||
|
|
||||||
|
counted_dirs = self._counted.setdefault(tid, set())
|
||||||
|
|
||||||
|
# Determine y1 zone state (top edge of sack bbox)
|
||||||
|
if y1 < upper:
|
||||||
|
new_state = "above"
|
||||||
|
elif y1 > lower:
|
||||||
|
new_state = "below"
|
||||||
|
else:
|
||||||
|
new_state = self._state.get(tid) # in band: hold
|
||||||
|
|
||||||
|
prev_state = self._state.get(tid)
|
||||||
|
self._state[tid] = new_state
|
||||||
|
|
||||||
|
# Track zone history — CRITICAL for low-FPS robustness
|
||||||
|
# Once a track has been seen above/below, it stays recorded forever
|
||||||
|
if new_state == "above":
|
||||||
|
self._has_been_above[tid] = True
|
||||||
|
elif new_state == "below":
|
||||||
|
self._has_been_below[tid] = True
|
||||||
|
|
||||||
|
# --- HYBRID COUNTING LOGIC ---
|
||||||
|
# Loading: track was EVER above, NOW below (entered truck from top)
|
||||||
|
# This works even if the track jumped over the line between frames
|
||||||
|
is_loading = (
|
||||||
|
new_state == "below"
|
||||||
|
and self._has_been_above.get(tid, False)
|
||||||
|
and "loading" not in counted_dirs
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unloading: track was EVER below, NOW above (left truck)
|
||||||
|
is_unloading = (
|
||||||
|
new_state == "above"
|
||||||
|
and self._has_been_below.get(tid, False)
|
||||||
|
and "unloading" not in counted_dirs
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_loading or is_unloading:
|
||||||
|
# Check spatial distance against all active dedup circles
|
||||||
|
is_duplicate = False
|
||||||
|
for circle in self._dedup_circles:
|
||||||
|
dist = ((cx - circle["x"]) ** 2 + (y1 - circle["y"]) ** 2) ** 0.5
|
||||||
|
if dist <= self._dedup_radius:
|
||||||
|
is_duplicate = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if is_duplicate:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add this coordinate to the active dedup circles
|
||||||
|
self._dedup_circles.append({
|
||||||
|
"x": cx,
|
||||||
|
"y": y1,
|
||||||
|
"time": now_t,
|
||||||
|
"track_id": tid
|
||||||
|
})
|
||||||
|
|
||||||
|
if is_loading:
|
||||||
|
self._loading_count += 1
|
||||||
|
counted_dirs.add("loading")
|
||||||
|
events.append({
|
||||||
|
"track_id": tid,
|
||||||
|
"direction": "loading",
|
||||||
|
"cx": cx,
|
||||||
|
"cy": y1
|
||||||
|
})
|
||||||
|
|
||||||
|
elif is_unloading:
|
||||||
|
self._unloading_count += 1
|
||||||
|
counted_dirs.add("unloading")
|
||||||
|
events.append({
|
||||||
|
"track_id": tid,
|
||||||
|
"direction": "unloading",
|
||||||
|
"cx": cx,
|
||||||
|
"cy": y1
|
||||||
|
})
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
@property
|
||||||
|
def loading_count(self) -> int:
|
||||||
|
return self._loading_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unloading_count(self) -> int:
|
||||||
|
return self._unloading_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def net_count(self) -> int:
|
||||||
|
return self._loading_count - self._unloading_count
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset all counters (new batch)."""
|
||||||
|
self._loading_count = 0
|
||||||
|
self._unloading_count = 0
|
||||||
|
self._state.clear()
|
||||||
|
self._has_been_above.clear()
|
||||||
|
self._has_been_below.clear()
|
||||||
|
self._counted.clear()
|
||||||
|
self._entry_points.clear()
|
||||||
|
self._dedup_circles.clear()
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""Dashboard overlay — draws counting info onto the video frame.
|
||||||
|
|
||||||
|
Draws: truck ROI, counting zone (band), sack bounding boxes with y1
|
||||||
|
marker (the crossing trigger edge), stats panel, batch history,
|
||||||
|
and system state indicator.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from src.batch import BatchRecord
|
||||||
|
from src.interfaces import Detection
|
||||||
|
from src.truck_roi import TruckROI
|
||||||
|
|
||||||
|
|
||||||
|
# Colors (BGR)
|
||||||
|
GREEN = (0, 200, 0)
|
||||||
|
RED = (0, 0, 220)
|
||||||
|
CYAN = (220, 200, 0)
|
||||||
|
WHITE = (255, 255, 255)
|
||||||
|
YELLOW = (0, 230, 255)
|
||||||
|
MAGENTA = (255, 0, 255)
|
||||||
|
ORANGE = (0, 165, 255)
|
||||||
|
GRAY = (140, 140, 140)
|
||||||
|
DARK_GREEN = (0, 130, 0)
|
||||||
|
LIGHT_BLUE = (255, 200, 100)
|
||||||
|
|
||||||
|
|
||||||
|
# State display labels and colors
|
||||||
|
STATE_DISPLAY = {
|
||||||
|
"IDLE": ("MENCARI TRUK...", ORANGE),
|
||||||
|
"TRUCK_STABILIZING": ("TRUK TERDETEKSI - STABILISASI", YELLOW),
|
||||||
|
"COUNTING_SACKS": ("MENGHITUNG KARUNG", GREEN),
|
||||||
|
"WAITING_FOR_ACTIVITY": ("MENUNGGU PALET / TRUK PERGI", LIGHT_BLUE),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardOverlay:
|
||||||
|
"""Draws detection boxes, ROI, counting line, and stats onto frames."""
|
||||||
|
|
||||||
|
def draw(
|
||||||
|
self,
|
||||||
|
frame: np.ndarray,
|
||||||
|
detections: list[Detection],
|
||||||
|
roi: TruckROI | None,
|
||||||
|
loading_count: int,
|
||||||
|
unloading_count: int,
|
||||||
|
batch_id: int | None,
|
||||||
|
history: list[BatchRecord] | None = None,
|
||||||
|
system_state: str = "IDLE",
|
||||||
|
batch_duration: float = 0.0,
|
||||||
|
idle_timer: float = 0.0,
|
||||||
|
stabilize_progress: float = 0.0,
|
||||||
|
waiting_duration: float = 0.0,
|
||||||
|
) -> np.ndarray:
|
||||||
|
out = frame.copy()
|
||||||
|
if roi is not None:
|
||||||
|
self._draw_roi(out, roi)
|
||||||
|
self._draw_counting_zone(out, roi)
|
||||||
|
self._draw_detections(out, detections)
|
||||||
|
self._draw_stats(out, loading_count, unloading_count, batch_id, history)
|
||||||
|
self._draw_system_state(
|
||||||
|
out, system_state, batch_duration, idle_timer, stabilize_progress,
|
||||||
|
waiting_duration,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _draw_roi(self, frame: np.ndarray, roi: TruckROI) -> None:
|
||||||
|
cv2.rectangle(
|
||||||
|
frame, (roi.x1, roi.y1), (roi.x2, roi.y2), ORANGE, 2,
|
||||||
|
)
|
||||||
|
cv2.putText(
|
||||||
|
frame, f"TRUCK ROI ({roi.confidence:.0%})",
|
||||||
|
(roi.x1, roi.y1 - 8),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, ORANGE, 1,
|
||||||
|
)
|
||||||
|
# Draw truck top reference line (dashed via short segments)
|
||||||
|
for x in range(roi.x1, roi.x2, 20):
|
||||||
|
cv2.line(frame, (x, roi.y1), (min(x + 10, roi.x2), roi.y1), GRAY, 1)
|
||||||
|
|
||||||
|
def _draw_counting_zone(
|
||||||
|
self, frame: np.ndarray, roi: TruckROI, margin: int = 20,
|
||||||
|
) -> None:
|
||||||
|
y = roi.line_y
|
||||||
|
# Draw zone band (semi-transparent)
|
||||||
|
overlay = frame.copy()
|
||||||
|
cv2.rectangle(
|
||||||
|
overlay, (roi.x1, y - margin), (roi.x2, y + margin),
|
||||||
|
MAGENTA, -1,
|
||||||
|
)
|
||||||
|
cv2.addWeighted(overlay, 0.15, frame, 0.85, 0, frame)
|
||||||
|
# Draw center line
|
||||||
|
cv2.line(frame, (roi.x1, y), (roi.x2, y), MAGENTA, 2)
|
||||||
|
cv2.putText(
|
||||||
|
frame,
|
||||||
|
f"COUNT LINE Y={y} (y1 trigger)",
|
||||||
|
(roi.x1, y - margin - 8),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, MAGENTA, 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_detections(
|
||||||
|
self,
|
||||||
|
frame: np.ndarray,
|
||||||
|
detections: list[Detection],
|
||||||
|
) -> None:
|
||||||
|
for det in detections:
|
||||||
|
x1, y1, x2, y2 = [int(v) for v in det.bbox]
|
||||||
|
label = "sack"
|
||||||
|
if det.track_id is not None:
|
||||||
|
label += f" #{det.track_id}"
|
||||||
|
label += f" {det.confidence:.0%}"
|
||||||
|
|
||||||
|
cv2.rectangle(frame, (x1, y1), (x2, y2), CYAN, 2)
|
||||||
|
cv2.putText(
|
||||||
|
frame, label, (x1, y1 - 6),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.4, CYAN, 1,
|
||||||
|
)
|
||||||
|
# Mark TOP edge (y1) — the crossing trigger
|
||||||
|
cv2.line(frame, (x1, y1), (x2, y1), GREEN, 3)
|
||||||
|
|
||||||
|
def _draw_stats(
|
||||||
|
self,
|
||||||
|
frame: np.ndarray,
|
||||||
|
loading: int,
|
||||||
|
unloading: int,
|
||||||
|
batch_id: int | None,
|
||||||
|
history: list[BatchRecord] | None,
|
||||||
|
) -> None:
|
||||||
|
# Panel background
|
||||||
|
cv2.rectangle(frame, (10, 10), (320, 160), (0, 0, 0), -1)
|
||||||
|
cv2.rectangle(frame, (10, 10), (320, 160), WHITE, 1)
|
||||||
|
|
||||||
|
batch_text = f"Batch #{batch_id}" if batch_id else "IDLE"
|
||||||
|
net = loading - unloading
|
||||||
|
y0 = 35
|
||||||
|
|
||||||
|
cv2.putText(
|
||||||
|
frame, batch_text, (20, y0),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.7, YELLOW, 2,
|
||||||
|
)
|
||||||
|
cv2.putText(
|
||||||
|
frame, f"Loading: {loading}", (20, y0 + 30),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.6, GREEN, 2,
|
||||||
|
)
|
||||||
|
cv2.putText(
|
||||||
|
frame, f"Unloading: {unloading}", (20, y0 + 60),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.6, RED, 2,
|
||||||
|
)
|
||||||
|
cv2.putText(
|
||||||
|
frame, f"Net: {net}", (20, y0 + 90),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.6, WHITE, 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# History (last 3 batches)
|
||||||
|
if history:
|
||||||
|
y_h = 180
|
||||||
|
cv2.putText(
|
||||||
|
frame, "HISTORY", (20, y_h),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, YELLOW, 1,
|
||||||
|
)
|
||||||
|
for rec in history[-3:]:
|
||||||
|
y_h += 22
|
||||||
|
txt = (
|
||||||
|
f"B#{rec.batch_id}: "
|
||||||
|
f"L={rec.loading_count} "
|
||||||
|
f"U={rec.unloading_count} "
|
||||||
|
f"Net={rec.net_count}"
|
||||||
|
)
|
||||||
|
cv2.putText(
|
||||||
|
frame, txt, (20, y_h),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.4, WHITE, 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_system_state(
|
||||||
|
self,
|
||||||
|
frame: np.ndarray,
|
||||||
|
system_state: str,
|
||||||
|
batch_duration: float,
|
||||||
|
idle_timer: float,
|
||||||
|
stabilize_progress: float,
|
||||||
|
waiting_duration: float = 0.0,
|
||||||
|
) -> None:
|
||||||
|
"""Draw system state indicator bar at bottom of frame."""
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
|
||||||
|
# Get display info for current state
|
||||||
|
label, color = STATE_DISPLAY.get(system_state, ("UNKNOWN", GRAY))
|
||||||
|
|
||||||
|
# Draw state bar background
|
||||||
|
bar_h = 36
|
||||||
|
bar_y = h - bar_h
|
||||||
|
overlay = frame.copy()
|
||||||
|
cv2.rectangle(overlay, (0, bar_y), (w, h), (0, 0, 0), -1)
|
||||||
|
cv2.addWeighted(overlay, 0.7, frame, 0.3, 0, frame)
|
||||||
|
|
||||||
|
# Draw colored indicator dot
|
||||||
|
cv2.circle(frame, (20, bar_y + bar_h // 2), 8, color, -1)
|
||||||
|
cv2.circle(frame, (20, bar_y + bar_h // 2), 8, WHITE, 1)
|
||||||
|
|
||||||
|
# Draw state label
|
||||||
|
cv2.putText(
|
||||||
|
frame, label, (36, bar_y + bar_h // 2 + 5),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Draw additional info based on state
|
||||||
|
if system_state == "TRUCK_STABILIZING":
|
||||||
|
# Draw stabilization progress bar
|
||||||
|
prog_x = 340
|
||||||
|
prog_w = 150
|
||||||
|
prog_h = 14
|
||||||
|
prog_y = bar_y + (bar_h - prog_h) // 2
|
||||||
|
|
||||||
|
cv2.rectangle(frame, (prog_x, prog_y), (prog_x + prog_w, prog_y + prog_h), GRAY, 1)
|
||||||
|
fill_w = int(prog_w * stabilize_progress)
|
||||||
|
if fill_w > 0:
|
||||||
|
cv2.rectangle(frame, (prog_x, prog_y), (prog_x + fill_w, prog_y + prog_h), YELLOW, -1)
|
||||||
|
|
||||||
|
pct_text = f"{stabilize_progress * 100:.0f}%"
|
||||||
|
cv2.putText(
|
||||||
|
frame, pct_text, (prog_x + prog_w + 8, prog_y + prog_h - 2),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.4, YELLOW, 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif system_state == "COUNTING_SACKS":
|
||||||
|
# Draw batch duration and idle timer
|
||||||
|
info_x = 340
|
||||||
|
dur_text = f"Durasi: {batch_duration:.0f}s"
|
||||||
|
cv2.putText(
|
||||||
|
frame, dur_text, (info_x, bar_y + 15),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.4, WHITE, 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
if idle_timer > 0:
|
||||||
|
idle_color = RED if idle_timer > 7.0 else (YELLOW if idle_timer > 4.0 else WHITE)
|
||||||
|
idle_text = f"Idle: {idle_timer:.1f}s / 10s"
|
||||||
|
cv2.putText(
|
||||||
|
frame, idle_text, (info_x, bar_y + 30),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.4, idle_color, 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif system_state == "WAITING_FOR_ACTIVITY":
|
||||||
|
# Show waiting duration and batch info
|
||||||
|
info_x = 360
|
||||||
|
wait_text = f"Menunggu: {waiting_duration:.0f}s | Batch masih terbuka"
|
||||||
|
cv2.putText(
|
||||||
|
frame, wait_text, (info_x, bar_y + bar_h // 2 + 5),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.4, LIGHT_BLUE, 1,
|
||||||
|
)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""YOLO-based detectors for sacks and trucks.
|
||||||
|
|
||||||
|
Each detector is a single-responsibility unit (S). New model types can be
|
||||||
|
added as new classes without touching these (O).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from ultralytics import YOLO
|
||||||
|
|
||||||
|
from src.interfaces import Detection
|
||||||
|
|
||||||
|
|
||||||
|
class SackDetector:
|
||||||
|
"""Detects sacks (and persons) using a YOLO segmentation model."""
|
||||||
|
|
||||||
|
def __init__(self, model_path: str | YOLO, conf: float = 0.35) -> None:
|
||||||
|
self._model = model_path if isinstance(model_path, YOLO) else YOLO(model_path)
|
||||||
|
self._conf = conf
|
||||||
|
|
||||||
|
def detect(self, frame: np.ndarray) -> list[Detection]:
|
||||||
|
results = self._model.predict(
|
||||||
|
frame, conf=self._conf, verbose=False
|
||||||
|
)
|
||||||
|
return self._parse(results[0])
|
||||||
|
|
||||||
|
def _parse(self, result) -> list[Detection]:
|
||||||
|
detections: list[Detection] = []
|
||||||
|
masks = result.masks
|
||||||
|
for i, box in enumerate(result.boxes):
|
||||||
|
cls_id = int(box.cls[0])
|
||||||
|
name = self._model.names.get(cls_id, str(cls_id)) if isinstance(self._model.names, dict) else self._model.names[cls_id]
|
||||||
|
if name != "sack":
|
||||||
|
continue
|
||||||
|
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
||||||
|
mask = None
|
||||||
|
if masks is not None and i < len(masks):
|
||||||
|
mask = masks[i].data.cpu().numpy().squeeze()
|
||||||
|
detections.append(
|
||||||
|
Detection(
|
||||||
|
bbox=(x1, y1, x2, y2),
|
||||||
|
confidence=float(box.conf[0]),
|
||||||
|
class_id=cls_id,
|
||||||
|
class_name=name,
|
||||||
|
mask=mask,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return detections
|
||||||
|
|
||||||
|
|
||||||
|
class TruckDetector:
|
||||||
|
"""Detects trucks using a YOLO detection model."""
|
||||||
|
|
||||||
|
def __init__(self, model_path: str | YOLO, conf: float = 0.35) -> None:
|
||||||
|
self._model = model_path if isinstance(model_path, YOLO) else YOLO(model_path)
|
||||||
|
self._conf = conf
|
||||||
|
|
||||||
|
def detect(self, frame: np.ndarray) -> list[Detection]:
|
||||||
|
results = self._model.predict(
|
||||||
|
frame, conf=self._conf, verbose=False
|
||||||
|
)
|
||||||
|
return self._parse(results[0])
|
||||||
|
|
||||||
|
def _parse(self, result) -> list[Detection]:
|
||||||
|
detections: list[Detection] = []
|
||||||
|
if result.boxes is None or len(result.boxes) == 0:
|
||||||
|
return detections
|
||||||
|
for box in result.boxes:
|
||||||
|
cls_id = int(box.cls[0])
|
||||||
|
name = self._model.names.get(cls_id, str(cls_id)) if isinstance(self._model.names, dict) else self._model.names[cls_id]
|
||||||
|
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
||||||
|
detections.append(
|
||||||
|
Detection(
|
||||||
|
bbox=(x1, y1, x2, y2),
|
||||||
|
confidence=float(box.conf[0]),
|
||||||
|
class_id=cls_id,
|
||||||
|
class_name=name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return detections
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Abstract interfaces — all components code against these, never concretions.
|
||||||
|
|
||||||
|
Keeps Interface Segregation (I) and Dependency Inversion (D) satisfied.
|
||||||
|
Each protocol is tiny and single-purpose (S).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
# ── Data transfer objects ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Detection:
|
||||||
|
"""Single object detection."""
|
||||||
|
|
||||||
|
bbox: tuple[float, float, float, float] # x1, y1, x2, y2
|
||||||
|
confidence: float
|
||||||
|
class_id: int
|
||||||
|
class_name: str
|
||||||
|
track_id: int | None = None
|
||||||
|
mask: np.ndarray | None = None # segmentation mask (optional)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FrameResult:
|
||||||
|
"""All detections for one frame."""
|
||||||
|
|
||||||
|
detections: list[Detection] = field(default_factory=list)
|
||||||
|
frame_index: int = 0
|
||||||
|
timestamp: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Protocols ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class StreamSource(Protocol):
|
||||||
|
"""Reads frames from a video source."""
|
||||||
|
|
||||||
|
def open(self) -> bool: ...
|
||||||
|
def read(self) -> tuple[bool, np.ndarray | None]: ...
|
||||||
|
def release(self) -> None: ...
|
||||||
|
@property
|
||||||
|
def fps(self) -> float: ...
|
||||||
|
@property
|
||||||
|
def frame_size(self) -> tuple[int, int]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class Detector(Protocol):
|
||||||
|
"""Runs inference on a frame and returns detections."""
|
||||||
|
|
||||||
|
def detect(self, frame: np.ndarray) -> list[Detection]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class Tracker(Protocol):
|
||||||
|
"""Assigns persistent IDs to detections across frames."""
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self, frame: np.ndarray, detections: list[Detection]
|
||||||
|
) -> list[Detection]: ...
|
||||||
|
|
||||||
|
def reset(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class Counter(Protocol):
|
||||||
|
"""Counts objects crossing a virtual boundary."""
|
||||||
|
|
||||||
|
def update(self, detections: list[Detection]) -> None: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def loading_count(self) -> int: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unloading_count(self) -> int: ...
|
||||||
|
|
||||||
|
def reset(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class BatchManager(Protocol):
|
||||||
|
"""Manages batch lifecycle based on truck presence."""
|
||||||
|
|
||||||
|
def update(self, truck_detected: bool, timestamp: float) -> None: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_batch_id(self) -> int | None: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool: ...
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""CSV logger — persists batch summaries and counting events."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.batch import BatchRecord
|
||||||
|
|
||||||
|
|
||||||
|
class CSVLogger:
|
||||||
|
"""Appends batch summaries and sack events to CSV files."""
|
||||||
|
|
||||||
|
def __init__(self, output_dir: str) -> None:
|
||||||
|
self._dir = Path(output_dir)
|
||||||
|
self._dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self._batch_file = self._dir / "batch_summary.csv"
|
||||||
|
self._event_file = self._dir / "sack_events.csv"
|
||||||
|
|
||||||
|
self._init_batch_csv()
|
||||||
|
self._init_event_csv()
|
||||||
|
|
||||||
|
# ── Batch summary ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def log_batch(self, record: BatchRecord) -> None:
|
||||||
|
"""Append one completed batch row."""
|
||||||
|
with open(self._batch_file, "a", newline="") as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerow([
|
||||||
|
record.batch_id,
|
||||||
|
self._fmt(record.start_time),
|
||||||
|
self._fmt(record.end_time),
|
||||||
|
f"{record.duration_seconds:.1f}",
|
||||||
|
record.loading_count,
|
||||||
|
record.unloading_count,
|
||||||
|
record.net_count,
|
||||||
|
])
|
||||||
|
|
||||||
|
# ── Sack events ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def log_event(
|
||||||
|
self,
|
||||||
|
batch_id: int,
|
||||||
|
track_id: int,
|
||||||
|
direction: str,
|
||||||
|
timestamp: float,
|
||||||
|
) -> None:
|
||||||
|
"""Append one sack crossing event."""
|
||||||
|
with open(self._event_file, "a", newline="") as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerow([
|
||||||
|
self._fmt(timestamp),
|
||||||
|
batch_id,
|
||||||
|
"sack_crossed",
|
||||||
|
f"T-{track_id:04d}",
|
||||||
|
direction,
|
||||||
|
])
|
||||||
|
|
||||||
|
# ── Init ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _init_batch_csv(self) -> None:
|
||||||
|
if not self._batch_file.exists():
|
||||||
|
with open(self._batch_file, "w", newline="") as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerow([
|
||||||
|
"batch_id", "start_time", "end_time",
|
||||||
|
"duration_s", "loading", "unloading", "net",
|
||||||
|
])
|
||||||
|
|
||||||
|
def _init_event_csv(self) -> None:
|
||||||
|
if not self._event_file.exists():
|
||||||
|
with open(self._event_file, "w", newline="") as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerow([
|
||||||
|
"timestamp", "batch_id", "event",
|
||||||
|
"track_id", "direction",
|
||||||
|
])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fmt(ts: float) -> str:
|
||||||
|
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
+241
@@ -0,0 +1,241 @@
|
|||||||
|
"""Main pipeline — wires all components together via dependency injection.
|
||||||
|
|
||||||
|
Pipeline: Frame → TruckDetect → ROI → SackTrack → Stabilize → Count
|
||||||
|
The stabilizer sits between the tracker and counter, smoothing bbox
|
||||||
|
coordinates and holding lost tracks through momentary dropouts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import time
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
from src.batch import BatchLifecycleManager
|
||||||
|
from src.config import load_config, Config
|
||||||
|
from src.counting import LineCrossCounter
|
||||||
|
from src.dashboard import DashboardOverlay
|
||||||
|
from src.detection import TruckDetector
|
||||||
|
from src.logger import CSVLogger
|
||||||
|
from src.stabilizer import BboxStabilizer
|
||||||
|
from src.streaming import RTSPSource, VideoFileSource
|
||||||
|
from src.tracking import ByteTrackTracker
|
||||||
|
from src.truck_roi import TruckROITracker
|
||||||
|
|
||||||
|
|
||||||
|
def build_pipeline(cfg: Config, source_path: str | None = None):
|
||||||
|
"""Construct all components from config."""
|
||||||
|
|
||||||
|
# ── Stream source ────────────────────────────────────────────
|
||||||
|
if source_path:
|
||||||
|
stream = VideoFileSource(source_path)
|
||||||
|
elif cfg.local_rtsp:
|
||||||
|
stream = RTSPSource(cfg.local_rtsp)
|
||||||
|
else:
|
||||||
|
raise ValueError("No video source: pass --source or set LOCAL_RTSP")
|
||||||
|
|
||||||
|
if not stream.open():
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Cannot open stream: {source_path or cfg.local_rtsp}"
|
||||||
|
)
|
||||||
|
|
||||||
|
w, h = stream.frame_size
|
||||||
|
print(f"Stream opened: {w}x{h} @ {stream.fps:.1f} FPS")
|
||||||
|
|
||||||
|
# ── Components ───────────────────────────────────────────────
|
||||||
|
truck_detector = TruckDetector(cfg.truck_model_path, cfg.truck_conf)
|
||||||
|
tracker = ByteTrackTracker(cfg.sack_model_path, cfg.sack_conf)
|
||||||
|
stabilizer = BboxStabilizer(
|
||||||
|
ema_alpha=0.35,
|
||||||
|
max_hold_frames=10,
|
||||||
|
max_height_ratio=1.5,
|
||||||
|
min_height_ratio=0.70,
|
||||||
|
)
|
||||||
|
|
||||||
|
roi_tracker = TruckROITracker(frame_width=w, frame_height=h)
|
||||||
|
|
||||||
|
# Initial line position (updated dynamically by ROI tracker)
|
||||||
|
counter = LineCrossCounter(
|
||||||
|
line_y=int(h * 0.50),
|
||||||
|
line_x_start=int(w * 0.38),
|
||||||
|
line_x_end=int(w * 0.72),
|
||||||
|
margin=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
batch_mgr = BatchLifecycleManager(cfg.batch_timeout_seconds)
|
||||||
|
dashboard = DashboardOverlay()
|
||||||
|
logger = CSVLogger(cfg.csv_output_dir)
|
||||||
|
|
||||||
|
# ── Wire callbacks ───────────────────────────────────────────
|
||||||
|
def on_batch_start(batch_id: int, timestamp: float) -> None:
|
||||||
|
print(f"\n>>> BATCH #{batch_id} STARTED")
|
||||||
|
counter.reset()
|
||||||
|
stabilizer.reset()
|
||||||
|
roi_tracker.reset()
|
||||||
|
|
||||||
|
def on_batch_end(record) -> None:
|
||||||
|
print(
|
||||||
|
f"\n>>> BATCH #{record.batch_id} ENDED — "
|
||||||
|
f"L={record.loading_count} U={record.unloading_count} "
|
||||||
|
f"Net={record.net_count}"
|
||||||
|
)
|
||||||
|
logger.log_batch(record)
|
||||||
|
|
||||||
|
batch_mgr.on_batch_start(on_batch_start)
|
||||||
|
batch_mgr.on_batch_end(on_batch_end)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"stream": stream,
|
||||||
|
"truck_detector": truck_detector,
|
||||||
|
"tracker": tracker,
|
||||||
|
"stabilizer": stabilizer,
|
||||||
|
"roi_tracker": roi_tracker,
|
||||||
|
"counter": counter,
|
||||||
|
"batch_mgr": batch_mgr,
|
||||||
|
"dashboard": dashboard,
|
||||||
|
"logger": logger,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_sacks_in_roi(detections, roi):
|
||||||
|
"""Keep only sacks whose centroid X falls within the truck ROI."""
|
||||||
|
if roi is None:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
d for d in detections
|
||||||
|
if roi.contains_x((d.bbox[0] + d.bbox[2]) / 2.0)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run(cfg: Config, source_path: str | None = None) -> None:
|
||||||
|
"""Main processing loop — processes EVERY frame for accuracy."""
|
||||||
|
p = build_pipeline(cfg, source_path)
|
||||||
|
|
||||||
|
stream = p["stream"]
|
||||||
|
tracker = p["tracker"]
|
||||||
|
stabilizer = p["stabilizer"]
|
||||||
|
truck_det = p["truck_detector"]
|
||||||
|
roi_tracker = p["roi_tracker"]
|
||||||
|
counter = p["counter"]
|
||||||
|
batch_mgr = p["batch_mgr"]
|
||||||
|
dashboard = p["dashboard"]
|
||||||
|
logger = p["logger"]
|
||||||
|
|
||||||
|
frame_idx = 0
|
||||||
|
prev_loading = 0
|
||||||
|
prev_unloading = 0
|
||||||
|
|
||||||
|
# Truck detection runs every N frames (heavy model, truck moves slow)
|
||||||
|
TRUCK_DET_INTERVAL = 15
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
ok, frame = stream.read()
|
||||||
|
if not ok:
|
||||||
|
break
|
||||||
|
|
||||||
|
timestamp = time.time()
|
||||||
|
frame_idx += 1
|
||||||
|
|
||||||
|
# ── Truck detection (every N frames — truck is slow) ─
|
||||||
|
roi = roi_tracker.roi
|
||||||
|
if frame_idx % TRUCK_DET_INTERVAL == 0:
|
||||||
|
trucks = truck_det.detect(frame)
|
||||||
|
roi = roi_tracker.update(trucks)
|
||||||
|
|
||||||
|
truck_present = roi is not None and roi.confidence > 0
|
||||||
|
|
||||||
|
# Sync counter line to ROI
|
||||||
|
if roi is not None:
|
||||||
|
counter.line_y = roi.line_y
|
||||||
|
counter.line_x_start = roi.x1
|
||||||
|
counter.line_x_end = roi.x2
|
||||||
|
|
||||||
|
if frame_idx % TRUCK_DET_INTERVAL == 0:
|
||||||
|
batch_mgr.update(
|
||||||
|
truck_detected=truck_present,
|
||||||
|
timestamp=timestamp,
|
||||||
|
loading_count=counter.loading_count,
|
||||||
|
unloading_count=counter.unloading_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Track → Stabilize → Filter → Count ──────────────
|
||||||
|
tracked_sacks = []
|
||||||
|
if batch_mgr.is_active:
|
||||||
|
raw_tracked = tracker.update(frame, [])
|
||||||
|
stable = stabilizer.update(raw_tracked)
|
||||||
|
tracked_sacks = _filter_sacks_in_roi(stable, roi)
|
||||||
|
events = counter.update(tracked_sacks)
|
||||||
|
|
||||||
|
# Log crossing events
|
||||||
|
for ev in events:
|
||||||
|
logger.log_event(
|
||||||
|
batch_mgr.current_batch_id or 0,
|
||||||
|
ev["track_id"],
|
||||||
|
ev["direction"],
|
||||||
|
timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
if counter.loading_count != prev_loading:
|
||||||
|
print(
|
||||||
|
f" [F{frame_idx}] Loading: "
|
||||||
|
f"{counter.loading_count}"
|
||||||
|
)
|
||||||
|
if counter.unloading_count != prev_unloading:
|
||||||
|
print(
|
||||||
|
f" [F{frame_idx}] Unloading: "
|
||||||
|
f"{counter.unloading_count}"
|
||||||
|
)
|
||||||
|
prev_loading = counter.loading_count
|
||||||
|
prev_unloading = counter.unloading_count
|
||||||
|
|
||||||
|
# ── Dashboard ────────────────────────────────────────
|
||||||
|
viz = dashboard.draw(
|
||||||
|
frame=frame,
|
||||||
|
detections=tracked_sacks,
|
||||||
|
roi=roi,
|
||||||
|
loading_count=counter.loading_count,
|
||||||
|
unloading_count=counter.unloading_count,
|
||||||
|
batch_id=batch_mgr.current_batch_id,
|
||||||
|
history=batch_mgr.history,
|
||||||
|
)
|
||||||
|
|
||||||
|
cv2.imshow("Sack Counter", viz)
|
||||||
|
key = cv2.waitKey(1) & 0xFF
|
||||||
|
if key == ord("q"):
|
||||||
|
break
|
||||||
|
elif key == ord("r"):
|
||||||
|
counter.reset()
|
||||||
|
stabilizer.reset()
|
||||||
|
print("Counter reset manually")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
stream.release()
|
||||||
|
cv2.destroyAllWindows()
|
||||||
|
print(f"\nProcessed {frame_idx} frames")
|
||||||
|
print(
|
||||||
|
f"Final — Loading: {counter.loading_count} "
|
||||||
|
f"Unloading: {counter.unloading_count} "
|
||||||
|
f"Net: {counter.net_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Sack Counting System v3")
|
||||||
|
parser.add_argument(
|
||||||
|
"--source", type=str, default=None,
|
||||||
|
help="Video file path (overrides RTSP)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--env", type=str, default=".env",
|
||||||
|
help="Path to .env file",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
cfg = load_config(args.env)
|
||||||
|
run(cfg, args.source)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Bounding-box stabilizer — EMA smoothing + dual height clamping + dropout hold.
|
||||||
|
|
||||||
|
Addresses four occlusion/flickering problems:
|
||||||
|
1. Bbox jitter: raw detections jump 10-50px between frames.
|
||||||
|
Fix: EMA (exponential moving average) on bbox coordinates.
|
||||||
|
2. Bbox loss at line: worker's head/body blocks sack for 5-10 frames.
|
||||||
|
Fix: hold last known smoothed bbox for `max_hold_frames` (10 frames).
|
||||||
|
3. Height expansion spike: worker body merges into sack bbox.
|
||||||
|
Fix: clamp height expansion (`raw_h > smooth_h * max_h_ratio`).
|
||||||
|
4. Height shrinkage collapse: worker head/shoulder covers bottom of sack.
|
||||||
|
Fix: clamp height shrinkage (`raw_h < smooth_h * min_h_ratio`).
|
||||||
|
|
||||||
|
All rules are applied per track ID to maintain smooth trajectories.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.interfaces import Detection
|
||||||
|
|
||||||
|
|
||||||
|
class BboxStabilizer:
|
||||||
|
"""Smooths and holds bounding boxes per track ID against worker occlusion."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ema_alpha: float = 0.35,
|
||||||
|
max_hold_frames: int = 10,
|
||||||
|
max_height_ratio: float = 1.5,
|
||||||
|
min_height_ratio: float = 0.70,
|
||||||
|
) -> None:
|
||||||
|
self._alpha = ema_alpha
|
||||||
|
self._max_hold = max_hold_frames
|
||||||
|
self._max_h_ratio = max_height_ratio
|
||||||
|
self._min_h_ratio = min_height_ratio
|
||||||
|
|
||||||
|
# tid -> (smoothed_x1, smoothed_y1, smoothed_x2, smoothed_y2)
|
||||||
|
self._smooth: dict[int, tuple[float, float, float, float]] = {}
|
||||||
|
# tid -> frames since last real detection
|
||||||
|
self._age: dict[int, int] = {}
|
||||||
|
# tid -> last confidence and class info
|
||||||
|
self._meta: dict[int, tuple[float, int, str]] = {}
|
||||||
|
|
||||||
|
def update(self, detections: list[Detection]) -> list[Detection]:
|
||||||
|
"""Smooth incoming detections + inject held tracks during occlusion."""
|
||||||
|
seen_tids: set[int] = set()
|
||||||
|
result: list[Detection] = []
|
||||||
|
|
||||||
|
# 1. Process real detections — apply EMA & dual height clamping
|
||||||
|
for det in detections:
|
||||||
|
tid = det.track_id
|
||||||
|
if tid is None:
|
||||||
|
result.append(det)
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen_tids.add(tid)
|
||||||
|
self._age[tid] = 0
|
||||||
|
self._meta[tid] = (det.confidence, det.class_id, det.class_name)
|
||||||
|
|
||||||
|
x1, y1, x2, y2 = det.bbox
|
||||||
|
raw_h = y2 - y1
|
||||||
|
|
||||||
|
if tid in self._smooth:
|
||||||
|
sx1, sy1, sx2, sy2 = self._smooth[tid]
|
||||||
|
smooth_h = sy2 - sy1
|
||||||
|
|
||||||
|
if smooth_h > 0:
|
||||||
|
# Height expansion clamp (worker body merged)
|
||||||
|
if raw_h > smooth_h * self._max_h_ratio:
|
||||||
|
y2 = y1 + smooth_h * self._max_h_ratio
|
||||||
|
# Height shrinkage clamp (worker head/shoulder blocking bottom)
|
||||||
|
elif raw_h < smooth_h * self._min_h_ratio:
|
||||||
|
y2 = y1 + smooth_h * self._min_h_ratio
|
||||||
|
|
||||||
|
a = self._alpha
|
||||||
|
sx1 = a * x1 + (1 - a) * sx1
|
||||||
|
sy1 = a * y1 + (1 - a) * sy1
|
||||||
|
sx2 = a * x2 + (1 - a) * sx2
|
||||||
|
sy2 = a * y2 + (1 - a) * sy2
|
||||||
|
else:
|
||||||
|
sx1, sy1, sx2, sy2 = x1, y1, x2, y2
|
||||||
|
|
||||||
|
self._smooth[tid] = (sx1, sy1, sx2, sy2)
|
||||||
|
|
||||||
|
result.append(Detection(
|
||||||
|
bbox=(sx1, sy1, sx2, sy2),
|
||||||
|
confidence=det.confidence,
|
||||||
|
class_id=det.class_id,
|
||||||
|
class_name=det.class_name,
|
||||||
|
track_id=tid,
|
||||||
|
mask=det.mask,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 2. Hold tracks missing this frame (occlusion tolerance)
|
||||||
|
expired: list[int] = []
|
||||||
|
for tid in list(self._age.keys()):
|
||||||
|
if tid in seen_tids:
|
||||||
|
continue
|
||||||
|
self._age[tid] += 1
|
||||||
|
if self._age[tid] > self._max_hold:
|
||||||
|
expired.append(tid)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Inject held bbox from last smoothed position
|
||||||
|
sx1, sy1, sx2, sy2 = self._smooth[tid]
|
||||||
|
conf, cls_id, cls_name = self._meta[tid]
|
||||||
|
result.append(Detection(
|
||||||
|
bbox=(sx1, sy1, sx2, sy2),
|
||||||
|
confidence=conf * 0.85, # gentle decay during occlusion hold
|
||||||
|
class_id=cls_id,
|
||||||
|
class_name=cls_name,
|
||||||
|
track_id=tid,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 3. Clean up expired tracks
|
||||||
|
for tid in expired:
|
||||||
|
del self._smooth[tid]
|
||||||
|
del self._age[tid]
|
||||||
|
del self._meta[tid]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Clear all state (new batch)."""
|
||||||
|
self._smooth.clear()
|
||||||
|
self._age.clear()
|
||||||
|
self._meta.clear()
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Video stream sources — RTSP and file-based."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class VideoFileSource:
|
||||||
|
"""Reads frames from a local video file."""
|
||||||
|
|
||||||
|
def __init__(self, path: str) -> None:
|
||||||
|
self._path = path
|
||||||
|
self._cap: cv2.VideoCapture | None = None
|
||||||
|
|
||||||
|
def open(self) -> bool:
|
||||||
|
self._cap = cv2.VideoCapture(self._path)
|
||||||
|
return self._cap.isOpened()
|
||||||
|
|
||||||
|
def read(self) -> tuple[bool, np.ndarray | None]:
|
||||||
|
if self._cap is None:
|
||||||
|
return False, None
|
||||||
|
ret, frame = self._cap.read()
|
||||||
|
return ret, frame if ret else None
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
if self._cap is not None:
|
||||||
|
self._cap.release()
|
||||||
|
self._cap = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fps(self) -> float:
|
||||||
|
if self._cap is None:
|
||||||
|
return 0.0
|
||||||
|
return self._cap.get(cv2.CAP_PROP_FPS)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frame_size(self) -> tuple[int, int]:
|
||||||
|
if self._cap is None:
|
||||||
|
return (0, 0)
|
||||||
|
w = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
h = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
return (w, h)
|
||||||
|
|
||||||
|
|
||||||
|
class RTSPSource:
|
||||||
|
"""Reads frames from an RTSP stream."""
|
||||||
|
|
||||||
|
def __init__(self, url: str) -> None:
|
||||||
|
self._url = url
|
||||||
|
self._cap: cv2.VideoCapture | None = None
|
||||||
|
|
||||||
|
def open(self) -> bool:
|
||||||
|
self._cap = cv2.VideoCapture(self._url, cv2.CAP_FFMPEG)
|
||||||
|
return self._cap.isOpened()
|
||||||
|
|
||||||
|
def read(self) -> tuple[bool, np.ndarray | None]:
|
||||||
|
if self._cap is None:
|
||||||
|
return False, None
|
||||||
|
ret, frame = self._cap.read()
|
||||||
|
return ret, frame if ret else None
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
if self._cap is not None:
|
||||||
|
self._cap.release()
|
||||||
|
self._cap = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fps(self) -> float:
|
||||||
|
if self._cap is None:
|
||||||
|
return 0.0
|
||||||
|
return self._cap.get(cv2.CAP_PROP_FPS) or 25.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frame_size(self) -> tuple[int, int]:
|
||||||
|
if self._cap is None:
|
||||||
|
return (0, 0)
|
||||||
|
w = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
h = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
return (w, h)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""FastTrack wrapper — occlusion-aware tracker with custom tuning.
|
||||||
|
|
||||||
|
Uses Ultralytics FastTrack which handles:
|
||||||
|
- Kalman rollback on occlusion onset (restores pre-occlusion velocity)
|
||||||
|
- Enlarged search region during occlusion
|
||||||
|
- Re-identification of occluded tracks after reappearance
|
||||||
|
|
||||||
|
Our custom cfg/tracker.yaml tunes:
|
||||||
|
- track_buffer=60 (hold lost tracks ~2.4s to survive worker occlusion)
|
||||||
|
- new_track_thresh=0.3 (prevent duplicate IDs from spawning)
|
||||||
|
- active_occ_to_lost_thresh=15 (tolerate 15 occluded frames)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from ultralytics import YOLO
|
||||||
|
|
||||||
|
from src.interfaces import Detection
|
||||||
|
|
||||||
|
_TRACKER_CFG = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(__file__)), "cfg", "tracker.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ByteTrackTracker:
|
||||||
|
"""Tracks sacks across frames using FastTrack (occlusion-aware)."""
|
||||||
|
|
||||||
|
def __init__(self, model_path: str | YOLO, conf: float = 0.35) -> None:
|
||||||
|
if isinstance(model_path, YOLO):
|
||||||
|
self._model = model_path
|
||||||
|
self._model_path = getattr(model_path, "ckpt_path", str(model_path))
|
||||||
|
else:
|
||||||
|
self._model = YOLO(model_path)
|
||||||
|
self._model_path = model_path
|
||||||
|
self._conf = conf
|
||||||
|
self._tracker_cfg = _TRACKER_CFG if os.path.exists(_TRACKER_CFG) else "bytetrack.yaml"
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self, frame: np.ndarray, detections: list[Detection]
|
||||||
|
) -> list[Detection]:
|
||||||
|
"""Run tracking on the frame, return detections with track IDs."""
|
||||||
|
results = self._model.track(
|
||||||
|
frame,
|
||||||
|
conf=self._conf,
|
||||||
|
persist=True,
|
||||||
|
tracker=self._tracker_cfg,
|
||||||
|
verbose=False,
|
||||||
|
)
|
||||||
|
return self._parse(results[0])
|
||||||
|
|
||||||
|
def _parse(self, result) -> list[Detection]:
|
||||||
|
tracked: list[Detection] = []
|
||||||
|
if result.boxes is None or len(result.boxes) == 0:
|
||||||
|
return tracked
|
||||||
|
ids = result.boxes.id
|
||||||
|
for i, box in enumerate(result.boxes):
|
||||||
|
cls_id = int(box.cls[0])
|
||||||
|
name = self._model.names.get(cls_id, str(cls_id)) if isinstance(self._model.names, dict) else self._model.names[cls_id]
|
||||||
|
if name not in ("sack", "truck"):
|
||||||
|
continue
|
||||||
|
track_id = int(ids[i]) if ids is not None else None
|
||||||
|
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
||||||
|
|
||||||
|
mask = None
|
||||||
|
|
||||||
|
tracked.append(
|
||||||
|
Detection(
|
||||||
|
bbox=(x1, y1, x2, y2),
|
||||||
|
confidence=float(box.conf[0]),
|
||||||
|
class_id=cls_id,
|
||||||
|
class_name=name,
|
||||||
|
track_id=track_id,
|
||||||
|
mask=mask,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tracked
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset tracker state (new batch / new truck)."""
|
||||||
|
if isinstance(self._model_path, str) and os.path.exists(self._model_path):
|
||||||
|
self._model = YOLO(self._model_path)
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Truck ROI tracker — identifies the main truck and provides a stable ROI.
|
||||||
|
|
||||||
|
Uses exponential moving average (EMA) to smooth the bounding box across
|
||||||
|
frames, preventing jitter from frame-to-frame detection variance.
|
||||||
|
|
||||||
|
For y2-based counting (bottom edge of sack bbox), the counting line is
|
||||||
|
placed `LINE_OFFSET_PX` pixels relative to the truck bottom edge (`roi.y2`).
|
||||||
|
With `offset = +20`, the line sits at `roi.y2 + 20` (~620px), cleanly
|
||||||
|
separating sacks on the ground (`y2 > 650`) from loaded sacks (`y2 < 580`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from src.interfaces import Detection
|
||||||
|
|
||||||
|
# Offset for y1 counting line relative to truck top edge (px).
|
||||||
|
# Positive = below truck top edge (into the truck).
|
||||||
|
# Negative = above truck top edge (towards the camera).
|
||||||
|
LINE_OFFSET_PX = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TruckROI:
|
||||||
|
"""Region of interest derived from the main truck bbox."""
|
||||||
|
|
||||||
|
x1: int
|
||||||
|
y1: int
|
||||||
|
x2: int
|
||||||
|
y2: int
|
||||||
|
line_y: int # counting line Y position (pixels)
|
||||||
|
confidence: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def width(self) -> int:
|
||||||
|
return self.x2 - self.x1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def height(self) -> int:
|
||||||
|
return self.y2 - self.y1
|
||||||
|
|
||||||
|
def contains_x(self, cx: float) -> bool:
|
||||||
|
"""Check if a centroid X falls within the truck X bounds."""
|
||||||
|
return self.x1 <= cx <= self.x2
|
||||||
|
|
||||||
|
|
||||||
|
class TruckROITracker:
|
||||||
|
"""Tracks the main truck and provides a smoothed ROI + counting line.
|
||||||
|
|
||||||
|
Main truck = largest truck detection whose center X falls in the
|
||||||
|
expected lane (center region of the frame).
|
||||||
|
|
||||||
|
The counting line is placed `line_offset` pixels below the truck
|
||||||
|
bottom edge (`roi.y2`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
frame_width: int,
|
||||||
|
frame_height: int,
|
||||||
|
lane_x_min: float = 0.35,
|
||||||
|
lane_x_max: float = 0.80,
|
||||||
|
ema_alpha: float = 0.15,
|
||||||
|
line_offset: int = LINE_OFFSET_PX,
|
||||||
|
) -> None:
|
||||||
|
self._fw = frame_width
|
||||||
|
self._fh = frame_height
|
||||||
|
self._lane_x_min = int(lane_x_min * frame_width)
|
||||||
|
self._lane_x_max = int(lane_x_max * frame_width)
|
||||||
|
self._alpha = ema_alpha
|
||||||
|
self._line_offset = line_offset
|
||||||
|
|
||||||
|
# Smoothed bbox (None until first detection)
|
||||||
|
self._sx1: float | None = None
|
||||||
|
self._sy1: float | None = None
|
||||||
|
self._sx2: float | None = None
|
||||||
|
self._sy2: float | None = None
|
||||||
|
|
||||||
|
self._last_roi: TruckROI | None = None
|
||||||
|
self._frames_without_truck = 0
|
||||||
|
|
||||||
|
def update(self, truck_detections: list[Detection]) -> TruckROI | None:
|
||||||
|
"""Pick the main truck, smooth its bbox, return ROI."""
|
||||||
|
main = self._pick_main_truck(truck_detections)
|
||||||
|
|
||||||
|
if main is None:
|
||||||
|
self._frames_without_truck += 1
|
||||||
|
if self._frames_without_truck > 5: # Clear ROI if truck is missing for >5 updates (~3 seconds)
|
||||||
|
self.reset()
|
||||||
|
return None
|
||||||
|
return self._last_roi # hold last known ROI briefly
|
||||||
|
|
||||||
|
self._frames_without_truck = 0
|
||||||
|
x1, y1, x2, y2 = main.bbox
|
||||||
|
|
||||||
|
# EMA smoothing
|
||||||
|
if self._sx1 is None:
|
||||||
|
self._sx1, self._sy1 = float(x1), float(y1)
|
||||||
|
self._sx2, self._sy2 = float(x2), float(y2)
|
||||||
|
else:
|
||||||
|
a = self._alpha
|
||||||
|
self._sx1 = a * x1 + (1 - a) * self._sx1
|
||||||
|
self._sy1 = a * y1 + (1 - a) * self._sy1
|
||||||
|
self._sx2 = a * x2 + (1 - a) * self._sx2
|
||||||
|
self._sy2 = a * y2 + (1 - a) * self._sy2
|
||||||
|
|
||||||
|
# Build ROI — line placed at truck top edge
|
||||||
|
roi_x1 = max(0, int(self._sx1))
|
||||||
|
roi_y1 = max(0, int(self._sy1))
|
||||||
|
roi_x2 = min(self._fw, int(self._sx2))
|
||||||
|
roi_y2 = min(self._fh, int(self._sy2))
|
||||||
|
line_y = roi_y1 + self._line_offset
|
||||||
|
|
||||||
|
self._last_roi = TruckROI(
|
||||||
|
x1=roi_x1, y1=roi_y1, x2=roi_x2, y2=roi_y2,
|
||||||
|
line_y=line_y, confidence=main.confidence,
|
||||||
|
)
|
||||||
|
return self._last_roi
|
||||||
|
|
||||||
|
@property
|
||||||
|
def roi(self) -> TruckROI | None:
|
||||||
|
return self._last_roi
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frames_without_truck(self) -> int:
|
||||||
|
return self._frames_without_truck
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._sx1 = self._sy1 = self._sx2 = self._sy2 = None
|
||||||
|
self._last_roi = None
|
||||||
|
self._frames_without_truck = 0
|
||||||
|
|
||||||
|
def _pick_main_truck(
|
||||||
|
self, detections: list[Detection]
|
||||||
|
) -> Detection | None:
|
||||||
|
"""Select the largest truck whose center X is in the expected lane."""
|
||||||
|
best: Detection | None = None
|
||||||
|
best_area = 0
|
||||||
|
|
||||||
|
for det in detections:
|
||||||
|
x1, y1, x2, y2 = det.bbox
|
||||||
|
cx = (x1 + x2) / 2
|
||||||
|
if not (self._lane_x_min <= cx <= self._lane_x_max):
|
||||||
|
continue
|
||||||
|
area = (x2 - x1) * (y2 - y1)
|
||||||
|
if area > best_area:
|
||||||
|
best = det
|
||||||
|
best_area = area
|
||||||
|
|
||||||
|
return best
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Performance Analytics & Stats - Karung.AI{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<style>
|
||||||
|
.analytics-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metric Summary Grid */
|
||||||
|
.metric-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 140px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-title i {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 12px 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-subtext {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-subtext strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart Section */
|
||||||
|
.chart-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-filters {
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--bg-base);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
color: var(--accent-blue);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
position: relative;
|
||||||
|
height: 380px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="analytics-layout">
|
||||||
|
<!-- Top Row: Metrics Overview -->
|
||||||
|
<div class="metric-grid">
|
||||||
|
<!-- Card 1: Today's Count -->
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-title">
|
||||||
|
<i class="fa-solid fa-calendar-day" style="color: var(--accent-blue);"></i> Today's Sacks
|
||||||
|
</div>
|
||||||
|
<div class="metric-value" id="todayCount">0</div>
|
||||||
|
<div class="metric-subtext" id="todayBatches">0 batches recorded</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card 2: Yesterday's Count -->
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-title">
|
||||||
|
<i class="fa-solid fa-history" style="color: var(--text-secondary);"></i> Yesterday's Sacks
|
||||||
|
</div>
|
||||||
|
<div class="metric-value" id="yesterdayCount">0</div>
|
||||||
|
<div class="metric-subtext" id="yesterdayBatches">0 batches recorded</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card 3: Daily Average -->
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-title">
|
||||||
|
<i class="fa-solid fa-calculator" style="color: var(--accent-green);"></i> Daily Average
|
||||||
|
</div>
|
||||||
|
<div class="metric-value" id="avgCount">0</div>
|
||||||
|
<div class="metric-subtext" id="recordedDays">0 days recorded in total</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card 4: Best Day Record -->
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-title">
|
||||||
|
<i class="fa-solid fa-trophy" style="color: var(--accent-orange);"></i> All-Time Record
|
||||||
|
</div>
|
||||||
|
<div class="metric-value" id="bestCount">0</div>
|
||||||
|
<div class="metric-subtext" id="bestDate">Best day: --</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Row: Chart -->
|
||||||
|
<div class="card chart-card">
|
||||||
|
<div class="chart-header">
|
||||||
|
<div class="chart-title-group">
|
||||||
|
<h3 class="chart-title">Counting Trends</h3>
|
||||||
|
<span class="chart-subtitle">Daily count output and total batch processing volumes</span>
|
||||||
|
</div>
|
||||||
|
<div class="chart-filters">
|
||||||
|
<button class="filter-btn active" data-days="7" onclick="loadChartData(7)">7 Days</button>
|
||||||
|
<button class="filter-btn" data-days="30" onclick="loadChartData(30)">30 Days</button>
|
||||||
|
<button class="filter-btn" data-days="90" onclick="loadChartData(90)">90 Days</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="mainChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
let mainChart = null;
|
||||||
|
let chartDays = 7;
|
||||||
|
|
||||||
|
async function loadMetrics() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/summary');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
document.getElementById('todayCount').textContent = (data.today.total_count || 0).toLocaleString();
|
||||||
|
document.getElementById('todayBatches').textContent = `${data.today.total_batches || 0} batches recorded`;
|
||||||
|
|
||||||
|
document.getElementById('yesterdayCount').textContent = (data.yesterday.total_count || 0).toLocaleString();
|
||||||
|
document.getElementById('yesterdayBatches').textContent = `${data.yesterday.total_batches || 0} batches recorded`;
|
||||||
|
|
||||||
|
document.getElementById('avgCount').textContent = Math.round(data.average_per_day || 0).toLocaleString();
|
||||||
|
document.getElementById('recordedDays').textContent = `${data.all_time.total_days || 0} active days recorded`;
|
||||||
|
|
||||||
|
document.getElementById('bestCount').textContent = (data.best_day.count || 0).toLocaleString();
|
||||||
|
document.getElementById('bestDate').textContent = data.best_day.date ? `Record date: ${data.best_day.date}` : 'Record date: --';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load metrics summary:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChartData(days) {
|
||||||
|
chartDays = days;
|
||||||
|
|
||||||
|
// Update active class on buttons
|
||||||
|
document.querySelectorAll('.chart-filters button').forEach(btn => {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
if (parseInt(btn.getAttribute('data-days')) === days) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/daily-data?days=${days}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const labels = data.map(d => {
|
||||||
|
const date = new Date(d.date);
|
||||||
|
return date.toLocaleDateString('en-US', { day: 'numeric', month: 'short' });
|
||||||
|
});
|
||||||
|
const counts = data.map(d => d.total_count);
|
||||||
|
const batches = data.map(d => d.total_batches);
|
||||||
|
|
||||||
|
if (mainChart) mainChart.destroy();
|
||||||
|
|
||||||
|
const ctx = document.getElementById('mainChart').getContext('2d');
|
||||||
|
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
|
|
||||||
|
// Dynamic theme colors
|
||||||
|
const gridColor = isDark ? '#2C2C2E' : '#E5E7EB';
|
||||||
|
const textColor = isDark ? '#8E8E93' : '#86868B';
|
||||||
|
|
||||||
|
const barBg = isDark ? 'rgba(10, 132, 255, 0.7)' : 'rgba(0, 113, 227, 0.7)';
|
||||||
|
const barBorder = isDark ? '#0A84FF' : '#0071E3';
|
||||||
|
|
||||||
|
const lineBorder = isDark ? '#30D158' : '#34C759';
|
||||||
|
|
||||||
|
mainChart = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Total Sacks',
|
||||||
|
data: counts,
|
||||||
|
backgroundColor: barBg,
|
||||||
|
borderColor: barBorder,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderRadius: 6,
|
||||||
|
yAxisID: 'y'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total Batches',
|
||||||
|
data: batches,
|
||||||
|
type: 'line',
|
||||||
|
borderColor: lineBorder,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 2.5,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointBackgroundColor: lineBorder,
|
||||||
|
tension: 0.35,
|
||||||
|
yAxisID: 'y1'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
padding: 16,
|
||||||
|
font: {
|
||||||
|
size: 11,
|
||||||
|
family: 'Inter, sans-serif'
|
||||||
|
},
|
||||||
|
color: textColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
padding: 12,
|
||||||
|
cornerRadius: 8,
|
||||||
|
titleFont: { size: 12, family: 'Inter, sans-serif', weight: 'bold' },
|
||||||
|
bodyFont: { size: 11, family: 'Inter, sans-serif' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: {
|
||||||
|
font: { size: 10, family: 'Inter, sans-serif' },
|
||||||
|
color: textColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'left',
|
||||||
|
grid: { color: gridColor },
|
||||||
|
ticks: {
|
||||||
|
color: textColor,
|
||||||
|
font: { size: 10, family: 'Inter, sans-serif' }
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Sacks Count',
|
||||||
|
color: textColor,
|
||||||
|
font: { size: 11, family: 'Inter, sans-serif', weight: 'bold' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'right',
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: {
|
||||||
|
color: textColor,
|
||||||
|
font: { size: 10, family: 'Inter, sans-serif' }
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Batches Count',
|
||||||
|
color: textColor,
|
||||||
|
font: { size: 11, family: 'Inter, sans-serif', weight: 'bold' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load chart:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redraw charts dynamically on theme switch
|
||||||
|
window.addEventListener('themeChanged', () => {
|
||||||
|
loadChartData(chartDays);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadMetrics();
|
||||||
|
loadChartData(7);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Karung.AI Dashboard{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
/* Apple Minimalist Light Palette */
|
||||||
|
--bg-base: #F5F5F7;
|
||||||
|
--bg-card: #FFFFFF;
|
||||||
|
--border-color: #E5E7EB;
|
||||||
|
--text-primary: #1D1D1F;
|
||||||
|
--text-secondary: #86868B;
|
||||||
|
--accent-blue: #0071E3;
|
||||||
|
--accent-green: #34C759;
|
||||||
|
--accent-red: #FF3B30;
|
||||||
|
--accent-orange: #FF9500;
|
||||||
|
--nav-bg: rgba(255, 255, 255, 0.8);
|
||||||
|
--nav-border: rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
/* Apple Dark Mode Palette */
|
||||||
|
--bg-base: #000000;
|
||||||
|
--bg-card: #1C1C1E;
|
||||||
|
--border-color: #2C2C2E;
|
||||||
|
--text-primary: #F5F5F7;
|
||||||
|
--text-secondary: #8E8E93;
|
||||||
|
--accent-blue: #0A84FF;
|
||||||
|
--accent-green: #30D158;
|
||||||
|
--accent-red: #FF453A;
|
||||||
|
--accent-orange: #FF9F0A;
|
||||||
|
--nav-bg: rgba(28, 28, 30, 0.8);
|
||||||
|
--nav-border: rgba(255, 255, 255, 0.05);
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 4px 10px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background-color: var(--bg-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
transition: background-color 0.3s var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grouped Header/Navbar */
|
||||||
|
header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background-color: var(--nav-bg);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid var(--nav-border);
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container i {
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-groups {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group:first-child {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .nav-link:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
color: var(--accent-blue);
|
||||||
|
background-color: rgba(0, 113, 227, 0.08);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
background-color: rgba(52, 199, 89, 0.12);
|
||||||
|
color: var(--accent-green);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-badge span.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background-color: var(--accent-green);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
box-shadow: 0 0 6px var(--accent-green);
|
||||||
|
animation: pulse-dot 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.4; transform: scale(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.03);
|
||||||
|
border-color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .theme-btn:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container & Layout */
|
||||||
|
main.container {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1440px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reusable UI Components */
|
||||||
|
.card {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--accent-blue);
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #005bb5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--bg-base);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .btn-secondary:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0,0,0,0.1);
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] ::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% block extra_head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="logo-container">
|
||||||
|
<i class="fa-solid fa-cube"></i>
|
||||||
|
<div class="logo-text">Karung.AI</div>
|
||||||
|
</div>
|
||||||
|
{% if show_nav is not defined or show_nav %}
|
||||||
|
<div class="nav-groups">
|
||||||
|
<!-- Group 1: Monitoring -->
|
||||||
|
<div class="nav-group">
|
||||||
|
<span class="nav-group-label">Monitor</span>
|
||||||
|
<a href="/" class="nav-link {% if request.path == '/' or request.path == '/monitoring' %}active{% endif %}">
|
||||||
|
<i class="fa-solid fa-desktop"></i> Live CCTV
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<!-- Group 2: Data & Reports -->
|
||||||
|
<div class="nav-group">
|
||||||
|
<span class="nav-group-label">Data</span>
|
||||||
|
<a href="/history" class="nav-link {% if request.path == '/history' %}active{% endif %}">
|
||||||
|
<i class="fa-solid fa-history"></i> History
|
||||||
|
</a>
|
||||||
|
<a href="/analytics" class="nav-link {% if request.path == '/analytics' %}active{% endif %}">
|
||||||
|
<i class="fa-solid fa-chart-line"></i> Analytics
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="live-badge">
|
||||||
|
<span class="dot"></span> {{ site_name }}
|
||||||
|
</div>
|
||||||
|
<button class="theme-btn" id="themeToggleBtn" onclick="toggleTheme()" title="Toggle Light/Dark Mode">
|
||||||
|
<i class="fa-solid fa-moon"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>© 2026 Karung.AI - Automated Sack Counting Terminal. All rights reserved.</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Init theme from localStorage or system preference
|
||||||
|
function initTheme() {
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
const themeBtn = document.getElementById('themeToggleBtn');
|
||||||
|
const icon = themeBtn.querySelector('i');
|
||||||
|
|
||||||
|
if (savedTheme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||||
|
if (savedTheme === 'dark') {
|
||||||
|
icon.className = 'fa-solid fa-sun';
|
||||||
|
} else {
|
||||||
|
icon.className = 'fa-solid fa-moon';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
const activeTheme = systemPrefersDark ? 'dark' : 'light';
|
||||||
|
document.documentElement.setAttribute('data-theme', activeTheme);
|
||||||
|
icon.className = systemPrefersDark ? 'fa-solid fa-sun' : 'fa-solid fa-moon';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
const currentTheme = document.documentElement.getAttribute('data-theme');
|
||||||
|
const nextTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||||
|
const themeBtn = document.getElementById('themeToggleBtn');
|
||||||
|
const icon = themeBtn.querySelector('i');
|
||||||
|
|
||||||
|
document.documentElement.setAttribute('data-theme', nextTheme);
|
||||||
|
localStorage.setItem('theme', nextTheme);
|
||||||
|
|
||||||
|
if (nextTheme === 'dark') {
|
||||||
|
icon.className = 'fa-solid fa-sun';
|
||||||
|
} else {
|
||||||
|
icon.className = 'fa-solid fa-moon';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch event for components that need to redrawn (like Chart.js)
|
||||||
|
window.dispatchEvent(new Event('themeChanged'));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', initTheme);
|
||||||
|
</script>
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Batch History & Reports - Karung.AI{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.history-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px 1fr;
|
||||||
|
gap: 28px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.history-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Dates List */
|
||||||
|
.sidebar-dates {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dates-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dates-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 550px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-row {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
transition: var(--transition);
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-row:hover {
|
||||||
|
border-color: var(--accent-blue);
|
||||||
|
background-color: rgba(0, 113, 227, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-row.active {
|
||||||
|
border-color: var(--accent-blue);
|
||||||
|
background-color: rgba(0, 113, 227, 0.08);
|
||||||
|
color: var(--accent-blue);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-val {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
background-color: var(--bg-base);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-row.active .date-badge {
|
||||||
|
background-color: var(--accent-blue);
|
||||||
|
color: #FFFFFF;
|
||||||
|
border-color: var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right Main Panel: Date Details */
|
||||||
|
.details-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-mini-card {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table styles */
|
||||||
|
.table-container {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: var(--bg-base);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background-color: rgba(0, 0, 0, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] tr:hover td {
|
||||||
|
background-color: rgba(255, 255, 255, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data i {
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: block;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="history-layout">
|
||||||
|
<!-- Left Column: Dates List -->
|
||||||
|
<div class="sidebar-dates">
|
||||||
|
<div class="dates-header">
|
||||||
|
<span>Recorded Dates</span>
|
||||||
|
<i class="fa-solid fa-calendar-days"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dates-list" id="datesList">
|
||||||
|
<div class="no-data" style="padding: 20px;">
|
||||||
|
<i class="fa-solid fa-circle-notch fa-spin"></i>
|
||||||
|
<p style="font-size: 12px;">Loading dates...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/api/export-daily-csv?days=30" class="btn btn-secondary" style="width: 100%; justify-content: center;">
|
||||||
|
<i class="fa-solid fa-file-excel"></i> Export All (30 Days)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Date Details Table -->
|
||||||
|
<div class="details-panel">
|
||||||
|
<div class="details-header">
|
||||||
|
<h2 class="details-title" id="selectedDateTitle">Select a Date</h2>
|
||||||
|
<a href="#" id="exportDayBtn" class="btn btn-primary" style="display: none;">
|
||||||
|
<i class="fa-solid fa-file-excel"></i> Export Day Excel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metric summaries for selected date -->
|
||||||
|
<div class="stats-row" id="dateStatsRow" style="display: none;">
|
||||||
|
<div class="stat-mini-card">
|
||||||
|
<div class="stat-label">Total Sacks</div>
|
||||||
|
<div class="stat-number" id="statTotalCount">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-mini-card">
|
||||||
|
<div class="stat-label">Total Batches</div>
|
||||||
|
<div class="stat-number" id="statTotalBatches">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-mini-card">
|
||||||
|
<div class="stat-label">Avg Sacks / Batch</div>
|
||||||
|
<div class="stat-number" id="statAvgSacks">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-mini-card">
|
||||||
|
<div class="stat-label">Avg Duration</div>
|
||||||
|
<div class="stat-number" id="statAvgDuration">0 min</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Batches table -->
|
||||||
|
<div class="table-container">
|
||||||
|
<table id="batchesTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Batch #</th>
|
||||||
|
<th>Sacks Counted</th>
|
||||||
|
<th>Start Time</th>
|
||||||
|
<th>End Time</th>
|
||||||
|
<th>Duration (Min)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="batchesTableBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="no-data">
|
||||||
|
<i class="fa-solid fa-arrow-left"></i>
|
||||||
|
Silakan pilih tanggal dari daftar sebelah kiri untuk memuat detail batch.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
let availableDates = [];
|
||||||
|
let selectedDate = null;
|
||||||
|
|
||||||
|
async function loadDates() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/available-dates');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const listEl = document.getElementById('datesList');
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
listEl.innerHTML = `
|
||||||
|
<div class="no-data" style="padding: 20px;">
|
||||||
|
<i class="fa-solid fa-folder-open"></i>
|
||||||
|
<p style="font-size: 12px;">No records found yet.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
availableDates = data;
|
||||||
|
|
||||||
|
data.forEach((item, idx) => {
|
||||||
|
const row = document.createElement('a');
|
||||||
|
row.href = '#';
|
||||||
|
row.className = 'date-row';
|
||||||
|
row.onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
selectDate(item.date, row);
|
||||||
|
};
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<span class="date-val">${item.date}</span>
|
||||||
|
<span class="date-badge">${item.total_count} bags</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
listEl.appendChild(row);
|
||||||
|
|
||||||
|
// Pilih tanggal pertama secara default
|
||||||
|
if (idx === 0) {
|
||||||
|
selectDate(item.date, row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load dates list:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectDate(date, element) {
|
||||||
|
selectedDate = date;
|
||||||
|
|
||||||
|
// Update active class in sidebar list
|
||||||
|
document.querySelectorAll('.date-row').forEach(row => {
|
||||||
|
row.classList.remove('active');
|
||||||
|
});
|
||||||
|
if (element) {
|
||||||
|
element.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show details
|
||||||
|
document.getElementById('selectedDateTitle').textContent = `Summary details for ${date}`;
|
||||||
|
|
||||||
|
const exportBtn = document.getElementById('exportDayBtn');
|
||||||
|
exportBtn.href = `/api/export-day-csv/${date}`;
|
||||||
|
exportBtn.style.display = 'inline-flex';
|
||||||
|
|
||||||
|
const tableBody = document.getElementById('batchesTableBody');
|
||||||
|
tableBody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="no-data">
|
||||||
|
<i class="fa-solid fa-circle-notch fa-spin"></i>
|
||||||
|
Memuat data batch untuk tanggal ${date}...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/day-detail/${date}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
tableBody.innerHTML = '';
|
||||||
|
|
||||||
|
// Show stats row
|
||||||
|
document.getElementById('dateStatsRow').style.display = 'grid';
|
||||||
|
document.getElementById('statTotalCount').textContent = (data.total_count || 0).toLocaleString();
|
||||||
|
document.getElementById('statTotalBatches').textContent = data.total_batches || 0;
|
||||||
|
|
||||||
|
const avgSacks = data.total_batches > 0 ? (data.total_count / data.total_batches).toFixed(1) : '0';
|
||||||
|
document.getElementById('statAvgSacks').textContent = avgSacks;
|
||||||
|
document.getElementById('statAvgDuration').textContent = `${data.avg_duration_minutes || 0} min`;
|
||||||
|
|
||||||
|
if (!data.batches || data.batches.length === 0) {
|
||||||
|
tableBody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="no-data">
|
||||||
|
<i class="fa-solid fa-inbox"></i>
|
||||||
|
Tidak ada batch tercatat pada tanggal ${date}.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.batches.forEach(b => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
|
||||||
|
const startStr = b.start_time ? new Date(b.start_time).toLocaleTimeString('en-US', { hour12: false }) : '--:--:--';
|
||||||
|
const endStr = b.end_time ? new Date(b.end_time).toLocaleTimeString('en-US', { hour12: false }) : '--:--:--';
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td style="font-weight: 600; color: var(--accent-blue);">Batch #${b.batch_number}</td>
|
||||||
|
<td style="font-weight: 500;">${b.count.toLocaleString()}</td>
|
||||||
|
<td>${startStr}</td>
|
||||||
|
<td>${endStr}</td>
|
||||||
|
<td>${b.duration_minutes || 0} min</td>
|
||||||
|
`;
|
||||||
|
tableBody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load date details:', err);
|
||||||
|
tableBody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="no-data" style="color: var(--accent-red);">
|
||||||
|
<i class="fa-solid fa-circle-exclamation"></i>
|
||||||
|
Gagal memuat detail data: ${err.message}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', loadDates);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,681 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Live CCTV Monitoring - Karung.AI{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.monitoring-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.monitoring-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Video Feed Panel */
|
||||||
|
.video-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #000000;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-feed {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #FFFFFF;
|
||||||
|
background-color: #111111;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-placeholder i {
|
||||||
|
font-size: 40px;
|
||||||
|
color: var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-placeholder p {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #8E8E93;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active Batch Details (Right Column) */
|
||||||
|
.control-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-batch-card {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-display {
|
||||||
|
font-size: 64px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -2px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--accent-blue);
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-key {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live Event Logs */
|
||||||
|
.log-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: var(--bg-base);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
font-size: 13px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item:hover {
|
||||||
|
background-color: rgba(0,0,0,0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .log-item:hover {
|
||||||
|
background-color: rgba(255,255,255,0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: monospace;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-icon.masuk {
|
||||||
|
background-color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-icon.keluar {
|
||||||
|
background-color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-icon.info {
|
||||||
|
background-color: var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple Style Switch */
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
inset: 0;
|
||||||
|
background-color: var(--border-color);
|
||||||
|
transition: .3s;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background-color: white;
|
||||||
|
transition: .3s;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider {
|
||||||
|
background-color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider:before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="monitoring-grid">
|
||||||
|
<!-- Left Column: Video Feed -->
|
||||||
|
<div class="video-panel">
|
||||||
|
<div class="video-header">
|
||||||
|
<h2 class="video-title">
|
||||||
|
<i class="fa-solid fa-camera"></i> CCTV Feed - CC1
|
||||||
|
</h2>
|
||||||
|
<div style="display: flex; align-items: center; gap: 16px;">
|
||||||
|
<!-- Live CCTV Toggle Switch -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<span style="font-size: 13px; font-weight: 500; color: var(--text-secondary);">Live View:</span>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="liveFeedToggle" onchange="toggleLiveFeed()">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="btn btn-secondary btn-sm" onclick="reloadStream()" title="Reload Stream Feed">
|
||||||
|
<i class="fa-solid fa-refresh"></i> Refresh Feed
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="video-container">
|
||||||
|
<!-- Fetch direct live frame stream from local dashboard Flask -->
|
||||||
|
<img id="liveVideoFeed" class="video-feed" src="" alt="CCTV Stream Feed" style="display: none;" onerror="handleStreamError()">
|
||||||
|
<div id="videoPlaceholder" class="video-placeholder" style="display: flex;">
|
||||||
|
<i class="fa-solid fa-play" style="font-size: 40px; cursor: pointer; color: var(--accent-blue);" onclick="startLiveFeedManual()"></i>
|
||||||
|
<p>Live Feed dinonaktifkan (Hemat CPU & Bandwidth)</p>
|
||||||
|
<div class="btn btn-secondary" onclick="startLiveFeedManual()">Aktifkan Live View CCTV</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Control & Active Batch Stats -->
|
||||||
|
<div class="control-panel">
|
||||||
|
<!-- Card 1: Current Batch Stats -->
|
||||||
|
<div class="active-batch-card">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
|
||||||
|
<h3 class="card-label" style="margin-bottom: 0;">Active Batch</h3>
|
||||||
|
<!-- Mode Switcher -->
|
||||||
|
<div style="display: inline-flex; background: var(--bg-base); border: 1px solid var(--border-color); border-radius: 20px; padding: 2px;">
|
||||||
|
<button id="btnModeManual" class="btn btn-sm" style="padding: 4px 10px; font-size: 11px; border-radius: 18px; border: none; background: var(--accent-blue); color: #fff; font-weight: 600;" onclick="switchBatchMode('manual')">
|
||||||
|
<i class="fa-solid fa-hand"></i> Manual
|
||||||
|
</button>
|
||||||
|
<button id="btnModeAuto" class="btn btn-sm" style="padding: 4px 10px; font-size: 11px; border-radius: 18px; border: none; background: transparent; color: var(--text-secondary);" onclick="switchBatchMode('auto')">
|
||||||
|
<i class="fa-solid fa-robot"></i> Otomatis
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="count-display" id="liveCount">0</div>
|
||||||
|
<div class="card-label" style="font-size: 11px; margin-top: -4px;">Sacks Counted</div>
|
||||||
|
|
||||||
|
<div class="meta-list">
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-key">Mode Batch</span>
|
||||||
|
<span class="meta-value" id="liveBatchModeText" style="font-weight: 600; color: var(--accent-blue);">Manual (Tombol)</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-key">Batch Number</span>
|
||||||
|
<span class="meta-value" id="liveBatchNumber">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-key">Last Detection</span>
|
||||||
|
<span class="meta-value" id="liveLastDetection">Waiting...</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-key">Status</span>
|
||||||
|
<span class="meta-value" id="liveStatus" style="color: var(--text-secondary);">Standby (Idle)</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-key">Pipeline Speed</span>
|
||||||
|
<span class="meta-value"><span id="liveFps">--</span> FPS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Batch Control Buttons for Office Dashboard (Hidden when in Auto Mode) -->
|
||||||
|
<div id="manualControlSection" style="margin-top: 20px; display: flex; gap: 10px;">
|
||||||
|
<button id="monBtnStart" class="btn btn-primary" style="flex: 1; background-color: var(--accent-green);" onclick="confirmStartBatch()">
|
||||||
|
<i class="fa-solid fa-play"></i> Mulai Batch
|
||||||
|
</button>
|
||||||
|
<button id="monBtnStop" class="btn btn-primary" style="flex: 1; background-color: var(--accent-red); display: none;" onclick="confirmStopBatch()">
|
||||||
|
<i class="fa-solid fa-square"></i> Akhiri Batch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="autoModeHint" style="margin-top: 14px; font-size: 12px; color: var(--text-secondary); text-align: center; display: none;">
|
||||||
|
<i class="fa-solid fa-info-circle"></i> Batch dimulai & diakhiri otomatis oleh AI deteksi truk
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card 2: Live Activity Logs -->
|
||||||
|
<div class="card log-card">
|
||||||
|
<div class="log-header">
|
||||||
|
<h3 class="log-title">Live Activity Log</h3>
|
||||||
|
<span class="live-badge" style="font-size: 9px;"><span class="dot"></span> Real-time</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-container" id="logContainer">
|
||||||
|
<div class="log-item" id="emptyLogItem">
|
||||||
|
<div class="log-time">--:--:--</div>
|
||||||
|
<div class="log-content">Sistem aktif. Menunggu batch dimulai...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Konfirmasi Start -->
|
||||||
|
<div id="modalStart" style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 999; padding: 20px;">
|
||||||
|
<div style="background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; max-width: 400px; width: 100%; text-align: center;">
|
||||||
|
<h3 style="font-size: 18px; font-weight: 700; margin-bottom: 12px;"><i class="fa-solid fa-play" style="color: var(--accent-green);"></i> Mulai Batch Baru?</h3>
|
||||||
|
<p style="font-size: 14px; color: var(--text-secondary); margin-bottom: 24px;">Counter akan diaktifkan dan batch baru akan dibuat.</p>
|
||||||
|
<div style="display: flex; gap: 12px;">
|
||||||
|
<button class="btn btn-secondary" style="flex: 1;" onclick="document.getElementById('modalStart').style.display='none'">Batal</button>
|
||||||
|
<button class="btn btn-primary" style="flex: 1; background-color: var(--accent-green);" onclick="executeStartBatch()">Ya, Mulai</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Konfirmasi Stop -->
|
||||||
|
<div id="modalStop" style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 999; padding: 20px;">
|
||||||
|
<div style="background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; max-width: 400px; width: 100%; text-align: center;">
|
||||||
|
<h3 style="font-size: 18px; font-weight: 700; margin-bottom: 12px;"><i class="fa-solid fa-square" style="color: var(--accent-red);"></i> Selesai Batch?</h3>
|
||||||
|
<p style="font-size: 14px; color: var(--text-secondary); margin-bottom: 24px;">Pemuatan batch akan difinalisasi dan disimpan ke database.</p>
|
||||||
|
<div style="display: flex; gap: 12px;">
|
||||||
|
<button class="btn btn-secondary" style="flex: 1;" onclick="document.getElementById('modalStop').style.display='none'">Batal</button>
|
||||||
|
<button class="btn btn-primary" style="flex: 1; background-color: var(--accent-red);" onclick="executeStopBatch()">Ya, Selesai</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
let streamRetryCount = 0;
|
||||||
|
|
||||||
|
function startLiveFeedManual() {
|
||||||
|
const toggle = document.getElementById('liveFeedToggle');
|
||||||
|
toggle.checked = true;
|
||||||
|
toggleLiveFeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStreamError() {
|
||||||
|
const feed = document.getElementById('liveVideoFeed');
|
||||||
|
const placeholder = document.getElementById('videoPlaceholder');
|
||||||
|
feed.style.display = 'none';
|
||||||
|
placeholder.style.display = 'flex';
|
||||||
|
|
||||||
|
if (streamRetryCount < 10) {
|
||||||
|
streamRetryCount++;
|
||||||
|
setTimeout(reloadStream, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadStream() {
|
||||||
|
const toggle = document.getElementById('liveFeedToggle');
|
||||||
|
if (!toggle.checked) return; // Jangan reload jika dimatikan
|
||||||
|
|
||||||
|
const feed = document.getElementById('liveVideoFeed');
|
||||||
|
const placeholder = document.getElementById('videoPlaceholder');
|
||||||
|
|
||||||
|
feed.style.display = 'block';
|
||||||
|
placeholder.style.display = 'none';
|
||||||
|
feed.src = '/api/live-video?t=' + new Date().getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLiveFeed() {
|
||||||
|
const toggle = document.getElementById('liveFeedToggle');
|
||||||
|
const feed = document.getElementById('liveVideoFeed');
|
||||||
|
const placeholder = document.getElementById('videoPlaceholder');
|
||||||
|
|
||||||
|
if (toggle.checked) {
|
||||||
|
// Aktifkan live feed
|
||||||
|
feed.style.display = 'block';
|
||||||
|
placeholder.style.display = 'none';
|
||||||
|
feed.src = '/api/live-video';
|
||||||
|
} else {
|
||||||
|
// Matikan live feed (putus koneksi stream)
|
||||||
|
feed.style.display = 'none';
|
||||||
|
placeholder.style.display = 'flex';
|
||||||
|
placeholder.querySelector('p').textContent = 'Live Feed dinonaktifkan (Hemat CPU & Bandwidth)';
|
||||||
|
feed.src = ''; // Putus koneksi stream dengan backend!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmStartBatch() {
|
||||||
|
document.getElementById('modalStart').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmStopBatch() {
|
||||||
|
document.getElementById('modalStop').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeStartBatch() {
|
||||||
|
document.getElementById('modalStart').style.display = 'none';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/batch/start', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
await pollCurrentBatch();
|
||||||
|
} else {
|
||||||
|
alert('Gagal memulai batch: ' + (data.error || 'Terjadi kesalahan'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Gagal terhubung ke server');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeStopBatch() {
|
||||||
|
document.getElementById('modalStop').style.display = 'none';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/batch/stop', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
await pollCurrentBatch();
|
||||||
|
} else {
|
||||||
|
alert('Gagal mengakhiri batch: ' + (data.error || 'Terjadi kesalahan'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Gagal terhubung ke server');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polling current batch stats every 2 seconds
|
||||||
|
async function pollCurrentBatch() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/current-batch');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const countEl = document.getElementById('liveCount');
|
||||||
|
const batchNumEl = document.getElementById('liveBatchNumber');
|
||||||
|
const lastDetEl = document.getElementById('liveLastDetection');
|
||||||
|
const statusEl = document.getElementById('liveStatus');
|
||||||
|
const fpsEl = document.getElementById('liveFps');
|
||||||
|
const btnStart = document.getElementById('monBtnStart');
|
||||||
|
const btnStop = document.getElementById('monBtnStop');
|
||||||
|
|
||||||
|
if (data.fps !== undefined && data.fps !== null) {
|
||||||
|
fpsEl.textContent = data.fps;
|
||||||
|
} else {
|
||||||
|
fpsEl.textContent = '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.success && data.batch_number) {
|
||||||
|
// Batch sedang berjalan
|
||||||
|
const currentCount = parseInt(data.count) || 0;
|
||||||
|
countEl.textContent = currentCount.toLocaleString();
|
||||||
|
batchNumEl.textContent = '#' + (data.batch_number || '--');
|
||||||
|
statusEl.textContent = 'Counting Sacks (Active)';
|
||||||
|
statusEl.style.color = 'var(--accent-blue)';
|
||||||
|
|
||||||
|
if (data.last_detection_time) {
|
||||||
|
const lastDet = new Date(data.last_detection_time);
|
||||||
|
lastDetEl.textContent = lastDet.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
} else {
|
||||||
|
lastDetEl.textContent = 'waiting...';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnStart && btnStop) {
|
||||||
|
btnStart.style.display = 'none';
|
||||||
|
btnStop.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tambahkan event ke log secara dinamis jika jumlah hitungan bertambah
|
||||||
|
addLogItem(new Date().toLocaleTimeString('en-US', { hour12: false }), `Karung ke-${currentCount} terhitung di area counting.`, 'masuk');
|
||||||
|
} else {
|
||||||
|
// Tidak ada batch aktif (standby)
|
||||||
|
countEl.textContent = '0';
|
||||||
|
batchNumEl.textContent = '--';
|
||||||
|
lastDetEl.textContent = '--';
|
||||||
|
statusEl.textContent = 'Standby (Menunggu Mulai)';
|
||||||
|
statusEl.style.color = 'var(--text-secondary)';
|
||||||
|
|
||||||
|
if (btnStart && btnStop) {
|
||||||
|
btnStart.style.display = 'flex';
|
||||||
|
btnStop.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to poll current batch:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polling previous batch details periodically to see if a batch just finalized
|
||||||
|
let lastFinalizedBatchNum = null;
|
||||||
|
async function pollFinalizedBatch() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/previous-batch');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
if (lastFinalizedBatchNum !== null && lastFinalizedBatchNum !== data.batch_number) {
|
||||||
|
// Ada batch baru yang baru saja diselesaikan!
|
||||||
|
addLogItem(
|
||||||
|
new Date(data.end_time).toLocaleTimeString('en-US', { hour12: false }),
|
||||||
|
`Batch #${data.batch_number} selesai. Total: ${data.count} karung, Durasi: ${data.duration_minutes} m.`,
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lastFinalizedBatchNum = data.batch_number;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to poll previous batch:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addedLogs = new Set();
|
||||||
|
function addLogItem(time, message, type = 'info') {
|
||||||
|
const key = `${time}_${message}`;
|
||||||
|
if (addedLogs.has(key)) return;
|
||||||
|
addedLogs.add(key);
|
||||||
|
|
||||||
|
const container = document.getElementById('logContainer');
|
||||||
|
const emptyItem = document.getElementById('emptyLogItem');
|
||||||
|
if (emptyItem) emptyItem.remove();
|
||||||
|
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'log-item';
|
||||||
|
|
||||||
|
let iconClass = 'fa-info';
|
||||||
|
if (type === 'masuk') iconClass = 'fa-plus';
|
||||||
|
if (type === 'keluar') iconClass = 'fa-minus';
|
||||||
|
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="log-icon ${type}"><i class="fa-solid ${iconClass}"></i></div>
|
||||||
|
<div class="log-time">${time}</div>
|
||||||
|
<div class="log-content">${message}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.insertBefore(item, container.firstChild);
|
||||||
|
|
||||||
|
// Batasi log maksimal 40 baris agar tidak membebani memori browser
|
||||||
|
while (container.children.length > 40) {
|
||||||
|
container.removeChild(container.lastChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentBatchMode = 'manual';
|
||||||
|
|
||||||
|
async function switchBatchMode(mode) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/batch/mode', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode: mode })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
updateModeUI(data.mode);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to switch batch mode:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkBatchMode() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/batch/mode');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
updateModeUI(data.mode);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to check batch mode:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateModeUI(mode) {
|
||||||
|
currentBatchMode = mode;
|
||||||
|
const btnManual = document.getElementById('btnModeManual');
|
||||||
|
const btnAuto = document.getElementById('btnModeAuto');
|
||||||
|
const modeText = document.getElementById('liveBatchModeText');
|
||||||
|
const manualSection = document.getElementById('manualControlSection');
|
||||||
|
const autoHint = document.getElementById('autoModeHint');
|
||||||
|
|
||||||
|
if (mode === 'auto') {
|
||||||
|
btnAuto.style.background = 'var(--accent-blue)';
|
||||||
|
btnAuto.style.color = '#fff';
|
||||||
|
btnAuto.style.fontWeight = '600';
|
||||||
|
btnManual.style.background = 'transparent';
|
||||||
|
btnManual.style.color = 'var(--text-secondary)';
|
||||||
|
btnManual.style.fontWeight = 'normal';
|
||||||
|
if (modeText) {
|
||||||
|
modeText.textContent = 'Otomatis (AI Truk)';
|
||||||
|
modeText.style.color = 'var(--accent-orange)';
|
||||||
|
}
|
||||||
|
if (manualSection) manualSection.style.display = 'none';
|
||||||
|
if (autoHint) autoHint.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
btnManual.style.background = 'var(--accent-blue)';
|
||||||
|
btnManual.style.color = '#fff';
|
||||||
|
btnManual.style.fontWeight = '600';
|
||||||
|
btnAuto.style.background = 'transparent';
|
||||||
|
btnAuto.style.color = 'var(--text-secondary)';
|
||||||
|
btnAuto.style.fontWeight = 'normal';
|
||||||
|
if (modeText) {
|
||||||
|
modeText.textContent = 'Manual (Tombol)';
|
||||||
|
modeText.style.color = 'var(--accent-blue)';
|
||||||
|
}
|
||||||
|
if (manualSection) manualSection.style.display = 'flex';
|
||||||
|
if (autoHint) autoHint.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start interval pollings
|
||||||
|
setInterval(pollCurrentBatch, 2000);
|
||||||
|
setInterval(pollFinalizedBatch, 3000);
|
||||||
|
setInterval(checkBatchMode, 5000);
|
||||||
|
|
||||||
|
// Initial first load
|
||||||
|
checkBatchMode();
|
||||||
|
pollCurrentBatch();
|
||||||
|
pollFinalizedBatch();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Kontrol Pemuatan Karung{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.operator-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-card {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 32px 24px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge-box {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 30px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-idle {
|
||||||
|
background-color: rgba(142, 142, 147, 0.15);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active {
|
||||||
|
background-color: rgba(52, 199, 89, 0.15);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-info-box {
|
||||||
|
background-color: var(--bg-base);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item .info-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item .info-val {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action-large {
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
transition: transform 0.1s ease, filter 0.2s ease;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action-large:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-start {
|
||||||
|
background-color: var(--accent-green);
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-start:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-stop {
|
||||||
|
background-color: var(--accent-red);
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-stop:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Simple Confirmation Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0,0,0,0.5);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 999;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions .btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="operator-container">
|
||||||
|
<div class="operator-card">
|
||||||
|
<h1 class="operator-title"><i class="fa-solid fa-boxes-packing"></i> Kontrol Pemuatan Karung</h1>
|
||||||
|
<p class="operator-subtitle">Tekan tombol saat proses muat truk dimulai dan selesai</p>
|
||||||
|
|
||||||
|
<!-- Status Indicator -->
|
||||||
|
<div id="statusBadge" class="status-badge-box status-idle">
|
||||||
|
<i id="statusIcon" class="fa-solid fa-circle-pause"></i>
|
||||||
|
<span id="statusText">Status: Standby (Tidak Memuat)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Detail -->
|
||||||
|
<div class="batch-info-box">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Batch Berjalan</div>
|
||||||
|
<div class="info-val" id="opBatchNum">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Waktu Mulai</div>
|
||||||
|
<div class="info-val" id="opStartTime">--:--:--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Button -->
|
||||||
|
<div id="actionContainer">
|
||||||
|
<button id="btnStartBatch" class="btn-action-large btn-start" onclick="confirmStartBatch()">
|
||||||
|
<i class="fa-solid fa-play"></i> MULAI BATCH BARU
|
||||||
|
</button>
|
||||||
|
<button id="btnStopBatch" class="btn-action-large btn-stop" style="display: none;" onclick="confirmStopBatch()">
|
||||||
|
<i class="fa-solid fa-square"></i> SELESAI / AKHIRI BATCH
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Konfirmasi Start -->
|
||||||
|
<div id="modalStart" class="modal-overlay">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="modal-title"><i class="fa-solid fa-play" style="color: var(--accent-green);"></i> Mulai Batch Baru?</h3>
|
||||||
|
<p class="modal-desc">Pastikan truk sudah siap di posisi pemuatan karung.</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal('modalStart')">Batal</button>
|
||||||
|
<button class="btn btn-primary" style="background-color: var(--accent-green);" onclick="executeStartBatch()">Ya, Mulai</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Konfirmasi Stop -->
|
||||||
|
<div id="modalStop" class="modal-overlay">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="modal-title"><i class="fa-solid fa-square" style="color: var(--accent-red);"></i> Selesai Batch?</h3>
|
||||||
|
<p class="modal-desc">Pemuatan untuk batch ini akan ditutup dan data akan disimpan.</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal('modalStop')">Batal</button>
|
||||||
|
<button class="btn btn-primary" style="background-color: var(--accent-red);" onclick="executeStopBatch()">Ya, Selesai</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
let isBatchActive = false;
|
||||||
|
|
||||||
|
function closeModal(id) {
|
||||||
|
document.getElementById(id).style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmStartBatch() {
|
||||||
|
document.getElementById('modalStart').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmStopBatch() {
|
||||||
|
document.getElementById('modalStop').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeStartBatch() {
|
||||||
|
closeModal('modalStart');
|
||||||
|
const btn = document.getElementById('btnStartBatch');
|
||||||
|
btn.classList.add('btn-disabled');
|
||||||
|
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Memulai...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/batch/start', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
await pollStatus();
|
||||||
|
} else {
|
||||||
|
alert('Gagal memulai batch: ' + (data.error || 'Terjadi kesalahan'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Gagal terhubung ke server');
|
||||||
|
} finally {
|
||||||
|
btn.classList.remove('btn-disabled');
|
||||||
|
btn.innerHTML = '<i class="fa-solid fa-play"></i> MULAI BATCH BARU';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeStopBatch() {
|
||||||
|
closeModal('modalStop');
|
||||||
|
const btn = document.getElementById('btnStopBatch');
|
||||||
|
btn.classList.add('btn-disabled');
|
||||||
|
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Mengakhiri...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/batch/stop', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
await pollStatus();
|
||||||
|
} else {
|
||||||
|
alert('Gagal mengakhiri batch: ' + (data.error || 'Terjadi kesalahan'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Gagal terhubung ke server');
|
||||||
|
} finally {
|
||||||
|
btn.classList.remove('btn-disabled');
|
||||||
|
btn.innerHTML = '<i class="fa-solid fa-square"></i> SELESAI / AKHIRI BATCH';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/current-batch');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const badge = document.getElementById('statusBadge');
|
||||||
|
const icon = document.getElementById('statusIcon');
|
||||||
|
const text = document.getElementById('statusText');
|
||||||
|
const batchNum = document.getElementById('opBatchNum');
|
||||||
|
const startTime = document.getElementById('opStartTime');
|
||||||
|
const btnStart = document.getElementById('btnStartBatch');
|
||||||
|
const btnStop = document.getElementById('btnStopBatch');
|
||||||
|
|
||||||
|
if (data.success && data.batch_number) {
|
||||||
|
isBatchActive = true;
|
||||||
|
badge.className = 'status-badge-box status-active';
|
||||||
|
icon.className = 'fa-solid fa-circle-play';
|
||||||
|
text.textContent = 'Status: Sedang Memuat (Batch #' + data.batch_number + ')';
|
||||||
|
|
||||||
|
batchNum.textContent = '#' + data.batch_number;
|
||||||
|
if (data.start_time) {
|
||||||
|
const st = new Date(data.start_time);
|
||||||
|
startTime.textContent = st.toLocaleTimeString('id-ID', { hour12: false });
|
||||||
|
} else {
|
||||||
|
startTime.textContent = '--:--:--';
|
||||||
|
}
|
||||||
|
|
||||||
|
btnStart.style.display = 'none';
|
||||||
|
btnStop.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
isBatchActive = false;
|
||||||
|
badge.className = 'status-badge-box status-idle';
|
||||||
|
icon.className = 'fa-solid fa-circle-pause';
|
||||||
|
text.textContent = 'Status: Standby (Siap Memuat)';
|
||||||
|
|
||||||
|
batchNum.textContent = '--';
|
||||||
|
startTime.textContent = '--:--:--';
|
||||||
|
|
||||||
|
btnStart.style.display = 'flex';
|
||||||
|
btnStop.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Poll error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(pollStatus, 2000);
|
||||||
|
pollStatus();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from ultralytics import YOLO
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def main():
|
||||||
|
model_path = "/home/jetson/karung/model_karung_truk.engine"
|
||||||
|
print("Loading model...")
|
||||||
|
model = YOLO(model_path)
|
||||||
|
|
||||||
|
# Warm up
|
||||||
|
print("Warming up model...")
|
||||||
|
dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
|
||||||
|
results = model(dummy, imgsz=640, device="cuda", verbose=False)
|
||||||
|
print("Warm up complete!")
|
||||||
|
|
||||||
|
# Test tracking with standard bytetrack
|
||||||
|
print("Testing standard bytetrack on 10 frames...")
|
||||||
|
for i in range(10):
|
||||||
|
print(f"Tracking frame {i+1}...")
|
||||||
|
results = model.track(dummy, persist=True, tracker="bytetrack.yaml", verbose=False)
|
||||||
|
print(f"Frame {i+1} track complete! Detections count: {len(results[0])}")
|
||||||
|
|
||||||
|
print("Standard ByteTrack test passed successfully!")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from ultralytics import YOLO
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def main():
|
||||||
|
model_path = "/home/jetson/karung/model_karung_truk.engine"
|
||||||
|
print("Loading model...")
|
||||||
|
model = YOLO(model_path)
|
||||||
|
|
||||||
|
# Warm up
|
||||||
|
print("Warming up model...")
|
||||||
|
dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
|
||||||
|
results = model(dummy, imgsz=640, device="cuda", verbose=False)
|
||||||
|
print("Warm up complete!")
|
||||||
|
|
||||||
|
# Test tracking
|
||||||
|
print("Testing track on 5 frames...")
|
||||||
|
for i in range(5):
|
||||||
|
print(f"Tracking frame {i+1}...")
|
||||||
|
results = model.track(dummy, persist=True, verbose=False)
|
||||||
|
print(f"Frame {i+1} track complete! Detections count: {len(results[0])}")
|
||||||
|
|
||||||
|
print("All tests passed successfully!")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"palet": [
|
||||||
|
[
|
||||||
|
612,
|
||||||
|
628
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1454,
|
||||||
|
630
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1458,
|
||||||
|
1074
|
||||||
|
],
|
||||||
|
[
|
||||||
|
608,
|
||||||
|
1071
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"truck": [
|
||||||
|
[600, 385],
|
||||||
|
[609, 1076],
|
||||||
|
[1404, 1078],
|
||||||
|
[1381, 343]
|
||||||
|
],
|
||||||
|
"counting": [
|
||||||
|
[574, 50],
|
||||||
|
[586, 1077],
|
||||||
|
[1418, 1076],
|
||||||
|
[1397, 50]
|
||||||
|
],
|
||||||
|
"left_limit": 0.27578,
|
||||||
|
"right_limit": 0.72578,
|
||||||
|
"duplicate_circle_radius": 30,
|
||||||
|
"min_valid_area": 15000,
|
||||||
|
"jarak_toleransi_duplikat": 30,
|
||||||
|
"max_reid_transit_distance": 400,
|
||||||
|
"circle_stay_timeout_sec": 10.0,
|
||||||
|
"inference_stride": 2,
|
||||||
|
"confirm_delay_sec": 0.5,
|
||||||
|
"exit_confirm_delay_sec": 6.0,
|
||||||
|
"external_stream_url": "http://192.168.192.96:8888/cam/"
|
||||||
|
}
|
||||||
Reference in new issue
Block a user