本文へ移動
購読
GUIDE#ai-ml更新 2026.10.15

Tesseract OCRで日本語を読み取る:精度を左右する設定とPDFの検索可能化

Tesseract 5.5.0で自作の日本語画像を読み、傾き・解像度・影・--psm・言語データを1つずつ変えて文字誤り率を実測。OCRmyPDFでスキャンPDFを検索可能にする手順とエラー対処も扱います。

level
中級
time
約60分
stack
Ubuntu 26.04.1 LTS / Tesseract 5.5.0(tesseract-ocr 5.5.0-1build1、Leptonica 1.86.0) / tesseract-ocr-jpn・tesseract-ocr-jpn-vert 1:4.1.0-2build1(tessdata_fast 4.1.0と同一) / tessdata_best 4.1.0 / OCRmyPDF 16.13.0 / Ghostscript 10.06.0 / ImageMagick 7.1.2-18 / poppler-utils 26.01.0 / fonts-ipaexfont-mincho 00401-5ubuntu1 / Pillow 12.1.1 / ReportLab 4.4.10 / Python 3.14.4 / perl 5.40.1
author
@ryoma_t
// 前提条件
Dockerでubuntu:26.04のコンテナを起動できること / シェルのforループとパイプが読めること / メモリ1GB・ディスク1GB程度の空き
Tesseractで日本語の画像を読み取り、傾きの角度ごと・補正後・--psmごとの文字誤り率を測った結果が並ぶターミナル画面
画像:nagoya.dev(実際の画面・実行結果をもとに作成)

Tesseract OCRとは、画像に写った文字をテキストに変換するオープンソースのOCRエンジンで、ライセンスはApache License 2.0です。言語ごとの学習済みデータを追加すると日本語も読めますが、PDFを直接読むことはできません。ここではUbuntu 26.04のパッケージ版(Tesseract 5.5.0)で、正解のわかっている日本語の画像を自作し、設定を1つずつ変えながら文字誤り率を測りました。今回の画像では、傾きや影があると既定の設定のままでは大きく読み誤り、前処理をかけると誤りはきれいな画像に近い数まで減りました。

対象は、紙の書類やスキャンPDFを検索できる形にしたい情シス・社内SEの方です。読み終えると、解像度・傾き・ノイズ・影、ページ分割モード(--psm)、言語データ、文字間の空白について「変えたら何が起きたか」を数字で確認でき、OCRmyPDFでスキャンPDFに検索用の文字を重ねる手順まで手元で再現できます。数字はどれも今回作った画像での結果で、Tesseract一般の精度や、お手元の書類での精度を示すものではありません。

この記事でわかること

  • Ubuntu 26.04にTesseract 5.5.0と日本語データ(jpn・jpn_vert)を入れる手順と、入れ忘れたときのエラー
  • 文字誤り率(CER)を測る短いスクリプトと、解像度・傾き・ノイズ・影を変えた画像での実測値
  • ImageMagickの前処理、--psm、言語データ(jpn+eng・jpn_vert・tessdata_best)、空白の設定を変えたときの誤り率の変化
  • OCRmyPDFでスキャンPDFを検索可能にする手順、ファイルサイズの変化、--skip-textと--force-ocrの違い
  • Tesseract・深層学習OCR・クラウドのOCR APIを選ぶときの判断基準(機密性・手書き・処理量)

検証環境:コンテナとパッケージ

検証はDockerのubuntu:26.04コンテナ1つで行いました。コンテナ名tesseract-ocr-japanese-pdf-lab、ホスト名 nagoyadevlab01、作業ユーザー ryoma、作業ディレクトリ~/ocr-labは、どれも検証用に付けた名前です。コマンドや出力に出てくるこれらの名前と/home/ryomaは、自分の環境のものに読み替えてください。Dockerホストで次のコマンドを実行してコンテナを起動します(表示されるコンテナIDは省いています)。

terminal(Dockerホスト)
docker run -d --memory 1g --cpus 2 --name tesseract-ocr-japanese-pdf-lab --hostname nagoyadevlab01 ubuntu:26.04 sleep infinity

--memory 1gと--cpus 2は共用サーバーで動かすための上限で、OCRそのものには要りません。ポートの公開も--privilegedも使いません。docker exec -it tesseract-ocr-japanese-pdf-lab bashで root として入り、パッケージを入れて作業ユーザーを作ります。

terminal(コンテナ内・root)
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends tesseract-ocr ocrmypdf imagemagick poppler-utils fonts-ipaexfont-mincho python3-pil python3-reportlab time curl ca-certificates >/dev/null 2>&1; echo "exit=$?"
exit=0
useradd -m -s /bin/bash ryoma && id ryoma
uid=1001(ryoma) gid=1001(ryoma) groups=1001(ryoma)
grep PRETTY_NAME /etc/os-release
PRETTY_NAME="Ubuntu 26.04.1 LTS"
tesseract --version | head -n 3
tesseract 5.5.0
 leptonica-1.86.0
  libgif 5.2.2 : libjpeg 8d (libjpeg-turbo 2.1.5) : libpng 1.6.57 : libtiff 4.7.0 : zlib 1.3.1 : libwebp 1.5.0 : libopenjp2 2.5.4
tesseract --list-langs
List of available languages in "/usr/share/tesseract-ocr/5/tessdata/" (2):
eng
osd
ocrmypdf --version; magick -version | head -n 1; gs --version; python3 --version
16.13.0+dfsg1
Version: ImageMagick 7.1.2-18 Q16 x86_64 23822 https://imagemagick.org
10.06.0
Python 3.14.4
dpkg-query -W tesseract-ocr tesseract-ocr-eng tesseract-ocr-osd libleptonica6 ocrmypdf ghostscript imagemagick poppler-utils fonts-ipaexfont-mincho python3-pil python3-reportlab perl-base time curl
curl	8.18.0-1ubuntu2.7
fonts-ipaexfont-mincho	00401-5ubuntu1
ghostscript	10.06.0~dfsg-3ubuntu1.1
imagemagick	8:7.1.2.18+dfsg1-1
libleptonica6:amd64	1.86.0-1
ocrmypdf	16.13.0+dfsg1-1
perl-base	5.40.1-7ubuntu0.3
poppler-utils	26.01.0-2ubuntu0.2
python3-pil:amd64	12.1.1-2ubuntu1.3
python3-reportlab	4.4.10-1
tesseract-ocr	5.5.0-1build1
tesseract-ocr-eng	1:4.1.0-2build1
tesseract-ocr-osd	1:4.1.0-2build1
time	1.9-0.4
apt-cache depends ocrmypdf | grep Recommends
  Recommends: unpaper
  Recommends: pngquant
  Recommends: jbig2

tesseract-ocrを入れると、英語(eng)と、向きや文字種の判定に使うデータ(osd)が一緒に入ります。tesseract --list-langsが示すとおり、この時点で日本語のデータはありません(あとでエラーを確認してから追加します)。--versionの出力の最後の行は、画像の読み込みを受け持つLeptonicaが使えるライブラリです。公式ドキュメントも、Tesseractが対応する入力形式としてPNG・JPEG・TIFF・JPEG 2000・GIF・WebP・BMP・PNMを挙げ、PDFの読み込みには対応しないと書いています。

--no-install-recommendsを付けたので、OCRmyPDFの推奨パッケージ(unpaper・pngquant・jbig2)は入っていません。これらを使う機能は今回の検証の範囲外です。timeは処理時間と最大メモリを測る/usr/bin/time、curlとca-certificatesは後半でモデルファイルを取得するために入れています。

テスト画像と正解テキストを作る

OCRの精度を測るには、正解のテキストが要ります。そこで文面を自分たちで書き、IPAex明朝(fonts-ipaexfont-minchoパッケージ)で画像に描くPythonスクリプトを用意しました。文字の大きさは、10.5ptの文字を300dpiで取り込んだ場合にあたる44ピクセル、用紙はA4(2480×3508ピクセル)です。文面はすべて架空の例で、実在の組織や人物とは関係ありません。

ここからはdocker exec -it -u ryoma -w /home/ryoma tesseract-ocr-japanese-pdf-lab bashで作業ユーザーとして入り直します。このコンテナにはエディタが無いので、ファイルはヒアドキュメントで書き込みます。以降のコマンドは、すべて~/ocr-labをカレントディレクトリにして実行したものです。出力は、端末を割り当てないdocker execでコマンドを1つずつshに渡して記録しました。端末で直接実行したときの表示(ocrmypdfの進行状況の表示など)は確認していません。

terminal
mkdir -p ~/ocr-lab && cd ~/ocr-lab
cat > make_images.py <<'EOF'
"""OCRの検証用画像と正解テキストを作る(文面はすべて架空の例)"""
import random

from PIL import Image, ImageDraw, ImageFont

FONT = "/usr/share/fonts/opentype/ipaexfont-mincho/ipaexm.ttf"
DPI = 300
A4 = (2480, 3508)   # A4を300dpiで取り込んだときのピクセル数
SIZE = 44           # 10.5ptの文字を300dpiで描いたときの大きさ(ピクセル)
PITCH = 72          # 行送り
font = ImageFont.truetype(FONT, SIZE)
big = ImageFont.truetype(FONT, 60)

TITLE = "社内ネットワーク機器更新のお知らせ"
PARAS = [
    "情報システム課では、本社3階のサーバー室にあるルーターとスイッチを、2026年11月14日(土)に新しい機種へ入れ替えます。"
    "作業は午前9時から午後5時までを予定しており、その間は有線LANとWi-Fiの両方が断続的に使えなくなります。",
    "当日に出社する予定の方は、前日までに所属と氏名を課の受付票へ記入してください。"
    "VPNで自宅から接続している方も、同じ時間帯は社内のファイルサーバーと勤怠システムに接続できません。"
    "急ぎの業務がある場合は、作業の前日までに必要なファイルを手元のPCへ保存しておいてください。",
    "入れ替えのあとは、会議室の無線アクセスポイントの名前が「KAIGI-5G」から「HQ-Meeting」に変わります。"
    "パスワードは変更しませんが、古い名前の設定はPCとスマートフォンから削除してください。"
    "接続できないときは、内線4021の窓口へ連絡をお願いします。",
]
TATE = ("拝啓 秋冷の候、皆様にはますますご健勝のこととお喜び申し上げます。"
        "さて、当社は創業三十周年を迎えるにあたり、十一月二十日に本社一階の展示室で記念の見学会を開きます。"
        "当日は製品の組立工程を公開し、担当の技術者が質問にお答えします。"
        "ご多用のところ恐縮ですが、ご来場をお待ちしております。 敬具")
LEFT = ("共有フォルダの整理について。部署ごとの共有フォルダは、年度の終わりに不要なファイルを削除します。"
        "三年以上更新のない資料は保管用の領域へ移し、契約書と議事録は原本の所在を台帳に記録してください。")
RIGHT = ("貸出用の機材について。プロジェクターとWebカメラは総務課の棚で管理しています。"
         "利用する前日までに予約表へ記入し、返却のときは付属のケーブルがそろっているかを確認してください。")
TABLE = [["品名", "数量", "単価(円)", "保管場所"],
         ["ノートPC", "12", "148,000", "第2倉庫"],
         ["液晶モニター", "8", "32,500", "第2倉庫"],
         ["ドッキングステーション", "6", "19,800", "3階書庫"],
         ["会議用スピーカー", "3", "27,000", "総務課"]]
LINE = "件名:サーバー室の空調設備点検に伴う停電のお知らせ"


def wrap(text, fits):
    """fits(行)が偽になる手前で折り返す。英数字の途中では切らず、行頭に句読点や閉じ括弧を置かない。"""
    lines, cur = [], ""
    for ch in text:
        keep = ch in "、。」)" or (ch.isascii() and cur[-1:].isascii())
        if cur and not fits(cur + ch) and not keep:
            lines.append(cur)
            cur = ""
        cur += ch
    return lines + [cur]


def save(img, name):
    img.save(name, dpi=(DPI, DPI))


def write(name, text):
    with open(name, "w", encoding="utf-8") as fh:
        fh.write(text + "\n")


# page.png: A4・横書き・明朝10.5pt相当
page = Image.new("L", A4, 255)
d = ImageDraw.Draw(page)
d.text((240, 300), TITLE, font=big, fill=0)
y = 480
for para in PARAS:
    for line in wrap(para, lambda s: font.getlength(s) <= 2000):
        d.text((240, y), line, font=font, fill=0)
        y += PITCH
    y += PITCH // 2
save(page, "page.png")
write("page.txt", "\n".join([TITLE] + PARAS))

# page-noise.png: 全画素の4%にあたる回数だけ、乱数で選んだ画素を白か黒に置き換える(乱数の種は固定)
rnd = random.Random(20261013)
noise = page.copy()
px = noise.load()
for _ in range(A4[0] * A4[1] * 4 // 100):
    px[rnd.randrange(A4[0]), rnd.randrange(A4[1])] = rnd.choice((0, 255))
save(noise, "page-noise.png")

# page-shade.png: 背景が左から右へ暗くなり、文字は背景より少し暗いだけ(影の入った写真の代役)
shade = Image.new("L", A4, 255)
sp, pp = shade.load(), page.load()
for yy in range(A4[1]):
    for xx in range(A4[0]):
        bg = 235 - 150 * xx // A4[0]
        sp[xx, yy] = bg if pp[xx, yy] > 128 else bg - 70
save(shade, "page-shade.png")

# tate.png: 縦書き(右の行から左へ)
tate = Image.new("L", (1500, 1700), 255)
d = ImageDraw.Draw(tate)
x = 1300
for line in wrap(TATE, lambda s: len(s) <= 30):
    d.text((x, 160), line, font=font, fill=0, direction="ttb")
    x -= PITCH + 12
save(tate, "tate.png")
write("tate.txt", TATE)

# two-col.png: 2段組み
col = Image.new("L", (2480, 900), 255)
d = ImageDraw.Draw(col)
for x0, text in ((240, LEFT), (1320, RIGHT)):
    y = 160
    for line in wrap(text, lambda s: font.getlength(s) <= 900):
        d.text((x0, y), line, font=font, fill=0)
        y += PITCH
save(col, "two-col.png")
write("two-col.txt", LEFT + "\n" + RIGHT)

# table.png: 罫線つきの表
tbl = Image.new("L", (2480, 800), 255)
d = ImageDraw.Draw(tbl)
y = 160
for row in TABLE:
    x = 240
    for w, cell in zip((1040, 220, 340, 400), row):
        d.rectangle([x, y, x + w, y + 96], outline=0, width=3)
        d.text((x + 24, y + 24), cell, font=font, fill=0)
        x += w
    y += 96
save(tbl, "table.png")
write("table.txt", "\n".join(" ".join(r) for r in TABLE))

# line.png: 1行だけを切り出した画像
one = Image.new("L", (int(font.getlength(LINE)) + 80, 100), 255)
ImageDraw.Draw(one).text((40, 28), LINE, font=font, fill=0)
save(one, "line.png")
write("line.txt", LINE)
EOF
terminal
python3 make_images.py && wc -c *.png
  10450 line.png
 569315 page-noise.png
  68647 page-shade.png
 167544 page.png
  38626 table.png
  74638 tate.png
  75834 two-col.png
1005054 total
  • page.png:A4・横書きの「お知らせ」。漢字かな交じりの文に、英数字(LAN、Wi-Fi、VPN、KAIGI-5G など)と全角の括弧を混ぜてあります
  • page-noise.png:page.pngの画素を乱数で白か黒に置き換えたもの。置き換える回数は全画素数の4%で、乱数の種は固定しています
  • page-shade.png:背景が左から右へ暗くなり、文字と背景の明るさの差が小さい画像(影の入った写真の代役)
  • tate.png:縦書きの挨拶文(Pillowのdirection="ttb"で描画)
  • two-col.png:2段組みの文章
  • table.png:罫線つきの表(品名・数量・単価・保管場所)
  • line.png:1行だけの件名

劣化させた画像はImageMagickで作ります。-resize 50%と-resize 25%は、同じ紙を150dpi・75dpiで取り込んだ場合の代役で、-densityでPNGに記録される解像度の値も書き換えています。-distort SRTは、画像の大きさを変えずに中心のまわりで回転させる指定で、1・2・3・5・10度の傾きを作りました。line-trim.pngは、line.pngの余白を-trimで削り、文字ぎりぎりで切り抜いた画像です。

terminal
magick page.png -resize 50% -units PixelsPerInch -density 150 page-150.png
magick page.png -resize 25% -units PixelsPerInch -density 75 page-75.png
for a in 1 2 3 5 10; do magick page.png -virtual-pixel white -distort SRT $a page-skew$a.png; done
magick line.png -trim +repage line-trim.png
magick identify -format '%f %wx%h\n' page.png page-150.png page-75.png page-skew2.png tate.png two-col.png table.png line.png line-trim.png
page.png 2480x3508
page-150.png 1240x1754
page-75.png 620x877
page-skew2.png 2480x3508
tate.png 1500x1700
two-col.png 2480x900
table.png 2480x800
line.png 1180x100
line-trim.png 1097x41

フォントで描いた文字は、紙を実際にスキャンした画像とは別物です。学習の条件については、tessdata_fastのREADMEに、jpnのモデルは日本語を描画できるフォントだけで学習したと書かれています。今回の画像も同じくフォントで描いたものです。紙をスキャンした画像では別の数字になるので、以降の数字は「設定を変えると誤りがどちらに動くか」を見るためのものとして読んでください。

文字誤り率(CER)を測るスクリプト

文字誤り率(CER:Character Error Rate)は、OCRの結果を正解に直すのに必要な挿入・削除・置換の回数(編集距離)を、正解の文字数で割った値です。次のcer.pyは、空白と改行を取り除いてから編集距離を数えます。行の折り返し位置や文字間の空白は誤りに数えず、全角と半角の違い(「(」と「(」など)は誤りに数える、という決めごとです。空白は別に、日本語の文字どうしの間に入った半角スペースの箇所をgapsとして数えます。

terminal
cat > cer.py <<'EOF'
"""文字誤り率(CER)= 編集距離 ÷ 正解の文字数。空白と改行は比べる前に取り除く。
使い方: python3 cer.py 正解.txt OCR結果.txt(- なら標準入力) [ラベル] [-v]"""
import difflib
import re
import sys

args = [a for a in sys.argv[1:] if a != "-v"]
raw = sys.stdin.buffer.read().decode("utf-8") if args[1] == "-" else open(args[1], encoding="utf-8").read()
ref = "".join(open(args[0], encoding="utf-8").read().split())
hyp = "".join(raw.split())

prev = list(range(len(hyp) + 1))
for i, a in enumerate(ref, 1):
    cur = [i]
    for j, b in enumerate(hyp, 1):
        cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a != b)))
    prev = cur
dist = prev[-1]

# gaps: 日本語の文字どうしの間に入った半角スペースの箇所の数
gaps = len(re.findall(r"(?<=[^\x00-\x7f]) +(?=[^\x00-\x7f])", raw))
label = args[2].ljust(18) if len(args) > 2 else ""
print(f"{label}CER={100 * dist / len(ref):.2f}% dist={dist} ref={len(ref)} hyp={len(hyp)} gaps={gaps}")
if "-v" in sys.argv:
    for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, ref, hyp, autojunk=False).get_opcodes():
        if tag != "equal":
            print(f"  正解「{ref[max(i1 - 2, 0):i2 + 2]}」→ OCR「{hyp[max(j1 - 2, 0):j2 + 2]}」")
EOF

第2引数に-を渡すと標準入力を読みます。第3引数は行頭に付けるラベルで、-vを付けると食い違った箇所を前後2文字と一緒に表示します。出力のdistが編集距離、refが正解の文字数、hypがOCR結果の文字数です。

日本語データを入れてTesseract OCRで1枚読む

まず、日本語データを入れないまま-l jpnを指定するとどうなるかを見ておきます。apt install tesseract-ocrだけで始めたときに最初に出会うエラーです。

terminal(エラーになる)
tesseract page.png out -l jpn; echo "exit=$?"
Error opening data file /usr/share/tesseract-ocr/5/tessdata/jpn.traineddata
Please make sure the TESSDATA_PREFIX environment variable is set to your "tessdata" directory.
Failed loading language 'jpn'
Tesseract couldn't load any languages!
Could not initialize tesseract.
exit=1

-lで指定された言語のjpn.traineddataを、Tesseractはデータディレクトリ(このパッケージでは/usr/share/tesseract-ocr/5/tessdata/)から開こうとし、開けないと初期化に失敗して終了コード1で終わります。メッセージはTESSDATA_PREFIXの設定を促しますが、この環境で足りなかったのは環境変数ではなく言語パッケージでした。root のシェルで、横書き用のjpnと縦書き用のjpn_vertを追加します。

terminal(コンテナ内・root)
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends tesseract-ocr-jpn tesseract-ocr-jpn-vert >/dev/null 2>&1; echo "exit=$?"
exit=0
tesseract --list-langs
List of available languages in "/usr/share/tesseract-ocr/5/tessdata/" (4):
eng
jpn
jpn_vert
osd
dpkg-query -W tesseract-ocr-jpn tesseract-ocr-jpn-vert
tesseract-ocr-jpn	1:4.1.0-2build1
tesseract-ocr-jpn-vert	1:4.1.0-2build1

作業ユーザーのシェルに戻って同じコマンドを実行すると、今度は何も表示せずに終わり、out.txtができます(出力名に-を指定すると標準出力に書きます)。

terminal
tesseract page.png out -l jpn
cat out.txt
社内ネットワーク機和器更新のお知らせ

情報システム課では、本社3階のサーバー室にあるルーターとスイッチを、2026年11月14日 (土)
こ新しい機種へ入れ替えます。 作業は午前9時から午後5時までを予定しており、その間は有線LANと
Wi-Riの両方が断続的に使えなくなります。

当日に出社する予定の方は、前日までに所属と氏名を課の受付票へ記入してください。VPNで自宅か
ら接続している方も、同じ時間帯は社内のファイルサーバーと勤怠システムに接続できません。急ぎ
の業務がある場合は、作業の前日までに必要なファイルを手元のPCへ保存しておいてください。

入れ替えのあとは、会議室の無線アクセスポイントの名前が「KAIGI-5G」から「HQ-Meeting」 に
変わります。パスワードは変更しませんが、古い名前の設定はPCとスマートフォンから削除してくだ
さい。 接続できないときは、内線4021の窓口へ連絡をお願いします。
python3 cer.py page.txt out.txt -v
CER=1.25% dist=5 ref=400 hyp=401 gaps=3
  正解「ク機器更」→ OCR「ク機和器更」
  正解「4日(土)」→ OCR「4日(土)」
  正解「(土)に新し」→ OCR「(土)こ新し」
  正解「i-Fiの」→ OCR「i-Riの」

400文字のうち誤りは5文字で、CERは1.25%でした。-vの表示から内訳を拾うと、「機器」の間に余分な「和」が入った1文字、全角の括弧「(」「)」が半角になった2文字、行頭の「に」を「こ」と読んだ1文字、「Wi-Fi」を「Wi-Ri」と読んだ1文字です。全角と半角を区別しない用途なら、気にすべき誤りは3文字ということになります。

解像度・傾き・ノイズ・影でどれだけ崩れるか

同じ文面の画像を、追加の設定なし(-l jpnだけ)で読ませます。ループの中では、Tesseractが標準エラー出力に出すメッセージを2>/dev/nullで捨てています。メッセージの中身は「つまずきポイント」で個別に見ます。

terminal
for f in page page-150 page-75; do tesseract $f.png - -l jpn 2>/dev/null | python3 cer.py page.txt - $f; done
page              CER=1.25% dist=5 ref=400 hyp=401 gaps=3
page-150          CER=1.50% dist=6 ref=400 hyp=398 gaps=2
page-75           CER=6.00% dist=24 ref=400 hyp=397 gaps=2
for a in 1 2 3 5 10; do tesseract page-skew$a.png - -l jpn 2>/dev/null | python3 cer.py page.txt - skew$a; done
skew1             CER=3.00% dist=12 ref=400 hyp=400 gaps=2
skew2             CER=6.00% dist=24 ref=400 hyp=396 gaps=15
skew3             CER=37.75% dist=151 ref=400 hyp=303 gaps=14
skew5             CER=74.75% dist=299 ref=400 hyp=274 gaps=4
skew10            CER=100.00% dist=400 ref=400 hyp=0 gaps=0
for f in page-noise page-shade; do tesseract $f.png - -l jpn 2>/dev/null | python3 cer.py page.txt - $f; done
page-noise        CER=29.25% dist=117 ref=400 hyp=340 gaps=19
page-shade        CER=60.75% dist=243 ref=400 hyp=217 gaps=2

解像度は、150dpi相当に落としても1.50%でほとんど変わらず、75dpi相当で6.00%まで上がりました。この文面でいちばん効いたのは傾きです。1度で3.00%、2度で6.00%、3度で37.75%、5度で74.75%と急に悪くなり、10度ではhyp=0、つまり1文字も出力されませんでした。ノイズを加えた画像は29.25%、影のある画像は60.75%です。

傾きは見落としやすい原因です。今回のページでは、2度の傾きで誤りが5文字から24文字に、3度で151文字に増えました。原稿台に斜めに置いた紙や、給紙のときに斜めに入った紙は、次の節の傾き補正を通してからOCRにかけてください。

ImageMagickの前処理でどこまで戻せるか

解像度:拡大してから読ませる

解像度の低い画像を、300dpi相当の大きさになるように拡大してから読ませます。

terminal
magick page-150.png -resize 200% page-150-up.png
magick page-75.png -resize 400% page-75-up.png
for f in page-150-up page-75-up; do tesseract $f.png - -l jpn 2>/dev/null | python3 cer.py page.txt - $f; done
page-150-up       CER=0.75% dist=3 ref=400 hyp=400 gaps=3
page-75-up        CER=1.50% dist=6 ref=400 hyp=402 gaps=1

75dpi相当の画像は6.00%から1.50%に、150dpi相当の画像は1.50%から0.75%になりました。300dpi以上の画像を使うよう、Tesseractのドキュメントは勧めています。今回の2枚では、スキャンし直さなくても、拡大して文字の大きさをそろえるだけで誤りが減りました。--dpiオプションで解像度の数値だけを伝える方法も試しました。

terminal
for d in 70 300 600; do tesseract page-75.png - -l jpn --dpi $d 2>/dev/null | python3 cer.py page.txt - page-75,dpi=$d; done
page-75,dpi=70    CER=6.00% dist=24 ref=400 hyp=397 gaps=2
page-75,dpi=300   CER=6.00% dist=24 ref=400 hyp=397 gaps=2
page-75,dpi=600   CER=35.00% dist=140 ref=400 hyp=301 gaps=6

75dpi相当の画像に--dpi 70や--dpi 300を付けても6.00%のままで、実際とかけ離れた--dpi 600では35.00%に悪化しました。この画像では、--dpiは拡大の代わりにならず、誤った値は害になりました。

傾き:-deskewで補正する

ImageMagickの-deskewは、画像の傾きを推定してまっすぐに直すオプションです。しきい値には、公式ドキュメントが多くの画像に合うとしている40%を使いました。傾けていないpage.pngにも同じ処理をかけ、副作用がないかを見ます。

terminal
for a in 1 2 3 5 10; do magick page-skew$a.png -deskew 40% +repage page-skew$a-fix.png; done
magick page.png -deskew 40% +repage page-fix.png
for a in 1 2 3 5 10; do tesseract page-skew$a-fix.png - -l jpn 2>/dev/null | python3 cer.py page.txt - skew$a-fix; done
skew1-fix         CER=0.50% dist=2 ref=400 hyp=400 gaps=4
skew2-fix         CER=1.00% dist=4 ref=400 hyp=400 gaps=2
skew3-fix         CER=1.00% dist=4 ref=400 hyp=401 gaps=2
skew5-fix         CER=1.00% dist=4 ref=400 hyp=400 gaps=3
skew10-fix        CER=21.25% dist=85 ref=400 hyp=347 gaps=20
tesseract page-fix.png - -l jpn 2>/dev/null | python3 cer.py page.txt - page-fix
page-fix          CER=1.25% dist=5 ref=400 hyp=401 gaps=3

1〜5度の画像は0.50%〜1.00%になり、傾けていないpage.pngに同じ処理をかけたpage-fix.pngは1.25%のまま変わりませんでした。10度の画像は21.25%までしか戻っていません。補正をせずに--psm 6(全体を1つのまとまった文章とみなす設定。次の節で扱います)を指定する方法も比べておきます。

terminal
for a in 2 3 5 10; do tesseract page-skew$a.png - -l jpn --psm 6 2>/dev/null | python3 cer.py page.txt - skew$a,psm=6; done
skew2,psm=6       CER=2.75% dist=11 ref=400 hyp=400 gaps=15
skew3,psm=6       CER=8.00% dist=32 ref=400 hyp=392 gaps=22
skew5,psm=6       CER=13.75% dist=55 ref=400 hyp=391 gaps=39
skew10,psm=6      CER=49.75% dist=199 ref=400 hyp=347 gaps=62

--psm 6だけでも2度で2.75%、3度で8.00%、5度で13.75%まで下がりましたが、-deskewには届きません。--psm 6は、今回の2段組みと罫線つきの表の読み取りを壊したので(次の節)、傾きにはまず-deskewを使うことを勧めます。

ノイズ:メディアンフィルタは効いたが、きれいなページでは誤りが増えた

terminal
magick page-noise.png -statistic Median 3x3 page-noise-m3.png; magick page-noise.png -statistic Median 5x5 page-noise-m5.png; magick page-noise.png -despeckle page-noise-ds.png; magick page.png -statistic Median 3x3 page-m3.png
for f in page-noise-m3 page-noise-m5 page-noise-ds page-m3; do tesseract $f.png - -l jpn 2>/dev/null | python3 cer.py page.txt - $f; done
page-noise-m3     CER=6.00% dist=24 ref=400 hyp=397 gaps=5
page-noise-m5     CER=28.00% dist=112 ref=400 hyp=327 gaps=3
page-noise-ds     CER=19.50% dist=78 ref=400 hyp=376 gaps=16
page-m3           CER=5.75% dist=23 ref=400 hyp=407 gaps=2

3×3のメディアンフィルタ(各画素を周囲9画素の中央値に置き換える処理)で、ノイズ画像は29.25%から6.00%に下がりました。範囲を5×5に広げると28.00%で、かける前とほとんど変わりません。-despeckleは19.50%でした。一方、同じ3×3をノイズのないpage.pngにかけると、1.25%だったCERが5.75%に悪化します。処理後の画像を拡大して見ると、明朝体の細い横線が消えたり途切れたりしていました。

影:しきい値を場所ごとに決める

terminal
magick page-shade.png -threshold 50% page-shade-t50.png; magick page-shade.png -lat 50x50-5% page-shade-lat.png; magick page.png -lat 50x50-5% page-lat.png; magick page-noise.png -lat 50x50-5% page-noise-lat.png
for f in page-shade-t50 page-shade-lat page-lat page-noise-lat; do tesseract $f.png - -l jpn 2>/dev/null | python3 cer.py page.txt - $f; done
page-shade-t50    CER=42.50% dist=170 ref=400 hyp=236 gaps=1
page-shade-lat    CER=1.75% dist=7 ref=400 hyp=401 gaps=3
page-lat          CER=1.00% dist=4 ref=400 hyp=400 gaps=4
page-noise-lat    CER=21.75% dist=87 ref=400 hyp=400 gaps=17

画像全体を1つのしきい値で白と黒に分ける-threshold 50%では42.50%にしかなりません。処理後の画像を見ると、明るい左側では薄い文字が白く飛び、暗い右側では背景ごと黒くなっていて、読める形で残ったのは中央の帯だけでした。-lat 50x50-5%は、各画素を、その周囲50×50画素から求めた基準の明るさと比べて白黒を決める局所的な二値化で、こちらは1.75%まで下がりました。影のないpage.pngに同じ-latをかけた結果は1.00%で、悪化はしていません。ただし、影のない2段組みのtwo-col.pngに同じ-latをかけると、0.00%だった誤り率が77.17%になりました。ノイズ画像にかけた場合は21.75%で、ノイズへの効き目は3×3のメディアンフィルタに及びませんでした。

二値化の方式は、Tesseract自身のthresholding_methodというパラメータでも選べます。パラメータの説明は--print-parametersで確認できます。

terminal
tesseract --print-parameters 2>/dev/null | grep -E '^(thresholding_method|preserve_interword_spaces|tessedit_load_sublangs)'
thresholding_method	0	Thresholding method: 0 = Otsu, 1 = LeptonicaOtsu, 2 = Sauvola
preserve_interword_spaces	0	Preserve multiple interword spaces
tessedit_load_sublangs		List of languages to load with this one
for m in 0 1 2; do tesseract page-shade.png - -l jpn -c thresholding_method=$m 2>/dev/null | python3 cer.py page.txt - method=$m; done
method=0          CER=60.75% dist=243 ref=400 hyp=217 gaps=2
method=1          CER=54.50% dist=218 ref=400 hyp=379 gaps=14
method=2          CER=49.25% dist=197 ref=400 hyp=389 gaps=2

影の画像では、既定の0(Otsu)が60.75%、1(LeptonicaOtsu)が54.50%、2(Sauvola)が49.25%でした。この画像に限れば、Tesseractの設定を変えるより、ImageMagickで局所的に二値化してから渡すほうがはっきり良い結果です。

前処理は、必要な画像にだけかけてください。今回の結果では、3×3のメディアンフィルタはノイズのない画像のCERを1.25%から5.75%に悪化させ、5×5ではノイズ画像への効果もほぼ消えました。全ページに一律でかける前に、数枚でcer.pyを回して確かめるのが安全です。

ページ分割モード(--psm)を変えるべきレイアウト

--psm(page segmentation mode)は、画像のどこに、どんな形で文字が並んでいるとみなすかを指定するオプションです。既定は3で、一覧はtesseract --help-psmで出ます。

terminal
tesseract --help-psm
Page segmentation modes (PSM):
  0|osd_only                Orientation and script detection (OSD) only.
  1|auto_osd                Automatic page segmentation with OSD.
  2|auto_only               Automatic page segmentation, but no OSD, or OCR. (not implemented)
  3|auto                    Fully automatic page segmentation, but no OSD. (Default)
  4|single_column           Assume a single column of text of variable sizes.
  5|single_block_vert_text  Assume a single uniform block of vertically aligned text.
  6|single_block            Assume a single uniform block of text.
  7|single_line             Treat the image as a single text line.
  8|single_word             Treat the image as a single word.
  9|circle_word             Treat the image as a single word in a circle.
 10|single_char             Treat the image as a single character.
 11|sparse_text             Sparse text. Find as much text as possible in no particular order.
 12|sparse_text_osd         Sparse text with OSD.
 13|raw_line                Raw line. Treat the image as a single text line,
                            bypassing hacks that are Tesseract-specific.

日本語なら--psm 6を付けておけばよい、とは言えません。2段組み、表、1行だけの画像で比べます。

terminal
for p in 3 4 6; do tesseract two-col.png - -l jpn --psm $p 2>/dev/null | python3 cer.py two-col.txt - two-col,psm=$p; done
two-col,psm=3     CER=0.00% dist=0 ref=184 hyp=184 gaps=2
two-col,psm=4     CER=77.17% dist=142 ref=184 hyp=184 gaps=9
two-col,psm=6     CER=77.17% dist=142 ref=184 hyp=184 gaps=7
tesseract two-col.png - -l jpn --psm 6 2>/dev/null | head -n 2
共有フォルダの整理について。部署ごとの共      貸出用の機材について。 プロジェクターとWeb
有フォルダは、年度の終わりに不要なファイ            カメラは総務課の棚で管理しています。 利用

2段組みは、既定の3で0.00%、4(1段とみなす)と6(1つのブロックとみなす)では77.17%でした。hypは3つとも正解と同じ184文字です。--psm 6の出力の先頭を見ると、左の段と右の段の行が横につながっていて、文章の順序が崩れています。

terminal
for p in 3 4 6 11; do tesseract table.png - -l jpn --psm $p 2>/dev/null | python3 cer.py table.txt - table,psm=$p; done
table,psm=3       CER=4.55% dist=4 ref=88 hyp=89 gaps=2
table,psm=4       CER=2.27% dist=2 ref=88 hyp=88 gaps=2
table,psm=6       CER=96.59% dist=85 ref=88 hyp=19 gaps=0
table,psm=11      CER=9.09% dist=8 ref=88 hyp=84 gaps=1
tesseract table.png - -l jpn --psm 4 2>/dev/null
品名                                                                          数量          単価 (円)            保管場所

ノートPC                                                    12           148,000          第2倉庫
液晶モニター                                         8          32,500          第2倉庫
ドッキングステーション                  6      19,800      3階書庫

会議用スピーカー                              3         27,000         総務課
tesseract table.png - -l jpn --psm 6 2>/dev/null
les ーー ss [sw
Ipクー sw [ww
tesseract table.png - -l jpn 2>/dev/null | python3 cer.py table.txt - table,psm=3 -v
table,psm=3       CER=4.55% dist=4 ref=88 hyp=89 gaps=2
  正解「単価(円)」→ OCR「単価(円)」
  正解「(円)保管」→ OCR「(円)保管」
  正解「8,00」→ OCR「8,.00」
  正解「27,00」→ OCR「27.00」

罫線つきの表は、3で4.55%、4で2.27%、11(散らばった文字を順不同で拾う)で9.09%、6では96.59%でした。6の出力は意味のない文字列だけです。4の出力は列の間に空白を入れて行ごとに並び、数字はすべて正解どおりでした(誤りの2文字は、半角になった括弧です)。既定の3では、-vの表示のとおり「148,000」が「148,.000」に、「27,000」が「27.000」になっています。金額を拾う用途では、こうした1文字の違いがそのまま間違った数字になります。

terminal
magick line-trim.png -bordercolor white -border 10 line-border.png
for f in line line-trim line-border; do for p in 3 7; do tesseract $f.png - -l jpn --psm $p 2>/dev/null | python3 cer.py line.txt - $f,psm=$p; done; done
line,psm=3        CER=4.00% dist=1 ref=25 hyp=25 gaps=0
line,psm=7        CER=4.00% dist=1 ref=25 hyp=25 gaps=0
line-trim,psm=3   CER=100.00% dist=25 ref=25 hyp=0 gaps=0
line-trim,psm=7   CER=4.00% dist=1 ref=25 hyp=25 gaps=1
line-border,psm=3 CER=4.00% dist=1 ref=25 hyp=25 gaps=0
line-border,psm=7 CER=4.00% dist=1 ref=25 hyp=25 gaps=0

余白のあるline.pngは3でも7(1行とみなす)でも4.00%です。文字ぎりぎりで切り抜いたline-trim.pngは、既定の3では1文字も出ず(100.00%)、7で4.00%になりました。切り抜いた画像に10ピクセルの白い余白を足したline-border.pngは、3のままで読めます。今回の画像では、--psm 7を指定するか、余白を残して切り抜くかのどちらでも読めるようになりました。

今回の結果から決めた使い分けは次のとおりです。1ページ全体の文書は既定のまま(--psmを指定しない)。1行だけの切り抜きは7。罫線のある表だけの画像は4を試す。6は2段組みと表の両方を壊したので、段組みも表もない文書に限って使います。

言語データを変える:jpn+eng、縦書き、tessdata_best

英数字まじりの文にjpn+engは効くか

terminal
for l in jpn jpn+eng eng+jpn; do tesseract page.png - -l $l 2>/dev/null | python3 cer.py page.txt - $l; done
jpn               CER=1.25% dist=5 ref=400 hyp=401 gaps=3
jpn+eng           CER=1.50% dist=6 ref=400 hyp=401 gaps=3
eng+jpn           CER=6.00% dist=24 ref=400 hyp=402 gaps=232
tesseract page.png - -l eng+jpn 2>/dev/null | head -n 3
ALAA Y bY BSc LOT O BA 6 tf

情報 シス テム 課 で は 、 本 社 3 階 の サー バー 室 に ある ルー ター と スイ ッ チ を 、2026 年 11 月 14 日 (+)

英数字を含むpage.pngでは、jpnだけの指定が1.25%、jpn+engが1.50%、順序を逆にしたeng+jpnが6.00%でした。engを足しても誤りは減らず、eng+jpnでは題名の行が英字の並びになり、日本語の文字間の空白が232か所に増えています。-lに複数の言語を並べるときは、主に読みたい言語を先に書いてください。このページはjpnだけで足りました。

縦書きはjpn_vertを指定する

terminal
for l in jpn jpn_vert; do for p in 3 5; do tesseract tate.png - -l $l --psm $p 2>/dev/null | python3 cer.py tate.txt - $l,psm=$p; done; done
jpn,psm=3         CER=98.59% dist=140 ref=142 hyp=144 gaps=6
jpn,psm=5         CER=97.89% dist=139 ref=142 hyp=138 gaps=5
jpn_vert,psm=3    CER=2.82% dist=4 ref=142 hyp=143 gaps=110
jpn_vert,psm=5    CER=2.11% dist=3 ref=142 hyp=143 gaps=110
tesseract tate.png - -l jpn 2>/dev/null | head -n 2
蛇約 証G部。 親紅り8叱必生)趣訟@〇りう) 舞和や下つ雪生
8屋” のゆり^ 基層8蛋半1川十臣時的漠RXG選答記” 十] 下日十ロ
tesseract tate.png - -l jpn_vert --psm 5 2>/dev/null | head -n 2
拝 嘱 秋 冷 の 候 、 上 皆様 に は ます ます ご 健勝 の こと と お 喜び 申し 上 げ
ます 。 さ て 、 当 社 は 創業 三 十 周 年 を 迎え る に あたり 、 十 一 月 二 十 日

縦書きの画像は、-l jpnでは--psm 3でも5(縦書きの1ブロックとみなす)でも98.59%・97.89%で、意味のある文字が出ません。-l jpn_vertにすると2.82%・2.11%になりました。配布元であるtessdata_fastのREADMEには、jpnがjpn_vertを副言語として読み込むという説明がありますが、tessdata_fast 4.1.0のjpn(Ubuntuのパッケージと同じファイル)ではそうならず、縦書きには明示的な指定が必要でした。jpn_vertの出力には語の間に空白が入っています。これは空白の節で扱います。

tessdata_bestと比べる

Ubuntuの言語パッケージとは別に、Tesseractの開発元は tessdata_best(精度を優先したモデル)と tessdata_fast(整数化した高速版)をGitHubで配布しています。どちらもApache License 2.0です。タグ4.1.0を指定して取得し、チェックサムを記録します。

terminal
curl -fsSL --create-dirs -o tessdata-best/jpn.traineddata https://github.com/tesseract-ocr/tessdata_best/raw/4.1.0/jpn.traineddata
curl -fsSL -o tessdata-best/jpn_vert.traineddata https://github.com/tesseract-ocr/tessdata_best/raw/4.1.0/jpn_vert.traineddata
curl -fsSL --create-dirs -o tessdata-fast/jpn.traineddata https://github.com/tesseract-ocr/tessdata_fast/raw/4.1.0/jpn.traineddata
sha256sum tessdata-best/jpn.traineddata tessdata-best/jpn_vert.traineddata tessdata-fast/jpn.traineddata /usr/share/tesseract-ocr/5/tessdata/jpn.traineddata
36bdf9ac823f5911e624c30d0553e890b8abc7c31a65b3ef14da943658c40b79  tessdata-best/jpn.traineddata
1258be6eb2a9851f18043234ad18cca13ed32690bfff62b335c898bbea371548  tessdata-best/jpn_vert.traineddata
1f5de9236d2e85f5fdf4b3c500f2d4926f8d9449f28f5394472d9e8d83b91b4d  tessdata-fast/jpn.traineddata
1f5de9236d2e85f5fdf4b3c500f2d4926f8d9449f28f5394472d9e8d83b91b4d  /usr/share/tesseract-ocr/5/tessdata/jpn.traineddata
wc -c tessdata-best/jpn.traineddata tessdata-best/jpn_vert.traineddata /usr/share/tesseract-ocr/5/tessdata/jpn.traineddata /usr/share/tesseract-ocr/5/tessdata/jpn_vert.traineddata
14330109 tessdata-best/jpn.traineddata
14330809 tessdata-best/jpn_vert.traineddata
 2471260 /usr/share/tesseract-ocr/5/tessdata/jpn.traineddata
 3037480 /usr/share/tesseract-ocr/5/tessdata/jpn_vert.traineddata
34169658 total

取得したtessdata_fastのjpn.traineddataと、Ubuntuのパッケージが置いた/usr/share/tesseract-ocr/5/tessdata/jpn.traineddataは、SHA-256が一致しました。aptで入る日本語データは、tessdata_fast 4.1.0と同じファイルです。以降はパッケージ版を「標準」、tessdata_bestを「best」と呼びます。wc -cの値は、bestのjpn.traineddataが14330109、標準が2471260です。

github.comに直接つながらない環境では、接続できる端末で同じURLから取得し、SHA-256が上の値と一致することを確かめてからtessdata-best/に置けば、同じ手順になります(プロキシ経由や持ち込みでの取得は、この検証では実行していません)。取得できない場合は標準のデータだけで先へ進めます。bestを使うのはこの節と次の節、それに「つまずきポイント」の一部だけです。

terminal
for i in 1 2 3; do /usr/bin/time -f '%e秒 %MKB' tesseract page.png t -l jpn; done
7.09秒 91444KB
1.99秒 91448KB
2.49秒 91512KB
for i in 1 2 3; do /usr/bin/time -f '%e秒 %MKB' tesseract --tessdata-dir tessdata-best page.png t -l jpn; done
19.95秒 174572KB
11.40秒 174648KB
12.50秒 174536KB

--tessdata-dirは、言語データを探すディレクトリを切り替えるオプションです。page.png 1枚を標準とbestで繰り返し読ませたところ、かかった時間はどの回もbestのほうが標準より長くなりました。秒数そのものは、ほかの処理も動いている共用サーバーの2コアで測った値で、同じコマンドでも実行のたびに大きく変わります。上の出力は、そのばらつきも含めた記録として見てください。最大メモリ(/usr/bin/timeの%M)は安定していて、標準が91444KB・91448KB・91512KB、bestが174572KB・174648KB・174536KBでした。

terminal
for f in page page-150 page-75; do tesseract --tessdata-dir tessdata-best $f.png - -l jpn 2>/dev/null | python3 cer.py page.txt - $f; done
page              CER=1.00% dist=4 ref=400 hyp=400 gaps=244
page-150          CER=1.25% dist=5 ref=400 hyp=399 gaps=240
page-75           CER=3.50% dist=14 ref=400 hyp=397 gaps=241
for a in 1 2 3 5 10; do tesseract --tessdata-dir tessdata-best page-skew$a.png - -l jpn 2>/dev/null | python3 cer.py page.txt - skew$a; done
skew1             CER=1.25% dist=5 ref=400 hyp=399 gaps=236
skew2             CER=1.75% dist=7 ref=400 hyp=401 gaps=246
skew3             CER=58.25% dist=233 ref=400 hyp=174 gaps=115
skew5             CER=96.00% dist=384 ref=400 hyp=17 gaps=11
skew10            CER=100.00% dist=400 ref=400 hyp=0 gaps=0
for f in page-noise page-shade; do tesseract --tessdata-dir tessdata-best $f.png - -l jpn 2>/dev/null | python3 cer.py page.txt - $f; done
page-noise        CER=11.25% dist=45 ref=400 hyp=399 gaps=235
page-shade        CER=60.00% dist=240 ref=400 hyp=222 gaps=162
for f in two-col table tate line; do tesseract $f.png - -l jpn 2>/dev/null | python3 cer.py $f.txt - $f; done
two-col           CER=0.00% dist=0 ref=184 hyp=184 gaps=2
table             CER=4.55% dist=4 ref=88 hyp=89 gaps=2
tate              CER=98.59% dist=140 ref=142 hyp=144 gaps=6
line              CER=4.00% dist=1 ref=25 hyp=25 gaps=0
for f in two-col table tate line; do tesseract --tessdata-dir tessdata-best $f.png - -l jpn 2>/dev/null | python3 cer.py $f.txt - $f; done
two-col           CER=0.00% dist=0 ref=184 hyp=184 gaps=130
table             CER=3.41% dist=3 ref=88 hyp=89 gaps=20
tate              CER=1.41% dist=2 ref=142 hyp=142 gaps=109
line              CER=4.00% dist=1 ref=25 hyp=25 gaps=14
画像(どちらも-l jpn、ほかの設定なし)標準のCERbestのCER
page.png(300dpi相当)1.25%1.00%
page-150.png1.50%1.25%
page-75.png6.00%3.50%
page-skew1.png(1度)3.00%1.25%
page-skew2.png(2度)6.00%1.75%
page-skew3.png(3度)37.75%58.25%
page-skew5.png(5度)74.75%96.00%
page-skew10.png(10度)100.00%100.00%
page-noise.png29.25%11.25%
page-shade.png60.75%60.00%
two-col.png0.00%0.00%
table.png4.55%3.41%
tate.png98.59%1.41%
line.png4.00%4.00%

bestがはっきり良かったのは、低い解像度(75dpi相当で6.00%→3.50%)、小さな傾き(2度で6.00%→1.75%)、ノイズ(29.25%→11.25%)です。きれいなページは1.25%と1.00%で、差は1文字でした。3度と5度の傾きではbestのほうが悪く、影の画像はどちらも読めていません。3度以上の傾きと影は、モデルを替えても読めず、前処理が必要でした。

縦書きのtate.pngは、bestでは-l jpnのままで1.41%になりました。関係する設定を次の節で確かめます。勧める順番は、標準で始め、低解像度やノイズの多い原稿でCERが足りないときにbestを試す、です。bestは上の実測のとおり時間とメモリを多く使い、出力の空白の入り方も変わります。

日本語の文字間に入る空白を消す

日本語のOCR結果で扱いに困るのが、語と語の間に入る半角スペースです。ここまでの出力を見返すと、標準の-l jpnではほとんど入らず(page.pngでgaps=3)、jpn_vertやbestでは大量に入っていました(gaps=110、gaps=244)。違いは言語データに埋め込まれた設定にあります。combine_tessdataで取り出して確認します。

terminal
combine_tessdata -e /usr/share/tesseract-ocr/5/tessdata/jpn.traineddata /tmp/jpn.config >/dev/null 2>&1; cat /tmp/jpn.config
#Fixes https://github.com/tesseract-ocr/tesseract/issues/988
preserve_interword_spaces 1
combine_tessdata -e /usr/share/tesseract-ocr/5/tessdata/jpn_vert.traineddata /tmp/jpn_vert.config 2>&1 | tail -n 1
Not extracting /tmp/jpn_vert.config, since this component is not present
combine_tessdata -e tessdata-best/jpn.traineddata /tmp/best.config >/dev/null 2>&1; grep -c '' /tmp/best.config; grep -n -E 'sublangs|interword' /tmp/best.config
63
1:tessedit_load_sublangs jpn_vert

標準のjpn.traineddataにはpreserve_interword_spaces 1という設定が入っています。jpn_vert.traineddataには設定のデータ自体がありません。bestのjpn.traineddataの設定は行数が63で、先頭がtessedit_load_sublangs jpn_vert(jpn_vertを一緒に読み込む)、preserve_interword_spacesを含む行はありません。bestが-l jpnのままで縦書きを読めたことは、この先頭の設定と符合します。preserve_interword_spacesの既定値は、前の節の--print-parametersの出力にあるとおり0です。

terminal
for v in 0 1; do tesseract page.png - -l jpn -c preserve_interword_spaces=$v 2>/dev/null | python3 cer.py page.txt - jpn,$v; done
jpn,0             CER=1.25% dist=5 ref=400 hyp=401 gaps=244
jpn,1             CER=1.25% dist=5 ref=400 hyp=401 gaps=3
tesseract page.png - -l jpn -c preserve_interword_spaces=0 2>/dev/null | head -n 3
社内 ネッ トワ ー ク 機 和 器 更新 の お 知ら せ

情報 シス テム 課 で は 、 本 社 3 階 の サー バー 室 に ある ルー ター と スイ ッ チ を 、2026 年 11 月 14 日 ( 土 )
for v in 0 1; do tesseract tate.png - -l jpn_vert --psm 5 -c preserve_interword_spaces=$v 2>/dev/null | python3 cer.py tate.txt - jpn_vert,$v; done
jpn_vert,0        CER=2.11% dist=3 ref=142 hyp=143 gaps=110
jpn_vert,1        CER=2.11% dist=3 ref=142 hyp=143 gaps=2
for v in 0 1; do tesseract --tessdata-dir tessdata-best page.png - -l jpn -c preserve_interword_spaces=$v 2>/dev/null | python3 cer.py page.txt - best,$v; done
best,0            CER=1.00% dist=4 ref=400 hyp=400 gaps=244
best,1            CER=1.00% dist=4 ref=400 hyp=400 gaps=0

値を0にすると、標準のjpnでもgapsが3から244に増えます。逆に、jpn_vertとbestは-c preserve_interword_spaces=1を付けると、110→2、244→0に減りました。CERは3組とも変わっていません。

すでに空白入りで保存してしまったテキストは、後処理で消す手もあります。次のperlは、ASCII以外の文字に挟まれた半角スペースだけを削ります。

terminal
tesseract tate.png - -l jpn_vert --psm 5 2>/dev/null | perl -CSD -pe 's/(?<=[^\x00-\x7f]) +(?=[^\x00-\x7f])//g' | python3 cer.py tate.txt - jpn_vert+perl
jpn_vert+perl     CER=2.11% dist=3 ref=142 hyp=143 gaps=0
tesseract table.png - -l jpn --psm 4 -c preserve_interword_spaces=0 2>/dev/null | grep -v '^$'
品名 数 量 単価 (円 ) 保管 場所
ノー ト PC 12 148,000 第 2 倉庫
液晶 モニ ター 8 32,500 第 2 倉庫
ドッ キン グ ス テ ーション 6 19,800 3 階 書庫
会 議 用 スピ ー カ ー 3 27,000 総務 課
tesseract table.png - -l jpn --psm 4 -c preserve_interword_spaces=0 2>/dev/null | perl -CSD -pe 's/(?<=[^\x00-\x7f]) +(?=[^\x00-\x7f])//g' | grep -v '^$'
品名数量単価 (円 ) 保管場所
ノート PC 12 148,000 第 2 倉庫
液晶モニター 8 32,500 第 2 倉庫
ドッキングステーション 6 19,800 3 階書庫
会議用スピーカー 3 27,000 総務課

縦書きの出力はgaps=0になり、CERは2.11%のままでした。ただし、この後処理は表で副作用を出します。preserve_interword_spaces=0で読んだ表に通すと、見出し行の「品名」と「数量」の間の空白まで消えて「品名数量単価」とつながり、列の区切りがなくなりました。preserve_interword_spaces=1(標準のjpnの既定)なら、前の節の--psm 4の出力のように列の間の空白が残ります。表を含む文書は、後処理ではなく設定で対処してください。

実測結果のまとめ

ここまでの数字を、変えた設定ごとに並べます。どれも今回の自作画像1枚ずつの結果で、文面や書体、紙の状態が変われば数字も変わります。自分の書類で試すときの、当たりをつける順番として使ってください。

変えた設定画像変更前のCER(-l jpnのみ)変更後のCER
拡大(-resize 400%)page-75.png6.00%1.50%
拡大(-resize 200%)page-150.png1.50%0.75%
--dpi 600(誤った値)page-75.png6.00%35.00%
傾き補正(-deskew 40%)page-skew2.png6.00%1.00%
傾き補正(-deskew 40%)page-skew5.png74.75%1.00%
傾き補正(-deskew 40%)page-skew10.png100.00%21.25%
メディアン 3x3page-noise.png29.25%6.00%
メディアン 5x5page-noise.png29.25%28.00%
メディアン 3x3(不要な画像に)page.png1.25%5.75%
局所二値化(-lat 50x50-5%)page-shade.png60.75%1.75%
thresholding_method=2page-shade.png60.75%49.25%
--psm 6two-col.png0.00%77.17%
--psm 4table.png4.55%2.27%
--psm 6table.png4.55%96.59%
--psm 7line-trim.png100.00%4.00%
-l jpn+engpage.png1.25%1.50%
-l jpn_vert --psm 5tate.png98.59%2.11%
tessdata_bestpage-75.png6.00%3.50%
tessdata_bestpage-noise.png29.25%11.25%
tessdata_bestpage-skew3.png37.75%58.25%

OCRmyPDFでスキャンPDFを検索可能にする

OCRmyPDFは、PDFの各ページを画像としてTesseractに渡し、認識した文字を見えない文字の層としてページに重ねるツールです(ライセンスはMozilla Public License 2.0)。検証用に、スキャナーが出すPDFの代役を作ります。page.pngと、2度傾けたpage-skew2.pngをJPEG(品質85)にし、ReportLabでA4の2ページに貼ったscan.pdfと、1ページ目が文字データで2ページ目が画像のmixed.pdfです。

terminal
magick page.png -quality 85 page.jpg; magick page-skew2.png -quality 85 page-skew2.jpg
cat > make_pdf.py <<'EOF'
"""検証用PDFを作る: scan.pdf(JPEG画像だけの2ページ)と mixed.pdf(1ページ目は文字データ、2ページ目はJPEG画像)"""
from reportlab.lib.pagesizes import A4
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas

pdfmetrics.registerFont(TTFont("IPAexMincho", "/usr/share/fonts/opentype/ipaexfont-mincho/ipaexm.ttf"))
W, H = A4

# scan.pdf: スキャナーが出すPDFの代役。1ページ目はまっすぐ、2ページ目は2度傾いている
c = canvas.Canvas("scan.pdf", pagesize=A4, invariant=1)
for jpg in ("page.jpg", "page-skew2.jpg"):
    c.drawImage(jpg, 0, 0, width=W, height=H)
    c.showPage()
c.save()

# mixed.pdf: 1ページ目はフォントを埋め込んだ文字データ(page.txtと同じ文面)、2ページ目はスキャン画像
c = canvas.Canvas("mixed.pdf", pagesize=A4, invariant=1)
c.setFont("IPAexMincho", 10.5)
y = H - 72
for para in open("page.txt", encoding="utf-8").read().splitlines():
    for i in range(0, len(para), 44):
        c.drawString(58, y, para[i:i + 44])
        y -= 17
    y -= 9
c.showPage()
c.drawImage("page.jpg", 0, 0, width=W, height=H)
c.showPage()
c.save()
EOF
terminal
python3 make_pdf.py && wc -c page.jpg page-skew2.jpg scan.pdf mixed.pdf
 306806 page.jpg
 291414 page-skew2.jpg
 657750 scan.pdf
 399055 mixed.pdf
1655025 total
pdfimages -list scan.pdf
page   num  type   width height color comp bpc  enc interp  object ID x-ppi y-ppi size ratio
--------------------------------------------------------------------------------------------
   1     0 image    2480  3508  gray    1   8  jpeg   no         3  0   300   300  329K 3.9%
   2     1 image    2480  3508  gray    1   8  jpeg   no         5  0   300   300  311K 3.7%
pdffonts scan.pdf; pdftotext scan.pdf - | wc -c
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
Helvetica                            Type 1            WinAnsi          no  no  no       2  0
2

pdfimages -listは、2ページとも300dpiのJPEG画像であることを示しています。pdffontsに出るHelveticaは、ReportLabがPDFに書き込む既定のフォント定義です。pdftotextの出力はwc -cで2、つまり改ページ文字2つだけで、文字は入っていません。このPDFをTesseractにそのまま渡すと、エラーになります。

terminal(エラーになる)
tesseract scan.pdf out -l jpn; echo "exit=$?"
Error in pixReadStream: Pdf reading is not supported
Leptonica Error in pixRead: pix not read: scan.pdf
Error during processing.
exit=1

LeptonicaがPDFを読めないためで、終了コードは1です。PDFはOCRmyPDFに渡します。まず既定の設定で実行します。

terminal(出力は空行を除いた抜粋)
ocrmypdf -l jpn scan.pdf scan-pdfa.pdf
Ghostscript 10.6.x contains JPEG encoding errors that may corrupt images. OCRmyPDF will attempt to mitigate, but this version is strongly not recommended. Please upgrade to a newer version. As of 2025-12, 10.6.0 is the latest version of Ghostscript.
Start processing 2 pages concurrently
Postprocessing...
Image optimization ratio: 1.39 savings: 28.0%
Total file size ratio: 1.48 savings: 32.4%
Output file is a PDF/A-2b (as expected)

既定の出力は、長期保存用の規格であるPDF/A(ここではPDF/A-2b)です。この環境のGhostscript 10.06.0に対して、OCRmyPDFは「JPEGのエンコードに誤りがあり、画像を壊すおそれがある」という警告を出しました。出力の画像が実際に壊れているかどうかは、この検証では確かめていません。警告の出ない方法として、PDF/Aに変換しない--output-type pdfで実行し直します。

terminal(出力は空行を除いた抜粋)
ocrmypdf -l jpn --output-type pdf scan.pdf scan-ocr.pdf
Start processing 2 pages concurrently
Postprocessing...
Image optimization ratio: 1.00 savings: 0.0%
Total file size ratio: 0.98 savings: -1.5%
wc -c scan.pdf scan-pdfa.pdf scan-ocr.pdf
 657750 scan.pdf
 444292 scan-pdfa.pdf
 667634 scan-ocr.pdf
1769676 total
for i in 1 2 3; do /usr/bin/time -f '%e秒 %MKB' ocrmypdf -q -l jpn --output-type pdf scan.pdf rep-$i.pdf; done; wc -c rep-1.pdf rep-2.pdf rep-3.pdf
2.90秒 101668KB
2.91秒 101948KB
2.92秒 102296KB
 667637 rep-1.pdf
 667636 rep-2.pdf
 667638 rep-3.pdf
2002911 total
pdfimages -all scan.pdf img-in; pdfimages -all scan-pdfa.pdf img-pdfa; pdfimages -all scan-ocr.pdf img-ocr; sha256sum img-*.jpg; wc -c img-*.jpg
69e2e7308a608bad39cd13ffb8a60ac67a8f4329cebffa32ffe57a2b8ffa86bd  img-in-000.jpg
9334119d50fb08b37296d56b9c3d1e1e10909d26aeb659833b588b0a6f819b2e  img-in-001.jpg
69e2e7308a608bad39cd13ffb8a60ac67a8f4329cebffa32ffe57a2b8ffa86bd  img-ocr-000.jpg
9334119d50fb08b37296d56b9c3d1e1e10909d26aeb659833b588b0a6f819b2e  img-ocr-001.jpg
0c7ad11a94a472ce74e86002752f086304ee051aeae245245692d2cb79b2cf3e  img-pdfa-000.jpg
51bb3c89395828f07e3159d6052a74355f0eeb983e6f4f52b83da9baf1d7236b  img-pdfa-001.jpg
 306806 img-in-000.jpg
 291414 img-in-001.jpg
 306806 img-ocr-000.jpg
 291414 img-ocr-001.jpg
 268454 img-pdfa-000.jpg
 256145 img-pdfa-001.jpg
1721039 total
pdffonts scan-ocr.pdf
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
Helvetica                            Type 1            WinAnsi          no  no  no       7  0
GlyphLessFont                        CID TrueType      Identity-H       yes no  yes      8  0

ファイルサイズ(wc -cの値)は、元のscan.pdfが657750、PDF/A版が444292、--output-type pdf版が667634でした。pdfimages -allで取り出したJPEGを比べると、--output-type pdf版(img-ocr-*)は元(img-in-*)とSHA-256が一致し、PDF/A版(img-pdfa-*)は別のデータで、サイズも小さくなっています。--output-type pdf版は画像のデータを変えておらず、OCRmyPDFの表示でも画像の最適化による削減は0.0%です。pdffontsに加わったGlyphLessFontが、重ねられた見えない文字の層のフォントです。

同じコマンドを繰り返し実行すると(上のループ)、出力のサイズは667637・667636・667638と、実行のたびに数バイト違いました。再現するときも、末尾の桁は一致しないはずです。各回の時間と最大メモリは出力のとおりです(-qはINFOレベルのメッセージを出さないオプションで、時間の行を読みやすくするために付けました)。

使い分けは、画像に手を加えたくないスキャンには--output-type pdf、PDF/Aでの保存が決まりになっている文書には既定の出力、です。後者は、警告の出ない版のGhostscriptを用意できるかを先に確かめてください(別の版のGhostscriptは、この検証では試していません)。

次に、重ねられた文字の層を確かめます。pdftotextでページごとにテキストを取り出し、CERを測ります。

terminal
for p in 1 2; do pdftotext -f $p -l $p scan-ocr.pdf - | python3 cer.py page.txt - p$p; pdftotext -raw -f $p -l $p scan-ocr.pdf - | python3 cer.py page.txt - p$p,-raw; done
p1                CER=3.75% dist=15 ref=400 hyp=399 gaps=167
p1,-raw           CER=1.75% dist=7 ref=400 hyp=399 gaps=156
p2                CER=42.75% dist=171 ref=400 hyp=366 gaps=153
p2,-raw           CER=17.50% dist=70 ref=400 hyp=366 gaps=152
pdftotext -f 1 -l 1 scan-ocr.pdf - | head -n 4; pdftotext -raw -f 1 -l 1 scan-ocr.pdf - | head -n 2
社内 ネッ トワ

せ
更新のお 知ら
社内 ネッ トワ ー ク 機器 更新のお 知ら せ
情報 システム 課では、 本 社 3 階 の サーバー 室 にある ルー ター と スイ ッ チ を 、2026 年11月 14 日 ( 土 )

ここに落とし穴が2つあります。1つはpdftotextの既定の並べ替えで、1ページ目は既定で3.75%、-raw(PDFに書かれている順のまま出力)で1.75%でした。先頭の行を比べると、既定では題名が数行に分かれて順序も入れ替わり、-rawでは1行にまとまっています。文字の層を確かめるときは-rawを付けてください。もう1つは傾きで、2度傾いた2ページ目は-rawでも17.50%でした。

terminal(一致しない例を含む)
pdftotext -raw scan-ocr.pdf - | grep -c 接続できません; pdftotext -raw scan-ocr.pdf - | grep 勤怠
0
ら 接続 している 方 も、同 じ 時 間 帯は 社内 の ファイルサーバー と 勤怠システム に 接続 できま せん 。 急ぎ
pdftotext -raw scan-ocr.pdf - | perl -CSD -pe 's/(?<=[^\x00-\x7f]) +(?=[^\x00-\x7f])//g' | grep -c 接続できません
1

grep -cで「接続できません」を数えると0で、1つも見つかりません。「勤怠」を含む行を表示してみると、文字の層が「接続 できま せん」のように語ごとに空白で区切られて取り出されていて、続けて書いた語とは一致しません。空白の節のperlで空白を消してから数えると1になりました。2ページあるのに1なのは、傾いた2ページ目の読み取りが崩れているからです。

傾きは--deskewで、検索用のテキストは--sidecarで対処できます。--sidecarは、認識したテキストをPDFとは別のファイルにも書き出すオプションです。

terminal(出力は空行を除いた抜粋)
ocrmypdf -l jpn --output-type pdf --deskew --sidecar scan-deskew.txt scan.pdf scan-deskew.pdf
Start processing 2 pages concurrently
Postprocessing...
Image optimization ratio: 1.00 savings: 0.0%
Total file size ratio: 0.76 savings: -31.6%
wc -c scan-ocr.pdf scan-deskew.pdf; pdfimages -list scan-deskew.pdf
 667634 scan-ocr.pdf
 865410 scan-deskew.pdf
1533044 total
page   num  type   width height color comp bpc  enc interp  object ID x-ppi y-ppi size ratio
--------------------------------------------------------------------------------------------
   1     0 image    2480  3508  icc     1   8  image  no        12  0   300   300  402K 4.7%
   2     1 image    2480  3508  icc     1   8  image  no        15  0   300   300  427K 5.0%
for p in 1 2; do pdftotext -raw -f $p -l $p scan-deskew.pdf - | python3 cer.py page.txt - deskew,p$p; done
deskew,p1         CER=0.75% dist=3 ref=400 hyp=399 gaps=156
deskew,p2         CER=1.00% dist=4 ref=400 hyp=400 gaps=150
pdftotext -raw scan-deskew.pdf - | perl -CSD -pe 's/(?<=[^\x00-\x7f]) +(?=[^\x00-\x7f])//g' | grep -c 接続できません; grep -c 接続できません scan-deskew.txt
2
2

--deskewを付けると、2ページ目のCERは17.50%から1.00%になり(1ページ目は0.75%)、perlを通したgrep -cも、サイドカーのテキストへのgrep -cも2になりました。代わりにファイルサイズが667634から865410に増えています。pdfimages -listを見ると、enc列が2ページともjpegからimageに変わり、size列の値も大きくなりました。傾いていなかった1ページ目も含めて、ページの画像がJPEGではない形式で埋め込み直されています。

全文検索の索引に入れるテキストは、PDFからpdftotextで取り出し直すより、--sidecarのテキストを使うほうが手間がかかりません。今回のサイドカーは、空白を消す後処理なしでgrepが通りました。PDFビューアーの検索が語の間の空白をどう扱うかはビューアーによるので、この記事では確認していません。

PDFやWordの文書をMarkdownに変換してRAGに取り込む話は、MarkItDownとDoclingの比較で扱っています。スキャンPDFを別のOCRエンジンで読んだ結果もそちらにあります。

すでに文字があるPDF:--skip-textと--force-ocr

実務のPDFには、Wordなどから書き出したページ(文字データあり)と、スキャンしたページが混ざったものがあります。mixed.pdfはその代役で、1ページ目にはIPAex明朝を埋め込んだ文字データが入っています。

terminal(エラーになる・出力は空行を除いた抜粋)
pdffonts mixed.pdf
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
Helvetica                            Type 1            WinAnsi          no  no  no       2  0
AAAAAA+IPAexMincho                   TrueType          WinAnsi          yes yes yes      9  0
ocrmypdf -l jpn --output-type pdf mixed.pdf mixed-ocr.pdf; echo "exit=$?"
Start processing 2 pages concurrently
PriorOcrFoundError: page already has text! - aborting (use --force-ocr to force OCR;  see also help for the arguments --skip-text and --redo-ocr
exit=6

文字のあるページを見つけると、OCRmyPDFは「page already has text!」で処理を中止します(終了コード6)。メッセージが挙げる選択肢のうち、--skip-textは文字のあるページを処理せずに残し、--force-ocrは全ページを画像に変換してからOCRをかけ直します(--redo-ocrは、この記事では試していません)。2つを実行して比べます。

terminal(出力は空行を除いた抜粋)
ocrmypdf -l jpn --output-type pdf --skip-text --sidecar mixed-skip.txt mixed.pdf mixed-skip.pdf
Start processing 2 pages concurrently
    1 skipping all processing on this page
Postprocessing...
Image optimization ratio: 1.00 savings: 0.0%
Total file size ratio: 0.99 savings: -0.9%
ocrmypdf -l jpn --output-type pdf --force-ocr mixed.pdf mixed-force.pdf
Start processing 2 pages concurrently
    1 page already has text! - rasterizing text and running OCR anyway
    1 GPL Ghostscript 10.06.0 (2025-09-09)
Copyright (C) 2025 Artifex Software, Inc.  All rights reserved.
This software is supplied under the GNU AGPLv3 and comes with NO WARRANTY:
see the file COPYING for details.
…(省略)…
Postprocessing...
Image optimization ratio: 1.00 savings: 0.0%
Total file size ratio: 0.86 savings: -16.3%
wc -c mixed.pdf mixed-skip.pdf mixed-force.pdf
 399055 mixed.pdf
 402240 mixed-skip.pdf
 463688 mixed-force.pdf
1264983 total
for f in mixed mixed-skip mixed-force; do for p in 1 2; do pdftotext -raw -f $p -l $p $f.pdf - | python3 cer.py page.txt - $f,p$p; done; done
mixed,p1          CER=0.00% dist=0 ref=400 hyp=400 gaps=0
mixed,p2          CER=100.00% dist=400 ref=400 hyp=0 gaps=0
mixed-skip,p1     CER=0.00% dist=0 ref=400 hyp=400 gaps=0
mixed-skip,p2     CER=1.75% dist=7 ref=400 hyp=399 gaps=156
mixed-force,p1    CER=1.50% dist=6 ref=400 hyp=401 gaps=169
mixed-force,p2    CER=1.75% dist=7 ref=400 hyp=399 gaps=156
pdffonts mixed-skip.pdf; pdffonts mixed-force.pdf
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
Helvetica                            Type 1            WinAnsi          no  no  no       7  0
AAAAAA+IPAexMincho                   TrueType          WinAnsi          yes yes yes      8  0
GlyphLessFont                        CID TrueType      Identity-H       yes no  yes      9  0
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
GlyphLessFont                        CID TrueType      Identity-H       yes no  yes      7  0
pdfimages -list mixed-force.pdf
page   num  type   width height color comp bpc  enc interp  object ID x-ppi y-ppi size ratio
--------------------------------------------------------------------------------------------
   1     0 image    3307  4677  icc     1   1  image  no        12  0   400   400 60.0K 3.2%
   2     1 image    2480  3508  icc     1   8  image  no        15  0   300   300  378K 4.4%
tr '\f' '\n' < mixed-skip.txt | head -n 3
[OCR skipped on page(s) 1]
社内ネットワーク機器更新のお知らせ

--skip-textでは、1ページ目の文字データがそのまま残りました(CERは0.00%、埋め込みフォントのIPAexMinchoも残っています)。OCRの層が付いたのは2ページ目だけで、サイズは399055から402240への増加にとどまります。サイドカーでは、飛ばしたページが[OCR skipped on page(s) 1]という印に置き換わり、そのページの文字は入りません。

--force-ocrでは、1ページ目も画像になりました。pdffontsからIPAexMinchoが消え、pdfimages -listには1ページ目として3307×4677ピクセル・400dpi・1ビットの画像が現れます。正確だった文字データはOCRの結果に置き換わり、1ページ目のCERは0.00%から1.50%になりました。サイズは463688で、--skip-textより大きくなっています。2ページ目の画像もenc列がimageに変わりました。

文字データとスキャンが混ざったPDFには--skip-textを使ってください。--force-ocrの用途としてOCRmyPDFのドキュメントが挙げているのは、以前にかけたOCRが失敗している場合と、文字の透かしが入った文書です。なお、--force-ocrの出力の途中に出ているのは、ページを画像に変換するために呼び出されたGhostscriptのメッセージです。GhostscriptはAGPLv3で配布されています。

つまずきポイント

Failed loading language:言語データが見つからない

最初のOCRで見たとおり、日本語のパッケージを入れていないと-l jpnは終了コード1で失敗します。--tessdata-dirでディレクトリを切り替えたときにも、同じ種類のメッセージが出ます。tessdata-best/にはjpnとjpn_vertしか置いていないので、+engは読み込めません。

terminal(エラーになる)
tesseract --tessdata-dir tessdata-best page.png out-best -l jpn+eng; echo "exit=$?"
Error opening data file tessdata-best/eng.traineddata
Please make sure the TESSDATA_PREFIX environment variable is set to your "tessdata" directory.
Failed loading language 'eng'
exit=0
python3 cer.py page.txt out-best.txt
CER=1.00% dist=4 ref=400 hyp=400 gaps=244
mkdir best-only && cp tessdata-best/jpn.traineddata best-only/ && tesseract --tessdata-dir best-only page.png out-only -l jpn; echo "exit=$?"
Error opening data file best-only/jpn_vert.traineddata
Please make sure the TESSDATA_PREFIX environment variable is set to your "tessdata" directory.
Failed loading language 'jpn_vert'
exit=0

注意が要るのは、この2つの場合は終了コードが0で、読み込めた言語だけで処理が続くことです(out-best.txtのCERは1.00%で、-l jpnだけを指定したbestの結果と同じでした)。スクリプトで終了コードだけを見ていると、英語のデータ抜きで動いていることに気づけません。指定する言語のファイルは、すべて同じディレクトリに置いてください。bestのjpn.traineddataだけを置いたbest-only/でも、jpn_vertを読み込めないというメッセージが出ています。

Empty page!!:1文字も出力されない

terminal
tesseract line-trim.png out-line -l jpn; echo "exit=$?"; wc -c out-line.txt
Detected 20 diacritics
Empty page!!
Detected 20 diacritics
Empty page!!
exit=0
0 out-line.txt
tesseract page-skew10.png out-skew10 -l jpn; echo "exit=$?"; wc -c out-skew10.txt
Empty page!!
Empty page!!
exit=0
0 out-skew10.txt

今回Empty page!!が出たのは、文字ぎりぎりで切り抜いたline-trim.pngと、10度傾いたpage-skew10.pngの2枚です。どちらも終了コードは0で、出力ファイルは空でした。前者は--psm 7か余白の追加で読めるようになり、後者は-deskewで21.25%まで戻りました。ここまで傾いた原稿は、置き直してスキャンし直すほうが確実です。

Estimating resolutionとDpiError:解像度の情報がない

terminal
magick page.png -strip page-nodpi.png; magick identify -format '%f %U\n' page.png page-nodpi.png
page.png PixelsPerCentimeter
page-nodpi.png Undefined
tesseract page-nodpi.png - -l jpn | python3 cer.py page.txt - nodpi
Estimating resolution as 437
nodpi             CER=1.25% dist=5 ref=400 hyp=401 gaps=3

画像に解像度の情報がないと、TesseractはEstimating resolution as 437のように推定値を表示して処理を続けます。今回は-stripでメタデータを落とした画像で再現しました。CERは1.25%で、解像度の情報があるpage.pngと同じです。OCRmyPDFに同じ画像を直接渡すと、こちらは止まります。

terminal(エラーになる・出力は空行を除いた抜粋)
ocrmypdf -l jpn --output-type pdf page-nodpi.png nodpi.pdf; echo "exit=$?"
Input file is not a PDF, checking if it is an image...
Input file is an image
Image size: (2480, 3508)
DpiError: Input file is an image, but has no resolution (DPI) in its metadata.  Estimate the resolution at which image was scanned and specify it using --image-dpi.
exit=2
ocrmypdf -l jpn --output-type pdf --image-dpi 300 page-nodpi.png nodpi.pdf; echo "exit=$?"
Input file is not a PDF, checking if it is an image...
Input file is an image
Image seems valid. Try converting to PDF...
Successfully converted to PDF, processing...
Postprocessing...
Image optimization ratio: 1.00 savings: 0.0%
Total file size ratio: 0.97 savings: -2.9%
exit=0
pdftotext -raw nodpi.pdf - | python3 cer.py page.txt - nodpi.pdf
nodpi.pdf         CER=1.25% dist=5 ref=400 hyp=401 gaps=154

DpiError(終了コード2)のメッセージのとおり、--image-dpiで取り込み時の解像度を指定すると通り、できたPDFの文字の層は1.25%でした。このほか、Ghostscriptに関する警告と「page already has text!」は、OCRmyPDFの2つの節で扱ったとおりです。

動作確認

最後に、環境が記事と同じ状態かを5つのコマンドで確かめます。

terminal
tesseract --list-langs
List of available languages in "/usr/share/tesseract-ocr/5/tessdata/" (4):
eng
jpn
jpn_vert
osd
tesseract page.png - -l jpn 2>/dev/null | head -n 4
社内ネットワーク機和器更新のお知らせ

情報システム課では、本社3階のサーバー室にあるルーターとスイッチを、2026年11月14日 (土)
こ新しい機種へ入れ替えます。 作業は午前9時から午後5時までを予定しており、その間は有線LANと
tesseract page.png - -l jpn 2>/dev/null | python3 cer.py page.txt - page
page              CER=1.25% dist=5 ref=400 hyp=401 gaps=3
pdftotext -raw scan-deskew.pdf - | perl -CSD -pe 's/(?<=[^\x00-\x7f]) +(?=[^\x00-\x7f])//g' | grep -n 接続できません
6:ら接続している方も、同じ時間帯は社内のファイルサーバーと勤怠システムに接続できません。急ぎ
15:ら接続している方も、同じ時間帯は社内のファイルサーバーと勤怠システムに接続できません。急き
pdfinfo scan-deskew.pdf | grep -E "Pages|Creator"
Creator:         OCRmyPDF 16.13.0+dfsg1 / Tesseract OCR-hOCR 5.5.0
Pages:           2

--list-langsにjpnとjpn_vertが並び、page.pngのCERが1.25%、傾きを補正したPDFから「接続できません」を含む行が2つ見つかり、pdfinfoのCreatorにOCRmyPDFとTesseractの版が出ていれば、同じ結果を再現できています。CERが違うときは、フォントやパッケージの版が違う可能性があります。dpkg-query -Wの結果を冒頭の一覧と比べてください。

Tesseract・深層学習OCR・クラウドOCR APIの使い分け

ここまでの実測と、各プロジェクトの公式リポジトリの記載(執筆時点)を並べます。PaddleOCRとYomiToku、クラウドのOCR APIは、この記事では実行していません。そのため、精度や速度の数字は載せていません。

観点Tesseract+OCRmyPDF深層学習OCR(PaddleOCR・YomiToku)クラウドのOCR API
書類の置き場所手元のコンテナの中で処理。ネットワークを切り離した状態でも同じ結果(下の確認)自分のサーバーで動かす構成なら手元に置ける(この記事では未確認)画像を事業者のサービスに送る
ライセンス(執筆時点)Tesseractとtessdata:Apache License 2.0/OCRmyPDF:MPL-2.0PaddleOCR:Apache License 2.0/YomiToku:CC BY-NC-SA 4.0(商用利用は別途の商用ライセンス)各事業者の利用規約と契約
計算資源今回はCPUだけ(2コア)で実行。最大メモリは標準91444KB、best 174572KB(page.png 1枚、最初の実行の値)READMEの記載:YomiTokuはGPUでの実行を推奨、軽量モデルはCPUでも動作/PaddleOCRはCPUとGPUに対応手元の計算資源は不要
日本語言語データjpn(横書き)とjpn_vert(縦書き)READMEの記載:PaddleOCRは日本語を含む多言語のモデルを提供、YomiTokuは日本語の文書画像に特化事業者ごとに確認が必要
手書き今回は活字だけを測定。手書きは測っていないYomiTokuのREADMEは手書き文字と縦書きへの対応を明記事業者ごとに確認が必要
この記事での実測あり(上の表)なしなし

TesseractとOCRmyPDFのライセンスは、入れたパッケージのcopyrightファイルの先頭のLicense:行でも確かめられます。

terminal
grep -m 1 '^License:' /usr/share/doc/tesseract-ocr/copyright /usr/share/doc/tesseract-ocr-jpn/copyright /usr/share/doc/ocrmypdf/copyright
/usr/share/doc/tesseract-ocr/copyright:License: Apache-2.0
/usr/share/doc/tesseract-ocr-jpn/copyright:License: Apache-2.0
/usr/share/doc/ocrmypdf/copyright:License: MPL-2.0

機密性の面で確かめておきたいのは、処理が手元だけで完結するかどうかです。Dockerホストでコンテナをネットワークから切り離し、同じOCRをもう一度実行しました。

terminal(Dockerホスト)
docker network disconnect bridge tesseract-ocr-japanese-pdf-lab
terminal(出力は空行を除いた抜粋)
ls /sys/class/net; curl -sS -m 5 -o /dev/null https://github.com/; echo "exit=$?"
lo
curl: (6) Could not resolve host: github.com
exit=6
tesseract page.png - -l jpn 2>/dev/null | python3 cer.py page.txt - offline
offline           CER=1.25% dist=5 ref=400 hyp=401 gaps=3
ocrmypdf -l jpn --output-type pdf --deskew scan.pdf offline.pdf; echo "exit=$?"
Start processing 2 pages concurrently
Postprocessing...
Image optimization ratio: 1.00 savings: 0.0%
Total file size ratio: 0.76 savings: -31.6%
exit=0
pdftotext -raw -f 2 -l 2 offline.pdf - | python3 cer.py page.txt - offline,p2
offline,p2        CER=1.00% dist=4 ref=400 hyp=400 gaps=150

ネットワークインターフェースがループバック(lo)だけになり、curlが名前解決に失敗する状態でも、Tesseractの結果は1.25%、OCRmyPDFで傾きを補正した2ページ目は1.00%で、接続していたときと同じでした。外部との通信が必要だったのは、パッケージとモデルファイルの取得だけです。

判断の順番はこう考えています。社外に出せない書類で、活字が中心なら、まずTesseract+OCRmyPDFを試します。解像度と傾きを整えれば、今回の画像では標準のデータでも誤りは400文字中数文字でした。自分の書類を数枚選んで正解テキストを作り、cer.pyで測れば、足りるかどうかを数字で判断できます。

手書き、複雑な帳票、影やゆがみの多い写真が中心なら、深層学習OCRを同じ手順で測って比べます。このときライセンスを先に確認してください。YomiTokuのリポジトリはCC BY-NC-SA 4.0(表示-非営利-継承)で、商用利用には別途の商用ライセンスが用意されています。社内業務での利用がどちらにあたるかは、リポジトリにある判断のガイドラインで確かめてください。処理量が多く、社外への送信が認められる書類なら、クラウドのOCR APIが候補になります。送信先での保存期間や利用目的を契約で確かめる手順は、生成AIの社内利用ガイドラインで整理した考え方がそのまま使えます。

許容できる誤り率は用途で決まります。全文検索が目的なら、数文字の誤りがあっても文書は見つかります。金額や型番を転記する用途では、表の検証で見た「148,.000」のような1文字の誤りが問題になるので、どのエンジンを選んでも人の目による確認を残してください。

よくある質問

「Tesseract」とはどういう意味ですか?

英語のtesseractは、四次元の超立方体を指す数学の用語です。OCRエンジンのTesseractは、READMEの沿革によると1985年から1994年にかけてHewlett-Packardで開発され、2005年にオープンソース化、2006年から2017年8月まではGoogleが開発を担いました。日本語では「テッセラクト」と読まれることが多い名前です。

PythonのTesseractとは?

多くの場合、pytesseractを指します。pytesseractはTesseractを呼び出すPythonのラッパー(Apache License 2.0)で、認識の中身はこの記事と同じエンジンと言語データです。言語はlang引数で、--psmなどのオプションはconfig引数で渡します。pytesseractそのものは、この記事では実行していません。

OCRスキャナーとは何ですか?

読み取った画像に文字認識(OCR)をかけ、検索できるPDFやテキストとして保存する機能を持つスキャナーや複合機、スマートフォンのアプリを指す呼び方です。OCR機能のない機器で作った画像だけのPDFにも、この記事のようにOCRmyPDFを後からかければ文字の層を足せます。

Tesseract OCRの日本語の精度はどのくらいですか?

条件で大きく変わります。今回の自作画像では、300dpi相当のきれいな明朝体のページで文字誤り率1.25%、同じページを2度傾けると6.00%、5度で74.75%でした。活字でも傾き・解像度・影で桁が変わるので、自分の書類を数枚選んで正解テキストを作り、実際に測ることを勧めます。

まとめ

Ubuntu 26.04のTesseract 5.5.0は、tesseract-ocr-jpnとtesseract-ocr-jpn-vertを足せば日本語を読めます。今回の画像で誤り率を大きく動かしたのは、モデルの種類より、傾き(2度で6.00%、5度で74.75%)と影(60.75%)でした。5度までの傾きは-deskew、影は-lat、75dpi相当の解像度は拡大で、いずれも1.75%以下まで戻せました。

--psmは既定のままを基本にし、1行だけの切り抜きには7、縦書きにはjpn_vertを指定します。スキャンだけのPDFはocrmypdf -l jpn --output-type pdf --deskew --sidecarで検索できる形にし、文字データの混ざったPDFには--skip-textを使います(--skip-textと--deskewの併用は試していません)。

次の一歩として、自分の書類を3枚スキャンし、正解テキストを手で打ってcer.pyにかけてみてください。その数字が、標準のデータで足りるか、前処理やbest、別のエンジンが要るかを決める材料になります。

次に読む

$ subscribe --weekly
毎週月曜、企業ITとホームラボの要点を5分で。