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