import os os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp|threads;1|buffer_size;20480000|max_delay;500000|reorder_queue_size;500" import cv2 import numpy as np import json import time import sqlite3 import threading from datetime import datetime, timedelta from collections import defaultdict, deque from shapely.geometry import Point, Polygon, box from ultralytics import YOLO class RTSPBufferlessCapture: """Bufferless Capture using cap.grab() in main thread - 100% thread-safe on Windows.""" def __init__(self, source_path): self.source_path = source_path self.cap = cv2.VideoCapture(source_path, cv2.CAP_FFMPEG) if self.cap.isOpened(): self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH)) self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) self.fps = self.cap.get(cv2.CAP_PROP_FPS) else: self.width, self.height, self.fps = 1920, 1080, 25.0 if self.fps <= 0 or np.isnan(self.fps): self.fps = 25.0 def isOpened(self): return self.cap is not None and self.cap.isOpened() def get(self, propId): if propId == cv2.CAP_PROP_FRAME_WIDTH: return self.width elif propId == cv2.CAP_PROP_FRAME_HEIGHT: return self.height elif propId == cv2.CAP_PROP_FPS: return self.fps elif self.cap is not None: return self.cap.get(propId) return 0 def read(self): if self.cap is None or not self.cap.isOpened(): return False, None # Flush buffer to get latest live frame self.cap.grab() ret, frame = self.cap.retrieve() if not ret or frame is None: ret, frame = self.cap.read() return ret, frame def release(self): if self.cap is not None: self.cap.release() self.cap = None # ===================================================================== # SYSTEM DATABASES AND CONFIGURATION FOR LIVE DASHBOARD # ===================================================================== if os.name == 'nt': _DEFAULT_DIR = "d:/Belajar/menghitung karung" DB_PATH = os.getenv('DB_PATH', f"{_DEFAULT_DIR}/jetson_counter.db") STATE_FILE = os.getenv('STATE_FILE', f"{_DEFAULT_DIR}/current_batch.json") LIVE_STREAM_FRAME_PATH = os.getenv('LIVE_STREAM_FRAME_PATH', f"{_DEFAULT_DIR}/live_frame.jpg") else: _DEFAULT_DIR = "/opt/jetson-counter" DB_PATH = os.getenv('DB_PATH', f"{_DEFAULT_DIR}/jetson_counter.db") STATE_FILE = os.getenv('STATE_FILE', f"{_DEFAULT_DIR}/current_batch.json") LIVE_STREAM_FRAME_PATH = os.getenv('LIVE_STREAM_FRAME_PATH', '/dev/shm/jetson-counter/live_frame.jpg') CAMERA_NAME = os.getenv('CAMERA_NAME', 'CC1') OBJECT_LABEL = os.getenv('OBJECT_LABEL', 'karung-pakan') DAILY_CUTOFF_TIME = os.getenv('DAILY_CUTOFF_TIME', '20:00') def init_db(): try: os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) conn = sqlite3.connect(DB_PATH) cur = conn.cursor() cur.execute(""" CREATE TABLE IF NOT EXISTS batches ( id INTEGER PRIMARY KEY AUTOINCREMENT, counting_date TEXT NOT NULL, batch_number INTEGER NOT NULL, camera_name TEXT NOT NULL, object_label TEXT NOT NULL, count INTEGER NOT NULL, start_time TEXT NOT NULL, end_time TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(counting_date, batch_number, camera_name, object_label) ) """) cur.execute(""" CREATE TABLE IF NOT EXISTS daily_summaries ( id INTEGER PRIMARY KEY AUTOINCREMENT, counting_date TEXT NOT NULL, camera_name TEXT NOT NULL, object_label TEXT NOT NULL, total_count INTEGER NOT NULL DEFAULT 0, total_batches INTEGER NOT NULL DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(counting_date, camera_name, object_label) ) """) conn.commit() conn.close() except Exception as e: print(f"[DB Error] Gagal inisialisasi database: {e}") def get_counting_date(dt=None, cutoff_str=DAILY_CUTOFF_TIME): if dt is None: dt = datetime.now() try: cutoff = datetime.strptime(cutoff_str, "%H:%M").time() except Exception: cutoff = datetime.strptime("20:00", "%H:%M").time() if cutoff.hour == 0 and cutoff.minute == 0: return dt.date().isoformat() if dt.time() < cutoff: return (dt.date() - timedelta(days=1)).isoformat() return dt.date().isoformat() def get_next_batch_number(date_str): try: conn = sqlite3.connect(DB_PATH) cur = conn.cursor() cur.execute(""" SELECT COALESCE(MAX(batch_number), 0) + 1 FROM batches WHERE counting_date = ? AND camera_name = ? AND object_label = ? """, (date_str, CAMERA_NAME, OBJECT_LABEL)) num = cur.fetchone()[0] conn.close() return num except Exception: return 1 def finalize_batch(final_count, start_time_str, end_time_str, batch_num, counting_date): try: init_db() conn = sqlite3.connect(DB_PATH) cur = conn.cursor() cur.execute(""" INSERT OR REPLACE INTO batches (counting_date, batch_number, camera_name, object_label, count, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?, ?) """, (counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL, final_count, start_time_str, end_time_str)) cur.execute(""" SELECT COALESCE(SUM(count), 0) as tot_count, COUNT(id) as tot_batches FROM batches WHERE counting_date = ? AND camera_name = ? AND object_label = ? """, (counting_date, CAMERA_NAME, OBJECT_LABEL)) row = cur.fetchone() tot_count = row[0] tot_batches = row[1] cur.execute(""" INSERT OR REPLACE INTO daily_summaries (counting_date, camera_name, object_label, total_count, total_batches, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) """, (counting_date, CAMERA_NAME, OBJECT_LABEL, tot_count, tot_batches)) conn.commit() conn.close() print(f"[DB Info] Sesi batch #{batch_num} disimpan ke database SQLite: {final_count} karung.") except Exception as e: print(f"[DB Error] Gagal menyimpan batch ke database: {e}") # ===================================================================== # 0. PARAMETER KONFIGURASI KALIBRASI (RANCANGAN BRAIN-STORMING) # ===================================================================== CAMERA_NOISE_DEADBAND = 5 # Filter getaran kamera (pixel) JARAK_TOLERANSI_DUPLIKAT = 80 # Jarak spasial maksimal untuk anti-double check (pixel) TOLERANSI_FRAME_HILANG = 120 # Frame timeout untuk Re-ID lost track MAX_REID_TRANSIT_DISTANCE = 400 # Jarak dasar pencarian Re-ID (pixel) MAX_REID_FRAMES = 120 # Frame maks untuk memulihkan ID yang hilang CONFIRM_DELAY_SEC = 0.5 # Delay debounce statis sebelum dihitung (detik) MAX_STATIC_SPEED = 80.0 # Batas kecepatan maks untuk dikategorikan statis (px/s) INFERENCE_STRIDE = 2 # Frame skipping (1 = proses semua, 2 = skip 1 frame) # --- Path Model --- TRUCK_MODEL_PATH = "truck-detector.pt" SACK_MODEL_PATH = "karung-dimuat-detection-di-feedmill-yolo26n-seg-200e.pt" # --- Konstanta State Machine --- STATE_WAITING_FOR_TRUCK = "WAITING_FOR_TRUCK" STATE_COUNTING_SACKS = "COUNTING_SACKS" STATE_TRUCK_LEAVING = "TRUCK_LEAVING" # ===================================================================== # 1. UTILITY AKURASI (VISUAL SIMILARITY & PERSPECTIVE PROFILE) # ===================================================================== def get_visual_features(crop): """Mengekstrak fitur visual berupa histogram HSV (warna) dan grayscale image (struktur/tekstur) dari crop karung.""" if crop is None or crop.size == 0: return None, None try: resized = cv2.resize(crop, (64, 64)) # 1. Color Profile: HSV Hist hsv = cv2.cvtColor(resized, cv2.COLOR_BGR2HSV) hist = cv2.calcHist([hsv], [0, 1], None, [16, 16], [0, 180, 0, 256]) cv2.normalize(hist, hist, 0, 1, cv2.NORM_MINMAX) # 2. Structural Profile: Grayscale NCC gray = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY) return hist, gray except Exception as e: return None, None def compare_visual_similarity(feat1, feat2): """Membandingkan kemiripan visual karung (gabungan korelasi warna HSV 60% dan struktur grayscale NCC 40%).""" if feat1 is None or feat2 is None: return 0.0 hist1, gray1 = feat1 hist2, gray2 = feat2 if hist1 is None or hist2 is None or gray1 is None or gray2 is None: return 0.0 try: # Kemiripan Warna HSV color_sim = cv2.compareHist(hist1, hist2, cv2.HISTCMP_CORREL) color_sim = max(0.0, color_sim) if not np.isnan(color_sim) else 0.0 # Kemiripan Struktur Grayscale NCC res = cv2.matchTemplate(gray1, gray2, cv2.TM_CCOEFF_NORMED) struct_sim = max(0.0, res[0][0]) if not np.isnan(res[0][0]) else 0.0 return 0.6 * color_sim + 0.4 * struct_sim except Exception: return 0.0 def get_min_valid_area(cy, scale_x=1.0, scale_y=1.0): """Menghitung batas luas area minimum secara dinamis berdasarkan perspektif Y (Interpolasi Linier).""" top_y = 200 * scale_y top_area = 8000 * scale_x * scale_y bot_y = 1080 * scale_y bot_area = 25000 * scale_x * scale_y if cy <= top_y: return top_area if cy >= bot_y: return bot_area ratio = (cy - top_y) / (bot_y - top_y) return top_area + ratio * (bot_area - top_area) # ===================================================================== # 2. SISTEM DEBOUNCE STATIS & PENYARING DUPLIKAT SPASIAL-VISUAL # ===================================================================== class SackCounterPipeline: def __init__(self, output_json_path="hasil_perhitungan.json"): self.output_json_path = output_json_path self.system_state = STATE_WAITING_FOR_TRUCK # Area Deteksi (Poligon Shapely) self.poly_truck = None self.poly_palet = None # Ditentukan manual jika zones.json dimuat # State Monitoring Truk self.truck_initial_bbox = None self.truck_static_frames = 0 # Tracking Karung Aktif self.static_frames = defaultdict(int) self.moving_frames = defaultdict(int) self.already_counted = defaultdict(bool) self.blocked_without_counting = defaultdict(bool) self.track_positions = defaultdict(lambda: deque(maxlen=30)) self.track_areas = defaultdict(float) self.track_is_valid_bag = defaultdict(bool) self.track_visited_palet = defaultdict(bool) # --- TAMBAHAN BARU: LINE CROSSING TRACKER --- # Registry Visual Karung Terhitung (Anti-Double Count) self.static_sack_visuals = {} # track_id -> (hist, gray) # Re-ID Lost Tracks self.lost_tracks = {} # lost_id -> dict properties # Metrik Penghitungan Batch self.total_masuk = 0 self.total_keluar = 0 self.entry_points = {} # track_id -> (ex, ey) # Database & Active State initialization init_db() self.counting_date = get_counting_date() self.batch_number = get_next_batch_number(self.counting_date) self.start_time = datetime.now().isoformat() self.last_detection_time = self.start_time self.save_active_batch_state() def save_active_batch_state(self): try: os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) state_data = { "counting_date": self.counting_date, "batch_number": self.batch_number, "count": self.total_masuk, "start_time": self.start_time, "last_detection_time": self.last_detection_time } with open(STATE_FILE, 'w') as f: json.dump(state_data, f, indent=4) except Exception: pass def clear_active_batch_state(self): try: if os.path.exists(STATE_FILE): os.remove(STATE_FILE) except Exception: pass def reset_batch(self): """Reset state tracking dan counter untuk memulai batch truk baru.""" self.static_frames.clear() self.moving_frames.clear() self.already_counted.clear() self.blocked_without_counting.clear() self.track_positions.clear() self.track_areas.clear() self.track_is_valid_bag.clear() self.track_visited_palet.clear() self.static_sack_visuals.clear() self.lost_tracks.clear() self.entry_points.clear() self.total_masuk = 0 self.total_keluar = 0 self.counting_date = get_counting_date() self.batch_number = get_next_batch_number(self.counting_date) self.start_time = datetime.now().isoformat() self.last_detection_time = self.start_time self.save_active_batch_state() def save_batch_report(self): """Menulis file laporan batch JSON ketika truk meninggalkan area.""" timestamp_str = time.strftime("%Y%m%d_%H%M%S") batch_folder = "batch_history_folder" os.makedirs(batch_folder, exist_ok=True) batch_file = os.path.join(batch_folder, f"batch_{timestamp_str}.json") report_data = { "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), "total_masuk_truck": self.total_masuk, "total_keluar_truck": self.total_keluar, "net_karung_di_truck": self.total_masuk - self.total_keluar } try: with open(batch_file, 'w') as f: json.dump(report_data, f, indent=4) print(f"\n[REPORT] Laporan Batch disimpan ke: {batch_file}") # Update juga file output kumulatif with open(self.output_json_path, 'w') as f: json.dump(report_data, f, indent=4) # Simpan ke SQLite database dan bersihkan berkas state aktif finalize_batch(self.total_masuk, self.start_time, datetime.now().isoformat(), self.batch_number, self.counting_date) self.clear_active_batch_state() except Exception as e: print(f"[ERROR] Gagal menyimpan laporan batch: {e}") # ===================================================================== # 3. PIPELINE PREDIKSI UTAMA (DUO-MODEL PIPELINE) # ===================================================================== def run_prediction(source_path, max_frames=None, save_output_video=True, show_live=True): print("=" * 60) print("AI SACK COUNTER PIPELINE - DIKEMBANGKAN DARI AWAL (BRAIN-STORMING)") print("=" * 60) # 1. Load Model print("[INFO] Model Truk dinonaktifkan (area truk di-hardcode)...") model_truck = None print(f"[INFO] Memuat Model Karung: {SACK_MODEL_PATH}...") model_sack = YOLO(SACK_MODEL_PATH) # Deteksi otomatis ID kelas karung dan pekerja global sack_class_id, person_class_id sack_class_id = 1 person_class_id = 0 if hasattr(model_sack, 'names') and model_sack.names: for cid, name in model_sack.names.items(): name_str = str(name).lower() if any(w in name_str for w in ['karung', 'cuval', 'sack', 'bag']): sack_class_id = int(cid) elif any(w in name_str for w in ['person', 'human', 'pekerja', 'manusia']): person_class_id = int(cid) print(f"[INFO] Auto-detected Kelas: Karung ID = {sack_class_id}, Pekerja ID = {person_class_id}") # 2. Buka Video Input (Threaded untuk RTSP stream, direct untuk file lokal) is_stream = any(str(source_path).startswith(p) for p in ["rtsp://", "rtmp://", "http://", "https://"]) if is_stream: print(f"[INFO] Membuka RTSP Stream menggunakan RTSPBufferlessCapture: {source_path}") cap = RTSPBufferlessCapture(source_path) else: print(f"[INFO] Membuka file video lokal: {source_path}") cap = cv2.VideoCapture(source_path) if not cap.isOpened(): print(f"[ERROR] Gagal membuka video source: {source_path}") return width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) fps = cap.get(cv2.CAP_PROP_FPS) if fps <= 0 or np.isnan(fps): fps = 25.0 # Scale faktor terhadap resolusi dasar 1920x1080 scale_x = width / 1920.0 scale_y = height / 1080.0 # Setup Video Writer (jika diaktifkan) writer = None if save_output_video: output_name = "annotated_output.mp4" fourcc = cv2.VideoWriter_fourcc(*'mp4v') writer = cv2.VideoWriter(output_name, fourcc, fps, (width, height)) print(f"[INFO] Output video akan disimpan ke: {output_name}") # Inisialisasi Pipeline State pipeline = SackCounterPipeline() # Mulai langsung di mode penghitungan (tidak perlu mendeteksi truk) pipeline.system_state = STATE_COUNTING_SACKS # Set default area palet dari pengguna (menggunakan koordinat referensi 1920x1080) default_palet_pts = np.array([[514, 437], [1112, 439], [1112, 818], [500, 817]], dtype=np.int32) scaled_palet_pts = np.array([[int(p[0] * scale_x), int(p[1] * scale_y)] for p in default_palet_pts], dtype=np.int32) pipeline.poly_palet = Polygon(scaled_palet_pts) print(f"[INFO] Poligon Zona Palet berhasil diinisialisasi: {scaled_palet_pts.tolist()}") # Set default area truk dari pengguna (menggunakan koordinat referensi 1920x1080) default_truck_pts = np.array([[566, 1], [547, 496], [1090, 502], [1072, 5]], dtype=np.int32) scaled_truck_pts = np.array([[int(p[0] * scale_x), int(p[1] * scale_y)] for p in default_truck_pts], dtype=np.int32) pipeline.poly_truck = Polygon(scaled_truck_pts) print(f"[INFO] Poligon Zona Truk (Hardcoded) berhasil diinisialisasi: {scaled_truck_pts.tolist()}") # Muat zones.json default jika ada untuk override if os.path.exists("zones.json"): try: with open("zones.json", 'r') as f: data = json.load(f) if 'palet' in data and len(data['palet']) >= 3: pts = np.array([[int(p[0] * scale_x), int(p[1] * scale_y)] for p in data['palet']], dtype=np.int32) pipeline.poly_palet = Polygon(pts) print("[INFO] Poligon Zona Palet berhasil dimuat dari zones.json (override)") if 'truck' in data and len(data['truck']) >= 3: pts = np.array([[int(p[0] * scale_x), int(p[1] * scale_y)] for p in data['truck']], dtype=np.int32) pipeline.poly_truck = Polygon(pts) print("[INFO] Poligon Zona Truk berhasil dimuat dari zones.json (override)") except Exception as e: print(f"[WARNING] Gagal memuat zones.json: {e}") frame_idx = 0 last_time = time.time() current_fps = 0.0 while cap.isOpened(): ret, frame = cap.read() if not ret: break frame_idx += 1 if max_frames is not None and frame_idx > max_frames: break # Hitung durasi interval frame aktual untuk kompensasi FPS rendah dt = (INFERENCE_STRIDE / fps) if fps > 0 else 0.04 required_frames = max(1, int(CONFIRM_DELAY_SEC * fps)) required_static_updates = max(1, int(required_frames / INFERENCE_STRIDE)) # Bbox list untuk HUD visualizer visual_bboxes = [] # ===================================================================== # STATE MACHINE LOGIC # ===================================================================== # STATE 1: WAITING_FOR_TRUCK if pipeline.system_state == STATE_WAITING_FOR_TRUCK: if model_truck is None: pipeline.system_state = STATE_COUNTING_SACKS continue res_truck = model_truck(frame, conf=0.5, verbose=False) best_box = None best_conf = -1.0 if res_truck[0].boxes is not None and len(res_truck[0].boxes) > 0: for box_obj in res_truck[0].boxes: conf = float(box_obj.conf[0].cpu().item()) if conf > best_conf: best_conf = conf best_box = box_obj.xyxy[0].cpu().numpy() if best_box is not None: x1_t, y1_t, x2_t, y2_t = best_box cx_t = int((x1_t + x2_t) / 2) cy_t = int((y1_t + y2_t) / 2) # Cek stabilitas posisi truk if pipeline.truck_initial_bbox is None: pipeline.truck_initial_bbox = best_box pipeline.truck_static_frames = 0 else: cx_old = int((pipeline.truck_initial_bbox[0] + pipeline.truck_initial_bbox[2]) / 2) cy_old = int((pipeline.truck_initial_bbox[1] + pipeline.truck_initial_bbox[3]) / 2) disp = np.sqrt((cx_t - cx_old)**2 + (cy_t - cy_old)**2) if disp < CAMERA_NOISE_DEADBAND: pipeline.truck_static_frames += 1 else: pipeline.truck_initial_bbox = best_box pipeline.truck_static_frames = 0 # Truk dianggap berhenti jika stabil selama 45 frame (~1.5s) if pipeline.truck_static_frames >= 45: # Kunci area truk dengan margin aman 5% ke dalam bak w_t = x2_t - x1_t h_t = y2_t - y1_t x1_t += w_t * 0.05 x2_t -= w_t * 0.05 y1_t += h_t * 0.05 y2_t -= h_t * 0.05 pts_truck = np.array([[x1_t, y1_t], [x2_t, y1_t], [x2_t, y2_t], [x1_t, y2_t]], dtype=np.int32) pipeline.poly_truck = Polygon(pts_truck) # Reset data untuk batch baru pipeline.reset_batch() pipeline.system_state = STATE_COUNTING_SACKS print(f"\n[STATE] Truk diam terkunci di koordinat: {best_box}. Mulai menghitung karung...") # Append box truk ke visualizer visual_bboxes.append({ "bbox": [int(x1_t), int(y1_t), int(x2_t), int(y2_t)], "label": f"MONITORING TRUK: {pipeline.truck_static_frames}/45", "color": (0, 204, 255), "thick": 3 }) else: pipeline.truck_initial_bbox = None pipeline.truck_static_frames = 0 # STATE 3: TRUCK_LEAVING elif pipeline.system_state == STATE_TRUCK_LEAVING: pipeline.save_batch_report() pipeline.poly_truck = None pipeline.truck_initial_bbox = None pipeline.truck_static_frames = 0 pipeline.system_state = STATE_WAITING_FOR_TRUCK # STATE 2: COUNTING_SACKS elif pipeline.system_state == STATE_COUNTING_SACKS: # Pengecekan keberadaan truk dinonaktifkan (area truk di-hardcode) pass # Jalankan Tracker Karung dan Pekerja (Inference Stride) if INFERENCE_STRIDE <= 1 or frame_idx % INFERENCE_STRIDE == 0 or 'last_results' not in locals(): results_sack = model_sack.track(frame, persist=True, tracker="bytetrack.yaml", conf=0.05, classes=[person_class_id, sack_class_id], verbose=False) last_results = results_sack else: results_sack = last_results current_active_ids = set() if results_sack[0].boxes.id is not None: boxes = results_sack[0].boxes.xyxy.cpu().numpy() track_ids = results_sack[0].boxes.id.int().cpu().numpy() classes_ids = results_sack[0].boxes.cls.int().cpu().numpy() for box_coord, track_id, cls_id in zip(boxes, track_ids, classes_ids): # Jika terdeteksi sebagai pekerja/manusia, gambarkan bbox merah dan lewati logika hitung if cls_id == person_class_id: if save_output_video: x1, y1, x2, y2 = box_coord cv2.rectangle(frame, (int(x1), int(y1)), (int(x2), int(y2)), (0, 0, 255), 2) cv2.putText(frame, f"PEKERJA #{track_id}", (int(x1), int(y1) - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 2) continue x1, y1, x2, y2 = box_coord cx = int((x1 + x2) / 2) cy = int((y1 + y2) / 2) box_area = (x2 - x1) * (y2 - y1) pt = Point(cx, cy) # 1. Filter Perspektif Adaptif (Perspective Profile) min_area_thresh = get_min_valid_area(cy, scale_x, scale_y) is_fragment = (box_area < min_area_thresh) and not pipeline.track_is_valid_bag[track_id] if is_fragment: # Abaikan objek kecil/sampah yang terdeteksi continue else: pipeline.track_is_valid_bag[track_id] = True current_active_ids.add(track_id) pipeline.track_positions[track_id].append((cx, cy)) pipeline.track_areas[track_id] = box_area # 2. Cek Re-ID Lost Tracks (Dynamic Search Window) if len(pipeline.track_positions[track_id]) == 1: # Jika baru muncul, coba pulihkan dari registry lost track closest_old_id = None min_d = float('inf') for old_id, info in pipeline.lost_tracks.items(): frame_diff = frame_idx - info['frame_idx'] if frame_diff > MAX_REID_FRAMES: continue lc = info['last_centroid'] dist_reid = np.sqrt((cx - lc[0])**2 + (cy - lc[1])**2) # Jendela pencarian melebar seiring pertambahan frame drop (Kompensasi Lag FPS) dynamic_search_radius = MAX_REID_TRANSIT_DISTANCE * (1.0 + 0.01 * frame_diff) if dist_reid < dynamic_search_radius: if dist_reid < min_d: min_d = dist_reid closest_old_id = old_id if closest_old_id is not None: # Pulihkan state data track lama old_info = pipeline.lost_tracks[closest_old_id] pipeline.already_counted[track_id] = old_info['already_counted'] pipeline.blocked_without_counting[track_id] = old_info['blocked_without_counting'] pipeline.static_frames[track_id] = old_info['static_frames'] pipeline.track_visited_palet[track_id] = old_info.get('visited_palet', False) if old_info['already_counted'] and closest_old_id in pipeline.static_sack_visuals: pipeline.static_sack_visuals[track_id] = pipeline.static_sack_visuals[closest_old_id] del pipeline.lost_tracks[closest_old_id] print(f"[RE-ID] Tracker #{track_id} berhasil dipulihkan dari ID lama #{closest_old_id}") # 3. Hitung Vektor Kecepatan & Debounce Statis (Velocity Filtering) speed = 0.0 if len(pipeline.track_positions[track_id]) > 1: prev_cx, prev_cy = pipeline.track_positions[track_id][-2] disp = np.sqrt((cx - prev_cx)**2 + (cy - prev_cy)**2) # Filter getaran kamera (Noise Deadband) if disp < CAMERA_NOISE_DEADBAND: disp = 0.0 if dt > 0: speed = disp / dt # Update status gerak if speed < MAX_STATIC_SPEED: pipeline.static_frames[track_id] += 1 pipeline.moving_frames[track_id] = 0 else: pipeline.static_frames[track_id] = 0 pipeline.moving_frames[track_id] += 1 # Deteksi zona aktual centroid in_truck_polygon = pipeline.poly_truck is not None and pipeline.poly_truck.contains(pt) in_palet_polygon = pipeline.poly_palet is not None and pipeline.poly_palet.contains(pt) # Logika Perhitungan Sederhana: Bergerak > 50px dari Titik Masuk Area Truk if in_truck_polygon: if track_id not in pipeline.entry_points: pipeline.entry_points[track_id] = (cx, cy) pipeline.already_counted[track_id] = False if track_id in pipeline.entry_points: if not pipeline.already_counted[track_id]: ex, ey = pipeline.entry_points[track_id] dist_from_entry = np.sqrt((cx - ex)**2 + (cy - ey)**2) if dist_from_entry > 50: pipeline.total_masuk += 1 pipeline.already_counted[track_id] = True pipeline.last_detection_time = datetime.now().isoformat() pipeline.save_active_batch_state() print(f"[COUNTER] Karung #{track_id} terhitung masuk! (Jarak gerak: {dist_from_entry:.1f}px > 50px). Total: {pipeline.total_masuk}") # 5. Penentuan Kategori Label Visual HUD if pipeline.blocked_without_counting[track_id]: color = (128, 128, 128) # Abu-abu label = f"DUPLIKAT #{track_id}" elif pipeline.already_counted[track_id]: color = (0, 255, 0) # Hijau terang label = f"VERIFIED #{track_id}" elif in_truck_polygon: if speed >= MAX_STATIC_SPEED: color = (0, 255, 255) # Kuning label = f"TRANSIT #{track_id} ({speed:.0f}px/s)" else: color = (0, 165, 255) # Oranye label = f"NEW_STATIC #{track_id} ({pipeline.static_frames[track_id]}/{required_static_updates})" elif in_palet_polygon: color = (255, 255, 0) # Cyan label = f"PALET #{track_id}" else: color = (255, 0, 255) # Magenta label = f"SACK #{track_id}" # Tampilkan bounding box, titik tengah, dan label di frame if True: cv2.rectangle(frame, (int(x1), int(y1)), (int(x2), int(y2)), color, 2) cv2.putText(frame, label, (int(x1), int(y1) - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2) # 1. Gambar Point (Titik Tengah BBox) cv2.circle(frame, (cx, cy), 5, (0, 255, 255), -1) # 2. Menggambar titik acuan masuk, radius 50px, dan indikator perpindahan if track_id in pipeline.entry_points: ex, ey = pipeline.entry_points[track_id] is_counted = pipeline.already_counted[track_id] # Warna: Hijau jika terhitung (>50px), Oranye jika masih di dalam radius 50px viz_color = (0, 255, 0) if is_counted else (0, 140, 255) # Gambar Titik Acuan Awal saat Masuk Area Truk cv2.circle(frame, (ex, ey), 4, viz_color, -1) # Gambar Lingkaran Radius 50px cv2.circle(frame, (ex, ey), 50, viz_color, 2, lineType=cv2.LINE_AA) # Gambar garis hubung dari titik awal ke titik bbox saat ini cv2.line(frame, (ex, ey), (cx, cy), viz_color, 1) # Tampilkan label status jarak dist_val = np.sqrt((cx - ex)**2 + (cy - ey)**2) dist_label = f"COUNTED (+1)" if is_counted else f"{dist_val:.0f}/50px" cv2.putText(frame, dist_label, (ex - 20, ey - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.45, viz_color, 2) # Daftarkan track yang hilang pada frame ini ke registry Re-ID for old_id in list(pipeline.track_positions.keys()): if old_id not in current_active_ids: # Masukkan ke lost tracks if len(pipeline.track_positions[old_id]) > 0: pipeline.lost_tracks[old_id] = { "frame_idx": frame_idx, "last_centroid": pipeline.track_positions[old_id][-1], "already_counted": pipeline.already_counted[old_id], "blocked_without_counting": pipeline.blocked_without_counting[old_id], "static_frames": pipeline.static_frames[old_id], "visited_palet": pipeline.track_visited_palet[old_id], "positions": pipeline.track_positions[old_id].copy() } # Bersihkan dari tracker aktif pipeline.track_positions.pop(old_id, None) pipeline.static_frames.pop(old_id, None) pipeline.moving_frames.pop(old_id, None) pipeline.track_visited_palet.pop(old_id, None) # ===================================================================== # RENDER PREMIUM HUD OVERLAY (BURNT INTO FRAME) # ===================================================================== if True: # 1. Gambar Batas Zona if pipeline.poly_palet is not None: pts = np.array(pipeline.poly_palet.exterior.coords, dtype=np.int32) cv2.polylines(frame, [pts], True, (255, 255, 0), 2) cv2.putText(frame, "ZONA PALET", (pts[0][0], pts[0][1] - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 0), 2) if pipeline.poly_truck is not None: pts = np.array(pipeline.poly_truck.exterior.coords, dtype=np.int32) cv2.polylines(frame, [pts], True, (0, 204, 255), 2) cv2.putText(frame, "ZONA TRUK BATCH", (pts[0][0], pts[0][1] - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 204, 255), 2) # 2. Gambar Background HUD Panel (Top-Left) # HUD Glassmorphic Rectangle overlay = frame.copy() cv2.rectangle(overlay, (20, 20), (450, 180), (15, 17, 24), -1) cv2.addWeighted(overlay, 0.75, frame, 0.25, 0, frame) cv2.rectangle(frame, (20, 20), (450, 180), (255, 255, 255), 1, lineType=cv2.LINE_AA) # Text HUD info cv2.putText(frame, "AI SACK COUNTER PIPELINE v2.0", (35, 45), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 240, 255), 2) cv2.line(frame, (35, 55), (435, 55), (100, 100, 100), 1) # State System state_color = (0, 255, 0) if pipeline.system_state == STATE_COUNTING_SACKS else (0, 204, 255) cv2.putText(frame, f"STATUS: {pipeline.system_state}", (35, 80), cv2.FONT_HERSHEY_SIMPLEX, 0.5, state_color, 2) # Metrics cv2.putText(frame, f"TOTAL MASUK : {pipeline.total_masuk}", (35, 115), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) cv2.putText(frame, f"TOTAL KELUAR : {pipeline.total_keluar}", (35, 145), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) # FPS & Frame counter if frame_idx % 25 == 0: elapsed = time.time() - last_time current_fps = 25.0 / elapsed if elapsed > 0 else 0.0 last_time = time.time() cv2.putText(frame, f"FPS: {current_fps:.1f} | Frame: {frame_idx}", (35, 168), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (200, 200, 200), 1) # Write annotated frame to output video file if enabled if save_output_video and writer is not None: writer.write(frame) # Write live frame to shared memory RAM disk for dashboard streaming (every 2 frames) if frame_idx % 2 == 0: try: live_path = LIVE_STREAM_FRAME_PATH os.makedirs(os.path.dirname(live_path), exist_ok=True) tmp_path = live_path.replace(".jpg", ".tmp.jpg") cv2.imwrite(tmp_path, frame, [cv2.IMWRITE_JPEG_QUALITY, 80]) os.replace(tmp_path, live_path) except Exception: pass # Tampilkan Live Preview jika show_live aktif if show_live: display_frame = cv2.resize(frame, (1280, 720)) if (width > 1280 or height > 720) else frame cv2.imshow("AI Sack Counter - Live Preview", display_frame) if cv2.waitKey(1) & 0xFF == ord('q'): print("\n[INFO] Live preview dihentikan oleh pengguna (menekan tombol 'q').") break # Log status periodic ke konsol if frame_idx % 25 == 0: print(f"[INFO] Frame {frame_idx} - State: {pipeline.system_state} - Masuk: {pipeline.total_masuk} - Keluar: {pipeline.total_keluar} ({current_fps:.1f} FPS)") # Clean resources cap.release() if writer is not None: writer.release() cv2.destroyAllWindows() # Save final batch report pipeline.save_batch_report() print("\n" + "=" * 60) print("PROSES PIPELINE SELESAI!") print(f"Hasil Akhir Batch: Masuk = {pipeline.total_masuk}, Keluar = {pipeline.total_keluar}") print("=" * 60) if __name__ == "__main__": # RTSP Camera Live Stream SOURCE_INPUT = "rtsp://192.168.192.96:8554/cam" is_stream = any(str(SOURCE_INPUT).startswith(p) for p in ["http://", "https://", "rtsp://", "rtmp://"]) if is_stream or os.path.exists(SOURCE_INPUT): try: run_prediction( source_path=SOURCE_INPUT, max_frames=None, # Proses seluruh video save_output_video=True, show_live=True # Aktifkan window GUI OpenCV untuk live preview langsung ) except KeyboardInterrupt: print("\n[INFO] Program dihentikan secara manual (Ctrl+C).") else: print(f"[ERROR] Video/Stream '{SOURCE_INPUT}' tidak ditemukan.")