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 = """ AI Sack Counter Dashboard

Sack Counter Real-time Dashboard

LIVE PREDICTION VIEW

Live stream is disabled to maximize counting speed (25 FPS).

Tampilkan Live Prediction Stream
Mengaktifkan visualisasi video real-time. Mematikan fitur ini akan menaikkan FPS pemrosesan ke batas maksimal.
Atur Koordinat Zona Deteksi
Kalibrasi Parameter Deteksi
0
Total Masuk
0
Total Keluar
0
Net di Truck
0.0
Processing FPS
RIWAYAT DETEKSI REAL-TIME
Menunggu aktivitas deteksi...
""" 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)