"""
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