Files
hypertower/scripts/output_analysis/explainability/plot_disc_attention_detail.py
T
rpotter6298 786457b30d update 3-19
2026-03-19 11:18:58 +01:00

285 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Granular disc-attention visualisation.
Layout (3 rows × N_class cols):
Row 0 — disc-centred mean GradCAM patch for CORRECT predictions
Row 1 — disc-centred mean GradCAM patch for INCORRECT predictions
Row 2 — per-patient strip plot of disc_frac (blue=correct, red=incorrect)
Disc-centred patches: each patient's CAM is translated and scaled so the GT
disc centroid sits at the patch centre before averaging. A dashed white circle
marks the average GT disc size. This makes cross-patient averaging meaningful
regardless of where the disc sits in the original image.
Usage
-----
python scripts/output_analysis/explainability/plot_disc_attention_detail.py \
--agg-dir analysis_data/pipeline_nocrop/binary/single/gradcam_aggregate \
--manifest manifest.csv
"""
from __future__ import annotations
import argparse
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Circle
from PIL import Image, ImageDraw
DISC_SPAN = 5 # patch side = DISC_SPAN × disc diameter
OUTPUT_SIZE = 96 # pixel size of each thumbnail
# ---------------------------------------------------------------------------
# Disc mask helpers
# ---------------------------------------------------------------------------
def _load_disc_mask(contour_path: Path, orig_size: tuple, cam_h: int, cam_w: int):
try:
arr = np.loadtxt(str(contour_path), dtype=np.float32)
except Exception:
return None
if arr.ndim == 1:
arr = arr.reshape(-1, 2)
if arr.shape[0] < 3 or arr.shape[1] < 2:
return None
img = Image.new("L", orig_size, 0)
ImageDraw.Draw(img).polygon([tuple(pt) for pt in arr[:, :2]], fill=1)
return np.array(img.resize((cam_w, cam_h), Image.NEAREST), dtype=bool)
def build_disc_lookup(manifest_path: Path) -> dict:
mf = pd.read_csv(manifest_path)
lookup: dict = {}
for _, row in mf.iterrows():
sid = str(row["sample_id"])
if not sid.startswith("papila_RET"):
continue
suffix = sid[len("papila_RET"):]
eye = suffix[-2:]
pid = int(suffix[:-2])
disc_path = Path(str(row["annotation_disc"]))
img_path = Path(str(row["image_path"]))
if not disc_path.exists():
continue
try:
with Image.open(img_path) as im:
orig_size = im.size
except Exception:
continue
lookup[(pid, eye)] = (disc_path, orig_size)
return lookup
# ---------------------------------------------------------------------------
# Disc-centred patch extraction
# ---------------------------------------------------------------------------
def disc_centered_patch(
cam: np.ndarray,
disc_mask: np.ndarray,
span: int = DISC_SPAN,
out: int = OUTPUT_SIZE,
) -> tuple[np.ndarray | None, float | None]:
"""
Return (patch, disc_r_out):
patch — (out, out) float32 in [0, 1]
disc_r_out — disc radius in patch-pixel units (for drawing reference circle)
"""
if disc_mask is None or disc_mask.sum() == 0:
return None, None
ys, xs = np.where(disc_mask)
cy, cx = ys.mean(), xs.mean()
disc_r = float(np.sqrt(disc_mask.sum() / np.pi))
half = max(1, int(round(span * disc_r / 2)))
h, w = cam.shape
y0, y1 = int(round(cy)) - half, int(round(cy)) + half
x0, x1 = int(round(cx)) - half, int(round(cx)) + half
pt = max(0, -y0); pb = max(0, y1 - h)
pl = max(0, -x0); pr = max(0, x1 - w)
cam_pad = np.pad(cam, ((pt, pb), (pl, pr)), constant_values=0.0)
patch = cam_pad[y0 + pt : y1 + pt, x0 + pl : x1 + pl]
patch_img = Image.fromarray((np.clip(patch, 0, 1) * 255).astype(np.uint8))
patch_out = np.array(patch_img.resize((out, out), Image.BILINEAR)) / 255.0
disc_r_out = out * disc_r / (2 * half)
return patch_out.astype(np.float32), disc_r_out
# ---------------------------------------------------------------------------
# Data loading
# ---------------------------------------------------------------------------
def load_all_cam_records(mode_dir: Path) -> list[dict]:
records = []
fold_dirs = sorted(
[d for d in mode_dir.iterdir() if d.is_dir() and d.name.startswith("fold")],
key=lambda p: int(p.name.replace("fold", "")),
)
for fd in fold_dirs:
gcam_dir = fd / "explainability" / "gradcam"
idx_path = gcam_dir / "gradcam_index.csv"
if not idx_path.exists():
continue
idx = pd.read_csv(idx_path)
for _, row in idx.iterrows():
pid = int(row["patient_id"])
for eye in ("OD", "OS"):
npy = gcam_dir / f"patient_{pid}_{eye}_cam.npy"
if not npy.exists():
continue
records.append({
"patient_id": pid,
"eye": eye,
"true_name": row["true_name"],
"correct": bool(row["correct"]),
"cam": np.load(npy),
})
return records
def build_mean_patches(
records: list[dict],
disc_lookup: dict,
classes: list[str],
) -> dict[tuple, tuple]:
"""
Returns {(cls, split): (mean_patch, mean_disc_r_out, count)}
split = 'correct' | 'incorrect'
"""
buckets: dict[tuple, list] = {}
radii: dict[tuple, list] = {}
for r in records:
split = "correct" if r["correct"] else "incorrect"
key = (r["true_name"], split)
pid, eye = r["patient_id"], r["eye"]
if (pid, eye) not in disc_lookup:
continue
disc_path, orig_size = disc_lookup[(pid, eye)]
h, w = r["cam"].shape
disc_mask = _load_disc_mask(disc_path, orig_size, h, w)
patch, disc_r_out = disc_centered_patch(r["cam"], disc_mask)
if patch is None:
continue
buckets.setdefault(key, []).append(patch)
radii.setdefault(key, []).append(disc_r_out)
return {
key: (np.stack(ps).mean(0), float(np.mean(radii[key])), len(ps))
for key, ps in buckets.items()
}
# ---------------------------------------------------------------------------
# Plotting
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--agg-dir", required=True)
ap.add_argument("--manifest", default="manifest.csv")
ap.add_argument("--out", default=None)
args = ap.parse_args()
agg_dir = Path(args.agg_dir)
mode_dir = agg_dir.parent
out_path = Path(args.out) if args.out else agg_dir / "disc_attention_detail.png"
print("Loading disc lookup…")
disc_lookup = build_disc_lookup(Path(args.manifest))
print(f" {len(disc_lookup)} entries")
print("Loading CAM records…")
records = load_all_cam_records(mode_dir)
print(f" {len(records)} eye records")
stats = pd.read_csv(agg_dir / "attention_stats.csv")
classes = sorted({r["true_name"] for r in records})
n_cls = len(classes)
print(f"Classes: {classes}")
print("Building disc-centred mean patches…")
mean_patches = build_mean_patches(records, disc_lookup, classes)
# -----------------------------------------------------------------------
# Figure
# -----------------------------------------------------------------------
corr_colors = {"correct": "steelblue", "incorrect": "tomato"}
splits = ["correct", "incorrect"]
row_labels = ["Correct", "Incorrect", "Disc fraction\n(strip plot)"]
fig, axes = plt.subplots(3, n_cls, figsize=(4.2 * n_cls, 13))
if n_cls == 1:
axes = axes[:, np.newaxis]
# ---- rows 0 & 1: disc-centred heatmaps ----
for ri, split in enumerate(splits):
for ci, cls in enumerate(classes):
ax = axes[ri, ci]
key = (cls, split)
if key in mean_patches:
mean_patch, disc_r_out, count = mean_patches[key]
ax.imshow(mean_patch, cmap="jet", vmin=0, vmax=1, origin="upper",
extent=[0, OUTPUT_SIZE, OUTPUT_SIZE, 0])
cx = cy = OUTPUT_SIZE / 2
ax.add_patch(Circle((cx, cy), disc_r_out,
fill=False, edgecolor="white",
linewidth=2, linestyle="--"))
ax.set_title(f"{cls} | {split}\n(N={count})", fontsize=9)
else:
ax.text(0.5, 0.5, "no data", ha="center", va="center",
transform=ax.transAxes, fontsize=9, color="grey")
ax.set_title(f"{cls} | {split}", fontsize=9)
ax.axis("off")
axes[ri, 0].set_ylabel(row_labels[ri], fontsize=10, labelpad=6)
# ---- row 2: strip plots ----
rng = np.random.default_rng(42)
for ci, cls in enumerate(classes):
ax = axes[2, ci]
sub = stats[stats["true_name"] == cls].dropna(subset=["disc_frac"])
for xi, split in enumerate(splits):
correct_val = (split == "correct")
pts = sub[sub["correct"] == correct_val]["disc_frac"].values
if len(pts) == 0:
continue
color = corr_colors[split]
jitter = rng.uniform(-0.18, 0.18, size=len(pts))
ax.scatter(xi + jitter, pts, color=color, alpha=0.7, s=28, edgecolors="none")
ax.hlines(pts.mean(), xi - 0.28, xi + 0.28,
colors=color, linewidth=2.5, zorder=5)
ax.set_xticks([0, 1])
ax.set_xticklabels(["Correct", "Incorrect"], fontsize=9)
ax.set_xlim(-0.55, 1.55)
ax.set_ylim(0, 1)
ax.set_title(cls, fontsize=10)
ax.grid(axis="y", linestyle="--", alpha=0.3)
if ci == 0:
ax.set_ylabel("Disc fraction\n(GT disc attention)", fontsize=9)
fig.suptitle(
"Disc-centred GradCAM attention | dashed circle = GT disc boundary",
fontsize=12,
)
fig.tight_layout()
fig.savefig(out_path, dpi=150, bbox_inches="tight")
plt.close(fig)
print(f"Saved → {out_path}")
if __name__ == "__main__":
main()