--- license: apache-2.0 base_model: ibm-granite/granite-vision-4.1-4b tags: - document-intelligence - structured-extraction - vision-language - ocr - rag language: - en pipeline_tag: image-text-to-text --- # shrew-ocr-preview shrew-ocr-preview converts one document page image per request into a single JSON object containing document metadata, a summary, self-contained semantic chunks (sized for RAG ingestion, not raw OCR lines), and figures/tables with bounding boxes and HTML. A text modality accepts HTML/markdown/plain-text input and produces the same output schema. Fine-tuned from [ibm-granite/granite-vision-4.1-4b](https://huggingface.co/ibm-granite/granite-vision-4.1-4b) (merged weights, bf16). A GPTQ-8bit quantization with near-identical fidelity is released alongside this model for faster serving. > **Preview release.** Works well on mainstream printed documents (papers, reports, filings, > manuals). Known failure modes are listed under Limitations; measured results under Results. > Weights are updated in place under this name — pin a commit (`revision=`) for reproducibility. ## Output schema One request = one page. The model returns exactly one JSON object, five keys always present: ``` { "metadata": {"title", "authors" (list), "organization", "year", "doc_type"} — null where unknown, "summary": str | null, "semantic_chunks": [{"chunk_id", "title", "content", "section_type" ∈ {abstract, introduction, methodology, results, discussion, conclusion, technical_content, appendix}}], "figures": [{"bbox": [x0,y0,x1,y1] | null, "caption", "description"}], "tables": [{"html": "…", "bbox": [...] | null, "caption", "description"}] } ``` Bounding boxes are xyxy on a 0–1000 normalized grid over the page image. In text modality, bboxes are null. ## Usage **Recommended path: [shrew-server](https://github.com/btbtyler09/shrew-server)** (MIT, pin tag `v0.2.1`), the reference server for this model. It implements the model's entire input contract server-side — glyph-routed bucket preprocessing, the `structured_extraction` request shape, tuned decoding with a schema-enforced retry tier, the streaming repetition guard, schema/coercion gates, and multi-page assembly. POST a PDF, receive structured JSON. It does not serve the model itself; point it at an OpenAI-compatible endpoint (vLLM, below): ```bash vllm serve btbtyler09/shrew-ocr-preview --trust-remote-code \ --served-model-name shrew-ocr-preview \ --max-model-len 32768 --limit-mm-per-prompt '{"image":1}' --no-enable-prefix-caching VLM_URL=http://localhost:8000 VLM_MODEL=shrew-ocr-preview shrew serve curl -X POST localhost:8080/v1/convert -F file=@doc.pdf -F pipeline_mode=structured ``` Full instructions, including a Docker Compose quickstart, are in the repo README under "Using with shrew-ocr-preview (recommended)". For direct integration without shrew-server, the requirements below define the input contract. Deviating from any of these degrades output quality: **1. System prompt.** Set the system prompt to the literal string `structured_extraction`. Do not send instruction text; the model was trained on this fixed prompt only. **2. Decoding.** Set `temperature` to 0 and `max_tokens` to 20000. `presence_penalty` 0.3–0.6 is measured fidelity-neutral; 0.3 is the reference server's first-pass default. Do not set top_p or any other penalty parameter (measured basis under the repetition guard below). Serve with context length ≥ 32768; dense pages need room for both the image tokens and a long completion. **3. Input resolution ("buckets").** Resize each page image to one of three portrait tile grids, selected by the page's measured glyph height (target ~10 px after resize). Training used exactly this routing. Reference implementation: ```python import cv2, statistics import numpy as np from PIL import Image BUCKETS = [("B1", (1152, 1536)), ("B2", (1536, 2304)), ("B3", (2304, 3072))] SQUARE = ("B0", (1152, 1152)) # square-ish inputs only (e.g. table crops) def glyph_height(img, max_side=2600): """Median connected-component height in native px — the routing signal.""" W, H = img.size s = min(1.0, max_side / max(W, H)) im = img.convert("L") if s < 1.0: im = im.resize((int(W * s), int(H * s)), Image.BOX) g = cv2.adaptiveThreshold(np.asarray(im), 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 10) n, _, stats, _ = cv2.connectedComponentsWithStats(g, connectivity=8) hs = [stats[i][3] for i in range(1, n) if 2 <= stats[i][3] <= 60 and 1 <= stats[i][2] <= 60 and stats[i][4] >= 4 and 0.08 <= stats[i][2] / max(stats[i][3], 1) <= 6.0] return statistics.median(hs) / max(s, 1e-6) if len(hs) >= 50 else None def prepare_page(img, target=10.0): """Route to the smallest bucket that reaches ~10px effective glyph height, then enhance.""" w, h = img.size if h and 0.9 <= w / h <= 1.15: bw, bh = SQUARE[1] else: g = glyph_height(img) bw, bh = BUCKETS[1][1] # default when unmeasurable if g: for _, (cw, ch) in BUCKETS: if g * min(cw / w, ch / h) >= target * 0.95: bw, bh = cw, ch break else: bw, bh = BUCKETS[-1][1] s = min(bw / w, bh / h) fit = img.resize((round(w * s), round(h * s)), Image.LANCZOS) gray = np.asarray(fit.convert("L")) e = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(gray) # CLAHE blur = cv2.GaussianBlur(e, (0, 0), 1.2) e = cv2.addWeighted(e, 1.8, blur, -0.8, 0) # unsharp return Image.fromarray(e).convert("RGB") ``` The checkpoint's `config.json` and `preprocessor_config.json` carry the matching `image_grid_pinpoints`. Do not remove or modify them; the tile packing must match training. ### Serving with vLLM ```bash vllm serve /path/to/shrew-ocr-preview \ --trust-remote-code --dtype bfloat16 \ --served-model-name shrew-ocr-preview \ --max-model-len 32768 --limit-mm-per-prompt '{"image":1}' \ --no-enable-prefix-caching ``` **Scaling note:** the model is small (2–5 GB weights). For batch serving on multi-GPU hosts, data-parallel replicas (`--data-parallel-size N`) outperform tensor parallelism substantially (+54% measured on a 4-GPU node) — prefer DP unless a single GPU cannot hold the weights. On memory-constrained GPUs keep `--max-num-batched-tokens` at 2048 or below: the vision encoder batches image tiles, and large prefill budgets can OOM the tower on high-tile pages. Request shape (OpenAI-compatible): ```json { "model": "shrew-ocr-preview", "temperature": 0, "max_tokens": 20000, "messages": [ {"role": "system", "content": "structured_extraction"}, {"role": "user", "content": [ {"type": "image_url", "image_url": {"url": "data:image/png;base64,"}}, {"type": "text", "text": "Extract the structured representation of this document page."}]} ] } ``` **Recommended: streaming repetition guard.** On hard pages the failure mode is degenerate repetition, not plausible-but-wrong output. Stream the completion, compute `len(window)/len(zlib.compress(window))` over a trailing ~2,000-character window every ~800 characters, and abort after 2 consecutive windows above ~15. Clean pages measure ~2; looping output exceeds 25. Penalty parameters (measured: 900 first-pass runs, 150 stratified pages × 6 decode arms on the production stack): `presence_penalty` 0.3 at first pass raised the first-pass success rate (valid JSON passing all schema and degeneration gates, no retry) 0.880→0.887 with extraction precision flat (0.949→0.949 median 10-gram precision vs ground truth), and is the reference server's first-pass default; 0.3–0.6 both measured fidelity-neutral on healthy pages. In a separate rescue experiment, a penalized retry (`presence_penalty` 0.6) recovered 12/12 sampled loop-failed Latin-script broadsheet pages with fidelity flat; dense non-Latin broadsheets did not recover at any penalty (see Limitations — that class is a training gap, not a decoding one). Do not use frequency or repetition penalties or `no_repeat_ngram_size`: ngram blocking suppresses JSON tokens that must repeat (`"bbox": [`); frequency penalties accumulate with each repeated occurrence, and repetition penalties apply to every previously-seen token — both degrade required schema tokens in long structured outputs. Do not use grammar-constrained (schema-enforced) decoding at first pass: it degrades table transcription severely (table one-shot 0.975→0.225), primarily by overrunning the token budget mid-table (grammar-constrained decoding transcribes exhaustively), with degraded table fidelity (0.483→0.280) on the pages that do finish; the reference server applies enforcement only on retry. Two caveats: the Results tables were measured with penalty-free greedy decoding (the presence-penalty recommendation comes from the separate decode matrix), and retry rescue was validated on Latin-script pages only — for pages whose text the vision tower cannot resolve, a penalized retry can convert a detectable loop into plausible hallucination (observed: a penalized retry on an unsupported-script page produced fluent output with zero n-gram overlap with the page), so validate retry output with the same window check and schema gates and mark it lower-confidence downstream. Which borderline pages loop varies with serving configuration (kernel paths, compilation settings); the overall loop rate does not. Compare deployments by loop rate over a fixed page set, not by which pages failed. ### Text modality The same model accepts born-digital text — HTML (emails, filings), markdown, source code, plain text — and returns the same 5-key output schema with `figures[].bbox` and `tables[].bbox` null. Do not route OCR output of scanned pages here; scanned pages go through the image modality. Request shape: same envelope, with the raw content as a single text part in place of the image. Send the content as-is — no wrapper, no instructions, no cleaning: ```json { "model": "shrew-ocr-preview", "temperature": 0, "max_tokens": 12000, "messages": [ {"role": "system", "content": "structured_extraction"}, {"role": "user", "content": [{"type": "text", "text": ""}]} ] } ``` Input sizing: send 2,000–9,000 characters (~500–2,500 tokens) per request; treat 13,000 characters as the ceiling (training inputs never exceeded it). Split longer documents at structural boundaries (headings, sections) and send one request per section. Each request in the recommended range yields roughly 2–6 semantic chunks (median chunk ~820 characters). ## Results — OHR-Bench document RAG Measured on the [OHR-Bench](https://huggingface.co/datasets/opendatalab/OHR-Bench) corpus (ICCV 2025): 1,261 PDFs / 8,561 pages across 7 domains (textbook, law, finance, newspaper, manual, academic, administration). Every page runs through our full production path (rasterize → bucket routing → model → schema gates → assembly); each system's structured output is chunked under the same budget, embedded with `nvidia/llama-nemotron-embed-vl-1b-v2` ("nemotron-vl"), and scored as retrieval hit@5 / MRR@10 over OHR-Bench's ~8.5k human-verified Q&A pairs. These are our own retrieval-harness measurements, not official OHR-Bench generation (LCS/F1) numbers. `gt` is retrieval over OHR-Bench's human ground-truth structured data; MinerU and PaddleOCR outputs were run through the identical chunking and indexing. **Text retrieval, hit@5 / MRR@10 by evidence type** (higher is better, best per row in bold): | evidence type | gt (human) | MinerU | PaddleOCR | shrew bf16 | shrew INT8 | |---|---|---|---|---|---| | plain text | .853 / .781 | .872 / .791 | .887 / .812 | **.896** / **.814** | .889 / .808 | | multi-evidence | .859 / .769 | .911 / .838 | .867 / .772 | .889 / .770 | **.933** / **.840** | | table | .864 / .756 | **.889** / **.761** | .858 / .741 | .859 / .737 | .870 / .749 | | formula | .871 / .791 | .895 / .807 | .878 / .807 | .905 / .805 | **.913** / **.821** | | chart | **.776** / **.665** | .594 / .491 | .546 / .434 | .673 / .551 | .711 / .582 | | vision | .693 / .505 | .597 / .458 | .660 / .525 | .664 / .534 | **.714** / **.571** | | reading order† | .844 / .757 | **.902** / **.825** | .877 / .786 | .122 / .105 | .077 / .070 | † Known failure. OHR-Bench draws reading-order queries almost entirely from dense broadsheet newspaper scans, which fall in this model's repetition-loop failure class (see Limitations); with those pages unparsed, the attainable ceiling is ~0.14. The bf16/INT8 gap on this row is a harness-gating artifact, not quantization: the bf16 number is inflated by hallucinated filler that the final output gate rejects. Treat broadsheet reading order as unsupported in this release. **Figure/table localization** vs our own frozen human-annotated gold subset — 551 corpus pages / 1,100 boxes, not an OHR-Bench artifact (greedy match at IoU ≥ 0.5): | arm | figure recall@0.5 | figure mean IoU | table recall@0.5 | table mean IoU | |---|---|---|---|---| | bf16 | 0.627 | 0.795 | 0.598 | 0.803 | | INT8 | 0.618 | 0.796 | 0.603 | 0.804 | **Reliability:** 84.7% (bf16) / 84.35% (INT8) of pages produce valid schema-complete JSON on the first pass; 97.2% after mechanical schema coercion. Hard failures are ~3% of pages, concentrated in the dense-broadsheet loop class, and terminate as repetition-guard aborts rather than silent bad output. **Quantization cost:** the INT8 variant matches or beats bf16 on 5 of 7 retrieval types, is within noise on localization, and measures +0.25% domain perplexity. See the GPTQ-8bit repo for serving-throughput numbers. ## Limitations - **Difficult documents.** Dense broadsheet scans (historical newspapers), low-resolution scans of dense layouts, and pages whose text the vision tower cannot resolve can produce repetition loops instead of output. The streaming guard above converts these into fast, detectable failures. Work on this class is ongoing. - **CJK, Cyrillic, Arabic and handwriting are unsupported.** The model is trained and evaluated on Latin-script print; non-Latin scripts loop or transcribe poorly. Multilingual coverage is planned. - **Bounding boxes are model-supervised.** Figure/table geometry is trained from model-generated labels with automated repair; boxes are generally tight but can under- or over-shoot on unusual layouts. Pad boxes outward slightly when cropping; do not treat edges as pixel-exact. - **One page per request.** The model has no cross-page state; feed multi-page documents page by page and assemble downstream. - **Reading order on dense broadsheets** scores near the failure floor (see Results). Same failure class as the first bullet. ## Versions | variant | precision | size | notes | |---|---|---|---| | shrew-ocr-preview | bf16 (this repo) | 7.5 GB | reference quality | | [shrew-ocr-preview-GPTQ-8bit](https://huggingface.co/btbtyler09/shrew-ocr-preview-GPTQ-8bit) | INT8 LM / bf16 vision | 4.9 GB | ~1.8× serving throughput, +0.25% domain perplexity; serve with `--dtype half` | | [shrew-ocr-preview-GGUF](https://huggingface.co/btbtyler09/shrew-ocr-preview-GGUF) | Q8_0 or f16 LM / f16 vision | 3.6–6.8 GB | llama.cpp; full context per slot required (`-c` = N × 32768) | | [shrew-ocr-preview-lora](https://huggingface.co/btbtyler09/shrew-ocr-preview-lora) | LoRA adapter (r=256, bf16) | 2.0 GB | for composition / continued training — serve the merged variants instead | This is a preview: weights update in place under these names as the model improves. Each weight push's commit message records the training and calibration generation — pin a commit (`revision=`) for reproducibility. Base model: ibm-granite/granite-vision-4.1-4b (Apache 2.0). The vision tower is unchanged from the base; all fine-tuning lives in the language model.