Initial commit: BoxedMan core architecture with vision, calibration, physics, and autonomous pegman

This commit is contained in:
Christian Johannesen 2026-10-18 15:08:04 +02:00
commit 5190192552
9 changed files with 887 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
.venv/
__pycache__/
*.pyc
.calibration.json
config_local.json
.vscode/
.idea/

82
README.md Normal file
View file

@ -0,0 +1,82 @@
# BoxedMan 📦🚶‍♂️
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.
* **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.
---
## 🛠️ Teknologistak
* **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.
---
## 🚀 Installation & Kørsel
### 1. Klargør miljø
```powershell
# Opret og aktivér virtuelt miljø
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
```
* 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
# Kør i fuldskærm på projektoren
python main.py --fullscreen
# Vælg et specifikt webkamera-indeks (hvis du har flere tilsluttet, fx 0, 1, 2)
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`.

175
main.py Normal file
View file

@ -0,0 +1,175 @@
"""Main application entry point for BoxedMan: integrates Vision, Physics, Agent, and Display."""
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
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)")
args = parser.parse_args()
settings = Settings.load()
if args.cam is not None:
settings.camera_index = args.cam
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
# 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()
# 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
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:
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()
# 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))
pygame.display.flip()
if vision:
vision.stop_camera()
pygame.quit()
sys.exit(0)
if __name__ == "__main__":
main()

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
opencv-python>=4.9.0
pygame-ce>=2.5.0
pymunk>=6.8.0
numpy>=1.26.0

84
src/calibration.py Normal file
View file

@ -0,0 +1,84 @@
"""Interactive 4-point calibration utility to map camera perspective directly to projection area."""
import cv2
import numpy as np
from typing import List, Optional
from .config import Settings, CalibrationData
class Calibrator:
def __init__(self, settings: Settings):
self.settings = settings
self.points: List[List[float]] = []
self.window_name = "BoxedMan - 4-Point Calibration (Click 4 Corners)"
def _mouse_callback(self, event, x, y, flags, param):
if event == cv2.EVENT_LBUTTONDOWN:
if len(self.points) < 4:
self.points.append([float(x), float(y)])
print(f"[Calibrator] Selected corner {len(self.points)}: ({x}, {y})")
def run(self) -> Optional[CalibrationData]:
cap = cv2.VideoCapture(self.settings.camera_index, cv2.CAP_DSHOW)
if not cap.isOpened():
cap = cv2.VideoCapture(self.settings.camera_index)
if not cap.isOpened():
print(f"[Calibrator] Cannot open camera {self.settings.camera_index}")
return None
cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.settings.camera_width)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.settings.camera_height)
cv2.namedWindow(self.window_name)
cv2.setMouseCallback(self.window_name, self._mouse_callback)
print("\n--- KALIBRERING ---")
print("1. Klik på de 4 hjørner af den projicerede hvide boks i kamera-billedet i rækkefølge:")
print(" [1] Øverst-Venstre -> [2] Øverst-Højre -> [3] Nederst-Højre -> [4] Nederst-Venstre")
print("Tast 'r' for at nulstille punkter.")
print("Tast 'c' for at gemme og fortsætte (når 4 punkter er valgt).")
print("Tast 'q' for at annullere.\n")
saved = False
while True:
ret, frame = cap.read()
if not ret:
break
display = frame.copy()
# Draw already clicked points and lines
for i, pt in enumerate(self.points):
pos = (int(pt[0]), int(pt[1]))
cv2.circle(display, pos, 6, (0, 0, 255), -1)
cv2.putText(display, f"{i+1}", (pos[0] + 10, pos[1] - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
if len(self.points) > 1:
for i in range(len(self.points) - 1):
p1 = (int(self.points[i][0]), int(self.points[i][1]))
p2 = (int(self.points[i + 1][0]), int(self.points[i + 1][1]))
cv2.line(display, p1, p2, (0, 255, 255), 2)
if len(self.points) == 4:
p1 = (int(self.points[3][0]), int(self.points[3][1]))
p2 = (int(self.points[0][0]), int(self.points[0][1]))
cv2.line(display, p1, p2, (0, 255, 255), 2)
cv2.imshow(self.window_name, display)
key = cv2.waitKey(15) & 0xFF
if key == ord('r'):
self.points.clear()
print("[Calibrator] Punkter nulstillet.")
elif key == ord('c') and len(self.points) == 4:
calib = CalibrationData(points=self.points)
calib.save()
print("[Calibrator] Kalibrering gemt i config/calibration.json!")
saved = True
break
elif key == ord('q') or key == 27:
break
cap.release()
cv2.destroyAllWindows()
return CalibrationData(points=self.points) if saved else None

82
src/config.py Normal file
View file

@ -0,0 +1,82 @@
"""Configuration settings and calibration storage for BoxedMan."""
import json
import os
from dataclasses import dataclass, asdict
from typing import List, Tuple, Optional
CONFIG_FILE = "config/settings.json"
CALIBRATION_FILE = "config/calibration.json"
@dataclass
class Settings:
# Camera settings
camera_index: int = 0
camera_width: int = 1280
camera_height: int = 720
camera_fps: int = 30
# Display / Projector settings
projector_width: int = 1280
projector_height: int = 720
fullscreen: bool = False
display_index: int = 0 # 0 for primary monitor, 1 for secondary/projector
# Vision / Shadow Detection
# Threshold mode: 'diff' (against reference white frame) or 'adaptive' / 'brightness'
shadow_threshold: int = 70 # Pixel value difference considered a shadow
min_contour_area: float = 800.0 # Ignore tiny noise artifacts
polygon_epsilon: float = 3.0 # Ramer-Douglas-Peucker simplification factor
blur_kernel: int = 5
temporal_smoothing: float = 0.5 # Alpha blending between frames to reduce jitter
# Simulation / Physics
gravity: float = 900.0 # Pixels/sec^2
fps: int = 60
# Peg man properties
man_color: Tuple[int, int, int] = (220, 20, 20)
man_width: int = 24
man_height: int = 56
walk_speed: float = 140.0
jump_strength: float = 380.0
climb_speed: float = 120.0
@classmethod
def load(cls, path: str = CONFIG_FILE) -> "Settings":
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return cls(**data)
except Exception as e:
print(f"[Config] Error loading settings from {path}: {e}. Using defaults.")
return cls()
def save(self, path: str = CONFIG_FILE) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(asdict(self), f, indent=4)
@dataclass
class CalibrationData:
"""Stores 4 corners (top-left, top-right, bottom-right, bottom-left) in camera coordinates."""
points: List[List[float]] # List of [x, y] in camera pixel coordinates
@classmethod
def load(cls, path: str = CALIBRATION_FILE) -> Optional["CalibrationData"]:
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return cls(points=data["points"])
except Exception as e:
print(f"[Calibration] Error loading calibration from {path}: {e}")
return None
def save(self, path: str = CALIBRATION_FILE) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump({"points": self.points}, f, indent=4)

229
src/pegman.py Normal file
View file

@ -0,0 +1,229 @@
"""Autonomous Peg Man agent: physics body, sensory raycasts, behavior state machine, and animation."""
import math
import random
import pygame
import pymunk
from enum import Enum
from typing import Tuple, Optional
from .config import Settings
from .physics import PhysicsWorld
class ManState(Enum):
WANDERING = "wandering"
WAITING = "waiting"
JUMPING = "jumping"
CLIMBING = "climbing"
FALLING = "falling"
class PegMan:
def __init__(self, physics: PhysicsWorld, settings: Settings, start_pos: Tuple[float, float]):
self.physics = physics
self.settings = settings
self.state = ManState.WANDERING
self.state_timer = 0.0
# Movement attributes
self.direction = 1 # 1 = right, -1 = left
self.on_ground = False
self.near_wall = False
self.near_edge = False
self.walk_cycle = 0.0
# Physical dimensions
self.width = settings.man_width
self.height = settings.man_height
self.radius = self.width / 2.0
# Create rigid body (upright capsule)
mass = 10.0
moment = pymunk.moment_for_box(mass, (self.width, self.height))
# Infinite moment keeps him upright without tumbling
self.body = pymunk.Body(mass, float('inf'))
self.body.position = start_pos
# Main collision shape (capsule / rounded segment)
p1 = (0, -self.height / 2.0 + self.radius)
p2 = (0, self.height / 2.0 - self.radius)
self.shape = pymunk.Segment(self.body, p1, p2, self.radius)
self.shape.friction = 0.4
self.shape.elasticity = 0.0
self.shape.filter = pymunk.ShapeFilter(
categories=PhysicsWorld.CATEGORY_MAN,
mask=PhysicsWorld.CATEGORY_WALL | PhysicsWorld.CATEGORY_SHADOW
)
self.physics.space.add(self.body, self.shape)
@property
def position(self) -> Tuple[float, float]:
return float(self.body.position.x), float(self.body.position.y)
def _cast_ray(self, start: Tuple[float, float], end: Tuple[float, float]) -> Optional[pymunk.SegmentQueryInfo]:
"""Performs a line segment query against walls and shadows."""
mask = PhysicsWorld.CATEGORY_WALL | PhysicsWorld.CATEGORY_SHADOW
shape_filter = pymunk.ShapeFilter(mask=mask)
query = self.physics.space.segment_query_first(start, end, 1.0, shape_filter)
if query and query.shape != self.shape:
return query
return None
def update_sensors(self) -> None:
"""Fires raycasts to perceive the ground, obstacles, edges, and climbable surfaces."""
px, py = self.position
half_h = self.height / 2.0
half_w = self.width / 2.0
# 1. Ground sensor (downwards from feet)
feet_y = py + half_h
ground_query = self._cast_ray((px, feet_y - 2), (px, feet_y + 8))
self.on_ground = ground_query is not None
# 2. Forward wall / obstacle sensor (at waist and chest height)
dir_x = self.direction
wall_dist = half_w + 14.0
waist_query = self._cast_ray((px, py), (px + dir_x * wall_dist, py))
chest_query = self._cast_ray((px, py - 15), (px + dir_x * wall_dist, py - 15))
knee_query = self._cast_ray((px, py + 15), (px + dir_x * wall_dist, py + 15))
self.near_wall = (waist_query is not None) or (knee_query is not None)
self.high_wall = chest_query is not None and waist_query is not None
# 3. Ledge / step up sensor (overhead obstacle clear check)
head_clear_query = self._cast_ray((px + dir_x * wall_dist, py - half_h - 10), (px + dir_x * wall_dist, py - half_h))
self.can_climb_step = self.near_wall and not self.high_wall and (head_clear_query is None)
# 4. Pit / Edge sensor (downwards in front of movement direction)
edge_x = px + dir_x * (half_w + 16.0)
edge_query = self._cast_ray((edge_x, feet_y - 2), (edge_x, feet_y + 40))
self.near_edge = (edge_query is None)
def update(self, dt: float) -> None:
self.update_sensors()
self.state_timer -= dt
# State transition logic
if not self.on_ground and self.state not in (ManState.JUMPING, ManState.CLIMBING):
self.state = ManState.FALLING
if self.state == ManState.FALLING:
if self.on_ground:
self.state = ManState.WANDERING
self.state_timer = random.uniform(2.0, 5.0)
elif self.state == ManState.WANDERING:
self.walk_cycle += dt * 10.0
# Check if we should climb a step
if self.can_climb_step and self.on_ground:
self.state = ManState.CLIMBING
self.state_timer = 0.35
# Encountered a wall or edge
elif self.near_wall or self.near_edge:
# 30% chance to jump over if near edge or low obstacle, otherwise turn around
if self.on_ground and random.random() < 0.35 and not self.high_wall:
self.state = ManState.JUMPING
self.body.velocity = (self.direction * self.settings.walk_speed * 1.1, -self.settings.jump_strength)
self.state_timer = 0.5
else:
self.direction *= -1
self.state = ManState.WAITING
self.state_timer = random.uniform(0.6, 1.8)
elif self.state_timer <= 0:
# Randomly stop to look around or turn
if random.random() < 0.4:
self.state = ManState.WAITING
self.state_timer = random.uniform(1.0, 3.0)
else:
if random.random() < 0.3:
self.direction *= -1
self.state_timer = random.uniform(3.0, 7.0)
# Apply horizontal walking velocity
vx = self.direction * self.settings.walk_speed
self.body.velocity = (vx, self.body.velocity.y)
elif self.state == ManState.WAITING:
# Stand still
self.body.velocity = (self.body.velocity.x * 0.7, self.body.velocity.y)
if self.state_timer <= 0:
self.state = ManState.WANDERING
self.state_timer = random.uniform(2.0, 5.0)
elif self.state == ManState.CLIMBING:
# Ascend upwards and slightly forward to mount obstacle
vx = self.direction * (self.settings.walk_speed * 0.7)
vy = -self.settings.climb_speed
self.body.velocity = (vx, vy)
if self.state_timer <= 0:
self.state = ManState.WANDERING
self.state_timer = 2.0
elif self.state == ManState.JUMPING:
if self.state_timer <= 0 and self.on_ground:
self.state = ManState.WANDERING
self.state_timer = random.uniform(2.0, 4.0)
# Keep within canvas bounds
w = self.settings.projector_width
px = max(self.radius, min(w - self.radius, self.body.position.x))
if px != self.body.position.x:
self.body.position = (px, self.body.position.y)
self.direction *= -1
def render(self, surface: pygame.Surface) -> None:
"""Renders the stylized red stick/peg man with procedural limbs."""
px, py = self.position
dir_x = self.direction
color = self.settings.man_color
line_w = 4
# Head
head_radius = 8
head_pos = (int(px), int(py - self.height / 2.0 + head_radius))
pygame.draw.circle(surface, color, head_pos, head_radius)
# Eyes (directional dot)
eye_x = head_pos[0] + dir_x * 4
eye_y = head_pos[1] - 1
pygame.draw.circle(surface, (255, 255, 255), (eye_x, eye_y), 2)
# Torso
torso_top = (int(px), head_pos[1] + head_radius)
pelvis = (int(px), int(py + 6))
pygame.draw.line(surface, color, torso_top, pelvis, line_w)
# Procedural limbs based on movement state
if self.state in (ManState.WANDERING, ManState.CLIMBING):
leg_phase = math.sin(self.walk_cycle)
arm_phase = math.cos(self.walk_cycle)
elif self.state == ManState.JUMPING:
leg_phase = 0.8
arm_phase = -0.8
else:
leg_phase = 0.0
arm_phase = 0.0
# Legs
feet_y = py + self.height / 2.0
leg1_end = (int(px + dir_x * leg_phase * 12), int(feet_y))
leg2_end = (int(px - dir_x * leg_phase * 12), int(feet_y))
# Knees
knee1 = (int(px + dir_x * leg_phase * 6), int(py + 18))
knee2 = (int(px - dir_x * leg_phase * 6), int(py + 18))
pygame.draw.lines(surface, color, False, [pelvis, knee1, leg1_end], line_w)
pygame.draw.lines(surface, color, False, [pelvis, knee2, leg2_end], line_w)
# Arms
shoulder = (int(px), int(torso_top[1] + 5))
arm1_end = (int(px + dir_x * 10 - arm_phase * 10), int(py + 8))
arm2_end = (int(px - dir_x * 10 + arm_phase * 10), int(py + 8))
pygame.draw.line(surface, color, shoulder, arm1_end, line_w)
pygame.draw.line(surface, color, shoulder, arm2_end, line_w)

82
src/physics.py Normal file
View file

@ -0,0 +1,82 @@
"""Physics world and dynamic shadow collider manager using Pymunk."""
import pymunk
import numpy as np
from typing import List, Tuple
from .config import Settings
class PhysicsWorld:
# Collision categories / bitmasks
CATEGORY_WALL = 0b0001
CATEGORY_SHADOW = 0b0010
CATEGORY_MAN = 0b0100
CATEGORY_SENSOR = 0b1000
def __init__(self, settings: Settings):
self.settings = settings
self.space = pymunk.Space()
self.space.gravity = (0, self.settings.gravity)
self.space.iterations = 10
self.shadow_shapes: List[pymunk.Shape] = []
self._setup_boundary_walls()
def _setup_boundary_walls(self) -> None:
"""Creates the enclosing box (left, right, bottom, top) around the projector canvas."""
w = float(self.settings.projector_width)
h = float(self.settings.projector_height)
thickness = 40.0
static_body = self.space.static_body
walls = [
# Bottom floor
pymunk.Segment(static_body, (-thickness, h - 2), (w + thickness, h - 2), thickness),
# Left wall
pymunk.Segment(static_body, (2, -thickness), (2, h + thickness), thickness),
# Right wall
pymunk.Segment(static_body, (w - 2, -thickness), (w - 2, h + thickness), thickness),
# Ceiling
pymunk.Segment(static_body, (-thickness, 2), (w + thickness, 2), thickness),
]
for wall in walls:
wall.friction = 1.0
wall.elasticity = 0.0
wall.filter = pymunk.ShapeFilter(categories=self.CATEGORY_WALL)
self.space.add(wall)
def update_shadow_geometry(self, polygons: List[np.ndarray]) -> None:
"""
Replaces the current dynamic shadow colliders with new ones derived from vision contours.
Uses chain segments or decomposed convex/concave polygons.
Using closed segment chains ensures stable collisions with arbitrary shadow shapes.
"""
# Remove previous shadow colliders
if self.shadow_shapes:
self.space.remove(*self.shadow_shapes)
self.shadow_shapes.clear()
static_body = self.space.static_body
for poly in polygons:
n = len(poly)
if n < 3:
continue
# Create perimeter segment edges
for i in range(n):
p1 = tuple(map(float, poly[i]))
p2 = tuple(map(float, poly[(i + 1) % n]))
seg = pymunk.Segment(static_body, p1, p2, radius=3.0)
seg.friction = 0.9
seg.elasticity = 0.0
seg.filter = pymunk.ShapeFilter(categories=self.CATEGORY_SHADOW)
self.space.add(seg)
self.shadow_shapes.append(seg)
def step(self, dt: float) -> None:
self.space.step(dt)

142
src/vision.py Normal file
View file

@ -0,0 +1,142 @@
"""Vision pipeline for BoxedMan: camera input, perspective warping, and shadow contour detection."""
import cv2
import numpy as np
from typing import List, Optional, Tuple
from .config import Settings, CalibrationData
class VisionPipeline:
def __init__(self, settings: Settings, calibration: Optional[CalibrationData] = None):
self.settings = settings
self.calibration = calibration
self.homography_matrix: Optional[np.ndarray] = None
self.reference_frame: Optional[np.ndarray] = None
self.cap: Optional[cv2.VideoCapture] = None
if self.calibration and len(self.calibration.points) == 4:
self._update_homography()
def start_camera(self) -> bool:
"""Initializes the webcam capture."""
self.cap = cv2.VideoCapture(self.settings.camera_index, cv2.CAP_DSHOW)
if not self.cap.isOpened():
# Fallback to standard backend if DSHOW fails
self.cap = cv2.VideoCapture(self.settings.camera_index)
if not self.cap.isOpened():
print(f"[Vision] Could not open camera at index {self.settings.camera_index}")
return False
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.settings.camera_width)
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.settings.camera_height)
self.cap.set(cv2.CAP_PROP_FPS, self.settings.camera_fps)
print(f"[Vision] Camera {self.settings.camera_index} initialized.")
return True
def stop_camera(self) -> None:
if self.cap:
self.cap.release()
self.cap = None
def read_raw_frame(self) -> Optional[np.ndarray]:
if not self.cap or not self.cap.isOpened():
return None
ret, frame = self.cap.read()
if not ret:
return None
return frame
def set_calibration_points(self, points: List[List[float]]) -> None:
self.calibration = CalibrationData(points=points)
self.calibration.save()
self._update_homography()
def _update_homography(self) -> None:
"""Computes the 3x3 homography matrix from camera corner points to projector dimensions."""
if not self.calibration or len(self.calibration.points) != 4:
self.homography_matrix = None
return
src_pts = np.float32(self.calibration.points)
dst_pts = np.float32([
[0, 0],
[self.settings.projector_width, 0],
[self.settings.projector_width, self.settings.projector_height],
[0, self.settings.projector_height]
])
self.homography_matrix = cv2.getPerspectiveTransform(src_pts, dst_pts)
print("[Vision] Perspective transform updated successfully.")
def warp_frame(self, frame: np.ndarray) -> np.ndarray:
"""Warps the camera frame to match the projector view rectangle."""
if self.homography_matrix is not None:
return cv2.warpPerspective(
frame,
self.homography_matrix,
(self.settings.projector_width, self.settings.projector_height)
)
return cv2.resize(frame, (self.settings.projector_width, self.settings.projector_height))
def capture_reference_background(self) -> None:
"""Captures a baseline frame of the empty projected surface to compare against."""
raw = self.read_raw_frame()
if raw is not None:
warped = self.warp_frame(raw)
gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)
k = self.settings.blur_kernel | 1
self.reference_frame = cv2.GaussianBlur(gray, (k, k), 0)
print("[Vision] Background reference frame captured.")
def extract_shadow_polygons(self, warped_frame: np.ndarray) -> Tuple[List[np.ndarray], np.ndarray]:
"""
Detects shadows from the warped camera frame and returns simplified polygon vertices
in projector coordinates, along with the binary shadow mask for debugging/display.
"""
# Convert to HSV to separate shadows/luminance from color (like the red peg man)
hsv = cv2.cvtColor(warped_frame, cv2.COLOR_BGR2HSV)
v_channel = hsv[:, :, 2] # Value / Brightness channel
k = self.settings.blur_kernel | 1
blurred_v = cv2.GaussianBlur(v_channel, (k, k), 0)
if self.reference_frame is not None:
# Difference against baseline reference
# Shadows appear darker than baseline white screen
diff = cv2.subtract(self.reference_frame, blurred_v)
_, mask = cv2.threshold(diff, self.settings.shadow_threshold, 255, cv2.THRESH_BINARY)
else:
# Adaptive threshold / Otsu fallback when no baseline captured yet
# Invert so dark areas (shadows) become 255 (white in mask)
_, mask = cv2.threshold(
blurred_v,
0,
255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU
)
# Morphological clean up: remove small noise specs and fill internal holes
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2)
# Extract contours
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
polygons: List[np.ndarray] = []
for cnt in contours:
area = cv2.contourArea(cnt)
if area < self.settings.min_contour_area:
continue
# Simplify contour with Ramer-Douglas-Peucker algorithm
epsilon = self.settings.polygon_epsilon
approx = cv2.approxPolyDP(cnt, epsilon, closed=True)
# We need at least 3 points for a valid 2D polygon
if len(approx) >= 3:
# Reshape to (N, 2)
pts = approx.reshape(-1, 2)
polygons.append(pts)
return polygons, mask