Tools,

Image preprocessing for OCR: what helps Tesseract, tested

Leave a clean scan alone. Tesseract makes its own black and white copy, so on a clean page most tutorial steps change little, and some do real harm: the adaptiveThreshold line from the OpenCV tutorial, block 11 and C 2, lost 53% of the words on 55 clean book scans. Three problems are worth fixing: tilt, uneven light and grain. With 5 degrees of tilt added, 72 invoices kept 35 of 2,418 numbers. Under a shadow at 70% light they kept 880. Dividing the page by a blurred copy of itself, then deskewing, took a phone style set from 6,666 to 13,847 words and from 813 to 2,351 numbers. I added that shadow, tilt and grain with code, so this is a test, not a stack of real phone photos.

Try it on your PDF, $0.006 a page

What to do with your page

Each cell is words right / numbers right. Words are out of 14,693 on 55 real printed book scans. Numbers are out of 2,418 on 72 made-up invoices.

Your page Do this No step With it
Clean scan Nothing 13,858 / 2,337 Little to win
Tilted 3 degrees Deskew 12,054 / 1,177 13,848 / 2,327
Tilted 7 degrees Deskew 3,655 / 13 13,741 / 2,324
Soft shadow, 70% light Divide 11,277 / 880 13,854 / 2,371
Deep shadow, 35% light Divide 6,769 / 839 13,841 / 2,370
Grainy scan Divide 13,817 / 2,079 13,857 / 2,366
Faded print Nothing 13,860 / 2,321 Little to win
White text on black Nothing 13,861 / 2,345 Little to win
Phone style: 50% shadow, 2 degrees, blur, noise Divide, then deskew 6,666 / 813 13,847 / 2,351

I tested 28 steps and 3 ways to deskew, on 127 pages. They ran one at a time, in plain tesseract with psm 3 and --dpi 300 (600 after a 2 times resize). Phone style means a shadow down to 50% light, 2 degrees of tilt, blur of 1 pixel, noise of sigma 6, JPEG quality 80 and a warm tint. The fix for shadows and grain turned out to be the same line. On clean pages it added 37 numbers and halved misreads, 49 to 24, for 16 fewer book words.

The recipe

If pages come from anywhere, use this. It needs pip install opencv-python-headless pytesseract and the tesseract program. It flattens the light, then it levels the rows of text. The blur size of 25 pixels suits pages about 1,900 to 2,550 pixels wide, which is what I tested.

import cv2
import numpy as np
import pytesseract


def flatten(gray):
    back = cv2.GaussianBlur(gray, (0, 0), 25)
    if ((gray.astype(np.float32) - back) ** 3).mean() > 0:  # light text on a dark page
        gray, back = cv2.bitwise_not(gray), cv2.bitwise_not(back)
    return cv2.divide(gray, back, scale=255)


def skew_angle(gray, limit=15, step=0.25):
    small = cv2.resize(gray, None, fx=0.25, fy=0.25, interpolation=cv2.INTER_AREA)
    ink = cv2.threshold(small, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]
    h, w = ink.shape
    best, top = 0.0, -1.0
    for a in np.arange(-limit, limit + step, step):
        m = cv2.getRotationMatrix2D((w / 2, h / 2), float(a), 1)
        rows = cv2.warpAffine(ink, m, (w, h)).sum(axis=1, dtype=np.float64)
        score = ((rows[1:] - rows[:-1]) ** 2).sum()
        if score > top:
            best, top = float(a), score
    return best


def rotate(gray, angle):
    h, w = gray.shape
    m = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1)
    return cv2.warpAffine(
        gray, m, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE
    )


gray = cv2.cvtColor(cv2.imread("page.jpg"), cv2.COLOR_BGR2GRAY)
page = flatten(gray)
page = rotate(page, skew_angle(page))
print(pytesseract.image_to_string(page, config="--dpi 300"))

It cost 0.83 CPU seconds a page on top of OCR. I ran it through pytesseract, with the printed functions, on 3 sets, and the counts matched my bench to the digit.

Set No step The recipe
Clean 13,858 / 2,337 13,844 / 2,369
Grainy 13,817 / 2,079 13,861 / 2,361
Shadow, 35% light 6,769 / 839 13,842 / 2,367
Faded 13,860 / 2,321 13,861 / 2,365
White on black 13,861 / 2,345 13,844 / 2,369
Tilted 3 degrees 12,054 / 1,177 13,831 / 2,365
Tilted 5 degrees 7,601 / 35 13,793 / 2,367
Tilted 7 degrees 3,655 / 13 13,728 / 2,364
Tilted 15 degrees 36 / 0 13,429 / 2,374
Phone style 6,666 / 813 13,847 / 2,351

I wrote this recipe while looking at these same pages, and I fixed one bug in it on the way, so its numbers flatter it a little. A plain mean() < 127 test for dark pages inverted 16 of the 127 shadowed pages by mistake, and that set fell to 10,520 words. The test in the code looks at whether text is darker or lighter than the paper around it. It fired on all 127 dark pages and on none of the 127 pages in each of 6 other sets, shadows included.

Tilt does the most damage

The docs say that line finding "reduces significantly if a page is too skewed". The shape of that drop surprised me: it is a cliff. Invoices kept 96% of their numbers up to about 2.2 degrees of total tilt, 75% from 2.3 to 2.9, and 4% or less from 3.2. Book pages lost under 2% at 2 degrees and 13% at 3. With 10 degrees added, most pages came back blank. Tesseract exits with code 0 and no text. Of the 117 blank pages at 10 degrees, 40 printed Empty page!! and 77 printed nothing at all.

Tilt I added Words, no step Numbers, no step Pages with no text After deskew
None added 13,858 2,337 0 Not needed
1 degree 13,872 2,312 0 13,857 / 2,321
2 degrees 13,653 1,870 0 13,852 / 2,328
3 degrees 12,054 1,177 0 13,848 / 2,327
5 degrees 7,601 35 10 13,798 / 2,325
7 degrees 3,655 13 46 13,741 / 2,324
10 degrees 227 3 117 13,115 / 2,311
15 degrees 36 0 124 12,531 / 2,327

The rows show the turn I added. The invoices already lean 0.3 to 1.2 degrees, so their true tilt is that much more or less, and the 2 and 3 degree rows average pages on both sides of the cliff. Each page was turned that many degrees, either way, in the same canvas. So the 10 and 15 degree pages lost some corner text. Even an exact turn back read 13,635 and 13,409 words there.

The words are gone, not just shuffled. Counting words of 3 letters or more anywhere on the page, in any order, the book pages kept 11,001 of 11,327 clean and 6,403 at 5 degrees. --psm 6 kept more prose, 12,369 words at 5 degrees and 7,691 at 10, but the tables stayed lost at 127 and 9 numbers. See Tesseract psm modes for why. A page turned 90 or 180 degrees is another problem: Tesseract orientation detection.

How you deskew matters. The skew_angle function above tries each angle on a small copy and keeps the one where rows of text stand out most. The tutorial version takes cv2.minAreaRect of all the ink. I ran it as printed, under OpenCV 5.0.0, and its angle rule also held on a test drawing under 4.14.0.

Method 3 degrees 7 degrees Book pages that lost 10% or more
Exact turn back of my added tilt (my control) 13,839 / 2,321 13,739 / 2,313 Control
Sharpest rows (no divide) 13,848 / 2,327 13,741 / 2,324 0 and 0 of 55
cv2.minAreaRect snippet 13,460 / 2,324 12,008 / 2,321 2 and 10 of 55
magick -deskew 40% 12,921 / 2,316 12,883 / 2,330 5 and 5 of 55
magick page.jpg -background white -deskew 40% +repage out.png

On the 7 degree invoices all 3 did about as well as the exact turn back. The gap showed up on real book pages. At 15 degrees the rows method alone read 12,531 words, and after the divide step 13,429. Most of that gap is 5 pages of one book, where rows alone picked the wrong angle and read nothing, and the divide step fixed all 5. So flatten first.

A shadow beats Otsu, and one line beats a shadow

The same book page twice. On the left, a dark shadow covers the top half. On the right, after the divide step, the paper is evenly white and the text is sharp.
Left: a real scan with a shadow I added, down to 35% light. Right: the divide step. On this page, my bench went from 136 to 215 of 238 words.

The docs warn about this: the built in Otsu "can be suboptimal, particularly if the page background is of uneven darkness". Here is why. Otsu picks one gray level for the whole page. With a shadow, it can land between lit paper and shadowed paper, not between ink and paper. At 70% light, Otsu turned a median of 49% of each invoice black, against 2% with no shadow. Text inside that black area is lost. So an Otsu step of your own does not rescue the page at 70% light or darker, where it stayed under 960 numbers: it repeats the same mistake. It did help at 85% light, 2,114 to 2,365.

Light in the shadow No step Otsu Adaptive 31, 15 Sauvola flag Divide
85% 13,853 / 2,114 13,863 / 2,365 13,691 / 2,346 13,848 / 2,093 13,843 / 2,377
70% 11,277 / 880 10,824 / 938 13,713 / 2,344 13,845 / 2,067 13,854 / 2,371
50% 7,127 / 837 6,975 / 956 13,724 / 2,345 13,498 / 2,074 13,853 / 2,367
35% 6,769 / 839 6,716 / 935 13,684 / 2,351 12,371 / 2,053 13,841 / 2,370
20% 6,919 / 841 6,797 / 945 13,024 / 2,337 11,301 / 2,032 13,841 / 2,368
back = cv2.GaussianBlur(gray, (0, 0), 25)
flat = cv2.divide(gray, back, scale=255)

The blur wipes out the text and keeps only the light. Dividing removes the light and leaves the text. Blur size barely mattered: 10, 15, 25 and 50 pixels read 2,359, 2,361, 2,370 and 2,362 numbers on the 35% set. 25 was my first guess. Larger is slower: 0.23 CPU seconds a page at 15, 0.53 at 25, 2.5 at 50. With the divide step, depth barely mattered either: 13,841 to 13,854 words at all 5 depths. Without it, 85% light is mild, and then the page falls away.

A large adaptive threshold (block 31, C 15) was nearly as good here, and -c thresholding_method=2 got most of the way. Both have a price further down this page.

Grainy scans: skip the median blur

These are the rough invoices from my DPI test, with noise of sigma 10 to 16, blur and JPEG quality 60 to 80, plus book pages with noise of sigma 12 and JPEG 75. Book text barely noticed. The small digits did.

Step Words Numbers Numbers read wrong CPU seconds a page, step alone
No step 13,817 2,079 304 0
Divide 13,857 2,366 21 0.55
Otsu 13,801 2,344 42 0.03
Gaussian blur 5, then Otsu 13,814 2,336 32 0.05
Non-local means, h 30 13,867 2,312 70 5.70
Non-local means, h 10 13,860 2,245 132 5.68
Bilateral filter 13,860 2,208 167 0.17
Median blur 3 13,824 2,055 311 0.04
Median blur 5 13,841 2,034 329 0.04

Median blur, which the Nanonets tutorial uses, cost a few numbers. Otsu and divide both help. My guess is that the reader sees your pixels, and both steps push the paper to plain white. Removing grain alone did not do the same: median, bilateral and non-local means all read fewer numbers than Otsu. Non-local means works, at about 10 times the step time of divide and 5 times the whole page.

Faded print: leave it alone

I cut contrast down to 5% of the original, about 13 gray levels. Tesseract did not mind.

Contrast left No step Otsu Divide Sauvola flag
22% 13,860 / 2,321 13,841 / 2,357 13,857 / 2,373 0 / 0
10% 13,837 / 2,338 13,824 / 2,348 13,847 / 2,362 0 / 0
5% 13,791 / 2,339 13,792 / 2,348 13,855 / 2,355 0 / 0

The danger here is the wrong threshold. -c thresholding_method=2 returned no text on all 127 pages at each level. On the one page I checked, its black and white copy came out fully white. The fixed threshold of 127 from the OpenCV tutorial gave no text on all 127 pages at 22% contrast, because the ink never gets that dark.

White text on black: leave it alone, or invert it first

The docs say to use dark text on a light background, but 5.5.3 tries weak lines again inverted. Whole white on black pages read 13,861 words and 2,345 numbers with no step at all. See pytesseract returns an empty string for what happens with that switched off.

The trap is in the fix for shadows: the divide step alone read 9,745 words on these pages. Invert first, as the recipe does, and it read 13,843. Tesseract's threshold flags also fell over here: method 2 read 321 numbers and method 1 read 482.

Why a threshold step rarely helps

Tesseract uses its black and white copy to find the lines. The reader itself then takes the original image: GetRectImage in linerec.cpp calls BestPix(), which returns the original when the size matches. So your threshold does not replace Tesseract's own step: it still makes its copy. What changes is the gray the reader sees. On clean books Otsu moved 17 pages up and 23 down, for a net of 8 words.

Two crops of the same text. The top crop is a clean gray scan. The bottom crop is covered in black specks after adaptive thresholding.
Top: clean scan. Bottom: the same text with grain added, after adaptive threshold with block 11 and C 2.

The worst threshold on clean and grainy pages was adaptive threshold with block 11 and C 2. Those are the example values in the OpenCV thresholding tutorial, which is about images in general, not OCR. A C of 2 is smaller than the scan noise next to each letter, so a halo of specks forms around the text. Add grain and the whole page fills with them. All 55 clean book pages got worse, down to 6,468 words in total, and grainy book pages fell to 0. Use a bigger block and a bigger C, or skip it.

Steps that did harm

All 28 steps on the 5 main sets

Words right / numbers right. The two non-local means rows ran on 2 sets only, because each takes over 5 seconds a page. The No step row is the one to beat. On it, a change of a few words is noise.

Step Clean Grainy Shadow, 35% light Faded White on black
No step 13,858 / 2,337 13,817 / 2,079 6,769 / 839 13,860 / 2,321 13,861 / 2,345
Grayscale 13,859 / 2,337 13,817 / 2,079 6,847 / 834 13,860 / 2,321 13,861 / 2,345
Otsu threshold 13,850 / 2,350 13,801 / 2,344 6,716 / 935 13,841 / 2,357 13,844 / 2,340
Fixed threshold 127 13,762 / 2,199 13,196 / 2,201 7,093 / 1,068 0 / 0 13,755 / 2,194
Adaptive, block 11, C 2 6,468 / 2,188 0 / 2 8,380 / 1,451 12,537 / 2,094 360 / 258
Adaptive, block 31, C 15 13,638 / 2,355 3,745 / 1,249 13,684 / 2,351 12,621 / 2,317 1,401 / 118
Gaussian blur 5, then Otsu 13,826 / 2,341 13,814 / 2,336 6,805 / 942 13,827 / 2,341 13,812 / 2,335
Median blur 3 13,859 / 2,348 13,824 / 2,055 6,837 / 901 13,832 / 2,343 13,857 / 2,344
Median blur 5 13,866 / 2,336 13,841 / 2,034 6,799 / 935 13,866 / 2,347 13,862 / 2,335
Median blur 5, then Otsu 13,790 / 2,333 13,776 / 2,322 6,741 / 933 13,793 / 2,330 13,801 / 2,321
Bilateral filter 13,855 / 2,335 13,860 / 2,208 6,875 / 948 13,794 / 2,282 13,860 / 2,336
Non-local means, h 10 13,860 / 2,353 13,860 / 2,245 not run not run not run
Non-local means, h 30 13,878 / 2,338 13,867 / 2,312 not run not run not run
Erode 2 by 2 13,885 / 2,318 13,827 / 2,117 7,026 / 885 13,879 / 2,316 13,817 / 2,338
Dilate 2 by 2 13,808 / 2,341 13,779 / 2,074 6,753 / 782 13,816 / 2,348 13,892 / 2,326
Open 2 by 2 13,865 / 2,339 13,839 / 2,059 6,797 / 887 13,866 / 2,339 13,841 / 2,346
Close 2 by 2 13,834 / 2,353 13,806 / 2,091 6,847 / 875 13,841 / 2,355 13,860 / 2,345
Erode 5 by 5 12,901 / 2,260 13,100 / 2,252 6,138 / 699 13,191 / 2,278 6,378 / 545
Dilate 5 by 5 6,288 / 540 7,613 / 437 2,783 / 113 6,829 / 613 12,915 / 2,263
Sharpen kernel 13,825 / 2,361 5,060 / 1,683 6,192 / 841 13,822 / 2,302 13,818 / 2,341
Unsharp mask 13,855 / 2,375 13,796 / 2,176 6,193 / 878 13,832 / 2,359 13,839 / 2,362
CLAHE 13,664 / 2,351 9,796 / 2,118 6,790 / 843 13,808 / 2,331 13,695 / 2,334
Contrast stretch 13,855 / 2,339 13,820 / 2,079 6,821 / 856 13,854 / 2,350 13,849 / 2,346
Resize 2 times 13,818 / 2,200 13,768 / 1,917 6,947 / 866 13,830 / 2,208 13,809 / 2,306
Invert 13,866 / 2,345 13,819 / 2,072 6,848 / 840 13,852 / 2,346 13,861 / 2,337
Divide 13,842 / 2,374 13,857 / 2,366 13,841 / 2,370 13,857 / 2,373 9,745 / 2,289
Divide, then Otsu 13,841 / 2,367 13,833 / 2,348 13,828 / 2,358 13,849 / 2,362 10,119 / 2,291
Flag: thresholding_method=1 13,301 / 2,323 12,896 / 454 11,929 / 1,880 13,323 / 2,339 13,581 / 482
Flag: thresholding_method=2 13,841 / 2,343 13,836 / 2,100 12,371 / 2,053 0 / 0 10,782 / 321

Test it on your own pages

My pages are not your pages. Type out one page into page.txt, save the image as page.jpg, and count how many of your words each step finds:

import difflib

import cv2
import pytesseract

gray = cv2.cvtColor(cv2.imread("page.jpg"), cv2.COLOR_BGR2GRAY)
truth = open("page.txt").read().split()

steps = {
    "no step": lambda g: g,
    "otsu": lambda g: cv2.threshold(g, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1],
    "divide": lambda g: cv2.divide(g, cv2.GaussianBlur(g, (0, 0), 25), scale=255),
}
for name, step in steps.items():
    words = pytesseract.image_to_string(step(gray), config="--dpi 300").split()
    match = difflib.SequenceMatcher(None, truth, words, autojunk=False)
    found = sum(b.size for b in match.get_matching_blocks())
    print(f"{name}: {found} of {len(truth)} words")

This is the output on one shadowed page. It counts lower than my bench, which rejoins words split at line ends and straightens quotes. The order is what matters.

no step: 114 of 238 words
otsu: 109 of 238 words
divide: 184 of 238 words

Do not use Tesseract's confidence to choose a step: Tesseract confidence score. For settings beyond the image, see how to improve Tesseract accuracy, and for better model files, tessdata_best vs fast.

If you do not want to build it

PDF OCR to Markdown is our Apify Actor. It renders each scanned page at 300 dpi in gray, turns it upright, deskews up to 5 degrees with the same sharpest rows idea, and applies a 3 pixel median filter. It does not do the divide step, so a photo with a shadow should lose text there too. Flatten those pages first. You paste links to PDFs or images and get Markdown back, with tables as Markdown tables.

I did not run these tests on it, so no number above describes it.

What I did not test

Questions people ask

What is the best image preprocessing for OCR?

On a clean scan, none. For photos and rough scans, divide the page by a blurred copy of itself, then deskew. That took my phone style set from 6,666 to 13,847 of 14,693 words, and it left clean pages almost where they were, at 13,844 against 13,858.

Should I binarize an image before Tesseract?

Not by default. Otsu changed 55 clean book pages by a net 8 words. It did help grainy invoices, from 2,079 to 2,344 of 2,418 numbers, but the divide step read 2,366. Tesseract makes its own black and white copy, and its text reader uses your original pixels.

Does Tesseract do its own preprocessing?

Yes. It makes a black and white copy with Otsu to find the lines, and it tries weak lines again inverted. So faded print and white on black read as well as clean pages. It did not cope with a shadow, or with tilt past about 2 degrees.

How do I remove a shadow from a document image for OCR?

Divide the gray image by a heavily blurred copy: cv2.divide(gray, cv2.GaussianBlur(gray, (0, 0), 25), scale=255). On pages with a shadow down to 35% light, that raised words from 6,769 to 13,841 and numbers from 839 to 2,370.

How much skew can Tesseract handle?

About 2 degrees. My invoices kept 96% of their numbers up to about 2.2 degrees of total tilt, 75% from 2.3 to 2.9, and 4% or less from 3.2. Book pages lost under 2% at 2 degrees, 13% at 3 and 45% at 5. With 10 degrees added, 117 of 127 pages gave no text at all, with no error.

Why does adaptive threshold make OCR worse?

With block 11 and C 2, faint noise turns into black specks around the letters. That lost 53% of the words on clean book scans and all of them on grainy ones. Block 31 and C 15 kept the clean pages and fixed shadows, but it still fell to 3,745 of 14,693 words on grainy pages.

How I tested: Tesseract 5.5.3, default English model, on a Mac, with OpenCV 5.0.0, pytesseract 0.3.13 and ImageMagick 7.1.2. Book pages are 55 proofread Wikisource scans from 11 books. A word counts when it appears in order with exact case. A number counts when the right row holds the exact text. The invoices and all added damage are synthetic. The invoices are drawn with their own slight tilt, 0.3 to 1.2 degrees, and a light blur, to look scanned. Every code block ran as printed on copies of those files, apart from the file names and the setup lines of the short divide block. The scripts and their output are in our private repo. Sources, read October 31, 2026: Improving the quality of the output (tessdoc), OpenCV image thresholding tutorial, Nanonets on Tesseract in Python (the 5 by 5 kernels and median blur 5), PyImageSearch on text skew, and at Tesseract 5.5.3 linerec.cpp and tesseractclass.h. We make the Apify Actors. Tesseract, OpenCV, ImageMagick, pytesseract and Wikisource are projects we do not make.