Tools,

How to improve Tesseract accuracy: what each fix did on two real scans

On my scanned test invoice, two fixes did most of the work. Telling Tesseract the resolution took it from 0 to 24 of 36 numbers right. Reading the table one cell at a time, with a check on the math, took it to 33 and flagged the other 3. Cleaning the image to black and white first did not help, and on a clean typed page it made the result a little worse.

What each step did

Two test pages, both 300 dpi scans. One is page 1 of Executive Order 9066, typed in 1942, a public domain scan. The other is a plumbing invoice we made, with 36 numbers in a ruled table, tilted 0.8 degrees, with noise, blur and JPEG grain. We made the invoice, so the true value of every number is known.

Step Typed page: word match Invoice: numbers right
pytesseract, no dpi given 0.957 0 of 36
Add --dpi 300 (psm 3) 0.957 24 of 36
Same, psm 4 0.957 21 of 36
Same, psm 6 0.957 0 of 36
Deskew, denoise and black and white first (psm 3) 0.939 22 of 36
Join words split at line ends (psm 3) 0.972 not run
Read table cells one by one and check the math (our Actor's full pass) 0.985 33 of 36, all 3 misses flagged

Word match is the share of words that line up, in order, with an earlier transcript of the same page. 1.000 would mean identical. Our OCR for RAG page counts the same page another way, against a transcript checked word by word: plain Tesseract got 187 of 194 words, our Actor 191.

1. Tell Tesseract the resolution

This one surprised me. The tesseract command read 21 of the invoice's 36 numbers straight from the JPEG at psm 4. The same call through pytesseract, the Python wrapper, read 0. Its output began like this:

01 [american fo [usar [esa
toe [Pex ung [7 [mat [sso

pytesseract hands Tesseract a temporary copy of the image, and that copy did not keep the file's 300 dpi. So Tesseract had to guess the size of the text, and it guessed wrong. One setting fixed it:

import pytesseract
from PIL import Image

img = Image.open("invoice.jpg")
text = pytesseract.image_to_string(img, config="--psm 3 --dpi 300")

Use the real resolution of your scan. On the typed page, the missing dpi changed nothing, so a test on one easy page can hide this.

2. Try psm 3, 4 and 6 on your own pages

The page segmentation mode (psm) tells Tesseract what layout to expect. On the typed page, all three modes gave the same result. On the invoice they did not. psm 3 (find the layout itself) got 24 numbers, psm 4 (one column of lines) got 21, and psm 6 (one block of text) got 0, with rows like "it [comprise fe [visas ose".

That is one table. On another layout the order can change, so try the three modes on a few of your own pages before you pick one.

3. Clean the image only if it helps

Most tips say to straighten the page, remove noise and make it black and white before OCR. I did all three: deskew, a 3 pixel median filter, then an Otsu threshold. On the invoice, the count went from 24 to 22 at psm 3 (and from 21 to 22 at psm 4). On the typed page it went down, from 0.957 to 0.939.

Tesseract already makes its own black and white copy of each page. So measure each step on your own pages before you keep it.

4. Fix the text after OCR

Tesseract keeps every line break of the paper, and it splits words at line ends, such as "util- ities". The typed page had 3 of them. One line of Python joins them, and the word match went from 0.957 to 0.972:

import re

text = re.sub(r"-\n(\w)", r"\1", text)

It also joins a real hyphen that falls at a line end, so "well-known" split over two lines becomes "wellknown". Our Actor's full pass, which also joins wrapped lines into paragraphs, scored 0.985.

5. Read tables one cell at a time, then check the math

This was the big one for the invoice: from 24 to 33 numbers. Our Actor finds the table by its ruling lines, cuts out each cell, and reads it alone with psm 7 (one line of text). When a number cell looks like it lost its decimal point, it reads that cell again with digits only. Then it checks every row: Qty times Unit price must equal Amount, and each column must keep its decimal places.

The 3 numbers it still got wrong, and the warning for each, word for word (row 1 is the header):

550.48 read as 350.48
  Table row 3: Qty times Unit price does not equal Amount. Check these numbers.
Qty 1 read as nothing
  Table cell row 6, column 3 has marks that could not be read.
1,138.00 read as 138.00
  Table row 13: Qty times Unit price does not equal Amount. Check these numbers.

The same invoice as a PDF, run on Apify, got 32 of 36, and all 4 misses had a warning (more on that run in PDF table to Markdown). Words get no such check: "Shutoff valve" came back as "ShutolT valve" with no warning.

What I did not test

For many files, or from your code

The last row of the table is PDF OCR to Markdown, our Apify Actor. It reads a PDF's own text where there is some, and uses OCR only on scanned pages. Paste PDF or image links, or upload a file. You get Markdown for the whole file, a pages list with each page's warnings, and a .md file.

Want the free route instead? See how to turn a scanned PDF into Markdown, with OCRmyPDF and Docling. Screenshots, not scans? See image to Markdown.

How this was made: Tack, our AI personality, ran Tesseract 5.5.3 (Homebrew) through pytesseract on a Mac on October 29, 2026, one run per step, and copied the counts above from that run. The Apify run used PDF OCR to Markdown build 0.1.18 the same day. The typed page is a public domain scan from Wikimedia Commons, and we made the test invoice. We make the Apify Actor. Tesseract and pytesseract are free, open source tools that we do not make.