オンラインゲーム（Overwatch 2）の試合中のチャットをほぼリアルタイムで翻訳するプログラムの作成

# はじめに
ゲーミングパソコンを購入した結果、Overwatch 2[^1]にハマってしまった。ゲーム内で試合中に飛び交う中国語・韓国語のチャットの内容を理解したかったので、画面に表示されるチャットの読み取り・翻訳を自動で行ってくれるプログラムをPythonで作成した。
# （プログラム作成にかかわる）OW2のチャットの仕様
- 試合中は画面中央左に表示される
- 試合前後は画面左下に表示される
- 新たにチャットが来ると一定時間表示され、その後フェードアウトする
    - この仕様のおかげで同じチャットを何度も読み取ったり、すでに翻訳したかどうか確認したりせずに済む
- エンターキーを押すとチャットログが一定時間表示される
- ラジオチャット[^3]は常に日本語で表示される
- 背景が透過する

# 方針
- 試合中に来るコメントをリアルタイムで翻訳できるようにする
    - とりあえず機能するものを作る（精度は気にしない）
- 既存のライブラリを組み合わせる
# 構成
一定の時間間隔（約10秒程度）で次のループを繰り返す。

```mermaid
graph TD;
    チャット欄のスクリーンショットを取得-->OCRでテキストを抽出;
    OCRでテキストを抽出-->DeepLで翻訳;
    DeepLで翻訳-->結果を出力;
    結果を出力-->チャット欄のスクリーンショットを取得;
```
# 使用したライブラリ・プログラム
- [mss](https://python-mss.readthedocs.io/) ver. 8.0.3
    - スクリーンショット取得用のライブラリ
    - PILよりも高速らしい[^2]
- [DeepL API](https://www.deepl.com/ja/docs-api)
    - 無料版だと制限付き（1か月に50万文字まで）
    - アカウント登録して認証キーを取得する必要がある
- [DeepL クライアントライブラリ（Python）](https://github.com/DeepLcom/deepl-python) ver. 1.14.0
- [Tesseract OCR](https://github.com/tesseract-ocr/tesseract)
    - OCRエンジン
- [pillow](https://pypi.org/project/Pillow/) ver. 9.5.0
# 実装
## チャット欄のスクリーンショットを取得

[この記事](https://qiita.com/ll_Roki/items/9f45a60a14aa535bf0ff)を参考にした。
マルチモニタ環境だとディスプレイやウインドウを指定する必要があるかもしれない。
スクリーンショットを撮影する領域は、ゲームプレイ画面全体のスクリーンショットをWindows標準搭載のMS Paintに貼り付け、チャットの表示領域にカーソルを合わせて座標を読み取り、それらの値を指定した。

```Python:sct.py
import mss
import mss.tools
from PIL import Image

def screenshot():
    with mss.mss() as sct:
        # チャットが表示される領域を選択（この範囲のスクショを取得）
        monitor = {"top": 500, "left": 80, "width": (460-80), "height": (690-500)}
        sct_img = sct.grab(monitor)
        pmap = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
    return pmap
    
```

## OCRでテキストを抽出

[スクリーンショットと同じ記事](https://qiita.com/ll_Roki/items/9f45a60a14aa535bf0ff)を参考にした。
読み取り対象の言語を手軽に複数指定できるのが便利。すごい。OW2のチャットではひらがなとカタカナとアルファベットと漢字とハングル（と記号）以外が使われているのを見たことがないので、日本語・英語・簡体字中国語・韓国語の４つを指定する。

```img2txt```にスクリーンショットを渡せばよい。読み取り結果は行ごとに分割されたリストとして帰ってくる。たいていの場合、チャットは１行に収まる程度の長さであるので、これで問題ないはず。複数行に渡る長文を試合中に送り付けてくる人は恐らく[トロール](https://ja.wikipedia.org/wiki/%E3%83%88%E3%83%AD%E3%83%BC%E3%83%AB#%E3%82%A4%E3%83%B3%E3%82%BF%E3%83%BC%E3%83%8D%E3%83%83%E3%83%88%E3%83%BB%E3%83%88%E3%83%AD%E3%83%AB)だと思われる。翻訳したらきっと嫌な気分になります。

```Python:ocrreader.py
import pyocr
import pyocr.builders

# パスの指定（tesseract.exeの直接パス）
pyocr.tesseract.TESSERACT_CMD = r"C:\\Program Files\\Tesseract-OCR\\tesseract.exe"
# OCRエンジンの取得
tools = pyocr.get_available_tools()
tool = tools[0]


def img2txt(input_img):
    # OCRの実行
    builder = pyocr.builders.TextBuilder()
    result = tool.image_to_string(input_img, lang="eng+jpn+chi_sim+kor", builder=builder).splitlines()
    return result

```

## DeepLで翻訳
APIの字数制限に引っかからないようにしたかった（実際には50万字/月も使うことはほぼなさそう）こと、ラジオチャットは送信者の使用言語によらず日本語で表示されることから、ひらがな・カタカナを含まない文のみ翻訳した。
翻訳元の言語（```source_lang```）は指定しない場合には自動で判定されるため、今回は指定していない。

```Python:translator.py
import deepl
import re

# 自分のAPIキーを入力
API_KEY = 'hoge'

# source_lang は指定しない
# 日本語に翻訳
target_lang = 'JA'
# イニシャライズ
translator = deepl.Translator(API_KEY)


def translate(input_text):
    for index, comment in enumerate(input_text):
        # 日本語は翻訳しない（日本語を含む or 空の文字列はスキップ）
        if len(comment) > 0 and not re.search(r'[ぁ-ん]+|[ァ-ヴー]+', comment):
            input_text[index] = translator.translate_text(comment, target_lang=target_lang).text
    return input_text
```

## 結果を出力

一定時間ごとに動かす方法は[この記事](https://qiita.com/kurogelee/items/0e5fd8b6a1d1f169179a)を参考にした。

```Python:main.py
import sct
import ocrreader
import citation
import translator
import re
import time
import threading


def worker():
    # スクリーンショットの取得, OCRによる読み取り
    ocr_text = ocrreader.img2txt(sct.screenshot())
    # テキストが読み取れれば翻訳
    if len(ocr_text) > 0:
        translated_text = translator.translate(ocr_text.copy())
        for index, comment in enumerate(ocr_text):
            # 空文字列でなければ表示
            if len(comment) > 0:
                print("\n翻訳前", ocr_text[index])
                print("翻訳後", translated_text[index])

def schedule(interval, f, wait=True):
    base_time = time.time()
    next_time = 0
    while True:
        t = threading.Thread(target=f)
        t.start()
        if wait:
            t.join()
        next_time = ((base_time - time.time()) % interval) or interval
        time.sleep(next_time)

# 10秒ごとに動かす
schedule(10, worker, False)


```

元々は[pyttsx3](https://pypi.org/project/pyttsx3/)を使って読み上げさせていたが、ゲームプレイ中にチャットの読み上げ音声を聞き取ることが難しかったため、翻訳したチャットはテキスト出力のみにしてゲーム中にウインドウを素早く切り替えてチラチラ見るようにした。あと、時々```run loop already started in pyttsx```というエラーが出てしまい、解決できなかった（動作に問題はなさそうだったが）。読み上げ速度が入力速度に追い付かないことが原因だと推測しているが未検証。

# 結果
とりあえず動かしてプレーしてみたところ、想定通りに試合中のコメントを翻訳して出力してくれたので満足した。


試合中に読み取った画像を保存していないので手動で撮ったスクリーンショットであるが、実際に動かした例を示す。
まず、元のチャット。
![Qiita_翻訳前.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3358533/0dfc77e8-9d03-9f5f-0685-ea721126ea20.jpeg)
次に、プログラムの出力。
![Qiita_translated.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3358533/2572c8b2-0bf4-2317-3dba-f30c584a6054.jpeg)


絵文字が邪魔だったり謎のスペースが入ったりはしているが、まずまずじゃないかなぁと思う。試合中は背景がぐちゃぐちゃなので、体感だとOCR読み取りの精度はもう少し低い。もし試合中に余裕があれば、空や壁など単色で模様がないような部分をチャット表示領域に合わせるように視点を変えてエンターキーを押せば、再度表示されたチャットをより高い精度で読み取って翻訳してくれる。

本当は、常に最前面に表示される透過ウインドウとして翻訳後のテキストを表示したかったんですけど、技術力が足りなくてできませんでした……誰かやってみてください（他力本願）。
なんか良い方法があれば教えていただけるとありがたいです。

（2023/05/14 追記）
PyQt5を使って透過ウインドウ上に翻訳語テキストを表示させるようにしたが、ゲームの画面を「フルスクリーン」にすると最前面には表示されなくなってしまう……
「枠なしウインドウ」モードにするとプレイ中にも表示されるようになる。[PyQt 最前面]とかで調べるとそれっぽい解説記事が引っかかるのでそれらを参考にしてください（丸投げ）。

# まとめ
試合中のチャットをほぼリアルタイムで翻訳できるようになったことで、チャットの大半は煽りと罵倒であることが分かった。英語でも日本語でも似たようなコメントばかりだったので察してはいたものの「せっかくプログラム作って翻訳できるようにしたのに」と、ちょっとがっかりした。

[^1]:[Overwatch 2](https://overwatch.blizzard.com/ja-jp/)とは、[Bllizard Entertainment](https://www.blizzard.com/ja-jp/)が開発・運営するオンラインアクションFPSゲームである。
[^2]:https://qiita.com/ll_Roki/items/9f45a60a14aa535bf0ff
[^3]:「回復してくれ」「撤退しよう」のような定型文の短文チャット。