Source code for alexandria.plotters.ctp401_plotter

"""
CTP401 Plotter

Creates comprehensive visualization plots for 4-ROI linearity analysis results.
Displays main image with ROI circles, per-ROI histograms, and 2D heatmaps.
"""

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

from ..utils import compute_phantom_boundary


[docs] class CTP401Plotter: """ Plotter for AnalyzerCTP401 results. """ def __init__(self, analyzer, vmin: float = None, vmax: float = None): self.analyzer = analyzer self.results = analyzer.results self.vmin = vmin self.vmax = vmax self.materials = ["LDPE", "Air", "Teflon", "Acrylic"] self.roi_angles = {"LDPE": 0, "Air": 90, "Teflon": 180, "Acrylic": 270} self.roi_colors = { "LDPE": "blue", "Air": "orange", "Teflon": "green", "Acrylic": "red", } self.roi_display_names = { "LDPE": "ROI0_LDPE", "Air": "ROI90_Air", "Teflon": "ROI180_Teflon", "Acrylic": "ROI270_Acrylic", }
[docs] def plot(self): image = getattr(self.analyzer, "image", None) if image is None: raise ValueError("Analyzer has no image to plot.") fig = plt.figure(figsize=(12, 8)) gs = fig.add_gridspec(4, 3, width_ratios=[3, 1, 1], wspace=0.3, hspace=0.6) ax_img = fig.add_subplot(gs[:, 0]) ax_img.imshow(image, cmap="gray", vmin=self.vmin, vmax=self.vmax) ax_img.set_title("CTP401 ROIs and HU Values") ax_img.axis("off") 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( image, self.analyzer.center, pixel_spacing ) if len(boundary_x) > 0: ax_img.plot( boundary_x, boundary_y, "r-", linewidth=1.5, alpha=0.5, label="Phantom Boundary", ) pixel_spacing = getattr(self.analyzer, "pixel_spacing", None) if hasattr(self.analyzer, "material_distance") and pixel_spacing: analysis_radius_px = self.analyzer.material_distance / pixel_spacing t = np.linspace(0, 2 * np.pi, 100) analysis_x = analysis_radius_px * np.cos(t) + self.analyzer.center[0] analysis_y = analysis_radius_px * np.sin(t) + self.analyzer.center[1] ax_img.plot( analysis_x, analysis_y, "c--", linewidth=1.0, alpha=0.4, label="Analysis Region", ) cx, cy = self.analyzer.center ax_img.plot(cx, cy, "r+", markersize=15, markeredgewidth=2, label="Center") legend_handles = [] rois = self.analyzer.results.get("ROIs", {}) ny, nx = image.shape[:2] for i, material in enumerate(self.materials): stats = rois.get(material) if stats is None: display_name = self.roi_display_names[material] ax_hist = fig.add_subplot(gs[i, 1]) ax_hist.text(0.5, 0.5, "no data", ha="center", va="center") ax_hist.set_title(display_name, fontsize=9) ax_hist.axis("off") ax_heat = fig.add_subplot(gs[i, 2]) ax_heat.text(0.5, 0.5, "no data", ha="center", va="center") ax_heat.set_title(f"{display_name} Heatmap", fontsize=9) ax_heat.axis("off") continue color = self.roi_colors.get(material, "white") display_name = self.roi_display_names[material] cx = stats.get("center_x", self.analyzer.center[0]) cy = stats.get("center_y", self.analyzer.center[1]) radius_px = self.analyzer.roi_radius / self.analyzer.pixel_spacing circle = patches.Circle( (cx, cy), radius=radius_px, edgecolor=color, facecolor="none", linewidth=2, alpha=0.6, ) ax_img.add_patch(circle) label = f"{display_name}: {stats['mean']:.1f} ± {stats['std']:.1f}" legend_handles.append( patches.Circle( (0, 0), radius=radius_px, edgecolor=color, facecolor="none", alpha=0.6, label=label, ) ) ax_hist = fig.add_subplot(gs[i, 1]) try: mask = self.analyzer._create_circular_mask( ny, nx, center=(cx, cy), radius=radius_px ) data = image[mask] except Exception: data = np.array([]) if data.size > 0: counts, bins = np.histogram(data.flatten(), bins=30) bin_centers = (bins[:-1] + bins[1:]) / 2.0 import matplotlib.colors as mcolors darker_color = mcolors.to_rgba(color, alpha=1.0) darker_color = tuple(max(0, c - 0.3) for c in darker_color[:3]) + (1.0,) ax_hist.bar( bin_centers, counts, width=(bins[1] - bins[0]), color=color, alpha=0.75, align="center", edgecolor=darker_color, linewidth=0.5, ) mean_val = float(stats.get("mean", np.mean(data))) std_val = float(stats.get("std", np.std(data))) median_val = float(np.median(data)) ypos = counts.max() * 0.9 if counts.size else 1.0 ax_hist.axvline(mean_val, color="k", linestyle="--", linewidth=1) ax_hist.axvline(median_val, color="k", linestyle=":", linewidth=1) ax_hist.errorbar( [mean_val], [ypos], xerr=[std_val], fmt="none", ecolor="k", capsize=3, linewidth=1, ) else: ax_hist.text(0.5, 0.5, "no data", ha="center", va="center") ax_hist.grid(axis="y", linestyle="--", alpha=0.7) for spine in ax_hist.spines.values(): spine.set_linewidth(2) spine.set_color("black") ax_hist.set_title(display_name, fontsize=9) if i == 3: ax_hist.set_xlabel("HU", fontsize=8) ax_hist.set_ylabel("Counts", fontsize=8) ax_hist.tick_params(axis="both", which="major", labelsize=8) ax_heat = fig.add_subplot(gs[i, 2]) if data.size > 0: roi_img = np.zeros((int(radius_px * 2), int(radius_px * 2))) y_indices, x_indices = np.ogrid[: roi_img.shape[0], : roi_img.shape[1]] dist = np.sqrt( (x_indices - radius_px) ** 2 + (y_indices - radius_px) ** 2 ) circle_mask = dist <= radius_px roi_img[circle_mask] = data[: circle_mask.sum()] ax_heat.imshow(roi_img, cmap="gray", vmin=self.vmin, vmax=self.vmax) else: ax_heat.text(0.5, 0.5, "no data", ha="center", va="center") ax_heat.set_title(f"{display_name} Heatmap", fontsize=9) ax_heat.axis("off") if legend_handles: ax_img.legend( handles=legend_handles, loc="upper right", fontsize=8, framealpha=0.9 ) from matplotlib.lines import Line2D mean_line = Line2D( [0], [0], color="k", linestyle="--", linewidth=1, label="Mean" ) median_line = Line2D( [0], [0], color="k", linestyle=":", linewidth=1, label="Median" ) fig.legend( handles=[mean_line, median_line], loc="lower center", ncol=2, fontsize=10, framealpha=0.9, ) return fig