843 lines
40 KiB
Python
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.")
|