0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Python+macOSのVisionで「画面の囲んだ所」をオフラインOCRして、VOICEVOXで読み上げる

0
Posted at

はじめに

「画面に映っている参考書の、ここだけ読んでほしい」

そう思って、画面の一部をマウスで囲むと、その部分をずんだもんが読み上げてくれる機能を、自作のデスクトップアプリ(TextToVoicevox)に入れました。

この記事では、そのうち Mac 側の仕組みを、Python だけで動く最小のコードに切り出して紹介します。

  • 文字の読み取り:macOS 標準の Vision フレームワーク(pyobjc 経由)
  • 読み上げ:ローカルで動く VOICEVOX エンジンの HTTP API

どちらもパソコンの中で完結するので、ネットにつながっていなくても動きます。読ませた文章が外に送られることもありません。

※ 私は独学でプログラミングをしている学生で、ひとりで作っています。もっと良いやり方があれば、ぜひ教えてください。

全体の流れ

画面を撮る(Pillow)
  → 読みたい所を囲ませる(Tkinter)
  → 囲んだ画像を OCR(Vision)
  → 折り返しで割れた文をつなぐ(行の座標を使う)
  → VOICEVOX で音声にする

この記事では、真ん中の OCR → 文をつなぐ → 音声にする の3つを扱います。

動作環境

  • macOS(Vision の日本語 OCR は macOS 13 以降)
  • Python 3.12
  • pip install pyobjc-framework-Vision requests
  • VOICEVOX を起動しておく(エンジンが 127.0.0.1:50021 で待ち受けます)

1. Vision で OCR する

ocr.py
import sys
import Vision
from Foundation import NSURL

def ocr_lines(path):
    """画像を Vision で OCR し、行ごとのテキストと位置(上端基準・0〜1)を返す"""
    url = NSURL.fileURLWithPath_(path)
    handler = Vision.VNImageRequestHandler.alloc().initWithURL_options_(url, None)
    req = Vision.VNRecognizeTextRequest.alloc().init()
    req.setRecognitionLevel_(Vision.VNRequestTextRecognitionLevelAccurate)
    req.setUsesLanguageCorrection_(True)
    if Vision.VNRecognizeTextRequest.supportedRevisions().containsIndex_(3):
        req.setRevision_(3)          # 日本語に対応した版を明示する
    req.setRecognitionLanguages_(["ja-JP", "en-US"])
    ok, err = handler.performRequests_error_([req], None)
    if not ok:
        raise RuntimeError(err)
    lines = []
    for obs in req.results():
        text = str(obs.topCandidates_(1)[0].string())
        b = obs.boundingBox()        # 原点は「左下」
        lines.append({
            "text": text,
            "x0": b.origin.x, "x1": b.origin.x + b.size.width,
            "y0": 1 - (b.origin.y + b.size.height),   # 上端基準に直す
        })
    return lines

if __name__ == "__main__":
  for l in ocr_lines(sys.argv[1]):
    print(f'{l["x0"]:.2f}-{l["x1"]:.2f}  {l["text"]}')

参考書の1段落を切り出した画像で試すと、こうなります。

FE試験の全体像
FEは科目Aと科目Bの2科目で構成されます。両方で基準点を超えると
合格です。つまり、片方だけ得意でも合格できません。

(各行の前には 0.02-0.96 のような左端・右端の位置も出ます)

文字は一字も間違えずに読めています。ポイントは2つです。

setRevision_(3) を明示する。 何も指定しないときにどの版が使われるかは、実行環境によって変わります。古い版に落ちると日本語の精度が下がるので、使えるときは日本語対応の版を指定しています。

boundingBox() の原点は左下。 画像処理でよく使う「左上が原点」とは上下が逆です。あとで行を上から順に並べたいので、ここで上端基準に直しておきます。

2. 折り返しで割れた文をつなぐ

上の結果を見ると、「両方で基準点を超えると」と「合格です。」が別の行に分かれています。Vision は見た目の行で返すので、本の折り返しで1つの文が2行に割れてしまいます。

このまま読み上げると、文の途中で間が空いて、とても聞きにくいです。かといって、全部の行をつなぐと、見出しや箇条書きまでくっついてしまいます。

そこで、行の位置を使って「右端まで届いている行=折り返された行」だけをつなぐことにしました。

reflow.py
import sys
from ocr import ocr_lines

def reflow(lines, tol=0.03):
    """右端まで届いた行(=折り返し)だけを、次の行とつなぐ"""
    lines = sorted(lines, key=lambda l: l["y0"])
    right = max(l["x1"] for l in lines)          # このブロックの右余白
    out = []
    for l in lines:
        if out:
            prev = out[-1]
            wrapped = prev["x1"] >= right - tol               # 右端まで届いている
            same_col = abs(prev["x0"] - l["x0"]) < tol        # 左端がそろっている
            open_end = not prev["text"].endswith(("。", "!", "?", "…"))
            if wrapped and same_col and open_end:
                prev["text"] += l["text"]
                prev["x1"] = l["x1"]                          # 次の判定は今の行の右端で
                continue
        out.append(dict(l))
    return "\n".join(l["text"] for l in out)

print(reflow(ocr_lines(sys.argv[1])))
FE試験の全体像
FEは科目Aと科目Bの2科目で構成されます。両方で基準点を超えると合格です。つまり、片方だけ得意でも合格できません。

見出しは右端が短いのでつながらず、本文だけが1つにまとまりました。

テキストだけを見て「。で終わっていなければつなぐ」というやり方もありますが、見出しや箇条書きは「。」で終わらないことが多いので、誤ってつながってしまいます。位置という手がかりがあると、判断がぐっと確かになります。

(実際のアプリでは、行の間が大きく空いている所は別の段落として扱ったり、2段組を列ごとに読んだりもしています。記事では一番大事な部分だけに絞りました)

3. VOICEVOX で音声にする

VOICEVOX を起動すると、裏でエンジンが HTTP で待ち受けています。audio_query で読み方や抑揚のデータを作り、それを synthesis に渡すと WAV が返ってきます。

speak.py
import requests

def synthesize(text, speaker=3, base="http://127.0.0.1:50021"):
    """VOICEVOX エンジンで1文を WAV にする(speaker=3 はずんだもん・ノーマル)"""
    q = requests.post(f"{base}/audio_query",
                      params={"text": text, "speaker": speaker}, timeout=30)
    q.raise_for_status()
    s = requests.post(f"{base}/synthesis", params={"speaker": speaker},
                      json=q.json(), timeout=60)
    s.raise_for_status()
    return s.content

with open("out.wav", "wb") as f:
    f.write(synthesize("両方で基準点を超えると合格です。"))

audio_query の結果には、VOICEVOX がどう読むつもりかを表すカナも入っています。試しに確かめると、「FE」は「エフイー」、「基準点」も「キジュンテン」と、ちゃんと読めていました。

ハマったところ

画面を撮るには「画面収録」の許可が要る。 Mac では、アプリが画面を撮るのに「システム設定 → プライバシーとセキュリティ → 画面収録」での許可が必要です。初めて使うときに聞かれます。

自分で囲んだ範囲には、ノイズ除去をかけない。 アプリには、画面全体を撮ったときに混ざる時刻やラベルのような短い行を取り除く処理があります。ところが、それを囲んだ範囲にもかけると、正しく読めた短い行まで捨ててしまいました。人が「ここを読んで」と囲んだ所は、そのまま信じることにしています。

Apple 付属の Python では画面が描けなかった。 Xcode のコマンドラインツールに付いてくる Python は Tk が古く(8.5)、Tkinter の画面が真っ白になりました。python.org などの新しい Python を使うと解決しました。

おわりに

文字を目で追うのがつらい日に、耳に切り替えられる道具がほしくて作りました。アプリ本体は無料で、Windows と Mac で動きます(中身はすべて公開しています・MIT)。

音声:VOICEVOX:ずんだもん

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?