扫描件数字化最容易被低估的地方,不是 OCR 本身,而是“页面上这些文字到底对应哪张脸、哪个表格、哪个区域”。这篇方案的核心变化是把任务拆给两个模型:Amazon Nova 2 Lite 先做原生多模态抽取,在一次调用里识别照片、可见姓名、坐标和页面级元数据;Claude Sonnet 4.6 再根据版面做空间推理,把名字和人脸配对。这样既控制成本,也把复杂推理留给更适合的模型。
为什么不是一个模型从头干到尾
如果把扫描年鉴页直接丢给一个强推理模型,让它完成检测、OCR、坐标抽取、关系判断,工程上当然省事,但规模化时成本会很快变硬约束。
两模型流水线的思路更像传统文档处理系统,只是把几个脆弱的规则模块换成了多模态模型:
- Nova 2 Lite 负责“看见了什么”:照片框、姓名文本、坐标、页码、版式等结构化信息。
- Claude Sonnet 4.6 负责“这些东西如何关联”:根据上下左右、网格、标题、姓名靠近关系,把姓名匹配到头像。
- 中间结果用 JSON 传递,便于审计、重试、缓存和人工校验。
关键点在于,不要让第二个模型重新阅读整张扫描图。它需要的是 Nova 2 Lite 已经抽出来的结构化布局,而不是原始像素。这样 Claude 的输入更短、更稳定,推理成本也更可控。
中间 JSON 是整条流水线的合同
这类系统最怕“模型说得像对,但无法落库”。建议把 Nova 的输出约束成一个明确 schema,例如:
{
"page": {
"page_number": 12,
"width": 2480,
"height": 3508,
"document_type": "yearbook_page"
},
"photos": [
{"id": "photo_1", "bbox": [120, 320, 420, 720]},
{"id": "photo_2", "bbox": [520, 320, 820, 720]}
],
"names": [
{"id": "name_1", "text": "Alice Chen", "bbox": [130, 735, 410, 780]},
{"id": "name_2", "text": "Marcus Lee", "bbox": [535, 735, 805, 780]}
]
}
坐标最好统一成像素坐标,并明确 bbox 顺序,比如 [x1, y1, x2, y2]。如果页面在进入模型前做了缩放、裁剪或旋转,也要把这些转换记录在 metadata 里,否则后续人工复核和可视化会对不上。
Claude 这一步不需要“猜页面内容”,而是接收结构化对象并输出匹配结果:
{
"matches": [
{
"photo_id": "photo_1",
"name_id": "name_1",
"name": "Alice Chen",
"confidence": 0.92,
"reason": "Name is directly below the photo in the same column."
}
],
"unmatched_photos": [],
"unmatched_names": []
}
这个合同越清楚,系统越容易做批处理、质量抽样和回归测试。
可以这样实践:用 Bedrock 串起两次调用
下面示例展示一个最小 Python 流程:读取一张扫描页图片,先调用 Nova 2 Lite 抽取布局,再把布局 JSON 发给 Claude Sonnet 4.6 做空间匹配。
运行前需要修改三处:AWS_REGION、两个 modelId,以及本地图片路径。不同账号和区域可用的 Bedrock model ID 可能不同,请以你控制台里启用的模型为准。
python -m venv .venv
source .venv/bin/activate
pip install boto3
export AWS_REGION=us-east-1
python process_yearbook_page.py ./page-001.jpg
process_yearbook_page.py:
import base64
import json
import os
import sys
from pathlib import Path
import boto3
REGION = os.getenv("AWS_REGION", "us-east-1")
# Replace these with the exact model IDs enabled in your Bedrock account/region.
NOVA_MODEL_ID = os.getenv("NOVA_MODEL_ID", "amazon.nova-2-lite-v1:0")
CLAUDE_MODEL_ID = os.getenv("CLAUDE_MODEL_ID", "anthropic.claude-sonnet-4-6-v1:0")
bedrock = boto3.client("bedrock-runtime", region_name=REGION)
def image_format(path: Path) -> str:
suffix = path.suffix.lower()
if suffix in {".jpg", ".jpeg"}:
return "jpeg"
if suffix == ".png":
return "png"
raise ValueError("Use a .jpg, .jpeg, or .png scan for this example")
def extract_layout_with_nova(image_path: Path) -> dict:
prompt = """
You are digitizing a scanned yearbook page.
Return strict JSON only with this shape:
{
"page": {"width": number, "height": number, "document_type": string},
"photos": [{"id": string, "bbox": [x1, y1, x2, y2]}],
"names": [{"id": string, "text": string, "bbox": [x1, y1, x2, y2]}],
"notes": [string]
}
Detect portrait photos and visible person names. Use pixel coordinates if available.
""".strip()
image_bytes = image_path.read_bytes()
response = bedrock.converse(
modelId=NOVA_MODEL_ID,
messages=[
{
"role": "user",
"content": [
{"text": prompt},
{
"image": {
"format": image_format(image_path),
"source": {"bytes": image_bytes},
}
},
],
}
],
inferenceConfig={"temperature": 0, "maxTokens": 3000},
)
text = response["output"]["message"]["content"][0]["text"]
return json.loads(text)
def match_names_with_claude(layout: dict) -> dict:
prompt = f"""
You match names to portrait photos using page layout.
Input JSON contains photo boxes and name boxes in [x1, y1, x2, y2] order.
Return strict JSON only:
{{
"matches": [
{{"photo_id": string, "name_id": string, "name": string, "confidence": number, "reason": string}}
],
"unmatched_photos": [string],
"unmatched_names": [string]
}}
Prefer direct vertical or grid-column relationships. Mark ambiguous cases with lower confidence.
Layout JSON:
{json.dumps(layout, ensure_ascii=False)}
""".strip()
response = bedrock.converse(
modelId=CLAUDE_MODEL_ID,
messages=[{"role": "user", "content": [{"text": prompt}]}],
inferenceConfig={"temperature": 0, "maxTokens": 3000},
)
text = response["output"]["message"]["content"][0]["text"]
return json.loads(text)
def main() -> None:
if len(sys.argv) != 2:
raise SystemExit("Usage: python process_yearbook_page.py ./page-001.jpg")
image_path = Path(sys.argv[1])
layout = extract_layout_with_nova(image_path)
matches = match_names_with_claude(layout)
print(json.dumps({"layout": layout, "matches": matches}, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
这个示例刻意把两步分开。生产环境里你应该把 layout 存下来,例如写入 S3 或数据库。这样 Claude 匹配提示词调整后,可以只重跑第二步,不必重新做图像抽取。
批处理时要把成本控制点显式化
这类文档处理系统一旦进入“几万页、几十万页”,成本和质量问题会同时出现。建议把以下控制点做成配置,而不是散落在代码里:
pipeline:
input_bucket: scanned-yearbook-pages
output_bucket: digitized-yearbook-results
nova_extraction:
model_id: amazon.nova-2-lite-v1:0
temperature: 0
max_tokens: 3000
cache_layout_json: true
claude_matching:
model_id: anthropic.claude-sonnet-4-6-v1:0
temperature: 0
max_tokens: 3000
only_send_layout_json: true
quality_gate:
min_confidence: 0.80
send_ambiguous_to_review: true
sample_rate_for_manual_audit: 0.02
配置里的 only_send_layout_json 是一个很重要的工程约束:第二步只处理结构化布局,避免把大图重复发给强推理模型。min_confidence 则决定哪些结果进入人工复核队列。年鉴页、证件页、档案页这类材料经常有老照片、歪斜扫描、遮挡、重名和跨栏排版,完全自动化并不总是值得追求。
落地建议:从可观测的小闭环开始
这套 Nova 2 Lite + Claude 的组合适合“先抽取、再推理”的文档场景,尤其是页面里存在空间关系的问题:头像和姓名、表格单元和字段、图片说明和图片对象。
上线前可以按这个清单推进:
- 固定中间 JSON schema,并对模型输出做 JSON 解析失败重试。
- 保存原图、Nova 输出、Claude 输出和最终人工修正结果。
- 对低置信度、重叠 bbox、未匹配姓名建立复核队列。
- 用一小批标注页做回归集,每次改 prompt 或 modelId 都跑一遍。
- 不要只看平均准确率,重点看“错误是否可发现、可修正、可回放”。
边界也要说清楚:如果扫描质量极差、姓名离头像很远、页面设计高度不规则,空间推理仍然会出错。把便宜模型用于大规模视觉抽取,把强模型用于明确的布局推理,是一个务实的成本优化方向,但它不是免人工校验的魔法按钮。