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.
