
東京リージョンで日本語対応した Textract を試してみた
はじめに
2026年11月2日に、Amazon Textract の日本語を含むサポート言語の追加と、東京リージョンでの提供開始が発表されました。
今回は日本語サポートを試すため、テキストを埋め込んだ日本語の PDF を東京リージョンで処理した結果を紹介します。あわせて、同じ記事のページ画像を Textract と Bedrock の Sonnet 5.5 で文字起こしし、結果と費用を比べました。
アップデートの内容
東京リージョンで使える API
発表によると、東京とチューリッヒで使える API は次の6つです。
- DetectDocumentText と AnalyzeDocument
- StartDocumentTextDetection と GetDocumentTextDetection
- StartDocumentAnalysis と GetDocumentAnalysis
AnalyzeExpense、AnalyzeID、AnalyzeLending と Custom Queries(adapters)は、この2リージョンでは使えません。
日本語の対応範囲
発表では、更新後のモデルは日本語を含む40言語超に対応し、対象は DetectDocumentText と AnalyzeDocument です。API のリクエストとレスポンスの形式は変更不要で、Textract が使える全リージョンで提供されます。多言語の表抽出は、結合セルのある表、傾いた表や回転した表、部分的に欠けた表や壊れた表で、堅牢になったとされています。
Developer Guide では、API ごとの対応言語は次のとおりです。
- 全言語に対応
- StartDocumentTextDetection と GetDocumentTextDetection
- AnalyzeDocument と StartDocumentAnalysis の Table・Signatures・Layout
- 英語、スペイン語、イタリア語、ポルトガル語、フランス語、ドイツ語に対応
- Forms を指定する AnalyzeDocument と StartDocumentAnalysis
- GetDocumentAnalysis
Forms の対応言語に日本語は明記されていません。Developer Guide によると、手書き文字認識は英語のみです。
検証の概要
入力は2種類で、いずれも東京リージョン(ap-northeast-1)で処理しました。検証ごとの入力、使った API、確かめたことは次のとおりです。
| 入力 | 使った API | 確かめたこと | |
|---|---|---|---|
| 検証1 | PDF(12ページ、埋め込みテキストあり) | Textract の非同期 API(テキスト検出、Tables、Layout) | 日本語の文字、表、見出しの抽出 |
| 検証2 | ページ画像(JPEG、6枚に分割) | Textract の同期 API(テキスト検出)と、Bedrock の Sonnet 5.5 | 文字起こしの正確さと費用の比較 |
素材
PDF は、次の DevelopersIO の記事を Chrome ブラウザで PDF にした A4・12ページ・約2.4MB のファイルで、埋め込みテキストを持ちます。
各ページに印刷日時のヘッダーと URL・ページ番号のフッターがあり、比較の正解データにはこれらを除いた埋め込みテキストを使いました。著者欄、リンクカード、関連記事は比較に含めています。
同じ記事のページ画像(JPEG)も用意しました。テキスト層を持たない画像として、Textract と Bedrock の Sonnet 5.5 に渡します。作り方と結果は「検証2」に書いています。
検証1: PDF(Textract)
API の呼び出し
同期 API が受け付ける PDF は1ページまでです。非同期 API は3,000ページ・500MB まで受け付けるので、12ページの PDF は非同期 API で処理しました。PDF は東京リージョンの S3 バケットに置き、次の2つのジョブを開始しました。
aws textract start-document-text-detection --region ap-northeast-1 --document-location 'S3Object={Bucket=<バケット名>,Name=devio-lwf.pdf}'
aws textract start-document-analysis --region ap-northeast-1 --feature-types TABLES LAYOUT --document-location 'S3Object={Bucket=<バケット名>,Name=devio-lwf.pdf}'
ジョブの状態は6秒ごとに確認し、開始から26〜27秒後の確認で、2つとも SUCCEEDED になっていました。
結果は、開始コマンドが返す JobId を指定して取得します。テキスト検出は get-document-text-detection、Tables・Layout は get-document-analysis です。1回の取得で返るのは1,000ブロックまでで、2回目以降はレスポンスの NextToken を --next-token に渡します。2回目以降の取得は次のとおりです。
aws textract get-document-text-detection --region ap-northeast-1 --job-id <JobId> --next-token <NextToken>
今回の PDF では、初回の取得で返ったのは、テキスト検出が1〜7ページ、Tables と Layout が1〜6ページの分でした。残りは NextToken を辿って、12ページ目まで取得しました。
テキスト検出
7ページ目の LINE(36行)を次のコマンドで出力しました。そのうち3行を示します。
jq -r '.Blocks[] | select(.BlockType=="LINE" and .Page==7) | [.Confidence, .Text] | @tsv' <取得結果のJSON>
99.33175659179688 過去24時間のピーク時の vCPU 消費数は、 CloudWatch の EndpointActiveEnvironments の1
98.97364044189453 分ごとの最大値に、 1環境あたりの vCPU 数 (2) をかけて算出しました。 東京リージョンの値
99.96810913085938 には、 dev と stg の環境も含まれます。
LINE は見た目の行ごとに返るため、文の途中で分かれます。
元の本文との比較は、PDF の埋め込みテキスト(印刷のヘッダー・フッターを除く)と LINE の出力で、文字ごとの出現数を比べました。NFKC で正規化して空白を除き、並び順は見ていません。LINE の出力には、図やスクリーンショット内の文字も含まれます。
5,785文字のうち5,720文字(98.88%)が出力に含まれました。含まれなかった65文字は、表の中が53文字、文章中が10文字、関連記事欄が2文字です。この PDF の矢印57個は、すべて8〜9ページの表の中にありました。内訳は次のとおりです。
| 元の本文 | Textract の出力 | 件数 | 出力の行(抜粋) |
|---|---|---|---|
| → | 57個のうち5個だけ出力に含まれる | 52 | — |
| 〜 | ~ に置換 | 5 | 約0.4~1.2 |
| へ(ひらがな) | ヘ(カタカナ)に置換 | 3 | リージョンヘリクエスト |
| 弧 | 孤 に置換 | 1 | 括孤内 |
| ハイフン(表の中)、@(関連記事欄)、⻑(CJK 部首補助の文字) | ハイフンと @ は出力にない。⻑ は通常の「長」に置換 | 4(ハイフン1、@ 2、⻑ 1) | — |
| AI | Al | 1(65文字には含まれない) | Al 検索ボット |
LINE の信頼度は、日本語(ひらがな・カタカナ・漢字)を含む193行で、平均98.52、最低81.41でした。90未満は5行です。全654行のうち、信頼度80未満の24行は、すべて日本語を含まない行(URL、記号、英数字の断片)でした。
取り違えのあった行の信頼度は、「孤」の行が81.41、「Al」の行が92.7、「ヘ」の3行が98.3〜99.7です。信頼度90未満の行だけを確認すると、見つかるのは「孤」の行だけです。
表とレイアウト
元の記事の本文には表が8つあり、7つは行数・列数まで一致して抽出されました。ページをまたぐ1つ(18行×6列)は、4行と15行の2つの TABLE に分かれました。2つ目の先頭行は印刷で繰り返された見出し行なので、比較では2つをつなぎ、この行を除いています。本文の表以外にも、著者欄、リンクカード、関連記事、記事末尾のフッターなどの領域が TABLE として返りました。
セルの比較は、本文の表8つ、計195セルを対象に、NFKC 正規化と空白除去のうえで行いました。完全一致は146個(74.9%)で、矢印を無視すると194個(99.5%)が一致します。ページをまたぐ表では、矢印を含む54セルのうち49セルで矢印が落ち、5セルで残りました。矢印を無視しても一致しなかったのは、0.004→- が 0.004 になった1セルだけです。
| 元の表のセル | Textract のセル |
|---|---|
| 0.461→0.118 | 0.461 0.118 |
| 14→15 | 14 15 |
| 0.004→- | 0.004 |
| ミス件数(ECS→Web Functions) | ミス件数 (ECS Web Functions) |
レイアウトでは、元の記事の見出し(h2・h3)13個のうち12個が LAYOUT_SECTION_HEADER として抽出されました。抽出されなかったのは元の記事の「はじめに」です。LAYOUT_SECTION_HEADER は全体で16個あり、残り4個は記事タイトル、リンクカードの題名、「関連記事」、記事末尾のフッターの「classmethod」でした。「目次」は見出しになりませんでした。
検証2: ページ画像(Textract と Bedrock の Sonnet)
ここでは同じ記事をブラウザで画像にして、Textract と Amazon Bedrock の Claude Sonnet 5.5 に同じ画像を渡し、文字起こしの結果と費用を比べます。テキスト層を持たない画像にすると、どちらにも同じ入力を渡せます。
画像の作成
Chrome の DevTools でコマンドメニュー(Ctrl+Shift+P、Mac は Cmd+Shift+P)を開き、「Capture full size screenshot」で記事のページ全体を撮りました。結果は PNG で、1,268×10,817 ピクセル、約4.0MB です。記事末尾の広告、関連記事、サイトのフッターを削り、JPEG(1,268×8,978 ピクセル、1.74MB)にしました。
画像には、記事本文のほかに、上部のメニュー、右側のイベント一覧と広告、本文中の構成図が写っています。グラフの位置は描画されておらず、空白です。
上限と分割
画像1枚あたりの上限は、Textract と Bedrock で次のとおりです。
| Textract(同期) | Bedrock(Converse) | |
|---|---|---|
| 1枚のサイズ | 10MB | 3.75MB |
| 縦横のピクセル数 | 10,000以下 | 8,000以下 |
| PNG(4.0MB、高さ10,817) | 高さが超過 | サイズと高さが超過 |
| JPEG(1.74MB、高さ8,978) | 範囲内 | 高さが超過 |
Textract の上限は Developer Guide、Bedrock の上限は API リファレンスの記載です。JPEG は Textract の範囲内ですが、Bedrock は高さが超えるため、どちらも同じ条件にそろえて、約1,500ピクセルごとに6枚へ分割しました。
分割は Python で行いました。本文の列に文字がない行を探して、そこで切っています。右側の広告の列は、切れ目の判定に使っていません。
分割スクリプト(split_image.py)
Pillow と NumPy を使います。python3 split_image.py page.jpg split で実行します。
"""縦長の画像を、本文の列に文字がない行で、約1,500ピクセルごとに分割する。
使い方: python3 split_image.py <入力画像> <出力ディレクトリ>
"""
import os
import sys
import numpy as np
from PIL import Image
src, out_dir = sys.argv[1], sys.argv[2]
os.makedirs(out_dir, exist_ok=True)
TARGET = 1500 # 1枚あたりの目標の高さ(ピクセル)
WINDOW = 250 # 目標位置の前後で、切れ目を探す範囲
BODY_W = 980 # 本文の列の右端。右側の広告の列は判定に使わない
im = Image.open(src).convert("RGB")
w, h = im.size
gray = np.asarray(im.convert("L")).astype(float)
ink = (gray[:, :BODY_W] < 200).sum(axis=1) # 行ごとの、暗い画素の数
cuts = [0]
while h - cuts[-1] > TARGET + WINDOW:
center = cuts[-1] + TARGET
# 暗い画素が最も少なく、目標位置に近い行で切る
best = min(range(center - WINDOW, center + WINDOW),
key=lambda y: (ink[y], abs(y - center)))
cuts.append(best)
cuts.append(h)
for i, (top, bottom) in enumerate(zip(cuts, cuts[1:]), 1):
path = os.path.join(out_dir, f"tile-{i:02d}.png")
im.crop((0, top, w, bottom)).save(path)
print(f"{path}: y={top}-{bottom} height={bottom - top}")
分割の結果は次のとおりです。
| 枚 | 縦位置(ピクセル) | 高さ |
|---|---|---|
| 1 | 0〜1,500 | 1,500 |
| 2 | 1,500〜3,000 | 1,500 |
| 3 | 3,000〜4,501 | 1,501 |
| 4 | 4,501〜5,993 | 1,492 |
| 5 | 5,993〜7,493 | 1,500 |
| 6 | 7,493〜8,978 | 1,485 |
Textract で処理する
6枚を、同期の detect-document-text で処理しました。AWS CLI 2.39.3(cli_binary_format は未設定)では、--document Bytes=fileb://<画像> の指定が UnsupportedDocumentException(Request has unsupported document format)で失敗したため、画像を base64 でエンコードして埋め込んだ JSON を渡しています。
画像を JSON にするスクリプト(make_document_json.py)
次の内容を make_document_json.py として保存し、python3 make_document_json.py <画像> <出力JSON> で実行します。
import base64
import json
import sys
src, dst = sys.argv[1], sys.argv[2]
data = open(src, "rb").read()
json.dump({"Bytes": base64.b64encode(data).decode("ascii")}, open(dst, "w"))
for i in 01 02 03 04 05 06; do
python3 make_document_json.py split/tile-$i.png doc-$i.json
aws textract detect-document-text --region ap-northeast-1 \
--document file://doc-$i.json > textract-$i.json
done
6枚とも成功し、1枚あたり1.3〜1.6秒で返りました。返った LINE は、1枚目から順に71、111、69、50、92、66行で、合計459行です。
分割しない JPEG(高さ8,978)も Textract の上限の範囲内なので、同じ方法で処理しました。呼び出しは成功しましたが、返った LINE は79行、326文字で、見出しなどの大きな文字が中心です。本文の大半は検出されていません。
Bedrock の Sonnet で処理する
Bedrock では、東京リージョンから Global の推論プロファイル global.anthropic.claude-sonnet-5-5 を、Converse API で呼びました。6枚を1枚ずつ渡し、次のプロンプトで文字起こしをさせています。
この画像に含まれる文字をすべて、見えるとおりに文字起こししてください。説明や要約は付けず、文字起こしだけを出力してください。
Sonnet 5.5 では、temperature を指定すると ValidationException(temperature is deprecated for this model)になるため、指定していません。応答には、推論の内容を示す reasoningContent のブロックが混ざることがあるので、text のブロックだけを連結しています。
Bedrock 呼び出しスクリプト(bedrock_transcribe.py)
boto3 を使います。python3 bedrock_transcribe.py global.anthropic.claude-sonnet-5-5 split . sonnet で実行すると、sonnet-01.txt から sonnet-06.txt ができます。
"""分割した画像を Bedrock の Converse API で文字起こしし、トークン数を記録する。
使い方: python3 bedrock_transcribe.py <モデルID> <タイル画像のディレクトリ> <出力ディレクトリ> <出力ファイルの接頭辞>
"""
import json
import os
import sys
import time
import boto3
model_id, tile_dir, out_dir, prefix = sys.argv[1:5]
os.makedirs(out_dir, exist_ok=True)
client = boto3.client("bedrock-runtime", region_name="ap-northeast-1")
prompt = "この画像に含まれる文字をすべて、見えるとおりに文字起こししてください。説明や要約は付けず、文字起こしだけを出力してください。"
total_in = total_out = 0
for i in range(1, 7):
with open(os.path.join(tile_dir, f"tile-{i:02d}.png"), "rb") as f:
image = f.read()
started = time.time()
res = client.converse(
modelId=model_id,
messages=[{"role": "user", "content": [
{"image": {"format": "png", "source": {"bytes": image}}},
{"text": prompt},
]}],
inferenceConfig={"maxTokens": 8192},
)
usage = res["usage"]
total_in += usage["inputTokens"]
total_out += usage["outputTokens"]
# 応答には推論ブロック(reasoningContent)が混ざることがあるため、text だけを連結する
blocks = res["output"]["message"]["content"]
text = "".join(b["text"] for b in blocks if "text" in b)
with open(os.path.join(out_dir, f"{prefix}-{i:02d}.txt"), "w", encoding="utf-8") as f:
f.write(text)
with open(os.path.join(out_dir, f"{prefix}-{i:02d}.meta.json"), "w") as f:
json.dump({"usage": usage, "stopReason": res["stopReason"]}, f)
print(f"tile-{i:02d} in={usage['inputTokens']} out={usage['outputTokens']} "
f"blocks={[list(b)[0] for b in blocks]} {time.time() - started:.1f}s")
print("TOTAL in", total_in, "out", total_out)
6枚の入力トークン数は、どれも2,548でした。幅×高さ÷750(Anthropic のドキュメントにある画像のトークン数の目安)とほぼ一致します。
| 枚 | 入力トークン | 出力トークン | 所要時間 |
|---|---|---|---|
| 1 | 2,548 | 2,121 | 14.3秒 |
| 2 | 2,548 | 1,128 | 9.3秒 |
| 3 | 2,548 | 852 | 5.6秒 |
| 4 | 2,548 | 884 | 6.3秒 |
| 5 | 2,548 | 1,203 | 8.4秒 |
| 6 | 2,548 | 1,074 | 7.7秒 |
| 合計 | 15,288 | 7,262 |
1枚目の出力トークンが多いのは、推論のブロックが含まれていたためです。1枚目の文字起こしは1,391文字でした。
正解データとの比較
正解データは、元の記事の HTML から抜き出した本文のテキストです。折りたたみの中身、単独のリンク行、表のセル区切りを除いた5,004文字です。比較は PDF と同じく文字ごとの出現数で行いました。出力には広告などの文字も混ざるため、出現数だけでは実際より良く見えます。そこで、正解の行の中の連続6文字が出力にそのまま現れるかも見ています。
比較スクリプト(compare_ocr.py)
python3 compare_ocr.py ground-truth.txt "Textract=textract-0*.json" "Sonnet=sonnet-0*.txt" のように、名前とファイルのパターンを並べて実行します。.json は Textract の出力として読みます。
"""OCR・文字起こしの出力を、元記事の本文(正解データ)と比べる。
使い方: python3 compare_ocr.py <正解データ> "名前=ファイルのパターン" ...
例: python3 compare_ocr.py ground-truth.txt "Textract=textract-0*.json" "Sonnet=sonnet-0*.txt"
.json は Textract の出力(LINE の Text を連結)、それ以外はテキストとして読む。
"""
import glob
import json
import re
import sys
import unicodedata
from collections import Counter
def norm(s):
# NFKC で正規化し、空白を除く
return re.sub(r"\s+", "", unicodedata.normalize("NFKC", s))
def ngrams(s, n):
return [s[i:i + n] for i in range(len(s) - n + 1)]
def read_output(pattern):
texts = []
for path in sorted(glob.glob(pattern)):
if path.endswith(".json"):
blocks = json.load(open(path, encoding="utf-8"))["Blocks"]
texts.append("".join(b["Text"] for b in blocks if b["BlockType"] == "LINE"))
else:
texts.append(open(path, encoding="utf-8").read())
return norm("".join(texts))
# 正解データ: 折りたたみ(details)の中身は画面に表示されないので除く(34〜54行目)。
# 単独のリンク行(画像ではカード表示になる)と、表のセル区切りの | も除く。
raw = open(sys.argv[1], encoding="utf-8").read().splitlines()
raw = raw[:33] + ["buildspec の抜粋"] + raw[54:]
lines = [re.sub(r"\s*\|\s*$", "", l) for l in raw if not re.match(r"^\s*https?://\S+\s*$", l)]
lines = [norm(l) for l in lines if norm(l)]
truth = "".join(lines)
print("正解データ", len(truth), "文字")
for arg in sys.argv[2:]:
name, pattern = arg.split("=", 1)
out = read_output(pattern)
# 1. 文字ごとの出現数(PDF の比較と同じ方法。順序は見ない)
missing = Counter(truth) - Counter(out)
n = sum(missing.values())
print(f"\n{name}: 出力 {len(out)} 文字")
print(f" 正解のうち含まれた文字 {len(truth) - n}/{len(truth)} = {(len(truth) - n) / len(truth) * 100:.2f}%")
print(" 含まれなかった文字", missing.most_common(8))
# 2. 正解の行の中の連続 n 文字が、出力にそのまま現れるか(1文字の誤りで周辺の n 文字が不一致になる)
for k in (4, 6, 8):
total = sum(len(ngrams(l, k)) for l in lines)
hit = sum(g in out for l in lines for g in ngrams(l, k))
print(f" 行内の連続{k}文字の一致 {hit}/{total} = {hit / total * 100:.2f}%")
| Textract | Sonnet 5.5 | |
|---|---|---|
| 出力の文字数 | 7,056 | 7,279 |
| 正解5,004文字のうち含まれた文字 | 4,991(99.74%) | 5,004(100%) |
| 行内の連続6文字の一致 | 96.15% | 98.82% |
出力の文字数が正解より多いのは、広告、メニュー、図の中の文字も出力に含まれるためです。
Textract(6分割)で含まれなかった13文字と、取り違えた文字は次のとおりです。PDF の検証で見つかったものと、同じ種類の誤りです。
- 矢印「→」: 54個のうち5個が出力にない
- 「〜」: 5個すべてが「~」に置換
- 「へ」: 「ヘ」に置換(2か所)
- 「弧」: 「孤」に置換(括弧)
- 「北」: 「北米」の1文字が出力にない
- 「I」: DevelopersIO の「I」が「l」に置換(出現数の比較では検出されない取り違え)
Sonnet 5.5 には、本文の文字の取り違えや欠落がありませんでした。正解と違っていたのは、インラインコードをバッククォートで囲んだ箇所だけで、文字の誤りではなく書式の追加です。
Textract の連続6文字の一致が Sonnet 5.5 より低いのには、読み取りの順序も影響しています。右側の広告や表の文字が、本文の行と入り混じって返るためです。
1枚あたりの所要時間は、Textract が1.3〜1.6秒、Sonnet 5.5 が5.6〜14.3秒でした。費用は「費用」の章にまとめています。
この比較の範囲
- 対象は、1つの記事のページを撮った画像で、各1回の実行です。
- 正解データがあるのは本文だけです。広告や図の中の文字は、評価に含めていません。
- Textract は、バウンディングボックスや表の構造も返します。この比較は、文字起こしの文字だけを見ています。
費用
Textract の東京リージョンの単価は、1ページ(画像は1枚)あたり、テキスト検出が 0.0015 USD、Tables が 0.015 USD、Layout が 0.004 USD です。
検証ごとの費用は、この単価で計算すると次のとおりです。
| 検証 | 処理 | 計算 | 費用 |
|---|---|---|---|
| 検証1 | PDF 12ページ: テキスト検出、Tables、Layout | 12 × (0.0015 + 0.015 + 0.004) | 約0.25 USD(テキスト検出 0.018、Tables 0.18、Layout 0.048) |
| 検証2 | ページ画像 6枚: Textract のテキスト検出 | 6 × 0.0015 | 0.009 USD |
| 検証2 | 同じ6枚: Sonnet 5.5 | 入力 15,288 トークン、出力 7,262 トークン | 約0.10 USD |
Sonnet 5.5 は Global の単価(入力 2.00 USD、出力 10.00 USD / 100万トークン)で計算しています。
| Sonnet 5.5 | 計算 | 費用 |
|---|---|---|
| 入力 | 15,288 × 2.00 / 100万 | 約0.031 USD |
| 出力 | 7,262 × 10.00 / 100万 | 約0.073 USD |
| 合計 | 約0.10 USD |
同じ6枚の画像では、Sonnet 5.5 の費用は Textract の約11倍でした。いずれも公開単価からの概算です。
まとめ
東京リージョンの Amazon Textract で、日本語の PDF のテキスト検出と、表・レイアウトの抽出を実行できました。
画像の OCR や文字起こしが必要でも、日本語のサポートや東京リージョンでの提供がないため Textract の採用を見送っていた方や、OCR に Sonnet などの LLM を採用したもののコストが課題になっている方は、今回のアップデートを試してみてください。









