Source code for HMB.PlotsHelper

import os
import random
import numpy as np
import pandas as pd
from pathlib import Path
import matplotlib.pyplot as plt
from typing import Any, Optional, Tuple
from matplotlib import colors as mcolors
from matplotlib.patches import Patch

COLORS = [
  "orange", "tab:blue", "tab:green", "tab:red", "tab:purple", "tab:brown",
  "tab:pink", "tab:gray", "tab:olive", "tab:cyan", "gold", "lime", "teal",
  "navy", "maroon", "coral", "darkgreen", "darkblue", "darkred", "darkorange"
]
# Shuffle colors to avoid similar adjacent colors in plots.
random.shuffle(COLORS)


[docs] def GetRandomCMAPalette(fromAll=False): r''' Get a random color palette from Matplotlib. Parameters: fromAll (bool): If True, select from all available colormaps. If False, select from a curated subset of visually distinct colormaps. Default is False. Returns: str: Name of the randomly selected colormap. ''' # Get a list of all available colormap names in Matplotlib. if (fromAll): cmapNames = plt.colormaps() else: # Curated list of visually distinct colormaps for better aesthetics in plots. cmapNames = [ "tab10", "Set1", "Set2", "Set3", "Pastel1", "Pastel2", "Dark2", "Accent", "Paired", "viridis", "plasma", "inferno", "magma", "cividis", "tab20", "tab20b", "tab20c" ] # Randomly select one colormap name from the list. randomCmap = random.choice(cmapNames) # Return the name of the randomly selected colormap. return randomCmap
[docs] def GetCmapColors(cmap, noColors, darkColorsOnly=True, darknessThreshold=0.7): r''' Utility to get a list of unique colors from a matplotlib colormap. Parameters: cmap (str or Colormap): Name of the colormap or a Colormap object. noColors (int): Number of distinct colors to generate. darkColorsOnly (bool, optional): Whether to filter out light colors. Default is True. darknessThreshold (float, optional): Brightness threshold to classify colors as dark. Default is 0.7. Returns: list: List of unique RGBA color tuples. ''' # Filter out light colors based on perceived brightness (YIQ formula). def IsDark(color): r, g, b = color[:3] brightness = 0.299 * r + 0.587 * g + 0.114 * b return brightness < darknessThreshold # Get the colormap object. if (isinstance(cmap, str)): cmapObj = plt.get_cmap(cmap) else: cmapObj = cmap # To maximize uniqueness, sample evenly spaced points in the colormap. allColors = [cmapObj(i / max(noColors, 1)) for i in range(noColors)] if (darkColorsOnly): # Filter to keep only dark colors. darkColors = [c for c in allColors if IsDark(c)] # If not enough dark colors, try to fill with more from the colormap. if (len(darkColors) < noColors): # Try to get more dark colors by oversampling. extraColors = [cmapObj(i / 1000.0) for i in range(1000)] extraDark = [c for c in extraColors if (IsDark(c) and c not in darkColors)] darkColors.extend(extraDark) # Remove duplicates while preserving order. seen = set() uniqueDark = [] for c in darkColors: if (c not in seen): uniqueDark.append(c) seen.add(c) darkColors = uniqueDark # If still not enough, repeat or fallback. if (len(darkColors) >= noColors): return darkColors[:noColors] elif (len(darkColors) > 0): repeats = (noColors // len(darkColors)) + 1 extended = (darkColors * repeats)[:noColors] return extended else: return allColors else: # Remove duplicates if any (shouldn't be, but for safety). seen = set() uniqueColors = [] for c in allColors: if (c not in seen): uniqueColors.append(c) seen.add(c) if (len(uniqueColors) >= noColors): return uniqueColors[:noColors] else: repeats = (noColors // len(uniqueColors)) + 1 extended = (uniqueColors * repeats)[:noColors] return extended
# Define a function to plot a heatmap from 2D data with flexible options.
[docs] def PlotHeatmap( data: np.ndarray, rowLabels: list[str], colLabels: list[str], title: str = "Heatmap", xlabel: str = "Column", ylabel: str = "Row", cmap: Any = "viridis", vmin: Optional[float] = None, vmax: Optional[float] = None, valueFormat: str = "{:.2f}", annotate: bool = True, fontSize: int = 7, figSize: Optional[tuple[float, float]] = None, colorbarLabel: Optional[str] = None, save: bool = False, savePath: Optional[Path | str] = None, fileName: Optional[str] = None, dpi: int = 300, display: bool = True, colorbar: bool = True, returnFig: bool = False, ) -> Optional[plt.Figure]: r''' Plot a labeled heatmap from a 2D numeric array with flexible display and saving options. Parameters: data (numpy.ndarray): 2D numeric array to visualize (shape: [n_rows, n_cols]). rowLabels (list): Labels for the heatmap rows (y-axis). Length must match data.shape[0]. colLabels (list): Labels for the heatmap columns (x-axis). Length must match data.shape[1]. title (str): Plot title. Default is "Heatmap". xlabel (str): Label for x-axis. Default is "Column". ylabel (str): Label for y-axis. Default is "Row". cmap (matplotlib.colors.Colormap or str): Colormap to use for the heatmap. Default is "viridis". vmin (float or None): Minimum data value mapped to the colormap. Default is None. vmax (float or None): Maximum data value mapped to the colormap. Default is None. valueFormat (str): Format string used to annotate cell values (default: "{:.2f}"). annotate (bool): Whether to annotate each cell with its numeric value. Default is True. fontSize (int): Font size used for annotations and axis labels. Default is 7. figSize (tuple): Figure size in inches. If None a size is inferred from label lengths. colorbarLabel (str or None): Label for the colorbar if shown. save (bool): Whether to save the plot. Default is False. savePath (str or pathlib.Path or None): Path to save the figure. If provided and `save` is False the function will still save to this path (legacy behavior). fileName (str or None): Alternative filename to save the figure when `save` is True. dpi (int): DPI for saving the figure. Default is 300. display (bool): Whether to display the plot using plt.show(). Default is True. colorbar (bool): Whether to show a colorbar. Default is True. returnFig (bool): Whether to return the matplotlib Figure object. Default is False. Returns: matplotlib.figure.Figure or None: The matplotlib figure object if returnFig is True, otherwise None. Notes: - When saving, a PNG sibling file is written alongside the requested format where possible. - The function tolerates NaN/Inf in the data: those cells are not annotated. - Backwards compatibility: if `savePath` is provided without `save`, the function will still save to `savePath` (behavior maintained from prior versions). Examples -------- .. code-block:: python import numpy as np from HMB.PlotsHelper import PlotHeatmap data = np.random.rand(4, 6) PlotHeatmap( data, rowLabels=[f"R{i}" for i in range(4)], colLabels=[f"C{j}" for j in range(6)], title="Example Heatmap", annotate=True, display=True, ) ''' # Compute default figure size when not provided. if (figSize is None): # Set figure width and height based on label counts. figSize = ( max(6.0, float(len(colLabels)) * 0.6), max(4.0, float(len(rowLabels)) * 0.4) ) # Create a new matplotlib Figure and Axes for the heatmap. fig, ax = plt.subplots(figsize=figSize) # Display the data array as an image on the Axes with the provided colormap. im = ax.imshow(data, aspect="auto", interpolation="nearest", cmap=cmap, vmin=vmin, vmax=vmax) # Set y-axis tick positions based on number of rows. ax.set_yticks(np.arange(len(rowLabels))) # Set y-axis tick labels from the provided rowLabels. ax.set_yticklabels(rowLabels) # Set x-axis tick positions based on number of columns. ax.set_xticks(np.arange(len(colLabels))) # Set x-axis tick labels from the provided colLabels and rotate for readability. ax.set_xticklabels(colLabels, rotation=45, ha="right") # Set the x-axis label text. ax.set_xlabel(xlabel) # Set the y-axis label text. ax.set_ylabel(ylabel) # Set the title if provided. if (title and len(title) > 0): # Assign the title text to the Axes. ax.set_title(title) # Optionally add a colorbar to the Figure. cbar = None if (colorbar): # Create a colorbar for the image using the Figure and Axes. cbar = fig.colorbar(im, ax=ax) # Set the colorbar label if provided. if (colorbarLabel is not None): # Assign the label text to the colorbar. cbar.set_label(colorbarLabel) # Optionally annotate each cell with its numeric value. if (annotate): # Compute the maximum of the data for thresholding text color. try: dataMax = np.nanmax(data) except Exception: # If computing the maximum fails, set dataMax to None. dataMax = None # Compute a threshold to decide text color for contrast. thresh = (dataMax / 2.0) if (dataMax is not None and dataMax != 0) else None # Iterate over rows to annotate cell values. for i in range(data.shape[0]): # Iterate over columns for each row. for j in range(data.shape[1]): # Read the cell value from the data array. val = data[i, j] # Skip annotation for NaN or infinite values. if (not (np.isnan(val) or np.isinf(val))): # Default text color for annotations. color = "white" # If threshold is available, choose text color for contrast. if (thresh is not None): try: # Use white text when value is above threshold, otherwise black. color = "white" if val > thresh else "black" except Exception: # Fallback to white in case of any error. color = "white" # Draw the annotation text at the center of the cell. ax.text( j, i, valueFormat.format(val), ha="center", va="center", color=color, fontsize=fontSize ) # Tighten the layout of the Figure to avoid clipping labels. fig.tight_layout() # Optionally save the Figure to disk when requested. if (save): # Initialize the target path variable. targetPath: Optional[Path] = None # Prefer explicit savePath if provided. if (savePath is not None): # Convert savePath to a Path object. targetPath = Path(savePath) elif (fileName is not None): # Convert fileName to a Path object. targetPath = Path(fileName) # If a target path was determined, create directories and save. if (targetPath is not None): # Ensure parent directories exist. targetPath.parent.mkdir(parents=True, exist_ok=True) # Determine file extension for additional PNG save. ext = targetPath.suffix.lower().lstrip(".") try: # Attempt to save the Figure to the requested path. fig.savefig(str(targetPath), dpi=dpi, bbox_inches="tight") except Exception as e: # Print an error message if saving fails. print(f"Error saving heatmap: {e}") # Also write a PNG sibling file when the primary extension is not PNG. if (ext != "png"): try: # Save a PNG sibling file. fig.savefig(str(targetPath.with_suffix(".png")), dpi=dpi, bbox_inches="tight") except Exception: # Ignore PNG save errors silently. pass # Backwards compatibility: save when savePath provided even if save is False. if (savePath is not None) and (not save) and (fileName is None): try: # Convert the legacy savePath to a Path object. pathObj = Path(savePath) # Ensure parent directories exist. pathObj.parent.mkdir(parents=True, exist_ok=True) # Save the Figure to the legacy path. fig.savefig(str(pathObj), dpi=dpi, bbox_inches="tight") # Inform the user where the heatmap was saved. print(f"Heatmap saved to: {pathObj}") except Exception: # Ignore save errors in legacy path behavior. pass # Optionally display the Figure using matplotlib's show. if (display): # Show the Figure on screen. plt.show() # Close the Figure to release resources. plt.close(fig) # Optionally return the created Figure object. if (returnFig): # Return the Figure instance to the caller. return fig # Return None when not returning the Figure. return None
# Define a function to plot a bar chart from values and labels.
[docs] def PlotBarChart( values: list[float], labels: list[str], title: str = "", ylabel: str = "", save: bool = False, savePath: Optional[Path | str] = None, fileName: Optional[str] = None, color: Any = "tab:blue", colors: Optional[list[str]] = None, alpha: float = 0.85, dpi: int = 300, display: bool = True, annotate: bool = False, annotateFormat: str = "{:.4f}", annotateFontSize: Optional[int] = None, fontSize: int = 10, figSize: Optional[tuple[float, float]] = None, rotation: float = 45, returnFig: bool = False, ) -> Optional[plt.Figure]: r''' Create a bar chart from values and labels with options to annotate, save, display and return the Figure. Parameters: values (list): Numeric heights for each bar. labels (list): Labels for each bar (x-axis). Must have the same length as `values`. title (str): Plot title. Default is empty. ylabel (str): Label for the y-axis. save (bool): Whether to save the plot. Default is False. savePath (str or pathlib.Path or None): Path to save the figure. Kept for backward compatibility; when provided and `save` is False the function will still save to this path. fileName (str or None): Alternative filename to save the figure when `save` is True. color (str or color spec): Single color for all bars (default: "tab:blue"). colors (list or None): Per-bar color list. When provided it overrides `color`. alpha (float): Alpha (opacity) applied to bars. Default is 0.85. dpi (int): DPI for saving the figure. Default is 300. display (bool): Whether to display the plot using plt.show(). Default is True. annotate (bool): Whether to annotate each bar with its numeric value. Default is False. annotateFormat (str): Format string used for bar annotations (default: "{:.2f}"). annotateFontSize (int or None): Font size for annotations. If None a size is derived from `fontSize`. fontSize (int): Base font size for axis labels and ticks. Default is 10. figSize (tuple or None): Figure size in inches. When None a width is inferred from number of bars. rotation (float): Rotation applied to x tick labels (degrees). Default is 45. returnFig (bool): Whether to return the matplotlib Figure object. Default is False. Returns: matplotlib.figure.Figure or None: The matplotlib figure object if returnFig is True, otherwise None. Notes: - When saving, a PNG sibling file is written alongside other formats where possible. - Backwards compatibility: if `savePath` is provided without `save`, the function will still save to `savePath` (preserving legacy behavior). Examples -------- .. code-block:: python from HMB.PlotsHelper import PlotBarChart PlotBarChart([1.2, 3.4, 2.1], ["A", "B", "C"], title="Counts", annotate=True) ''' # Validate that values and labels lengths match. if (len(values) != len(labels)): # Raise when lengths mismatch. raise ValueError("Length of 'values' must match 'labels'.") # Return early when there are no labels to plot. if (len(labels) == 0): # Nothing to plot; return None to indicate no figure created. return None # Infer a default figure size when not provided. if (figSize is None): # Compute width based on number of labels. figWidth = max(6.0, float(len(labels)) * 0.5) # Set figSize tuple. figSize = (figWidth, 4.0) # Create a new Figure and Axes for the bar chart. fig, ax = plt.subplots(figsize=figSize) # Add dashed grid lines along the y-axis for readability. ax.yaxis.grid(True, linestyle="--", which="major", color="grey", alpha=0.6) # Keep the dashed grid lines behind the bars. ax.set_axisbelow(True) # Decide bar colors: prefer per-bar list when provided. if (colors is not None): # Use the provided per-bar colors. barColors = colors else: # Use a single color specification for all bars. barColors = color # Draw the bars on the Axes. bars = ax.bar(range(len(labels)), values, color=barColors, alpha=alpha) # Keep the bar edges sharp. ax.bar(range(len(labels)), values, color=barColors, alpha=alpha, edgecolor="black", linewidth=0.7) # Set x tick positions for each label. ax.set_xticks(range(len(labels))) # Set x tick labels with rotation and font size. ax.set_xticklabels(labels, rotation=rotation, ha="center", fontsize=fontSize) # Set the y-axis label text. ax.set_ylabel(ylabel, fontsize=fontSize) # Set the title when provided. if (title and len(title) > 0): # Assign the title text to the Axes. ax.set_title(title, fontsize=fontSize) # Tighten layout to avoid clipping. fig.tight_layout() # Optionally annotate each bar with its value placed in the middle and rotated 45 degrees. if (annotate): # Derive a sensible annotation font size when not explicitly provided. if (annotateFontSize is None): # Compute annotation font size relative to base font size. annotateFontSize = max(8, int(fontSize * 0.9)) # Iterate over bars to place annotation texts at the bar center with rotation. for idx, bar in enumerate(bars): # Safely format the annotation label. try: label = annotateFormat.format(values[idx]) except Exception: label = str(values[idx]) # Get bar height for vertical placement (works for positive and negative bars). barHeight = float(bar.get_height()) # Compute bar bottom/top and center (handles both positive and negative values). barBottom = float(bar.get_y()) barTop = barBottom + float(bar.get_height()) barCenter = barBottom + (barHeight / 2.0) # Determine axis span to decide whether there's room inside the bar for the label. yMin, yMax = ax.get_ylim() ySpan = max(1e-6, (yMax - yMin)) # If the bar height is large enough relative to the axis span, put the label inside (vertically centered). # Otherwise place it slightly above the bar top and expand y-limits if needed. minRatioForInside = 0.08 if (abs(barHeight) >= (ySpan * minRatioForInside)): yPos = barCenter va = "center" clipOn = True else: offset = ySpan * 0.02 # For positive bars place above top, for negative bars place below bottom. if (barHeight >= 0): yPos = barTop + offset va = "bottom" # Expand y-axis top if label would be out of bounds. if (yPos > yMax): ax.set_ylim(yMin, yPos + ySpan * 0.05) else: # Negative bar: put label below the bar. yPos = barTop - offset va = "top" if yPos < yMin: ax.set_ylim(yPos - ySpan * 0.05, yMax) clipOn = False # Determine x position centered on the bar. xPos = bar.get_x() + bar.get_width() / 2.0 # Place the annotation. Use different vertical alignment and clipping depending on placement. ax.text( xPos, yPos, label, ha="center", va=va, fontsize=annotateFontSize, rotation=45, color="black", clip_on=clipOn, zorder=10, ) # Optionally save the bar chart to disk when requested. if (save): # Initialize target path variable. targetPath: Optional[Path] = None # Prefer explicit savePath when provided. if (savePath is not None): # Convert savePath to a Path object. targetPath = Path(savePath) elif (fileName is not None): # Convert fileName to a Path object. targetPath = Path(fileName) # If a target path was determined, persist the figure. if (targetPath is not None): # Create parent directories if required. targetPath.parent.mkdir(parents=True, exist_ok=True) try: # Save the Figure to the requested path. fig.savefig(str(targetPath), dpi=dpi, bbox_inches="tight") except Exception as e: # Print an error message if saving fails. print(f"Error saving bar chart: {e}") # Also save a PNG copy when the primary extension is not PNG. if (targetPath.suffix.lower().lstrip(".") != "png"): try: # Save a PNG sibling file. fig.savefig(str(targetPath.with_suffix(".png")), dpi=dpi, bbox_inches="tight") except Exception: # Ignore errors when saving the PNG copy. pass # Legacy behavior: if savePath provided but save is False, still save for compatibility. if ((savePath is not None) and (not save) and (fileName is None)): try: # Convert the legacy savePath to a Path object. pathObj = Path(savePath) # Ensure parent directories exist. pathObj.parent.mkdir(parents=True, exist_ok=True) # Save the Figure to the legacy path. fig.savefig(str(pathObj), dpi=dpi, bbox_inches="tight") # Inform the user where the bar chart was saved. print(f"Bar chart saved to: {pathObj}") except Exception: # Ignore legacy save errors. pass # Optionally display the Figure on screen. if (display): # Show the Figure using matplotlib. plt.show() # Close the Figure to release memory. plt.close(fig) # Optionally return the Figure instance to the caller. if (returnFig): # Return the Figure object. return fig # Return None when not returning the Figure. return None
# Additional generic plot helpers.
[docs] def PlotHorizontalBarChart( values: list[float], labels: list[str], title: str = "", xlabel: str = "", save: bool = False, savePath: Optional[Path | str] = None, fileName: Optional[str] = None, color: Any = "tab:blue", colors: Optional[list[str]] = None, alpha: float = 0.85, dpi: int = 300, display: bool = True, annotate: bool = False, annotateFormat: str = "{:.4f}", annotateFontSize: Optional[int] = None, fontSize: int = 10, figSize: Optional[tuple[float, float]] = None, returnFig: bool = False, ) -> Optional[plt.Figure]: r''' Create a horizontal bar chart (bars extend along the x-axis) with labeled y-axis. This helper creates a horizontal bar chart suitable for categorical counts or scores. It mirrors the options and backwards-compatible saving behavior of `PlotBarChart`. Parameters: values (list[float]): Numeric lengths for each horizontal bar. labels (list[str]): Labels for each bar shown on the y-axis. Length must match `values`. title (str): Plot title. Default is empty. xlabel (str): Label for the x-axis. Default is empty. save (bool): Whether to save the plot to disk. Default is False. savePath (str|Path|None): Backwards-compatible path to save the figure. When provided and `save` is False the function still saves to this location (legacy behavior). fileName (str|None): Alternative file path used when `save` is True. color (Any): Single color specification for all bars (default "tab:blue"). colors (list[str]|None): Per-bar color list that overrides `color` when provided. alpha (float): Opacity applied to bars. Default is 0.85. dpi (int): DPI for saved figures. Default is 300. display (bool): Whether to call `plt.show()` after drawing. Default is True. annotate (bool): Whether to annotate each bar with its numeric value. Default is False. annotateFormat (str): Format string used for annotations. Default is "{:.2f}". annotateFontSize (int|None): Font size for bar annotations. If None a sensible size is derived. fontSize (int): Base font size used for axis labels and ticks. Default is 10. figSize (tuple|None): Figure size in inches. If None a default is inferred from number of labels. returnFig (bool): Whether to return the created matplotlib Figure object. Default is False. Returns: matplotlib.figure.Figure or None: The created Figure when `returnFig` is True, otherwise None. Notes: - When saving, a PNG sibling file is written alongside other formats when possible. - Backwards compatibility: if `savePath` is provided without `save`, the function will still save to `savePath` (preserving legacy behavior used elsewhere in this module). Examples -------- .. code-block:: python from HMB.PlotsHelper import PlotHorizontalBarChart PlotHorizontalBarChart([10, 5, 7], ["A","B","C"], title="Counts", save=True, fileName="counts.pdf") ''' # Validate that the provided lists have matching length. if (len(values) != len(labels)): # Raise an informative exception when lengths mismatch. raise ValueError("Length of 'values' must match 'labels'.") # Return early when there are no labels to plot. if (len(labels) == 0): # Nothing to plot; return None to indicate no figure created. return None # Compute a default figure size when not provided. if (figSize is None): # Compute height based on number of labels for readability. figHeight = max(4.0, float(len(labels)) * 0.35) # Set the computed figure size tuple. figSize = (8.0, figHeight) # Create a matplotlib Figure and Axes for drawing. fig, ax = plt.subplots(figsize=figSize) # Decide bar colors preferring per-bar list when provided. barColors = (colors if (colors is not None) else color) # Build y positions for the horizontal bars. yPositions = range(len(labels)) # Draw the horizontal bars on the Axes. ax.barh(yPositions, values, color=barColors, alpha=alpha) # Set y tick positions to align with bars. ax.set_yticks(yPositions) # Set y tick labels using provided labels. ax.set_yticklabels(labels, fontsize=fontSize) # Set the x-axis label text. ax.set_xlabel(xlabel, fontsize=fontSize) # Optionally set the plot title when provided. if (title and len(title) > 0): # Assign the title text to the Axes. ax.set_title(title, fontsize=fontSize) # Tighten layout to avoid clipping labels. fig.tight_layout() # Optionally annotate each bar with its numeric value. if (annotate): # Compute a sensible annotation font size when not provided. if (annotateFontSize is None): # Derive annotation font size from base font size. annotateFontSize = max(8, int(fontSize * 0.9)) # Iterate over bars to place annotation texts. for i, v in enumerate(values): # Format the annotation text safely. try: label = annotateFormat.format(v) except Exception: label = str(v) # Place the annotation text adjacent to the bar value. ax.text( v, i, f" {label}", va="center", fontsize=annotateFontSize ) # Optionally save the figure to disk when requested. if (save): # Initialize target path variable. targetPath: Optional[Path] = None # Prefer explicit savePath when provided. if (savePath is not None): # Convert savePath to a Path object. targetPath = Path(savePath) elif (fileName is not None): # Convert fileName to a Path object. targetPath = Path(fileName) # If a target path was determined, persist the figure. if (targetPath is not None): # Create parent directories if required. targetPath.parent.mkdir(parents=True, exist_ok=True) try: # Save the Figure to the requested path. fig.savefig(str(targetPath), dpi=dpi, bbox_inches="tight") except Exception as e: # Print an error message if saving fails. print(f"Error saving horizontal bar chart: {e}") # Also save a PNG copy when the primary extension is not PNG. if (targetPath.suffix.lower().lstrip(".") != "png"): try: # Save a PNG sibling file. fig.savefig(str(targetPath.with_suffix(".png")), dpi=dpi, bbox_inches="tight") except Exception: # Ignore errors when saving the PNG copy. pass # Legacy behavior: if savePath provided but save flag is False still persist for compatibility. if ((savePath is not None) and (not save) and (fileName is None)): try: # Convert the legacy savePath to a Path object. pathObj = Path(savePath) # Ensure parent directories exist. pathObj.parent.mkdir(parents=True, exist_ok=True) # Save the Figure to the legacy path. fig.savefig(str(pathObj), dpi=dpi, bbox_inches="tight") # Inform the user where the horizontal bar chart was saved. print(f"Horizontal bar chart saved to: {pathObj}") except Exception: # Ignore legacy save errors silently. pass # Optionally display the Figure on screen. if (display): # Show the Figure using matplotlib. plt.show() # Close the Figure to release memory. plt.close(fig) # Optionally return the Figure instance to the caller. if (returnFig): # Return the Figure object. return fig # Default return when nothing to return. return None
[docs] def PlotPieChart( values: list[float], labels: list[str], title: str = "", save: bool = False, savePath: Optional[Path | str] = None, fileName: Optional[str] = None, autopct: Optional[str] = "%1.1f%%", explode: Optional[list[float]] = None, colors: Optional[list[str]] = None, dpi: int = 300, display: bool = True, fontSize: int = 10, figSize: Optional[tuple[float, float]] = None, returnFig: bool = False, ) -> Optional[plt.Figure]: r''' Create a pie chart from categorical values and labels. This helper draws a pie chart and a right-side legend for readability. The chart accepts options for percentage annotations, slice explosion and custom colors. Parameters: values (list[float]): Numeric values for pie slices. Length must match `labels`. labels (list[str]): Labels for each slice. title (str): Plot title. Default is empty. save (bool): Whether to save the plot to disk. Default is False. savePath (str|Path|None): Backwards-compatible save path used when provided. fileName (str|None): Alternative file path used when `save` is True. autopct (str|None): Format string for slice percentage annotation (e.g. "%1.1f%%"). explode (list[float]|None): Per-slice offset fractions for emphasizing slices. colors (list[str]|None): Per-slice colors. dpi (int): DPI for saved figures. Default is 300. display (bool): Whether to display the figure with `plt.show()`. Default is True. fontSize (int): Font size used in the legend and title. Default is 10. figSize (tuple|None): Figure size in inches. Default is a square figure. returnFig (bool): Whether to return the created matplotlib Figure object. Default is False. Returns: matplotlib.figure.Figure or None: The created Figure when `returnFig` is True, otherwise None. Notes: - The function places labels in a legend to the right to keep the pie area uncluttered. - Backwards-compatible saving behavior is preserved (see `savePath` semantics). Examples -------- .. code-block:: python from HMB.PlotsHelper import PlotPieChart PlotPieChart([30, 70], ["CatA", "CatB"], title="Distribution", save=True, fileName="dist.png") ''' # Validate that the lengths of values and labels match. if (len(values) != len(labels)): # Raise an informative exception for mismatched lengths. raise ValueError("Length of 'values' must match 'labels'.") # Return early when there are no labels to plot. if (len(labels) == 0): # Nothing to plot; return None. return None # Determine a default figure size when not provided. if (figSize is None): # Use a square figure for pie charts. figSize = (6.0, 6.0) # Create a new Figure and Axes for the pie chart. fig, ax = plt.subplots(figsize=figSize) # Draw the pie chart wedges without labels (legend will show names). wedges_texts = ax.pie( values, labels=None, autopct=autopct, explode=explode, colors=colors, startangle=90, ) # Ensure the pie is rendered as a circle. ax.axis("equal") # Add a legend with labels placed to the right for readability. ax.legend(wedges_texts[0], labels, title=None, loc="center left", bbox_to_anchor=(1, 0.5), fontsize=fontSize) # Optionally set the title when provided. if (title and len(title) > 0): # Assign the title text to the Axes. ax.set_title(title, fontsize=fontSize) # Tighten layout to avoid clipping the legend. fig.tight_layout() # Persist the figure to disk when requested via save flag. if (save): # Initialize the target path variable. targetPath: Optional[Path] = None # Prefer explicit savePath when provided. if (savePath is not None): # Convert savePath to a Path object. targetPath = Path(savePath) elif (fileName is not None): # Convert fileName to a Path object. targetPath = Path(fileName) # Save the figure when a target path was determined. if (targetPath is not None): # Ensure parent directories exist. targetPath.parent.mkdir(parents=True, exist_ok=True) # Attempt to save the Figure to the requested path. try: fig.savefig(str(targetPath), dpi=dpi, bbox_inches="tight") except Exception as e: # Print an error message when saving fails. print(f"Error saving pie chart: {e}") # Also write a PNG sibling file when the primary extension is not PNG. if (targetPath.suffix.lower().lstrip(".") != "png"): try: fig.savefig(str(targetPath.with_suffix(".png")), dpi=dpi, bbox_inches="tight") except Exception: # Ignore PNG save errors silently. pass # Legacy behavior: save when savePath provided even if save flag is False. if ((savePath is not None) and (not save) and (fileName is None)): # Attempt to save to the legacy path for compatibility. try: pathObj = Path(savePath) pathObj.parent.mkdir(parents=True, exist_ok=True) fig.savefig(str(pathObj), dpi=dpi, bbox_inches="tight") print(f"Pie chart saved to: {pathObj}") except Exception: # Ignore legacy save errors silently. pass # Display the figure interactively when requested. if (display): # Show the matplotlib figure. plt.show() # Close the figure to release memory. plt.close(fig) # Optionally return the Figure when requested. if (returnFig): # Return the Figure instance to the caller. return fig # Default return when no figure is returned. return None
[docs] def PlotLineChart( x: Optional[list[float]], y: list[float], title: str = "", xlabel: str = "", ylabel: str = "", save: bool = False, savePath: Optional[Path | str] = None, fileName: Optional[str] = None, dpi: int = 300, display: bool = True, annotate: bool = False, annotateFormat: str = "{:.2f}", fontSize: int = 10, figSize: Optional[tuple[float, float]] = None, returnFig: bool = False, ) -> Optional[plt.Figure]: r''' Plot a simple line chart connecting (x, y) points. This helper supports optional annotation of points and standard saving/display options consistent with other plot helpers in this module. Parameters: x (list[float]|None): X coordinates for points. When None the indices of `y` are used. y (list[float]): Y coordinates for points. title (str): Plot title. Default is empty. xlabel (str): X-axis label. Default is empty. ylabel (str): Y-axis label. Default is empty. save (bool): Whether to save the plot to disk. Default is False. savePath (str|Path|None): Backwards-compatible save path used when provided. fileName (str|None): Alternative file path used when `save` is True. dpi (int): DPI for saved figures. Default is 300. display (bool): Whether to display the figure with `plt.show()`. Default is True. annotate (bool): Whether to annotate each point with its value. Default is False. annotateFormat (str): Format string used for annotations. Default is "{:.2f}". fontSize (int): Font size used for labels and annotations. Default is 10. figSize (tuple|None): Figure size in inches. returnFig (bool): Whether to return the created matplotlib Figure object. Default is False. Returns: matplotlib.figure.Figure or None: The created Figure when `returnFig` is True, otherwise None. Notes: - When `x` is None the function uses sequential integer indices for the x-axis. - Backwards-compatible saving behavior is preserved. Examples -------- .. code-block:: python from HMB.PlotsHelper import PlotLineChart PlotLineChart(None, [1, 3, 2, 5], title="Signal", annotate=True) ''' # Use index sequence for x when None is provided. if (x is None): # Create a range index matching the length of y. x = list(range(len(y))) # Validate that x and y lengths match. if (len(x) != len(y)): # Raise when the provided x and y lengths differ. raise ValueError("Length of \"x\" must match \"y\".") # Return early when there is no data to plot. if (len(y) == 0): # Nothing to plot; return None. return None # Determine default figure size when not given. if (figSize is None): # Use a standard wide figure for line plots. figSize = (8.0, 4.0) # Create the Figure and Axis for plotting. fig, ax = plt.subplots(figsize=figSize) # Plot the line with markers. ax.plot(x, y, marker="o") # Set x-axis label text. ax.set_xlabel(xlabel, fontsize=fontSize) # Set y-axis label text. ax.set_ylabel(ylabel, fontsize=fontSize) # Optionally set the title when provided. if (title and len(title) > 0): # Assign the title text to the Axes. ax.set_title(title, fontsize=fontSize) # Tighten layout to avoid clipping. fig.tight_layout() # Optionally annotate individual points with their values. if (annotate): # Iterate through points and add annotations. for xi, yi in zip(x, y): # Format the annotation text safely. try: txt = annotateFormat.format(yi) except Exception: txt = str(yi) # Place the annotation slightly above the point. ax.annotate( txt, (xi, yi), textcoords="offset points", xytext=(0, 5), ha="center", fontsize=max(8, int(fontSize * 0.9)) ) # Persist the figure to disk when requested. if (save): # Determine the target path object for saving. targetPath: Optional[Path] = None # Prefer explicit savePath when provided. if (savePath is not None): # Convert savePath to a Path object. targetPath = Path(savePath) elif (fileName is not None): # Convert fileName to a Path object. targetPath = Path(fileName) # Save the figure when a target path exists. if (targetPath is not None): # Ensure parent directories exist for the target path. targetPath.parent.mkdir(parents=True, exist_ok=True) # Attempt to write the figure to the requested path. try: fig.savefig(str(targetPath), dpi=dpi, bbox_inches="tight") except Exception as e: # Print a helpful message on save failure. print(f"Error saving line chart: {e}") # Also attempt a PNG sibling when primary extension is not PNG. if (targetPath.suffix.lower().lstrip(".") != "png"): try: fig.savefig(str(targetPath.with_suffix(".png")), dpi=dpi, bbox_inches="tight") except Exception: # Ignore PNG sibling save errors silently. pass # Legacy behavior: save when savePath provided even when save flag is False. if ((savePath is not None) and (not save) and (fileName is None)): # Attempt to persist to legacy savePath for compatibility. try: pathObj = Path(savePath) pathObj.parent.mkdir(parents=True, exist_ok=True) fig.savefig(str(pathObj), dpi=dpi, bbox_inches="tight") print(f"Line chart saved to: {pathObj}") except Exception: # Ignore legacy save errors silently. pass # Display the figure when requested. if (display): # Show the matplotlib figure. plt.show() # Close the figure to release memory. plt.close(fig) # Optionally return the created Figure object. if (returnFig): # Return the Figure instance. return fig # Default return when nothing to return. return None
[docs] def PlotHistogram( data: list[float], bins: int = 20, title: str = "", xlabel: str = "", ylabel: str = "Frequency", save: bool = False, savePath: Optional[Path | str] = None, fileName: Optional[str] = None, dpi: int = 300, display: bool = True, fontSize: int = 10, figSize: Optional[tuple[float, float]] = None, returnFig: bool = False, ) -> Optional[plt.Figure]: r''' Plot a histogram for 1D numeric data. This helper draws a histogram with configurable bin count and axis labels. It follows the same saving and display semantics used elsewhere in this module. Parameters: data (list[float]): 1D numeric sequence to bin and plot. bins (int): Number of histogram bins. Default is 20. title (str): Plot title. Default is empty. xlabel (str): X-axis label. Default is empty. ylabel (str): Y-axis label. Default is "Frequency". save (bool): Whether to save the plot to disk. Default is False. savePath (str|Path|None): Backwards-compatible save path used when provided. fileName (str|None): Alternative file path used when `save` is True. dpi (int): DPI for saved figures. Default is 300. display (bool): Whether to display the figure with `plt.show()`. Default is True. fontSize (int): Font size used for axis labels. Default is 10. figSize (tuple|None): Figure size in inches. Default is a standard wide figure. returnFig (bool): Whether to return the created matplotlib Figure object. Default is False. Returns: matplotlib.figure.Figure or None: The created Figure when `returnFig` is True, otherwise None. Notes: - Backwards-compatible saving behavior is preserved. Examples -------- .. code-block:: python from HMB.PlotsHelper import PlotHistogram PlotHistogram([0.1,0.2,0.2,0.3], bins=10, title="Values") ''' # Return early when data is None or empty. if (data is None or len(data) == 0): # Nothing to plot; return None. return None # Compute a default figure size when not provided. if (figSize is None): # Use a standard wide figure for histograms. figSize = (8.0, 4.0) # Create Figure and Axis for the histogram. fig, ax = plt.subplots(figsize=figSize) # Draw the histogram bars on the Axis. ax.hist(data, bins=bins, color="tab:blue", alpha=0.85) # Set x-axis label text. ax.set_xlabel(xlabel, fontsize=fontSize) # Set y-axis label text. ax.set_ylabel(ylabel, fontsize=fontSize) # Optionally set the title when provided. if (title and len(title) > 0): # Assign the title text to the Axes. ax.set_title(title, fontsize=fontSize) # Tighten layout to avoid clipping. fig.tight_layout() # Persist the figure when requested. if (save): # Determine the target path object for saving. targetPath: Optional[Path] = None # Prefer explicit savePath when provided. if (savePath is not None): # Convert savePath to a Path object. targetPath = Path(savePath) elif (fileName is not None): # Convert fileName to a Path object. targetPath = Path(fileName) # Save the figure when a target path exists. if (targetPath is not None): # Ensure parent directories exist for the target path. targetPath.parent.mkdir(parents=True, exist_ok=True) # Attempt to write the figure to the requested path. try: fig.savefig(str(targetPath), dpi=dpi, bbox_inches="tight") except Exception as e: # Print a helpful message on save failure. print(f"Error saving histogram: {e}") # Also attempt a PNG sibling when primary extension is not PNG. if (targetPath.suffix.lower().lstrip(".") != "png"): try: fig.savefig(str(targetPath.with_suffix(".png")), dpi=dpi, bbox_inches="tight") except Exception: # Ignore PNG sibling save errors silently. pass # Legacy behavior: save when savePath provided even if save flag is False. if ((savePath is not None) and (not save) and (fileName is None)): # Attempt to persist to legacy savePath for compatibility. try: pathObj = Path(savePath) pathObj.parent.mkdir(parents=True, exist_ok=True) fig.savefig(str(pathObj), dpi=dpi, bbox_inches="tight") print(f"Histogram saved to: {pathObj}") except Exception: # Ignore legacy save errors silently. pass # Display the figure when requested. if (display): # Show the matplotlib figure. plt.show() # Close the figure to release memory. plt.close(fig) # Optionally return the created Figure object. if (returnFig): # Return the Figure instance. return fig # Default return when nothing to return. return None
[docs] def GenerateGenericBubblePlot( data: pd.DataFrame, xColumn: str, yColumn: str, sizeColumn: str, colorColumn: str, outputPath: Optional[Path | str], figureSize: Tuple[int, int] = (12, 12), dpiValue: int = 300, title: str = "Bubble Plot", xlabel: str = "X Axis", ylabel: str = "Y Axis", performanceZones: bool = True, concentrationPeak: bool = True, ) -> bool: r''' Generate a generic bubble plot from structured data. Parameters: data (pd.DataFrame): Input data with required columns. xColumn (str): Column name for x-axis values. yColumn (str): Column name for y-axis categories (will be indexed). sizeColumn (str): Column name for bubble size (numeric). colorColumn (str): Column name for categorical coloring. outputPath (str|Path|None): Path to save the output plot (PNG and PDF). figureSize (tuple): Figure dimensions (width, height). dpiValue (int): DPI for saved figures. title (str): Plot title. xlabel (str): X-axis label. ylabel (str): Y-axis label. performanceZones (bool): Whether to shade low/medium/high performance zones. concentrationPeak (bool): Whether to highlight the densest region in x-values. Returns: bool: True if plot was saved successfully, False otherwise. ''' # Validate required columns. required = {xColumn, yColumn, sizeColumn, colorColumn} if not required.issubset(data.columns): missing = required - set(data.columns) print(f"ERROR: Missing columns in data: {missing}") return False # Prepare categorical y-axis. uniqueY = sorted(data[yColumn].unique()) yIndexMap = {label: idx for idx, label in enumerate(uniqueY)} data["_y_index"] = data[yColumn].map(yIndexMap) # Prepare colors. uniqueColors = sorted(data[colorColumn].unique()) colors = plt.cm.tab20(np.linspace(0, 1, len(uniqueColors))) colorMap = dict(zip(uniqueColors, colors)) # Create plot. fig, ax = plt.subplots(figsize=figureSize) # Plot bubbles. for _, row in data.iterrows(): x = float(row[xColumn]) y = row["_y_index"] s = float(row[sizeColumn]) c = colorMap[row[colorColumn]] ax.scatter(x, y, s=s, c=[c], alpha=0.7, edgecolors="black", linewidth=2) # Horizontal dividers between y-categories. for i in range(1, len(uniqueY)): ax.axhline(y=i - 0.5, color="gray", linestyle="-", linewidth=0.8, alpha=0.4) # Performance zones (shaded vertical bands). if performanceZones: xMin, xMax = data[xColumn].min(), data[xColumn].max() xRange = xMax - xMin lowThresh = xMin + xRange * 0.33 highThresh = xMin + xRange * 0.66 ax.axvspan(xMin, lowThresh, alpha=0.03, color="red", label="Low Performance Zone") ax.axvspan(lowThresh, highThresh, alpha=0.03, color="yellow", label="Medium Performance Zone") ax.axvspan(highThresh, xMax, alpha=0.03, color="green", label="High Performance Zone") # Concentration peak (densest bin). if (concentrationPeak): xVals = data[xColumn].values bins = np.linspace(xVals.min(), xVals.max(), 4) counts, _ = np.histogram(xVals, bins=bins) peak_idx = np.argmax(counts) ax.axvspan( bins[peak_idx], bins[peak_idx + 1], alpha=0.15, facecolor="gold", edgecolor="orange", linestyle="--", label="Concentration Peak" ) # Axes formatting. ax.set_yticks(range(len(uniqueY))) ax.set_yticklabels(uniqueY, fontsize=12, fontweight="bold") ax.set_xlabel(xlabel, fontsize=14, fontweight="bold") ax.set_ylabel(ylabel, fontsize=14, fontweight="bold") ax.set_title(title, fontsize=16, fontweight="bold") ax.grid(True, alpha=0.3, linestyle="--", axis="x") # Legend. legendElements = [ Patch(facecolor=colorMap[cat], edgecolor="black", label=cat) for cat in uniqueColors ] if performanceZones: legendElements.extend([ Patch(facecolor="red", alpha=0.2, edgecolor="none", label="Low Performance Zone"), Patch(facecolor="yellow", alpha=0.2, edgecolor="none", label="Medium Performance Zone"), Patch(facecolor="green", alpha=0.2, edgecolor="none", label="High Performance Zone"), ]) if concentrationPeak: legendElements.append( Patch(facecolor="gold", alpha=0.5, edgecolor="orange", linestyle="--", label="Concentration Peak") ) ax.legend( handles=legendElements, loc="upper left", bbox_to_anchor=(1.02, 1), fontsize=10, title="Legend", title_fontsize=12, frameon=True, fancybox=True ) plt.tight_layout() # Save outputs. outputPath = Path(outputPath) outputPath.parent.mkdir(parents=True, exist_ok=True) fig.savefig(outputPath, dpi=dpiValue, bbox_inches="tight") fig.savefig(outputPath.with_suffix(".pdf"), dpi=dpiValue, bbox_inches="tight") plt.close(fig) print(f"\nBubble plot saved to: {outputPath}") return True
[docs] def PlotApproachesComplexityComparison( approaches, flopsValues, paramsValues, outDir, baseFlops=None, baseParams=None, dpi=300, deviceName="CUDA", ): r''' Plot a complexity comparison chart for different approaches. This function creates a dual-axis line chart comparing FLOPs and Parameters for various approaches. It annotates each point with values and percentage deltas from a base approach when provided. The plot is saved to the specified output directory with a filename that includes the device type for clarity. Parameters: approaches (list[str]): List of approach names to compare. flopsValues (list[float]): Corresponding FLOPs values (in GigaFLOPs) for each approach. paramsValues (list[float]): Corresponding Parameters values (in Millions) for each approach. outDir (str|Path): Directory where the plot will be saved. baseFlops (float|None): Optional base FLOPs value for calculating percentage deltas. If None, deltas are not calculated. baseParams (float|None): Optional base Parameters value for calculating percentage deltas. If None, deltas are not calculated. dpi (int): DPI for the saved plot image. deviceName (str): Human-readable name for the device type to include in the title (default is "CUDA"). ''' import os import numpy as np import matplotlib.pyplot as plt # Create x-axis indices for plotting based on the number of approaches. x = np.arange(len(approaches)) # Create the figure and the primary axis object. fig, ax1 = plt.subplots(figsize=(12, 6)) # Define the color for the FLOPs plot. color1 = "tab:blue" # Set the label for the x-axis. ax1.set_xlabel("Approach") # Set the label for the left y-axis with the specified color. ax1.set_ylabel("FLOPs (G)", color=color1) # Plot the FLOPs values on the left axis with lines and markers. ax1.plot(x, flopsValues, color=color1, marker="o", linestyle="-", label="FLOPs (G)") # Scatter the FLOPs values on the left axis for emphasis. ax1.scatter(x, flopsValues, color=color1) # Configure the tick parameters for the left y-axis to match the color. ax1.tick_params(axis="y", labelcolor=color1) # Set the x-axis ticks to the generated indices. ax1.set_xticks(x) # Set the x-axis tick labels to the approach names with rotation. ax1.set_xticklabels(approaches, rotation=45, ha="right") # Create a twin axis sharing the x-axis for the parameters plot. ax2 = ax1.twinx() # Define the color for the Parameters plot. color2 = "tab:red" # Set the label for the right y-axis with the specified color. ax2.set_ylabel("Parameters (M)", color=color2) # Plot the Parameters values on the right axis with lines and markers. ax2.plot(x, paramsValues, color=color2, marker="s", linestyle="--", label="Params (M)") # Scatter the Parameters values on the right axis for emphasis. ax2.scatter(x, paramsValues, color=color2) # Configure the tick parameters for the right y-axis to match the color. ax2.tick_params(axis="y", labelcolor=color2) # Iterate through the range of values to annotate each point. for i in range(len(flopsValues)): # Check if the FLOPs value is not NaN before annotating. if (not np.isnan(flopsValues[i])): # Check if a base FLOPs value is provided and non-zero for delta calculation. if (baseFlops is not None and baseFlops != 0): # Calculate the percentage delta from the base FLOPs. delta = (flopsValues[i] - baseFlops) / baseFlops * 100.0 # Determine the sign string for the delta annotation. sign = "+" if (delta >= 0) else "" # Format the annotation string with value and delta percentage. ann = f"{flopsValues[i]:.2f}\n({sign}{delta:.1f}%)" # Otherwise use only the FLOPs value for the annotation. else: # Format the annotation string with only the value. ann = f"{flopsValues[i]:.2f}" # Annotate the FLOPs point above the marker on the first axis. ax1.annotate(ann, (x[i], flopsValues[i]), textcoords="offset points", xytext=(0, 6), ha="center", fontsize=8) # Check if the Parameters value is not NaN before annotating. if (not np.isnan(paramsValues[i])): # Check if a base Parameters value is provided and non-zero for delta calculation. if (baseParams is not None and baseParams != 0): # Calculate the percentage delta from the base Parameters. delta2 = (paramsValues[i] - baseParams) / baseParams * 100.0 # Determine the sign string for the delta annotation. sign2 = "+" if (delta2 >= 0) else "" # Format the annotation string with value and delta percentage. ann2 = f"{paramsValues[i]:.1f}\n({sign2}{delta2:.1f}%)" # Otherwise use only the Parameters value for the annotation. else: # Format the annotation string with only the value. ann2 = f"{paramsValues[i]:.1f}" # Annotate the Parameters point below the marker on the second axis. ax2.annotate( ann2, (x[i], paramsValues[i]), textcoords="offset points", xytext=(0, -18), ha="center", fontsize=8, color=color2 ) # Format the title string with generic device details. titleDevice = f"Complexity Comparison - Device: {deviceName}" # Set the title of the plot using the generated device string. plt.title(titleDevice) # Adjust the layout of the figure to prevent label overlap. fig.tight_layout() # Create the output directory if it does not already exist. os.makedirs(outDir, exist_ok=True) # Save the figure as a PDF file in the output directory. plt.savefig(os.path.join(outDir, "ComplexityComparison.pdf"), dpi=dpi) # Save the figure as a PNG file in the output directory. plt.savefig(os.path.join(outDir, "ComplexityComparison.png"), dpi=dpi) # Close the figure to free up memory. plt.close()
[docs] def PlotClassDistribution( labelsArray, # Array-like of class labels to plot the distribution for. outPath: Path, # Path to save the resulting plot. title: str = None, # Optional title for the plot. maxVertical: int = 15, # Threshold for switching between vertical and horizontal bar layouts. exportPng: bool = True, # Whether to also export a PNG alongside the PDF. colormap: str = "viridis", # Matplotlib colormap name to use for bars/line. fontSize: str = "small", # Font size for tick labels and annotations. orientation: str = "auto", # "auto", "vertical", or "horizontal" to override layout. dpi: int = 300, # Dots-per-inch for raster outputs (PNG) and fallback for PDF. figureSize: tuple = None, # Optional figure size override as (width, height). paretoThreshold: float = 80.0, # Percent threshold to mark on cumulative curve (e.g., 80.0). save: bool = True, # Whether to save the generated figure to outPath. Default True for backward compatibility. display: bool = False, # Whether to display the figure using plt.show(). Default False to avoid blocking scripts. ): r''' Plot the distribution of class labels as a bar chart with cumulative percentage overlay. This function computes the frequency of each unique class label, sorts them in descending order, and visualizes the distribution as a bar chart. It also overlays a line plot showing the cumulative percentage of samples covered by the classes. A marker and annotation indicate where the cumulative percentage crosses a specified Pareto threshold (e.g., 80%), highlighting the number of classes that contribute to that coverage. The plot can be oriented vertically or horizontally based on the number of classes or an explicit override. The resulting figure is saved to the specified path in PDF format, with an optional PNG export for raster use cases. Parameters: labelsArray (array-like): Sequence of class labels to analyze and plot. outPath (Path): Path to save the resulting plot (PDF and optionally PNG). title (str, optional): Title for the plot. Default is None (no title). maxVertical (int, optional): Maximum number of classes to use vertical bars before switching to horizontal. Default is 15. exportPng (bool, optional): Whether to also export a PNG version of the plot. Default is True. colormap (str, optional): Matplotlib colormap name to use for the bars and line. Default is "viridis". fontSize (str or int, optional): Font size for tick labels and annotations. Default is "small". orientation (str, optional): "auto", "vertical", or "horizontal" to control bar orientation. Default is "auto". dpi (int, optional): Dots-per-inch for raster outputs (PNG) and fallback for PDF. Default is 300. figureSize (tuple, optional): Optional figure size override as (width, height) in inches. Default is None (automatic sizing). paretoThreshold (float, optional): Percent threshold to mark on cumulative curve (e.g., 80.0). Default is 80.0. save (bool, optional): Whether to save the generated figure to outPath. Default is True for backward compatibility. display (bool, optional): Whether to display the figure using plt.show(). Default is False to avoid blocking scripts. ''' # Convert the provided labels into a NumPy array. labelsArray = np.array(labelsArray) # Compute unique class names and their corresponding counts. classNames, classCounts = np.unique(labelsArray, return_counts=True) # Sort classes by descending count to highlight the largest classes first. order = np.argsort(-classCounts) classNames = classNames[order] classCounts = classCounts[order] # Compute the total number of samples for percentage calculations. totalSamples = int(classCounts.sum()) # Compute the percentage of each class relative to the total. percentages = (classCounts / totalSamples) * 100.0 # Determine final orientation based on the override and threshold. finalOrientation = orientation if (orientation == "auto"): finalOrientation = ("vertical" if (len(classNames) <= maxVertical) else "horizontal") # Prepare a colormap and derive colors for each class using a continuous sampling # so colors do not repeat even for many classes. cmap = plt.get_cmap(colormap) colors = cmap(np.linspace(0, 1, len(classNames))) # Prepare readable label strings and perform word-wrapping for very long names. rawLabels = [str(c) for c in classNames] maxLabelLen = max((len(s) for s in rawLabels), default=0) # Choose wrap width based on the longest label; wrap when labels exceed 20 characters. wrapWidth = (20 if (maxLabelLen > 20) else None) if (wrapWidth): import textwrap displayLabels = ["\n".join(textwrap.wrap(s, width=wrapWidth)) for s in rawLabels] else: displayLabels = rawLabels # Determine the effective figure size early so it can be applied when the figure is created. if (figureSize is not None): effectiveFigSize = figureSize else: effectiveFigSize = (10, 6) if (finalOrientation == "vertical") else (10, max(6, len(classNames) * 0.3)) # Create plots for vertical orientation. if (finalOrientation == "vertical"): # Create the main figure and axis for the vertical bar chart. fig, ax = plt.subplots(figsize=effectiveFigSize) # Create x positions for the bars. xPos = np.arange(len(classNames)) # Draw the vertical bars for class counts. bars = ax.bar(xPos, classCounts, color=colors, edgecolor="black", linewidth=0.3) # Configure the x-axis tick locations and labels. ax.set_xticks(xPos) ax.set_xticklabels(displayLabels, rotation=45, ha="right", fontsize=fontSize) # Label the y-axis with counts. ax.set_ylabel("Count") # Annotate each bar with the absolute count and percentage. for i, bar in enumerate(bars): # Get the height of the bar to position the annotation. height = classCounts[i] # Build the label text showing count and percent with one decimal place. labelText = f"{height}\n({percentages[i]:.1f}%)" # Place the annotation above the bar. ax.annotate( labelText, xy=(bar.get_x() + bar.get_width() / 2, height), xytext=(0, 3), textcoords="offset points", ha="center", va="bottom", fontsize=fontSize ) # Add a secondary y-axis to show cumulative percentage plotted against the same x-axis positions. ax2 = ax.twinx() # Compute cumulative percentages for the overlay. cumulative = np.cumsum(percentages) # Plot the cumulative percentage line with markers on the secondary axis. ax2.plot(xPos, cumulative, color="black", marker="o", linestyle="--", linewidth=1) # Mark and annotate the Pareto threshold crossing (e.g., 80%). try: # Find the first index where cumulative percentage meets or exceeds the threshold. crossing = np.where(cumulative >= paretoThreshold)[0] if (len(crossing) > 0): idx = int(crossing[0]) # Draw a marker at the crossing point on the cumulative curve. ax2.scatter([xPos[idx]], [cumulative[idx]], color="red", zorder=5) # Annotate the crossing point with number of classes and class name. annText = f"{paretoThreshold:.0f}% at {idx + 1} classes:\n{rawLabels[idx]} ({cumulative[idx]:.1f}%)" ax2.annotate(annText, xy=(xPos[idx], cumulative[idx]), xytext=(10, -30), textcoords="offset points", fontsize="x-small", color="red", arrowprops=dict(arrowstyle="->", color="red", lw=0.5)) except Exception: # Ignore Pareto annotation failures to keep plotting robust. pass # Label the secondary axis and limit it to 0-100%. ax2.set_ylabel("Cumulative %") ax2.set_ylim(0, 100) # Add grid lines for readability on the primary axis. ax.grid(axis="y", linestyle=":", alpha=0.3) # If a title was provided, use it and append the total sample count. if (title): ax.set_title(f"{title} (Total: {totalSamples})") # Create plots for horizontal orientation. else: # Create the main figure and axis for the horizontal bar chart. fig, ax = plt.subplots(figsize=effectiveFigSize) # Create y positions for the horizontal bars. yPos = np.arange(len(classNames)) # Draw the horizontal bars for class counts. bars = ax.barh(yPos, classCounts, color=colors, edgecolor="black", linewidth=0.3) # Configure the y-axis tick locations and labels. ax.set_yticks(yPos) ax.set_yticklabels(displayLabels, fontsize=fontSize) # Label the x-axis with counts for horizontal layout. ax.set_xlabel("Count") # Annotate each horizontal bar with the absolute count and percentage placed at the bar end. for i, bar in enumerate(bars): # Get the width of the bar to position the annotation. width = classCounts[i] # Build the label text showing count and percent with one decimal place. labelText = f"{width} ({percentages[i]:.1f}%)" # Place the annotation at the end of the bar. ax.annotate( labelText, xy=(width, bar.get_y() + bar.get_height() / 2), xytext=(3, 0), textcoords="offset points", ha="left", va="center", fontsize=fontSize ) # Add a secondary x-axis to show cumulative percentage plotted against the same y positions. ax2 = ax.twiny() # Compute cumulative percentages for the overlay. cumulative = np.cumsum(percentages) # Plot the cumulative percentage line with markers on the secondary axis. ax2.plot(cumulative, yPos, color="black", marker="o", linestyle="--", linewidth=1) # Mark and annotate the Pareto threshold crossing (e.g., 80%) for horizontal layout. try: # Find the first index where cumulative percentage meets or exceeds the threshold. crossing = np.where(cumulative >= paretoThreshold)[0] if (len(crossing) > 0): idx = int(crossing[0]) # Draw a marker at the crossing point on the cumulative curve. ax2.scatter([cumulative[idx]], [yPos[idx]], color="red", zorder=5) # Annotate the crossing point with number of classes and class name. clsOrClasses = "class" if (idx == 0) else "classes" annText = f"{paretoThreshold:.0f}% at {idx + 1} {clsOrClasses}: {rawLabels[idx]} ({cumulative[idx]:.1f}%)" ax2.annotate( annText, xy=(cumulative[idx], yPos[idx]), xytext=(5, 5), textcoords="offset points", fontsize="x-small", color="red", arrowprops=dict(arrowstyle="->", color="red", lw=0.5) ) except Exception: # Ignore Pareto annotation failures to keep plotting robust. pass # Label the secondary axis and limit it to 0-100%. ax2.set_xlabel("Cumulative %") ax2.set_xlim(0, 100) # Invert y-axis so the largest class appears at the top. ax.invert_yaxis() # Tighten layout so labels and annotations do not overlap. plt.tight_layout() # Adjust subplot margins automatically when labels are long to avoid clipping. try: if (wrapWidth): # Increase bottom margin for vertical orientation when long x-tick labels exist. if (finalOrientation == "vertical"): plt.subplots_adjust(bottom=0.30) else: # Increase left margin for horizontal orientation when long y-tick labels exist. plt.subplots_adjust(left=0.40) except Exception: # Ignore subplot adjustment failures and proceed to save. pass # Optionally save the figure to disk. if (save): # Ensure the output directory exists before saving. outPath.parent.mkdir(parents=True, exist_ok=True) # Save the figure as a PDF to the specified path using the requested dpi. try: fig.savefig(str(outPath), format="pdf", dpi=dpi, bbox_inches="tight") except Exception: # Attempt to save without specifying dpi for vector formats if the above fails. fig.savefig(str(outPath), format="pdf") # Optionally export a PNG alongside the PDF using the specified dpi. if (exportPng): pngPath = outPath.with_suffix(".png") fig.savefig(str(pngPath), format="png", dpi=dpi, bbox_inches="tight") # Optionally display the figure to the user. if (display): try: plt.show() except Exception: # Non-interactive environments may not support plt.show(); ignore. pass # Close the figure to free memory. plt.close(fig)
[docs] def SaveMatplotlibFigure( path, fig=None, dpi=720, show=False, exportPdf=True, exportPng=True, otherExtensions=None, ): r''' Save a matplotlib figure to disk in both PDF and PNG formats with tight layout. This helper function attempts to save the provided figure (or the current figure if none is provided) to disk using a base path. It saves in PDF format for vector quality and PNG format for raster use cases, applying tight layout to ensure all elements are visible. The function also ensures that the target directory exists before saving. Optionally, it can display the figure after saving. Parameters: path (str|Path): The base file path to save the figure. The function will derive PDF and PNG paths from this base. fig (matplotlib.figure.Figure, optional): The figure to save. If None, the current active figure will be used. Default is None. dpi (int, optional): Dots-per-inch for the saved figures. Default is 720 for high-quality output. show (bool, optional): Whether to display the figure after saving. Default is False. exportPdf (bool, optional): Whether to save the figure in PDF format. Default is True. exportPng (bool, optional): Whether to save the figure in PNG format. Default is True. otherExtensions (list[str], optional): Additional file extensions to save (e.g., ["svg", "eps"]). Default is None. Notes: - The function will attempt to save in PDF format first, and if that fails (e.g., due to issues with the figure content), it will still attempt to save a PNG version. - The target directory will be created if it does not exist, ensuring that the save operation does not fail due to missing directories. - Tight layout is applied to the figure before saving to prevent clipping of labels and other elements, but it will avoid calling `tight_layout` if the figure was created with `constrained_layout` to prevent layout warnings. ''' import os if (fig is None): fig = plt.gcf() # Ensure directory exists. d = os.path.dirname(path) if (d and (not os.path.exists(d))): os.makedirs(d, exist_ok=True) # Check if the path already has an extension; if not, default to .pdf for the main save. # If it has an extension, use it as the primary save format and derive the PDF path from it. # If it has not an extension, this means the user likely intended to specify a base path without extension, # so we will save both PDF and PNG using that base. pathObj = Path(path) if (pathObj.suffix == ""): pdfPath = pathObj.with_suffix(".pdf") pngPath = pathObj.with_suffix(".png") else: pdfPath = pathObj pngPath = pathObj.with_suffix(".png") # If the figure was created with constrained_layout=True, calling tight_layout # will change the layout and emit a UserWarning. Avoid calling tight_layout # in that case to prevent repeated "layout has changed to tight" warnings. constrained = False try: constrained = bool(getattr(fig, "get_constrained_layout", lambda: False)()) except Exception: constrained = getattr(fig, "constrained_layout", False) # Apply tight_layout while suppressing known UserWarnings about incompatible axes # (for example 3D axes, inset axes, or certain colorbar setups). Use a warnings # filter around the call so the save path is not noisy for users. if (not constrained): import warnings try: with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message="This figure includes Axes that are not compatible with tight_layout", ) fig.tight_layout() except Exception: # If tight_layout fails for any reason, continue and attempt saving the figure # without tight adjustments to avoid aborting the save routine. pass if (exportPdf): try: fig.savefig(str(pdfPath), format="pdf", dpi=dpi, bbox_inches="tight") except Exception: # Fallback to png only. pass if (exportPng): fig.savefig(str(pngPath), format="png", dpi=dpi, bbox_inches="tight") if (otherExtensions): for ext in otherExtensions: try: fig.savefig(str(pathObj.with_suffix(f".{ext}")), format=ext, dpi=dpi, bbox_inches="tight") except Exception: # Ignore failures for additional formats to ensure primary saves succeed. pass if (show): plt.show() plt.close(fig)
[docs] def GenerateDistributionCountFigure( countDictionary, classList, outputDirectory, splitList=None, dpi=720, fileName="Figure", xLabel="Dataset Split", yLabel="Number of Images", title="Class Distribution Counts", ): r''' Generate a bar chart figure showing the distribution of class counts across different dataset splits. Parameters: countDictionary (dict): A dictionary mapping keys of the form "SplitClass" to integer counts, e.g., {"TrainCat": 100, "TestDog": 50}. classList (list): A list of class names to include in the plot, e.g., ["Cat", "Dog", "Bird"]. outputDirectory (str|Path): The directory where the output figure files will be saved. splitList (list, optional): A list of dataset split names to include in the plot, e.g., ["Train", "Test", "Val"]. If None, defaults to ["Train", "Test", "Val"]. dpi (int, optional): Dots-per-inch for the saved figure. Default is 720 for high-quality output. fileName (str, optional): Base name for the output figure files (without extension). Default is "Figure". xLabel (str, optional): Label for the x-axis. Default is "Dataset Split". yLabel (str, optional): Label for the y-axis. Default is "Number of Images". title (str, optional): Title for the figure. Default is "Class Distribution Counts". Notes: - The function will create a grouped bar chart where each group corresponds to a dataset split, and each bar within a group corresponds to a class. The height of each bar represents the count of images for that class in that split. - The function saves the figure in both PDF and PNG formats in the specified output directory. ''' from HMB.Initializations import UpdateMatplotlibSettings # Update the matplotlib settings for publication quality. UpdateMatplotlibSettings() # Set the default split list if not provided. if (splitList is None): # Define the default split list. splitList = ["Train", "Test", "Val"] # Calculate the number of splits. numSplits = len(splitList) # Calculate the number of classes. numClasses = len(classList) # Initialize the figure and axis with a specific size. figure, axis = plt.subplots(figsize=(8, 6)) # Define the width of each bar dynamically based on the number of classes. barWidth = 0.8 / numClasses if (numClasses > 0) else 0.8 # Define the x locations for the groups. xLocations = np.arange(numSplits) # Generate a list of distinct colors for the classes. colorMap = plt.get_cmap("tab10") if (numClasses <= 10) else plt.get_cmap("tab20") # Extract the colors from the color map. colors = [colorMap(i) for i in range(numClasses)] # Iterate over each class to create grouped bars. for clsIndex, cls in enumerate(classList): # Calculate the x-axis offset for the current class. offset = (clsIndex - numClasses / 2 + 0.5) * barWidth # Extract the counts for the current class across all splits. currentCounts = [countDictionary.get(f"{split.capitalize()}{cls}", 0) for split in splitList] # Create the bars for the current class. barContainer = axis.bar( xLocations + offset, currentCounts, barWidth, label=cls, color=colors[clsIndex], alpha=0.9, edgecolor="black", linewidth=0.5 ) # Iterate over each bar in the container to annotate it. for bar in barContainer: # Get the height of the current bar. height = bar.get_height() # Annotate the bar with its value. axis.annotate( f"{int(height)}", xy=(bar.get_x() + bar.get_width() / 2, height), xytext=(0, 3), textcoords="offset points", ha="center", va="bottom", fontsize=10 ) # Set the x-axis ticks. axis.set_xticks(xLocations) # Set the x-axis tick labels. axis.set_xticklabels(splitList) # Set the x-axis label. axis.set_xlabel(xLabel) # Set the y-axis label. axis.set_ylabel(yLabel) # Set the title of the plot. axis.set_title(title) # Add a legend to the plot. axis.legend(frameon=False) # Remove the top spine of the plot. axis.spines["top"].set_visible(False) # Remove the right spine of the plot. axis.spines["right"].set_visible(False) # Enable the y-axis grid for better readability. axis.yaxis.grid(True, linestyle="--", alpha=0.6, zorder=0) # Set the grid to be below the bars. axis.set_axisbelow(True) # Adjust the layout to prevent clipping. plt.tight_layout() # Construct the output path for the PDF figure. figurePath = os.path.join(outputDirectory, f"{fileName}.pdf") # Save the figure as a high-resolution PDF. plt.savefig(figurePath, format="pdf", dpi=dpi, bbox_inches="tight") # Construct the output path for the PNG figure. pngPath = os.path.join(outputDirectory, f"{fileName}.png") # Save the figure as a high-resolution PNG. plt.savefig(pngPath, format="png", dpi=dpi, bbox_inches="tight") # Close the figure to free memory. plt.close()