Skip to content

Commit 0a5e567

Browse files
committed
Enrich CVPR presentation types
1 parent 571bc62 commit 0a5e567

4 files changed

Lines changed: 111 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ characteristic terms. Grey points are papers that fit no cluster.
2828
- **Search** matches titles and abstracts and dims every other point, so the shape of the map
2929
stays visible. The query is stored in the URL, so
3030
[`?q=diffusion`](https://flecomet.github.io/cvpr-explorer/?q=diffusion) is a shareable link.
31+
- **Presentation filter** separates the 141 oral, 575 spotlight and 3352 poster papers in the
32+
explorer. The official program calls spotlight papers "Highlights". Every oral paper also has
33+
a poster presentation, but appears as oral in this filter.
3134
- **Click** a point to show the title, authors and abstract in the side panel, with a link to the PDF.
3235
- **Scroll and drag** to zoom and pan.
3336
- **Topic names** are drawn on the map, and more appear as you zoom in.
@@ -68,7 +71,7 @@ The pipeline is offline and the site is static.
6871

6972
| Step | Script | Output |
7073
|------|--------|--------|
71-
| Scrape titles, authors, abstracts, PDF links | `scrape.py` | `data/cvpr_2026_papers.json` |
74+
| Scrape paper metadata and enrich presentation types from the official program | `scrape.py` | `data/cvpr_2026_papers.json` |
7275
| Embed abstracts with SPECTER2 | `embed.py` | `data/cvpr_2026_specter2.npy` |
7376
| UMAP to 2D, HDBSCAN clusters, c-TF-IDF labels, nearest neighbours | `layout.py` | `data/cvpr_2026_layout.json` |
7477
| Merge into the site payload | `build_site.py` | `site/data.json`, `site/details.json`, `site/index.html` |

‎config.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,11 @@
1919
# CVF open-access site, read by scrape.py.
2020
CVF_URL = "https://openaccess.thecvf.com"
2121
LISTING_URL = f"{CVF_URL}/CVPR2026?day=all"
22+
23+
# Official conference program pages, used to enrich presentation types. Papers absent
24+
# from these special-presentation lists use DEFAULT_PRESENTATION.
25+
PRESENTATION_URLS = {
26+
"oral": f"https://cvpr.thecvf.com/virtual/{YEAR}/events/Oral",
27+
"spotlight": f"https://cvpr.thecvf.com/virtual/{YEAR}/events/Highlights{YEAR}",
28+
}
29+
DEFAULT_PRESENTATION = "poster"

‎data/cvpr_2026_papers.json‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎scrape.py‎

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22
33
Writes config.PAPERS_PATH: a list of {id, title, authors, abstract, pdf_link, forum_link,
44
keywords, tldr, area, decision, track, site} in listing order. CVF publishes no keywords,
5-
TL;DR, primary area or presentation type, so those fields stay empty and decision is "other".
5+
TL;DR, primary area or presentation type. Official conference program pages supply oral and
6+
spotlight assignments; every remaining paper uses config.DEFAULT_PRESENTATION.
67
"""
8+
import argparse
79
import json
810
import os
911
import re
1012
import sys
1113
import threading
1214
import time
15+
import unicodedata
1316
from concurrent.futures import ThreadPoolExecutor, as_completed
1417
from urllib.parse import urljoin
1518

@@ -82,6 +85,74 @@ def parse_paper_html(html, url):
8285
return to_record(title, authors, abstract, urljoin(url, pdf_a["href"]))
8386

8487

88+
def normalize_match_text(value):
89+
"""Normalize typography while retaining the words used to identify a paper."""
90+
value = unicodedata.normalize("NFKC", value).casefold()
91+
value = re.sub(r"[‐‑‒–—−]", "-", value)
92+
return " ".join(re.sub(r"[^\w]+", " ", value).split())
93+
94+
95+
def parse_presentation_html(html):
96+
"""Return title and ordered authors from a CVPR program event page."""
97+
soup = BeautifulSoup(html, "html.parser")
98+
entries = []
99+
for card in soup.select(".event-card"):
100+
title = card.select_one("h3.event-title a")
101+
authors = card.select_one(".event-speakers")
102+
if title is not None:
103+
entries.append({
104+
"title": " ".join(title.get_text(" ", strip=True).split()),
105+
"authors": " ".join(authors.get_text(" ", strip=True).split()) if authors else "",
106+
})
107+
return entries
108+
109+
110+
def fetch_presentations():
111+
listings = {}
112+
for presentation, url in config.PRESENTATION_URLS.items():
113+
resp = requests.get(url, timeout=30)
114+
resp.raise_for_status()
115+
listings[presentation] = parse_presentation_html(resp.text)
116+
if not listings[presentation]:
117+
raise ValueError(f"no {presentation} papers found at {url}")
118+
return listings
119+
120+
121+
def enrich_presentations(papers, listings):
122+
"""Assign configured presentation types by title, with ordered authors as fallback."""
123+
by_title = {}
124+
by_authors = {}
125+
for index, paper in enumerate(papers):
126+
by_title.setdefault(normalize_match_text(paper["title"]), []).append(index)
127+
by_authors.setdefault(normalize_match_text(paper["authors"]), []).append(index)
128+
129+
assigned = {}
130+
for presentation, entries in listings.items():
131+
seen = set()
132+
for entry in entries:
133+
matches = by_title.get(normalize_match_text(entry["title"]), [])
134+
if len(matches) != 1 and entry["authors"]:
135+
matches = by_authors.get(normalize_match_text(entry["authors"]), [])
136+
if len(matches) != 1:
137+
raise ValueError(
138+
f"{presentation} paper did not match one record: {entry['title']!r}"
139+
)
140+
index = matches[0]
141+
if index in seen:
142+
raise ValueError(f"duplicate {presentation} paper: {entry['title']!r}")
143+
if index in assigned:
144+
raise ValueError(
145+
f"paper has both {assigned[index]} and {presentation} presentations: "
146+
f"{papers[index]['title']!r}"
147+
)
148+
seen.add(index)
149+
assigned[index] = presentation
150+
151+
for index, paper in enumerate(papers):
152+
paper["decision"] = assigned.get(index, config.DEFAULT_PRESENTATION)
153+
return {presentation: len(entries) for presentation, entries in listings.items()}
154+
155+
85156
def fetch_with_retry(href):
86157
url = config.CVF_URL + href
87158
delay = 1.0
@@ -99,7 +170,7 @@ def fetch_with_retry(href):
99170
return url, {"__error__": str(last_exc)}
100171

101172

102-
def main():
173+
def scrape_all():
103174
print(f"Fetching listing: {config.LISTING_URL}")
104175
hrefs = fetch_listing_hrefs()
105176
n_expected = len(hrefs)
@@ -133,6 +204,31 @@ def main():
133204
# Listing order, not id order: the committed embeddings follow this order.
134205
papers = [results_by_href[href] for href in hrefs]
135206

207+
return papers
208+
209+
210+
def main():
211+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
212+
parser.add_argument(
213+
"--enrich-presentations",
214+
action="store_true",
215+
help="update presentation types in the committed paper data without rescraping papers",
216+
)
217+
args = parser.parse_args()
218+
219+
if args.enrich_presentations:
220+
with open(config.PAPERS_PATH, encoding="utf-8") as f:
221+
papers = json.load(f)
222+
else:
223+
papers = scrape_all()
224+
225+
print("Fetching presentation types from the official conference program.")
226+
counts = enrich_presentations(papers, fetch_presentations())
227+
counts[config.DEFAULT_PRESENTATION] = sum(
228+
paper["decision"] == config.DEFAULT_PRESENTATION for paper in papers
229+
)
230+
print("Presentation types: " + ", ".join(f"{key}={value}" for key, value in counts.items()))
231+
136232
assert len({p["id"] for p in papers}) == len(papers), "duplicate paper ids"
137233
for p in papers:
138234
assert p["title"], f"empty title for paper: {p}"

0 commit comments

Comments
 (0)