OCR in Python: Extract Text from Images and Scanned PDFs with RapidOCR (2026)
OCR (optical character recognition) turns pixels into text you can search, store and process: receipts, invoices, screenshots, photographed notes and scanned PDFs that have no text layer. The classic Python answer is pytesseract, but it is only a wrapper: you must install the Tesseract program separately with apt, Homebrew or a Windows installer, and on locked-down servers or CI runners that is often impossible. In 2026 a simpler option is RapidOCR, a pure pip install OCR library that runs the PaddleOCR detection and recognition models through ONNX Runtime on an ordinary CPU, with the models bundled in the package. In this tutorial we read text from an image, get the bounding box of every line, pull invoice fields into JSON, OCR a whole folder into a CSV file and extract text from a scanned PDF. Everything was tested in a fresh virtual environment on Python 3.13.5 with rapidocr 3.10.0, onnxruntime 1.31.1, Pillow 13.0.0, numpy 2.5.4 and pypdfium2 5.14.0, and every output block below is the real output of the scripts shown.
Why RapidOCR instead of pytesseract?
- One pip install: no system package, no PATH setup, no
tesseract_cmdpointing atC:\Program Files. It works the same on Windows, macOS, Linux and Docker. - Modern models: it uses the PP-OCR detection and recognition networks, which cope well with photos, coloured backgrounds and slightly rotated text.
- Fast on CPU: our seven-line receipt was read in about 0.4 seconds, and there is no API key or per-page fee because everything runs locally.
- Useful output: each line comes back with its text, a confidence score and four corner points, so you can filter weak results or crop regions.
Install
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install rapidocr==3.10.0 onnxruntime pillow pypdfium2
The first time you create the engine it checks the model files (detection, angle classifier and recognition, a few megabytes each) and downloads any that are missing, so run it once while online if you plan to work offline later.
Create a sample image
To keep the tutorial reproducible we draw a small receipt with Pillow instead of relying on a photo you do not have. Swap receipt.png for your own scan at any step.
from PIL import Image, ImageDraw, ImageFont
FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
def receipt(path="receipt.png"):
img = Image.new("RGB", (640, 420), "white")
d = ImageDraw.Draw(img)
big, small = ImageFont.truetype(FONT, 34), ImageFont.truetype(FONT, 24)
lines = [("CITY BOOK STORE", big), ("Invoice No: INV-2026-0417", small),
("Date: 2026-11-01", small), ("Python Crash Course 1 x 32.50", small),
("Clean Code 1 x 28.00", small), ("TOTAL: 60.50 USD", big)]
y = 25
for text, font in lines:
d.text((30, y), text, fill="black", font=font)
y += 62 if font is big else 50
img.save(path)
return path
if __name__ == "__main__":
print("saved", receipt())
Step 1: Extract text from an image
from rapidocr import RapidOCR
engine = RapidOCR(params={"Global.log_level": "error"}) # quiet logs
result = engine("receipt.png") # file path, bytes, PIL image or numpy array
for text, score in zip(result.txts, result.scores):
print(f"{score:.2f} {text}")
print(f"lines: {len(result.txts)} elapsed: {result.elapse:.2f}s")
$ python ocr_basic.py
1.00 CITY BOOK STORE
1.00 Invoice No: INV-2026-0417
1.00 Date: 2026-11-01
0.99 Python Crash Course 1 x 32.50
1.00 Clean Code
0.96 1 x 28.00
0.99 TOTAL: 60.50 USD
lines: 7 elapsed: 0.38s
result.txts holds the recognised lines in reading order and result.scores the confidence from 0 to 1. Notice that "Clean Code" and its price were returned as two lines because there is a wide gap between them; the next step shows how to tell where each piece sits.
Step 2: Get bounding boxes and draw them
from PIL import Image, ImageDraw
from rapidocr import RapidOCR
engine = RapidOCR(params={"Global.log_level": "error"})
result = engine("receipt.png")
img = Image.open("receipt.png").convert("RGB")
draw = ImageDraw.Draw(img)
for box, text in zip(result.boxes, result.txts):
pts = [tuple(map(int, p)) for p in box] # 4 corner points
draw.polygon(pts, outline="red", width=3)
x, y = pts[0]
print(f"({x:3d},{y:3d}) {text}")
img.save("receipt_boxes.png")
print("saved receipt_boxes.png")
$ python ocr_boxes.py
( 29, 27) CITY BOOK STORE
( 29, 87) Invoice No: INV-2026-0417
( 29,138) Date: 2026-11-01
( 29,188) Python Crash Course 1 x 32.50
( 28,237) Clean Code
(262,237) 1 x 28.00
( 28,291) TOTAL: 60.50 USD
saved receipt_boxes.png
Each box is four (x, y) corners starting at the top-left. Lines whose top y values are close (both 237 here) belong to the same row, which is how you rebuild table rows such as "Clean Code ... 1 x 28.00".
Step 3: Turn a receipt into JSON
import json, re
from rapidocr import RapidOCR
engine = RapidOCR(params={"Global.log_level": "error"})
text = "\n".join(engine("receipt.png").txts)
patterns = {
"invoice": r"Invoice No:\s*([A-Z0-9-]+)",
"date": r"Date:\s*(\d{4}-\d{2}-\d{2})",
"total": r"TOTAL:\s*([\d.]+)",
}
data = {k: (m.group(1) if (m := re.search(p, text)) else None)
for k, p in patterns.items()}
data["total"] = float(data["total"]) if data["total"] else None
print(json.dumps(data, indent=2))
$ python ocr_receipt_json.py
{
"invoice": "INV-2026-0417",
"date": "2026-11-01",
"total": 60.5
}
OCR gives you text; a few regular expressions turn it into structured data you can save to a database or a spreadsheet. Keep the patterns tolerant of extra spaces, because OCR spacing is not always exact.
Step 4: Batch OCR a folder into a CSV file
import csv
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from rapidocr import RapidOCR
FONT = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 30)
folder = Path("scans"); folder.mkdir(exist_ok=True)
for i, msg in enumerate(["Meeting at 10:30 AM", "Room B-204", "Bring your laptop"], 1):
img = Image.new("RGB", (520, 90), "white")
ImageDraw.Draw(img).text((20, 25), msg, fill="black", font=FONT)
img.rotate(2, expand=True, fillcolor="white").save(folder / f"note_{i}.png")
engine = RapidOCR(params={"Global.log_level": "error"})
with open("ocr_results.csv", "w", newline="") as f:
w = csv.writer(f)
w.writerow(["file", "text", "min_score"])
for path in sorted(folder.glob("*.png")):
r = engine(str(path))
text = " ".join(r.txts) if r.txts else ""
score = round(min(r.scores), 2) if r.scores else 0
w.writerow([path.name, text, score])
print(Path("ocr_results.csv").read_text())
$ python ocr_batch_csv.py
file,text,min_score
note_1.png,Meeting at 10:30 AM,1.0
note_2.png,Room B-204,1.0
note_3.png,Bring your laptop,1.0
The notes were rotated by 2 degrees on purpose and were still read with full confidence. Create the engine once and reuse it in the loop; loading the models is the slow part. The double space in "Meeting at" is real OCR output, so normalise whitespace with " ".join(text.split()) before comparing strings.
Step 5: OCR a scanned PDF
A scanned PDF is just images inside a PDF, so pypdf or page.get_text() returns nothing. Render each page to an image with pypdfium2 and pass it to RapidOCR:
import pypdfium2 as pdfium
from PIL import Image
from rapidocr import RapidOCR
# make a "scanned" PDF: an image-only page with no text layer
Image.open("receipt.png").save("scanned.pdf", resolution=150)
pdf = pdfium.PdfDocument("scanned.pdf")
print("text layer:", repr(pdf[0].get_textpage().get_text_range()))
engine = RapidOCR(params={"Global.log_level": "error"})
for i, page in enumerate(pdf, 1):
image = page.render(scale=2).to_pil() # rasterize at 2x for accuracy
result = engine(image)
print(f"--- page {i} ---")
print("\n".join(result.txts))
$ python ocr_scanned_pdf.py
text layer: ''
--- page 1 ---
CITY BOOK STORE
Invoice No: INV-2026-0417
Date: 2026-11-01
Python Crash Course 1 x 32.50
Clean Code
1 × 28.00
TOTAL: 60.50 USD
The empty text layer proves the PDF had no real text, and OCR recovered every line. One honest detail: at this render scale the letter "x" in "1 x 28.00" came back as the multiplication sign "×". Small differences like this are normal in OCR, so validate important fields (totals, dates, IDs) with patterns like the ones in Step 3.
Tips for better OCR accuracy
- Resolution matters most: text should be at least 20 to 30 pixels tall. Upscale small screenshots with Pillow or render PDF pages at
scale=2or higher. - Increase contrast: convert faded photos to grayscale and apply
ImageOps.autocontrastbefore OCR. - Filter by score: drop or flag lines below about 0.8 for human review instead of trusting them blindly.
- Language: the default models target Chinese and English; check the RapidOCR documentation for other recognition models if you need more languages.
- Digital PDFs: if a PDF already has a text layer, extract it directly; it is faster and exact. Use OCR only for scans.
Common errors and fixes
TypeError: 'NoneType' object is not iterable: nothing was detected, soresult.txtsandresult.scoresareNone(we checked this with a blank white image). Guard withif r.txts:as in Step 4, and if real text was missed, try a sharper or higher-resolution image.- Noisy INFO logs: pass
params={"Global.log_level": "error"}when you create the engine. - Slow loops: you are creating
RapidOCR()inside the loop. Create it once.
Where to use this next
- Clean up and resize scans before OCR with our Pillow guide to resizing, cropping and watermarking images.
- Split a long scanned file into pages or merge the results with pypdf: merge and split PDFs in Python.
- For digital PDFs with real text and tables, convert them for LLMs with Docling PDF to Markdown, and keep OCR for image-only pages.
Conclusion
With RapidOCR you can add OCR to any Python script using only pip: read lines and confidence scores, locate each line with bounding boxes, turn receipts into JSON, process whole folders into CSV and recover text from scanned PDFs. Start with clear, high-resolution images, validate key fields with patterns, and you have a reliable offline text extraction pipeline for invoices, forms and notes.