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

843 lines
40 KiB
Python

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.")