PaddleOCR: High Accuracy Text Extraction from Images and Documents
Hey everyone, today we look at one of the best open source OCR libraries available: PaddleOCR. If you have ever had to pull text out of an ID card photo, a receipt, an invoice, a form, or a screenshot, you know OCR looks simple and turns out to be full of traps. PaddleOCR is one of the most mature solutions to that problem, and pleasantly, it is free and runs fine on CPU.
What makes PaddleOCR stand out against the older Tesseract is that it is fully deep learning based, supports more than 80 languages, ships very lightweight models (the mobile variants are only a few MB), and most importantly handles slanted text, curved text, and messy backgrounds. Those are exactly the conditions where Tesseract gives up.
We will cover the OCR pipeline architecture, installation, your first extraction, understanding the output format, document layout and table analysis, preprocessing for hard cases, and finally building an invoice data extraction pipeline.
Introduction
How Modern OCR Works
I want you to understand the pipeline first, because it helps enormously when debugging.
Modern OCR is not one model but three sequential stages.
The first stage is text detection. This model finds where text exists in the image and draws boxes (or polygons) around it. Nothing is read yet, only located.
The second stage is angle classification. Each detected crop is checked for orientation, whether it is upright or rotated 180 degrees, and rotated if needed. This stage is optional but very helpful for casually captured photos.
The third stage is text recognition. Each crop containing a single line of text goes into a recognition model that turns pixels into characters.
Why does this matter? Because when your OCR output is bad, the cause can be at any stage. Text that is completely missing usually means a detection failure, not a recognition one. Text that appears with wrong characters is a recognition problem. Diagnosing the right stage saves a lot of time.
Installation
python -m venv venv
source venv/bin/activate # Linux / Mac
venv\Scripts\activate # Windows
CPU
pip install paddlepaddle
pip install paddleocr
Or GPU (match your CUDA version)
pip install paddlepaddle-gpu
Some workflows also need extra image dependencies:
pip install opencv-python pillow
Models download automatically on first use and are cached, so later calls start much faster.
First Extraction
from paddleocr import PaddleOCR
lang="en" for English, "ch" for Chinese, "japan", "korean",
"arabic", "latin" for most Latin-script languages, and more
ocr = PaddleOCR(useanglecls=True, lang="en")
result = ocr.ocr("receipt.jpg", cls=True)
for line in result[0]:
box, (text, score) = line
print(f"{score:.3f} {text}")
The output is a list per image, and each element carries two things: polygon coordinates (four x,y points) and a tuple with the recognized text and its confidence score.
Here is the structure in more detail:
result = ocr.ocr("document.jpg", cls=True)
for line in result[0]:
box = line[0] # [[x1,y1],[x2,y2],[x3,y3],[x4,y4]]
text = line[1][0] # the recognized string
score = line[1][1] # 0.0 to 1.0
left = min(p[0] for p in box)
top = min(p[1] for p in box)
print(f"position ({left:.0f}, {top:.0f}) score {score:.2f} '{text}'")
Those coordinates are not decoration, they are the key to many useful things. With coordinates you can sort text into reading order, group text that lives in the same column, or find the value sitting to the right of a label.
Sorting Results Into Reading Order
This is a practical problem that comes up constantly. PaddleOCR returns results in detection order, which is not always human reading order.
def readingorder(ocrresult, ytolerance=10):
"""Sort OCR results top to bottom, then left to right."""
items = []
for line in ocr
result:
box, (text, score) = line
y = sum(p[1] for p in box) / 4
x = min(p[0] for p in box)
items.append({"x": x, "y": y, "text": text, "score": score})
items.sort(key=lambda i: i["y"])
rows = []
current = []
refy = None
for it in items:
if refy is None or abs(it["y"] - refy) <= ytolerance:
current.append(it)
refy = it["y"] if refy is None else refy
else:
rows.append(sorted(current, key=lambda i: i["x"]))
current = [it]
refy = it["y"]
if current:
rows.append(sorted(current, key=lambda i: i["x"]))
return rows
result = ocr.ocr("invoice.jpg", cls=True)
for row in readingorder(result[0]):
print(" | ".join(i["text"] for i in row))
This groups text with similar vertical position into one row, then orders each row left to right. Tune ytolerance to your image resolution. Larger images need a larger value.
Document Layout and Table Analysis
This is the feature that separates PaddleOCR from ordinary OCR. The PP-Structure module recognizes document layout: which block is a title, a paragraph, a table, a figure. Most usefully, it can reconstruct tables into HTML.
from paddleocr import PPStructure, savestructureres
import cv2
engine = PPStructure(table=True, ocr=True, showlog=False, lang="en")
img = cv2.imread("financialreport.jpg")
result = engine(img)
for block in result:
print("type:", block["type"]) # text, title, table, figure, list
print("bbox:", block["bbox"])
if block["type"] == "table":
print("table HTML:")
print(block["res"]["html"])
savestructureres(result, "output", "report")
To turn that HTML table into a pandas DataFrame:
import pandas as pd
for block in result:
if block["type"] == "table":
table = pd.readhtml(block["res"]["html"])[0]
print(table)
table.toexcel("extractedtable.xlsx", index=False)
This is extremely useful for financial reports, lab results, or any document dominated by tables. I use it often for clients with thousands of scanned PDFs who want the data in a database.
Handling PDFs
PaddleOCR works on images, so PDFs must be rendered first.
import fitz # pip install pymupdf
from paddleocr import PaddleOCR
import numpy as np
ocr = PaddleOCR(useanglecls=True, lang="en")
doc = fitz.open("document.pdf")
alltext = []
for pagenumber, page in enumerate(doc, start=1):
# Render at high resolution, this matters a lot for accuracy
pix = page.getpixmap(dpi=300)
img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width, pix.n)
img = img[:, :, :3] # drop alpha if present
result = ocr.ocr(img, cls=True)
if result and result[0]:
pagetext = "\n".join(b[1][0] for b in result[0])
alltext.append(f"=== Page {pagenumber} ===\n{pagetext}")
with open("output.txt", "w", encoding="utf-8") as f:
f.write("\n\n".join(alltext))
Note the dpi=300. This is one of the highest impact settings. Rendering a PDF at 72 dpi produces a small blurry image that OCR struggles with. 300 dpi is the sweet spot for most documents. For very small print, try 400.
Preprocessing for Hard Cases
When OCR output is poor, the biggest improvement usually comes from fixing the image rather than swapping models.
import cv2
import numpy as np
def preprocess(path):
img = cv2.imread(path)
# 1. Upscale if the resolution is low
h, w = img.shape[:2]
if max(h, w) < 1000:
scale = 1500 / max(h, w)
img = cv2.resize(img, None, fx=scale, fy=scale, interpolation=cv2.INTERCUBIC)
# 2. Convert to grayscale
gray = cv2.cvtColor(img, cv2.COLORBGR2GRAY)
# 3. Reduce noise while preserving character edges
gray = cv2.bilateralFilter(gray, 9, 75, 75)
# 4. Improve contrast with CLAHE
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
gray = clahe.apply(gray)
return cv2.cvtColor(gray, cv2.COLORGRAY2BGR)
clean = preprocess("darkreceiptphoto.jpg")
result = ocr.ocr(clean, cls=True)
For skewed document photos, deskewing helps a lot:
def deskew(img):
gray = cv2.cvtColor(img, cv2.COLORBGR2GRAY)
edges = cv2.Canny(gray, 50, 150, apertureSize=3)
lines = cv2.HoughLinesP(edges, 1, np.pi / 180, 100, minLineLength=100, maxLineGap=10)
if lines is None:
return img
angles = []
for x1, y1, x2, y2 in lines[:, 0]:
a = np.degrees(np.arctan2(y2 - y1, x2 - x1))
if abs(a) < 45:
angles.append(a)
if not angles:
return img
median = np.median(angles)
h, w = img.shape[:2]
M = cv2.getRotationMatrix2D((w // 2, h // 2), median, 1.0)
return cv2.warpAffine(img, M, (w, h), flags=cv2.INTERCUBIC,
borderMode=cv2.BORDERREPLICATE)
One important tip: do not immediately binarize the image (convert to pure black and white) the way people commonly recommend for Tesseract. PaddleOCR's deep learning models actually perform better on grayscale or color images that still have gradation. Aggressive binarization throws away information the model needs.
Case Study: Invoice Data Extraction
Let us combine everything into something useful. Here is a simple extractor that pulls specific fields from an invoice.
import re
from paddleocr import PaddleOCR
ocr = PaddleOCR(useanglecls=True, lang="en")
def extractinvoice(path):
result = ocr.ocr(path, cls=True)
if not result or not result[0]:
return {}
items = []
for line in result[0]:
box, (text, score) = line
items.append({
"text": text,
"score": score,
"x": min(p[0] for p in box),
"y": sum(p[1] for p in box) / 4,
})
joined = " ".join(i["text"] for i in items)
data = {}
# Invoice number
m = re.search(r"invoice[\s#:]*([A-Z0-9\-/]{4,})", joined, re.I)
if m:
data["invoicenumber"] = m.group(1)
# Date
m = re.search(r"(\d{1,2}[/\-]\d{1,2}[/\-]\d{2,4})", joined)
if m:
data["date"] = m.group(1)
# Total: find numeric values near the word total
candidates = []
for it in items:
if re.search(r"total|amount due|grand", it["text"], re.I):
for other in items:
if abs(other["y"] - it["y"]) < 15 and other["x"] > it["x"]:
digits = re.sub(r"[^\d]", "", other["text"])
if digits:
candidates.append(int(digits))
if candidates:
data["total"] = max(candidates)
# Average confidence, used as a quality gate
data["avgscore"] = sum(i["score"] for i in items) / len(items)
return data
print(extractinvoice("invoice.jpg"))
Notice the total lookup. I do not only regex over joined text, I use coordinates to find numbers sitting on the same line as the word "total". That is far more accurate than pure regex, because real invoices contain several similar looking numbers.
Also notice avgscore. In production you need a quality gate. If average confidence falls below a threshold, say 0.85, route that document to a human review queue instead of writing possibly wrong data into your system.
Tips and Best Practices
Resolution is king. Most OCR failures I encounter come from input images that are simply too small. Aim for a character height of at least 20 to 30 pixels.
Do not binarize aggressively. Deep learning OCR differs from classic OCR in this respect.
Enable useanglecls=True for user-submitted photos, since many arrive rotated or upside down.
Store confidence scores and coordinates in your database, not just the text. That data is gold for debugging and for building quality gates.
For fixed-format documents such as ID cards or standard forms, consider a template based approach: define a region of interest per field and run OCR only inside it. Accuracy is far higher than searching patterns across the whole page.
If you have many documents with an unusual font or style, PaddleOCR supports fine-tuning the recognition model on your own data. It is non-trivial work but can dramatically improve accuracy in a narrow domain.
For deployment, instantiate the PaddleOCR object once at module level rather than per request, because model initialization takes several seconds.
Conclusion
PaddleOCR is a very strong choice for nearly any OCR need, and it runs well even on CPU. The key takeaways:
The OCR pipeline is detection, angle classification, and recognition, and knowing which stage failed speeds up debugging enormously.
The output carries coordinates and confidence scores, both as valuable as the text itself.
The PP-Structure module reconstructs tables into HTML that converts directly into a DataFrame, which is a huge win for report documents.
Input image quality matters far more than model choice. Render PDFs at 300 dpi, upscale small images, fix contrast, and deskew crooked pages.
For field extraction, combine regex with coordinate based spatial reasoning, and always add a confidence based quality gate.
Take ten receipt or invoice photos you have on hand, run the pipeline above, and see what percentage extracts correctly. That number will tell you honestly how much preprocessing you still need before the system is production ready. Happy extracting.