Tesseract orientation detection: turn sideways and upside-down scans upright, tested on 408 turns
Tesseract does not turn a page for you in its default mode. On my 102
test pages turned upside down, every page came out under 0.2 word
match, with no warning. Ask its orientation check,
image_to_osd, for the turn, and rotate with Pillow the
right way. The code below got 408 of 408 turns right: each page
upright and turned 90, 180 and 270 degrees.
Try it on your PDF, $0.006 a page
The code
Tesseract 5.5.3, pytesseract 0.3.13, Pillow 13.0.0, Python 3.13, on a
Mac. The check needs osd.traineddata. Homebrew installs
it, and tesseract --list-langs lists it as
osd.
import pytesseract
from PIL import Image
from pytesseract import Output, TesseractError
def osd_turn(image):
"""Degrees clockwise that make the page upright, from Tesseract's OSD."""
try:
return pytesseract.image_to_osd(image, output_type=Output.DICT)["rotate"]
except TesseractError: # "Too few characters": try once at twice the size
big = image.resize((image.width * 2, image.height * 2), Image.Resampling.LANCZOS)
try:
return pytesseract.image_to_osd(big, output_type=Output.DICT)["rotate"]
except TesseractError:
return 0 # a nearly empty page: leave it as it is
def upright(image):
"""Return the page turned upright, and the total turn in degrees clockwise."""
total = 0
for _ in range(2): # a second look catches a sideways page turned the wrong way
turn = osd_turn(image)
if turn == 0:
break
# Pillow's rotate() turns counterclockwise, so pass -turn.
# expand=True keeps the corners of a page that is taller than it is wide.
image = image.rotate(-turn, expand=True)
total = (total + turn) % 360
return image, total
Then OCR the turned page, with the scan's own dpi:
image = Image.open("scan.jpg")
page, turn = upright(image)
print("turned", turn, "degrees clockwise")
# pytesseract hands Tesseract a copy with no dpi, so pass the scan's own.
dpi = round(image.info.get("dpi", (300, 300))[0])
print(pytesseract.image_to_string(page, config=f"--dpi {dpi}"))
On our scanned test invoice, fed sideways (turned 90 degrees clockwise, 300 dpi), this printed (the shop name line is left out here):
turned 270 degrees clockwise
Date: October 12, 2026. Terms: net 30.
SKU Description Qty | Unit price | Amount
1001 | Copper pipe 3/4in 6 | 113.82 682.92
1002 | PEX fitting kit 7 (| 78.64 550.48
1003 | Ball valve 1/2in 2 | 98.82 197.64
The text is the same as the upright page gives. The
(| is a grid line, which the
table extraction page
strips.
What image_to_osd prints
image_to_osd runs Tesseract with --psm 0,
"Orientation and script detection (OSD) only." On the sideways
invoice:
Page number: 0
Orientation in degrees: 90
Rotate: 270
Orientation confidence: 0.55
Script: Cyrillic
Script confidence: 1.33
- Orientation in degrees is how far the page is turned now: 90 for my page turned 90 degrees clockwise.
- Rotate is the turn that fixes it. The Tesseract source says it plainly: "clockwise rotation needed to make the page upright". Use this one.
- Script is not reliable on a sideways page. This invoice is in English, and the check said Cyrillic.
With output_type=Output.DICT, pytesseract gives the same
lines as a dict:
{'page_num': 0, 'orientation': 90, 'rotate': 270, 'orientation_conf': 0.55, 'script': 'Cyrillic', 'script_conf': 1.33}
Three ways to get it wrong
1. Turning the wrong way
Pillow's rotate() turns "counter clockwise", and
rotate from Tesseract is clockwise. Pass it without the
minus sign, and a sideways page ends up upside down. On 98 sideways
pages, image.rotate(rotate, expand=True) gave a median
word match of 0.074. The invoice's first line came out as
"sSouIsng InOA JO} NOA YunU |,, which is "Thank you for
your business" read upside down.
2. Leaving out expand=True
Without it, Pillow keeps the old width and height. A sideways page is
wider than it is tall, so the upright page gets its top and bottom cut
off. On the same 98 pages, the median word match fell to 0.616. The
invoice's heading line, the shop name and invoice number, came out as
tie dhe and a dash.
3. Losing the dpi
A turned Pillow image goes to Tesseract as a new file with no dpi, and
Tesseract prints "Invalid resolution 0 dpi. Using 70 instead". On the
upright invoice with no --dpi, the table came out as
noise:
1001, Copper pipe 3/4in
om
one a
rs | Bat wave van [2 |oma
With config="--dpi 300", the same page gave the clean
rows above.
Sideways is a close call
Upright against upside down is easy for the check. Sideways, 90
against 270, can be close. The invoice above, saved as a JPEG and
passed as a file path (tesseract scan.jpg - --psm 0 does
the same), got Rotate: 90 at confidence 0.36. That is
wrong. The same file, opened with Pillow first, got the right 270 at
0.55. pytesseract saves an opened JPEG again, without its dpi, before
Tesseract reads it, and that small change was enough.
That is why upright() looks twice. A sideways page turned
the wrong way is upside down, and the check finds that clearly. Here,
the second look said 180 at confidence 5.21, so the total became 270,
the right turn. In the full test the first look was never wrong, so
the second look changed nothing there. It costs one more check, and
only on pages that were turned.
Do not filter on the confidence instead. Right answers went as low as 0.01, and 5 of the 204 upright and upside-down answers were under 1. On upright pages, the check said 0 on all 102.
Too few characters. Skipping this page
The check needs some letter shapes to vote on. 4 small screenshots
(360 to 520 pixels wide) passed upright and failed turned sideways, 8
cases in all. Tesseract printed "Too few characters. Skipping this
page" and exited with code 1, which pytesseract raises as a
TesseractError.
| Second try | Right, of the 8 |
|---|---|
| The same image at twice the size (the code above) | 8, confidence 6.85 to 12.16 |
-c min_characters_to_try=10 |
2, confidence 0.23 or less |
min_characters_to_try is 50 by default. Lowering it gets
an answer, but on these 8 it was mostly the wrong one.
Does --psm 1 do it for you?
Partly. psm 1 is "Automatic page segmentation with OSD." The test: 102 pages. 101 we made on a computer, most with a scan look: 72 made-up invoices and stock lists, our scanned test invoice, 16 dense forms, 8 screenshots and 4 tables with no lines. The last is one clean scan of a typed page (Executive Order 9066, page 1). Each page upright and turned 90, 180 and 270 degrees clockwise. Word match is the share of the upright page's OCR words that come back in the other OCR, in any order.
| Page turned (clockwise) | psm 3 (default), as is | psm 1, as is | upright(), then psm 3 |
|---|---|---|---|
| Not turned | 1.000, 102 of 102 | 1.000, 102 | 1.000, 102 |
| 90 | 0.848, 37 | 0.848, 37 | 1.000, 102 |
| 180 | 0.068, 0 | 0.807, 33 | 1.000, 102 |
| 270 | 0.061, 0 | 0.778, 26 | 1.000, 102 |
Each cell: the median word match, then the pages at 0.9 or more. Turned 180 or 270, all 102 pages were under 0.2 in psm 3. Turned 90, Tesseract still found most words, but not in order: on the invoice, the prices came out above their rows.
The confidence does not warn you
Turned 90, the median mean confidence was 82.0, against 85.2 upright. The sideways invoice scored 86.6, higher than the upright one at 85.3. Upside down, it was 38.1. So a low score can mean a turned page, but a high score does not mean an upright one. More on what the score catches: Tesseract confidence score.
If you do not want to build it
PDF OCR to Markdown is our Apify Actor. Since build 0.1.29 (October 31, 2026), it runs the orientation check on every scanned page, turns the page, and adds a warning. For the sideways invoice: "This page was sideways or upside down, so it was turned 270 degrees clockwise before OCR. If the text looks wrong, check the page." On the same 102 pages, all 306 turned copies gave the same text as the upright page. The check adds about 0.13 seconds a scanned page on our Mac.
- $0.006 a page, plus $0.00005 a run at the default 1 GB of memory. Failed and blank pages are free.
- The Apify free plan lets you try 20 pages a run and 50 a month.
- Pages are read inside the run. They are not sent to any AI service.
For PDFs from Python, see
pytesseract PDF to text:
render each page, then pass it to upright().
What I did not test
- Real scans and phone photos. 101 of the 102 pages were made on a computer, so real scans can do worse.
- A small tilt, a few degrees off. OSD only answers 0, 90, 180 or 270. For a tilt, see how to improve Tesseract accuracy (deskew).
- A page with text in two directions, such as a sideways table on an upright page. OSD gives one answer for the whole page.
- Windows and Linux, other Tesseract versions, and languages other than English.
- Speed. The test ran 8 pages at a time, so its times are not one page's time.
Questions people ask
How do I detect text orientation with Tesseract?
Run its orientation check: tesseract scan.jpg - --psm 0,
or pytesseract.image_to_osd in Python. The Rotate line is
the turn, in degrees clockwise, that makes the page upright. It needs
osd.traineddata, which Homebrew installs.
How do I auto-rotate an image with pytesseract?
Read rotate from image_to_osd with
Output.DICT, then call
image.rotate(-rotate, expand=True). Pillow turns
counterclockwise, so the minus sign matters. Pass the scan's dpi to
the OCR call after the turn.
What is the difference between Orientation in degrees and Rotate?
Orientation in degrees is how far the page is turned now. Rotate is the clockwise turn that makes it upright. On a page turned 90 degrees clockwise, Orientation is 90 and Rotate is 270. Use Rotate.
What does Too few characters. Skipping this page mean?
The orientation check found too few letter shapes to vote on. It happened on small screenshots turned sideways. Running the check on a copy at twice the size fixed all 8 cases in my test.
What is a good Tesseract orientation confidence?
There is no safe floor. In my test, right answers had confidence as low as 0.01. Check the page again after the turn instead: a page turned the wrong way is upside down, and the check finds that clearly.
Also in this set: Tesseract table extraction, pytesseract returns an empty string, scanned PDF to text, image to Markdown.
How this was made: Tack, our AI personality, ran Tesseract 5.5.3
(Homebrew, with its osd model) and pytesseract 0.3.13 with Pillow
13.0.0 on a Mac on October 31, 2026, and copied every result above
from that run. Every code block on this page ran as printed. The
scripts and their output are in
research/bench/tesseract-orientation-2026-10-31/. The test pages are
ones we made on a computer, most with a scan look added, and one
public scan. Sources, read October 31, 2026:
baseapi.cpp
(GetOsdText) and
osdetect.cpp
(Tesseract 5.5.3 source),
Improving the quality of the output
(Tesseract docs),
tesseract --help-psm and --print-parameters,
pytesseract.py at 0.3.13
and
Image.rotate
(Pillow docs). We make the Apify Actor. Tesseract, pytesseract and
Pillow are free tools we do not make.