Python OCR PDF Workflows That Handle Scanned Documents

Posted on | Last updated on
Python OCR PDF Workflows That Handle Scanned Documents

Python OCR PDF work has one rule that saves most of the effort: only run recognition on the pages that need it. A PDF is a container, its pages can be text or images independently, and a twenty-page contract is often nineteen text pages and one scanned signature page.

Recognising all twenty is slower, less accurate and more expensive than recognising one.

Key takeaways

  • Classify pages, not documents, since one PDF can hold both text and scans.
  • Render at 300 DPI; below 200 the recogniser guesses and does not tell you.
  • Keep a flag marking which pages were recognised, because that text carries uncertainty.
  • Read the confidence scores, and hold digits to a stricter threshold than words.
  • Store the original page alongside the text, because extraction rules change.

Sorting pages before recognising any

The wider pipeline this sits inside is described in the guide to automated OCR data extraction.

The check is a character count per page, not per document: pull the text PyMuPDF already sees for each page, and treat anything under about fifty characters as an image rather than text.

pip install pymupdf
import fitz  # pymupdf

TEXT_FLOOR = 50

doc = fitz.open("contract.pdf")
text_pages = [i for i, page in enumerate(doc) if len(page.get_text().strip()) >= TEXT_FLOOR]
image_pages = [i for i in range(doc.page_count) if i not in text_pages]

print(f"{len(text_pages)} text pages, {len(image_pages)} needing recognition")

Fifty is a deliberate floor rather than zero, since scanned pages often carry a few stray characters from a header stamp or watermark, and a test for “any text at all” would misclassify them. Tune TEXT_FLOOR against your own files.

Everything in text_pages gets extracted exactly. Only image_pages costs you recognition, which is where the rest of this article spends its time.

Rasterising at the right resolution

Recognition operates on pixels, so image pages have to be rendered out of the PDF first. The resolution you choose matters more than the engine you choose.

import fitz

doc = fitz.open("contract.pdf")
page = doc[7]
pix = page.get_pixmap(dpi=300)
pix.save("page-7.png")

300 DPI is the number to use. Below about 200 the recogniser starts guessing at characters, and the failure is quiet: it returns confident words that are wrong rather than admitting it could not read them. Above 400 the file gets large and slow with no accuracy gained, because the limiting factor is the original scan rather than the render.

If the original scan was itself low resolution, rendering at 300 does not recover detail that was never captured. It only stops you losing more.

Running recognition locally

With pages rendered, pytesseract is the shortest path:

pip install pytesseract pymupdf pillow opencv-python numpy
brew install tesseract        # or: apt-get install tesseract-ocr
import fitz, pytesseract
from PIL import Image
import io

def ocr_page(doc, index, dpi=300):
    pix = doc[index].get_pixmap(dpi=dpi)
    img = Image.open(io.BytesIO(pix.tobytes("png")))
    return pytesseract.image_to_string(img)

doc = fitz.open("contract.pdf")
for i in image_pages:
    print(f"--- page {i}")
    print(ocr_page(doc, i))

For pages that come back badly, preprocessing does more than switching engine:

import cv2, numpy as np

def prepare(pil_img):
    arr = np.array(pil_img.convert("L"))
    arr = cv2.threshold(arr, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]
    return cv2.medianBlur(arr, 3)

Greyscale, threshold to pure black and white, remove speckle. Pass the result of prepare to image_to_string in place of the raw image.

Rebuilding one document from two paths

The output people actually want is the whole PDF as text, in page order, regardless of which route each page took.

def extract_all(path):
    doc = fitz.open(path)
    out = []
    for i, page in enumerate(doc):
        text = page.get_text().strip()
        if len(text) < 50:
            text = ocr_page(doc, i)
        out.append({"page": i, "text": text, "recognised": len(page.get_text().strip()) < 50})
    return out

Keeping the recognised flag is worth the extra field. Recognised text carries uncertainty that extracted text does not, and any downstream step doing exact matching should know which kind it is looking at.

Where a hosted API fits

The local path is fine until the PDFs stop being predictable or the volume stops being steady. Rendering, preprocessing and recognition all happen on your machine, which has to be sized for the busiest week rather than the average one.

With Filestack, the file is stored once and both paths are tasks against the same handle. Text extraction needs no signature:

import requests

handle = "jLoemkNMSiyxhKHxo4G0"
text = requests.get(f"https://cdn.filestackcontent.com/output=format:txt/{handle}").text

And the metadata that tells you which route to take:

info = requests.get(f"https://cdn.filestackcontent.com/pdfinfo/{handle}").json()
print(info["pages"], info["encrypted"], info["protected"])

Recognition for the image pages is the ocr task, and unlike the two calls above it is secured. A request carrying only a handle returns HTTP 403, so it needs your API key and a policy signed with your app secret:

import base64
import hashlib
import hmac
import json
import os
import time

API_KEY = os.environ["FILESTACK_API_KEY"]
APP_SECRET = os.environ["FILESTACK_APP_SECRET"]

policy = {"expiry": int(time.time()) + 3600, "call": ["read", "convert"]}
encoded = base64.urlsafe_b64encode(json.dumps(policy).encode()).decode()
signature = hmac.new(APP_SECRET.encode(), encoded.encode(), hashlib.sha256).hexdigest()

response = requests.get(
    f"https://cdn.filestackcontent.com/{API_KEY}"
    f"/security=policy:{encoded},signature:{signature}/ocr/{handle}"
)
response.raise_for_status()
text = response.json()["text"]

The secret signs the policy on your server and never travels with the request. output=format:txt and pdfinfo above need none of this, because reading a file you already stored is not a secured operation. A signed request still returns 403 when the task is not included in your plan, and the document capture and data extraction page lists what each plan includes. The split above means only the genuinely scanned documents take this route at all.

One difference matters for the confidence work later in this article. The hosted response gives you text plus per-word bounding boxes, and no confidence scores, so a page-level review threshold is something the local pytesseract path supports and this one does not.

A hosted ocr api removes the container sizing, the queue and the accuracy maintenance, and gives up offline operation and per-page control of preprocessing.

Comparing with the other hosted options

Teams evaluating this usually look at the large cloud vendors alongside it, and the comparison is less about accuracy than about shape. Most of them are close enough on clean documents that the difference is invisible; where they diverge is pricing model, whether the file has to be uploaded separately before processing, and how much of the result is structured for you.

The google ocr api route is a reasonable baseline for what recognition quality looks like before you weigh the rest.

Join the Filestack developer community on Discord

Choosing a threshold that matches your documents

Fifty characters works as a default and is worth calibrating once against real files, because the right number depends on what your scans carry.

Run the classifier over a few hundred known documents and look at the distribution. Text pages usually land in the thousands of characters. Scanned pages land at zero, or in the low tens when a scanner stamps a header. The gap between those two clusters is wide, and the threshold belongs in the middle of it rather than at either edge.

Two document types break the assumption and are worth checking for specifically. Cover pages and section dividers are genuinely near-empty while still being text pages, so they classify as scans and get recognised for nothing. Forms with an image background and a text layer for the fields sit in between, and recognising them loses the field values that were already exact.

Scaling the threshold by page area handles most of it:

def is_scanned(page, chars_per_inch2=0.5):
    rect = page.rect
    area = (rect.width / 72) * (rect.height / 72)     # PDF units are points
    return len(page.get_text().strip()) < area * chars_per_inch2

A full A4 page is about 97 square inches, so that works out near 48 characters, and it adapts when a document mixes page sizes rather than applying one number to all of them.

What this feeds

Everything above exists because a page came back as pixels instead of text, and the confidence score attached to each recognised word is what tells the next system how much to trust what it got.

A search index can absorb a wrong word here and there, since a query that misses one recognised term usually still matches the rest of the page. A ledger cannot absorb the same error, because a misread digit in an invoice total is not a near miss, it is a wrong number that looks exactly like a right one, which is why per-word confidence matters more there than the recognised text alone. Volume adds a third constraint on top of accuracy: intake that lands in a short annual window, as in tax document OCR, turns rasterisation and recognition throughput into a capacity plan rather than a coding problem.

Whichever of those three you are feeding, keep the confidence scores rather than discarding them once the output looks clean.

What to watch out for

Rotated pages. Scanners produce upside-down and sideways pages routinely. page.rotation reports what the PDF declares, which is not always what the pixels show. Most recognisers have an orientation detection step worth enabling.

Mixed page sizes. A document assembled from several sources can mix A4 and Letter, and a fixed pixel crop applied across all pages will clip some of them.

Pages that are one enormous image with a thin text layer. Some scanning software adds a searchable text layer of its own, produced by recognition you did not run and cannot assess. The page classifies as extractable and the text you get is somebody else’s OCR output, complete with its errors and without confidence scores. If extracted text looks subtly wrong in ways a digital document never would, this is usually why.

Multi-page TIFFs renamed as PDFs. fitz.open() raises rather than reporting a wrong file type. Check the first bytes for %PDF, and convert genuine TIFFs with Pillow before rendering.

Memory on large documents. Rendering a 500-page PDF at 300 DPI produces a lot of pixels. Process page by page and release each pixmap rather than building a list of them.

Using confidence rather than trusting the output

The difference between a recognition pipeline that works and one that quietly corrupts your data is whether it reads the confidence scores.

image_to_data returns a score per word, and words below about sixty are usually wrong:

import pytesseract

data = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT)
pairs = [(t, int(c)) for t, c in zip(data["text"], data["conf"]) if t.strip()]
weak = [t for t, c in pairs if c < 60]
mean = sum(c for _, c in pairs) / max(len(pairs), 1)

What you do with that depends on the job. For search, low-confidence words are noise you can live with, since a wrong word fails to match. For anything where a value is acted on, they need routing to a person, and the mean score per page is a reasonable trigger: below seventy, send the page for review rather than accepting it.

Digits deserve a stricter threshold than words. A recogniser reading an amount as 1000 instead of 100.0 produces a well-formed number that is wrong by a factor of ten, and 0 and O, 1 and l, 5 and S are close enough that nothing downstream can detect the substitution. The confidence score is the only signal available before a person looks at the page.

Keeping the original alongside the text

Whatever the pipeline produces, store the source file and the page it came from.

Extraction rules change. A field you did not capture becomes important, a threshold turns out to be wrong, a document type starts arriving in a new layout. Re-running recognition over stored originals is cheap. Recovering originals you discarded is impossible.

The same applies during disputes. When somebody questions a value your system extracted, the answer has to be the page, not your transcription of it. Storing the handle alongside the extracted text makes that a link rather than an investigation.

Questions people ask

Can I OCR a PDF without converting it to images first?

Not locally. Recognition needs pixels, so something has to rasterise the page, and doing it explicitly gives you control over resolution. A hosted API does the rasterising server side, which is why the request takes a handle rather than an image.

How do I know whether a PDF is scanned?

Count characters in the extracted text per page. Under about fifty on a full page means the page is an image. Doing it per page rather than per document is what lets you handle mixed files correctly.

Is the output searchable?

Only if you write it somewhere searchable. Recognition returns text; making a searchable PDF means writing that text back as an invisible layer over the original page, which ocrmypdf does if that is the goal.

Why is accuracy worse than the demo?

Almost always resolution or contrast rather than the engine. Render at 300 DPI, convert to greyscale and threshold before concluding the recogniser is the problem.

How long should a page take?

Locally, on CPU, expect a second or two per page for Tesseract at 300 DPI and several seconds for the neural recognisers. At that rate a document of more than a handful of pages exceeds a normal web request timeout, so recognition belongs in a worker rather than in the request.

Should recognition run on upload or on demand?

On upload, for anything users will search. Recognising lazily means the first person to search waits for it, and search that is sometimes instant and sometimes thirty seconds reads as broken. On demand makes sense only when most documents are never read, which is rarer than teams assume.

What about password-protected files?

pdfinfo reports encrypted and protected separately. Encrypted means it needs a password to open at all. Protected means it opens but declares restrictions, which some libraries honour and others ignore. Either way, check before treating an empty extraction as evidence of a scan.

 

 

Read More →