""" 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'') 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 # ---------------------------------------------------------------- DSP curves 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) # ---------------------------------------------------------- embedding explorer 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)]) # legend proxies 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)