Skip to content

Commit 6a61183

Browse files
committed
Add animated atlas reveal banner for profile
1 parent 73d7312 commit 6a61183

2 files changed

Lines changed: 154 additions & 0 deletions

File tree

docs/profile_banner.gif

753 KB
Loading

scripts/make_profile_banner.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Generate an animated profile banner: UMAP atlas reveal + volcano plot."""
2+
3+
import scanpy as sc
4+
import matplotlib.pyplot as plt
5+
import matplotlib.patheffects as pe
6+
import imageio.v3 as iio
7+
from pathlib import Path
8+
from io import BytesIO
9+
10+
RESULTS_DIR = Path("results")
11+
12+
PALETTE = {
13+
"CD4+ T cells": "#E69F00",
14+
"CD8+ T cells": "#56B4E9",
15+
"NK cells": "#009E73",
16+
"B cells": "#F0E442",
17+
"CD14+ Monocytes": "#0072B2",
18+
"FCGR3A+ Monocytes": "#D55E00",
19+
"Dendritic cells": "#CC79A7",
20+
"Megakaryocytes": "#999999",
21+
}
22+
23+
BG = "#0d1117" # GitHub dark mode background
24+
TEXT = "#e6edf3"
25+
GRID = "#21262d"
26+
27+
FPS = 20
28+
29+
30+
def render_frame(umap_coords, cell_types, categories, reveal_progress,
31+
xlim=None, ylim=None):
32+
"""Render one frame of the atlas reveal."""
33+
fig, ax = plt.subplots(figsize=(10, 7), facecolor=BG)
34+
ax.set_facecolor(BG)
35+
if xlim:
36+
ax.set_xlim(xlim)
37+
if ylim:
38+
ax.set_ylim(ylim)
39+
40+
n_types = len(categories)
41+
# Each cluster gets an equal slice of the progress bar
42+
cluster_slice = 1.0 / n_types
43+
44+
for i, ct in enumerate(categories):
45+
mask = cell_types == ct
46+
coords = umap_coords[mask]
47+
colour = PALETTE.get(ct, "#AAAAAA")
48+
49+
# Calculate this cluster's visibility (0 to 1)
50+
cluster_start = i * cluster_slice
51+
if reveal_progress < cluster_start:
52+
continue # not yet visible
53+
54+
# Fade in: 0 at cluster_start, 1 at cluster_end
55+
alpha = min(1.0, (reveal_progress - cluster_start) / cluster_slice)
56+
alpha = alpha ** 0.5 # ease-in curve
57+
58+
ax.scatter(
59+
coords[:, 0], coords[:, 1],
60+
c=colour, s=5, alpha=alpha * 0.85,
61+
edgecolors="none", rasterized=True,
62+
)
63+
64+
# Show label once cluster is >60% visible
65+
if alpha > 0.6:
66+
cx, cy = coords[:, 0].mean(), coords[:, 1].mean()
67+
ax.text(
68+
cx, cy, ct, fontsize=8, color="white",
69+
ha="center", va="center", fontweight="bold",
70+
alpha=min(1.0, (alpha - 0.6) / 0.4),
71+
path_effects=[
72+
pe.withStroke(linewidth=2.5, foreground=BG, alpha=min(1.0, (alpha - 0.6) / 0.4))
73+
],
74+
)
75+
76+
ax.set_xticks([])
77+
ax.set_yticks([])
78+
for spine in ax.spines.values():
79+
spine.set_visible(False)
80+
81+
# Title with fade
82+
title_alpha = min(1.0, reveal_progress * 3) # fades in early
83+
ax.set_title(
84+
"PBMC Immune Cell Atlas · 2,638 cells · 6 cell types",
85+
color=TEXT, fontsize=13, fontweight="bold", pad=15,
86+
alpha=title_alpha,
87+
)
88+
89+
# Subtitle
90+
if reveal_progress > 0.1:
91+
sub_alpha = min(1.0, (reveal_progress - 0.1) * 5)
92+
ax.text(
93+
0.5, -0.02, "scanpy · Leiden clustering · automated marker-based annotation",
94+
transform=ax.transAxes, ha="center", fontsize=9,
95+
color=TEXT, alpha=sub_alpha * 0.6,
96+
)
97+
98+
fig.subplots_adjust(left=0.02, right=0.98, top=0.90, bottom=0.05)
99+
buf = BytesIO()
100+
fig.savefig(buf, format="png", dpi=120, facecolor=BG,
101+
edgecolor="none")
102+
plt.close(fig)
103+
buf.seek(0)
104+
return iio.imread(buf)
105+
106+
107+
def main():
108+
adata = sc.read_h5ad(RESULTS_DIR / "05_annotated.h5ad")
109+
print(f"Loaded {adata.n_obs} cells")
110+
111+
umap_coords = adata.obsm["X_umap"]
112+
cell_types = adata.obs["cell_type"]
113+
categories = cell_types.cat.categories.tolist()
114+
115+
# Sort categories by size (largest first) for dramatic reveal
116+
sizes = cell_types.value_counts()
117+
categories = sizes.index.tolist()
118+
119+
# Precompute axis limits so they're consistent across frames
120+
pad = 1.5
121+
xlim = (umap_coords[:, 0].min() - pad, umap_coords[:, 0].max() + pad)
122+
ylim = (umap_coords[:, 1].min() - pad, umap_coords[:, 1].max() + pad)
123+
124+
# Build frames: reveal phase + hold phase
125+
n_reveal = 80
126+
n_hold = 40
127+
total = n_reveal + n_hold
128+
129+
print(f"Rendering {total} frames...")
130+
frames = []
131+
for i in range(total):
132+
if i < n_reveal:
133+
# Start at 0.05 so first frame already has visible cells
134+
progress = 0.05 + 0.95 * (i / (n_reveal - 1))
135+
else:
136+
progress = 1.0
137+
138+
frame = render_frame(umap_coords, cell_types, categories, progress,
139+
xlim=xlim, ylim=ylim)
140+
frames.append(frame)
141+
if (i + 1) % 30 == 0:
142+
print(f" {i + 1}/{total}")
143+
144+
out_path = Path("docs") / "profile_banner.gif"
145+
out_path.parent.mkdir(exist_ok=True)
146+
print(f"Writing GIF to {out_path}...")
147+
iio.imwrite(out_path, frames, duration=int(1000 / FPS), loop=0)
148+
149+
size_mb = out_path.stat().st_size / 1024 / 1024
150+
print(f"Done! {size_mb:.1f} MB")
151+
152+
153+
if __name__ == "__main__":
154+
main()

0 commit comments

Comments
 (0)