Add OvisOCR2 support, with a gate fix for long-period loops - #43
Open
charitarthchugh wants to merge 1 commit into
Open
Add OvisOCR2 support, with a gate fix for long-period loops#43charitarthchugh wants to merge 1 commit into
charitarthchugh wants to merge 1 commit into
Conversation
OvisOCR2 (ATH-MaaS/OvisOCR2) is a 0.8B end-to-end page parser post-trained
from Qwen3.5-0.8B. Selected with `--ocr-model ovisocr2`.
Adapter details taken from primary sources (the model card's own inference
script, config.json, chat_template.jinja, vLLM engine args), and verified
against a live vLLM 0.27.1 server:
* Vendor prompt verbatim, including its leading newline and the literal
{left}/{top} braces.
* Image content part before the text part. The chat template trims the ends
of the rendered user turn, so a text-first layout would eat that leading
newline and send a different token sequence than the model was trained on.
* max_tokens=16384 per the vendor; the pipeline's 8000 default truncates
pages of dense HTML tables. Temperature stays pipeline-owned.
* Strips the `<img src="images/bbox_...">` placeholders (paperscale never
writes the crops they reference) and reports those pages as diagrams;
recomputes is_table from the emitted HTML. Confirmed against a real
response containing `<img src="images/bbox_94_615_891_757.jpg" />`.
The `<think>`-block strip and the HTML-table flag were duplicated verbatim
from qianfan.py, so both move to markdown.py as strip_think_blocks() and
has_html_table(), and qianfan.py now uses them too.
The adapter deliberately does NOT port the vendor's _clean_truncated_repeats
tail trimmer. A looping page is an incomplete page, and rewriting it in parse
would hand the pipeline something that reads as clean and complete.
That detection belongs in the quality gate, which turned out to have a
structural blind spot: _has_repeated_ngram_loop scores
count * ngram_size / tokens with ngram_size capped at 5, so it tops out at
5 / period. A looped sentence (13+ tokens) stays under the 0.35 threshold
however much of the page it consumes -- measured at 0.35 even when the loop
was 90% of the output, i.e. accepted. _has_repeated_tail_loop tests instead
whether a span near the end of the page repeats at a fixed period, so period
length stops mattering. It reuses OvisOCR2's vendor constants as a detector
rather than a repair, and requires the same 0.35 share, which is what keeps
genuinely repetitive form rows (measured at 0.23) from being read as a loop.
This changes acceptance for every --ocr-model, not just ovisocr2.
Two details that a first cut got wrong, both caught by review:
* The loop rarely runs to the final character -- OvisOCR2 emits HTML tables,
so a model looping table rows then closing the tag left "</table>" behind
and an end-anchored check accepted the page. The check now probes a ladder
of end positions. Stepping back one character at a time does not work: on
prose a character coincidentally lines up at the period within a few steps,
which stops the search inside the trailer.
* The check runs on every page, so it is a slice comparison rather than a
per-period character walk. The walk cost ~300ms on a page whose tail
repeats just under the share threshold; it is ~1.5ms now, worst case, and
0.04ms on ordinary pages. Cross-checked against the original walk over
5000 randomised inputs: strictly more sensitive, never less.
Also adds `--disable-quality-check CHECK` (repeatable, `all` disables the gate
entirely) so a corpus that trips one gate systematically can be processed
without it, instead of loosening --max_page_error_rate for every failure mode
at once. The "all" expansion lives beside QUALITY_CHECK_CODES in verifier.py,
and main() sets the run's verifier on args, with the argparse default keeping
the fully armed gate for callers that bypass main().
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds an OCR adapter for OvisOCR2 (
ATH-MaaS/OvisOCR2, 0.8B,Qwen3_5ForConditionalGeneration), selected with--ocr-model ovisocr2. It is a compact end-to-end page parser post-trained from Qwen3.5-0.8B — 96.58 on OmniDocBench v1.6 (first end-to-end model to top that leaderboard) and 75.06 Avg3 on PureDocBench.Along the way this uncovered a structural blind spot in the quality gate, fixed here, plus a new flag to switch individual checks off.
The adapter
Everything came from primary sources — the model card's own inference script,
config.json,chat_template.jinja, and vLLM's engine args — then was verified against a live server.Three details a plausible-looking guess would have gotten wrong:
chat_template.jinjaappliesrender_content(...)|trimto the user turn. The vendor prompt begins with a literal\n; with the image part first, that newline sits mid-string and survives as the image/instruction separator. Text-first and|trimeats it — a different token sequence than the model was trained on.chat_template_kwargsneeded. The template's generation prompt is{%- if enable_thinking is defined and enable_thinking is true %}, whose else-branch emits a pre-closed<think>\n\n</think>. A plain OpenAI request already means non-thinking, so the vendor'senable_thinking=Falseis the default.gdn_prefill_backend="triton"should not be copied. 18 of the model's 24 layers are Gated-DeltaNetlinear_attention, whose prefill needs a chunked parallel scan; vLLM ships three implementations. The--gdn-prefill-backendflag that selects one is real but absent fromvllm serve --help(registered inarg_utils.py). Its default isauto, and_resolve_gdn_prefill_backendalready returns Triton on anything below Hopper, or the faster FlashInfer kernel on Hopper (SM90) and Blackwell (SM10.x,linear_key_head_dim == 128, CUDA >= 13). So pinningtritonis a no-op on consumer cards and a downgrade on Hopper. The serve command below omits it; docs say to add it only to force the fallback if FlashInfer won't build.Other adapter behaviour:
max_tokens=16384per the vendor (the pipeline's 8000 default truncates dense HTML tables); temperature stays pipeline-owned;<img src="images/bbox_…">placeholders are stripped, since paperscale never writes the crops they reference, and those pages are reported as diagrams instead;is_tableis recomputed from the emitted HTML.The gate fix (affects every
--ocr-model)The vendor ships a
_clean_truncated_repeatshelper that trims a repeating tail out of long responses. This adapter deliberately does not port it: a looping page is an incomplete page, and rewriting it inparsehands the pipeline something that reads as clean and complete.Putting that detection in the gate instead exposed a real gap.
_has_repeated_ngram_loopscorescount × ngram_size / tokenswithngram_sizecapped at 5, so it tops out at5 / period:A page that is 86% one repeated sentence was being accepted.
_has_repeated_tail_looptests whether a span near the end repeats at a fixed period, so period length stops mattering. It reuses OvisOCR2's vendor constants as a detector rather than a repair — the page is retried at a higher temperature instead of silently rewritten — and requires the same 0.35 share the n-gram check uses, which is what keeps genuinely repetitive form rows (measured at 0.23) from reading as a loop.Two details a first cut got wrong, both caught in review:
</table>behind — and an end-anchored check accepted every such page. It now probes a ladder of end positions. Stepping back one character at a time does not work: on prose a character coincidentally lines up at the period within a few steps, which halts the search inside the trailer.New flag
--disable-quality-check CHECK, repeatable, validated against the check vocabulary, withallto disable the gate entirely. Disabling one check leaves the others armed, which is the point: a corpus that systematically trips one gate can be processed without it, instead of loosening--max_page_error_ratefor every failure mode at once. It logs a warning when active. Pairs withPAPERSCALE_DEBUG_REJECTSto see what a gate was dropping before switching it off.Verification
Live smoke test against vLLM 0.27.1 serving the model on an RTX 3090: 2 pages, 0 failures, 0 retries, 0 quality-gate rejects,
finish_reason: stop.Sending the adapter's exact payload and inspecting the unprocessed response confirmed the parts that unit tests cannot: content parts arrived as
['image_url', 'text']with the prompt's leading newline intact, and the raw output contained<img src="images/bbox_94_615_891_757.jpg" />, whichparse()reduced to zero img tags withis_table=True, is_diagram=True. Transcription matched thepdftotextground truth — all six table columns and three data rows exact, including(3,664.60)and the em-dash inEscrow — Class B, with the display formula as well-formed LaTeX.poetry run pytest -q→ 378 passed, 1 skipped, 34 subtests, 0 failed (needs--extras tui; without it 16test_tui.pytests fail on a missingrich, unrelated to this change).ruff checkandruff format --checkclean; no line exceeds the declared 160.The change also went through a two-axis review (standards + spec). Acted on: the end-anchoring bug above; deduplicating the
<think>-strip and HTML-table flag out ofqianfan.pyintomarkdown.py; removing a silentgetattrfallback so the verifier onargsis always the one a page is judged by; moving the"all"expansion besideQUALITY_CHECK_CODES; making the vocabulary-drift test actually detect drift in both directions; and adding end-to-end coverage for the CLI flag from argv through to the verifier.Serving
Reviewer notes
0.22.1.$$…$$(likeqianfan-ocr), whereassurya2converts to\[…\]/\(…\). Worth normalising separately if that matters.