diff --git a/.gitignore b/.gitignore index 67f30f8..51f3ceb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ __pycache__/ config_local.json .vscode/ .idea/ +build/ +dist/ + diff --git a/README.md b/README.md index 715a958..e6c85e7 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,88 @@ # BoxedMan 📦🚶‍♂️ -En interaktiv visualisering og fysisk projektionsinstallation bygget i Python. +En interaktiv visualisering og fysisk projektionsinstallation bygget i Python & Qt (PySide6). * **Projektoren** viser en ren hvid flade, hvor en rød autonom "Peg Man" bevæger sig rundt i sin kasse. * **Webkameraet** overvåger projektionsfladen og detekterer fysiske skygger eller silhuetter (fx hænder, kroppe, papstykker). * Skyggerne omsættes i realtid til faste 2D-kollisionsobjekter (*solids*), som Peg Man kan gå på, klatre op ad, hoppe ned fra og interagere med. +* **Kontrolpanelet** giver fuld visuel kontrol over kamerabillede, kalibrering, parametre og projektionsvindue. --- ## 🛠️ Teknologistak +* **GUI & Kontrolpanel**: PySide6 (Qt) – Cross-platform til Windows 10/11, Ubuntu og Linux Mint. * **Computer Vision**: OpenCV (`cv2`) – 4-punkts homografisk perspektivtransformation, adaptiv skyggeudtræk og polygonapproksimering. * **Fysikmotor**: Pymunk (Chipmunk 2D) – realtids segment/polygon kollisionshåndtering og raycasts. -* **Grafik & Display**: Pygame-ce – fuldskærms rendering på projektor. +* **Grafik & Display**: Pygame-ce – lavlatens rendering af projektorfladen. +* **Standalone Packaging**: PyInstaller med cross-platform `.spec` fil. --- ## 🚀 Installation & Kørsel -### 1. Klargør miljø +### 1. Klargør miljø (Udvikling) ```powershell -# Opret og aktivér virtuelt miljø +# Windows py -3.12 -m venv .venv .\.venv\Scripts\Activate.ps1 - -# Installer afhængigheder pip install -r requirements.txt ``` -### 2. Kalibrering (Første gang) -Når du starter BoxedMan første gang med projektor og kamera sat op, åbnes kalibreringsvinduet automatisk. - -Du kan også køre kalibreringen direkte når som helst: -```powershell -python main.py --calibrate +```bash +# Linux (Ubuntu / Linux Mint) +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt ``` -* Klik på de 4 hjørner af den hvide projektionsflade i kamera-vinduet i rækkefølgen: - 1. **Øverst-Venstre** - 2. **Øverst-Højre** - 3. **Nederst-Højre** - 4. **Nederst-Venstre** -* Tryk på **'c'** for at bekræfte og gemme kalibreringen. -* Tryk på **'r'** for at nulstille punkterne hvis du klikkede forkert. -### 3. Normal kørsel +### 2. Kør med GUI Kontrolpanel (Standard) ```powershell -# Standard kørsel i vindue python main.py +``` +Dette åbner to vinduer: +1. **BoxedMan - Kontrolpanel**: Viser live kamerabillede, skyggemaske, kalibreringsknapper og parametre (skyggetærskel, støjfilter, ganghastighed). +2. **BoxedMan - Projection Display**: Det rene projektionsbillede, der trækkes over på projektoren (eller sættes i fuldskærm via knappen i kontrolpanelet). -# Kør i fuldskærm på projektoren -python main.py --fullscreen +### 3. Kalibrering i GUI +1. I kontrolpanelet klikkes på **"🎯 Start Kalibrering"**. +2. Klik på de 4 hjørner af den hvide projektionsflade direkte i kamerabilledet i urets retning: + 1. *Øverst-Venstre* + 2. *Øverst-Højre* + 3. *Nederst-Højre* + 4. *Nederst-Venstre* +3. Kalibreringen gemmes automatisk og opdaterer projektionsgeometrien med det samme. -# Vælg et specifikt webkamera-indeks (hvis du har flere tilsluttet, fx 0, 1, 2) +--- + +## 🔨 Byg Standalone Binær (Build System) + +### Windows 10 & 11 +Kør PowerShell-scriptet: +```powershell +.\build_windows.ps1 +``` +Outputtet placeres i `dist\BoxedMan\BoxedMan.exe` (indeholder alle afhængigheder og kan køres uden Python installeret). + +### Linux Mint & Ubuntu +Kør bash-scriptet: +```bash +chmod +x build_linux.sh +./build_linux.sh +``` +Outputtet placeres i `dist/BoxedMan/BoxedMan`. + +--- + +## ⌨️ Yderligere CLI Flag + +```powershell +# Kør i simulerings-tilstand (test uden kamera, tegn med musen): +python main.py --sim + +# Kør uden GUI kontrolpanel (kun direkte projektionsvindue): +python main.py --no-gui + +# Specificér specifikt kamera-indeks (hvis flere webcams er tilsluttet): python main.py --cam 1 ``` - -### 4. Simulerings-tilstand (Uden webcam/projektor) -Hvis du vil teste fysik og Peg Mans adfærd direkte på din skærm: -```powershell -python main.py --sim -``` -* **Venstreklik og træk musen**: Tegn forhindringer/skygger. -* **Højreklik**: Ryd alle tegnede forhindringer. - ---- - -## ⌨️ Genvejstaster under kørsel - -| Tast | Funktion | -|---|---| -| **b** | Tag et nyt baseline baggrundsbillede (når skærmen er helt tom for skygger) | -| **d** | Slå Vision Debug Mask overlay til/fra | -| **c** | Start 4-punkts kalibrering igen | -| **r** | Nulstil Peg Mans position | -| **ESC** | Afslut programmet | - ---- - -## ⚙️ Konfiguration - -Indstillinger som f.eks. kameraopløsning, projektorstørrelse, tyngdekraft, ganghastighed og skyggefølsomhed kan justeres i `src/config.py` eller gemmes i `config/settings.json`. diff --git a/boxedman.spec b/boxedman.spec new file mode 100644 index 0000000..bc4f6a4 --- /dev/null +++ b/boxedman.spec @@ -0,0 +1,66 @@ +# -*- mode: python ; coding: utf-8 -*- + +from PyInstaller.utils.hooks import collect_data_files, collect_submodules + +block_cipher = None + +datas = [ + ('config', 'config'), +] + +hiddenimports = [ + 'pymunk', + 'pygame_ce', + 'cv2', + 'PySide6', + 'PySide6.QtCore', + 'PySide6.QtGui', + 'PySide6.QtWidgets', +] + collect_submodules('pymunk') + +a = Analysis( + ['main.py'], + pathex=['.'], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=['tkinter', 'matplotlib', 'scipy', 'PySide6.QtWebEngineCore', 'PySide6.QtPdf'], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='BoxedMan', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, # Set to True for logging / diagnostics, or False for windowed only + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='BoxedMan', +) diff --git a/build_linux.sh b/build_linux.sh new file mode 100644 index 0000000..0b0f5ab --- /dev/null +++ b/build_linux.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -e + +echo "=== Building BoxedMan for Linux (Ubuntu / Linux Mint) ===" + +# Check Python environment +if [ ! -d ".venv" ]; then + echo "Creating virtual environment..." + python3 -m venv .venv +fi + +source .venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt +pip install pyinstaller + +echo "Running PyInstaller..." +pyinstaller boxedman.spec --noconfirm --clean + +echo "" +echo "=== Build successful! Binary location: dist/BoxedMan/BoxedMan ===" diff --git a/build_windows.ps1 b/build_windows.ps1 new file mode 100644 index 0000000..b8189ed --- /dev/null +++ b/build_windows.ps1 @@ -0,0 +1,8 @@ +Write-Host "Building BoxedMan for Windows..." -ForegroundColor Cyan +.\.venv\Scripts\pyinstaller.exe boxedman.spec --noconfirm --clean + +if ($LASTEXITCODE -eq 0) { + Write-Host "`nBuild successful! Binary location: dist\BoxedMan\BoxedMan.exe" -ForegroundColor Green +} else { + Write-Host "`nBuild failed." -ForegroundColor Red +} diff --git a/main.py b/main.py index a0e484e..11e19eb 100644 --- a/main.py +++ b/main.py @@ -1,25 +1,18 @@ -"""Main application entry point for BoxedMan: integrates Vision, Physics, Agent, and Display.""" +"""Main application entry point for BoxedMan: supports both GUI and CLI/Headless modes.""" import sys -import os import argparse -import pygame -import cv2 -import numpy as np from src.config import Settings, CalibrationData -from src.vision import VisionPipeline -from src.physics import PhysicsWorld -from src.pegman import PegMan -from src.calibration import Calibrator +from src.gui import run_gui def main(): parser = argparse.ArgumentParser(description="BoxedMan - Interactive Projector Installation") - parser.add_argument("--calibrate", action="store_true", help="Run 4-point perspective camera calibration") parser.add_argument("--cam", type=int, default=None, help="Camera index override (e.g. 0, 1, 2)") parser.add_argument("--fullscreen", action="store_true", help="Launch in fullscreen mode on projector") - parser.add_argument("--sim", action="store_true", help="Simulation mode without webcam (use mouse to draw obstacles)") + parser.add_argument("--sim", action="store_true", help="Simulation mode without webcam (draw obstacles with mouse)") + parser.add_argument("--no-gui", action="store_true", help="Run without control panel GUI (direct Pygame window)") args = parser.parse_args() settings = Settings.load() @@ -28,147 +21,43 @@ def main(): if args.fullscreen: settings.fullscreen = True - # 1. Calibration mode if requested or no calibration file exists calibration = CalibrationData.load() - if args.calibrate or (calibration is None and not args.sim): - print("[BoxedMan] Starting calibration utility...") - calibrator = Calibrator(settings) - calibration = calibrator.run() - # 2. Initialize Vision Pipeline (unless running pure simulation) - vision: VisionPipeline = None - if not args.sim: - vision = VisionPipeline(settings, calibration) - if not vision.start_camera(): - print("[BoxedMan] Could not start camera. Falling back to mouse simulation mode (--sim).") - args.sim = True + # Launch cross-platform PySide6 GUI by default + if not args.no_gui: + run_gui(settings, calibration, sim_mode=args.sim) + else: + # Fallback headless / direct Pygame loop + import pygame + from src.engine import SimulationEngine - # 3. Initialize Pygame Display - pygame.init() - flags = pygame.FULLSCREEN if settings.fullscreen else 0 - screen = pygame.display.set_mode((settings.projector_width, settings.projector_height), flags) - pygame.display.set_caption("BoxedMan") - clock = pygame.time.Clock() + pygame.init() + flags = pygame.FULLSCREEN if settings.fullscreen else 0 + screen = pygame.display.set_mode((settings.projector_width, settings.projector_height), flags) + pygame.display.set_caption("BoxedMan") + clock = pygame.time.Clock() - # 4. Initialize Physics & Peg Man - physics = PhysicsWorld(settings) - pegman = PegMan(physics, settings, start_pos=(settings.projector_width // 2, settings.projector_height - 100)) + engine = SimulationEngine(settings, calibration, sim_mode=args.sim) + running = True - # Mouse simulation obstacles - mouse_polygons = [] - drawing_poly = [] - - # State variables - show_debug_vision = False - running = True - - print("\n--- BOXEDMAN KØRER ---") - print("Taster:") - print(" 'b' : Tag nyt baggrundsreferencebillede (uden skygger/hænder på lærredet)") - print(" 'd' : Vis / skjul vision debug overlay") - print(" 'c' : Genkalibrér 4 hjørner") - print(" 'r' : Nulstil Peg Mans position") - print(" 'ESC' eller luk vindue for at afslutte.") - if args.sim: - print("SIMULATIONSTILSTAND AKTIV: Venstreklik og træk musen for at tegne forhindringer, Højreklik for at rydde.") - print("----------------------\n") - - while running: - dt = clock.tick(settings.fps) / 1000.0 - dt = min(dt, 0.05) # Prevent physics explosion on lag spike - - # --- Event Handling --- - for event in pygame.event.get(): - if event.type == pygame.QUIT: - running = False - elif event.type == pygame.KEYDOWN: - if event.key == pygame.K_ESCAPE: + while running: + dt = clock.tick(settings.fps) / 1000.0 + for event in pygame.event.get(): + if event.type == pygame.QUIT or (event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE): running = False - elif event.key == pygame.K_b and vision: - vision.capture_reference_background() - elif event.key == pygame.K_d: - show_debug_vision = not show_debug_vision - elif event.key == pygame.K_r: - pegman.body.position = (settings.projector_width // 2, settings.projector_height - 100) - pegman.body.velocity = (0, 0) - elif event.key == pygame.K_c and vision: - vision.stop_camera() - calibrator = Calibrator(settings) - new_calib = calibrator.run() - if new_calib: - calibration = new_calib - vision.calibration = calibration - vision._update_homography() - vision.start_camera() + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_b: + engine.capture_background() + elif event.key == pygame.K_r: + engine.reset_pegman() - # Mouse obstacle drawing for simulation mode - elif args.sim: - if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: - drawing_poly = [event.pos] - elif event.type == pygame.MOUSEMOTION and event.buttons[0]: - if drawing_poly and np.hypot(event.pos[0] - drawing_poly[-1][0], event.pos[1] - drawing_poly[-1][1]) > 10: - drawing_poly.append(event.pos) - elif event.type == pygame.MOUSEBUTTONUP and event.button == 1: - if len(drawing_poly) >= 3: - mouse_polygons.append(np.array(drawing_poly, dtype=np.int32)) - drawing_poly = [] - elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 3: - mouse_polygons.clear() + engine.step(dt) + engine.render(screen) + pygame.display.flip() - # --- Vision Processing --- - detected_polygons = [] - debug_mask = None - if vision: - raw_frame = vision.read_raw_frame() - if raw_frame is not None: - warped = vision.warp_frame(raw_frame) - detected_polygons, debug_mask = vision.extract_shadow_polygons(warped) - - if args.sim: - detected_polygons = mouse_polygons - - # Update dynamic physics colliders based on detected shapes - physics.update_shadow_geometry(detected_polygons) - - # Step physics and agent behavior - pegman.update(dt) - physics.step(dt) - - # --- Rendering --- - # 1. Clear with clean white projector canvas - screen.fill((255, 255, 255)) - - # 2. Render borders / bounding box - pygame.draw.rect(screen, (30, 30, 30), (0, 0, settings.projector_width, settings.projector_height), 4) - - # 3. Draw shadow outlines or solids (subtle styling on the white projector) - for poly in detected_polygons: - if len(poly) >= 3: - pts = [(int(p[0]), int(p[1])) for p in poly] - # Light neutral fill / outline - pygame.draw.polygon(screen, (230, 230, 230), pts) - pygame.draw.lines(screen, (100, 100, 100), True, pts, 2) - - # Draw currently drawn sim line - if drawing_poly and len(drawing_poly) > 1: - pygame.draw.lines(screen, (150, 150, 150), False, drawing_poly, 2) - - # 4. Render Peg Man - pegman.render(screen) - - # 5. Optional Debug Overlay for Vision - if show_debug_vision and debug_mask is not None: - mask_rgb = cv2.cvtColor(debug_mask, cv2.COLOR_GRAY2RGB) - mask_surf = pygame.surfarray.make_surface(np.transpose(mask_rgb, (1, 0, 2))) - mask_surf.set_alpha(128) - screen.blit(mask_surf, (0, 0)) - - pygame.display.flip() - - if vision: - vision.stop_camera() - pygame.quit() - sys.exit(0) + engine.close() + pygame.quit() + sys.exit(0) if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index e662c7b..cf931d5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ opencv-python>=4.9.0 pygame-ce>=2.5.0 pymunk>=6.8.0 numpy>=1.26.0 +PySide6>=6.6.0 diff --git a/src/engine.py b/src/engine.py new file mode 100644 index 0000000..eb0ea92 --- /dev/null +++ b/src/engine.py @@ -0,0 +1,119 @@ +"""Core engine running the simulation, vision, physics, and rendering loops.""" + +import cv2 +import pygame +import numpy as np +from typing import Optional, List, Tuple +from .config import Settings, CalibrationData +from .vision import VisionPipeline +from .physics import PhysicsWorld +from .pegman import PegMan + + +class SimulationEngine: + def __init__(self, settings: Settings, calibration: Optional[CalibrationData] = None, sim_mode: bool = False): + self.settings = settings + self.calibration = calibration + self.sim_mode = sim_mode + + self.vision: Optional[VisionPipeline] = None + if not self.sim_mode: + self.vision = VisionPipeline(settings, calibration) + if not self.vision.start_camera(): + print("[Engine] Could not open camera. Falling back to mouse sim mode.") + self.sim_mode = True + self.vision = None + + self.physics = PhysicsWorld(settings) + self.pegman = PegMan( + self.physics, + settings, + start_pos=(settings.projector_width // 2, settings.projector_height - 100) + ) + + self.sim_polygons: List[np.ndarray] = [] + self.latest_detected_polygons: List[np.ndarray] = [] + self.latest_warped_frame: Optional[np.ndarray] = None + self.latest_raw_frame: Optional[np.ndarray] = None + self.latest_debug_mask: Optional[np.ndarray] = None + + # Standalone projection window surface (if opened directly in pygame) + self.screen_surface = pygame.Surface((self.settings.projector_width, self.settings.projector_height)) + + def update_calibration(self, calibration: CalibrationData) -> None: + self.calibration = calibration + if self.vision: + self.vision.set_calibration_points(calibration.points) + + def capture_background(self) -> None: + if self.vision: + self.vision.capture_reference_background() + + def reset_pegman(self) -> None: + self.pegman.body.position = (self.settings.projector_width // 2, self.settings.projector_height - 100) + self.pegman.body.velocity = (0, 0) + + def add_sim_polygon(self, pts: List[Tuple[int, int]]) -> None: + if len(pts) >= 3: + self.sim_polygons.append(np.array(pts, dtype=np.int32)) + + def clear_sim_polygons(self) -> None: + self.sim_polygons.clear() + + def step(self, dt: float) -> None: + """Runs one frame of computer vision, physics update, and agent decisions.""" + dt = min(dt, 0.05) + + polygons: List[np.ndarray] = [] + + if self.vision: + raw = self.vision.read_raw_frame() + if raw is not None: + self.latest_raw_frame = raw + warped = self.vision.warp_frame(raw) + self.latest_warped_frame = warped + polygons, mask = self.vision.extract_shadow_polygons(warped) + self.latest_debug_mask = mask + self.latest_detected_polygons = polygons + + if self.sim_mode: + polygons = self.sim_polygons + self.latest_detected_polygons = polygons + + # Update dynamic physics colliders + self.physics.update_shadow_geometry(polygons) + + # Update agent and step physics space + self.pegman.update(dt) + self.physics.step(dt) + + def render(self, surface: pygame.Surface, draw_debug_mask: bool = False) -> None: + """Renders the projector scene to any given Pygame surface.""" + # 1. Clean white canvas + surface.fill((255, 255, 255)) + + # 2. Border + w = self.settings.projector_width + h = self.settings.projector_height + pygame.draw.rect(surface, (40, 40, 40), (0, 0, w, h), 4) + + # 3. Shadows / solids + for poly in self.latest_detected_polygons: + if len(poly) >= 3: + pts = [(int(p[0]), int(p[1])) for p in poly] + pygame.draw.polygon(surface, (230, 230, 230), pts) + pygame.draw.lines(surface, (100, 100, 100), True, pts, 2) + + # 4. Peg Man + self.pegman.render(surface) + + # 5. Optional debug mask + if draw_debug_mask and self.latest_debug_mask is not None: + mask_rgb = cv2.cvtColor(self.latest_debug_mask, cv2.COLOR_GRAY2RGB) + mask_surf = pygame.surfarray.make_surface(np.transpose(mask_rgb, (1, 0, 2))) + mask_surf.set_alpha(120) + surface.blit(mask_surf, (0, 0)) + + def close(self) -> None: + if self.vision: + self.vision.stop_camera() diff --git a/src/gui.py b/src/gui.py new file mode 100644 index 0000000..3388850 --- /dev/null +++ b/src/gui.py @@ -0,0 +1,395 @@ +"""Cross-platform PySide6 Control Panel and Projector Window for BoxedMan.""" + +import sys +import numpy as np +import pygame +from typing import Optional, List + +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtGui import QImage, QPixmap, QColor, QPainter, QPen, QBrush +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QPushButton, QSlider, QSpinBox, QCheckBox, QGroupBox, + QTabWidget, QSplitter, QStatusBar, QFileDialog, QMessageBox +) + +from src.config import Settings, CalibrationData +from src.engine import SimulationEngine + + +def pygame_surface_to_qimage(surface: pygame.Surface) -> QImage: + """Converts a Pygame surface to a QImage without disk IO.""" + w, h = surface.get_size() + raw_data = pygame.image.tostring(surface, "RGBA", False) + image = QImage(raw_data, w, h, QImage.Format_RGBA8888) + return image.copy() + + +def cv_frame_to_qimage(frame: np.ndarray) -> QImage: + """Converts an OpenCV BGR frame or single channel mask to QImage.""" + if len(frame.shape) == 2: # Grayscale / mask + h, w = frame.shape + bytes_per_line = w + image = QImage(frame.data, w, h, bytes_per_line, QImage.Format_Grayscale8) + return image.copy() + else: # BGR + h, w, ch = frame.shape + bytes_per_line = ch * w + rgb_frame = np.ascontiguousarray(frame[:, :, ::-1]) + image = QImage(rgb_frame.data, w, h, bytes_per_line, QImage.Format_RGB888) + return image.copy() + + +class InteractiveCameraLabel(QLabel): + """Clickable camera feed widget for setting the 4 calibration corners.""" + point_clicked = Signal(float, float) + + def __init__(self): + super().__init__() + self.points: List[List[float]] = [] + self.calibrating: bool = False + self.setMouseTracking(True) + self.setMinimumSize(400, 250) + self.setAlignment(Qt.AlignCenter) + self.setStyleSheet("background-color: #1a1a1a; border: 1px solid #333;") + + def mousePressEvent(self, event): + if self.calibrating and event.button() == Qt.LeftButton: + pixmap = self.pixmap() + if not pixmap or pixmap.isNull(): + return + + pw = pixmap.width() + ph = pixmap.height() + ww = self.width() + wh = self.height() + + # Compensate for letterboxing / centering of pixmap + ox = (ww - pw) / 2.0 + oy = (wh - ph) / 2.0 + + x = event.position().x() - ox + y = event.position().y() - oy + + if 0 <= x <= pw and 0 <= y <= ph: + # Normalize coordinates (0.0 to 1.0) + norm_x = x / float(pw) + norm_y = y / float(ph) + self.point_clicked.emit(norm_x, norm_y) + + +class ProjectorWindow(QMainWindow): + """Clean, dedicated projection window to be dragged onto projector or set fullscreen.""" + def __init__(self, engine: SimulationEngine, settings: Settings): + super().__init__() + self.engine = engine + self.settings = settings + self.setWindowTitle("BoxedMan - Projection Display") + self.resize(settings.projector_width, settings.projector_height) + self.setStyleSheet("background-color: white;") + + self.display_label = QLabel() + self.display_label.setAlignment(Qt.AlignCenter) + self.setCentralWidget(self.display_label) + + def update_frame(self, qimage: QImage): + pixmap = QPixmap.fromImage(qimage) + self.display_label.setPixmap( + pixmap.scaled(self.display_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) + ) + + +class ControlPanelWindow(QMainWindow): + """Primary GUI window: Camera preview, sliders, calibration, and control triggers.""" + def __init__(self, engine: SimulationEngine, settings: Settings, projector_window: ProjectorWindow): + super().__init__() + self.engine = engine + self.settings = settings + self.projector_window = projector_window + self.calibration_points: List[List[float]] = [] + self.is_calibrating = False + + self.setWindowTitle("BoxedMan - Kontrolpanel") + self.resize(1050, 720) + self._init_ui() + + # Engine tick timer (runs at 60 FPS) + self.timer = QTimer(self) + self.timer.timeout.connect(self._game_loop_tick) + self.timer.start(int(1000 / settings.fps)) + + def _init_ui(self): + central = QWidget() + self.setCentralWidget(central) + main_layout = QHBoxLayout(central) + + # Left Column: Video feeds (Camera & Mask/Warped) + left_box = QWidget() + left_layout = QVBoxLayout(left_box) + + # Camera feed + cam_group = QGroupBox("Kamera Feed (Kalibrering & Detektion)") + cam_layout = QVBoxLayout(cam_group) + self.camera_view = InteractiveCameraLabel() + self.camera_view.point_clicked.connect(self._on_calibration_point_clicked) + cam_layout.addWidget(self.camera_view) + + # Status text for calibration + self.lbl_calib_status = QLabel("Normal tilstand. Klik 'Start Kalibrering' for at vælge hjørner.") + self.lbl_calib_status.setStyleSheet("color: #aaa; font-style: italic;") + cam_layout.addWidget(self.lbl_calib_status) + + left_layout.addWidget(cam_group, stretch=3) + + # Processed vision feed (Mask or Warped) + preview_group = QGroupBox("Vision Processering (Skyggemaske)") + prev_layout = QVBoxLayout(preview_group) + self.mask_view = QLabel() + self.mask_view.setAlignment(Qt.AlignCenter) + self.mask_view.setStyleSheet("background-color: #111; border: 1px solid #333;") + self.mask_view.setMinimumHeight(180) + prev_layout.addWidget(self.mask_view) + left_layout.addWidget(preview_group, stretch=2) + + main_layout.addWidget(left_box, stretch=3) + + # Right Column: Controls, Tuning & Actions + right_box = QWidget() + right_layout = QVBoxLayout(right_box) + + # Group 1: Hurtige handlinger + action_group = QGroupBox("Handlinger") + act_layout = QVBoxLayout(action_group) + + self.btn_capture_bg = QPushButton("📸 Tag reference-baggrund (Baseline)") + self.btn_capture_bg.clicked.connect(self._capture_background) + act_layout.addWidget(self.btn_capture_bg) + + self.btn_reset_man = QPushButton("🚶‍♂️ Nulstil Peg Man position") + self.btn_reset_man.clicked.connect(self.engine.reset_pegman) + act_layout.addWidget(self.btn_reset_man) + + self.btn_toggle_fullscreen = QPushButton("🖥️ Skift fuldskærm (Projektor)") + self.btn_toggle_fullscreen.clicked.connect(self._toggle_projector_fullscreen) + act_layout.addWidget(self.btn_toggle_fullscreen) + + right_layout.addWidget(action_group) + + # Group 2: Kalibrering + calib_group = QGroupBox("4-Punkts Kalibrering") + c_layout = QVBoxLayout(calib_group) + + h_cal_btns = QHBoxLayout() + self.btn_start_calib = QPushButton("🎯 Start Kalibrering") + self.btn_start_calib.clicked.connect(self._start_calibration) + h_cal_btns.addWidget(self.btn_start_calib) + + self.btn_clear_calib = QPushButton("↺ Nulstil punkter") + self.btn_clear_calib.clicked.connect(self._reset_calibration) + h_cal_btns.addWidget(self.btn_clear_calib) + c_layout.addLayout(h_cal_btns) + + right_layout.addWidget(calib_group) + + # Group 3: Parameter Justering (Sliders) + param_group = QGroupBox("Sensitivitet & Fysik") + p_layout = QVBoxLayout(param_group) + + # Skygge threshold slider + p_layout.addWidget(QLabel("Skyggetærskel (Shadow Threshold):")) + self.slider_thresh = QSlider(Qt.Horizontal) + self.slider_thresh.setRange(10, 200) + self.slider_thresh.setValue(self.settings.shadow_threshold) + self.lbl_thresh_val = QLabel(str(self.settings.shadow_threshold)) + self.slider_thresh.valueChanged.connect(self._on_thresh_changed) + h_thresh = QHBoxLayout() + h_thresh.addWidget(self.slider_thresh) + h_thresh.addWidget(self.lbl_thresh_val) + p_layout.addLayout(h_thresh) + + # Min contour area slider + p_layout.addWidget(QLabel("Min. Skyggestørrelse (Støjfilter):")) + self.slider_area = QSlider(Qt.Horizontal) + self.slider_area.setRange(100, 5000) + self.slider_area.setValue(int(self.settings.min_contour_area)) + self.lbl_area_val = QLabel(str(int(self.settings.min_contour_area))) + self.slider_area.valueChanged.connect(self._on_area_changed) + h_area = QHBoxLayout() + h_area.addWidget(self.slider_area) + h_area.addWidget(self.lbl_area_val) + p_layout.addLayout(h_area) + + # Peg Man hastighed + p_layout.addWidget(QLabel("Peg Man Gåhastighed:")) + self.slider_speed = QSlider(Qt.Horizontal) + self.slider_speed.setRange(40, 300) + self.slider_speed.setValue(int(self.settings.walk_speed)) + self.lbl_speed_val = QLabel(str(int(self.settings.walk_speed))) + self.slider_speed.valueChanged.connect(self._on_speed_changed) + h_speed = QHBoxLayout() + h_speed.addWidget(self.slider_speed) + h_speed.addWidget(self.lbl_speed_val) + p_layout.addLayout(h_speed) + + # Checkbox debug mask over projection + self.chk_debug_overlay = QCheckBox("Vis vision-maske over projektion") + p_layout.addWidget(self.chk_debug_overlay) + + # Checkbox simulation mode + self.chk_sim_mode = QCheckBox("Simuleringstilstand (Tegn med mus)") + self.chk_sim_mode.setChecked(self.engine.sim_mode) + self.chk_sim_mode.stateChanged.connect(self._on_sim_mode_toggle) + p_layout.addWidget(self.chk_sim_mode) + + right_layout.addWidget(param_group) + + # Save configuration button + self.btn_save_settings = QPushButton("💾 Gem indstillinger") + self.btn_save_settings.clicked.connect(self._save_settings) + right_layout.addWidget(self.btn_save_settings) + + right_layout.addStretch() + main_layout.addWidget(right_box, stretch=2) + + self.statusBar = QStatusBar() + self.setStatusBar(self.statusBar) + self.statusBar.showMessage("Klar.") + + # --- Calibration Handlers --- + def _start_calibration(self): + self.calibration_points.clear() + self.is_calibrating = True + self.camera_view.calibrating = True + self.lbl_calib_status.setText("Trin 1/4: Klik på [Øverst-Venstre] hjørne i kamerabilledet.") + self.statusBar.showMessage("Kalibrering i gang: Klik på de 4 hjørner i urets retning.") + + def _reset_calibration(self): + self.calibration_points.clear() + self.is_calibrating = False + self.camera_view.calibrating = False + self.lbl_calib_status.setText("Kalibrering nulstillet.") + self.statusBar.showMessage("Kalibrering nulstillet.") + + def _on_calibration_point_clicked(self, norm_x: float, norm_y: float): + if not self.is_calibrating or self.engine.latest_raw_frame is None: + return + + frame_h, frame_w = self.engine.latest_raw_frame.shape[:2] + pixel_x = norm_x * frame_w + pixel_y = norm_y * frame_h + self.calibration_points.append([pixel_x, pixel_y]) + + count = len(self.calibration_points) + prompts = [ + "Trin 2/4: Klik på [Øverst-Højre] hjørne.", + "Trin 3/4: Klik på [Nederst-Højre] hjørne.", + "Trin 4/4: Klik på [Nederst-Venstre] hjørne." + ] + + if count < 4: + self.lbl_calib_status.setText(prompts[count - 1]) + else: + self.lbl_calib_status.setText("✅ Kalibrering fuldført og gemt!") + self.is_calibrating = False + self.camera_view.calibrating = False + calib = CalibrationData(points=self.calibration_points) + calib.save() + self.engine.update_calibration(calib) + self.statusBar.showMessage("Perspektivkalibrering gennemført!") + + # --- Parameter Handlers --- + def _on_thresh_changed(self, val: int): + self.settings.shadow_threshold = val + self.lbl_thresh_val.setText(str(val)) + + def _on_area_changed(self, val: int): + self.settings.min_contour_area = float(val) + self.lbl_area_val.setText(str(val)) + + def _on_speed_changed(self, val: int): + self.settings.walk_speed = float(val) + self.lbl_speed_val.setText(str(val)) + + def _on_sim_mode_toggle(self, state: int): + self.engine.sim_mode = (state == Qt.Checked) + self.statusBar.showMessage(f"Simuleringstilstand: {'Aktiv' if self.engine.sim_mode else 'Inaktiv'}") + + def _save_settings(self): + self.settings.save() + self.statusBar.showMessage("Indstillinger gemt i config/settings.json!") + + def _capture_background(self): + self.engine.capture_background() + self.statusBar.showMessage("Reference-baggrund opdateret!") + + def _toggle_projector_fullscreen(self): + if self.projector_window.isFullScreen(): + self.projector_window.showNormal() + else: + self.projector_window.showFullScreen() + + # --- Main Loop Tick --- + def _game_loop_tick(self): + dt = 1.0 / self.settings.fps + self.engine.step(dt) + + # Render engine scene to pygame surface + self.engine.render(self.engine.screen_surface, draw_debug_mask=self.chk_debug_overlay.isChecked()) + + # Update projector window + qimg_proj = pygame_surface_to_qimage(self.engine.screen_surface) + self.projector_window.update_frame(qimg_proj) + + # Update camera feed in GUI with calibration guides + if self.engine.latest_raw_frame is not None: + cam_display = self.engine.latest_raw_frame.copy() + + # Draw clicked calibration corners + for i, pt in enumerate(self.calibration_points): + pos = (int(pt[0]), int(pt[1])) + cv2.circle(cam_display, pos, 6, (0, 0, 255), -1) + cv2.putText(cam_display, str(i + 1), (pos[0] + 8, pos[1] - 8), + cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) + + # If existing calibration loaded, draw current bounding box + if self.engine.calibration and len(self.engine.calibration.points) == 4 and not self.is_calibrating: + pts = np.int32(self.engine.calibration.points) + cv2.polylines(cam_display, [pts], True, (0, 255, 255), 2) + + qimg_cam = cv_frame_to_qimage(cam_display) + pix_cam = QPixmap.fromImage(qimg_cam) + self.camera_view.setPixmap( + pix_cam.scaled(self.camera_view.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) + ) + + # Update shadow mask preview in GUI + if self.engine.latest_debug_mask is not None: + qimg_mask = cv_frame_to_qimage(self.engine.latest_debug_mask) + pix_mask = QPixmap.fromImage(qimg_mask) + self.mask_view.setPixmap( + pix_mask.scaled(self.mask_view.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) + ) + + def closeEvent(self, event): + self.projector_window.close() + self.engine.close() + event.accept() + + +def run_gui(settings: Settings, calibration: Optional[CalibrationData], sim_mode: bool = False): + """Launches the PySide6 application.""" + pygame.init() + app = QApplication(sys.argv) + app.setStyle("Fusion") + + engine = SimulationEngine(settings, calibration, sim_mode=sim_mode) + + # 1. Projector Window + proj_win = ProjectorWindow(engine, settings) + proj_win.show() + + # 2. Control Panel Window + ctrl_win = ControlPanelWindow(engine, settings, proj_win) + ctrl_win.show() + + sys.exit(app.exec())