EyeQC / src /visualize.py
rdharini2001's picture
EyeQC
6fe482b
Raw
History Blame Contribute Delete
11.4 kB
"""
Publication-grade visualisations for the retinal QC world.
All figures share a restrained clinical palette (deep slate ink on warm paper,
teal / amber / coral accents) so the tool reads like a journal figure rather
than a dashboard. Every function returns an RGB numpy array for Gradio.
"""
from __future__ import annotations
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Circle
import cv2
import io
# ----------------------------------------------------------------- palette / style
INK = "#12232e"
PAPER = "#faf8f4"
TEAL = "#1f7a8c"
DEEP = "#0b3c49"
AMBER = "#d69e2e"
CORAL = "#e05252"
GREEN = "#1f9d61"
MUTED = "#6b7c85"
GRID = "#dfe4e2"
VERDICT_COLORS = {"PASS": GREEN, "ACCEPTABLE": AMBER, "FAIL": CORAL}
plt.rcParams.update({
"figure.facecolor": PAPER, "axes.facecolor": PAPER,
"savefig.facecolor": PAPER, "text.color": INK,
"axes.edgecolor": "#c8d0cd", "axes.labelcolor": INK,
"xtick.color": MUTED, "ytick.color": MUTED,
"font.family": "DejaVu Sans", "font.size": 10,
"axes.grid": True, "grid.color": GRID, "grid.linewidth": 0.7,
})
def _fig_to_rgb(fig, dpi=130):
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
arr = np.array(matplotlib.image.imread(buf) * 255, np.uint8)
return arr[..., :3]
# --------------------------------------------------------------------- score card
def score_gauge(summary):
"""A circular composite-score gauge with the verdict."""
fig, ax = plt.subplots(figsize=(3.2, 3.2))
ax.set_xlim(-1.3, 1.3); ax.set_ylim(-1.3, 1.3); ax.axis("off"); ax.set_aspect("equal")
score = summary["composite"]
color = VERDICT_COLORS[summary["verdict"]]
# track
theta = np.linspace(np.pi * 1.25, -np.pi * 0.25, 200)
ax.plot(np.cos(theta), np.sin(theta), color=GRID, lw=14, solid_capstyle="round")
frac = score / 100
theta2 = np.linspace(np.pi * 1.25, np.pi * 1.25 - 1.5 * np.pi * frac, 200)
ax.plot(np.cos(theta2), np.sin(theta2), color=color, lw=14, solid_capstyle="round")
ax.text(0, 0.12, f"{score:.0f}", ha="center", va="center", fontsize=40,
fontweight="bold", color=INK)
ax.text(0, -0.32, "/ 100", ha="center", va="center", fontsize=12, color=MUTED)
ax.text(0, -0.72, summary["verdict"], ha="center", va="center", fontsize=17,
fontweight="bold", color=color)
ax.text(0, -1.02, f'({summary["band"]})', ha="center", va="center",
fontsize=10, color=MUTED)
return _fig_to_rgb(fig)
def metric_radar(metrics):
"""Radar chart of the per-axis 0-1 scores."""
names = [m["name"].replace(" / ", "/\n").replace(" (", "\n(") for m in metrics]
vals = [m["score"] for m in metrics]
N = len(vals)
ang = np.linspace(0, 2 * np.pi, N, endpoint=False).tolist()
vals2 = vals + vals[:1]; ang2 = ang + ang[:1]
fig, ax = plt.subplots(figsize=(5.2, 5.2), subplot_kw=dict(polar=True))
ax.set_facecolor(PAPER)
ax.plot(ang2, vals2, color=TEAL, lw=2)
ax.fill(ang2, vals2, color=TEAL, alpha=0.22)
# pass / acceptable rings
ax.plot(np.linspace(0, 2*np.pi, 100), [0.66]*100, color=GREEN, ls="--", lw=1, alpha=0.6)
ax.plot(np.linspace(0, 2*np.pi, 100), [0.40]*100, color=AMBER, ls="--", lw=1, alpha=0.6)
ax.set_xticks(ang); ax.set_xticklabels(names, fontsize=8)
ax.set_yticks([0.4, 0.66, 1.0]); ax.set_yticklabels(["0.4", "0.66", "1.0"], fontsize=7)
ax.set_ylim(0, 1)
ax.set_title("Per-axis quality profile", color=INK, fontsize=12, pad=18)
return _fig_to_rgb(fig)
def metric_bars(metrics):
"""Horizontal bar chart of per-axis scores, coloured by status."""
metrics = sorted(metrics, key=lambda m: m["score"])
names = [m["name"] for m in metrics]
vals = [m["score"] for m in metrics]
cols = [GREEN if m["score"] >= 0.66 else AMBER if m["score"] >= 0.40 else CORAL
for m in metrics]
fig, ax = plt.subplots(figsize=(6.4, 4.3))
y = np.arange(len(names))
ax.barh(y, vals, color=cols, height=0.66, edgecolor="white")
for yi, m in zip(y, metrics):
ax.text(min(m["score"] + 0.02, 0.92), yi,
f'{m["value"]:.1f} {m["unit"]}', va="center", fontsize=7.5, color=MUTED)
ax.set_yticks(y); ax.set_yticklabels(names, fontsize=9)
ax.axvline(0.66, color=GREEN, ls="--", lw=1, alpha=0.6)
ax.axvline(0.40, color=AMBER, ls="--", lw=1, alpha=0.6)
ax.set_xlim(0, 1); ax.set_xlabel("axis score (0-1)")
ax.set_title("Quality axes - worst first", fontsize=12, loc="left")
ax.grid(axis="y", alpha=0)
return _fig_to_rgb(fig)
# --------------------------------------------------------------------- cohort views
def cohort_distribution(df):
"""Histogram of composite scores + PASS/ACCEPTABLE/FAIL bar."""
fig, (a1, a2) = plt.subplots(1, 2, figsize=(9.2, 3.6),
gridspec_kw=dict(width_ratios=[2.2, 1]))
a1.hist(df["composite"], bins=20, range=(0, 100), color=TEAL, alpha=0.85,
edgecolor="white")
a1.axvline(70, color=GREEN, ls="--", lw=1.2); a1.axvline(45, color=AMBER, ls="--", lw=1.2)
a1.set_xlabel("composite quality score"); a1.set_ylabel("images")
a1.set_title("Cohort quality distribution", fontsize=12, loc="left")
counts = df["verdict"].value_counts()
order = ["PASS", "ACCEPTABLE", "FAIL"]
vals = [int(counts.get(k, 0)) for k in order]
a2.bar(order, vals, color=[VERDICT_COLORS[k] for k in order], edgecolor="white")
for i, v in enumerate(vals):
a2.text(i, v, str(v), ha="center", va="bottom", fontsize=11, fontweight="bold")
a2.set_title("Verdicts", fontsize=12, loc="left"); a2.grid(axis="x", alpha=0)
a2.tick_params(axis="x", labelrotation=25)
return _fig_to_rgb(fig)
def axis_heatmap(df, metric_names):
"""Image x axis score heatmap for the cohort (small-multiple-free overview)."""
M = df[metric_names].values.T
fig, ax = plt.subplots(figsize=(max(6, 0.28 * len(df)), 4.2))
im = ax.imshow(M, aspect="auto", cmap="RdYlGn", vmin=0, vmax=1)
ax.set_yticks(range(len(metric_names)))
ax.set_yticklabels(metric_names, fontsize=8)
ax.set_xlabel("image index")
ax.set_title("Per-axis scores across the cohort", fontsize=12, loc="left")
fig.colorbar(im, ax=ax, fraction=0.02, pad=0.01, label="score")
ax.grid(False)
return _fig_to_rgb(fig)
def metric_correlation(df, metric_names):
C = np.corrcoef(df[metric_names].values.T)
fig, ax = plt.subplots(figsize=(5.6, 5.0))
im = ax.imshow(C, cmap="RdBu_r", vmin=-1, vmax=1)
ax.set_xticks(range(len(metric_names))); ax.set_yticks(range(len(metric_names)))
ax.set_xticklabels(metric_names, rotation=55, ha="right", fontsize=7.5)
ax.set_yticklabels(metric_names, fontsize=7.5)
ax.set_title("Quality-axis correlation", fontsize=12, loc="left")
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02); ax.grid(False)
return _fig_to_rgb(fig)
# --------------------------------------------------------------------- batch views
def batch_scatter(emb, batches, name, title_suffix=""):
fig, ax = plt.subplots(figsize=(5.6, 4.8))
batches = np.asarray(batches)
cmap = plt.cm.get_cmap("tab10")
for i, g in enumerate(np.unique(batches)):
sel = batches == g
ax.scatter(emb[sel, 0], emb[sel, 1], s=42, color=cmap(i % 10),
edgecolor="white", lw=0.6, label=str(g), alpha=0.9)
ax.set_xlabel(f"{name}-1"); ax.set_ylabel(f"{name}-2")
ax.legend(title="batch", frameon=False, fontsize=8)
ax.set_title(f"Embedding by batch{title_suffix}", fontsize=12, loc="left")
return _fig_to_rgb(fig)
def batch_before_after(emb_b, emb_a, batches, name):
fig, axes = plt.subplots(1, 2, figsize=(10.2, 4.5), sharex=False)
batches = np.asarray(batches); cmap = plt.cm.get_cmap("tab10")
for ax, emb, ttl in [(axes[0], emb_b, "Before correction"),
(axes[1], emb_a, "After correction")]:
for i, g in enumerate(np.unique(batches)):
sel = batches == g
ax.scatter(emb[sel, 0], emb[sel, 1], s=40, color=cmap(i % 10),
edgecolor="white", lw=0.6, label=str(g), alpha=0.9)
ax.set_title(ttl, fontsize=12, loc="left")
ax.set_xlabel(f"{name}-1"); ax.set_ylabel(f"{name}-2")
axes[1].legend(title="batch", frameon=False, fontsize=8)
return _fig_to_rgb(fig)
# --------------------------------------------------------------------- image panels
def av_overlay(rgb, vessels):
"""Overlay arteries (red) and veins (blue) from a deep A/V segmentation."""
out = rgb.copy().astype(np.float32)
a = vessels.get("artery"); v = vessels.get("vein")
if a is None or v is None:
return vessel_overlay(rgb, vessels)
am = np.clip(a, 0, 1)[..., None]; vm = np.clip(v, 0, 1)[..., None]
red = np.array([230, 60, 60], np.float32)
blue = np.array([70, 130, 235], np.float32)
out = out * (1 - 0.65 * am) + red * (0.65 * am)
out = out * (1 - 0.65 * vm) + blue * (0.65 * vm)
return np.clip(out, 0, 255).astype(np.uint8)
def vessel_overlay(rgb, vessels):
"""Overlay the vessel probability map (teal) and skeleton on the fundus."""
out = rgb.copy().astype(np.float32)
prob = vessels["prob_map"]
heat = np.zeros_like(out)
heat[..., 0] = 31; heat[..., 1] = 122; heat[..., 2] = 140 # teal
a = np.clip(prob / (prob.max() + 1e-6), 0, 1)[..., None]
out = out * (1 - 0.6 * a) + heat * (0.6 * a)
sk = vessels["skeleton"]
out[sk] = [230, 210, 60] # amber skeleton
return np.clip(out, 0, 255).astype(np.uint8)
def vessel_stats_panel(vessels):
"""Bar chart of structural vessel descriptors."""
keys = ["density", "mean_vesselness", "skeleton_length", "fractal_dimension"]
labels = ["Vessel density", "Mean vesselness", "Skeleton length", "Fractal dim"]
vals = [vessels[k] for k in keys]
fig, ax = plt.subplots(figsize=(5.2, 3.2))
ax.barh(labels, vals, color=[TEAL, DEEP, GREEN, AMBER], edgecolor="white")
for i, v in enumerate(vals):
ax.text(v, i, f" {v:.3f}", va="center", fontsize=9, color=MUTED)
ax.set_title(f"Vascular structure ({vessels['backend']} backend)",
fontsize=12, loc="left")
ax.grid(axis="y", alpha=0)
return _fig_to_rgb(fig)
def quality_panel(thumbs, labels, verdicts, ncol=4, thumb=190):
"""Montage of image thumbnails with a coloured quality frame and label."""
n = len(thumbs)
if n == 0:
return np.full((200, 400, 3), 250, np.uint8)
nrow = int(np.ceil(n / ncol))
pad, lab_h = 14, 30
cell = thumb + 2 * pad + lab_h
W, H = ncol * cell, nrow * cell
canvas = np.full((H, W, 3), 248, np.uint8)
for i, (t, lab, vd) in enumerate(zip(thumbs, labels, verdicts)):
r, c = divmod(i, ncol)
y0, x0 = r * cell, c * cell
img = cv2.resize(t, (thumb, thumb))
col = tuple(int(VERDICT_COLORS[vd].lstrip("#")[k:k+2], 16) for k in (0, 2, 4))
fy, fx = y0 + pad, x0 + pad
cv2.rectangle(canvas, (fx - 4, fy - 4), (fx + thumb + 4, fy + thumb + 4), col, 3)
canvas[fy:fy + thumb, fx:fx + thumb] = img
cv2.putText(canvas, lab[:22], (fx - 2, fy + thumb + 20),
cv2.FONT_HERSHEY_SIMPLEX, 0.44, (30, 40, 45), 1, cv2.LINE_AA)
return canvas