Textract vs Bedrock Data Automation vs Vision LLMs on Invoice Photos: 140 Images and a 6-Page Invoice, Real Calls

2026-11-09来源 dev.to PythonEnglish
Textract vs Bedrock Data Automation vs Vision LLMs on Invoice Photos: 140 Images and a 6-Page Invoice, Real Calls

Invoices from the field rarely arrive as clean PDFs. They arrive as phone photos: the invoice lying on a stack of other papers, a coffee receipt on top of it, or a sheet held against a car dashboard. I wanted to know which extraction setup holds up in those conditions, what goes wrong, and what a team can do about it in a few lines of code.

This post is a small comparison. Every number comes from real API calls, and every raw response was saved so the scoring could be re-run without calling AWS again. The code snippets below are the code I used, trimmed to the parts that matter. I ran each one against the saved responses to check that it reproduces the numbers in this post. The last section explains how the benchmark is built, so you can rebuild it for your own documents.

TL;DR

  • Out of the box, no tool got the invoice total right on more than 83% of the 40 photos. For Textract and BDA, the usual failure was not reading the text. It was picking the wrong amount: "Amount paid", "Amount due" or a previous balance.
  • Small configuration changes moved totals as much as the choice of tool. A label filter plus a confidence-gated crop took Textract from 42.5% to 85% on totals. A custom BDA blueprint took BDA from 67.5% to 100%.
  • A subtotal + tax = total check catches most OCR mistakes but few vision-model mistakes. Nova 2 Lite passed that check on 35 of 40 photos, and 20 of those had a wrong field, mostly the date or the invoice number.
  • Using Textract for the values and Nova 2 Lite as a cross-check auto-approved 22 of 40 invoices with zero silent errors, for about $11 per 1,000.
  • On photographed pages of a 6-page invoice, checking each page against its own carried-forward amount flagged every page with a real error and missed none.
  • On 95 real receipts with a labelled total, Textract read it right 97.9% of the time. BDA and Nova often read "31.000" (Indonesian rupiah) as 31.

What was tested

Data. Three sets.

The first set is 5 synthetic invoices in 2 layouts, each photographed in 8 scenes: flat, angled, blur and glare, folded, low light, a stack of papers, a receipt lying on the invoice, and a dark dashboard-like background with a fuel receipt and a parking stub. That makes 40 images. The invoices are generated, so the answer key is exact, and every background paper has known values too. They contain traps that come up in accounts payable work: "Amount paid" and "Balance due" next to "Total", a "Previous balance" line, separate "Invoice to" and "Deliver to" blocks, and DD/MM/YYYY dates.
he 40 synthetic photos: 5 invoices in 8 scenes

The second set is the CORD v2 test split: 100 real phone photos of receipts from Indonesia (CC BY 4.0). 95 of them have a labelled total.

The third set is one synthetic 6-page invoice with 149 line items. Each page ends with "Page subtotal (carried forward)" and the next page starts with "Brought forward". The header is only on page 1 and the totals are only on page 6. It was tested twice: as a clean PDF, and with each page photographed in a different scene and combined into one PDF, the way a phone scanner app would.

Tools. All run in us-west-2.

  • Amazon Textract AnalyzeExpense (synchronous for single images, StartExpenseAnalysis for the 6-page PDF)
  • Amazon Bedrock Data Automation (BDA) with the AWS public Invoice and Receipt blueprints, and with a custom blueprint
  • Claude Sonnet 4.5 and Amazon Nova 2 Lite on Amazon Bedrock, through the us. inference profiles, with one JSON prompt that asks for each value plus a 0 to 1000 bounding box (the prompt follows the pattern in aws-samples/sample-document-information-localization), temperature 0
  • Pre-processing before Textract: Apple Vision document detection with perspective correction, and PaddleOCR's DocPreprocessor (orientation plus UVDoc unwarping)
  • A spatial check after Textract: textract-field-memory, an open-source library I wrote that learns where fields sit on a known layout (earlier post)

Two modes for Textract and BDA. Out of the box means defaults and the highest-confidence answer. Configured means the change a team would make after reading the output once. The vision models ran with one prompt and no tuning. The configured changes are shown as code below, so you can judge whether they are fair.

Result 1: accuracy on 40 photos

Brackets are 95% intervals. With 40 images they are wide, and that is the honest size of this study.

Setup Mode Invoice total right All 6 key fields right Median latency Cost / 1,000
Textract AnalyzeExpense default 42.5% (29 to 58) 42.5% 3.7 s $10.00
BDA, public Invoice blueprint default 67.5% (52 to 80) 65.0% 25.9 s $40.00
Claude Sonnet 4.5 default 70.0% (55 to 82) 50.0% 6.8 s $10.70
Amazon Nova 2 Lite default 82.5% (68 to 91) 37.5% 3.7 s $1.07
Textract + label filter + confident crop configured 85.0% (71 to 93) 82.5% 4.0 s $10.00
BDA, custom blueprint configured 100.0% (91 to 100) 67.5% 25.2 s $40.00

The six key fields are vendor, invoice number, invoice date, subtotal, tax and total. BDA latency includes the S3 upload and polling for the asynchronous job.

Textract picks the wrong amount

Textract read the text well. The problem was which amount it labelled TOTAL. On Layout A it took the "Amount paid" line, which is printed as a negative number such as -$100.00, on 7 of 24 photos. On Layout B it was worse: 14 of 16 photos returned the amount due or the previous balance.
Textract returns the Amount paid line as the total

Every candidate in the AnalyzeExpense response carries the label printed next to it, so the fix is a few lines:

import re FIELDS = {"VENDOR_NAME": "vendor_name", "INVOICE_RECEIPT_ID": "invoice_number", "INVOICE_RECEIPT_DATE": "invoice_date", "DUE_DATE": "due_date", "RECEIVER_NAME": "customer_name", "SUBTOTAL": "subtotal", "TAX": "tax", "TOTAL": "total"} NOT_THE_TOTAL = re.compile(r"paid|balance|previous|due", re.I) SKIP_LABEL = {"TOTAL": NOT_THE_TOTAL, "SUBTOTAL": NOT_THE_TOTAL, "TAX": NOT_THE_TOTAL, "RECEIVER_NAME": re.compile(r"deliver|ship", re.I)} MONEY = {"SUBTOTAL", "TAX", "TOTAL"} def pick_fields(response, configured=True): """Highest-confidence candidate per field, skipping amounts that are not the invoice total.""" best = {} for doc in response["ExpenseDocuments"]: for f in doc["SummaryFields"]: kind = f["Type"]["Text"] value = (f.get("ValueDetection") or {}).get("Text", "") label = (f.get("LabelDetection") or {}).get("Text", "") if kind not in FIELDS or not value: continue if configured and kind in MONEY and value.strip().startswith("-"): continue # "Amount paid" is printed as a negative number  if configured and kind in SKIP_LABEL and SKIP_LABEL[kind].search(label): continue rank = (f["Type"].get("Confidence", 0), f["ValueDetection"].get("Confidence", 0)) if kind not in best or rank > best[kind][0]: best[kind] = (rank, value) return {FIELDS[k]: v for k, (_, v) in best.items()} 

On real receipts the same filter cost 3.2 points (97.9% to 94.7%), so it is a per-document-type choice, not a free win.

BDA's public blueprint defines TOTAL as the amount due

The public Invoice blueprint defines TOTAL as "total amount due including subtotal and tax". On the two invoices that print a previous balance, it returned the amount due on 13 of 16 photos.
BDA public blueprint returns the amount due as TOTAL
A custom blueprint that asks for the two amounts separately got every total right. This is the full schema (9 fields):

import json import boto3 S = lambda t, i: {"type": t, "inferenceType": "explicit", "instruction": i} SCHEMA = { "$schema": "http://json-schema.org/draft-07/schema#", "description": "A single invoice photographed on a desk or dashboard. " "Other papers may be visible; use only the invoice.", "class": "Invoice", "type": "object", "properties": { "VENDOR_NAME": S("string", "Name of the company that issued the invoice, from the invoice header"), "INVOICE_NUMBER": S("string", "The invoice number exactly as printed"), "INVOICE_DATE": S("string", "Invoice issue date as YYYY-MM-DD. If the invoice says dates " "are DD/MM/YYYY, convert accordingly"), "DUE_DATE": S("string", "Payment due date as YYYY-MM-DD, same date-format rule as INVOICE_DATE"), "CUSTOMER_NAME": S("string", "Name in the Bill to or Invoice to block. Not the Deliver to or " "Ship to block"), "SUBTOTAL": S("number", "Subtotal or net amount before tax"), "TAX": S("number", "Total tax or VAT amount on this invoice"), "INVOICE_TOTAL": S("number", "Total of this invoice: subtotal plus tax. Not the balance due, " "amount due, amount paid or previous balance"), "AMOUNT_DUE": S("number", "Amount due or balance due, if printed separately from the " "invoice total"), }, } bda = boto3.client("bedrock-data-automation", region_name="us-west-2") bda.create_blueprint(blueprintName="invoice-benchmark-total-vs-due", type="DOCUMENT", blueprintStage="LIVE", schema=json.dumps(SCHEMA)) 

One date problem remained. Both Layout B invoices state "Dates are shown as DD/MM/YYYY". The custom blueprint converted 07/10/2026 correctly on all 8 photos of one invoice, and read 11/08/2026 as November 8 on all 8 photos of the other. Instructions guide the model; they do not guarantee the result.

Cropping helps on clutter, fails on the dashboard

Apple Vision's document detection helped on stacks of paper (Textract fields went from 60% to 87.5% right on that scene) and failed on the dashboard scene, where it detected the wrong region with a confidence of about 0.41.
Apple Vision detects the wrong region on the dashboard scene
Using the crop only when the detector is confident kept the gain and removed the loss:

CROP_MIN_CONFIDENCE = 0.8 def image_to_send(original, cropped, crop_confidence): return cropped if crop_confidence >= CROP_MIN_CONFIDENCE else original 

In this set every dashboard crop scored about 0.41 and every other crop scored 0.855 or more, so the threshold was not tested on borderline cases.

PaddleOCR's unwarping helped Textract on the cluttered scenes (stack 60% to 70%, receipt on top 72.5% to 82.5%, dashboard 40% to 50% of fields) and made no difference on angled photos. Its orientation step turned 5 upright pages by 90 degrees, and totals stayed at 42.5%.

textract-field-memory did not help on uncropped photos: no learned template matched any of them. My reading is that the page sits in a different spot in every photo, but I did not test that directly. After cropping, it flagged 46 of 63 wrong fields for review, and also flagged 43 of 257 correct ones. That makes it a review signal, not an extractor.

Result 2: what this means in production

Accuracy does not tell you how much work is left. Two numbers do: how many invoices can be approved without a person, and how many errors slip through.

Rule Auto-approved Silent errors Cost / 1,000
Nova 2 Lite, validation only 35 of 40 20 $1.07
Claude Sonnet 4.5, validation only 34 of 40 14 $10.70
BDA custom blueprint, validation only 35 of 40 8 $40.00
Textract configured, validation only 34 of 40 1 $10.00
Textract configured values, Nova 2 Lite as cross-check 22 of 40 0 $11.07
Textract configured values, BDA custom as cross-check 30 of 40 1 $50.00

A silent error is an approved invoice with any of the six key fields wrong. Validation and the cross-check look like this:

from datetime import datetime def money(text): """'$1,141.60' -> 1141.6, '-$100.00' -> -100.0, 412.68 -> 412.68.""" if text is None: return None if isinstance(text, (int, float)): return round(float(text), 2) m = re.search(r"(-?)[$£€]?(\d+(?:\.\d+)?)", str(text).replace(",", "").replace(" ", "")) return round(float(m.group(1) + m.group(2)), 2) if m else None def validates(f): """Key fields present and subtotal + tax = total, to the cent.""" s, t, total = money(f.get("subtotal")), money(f.get("tax")), money(f.get("total")) if None in (s, t, total): return False if any(not str(f.get(k) or "").strip() for k in ("vendor_name", "invoice_number", "invoice_date")): return False return abs(s + t - total) <= 0.011 def iso_date(value): """Only unambiguous formats. 07/10/2026 could be July or October, so it returns None.""" for fmt in ("%Y-%m-%d", "%b %d, %Y"): try: return datetime.strptime(str(value or "").strip(), fmt).date().isoformat() except ValueError: pass return None def same(a, b, field): if field == "invoice_date": da, db = iso_date(a), iso_date(b) return True if None in (da, db) else da == db if field in ("subtotal", "tax", "total"): return money(a) is not None and money(a) == money(b) norm = lambda x: re.sub(r"[^a-z0-9]", "", str(x or "").lower()) return bool(norm(a)) and norm(a) == norm(b) def auto_approve(values, cross_check): """Approve with the first tool's values only when both tools pass validation and agree.""" return (validates(values) and validates(cross_check) and all(same(values.get(k), cross_check.get(k), k) for k in ("invoice_number", "invoice_date", "subtotal", "tax", "total"))) 

The arithmetic check works well for Textract: none of the 23 Textract photos with a wrong amount passed it. It works poorly for the vision models, for two reasons. Most of their silent errors are in fields the check never looks at: of Nova's 20, the date was wrong on 15 and the invoice number on 7. Some of their money errors also add up. Sonnet read 381.23 + 31.45 = 412.68 on a blurred photo as 391.23 + 31.40 = 422.63, and 6 of the 12 Sonnet photos with a wrong amount passed the check this way.
Sonnet misreads digits and the wrong numbers still add up
Agreement routing works, and the order matters. With Textract supplying the values and Nova checking them, 22 invoices were approved and none were wrong. Swapping the roles (Nova values, Textract check) approved the same 22 with 9 silent errors, all of them dates. Textract returns the date as printed, so a DD/MM/YYYY date cannot be compared safely, and Nova's conversion of those dates was its weak spot.

All 8 of BDA custom's silent errors are the 11/08/2026 date from Result 1.

Result 3: real receipts

95 of the 100 CORD receipts have a labelled total.

Setup Total right Read 31.000 as 31
Textract AnalyzeExpense 97.9% (93 to 99) 0
Claude Sonnet 4.5 91.6% (84 to 96) 1
Amazon Nova 2 Lite 83.2% (74 to 89) 12
BDA, public Receipt blueprint 76.8% (67 to 84) 20

If your documents use a dot as the thousands separator, test that case first. It accounts for 20 of BDA's 22 misses and 12 of Nova's 16.
BDA reads 31.000 rupiah as 31

Result 4: a 6-page invoice with 149 line items

Long invoices add problems that single pages do not have: the header and totals sit on different pages, every page has a running subtotal that looks like a total, and a model has to return about 150 rows without dropping or repeating one.

Textract and BDA read the whole PDF natively, so they need no chunking. For the vision models I tested two ways. The first sends all 6 pages in one call. The second is structure-aware chunking: page 1 is read first, then pages 2 to 6 in parallel, each told the vendor, invoice number and date that page 1 returned. Each page is then checked against its own printed running totals, and each page's brought-forward amount must equal the previous page's carried-forward amount:

from decimal import Decimal def cents(x): try: return Decimal(str(x)).quantize(Decimal("0.01")) except Exception: return None def pages_to_review(pages, subtotal): """pages: the model's JSON for each page, in order, with brought_forward, carried_forward and line_items. subtotal: from the totals block on the last page. Returns the page numbers to review.""" flagged, prev = [], None for n, p in enumerate(pages, 1): if not p: flagged.append(n) prev = None continue lines = sum((cents(li.get("amount")) or Decimal(0)) for li in p.get("line_items") or []) start = cents(p.get("brought_forward")) or Decimal(0) end = cents(p.get("carried_forward")) if end is None and n == len(pages): end <...>

原文:https://www.a8q9.cn/en/article/textract-vs-bedrock-data-automation-vs-vision-llms-on-invoice-photos-140-images--2773c1.html

相关文章