Tools,

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

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.

For PDFs from Python, see pytesseract PDF to text: render each page, then pass it to upright().

What I did not test

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.