| """ |
| Interactive Plotly figures (returned to gr.Plot). Dark clinical styling to match |
| the EyeQC theme; hover, zoom, rotate and animation where it aids insight. |
| """ |
|
|
| from __future__ import annotations |
| import numpy as np |
| import plotly.graph_objects as go |
| from plotly.subplots import make_subplots |
|
|
| INK = "#0c1418"; PAPER = "rgba(0,0,0,0)"; TEAL = "#2aa5b8"; AMBER = "#e0b23c" |
| CORAL = "#e05252"; GREEN = "#28c07f"; GRID = "rgba(255,255,255,0.08)" |
| FONT = "Inter, system-ui, sans-serif" |
|
|
|
|
| def fig_to_iframe(fig, height=480): |
| """Render a Plotly figure as a self-contained iframe so Play/Reset work.""" |
| import html as _html |
| raw = fig.to_html(include_plotlyjs="cdn", full_html=True, |
| config={"displayModeBar": False, "responsive": True}) |
| doc = _html.escape(raw, quote=True) |
| return (f'<iframe srcdoc="{doc}" loading="lazy" ' |
| f'style="width:100%;height:{height}px;border:none;border-radius:12px;' |
| f'background:transparent;"></iframe>') |
|
|
|
|
| def _style(fig, h=430, legend=True): |
| fig.update_layout( |
| template="plotly_dark", paper_bgcolor=PAPER, plot_bgcolor=PAPER, |
| font=dict(family=FONT, color="#d7e2e6", size=12), |
| margin=dict(l=48, r=24, t=48, b=44), height=h, |
| showlegend=legend, legend=dict(bgcolor="rgba(0,0,0,0)"), |
| ) |
| fig.update_xaxes(gridcolor=GRID, zeroline=False) |
| fig.update_yaxes(gridcolor=GRID, zeroline=False) |
| return fig |
|
|
|
|
| |
| def dsp_figure(dsp): |
| """Degradation-sensitivity curves: disease confidence & QC vs severity.""" |
| kinds = list(dsp["curves"].keys()) |
| fig = make_subplots(rows=1, cols=len(kinds), shared_yaxes=True, |
| subplot_titles=[k.capitalize() for k in kinds]) |
| for c, k in enumerate(kinds, 1): |
| cur = dsp["curves"][k] |
| sev = cur["severity"] |
| fig.add_trace(go.Scatter(x=sev, y=cur["disease_prob"], name="disease p", |
| line=dict(color=CORAL, width=3), mode="lines+markers", |
| legendgroup="d", showlegend=(c == 1)), row=1, col=c) |
| fig.add_trace(go.Scatter(x=sev, y=[q/100 for q in cur["qc"]], name="QC quality", |
| line=dict(color=TEAL, width=3, dash="dot"), mode="lines+markers", |
| legendgroup="q", showlegend=(c == 1)), row=1, col=c) |
| fig.add_trace(go.Scatter(x=sev, y=cur["ungradable"], name="ungradable p", |
| line=dict(color=AMBER, width=2), mode="lines", |
| legendgroup="u", showlegend=(c == 1)), row=1, col=c) |
| fig.update_xaxes(title_text="severity", row=1, col=c) |
| fig.update_yaxes(title_text="probability / quality", range=[0, 1], row=1, col=1) |
| ttl = (f"Degradation Sensitivity - {dsp['top_disease']} | " |
| f"entanglement {dsp['entanglement_index']:.2f}") |
| fig.update_layout(title=dict(text=ttl, font=dict(size=14))) |
| return _style(fig, h=420) |
|
|
|
|
| def entanglement_dial(entanglement): |
| """Radial gauge for the entanglement index.""" |
| val = float(entanglement) * 100 |
| color = CORAL if val > 40 else GREEN |
| fig = go.Figure(go.Indicator( |
| mode="gauge+number", value=val, number=dict(suffix="%", font=dict(size=34)), |
| title=dict(text="Entanglement index", font=dict(size=14)), |
| gauge=dict(axis=dict(range=[0, 100], tickcolor="#8aa"), |
| bar=dict(color=color, thickness=0.32), |
| steps=[dict(range=[0, 40], color="rgba(40,192,127,0.18)"), |
| dict(range=[40, 100], color="rgba(224,82,82,0.18)")], |
| bordercolor="rgba(0,0,0,0)"))) |
| return _style(fig, h=300, legend=False) |
|
|
|
|
| |
| def embedding_scatter(emb, labels, title="Embedding", dims=2): |
| labels = np.asarray(labels).astype(str) |
| fig = go.Figure() |
| palette = [TEAL, CORAL, AMBER, GREEN, "#9d7bd8", "#e08a3c", "#4db6ac"] |
| for i, g in enumerate(sorted(set(labels))): |
| sel = labels == g |
| col = palette[i % len(palette)] |
| if dims == 3 and emb.shape[1] >= 3: |
| fig.add_trace(go.Scatter3d(x=emb[sel, 0], y=emb[sel, 1], z=emb[sel, 2], |
| mode="markers", name=g, |
| marker=dict(size=5, color=col, opacity=0.9, |
| line=dict(width=0.5, color="#fff")))) |
| else: |
| fig.add_trace(go.Scatter(x=emb[sel, 0], y=emb[sel, 1], mode="markers", |
| name=g, marker=dict(size=11, color=col, opacity=0.9, |
| line=dict(width=1, color="#fff")))) |
| fig.update_layout(title=dict(text=title, font=dict(size=14))) |
| return _style(fig, h=460) |
|
|
|
|
| def animated_correction(emb_before, emb_after, batches, frames=24): |
| """Animate points morphing from pre-correction to post-correction positions.""" |
| b = np.asarray(batches).astype(str) |
| palette = [TEAL, CORAL, AMBER, GREEN, "#9d7bd8", "#e08a3c", "#4db6ac"] |
| uniq = sorted(set(b)) |
| cmap = {g: palette[i % len(palette)] for i, g in enumerate(uniq)} |
| colors = [cmap[x] for x in b] |
|
|
| def frame_data(t): |
| p = (1 - t) * emb_before + t * emb_after |
| return go.Scatter(x=p[:, 0], y=p[:, 1], mode="markers", |
| marker=dict(size=11, color=colors, opacity=0.9, |
| line=dict(width=1, color="#fff")), |
| showlegend=False) |
|
|
| ts = np.linspace(0, 1, frames) |
| fig = go.Figure( |
| data=[frame_data(0)], |
| frames=[go.Frame(data=[frame_data(t)], name=f"{i}") |
| for i, t in enumerate(ts)]) |
| |
| for g in uniq: |
| fig.add_trace(go.Scatter(x=[None], y=[None], mode="markers", name=g, |
| marker=dict(size=11, color=cmap[g]))) |
| fig.update_layout( |
| title=dict(text="Batch harmonisation (press play)", font=dict(size=14)), |
| updatemenus=[dict(type="buttons", showactive=False, x=0.02, y=1.12, |
| buttons=[dict(label="▶ Play", method="animate", |
| args=[None, dict(frame=dict(duration=60, redraw=True), |
| fromcurrent=True, transition=dict(duration=0))]), |
| dict(label="⏮ Reset", method="animate", |
| args=[["0"], dict(frame=dict(duration=0, redraw=True), |
| mode="immediate")])])]) |
| return _style(fig, h=460, legend=True) |
|
|