Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
9 changed files with 195 additions and 704 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -5,6 +5,3 @@ __pycache__/
|
||||||
config_local.json
|
config_local.json
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
|
|
||||||
|
|
|
||||||
108
README.md
108
README.md
|
|
@ -1,88 +1,82 @@
|
||||||
# BoxedMan 📦🚶♂️
|
# BoxedMan 📦🚶♂️
|
||||||
|
|
||||||
En interaktiv visualisering og fysisk projektionsinstallation bygget i Python & Qt (PySide6).
|
En interaktiv visualisering og fysisk projektionsinstallation bygget i Python.
|
||||||
|
|
||||||
* **Projektoren** viser en ren hvid flade, hvor en rød autonom "Peg Man" bevæger sig rundt i sin kasse.
|
* **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).
|
* **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.
|
* 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
|
## 🛠️ 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.
|
* **Computer Vision**: OpenCV (`cv2`) – 4-punkts homografisk perspektivtransformation, adaptiv skyggeudtræk og polygonapproksimering.
|
||||||
* **Fysikmotor**: Pymunk (Chipmunk 2D) – realtids segment/polygon kollisionshåndtering og raycasts.
|
* **Fysikmotor**: Pymunk (Chipmunk 2D) – realtids segment/polygon kollisionshåndtering og raycasts.
|
||||||
* **Grafik & Display**: Pygame-ce – lavlatens rendering af projektorfladen.
|
* **Grafik & Display**: Pygame-ce – fuldskærms rendering på projektor.
|
||||||
* **Standalone Packaging**: PyInstaller med cross-platform `.spec` fil.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Installation & Kørsel
|
## 🚀 Installation & Kørsel
|
||||||
|
|
||||||
### 1. Klargør miljø (Udvikling)
|
### 1. Klargør miljø
|
||||||
```powershell
|
```powershell
|
||||||
# Windows
|
# Opret og aktivér virtuelt miljø
|
||||||
py -3.12 -m venv .venv
|
py -3.12 -m venv .venv
|
||||||
.\.venv\Scripts\Activate.ps1
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
|
||||||
|
# Installer afhængigheder
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
### 2. Kalibrering (Første gang)
|
||||||
# Linux (Ubuntu / Linux Mint)
|
Når du starter BoxedMan første gang med projektor og kamera sat op, åbnes kalibreringsvinduet automatisk.
|
||||||
python3 -m venv .venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Kør med GUI Kontrolpanel (Standard)
|
Du kan også køre kalibreringen direkte når som helst:
|
||||||
```powershell
|
```powershell
|
||||||
|
python main.py --calibrate
|
||||||
|
```
|
||||||
|
* 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
|
||||||
|
```powershell
|
||||||
|
# Standard kørsel i vindue
|
||||||
python main.py
|
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).
|
|
||||||
|
|
||||||
### 3. Kalibrering i GUI
|
# Kør i fuldskærm på projektoren
|
||||||
1. I kontrolpanelet klikkes på **"🎯 Start Kalibrering"**.
|
python main.py --fullscreen
|
||||||
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
|
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`.
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
# -*- 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',
|
|
||||||
)
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
#!/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 ==="
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
149
main.py
149
main.py
|
|
@ -1,18 +1,25 @@
|
||||||
"""Main application entry point for BoxedMan: supports both GUI and CLI/Headless modes."""
|
"""Main application entry point for BoxedMan: integrates Vision, Physics, Agent, and Display."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
import argparse
|
import argparse
|
||||||
|
import pygame
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
from src.config import Settings, CalibrationData
|
from src.config import Settings, CalibrationData
|
||||||
from src.gui import run_gui
|
from src.vision import VisionPipeline
|
||||||
|
from src.physics import PhysicsWorld
|
||||||
|
from src.pegman import PegMan
|
||||||
|
from src.calibration import Calibrator
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="BoxedMan - Interactive Projector Installation")
|
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("--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("--fullscreen", action="store_true", help="Launch in fullscreen mode on projector")
|
||||||
parser.add_argument("--sim", action="store_true", help="Simulation mode without webcam (draw obstacles with mouse)")
|
parser.add_argument("--sim", action="store_true", help="Simulation mode without webcam (use mouse to draw obstacles)")
|
||||||
parser.add_argument("--no-gui", action="store_true", help="Run without control panel GUI (direct Pygame window)")
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
settings = Settings.load()
|
settings = Settings.load()
|
||||||
|
|
@ -21,41 +28,145 @@ def main():
|
||||||
if args.fullscreen:
|
if args.fullscreen:
|
||||||
settings.fullscreen = True
|
settings.fullscreen = True
|
||||||
|
|
||||||
|
# 1. Calibration mode if requested or no calibration file exists
|
||||||
calibration = CalibrationData.load()
|
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()
|
||||||
|
|
||||||
# Launch cross-platform PySide6 GUI by default
|
# 2. Initialize Vision Pipeline (unless running pure simulation)
|
||||||
if not args.no_gui:
|
vision: VisionPipeline = None
|
||||||
run_gui(settings, calibration, sim_mode=args.sim)
|
if not args.sim:
|
||||||
else:
|
vision = VisionPipeline(settings, calibration)
|
||||||
# Fallback headless / direct Pygame loop
|
if not vision.start_camera():
|
||||||
import pygame
|
print("[BoxedMan] Could not start camera. Falling back to mouse simulation mode (--sim).")
|
||||||
from src.engine import SimulationEngine
|
args.sim = True
|
||||||
|
|
||||||
|
# 3. Initialize Pygame Display
|
||||||
pygame.init()
|
pygame.init()
|
||||||
flags = pygame.FULLSCREEN if settings.fullscreen else 0
|
flags = pygame.FULLSCREEN if settings.fullscreen else 0
|
||||||
screen = pygame.display.set_mode((settings.projector_width, settings.projector_height), flags)
|
screen = pygame.display.set_mode((settings.projector_width, settings.projector_height), flags)
|
||||||
pygame.display.set_caption("BoxedMan")
|
pygame.display.set_caption("BoxedMan")
|
||||||
clock = pygame.time.Clock()
|
clock = pygame.time.Clock()
|
||||||
|
|
||||||
engine = SimulationEngine(settings, calibration, sim_mode=args.sim)
|
# 4. Initialize Physics & Peg Man
|
||||||
|
physics = PhysicsWorld(settings)
|
||||||
|
pegman = PegMan(physics, settings, start_pos=(settings.projector_width // 2, settings.projector_height - 100))
|
||||||
|
|
||||||
|
# Mouse simulation obstacles
|
||||||
|
mouse_polygons = []
|
||||||
|
drawing_poly = []
|
||||||
|
|
||||||
|
# State variables
|
||||||
|
show_debug_vision = False
|
||||||
running = True
|
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:
|
while running:
|
||||||
dt = clock.tick(settings.fps) / 1000.0
|
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():
|
for event in pygame.event.get():
|
||||||
if event.type == pygame.QUIT or (event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE):
|
if event.type == pygame.QUIT:
|
||||||
running = False
|
running = False
|
||||||
elif event.type == pygame.KEYDOWN:
|
elif event.type == pygame.KEYDOWN:
|
||||||
if event.key == pygame.K_b:
|
if event.key == pygame.K_ESCAPE:
|
||||||
engine.capture_background()
|
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:
|
elif event.key == pygame.K_r:
|
||||||
engine.reset_pegman()
|
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()
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
# --- 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))
|
||||||
|
|
||||||
engine.step(dt)
|
|
||||||
engine.render(screen)
|
|
||||||
pygame.display.flip()
|
pygame.display.flip()
|
||||||
|
|
||||||
engine.close()
|
if vision:
|
||||||
|
vision.stop_camera()
|
||||||
pygame.quit()
|
pygame.quit()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,3 @@ opencv-python>=4.9.0
|
||||||
pygame-ce>=2.5.0
|
pygame-ce>=2.5.0
|
||||||
pymunk>=6.8.0
|
pymunk>=6.8.0
|
||||||
numpy>=1.26.0
|
numpy>=1.26.0
|
||||||
PySide6>=6.6.0
|
|
||||||
|
|
|
||||||
119
src/engine.py
119
src/engine.py
|
|
@ -1,119 +0,0 @@
|
||||||
"""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()
|
|
||||||
396
src/gui.py
396
src/gui.py
|
|
@ -1,396 +0,0 @@
|
||||||
"""Cross-platform PySide6 Control Panel and Projector Window for BoxedMan."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import cv2
|
|
||||||
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())
|
|
||||||
Loading…
Add table
Reference in a new issue