import cv2
import datetime
import os
import csv
from PIL import Image, ImageTk
import tkinter as tk
from tkinter import ttk
class MotionDetectorApp:
def __init__(self, window, window_title):
self.window = window
self.window.title(window_title)
self.window.geometry("1280x820")
self.window.configure(bg="#2c3e50")
# Inisialisasi Kamera Default (Index 0)
self.selected_camera_id = 0
self.cap = cv2.VideoCapture(self.selected_camera_id, cv2.CAP_DSHOW)
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
self.fps = self.cap.get(cv2.CAP_PROP_FPS)
if self.fps == 0 or self.fps != self.fps:
self.fps = 20.0
self.frame_width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
self.frame_height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
self.total_pixels = self.frame_width * self.frame_height
# Variabel Kontrol Sesi & Perekaman
self.is_started = False
self.out = None
self.is_recording = False
self.no_motion_frames = 0
self.MAX_NO_MOTION_FRAMES = 30
self.camera_distance = 5.0 # Default jarak kamera (meter)
self.csv_file = None
self.csv_writer = None
self.csv_filename = ""
# StringVar untuk Dashboard
self.log_thresh = tk.StringVar(value="25")
self.log_distance = tk.StringVar(value="5.0 m")
self.log_pixel_pct = tk.StringVar(value="0.00 %")
self.log_est_size = tk.StringVar(value="0.00 m²")
self.log_contours = tk.StringVar(value="0")
self.log_timestamp = tk.StringVar(value="-")
self.log_status = tk.StringVar(value="IDLE (Menunggu Mulai)")
# Folder penyimpanan
for folder in ["recordings", "logs"]:
if not os.path.exists(folder):
os.makedirs(folder)
# Frame awal untuk background differencing
ret, self.frame1 = self.cap.read()
if ret:
self.frame1 = cv2.convertScaleAbs(self.frame1, alpha=1.0, beta=0)
self.gray1 = cv2.cvtColor(self.frame1, cv2.COLOR_BGR2GRAY)
self.gray1 = cv2.GaussianBlur(self.gray1, (21, 21), 0)
else:
self.gray1 = None
# --- TATA LETAK GUI (UI LAYOUT) ---
# Container Utama untuk 2 Jendela Video (Kiri)
video_container = tk.Frame(window, bg="#2c3e50")
video_container.pack(side=tk.LEFT, padx=10, pady=10)
# 1. Canvas Live View Asli
live_frame = tk.LabelFrame(video_container, text="1. Live View Kamera", fg="white", bg="#2c3e50", font=("Arial", 10, "bold"))
live_frame.pack(side=tk.TOP, pady=5)
self.canvas_live = tk.Canvas(live_frame, width=480, height=360, bg="black")
self.canvas_live.pack(padx=5, pady=5)
# 2. Canvas Citra Hitam-Putih (Threshold)
thresh_frame = tk.LabelFrame(video_container, text="2. Citra Deteksi (Hitam Putih)", fg="white", bg="#2c3e50", font=("Arial", 10, "bold"))
thresh_frame.pack(side=tk.TOP, pady=5)
self.canvas_thresh = tk.Canvas(thresh_frame, width=480, height=360, bg="black")
self.canvas_thresh.pack(padx=5, pady=5)
# Frame Kanan: Panel Kontrol, Live Data Dashboard, & Table Preview
control_frame = tk.Frame(window, bg="#34495e", padx=12, pady=12, relief=tk.RAISED, bd=2)
control_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=10, pady=10)
tk.Label(control_frame, text="RISET MOTION DETECTOR - zidCreative R&D", fg="white", bg="#34495e", font=("Arial", 11, "bold")).pack(pady=3)
# --- KOTAK PEMBATAS (LABEL FRAME) UNTUK SLIDER ---
config_frame = tk.LabelFrame(control_frame, text="Konfigurasi pra-pengamatan", fg="white", bg="#34495e", font=("Arial", 9, "bold"), padx=8, pady=6)
config_frame.pack(fill=tk.X, pady=4)
# --- PENGATURAN KAMERA & JARAK ---
top_config_frame = tk.Frame(config_frame, bg="#34495e")
top_config_frame.pack(fill=tk.X, pady=3)
# Dropdown Kamera
cam_box = tk.Frame(config_frame, bg="#34495e")
cam_box.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5))
tk.Label(cam_box, text="Kamera (Index):", fg="white", bg="#34495e", font=("Arial", 8, "bold")).pack(anchor="w")
self.cam_var = tk.StringVar(value="0")
self.cam_dropdown = ttk.Combobox(cam_box, textvariable=self.cam_var, values=["0", "1", "2"], state="readonly", font=("Arial", 9, "bold"))
self.cam_dropdown.pack(fill=tk.X, pady=2)
self.cam_dropdown.bind("<<ComboboxSelected>>", self.change_camera)
# Input Jarak
dist_box = tk.Frame(config_frame, bg="#34495e")
dist_box.pack(side=tk.RIGHT, fill=tk.X, expand=True, padx=(5, 0))
tk.Label(dist_box, text="Jarak Latar (m):", fg="white", bg="#34495e", font=("Arial", 8, "bold")).pack(anchor="w")
self.distance_entry = tk.Entry(dist_box, font=("Arial", 9, "bold"), justify="center")
self.distance_entry.insert(0, "5.0")
self.distance_entry.pack(fill=tk.X, pady=2)
# --- TOMBOL KONTROL SESI (MULAI & SELESAI) ---
btn_container = tk.Frame(control_frame, bg="#34495e")
btn_container.pack(fill=tk.X, pady=4)
self.btn_start = tk.Button(btn_container, text="▶ Mulai Deteksi", bg="#27ae60", fg="white", font=("Arial", 9, "bold"), command=self.start_session)
self.btn_start.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(0, 3))
self.btn_stop = tk.Button(btn_container, text="⏹ Selesai", bg="#c0392b", fg="white", font=("Arial", 9, "bold"), state=tk.DISABLED, command=self.stop_session)
self.btn_stop.pack(side=tk.RIGHT, expand=True, fill=tk.X, padx=(3, 0))
# --- KOTAK PEMBATAS (LABEL FRAME) UNTUK SLIDER ---
slider_frame = tk.LabelFrame(control_frame, text="Parameter Kontrol & Kalibrasi", fg="white", bg="#34495e", font=("Arial", 9, "bold"), padx=8, pady=6)
slider_frame.pack(fill=tk.X, pady=4)
# --- LAYOUT SLIDER 2 KOLOM (KIRI: Threshold & MinArea | KANAN: Brightness) ---
sliders_container = tk.Frame(slider_frame, bg="#34495e")
sliders_container.pack(fill=tk.X, pady=2)
# Kolom Kiri (Threshold & MinArea)
col_left = tk.Frame(sliders_container, bg="#34495e")
col_left.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 5))
tk.Label(col_left, text="Sensitivitas Threshold:", fg="white", bg="#34495e", anchor="w", font=("Arial", 8)).pack(fill=tk.X)
self.thresh_slider = ttk.Scale(col_left, from_=0, to=255, orient=tk.HORIZONTAL, command=self.update_thresh_label)
self.thresh_slider.set(25)
self.thresh_slider.pack(fill=tk.X)
self.thresh_val_label = tk.Label(col_left, text="Nilai: 25", fg="#1abc9c", bg="#34495e", font=("Arial", 8), anchor="w")
self.thresh_val_label.pack(fill=tk.X, pady=(0, 4))
tk.Label(col_left, text="Minimum Area Objek:", fg="white", bg="#34495e", anchor="w", font=("Arial", 8)).pack(fill=tk.X)
self.area_slider = ttk.Scale(col_left, from_=100, to=10000, orient=tk.HORIZONTAL, command=self.update_area_label)
self.area_slider.set(1000)
self.area_slider.pack(fill=tk.X)
self.area_val_label = tk.Label(col_left, text="Nilai: 1000", fg="#1abc9c", bg="#34495e", font=("Arial", 8), anchor="w")
self.area_val_label.pack(fill=tk.X)
# Kolom Kanan (Brightness / Kecerahan)
col_right = tk.Frame(sliders_container, bg="#34495e")
col_right.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(5, 0))
tk.Label(col_right, text="Kecocokan Kecerahan:", fg="white", bg="#34495e", anchor="w", font=("Arial", 8)).pack(fill=tk.X)
self.brightness_slider = ttk.Scale(col_right, from_=-100, to=100, orient=tk.HORIZONTAL, command=self.update_brightness_label)
self.brightness_slider.set(0)
self.brightness_slider.pack(fill=tk.X)
self.brightness_val_label = tk.Label(col_right, text="Nilai: 0 (Normal)", fg="#1abc9c", bg="#34495e", font=("Arial", 8), anchor="w")
self.brightness_val_label.pack(fill=tk.X, pady=(0, 20)) # Menyesuaikan tinggi dengan kolom kiri
# --- LIVE DATA LOGGING DASHBOARD ---
log_frame = tk.LabelFrame(control_frame, text="Live Data Metrik (Kalkulasi Jarak)", fg="white", bg="#34495e", font=("Arial", 9, "bold"), padx=6, pady=4)
log_frame.pack(fill=tk.X, pady=3)
def add_row(parent, label_text, var_ref, row_idx):
lbl_title = tk.Label(parent, text=label_text, fg="#bdc3c7", bg="#34495e", anchor="w", font=("Arial", 8))
lbl_title.grid(row=row_idx, column=0, sticky="w", pady=1)
lbl_val = tk.Label(parent, textvariable=var_ref, fg="#f39c12", bg="#34495e", anchor="e", font=("Arial", 8, "bold"))
lbl_val.grid(row=row_idx, column=1, sticky="e", pady=1)
log_frame.columnconfigure(0, weight=1)
log_frame.columnconfigure(1, weight=1)
add_row(log_frame, "Threshold Aktif:", self.log_thresh, 0)
add_row(log_frame, "Jarak Kamera (D):", self.log_distance, 1)
add_row(log_frame, "Piksel Bergerak (%):", self.log_pixel_pct, 2)
add_row(log_frame, "Estimasi Luas Fisik:", self.log_est_size, 3)
add_row(log_frame, "Kontur Terdeteksi:", self.log_contours, 4)
add_row(log_frame, "Status Sesi:", self.log_status, 5)
# --- LIVE TABLE PREVIEW (TREEVIEW LENGKAP) ---
preview_frame = tk.LabelFrame(control_frame, text="Live Table Preview (Log Lengkap)", fg="white", bg="#34495e", font=("Arial", 9, "bold"), padx=5, pady=4)
preview_frame.pack(fill=tk.BOTH, expand=True, pady=3)
columns = ("time", "thresh", "min_area", "pct", "size", "contours", "status")
self.tree = ttk.Treeview(preview_frame, columns=columns, show="headings", height=3)
self.tree.heading("time", text="Waktu")
self.tree.heading("thresh", text="Thr")
self.tree.heading("min_area", text="MinArea")
self.tree.heading("pct", text="Piksel%")
self.tree.heading("size", text="Luas(m²)")
self.tree.heading("contours", text="Kontur")
self.tree.heading("status", text="Status")
self.tree.column("time", width=55, anchor="center")
self.tree.column("thresh", width=30, anchor="center")
self.tree.column("min_area", width=50, anchor="center")
self.tree.column("pct", width=45, anchor="center")
self.tree.column("size", width=50, anchor="center")
self.tree.column("contours", width=40, anchor="center")
self.tree.column("status", width=55, anchor="center")
self.tree.pack(fill=tk.BOTH, expand=True)
# Tombol Keluar
btn_exit = tk.Button(control_frame, text="Keluar Aplikasi", bg="#7f8c8d", fg="white", font=("Arial", 8, "bold"), command=self.on_closing)
btn_exit.pack(fill=tk.X, pady=(2, 0))
# Jalankan Loop Pembaruan Frame GUI
self.delay = 15
self.update_frame()
self.window.protocol("WM_DELETE_WINDOW", self.on_closing)
self.window.mainloop()
def update_thresh_label(self, val):
self.thresh_val_label.config(text=f"Nilai: {int(float(val))}")
def update_area_label(self, val):
self.area_val_label.config(text=f"Nilai: {int(float(val))}")
def update_brightness_label(self, val):
b_val = int(float(val))
self.brightness_val_label.config(text=f"Nilai: {b_val}")
def change_camera(self, event=None):
if self.is_started:
return
try:
new_cam_id = int(self.cam_var.get())
if self.cap.isOpened():
self.cap.release()
self.cap = cv2.VideoCapture(new_cam_id, cv2.CAP_DSHOW)
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
ret, self.frame1 = self.cap.read()
if ret:
brightness_val = int(self.brightness_slider.get())
self.frame1 = cv2.convertScaleAbs(self.frame1, alpha=1.0, beta=brightness_val)
self.gray1 = cv2.cvtColor(self.frame1, cv2.COLOR_BGR2GRAY)
self.gray1 = cv2.GaussianBlur(self.gray1, (21, 21), 0)
else:
self.gray1 = None
print(f"[INFO] Kamera berhasil dialihkan ke index: {new_cam_id}")
except Exception as e:
print(f"[ERROR] Gagal mengganti kamera: {e}")
def start_session(self):
try:
self.camera_distance = float(self.distance_entry.get())
except ValueError:
self.camera_distance = 5.0
self.is_started = True
self.btn_start.config(state=tk.DISABLED, bg="#95a5a6")
self.btn_stop.config(state=tk.NORMAL, bg="#c0392b")
self.distance_entry.config(state=tk.DISABLED)
self.cam_dropdown.config(state=tk.DISABLED)
session_time = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
self.csv_filename = f"logs/experiment_log_{session_time}.csv"
self.csv_file = open(self.csv_filename, mode='w', newline='', encoding='utf-8')
self.csv_writer = csv.writer(self.csv_file)
# Header CSV lengkap dengan Brightness tanpa menghilangkan parameter lain
self.csv_writer.writerow(["Timestamp", "Threshold", "MinArea", "CameraDistance_m", "Brightness", "PixelChangedPct", "EstimatedArea_m2", "ContoursCount", "StatusRecord"])
print(f"[INFO] Sesi riset dimulai. Kamera ID: {self.cam_var.get()} | Jarak: {self.camera_distance}m | Log: {self.csv_filename}")
def stop_session(self):
self.is_started = False
self.btn_start.config(state=tk.NORMAL, bg="#27ae60")
self.btn_stop.config(state=tk.DISABLED, bg="#95a5a6")
self.distance_entry.config(state=tk.NORMAL)
self.cam_dropdown.config(state="readonly")
if self.is_recording and self.out is not None:
self.out.release()
self.is_recording = False
if self.csv_file is not None:
self.csv_file.close()
self.csv_file = None
print(f"[INFO] Sesi riset selesai. File CSV berhasil disimpan.")
self.log_status.set("SELESAI (Idle)")
def update_frame(self):
ret, raw_frame = self.cap.read()
if ret:
current_time = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
full_timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
thresh_val = int(self.thresh_slider.get())
min_area = int(self.area_slider.get())
brightness_val = int(self.brightness_slider.get())
frame2 = cv2.convertScaleAbs(raw_frame, alpha=1.0, beta=brightness_val)
display_frame = frame2.copy()
self.log_thresh.set(str(thresh_val))
self.log_distance.set(f"{self.camera_distance} m")
self.log_timestamp.set(full_timestamp)
gray2 = cv2.cvtColor(frame2, cv2.COLOR_BGR2GRAY)
gray2 = cv2.GaussianBlur(gray2, (21, 21), 0)
pixel_pct = 0.0
total_contour_pixel_area = 0.0
valid_contours_count = 0
record_status_str = "STANDBY"
if self.gray1 is not None:
diff = cv2.absdiff(self.gray1, gray2)
thresh = cv2.threshold(diff, thresh_val, 255, cv2.THRESH_BINARY)[1]
thresh = cv2.dilate(thresh, None, iterations=2)
non_zero_pixels = cv2.countNonZero(thresh)
pixel_pct = (non_zero_pixels / self.total_pixels) * 100.0
self.log_pixel_pct.set(f"{pixel_pct:.3f} %")
contours, _ = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
motion_detected = False
calibration_factor = 0.00005
for i, contour in enumerate(contours):
area_px = cv2.contourArea(contour)
if area_px < min_area:
continue
valid_contours_count += 1
total_contour_pixel_area += area_px
motion_detected = True
contour_area_m2 = area_px * (self.camera_distance ** 2) * calibration_factor
(x, y, w, h) = cv2.boundingRect(contour)
cv2.rectangle(display_frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
label_text = f"#{valid_contours_count} | {contour_area_m2:.2f}m²"
cv2.putText(display_frame, label_text, (x, max(y - 10, 20)),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
estimated_real_area_m2 = total_contour_pixel_area * (self.camera_distance ** 2) * calibration_factor
self.log_est_size.set(f"{estimated_real_area_m2:.3f} m²")
self.log_contours.set(str(valid_contours_count))
cv2.putText(display_frame, f"Waktu: {full_timestamp} | Brig: {brightness_val} | Jarak: {self.camera_distance}m", (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 0, 255), 2)
if self.is_started:
if motion_detected:
self.no_motion_frames = 0
if not self.is_recording:
self.is_recording = True
video_filename = f"recordings/session_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.avi"
fourcc = cv2.VideoWriter_fourcc(*'XVID')
self.out = cv2.VideoWriter(video_filename, fourcc, self.fps, (self.frame_width, self.frame_height))
record_status_str = "MEREKAM"
self.log_status.set("MEREKAM (REC)")
cv2.circle(display_frame, (self.frame_width - 30, 30), 10, (0, 0, 255), -1)
else:
if self.is_recording:
self.no_motion_frames += 1
if self.no_motion_frames > self.MAX_NO_MOTION_FRAMES:
self.is_recording = False
if self.out is not None:
self.out.release()
else:
record_status_str = "JEDA"
self.log_status.set("Menunggu Jeda...")
else:
record_status_str = "STANDBY"
self.log_status.set("SESI AKTIF (Aman)")
if self.is_recording and self.out is not None:
self.out.write(display_frame)
if self.csv_writer is not None:
self.csv_writer.writerow([
full_timestamp, thresh_val, min_area, self.camera_distance, brightness_val,
f"{pixel_pct:.3f}", f"{estimated_real_area_m2:.4f}",
valid_contours_count, record_status_str
])
self.tree.insert("", 0, values=(current_time, thresh_val, min_area, f"{pixel_pct:.1f}%", f"{estimated_real_area_m2:.2f}m²", valid_contours_count, record_status_str))
if len(self.tree.get_children()) > 15:
last_item = self.tree.get_children()[-1]
self.tree.delete(last_item)
else:
self.log_status.set("IDLE (Klik Mulai)")
self.gray1 = gray2.copy()
# Render ke Kanvas GUI
img_live = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB)
img_live = Image.fromarray(img_live).resize((480, 360), Image.Resampling.LANCZOS)
self.photo_live = ImageTk.PhotoImage(image=img_live)
self.canvas_live.create_image(0, 0, image=self.photo_live, anchor=tk.NW)
img_thresh = cv2.cvtColor(thresh, cv2.COLOR_GRAY2RGB) if 'thresh' in locals() else cv2.cvtColor(gray2, cv2.COLOR_GRAY2RGB)
img_thresh = Image.fromarray(img_thresh).resize((480, 360), Image.Resampling.LANCZOS)
self.photo_thresh = ImageTk.PhotoImage(image=img_thresh)
self.canvas_thresh.create_image(0, 0, image=self.photo_thresh, anchor=tk.NW)
self.window.after(self.delay, self.update_frame)
def on_closing(self):
self.stop_session()
if self.cap.isOpened():
self.cap.release()
self.window.destroy()
if __name__ == "__main__":
root = tk.Tk()
app = MotionDetectorApp(root, "Riset Motion Detector - zidCreative R&D")