Source code for alexandria.plotters.ctp515_plotter

"""
CTP515 Plotter

Creates comprehensive visualization plots for low-contrast detectability analysis results.
Displays image with color-coded ROIs, dual-axis CNR/Contrast plots, and statistics table.
"""

import matplotlib.patches as patches
import matplotlib.pyplot as plt
import numpy as np

from ..utils import compute_phantom_boundary


[docs] class CTP515Plotter: """ Plotter for AnalyzerCTP515 (low-contrast detectability) results. Creates a 2x2 layout displaying: - Image with color-coded ROI circles and background ROI - Dual-axis plot of CNR and Contrast vs. ROI diameter - Statistics table showing mean, std, CNR, and contrast for each ROI ROIs are color-coded by diameter size with adaptive contrast windowing to enhance visibility of low-contrast features. Args: analyzer (AnalyzerCTP515): Completed analyzer instance with results. """ def __init__(self, analyzer): """ Args: analyzer (AnalyzerCTP515): Completed analyzer instance with results. """ self.analyzer = analyzer self.results = analyzer.results self.image = analyzer.image self.center = analyzer.center
[docs] def plot(self): """ Generate visualization of low-contrast ROI analysis. Layout: - Top left: Image with ROI overlays - Top right: CNR and Contrast vs. ROI Diameter (dual y-axes) - Bottom: Statistics table """ fig = plt.figure(figsize=(16, 10)) # Create subplots ax_img = plt.subplot2grid((2, 2), (0, 0)) ax_plot = plt.subplot2grid((2, 2), (0, 1)) ax_table = plt.subplot2grid((2, 2), (1, 0), colspan=2) # Left: Image with ROIs (no cropping) display_image = self.image # Apply adaptive contrast focused on background values center_crop_size = 100 h, w = display_image.shape cy_center = h // 2 cx_center = w // 2 center_sample = display_image[ cy_center - center_crop_size // 2 : cy_center + center_crop_size // 2, cx_center - center_crop_size // 2 : cx_center + center_crop_size // 2, ] bg_mean = np.mean(center_sample) bg_std = np.std(center_sample) vmin = bg_mean - 3 * bg_std vmax = bg_mean + 3 * bg_std ax_img.imshow(display_image, cmap="gray", vmin=vmin, vmax=vmax) ax_img.set_title(f"CTP515 Low-Contrast ROIs (n={self.results['n_detected']})") ax_img.axis("off") # Draw outer phantom boundary (retrieve from analyzer if available) boundary = getattr(self.analyzer, "boundary", None) if boundary and "x" in boundary and "y" in boundary: boundary_x = np.array(boundary["x"]) boundary_y = np.array(boundary["y"]) else: pixel_spacing = getattr(self.analyzer, "pixel_spacing", None) _, (boundary_x, boundary_y) = compute_phantom_boundary( display_image, self.center, pixel_spacing ) if len(boundary_x) > 0: ax_img.plot(boundary_x, boundary_y, "r-", linewidth=1.5, alpha=0.5) # Draw central analysis region (approximate 65mm radius for low-contrast ROIs) pixel_spacing = getattr(self.analyzer, "pixel_spacing", None) if pixel_spacing: analysis_radius_px = 65.0 / pixel_spacing t = np.linspace(0, 2 * np.pi, 100) analysis_x = analysis_radius_px * np.cos(t) + self.center[0] analysis_y = analysis_radius_px * np.sin(t) + self.center[1] ax_img.plot(analysis_x, analysis_y, "c--", linewidth=1.0, alpha=0.4) # Plot phantom center center_col = self.center[0] center_row = self.center[1] ax_img.plot(center_col, center_row, "r+", markersize=12, markeredgewidth=2) # Define color map for different ROI diameters color_map = { 15: "#00ffff", 9: "#00ff00", 8: "#ffff00", 7: "#ff8800", 6: "#ff00ff", 5: "#ff0000", } diameters = [] cnrs = [] contrasts = [] legend_handles = [] legend_labels = [] bg_dist_mm = 35 bg_radius_mm = 5 bg_angle_deg = self.analyzer.ROI_ANGLES[0] - self.analyzer.angle_offset bg_angle_rad = np.radians(bg_angle_deg) spacing = self.analyzer.pixel_spacing bg_dist_px = bg_dist_mm / spacing bg_radius_px = bg_radius_mm / spacing cy, cx = self.center[1], self.center[0] bg_x = cx + bg_dist_px * np.cos(bg_angle_rad) bg_y = cy + bg_dist_px * np.sin(bg_angle_rad) bg_color = "red" bg_circle = patches.Circle( (bg_x, bg_y), bg_radius_px, edgecolor=bg_color, facecolor="none", linewidth=1, linestyle="-", ) ax_img.add_patch(bg_circle) for roi_name, roi_data in self.results["blobs"].items(): x = roi_data["x"] y = roi_data["y"] r = roi_data["r"] cnr = roi_data["cnr"] contrast = roi_data["contrast"] diameter = float(roi_name.split("_")[1].replace("mm", "")) diameters.append(diameter) cnrs.append(cnr) contrasts.append(contrast) color = color_map.get(int(diameter), "cyan") circle = patches.Circle( (x, y), r, edgecolor=color, facecolor="none", linewidth=1 ) ax_img.add_patch(circle) if int(diameter) not in [ int(label.split("mm")[0]) for label in legend_labels ]: legend_handles.append( patches.Patch(facecolor="none", edgecolor=color, linewidth=1) ) legend_labels.append(f"{int(diameter)}mm") legend_handles.append( patches.Patch( facecolor="none", edgecolor=bg_color, linewidth=3, linestyle="-" ) ) legend_labels.append("Background") if legend_handles: ax_img.legend( legend_handles, legend_labels, loc="upper right", fontsize=10, framealpha=0.8, title="ROI Diameter", ) sorted_indices = np.argsort(diameters) diameters_sorted = [diameters[i] for i in sorted_indices] cnrs_sorted = [cnrs[i] for i in sorted_indices] contrasts_sorted = [contrasts[i] for i in sorted_indices] ax_plot.plot( diameters_sorted, cnrs_sorted, "bo-", linewidth=3, markersize=8, label="CNR", alpha=0.5, ) ax_plot.set_xlabel("ROI Diameter (mm)", fontsize=12) ax_plot.set_ylabel("CNR", fontsize=12, color="blue") ax_plot.tick_params(axis="y", labelcolor="blue") ax_plot.grid(True, alpha=0.3) ax_contrast = ax_plot.twinx() ax_contrast.plot( diameters_sorted, contrasts_sorted, "ro-", linewidth=2, markersize=8, label="Contrast (%)", alpha=0.5, ) ax_contrast.set_ylabel("Contrast (%)", fontsize=12, color="red") ax_contrast.tick_params(axis="y", labelcolor="red") ax_plot.set_title("CNR and Contrast vs. ROI Diameter") lines1, labels1 = ax_plot.get_legend_handles_labels() lines2, labels2 = ax_contrast.get_legend_handles_labels() ax_plot.legend( lines1 + lines2, labels1 + labels2, loc="lower right", bbox_to_anchor=(1, 0) ) ax_table.axis("off") table_data = [["Diameter (mm)", "Mean (HU)", "Std (HU)", "CNR", "Contrast (%)"]] roi_list = [ (float(name.split("_")[1].replace("mm", "")), name, data) for name, data in self.results["blobs"].items() ] roi_list.sort(key=lambda x: x[0]) for diameter, roi_name, roi_data in roi_list: table_data.append( [ f"{diameter:.0f}", f"{roi_data['mean']:.1f}", f"{roi_data['std']:.1f}", f"{roi_data['cnr']:.2f}", f"{roi_data['contrast']:.2f}", ] ) if roi_list: bg_data = roi_list[0][2] table_data.append( [ "Background", f"{bg_data['bg_mean']:.1f}", f"{bg_data['bg_std']:.1f}", "—", "—", ] ) table = ax_table.table( cellText=table_data, cellLoc="center", loc="center", colWidths=[0.15] * 5 ) table.auto_set_font_size(False) table.set_fontsize(10) table.scale(1, 2) for i in range(len(table_data[0])): cell = table[(0, i)] cell.set_facecolor("#4CAF50") cell.set_text_props(weight="bold", color="white") for i in range(1, len(table_data)): for j in range(len(table_data[0])): cell = table[(i, j)] if i % 2 == 0: cell.set_facecolor("#f0f0f0") ax_table.set_title("ROI Statistics", fontsize=12, weight="bold", pad=10) fig.tight_layout() return fig