Tools,

MRZ OCR in Python, tested on 40 documents

37 of 40

right with RapidOCR plus PassportEye's cleanup

Get the code

RapidOCR, then PassportEye's cleanup

pip install passporteye rapidocr onnxruntime
from passporteye.mrz.text import MRZ
from rapidocr import RapidOCR

result = RapidOCR()("passport.png")
mrz = MRZ.from_ocr("\n".join(result.txts))
print(mrz.valid, mrz.number, mrz.surname, mrz.names)
True L898902C3 ERIKSSON ANNA MARIA

RapidOCR reads every character but often takes the letter O for a zero, as in UT0. MRZ.from_ocr fixes each character by its place: a letter where the format wants a letter, a digit where it wants a digit.

7 ways on the same 40 MRZs

MRZs read right, of 40
Way Scan Photo
PassportEye, defaults 0 0
PassportEye, --oem 0, full eng 32 9
PassportEye, -l ocrb 27 31
fastmrz 2.1.2 23 30
Tesseract 5.5.4 alone 0 0
RapidOCR + MRZ.from_ocr 37 32
Our Actor + MRZ.from_ocr 25 17

20 passports and 20 ID cards. Scan: the card at 300 dpi. Photo: the card on a table, tilted, smaller, soft and noisy, as a JPEG. A reading counts only if every MRZ line is right.

Why PassportEye's defaults read 0

from passporteye import read_mrz

mrz = read_mrz("passport.png")
if mrz is None:
    print("No MRZ found")
else:
    d = mrz.to_dict()
    print(d["valid_score"], d["number"], d["surname"], d["names"], d["date_of_birth"])
98 L898902C3 ERIKSSON ANNAKMARIAK X    K K  K KKKKK 740812

That is the ICAO specimen card, given names ANNA MARIA. PassportEye calls Tesseract, and Homebrew installs the fast English model. It drops runs of < and reads some as K.

Two fixes: --oem 0 or the ocrb model

The README tip, --oem 0, needs the legacy model:

Error: Tesseract (legacy) engine requested, but components are not present in /opt/homebrew/share/tessdata/eng.traineddata!!

The eng file from the tessdata repo has it (Debian's tesseract-ocr-eng installs that file). Or use the ocrb model, trained on MRZ lines:

curl -sSfL -o tessdata/ocrb.traineddata https://github.com/Shreeshrii/tessdata_ocrb/raw/master/ocrb.traineddata
from passporteye import read_mrz

mrz = read_mrz("passport.png", extra_cmdline_params="-l ocrb --tessdata-dir tessdata")
print(mrz.to_dict()["valid_score"] if mrz else "No MRZ found")

Check digits miss names and K

31 wrong readings passed every check digit

0 check digits on the name line

Check digits cover the document number and the dates, not the name, sex or nationality. And some misreads keep the sum: K counts 20 and < counts 0, so mod 10 they match.

def check_digit(s):
    value = lambda c: int(c) if c.isdigit() else 0 if c == "<" else ord(c) - 55
    return sum(value(c) * (7, 3, 1)[i % 3] for i, c in enumerate(s)) % 10


print(check_digit("L898902C3"), check_digit("<<<<<<"), check_digit("KKKKKK"))
6 0 0

The same goes for G and 6, S and 8. Compare the name with the printed page, not only mrz.valid.

PDFs: PassportEye wants a JPEG inside

read_mrz takes the first JPEG image in a PDF. A 1-bit scan has none, and the call stops on all 40:

AttributeError: 'NoneType' object has no attribute 'shape'

Render the page first:

import pypdfium2 as pdfium
from passporteye.mrz.text import MRZ
from rapidocr import RapidOCR

page = pdfium.PdfDocument("scan.pdf")[0]
image = page.render(scale=300 / 72).to_numpy()
mrz = MRZ.from_ocr("\n".join(RapidOCR()(image).txts))
print(mrz.valid, mrz.number)

That read 33 of 40 1-bit A4 scans right, with the card in one corner.

fastmrz: add a model, distrust FAILURE

pip install fastmrz brings no MRZ model for Tesseract:

Error opening data file /opt/homebrew/share/tessdata/mrz.traineddata
curl -sSfL -o tessdata/mrz.traineddata https://raw.githubusercontent.com/sivakumar-mahalingam/fastmrz/main/tessdata/mrz.traineddata
from fastmrz import FastMRZ

fast = FastMRZ(tessdata_path="tessdata")
d = fast.get_details("passport.png")
print(d["status"], d.get("document_number"), d.get("surname"), d.get("given_name"))

It read 23 of 40 scans right but said FAILURE on 15 of those. An empty personal number may end in <, which ICAO 9303 allows. fastmrz 2.1.2 wants 0.

Need page text with nothing to install?

Our Actor reads whole pages: Tesseract, and RapidOCR on English pages that Tesseract reads badly. It is not an MRZ reader.

Its Markdown joins the MRZ lines, so split at spaces first: MRZ.from_ocr("\n".join(md.split())). That gave 25 of 40 scans right. On 9 scans it read a line backward, with > for <.

curl -s -X POST "https://api.apify.com/v2/acts/brasspage~pdf-ocr-to-markdown/run-sync-get-dataset-items" \
  -H "Authorization: Bearer $APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"documentUrls": ["https://api.apify.com/v2/key-value-stores/sTnmNrGoSsi7tLQvs/records/anythingllm-mixed-11-pages.pdf"]}' \
  | jq -r '.[].markdown' > book.md

It costs $0.006 a page.

How I tested it
  • 40 made-up documents from Utopia (UTO), the test state of ICAO 9303: its 2 specimens and 38 more with every check digit right. Names are random syllables.
  • MRZ in OCR-B (the free outline font on CTAN), 10 characters an inch, 300 dpi. Scans also went into A4 PDFs: one with a JPEG, one 1-bit.
  • PassportEye 2.2.2, fastmrz 2.1.2, RapidOCR 3.10.0 and Tesseract 5.5.4 on Python 3.13, Apple Silicon Mac. 2 workers, 1 thread each, lowest priority.
  • Right: every MRZ line equals the truth, character for character. PassportEye rows use the lines read_mrz parsed.
  • The Actor rows ran the Actor's own code on this Mac, with the same Tesseract model as its image.
What I did not test
  • Real passports, glare, holograms, worn cards.
  • TD2 cards, visas, and other MRZ fonts.
Sources

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

Why does PassportEye read the MRZ wrong?

With the fast English model that Homebrew installs, Tesseract drops runs of < and reads some as K. PassportEye's defaults read 0 of 40 test MRZs right. Use -l ocrb, --oem 0 with the full eng model, or RapidOCR with MRZ.from_ocr.

Is mrz.valid enough to trust a reading?

No. Check digits do not cover the name, sex or nationality, and a < read as K does not change them. In this test, 31 results passed every check digit and were still wrong.

Can PassportEye read a PDF?

Only a PDF with a JPEG image inside. A 1-bit scan stops with AttributeError. Render the page with pypdfium2 first.

Why does fastmrz say FAILURE on a good MRZ?

fastmrz 2.1.2 wants 0 as the check digit of an empty personal number. ICAO 9303 also allows <. It said FAILURE on 15 of the 23 scans it read right.