Improve OCR Accuracy with Image Preprocessing
Tesseract runs, returns text, and the text is wrong in ways that break every downstream parser:
Invoice Nurnber: 1NV-2O26-OO47 # rn read as m, O read as 0, 1 read as I
Tota1 due: £1,2S0.OO
Date: O1/O8/2O26
Nothing is misconfigured. Tesseract is a line-based recogniser tuned for clean, high-contrast, upright text at roughly 300 DPI, and a typical scan is none of those things. Preprocessing the image before recognition is worth more than any parameter change — a 200 DPI grey scan of a skewed page routinely goes from 82% to 98% character accuracy with four cheap transforms.
Root Cause
Recognition happens in three stages: page layout analysis finds text lines, each line is segmented into characters, and each character is matched against the trained model. Every stage degrades differently.
Low resolution starves the character matcher: below roughly 20 pixels of x-height, the difference between rn and m is a couple of pixels. Skew breaks line-finding, because Tesseract's layout analysis assumes near-horizontal baselines and a 2° tilt is enough to merge two lines into one. Uneven illumination — the grey gradient every flatbed scanner produces — defeats a global threshold, so parts of the page binarise to solid black or vanish entirely. Speckle noise from a photocopier adds spurious connected components that the segmenter reads as punctuation.
Prerequisites
sudo apt-get update && sudo apt-get install -y tesseract-ocr poppler-utils
pip install pytesseract opencv-python-headless pdf2image numpy
If pytesseract cannot find the binary, fix that before tuning anything — the symptom and fix are in fix Tesseract not found error.
Fix: The Preprocessing Pipeline
Each stage is a few lines, and each is independently switchable so you can measure what actually helps on your scans.
# pip install opencv-python-headless numpy
import cv2
import numpy as np
def to_grey(image: np.ndarray) -> np.ndarray:
"""Drop colour: Tesseract ignores it and it triples the data to threshold."""
if image.ndim == 3:
return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
return image
def upscale(image: np.ndarray, target_height: int = 3300) -> np.ndarray:
"""Resample to roughly 300 DPI for A4. Cubic keeps stroke edges sharp."""
if image.shape[0] >= target_height:
return image
factor = target_height / image.shape[0]
return cv2.resize(image, None, fx=factor, fy=factor, interpolation=cv2.INTER_CUBIC)
def binarise(image: np.ndarray) -> np.ndarray:
"""Adaptive threshold: computes a local cutoff, so a lamp gradient cannot wash out a corner."""
return cv2.adaptiveThreshold(
image, 255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY,
blockSize=31, # odd, ~2x the stroke width in pixels
C=15, # subtracted from the local mean; raise to keep faint text
)
def despeckle(image: np.ndarray) -> np.ndarray:
"""Median blur removes isolated dots without softening glyph edges."""
return cv2.medianBlur(image, 3)
def deskew(image: np.ndarray) -> np.ndarray:
"""Estimate the dominant text angle and rotate the page level."""
inverted = cv2.bitwise_not(image)
coords = np.column_stack(np.where(inverted > 0))
if coords.size == 0:
return image # blank page: nothing to align
angle = cv2.minAreaRect(coords)[-1]
angle = -(90 + angle) if angle < -45 else -angle
if abs(angle) < 0.1:
return image # already level; skip the resample
height, width = image.shape[:2]
matrix = cv2.getRotationMatrix2D((width / 2, height / 2), angle, 1.0)
return cv2.warpAffine(
image, matrix, (width, height),
flags=cv2.INTER_CUBIC,
borderMode=cv2.BORDER_REPLICATE, # never introduce a black border Tesseract reads as ink
)
def preprocess(image: np.ndarray) -> np.ndarray:
"""Full pipeline in the order that compounds rather than fights."""
stage = to_grey(image)
stage = upscale(stage)
stage = binarise(stage)
stage = despeckle(stage)
return deskew(stage)
Wire it to a PDF page and run recognition:
# pip install pytesseract opencv-python-headless pdf2image numpy
from pathlib import Path
import cv2
import numpy as np
import pytesseract
from pdf2image import convert_from_path
SCAN = Path("data/scanned_invoice.pdf")
def ocr_page(pdf_path: Path, page_number: int = 1, dpi: int = 300) -> str:
"""Rasterise one page, preprocess it, and recognise the text."""
if not pdf_path.exists():
raise FileNotFoundError(f"No such file: {pdf_path}")
try:
pages = convert_from_path(str(pdf_path), dpi=dpi,
first_page=page_number, last_page=page_number)
except Exception as exc:
raise RuntimeError(f"Rasterising failed (is poppler installed?): {exc}") from exc
image = cv2.cvtColor(np.array(pages[0]), cv2.COLOR_RGB2BGR)
cleaned = preprocess(image)
# psm 6: assume a single uniform block of text — right for invoices and statements
return pytesseract.image_to_string(cleaned, config="--oem 3 --psm 6")
if __name__ == "__main__":
print(ocr_page(SCAN)[:600])
Rasterising at 300 DPI in convert_from_path and also upscaling is not redundant work: dpi controls how much detail exists, upscale only rescues pages that arrived as images at a lower resolution. When you control the rasterisation, ask for 300 DPI and the upscale becomes a no-op.
Variant: Choosing a Page Segmentation Mode
--psm tells Tesseract what layout to expect, and the wrong choice costs more accuracy than any filter recovers.
| Mode | Use when | Typical page |
|---|---|---|
--psm 3 | Fully automatic, multiple columns | Newspaper, magazine |
--psm 4 | Single column of variable-size text | Letter, memo |
--psm 6 | One uniform block of text | Invoice, statement, form |
--psm 7 | A single line | A cropped field, a total |
--psm 11 | Sparse text, no order assumed | Diagram labels, receipts |
Modes 6 and 4 cover most document automation. Mode 11 is the one to reach for when a receipt's short, scattered lines are being merged into nonsense.
Variant: Measure Before You Tune
Preprocessing choices are empirical. Tesseract reports a per-word confidence, which is enough to compare two pipelines on a real batch without hand-labelling a ground truth.
# pip install pytesseract pandas opencv-python-headless
import pandas as pd
import pytesseract
def mean_confidence(image, config: str = "--oem 3 --psm 6") -> float:
"""Average Tesseract's own word confidence, ignoring empty boxes."""
data = pytesseract.image_to_data(image, config=config, output_type=pytesseract.Output.DATAFRAME)
words = data[(data.conf != -1) & data.text.notna() & (data.text.str.strip() != "")]
return float(words.conf.mean()) if len(words) else 0.0
def compare(raw_image) -> pd.DataFrame:
"""Score each pipeline variant so the choice is measured, not assumed."""
variants = {
"raw": raw_image,
"grey": to_grey(raw_image),
"grey+threshold": binarise(to_grey(raw_image)),
"full pipeline": preprocess(raw_image),
}
rows = [{"variant": name, "mean_conf": round(mean_confidence(img), 1)}
for name, img in variants.items()]
return pd.DataFrame(rows).sort_values("mean_conf", ascending=False)
Load the comparison into pandas as above and the answer for a given scanner arrives in one run:
Verification
Confidence is a proxy. For fields that must be right, verify structurally: a known regular expression must match, and a checksum-style total must reconcile.
# pip install pytesseract opencv-python-headless
import re
INVOICE_RE = re.compile(r"\bINV-\d{4}-\d{4}\b")
AMOUNT_RE = re.compile(r"£\s?\d{1,3}(?:,\d{3})*\.\d{2}")
def assert_fields_readable(text: str) -> None:
"""Fail when OCR output cannot yield the fields the parser needs."""
invoice = INVOICE_RE.search(text)
assert invoice, "no invoice reference matched — OCR quality too low to parse"
amounts = AMOUNT_RE.findall(text)
assert amounts, "no monetary amounts matched — check thresholding and DPI"
print(f"reference {invoice.group()} and {len(amounts)} amount(s) recovered")
Two habits make this durable. Keep three or four representative scans as a fixture set and run the comparison on every dependency bump, because a Tesseract or OpenCV upgrade can shift the best variant. And write the preprocessed image to disk when an assertion fails — looking at what Tesseract was actually given answers most questions in seconds. The full scanned-document workflow, including multi-page batching and table reconstruction, is in how to extract tables from scanned PDFs.
FAQ
What DPI should I scan at? 300 DPI for body text, 400–600 for small print or dense tables. Beyond 600 accuracy plateaus while processing time and memory keep climbing, so it is wasted cost on a batch.
Adaptive or global thresholding?
Adaptive, unless the page is already clean and evenly lit. A global cv2.threshold with Otsu's method is faster and fine for born-digital renders; on flatbed scans it loses the corner nearest the lamp.
Does deskewing ever make things worse?
Yes, on pages with large images or heavy rules, where minAreaRect locks onto the artwork rather than the text. Guard it with the small-angle skip shown above and clamp corrections to a few degrees.
Should I remove lines from tables before OCR? For column-structured pages, yes — morphological line removal stops the recogniser reading rules as characters. Do it after thresholding and keep a copy of the line mask if you also need the grid for reconstruction.
Is a different OCR engine worth trying? When preprocessing plateaus below about 90% on clean input, the limit is usually the model rather than the image. Compare against an alternative engine on the same fixture set before investing more in filters.
Knowing When to Stop Tuning
Preprocessing has a ceiling, and recognising it saves days. Once the pipeline reaches roughly ninety-five per cent character accuracy on clean input, further filter tuning tends to trade one error class for another: sharpening recovers thin strokes and starts splitting joined characters, and aggressive despeckling removes noise along with the dots on the letter i.
The better investment past that point is usually structural. Constraining the recognition to the alphabet a field can contain — digits and a decimal point for an amount, uppercase and digits for a reference — removes whole categories of confusion that no image filter can. So does validating against a checksum or a known list where one exists, because a reference that fails its own check digit can be re-read at higher resolution rather than shipped wrong.
And for the pages that still fail, route them to a human rather than to another filter. A one per cent manual review rate on a thousand-page month is ten pages; chasing that last percentage point in code has cost far more than ten pages of attention in most projects that have tried it.
Related
- Scanning and OCR Processing with Python — the end-to-end OCR workflow
- Fix Tesseract Not Found Error — binary and PATH setup
- How to Extract Tables from Scanned PDFs — turning OCR output into rows
- Fix pdfplumber Returning Empty Text — deciding when OCR is the only option
- Extracting PDF Data into pandas — validating recognised values downstream