Source code for alexandria.plotters.detailed_uniformity_plotter
"""
Detailed Uniformity Plotter
Plots concentric profile values (angle vs HU) and overlayed histograms.
"""
from typing import List, Dict, Any
import matplotlib.pyplot as plt
import numpy as np
from scipy.signal import savgol_filter
[docs]
class DetailedUniformityPlotter:
"""
Plotter for DetailedUniformityAnalyzer results.
"""
def __init__(self, analyzer):
self.analyzer = analyzer
if not getattr(self.analyzer, "results", None):
self.analyzer.analyze()
def _get_profiles(self) -> List[Dict[str, Any]]:
plot_radii = set(round(float(r), 3) for r in getattr(self.analyzer, "radii_mm", self.analyzer.results.get("plot_radii_mm", [])))
if getattr(self.analyzer, "profile_data", None):
return [profile for profile in self.analyzer.profile_data if round(float(profile.get("radius_mm", 0.0)), 3) in plot_radii]
profiles = []
for profile in self.analyzer.results.get("profiles", []):
if round(float(profile.get("radius_mm", 0.0)), 3) not in plot_radii:
continue
profiles.append({"radius_mm": profile["radius_mm"], "angles_deg": np.array(profile["angles_deg"], dtype=float), "values": np.array(profile["values"], dtype=float)})
return profiles
def _get_all_profiles(self) -> List[Dict[str, Any]]:
if getattr(self.analyzer, "profile_data", None):
return self.analyzer.profile_data
profiles = []
for profile in self.analyzer.results.get("profiles", []):
profiles.append({"radius_mm": profile["radius_mm"], "angles_deg": np.array(profile["angles_deg"], dtype=float), "values": np.array(profile["values"], dtype=float)})
return profiles
[docs]
def plot(self, bins: int = 25, figsize: tuple = (12, 18)) -> plt.Figure:
profiles = self._get_profiles()
if not profiles:
raise ValueError("No profile data available for plotting")
fig, (ax_profile, ax_smooth, ax_resid, ax_hist, ax_mean, ax_img) = plt.subplots(6, 1, figsize=figsize)
colors = plt.cm.tab10(np.linspace(0, 1, max(3, len(profiles))))
mean_points = []
std_points = []
radius_points = []
for idx, profile in enumerate(profiles):
radius = profile["radius_mm"]
angles = profile["angles_deg"]
values = profile["values"]
color = colors[idx % len(colors)]
ax_profile.plot(angles, values, color=color, linewidth=1.2, label=f"r={radius:.1f}mm")
window_length = min(17, len(values) - 1 if len(values) % 2 == 0 else len(values))
if window_length < 5:
smoothed = values
else:
if window_length % 2 == 0:
window_length -= 1
smoothed = savgol_filter(values, window_length=window_length, polyorder=3, mode='interp')
ax_smooth.plot(angles, smoothed, color=color, linewidth=1.2, label=f"r={radius:.1f}mm")
mean_val = float(np.mean(values))
residuals = values - mean_val
ax_resid.plot(angles, residuals, color=color, linewidth=1.0, label=f"r={radius:.1f}mm")
ax_hist.hist(values, bins=bins, histtype='step', color=color, linewidth=2, label=f"r={radius:.1f}mm")
std_val = float(np.std(values))
if std_val > 0.0:
x_min = float(np.min(values))
x_max = float(np.max(values))
if x_max > x_min:
x_vals = np.linspace(x_min, x_max, 200)
bin_width = (x_max - x_min) / float(bins)
pdf = (1.0 / (std_val * np.sqrt(2.0 * np.pi))) * np.exp(-0.5 * ((x_vals - float(np.mean(values))) / std_val) ** 2)
ax_hist.plot(x_vals, pdf * len(values) * bin_width, color=color, linestyle='--', linewidth=1.0)
radius_points.append(radius)
mean_points.append(mean_val)
std_points.append(std_val)
ax_profile.set_title("Detailed Uniformity Profiles")
ax_profile.set_xlabel("Angle (deg)")
ax_profile.set_ylabel("Pixel Value (HU)")
ax_profile.grid(True, alpha=0.3)
ax_profile.legend(loc='upper left', bbox_to_anchor=(1.05, 1), fontsize=9)
ax_smooth.set_title("Smoothed Profiles (Savitzky-Golay)")
ax_smooth.set_xlabel("Angle (deg)")
ax_smooth.set_ylabel("Pixel Value (HU)")
ax_smooth.grid(True, alpha=0.3)
ax_smooth.legend(loc='center left', bbox_to_anchor=(1.02, 0.5), fontsize=9)
ax_resid.set_title("Residuals vs Angle (Value - Mean)")
ax_resid.set_xlabel("Angle (deg)")
ax_resid.set_ylabel("Residual (HU)")
ax_resid.grid(True, alpha=0.3)
ax_resid.legend(loc='center left', bbox_to_anchor=(1.02, 0.5), fontsize=9)
ax_hist.set_title("Profile Value Histograms")
ax_hist.set_xlabel("Pixel Value (HU)")
ax_hist.set_ylabel("Count")
ax_hist.grid(True, alpha=0.3)
ax_hist.legend(loc='center left', bbox_to_anchor=(1.02, 0.5), fontsize=9)
all_profiles = self._get_all_profiles()
mean_points_all = [float(np.mean(p["values"])) for p in all_profiles]
std_points_all = [float(np.std(p["values"])) for p in all_profiles]
radius_points_all = [float(p["radius_mm"]) for p in all_profiles]
ax_mean.errorbar(radius_points_all, mean_points_all, yerr=std_points_all, fmt='o-', color='black', ecolor='gray', capsize=4, label='Mean (HU)')
ax_mean.set_title("Mean and Std vs Radius")
ax_mean.set_xlabel("Radius (mm)")
ax_mean.set_ylabel("Mean Pixel Value (HU)")
ax_mean.grid(True, alpha=0.3)
ax_std = ax_mean.twinx()
ax_std.plot(radius_points_all, std_points_all, 's--', color='tab:blue', label='Std Dev (HU)')
ax_std.set_ylabel("Std Dev (HU)")
lines_mean, labels_mean = ax_mean.get_legend_handles_labels()
lines_std, labels_std = ax_std.get_legend_handles_labels()
ax_mean.legend(lines_mean + lines_std, labels_mean + labels_std, loc='upper left', bbox_to_anchor=(1.05, 1), fontsize=9)
image = getattr(self.analyzer, "image", None)
center = getattr(self.analyzer, "center", None)
if image is None or center is None:
raise ValueError("Analyzer image and center are required for overlay plot")
ax_img.imshow(image, cmap="gray")
ax_img.set_title("Sampling Rings")
ax_img.axis("off")
cx, cy = center
for idx, profile in enumerate(profiles):
radius_mm = profile["radius_mm"]
radius_px = radius_mm / float(getattr(self.analyzer, "pixel_spacing", 1.0))
theta = np.linspace(0, 2 * np.pi, 200)
circle_x = cx + radius_px * np.cos(theta)
circle_y = cy + radius_px * np.sin(theta)
ax_img.plot(circle_x, circle_y, color=colors[idx % len(colors)], linewidth=1.2)
ax_img.plot(cx, cy, "r+", markersize=10, markeredgewidth=2)
fig.tight_layout()
return fig