Prototype for merging OCR result

Merging Three OCR Engines Into One Trustworthy Tibetan Text

The problem

We had a large batch of scanned Tibetan-language texts that had already been run through three different OCR systems, and none of the three was reliable enough to use on its own. BDRC OCR (ocrv1), our traditional line-detection engine, does well on clean printed Tibetan but is fragile whenever the page layout gets messy. Qwen OCR (ocr_qwen_v1), our vision-language-model engine, is usually the sharpest reader of dense Tibetan script — but like most VLM-based OCR, it would occasionally go off the rails: it would get stuck repeating the same syllable or phrase for the rest of the page, or it would run out of its generation budget partway through and leave English status text like “tokens exhausted” sitting in the middle of the transcription instead of stopping cleanly. Google Vision OCR (gv) is steady and dependable but noticeably weaker on cursive or heavily stylized Tibetan scripts.

So we had three outputs per page and no single one we could trust by default. We also had a separate script-classification pass already done on every page, telling us in advance whether it was blank, non-Tibetan (a title page, a library stamp), or which Tibetan script it was written in. The problem, concretely: for every page in the corpus, automatically pick the one transcription worth keeping — and specifically catch Qwen’s repeating-loop and cut-off failures before they ship, instead of quietly handing back a page full of repeated garbage.

What we did about it

We implemented a merge pipeline that uses the script-classification label to route each page to the right engine, and added a dedicated hallucination check that Qwen’s output has to pass before we trust it.

The workflow

Two steps drive this:

Step 1 — route by page type. The classification label decides most of the routing on its own:

Page labelled… Engine we use Reasoning
blank whichever engine returned the shortest text An empty or near-empty result is the honest answer for a blank page, and it’s a simple, deterministic rule.
non-Tibetan (title pages, stamps, etc.) Google Vision (gv) Google Vision is our strongest engine on ordinary printed Latin/mixed-script pages.
Tibetan content (uchen and others) Qwen, by default Qwen is generally our sharpest reader of Tibetan script — provided it hasn’t failed on this page, which step 2 checks.

Step 2 — catch Qwen’s two failure modes before trusting it.

  • Repeating loops. We scan the full Qwen page text for a character or short phrase that repeats at least four times back-to-back — a flood of the Tibetan tsek mark, or a syllable cycling on and on, anywhere on the page, not just at the end. We only flag it once the repeated stretch is long enough and covers a real share of the page, so a couple of coincidental repeats in normal prose doesn’t get caught by mistake.
  • Leaked stop messages. We also check for English fragments like “tokens exhausted” or “finish_reason=length” that Qwen sometimes leaves behind when it hits its generation budget mid-page. The phrase list is worded narrowly enough that a genuinely English title or catalog page doesn’t trip it.

When either check fires, we don’t just drop the page — we still need a transcription, so we fall back: uchen pages fall back to the line-based engine (BDRC/ocrv1), since that engine holds up best on the most common script in this corpus; every other script falls back to Google Vision.

Both checks run on the text we already have — no external calls, no extra ML — so the merge is fast, deterministic, and can be re-run any time the upstream OCR or classification data changes.

Results: running it across all 10 volumes

We ran the merge pipeline over the full corpus — all 10 scanned works we have classification labels for — and let it make the real, final call on every one of the 4,119 pages. Here’s what it decided, work by work:

Work / volume Pages Blank Non-Tibetan Hallucination caught → Qwen → ocrv1 (BDRC) → gv
W3KG424 / I3KG1177 30 1 0 2 27 2 1
W1LT0482 / I1LT0482 200 6 0 2 192 1 7
W8CZ237 / I8CZ786 623 3 4 40 576 0 47
W8LS67515 / I8LS67520 596 2 4 3 587 3 6
W8LS26135 / I8LS26137 122 1 1 0 120 0 2
W1KG22442 / I1KG22493 712 7 1 4 700 0 12
W8LS32582 / I8LS32754 582 1 1 2 578 2 2
W8LS18063 / I8LS18085 50 0 1 0 49 0 1
W3PD1002 / I1KG81117 640 7 1 0 632 0 8
W3PD1002 / I1KG81118 564 7 1 1 555 1 8
Total 4,119 35 14 54 4,016 9 94

Totals across the corpus: 4,119 pages processed. 35 blank, 14 non-Tibetan, 4,070 Tibetan-content pages. Of those Tibetan-content pages, 54 tripped the hallucination check — about 1 in 75 — and were automatically rerouted instead of shipping broken Qwen output. In the end, Qwen’s own transcription was trusted on 4,016 pages (97.5%), 94 pages (2.3%) fell back to Google Vision, and 9 pages (0.2%) fell back to the line-based engine.

That 97.5% figure is the number we care about most: it means the pipeline left the great majority of pages alone and only intervened on the pages where there was real evidence Qwen had gone wrong — it isn’t second-guessing a good transcription, it’s specifically catching the failure pattern we set out to catch.

We also spot-checked this against the unit test suite (14 tests covering the loop detector and the stop-phrase detector, including negative cases for ordinary Tibetan prose and ordinary English catalog text) — all 14 pass, so the numbers above reflect the detector behaving exactly as designed, not an accident of this particular corpus.

Code & resources

GitHub repository: github.com/OpenPecha/ocr_merger

Data source: the scanned works and all three OCR outputs come from BDRC — the Buddhist Digital Resource Center, an archive of Tibetan (and broader Buddhist-canon) texts formerly known as TBRC, whose public S3 bucket (bec.bdrc.io) hosts the Parquet files this pipeline reads from.