| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224 |
- # -*- 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
|