Files
karung-counting-feedmill-se…/archive/ini.py
T

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)