feat: add manual & auto batch counting modes, dual-port operator and monitoring dashboards, and historical batch data corrections

This commit is contained in:
ervanfahriaw committed 2026-08-24 15:25:17 +07:00
commit 0235a0f597
53 files changed
+14127

No files matched your search

+19
View File
@@ -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
View File
@@ -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
+1388
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -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()
+26
View File
@@ -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
+35
View File
@@ -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()
+807
View File
@@ -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()
+47
View File
@@ -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()
+94
View File
@@ -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()
+46
View File
@@ -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()
+69
View File
@@ -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)
+35
View File
@@ -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()
+207
View File
@@ -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()
+2100
View File
File diff suppressed because it is too large. Load diff
+16
View File
@@ -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
+17
View File
@@ -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
+14
View File
@@ -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
+209
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+840
View File
@@ -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.")
+12
View File
@@ -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
}
+15
View File
@@ -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"
}
+12
View File
@@ -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
}
+11
View File
@@ -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"
}
+14
View File
@@ -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"
}
]
}
+21
View File
@@ -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"
}
+17
View File
@@ -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
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"enabled": true,
"token": "8654129536:AAHrx4x7OPm84WRDhRLj3oIMgecUDKSfqgs",
"chat_id": "-5147118224"
}
+1533
View File
File diff suppressed because it is too large. Load diff
+625
View File
@@ -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).")
+68
View File
@@ -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.")
+1
View File
@@ -0,0 +1 @@
"""Package marker."""
+395
View File
@@ -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)
+64
View File
@@ -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
View File
@@ -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()
+251
View File
@@ -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,
)
+81
View File
@@ -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
+98
View File
@@ -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: ...
+86
View File
@@ -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
View File
@@ -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()
+126
View File
@@ -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()
+80
View File
@@ -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)
+84
View File
@@ -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)
+151
View File
@@ -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
+379
View File
@@ -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 %}
+401
View File
@@ -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>&copy; 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>
+417
View File
@@ -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 %}
+681
View File
@@ -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 %}
+343
View File
@@ -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 %}
+28
View File
@@ -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()
+28
View File
@@ -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
View File
@@ -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/"
}