EyeQC / src /interactive_viz.py
rdharini2001's picture
EyeQC
6fe482b
Raw
History Blame Contribute Delete
6.52 kB
"""
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
# ---------------------------------------------------------------- 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)