Milestone 2: Add PySide6 cross-platform GUI, build scripts for Windows and Linux, and PyInstaller spec

This commit is contained in:
Christian Johannesen 2026-10-18 15:23:10 +02:00
parent 5190192552
commit 837b37e463
9 changed files with 702 additions and 194 deletions

3
.gitignore vendored
View file

@ -5,3 +5,6 @@ __pycache__/
config_local.json
.vscode/
.idea/
build/
dist/

106
README.md
View file

@ -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`.

66
boxedman.spec Normal file
View file

@ -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',
)

21
build_linux.sh Normal file
View file

@ -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 ==="

8
build_windows.ps1 Normal file
View file

@ -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
}

177
main.py
View file

@ -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__":

View file

@ -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

119
src/engine.py Normal file
View file

@ -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()

395
src/gui.py Normal file
View file

@ -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())