2100 lines
94 KiB
Python
2100 lines
94 KiB
Python
import os
|
|
# Force OpenCV/FFmpeg to use TCP for RTSP streams to avoid UDP packet loss and decoding errors
|
|
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp"
|
|
import cv2
|
|
import numpy as np
|
|
import json
|
|
import threading
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
from socketserver import ThreadingMixIn
|
|
from ultralytics import YOLO
|
|
from shapely.geometry import Point, Polygon, LineString, box
|
|
from collections import defaultdict, deque
|
|
|
|
# --- RUNNING LOCALLY (Colab patches removed) ---
|
|
# -----------------------------------------------
|
|
|
|
# =====================================================================
|
|
# 0. PARAMETER KALIBRASI
|
|
# =====================================================================
|
|
MIN_VALID_AREA_REF = 15000
|
|
MIN_VALID_AREA = 15000
|
|
JARAK_ABSORBSI_GHOST = 50
|
|
|
|
# --- FIX: logika masuk/keluar sekarang murni berbasis overlap + delay,
|
|
# TIDAK lagi bergantung pada "state" (zone debounce) atau jarak anchor 500px ---
|
|
ENTRY_OVERLAP_THRESHOLD = 0.20 # 20% - agar langsung masuk delay counting ketika masuk truk
|
|
EXIT_OVERLAP_THRESHOLD = 0.05 # diturunkan ke 5% agar tidak mudah dianggap keluar
|
|
CONFIRM_DELAY_SEC = 0.5 # delay masuk
|
|
EXIT_CONFIRM_DELAY_SEC = 6.0 # --- TAMBAHAN BARU: delay keluar truk (3 detik) ---
|
|
COUNTED_DISPLAY_TIMEOUT_SEC = 0.5 # durasi tampil kotak hijau setelah terhitung (detik)
|
|
|
|
# =====================================================================
|
|
# 0.1 PARAMETER ANTI-DOUBLE COUNT (SPASIAL DUPLIKASI) # --- TAMBAHAN BARU ---
|
|
# =====================================================================
|
|
JARAK_TOLERANSI_DUPLIKAT_REF = 80
|
|
JARAK_TOLERANSI_DUPLIKAT = 80 # Jarak maks (pixel) - Teroptimasi untuk menghindari false-positive duplicate
|
|
TOLERANSI_FRAME_HILANG = 1200 # Diingat lebih lama (120 frame ~ 4 detik) untuk mencegah double-count
|
|
MAX_REID_TRANSIT_DISTANCE_REF = 400
|
|
MAX_REID_TRANSIT_DISTANCE = 400 # Jarak maks (pixel) - Teroptimasi untuk mencegah salah Re-ID
|
|
|
|
# --- PARAMETER UNTUK BBOX YANG MUNCUL TIBA-TIBA DI TRUK ---
|
|
MIN_DISPLACEMENT_START_IN_TRUCK = 15
|
|
MIN_LINEARITY_START_IN_TRUCK = 0.70
|
|
|
|
# --- PARAMETER UNTUK BBOX DI ZONA COUNTING (DARI PALET KE TRUK) ---
|
|
MIN_DISPLACEMENT_COUNTING_ZONE = 12
|
|
MIN_DY_COUNTING_ZONE = -3
|
|
|
|
# --- PARAMETER LINGKARAN DUPLIKAT STATIS DI TRUK ---
|
|
DUPLICATE_CIRCLE_RADIUS_REF = 60
|
|
DUPLICATE_CIRCLE_RADIUS_REF = 60
|
|
DUPLICATE_CIRCLE_RADIUS_REF = 60
|
|
DUPLICATE_CIRCLE_RADIUS_REF = 60
|
|
DUPLICATE_CIRCLE_RADIUS_REF = 60
|
|
DUPLICATE_CIRCLE_RADIUS_REF = 60
|
|
DUPLICATE_CIRCLE_RADIUS = 20
|
|
SHOW_ALL_BBOXES = False
|
|
SHOW_ALL_BBOXES = False
|
|
SHOW_ALL_BBOXES = False # Radius lingkaran statis (pixel)
|
|
CIRCLE_STAY_TIMEOUT_SEC = 10.0 # Durasi tinggal maks sebelum dianggap duplikat (detik)
|
|
INFERENCE_STRIDE = 2 # Inferensi stride skipping frame (Default: 2)
|
|
# =====================================================================
|
|
|
|
# =====================================================================
|
|
# 1. KONFIGURASI KOORDINAT ZONA
|
|
# =====================================================================
|
|
width = 1920
|
|
height = 1080
|
|
scale_x = 1.0
|
|
scale_y = 1.0
|
|
|
|
ZONES_JSON_PATH = "zones.json"
|
|
DEFAULT_PALET = []
|
|
DEFAULT_TRUCK = []
|
|
|
|
def load_zones():
|
|
global ZONA_PALET_REF, ZONA_TRUCK_REF, GARIS_COUNTING_REF, DUPLICATE_CIRCLE_RADIUS_REF
|
|
global MIN_VALID_AREA_REF, JARAK_TOLERANSI_DUPLIKAT_REF, MAX_REID_TRANSIT_DISTANCE_REF
|
|
global CIRCLE_STAY_TIMEOUT_SEC, INFERENCE_STRIDE
|
|
if os.path.exists(ZONES_JSON_PATH):
|
|
try:
|
|
with open(ZONES_JSON_PATH, 'r') as f:
|
|
data = json.load(f)
|
|
ZONA_PALET_REF = np.array(data['palet'], dtype=np.int32)
|
|
ZONA_TRUCK_REF = np.array(data['truck'], dtype=np.int32)
|
|
GARIS_COUNTING_REF = ZONA_TRUCK_REF.copy()
|
|
DUPLICATE_CIRCLE_RADIUS_REF = data.get('duplicate_circle_radius', 60)
|
|
MIN_VALID_AREA_REF = data.get('min_valid_area', 15000)
|
|
JARAK_TOLERANSI_DUPLIKAT_REF = data.get('jarak_toleransi_duplikat', 20)
|
|
MAX_REID_TRANSIT_DISTANCE_REF = data.get('max_reid_transit_distance', 400)
|
|
CIRCLE_STAY_TIMEOUT_SEC = data.get('circle_stay_timeout_sec', 10.0)
|
|
INFERENCE_STRIDE = data.get('inference_stride', 2)
|
|
print("[INFO] Berhasil memuat koordinat zona dan parameter kalibrasi dari zones.json")
|
|
return
|
|
except Exception as e:
|
|
print(f"[WARNING] Gagal memuat zones.json ({e}), menggunakan default.")
|
|
|
|
ZONA_PALET_REF = np.array(DEFAULT_PALET, dtype=np.int32)
|
|
ZONA_TRUCK_REF = np.array(DEFAULT_TRUCK, dtype=np.int32)
|
|
GARIS_COUNTING_REF = ZONA_TRUCK_REF.copy()
|
|
|
|
load_zones()
|
|
|
|
ZONA_PALET = ZONA_PALET_REF.copy()
|
|
ZONA_TRUCK = ZONA_TRUCK_REF.copy()
|
|
GARIS_COUNTING = GARIS_COUNTING_REF.copy()
|
|
|
|
poly_palet = Polygon(ZONA_PALET) if len(ZONA_PALET) >= 3 else None
|
|
poly_truck = Polygon(ZONA_TRUCK) if len(ZONA_TRUCK) >= 3 else None
|
|
line_counting = poly_truck.boundary if poly_truck is not None else None
|
|
|
|
DEBOUNCE_FRAMES = 8 # tetap dipakai untuk label visual zona (PALET/AREA BEBAS), TIDAK untuk keputusan counting
|
|
|
|
# =====================================================================
|
|
# 2. STATE TRACKING
|
|
# =====================================================================
|
|
track_zone_history = defaultdict(lambda: deque(maxlen=DEBOUNCE_FRAMES))
|
|
track_confirmed_state = {} # dipakai untuk LABEL VISUAL saja (PALET/AREA BEBAS), bukan untuk counting
|
|
|
|
is_locked = defaultdict(bool)
|
|
already_counted = defaultdict(bool)
|
|
has_crossed_line = defaultdict(bool) # --- TAMBAHAN BARU ---
|
|
exit_crossed_line = defaultdict(bool) # --- TAMBAHAN BARU: LOGIKA KELUAR ---
|
|
track_areas = defaultdict(float) # --- TAMBAHAN BARU: LUAS BBOX ---
|
|
track_started_in_truck = defaultdict(bool)
|
|
outside_truck_frames = defaultdict(int)
|
|
counted_at_frame = {}
|
|
|
|
# --- FIX: pending timer terpisah untuk proses MASUK dan KELUAR, berbasis overlap, bukan jarak ---
|
|
pending_enter_since = defaultdict(lambda: None)
|
|
pending_exit_since = defaultdict(lambda: None)
|
|
|
|
track_positions = defaultdict(lambda: deque(maxlen=20))
|
|
lost_tracks = {}
|
|
prev_active_track_ids = set()
|
|
|
|
# --- STATE LINGKARAN DUPLIKAT STATIS ---
|
|
track_initial_truck_pos = {}
|
|
track_truck_entry_frame = {}
|
|
has_exited_circle = defaultdict(bool)
|
|
delay_completed = defaultdict(bool)
|
|
|
|
metrics = {
|
|
"total_masuk": 0,
|
|
"total_keluar": 0
|
|
}
|
|
|
|
MAX_REID_DISTANCE = 120
|
|
MAX_REID_FRAMES = 200
|
|
|
|
# Warna
|
|
COKLAT = (19, 69, 139) # PENDING - baru masuk, menunggu konfirmasi 0.5s
|
|
HIJAU_TERVERIFIKASI = (100, 255, 100) # CONFIRMED - masuk sah
|
|
ORANYE_PENDING_KELUAR = (0, 165, 255) # PENDING - sedang menunggu konfirmasi keluar
|
|
BIRU_PALET = (255, 100, 100)
|
|
MERAH_BEBAS = (100, 100, 255)
|
|
ABU_FRAGMENT = (150, 150, 150)
|
|
|
|
|
|
# =====================================================================
|
|
# MULTI-THREADED REAL-TIME WEB DASHBOARD & STREAMING (ZERO DEPENDENCY)
|
|
# =====================================================================
|
|
import queue
|
|
|
|
streaming_frame = None
|
|
streaming_lock = threading.Lock()
|
|
sse_clients = []
|
|
sse_lock = threading.Lock()
|
|
live_stream_enabled = False
|
|
current_fps = 0.0
|
|
save_queue = queue.Queue(maxsize=100)
|
|
|
|
DASHBOARD_HTML = """
|
|
<!DOCTYPE html>
|
|
<html lang="id">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>AI Sack Counter Dashboard</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--bg-color: #0d0e12;
|
|
--panel-bg: rgba(22, 24, 33, 0.8);
|
|
--border-color: rgba(255, 255, 255, 0.08);
|
|
--accent-primary: #00ff88;
|
|
--accent-secondary: #ff3b30;
|
|
--accent-neutral: #ffcc00;
|
|
--accent-cyan: #00f0ff;
|
|
--text-main: #f5f6fa;
|
|
--text-muted: #8a8d9a;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Outfit', sans-serif;
|
|
background-color: var(--bg-color);
|
|
color: var(--text-main);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 20px 40px;
|
|
background: rgba(13, 14, 18, 0.5);
|
|
backdrop-filter: blur(10px);
|
|
border-bottom: 1px solid var(--border-color);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
}
|
|
|
|
.logo-section {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 15px;
|
|
}
|
|
|
|
.logo-section h1 {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
background: linear-gradient(135deg, #00f0ff, #00ff88);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 12px;
|
|
height: 12px;
|
|
background-color: var(--accent-primary);
|
|
border-radius: 50%;
|
|
display: inline-block;
|
|
box-shadow: 0 0 10px var(--accent-primary);
|
|
animation: pulse 1.5s infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0% { transform: scale(0.9); opacity: 0.6; }
|
|
50% { transform: scale(1.1); opacity: 1; }
|
|
100% { transform: scale(0.9); opacity: 0.6; }
|
|
}
|
|
|
|
.container {
|
|
display: grid;
|
|
grid-template-columns: 2fr 1fr;
|
|
gap: 30px;
|
|
padding: 30px 40px;
|
|
flex: 1;
|
|
}
|
|
|
|
@media (max-width: 1024px) {
|
|
.container {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.panel {
|
|
background: var(--panel-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 16px;
|
|
padding: 25px;
|
|
backdrop-filter: blur(12px);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
|
}
|
|
|
|
.panel-title {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: var(--text-main);
|
|
border-left: 4px solid var(--accent-cyan);
|
|
padding-left: 10px;
|
|
}
|
|
|
|
.kpi-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 20px;
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.kpi-grid {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
}
|
|
|
|
.kpi-card {
|
|
background: rgba(255, 255, 255, 0.02);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
text-align: center;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
transition: transform 0.2s, border-color 0.2s;
|
|
}
|
|
|
|
.kpi-card:hover {
|
|
transform: translateY(-2px);
|
|
border-color: rgba(255, 255, 255, 0.15);
|
|
}
|
|
|
|
.kpi-val {
|
|
font-size: 36px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.kpi-label {
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.card-masuk .kpi-val { color: var(--accent-primary); text-shadow: 0 0 15px rgba(0, 255, 136, 0.2); }
|
|
.card-keluar .kpi-val { color: var(--accent-secondary); text-shadow: 0 0 15px rgba(255, 59, 48, 0.2); }
|
|
.card-net .kpi-val { color: var(--accent-neutral); text-shadow: 0 0 15px rgba(255, 204, 0, 0.2); }
|
|
.card-fps .kpi-val { color: var(--accent-cyan); text-shadow: 0 0 15px rgba(0, 240, 255, 0.2); }
|
|
|
|
.video-container {
|
|
width: 100%;
|
|
aspect-ratio: 16/9;
|
|
background: #000;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
position: relative;
|
|
border: 1px solid var(--border-color);
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.video-img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: fill;
|
|
}
|
|
|
|
.video-placeholder {
|
|
text-align: center;
|
|
padding: 30px;
|
|
color: var(--text-muted);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
align-items: center;
|
|
}
|
|
|
|
.video-placeholder svg {
|
|
width: 48px;
|
|
height: 48px;
|
|
stroke: var(--text-muted);
|
|
}
|
|
|
|
.toggle-container {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
background: rgba(255, 255, 255, 0.02);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 12px;
|
|
padding: 15px 20px;
|
|
}
|
|
|
|
.toggle-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.toggle-title {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.toggle-desc {
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.switch {
|
|
position: relative;
|
|
display: inline-block;
|
|
width: 50px;
|
|
height: 26px;
|
|
}
|
|
|
|
.switch input {
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
|
|
.slider {
|
|
position: absolute;
|
|
cursor: pointer;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: #3a3b45;
|
|
transition: .3s;
|
|
border-radius: 34px;
|
|
}
|
|
|
|
.slider:before {
|
|
position: absolute;
|
|
content: "";
|
|
height: 18px;
|
|
width: 18px;
|
|
left: 4px;
|
|
bottom: 4px;
|
|
background-color: white;
|
|
transition: .3s;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
input:checked + .slider {
|
|
background-color: var(--accent-primary);
|
|
}
|
|
|
|
input:checked + .slider:before {
|
|
transform: translateX(24px);
|
|
}
|
|
|
|
.log-panel {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
min-height: 300px;
|
|
}
|
|
|
|
.log-list {
|
|
flex: 1;
|
|
background: rgba(0, 0, 0, 0.2);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 12px;
|
|
padding: 15px;
|
|
overflow-y: auto;
|
|
font-family: monospace;
|
|
font-size: 13px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
max-height: 400px;
|
|
}
|
|
|
|
.log-item {
|
|
display: flex;
|
|
gap: 10px;
|
|
padding: 6px 10px;
|
|
border-radius: 6px;
|
|
background: rgba(255, 255, 255, 0.01);
|
|
animation: slideIn 0.2s ease-out;
|
|
}
|
|
|
|
@keyframes slideIn {
|
|
from { opacity: 0; transform: translateY(-5px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.log-time {
|
|
color: var(--accent-cyan);
|
|
font-weight: bold;
|
|
}
|
|
|
|
.log-msg {
|
|
color: var(--text-main);
|
|
}
|
|
|
|
.log-item.masuk {
|
|
border-left: 3px solid var(--accent-primary);
|
|
background: rgba(0, 255, 136, 0.03);
|
|
}
|
|
|
|
.log-item.keluar {
|
|
border-left: 3px solid var(--accent-secondary);
|
|
background: rgba(255, 59, 48, 0.03);
|
|
}
|
|
|
|
.btn-download {
|
|
background: rgba(255, 255, 255, 0.03);
|
|
border: 1px solid var(--border-color);
|
|
color: var(--text-main);
|
|
padding: 12px;
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
text-decoration: none;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
transition: background 0.2s, border-color 0.2s;
|
|
}
|
|
|
|
.btn-download:hover {
|
|
background: rgba(255, 255, 255, 0.08);
|
|
border-color: var(--accent-cyan);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div class="logo-section">
|
|
<span class="status-dot"></span>
|
|
<h1>Sack Counter Real-time Dashboard</h1>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="container">
|
|
<!-- Kolom Kiri: Live Predict & KPI -->
|
|
<div class="panel">
|
|
<div class="panel-title">LIVE PREDICTION VIEW</div>
|
|
|
|
<div class="video-container" id="video-container" style="position: relative;">
|
|
<div class="video-placeholder" id="video-placeholder">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
|
</svg>
|
|
<p>Live stream is disabled to maximize counting speed (25 FPS).</p>
|
|
</div>
|
|
<canvas id="zone-canvas" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10;"></canvas>
|
|
</div>
|
|
|
|
<div class="toggle-container" style="flex-direction: column; align-items: stretch; gap: 15px;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
|
|
<div class="toggle-info">
|
|
<div class="toggle-title">Tampilkan Live Prediction Stream</div>
|
|
<div class="toggle-desc">Mengaktifkan visualisasi video real-time. Mematikan fitur ini akan menaikkan FPS pemrosesan ke batas maksimal.</div>
|
|
</div>
|
|
<label class="switch">
|
|
<input type="checkbox" id="stream-toggle" onchange="toggleStream(this.checked)">
|
|
<span class="slider"></span>
|
|
</label>
|
|
</div>
|
|
|
|
<div style="border-top: 1px solid var(--border-color); padding-top: 15px; display: flex; flex-direction: column; gap: 10px; width: 100%;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
|
|
<span class="toggle-title">Atur Koordinat Zona Deteksi</span>
|
|
<button id="btn-edit-zones" class="btn-download" style="padding: 6px 12px; margin: 0; border-color: var(--accent-cyan);" onclick="startEditingZones()">Gambar Zona</button>
|
|
</div>
|
|
<div id="zone-editor-controls" style="display: none; background: rgba(255, 255, 255, 0.02); padding: 15px; border-radius: 8px; border: 1px dashed var(--accent-cyan); flex-direction: column; gap: 12px;">
|
|
<div style="font-size: 12px; color: var(--text-muted);">
|
|
Petunjuk: Klik 4 kali pada video untuk menggambar sudut zona. Ulangi untuk kedua zona.
|
|
</div>
|
|
<div style="display: flex; gap: 10px; align-items: center;">
|
|
<label style="font-size: 13px;">Pilih Zona:</label>
|
|
<select id="select-active-zone" style="background: #161821; color: #fff; border: 1px solid var(--border-color); padding: 6px; border-radius: 6px; font-family: inherit;" onchange="changeActiveZone()">
|
|
<option value="palet">Zona Palet (Biru)</option>
|
|
<option value="truck">Zona Truk / Counting (Kuning)</option>
|
|
</select>
|
|
</div>
|
|
<div style="font-size: 12px;">
|
|
Jumlah klik zona aktif: <strong id="click-count" style="color: var(--accent-cyan);">0 / 4</strong>
|
|
</div>
|
|
<div style="display: flex; flex-direction: column; gap: 6px; border-top: 1px solid var(--border-color); padding-top: 10px;">
|
|
<label style="font-size: 13px; display: flex; justify-content: space-between;">
|
|
<span>Radius Anti-Double:</span>
|
|
<strong id="radius-val" style="color: var(--accent-cyan);">60 px</strong>
|
|
</label>
|
|
<input type="range" id="radius-slider" min="10" max="150" value="60" style="width: 100%; accent-color: var(--accent-cyan);" oninput="updateRadiusLabel(this.value)">
|
|
</div>
|
|
<div style="display: flex; gap: 10px; margin-top: 5px;">
|
|
<button class="btn-download" style="padding: 8px 14px; border-color: var(--accent-primary); background: rgba(0, 255, 136, 0.05);" onclick="saveCustomZones()">Simpan</button>
|
|
<button class="btn-download" style="padding: 8px 14px;" onclick="resetCurrentZonePoints()">Ulangi</button>
|
|
<button class="btn-download" style="padding: 8px 14px; border-color: var(--accent-secondary); background: rgba(255, 59, 48, 0.05);" onclick="cancelEditingZones()">Batal</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="border-top: 1px solid var(--border-color); padding-top: 15px; display: flex; flex-direction: column; gap: 10px; width: 100%;">
|
|
<span class="toggle-title">Kalibrasi Parameter Deteksi</span>
|
|
<div style="background: rgba(255, 255, 255, 0.01); padding: 15px; border-radius: 8px; border: 1px solid var(--border-color); display: flex; flex-direction: column; gap: 10px;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; gap: 10px;">
|
|
<label style="font-size: 12px; color: var(--text-muted);">Min Area BBox (Fragment Filter):</label>
|
|
<input type="number" id="input-min-area" style="width: 80px; background: #161821; color: #fff; border: 1px solid var(--border-color); padding: 4px 6px; border-radius: 6px; text-align: center; font-family: inherit; font-size: 12px;" value="15000">
|
|
</div>
|
|
<div style="display: flex; justify-content: space-between; align-items: center; gap: 10px;">
|
|
<label style="font-size: 12px; color: var(--text-muted);">Toleransi Jarak Duplikat (px):</label>
|
|
<input type="number" id="input-dup-dist" style="width: 80px; background: #161821; color: #fff; border: 1px solid var(--border-color); padding: 4px 6px; border-radius: 6px; text-align: center; font-family: inherit; font-size: 12px;" value="20">
|
|
</div>
|
|
<div style="display: flex; justify-content: space-between; align-items: center; gap: 10px;">
|
|
<label style="font-size: 12px; color: var(--text-muted);">Re-ID Transit Distance (px):</label>
|
|
<input type="number" id="input-reid-dist" style="width: 80px; background: #161821; color: #fff; border: 1px solid var(--border-color); padding: 4px 6px; border-radius: 6px; text-align: center; font-family: inherit; font-size: 12px;" value="400">
|
|
</div>
|
|
<div style="display: flex; justify-content: space-between; align-items: center; gap: 10px;">
|
|
<label style="font-size: 12px; color: var(--text-muted);">Timeout Diam Lingkaran (detik):</label>
|
|
<input type="number" id="input-timeout" step="0.5" style="width: 80px; background: #161821; color: #fff; border: 1px solid var(--border-color); padding: 4px 6px; border-radius: 6px; text-align: center; font-family: inherit; font-size: 12px;" value="10.0">
|
|
</div>
|
|
<div style="display: flex; justify-content: space-between; align-items: center; gap: 10px;">
|
|
<label style="font-size: 12px; color: var(--text-muted);">Inference Frame Stride:</label>
|
|
<select id="select-stride" style="width: 80px; background: #161821; color: #fff; border: 1px solid var(--border-color); padding: 4px 6px; border-radius: 6px; text-align: center; font-family: inherit; font-size: 12px;">
|
|
<option value="1">1 (No Skip)</option>
|
|
<option value="2">2 (Skip 1f)</option>
|
|
<option value="3">3 (Skip 2f)</option>
|
|
<option value="4">4 (Skip 3f)</option>
|
|
</select>
|
|
</div>
|
|
<button class="btn-download" style="padding: 6px 12px; border-color: var(--accent-cyan); font-size: 12px; font-weight: bold; margin-top: 5px; width: 100%;" onclick="saveCalibrationOnly()">Terapkan Kalibrasi</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="kpi-grid">
|
|
<div class="kpi-card card-masuk">
|
|
<div class="kpi-val" id="val-masuk">0</div>
|
|
<div class="kpi-label">Total Masuk</div>
|
|
</div>
|
|
<div class="kpi-card card-keluar">
|
|
<div class="kpi-val" id="val-keluar">0</div>
|
|
<div class="kpi-label">Total Keluar</div>
|
|
</div>
|
|
<div class="kpi-card card-net">
|
|
<div class="kpi-val" id="val-net">0</div>
|
|
<div class="kpi-label">Net di Truck</div>
|
|
</div>
|
|
<div class="kpi-card card-fps">
|
|
<div class="kpi-val" id="val-fps">0.0</div>
|
|
<div class="kpi-label">Processing FPS</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Kolom Kanan: Log -->
|
|
<div class="panel">
|
|
<div class="panel-title">RIWAYAT DETEKSI REAL-TIME</div>
|
|
<div class="log-panel">
|
|
<div class="log-list" id="log-list" style="max-height: 520px;">
|
|
<div style="color: var(--text-muted); text-align: center; margin-top: 100px;">Menunggu aktivitas deteksi...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let sseSource = null;
|
|
let isEditingZones = false;
|
|
let activeZoneType = 'palet';
|
|
let zonePoints = { palet: [], truck: [] };
|
|
|
|
const canvas = document.getElementById('zone-canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
function resizeCanvas() {
|
|
canvas.width = canvas.clientWidth;
|
|
canvas.height = canvas.clientHeight;
|
|
drawZones();
|
|
}
|
|
|
|
window.addEventListener('resize', resizeCanvas);
|
|
|
|
let currentBBoxes = [];
|
|
let circleRadius = 0.03;
|
|
|
|
function drawZones() {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Draw Palet Zone (Blue)
|
|
if (zonePoints.palet && zonePoints.palet.length > 0) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(zonePoints.palet[0][0] * canvas.width, zonePoints.palet[0][1] * canvas.height);
|
|
for (let i = 1; i < zonePoints.palet.length; i++) {
|
|
ctx.lineTo(zonePoints.palet[i][0] * canvas.width, zonePoints.palet[i][1] * canvas.height);
|
|
}
|
|
if (zonePoints.palet.length === 4) {
|
|
ctx.closePath();
|
|
ctx.fillStyle = 'rgba(0, 240, 255, 0.15)';
|
|
ctx.fill();
|
|
}
|
|
ctx.strokeStyle = '#00f0ff';
|
|
ctx.lineWidth = 3;
|
|
ctx.stroke();
|
|
|
|
zonePoints.palet.forEach((pt) => {
|
|
ctx.beginPath();
|
|
ctx.arc(pt[0] * canvas.width, pt[1] * canvas.height, 6, 0, 2 * Math.PI);
|
|
ctx.fillStyle = '#00f0ff';
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#fff';
|
|
ctx.stroke();
|
|
});
|
|
}
|
|
|
|
// Draw Truck Zone (Yellow)
|
|
if (zonePoints.truck && zonePoints.truck.length > 0) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(zonePoints.truck[0][0] * canvas.width, zonePoints.truck[0][1] * canvas.height);
|
|
for (let i = 1; i < zonePoints.truck.length; i++) {
|
|
ctx.lineTo(zonePoints.truck[i][0] * canvas.width, zonePoints.truck[i][1] * canvas.height);
|
|
}
|
|
if (zonePoints.truck.length === 4) {
|
|
ctx.closePath();
|
|
ctx.fillStyle = 'rgba(255, 204, 0, 0.15)';
|
|
ctx.fill();
|
|
}
|
|
ctx.strokeStyle = '#ffcc00';
|
|
ctx.lineWidth = 6;
|
|
ctx.stroke();
|
|
|
|
zonePoints.truck.forEach((pt) => {
|
|
ctx.beginPath();
|
|
ctx.arc(pt[0] * canvas.width, pt[1] * canvas.height, 6, 0, 2 * Math.PI);
|
|
ctx.fillStyle = '#ffcc00';
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#fff';
|
|
ctx.stroke();
|
|
});
|
|
}
|
|
|
|
// Draw Bounding Boxes and Trails from SSE data
|
|
currentBBoxes.forEach(item => {
|
|
const color = item.color;
|
|
const bbox = item.bbox;
|
|
const trail = item.trail;
|
|
const status = item.status;
|
|
const id = item.id;
|
|
|
|
const bx1 = bbox[0] * canvas.width;
|
|
const by1 = bbox[1] * canvas.height;
|
|
const bx2 = bbox[2] * canvas.width;
|
|
const by2 = bbox[3] * canvas.height;
|
|
const bw = bx2 - bx1;
|
|
const bh = by2 - by1;
|
|
|
|
// Draw fading trail
|
|
if (trail && trail.length > 1) {
|
|
ctx.lineWidth = 2;
|
|
for (let i = 1; i < trail.length; i++) {
|
|
const alpha = i / trail.length;
|
|
ctx.strokeStyle = color.replace('1.0', alpha.toString());
|
|
ctx.beginPath();
|
|
ctx.moveTo(trail[i - 1][0] * canvas.width, trail[i - 1][1] * canvas.height);
|
|
ctx.lineTo(trail[i][0] * canvas.width, trail[i][1] * canvas.height);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
// Draw Bounding Box rectangle
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(bx1, by1, bw, bh);
|
|
|
|
// Draw centroid dot
|
|
const cx = item.centroid[0] * canvas.width;
|
|
const cy = item.centroid[1] * canvas.height;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, 4, 0, 2 * Math.PI);
|
|
ctx.fillStyle = '#00f0ff';
|
|
ctx.fill();
|
|
|
|
// Draw static anti-double circle
|
|
if (item.initial_pos && !item.has_exited_circle) {
|
|
const icx = item.initial_pos[0] * canvas.width;
|
|
const icy = item.initial_pos[1] * canvas.height;
|
|
const r = circleRadius * canvas.width;
|
|
ctx.strokeStyle = 'rgba(0, 255, 255, 0.6)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(icx, icy, r, 0, 2 * Math.PI);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Draw label text
|
|
ctx.fillStyle = color;
|
|
ctx.font = 'bold 12px sans-serif';
|
|
let labelText = `ID:${id} [${status}]`;
|
|
if (item.is_locked) labelText += ' [LOCKED]';
|
|
ctx.fillText(labelText, bx1, by1 - 6);
|
|
});
|
|
|
|
// Draw Warning if Zones are not configured
|
|
if ((!zonePoints.palet || zonePoints.palet.length < 3) && (!zonePoints.truck || zonePoints.truck.length < 3)) {
|
|
ctx.fillStyle = '#ff3b30';
|
|
ctx.font = 'bold 18px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText("ZONA BELUM DIKONFIGURASI. Silakan klik 'Gambar Zona'.", canvas.width / 2, 40);
|
|
ctx.textAlign = 'left';
|
|
}
|
|
|
|
// Draw sample duplicate circle at the center of screen to preview size
|
|
if (isEditingZones) {
|
|
const radiusVal = parseInt(document.getElementById('radius-slider').value) || 60;
|
|
const canvasRadius = (radiusVal / 1920) * canvas.width;
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(canvas.width / 2, canvas.height / 2, canvasRadius, 0, 2 * Math.PI);
|
|
ctx.strokeStyle = 'rgba(255, 255, 0, 0.4)';
|
|
ctx.lineWidth = 2;
|
|
ctx.setLineDash([5, 5]);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
ctx.fillStyle = 'rgba(255, 255, 0, 0.7)';
|
|
ctx.font = '12px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(`Preview Radius Anti-Double: ${radiusVal} px`, canvas.width / 2, canvas.height / 2 + canvasRadius + 15);
|
|
ctx.textAlign = 'left';
|
|
}
|
|
}
|
|
|
|
canvas.addEventListener('mousedown', function(e) {
|
|
if (!isEditingZones) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = (e.clientX - rect.left) / canvas.width;
|
|
const y = (e.clientY - rect.top) / canvas.height;
|
|
|
|
if (zonePoints[activeZoneType].length < 4) {
|
|
zonePoints[activeZoneType].push([x, y]);
|
|
document.getElementById('click-count').innerText = `${zonePoints[activeZoneType].length} / 4`;
|
|
drawZones();
|
|
}
|
|
});
|
|
|
|
function startEditingZones() {
|
|
const streamToggle = document.getElementById('stream-toggle');
|
|
if (!streamToggle.checked) {
|
|
streamToggle.checked = true;
|
|
toggleStream(true);
|
|
}
|
|
|
|
isEditingZones = true;
|
|
canvas.style.pointerEvents = 'auto';
|
|
document.getElementById('zone-editor-controls').style.display = 'flex';
|
|
document.getElementById('btn-edit-zones').style.display = 'none';
|
|
|
|
loadCalibration();
|
|
setTimeout(resizeCanvas, 300);
|
|
}
|
|
|
|
function cancelEditingZones() {
|
|
isEditingZones = false;
|
|
canvas.style.pointerEvents = 'none';
|
|
document.getElementById('zone-editor-controls').style.display = 'none';
|
|
document.getElementById('btn-edit-zones').style.display = 'block';
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
}
|
|
|
|
function changeActiveZone() {
|
|
activeZoneType = document.getElementById('select-active-zone').value;
|
|
document.getElementById('click-count').innerText = `${zonePoints[activeZoneType].length} / 4`;
|
|
}
|
|
|
|
function resetCurrentZonePoints() {
|
|
zonePoints[activeZoneType] = [];
|
|
document.getElementById('click-count').innerText = `0 / 4`;
|
|
drawZones();
|
|
}
|
|
|
|
function updateRadiusLabel(val) {
|
|
document.getElementById('radius-val').innerText = val + ' px';
|
|
drawZones();
|
|
}
|
|
|
|
function loadCalibration() {
|
|
fetch('/api/get_zones')
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
zonePoints = { palet: data.palet, truck: data.truck };
|
|
const radius = data.duplicate_circle_radius || 60;
|
|
document.getElementById('radius-slider').value = radius;
|
|
document.getElementById('radius-val').innerText = radius + ' px';
|
|
|
|
document.getElementById('input-min-area').value = data.min_valid_area || 15000;
|
|
document.getElementById('input-dup-dist').value = data.jarak_toleransi_duplikat || 20;
|
|
document.getElementById('input-reid-dist').value = data.max_reid_transit_distance || 400;
|
|
document.getElementById('input-timeout').value = data.circle_stay_timeout_sec || 10.0;
|
|
document.getElementById('select-stride').value = data.inference_stride || 2;
|
|
});
|
|
}
|
|
|
|
function saveCustomZones() {
|
|
if (zonePoints.palet.length !== 4 || zonePoints.truck.length !== 4) {
|
|
alert("Silakan gambar kedua zona lengkap dengan masing-masing 4 sudut!");
|
|
return;
|
|
}
|
|
|
|
const paletStr = zonePoints.palet.map(pt => `${pt[0]},${pt[1]}`).join(';');
|
|
const truckStr = zonePoints.truck.map(pt => `${pt[0]},${pt[1]}`).join(';');
|
|
const radius = document.getElementById('radius-slider').value;
|
|
|
|
const minArea = document.getElementById('input-min-area').value;
|
|
const dupDist = document.getElementById('input-dup-dist').value;
|
|
const reidDist = document.getElementById('input-reid-dist').value;
|
|
const timeout = document.getElementById('input-timeout').value;
|
|
const stride = document.getElementById('select-stride').value;
|
|
|
|
fetch(`/api/save_zones?palet=${paletStr}&truck=${truckStr}&radius=${radius}&min_valid_area=${minArea}&jarak_toleransi_duplikat=${dupDist}&max_reid_transit_distance=${reidDist}&circle_stay_timeout_sec=${timeout}&inference_stride=${stride}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
alert("Konfigurasi sukses disimpan!");
|
|
cancelEditingZones();
|
|
} else {
|
|
alert("Error: " + data.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
function saveCalibrationOnly() {
|
|
const paletStr = zonePoints.palet.map(pt => `${pt[0]},${pt[1]}`).join(';');
|
|
const truckStr = zonePoints.truck.map(pt => `${pt[0]},${pt[1]}`).join(';');
|
|
const radius = document.getElementById('radius-slider').value;
|
|
|
|
const minArea = document.getElementById('input-min-area').value;
|
|
const dupDist = document.getElementById('input-dup-dist').value;
|
|
const reidDist = document.getElementById('input-reid-dist').value;
|
|
const timeout = document.getElementById('input-timeout').value;
|
|
const stride = document.getElementById('select-stride').value;
|
|
|
|
fetch(`/api/save_zones?palet=${paletStr}&truck=${truckStr}&radius=${radius}&min_valid_area=${minArea}&jarak_toleransi_duplikat=${dupDist}&max_reid_transit_distance=${reidDist}&circle_stay_timeout_sec=${timeout}&inference_stride=${stride}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
alert("Parameter kalibrasi berhasil diperbarui!");
|
|
} else {
|
|
alert("Error: " + data.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
function initSSE() {
|
|
sseSource = new EventSource('/api/events');
|
|
|
|
sseSource.addEventListener('bbox_data', function(e) {
|
|
const data = JSON.parse(e.data);
|
|
currentBBoxes = data.bboxes;
|
|
circleRadius = data.circle_radius;
|
|
drawZones();
|
|
});
|
|
|
|
sseSource.addEventListener('update_stats', function(e) {
|
|
const data = JSON.parse(e.data);
|
|
document.getElementById('val-masuk').innerText = data.total_masuk;
|
|
document.getElementById('val-keluar').innerText = data.total_keluar;
|
|
document.getElementById('val-net').innerText = data.net;
|
|
document.getElementById('val-fps').innerText = data.fps.toFixed(2);
|
|
});
|
|
|
|
sseSource.addEventListener('log_event', function(e) {
|
|
const data = JSON.parse(e.data);
|
|
const logList = document.getElementById('log-list');
|
|
|
|
if (logList.innerHTML.includes('Menunggu aktivitas deteksi...')) {
|
|
logList.innerHTML = '';
|
|
}
|
|
|
|
const item = document.createElement('div');
|
|
item.className = 'log-item ' + data.type;
|
|
item.innerHTML = `<span class="log-time">[${data.timestamp}]</span> <span class="log-msg">${data.message}</span>`;
|
|
|
|
logList.insertBefore(item, logList.firstChild);
|
|
});
|
|
|
|
sseSource.onerror = function() {
|
|
console.log("SSE Connection closed, retrying...");
|
|
};
|
|
}
|
|
|
|
function toggleStream(isActive) {
|
|
const container = document.getElementById('video-container');
|
|
const placeholder = document.getElementById('video-placeholder');
|
|
|
|
fetch(`/api/toggle_stream?active=${isActive ? 1 : 0}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (isActive) {
|
|
placeholder.style.display = 'none';
|
|
let img = document.getElementById('stream-img');
|
|
if (!img) {
|
|
img = document.createElement('img');
|
|
img.id = 'stream-img';
|
|
img.className = 'video-img';
|
|
img.src = '/stream.mjpg';
|
|
container.appendChild(img);
|
|
} else {
|
|
img.style.display = 'block';
|
|
img.src = '/stream.mjpg';
|
|
}
|
|
setTimeout(resizeCanvas, 300);
|
|
} else {
|
|
placeholder.style.display = 'flex';
|
|
const img = document.getElementById('stream-img');
|
|
if (img) {
|
|
img.style.display = 'none';
|
|
img.src = '';
|
|
}
|
|
currentBBoxes = [];
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
}
|
|
});
|
|
}
|
|
|
|
initSSE();
|
|
loadCalibration();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
class SSEClient:
|
|
def __init__(self, handler):
|
|
self.handler = handler
|
|
self.active = True
|
|
|
|
def send(self, event, data):
|
|
try:
|
|
msg = f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
|
self.handler.wfile.write(msg.encode('utf-8'))
|
|
self.handler.wfile.flush()
|
|
except Exception:
|
|
self.active = False
|
|
|
|
def trigger_event(event_type, data):
|
|
with sse_lock:
|
|
for client in sse_clients:
|
|
client.send(event_type, data)
|
|
|
|
class StreamingHandler(BaseHTTPRequestHandler):
|
|
def log_message(self, format, *args):
|
|
# Mute normal HTTP logs to keep console clean for FPS print logs
|
|
pass
|
|
|
|
def do_GET(self):
|
|
global streaming_frame, live_stream_enabled, sse_clients, current_fps, SHOW_ALL_BBOXES
|
|
global ZONA_PALET_REF, ZONA_TRUCK_REF, GARIS_COUNTING_REF
|
|
global ZONA_PALET, ZONA_TRUCK, GARIS_COUNTING, poly_palet, poly_truck, line_counting
|
|
global width, height, DUPLICATE_CIRCLE_RADIUS_REF, DUPLICATE_CIRCLE_RADIUS
|
|
global MIN_VALID_AREA, JARAK_TOLERANSI_DUPLIKAT, MAX_REID_TRANSIT_DISTANCE, CIRCLE_STAY_TIMEOUT_SEC, INFERENCE_STRIDE
|
|
global MIN_VALID_AREA_REF, JARAK_TOLERANSI_DUPLIKAT_REF, MAX_REID_TRANSIT_DISTANCE_REF, SHOW_ALL_BBOXES
|
|
global ZONA_PALET_REF, ZONA_TRUCK_REF, GARIS_COUNTING_REF
|
|
global ZONA_PALET, ZONA_TRUCK, GARIS_COUNTING, poly_palet, poly_truck, line_counting
|
|
global width, height, DUPLICATE_CIRCLE_RADIUS_REF, DUPLICATE_CIRCLE_RADIUS
|
|
global MIN_VALID_AREA, JARAK_TOLERANSI_DUPLIKAT, MAX_REID_TRANSIT_DISTANCE, CIRCLE_STAY_TIMEOUT_SEC, INFERENCE_STRIDE
|
|
global MIN_VALID_AREA, JARAK_TOLERANSI_DUPLIKAT, MAX_REID_TRANSIT_DISTANCE, CIRCLE_STAY_TIMEOUT_SEC, INFERENCE_STRIDE
|
|
|
|
if self.path == '/':
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
|
self.end_headers()
|
|
self.wfile.write(DASHBOARD_HTML.encode('utf-8'))
|
|
|
|
elif self.path.startswith('/api/toggle_stream'):
|
|
from urllib.parse import urlparse, parse_qs
|
|
query = parse_qs(urlparse(self.path).query)
|
|
active = query.get('active', ['0'])[0]
|
|
live_stream_enabled = (active == '1')
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps({"status": "ok", "live_stream_enabled": live_stream_enabled}).encode())
|
|
|
|
elif self.path == '/api/events':
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'text/event-stream')
|
|
self.send_header('Cache-Control', 'no-cache')
|
|
self.send_header('Connection', 'keep-alive')
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.end_headers()
|
|
|
|
client = SSEClient(self)
|
|
with sse_lock:
|
|
sse_clients.append(client)
|
|
|
|
try:
|
|
initial_stats = {
|
|
"total_masuk": metrics['total_masuk'],
|
|
"total_keluar": metrics['total_keluar'],
|
|
"net": metrics['total_masuk'] - metrics['total_keluar'],
|
|
"fps": current_fps
|
|
}
|
|
client.send("update_stats", initial_stats)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
while client.active:
|
|
time.sleep(5)
|
|
try:
|
|
self.wfile.write(b": keep-alive\n\n")
|
|
self.wfile.flush()
|
|
except Exception:
|
|
client.active = False
|
|
break
|
|
finally:
|
|
with sse_lock:
|
|
if client in sse_clients:
|
|
sse_clients.remove(client)
|
|
elif self.path == '/api/get_zones':
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.end_headers()
|
|
zones = {
|
|
"palet": [[p[0]/1920.0, p[1]/1080.0] for p in ZONA_PALET_REF],
|
|
"truck": [[p[0]/1920.0, p[1]/1080.0] for p in ZONA_TRUCK_REF],
|
|
"duplicate_circle_radius": DUPLICATE_CIRCLE_RADIUS_REF,
|
|
"min_valid_area": MIN_VALID_AREA_REF,
|
|
"jarak_toleransi_duplikat": JARAK_TOLERANSI_DUPLIKAT_REF,
|
|
"max_reid_transit_distance": MAX_REID_TRANSIT_DISTANCE_REF,
|
|
"circle_stay_timeout_sec": CIRCLE_STAY_TIMEOUT_SEC,
|
|
"inference_stride": INFERENCE_STRIDE,
|
|
"show_all_bboxes": SHOW_ALL_BBOXES
|
|
}
|
|
self.wfile.write(json.dumps(zones).encode())
|
|
|
|
elif self.path.startswith('/api/save_zones'):
|
|
from urllib.parse import urlparse, parse_qs
|
|
query = parse_qs(urlparse(self.path).query)
|
|
try:
|
|
palet_str = query.get('palet', [''])[0]
|
|
truck_str = query.get('truck', [''])[0]
|
|
radius_str = query.get('radius', ['60'])[0]
|
|
min_valid_area_str = query.get('min_valid_area', ['15000'])[0]
|
|
jarak_toleransi_duplikat_str = query.get('jarak_toleransi_duplikat', ['20'])[0]
|
|
max_reid_transit_distance_str = query.get('max_reid_transit_distance', ['400'])[0]
|
|
circle_stay_timeout_sec_str = query.get('circle_stay_timeout_sec', ['10.0'])[0]
|
|
inference_stride_str = query.get('inference_stride', ['2'])[0]
|
|
|
|
new_palet = []
|
|
for pt in palet_str.split(';'):
|
|
if pt:
|
|
x, y = map(float, pt.split(','))
|
|
new_palet.append([int(x * 1920), int(y * 1080)])
|
|
|
|
new_truck = []
|
|
for pt in truck_str.split(';'):
|
|
if pt:
|
|
x, y = map(float, pt.split(','))
|
|
new_truck.append([int(x * 1920), int(y * 1080)])
|
|
|
|
try:
|
|
new_radius = int(radius_str)
|
|
except ValueError:
|
|
new_radius = 60
|
|
|
|
try:
|
|
new_min_valid_area = int(min_valid_area_str)
|
|
new_jarak_toleransi_duplikat = int(jarak_toleransi_duplikat_str)
|
|
new_max_reid_transit_distance = int(max_reid_transit_distance_str)
|
|
new_circle_stay_timeout_sec = float(circle_stay_timeout_sec_str)
|
|
new_inference_stride = int(inference_stride_str)
|
|
except ValueError:
|
|
new_min_valid_area = MIN_VALID_AREA_REF
|
|
new_jarak_toleransi_duplikat = JARAK_TOLERANSI_DUPLIKAT_REF
|
|
new_max_reid_transit_distance = MAX_REID_TRANSIT_DISTANCE_REF
|
|
new_circle_stay_timeout_sec = CIRCLE_STAY_TIMEOUT_SEC
|
|
new_inference_stride = INFERENCE_STRIDE
|
|
|
|
if len(new_palet) == 4 and len(new_truck) == 4:
|
|
zones_data = {
|
|
"palet": new_palet,
|
|
"truck": new_truck,
|
|
"duplicate_circle_radius": new_radius,
|
|
"min_valid_area": new_min_valid_area,
|
|
"jarak_toleransi_duplikat": new_jarak_toleransi_duplikat,
|
|
"max_reid_transit_distance": new_max_reid_transit_distance,
|
|
"circle_stay_timeout_sec": new_circle_stay_timeout_sec,
|
|
"inference_stride": new_inference_stride
|
|
}
|
|
with open(ZONES_JSON_PATH, 'w') as f:
|
|
json.dump(zones_data, f, indent=4)
|
|
|
|
ZONA_PALET_REF = np.array(new_palet, dtype=np.int32)
|
|
ZONA_TRUCK_REF = np.array(new_truck, dtype=np.int32)
|
|
GARIS_COUNTING_REF = ZONA_TRUCK_REF.copy()
|
|
DUPLICATE_CIRCLE_RADIUS_REF = new_radius
|
|
MIN_VALID_AREA_REF = new_min_valid_area
|
|
JARAK_TOLERANSI_DUPLIKAT_REF = new_jarak_toleransi_duplikat
|
|
MAX_REID_TRANSIT_DISTANCE_REF = new_max_reid_transit_distance
|
|
CIRCLE_STAY_TIMEOUT_SEC = new_circle_stay_timeout_sec
|
|
INFERENCE_STRIDE = new_inference_stride
|
|
|
|
scale_x = width / 1920.0
|
|
scale_y = height / 1080.0
|
|
|
|
ZONA_PALET = np.array([[int(p[0] * scale_x), int(p[1] * scale_y)] for p in ZONA_PALET_REF], dtype=np.int32)
|
|
ZONA_TRUCK = np.array([[int(p[0] * scale_x), int(p[1] * scale_y)] for p in ZONA_TRUCK_REF], dtype=np.int32)
|
|
GARIS_COUNTING = np.array([[int(p[0] * scale_x), int(p[1] * scale_y)] for p in GARIS_COUNTING_REF], dtype=np.int32)
|
|
DUPLICATE_CIRCLE_RADIUS = int(DUPLICATE_CIRCLE_RADIUS_REF * scale_x)
|
|
MIN_VALID_AREA = int(MIN_VALID_AREA_REF * scale_x * scale_y)
|
|
JARAK_TOLERANSI_DUPLIKAT = int(JARAK_TOLERANSI_DUPLIKAT_REF * scale_x)
|
|
MAX_REID_TRANSIT_DISTANCE = int(MAX_REID_TRANSIT_DISTANCE_REF * scale_x)
|
|
|
|
poly_palet = Polygon(ZONA_PALET) if len(ZONA_PALET) >= 3 else None
|
|
poly_truck = Polygon(ZONA_TRUCK) if len(ZONA_TRUCK) >= 3 else None
|
|
line_counting = poly_truck.boundary if poly_truck is not None else None
|
|
|
|
print("[INFO] Berhasil memperbarui konfigurasi zona dan kalibrasi via API Web")
|
|
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps({"status": "success", "message": "Konfigurasi berhasil disimpan"}).encode())
|
|
else:
|
|
self.send_response(400)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps({"status": "error", "message": "Jumlah titik klik harus masing-masing 4 sudut"}).encode())
|
|
except Exception as e:
|
|
self.send_response(500)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps({"status": "error", "message": str(e)}).encode())
|
|
|
|
elif self.path == '/stream.mjpg':
|
|
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 live_stream_enabled:
|
|
with streaming_lock:
|
|
frame_to_stream = streaming_frame
|
|
|
|
if frame_to_stream is None:
|
|
time.sleep(0.05)
|
|
continue
|
|
|
|
ret, jpeg = cv2.imencode('.jpg', frame_to_stream)
|
|
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 as e:
|
|
pass
|
|
|
|
# (Fitur download video segmen dinonaktifkan untuk menghemat daya komputasi Jetson)
|
|
else:
|
|
self.send_error(404, "Path not found")
|
|
|
|
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
|
|
allow_reuse_address = True
|
|
|
|
def start_streaming_server(port=8000):
|
|
server = ThreadedHTTPServer(('0.0.0.0', port), StreamingHandler)
|
|
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
server_thread.start()
|
|
print(f"\n[INFO] Live view is streaming at http://localhost:{port}/\n")
|
|
|
|
class RTSPStreamReader:
|
|
def __init__(self, source_path):
|
|
self.source_path = source_path
|
|
self.cap = cv2.VideoCapture(source_path)
|
|
self.frame = None
|
|
self.ret = False
|
|
self.new_frame_event = threading.Event()
|
|
self.running = True
|
|
self.lock = threading.Lock()
|
|
self.thread = threading.Thread(target=self._update, daemon=True)
|
|
self.thread.start()
|
|
|
|
def _update(self):
|
|
while self.running:
|
|
if not self.cap.isOpened():
|
|
time.sleep(0.1)
|
|
continue
|
|
ret, frame = self.cap.read()
|
|
if not ret:
|
|
time.sleep(0.01)
|
|
continue
|
|
with self.lock:
|
|
self.ret = ret
|
|
self.frame = frame
|
|
self.new_frame_event.set()
|
|
time.sleep(0.001)
|
|
|
|
def read(self):
|
|
if self.new_frame_event.wait(timeout=1.0):
|
|
self.new_frame_event.clear()
|
|
with self.lock:
|
|
if self.frame is None:
|
|
return False, None
|
|
return self.ret, self.frame.copy()
|
|
else:
|
|
with self.lock:
|
|
if self.frame is None:
|
|
return False, None
|
|
return self.ret, self.frame.copy()
|
|
|
|
def isOpened(self):
|
|
return self.cap.isOpened()
|
|
|
|
def get(self, propId):
|
|
return self.cap.get(propId)
|
|
|
|
def release(self):
|
|
self.running = False
|
|
if self.cap.isOpened():
|
|
self.cap.release()
|
|
|
|
# (VideoSaver class dan worker dihapus untuk menghemat daya komputasi Jetson secara penuh)
|
|
|
|
def get_zone_name(point):
|
|
pt = Point(point)
|
|
if poly_palet is not None and not poly_palet.is_empty and poly_palet.contains(pt):
|
|
return "PALET"
|
|
elif poly_truck is not None and not poly_truck.is_empty and poly_truck.contains(pt):
|
|
return "TRUCK"
|
|
else:
|
|
return "BEBAS"
|
|
|
|
|
|
def update_zone_label(track_id, current_zone):
|
|
"""Update label visual zona (dengan debounce ringan), TIDAK memengaruhi logika counting."""
|
|
track_zone_history[track_id].append(current_zone)
|
|
history = list(track_zone_history[track_id])
|
|
if len(history) < DEBOUNCE_FRAMES:
|
|
track_confirmed_state[track_id] = current_zone
|
|
return
|
|
most_frequent_zone = max(set(history), key=history.count)
|
|
if history.count(most_frequent_zone) >= (DEBOUNCE_FRAMES - 2):
|
|
track_confirmed_state[track_id] = most_frequent_zone
|
|
|
|
|
|
# =====================================================================
|
|
# 3. RE-ID: PEMULIHAN ID SETELAH OKLUSI
|
|
# =====================================================================
|
|
def check_reid_recovery(new_id, current_centroid, overlap_ratio_now, frame_idx):
|
|
global lost_tracks, counted_at_frame
|
|
if not lost_tracks:
|
|
return False
|
|
|
|
closest_old_id = None
|
|
min_dist = float('inf')
|
|
|
|
pt = Point(current_centroid)
|
|
in_truck_zone = poly_truck is not None and not poly_truck.is_empty and poly_truck.contains(pt)
|
|
|
|
for old_id, info in lost_tracks.items():
|
|
frame_diff = frame_idx - info['frame_idx']
|
|
if frame_diff > MAX_REID_FRAMES:
|
|
continue
|
|
# JIKA track lama sudah terhitung, track baru tidak boleh berada di area palet untuk memulihkannya
|
|
if info['already_counted'] and poly_palet is not None and not poly_palet.is_empty and poly_palet.contains(pt):
|
|
continue
|
|
lc = info['last_centroid']
|
|
dist = np.sqrt((current_centroid[0] - lc[0]) ** 2 + (current_centroid[1] - lc[1]) ** 2)
|
|
|
|
# Arah pergerakan konsisten menuju truk (Y berkurang/tetap) atau berada di area truk
|
|
is_consistent_direction = (current_centroid[1] < lc[1] + (50 * scale_y))
|
|
|
|
if (dist < MAX_REID_TRANSIT_DISTANCE) and (is_consistent_direction or in_truck_zone):
|
|
if dist < min_dist:
|
|
min_dist = dist
|
|
closest_old_id = old_id
|
|
|
|
if closest_old_id is None:
|
|
return False
|
|
|
|
info = lost_tracks[closest_old_id]
|
|
|
|
# JIKA sudah terhitung (already_counted), langsung pulihkan ID tersebut agar tidak terhitung lagi
|
|
if info['already_counted']:
|
|
already_counted[new_id] = True
|
|
is_locked[new_id] = True
|
|
pending_enter_since[new_id] = None
|
|
pending_exit_since[new_id] = info['pending_exit_since']
|
|
track_zone_history[new_id] = info['zone_history'].copy()
|
|
track_positions[new_id] = info['positions'].copy()
|
|
has_crossed_line[new_id] = info.get('has_crossed_line', True)
|
|
exit_crossed_line[new_id] = info.get('exit_crossed_line', False)
|
|
track_areas[new_id] = info.get('box_area', 0.0)
|
|
track_started_in_truck[new_id] = info.get('started_in_truck', False)
|
|
counted_at_frame[new_id] = info.get('counted_at_frame')
|
|
|
|
# Pulihkan state lingkaran
|
|
track_initial_truck_pos[new_id] = info.get('initial_truck_pos')
|
|
track_truck_entry_frame[new_id] = info.get('truck_entry_frame')
|
|
has_exited_circle[new_id] = info.get('has_exited_circle', False)
|
|
delay_completed[new_id] = info.get('delay_completed', False)
|
|
|
|
del lost_tracks[closest_old_id]
|
|
return True
|
|
|
|
# Logika lama untuk yang belum terhitung (pending masuk dll)
|
|
was_counted_or_pending = info['already_counted'] or (info['pending_enter_since'] is not None)
|
|
if was_counted_or_pending and overlap_ratio_now < EXIT_OVERLAP_THRESHOLD:
|
|
if info['already_counted']:
|
|
del lost_tracks[closest_old_id]
|
|
return False
|
|
|
|
already_counted[new_id] = info['already_counted']
|
|
is_locked[new_id] = info['is_locked']
|
|
pending_enter_since[new_id] = info['pending_enter_since']
|
|
pending_exit_since[new_id] = info['pending_exit_since']
|
|
track_zone_history[new_id] = info['zone_history'].copy()
|
|
track_positions[new_id] = info['positions'].copy()
|
|
has_crossed_line[new_id] = info.get('has_crossed_line', False)
|
|
exit_crossed_line[new_id] = info.get('exit_crossed_line', False)
|
|
track_areas[new_id] = info.get('box_area', 0.0)
|
|
track_started_in_truck[new_id] = info.get('started_in_truck', False)
|
|
counted_at_frame[new_id] = info.get('counted_at_frame')
|
|
|
|
# Pulihkan state lingkaran
|
|
track_initial_truck_pos[new_id] = info.get('initial_truck_pos')
|
|
track_truck_entry_frame[new_id] = info.get('truck_entry_frame')
|
|
has_exited_circle[new_id] = info.get('has_exited_circle', False)
|
|
delay_completed[new_id] = info.get('delay_completed', False)
|
|
|
|
del lost_tracks[closest_old_id]
|
|
return True
|
|
|
|
|
|
# =====================================================================
|
|
# 3.5 FUNGSI ANTI-DOUBLE COUNT (SPASIAL DUPLIKASI) # --- TAMBAHAN BARU ---
|
|
# =====================================================================
|
|
def cek_duplikat_karung_locked(new_id, cx, cy, box_area, frame_idx):
|
|
"""
|
|
Mengecek apakah bbox baru muncul di titik yang sangat dekat dengan
|
|
karung yang SUDAH DIHITUNG (locked), baik yang sedang aktif maupun yang baru hilang.
|
|
"""
|
|
pt = Point(cx, cy)
|
|
# Karung di palet tidak boleh dideteksi duplikat
|
|
if poly_palet is not None and not poly_palet.is_empty and poly_palet.contains(pt):
|
|
return False
|
|
|
|
# Hanya lakukan duplicate checking jika centroid baru berada di area truk/counting
|
|
is_in_truck = poly_truck is not None and not poly_truck.is_empty and poly_truck.contains(pt)
|
|
if not is_in_truck:
|
|
return False
|
|
|
|
# 1. Cek dari track yang SEDANG AKTIF dan SUDAH COUNTED
|
|
for active_id in prev_active_track_ids:
|
|
if active_id != new_id and already_counted.get(active_id, False):
|
|
if active_id in track_positions and len(track_positions[active_id]) > 0:
|
|
last_cx, last_cy = track_positions[active_id][-1]
|
|
dist = np.sqrt((cx - last_cx)**2 + (cy - last_cy)**2)
|
|
|
|
# Cek perbandingan luas area box
|
|
old_area = track_areas.get(active_id, 0)
|
|
if old_area > 0 and box_area > 0:
|
|
area_ratio = min(box_area, old_area) / max(box_area, old_area)
|
|
else:
|
|
area_ratio = 1.0
|
|
|
|
is_similar_size = area_ratio >= 0.65
|
|
if is_similar_size:
|
|
if dist < JARAK_TOLERANSI_DUPLIKAT:
|
|
return True
|
|
|
|
# 2. Cek dari track yang SUDAH HILANG (lost_tracks)
|
|
for lost_id, info in lost_tracks.items():
|
|
if info.get('already_counted', False):
|
|
frame_diff = frame_idx - info['frame_idx']
|
|
if frame_diff <= TOLERANSI_FRAME_HILANG:
|
|
last_cx, last_cy = info['last_centroid']
|
|
dist = np.sqrt((cx - last_cx)**2 + (cy - last_cy)**2)
|
|
|
|
# Cek perbandingan luas area box
|
|
old_area = info.get('box_area', 0)
|
|
if old_area > 0 and box_area > 0:
|
|
area_ratio = min(box_area, old_area) / max(box_area, old_area)
|
|
else:
|
|
area_ratio = 1.0
|
|
|
|
is_similar_size = area_ratio >= 0.65
|
|
if is_similar_size:
|
|
if dist < JARAK_TOLERANSI_DUPLIKAT:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
# =====================================================================
|
|
# 4. LOGIKA MASUK / KELUAR
|
|
# =====================================================================
|
|
def update_counting(track_id, overlap_ratio_counting, in_counting_zone, overlap_ratio_truck, frame_idx, required_frames, current_zone, required_exit_frames=None):
|
|
global has_crossed_line, exit_crossed_line, already_counted, pending_enter_since, pending_exit_since, is_locked, metrics, counted_at_frame
|
|
global has_exited_circle, delay_completed, track_started_in_truck
|
|
|
|
if required_exit_frames is None:
|
|
required_exit_frames = required_frames
|
|
|
|
if not already_counted[track_id]:
|
|
# Logika Masuk Baru Berdasarkan Zona:
|
|
# - Zona COUNTING: Centroid di area counting, overlap counting >= 70%
|
|
# - Zona TRUCK: Centroid di area truck, overlap truck >= 70%
|
|
if current_zone == "TRUCK":
|
|
# Jika mulai di dalam truk, kita ijinkan delay berjalan meskipun belum cross line
|
|
# agar saat keluar lingkaran bisa langsung dihitung jika delay sudah selesai.
|
|
is_qualifying_entry = (has_crossed_line[track_id] or track_started_in_truck[track_id]) and (overlap_ratio_truck >= ENTRY_OVERLAP_THRESHOLD)
|
|
else:
|
|
is_qualifying_entry = False
|
|
|
|
if is_qualifying_entry:
|
|
if pending_enter_since[track_id] is None:
|
|
pending_enter_since[track_id] = frame_idx
|
|
else:
|
|
elapsed = frame_idx - pending_enter_since[track_id]
|
|
if elapsed >= required_frames:
|
|
# JIKA masih di dalam lingkaran, jangan dulu counting, "simpan dulu"
|
|
if not has_exited_circle[track_id]:
|
|
delay_completed[track_id] = True
|
|
else:
|
|
metrics['total_masuk'] += 1
|
|
already_counted[track_id] = True
|
|
is_locked[track_id] = True
|
|
pending_enter_since[track_id] = None
|
|
counted_at_frame[track_id] = frame_idx
|
|
else:
|
|
# Jika tidak memenuhi kualifikasi masuk, reset pending timer
|
|
pending_enter_since[track_id] = None
|
|
else:
|
|
pass
|
|
|
|
|
|
# =====================================================================
|
|
# 5. PROSES PREDIKSI & VISUALISASI VIDEO
|
|
# =====================================================================
|
|
def run_prediction(model_path, source_path,
|
|
output_json_path="hasil_perhitungan.json", max_frames=None,
|
|
inference_stride=2):
|
|
global prev_active_track_ids, lost_tracks, metrics, track_positions, counted_at_frame
|
|
global track_confirmed_state, already_counted, is_locked, has_crossed_line, exit_crossed_line, track_areas
|
|
global pending_enter_since, pending_exit_since, track_started_in_truck, outside_truck_frames
|
|
global track_initial_truck_pos, track_truck_entry_frame, has_exited_circle, delay_completed
|
|
global streaming_frame, current_fps, live_stream_enabled, INFERENCE_STRIDE
|
|
global width, height
|
|
|
|
INFERENCE_STRIDE = inference_stride
|
|
|
|
if not os.path.exists(model_path):
|
|
print(f"Error: Model tidak ditemukan di {model_path}")
|
|
return
|
|
|
|
is_stream = any(str(source_path).startswith(p) for p in ["rtsp://", "rtmp://", "http://", "https://"])
|
|
if not is_stream and not os.path.exists(source_path):
|
|
print(f"Error: Source tidak ditemukan di {source_path}")
|
|
return
|
|
|
|
prev_active_track_ids = set()
|
|
lost_tracks = {}
|
|
track_positions.clear()
|
|
track_confirmed_state.clear()
|
|
already_counted.clear()
|
|
is_locked.clear()
|
|
pending_enter_since.clear()
|
|
counted_at_frame.clear()
|
|
pending_exit_since.clear()
|
|
has_crossed_line.clear()
|
|
exit_crossed_line.clear()
|
|
track_areas.clear()
|
|
track_started_in_truck.clear()
|
|
outside_truck_frames.clear()
|
|
track_initial_truck_pos.clear()
|
|
track_truck_entry_frame.clear()
|
|
has_exited_circle.clear()
|
|
delay_completed.clear()
|
|
for k in metrics:
|
|
metrics[k] = 0
|
|
|
|
print("=" * 50)
|
|
print("MEMULAI PREDIKSI FOKUS PENGHITUNGAN (MASUK & KELUAR TRUCK)...")
|
|
print("Logika: Anti Double-Count via Spatial Proximity Aktif")
|
|
print("=" * 50)
|
|
|
|
model = YOLO(model_path)
|
|
|
|
# Pilih reader berdasarkan jenis source (threaded untuk RTSP, direct untuk local file)
|
|
if is_stream:
|
|
print("[INFO] Membuka RTSP stream menggunakan Threaded Bufferless Reader...")
|
|
cap = RTSPStreamReader(source_path)
|
|
else:
|
|
print("[INFO] Membuka file video lokal...")
|
|
cap = cv2.VideoCapture(source_path)
|
|
|
|
if not cap.isOpened():
|
|
print(f"Error: Gagal membuka video source (RTSP stream/file) di {source_path}")
|
|
return
|
|
|
|
# Jalankan server dashboard web di port 8000
|
|
start_streaming_server(port=8000)
|
|
|
|
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
|
|
# Auto-scale koordinat zona jika resolusi video berbeda dari referensi 1920x1080
|
|
global ZONA_PALET, ZONA_TRUCK, GARIS_COUNTING, poly_palet, poly_truck, line_counting, scale_x, scale_y
|
|
ref_w, ref_h = 1920, 1080
|
|
scale_x = width / ref_w
|
|
scale_y = height / ref_h
|
|
|
|
# Selalu kalkulasi ulang dari koordinat referensi asli untuk menghindari akumulasi scaling
|
|
ZONA_PALET = np.array([[int(p[0] * scale_x), int(p[1] * scale_y)] for p in ZONA_PALET_REF], dtype=np.int32) if len(ZONA_PALET_REF) >= 3 else np.zeros((0, 2), dtype=np.int32)
|
|
ZONA_TRUCK = np.array([[int(p[0] * scale_x), int(p[1] * scale_y)] for p in ZONA_TRUCK_REF], dtype=np.int32) if len(ZONA_TRUCK_REF) >= 3 else np.zeros((0, 2), dtype=np.int32)
|
|
GARIS_COUNTING = np.array([[int(p[0] * scale_x), int(p[1] * scale_y)] for p in GARIS_COUNTING_REF], dtype=np.int32) if len(GARIS_COUNTING_REF) >= 3 else np.zeros((0, 2), dtype=np.int32)
|
|
|
|
poly_palet = Polygon(ZONA_PALET) if len(ZONA_PALET) >= 3 else None
|
|
poly_truck = Polygon(ZONA_TRUCK) if len(ZONA_TRUCK) >= 3 else None
|
|
line_counting = poly_truck.boundary if poly_truck is not None else None # Seluruh 4 sisi area truk berfungsi sebagai garis counting
|
|
|
|
global DUPLICATE_CIRCLE_RADIUS, MIN_VALID_AREA, JARAK_TOLERANSI_DUPLIKAT, MAX_REID_TRANSIT_DISTANCE, JARAK_ABSORBSI_GHOST
|
|
DUPLICATE_CIRCLE_RADIUS = int(DUPLICATE_CIRCLE_RADIUS_REF * scale_x)
|
|
MIN_VALID_AREA = int(MIN_VALID_AREA_REF * scale_x * scale_y)
|
|
JARAK_TOLERANSI_DUPLIKAT = int(JARAK_TOLERANSI_DUPLIKAT_REF * scale_x)
|
|
MAX_REID_TRANSIT_DISTANCE = int(MAX_REID_TRANSIT_DISTANCE_REF * scale_x)
|
|
JARAK_ABSORBSI_GHOST = int(50 * scale_x)
|
|
|
|
if scale_x != 1.0 or scale_y != 1.0:
|
|
print(f"[INFO] Auto-scaling koordinat zona dari {ref_w}x{ref_h} ke {width}x{height} (Scale X: {scale_x:.2f}, Y: {scale_y:.2f})")
|
|
|
|
fps = cap.get(cv2.CAP_PROP_FPS)
|
|
if fps <= 0 or np.isnan(fps):
|
|
fps = 25.0
|
|
|
|
required_frames = max(1, int(CONFIRM_DELAY_SEC * fps))
|
|
required_exit_frames = max(1, int(EXIT_CONFIRM_DELAY_SEC * fps))
|
|
|
|
# Penyimpanan video dinonaktifkan secara penuh untuk menghemat CPU
|
|
saver = None
|
|
saver_thread = None
|
|
|
|
frame_idx = 0
|
|
net_count = 0
|
|
last_time = time.time()
|
|
|
|
while cap.isOpened():
|
|
ret, frame = cap.read()
|
|
if not ret:
|
|
# Jika menggunakan RTSP stream, tunggu sebentar dan coba lagi (toleransi dropout ringan)
|
|
if is_stream:
|
|
time.sleep(0.01)
|
|
continue
|
|
else:
|
|
break
|
|
|
|
frame_idx += 1
|
|
|
|
|
|
if max_frames is not None and frame_idx > max_frames:
|
|
break
|
|
|
|
# Simpan state status deteksi sebelum frame ini diproses
|
|
previously_counted = {k for k, v in already_counted.items() if v}
|
|
bbox_list = []
|
|
|
|
# Terapkan Frame Skipping (Inference Stride) untuk meningkatkan FPS di Jetson
|
|
if INFERENCE_STRIDE <= 1 or frame_idx % INFERENCE_STRIDE == 0 or 'last_results' not in locals():
|
|
results = model.track(frame, persist=True, tracker="bytetrack.yaml", conf=0.05, classes=[1], verbose=False)
|
|
last_results = results
|
|
else:
|
|
results = last_results
|
|
|
|
# OpenCV visual drawings on frames removed to eliminate Jetson CPU/GPU encoding overhead
|
|
pass
|
|
|
|
current_active_track_ids = set()
|
|
if results[0].boxes.id is not None:
|
|
boxes = results[0].boxes.xyxy.cpu().numpy()
|
|
track_ids = results[0].boxes.id.int().cpu().numpy()
|
|
|
|
for box_coord, track_id in zip(boxes, track_ids):
|
|
x1, y1, x2, y2 = box_coord
|
|
cx = int((x1 + x2) / 2)
|
|
cy = int((y1 + y2) / 2)
|
|
|
|
# --- CLEAR STATE JIKA DI LUAR TRUCK ---
|
|
current_zone = get_zone_name((cx, cy))
|
|
if current_zone != "TRUCK":
|
|
track_initial_truck_pos.pop(track_id, None)
|
|
track_truck_entry_frame.pop(track_id, None)
|
|
has_exited_circle[track_id] = False
|
|
delay_completed[track_id] = False
|
|
|
|
# --- FILTER AREA BEBAS (Abaikan sepenuhnya) ---
|
|
if current_zone == "BEBAS":
|
|
continue
|
|
|
|
# --- FILTER FRAGMENT ---
|
|
box_area = (x2 - x1) * (y2 - y1)
|
|
is_fragment = box_area < MIN_VALID_AREA
|
|
is_ghost = False
|
|
ghost_parent_id = None
|
|
|
|
if is_fragment:
|
|
for l_id, locked in is_locked.items():
|
|
if locked and l_id in track_positions and len(track_positions[l_id]) > 0:
|
|
lx, ly = track_positions[l_id][-1]
|
|
if np.sqrt((cx - lx) ** 2 + (cy - ly) ** 2) < JARAK_ABSORBSI_GHOST:
|
|
is_ghost = True
|
|
ghost_parent_id = l_id
|
|
break
|
|
|
|
current_active_track_ids.add(track_id)
|
|
track_positions[track_id].append((cx, cy))
|
|
track_areas[track_id] = box_area
|
|
|
|
if is_fragment or is_ghost:
|
|
show_frag = True
|
|
if is_ghost and ghost_parent_id is not None:
|
|
if already_counted[ghost_parent_id] and counted_at_frame.get(ghost_parent_id) is not None:
|
|
if frame_idx - counted_at_frame[ghost_parent_id] > COUNTED_DISPLAY_TIMEOUT_SEC * fps:
|
|
show_frag = False
|
|
if show_frag:
|
|
bbox_list.append({
|
|
"id": int(track_id),
|
|
"bbox": [float(x1)/width, float(y1)/height, float(x2)/width, float(y2)/height],
|
|
"centroid": [float(cx)/width, float(cy)/height],
|
|
"status": "FRAGMENT",
|
|
"color": "rgba(150, 150, 150, 1.0)",
|
|
"trail": []
|
|
})
|
|
continue
|
|
|
|
# --- Hitung overlap SEBELUM re-id ---
|
|
bbox_poly = box(x1, y1, x2, y2)
|
|
|
|
# Deteksi persilangan garis oleh lintasan titik ciri (trajectory)
|
|
if line_counting is not None:
|
|
if len(track_positions[track_id]) >= 2:
|
|
traj = LineString(track_positions[track_id])
|
|
if traj.intersects(line_counting):
|
|
has_crossed_line[track_id] = True
|
|
if already_counted[track_id]:
|
|
exit_crossed_line[track_id] = True
|
|
else:
|
|
# Jika track baru muncul langsung menempel sangat dekat dengan garis
|
|
centroid_point = Point(cx, cy)
|
|
if centroid_point.distance(line_counting) < (5 * scale_x):
|
|
has_crossed_line[track_id] = True
|
|
if already_counted[track_id]:
|
|
exit_crossed_line[track_id] = True
|
|
|
|
# Hitung overlap dengan poly_truck
|
|
if poly_truck is not None:
|
|
overlap_area_truck = bbox_poly.intersection(poly_truck).area
|
|
else:
|
|
overlap_area_truck = 0.0
|
|
overlap_ratio_truck = overlap_area_truck / box_area if box_area > 0 else 0
|
|
overlap_ratio_counting = 0.0
|
|
|
|
# --- Label visual zona ---
|
|
current_zone = get_zone_name((cx, cy))
|
|
|
|
# --- ATURAN BARU: Karung di area PALET tidak boleh dikunci/dicounting ---
|
|
if current_zone == "PALET":
|
|
if already_counted[track_id]:
|
|
already_counted[track_id] = False
|
|
is_locked[track_id] = False
|
|
has_crossed_line[track_id] = False
|
|
exit_crossed_line[track_id] = False
|
|
outside_truck_frames[track_id] = 0
|
|
counted_at_frame.pop(track_id, None)
|
|
|
|
# =====================================================================
|
|
# --- TAMBAHAN BARU: RE-ID & PENGECEKAN SPASIAL ANTI DUPLIKAT ---
|
|
# =====================================================================
|
|
if track_id not in prev_active_track_ids:
|
|
# 1. Coba pulihkan ID dengan Re-ID standar
|
|
reid_success = check_reid_recovery(track_id, (cx, cy), overlap_ratio_truck, frame_idx)
|
|
|
|
# 2. Jika dianggap ID baru, pastikan ini BUKAN pecahan dari karung yang sudah counted
|
|
if not reid_success:
|
|
# Inisialisasi jika muncul pertama kali di area truk/counting
|
|
centroid_point = Point(cx, cy)
|
|
if poly_truck is not None and not poly_truck.is_empty and poly_truck.contains(centroid_point):
|
|
track_started_in_truck[track_id] = True
|
|
# =====================================================================
|
|
|
|
# --- LOGIKA ZONA LINGKARAN DUPLIKAT DI TRUK ---
|
|
if current_zone == "TRUCK":
|
|
if track_id not in track_initial_truck_pos or track_initial_truck_pos[track_id] is None:
|
|
# Tunggu sampai koordinat bbox stabil (minimal 5 frame) sebelum mengunci titik awal lingkaran
|
|
if len(track_positions[track_id]) >= 5:
|
|
track_initial_truck_pos[track_id] = (cx, cy)
|
|
track_truck_entry_frame[track_id] = frame_idx
|
|
has_exited_circle[track_id] = False
|
|
delay_completed[track_id] = False
|
|
|
|
if not already_counted[track_id] and not has_exited_circle[track_id]:
|
|
if track_initial_truck_pos.get(track_id) is not None:
|
|
start_cx, start_cy = track_initial_truck_pos[track_id]
|
|
dist = np.sqrt((cx - start_cx)**2 + (cy - start_cy)**2)
|
|
|
|
if dist > DUPLICATE_CIRCLE_RADIUS:
|
|
if cy < start_cy + (15 * scale_y): # Arah cenderung ke atas
|
|
has_exited_circle[track_id] = True
|
|
has_crossed_line[track_id] = True
|
|
|
|
# JIKA delay sudah habis sewaktu di dalam lingkaran ("simpan dulu"),
|
|
# langsung hitung (counting) sekarang juga saat keluar lingkaran!
|
|
if delay_completed[track_id]:
|
|
metrics['total_masuk'] += 1
|
|
already_counted[track_id] = True
|
|
is_locked[track_id] = True
|
|
pending_enter_since[track_id] = None
|
|
counted_at_frame[track_id] = frame_idx
|
|
else:
|
|
# Cek durasi tinggal
|
|
elapsed_sec = (frame_idx - track_truck_entry_frame[track_id]) / fps
|
|
if elapsed_sec >= CIRCLE_STAY_TIMEOUT_SEC:
|
|
already_counted[track_id] = True
|
|
is_locked[track_id] = True
|
|
pending_enter_since[track_id] = None
|
|
counted_at_frame[track_id] = frame_idx
|
|
|
|
# --- PENGECEKAN SPASIAL ANTI DUPLIKAT AKTIF TIAP FRAME ---
|
|
if (current_zone == "TRUCK") and has_crossed_line.get(track_id, False) and not already_counted[track_id]:
|
|
is_duplicate_locked = cek_duplikat_karung_locked(track_id, cx, cy, box_area, frame_idx)
|
|
if is_duplicate_locked:
|
|
already_counted[track_id] = True
|
|
is_locked[track_id] = True
|
|
pending_enter_since[track_id] = None # Bersihkan antrian pending jika ada
|
|
counted_at_frame[track_id] = frame_idx
|
|
|
|
# --- VALIDASI GERAKAN DARI PALET KE TRUK (UNTUK SET CROSS LINE) ---
|
|
if current_zone in ["TRUCK", "BEBAS"] and not has_crossed_line[track_id] and not already_counted[track_id]:
|
|
if (100 * scale_y) <= cy <= (950 * scale_y):
|
|
points = list(track_positions[track_id])
|
|
if len(points) > 1:
|
|
idx_prev = max(0, len(points) - 6)
|
|
prev_cx, prev_cy = points[idx_prev]
|
|
dy = cy - prev_cy
|
|
dx = cx - prev_cx
|
|
dist = np.sqrt(dx**2 + dy**2)
|
|
# Arah dari palet ke truk (Y berkurang)
|
|
if dist > (30 * scale_y) and dy < (-10 * scale_y):
|
|
has_crossed_line[track_id] = True
|
|
|
|
update_zone_label(track_id, current_zone)
|
|
visual_state = track_confirmed_state.get(current_zone, current_zone)
|
|
|
|
# --- Update counting ---
|
|
track_required_frames = required_frames
|
|
|
|
update_counting(
|
|
track_id=track_id,
|
|
overlap_ratio_counting=overlap_ratio_counting,
|
|
in_counting_zone=False,
|
|
overlap_ratio_truck=overlap_ratio_truck,
|
|
frame_idx=frame_idx,
|
|
required_frames=track_required_frames,
|
|
current_zone=current_zone,
|
|
required_exit_frames=required_exit_frames
|
|
)
|
|
|
|
# --- Hide counted tracks after timeout ---
|
|
if not SHOW_ALL_BBOXES and already_counted[track_id] and counted_at_frame.get(track_id) is not None:
|
|
if frame_idx - counted_at_frame[track_id] > COUNTED_DISPLAY_TIMEOUT_SEC * fps:
|
|
continue
|
|
|
|
# --- Warna & label ---
|
|
if already_counted[track_id]:
|
|
if pending_exit_since[track_id] is not None:
|
|
color = "rgba(255, 165, 0, 1.0)" # Orange
|
|
status_label = "PENDING_KELUAR"
|
|
else:
|
|
color = "rgba(0, 255, 136, 1.0)" # Green (accent primary)
|
|
status_label = "MASUK_TERVERIFIKASI"
|
|
elif pending_enter_since[track_id] is not None:
|
|
color = "rgba(255, 204, 0, 1.0)" # Yellow (accent neutral)
|
|
status_label = "PENDING_MASUK"
|
|
elif current_zone == "PALET":
|
|
color = "rgba(0, 240, 255, 1.0)" # Cyan (accent cyan)
|
|
status_label = "PALET"
|
|
elif current_zone == "BEBAS":
|
|
continue
|
|
else:
|
|
continue
|
|
|
|
is_visible = True
|
|
if not SHOW_ALL_BBOXES and already_counted[track_id] and counted_at_frame.get(track_id) is not None:
|
|
if frame_idx - counted_at_frame[track_id] > COUNTED_DISPLAY_TIMEOUT_SEC * fps:
|
|
is_visible = False
|
|
|
|
if is_visible:
|
|
bbox_list.append({
|
|
"id": int(track_id),
|
|
"bbox": [float(x1)/width, float(y1)/height, float(x2)/width, float(y2)/height],
|
|
"centroid": [float(cx)/width, float(cy)/height],
|
|
"status": status_label,
|
|
"color": color,
|
|
"trail": [[float(p[0])/width, float(p[1])/height] for p in track_positions[track_id]],
|
|
"is_locked": bool(is_locked[track_id]),
|
|
"pending_enter": bool(pending_enter_since[track_id] is not None),
|
|
"pending_exit": bool(pending_exit_since[track_id] is not None),
|
|
"initial_pos": [float(track_initial_truck_pos[track_id][0])/width, float(track_initial_truck_pos[track_id][1])/height] if (track_id in track_initial_truck_pos and track_initial_truck_pos[track_id] is not None) else None,
|
|
"has_exited_circle": bool(has_exited_circle[track_id])
|
|
})
|
|
|
|
# --- RE-ID INSTAN ---
|
|
for old_id in prev_active_track_ids:
|
|
if old_id not in current_active_track_ids:
|
|
if track_positions[old_id]:
|
|
transferred = False
|
|
last_c = track_positions[old_id][-1]
|
|
|
|
for active_id in current_active_track_ids:
|
|
if already_counted[active_id] or pending_enter_since[active_id] is not None:
|
|
continue
|
|
|
|
if active_id in track_positions and len(track_positions[active_id]) > 0:
|
|
act_c = track_positions[active_id][-1]
|
|
dist = np.sqrt((last_c[0] - act_c[0]) ** 2 + (last_c[1] - act_c[1]) ** 2)
|
|
|
|
pt_act = Point(act_c)
|
|
if already_counted[old_id] and poly_palet is not None and not poly_palet.is_empty and poly_palet.contains(pt_act):
|
|
continue
|
|
|
|
in_truck = poly_truck is not None and not poly_truck.is_empty and poly_truck.contains(pt_act)
|
|
threshold_dist = (100 * scale_x) if in_truck else (60 * scale_x)
|
|
|
|
if dist < threshold_dist:
|
|
pending_enter_since[active_id] = pending_enter_since[old_id]
|
|
pending_exit_since[active_id] = pending_exit_since[old_id]
|
|
already_counted[active_id] = already_counted[old_id]
|
|
is_locked[active_id] = is_locked[old_id]
|
|
has_crossed_line[active_id] = has_crossed_line[old_id]
|
|
exit_crossed_line[active_id] = exit_crossed_line[old_id]
|
|
counted_at_frame[active_id] = counted_at_frame.get(old_id)
|
|
track_areas[active_id] = track_areas[old_id]
|
|
track_started_in_truck[active_id] = track_started_in_truck[old_id]
|
|
|
|
track_initial_truck_pos[active_id] = track_initial_truck_pos.get(old_id)
|
|
track_truck_entry_frame[active_id] = track_truck_entry_frame.get(old_id)
|
|
has_exited_circle[active_id] = has_exited_circle[old_id]
|
|
delay_completed[active_id] = delay_completed[old_id]
|
|
|
|
transferred = True
|
|
break
|
|
|
|
if not transferred:
|
|
lost_tracks[old_id] = {
|
|
'last_centroid': track_positions[old_id][-1],
|
|
'frame_idx': frame_idx,
|
|
'already_counted': already_counted[old_id],
|
|
'is_locked': is_locked[old_id],
|
|
'pending_enter_since': pending_enter_since[old_id],
|
|
'pending_exit_since': pending_exit_since[old_id],
|
|
'zone_history': track_zone_history[old_id].copy(),
|
|
'positions': track_positions[old_id].copy(),
|
|
'has_crossed_line': has_crossed_line[old_id],
|
|
'exit_crossed_line': exit_crossed_line[old_id],
|
|
'box_area': track_areas[old_id],
|
|
'started_in_truck': track_started_in_truck[old_id],
|
|
'counted_at_frame': counted_at_frame.get(old_id),
|
|
'initial_truck_pos': track_initial_truck_pos.get(old_id),
|
|
'truck_entry_frame': track_truck_entry_frame.get(old_id),
|
|
'has_exited_circle': has_exited_circle[old_id]
|
|
}
|
|
track_positions.pop(old_id, None)
|
|
track_initial_truck_pos.pop(old_id, None)
|
|
track_truck_entry_frame.pop(old_id, None)
|
|
has_exited_circle.pop(old_id, None)
|
|
|
|
# --- LOST TRACK RECOVERY FOR FAST MOVING BAGS ---
|
|
grace_frames = max(5, int(0.5 * fps))
|
|
for old_id, info in list(lost_tracks.items()):
|
|
if not info['already_counted'] and (info['has_crossed_line'] or info.get('started_in_truck', False)):
|
|
last_pt = Point(info['last_centroid'])
|
|
if poly_truck is not None and not poly_truck.is_empty and poly_truck.contains(last_pt):
|
|
if (frame_idx - info['frame_idx']) >= grace_frames:
|
|
metrics['total_masuk'] += 1
|
|
info['already_counted'] = True
|
|
info['counted_at_frame'] = frame_idx
|
|
already_counted[old_id] = True
|
|
is_locked[old_id] = True
|
|
counted_at_frame[old_id] = frame_idx
|
|
|
|
expired_ids = [k for k, v in lost_tracks.items() if (frame_idx - v['frame_idx']) > MAX_REID_FRAMES]
|
|
for k in expired_ids:
|
|
del lost_tracks[k]
|
|
|
|
prev_active_track_ids = current_active_track_ids
|
|
|
|
# Broadcast bbox coordinates to web client
|
|
if sse_clients:
|
|
trigger_event("bbox_data", {
|
|
"frame_idx": frame_idx,
|
|
"bboxes": bbox_list,
|
|
"circle_radius": float(DUPLICATE_CIRCLE_RADIUS) / width if 'DUPLICATE_CIRCLE_RADIUS' in globals() else 0.03
|
|
})
|
|
|
|
# --- REAL-TIME BROADCAST LOG & DATA ---
|
|
currently_counted = {k for k, v in already_counted.items() if v}
|
|
newly_counted = currently_counted - previously_counted
|
|
for sack_id in newly_counted:
|
|
timestamp_str = time.strftime("%H:%M:%S")
|
|
is_exit = exit_crossed_line.get(sack_id, False)
|
|
change_type = "KELUAR" if is_exit else "MASUK"
|
|
log_msg = f"Karung #{sack_id} {change_type} terkonfirmasi"
|
|
|
|
trigger_event("log_event", {"timestamp": timestamp_str, "message": log_msg, "type": change_type.lower()})
|
|
trigger_event("update_stats", {
|
|
"total_masuk": metrics['total_masuk'],
|
|
"total_keluar": metrics['total_keluar'],
|
|
"net": metrics['total_masuk'] - metrics['total_keluar'],
|
|
"fps": current_fps
|
|
})
|
|
|
|
# Kirim frame hasil prediksi ke thread background VideoSaver (jika running di mode stream)
|
|
if saver is not None:
|
|
try:
|
|
save_queue.put_nowait(frame.copy())
|
|
except queue.Full:
|
|
pass
|
|
|
|
# Update frame streaming dengan gambar hasil prediksi (annotated frame) jika client mengaktifkan live stream
|
|
if live_stream_enabled:
|
|
with streaming_lock:
|
|
streaming_frame = frame.copy()
|
|
|
|
# Hitung FPS secara periodik (setiap 25 frame ~1 detik)
|
|
if frame_idx % 25 == 0:
|
|
elapsed = time.time() - last_time
|
|
current_fps = 25.0 / elapsed if elapsed > 0 else 0
|
|
last_time = time.time()
|
|
print(f"[INFO] Frame {frame_idx} - Total Masuk: {metrics['total_masuk']} - Total Keluar: {metrics['total_keluar']} - Net: {metrics['total_masuk'] - metrics['total_keluar']} ({current_fps:.2f} FPS)")
|
|
|
|
trigger_event("update_stats", {
|
|
"total_masuk": metrics['total_masuk'],
|
|
"total_keluar": metrics['total_keluar'],
|
|
"net": metrics['total_masuk'] - metrics['total_keluar'],
|
|
"fps": current_fps
|
|
})
|
|
|
|
# GUI lokal (cv2.imshow) dinonaktifkan untuk menghemat daya komputasi dan memori Jetson.
|
|
# Live preview tetap aktif melalui web dashboard server (http://localhost:8000).
|
|
|
|
# Membersihkan dan menutup semua resource
|
|
if saver_thread is not None:
|
|
save_queue.put(None)
|
|
saver_thread.join(timeout=2.0)
|
|
if saver is not None:
|
|
saver.release()
|
|
cap.release()
|
|
cv2.destroyAllWindows()
|
|
|
|
final_results = {
|
|
"total_masuk_truck": metrics['total_masuk'],
|
|
"total_keluar_truck": metrics['total_keluar'],
|
|
"net_karung_di_truck": net_count
|
|
}
|
|
|
|
with open(output_json_path, 'w') as f:
|
|
json.dump(final_results, f, indent=4)
|
|
print("\n" + "=" * 50)
|
|
print("PROSES SELESAI!")
|
|
print(final_results)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Menggunakan model TensorRT .engine untuk performa maksimal di Jetson
|
|
MODEL_FILE = "karung-dimuat-detection-di-feedmill-yolo26n-seg-200e.engine"
|
|
# Menggunakan Sub Stream (subtype=1) agar resolusi video ringan untuk didecode (640x480)
|
|
SOURCE_INPUT = "rtsp://admin:K0l0r4n123@10.38.250.21/cam/realmonitor?channel=1&subtype=1"
|
|
OUTPUT_JSON = "hasil_perhitungan.json"
|
|
|
|
try:
|
|
run_prediction(
|
|
model_path=MODEL_FILE,
|
|
source_path=SOURCE_INPUT,
|
|
output_json_path=OUTPUT_JSON,
|
|
max_frames=None,
|
|
inference_stride=2
|
|
)
|
|
except KeyboardInterrupt:
|
|
print("\n" + "=" * 50)
|
|
print("[INFO] Program dihentikan secara manual (Ctrl+C).")
|
|
print("Membersihkan resource dan menyimpan hasil perhitungan terakhir...")
|
|
|
|
# Simpan hasil perhitungan parsial sebelum keluar
|
|
final_results = {
|
|
"total_masuk_truck": metrics['total_masuk'],
|
|
"total_keluar_truck": metrics['total_keluar'],
|
|
"net_karung_di_truck": metrics['total_masuk'] - metrics['total_keluar']
|
|
}
|
|
with open(OUTPUT_JSON, 'w') as f:
|
|
json.dump(final_results, f, indent=4)
|
|
|
|
print("Hasil akhir yang disimpan:")
|
|
print(final_results)
|
|
print("=" * 50) |