OCR: Text from Images and Scanned Documents
When the Data Is a Picture
Some pages show the data as an image: scanned invoices, photographed product labels, screenshot-tables, or PDFs that are literally page scans with no embedded text layer. A parser cannot extract what the markup does not contain. Optical Character Recognition — OCR — is the adapter that turns pixels back into text, and it is the last weapon in the scraping arsenal for opaque documents.
Start with Tesseract
The reference open-source engine is Tesseract, driven from Python via pytesseract. Its default call reads text out of a decent-quality image out of the box:
import pytesseract
from PIL import Image
text = pytesseract.image_to_string(Image.open("scan.png"))
Install the engine and the Python wrapper (apt install tesseract-ocr on Linux, choco install tesseract on Windows, plus pip install pytesseract pillow), and test on your worst page first: OCR quality varies wildly with fonts, contrast and rotation, and your pipeline is only as good as its hardest image.
Preprocessing Is 80% of Accuracy
Tesseract wants clean, high-contrast, straight text. The winning preprocess pipeline, in order: convert to grayscale, increase resolution (upscale small scans 2–3x with a good resampler so strokes are thicker than the OCR's minimum), binarize with an adaptive threshold to kill shadows and gradients, and remove noise. PIL alone handles the first three:
from PIL import Image, ImageOps
img = Image.open("scan.png").convert("L")
img = ImageOps.scale(img, 2.0, Image.LANCZOS)
img = img.point(lambda p: 255 if p > 140 else 0) # simple binarize
If text is rotated, deskew — calculate the mean angle of text lines and rotate the image back — because even 5 degrees of tilt tanks recognition. Autocontrast, sharpen and a median filter (against salt-and-pepper noise) finish the list. Every one of these steps should be toggled in a small test harness until your specific document type reads clean.
Tune the Engine, Don't Fight It
Tesseract has modes that matter. Page-segmentation mode (--psm) tells it how the page is laid out: 6 for a single uniform block of text, 7 for a single line, 11 for sparse text (like a product label), 3 for a full page with mixed layout — the last one is Tesseract's default and often the wrong one for your crop. Whitelisting characters (-c tessedit_char_whitespace_list) or restricting to digits via config="--psm 7 -c tessedit_char_whitelist=0123456789." massively improves price and ID extraction:
price = pytesseract.image_to_string(crop, config="--psm 7 -c tessedit_char_whitelist=0123456789.")
For an array of similar images, calibrate once on a labeled sample — a handful of known-good images, run each mode, keep the best config — then apply it to the whole batch.
Restore Structure: Tables and Layout
OCR returns soup: unlabeled lines of text. If a scanned page contains a table, recover cells by letting Tesseract output box data (image_to_data returns per-word bounding boxes) and re-clustering words by row — words whose y-centers overlap form a row, then sort by x within the row. That is a 30-line function and it recovers the grid that the raw text dump threw away. For PDFs, add pdf2image to rasterize each page, OCR page by page, and stitch the results.
Know Its Limits
OCR is probabilistic: O versus 0, l versus 1, and punctuation vanish under bad light. Add a validation step — strip non-alphanumerics outside whitelisted fields, and if a required number fails a sanity check, flag the item for human review instead of writing garbage to the database. When accuracy on your worst image is the spec, OCR is reliable support for a scraper, never a substitute for one: prefer embedded text, JSON payloads or real markup every time, and reach for the pixel layer only when the document is genuinely image-only.