Tools,

Receipt OCR in Python, tested on 99 receipts

84%

of receipt words found by RapidOCR, free

For printed scans: $0.006 a page

pytesseract missed 2 of every 3 words

Receipt words found, of 2,501 on 99 photos
pytesseract, default
33%
pytesseract, --psm 6
38%
Our Actor, build 0.1.44
66%
RapidOCR 3.10
84%

A word counts when it shows up anywhere in the output. The photos are shop receipts from Indonesia, from the public CORD set.

How I tested it
  • 99 receipt photos from CORD v2 by Clova AI (CC BY 4.0). Its truth has the item and total lines only, so shop names and dates are not scored.
  • Our Actor's numbers come from its build 0.1.44 code in a local container, with the same scorer.
  • The line helper comes from an earlier test on other pages. A simpler helper I tried first on these 99 kept 86% of the priced lines.
  • With --psm 4, pytesseract found 34%.
  • One Mac (Apple M5 Max): Tesseract 5.5.3, pytesseract 0.3.13, RapidOCR 3.10.0 and ONNX Runtime 1.31.1.

The usual snippet splits almost every price off

RapidOCR returns one box for each run of text. Join result.txts with new lines, and most prices land on a line of their own.

Priced lines with name and price together, of 553
RapidOCR, plain result.txts
3%
pytesseract, default
17%
Our Actor, build 0.1.44
36%
RapidOCR, the script below
92%

The script takes out the photo's tilt, then joins the boxes on each line from left to right. The helper comes from our four engine test.

What counts as a priced line?

A receipt line that ends in a price, totals included. It counts when one of its words and then its price come out close together on one line.

The script: 19 lines, no GPU

pip install rapidocr onnxruntime
import sys

from rapidocr import RapidOCR

from lines import to_lines

engine = RapidOCR()


def read_receipt(path):
    """Receipt photo to text, one receipt line per output line."""
    result = engine(path)
    if result.boxes is None:  # no text found
        return ""
    return "\n".join(to_lines(result.boxes.tolist(), result.txts))


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

Save the helper below as lines.py in the same folder. Then run python receipt_ocr.py receipt.jpg.

lines.py, the 27-line helper
import math
import statistics


def to_lines(boxes, texts):
    """Sort OCR boxes into lines of text, top to bottom, left to right."""
    tilts = []
    for b in boxes:
        (x0, y0), (x1, y1) = b[0], b[1]
        if abs(x1 - x0) > 3 * abs(b[3][1] - y0):
            tilts.append(math.atan2(y1 - y0, x1 - x0))
    tilt = statistics.median(tilts) if tilts else 0
    cos, sin = math.cos(tilt), math.sin(tilt)

    items = []
    for b, text in zip(boxes, texts):
        x, y = sum(p[0] for p in b) / 4, sum(p[1] for p in b) / 4
        items.append((y * cos - x * sin, x * cos + y * sin, math.dist(b[0], b[3]), text))
    height = statistics.median(i[2] for i in items)

    rows = []
    for item in sorted(items, key=lambda i: i[0]):
        if rows and item[0] - rows[-1][-1][0] < height / 2:
            rows[-1].append(item)
        else:
            rows.append([item])
    return [" ".join(i[3] for i in sorted(r, key=lambda i: i[1])) for r in rows]

One receipt: 22 of 22 items right

The top of a printed shop receipt: 8 lines, each with a quantity, an item name and a price, such as 1 x Nasi Campur Bali 75,000
Cropped from receipt cord_train_000 of CORD by Park et al., Clova AI, CC BY 4.0. The first of the 99, not picked by result.

The first 8 lines that the script printed:

Nasi Campur Bali 75,000
Bbk Bengil Nasi 125,000
MilkShake Starwb 37,000
Ice Lemon Tea 24,000
Nasi Ayam Dewata 70,000
Free Ice Tea 0
Organic Green Sa 65,000
Ice Tea 18,000

It dropped the faint quantity column (1 x, 3 x) and the word "Total" in "Grand Total".

What it still gets wrong

Where our Actor fits

On receipt photos, use the script above. Our Actor is built for printed scans and PDFs.

Two ways to skip the work

Run it yourself. Our Apify Actor turns scanned PDFs into Markdown, tables included.

Try the Actor

Or send us the scan. Tack, our AI, does the job for $29, up to 50 pages. First page free.

Get one page free

Questions people ask

How often does pytesseract return nothing?

At the default page mode, 24 of the 99 receipts came back empty. With --psm 6, only one did.

Is RapidOCR free for business use?

Its code is Apache-2.0, and the notice for its model files says they are not restricted to non-commercial use. Read both notices before you ship.

Does it need a GPU?

No. It ran on the CPU through ONNX Runtime, in about 0.2 seconds a receipt (the median) on our Mac.