From 5190192552efd40133c0096d80704731cae46f5d Mon Sep 17 00:00:00 2001 From: Christian Johannesen Date: Sun, 18 Oct 2026 15:08:04 +0200 Subject: [PATCH] Initial commit: BoxedMan core architecture with vision, calibration, physics, and autonomous pegman --- .gitignore | 7 ++ README.md | 82 ++++++++++++++++ main.py | 175 ++++++++++++++++++++++++++++++++++ requirements.txt | 4 + src/calibration.py | 84 +++++++++++++++++ src/config.py | 82 ++++++++++++++++ src/pegman.py | 229 +++++++++++++++++++++++++++++++++++++++++++++ src/physics.py | 82 ++++++++++++++++ src/vision.py | 142 ++++++++++++++++++++++++++++ 9 files changed, 887 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 src/calibration.py create mode 100644 src/config.py create mode 100644 src/pegman.py create mode 100644 src/physics.py create mode 100644 src/vision.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67f30f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.pyc +.calibration.json +config_local.json +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..715a958 --- /dev/null +++ b/README.md @@ -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`. diff --git a/main.py b/main.py new file mode 100644 index 0000000..a0e484e --- /dev/null +++ b/main.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e662c7b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +opencv-python>=4.9.0 +pygame-ce>=2.5.0 +pymunk>=6.8.0 +numpy>=1.26.0 diff --git a/src/calibration.py b/src/calibration.py new file mode 100644 index 0000000..fe376dd --- /dev/null +++ b/src/calibration.py @@ -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 diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..1319c09 --- /dev/null +++ b/src/config.py @@ -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) diff --git a/src/pegman.py b/src/pegman.py new file mode 100644 index 0000000..2a9b32f --- /dev/null +++ b/src/pegman.py @@ -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) diff --git a/src/physics.py b/src/physics.py new file mode 100644 index 0000000..3e32483 --- /dev/null +++ b/src/physics.py @@ -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) diff --git a/src/vision.py b/src/vision.py new file mode 100644 index 0000000..69fc55a --- /dev/null +++ b/src/vision.py @@ -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