環境は横着して llama.cpp の llama-cli をPythonから直接叩く形にしています。ちゃんと書く場合は llama-server か llama-cpp-python か PyTorch を使うと良いでしょう。
今回使うモデルはsurya-2(平仮名)とHunyuanOCR(漢字その他)です。
https://huggingface.co/datalab-to/surya-ocr-2-gguf/tree/main
https://huggingface.co/ggml-org/HunyuanOCR-GGUF/tree/main
もっと日本語に特化したOCRモデルもあります(NDLOCR や Sarashina2.2-OCR など)が、導入が簡単では無いため今回は llama.cpp で動くものを選びました。
スクリプトは認識力を上げるため ImageMagick (2ページを1ページに)と deskew_HT(NDLOCR用資料画像の傾き補正モジュール)にも依存してます。
https://github.com/ndl-lab/deskew_HT
import unicodedata
from difflib import SequenceMatcher
import sys
if len(sys.argv) < 2:
print("python my_ocr.py <img path>")
exit()
img_path = sys.argv[1]
llama_cli = "/home/xxx/data/llama.cpp/build/bin/llama-cli"
# https://github.com/datalab-to/surya/blob/11d18847e8b8c17fd55e254e3e5c44b3fed5c87e/surya/inference/prompts.py#L82
prompt1 = """OCR this image to HTML. Each block is a div with data-label and data-bbox (x0 y0 x1 y1, normalized 0-1000)."""
#prompt2 = "Extract all information from the main body of the document image and represent it in HTML format."
prompt2 = "Extract all."
model1_gguf = "/media/xxx/data/ocr/surya-2.gguf"
mmproj1_gguf = "/media/xxx/data/ocr/surya-2-mmproj.gguf"
model2_gguf = "/media/xxx/data/ocr/HunyuanOCR-bf16.gguf"
mmproj2_gguf = "/media/xxx/data/ocr/mmproj-HunyuanOCR-bf16.gguf"
import subprocess
# ImageMagickで左右にぶった切る
subprocess.run(["convert", img_path, "-crop","2x1@", "+repage", img_path + "_%d.png"])
for n in range(2):
img_path_n = img_path + f"_{n}.png"
subprocess.run(["python3", "../deskew_HT/run_deskew.py", img_path_n,"-o", img_path_n])
# subprocess.run(["python3", "../deskew_HT/run_deskew.py", img_path_n,"-o", img_path_n]) # second deskew
result1 = subprocess.run([llama_cli, "-p", prompt1, "--image", img_path_n, "-m", model1_gguf,
"--mmproj", mmproj1_gguf, "--temp", "0", "--single-turn", "--log-disable", "--no-display-prompt",
"--simple-io", "-c", "8192"],
capture_output=True, text=True)
t = result1.stdout.find("Loaded media")
t = result1.stdout.find(">", t)
t = result1.stdout.find("\n", t)
r1 = result1.stdout[t:]
print(r1)
result2 = subprocess.run([llama_cli, "-p", prompt2, "--image", img_path_n, "-m", model2_gguf,
"--mmproj", mmproj2_gguf, "--temp", "0", "--single-turn", "--log-disable", "--no-display-prompt",
"--simple-io", "-c", "8192"],
capture_output=True, text=True)
t = result2.stdout.find("Loaded media")
t = result2.stdout.find(">", t)
t = result2.stdout.find("\n", t)
r2 = result2.stdout[t:]
r2 = r2.replace("\n", "")
print(r2)
def is_kana(c):
name = unicodedata.name(c, "")
return "HIRAGANA" in name or "KATAKANA" in name
def is_kanji(c):
name = unicodedata.name(c, "")
return "CJK UNIFIED IDEOGRAPH" in name
def merge_ocr(ocr_kanji, ocr_kana):
sm = SequenceMatcher(None, ocr_kanji.replace("ぶ", "ふ"), ocr_kana, autojunk=False)
result = []
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == "equal":
result.extend(ocr_kanji[i1:i2]) # 「ぶ」優先
continue
if tag == "replace":
a = ocr_kanji[i1:i2]
b = ocr_kana[j1:j2]
# かなブロック単位で扱う
# まず「かな側を優先的に採用」
if any(is_kana(c) for c in b):
result.extend(b)
else:
result.extend(a)
continue
if tag == "delete":
result.extend(ocr_kanji[i1:i2])
continue
if tag == "insert":
result.extend(ocr_kana[j1:j2])
continue
return "".join(result)
print(merge_ocr(r2, r1))
テスト
テストに使った画像は国立国会図書館デジタルコレクションにある『新編御伽草子』(1901年)収録の「福富草子」の高解像度JPGです。
https://dl.ndl.go.jp/pid/992342/1/10
OCR結果のHTMLタグと改行は除去してあります。括弧内は掛けてる文字です。「、」の不足は多すぎるため一旦無視しています。
| モデル | 注釈のOCR結果 | 誤ブロック数 |
|---|---|---|
| 正解 | 冒頭の一句は、一篇の綱領なり、 いまだ其藝の何事たるを説破せず、聞の字、笑の字を下す、頗妙なり、 五のたなつものは五穀をいふ、たなつものは種つ物の義なり、日本紀には、水田の種子をしかよめり 一段、福富が福分富有を寫して、後段の地をなす、 築地は、土を高く築きたる垣墻をいふ、練塀の類なり、 | 0 |
| surya-2 | 冒頭の一句は、一篇の綱領なり、一いまだ其藝の何事たるを説破せず、(聞の字、笑の字を)下す、頗妙なり、五のたなつものは五穀ないふ、たなつものは種つ物の義なり、日本紀には、水田の種子をしかよめり一段、福富が福分(富有を寫して、後)段の地をなす、後築地は、土を高く築きたる垣墻をいふ、練塀の類なり、 | 5 |
| HunyuanOCR | 冒頭の一句に一篇の綱領なりいまだ其藝の何事たるか説破せず聞の字笑の字下す頗妙なり五のたなつものは五穀ないふたなつものば種つ物の葉なり日本紀には水田の種于たしがよめり一段福富が福分富有を寫して後段の地(を)なす築地は土を高く築きたる垣徳たいふ練塀の類なり | 9 |
| マージ | 冒頭の一句は、一篇の綱領なり、一いまだ其藝の何事たるを説破せず聞の字笑の字下す、頗妙なり、五のたなつものは五穀ないふ、たなつものは種つ物の葉なり、日本紀には、水田の種子をしかよめり一段、福富が福分富有を寫して後段の地をなす、後築地は、土を高く築きたる垣墻をいふ、練塀の類なり、 | 4 |
下記の平仮名の誤字数は約物の誤字(〱とくの混同)を除きます。
| モデル | 本文のOCR結果 | 平仮名の誤字数 | 漢字の誤字数 |
|---|---|---|---|
| 正解 | 人は身に應せぬ果報をうらやむまじきことになん侍る、むかし福富の織部とて、長者一人侍りけり、いかなる過去の宿緣にや、身に生れつきたる藝ひとつさふらひけるが、ならはざるに奇特をあらはし、はからざるに名を發して、世の人、神の如くにぞ思ひける、其藝あさましくいぶせければ、上中下の人までも、よく聞き知りて笑をもよほすことなりければ、おのづからおほやけ方にもきこしめし、もて興じおはしましけることなゝめならず、されば富めるが上に富み樂しきが上にたのしみて、棟に棟をあらそひ、藏に藏をたてゝ、五のたなつもの、耕さずして、庭にみち〱たり、それか隣にほくせうの藤太とて、いとまづしきもの侍り、こは織部にひきかへて朝夕の烟も竈に絶え、とつのみち草しげりつゝ、築地にあらぬ柴垣や、慢幕ならぬ薦たれに夜寒の床をあかしかねつゝ、軒もかきをも、このためにこぼちとりて、あまり寒さの風を入れける、夏はあさましき麻の衣ふるびて、やぶれ團扇にて | 0 | 0 |
| surya-2 | 人は身に應せぬ果報をうらやむまじきことになん侍る、むかし福富の織部とて、長者一人侍りけり、いかなる過去の宿縁にや、身に生れつきたる藝ひとつさふらひけるが、ならはざるに奇特をあらはし、はからざるに名を發して、世の人、神の如くにぞ思ひける、其藝あさましくいふせければ、上中下の人までも、よく聞き知りて笑をもよほすことなりければ、おのづからおほやけ方にもきこしめし、もて興じおはしましけることな、めならず、されば富めるが上に富み、樂しきが上にたのしみて、棟に棟をあらそひ、藏に藏をたて(ゝ)、五のたなつもの、耕さずして、庭にみちくたり、それか隣にほくせうの藤太とて、いとまづしきもの侍り、こは織部にひきかへて朝夕の烟も竈に絶え、とつのみち草しげりつ(ゝ)、築地にあらぬ柴垣や、慢暮ならぬ鷹たれに夜寒の床をあかしかねつ(ゝ)、軒もかきをも、このためにこぼちとりて、あまり寒さの風を入れける、夏はあさましき麻の衣ふるびて、やふれ團扇にて | 2 | 3 |
| HunyuanOCR | 人は身に應せの果報をうらやむまじきこになん侍るむかし福富の織部どて長者一人侍りけりいかなる過去の宿緣にや身に生れつきたる藝ひどつさふらひけるがならはざるに奇特をあらはしはからざるに名を發して世の人神の如くにぞ思ひける其藝あさましくいぶせければ上中下の人までもよく聞き知りて笑をもよほすこどなりければおのづからおほやけ方にもきこしめしもて興じおはしましけるこどな(ゝ)めならずされば富めるが上に富み楽しきが上にたのしみて棟に棟をあらそひ藏に藏をたて(ゝ)五のたなつもの耕さすして庭にみち(〱)たりそれか隣にほくせうの藤太どていどまづしきもの侍りこは織部にひきかへて朝夕の烟も窓に絶えどつのみち草しげりつ(ゝ)築地にあらぬ柴垣や慢幕ならぬ薦たれに夜寒の床をあかしかねつ(ゝ)軒もかきをもこのためにこぼちどりてあまり寒さの風を入れける夏はあさましき麻の衣ふるびてやぶれ團扇にて | 7 | 2 |
| マージ | 人は身に應せぬ果報をうらやむまじきことになん侍る、むかし福富の織部とて、長者一人侍りけり、いかなる過去の宿緣にや、身に生れつきたる藝ひとつさふらひけるが、ならはざるに奇特をあらはし、はからざるに名を發して、世の人、神の如くにぞ思ひける、其藝あさましくいぶせければ、上中下の人までも、よく聞き知りて笑をもよほすことなりければ、おのづからおほやけ方にもきこしめし、もて興じおはしましけることな、めならず、されば富めるが上に富み楽しきが上にたのしみて、棟に棟をあらそひ、藏に藏をたて(ゝ)、五のたなつもの、耕さずして、庭にみちくたり、それか隣にほくせうの藤太とて、いとまづしきもの侍り、こは織部にひきかへて朝夕の烟も窓に絶え、とつのみち草しげりつ(ゝ)、築地にあらぬ柴垣や、慢幕ならぬ薦たれに夜寒の床をあかしかねつ(ゝ)、軒もかきをも、このためにこぼちとりて、あまり寒さの風を入れける、夏はあさましき麻の衣ふるびて、やぶれ團扇にて | 0 | 2 |
だいぶ良くなりましたが、まだちょっと微妙ですね…。