OCR for Scans skill for Claude Code

Searchable scans with measured, not assumed, accuracy. Claude OCRs only the pages that need it, preprocesses photos and scans for Tesseract by fixing rotation, contrast, scale and borders, and picks the page segmentation mode that matches the layout. A bundled script reports word-level confidence and the shakiest words, so numbers and names get checked against the image, and tables or handwriting are routed to better tools.

  • nameocr-scans
  • size164 lines · ≈1,850 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Turns scanned documents and images into searchable, extractable text with Tesseract and OCRmyPDF — detecting which pages need OCR, image preprocessing (DPI, deskew, binarization, cropping), language packs and page segmentation modes, searchable PDF/A output, word-level confidence measurement, and when to switch to layout-aware or cloud OCR for tables, forms, and handwriting. Use when a PDF has no selectable text, the user shares photos or scans of documents, text extraction returns empty strings, or OCR output is garbled.

Try asking

  • Make this 200-page scanned contract searchable
  • Extract the text from these phone photos of receipts
  • The OCR output from our scanner is garbage — improve it

Use it when

  • A PDF has no selectable text or extraction returns nothing
  • Photos or scans of documents need to become text
  • Existing OCR output is garbled or in the wrong language
  • OCR quality must be measured before data is trusted

Skip it when

  • The PDF already has a text layer and you need tables or merging — use pdf-toolkit instead
  • The goal is removing personal data from scans — use document-redaction instead

How to check the result

Every previously image-only page now returns text, the confidence script meets the agreed mean, and a manual spot check of numbers, names and dates against the image matches.

The common mistake it prevents

Forcing OCR on PDFs that already contain digital text, which rasterizes crisp vector text and replaces accurate characters with recognition errors.

~/.claude/skills/ocr-scans/SKILL.md

--- name: ocr-scans description: Turns scanned documents and images into searchable, extractable text with Tesseract and OCRmyPDF — detecting which pages need OCR, image… license: MIT ---

Done well means: only pages without a real text layer were OCRed, the output is a searchable PDF (or text) with the right language, quality was measured with word confidence and spot checks against the image, and low-confidence regions were flagged to the user instead of silently passed off as accurate text.

Setup

# macOS
brew install tesseract tesseract-lang ocrmypdf
# Debian/Ubuntu
sudo apt install tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu ocrmypdf unpaper
# Python wrapper (optional)
pip install pytesseract pillow
tesseract --version && tesseract --list-langs

Decide the route

InputRoute
PDF, some pages scanned, some digitalocrmypdf --skip-text (OCR only pages without text)
PDF fully scannedocrmypdf with preprocessing flags
PDF with a bad existing OCR layerocrmypdf --redo-ocr (or --force-ocr, which rasterizes everything — loses vector quality)
Phone photos / PNG / JPEG / TIFFPreprocess, then tesseract or ocrmypdf (accepts single images)
Tables, forms, invoices where structure mattersLayout-aware OCR: docTR, PaddleOCR, Surya, or cloud document AI (AWS Textract, Google Document AI, Azure Document Intelligence)
HandwritingTesseract performs poorly; use cloud handwriting models or human transcription
Sensitive documentsKeep OCR local; do not upload to cloud services without the user’s agreement

First check whether OCR is needed at all: for PDFs, run the inspector from pdf-toolkit (pdf_inspect.py) or pdffonts file.pdf (no fonts means image-only pages).

Searchable PDF with OCRmyPDF (default)

ocrmypdf -l eng --rotate-pages --deskew --skip-text --output-type pdfa \
         --sidecar out.txt --jobs 4 input.pdf output.pdf
FlagPurpose
-l eng+deuLanguages present; order by prevalence; each must be installed
--rotate-pagesFix pages scanned sideways/upside down
--deskewStraighten slightly rotated scans
--cleanClean pages with unpaper before OCR (output keeps the original image)
--clean-finalAlso put the cleaned image in the output
--skip-textLeave pages that already have text alone
--redo-ocrReplace an existing OCR layer while keeping visible content
--output-type pdfa / pdfPDF/A for archiving (default) or plain PDF
--sidecar out.txtPlain text alongside the PDF
--optimize 1Lossless size optimization (higher levels may need pngquant/jbig2)
--pages 1-3Limit OCR to some pages (when supported by your version)

OCRmyPDF exits non-zero with distinct codes (for example when a page already has text and neither --skip-text nor --force-ocr was given); read the message rather than retrying blindly.

Tesseract directly (images, fine control)

tesseract page.png out -l eng --psm 3             # writes out.txt
tesseract page.png out -l eng --psm 6 pdf         # searchable single-page PDF
tesseract page.png out -l eng tsv                 # word boxes + confidence
tesseract page.png out -l eng hocr                # HTML with layout

Page segmentation modes that matter:

--psmUse for
3Default: full page, automatic layout
4Single column of variable-size text (receipts, simple tables read row-wise)
6One uniform block of text (cropped paragraph)
7Single text line (a cropped field)
11 / 12Sparse text, find as much as possible (labels, forms, diagrams)

--oem 1 (LSTM) is the default engine in Tesseract 5. Restrict characters for fields like amounts: -c tessedit_char_whitelist=0123456789.,- (works with LSTM in Tesseract 4.1+/5, but test it).

Preprocessing (biggest quality lever)

Tesseract does best with: 300 DPI, black text on white, text height around 20–40 px for capitals, horizontal lines, no borders or shadows.

from PIL import Image, ImageOps, ImageFilter   # pip install pillow

img = Image.open("photo.jpg")
img = ImageOps.exif_transpose(img)             # honor camera rotation
img = ImageOps.grayscale(img)
if img.width < 2000:                           # upscale small captures
    img = img.resize((img.width * 2, img.height * 2), Image.LANCZOS)
img = ImageOps.autocontrast(img, cutoff=1)
img = img.filter(ImageFilter.MedianFilter(3))  # remove speckle noise
img = img.point(lambda p: 255 if p > 160 else 0)   # simple threshold; tune or use Otsu (OpenCV)
img.save("clean.png", dpi=(300, 300))

For uneven lighting (phone photos), adaptive thresholding with OpenCV (cv2.adaptiveThreshold) beats a global threshold. Crop away dark scanner borders — they produce garbage characters. For perspective-distorted photos, correct the perspective (OpenCV four-point transform) before OCR.

Rasterize PDF pages at 300 DPI when you need custom preprocessing: pdftoppm -r 300 -png input.pdf page (poppler).

Measure quality

python3 scripts/ocr_confidence.py clean.png --lang eng --psm 6
python3 scripts/ocr_confidence.py pages/*.png --threshold 70 --min-mean 85 --text all.txt

Standard library plus the tesseract binary. For each image it reports word count, mean and median word confidence, the share of words below the threshold, and the lowest- confidence words; --text writes the reconstructed text. Exit code 1 if any image’s mean confidence is below --min-mean.

Interpretation:

  • Mean ≥ 90 on clean print is typical; 70–85 means spot-check closely; < 70 means preprocessing, language, or PSM is wrong (or the content is handwriting/tables).
  • Confidence is not accuracy. Always compare a few lines against the image, especially numbers, names, and dates. Digits confusions to check: 0/O, 1/l/I, 5/S, 8/B, ,/..
  • For structured fields (totals, IDs), validate with rules: checksums, date parsing, totals equal the sum of line items.

Extract structure

  • Tesseract TSV gives block/paragraph/line/word numbers and bounding boxes; group by line_num and x-positions to rebuild simple columns.
  • Tables with ruling lines: detect cells (OpenCV morphology) and OCR each cell with --psm 6 or 7, or use a layout-aware model.
  • Forms: crop known field regions by coordinates (for a fixed template) and OCR each with --psm 7 and a character whitelist.

Gotchas

  • OCRmyPDF on an already-digital PDF without --skip-text stops with an error; with --force-ocr it rasterizes vector text and makes the file worse.
  • JPEG compression artifacts hurt OCR; save intermediates as PNG or TIFF.
  • Multi-language documents need every language in -l; adding unused languages slows OCR and can reduce accuracy.
  • Searchable PDFs place invisible text over the image; copy/paste order follows OCR layout and may differ from visual reading order in multi-column pages.
  • Redacted or blacked-out areas may still be legible to OCR if the “redaction” was a translucent overlay — relevant for document-redaction work.

Verification

  • pdffonts output.pdf shows a GlyphLessFont (the OCR layer) on previously image-only pages; pdftotext output.pdf - returns text for every page.
  • scripts/ocr_confidence.py meets the agreed mean confidence, and low-confidence words are listed for the user.
  • Manual spot check of at least one page per document type against the image, with numeric fields validated by rules.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/ocr_confidence.py 99 lines · 3.9 KB
#!/usr/bin/env python3
"""Run Tesseract on images and report word-level confidence, so OCR quality is measured
instead of assumed.

Requires the tesseract binary (brew install tesseract / apt install tesseract-ocr) plus
language packs for non-English text. Python standard library only.

Usage:
  python3 ocr_confidence.py page1.png page2.png
  python3 ocr_confidence.py scans/*.png --lang eng+deu --psm 4 --threshold 70
  python3 ocr_confidence.py page.png --text out.txt      # also write recognized text

Exit codes: 0 = every image meets --min-mean, 1 = at least one image below it,
            2 = tesseract missing or failed.
"""
import argparse
import csv
import io
import shutil
import statistics
import subprocess
import sys


def run_tsv(path, lang, psm, dpi):
    cmd = ["tesseract", path, "stdout", "-l", lang, "--psm", str(psm)]
    if dpi:
        cmd += ["--dpi", str(dpi)]
    cmd.append("tsv")
    proc = subprocess.run(cmd, capture_output=True, text=True)
    if proc.returncode != 0:
        raise RuntimeError(proc.stderr.strip() or f"tesseract exited {proc.returncode}")
    return list(csv.DictReader(io.StringIO(proc.stdout), delimiter="\t", quoting=csv.QUOTE_NONE))


def rebuild_text(rows):
    lines, current, key = [], [], None
    for r in rows:
        if r.get("level") != "5":
            continue
        k = (r["page_num"], r["block_num"], r["par_num"], r["line_num"])
        if key is not None and k != key:
            lines.append(" ".join(current)); current = []
        key = k
        current.append(r["text"])
    if current:
        lines.append(" ".join(current))
    return "\n".join(lines)


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("images", nargs="+")
    ap.add_argument("--lang", default="eng")
    ap.add_argument("--psm", type=int, default=3, help="page segmentation mode (default 3 = auto)")
    ap.add_argument("--dpi", type=int, default=0, help="tell tesseract the DPI if the image lacks it")
    ap.add_argument("--threshold", type=float, default=60, help="word confidence counted as low (default 60)")
    ap.add_argument("--min-mean", type=float, default=80, help="mean confidence required to pass (default 80)")
    ap.add_argument("--text", help="write recognized text of all images to this file")
    args = ap.parse_args()

    if not shutil.which("tesseract"):
        print("error: tesseract not found on PATH", file=sys.stderr)
        return 2

    failed, all_text = 0, []
    for path in args.images:
        try:
            rows = run_tsv(path, args.lang, args.psm, args.dpi)
        except RuntimeError as exc:
            print(f"{path}: tesseract failed: {exc}", file=sys.stderr)
            return 2
        words = [r for r in rows if r.get("level") == "5" and (r.get("text") or "").strip()]
        confs = [float(r["conf"]) for r in words if float(r["conf"]) >= 0]
        if not confs:
            print(f"{path}: no words recognized (blank page, wrong --psm, or image too small/low contrast)")
            failed += 1
            continue
        low = [(r["text"], float(r["conf"])) for r in words if 0 <= float(r["conf"]) < args.threshold]
        mean = statistics.fmean(confs)
        median = statistics.median(confs)
        status = "PASS" if mean >= args.min_mean else "LOW"
        failed += status == "LOW"
        print(f"{path}: {status} words={len(confs)} mean={mean:.1f} median={median:.1f} "
              f"low(<{args.threshold:g})={len(low)} ({100 * len(low) / len(confs):.1f}%)")
        if low:
            sample = ", ".join(f"{t!r}:{c:.0f}" for t, c in sorted(low, key=lambda x: x[1])[:15])
            print(f"    lowest: {sample}")
        all_text.append(rebuild_text(rows))

    if args.text:
        with open(args.text, "w", encoding="utf-8") as fh:
            fh.write("\n\f\n".join(all_text))
        print(f"text written to {args.text}")
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc