"""
High Contrast Plotter
Creates visualization plots for MTF/resolution analysis results.
"""
import matplotlib.pyplot as plt
import numpy as np
[docs]
class HighContrastPlotter:
"""
Creates visualization plots for HighContrastAnalyzer results.
"""
def __init__(self, analyzer):
self.analyzer = analyzer
if not hasattr(analyzer, "nMTF") or analyzer.nMTF is None:
analyzer.analyze(verbose=False)
[docs]
def plot(
self, figsize: tuple = None, vmin: float = None, vmax: float = None, **kwargs
) -> plt.Figure:
n_profiles = (
len(self.analyzer.profiles)
if hasattr(self.analyzer, "profiles") and self.analyzer.profiles
else 0
)
if figsize is None:
n_rows = max(n_profiles, 2)
figsize = (16, max(10, n_rows * 2))
from matplotlib.gridspec import GridSpec
n_to_show = n_profiles if n_profiles > 0 else 4
n_rows = max(n_to_show, 2)
fig = plt.figure(figsize=figsize)
gs = GridSpec(n_rows, 2, figure=fig, width_ratios=[1, 1])
ax_img = fig.add_subplot(gs[: n_rows // 2, 0])
img = self.analyzer.image
ax_img.imshow(img, cmap="gray", vmin=vmin, vmax=vmax)
ax_img.set_title("CTP528 Line Pairs & Sampling")
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"])
if len(boundary_x) > 0:
ax_img.plot(boundary_x, boundary_y, "r-", linewidth=1.5, alpha=0.5)
if (
hasattr(self.analyzer, "lp_r_mm")
and hasattr(self.analyzer, "pixel_spacing")
and self.analyzer.pixel_spacing
):
analysis_radius_px = self.analyzer.lp_r_mm / self.analyzer.pixel_spacing
center = self.analyzer.center
t = np.linspace(0, 2 * np.pi, 100)
analysis_x = analysis_radius_px * np.cos(t) + center[0]
analysis_y = analysis_radius_px * np.sin(t) + center[1]
ax_img.plot(analysis_x, analysis_y, "c--", linewidth=1.0, alpha=0.4)
if self.analyzer.center:
cx, cy = self.analyzer.center
ax_img.plot(cx, cy, "r+", markersize=15, markeredgewidth=2)
if self.analyzer.lpx is not None and self.analyzer.lpy is not None:
ax_img.plot(
self.analyzer.lpx,
self.analyzer.lpy,
"-r",
linewidth=1.5,
label="Line Pair Path",
)
if hasattr(self.analyzer, "lp_x") and hasattr(self.analyzer, "lp_y"):
for xs, ys in zip(self.analyzer.lp_x, self.analyzer.lp_y):
ax_img.plot(
[xs[0], xs[1]], [ys[0], ys[1]], "-g", linewidth=0.8, alpha=0.6
)
ax_img.axis("off")
ax_mtf = fig.add_subplot(gs[n_rows // 2 :, 0])
if self.analyzer.nMTF is not None and self.analyzer.lp_axis is not None:
ax_mtf.plot(
self.analyzer.lp_axis,
self.analyzer.nMTF,
"b-",
linewidth=2,
alpha=0.7,
label="nMTF",
)
mtf_thresholds = [0.8, 0.5, 0.3, 0.1]
colors = ["green", "orange", "red", "purple"]
labels = ["80%", "50%", "30%", "10%"]
mtf_points = self.analyzer.mtf_points
threshold_keys = ["MTF80", "MTF50", "MTF30", "MTF10"]
for threshold, color, label, key in zip(
mtf_thresholds, colors, labels, threshold_keys
):
ax_mtf.axhline(
threshold, color=color, linestyle="--", alpha=0.3, linewidth=1
)
if key in mtf_points and not np.isnan(mtf_points[key]):
ax_mtf.plot(
mtf_points[key],
threshold,
"o",
color=color,
markersize=8,
mfc="none",
label=key,
zorder=5,
)
ax_mtf.set_xlabel("Spatial Frequency (lp/mm)")
ax_mtf.set_ylabel("Normalized MTF")
ax_mtf.set_title("Aggregated Normalized MTF")
ax_mtf.grid(True, alpha=0.3)
ax_mtf.legend()
ax_mtf.set_ylim([0, 1.1])
if (
n_to_show > 0
and hasattr(self.analyzer, "profiles")
and self.analyzer.profiles
):
if n_to_show <= 10:
colors = plt.cm.tab10(np.linspace(0, 1, 10))
else:
colors = plt.cm.tab20(np.linspace(0, 1, 20))
ax_profiles = []
for i in range(n_to_show):
if i == 0:
ax_prof = fig.add_subplot(gs[i, 1])
else:
ax_prof = fig.add_subplot(gs[i, 1], sharex=ax_profiles[0])
profile = self.analyzer.profiles[i]
ax_prof.plot(
range(len(profile)), profile, color=colors[i], linewidth=1.5
)
ax_prof.set_ylabel("HU", fontsize=9)
ax_prof.grid(True, alpha=0.3)
ax_prof.text(
0.02,
0.98,
f"LP{i+1} (Profile {i})",
transform=ax_prof.transAxes,
fontsize=9,
va="top",
ha="left",
bbox=dict(facecolor="white", alpha=0.7, pad=2),
color=colors[i],
weight="bold",
)
if i == n_to_show - 1:
ax_prof.set_xlabel("Sample Index")
else:
ax_prof.tick_params(labelbottom=False)
ax_profiles.append(ax_prof)
fig.tight_layout()
return fig