Skip to content

Commit 2c1e4db

Browse files
committed
Add LightOnOCR-2 VLM comparison harness and first results
Answers one question: does an end-to-end VLM read a poster better than pdfplumber + xy_cut? Raw text only, no corrector, no JSON, no LLM stage. Branched from feature/xy-cut-calibration rather than main so the control is the current best pipeline (rField 0.741, not main's 0.727) and both sides are scored by the same harness against the same human _raw.md. Verdict: not a drop-in replacement, because it fabricates identifiers. But it reads poster structure markedly better than we do, and the two fail in opposite directions, which is the interesting part. It wins where we struggled most. rField 0.765 vs our 0.741, and on the banner - authors+affiliations, the field that cost this project Track A, Track B and approach A - it wins 15 of 19 posters, mean +0.179, gasimova 0.742 to 1.000. From pixels, with no xy_cut, no top-band flatten, no superscript-row merge. It also returns wrapped titles in one piece. It also invents identifiers, which no average will show you. Checked as exact strings against the transcription: pdfplumber invents zero ORCIDs and zero DOIs; LightOnOCR gets 6 of 10 ORCIDs wrong and 2 of 9 DOIs, including 10.1101/2024.08.13.24311948 read as 10.1105/...24311348 and an ORCID read as 0000-0002-3982-7202 instead of 0000-0002-2862-7302. Those are different identifiers, not near misses: a wrong ORCID attributes work to another researcher. The cause is structural - a VLM predicts every glyph from pixels, so an identifier is a guess, while pdfplumber copies bytes the author embedded and can only ever miss. The environment is deliberately isolated. LightOnOcr* needs transformers v5; ~/myenv is on 4.57 and every consumer of the pipeline imports it, so v5 goes in ~/locr-libs via pip --target and is shadowed in by PYTHONPATH for the generate step only. Scoring runs under plain ~/myenv. The model is loaded with transformers rather than vLLM on purpose: vLLM pre-allocates a pool and would fight the ollama and vLLM services already holding ~74 of GPU 1's 98 GB. It needs 3.0 GB and coexists. Includes the raw .md output for all 20 posters so the extractions can be read without a GPU, and a truncation flag - isporeu2023 never terminated (still generating at 16384 tokens, 471s) and would otherwise have been scored as if complete.
1 parent 8a8c95a commit 2c1e4db

26 files changed

Lines changed: 3365 additions & 0 deletions

calibration/vlm/FINDINGS.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Findings: LightOnOCR-2-1B vs pdfplumber + xy_cut (2026-07-16)
2+
3+
First run, 19 corpus posters + gasimova. Model `lightonai/LightOnOCR-2-1B`,
4+
200 DPI, longest side 1540px, bf16, greedy. **3.0 GB peak VRAM, ~60s/poster**
5+
on one GPU alongside the running ollama/vLLM services.
6+
7+
## Verdict
8+
9+
**Not a drop-in replacement — it fabricates identifiers. But it reads poster
10+
structure markedly better than we do, and that is worth having.**
11+
12+
Do not read the headline averages alone; they say the opposite of what matters.
13+
14+
## It reads better
15+
16+
| metric | pdfplumber + xy_cut | LightOnOCR-2-1B |
17+
|---|---|---|
18+
| `w` (word capture) | **0.976** | 0.936 |
19+
| `rGlobal` | **0.835** | 0.788 |
20+
| `rField` (length-normalized) | 0.741 | **0.765** |
21+
22+
rField, the metric we treat as the headline, favours the VLM. Head-to-head it
23+
wins 12 of 19. On the **banner**`authors+affiliations`, the field that cost
24+
this project Track A, Track B and approach A — it wins **15 of 19** (2 ties, 2
25+
losses), mean **+0.179**:
26+
27+
gasimova 0.742 -> 1.000 4607450 0.244 -> 0.909
28+
4560930 0.600 -> 1.000 aysaekanger 0.429 -> 0.880
29+
4446908 0.667 -> 1.000 10890106 0.600 -> 0.925
30+
42 0.640 -> 0.913 AISec 0.696 -> 0.929
31+
32+
It gets these right for free, from pixels, with no xy_cut, no
33+
`_flatten_top_band`, no superscript-row merge, no marker parsing. It also
34+
returns wrapped titles in one piece (10890106, which our block grouper still
35+
splits) and emits its own markdown headers.
36+
37+
## It invents identifiers
38+
39+
This is disqualifying for a metadata pipeline and ROUGE cannot see it. Exact
40+
strings checked against the human transcription (`fidelity_check.py`):
41+
42+
| extractor | kind | recovered | missed | **invented** |
43+
|---|---|---|---|---|
44+
| pdfplumber | orcid | 9 | 1 | **0** |
45+
| pdfplumber | doi | 10 | 0 | **0** |
46+
| pdfplumber | email | 20 | 2 | 1* |
47+
| LightOnOCR | orcid | 4 | 6 | **6** |
48+
| LightOnOCR | doi | 7 | 3 | **2** |
49+
| LightOnOCR | email | 18 | 4 | 2 |
50+
51+
\* not a fabrication: the poster prints `1aperdomo@iac.es` where the `1` is a
52+
superscript affiliation marker glued to the address. Our extractor correctly
53+
splits it; the checker counts the clean address as "not in the reference".
54+
55+
**6 of 10 ORCIDs are wrong.** Actual corruptions:
56+
57+
DOI 10.1101/2024.08.13.24311948 -> 10.1105/2024.08.13.24311348
58+
DOI 10.1007/978-3-031-02170-1 -> 10.1007/978-3-031-02701-1
59+
email joneilliii@sdsu.edu -> joneilliii@sdssu.edu
60+
ORCID 0000-0002-2862-7302 -> 0000-0002-3982-7202
61+
62+
These are not near-misses, they are different identifiers. A corrupted ORCID
63+
attributes a poster to another researcher; a corrupted DOI resolves to the
64+
wrong paper or nowhere. Silent, plausible, and worse than no value at all.
65+
A text-layer extractor cannot do this: it can only miss.
66+
67+
The reason is structural, not a tuning problem. The VLM re-renders every glyph
68+
from pixels, so an identifier is a prediction. pdfplumber copies bytes the
69+
author embedded.
70+
71+
## Other failure modes seen
72+
73+
- **isporeu2023** is a genuine failure: it dropped 4 of 8 authors, mis-assigned
74+
markers (Ciccarone 3 -> 2, Schlichting 4 -> 1), read "Delta Hat Ltd" as
75+
"Delta et Ltd", and hallucinated 3 ORCIDs. It also never terminated: 6144
76+
tokens truncated, and at 16384 it was STILL going (471s), emitting HTML
77+
tables. Our pipeline scores 0.849 rField on this poster; the VLM 0.509.
78+
- **8228476** (RTL Hebrew) is worse under the VLM too (rField 0.503 vs 0.692),
79+
so approach D is not solved by switching extractor.
80+
- Superscripts come back as LaTeX (`$^{1,2}$`). Harmless for raw-text scoring
81+
(`_alpha()` reduces it to `12`, matching the reference's NFKD-normalized
82+
`¹˒²`) but the affiliation corrector would need to read it.
83+
84+
## Where this points
85+
86+
A hybrid is the obvious shape, and the numbers support it: **take structure
87+
from the VLM, take exact strings from the text layer.** The VLM is good at
88+
precisely what xy_cut finds hard (which text belongs to which line, in what
89+
order) and bad at precisely what pdfplumber gets for free (reproducing a string
90+
exactly). They fail in opposite directions.
91+
92+
Concretely, worth testing next:
93+
94+
1. VLM output as the reading-order source, then verify/replace every ORCID,
95+
DOI and email against the PDF text layer — reject any identifier the text
96+
layer does not contain verbatim. This bounds the fabrication to zero while
97+
keeping the banner gains.
98+
2. Or narrower and safer: keep our pipeline, and use the VLM only for the
99+
banner region, where it wins by +0.179 and where identifiers can be
100+
cross-checked against a small, well-defined slice of text.
101+
3. Re-run with `--dpi 300` before concluding on recall; `w` is 0.936 vs our
102+
0.976 and some of that gap may be resolution, not the model.
103+
104+
Do NOT wire this into the pipeline on the strength of rField=0.765.

calibration/vlm/README.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# LightOnOCR-2 as an extraction option: raw-text comparison
2+
3+
Feature branch `feature/lightonocr-eval`. Asks one question and no others:
4+
**does an end-to-end VLM read a poster better than pdfplumber + xy_cut?**
5+
Raw text only. No affiliation corrector, no JSON, no LLM stage.
6+
7+
Branched from `feature/xy-cut-calibration`, not `main`, deliberately: that
8+
branch holds the current best pipeline (rField 0.741 vs main's 0.727) and the
9+
harness whose metrics both sides are scored with. Comparing a new model against
10+
a stale control would flatter it.
11+
12+
## What is being compared
13+
14+
| | control | candidate |
15+
|---|---|---|
16+
| | `extract_text_with_pdfplumber` (pdfplumber + xy_cut) | `lightonai/LightOnOCR-2-1B` |
17+
| input | PDF text layer | page rendered to an image |
18+
| output | markdown-ish text | markdown |
19+
20+
Both are scored by the same code against the same reference, the human
21+
`_raw.md` transcription. Neither side gets a metric of its own.
22+
23+
- `w` — word capture vs `_raw.md`. Format-blind; the fairest single number for
24+
"did it read the page".
25+
- `rGlobal` — whole-document ROUGE-L. Order-sensitive, format-blind.
26+
- `rField` — per-field ROUGE-L, length-normalized (title, authors+affiliations,
27+
and each section count once each regardless of length). **Needs the output to
28+
carry markdown headers** so it can be chunked into fields. Ours emits `## `;
29+
LightOnOCR emits `#`/`##` of its own accord, so the comparison holds. A model
30+
that emitted flat text would score badly here for reasons of format rather
31+
than reading — so read `w` and `rGlobal` first.
32+
33+
## Running it
34+
35+
Two phases, two environments, on purpose.
36+
37+
# phase 1: generate (needs transformers v5)
38+
CUDA_VISIBLE_DEVICES=1 PYTHONPATH=~/locr-libs \
39+
~/myenv/bin/python calibration/vlm/run_lightonocr.py
40+
41+
# phase 2: score (needs poster2json, i.e. plain ~/myenv)
42+
~/myenv/bin/python calibration/vlm/compare_vlm.py --details
43+
44+
### The environment split matters
45+
46+
`LightOnOcrForConditionalGeneration` / `LightOnOcrProcessor` landed in
47+
**transformers v5**. `~/myenv` is on **4.57.6** and is what poster2json, the
48+
calibration harness and the 8B validation all import. **Do not upgrade it in
49+
place.** Instead:
50+
51+
~/myenv/bin/pip install --target ~/locr-libs "transformers>=5" pypdfium2
52+
53+
`PYTHONPATH=~/locr-libs` shadows 4.57 for that one process and leaves every
54+
other consumer alone. Verified: transformers 5.14.1 + torch 2.8.0+cu128 (torch
55+
is reused from `~/myenv`, not reinstalled). A `--system-site-packages` venv does
56+
NOT work here, because `~/myenv` is itself a venv and the new venv inherits the
57+
base interpreter's packages, not `~/myenv`'s.
58+
59+
### GPU etiquette
60+
61+
hpcf's GPUs are usually busy: GPU 1 typically runs an ollama llama-server and
62+
two vLLM engines (~74 of 98 GB). LightOnOCR-2-1B is small — **2.5 GB peak, bf16**
63+
— so it coexists on GPU 1's headroom via `CUDA_VISIBLE_DEVICES=1`. It is loaded
64+
with plain transformers rather than vLLM precisely because vLLM pre-allocates a
65+
memory pool and would fight the running engines. **Do not kill those services to
66+
make room.** GPU 0 (the 4090) has only ~3.5 GB free and drives the display.
67+
68+
## Rendering
69+
70+
Per the model card: 200 DPI, longest side 1540px, aspect preserved. Both are
71+
flags on the runner (`--dpi`, `--longest`) since posters are unusually large
72+
(gasimova is 3312x3312pt) and resolution is the obvious thing to sweep if
73+
recall disappoints.
74+
75+
`--max-new-tokens` defaults to 6144. A generation that stops exactly at the cap
76+
was truncated and has silently lost recall; the runner flags those in `run.json`
77+
and in its output rather than letting them be scored as if complete.
78+
79+
## What to watch for
80+
81+
- **Hallucination.** A VLM can produce fluent text that is not on the poster.
82+
ROUGE against `_raw.md` rewards recall and will not punish invention hard
83+
enough on its own. Read some outputs before believing a headline number.
84+
- **LaTeX.** LightOnOCR emits `$^{1,2}$` for superscript markers. Harmless for
85+
raw-text scoring (`_alpha()` strips it to `12`, and the reference's `¹˒²`
86+
NFKD-normalizes to the same), but it would need handling before the
87+
affiliation corrector could consume VLM output. Out of scope here.
88+
- **The banner is the interesting part.** That is where our pipeline needed all
89+
of Track A/B and approach A. Compare `authors+affiliations` per-field
90+
(`--details`) as much as the corpus average.

calibration/vlm/compare_vlm.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
#!/usr/bin/env python3
2+
"""Score LightOnOCR-2's raw output against the same controls as our pipeline.
3+
4+
Phase 2 of the VLM comparison. Runs under plain ~/myenv (it needs poster2json
5+
for the control) and reads the .md files phase 1 wrote.
6+
7+
Both extractors are scored by the SAME code against the SAME reference: the
8+
human `_raw.md` transcription. Nothing here is VLM-specific, so neither side
9+
gets a metric of its own.
10+
11+
w word capture vs _raw.md. Format-blind; the fairest single number
12+
for "did it read the page".
13+
rGlobal whole-document ROUGE-L. Order-sensitive, format-blind.
14+
rField per-field ROUGE-L, length-normalized (title, authors+affiliations,
15+
each section count once each). CAVEAT: this one needs the output
16+
to carry markdown headers so it can be chunked into fields. Our
17+
pipeline emits '## '; LightOnOCR emits '#'/'##' of its own accord.
18+
If a future model emits flat text, its rField will be low for
19+
reasons of format rather than reading, so read w and rGlobal first.
20+
"""
21+
import argparse
22+
import glob
23+
import json
24+
import os
25+
import statistics
26+
import sys
27+
28+
REPO = "/home/joneill/Nextcloud/vaults/jmind/calmi2/poster_science/poster2json"
29+
sys.path.insert(0, REPO)
30+
sys.path.insert(0, os.path.join(REPO, "calibration"))
31+
from poster2json import extract as E # noqa: E402
32+
E.log = lambda *a, **k: None
33+
import reading_order_eval as REV # noqa: E402
34+
35+
CORPUS = ("/home/joneill/Nextcloud/vaults/jmind/calmi2/poster_science/"
36+
"json_schema/manual_poster_annotation")
37+
EXTRA = [("gasimova(oos)", "/storage/poster-work/gasimova.pdf",
38+
"/storage/poster-work/gasimova_clean_raw.md")]
39+
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "out")
40+
41+
42+
def score(gen, ref_md):
43+
if not gen or not gen.strip():
44+
return None
45+
macro, fields = REV.field_scores(gen, ref_md)
46+
return {
47+
"w": round(len(REV._words(gen) & REV._words(ref_md))
48+
/ max(len(REV._words(ref_md)), 1), 3),
49+
"r_global": round(REV._rougeL(REV._alpha(ref_md), REV._alpha(gen)), 3),
50+
"r_field": round(macro, 3),
51+
"fields": fields,
52+
}
53+
54+
55+
def items():
56+
out = []
57+
for d in sorted(glob.glob(os.path.join(CORPUS, "*"))):
58+
if not os.path.isdir(d):
59+
continue
60+
pid = os.path.basename(d)
61+
pdf = glob.glob(os.path.join(d, "*.pdf"))
62+
raw = glob.glob(os.path.join(d, "*_raw.md"))
63+
if raw:
64+
out.append((pid, pdf[0] if pdf else "", raw[0]))
65+
for pid, pdf, raw in EXTRA:
66+
if os.path.exists(raw):
67+
out.append((pid, pdf if os.path.exists(pdf) else "", raw))
68+
return out
69+
70+
71+
def main():
72+
ap = argparse.ArgumentParser()
73+
ap.add_argument("--save", default=None)
74+
ap.add_argument("--details", action="store_true")
75+
args = ap.parse_args()
76+
77+
rows = []
78+
for pid, pdf, rawp in items():
79+
with open(rawp, encoding="utf-8") as fh:
80+
ref = fh.read()
81+
row = {"id": pid}
82+
vlm_path = os.path.join(OUT, f"{pid}.md")
83+
if os.path.exists(vlm_path):
84+
with open(vlm_path, encoding="utf-8") as fh:
85+
row["vlm"] = score(fh.read(), ref)
86+
if pdf:
87+
row["ctl"] = score(E.extract_text_with_pdfplumber(pdf) or "", ref)
88+
rows.append(row)
89+
90+
print(f" {'poster':40s} {'--- pdfplumber + xy_cut ---':>28} "
91+
f"{'--- LightOnOCR-2-1B ---':>26}")
92+
print(f" {'':40s} {'w':>6} {'rGlob':>7} {'rField':>7} "
93+
f"{'w':>6} {'rGlob':>7} {'rField':>7} {'dField':>7}")
94+
for r in rows:
95+
c, v = r.get("ctl"), r.get("vlm")
96+
97+
def f(d, k):
98+
return f"{d[k]:.3f}" if d else " - "
99+
d = (f"{v['r_field'] - c['r_field']:+.3f}" if (c and v) else " - ")
100+
print(f" {r['id']:40s} {f(c, 'w'):>6} {f(c, 'r_global'):>7} "
101+
f"{f(c, 'r_field'):>7} {f(v, 'w'):>6} {f(v, 'r_global'):>7} "
102+
f"{f(v, 'r_field'):>7} {d:>7}")
103+
104+
print(" " + "-" * 104)
105+
for label, key in (("pdfplumber+xy_cut", "ctl"), ("LightOnOCR-2-1B ", "vlm")):
106+
got = [r[key] for r in rows if r.get(key) and "oos" not in r["id"]]
107+
if got:
108+
print(f" {label} n={len(got):2d} "
109+
f"w={statistics.fmean(g['w'] for g in got):.3f} "
110+
f"rGlobal={statistics.fmean(g['r_global'] for g in got):.3f} "
111+
f"rField={statistics.fmean(g['r_field'] for g in got):.3f}")
112+
113+
both = [r for r in rows if r.get("ctl") and r.get("vlm") and "oos" not in r["id"]]
114+
if both:
115+
wins = sum(1 for r in both if r["vlm"]["r_field"] > r["ctl"]["r_field"])
116+
print(f" head-to-head on {len(both)} posters scored by both: "
117+
f"VLM better on rField for {wins}, worse for {len(both) - wins}")
118+
119+
if args.details:
120+
print("\nper-field (control -> vlm):")
121+
for r in rows:
122+
if not (r.get("ctl") and r.get("vlm")):
123+
continue
124+
print(f" {r['id']}")
125+
cf = {n: s for n, s, _ in r["ctl"]["fields"]}
126+
for n, s, ln in r["vlm"]["fields"]:
127+
base = cf.get(n)
128+
d = f"{s - base:+.3f}" if base is not None else " new"
129+
print(f" {base if base is None else f'{base:.3f}'} -> "
130+
f"{s:.3f} {d} ({ln:4d}w) {n}")
131+
132+
if args.save:
133+
with open(args.save, "w", encoding="utf-8") as fh:
134+
json.dump(rows, fh, indent=2)
135+
print(f"\nsaved {args.save}")
136+
137+
138+
if __name__ == "__main__":
139+
main()

0 commit comments

Comments
 (0)