| """Shared helpers for visualization and array handling.""" |
|
|
| from __future__ import annotations |
|
|
| import math |
| from io import BytesIO |
| from typing import Iterable, Sequence |
|
|
| import cv2 |
| import matplotlib |
| import numpy as np |
| from matplotlib import pyplot as plt |
|
|
| matplotlib.use("Agg") |
|
|
|
|
| def to_rgb(image: np.ndarray) -> np.ndarray: |
| """Convert a grayscale or BGR image into RGB uint8.""" |
| if image.ndim == 2: |
| return cv2.cvtColor(image, cv2.COLOR_GRAY2RGB) |
| if image.shape[2] == 4: |
| return cv2.cvtColor(image, cv2.COLOR_RGBA2RGB) |
| return cv2.cvtColor(image, cv2.COLOR_BGR2RGB) |
|
|
|
|
| def normalize_uint8(image: np.ndarray) -> np.ndarray: |
| """Scale a float image to uint8 if needed.""" |
| if image.dtype == np.uint8: |
| return image |
| clipped = np.clip(image, 0.0, 1.0) |
| return (clipped * 255).astype(np.uint8) |
|
|
|
|
| def figure_to_image(fig: plt.Figure) -> np.ndarray: |
| """Render a Matplotlib figure into an RGB numpy image.""" |
| buffer = BytesIO() |
| fig.savefig(buffer, format="png", bbox_inches="tight", dpi=160) |
| plt.close(fig) |
| buffer.seek(0) |
| data = np.frombuffer(buffer.getvalue(), dtype=np.uint8) |
| decoded = cv2.imdecode(data, cv2.IMREAD_COLOR) |
| return cv2.cvtColor(decoded, cv2.COLOR_BGR2RGB) |
|
|
|
|
| def plot_confusion_matrix( |
| matrix: np.ndarray, |
| labels: Sequence[str], |
| title: str, |
| cmap: str = "YlOrRd", |
| ) -> np.ndarray: |
| """Create a labeled confusion-matrix heatmap.""" |
| fig, ax = plt.subplots(figsize=(6.2, 5.2)) |
| heat = ax.imshow(matrix, cmap=cmap) |
| ax.set_title(title, fontsize=14, fontweight="bold") |
| ax.set_xticks(range(len(labels))) |
| ax.set_yticks(range(len(labels))) |
| ax.set_xticklabels(labels, rotation=25, ha="right") |
| ax.set_yticklabels(labels) |
| ax.set_xlabel("Predicted") |
| ax.set_ylabel("True") |
| ax.figure.colorbar(heat, ax=ax, shrink=0.82) |
|
|
| threshold = float(matrix.max()) * 0.55 if matrix.size else 0.0 |
| for row in range(matrix.shape[0]): |
| for col in range(matrix.shape[1]): |
| value = int(matrix[row, col]) |
| color = "white" if value >= threshold else "#2f1d0b" |
| ax.text(col, row, value, ha="center", va="center", color=color, fontsize=11) |
|
|
| fig.tight_layout() |
| return figure_to_image(fig) |
|
|
|
|
| def plot_lines( |
| series: Sequence[tuple[str, Sequence[float], str]], |
| title: str, |
| xlabel: str = "Epoch", |
| ylabel: str = "Value", |
| ) -> np.ndarray: |
| """Plot multiple lines on a single axis.""" |
| fig, ax = plt.subplots(figsize=(7.2, 4.0)) |
| for name, values, color in series: |
| xs = np.arange(1, len(values) + 1) |
| ax.plot(xs, values, label=name, linewidth=2.2, color=color) |
| ax.set_title(title, fontsize=14, fontweight="bold") |
| ax.set_xlabel(xlabel) |
| ax.set_ylabel(ylabel) |
| ax.grid(alpha=0.25) |
| ax.legend(frameon=False) |
| fig.tight_layout() |
| return figure_to_image(fig) |
|
|
|
|
| def plot_bar( |
| labels: Sequence[str], |
| values: Sequence[float], |
| title: str, |
| color: str = "#ef7d32", |
| ylabel: str = "Value", |
| ) -> np.ndarray: |
| """Create a simple labeled bar chart.""" |
| fig, ax = plt.subplots(figsize=(7.0, 4.0)) |
| positions = np.arange(len(labels)) |
| ax.bar(positions, values, color=color, edgecolor="#7a3310") |
| ax.set_title(title, fontsize=14, fontweight="bold") |
| ax.set_xticks(positions) |
| ax.set_xticklabels(labels, rotation=20, ha="right") |
| ax.set_ylabel(ylabel) |
| ax.grid(axis="y", alpha=0.25) |
|
|
| upper = max(values) if values else 1.0 |
| for idx, value in enumerate(values): |
| ax.text(idx, value + upper * 0.02, f"{value:.2f}", ha="center", va="bottom", fontsize=10) |
|
|
| fig.tight_layout() |
| return figure_to_image(fig) |
|
|
|
|
| def make_image_grid( |
| images: Sequence[np.ndarray], |
| captions: Sequence[str] | None = None, |
| columns: int = 4, |
| tile_size: tuple[int, int] | None = None, |
| background: tuple[int, int, int] = (247, 241, 232), |
| ) -> np.ndarray: |
| """Lay out images on a canvas with optional captions.""" |
| if not images: |
| blank = np.full((240, 320, 3), background, dtype=np.uint8) |
| cv2.putText( |
| blank, |
| "No images", |
| (92, 126), |
| cv2.FONT_HERSHEY_SIMPLEX, |
| 0.9, |
| (74, 67, 61), |
| 2, |
| cv2.LINE_AA, |
| ) |
| return blank |
|
|
| rgb_images = [normalize_uint8(to_rgb(image)) for image in images] |
| if tile_size is None: |
| tile_h = max(image.shape[0] for image in rgb_images) |
| tile_w = max(image.shape[1] for image in rgb_images) |
| else: |
| tile_w, tile_h = tile_size |
|
|
| columns = max(1, columns) |
| rows = math.ceil(len(rgb_images) / columns) |
| caption_h = 28 if captions else 0 |
| canvas = np.full( |
| (rows * (tile_h + caption_h + 18) + 20, columns * (tile_w + 18) + 20, 3), |
| background, |
| dtype=np.uint8, |
| ) |
|
|
| for index, image in enumerate(rgb_images): |
| row = index // columns |
| col = index % columns |
| y = 12 + row * (tile_h + caption_h + 18) |
| x = 12 + col * (tile_w + 18) |
| resized = cv2.resize(image, (tile_w, tile_h), interpolation=cv2.INTER_AREA) |
| canvas[y : y + tile_h, x : x + tile_w] = resized |
| cv2.rectangle(canvas, (x, y), (x + tile_w, y + tile_h), (201, 165, 130), 1) |
|
|
| if captions: |
| text = captions[index] |
| cv2.putText( |
| canvas, |
| text, |
| (x, y + tile_h + 19), |
| cv2.FONT_HERSHEY_SIMPLEX, |
| 0.52, |
| (84, 60, 42), |
| 1, |
| cv2.LINE_AA, |
| ) |
|
|
| return canvas |
|
|
|
|
| def softmax(logits: np.ndarray) -> np.ndarray: |
| """Numerically stable softmax.""" |
| shifted = logits - logits.max(axis=1, keepdims=True) |
| exp_values = np.exp(shifted) |
| return exp_values / exp_values.sum(axis=1, keepdims=True) |
|
|
|
|