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