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. 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. 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. 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. 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)
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to