# -*- coding: utf-8 -*- """ Braille-basiertes Loss-Chart Widget. Zeigt Training- und Validation-Loss als Braille-Grafik mit Y-Achse, Legende und optionalem Downsampling. """ from __future__ import annotations from textual.widget import Widget from textual.app import RenderResult from rich.text import Text # Braille-Zeichen: 2x4 Punkte pro Zeichen # Bit-Layout: # 0 3 # 1 4 # 2 5 # 6 7 BRAILLE_BASE = 0x2800 BRAILLE_DOTS = [ [0x01, 0x08], # Zeile 0: Bit 0, Bit 3 [0x02, 0x10], # Zeile 1: Bit 1, Bit 4 [0x04, 0x20], # Zeile 2: Bit 2, Bit 5 [0x40, 0x80], # Zeile 3: Bit 6, Bit 7 ] class LossChart(Widget): """ Braille-basiertes Chart fuer Training/Validation Loss. Zeigt zwei Linien (Train=cyan, Val=magenta) mit Y-Achse (5 Stufen) und Legende. """ DEFAULT_CSS = """ LossChart { height: 14; margin: 0 1; } """ def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self._train_loss: list[float] = [] self._val_loss: list[float] = [] self._chart_height: int = 12 def update_data( self, train_loss: list[float], val_loss: list[float] | None = None, ) -> None: """Aktualisiert die Chart-Daten.""" self._train_loss = list(train_loss) self._val_loss = list(val_loss) if val_loss else [] self.refresh() def render(self) -> RenderResult: """Rendert das Braille-Chart.""" if not self._train_loss: return Text("Keine Daten", style="dim") # Daten vorbereiten (mit Downsampling bei >200 Punkten) train = self._downsample(self._train_loss) val = self._downsample(self._val_loss) if self._val_loss else [] # Dimensionen berechnen chart_rows = self._chart_height # Braille: 4 Unterzeilen pro Textzeile, 2 Spalten pro Zeichen pixel_rows = chart_rows * 4 all_values = train + val y_min = min(all_values) y_max = max(all_values) if y_max == y_min: y_max = y_min + 1.0 # Verfuegbare Breite (ohne Y-Achse Label, ca. 8 Zeichen) available_width = max(self.size.width - 10, 20) pixel_cols = available_width * 2 # Daten auf Pixel-Koordinaten abbilden def to_pixel_y(value: float) -> int: normalized = (value - y_min) / (y_max - y_min) return max(0, min(pixel_rows - 1, int((1.0 - normalized) * (pixel_rows - 1)))) def to_pixel_x(index: int, total: int) -> int: if total <= 1: return 0 return int(index / (total - 1) * (pixel_cols - 1)) # Grid erstellen (chart_rows x available_width) # Jede Zelle speichert: (train_dot, val_dot) grid_train = [[False] * pixel_cols for _ in range(pixel_rows)] grid_val = [[False] * pixel_cols for _ in range(pixel_rows)] # Punkte setzen for i, v in enumerate(train): px = to_pixel_x(i, len(train)) py = to_pixel_y(v) if 0 <= px < pixel_cols and 0 <= py < pixel_rows: grid_train[py][px] = True # Verbindungslinie zum naechsten Punkt if i + 1 < len(train): next_px = to_pixel_x(i + 1, len(train)) next_py = to_pixel_y(train[i + 1]) self._draw_line(grid_train, px, py, next_px, next_py, pixel_cols, pixel_rows) for i, v in enumerate(val): px = to_pixel_x(i, len(val)) py = to_pixel_y(v) if 0 <= px < pixel_cols and 0 <= py < pixel_rows: grid_val[py][px] = True if i + 1 < len(val): next_px = to_pixel_x(i + 1, len(val)) next_py = to_pixel_y(val[i + 1]) self._draw_line(grid_val, px, py, next_px, next_py, pixel_cols, pixel_rows) # Braille-Zeichen generieren output = Text() # Y-Achse Labels (5 Stufen) y_labels = [] for i in range(5): val_at = y_max - i * (y_max - y_min) / 4 y_labels.append(f"{val_at:.4f}"[:7].rjust(7)) for row in range(chart_rows): # Y-Achse Label (alle 3 Zeilen) if chart_rows >= 4: label_idx = int(row / (chart_rows - 1) * 4) if chart_rows > 1 else 0 label_idx = min(label_idx, 4) else: label_idx = min(row, 4) if row == 0 or row == chart_rows - 1 or (chart_rows >= 6 and row == chart_rows // 2): output.append(y_labels[label_idx] + " |", style="dim") else: output.append(" |", style="dim") # Braille-Zeichen fuer diese Zeile for col in range(available_width): char_code = BRAILLE_BASE has_train = False has_val = False for sub_row in range(4): for sub_col in range(2): py = row * 4 + sub_row px = col * 2 + sub_col if py < pixel_rows and px < pixel_cols: if grid_train[py][px]: char_code |= BRAILLE_DOTS[sub_row][sub_col] has_train = True if grid_val[py][px]: char_code |= BRAILLE_DOTS[sub_row][sub_col] has_val = True braille = chr(char_code) if has_train and has_val: output.append(braille, style="bold yellow") elif has_train: output.append(braille, style="cyan") elif has_val: output.append(braille, style="magenta") else: output.append(braille, style="dim") output.append("\n") # Legende epoch_count = max(len(self._train_loss), len(self._val_loss)) output.append(" ") output.append(" \u2588 Train-Loss ", style="cyan") if self._val_loss: output.append(" \u2588 Val-Loss ", style="magenta") output.append(f" Epochen: {epoch_count}", style="dim") return output @staticmethod def _draw_line( grid: list[list[bool]], x0: int, y0: int, x1: int, y1: int, max_x: int, max_y: int, ) -> None: """Zeichnet eine Linie mit Bresenham-Algorithmus.""" dx = abs(x1 - x0) dy = abs(y1 - y0) sx = 1 if x0 < x1 else -1 sy = 1 if y0 < y1 else -1 err = dx - dy while True: if 0 <= x0 < max_x and 0 <= y0 < max_y: grid[y0][x0] = True if x0 == x1 and y0 == y1: break e2 = 2 * err if e2 > -dy: err -= dy x0 += sx if e2 < dx: err += dx y0 += sy @staticmethod def _downsample(data: list[float], max_points: int = 200) -> list[float]: """Reduziert Datenpunkte durch Mittelwert-Aggregation.""" if len(data) <= max_points: return list(data) chunk_size = len(data) / max_points result = [] for i in range(max_points): start = int(i * chunk_size) end = int((i + 1) * chunk_size) chunk = data[start:end] if chunk: result.append(sum(chunk) / len(chunk)) return result