Skip to content

Commit aabe82a

Browse files
committed
Add animated 3D UMAP rotation GIF to README
120-frame rotation showing PBMC immune cell clusters in 3D space. Moves the static publication figure into a collapsible details section.
1 parent e7835ac commit aabe82a

3 files changed

Lines changed: 121 additions & 0 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@
22

33
End-to-end single-cell RNA-seq analysis pipeline in Python using [scanpy](https://scanpy.readthedocs.io/). Demonstrates quality control, normalization, dimensionality reduction, clustering with automated resolution selection, and marker-based cell type annotation on human PBMC data.
44

5+
<p align="center">
6+
<img src="docs/umap_3d_rotation.gif" alt="3D UMAP rotation showing PBMC immune cell clusters" width="600">
7+
</p>
8+
9+
<details>
10+
<summary>Publication figure (static)</summary>
11+
512
![Publication Figure](docs/publication_figure.png)
13+
</details>
614

715
## Dataset
816

docs/umap_3d_rotation.gif

1.06 MB
Loading

scripts/make_3d_umap_gif.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Generate an animated 3D UMAP rotation GIF for the README."""
2+
3+
import scanpy as sc
4+
import matplotlib.pyplot as plt
5+
import numpy as np
6+
import imageio.v3 as iio
7+
from pathlib import Path
8+
from io import BytesIO
9+
10+
RESULTS_DIR = Path("results")
11+
DOCS_DIR = Path("docs")
12+
13+
PALETTE = {
14+
"CD4+ T cells": "#E69F00",
15+
"CD8+ T cells": "#56B4E9",
16+
"NK cells": "#009E73",
17+
"B cells": "#F0E442",
18+
"CD14+ Monocytes": "#0072B2",
19+
"FCGR3A+ Monocytes": "#D55E00",
20+
"Dendritic cells": "#CC79A7",
21+
"Megakaryocytes": "#999999",
22+
}
23+
24+
N_FRAMES = 120
25+
FPS = 24
26+
27+
28+
def compute_3d_umap(adata):
29+
"""Compute 3D UMAP embedding."""
30+
sc.tl.umap(adata, n_components=3)
31+
return adata.obsm["X_umap"]
32+
33+
34+
def render_frame(coords, cell_types, azim, elev=25):
35+
"""Render a single frame of the 3D UMAP at a given azimuth angle."""
36+
fig = plt.figure(figsize=(8, 6), facecolor="white")
37+
ax = fig.add_subplot(111, projection="3d", facecolor="white")
38+
39+
for ct in cell_types.cat.categories:
40+
mask = cell_types == ct
41+
ax.scatter(
42+
coords[mask, 0], coords[mask, 1], coords[mask, 2],
43+
c=PALETTE.get(ct, "#AAAAAA"),
44+
s=6, alpha=0.8, label=ct, edgecolors="none",
45+
)
46+
47+
ax.view_init(elev=elev, azim=azim)
48+
ax.set_xticks([])
49+
ax.set_yticks([])
50+
ax.set_zticks([])
51+
ax.xaxis.pane.fill = False
52+
ax.yaxis.pane.fill = False
53+
ax.zaxis.pane.fill = False
54+
ax.xaxis.pane.set_edgecolor("#EEEEEE")
55+
ax.yaxis.pane.set_edgecolor("#EEEEEE")
56+
ax.zaxis.pane.set_edgecolor("#EEEEEE")
57+
ax.xaxis.line.set_color("#CCCCCC")
58+
ax.yaxis.line.set_color("#CCCCCC")
59+
ax.zaxis.line.set_color("#CCCCCC")
60+
ax.grid(True, alpha=0.15)
61+
62+
ax.legend(
63+
loc="upper left", fontsize=7, framealpha=0.7,
64+
facecolor="white", edgecolor="#DDDDDD",
65+
markerscale=3,
66+
)
67+
68+
ax.set_title("3D UMAP — PBMC Immune Cell Profiling", color="#222222",
69+
fontsize=14, fontweight="bold", pad=10)
70+
71+
buf = BytesIO()
72+
fig.savefig(buf, format="png", dpi=100, bbox_inches="tight",
73+
facecolor="white", edgecolor="none")
74+
plt.close(fig)
75+
buf.seek(0)
76+
return iio.imread(buf)
77+
78+
79+
def main():
80+
in_path = RESULTS_DIR / "05_annotated.h5ad"
81+
adata = sc.read_h5ad(in_path)
82+
print(f"Loaded {in_path}")
83+
84+
# Need to recompute neighbor graph since preprocessed data is subset
85+
if "neighbors" not in adata.uns:
86+
sc.pp.neighbors(adata, n_neighbors=15, n_pcs=40)
87+
88+
print("Computing 3D UMAP...")
89+
coords = compute_3d_umap(adata)
90+
cell_types = adata.obs["cell_type"]
91+
92+
print(f"Rendering {N_FRAMES} frames...")
93+
frames = []
94+
for i in range(N_FRAMES):
95+
azim = (i / N_FRAMES) * 360
96+
frame = render_frame(coords, cell_types, azim)
97+
frames.append(frame)
98+
if (i + 1) % 30 == 0:
99+
print(f" {i + 1}/{N_FRAMES} frames")
100+
101+
DOCS_DIR.mkdir(exist_ok=True)
102+
out_path = DOCS_DIR / "umap_3d_rotation.gif"
103+
print(f"Writing GIF to {out_path}...")
104+
iio.imwrite(out_path, frames, duration=int(1000 / FPS), loop=0)
105+
106+
size_mb = out_path.stat().st_size / 1024 / 1024
107+
print(f"Done! GIF size: {size_mb:.1f} MB")
108+
109+
return out_path
110+
111+
112+
if __name__ == "__main__":
113+
main()

0 commit comments

Comments
 (0)