File size: 18,493 Bytes
10726af
c58c39d
 
 
 
 
 
 
 
10726af
c58c39d
 
 
10726af
 
 
4b73edf
 
 
 
c58c39d
10726af
02e9784
10726af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b03fbd
 
 
10726af
 
 
 
 
 
9b03fbd
 
 
 
 
10726af
 
 
 
c58c39d
10726af
9b03fbd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10726af
9b03fbd
 
 
 
 
 
10726af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4b73edf
 
 
 
c58c39d
4b73edf
10726af
 
 
c58c39d
10726af
 
 
 
 
 
 
 
c58c39d
4b73edf
10726af
 
 
 
 
 
 
 
 
 
 
c58c39d
 
033094c
 
 
9b03fbd
c58c39d
033094c
 
10726af
 
033094c
10726af
 
 
 
033094c
9b03fbd
 
 
033094c
 
 
 
 
 
 
 
 
 
 
 
 
4b73edf
 
 
 
 
 
 
 
 
 
 
 
 
033094c
4b73edf
033094c
 
 
 
 
 
 
 
 
 
 
 
10726af
033094c
10726af
 
033094c
10726af
 
 
 
 
9b03fbd
 
10726af
 
 
 
 
 
 
 
02e9784
 
 
 
 
 
10726af
 
 
 
c58c39d
9b03fbd
10726af
 
 
9b03fbd
10726af
 
 
 
c58c39d
10726af
 
38c5f38
10726af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
033094c
 
 
 
 
 
 
 
 
10726af
9b03fbd
10726af
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
"""RAP demo UI: PDF -> assets -> model HTML -> rendered poster.  Pipeline unchanged; UI redesigned 2026-10-15."""
import json
import re
import tempfile
import time
from pathlib import Path
import gradio as gr
import torch
import spaces
from pdf_pipeline import prepare, chat, CANVASES, DEFAULT_INSTRUCTION
from paper_sections import budget_body
from poster_render import render

ROOT = Path(__file__).resolve().parent
ARTIFACTS = Path(tempfile.mkdtemp(prefix='rap_demo_'))
WINDOW = 16384
MAX_STEPS = 10   # chained ZeroGPU steps per run
GPU_LADDER = (600, 300, 120, 60)   # requested step length; ZeroGPU refuses what the visitor's quota cannot cover
QUOTA_HINT = (' — this 27B BF16 model needs the full 96 GB ZeroGPU (2x quota) and a poster takes roughly 15–25 min of quota. '
              'Daily ZeroGPU quota: PRO 40 min, free account 5 min, anonymous 2 min, so sign in with a PRO account to finish a poster.')

CSS = '''
#hero .links { margin: 14px 0 0; font-size: 14px; opacity: .92 } #hero .links a { color: #fff; text-decoration: underline; text-underline-offset: 3px }
.gradio-container {max-width: 1360px !important; margin: 0 auto;}
#hero {padding: 26px 30px 22px; border-radius: 16px; margin-bottom: 6px;
       background: linear-gradient(120deg, #1e1b4b 0%, #4c3d99 60%, #6d5bd0 100%); color: #fff;}
#hero h1 {margin: 0 0 6px; font-size: 30px; letter-spacing: .2px; color: #fff;}
#hero p {margin: 0; color: #e6e2ff; font-size: 15px;}
#hero a {color: #fff; text-decoration: underline;}
.badge {display: inline-block; margin: 10px 8px 0 0; padding: 3px 10px; border-radius: 999px;
        background: rgba(255,255,255,.14); font-size: 12px; color: #fff;}
#status {min-height: 24px; font-size: 14px;}
#poster img {border-radius: 10px; box-shadow: 0 10px 30px rgba(20,16,60,.12);}
#examples .thumbnail-item {border-radius: 10px;}
footer {display: none !important;}
'''

def load_examples():
    """examples/<name>/: meta.json, paper.pdf, poster.png, poster.html, prompt.json (the exact benchmark prompt),
    thumbnails/ (what the model sees) and assets/ (full-resolution crops for rendering)."""
    import hashlib
    out = []
    for d in sorted((ROOT / 'examples').glob('*/')):
        meta = d / 'meta.json'
        if not meta.exists() or not (d / 'poster.png').exists() or not (d / 'paper.pdf').exists():
            continue
        m = json.loads(meta.read_text(encoding='utf-8'))
        out.append({'dir': d, 'title': m.get('title', d.name), 'short': m.get('short', m.get('title', d.name))[:40], 'venue': m.get('venue', ''),
                    'paper_id': m.get('paper_id', d.name), 'instruction': m.get('instruction', DEFAULT_INSTRUCTION), 'spec': m.get('spec', ''),
                    'canvas': m.get('canvas', 'Landscape · 3:2'), 'columns': m.get('columns', 3), 'accent': m.get('accent', '#584287'),
                    'prompt': d / 'prompt.json' if (d / 'prompt.json').exists() else None,
                    'pdf_sha': hashlib.sha256((d / 'paper.pdf').read_bytes()).hexdigest()})
    return out

def fileout(path):
    return gr.update(value=str(path), visible=True) if path else gr.update(value=None, visible=False)

def build(model, processor):
    def match_example(pdf, instruction, custom_spec):
        """The exact benchmark prompt is used when the PDF is one of the examples and instruction + spec are unchanged."""
        import hashlib
        try:
            sha = hashlib.sha256(Path(pdf).read_bytes()).hexdigest()
        except OSError:
            return None
        for ex in examples:
            if ex['prompt'] and ex['pdf_sha'] == sha and instruction.strip() == ex['instruction'].strip() and custom_spec.strip() == ex['spec'].strip():
                return ex
        return None

    def analyze_example(ex):
        """Reproduce a benchmark case: the stored prompt (paper text, asset list, thumbnails, instruction, layout spec)."""
        d = ex['dir']; pr = json.loads((d / 'prompt.json').read_text(encoding='utf-8'))
        system, user = pr['messages'][0]['content'], pr['messages'][1]['content']
        parts = user.split('<image>'); images = [str(d / im) for im in pr['images']]
        content = []
        for k, text in enumerate(parts):
            if text: content.append({'type': 'text', 'text': text})
            if k < len(images): content.append({'type': 'image', 'image': images[k]})
        messages = [{'role': 'system', 'content': [{'type': 'text', 'text': system}]}, {'role': 'user', 'content': content}]
        assets = []
        for im in pr['images']:
            aid = Path(im).stem; f = d / 'assets' / f'{aid}.png'
            kind = aid.rsplit('_', 1)[0]
            assets.append({'id': aid, 'kind': kind, 'file': str(f if f.exists() else d / im), 'thumb': str(d / im), 'page': None})
        w, h = (int(v) for v in re.match(r'(\d+)x(\d+)', ex['canvas']).groups()) if re.match(r'\d+x\d+', ex['canvas']) else CANVASES.get(ex['canvas'], (2400, 1600))
        job = Path(tempfile.mkdtemp(prefix='example_', dir=ARTIFACTS))
        return {'title': ex['title'], 'authors': '', 'assets': assets, 'instruction': ex['instruction'], 'spec': ex['spec'], 'canvas': [w, h],
                'messages_pre': messages, 'job': str(job), 'example': ex['paper_id'], 'sections': [], 'body': ''}

    def analyze(pdf, instruction, canvas, accent, columns, custom_spec):
        ex = match_example(pdf, instruction, custom_spec)
        if ex is not None:
            p = analyze_example(ex)
            inputs = processor.apply_chat_template(chat(p), tokenize=True, add_generation_prompt=True, enable_thinking=False, return_dict=True, return_tensors='pt')
            p['input_tokens'] = int(inputs['input_ids'].shape[1]); p['continuation_ids'] = []; p['raw'] = ''; p['done'] = False
            return p
        job = Path(tempfile.mkdtemp(prefix='paper_', dir=ARTIFACTS))
        p = prepare(pdf, job, instruction, canvas, accent, columns, custom_spec)
        budget = 22000
        while True:
            inputs = processor.apply_chat_template(chat(p), tokenize=True, add_generation_prompt=True,
                                                   enable_thinking=False, return_dict=True, return_tensors='pt')
            n = inputs['input_ids'].shape[1]
            if n <= 10000:
                break
            if budget <= 3500:
                raise ValueError('The paper and its figures exceed the demo input budget.')
            budget = max(3500, int(budget * .8))
            p['body'], _ = budget_body(p['sections'], total=budget, abstract_missing=True)
        p['input_tokens'] = n; p['continuation_ids'] = []; p['raw'] = ''; p['done'] = False
        return p

    def gpu_seconds(p):
        return int(p.get('gpu_seconds', 120))

    @spaces.GPU(size='xlarge', duration=gpu_seconds)   # 27B BF16 needs the full 96 GB GPU (2x quota)
    def gpu_chunk(p):
        budget = max(30, gpu_seconds(p) - 90)   # leave room for moving the weights to the GPU and the prefill
        inputs = processor.apply_chat_template(chat(p), tokenize=True, add_generation_prompt=True,
                                               enable_thinking=False, return_dict=True, return_tensors='pt')
        prefix = p.get('continuation_ids', [])
        if prefix:
            inputs['input_ids'] = torch.cat([inputs['input_ids'], torch.tensor([prefix], dtype=torch.long)], dim=1)
            inputs['attention_mask'] = torch.ones_like(inputs['input_ids'])
        length = int(inputs['input_ids'].shape[1])
        remaining = WINDOW - length
        if remaining <= 0:
            raise ValueError('The 16K context budget has been exhausted.')
        inputs = inputs.to('cuda')
        start = time.monotonic()
        with torch.inference_mode():
            output = model.generate(**inputs, max_new_tokens=min(8192, remaining), max_time=budget, do_sample=False,
                                    use_cache=True, temperature=None, top_p=None, top_k=None, repetition_penalty=1.1)
        new_ids = output[0, length:].detach().cpu().tolist()
        all_ids = prefix + new_ids
        text = processor.decode(all_ids, skip_special_tokens=True)
        eos = model.generation_config.eos_token_id
        eos = eos if isinstance(eos, list) else [eos]
        complete = bool(re.search(r'</html\s*>', text, re.I))
        stopped = complete or bool(new_ids and new_ids[-1] in eos) or length + len(new_ids) >= WINDOW
        result = {'ids': all_ids, 'raw': text, 'done': stopped, 'complete': complete, 'seconds': time.monotonic() - start,
                  'new_tokens': len(new_ids), 'total_tokens': p['input_tokens'] + len(all_ids)}
        del inputs, output
        return result

    HIDE = gr.update(value=None, visible=False)

    def gallery_of(p):
        return [(a['thumb'], f"{a['id']} · page {a['page']}" if a.get('page') else a['id']) for a in p['assets']]

    def analyze_evt(pdf, instruction, canvas, accent, columns, custom_spec, progress=gr.Progress()):
        """Request 1 of a run: parse the PDF on CPU. GPU steps run in their own requests (see step)."""
        if not pdf:
            raise gr.Error('Upload a paper PDF first.')
        progress(.1, desc='Reading the paper (LayoutLMv3 on CPU, up to a few minutes)…')
        try:
            p = analyze(pdf, instruction, canvas, accent, columns, custom_spec)
        except (ValueError, RuntimeError) as e:
            raise gr.Error(str(e)) from None
        p = dict(p, step=0, paused=False)
        head = (f"Benchmark case **{p['title'][:80]}** · exact test prompt, {len(p['assets'])} figures/tables" if p.get('example')
                else f"Parsed **{p['title'][:90]}** · {len(p['assets'])} figures/tables found")
        return (p, f"{head} · generating (step 1)…",
                None, HIDE, HIDE, gallery_of(p), p['spec'], '', gr.update(visible=False))

    def step(p, progress=gr.Progress()):
        """One ZeroGPU step per request: the ZeroGPU proxy token is tied to the request and expires after a few minutes,
        so a single long request (parse + several 120 s steps) fails with 'Expired ZeroGPU proxy token'."""
        if not p or p.get('done') or p.get('paused'):
            return tuple(gr.skip() for _ in range(9))
        k = p.get('step', 0) + 1
        gallery = gallery_of(p)
        if k > MAX_STEPS:
            return (dict(p, paused=True), f"Stopped after {MAX_STEPS} steps without a complete poster.", None, HIDE, HIDE,
                    gallery, p['spec'], p['raw'], gr.update(visible=True))
        progress(min(.1 + .12 * k, .9), desc=f'Generating poster HTML (step {k})…')
        r = None
        for secs in [d for d in GPU_LADDER if d <= p.get('gpu_seconds', GPU_LADDER[0])]:
            p = dict(p, gpu_seconds=secs)
            try:
                r = gpu_chunk(p)
                break
            except Exception as e:                                         # noqa: BLE001  (quota, duration limit, timeout, …)
                msg = str(e)
                if 'larger than the maximum allowed' in msg and secs != GPU_LADDER[-1]:
                    continue                                               # ask for a shorter GPU slot
                hint = QUOTA_HINT if ('maximum allowed' in msg or 'quota' in msg.lower()) else ''
                return (dict(p, paused=True), f"Paused after step {k - 1}: {msg[:200]}{hint}", None, HIDE, HIDE,
                        gallery, p['spec'], p['raw'], gr.update(visible=bool(p.get('continuation_ids'))))
        p = dict(p, continuation_ids=r['ids'], raw=r['raw'], done=r['done'], step=k)
        tokens = f"{len(r['ids']):,} tokens · {r['new_tokens'] / max(r['seconds'], .01):.0f} tok/s · {p['gpu_seconds']} s GPU step"
        if not (r['complete'] or p['done']):
            return (p, f"Step {k} done · {tokens} · continuing (step {k + 1})…", None, HIDE, HIDE,
                    gallery, p['spec'], p['raw'], gr.update(visible=False))
        progress(.95, desc='Rendering…')
        try:
            png, html, archive, meta = render(p['raw'], p)
        except Exception as e:                                             # noqa: BLE001
            return (dict(p, paused=True), f"The model stopped before a complete poster ({str(e)[:160]}).", None, HIDE, HIDE,
                    gallery, p['spec'], p['raw'], gr.update(visible=not p.get('done')))
        note = '' if meta['complete_html'] else ' · output incomplete'
        return (dict(p, done=True), f"Done · {tokens}{note}", png, fileout(html), fileout(archive),
                gallery, p['spec'], p['raw'], gr.update(visible=False))

    def resume(p):
        if not p or p.get('done'):
            raise gr.Error('Nothing to continue. Click Generate poster.')
        return dict(p, paused=False)

    examples = load_examples()
    def pick_example(evt: gr.SelectData):
        ex = examples[evt.index]; d = ex['dir']
        html = d / 'poster.html'; archive = d / 'poster_bundle.zip'
        return (str(d / 'paper.pdf'), ex['instruction'], ex['canvas'], ex['accent'], ex['columns'], ex['spec'],
                f"Benchmark case · {ex['title']}" + (f" ({ex['venue']})" if ex['venue'] else '') + ' · click **Generate poster** to reproduce it',
                str(d / 'poster.png'), fileout(html if html.exists() else None), fileout(archive if archive.exists() else None))

    with gr.Blocks(title='RAP · Paper to Poster', css=CSS, delete_cache=(3600, 86400),
                   theme=gr.themes.Soft(primary_hue='violet', neutral_hue='slate', radius_size='lg')) as demo:
        gr.HTML('<div id="hero"><h1>RAP · Paper to Poster</h1>'
                '<p>Upload a research paper and get an editable academic poster: a 27B vision-language model '
                'reads the paper, picks figures and tables, and writes the poster as standalone HTML.</p>'
                '<span class="badge">Qwen3.8-27B · SFT + GRPO</span><span class="badge">BF16 · ZeroGPU</span>'
                '<span class="badge">Output: HTML + PNG</span>'
                '<p class="links"><a href="https://arxiv.org/abs/2610.23230" target="_blank">Paper</a> · '
                '<a href="https://reconstruct-a-poster.github.io/" target="_blank">Project page</a> · '
                '<a href="https://github.com/VincentHancoder/RAP" target="_blank">Code</a> · '
                '<a href="https://huggingface.co/VincentHancoder/RAP-Qwen3.8-27B-RL" target="_blank">Model</a> · '
                '<a href="https://huggingface.co/datasets/VincentHancoder/RAP" target="_blank">Dataset RAP-25K</a></p></div>')
        with gr.Row(equal_height=False):
            with gr.Column(scale=5, min_width=360):
                pdf = gr.File(label='Paper PDF', file_types=['.pdf'], type='filepath', height=110)
                instruction = gr.Textbox(label='Instruction', value=DEFAULT_INSTRUCTION, lines=3)
                with gr.Row():
                    canvas = gr.Dropdown(list(CANVASES), value='Landscape · 3:2', label='Canvas (preset or WxH)', allow_custom_value=True, scale=2)
                    columns = gr.Slider(1, 4, value=3, step=1, label='Columns', scale=2)
                    accent = gr.ColorPicker(value='#584287', label='Accent', scale=1)
                with gr.Accordion('Custom layout specification (optional)', open=False):
                    custom_spec = gr.Textbox(show_label=False, placeholder='Leave empty to let the demo plan the layout. Benchmark cases fill in their exact layout specification here.', lines=4)
                run_btn = gr.Button('Generate poster', variant='primary', size='lg')
                cont_btn = gr.Button('Continue generation', variant='secondary', visible=False)
                gr.Markdown('<small>Generation runs in ZeroGPU steps of up to 120 s; a full poster usually takes 3–6 steps. '
                            'Sign in to Hugging Face to use your GPU quota.</small>')
            with gr.Column(scale=7):
                status = gr.Markdown('Upload a PDF or pick an example below.', elem_id='status')
                poster = gr.Image(label='Poster', type='filepath', interactive=False, height=560, elem_id='poster', show_download_button=True)
                with gr.Row():
                    html_download = gr.File(label='Poster HTML', interactive=False, visible=False)
                    bundle = gr.File(label='HTML + figures (ZIP)', interactive=False, visible=False)
        if examples:
            gr.Markdown('### Examples')
            ex_gallery = gr.Gallery(value=[(str(e['dir'] / 'poster.png'), e['short']) for e in examples], columns=5, rows=1,
                                    height=230, object_fit='contain', show_label=False, elem_id='examples', allow_preview=False)
        with gr.Accordion('Details: extracted figures, layout plan, raw HTML', open=False):
            gallery = gr.Gallery(label='Figures and tables offered to the model', columns=6, height=220)
            spec_view = gr.Textbox(label='Layout specification', lines=4, interactive=False)
            code = gr.Code(language='html', label='Model output', interactive=False)
        with gr.Accordion('Notes', open=False):
            gr.Markdown('Research demo. Scanned PDFs are not supported; uploads are processed on this Space and are temporary. '
                        'Check every statement, number and caption before using a poster. '
                        '[Source and third-party notices](https://huggingface.co/spaces/VincentHancoder/RAP/tree/main).')
        state = gr.State()
        outs = [state, status, poster, html_download, bundle, gallery, spec_view, code, cont_btn]
        # Chain: one request for parsing, then one request per GPU step (fresh ZeroGPU token each time).
        # API clients: call /analyze once, then /step until the status starts with "Done", "Paused" or "Stopped".
        ev = run_btn.click(analyze_evt, [pdf, instruction, canvas, accent, columns, custom_spec], outs, api_name='analyze',
                           concurrency_limit=1)
        for k in range(MAX_STEPS):
            ev = ev.then(step, [state], outs, api_name='step' if k == 0 else False, concurrency_limit=1)
        ev = cont_btn.click(resume, [state], [state], api_name=False)
        for _ in range(MAX_STEPS):
            ev = ev.then(step, [state], outs, api_name=False, concurrency_limit=1)
        if examples:
            ex_gallery.select(pick_example, None, [pdf, instruction, canvas, accent, columns, custom_spec, status, poster, html_download, bundle], api_name=False)
    return demo, ARTIFACTS