Spaces:
Running on Zero
Running on Zero
Download paper_sections.py from VincentHancoder/RAP: direct link, hf CLI and curl.
- Browser
- Download file 19.6 kB
-
https://huggingface.co/spaces/VincentHancoder/RAP/resolve/main/paper_sections.py
- Command line
-
hf download hf://spaces/VincentHancoder/RAP/paper_sections.py
-
curl -L -o paper_sections.py https://huggingface.co/spaces/VincentHancoder/RAP/resolve/main/paper_sections.py
19.6 kB
| #!/usr/bin/env python3 | |
| """ | |
| Section-aware paper text for the poster SFT input (v2 of paper_side.load_paper_text). | |
| Why this exists. load_paper_text reads the LayoutLMv3 text boxes in page order | |
| and stops at 12,000 characters. Measured on the built dataset that cut lands | |
| inside Related Work / Preliminaries for almost every paper: only 7.5% of inputs | |
| contained any Experiments/Results section and 0.6% a Conclusion, while the | |
| posters the model is trained to emit are mostly Method / Results / Conclusion. | |
| Only 13.6% of poster text blocks shared a 7-gram with the input. | |
| What it does instead -- the way a person reads a paper before making a poster: | |
| 1. read every text box (same LayoutLMv3 classes, same reading order) up to | |
| References / Acknowledgements / Appendix, never a fixed character cut; | |
| 2. split the stream into sections at the `title` boxes, work out each | |
| heading's level (numbering) and *role* (introduction, background, method, | |
| experiments, conclusion, ...); | |
| 3. spend a per-role character budget: Introduction, Method, Experiments and | |
| Conclusion get most of it, Related Work gets none, and inside a section | |
| every subsection keeps its opening paragraphs rather than the first | |
| subsection eating the whole budget; | |
| 4. tell the model which section each figure/table lives in, so a figure in | |
| the Method section reads as the overview figure and one in Experiments as | |
| a result. | |
| The output is still plain text with `## ` headings, now `## [role] heading`, | |
| so the prompt format changes only inside the <paper> and <assets> blocks. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| from pathlib import Path | |
| from typing import Dict, List, Optional, Tuple | |
| from paper_side import TEXT_CLASSES, reading_order | |
| # PyMuPDF returns the PDF's own characters: typographic ligatures (fi fl) and the | |
| # line-break hyphens of justified text ("calibra- tion") come through verbatim. | |
| # Measured on 800 built inputs: 6.5 hyphenation artefacts and ~3 ligatures per | |
| # 1000 words -- text the model would otherwise learn to copy into posters. | |
| _LIGATURES = {'\ufb00': 'ff', '\ufb01': 'fi', '\ufb02': 'fl', '\ufb03': 'ffi', '\ufb04': 'ffl', '\ufb05': 'st', '\ufb06': 'st'} | |
| _PUA = re.compile('[\ue000-\uf8ff]') | |
| _HYPHEN_BREAK = re.compile(r'\b([A-Za-z]+)- ([a-z]+)\b') | |
| def clean_text(t: str) -> str: | |
| for k, v in _LIGATURES.items(): # not NFKC: that would also flatten x^2 and R^d | |
| t = t.replace(k, v) | |
| t = _PUA.sub('', t) # private-use glyphs (broken symbol fonts) | |
| return re.sub(r'\s+', ' ', t).strip() | |
| def dehyphenate(texts: List[str]) -> List[str]: | |
| """Resolve line-break hyphens with the paper's own vocabulary. | |
| "calibra- tion" -> "calibration" (the joined word occurs elsewhere, or neither | |
| form does: a syllable break); "self- supervised" -> "self-supervised" (the | |
| hyphenated form occurs elsewhere in the paper: a real compound). | |
| """ | |
| joined_vocab, hyphen_vocab = set(), set() | |
| for t in texts: | |
| for w in re.findall(r'[A-Za-z]+(?:-[A-Za-z]+)+', t): | |
| hyphen_vocab.add(w.lower()) | |
| for w in re.findall(r'\b[A-Za-z]{4,}\b', t): | |
| joined_vocab.add(w.lower()) | |
| def fix(m): | |
| a, b = m.group(1), m.group(2) | |
| if (a + b).lower() in joined_vocab: | |
| return a + b | |
| if (a + '-' + b).lower() in hyphen_vocab: | |
| return a + '-' + b | |
| return a + b | |
| return [_HYPHEN_BREAK.sub(fix, t) for t in texts] | |
| # --------------------------------------------------------------------------- # | |
| # budgets (characters). ~3.4 chars/token for this kind of English prose. | |
| # --------------------------------------------------------------------------- # | |
| # | |
| # Measured with the Qwen3.8 tokenizer on the test split: this prose runs ~2.5 | |
| # chars/token (math, citations), so 22k chars ≈ 9k text tokens; with ~2.2k | |
| # visual tokens and a ~3.8k-token target the whole sample sits ~11.5k tokens | |
| # median, p90 ≈ 13.5k, inside a 16384 window with a small margin. | |
| ROLE_BUDGET = { | |
| 'abstract': 0, # already carried in the <paper> header | |
| 'introduction': 4000, | |
| 'related': 0, # posters never carry it | |
| 'background': 1500, | |
| 'method': 8000, | |
| 'experiments': 7000, | |
| 'conclusion': 2000, | |
| 'other': 1200, | |
| } | |
| TOTAL_BUDGET = 22000 | |
| SUBSECTION_FLOOR = 500 # every subsection keeps at least its opening | |
| SECTION_FLOOR = 800 | |
| STOP_ROLES = {'references', 'acknowledgements', 'appendix'} | |
| _RULES: List[Tuple[str, str]] = [ | |
| ('references', r'\b(references?|bibliography)\b'), | |
| ('acknowledgements', r'\backnowledg'), | |
| ('appendix', r'\b(appendix|appendices|supplementa)'), | |
| ('conclusion', r'\b(conclusions?|concluding|discussions?|limitations?|future work|' | |
| r'summary|broader impacts?|societal impact|ethic)'), | |
| ('experiments', r'\b(experiments?|experimental|results?|evaluations?|empirical|' | |
| r'benchmarks?|ablations?|case stud|applications?|findings)'), | |
| ('related', r'\b(related|prior|previous|existing) work|literature'), | |
| ('introduction', r'\bintroduction\b|\bmotivation\b'), | |
| ('abstract', r'\babstract\b'), | |
| ('background', r'\b(preliminar|background|notation|problem (setup|formulation|' | |
| r'statement|definition|setting)|setup|setting|definitions?|formulation)'), | |
| ] | |
| _NUM = re.compile(r'^\s*(\d+(?:\.\d+)*)[.\s]') | |
| _ROMAN = re.compile(r'^\s*(?:[IVX]+)[.\s]+[A-Z]') | |
| _LETTER = re.compile(r'^\s*[A-Z][.)]\s+[A-Z]') | |
| def _role_of(text: str) -> Optional[str]: | |
| t = text.lower() | |
| for role, pat in _RULES: | |
| if re.search(pat, t): | |
| return role | |
| return None | |
| def _level_of(text: str, role: Optional[str], have_l1: bool) -> int: | |
| m = _NUM.match(text) | |
| if m: | |
| return min(m.group(1).count('.') + 1, 3) | |
| if _ROMAN.match(text): | |
| return 1 | |
| if _LETTER.match(text): | |
| return 2 | |
| if role in ('abstract', 'introduction', 'related', 'conclusion') or role in STOP_ROLES: | |
| return 1 | |
| return 2 if have_l1 else 1 | |
| def _looks_like_heading(text: str) -> bool: | |
| if len(text) > 140 or len(text) < 3: | |
| return False | |
| if re.match(r'^(theorem|lemma|corollary|proposition|definition|proof|remark|' | |
| r'assumption|example|algorithm|figure|table)\b', text.lower()): | |
| return False | |
| return True | |
| # --------------------------------------------------------------------------- # | |
| # extraction | |
| # --------------------------------------------------------------------------- # | |
| def read_items(pid: str, root: str, pdf_path: str, max_pages: int = 12) -> Tuple[List[Dict], Dict]: | |
| """Text boxes in reading order: [{'kind','text','page','bbox','key'}], plus page info. | |
| `key` is a sortable reading-order position (page, column, y) shared with | |
| assets so a figure can be attributed to the section it sits in. | |
| """ | |
| d = Path(root) / pid | |
| layout_p = d / 'layout.json' | |
| if not layout_p.exists() or not pdf_path or not Path(pdf_path).exists(): | |
| return [], {} | |
| layout = json.loads(layout_p.read_text(encoding='utf-8')) | |
| meta = json.loads((d / 'assets.json').read_text(encoding='utf-8')) | |
| scale = 72.0 / meta.get('dpi', 200) | |
| import fitz | |
| doc = fitz.open(pdf_path) | |
| items: List[Dict] = [] | |
| pages: Dict[int, Dict] = {} | |
| try: | |
| for page_no in range(1, min(max_pages, len(doc)) + 1): | |
| boxes = [b for b in layout if b.get('page') == page_no | |
| and b.get('class_name') in TEXT_CLASSES] | |
| page = doc[page_no - 1] | |
| pw = page.rect.width / scale | |
| ph = page.rect.height / scale | |
| pages[page_no] = {'w': pw, 'h': ph} | |
| if not boxes: | |
| continue | |
| ordered = reading_order(boxes, pw) | |
| body_w = max(b['bbox'][2] - b['bbox'][0] for b in boxes) | |
| for b in ordered: | |
| x1, y1, x2, y2 = (v * scale for v in b['bbox']) | |
| t = page.get_text('text', clip=fitz.Rect(x1 - 1, y1 - 1, x2 + 1, y2 + 1)) | |
| t = clean_text(t) | |
| if len(t) < 3: | |
| continue | |
| items.append({'kind': 'title' if TEXT_CLASSES[b['class_name']] == 'title' else 'text', | |
| 'text': t, 'page': page_no, 'bbox': b['bbox'], | |
| 'key': order_key(b['bbox'], page_no, pw, body_w)}) | |
| finally: | |
| doc.close() | |
| fixed = dehyphenate([it['text'] for it in items]) | |
| for it, t in zip(items, fixed): | |
| it['text'] = t | |
| return items, pages | |
| def order_key(bbox, page_no: int, page_w: float, body_w: float) -> Tuple[int, int, float]: | |
| x1, y1, x2, _ = bbox | |
| spanning = (x2 - x1) > 0.75 * body_w | |
| col = 0 if spanning or (x1 + x2) / 2 < page_w / 2 else 1 | |
| return (page_no, col, float(y1)) | |
| def split_sections(items: List[Dict]) -> List[Dict]: | |
| """-> [{'heading','level','role','page','key','groups':[{'heading','level','paras':[str]}]}] | |
| Level-1 sections carry a role; deeper headings become groups inside the | |
| enclosing level-1 section. Text before the first real heading (paper | |
| title, authors, affiliations, and the abstract when it has no heading box) | |
| is kept in a 'front' section that the budget step drops. | |
| """ | |
| sections: List[Dict] = [] | |
| cur: Optional[Dict] = None | |
| seen_intro = seen_exp = False | |
| front = {'heading': '', 'level': 1, 'role': 'front', 'page': 0, 'key': (0, 0, 0.0), | |
| 'groups': [{'heading': '', 'level': 1, 'paras': []}]} | |
| def new_section(heading, level, role, it): | |
| return {'heading': heading, 'level': level, 'role': role, 'page': it['page'], | |
| 'key': it['key'], 'groups': [{'heading': '', 'level': level, 'paras': []}]} | |
| for it in items: | |
| if it['kind'] == 'title' and _looks_like_heading(it['text']): | |
| role = _role_of(it['text']) | |
| level = _level_of(it['text'], role, cur is not None) | |
| if level == 1: | |
| if role is None: | |
| # positional default: between intro and experiments = method, | |
| # after experiments = still experiments (Analysis, Discussion...) | |
| role = 'experiments' if seen_exp else ('method' if seen_intro else 'other') | |
| if role in STOP_ROLES: | |
| break | |
| if not seen_intro and role not in ('abstract', 'introduction') and not _NUM.match(it['text']): | |
| # paper title, author names, affiliations: front matter, not a section | |
| role = 'front' | |
| seen_intro |= role in ('introduction', 'abstract') | |
| seen_exp |= role == 'experiments' | |
| cur = new_section(it['text'], 1, role, it) | |
| sections.append(cur) | |
| else: | |
| if cur is None: # sub-heading before any section: treat as front text | |
| front['groups'][0]['paras'].append(it['text']) | |
| continue | |
| cur['groups'].append({'heading': it['text'], 'level': level, 'paras': []}) | |
| continue | |
| target = cur if cur is not None else front | |
| target['groups'][-1]['paras'].append(it['text']) | |
| return [front] + sections | |
| # --------------------------------------------------------------------------- # | |
| # budgeting | |
| # --------------------------------------------------------------------------- # | |
| def _cut(text: str, n: int) -> str: | |
| """Cut at a sentence boundary at or before n characters.""" | |
| if len(text) <= n: | |
| return text | |
| head = text[:n] | |
| m = max(head.rfind('. '), head.rfind('? '), head.rfind('! ')) | |
| if m > n * 0.5: | |
| return head[:m + 1] | |
| m = head.rfind(' ') | |
| return (head[:m] if m > 0 else head) + ' …' | |
| def _take_paras(paras: List[str], budget: int) -> Tuple[str, bool]: | |
| out, used, cut = [], 0, False | |
| for p in paras: | |
| if used + len(p) + 2 <= budget: | |
| out.append(p) | |
| used += len(p) + 2 | |
| continue | |
| room = budget - used | |
| if room > 200: | |
| out.append(_cut(p, room)) | |
| cut = True | |
| break | |
| return '\n\n'.join(out), cut | |
| def _spend(groups: List[Dict], budget: int) -> str: | |
| """Distribute a section's budget over its subsection groups, floor first.""" | |
| lens = [sum(len(p) + 2 for p in g['paras']) for g in groups] | |
| total = sum(lens) | |
| if total == 0: | |
| return '' | |
| if total <= budget: | |
| alloc = lens | |
| else: | |
| n = len(groups) | |
| floor = min(SUBSECTION_FLOOR, budget // max(n, 1)) | |
| alloc = [min(l, floor) for l in lens] | |
| rest = budget - sum(alloc) | |
| extra_need = [l - a for l, a in zip(lens, alloc)] | |
| need_total = sum(extra_need) | |
| if rest > 0 and need_total > 0: | |
| alloc = [a + int(rest * e / need_total) for a, e in zip(alloc, extra_need)] | |
| parts = [] | |
| for g, b in zip(groups, alloc): | |
| mark = '#' * max(g['level'] + 1, 3) # section = ##, subsections = ###, #### | |
| if not g['paras']: | |
| if g['heading']: | |
| parts.append(mark + ' ' + g['heading']) | |
| continue | |
| body, cut = _take_paras(g['paras'], b) | |
| if not body: | |
| continue | |
| head = (mark + ' ' + g['heading'] + '\n\n') if g['heading'] else '' | |
| parts.append(head + body + (' […]' if cut else '')) | |
| return '\n\n'.join(parts) | |
| def budget_body(sections: List[Dict], total: int = TOTAL_BUDGET, | |
| role_budget: Dict[str, int] = ROLE_BUDGET, | |
| abstract_missing: bool = False) -> Tuple[str, Dict]: | |
| """Render the sections into the body text under the per-role budgets.""" | |
| if total < TOTAL_BUDGET: | |
| # long poster / many figures: shrink every role's share proportionally | |
| # so the section mix stays the same and only the depth per section drops | |
| f = total / TOTAL_BUDGET | |
| role_budget = {k: int(v * f) for k, v in role_budget.items()} | |
| keep = [s for s in sections if s['role'] not in ('front', 'related', 'abstract')] | |
| if abstract_missing: | |
| for s in sections: | |
| if s['role'] in ('abstract', 'front'): | |
| keep.insert(0, dict(s, role='abstract')) | |
| break | |
| lens = {id(s): sum(len(p) + 2 for g in s['groups'] for p in g['paras']) for s in keep} | |
| by_role: Dict[str, List[Dict]] = {} | |
| for s in keep: | |
| by_role.setdefault(s['role'], []).append(s) | |
| alloc: Dict[int, int] = {} | |
| for role, secs in by_role.items(): | |
| b = role_budget.get(role, role_budget['other']) | |
| if role == 'abstract': | |
| b = 1800 | |
| need = [lens[id(s)] for s in secs] | |
| if sum(need) <= b: | |
| for s, n in zip(secs, need): | |
| alloc[id(s)] = n | |
| else: | |
| floor = min(SECTION_FLOOR, b // max(len(secs), 1)) | |
| a = [min(n, floor) for n in need] | |
| rest = b - sum(a) | |
| extra = [n - x for n, x in zip(need, a)] | |
| et = sum(extra) | |
| for i, s in enumerate(secs): | |
| alloc[id(s)] = a[i] + (int(rest * extra[i] / et) if et > 0 and rest > 0 else 0) | |
| # leftover (no conclusion, short intro...) goes to experiments, then method | |
| used = sum(alloc.values()) | |
| for role in ('experiments', 'method', 'introduction'): | |
| rest = total - used | |
| if rest <= 0: | |
| break | |
| for s in by_role.get(role, []): | |
| room = lens[id(s)] - alloc[id(s)] | |
| if room > 0: | |
| add = min(room, rest) | |
| alloc[id(s)] += add | |
| used += add | |
| rest -= add | |
| # hard cap | |
| if used > total: | |
| scale = total / used | |
| alloc = {k: int(v * scale) for k, v in alloc.items()} | |
| parts, stats = [], {} | |
| for s in keep: | |
| text = _spend(s['groups'], alloc[id(s)]) | |
| if not text.strip(): | |
| continue | |
| parts.append(f"## [{s['role']}] {s['heading']}".rstrip() + '\n\n' + text) | |
| stats[s['role']] = stats.get(s['role'], 0) + len(text) | |
| body = '\n\n'.join(parts) | |
| stats['total'] = len(body) | |
| stats['roles'] = sorted(by_role) | |
| return body, stats | |
| def load_paper_body(pid: str, root: str, pdf_path: str, abstract_missing: bool = False, | |
| max_pages: int = 12, total: int = TOTAL_BUDGET) -> Tuple[str, Dict, List[Dict]]: | |
| items, _ = read_items(pid, root, pdf_path, max_pages) | |
| if not items: | |
| return '', {'total': 0, 'roles': []}, [] | |
| sections = split_sections(items) | |
| body, stats = budget_body(sections, total=min(total, TOTAL_BUDGET), abstract_missing=abstract_missing) | |
| stats['budget'] = min(total, TOTAL_BUDGET) | |
| return body, stats, sections | |
| # --------------------------------------------------------------------------- # | |
| # assets: section attribution + caption fallback | |
| # --------------------------------------------------------------------------- # | |
| def section_of_asset(sections: List[Dict], page: int, bbox, page_w: float, body_w: float) -> Optional[Dict]: | |
| key = order_key(bbox, page, page_w, body_w) | |
| best = None | |
| for s in sections: | |
| if s['role'] == 'front': | |
| continue | |
| if s['key'] <= key: | |
| best = s | |
| return best | |
| _CAP_LINE = re.compile(r'^\s*(Figure|Fig\.|Table)\s*(\d+|[IVX]+)\s*[:.]', re.I) | |
| def section_by_reference(sections: List[Dict], caption: str, kind: str) -> Optional[Dict]: | |
| """The section whose text first cites this figure/table ("Figure 3", "Tab. 1"). | |
| Figures sit where the page layout allows (often at the top of the next | |
| column, under the next heading); they are *discussed* in the section that | |
| cites them, and that is the section a poster would place them in. | |
| """ | |
| m = _CAP_LINE.match(caption or '') | |
| if not m or kind not in ('figure', 'table'): | |
| return None | |
| num = m.group(2) | |
| if kind == 'table': | |
| pat = re.compile(r'\b(Table|Tab\.)\s*' + re.escape(num) + r'(?!\d)') | |
| else: | |
| pat = re.compile(r'\b(Figure|Fig\.|Figs?\.)\s*' + re.escape(num) + r'(?!\d)') | |
| for s in sections: | |
| if s['role'] in ('front', 'related'): | |
| continue | |
| for g in s['groups']: | |
| for p in g['paras']: | |
| if pat.search(p): | |
| return s | |
| return None | |
| def caption_fallback(doc, page_no: int, bbox, kind: str, scale: float, max_chars: int = 300) -> str: | |
| """When no LayoutLMv3 caption box matched: the nearest 'Figure N:'/'Table N:' | |
| text block on the page, below the asset (tables: above or below).""" | |
| if page_no > len(doc): | |
| return '' | |
| page = doc[page_no - 1] | |
| ph = page.rect.height / scale | |
| ax1, ay1, ax2, ay2 = bbox | |
| want = 'table' if kind == 'table' else 'figure' | |
| best, best_gap = '', 1e9 | |
| for blk in page.get_text('dict').get('blocks', []): | |
| lines = blk.get('lines') or [] | |
| if not lines: | |
| continue | |
| text = ' '.join(s['text'] for l in lines for s in l['spans']) | |
| text = re.sub(r'\s+', ' ', text).strip() | |
| m = _CAP_LINE.match(text) | |
| if not m: | |
| continue | |
| if (m.group(1).lower().startswith('tab')) != (want == 'table'): | |
| continue | |
| bx1, by1, bx2, by2 = (v / scale for v in blk['bbox']) | |
| lo, hi = max(ax1, bx1), min(ax2, bx2) | |
| if hi <= lo: | |
| continue | |
| gap = by1 - ay2 if by1 >= ay2 else ay1 - by2 | |
| if gap < -5 or gap > 0.2 * ph: | |
| continue | |
| if gap < best_gap: | |
| best, best_gap = text, gap | |
| if len(best) > max_chars: | |
| best = best[:max_chars].rsplit(' ', 1)[0] + '…' | |
| return best | |