Tools,

dots.ocr, tested on a real scanned PDF

1,946

of 1,955 scan words, read in order

Get the setup

On a Mac, change three CUDA parts

The parser is built for NVIDIA GPUs. I changed three parts:

1,946 of 1,955 words, in order

168 s for 10 pages, Mac GPU

In the repo folder, run pip install -e ., then python3 dots_mac.py book.pdf. The 6.1 GB model goes in weights/DotsOCR. Each page gets a Markdown file in out/book/.

The full dots_mac.py script
# dots.ocr on a Mac: the repo's own parser, with its CUDA-only parts swapped for the Mac GPU.
# Run in the dots.ocr repo folder (pip install -e .), weights in ./weights/DotsOCR:
#   python3 dots_mac.py book.pdf    (Markdown per page in out/book/)
import sys

import torch
from qwen_vl_utils import process_vision_info
from transformers import AutoConfig, AutoModelForCausalLM, Qwen2_5_VLProcessor

from dots_ocr.parser import DotsOCRParser

PATH = "./weights/DotsOCR"


class MacParser(DotsOCRParser):
    def _load_hf_model(self):
        cfg = AutoConfig.from_pretrained(PATH, trust_remote_code=True)
        cfg.vision_config.attn_implementation = "sdpa"  # not flash_attention_2 (CUDA only)
        self.model = AutoModelForCausalLM.from_pretrained(
            PATH, config=cfg, attn_implementation="sdpa", torch_dtype=torch.bfloat16,
            device_map="mps", trust_remote_code=True,
        )
        # The model's own processor class fails on transformers 4.56.1 (video_processor is None),
        # so load its base class and set the two values it adds.
        self.processor = Qwen2_5_VLProcessor.from_pretrained(PATH, use_fast=True)
        self.processor.image_token, self.processor.image_token_id = "<|imgpad|>", 151665
        self.process_vision_info = process_vision_info

    def _inference_with_hf(self, image, prompt):
        msgs = [{"role": "user", "content": [{"type": "image", "image": image}, {"type": "text", "text": prompt}]}]
        text = self.processor.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
        images, videos = self.process_vision_info(msgs)
        inputs = self.processor(text=[text], images=images, videos=videos, padding=True, return_tensors="pt")
        inputs = inputs.to("mps")  # the repo has "cuda" here
        out = self.model.generate(**inputs, max_new_tokens=24000)
        return self.processor.batch_decode(
            out[:, inputs.input_ids.shape[1]:], skip_special_tokens=True, clean_up_tokenization_spaces=False
        )[0]


MacParser(use_hf=True).parse_file(sys.argv[1], output_dir="out")

The README's commands stop on a Mac

All four stop loudly, so a script can catch them. Compare Chandra OCR, which wrote an empty file with exit code 0.

Typed pages: 13 of 13 cover words

dots.ocr reads every page as a picture, typed pages too. In the 11-page file, it read all 13 words of the typed cover page. The scan words stayed at 1,946.

German worked with no language setting

dots.ocr has no language option. I gave it 3 German pages. One is a magazine page with two columns.

204 of 206 umlauts

969 of 972 words in order, two columns

dots.ocr read more, our Actor was faster

Our Actor makes Markdown from a file URL, with no install:

curl -s -X POST "https://api.apify.com/v2/acts/brasspage~pdf-ocr-to-markdown/run-sync-get-dataset-items" \
  -H "Authorization: Bearer $APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"documentUrls": ["https://api.apify.com/v2/key-value-stores/sTnmNrGoSsi7tLQvs/records/anythingllm-mixed-11-pages.pdf"]}' \
  | jq -r '.[].markdown' > mixed.md
Same files, dots.ocr on the Mac GPU
Test dots.ocr Actor
Scan words in order 1,946 1,874
Typed cover words 13 13
Umlauts 204 192
Two columns, in order 969 954
Time, 11 pages 180 s 133 s
Time, 3 German pages 157 s 59 s

The Actor needs no GPU and no 7.4 GB setup. It costs $0.006 a page.

The license allows business use

The code is MIT. The model comes with a license agreement on top. It allows commercial use, paid services and closed products.

It bars fake documents, personal data without consent, and scans of copyrighted books without permission.

How I tested it
  • Scan: the 10 pages of OCR in n8n, proofread Wikisource book scans, 1,955 true words. The 11-page file adds the 13-word typed cover page of AnythingLLM OCR.
  • German: the 3 pages of Paperless-ngx OCR, 1,887 words, 206 umlauts.
  • Score: true words found in order in the parser's Markdown pages, with the scorer of the tessdata test. German: the scorer of Tesseract German.
  • dots.ocr repo at commit 36d7248878f1, transformers 4.56.1, PyTorch 2.15.0, Python 3.12 arm64, on an Apple Silicon Mac GPU, at the lowest priority. Model revision c0111ce6bc07. One run each. Times include the model start.
  • The script above is the file that ran. The Actor: two real runs earlier the same day, build 0.1.65, on the same files.
What I did not test
  • dots.mocr, the newer model that the README calls dots.ocr 1.5. The repo's download script now fetches it into weights/DotsMOCR, but parser.py loads weights/DotsOCR. I read that in the code.
  • vLLM, NVIDIA GPUs, and a run with no GPU.
  • Handwriting, tables, math and forms.
Sources

Two ways to skip the work

Run it yourself. Our Apify Actor turns scanned PDFs into Markdown, tables included.

Try the Actor

Or send us the scan. Tack, our AI, does the job for $29, up to 50 pages. First page free.

Get one page free

Questions people ask

Does dots.ocr run on a Mac?

Yes, with three changes to its parser: sdpa attention, the base processor class and the mps device. On a Mac GPU it read 10 scanned pages in 168 s.

Why does dots.ocr say flash_attn is not installed?

With --use_hf true, the parser loads the model with flash_attention_2, which needs an NVIDIA GPU. On a Mac it stopped after 6 s with that error. Use sdpa.

Can a business use dots.ocr?

Yes. The license agreement allows commercial use, paid services and closed products. It bars fake documents, personal data without consent and copyrighted scans without permission.