You need to agree to share your Hugging Face information to access this model

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

Log in or Sign Up to review the conditions and access this model content.

Netra · เนตร logo, an eye whose lower lid is a line of text. Reads Thai accounting documents — text and key fields, in a 2B model. v0.2, 2B, Thai / English, Apache-2.0. Beside it, one synthetic tax invoice: printed ใบกำกับภาษี becomes "doc_type": "tax_invoice", 29 ก.ย. 2570 becomes "date": "2027-09-29", 0-7552-78249-83-1 becomes "tax_id": "0755278249831", 4,183.70 becomes "total": 4183.7

Netra-2B v0.2

Status: released — v0.2 (2026-10-26), replacing v0.1 (2026-10-22). v0.2 is training round 13. Use it with the loop guard; the release gate is passed only with the guard on. Against v0.1, both with the guard, v0.2 extracts much more from real documents (0.709 vs 0.546) and reads full pages slightly worse (0.693 vs 0.706, within noise). It does not beat v0.1 on every measure; see Release gate. v0.1 remains in this repository's commit history. The trust gate checks every field, cross-reads two independent OCR passes, and checks every party name against a character-level reader (Trust gate).

Netra (เนตร) reads Thai business and accounting documents. Give it a photo or scan of a page and it returns the text as Markdown, or the accounting fields as one JSON object. It is built for developers who process Thai tax invoices, receipts, transfer slips, 50 ทวิ withholding-tax certificates and billing notes. Netra is a 2B vision-language model, fine-tuned from typhoon-ai/typhoon-ocr1.5-2b, and released under Apache-2.0.

Highlights

  • Real documents, with the loop guard (as shipped): reading 0.693 vs 0.666 for the base, extraction 0.709 vs 0.406 (Netra-Bench-Real, 147 real Thai finance documents). v0.1 with the guard: 0.706 / 0.546.
  • ThaiOCRBench, with the loop guard: lower character error rate than the base on all three page tasks (ThaiOCRBench); the Document-parsing margin (0.2173 vs 0.2179) is a tie in practice.
  • Hold-out sets: 5 wins, 2 ties and no loss against the base on 802 rows never used to choose a model (hold-out).
  • Real receipts: 0.638 of 105 fields right on 8 real receipts (test only, not released) vs 0.495 for v0.1.
  • Ahead of the larger 3B sibling on real documents: 0.680 / 0.713 vs 0.669 / 0.474 for typhoon-ocr1.5-3b-qat (reading / extraction, no loop guard, same benchmark and settings).
  • Trust gate: 0 wrong answers accepted on three synthetic test sets (387 documents); it accepts only 32 of them (3–11% per set) and sends the rest to a person (Trust gate).
  • Receipt key mapping (NECTEC thairocr) was measured on v0.1 only (thairocr).

Quick start

Define the two prompts, then run one page. Tested with transformers 5.17.0.

OCR_PROMPT and KIE_PROMPT (copy these exactly)
OCR_PROMPT = """Extract all text from the image.

Instructions:
- Only return the clean Markdown.
- Do not include any explanation or extra text.
- You must include all information on the page.

Formatting Rules:
- Tables: Render tables using <table>...</table> in clean HTML format.
- Equations: Render equations using LaTeX syntax with inline ($...$) and block ($$...$$).
- Images/Charts/Diagrams: Wrap any clearly defined visual areas (e.g. charts, diagrams, pictures) in:

<figure>
Describe the image's main elements (people, objects, text), note any contextual clues (place, event, culture), mention visible text and its meaning, provide deeper analysis when relevant (especially for financial charts, graphs, or documents), comment on style or architecture if relevant, then give a concise overall summary. Describe in Thai.
</figure>

- Page Numbers: Wrap page numbers in <page_number>...</page_number> (e.g., <page_number>14</page_number>).
- Checkboxes: Use ☐ for unchecked and ☑ for checked boxes."""

KIE_PROMPT = """Extract the accounting data from this Thai document image.

Return only one JSON object, no explanation, with these keys (null when the document has no such field):
doc_type, doc_title, doc_no, book_no, date, time, due_date, seller, buyer, payer, payee, items, subtotal, discount,
vat_base, vat_rate, vat, vat_included, wht_rate, wht, total, net_paid, amount_in_words, currency, ref_doc_no, ref_date, payment, extra.
Parties are {name, tax_id, branch, address, phone}; items are [{desc, qty, unit, unit_price, amount}].
Dates YYYY-MM-DD (convert พ.ศ. to ค.ศ.). Tax id = 13 digits, no dashes. Branch = 5 digits, head office "00000".
Write all digits as Arabic numerals. Copy names exactly as printed. Money printed blank or "-" is 0. Numbers must be JSON numbers."""
import torch
from PIL import Image
from transformers import AutoModelForImageTextToText, AutoProcessor
from loop_guard import degenerate  # loop_guard.py from this repo

model_id = "botnick/Netra-2B"
model = AutoModelForImageTextToText.from_pretrained(model_id, dtype=torch.bfloat16, device_map="auto")
processor = AutoProcessor.from_pretrained(model_id)

def load(path, max_side=1400):
    img = Image.open(path).convert("RGB")
    if max(img.size) > max_side:
        k = max_side / max(img.size)
        img = img.resize((round(img.width * k), round(img.height * k)), Image.LANCZOS)
    return img

def run(img, prompt, max_new_tokens, guard=False):
    messages = [{"role": "user", "content": [{"type": "image", "image": img}, {"type": "text", "text": prompt}]}]
    inputs = processor.apply_chat_template(messages, tokenize=True, add_generation_prompt=True,
                                           return_dict=True, return_tensors="pt").to(model.device)
    def gen(**kw):
        out = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False, **kw)
        return processor.batch_decode(out[:, inputs["input_ids"].shape[1]:], skip_special_tokens=True)[0]
    text = gen(repetition_penalty=1.05)
    if guard and degenerate(text):  # loop guard: retry once with stronger anti-repetition
        text = gen(repetition_penalty=1.25, no_repeat_ngram_size=16)
    return text

page = load("invoice.png")
markdown = run(page, OCR_PROMPT, 4096, guard=True)   # page reading
kie_raw = run(page, KIE_PROMPT, 2048)                # accounting JSON

Recommended settings: greedy decoding, repetition_penalty=1.05, long side of the image at most 1400 px, max_new_tokens 4096 for reading and 2048 for extraction, and the loop guard on reading output. The shipped generation_config.json keeps the base's sampling defaults (do_sample=true, temperature=0.7), so pass do_sample=False and repetition_penalty=1.05 explicitly, as above.

For batch inference, see vLLM (offline batch). To ask for your own keys, see key mapping.

Results

This chart shows v0.1 (2026-10-22); it is kept because its thairocr row was measured on v0.1 only. v0.2 numbers are in the tables under Details.

Bar chart, Netra-2B v0.1 vs the base typhoon-ocr1.5-2b, higher is better. Netra-Bench-Real: page reading 0.666 to 0.709, information extraction 0.406 to 0.575. NECTEC thairocr receipt key mapping: key accuracy 0.104 to 0.528, valid JSON 0.416 to 0.973. Synthetic accounting KIE: field accuracy 0.037 to 0.908, valid JSON 0.750 to 1.000, every field right 0.000 to 0.542.

v0.1 improves on its base on every benchmark in the chart, and v0.2 raises real-document extraction further (0.575 → 0.713 without the guard). The largest gains are in structured output: asked for specific keys, it returns valid JSON with the right keys far more often. The real-document numbers come from a small set (34 extraction documents), and the synthetic KIE comparison is not a fair head-to-head because the base never saw that schema. Sources and caveats for each number are in Details.

สรุปภาษาไทย (Thai Executive Summary)

  • ภาพรวมโมเดล: เนตร (Netra-2B) เวอร์ชัน 0.2 (26 ต.ค. 2569) พัฒนาต่อยอดจาก Typhoon OCR 1.5 2B โดย fine-tune ด้วยเทคนิค LoRA สำหรับเอกสารทางบัญชีและธุรกิจไทยโดยเฉพาะ (เช่น ใบกำกับภาษีเต็มรูป, ใบกำกับภาษีอย่างย่อ, ใบลดหนี้/ใบเพิ่มหนี้, หนังสือรับรองการหักภาษี ณ ที่จ่าย ตามมาตรา 50 ทวิ, สลิปโอนเงิน และใบวางบิล) ตลอดจนเอกสารทางราชการ เป็นผลจากการเทรนรอบที่ 13 แล้วผสานน้ำหนักโมเดล (merge) กลับเข้าสู่โมเดลฐานเต็มรูปแบบ (1.0)
  • รูปแบบผลลัพธ์: แปลงเนื้อหาบนหน้าเอกสารเป็น Markdown (แสดงโครงสร้างตารางด้วย HTML) และสกัดข้อมูลสำคัญทางบัญชี (Key Information Extraction: KIE) ให้อยู่ในรูป JSON ตามโครงสร้างข้อมูล (schema) 28 คีย์มาตรฐานเดียว
  • ประสิทธิภาพบนเอกสารจริง (Netra-Bench-Real, 147 ฉบับ, เปิด loop guard): อ่านเอกสาร 0.693 (โมเดลฐาน 0.666, v0.1 0.706) และสกัดข้อมูล 0.709 (โมเดลฐาน 0.406, v0.1 0.546) ถ้าไม่เปิด guard: 0.680 / 0.713 ซึ่งดีกว่าโมเดลขนาดใหญ่กว่าอย่าง typhoon-ocr1.5-3b-qat (0.669 / 0.474)
  • ใบเสร็จจริง: ถูก 0.638 จาก 105 ช่องข้อมูลในใบเสร็จจริง 8 ใบ (ใช้ทดสอบเท่านั้น ไม่เผยแพร่) เทียบกับ v0.1 ที่ 0.495
  • ชุดทดสอบแบบ Hold-out (802 รายการ): ไม่แพ้โมเดลฐานในชุดใดเลย ชนะ 5 ชุด เสมอ 2 ชุด
  • ข้อกำหนดสำคัญในการประมวลผล (Loop Guard): ต้องเปิดระบบป้องกันข้อความวนซ้ำ (loop guard) จึงจะผ่านเกณฑ์ปล่อย คือดีกว่าโมเดลฐานใน ThaiOCRBench ครบทั้ง 3 ภารกิจ (หมวดเอกสารทั่วไปชนะเฉียดฉิว CER 0.2173 เทียบ 0.2179 ถือว่าเสมอ) หากไม่เปิด หมวดเอกสารทั่วไปจะแย่กว่าโมเดลฐาน (CER 0.242 เทียบ 0.218)
  • เทียบกับ v0.1: ความสามารถในการสกัดข้อมูลดีขึ้นอย่างมาก แต่การอ่านเนื้อหาทั้งหน้ามีประสิทธิภาพลดลงเล็กน้อย (0.693 เทียบกับ 0.706 เมื่อเปิดใช้งาน Guard ทั้งคู่) และผล ThaiOCRBench ทั้ง 3 หมวดยังแย่กว่า v0.1 (ค่าความผิดพลาด CER สูงกว่า) โดยผลการจับคู่ใบเสร็จ (thairocr) ยังคงอ้างอิงตามค่าของ v0.1 เท่านั้น
  • ระบบตรวจยืนยัน (Trust Gate): ระบบไม่รับคำตอบที่ผิดเลยแม้แต่ใบเดียวจากชุดทดสอบสังเคราะห์ 3 ชุด (รวม 387 ใบ) โดยระบบรับรองโดยอัตโนมัติ 32 ใบ (3–11% ต่อชุด) และใบที่เหลือจะถูกส่งให้เจ้าหน้าที่ตรวจสอบ ทั้งนี้ ชื่อคู่ค้าทุกชื่อจะต้องตรงกับผลการอ่านทีละตัวอักษรจากตัวอ่านลำดับที่สาม ซึ่งปิดการใช้งานพจนานุกรมไว้
  • ข้อควรระวังในการนำไปใช้: ข้อมูลทางบัญชีที่ใช้เทรนเป็นชุดข้อมูลสังเคราะห์ (synthetic data) ทั้งหมด จึงห้ามนำผลลัพธ์ไปบันทึกบัญชีโดยอัตโนมัติ เว้นแต่จะผ่านการตรวจสอบยืนยัน (Trust Gate) เทียบกับผลอ่าน OCR หรือมีผู้ตรวจทาน และไม่อนุญาตให้นำไปประมวลผลบัตรประจำตัวประชาชนหรือเอกสารส่วนบุคคล

Intended use & limits

Use it for:

  • Transcribing Thai (and Thai/English) business documents to Markdown for search, archiving and downstream parsing.
  • Extracting accounting fields from Thai tax invoices, abbreviated tax invoices, credit/debit notes, 50 ทวิ withholding-tax certificates, bank transfer slips and billing notes into JSON.
  • The extraction stage of a pipeline in which every JSON answer passes a deterministic check and failing documents go to a person.

Know its limits:

  • The accounting training data is synthetic. No real Thai tax invoice, receipt, slip, 50 ทวิ or billing note was used for training. Expect lower accuracy on real layouts than the synthetic numbers suggest.
  • Do not book entries automatically unless each answer passes the trust gate with an OCR cross-read, or a person reviews it. The model can produce plausible but wrong tax IDs, dates and names.
  • Real phone photos of receipts are still error-prone. The model may recompute VAT on top of a total that already includes it, misread the total, or fill payment fields (bank, account) the receipt does not print.
  • No ID cards or personal documents (ID cards, passports, house registrations, medical records). The model was not trained or evaluated on them, and processing them raises PDPA obligations this card does not address.
  • Not for general visual question answering or for languages other than Thai and English.

The full list is in Known limitations.

Details

Modes and output formats

Netra-2B is typhoon-ai/typhoon-ocr1.5-2b (Qwen3-VL-2B architecture) with a LoRA adapter trained on Thai accounting and government documents (training round 11, continued for one epoch in round 13), merged into the base at full strength. One set of weights does three jobs:

Mode Input Output
Page reading page image + OCR_PROMPT (the Typhoon OCR prompt) Markdown, tables as one-line HTML, <figure> descriptions
Accounting extraction (KIE) page image + KIE_PROMPT one JSON object, fixed 28-key schema
Key mapping page image + a prompt naming the keys you want JSON with exactly the asked keys

Reading output:

Element Format
Text Markdown; title as # …; reading order left column, then right column, top to bottom
Tables HTML on one line: <table><tr><td>…</td></tr></table>; no <th>, no <thead>/<tbody>; colspan/rowspan kept
Pictures, logos, stamps, charts <figure>…</figure> with a Thai description
Page numbers <page_number>…</page_number>
Checkboxes ☐ / ☑
Equations LaTeX, $…$ and $$…$$
Digits in reading mode as printed (Thai digits stay Thai digits; a dashed tax ID stays dashed)

KIE JSON schema

Every object has all 28 keys. A field the document does not have is null.

Key Type Notes
doc_type string tax_invoice, abbreviated_tax_invoice, credit_note, debit_note, wht_certificate, transfer_slip, billing_note; also invoice, quotation, receipt, payment_voucher
doc_title string printed title, plus the English subtitle when printed
doc_no string document number; for a transfer slip, the transaction reference
book_no string เล่มที่ (receipt, 50 ทวิ)
date, due_date YYYY-MM-DD Gregorian, converted from พ.ศ.
time HH:MM[:SS] POS and transfer slips
seller, buyer, payer, payee party {name, tax_id, branch, address, phone}; tax_id 13 digits, no dashes; branch 5 digits, head office "00000", null when none is printed; name without the branch suffix
items list {desc, qty, unit, unit_price, amount}; billing note: {doc_no, date, due_date, desc, amount}; [] when none
subtotal, discount, vat_base, vat, wht, total, net_paid number discount 0 when none; total includes VAT, before WHT; net_paid = total − WHT
vat_rate, wht_rate number percent; wht_rate null when rates are mixed
vat_included bool line prices already include VAT
amount_in_words string จำนวนเงินตัวอักษรภาษาไทยโดยไม่ต้องใส่วงเล็บ (50 ทวิ: จำนวนภาษีที่หักและนำส่งไว้)
currency string "THB"
ref_doc_no, ref_date string, date original tax invoice referenced by a credit/debit note
payment object method and details (cash / transfer / cheque / card / QR / PromptPay fields)
extra object ข้อมูลเฉพาะตามประเภทเอกสาร เช่น 50 ทวิ: form, income[], funds; ใบลดหนี้/ใบเพิ่มหนี้: adjustment, difference

Normalisation: all digits Arabic. Numbers are JSON numbers (int when whole, else at most 2 decimals). Money printed blank or - is 0. Masked account numbers and names on slips stay masked as printed.

Shape of an answer (fictitious values):

{"doc_type": "tax_invoice", "doc_title": "ใบกำกับภาษี/ใบเสร็จรับเงิน TAX INVOICE/RECEIPT", "doc_no": "INV-0001",
 "book_no": null, "date": "2026-01-15", "time": null, "due_date": "2026-02-14",
 "seller": {"name": "บริษัท ตัวอย่าง จำกัด", "tax_id": "0000000000001", "branch": "00000", "address": "…", "phone": null},
 "buyer": {"name": "ห้างหุ้นส่วนจำกัด ทดสอบ", "tax_id": "0000000000001", "branch": "00000", "address": "…", "phone": null},
 "payer": null, "payee": null,
 "items": [{"desc": "ค่าบริการ", "qty": 1, "unit": null, "unit_price": 1000, "amount": 1000}],
 "subtotal": 1000, "discount": 0, "vat_base": 1000, "vat_rate": 7, "vat": 70, "vat_included": false,
 "wht_rate": null, "wht": null, "total": 1070, "net_paid": null,
 "amount_in_words": "หนึ่งพันเจ็ดสิบบาทถ้วน", "currency": "THB", "ref_doc_no": null, "ref_date": null,
 "payment": null, "extra": {"po_no": null, "salesperson": null, "copy": null, "note": null}}

Loop guard

On a small share of pages the model repeats one line or phrase, often until the token limit. Without the guard, degenerate() flags 7 of the 120 v0.2 ThaiOCRBench outputs (v0.1: 9). The guard detects the loop and generates once more with stronger anti-repetition. degenerate() ships in loop_guard.py and needs only the standard library:

from loop_guard import degenerate   # True if a non-trivial line repeats ≥ 8× or a 15–80 char span repeats ≥ 6× in a row

text = generate(page, do_sample=False, repetition_penalty=1.05)
if degenerate(text):
    text = generate(page, do_sample=False, repetition_penalty=1.25, no_repeat_ngram_size=16)

What was measured:

  • On ThaiOCRBench the guard was measured with transformers generate (see ThaiOCRBench). There the first pass used repetition_penalty=1.1, not the 1.05 recommended here. It regenerated 8 of 120 v0.2 outputs.
  • On Netra-Bench-Real it was measured with vLLM, whose SamplingParams has no no_repeat_ngram_size: an output is regenerated once with repetition_penalty=1.25 when degenerate() flags it or it hits the token limit (12 of 147 v0.2 outputs). The transformers retry above has not been measured with vLLM; to use it, re-run the flagged pages through transformers.
  • The retry also breaks legitimately repeated table cells. On ThaiOCRBench tables it costs v0.2 0.016 CER compared with no guard (0.302 vs 0.286; still better than the base, 0.308).

vLLM (offline batch)

This is the configuration used for Netra-Bench-Real and the hold-out sets below (vLLM 0.31.0; loop guard only where a row says so).

from transformers import AutoProcessor
from vllm import LLM, SamplingParams

model_id = "botnick/Netra-2B"
processor = AutoProcessor.from_pretrained(model_id)
llm = LLM(model=model_id, max_model_len=8192, max_num_seqs=32, seed=0,
          limit_mm_per_prompt={"image": {"count": 1, "width": 1400, "height": 1400}, "video": 0})

def chat(prompt):
    return processor.apply_chat_template(
        [{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": prompt}]}],
        tokenize=False, add_generation_prompt=True)

read = SamplingParams(temperature=0.0, max_tokens=4096, repetition_penalty=1.05)
extract = SamplingParams(temperature=0.0, max_tokens=2048, repetition_penalty=1.05)

pages = [load(p) for p in ["a.png", "b.png"]]          # load() from the quick start
reqs = [{"prompt": chat(OCR_PROMPT), "multi_modal_data": {"image": im}} for im in pages] + \
       [{"prompt": chat(KIE_PROMPT), "multi_modal_data": {"image": im}} for im in pages]
outs = llm.generate(reqs, [read] * len(pages) + [extract] * len(pages))

An OpenAI-compatible server (vllm serve botnick/Netra-2B --max-model-len 8192) accepts the same prompts. Send the image as an image_url content part and set temperature=0, repetition_penalty=1.05. The server path has not been benchmarked for this card.

Key mapping with your own keys

Name the keys you want. The answer uses exactly those keys, in that order, with null when a key is absent. The prompt decides whether values are copied verbatim or normalised. An example in the style of the training prompts:

ดึงข้อมูลต่อไปนี้จากเอกสาร ตอบเป็น JSON อย่างเดียว:
เลขที่ใบกำกับภาษี
วันที่ออกเอกสาร
ยอดรวมทั้งสิ้น
Seller Tax ID
(ตัวเลขเป็นตัวเลข, วันที่แบบ YYYY-MM-DD, ไม่มีให้ใส่ null)

Ask for "values exactly as printed" instead and the answer keeps the page's own strings (Thai digits, commas and dashes).

Release gate

The release rule was written down before the round-5 results were read: the model must lose to the base on no hold-out set and no ThaiOCRBench task, and since 2026-10-22 the trust gate must accept no wrong answer on any synthetic test set. v0.2 passes only with the loop guard. Without it, ThaiOCRBench Document parsing is worse than the base (CER 0.242 vs 0.218). With it, Document parsing is 0.2173 vs 0.2179: a pass by the rule, not evidence of a real gain at n = 40. The guard is therefore part of the recommended inference.

The rule compares with the base, not with the previous release. v0.2 does not beat v0.1 everywhere:

  • Netra-Bench-Real reading with the guard is 0.013 lower (paired difference −0.0132, standard error 0.0140).
  • ThaiOCRBench CER with the guard is higher than v0.1's on all three tasks (Full-page 0.343 vs 0.340, Document parsing 0.217 vs 0.172, Table 0.302 vs 0.293).
  • Hold-out FinTabNet is 0.804 vs 0.814.

It replaces v0.1 because extraction gains are large: Netra-Bench-Real extraction with the guard +0.163 (standard error 0.066), and 0.638 vs 0.495 of 105 fields on 8 real receipts (test only, not released). The replacement was a maintainer decision, not an automatic one.

Two candidates were tested against the rule. Round 13 at merge strength 0.85 was tested first and failed (Document parsing 0.2227 vs 0.2179 with the guard). Strength 1.0 was then tested on the same 120 pages and passed. Choosing between two strengths on the gate set makes that pass slightly optimistic.

Round 13 was first blocked (2026-10-24) by the trust gate, which accepted wrong shop names spelled one mark off. Rounds 14 and 15 added training data for that error and were not released (see Rounds). Instead the gate gained a character-level third reader on party names (2026-10-26), with which round 13 accepts no wrong answer. That check was added after the false accepts on these test sets had been seen, so the gate's 0 is measured on sets that are no longer fully unseen for the gate.

Netra-Bench-Real

147 real Thai documents selected from the ThaiOCRBench test split (typhoon-ai/ThaiOCRBench, CC BY-SA 4.0, human-annotated). Selection: category in Finance, Retail, Food & Beverage, Utilities or Insurance, and the question or answer contains a money/tax keyword (ใบเสร็จ, ใบกำกับ, receipt, invoice, ภาษี, VAT, ยอด, บาท, ชำระ, สลิป, โอน).

  • Test only. No ThaiOCRBench image appears in any training source (checked by image path and ID against all 23 training files of rounds 11 and 13, the two rounds v0.2 was trained in; content-level near-duplicates were not checked).
  • Category mix: Finance 59, Food & Beverage 29, Retail 24, Insurance 21, Utilities 14.
  • Reading (113 docs; Full-page OCR, Document parsing, Table parsing, Text recognition, Handwritten, Fine-grained): the model gets OCR_PROMPT. Score = 1 − min(1, Levenshtein / reference length) after removing <figure> blocks and tags, markdown symbols and extra whitespace on both sides. Table parsing scores only the <table>…</table> span. Higher is better.
  • Extraction (34 docs; Key information extraction, Key information mapping): the model gets the benchmark's own question. Score = share of reference values found verbatim (whitespace-normalised) in the output. Higher is better.
  • Engine: vLLM 0.31.0, greedy, repetition_penalty=1.05, settings as in the quick start. No loop guard unless the row says "+ loop guard" (the vLLM retry described in Loop guard). Same 147 document IDs for every model.
Model Merge strength Reading (113) Extraction (34)
typhoon-ocr1.5-2b (base) — 0.6659 0.4058
typhoon-ocr1.5-3b-qat (larger sibling, reference) — 0.6691 0.4738
round 3 (rejected) 1.0 0.5729 0.4881
round 4 1.0 0.6415 0.506
round 4 (previous candidate, not shipped) 0.78 0.6663 0.483
round 5 0.70 0.6819 0.5345
round 5 0.78 0.6841 0.5422
round 5 0.85 0.6798 0.533
round 5 — Netra-2B v0.1 (previous release) 1.0 0.7091 0.575
round 5 — v0.1 + loop guard 1.0 0.7061 0.5456
round 6 (not released) 0.85 0.6945 0.5324
round 7 (not released) 0.85 0.6751 0.5763
round 8 (not released) 0.85 0.6795 0.5652
round 9 (not released) 1.0 0.659 0.5562
round 9 (not released) 0.78 0.69 0.5303
round 10 (not released) 1.0 0.6782 0.5823
round 11 (not released) 0.85 0.6762 0.5811
round 12 (not released) 1.0 0.6653 0.5639
round 13 (not released) 0.85 0.6737 0.6741
round 13 + loop guard (failed the gate) 0.85 0.7030 0.6800
round 13 — Netra-2B v0.2 (released) 1.0 0.6802 0.7126
round 13 — v0.2 + loop guard (recommended) 1.0 0.6929 0.7089

Other models, same 147 documents, harness and prompts (reference only):

Model Reading (113) Extraction (34) Note
datalab-to/chandra-ocr-2 0.6998 0.0282 page reader; does not answer extraction questions as JSON, so its extraction score is not a fair comparison
Qwen/Qwen3-VL-8B-Instruct 0.5412 0.6577 4× larger general model; best extraction measured here
google/gemma-4-12B-it (w4a16 quantised) 0.3828 0.6663 quantised build; extraction strong, reading weak
Pathumma-llm-vision 3.0.0 0.5474 0.3626

v0.2 has the best extraction score measured here (0.713, ahead of the 8B and 12B general models). Without the guard its reading (0.680) is below v0.1 (0.709) and chandra-ocr-2 (0.700); with the guard it is 0.693.

How to read this table:

  • This benchmark is the selection set: rounds and merge strengths were compared on it, so the numbers of the chosen models are somewhat optimistic. The hold-out sets and ThaiOCRBench (below) are the independent checks.
  • Paired difference, v0.2 − base, computed per document, no guard: reading +0.0142 (standard error 0.0225), extraction +0.3068 (standard error 0.0822). The reading difference is within noise; the extraction gain is about 3.7 standard errors. Per-model standard error of the mean is about 0.03 for reading and 0.07 for extraction.
  • Paired difference, v0.2 − v0.1, both with the guard: reading −0.0132 (standard error 0.0140), extraction +0.1633 (standard error 0.0663).
  • Per document, v0.2 vs base (no guard): 63 better, 50 worse, 34 equal. v0.1 vs base: 62 better, 54 worse, 31 equal.
  • Outputs that hit the 4,096-token limit, no guard: v0.2 9 of 147, v0.1 7.
  • The typhoon-ocr1.5-3b-qat row was run with the same harness, prompts and vLLM settings.
  • Round 1–3 reports quote HF-generate numbers for the base (0.657 / 0.434). Compare numbers within one engine only.

Per task (mean score, same runs):

Task n Base v0.1 v0.1 + guard v0.2 v0.2 + guard
Table parsing 42 0.650 0.716 0.698 0.665 0.675
Document parsing 39 0.728 0.756 0.767 0.736 0.766
Key information extraction 17 0.516 0.424 0.424 0.752 0.741
Key information mapping 17 0.296 0.726 0.667 0.673 0.677
Text recognition 14 0.582 0.588 0.588 0.587 0.587
Full-page OCR 10 0.823 0.896 0.892 0.899 0.888
Handwritten content extraction 6 0.531 0.563 0.564 0.500 0.500
Fine-grained text recognition 2 0.000 0.000 0.000 0.000 0.000

Tasks with n ≤ 10 move by large steps per document; do not read trends into them. Key information extraction (free-form questions about menus, receipts and similar) rose from below the base in v0.1 (0.424 vs 0.516) to 0.752. Key information mapping (pair given items with given values) is below v0.1 without the guard (0.673 vs 0.726). Table parsing is below v0.1 (0.665 vs 0.716 without the guard). Handwriting (n = 6) is below the base.

Throughput (vLLM 0.31.0, one RTX 5090, gpu_memory_utilization=0.4, max_num_seqs=32): 147 documents generated in 59.6 s by v0.2 (v0.1: 70.9 s at 0.6). Not a controlled latency benchmark.

ThaiOCRBench

40 documents per task from the ThaiOCRBench test split (the same 120 documents for every row). Metric: CER after tag/whitespace normalisation (table: table span only); lower is better. 22 of these 120 documents are also in Netra-Bench-Real. Engine: transformers generate (Unsloth 2026.10.7, transformers 5.17.0), greedy, repetition_penalty=1.1, max_new_tokens=4096; loop-guard retry with repetition_penalty=1.25, no_repeat_ngram_size=16. Any higher mean CER than the base counts as a loss under the release gate.

Grouped bar chart of ThaiOCRBench character error rate, lower is better, 40 documents per task. Full-page OCR: base 0.359, v0.1 with loop guard 0.340, v0.2 without guard 0.355, v0.2 with loop guard 0.343. Document parsing: 0.218, 0.172, 0.242, 0.217. Table parsing: 0.308, 0.293, 0.286, 0.302.

Task (n = 40) Base v0.1 + loop guard v0.2, no guard v0.2 + loop guard (recommended) Pass? (v0.2 + guard)
Full-page OCR CER 0.3586 0.3401 0.3550 0.3427 yes (better)
Document parsing CER 0.2179 0.1722 0.2422 0.2173 yes (by 0.0006: a tie in practice)
Table parsing CER 0.3076 0.2929 0.2861 0.3017 yes (better)
Outputs with whole-output CER ≥ 0.9 (of 120) 21 19 22 20 —
  • Without the guard, v0.2 fails the rule (Document parsing 0.242 vs 0.218). degenerate() flags 7 of its 120 outputs; the guard regenerated 8.
  • With the guard, v0.2 is better than the base on all three tasks but worse than v0.1 with the guard on all three.
  • Round 13 at merge strength 0.85 failed the rule with the guard (Document parsing 0.2227). Strength 1.0 was tested afterwards on the same 120 pages, so its narrow pass is a second look at the same set (see Release gate).
  • v0.1 (for reference): without the guard it failed on Full-page OCR (0.386 vs 0.359); 6 pages scored CER ≈ 1.0 (4 repetition loops, 2 diagram-heavy pages transcribed inside one <figure>, which the metric strips).

Hold-out sets (never used for selection)

802 rows from seven sets that were never used to choose a model. An image-path leak check of every training file of rounds 11 and 13 (119,742 trainable images after the evaluation images are removed) against all 802 images found 0 overlap. Scoring is the same as Netra-Bench-Real reading/extraction per row (vLLM, repetition_penalty=1.05, no loop guard). Difference = v0.2 − base per row, with a paired bootstrap 95% CI (2,000 resamples). "win" and "loss" are used only when the CI excludes 0.

Dot-and-interval chart of the paired difference v0.2 minus base per hold-out set with 95% confidence intervals. Key mapping +0.692, WildReceipt +0.440, PubTabNet +0.241, FinTabNet +0.076, General Thai pages +0.005 are wins; Thai government documents +0.033 and DocAtlas -0.034 are ties. No set is a loss.

Set n Base v0.1 v0.2 Δ (v0.2 − base) 95% CI Verdict
Thai government documents (real) 34 0.570 0.580 0.603 +0.033 [−0.041, +0.108] tie
FinTabNet tables (real) 150 0.727 0.814 0.804 +0.076 [+0.052, +0.102] win
PubTabNet tables (real) 150 0.572 0.787 0.813 +0.241 [+0.203, +0.283] win
DocAtlas Thai pages (real) 7 0.631 0.526 0.597 −0.034 [−0.101, +0.012] tie
WildReceipt key mapping (real) 61 0.347 0.728 0.787 +0.440 [+0.335, +0.548] win
Key mapping (synthetic + CORD/FATURA) 300 0.272 0.959 0.964 +0.692 [+0.656, +0.730] win
General Thai pages (rendered) 100 0.976 0.980 0.980 +0.005 [+0.003, +0.006] win
  • No set is a loss. v0.2 is above v0.1 on five sets and below it on FinTabNet (0.804 vs 0.814); the v0.2 − v0.1 differences were not bootstrapped.
  • Training used other pages of the same Thai government collection and of FinTabNet, PubTabNet, WildReceipt and the key-mapping generator. Those hold-out rows are therefore in-distribution (different images; the government pages come from different source PDFs). DocAtlas (n = 7) is too small to call.

Thai receipt key mapping (thairocr)

Measured on v0.1 only; not re-run for v0.2. An extra, report-only check, not used to choose or release the model. It uses 1,091 receipt images from the test split of NECTEC thairocr (photorealistic rendered receipts, not scans of real ones), never used in round-5 training. Each prompt names the keys to return (key mapping). vLLM, no loop guard. Key accuracy = correct keys / asked keys, pooled over all rows. Row score = the same per row, averaged (95% CI). No thairocr image is included in this repository.

Model Key accuracy Row score [95% CI] JSON valid Every key right Hit token limit
typhoon-ocr1.5-2b (base) 0.104 0.105 [0.094, 0.117] 0.416 0.002 412 of 1,091
Netra-2B v0.1 0.528 0.525 [0.508, 0.542] 0.973 0.075 1 of 1,091

Accounting KIE on synthetic documents

120 held-out documents from our synthetic set (synth2/val, first 120 KIE rows). The v0.1 rows: transfer slip 24, tax invoice 21, abbreviated tax invoice 21, credit/debit note 19, 50 ทวิ certificate 18, billing note 17. The validation file gained more document types before v0.2 was scored, so v0.2's 120 documents also include receipts, delivery notes, statements, utility bills, purchase orders and menus; the rows are not a paired comparison. Prompt KIE_PROMPT; transformers generate, greedy, max_new_tokens=2048, no repetition penalty, no loop guard.

Metrics:

  • Field accuracy over 15 scored fields: doc_type, doc_no, date, seller/buyer name, tax_id and branch, subtotal, discount, vat, wht, total, net_paid, ref_doc_no. Strings are whitespace-normalised; numbers must match within 0.005.
  • Item F1 on (desc, amount).
  • JSON-valid rate.
  • Doc perfect = every scored field and every item right.

Caveat: the base model never saw this schema. Its score measures schema compliance, not reading ability, so this is not a fair head-to-head. The fine-tuned rows are in-distribution (same generator as the training data).

Model JSON valid Field accuracy Item F1 Doc perfect
typhoon-ocr1.5-2b (base) 0.75 0.0368 0.3634 0.0
round 2 (rejected for release) 0.975 0.8962 0.9662 0.483
round 4 @0.78 (previous candidate) 0.942 0.8149 0.9482 0.25
Netra-2B v0.1 (round 5) 1.0 0.9082 0.9208 0.542
Netra-2B v0.2 (round 13) 1.0 0.9536 0.9731 0.692

Phone-photo invoices (synth3/val, 117 documents, rebuilt before round 13; not comparable with v0.1): v0.2 field accuracy 0.9857, item F1 0.9853, doc perfect 0.838. Photo invoices with look-alike characters in the names (synth4/val, 150 documents, added 2026-10-25): v0.2 0.957 / 0.9785 / 0.600.

A synthetic tax invoice (fictitious companies) from the held-out set, next to Netra-2B v0.1's JSON output for it: doc_type tax_invoice, doc_no TX7009-0832, date 2027-09-29 converted from the printed Buddhist-era date, seller and buyer names, tax IDs and branches, subtotal 3910, VAT 7% 273.7, total 4183.7. Every scored field is correct.

v0.1 field accuracy by type: tax invoice 0.971, abbreviated tax invoice 0.960, credit/debit note 0.934, transfer slip 0.933, billing note 0.918, 50 ทวิ certificate 0.623. v0.2, same six types: 0.962, 0.967, 0.976, 1.000, 0.937, 0.952.

Trust gate

The trust gate auto-accepts a KIE answer only when every deterministic check passes. Otherwise it names the fields a person must check. Checks: required and forbidden fields per doc_type, item sums, the VAT chain, WHT = total × rate, net = total − WHT, tax-ID checksum, Thai baht words, branch suffix rules, and (when an OCR pass is supplied) that dates, names, tax IDs and branches in the JSON are printed in the OCR text.

First replay, on v0.1's KIE outputs (120 synthetic documents; v0.2 is measured in the production replay below):

Mode Accepted Correct among accepted Wrong but accepted Perfect docs rejected Share of perfect docs accepted
Checks without OCR cross-read 87 / 120 (72.5%) 64.4% 31 9 86.2%
With OCR cross-read, gold OCR text 49 / 120 (40.8%) 95.9% 2 18 72.3%
  • Without the cross-read the gate is not sufficient. About 1 in 3 accepted answers is wrong (31 of 87), mostly a wrong month in a date or a one-letter slip in a name. Arithmetic and checksum checks cannot see these.
  • Caveat (first replay): the cross-read used the synthetic ground-truth text in place of a real OCR pass. That is an upper bound: a real OCR pass (for example this model in reading mode) makes its own errors, some shared with the KIE answer. The cross-read with a real OCR pass has not been measured. The 2 wrong answers it accepted were 50 ทวิ certificates with a wrong subtotal.

Production replay (2026-10-22)

The gate was then extended and replayed the way it runs in practice, on Netra's own OCR pass instead of the ground truth (first on v0.1 outputs; v0.2 in v0.2 production gate):

  • Every field scored. A wrong answer counts if any field is wrong, not only the 15 core fields.
  • Field placement checks: item descriptions must be whole table cells with their amount on the same row; party names must not be cut short; a branch must sit in its own party's block; payment fields (bank, account, PromptPay, reference) and item quantities and prices must be printed; labelled extras (copy mark, cashier, credit days, due date) must not be dropped.
  • Second reader: every OCR check must also pass against an independent reader (typhoon-ocr1.5-3b-qat). Netra's own OCR shares its misreadings: 9 of 10 wrong answers accepted in the first own-OCR replay were the same one-letter Thai name slip in both its OCR and its JSON.
  • Token confidence: a free-text value (name, item, address, bank, extras) is sent to a person when any of its tokens has probability below 0.85. The misread names had 0.45–0.84.
Set Pipeline Accepted Wrong but accepted
synthetic accounting, 120 docs own OCR + confidence 18 (15.0%) 1 (copy mark dropped)
synthetic accounting, 120 docs + second reader 16 (13.3%) 1
phone-photo invoices, 117 docs own OCR + confidence 17 (14.5%) 2 (card mask, salesperson dropped)
phone-photo invoices, 117 docs + second reader 8 (6.8%) 0
  • Correct documents rejected by the checks themselves: 0 of 7,200 ground-truth documents.
  • The gate is close to zero wrong answers, but it accepts only 7–15% of documents; the rest go to a person. Raising that share needs a more accurate model, not a looser gate.
  • 36 adversarial mutations of correct answers (invented fields, shifted rows, cut names) are all rejected; they are regression tests.

v0.2 production gate (2026-10-26)

Pipeline: Netra's own OCR pass, a second reader (typhoon-ocr1.5-3b-qat), token confidence (threshold 0.85, fixed in 2026-10-22 on v0.1 outputs, not refitted), and a character-level third reader on party names: Tesseract (tha+eng, page mode 6, system and frequency dictionaries off). Every seller, buyer, payer and payee name in the JSON must appear, whitespace-insensitive, in the Tesseract text. Netra and the second reader both turn a name printed one mark off into the dictionary word (กู้เงิณ read as กู้เงิน); a reader without a dictionary does not. A document whose OCR text, second-reader text or confidence pass is missing is rejected (fail closed).

Round 13 (v0.2) outputs, stage by stage (accepted / wrong but accepted):

Set + second reader + token confidence + character-level names (shipped)
synthetic accounting (synth2/val), 120 docs 32 / 0 16 / 0 4 / 0 (3.3%)
phone-photo invoices (synth3/val), 117 docs 55 / 1 48 / 0 12 / 0 (10.3%)
photo invoices with look-alike names (synth4/val), 150 docs 60 / 14 49 / 11 16 / 0 (10.7%)
  • All 14 wrong answers accepted on synth4/val before the third reader were a name with one look-alike mark.
  • The confidence pass runs in vLLM and the scored KIE answers in transformers; where the two outputs differ (20, 5 and 6 documents), the document is rejected.
  • The cost is a low accept rate. The third reader rejects most correct answers too (it misses many names on photos), so only 3–11% of documents are auto-accepted and the share of fully correct documents accepted falls to 4.8%, 12.2% and 17.8%. Everything else goes to a person.
  • Caveat: the third-reader check was added after the 14 wrong answers on synth4/val had been read. It has no fitted threshold (exact name containment), but these three sets are no longer fully unseen for the gate.
  • Real fuel-receipt photo (test only): the gate rejects v0.2's answer (13 of 20 fields right). The added checks can only reject more, so this was not re-run.

Training

Rounds

All rounds: LoRA on typhoon-ocr1.5-2b, scored on test sets never used in training.

Round Start Data (samples) lr, epochs Real reading / extraction Verdict
r1 base 6,072 (synthetic forms + Thai-Synth-Receipts) 1e-4, 2 0.624 / 0.394 (HF; base 0.657 / 0.434) Not released: full-page OCR CER 13.8% → 26.3% (n = 10), stopped emitting <figure>
r2 base 42,074 5e-5, 1 0.606 / 0.273 (HF) Rejected: answered in its own schema instead of the asked keys; template hallucination; early stop
r3 r2 adapter 25,778 (+ 8,000 key-mapping rows) 5e-5, 1 0.590 / 0.446 (HF); 0.5729 / 0.4881 (vLLM) Rejected: reading below base; tables regressed
r4 base (fresh LoRA) 37,593 (+ table normalisation, self-distillation, real tables) 5e-5, 1 0.6415 / 0.506 (vLLM) at strength 1.0; 0.6663 / 0.483 at 0.78 Previous candidate (0.78); replaced: hold-out loss on FinTabNet −0.050
r5 — v0.1 (released 2026-10-22) base (fresh LoRA) 38,630 5e-5, 2 0.7091 / 0.575 (vLLM; base 0.6659 / 0.4058) at strength 1.0 Released as v0.1: no hold-out loss; ThaiOCRBench better on 3/3 with the loop guard (Full-page worse without it)
r6 r5 adapter 53,196 (+ phone-photo tax invoices, + 10,000 thairocr key-mapping rows) 5e-5, 1 0.6945 / 0.5324 at 0.85 Not released: below v0.1 on both
r7 r5 adapter 44,931 (r6 without thairocr; 2,070 crawled government pages replace 300) lower lr, 1 0.6751 / 0.5763 at 0.85 Not released: reading below v0.1
r8 base (fresh LoRA) 44,931 (r7 data) 5e-5, 2 0.6385 / 0.5385 at 1.0; 0.6795 / 0.5652 at 0.85 Not released: hold-out ties v0.1 on all 7 sets, real reading lower
r9 base (fresh LoRA) 51,074 (r5 mix + phone-photo invoices + 14,684 free-form extraction questions) 5e-5, 2 0.659 / 0.5562 at 1.0; 0.69 / 0.5303 at 0.78; 0.6916 / 0.5549 at 1.0 with the loop guard Not released: reading below v0.1
r10 base (fresh LoRA) 51,708 (r5 mix + photo invoices + free-form extraction) 5e-5, 2 0.6782 / 0.5823 at 1.0 Not released: reading below v0.1
r11 base (fresh LoRA) 74,774 (r10 + 8,000 photo invoices + receipt-printer rows) 5e-5, 2 0.6397 / 0.5784 at 1.0; 0.676 / 0.5805 at 0.85; 0.709 / 0.5732 at 0.85 with the loop guard Not released: unguarded reading below v0.1; key mapping fell 0.726 → 0.553. Its adapter is the start of r12 and r13
r12 r11 adapter 38,630 (r5 mix replay) 2e-5, 1 0.6646 / 0.5642 at 1.0 Not released: no gain over r11
r13 @0.85 r11 adapter 46,606 (r5 mix + 5,978 matching questions + 1,977 free-form) 2e-5, 1 0.6737 / 0.6741; 0.703 / 0.68 with the loop guard Not released: ThaiOCRBench Document parsing CER 0.2227 vs base 0.2179 with the guard
r13 — v0.2 (released 2026-10-26) r11 adapter 46,606 (same) 2e-5, 1 0.6802 / 0.7126 at 1.0; 0.6929 / 0.7089 with the loop guard Released as v0.2: no hold-out loss; ThaiOCRBench better than the base on 3/3 with the loop guard (Document parsing by 0.0006); trust gate 0 wrong accepted once the character-level name check was added
r14 r13 adapter 63,964 (r13 mix + 5,679 photo invoices with look-alike name swaps + 3,000 photo-invoice replay) 2e-5, 1 0.6604 / 0.6565; 0.6879 / 0.6736 with the loop guard Not released: trust gate (second reader) accepted 9 wrong answers; extraction below r13
r15 r13 adapter r14 mix + 6,500 documents of the same name swaps over 13 document types 2e-5, 1 0.6535 / 0.5922; 0.6719 / 0.6027 with the loop guard Not released: trust gate (second reader) accepted 11 wrong answers; extraction below r13. Best ThaiOCRBench so far with the guard (0.193 / 0.335 / 0.264)

Rounds 10–13 (2026-10-23/24): the free-form extraction questions raise extraction on real documents (0.575 → 0.713 for r13), but every round since r10 reads full pages about 0.03 below v0.1 without the loop guard, mostly from repetition on long tables. Round 11 also lost key mapping: it copied values as printed instead of choosing from the values given in the question. Round 13 added about 6,000 matching questions, built from gold tables so the answer is one of the offered values, and recovered most of it (0.553 → 0.673; v0.1 0.726). Round 13 at strength 0.85 failed the release gate on ThaiOCRBench Document parsing; at full strength it passed and became v0.2 (see Release gate). On one real phone photo of a fuel receipt (test only), v0.2 read 13 of 20 fields right; it still misread the total and the amount in words and filled payment fields the receipt does not print, and the trust gate rejected the answer.

Rounds 14–15 (2026-10-25/26): round 13's trust gate accepted shop names one look-alike mark off (รุ่งรุจิ for printed รุ่งรุจี). Two rounds of generated invoices with such swaps in the names did not remove the error (wrong accepts on synth4/val: 14 for round 13, 8 for round 14, 10 for round 15) and lowered real-document extraction (0.709 → 0.674 → 0.603 with the guard). The fix moved to the gate instead (v0.2 production gate), and round 13, the most accurate on real documents, became v0.2.

Round-8 root cause: the loss is concentrated in long tables. Round 8 runs into the 4,096-token limit (a repetition loop) on 13 of the 147 pages against 7 for v0.1, and a looped page scores near 0. Rounds 7 and 8 share the larger crawled government set, so round 9 returns to the round-5 mix and adds only the phone-photo invoices, to test that cause in isolation. The comparison is being re-run with the loop guard on, as Netra is used in practice.

Round-9 result: the free-form extraction questions it added worked on their own task ("Key information extraction" 0.424 → 0.497), but key mapping fell (0.726 → 0.616) and loops rose again (13 pages hit the token limit against 7 for v0.1), so reading dropped. On a real fuel receipt (test only, not in any training set) round 9 read 19 of 20 fields right against 11 for v0.1 and no longer invented bank details, but its JSON was invalid (it joined two strings with +) and the total was the pre-VAT amount; the trust gate rejected it. v0.1 stays the release.

Round-5 changes, from the round-4 root-cause analysis:

  • Dot-leader runs (". . . .", up to thousands of dots in 860 of 2,850 FinTabNet targets) collapsed to ... in every OCR target. Round 4 had learned to emit them until the token limit.
  • <figure> pages no longer upsampled. Round 4 used ×4, which pushed figure pages to about a quarter of OCR targets and made the model describe figures at length while dropping page text.
  • 4,427 more self-distilled pages (base model outputs, quality-filtered), on top of the 3,959 from round 4. Sources with real gold labels (government pages, FinTabNet, PubTabNet) keep their own labels.
  • 300 real Thai government pages added (see below).
  • Thai-Synth-Receipts: one OCR target per image (68 contradicting duplicates dropped).
  • 2 epochs instead of 1.

v0.2 training: two stages

v0.2 is one LoRA adapter trained in two stages: round 11 from a fresh adapter, then round 13 continuing from it.

Stage Data (samples) lr, epochs Steps Mean train loss / final eval loss Time (1 × RTX 5090)
r11 74,774 5e-5, 2 18,694 0.0238 / 0.1736 not cleanly measured: a host shutdown stopped it at step 18,483, and it was resumed from the epoch-1 checkpoint
r13 46,606 2e-5, 1 5,826 0.0721 / 0.1697 5,878 s

Rows added on top of the round-5 mix below (counts as loaded at train start):

Source r11 rows r13 rows Task Images Licence
Synthetic accounting documents (synth2), raised cap 4,987 (r5: 2,495) 2,495 reading + KIE synthetic in-house
Phone-photo versions of synthetic tax invoices (synth3) 8,000 — reading + KIE synthetic in-house
Receipt-printer (POS thermal) synthetic receipts, clean and photo 5,806 — reading + KIE synthetic in-house
Free-form extraction questions, answers cut from gold page text (synthetic sets, Thai-Synth-Receipts) 2,962 1,977 free-form KIE as source as source
Free-form questions answered by Qwen3-VL-8B-Instruct, kept only when every value is printed in the gold text 620 — free-form KIE as source (incl. 105 Thai government pages, see ATTRIBUTION.md) as source; teacher Apache-2.0
Matching questions (pair given items with given values) from gold tables — 5,978 key mapping as source as source

The Qwen-answered rows are the only rows whose answers came from another model; their images are from Thai-Synth- Receipts, the synthetic sets, SmartWhatt, DocAtlas and Thai government pages. The 105 government pages come from 21 PDFs of 20 agencies; 104 of them are from the wider round-7 crawl rather than the 300-page round-5 set. They passed the same filter that drops pages with personal ID numbers or home addresses.

Round-5 data mix

The round-5 mix is replayed inside both v0.2 stages. Rows as loaded at train start: after per-source caps, after removal of all evaluation and hold-out images, and after replacing a page's own label with the base model's output where the page was self-distilled. A row that has both a reading and a KIE label becomes two samples: 38,630 training samples, 200 validation.

Source Rows Task Images Licence
In-house synthetic forms (invoice, quotation, receipt, payment voucher) 1,476 reading synthetic in-house
In-house synthetic Thai accounting documents (6 types) 2,439 reading + KIE synthetic in-house
KIE labels for the synthetic forms 1,383 KIE synthetic in-house
Thai-Synth-Receipts 3,000 reading synthetic MIT
Thai Wikipedia text rendered as articles/reports/posters 8 own label (+1,394 pages in self-distillation) reading synthetic render CC BY-SA 4.0 (text)
SmartWhatt thai_doc_parsing (Thai government publications) 39 own label (+1,201 pages in self-distillation) reading real ODC / CC BY / CC BY-SA (per document)
Thai government documents (procurement notices, budget reports, agency financial statements, blank official forms; 13 agencies) 300 reading real not copyrighted (Copyright Act B.E. 2537, section 7)
iapp Thai handwriting 2,101 reading real Apache-2.0
CORD v2 (receipts) 760 partial KIE (annotated keys only) real CC BY 4.0
FATURA2 (invoices) 1,481 partial KIE (annotated keys only) template-generated CC BY 4.0
Key-mapping rows built from the synthetic sets, CORD and FATURA 8,000 key mapping as source as source
Self-distillation (base outputs on train-split images of the sources in this table) 8,386 reading as source base output (Apache-2.0); images as source
FinTabNet.c (table crops → HTML) 2,850 tables real CDLA-Permissive-2.0
PubTabNet (table crops → HTML) 2,850 tables real CDLA-Permissive-1.0
DocAtlas Instruct, Thai rows 31 own label (+537 pages in self-distillation) reading real CC BY 4.0
WildReceipt 1,168 key mapping real Apache-2.0

Every filled-in Thai accounting document in training (tax invoices, receipts, 50 ทวิ, slips, billing notes) is synthetic. The real Thai pages are government publications and Thai handwriting. The government pages (new in round 5) were issued by ministries, departments, state agencies and local administrations, which section 7 of the Thai Copyright Act B.E. 2537 excludes from copyright. Pages showing private individuals' ID numbers or home addresses were filtered out. No customer documents were used.

Hyperparameters

Setting Value
Method LoRA on vision and language layers, attention + MLP
Rank / alpha / dropout 16 / 16 / 0
Trainable parameters 23,724,032
Schedule cosine, warmup ratio 0.05
Batch 2 × 4 gradient accumulation = 8
Optimiser AdamW 8-bit, weight decay 0.01
Precision bf16
Image size long side ≤ 1400 px
<figure> upsampling none
Seed 3407
Learning rate, epochs, losses, time per stage: see v0.2 training (round 5 / v0.1: 5e-5, 2 epochs, 9,658 steps, 0.0953 / 0.1692, 17,550 s)
Software Unsloth 2026.10.7, transformers 5.17.0, TRL 1.15.0, PEFT 0.21.2
Merge LoRA merged into the base at full strength (1.0), bf16. Strengths 0.85 / 0.78 were evaluated; 0.85 failed the release gate

Known limitations

From the per-round root-cause analyses. Items marked not re-measured have not been checked on v0.2.

  • Page reading is slightly below v0.1. On Netra-Bench-Real, v0.2 reads 0.013 lower than v0.1 with the guard (0.693 vs 0.706) and 0.029 lower without it (0.680 vs 0.709); ThaiOCRBench CER is higher than v0.1's on all three tasks. If you only need page reading, v0.1 (in this repository's history) reads slightly better.
  • Repetition loops. Without the loop guard a few pages loop until the token limit (ThaiOCRBench: 7 of 120 v0.2 outputs flagged; Netra-Bench-Real: 9 of 147 hit the limit, v0.1 7). This is why Document parsing fails the release rule without the guard. The guard is measured with transformers only; with vLLM, flagged pages must be re-run (see Loop guard). Diagram-heavy pages can still be transcribed entirely inside one <figure> block.
  • Template hallucination. Round 2 transcribed a restaurant menu with an invented shop address and TAX ID: line. Cause: most accounting training targets are synthetic documents with shared header layouts. A plausible fabricated tax ID is the most damaging error for accounting use. Not re-measured; a hallucination probe set is planned. Always cross-check extracted tax IDs against an independent OCR pass.
  • The trust gate needs an OCR cross-read. Without it (measured on v0.1), the gate accepted 72.5% of v0.1's synthetic KIE answers, and only 64.4% of those were fully correct (wrong months in dates, one-letter name slips). Use the gate only in cross-read mode with a real OCR pass, the second reader and the character-level name check (see Trust gate); it then auto-accepts only 3–11% of documents.
  • Early stop on long pages. Round 2 returned 30–190 characters where the reference was 600–2,000. Short synthetic targets dominate the length distribution. Rounds 4–5 added distilled long pages; output/reference length ratio not re-measured.
  • Tables. With the loop guard, ThaiOCRBench table CER is 0.302 (base 0.308, no guard 0.286, v0.1 with the guard 0.293). Netra-Bench-Real table parsing is below v0.1 (0.665 vs 0.716 without the guard). The retry's no_repeat_ngram_size=16 also breaks legitimately repeated cells.
  • Accounting data is synthetic. No real Thai tax invoice, receipt, slip, 50 ทวิ or billing note was used for training. Synthetic layouts are low-entropy (round 1 reached train loss 0.0177), so accuracy on unseen real layouts is lower than the synthetic KIE numbers suggest. In one real phone photo of a VAT-inclusive tax invoice, an earlier round (r4) read every key field correctly in reading mode. In KIE mode it returned invalid JSON, recomputed VAT on top of the printed VAT-inclusive total and invented payment fields. On 8 real receipts (test only, not released) v0.2 gets 0.638 of 105 fields right (v0.1 0.495): better, but far from bookable without review. On one real fuel-receipt photo it still misreads the total and fills payment fields that are not printed. 50 ทวิ certificates were the weakest synthetic type in v0.1 (field accuracy 0.623; v0.2 0.952).
  • Small real test set. 113 reading and 34 extraction documents; standard error ≈ 0.03 on reading means and ≈ 0.07 on extraction means. Differences under ~0.05 (reading) are not distinguishable from noise. v0.2's extraction gain over the base (+0.307, standard error 0.082) is clear; its reading difference from the base and from v0.1 is not.
  • Rounds and merge strength chosen on test sets. Rounds were compared on Netra-Bench-Real, and the 0.85 vs 1.0 choice was settled on the ThaiOCRBench gate set (see Release gate). The hold-out sets are the independent check.
  • Schema imposition / generic extraction. Round 2 answered free-form questions in its own 28-key schema. Key-mapping data fixed most of it (mapping 0.296 → 0.726 in v0.1). v0.2 answers free-form questions far better (Key information extraction 0.752 vs 0.424 for v0.1 and 0.516 for the base) but pairs given items with given values less well than v0.1 (Key information mapping 0.673 vs 0.726).
  • Handwriting (n = 6) scores below the base (0.500 vs 0.531).
  • Fine-grained text recognition scores 0.000 for every model on the 2 documents in the set.
  • Dates. KIE converts พ.ศ. to ค.ศ.; two-digit พ.ศ. years are read as 25yy. Synthetic training dates span 2025–2027.

Licence and attribution

The model weights are released under Apache-2.0, the licence of the base model. Training data carries the terms below. Attribution is required for CC BY, ODC and CDLA sources.

Data Licence Attribution
typhoon-ai/typhoon-ocr1.5-2b (base) Apache-2.0 Nonesung et al., "Typhoon OCR: Open Vision-Language Model For Thai Document Extraction", arXiv:2601.14722 (2026)
Thai-Synth-Receipts MIT DuckerMaster/Thai-Synth-Receipts on Hugging Face
iapp Thai handwriting Apache-2.0 iapp/thai_handwriting_dataset on Hugging Face
SmartWhatt thai_doc_parsing Open Data Common / CC BY / CC BY-SA, per document SmartWhatt/thai_doc_parsing; per-document title, publisher, licence and URL in ATTRIBUTION.md
Thai government documents (rounds 5 and 11) Not copyrighted: Copyright Act B.E. 2537, section 7 Issuing agencies listed in ATTRIBUTION.md
Qwen/Qwen3-VL-8B-Instruct (teacher for 620 free-form answers, round 11) Apache-2.0 Qwen Team, Qwen3-VL; Qwen/Qwen3-VL-8B-Instruct on Hugging Face
Thai Wikipedia CC BY-SA 4.0 Thai Wikipedia contributors; article URLs recorded per training page
CORD v2 CC BY 4.0 Park et al., "CORD: A Consolidated Receipt Dataset for Post-OCR Parsing", NeurIPS 2019 Document Intelligence Workshop; naver-clova-ix/cord-v2
FATURA2 CC BY 4.0 Limam, Dhiaf, Kessentini, "FATURA: A Multi-Layout Invoice Image Dataset for Document Analysis and Understanding", arXiv:2311.11856 (2023); Zenodo record 10371464
FinTabNet.c CDLA-Permissive-2.0 (original FinTabNet: CDLA-Permissive-1.0) Smock, Pesala, Abraham, "Aligning benchmark datasets for table structure recognition", ICDAR 2023, arXiv:2303.00716; IBM FinTabNet, Zheng et al., WACV 2021
PubTabNet CDLA-Permissive-1.0 Zhong, ShafieiBavani, Jimeno Yepes, "Image-based table recognition: data, model, and evaluation", ECCV 2020, arXiv:1911.10683; images from the PubMed Central Open Access Subset
DocAtlas Instruct (Thai rows) CC BY 4.0 Heakl et al., "DocAtlas: Multilingual Document Understanding Across 80+ Languages", arXiv:2605.12623 (2026)
WildReceipt Apache-2.0 Sun et al., "Spatial Dual-Modality Graph Reasoning for Key Information Extraction", arXiv:2103.14470 (2021); OpenMMLab MMOCR
ThaiOCRBench (evaluation only) CC BY-SA 4.0 Nonesung et al., "ThaiOCRBench: A Task-Diverse Benchmark for Vision-Language Understanding in Thai", arXiv:2511.04479 (2025)

Modifications to third-party data: table crops and HTML targets rebuilt from annotations (FinTabNet.c, PubTabNet); markdown normalised to the output conventions above (DocAtlas); key-mapping prompts generated from box labels (WildReceipt); CORD/FATURA labels converted to the KIE schema with unannotated keys removed from the target.

Licence decision for this release: the weights are Apache-2.0. CC BY-SA material (Thai Wikipedia text rendered to pages; 36 page images from 15 CC BY-SA SmartWhatt documents) was a training input only. No training data, page image or label is redistributed with the model, and no ThaiOCRBench image is included in this repository.

Citation

@misc{netra2026,
  title  = {Netra-2B: a Thai accounting-document reader fine-tuned from Typhoon OCR 1.5},
  author = {botnick},
  year   = {2026},
  note   = {Version 0.2},
  url    = {https://huggingface.co/botnick/Netra-2B}
}

Please also cite the base model:

@misc{nonesung2026typhoonocropenvisionlanguage,
  title         = {Typhoon OCR: Open Vision-Language Model For Thai Document Extraction},
  author        = {Surapon Nonesung and Natapong Nitarach and Teetouch Jaknamon and Pittawat Taveekitworachai and Kunat Pipatanakul},
  year          = {2026},
  eprint        = {2601.14722},
  archivePrefix = {arXiv},
  primaryClass  = {cs.CL},
  url           = {https://arxiv.org/abs/2601.14722}
}
Downloads last month
32
Safetensors
Model size
2B params
Tensor type
BF16
·
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support

Model tree for botnick/Netra-2B

Finetuned
(1)
this model
Quantizations
1 model

Datasets used to train botnick/Netra-2B

Papers for botnick/Netra-2B