Why Is OCR So Bad? Diagnose Bad Output Before You Switch Tools
← All posts
GuideOct 19, 2026· 9 min read

Why Is OCR So Bad? Diagnose Bad Output Before You Switch Tools

If you searched for why is OCR so bad, you probably just got back text like Hex bolt MBx40 from a page that clearly says M8x40, or a table that came out as one long run-on line. Short answer: OCR is rarely bad at random. Most bad output traces back to a small set of causes: text that is too small or too compressed by the time the engine sees it, pages that are skewed or photographed at an angle, layouts the engine reads in the wrong order, content it was never built for (handwriting, part numbers, other scripts), and PDFs whose text layer came from an older OCR pass. Each cause leaves a different fingerprint in the output, so you can usually find it in a few minutes before you replace anything.

This post walks through those causes with checks you can run, a short triage script, and a test on a deliberately bad page. If you have already ruled out the input and want to compare engines, the OCR model guide covers that next step.

Match the symptom to the cause

Start with what the output looks like. Different failures look different.

What you see Likely cause First check
Letters swapped for look-alikes (8 and B, 0 and O, Z and 2) Text too small, blurry, or JPEG-compressed Measure letter height in pixels
Whole lines missing or merged Skew, rotation, or perspective from a phone photo Look at the page the engine actually received
Every word right, order wrong Columns, tables, or sidebars read as one block Try a different page segmentation mode
Fluent text that is not on the page A vision model guessing from a low-detail image Compare against the source, value by value
Codes “corrected” into words Dictionary bias in the engine Disable dictionaries or restrict characters
Copy-paste from a PDF gives garbage or nothing Image-only page or a bad existing text layer pdffonts and pdftotext
Accents dropped, characters replaced Wrong language model loaded Check the language setting
Handwriting comes out as noise Engine trained for print Use a model built for handwriting

1. The text is too small by the time the engine sees it

This cause is common, and it hides well. A page can look sharp on your screen and still give the engine only a few pixels per letter.

The Tesseract documentation says it works best on images of at least 300 DPI. DPI is a proxy, though. What matters is the size of the letters in pixels. Willus Dotkom, who benchmarks Tesseract for k2pdfopt, reported that the best accuracy comes when a capital letter is between 25 and 35 pixels tall for both Tesseract 4.1 and 5.1. Far below that, characters start to collapse into their look-alikes. His earlier Tesseract 4 tests also showed errors creeping back in when letters get very large, so bigger is not always better.

Two things shrink text without you noticing:

  • Lossy compression and re-saves. A scan that went through a fax, a chat app, or several JPEG saves loses the thin strokes that separate 8 from B.
  • Automatic downscaling in vision APIs. Vision model APIs commonly resize large images before the model reads them. Claude’s vision documentation lists a maximum of 1,568 pixels on the long edge and 1,568 visual tokens for most models, and 2,576 pixels and 4,784 tokens for Claude 4.7 and later. Its image guidance warns that this resizing “might, for example, make text less legible”. An A4 page scanned at 300 DPI is about 2,480 by 3,510 pixels. On the standard tier it is shrunk to roughly 920 by 1,300, so the capital letters of 10 pt body text, about 29 pixels tall in the scan for a typical font, end up near 11 pixels. That is a long way below the range above, and it happens after you uploaded a perfectly good file.

The fix is at capture time: scan at 300 DPI or better, avoid repeated JPEG saves, and for vision models, crop or split tall pages into regions so each one stays within the size limit.

2. The page is skewed, rotated, or photographed

Classic engines find lines of text first and recognize characters second. The Tesseract docs say line segmentation quality “reduces significantly if a page is too skewed”, which then hurts everything downstream. Scanner borders can be read as extra characters, and a crop with no white margin at all can confuse the engine too.

Phone photos add perspective distortion, uneven light, curled paper, and background clutter on top of skew. Deskewing a flat scan is routine, and libraries such as OpenCV and ImageMagick handle it. Shadows and folds are harder because no single global adjustment fixes every region of the image. We wrote up one such case, and the segmentation approach that handled it, in When OCR Fails.

A cheap habit that catches most of these: look at the exact image the engine received after any auto-rotate or resize step. Tesseract can write its preprocessed input to disk if you set tessedit_write_images to true. If that file looks wrong to you, it looks wrong to the engine.

3. The words are right but the order is wrong

Multi-column layouts, tables, forms with label and value side by side, and sidebars all depend on reading order. A classic engine that reads straight across a two-column page can interleave the two columns line by line. The text is technically correct and practically useless.

Tesseract exposes this choice as the page segmentation mode (--psm). The default, mode 3, does automatic page segmentation. Mode 4 assumes a single column of text of variable sizes. Mode 6 treats the image as one uniform block, mode 7 as a single line, and mode 11 looks for sparse text in no particular order. Trying two or three modes on a failing page tells you quickly whether layout is the problem. The same docs also note that Tesseract has known trouble with tables unless you do custom segmentation first.

If your documents are mostly tables and multi-column reports, this is the strongest argument for a layout-aware tool rather than more tuning. Document models that output Markdown or HTML tables handle structure as part of the job.

4. The content is not what the engine was built for

Some failures are a mismatch, not a defect.

  • Handwriting. The Tesseract FAQ is direct about it: you can use it for handwriting, “but it won’t work very well, as Tesseract is designed for printed text”. A printed-text engine still returns something, and confident-looking noise is harder to catch than an empty result.
  • Codes, IDs, and part numbers. The docs say Tesseract is “optimized to recognize sentences of words” by default. Its dictionaries nudge ambiguous strings toward real words, which is exactly what you do not want for 1Z44A0E90318. Turning off load_system_dawg and load_freq_dawg, or limiting characters with tessedit_char_whitelist for a digits-only field, often helps.
  • Language and script. If the language model loaded does not match the page, accented and non-Latin characters get replaced with the closest match from the wrong alphabet. In Tesseract, check the -l setting and make sure the right traineddata files are installed.

5. The PDF text layer was never trustworthy

A lot of “OCR is bad” complaints are about PDFs where nobody ran OCR recently. The text you copy out came from whatever produced the file. Two quick checks with poppler-utils tell you what you are dealing with:

pdffonts invoice.pdf                                  # which fonts carry text, if any
pdftotext -layout -f 1 -l 1 invoice.pdf - | head -20  # what the text layer says on page 1

How to read the result:

  • No fonts listed and almost no text. The page is an image. Copy-paste gives nothing because there is no text to copy. Run OCR.
  • A font named GlyphLessFont. That is the invisible font Tesseract uses when it writes a searchable PDF, so the text layer came from an earlier OCR pass, not from the document’s original fonts. When we turned the small test image below into a searchable PDF with Tesseract, pdftotext returned Hex bolt MBx40 from it. Every search and every downstream parser inherits that mistake until someone re-reads the page.
  • Real fonts, but garbled characters. The uni column in pdffonts shows whether a font has an explicit ToUnicode map. Its man page notes that a missing map does not always mean the text cannot be converted, but when you see no and the extracted text is garbage, rendering the page and OCRing the image is usually faster than repairing the layer.

6. The output is fluent and wrong

Classic engines fail loudly: broken words, odd symbols, low confidence. Vision language models can fail quietly. When the image is too small or degraded to read, they may produce text that is grammatical and plausible but not on the page. Claude’s documentation lists this among its vision limitations: it “might hallucinate or make mistakes when interpreting low-quality, rotated, or very small images under 200 pixels”.

You will not spot this by skimming, because nothing looks broken. Check values that must be exact (totals, dates, IDs) against the source, and add rules that must hold: line totals that sum to the invoice total, check digits, date formats.

A two-minute triage script

The script below runs Tesseract on one image and reports the word count, mean word confidence, how many words fall below 60 confidence, and an estimate of capital-letter height in pixels (from words made only of capitals and digits). If the letters are outside a sensible range, it rescales the image toward 30 pixels and runs again so you can see whether size was the problem. It needs Pillow and the tesseract binary.

"""Quick triage for bad OCR on one image: is it the pixels or the settings?

Usage: python3 ocr_triage.py page.jpg
Needs: Pillow (pip install pillow) and the tesseract binary on PATH.
"""
import csv
import io
import re
import statistics
import subprocess
import sys
import tempfile
from pathlib import Path

from PIL import Image, ImageOps

TARGET_CAP_PX = 30  # capital-letter height Tesseract tends to read best


def run_tesseract(img: Image.Image, psm: int) -> list[dict]:
    with tempfile.NamedTemporaryFile(suffix=".png") as tmp:
        img.save(tmp.name)
        tsv = subprocess.run(
            ["tesseract", tmp.name, "-", "--psm", str(psm), "tsv"],
            capture_output=True, text=True, check=True,
        ).stdout
    rows = csv.DictReader(io.StringIO(tsv), delimiter="\t", quoting=csv.QUOTE_NONE)
    return [r for r in rows if r["level"] == "5" and r["text"].strip()]


def cap_height(words: list[dict]) -> float | None:
    # Words made only of capitals and digits approximate capital-letter height.
    caps = [int(w["height"]) for w in words if re.fullmatch(r"[A-Z0-9]{2,}", w["text"])]
    return statistics.median(caps) if caps else None


def report(name: str, words: list[dict]) -> float | None:
    confs = [float(w["conf"]) for w in words]
    mean = statistics.mean(confs) if confs else 0.0
    low = sum(c < 60 for c in confs)
    cap = cap_height(words)
    cap_txt = f"{cap:.0f}" if cap else "-"
    print(f"{name:<16}{len(words):>6}{mean:>11.1f}{low:>9}{cap_txt:>8}")
    return cap


def main(path: str) -> None:
    src = ImageOps.exif_transpose(Image.open(path)).convert("L")
    print(f"{Path(path).name}: {src.width}x{src.height}px, dpi metadata: "
          f"{src.info.get('dpi', ('not set',))[0]}")
    print(f"{'variant':<16}{'words':>6}{'mean conf':>11}{'conf<60':>9}{'cap px':>8}")
    cap = report("as-is", run_tesseract(src, 3))
    report("as-is psm 6", run_tesseract(src, 6))
    if cap and not 20 <= cap <= 45:
        scale = TARGET_CAP_PX / cap
        resized = src.resize((round(src.width * scale), round(src.height * scale)), Image.LANCZOS)
        report(f"rescaled x{scale:.1f}", run_tesseract(resized, 3))
        print("Text size was outside the 20-45 px range: fix capture resolution first.")


if __name__ == "__main__":
    main(sys.argv[1])

We tested it with Tesseract 5.5.0 on two synthetic versions of the same seven-line delivery note: one rendered with tiny text and saved as a heavily compressed JPEG to imitate a bad fax, and one rendered cleanly at a normal size.

small-fax.jpg: 286x152px, dpi metadata: not set
variant          words  mean conf  conf<60  cap px
as-is               44       74.2       11       8
as-is psm 6         44       74.2       11       8
rescaled x3.8       44       84.8        5      33
Text size was outside the 20-45 px range: fix capture resolution first.

clean.png: 1040x556px, dpi metadata: not set
variant          words  mean conf  conf<60  cap px
as-is               45       92.3        1      30
as-is psm 6         45       92.3        1      30

Capital letters on the fax were 8 pixels tall. As-is, Tesseract read M8x40 as MBx40 and the carrier reference 1Z44A0E90318 as 1244A0E90318. After rescaling, mean confidence rose from 74 to 85 and the bolt size came back as M8x40, but the carrier reference was still wrong, now as 1Z4440E90318, and PO turned into PQ. The clean render, with 30-pixel capitals, returned every size, quantity, and reference number correctly.

That result is the main lesson of this section. Upscaling helps the engine use what is there, but it cannot restore detail the compression already threw away. The page-level average looked healthier while one of the values you would actually match on stayed wrong. Word-level confidence did help: the wrong carrier reference scored 17 out of 100 after rescaling, so a filter on low-confidence words would have sent it to review. When the script says the text was too small, the real fix is a better scan, and the values that matter still need a check against a rule or a person.

Page segmentation made no difference here because the test page is a single block. On a two-column page, run tesseract page.png - --psm 4 and the other modes from section 3, and compare the text, not just the scores.

Fix it in this order

  1. Get the input right. 300 DPI or better, no repeated JPEG saves, flat and straight pages, a small white margin.
  2. Check what the engine actually saw. Resizing, rotation, and conversion steps in your own pipeline or the API can undo a good scan.
  3. Match the reading mode to the layout. Page segmentation for classic engines, a layout-aware model for tables and columns.
  4. Match the engine to the content. A handwriting model for handwriting, dictionaries off for codes, the right language model.
  5. Validate the values that matter. Totals, dates, and IDs get format rules or check digits, and failures go to review.
  6. Only then compare engines. Score candidates on your own hand-checked pages, as described in the OCR model guide.

Where OCRskill fits

OCRskill is a hosted OCR API. You send a file to POST /ocr and get Markdown back, or call POST /ocr.json?fields=... and get the named fields as typed JSON. Uploads are limited to 20 MB, and the structured JSON API reference lists the supported fields.

The part that matters for this topic is how it fails. If a required field cannot be read, the JSON endpoint returns a 400 instead of a silent null, so a bad page shows up as an error you can route to review rather than a wrong number in your database. It does not fix a source image that has lost its detail, and you should still validate critical values in your own code.

Limitations of this guide

  • The test is small and synthetic. One rendered delivery note in two qualities shows the mechanism. It is not a benchmark, and real scans add noise, skew, and lighting problems the test does not include.
  • Tesseract confidence is a hint. Word confidence flagged the bad values in this test, which makes it a good review filter. It measures how sure the engine is, not whether the text matches the page, so it does not replace checks on critical values.
  • The capital-height estimate is rough. It relies on words made of capitals and digits and uses Tesseract’s word boxes. Pages with no such words return no estimate.
  • Vision API limits change. The resize limits quoted here come from Claude’s documentation in October 2026. Other providers use different rules, so check the docs for the model you call.

Conclusion

OCR feels bad when the cause is invisible: a page that looks fine to you but reaches the engine as 8-pixel letters, a two-column report read straight across, or a PDF text layer written by an earlier OCR pass. Read the output for its fingerprint, measure letter size before you change engines, check what the engine actually received, and look at the PDF’s fonts before blaming recognition. The test above also shows where tuning stops: rescaling fixed one value and still missed another, so the lasting fixes are a better source image and validation on the values you cannot afford to get wrong. Once input, layout, and content type are ruled out, a side-by-side test on your own pages tells you whether a different engine is worth the switch.