Validating Low-Confidence AWS Textract Extractions with Amazon Bedrock
AWS Textract can misread visually similar symbols like ≤ and < in documents with specialized notation, flagging them as low confidence without providing a correction. This post walks through a post-processing pattern that crops those uncertain regions using Textract's bounding box coordinates and sends them to a multimodal model on Amazon Bedrock for visual verification.
All code was generated using Amazon Kiro, and reviewed by a Human. For more information: Kiro
The Problem: When OCR Gets the Details Wrong
AWS Textract is a powerful document analysis service that handles the heavy lifting of extracting text, tables, and forms from scanned documents. For most use cases it works remarkably well. But when processing documents with specialized symbols — medical reports, engineering specs, financial statements — customers can hit a frustrating edge case: Textract misreads visually similar characters and reports low confidence scores without giving a clear path to fix them.
This exact problem can surface while processing a transthoracic echocardiogram report. These documents can contain dozens of clinical measurements with normal-range thresholds, many using the less-than-or-equal-to sign (≤). Textract correctly identified some of these symbols but misread others as plain less-than signs (<). In a clinical context, the difference between "MV E/e' Avg < 14.00" and "MV E/e' Avg ≤ 14.00" changes the diagnostic interpretation — a value of exactly 14.00 would be normal under one reading and abnormal under the other.
The challenge is compounded by the fact that ≤ and < look nearly identical in many fonts, especially at lower resolutions. The only visual difference is a thin horizontal line beneath the angle bracket. Textract's confidence scores flagged these extractions (typically in the 58-69% range), but confidence scores alone don't tell you what the correct text should be.
The Approach: Bedrock as a Visual Validator
Rather than manually reviewing every low-confidence extraction, a post-processing pipeline can send the questionable regions back through a multimodal large language model (Claude on Amazon Bedrock) for visual verification. The key insight is that Textract already provides everything needed to do this efficiently: the bounding box coordinates for every extracted word.
Here's the workflow:
- Parse the Textract response and filter for WORD blocks below a confidence threshold (in this case, 70%)
- Convert the source PDF to images at 300 DPI using pdf2image/poppler
- Crop each low-confidence region using the bounding box coordinates from Textract, with some padding for context
- Send the cropped image to Bedrock along with what Textract thought the text said, asking the model to visually verify
- Update the Textract response in place with any corrections, preserving the original text for audit purposes
The approach is targeted — only small image crops for the blocks that Textract flagged as uncertain get sent, not the entire document. This keeps Bedrock costs minimal while delivering high-accuracy corrections exactly where they're needed.
Results
On the 3-page echocardiogram report, Textract flagged 15 words with confidence below 70%. The Bedrock validation run confirmed two critical corrections:
\<14.00→≤14.00(MV E/e' Average normal range)\<2.0→≤2.0(Sinus of Valsalva Index normal range)
In both cases, Bedrock's explanation was clear: "The image shows a less-than-or-equal-to sign (≤), which consists of a less-than sign with a horizontal line underneath it. Textract incorrectly read this as a plain less-than sign (<), missing the underline that denotes 'or equal to'."
The model also caught some partial-text issues where Textract's word boundary detection clipped characters, but the ≤ vs < corrections were the clinically significant ones.
The ASCII/Unicode Gotcha
There's a subtle but important implementation detail that can trip up developers: character encoding in the output JSON.
When Python's
json.dump() writes a file, it defaults to ensure_ascii=True. This means any non-ASCII character gets escaped to its Unicode code point representation. So the symbol ≤ becomes \u2264, the symbol ² becomes \u00b2, and ≥ becomes \u2265.The original Textract response from AWS contains these characters as literal UTF-8 — the JSON shows
≤0.42, not \u22640.42. But when a script reads the JSON, makes corrections, and writes it back out, Python's default behavior converts every non-ASCII character to escape sequences. The file is still valid JSON (parsers handle both forms identically), but it breaks downstream tools that expect to grep or visually inspect the output, and it makes diffs against the original impossible to read.The fix is straightforward — pass
ensure_ascii=False to json.dump():1
2
with open(output_path, "w") as f:
json.dump(textract_response, f, indent=2, ensure_ascii=False)This preserves the literal UTF-8 characters in the output file, matching the format AWS originally provided. It's a one-line change, but if missed, the "corrected" JSON looks mangled compared to the original even though the data is semantically identical.
This is worth calling out because it affects any pipeline that round-trips JSON containing special characters — not just Textract workflows. Medical documents, scientific papers, financial reports with currency symbols, and internationalized text all contain non-ASCII characters that will get escaped without explicitly opting out.
When to Use This Pattern
This Bedrock validation approach works best when:
- The errors are visual in nature — distinguishing ≤ from <, 0 from O, 1 from l, rn from m
- Textract provides confidence scores indicating uncertainty (the bounding boxes are critical for targeted cropping)
- The volume of low-confidence blocks is small — sending 10-20 crops per document, not thousands
- Accuracy matters more than speed — each Bedrock call adds latency, so this is a batch/post-processing pattern rather than real-time
For high-volume document processing, the confidence threshold could be set lower (say 50%) to only catch the most egregious errors, or multiple crops could be batched into a single Bedrock call with a more complex prompt.
Full Script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
"""
Validate low-confidence Textract extractions using Bedrock (Claude) with image crops.
This script:
1. Loads the Textract response JSON
2. Finds all WORD blocks with confidence below 70%
3. Crops the relevant region from the PDF using bounding box coordinates
4. Sends each crop to Bedrock Claude for visual validation
5. Updates the analyzeDocResponseNEW.json with corrected text
6. Writes a change log with all modifications made
"""
import json
import base64
import datetime
from io import BytesIO
from pathlib import Path
import boto3
from pdf2image import convert_from_path
from PIL import Image
# --- Configuration ---
CONFIDENCE_THRESHOLD = 70.0
PDF_PATH = Path(__file__).parent / "YOURPDF.pdf"
TEXTRACT_RESPONSE_PATH = Path(__file__).parent / "analyzeDocResponse.json"
OUTPUT_PATH = Path(__file__).parent / "analyzeDocResponse.json" # overwrite in place
LOG_PATH = Path(__file__).parent / "validation_change_log.json"
BEDROCK_MODEL_ID = "us.anthropic.claude-opus-4-8"
BEDROCK_REGION = "us-west-2"
PADDING_PX = 30 # pixels of padding around the bounding box crop
def load_textract_response(path: Path) -\> dict:
"""Load the Textract JSON response."""
with open(path) as f:
return json.load(f)
def get_low_confidence_blocks(textract_response: dict, threshold: float) -\> list[dict]:
"""Filter WORD blocks below the confidence threshold."""
return [
block for block in textract_response["Blocks"]
if block["BlockType"] == "WORD"
and block.get("Confidence", 100) \< threshold
]
def convert_pdf_to_images(pdf_path: Path, dpi: int = 300) -\> list[Image.Image]:
"""Convert PDF pages to PIL images."""
print(f"Converting PDF to images at {dpi} DPI...")
return convert_from_path(str(pdf_path), dpi=dpi)
def crop_region(page_image: Image.Image, bounding_box: dict, padding: int = PADDING_PX) -\> Image.Image:
"""Crop a region from the page image using Textract bounding box coordinates."""
width, height = page_image.size
left = int(bounding_box["Left"] * width) - padding
top = int(bounding_box["Top"] * height) - padding
right = int((bounding_box["Left"] + bounding_box["Width"]) * width) + padding
bottom = int((bounding_box["Top"] + bounding_box["Height"]) * height) + padding
# Clamp to image bounds
left = max(0, left)
top = max(0, top)
right = min(width, right)
bottom = min(height, bottom)
return page_image.crop((left, top, right, bottom))
def image_to_base64(image: Image.Image) -\> str:
"""Convert a PIL image to a base64-encoded PNG string."""
buffer = BytesIO()
image.save(buffer, format="PNG")
return base64.standard_b64encode(buffer.getvalue()).decode("utf-8")
def validate_with_bedrock(bedrock_client, cropped_image_b64: str, textract_text: str) -\> dict:
"""Send cropped image + Textract text to Bedrock for validation."""
prompt = (
f'AWS Textract extracted the following text from this image region: "{textract_text}"\n\n'
f"Textract flagged this as low confidence. Please look at the image carefully and tell me:\n"
f"1. What does the text actually say in this image?\n"
f"2. Pay special attention to mathematical symbols: is it a less-than sign (\<), "
f"less-than-or-equal-to sign (≤), greater-than-or-equal-to sign (≥), or something else?\n"
f"3. Pay attention to superscripts (like ² in cm² or m²) and special characters.\n\n"
f"Respond ONLY with valid JSON in this exact format, no other text:\n"
f'{{"corrected_text": "...", "is_different": true/false, "explanation": "..."}}'
)
response = bedrock_client.converse(
modelId=BEDROCK_MODEL_ID,
messages=[
{
"role": "user",
"content": [
{
"image": {
"format": "png",
"source": {"bytes": base64.standard_b64decode(cropped_image_b64)}
}
},
{
"text": prompt
}
]
}
],
inferenceConfig={"maxTokens": 256}
)
response_text = response["output"]["message"]["content"][0]["text"]
# Parse the JSON response from Bedrock
try:
# Handle case where model wraps in markdown code block
if "```" in response_text:
response_text = response_text.split("```json")[-1].split("```")[0].strip()
if not response_text:
response_text = response_text.split("```")[-2].strip()
return json.loads(response_text)
except json.JSONDecodeError:
return {
"corrected_text": textract_text,
"is_different": False,
"explanation": f"Could not parse Bedrock response: {response_text}"
}
def main():
print("=" * 60)
print("Textract Low-Confidence Validation with Bedrock")
print("=" * 60)
# Load Textract response
print(f"\nLoading Textract response from: {TEXTRACT_RESPONSE_PATH}")
textract_response = load_textract_response(TEXTRACT_RESPONSE_PATH)
# Find low-confidence blocks
low_conf_blocks = get_low_confidence_blocks(textract_response, CONFIDENCE_THRESHOLD)
print(f"Found {len(low_conf_blocks)} WORD blocks with confidence \< {CONFIDENCE_THRESHOLD}%")
if not low_conf_blocks:
print("Nothing to validate. Exiting.")
return
# Convert PDF to images
pages = convert_pdf_to_images(PDF_PATH)
print(f"Converted {len(pages)} pages from PDF")
# Initialize Bedrock client
bedrock = boto3.client("bedrock-runtime", region_name=BEDROCK_REGION)
# Process each low-confidence block
change_log = []
changes_made = 0
print(f"\nValidating {len(low_conf_blocks)} blocks...\n")
print("-" * 60)
for i, block in enumerate(low_conf_blocks, 1):
block_id = block["Id"]
original_text = block["Text"]
confidence = block["Confidence"]
page_num = block.get("Page", 1)
bbox = block["Geometry"]["BoundingBox"]
print(f"[{i}/{len(low_conf_blocks)}] Page {page_num}: \"{original_text}\" "
f"(confidence: {confidence:.1f}%)")
# Crop the region from the PDF page
page_image = pages[page_num - 1]
cropped = crop_region(page_image, bbox)
cropped_b64 = image_to_base64(cropped)
# Send to Bedrock for validation
correction = validate_with_bedrock(bedrock, cropped_b64, original_text)
log_entry = {
"block_id": block_id,
"page": page_num,
"original_text": original_text,
"original_confidence": round(confidence, 2),
"corrected_text": correction["corrected_text"],
"is_different": correction["is_different"],
"explanation": correction["explanation"],
"bounding_box": bbox
}
if correction["is_different"]:
# Update the block in the Textract response
block["OriginalText"] = original_text
block["OriginalConfidence"] = confidence
block["Text"] = correction["corrected_text"]
block["BedrockCorrected"] = True
block["CorrectionExplanation"] = correction["explanation"]
changes_made += 1
print(f" ✓ CORRECTED: \"{original_text}\" → \"{correction['corrected_text']}\"")
print(f" Reason: {correction['explanation']}")
else:
print(f" — No change needed: {correction['explanation']}")
change_log.append(log_entry)
print()
print("-" * 60)
print(f"\nSummary: {changes_made} corrections made out of {len(low_conf_blocks)} blocks validated")
# Save updated Textract response
print(f"\nSaving updated response to: {OUTPUT_PATH}")
with open(OUTPUT_PATH, "w") as f:
json.dump(textract_response, f, indent=2, ensure_ascii=False)
# Save change log
log_output = {
"timestamp": datetime.datetime.now().isoformat(),
"pdf_file": str(PDF_PATH.name),
"confidence_threshold": CONFIDENCE_THRESHOLD,
"total_blocks_validated": len(low_conf_blocks),
"total_corrections_made": changes_made,
"bedrock_model": BEDROCK_MODEL_ID,
"changes": change_log
}
print(f"Saving change log to: {LOG_PATH}")
with open(LOG_PATH, "w") as f:
json.dump(log_output, f, indent=2, ensure_ascii=False)
print("\nDone!")
if __name__ == "__main__":
main()Inputs:
| File | Description |
|---|---|
YOURPDF.pdf | The source PDF document that was originally processed by Textract |
analyzeDocResponse.json | A copy of the Textract AnalyzeDocument response JSON (this file gets updated in place) |
Outputs:
| File | Description |
|---|---|
analyzeDocResponse.json | The corrected Textract response with updated Text fields and added BedrockCorrected, OriginalText, OriginalConfidence, and CorrectionExplanation fields on modified blocks. This can be adjusted to be a different file to retain the original Textract output. |
validation_change_log.json | Audit log listing every block validated, whether it changed, the original and corrected text, confidence scores, and Bedrock's explanation |
Python Dependencies:
| Package | Purpose |
|---|---|
boto3 | AWS SDK for calling Bedrock |
Pillow | Image manipulation (cropping bounding box regions) |
pdf2image | Converting PDF pages to images |
System Dependencies:
| Package | Purpose |
|---|---|
poppler | PDF rendering engine used by pdf2image (brew install poppler on macOS) |
Key Takeaways
- Textract's confidence scores are the signal — don't ignore them, but don't just log them either. Use them as a trigger for validation.
- Bounding box coordinates enable surgical validation. There's no need to reprocess entire pages.
- Multimodal LLMs excel at exactly the visual disambiguation task that trips up traditional OCR.
- Watch character encoding when round-tripping JSON. Python's
ensure_ascii=Truedefault will silently transform the output format. - Always preserve the original extraction alongside corrections for auditability.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article