#!/usr/bin/env python3 # -*- coding: utf-8 -*- import argparse import os from dataclasses import dataclass from typing import Dict, Tuple, List, Optional import pygame import pygame.surfarray as surfarray from detector import IMX500Detector, FrameSnapshot from steps import StepTransformer, build_step_text, build_gate_text, total_steps_for_level from ui.theme import Theme from ui.textlayout import TextLayout from ui.renderer import Renderer @dataclass(frozen=True) class StepInfo: title: str body: str class App: def __init__(self, args: argparse.Namespace): self.args = args # UI services self.theme = Theme() self.text_layout = TextLayout() self.renderer = Renderer(self.theme) # Pygame setup pygame.init() pygame.font.init() pygame.mixer.init() self.screen = self._set_fullscreen() self.win_w, self.win_h = self.screen.get_size() pygame.display.set_caption("IMX500 GUI") # Assets paths self.base_dir = os.path.dirname(os.path.abspath(__file__)) self.landing_bg_path = "assets/landingpagebg.jpg" self.font_path = "assets/Kanit-Bold.ttf" self.audio_dir = os.path.join(self.base_dir, "assets", "audio") self.qr_path = os.path.join(self.base_dir, "assets", "qr.png") # Background & Image caches self._landing_bg_original = None self._landing_bg_scaled = None self._landing_bg_scaled_size = None self._qr_code_img = None self._load_landing_bg() self._load_qr_code() # Application State self.running = True self.state = "LIVEINTRO" # LIVEINTRO / LANDING / RUNNING / GATE self.mode = "LIVE" # LIVE / ANALYSE self.step = 0 # 0=LIVE, 1..N=Analyse self.clock = pygame.time.Clock() # User Configuration self.lang = "DE" self.level = None # "SCHUELER" / "STUDENT" # Logic Components self.detector: IMX500Detector | None = None self.transformer: StepTransformer | None = None self.snapshot: FrameSnapshot | None = None # Simulation / Rendering Caches self.sim_surface = None self.sim_step_cached: int | None = None # Gate Text Caches self.gate_cached_step: int | None = None self.gate_cached_title_font = None self.gate_cached_body_font = None self.gate_cached_title_lines = None self.gate_cached_body_lines = None # Gate Animation self.gate_anim_frames: list[pygame.Surface] | None = None self.gate_anim_key: str | None = None self.gate_anim_idx: int = 0 self.gate_anim_last_ms: int = 0 self.gate_anim_frame_ms: int = 55 # Hitboxes (General) self.lang_button_rect = pygame.Rect(0, 0, 0, 0) self.home_button_rect = pygame.Rect(0, 0, 0, 0) self.level_left_rect = pygame.Rect(0, 0, 0, 0) self.level_right_rect = pygame.Rect(0, 0, 0, 0) self.cta_button_rect = pygame.Rect(0, 0, 0, 0) # Hitboxes (Gate) self.gate_prev_rect = pygame.Rect(0, 0, 0, 0) self.gate_next_rect = pygame.Rect(0, 0, 0, 0) # Hitboxes (Navigation/Audio) self.nav_prev_rect = pygame.Rect(0, 0, 0, 0) self.nav_next_rect = pygame.Rect(0, 0, 0, 0) self.nav_action_rect = pygame.Rect(0, 0, 0, 0) self.nav_audio_rect = pygame.Rect(0, 0, 0, 0) # Audio State self.current_audio_file: str | None = None self.audio_is_paused = False # Gate Logic self.gate_next_step: int | None = None # Text Resources self.UI = { "DE": { "landing_title": "KI OBJEKTERKENNUNG", "landing_sub": "wähle hier dein Niveau", "level_left": "Schüler:in", "level_right": "Student:in", "hint": "ESC/q zum Beenden", "live_hint": "SPACE: Analyse", "quit_hint": "ESC/q: Quit", "analyse_hint": "ENTER next | BACKSPACE prev | SPACE end", "workflow": "Workflow", "workflow_hint": "SPACE friert ein & wechselt zu Analyse.", "lang_label": "DE", "liveintro_cta": "Was erkennt die KI? / What does the AI detect?", "home": "Home", "gate_btn": "Weiter", "back": "Zurück" }, "EN": { "landing_title": "AI OBJECT DETECTION", "landing_sub": "choose your level", "level_left": "Pupil", "level_right": "Student", "hint": "ESC/q to quit", "live_hint": "SPACE: Analyse", "quit_hint": "ESC/q: Quit", "analyse_hint": "ENTER next | BACKSPACE prev | SPACE end", "workflow": "Workflow", "workflow_hint": "SPACE freezes & switches to Analyse.", "lang_label": "EN", "liveintro_cta": "Was erkennt die KI? / What does the AI detect?", "home": "Home", "gate_btn": "Continue", "back": "Back" }, } self._recompute_responsive() # ---------------- Responsive System ---------------- def _scale(self) -> float: s_w = self.win_w / 1920.0 s_h = self.win_h / 1080.0 return max(0.6, min(1.6, (s_w + s_h) * 0.5)) def _fsize(self, px: float) -> int: return max(12, int(px * self._scale())) def _load_font(self, size: int) -> pygame.font.Font: try: return pygame.font.Font(self.font_path, size) except Exception: return pygame.font.Font(None, size) def _recompute_responsive(self) -> None: self.win_w, self.win_h = self.screen.get_size() self.font_ui = self._load_font(self._fsize(22)) self.font_small = self._load_font(self._fsize(20)) self.font_header = self._load_font(self._fsize(28)) self.font_title = self._load_font(self._fsize(38)) self.font_landing_title = self._load_font(self._fsize(86)) self.font_landing_sub = self._load_font(self._fsize(44)) self.font_landing_btn = self._load_font(self._fsize(44)) self.pad = max(12, int(18 * self._scale())) # Button sizes self.lang_btn_w = max(72, int(92 * self._scale())) self.lang_btn_h = max(36, int(44 * self._scale())) self.home_btn_w = max(int(96 * self._scale()), 80) self.home_btn_h = max(int(44 * self._scale()), 34) self.level_radius = max(18, int(28 * self._scale())) self.level_border_w = max(2, int(4 * self._scale())) self._landing_bg_scaled = None self._landing_bg_scaled_size = None # ---------------- Helpers ---------------- def _t(self, key: str) -> str: return self.UI[self.lang][key] def _total_steps(self) -> int: return total_steps_for_level(self.level or "SCHUELER") def _step_map(self) -> Dict[int, object]: assert self.snapshot is not None assert self.level is not None return build_step_text(lang=self.lang, level=self.level, debug=self.snapshot.debug) def _gate_map(self) -> Dict[int, object]: assert self.snapshot is not None assert self.level is not None return build_gate_text(lang=self.lang, level=self.level, debug=self.snapshot.debug) def _set_fullscreen(self) -> pygame.Surface: info = pygame.display.Info() return pygame.display.set_mode((info.current_w, info.current_h), pygame.FULLSCREEN) def _invalidate_caches(self) -> None: self.sim_step_cached = None self.gate_cached_step = None self._stop_audio() def _ensure_camera(self) -> None: if self.detector is None: self.detector = IMX500Detector(self.args) if self.snapshot is None: self.snapshot = FrameSnapshot(src_size=(self.args.cam_width, self.args.cam_height)) def _go_home(self) -> None: self.state = "LIVEINTRO" self.mode = "LIVE" self.step = 0 self.level = None self.gate_next_step = None self._invalidate_caches() def _is_student(self) -> bool: return self.level == "STUDENT" def _load_landing_bg(self) -> None: try: path = os.path.join(self.base_dir, self.landing_bg_path) self._landing_bg_original = pygame.image.load(path).convert() except Exception: self._landing_bg_original = None self._landing_bg_scaled = None self._landing_bg_scaled_size = None def _load_qr_code(self) -> None: try: self._qr_code_img = pygame.image.load(self.qr_path).convert_alpha() except Exception as e: print(f"Warning: QR Code not found at {self.qr_path} ({e})") self._qr_code_img = None def _get_landing_bg_scaled(self) -> pygame.Surface | None: if self._landing_bg_original is None: return None size = (self.win_w, self.win_h) if self._landing_bg_scaled is None or self._landing_bg_scaled_size != size: self._landing_bg_scaled = pygame.transform.smoothscale(self._landing_bg_original, size) self._landing_bg_scaled_size = size return self._landing_bg_scaled # ---------------- Audio Logic ---------------- def _get_audio_filename(self) -> str | None: """Ermittelt den Dateinamen basierend auf Level und Step.""" # STRIKTE REGEL: Audio NUR fuer SCHUELER if self.level != "SCHUELER": return None if self.lang == "EN": filename = f"schueler_step_{self.step}_english.mp3" else: filename = f"schueler_step_{self.step}.mp3" path = os.path.join(self.audio_dir, filename) if os.path.exists(path): return path return None def _toggle_audio(self) -> None: if self.audio_is_paused: pygame.mixer.music.unpause() self.audio_is_paused = False return if pygame.mixer.music.get_busy(): pygame.mixer.music.pause() self.audio_is_paused = True return path = self._get_audio_filename() if path: try: pygame.mixer.music.load(path) pygame.mixer.music.play() self.current_audio_file = path self.audio_is_paused = False except Exception as e: print(f"Audio error: {e}") def _stop_audio(self) -> None: pygame.mixer.music.stop() self.audio_is_paused = False self.current_audio_file = None # ---------------- Layout Calculation ---------------- def make_layout(self) -> Tuple[pygame.Rect, pygame.Rect, pygame.Rect, Optional[pygame.Rect]]: pad = self.pad # 1. Panel Decision show_panel = False if self.mode == "ANALYSE": if self.level == "SCHUELER": show_panel = (self.step >= 3) else: show_panel = (self.step >= 6) # 2. Vertical Grid title_h = max(60, int(80 * self._scale())) nav_h = max(80, int(100 * self._scale())) available_h = self.win_h - title_h - nav_h - 4 * pad y_title = pad y_content = y_title + title_h + pad y_nav = y_content + available_h + pad # 3. Horizontal Grid # Change: Force 1:1 Aspect Ratio (Square) ONLY for Student Steps 2-6 target_ar = 16 / 9 if self.level == "STUDENT" and (2 <= self.step <= 6): target_ar = 1.0 # 9:9 Aspect Ratio (Square) if show_panel: panel_w = max(int(350 * self._scale()), int(self.win_w * 0.28)) max_video_w = self.win_w - panel_w - 3 * pad video_w = int(available_h * target_ar) if video_w > max_video_w: video_w = max_video_w video_rect = pygame.Rect(pad, y_content, video_w, available_h) panel_x = video_rect.right + pad panel_w_actual = self.win_w - panel_x - pad panel_rect = pygame.Rect(panel_x, y_content, panel_w_actual, available_h) else: panel_rect = None max_video_w = self.win_w - 2 * pad video_w = int(available_h * target_ar) if video_w < max_video_w: x_video = pad + (max_video_w - video_w) // 2 else: x_video = pad video_w = max_video_w video_rect = pygame.Rect(x_video, y_content, video_w, available_h) title_rect = pygame.Rect(pad, y_title, self.win_w - 2*pad, title_h) nav_rect = pygame.Rect(video_rect.x, y_nav, video_rect.w, nav_h) return video_rect, title_rect, nav_rect, panel_rect # ---------------- UI Drawing ---------------- def _draw_title_area(self, rect: pygame.Rect) -> None: if self.mode == "LIVE": text = "Live Workflow" else: step_map = self._step_map() info = step_map.get(self.step) text = info.title if info else f"Schritt {self.step}" self.renderer.draw_text(self.screen, self.font_title, text, (rect.x, rect.centery - self.font_title.get_height()//2), self.theme.TEXT) def _draw_step_indicator_global(self) -> None: buttons_w = self.home_btn_w + self.lang_btn_w + 3 * self.pad ind_w = int(self.win_w * 0.25) ind_h = int(26 * self._scale()) gap = int(50 * self._scale()) x = self.win_w - buttons_w - ind_w - gap title_h = max(60, int(80 * self._scale())) y = self.pad + (title_h - ind_h) // 2 rect = pygame.Rect(x, y, ind_w, ind_h) self.renderer.draw_step_indicator(self.screen, rect, step=self.step, total_steps=self._total_steps(), font_small=self.font_small) def _draw_navigation_area(self, rect: pygame.Rect) -> None: self.nav_prev_rect = pygame.Rect(0,0,0,0) self.nav_next_rect = pygame.Rect(0,0,0,0) self.nav_action_rect = pygame.Rect(0,0,0,0) self.nav_audio_rect = pygame.Rect(0,0,0,0) if self.mode == "LIVE": btn_w = min(rect.w, int(600 * self._scale())) btn_h = rect.h btn_x = rect.centerx - btn_w // 2 self.nav_action_rect = pygame.Rect(btn_x, rect.y, btn_w, btn_h) lbl = "Analyse starten" if self.lang == "DE" else "Start Analysis" self.renderer.draw_button(self.screen, self.nav_action_rect, lbl, self.font_ui, primary=True) else: # Audio Check audio_path = self._get_audio_filename() has_audio = (audio_path is not None) gap = int(20 * self._scale()) if has_audio: btn_w = (rect.w - 2 * gap) // 3 btn_h = rect.h self.nav_prev_rect = pygame.Rect(rect.x, rect.y, btn_w, btn_h) self.nav_audio_rect = pygame.Rect(rect.x + btn_w + gap, rect.y, btn_w, btn_h) self.nav_next_rect = pygame.Rect(rect.x + 2*btn_w + 2*gap, rect.y, btn_w, btn_h) is_playing = pygame.mixer.music.get_busy() and not self.audio_is_paused audio_lbl = "Audio ||" if is_playing else "Audio ▶" self.renderer.draw_button(self.screen, self.nav_audio_rect, audio_lbl, self.font_ui, primary=True) else: btn_w = (rect.w - gap) // 2 btn_h = rect.h self.nav_prev_rect = pygame.Rect(rect.x, rect.y, btn_w, btn_h) self.nav_next_rect = pygame.Rect(rect.x + btn_w + gap, rect.y, btn_w, btn_h) lbl_prev = "Zurück" if self.lang == "DE" else "Back" is_last = self.step >= self._total_steps() lbl_next = ("Beenden" if self.lang == "DE" else "Finish") if is_last else ("Weiter" if self.lang == "DE" else "Next") self.renderer.draw_button(self.screen, self.nav_prev_rect, lbl_prev, self.font_ui, primary=False) self.renderer.draw_button(self.screen, self.nav_next_rect, lbl_next, self.font_ui, primary=True) def _draw_right_panel_content(self, panel_rect: pygame.Rect) -> None: self.renderer.draw_card(self.screen, panel_rect, fill=self.theme.PANEL_2, outline=self.theme.LINE, radius=self.theme.RADIUS) header_h = int(60 * self._scale()) self.renderer.draw_text(self.screen, self.font_header, "Ergebnisse", (panel_rect.x + 20, panel_rect.y + 20), self.theme.TEXT) # 1. Definiere den Bereich für das Diagramm chart_top_y = panel_rect.y + header_h # Wir reservieren ca. 35% der Panel-Höhe für das Diagramm (oder fix 160px skalierbar) chart_height = int(160 * self._scale()) content_rect = pygame.Rect( panel_rect.x + 14, chart_top_y, panel_rect.w - 28, chart_height ) # Zeichne Diagramm chart_font = self._load_font(self._fsize(26)) self.renderer.draw_bar_chart(self.screen, content_rect, self.snapshot.top3, self.args.threshold, chart_font, self.font_ui) # Draw QR Code & Bias Info only in Student Step 7 if self.level == "STUDENT" and self.step == 7: # Layout Cursor: Wo sind wir jetzt? (Unterhalb des Diagramms) cursor_y = content_rect.bottom + int(30 * self._scale()) # 1. Info Text (Bias/Fehler) bias_text = "Hinweis: KI ist nicht objektiv (Bias). Fehler sind möglich!" if self.lang == "DE" else "Note: AI is not objective (Bias). Errors are possible!" txt_surf = self.font_small.render(bias_text, True, self.theme.WARN) txt_rect = txt_surf.get_rect(centerx=panel_rect.centerx, top=cursor_y) self.screen.blit(txt_surf, txt_rect) # Update Cursor (Text-Höhe + Padding) cursor_y = txt_rect.bottom + int(20 * self._scale()) # 2. QR Code (Nur wenn Platz ist) if self._qr_code_img: available_h = panel_rect.bottom - cursor_y - 20 if available_h > 80: # Maximal verfügbare Breite/Höhe nutzen, aber nicht riesig werden size = min(content_rect.w - 40, available_h) size = min(size, int(300 * self._scale())) # Cap size # Label above QR lbl = self.font_small.render("Source Code (GitHub)", True, self.theme.TEXT_MUTED) lbl_rect = lbl.get_rect(centerx=panel_rect.centerx, top=cursor_y) self.screen.blit(lbl, lbl_rect) cursor_y = lbl_rect.bottom + 5 # Draw QR Code scaled_qr = pygame.transform.smoothscale(self._qr_code_img, (int(size), int(size))) qr_rect = scaled_qr.get_rect(centerx=panel_rect.centerx, top=cursor_y) self.screen.blit(scaled_qr, qr_rect) def _draw_home_button(self) -> None: pad = self.pad w, h = self.home_btn_w, self.home_btn_h self.home_button_rect = pygame.Rect(self.win_w - w - pad, pad, w, h) self.renderer.draw_button(self.screen, self.home_button_rect, self._t("home"), self.font_ui, primary=False) def _draw_lang_button(self) -> None: w, h = self.lang_btn_w, self.lang_btn_h pad = self.pad x = self.win_w - self.home_btn_w - 2 * pad - w self.lang_button_rect = pygame.Rect(x, pad, w, h) self.renderer.draw_card(self.screen, self.lang_button_rect, fill=self.theme.PANEL_2, outline=self.theme.LINE, radius=max(10, int(12 * self._scale()))) pygame.draw.rect(self.screen, self.theme.ACCENT, self.lang_button_rect, width=max(1, int(2 * self._scale())), border_radius=max(10, int(12 * self._scale()))) txt = self.font_ui.render(self._t("lang_label"), True, self.theme.TEXT) self.screen.blit(txt, txt.get_rect(center=self.lang_button_rect.center)) def _toggle_lang(self) -> None: self.lang = "EN" if self.lang == "DE" else "DE" self._invalidate_caches() # ---------------- Live Intro / Landing / Gate ---------------- def _liveintro_button_layout(self) -> None: btn_w = int(self.win_w * 0.60) btn_w = max(int(520 * self._scale()), min(btn_w, int(1400 * self._scale()))) btn_h = max(int(90 * self._scale()), int(self.win_h * 0.11)) btn_x = (self.win_w - btn_w) // 2 btn_y = int(self.win_h * 0.72) self.cta_button_rect = pygame.Rect(btn_x, btn_y, btn_w, btn_h) def _draw_liveintro(self) -> None: self._recompute_responsive() self.screen.fill(self.theme.BG) if self.snapshot is not None and self.snapshot.frame_rgb is not None: surf = surfarray.make_surface(self.snapshot.frame_rgb.swapaxes(0, 1)) self.screen.blit(pygame.transform.smoothscale(surf, (self.win_w, self.win_h)), (0, 0)) if self.snapshot.dets: for d in self.snapshot.dets: r = self.renderer.rect_in_video_coords(d.box, self.snapshot.src_size, pygame.Rect(0, 0, self.win_w, self.win_h)) pygame.draw.rect(self.screen, self.renderer.conf_color(d.conf), r, width=2) self._liveintro_button_layout() self.renderer.draw_button(self.screen, self.cta_button_rect, self._t("liveintro_cta"), self.font_landing_btn, primary=True) pygame.display.flip() self.clock.tick(30) def _landing_layout(self) -> None: bw = int(self.win_w * 0.30) bh = int(self.win_h * 0.18) bw = max(int(300 * self._scale()), min(bw, int(720 * self._scale()))) bh = max(int(120 * self._scale()), min(bh, int(240 * self._scale()))) y = int(self.win_h * 0.62) gap = max(int(self.win_w * 0.06), int(100 * self._scale())) left_x = (self.win_w // 2) - (gap // 2) - bw right_x = (self.win_w // 2) + (gap // 2) self.level_left_rect = pygame.Rect(left_x, y, bw, bh) self.level_right_rect = pygame.Rect(right_x, y, bw, bh) def _draw_level_button(self, rect: pygame.Rect, label: str) -> None: self.renderer.draw_button(self.screen, rect, label, self.font_landing_btn, primary=True, border_width=self.level_border_w) def _draw_landing(self) -> None: self._recompute_responsive() bg = self._get_landing_bg_scaled() if bg is not None: self.screen.blit(bg, (0, 0)) else: self.screen.fill((7, 14, 26)) overlay = pygame.Surface((self.win_w, self.win_h), pygame.SRCALPHA) overlay.fill((0, 0, 0, 70)) self.screen.blit(overlay, (0, 0)) self._landing_layout() col = self.theme.BTN_PRIMARY_BORDER title_surf = self.font_landing_title.render(self._t("landing_title"), True, col) self.screen.blit(title_surf, title_surf.get_rect(center=(self.win_w // 2, int(self.win_h * 0.22)))) sub_surf = self.font_landing_sub.render(self._t("landing_sub"), True, col) self.screen.blit(sub_surf, sub_surf.get_rect(center=(self.win_w // 2, int(self.win_h * 0.36)))) self._draw_level_button(self.level_left_rect, self._t("level_left")) self._draw_level_button(self.level_right_rect, self._t("level_right")) hint_surf = self.font_ui.render(self._t("hint"), True, col) self.screen.blit(hint_surf, hint_surf.get_rect(center=(self.win_w // 2, int(self.win_h * 0.92)))) self._draw_home_button() self._draw_lang_button() pygame.display.flip() self.clock.tick(30) def _start_detection(self, level: str) -> None: self.level = level self._ensure_camera() if self.transformer is None: self.transformer = StepTransformer() self.mode = "LIVE" self.step = 0 self.gate_next_step = None self._invalidate_caches() self.state = "RUNNING" def _gate_layout(self) -> None: btn_w_total = int(self.win_w * 0.35) btn_w_total = max(int(320 * self._scale()), min(btn_w_total, int(800 * self._scale()))) btn_h = max(int(90 * self._scale()), int(self.win_h * 0.11)) y = int(self.win_h * 0.80) gap = int(20 * self._scale()) # Two buttons side by side single_btn_w = (btn_w_total - gap) // 2 start_x = (self.win_w - btn_w_total) // 2 self.gate_prev_rect = pygame.Rect(start_x, y, single_btn_w, btn_h) self.gate_next_rect = pygame.Rect(start_x + single_btn_w + gap, y, single_btn_w, btn_h) def _enter_gate_for_step(self, target_step: int) -> None: self.gate_next_step = target_step self.state = "GATE" self._invalidate_caches() def _accept_gate(self) -> None: if self.gate_next_step is None: self.state = "RUNNING"; return self.mode = "ANALYSE" self.step = self.gate_next_step self.gate_next_step = None self.state = "RUNNING" self._invalidate_caches() def _gate_back(self) -> None: # Wenn wir bei Step 1 sind, geht es zurück zu LIVE if self.gate_next_step == 1: self.mode = "LIVE" self.step = 0 self.state = "RUNNING" self.gate_next_step = None self._invalidate_caches() else: # Ansonsten zurück zum vorherigen Analyse-Schritt (ohne Gate) target = (self.gate_next_step or 2) - 1 self.mode = "ANALYSE" self.step = target self.state = "RUNNING" self.gate_next_step = None self._invalidate_caches() def _load_gate_animation_frames(self, step_n: int) -> None: sequences = { 1: dict(folder="schritt_1_experte", prefix="schritt1experte", start=1, end=62), 2: dict(folder="schritt_2_experte", prefix="schritt2experte", start=1, end=101), 3: dict(folder="schritt_3_experte", prefix="schritt3experte", start=1, end=48), 4: dict(folder="schritt_4_experte", prefix="schritt4experte", start=1, end=78), 5: dict(folder="schritt_5_experte", prefix="schritt5experte", start=1, end=29), 6: dict(folder="schritt_6_experte", prefix="schritt6experte", start=1, end=68), 7: dict(folder="schritt_7_experte", prefix="schritt7experte", start=1, end=80), } seq = sequences.get(step_n) if seq is None: self.gate_anim_frames = None; self.gate_anim_key = None; return folder = os.path.join(self.base_dir, "assets", seq["folder"]) prefix = seq["prefix"]; start = seq["start"]; end = seq["end"] key = f"{folder}:{prefix}:{start}:{end}" if self.gate_anim_key == key and self.gate_anim_frames is not None: return frames = [] for i in range(start, end + 1): path = os.path.join(folder, f"{prefix}{i}.jpg") try: frames.append(pygame.image.load(path).convert()) except Exception as e: if i == start: print(f"DEBUG: Konnte {path} nicht laden. Grund: {e}") self.gate_anim_frames = frames if frames else None self.gate_anim_key = key self.gate_anim_idx = 0 self.gate_anim_last_ms = pygame.time.get_ticks() def _draw_gate(self) -> None: self._recompute_responsive() bg = self._get_landing_bg_scaled() if bg is not None: self.screen.blit(bg, (0, 0)) else: self.screen.fill((0, 0, 0)) overlay = pygame.Surface((self.win_w, self.win_h), pygame.SRCALPHA) overlay.fill((0, 0, 0, 85)) self.screen.blit(overlay, (0, 0)) self._gate_layout() assert self.snapshot is not None gate_map = self._gate_map() step_n = self.gate_next_step or 1 info = gate_map.get(step_n) if info is None: info = StepInfo(title=f"Zwischenschritt {step_n}", body="") outer = pygame.Rect(int(self.win_w * 0.06), int(self.win_h * 0.10), int(self.win_w * 0.88), int(self.win_h * 0.62)) gap = max(12, int(18 * self._scale())) text_w = int(outer.w * (2 / 3)) - gap // 2 anim_w = outer.w - text_w - gap text_rect = pygame.Rect(outer.x, outer.y, text_w, outer.h) anim_rect = pygame.Rect(text_rect.right + gap, outer.y, anim_w, outer.h) target_w, target_h = 720, 405 anim_frame_rect = pygame.Rect(0, 0, target_w, target_h) anim_frame_rect.center = anim_rect.center if anim_frame_rect.w > anim_rect.w or anim_frame_rect.h > anim_rect.h: scale = min(anim_rect.w / target_w, anim_rect.h / target_h) anim_frame_rect.size = (max(1, int(target_w * scale)), max(1, int(target_h * scale))) anim_frame_rect.center = anim_rect.center if self.gate_cached_step != step_n: title, body = self.text_layout.split_title_body(info.title, info.body) self.gate_cached_title_font, self.gate_cached_body_font = self.text_layout.fit_title_and_body( title, body, text_rect, min_body=self._fsize(18), max_body=self._fsize(34), title_ratio=1.25, line_spacing=max(2, int(4 * self._scale())), ) self.gate_cached_title_lines = self.text_layout.wrap_lines(title, self.gate_cached_title_font, text_rect.w) self.gate_cached_body_lines = self.text_layout.wrap_lines(body, self.gate_cached_body_font, text_rect.w) if body else [] self.gate_cached_step = step_n y = text_rect.y y = self.text_layout.draw_wrapped_lines(self.screen, self.gate_cached_title_lines, self.gate_cached_title_font, (235, 235, 235), text_rect.x, y, line_spacing=max(2, int(6 * self._scale()))) if self.gate_cached_body_lines: y += self.gate_cached_body_font.get_linesize() self.text_layout.draw_wrapped_lines(self.screen, self.gate_cached_body_lines, self.gate_cached_body_font, (200, 200, 200), text_rect.x, y, line_spacing=max(2, int(6 * self._scale()))) self._load_gate_animation_frames(step_n) self.renderer.draw_card(self.screen, anim_frame_rect, fill=(10, 10, 10), outline=(60, 60, 60), radius=max(12, int(16 * self._scale()))) if self.gate_anim_frames: now = pygame.time.get_ticks() if now - self.gate_anim_last_ms >= self.gate_anim_frame_ms: self.gate_anim_idx = (self.gate_anim_idx + 1) % len(self.gate_anim_frames) self.gate_anim_last_ms = now frame = self.gate_anim_frames[self.gate_anim_idx] new_size = (max(1, int(frame.get_width() * min(anim_frame_rect.w / frame.get_width(), anim_frame_rect.h / frame.get_height()))), max(1, int(frame.get_height() * min(anim_frame_rect.w / frame.get_width(), anim_frame_rect.h / frame.get_height())))) frame_s = pygame.transform.smoothscale(frame, new_size) self.screen.blit(frame_s, frame_s.get_rect(center=anim_frame_rect.center).topleft) # Draw Buttons self.renderer.draw_button(self.screen, self.gate_prev_rect, self._t("back"), self.font_landing_btn, primary=False) self.renderer.draw_button(self.screen, self.gate_next_rect, self._t("gate_btn"), self.font_landing_btn, primary=True) self._draw_home_button() self._draw_lang_button() pygame.display.flip() self.clock.tick(30) # ---------------- Events ---------------- def handle_events(self) -> None: for event in pygame.event.get(): if event.type == pygame.QUIT: self.running = False elif event.type == pygame.KEYDOWN: if event.key in (pygame.K_q, pygame.K_ESCAPE): self.running = False if self.state == "GATE": if event.key == pygame.K_RETURN: self._accept_gate(); return if event.key == pygame.K_BACKSPACE: self._gate_back(); return if self.state == "RUNNING": if event.key == pygame.K_SPACE: if self.mode == "LIVE": if self._is_student(): self._enter_gate_for_step(1) else: self.mode, self.step = "ANALYSE", 1; self._invalidate_caches() else: self.mode, self.step = "LIVE", 0; self._invalidate_caches() elif self.mode == "ANALYSE": if event.key == pygame.K_RETURN: nxt = min(self._total_steps(), self.step + 1) if nxt != self.step: if self._is_student(): self._enter_gate_for_step(nxt) else: self.step = nxt; self._invalidate_caches() elif event.key == pygame.K_BACKSPACE: if self._is_student(): # CHANGE: Im Student-Mode geht BACKSPACE jetzt zum Gate des AKTUELLEN Steps zurück self._enter_gate_for_step(self.step) else: prv = max(1, self.step - 1) if prv != self.step: self.step = prv; self._invalidate_caches() elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: if self.state == "LIVEINTRO": if self.cta_button_rect.collidepoint(event.pos): self.state = "LANDING"; return if self.state in ("LANDING", "RUNNING", "GATE"): if self.home_button_rect.collidepoint(event.pos): self._go_home(); return if self.lang_button_rect.collidepoint(event.pos): self._toggle_lang(); return if self.state == "LANDING": if self.level_left_rect.collidepoint(event.pos): self._start_detection("SCHUELER"); return if self.level_right_rect.collidepoint(event.pos): self._start_detection("STUDENT"); return if self.state == "GATE": if self.gate_next_rect.collidepoint(event.pos): self._accept_gate(); return if self.gate_prev_rect.collidepoint(event.pos): self._gate_back(); return if self.state == "RUNNING": # Audio Button Check if self.nav_audio_rect.collidepoint(event.pos): self._toggle_audio() return if self.mode == "LIVE": if self.nav_action_rect.collidepoint(event.pos): if self._is_student(): self._enter_gate_for_step(1) else: self.mode, self.step = "ANALYSE", 1; self._invalidate_caches() elif self.mode == "ANALYSE": if self.nav_next_rect.collidepoint(event.pos): if self.step == self._total_steps(): self.mode, self.step = "LIVE", 0; self._invalidate_caches() else: nxt = min(self._total_steps(), self.step + 1) if nxt != self.step: if self._is_student(): self._enter_gate_for_step(nxt) else: self.step = nxt; self._invalidate_caches() if self.nav_prev_rect.collidepoint(event.pos): if self._is_student(): # CHANGE: Im Student-Mode geht der ZURÜCK-Button jetzt zum Gate des AKTUELLEN Steps zurück self._enter_gate_for_step(self.step) else: prv = max(1, self.step - 1) if prv != self.step: self.step = prv; self._invalidate_caches() def update(self) -> None: if self.state == "LIVEINTRO": self._ensure_camera() self.snapshot = self.detector.capture_snapshot() elif self.state == "RUNNING" and self.mode == "LIVE": self.snapshot = self.detector.capture_snapshot() # ---------------- Left overlays helpers ---------------- def _draw_hud_lines(self, video_rect: pygame.Rect, lines: List[str]) -> None: x, y = video_rect.x + self.pad, video_rect.y + self.pad for line in lines: self.renderer.draw_pill(self.screen, self.font_small, line, (x, y), fg=self.theme.TEXT) y += self.font_small.get_linesize() + 6 def _draw_roi_overlay(self, video_rect: pygame.Rect) -> None: if not self.snapshot or not self.snapshot.debug.get("roi"): return r = self.renderer.rect_in_video_coords(self.snapshot.debug["roi"], self.snapshot.src_size, video_rect) pygame.draw.rect(self.screen, (80, 160, 255), r, width=2) def _draw_det_list(self, video_rect: pygame.Rect, dets, color=None, draw_label=False) -> None: if not self.snapshot: return for d in dets: r = self.renderer.rect_in_video_coords(d.box, self.snapshot.src_size, video_rect) col = color if color else self.renderer.conf_color(d.conf) pygame.draw.rect(self.screen, col, r, width=2) if draw_label: label = f"{d.label} {d.conf*100:.0f}%" txt = self.font_ui.render(label, True, self.theme.TEXT) pill = pygame.Surface((txt.get_width()+16, txt.get_height()+10), pygame.SRCALPHA) pill.fill((15, 16, 20, 180)) self.screen.blit(pill, (r.x, max(video_rect.y+6, r.y-txt.get_height()-16))) self.screen.blit(txt, (r.x+8, max(video_rect.y+10, r.y-txt.get_height()-12))) def _draw_pixel_inspector(self, video_rect: pygame.Rect) -> None: if self.step != 1: return mx, my = pygame.mouse.get_pos() if not video_rect.collidepoint(mx, my): return # Hole die Farbe direkt vom Bildschirm (genau das Pixel unter der Maus) try: col = self.screen.get_at((mx, my)) except Exception: return r, g, b, _ = col # Tooltip Text text = f"R:{r} G:{g} B:{b}" font = self.font_ui txt_surf = font.render(text, True, (255, 255, 255)) # Weißer Text # Positionierung (etwas versetzt, damit die Maus nicht verdeckt wird) tip_x = mx + 20 tip_y = my + 20 # Am Bildschirmrand? Nach links/oben schieben if tip_x + txt_surf.get_width() > self.win_w: tip_x = mx - txt_surf.get_width() - 15 if tip_y + txt_surf.get_height() > self.win_h: tip_y = my - txt_surf.get_height() - 15 bg_rect = pygame.Rect(tip_x - 6, tip_y - 6, txt_surf.get_width() + 12, txt_surf.get_height() + 12) # Zeichnen pygame.draw.rect(self.screen, (20, 20, 20), bg_rect, border_radius=6) # Dunkler Hintergrund pygame.draw.rect(self.screen, (r, g, b), bg_rect, width=2, border_radius=6) # Rahmen in Pixelfarbe self.screen.blit(txt_surf, (tip_x, tip_y)) # ---------------- Draw Main ---------------- def draw(self) -> None: if self.state == "LIVEINTRO": self._draw_liveintro(); return if self.state == "LANDING": self._draw_landing(); return if self.state == "GATE": self._draw_gate(); return self._recompute_responsive() assert self.snapshot is not None video_rect, title_rect, nav_rect, panel_rect = self.make_layout() self.screen.fill(self.theme.BG) # 1. Video self.renderer.draw_card(self.screen, video_rect, fill=(12, 13, 16), outline=self.theme.LINE, radius=self.theme.RADIUS) self._draw_left_view(video_rect) self._draw_pixel_inspector(video_rect) # 2. Title self._draw_title_area(title_rect) # 3. Nav Buttons self._draw_navigation_area(nav_rect) # 4. Optional Panel (Scores) if panel_rect: self._draw_right_panel_content(panel_rect) # 5. Global Elements self._draw_step_indicator_global() self._draw_home_button() self._draw_lang_button() pygame.display.flip() self.clock.tick(30) def _draw_left_view(self, video_rect: pygame.Rect) -> None: if self.snapshot.frame_rgb is None: return if self.mode == "LIVE": surf = surfarray.make_surface(self.snapshot.frame_rgb.swapaxes(0, 1)) self.screen.blit(pygame.transform.smoothscale(surf, (video_rect.w, video_rect.h)), video_rect.topleft) for d in self.snapshot.dets: r = self.renderer.rect_in_video_coords(d.box, self.snapshot.src_size, video_rect) pygame.draw.rect(self.screen, self.renderer.conf_color(d.conf), r, width=2) self._draw_hud_lines(video_rect, [f"Stream: {self.snapshot.debug.get('src_size', (0,0))}"]) else: if self.sim_step_cached != self.step: sim_rgb = self.transformer.apply(self.snapshot.frame_rgb, self.step, self.level) self.sim_surface = surfarray.make_surface(sim_rgb.swapaxes(0, 1)) self.sim_step_cached = self.step if self.sim_surface: self.screen.blit(pygame.transform.smoothscale(self.sim_surface, (video_rect.w, video_rect.h)), video_rect.topleft) dbg = self.snapshot.debug if self.level == "SCHUELER": if self.step == 1: self.renderer.draw_pixel_grid(self.screen, video_rect, spacing=max(18, int(26*self._scale()))) self.renderer.draw_pill(self.screen, self.font_ui, "RGB Pixel", (video_rect.x+self.pad, video_rect.y+self.pad), fg=self.theme.TEXT) if self.step == 3: self._draw_det_list(video_rect, self.snapshot.raw_dets, color=(120, 120, 120)) self._draw_det_list(video_rect, self.snapshot.dets[:10]) if self.step == 4 and self.snapshot.top_dets: self._draw_det_list(video_rect, self.snapshot.top_dets, draw_label=True) else: if self.step == 1: self._draw_hud_lines(video_rect, ["Step1 Capture"]) elif self.step == 2: self._draw_roi_overlay(video_rect) self._draw_hud_lines(video_rect, ["Step2 ROI/Aspect", f"ROI: {dbg.get('roi')}"]) elif self.step == 3: self._draw_hud_lines(video_rect, ["Step3 Inference (IMX500)"]) elif self.step == 4: lines = ["Step4 Read tensors"] if dbg.get("output_shapes"): lines.extend([f"out{i}: {s}" for i, s in enumerate(dbg["output_shapes"][:2])]) self._draw_hud_lines(video_rect, lines) elif self.step == 5: self._draw_det_list(video_rect, self.snapshot.raw_dets, color=(255, 220, 80)) self._draw_hud_lines(video_rect, ["Step5 Parse candidates"]) elif self.step == 6: self._draw_det_list(video_rect, self.snapshot.raw_dets, color=(120, 120, 120)) self._draw_det_list(video_rect, self.snapshot.dets[:10]) self._draw_hud_lines(video_rect, ["Step6 Filter/Rank"]) elif self.step == 7: self._draw_det_list(video_rect, self.snapshot.top_dets, draw_label=True) self._draw_hud_lines(video_rect, ["Step7 Render"]) # ---------------- Main Loop ---------------- def run(self) -> None: try: while self.running: self.handle_events() self.update() self.draw() finally: pygame.quit() if self.detector: self.detector.stop() def get_args() -> argparse.Namespace: p = argparse.ArgumentParser() p.add_argument("--model", required=True) p.add_argument("--threshold", type=float, default=0.55) p.add_argument("--iou", type=float, default=0.65) p.add_argument("--max-detections", type=int, default=10) p.add_argument("--bbox-normalization", action=argparse.BooleanOptionalAction, default=None) p.add_argument("--bbox-order", choices=["yx", "xy"], default="yx") p.add_argument("--postprocess", choices=["", "nanodet"], default=None) p.add_argument("-r", "--preserve-aspect-ratio", action=argparse.BooleanOptionalAction, default=None) p.add_argument("--labels", type=str, default=None) p.add_argument("--cam-width", type=int, default=1280) p.add_argument("--cam-height", type=int, default=720) return p.parse_args() def main() -> None: args = get_args() App(args).run() if __name__ == "__main__": main()