diff --git a/mucapy/main.py b/mucapy/main.py index 4175613..e2cc241 100644 --- a/mucapy/main.py +++ b/mucapy/main.py @@ -5,9 +5,10 @@ import numpy as np from PyQt5.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QWidget, QLabel, QPushButton, QComboBox, QSpinBox, QFileDialog, QMessageBox, QMenu, QAction, QMenuBar, - QActionGroup, QSizePolicy, QGridLayout, QGroupBox) -from PyQt5.QtCore import Qt, QTimer, QDir -from PyQt5.QtGui import QImage, QPixmap + QActionGroup, QSizePolicy, QGridLayout, QGroupBox, + QDockWidget, QScrollArea, QToolButton) +from PyQt5.QtCore import Qt, QTimer, QDir, QSize +from PyQt5.QtGui import QImage, QPixmap, QIcon, QColor class MultiCamYOLODetector: def __init__(self): @@ -214,27 +215,123 @@ class MultiCamYOLODetector: return frames - def get_frames(self): - """Get frames from all cameras with detections""" - frames = [] - for i, (cam_path, cam) in enumerate(self.cameras): - ret, frame = cam.read() - if not ret: - print(f"Warning: Could not read frame from camera {cam_path}") - frame = np.zeros((720, 1280, 3), dtype=np.uint8) - else: - frame = self.get_detections(frame) - frames.append(frame) - return frames - class CameraDisplay(QLabel): - """Custom QLabel for displaying camera feed""" + """Custom QLabel for displaying camera feed with fullscreen support""" def __init__(self, parent=None): super().__init__(parent) self.setAlignment(Qt.AlignCenter) self.setText("No camera feed") - self.setStyleSheet("background-color: black; color: white;") + self.setStyleSheet(""" + QLabel { + background-color: #1E1E1E; + color: #DDD; + border: 2px solid #444; + border-radius: 4px; + } + """) self.setMinimumSize(320, 240) + self.fullscreen_window = None + self.cam_id = None + self.fullscreen_timer = None + + def set_cam_id(self, cam_id): + """Set camera identifier for this display""" + self.cam_id = cam_id + + def mouseDoubleClickEvent(self, event): + """Handle double click to toggle fullscreen""" + if self.pixmap() and not self.fullscreen_window: + self.show_fullscreen() + elif self.fullscreen_window: + self.close_fullscreen() + + def show_fullscreen(self): + """Show this camera in fullscreen mode""" + self.fullscreen_window = QMainWindow() + self.fullscreen_window.setWindowTitle(f"Camera {self.cam_id} - Fullscreen") + + screen = QApplication.primaryScreen().availableGeometry() + self.fullscreen_window.resize(int(screen.width() * 0.9), int(screen.height() * 0.9)) + + label = QLabel() + label.setAlignment(Qt.AlignCenter) + if self.pixmap(): + label.setPixmap(self.pixmap().scaled( + self.fullscreen_window.size(), + Qt.KeepAspectRatio, + Qt.SmoothTransformation + )) + + self.fullscreen_window.setCentralWidget(label) + self.fullscreen_window.showFullScreen() + + # Update fullscreen image when main window updates + self.fullscreen_timer = QTimer() + self.fullscreen_timer.timeout.connect( + lambda: self.update_fullscreen(label) + ) + self.fullscreen_timer.start(30) + + def update_fullscreen(self, label): + """Update the fullscreen display""" + if self.pixmap(): + label.setPixmap(self.pixmap().scaled( + label.size(), + Qt.KeepAspectRatio, + Qt.SmoothTransformation + )) + + def close_fullscreen(self): + """Close the fullscreen window""" + if self.fullscreen_window: + if self.fullscreen_timer: + self.fullscreen_timer.stop() + self.fullscreen_window.close() + self.fullscreen_window = None + self.fullscreen_timer = None + +class CollapsibleDock(QDockWidget): + """Custom dock widget with collapse/expand functionality""" + def __init__(self, title, parent=None): + super().__init__(title, parent) + self.setFeatures(QDockWidget.DockWidgetClosable | + QDockWidget.DockWidgetMovable | + QDockWidget.DockWidgetFloatable) + + self.toggle_button = QToolButton(self) + self.toggle_button.setIcon(QIcon.fromTheme("arrow-left")) + self.toggle_button.setIconSize(QSize(16, 16)) + self.toggle_button.setStyleSheet("border: none;") + self.toggle_button.clicked.connect(self.toggle_collapse) + + self.setTitleBarWidget(self.toggle_button) + self.collapsed = False + self.original_size = None + + def toggle_collapse(self): + """Toggle between collapsed and expanded states""" + if self.collapsed: + self.expand() + else: + self.collapse() + + def collapse(self): + """Collapse the dock widget""" + if not self.collapsed: + self.original_size = self.size() + self.setFixedWidth(40) + self.toggle_button.setIcon(QIcon.fromTheme("arrow-right")) + self.collapsed = True + + def expand(self): + """Expand the dock widget""" + if self.collapsed: + self.setMinimumWidth(250) + self.setMaximumWidth(16777215) # Qt default maximum + if self.original_size: + self.resize(self.original_size) + self.toggle_button.setIcon(QIcon.fromTheme("arrow-left")) + self.collapsed = False class MainWindow(QMainWindow): def __init__(self): @@ -242,13 +339,111 @@ class MainWindow(QMainWindow): self.setWindowTitle("Multi-Camera YOLO Detection") self.setGeometry(100, 100, 1200, 800) - self.detector = MultiCamYOLODetector() - self.camera_settings = {} # Store camera-specific settings - self.create_menus() + # Set dark theme style + self.setStyleSheet(""" + QMainWindow, QWidget { + background-color: #2D2D2D; + color: #DDD; + } + QLabel { + color: #DDD; + } + QPushButton { + background-color: #3A3A3A; + color: #DDD; + border: 1px solid #555; + border-radius: 4px; + padding: 5px; + } + QPushButton:hover { + background-color: #4A4A4A; + } + QPushButton:pressed { + background-color: #2A2A2A; + } + QPushButton:disabled { + background-color: #2A2A2A; + color: #777; + } + QComboBox, QSpinBox { + background-color: #3A3A3A; + color: #DDD; + border: 1px solid #555; + border-radius: 4px; + padding: 3px; + } + QGroupBox { + border: 1px solid #555; + border-radius: 4px; + margin-top: 10px; + padding-top: 15px; + background-color: #252525; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 3px; + color: #DDD; + } + QMenuBar { + background-color: #252525; + color: #DDD; + } + QMenuBar::item { + background-color: transparent; + padding: 5px 10px; + } + QMenuBar::item:selected { + background-color: #3A3A3A; + } + QMenu { + background-color: #252525; + border: 1px solid #444; + color: #DDD; + } + QMenu::item:selected { + background-color: #3A3A3A; + } + QScrollArea { + border: none; + } + QDockWidget { + titlebar-close-icon: url(none); + titlebar-normal-icon: url(none); + } + QDockWidget::title { + background: #252525; + padding-left: 5px; + } + QToolButton { + background-color: transparent; + border: none; + } + """) + # Set palette for better dark mode support + palette = self.palette() + palette.setColor(palette.Window, QColor(45, 45, 45)) + palette.setColor(palette.WindowText, QColor(221, 221, 221)) + palette.setColor(palette.Base, QColor(35, 35, 35)) + palette.setColor(palette.AlternateBase, QColor(45, 45, 45)) + palette.setColor(palette.ToolTipBase, QColor(221, 221, 221)) + palette.setColor(palette.ToolTipText, QColor(221, 221, 221)) + palette.setColor(palette.Text, QColor(221, 221, 221)) + palette.setColor(palette.Button, QColor(58, 58, 58)) + palette.setColor(palette.ButtonText, QColor(221, 221, 221)) + palette.setColor(palette.BrightText, Qt.red) + palette.setColor(palette.Link, QColor(42, 130, 218)) + palette.setColor(palette.Highlight, QColor(42, 130, 218)) + palette.setColor(palette.HighlightedText, Qt.black) + self.setPalette(palette) + + self.detector = MultiCamYOLODetector() + self.camera_settings = {} + self.create_menus() self.init_ui() self.init_timer() - + def create_menus(self): """Create the menu bar with model and camera menus""" menubar = self.menuBar() @@ -310,52 +505,101 @@ class MainWindow(QMainWindow): QMessageBox.information(self, "Success", "Model loaded successfully!") else: QMessageBox.critical(self, "Error", "Failed to load model from selected directory") - + def init_ui(self): - """Initialize the user interface""" + """Initialize the user interface with collapsible sidebar""" main_widget = QWidget() - main_layout = QVBoxLayout() + main_layout = QHBoxLayout() - # Control panel - control_panel = QGroupBox("Controls") - control_layout = QHBoxLayout() + # Create collapsible sidebar + self.sidebar = CollapsibleDock("Controls") + self.sidebar.setMinimumWidth(250) + + # Sidebar content + sidebar_content = QWidget() + sidebar_layout = QVBoxLayout() + + # Model section + model_group = QGroupBox("Model") + model_layout = QVBoxLayout() - # Model info self.model_label = QLabel("Model: Not loaded") - control_layout.addWidget(self.model_label) + model_layout.addWidget(self.model_label) + + load_model_btn = QPushButton("Load Model Directory...") + load_model_btn.clicked.connect(self.load_model_directory) + model_layout.addWidget(load_model_btn) + + model_group.setLayout(model_layout) + sidebar_layout.addWidget(model_group) + + # Camera section + camera_group = QGroupBox("Cameras") + camera_layout = QVBoxLayout() - # Selected cameras label self.cameras_label = QLabel("Selected Cameras: None") - control_layout.addWidget(self.cameras_label) + camera_layout.addWidget(self.cameras_label) + + refresh_cams_btn = QPushButton("Refresh Camera List") + refresh_cams_btn.clicked.connect(self.populate_camera_menu) + camera_layout.addWidget(refresh_cams_btn) + + camera_group.setLayout(camera_layout) + sidebar_layout.addWidget(camera_group) + + # Settings section + settings_group = QGroupBox("Settings") + settings_layout = QVBoxLayout() # FPS control + fps_layout = QHBoxLayout() + fps_layout.addWidget(QLabel("FPS:")) self.fps_spin = QSpinBox() self.fps_spin.setRange(1, 60) self.fps_spin.setValue(10) - control_layout.addWidget(QLabel("FPS:")) - control_layout.addWidget(self.fps_spin) + fps_layout.addWidget(self.fps_spin) + settings_layout.addLayout(fps_layout) - # Camera layout selection + # Layout selection + layout_layout = QHBoxLayout() + layout_layout.addWidget(QLabel("Layout:")) self.layout_combo = QComboBox() self.layout_combo.addItems(["1 Camera", "2 Cameras", "3 Cameras", "4 Cameras", "Grid Layout"]) self.layout_combo.currentIndexChanged.connect(self.change_camera_layout) - control_layout.addWidget(QLabel("Layout:")) - control_layout.addWidget(self.layout_combo) + layout_layout.addWidget(self.layout_combo) + settings_layout.addLayout(layout_layout) - # Buttons - self.start_btn = QPushButton("Start Detection") + settings_group.setLayout(settings_layout) + sidebar_layout.addWidget(settings_group) + + # Control buttons + btn_layout = QHBoxLayout() + self.start_btn = QPushButton("Start") self.start_btn.clicked.connect(self.start_detection) - control_layout.addWidget(self.start_btn) + btn_layout.addWidget(self.start_btn) - self.stop_btn = QPushButton("Stop Detection") + self.stop_btn = QPushButton("Stop") self.stop_btn.clicked.connect(self.stop_detection) self.stop_btn.setEnabled(False) - control_layout.addWidget(self.stop_btn) + btn_layout.addWidget(self.stop_btn) - control_panel.setLayout(control_layout) - main_layout.addWidget(control_panel) + sidebar_layout.addLayout(btn_layout) - # Camera display area + # Add stretch to push everything up + sidebar_layout.addStretch() + + sidebar_content.setLayout(sidebar_layout) + + # Add scroll area to sidebar + scroll = QScrollArea() + scroll.setWidget(sidebar_content) + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.sidebar.setWidget(scroll) + + self.addDockWidget(Qt.LeftDockWidgetArea, self.sidebar) + + # Main display area self.display_area = QWidget() self.display_layout = QGridLayout() self.camera_displays = [] @@ -363,6 +607,7 @@ class MainWindow(QMainWindow): # Initially create 4 camera displays for i in range(4): display = CameraDisplay() + display.set_cam_id(i+1) self.camera_displays.append(display) self.display_layout.addWidget(display, i//2, i%2) @@ -372,6 +617,9 @@ class MainWindow(QMainWindow): main_widget.setLayout(main_layout) self.setCentralWidget(main_widget) + # Start with sidebar expanded + self.sidebar.expand() + def change_camera_layout(self, index): """Change the camera display layout""" # Clear the layout @@ -402,12 +650,12 @@ class MainWindow(QMainWindow): # Hide unused displays for i, display in enumerate(self.camera_displays): display.setVisible(i < num_cameras) - + def init_timer(self): """Initialize the timer for updating camera feeds""" self.timer = QTimer() self.timer.timeout.connect(self.update_feeds) - + def start_detection(self): """Start the detection process""" if not self.detector.model_dir: @@ -441,7 +689,7 @@ class MainWindow(QMainWindow): # Start timer self.timer.start(int(1000 / self.detector.target_fps)) - + def stop_detection(self): """Stop the detection process""" self.timer.stop() @@ -455,8 +703,15 @@ class MainWindow(QMainWindow): # Clear displays for display in self.camera_displays: display.setText("No camera feed") - display.setStyleSheet("background-color: black; color: white;") - + display.setStyleSheet(""" + QLabel { + background-color: #1E1E1E; + color: #DDD; + border: 2px solid #444; + border-radius: 4px; + } + """) + def update_selection_labels(self): """Update the model and camera selection labels""" # Update cameras label @@ -465,7 +720,7 @@ class MainWindow(QMainWindow): if action.isChecked(): selected_cams.append(action.text()) self.cameras_label.setText(f"Selected Cameras: {', '.join(selected_cams) or 'None'}") - + def update_feeds(self): """Update the camera feeds in the display""" frames = self.detector.get_frames() @@ -493,6 +748,10 @@ class MainWindow(QMainWindow): if __name__ == "__main__": app = QApplication(sys.argv) + + # Set application style to Fusion for better dark mode support + app.setStyle("Fusion") + window = MainWindow() window.show() sys.exit(app.exec_()) \ No newline at end of file