Tesseract vs EasyOCR for Scanned Legal Documents
Deciding between Tesseract and EasyOCR for scanned litigation filings comes down to a measurable question: which engine yields a lower character error rate at acceptable throughput on your actual scans, and does its confidence signal reliably gate what gets redacted. This guide measures both empirically and shows where each wins.
The stakes are specific to legal work. OCR output is not the deliverable — it is the coordinate map that tells the redactor where a Social Security number sits on a page. A missed character is a missed redaction, so accuracy and confidence gating matter more than raw speed. This companion to the Parsing Engine Selection Benchmarks framework focuses only on the OCR row.
Prerequisites and version matrix Permalink to this section
Pin versions; OCR accuracy shifts between releases and a reproducible benchmark demands a frozen toolchain.
| Component | Version | Notes |
|---|---|---|
| Python | 3.11 | CPython |
pytesseract |
0.3.13 | Python wrapper |
| Tesseract engine | 5.3.x | system binary (apt install tesseract-ocr) |
easyocr |
1.7.2 | bundles PyTorch |
torch |
2.2.x | CPU or CUDA build |
Pillow |
10.x | image loading |
jiwer |
3.0.x | CER/WER computation |
Tesseract runs comfortably on CPU. EasyOCR loads a PyTorch model and is markedly faster with CUDA; on CPU it still runs but at a fraction of GPU throughput.
Step-by-step implementation Permalink to this section
1. Load a scan and run both engines. Tesseract exposes page segmentation modes (PSM); --psm 6 (assume a uniform block of text) suits single-column filings, while --psm 4 handles multi-column exhibits.
import pytesseract
import easyocr
from PIL import Image
def ocr_tesseract(image_path: str, psm: int = 6) -> str:
img = Image.open(image_path)
# PSM tuning is Tesseract's main accuracy lever on legal layouts.
return pytesseract.image_to_string(img, config=f"--oem 1 --psm {psm}")
# Reader construction is expensive; build once and reuse across pages.
_reader = easyocr.Reader(["en"], gpu=False)
def ocr_easyocr(image_path: str) -> str:
# detail=0 returns plain strings; paragraph=True reflows line fragments.
lines = _reader.readtext(image_path, detail=0, paragraph=True)
return "\n".join(lines)
2. Score both against a known transcript. Character error rate (CER) is the edit distance between recognized and ground-truth text, normalized by reference length. Lower is better.
from jiwer import cer
def score(reference: str, hypothesis: str) -> float:
# CER is stable across engines because it ignores tokenization choices.
return round(cer(reference, hypothesis), 4)
3. Gate redaction on confidence. Tesseract returns per-word confidence via image_to_data; EasyOCR returns per-detection probabilities when detail=1. Any region below the floor must be treated as suspect and over-masked rather than trusted.
import pytesseract
from pytesseract import Output
def low_confidence_boxes(image_path: str, floor: int = 60) -> list:
data = pytesseract.image_to_data(Image.open(image_path), output_type=Output.DICT)
boxes = []
for i, conf in enumerate(data["conf"]):
if conf != "-1" and int(conf) < floor:
# Expand these regions before masking; do not log the raw word.
boxes.append((data["left"][i], data["top"][i],
data["width"][i], data["height"][i]))
return boxes
Compliance checkpoint Permalink to this section
OCR confidence is a redaction control, not a metric for dashboards. Under FRCP Rule 34(b)(2)(E), scanned material must be produced in a reasonably usable form, and a defensible pipeline records that every page cleared a confidence threshold before its coordinate map drove redaction. Persist the per-page CER and the confidence floor into the audit trail so a challenge can be answered with data. Regions falling below the floor should route to a reviewer rather than being silently trusted, consistent with the review-queue routing in Confidence Threshold Configuration.
Gotchas Permalink to this section
- Low-DPI scans wreck both engines. Below 300 DPI, CER climbs steeply. Upsample and binarize before OCR; never redact from a sub-200-DPI scan without manual review.
- Rotated or skewed pages. EasyOCR tolerates modest rotation better than stock Tesseract. Deskew first, and remember that a page rotated 90° silently produces empty text and therefore zero redactions.
- Redaction must cover what OCR missed. When confidence is low or a region returns no text, over-mask the bounding box rather than assuming it holds nothing sensitive. A false negative here is an exposed identifier in a produced document.
- Non-deterministic reader init. EasyOCR’s GPU path can vary slightly run to run; fix seeds and pin
torchwhen a benchmark must be reproducible for the record.
Verification and testing Permalink to this section
Assert CER against a fixture so accuracy regressions fail CI deterministically. Use a checked-in image with a known transcript.
def test_engine_cer_within_bounds():
reference = "PLAINTIFF JANE DOE HEREBY MOVES THE COURT"
t_cer = score(reference, ocr_tesseract("fixtures/clean_filing.png"))
e_cer = score(reference, ocr_easyocr("fixtures/clean_filing.png"))
print(f"Tesseract CER={t_cer} EasyOCR CER={e_cer}")
# On a clean 300-DPI fixture both should stay under 5% error.
assert t_cer < 0.05
assert e_cer < 0.05
On clean 300-DPI filings Tesseract typically matches EasyOCR on CER while running faster on CPU, so it wins on cost. On noisy, low-contrast, or rotated scans EasyOCR’s detector pulls ahead — worth the GPU if degraded exhibits dominate your corpus.
Related Permalink to this section
Feed these measurements back into the Parsing Engine Selection Benchmarks matrix, and for full pipeline integration see Handling Scanned PDFs with OCR. The sibling Celery vs RQ vs Dramatiq for Redaction Queues covers the fleet that runs these OCR jobs at scale. Part of the PDF and DOCX Parsing & Extraction Workflows field guide.