Donutモデルとは?

(画像は、Google AI Studioの「Nano Banana 2」モデルを用いて作成されたものです)
Donutモデルの概要
Donutモデルは2021年に発表され、ECCV 2022に採択された論文: "OCR-free Document Understanding Transformer"で提案された技術で、OCR処理なしで画像から直接構造化された情報(行や列に沿って整理されたデータ)を抽出することができます。
ここでのOCR処理とは、画像からテキストがある領域を検出(BBOX検出:バウンディングボックス検出)した後に、文字認識を行う処理を意味しています。Donut以前のモデルではこの処理を行った後に、BERTなどのモデルを用いて、文字認識結果から構造化データを作成していましたが、Donutモデルは、画像入力後にBBOX検出処理や文字認識の中間出力を行わずに直接構造化データ(JSON形式)を出力することが可能です。
Donutモデルの処理概要

(画像は、Geminiを用いて作成されたものです)
Donutモデルは入力画像に対してリサイズや正規化などの前処理を行った後、Swin Transformer EncoderでSelf-Attention処理を行い出力されます。 一方で、ユーザーのプロンプトはBART Decoderに入力されて埋め込みベクトルに変換されます。その後、同じBART Decoder内で、Swin Transformerによって画像から抽出されたベクトル群とCross-Attentionを行い、トークンを順次生成することで構造化データを出力します。
さらに詳しくDonutモデルの処理を理解するために、コードサンプルを用いて確認してみましょう。
サンプル画像読み込みコード
モデルの学習・評価には論文でも利用されているCORDデータセットを用います。
(論文はv1が用いられているが、ラベル付けのミスなどが修正されたv2を利用)
本記事で使用する「CORD-v2」データセットは、クリエイティブ・コモンズ(CC BY 4.0)ライセンスの下で提供されています。営利・非営利を問わず利用可能ですが、適切なクレジット表記が必要となります。詳細は記事の末尾に記載しています。
CORD-v2データセットの著作権は NAVER Clova AI に帰属し、CC BY 4.0 ライセンスの下で提供されています。
CORD-v2のデータセットはHugging Faceのdatasetsライブラリから簡単にダウンロードすることができます。
import json
import matplotlib.pyplot as plt
from datasets import load_dataset
# 1. データセットのロード
dataset = load_dataset("naver-clova-ix/cord-v2")
# 2. 訓練セットから最初のサンプル(インデックス0)を取得
sample_idx = 0
sample = dataset["train"][sample_idx]
image = sample["image"]
ground_truth_str = sample["ground_truth"]
# 3. JSON文字列を見やすく整形 (Pretty Print)
ground_truth_json = json.loads(ground_truth_str)
formatted_json = json.dumps(ground_truth_json, indent=4, ensure_ascii=False)
# 4. 画像の可視化
fig, ax = plt.subplots(figsize=(8, 10))
ax.imshow(image)
ax.axis("off") # 軸の目盛りを消す
ax.set_title(f"CORD-v2 Train Sample #{sample_idx}", fontsize=14)
plt.show()
# 5. 正解データ (Ground Truth) の表示
print("=== 正解データ (Ground Truth) ===")
print(formatted_json)
上記のコードを実行すると以下のようなレシートの画像sample["image"]とレシートに書かれた情報の正解データsample["ground_truth"]が表示されます。
dataset["train"]のインデックス0のsample["image"]

dataset["train"]のインデックス0のsample["ground_truth"]
=== 正解データ (Ground Truth) ===
{
"gt_parse": {
"menu": [
{
"nm": "Nasi Campur Bali",
"cnt": "1 x",
"price": "75,000"
},
{
"nm": "Bbk Bengil Nasi",
"cnt": "1 x",
"price": "125,000"
},
{
"nm": "MilkShake Starwb",
"cnt": "1 x",
"price": "37,000"
},
{
"nm": "Ice Lemon Tea",
"cnt": "1 x",
"price": "24,000"
},
{
....
sample["ground_truth"]にはレシートの画像に記述されている項目名(nm)、個数(cnt)、価格(price) が記載されています。 Donutはsample["image"] の画像を入力し、sample["ground_truth"]のJSONを出力できるようにモデルを学習します。
Donutモデルの実装(概念的なシンプルな実装)
実際のDonutモデルは上述のようにEncoderにはSwinEncoder、DecoderにはBART Decoderを用いているが、 Donutモデルの原理を理解するためにSwinEncoderをPyTorchのnn.TransformerEncoder、BART Decoderの代わりにnn.TransformerDecoderに置き換えてモデルを0から学習・評価してみましょう。
Donut Encoderの実装
まずはEncoderをシンプルなTransformer Encoder(PyTorchのnn.TransformerEncoderを利用)で実装してみます。
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import transforms
from transformers import XLMRobertaTokenizer
## nn.TransformerEncoderによるシンプルなDonut Encoderの実装
class SimpleDonutEncoder(nn.Module):
def __init__(self, hidden_dim=128, num_layers=2, nhead=4, max_seq_len=1024):
super().__init__()
self.patch_embed = nn.Conv2d(in_channels=3, out_channels=hidden_dim, kernel_size=16, stride=16)
self.pos_embed = nn.Parameter(torch.randn(1, max_seq_len, hidden_dim))
encoder_layer = nn.TransformerEncoderLayer(d_model=hidden_dim, nhead=nhead, batch_first=True)
self.transformer_encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)
def forward(self, x):
x = self.patch_embed(x)
x = x.flatten(2).transpose(1, 2)
seq_len = x.size(1)
x = x + self.pos_embed[:, :seq_len, :]
return self.transformer_encoder(x)
SimpleDonutEncoderでは、self.patch_embedというConv2d層を用いて、Vision Transformerのように画像を16×16のパッチに分割しています。
(kernel_size=16, stride=16とすることで入力画像から16×16のパッチを抽出することができます。)
また、入力画像におけるパッチの位置を符号化するためself.pos_embedより位置エンコーディングのための埋め込みベクトルを追加し、
self.transformer_encoderよりTransformer Encoderを実装しています。(TransformerEncoderの次元数、Multi-Head-Attentionのヘッド数はencoder_layerで指定しています。)
Encoderのforwardは画像データをxとして受け取ります。self.patch_embed(x)で画像をパッチに分割します。
xはnn.Conv2dの処理により(batch_size, hidden_dim, patch_height, patch_width)次元のベクトルに変換されます。
これをVision Transformerと同様に、x.flatten(2)で(batch_size, hidden_dim, patch_height * patch_width)に変換した後、x.transpose(1, 2)で(batch_size, パッチ数, hidden_dim)の形式へと整形しています。
x = x + self.pos_embed[:, :seq_len, :]で位置エンコーディングを追加し、self.transformer_encoderに入力することで、画像パッチごとのSelf-Attentionを実行します。
Donut Decoderの実装
次にDecoderをTransformer Decoder(PyTorchのnn.TransformerDecoderを利用)で実装してみます。
## nn.TransformerDecoderによるシンプルなDonut Decoderの実装
class SimpleDonutDecoder(nn.Module):
def __init__(self, vocab_size, hidden_dim=128, max_seq_len=1024):
super().__init__()
self.embedding = nn.Embedding(vocab_size, hidden_dim)
# テキスト側にも位置情報を追加
self.pos_embed = nn.Parameter(torch.randn(1, max_seq_len, hidden_dim))
self.decoder_layer = nn.TransformerDecoderLayer(d_model=hidden_dim, nhead=4, batch_first=True)
self.transformer_decoder = nn.TransformerDecoder(self.decoder_layer, num_layers=2)
self.lm_head = nn.Linear(hidden_dim, vocab_size)
def forward(self, input_ids, encoder_hidden_states):
seq_len = input_ids.size(1)
device = input_ids.device
tgt_embed = self.embedding(input_ids)
tgt_embed = tgt_embed + self.pos_embed[:, :seq_len, :]
# 将来トークンのカンニング防止マスク (Look-ahead mask / Causal mask)
tgt_mask = torch.full((seq_len, seq_len), float('-inf'), device=device)
tgt_mask = torch.triu(tgt_mask, diagonal=1)
decoder_outputs = self.transformer_decoder(
tgt=tgt_embed,
memory=encoder_hidden_states,
tgt_mask=tgt_mask
)
return self.lm_head(decoder_outputs)
Decoderは文字列を出力するため、語彙の埋め込みベクトルを用意する必要があります。これはself.embeddingで実装しています。
また、テキスト側にも位置情報を追加するためself.pos_embedより位置エンコーディングのための埋め込みベクトルを追加し、
self.transformer_decoderよりTransformer Decoderを実装しています。(TransformerDecoderの次元数、Multi-Head-Attentionのヘッド数はdecoder_layerで指定しています。)
最終的には文字を出力する必要があるため、TransformerDecoderの出力ベクトルから語彙ID(トークンID)に変換するためのself.lm_headを追加しています。
Decoderのforwardはinput_idsとencoder_hidden_statesを入力として受け取ります。
input_idsはプロンプト入力を後述するtokenizerでトークンに分割しIDに変換したものを受け取ります。プロンプトによってDecoderの出力結果を変えたい場合には、input_idsを変更するだけです。TransformerEncoderからの出力はencoder_hidden_statesとして受け取ります。
tgt_embed = self.embedding(input_ids)でinput_idsを語彙IDから埋め込みベクトルに変換します。
tgt_embed = tgt_embed + self.pos_embed[:, :seq_len, :]でtgt_embedに位置エンコーディングを適用し、
tgt_mask = torch.full((seq_len, seq_len), float('-inf'), device=device)とtgt_mask = torch.triu(tgt_mask, diagonal=1)で、現在のトークン予測位置から将来のトークン予測位置の結果が見えないようにするマスクを作成します。(将来トークンのカンニング防止マスク、またはLook-ahead maskと呼ばれます。) プロンプトのトークンの埋め込みベクトル(tgt_embed)とEncoderの出力ベクトル(encoder_hidden_states)、将来トークンのカンニング防止マスク(tgt_mask)をself.transformer_decoderに入力することで、プロンプトとTransformerEncoderで符号化された画像ベクトルから構造化されたデータのベクトルを生成します。
self.lm_head(decoder_outputs)でdecoder_outputsのベクトルから語彙IDへの変換を行います。
Donutモデルの実装
EncoderとDecoderの定義が完了したため、これらを組み合わせた最終的なDonutモデルを実装してみましょう。
class ConceptualDonutModel(nn.Module):
def __init__(self, vocab_size, pad_token_id):
super().__init__()
self.encoder = SimpleDonutEncoder()
self.decoder = SimpleDonutDecoder(vocab_size)
# Padトークンを計算から除外する
self.loss_fn = nn.CrossEntropyLoss(ignore_index=pad_token_id)
def forward(self, image_tensors, decoder_input_ids, decoder_labels):
encoder_outputs = self.encoder(image_tensors)
logits = self.decoder(input_ids=decoder_input_ids, encoder_hidden_states=encoder_outputs)
loss = self.loss_fn(logits.view(-1, logits.size(-1)), decoder_labels.view(-1))
return loss
def generate(self, image_tensors, start_prompt_id, eos_id, max_length=100):
encoder_outputs = self.encoder(image_tensors)
generated_ids = [start_prompt_id]
for _ in range(max_length):
input_ids = torch.tensor([generated_ids]).to(image_tensors.device)
logits = self.decoder(input_ids, encoder_outputs)
next_token_id = torch.argmax(logits[0, -1, :]).item()
generated_ids.append(next_token_id)
if next_token_id == eos_id:
break
return generated_ids
self.encoder = SimpleDonutEncoder()でnn.TransformerEncoderによるシンプルなDonut Encoder、
self.decoder = SimpleDonutDecoder(vocab_size)でnn.TransformerDecoderによるシンプルなDonut Decoderを実装しています。
self.loss_fn = nn.CrossEntropyLoss(ignore_index=pad_token_id)でモデルの学習で用いるCrossEntropyLossを定義しています。
ignore_index=pad_token_idで、PADのトークンIDを学習対象から外すことで、PADの誤差がモデルの勾配のノイズとして影響するのを防ぎます。
ConceptualDonutModelのメソッドには、forwardとgenerateの2つがあります。
forwardはモデル学習時に使用され、generateはモデル評価時に使用されます。
大きな違いは、forwardではdecoder_inputsに文字列開始を示すBOSトークンID+正解構造データ[:-1]と、
decoder_labelsには正解構造データを入力し、一つ前のインデックスの構造化データであるdecoder_inputsから、
正解の構造化データdecoder_labelsを予測するという点であることに対して、
generateではstart_prompt_idに文字列開始を示すBOSトークンIDを入力し、その後の構造化データは順に予測されたトークンIDを入力して予測する点が異なります。
forwardでは、一つ前のインデックスの予測結果に正しいトークンIDが予測されたという前提で、後続のトークンの予測を行います。
このようにモデルの誤った出力ではなく「正解のデータ」を次の入力として強制的に与えて学習させる手法をTeacher Forcingと呼びます。
Teacher Forcingを行うことでDecoderが途中の間違って出力したトークン結果を入力して、モデルが学習を誤った学習を行ってしまうことを防ぐことができます。
forwardはencoder_outputs = self.encoder(image_tensors)より画像をTransformerEncoderよりベクトルに変換し、
logits = self.decoder(input_ids=decoder_input_ids, encoder_hidden_states=encoder_outputs)よりTransformerDecoderを用いて、
画像と入力トークンとのCross-Attentionを行い構造化データを出力します。
loss = self.loss_fn(logits.view(-1, logits.size(-1)), decoder_labels.view(-1))で構造化データの予測結果と正解の構造化データのクロスエントロピー誤差を計算します。
generateは、学習されたモデルから予測を行うメソッドで、
forwardにおけるdecoder_inputsの代わりに、
文字列の開始を示すBOSトークンIDであるstart_prompt_idのみを入力し、後続の文字列は予測によって生成する点が異なります。
encoder_outputs = self.encoder(image_tensors)はforwardと同じく画像をTransformerEncoderよりベクトルに変換します。
generated_ids = [start_prompt_id]はBOSトークンIDを入力し、次のfor文で、1文字ずつ順番にトークンを予測していきます。
logits = self.decoder(input_ids, encoder_outputs)で(batch_size, vocab_size)のベクトルを出力し、
next_token_id = torch.argmax(logits[0, -1, :]).item()で最も確率が高いトークンIDを
generated_ids.append(next_token_id)で予測されたトークンを追加していきます。
トークンIDが文の終わりを表すeos_idを検出するか、予測されたトークン数がmax_lengthに達したら、for文を終了し、予測された文字列を返します。
Donutモデルの学習と予測
最後にConceptualDonutModelを用いた学習及び予測の処理を実装します。
# Tokenizerの読み込み
tokenizer = XLMRobertaTokenizer.from_pretrained("naver-clova-ix/donut-base")
vocab_size = len(tokenizer)
# 今回は概念の確認のため、構造化データを400文字で切り詰める。
max_test_length = 400
short_ground_truth_str = ground_truth_str[:max_test_length]
# 構造化データ文字列のトークンIDへの変換
tokens = tokenizer.encode(short_ground_truth_str)
# GPUが利用できる環境ではcuda、CPUのみの場合はcpuを設定
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# モデルの初期化 (pad_token_id=1 を渡す)
model = ConceptualDonutModel(vocab_size=vocab_size, pad_token_id=tokenizer.pad_token_id)
model.to(device)
# optimizerの定義
optimizer = optim.AdamW(model.parameters(), lr=1e-3)
# 画像の前処理(256×256へのリサイズ、正規化の実施)
transform = transforms.Compose([
transforms.Resize((256, 256)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5])
])
# 今回は訓練サンプルの0番目のデータに過学習するように学習させるが、モデルはバッチ入力が必要なため、バッチ次元として1次元目をbatch_size=1として追加
image_tensor = transform(image).unsqueeze(0).to(device)
tokens_tensor = torch.tensor(tokens).unsqueeze(0).to(device)
# Teacher Forcingのためのラベルデータの追加(tokens_tensorの0番目にはbos_idが設定されている。)
decoder_input_ids = tokens_tensor[:, :-1]
decoder_labels = tokens_tensor[:, 1:]
# モデルの学習
model.train()
epochs = 300
print("モデルの学習開始")
for epoch in range(1, epochs + 1):
optimizer.zero_grad()
loss = model(image_tensor, decoder_input_ids, decoder_labels)
loss.backward()
optimizer.step()
if epoch % 20 == 0 or epoch == 1:
print(f"Epoch {epoch:03d}/{epochs} - Loss: {loss.item():.4f}")
print("学習完了")
print("モデル評価")
model.eval()
with torch.no_grad():
predicted_ids = model.generate(
image_tensors=image_tensor,
start_prompt_id=tokenizer.bos_token_id, # bosトークンID
eos_id=tokenizer.eos_token_id, # eosトークンID
max_length=len(tokens) + 10
)
# 特殊トークンを除外してテキストに復元
predicted_text = tokenizer.decode(predicted_ids, skip_special_tokens=True)
# 正解側も、特殊トークン抜きの純粋なテキストにするため一度デコードして比較基準にします
target_text = tokenizer.decode(tokens, skip_special_tokens=True)
print("【モデルの予測結果】")
print(predicted_text)
print("-" * 30)
print("【本来の正解データ(400文字に切り詰め】")
print(target_text)
if predicted_text == target_text:
print("予測と正解データが一致しました。")
else:
print("予測と正解データが一致しませんでした。")
まず、XLMRobertaTokenizer.from_pretrained("naver-clova-ix/donut-base")を用いて、Donutモデルで標準的に使用されるマルチリンガル対応のトークナイザーを読み込んでいます。 (補足:Donutの論文内ではmBARTのトークナイザーが使用されていましたが、Hugging Faceにアップロードされた公式モデルではXLMRobertaのトークナイザーに変更されています。) max_test_length = 400で構造化データの文字列を切り詰め、tokenizer.encodeによってテキストをモデルが処理可能なトークンIDのシーケンスに変換しています。
モデルの初期化では、ConceptualDonutModelに対して読み込んだトークナイザーの語彙数(vocab_size)とパディング用のトークンIDを渡しています。 画像の前処理として、transforms.Resize((256, 256))によるリサイズと、transforms.Normalizeによる標準化(各チャネルの平均と標準偏差を0.5に設定)を行い、入力データのスケールを揃えています。
学習フェーズでは、Teacher Forcingを適用するためにtokens_tensorから入力を1トークンずらしたdecoder_input_ids(最後のトークンを除く)とdecoder_labels(最初のトークンを除く)を作成しています。 optimizer.zero_grad()で勾配をリセットした後、model(image_tensor, decoder_input_ids, decoder_labels)を呼び出して損失を計算し、loss.backward()とoptimizer.step()によってモデルの重みを更新しています。
推論(評価)フェーズでは、model.eval()に切り替えた後、model.generateを呼び出しています。 ここでは学習時とは異なり、start_prompt_id(BOSトークン)のみを起点として、モデルが自力で次のトークンを1つずつ予測・生成していきます。 最後に、生成されたトークンIDのリストをtokenizer.decodeでテキストに戻し、元の正解データと比較することで、モデルが正しく構造化データを抽出できているかを確認しています。
モデルが正しく学習されると、以下のようにクロスエントロピー誤差がepochとともに減少します。 今回は動作の理解のため学習に用いた訓練サンプルの構造化データを400文字切り詰めた結果と予測結果が一致するかを確認すると、一致することが確認できた。
モデルの学習開始
Epoch 001/300 - Loss: 10.9835
Epoch 020/300 - Loss: 3.9394
Epoch 040/300 - Loss: 0.6503
Epoch 060/300 - Loss: 0.1062
Epoch 080/300 - Loss: 0.0493
Epoch 100/300 - Loss: 0.0337
Epoch 120/300 - Loss: 0.0261
Epoch 140/300 - Loss: 0.0214
Epoch 160/300 - Loss: 0.0179
Epoch 180/300 - Loss: 0.0153
Epoch 200/300 - Loss: 0.0131
Epoch 220/300 - Loss: 0.0115
Epoch 240/300 - Loss: 0.0100
Epoch 260/300 - Loss: 0.0088
Epoch 280/300 - Loss: 0.0080
Epoch 300/300 - Loss: 0.0073
学習完了
モデル評価
【モデルの予測結果】
{"gt_parse": {"menu": [{"nm": "Nasi Campur Bali", "cnt": " x", "price": "75,000"}, {"nm": "Bbk Bengil Nasi", "cnt": " x", "price": "125,000"}, {"nm": "MilkShake Starwb", "cnt": " x", "price": "37,000"}, {"nm": "Ice Lemon Tea", "cnt": " x", "price": "24,000"}, {"nm": "Nasi Ayam Dewata", "cnt": " x", "price": "70,000"}, {"nm": "Free Ice Tea", "cnt": "3 x", "price": "0"}, {"nm": "Organic Green S
------------------------------
【本来の正解データ(400文字に切り詰め】
{"gt_parse": {"menu": [{"nm": "Nasi Campur Bali", "cnt": " x", "price": "75,000"}, {"nm": "Bbk Bengil Nasi", "cnt": " x", "price": "125,000"}, {"nm": "MilkShake Starwb", "cnt": " x", "price": "37,000"}, {"nm": "Ice Lemon Tea", "cnt": " x", "price": "24,000"}, {"nm": "Nasi Ayam Dewata", "cnt": " x", "price": "70,000"}, {"nm": "Free Ice Tea", "cnt": "3 x", "price": "0"}, {"nm": "Organic Green S
予測と正解データが一致しました。
実際のHugging Faceモデルを用いた推論テスト
ここまではDonutモデルの仕組みを理解するためにシンプルな概念モデルをゼロから構築してきましたが、実際の開発ではHugging Faceのtransformersライブラリを利用することで、すでに学習済みの強力なモデルを数行のコードで呼び出すことができます。
※注意点:naver-clova-ix/donut-baseは事前学習のみが行われたベースモデルです。今回はCORDデータセットのレシートをJSON形式で正しく構造化して出力させるため、CORDデータセット用にファインチューニング(微調整)されたnaver-clova-ix/donut-base-finetuned-cord-v2を使用します。
以下のコードでは、テストデータセットの画像を入力し、実際のDonutモデルがどのように構造化データを抽出するかを確認します。
from transformers import DonutProcessor, VisionEncoderDecoderModel
# 1. デバイスの設定
device = "cuda" if torch.cuda.is_available() else "cpu"
# 2. モデルとプロセッサの読み込み (CORDデータセット用に学習済みのモデルを使用)
model_id = "naver-clova-ix/donut-base-finetuned-cord-v2"
processor = DonutProcessor.from_pretrained(model_id)
model = VisionEncoderDecoderModel.from_pretrained(model_id)
model.to(device)
# 3. データセットのロード(テストセットから最初の1件を取得)
test_sample = dataset["test"][0]
image = test_sample["image"]
# 4. タスクプロンプトの準備と前処理
# CORD用のファインチューニングモデルは "<s_cord-v2>" というプロンプトから生成を開始します
task_prompt = "<s_cord-v2>"
decoder_input_ids = processor.tokenizer(task_prompt, add_special_tokens=False, return_tensors="pt").input_ids
pixel_values = processor(image, return_tensors="pt").pixel_values
# 5. モデルによる生成(推論)の実行
print("モデルによる推論を実行中...")
outputs = model.generate(
pixel_values.to(device),
decoder_input_ids=decoder_input_ids.to(device),
max_length=model.decoder.config.max_position_embeddings,
pad_token_id=processor.tokenizer.pad_token_id,
eos_token_id=processor.tokenizer.eos_token_id,
use_cache=True,
bad_words_ids=[[processor.tokenizer.unk_token_id]],
return_dict_in_generate=True,
)
# 6. 出力トークンのデコードとJSONへのパース
sequence = processor.batch_decode(outputs.sequences)[0]
# 不要な特殊トークン(EOSやPAD)を削除
sequence = sequence.replace(processor.tokenizer.eos_token, "").replace(processor.tokenizer.pad_token, "")
# タスクプロンプト以降の文字列を抽出
sequence = sequence.split("<s_cord-v2>", 1)[-1].strip()
# DonutProcessorの便利機能:出力されたXML風のタグ構造をPythonの辞書(JSON)に自動変換
parsed_json = processor.token2json(sequence)
print("=== 実際のDonutモデルによる推論結果 ===")
print(json.dumps(parsed_json, indent=4, ensure_ascii=False))
# 7. 画像の可視化
fig, ax = plt.subplots(figsize=(8, 10))
ax.imshow(image)
ax.axis("off") # 軸の目盛りを消す
ax.set_title(f"CORD-v2 Test Sample #0", fontsize=14)
plt.show()
まず、DonutProcessorを用いて、モデルへの入力画像の前処理と出力されたトークンのデコードを一括して担当するプロセッサを初期化しています。 同様に、VisionEncoderDecoderModel.from_pretrainedで、CORDデータセット用にファインチューニングされた事前学習済みモデルをロードし、GPU(またはCPU)に転送しています。
推論の準備段階では、特定のタスク(今回はs_cord-v2)を指示するためのプロンプトを作成しています。 processor.tokenizerでこのプロンプトをID化し、processor(image, ...).pixel_valuesで画像を正規化されたテンソルに変換しています。
生成処理を行うmodel.generateでは、画像から得られたpixel_valuesと、デコーダーの初期入力となるdecoder_input_idsを渡しています。 ここで、bad_words_idsに未知語トークン(unk_token_id)を指定することで、不安定な生成を抑制しています。
最後に、得られた出力シーケンスをprocessor.batch_decodeでテキストに戻した後、processor.token2jsonという非常に便利なメソッドを使用しています。
Donutモデルは本来、XMLのようなタグ形式(例:
上記のコードを実行すると以下の結果が得られ、テスト画像とモデルによって読み取られた内容が一致することを確認できます。
モデルによる推論を実行中...
=== 実際のDonutモデルによる推論結果 ===
{
"menu": {
"nm": "- TICKET CP",
"num": "901016",
"unitprice": "60.000",
"cnt": "2",
"price": "60.000"
},
"sub_total": {
"subtotal_price": "-60.000",
"tax_price": "5,455"
},
"total": {
"total_price": "60.000",
"emoneyprice": "60.000",
"menuqty_cnt": "2.00"
}
}
dataset["test"]のインデックス0のsample["image"]

まとめ
本記事では、OCR処理を必要とせずに画像から直接構造化データを抽出できるDonutモデルについて解説しました。
記事を通じて、以下の内容を学習・実践しました。
- Donutモデルのコンセプト: 画像からテキスト領域を検出するBBOX検出を介さず、End-to-Endで直接JSONを出力する革新的なアプローチを理解しました。
- アーキテクチャの理解: Swin Transformer(Encoder)で画像特徴を抽出し、BART(Decoder)を用いてプロンプトに応じたテキスト生成を行う仕組みを学びました。
- 概念モデルのスクラッチ実装: PyTorchの
nn.TransformerEncoderとnn.TransformerDecoderを用い、Teacher Forcingなどの学習手法を含めたモデルの内部構造をコードレベルで確認しました。 - Hugging Faceによる実践: ファインチューニング済みのモデルと
DonutProcessorを活用し、実際のレシート画像から高精度にデータを抽出・パースできることを検証しました。
Donutのように、文書の視覚的情報と意味情報を同時に処理できるモデルは、複雑なレイアウトを持つ帳票やレシートの自動処理において非常に強力です。ぜひ、独自のデータセットを用いたファインチューニングなど、実務への活用も検討してみてください。
出典・ライセンスについて
本記事の文章・構成の一部に生成AIを使用しています。