import os os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp|buffer_size;20480000|max_delay;500000|reorder_queue_size;500" import cv2 import numpy as np import json import 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 import torch import sqlite3 from datetime import datetime, timedelta import logging # Import repo rafan modules from src.detection import SackDetector, TruckDetector from src.tracking import ByteTrackTracker from src.stabilizer import BboxStabilizer from src.truck_roi import TruckROITracker from src.counting import LineCrossCounter from src.batch import BatchLifecycleManager, BatchRecord from src.dashboard import DashboardOverlay # --- SQLite Database & State Configuration --- if os.name == 'nt': OUTPUT_DIR = 'd:/Belajar/menghitung karung' DB_PATH = f'{OUTPUT_DIR}/jetson_counter.db' STATE_FILE = f'{OUTPUT_DIR}/current_batch.json' LIVE_STREAM_FRAME_PATH = f'{OUTPUT_DIR}/live_frame.jpg' else: OUTPUT_DIR = os.getenv('OUTPUT_DIR', '/opt/jetson-counter') DB_PATH = os.getenv('DB_PATH', f'{OUTPUT_DIR}/jetson_counter.db') STATE_FILE = os.getenv('STATE_FILE', f'{OUTPUT_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') BATCH_MERGE_THRESHOLD_SECONDS = int(os.getenv('BATCH_MERGE_THRESHOLD_SECONDS', '300')) active_batch_info = None def get_counting_date(dt=None): if dt is None: dt = datetime.now() try: cutoff = datetime.strptime(DAILY_CUTOFF_TIME, "%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 init_db(): try: os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) os.makedirs(os.path.dirname(LIVE_STREAM_FRAME_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() print(f"[DB Info] Inisialisasi SQLite database berhasil: {DB_PATH}") except Exception as e: print(f"[DB Error] Gagal inisialisasi SQLite database: {e}") def get_next_batch_number(counting_date): try: conn = sqlite3.connect(DB_PATH) cur = conn.cursor() cur.execute(""" SELECT COALESCE(MAX(batch_number), 0) FROM batches WHERE counting_date = ? AND camera_name = ? AND object_label = ? """, (counting_date, CAMERA_NAME, OBJECT_LABEL)) row = cur.fetchone() conn.close() return row[0] + 1 except Exception as e: print(f"[DB Error] Gagal mendapatkan batch_number: {e}") return 1 def get_last_batch_info(counting_date): try: conn = sqlite3.connect(DB_PATH) cur = conn.cursor() cur.execute(""" SELECT batch_number, count, start_time, end_time FROM batches WHERE counting_date = ? AND camera_name = ? AND object_label = ? ORDER BY batch_number DESC LIMIT 1 """, (counting_date, CAMERA_NAME, OBJECT_LABEL)) row = cur.fetchone() conn.close() if row: return { "batch_number": row[0], "count": row[1], "start_time": row[2], "end_time": row[3] } except Exception as e: print(f"[DB Error] Gagal mendapatkan batch terakhir: {e}") return None def save_active_batch_state(): global active_batch_info if active_batch_info is None: try: if os.path.exists(STATE_FILE): os.remove(STATE_FILE) except Exception: pass return try: with open(STATE_FILE, 'w', encoding='utf-8') as f: json.dump(active_batch_info, f, indent=2, ensure_ascii=False) except Exception as e: print(f"[DB Error] Gagal menulis {STATE_FILE}: {e}") def finalize_batch(final_count, start_time_iso, end_time_iso): global active_batch_info if active_batch_info is None: return if final_count == 0: print(f"[BATCH] Batch #{active_batch_info.get('batch_number', 0)} bernilai 0 diabaikan (tidak disimpan ke database).") active_batch_info = None save_active_batch_state() return counting_date = active_batch_info["counting_date"] batch_num = active_batch_info["batch_number"] try: conn = sqlite3.connect(DB_PATH) cur = conn.cursor() # 1. Insert completed batch cur.execute(""" INSERT OR REPLACE INTO batches (counting_date, batch_number, camera_name, object_label, count, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?, ?) """, (counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL, final_count, start_time_iso, end_time_iso)) # 2. Update daily summaries cur.execute(""" SELECT SUM(count), COUNT(id) FROM batches WHERE counting_date = ? AND camera_name = ? AND object_label = ? """, (counting_date, CAMERA_NAME, OBJECT_LABEL)) sum_row = cur.fetchone() tot_count = sum_row[0] if sum_row[0] is not None else 0 tot_batches = sum_row[1] if sum_row[1] is not None else 0 cur.execute(""" INSERT OR REPLACE INTO daily_summaries (counting_date, camera_name, object_label, total_count, total_batches, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) """, (counting_date, CAMERA_NAME, OBJECT_LABEL, tot_count, tot_batches)) conn.commit() conn.close() 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}") active_batch_info = None save_active_batch_state() def write_live_frame(frame): try: os.makedirs(os.path.dirname(LIVE_STREAM_FRAME_PATH), exist_ok=True) tmp_path = LIVE_STREAM_FRAME_PATH.replace(".jpg", ".tmp.jpg") cv2.imwrite(tmp_path, frame, [cv2.IMWRITE_JPEG_QUALITY, 80]) os.replace(tmp_path, LIVE_STREAM_FRAME_PATH) except PermissionError: # Transient file lock on Windows when Flask dashboard reads it, safe to ignore pass except Exception as e: print(f"[ERROR] Gagal menulis live frame: {e}") def draw_annotations_on_frame(frame, bbox_list): try: h_f, w_f = frame.shape[:2] # 1. Draw left and right limits (vertical lines) cv2.line(frame, (left_limit, 0), (left_limit, h_f), (255, 0, 255), 2) cv2.line(frame, (right_limit, 0), (right_limit, h_f), (255, 0, 255), 2) # 2. Draw ZONA_PALET (Cyan) if len(ZONA_PALET) >= 3: cv2.polylines(frame, [ZONA_PALET], True, (255, 255, 0), 2) # 3. Draw ZONA_TRUCK (Yellow/Red based on system_state) if poly_truck is not None and not poly_truck.is_empty: pts = np.array(poly_truck.exterior.coords, dtype=np.int32) color_truck = (0, 204, 255) if system_state == STATE_WAITING_FOR_TRUCK else (0, 255, 0) cv2.polylines(frame, [pts], True, color_truck, 2) cv2.putText(frame, "ZONA TRUK BATCH", (pts[0][0], max(20, pts[0][1] - 8)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color_truck, 2) elif len(ZONA_TRUCK) >= 3: color_truck = (0, 204, 255) if system_state == STATE_WAITING_FOR_TRUCK else (0, 255, 0) cv2.polylines(frame, [ZONA_TRUCK], True, color_truck, 2) cv2.putText(frame, "ZONA TRUK BATCH", (ZONA_TRUCK[0][0], max(20, ZONA_TRUCK[0][1] - 8)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color_truck, 2) # 4. Draw active bboxes and points from bbox_list for item in bbox_list: try: if item.get("is_counted", False): continue color_str = item.get("color", "rgba(0, 255, 0, 1.0)") if color_str.startswith("rgba"): parts = color_str.replace("rgba(", "").replace(")", "").split(",") r, g, b = int(parts[0]), int(parts[1]), int(parts[2]) bgr_color = (b, g, r) else: bgr_color = (0, 255, 0) norm_box = item["bbox"] x1 = int(norm_box[0] * w_f) y1 = int(norm_box[1] * h_f) x2 = int(norm_box[2] * w_f) y2 = int(norm_box[3] * h_f) cv2.rectangle(frame, (x1, y1), (x2, y2), bgr_color, 2) cx, cy = int(item["centroid"][0] * w_f), int(item["centroid"][1] * h_f) # Gambar Point di Tengah BBox cv2.circle(frame, (cx, cy), 5, (0, 255, 255), -1) label = f"{item['status']} #{item['id']}" if item["id"] != 9999 else item["status"] cv2.putText(frame, label, (x1, max(15, y1 - 8)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, bgr_color, 2) # Jika ada entry_point (titik acuan awal & radius 50px) if item.get("entry_point") is not None: ex, ey = item["entry_point"] is_counted = item.get("is_counted", False) viz_color = (0, 255, 0) if is_counted else (0, 140, 255) # 1. Gambar Titik Acuan Awal cv2.circle(frame, (ex, ey), 4, viz_color, -1) # 2. Gambar Lingkaran Radius 50px cv2.circle(frame, (ex, ey), 50, viz_color, 2, lineType=cv2.LINE_AA) # 3. Garis hubung ke centroid aktif cv2.line(frame, (ex, ey), (cx, cy), viz_color, 1) # 4. Label Jarak dist_val = np.sqrt((cx - ex)**2 + (cy - ey)**2) dist_label = "COUNTED (+1)" if is_counted else f"{dist_val:.0f}/50px" cv2.putText(frame, dist_label, (ex - 20, max(15, ey - 10)), cv2.FONT_HERSHEY_SIMPLEX, 0.45, viz_color, 2) except Exception: pass # 5. Draw active Duplicate Radius Circles on Port 8000 live stream (terkini 3.0 detik) if 'counted_sack_positions' in globals() and counted_sack_positions: rad_vis = DUPLICATE_CIRCLE_RADIUS if ('DUPLICATE_CIRCLE_RADIUS' in globals() and DUPLICATE_CIRCLE_RADIUS > 0) else 35 now_t = time.time() active_circles = [p for p in counted_sack_positions if len(p) < 3 or (now_t - p[2]) <= 3.0] for pos_item in active_circles: px, py = pos_item[0], pos_item[1] tid = pos_item[3] if len(pos_item) > 3 else 0 cv2.circle(frame, (int(px), int(py)), int(rad_vis), (0, 255, 255), 2, lineType=cv2.LINE_AA) cv2.circle(frame, (int(px), int(py)), 4, (0, 255, 0), -1) cv2.putText(frame, f"DEDUP #{tid}", (int(px) - 25, max(15, int(py) - int(rad_vis) - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 255, 255), 1) cv2.putText(frame, f"RADIUS DEDUP: {rad_vis}px", (w_f - 270, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 255, 255), 2) # 6. Draw HUD stats on top left total_in = metrics.get('total_masuk', 0) total_out = metrics.get('total_keluar', 0) net_cnt = total_in - total_out cv2.putText(frame, f"STATUS: {system_state}", (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2) cv2.putText(frame, f"IN: {total_in} OUT: {total_out} NET: {net_cnt}", (20, 75), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) cv2.putText(frame, f"FPS: {current_fps:.2f}", (20, 110), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 204, 0), 2) except Exception: pass # --- RUNNING LOCALLY (Colab patches removed) --- # ----------------------------------------------- # ===================================================================== # 0. PARAMETER KALIBRASI & STATE MACHINE # ===================================================================== MIN_VALID_AREA_REF = 15000 MIN_VALID_AREA = 15000 JARAK_ABSORBSI_GHOST = 50 # --- Logika masuk/keluar berbasis overlap + delay --- ENTRY_OVERLAP_THRESHOLD = 0.20 # 20% EXIT_OVERLAP_THRESHOLD = 0.05 # 5% CONFIRM_DELAY_SEC = 0.5 # delay masuk EXIT_CONFIRM_DELAY_SEC = 6.0 # delay keluar COUNTED_DISPLAY_TIMEOUT_SEC = 0.5 # durasi tampil kotak hijau setelah terhitung # --- Parameter Anti-Double Count (Spasial) --- JARAK_TOLERANSI_DUPLIKAT_REF = 80 JARAK_TOLERANSI_DUPLIKAT = 80 TOLERANSI_FRAME_HILANG = 1200 # 1200 frame untuk Re-ID lost MAX_REID_TRANSIT_DISTANCE_REF = 400 MAX_REID_TRANSIT_DISTANCE = 400 # Max pixel distance for Re-ID # --- Parameter Bbox Muncul Tiba-tiba & Transit --- MIN_DISPLACEMENT_START_IN_TRUCK = 15 MIN_LINEARITY_START_IN_TRUCK = 0.70 MIN_DISPLACEMENT_COUNTING_ZONE = 12 MIN_DY_COUNTING_ZONE = -3 # --- Parameter Lingkaran Duplikat Statis --- DUPLICATE_CIRCLE_RADIUS_REF = 35 DUPLICATE_CIRCLE_RADIUS = 35 SHOW_ALL_BBOXES = False counted_sack_positions = [] CIRCLE_STAY_TIMEOUT_SEC = 10.0 INFERENCE_STRIDE = 2 CAMERA_NOISE_DEADBAND = 50 # pixels deadband for static checks (diperbesar ke 50 sesuai permintaan user) # --- Duo-Model Batching State Machine --- STATE_WAITING_FOR_TRUCK = "WAITING_FOR_TRUCK" STATE_COUNTING_SACKS = "COUNTING_SACKS" STATE_TRUCK_FULL = "TRUCK_FULL" STATE_TRUCK_LEAVING = "TRUCK_LEAVING" system_state = STATE_WAITING_FOR_TRUCK truck_static_frames = 0 truck_initial_bbox = None # [x1, y1, x2, y2] truck_entry_point = None # (ex_t, ey_t) entry_points = {} # track_id -> (ex, ey) static_sack_visuals = {} # track_id -> HSV histogram representation static_frames = defaultdict(int) moving_frames = defaultdict(int) sack_class_id = 0 # Default class ID for sack counted_sacks = {} lost_counted_sacks = {} all_counted_sacks_map = {} last_seen_near_person_frame = {} blocked_due_to_duplicate = {} # --- Model Paths --- # Model gabungan karung + truk (menggantikan truck-detector dan best) if os.name == 'nt': COMBINED_MODEL_PATH = r"D:\Belajar\Menghitung karung\model karung + truk.pt" else: COMBINED_MODEL_PATH = "model_karung_truk.engine" if os.path.exists("model_karung_truk.engine") else "model_karung_truk.pt" # ===================================================================== # ===================================================================== # 1. KONFIGURASI KOORDINAT ZONA # ===================================================================== width = 1280 height = 720 scale_x = 1.0 scale_y = 1.0 def get_bottom_quarter(pts): if len(pts) < 4: return np.array([], dtype=np.int32) pts_list = pts.tolist() if isinstance(pts, np.ndarray) else list(pts) sorted_by_y = sorted(pts_list, key=lambda p: p[1]) tops = sorted_by_y[:2] bottoms = sorted_by_y[2:] tops_sorted = sorted(tops, key=lambda p: p[0]) tl = np.array(tops_sorted[0], dtype=np.float32) tr = np.array(tops_sorted[1], dtype=np.float32) bottoms_sorted = sorted(bottoms, key=lambda p: p[0]) bl = np.array(bottoms_sorted[0], dtype=np.float32) br = np.array(bottoms_sorted[1], dtype=np.float32) p_left = tl * 0.5 + bl * 0.5 p_right = tr * 0.5 + br * 0.5 return np.array([ [int(p_left[0]), int(p_left[1])], [int(p_right[0]), int(p_right[1])], [int(tr[0]), int(tr[1])], [int(tl[0]), int(tl[1])] ], dtype=np.int32) 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, CONFIRM_DELAY_SEC, EXIT_CONFIRM_DELAY_SEC global ZONA_COUNTING_REF, left_limit_ref, right_limit_ref, EXTERNAL_STREAM_URL_REF 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.get('palet', []), dtype=np.int32) ZONA_TRUCK_REF = np.array(data.get('truck', []), dtype=np.int32) ZONA_COUNTING_REF = get_bottom_quarter(ZONA_TRUCK_REF) left_limit_ref = float(data.get('left_limit', 0.05)) right_limit_ref = float(data.get('right_limit', 0.95)) 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) CONFIRM_DELAY_SEC = data.get('confirm_delay_sec', 0.5) EXIT_CONFIRM_DELAY_SEC = data.get('exit_confirm_delay_sec', 6.0) EXTERNAL_STREAM_URL_REF = data.get('external_stream_url', 'http://192.168.192.96:8888/cam/') if not EXTERNAL_STREAM_URL_REF: EXTERNAL_STREAM_URL_REF = 'http://192.168.192.96:8888/cam/' 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) ZONA_COUNTING_REF = get_bottom_quarter(ZONA_TRUCK_REF) left_limit_ref = 0.05 right_limit_ref = 0.95 GARIS_COUNTING_REF = ZONA_TRUCK_REF.copy() load_zones() 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 ZONA_PALET = ZONA_PALET_REF.copy() ZONA_TRUCK = ZONA_TRUCK_REF.copy() ZONA_COUNTING = ZONA_COUNTING_REF.copy() if len(ZONA_COUNTING_REF) > 0 else np.array([], dtype=np.int32) 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 poly_counting = Polygon(ZONA_COUNTING) if len(ZONA_COUNTING) >= 3 else None left_limit = int(left_limit_ref * 1280) right_limit = int(right_limit_ref * 1280) from shapely.geometry import LineString if len(ZONA_COUNTING) >= 4: line_counting = LineString([ZONA_COUNTING[3], ZONA_COUNTING[2]]) else: 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) blocked_without_counting = defaultdict(bool) track_is_valid_bag = 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 EXTERNAL_STREAM_URL_REF = "http://192.168.192.96:8888/cam/" current_fps = 0.0 save_queue = queue.Queue(maxsize=100) DASHBOARD_HTML = "" class RTSPStreamReader: def __init__(self, source_path): self.source_path = source_path self.cap = None # 1. Pipeline GStreamer H.265 (Jetson NVDEC) pipeline_h265 = ( f"rtspsrc location=\"{source_path}\" protocols=tcp latency=0 ! " "rtph265depay ! h265parse ! nvv4l2decoder ! " "nvvidconv ! video/x-raw, format=BGRx ! " "videoconvert ! video/x-raw, format=BGR ! appsink drop=1" ) # 2. Pipeline GStreamer H.264 (Jetson NVDEC) pipeline_h264 = ( f"rtspsrc location=\"{source_path}\" protocols=tcp latency=0 ! " "rtph264depay ! h264parse ! nvv4l2decoder ! " "nvvidconv ! video/x-raw, format=BGRx ! " "videoconvert ! video/x-raw, format=BGR ! appsink drop=1" ) # Mencoba membuka dengan GStreamer H.265 print("[INFO] Mencoba GStreamer H.265 NVDEC di Jetson...") self.cap = cv2.VideoCapture(pipeline_h265, cv2.CAP_GSTREAMER) # Jika gagal, coba H.264 if self.cap is None or not self.cap.isOpened(): print("[INFO] GStreamer H.265 gagal, mencoba GStreamer H.264 NVDEC...") self.cap = cv2.VideoCapture(pipeline_h264, cv2.CAP_GSTREAMER) # Fallback ke default CPU OpenCV jika GStreamer tidak terpasang/gagal if self.cap is None or not self.cap.isOpened(): print("[WARNING] GStreamer NVDEC gagal dibuka, menggunakan backend default OpenCV (CPU)...") 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() # ===================================================================== # 2.5 UTILITY AKURASI (HSV HISTOGRAM & 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)) hsv = cv2.cvtColor(resized, cv2.COLOR_BGR2HSV) # Ekstrak histogram H-S untuk ketahanan terhadap pencahayaan hist = cv2.calcHist([hsv], [0, 1], None, [16, 16], [0, 180, 0, 256]) cv2.normalize(hist, hist, 0, 1, cv2.NORM_MINMAX) # Fitur tekstur/struktur menggunakan grayscale thumbnail gray = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY) return hist, gray except Exception as e: print(f"[ERROR get_visual_features] {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 tekstur/struktur menggunakan Template Matching Normalized Cross-Correlation (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 # Rata-rata tertimbang return 0.6 * color_sim + 0.4 * struct_sim except Exception: return 0.0 def get_min_valid_area(cy): """Menghitung batas luas area minimum secara dinamis berdasarkan perspektif Y.""" global scale_x, scale_y 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) # ===================================================================== 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, blocked_without_counting, track_is_valid_bag, static_frames, moving_frames, counted_sacks, lost_counted_sacks, blocked_due_to_duplicate 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) is_consistent_direction = (current_centroid[1] < lc[1] + (50 * scale_y)) max_dist = MAX_REID_TRANSIT_DISTANCE * (1.0 + 0.01 * frame_diff) if (dist < max_dist) 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) blocked_without_counting[new_id] = info.get('blocked_without_counting', False) track_is_valid_bag[new_id] = info.get('is_valid_bag', False) blocked_due_to_duplicate[new_id] = info.get('blocked_due_to_duplicate', False) # Pulihkan state stabilitas static_frames[new_id] = info.get('static_frames', 0) moving_frames[new_id] = info.get('moving_frames', 0) # Pulihkan posisi terhitung aktif if closest_old_id in lost_counted_sacks: val = lost_counted_sacks.pop(closest_old_id) counted_sacks[new_id] = (val[0], val[1]) 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) blocked_without_counting[new_id] = info.get('blocked_without_counting', False) track_is_valid_bag[new_id] = info.get('is_valid_bag', False) blocked_due_to_duplicate[new_id] = info.get('blocked_due_to_duplicate', False) # Pulihkan state stabilitas static_frames[new_id] = info.get('static_frames', 0) moving_frames[new_id] = info.get('moving_frames', 0) 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, frame=None, bbox=None): """ Mengecek apakah bbox baru muncul di titik yang sangat dekat dengan karung yang SUDAH DIHITUNG (locked), baik yang sedang aktif maupun yang baru hilang. Menggunakan visual similarity (HSV Histogram & NCC Grayscale) untuk membedakan penumpukan karung. """ 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 # Ekstrak fitur visual untuk deteksi baru new_feat = None if frame is not None and bbox is not None: x1, y1, x2, y2 = bbox crop = frame[max(0, int(y1)):min(frame.shape[0], int(y2)), max(0, int(x1)):min(frame.shape[1], int(x2))] new_feat = get_visual_features(crop) # 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 = (dist < 120) or (area_ratio >= 0.40) if is_similar_size and dist < JARAK_TOLERANSI_DUPLIKAT: # Lakukan verifikasi visual jika fitur tersedia old_feat = static_sack_visuals.get(active_id) if new_feat is not None and old_feat is not None: sim = compare_visual_similarity(new_feat, old_feat) if sim > 0.85: return True else: # Fallback jika tidak ada data visual, anggap duplikat secara spasial 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 = (dist < 120) or (area_ratio >= 0.40) if is_similar_size and dist < JARAK_TOLERANSI_DUPLIKAT: # Lakukan verifikasi visual jika fitur tersedia old_feat = static_sack_visuals.get(lost_id) if new_feat is not None and old_feat is not None: sim = compare_visual_similarity(new_feat, old_feat) if sim > 0.85: return True else: # Fallback 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 _filter_sacks_in_roi(detections, roi): """Keep only sacks whose centroid X falls within the truck ROI.""" if roi is None: return [] return [ d for d in detections if roi.contains_x((d.bbox[0] + d.bbox[2]) / 2.0) ] def run_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, blocked_without_counting, track_is_valid_bag global current_fps, INFERENCE_STRIDE, all_counted_sacks_map, last_seen_near_person_frame, blocked_due_to_duplicate global width, height, CONFIRM_DELAY_SEC, EXIT_CONFIRM_DELAY_SEC global active_batch_info, system_state global DUPLICATE_CIRCLE_RADIUS, MIN_VALID_AREA, JARAK_TOLERANSI_DUPLIKAT, MAX_REID_TRANSIT_DISTANCE # 1. Silencing YOLO logs from ultralytics.utils import LOGGER import logging LOGGER.setLevel(logging.WARNING) INFERENCE_STRIDE = inference_stride saver = None saver_thread = None save_queue = None # Reset lists and dicts for k in metrics: metrics[k] = 0 # Device device = 'cuda' if torch.cuda.is_available() else 'cpu' print(f"[INFO] Device inferensi diset ke: {device}") # Reader is_stream = any(str(source_path).startswith(p) for p in ["rtsp://", "rtmp://", "http://", "https://"]) if is_stream: print("[INFO] Membuka RTSP stream menggunakan Threaded GStreamer NVDEC 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 width = 1280 height = 720 fps = cap.get(cv2.CAP_PROP_FPS) if fps <= 0 or np.isnan(fps): fps = 25.0 print(f"[INFO] Resolusi Asli: {int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))}x{int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))} @ {fps:.1f} FPS (Diresize ke 1280x720 untuk koordinat tetap)") # Initialize components from repo rafan using shared model print(f"[INFO] Memuat model YOLO gabungan dari: {model_path}") shared_model = YOLO(model_path) # Warm-up model to initialize CUDA/TensorRT execution context and prevent segfaults on tracking print("[INFO] Melakukan warm-up model YOLO...") dummy_frame = np.zeros((720, 1280, 3), dtype=np.uint8) _ = shared_model(dummy_frame, imgsz=640, device="cuda", verbose=False) print("[INFO] Warm-up model selesai.") truck_detector = TruckDetector(shared_model, 0.6) tracker = ByteTrackTracker(shared_model, 0.6) stabilizer = BboxStabilizer( ema_alpha=0.35, max_hold_frames=10, max_height_ratio=1.5, min_height_ratio=0.70, ) # ================================================================ # HARDCODED COORDINATES FOR LOCAL (1280x720) # Truck detector hanya untuk batch lifecycle (deteksi truk datang/pergi) # Area di bawah ini FIXED, tidak tergantung deteksi truk. # ================================================================ # Calculate scale factors from native 1920x1080 to 1280x720 scale_x = 1280.0 / 1920.0 scale_y = 720.0 / 1080.0 # 1. Detection Area (4-point Polygon) detection_poly_pts = [ [int(574 * scale_x), int(50 * scale_y)], [int(586 * scale_x), int(1077 * scale_y)], [int(1418 * scale_x), int(1076 * scale_y)], [int(1397 * scale_x), int(50 * scale_y)], ] detection_polygon = Polygon(detection_poly_pts) # 2. Count Line coordinates static_line_y = int(330 * scale_y) static_line_x_start = int(577 * scale_x) static_line_x_end = int(1401 * scale_x) # 3. Truck Area (4-point Polygon for presence check) truck_poly_pts = [ [int(600 * scale_x), int(385 * scale_y)], [int(609 * scale_x), int(1076 * scale_y)], [int(1404 * scale_x), int(1078 * scale_y)], [int(1381 * scale_x), int(343 * scale_y)], ] truck_polygon = Polygon(truck_poly_pts) from src.truck_roi import TruckROI static_roi = TruckROI( x1=int(600 * scale_x), y1=int(343 * scale_y), x2=int(1404 * scale_x), y2=int(1078 * scale_y), line_y=static_line_y, confidence=1.0 ) 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 counter = LineCrossCounter( line_y=static_line_y, line_x_start=static_line_x_start, line_x_end=static_line_x_end, margin=20, dedup_radius=float(DUPLICATE_CIRCLE_RADIUS), ) batch_mgr = BatchLifecycleManager( stabilize_seconds=0.0, # Start batch instantly when triggered by crossing stabilize_threshold_px=9999.0, # Disable displacement threshold check sack_idle_timeout=10.0, # Waiting state timeout min_batch_duration=5.0, # Short min duration truck_gone_tolerance=15.0, # Time to wait (in seconds) after all sacks disappear before closing batch ) dashboard = DashboardOverlay() global active_batch_info active_batch_info = None save_active_batch_state() frame_idx = 0 last_time = time.time() current_fps = 0.0 TRUCK_DET_INTERVAL_IDLE = 5 # Check truck every 5 frames when IDLE TRUCK_DET_INTERVAL_STABILIZING = 1 # Check truck every frame when STABILIZING try: while cap.isOpened(): ret, frame = cap.read() if not ret: if is_stream: time.sleep(0.01) continue else: break if frame is not None: frame = cv2.resize(frame, (1280, 720)) timestamp = time.time() frame_idx += 1 if max_frames is not None and frame_idx > max_frames: break # Track previous state for transition detection prev_active = batch_mgr.is_active prev_state = batch_mgr.state # ================================================================ # STATE-DRIVEN MODEL SWITCHING # ================================================================ tracked_sacks = [] events = [] # Run tracker and stabilizer for sacks on every frame if INFERENCE_STRIDE <= 1 or frame_idx % INFERENCE_STRIDE == 0 or 'last_raw_tracked_all' not in locals(): raw_tracked_all = tracker.update(frame, []) last_raw_tracked_all = raw_tracked_all else: raw_tracked_all = last_raw_tracked_all # Filter sack detections raw_tracked_sacks = [d for d in raw_tracked_all if d.class_name == "sack"] stable = stabilizer.update(raw_tracked_sacks) # Filter using Detection Area (4-point Polygon) stable = [ d for d in stable if detection_polygon.contains(Point((d.bbox[0] + d.bbox[2]) / 2.0, (d.bbox[1] + d.bbox[3]) / 2.0)) ] # Count sacks currently visible in the bottom 70% of truck area (for batch start/end condition) min_ty, max_ty = truck_polygon.bounds[1], truck_polygon.bounds[3] truck_height = max_ty - min_ty truck_cutoff_y = min_ty + 0.30 * truck_height sacks_in_truck_area = 0 for d in stable: cx = (d.bbox[0] + d.bbox[2]) / 2.0 cy = (d.bbox[1] + d.bbox[3]) / 2.0 if truck_polygon.contains(Point(cx, cy)) and cy >= truck_cutoff_y: sacks_in_truck_area += 1 # Run line crossing counter on every frame tracked_sacks = _filter_sacks_in_roi(stable, static_roi) events = counter.update(tracked_sacks) has_crossing = len(events) > 0 # --- Sack-driven Batch Lifecycle Transitions --- if batch_mgr.state in ("IDLE", "TRUCK_STABILIZING"): # Trigger batch start ONLY when a sack actually crosses the line batch_mgr.update_truck(has_crossing, (0.0, 0.0), timestamp) if batch_mgr.state in ("COUNTING_SACKS", "WAITING_FOR_ACTIVITY"): # Opsi 3: Toleransi dinamis berdasarkan jumlah karung terhitung current_count = counter.loading_count if current_count < 20: batch_mgr._truck_gone_tolerance = 300.0 # 5 menit jika < 20 karung elif current_count >= 40: batch_mgr._truck_gone_tolerance = 60.0 # 1 menit jika >= 40 karung else: batch_mgr._truck_gone_tolerance = 120.0 # 2 menit default jika di antara 20 - 39 batch_mgr.update_sacks( has_crossing_event=has_crossing, sacks_in_area_count=sacks_in_truck_area, timestamp=timestamp, loading_count=counter.loading_count, unloading_count=counter.unloading_count, ) # If waiting, check if sacks are completely gone to finalize batch if batch_mgr.state == "WAITING_FOR_ACTIVITY": batch_mgr.update_truck(sacks_in_truck_area > 0, None, timestamp) # Process crossing events for ev in events: print(f"[KARUNG] Karung #{ev['track_id']} masuk.") print(f"[TOTAL] Total karung saat ini: {counter.loading_count}.") if 'cx' in ev and 'cy' in ev: counted_sack_positions.append((ev['cx'], ev['cy'], time.time(), ev['track_id'])) if active_batch_info is not None: active_batch_info["count"] = counter.loading_count active_batch_info["last_detection_time"] = datetime.now().isoformat() save_active_batch_state() # ================================================================ # BATCH TRANSITION HANDLING # ================================================================ # ROI and counting line are always visible roi = static_roi # Batch just started (STABILIZING -> COUNTING) if batch_mgr.is_active and not prev_active: counting_date = get_counting_date() last_batch = get_last_batch_info(counting_date) # Disable batch resume/merge logic. Every session is a brand new batch. should_resume = False if should_resume: batch_num = last_batch["batch_number"] prev_count = last_batch["count"] start_iso = last_batch["start_time"] # Set the counter's starting count counter._loading_count = prev_count counter._unloading_count = 0 # Assuming loading session # Resume the batch in the lifecycle manager try: start_dt = datetime.fromisoformat(start_iso) start_ts = start_dt.timestamp() except Exception: start_ts = timestamp batch_mgr.resume_batch( batch_id=batch_num, start_time=start_ts, loading_count=prev_count, unloading_count=0 ) active_batch_info = { "counting_date": counting_date, "batch_number": batch_num, "count": prev_count, "start_time": start_iso, "last_detection_time": datetime.now().isoformat() } save_active_batch_state() print(f"[BATCH] Melanjutkan sesi batch #{batch_num} (selisih waktu: {gap_seconds:.1f}s < {BATCH_MERGE_THRESHOLD_SECONDS}s). Mulai dari {prev_count} karung.") else: batch_num = get_next_batch_number(counting_date) now_iso = datetime.now().isoformat() active_batch_info = { "counting_date": counting_date, "batch_number": batch_num, "count": 0, "start_time": now_iso, "last_detection_time": now_iso } save_active_batch_state() print(f"[BATCH] Sesi batch #{batch_num} dimulai.") system_state = STATE_COUNTING_SACKS # Batch just ended (WAITING -> IDLE, truck left) elif not batch_mgr.is_active and prev_active: final_count = counter.loading_count start_iso = active_batch_info["start_time"] if active_batch_info else datetime.now().isoformat() end_iso = datetime.now().isoformat() batch_num = active_batch_info["batch_number"] if active_batch_info else 0 finalize_batch(final_count, start_iso, end_iso) print(f"[BATCH] Truk pergi. Sesi batch #{batch_num} selesai. Total karung: {final_count}.") system_state = STATE_WAITING_FOR_TRUCK # Reset counter and trackers for next batch counter.reset() stabilizer.reset() # Update system_state for display if batch_mgr.state == "TRUCK_STABILIZING" and prev_state != "TRUCK_STABILIZING": system_state = "TRUCK_STABILIZING" elif batch_mgr.state == "WAITING_FOR_ACTIVITY" and prev_state != "WAITING_FOR_ACTIVITY": system_state = "WAITING_FOR_ACTIVITY" elif batch_mgr.state == "COUNTING_SACKS" and prev_state == "WAITING_FOR_ACTIVITY": system_state = STATE_COUNTING_SACKS # Resumed from waiting # Sync counts to metrics so APIs get correct results metrics['total_masuk'] = counter.loading_count metrics['total_keluar'] = counter.unloading_count # 4. Draw Dashboard visualization overlay viz = dashboard.draw( frame=frame, detections=stable if (batch_mgr.is_active and SHOW_ALL_BBOXES and 'stable' in dir()) else tracked_sacks, roi=roi, loading_count=counter.loading_count, unloading_count=counter.unloading_count, batch_id=batch_mgr.current_batch_id, history=batch_mgr.history, system_state=batch_mgr.state, batch_duration=batch_mgr.batch_duration, idle_timer=batch_mgr.time_since_last_sack_activity, stabilize_progress=batch_mgr.stabilize_progress, waiting_duration=batch_mgr.waiting_duration, ) # Draw active Duplicate Radius Circles on viz frame (terkini 3.0 detik) if 'counted_sack_positions' in globals() and counted_sack_positions: rad_vis = DUPLICATE_CIRCLE_RADIUS if ('DUPLICATE_CIRCLE_RADIUS' in globals() and DUPLICATE_CIRCLE_RADIUS > 0) else 60 now_t = time.time() # Clean up expired entries in-place to avoid memory accumulation counted_sack_positions[:] = [p for p in counted_sack_positions if len(p) >= 3 and (now_t - p[2]) <= 3.0] for pos_item in counted_sack_positions: px, py = pos_item[0], pos_item[1] tid = pos_item[3] if len(pos_item) > 3 else 0 cv2.circle(viz, (int(px), int(py)), int(rad_vis), (0, 255, 255), 2, lineType=cv2.LINE_AA) cv2.circle(viz, (int(px), int(py)), 4, (0, 255, 0), -1) cv2.putText(viz, f"DEDUP #{tid}", (int(px) - 25, max(15, int(py) - int(rad_vis) - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 255, 255), 1) cv2.putText(viz, f"RADIUS DEDUP: {rad_vis}px", (viz.shape[1] - 270, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 255, 255), 2) # Write live frame to RAM disk for Flask port 5000 if frame_idx % 2 == 0: write_live_frame(viz) # Compute FPS every 25 frames if frame_idx % 25 == 0: elapsed = time.time() - last_time current_fps = 25.0 / elapsed if elapsed > 0 else 0 last_time = time.time() try: status_file = os.getenv('LIVE_STATUS_FILE', '/dev/shm/jetson-counter/live_status.json' if os.name != 'nt' else 'd:/Belajar/menghitung karung/live_status.json') os.makedirs(os.path.dirname(status_file), exist_ok=True) with open(status_file, 'w') as f: json.dump({"fps": round(current_fps, 1)}, f) except Exception: pass finally: # 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() net_count = metrics['total_masuk'] - metrics['total_keluar'] 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__": MODEL_FILE = COMBINED_MODEL_PATH # Default to local sample video 0727.mp4 on Windows SOURCE_INPUT = "07277.mp4" if (os.path.exists("07277.mp4") and os.name == 'nt') else "rtsp://192.168.192.96:8554/cam" OUTPUT_JSON = "hasil_perhitungan.json" try: run_prediction( model_path=MODEL_FILE, source_path=SOURCE_INPUT, output_json_path=OUTPUT_JSON, max_frames=None ) 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)