How to Benchmark MRZ Recognition Accuracy on MIDV-500 with Python

Building an MRZ recognition pipeline is only half the battle. You also need to know how it performs across real-world capture conditions, and you need a dataset whose ground truth matches the pixels being scored. This tutorial walks through a Python benchmark that evaluates the Dynamsoft Capture Vision SDK on a curated 3,315-image MIDV-500 MRZ subset, measuring exact accuracy, success rate, character-level match rate, and per-image decode timing.

benchmark-mrz-recognition-midv500-dataset

What you’ll build: A Python command-line tool that benchmarks Dynamsoft Capture Vision MRZ recognition on MIDV-500 images, uses audited ground truth, and exports a JSON report plus an HTML dashboard with accuracy metrics and timing distribution charts.

Key Takeaways

  • The curated benchmark contains 3,315 scoreable MRZ images from an original 3,600 labeled MIDV-500 subset.
  • The benchmark keeps all visible, scoreable MRZ pages, including passport MRZ images and the Algerian ID-card style MRZ images in document type 18.
  • The audit excludes 285 frames where the MRZ is cropped, incomplete, or not yet visible.
  • Dynamsoft Capture Vision decoded MRZ text from 2,128 images, giving a 64.19% success rate.
  • 1,602 images matched the ground truth exactly, giving 48.33% exact accuracy.
  • The average decode time was 365.2 ms, with a P50 of 258.9 ms, P95 of 919.3 ms, and P99 of 1,283.7 ms.
  • The benchmark uses the ReadPassportAndId template, which handles passport and ID MRZ formats in one API call via router.capture_multi_pages().

Common Developer Questions

How accurate is Dynamsoft Capture Vision for MRZ reading?

On the curated 3,315-image MIDV-500 MRZ benchmark, the SDK achieved 48.33% exact MRZ accuracy and 64.19% success rate. The exact-match number is strict: the normalized recognized MRZ must match the ground-truth MRZ character for character. The success-rate number is higher because it counts images where the SDK returned MRZ text, even if one line or some characters did not match exactly.

Why is the curated benchmark smaller than the original 3,600 labels?

The original label file includes frames from moving capture sequences. Some early PA and PS frames show only part of the document, or the MRZ has not entered the frame yet. These images have a label row, but the MRZ cannot be fairly scored from the pixels. The audit removes 285 such frames and records every removal in assets/midv500_mrz_exclusions.csv.

What document types are included?

The source subset covers 12 MIDV-500 document groups: Azerbaijan passport (05), Brazil passport (06), Czech passport (11), German new passport (16), German old passport (17), Algerian document with an ID-card style MRZ (18), Greek passport (25), Croatian passport (27), Hungarian passport (28), Latvian passport (32), Moldovan passport (34), and Serbian passport (41). The benchmark is not limited to passport MRZ pages when another scoreable MRZ page is present.

How fast is the Dynamsoft Capture Vision Python SDK for MRZ decoding?

The average decode time across the curated benchmark is 365.2 ms per image. The median is 258.9 ms, with P95 at 919.3 ms and P99 at 1,283.7 ms. Timing starts immediately before the SDK call and stops immediately after it returns, excluding JSON serialization and report generation.

Prerequisites

  • Python 3.8 or later
  • dynamsoft-capture-vision-bundle
  • Pillow, only needed when converting downloaded .tif files
  • A valid Dynamsoft license key. Get a 30-day free trial license.

Install the Python dependencies:

pip install -r requirements.txt

Step 1: Initialize the Dynamsoft Capture Vision Router

The first step is to initialize the SDK license and create a CaptureVisionRouter instance. The router is the central object that handles image recognition tasks. The ReadPassportAndId template is a built-in preset for passport and ID MRZ recognition, including TD1, TD2, and TD3 document formats.

from dynamsoft_capture_vision_bundle import (
    CaptureVisionRouter,
    EnumErrorCode,
    LicenseManager,
)

DEFAULT_LICENSE = "LICENSE-KEY"
DEFAULT_TEMPLATE = "ReadPassportAndId"

def init_dynamsoft_router(license_key: str) -> CaptureVisionRouter:
    """Initialise the Dynamsoft license and create a CaptureVisionRouter."""
    code, message = LicenseManager.init_license(license_key)
    if code not in (EnumErrorCode.EC_OK, EnumErrorCode.EC_LICENSE_WARNING):
        raise RuntimeError(
            f"License initialization failed. code={code}, message={message}"
        )
    return CaptureVisionRouter()

Replace LICENSE-KEY with your own license before production use.

Step 2: Load Curated Ground-Truth MRZ Labels

The benchmark compares the SDK’s recognized MRZ text against ground-truth labels stored in a CSV file. The bundled labels.csv in this sample is curated: it contains 3,315 scoreable MRZ images and excludes 285 unscoreable frames.

By default, the script uses this bundled curated CSV:

BUNDLED_LABELS_CSV = Path(__file__).resolve().parent / "labels.csv"

if args.csv:
    csv_path = args.csv
elif BUNDLED_LABELS_CSV.exists():
    csv_path = BUNDLED_LABELS_CSV
else:
    csv_path = dataset_dir / "labels.csv"

The loader normalizes image paths by taking the last two path components, for example CA/CA05_01.jpg becomes ca/ca05_01.jpg. This keeps the CSV independent of the absolute dataset location.

def load_ground_truth(csv_path: Path) -> Dict[str, str]:
    """Load ground-truth MRZ labels from a CSV file."""
    mapping: Dict[str, str] = {}
    with csv_path.open("r", encoding="utf-8", newline="") as fp:
        reader = csv.reader(fp)
        for row in reader:
            if not row or len(row) < 2:
                continue
            image_path = row[0].strip()
            mrz_label = row[1].strip()
            if not image_path or not mrz_label:
                continue
            if image_path.lower().startswith("imagepath"):
                continue

            normalised = image_path.replace("\\", "/").strip("/").lower()
            parts = normalised.split("/")
            key = "/".join(parts[-2:]) if len(parts) >= 2 else normalised
            mapping[key] = mrz_label
    return mapping

Step 3: Decode a Single Image and Extract MRZ Text

The decode_once function calls router.capture_multi_pages() with an image file path and the ReadPassportAndId template name. The SDK returns one or more captured results. The benchmark extracts MRZ text from line1, line2, and line3 fields of the parsed result items.

def decode_once(
    router: CaptureVisionRouter,
    image_path: Path,
    template_name: str,
) -> Tuple[Optional[str], Optional[str], int, float]:
    """Decode a single image and return (mrz_text, error, page_number, elapsed_ms)."""
    start = time.perf_counter()
    result_array = router.capture_multi_pages(str(image_path), template_name)
    elapsed_ms = (time.perf_counter() - start) * 1000.0

    results = result_array.get_results() if result_array is not None else None
    if not results:
        return None, "No captured results.", 1, elapsed_ms

    for index, captured in enumerate(results, start=1):
        text = extract_mrz_from_captured_result(captured)
        if text:
            return text, None, _page_number(captured, index), elapsed_ms

    first = results[0]
    try:
        err_text = f"Capture error {first.get_error_code()}: {first.get_error_string()}"
    except Exception:
        err_text = "No parsable MRZ found in captured result."
    return None, err_text, _page_number(first, 1), elapsed_ms

The timing measurement uses time.perf_counter(), which provides high-resolution monotonic timestamps suitable for benchmarking. The elapsed time is calculated immediately after capture_multi_pages() returns, before any result extraction — this ensures the measurement reflects only the SDK’s recognition work, not the application’s result parsing overhead.

Step 4: Run the Benchmark Across All Images

The benchmark iterates over matched image records, decodes each image, computes a character-level match rate, and records whether the result is an exact match. When --runs is greater than 1, each image is decoded multiple times and the fastest successful timing is kept.

def benchmark(
    router: CaptureVisionRouter,
    records: Sequence[ImageRecord],
    runs: int,
    template_name: str,
    has_ground_truth: bool = True,
) -> List[ImageResult]:
    output: List[ImageResult] = []
    total = len(records)

    for idx, record in enumerate(records, start=1):
        best_text: Optional[str] = None
        best_err: Optional[str] = None
        best_page = 1
        best_ms: Optional[float] = None

        for _ in range(max(1, runs)):
            recognized, err, page_number, elapsed_ms = decode_once(
                router=router,
                image_path=record.path,
                template_name=template_name,
            )

            is_better = best_ms is None or elapsed_ms < best_ms
            prefer_success = recognized is not None and best_text is None
            if prefer_success or is_better:
                best_text = recognized
                best_err = err
                best_page = page_number
                best_ms = elapsed_ms

        score = match_rate(best_text, record.truth_mrz) if has_ground_truth else 0.0
        exact = score >= 0.99999 if has_ground_truth else False

        output.append(ImageResult(
            key=record.key,
            success=best_text is not None,
            error=best_err if best_text is None else None,
            recognized_mrz=best_text,
            truth_mrz=record.truth_mrz,
            match_rate=score,
            exact_match=exact,
            run_ms=best_ms,
            page_number=best_page,
        ))

Step 5: Generate JSON and HTML Reports

After all images are processed, the script computes aggregate metrics: exact accuracy, success rate, average match rate, and timing percentiles. It writes both a machine-readable JSON file and an HTML dashboard.

summary = build_summary(results, total_wall_ms)

report_obj = {
    "sdk": "Dynamsoft Capture Vision",
    "template": args.template,
    "dataset": str(dataset_dir.resolve()),
    "csv": str(csv_path.resolve()) if has_ground_truth else None,
    "image_count": summary.total_images,
    "summary": {
        "exact_accuracy": round(summary.exact_count / summary.total_images, 6),
        "success_rate": round(summary.success_count / summary.total_images, 6),
        "average_match_rate": round(summary.avg_match_rate, 6),
        "avg_ms": round(summary.avg_ms, 3),
        "p50_ms": round(summary.p50_ms, 3),
        "p95_ms": round(summary.p95_ms, 3),
        "p99_ms": round(summary.p99_ms, 3),
    },
    "results": [result_to_dict(r) for r in results],
}

Step 6: Run the Benchmark

Use a preprocessed MIDV-500 image folder with CA/, CS/, HA/, HS/, KA/, KS/, PA/, PS/, TA/, and TS/ subfolders:

python benchmark_dcv_mrz.py --dataset /path/to/midv-500-mrz --output benchmark_report_dcv_python_full

Run a quick smoke test:

python benchmark_dcv_mrz.py --dataset /path/to/midv-500-mrz --limit 5 --output benchmark_report_dcv_python

Use --csv only when you intentionally want to override the bundled curated labels:

python benchmark_dcv_mrz.py --dataset /path/to/midv-500-mrz --csv /path/to/labels.csv

Benchmark Results Analysis

Running the latest full benchmark on the curated 3,315-image MRZ subset produced the following results:

Metric Value
Total benchmark images 3,315
Exact MRZ matches 1,602 (48.33%)
Successful decodes 2,128 (64.19%)
Partial matches (success, not exact) 526 (15.87%)
Failed decodes (no MRZ text returned) 1,187 (35.81%)
Average match rate 58.09%
Average decode time 365.2 ms
P50 decode time 258.9 ms
P95 decode time 919.3 ms
P99 decode time 1,283.7 ms
Fastest decode 81.4 ms
Slowest decode 2,168.9 ms

The exact-match score is intentionally strict. A result that returns only the first MRZ line, omits one line, or returns a second line with a wrong checksum character counts as a miss even when most characters are correct. This is why the success rate is higher than the exact accuracy: 2,128 images returned MRZ text, but only 1,602 matched the normalized ground truth exactly. The 526 partial matches are useful for debugging because the per-image report preserves the recognized MRZ, ground truth, match rate, and decode time.

The timing distribution shows that most successful and failed attempts still complete quickly. In this run, 955 images (28.8%) completed in under 200 ms, and another 1,667 images (50.3%) completed between 200 and 500 ms. That means 79.1% of the curated benchmark finished in under 500 ms. Only 118 images (3.6%) exceeded 1,000 ms, with the slowest image taking 2,168.9 ms.

Performance varies strongly by capture condition. TA was the best subset, with 92.2% success rate and 83.3% exact accuracy. TS followed with 85.0% success and 67.5% exact accuracy. KA and CA also performed above the overall average. The hardest subsets were PA and PS, with 32.4% and 33.5% success rates, because they contain motion, angle, partial entry into frame, and blur. The audit removes frames where a complete MRZ is not visible, but it intentionally keeps difficult images when the MRZ is visible and scoreable.

The curated subset changes the benchmark denominator, not the benchmark rules. It excludes 285 frames where the label exists but the image does not contain a complete scoreable MRZ. It still keeps all normal visible MRZ images, including passport MRZ pages and the ID-card style MRZ in document type 18. On a small clean smoke test (ca/ca05_01.jpg through ca/ca05_05.jpg), the SDK produced 5 out of 5 exact matches at an average of 192.3 ms, which confirms that the lower full-dataset score is driven mainly by image quality and capture-condition difficulty rather than by the ground-truth format.

Common Issues & Edge Cases

  • Motion blur and extreme angles: MIDV-500 intentionally includes challenging capture conditions. Images with significant blur, glare, or perspective distortion may return no MRZ or only a partial MRZ.
  • Incomplete capture frames: Some PA and PS frames contain a label but no complete MRZ in the image. These are excluded from the curated benchmark and listed in assets/midv500_mrz_exclusions.csv.
  • Passport and ID MRZ formats: Most document groups are passports, while document type 18 contains a visible ID-card style MRZ. It remains in the benchmark because the goal is MRZ recognition, not passport-only recognition.
  • Downloaded data and labels.csv mismatch: Downloaded MIDV-500 archives use original folder names. The curated labels expect the preprocessed CA/ to TS/ directory layout. Use --no-ground-truth for speed-only runs unless your images match the curated label paths.
  • Exact match vs. success rate: success_rate counts any returned MRZ text. exact_accuracy requires the normalized MRZ to match ground truth exactly.

Conclusion

This benchmark demonstrates how to evaluate MRZ recognition with a reproducible Python workflow, audited ground truth, and per-image result records. The curated MIDV-500 MRZ subset keeps all visible, scoreable MRZ images, including passport and ID-card MRZ pages, while excluding incomplete or cropped frames. On the latest 3,315-image run, Dynamsoft Capture Vision achieved 48.33% exact accuracy, 64.19% success rate, and 365.2 ms average decode time.

Source Code

Get the complete sample project source code on GitHub