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 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.
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
-
Dilate 5 by 5: 6,288 words and 540 numbers on clean
pages.
cv2.dilategrows white, so it thins black text. On white text the two swap: erode 5 by 5 read 6,378 words. The 2 by 2 versions stayed close to no step. - Resize 2 times: 2,200 numbers against 2,337, with 161 read wrong against 49, for twice the CPU. Pages at 300 dpi do not need it. Small text does: best DPI for OCR.
- Sharpen kernel on grain: 5,060 words against 13,817. It sharpens the grain too.
- CLAHE on grain: 9,796 words against 13,817.
- Fixed threshold on faded print: no text at all.
-
thresholding_method=1on grain: 454 numbers against 2,079.
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.
- $0.006 a page, plus $0.00005 a run at the default 1 GB. Failed and blank pages are free.
- Links in a sheet? Use Bulk OCR from Google Sheets and CSV, $0.005 an OCR page.
- Pages are read inside the run. They are not sent to any AI service.
I did not run these tests on it, so no number above describes it.
What I did not test
- Real phone photos. Every shadow, tilt, fade, grain on books and dark page here is mine, added by code. Real photos also bring curved pages and perspective, which no step here fixes.
- Real invoices. All 72 are made up. Only the 55 book pages are real scans.
- Pages that mix dark and light areas, such as a dark header bar. The recipe decides for the whole page.
- Colored paper beyond the warm tint on the phone style set, stamps and handwriting.
- Tables read at psm 4 or 6 on level pages.
- Blur sizes on pages much larger or smaller than these.
- Any language but English, or any model but the default.
- Any machine but this Mac.
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.