本記事では、最近高精度で軽量なOCRモデルが続出しているので好機到来とばかりに、今までOSINTで貯まった日本語以外の各種ドキュメントを自動言語判別して一括翻訳し、Markdown形式で自動保存するGradioアプリにチャレンジした結果、現時点の私見として最良な選択肢が得られたので一例として紹介します。
対象となるファイルは、PDFファイル、各種画像ファイル(jpg、png等)、Microsoft Office系ファイル(xlsx, pptx, docx等)ですが、OSINTで溜まっていくファイルの中には表形式の画像なども含まれるので、これらもMarkdownにより表形式を再現できるようにしました。OSINT以外でのドキュメント処理にも使えるのではと考え、極力汎用的な作りを心掛けました。
結論を先に言うと、ファイルはグループA:PDFと画像ファイル、グループB:MS Office系ファイル、の2つのグループに拡張子で振り分けて、グループAは全て画像としてOCR処理してからMarkdownへ変換し、グループBは直接Markdownに変換する流れです。また、OCRモデルは、”lightonai/LightOnOCR-1B-1025”、”ibm-granite/granite-docling-258M”、がお勧めのモデルとなります。
LightOnOCR-1B-1025の実行環境は、Apple Silicon Macであればmlx版OCRモデルをmlx-vlmで動かすのがローカル環境としてはお勧めですが、それ以外であればvllmが対応しているようです(LightOnOCR-1B-1025はGGUF版もありllama.cppでは動くようですがOllamaではうまく動きませんでした)。またOllama対応も成功したので、最後にOllamaモデルのリンクも載せておきました。
1. 全体の概要
今回利用したPythonライブライは下記の通りです。
・Python=3.12.10
・gradio==5.49.1
・pillow==12.0.0
・pdf2image==1.17.0
・mlx==0.29.4
・mlx-lm==0.28.3
・mlx-vlm==0.3.6
・transformers==5.0.0.dev0 [repoから pip install git+https://github.com/baptiste-aubertin/transformers.git@main pip install git+https://github.com/baptiste-aubertin/transformers.git@83d01ff90112ea3a0d8a6679aa9383e80f31c1db で直接インストール(更新)]
・argostranslate==1.10.0
・googletrans==4.0.2
・markitdown==0.1.3 [pip install "markitdown[all]"]
・lingua-language-detector==2.1.1
全体のフローは下記のようになります。
→ ファイルの拡張子でグループ分け
→ PDFはpdf2imageで画像に変換してから、画像はそのままOCRでテキスト抽出し、結果をMarkdownで出力
→ Office系ファイルはMicrosoftの"MarkItDown"を使い直接Markdownで出力
→ 上記Markdownを日本語に翻訳してMarkdownで出力
使い方はgradioのUI上で処理したいファイルが含まれるフォルダをアップロードするか、複数のファイルを一括でアップロードしてから、"Process Document"ボタンをクリックするだけです。処理が終わるとワーキングディレクトリ直下のtranslated_docsフォルダ内に<元のファイル名>.mdとして保存されます。
2. OCR(構造を含むテキスト抽出)
PDFファイルはテキスト抽出に特化した優れたライブラリー(PyPDF2やpdfplumber等)が複数ありますが、画像形式のPDFからはテキスト抽出できないため、一括処理するには全てのPDFファイルを予め画像ファイルに変換し、他の画像ファイルと同様にOCR処理するのが手っ取り早い方法のようです(Doclingの処理もそのような流れになっている)。
また、今回扱うファイルは形式がかなり多様なため、翻訳するためのテキスト(構造含む)抽出の精度や処理速度に対して大きく影響するのはOCR機能になります。
次が実際試してみた各OCRモデルになります。
| Repo id | 特 徴 及 び 概 要 | ライセンス |
|---|---|---|
| lightonai/LightOnOCR-1B-1025 | サイズも比較的軽量で処理も高速。OCR精度もそこそこ高く実用的。 | Apache2.0 |
| nanonets/Nanonets-OCR2-3B | 構造化テキストの抽出能力は高いが、他のモデルよりサイズが大きくライセンスが不明。特定のドキュメントで繰り返しが発生する。 | --- |
| ibm-granite/granite-docling-258M | IBMのGranite 165M LLMをベースにDocling[*]向けに学習させたモデルで、同サイズの先行モデルSmolDoclingよりOCR精度は改善されている。 | Apache2.0 |
| docling-project/SmolDocling-256M-preview | HuggingFaceのSmolVLM-256M-InstructをベースにDocling[*]向けに学習させたモデル。 | cdla-permissive-2.0 |
[*] Docling: ドキュメント処理を簡素化し、高度な PDF 理解を含むさまざまな形式を解析し、gen AI エコシステムとのシームレスな統合を実現するライブラリー。
上記各モデルはmlx変換されたモデルとGGUF化された量子化モデルがHugginFaceにあるので、Apple Silicon MacのGPUや、CPU機(ollama等を使用して)でも動作可能と思われます。なおmlx版で使用する場合は、検証した時点ではmlx-vlmの制約でtransformersをRepositoryから直接インストールしないとエラーが出ました。
また、max_tokensやtemperatureを適切に設定するのとしないのとでかなり出力精度に影響が出ました。詳細はサンプルコードの説明の所で触れたいと思います。
3. 翻訳
翻訳はOCRで抽出したテキストを使って日本語に翻訳しますが、Plamo-2-translateやgemma-3を使って翻訳すると精度と速度面で限界がありました(GPUリソース等が潤沢に無い等の為)。そこで今回はローカルLLMでの翻訳を諦め、google翻訳とargos翻訳を使いました。前者は1,5000文字までの制限とAPIでネットアクセスが必要となり、後者のargos翻訳は完全にローカル処理出来ますが、韓国語→日本語などは直接翻訳出来ないため英語経由のダブル翻訳が必要になります(韓国語→英語→日本語)。
lingua言語自動判別、google翻訳、argos翻訳のインストールは下記の通りです。
# lingua-language-detector
pip install lingua-language-detector
# googletrans install
pip install googletrans
# argostranslate install
pip install argostranslate
# 言語別にパッケージをインストール(韓国語→英語→日本語の場合)
argospm install translate-ko_en
argospm install translate-en_ja
## 全てのパッケージをインストール
argospm install translate
各翻訳ライブラリーの使い方はシンプルなので(pypi.orgの各ページを参照)ここでは簡単に紹介する程度にします。
#lingua-language-detector
from lingua import Language, LanguageDetectorBuilder
# 言語の設定
languages = [Language.ENGLISH, Language.JAPANESE, ...]
# detectorの作成
detector = LanguageDetectorBuilder.from_languages(*languages).build()
# 言語判定結果の取得
language = detector.detect_language_of(str(text))
# → 結果は Language.ENGLISH のように取得できます。
# googletrans
from googletrans import Translator
# 翻訳先言語の設定
dest_lang="ja"
# 翻訳インスタンスによる翻訳
translator = Translator()
translation = await translator.translate(text, dest=dest_lang)
# → 翻訳結果の文字列は translation.text で取得できます。
# argostranslate
import argostranslate.package
import argostranslate.translate
# 翻訳元・翻訳先の言語設定
_from_code = "en"
_to_code = "ja"
# Argos Translate 言語packageのダウンロード・インストール(事前にインストール済みの場合不要)
argostranslate.package.update_package_index()
available_packages = argostranslate.package.get_available_packages()
package_to_install = next(
filter(
lambda x: x.from_code == _from_code and x.to_code == _to_code, available_packages
)
)
argostranslate.package.install_from_path(package_to_install.download())
# 翻訳結果の取得
trans_result = argostranslate.translate.translate(text, _from_code, _to_code)
# → 翻訳結果の文字列は trans_result で直接取得できます。
4. サンプルコード
最近はAIでCode生成できるので、自分でコードを書くケースが確実に減少していると思われますが、今回の処理のようなケースでは、結構動かないコードが生成される可能性もあるので、参考までにサンプルコードを載せておきます(mlx-vlm等のmlx系とtransformersのVersionは要注意です)。
以下はmlx版の"LightOnOCR-1B-1025"モデルをMac(M1 MacBookAir 8GB)上で動作させるサンプルコードです。予めワーキングディレクトリ直下に自動保存先となる"translated_docs"フォルダを作成しておいて下さい。
まずはライブラリーを読み込みます。
### part1 ###
import gradio as gr
import os, time
import tempfile
from pathlib import Path
from io import BytesIO
from urllib.parse import urlparse
import requests
from PIL import Image
from pdf2image import convert_from_path, convert_from_bytes
import json
from mlx_vlm import load, generate
from mlx_vlm.prompt_utils import apply_chat_template
from mlx_vlm.utils import load_config
from mlx_vlm.generate import stream_generate
import argostranslate.package
import argostranslate.translate
import asyncio
from googletrans import Translator
from markitdown import MarkItDown
from lingua import Language, LanguageDetectorBuilder
次は各種前処理の関数定義です。mlx版OCRモデルはHugginFaceからのダウンロードになってますが、モデルをローカルにダウンロードしてpath指定でも使えます。mlx版以外を使う場合はvllm用にload_model()関数を変更して下さい(現時点Ollamaはどうも未対応のようですね)。
### part2 ###
def load_input_resource(input_path):
"""Load image or PDF or Office file from path and return list of images and markdown."""
images = []
file_path = Path(input_path)
if file_path.suffix.lower() == ".pdf":
# Convert PDF pages to images
pdf_images = convert_from_path(str(file_path))
images.extend(pdf_images)
md = None
elif file_path.suffix.lower() == ".jpg" or file_path.suffix.lower() == ".png" or file_path.suffix.lower() == ".jpeg" or file_path.suffix.lower() == ".webp":
images.append(Image.open(file_path))
md = None
elif file_path.suffix.lower() == ".xls" or file_path.suffix.lower() == ".xlsx" or file_path.suffix.lower() == ".docx" or file_path.suffix.lower() == ".pptx":
images = None
md = MarkItDown().convert(file_path)
return images, md
model_path = "mlx-community/LightOnOCR-1B-1025-4bit"
model, processor = load(model_path)
config = load_config(model_path)
def load_model():
"""Load the LightonOCR model with MLX optimizations"""
import mlx.core as mx
# Force Metal backend (Apple GPU)
mx.set_default_device(mx.gpu)
# Use more memory-efficient precision
model.eval()
mx.eval(model.parameters())
print(f"Running on {mx.default_device().type}") # Verify device
return model, processor, config
async def google_translate(text):
"""Translate text using Google Translate API."""
dest_lang="ja"
translator = Translator()
translation = await translator.translate(text, dest=dest_lang)
return translation.text
def argos_translate(text):
languages = [Language.ENGLISH, Language.CHINESE, Language.JAPANESE, Language.RUSSIAN, Language.KOREAN]
detector = LanguageDetectorBuilder.from_languages(*languages).build()
language = detector.detect_language_of(str(text))
print(" Language: ",language)
if language == Language.ENGLISH:
direction = "英語→日本語"
elif language == Language.CHINESE:
direction = "中国語→日本語"
elif language == Language.RUSSIAN:
direction = "ロシア語→日本語"
elif language == Language.KOREAN:
direction = "韓国語→日本語"
try:
_from_code = ""
_to_code = ""
if direction == "英語→日本語":
_from_code = "en"
_to_code = "ja"
elif direction == "中国語→日本語":
_from_code = "zh"
#_to_code = "ja"
_to_code = "en"
elif direction == "ロシア語→日本語":
_from_code = "ru"
#_to_code = "ja"
_to_code = "en"
elif direction == "韓国語→日本語":
_from_code = "ko"
#_to_code = "ja"
_to_code = "en"
# Download and install Argos Translate package
argostranslate.package.update_package_index()
available_packages = argostranslate.package.get_available_packages()
package_to_install = next(
filter(
lambda x: x.from_code == _from_code and x.to_code == _to_code, available_packages
)
)
argostranslate.package.install_from_path(package_to_install.download())
trans_result = argostranslate.translate.translate(text, _from_code, _to_code)
if direction == "中国語→日本語" or direction == "ロシア語→日本語" or direction == "韓国語→日本語":
_from_code = "en"
_to_code = "ja"
argostranslate.package.update_package_index()
available_packages = argostranslate.package.get_available_packages()
package_to_install = next(
filter(
lambda x: x.from_code == _from_code and x.to_code == _to_code, available_packages
)
)
argostranslate.package.install_from_path(package_to_install.download())
trans_result = argostranslate.translate.translate(trans_result, _from_code, _to_code)
time.sleep(3)
return trans_result
except Exception as e:
print("Exception in argos_translate:", e)
そしてドキュメント処理のメイン関数と保存処理関数です。
stream_generate内に設定するgeneration_argsがOCR精度にかなり影響することが判明したため、自分の利用環境に合わせて微調整されることを推奨します。PDFや画像ファイルの解像度も影響が大きいので、参考の"LitOnOCR利用のベストプラクティス"の手法を使ってみるとOCR精度が改善する場合もあると思います。
### part3 ###
def process_document(file_obj, export_format):
"""Process a document with LightonOCR and return the results."""
for i in range(len(file_obj)):
try:
# Load the model
model, processor, config = load_model()
# Determine the input source
if file_obj[i] is not None:
# Save the uploaded file to a temporary location
temp_dir = tempfile.mkdtemp()
# Get the file name from the upload
file_name = getattr(file_obj[i], 'name', 'uploaded_file')
# Handle different types of file objects that gradio might provide
temp_path = os.path.join(temp_dir, file_name)
# Different handling based on file object type
if hasattr(file_obj[i], 'read'):
# If it's a file-like object with read method
with open(temp_path, "wb") as f:
f.write(file_obj[i].read())
else:
# If it's already a path (in newer Gradio versions)
if isinstance(file_obj[i], str):
temp_path = file_obj[i]
else:
# For Gradio's file component that returns tuple (path, name)
temp_path = file_obj[i] if isinstance(file_obj[i], str) else file_obj[i].name
input_path = temp_path
else:
return "Please provide either a file upload or a URL", None, None
# Get images from input file
images, md = load_input_resource(input_path)
if images is not None and md is None:
# Set up the prompt
prompt = "Extract the text from the image and return them in Markdown format."
formatted_prompt = apply_chat_template(processor, config, prompt, num_images=1)
# Process each image and generate output
all_outputs = []
all_text = ""
all_images = []
processing_log = ""
generation_args = {
"max_tokens": 4096,
"temperature": 0.2,
"repetition_penalty": 1.2,
"top_p": 0.9,
}
for i, image in enumerate(images):
print('\rPage Num = %d' % i, end='')
processing_log += f"Processing page {i+1}/{len(images)}...\n\n"
output = ""
all_images.append(image)
for token in stream_generate(
model, processor, formatted_prompt, [image], **generation_args, verbose=False
):
output += token.text
all_outputs.append(output)
all_text += output + "\n"
processing_log += output + "\n\n"
elif images is None and md is not None:
all_text = str(md)
all_outputs = md
processing_log = ""
# translate
if len(all_text) < 15000:
translated_output = asyncio.run(google_translate(all_text))
else:
translated_output = argos_translate(all_text)
# save translated_output as markdown file
fname = os.path.splitext(os.path.basename(input_path))[0]
res = save_translated_text_to_file(translated_output, fname)
# Export based on selected format
if export_format == "Markdown":
result = all_text
else:
result = "Invalid export format selected"
# Return the first image as a preview and the processing log
yield result, images[0] if images else None, processing_log, translated_output
except Exception as e:
import traceback
error_details = traceback.format_exc()
yield f"Error processing document: {str(e)}\n\nDetails:\n{error_details}", None, error_details
return print("Completed")
def save_translated_text_to_file(translated_text, filename):
try:
with open("./translated_docs/"+filename+".md", "w") as f:
f.write(translated_text)
return f"Text successfully saved to {filename}"
except Exception as e:
return f"Error saving text: {e}"
最後にgradio関連の処理です。
### part4 ###
def render_output(result, export_format):
#print("render_output")
"""Render the processed result based on export format."""
if export_format == "Markdown":
# For markdown, show the rendered markdown component.
return gr.update(value=result, visible=True), gr.update(visible=False), gr.update(visible=False)
elif export_format == "JSON":
# For JSON, parse it into an object so that gr.JSON can render it as an expandable tree.
try:
json_obj = json.loads(result)
except Exception as e:
json_obj = {"error": "Invalid JSON", "detail": str(e)}
return gr.update(visible=False), gr.update(visible=False), gr.update(value=json_obj, visible=True)
else:
# Fallback: hide all rendered views.
return gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
# Create the Gradio interface
with gr.Blocks(title="LightonOCR Document Processing") as app:
# Add custom CSS for border styling in the output sections.
gr.HTML(
"""
<style>
#raw_output_box, #formatted_output_box {
border: 1px solid #ccc;
padding: 10px;
border-radius: 5px;
},#japanese_output_box {
border: 1px solid #ccc;
padding: 10px;
border-radius: 5px;
}
</style>
"""
)
gr.Markdown("""
# 📄 LightonOCR Document Processor
Upload a document image or PDF, or provide a URL, to convert it into a structured format using LightonOCR.
""")
lang=None
with gr.Row():
with gr.Column(scale=1):
file_input = gr.File(label="Upload PDF or Image", file_types=["image", ".pdf", ".xls", ".xlsx" ,".doc", ".docx", ".pptx"], file_count="directory")
export_format = gr.Radio(
choices=["Markdown"],
label="Export Format",
value="Markdown"
)
submit_button = gr.Button("Process Document", variant="primary")
if export_format == "Markdown":
lang = "markdown"
with gr.Column(scale=2):
with gr.Tab("Raw Output"):
with gr.Column(elem_id="raw_output_box"):
# Display the raw output in a code block.
output_text = gr.Code(label="Structured Output", language=lang, lines=20, max_lines=20)
with gr.Tab("Document Preview"):
preview_image = gr.Image(label="Document Preview", type="pil")
with gr.Tab("Log"):
# Display the log in a code block.
log_output = gr.Code(label="Processing Log", language="html", lines=20, max_lines=20)
with gr.Tab("日本語翻訳結果"):
with gr.Column(elem_id="Japanese_output_box"):
translated_output = gr.Markdown(visible=True, label="Markdown Render")
with gr.Tab("Formatted Output"):
with gr.Column(elem_id="formatted_output_box"):
rendered_markdown = gr.Markdown(visible=False, label="Markdown Render")
rendered_html = gr.HTML(visible=False, label="HTML Render")
rendered_json = gr.JSON(visible=False, label="JSON Render")
# Set up event handlers with chained callbacks:
submit_button.click(
process_document,
inputs=[file_input, export_format],
outputs=[output_text, preview_image, log_output, translated_output]
).then(
render_output,
inputs=[output_text, export_format],
outputs=[rendered_markdown, rendered_html, rendered_json]
)
if __name__ == "__main__":
app.launch()
"LightOnOCR-1B-1025"のサンプルコードは以上です。
”ibm-granite/granite-docling-258M”の場合はDoclingの処理が追加されますが、DoclingでOffice系のファイルもmarkdown形式に変換できるためMarkItDownは不要になります。今回は長くなるので別途ご紹介させていただく予定です。PDFと画像ファイルのみで良いから"granite-docling-258M"を使ってみたい方は、参考の"Github:SmolDocling-Document-Processor"のモデルを"SmolDocling-256M-preview"から"granite-docling-258M"に変更して試してみて下さい(mlx-vlm等のversionが異なり、環境構築は注意が必要ですので、venv等で仮想環境を分けて試すことをお勧めします)。
また、質問や間違いの指摘等あれば、お気軽にコメントをお願いします。
(追加) Ollamaのモデル(Q4_K_M)もアップしましたので、是非お試し下さい。
参考
・Github :microsoft/markitdown
・Github :bibekpdl/SmolDocling-Document-Processor
・HuggingFace: lightonai/LightOnOCR-1B-1025
・HuggingFace: ibm-granite/granite-docling-258M
・HuggingFace: nanonets/Nanonets-OCR2-3B
・LightOnOCR-1B-1025 紹介記事(英語): LightonOCR : Fastest OCR AI, beats DeepSeek OCR, PaddleOCR
・granite-docling-258Mの紹介記事(IBM): IBM Granite-Docling:単一の小規模モデルによるエンドツーエンドのドキュメント理解
・LitOnOCR利用のベストプラクティス(Github):LightOnOCR Usage Patterns & Gotchas