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.pyplot as plt
import matplotlib.patches as patches
import numpy as np
from typing import Optional
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 = int(self.center[1]), int(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