---
title: "Extract text from an image with Python (OCR that actually works)"
description: "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."
url: https://articles.sythra.ai/articles/extract-text-from-image-python-ocr
slug: extract-text-from-image-python-ocr
author: "vaibhavkothari"
author_url: https://articles.sythra.ai/writers/vaibhavkothari
date_published: 2026-08-26T08:30:17.365Z
date_modified: 2026-08-29T10:29:25.265Z
topics: ["Python", "Opencv", "Computer Vision", "Coding"]
reading_time_minutes: 6
publisher: "Sythra"
publisher_url: https://sythra.ai
access: free
language: en
---

# 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.

Source: https://articles.sythra.ai/articles/extract-text-from-image-python-ocr · Author: vaibhavkothari · Published: 2026-08-26 · Reading time: 6 min · Topics: Python, Opencv, Computer Vision, Coding

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.

OCR 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](https://github.com/UB-Mannheim/tesseract/wiki), then note the install path.

**macOS**

```bash
brew install tesseract
```

**Linux**

```bash
sudo apt install tesseract-ocr
```

Then the Python side:

```bash
python -m pip install pytesseract opencv-python pillow
```

## Step 1 — Confirm Tesseract is reachable

```python
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

```python
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](https://articles.sythra.ai/articles/nonetype-not-subscriptable-fix).

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:

```python
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:

```python
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:

| Mode | Use for |
|---|---|
| `--psm 3` | Default — a full page with mixed layout |
| `--psm 4` | A single column, like a receipt |
| `--psm 6` | A uniform block of text |
| `--psm 7` | One single line |
| `--psm 8` | One single word |
| `--psm 11` | Sparse text scattered anywhere |

```python
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:

```python
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:

```python
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

```python
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](https://articles.sythra.ai/articles/python-rename-files-script) and you can OCR an entire directory of scans in one run.

## Common problems (and fixes)

| Problem | Fix |
|---|---|
| `TesseractNotFoundError` | Set `tesseract_cmd` to the real executable path |
| Output is gibberish | Upscale 2–3× and switch to adaptive threshold |
| Numbers read as letters | Add `tessedit_char_whitelist=0123456789` |
| Columns merge together | Try `--psm 4` or crop each column separately |
| Blank output | The image may be inverted — try `cv2.bitwise_not` |
| Slow on large scans | Resize down to about 2000 px on the long edge |

```remember
# Remember this
Preprocessing -> matters far more than the OCR engine
Grayscale, threshold, deskew -> the three that pay for themselves
`--psm` -> tells Tesseract what shape the text is in
---
Bad OCR is almost always a bad image, not a bad model.
```


## 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

- [Real-time face detection: MediaPipe vs Haar](https://articles.sythra.ai/articles/face-detection-python-mediapipe-vs-haar)
- [Stop renaming files by hand](https://articles.sythra.ai/articles/python-rename-files-script)
- [ModuleNotFoundError: No module named 'cv2'](https://articles.sythra.ai/articles/no-module-named-cv2-fix)

## Glossary (terms defined in this article)
- **OCR** (basic) — Optical Character Recognition — reading text out of a picture
