DEV Community

Cover image for OCR a Folder of Receipts and Invoices in Python, and Flag the Pages to Check
Oleg Tagobitsky for API4AI

Posted on

OCR a Folder of Receipts and Invoices in Python, and Flag the Pages to Check

Typing receipts and invoices into a spreadsheet is the kind of job everyone agrees should be automated, and then it isn't, because "automated" has to include knowing which pages came out wrong.

This tutorial builds a small tool around the API4AI OCR API. It reads a whole folder of scans and photos (JPEG, PNG and multi-page PDF), saves the text with the original line structure, keeps every word's position in a CSV, and writes a report that tells a person which pages to open. It's about 150 lines of Python, and the only dependency is requests.

One request

curl -X POST "https://api4ai.cloud/ocr/v1/results?algo=simple-words" \
     -H "X-API-KEY: $API4AI_KEY" \
     -F "image=@receipt.jpg"
Enter fullscreen mode Exit fullscreen mode

The file goes in as multipart form data: in image, or a public link in url. JPEG, PNG or PDF, under 16 MB, at most 4096×4096.

algo picks the mode. The default, simple-text, returns the whole page as one block of text. simple-words returns every word with its own box, and that's the one this script uses. The reason is in the next section.

What comes back

Here's the docs example, a handwritten envelope, in simple-words mode (trimmed to three of its nine words):

{
  "results": [
    {
      "status": { "code": "ok", "message": "Success" },
      "name": "text.jpg",
      "width": 1500,
      "height": 1000,
      "entities": [
        {
          "kind": "objects",
          "name": "words",
          "objects": [
            { "box": [0.2073, 0.301, 0.1187, 0.077],
              "entities": [{ "kind": "text", "name": "text", "text": "Jane" }] },
            { "box": [0.22, 0.453, 0.094, 0.044],
              "entities": [{ "kind": "text", "name": "text", "text": "1095" }] },
            { "box": [0.3793, 0.43, 0.0947, 0.061],
              "entities": [{ "kind": "text", "name": "text", "text": "Main" }] }
          ]
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Each box is [x, y, width, height], as fractions of the image size. For a PDF, results has one entry per page, each with a page number.

Three things the docs tell you that change the code

1. Failures come back as HTTP 200. A file the service can't read (corrupted, not really an image, too large) doesn't raise an HTTP error. It returns 200, with "failure" in status.code and the reason in status.message:

{ "status": { "code": "failure", "message": "Can not load image." },
  "name": "file.txt", "entities": [] }
Enter fullscreen mode Exit fullscreen mode

If you only check the HTTP code, those files silently drop out of your results. The script checks status.code for every page.

2. There's no confidence score. You can't filter on "below 80%". So the script checks the text itself: is the page empty, does it have fewer words than a real document would, and does it contain the things this kind of document always has. For receipts and invoices that's an amount and a date.

3. A PDF is billed per page. Each page is converted to an image and read separately. A 12-page contract is 12 requests. The script counts pages, so the summary line tells you what the run cost.

Rebuilding the lines

We photographed a test receipt (a made-up café, lying slightly crooked on a table) and sent it to the API in both modes. All 51 words came back right. Here's the end of the simple-text block:

1 x Banana bread
3.50
Subtotal
VAT 20% incl.
TOTAL
Card
16.50
2.75
16.50
16.50
Thank you, see you soon!
Enter fullscreen mode Exit fullscreen mode

The labels and the amounts at the bottom came back as two separate runs. Which 16.50 is the total? The text alone doesn't say.

With simple-words you get every word's box, so you can rebuild the lines yourself. The obvious rule is to sort words by their vertical centre and start a new line when the centre jumps by more than half a word's height. On this receipt that rule fails too: the photo is tilted by a couple of degrees, so each amount on the right sits slightly higher than its label on the left, and "TOTAL" and "16.50" land on different lines.

The fix is to measure the tilt first. Neighbouring words on the same line give you a slope; take the median, subtract it, and then group:

def to_lines(words):
    """Rebuild reading order from word boxes, allowing for a slightly tilted photo."""
    mid = lambda w: w[2] + w[4] / 2                      # vertical centre
    # Tilt: how far the centre moves per unit of x between neighbouring words.
    slopes = [(mid(b) - mid(a)) / (b[1] - a[1]) for a in words for b in words
              if 0 < b[1] - (a[1] + a[3]) < 2 * a[4] and abs(mid(b) - mid(a)) < 0.5 * a[4]]
    tilt = statistics.median(slopes) if slopes else 0.0
    level = lambda w: mid(w) - tilt * w[1]
    ...
Enter fullscreen mode Exit fullscreen mode

On the receipt, that gives:

Date 16/10/2026 08:42
Receipt No. 004471
2 x Flat white 7.00
1 x Croissant 2.80
1 x Orange juice 3.20
1 x Banana bread 3.50
Subtotal 16.50
VAT 20% incl. 2.75
TOTAL 16.50
Card 16.50
Enter fullscreen mode Exit fullscreen mode

It's still a simple rule. A label photographed strongly from the side, where the tilt changes across the page, breaks it: on our angled jar label it split "Wildflower Honey" over two lines. That's one more reason the report exists.

The checks

These live at the top of the script, so you can change them per document type:

MIN_WORDS = 5
EXPECT = {
    "amount": r"\d[\d,]*[.,]\d{2}\b",                       # 12.50, 1,204.00
    "date": r"\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b|\b\d{4}-\d{2}-\d{2}\b",
}
Enter fullscreen mode Exit fullscreen mode

A page gets a flag when it has no text, fewer than MIN_WORDS words, or no match for one of the EXPECT patterns. For delivery notes you might expect an order number instead; for shipping labels, a postcode. Thresholds and patterns here are a starting point, not a tuned set.

The whole script

"""OCR a folder of scans and photos, and flag the pages a person should check.

    pip install requests
    export API4AI_KEY=...            # from portal.api4.ai
    python3 ocr_folder.py scans/ text/

For every file you get text/<same path>.txt (pages separated by a form feed),
plus text/words.csv (every word with its box) and text/report.csv (one row per
page: status, word count, and the reasons to look at it).
"""
import csv
import os
import pathlib
import re
import statistics
import sys
import time
from concurrent.futures import ThreadPoolExecutor

import requests

URL = "https://api4ai.cloud/ocr/v1/results?algo=simple-words"
KEY = os.environ["API4AI_KEY"]
FILES = {".jpg", ".jpeg", ".png", ".pdf"}

# What a normal page should contain. Edit these for your own documents.
MIN_WORDS = 5
EXPECT = {
    "amount": r"\d[\d,]*[.,]\d{2}\b",                       # 12.50, 1,204.00
    "date": r"\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b|\b\d{4}-\d{2}-\d{2}\b",
}

src, dst = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])


def call_api(path, tries=4):
    for attempt in range(tries):
        with path.open("rb") as f:
            r = requests.post(URL, headers={"X-API-KEY": KEY},
                              files={"image": f}, timeout=300)
        if r.status_code in (429, 500, 502, 503, 504) and attempt < tries - 1:
            time.sleep(2 ** attempt)                          # 1, 2, 4 s
            continue
        r.raise_for_status()
        return r.json()["results"]          # one result per page for a PDF


def words_of(page):
    """[(text, x, y, w, h), ...] from one page of the response."""
    out = []
    for entity in page.get("entities", []):
        for obj in entity.get("objects", []):
            text = " ".join(e["text"] for e in obj["entities"] if e["kind"] == "text")
            out.append((text, *obj["box"]))
    return out


def to_lines(words):
    """Rebuild reading order from word boxes, allowing for a slightly tilted photo."""
    mid = lambda w: w[2] + w[4] / 2                      # vertical centre
    # Tilt: how far the centre moves per unit of x between neighbouring words.
    slopes = [(mid(b) - mid(a)) / (b[1] - a[1]) for a in words for b in words
              if 0 < b[1] - (a[1] + a[3]) < 2 * a[4] and abs(mid(b) - mid(a)) < 0.5 * a[4]]
    tilt = statistics.median(slopes) if slopes else 0.0
    level = lambda w: mid(w) - tilt * w[1]
    lines = []
    for w in sorted(words, key=level):                   # same level = same line
        if lines and abs(level(w) - lines[-1][0]) < 0.5 * w[4]:
            lines[-1][1].append(w)
        else:
            lines.append([level(w), [w]])
    text = []
    for _, ws in lines:
        line = ""
        for t, *_ in sorted(ws, key=lambda w: w[1]):      # left to right
            glue = t in ",.;:!?%)" or (line[-1:] == ":" and line[-2:-1].isdigit())
            line += t if not line or glue else " " + t
        text.append(line)
    return "\n".join(text)


def checks(words, text):
    flags = []
    if not words:
        return ["no text"]
    if len(words) < MIN_WORDS:
        flags.append("few words")
    for name, pattern in EXPECT.items():
        if not re.search(pattern, text):
            flags.append("no " + name)
    return flags


def ocr(path):
    rel = path.relative_to(src)
    out = dst / rel.with_suffix(rel.suffix + ".txt")
    if out.exists():
        return rel, None
    rows, texts = [], []
    for n, page in enumerate(call_api(path), start=1):
        number = page.get("page", n)
        # A file the service can't read still comes back as HTTP 200.
        if page["status"]["code"] != "ok":
            rows.append((rel, number, "failed: " + page["status"]["message"], [], []))
            continue
        words = words_of(page)
        text = to_lines(words)
        texts.append(text)
        rows.append((rel, number, "ok", words, checks(words, text)))
    if texts:                                # nothing written means retry next run
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_text("\f".join(texts), encoding="utf-8")
    return rel, rows


files = sorted(p for p in src.rglob("*") if p.suffix.lower() in FILES)
dst.mkdir(parents=True, exist_ok=True)
pages = failed = flagged = skipped = 0


def open_csv(name, header):
    f = open(dst / name, "a", newline="", encoding="utf-8")
    w = csv.writer(f)
    if f.tell() == 0:
        w.writerow(header)
    return f, w


fr, report = open_csv("report.csv", ["file", "page", "status", "words", "flags"])
fw, wordlist = open_csv("words.csv", ["file", "page", "word", "x", "y", "w", "h"])
with ThreadPoolExecutor(max_workers=4) as pool, fr, fw:
    for rel, rows in pool.map(ocr, files):
        if rows is None:
            skipped += 1
            continue
        for rel, number, status, words, flags in rows:
            pages += 1
            failed += status != "ok"
            flagged += bool(flags)
            report.writerow([rel, number, status, len(words), "; ".join(flags)])
            wordlist.writerows([rel, number, t, *(round(v, 4) for v in box)]
                               for t, *box in words)
            mark = "CHECK " if flags or status != "ok" else "      "
            print(f"{mark}{rel} p{number}: {status}, {len(words)} words {'; '.join(flags)}")

print(f"\n{len(files)} files: {skipped} already done. {pages} pages read "
      f"(each page is billed), {failed} failed, {flagged} flagged for a person")
Enter fullscreen mode Exit fullscreen mode

Run it:

pip install requests
export API4AI_KEY=...
python3 ocr_folder.py scans/ text/
Enter fullscreen mode Exit fullscreen mode

What you get in text/:

  • test-receipt.jpg.txt for every file: the rebuilt text, pages separated by a form feed. The original extension stays in the name, so invoice.pdf and invoice.jpg can't overwrite each other.
  • words.csv: one row per word with its page and box, for anything that needs positions later.
  • report.csv: one row per page.
file,page,status,words,flags
test-receipt.jpg,1,ok,51,
invoice-77.pdf,1,ok,214,
invoice-77.pdf,2,ok,4,few words; no amount; no date
photo-blurry.jpg,1,ok,0,no text
broken.png,1,failed: Can not load image.,0,
Enter fullscreen mode Exit fullscreen mode

(The first row is the test receipt above; the others are examples.) Only the last three need a person.

What else it takes care of:

  • Originals are never touched. Output goes to a separate folder with the same structure.
  • Resume. A file with a .txt already in place is skipped, so after a crash or Ctrl-C you run the same command again. Files that failed have no .txt, so they're retried.
  • Rate limits. A 429 or a 5xx waits 1, 2, then 4 seconds before giving up.
  • Count pages. The last line says how many pages were read, failed and flagged. Pages, not files, are what you pay for.

Four workers is a starting point. PDFs take longer than single images, so if you see timeouts, lower it before raising the timeout.

What it costs

On the API4AI developer portal the OCR API is pay-as-you-go at $5 per 1,000 requests, with no subscription. One image is one request; a PDF counts per page. New accounts start with $5 of credit, about 1,000 pages. For large volumes, ask for a quote at hello@api4.ai.

Before you write code

Put your worst pages through the demo on the OCR API page first: the faded thermal receipt, the handwritten form, the photo taken at an angle. It takes any JPEG or PNG and shows every word it found. The full reference is in the docs. There's also a separate VIN endpoint that applies VIN rules and returns suggestions, if that's your use case. The less technical version of this post, covering receipts, forms, labels and VINs, is on our blog.

What does your pipeline do with a page it can't read: retry, skip, or send it to a person?

Top comments (0)