Skip to content

Commit 686e402

Browse files
coolteemfclaude
andcommitted
Add static site and GitHub Pages deployment
The Dash app needs a Python server, which rules out free static hosting. Add a static front-end that renders the same map client-side: - build_site.py merges papers + layout into a single column-oriented site/data.json (7 MB, generated, gitignored) - site/index.html draws it with plotly.js from CDN, plus keyword search over titles and abstracts with a shareable ?q= URL param - .github/workflows/pages.yml rebuilds and publishes site/ on push to main app.py is kept for local use. README rewritten with fork attribution. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3bdb3e8 commit 686e402

5 files changed

Lines changed: 333 additions & 31 deletions

File tree

‎.github/workflows/pages.yml‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Deploy site to GitHub Pages
2+
3+
on:
4+
push:
5+
branches: [main]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
pages: write
11+
id-token: write
12+
13+
concurrency:
14+
group: pages
15+
cancel-in-progress: true
16+
17+
jobs:
18+
deploy:
19+
runs-on: ubuntu-latest
20+
environment:
21+
name: github-pages
22+
url: ${{ steps.deployment.outputs.page_url }}
23+
steps:
24+
- uses: actions/checkout@v4
25+
- name: Build site payload
26+
run: python build_site.py
27+
- uses: actions/configure-pages@v5
28+
- uses: actions/upload-pages-artifact@v3
29+
with:
30+
path: site
31+
- id: deployment
32+
uses: actions/deploy-pages@v4

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
.venv/
2+
site/data.json

‎README.md‎

Lines changed: 63 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,79 @@
1-
# cvpr-explorer
2-
Visualize CVPR papers as embeddings on [cvprexplorer.com](http://cvprexplorer.com)
1+
# CVPR 2026 Explorer
32

4-
# Problem Statement
5-
As the field of AI and machine learning has exploded in recent years, it has become increasingly difficult to keep up to date with latest research. Conference websites frequently list >2000 papers in one giant list out of which it is not easy to find papers one is interested in.
3+
A 2D semantic map of all 4068 CVPR 2026 papers, built from
4+
[SPECTER2](https://huggingface.co/allenai/specter2) embeddings of their abstracts.
5+
Nearby points are semantically similar papers, so a cluster is a topic.
66

7-
# This Project
8-
The current project is a visual aid for browsing CVPR papers. I am starting with CVPR because its a popular conference and has extensive public records. I scraped the website and found the paper titles and abstracts, then used OpenAI embedding API to find vector embeddings of the abstracts. Finally, I visualized the embeddings in 2D with t-SNE plot. Each point in the plot is a paper.
7+
**Live site: https://flecomet.github.io/cvpr-explorer/**
98

10-
The results are amazing. The embeddings reveal clusters of popular topics like transformers, NeRF, diffusion models etc in recent years. So, if you are interested in only diffusion models, for example, you do not have to wade through hundreds of irrelevant papers and only need to look at points (papers) in the corresponding cluster.
9+
> This is a fork of [dataplayer12/cvpr-explorer](https://github.com/dataplayer12/cvpr-explorer)
10+
> ([cvprexplorer.com](http://cvprexplorer.com)), rewritten for CVPR 2026: OpenAI embeddings
11+
> replaced with the open SPECTER2 scientific-document model, automatic cluster labels, and a
12+
> static front-end that needs no server. Original idea and design credit to
13+
> [@dataplayer12](https://github.com/dataplayer12). Same license as upstream — see [LICENSE](LICENSE).
1114
12-
I hope this tool will be useful for stuents, researchers and ML engineers. I invite the community to biuld on this project and add more conferences. I hope that some day this will become the standard way of finding new papers and that unorganized long lists of >2000 papers will be a thing of the past.
15+
## Why
1316

14-
# Demo
15-
You can view the app at [cvprexplorer.com](http://cvprexplorer.com)
17+
Conference sites list 4000+ papers as one flat list. Finding the ones you care about means
18+
scrolling past thousands you don't. Here you zoom into the region of the map you're interested
19+
in — diffusion, 3D reconstruction, medical imaging — and read only those.
1620

17-
# Results
18-
![embeddings](https://github.com/dataplayer12/cvpr-explorer/assets/11517109/d8913d58-26f2-43a2-b0f8-de5c526f0ab6)
21+
## How it works
1922

23+
The pipeline is offline; the site is static.
2024

21-
## Deploy using docker
22-
```Shell
23-
git clone http://github.com/dataplayer12/cvpr-explorer.git
24-
cd cvpr-explorer/
25-
docker build -t cvpr-explorer .
26-
docker run --network=host cvpr-explorer
25+
| Step | Script | Output |
26+
|------|--------|--------|
27+
| Scrape titles, authors, abstracts, PDF links | `scrape.py` | `data/cvpr_2026_papers.json` |
28+
| Embed abstracts with SPECTER2 | `embed.py` | `data/cvpr_2026_specter2.npy` |
29+
| UMAP to 2D + HDBSCAN clusters + c-TF-IDF labels | `layout.py` | `data/cvpr_2026_layout.json` |
30+
| Merge into the site payload | `build_site.py` | `site/data.json` |
31+
32+
`site/index.html` renders that payload client-side with plotly.js. No backend, no API keys at
33+
serve time.
34+
35+
## Run the pipeline
36+
37+
```shell
38+
pip install -r requirements-pipeline.txt
39+
python scrape.py # writes data/cvpr_2026_papers.json
40+
python embed.py # writes data/cvpr_2026_specter2.npy (GPU recommended)
41+
python layout.py # writes data/cvpr_2026_layout.json
42+
```
43+
44+
## Serve locally
45+
46+
Static site (what the live URL serves):
47+
48+
```shell
49+
python build_site.py
50+
python -m http.server -d site 8000 # http://localhost:8000
2751
```
2852

29-
## Deploy with docker compose
30-
```Shell
53+
Or the original Dash app:
54+
55+
```shell
56+
pip install -r requirements.txt
57+
python app.py # http://localhost:8050
58+
```
59+
60+
Or with Docker:
61+
62+
```shell
3163
docker compose build
3264
docker compose up
3365
```
3466

67+
## Deployment
68+
69+
Pushing to `main` runs `.github/workflows/pages.yml`, which rebuilds `site/data.json` and
70+
publishes `site/` to GitHub Pages.
71+
3572
## Roadmap
3673

37-
- [x] Show paper title, abstract and PDF link
38-
- [x] Allow figure to stretch with screen size
39-
- [x] Cache embeddings to disk, read from disk
40-
- [x] Add links to the GitHub repo and my LinkedIn
41-
- [ ] Mark papers with code, add 'see code' button
42-
- [ ] Allow searching by keyword
43-
- [x] Use OpenAI embeddings
44-
- [x] Add more years, allow selecting year
45-
- [x] Add ECCV
46-
- [ ] Add ICCV
47-
- [ ] Docker image that builds on both arm and x86
74+
- [x] SPECTER2 embeddings (open model, no API key)
75+
- [x] 2D projection with clusters and automatic topic labels
76+
- [x] Static site — no server, no cold start
77+
- [x] Keyword search across titles and abstracts
78+
- [ ] Mark papers with released code
79+
- [ ] Add more conferences and years back

‎build_site.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Build the static site payload served by GitHub Pages.
2+
3+
Merges data/cvpr_2026_papers.json and data/cvpr_2026_layout.json into a single
4+
column-oriented site/data.json, which site/index.html renders client-side with
5+
plotly.js. Run after the offline pipeline (scrape.py, embed.py, layout.py).
6+
"""
7+
import json
8+
import os
9+
10+
PAPERS_PATH = "data/cvpr_2026_papers.json"
11+
LAYOUT_PATH = "data/cvpr_2026_layout.json"
12+
OUT_PATH = "site/data.json"
13+
14+
15+
def main():
16+
with open(PAPERS_PATH) as f:
17+
papers = json.load(f)
18+
with open(LAYOUT_PATH) as f:
19+
layout_data = json.load(f)
20+
21+
points = layout_data["points"]
22+
assert len(points) == len(papers), (
23+
f"point count {len(points)} != paper count {len(papers)}"
24+
)
25+
26+
payload = {
27+
"clusters": layout_data["clusters"],
28+
# Column-oriented: ~15% smaller than a list of per-paper objects.
29+
"x": [round(p["x"], 3) for p in points],
30+
"y": [round(p["y"], 3) for p in points],
31+
"c": [p["cluster"] for p in points],
32+
"title": [p["title"] for p in papers],
33+
"authors": [p["authors"] for p in papers],
34+
"abstract": [p["abstract"] for p in papers],
35+
"pdf": [p["pdf_link"] for p in papers],
36+
}
37+
38+
os.makedirs("site", exist_ok=True)
39+
with open(OUT_PATH, "w") as f:
40+
json.dump(payload, f, separators=(",", ":"), ensure_ascii=False)
41+
42+
mb = os.path.getsize(OUT_PATH) / 1e6
43+
print(f"wrote {OUT_PATH}: {len(papers)} papers, {mb:.1f} MB")
44+
45+
46+
if __name__ == "__main__":
47+
main()

‎site/index.html‎

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>CVPR 2026 Explorer — SPECTER2 Semantic Map</title>
7+
<meta name="description" content="A 2D semantic map of all CVPR 2026 papers, built from SPECTER2 embeddings.">
8+
<script src="https://cdn.plot.ly/plotly-gl2d-2.35.2.min.js" charset="utf-8"></script>
9+
<style>
10+
:root { color-scheme: dark; }
11+
* { box-sizing: border-box; }
12+
body {
13+
margin: 0; background: #111; color: #eee;
14+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
15+
}
16+
header {
17+
display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
18+
padding: 12px 16px; border-bottom: 1px solid #2a2a2a;
19+
}
20+
h1 { font-size: 18px; margin: 0; font-weight: 600; }
21+
h1 small { color: #888; font-weight: 400; }
22+
#search {
23+
flex: 1 1 220px; max-width: 380px; padding: 7px 10px;
24+
background: #1c1c1c; color: #eee; border: 1px solid #3a3a3a; border-radius: 6px;
25+
font-size: 14px;
26+
}
27+
#search:focus { outline: none; border-color: #6ea8fe; }
28+
#hits { color: #888; font-size: 13px; min-width: 90px; }
29+
header a { color: #9ab; font-size: 13px; text-decoration: none; }
30+
header a:hover { text-decoration: underline; }
31+
main { display: flex; flex-wrap: wrap; }
32+
#plot { flex: 1 1 620px; height: calc(100vh - 130px); min-height: 420px; }
33+
#panel {
34+
flex: 0 1 380px; max-height: calc(100vh - 130px); overflow-y: auto;
35+
padding: 16px; border-left: 1px solid #2a2a2a; font-size: 14px; line-height: 1.5;
36+
}
37+
#panel h2 { font-size: 16px; margin: 0 0 8px; }
38+
#panel .authors { color: #9a9a9a; font-size: 13px; margin-bottom: 12px; }
39+
#panel .abstract { color: #ccc; }
40+
#panel a.pdf {
41+
display: inline-block; margin-bottom: 12px; padding: 6px 12px;
42+
background: #2b6cb0; color: #fff; border-radius: 6px; text-decoration: none; font-size: 13px;
43+
}
44+
#panel .hint { color: #777; }
45+
footer { padding: 10px 16px; color: #777; font-size: 12px; border-top: 1px solid #2a2a2a; }
46+
footer a { color: #9ab; }
47+
@media (max-width: 800px) {
48+
#plot { height: 60vh; }
49+
#panel { border-left: none; border-top: 1px solid #2a2a2a; max-height: none; }
50+
}
51+
</style>
52+
</head>
53+
<body>
54+
<header>
55+
<h1>CVPR 2026 Explorer <small>— SPECTER2 semantic map</small></h1>
56+
<input id="search" type="search" placeholder="Search titles &amp; abstracts…" autocomplete="off" disabled>
57+
<span id="hits"></span>
58+
<a href="https://github.com/flecomet/cvpr-explorer" target="_blank" rel="noopener">GitHub</a>
59+
</header>
60+
<main>
61+
<div id="plot"></div>
62+
<aside id="panel"><p class="hint">Loading 4000+ papers…</p></aside>
63+
</main>
64+
<footer>
65+
Fork of <a href="https://github.com/dataplayer12/cvpr-explorer" target="_blank" rel="noopener">dataplayer12/cvpr-explorer</a>.
66+
Nearby points are semantically similar papers. Click a point for details.
67+
</footer>
68+
69+
<script>
70+
const panel = document.getElementById("panel");
71+
const search = document.getElementById("search");
72+
const hits = document.getElementById("hits");
73+
let D = null, traces = null, haystack = null;
74+
75+
// Golden-angle hue rotation: distinct colors for an arbitrary cluster count.
76+
function clusterColor(i) {
77+
return "hsl(" + ((i * 137.508) % 360).toFixed(1) + ",65%,60%)";
78+
}
79+
80+
function buildTraces(data) {
81+
const byCluster = new Map();
82+
data.c.forEach((cid, i) => {
83+
if (!byCluster.has(cid)) byCluster.set(cid, []);
84+
byCluster.get(cid).push(i);
85+
});
86+
// -1 ("unclustered") first so it renders underneath everything else.
87+
const ids = [...byCluster.keys()].sort((a, b) => (a === -1 ? -1 : b === -1 ? 1 : a - b));
88+
return ids.map((cid, n) => ({
89+
type: "scattergl",
90+
mode: "markers",
91+
// Labels are "term · term · term"; the first two keep the legend narrow.
92+
name: data.clusters[String(cid)].split(" · ").slice(0, 2).join(" · "),
93+
x: byCluster.get(cid).map(i => data.x[i]),
94+
y: byCluster.get(cid).map(i => data.y[i]),
95+
text: byCluster.get(cid).map(i => data.title[i]),
96+
customdata: byCluster.get(cid),
97+
marker: { size: 6, color: cid === -1 ? "#666" : clusterColor(n) },
98+
hovertemplate: "<b>%{text}</b><extra></extra>",
99+
}));
100+
}
101+
102+
const LAYOUT = {
103+
margin: { l: 40, r: 20, b: 40, t: 20 },
104+
paper_bgcolor: "#111", plot_bgcolor: "#111",
105+
font: { color: "#eee" },
106+
xaxis: { showgrid: false, zeroline: false, showticklabels: false },
107+
// scaleanchor keeps the map's true aspect ratio: distance means the same on both axes.
108+
yaxis: { showgrid: false, zeroline: false, showticklabels: false, scaleanchor: "x" },
109+
legend: { font: { size: 10 }, itemsizing: "constant", itemwidth: 30 },
110+
dragmode: "pan",
111+
hovermode: "closest",
112+
};
113+
114+
function showPaper(i) {
115+
panel.innerHTML = "";
116+
const h2 = document.createElement("h2");
117+
h2.textContent = D.title[i];
118+
const au = document.createElement("p");
119+
au.className = "authors";
120+
au.textContent = D.authors[i];
121+
const a = document.createElement("a");
122+
a.className = "pdf";
123+
a.href = D.pdf[i];
124+
a.target = "_blank";
125+
a.rel = "noopener";
126+
a.textContent = "Open PDF";
127+
const ab = document.createElement("p");
128+
ab.className = "abstract";
129+
ab.textContent = D.abstract[i];
130+
panel.append(h2, au, a, ab);
131+
panel.scrollTop = 0;
132+
}
133+
134+
// Search dims non-matching points instead of hiding them, so the map keeps its shape.
135+
function applySearch(q) {
136+
q = q.trim().toLowerCase();
137+
if (!q) {
138+
Plotly.restyle("plot", { "marker.opacity": 1, selectedpoints: [null] });
139+
hits.textContent = "";
140+
return;
141+
}
142+
let n = 0;
143+
const selected = traces.map(t => {
144+
const sel = [];
145+
t.customdata.forEach((i, k) => { if (haystack[i].includes(q)) { sel.push(k); n++; } });
146+
return sel;
147+
});
148+
Plotly.restyle("plot", { selectedpoints: selected, "marker.opacity": 1 });
149+
Plotly.restyle("plot", {
150+
"selected.marker.opacity": 1,
151+
"unselected.marker.opacity": 0.06,
152+
});
153+
hits.textContent = n + " match" + (n === 1 ? "" : "es");
154+
}
155+
156+
fetch("data.json").then(r => r.json()).then(data => {
157+
D = data;
158+
traces = buildTraces(data);
159+
haystack = data.title.map((t, i) => (t + " " + data.abstract[i]).toLowerCase());
160+
Plotly.newPlot("plot", traces, LAYOUT, {
161+
responsive: true, displaylogo: false, scrollZoom: true,
162+
modeBarButtonsToRemove: ["select2d", "lasso2d", "toggleSpikelines"],
163+
});
164+
document.getElementById("plot").on("plotly_click", e => showPaper(e.points[0].customdata));
165+
panel.innerHTML = '<p class="hint">A 2D semantic map of all ' + data.x.length +
166+
" CVPR 2026 papers, built from SPECTER2 embeddings.<br><br>" +
167+
"Click any point to read its title, authors and abstract, and to open the PDF. " +
168+
"Scroll to zoom, drag to pan.</p>";
169+
search.disabled = false;
170+
let timer;
171+
search.addEventListener("input", () => {
172+
clearTimeout(timer);
173+
timer = setTimeout(() => {
174+
applySearch(search.value);
175+
// Keep the URL shareable without adding history entries per keystroke.
176+
const url = new URL(location);
177+
if (search.value.trim()) url.searchParams.set("q", search.value);
178+
else url.searchParams.delete("q");
179+
history.replaceState(null, "", url);
180+
}, 150);
181+
});
182+
183+
const q = new URLSearchParams(location.search).get("q");
184+
if (q) { search.value = q; applySearch(q); }
185+
}).catch(err => {
186+
panel.innerHTML = '<p class="hint">Failed to load data.json: ' + err + "</p>";
187+
});
188+
</script>
189+
</body>
190+
</html>

0 commit comments

Comments
 (0)