diff --git a/test_visualization_utils.py b/test_visualization_utils.py new file mode 100644 index 00000000..6eb57b71 --- /dev/null +++ b/test_visualization_utils.py @@ -0,0 +1,124 @@ +"""Tests for the visualization_utils module.""" + +import numpy as np +import os +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from visualization_utils import ( + plot_image_grid, + plot_training_history, + plot_confusion_matrix, + generate_classification_report, +) + + +def test_plot_image_grid_saves(): + """Test that image grid saves to file.""" + images = np.random.rand(8, 32, 32) + labels = np.array([0, 1, 2, 0, 1, 2, 0, 1]) + save_path = os.path.join(tempfile.gettempdir(), "test_grid.png") + + plot_image_grid( + images, labels, + class_names=["No Sub", "Vortex", "Sphere"], + save_path=save_path, + ) + assert os.path.exists(save_path), "Grid image should be saved" + os.remove(save_path) + print("[PASS] test_plot_image_grid_saves") + + +def test_plot_image_grid_returns_fig(): + """Test that image grid returns figure when no save path.""" + images = np.random.rand(4, 32, 32) + fig = plot_image_grid(images, title="Test") + assert fig is not None, "Should return figure object" + import matplotlib.pyplot as plt + plt.close(fig) + print("[PASS] test_plot_image_grid_returns_fig") + + +def test_plot_training_history_saves(): + """Test that training history plot saves.""" + history = { + "train_loss": [0.9, 0.7, 0.5, 0.3, 0.2], + "val_loss": [0.95, 0.8, 0.6, 0.45, 0.4], + "train_acc": [0.6, 0.7, 0.8, 0.85, 0.9], + "val_acc": [0.55, 0.65, 0.75, 0.8, 0.82], + } + save_path = os.path.join(tempfile.gettempdir(), "test_history.png") + plot_training_history(history, save_path=save_path) + assert os.path.exists(save_path), "History plot should be saved" + os.remove(save_path) + print("[PASS] test_plot_training_history_saves") + + +def test_plot_confusion_matrix_saves(): + """Test confusion matrix plot saves.""" + cm = np.array([[45, 3, 2], [5, 40, 5], [1, 4, 45]]) + save_path = os.path.join(tempfile.gettempdir(), "test_cm.png") + plot_confusion_matrix( + cm, + class_names=["No Sub", "Vortex", "Sphere"], + save_path=save_path, + ) + assert os.path.exists(save_path), "Confusion matrix should be saved" + os.remove(save_path) + print("[PASS] test_plot_confusion_matrix_saves") + + +def test_plot_confusion_matrix_normalized(): + """Test normalized confusion matrix.""" + cm = np.array([[40, 10], [5, 45]]) + save_path = os.path.join(tempfile.gettempdir(), "test_cm_norm.png") + plot_confusion_matrix(cm, normalize=True, save_path=save_path) + assert os.path.exists(save_path) + os.remove(save_path) + print("[PASS] test_plot_confusion_matrix_normalized") + + +def test_generate_classification_report(): + """Test classification report generation.""" + metrics = { + "accuracy": 0.85, + "no_sub_precision": 0.90, + "no_sub_recall": 0.88, + "no_sub_f1": 0.89, + "vortex_precision": 0.82, + "vortex_recall": 0.80, + "vortex_f1": 0.81, + "macro_precision": 0.86, + "macro_recall": 0.84, + "macro_f1": 0.85, + } + report = generate_classification_report(metrics) + assert "CLASSIFICATION REPORT" in report + assert "0.8500" in report + assert "no_sub" in report + assert "vortex" in report + assert "Macro Average" in report + print("[PASS] test_generate_classification_report") + + +def test_plot_image_grid_rgb(): + """Test with RGB images.""" + images = np.random.rand(4, 32, 32, 3) + fig = plot_image_grid(images) + assert fig is not None + import matplotlib.pyplot as plt + plt.close(fig) + print("[PASS] test_plot_image_grid_rgb") + + +if __name__ == "__main__": + test_plot_image_grid_saves() + test_plot_image_grid_returns_fig() + test_plot_training_history_saves() + test_plot_confusion_matrix_saves() + test_plot_confusion_matrix_normalized() + test_generate_classification_report() + test_plot_image_grid_rgb() + print("\n=== All 7 tests passed! ===") diff --git a/visualization_utils.py b/visualization_utils.py new file mode 100644 index 00000000..6218a6d1 --- /dev/null +++ b/visualization_utils.py @@ -0,0 +1,305 @@ +""" +Visualization Utilities for DeepLense. + +This module provides reusable plotting and visualization functions +for gravitational lensing image analysis. It includes functions for +displaying image grids, plotting training history, visualizing +confusion matrices, and generating classification reports. + +Requires: matplotlib (standard in ML environments) + +Author: Kamala Hasini Burra +""" + +import numpy as np +from typing import Dict, List, Optional, Tuple + +try: + import matplotlib + matplotlib.use("Agg") # Non-interactive backend for saving plots + import matplotlib.pyplot as plt + import matplotlib.gridspec as gridspec + HAS_MATPLOTLIB = True +except ImportError: + HAS_MATPLOTLIB = False + + +def plot_image_grid( + images: np.ndarray, + labels: Optional[np.ndarray] = None, + class_names: Optional[List[str]] = None, + n_cols: int = 4, + figsize: Tuple[int, int] = (12, 8), + title: str = "Sample Images", + save_path: Optional[str] = None, + cmap: str = "viridis", +) -> Optional[object]: + """Display a grid of images with optional labels. + + Parameters + ---------- + images : np.ndarray + Batch of images, shape (N, H, W) or (N, H, W, C). + labels : np.ndarray, optional + Labels for each image. + class_names : list of str, optional + Human-readable class names. + n_cols : int + Number of columns in the grid. + figsize : tuple + Figure size (width, height). + title : str + Main title for the figure. + save_path : str, optional + Path to save the figure. If None, returns the figure object. + cmap : str + Colormap for grayscale images. + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if save_path is None, otherwise saves and returns None. + """ + if not HAS_MATPLOTLIB: + print("Warning: matplotlib not available. Skipping plot.") + return None + + n_images = min(len(images), n_cols * 4) # Max 4 rows + n_rows = (n_images + n_cols - 1) // n_cols + + fig, axes = plt.subplots(n_rows, n_cols, figsize=figsize) + fig.suptitle(title, fontsize=14, fontweight="bold") + + if n_rows == 1: + axes = axes.reshape(1, -1) if n_cols > 1 else np.array([[axes]]) + + for i in range(n_rows * n_cols): + ax = axes[i // n_cols, i % n_cols] + if i < n_images: + img = images[i] + if img.ndim == 2: + ax.imshow(img, cmap=cmap) + else: + ax.imshow(img) + + if labels is not None and i < len(labels): + label = labels[i] + if class_names and label < len(class_names): + ax.set_title(class_names[int(label)], fontsize=9) + else: + ax.set_title(f"Class {label}", fontsize=9) + ax.axis("off") + + plt.tight_layout() + + if save_path: + fig.savefig(save_path, dpi=150, bbox_inches="tight") + plt.close(fig) + return None + return fig + + +def plot_training_history( + history: Dict[str, List[float]], + metrics: Optional[List[str]] = None, + figsize: Tuple[int, int] = (12, 5), + title: str = "Training History", + save_path: Optional[str] = None, +) -> Optional[object]: + """Plot training and validation metrics over epochs. + + Parameters + ---------- + history : dict + Dictionary mapping metric names to lists of values per epoch. + Expected keys like 'train_loss', 'val_loss', 'train_acc', 'val_acc'. + metrics : list of str, optional + Which metrics to plot. If None, plots all available. + figsize : tuple + Figure size. + title : str + Main title. + save_path : str, optional + Path to save figure. + + Returns + ------- + matplotlib.figure.Figure or None + """ + if not HAS_MATPLOTLIB: + print("Warning: matplotlib not available. Skipping plot.") + return None + + if metrics is None: + metrics = list(history.keys()) + + # Group metrics by type (loss vs accuracy) + loss_metrics = [m for m in metrics if "loss" in m.lower()] + acc_metrics = [m for m in metrics if "acc" in m.lower() or "f1" in m.lower()] + other_metrics = [m for m in metrics if m not in loss_metrics and m not in acc_metrics] + + n_plots = sum(1 for g in [loss_metrics, acc_metrics, other_metrics] if g) + fig, axes = plt.subplots(1, max(n_plots, 1), figsize=figsize) + fig.suptitle(title, fontsize=14, fontweight="bold") + + if n_plots <= 1: + axes = [axes] + + plot_idx = 0 + + for group, ylabel in [ + (loss_metrics, "Loss"), + (acc_metrics, "Score"), + (other_metrics, "Value"), + ]: + if not group: + continue + ax = axes[plot_idx] + for metric_name in group: + if metric_name in history: + epochs = range(1, len(history[metric_name]) + 1) + style = "--" if "val" in metric_name else "-" + ax.plot(epochs, history[metric_name], style, label=metric_name, linewidth=1.5) + ax.set_xlabel("Epoch") + ax.set_ylabel(ylabel) + ax.legend(fontsize=8) + ax.grid(True, alpha=0.3) + plot_idx += 1 + + plt.tight_layout() + + if save_path: + fig.savefig(save_path, dpi=150, bbox_inches="tight") + plt.close(fig) + return None + return fig + + +def plot_confusion_matrix( + cm: np.ndarray, + class_names: Optional[List[str]] = None, + figsize: Tuple[int, int] = (8, 6), + title: str = "Confusion Matrix", + save_path: Optional[str] = None, + normalize: bool = False, +) -> Optional[object]: + """Plot a confusion matrix as a heatmap. + + Parameters + ---------- + cm : np.ndarray + Confusion matrix of shape (n_classes, n_classes). + class_names : list of str, optional + Names for each class. + figsize : tuple + Figure size. + title : str + Plot title. + save_path : str, optional + Path to save figure. + normalize : bool + Whether to normalize by row (show percentages). + + Returns + ------- + matplotlib.figure.Figure or None + """ + if not HAS_MATPLOTLIB: + print("Warning: matplotlib not available. Skipping plot.") + return None + + if normalize: + row_sums = cm.sum(axis=1, keepdims=True) + cm_display = np.divide( + cm.astype(float), row_sums, + where=row_sums != 0, out=np.zeros_like(cm, dtype=float), + ) + fmt = ".2f" + else: + cm_display = cm + fmt = "d" + + n_classes = cm.shape[0] + if class_names is None: + class_names = [f"Class {i}" for i in range(n_classes)] + + fig, ax = plt.subplots(figsize=figsize) + im = ax.imshow(cm_display, cmap="Blues", aspect="auto") + + # Add text annotations + for i in range(n_classes): + for j in range(n_classes): + value = cm_display[i, j] + color = "white" if value > cm_display.max() / 2 else "black" + text = f"{value:{fmt}}" if isinstance(value, float) else str(value) + ax.text(j, i, text, ha="center", va="center", color=color, fontsize=10) + + ax.set_xticks(range(n_classes)) + ax.set_yticks(range(n_classes)) + ax.set_xticklabels(class_names, rotation=45, ha="right") + ax.set_yticklabels(class_names) + ax.set_xlabel("Predicted Label") + ax.set_ylabel("True Label") + ax.set_title(title, fontweight="bold") + + fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) + plt.tight_layout() + + if save_path: + fig.savefig(save_path, dpi=150, bbox_inches="tight") + plt.close(fig) + return None + return fig + + +def generate_classification_report( + metrics: Dict[str, float], + class_names: Optional[List[str]] = None, +) -> str: + """Generate a formatted classification report string. + + Parameters + ---------- + metrics : dict + Metrics dictionary from compute_classification_metrics(). + class_names : list of str, optional + Class names for display. + + Returns + ------- + str + Formatted report string. + """ + lines = [] + lines.append("=" * 60) + lines.append("CLASSIFICATION REPORT") + lines.append("=" * 60) + + if "accuracy" in metrics: + lines.append(f"\nOverall Accuracy: {metrics['accuracy']:.4f}") + + # Find per-class metrics + precision_keys = [k for k in metrics if k.endswith("_precision")] + if precision_keys: + lines.append(f"\n{'Class':<20} {'Precision':>10} {'Recall':>10} {'F1-Score':>10}") + lines.append("-" * 52) + + for pk in precision_keys: + cls_name = pk.replace("_precision", "") + prec = metrics.get(f"{cls_name}_precision", 0.0) + rec = metrics.get(f"{cls_name}_recall", 0.0) + f1 = metrics.get(f"{cls_name}_f1", 0.0) + lines.append(f"{cls_name:<20} {prec:>10.4f} {rec:>10.4f} {f1:>10.4f}") + + # Macro averages + lines.append("-" * 52) + if "macro_precision" in metrics: + lines.append( + f"{'Macro Average':<20} " + f"{metrics['macro_precision']:>10.4f} " + f"{metrics['macro_recall']:>10.4f} " + f"{metrics['macro_f1']:>10.4f}" + ) + + lines.append("=" * 60) + return "\n".join(lines)