東京リージョンで日本語対応した Textract を試してみた

東京リージョンで日本語対応した Textract を試してみた

Amazon Textract で、日本語の PDF(12ページ)を東京リージョンで処理しました。埋め込みテキストの文字の98.88%(出現数)が出力に含まれました。Textract は文字の取りこぼしが0.26%で、費用は Sonnet 5.5 の約11分の1でした。
2026.11.03

はじめに

2026年11月2日に、Amazon Textract の日本語を含むサポート言語の追加と、東京リージョンでの提供開始が発表されました。

https://aws.amazon.com/about-aws/whats-new/2026/11/textract-tokyo-zurich/

https://aws.amazon.com/about-aws/whats-new/2026/11/textract-text-detection-model/

今回は日本語サポートを試すため、テキストを埋め込んだ日本語の 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 ごとの対応言語は次のとおりです。

https://docs.aws.amazon.com/textract/latest/dg/limits-document.html

  • 全言語に対応
    • 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 のファイルで、埋め込みテキストを持ちます。

https://dev.classmethod.jp/articles/developersio-lambda-web-functions-multiregion/

各ページに印刷日時のヘッダーと 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 を採用したもののコストが課題になっている方は、今回のアップデートを試してみてください。

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事