Sythra

Article

Extract text from an image with Python (OCR that actually works)

Tesseract plus a few lines of OpenCV preprocessing turns blurry photos into clean text. Learn the cleanup steps that take accuracy from unusable to reliable.

vaibhavkothari· Aug 26, 2026· 6 min readBeginner
ShareXLinkedIn
Extract text from an image with Python (OCR that actually works) cover

Most OCR tutorials show you three lines, get poor results, and stop. The three lines are real, but the accuracy comes from what you do to the image before those lines run.

OCROptical Character Recognition — reading text out of a picture engines want black text on a flat white background, straight and reasonably large. Photos are none of those things. This guide covers the cleanup that turns garbage output into something you can use.

What you need

Tesseract is a separate program, not just a Python package. Install both.

Windows — download the installer from UB Mannheim's build, then note the install path.

macOS

brew install tesseract

Linux

sudo apt install tesseract-ocr

Then the Python side:

python -m pip install pytesseract opencv-python pillow

Step 1 — Confirm Tesseract is reachable

import pytesseract

# Windows only — point at the executable you installed
# pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"

print(pytesseract.get_tesseract_version())

TesseractNotFoundError means the program is installed but Python cannot find it. Uncomment that line and set your real path. This is the single most common setup failure.

Step 2 — Read text the naive way

import cv2
import pytesseract

image = cv2.imread("receipt.jpg")
if image is None:
    raise FileNotFoundError("Check the path — imread returned None")

text = pytesseract.image_to_string(image)
print(text)

That is None guard is not optional. cv2.imread returns None for a bad path without raising anything — the trap explained in NoneType is not subscriptable.

On a clean scan this already works. On a phone photo the output is usually a mess. Fix that next.

Step 3 — Preprocess: the part that matters

Five steps, in this order:

import cv2

def prepare(path):
    image = cv2.imread(path)
    if image is None:
        raise FileNotFoundError(path)

    # 1. Grayscale — colour carries no information for OCR
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

    # 2. Upscale — Tesseract wants characters roughly 30 px tall
    gray = cv2.resize(gray, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)

    # 3. Denoise while keeping edges sharp
    gray = cv2.bilateralFilter(gray, 9, 75, 75)

    # 4. Adaptive threshold — handles uneven lighting
    binary = cv2.adaptiveThreshold(
        gray, 255,
        cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
        cv2.THRESH_BINARY,
        blockSize=31, C=15,
    )

    return binary

Why each step earns its place:

  • Grayscale — Tesseract ignores colour anyway
  • Upscale 2× — small text is the number one cause of bad OCR
  • Bilateral filter — removes grain without softening letter edges the way a blur would
  • Adaptive threshold — computes a separate cutoff per region, so a shadow across half the page stops mattering

Plain cv2.threshold with a fixed value works on flat scans and fails on anything photographed by hand. Prefer the adaptive version.

Step 4 — Straighten the page

Even five degrees of rotation hurts accuracy noticeably. Tesseract can measure the skew for you:

import cv2
import numpy as np
import pytesseract

def deskew(binary):
    osd = pytesseract.image_to_osd(binary, output_type=pytesseract.Output.DICT)
    angle = osd.get("rotate", 0)

    if angle == 0:
        return binary

    h, w = binary.shape[:2]
    matrix = cv2.getRotationMatrix2D((w // 2, h // 2), -angle, 1.0)
    return cv2.warpAffine(
        binary, matrix, (w, h),
        flags=cv2.INTER_CUBIC,
        borderMode=cv2.BORDER_REPLICATE,
    )

image_to_osd reports orientation and script detection. It occasionally throws on images with very little text, so wrap it in try/except and fall back to the unrotated image.

Step 5 — Tell Tesseract what shape the text is

The --psm (page segmentation mode) flag changes results dramatically. Using the right one is often worth more than any preprocessing:

ModeUse for
--psm 3Default — a full page with mixed layout
--psm 4A single column, like a receipt
--psm 6A uniform block of text
--psm 7One single line
--psm 8One single word
--psm 11Sparse text scattered anywhere
config = "--oem 3 --psm 6"
text = pytesseract.image_to_string(binary, config=config)

Restrict the character set when you know the format — this alone eliminates most of the classic O versus 0 confusion:

digits_only = "--psm 7 -c tessedit_char_whitelist=0123456789"
plate = pytesseract.image_to_string(binary, config=digits_only)

Step 6 — Get positions, not just text

image_to_data returns every word with its bounding box and a confidence score, which lets you filter out junk:

import pytesseract
from pytesseract import Output

data = pytesseract.image_to_data(binary, output_type=Output.DICT)

for i, word in enumerate(data["text"]):
    confidence = int(data["conf"][i])
    if word.strip() and confidence > 60:
        x, y = data["left"][i], data["top"][i]
        print(f"{word!r} at ({x}, {y}) confidence {confidence}")

Dropping everything under 60 confidence removes most nonsense output in one line. You can also draw the boxes on the original image to see exactly what Tesseract read and where.

Step 7 — A reusable function

import cv2
import pytesseract

def image_to_text(path, psm=6, whitelist=None):
    image = cv2.imread(path)
    if image is None:
        raise FileNotFoundError(path)

    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    gray = cv2.resize(gray, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)
    gray = cv2.bilateralFilter(gray, 9, 75, 75)
    binary = cv2.adaptiveThreshold(
        gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
        cv2.THRESH_BINARY, 31, 15,
    )

    config = f"--oem 3 --psm {psm}"
    if whitelist:
        config += f" -c tessedit_char_whitelist={whitelist}"

    return pytesseract.image_to_string(binary, config=config).strip()


if __name__ == "__main__":
    print(image_to_text("receipt.jpg", psm=4))

Pair it with the folder-walking pattern from our file renaming guide and you can OCR an entire directory of scans in one run.

Common problems (and fixes)

ProblemFix
TesseractNotFoundErrorSet tesseract_cmd to the real executable path
Output is gibberishUpscale 2–3× and switch to adaptive threshold
Numbers read as lettersAdd tessedit_char_whitelist=0123456789
Columns merge togetherTry --psm 4 or crop each column separately
Blank outputThe image may be inverted — try cv2.bitwise_not
Slow on large scansResize down to about 2000 px on the long edge

FAQ

How do I extract text from an image in Python?

Install Tesseract and pytesseract, load the image with OpenCV, convert it to grayscale, upscale it, apply an adaptive threshold, then call pytesseract.image_to_string on the cleaned image.

Why is my Tesseract OCR accuracy so bad?

Almost always the input image. Characters should be roughly 30 pixels tall, the background flat white, and the page straight. Upscaling 2× and using adaptive thresholding fixes most cases.

What does the psm option do in Tesseract?

Page segmentation mode tells Tesseract what layout to expect. Use 6 for a block of text, 4 for a single column such as a receipt, and 7 for a single line.

How do I fix TesseractNotFoundError in Python?

The Tesseract program is not on your PATH. Set pytesseract.pytesseract.tesseract_cmd to the full path of tesseract.exe on Windows, or install it with Homebrew or apt on macOS and Linux.

Can I get the position of each word from Tesseract?

Yes. pytesseract.image_to_data returns each word with its bounding box coordinates and a confidence score, so you can filter low-confidence results and draw boxes on the image.

Next reading on Sythra Articles

Keep reading

Related articles

Newsletter

Get new articles

Free essays on learning, Python, and ML — no account required. We’ll only email when there’s something worth reading.