Baidu recently released a new OCR model called Unlimited-OCR. Unlike traditional OCR models that process documents page by page, this model employs "one-shot long-horizon parsing," enabling it to parse dozens of pages in a single pass. This approach eliminates the need to repeatedly reset the model's memory — a requirement of page-by-page processing — thereby better preserving contextual information across pages. Consequently, OCR has evolved from a tool for recognizing individual pages into a system capable of understanding and parsing long documents.

Unlimited-OCR Features

  • Full-document parsing instead of cropped-region OCR
  • 32K output length for long OCR sequences
  • Based on DeepSeek-OCR, inheriting the high-compression visual encoding capabilities of DeepEncoder.
  • Utilizing the R-SWA attention mechanism makes long-sequence decoding more stable and efficient.

Unlimited-OCR Architecture

Unlimited-OCR employs DeepSeek OCR as its baseline model. Unlike the original DeepSeek OCR, it replaces the standard multi-head attention mechanism with a novel attention mechanism known as R-SWA.

None
Unlimited-OCR Architecture from Huggingface

Local Deployment

The official Unlimited-OCR documentation provides detailed instructions on deploying Unlimited-OCR using Transformers, vLLM, or SGLang; here, I will explain how to deploy it locally on macOS using mlx-vlm.

  1. Configure the virtual environment
uv venv .venv
source .venv/bin/activate

2. Install mlx-vlm

uv pip install "git+https://github.com/Blaizzy/mlx-vlm" --prerelease=allow

3. Download the model

Download the Unlimited-OCR model using the hf CLI tool provided by Hugging Face.

hf download baidu/Unlimited-OCR --local-dir ./models/Unlimited-OCR

4. Run the Unlimited-OCR model.

4.1 Single-image document parsing

When you only need to parse a single image, you need to set the value of the prompt parameter in the apply_chat_template function to "document parsing."

from pathlib import Path

from ocr_result_parser import parse_single_result, save_parsed_result


IMAGE_PATH = Path("images/page_0001.png")
OUTPUT_DIR = Path("output/single_image")
MODEL_PATH = "models/Unlimited-OCR"


def main():
    from mlx_vlm import generate, load
    from mlx_vlm.prompt_utils import apply_chat_template

    model, processor = load(MODEL_PATH)

    prompt = apply_chat_template(
        processor,
        model.config,
        "document parsing.",
        num_images=1,
    )

    result = generate(
        model=model,
        processor=processor,
        image=str(IMAGE_PATH),
        prompt=prompt,
        max_tokens=32768,
        temperature=0.0,
    )

    parsed = parse_single_result(
        result.text,
        image_path=IMAGE_PATH,
        output_dir=OUTPUT_DIR,
    )
    save_parsed_result(result.text, parsed, OUTPUT_DIR)
    print(parsed["markdown"])


if __name__ == "__main__":
    main()

In the code above, the ocr_result_parser script is used to parse the model's output; the code for this script is as follows:

import ast
import json
import re
from pathlib import Path

try:
    from PIL import Image, ImageOps
except ImportError:
    Image = None
    ImageOps = None


DET_OPEN = "<|det|>"
DET_CLOSE = "<|/det|>"
REF_OPEN = "<|ref|>"
REF_CLOSE = "<|/ref|>"
PAGE_MARKER = "<PAGE>"
SPECIAL_TOKENS = (
    "<|begin▁of▁sentence|>",
    "<|end▁of▁sentence|>",
    "<|▁pad▁|>",
)


def strip_special_tokens(text):
    for token in SPECIAL_TOKENS:
        text = text.replace(token, "")
    return text.strip()


def find_balanced_brackets(text, start):
    if start >= len(text) or text[start] != "[":
        return None

    depth = 0
    for index in range(start, len(text)):
        char = text[index]
        if char == "[":
            depth += 1
        elif char == "]":
            depth -= 1
            if depth == 0:
                return index + 1
    return None


def parse_boxes(raw_boxes):
    value = ast.literal_eval(raw_boxes)
    if value and all(isinstance(item, (int, float)) for item in value):
        value = [value]

    boxes = []
    for box in value:
        if (
            isinstance(box, (list, tuple))
            and len(box) == 4
            and all(isinstance(item, (int, float)) for item in box)
        ):
            boxes.append([float(item) for item in box])
    return boxes


def detection_spans(text):
    spans = []
    position = 0
    label_pattern = re.compile(r"[A-Za-z_][\w-]*")

    while True:
        det_start = text.find(DET_OPEN, position)
        if det_start == -1:
            break

        cursor = det_start + len(DET_OPEN)
        while cursor < len(text) and text[cursor].isspace():
            cursor += 1

        label_match = label_pattern.match(text, cursor)
        if not label_match:
            position = cursor
            continue

        label = label_match.group(0)
        cursor = label_match.end()
        while cursor < len(text) and text[cursor].isspace():
            cursor += 1

        box_end = find_balanced_brackets(text, cursor)
        if box_end is None:
            position = cursor
            continue

        close_start = box_end
        while close_start < len(text) and text[close_start].isspace():
            close_start += 1
        if not text.startswith(DET_CLOSE, close_start):
            position = close_start
            continue

        span_start = det_start
        ref_text = None
        if text[:det_start].endswith(REF_CLOSE):
            ref_start = text.rfind(REF_OPEN, 0, det_start)
            ref_close_start = text.rfind(REF_CLOSE, 0, det_start)
            if ref_start != -1 and ref_close_start != -1:
                ref_close_end = ref_close_start + len(REF_CLOSE)
                if ref_close_end == det_start:
                    span_start = ref_start
                    ref_text = text[ref_start + len(REF_OPEN):ref_close_start]

        span_end = close_start + len(DET_CLOSE)
        raw_boxes = text[cursor:box_end]
        try:
            boxes = parse_boxes(raw_boxes)
        except (SyntaxError, TypeError, ValueError):
            boxes = []

        spans.append(
            {
                "span": [span_start, span_end],
                "raw": text[span_start:span_end],
                "label": label,
                "boxes": boxes,
                "ref": ref_text.strip() if ref_text else None,
            }
        )
        position = span_end

    return spans


def normalized_box_to_pixels(box, image_size):
    width, height = image_size
    x1, y1, x2, y2 = box
    left = int(max(0, min(width, x1 / 999 * width)))
    top = int(max(0, min(height, y1 / 999 * height)))
    right = int(max(0, min(width, x2 / 999 * width)))
    bottom = int(max(0, min(height, y2 / 999 * height)))
    return left, top, right, bottom


def load_source_image(image_path):
    if Image is None or ImageOps is None or image_path is None:
        return None

    image_path = Path(image_path)
    if not image_path.exists():
        return None
    return ImageOps.exif_transpose(Image.open(image_path))


def save_image_crop(source_image, box, output_path):
    left, top, right, bottom = normalized_box_to_pixels(box, source_image.size)
    if right <= left or bottom <= top:
        return False
    output_path.parent.mkdir(parents=True, exist_ok=True)
    source_image.crop((left, top, right, bottom)).save(output_path)
    return True


def is_image_detection(item):
    return item["label"] == "image" or item["ref"] == "image"


def parse_page_result(raw_text, image_path=None, output_dir=None, image_prefix=""):
    text = strip_special_tokens(raw_text)
    spans = detection_spans(text)
    source_image = load_source_image(image_path)

    output_dir = Path(output_dir) if output_dir is not None else None
    markdown_parts = []
    cursor = 0
    image_index = 0

    for item in spans:
        span_start, span_end = item["span"]
        markdown_parts.append(text[cursor:span_start])

        if is_image_detection(item) and item["boxes"] and source_image is not None and output_dir is not None:
            image_name = f"{image_prefix}{image_index}.jpg"
            image_output = output_dir / "images" / image_name
            if save_image_crop(source_image, item["boxes"][0], image_output):
                item["image"] = f"images/{image_name}"
                markdown_parts.append(f"![]({item['image']})\n")
                image_index += 1

        cursor = span_end

    markdown_parts.append(text[cursor:])
    markdown = "".join(markdown_parts)
    markdown = markdown.replace("\\coloneqq", ":=").replace(
        "\\eqqcolon", "=:").strip()

    return {
        "markdown": markdown,
        "detections": spans,
    }


def split_page_segments(text):
    if PAGE_MARKER not in text:
        return [text]

    parts = text.split(PAGE_MARKER)
    if not parts[0].strip():
        return parts[1:]
    return parts


def parse_single_result(raw_text, image_path=None, output_dir=None):
    return parse_page_result(raw_text, image_path=image_path, output_dir=output_dir)


def parse_multi_page_result(raw_text, image_paths=None, output_dir=None):
    text = strip_special_tokens(raw_text)
    image_paths = [Path(path) for path in image_paths or []]
    output_dir = Path(output_dir) if output_dir is not None else None

    pages = []
    markdown_pages = []
    for page_index, page_text in enumerate(split_page_segments(text)):
        image_path = image_paths[page_index] if page_index < len(
            image_paths) else None
        parsed_page = parse_page_result(
            page_text,
            image_path=image_path,
            output_dir=output_dir,
            image_prefix=f"page_{page_index + 1:04d}_",
        )
        parsed_page["page_number"] = page_index + 1
        parsed_page["image_path"] = str(
            image_path) if image_path is not None else None

        for item in parsed_page["detections"]:
            item["page_number"] = page_index + 1

        pages.append(parsed_page)
        markdown_pages.append(parsed_page["markdown"])

    markdown = f"{PAGE_MARKER}\n" + f"\n{PAGE_MARKER}\n".join(markdown_pages)
    return {
        "markdown": markdown.strip(),
        "pages": pages,
    }


def save_parsed_result(raw_text, parsed, output_dir):
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)
    (output_dir / "raw.txt").write_text(raw_text, encoding="utf-8")
    (output_dir / "result.md").write_text(parsed["markdown"], encoding="utf-8")
    (output_dir / "result.json").write_text(
        json.dumps(parsed, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )

Input Image

None
page_0001.png

Result:

None

4.2 Multi-page parsing

When you need to parse multiple images, you must set the value of the prompt parameter in the apply_chat_template function to "Multi page parsing."

import argparse
from pathlib import Path

from ocr_result_parser import parse_multi_page_result, save_parsed_result


MODEL_PATH = "models/Unlimited-OCR"
OUTPUT_DIR = Path("output/multi_page")
DEFAULT_IMAGE_PATTERNS = (
    "images/page_*.png",
    "page_*.png",
)


def parse_args():
    parser = argparse.ArgumentParser(
        description="Run Unlimited-OCR multi-page parsing on rendered PDF page images."
    )
    parser.add_argument(
        "images",
        nargs="*",
        type=Path,
        help="Page images in reading order. If omitted, images/page_*.png is used.",
    )
    parser.add_argument(
        "-o",
        "--output-dir",
        type=Path,
        default=OUTPUT_DIR,
        help="Directory for raw.txt, result.md, result.json, and cropped images.",
    )
    parser.add_argument(
        "--max-tokens",
        type=int,
        default=32768,
        help="Maximum generated tokens.",
    )
    return parser.parse_args()


def resolve_page_images(images):
    if images:
        page_images = [path.expanduser().resolve() for path in images]
    else:
        page_images = []
        for pattern in DEFAULT_IMAGE_PATTERNS:
            page_images = sorted(Path().glob(pattern))
            if page_images:
                break
        page_images = [path.resolve() for path in page_images]

    missing = [path for path in page_images if not path.is_file()]
    if missing:
        names = ", ".join(str(path) for path in missing[:5])
        suffix = " ..." if len(missing) > 5 else ""
        raise FileNotFoundError(f"Page image(s) not found: {names}{suffix}")
    if not page_images:
        patterns = ", ".join(DEFAULT_IMAGE_PATTERNS)
        raise FileNotFoundError(f"No page images found. Pass images explicitly or create files matching: {patterns}")
    return page_images


def main():
    args = parse_args()
    page_images = resolve_page_images(args.images)

    from mlx_vlm import generate, load
    from mlx_vlm.prompt_utils import apply_chat_template

    model, processor = load(MODEL_PATH)

    prompt = apply_chat_template(
        processor,
        model.config,
        "Multi page parsing.",
        num_images=len(page_images),
    )

    result = generate(
        model=model,
        processor=processor,
        image=[str(path) for path in page_images],
        prompt=prompt,
        max_tokens=args.max_tokens,
        temperature=0.0,
        cropping=False,
        image_size=1024,
    )

    parsed = parse_multi_page_result(
        result.text,
        image_paths=page_images,
        output_dir=args.output_dir,
    )
    save_parsed_result(result.text, parsed, args.output_dir)
    print(parsed["markdown"])


if __name__ == "__main__":
    main()

In most PDF parsing scenarios, you can use the following script to convert a PDF document into ordered page images.

from pathlib import Path

import pymupdf

pdf_path = Path("2606.23050v1.pdf")
out_dir = Path("images")
out_dir.mkdir(exist_ok=True)

# Upstream examples use 300 DPI. Lower this if you need a quicker smoke test.
dpi = 300
matrix = pymupdf.Matrix(dpi / 72, dpi / 72)

with pymupdf.open(pdf_path) as doc:
    for i, page in enumerate(doc):
        page.get_pixmap(matrix=matrix, alpha=False).save(
            out_dir / f"page_{i + 1:04d}.png")

Summary

Unlimited-OCR redefines how long documents are parsed. By processing dozens of pages in a single pass, it avoids page-by-page handling, reduces fragmentation, preserves context, and improves the efficiency of long-document processing.

If you need to parse long documents, you might want to try the Unlimited-OCR model. Should it fail to meet your requirements, you can also test the PaddleOCR-VL-1.6 and MinerU2.5-Pro models.