StarDoc-AI/TeleOCR

Model

TeleOCR: Navigating Document Parsing Across Digital and Camera-Captured Documents

82

28 commits

5 linked in READMEs

updated Sep 17, 2026

See the code
conversational
custom_code
document-parsing
document-understanding
endpoints_compatible
eval-results
image-text-to-text
multimodal
qwen2_5_vl
safetensors
text-generation-inference
transformers
vision-language

README

TeleOCR: Navigating Document Parsing Across Digital and Camera-Captured Documents

License GitHub Papers with Code: SOTA on OmniDocBench v1.6 TeleAI arXiv

πŸ”₯ News

  • 2026/09/10 - We have renamed NaviDC-OCR to TeleOCR, and all subsequent model iterations will be developed and released under the TeleOCR version.
  • 2026/09/01 - We noticed that EMNLP 2026 is hosting the Dr.DocBench Challenge, a document parsing competition. We evaluated NaviDC-OCR with its native weights, achieving better results than MinerU 2.5 Pro and PaddleOCR-VL 1.6. Detailed results are shown below dr.docbench-challenge. We welcome the use of NaviDC‑OCR for competitions. Going forward, we will continue to deliver competitive parsing models for the community.
  • 2026/08/29 β€” Thanks to Nandraj for the GGUF conversion and llama.cpp support πŸ”— NaviDC-OCR-GGUF, and to the community for sharing their experience deploying NaviDC-OCR on Ascend 910B!
  • 2026/08/17 β€” NaviDC-OCR model weights and technical report have been released.

πŸ“– Introduction

TeleOCR is a lightweight (~1.2B parameters), open-source Vision-Language Model designed specifically for document parsing.

Unlike existing methods that mainly target either digital documents or camera-captured documents, TeleOCR unifies both scenarios within a single framework.

Compared with previous document parsing models, TeleOCR introduces

  • Multi-node Consensus Voting (MCV) for automatic pseudo-label generation
  • Geometry-aware document modeling for camera-captured documents
  • Curvature-Guided Douglas-Peucker Sampling (CGDP)
  • Image-to-image self-verification for automatic data refinement
  • Progressive four-stage training pipeline
  • Content-Structure Decoupled Learning for tables and formulas

These techniques enable TeleOCR to achieve state-of-the-art performance on both digital and camera-captured document benchmarks while remaining lightweight enough for practical deployment.

πŸ“Š Experimental Results

TeleOCR achieves state-of-the-art performance on multiple public document parsing benchmarks.

Layout Visualization of Distorted Documents

To evaluate the model's ability to understand complex document deformations, we conduct a visual evaluation on the public dewarping datasets DocUNet and DIR300, with representative results shown in Figure. TeleOCR directly performs layout and content parsing on distorted documents without dewarping preprocessing or a dedicated rectification model, demonstrating robust parsing under complex geometric deformations.

Parsing evaluation on the DIR300 dataset. Parsing evaluation on the DocUNet dataset.

Dr.DocBench Challenge

ζ¨‘εž‹overall ↑Text edit ↓formula cdm ↑Table teds ↑order edit ↓
Specialized VLMs
TeleOCR67.960.19030.0264.970.398
Mineru 2.5 pro62.260.34020.0467.750.356
OvisOCR259.250.38830.0061.590.3791
PaddleOCRvl 1.655.110.43640.2151.340.412

OmniDocBench v1.6

Model TypeMethodsParamOverall ↑Text Edit ↓Formula CDM ↑Table TEDS ↑Table TEDS-S ↑Read Order Edit ↓
Specialized VLMsTeleOCR1.2B96.870.02796.3697.0598.520.122
OvisOCR20.8B96.580.02597.5394.7697.160.111
PaddleOCR-VL-1.60.9B96.330.03397.4994.7697.110.127
MinerU2.5-Pro1.2B95.750.03697.4593.4295.920.120
GLM-OCR0.9B95.220.04497.1892.8395.390.133
PaddleOCR-VL-1.50.9B94.870.03896.6991.6794.370.130
HunyuanOCR-1.51B94.740.03397.4994.7697.110.127
PaddleOCR-VL0.9B94.110.04095.7090.6593.740.135
Youtu-Parsing2.5B93.680.04493.4592.0295.000.116
Logics-Parsing-v24B93.270.04195.4788.4291.980.137
FireRed-OCR2B93.200.03795.2788.0491.060.131
MinerU2.51.2B92.980.04595.5987.8891.470.130
OpenDoc-0.1B0.1B90.640.04992.9383.8887.450.140
dots.ocr3B90.500.04889.1287.1890.580.138
DeepSeek-OCR 23B90.170.05091.5983.8987.750.144
HunyuanOCR1B89.870.08987.4491.0193.230.171
Dolphin-v23B89.340.06990.5384.4087.440.150
OCRVerse4B88.440.06389.1482.4486.270.163
MonkeyOCR-pro-3B3B88.430.07488.3384.3588.620.189
General VLMsOvis2.6-30B-A3B30B93.620.03594.9389.4492.400.135
Gemini 3 Pro--92.850.06495.8389.1592.960.165
Gemini 3 Flash--92.580.06695.0389.2993.510.173
Qwen3-VL-235B235B89.780.06392.5383.0786.750.166
GPT-5.2--86.520.11488.0082.9587.930.193
InternVL3.5-241B241B83.610.13089.5274.3579.780.215

Wild_OmniDocBench

Model TypeMethodsParamOverall ↑Text Edit ↓Formula CDM ↑Table TEDS ↑Table TEDS-S ↑Read Order Edit ↓
Decoupled VLMsTeleOCR1.2B88.530.117388.2689.0592.140.2011
PaddleOCR-VL-1.60.9B87.360.136988.4285.7690.140.2057
MinerU2.5-Pro1.2B87.330.136290.1585.4690.120.2013
GLM-OCR0.9B85.080.151489.0981.3185.900.2228
PaddleOCR-VL-1.50.9B84.640.146186.7281.8086.520.2138
End-to-End VLMsOvisOCR20.8B87.910.12990.3785.1389.110.2021
dots.ocr3B81.840.148385.075.3280.200.2200
HunyuanOCR-1.51B77.620.197985.1267.5470.670.2750
Logics-Parsing-v24B77.100.402991.480.1987.160.2355

PureDocBench

Model TypeModelClean Overall ↑Clean Text ↓Clean Formula ↑Clean Table ↑Digital Degraded Overall ↑Digital Degraded Text ↓Digital Degraded Formula ↑Digital Degraded Table ↑Real Degraded Overall ↑Real Degraded Text ↓Real Degraded Formula ↑Real Degraded Table ↑
Decoupled VLMTeleOCR86.900.11181.0191.0977.470.20672.5980.4570.850.30265.1177.66
DotsMOCR76.270.15166.2377.6573.160.19864.3274.9561.730.31254.3961.97
MinerU2.5-Pro75.870.22265.1484.6871.770.27261.7980.7362.560.37552.7072.47
YouTu-Parsing75.020.23067.3480.7469.660.27061.4474.4960.290.36052.2064.69
PaddleOCR-VL-1.573.010.26663.5382.1266.730.33958.0376.0760.500.39854.0067.33
GLM-OCR68.650.31457.8979.4463.060.38353.2374.2158.310.43350.3467.83
Dolphin-v265.900.34259.8072.1260.240.39352.2067.8644.920.55339.9850.04
MonkeyOCR-pro-3B62.230.34648.4672.8357.400.39745.5766.3246.490.51138.1852.43
End-to-End VLMOvisOCR282.140.14971.2990.1277.770.19267.8784.7166.610.31657.6473.79
FD-RL78.380.19368.2186.2276.330.21467.1683.2267.040.29858.8272.08
Logics-Parsing-v276.350.21367.6782.6773.850.24867.3379.0267.640.30461.6571.64
dots.ocr72.010.24861.3779.5165.950.30756.6771.8655.680.40347.7059.63
Qianfan-OCR57.220.37049.7958.8350.850.43844.4151.9645.060.49439.0845.53
General VLMsQwen3-VL-8B72.440.26165.1078.3572.030.26664.8877.8262.730.34255.5566.81
Kimi K2.672.320.30366.9380.3069.950.32264.6977.3168.020.33562.4475.14
Gemini-3.1-Pro70.040.30665.6375.0869.280.32265.8174.2471.980.30068.6277.26
Qwen3.5-397B-A17B69.120.23365.2665.4068.340.24463.9165.5362.700.28760.7056.12

ICDAR2026 Sci-ImageMiner

#TeamRMSTEDSWeighted
1TeleOCR17.2366.3941.81
2VLMinators17.2964.3140.80
3Ricoh_SRCB16.2361.1238.67
4Vassilis Sioros14.9455.2035.07
5DocMiner12.6753.7233.19
6Qwen3 VL 8B14.0857.8635.97

πŸš€ Installation

pip install transformers torch pillow

Quick Start

import html
import itertools
import json
import re
from dataclasses import dataclass

from PIL import Image
import torch
from transformers import AutoProcessor, AutoModel

@dataclass
class ContentBlock:
    type: str
    bbox: list[float]
    angle: int | None = None
    content: str | None = None


@dataclass
class TableCell:
    text: str
    start_row_offset_idx: int
    end_row_offset_idx: int
    start_col_offset_idx: int
    end_col_offset_idx: int
    row_span: int = 1
    col_span: int = 1


OTSL_NL = "<nl>"
OTSL_FCEL = "<fcel>"
OTSL_ECEL = "<ecel>"
OTSL_LCEL = "<lcel>"
OTSL_UCEL = "<ucel>"
OTSL_XCEL = "<xcel>"
OTSL_TOKENS = [OTSL_NL, OTSL_FCEL, OTSL_ECEL, OTSL_LCEL, OTSL_UCEL, OTSL_XCEL]


def _otsl_extract_tokens_and_text(text: str):
    pattern = "(" + "|".join(map(re.escape, OTSL_TOKENS)) + ")"
    tokens = re.findall(pattern, text)
    parts = [part for part in re.split(pattern, text) if part.strip()]
    return tokens, parts


def _count_right(rows, row_idx, col_idx, tokens):
    span = 0
    while col_idx < len(rows[row_idx]) and rows[row_idx][col_idx] in tokens:
        span += 1
        col_idx += 1
    return span


def _count_down(rows, row_idx, col_idx, tokens):
    span = 0
    while row_idx < len(rows) and col_idx < len(rows[row_idx]) and rows[row_idx][col_idx] in tokens:
        span += 1
        row_idx += 1
    return span


def _otsl_parse_texts(parts, tokens):
    rows = [list(row) for is_nl, row in itertools.groupby(tokens, lambda token: token == OTSL_NL) if not is_nl]
    if not rows:
        return [], []

    max_cols = max(len(row) for row in rows)
    for row in rows:
        row.extend([OTSL_ECEL] * (max_cols - len(row)))

    cells = []
    row_idx = 0
    col_idx = 0
    for idx, part in enumerate(parts):
        if part in (OTSL_FCEL, OTSL_ECEL):
            cell_text = ""
            right_offset = 1
            if part != OTSL_ECEL and idx + 1 < len(parts) and parts[idx + 1] not in OTSL_TOKENS:
                cell_text = parts[idx + 1].strip()
                right_offset = 2

            next_right = parts[idx + right_offset] if idx + right_offset < len(parts) else ""
            next_bottom = rows[row_idx + 1][col_idx] if row_idx + 1 < len(rows) and col_idx < len(rows[row_idx + 1]) else ""
            col_span = 1 + (_count_right(rows, row_idx, col_idx + 1, {OTSL_LCEL, OTSL_XCEL}) if next_right in {OTSL_LCEL, OTSL_XCEL} else 0)
            row_span = 1 + (_count_down(rows, row_idx + 1, col_idx, {OTSL_UCEL, OTSL_XCEL}) if next_bottom in {OTSL_UCEL, OTSL_XCEL} else 0)
            cells.append(TableCell(
                text=cell_text,
                row_span=row_span,
                col_span=col_span,
                start_row_offset_idx=row_idx,
                end_row_offset_idx=row_idx + row_span,
                start_col_offset_idx=col_idx,
                end_col_offset_idx=col_idx + col_span,
            ))
        if part in (OTSL_FCEL, OTSL_ECEL, OTSL_LCEL, OTSL_UCEL, OTSL_XCEL):
            col_idx += 1
        elif part == OTSL_NL:
            row_idx += 1
            col_idx = 0
    return cells, rows


def convert_otsl_to_html(otsl_content: str) -> str:
    if otsl_content.startswith("<table") and otsl_content.endswith("</table>"):
        return otsl_content

    tokens, parts = _otsl_extract_tokens_and_text(otsl_content)
    cells, rows = _otsl_parse_texts(parts, tokens)
    if not cells or not rows:
        return ""

    grid = [[None for _ in range(len(rows[0]))] for _ in range(len(rows))]
    for cell in cells:
        for row_idx in range(cell.start_row_offset_idx, min(cell.end_row_offset_idx, len(rows))):
            for col_idx in range(cell.start_col_offset_idx, min(cell.end_col_offset_idx, len(rows[0]))):
                grid[row_idx][col_idx] = cell

    html_rows = []
    for row_idx, row in enumerate(grid):
        html_rows.append("<tr>")
        for col_idx, cell in enumerate(row):
            if cell is None or cell.start_row_offset_idx != row_idx or cell.start_col_offset_idx != col_idx:
                continue
            attrs = ""
            if cell.row_span > 1:
                attrs += f' rowspan="{cell.row_span}"'
            if cell.col_span > 1:
                attrs += f' colspan="{cell.col_span}"'
            html_rows.append(f"<td{attrs}>{html.escape(cell.text.strip())}</td>")
        html_rows.append("</tr>")
    return "<table>" + "".join(html_rows) + "</table>"


def post_process(blocks: list[ContentBlock]) -> list[ContentBlock]:
    for block in blocks:
        if block.type == "table" and block.content:
            block.content = convert_otsl_to_html(block.content)
        elif block.type == "equation" and block.content:
            content = block.content.strip()
            content = content.removeprefix("\\[").removesuffix("\\]").strip()
            if not (content.startswith("$") and content.endswith("$")):
                content = f"$${content}$$"
            block.content = content
    return [block for block in blocks if block.type != "equation_block"]

def infer(image: Image.Image, prompt: str) -> str:
    messages = [
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": [
            {"type": "image"},
            {"type": "text", "text": prompt},
        ]},
    ]
    chat_prompt = processor.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True,
    )
    inputs = processor(
        text=[chat_prompt],
        images=[image.convert("RGB")],
        padding=True,
        return_tensors="pt",
    ).to(device=model.device, dtype=model.dtype)
    output_ids = model.generate(
        **inputs,
        use_cache=True,
        max_new_tokens=4096,
        do_sample=False,
    )
    output_ids = output_ids.cpu().tolist()[0][len(inputs.input_ids[0]):]
    return processor.batch_decode(
        [output_ids],
        skip_special_tokens=True,
        clean_up_tokenization_spaces=False,
    )[0].strip()



processor = AutoProcessor.from_pretrained("StarDoc-AI/TeleOCR", trust_remote_code=True, use_fast=True)
model = AutoModel.from_pretrained(
    "StarDoc-AI/TeleOCR",
    trust_remote_code=True,
    torch_dtype=torch.bfloat16,
).cuda().eval()

# text
image=Image.open("./assets/text.png").convert("RGB")
raw_text = infer(image, "Please output the text content from the image.")
print(raw_text.strip())

# table
image=Image.open("./assets/table.png").convert("RGB")
raw_otsl = infer(image, "This is the image of a table. Please output the table in OTSL format.")
print(convert_otsl_to_html(raw_otsl))

# formula 
image=Image.open("./assets/formula.png").convert("RGB")
raw_formula = infer(image, "Please write out the expression of the formula in the image using LaTeX format.")
formula_block = ContentBlock("equation", [0.0, 0.0, 1.0, 1.0], content=raw_formula)
formula = post_process([formula_block])[0].content
print(formula)

#code
image=Image.open("./assets/code.png").convert("RGB")
raw_code = infer(image,"The image contains a code snippet, please output the parsing result.")
print(raw_code.strip())

# layout
image=Image.open("./assets/layout.jpg").convert("RGB")
image = image.resize((1036, 1036), Image.Resampling.BICUBIC)
raw_layout = infer(image, "Analyze the image layout.")
print(raw_layout.strip())

# Distorted document layout
layout_image = Image.open("./assets/layout_distorted.jpg").convert("RGB")
layout_image = layout_image.resize((1036, 1036), Image.Resampling.BICUBIC)
raw_layout = infer(layout_image, "\nMulti-point Layout Segmentation Analysis.")
print(raw_layout.strip())

#scientific figure
image=Image.open("./assets/scientific_figure.png").convert("RGB")
raw_scientific_figure = infer(image, "This is a scientific figure. Please extract the table implied by this figure.")
print(convert_otsl_to_html(raw_scientific_figure))

If you would like to perform complete document parsing, please refer to our GitHub repository: https://github.com/caipeng328/NaviDC-OCR.

Citation

@article{teleocr,
  title={TeleOCR: Navigating Document Parsing Across Digital and Camera-Captured Documents},
  author={Cai, Peng and Zou, Zhaofan and Liu, Shifa and Wang, Yikun and Tang, Jiawei and Yang, Kaicheng and Tong, Meng and He, Zhongjiang and Sun, Hao},
  journal={arXiv preprint arXiv:2608.12898},
  year={2026}
}

Community Contributions

Thanks to Nandraj for the GGUF conversion and llama.cpp support! πŸ”— NaviDC-OCR-GGUF

Acknowledgements

TeleOCR is built upon

  • MinerU
  • Qwen2.5-VL
  • Qwen3
  • Transformers
  • PyTorch
  • FlashAttention

We sincerely thank these excellent open-source projects.


Contact

If you have any questions, feel free to open an issue or contact us.

Contributors

caipeng328

28 commits

StarDoc-AI/TeleOCR

Model

TeleOCR: Navigating Document Parsing Across Digital and Camera-Captured Documents

82

28 commits

5 linked in READMEs

updated Sep 17, 2026

See the code
conversational
custom_code
document-parsing
document-understanding
endpoints_compatible
eval-results
image-text-to-text
multimodal
qwen2_5_vl
safetensors
text-generation-inference
transformers
vision-language

README

TeleOCR: Navigating Document Parsing Across Digital and Camera-Captured Documents

License GitHub Papers with Code: SOTA on OmniDocBench v1.6 TeleAI arXiv

πŸ”₯ News

  • 2026/09/10 - We have renamed NaviDC-OCR to TeleOCR, and all subsequent model iterations will be developed and released under the TeleOCR version.
  • 2026/09/01 - We noticed that EMNLP 2026 is hosting the Dr.DocBench Challenge, a document parsing competition. We evaluated NaviDC-OCR with its native weights, achieving better results than MinerU 2.5 Pro and PaddleOCR-VL 1.6. Detailed results are shown below dr.docbench-challenge. We welcome the use of NaviDC‑OCR for competitions. Going forward, we will continue to deliver competitive parsing models for the community.
  • 2026/08/29 β€” Thanks to Nandraj for the GGUF conversion and llama.cpp support πŸ”— NaviDC-OCR-GGUF, and to the community for sharing their experience deploying NaviDC-OCR on Ascend 910B!
  • 2026/08/17 β€” NaviDC-OCR model weights and technical report have been released.

πŸ“– Introduction

TeleOCR is a lightweight (~1.2B parameters), open-source Vision-Language Model designed specifically for document parsing.

Unlike existing methods that mainly target either digital documents or camera-captured documents, TeleOCR unifies both scenarios within a single framework.

Compared with previous document parsing models, TeleOCR introduces

  • Multi-node Consensus Voting (MCV) for automatic pseudo-label generation
  • Geometry-aware document modeling for camera-captured documents
  • Curvature-Guided Douglas-Peucker Sampling (CGDP)
  • Image-to-image self-verification for automatic data refinement
  • Progressive four-stage training pipeline
  • Content-Structure Decoupled Learning for tables and formulas

These techniques enable TeleOCR to achieve state-of-the-art performance on both digital and camera-captured document benchmarks while remaining lightweight enough for practical deployment.

πŸ“Š Experimental Results

TeleOCR achieves state-of-the-art performance on multiple public document parsing benchmarks.

Layout Visualization of Distorted Documents

To evaluate the model's ability to understand complex document deformations, we conduct a visual evaluation on the public dewarping datasets DocUNet and DIR300, with representative results shown in Figure. TeleOCR directly performs layout and content parsing on distorted documents without dewarping preprocessing or a dedicated rectification model, demonstrating robust parsing under complex geometric deformations.

Parsing evaluation on the DIR300 dataset. Parsing evaluation on the DocUNet dataset.

Dr.DocBench Challenge

ζ¨‘εž‹overall ↑Text edit ↓formula cdm ↑Table teds ↑order edit ↓
Specialized VLMs
TeleOCR67.960.19030.0264.970.398
Mineru 2.5 pro62.260.34020.0467.750.356
OvisOCR259.250.38830.0061.590.3791
PaddleOCRvl 1.655.110.43640.2151.340.412

OmniDocBench v1.6

Model TypeMethodsParamOverall ↑Text Edit ↓Formula CDM ↑Table TEDS ↑Table TEDS-S ↑Read Order Edit ↓
Specialized VLMsTeleOCR1.2B96.870.02796.3697.0598.520.122
OvisOCR20.8B96.580.02597.5394.7697.160.111
PaddleOCR-VL-1.60.9B96.330.03397.4994.7697.110.127
MinerU2.5-Pro1.2B95.750.03697.4593.4295.920.120
GLM-OCR0.9B95.220.04497.1892.8395.390.133
PaddleOCR-VL-1.50.9B94.870.03896.6991.6794.370.130
HunyuanOCR-1.51B94.740.03397.4994.7697.110.127
PaddleOCR-VL0.9B94.110.04095.7090.6593.740.135
Youtu-Parsing2.5B93.680.04493.4592.0295.000.116
Logics-Parsing-v24B93.270.04195.4788.4291.980.137
FireRed-OCR2B93.200.03795.2788.0491.060.131
MinerU2.51.2B92.980.04595.5987.8891.470.130
OpenDoc-0.1B0.1B90.640.04992.9383.8887.450.140
dots.ocr3B90.500.04889.1287.1890.580.138
DeepSeek-OCR 23B90.170.05091.5983.8987.750.144
HunyuanOCR1B89.870.08987.4491.0193.230.171
Dolphin-v23B89.340.06990.5384.4087.440.150
OCRVerse4B88.440.06389.1482.4486.270.163
MonkeyOCR-pro-3B3B88.430.07488.3384.3588.620.189
General VLMsOvis2.6-30B-A3B30B93.620.03594.9389.4492.400.135
Gemini 3 Pro--92.850.06495.8389.1592.960.165
Gemini 3 Flash--92.580.06695.0389.2993.510.173
Qwen3-VL-235B235B89.780.06392.5383.0786.750.166
GPT-5.2--86.520.11488.0082.9587.930.193
InternVL3.5-241B241B83.610.13089.5274.3579.780.215

Wild_OmniDocBench

Model TypeMethodsParamOverall ↑Text Edit ↓Formula CDM ↑Table TEDS ↑Table TEDS-S ↑Read Order Edit ↓
Decoupled VLMsTeleOCR1.2B88.530.117388.2689.0592.140.2011
PaddleOCR-VL-1.60.9B87.360.136988.4285.7690.140.2057
MinerU2.5-Pro1.2B87.330.136290.1585.4690.120.2013
GLM-OCR0.9B85.080.151489.0981.3185.900.2228
PaddleOCR-VL-1.50.9B84.640.146186.7281.8086.520.2138
End-to-End VLMsOvisOCR20.8B87.910.12990.3785.1389.110.2021
dots.ocr3B81.840.148385.075.3280.200.2200
HunyuanOCR-1.51B77.620.197985.1267.5470.670.2750
Logics-Parsing-v24B77.100.402991.480.1987.160.2355

PureDocBench

Model TypeModelClean Overall ↑Clean Text ↓Clean Formula ↑Clean Table ↑Digital Degraded Overall ↑Digital Degraded Text ↓Digital Degraded Formula ↑Digital Degraded Table ↑Real Degraded Overall ↑Real Degraded Text ↓Real Degraded Formula ↑Real Degraded Table ↑
Decoupled VLMTeleOCR86.900.11181.0191.0977.470.20672.5980.4570.850.30265.1177.66
DotsMOCR76.270.15166.2377.6573.160.19864.3274.9561.730.31254.3961.97
MinerU2.5-Pro75.870.22265.1484.6871.770.27261.7980.7362.560.37552.7072.47
YouTu-Parsing75.020.23067.3480.7469.660.27061.4474.4960.290.36052.2064.69
PaddleOCR-VL-1.573.010.26663.5382.1266.730.33958.0376.0760.500.39854.0067.33
GLM-OCR68.650.31457.8979.4463.060.38353.2374.2158.310.43350.3467.83
Dolphin-v265.900.34259.8072.1260.240.39352.2067.8644.920.55339.9850.04
MonkeyOCR-pro-3B62.230.34648.4672.8357.400.39745.5766.3246.490.51138.1852.43
End-to-End VLMOvisOCR282.140.14971.2990.1277.770.19267.8784.7166.610.31657.6473.79
FD-RL78.380.19368.2186.2276.330.21467.1683.2267.040.29858.8272.08
Logics-Parsing-v276.350.21367.6782.6773.850.24867.3379.0267.640.30461.6571.64
dots.ocr72.010.24861.3779.5165.950.30756.6771.8655.680.40347.7059.63
Qianfan-OCR57.220.37049.7958.8350.850.43844.4151.9645.060.49439.0845.53
General VLMsQwen3-VL-8B72.440.26165.1078.3572.030.26664.8877.8262.730.34255.5566.81
Kimi K2.672.320.30366.9380.3069.950.32264.6977.3168.020.33562.4475.14
Gemini-3.1-Pro70.040.30665.6375.0869.280.32265.8174.2471.980.30068.6277.26
Qwen3.5-397B-A17B69.120.23365.2665.4068.340.24463.9165.5362.700.28760.7056.12

ICDAR2026 Sci-ImageMiner

#TeamRMSTEDSWeighted
1TeleOCR17.2366.3941.81
2VLMinators17.2964.3140.80
3Ricoh_SRCB16.2361.1238.67
4Vassilis Sioros14.9455.2035.07
5DocMiner12.6753.7233.19
6Qwen3 VL 8B14.0857.8635.97

πŸš€ Installation

pip install transformers torch pillow

Quick Start

import html
import itertools
import json
import re
from dataclasses import dataclass

from PIL import Image
import torch
from transformers import AutoProcessor, AutoModel

@dataclass
class ContentBlock:
    type: str
    bbox: list[float]
    angle: int | None = None
    content: str | None = None


@dataclass
class TableCell:
    text: str
    start_row_offset_idx: int
    end_row_offset_idx: int
    start_col_offset_idx: int
    end_col_offset_idx: int
    row_span: int = 1
    col_span: int = 1


OTSL_NL = "<nl>"
OTSL_FCEL = "<fcel>"
OTSL_ECEL = "<ecel>"
OTSL_LCEL = "<lcel>"
OTSL_UCEL = "<ucel>"
OTSL_XCEL = "<xcel>"
OTSL_TOKENS = [OTSL_NL, OTSL_FCEL, OTSL_ECEL, OTSL_LCEL, OTSL_UCEL, OTSL_XCEL]


def _otsl_extract_tokens_and_text(text: str):
    pattern = "(" + "|".join(map(re.escape, OTSL_TOKENS)) + ")"
    tokens = re.findall(pattern, text)
    parts = [part for part in re.split(pattern, text) if part.strip()]
    return tokens, parts


def _count_right(rows, row_idx, col_idx, tokens):
    span = 0
    while col_idx < len(rows[row_idx]) and rows[row_idx][col_idx] in tokens:
        span += 1
        col_idx += 1
    return span


def _count_down(rows, row_idx, col_idx, tokens):
    span = 0
    while row_idx < len(rows) and col_idx < len(rows[row_idx]) and rows[row_idx][col_idx] in tokens:
        span += 1
        row_idx += 1
    return span


def _otsl_parse_texts(parts, tokens):
    rows = [list(row) for is_nl, row in itertools.groupby(tokens, lambda token: token == OTSL_NL) if not is_nl]
    if not rows:
        return [], []

    max_cols = max(len(row) for row in rows)
    for row in rows:
        row.extend([OTSL_ECEL] * (max_cols - len(row)))

    cells = []
    row_idx = 0
    col_idx = 0
    for idx, part in enumerate(parts):
        if part in (OTSL_FCEL, OTSL_ECEL):
            cell_text = ""
            right_offset = 1
            if part != OTSL_ECEL and idx + 1 < len(parts) and parts[idx + 1] not in OTSL_TOKENS:
                cell_text = parts[idx + 1].strip()
                right_offset = 2

            next_right = parts[idx + right_offset] if idx + right_offset < len(parts) else ""
            next_bottom = rows[row_idx + 1][col_idx] if row_idx + 1 < len(rows) and col_idx < len(rows[row_idx + 1]) else ""
            col_span = 1 + (_count_right(rows, row_idx, col_idx + 1, {OTSL_LCEL, OTSL_XCEL}) if next_right in {OTSL_LCEL, OTSL_XCEL} else 0)
            row_span = 1 + (_count_down(rows, row_idx + 1, col_idx, {OTSL_UCEL, OTSL_XCEL}) if next_bottom in {OTSL_UCEL, OTSL_XCEL} else 0)
            cells.append(TableCell(
                text=cell_text,
                row_span=row_span,
                col_span=col_span,
                start_row_offset_idx=row_idx,
                end_row_offset_idx=row_idx + row_span,
                start_col_offset_idx=col_idx,
                end_col_offset_idx=col_idx + col_span,
            ))
        if part in (OTSL_FCEL, OTSL_ECEL, OTSL_LCEL, OTSL_UCEL, OTSL_XCEL):
            col_idx += 1
        elif part == OTSL_NL:
            row_idx += 1
            col_idx = 0
    return cells, rows


def convert_otsl_to_html(otsl_content: str) -> str:
    if otsl_content.startswith("<table") and otsl_content.endswith("</table>"):
        return otsl_content

    tokens, parts = _otsl_extract_tokens_and_text(otsl_content)
    cells, rows = _otsl_parse_texts(parts, tokens)
    if not cells or not rows:
        return ""

    grid = [[None for _ in range(len(rows[0]))] for _ in range(len(rows))]
    for cell in cells:
        for row_idx in range(cell.start_row_offset_idx, min(cell.end_row_offset_idx, len(rows))):
            for col_idx in range(cell.start_col_offset_idx, min(cell.end_col_offset_idx, len(rows[0]))):
                grid[row_idx][col_idx] = cell

    html_rows = []
    for row_idx, row in enumerate(grid):
        html_rows.append("<tr>")
        for col_idx, cell in enumerate(row):
            if cell is None or cell.start_row_offset_idx != row_idx or cell.start_col_offset_idx != col_idx:
                continue
            attrs = ""
            if cell.row_span > 1:
                attrs += f' rowspan="{cell.row_span}"'
            if cell.col_span > 1:
                attrs += f' colspan="{cell.col_span}"'
            html_rows.append(f"<td{attrs}>{html.escape(cell.text.strip())}</td>")
        html_rows.append("</tr>")
    return "<table>" + "".join(html_rows) + "</table>"


def post_process(blocks: list[ContentBlock]) -> list[ContentBlock]:
    for block in blocks:
        if block.type == "table" and block.content:
            block.content = convert_otsl_to_html(block.content)
        elif block.type == "equation" and block.content:
            content = block.content.strip()
            content = content.removeprefix("\\[").removesuffix("\\]").strip()
            if not (content.startswith("$") and content.endswith("$")):
                content = f"$${content}$$"
            block.content = content
    return [block for block in blocks if block.type != "equation_block"]

def infer(image: Image.Image, prompt: str) -> str:
    messages = [
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": [
            {"type": "image"},
            {"type": "text", "text": prompt},
        ]},
    ]
    chat_prompt = processor.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True,
    )
    inputs = processor(
        text=[chat_prompt],
        images=[image.convert("RGB")],
        padding=True,
        return_tensors="pt",
    ).to(device=model.device, dtype=model.dtype)
    output_ids = model.generate(
        **inputs,
        use_cache=True,
        max_new_tokens=4096,
        do_sample=False,
    )
    output_ids = output_ids.cpu().tolist()[0][len(inputs.input_ids[0]):]
    return processor.batch_decode(
        [output_ids],
        skip_special_tokens=True,
        clean_up_tokenization_spaces=False,
    )[0].strip()



processor = AutoProcessor.from_pretrained("StarDoc-AI/TeleOCR", trust_remote_code=True, use_fast=True)
model = AutoModel.from_pretrained(
    "StarDoc-AI/TeleOCR",
    trust_remote_code=True,
    torch_dtype=torch.bfloat16,
).cuda().eval()

# text
image=Image.open("./assets/text.png").convert("RGB")
raw_text = infer(image, "Please output the text content from the image.")
print(raw_text.strip())

# table
image=Image.open("./assets/table.png").convert("RGB")
raw_otsl = infer(image, "This is the image of a table. Please output the table in OTSL format.")
print(convert_otsl_to_html(raw_otsl))

# formula 
image=Image.open("./assets/formula.png").convert("RGB")
raw_formula = infer(image, "Please write out the expression of the formula in the image using LaTeX format.")
formula_block = ContentBlock("equation", [0.0, 0.0, 1.0, 1.0], content=raw_formula)
formula = post_process([formula_block])[0].content
print(formula)

#code
image=Image.open("./assets/code.png").convert("RGB")
raw_code = infer(image,"The image contains a code snippet, please output the parsing result.")
print(raw_code.strip())

# layout
image=Image.open("./assets/layout.jpg").convert("RGB")
image = image.resize((1036, 1036), Image.Resampling.BICUBIC)
raw_layout = infer(image, "Analyze the image layout.")
print(raw_layout.strip())

# Distorted document layout
layout_image = Image.open("./assets/layout_distorted.jpg").convert("RGB")
layout_image = layout_image.resize((1036, 1036), Image.Resampling.BICUBIC)
raw_layout = infer(layout_image, "\nMulti-point Layout Segmentation Analysis.")
print(raw_layout.strip())

#scientific figure
image=Image.open("./assets/scientific_figure.png").convert("RGB")
raw_scientific_figure = infer(image, "This is a scientific figure. Please extract the table implied by this figure.")
print(convert_otsl_to_html(raw_scientific_figure))

If you would like to perform complete document parsing, please refer to our GitHub repository: https://github.com/caipeng328/NaviDC-OCR.

Citation

@article{teleocr,
  title={TeleOCR: Navigating Document Parsing Across Digital and Camera-Captured Documents},
  author={Cai, Peng and Zou, Zhaofan and Liu, Shifa and Wang, Yikun and Tang, Jiawei and Yang, Kaicheng and Tong, Meng and He, Zhongjiang and Sun, Hao},
  journal={arXiv preprint arXiv:2608.12898},
  year={2026}
}

Community Contributions

Thanks to Nandraj for the GGUF conversion and llama.cpp support! πŸ”— NaviDC-OCR-GGUF

Acknowledgements

TeleOCR is built upon

  • MinerU
  • Qwen2.5-VL
  • Qwen3
  • Transformers
  • PyTorch
  • FlashAttention

We sincerely thank these excellent open-source projects.


Contact

If you have any questions, feel free to open an issue or contact us.

Contributors

caipeng328

28 commits